Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 21cc3458c9fac20f0393872240d1446cd13cb35d
parent 64e311189fd9a34a91de6c31524b8ddc5c5e187e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 13:23:10 -0400

report-to-video: room for posts in the build — the hold and the footage move are joined on the carrying clip's input

A held clip is frozen where the cut is joined: tpad clone on its picture and
apad silence on its sound, never in the segment file, so --chrome-only changes
a hold without rebuilding a clip. The footage move is one perspective filter
on that input (destination corners eased by smoothstep, evaluated per frame,
sub-pixel), the uncovered ground pinned to palette.bg; every other input's
chain is unchanged, and a cut without posts writes the graphs it always did.

Under the deck every length is the schedule's: the xfade offsets, the
chapters, a --chrome-preview window and --chapters-only add the holds to the
probed lengths (cutOffsets). A hard cut with holds or moves concatenates
through the concat filter; its prerail record names each join, so a changed
hold is never served from a stale concat. verify-build checks each held
clip's freeze in the file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/report-to-video/build-video.mjs | 306++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Aumtool/report-to-video/deck-room.test.mjs | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/verify-build.mjs | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
3 files changed, 689 insertions(+), 59 deletions(-)

diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -2105,28 +2105,179 @@ async function buildChartSegment(card, render, outDir, ledger) { return seg; } -// Crossfade every segment into the next. This is a full re-encode of the -// timeline — the concat demuxer can only stream-copy hard cuts — so --no-xfade -// stays available for quick iteration. -async function concatWithXfade(segments, render, outPath, railPlan, chrome = null) { - const D = render.transition ?? 0.5; - const durs = []; - for (const s of segments) durs.push(await probeDuration(s, render.fps)); +// ---- room for posts: the hold and the footage move, where the cut is joined -- +// A clip that carries posts is HELD on its last frame (`segments[i].hold` in +// the deck's schedule) and its footage MOVES aside as its first post appears +// (`schedule.moves`). Both happen on that segment's input chain, before the +// join -- never in the segment file -- so `--chrome-only` changes them +// without rebuilding a clip, and every other input's chain is exactly what it +// was. A cut without posts has no joins (`segmentJoins` returns null) and +// every line below behaves as it always did. - const inputs = segments.flatMap((s) => ["-i", s]); +/** + * Per-input join work from the deck's schedule, aligned with its segments: + * `{ hold, move }` for a carrying clip, null for every other; null overall when + * no segment has either -- the switch that keeps a cut without posts on the + * paths it always took. + * + * @returns {Array<{ hold: number, move: object|null } | null> | null} + */ +export function segmentJoins(schedule) { + if (!schedule?.segments) return null; + const moves = new Map((schedule.moves ?? []).map((m) => [m.segment, m])); + const joins = schedule.segments.map((s) => { + const hold = s.hold > 0 ? s.hold : 0; + const move = moves.get(s.id) ?? null; + return hold || move ? { hold, move } : null; + }); + return joins.some(Boolean) ? joins : null; +} + +/** A number for an ffmpeg expression or option: at most 4 decimals, no trailing zeros. */ +const exprNum = (v) => { + const s = (Math.round(Number(v) * 1e4) / 1e4).toFixed(4).replace(/\.?0+$/, ""); + return s === "-0" ? "0" : s; +}; + +/** + * The footage move as one `perspective` filter (destination sense, evaluated + * per frame): an affine map of the WHOLE frame that takes the `from` box to + * the box eased toward `to`. Each corner of the input frame is placed at + * `base + d·e(t)`, where e is smoothstep over [segmentAt, segmentAt + seconds] + * in the segment's own clock and d is where that corner has gone at e = 1 -- + * scale `to.width / from.width` (and height), then translate. Before the move + * e = 0 and the map is the identity, which perspective copies bit for bit; + * after it e = 1 and the frame holds at `to`. + * + * Perspective resamples at 1/256 px, so the box glides with no whole-pixel + * stepping (scale + overlay and zoompan both round to whole pixels). It has no + * `t`, and its `in` counts frames from 1 -- hence `(in-1)/fps`. What the + * shrink uncovers is filled from the input's edge (perspective clamps), which + * the deck framing made `palette.bg`; `fillborders` pins the outermost pixels + * to it exactly, so a coding artefact at the edge cannot be smeared across + * the uncovered band. + */ +export function moveFilter(move, render) { + const { from: F, to: T } = move; + const kx = T.width / F.width - 1; + const ky = T.height / F.height - 1; + const dx = (u) => T.x - F.x + (u - F.x) * kx; + const dy = (v) => T.y - F.y + (v - F.y) * ky; + const W = render.width ?? 1920; + const H = render.height ?? 1080; + const t = `(in-1)/${render.fps}`; + const a = exprNum(move.segmentAt); + const p = move.seconds > 0 ? `clip((${t}-${a})/${exprNum(move.seconds)},0,1)` : `gte(${t},${a})`; + // smoothstep, 3p² − 2p³: flat at both ends, so the glide starts and lands without a jolt. + const e = (base, d) => `'st(0,${p});${base}+(${exprNum(d)})*ld(0)*ld(0)*(3-2*ld(0))'`; + const corners = [ + ["x0", e("0", dx(0))], ["y0", e("0", dy(0))], + ["x1", e("W", dx(W))], ["y1", e("0", dy(0))], + ["x2", e("0", dx(0))], ["y2", e("H", dy(H))], + ["x3", e("W", dx(W))], ["y3", e("H", dy(H))], + ]; + return [ + `fillborders=left=2:right=2:top=2:bottom=2:mode=fixed:color=${render.palette.bg}`, + `perspective=${corners.map(([k, v]) => `${k}=${v}`).join(":")}:interpolation=linear:sense=destination:eval=frame`, + ].join(","); +} + +/** The hold on a segment's picture: its last frame, cloned for `hold` seconds. */ +export const holdVideoFilter = (hold) => `tpad=stop_mode=clone:stop_duration=${exprNum(hold)}`; +/** The hold on its sound: silence, for the same `hold` seconds. */ +export const holdAudioFilter = (hold) => `apad=pad_dur=${exprNum(hold)}`; + +/** + * Input `i`'s chains before the join. Without a join the labels are the + * input's own (`[i:v]`, `[i:a]`) and there is no chain at all, so a cut + * without posts writes the graph it always did. The move goes before the hold, + * so the held frame is the moved one. + * + * @returns {{ parts: string[], v: string, a: string }} + */ +export function joinInputChain(i, join, render) { + if (!join) return { parts: [], v: `[${i}:v]`, a: `[${i}:a]` }; const parts = []; - let vlab = "[0:v]"; - let alab = "[0:a]"; - let acc = durs[0]; + const vf = [join.move ? moveFilter(join.move, render) : null, join.hold > 0 ? holdVideoFilter(join.hold) : null] + .filter(Boolean); + const v = vf.length ? `[j${i}v]` : `[${i}:v]`; + if (vf.length) parts.push(`[${i}:v]${vf.join(",")}${v}`); + const a = join.hold > 0 ? `[j${i}a]` : `[${i}:a]`; + if (join.hold > 0) parts.push(`[${i}:a]${holdAudioFilter(join.hold)}${a}`); + return { parts, v, a }; +} - for (let i = 1; i < segments.length; i += 1) { +/** + * The segments' lengths IN THE CUT: probed, plus each one's hold -- the sum + * `deckSchedule` makes (it is handed the same probed lengths and adds the same + * holds), so the xfade offsets, the chapters and a preview's window agree with + * the schedule's starts. Without joins this is segmentOffsets, unchanged. + */ +export async function cutOffsets(segments, D, fps, joins = null) { + const { durs } = await segmentOffsets(segments, D, fps); + const full = durs.map((d, i) => d + (joins?.[i]?.hold ?? 0)); + return { ...scheduleFrom(full, D), durs: full }; +} + +/** + * The crossfade concat's filtergraph from the cut's segment lengths (`durs`, + * holds included) and the per-input joins. Without joins, the graph it always was. + */ +export function xfadeGraph(durs, D, joins = null, render = null) { + const parts = []; + const ins = durs.map((_, i) => { + const c = joinInputChain(i, joins?.[i] ?? null, render); + parts.push(...c.parts); + return c; + }); + let vlab = ins[0].v; + let alab = ins[0].a; + let acc = durs[0]; + for (let i = 1; i < durs.length; i += 1) { const off = acc - D; - parts.push(`${vlab}[${i}:v]xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`); - parts.push(`${alab}[${i}:a]acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`); + parts.push(`${vlab}${ins[i].v}xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`); + parts.push(`${alab}${ins[i].a}acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`); vlab = `[v${i}]`; alab = `[a${i}]`; acc = acc + durs[i] - D; } + return { parts, vlab, alab }; +} + +/** + * A hard-cut concat through the concat FILTER, for a cut whose joins need a + * filtergraph (the demuxer's stream copy cannot host one): every input's + * chain, then `concat`, one encode at the parameters every segment shares. + */ +export function hardCutFilterArgs(segments, joins, render, outPath) { + const parts = []; + const pairs = segments.map((_, i) => { + const c = joinInputChain(i, joins?.[i] ?? null, render); + parts.push(...c.parts); + return `${c.v}${c.a}`; + }); + parts.push(`${pairs.join("")}concat=n=${segments.length}:v=1:a=1[vc][ac]`); + return [ + "-nostdin", "-v", "error", "-y", + ...segments.flatMap((s) => ["-i", s]), + "-filter_complex", parts.join(";"), + "-map", "[vc]", "-map", "[ac]", + ...encodeArgs(render), + outPath, + ]; +} + +// Crossfade every segment into the next. This is a full re-encode of the +// timeline — the concat demuxer can only stream-copy hard cuts — so --no-xfade +// stays available for quick iteration. `joins` (the deck's holds and moves, +// `segmentJoins`) go on their inputs before the join; null leaves the graph +// exactly as it was. +async function concatWithXfade(segments, render, outPath, railPlan, chrome = null, joins = null) { + const D = render.transition ?? 0.5; + const { durs } = await cutOffsets(segments, D, render.fps, joins); + + const inputs = segments.flatMap((s) => ["-i", s]); + const { parts, vlab, alab } = xfadeGraph(durs, D, joins, render); // The rail attaches to the LAST xfade node, so it runs after every dissolve // and sees an absolute, continuous `t`. One encode, not two. @@ -2281,27 +2432,28 @@ export function windowSegments(starts, durs, at, dur) { * runs over the whole cut (or a plain concat for hard cuts), trimmed to the * window, the window's deck frames over it. Seconds, not a whole-cut encode. */ -export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, render, chromePlan, outPath }) { +export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, render, chromePlan, outPath, joins = null }) { + // `durs`/`starts` are the CUT's (cutOffsets: holds included), and the + // window's inputs carry their joins as the full concat's do. const { first, last, offset } = windowSegments(starts, durs, at, dur); const segs = segments.slice(first, last + 1); const ds = durs.slice(first, last + 1); - const parts = []; - let vlab = "[0:v]"; - let alab = "[0:a]"; + const js = joins ? joins.slice(first, last + 1) : null; + let parts = []; + let vlab; + let alab; if (segs.length > 1 && D > 0) { - let acc = ds[0]; - for (let i = 1; i < segs.length; i += 1) { - const off = acc - D; - parts.push(`${vlab}[${i}:v]xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`); - parts.push(`${alab}[${i}:a]acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`); - vlab = `[v${i}]`; - alab = `[a${i}]`; - acc = acc + ds[i] - D; + ({ parts, vlab, alab } = xfadeGraph(ds, D, js, render)); + } else { + const ins = segs.map((_, i) => joinInputChain(i, js?.[i] ?? null, render)); + for (const c of ins) parts.push(...c.parts); + vlab = ins[0].v; + alab = ins[0].a; + if (segs.length > 1) { + parts.push(`${ins.map((c) => `${c.v}${c.a}`).join("")}concat=n=${segs.length}:v=1:a=1[vc][ac]`); + vlab = "[vc]"; + alab = "[ac]"; } - } else if (segs.length > 1) { - parts.push(`${segs.map((_, i) => `[${i}:v][${i}:a]`).join("")}concat=n=${segs.length}:v=1:a=1[vc][ac]`); - vlab = "[vc]"; - alab = "[ac]"; } const S = offset.toFixed(3); const T = Number(dur).toFixed(3); @@ -2385,14 +2537,15 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro * schedule says AND no segment is newer than it: a re-trimmed clip that kept * its length would otherwise pass the length check and play the old cut. */ -async function freshConcat(file, segments, total, fps) { +async function freshConcat(file, segments, total, fps, joins = null) { const st = await stat(file).catch(() => null); if (!st) return false; - // Same segments, same order. Length and age alone would take a REORDERED - // timeline's old concat -- every title, QR and chapter then lands on the - // wrong footage while the length check still passes. + // Same segments, same order, and the same holds and moves on them. Length + // and age alone would take a REORDERED timeline's old concat -- every title, + // QR and chapter then lands on the wrong footage while the length check + // still passes -- or one whose holds were joined differently. const recorded = await readFile(`${file}.segments`, "utf8").catch(() => null); - if (!sameConcatList(recorded, segments)) return false; + if (!sameConcatList(recorded, segments, joins)) return false; for (const s of segments) { if ((await stat(s)).mtimeMs > st.mtimeMs) return false; } @@ -2400,9 +2553,12 @@ async function freshConcat(file, segments, total, fps) { return got != null && Math.abs(got - total) <= 1.5 / fps; } -/** Does a recorded concat list name exactly these segments, in this order? */ -export function sameConcatList(recorded, segments) { - return recorded != null && recorded === concatListText(segments); +/** + * Does a recorded concat list name exactly these segments, in this order, + * joined the same way (`joins`, the deck's holds and moves)? + */ +export function sameConcatList(recorded, segments, joins = null) { + return recorded != null && recorded === concatRecordText(segments, joins); } // ---- chapter markers ----------------------------------------------------- @@ -2475,6 +2631,13 @@ export async function chapterTitle(entry, index, provenance, { deck = false } = * @returns the schedule document (deck.mjs's shape) */ export async function writeChromeSchedule({ manifest, entries, segments, D, outDir }) { + const doc = await measureChromeSchedule({ manifest, entries, segments, D }); + await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n"); + return doc; +} + +/** writeChromeSchedule's document, not written: what `--chapters-only` measures the cut by. */ +export async function measureChromeSchedule({ manifest, entries, segments, D }) { const { render, provenance = {} } = manifest; const { durs } = await segmentOffsets(segments, D, render.fps); const metas = []; @@ -2491,14 +2654,14 @@ export async function writeChromeSchedule({ manifest, entries, segments, D, outD } // The variant's posts, placed on the clips this cut plays with their real // upload dates. A manifest without posts writes the schedule it always did. - const doc = deckSchedule({ entries, durs, D, render, provenance, metas, posts: manifest.posts ?? [] }); - await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n"); - return doc; + return deckSchedule({ entries, durs, D, render, provenance, metas, posts: manifest.posts ?? [] }); } -async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false) { +async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false, joins = null) { if (segments.length < 2) return; - const { starts, total } = await segmentOffsets(segments, D, fps); + // The cut's own offsets: under the deck a held clip is longer in the cut + // than its file, and every chapter after it starts that much later. + const { starts, total } = await cutOffsets(segments, D, fps, joins); const lines = [";FFMETADATA1", ""]; for (let i = 0; i < entries.length; i += 1) { // Land just PAST the crossfade, so the marker opens on the incoming clip @@ -2539,7 +2702,31 @@ async function muxChapters(finalPath, entries, segments, D, outDir, provenance, export const concatListText = (segments) => segments.map((s) => `file '${path.resolve(s)}'`).join("\n") + "\n"; -async function concatHardCut(segments, outDir, outPath, { record = false } = {}) { +/** + * What a cached hard-cut concat records it was made from: the list, and -- + * only when there are joins -- one line per joined input with its hold and + * move. Without joins it is the list alone, as it always was. + */ +export function concatRecordText(segments, joins = null) { + const list = concatListText(segments); + if (!joins) return list; + const lines = segments.flatMap((s, i) => (joins[i] + ? [`# join ${i} ${JSON.stringify({ hold: joins[i].hold, move: joins[i].move })}`] + : [])); + return list + lines.join("\n") + "\n"; +} + +/** + * The hard-cut concat. Without joins, the demuxer's stream copy, as always; + * with them (the deck's holds and moves) the concat filter over each input's + * chain, one encode -- the copy cannot host a filtergraph. + */ +async function concatHardCut(segments, outDir, outPath, { record = false, joins = null, render = null } = {}) { + if (joins) { + await execFileP(FFMPEG, hardCutFilterArgs(segments, joins, render, outPath), { maxBuffer: 1 << 26 }); + if (record) await writeFile(`${outPath}.segments`, concatRecordText(segments, joins), "utf8"); + return; + } const listPath = path.join(outDir, "concat.txt"); await writeFile(listPath, concatListText(segments), "utf8"); await execFileP( @@ -2717,7 +2904,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly if (!(await exists(seg))) throw new Error(`--chapters-only needs ${seg}, which is missing — run a full build first`); } - await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render)); + // Under the deck the cut's offsets include the holds: measured, not written. + const joins = deck ? segmentJoins(await measureChromeSchedule({ manifest, entries, segments: segs, D })) : null; + await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render), joins); return { out: finalPath, failures: [] }; } @@ -2772,6 +2961,8 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly } const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); + // The holds and the moves, joined on their inputs (null without posts). + const joins = segmentJoins(schedule); const prerail = prerailPath(outDir, manifest.slug, D); if (opts.chromePreview) { @@ -2781,14 +2972,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly manifestPath, render, outDir, variant, schedule, from: at, duration: dur, }); const out = path.join(outDir, `${manifest.slug}.preview.mp4`); - if (await freshConcat(prerail, segs, schedule.total, render.fps)) { + if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) { EMIT("chrome", { phase: "overlay", base: path.basename(prerail) }); await applyChrome(prerail, out, render, plan, { start: at, dur }); } else { - const { starts, durs } = await segmentOffsets(segs, D, render.fps); + const { starts, durs } = await cutOffsets(segs, D, render.fps, joins); EMIT("chrome", { phase: "overlay", base: "segments" }); await execFileP(FFMPEG, previewFromSegmentsArgs({ - segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out, + segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out, joins, }), { maxBuffer: 1 << 26 }); } EMIT("done", { out, failures: [] }); @@ -2802,19 +2993,19 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // The hard-cut concat is a stream copy of these very segments; when // nothing changed since it was made it is reused, and the overlay is // the only encode. - if (await freshConcat(prerail, segs, schedule.total, render.fps)) { + if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) { EMIT("note", { message: `reusing ${path.basename(prerail)}` }); } else { - await concatHardCut(segs, outDir, prerail, { record: true }); + await concatHardCut(segs, outDir, prerail, { record: true, joins, render }); } await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, dirs.final, render, plan, null); } else { - await concatWithXfade(segs, render, dirs.final, null, plan); + await concatWithXfade(segs, render, dirs.final, null, plan, joins); } await assertConcatLength(dirs.final, schedule.total, render.fps, "deck build"); // The overlay re-encodes, so the chapters on the previous final are gone. - if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render)); + if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render), joins); EMIT("done", { out: dirs.final, failures: [] }); return { out: dirs.final, failures: [] }; } @@ -2895,6 +3086,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly schedule = await writeChromeSchedule({ manifest, entries, segments, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); } + // The deck's holds and moves, joined on their inputs; null without posts + // (and always without the deck), which leaves every concat as it was. + const joins = deck ? segmentJoins(schedule) : null; const railPlan = opts.noRail ? null : await buildRailPlan(manifest, render, entries, segments, D, outDir); @@ -2947,14 +3141,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // Only the deck reaches here (the band refused above, and the deck // refuses a rail). Hard-cut concat to the prerail, then ONE overlay // re-encode to the final. - await concatHardCut(segments, outDir, prerail, { record: true }); + await concatHardCut(segments, outDir, prerail, { record: true, joins, render }); await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, final, render, chromePlan, null); } else { - await concatHardCut(segments, outDir, final); + await concatHardCut(segments, outDir, final, { joins, render }); } } else { - await concatWithXfade(segments, render, final, railPlan, chromePlan); + await concatWithXfade(segments, render, final, railPlan, chromePlan, joins); } // The deck's sequence is laid with shortest=1, so a sequence a frame short // would shorten the cut without a word; the schedule is the length to hold. @@ -2965,7 +3159,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // shortest=1), and a hang means an unbounded -loop 1. if (railPlan) await assertConcatLength(final, railPlan.total, render.fps, "rail build"); - if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render)); + if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render), joins); // A branded cut with a `thumbnail` gets one beside it. The cut is already // done, so a thumbnail that cannot be made is said, not thrown. diff --git a/umtool/report-to-video/deck-room.test.mjs b/umtool/report-to-video/deck-room.test.mjs @@ -0,0 +1,382 @@ +// Tests for "room for posts" in the build (slice R1): the hold and the footage +// move on a carrying clip's input chain, where the cut is joined; the +// hard-cut concat that hosts them; the record a cached concat keeps of them; +// the cut's lengths with holds; and real ffmpeg runs showing a held segment +// freezes for exactly hold·fps frames in silence while every other input's +// frames pass through untouched. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + concatListText, concatRecordText, hardCutFilterArgs, holdAudioFilter, holdVideoFilter, joinInputChain, + moveFilter, previewFromSegmentsArgs, sameConcatList, segmentJoins, windowSegments, xfadeGraph, +} from "./build-video.mjs"; +import { deckGeometry, deckSchedule, scheduleFrom, shiftedFootage } from "./deck.mjs"; + +const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; +const RENDER = { + width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, crf: 21, preset: "slow", + audioRate: 48000, audioChannels: 2, chrome: { engine: "hyperframes", layout: "deck", deck: {} }, +}; +const PROV = { siteOrigin: "https://example.test", channelSlug: "chan" }; +const CLIPS = [ + { id: "c1", type: "clip", video: "v1", start: 0, end: 10, date: "2024-09-05" }, + { id: "c2", type: "clip", video: "v2", start: 0, end: 12, date: "2024-10-01" }, + { id: "c3", type: "clip", video: "v3", start: 0, end: 4, date: "2025-06-01" }, +]; +const POST = (id, date) => ({ + id, platform: "bluesky", date, text: `post ${id}`, url: `https://bsky.app/profile/a/post/${id}`, +}); +const MOVE = (segmentAt, extra = {}) => ({ + segment: "c2", at: 9.5 + segmentAt, segmentAt, seconds: 0.6, + from: deckGeometry(RENDER).footage, to: shiftedFootage(RENDER), ...extra, +}); + +// ---- the join plan ---------------------------------------------------------- + +test("segmentJoins: null without posts; a hold and a move on each carrying clip, aligned with the segments", () => { + const durs = [10, 12, 4]; + const plain = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV }); + assert.equal(segmentJoins(plain), null); + assert.equal(segmentJoins(null), null); + const s = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); + const joins = segmentJoins(s); + assert.equal(joins.length, 3); + assert.equal(joins[0], null); + assert.equal(joins[2], null); + assert.equal(joins[1].hold, 2.5); + assert.deepEqual(joins[1].move, s.moves[0]); + // A hold alone (shift off) and a move alone (hold 0) are each a join. + const noShift = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { shift: false } } } }; + const h = segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: noShift, provenance: PROV, posts: [POST("a", "2024-10-19")] })); + assert.deepEqual(h[1], { hold: 2.5, move: null }); + const noHold = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { hold: 0 } } } }; + const m = segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: noHold, provenance: PROV, posts: [POST("a", "2024-10-19")] })); + assert.equal(m[1].hold, 0); + assert.ok(m[1].move); + // Neither: no joins at all. + const neither = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { hold: 0, shift: false } } } }; + assert.equal(segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: neither, provenance: PROV, posts: [POST("a", "2024-10-19")] })), null); +}); + +test("the cut's lengths: the schedule's starts are the probed lengths plus the holds, as the joins carry them", () => { + const durs = [10, 12, 4]; + const s = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); + const joins = segmentJoins(s); + // cutOffsets' sum, done by hand: probed + hold, then scheduleFrom. + const cut = scheduleFrom(durs.map((d, i) => d + (joins[i]?.hold ?? 0)), 0.5); + assert.deepEqual(cut.starts, s.segments.map((x) => x.start)); + assert.equal(cut.total, s.total); + assert.equal(s.total, 10 + 12 + 4 - 1 + 2.5); +}); + +// ---- the chains, as strings ------------------------------------------------- + +test("joinInputChain: no join, no chain -- the input's own labels", () => { + assert.deepEqual(joinInputChain(3, null, RENDER), { parts: [], v: "[3:v]", a: "[3:a]" }); +}); + +test("joinInputChain: a hold is tpad clone on the picture and apad silence on the sound", () => { + assert.equal(holdVideoFilter(2.5), "tpad=stop_mode=clone:stop_duration=2.5"); + assert.equal(holdAudioFilter(2.5), "apad=pad_dur=2.5"); + assert.deepEqual(joinInputChain(1, { hold: 2.5, move: null }, RENDER), { + parts: ["[1:v]tpad=stop_mode=clone:stop_duration=2.5[j1v]", "[1:a]apad=pad_dur=2.5[j1a]"], + v: "[j1v]", + a: "[j1a]", + }); +}); + +test("joinInputChain: the move goes before the hold, so the held frame is the moved one; a move alone leaves the sound alone", () => { + const both = joinInputChain(1, { hold: 2.5, move: MOVE(6) }, RENDER); + assert.equal(both.parts.length, 2); + assert.equal(both.parts[0], `[1:v]${moveFilter(MOVE(6), RENDER)},tpad=stop_mode=clone:stop_duration=2.5[j1v]`); + assert.equal(both.parts[1], "[1:a]apad=pad_dur=2.5[j1a]"); + const move = joinInputChain(1, { hold: 0, move: MOVE(6) }, RENDER); + assert.deepEqual(move, { parts: [`[1:v]${moveFilter(MOVE(6), RENDER)}[j1v]`], v: "[j1v]", a: "[1:a]" }); +}); + +test("moveFilter: perspective places the frame's corners so the footage box eases from `from` to `to`", () => { + const f = moveFilter(MOVE(6), RENDER); + const [fill, persp] = f.split(/,(?=perspective=)/); + assert.equal(fill, "fillborders=left=2:right=2:top=2:bottom=2:mode=fixed:color=#12101a"); + assert.match(persp, /:interpolation=linear:sense=destination:eval=frame$/); + // 1574×886 at (173,2) → 1354×762 at (24,64): the input frame's corners at e = 1. + const e = "st(0,clip(((in-1)/30-6)/0.6,0,1))"; + const ease = "*ld(0)*ld(0)*(3-2*ld(0))"; + assert.equal( + persp, + "perspective=" + [ + `x0='${e};0+(-124.8196)${ease}'`, `y0='${e};0+(62.2799)${ease}'`, + `x1='${e};W+(-393.1804)${ease}'`, `y1='${e};0+(62.2799)${ease}'`, + `x2='${e};0+(-124.8196)${ease}'`, `y2='${e};H+(-88.8713)${ease}'`, + `x3='${e};W+(-393.1804)${ease}'`, `y3='${e};H+(-88.8713)${ease}'`, + ].join(":") + ":interpolation=linear:sense=destination:eval=frame", + ); + // The corner offsets ARE the box map: the from box's corners land on the to box's. + const F = deckGeometry(RENDER).footage; + const T = shiftedFootage(RENDER); + const X = (u) => -124.8196 + (u * (1920 - 393.1804 - -124.8196)) / 1920; + const Y = (v) => 62.2799 + (v * (1080 - 88.8713 - 62.2799)) / 1080; + near(X(F.x), T.x, "left"); + near(X(F.x + F.width), T.x + T.width, "right"); + near(Y(F.y), T.y, "top"); + near(Y(F.y + F.height), T.y + T.height, "bottom"); + // No easing time: a cut. + assert.match(moveFilter(MOVE(6, { seconds: 0 }), RENDER), /x0='st\(0,gte\(\(in-1\)\/30,6\)\);0\+/); +}); + +function near(a, b, msg) { assert.ok(Math.abs(a - b) < 0.01, `${msg}: ${a} != ${b}`); } + +test("xfadeGraph: without joins, the graph the crossfade concat always wrote", () => { + const g = xfadeGraph([10, 12, 4], 0.5, null, RENDER); + assert.deepEqual(g.parts, [ + "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", + "[0:a][1:a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[v1][2:v]xfade=transition=fade:duration=0.5:offset=21.000[v2]", + "[a1][2:a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", + ]); + assert.equal(g.vlab, "[v2]"); + assert.equal(g.alab, "[a2]"); + // An all-null join list is the same graph. + assert.deepEqual(xfadeGraph([10, 12, 4], 0.5, [null, null, null], RENDER), g); +}); + +test("xfadeGraph: a held input joins through its chain, and the offsets after it move by the hold", () => { + const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; + const g = xfadeGraph([10, 14.5, 4], 0.5, joins, RENDER); + assert.deepEqual(g.parts.slice(2), [ + "[0:v][j1v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", + "[0:a][j1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[v1][2:v]xfade=transition=fade:duration=0.5:offset=23.500[v2]", + "[a1][2:a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", + ]); + assert.deepEqual(g.parts.slice(0, 2), joinInputChain(1, joins[1], RENDER).parts); +}); + +test("hardCutFilterArgs: the concat filter over each input's chain, one encode", () => { + const joins = [null, { hold: 2.5, move: null }, null]; + const args = hardCutFilterArgs(["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], joins, RENDER, "/o/x.prerail-hardcut.mp4"); + assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"]); + assert.equal( + args[args.indexOf("-filter_complex") + 1], + "[1:v]tpad=stop_mode=clone:stop_duration=2.5[j1v];[1:a]apad=pad_dur=2.5[j1a];" + + "[0:v][0:a][j1v][j1a][2:v][2:a]concat=n=3:v=1:a=1[vc][ac]", + ); + assert.deepEqual(args.slice(args.indexOf("-map"), args.indexOf("-map") + 4), ["-map", "[vc]", "-map", "[ac]"]); + assert.equal(args[args.indexOf("-c:v") + 1], "libx264"); + assert.equal(args[args.indexOf("-c:a") + 1], "aac"); + assert.equal(args.at(-1), "/o/x.prerail-hardcut.mp4"); +}); + +test("the hard-cut record: the list alone without joins; the joins with them, so a changed hold is never reused", () => { + const segs = ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"]; + assert.equal(concatRecordText(segs, null), concatListText(segs)); + const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; + const rec = concatRecordText(segs, joins); + assert.ok(rec.startsWith(concatListText(segs))); + assert.match(rec, /^# join 1 \{"hold":2\.5,"move":\{"segment":"c2"/m); + assert.equal(sameConcatList(rec, segs, joins), true); + // The list a deck without posts records is not the record of a joined one, either way round. + assert.equal(sameConcatList(rec, segs), false); + assert.equal(sameConcatList(concatListText(segs), segs, joins), false); + // A changed hold, a changed move, a hold moved to another clip: all stale. + assert.equal(sameConcatList(rec, segs, [null, { hold: 3, move: MOVE(6) }, null]), false); + assert.equal(sameConcatList(rec, segs, [null, { hold: 2.5, move: MOVE(6.5) }, null]), false); + assert.equal(sameConcatList(rec, segs, [{ hold: 2.5, move: MOVE(6) }, null, null]), false); +}); + +// ---- window maths with holds ------------------------------------------------ + +test("a preview window over a held clip: picked by the cut's lengths, its inputs joined as the full concat's", () => { + const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; + const durs = [10, 14.5, 4]; // c2 is 12 s on disk, 14.5 in the cut + const { starts } = scheduleFrom(durs, 0.5); + assert.deepEqual(starts, [0, 9.5, 23.5]); + // 21.5–23.0 is c2's hold: by the files' own lengths it would reach into c3. + assert.deepEqual(windowSegments(starts, durs, 21.5, 1.5), { first: 1, last: 1, offset: 12 }); + const plan = { regions: [], outLabel: "[hfout]" }; + const args = previewFromSegmentsArgs({ + segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs, starts, D: 0.5, at: 20, dur: 6, + render: RENDER, chromePlan: plan, outPath: "/p.mp4", joins, + }); + assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), ["/s/b.mp4", "/s/c.mp4"]); + const fc = args[args.indexOf("-filter_complex") + 1]; + // b is input 0 here, joined as input 1 is in the full concat. + assert.ok(fc.startsWith(joinInputChain(0, joins[1], RENDER).parts.join(";") + ";")); + assert.match(fc, /\[j0v\]\[1:v\]xfade=transition=fade:duration=0\.5:offset=14\.000\[v1\]/); + assert.match(fc, /\[v1\]trim=start=10\.500:duration=6\.000/); + // One held segment alone: its chain, then the trim. + const one = previewFromSegmentsArgs({ + segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs, starts, D: 0.5, at: 21.5, dur: 1.5, + render: RENDER, chromePlan: plan, outPath: "/p.mp4", joins, + }); + assert.match(one[one.indexOf("-filter_complex") + 1], /\[j0v\]trim=start=12\.000:duration=1\.500,setpts=PTS-STARTPTS\[vw\];\[j0a\]atrim=/); + // Without joins, the window's graph is unchanged. + const plain = previewFromSegmentsArgs({ + segments: ["/s/a.mp4", "/s/b.mp4"], durs: [10, 10], starts: [0, 9.5], D: 0.5, at: 8, dur: 4, + render: RENDER, chromePlan: plan, outPath: "/p.mp4", + }); + assert.match(plain[plain.indexOf("-filter_complex") + 1], /^\[0:v\]\[1:v\]xfade=transition=fade:duration=0\.5:offset=9\.500\[v1\];\[0:a\]\[1:a\]acrossfade/); +}); + +// ---- ffmpeg, for real ------------------------------------------------------- + +const have = (b, a) => spawnSync(b, a, { stdio: "ignore" }).status === 0; +const haveFfmpeg = have("ffmpeg", ["-version"]); + +const R = { + width: 320, height: 180, fps: 30, palette: PALETTE, crf: 21, preset: "veryfast", + audioRate: 48000, audioChannels: 2, +}; +const ff = (args, opts = {}) => { + const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args], { maxBuffer: 1 << 28, ...opts }); + assert.equal(r.status, 0, String(r.stderr)); + return r.stdout; +}; +/** framemd5's hashes for one output stream (the picture is stream 0). */ +const md5s = (out, stream = 0) => String(out).split("\n").filter((l) => l && !l.startsWith("#")) + .map((l) => l.split(",")).filter((f) => Number(f[0]) === stream).map((f) => f.at(-1).trim()); + +/** Three 2 s segments as the deck frames them: footage in a box over bg, a tone under it. */ +function segments(dir) { + const make = (name, src, hz) => { + const f = path.join(dir, `${name}.mov`); + ff([ + "-f", "lavfi", "-i", `${src}=s=280x150:r=30:d=2`, + "-f", "lavfi", "-i", `sine=frequency=${hz}:sample_rate=48000:duration=2`, + "-filter_complex", `[0:v]pad=320:180:20:10:color=${PALETTE.bg},format=yuv420p[v];[1:a]aformat=channel_layouts=stereo[a]`, + "-map", "[v]", "-map", "[a]", "-c:v", "ffv1", "-c:a", "pcm_s16le", f, + ]); + return f; + }; + return [make("a", "testsrc2", 440), make("b", "smptebars", 550), make("c", "rgbtestsrc", 660)]; +} + +test("ffmpeg: a held segment's last frame repeats for exactly hold·fps frames, in silence; the other inputs pass through untouched", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-")); + try { + const segs = segments(dir); + const own = segs.map((s) => md5s(ff(["-i", s, "-map", "0:v", "-f", "framemd5", "-"]))); + own.forEach((m) => assert.equal(m.length, 60)); + const joins = [null, { hold: 1, move: null }, null]; + // The hard cut's graph, run to frame hashes instead of an encode. + const args = hardCutFilterArgs(segs, joins, R, "-"); + const fc = args[args.indexOf("-filter_complex") + 1]; + const inputs = segs.flatMap((s) => ["-i", s]); + const out = md5s(ff([...inputs, "-filter_complex", fc, "-map", "[vc]", "-map", "[ac]", "-f", "framemd5", "-"])); + assert.equal(out.length, 60 + 60 + 30 + 60, "30 held frames and not one more"); + // a and c: their own frames, bit for bit. + assert.deepEqual(out.slice(0, 60), own[0]); + assert.deepEqual(out.slice(150), own[2]); + // b, then its last frame 15 more times. + assert.deepEqual(out.slice(60, 120), own[1]); + assert.deepEqual(out.slice(119, 150), Array(31).fill(own[1][59])); + // The sound under the hold is silence; around it, the tones. + const pcm = ff([...inputs, "-filter_complex", `${fc};[vc]nullsink`, "-map", "[ac]", "-f", "s16le", "-ac", "1", "-"], { encoding: "buffer" }); + const at = (s) => pcm.readInt16LE(Math.round(s * 48000) * 2); + const peak = (a, b) => { + let m = 0; + for (let i = Math.round(a * 48000); i < Math.round(b * 48000); i += 1) m = Math.max(m, Math.abs(pcm.readInt16LE(i * 2))); + return m; + }; + assert.equal(pcm.length / 2, 5 * 48000 + 96000, "the sound is held as long as the picture"); + assert.equal(peak(4.0, 5.0), 0, "silence under the hold"); + assert.ok(peak(3.5, 4.0) > 1000, "b's tone before it"); + assert.ok(peak(5.0, 5.5) > 1000, "c's tone after it"); + assert.ok(Number.isFinite(at(0))); + + // The crossfade concat (xfade hands on its own pixel format, so frames + // are compared with the graph the build ran before there were joins): + // a and c come out exactly as they did -- c 30 frames later -- and b's + // last frame is held up to the next dissolve. + const D = 0.5; + const legacy = md5s(ff([...inputs, "-filter_complex", [ + "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=1.500[v1]", + "[0:a][1:a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[v1][2:v]xfade=transition=fade:duration=0.5:offset=3.000[v2]", + "[a1][2:a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", + ].join(";"), "-map", "[v2]", "-map", "[a2]", "-f", "framemd5", "-"])); + assert.equal(legacy.length, 150); + const xg = xfadeGraph([2, 3, 2], D, joins, R); + const xo = md5s(ff([...inputs, "-filter_complex", xg.parts.join(";"), "-map", xg.vlab, "-map", xg.alab, "-f", "framemd5", "-"])); + assert.equal(xo.length, 60 + 90 + 60 - 30); + assert.deepEqual(xo.slice(0, 60), legacy.slice(0, 60), "a and the dissolve into b"); + assert.deepEqual(xo.slice(60, 90), legacy.slice(60, 90), "b, to where the old cut dissolved"); + assert.deepEqual(xo.slice(104, 120), Array(16).fill(xo[104]), "b's last frame, held to the next dissolve"); + assert.deepEqual(xo.slice(135), legacy.slice(105), "c after its dissolve, 30 frames later"); + + // Without joins the graph is exactly the one above. + const plain = xfadeGraph([2, 2, 2], D, null, R); + assert.deepEqual( + md5s(ff([...inputs, "-filter_complex", plain.parts.join(";"), "-map", plain.vlab, "-map", plain.alab, "-f", "framemd5", "-"])), + legacy, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("ffmpeg: the move is the identity before it starts, lands on the target box, and leaves the uncovered ground bg", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-mv-")); + try { + const [, b] = segments(dir); + const move = { segment: "b", at: 0, segmentAt: 0.5, seconds: 0.6, + from: { x: 20, y: 10, width: 280, height: 150 }, to: { x: 8, y: 20, width: 240, height: 128 } }; + const c = joinInputChain(0, { hold: 0, move }, R); + const rgb = (n) => ff(["-i", b, "-filter_complex", `${c.parts.join(";")};${c.v}select=eq(n\\,${n})[o]`, "-map", "[o]", + "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const src = (n) => ff(["-i", b, "-vf", `select=eq(n\\,${n})`, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const px = (buf, x, y) => [...buf.subarray((y * 320 + x) * 3, (y * 320 + x) * 3 + 3)]; + // Before the move, inside the 2 px border, the frame is the input's. + const pre = rgb(10), pin = src(10); + for (const [x, y] of [[20, 10], [160, 90], [299, 159], [100, 40]]) assert.deepEqual(px(pre, x, y), px(pin, x, y), `pre ${x},${y}`); + // After it (0.5 + 0.6 s → frame 33 on), the footage's top-left corner is + // at (8,20) and everything right of and below the target box is ground. + const post = rgb(45); + const bg = [0x12, 0x10, 0x1a]; + const close = (p, q, tol = 3) => p.every((v, i) => Math.abs(v - q[i]) <= tol); + for (const [x, y] of [[300, 10], [310, 170], [4, 4], [160, 160], [260, 100], [100, 15]]) { + assert.ok(close(px(post, x, y), bg), `ground at ${x},${y}: ${px(post, x, y)}`); + } + // The footage's own first column/row (smptebars' left edge) now starts at the target corner. + assert.ok(!close(px(post, 10, 22), bg), "footage at the target box"); + assert.ok(!close(px(post, 245, 145), bg), "footage to the target box's far corner"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("verify-build: a held clip's freeze is found in the file, and a cut that dropped it is refused", { skip: !haveFfmpeg }, async () => { + const { verifyHolds } = await import("./verify-build.mjs"); + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-vb-")); + try { + const render = { ...R, width: 640, height: 360, chrome: { engine: "hyperframes", layout: "deck", deck: { height: 120, posts: { width: 320 } } } }; + const held = path.join(dir, "held.mp4"); + const moving = path.join(dir, "moving.mp4"); + const enc = ["-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "18"]; + ff(["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=2", "-vf", "tpad=stop_mode=clone:stop_duration=1", ...enc, held]); + ff(["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=3", ...enc, moving]); + const schedule = { fps: 30, total: 3, segments: [{ id: "c1", start: 0, duration: 3, end: 3, hold: 1 }] }; + const ok = []; + const got = await verifyHolds(held, schedule, render, ok); + assert.deepEqual(ok, []); + assert.equal(got.length, 1); + assert.ok(got[0].diff <= 1.5, `diff ${got[0].diff}`); + const bad = []; + await verifyHolds(moving, schedule, render, bad); + assert.equal(bad.length, 1); + assert.match(bad[0], /c1 is held 1s but its picture moves during the hold/); + // Nothing held, nothing to check. + assert.deepEqual(await verifyHolds(moving, { ...schedule, segments: [{ id: "c1", start: 0, duration: 3, end: 3 }] }, render, bad), []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs @@ -19,10 +19,11 @@ import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { postsRegions, selectVariant, variantPaths } from "./build-video.mjs"; -import { deckOn, frameCount } from "./deck.mjs"; +import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck } from "./deck.mjs"; const execFileP = promisify(execFile); const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe"; +const FFMPEG = process.env.FFMPEG_BIN ?? "ffmpeg"; export async function verifyBuild(manifestPath, { outDir, variant = "sourced" } = {}) { // The SAME filter the build ran. Verifying the whole manifest against one @@ -96,8 +97,9 @@ export async function verifyBuild(manifestPath, { outDir, variant = "sourced" } /** * The deck's half of the check: schedule.json is there and is a measured deck * schedule, `chrome/deck-frames` holds frameCount(total, fps) frames, and the - * file is as long as the schedule. When the schedule carries posts, each - * window's `chrome/posts-<segment>-frames` holds that window's frame count. + * file is as long as the schedule -- the SCHEDULE's total, holds included. + * When the schedule carries posts, each window's `chrome/posts-<segment>-frames` + * holds that window's frame count, and each held clip's freeze is in the file. */ export async function verifyDeck(variantDir, render, file, problems) { const schedPath = path.join(variantDir, "schedule.json"); @@ -139,12 +141,61 @@ export async function verifyDeck(variantDir, render, file, problems) { problems.push(`${r.frames} holds ${got} frames; the posts window on ${r.segment} is ${r.frameCount}`); } } + const holds = await verifyHolds(file, schedule, render, problems); return { total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length, ...(posts.length ? { posts } : {}), + ...(holds.length ? { holds } : {}), }; } +/** The mean absolute difference allowed between two frames of one freeze (8-bit luma; re-encoding noise). */ +export const FREEZE_TOLERANCE = 1.5; + +/** + * Each held segment's freeze is in the file: the frame at `end − hold/2` is + * the frame at `end − hold + ε`. Compared over the picture outside the deck's + * panel and the posts column -- both still move during a hold (the deck's + * progress fuse burns on) -- on luma, within FREEZE_TOLERANCE of re-encoding + * noise. A cut whose holds were dropped plays on there and differs by far more. + */ +export async function verifyHolds(file, schedule, render, problems) { + const held = (schedule.segments ?? []).filter((s) => s.hold > 0); + if (!held.length) return []; + const fps = Number(schedule.fps ?? render.fps); + const g = deckGeometry(render); + const col = postsGeometry(render); + const left = resolveDeck(render).posts.position === "top-left"; + const crop = left + ? { x: col.x + col.width, y: 0, w: g.W - col.x - col.width, h: g.deck.y } + : { x: 0, y: 0, w: col.x, h: g.deck.y }; + const luma = async (t) => { + const { stdout } = await execFileP(FFMPEG, [ + "-nostdin", "-v", "error", "-ss", t.toFixed(3), "-i", file, "-frames:v", "1", + "-vf", `crop=${crop.w}:${crop.h}:${crop.x}:${crop.y},format=gray`, "-f", "rawvideo", "-", + ], { encoding: "buffer", maxBuffer: 1 << 26 }); + return stdout; + }; + const out = []; + for (const s of held) { + const a = s.end - s.hold + 2 / fps; + const b = s.end - s.hold / 2; + const [x, y] = await Promise.all([luma(a), luma(b)]); + let diff = 0; + if (x.length !== y.length || !x.length) diff = Infinity; + else { + for (let i = 0; i < x.length; i += 1) diff += Math.abs(x[i] - y[i]); + diff /= x.length; + } + out.push({ segment: s.id, hold: s.hold, at: [Number(a.toFixed(3)), Number(b.toFixed(3))], diff: Number(diff.toFixed(3)) }); + if (!(diff <= FREEZE_TOLERANCE)) { + problems.push(`${s.id} is held ${s.hold}s but its picture moves during the hold ` + + `(frames at ${a.toFixed(3)}s and ${b.toFixed(3)}s differ by ${diff.toFixed(2)} on average)`); + } + } + return out; +} + async function main() { const argv = process.argv.slice(2); const manifestPath = argv.find((a) => !a.startsWith("--")); @@ -170,6 +221,9 @@ async function main() { for (const w of res.deck.posts ?? []) { console.log(` posts on ${w.segment}: ${w.frames}/${w.expectedFrames} frame(s) at ${w.at.toFixed(3)}s`); } + for (const h of res.deck.holds ?? []) { + console.log(` hold on ${h.segment}: ${h.hold}s, frozen (${h.at.join("s ≈ ")}s, mean diff ${h.diff})`); + } } for (const p of res.problems) console.log(` ** ${p}`); if (res.ok) console.log(" ok");