Archilyzer · Source

archilyzer

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

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:
Mumtool/lib/report/driver.mjs | 1+
Mumtool/lib/report/driver.test.mjs | 9+++++++++
Mumtool/report-to-video/README.md | 6+++++-
Mumtool/report-to-video/build-video.mjs | 4+++-
Mumtool/report-to-video/dip.test.mjs | 37+++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/verify-build.mjs | 24++++++++++++++----------
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")) {