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:
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");