Archilyzer · Source

archilyzer

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

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:
Mumtool/report-to-video/build-video.mjs | 217++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Aumtool/report-to-video/deck-build.test.mjs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/render-cards.mjs | 9+++++++++
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);