Archilyzer · Source

archilyzer

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

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:
Aplans/onscreen-deck.md | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/attribution.mjs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/report-to-video/build-video.mjs | 11++++-------
Aumtool/report-to-video/deck.mjs | 546+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/deck.test.mjs | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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" }); +});