commit a2d73bb43e3df05b7054324aba47c55713ac003c
parent acd83ceb04369765701f07551d23d2de87643e1b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 21:03:39 -0400
deck S0: the on-screen deck's contract and pure core
plans/onscreen-deck.md fixes the manifest vocabulary (render.chrome, per-entry
onscreen), the schedule.json shape, the composition and build contracts and the
umtool writers/routes the later slices code against.
deck.mjs is the pure half every slice shares: deckOn/resolveDeck, validateChrome
(unknown keys, rail/chromeEngine beside it, footage that will not fit),
normalizeOnscreen, deckGeometry/deckLayout, pipXs, scheduleFrom (segmentOffsets
now calls it), estimatedDuration/estimateSchedule/deckSchedule, deckText and
deckQrUrl, deckChoreography, chromeCacheKey and the pinned hyperframesCommand.
attribution.mjs gains attributionParts (attributionLine is rebuilt on it, output
unchanged), formatDeckDate and deckSubtitle. test:scripts 211/211.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 1094 insertions(+), 12 deletions(-)
diff --git a/plans/onscreen-deck.md b/plans/onscreen-deck.md
@@ -0,0 +1,240 @@
+# On-screen deck for report videos
+
+A persistent bottom panel ("the deck") for a report cut, rendered as ONE HyperFrames
+composition over the whole concat: a pip timeline (one subtle, unlabelled pip per
+clip), a big authored title per clip, an automatic source-and-date subtitle, and the
+clip's QR. It replaces the old chrome for a cut that opts in — the citation header,
+the corner QR and the ffmpeg section footer — and the footage is scaled to ~82 %
+above it. At every clip change the pip travels and the title, subtitle and QR hand
+over to the next clip's.
+
+Opt-in per manifest (`render.chrome`). Every manifest without it builds byte-for-byte
+as before. Generic, not tied to any one report. Two ways to edit: umtool's
+On-screen section, or an agent editing the manifest.
+
+First application: the ferret-rescue report (`~/reports/ferret-rescue`).
+
+## Slices
+
+```
+S0 ─┬─> S1 ─┬─> S3 ─┬─> S6 ─> S7
+ │ └─> S4 ─┼─> S5 ─┘
+ └─> S2 ─────────┘
+S3 ─> S8
+```
+
+| Slice | What | Owns (files) |
+|---|---|---|
+| S0 | This contract; `deck.mjs` + `deck.test.mjs`; `attributionParts` / `deckSubtitle` / `formatDeckDate`; `segmentOffsets` on `scheduleFrom` | `report-to-video/deck*.mjs`, `attribution.mjs` |
+| S1 | Pipeline: deck framing for clip/image/card segments, chrome skipped when `deckOn`, schedule writer, chapters, `assertChrome` at build start, `reservedFooterHeight`/`chromeRegions` deck branches | `build-video.mjs` (segment builders, chapters, helpers), `render-cards.mjs` |
+| S2 | Composition: `chrome-deck.mjs` + the compose-chrome port (region dispatch, `--still`, `--from`, workers/quality/format, vendored GSAP, QR, version pin, render cache) | `compose-chrome.mjs`, `chrome-deck.mjs`, `report-to-video/assets/` |
+| S3 | Build integration: one command composes + renders + overlays; transition-0 `applyChrome`; `--chrome-only` / `--no-chrome` / `--chrome-preview`; driver `chromeOnly` | `build-video.mjs` (`buildVideo`, concat), `umtool/lib/report/driver.mjs` |
+| S4 | umtool writers and routes | `umtool/lib/report/{manifest,serve}.mjs`, `umtool/app/api/report/{window,chrome,still,video}` |
+| S5 | umtool UI | `umtool/components/projects/OnscreenSection.tsx`, `ReportProject.tsx`, `ClipBench.tsx` |
+| S6 | e2e | `umtool/e2e/onscreen.spec.ts`, fixture stubs |
+| S7 | Docs | `umtool/report-to-video/README.md`, `umtool/docs/report-video.md`, quirks |
+| S8 | First application | the ferret manifest (outside the repo) |
+
+## S0 contract
+
+Everything below is binding on the slices. A slice that needs to change it says so in
+its report; it does not quietly diverge.
+
+### Manifest: `render.chrome`
+
+```jsonc
+"render": {
+ "chrome": {
+ "engine": "hyperframes", "layout": "deck",
+ "deck": { // every key optional; defaults shown
+ "height": 190, // px, integer 120–400
+ "footageScale": 0.82, // 0.5–1, and the footage must fit above the deck
+ "background": "panel", // "panel" (lifted) | "flush"
+ "pip": { "spacing": "even", "size": 6, "activeSize": 12 }, // spacing "even" | "time"
+ "title": { "size": 54, "maxChars": 48 }, // maxChars is the editor's counter, not a refusal
+ "subtitle": { "parts": "auto", "dateFormat": "long" }, // parts "auto" | ["channel","title","date","clock"]; "long" = "Aug 14, 2026" | "iso"
+ "qr": { "show": true, "size": 150 }, // 80–380 and ≤ height − 20
+ "overCards": "hide", // "hide" | "show"
+ "motion": { "out": 0.3, "in": 0.45, "pip": 0.7 } // seconds
+ }
+ }
+}
+```
+
+- `deckOn(render)` is the ONE switch. `validateChrome(chrome, render)` returns
+ sentences; `assertChrome` throws them. Unknown keys are refused. A deck beside
+ `render.rail` or the legacy `render.chromeEngine` is refused.
+- `resolveDeck(render)` fills defaults (one level deep). Nothing else hardcodes a default.
+
+### Manifest: per-entry `onscreen`
+
+```jsonc
+{ "type": "clip", "id": "c04", …, "onscreen": { "title": "County approves pre-application",
+ "subtitle": "…optional override…" } }
+```
+
+- On any entry (clip, image, card). Nested because `entry.title` already means the
+ stream title in headers and chapters.
+- `normalizeOnscreen(v)` trims, drops empty strings, and returns `null` for nothing
+ left. A writer stores `null` by DELETING the key. One line each, ≤ 200 chars.
+- Deck title: `onscreen.title`, else (cards only) `heading`, else empty.
+- Deck subtitle: `onscreen.subtitle`, else auto — `deckSubtitle(attributionParts(…))`
+ for a clip, `title · date` for an image, `sub` for a card. The channel is in the
+ auto subtitle only when `isMultiChannel` (more than one clip channel slug).
+- QR: `deckQrUrl(entry, provenance)`. A clip's is the corner QR's rule unchanged
+ (`citeUrl`, else the site link at `floor(start)`); an image only with `citeUrl`;
+ a card never. `qr.show: false` makes every one null.
+
+### `deck.mjs` (pure; no fs, no ffmpeg, no network)
+
+| Export | Signature → result |
+|---|---|
+| `deckOn` | `(render) → boolean` |
+| `resolveDeck` | `(render) → deck settings, defaults filled` |
+| `validateChrome` / `assertChrome` | `(chrome, render) → string[]` / throws |
+| `normalizeOnscreen` | `(v) → {title?, subtitle?} \| null`, throws on bad shape |
+| `deckGeometry` | `(render) → {W, H, footage:{x,y,width,height}, deck:{x,y,width,height}}` — 1920×1080 defaults: footage 1574×886 at (173,2), deck 1920×190 at (0,890) |
+| `deckLayout` | `(render) → {width, height, pipTrack:{x0,x1,y}, text:{x,width,titleSize,subtitleSize}, qr:{x,y,size}\|null}` region-local; S2 may tune the numbers HERE |
+| `pipXs` | `(n, x0, x1, spacing="even", schedule?) → number[]` |
+| `scheduleFrom` | `(durs, D) → {starts, total}` — THE arithmetic; `segmentOffsets` calls it |
+| `estimatedDuration` | `(entry, render) → seconds` |
+| `transitionOf` | `(render, {noXfade}) → D` |
+| `isMultiChannel`, `hidesDeck`, `deckText`, `deckQrUrl` | as above |
+| `deckSchedule` | `({entries, durs, D, render, provenance, metas, estimated}) → schedule doc` |
+| `estimateSchedule` | `(variantManifest, {metas, noXfade}) → schedule doc, estimated:true` |
+| `deckChoreography` | `(schedule, render) → {handovers, visibility}` (absolute seconds) |
+| `pipSegments` | `(schedule) → segments shown with the deck` |
+| `chromeCacheKey` | `({html, assets:[[name, sha256]], fps, frames, version}) → hex` |
+| `frameCount` | `(total, fps) → Math.round(total*fps)` |
+| `hyperframesCommand` | `(env) → {cmd, args, version}`; `HYPERFRAMES_BIN` wins, else `npx --yes ${HYPERFRAMES_PKG ?? "hyperframes@0.8.24"}` |
+
+### `out/<variant>/schedule.json` when the deck is on
+
+Written by the build (S1's `writeChromeSchedule`) from PROBED segment durations and
+real source metadata; never hand-written. Same shape from `estimateSchedule` with
+`estimated: true`.
+
+```jsonc
+{ "version": 1, "kind": "deck", "estimated": false,
+ "fps": 30, "transition": 0.5, "total": 351.233, "multiChannel": false,
+ "segments": [
+ { "id": "c01", "type": "clip", "start": 0, "duration": 19.1, "end": 19.1,
+ "title": "…", "subtitle": "… · Sep 3, 2024", "qrUrl": "https://…", "hideDeck": false } ] }
+```
+
+### Choreography (from `deckChoreography`)
+
+With m = `segments[i].start + D/2` (= the cut for D = 0):
+old title wipes out over [m − out, m]; new title expands over [m, m + in] (power3.out);
+subtitle trails by 0.08 s; an accent underline grows with the title; the QR flips
+through [m − 0.125, m + 0.125]; the pip marker eases from i−1 to i over
+[m − pip/2, m + pip/2]; past pips are filled; a progress fill runs with the clock.
+Into a hidden segment the deck slides down over [start, start + max(D, in)] (hard cut:
+finishing at the cut), back up the same way after it. No text handover across a
+hidden segment. Every segment owns its own DOM nodes — nothing is swapped at runtime.
+
+### The composition (S2)
+
+- `composeChrome({ manifestPath, outDir, variant, region: "deck", schedule?, preview?,
+ doRender, fps, workers, quality, format, still, png, from, duration })
+ → { projDir, frames, still, cached, key, frameCount }`.
+ `schedule` (an object) overrides reading `out/<variant>/schedule.json` — umtool's
+ preview passes an estimate or a draft.
+- Render project: `out/<variant>/chrome/deck/` (`index.html`, `assets/`,
+ `hyperframes.json`). Frames: `out/<variant>/chrome/deck-frames/frame_%06d.png`
+ plus `deck-frames/.key` holding `chromeCacheKey`. A render is SKIPPED when the key
+ matches and the frame count is `frameCount(total, fps)`.
+- Preview project (no render, umtool): `out/<variant>/chrome/deck-preview/`, so a
+ preview never disturbs a build's project or cache.
+- Region: 1920 × `deck.height` (region-local), transparent outside the panel; overlaid
+ at (0, H − height). Root `data-composition-id="deck"`, `data-width`, `data-height`,
+ `data-duration = total`. One paused `window.__timelines.deck`.
+- Fonts copied into `assets/` as private families `DeckSans` / `DeckSansBold`
+ (from `render.fontRegular` / `render.fontBold`), fallback `sans-serif` only.
+ GSAP vendored at `report-to-video/assets/gsap.min.js` — no CDN, in this region or
+ the chart band.
+- `?still=<t>`: seek to t and hold. `?preview=1`: listen for `postMessage`
+ `{type:"deck:seek", t}` and `{type:"deck:text", id, title?, subtitle?}` (patch that
+ segment's nodes, refit), and post `{type:"deck:ready", total, ids}` to the parent.
+ DOM per segment: `[data-seg="<id>"]` holding `.deck-title`, `.deck-sub`,
+ `.deck-qr img`.
+- Text fit: at load, shrink a title's font until it fits the text column (floor 60 %
+ of `title.size`), then ellipsize.
+- Stills: system chromium (`CHROME` env, default `/usr/bin/chromium`), headless
+ screenshot of `index.html?still=t`. CLI: `compose-chrome.mjs <manifest> --region deck
+ [--still <t> --png <path>] [--render] [--workers 4] [--quality high] [--format png-sequence]
+ [--from <s>] [--duration <s>] [--variant v]`.
+
+### The build (S1 + S3)
+
+When `deckOn(render)`:
+1. `assertChrome` before any fetch.
+2. Segments framed into `deckGeometry().footage`: `scale…,pad` into the footage box,
+ then `pad` to W×H in `palette.bg`. No header, no corner QR, no ffmpeg footer
+ assets, no section animation. Cards: full frame when `overCards: "hide"`, else
+ framed like footage. `buildImageSegment` uses the same box.
+3. `writeChromeSchedule()` → `out/<variant>/schedule.json` (above), from
+ `segmentOffsets` durations and `videoMeta` metadata.
+4. `composeChrome({ region: "deck", doRender: true })` by dynamic import (cached).
+5. Concat: transition > 0 → `concatWithXfade` with `chromeOverlayChain`;
+ transition 0 → hard-cut concat to the `prerail-hardcut` file, then ONE overlay
+ re-encode (`applyChrome`, the diet fork's). This replaces the refusal.
+6. Chapters: `entry.chapter`, else `onscreen.title`, else today's fallback.
+7. `chromeRegions(render, outDir)` deck branch → `[{ name: "deck", frames:
+ chrome/deck-frames, x: 0, y: H − height, width: W, height }]`;
+ `reservedFooterHeight` deck branch → `overCards === "show" ? height : 0`.
+
+Flags: `--chrome-only` (segments must exist: re-probe, re-write the schedule,
+recompose, re-render if the key changed, re-concat with the overlay, re-mux chapters —
+no segment is rebuilt), `--no-chrome` (deck framing, no overlay), `--chrome-preview
+<at> <dur>` (a short window to `<slug>.preview.mp4`). NDJSON: `chrome` events with
+`phase: "schedule" | "compose" | "render" | "cached" | "overlay"`.
+
+Driver: `buildSteps(…, { options: { chromeOnly: true } })` → `--chrome-only`,
+labelled "re-render on-screen".
+
+### umtool (S4 + S5)
+
+Writers (all through `withManifestLock` / `backupOnce` / `writeManifestAtomic` and the
+stale-token guard):
+- `updateClip` accepts `onscreen` (normalised; `null`/empty deletes the key).
+- `updateOnscreen(dir, { [entryId]: {title?, subtitle?} | null }, { token })` — any
+ entry type; unknown id refuses the whole batch.
+- `updateChrome(dir, chrome | null, { token })` — `validateChrome`; `null` removes
+ `render.chrome`.
+
+Routes (`ctx.params` is a Promise in this Next):
+- `PUT /api/report/window` — whitelist gains `onscreen`.
+- `PUT /api/report/chrome` — `{ project, chrome, token }`.
+- `PUT /api/report/onscreen` — `{ project, onscreen: {id: {...}}, token }`.
+- `POST /api/report/chrome/preview` — `{ project, variant?, draft? }` → composes
+ `deck-preview` with the real schedule if one exists (draft `onscreen` overlaid),
+ else an estimate; NO render. Returns `{ src, geometry, layout, schedule }`.
+- `GET /api/report/chrome/files/[...path]` — serves the deck-preview composition dir,
+ traversal-guarded.
+- `GET /api/report/still?project&variant&(clip|at)` — a true still PNG.
+- `GET /api/report/video?project&variant&kind=final|preview` — range-served mp4;
+ the range code becomes `rangeResponse()` in `lib/report/serve.mjs`, shared with the
+ segment route.
+
+UI: "On-screen" section on the report project page (between the build chain and
+Deliver; NOT "deck", which is the song kind's name) — enable toggle + settings, the
+title/subtitle table (auto subtitle as placeholder, `maxChars` counter, one save), a
+16:9 live preview (the composition iframe at the deck rect over a still) with a
+scrubber, True still, Re-render on-screen, and the final video. The clip bench gets
+On-screen title/subtitle fields beside ATTRIB with a live deck preview fed by
+`postMessage`.
+
+## Verification gates
+
+- `pnpm test:scripts` (deck, attribution and brand tests unchanged and green).
+- Byte-identical: a manifest without `render.chrome` builds `--only <clip>
+ --skip-fetch` to the same segment md5 before and after.
+- `pnpm --filter umtool typecheck` and a capped `pnpm --filter umtool build` with the
+ corpus linked.
+- umtool e2e through its own filter, detached:
+ `SONG_DIR=~/reports/quartering-uh-song/data pnpm --filter umtool run e2e onscreen.spec.ts clip-bench.spec.ts build.spec.ts projects.spec.ts`.
+- First application: build + `verify-build.mjs` exit 0; stills at every handover
+ m ± 0.2 s and mid-clip; `zbarimg` on each mid-clip QR decodes that clip's URL;
+ ffprobe chapters equal the on-screen titles; an edit-one-title `--chrome-only`
+ round trip re-renders the deck only.
diff --git a/umtool/report-to-video/attribution.mjs b/umtool/report-to-video/attribution.mjs
@@ -115,13 +115,71 @@ export function channelName(entry, meta = {}, provenance = {}) {
* @param {{ channel?: string|null, channelSlug?: string|null }} [provenance]
*/
export function attributionLine(entry, meta, provenance = {}) {
- const who = channelName(entry, meta, provenance);
- const title = entry.title ?? cleanTitle(meta.title);
- const date = entry.date ?? uploadDateToIso(meta.uploadDate);
+ const { channel, title, date, at } = attributionParts(entry, meta, provenance);
// A cut with no channel to name anywhere reproduces the old line exactly,
// rather than opening on a stray separator.
- const head = [who, title].filter((s) => String(s ?? "").length > 0).join(" · ");
- return `${head} · ${date} @ ${hms(entry.cite ?? entry.start ?? 0)}`;
+ const head = [channel, title].filter((s) => String(s ?? "").length > 0).join(" · ");
+ return `${head} · ${date} @ ${hms(at)}`;
+}
+
+/**
+ * The header line's four facts, resolved and NOT joined: who, which recording,
+ * when, and the cited second. `attributionLine` joins them for the burned-in
+ * header; the deck's subtitle (`deckSubtitle`) picks from them. One resolver,
+ * so the two can never disagree about a clip's date or channel.
+ *
+ * @returns {{ channel: string, title: string, date: string, at: number }}
+ */
+export function attributionParts(entry, meta, provenance = {}) {
+ return {
+ channel: channelName(entry, meta, provenance),
+ title: entry.title ?? cleanTitle(meta.title),
+ date: entry.date ?? uploadDateToIso(meta.uploadDate),
+ at: entry.cite ?? entry.start ?? 0,
+ };
+}
+
+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+/**
+ * A `YYYY-MM-DD` as the deck prints it. "long" is `Aug 14, 2026` -- spelled
+ * out by hand, not by Intl, so a render does not depend on the machine's
+ * locale. Anything that is not a real calendar date passes through unchanged.
+ */
+export function formatDeckDate(d, format = "long") {
+ const s = String(d ?? "");
+ if (format === "iso" || !isCalendarDate(s)) return s;
+ const [y, m, day] = s.split("-").map(Number);
+ return `${MONTHS[m - 1]} ${day}, ${y}`;
+}
+
+/**
+ * The deck's automatic subtitle: the source and its date, from
+ * `attributionParts`. `parts: "auto"` is title and date, with the channel at
+ * the head only when the cut spans more than one channel (one channel
+ * throughout is furniture); an explicit list picks from channel/title/date/
+ * clock in the order given. Empty parts are dropped, never left as a dangling
+ * separator. An entry's `onscreen.subtitle` replaces all of this -- that is the
+ * caller's decision, not this function's.
+ *
+ * @param {{ channel?: string, title?: string, date?: string, at?: number|null }} parts
+ * @param {{ parts?: "auto"|string[], dateFormat?: "long"|"iso" }} [opts]
+ * @param {boolean} [multiChannel]
+ */
+export function deckSubtitle(parts, opts = {}, multiChannel = false) {
+ const want = !opts.parts || opts.parts === "auto"
+ ? (multiChannel ? ["channel", "title", "date"] : ["title", "date"])
+ : opts.parts;
+ const value = {
+ channel: () => parts.channel,
+ title: () => parts.title,
+ date: () => formatDeckDate(parts.date, opts.dateFormat ?? "long"),
+ clock: () => (parts.at === null || parts.at === undefined ? "" : hms(parts.at)),
+ };
+ return want
+ .map((k) => String(value[k]?.() ?? "").trim())
+ .filter(Boolean)
+ .join(" · ");
}
/**
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -64,6 +64,7 @@ import {
cardWidth, contentWidth, reservedFooterHeight,
} from "./render-cards.mjs";
import { createCueSource, siteOriginFromManifest } from "./cues.mjs";
+import { 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";
@@ -2034,13 +2035,9 @@ const ffmetaEscape = (s) => String(s).replace(/([=;#\\])/g, "\\$1").replace(/\n/
export async function segmentOffsets(segments, D, fps) {
const durs = [];
for (const s of segments) durs.push(await probeDuration(s, fps));
- const starts = [];
- let acc = 0;
- for (let i = 0; i < durs.length; i += 1) {
- starts.push(acc);
- acc += durs[i] - (i < durs.length - 1 ? D : 0);
- }
- return { starts, total: acc };
+ // The arithmetic lives in deck.mjs so an estimate made before any segment
+ // exists is the same sum, not a copy of it.
+ return { ...scheduleFrom(durs, D), durs };
}
async function chapterTitle(entry, index, provenance) {
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -0,0 +1,546 @@
+// The bottom deck: one persistent on-screen panel for a whole report cut.
+//
+// `render.chrome = { engine: "hyperframes", layout: "deck", deck: {...} }` turns
+// it on. The footage is scaled into a box above the deck, and ONE HyperFrames
+// composition -- a pip timeline, the clip's authored title, its source-and-date
+// subtitle and its QR -- is overlaid across the whole concat.
+//
+// Everything in this file is PURE: geometry, the schedule arithmetic, the
+// choreography times, validation, the cache key. Three programs read it -- the
+// build (segment framing, schedule.json), the composition (where things are and
+// when they move) and umtool (validation on write, the estimated schedule its
+// preview draws before any build exists) -- and they agree because none of them
+// has a copy of any of it.
+//
+// No ffmpeg, no fs, no network. If a function here needs one, it belongs in
+// build-video.mjs or compose-chrome.mjs instead.
+import { createHash } from "node:crypto";
+
+import { attributionParts, deckSubtitle } from "./attribution.mjs";
+
+/** The renderer version, pinned. It is part of the cache key: a new renderer is new frames. */
+export const HYPERFRAMES_PKG_DEFAULT = "hyperframes@0.8.24";
+
+/**
+ * Every deck setting and its default. A manifest names only what it changes;
+ * `resolveDeck` fills the rest. Each default is a setting, not a constant --
+ * the alternative in each comment is a supported value.
+ */
+export const DECK_DEFAULTS = Object.freeze({
+ height: 190,
+ footageScale: 0.82,
+ background: "panel", // | "flush"
+ pip: Object.freeze({ spacing: "even", size: 6, activeSize: 12 }), // spacing | "time"
+ title: Object.freeze({ size: 54, maxChars: 48 }),
+ subtitle: Object.freeze({ parts: "auto", dateFormat: "long" }), // dateFormat | "iso"
+ qr: Object.freeze({ show: true, size: 150 }),
+ overCards: "hide", // | "show"
+ motion: Object.freeze({ out: 0.3, in: 0.45, pip: 0.7 }),
+});
+
+/** Subtitle tokens a `subtitle.parts` array may name. "auto" picks from these. */
+export const SUBTITLE_TOKENS = Object.freeze(["channel", "title", "date", "clock"]);
+
+/** Segment types the deck slides away over when `overCards: "hide"`. */
+export const CARD_TYPES = Object.freeze(["card", "scroll", "chart", "ledger"]);
+
+/** Does this render block ask for the deck? Absent, nothing in this file runs. */
+export function deckOn(render) {
+ const c = render?.chrome;
+ return !!c && c.engine === "hyperframes" && c.layout === "deck";
+}
+
+/** The deck settings with every default filled in. */
+export function resolveDeck(render) {
+ const d = render?.chrome?.deck ?? {};
+ const merged = { ...DECK_DEFAULTS };
+ for (const [k, v] of Object.entries(d)) {
+ const base = DECK_DEFAULTS[k];
+ merged[k] = base && typeof base === "object" && v && typeof v === "object" && !Array.isArray(v)
+ ? { ...base, ...v }
+ : v;
+ }
+ return merged;
+}
+
+// ---------------------------------------------------------------------------
+// Validation. ONE validator: umtool's writer refuses with it and the build
+// refuses with it, so a manifest umtool accepted is one the build accepts.
+// ---------------------------------------------------------------------------
+
+const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
+const numIn = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
+
+function unknownKeys(obj, allowed, where, errors) {
+ for (const k of Object.keys(obj)) {
+ if (!allowed.includes(k)) errors.push(`${where}.${k} is not a deck setting`);
+ }
+}
+
+/**
+ * Every reason this `render.chrome` cannot be built, as sentences. Empty means
+ * it can. `render` is the rest of the render block: the deck refuses a rail or
+ * the legacy `chromeEngine` beside it, and checks the footage fits the frame.
+ *
+ * Unknown keys are refused, not ignored -- `footageScle` silently meaning the
+ * default is the bug this catches.
+ *
+ * @returns {string[]}
+ */
+export function validateChrome(chrome, render = {}) {
+ const errors = [];
+ if (chrome === undefined || chrome === null) return errors;
+ if (!isObj(chrome)) return ["render.chrome must be an object"];
+ unknownKeys(chrome, ["engine", "layout", "deck"], "render.chrome", errors);
+ if (chrome.engine !== "hyperframes") errors.push('render.chrome.engine must be "hyperframes"');
+ if (chrome.layout !== "deck") errors.push('render.chrome.layout must be "deck"');
+ if (render.rail) errors.push("render.chrome (the deck) and render.rail cannot both be set");
+ if (render.chromeEngine !== undefined) {
+ errors.push("render.chrome replaces render.chromeEngine — remove chromeEngine");
+ }
+ const d = chrome.deck ?? {};
+ if (!isObj(d)) return [...errors, "render.chrome.deck must be an object"];
+ const w = "render.chrome.deck";
+ unknownKeys(d, Object.keys(DECK_DEFAULTS), w, errors);
+
+ if (d.height !== undefined && !(Number.isInteger(d.height) && numIn(d.height, 120, 400))) {
+ errors.push(`${w}.height must be a whole number of pixels from 120 to 400`);
+ }
+ if (d.footageScale !== undefined && !numIn(d.footageScale, 0.5, 1)) {
+ errors.push(`${w}.footageScale must be from 0.5 to 1`);
+ }
+ if (d.background !== undefined && !["panel", "flush"].includes(d.background)) {
+ errors.push(`${w}.background must be "panel" or "flush"`);
+ }
+ if (d.overCards !== undefined && !["hide", "show"].includes(d.overCards)) {
+ errors.push(`${w}.overCards must be "hide" or "show"`);
+ }
+
+ const sub = (key, allowed, check) => {
+ if (d[key] === undefined) return;
+ if (!isObj(d[key])) { errors.push(`${w}.${key} must be an object`); return; }
+ unknownKeys(d[key], allowed, `${w}.${key}`, errors);
+ check(d[key]);
+ };
+ sub("pip", ["spacing", "size", "activeSize"], (p) => {
+ if (p.spacing !== undefined && !["even", "time"].includes(p.spacing)) {
+ errors.push(`${w}.pip.spacing must be "even" or "time"`);
+ }
+ if (p.size !== undefined && !numIn(p.size, 2, 24)) errors.push(`${w}.pip.size must be from 2 to 24`);
+ if (p.activeSize !== undefined && !numIn(p.activeSize, 2, 40)) {
+ errors.push(`${w}.pip.activeSize must be from 2 to 40`);
+ }
+ const size = p.size ?? DECK_DEFAULTS.pip.size;
+ const active = p.activeSize ?? DECK_DEFAULTS.pip.activeSize;
+ if (numIn(size, 2, 24) && numIn(active, 2, 40) && active < size) {
+ errors.push(`${w}.pip.activeSize must be at least pip.size`);
+ }
+ });
+ sub("title", ["size", "maxChars"], (t) => {
+ if (t.size !== undefined && !numIn(t.size, 24, 96)) errors.push(`${w}.title.size must be from 24 to 96`);
+ if (t.maxChars !== undefined && !(Number.isInteger(t.maxChars) && numIn(t.maxChars, 10, 120))) {
+ errors.push(`${w}.title.maxChars must be a whole number from 10 to 120`);
+ }
+ });
+ sub("subtitle", ["parts", "dateFormat"], (s) => {
+ if (s.parts !== undefined && s.parts !== "auto") {
+ const ok = Array.isArray(s.parts) && s.parts.length > 0 &&
+ s.parts.every((p) => SUBTITLE_TOKENS.includes(p)) &&
+ new Set(s.parts).size === s.parts.length;
+ if (!ok) {
+ errors.push(`${w}.subtitle.parts must be "auto" or a list of distinct ${SUBTITLE_TOKENS.join("/")}`);
+ }
+ }
+ if (s.dateFormat !== undefined && !["long", "iso"].includes(s.dateFormat)) {
+ errors.push(`${w}.subtitle.dateFormat must be "long" or "iso"`);
+ }
+ });
+ sub("qr", ["show", "size"], (q) => {
+ if (q.show !== undefined && typeof q.show !== "boolean") errors.push(`${w}.qr.show must be true or false`);
+ if (q.size !== undefined && !numIn(q.size, 80, 380)) errors.push(`${w}.qr.size must be from 80 to 380`);
+ const h = d.height ?? DECK_DEFAULTS.height;
+ const size = q.size ?? DECK_DEFAULTS.qr.size;
+ if (numIn(size, 80, 380) && Number.isInteger(h) && size > h - 20) {
+ errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`);
+ }
+ });
+ sub("motion", ["out", "in", "pip"], (m) => {
+ for (const k of ["out", "in"]) {
+ if (m[k] !== undefined && !numIn(m[k], 0, 2)) errors.push(`${w}.motion.${k} must be from 0 to 2 seconds`);
+ }
+ if (m.pip !== undefined && !numIn(m.pip, 0, 3)) errors.push(`${w}.motion.pip must be from 0 to 3 seconds`);
+ });
+
+ // The footage has to fit above the deck. Clamping the scale instead would
+ // make the setting lie about what was drawn.
+ if (!errors.length) {
+ const g = deckGeometry({ ...render, chrome });
+ const room = g.H - g.deck.height;
+ if (g.footage.height > room) {
+ const max = Math.floor((room / g.H) * 1000) / 1000;
+ errors.push(
+ `${w}.footageScale ${resolveDeck({ chrome }).footageScale} needs ${g.footage.height}px ` +
+ `but a ${g.deck.height}px deck leaves ${room} (footageScale at most ${max})`,
+ );
+ }
+ }
+ return errors;
+}
+
+/** `validateChrome`, thrown. The build calls this before it spends a single fetch. */
+export function assertChrome(chrome, render = {}) {
+ const errors = validateChrome(chrome, render);
+ if (errors.length) throw new Error(`render.chrome: ${errors.join("; ")}`);
+}
+
+/**
+ * A per-entry `onscreen` value, normalised: trimmed, empty strings dropped,
+ * `null` when nothing is left (which is what a writer stores as "delete it").
+ * Throws on a shape no writer should store.
+ *
+ * It is nested -- `onscreen.title`, not `title` -- because `entry.title`
+ * already means the STREAM title in headers and chapters.
+ */
+export function normalizeOnscreen(v) {
+ if (v === undefined || v === null) return null;
+ if (!isObj(v)) throw new Error("onscreen must be an object with title and/or subtitle");
+ for (const k of Object.keys(v)) {
+ if (k !== "title" && k !== "subtitle") throw new Error(`onscreen.${k} is not an on-screen field`);
+ }
+ const out = {};
+ for (const k of ["title", "subtitle"]) {
+ if (v[k] === undefined || v[k] === null) continue;
+ if (typeof v[k] !== "string") throw new Error(`onscreen.${k} must be a string`);
+ const s = v[k].trim();
+ if (/[\r\n]/.test(s)) throw new Error(`onscreen.${k} must be one line`);
+ if (s.length > 200) throw new Error(`onscreen.${k} is ${s.length} characters (at most 200)`);
+ if (s) out[k] = s;
+ }
+ return Object.keys(out).length ? out : null;
+}
+
+// ---------------------------------------------------------------------------
+// Geometry.
+// ---------------------------------------------------------------------------
+
+/** Nearest even integer -- yuv420 needs even dimensions, and pad offsets follow. */
+export const even = (v) => Math.round(v / 2) * 2;
+
+/**
+ * Where the footage and the deck sit in the frame.
+ *
+ * The footage box keeps the FRAME's aspect, is `footageScale` of its width
+ * (evened), and is centred in the area above the deck. For 1920×1080 at 0.82
+ * with a 190 px deck that is 1574×886 at (173, 2).
+ *
+ * @returns {{ W:number, H:number,
+ * footage:{x:number,y:number,width:number,height:number},
+ * deck:{x:number,y:number,width:number,height:number} }}
+ */
+export function deckGeometry(render) {
+ const W = render?.width ?? 1920;
+ const H = render?.height ?? 1080;
+ const deck = resolveDeck(render);
+ const dh = deck.height;
+ const fw = even(W * deck.footageScale);
+ const fh = even((fw * H) / W);
+ return {
+ W, H,
+ footage: { x: Math.floor((W - fw) / 2), y: Math.floor((H - dh - fh) / 2), width: fw, height: fh },
+ deck: { x: 0, y: H - dh, width: W, height: dh },
+ };
+}
+
+/**
+ * Where things sit INSIDE the deck region (region-local pixels). The
+ * composition draws from this; umtool's preview frames the same rect. A
+ * starting layout -- the composition may tune the numbers here, never copy them.
+ */
+export function deckLayout(render) {
+ const { W, deck: rect } = deckGeometry(render);
+ const d = resolveDeck(render);
+ const padX = 48;
+ const qr = d.qr.show
+ ? { x: W - padX - d.qr.size, y: Math.round((rect.height - d.qr.size) / 2) + 6, size: d.qr.size }
+ : null;
+ const textX = padX;
+ const textRight = qr ? qr.x - 40 : W - padX;
+ return {
+ width: W,
+ height: rect.height,
+ pipTrack: { x0: padX, x1: W - padX, y: 16 },
+ text: { x: textX, width: textRight - textX, titleSize: d.title.size, subtitleSize: 26 },
+ qr,
+ };
+}
+
+/**
+ * The x of every pip on the track.
+ *
+ * "even": evenly spaced, one per pip, a single pip centred. "time": each pip at
+ * its segment's MIDPOINT in the cut's own clock, so a long clip owns more track
+ * -- needs `schedule` (`{ total, segments: [{start, duration}] }`, the pipped
+ * segments only, in order).
+ */
+export function pipXs(n, x0, x1, spacing = "even", schedule = null) {
+ if (n <= 0) return [];
+ if (spacing === "time") {
+ if (!schedule?.segments || schedule.segments.length !== n || !(schedule.total > 0)) {
+ throw new Error("pipXs: spacing \"time\" needs a schedule with one segment per pip");
+ }
+ return schedule.segments.map((s) => x0 + ((x1 - x0) * (s.start + s.duration / 2)) / schedule.total);
+ }
+ if (n === 1) return [(x0 + x1) / 2];
+ return Array.from({ length: n }, (_, i) => x0 + (i * (x1 - x0)) / (n - 1));
+}
+
+// ---------------------------------------------------------------------------
+// The schedule.
+// ---------------------------------------------------------------------------
+
+/**
+ * Segment starts and the total, from durations and the crossfade. THE
+ * arithmetic: build-video's segmentOffsets probes the durations and calls this,
+ * and so does every estimate -- there is no second implementation to drift.
+ */
+export function scheduleFrom(durs, D) {
+ const starts = [];
+ let acc = 0;
+ for (let i = 0; i < durs.length; i += 1) {
+ starts.push(acc);
+ acc += durs[i] - (i < durs.length - 1 ? D : 0);
+ }
+ return { starts, total: acc };
+}
+
+/**
+ * A segment's length before it is built, from the manifest alone. Clips are
+ * their play window (the cut, with the lead-in, when there is one); silence
+ * snapping moves the real one by a fraction of a second, which is why a
+ * schedule built from these says `estimated: true`.
+ */
+export function estimatedDuration(entry, render = {}) {
+ if (entry.type === "clip") {
+ const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd);
+ const lead = render.leadIn ?? 0.4;
+ const a = hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start;
+ const b = hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end;
+ return Math.max(1, b - a);
+ }
+ if (entry.type === "image") return Number(entry.seconds ?? 4);
+ return Number(entry.seconds ?? 5);
+}
+
+/** The crossfade a build of this render block will use. */
+export function transitionOf(render, { noXfade = false } = {}) {
+ const t = render?.transition ?? 0.5;
+ return noXfade || t === 0 ? 0 : t;
+}
+
+/**
+ * Does the cut span more than one channel? The deck's auto subtitle names the
+ * channel only when it does: one channel throughout is furniture.
+ */
+export function isMultiChannel(entries, provenance = {}) {
+ const slugs = new Set(
+ entries.filter((e) => e.type === "clip").map((e) => e.channel ?? provenance.channelSlug ?? ""),
+ );
+ return slugs.size > 1;
+}
+
+/** Is the deck hidden over this segment? */
+export function hidesDeck(entry, deck) {
+ return deck.overCards === "hide" && CARD_TYPES.includes(entry.type);
+}
+
+/** The deck's title and subtitle for one entry. Pure: the caller resolves `meta`. */
+export function deckText(entry, meta, provenance, deck, multiChannel) {
+ const o = entry.onscreen ?? {};
+ let title = o.title ?? "";
+ let subtitle = o.subtitle;
+ if (subtitle === undefined) {
+ if (entry.type === "clip") {
+ subtitle = deckSubtitle(attributionParts(entry, meta ?? {}, provenance), deck.subtitle, multiChannel);
+ } else if (entry.type === "image") {
+ subtitle = deckSubtitle(
+ { channel: "", title: String(entry.title ?? "").trim(), date: String(entry.date ?? "").trim(), at: null },
+ { ...deck.subtitle, parts: ["title", "date"] },
+ false,
+ );
+ } else {
+ if (!title) title = entry.heading ?? "";
+ subtitle = entry.sub ?? "";
+ }
+ }
+ return { title, subtitle };
+}
+
+/**
+ * The QR the deck shows for one entry, or null for none. A clip's is the
+ * per-clip corner QR's rule unchanged (`citeUrl` wins, else the site link at
+ * the clip's start); a still has one only when it carries a `citeUrl`; a card
+ * has none.
+ */
+export function deckQrUrl(entry, provenance = {}) {
+ if (entry.type === "clip") {
+ return entry.citeUrl ??
+ `${provenance.siteOrigin}/?v=${encodeURIComponent(
+ `${entry.channel ?? provenance.channelSlug}/${entry.video}`,
+ )}&t=${Math.floor(entry.start)}`;
+ }
+ if (entry.type === "image") return entry.citeUrl ?? null;
+ return null;
+}
+
+/**
+ * The schedule document (`out/<variant>/schedule.json` when the deck is on).
+ *
+ * `metas[i]` is entry i's source metadata (`{ title, uploadDate, channel }`)
+ * or null. The build passes probed durations and real metadata; an estimate
+ * passes `estimatedDuration`s and whatever metadata it has.
+ *
+ * @returns {{ version: 1, kind: "deck", estimated: boolean, fps: number,
+ * transition: number, total: number, multiChannel: boolean,
+ * segments: Array<{ id: string, type: string, start: number, duration: number,
+ * end: number, title: string, subtitle: string, qrUrl: string|null,
+ * hideDeck: boolean }> }}
+ */
+export function deckSchedule({ entries, durs, D, render, provenance = {}, metas = [], estimated = false }) {
+ const deck = resolveDeck(render);
+ const { starts, total } = scheduleFrom(durs, D);
+ const multiChannel = isMultiChannel(entries, provenance);
+ const round = (v) => Math.round(v * 1000) / 1000;
+ return {
+ version: 1,
+ kind: "deck",
+ estimated,
+ fps: render.fps ?? 30,
+ transition: D,
+ total: round(total),
+ multiChannel,
+ segments: entries.map((e, i) => {
+ const { title, subtitle } = deckText(e, metas[i] ?? null, provenance, deck, multiChannel);
+ return {
+ id: e.id,
+ type: e.type,
+ start: round(starts[i]),
+ duration: round(durs[i]),
+ end: round(starts[i] + durs[i]),
+ title,
+ subtitle,
+ qrUrl: deck.qr.show ? deckQrUrl(e, provenance) : null,
+ hideDeck: hidesDeck(e, deck),
+ };
+ }),
+ };
+}
+
+/** `deckSchedule` from the manifest alone, for a preview before any build. */
+export function estimateSchedule(manifest, { metas = [], noXfade = false } = {}) {
+ const render = manifest.render ?? {};
+ const entries = manifest.timeline ?? [];
+ return deckSchedule({
+ entries,
+ durs: entries.map((e) => estimatedDuration(e, render)),
+ D: transitionOf(render, { noXfade }),
+ render,
+ provenance: manifest.provenance ?? {},
+ metas,
+ estimated: true,
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Choreography. Absolute seconds in the cut's clock, so the composition, the
+// tests and a reviewer all read the same numbers.
+// ---------------------------------------------------------------------------
+
+/**
+ * When everything moves at each segment boundary.
+ *
+ * The handover is centred on the MID-DISSOLVE, m = start + D/2 (= start for a
+ * hard cut): the old title wipes out over [m − out, m], the new one expands
+ * over [m, m + in] with the subtitle trailing by 0.08 s, the QR flips through m
+ * (unscannable for 0.25 s and no longer), and the pip marker travels over
+ * [m − pip/2, m + pip/2].
+ *
+ * The deck itself slides away over a hidden segment's dissolve and back over
+ * the next one's; a boundary into or out of a hidden segment has no text
+ * handover, because the text it would animate is off screen.
+ *
+ * @returns {{ handovers: Array<{ i:number, from:string, to:string, m:number,
+ * out:[number,number], in:[number,number], sub:[number,number],
+ * qr:[number,number], pip:[number,number] }>,
+ * visibility: Array<{ i:number, hide:boolean, at:[number,number] }> }}
+ */
+export function deckChoreography(schedule, render) {
+ const deck = resolveDeck(render);
+ const { out, in: inn, pip } = deck.motion;
+ const D = schedule.transition;
+ const segs = schedule.segments;
+ const handovers = [];
+ const visibility = [];
+ const slide = Math.max(D, inn);
+ if (segs.length && segs[0].hideDeck) visibility.push({ i: 0, hide: true, at: [0, 0] });
+ for (let i = 1; i < segs.length; i += 1) {
+ const m = segs[i].start + D / 2;
+ const a = segs[i - 1].hideDeck;
+ const b = segs[i].hideDeck;
+ if (a !== b) {
+ // Gone before the card is fully up; back once the footage is.
+ const at = D > 0 ? [segs[i].start, segs[i].start + slide] : b ? [m - slide, m] : [m, m + slide];
+ visibility.push({ i, hide: b, at });
+ continue;
+ }
+ if (b) continue;
+ handovers.push({
+ i,
+ from: segs[i - 1].id,
+ to: segs[i].id,
+ m,
+ out: [m - out, m],
+ in: [m, m + inn],
+ sub: [m + 0.08, m + 0.08 + inn],
+ qr: [m - 0.125, m + 0.125],
+ pip: [m - pip / 2, m + pip / 2],
+ });
+ }
+ return { handovers, visibility };
+}
+
+/** The segments that get a pip: every one the deck is shown over. */
+export function pipSegments(schedule) {
+ return schedule.segments.filter((s) => !s.hideDeck);
+}
+
+// ---------------------------------------------------------------------------
+// The render cache and the renderer command.
+// ---------------------------------------------------------------------------
+
+export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
+
+/**
+ * The deck render's cache key: the composition HTML, every asset by content
+ * hash (name-sorted), the fps, the frame count and the renderer version. Same
+ * key and the same number of frames on disk = skip the render.
+ */
+export function chromeCacheKey({ html, assets = [], fps, frames, version }) {
+ const sorted = [...assets].map(([n, h]) => [String(n), String(h)]).sort((x, y) => x[0].localeCompare(y[0]));
+ return sha256(JSON.stringify({ v: 1, html: sha256(html), assets: sorted, fps, frames, version }));
+}
+
+/** Frames a render of `total` seconds at `fps` produces. */
+export const frameCount = (total, fps) => Math.round(total * fps);
+
+/**
+ * How to invoke HyperFrames. `HYPERFRAMES_BIN` (an executable taking the CLI's
+ * own arguments, e.g. an e2e stub) wins; else `npx --yes <HYPERFRAMES_PKG>`,
+ * pinned by default. `version` is what the cache key records.
+ */
+export function hyperframesCommand(env = process.env) {
+ if (env.HYPERFRAMES_BIN) {
+ return { cmd: env.HYPERFRAMES_BIN, args: [], version: `bin:${env.HYPERFRAMES_BIN}` };
+ }
+ const pkg = env.HYPERFRAMES_PKG ?? HYPERFRAMES_PKG_DEFAULT;
+ return { cmd: "npx", args: ["--yes", pkg], version: pkg };
+}
diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs
@@ -0,0 +1,241 @@
+// Tests for deck.mjs and the deck half of attribution.mjs -- the pure core the
+// build, the composition and umtool all read.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { attributionLine, attributionParts, deckSubtitle, formatDeckDate } from "./attribution.mjs";
+import {
+ assertChrome,
+ chromeCacheKey,
+ DECK_DEFAULTS,
+ deckChoreography,
+ deckGeometry,
+ deckOn,
+ deckQrUrl,
+ deckSchedule,
+ deckText,
+ estimatedDuration,
+ estimateSchedule,
+ even,
+ frameCount,
+ hyperframesCommand,
+ HYPERFRAMES_PKG_DEFAULT,
+ isMultiChannel,
+ normalizeOnscreen,
+ pipSegments,
+ pipXs,
+ resolveDeck,
+ scheduleFrom,
+ validateChrome,
+} from "./deck.mjs";
+
+const CHROME = { engine: "hyperframes", layout: "deck", deck: {} };
+const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.5, chrome: CHROME };
+const PROV = { siteOrigin: "https://example.pages.dev", channelSlug: "chan", channel: "Chan" };
+
+test("deckOn: only the hyperframes deck", () => {
+ assert.equal(deckOn(RENDER), true);
+ assert.equal(deckOn({}), false);
+ assert.equal(deckOn({ chromeEngine: "hyperframes" }), false);
+ assert.equal(deckOn({ chrome: { engine: "hyperframes", layout: "band" } }), false);
+});
+
+test("resolveDeck merges one level deep", () => {
+ const d = resolveDeck({ chrome: { ...CHROME, deck: { height: 200, pip: { size: 8 } } } });
+ assert.equal(d.height, 200);
+ assert.deepEqual(d.pip, { spacing: "even", size: 8, activeSize: 12 });
+ assert.deepEqual(d.motion, DECK_DEFAULTS.motion);
+});
+
+test("deckGeometry: the ferret numbers", () => {
+ const g = deckGeometry(RENDER);
+ assert.deepEqual(g.footage, { x: 173, y: 2, width: 1574, height: 886 });
+ assert.deepEqual(g.deck, { x: 0, y: 890, width: 1920, height: 190 });
+ assert.equal(even(885.375), 886);
+});
+
+test("validateChrome: defaults are valid; refusals are sentences", () => {
+ assert.deepEqual(validateChrome(CHROME, RENDER), []);
+ assert.deepEqual(validateChrome(undefined, RENDER), []);
+ const bad = (chrome, render = RENDER) => validateChrome(chrome, render);
+ assert.match(bad({ ...CHROME, engine: "ffmpeg" })[0], /engine/);
+ assert.match(bad(CHROME, { ...RENDER, rail: {} })[0], /rail/);
+ assert.match(bad(CHROME, { ...RENDER, chromeEngine: "hyperframes" })[0], /chromeEngine/);
+ assert.match(bad({ ...CHROME, deck: { footageScle: 0.8 } })[0], /footageScle is not a deck setting/);
+ assert.match(bad({ ...CHROME, deck: { height: 50 } })[0], /height/);
+ assert.match(bad({ ...CHROME, deck: { pip: { size: 10, activeSize: 6 } } })[0], /activeSize/);
+ assert.match(bad({ ...CHROME, deck: { subtitle: { parts: ["date", "date"] } } })[0], /parts/);
+ assert.deepEqual(bad({ ...CHROME, deck: { subtitle: { parts: ["clock", "title"] } } }), []);
+ assert.match(bad({ ...CHROME, deck: { qr: { size: 180 } } })[0], /does not fit a 190px deck/);
+ assert.match(bad({ ...CHROME, deck: { motion: { in: 5 } } })[0], /motion.in/);
+ // The footage must fit above the deck: 0.9 of 1080 is 972 > 890.
+ assert.match(bad({ ...CHROME, deck: { footageScale: 0.9 } })[0], /at most 0.824/);
+ assert.throws(() => assertChrome({ ...CHROME, layout: "x" }, RENDER), /render.chrome: .*layout/);
+});
+
+test("normalizeOnscreen trims, drops empties, refuses odd shapes", () => {
+ assert.deepEqual(normalizeOnscreen({ title: " A ", subtitle: "" }), { title: "A" });
+ assert.equal(normalizeOnscreen({ title: " ", subtitle: "" }), null);
+ assert.equal(normalizeOnscreen(null), null);
+ assert.throws(() => normalizeOnscreen({ heading: "x" }), /onscreen.heading/);
+ assert.throws(() => normalizeOnscreen({ title: "a\nb" }), /one line/);
+ assert.throws(() => normalizeOnscreen({ title: 3 }), /string/);
+});
+
+test("pipXs: even, a single pip, by time", () => {
+ assert.deepEqual(pipXs(0, 0, 100), []);
+ assert.deepEqual(pipXs(1, 0, 100), [50]);
+ assert.deepEqual(pipXs(3, 0, 100), [0, 50, 100]);
+ const sched = { total: 10, segments: [{ start: 0, duration: 2 }, { start: 2, duration: 8 }] };
+ assert.deepEqual(pipXs(2, 0, 100, "time", sched), [10, 60]);
+ assert.throws(() => pipXs(3, 0, 100, "time", sched), /one segment per pip/);
+});
+
+test("scheduleFrom is segmentOffsets' arithmetic", () => {
+ // The same sum the crossfaded concat performs: each join overlaps by D.
+ const durs = [10, 5, 7];
+ assert.deepEqual(scheduleFrom(durs, 0.5), { starts: [0, 9.5, 14], total: 21 });
+ assert.deepEqual(scheduleFrom(durs, 0), { starts: [0, 10, 15], total: 22 });
+ assert.deepEqual(scheduleFrom([], 0.5), { starts: [], total: 0 });
+});
+
+test("estimatedDuration: play window, cut with lead-in, card and image seconds", () => {
+ assert.equal(estimatedDuration({ type: "clip", start: 10, end: 25 }), 15);
+ assert.equal(
+ estimatedDuration({ type: "clip", start: 10, end: 25, cutStart: 12, cutEnd: 20 }),
+ 8.4,
+ );
+ assert.equal(estimatedDuration({ type: "card", seconds: 6 }), 6);
+ assert.equal(estimatedDuration({ type: "image" }), 4);
+});
+
+test("attributionParts rebuilds attributionLine exactly", () => {
+ const entry = { cite: 3725, start: 3700 };
+ const meta = { title: "Stream 🎮 !discord", uploadDate: "20260814", channel: "Chan" };
+ const p = attributionParts(entry, meta, PROV);
+ assert.deepEqual(p, { channel: "Chan", title: "Stream", date: "2026-08-14", at: 3725 });
+ assert.equal(attributionLine(entry, meta, PROV), "Chan · Stream · 2026-08-14 @ 1:02:05");
+});
+
+test("deckSubtitle: auto, multi-channel, explicit parts, iso, empties", () => {
+ const p = { channel: "Chan", title: "Stream", date: "2026-08-14", at: 3725 };
+ assert.equal(formatDeckDate("2026-08-14"), "Aug 14, 2026");
+ assert.equal(formatDeckDate("2026-02-31"), "2026-02-31");
+ assert.equal(deckSubtitle(p), "Stream · Aug 14, 2026");
+ assert.equal(deckSubtitle(p, {}, true), "Chan · Stream · Aug 14, 2026");
+ assert.equal(deckSubtitle(p, { dateFormat: "iso" }), "Stream · 2026-08-14");
+ assert.equal(deckSubtitle(p, { parts: ["date", "clock"] }), "Aug 14, 2026 · 1:02:05");
+ assert.equal(deckSubtitle({ ...p, title: "" }), "Aug 14, 2026");
+});
+
+test("deckText: onscreen overrides, clip/image/card fallbacks", () => {
+ const deck = resolveDeck(RENDER);
+ const meta = { title: "Stream", uploadDate: "20260814" };
+ const clip = { id: "c1", type: "clip", video: "v", start: 1, end: 9 };
+ assert.deepEqual(deckText(clip, meta, PROV, deck, false), { title: "", subtitle: "Stream · Aug 14, 2026" });
+ assert.deepEqual(
+ deckText({ ...clip, onscreen: { title: "T", subtitle: "S" } }, meta, PROV, deck, false),
+ { title: "T", subtitle: "S" },
+ );
+ assert.deepEqual(
+ deckText({ id: "i", type: "image", title: "Poster", date: "2026-01-02" }, null, PROV, deck, true),
+ { title: "", subtitle: "Poster · Jan 2, 2026" },
+ );
+ assert.deepEqual(
+ deckText({ id: "k", type: "card", heading: "H", sub: "S" }, null, PROV, deck, false),
+ { title: "H", subtitle: "S" },
+ );
+});
+
+test("deckQrUrl: citeUrl wins, derived at the start, images only when cited, cards never", () => {
+ assert.equal(deckQrUrl({ type: "clip", citeUrl: "https://x/y" }, PROV), "https://x/y");
+ assert.equal(
+ deckQrUrl({ type: "clip", video: "abc", start: 61.9 }, PROV),
+ "https://example.pages.dev/?v=chan%2Fabc&t=61",
+ );
+ assert.equal(deckQrUrl({ type: "image" }, PROV), null);
+ assert.equal(deckQrUrl({ type: "image", citeUrl: "https://z" }, PROV), "https://z");
+ assert.equal(deckQrUrl({ type: "card" }, PROV), null);
+});
+
+test("isMultiChannel counts clip channels only", () => {
+ const clips = [{ type: "clip" }, { type: "clip", channel: "chan" }, { type: "card" }];
+ assert.equal(isMultiChannel(clips, PROV), false);
+ assert.equal(isMultiChannel([...clips, { type: "clip", channel: "mirror" }], PROV), true);
+});
+
+const TIMELINE = [
+ { id: "t0", type: "card", seconds: 5, heading: "Title" },
+ { id: "c1", type: "clip", video: "a", start: 0, end: 10, citeUrl: "https://q/1" },
+ { id: "c2", type: "clip", video: "b", start: 0, end: 6, onscreen: { title: "Two" } },
+ { id: "c3", type: "clip", video: "c", start: 0, end: 8 },
+ { id: "src", type: "card", seconds: 5 },
+];
+
+test("deckSchedule / estimateSchedule: the schedule.json shape", () => {
+ const s = estimateSchedule({ render: RENDER, provenance: PROV, timeline: TIMELINE });
+ assert.equal(s.version, 1);
+ assert.equal(s.kind, "deck");
+ assert.equal(s.estimated, true);
+ assert.equal(s.transition, 0.5);
+ assert.equal(s.total, 32);
+ assert.deepEqual(s.segments.map((x) => x.start), [0, 4.5, 14, 19.5, 27]);
+ assert.deepEqual(s.segments.map((x) => x.hideDeck), [true, false, false, false, true]);
+ assert.equal(s.segments[1].qrUrl, "https://q/1");
+ assert.equal(s.segments[2].title, "Two");
+ assert.equal(s.segments[0].qrUrl, null);
+ // A real build passes probed durations and gets estimated: false.
+ const b = deckSchedule({ entries: TIMELINE, durs: [5, 10, 6, 8, 5], D: 0.5, render: RENDER, provenance: PROV });
+ assert.equal(b.estimated, false);
+ assert.equal(b.total, s.total);
+ // qr.show false clears every code.
+ const noQr = estimateSchedule({
+ render: { ...RENDER, chrome: { ...CHROME, deck: { qr: { show: false } } } },
+ provenance: PROV, timeline: TIMELINE,
+ });
+ assert.ok(noQr.segments.every((x) => x.qrUrl === null));
+ assert.deepEqual(pipSegments(s).map((x) => x.id), ["c1", "c2", "c3"]);
+});
+
+test("deckChoreography: handovers centred on the mid-dissolve; slides over cards", () => {
+ const s = estimateSchedule({ render: RENDER, provenance: PROV, timeline: TIMELINE });
+ const { handovers, visibility } = deckChoreography(s, RENDER);
+ assert.deepEqual(handovers.map((h) => [h.from, h.to]), [["c1", "c2"], ["c2", "c3"]]);
+ const h = handovers[0];
+ assert.equal(h.m, 14.25);
+ assert.deepEqual(h.out, [13.95, 14.25]);
+ assert.deepEqual(h.in, [14.25, 14.7]);
+ assert.deepEqual(h.sub.map((v) => Number(v.toFixed(3))), [14.33, 14.78]);
+ assert.deepEqual(h.qr, [14.125, 14.375]);
+ assert.deepEqual(h.pip, [13.9, 14.6]);
+ assert.deepEqual(visibility, [
+ { i: 0, hide: true, at: [0, 0] },
+ { i: 1, hide: false, at: [4.5, 5] },
+ { i: 4, hide: true, at: [27, 27.5] },
+ ]);
+ // A hard cut: m is the cut itself, and the slide finishes by it.
+ const hard = estimateSchedule({ render: { ...RENDER, transition: 0 }, provenance: PROV, timeline: TIMELINE });
+ const c = deckChoreography(hard, { ...RENDER, transition: 0 });
+ assert.equal(c.handovers[0].m, hard.segments[2].start);
+ assert.deepEqual(c.visibility[2].at, [hard.segments[4].start - 0.45, hard.segments[4].start]);
+});
+
+test("chromeCacheKey is stable and sensitive", () => {
+ const base = { html: "<p>", assets: [["b.png", "2"], ["a.ttf", "1"]], fps: 30, frames: 900, version: "hyperframes@0.8.24" };
+ const k = chromeCacheKey(base);
+ assert.match(k, /^[0-9a-f]{64}$/);
+ assert.equal(chromeCacheKey({ ...base, assets: [["a.ttf", "1"], ["b.png", "2"]] }), k);
+ for (const change of [{ html: "<p> " }, { fps: 60 }, { frames: 901 }, { version: "hyperframes@0.8.25" },
+ { assets: [["a.ttf", "1"], ["b.png", "3"]] }]) {
+ assert.notEqual(chromeCacheKey({ ...base, ...change }), k);
+ }
+ assert.equal(frameCount(351.2, 30), 10536);
+});
+
+test("hyperframesCommand: pinned by default, overridable", () => {
+ assert.deepEqual(hyperframesCommand({}), { cmd: "npx", args: ["--yes", HYPERFRAMES_PKG_DEFAULT], version: HYPERFRAMES_PKG_DEFAULT });
+ assert.equal(hyperframesCommand({ HYPERFRAMES_PKG: "hyperframes@1.0.0" }).version, "hyperframes@1.0.0");
+ assert.deepEqual(hyperframesCommand({ HYPERFRAMES_BIN: "/stub" }), { cmd: "/stub", args: [], version: "bin:/stub" });
+});