Archilyzer · Source

archilyzer

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

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:
Mumtool/report-to-video/build-video.mjs | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Aumtool/report-to-video/deck-build.test.mjs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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"); +});