commit 5dc81b4e67e583b482c778c162c71949cb54e6d1
parent 7751153485035821b7a0309e9bf09cda52a61fa3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 21:23:18 -0400
report-to-video: verify-build counts a dipped teaser's frames at the transition it was built at -- the teaser's record now names it (`transition` in <id>.teaser.json), verify-build takes --no-xfade for older records without a schedule, and umtool's driver passes --no-xfade to the verify step whenever the build had it; tests for the verify and the driver's argv; the README says so
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 69 insertions(+), 12 deletions(-)
diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs
@@ -150,6 +150,7 @@ export function buildSteps(project, { preset = "fast", only = null, skipFetch =
if ((!p.only || !only) && !options.preview) {
const verify = ["node", script("verify-build.mjs"), manifest, "--out", outDir];
if (options.variant) verify.push("--variant", options.variant);
+ if (buildArgv.includes("--no-xfade")) verify.push("--no-xfade");
steps.push({
...base,
label: "verify the file that came out",
diff --git a/umtool/lib/report/driver.test.mjs b/umtool/lib/report/driver.test.mjs
@@ -24,3 +24,12 @@ test("without chromeOnly nothing changes: the preflight leads and no --chrome-on
assert.equal(steps[0].label, "check every source is still fetchable");
assert.ok(steps.every((s) => !s.argv.includes("--chrome-only")));
});
+
+test("the verify is told when the build joined without crossfades", () => {
+ const verifyOf = (steps) => steps.at(-1).argv;
+ const fast = buildSteps(project, { preset: "fast" });
+ assert.ok(fast.some((s) => s.argv.includes("--no-xfade") && !s.argv.some((a) => a.endsWith("verify-build.mjs"))));
+ assert.ok(verifyOf(fast).includes("--no-xfade"));
+ assert.ok(verifyOf(buildSteps(project, { preset: "final", options: { xfade: false } })).includes("--no-xfade"));
+ assert.ok(!verifyOf(buildSteps(project, { preset: "final" })).includes("--no-xfade"));
+});
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -589,7 +589,11 @@ veil starts lifting 1.1 s into the teaser and its first impact is 1.45 s in.
The lead counts the cut's transition AS BUILT: a `--no-xfade` build composes a
teaser whose lead is the black alone (`buildTeaserSegment` and compose-chrome
-take the build's transition; compose-chrome's CLI uses the manifest's). A
+take the build's transition; compose-chrome's CLI uses the manifest's). The
+teaser's record beside its segment (`<id>.teaser.json`) names that transition,
+and `verify-build.mjs` counts the teaser's frames at it (an older record
+without it: the schedule's, else 0 under `--no-xfade`, which umtool's driver
+passes on whenever the build had it, else the manifest's). A
teaser without `dip` composes the page, the sound and the segment key it
always did, and a cut without one writes every graph it did.
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -1236,7 +1236,9 @@ export async function buildTeaserSegment(entry, { manifestPath, render, outDir,
return seg;
}
await execFileP(FFMPEG, teaserEncodeArgs({ framesDir: r.frames, seconds, render, audio, outPath: seg }), { maxBuffer: 1 << 26 });
- await writeFile(recPath, JSON.stringify({ key, frames: r.key, hits: hits.length }) + "\n", "utf8");
+ // `transition` is the cut's as built (0 under --no-xfade): a dip's lead
+ // counts it, and verify-build reads it back from here.
+ await writeFile(recPath, JSON.stringify({ key, frames: r.key, hits: hits.length, transition }) + "\n", "utf8");
return seg;
}
diff --git a/umtool/report-to-video/dip.test.mjs b/umtool/report-to-video/dip.test.mjs
@@ -25,6 +25,7 @@ import {
} from "./deck.mjs";
import { teaserCues, teaserHtml } from "./chrome-teaser.mjs";
import { composeChrome } from "./compose-chrome.mjs";
+import { verifyTeasers } from "./verify-build.mjs";
const have = spawnSync("ffmpeg", ["-version"]).status === 0;
const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" };
@@ -266,6 +267,42 @@ test("the page's HTML: a veil node and its rule only with a dip; its length coun
assert.equal(teaserHtml(FIN, RENDER, { transition: 0 }), plain, "no dip: the transition is not read");
});
+test("verify-build: a dipped teaser built --no-xfade is counted at the build's transition, not the manifest's", async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "dip-verify-"));
+ try {
+ // A cut without the deck (no schedule.json) whose manifest crossfades 0.5 s.
+ const manifest = { render: { ...RENDER, chrome: undefined }, timeline: [CLIP, dipped()] };
+ const frames = path.join(dir, "chrome", "teaser-fin-frames");
+ mkdirSync(frames, { recursive: true });
+ mkdirSync(path.join(dir, "segments"));
+ // Built --no-xfade: the lead is the black alone, 7.4 s = 222 frames.
+ const n = Math.round(teaserSeconds(dipped(), 0, 30) * 30);
+ assert.equal(n, 222);
+ for (let i = 1; i <= n; i += 1) writeFileSync(path.join(frames, `frame_${String(i).padStart(6, "0")}.png`), "");
+ writeFileSync(path.join(frames, ".key"), "k1\n");
+ const rec = path.join(dir, "segments", "fin.teaser.json");
+ const run = async (record, opts) => {
+ writeFileSync(rec, JSON.stringify(record) + "\n");
+ const problems = [];
+ const [t] = await verifyTeasers(dir, manifest, problems, opts);
+ return { problems, t };
+ };
+ // The record names the transition it was built at: nothing to say, whatever the flag.
+ for (const opts of [undefined, { noXfade: true }]) {
+ const { problems, t } = await run({ key: "s", frames: "k1", hits: 4, transition: 0 }, opts);
+ assert.deepEqual(problems, []);
+ assert.deepEqual(t, { id: "fin", frames: 222, expectedFrames: 222, current: true });
+ }
+ // An older record without it: --no-xfade says what the build did.
+ assert.deepEqual((await run({ key: "s", frames: "k1", hits: 4 }, { noXfade: true })).problems, []);
+ // Without either, the manifest's 0.5 s is assumed and the 15 frames are missing.
+ const { problems } = await run({ key: "s", frames: "k1", hits: 4 });
+ assert.deepEqual(problems, [`${frames} holds 222 frames; the teaser fin is 237 (7.9s at 30 fps)`]);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
test("compose-chrome: a dipped teaser renders lead + card frames, keyed by the dip and the transition", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "dip-compose-"));
try {
diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs
@@ -11,7 +11,7 @@
// manifest. Cheap (one ffprobe) and the only thing that closes the loop.
//
// node umtool/report-to-video/verify-build.mjs <manifest.json> [--out <dir>]
-// [--variant sourced|full] [--json]
+// [--variant sourced|full] [--no-xfade] [--json]
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -25,7 +25,7 @@ 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" } = {}) {
+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.
@@ -90,7 +90,7 @@ export async function verifyBuild(manifestPath, { outDir, variant = "sourced" }
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);
+ 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,
@@ -173,22 +173,24 @@ export async function verifyDeck(variantDir, render, file, problems) {
* record beside its segment (`<id>.teaser.json`) names those frames' key -- a
* segment encoded from an older render (changed words) fails here.
*/
-export async function verifyTeasers(variantDir, manifest, problems) {
+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: the one the build measured
- // (its schedule, under the deck), else the manifest's.
+ // 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 D = Number.isFinite(sched?.transition) ? sched.transition : transitionOf(manifest.render);
+ 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);
- const seg = path.join(variantDir, "segments", `${e.id}.mp4`);
- const rec = await readFile(seg.replace(/\.mp4$/, ".teaser.json"), "utf8").then(JSON.parse, () => 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`);
@@ -283,13 +285,15 @@ async function main() {
const argv = process.argv.slice(2);
const manifestPath = argv.find((a) => !a.startsWith("--"));
if (!manifestPath) {
- console.error("usage: verify-build.mjs <manifest.json> [--out <dir>] [--variant sourced|full] [--json]");
+ console.error("usage: verify-build.mjs <manifest.json> [--out <dir>] [--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")) {