commit 195d8d13e5d110dadf0519374eafccd2569e4bb4
parent 0a75caf1b84bb7e61704dd28dca175a9fef92a0d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 21:13:29 -0400
deck S1: write the deck's schedule; chapters prefer the on-screen title
writeChromeSchedule probes the built segments (segmentOffsets), reads each
clip's source metadata (videoMeta, a clip that cannot be read gets null),
and writes deckSchedule's document to out/<variant>/schedule.json. A deck
build calls it after the segments and before the concat, and emits a
`chrome` event with phase "schedule". Composing, rendering and overlaying
the deck are not part of this change: a deck build is the framed footage.
A chapter is entry.chapter, else entry.onscreen.title, else the derived
line, whether or not the deck is on.
videoMeta and chapterTitle are exported; deck-build.test.mjs covers the
framing filters, reservedFooterHeight, chromeRegions and the chapter order.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 156 insertions(+), 3 deletions(-)
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -261,7 +261,7 @@ export function variantPaths(outRoot, slug, variant) {
// printed -- this is a formatting switch, not new instrumentation.
//
// Events: start, card, clip, fetch, snap, segment, entry-failed, concat,
-// chapters, note, done.
+// chapters, chrome, note, done.
const HUMAN = {
start: (e) => `${e.title} — ${e.entries} entr(ies)`,
card: (e) => `card ${e.id}`,
@@ -279,6 +279,11 @@ const HUMAN = {
"entry-failed": (e) => ` ** ${e.id} failed: ${e.message}`,
concat: (e) => `${e.mode === "xfade" ? "crossfading" : "hard-cutting"} ${e.n} segments…`,
chapters: (e) => `chapters: ${e.n} marker(s) -> ${e.file}`,
+ // The deck's steps. `phase` is schedule | compose | render | cached | overlay.
+ chrome: (e) =>
+ `chrome ${e.phase}` +
+ (e.segments !== undefined ? `: ${e.segments} segment(s)` : "") +
+ (e.total !== undefined ? `, ${Number(e.total).toFixed(3)}s` : ""),
note: (e) => e.message,
done: (e) =>
// A run that produced no file still emits `done` -- a consumer of the
@@ -326,7 +331,11 @@ function wrap(text, cols) {
// The published shard record carries the same fields as a local cue file, so this
// reads identically whichever source answered.
-async function videoMeta(videoId, channelSlug, hints = {}) {
+//
+// Exported for the deck's schedule writer and for umtool; it reads through the
+// cue source buildVideo() builds, so it answers only inside a build.
+export async function videoMeta(videoId, channelSlug, hints = {}) {
+ if (!CUES) throw new Error("videoMeta: no cue source — it is set up by buildVideo()");
const d = await CUES.load(channelSlug, videoId, hints);
// `channel` is the uploader's DISPLAY name, and it heads the attribution
// line. It costs nothing to carry: both sources -- a local
@@ -2140,8 +2149,16 @@ export async function segmentOffsets(segments, D, fps) {
return { ...scheduleFrom(durs, D), durs };
}
-async function chapterTitle(entry, index, provenance) {
+/**
+ * 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.
+ */
+export async function chapterTitle(entry, index, provenance) {
if (entry.chapter) return entry.chapter;
+ if (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.
@@ -2167,6 +2184,40 @@ async function chapterTitle(entry, index, provenance) {
}
}
+/**
+ * The deck's schedule, written down: `out/<variant>/schedule.json`.
+ *
+ * From the PROBED segment durations (segmentOffsets, the same sum the concat
+ * and the chapters use) and each clip's real source metadata, through
+ * deckSchedule -- so the composition's handovers land on the frames the concat
+ * actually cuts on. Never hand-written, and never estimated here: umtool's
+ * preview estimates, a build measures.
+ *
+ * A clip whose metadata cannot be read gets `null`, as its chapter does, and
+ * its subtitle falls back to what the entry itself says.
+ *
+ * @returns the schedule document (deck.mjs's shape)
+ */
+export async function writeChromeSchedule({ manifest, entries, segments, D, outDir }) {
+ const { render, provenance = {} } = manifest;
+ const { durs } = await segmentOffsets(segments, D, render.fps);
+ const metas = [];
+ for (const e of entries) {
+ if (e.type !== "clip") {
+ metas.push(null);
+ continue;
+ }
+ metas.push(
+ await videoMeta(e.video, e.channel ?? provenance.channelSlug, {
+ siteChannel: e.siteChannel, siteVideo: e.siteVideo,
+ }).catch(() => null),
+ );
+ }
+ const doc = deckSchedule({ entries, durs, D, render, provenance, metas });
+ await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n");
+ return doc;
+}
+
async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps) {
if (segments.length < 2) return;
const { starts, total } = await segmentOffsets(segments, D, fps);
@@ -2475,6 +2526,15 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
}
const final = dirs.final;
+
+ // The deck's schedule, from the segments just built. Written before the
+ // concat so a composition can be made from it; the overlay itself is a later
+ // step, and a build without one is the framed footage alone.
+ if (deck) {
+ const schedule = await writeChromeSchedule({ manifest, entries, segments, D, outDir });
+ EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length });
+ }
+
const railPlan = opts.noRail ? null : await buildRailPlan(manifest, render, entries, segments, D, outDir);
// The rendered chrome, if this manifest asks for it. Absent, `chromePlan` is
diff --git a/umtool/report-to-video/deck-build.test.mjs b/umtool/report-to-video/deck-build.test.mjs
@@ -0,0 +1,93 @@
+// Tests for the build's half of the deck: the framing filters, the regions the
+// overlay is placed by, the footer a card reserves, and the chapter name. The
+// geometry itself is deck.mjs's and is tested there; these check the build
+// reads it rather than restating it.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { chapterTitle, chromeRegions, deckFraming, deckFramingFilter } from "./build-video.mjs";
+import { deckGeometry } from "./deck.mjs";
+import { reservedFooterHeight } from "./render-cards.mjs";
+
+const PALETTE = { bg: "#15121c", fg: "#ece8f4", muted: "#9a93ad", accent: "#7c5cff", amber: "#f2b84b" };
+const BASE = { width: 1920, height: 1080, fps: 30, palette: PALETTE };
+const deck = (d = {}) => ({ ...BASE, chrome: { engine: "hyperframes", layout: "deck", deck: d } });
+
+test("deckFraming: the default box is 1574x886 at (173,2), the rest is ground", () => {
+ const f = deckFraming(deck());
+ assert.deepEqual(f.box, { x: 173, y: 2, width: 1574, height: 886 });
+ assert.deepEqual(f.fit, [
+ "scale=1574:886:force_original_aspect_ratio=decrease",
+ `pad=1574:886:(ow-iw)/2:(oh-ih)/2:color=${PALETTE.bg}`,
+ ]);
+ assert.deepEqual(f.place, [`pad=1920:1080:173:2:color=${PALETTE.bg}`, "setsar=1", "fps=30"]);
+});
+
+test("deckFraming: follows deckGeometry for other settings, never its own numbers", () => {
+ const r = deck({ height: 240, footageScale: 0.7 });
+ const g = deckGeometry(r);
+ const f = deckFraming(r);
+ assert.deepEqual(f.box, g.footage);
+ assert.equal(f.place[0], `pad=1920:1080:${g.footage.x}:${g.footage.y}:color=${PALETTE.bg}`);
+ // The bottom of the box sits at or above the deck's top edge.
+ assert.ok(g.footage.y + g.footage.height <= g.deck.y);
+});
+
+test("deckFramingFilter: fit then place, one chain", () => {
+ assert.equal(
+ deckFramingFilter(deck()),
+ [
+ "scale=1574:886:force_original_aspect_ratio=decrease",
+ `pad=1574:886:(ow-iw)/2:(oh-ih)/2:color=${PALETTE.bg}`,
+ `pad=1920:1080:173:2:color=${PALETTE.bg}`,
+ "setsar=1",
+ "fps=30",
+ ].join(","),
+ );
+});
+
+test("reservedFooterHeight: the deck reserves nothing over hidden cards, its height over shown ones", () => {
+ assert.equal(reservedFooterHeight(deck()), 0);
+ assert.equal(reservedFooterHeight(deck({ overCards: "hide" })), 0);
+ assert.equal(reservedFooterHeight(deck({ overCards: "show" })), 190);
+ assert.equal(reservedFooterHeight(deck({ overCards: "show", height: 240 })), 240);
+});
+
+test("reservedFooterHeight: without a deck, unchanged", () => {
+ assert.equal(reservedFooterHeight(BASE), 100);
+ assert.equal(reservedFooterHeight({ ...BASE, footerHeight: 92 }), 92);
+ assert.equal(reservedFooterHeight({ ...BASE, chromeEngine: "hyperframes" }), 200);
+ assert.equal(reservedFooterHeight({ ...BASE, chromeEngine: "hyperframes", chart: { height: 240 } }), 240);
+});
+
+test("chromeRegions: the deck is one full-width region at the bottom", () => {
+ assert.deepEqual(chromeRegions(deck(), "/o/sourced"), [
+ { name: "deck", frames: "/o/sourced/chrome/deck-frames", x: 0, y: 890, width: 1920, height: 190 },
+ ]);
+ assert.deepEqual(chromeRegions(deck({ height: 240 }), "/o")[0], {
+ name: "deck", frames: "/o/chrome/deck-frames", x: 0, y: 840, width: 1920, height: 240,
+ });
+});
+
+test("chromeRegions: the chart band's branch is untouched", () => {
+ assert.deepEqual(chromeRegions({ ...BASE, chromeEngine: "hyperframes" }, "/o"), [
+ { name: "chart", frames: "/o/chrome/chart-frames", x: 0, y: 880, width: 1920, height: 200 },
+ ]);
+});
+
+test("chapterTitle: chapter, then 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 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");
+ 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");
+ // A subtitle alone is not a title.
+ assert.equal(await chapterTitle({ ...card, onscreen: { subtitle: "only" } }, 0, PROV), "The title");
+});