Archilyzer · Source

archilyzer

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

commit 60b908a5a39c507ab071516f2fb179103147792a
parent 59c6e4f4ab475ee8f911e33be9f0a6a5eda1fcd6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 23:53:17 -0400

report-to-video: the deck reuses a cached hard-cut concat only for the same segments in the same order; chapters take onscreen.title only under the deck

Review fixes F1 and F2. The deck's hard-cut prerail now records its concat
list beside it (<prerail>.segments), and freshConcat reuses it only when that
list equals the current one: a reordered timeline of unchanged segments has
the same length and no newer file, and its old concat would put every title,
QR and chapter on the wrong footage. A prerail with no record is rebuilt.
chapterTitle reads onscreen.title only when the cut wears the deck, so a
manifest without render.chrome names its chapters exactly as before.

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

Diffstat:
Mumtool/report-to-video/build-video.mjs | 59++++++++++++++++++++++++++++++++++++++++-------------------
Mumtool/report-to-video/deck-build.test.mjs | 40++++++++++++++++++++++++++++++----------
2 files changed, 70 insertions(+), 29 deletions(-)

diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -2312,6 +2312,11 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro async function freshConcat(file, segments, total, fps) { const st = await stat(file).catch(() => null); if (!st) return false; + // Same segments, same order. Length and age alone would take a REORDERED + // timeline's old concat -- every title, QR and chapter then lands on the + // wrong footage while the length check still passes. + const recorded = await readFile(`${file}.segments`, "utf8").catch(() => null); + if (!sameConcatList(recorded, segments)) return false; for (const s of segments) { if ((await stat(s)).mtimeMs > st.mtimeMs) return false; } @@ -2319,6 +2324,11 @@ async function freshConcat(file, segments, total, fps) { return got != null && Math.abs(got - total) <= 1.5 / fps; } +/** Does a recorded concat list name exactly these segments, in this order? */ +export function sameConcatList(recorded, segments) { + return recorded != null && recorded === concatListText(segments); +} + // ---- 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 @@ -2340,13 +2350,15 @@ export async function segmentOffsets(segments, D, fps) { /** * One entry's chapter name. * - * An authored `chapter` wins; then the entry's on-screen title, deck or not -- - * it was written for a viewer to read at that moment, which is what a chapter - * list is for; then the line derived from the record. + * An authored `chapter` wins; then, when the cut wears the deck, the entry's + * on-screen title -- the words the viewer was shown at that moment, which is + * what a chapter list is for; then the line derived from the record. Without + * the deck an `onscreen` value is not drawn, so it does not name a chapter + * either: a manifest without `render.chrome` builds exactly as it did. */ -export async function chapterTitle(entry, index, provenance) { +export async function chapterTitle(entry, index, provenance, { deck = false } = {}) { if (entry.chapter) return entry.chapter; - if (entry.onscreen?.title) return entry.onscreen.title; + if (deck && entry.onscreen?.title) return entry.onscreen.title; // A still's chapter is the SAME line it burns into the header, for the reason // a clip's is: the chapter list and the picture are two views of one cut, and // a viewer jumping by chapter should land on the words they were shown. @@ -2406,7 +2418,7 @@ export async function writeChromeSchedule({ manifest, entries, segments, D, outD return doc; } -async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps) { +async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false) { if (segments.length < 2) return; const { starts, total } = await segmentOffsets(segments, D, fps); const lines = [";FFMETADATA1", ""]; @@ -2420,7 +2432,7 @@ async function muxChapters(finalPath, entries, segments, D, outDir, provenance, "TIMEBASE=1/1000", `START=${Math.round(start * 1000)}`, `END=${Math.round(end * 1000)}`, - `title=${ffmetaEscape(await chapterTitle(entries[i], i, provenance))}`, + `title=${ffmetaEscape(await chapterTitle(entries[i], i, provenance, { deck }))}`, "", ); } @@ -2439,13 +2451,19 @@ async function muxChapters(finalPath, entries, segments, D, outDir, provenance, EMIT("chapters", { n: entries.length, file: path.basename(metaPath) }); } -async function concatHardCut(segments, outDir, outPath) { +/** + * The hard-cut concat's list, as written. 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. + */ +export const concatListText = (segments) => + segments.map((s) => `file '${path.resolve(s)}'`).join("\n") + "\n"; + +async function concatHardCut(segments, outDir, outPath, { record = false } = {}) { const listPath = path.join(outDir, "concat.txt"); - // 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 writeFile(listPath, concatListText(segments), "utf8"); await execFileP( FFMPEG, ["-nostdin", "-v", "error", "-y", "-f", "concat", "-safe", "0", @@ -2456,6 +2474,9 @@ async function concatHardCut(segments, outDir, outPath) { "-i", listPath, "-c", "copy", outPath], { maxBuffer: 1 << 24 }, ); + // The deck reuses this file across --chrome-only runs, so it records exactly + // which segments, in which order, it was made from (freshConcat reads it). + if (record) await writeFile(`${outPath}.segments`, concatListText(segments), "utf8"); } // A hard-cut concat and a crossfaded one are different lengths, so a cached @@ -2612,7 +2633,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly if (!(await exists(seg))) throw new Error(`--chapters-only needs ${seg}, which is missing — run a full build first`); } - await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps); + await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render)); return { out: finalPath, failures: [] }; } @@ -2644,7 +2665,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // applyRail re-encodes, so the chapters muxed onto the previous final are // gone. Put them back, or --rail-only quietly ships a chapterless cut. if (!opts.noChapters) { - await muxChapters(out, entries, segs, D, outDir, provenance, render.fps); + await muxChapters(out, entries, segs, D, outDir, provenance, render.fps, deckOn(render)); } } EMIT("done", { out, failures: [] }); @@ -2700,7 +2721,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly if (await freshConcat(prerail, segs, schedule.total, render.fps)) { EMIT("note", { message: `reusing ${path.basename(prerail)}` }); } else { - await concatHardCut(segs, outDir, prerail); + await concatHardCut(segs, outDir, prerail, { record: true }); } await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, dirs.final, render, plan, null); @@ -2709,7 +2730,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly } 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); + if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render)); EMIT("done", { out: dirs.final, failures: [] }); return { out: dirs.final, failures: [] }; } @@ -2842,7 +2863,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // 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 concatHardCut(segments, outDir, prerail, { record: true }); await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, final, render, chromePlan, null); } else { @@ -2860,7 +2881,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // shortest=1), and a hang means an unbounded -loop 1. if (railPlan) await assertConcatLength(final, railPlan.total, render.fps, "rail build"); - if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps); + if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render)); // A branded cut with a `thumbnail` gets one beside it. The cut is already // done, so a thumbnail that cannot be made is said, not thrown. diff --git a/umtool/report-to-video/deck-build.test.mjs b/umtool/report-to-video/deck-build.test.mjs @@ -6,8 +6,9 @@ // Run with: pnpm test:scripts import assert from "node:assert/strict"; import test from "node:test"; +import path from "node:path"; -import { chapterTitle, chromeRegions, deckFraming, deckFramingFilter } from "./build-video.mjs"; +import { chapterTitle, chromeRegions, concatListText, deckFraming, deckFramingFilter, sameConcatList } from "./build-video.mjs"; import { deckGeometry } from "./deck.mjs"; import { reservedFooterHeight } from "./render-cards.mjs"; @@ -77,17 +78,36 @@ test("chromeRegions: the chart band's branch is untouched", () => { ]); }); -test("chapterTitle: chapter, then the on-screen title, then the derived line", async () => { +test("chapterTitle: chapter, then (under the deck) the on-screen title, then the derived line", async () => { const PROV = { siteOrigin: "https://example.pages.dev", channelSlug: "chan" }; - // A clip with an on-screen title never reaches the metadata lookup, so this - // needs no cue source and no network. + const DECK = { deck: true }; + // A clip with an on-screen title under the deck never reaches the metadata + // lookup, so this needs no cue source and no network. const clip = { type: "clip", id: "c01", video: "abc", start: 1, end: 9, onscreen: { title: "County says yes" } }; - assert.equal(await chapterTitle(clip, 0, PROV), "County says yes"); - assert.equal(await chapterTitle({ ...clip, chapter: "Authored" }, 0, PROV), "Authored"); + assert.equal(await chapterTitle(clip, 0, PROV, DECK), "County says yes"); + assert.equal(await chapterTitle({ ...clip, chapter: "Authored" }, 0, PROV, DECK), "Authored"); const card = { type: "card", id: "t00", heading: "The heading", title: "The title" }; - assert.equal(await chapterTitle(card, 0, PROV), "The title"); - assert.equal(await chapterTitle({ ...card, onscreen: { title: "On screen" } }, 0, PROV), "On screen"); - assert.equal(await chapterTitle({ type: "card", id: "x" }, 4, PROV), "Card 5"); + assert.equal(await chapterTitle(card, 0, PROV, DECK), "The title"); + assert.equal(await chapterTitle({ ...card, onscreen: { title: "On screen" } }, 0, PROV, DECK), "On screen"); + assert.equal(await chapterTitle({ type: "card", id: "x" }, 4, PROV, DECK), "Card 5"); // A subtitle alone is not a title. - assert.equal(await chapterTitle({ ...card, onscreen: { subtitle: "only" } }, 0, PROV), "The title"); + assert.equal(await chapterTitle({ ...card, onscreen: { subtitle: "only" } }, 0, PROV, DECK), "The title"); + // Without the deck an on-screen title is not drawn, so it names no chapter: + // a manifest without render.chrome builds exactly as it did. + assert.equal(await chapterTitle({ ...card, onscreen: { title: "On screen" } }, 0, PROV), "The title"); + assert.equal(await chapterTitle({ ...card, onscreen: { title: "On screen" } }, 0, PROV, { deck: false }), "The title"); +}); + +test("sameConcatList: a cached hard-cut concat is reused only for the same segments in the same order", () => { + const segs = ["out/sourced/segments/c01.mp4", "out/sourced/segments/c02.mp4", "out/sourced/segments/c03.mp4"]; + const recorded = concatListText(segs); + assert.equal(sameConcatList(recorded, segs), true); + // Reordered: same files, same total length -- the case length and age miss. + assert.equal(sameConcatList(recorded, [segs[1], segs[0], segs[2]]), false); + assert.equal(sameConcatList(recorded, segs.slice(0, 2)), false); + assert.equal(sameConcatList(recorded, [...segs, "out/sourced/segments/c04.mp4"]), false); + // No record (a concat made before this check existed) is never reused. + assert.equal(sameConcatList(null, segs), false); + // Absolute and relative spellings of the same files are the same list. + assert.equal(sameConcatList(recorded, segs.map((x) => path.resolve(x))), true); });