commit b50e90429d315c8094392fd4790a1da17d7f1609
parent fd28ef5edcaba12497e19b3c66ff22ec1b23fc72
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 21:41:21 -0400
Merge deck/s1-pipeline (deck slice S1) — every segment framed into the deck's footage box (clip, image, shown cards), header/corner QR/footer skipped under the deck, reservedFooterHeight and chromeRegions deck branches, assertChrome at build start, writeChromeSchedule, chapters prefer onscreen.title; byte-identical without render.chrome (c07, t00 md5); reviewed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 295 insertions(+), 24 deletions(-)
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -64,7 +64,10 @@ import {
cardWidth, contentWidth, reservedFooterHeight,
} from "./render-cards.mjs";
import { createCueSource, siteOriginFromManifest } from "./cues.mjs";
-import { scheduleFrom } from "./deck.mjs";
+// The deck (`render.chrome`): its geometry, validation and schedule are pure
+// and live in deck.mjs. This file only frames segments into its box and writes
+// the schedule down -- it never has a copy of the arithmetic.
+import { assertChrome, deckGeometry, deckOn, deckSchedule, resolveDeck, scheduleFrom } from "./deck.mjs";
// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table,
// in common, plain JS so bare `node` can load it.
import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs";
@@ -196,6 +199,41 @@ export function headerFilters(render, attribPath, channelPath = null) {
}
/**
+ * The deck's framing, as two runs of filters.
+ *
+ * `fit` scales a picture into the footage box (`deckGeometry().footage`),
+ * keeping its aspect, and pads it to the box in the palette background. `place`
+ * pads the box out to the whole frame at the box's own origin, so the bottom
+ * `deck.height` rows -- where the composition is overlaid -- are plain ground,
+ * then ends the way every segment ends: `setsar=1` and the cut's fps. xfade
+ * refuses a link whose parameters differ from its neighbour's, and that would
+ * surface only at concat time, after every fetch has been paid for.
+ *
+ * Two runs, not one string, because a still lays itself out INTO the box (a
+ * row of panels, a crawl) and only needs the second half.
+ *
+ * Pure, and exported, so the numbers can be tested without an encoder.
+ */
+export function deckFraming(render) {
+ const { W, H, footage: f } = deckGeometry(render);
+ const bg = render.palette.bg;
+ return {
+ box: f,
+ fit: [
+ `scale=${f.width}:${f.height}:force_original_aspect_ratio=decrease`,
+ `pad=${f.width}:${f.height}:(ow-iw)/2:(oh-ih)/2:color=${bg}`,
+ ],
+ place: [`pad=${W}:${H}:${f.x}:${f.y}:color=${bg}`, "setsar=1", `fps=${render.fps}`],
+ };
+}
+
+/** `deckFraming`'s two runs joined: a whole picture into the box, then the frame. */
+export function deckFramingFilter(render) {
+ const { fit, place } = deckFraming(render);
+ return [...fit, ...place].join(",");
+}
+
+/**
* Where a variant's own working files live.
*
* `clips-raw` stays at the ROOT and is shared: it holds the only expensive
@@ -223,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}`,
@@ -241,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
@@ -288,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
@@ -701,6 +748,28 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes,
const channelPath = who ? path.join(outDir, "segments", `${entry.id}.channel.txt`) : null;
if (channelPath) await writeFile(channelPath, who, "utf8");
+ // THE DECK. The citation header, the corner QR and the section footer are
+ // all things the deck says instead -- the source and date in its subtitle,
+ // the code in its QR, the position in its pips -- so none of them is drawn,
+ // and the segment is the picture alone, framed into the box above the deck.
+ // The composition is overlaid on the whole concat later; nothing here knows
+ // about it. Same cut, same audio map, same encode as every other segment.
+ if (deckOn(render)) {
+ await execFileP(
+ FFMPEG,
+ [
+ "-nostdin", "-v", "error", "-y",
+ ...cutArgs(raw, cutA, cutB),
+ "-filter_complex", `[0:v]${deckFramingFilter(render)}[v]`,
+ "-map", "[v]", "-map", "0:a",
+ ...encodeArgs(render),
+ seg,
+ ],
+ { maxBuffer: 1 << 24 },
+ );
+ return seg;
+ }
+
// The picture is the point. Nothing is drawn over it: the video is letterboxed
// between a thin citation header and a thin timeline footer, so the source
// material plays unobstructed and the additions stay subtle.
@@ -865,6 +934,13 @@ async function buildCardSegment(card, render, outDir, nodes) {
const png = await renderCard(card, render, outDir, nodes);
const seg = path.join(outDir, "segments", `${card.id}.mp4`);
const dur = String(card.seconds);
+ // Under the deck a card is either full frame -- `overCards: "hide"`, the
+ // deck slides away over it, and the card encodes exactly as it always has --
+ // or framed into the footage box like a clip, so the deck can stay up over
+ // it without covering its bottom rows.
+ const vf = deckOn(render) && resolveDeck(render).overCards === "show"
+ ? deckFramingFilter(render)
+ : `fps=${render.fps},setsar=1`;
await execFileP(
FFMPEG,
@@ -876,7 +952,7 @@ async function buildCardSegment(card, render, outDir, nodes) {
"-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", png,
"-f", "lavfi", "-t", dur,
"-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`,
- "-vf", `fps=${render.fps},setsar=1`,
+ "-vf", vf,
...encodeArgs(render),
"-shortest",
seg,
@@ -940,10 +1016,14 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
throw new Error(`${entry.id}: seconds must be a positive number, got ${entry.seconds}`);
}
+ // Under the deck the picture area is the deck's footage box -- the box a clip
+ // is framed into, so a still between two clips does not move either -- and
+ // there is no header, footer or corner code: the deck carries all three.
+ const deck = deckOn(render) ? deckFraming(render) : null;
const HH = render.headerHeight ?? 56;
- const VW = contentWidth(render);
+ const VW = deck ? deck.box.width : contentWidth(render);
const FH = chrome.footerHeight;
- const VH = height - HH - FH;
+ const VH = deck ? deck.box.height : height - HH - FH;
// Each picture carries its OWN redactions and crop, measured in its own
// source pixels -- a panel's boxes were taken off that file in an image
@@ -982,7 +1062,7 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
// the entry alone. Nothing to say means no header at all: drawtext refuses an
// empty textfile outright.
const line = imageAttributionLine(entry);
- const hasHeader = HH > 0 && line.length > 0;
+ const hasHeader = !deck && HH > 0 && line.length > 0;
const attribPath = path.join(outDir, "segments", `${entry.id}.attrib.txt`);
if (hasHeader) await writeFile(attribPath, line, "utf8");
@@ -1037,20 +1117,25 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
];
}
- const base = [
- ...head,
- // Widen back to the full frame, leaving the rail column (if any) as ground.
- `pad=${width}:${VH}:0:0:color=${pal.bg}`,
- `pad=${width}:${height}:0:${HH}:color=${pal.bg}`,
- "setsar=1",
- `fps=${render.fps}`,
- ...(hasHeader ? headerFilters(render, attribPath) : []),
- ].join(",");
+ const base = (
+ deck
+ ? [...head, ...deck.place]
+ : [
+ ...head,
+ // Widen back to the full frame, leaving the rail column (if any) as ground.
+ `pad=${width}:${VH}:0:0:color=${pal.bg}`,
+ `pad=${width}:${height}:0:${HH}:color=${pal.bg}`,
+ "setsar=1",
+ `fps=${render.fps}`,
+ ...(hasHeader ? headerFilters(render, attribPath) : []),
+ ]
+ ).join(",");
// `qrForEntry` already prefers `citeUrl`; the guard is that we never reach it
- // without one, so no still can be given a derived code.
+ // without one, so no still can be given a derived code. Under the deck the
+ // deck shows a still's code (the same `citeUrl` rule, deckQrUrl).
const qr =
- render.qr === false || render.rail || !entry.citeUrl
+ deck || render.qr === false || render.rail || !entry.citeUrl
? null
: await qrForEntry(entry, provenance, render, outDir);
const qrM = render.qr?.margin ?? 28;
@@ -1636,8 +1721,16 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
* track only moved at section handovers, which is precisely the fault the band
* exists to fix. So it takes the footer's ground and 100px more of it, and the
* picture loses that height.
+ *
+ * The deck is one region, full width, at the bottom of the frame -- where its
+ * framing left plain ground (`deckGeometry().deck`). It replaces the chart
+ * band's branch rather than joining it: the deck refuses `chromeEngine`.
*/
export function chromeRegions(render, outDir) {
+ if (deckOn(render)) {
+ const { deck } = deckGeometry(render);
+ return [{ name: "deck", frames: path.join(outDir, "chrome", "deck-frames"), ...deck }];
+ }
const H = render.chart?.height ?? 200;
return [
{
@@ -1670,6 +1763,22 @@ function reservedFooter(render) {
}
/**
+ * The footer's stand-in under the deck: none at all.
+ *
+ * The deck draws the position in the cut itself (its pips), and the segments
+ * frame into the deck's own box rather than letterboxing above a footer, so
+ * nothing reserves the footer's rows -- `footerHeight: 0` -- and nothing of the
+ * ffmpeg footer is rendered. The shape is reservedFooter()'s, and the one
+ * renderFooterAssets returns for a manifest with no nodes.
+ */
+function deckFooter() {
+ return {
+ footer: null, marker: null, bar: null, trackLen: 0,
+ footerHeight: 0, trackY: 0, xs: [], x0: 0, markerRadius: 0,
+ };
+}
+
+/**
* Everything the rail chain needs that depends on the built segments. Returns
* null when the manifest does not ask for a rail — which is what keeps this
* whole feature opt-in and every existing report byte-for-byte unchanged.
@@ -2040,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.
@@ -2067,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);
@@ -2148,6 +2299,10 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const whole = JSON.parse(await readFile(manifestPath, "utf8"));
const manifest = selectVariant(whole, variant);
const { render, provenance } = manifest;
+ // A `render.chrome` that cannot be built is refused here, before a single
+ // fetch is spent. Absent, validateChrome has nothing to say.
+ if (render.chrome !== undefined && render.chrome !== null) assertChrome(render.chrome, render);
+ const deck = deckOn(render);
// An `image` entry's `src` is relative to the MANIFEST, which is checked in
// beside the pictures it cites -- not to the cwd the build was started from.
const manifestDir = path.dirname(path.resolve(manifestPath));
@@ -2226,11 +2381,16 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
return { out: r.out, failures: [] };
}
- // Footer chrome is shared by every clip, so build it once up front.
- const hyper = render.chromeEngine === "hyperframes";
- const chrome = hyper
- ? reservedFooter(render)
- : await renderFooterAssets(render, manifest.timelineNodes, outDir);
+ // Footer chrome is shared by every clip, so build it once up front. The deck
+ // has none: it is the chrome. (`hyper` is the chart band's legacy switch,
+ // which assertChrome already refuses beside a deck; the `!deck` says so here
+ // too.)
+ const hyper = !deck && render.chromeEngine === "hyperframes";
+ const chrome = deck
+ ? deckFooter()
+ : hyper
+ ? reservedFooter(render)
+ : await renderFooterAssets(render, manifest.timelineNodes, outDir);
const entries = manifest.timeline.filter((e) => !only || e.id === only);
if (only && !entries.length) throw new Error(`no timeline entry with id ${only}`);
@@ -2366,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");
+});
diff --git a/umtool/report-to-video/render-cards.mjs b/umtool/report-to-video/render-cards.mjs
@@ -37,6 +37,7 @@ import { dateKey, ledgerTotals, rosterLine } from "./ledger-totals.mjs";
import { brandFaces, brandManifest, brandSvgFace, childOpts } from "./brand.mjs";
import { BRAND_CARD_STYLES, renderBrandCard } from "./brand-cards.mjs";
import { FIRA_SANS, textWidth } from "./svg-faces.mjs";
+import { deckOn, resolveDeck } from "./deck.mjs";
const execFileP = promisify(execFile);
@@ -526,8 +527,16 @@ export function railGeometry(render, nClaims) {
* of which are wrong the moment the band takes 200. The symptom is a card that
* looks finished in isolation and has its last two lines sitting under the
* chart in the cut.
+ *
+ * Under the deck a card either has the whole frame (`overCards: "hide"` -- the
+ * deck slides away over it) or leaves the deck's height free at the bottom
+ * (`"show"`).
*/
export function reservedFooterHeight(render) {
+ if (deckOn(render)) {
+ const deck = resolveDeck(render);
+ return deck.overCards === "show" ? deck.height : 0;
+ }
return render.chromeEngine === "hyperframes"
? (render.chart?.height ?? 200)
: (render.footerHeight ?? 100);