Archilyzer · Source

archilyzer

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

commit 0a75caf1b84bb7e61704dd28dca175a9fef92a0d
parent caa1739f4c30674a15474ab1f1763db1ebfec5b5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 21:13:29 -0400

deck S1: frame every segment into the deck's footage box

With render.chrome's deck on, a clip, a still and a shown card are framed
into deckGeometry().footage (scale to fit, pad to the box, pad to the frame
at the box's origin in palette.bg), so the bottom deck.height rows are plain
ground for the overlay. The citation header, the corner QR, the ffmpeg
footer assets and the section marker are not drawn: the deck carries all of
them. A card under overCards "hide" stays full frame and encodes as before.

- deckFraming / deckFramingFilter: the filters, from deck.mjs's geometry.
- reservedFooterHeight: under the deck, the deck's height for "show", else 0.
- chromeRegions: the deck is one full-width region at the bottom.
- buildVideo refuses a render.chrome assertChrome refuses, before any fetch,
  and a deck build renders no footer and never takes the chromeEngine path.

Every manifest without render.chrome builds byte-for-byte as before.

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

Diffstat:
Mumtool/report-to-video/build-video.mjs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mumtool/report-to-video/render-cards.mjs | 9+++++++++
2 files changed, 139 insertions(+), 21 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 @@ -701,6 +739,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 +925,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 +943,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 +1007,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 +1053,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 +1108,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 +1712,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 +1754,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. @@ -2148,6 +2248,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 +2330,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}`); 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);