commit 965aff572f818f22c7dbb155b1d74fce8ed70f04
parent b50e90429d315c8094392fd4790a1da17d7f1609
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 21:51:46 -0400
deck S3: one command composes, renders and overlays the deck; hard cuts get applyChrome; --chrome-only, --no-chrome, --chrome-preview
A deck build now writes the schedule, renders the composition through
compose-chrome (cached by key) and checks its frame count against the
schedule, then lays it in the concat: in the crossfade's own encode, or for
transition 0 as one overlay pass over the hard-cut prerail (applyChrome,
ported from the diet fork without its rail branch, which nothing here can
reach). The chart band keeps its transition-0 refusal. The final is held to
the schedule's length.
The deck's input carries -reinit_filter 0 and format=rgba: its PNGs are RGB
when opaque and RGBA otherwise, and a mid-stream format change reinitialises
the whole complex filtergraph and ends the output early; format=rgba alone
does not prevent that. The chart band's chain and inputs are unchanged.
--chrome-only re-lays the deck over the segments on disk (hard cuts reuse a
prerail that is newer than every segment and the schedule's length);
--no-chrome keeps the framing without the overlay; --chrome-preview <at> <dur>
renders that window and writes out/<variant>/<slug>.preview.mp4, from the
cached concat or from the segments the window touches. verify-build checks a
deck build's frames and picture length against its schedule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 501 insertions(+), 10 deletions(-)
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -51,6 +51,18 @@
// --preview <s> <d> Rail-only, over a <d>-second window starting at <s>
// --thumbnail A brand preset's thumbnail (manifest.thumbnail) and stop
//
+// The deck (`render.chrome`, see plans/onscreen-deck.md): a full build writes
+// out/<variant>/schedule.json, composes and renders the deck (compose-chrome,
+// cached by key) and overlays it in the concat -- one command.
+// --chrome-only Re-lay the deck over the segments already on disk: re-probe,
+// rewrite the schedule, recompose (re-render only when the key
+// changed), re-concat with the overlay, re-mux the chapters.
+// No segment is rebuilt, nothing is fetched.
+// --no-chrome The deck's framing without the overlay (a fast picture check)
+// --chrome-preview <at> <dur> Render only that window of the deck and write
+// out/<variant>/<slug>.preview.mp4 of it, from the cached
+// concat when there is one, else from the segments it touches
+//
// Requires: yt-dlp, ffmpeg/ffprobe, ImageMagick with Pango.
import { execFile } from "node:child_process";
@@ -67,7 +79,9 @@ import { createCueSource, siteOriginFromManifest } from "./cues.mjs";
// The deck (`render.chrome`): its geometry, validation and schedule are pure
// and live in deck.mjs. This file only frames segments into its box and writes
// the schedule down -- it never has a copy of the arithmetic.
-import { assertChrome, deckGeometry, deckOn, deckSchedule, resolveDeck, scheduleFrom } from "./deck.mjs";
+import {
+ assertChrome, deckGeometry, deckOn, deckSchedule, frameCount, resolveDeck, scheduleFrom,
+} from "./deck.mjs";
// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table,
// in common, plain JS so bare `node` can load it.
import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs";
@@ -283,7 +297,12 @@ const HUMAN = {
chrome: (e) =>
`chrome ${e.phase}` +
(e.segments !== undefined ? `: ${e.segments} segment(s)` : "") +
- (e.total !== undefined ? `, ${Number(e.total).toFixed(3)}s` : ""),
+ (e.total !== undefined ? `, ${Number(e.total).toFixed(3)}s` : "") +
+ (e.duration !== undefined ? ` window ${e.from}s +${e.duration}s` : "") +
+ (e.frames !== undefined ? `: ${e.frames} frame(s)` : "") +
+ (e.seconds !== undefined ? ` in ${e.seconds}s` : "") +
+ (e.key ? ` (key ${String(e.key).slice(0, 12)})` : "") +
+ (e.base ? ` over ${e.base}` : ""),
note: (e) => e.message,
done: (e) =>
// A run that produced no file still emits `done` -- a consumer of the
@@ -1694,7 +1713,20 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
const parts = [];
let lab = inLabel;
regions.forEach((r, i) => {
+ // The deck's sequence is MIXED: HyperFrames writes a frame with nothing
+ // transparent in it as RGB and every other frame as RGBA, so the decoded
+ // stream changes pixel format part-way through. By default ffmpeg answers
+ // that by REINITIALISING THE WHOLE filtergraph -- every xfade with it --
+ // which drops what was buffered and ends the output early (measured: a
+ // 3.5 s xfade+overlay came out 2.0 s). `-reinit_filter 0` keeps the graph
+ // and converts the odd frames instead, and `format=rgba` straight after
+ // the input pins the overlay's secondary to one format with alpha
+ // whichever kind of frame comes first. The `format` filter alone does not
+ // stop the reinit. The deck only: the chart band's chain and inputs stay
+ // byte-for-byte as they shipped.
+ const deck = r.name === "deck";
inputs.push(
+ ...(deck ? ["-reinit_filter", "0"] : []),
"-framerate", String(render.fps),
"-start_number", "1",
"-i", path.join(r.frames, "frame_%06d.png"),
@@ -1702,7 +1734,12 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
const idx = firstInputIdx + i;
const last = i === regions.length - 1;
const out = last && !final ? outLabel : `[hf${i}]`;
- parts.push(`${lab}[${idx}:v]overlay=x=${r.x}:y=${r.y}:format=yuv444:shortest=1${out}`);
+ let src = `[${idx}:v]`;
+ if (deck) {
+ parts.push(`${src}format=rgba[hfa${i}]`);
+ src = `[hfa${i}]`;
+ }
+ parts.push(`${lab}${src}overlay=x=${r.x}:y=${r.y}:format=yuv444:shortest=1${out}`);
lab = out;
});
if (final) parts.push(`${lab}format=yuv420p[vout]`);
@@ -2131,6 +2168,157 @@ async function applyRail(inPath, outPath, render, railPlan, preview) {
);
}
+// ---- the deck's overlay ----------------------------------------------------
+// The deck (`render.chrome`) is ONE rendered composition over the whole cut.
+// With a transition it rides the crossfade's own encode (concatWithXfade's
+// `chrome` argument); with hard cuts the concat is `-c copy`, which cannot
+// host a filtergraph, so it is a second pass over the concatenated file --
+// applyChrome, below. Either way the overlay chain is chromeOverlayChain's.
+
+/**
+ * applyChrome's ffmpeg argv: the rendered regions over an already
+ * concatenated file, one video re-encode, the audio copied.
+ *
+ * `preview` (`{ start, dur }`) cuts a window out of the input. The regions it
+ * is given must then be a WINDOW render (`compose-chrome --from`), whose frame
+ * 1 is cut time `start` -- the base is seeked to the same second and starts at
+ * 0, so the two line up with no timestamp shifting. (The rail's preview has to
+ * shift because its expressions read absolute `t`; a frame sequence has no `t`.)
+ *
+ * The rail is not ported here: the deck refuses `render.rail`, and the chart
+ * band keeps its refusal of `transition: 0`, so nothing that reaches this
+ * function draws one.
+ */
+export function applyChromeArgs(inPath, outPath, render, chromePlan, preview = null) {
+ const hf = chromeOverlayChain(render, chromePlan.regions, "[0:v]", 1, { final: true });
+ return [
+ "-nostdin", "-v", "error", "-y",
+ ...(preview ? ["-ss", String(preview.start), "-t", String(preview.dur)] : []),
+ "-i", inPath,
+ ...hf.inputs,
+ "-filter_complex", hf.chain,
+ "-map", hf.outLabel, "-map", "0:a",
+ ...encodeArgsVideoOnly(render),
+ outPath,
+ ];
+}
+
+/**
+ * The chrome over a concatenated file: the hard-cut build's overlay pass, and
+ * `--chrome-preview` over a cached concat. Ported from the diet fork's
+ * applyChrome, whose point stands: the CONCAT cannot host a filtergraph, the
+ * pass after it always could.
+ */
+async function applyChrome(inPath, outPath, render, chromePlan, preview = null) {
+ await execFileP(FFMPEG, applyChromeArgs(inPath, outPath, render, chromePlan, preview), {
+ maxBuffer: 1 << 26,
+ });
+}
+
+/**
+ * Which segments a window of the cut touches, and where the window starts in
+ * their own local timeline. `starts`/`durs` are segmentOffsets'; a segment
+ * occupies [starts[i], starts[i] + durs[i]] (crossfades overlap neighbours).
+ */
+export function windowSegments(starts, durs, at, dur) {
+ const idx = [];
+ for (let i = 0; i < starts.length; i += 1) {
+ if (starts[i] < at + dur && starts[i] + durs[i] > at) idx.push(i);
+ }
+ if (!idx.length) throw new Error(`no segment covers ${at}s–${at + dur}s`);
+ return { first: idx[0], last: idx[idx.length - 1], offset: at - starts[idx[0]] };
+}
+
+/**
+ * `--chrome-preview` with no cached concat: the window, built straight from
+ * the few segments it touches -- the SAME xfade arithmetic concatWithXfade
+ * 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 }) {
+ 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]";
+ 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;
+ }
+ } 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);
+ parts.push(`${vlab}trim=start=${S}:duration=${T},setpts=PTS-STARTPTS[vw]`);
+ parts.push(`${alab}atrim=start=${S}:duration=${T},asetpts=PTS-STARTPTS[aw]`);
+ const hf = chromeOverlayChain(render, chromePlan.regions, "[vw]", segs.length, { final: true });
+ parts.push(hf.chain);
+ return [
+ "-nostdin", "-v", "error", "-y",
+ ...segs.flatMap((sg) => ["-i", sg]),
+ ...hf.inputs,
+ "-filter_complex", parts.join(";"),
+ "-map", hf.outLabel, "-map", "[aw]",
+ ...encodeArgs(render),
+ outPath,
+ ];
+}
+
+/**
+ * Compose and render the deck (cached by compose-chrome's key), and check the
+ * sequence is as long as the cut -- or the window -- it will be laid over.
+ * Dynamic import: compose-chrome imports this file.
+ */
+async function renderDeck({ manifestPath, render, outDir, variant, schedule, from = 0, duration = null }) {
+ const { composeChrome } = await import("./compose-chrome.mjs");
+ EMIT("chrome", { phase: "compose", ...(duration != null ? { from, duration } : {}) });
+ const t0 = Date.now();
+ const r = await composeChrome({
+ manifestPath, outDir, variant, region: "deck", doRender: true,
+ fps: render.fps, workers: 4, quality: "high", format: "png-sequence",
+ ...(duration != null ? { from, duration } : {}),
+ });
+ const want = frameCount(duration ?? schedule.total, render.fps);
+ if (r.frameCount !== want) {
+ throw new Error(
+ `the deck's sequence is ${r.frameCount} frames but the ${duration != null ? "window" : "cut"} ` +
+ `is ${want} (${(duration ?? schedule.total).toFixed(3)}s at ${render.fps} fps)`,
+ );
+ }
+ EMIT("chrome", {
+ phase: r.cached ? "cached" : "render",
+ frames: r.frameCount, key: r.key, dir: r.frames,
+ seconds: Number(((Date.now() - t0) / 1000).toFixed(1)),
+ });
+ const regions = chromeRegions(render, outDir).map((g) => ({ ...g, frames: r.frames }));
+ return { regions, outLabel: "[hfout]" };
+}
+
+/**
+ * A cached concat is a base for the overlay only when it is as long as the
+ * 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) {
+ const st = await stat(file).catch(() => null);
+ if (!st) return false;
+ for (const s of segments) {
+ if ((await stat(s)).mtimeMs > st.mtimeMs) return false;
+ }
+ const got = await probeDuration(file, fps).catch(() => null);
+ return got != null && Math.abs(got - total) <= 1.5 / fps;
+}
+
// ---- chapter markers -----------------------------------------------------
// A compilation like this is a reference document as much as a video: the report
// cites moments, and a viewer wants to jump to them. Every clip therefore becomes
@@ -2459,6 +2647,69 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
return { out, failures: [] };
}
+ // The deck again, over the segments already on disk: `--chrome-only` re-lays
+ // it on the whole cut, `--chrome-preview <at> <dur>` on a window of it. No
+ // segment is rebuilt and nothing is fetched; the schedule is re-measured from
+ // the segments, so a re-render can never use durations a rebuild changed.
+ if (opts.chromeOnly || opts.chromePreview) {
+ const what = opts.chromeOnly ? "--chrome-only" : "--chrome-preview";
+ if (!deck) throw new Error(`${what} needs the deck: render.chrome is not set in the manifest`);
+ if (opts.noChrome) throw new Error(`${what} and --no-chrome contradict each other`);
+ if (only) throw new Error(`${what} lays the deck over the whole cut; --only does not apply`);
+ const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`));
+ for (const seg of segs) {
+ if (!(await exists(seg)))
+ throw new Error(`${what} needs ${seg}, which is missing — run a full build first`);
+ }
+ const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir });
+ EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length });
+ const prerail = prerailPath(outDir, manifest.slug, D);
+
+ if (opts.chromePreview) {
+ const at = Math.max(0, Math.min(opts.chromePreview.at, schedule.total - 1 / render.fps));
+ const dur = Math.min(opts.chromePreview.dur, schedule.total - at);
+ const plan = await renderDeck({
+ 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)) {
+ 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);
+ EMIT("chrome", { phase: "overlay", base: "segments" });
+ await execFileP(FFMPEG, previewFromSegmentsArgs({
+ segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out,
+ }), { maxBuffer: 1 << 26 });
+ }
+ EMIT("done", { out, failures: [] });
+ return { out, failures: [] };
+ }
+
+ const plan = await renderDeck({ manifestPath, render, outDir, variant, schedule });
+ EMIT("chrome", { phase: "overlay" });
+ EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length });
+ if (D === 0) {
+ // 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)) {
+ EMIT("note", { message: `reusing ${path.basename(prerail)}` });
+ } else {
+ await concatHardCut(segs, outDir, prerail);
+ }
+ 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 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);
+ EMIT("done", { out: dirs.final, failures: [] });
+ return { out: dirs.final, failures: [] };
+ }
+
EMIT("start", { title: manifest.title, entries: entries.length, out: outDir });
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
@@ -2528,10 +2779,11 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const final = dirs.final;
// The deck's schedule, from the segments just built. Written before the
- // concat so a composition can be made from it; the overlay itself is a later
- // step, and a build without one is the framed footage alone.
+ // concat because the composition is made from it; the rendered deck is then
+ // laid in the concat itself (or, for hard cuts, in one pass after it).
+ let schedule = null;
if (deck) {
- const schedule = await writeChromeSchedule({ manifest, entries, segments, D, outDir });
+ schedule = await writeChromeSchedule({ manifest, entries, segments, D, outDir });
EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length });
}
@@ -2554,13 +2806,22 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
chromePlan = { regions, outLabel: "[hfout]" };
EMIT("note", { message: `chrome: ${regions.map((r) => `${r.name} ${r.width}x${r.height}`).join(", ")} as png-sequence` });
}
+ // The deck: composed and rendered here, in the same command (compose-chrome
+ // skips the render when its key and frame count match). `--no-chrome` keeps
+ // the deck's framing and leaves the panel off -- a fast look at the picture.
+ if (deck && !opts.noChrome) {
+ chromePlan = await renderDeck({ manifestPath, render, outDir, variant, schedule });
+ EMIT("chrome", { phase: "overlay" });
+ }
// `transition: 0` is a real editorial choice, not just a speed knob: hard cuts
// hit harder on a compilation whose point is repetition. Honouring it here keeps
// the manifest the source of truth, so a rebuild does not silently re-add fades.
EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segments.length });
if (D === 0) {
- if (chromePlan) {
+ // The chart band keeps this refusal. The deck does not need it: its
+ // overlay is the second pass below, which can host anything.
+ if (chromePlan && !deck) {
throw new Error(
'render.chromeEngine "hyperframes" needs a filtergraph, and `transition: 0` concatenates with ' +
"-c copy, which cannot host one. Give the manifest a transition, or drop chromeEngine.",
@@ -2569,14 +2830,26 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// concatHardCut is `-c copy`, which cannot host a filtergraph, so the rail
// has to be a second pass here whether we like it or not.
const prerail = prerailPath(outDir, manifest.slug, D);
- await concatHardCut(segments, outDir, railPlan ? prerail : final);
if (railPlan) {
+ await concatHardCut(segments, outDir, prerail);
await assertConcatLength(prerail, railPlan.total, render.fps, "hard-cut concat");
await applyRail(prerail, final, render, railPlan, null);
+ } else if (chromePlan) {
+ // 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);
+ await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat");
+ await applyChrome(prerail, final, render, chromePlan, null);
+ } else {
+ await concatHardCut(segments, outDir, final);
}
} else {
await concatWithXfade(segments, render, final, railPlan, chromePlan);
}
+ // 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.
+ if (deck) await assertConcatLength(final, schedule.total, render.fps, chromePlan ? "deck build" : "deck concat");
// Length is the canary for the two ways a rail input can go wrong: a file
// LONGER than the timeline means a strip outran the main (a missing
@@ -2666,6 +2939,34 @@ async function buildThumbnail(manifest, dirs, manifestDir) {
return { out, jpg };
}
+/**
+ * The deck's three flags, read off argv. Pure, so the parser is tested
+ * without running a build; throws a sentence on a malformed one.
+ *
+ * --chrome-only re-lay the deck over the segments on disk
+ * --no-chrome the deck's framing, no overlay
+ * --chrome-preview <at> <dur> a window, to out/<variant>/<slug>.preview.mp4
+ */
+export function chromeFlags(argv) {
+ const out = {
+ chromeOnly: argv.includes("--chrome-only"),
+ noChrome: argv.includes("--no-chrome"),
+ chromePreview: null,
+ };
+ const i = argv.indexOf("--chrome-preview");
+ if (i >= 0) {
+ const at = Number(argv[i + 1]);
+ const dur = Number(argv[i + 2]);
+ if (argv[i + 1] === undefined || argv[i + 2] === undefined || !Number.isFinite(at) || !Number.isFinite(dur) || at < 0 || dur <= 0) {
+ throw new Error("--chrome-preview takes <at> <dur> in seconds (at ≥ 0, dur > 0)");
+ }
+ out.chromePreview = { at, dur };
+ }
+ const runs = [out.chromeOnly, out.noChrome, out.chromePreview].filter(Boolean).length;
+ if (runs > 1) throw new Error("--chrome-only, --no-chrome and --chrome-preview are different runs — pick one");
+ return out;
+}
+
async function main() {
const argv = process.argv.slice(2);
const manifestPath = argv.find((a) => !a.startsWith("--"));
@@ -2676,6 +2977,7 @@ async function main() {
" [--pad <s>] [--pad-before <s>] [--pad-after <s>] [--skip-fetch] [--no-xfade] [--no-chapters] [--chapters-only]\n" +
" [--progress ndjson] [--continue-on-error] [--no-reuse]\n" +
" [--no-rail] [--rail-only] [--preview <start> <dur>]\n" +
+ " [--chrome-only] [--no-chrome] [--chrome-preview <at> <dur>] (render.chrome, the deck)\n" +
" [--site-origin <url>] [--resolve-site-ids] [--cue-source auto|local|http]\n" +
" [--thumbnail] (render.brand only: out/<slug>.thumbnail.png and stop)",
);
@@ -2708,6 +3010,12 @@ async function main() {
cueSource: flag("--cue-source"),
thumbnailOnly: argv.includes("--thumbnail"),
};
+ try {
+ Object.assign(opts, chromeFlags(argv));
+ } catch (err) {
+ console.error(err.message);
+ process.exit(2);
+ }
const pv = argv.indexOf("--preview");
if (pv >= 0) {
opts.preview = { start: Number(argv[pv + 1]), dur: Number(argv[pv + 2]) };
diff --git a/umtool/report-to-video/deck-overlay.test.mjs b/umtool/report-to-video/deck-overlay.test.mjs
@@ -0,0 +1,132 @@
+// Tests for the deck's overlay in the build (slice S3): the overlay chain the
+// deck's frames go through, the hard-cut pass that lays it (applyChrome), the
+// preview window, and the CLI flags. All pure -- argv and filtergraph strings,
+// no ffmpeg run.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ applyChromeArgs, chromeFlags, chromeOverlayChain, chromeRegions, previewFromSegmentsArgs,
+ windowSegments,
+} from "./build-video.mjs";
+
+const PALETTE = { bg: "#15121c", fg: "#ece8f4", muted: "#9a93ad", accent: "#7c5cff", amber: "#f2b84b" };
+const BASE = { width: 1920, height: 1080, fps: 30, palette: PALETTE, crf: 21, preset: "slow" };
+const DECK = { ...BASE, chrome: { engine: "hyperframes", layout: "deck", deck: {} } };
+const CHART = { ...BASE, chromeEngine: "hyperframes" };
+
+test("chromeOverlayChain: the chart band's chain and inputs are as they shipped", () => {
+ const hf = chromeOverlayChain(CHART, chromeRegions(CHART, "/o"), "[v3]", 4, { outLabel: "[hfout]", final: false });
+ assert.deepEqual(hf.inputs, ["-framerate", "30", "-start_number", "1", "-i", "/o/chrome/chart-frames/frame_%06d.png"]);
+ assert.equal(hf.chain, "[v3][4:v]overlay=x=0:y=880:format=yuv444:shortest=1[hfout]");
+ const fin = chromeOverlayChain(CHART, chromeRegions(CHART, "/o"), "[v3]", 4);
+ assert.equal(fin.chain, "[v3][4:v]overlay=x=0:y=880:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]");
+ assert.equal(fin.outLabel, "[vout]");
+});
+
+test("chromeOverlayChain: the deck's input keeps the graph (-reinit_filter 0) and is pinned to rgba", () => {
+ const hf = chromeOverlayChain(DECK, chromeRegions(DECK, "/o/sourced"), "[v16]", 17);
+ assert.deepEqual(hf.inputs, [
+ "-reinit_filter", "0",
+ "-framerate", "30", "-start_number", "1",
+ "-i", "/o/sourced/chrome/deck-frames/frame_%06d.png",
+ ]);
+ assert.equal(
+ hf.chain,
+ "[17:v]format=rgba[hfa0];[v16][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]",
+ );
+ // The reinit option is an INPUT option: it has to precede its own -i.
+ assert.ok(hf.inputs.indexOf("-reinit_filter") < hf.inputs.indexOf("-i"));
+});
+
+test("applyChromeArgs: one video encode over the concat, audio copied, deck frames second input", () => {
+ const plan = { regions: chromeRegions(DECK, "/o/sourced"), outLabel: "[hfout]" };
+ const args = applyChromeArgs("/o/sourced/x.prerail-hardcut.mp4", "/o/x.mp4", DECK, plan);
+ assert.deepEqual(args.slice(0, 6), ["-nostdin", "-v", "error", "-y", "-i", "/o/sourced/x.prerail-hardcut.mp4"]);
+ const fc = args[args.indexOf("-filter_complex") + 1];
+ assert.equal(fc, "[1:v]format=rgba[hfa0];[0:v][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]");
+ assert.deepEqual(args.slice(args.indexOf("-map"), args.indexOf("-map") + 4), ["-map", "[vout]", "-map", "0:a"]);
+ assert.equal(args[args.indexOf("-c:a") + 1], "copy");
+ assert.equal(args[args.indexOf("-crf") + 1], "21");
+ assert.equal(args.at(-1), "/o/x.mp4");
+ assert.ok(!args.includes("-ss"));
+});
+
+test("applyChromeArgs: a preview seeks the base and needs no timestamp shift", () => {
+ const plan = { regions: chromeRegions(DECK, "/o").map((r) => ({ ...r, frames: "/o/chrome/deck-from20-frames" })), outLabel: "[hfout]" };
+ const args = applyChromeArgs("/in.mp4", "/out.mp4", DECK, plan, { start: 20, dur: 8 });
+ assert.deepEqual(args.slice(4, 10), ["-ss", "20", "-t", "8", "-i", "/in.mp4"]);
+ assert.ok(args.includes("/o/chrome/deck-from20-frames/frame_%06d.png"));
+ assert.ok(!args[args.indexOf("-filter_complex") + 1].includes("setpts"));
+});
+
+test("windowSegments: every segment the window touches, and the window's offset into the first", () => {
+ // Three segments of 10 s crossfaded by 0.5: starts 0, 9.5, 19.
+ const starts = [0, 9.5, 19];
+ const durs = [10, 10, 10];
+ assert.deepEqual(windowSegments(starts, durs, 2, 3), { first: 0, last: 0, offset: 2 });
+ // Inside the dissolve both neighbours are on screen.
+ assert.deepEqual(windowSegments(starts, durs, 9.7, 1), { first: 0, last: 1, offset: 9.7 });
+ assert.deepEqual(windowSegments(starts, durs, 12, 10), { first: 1, last: 2, offset: 2.5 });
+ assert.throws(() => windowSegments(starts, durs, 40, 2), /no segment covers/);
+});
+
+test("previewFromSegmentsArgs: the window's segments crossfaded as the full concat does, trimmed, deck over", () => {
+ const plan = { regions: chromeRegions(DECK, "/o").map((r) => ({ ...r, frames: "/o/chrome/deck-from12-frames" })), outLabel: "[hfout]" };
+ const args = previewFromSegmentsArgs({
+ segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs: [10, 10, 10], starts: [0, 9.5, 19],
+ D: 0.5, at: 12, dur: 10, render: DECK, chromePlan: plan, outPath: "/o/x.preview.mp4",
+ });
+ // Only b and c are inputs; the deck frames are the third.
+ assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), [
+ "/s/b.mp4", "/s/c.mp4", "/o/chrome/deck-from12-frames/frame_%06d.png",
+ ]);
+ assert.equal(
+ args[args.indexOf("-filter_complex") + 1],
+ [
+ "[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]trim=start=2.500:duration=10.000,setpts=PTS-STARTPTS[vw]",
+ "[a1]atrim=start=2.500:duration=10.000,asetpts=PTS-STARTPTS[aw]",
+ "[2:v]format=rgba[hfa0];[vw][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]",
+ ].join(";"),
+ );
+ assert.equal(args.at(-1), "/o/x.preview.mp4");
+});
+
+test("previewFromSegmentsArgs: hard cuts concatenate; one segment needs neither", () => {
+ const plan = { regions: chromeRegions(DECK, "/o"), outLabel: "[hfout]" };
+ const two = previewFromSegmentsArgs({
+ segments: ["/s/a.mp4", "/s/b.mp4"], durs: [10, 10], starts: [0, 10],
+ D: 0, at: 8, dur: 4, render: DECK, chromePlan: plan, outPath: "/p.mp4",
+ });
+ const fc2 = two[two.indexOf("-filter_complex") + 1];
+ assert.match(fc2, /^\[0:v\]\[0:a\]\[1:v\]\[1:a\]concat=n=2:v=1:a=1\[vc\]\[ac\];\[vc\]trim=start=8\.000:duration=4\.000/);
+ const one = previewFromSegmentsArgs({
+ segments: ["/s/a.mp4", "/s/b.mp4"], durs: [10, 10], starts: [0, 10],
+ D: 0, at: 1, dur: 4, render: DECK, chromePlan: plan, outPath: "/p.mp4",
+ });
+ assert.match(one[one.indexOf("-filter_complex") + 1], /^\[0:v\]trim=start=1\.000:duration=4\.000/);
+});
+
+test("chromeFlags: the three deck flags, one at a time", () => {
+ assert.deepEqual(chromeFlags(["m.json", "--skip-fetch"]), { chromeOnly: false, noChrome: false, chromePreview: null });
+ assert.deepEqual(chromeFlags(["m.json", "--chrome-only"]), { chromeOnly: true, noChrome: false, chromePreview: null });
+ assert.deepEqual(chromeFlags(["m.json", "--no-chrome"]), { chromeOnly: false, noChrome: true, chromePreview: null });
+ assert.deepEqual(chromeFlags(["m.json", "--chrome-preview", "20", "8", "--out", "o"]), {
+ chromeOnly: false, noChrome: false, chromePreview: { at: 20, dur: 8 },
+ });
+ assert.throws(() => chromeFlags(["m.json", "--chrome-preview", "20"]), /<at> <dur>/);
+ assert.throws(() => chromeFlags(["m.json", "--chrome-preview", "x", "8"]), /<at> <dur>/);
+ assert.throws(() => chromeFlags(["m.json", "--chrome-preview", "5", "0"]), /<at> <dur>/);
+ assert.throws(() => chromeFlags(["m.json", "--chrome-only", "--no-chrome"]), /pick one/);
+});
+
+test("the driver's chromeOnly produces the flag this parser reads", async () => {
+ const { buildSteps } = await import("../lib/report/driver.mjs");
+ const steps = buildSteps({ id: "p", dir: "/p" }, { preset: "final", options: { chromeOnly: true } });
+ const build = steps.find((s) => s.argv.some((a) => a.endsWith("build-video.mjs")));
+ assert.equal(chromeFlags(build.argv).chromeOnly, true);
+});
diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs
@@ -15,10 +15,11 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
-import { readFile, stat } from "node:fs/promises";
+import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { selectVariant, variantPaths } from "./build-video.mjs";
+import { deckOn, frameCount } from "./deck.mjs";
const execFileP = promisify(execFile);
const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe";
@@ -80,7 +81,54 @@ export async function verifyBuild(manifestPath, { outDir, variant = "sourced" }
problems.push(`${duration.toFixed(1)}s out of a timeline that asks for about ${wanted.toFixed(0)}s`);
}
- return { ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, problems };
+ // The deck (`render.chrome`). Its frames are laid with shortest=1, so a
+ // sequence that came up short shortens the cut without a word, and one that
+ // is missing means the file was built --no-chrome -- a picture check, not
+ // the deliverable. Both are measured against the schedule the build wrote.
+ let deck = null;
+ if (deckOn(manifest.render)) {
+ deck = await verifyDeck(path.join(root, variant), manifest.render, file, problems);
+ }
+
+ return { ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, deck, problems };
+}
+
+/**
+ * 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.
+ */
+export async function verifyDeck(variantDir, render, file, problems) {
+ const schedPath = path.join(variantDir, "schedule.json");
+ const schedule = await readFile(schedPath, "utf8").then(JSON.parse, () => null);
+ if (!schedule || schedule.kind !== "deck") {
+ problems.push(`the deck is on but ${schedPath} is missing or not a deck schedule`);
+ return null;
+ }
+ if (schedule.estimated) problems.push(`${schedPath} is an estimate; a build writes a measured one`);
+ const fps = Number(schedule.fps ?? render.fps);
+ const want = frameCount(schedule.total, fps);
+ const framesDir = path.join(variantDir, "chrome", "deck-frames");
+ const frames = await readdir(framesDir).then(
+ (fs) => fs.filter((f) => /^frame_\d+\.png$/.test(f)).length,
+ () => 0,
+ );
+ if (frames === 0) {
+ problems.push(`the deck is on but ${framesDir} has no frames — built with --no-chrome?`);
+ } else if (frames !== want) {
+ problems.push(`${framesDir} holds ${frames} frames; the cut is ${want} (${schedule.total}s at ${fps} fps)`);
+ }
+ // The VIDEO stream's length, in frames -- the build's own measure
+ // (assertConcatLength). The container's duration runs on with the audio.
+ const { stdout } = await execFileP(FFPROBE, [
+ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames",
+ "-of", "default=nw=1:nk=1", file,
+ ]);
+ const videoFrames = Number(stdout.trim());
+ if (!(Math.abs(videoFrames - schedule.total * fps) <= 1.5)) {
+ problems.push(`the picture is ${videoFrames} frames for a ${schedule.total.toFixed(3)}s schedule (${want} frames)`);
+ }
+ return { total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length };
}
async function main() {
@@ -103,6 +151,9 @@ async function main() {
`${res.file} (${res.variant})\n ${res.duration?.toFixed(1) ?? "?"}s · ${res.chapters ?? 0} chapter(s) for ` +
`${res.entries ?? 0} entr(ies) · ${((res.size ?? 0) / 1e6).toFixed(1)} MB`,
);
+ if (res.deck) {
+ console.log(` deck: ${res.deck.frames}/${res.deck.expectedFrames} frame(s) over ${res.deck.segments} segment(s), ${res.deck.total}s`);
+ }
for (const p of res.problems) console.log(` ** ${p}`);
if (res.ok) console.log(" ok");
}