#!/usr/bin/env node // verify-build.mjs — is the file that came out the file that was asked for? // // A build can exit 0 and still be wrong in ways nothing else notices: a concat // that produced a zero-length file, a chapter pass that silently dropped // markers, a timeline that lost a clip because --continue-on-error let it. Each // of those looks like success at the terminal and like a finished video in a // directory listing. // // So the last step of a build measures the deliverable and compares it to the // manifest. Cheap (one ffprobe) and the only thing that closes the loop. // // node umtool/report-to-video/verify-build.mjs [--out ] // [--variant sourced|full] [--no-xfade] [--json] import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { postsRegions, selectVariant, variantPaths } from "./build-video.mjs"; import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck, teaserSeconds, transitionOf } 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", noXfade = false } = {}) { // The SAME filter the build ran. Verifying the whole manifest against one // variant's file would report a missing chapter for every entry the other cut // carries -- i.e. it would be red exactly when the build was right. const manifest = selectVariant( JSON.parse(await readFile(manifestPath, "utf8")), variant, ); const root = outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out"); const file = variantPaths(root, manifest.slug, variant).final; const problems = []; const st = await stat(file).catch(() => null); if (!st) return { ok: false, file, problems: [`${file} does not exist`] }; if (st.size < 1024) problems.push(`${file} is ${st.size} bytes`); const { stdout } = await execFileP(FFPROBE, [ "-v", "error", "-show_entries", "format=duration,size", "-show_chapters", "-of", "json", file, ], { maxBuffer: 1 << 24 }); const probe = JSON.parse(stdout); const duration = Number(probe.format?.duration ?? 0); const chapters = (probe.chapters ?? []).length; const entries = (manifest.timeline ?? []).length; if (!(duration > 0)) problems.push("duration is not greater than zero"); // Every timeline entry becomes a chapter, so a mismatch means the timeline and // the file disagree about what is in it -- which is exactly the failure // --continue-on-error is allowed to cause and must never cause silently. if (chapters > 0 && chapters !== entries) { problems.push(`${chapters} chapter(s) for ${entries} timeline entr(ies) — the cut is missing something`); } // A rough floor: the sum of the windows, less the crossfades. Well under the // real duration because snapping moves the cuts, but a file that came out at // half the expected length did not build what was asked for. const wanted = (manifest.timeline ?? []).reduce( // `seconds` covers cards and the two end-sequence kinds (scroll, chart); // only a clip's length has to be derived from its window -- and from the // CUT when it has one, because that is what was rendered. Measuring a cut // clip against its whole extent would fire this floor on a build that did // exactly what the manifest asked for. (n, e) => n + (e.type === "clip" ? Math.max(0, (e.cutEnd ?? e.end ?? 0) - (e.cutStart ?? e.start ?? 0)) : (e.seconds ?? 0)), 0, ); if (wanted > 0 && duration < wanted * 0.5) { problems.push(`${duration.toFixed(1)}s out of a timeline that asks for about ${wanted.toFixed(0)}s`); } // 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); } const teasers = await verifyTeasers(path.join(root, variant), manifest, problems, { noXfade }); return { ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, deck, ...(teasers.length ? { teasers } : {}), 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 -- the SCHEDULE's total, holds included. * When the schedule carries posts, each window's `chrome/posts--frames` * holds that window's frame count, and each held clip's freeze is in the file. * When it stamps a claim, `chrome/stamp-frames` is as long as the deck's. */ 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 countFrames = (dir) => readdir(dir).then( (fs) => fs.filter((f) => /^frame_\d+\.png$/.test(f)).length, () => 0, ); const frames = await countFrames(framesDir); 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)`); } // The posts feed: one sequence for the whole cut, as long as the deck's -- // and a cut that never pauses: no hold and no footage move in its schedule. let feed = null; if (schedule.layout === "feed") { const dir = path.join(variantDir, "chrome", "feed-frames"); const got = await countFrames(dir); feed = { frames: got, expectedFrames: want, posts: (schedule.posts ?? []).length }; if (got !== want) problems.push(`${dir} holds ${got} frames; the posts feed runs the whole cut, ${want}`); const held = (schedule.segments ?? []).filter((s) => s.hold > 0).map((s) => s.id); if (held.length || schedule.moves?.length) { problems.push(`the posts feed never pauses the cut, but the schedule holds ${held.join(", ") || "nothing"} and moves ${schedule.moves?.length ?? 0}`); } } // The fact-check stamps: one sequence for the whole cut, as long as the deck's. let stamp = null; if (schedule.factcheck?.stamps?.length) { const dir = path.join(variantDir, "chrome", "stamp-frames"); const got = await countFrames(dir); stamp = { frames: got, expectedFrames: want, stamps: schedule.factcheck.stamps.length }; if (got !== want) problems.push(`${dir} holds ${got} frames; the fact-check stamps run the whole cut, ${want}`); } // The posts windows: each laid at its own second, each as long as snapWindow says. const posts = []; for (const r of postsRegions(render, variantDir, schedule)) { const got = await countFrames(r.frames); posts.push({ segment: r.segment, frames: got, expectedFrames: r.frameCount, at: r.offset }); if (got !== r.frameCount) { 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, ...(feed ? { feed } : {}), ...(stamp ? { stamp } : {}), ...(posts.length ? { posts } : {}), ...(holds.length ? { holds } : {}), }; } /** * Each `teaser` entry's segment is the render it claims to be: its frames * (`chrome/teaser--frames`) are `frameCount(seconds, fps)` long, and the * record beside its segment (`.teaser.json`) names those frames' key -- a * segment encoded from an older render (changed words) fails here. */ export async function verifyTeasers(variantDir, manifest, problems, { noXfade = false } = {}) { const fps = Number(manifest.render?.fps ?? 30); // A dip's lead counts the cut's transition AS BUILT: the one the teaser's // record names, else the one the build measured (its schedule, under the // deck), else 0 under --no-xfade, else the manifest's. const sched = await readFile(path.join(variantDir, "schedule.json"), "utf8").then(JSON.parse, () => null); const cutD = Number.isFinite(sched?.transition) ? sched.transition : noXfade ? 0 : transitionOf(manifest.render); const out = []; for (const e of manifest.timeline ?? []) { if (e.type !== "teaser") continue; const dir = path.join(variantDir, "chrome", `teaser-${e.id}-frames`); const frames = await readdir(dir).then((fs) => fs.filter((f) => /^frame_\d+\.png$/.test(f)).length, () => 0); const seg = path.join(variantDir, "segments", `${e.id}.mp4`); const rec = await readFile(seg.replace(/\.mp4$/, ".teaser.json"), "utf8").then(JSON.parse, () => null); const D = Number.isFinite(rec?.transition) ? rec.transition : cutD; const seconds = teaserSeconds(e, D, fps); const want = frameCount(seconds, fps); const key = await readFile(path.join(dir, ".key"), "utf8").then((s) => s.trim(), () => null); if (frames !== want) problems.push(`${dir} holds ${frames} frames; the teaser ${e.id} is ${want} (${seconds}s at ${fps} fps)`); if (!rec) problems.push(`the teaser ${e.id} has no record beside ${seg} — rebuild it`); else if (key && rec.frames !== key) problems.push(`the teaser ${e.id}'s segment was encoded from another render of it — rebuild it`); out.push({ id: e.id, frames, expectedFrames: want, current: !!rec && rec.frames === key }); } return out; } /** The mean absolute difference allowed between two frames of one freeze (8-bit luma; re-encoding noise). */ export const FREEZE_TOLERANCE = 1.5; /** * Where a held segment's freeze can be sampled: the still span is from the * later of the hold's start and the end of the footage move (the move runs * after the hold, so a late one glides over the frozen frame) to the start of * the outgoing dissolve (`end − D`), or the end fade on the last segment * (`end − endFade`), or the segment's end. The two samples sit a frame and a * half inside it. A span under three frames has nothing still to compare -- * at hold 0.5 under a 0.5 s crossfade the dissolve takes all of it -- and is * skipped, with the reason. * * @returns {{ at: [number, number] } | { skip: string }} */ export function freezeSamples(segment, { fps, D = 0, last = false, endFade = 0, moveEnd = -Infinity }) { const lo = Math.max(segment.end - segment.hold, moveEnd); const hi = last ? segment.end - (endFade > 0 ? endFade : 0) : segment.end - D; if (!(hi - lo >= 3 / fps)) { return { skip: `${Math.max(0, hi - lo).toFixed(3)}s of still picture between ${lo.toFixed(3)}s and ${hi.toFixed(3)}s ` + `(the rest of the hold is under the ${last ? "end fade" : "dissolve"}${moveEnd > segment.end - segment.hold ? " or the move" : ""})`, }; } return { at: [lo + 1.5 / fps, hi - 1.5 / fps] }; } /** * Each held segment's freeze is in the file: two frames inside its still span * (`freezeSamples`) are the same frame. 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 segs = schedule.segments ?? []; const held = segs.filter((s) => s.hold > 0); if (!held.length) return []; const fps = Number(schedule.fps ?? render.fps); const D = Number(schedule.transition ?? 0); const endFade = Number(render.endFade ?? 0); const moveEnd = new Map((schedule.moves ?? []).map((m) => [m.segment, m.at + m.seconds])); 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 span = freezeSamples(s, { fps, D, last: s === segs.at(-1), endFade, moveEnd: moveEnd.get(s.id) ?? -Infinity, }); if (span.skip) { out.push({ segment: s.id, hold: s.hold, skipped: span.skip }); continue; } const [a, b] = span.at; 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("--")); if (!manifestPath) { console.error("usage: verify-build.mjs [--out ] [--variant sourced|full] [--no-xfade] [--json]"); process.exit(2); } const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; }; const res = await verifyBuild(manifestPath, { outDir: flag("--out"), variant: flag("--variant") ?? "sourced", // The build's own flag: a cut joined without crossfades. noXfade: argv.includes("--no-xfade"), }); if (argv.includes("--json")) { console.log(JSON.stringify(res, null, 2)); } else { console.log( `${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`); if (res.deck.feed) { console.log(` posts feed: ${res.deck.feed.frames}/${res.deck.feed.expectedFrames} frame(s), ${res.deck.feed.posts} post(s), no hold`); } 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(h.skipped ? ` hold on ${h.segment}: ${h.hold}s, not checked — ${h.skipped}` : ` hold on ${h.segment}: ${h.hold}s, frozen (${h.at.join("s ≈ ")}s, mean diff ${h.diff})`); } } for (const t of res.teasers ?? []) { console.log(` teaser ${t.id}: ${t.frames}/${t.expectedFrames} frame(s)${t.current ? ", segment encoded from them" : ""}`); } for (const p of res.problems) console.log(` ** ${p}`); if (res.ok) console.log(" ok"); } process.exit(res.ok ? 0 : 1); } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { console.error(err.message ?? err); process.exit(1); }); }