Archilyzer · Source

archilyzer

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

commit 8c3a13f9857f395be8f40ad646048a6262d2f46f
parent 5dc81a12bcc8c7360acdd7f5ed438152896eb804
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 23:09:12 -0400

Merge deck/s3-build (deck slice S3) — one build command composes, renders (cached) and overlays the deck; transition 0 through applyChrome; --chrome-only, --no-chrome, --chrome-preview; -reinit_filter 0 + format=rgba on the deck input; verify-build deck checks; the hard-cut concat list names segments absolutely; ferret scratch gates 17/17 QR, chapters equal, round trip deck-only; reviewed by contact sheet

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

Diffstat:
Mumtool/report-to-video/build-video.mjs | 330++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Aumtool/report-to-video/deck-overlay.test.mjs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/verify-build.mjs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 506 insertions(+), 11 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 @@ -2253,7 +2441,11 @@ async function muxChapters(finalPath, entries, segments, D, outDir, provenance, async function concatHardCut(segments, outDir, outPath) { const listPath = path.join(outDir, "concat.txt"); - await writeFile(listPath, segments.map((s) => `file '${s}'`).join("\n") + "\n", "utf8"); + // Absolute: the concat demuxer resolves a relative entry against the LIST's + // directory, so a relative `--out` named every segment twice over and the + // hard-cut concat failed to open its first input. An absolute path is + // unchanged by this, and so is every build that already worked. + await writeFile(listPath, segments.map((s) => `file '${path.resolve(s)}'`).join("\n") + "\n", "utf8"); await execFileP( FFMPEG, ["-nostdin", "-v", "error", "-y", "-f", "concat", "-safe", "0", @@ -2459,6 +2651,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 +2783,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 +2810,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 +2834,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 +2943,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 +2981,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 +3014,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"); }