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:
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);
});