commit 56547e1a82cac104e943701c172be24c09bafda1
parent b7d31d9a1390e21aaa5f2c2fa833b4012905ae0a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 00:00:00 -0400
Merge deck/integration — report-to-video's on-screen deck (umtool; plans/onscreen-deck.md); test:scripts covers umtool/lib/report; reviewed SHIP by its own session's read-only review, merged by the session holding main
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
49 files changed, 7481 insertions(+), 158 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,8 @@
# Changelog
## [Unreleased]
+- **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. A manifest without `render.chrome` builds exactly as before, byte for byte.
+- **A report cut with `transition: 0` builds when its output folder was given as a relative path.** The hard-cut concat listed its segments relative to the working directory, and ffmpeg reads that list relative to the list file's own folder, so every hard-cut build with a relative `--out` failed at the concat. The list now names each segment by its full path.
- **`archilyzer doctor` checks the image Build all builds sites in.** When a container engine answers, a new **build image** section says whether the image named under **Settings → Build pipeline** is there, when it was built and how big it is. It warns when the image is missing, or older than the last change to its Dockerfile, and prints the one command that rebuilds it. Build all still builds or refreshes the image itself before it builds any site; the warning tells you ahead of time that the next Build all will spend that time. With no container engine the check is skipped in one line, and with no corpus it is only a note. It never fails the doctor.
- **The site build image runs Node 22 and pnpm 11**, the versions the rest of the workspace runs on, instead of Node 20 and pnpm 9, which did not read the workspace's install rules. The next Build all rebuilds the image from its first step, reinstalling every dependency, before it builds any site.
- **Build all sites works in containers again.** Every site's container build had been failing while it prerendered `/favicon.ico`. Each site now builds from the data composed for it, never from files baked into the build image. A bundle whose `site.json` and `corpus.json` do not both name its site is refused before it is handed back or deployed. The image carries no corpus data, and its build context is about 7 MB from any checkout.
diff --git a/package.json b/package.json
@@ -22,7 +22,7 @@
"e2e": "node scripts/worktree.mjs run -- pnpm --filter editor run e2e",
"wt": "node scripts/worktree.mjs",
"e2e:sharded": "node scripts/run-sharded-e2e.mjs",
- "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs",
+ "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs umtool/lib/report/*.test.mjs",
"lint": "pnpm --filter export run lint",
"ops": "node scripts/archilyzer-ops.mjs"
},
diff --git a/plans/onscreen-deck.md b/plans/onscreen-deck.md
@@ -0,0 +1,359 @@
+# 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.
+
+## As built
+
+Points where a slice's actual shape differs from, or adds detail this contract
+left open to, what is written above. None of these broke a verification gate;
+they are the things a reader of this file alone would not know.
+
+- **`deckLayout` fields were left open here on purpose ("S2 may tune the
+ numbers HERE"), and S2 did.** The shipped numbers: 48 px side padding, a
+ 16 px pip track with 14 px of clearance below it for the active pip's halo,
+ the title and subtitle boxes sized off `title.size` (title box
+ `1.2×`, subtitle `48%` of it clamped to 18–34 px, the gap between them
+ `30%`), and the QR (when shown) right-aligned with the text column ending
+ 48 px short of it. None of this is a second contract — `deckLayout` is still
+ the one place that draws it — but a reader of this file alone would not know
+ the actual pixels without reading `deck.mjs`.
+- **The per-clip progress fill is not a pure export.** The contract's
+ `deckChoreography` returns `{handovers, visibility}` and says in prose that
+ "a progress fill runs with the clock"; what ships is a `fuse` element in
+ `chrome-deck.mjs` whose `scaleX` is computed directly from the pip x
+ positions `deckLayout`/`pipXs` already handed it (burning from pip k to pip
+ k+1 while clip k plays), not a third field `deckChoreography` returns. The
+ timing is still handover-derived and still has no second implementation —
+ it is simply derived in the composition, where it is drawn, rather than
+ handed to it as data.
+- **`-reinit_filter 0` / `format=rgba` on the deck's overlay input isn't in
+ this contract at all.** S3 found it: HyperFrames' RGB/RGBA-mixed sequence
+ restarts ffmpeg's filtergraph mid-concat and truncates the output (a
+ 3.5 s xfade+overlay came out 2.0 s). It's now load-bearing in
+ `chromeOverlayChain`, on the deck's input only — see
+ `umtool/docs/quirks.md` and the pipeline README's deck section.
+- **`assertChrome` runs on any `render.chrome`, not only when `deckOn`.** The
+ contract's build section says "When `deckOn(render)`: 1. `assertChrome`
+ before any fetch" — read narrowly, a malformed block with a bad `engine` or
+ `layout` (so `deckOn` is false) would reach the fetch stage unchecked.
+ `buildVideo()` instead calls `assertChrome(render.chrome, render)` whenever
+ `render.chrome` is present at all, `deckOn` or not, which is the stricter
+ and correct reading.
+- **Two GET routes exist that the contract only specified as PUT.**
+ `GET /api/report/chrome` (the settings form's starting state: the saved
+ block, `deckOn`, the resolved deck, `DECK_DEFAULTS`, and `validateChrome`'s
+ errors against a block somebody hand-edited into an invalid shape) and
+ `GET /api/report/onscreen` (the On-screen table's rows: each entry's saved
+ `onscreen` beside the AUTO title/subtitle it falls back to, `maxChars`, and
+ the token) were needed for the UI to have anything to render and shipped
+ alongside the PUTs the contract named.
+- **A windowed compose gets its own name, not just its own flag.**
+ `compose-chrome.mjs --from`/`--duration` (used by `--chrome-preview`) writes
+ a project and frame sequence named `deck-from<s>[-frames]` rather than the
+ full cut's `deck`/`deck-frames`, so a preview window never overwrites or
+ races the whole-cut sequence's cache. The contract names `--from`/`--duration`
+ as composeChrome arguments but not this naming rule.
+
+## As shipped
+
+Branch `deck/integration`, every slice merged `--no-ff` after review; `main` 3edeb4d0 merged in
+at 0d1e7d69 before the final gates.
+
+| Commit | What |
+|---|---|
+| 2cd13c9a, d1e8439e | S0 — this contract, `deck.mjs` + tests, `attributionParts`/`deckSubtitle`/`formatDeckDate`, `segmentOffsets` on `scheduleFrom`, the `./deck` export |
+| e95f208e | S4 — umtool writers (`updateOnscreen`, `updateChrome`, `updateClip` `onscreen`), the chrome/onscreen/preview/files/still/video routes, `rangeResponse`, `buildSteps` `chromeOnly` |
+| 0be86732 | S2 — `chrome-deck.mjs`, the compose-chrome port (dispatch, `--still`, windows, workers, vendored GSAP, pinned renderer, render cache, `qrPng`) |
+| d3bb2804 | S1 — deck framing for clip/image/shown-card segments, chrome skipped under the deck, `writeChromeSchedule`, chapters prefer `onscreen.title` |
+| f936f799 | S5 — the On-screen section and the clip bench's on-screen fields |
+| c653e119 | S3 — one command composes, renders and overlays; `applyChrome` for transition 0; `--chrome-only` / `--no-chrome` / `--chrome-preview`; `-reinit_filter 0` + `format=rgba`; verify-build deck checks; absolute concat list |
+| 89fd4cfa | S7 — README, report-video/build/clip-bench docs, quirks, "As built" |
+| 66e60c65 | S6 — `onscreen.spec.ts`, deck fixtures and a HyperFrames stub; the 409 reload keeps only real edits; timeline-row locators scoped past the table's `data-entry` |
+| 2d746cbc | Review fixes — the deck reuses a cached hard-cut concat only for the same segments in the same order (`<prerail>.segments`, `sameConcatList`); chapters take `onscreen.title` only under the deck |
+
+### Gates
+
+On 66e60c65 (everything), then on 2d746cbc after the review fixes (the gates the fixes touch):
+
+
+- `pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit` clean (81 s).
+- common 2,404/2,404; editor unit 101/101; `test:scripts` 267 pass, 2 skipped (the
+ `queue-lock.test.mjs` timing cases fail intermittently under machine load and pass alone —
+ a known load flake, not touched here); mcp 271/271.
+- `pnpm --filter editor exec next build` exit 0 (50 s); `pnpm --filter export exec next build`
+ exit 0 (28 s).
+- umtool `next build` with the corpus linked, under the 5 GB cap: exit 0, 22 s; link removed.
+- umtool e2e `onscreen clip-bench build projects report-fetch-via-editor`: 87 passed (3.6 min);
+ on S6's branch 87/87 twice (7.8 and 3.3 min).
+- On 2d746cbc: tsc clean (65 s); `test:scripts` 268 pass, 2 skipped; capped umtool build exit 0
+ (19 s), link removed; umtool e2e 87 passed (3.3 min).
+- Review (read-only, Opus): SHIP AFTER FIXES — F1 (a reordered timeline reused the old hard-cut
+ concat under `--chrome-only`) and F2 (chapters took `onscreen.title` without the deck), both
+ fixed in 2d746cbc.
+- Byte-identical without `render.chrome`: `--only c07` and `--only t00` segment md5s equal to
+ the base's (S1), and `c07` again after S3.
+- First application, built from this branch with `--skip-fetch`: 17 clips, 350.2 s, deck
+ 10,506/10,506 frames (rendered in 211 s), `verify-build` ok, 17/17 mid-clip QRs decode to the
+ clip's `citeUrl`, chapters equal the on-screen titles in order. An edit-one-title
+ `--chrome-only` round trip (S3, scratch) re-rendered the deck only; an unchanged re-run was
+ `cached` in 0.6 s.
+
+### Found and left
+
+- `verify-build` cannot tell whether the overlay was laid: a `--no-chrome` final passes while
+ frames from an earlier render are on disk.
+- `--chrome-only` writes over the final while it encodes, as `--rail-only` does.
+- The umtool preview shows saved settings; unsaved title/subtitle drafts preview live, unsaved
+ settings do not (save recomposes).
+- e2e runs the renderer through a stub; the real renderer is proven by the gates above, not by e2e.
+- The legacy chart band still refuses `transition: 0`; `applyChrome` could serve it, untested.
+- The chart band's renderer is now the pinned `hyperframes@0.8.24` (it was `@latest`), through
+ the same `hyperframesCommand`; GSAP is vendored rather than loaded from a CDN.
+- `render.palette` colours reach the preview composition's CSS unescaped; only a manifest's
+ author can set them.
+- The rail's `--preview` and `--chrome-preview` both write `<slug>.preview.mp4`; a deck refuses
+ a rail, so one manifest cannot produce both.
+- `chrome/deck-from<s>[-frames]` preview windows are never pruned.
+
+### Rollout
+
+Umtool-only: a umtool rebuild (`pnpm --filter umtool exec next build`, capped, corpus linked)
+and a umtool restart. Nothing under `export/`, `homepage/`, `editor/` (beyond the changelog) or
+`common/` changed, so no site, hub or homepage rebuild is owed for it.
diff --git a/umtool/app/api/report/build/route.ts b/umtool/app/api/report/build/route.ts
@@ -76,7 +76,13 @@ export async function POST(request: Request) {
// ---- the options the pipeline has and the driver used to hide ----------
const raw = (body.options ?? {}) as Record<string, unknown>;
- const options: { variant?: string; xfade?: boolean; chaptersOnly?: boolean; preview?: { at: number; dur: number } | null } = {};
+ const options: {
+ variant?: string;
+ xfade?: boolean;
+ chaptersOnly?: boolean;
+ chromeOnly?: boolean;
+ preview?: { at: number; dur: number } | null;
+ } = {};
if (raw.variant !== undefined && raw.variant !== "") {
const v = String(raw.variant);
if (!(VARIANTS as string[]).includes(v)) {
@@ -86,6 +92,10 @@ export async function POST(request: Request) {
}
if (raw.xfade === false) options.xfade = false;
if (raw.chaptersOnly) options.chaptersOnly = true;
+ // Re-render the on-screen deck over the segments on disk ("Re-render
+ // on-screen"). Like chaptersOnly it rewrites the deliverable in place because
+ // somebody asked it to, so the overwrite guard below does not apply.
+ if (raw.chromeOnly) options.chromeOnly = true;
if (raw.preview && typeof raw.preview === "object") {
const pv = raw.preview as Record<string, unknown>;
const at = Number(pv.at);
@@ -95,8 +105,11 @@ export async function POST(request: Request) {
}
options.preview = { at, dur };
}
- if (options.chaptersOnly && options.preview) {
- return Response.json({ error: "chaptersOnly and preview are different runs — pick one" }, { status: 400 });
+ if ([options.chaptersOnly, options.chromeOnly, options.preview].filter(Boolean).length > 1) {
+ return Response.json(
+ { error: "chaptersOnly, chromeOnly and preview are different runs — pick one" },
+ { status: 400 },
+ );
}
const project = await projectRef(projectId);
@@ -137,7 +150,7 @@ export async function POST(request: Request) {
// the same file in place by design, and a preview writes a different one, so
// neither is guarded.
const finalPath = variantPaths(path.join(project.dir, "out"), manifest.slug, options.variant ?? DEFAULT_VARIANT).final;
- if (!only && !options.chaptersOnly && !options.preview) {
+ if (!only && !options.chaptersOnly && !options.chromeOnly && !options.preview) {
const [fin, man] = await Promise.all([
stat(finalPath).catch(() => null),
stat(path.join(project.dir, "video.manifest.json")).catch(() => null),
diff --git a/umtool/app/api/report/chrome/files/[...path]/route.ts b/umtool/app/api/report/chrome/files/[...path]/route.ts
@@ -0,0 +1,65 @@
+import path from "node:path";
+import {
+ decodeProjectSegment,
+ deckPreviewDir,
+ deckPreviewFile,
+ rangeResponse,
+ resolveReport,
+} from "@/lib/report/serve.mjs";
+
+export const dynamic = "force-dynamic";
+
+// The deck's preview composition, served to the iframe.
+//
+// /api/report/chrome/files/<project>/<variant>/<file…>, where <project> is the
+// project id base64url-encoded into one segment (POST /api/report/chrome/preview
+// hands out the src; nothing builds it by hand). The project and the variant
+// travel IN THE PATH because the composition names its assets by relative url,
+// and a relative url resolved against `index.html?project=…` drops the query.
+//
+// This is the one report route that takes a path from the client, so it is
+// confined twice: the project and the variant must be members of the server's
+// own scan and list, and the file must resolve -- symlinks followed -- inside
+// that cut's out/<variant>/chrome/deck-preview/. deckPreviewFile is the rule,
+// and it is tested.
+
+const TYPES: Record<string, string> = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".mjs": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+ ".woff2": "font/woff2",
+ ".woff": "font/woff",
+ ".ttf": "font/ttf",
+ ".otf": "font/otf",
+};
+
+export async function GET(request: Request, ctx: { params: Promise<{ path: string[] }> }) {
+ const { path: segments } = await ctx.params;
+ if (!Array.isArray(segments) || segments.length < 3) return new Response("not found", { status: 404 });
+ const [projectSeg, variant, ...rest] = segments;
+
+ const projectId = decodeProjectSegment(projectSeg);
+ if (!projectId) return new Response("not found", { status: 404 });
+ const r = await resolveReport(projectId, variant);
+ if ("error" in r) return new Response(r.error, { status: r.status });
+
+ const file = await deckPreviewFile(deckPreviewDir(r.project.dir, r.variant), rest);
+ if (!file) return new Response("not found", { status: 404 });
+
+ return rangeResponse(request, {
+ abs: file.abs,
+ size: file.size,
+ headers: {
+ "content-type": TYPES[path.extname(file.abs).toLowerCase()] ?? "application/octet-stream",
+ "cache-control": "no-store",
+ "x-content-type-options": "nosniff",
+ },
+ });
+}
diff --git a/umtool/app/api/report/chrome/preview/route.ts b/umtool/app/api/report/chrome/preview/route.ts
@@ -0,0 +1,71 @@
+import { composeDeckPreview, normalizeDraft, scheduleForPreview } from "@/lib/report/onscreen.mjs";
+import { deckPreviewSrc, resolveReport } from "@/lib/report/serve.mjs";
+import { deckGeometry, deckLayout, deckOn, validateChrome } from "umtool-report-to-video/deck";
+
+export const dynamic = "force-dynamic";
+
+// Compose the deck's PREVIEW for one cut, and say where to load it.
+//
+// No render. compose-chrome writes out/<variant>/chrome/deck-preview/ -- never
+// the build's chrome/deck/ or its frame cache -- and the page loads that in an
+// iframe from `src`, at `geometry.deck`'s rect over the footage, seeking it with
+// postMessage. Live typing is patched in by message too (`deck:text`); this
+// route is for when the text or the timing changes enough to recompose.
+//
+// The schedule is the build's own when one exists and still matches the cut,
+// else an estimate from the manifest (`schedule.estimated`), and the request's
+// `draft` -- unsaved rows, id → { title?, subtitle? } | null, the shape PUT
+// /api/report/onscreen takes -- is applied on top.
+//
+// The client sends a project id, a variant and the draft. Never a path.
+export async function POST(request: Request) {
+ let body: Record<string, unknown>;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "expected JSON" }, { status: 400 });
+ }
+
+ const r = await resolveReport(String(body.project ?? ""), body.variant ? String(body.variant) : null);
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ let draft;
+ try {
+ draft = normalizeDraft(body.draft);
+ } catch (e) {
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
+ }
+
+ const { variantManifest, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant, draft);
+ const render = (variantManifest.render ?? {}) as Record<string, unknown>;
+ if (!deckOn(render)) {
+ return Response.json(
+ { error: "the deck is off for this manifest — save render.chrome first", deckOn: false },
+ { status: 409 },
+ );
+ }
+ const { chrome, ...rest } = render;
+ const errors = validateChrome(chrome, rest);
+ if (errors.length) {
+ return Response.json({ error: `render.chrome: ${errors.join("; ")}`, errors }, { status: 400 });
+ }
+
+ try {
+ await composeDeckPreview(r.project, r.variant, schedule);
+ } catch (e) {
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 });
+ }
+
+ return Response.json(
+ {
+ // `v` so the iframe reloads a recomposed preview; the files themselves
+ // are served no-store, so its relative asset urls need none.
+ src: `${deckPreviewSrc(r.project.id, r.variant)}?v=${Date.now()}`,
+ variant: r.variant,
+ geometry: deckGeometry(render),
+ layout: deckLayout(render),
+ schedule,
+ },
+ { headers: { "cache-control": "no-store" } },
+ );
+}
diff --git a/umtool/app/api/report/chrome/route.ts b/umtool/app/api/report/chrome/route.ts
@@ -0,0 +1,80 @@
+import { ChromeRefused, StaleToken, manifestToken, updateChrome } from "@/lib/report/manifest.mjs";
+import { resolveReport } from "@/lib/report/serve.mjs";
+import { DECK_DEFAULTS, deckOn, resolveDeck, validateChrome } from "umtool-report-to-video/deck";
+
+export const dynamic = "force-dynamic";
+
+// The deck's settings: `render.chrome` in the manifest.
+//
+// The client sends a project id, the WHOLE chrome block (or null to turn the
+// deck off) and the token it was given when it read the manifest. It never
+// sends a path. Validation is deck.mjs's validateChrome, run by the writer, so
+// a block this route accepts is one the build accepts; a refusal comes back
+// as the same sentences the build would print, one per problem.
+
+type Body = Record<string, unknown>;
+
+const noStore = { "cache-control": "no-store" };
+
+/** What the settings form starts from: the block as written, and with every default filled. */
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const r = await resolveReport(url.searchParams.get("project") ?? "");
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+ const render = (r.manifest.render ?? {}) as Record<string, unknown>;
+ const { chrome = null, ...rest } = render;
+ return Response.json(
+ {
+ chrome,
+ deckOn: deckOn(render),
+ deck: chrome ? resolveDeck(render) : null,
+ defaults: DECK_DEFAULTS,
+ // A block somebody hand-edited into a state the build would refuse. The
+ // form shows these rather than pretending the saved settings are fine.
+ errors: chrome ? validateChrome(chrome, rest) : [],
+ token: await manifestToken(r.project.dir),
+ },
+ { headers: noStore },
+ );
+}
+
+export async function PUT(request: Request) {
+ let body: Body;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "expected JSON" }, { status: 400 });
+ }
+ if (!("chrome" in body)) {
+ return Response.json({ error: "chrome is required — an object, or null to turn the deck off" }, { status: 400 });
+ }
+
+ const r = await resolveReport(String(body.project ?? ""));
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ try {
+ const res = await updateChrome(r.project.dir, (body.chrome ?? null) as Record<string, unknown> | null, {
+ token: body.token === undefined ? null : String(body.token),
+ });
+ return Response.json(
+ {
+ ok: true,
+ chrome: res.chrome,
+ deck: res.chrome ? resolveDeck({ ...(r.manifest.render ?? {}), chrome: res.chrome }) : null,
+ token: res.token,
+ },
+ { headers: noStore },
+ );
+ } catch (e) {
+ if (e instanceof StaleToken) {
+ return Response.json(
+ { error: e.message, expected: e.expected, got: e.got, stale: true },
+ { status: 409 },
+ );
+ }
+ if (e instanceof ChromeRefused) {
+ return Response.json({ error: e.message, errors: e.errors }, { status: 400 });
+ }
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
+ }
+}
diff --git a/umtool/app/api/report/onscreen/route.ts b/umtool/app/api/report/onscreen/route.ts
@@ -0,0 +1,90 @@
+import { StaleToken, manifestToken, updateOnscreen } from "@/lib/report/manifest.mjs";
+import { deckMetas } from "@/lib/report/onscreen.mjs";
+import { resolveReport } from "@/lib/report/serve.mjs";
+import { selectVariant } from "umtool-report-to-video/build-video";
+import { deckText, isMultiChannel, resolveDeck } from "umtool-report-to-video/deck";
+
+export const dynamic = "force-dynamic";
+
+// The deck's per-entry text: `onscreen: { title?, subtitle? }` on any
+// timeline entry, clip, still or card.
+//
+// PUT is the On-screen table's one save: a map of entry id → the row, or null
+// to clear it. A row REPLACES the entry's whole `onscreen` (`{ title }` alone
+// clears a subtitle override), blanks are dropped, and an entry left with
+// nothing loses the key. One unknown id or one bad value refuses the WHOLE
+// batch -- nothing is written -- because a table that saved all but one row
+// reads as saved.
+
+type Entry = Record<string, unknown> & { id: string; type?: string; onscreen?: Record<string, string> };
+
+const noStore = { "cache-control": "no-store" };
+
+/**
+ * The table's rows for one cut: each entry's saved `onscreen` and what the
+ * deck says with no override at all -- the AUTO title and subtitle the form
+ * shows as placeholders. Auto text comes from the archive's cue files (the
+ * clip bench's source), so before a build it can differ from the fetched
+ * file's metadata the build will use.
+ */
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const r = await resolveReport(url.searchParams.get("project") ?? "", url.searchParams.get("variant"));
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ const cut = selectVariant(r.manifest, r.variant);
+ const entries = (cut.timeline ?? []) as Entry[];
+ const render = cut.render ?? {};
+ const deck = resolveDeck(render);
+ const provenance = cut.provenance ?? {};
+ const multi = isMultiChannel(entries, provenance);
+ const metas = await deckMetas(r.project.dir, r.manifest, entries);
+ const rows = entries.map((e, i) => {
+ const { onscreen, ...bare } = e;
+ return {
+ id: e.id,
+ type: e.type ?? "entry",
+ onscreen: onscreen ?? null,
+ auto: deckText(bare, metas[i] ?? null, provenance, deck, multi),
+ };
+ });
+ return Response.json(
+ {
+ variant: r.variant,
+ rows,
+ maxChars: deck.title.maxChars,
+ multiChannel: multi,
+ token: await manifestToken(r.project.dir),
+ },
+ { headers: noStore },
+ );
+}
+
+export async function PUT(request: Request) {
+ let body: Record<string, unknown>;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "expected JSON" }, { status: 400 });
+ }
+
+ const r = await resolveReport(String(body.project ?? ""));
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ try {
+ const res = await updateOnscreen(
+ r.project.dir,
+ body.onscreen as Record<string, { title?: string; subtitle?: string } | null>,
+ { token: body.token === undefined ? null : String(body.token) },
+ );
+ return Response.json({ ok: true, onscreen: res.onscreen, token: res.token }, { headers: noStore });
+ } catch (e) {
+ if (e instanceof StaleToken) {
+ return Response.json(
+ { error: e.message, expected: e.expected, got: e.got, stale: true },
+ { status: 409 },
+ );
+ }
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
+ }
+}
diff --git a/umtool/app/api/report/segment/route.ts b/umtool/app/api/report/segment/route.ts
@@ -1,6 +1,4 @@
-import { createReadStream } from "node:fs";
-import { resolveClip, segmentFor } from "@/lib/report/serve.mjs";
-import { Readable } from "node:stream";
+import { rangeResponse, resolveClip, segmentFor } from "@/lib/report/serve.mjs";
export const dynamic = "force-dynamic";
@@ -21,7 +19,8 @@ export const dynamic = "force-dynamic";
// what makes "re-render, then watch it" show the new cut rather than the old.
//
// Range support is not optional: without a 206 the <video> element will not seek
-// in a stream it did not fully download.
+// in a stream it did not fully download. rangeResponse() is the one
+// implementation, shared with /api/report/video.
export async function GET(request: Request) {
const url = new URL(request.url);
@@ -47,32 +46,5 @@ export async function GET(request: Request) {
"x-segment": seg.rel,
};
- const range = request.headers.get("range");
- const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
- if (m) {
- const size = seg.size;
- let start = m[1] ? Number(m[1]) : 0;
- let end = m[2] ? Number(m[2]) : size - 1;
- if (!m[1] && m[2]) {
- // A suffix range: the LAST n bytes.
- start = Math.max(0, size - Number(m[2]));
- end = size - 1;
- }
- if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) {
- return new Response(null, { status: 416, headers: { "content-range": `bytes */${size}` } });
- }
- end = Math.min(end, size - 1);
- return new Response(Readable.toWeb(createReadStream(seg.abs, { start, end })) as ReadableStream, {
- status: 206,
- headers: {
- ...headers,
- "content-range": `bytes ${start}-${end}/${size}`,
- "content-length": String(end - start + 1),
- },
- });
- }
-
- return new Response(Readable.toWeb(createReadStream(seg.abs)) as ReadableStream, {
- headers: { ...headers, "content-length": String(seg.size) },
- });
+ return rangeResponse(request, { abs: seg.abs, size: seg.size, headers });
}
diff --git a/umtool/app/api/report/still/route.ts b/umtool/app/api/report/still/route.ts
@@ -0,0 +1,63 @@
+import { deckStill, scheduleForPreview, stillTimeOf } from "@/lib/report/onscreen.mjs";
+import { resolveReport } from "@/lib/report/serve.mjs";
+import { deckOn, validateChrome } from "umtool-report-to-video/deck";
+
+export const dynamic = "force-dynamic";
+
+// A TRUE still of the deck: the composition at one moment, screenshotted by
+// the same browser engine the render uses, as a PNG of the deck region
+// (1920 × deck.height, transparent outside the panel).
+//
+// The live preview is an iframe in the page's own browser, which is close to
+// the render and not the same thing -- fonts, text fitting and the QR are
+// where they differ. This is the answer to "is that what the video will show".
+//
+// ?project&variant& one of:
+// clip=<entry id> the middle of that entry's segment
+// at=<seconds> a moment in the cut's clock
+//
+// Drawn from the same schedule the preview uses (the build's when it matches
+// the cut, else an estimate), saved text only -- a still is of what is saved.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const r = await resolveReport(url.searchParams.get("project") ?? "", url.searchParams.get("variant"));
+ if ("error" in r) return new Response(r.error, { status: r.status });
+
+ const { variantManifest, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant);
+ const render = (variantManifest.render ?? {}) as Record<string, unknown>;
+ if (!deckOn(render)) return new Response("the deck is off for this manifest", { status: 409 });
+ const { chrome, ...rest } = render;
+ const errors = validateChrome(chrome, rest);
+ if (errors.length) return new Response(`render.chrome: ${errors.join("; ")}`, { status: 400 });
+
+ const clip = url.searchParams.get("clip");
+ const atRaw = url.searchParams.get("at");
+ let t: number | null;
+ if (clip) {
+ t = stillTimeOf(schedule, clip);
+ if (t === null) return new Response(`no entry ${clip} in the ${r.variant} cut`, { status: 404 });
+ } else if (atRaw !== null && atRaw !== "") {
+ t = Number(atRaw);
+ if (!Number.isFinite(t) || t < 0 || t > schedule.total) {
+ return new Response(`at must be a number of seconds from 0 to ${schedule.total}`, { status: 400 });
+ }
+ } else {
+ return new Response("name a clip= or an at=", { status: 400 });
+ }
+
+ let png: Buffer;
+ try {
+ png = await deckStill(r.project, r.variant, schedule, t);
+ } catch (e) {
+ return new Response(e instanceof Error ? e.message : String(e), { status: 500 });
+ }
+ return new Response(new Uint8Array(png), {
+ headers: {
+ "content-type": "image/png",
+ "content-length": String(png.length),
+ "cache-control": "no-store",
+ "x-still-at": String(t),
+ "x-schedule-estimated": String(!!schedule.estimated),
+ },
+ });
+}
diff --git a/umtool/app/api/report/video/route.ts b/umtool/app/api/report/video/route.ts
@@ -0,0 +1,44 @@
+import { rangeResponse, resolveReport, videoFor } from "@/lib/report/serve.mjs";
+
+export const dynamic = "force-dynamic";
+
+// A built CUT, to a <video> element: the deliverable (`kind=final`, the
+// default) or the short window `--chrome-preview` writes (`kind=preview`).
+//
+// ?project&variant&kind. Names, never paths: the file is the pipeline's own
+// variantPaths() for the manifest's slug and a variant from its list.
+//
+// The `v` contract is the segment route's: a re-render writes the SAME path, so
+// the client passes the mtime it was told about (`x-video-mtime`) as `v`, and
+// only a matching one may be cached.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const kind = url.searchParams.get("kind") || "final";
+ if (kind !== "final" && kind !== "preview") {
+ return new Response("kind must be final or preview", { status: 400 });
+ }
+ const r = await resolveReport(url.searchParams.get("project") ?? "", url.searchParams.get("variant"));
+ if ("error" in r) return new Response(r.error, { status: r.status });
+
+ const video = await videoFor(r.project, r.manifest, r.variant, kind);
+ if (!video) {
+ // The normal state of a cut nobody has built yet; said in words.
+ return new Response(
+ kind === "preview" ? "no on-screen preview has been rendered for this cut yet" : "this cut has not been built yet",
+ { status: 404 },
+ );
+ }
+
+ const fresh = url.searchParams.get("v") === String(video.mtimeMs);
+ return rangeResponse(request, {
+ abs: video.abs,
+ size: video.size,
+ headers: {
+ "content-type": "video/mp4",
+ "accept-ranges": "bytes",
+ "cache-control": fresh ? "private, max-age=3600, immutable" : "private, no-store",
+ "x-video-mtime": String(video.mtimeMs),
+ "x-video": video.rel,
+ },
+ });
+}
diff --git a/umtool/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts
@@ -50,6 +50,9 @@ export async function PUT(request: Request) {
// Whether the walk has looked at this clip: "confirmed", or empty to clear
// it. A non-empty `correction` is the other answer and needs no value.
"verdict",
+ // What the deck says over this clip: { title?, subtitle? }, or null to
+ // clear it. Normalised and checked by the writer, like the date.
+ "onscreen",
]) {
if (body[k] !== undefined) patch[k] = body[k];
}
diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx
@@ -10,6 +10,16 @@ import { buttonVariants } from "@/components/ui/button";
// disagrees with the header by a character is worth less than no preview: you
// would only find out twenty minutes into a build.
import { attributionLine } from "umtool-report-to-video/attribution";
+import {
+ DeckFrame,
+ NeutralFrame,
+ composePreview,
+ midOf,
+ onscreenValue,
+ type DeckPreviewDoc,
+ type DeckTexts,
+ type Onscreen,
+} from "./OnscreenSection";
// ---------------------------------------------------------------------------
// Editing a clip's window against the audio and the words.
@@ -92,8 +102,15 @@ type Clip = {
lockCut: boolean;
/** "confirmed" / "incorrect", or null for "nobody has looked at this yet". */
verdict: "confirmed" | "incorrect" | null;
+ /** What the on-screen panel says over this clip. Absent means the auto text. */
+ onscreen: Onscreen | null;
};
+/** The on-screen fields as the inputs hold them. */
+type OsDraft = { title: string; subtitle: string };
+const osDraftOf = (o: Onscreen | null): OsDraft => ({ title: o?.title ?? "", subtitle: o?.subtitle ?? "" });
+const sameOs = (a: OsDraft, b: OsDraft) => a.title.trim() === b.title.trim() && a.subtitle.trim() === b.subtitle.trim();
+
/**
* Has anybody looked at this clip, and did they agree with the description?
*
@@ -164,6 +181,7 @@ const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({
cutEnd: e.cutEnd == null ? null : Number(e.cutEnd),
lockCut: !!e.lockCut,
verdict: e.verdict === "confirmed" || e.verdict === "incorrect" ? e.verdict : null,
+ onscreen: (e.onscreen as Onscreen | undefined) ?? null,
});
export type ClipBenchData = {
@@ -219,6 +237,8 @@ export type ClipBenchData = {
mixHref: string | null;
/** Every other clip in the cut from this same recording, by start. */
siblings: Sibling[];
+ /** Is the cut built with the on-screen panel (`render.chrome`)? */
+ deckOn: boolean;
};
const hms = (t: number) => {
@@ -313,6 +333,20 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
const [playback, setPlayback] = useState<Playback>(PLAYBACK_DEFAULT);
const [segment, setSegment] = useState(data.segment);
const [segmentMtime, setSegmentMtime] = useState(data.segmentMtime);
+ // ---- the on-screen panel ------------------------------------------------
+ // Its two fields, the text the panel says with them empty (`osAuto`, from
+ // the On-screen route), and a composed preview of the whole cut's panel, of
+ // which this bench shows this clip's segment.
+ const [os, setOs] = useState<OsDraft>(() => osDraftOf(data.clip.onscreen));
+ const [osAuto, setOsAuto] = useState<OsDraft | null>(null);
+ const [osMax, setOsMax] = useState(48);
+ const [deckPreview, setDeckPreview] = useState<DeckPreviewDoc | null>(null);
+ const [deckError, setDeckError] = useState<string | null>(null);
+ // The rendered strip is folded; its overlay only exists while it is open.
+ const [renderedOpen, setRenderedOpen] = useState(false);
+ const [segT, setSegT] = useState<number | null>(null);
+ const [segPaused, setSegPaused] = useState(true);
+ const [segDur, setSegDur] = useState(0);
const router = useRouter();
const video = useRef<HTMLVideoElement | null>(null);
@@ -392,6 +426,64 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
// ---- playback preferences ------------------------------------------------
useEffect(() => setPlayback(readPlayback()), []);
+ // ---- the on-screen preview ----------------------------------------------
+ //
+ // Composed once per clip: the cut's whole panel, from which the strip and
+ // the overlay show this clip's segment. Typing never recomposes -- the
+ // fields are patched in by message, before anything is saved.
+ useEffect(() => {
+ if (!data.deckOn) return;
+ let live = true;
+ void (async () => {
+ const [pv, table] = await Promise.all([
+ composePreview(data.project, null),
+ fetch(`/api/report/onscreen?project=${encodeURIComponent(data.project)}`, { cache: "no-store" })
+ .then((r) => (r.ok ? r.json() : null))
+ .catch(() => null) as Promise<{
+ rows?: { id: string; auto: OsDraft }[];
+ maxChars?: number;
+ } | null>,
+ ]);
+ if (!live) return;
+ if ("error" in pv) setDeckError(pv.error);
+ else setDeckPreview(pv);
+ const row = table?.rows?.find((r) => r.id === clip.id);
+ if (row) setOsAuto(row.auto);
+ if (table?.maxChars) setOsMax(table.maxChars);
+ })();
+ return () => {
+ live = false;
+ };
+ }, [data.deckOn, data.project, clip.id]);
+
+ // The rendered segment drives the overlay's clock: its own time, plus where
+ // its segment starts in the cut.
+ useEffect(() => {
+ const el = segVideo.current;
+ if (!el || !renderedOpen) return;
+ const tick = () => setSegT(el.currentTime);
+ const state = () => setSegPaused(el.paused);
+ const meta = () => {
+ setSegDur(el.duration || 0);
+ // A quarter in, not zero: at the segment's first frame the PREVIOUS
+ // clip's title is still handing over, which reads as the wrong title.
+ if (el.currentTime === 0 && el.duration) el.currentTime = el.duration / 4;
+ };
+ el.addEventListener("timeupdate", tick);
+ el.addEventListener("seeked", tick);
+ el.addEventListener("play", state);
+ el.addEventListener("pause", state);
+ el.addEventListener("loadedmetadata", meta);
+ if (el.readyState >= 1) meta();
+ return () => {
+ el.removeEventListener("timeupdate", tick);
+ el.removeEventListener("seeked", tick);
+ el.removeEventListener("play", state);
+ el.removeEventListener("pause", state);
+ el.removeEventListener("loadedmetadata", meta);
+ };
+ }, [segment, segmentMtime, renderedOpen, deckPreview]);
+
/**
* Change a preference AND remember it. Never an effect on `playback`.
*
@@ -437,7 +529,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
const again = () => apply(el);
el.addEventListener("loadedmetadata", again);
return () => el.removeEventListener("loadedmetadata", again);
- }, [playback, segment, cached]);
+ }, [playback, segment, cached, renderedOpen, deckPreview]);
// ---- audition -----------------------------------------------------------
const play = useCallback(
@@ -570,6 +662,17 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
for (const [k] of ATTRIB) if (patch[k] !== undefined) out[k] = attribValue(next, k);
return out;
});
+ // The on-screen pair saves as one value. Each field goes back to what was
+ // stored only if it still holds what was sent -- typing on into the
+ // other field while this was in flight must not be undone by it.
+ if (patch.onscreen !== undefined) {
+ const sent = osDraftOf(patch.onscreen as Onscreen | null);
+ const stored = osDraftOf(next.onscreen);
+ setOs((d) => ({
+ title: d.title.trim() === sent.title ? stored.title : d.title,
+ subtitle: d.subtitle.trim() === sent.subtitle ? stored.subtitle : d.subtitle,
+ }));
+ }
token.current = String(j.token ?? "");
setNote("saved");
// The widener's opinion changes when the window does.
@@ -629,6 +732,12 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
[draft, clip, save, needNote],
);
+ /** Persist the on-screen pair, on blur or Enter, if either field changed. */
+ const commitOs = useCallback(() => {
+ if (sameOs(os, osDraftOf(clip.onscreen))) return;
+ void save({ onscreen: onscreenValue(os) });
+ }, [os, clip.onscreen, save]);
+
// ---- the window patch, one rule, two callers ------------------------------
//
// `save window` writes it on demand; a confirmation carries it when the edges
@@ -1133,6 +1242,28 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
const verdict = verdictOf(clip);
const reviewed = data.reviewedOthers + (verdict === "unreviewed" ? 0 : 1);
+ // ---- the on-screen panel, as this clip's segment of it --------------------
+ const deckSeg = deckPreview?.schedule.segments.find((sg) => sg.id === clip.id) ?? null;
+ const osShown: OsDraft = {
+ title: os.title.trim() || osAuto?.title || "",
+ subtitle: os.subtitle.trim() || osAuto?.subtitle || "",
+ };
+ // Nothing is patched in until the auto text is known: an empty field would
+ // otherwise blank the line the composition was built with.
+ const osTexts: DeckTexts = osAuto ? { [clip.id]: osShown } : {};
+ const osChanged = !sameOs(os, osDraftOf(clip.onscreen));
+ const osOver = osShown.title.length > osMax;
+ // The strip follows the player when the player is inside what the cut
+ // plays, mapped onto the cut's clock; anywhere else it holds mid-segment.
+ const playFrom = clip.cutStart ?? clip.start;
+ const stripT = !deckSeg
+ ? 0
+ : playhead != null && playhead >= playFrom - 0.05 && playhead <= playFrom + deckSeg.duration
+ ? deckSeg.start + Math.max(0, playhead - playFrom)
+ : midOf(deckSeg);
+ const overlayT = deckSeg ? deckSeg.start + Math.min(segT ?? deckSeg.duration / 4, deckSeg.duration) : 0;
+ const showOverlay = data.deckOn && !!deckPreview && renderedOpen;
+
return (
// ONE SCREEN, at a desk.
//
@@ -1756,6 +1887,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
bench taller than a laptop. */}
<details
data-rendered-strip=""
+ onToggle={(e) => setRenderedOpen((e.currentTarget as HTMLDetailsElement).open)}
className="shrink-0 rounded border border-[var(--color-line)] px-2 py-1 text-[12px] open:max-h-[55vh] open:overflow-y-auto"
>
<summary className="cursor-pointer text-[11px] text-[var(--color-dim)]">
@@ -1768,7 +1900,59 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
</summary>
<div className="mt-2 grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
<div className="space-y-2">
- {segSrc ? (
+ {segSrc && showOverlay ? (
+ // The segment with the on-screen panel laid over it, where the
+ // build overlays it. The panel covers the bottom of the frame
+ // -- where native controls would be -- so the player's
+ // controls are a row of its own underneath.
+ <div className="space-y-1">
+ <DeckFrame preview={deckPreview!} t={overlayT} texts={osTexts} testid="bench-deck-preview">
+ <video
+ ref={segVideo}
+ data-testid="segment-video"
+ src={segSrc}
+ className="absolute inset-0 h-full w-full cursor-pointer object-contain"
+ preload="metadata"
+ onClick={(e) => {
+ const el = e.currentTarget;
+ if (el.paused) void el.play().catch(() => {});
+ else el.pause();
+ }}
+ />
+ </DeckFrame>
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ data-testid="segment-play"
+ className={buttonVariants({ size: "sm" })}
+ onClick={() => {
+ const el = segVideo.current;
+ if (!el) return;
+ if (el.paused) void el.play().catch(() => {});
+ else el.pause();
+ }}
+ >
+ {segPaused ? "play" : "pause"}
+ </button>
+ <input
+ type="range"
+ data-testid="segment-scrub"
+ min={0}
+ max={segDur || 0}
+ step={0.01}
+ value={Math.min(segT ?? 0, segDur || 0)}
+ onChange={(e) => {
+ const el = segVideo.current;
+ if (el) el.currentTime = Number(e.target.value);
+ }}
+ className="min-w-0 flex-1"
+ />
+ <span className="num font-mono text-[11px] text-[var(--color-dim)]">
+ {(segT ?? 0).toFixed(1)} / {segDur.toFixed(1)}s
+ </span>
+ </div>
+ </div>
+ ) : segSrc ? (
<video
ref={segVideo}
data-testid="segment-video"
@@ -1777,6 +1961,17 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
preload="metadata"
controls
/>
+ ) : showOverlay ? (
+ <div data-testid="no-segment">
+ <DeckFrame
+ preview={deckPreview!}
+ t={deckSeg ? midOf(deckSeg) : 0}
+ texts={osTexts}
+ testid="bench-deck-preview"
+ >
+ <NeutralFrame geometry={deckPreview!.geometry} label="nothing rendered for this clip yet" />
+ </DeckFrame>
+ </div>
) : (
<div
data-testid="no-segment"
@@ -2046,6 +2241,85 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
})}
</div>
+ {/* ---- on-screen: what the panel under the footage says ----
+ Beside the header's fields because it is the same sitting: the
+ words you hear are the words a title should summarise. Both
+ save together, on blur or Enter; an empty one is the grey text. */}
+ <div
+ data-testid="bench-onscreen"
+ data-deck-on={data.deckOn ? "1" : "0"}
+ className="space-y-1 rounded border border-[var(--color-line)] px-2 py-1.5"
+ >
+ <div className="flex flex-wrap items-baseline gap-2">
+ <span className="micro">on-screen</span>
+ {osChanged && <span className="text-[11px] text-[var(--color-dirty)]">unsaved</span>}
+ {!data.deckOn && (
+ <span className="text-[11px] text-[var(--color-dim)]">
+ off for this report — kept, and drawn once it is on (project page → On-screen)
+ </span>
+ )}
+ </div>
+ {(
+ [
+ ["title", "The big line over this clip. Empty: no title, and the source line takes its place."],
+ ["subtitle", "The line under it. Empty: the source and date, from the archive."],
+ ] as const
+ ).map(([k, why]) => (
+ <label key={k} className="flex items-center gap-2" title={why}>
+ <span className="w-14 shrink-0 font-mono text-[11px] text-[var(--color-text)]">{k}</span>
+ <input
+ type="text"
+ data-testid={`bench-onscreen-${k}`}
+ name={`onscreen-${k}`}
+ value={os[k]}
+ maxLength={200}
+ placeholder={
+ osAuto?.[k] ||
+ (k === "title" ? "(no title — the source line takes its place)" : "(the source and date)")
+ }
+ onChange={(e) => setOs((d) => ({ ...d, [k]: e.target.value }))}
+ onBlur={commitOs}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitOs();
+ }
+ }}
+ className={`min-w-0 flex-1 rounded border bg-[var(--color-panel-2)] px-2 py-1 text-[12px] placeholder:italic placeholder:text-[var(--color-dim)] ${
+ k === "title" && osOver ? "border-[var(--color-dirty)]" : "border-[var(--color-line)]"
+ }`}
+ />
+ {k === "title" && (
+ <span
+ data-testid="bench-onscreen-count"
+ data-over={osOver ? "1" : "0"}
+ className={`num w-12 shrink-0 text-right font-mono text-[11px] ${osOver ? "text-[var(--color-dirty)]" : "text-[var(--color-dim)]"}`}
+ >
+ {osShown.title.length}/{osMax}
+ </span>
+ )}
+ {k === "subtitle" && <span className="w-12 shrink-0" />}
+ </label>
+ ))}
+ {data.deckOn &&
+ (deckPreview ? (
+ <DeckFrame
+ preview={deckPreview}
+ t={stripT}
+ texts={osTexts}
+ mode="strip"
+ testid="bench-deck-strip"
+ />
+ ) : (
+ <p className="text-[11px] text-[var(--color-dim)]" data-testid="bench-deck-pending">
+ {deckError ? `no preview: ${deckError}` : "composing the panel…"}
+ </p>
+ ))}
+ {deckPreview && !deckSeg && (
+ <p className="text-[11px] text-[var(--color-dirty)]">this clip is not in the default cut</p>
+ )}
+ </div>
+
<details className="rounded border border-[var(--color-line)] px-2 py-1 text-[11px] text-[var(--color-dim)]">
<summary className="cursor-pointer">what these fields are for</summary>
<div className="mt-1 space-y-1 leading-snug">
diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx
@@ -12,6 +12,7 @@ import {
} from "@/lib/projects/report.mjs";
import { projectCache, segmentFor, windowsFor } from "@/lib/report/serve.mjs";
import { FETCH_MAX_PAD } from "@/lib/report/driver.mjs";
+import { deckOn } from "umtool-report-to-video/deck";
import type { ProjectRef } from "@/lib/project-types";
// The server half of the bench: resolve the clip, read what it needs, and hand
@@ -89,7 +90,16 @@ export default async function ClipBenchPage({
lockCut: !!entry.lockCut,
verdict:
entry.verdict === "confirmed" || entry.verdict === "incorrect" ? entry.verdict : null,
+ // From the manifest's own entry: what the on-screen panel says over this
+ // clip, when somebody has written it.
+ onscreen:
+ ((manifest.timeline ?? []) as { id: string; onscreen?: { title?: string; subtitle?: string } }[]).find(
+ (e) => e.id === clipId,
+ )?.onscreen ?? null,
},
+ // Whether the cut is built with the on-screen panel. Decides whether the
+ // bench composes a preview of it; the fields are there either way.
+ deckOn: deckOn(manifest.render ?? {}),
view,
windows: windows.map((w: { name: string; from: number; to: number }) => ({
name: w.name,
diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx
@@ -0,0 +1,1317 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { badgeVariants } from "@/components/ui/badge";
+import { buttonVariants } from "@/components/ui/button";
+
+// ---------------------------------------------------------------------------
+// ON-SCREEN: the persistent panel under the footage of a report cut.
+//
+// Called "on-screen" everywhere a person reads it. The manifest and the
+// pipeline call it the deck (`render.chrome.layout: "deck"`), but in this app
+// "deck" is already the song kind's name, and one word meaning two things two
+// pages apart is how somebody edits the wrong one.
+//
+// THE PREVIEW IS THE COMPOSITION ITSELF, not a mock of it. The pipeline's own
+// HTML (chrome-deck.mjs) is composed into out/<variant>/chrome/deck-preview/
+// and loaded in an iframe at the panel's rect over a 16:9 frame; the scrubber
+// seeks its timeline and every keystroke in the table is patched into it by
+// postMessage, so a title is seen in the real face and the real fit before it
+// is saved. Recomposing (a server round trip) is only for what postMessage
+// cannot carry: settings, and a schedule that moved.
+//
+// It is close to the render and not the same thing -- the page's browser is
+// not the render's -- which is what "true still" is for: the render engine's
+// own screenshot of one moment, of what is SAVED.
+//
+// THE IFRAME IS SAME-ORIGIN AND NOT SANDBOXED. Its fonts and assets are
+// fetched by relative url from /api/report/chrome/files, and a sandbox without
+// `allow-same-origin` makes the document an opaque origin, every font load a
+// CORS request that route does not answer, and the preview draws in the
+// fallback face -- the one thing it must not get wrong. A sandbox WITH
+// `allow-scripts allow-same-origin` is no boundary at all (the document may
+// lift it, and the browser warns as much on every load), so it is left off
+// rather than worn as decoration. What makes the frame safe to load is what
+// it is: the pipeline's own composition, every manifest string escaped by
+// chrome-deck.mjs, served no-store from the project's own preview directory.
+// Messages both ways are checked against this origin and this frame.
+// ---------------------------------------------------------------------------
+
+// ---- the composition's contract, as this page reads it --------------------
+
+export type Rect = { x: number; y: number; width: number; height: number };
+export type DeckGeometry = { W: number; H: number; footage: Rect; deck: Rect };
+export type DeckSegment = {
+ id: string;
+ type: string;
+ start: number;
+ duration: number;
+ end: number;
+ title: string;
+ subtitle: string;
+ qrUrl: string | null;
+ hideDeck: boolean;
+};
+export type DeckSchedule = {
+ estimated?: boolean;
+ fps: number;
+ transition: number;
+ total: number;
+ multiChannel: boolean;
+ segments: DeckSegment[];
+};
+export type DeckPreviewDoc = { src: string; variant: string; geometry: DeckGeometry; schedule: DeckSchedule };
+/** What each segment's panel should say right now, by entry id. */
+export type DeckTexts = Record<string, { title: string; subtitle: string }>;
+export type Onscreen = { title?: string; subtitle?: string };
+
+/** The middle of a segment: where a still of it, and a jump to it, lands. */
+export const midOf = (s: DeckSegment) => Math.round((s.start + s.duration / 2) * 1000) / 1000;
+
+/** The segment on screen at `t`. Past the end, the last one. */
+export const segmentAt = (schedule: DeckSchedule, t: number): DeckSegment | null => {
+ const segs = schedule.segments;
+ for (let i = segs.length - 1; i >= 0; i--) if (t >= segs[i].start) return segs[i];
+ return segs[0] ?? null;
+};
+
+/** A row's draft as the writers take it: blanks dropped, nothing left → null. */
+export const onscreenValue = (d: { title: string; subtitle: string }): Onscreen | null => {
+ const o: Onscreen = {};
+ if (d.title.trim()) o.title = d.title.trim();
+ if (d.subtitle.trim()) o.subtitle = d.subtitle.trim();
+ return o.title || o.subtitle ? o : null;
+};
+
+/** Ask for a fresh preview composition. One door, used by both callers. */
+export async function composePreview(
+ project: string,
+ variant: string | null,
+ draft: Record<string, Onscreen | null> = {},
+): Promise<DeckPreviewDoc | { error: string }> {
+ const r = await fetch("/api/report/chrome/preview", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, variant: variant || undefined, draft }),
+ });
+ const j = (await r.json().catch(() => ({}))) as Record<string, unknown>;
+ if (!r.ok) return { error: String(j.error ?? r.status) };
+ return j as unknown as DeckPreviewDoc;
+}
+
+const clock = (t: number) => {
+ const s = Math.max(0, t);
+ const m = Math.floor(s / 60);
+ return `${m}:${(s - m * 60).toFixed(1).padStart(4, "0")}`;
+};
+
+// ---------------------------------------------------------------------------
+// DeckFrame: the composition in an iframe, at its rect, seeked and patched.
+//
+// `frame` is a 16:9 picture (children are the backdrop: a still of the
+// footage, or a neutral frame) with the panel at geometry.deck; `strip` is the
+// panel alone. Either way the iframe is laid out at the composition's own
+// pixel size and scaled by a transform, so the text fit inside it is measured
+// at 1920 wide -- the width the render measures at.
+// ---------------------------------------------------------------------------
+export function DeckFrame({
+ preview,
+ t,
+ texts,
+ mode = "frame",
+ testid,
+ children,
+ className = "",
+}: {
+ preview: DeckPreviewDoc;
+ t: number;
+ texts: DeckTexts;
+ mode?: "frame" | "strip";
+ testid?: string;
+ children?: React.ReactNode;
+ className?: string;
+}) {
+ const box = useRef<HTMLDivElement | null>(null);
+ const frame = useRef<HTMLIFrameElement | null>(null);
+ const [width, setWidth] = useState(0);
+ // The `deck:ready` the composition posts once its fonts are in and its
+ // titles are fitted. Nothing is sent before it: a message to a document
+ // still loading is dropped without a word.
+ const [ready, setReady] = useState<{ src: string; total: number; ids: string[] } | null>(null);
+ // What this document has already been told, so a keystroke sends one row.
+ const sent = useRef<DeckTexts>({});
+ const g = preview.geometry;
+ const src = `${preview.src}${preview.src.includes("?") ? "&" : "?"}preview=1`;
+
+ useEffect(() => {
+ const el = box.current;
+ if (!el) return;
+ const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const onMsg = (e: MessageEvent) => {
+ // Only this frame's document, and only from this origin.
+ if (e.source !== frame.current?.contentWindow || e.origin !== window.location.origin) return;
+ const m = (e.data ?? {}) as { type?: string; total?: number; ids?: string[] };
+ if (m.type !== "deck:ready") return;
+ sent.current = {};
+ setReady({ src, total: Number(m.total) || 0, ids: Array.isArray(m.ids) ? m.ids : [] });
+ };
+ window.addEventListener("message", onMsg);
+ return () => window.removeEventListener("message", onMsg);
+ }, [src]);
+
+ const live = ready?.src === src;
+ const post = useCallback((msg: Record<string, unknown>) => {
+ frame.current?.contentWindow?.postMessage(msg, window.location.origin);
+ }, []);
+
+ // Text first, then the seek, in one effect: a patched title is re-fitted
+ // inside the composition, and the seek lands on the patched nodes.
+ useEffect(() => {
+ if (!live) return;
+ for (const [id, v] of Object.entries(texts)) {
+ const prev = sent.current[id];
+ if (prev && prev.title === v.title && prev.subtitle === v.subtitle) continue;
+ post({ type: "deck:text", id, title: v.title, subtitle: v.subtitle });
+ sent.current[id] = v;
+ }
+ }, [live, texts, post]);
+ useEffect(() => {
+ if (live) post({ type: "deck:seek", t });
+ }, [live, t, post]);
+
+ const scale = width > 0 ? width / (mode === "frame" ? g.W : g.deck.width) : 0;
+ return (
+ <div
+ ref={box}
+ data-testid={testid}
+ data-deck-ready={live ? "1" : "0"}
+ className={`relative w-full overflow-hidden rounded border border-[var(--color-line)] bg-black ${className}`}
+ style={{ aspectRatio: mode === "frame" ? `${g.W} / ${g.H}` : `${g.deck.width} / ${g.deck.height}` }}
+ >
+ {mode === "frame" && children}
+ <iframe
+ ref={frame}
+ key={src}
+ src={src}
+ title="on-screen preview"
+ data-testid={testid ? `${testid}-iframe` : undefined}
+ tabIndex={-1}
+ aria-hidden
+ style={{
+ position: "absolute",
+ left: mode === "frame" ? g.deck.x * scale : 0,
+ top: mode === "frame" ? g.deck.y * scale : 0,
+ width: g.deck.width,
+ height: g.deck.height,
+ transform: `scale(${scale})`,
+ transformOrigin: "0 0",
+ border: 0,
+ background: "transparent",
+ // A document with no color-scheme of its own is "normal"; matching it
+ // here is what keeps the browser from painting an opaque backdrop
+ // behind a transparent frame on a dark page.
+ colorScheme: "normal",
+ pointerEvents: "none",
+ visibility: scale > 0 ? "visible" : "hidden",
+ }}
+ />
+ </div>
+ );
+}
+
+/** Where the footage goes, drawn as a box: the backdrop when there is no picture. */
+export function NeutralFrame({ geometry: g, label = "footage" }: { geometry: DeckGeometry; label?: string }) {
+ const pct = (n: number, of: number) => `${(n / of) * 100}%`;
+ return (
+ <div className="absolute inset-0 bg-[#0d0f14]" data-testid="onscreen-neutral-frame">
+ <div
+ className="absolute flex items-center justify-center border border-dashed border-[var(--color-line)] bg-[var(--color-panel)]"
+ style={{
+ left: pct(g.footage.x, g.W),
+ top: pct(g.footage.y, g.H),
+ width: pct(g.footage.width, g.W),
+ height: pct(g.footage.height, g.H),
+ }}
+ >
+ <span className="micro">{label}</span>
+ </div>
+ </div>
+ );
+}
+
+// ---------------------------------------------------------------------------
+// The settings form: every key of render.chrome.deck, flattened.
+// ---------------------------------------------------------------------------
+
+type DeckSettings = {
+ height: number;
+ footageScale: number;
+ background: string;
+ pip: { spacing: string; size: number; activeSize: number };
+ title: { size: number; maxChars: number };
+ subtitle: { parts: "auto" | string[]; dateFormat: string };
+ qr: { show: boolean; size: number };
+ overCards: string;
+ motion: { out: number; in: number; pip: number };
+};
+
+type Field =
+ | { key: string; label: string; kind: "int" | "num"; step?: number; hint: string }
+ | { key: string; label: string; kind: "select"; options: string[]; hint: string }
+ | { key: string; label: string; kind: "bool"; hint: string }
+ | { key: string; label: string; kind: "parts"; hint: string };
+
+/** Grouped as the form shows them. The hints are deck.mjs's ranges. */
+const GROUPS: { name: string; fields: Field[] }[] = [
+ {
+ name: "panel",
+ fields: [
+ { key: "height", label: "height", kind: "int", hint: "px, 120–400" },
+ { key: "footageScale", label: "footage scale", kind: "num", step: 0.01, hint: "0.5–1; the footage must fit above the panel" },
+ { key: "background", label: "background", kind: "select", options: ["panel", "flush"], hint: "panel is lifted; flush sits on the frame" },
+ { key: "overCards", label: "over cards", kind: "select", options: ["hide", "show"], hint: "hide slides the panel away over a card" },
+ ],
+ },
+ {
+ name: "title",
+ fields: [
+ { key: "title.size", label: "size", kind: "num", hint: "px, 24–96; a long title shrinks to 60 % of it" },
+ { key: "title.maxChars", label: "max chars", kind: "int", hint: "10–120; the table's counter, not a refusal" },
+ ],
+ },
+ {
+ name: "subtitle",
+ fields: [
+ { key: "subtitle.parts", label: "parts", kind: "parts", hint: "auto, or a list of channel, title, date, clock" },
+ { key: "subtitle.dateFormat", label: "date", kind: "select", options: ["long", "iso"], hint: "long is Aug 14, 2026" },
+ ],
+ },
+ {
+ name: "timeline",
+ fields: [
+ { key: "pip.spacing", label: "spacing", kind: "select", options: ["even", "time"], hint: "even, or by each clip's length" },
+ { key: "pip.size", label: "pip", kind: "num", hint: "px, 2–24" },
+ { key: "pip.activeSize", label: "active", kind: "num", hint: "px, 2–40, at least the pip" },
+ ],
+ },
+ {
+ name: "qr",
+ fields: [
+ { key: "qr.show", label: "show", kind: "bool", hint: "off drops every clip's QR" },
+ { key: "qr.size", label: "size", kind: "num", hint: "px, 80–380 and at most height − 20" },
+ ],
+ },
+ {
+ name: "motion",
+ fields: [
+ { key: "motion.out", label: "out", kind: "num", step: 0.05, hint: "s, 0–2: the old title wiping out" },
+ { key: "motion.in", label: "in", kind: "num", step: 0.05, hint: "s, 0–2: the new title expanding" },
+ { key: "motion.pip", label: "pip", kind: "num", step: 0.05, hint: "s, 0–3: the marker's travel" },
+ ],
+ },
+];
+const FIELDS = GROUPS.flatMap((g) => g.fields);
+
+type Form = Record<string, string | boolean>;
+
+const getPath = (o: unknown, key: string): unknown =>
+ key.split(".").reduce<unknown>((v, k) => (v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined), o);
+
+const formOf = (deck: DeckSettings): Form =>
+ Object.fromEntries(
+ FIELDS.map((f) => {
+ const v = getPath(deck, f.key);
+ if (f.kind === "bool") return [f.key, !!v];
+ if (f.kind === "parts") return [f.key, Array.isArray(v) ? v.join(", ") : String(v ?? "auto")];
+ return [f.key, v == null ? "" : String(v)];
+ }),
+ );
+
+/**
+ * The form, back into a `render.chrome.deck` block: ONLY what differs from
+ * the defaults, so a manifest says what somebody chose and a default that
+ * moves later still reaches it. Anything that does not parse is sent as typed
+ * -- the validator's sentence is a better answer than a silent clamp here.
+ */
+const deckOf = (form: Form, defaults: DeckSettings): Record<string, unknown> => {
+ const out: Record<string, unknown> = {};
+ for (const f of FIELDS) {
+ const raw = form[f.key];
+ let v: unknown;
+ if (f.kind === "bool") v = !!raw;
+ else if (f.kind === "parts") {
+ const s = String(raw).trim();
+ v = !s || s === "auto" ? "auto" : s.split(/[\s,·]+/).filter(Boolean);
+ } else if (f.kind === "select") v = String(raw);
+ else {
+ const s = String(raw).trim();
+ if (!s) continue; // blank = the default
+ v = Number.isFinite(Number(s)) ? Number(s) : s;
+ }
+ if (JSON.stringify(v) === JSON.stringify(getPath(defaults, f.key))) continue;
+ const [a, b] = f.key.split(".");
+ if (b) out[a] = { ...((out[a] as Record<string, unknown>) ?? {}), [b]: v };
+ else out[a] = v;
+ }
+ return out;
+};
+
+// ---------------------------------------------------------------------------
+
+type ChromeDoc = {
+ chrome: Record<string, unknown> | null;
+ deckOn: boolean;
+ deck: DeckSettings | null;
+ defaults: DeckSettings;
+ errors: string[];
+ token: string | null;
+};
+type Row = {
+ id: string;
+ type: string;
+ onscreen: Onscreen | null;
+ auto: { title: string; subtitle: string };
+};
+type Draft = { title: string; subtitle: string };
+type JobView = {
+ id: string;
+ state: "running" | "done" | "failed";
+ stepIndex: number;
+ steps: { label: string }[];
+ error: string | null;
+ log: string[];
+ next: number;
+};
+
+const draftOf = (o: Onscreen | null): Draft => ({ title: o?.title ?? "", subtitle: o?.subtitle ?? "" });
+const sameDraft = (a: Draft, b: Draft) => a.title.trim() === b.title.trim() && a.subtitle.trim() === b.subtitle.trim();
+
+const STALE =
+ "the manifest changed since you opened this — reload the saved values (your edits are kept) and save again, or your edit would overwrite whatever was written";
+
+const field =
+ "rounded border bg-[var(--color-panel-2)] px-1.5 py-0.5 text-[12px] text-[var(--color-text)] placeholder:italic placeholder:text-[var(--color-dim)]";
+const input = `${field} border-[var(--color-line)]`;
+
+export default function OnscreenSection({
+ project,
+ entries,
+ built,
+}: {
+ project: string;
+ /**
+ * Is the default cut's deliverable on disk? The server already knows, and
+ * asking the video route about a file that is not there is a 404 in the
+ * console of every page view of an unbuilt report.
+ */
+ built: boolean;
+ /** Which entries have a built segment: the backdrop the preview can show. */
+ entries: { id: string; kind: string; segment: boolean }[];
+}) {
+ const [doc, setDoc] = useState<ChromeDoc | null>(null);
+ const [loadError, setLoadError] = useState<string | null>(null);
+ const [variants, setVariants] = useState<string[]>([]);
+ const [variant, setVariant] = useState("");
+ const [form, setForm] = useState<Form>({});
+ const [formDirty, setFormDirty] = useState(false);
+ const [errors, setErrors] = useState<string[]>([]);
+ const [rows, setRows] = useState<Row[]>([]);
+ // The rows as last saved, for loadRows to tell an edit from an old value.
+ const rowsRef = useRef<Row[]>([]);
+ useEffect(() => {
+ rowsRef.current = rows;
+ }, [rows]);
+ const [maxChars, setMaxChars] = useState(48);
+ const [drafts, setDrafts] = useState<Record<string, Draft>>({});
+ const [note, setNote] = useState<string | null>(null);
+ const [stale, setStale] = useState(false);
+ const [busy, setBusy] = useState<string | null>(null);
+ const [preview, setPreview] = useState<DeckPreviewDoc | null>(null);
+ const [previewError, setPreviewError] = useState<string | null>(null);
+ const [composing, setComposing] = useState(false);
+ const [t, setT] = useState(0);
+ const [still, setStill] = useState<{ url: string; at: number; estimated: boolean } | null>(null);
+ const [stillError, setStillError] = useState<string | null>(null);
+ const [preset, setPreset] = useState<"final" | "fast" | null>(null);
+ const [job, setJob] = useState<JobView | null>(null);
+ const [jobError, setJobError] = useState<string | null>(null);
+ const [finalV, setFinalV] = useState<number | null | "none">(null);
+ // The switch as pressed, while its write is in flight: the box follows the
+ // hand at once and falls back to the manifest's answer if the write fails.
+ const [switching, setSwitching] = useState<boolean | null>(null);
+ // One token for the manifest, shared by both writers here: a settings save
+ // moves it, and the table's next save must carry the moved one. A ref, as
+ // in the clip bench, because two saves can be in flight before a render.
+ const token = useRef<string | null>(null);
+ const saving = useRef<Promise<unknown>>(Promise.resolve());
+ const since = useRef(0);
+ const backdrop = useRef<HTMLVideoElement | null>(null);
+
+ const variantQ = variant ? `&variant=${encodeURIComponent(variant)}` : "";
+ const q = `project=${encodeURIComponent(project)}${variantQ}`;
+ const segmentOf = useMemo(() => new Set(entries.filter((e) => e.kind === "clip" && e.segment).map((e) => e.id)), [entries]);
+
+ // ---- reading ------------------------------------------------------------
+ const loadChrome = useCallback(async () => {
+ const r = await fetch(`/api/report/chrome?project=${encodeURIComponent(project)}`, { cache: "no-store" });
+ const j = (await r.json()) as ChromeDoc & { error?: string };
+ if (!r.ok) {
+ setLoadError(String(j.error ?? r.status));
+ return null;
+ }
+ setDoc(j);
+ token.current = j.token;
+ setForm(formOf(j.deck ?? j.defaults));
+ setFormDirty(false);
+ setErrors(j.errors ?? []);
+ return j;
+ }, [project]);
+
+ /**
+ * The table's rows. `keep` keeps every unsaved edit on top of what is now
+ * saved -- the reload after a 409 must not throw away what was typed.
+ *
+ * An EDIT is a draft that differs from what this page last read as saved.
+ * A row nobody touched here takes the newly saved value: kept as its old
+ * draft it would read as an edit, and the next save would revert the other
+ * writer's change -- the very write the stale token exists to protect.
+ */
+ const loadRows = useCallback(
+ async (keep: boolean) => {
+ const r = await fetch(`/api/report/onscreen?${q}`, { cache: "no-store" });
+ const j = (await r.json()) as { rows: Row[]; maxChars: number; token: string | null; error?: string };
+ if (!r.ok) {
+ setLoadError(String(j.error ?? r.status));
+ return;
+ }
+ const before = new Map(rowsRef.current.map((row) => [row.id, draftOf(row.onscreen)]));
+ token.current = j.token;
+ setRows(j.rows);
+ setMaxChars(j.maxChars);
+ setDrafts((prev) => {
+ const next: Record<string, Draft> = {};
+ for (const row of j.rows) {
+ const saved = draftOf(row.onscreen);
+ const old = prev[row.id];
+ const was = before.get(row.id);
+ next[row.id] = keep && old && was && !sameDraft(old, was) ? old : saved;
+ }
+ return next;
+ });
+ },
+ [q],
+ );
+
+ const recompose = useCallback(
+ async (draft: Record<string, Onscreen | null> = {}) => {
+ setComposing(true);
+ setPreviewError(null);
+ const res = await composePreview(project, variant || null, draft);
+ setComposing(false);
+ if ("error" in res) {
+ setPreviewError(res.error);
+ return;
+ }
+ setPreview(res);
+ // The schedule says how this cut was built: a crossfaded build is
+ // re-rendered as one, a hard-cut build as one.
+ setPreset((p) => p ?? (res.schedule.estimated || res.schedule.transition > 0 ? "final" : "fast"));
+ setT((cur) => Math.min(cur, res.schedule.total));
+ },
+ [project, variant],
+ );
+
+ const loadFinal = useCallback(async () => {
+ const r = await fetch(`/api/report/video?${q}&kind=final`, { method: "HEAD", cache: "no-store" }).catch(() => null);
+ const m = r?.ok ? r.headers.get("x-video-mtime") : null;
+ setFinalV(m ? Number(m) : "none");
+ }, [q]);
+
+ useEffect(() => {
+ void fetch("/api/report/build", { cache: "no-store" })
+ .then((r) => r.json())
+ .then((j) => setVariants((j.variants as string[]) ?? []))
+ .catch(() => {});
+ }, []);
+
+ useEffect(() => {
+ let live = true;
+ void (async () => {
+ const c = await loadChrome();
+ if (!live || !c) return;
+ await loadRows(false);
+ if (built || variant) void loadFinal();
+ else setFinalV("none");
+ if (live && c.deckOn && !(c.errors ?? []).length) await recompose();
+ })();
+ return () => {
+ live = false;
+ };
+ // `built` and `variant` are read once per load; loadFinal already follows the variant.
+ }, [loadChrome, loadRows, loadFinal, recompose]);
+
+ // ---- writing ------------------------------------------------------------
+ const queued = useCallback(<T,>(fn: () => Promise<T>): Promise<T> => {
+ const run = saving.current.catch(() => null).then(fn);
+ saving.current = run;
+ return run;
+ }, []);
+
+ /** PUT render.chrome. `null` turns the panel off. */
+ const putChrome = useCallback(
+ (chrome: Record<string, unknown> | null) =>
+ queued(async () => {
+ setBusy("saving…");
+ setNote(null);
+ const r = await fetch("/api/report/chrome", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, chrome, token: token.current }),
+ });
+ const j = (await r.json()) as Record<string, unknown>;
+ setBusy(null);
+ if (!r.ok) {
+ if (j.stale) {
+ setStale(true);
+ setNote(STALE);
+ } else {
+ setErrors((j.errors as string[]) ?? []);
+ setNote(`could not save: ${String(j.error ?? r.status)}`);
+ }
+ return false;
+ }
+ token.current = String(j.token ?? "");
+ setErrors([]);
+ setStale(false);
+ return true;
+ }),
+ [project, queued],
+ );
+
+ const toggle = useCallback(
+ async (on: boolean) => {
+ if (!doc) return;
+ if (!on) {
+ const custom = doc.chrome?.deck && Object.keys(doc.chrome.deck as object).length > 0;
+ if (
+ custom &&
+ !window.confirm(
+ "Turning the on-screen panel off removes render.chrome from the manifest, settings included. The titles and subtitles in the table stay. Turn it off?",
+ )
+ )
+ return;
+ }
+ setSwitching(on);
+ const ok = await putChrome(on ? { engine: "hyperframes", layout: "deck", deck: {} } : null);
+ if (!ok) {
+ setSwitching(null);
+ return;
+ }
+ const c = await loadChrome();
+ setSwitching(null);
+ await loadRows(true);
+ if (c?.deckOn) await recompose();
+ else setPreview(null);
+ setNote(on ? "on — the next build draws the panel" : "off — the next build draws the old chrome");
+ },
+ [doc, putChrome, loadChrome, loadRows, recompose],
+ );
+
+ const dirtyIds = rows.filter((r) => drafts[r.id] && !sameDraft(drafts[r.id], draftOf(r.onscreen))).map((r) => r.id);
+ const draftMap = useCallback(
+ () => Object.fromEntries(dirtyIds.map((id) => [id, onscreenValue(drafts[id])])) as Record<string, Onscreen | null>,
+ [dirtyIds, drafts],
+ );
+
+ const saveSettings = useCallback(async () => {
+ if (!doc) return;
+ const chrome = {
+ engine: (doc.chrome?.engine as string) ?? "hyperframes",
+ layout: (doc.chrome?.layout as string) ?? "deck",
+ deck: deckOf(form, doc.defaults),
+ };
+ const ok = await putChrome(chrome);
+ if (!ok) return;
+ await loadChrome();
+ // The auto subtitles follow subtitle.parts and dateFormat; the counter
+ // follows title.maxChars. Unsaved table edits ride through.
+ await loadRows(true);
+ await recompose(draftMap());
+ setNote("settings saved — the preview is recomposed with them");
+ }, [doc, form, putChrome, loadChrome, loadRows, recompose, draftMap]);
+
+ const saveRows = useCallback(
+ () =>
+ queued(async () => {
+ if (!dirtyIds.length) return;
+ setBusy("saving…");
+ setNote(null);
+ const r = await fetch("/api/report/onscreen", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, onscreen: draftMap(), token: token.current }),
+ });
+ const j = (await r.json()) as Record<string, unknown>;
+ setBusy(null);
+ if (!r.ok) {
+ // The drafts stay exactly as typed. A refused batch is a typo or a
+ // race, and neither is fixed by retyping nineteen rows.
+ if (j.stale) {
+ setStale(true);
+ setNote(STALE);
+ } else setNote(`could not save: ${String(j.error ?? r.status)}`);
+ return;
+ }
+ token.current = String(j.token ?? "");
+ setStale(false);
+ const saved = (j.onscreen ?? {}) as Record<string, Onscreen | null>;
+ setRows((prev) => prev.map((row) => (row.id in saved ? { ...row, onscreen: saved[row.id] } : row)));
+ // Re-sync from what the writer stored, which is trimmed.
+ setDrafts((prev) => {
+ const next = { ...prev };
+ for (const [id, v] of Object.entries(saved)) next[id] = draftOf(v);
+ return next;
+ });
+ setNote(`saved ${Object.keys(saved).length} row${Object.keys(saved).length === 1 ? "" : "s"}`);
+ }),
+ [queued, dirtyIds, project, draftMap],
+ );
+
+ const reloadSaved = useCallback(async () => {
+ await loadChrome();
+ await loadRows(true);
+ setStale(false);
+ setNote("reloaded the saved values — your unsaved edits are still here");
+ }, [loadChrome, loadRows]);
+
+ // ---- the true still -----------------------------------------------------
+ const trueStill = useCallback(async () => {
+ setStillError(null);
+ setBusy("rendering a still…");
+ const r = await fetch(`/api/report/still?${q}&at=${t.toFixed(3)}`, { cache: "no-store" });
+ setBusy(null);
+ if (!r.ok) {
+ setStillError(await r.text());
+ return;
+ }
+ const blob = await r.blob();
+ setStill((prev) => {
+ if (prev) URL.revokeObjectURL(prev.url);
+ return {
+ url: URL.createObjectURL(blob),
+ at: Number(r.headers.get("x-still-at") ?? t),
+ estimated: r.headers.get("x-schedule-estimated") === "true",
+ };
+ });
+ }, [q, t]);
+
+ // ---- re-render on-screen: the build chain's own job runner ---------------
+ const rerender = useCallback(async () => {
+ setJobError(null);
+ const options: Record<string, unknown> = { chromeOnly: true };
+ if (variant) options.variant = variant;
+ const r = await fetch("/api/report/build", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, preset: preset ?? "final", only: null, options }),
+ });
+ const j = (await r.json()) as Record<string, unknown>;
+ if (!r.ok) {
+ setJobError(String(j.error ?? r.status));
+ return;
+ }
+ since.current = 0;
+ setJob(j.job as JobView);
+ }, [project, preset, variant]);
+
+ useEffect(() => {
+ if (!job || job.state !== "running") return;
+ const tick = setInterval(async () => {
+ const r = await fetch(`/api/report/build?job=${job.id}&since=${since.current}`, { cache: "no-store" });
+ if (!r.ok) return;
+ const j = (await r.json()) as JobView;
+ since.current = j.next;
+ setJob((prev) => (prev ? { ...j, log: [...prev.log, ...j.log] } : j));
+ if (j.state !== "running") void loadFinal();
+ }, 800);
+ return () => clearInterval(tick);
+ }, [job, loadFinal]);
+
+ // ---- the live picture ----------------------------------------------------
+ const texts = useMemo<DeckTexts>(() => {
+ const out: DeckTexts = {};
+ for (const row of rows) {
+ const d = drafts[row.id] ?? draftOf(row.onscreen);
+ out[row.id] = { title: d.title.trim() || row.auto.title, subtitle: d.subtitle.trim() || row.auto.subtitle };
+ }
+ return out;
+ }, [rows, drafts]);
+
+ const schedule = preview?.schedule ?? null;
+ const current = schedule ? segmentAt(schedule, t) : null;
+ const backdropId = current && segmentOf.has(current.id) ? current.id : null;
+
+ // The backdrop follows the scrubber: the built segment of whichever clip is
+ // on screen, at the same offset into it.
+ useEffect(() => {
+ const el = backdrop.current;
+ if (!el || !current) return;
+ const at = Math.max(0, t - current.start);
+ const seek = () => {
+ el.currentTime = Math.min(at, Math.max(0, (el.duration || at) - 0.05));
+ };
+ if (el.readyState >= 1) seek();
+ else el.addEventListener("loadedmetadata", seek, { once: true });
+ return () => el.removeEventListener("loadedmetadata", seek);
+ }, [t, current, backdropId]);
+
+ const jump = (id: string) => {
+ const s = schedule?.segments.find((x) => x.id === id);
+ if (s) setT(midOf(s));
+ };
+
+ // ---- render ---------------------------------------------------------------
+ if (loadError) {
+ return (
+ <section data-testid="onscreen-section" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3">
+ <h2 className="micro">on-screen</h2>
+ <p className="text-[12px] text-[var(--color-bad)]">{loadError}</p>
+ </section>
+ );
+ }
+ if (!doc) {
+ return (
+ <section data-testid="onscreen-section" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3">
+ <h2 className="micro">on-screen</h2>
+ <p className="text-[11px] text-[var(--color-dim)]">reading the manifest…</p>
+ </section>
+ );
+ }
+
+ const on = doc.deckOn;
+ const over = (s: string) => s.trim().length > maxChars;
+ // The validator's sentences, one per problem. Beside the settings when the
+ // panel is on (that is the form they are about); at the top when it is off,
+ // where a refused switch is.
+ const errorList = errors.length > 0 && (
+ <ul
+ data-testid="onscreen-errors"
+ className="space-y-0.5 rounded border border-[var(--color-bad)] px-2 py-1 text-[11px] text-[var(--color-bad)]"
+ >
+ {errors.map((e) => (
+ <li key={e}>{e}</li>
+ ))}
+ </ul>
+ );
+
+ const words = (
+ <div className="space-y-1.5">
+ <div className="flex flex-wrap items-center gap-2">
+ <span className="micro">titles and subtitles — {rows.length} entries</span>
+ <span className="text-[11px] text-[var(--color-dim)]">
+ empty uses the grey text; the counter warns past {maxChars}, it does not refuse
+ </span>
+ <button
+ type="button"
+ data-testid="onscreen-save"
+ className={`${buttonVariants({ variant: "primary", size: "sm" })} ml-auto`}
+ disabled={!!busy || dirtyIds.length === 0}
+ onClick={() => void saveRows()}
+ >
+ {dirtyIds.length ? `save ${dirtyIds.length} change${dirtyIds.length === 1 ? "" : "s"}` : "saved"}
+ </button>
+ {dirtyIds.length > 0 && (
+ <button
+ type="button"
+ data-testid="onscreen-discard"
+ className={buttonVariants({ size: "sm" })}
+ disabled={!!busy}
+ onClick={() => setDrafts(Object.fromEntries(rows.map((r) => [r.id, draftOf(r.onscreen)])))}
+ >
+ discard
+ </button>
+ )}
+ </div>
+ <div className="overflow-x-auto">
+ <table data-testid="onscreen-table" className="w-full border-collapse text-[12px]">
+ <thead>
+ <tr className="micro text-left">
+ <th className="w-16 px-1.5 py-0.5">entry</th>
+ <th className="px-1.5 py-0.5">title</th>
+ <th className="w-14 px-1.5 py-0.5 text-right">chars</th>
+ <th className="px-1.5 py-0.5">subtitle</th>
+ </tr>
+ </thead>
+ <tbody>
+ {rows.map((row) => {
+ const d = drafts[row.id] ?? draftOf(row.onscreen);
+ const dirty = !sameDraft(d, draftOf(row.onscreen));
+ const shown = d.title.trim() || row.auto.title;
+ const isCurrent = current?.id === row.id;
+ const set = (k: keyof Draft, v: string) =>
+ setDrafts((prev) => ({ ...prev, [row.id]: { ...(prev[row.id] ?? draftOf(row.onscreen)), [k]: v } }));
+ return (
+ <tr
+ key={row.id}
+ data-testid="onscreen-row"
+ data-entry={row.id}
+ data-dirty={dirty ? "1" : "0"}
+ data-current={isCurrent ? "1" : "0"}
+ className={`border-t border-[var(--color-line)] align-middle ${isCurrent ? "bg-[var(--color-panel-2)]" : ""}`}
+ >
+ <td className="px-1.5 py-1">
+ <button
+ type="button"
+ onClick={() => jump(row.id)}
+ className={`font-mono text-[11px] hover:underline ${isCurrent ? "text-[var(--color-sel)]" : "text-[var(--color-text)]"}`}
+ title="show this entry in the preview"
+ >
+ {row.id}
+ </button>
+ <span className="micro ml-1">{row.type === "clip" ? "" : row.type}</span>
+ {dirty && <span className="ml-1 text-[var(--color-dirty)]" title="unsaved">●</span>}
+ </td>
+ <td className="px-1.5 py-1">
+ <input
+ type="text"
+ data-testid="onscreen-title"
+ name={`title-${row.id}`}
+ value={d.title}
+ maxLength={200}
+ placeholder={row.auto.title || "(no title — the source line takes its place)"}
+ onFocus={() => jump(row.id)}
+ onChange={(e) => set("title", e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void saveRows();
+ }}
+ className={`${field} w-full ${over(shown) ? "border-[var(--color-dirty)]" : "border-[var(--color-line)]"}`}
+ />
+ </td>
+ <td
+ className={`num px-1.5 py-1 text-right font-mono text-[11px] ${over(shown) ? "text-[var(--color-dirty)]" : "text-[var(--color-dim)]"}`}
+ data-testid="onscreen-count"
+ data-over={over(shown) ? "1" : "0"}
+ >
+ {shown.trim().length}/{maxChars}
+ </td>
+ <td className="px-1.5 py-1">
+ <input
+ type="text"
+ data-testid="onscreen-subtitle"
+ name={`subtitle-${row.id}`}
+ value={d.subtitle}
+ maxLength={200}
+ placeholder={row.auto.subtitle || "(no archive record here — the build reads the fetched file)"}
+ onFocus={() => jump(row.id)}
+ onChange={(e) => set("subtitle", e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void saveRows();
+ }}
+ className={`${input} w-full`}
+ />
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ );
+
+ return (
+ <section
+ data-testid="onscreen-section"
+ data-onscreen={on ? "on" : "off"}
+ className="space-y-3 rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3"
+ >
+ {/* ---- the switch ---- */}
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <h2 className="micro">on-screen</h2>
+ <label className="flex items-center gap-1.5 text-[12px] text-[var(--color-text)]">
+ <input
+ type="checkbox"
+ data-testid="onscreen-toggle"
+ checked={switching ?? on}
+ disabled={!!busy || switching !== null}
+ onChange={(e) => void toggle(e.target.checked)}
+ />
+ a panel under the footage: timeline, title, source, QR
+ </label>
+ {on && variants.length > 1 && (
+ <label className="flex items-center gap-1 text-[11px] text-[var(--color-dim)]">
+ cut
+ <select
+ data-testid="onscreen-variant"
+ value={variant}
+ onChange={(e) => {
+ setVariant(e.target.value);
+ setPreview(null);
+ }}
+ className={`${input} font-mono text-[11px]`}
+ >
+ <option value="">default</option>
+ {variants.map((v) => (
+ <option key={v} value={v}>
+ {v}
+ </option>
+ ))}
+ </select>
+ </label>
+ )}
+ {busy && <span className="micro">{busy}</span>}
+ {note && (
+ <span data-testid="onscreen-note" className={`text-[11px] ${stale ? "text-[var(--color-dirty)]" : "text-[var(--color-dim)]"}`}>
+ {note}
+ </span>
+ )}
+ {stale && (
+ <button type="button" data-testid="onscreen-reload" className={buttonVariants({ size: "sm" })} onClick={() => void reloadSaved()}>
+ reload saved values
+ </button>
+ )}
+ </div>
+
+ {!on && (
+ <p className="text-[11px] leading-snug text-[var(--color-dim)]">
+ Off: the cut is built with the citation header, the corner QR and the section footer. On, the
+ footage is scaled up above one panel that carries a pip per clip, a title per clip, the source
+ and date, and the clip’s QR. Titles can be written below either way; they are only drawn
+ when this is on.
+ </p>
+ )}
+
+ {!on && errorList}
+
+ {on && (
+ <div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(20rem,26rem)]">
+ {/* ================= the picture ================= */}
+ <div className="min-w-0 space-y-2">
+ {preview ? (
+ <DeckFrame preview={preview} t={t} texts={texts} testid="onscreen-preview">
+ {backdropId ? (
+ <video
+ ref={backdrop}
+ key={backdropId}
+ data-testid="onscreen-backdrop"
+ src={`/api/report/segment?project=${encodeURIComponent(project)}&clip=${encodeURIComponent(backdropId)}`}
+ muted
+ playsInline
+ preload="auto"
+ className="absolute inset-0 h-full w-full object-contain"
+ />
+ ) : (
+ <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} />
+ )}
+ </DeckFrame>
+ ) : (
+ <div className="flex aspect-video w-full items-center justify-center rounded border border-dashed border-[var(--color-line)] text-[12px] text-[var(--color-dim)]">
+ {composing ? "composing the preview…" : previewError ? "" : "no preview yet"}
+ </div>
+ )}
+ {previewError && (
+ <p data-testid="onscreen-preview-error" className="text-[11px] text-[var(--color-bad)]">
+ {previewError}
+ </p>
+ )}
+
+ {schedule && (
+ <div className="space-y-1">
+ {/* One block per segment, as long as it plays: where you are
+ in the cut, and a click to the middle of any of it. */}
+ <div className="flex h-4 w-full overflow-hidden rounded" data-testid="onscreen-segments">
+ {schedule.segments.map((s) => (
+ <button
+ key={s.id}
+ type="button"
+ title={`${s.id}${s.hideDeck ? " · panel hidden" : ""}`}
+ data-seg-jump={s.id}
+ onClick={() => setT(midOf(s))}
+ className={`h-full border-r border-[var(--color-ink)] ${
+ current?.id === s.id
+ ? "bg-[var(--color-sel)]"
+ : s.hideDeck
+ ? "bg-[var(--color-panel-2)]"
+ : dirtyIds.includes(s.id)
+ ? "bg-[var(--color-dirty)] opacity-70"
+ : "bg-[var(--color-line)]"
+ }`}
+ style={{ width: `${(s.duration / schedule.total) * 100}%` }}
+ />
+ ))}
+ </div>
+ <div className="flex items-center gap-2">
+ <input
+ type="range"
+ data-testid="onscreen-scrubber"
+ min={0}
+ max={schedule.total}
+ step={0.01}
+ value={t}
+ onChange={(e) => setT(Number(e.target.value))}
+ className="min-w-0 flex-1"
+ />
+ <span className="num w-28 text-right font-mono text-[11px] text-[var(--color-meter)]" data-testid="onscreen-time">
+ {clock(t)} / {clock(schedule.total)}
+ </span>
+ </div>
+ <div className="flex flex-wrap items-center gap-2 text-[11px] text-[var(--color-dim)]">
+ <span className="font-mono text-[var(--color-sel)]" data-testid="onscreen-current">
+ {current?.id ?? "—"}
+ </span>
+ {schedule.estimated ? (
+ <span className={badgeVariants({ variant: "open", size: "sm" })} data-testid="onscreen-estimated">
+ estimated timing
+ </span>
+ ) : (
+ <span className={badgeVariants({ variant: "meter", size: "sm" })}>built timing</span>
+ )}
+ <span>
+ {schedule.estimated
+ ? "nothing built yet, so the clock comes from the manifest; a build probes the real lengths"
+ : "the clock of the last build"}
+ </span>
+ </div>
+ </div>
+ )}
+
+ <div className="flex flex-wrap items-center gap-2">
+ <button
+ type="button"
+ data-testid="onscreen-recompose"
+ className={buttonVariants({ size: "sm" })}
+ disabled={composing}
+ onClick={() => void recompose(draftMap())}
+ title="Compose the preview again from the manifest, with the table's unsaved rows on top"
+ >
+ {composing ? "recomposing…" : "recompose"}
+ </button>
+ <button
+ type="button"
+ data-testid="onscreen-true-still"
+ className={buttonVariants({ size: "sm" })}
+ disabled={!!busy || !schedule}
+ onClick={() => void trueStill()}
+ title="The render engine's own picture of this moment, of what is saved"
+ >
+ true still at {clock(t)}
+ </button>
+ <span className="mx-1 h-4 w-px bg-[var(--color-line)]" />
+ <select
+ value={preset ?? "final"}
+ onChange={(e) => setPreset(e.target.value as "final" | "fast")}
+ data-testid="onscreen-rerender-preset"
+ title="Concat the segments as the last build did: crossfaded (final) or hard cuts (fast)"
+ className={`${input} text-[11px]`}
+ >
+ <option value="final">as final (crossfades)</option>
+ <option value="fast">as fast (hard cuts)</option>
+ </select>
+ <button
+ type="button"
+ data-testid="onscreen-rerender"
+ className={buttonVariants({ variant: "primary", size: "sm" })}
+ // Over the segments a build left, so there must have been one.
+ disabled={job?.state === "running" || finalV === "none"}
+ onClick={() => void rerender()}
+ title={
+ typeof finalV === "number"
+ ? "Render the panel over the segments already built and re-concat. No segment is rebuilt."
+ : "Build the cut first: a re-render works over the segments a build left"
+ }
+ >
+ re-render on-screen
+ </button>
+ </div>
+ {dirtyIds.length > 0 && (
+ <p className="text-[11px] text-[var(--color-dirty)]">
+ the preview shows {dirtyIds.length} unsaved row{dirtyIds.length === 1 ? "" : "s"}; a true still and a
+ re-render use what is saved
+ </p>
+ )}
+
+ {stillError && (
+ <p data-testid="onscreen-still-error" className="text-[11px] text-[var(--color-bad)]">
+ {stillError}
+ </p>
+ )}
+ {still && (
+ <figure className="space-y-0.5">
+ <img
+ src={still.url}
+ alt={`the panel at ${clock(still.at)}, as the render draws it`}
+ data-testid="onscreen-still"
+ data-still-at={still.at}
+ className="w-full rounded border border-[var(--color-line)] bg-[repeating-conic-gradient(#1e2732_0_25%,#161c24_0_50%)] bg-[length:16px_16px]"
+ />
+ <figcaption className="num text-[11px] text-[var(--color-dim)]">
+ true still at {clock(still.at)}
+ {still.estimated ? " · estimated timing" : ""} · what is saved, drawn by the render’s browser
+ </figcaption>
+ </figure>
+ )}
+
+ {jobError && (
+ <p data-testid="onscreen-job-error" className="text-[11px] text-[var(--color-bad)]">
+ {jobError}
+ </p>
+ )}
+ {job && (
+ <div className="space-y-1" data-testid="onscreen-job" data-job-state={job.state}>
+ <div className="flex flex-wrap items-center gap-2 text-[11px]">
+ <span className={badgeVariants({ variant: job.state === "failed" ? "blocking" : job.state === "done" ? "meter" : "on", size: "sm" })}>
+ {job.state}
+ </span>
+ <span className="text-[var(--color-dim)]">{job.steps[job.stepIndex]?.label ?? "re-render on-screen"}</span>
+ {job.error && <span className="text-[var(--color-bad)]">{job.error}</span>}
+ {job.state === "running" && (
+ <button
+ type="button"
+ data-testid="onscreen-rerender-cancel"
+ className={buttonVariants({ variant: "destructive", size: "sm" })}
+ onClick={() => void fetch(`/api/report/build?cancel=${job.id}`, { method: "POST" })}
+ >
+ cancel
+ </button>
+ )}
+ </div>
+ <pre className="max-h-40 overflow-auto rounded border border-[var(--color-line)] bg-[var(--color-ink)] p-2 font-mono text-[11px] text-[var(--color-dim)]">
+ {job.log.slice(-60).join("\n")}
+ </pre>
+ </div>
+ )}
+ </div>
+
+ {/* ================= settings, then the built video ================= */}
+ <div className="min-w-0 space-y-3">
+ <div data-testid="onscreen-settings" className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <span className="micro">settings</span>
+ {formDirty && <span className="text-[11px] text-[var(--color-dirty)]">unsaved</span>}
+ <button
+ type="button"
+ data-testid="onscreen-settings-save"
+ className={`${buttonVariants({ variant: "primary", size: "sm" })} ml-auto`}
+ disabled={!!busy || !formDirty}
+ onClick={() => void saveSettings()}
+ >
+ save settings
+ </button>
+ <button
+ type="button"
+ data-testid="onscreen-settings-defaults"
+ className={buttonVariants({ size: "sm" })}
+ disabled={!!busy}
+ onClick={() => {
+ setForm(formOf(doc.defaults));
+ setFormDirty(true);
+ }}
+ title="Fill the form with the defaults; nothing is written until you save"
+ >
+ defaults
+ </button>
+ </div>
+ {errorList}
+ <div className="grid grid-cols-[max-content_minmax(0,1fr)] items-center gap-x-2 gap-y-1 text-[11px]">
+ {GROUPS.map((group) => (
+ <div key={group.name} className="contents">
+ <span className="micro pt-1">{group.name}</span>
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-1">
+ {group.fields.map((f) => {
+ const v = form[f.key];
+ const set = (nv: string | boolean) => {
+ setForm((prev) => ({ ...prev, [f.key]: nv }));
+ setFormDirty(true);
+ };
+ return (
+ <label key={f.key} className="flex items-center gap-1 text-[var(--color-dim)]" title={f.hint}>
+ {f.label}
+ {f.kind === "bool" ? (
+ <input
+ type="checkbox"
+ data-testid={`onscreen-setting-${f.key}`}
+ checked={!!v}
+ onChange={(e) => set(e.target.checked)}
+ />
+ ) : f.kind === "select" ? (
+ <select
+ data-testid={`onscreen-setting-${f.key}`}
+ value={String(v ?? "")}
+ onChange={(e) => set(e.target.value)}
+ className={`${input} text-[11px]`}
+ >
+ {f.options.map((o) => (
+ <option key={o} value={o}>
+ {o}
+ </option>
+ ))}
+ </select>
+ ) : (
+ <input
+ type="text"
+ inputMode={f.kind === "parts" ? "text" : "decimal"}
+ data-testid={`onscreen-setting-${f.key}`}
+ value={String(v ?? "")}
+ onChange={(e) => set(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") void saveSettings();
+ }}
+ className={`${input} num font-mono text-[11px] ${f.kind === "parts" ? "w-36" : "w-14"}`}
+ />
+ )}
+ </label>
+ );
+ })}
+ </div>
+ </div>
+ ))}
+ </div>
+ <p className="text-[11px] leading-snug text-[var(--color-dim)]">
+ Only what differs from the defaults is written. A refused value is named above in the
+ words the build would use; nothing is written until all of it is accepted.
+ </p>
+ </div>
+
+ <div className="space-y-1">
+ <span className="micro">the built video</span>
+ {typeof finalV === "number" ? (
+ <video
+ data-testid="onscreen-final-video"
+ src={`/api/report/video?${q}&kind=final&v=${finalV}`}
+ controls
+ preload="metadata"
+ className="aspect-video w-full rounded border border-[var(--color-line)] bg-black"
+ />
+ ) : (
+ <p data-testid="onscreen-no-final" className="text-[11px] text-[var(--color-dim)]">
+ {finalV === null ? "looking…" : "this cut has not been built yet"}
+ </p>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* ================= the words =================
+ Open when the panel is on. Folded when it is off: a report that
+ never uses the panel should not carry a nineteen-row form, and one
+ that will can have its titles written before the switch. */}
+ {on ? (
+ words
+ ) : (
+ <details data-testid="onscreen-words-folded">
+ <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]">
+ titles and subtitles — {rows.length} entries
+ {rows.some((r) => r.onscreen) ? ` · ${rows.filter((r) => r.onscreen).length} written` : ""}
+ </summary>
+ <div className="mt-1.5">{words}</div>
+ </details>
+ )}
+ </section>
+ );
+}
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -19,6 +19,7 @@ import { diffManifests, formatChange } from "@/lib/report/manifest-diff.mjs";
import { listSnapshots, readSnapshot } from "@/lib/report/snapshots.mjs";
import DeliverSection from "./DeliverSection";
import FetchUnfetchedButton from "./FetchUnfetchedButton";
+import OnscreenSection from "./OnscreenSection";
import ReportBuildChain from "./ReportBuildChain";
import SnapshotButton from "./SnapshotButton";
import TagCitedButton from "./TagCitedButton";
@@ -409,6 +410,17 @@ export default async function ReportProject({
entries={entries.map((e) => ({ id: e.id, kind: e.kind }))}
/>
+ {/* --- what is drawn over it --------------------------------------- */}
+ {/* The on-screen panel: its settings, its words and a live preview of
+ both. After the build chain, because "re-render on-screen" is a run
+ of that chain over segments it already built; before Deliver,
+ because the panel is part of the video that gets delivered. */}
+ <OnscreenSection
+ project={project.id}
+ entries={entries.map((e) => ({ id: e.id, kind: e.kind, segment: !!e.segment }))}
+ built={!!build.built}
+ />
+
{/* --- delivering it --------------------------------------------- */}
{/* After the build chain, because that is the order the work happens
in: the video is one deliverable and the written report with its
diff --git a/umtool/docs/build.md b/umtool/docs/build.md
@@ -45,6 +45,7 @@ command" prints the argv with them in it, which is the proof.
| **hard cuts** | `--no-xfade` | hard cuts on a preset that would crossfade | all four |
| **chapters only** | `--chapters-only` | retitle the chapters from the segments already on disk — no fetch, no encode | build only, 5-minute cap; refuses when a segment is missing |
| **rail preview** | `--preview <at> <dur>` | the rail alone over a window, to `<slug>.preview.mp4` | build only, 5-minute cap; no verify (nothing to verify) |
+| **re-render on-screen** | `--chrome-only` | re-lay the on-screen deck over the segments already on disk — re-probe, rewrite the schedule, recompose (re-render only if its cache key changed), re-concat with the overlay, re-mux the chapters | build only; skips the preflight and the dry resolve (nothing is fetched) but keeps the build's OWN timeout, not the 5-minute cap — a deck re-render is a render plus a concat of the whole cut |
An unknown variant is a 400 before anything runs. `chaptersOnly` and `preview`
skip the preflight and the dry resolve because neither touches a source.
diff --git a/umtool/docs/clip-bench.md b/umtool/docs/clip-bench.md
@@ -184,6 +184,21 @@ which date it would otherwise use. An empty field goes back to it.
`http(s)`; the message comes back from the writer and lands under the controls,
with what you typed left in the box to fix.
+## On-screen title and subtitle
+
+When the on-screen deck is on (`render.chrome` — see
+[report-video.md](report-video.md#the-on-screen-deck-renderchrome)) the bench
+gets two more fields beside the attribution ones: `onscreen.title` and
+`onscreen.subtitle`. Same save rule — on blur or <kbd>Enter</kbd>, empty
+deletes the key — and each shows the auto line it falls back to as its
+placeholder, so a blank box reads as "using the automatic line" rather than as
+"empty".
+
+Beside them is a live strip of the deck itself at this clip's moment, fed by
+`postMessage` rather than a server round trip: the SAME composition the build
+renders, so a keystroke is seen in its real face and its real text fit before
+anything is saved.
+
## `correction` — a message to the next report pass
The sixth field in that panel is not about this video at all. It records what the
diff --git a/umtool/docs/e2e.md b/umtool/docs/e2e.md
@@ -33,6 +33,8 @@ rendered over a deliverable would be indistinguishable from a person doing it.
| `report-fixture` | read-only. 4 clips: c01 ends mid-sentence, c04 does too but sets `lockEnd`, c03's source has no punctuation |
| `bench-fixture` | the clip bench **writes** — windows, locks, attribution and corrections |
| `build-fixture` | the build **writes** |
+| `onscreen-fixture` | the On-screen section and the bench's on-screen fields **write** — 1920×1080 with the deck on, never built (the preview's estimate) |
+| `onscreen-build-fixture` | built with the deck, then re-rendered on-screen (`--chrome-only`) |
| `gone-fixture` | its source is gone — the preflight must block it |
| `no-origin-fixture` / `localhost-fixture` | the two defects that shipped |
| `bike-fixture` | the third kind |
@@ -45,8 +47,12 @@ which file playwright ran first — and the failure named the wrong thing entire
## Stubs
-`YTDLP_BIN` and `QRENCODE_BIN` point at node scripts the fixture writes, so the
-whole build chain runs **offline and deterministically**. They are node, not bash:
+`YTDLP_BIN`, `QRENCODE_BIN` and `HYPERFRAMES_BIN` point at node scripts the fixture
+writes, so the whole build chain runs **offline and deterministically**. The
+HyperFrames stub writes the exact frame sequence the build checks, alternating
+RGB and RGBA PNGs (the mix a real render produces, and the one `-reinit_filter 0`
+exists for), and logs every argv to `bin/hyperframes.invocations`. The deck's
+true still is the system chromium (`CHROME`), not a stub. They are node, not bash:
the yt-dlp stub does fractional arithmetic on `--download-sections *FROM-TO`, and
doing that in bash means awk, which means three layers of quoting inside a
generated file. It got mangled once.
diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md
@@ -180,6 +180,28 @@ edge, about two rows of its log window.
374 s band is 11,460 frames, 348 s of wall clock and 372 MB, on a box already
running something else.
+**Mixed RGB/RGBA frames in one sequence restart the whole filtergraph —
+the on-screen deck's own trap.** HyperFrames writes a frame with nothing
+transparent in it as opaque RGB and every other frame as RGBA. ffmpeg's
+default response to the decoded stream switching kind mid-sequence is to
+REINITIALISE the filtergraph, which drops whatever was buffered and ends the
+output early: measured, a 3.5 s xfade+overlay came out at 2.0 s. Fix: pass
+`-reinit_filter 0` on that input and `format=rgba` straight after it, on the
+deck's input only — the chart band's own chain never mixes frame kinds and is
+untouched.
+
+**The hard-cut concat list has to name its entries by absolute path.** The
+concat demuxer resolves a list entry against the LIST FILE's own directory,
+not the cwd, so a relative `--out` named every segment twice over and the
+first input failed to open. Fixed once, in `concatHardCut`, for every
+hard-cut build — not only a deck one.
+
+**The deck's font copy is strict where the band's is lenient.** Both copy
+fonts in as private families to dodge the `local()` trap above, but a missing
+`render.fontRegular` / `fontBold` throws on the deck instead of falling back
+silently to the browser default, because the deck has no other on-screen text
+to notice a wrong metric by.
+
## Rail strips and rolling counters
**A slab that slides moves text that did not change.** The tally used to be four
diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md
@@ -315,6 +315,77 @@ A still **never derives** a QR target. A clip's code is derived from the archive
that serves it; a screenshot has no such archive, and a code that resolves to the
wrong place is worse than no code — so only an explicit `citeUrl` draws one.
+## The on-screen deck (`render.chrome`)
+
+Opt-in, and it **replaces** the header, the footer's progress track and the
+corner QR described above — a deck manifest draws none of them, because the
+panel carries the citation itself. The full contract (every setting, the
+schedule document, the choreography) is the pipeline's own README
+([`render.chrome` — the on-screen deck](../report-to-video/README.md#renderchrome--the-on-screen-deck))
+and `plans/onscreen-deck.md`; this sheet covers what umtool reads, writes and
+serves.
+
+Any `clip`, `image` or `card` entry may carry `onscreen: { title?, subtitle? }`
+— nested, because `entry.title` already means the *stream's* title everywhere
+else on this page, the same reason `title`/`date` are their own fields above.
+`updateOnscreen(dir, { [id]: {title?, subtitle?} | null }, { token })` writes a
+whole batch through the same lock / backup / atomic-write / stale-token path
+as every other writer in this file; **one unknown id refuses the whole
+batch**, nothing partial is written. A value normalises to `null` — which
+DELETES the key — the same rule an empty attribution field follows.
+`updateClip` takes `onscreen` too, so a clip-bench save and an On-screen-table
+save are one rule, not two.
+
+`updateChrome(dir, chrome | null, { token })` writes `render.chrome` itself:
+`validateChrome()` first, so a block umtool saves is one the build accepts,
+and `null` removes the key — turning the deck off without touching anything
+else under `render`.
+
+### Preview, before and without a build
+
+`POST /api/report/chrome/preview` composes `out/<variant>/chrome/deck-preview/`
+— never the build's own `chrome/deck/` or its render cache — from `{ project,
+variant?, draft? }`. `draft` is the On-screen table's unsaved rows, id →
+`{title?, subtitle?}` or `null`, applied on top of the saved manifest before a
+schedule is built. The schedule it draws from is the **build's own**
+(`out/<variant>/schedule.json`, real probed durations) when one still lists
+this cut's entries in this order, else an **estimate** from the manifest
+alone (`estimated: true`) — so the section previews before anything has ever
+been built, and a finished build makes the preview's handovers land exactly
+where the render's will.
+
+`GET /api/report/chrome/files/[...path]` serves that preview directory,
+traversal-guarded — the composition's own HTML, loaded in an iframe that is
+**same-origin and unsandboxed** so its `@font-face` rules resolve (a sandboxed
+opaque origin fails every font fetch and falls back silently; see
+[quirks.md](quirks.md)). `GET /api/report/still?project&variant&(clip|at)`
+renders a **true** still, the same Chromium screenshot the pipeline's
+`--still` takes, of what is **saved** — not of the live preview's unsaved
+typing, which is the point of the two existing side by side.
+
+### What a build leaves behind
+
+`GET /api/report/video?project&variant&kind=final|preview` range-serves
+either the deliverable or the short window `--chrome-preview` wrote, through
+`rangeResponse()` (`lib/report/serve.mjs`), shared with the segment route and
+the same `v`-matches-mtime caching rule.
+
+In the build chain (see [build.md](build.md)), the driver's `chromeOnly`
+option is `--chrome-only`, labelled **"re-render on-screen"**: it skips the
+availability preflight and the dry resolve, same as chapters-only and rail
+preview (none of the three touches a source), but keeps the build's own
+timeout rather than their five-minute cap, because a deck re-render is a
+render plus a concat of the whole cut, not a seconds-long retitle. The
+pipeline's other two deck flags, `--no-chrome` and `--chrome-preview`, are
+CLI-only — the driver does not wire them up.
+
+### Naming
+
+The app calls this **"On-screen"** everywhere a person reads it. The manifest
+and the pipeline call it the deck (`render.chrome.layout: "deck"`), but "deck"
+is already the song kind's name in umtool, and one word meaning two things two
+pages apart is how somebody edits the wrong one.
+
## The three-stage window model
A clip's window passes through three different notions of "where the cut is", and
diff --git a/umtool/e2e/clip-bench.spec.ts b/umtool/e2e/clip-bench.spec.ts
@@ -271,7 +271,7 @@ test("a bench save shows up on the project page", async ({ page, request }) => {
});
await page.goto(`/browse/${PROJECT}`);
- const row = page.locator("[data-entry=c01]");
+ const row = page.locator("[data-entry=c01][data-kind]");
await expect(row).toContainText("end pinned");
// lockEnd is the acknowledgement, so the row's warning goes with it.
await expect(row).toHaveAttribute("data-mid-sentence", "0");
@@ -404,9 +404,9 @@ test("`ready N of M` counts the fetched clips that still need judgement", async
await page.goto(`/browse/${WALK}`);
await expect(page.locator("[data-ready-count]")).toHaveText("ready 2 of 3 needing judgement");
// The pill is the same question the walk asks, per row.
- await expect(page.locator("[data-entry=w02]")).toHaveAttribute("data-fetched", "0");
- await expect(page.locator("[data-entry=w02]")).toContainText("not fetched yet");
- await expect(page.locator("[data-entry=w01]")).toHaveAttribute("data-fetched", "1");
+ await expect(page.locator("[data-entry=w02][data-kind]")).toHaveAttribute("data-fetched", "0");
+ await expect(page.locator("[data-entry=w02][data-kind]")).toContainText("not fetched yet");
+ await expect(page.locator("[data-entry=w01][data-kind]")).toHaveAttribute("data-fetched", "1");
// And the walk starts where the walk actually goes.
await expect(page.locator("[data-walk-start=w01]")).toHaveAttribute(
"href",
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -1376,6 +1376,136 @@ writeProject(
]),
);
+// -- THE ON-SCREEN DECK -------------------------------------------------------
+//
+// A stub HYPERFRAMES_BIN, so a deck build renders offline in a second instead
+// of fetching a renderer with npx and driving a browser per frame.
+//
+// It keeps the renderer's contract, which is what the build checks: exactly
+// round(data-duration * fps) files `frame_%06d.png` (numbered from 1), at the
+// composition's own size, in --output. Every argv is logged, so a spec can
+// tell a render from a cache hit.
+//
+// The frames ALTERNATE between RGBA and RGB PNGs on purpose. A real render's
+// sequence mixes the two (a frame with nothing translucent in it is written
+// without alpha), and when it does ffmpeg rebuilds the overlay graph per
+// format change and drops frames -- the deck came out shorter than the cut.
+// `-reinit_filter 0` + `format=rgba` is the fix, and a stub of one format
+// would never exercise it.
+//
+// The review still is the system chromium's own screenshot (CHROME), not a
+// stub: it is ~1 s, and a still that only proved a file was written would say
+// nothing about the composition.
+writeFileSync(
+ path.join(BIN, "hyperframes"),
+ `#!/usr/bin/env node
+// Fixture stub for the HyperFrames renderer. Deterministic, offline.
+import { spawnSync } from "node:child_process";
+import { appendFileSync, copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs";
+import path from "node:path";
+
+const argv = process.argv.slice(2);
+appendFileSync(${JSON.stringify(path.join(BIN, "hyperframes.invocations"))}, argv.join(" ") + "\\n");
+
+if (argv.includes("--version")) {
+ process.stdout.write("0.0.0-fixture\\n");
+ process.exit(0);
+}
+const val = (n) => {
+ const i = argv.indexOf(n);
+ return i < 0 ? null : argv[i + 1];
+};
+if (argv[0] !== "render" || val("--format") !== "png-sequence") {
+ process.stderr.write("stub: only 'render --format png-sequence' is implemented\\n");
+ process.exit(2);
+}
+const fps = Number(val("--fps"));
+const out = val("--output");
+const proj = argv[argv.length - 1];
+const html = readFileSync(path.join(proj, "index.html"), "utf8");
+const num = (re, d) => {
+ const m = re.exec(html);
+ return m ? Number(m[1]) : d;
+};
+const width = num(/data-width="(\\d+)"/, 1920);
+const height = num(/data-height="(\\d+)"/, 190);
+const duration = num(/data-composition-id="[^"]*"[^>]*data-duration="([\\d.]+)"/, 0);
+if (!(fps > 0) || !out || !(duration > 0)) {
+ process.stderr.write(\`stub: fps \${fps}, output \${out}, duration \${duration}\\n\`);
+ process.exit(2);
+}
+const frames = Math.round(duration * fps);
+mkdirSync(out, { recursive: true });
+
+// Two source frames, one of each format, then copied out alternately.
+const tmp = path.join(out, ".stub");
+mkdirSync(tmp, { recursive: true });
+const make = (file, color, pixFmt) => {
+ const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y",
+ "-f", "lavfi", "-i", \`color=c=\${color}:size=\${width}x\${height}\`,
+ "-frames:v", "1", "-pix_fmt", pixFmt, file], { stdio: "inherit" });
+ if (r.status !== 0) process.exit(r.status ?? 1);
+};
+const rgba = path.join(tmp, "rgba.png");
+const rgb = path.join(tmp, "rgb.png");
+make(rgba, "0x1a2030@0.8", "rgba");
+make(rgb, "0x2a3040", "rgb24");
+for (let i = 1; i <= frames; i += 1) {
+ copyFileSync(i % 2 ? rgba : rgb, path.join(out, \`frame_\${String(i).padStart(6, "0")}.png\`));
+}
+rmSync(tmp, { recursive: true, force: true });
+process.stderr.write(\`stub: \${frames} frames \${width}x\${height} -> \${out}\\n\`);
+`,
+ { mode: 0o755 },
+);
+
+// The deck as the pipeline draws it: a 1920x1080 frame, because that is what
+// the default panel fits (190 px under footage scaled to 0.82) -- at the other
+// fixtures' 640x360 the switch's `deck: {}` is refused before anything else
+// can be tested. The source windows stay 320x180; they are scaled into the
+// footage box like any other.
+//
+// The deck REFUSES to draw without its two faces (they are copied in beside
+// the composition, never left to local()), so without a font on this machine
+// there is no deck to test and onscreen.spec.ts skips, naming why.
+const deckManifest = (slug, title, timeline) => {
+ const m = manifest(slug, title, { siteOrigin: "https://archive.example" }, timeline);
+ m.render = {
+ ...m.render,
+ width: 1920,
+ height: 1080,
+ chrome: { engine: "hyperframes", layout: "deck", deck: {} },
+ };
+ delete m.render.headerHeight;
+ return m;
+};
+
+// onscreen-fixture: the On-screen section and the bench's fields WRITE here --
+// the switch, the settings, the table, a stale token. Never built: its
+// schedule is the estimate, which is the state a report is in when titles are
+// first written. A card, because a row is any entry and not only a clip.
+const ONSCREEN = writeProject(
+ "onscreen-fixture",
+ deckManifest("onscreen-fixture", "The On-screen Fixture", [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because" },
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence" },
+ { type: "card", id: "k01", style: "chapter", seconds: 3, heading: "A card" },
+ ]),
+);
+
+// onscreen-build-fixture: BUILT by the spec, then re-rendered on-screen over
+// the segments that build left. Its own project because a build stamps out/
+// and the section specs above must not depend on whether it ran first. Clips
+// only: a card segment needs ImageMagick with Pango, which the build specs
+// keep out of the fixture.
+const ONSCREEN_BUILD = writeProject(
+ "onscreen-build-fixture",
+ deckManifest("onscreen-build-fixture", "The On-screen Build Fixture", [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because" },
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence" },
+ ]),
+);
+
mkdirSync(path.join(reports, "bike-fixture"), { recursive: true });
writeFileSync(
path.join(reports, "bike-fixture", "sweep-report.md"),
@@ -1415,7 +1545,7 @@ ff([
// intermediates and are excluded by name.
mkdirSync(path.join(reports, "no-origin-fixture", "out"), { recursive: true });
-for (const dir of [BENCH, BUILD]) {
+for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD]) {
mkdirSync(path.join(dir, "out", "clips-raw"), { recursive: true });
copyFileSync(
path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
@@ -1526,7 +1656,7 @@ console.log(` flagged source: ${flagged ? flagged.video : "none — no asr/"}`)
console.log(` SONG_CODE_DIR=${path.join(dest, "code")}`);
console.log(` SONG_DIR=${path.join(dest, "data")}`);
console.log(` SONG_REPORTS_DIR=${reports}`);
-console.log(` YTDLP_BIN=${path.join(BIN, "yt-dlp")} QRENCODE_BIN=${path.join(BIN, "qrencode")}`);
+console.log(` YTDLP_BIN=${path.join(BIN, "yt-dlp")} QRENCODE_BIN=${path.join(BIN, "qrencode")} HYPERFRAMES_BIN=${path.join(BIN, "hyperframes")}`);
console.log(` CHANNELS_DIR=${CHANNELS} (testchan/vid1 punctuated, vid2 not; vid3/vid4/vid5 for the editor fetch)`);
console.log(` projects: report-fixture (4 clips, 1 mid-sentence), no-origin-fixture,`);
console.log(` localhost-fixture, bike-fixture (sweep), find/ (shadowed),`);
@@ -1535,5 +1665,6 @@ console.log(` walk-fixture (read-only: w01/w04 walkable, w02 unfetche
console.log(` editor-fetch-{,many-,reuse-}fixture (nothing cached — the editor fetch's subjects),`);
console.log(` longform-fixture (cue gap, legacy .bak, ffmeta), longform-edit-fixture, dash-fixture`);
console.log(` deliver-fixture (writable: a01/a02 to cut, a03 unfetched, b01 shared, b02 incorrect, b03 unjudged)`);
+console.log(` onscreen-fixture (writable, deck on, unbuilt), onscreen-build-fixture (built with the deck)`);
console.log(` deliver-stop-fixture (writable: six confirmed clips to cut, for Stop and resume)`);
console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`);
diff --git a/umtool/e2e/onscreen.spec.ts b/umtool/e2e/onscreen.spec.ts
@@ -0,0 +1,489 @@
+import { test, expect, type APIRequestContext, type Locator, type Page } from "@playwright/test";
+import { execFileSync } from "node:child_process";
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// ON-SCREEN: the report cut's deck (render.chrome), as umtool edits it.
+//
+// NOT deck.spec.ts -- that is the song kind's deck, an unrelated thing that
+// owns the word in this app; see the header of OnscreenSection.tsx.
+//
+// Two projects, both made by make-fixture.mjs at 1920x1080 with the deck on:
+//
+// onscreen-fixture the section and the bench WRITE here. Never built,
+// so its preview is drawn from the estimate.
+// onscreen-build-fixture built by the last test (offline, ~10 s), then
+// re-rendered on-screen over its own segments.
+//
+// The renderer is a stub (HYPERFRAMES_BIN, see make-fixture.mjs): it writes the
+// frame sequence the build checks, alternating RGB and RGBA. The true still is
+// the system chromium's own screenshot, not a stub.
+//
+// The report fixtures need none of the song project's bulk data, so nothing
+// here skips on SONG_DIR. What the deck DOES need is a font (it refuses to draw
+// without its faces) and, for the still, chromium -- each test that needs one
+// says so and skips when this machine has none.
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const FIXTURE = path.join(HERE, "..", ".e2e-song");
+const PROJECT = "reports/onscreen-fixture";
+const DIR = path.join(FIXTURE, "reports", "onscreen-fixture");
+const BUILD_PROJECT = "reports/onscreen-build-fixture";
+const BUILD_DIR = path.join(FIXTURE, "reports", "onscreen-build-fixture");
+const INVOCATIONS = path.join(FIXTURE, "bin", "hyperframes.invocations");
+// The still route's browser, as compose-chrome resolves it. The server inherits
+// this process's environment, so the two agree.
+const CHROME = process.env.CHROME ?? "/usr/bin/chromium";
+
+const DECK_ON = { engine: "hyperframes", layout: "deck", deck: {} };
+
+type Entry = { id: string; type: string; onscreen?: { title?: string; subtitle?: string } };
+type Manifest = { render: Record<string, unknown> & { chrome?: unknown }; timeline: Entry[] };
+
+const readManifest = (dir = DIR): Manifest =>
+ JSON.parse(readFileSync(path.join(dir, "video.manifest.json"), "utf8"));
+const entry = (id: string, dir = DIR) => readManifest(dir).timeline.find((e) => e.id === id)!;
+const enc = encodeURIComponent;
+
+test.beforeEach(() => {
+ // The first page of a run compiles in dev mode (~20 s under load), and the
+ // section composes and still-shoots on top of that.
+ test.setTimeout(120_000);
+ test.skip(
+ !readManifest().render.fontRegular,
+ "the deck refuses to draw without render.fontRegular/fontBold, and make-fixture.mjs found no font on this machine",
+ );
+});
+
+// ---- state through the routes, so each test starts from a known manifest ----
+
+async function chromeToken(request: APIRequestContext, project: string): Promise<string> {
+ const j = (await (await request.get(`/api/report/chrome?project=${enc(project)}`)).json()) as { token: string };
+ return j.token;
+}
+
+async function putChrome(request: APIRequestContext, project: string, chrome: unknown) {
+ const r = await request.put("/api/report/chrome", {
+ data: { project, chrome, token: await chromeToken(request, project) },
+ });
+ expect(r.ok(), await r.text()).toBeTruthy();
+}
+
+async function putOnscreen(
+ request: APIRequestContext,
+ project: string,
+ onscreen: Record<string, { title?: string; subtitle?: string } | null>,
+) {
+ const r = await request.put("/api/report/onscreen", {
+ data: { project, onscreen, token: await chromeToken(request, project) },
+ });
+ expect(r.ok(), await r.text()).toBeTruthy();
+}
+
+/** The section, read and settled: the manifest loaded and the table filled. */
+async function openSection(page: Page, project = PROJECT) {
+ await page.goto(`/browse/${project}`);
+ const section = page.getByTestId("onscreen-section");
+ await expect(section).toHaveAttribute("data-onscreen", /on|off/);
+ await expect(page.getByTestId("onscreen-row").first()).toBeAttached();
+ return section;
+}
+
+const row = (page: Page, id: string) => page.locator(`[data-testid="onscreen-row"][data-entry="${id}"]`);
+
+/**
+ * fill() on a controlled input right after a goto can lose its first
+ * keystroke to a hydration re-render (docs/e2e.md, "Gotchas"), so assert the
+ * value and retry before anything commits on it.
+ */
+async function fillSure(input: Locator, value: string) {
+ await expect(async () => {
+ await input.fill(value);
+ await expect(input).toHaveValue(value, { timeout: 1000 });
+ }).toPass({ timeout: 15_000 });
+}
+
+// ---------------------------------------------------------------------------
+
+test("the switch writes render.chrome, and turning it off takes the key out", async ({ page, request }) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ const section = await openSection(page);
+ const toggle = page.getByTestId("onscreen-toggle");
+ await expect(section).toHaveAttribute("data-onscreen", "on");
+ await expect(toggle).toBeChecked();
+ await expect(toggle).toBeEnabled();
+
+ await toggle.click();
+ await expect(section).toHaveAttribute("data-onscreen", "off");
+ // Off is the key REMOVED, not set to null: every manifest without it builds
+ // as before, and that is a promise about the absence of the key.
+ await expect.poll(() => "chrome" in readManifest().render).toBe(false);
+ // Nothing else in render moved.
+ expect(readManifest().render.width).toBe(1920);
+
+ await page.reload();
+ await expect(section).toHaveAttribute("data-onscreen", "off");
+ await expect(toggle).not.toBeChecked();
+
+ await expect(toggle).toBeEnabled();
+ await toggle.click();
+ await expect(section).toHaveAttribute("data-onscreen", "on");
+ await expect.poll(() => readManifest().render.chrome).toEqual(DECK_ON);
+
+ await page.reload();
+ await expect(section).toHaveAttribute("data-onscreen", "on");
+ await expect(toggle).toBeChecked();
+});
+
+test("settings save only what differs from the defaults; a refused value shows the validator's sentence", async ({
+ page,
+ request,
+}) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ await openSection(page);
+ const defaults = (await (await request.get(`/api/report/chrome?project=${enc(PROJECT)}`)).json()) as {
+ defaults: { title: { maxChars: number } };
+ };
+
+ const maxChars = page.getByTestId("onscreen-setting-title.maxChars");
+ await expect(maxChars).toHaveValue(String(defaults.defaults.title.maxChars));
+ const want = defaults.defaults.title.maxChars === 20 ? 21 : 20;
+ await fillSure(maxChars, String(want));
+ await page.getByTestId("onscreen-settings-save").click();
+
+ await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({
+ title: { maxChars: want },
+ });
+ // The table's counter follows the saved setting.
+ await expect(page.getByTestId("onscreen-count").first()).toContainText(`/${want}`);
+
+ // Refused: the build's own sentence, beside the form, and nothing written.
+ await fillSure(page.getByTestId("onscreen-setting-height"), "999");
+ await page.getByTestId("onscreen-settings-save").click();
+ await expect(page.getByTestId("onscreen-errors")).toContainText(
+ "render.chrome.deck.height must be a whole number of pixels from 120 to 400",
+ );
+ expect((readManifest().render.chrome as { deck: unknown }).deck).toEqual({ title: { maxChars: want } });
+
+ await putChrome(request, PROJECT, DECK_ON);
+});
+
+test("a table edit saves onscreen; an emptied row deletes the key", async ({ page, request }) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ await putOnscreen(request, PROJECT, { c01: null, c02: null, k01: null });
+ await openSection(page);
+
+ const title = row(page, "c01").getByTestId("onscreen-title");
+ await fillSure(title, "County approves the pre-application");
+ await expect(row(page, "c01")).toHaveAttribute("data-dirty", "1");
+ // Any entry, not only a clip -- and the writer trims.
+ await fillSure(row(page, "k01").getByTestId("onscreen-subtitle"), " a card's own line ");
+
+ await page.getByTestId("onscreen-save").click();
+ await expect.poll(() => entry("c01").onscreen).toEqual({ title: "County approves the pre-application" });
+ expect(entry("k01").onscreen).toEqual({ subtitle: "a card's own line" });
+ await expect(row(page, "c01")).toHaveAttribute("data-dirty", "0");
+ await expect(row(page, "k01").getByTestId("onscreen-subtitle")).toHaveValue("a card's own line");
+
+ // The counter warns past maxChars; it does not refuse.
+ const limit = ((await (await request.get(`/api/report/onscreen?project=${enc(PROJECT)}`)).json()) as {
+ maxChars: number;
+ }).maxChars;
+ await fillSure(title, "x".repeat(limit + 1));
+ await expect(row(page, "c01").getByTestId("onscreen-count")).toHaveAttribute("data-over", "1");
+
+ // Emptied: the key goes, it is not left as {} or {title: ""}.
+ await fillSure(title, "");
+ await expect(row(page, "c01")).toHaveAttribute("data-dirty", "1");
+ await page.getByTestId("onscreen-save").click();
+ await expect.poll(() => "onscreen" in entry("c01")).toBe(false);
+ await expect(row(page, "c01")).toHaveAttribute("data-dirty", "0");
+ expect(entry("k01").onscreen).toEqual({ subtitle: "a card's own line" });
+
+ await putOnscreen(request, PROJECT, { k01: null });
+});
+
+test("a stale token is a 409; reloading keeps the edit and does not undo the other writer", async ({
+ page,
+ request,
+}) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ await putOnscreen(request, PROJECT, { c01: null, c02: null });
+ await openSection(page);
+
+ // Somebody else writes after this page read its token.
+ await putOnscreen(request, PROJECT, { c02: { title: "Written elsewhere" } });
+
+ await fillSure(row(page, "c01").getByTestId("onscreen-title"), "Mine");
+ const refused = page.waitForResponse(
+ (r) => r.url().includes("/api/report/onscreen") && r.request().method() === "PUT",
+ );
+ await page.getByTestId("onscreen-save").click();
+ expect((await refused).status()).toBe(409);
+ await expect(page.getByTestId("onscreen-note")).toContainText("the manifest changed since you opened this");
+ expect("onscreen" in entry("c01")).toBe(false);
+ expect(entry("c02").onscreen).toEqual({ title: "Written elsewhere" });
+
+ await page.getByTestId("onscreen-reload").click();
+ await expect(page.getByTestId("onscreen-reload")).toHaveCount(0);
+ // The unsaved edit survives the reload...
+ await expect(row(page, "c01").getByTestId("onscreen-title")).toHaveValue("Mine");
+ await expect(row(page, "c01")).toHaveAttribute("data-dirty", "1");
+ // ...and a row nobody touched here shows what the other writer saved. Kept
+ // as the old draft, it would read as an edit and the next save would quietly
+ // revert the very write the token exists to protect.
+ await expect(row(page, "c02").getByTestId("onscreen-title")).toHaveValue("Written elsewhere");
+ await expect(row(page, "c02")).toHaveAttribute("data-dirty", "0");
+
+ const saved = page.waitForResponse(
+ (r) => r.url().includes("/api/report/onscreen") && r.request().method() === "PUT",
+ );
+ await page.getByTestId("onscreen-save").click();
+ expect((await saved).status()).toBe(200);
+ await expect.poll(() => entry("c01").onscreen).toEqual({ title: "Mine" });
+ expect(entry("c02").onscreen).toEqual({ title: "Written elsewhere" });
+
+ await putOnscreen(request, PROJECT, { c01: null, c02: null });
+});
+
+test("the preview is the composition: it reports ready, and typing reaches it before a save", async ({
+ page,
+ request,
+}) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ await putOnscreen(request, PROJECT, { c01: null, c02: null, k01: null });
+ await openSection(page);
+
+ await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ // Never built, so the clock is the manifest's estimate, and says so.
+ await expect(page.getByTestId("onscreen-estimated")).toBeVisible();
+ const frame = page.frameLocator('[data-testid="onscreen-preview-iframe"]');
+
+ // One node per entry, and a card's title is its heading -- the same text the
+ // table offers as the placeholder.
+ for (const id of ["c01", "c02", "k01"]) await expect(frame.locator(`[data-seg="${id}"]`)).toHaveCount(1);
+ const cardAuto = await row(page, "k01").getByTestId("onscreen-title").getAttribute("placeholder");
+ expect(cardAuto).toBe("A card");
+ await expect(frame.locator('[data-seg="k01"] .deck-title')).toHaveText(cardAuto!);
+
+ // Typing is patched into the frame by postMessage: nothing is saved.
+ await fillSure(row(page, "c02").getByTestId("onscreen-title"), "Typed, not saved");
+ await expect(frame.locator('[data-seg="c02"] .deck-title')).toHaveText("Typed, not saved");
+ await fillSure(row(page, "c02").getByTestId("onscreen-subtitle"), "a line under it");
+ await expect(frame.locator('[data-seg="c02"] .deck-sub')).toHaveText("a line under it");
+ // Focusing a row moves the scrubber to that entry.
+ await expect(page.getByTestId("onscreen-current")).toHaveText("c02");
+ expect("onscreen" in entry("c02")).toBe(false);
+
+ // The scrubber is the composition's clock: its end is the schedule's total.
+ const max = Number(await page.getByTestId("onscreen-scrubber").getAttribute("max"));
+ const pv = (await (
+ await request.post("/api/report/chrome/preview", { data: { project: PROJECT } })
+ ).json()) as { schedule: { total: number } };
+ expect(max).toBeCloseTo(pv.schedule.total, 3);
+});
+
+test("a true still is the render browser's PNG of the deck region", async ({ page, request }) => {
+ test.skip(!existsSync(CHROME), `the true still needs chromium at ${CHROME} (set CHROME)`);
+ await putChrome(request, PROJECT, DECK_ON);
+
+ // The route: a PNG exactly the deck region's size, at the entry's middle.
+ const pv = (await (
+ await request.post("/api/report/chrome/preview", { data: { project: PROJECT } })
+ ).json()) as {
+ geometry: { deck: { width: number; height: number } };
+ schedule: { segments: { id: string; start: number; duration: number }[] };
+ };
+ const r = await request.get(`/api/report/still?project=${enc(PROJECT)}&clip=c01`);
+ expect(r.status(), await r.text()).toBe(200);
+ expect(r.headers()["content-type"]).toBe("image/png");
+ const png = await r.body();
+ expect(png.subarray(1, 4).toString("latin1")).toBe("PNG");
+ expect(png.readUInt32BE(16)).toBe(pv.geometry.deck.width);
+ expect(png.readUInt32BE(20)).toBe(pv.geometry.deck.height);
+ const c01 = pv.schedule.segments.find((s) => s.id === "c01")!;
+ expect(Number(r.headers()["x-still-at"])).toBeCloseTo(c01.start + c01.duration / 2, 2);
+
+ // And the button shows it.
+ await openSection(page);
+ await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ await page.getByTestId("onscreen-true-still").click();
+ const img = page.getByTestId("onscreen-still");
+ await expect(img).toBeVisible({ timeout: 30_000 });
+ await expect
+ .poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth))
+ .toBe(pv.geometry.deck.width);
+});
+
+test("the clip bench's on-screen fields preview live and save through the window route", async ({
+ page,
+ request,
+}) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ await putOnscreen(request, PROJECT, { c01: null });
+ await page.goto(`/browse/${PROJECT}/clip/c01`);
+
+ await expect(page.getByTestId("bench-onscreen")).toHaveAttribute("data-deck-on", "1");
+ await expect(page.getByTestId("bench-deck-strip")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ const strip = page.frameLocator('[data-testid="bench-deck-strip-iframe"]');
+
+ const title = page.getByTestId("bench-onscreen-title");
+ await fillSure(title, "From the bench");
+ await expect(strip.locator('[data-seg="c01"] .deck-title')).toHaveText("From the bench");
+ expect("onscreen" in entry("c01")).toBe(false);
+
+ const put = page.waitForRequest((r) => r.url().includes("/api/report/window") && r.method() === "PUT");
+ await title.press("Enter");
+ expect((await put).postDataJSON().onscreen).toEqual({ title: "From the bench" });
+ await expect.poll(() => entry("c01").onscreen).toEqual({ title: "From the bench" });
+
+ // Emptied, the key goes.
+ await fillSure(title, "");
+ const cleared = page.waitForRequest((r) => r.url().includes("/api/report/window") && r.method() === "PUT");
+ await title.press("Enter");
+ expect((await cleared).postDataJSON().onscreen).toBeNull();
+ await expect.poll(() => "onscreen" in entry("c01")).toBe(false);
+});
+
+// ---- built: the deck rendered by a build, then re-rendered on its own --------
+
+type Job = {
+ id: string;
+ state: string;
+ error: string | null;
+ steps: { label: string; argv: string[] }[];
+ events: { ev: string; phase?: string }[];
+};
+
+async function waitForJob(request: APIRequestContext, id: string, ms = 180_000): Promise<Job> {
+ const until = Date.now() + ms;
+ while (Date.now() < until) {
+ const j = (await (await request.get(`/api/report/build?job=${id}`)).json()) as Job;
+ if (j.state !== "running") return j;
+ await new Promise((r) => setTimeout(r, 400));
+ }
+ throw new Error("the job never finished");
+}
+
+const renders = () =>
+ existsSync(INVOCATIONS)
+ ? readFileSync(INVOCATIONS, "utf8")
+ .split("\n")
+ .filter((l) => l.includes("onscreen-build-fixture"))
+ : [];
+const VARIANT_OUT = path.join(BUILD_DIR, "out", "sourced");
+const FRAMES = path.join(VARIANT_OUT, "chrome", "deck-frames");
+const FINAL = path.join(BUILD_DIR, "out", "onscreen-build-fixture.mp4");
+const readSchedule = () =>
+ JSON.parse(readFileSync(path.join(VARIANT_OUT, "schedule.json"), "utf8")) as {
+ kind: string;
+ estimated: boolean;
+ fps: number;
+ transition: number;
+ total: number;
+ segments: { id: string; title: string }[];
+ };
+const frameFiles = () => readdirSync(FRAMES).filter((f) => /^frame_\d{6}\.png$/.test(f));
+const probeSeconds = (file: string) =>
+ Number(
+ execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file])
+ .toString()
+ .trim(),
+ );
+
+test("re-render on-screen runs --chrome-only over a built cut, to done, rebuilding no segment", async ({
+ page,
+ request,
+}) => {
+ test.setTimeout(300_000);
+
+ // The chain it asks for: no preflight, no resolve, --chrome-only, verified.
+ const dry = (await (
+ await request.post("/api/report/build?dry=1", {
+ data: { project: BUILD_PROJECT, preset: "final", options: { chromeOnly: true } },
+ })
+ ).json()) as { steps: { label: string; argv: string[] }[] };
+ const chain = dry.steps.map((s) => s.argv.join(" ")).join("\n");
+ expect(chain).not.toContain("check-availability.mjs");
+ expect(chain).not.toContain("resolve-windows.mjs");
+ const dryBuild = dry.steps.find((s) => s.argv.join(" ").includes("build-video.mjs"))!;
+ expect(dryBuild.argv).toContain("--chrome-only");
+ expect(dryBuild.label).toBe("re-render on-screen");
+ expect(dry.steps.at(-1)!.argv.join(" ")).toContain("verify-build.mjs");
+
+ // A full build first, as hard cuts: the deck is composed, rendered by the
+ // stub and laid over the concat in one command.
+ const before = renders().length;
+ const start = await request.post("/api/report/build?replace=1", {
+ data: { project: BUILD_PROJECT, preset: "fast" },
+ });
+ expect(start.ok(), await start.text()).toBeTruthy();
+ const built = await waitForJob(request, ((await start.json()) as { job: Job }).job.id);
+ expect(built.state, built.error ?? "").toBe("done");
+ expect(renders().length).toBe(before + 1);
+ const argv = renders().at(-1)!;
+ expect(argv).toContain("render --format png-sequence");
+ expect(argv).toContain("--fps 15");
+ expect(argv).toContain("--no-browser-gpu");
+ expect(argv).toContain(`--output ${FRAMES}`);
+ expect(built.events.filter((e) => e.ev === "chrome").map((e) => e.phase)).toEqual(
+ expect.arrayContaining(["schedule", "compose", "render", "overlay"]),
+ );
+
+ const first = readSchedule();
+ expect(first.kind).toBe("deck");
+ expect(first.estimated).toBe(false);
+ expect(first.transition).toBe(0);
+ expect(frameFiles().length).toBe(Math.round(first.total * first.fps));
+ // The stub's frames alternate RGB and RGBA. Without `-reinit_filter 0` the
+ // overlay drops frames at every format change and the cut comes out short;
+ // the build asserts the length, and so does this.
+ expect(Math.abs(probeSeconds(FINAL) - first.total)).toBeLessThan(2 / first.fps);
+
+ // A saved title moves the deck's cache key, so the re-render must render.
+ await putOnscreen(request, BUILD_PROJECT, { c02: { title: "Re-rendered title" } });
+ const segs = ["c01", "c02"].map((id) => path.join(VARIANT_OUT, "segments", `${id}.mp4`));
+ const segMtimes = segs.map((s) => statSync(s).mtimeMs);
+ const finalMtime = statSync(FINAL).mtimeMs;
+
+ await openSection(page, BUILD_PROJECT);
+ await expect(page.getByTestId("onscreen-final-video")).toBeVisible();
+ // As final: the crossfaded concat, so the overlay rides the xfade chain --
+ // the other half of the -reinit_filter fix from the hard cut above.
+ await page.getByTestId("onscreen-rerender-preset").selectOption("final");
+ const posted = page.waitForResponse(
+ (r) => r.url().endsWith("/api/report/build") && r.request().method() === "POST",
+ );
+ await page.getByTestId("onscreen-rerender").click();
+ const res = await posted;
+ expect(res.request().postDataJSON().options).toMatchObject({ chromeOnly: true });
+ const { job } = (await res.json()) as { job: Job };
+ const realBuild = job.steps.find((s) => s.argv.join(" ").includes("build-video.mjs"))!;
+ expect(realBuild.argv).toContain("--chrome-only");
+ expect(job.steps.map((s) => s.argv.join(" ")).join("\n")).not.toContain("check-availability.mjs");
+
+ await expect(page.getByTestId("onscreen-job")).toHaveAttribute("data-job-state", /done|failed/, {
+ timeout: 240_000,
+ });
+ const done = await waitForJob(request, job.id);
+ expect(done.state, `${done.error ?? ""}\n${(done as unknown as { log: string[] }).log?.slice(-20).join("\n")}`).toBe(
+ "done",
+ );
+ await expect(page.getByTestId("onscreen-job")).toHaveAttribute("data-job-state", "done");
+
+ // It rendered again, and over the SAME segments.
+ expect(renders().length).toBe(before + 2);
+ expect(segs.map((s) => statSync(s).mtimeMs)).toEqual(segMtimes);
+ expect(statSync(FINAL).mtimeMs).toBeGreaterThan(finalMtime);
+
+ const second = readSchedule();
+ expect(second.transition).toBeGreaterThan(0);
+ expect(second.total).toBeLessThan(first.total);
+ expect(second.segments.find((s) => s.id === "c02")!.title).toBe("Re-rendered title");
+ expect(frameFiles().length).toBe(Math.round(second.total * second.fps));
+ expect(Math.abs(probeSeconds(FINAL) - second.total)).toBeLessThan(2 / second.fps);
+ await expect(page.getByTestId("onscreen-final-video")).toBeVisible();
+});
diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts
@@ -490,14 +490,19 @@ test("a timeline entry of an unknown type renders, rather than crashing the page
const res = await page.goto("/browse/reports/report-fixture");
expect(res?.status()).toBe(200);
- await expect(page.locator("[data-entry=z01]")).toHaveAttribute("data-kind", "zz-unknown");
- await expect(page.locator("[data-entry=z01]")).toContainText("An entry type from the future");
- await expect(page.locator("[data-entry=k01]")).toHaveAttribute("data-kind", "card");
+ // The TIMELINE's entries, which carry a kind. The On-screen section's table
+ // marks its rows with data-entry too (one per entry, any type), so an
+ // unscoped [data-entry] counts every entry twice.
+ const entry = (id?: string) => page.locator(id ? `[data-entry=${id}][data-kind]` : "[data-entry][data-kind]");
+
+ await expect(entry("z01")).toHaveAttribute("data-kind", "zz-unknown");
+ await expect(entry("z01")).toContainText("An entry type from the future");
+ await expect(entry("k01")).toHaveAttribute("data-kind", "card");
// And it is COUNTED, not silently dropped: a card saying "4 clips · 1 card"
// about a 6-entry timeline would be lying by omission.
- await expect(page.locator("[data-entry]")).toHaveCount(6);
- await expect(page.locator("[data-entry=c01]")).toHaveAttribute("data-kind", "clip");
+ await expect(entry()).toHaveCount(6);
+ await expect(entry("c01")).toHaveAttribute("data-kind", "clip");
});
// The brand preset (report-to-video/brand.mjs, `render.brand`) is offered where
diff --git a/umtool/e2e/report-fetch-via-editor.spec.ts b/umtool/e2e/report-fetch-via-editor.spec.ts
@@ -130,7 +130,7 @@ test("the fetch route asks the editor, carries the provenance, and runs no yt-dl
// The page reads the corpus window as cached, so the walk stops skipping it.
await page.goto(`/browse/${PROJECT}`);
- await expect(page.locator('[data-entry="e01"]')).toHaveAttribute(
+ await expect(page.locator('[data-entry="e01"][data-kind]')).toHaveAttribute(
"data-fetched",
"1",
);
@@ -181,7 +181,7 @@ test("the project page fetches the unfetched clips one at a time, and Stop halts
// bytes in the corpus instead of in one project's out/.
await page.reload();
await expect(page.locator("[data-ready-count]")).toContainText("ready 2 of 2");
- await expect(page.locator('[data-entry="f02"]')).toHaveAttribute(
+ await expect(page.locator('[data-entry="f02"][data-kind]')).toHaveAttribute(
"data-fetched",
"1",
);
diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs
@@ -53,11 +53,15 @@ export const buildTimeoutMs = (clipCount, xfade) =>
* chaptersOnly `--chapters-only` -- retitle the chapters from the segments
* already on disk; no fetch, no encode
* preview `--preview <at> <dur>` -- the rail alone over a window
+ * chromeOnly `--chrome-only` -- re-render the on-screen deck over the
+ * segments already on disk and re-concat; no segment rebuilt
*
- * chaptersOnly and preview SKIP the preflight and the dry resolve: neither
- * touches a source, and both are seconds of work under a five-minute cap.
+ * chaptersOnly, preview and chromeOnly SKIP the preflight and the dry resolve:
+ * none of them touches a source. The first two are seconds of work under a
+ * five-minute cap; a deck re-render is a render plus a concat of the whole
+ * cut, so it keeps the build's own timeout.
*
- * @typedef {{ variant?: string, xfade?: boolean, chaptersOnly?: boolean, preview?: { at: number, dur: number } | null }} BuildOptions
+ * @typedef {{ variant?: string, xfade?: boolean, chaptersOnly?: boolean, chromeOnly?: boolean, preview?: { at: number, dur: number } | null }} BuildOptions
*/
/** The step-1 preflight alone, reused by the check-sources job. */
@@ -84,7 +88,7 @@ export function buildSteps(project, { preset = "fast", only = null, skipFetch =
const base = { cwd: PIPELINE_DIR, env };
const quick = !!(options.chaptersOnly || options.preview);
- const steps = quick
+ const steps = quick || options.chromeOnly
? []
: [
{
@@ -117,11 +121,18 @@ export function buildSteps(project, { preset = "fast", only = null, skipFetch =
if (skipFetch) buildArgv.push("--skip-fetch");
if (p.only && only) buildArgv.push("--only", only);
if (options.chaptersOnly) buildArgv.push("--chapters-only");
+ if (options.chromeOnly) buildArgv.push("--chrome-only");
if (options.preview) buildArgv.push("--preview", String(options.preview.at), String(options.preview.dur));
steps.push({
...base,
- label: options.chaptersOnly ? "retitle the chapters (no encode)" : options.preview ? `rail preview at ${options.preview.at}s` : p.label,
+ label: options.chaptersOnly
+ ? "retitle the chapters (no encode)"
+ : options.chromeOnly
+ ? "re-render on-screen"
+ : options.preview
+ ? `rail preview at ${options.preview.at}s`
+ : p.label,
argv: buildArgv,
ndjson: true,
timeoutMs: quick ? 5 * 60_000 : buildTimeoutMs(clipCount, p.xfade),
diff --git a/umtool/lib/report/driver.test.mjs b/umtool/lib/report/driver.test.mjs
@@ -0,0 +1,26 @@
+// buildSteps' `chromeOnly`: the deck re-rendered over the segments on disk.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { buildSteps } from "./driver.mjs";
+
+const project = { id: "p", dir: "/r/p" };
+
+test("chromeOnly: no preflight, --chrome-only on the build, labelled, then the verify", () => {
+ const steps = buildSteps(project, { preset: "final", options: { chromeOnly: true, variant: "full" } });
+ assert.deepEqual(steps.map((s) => s.label), ["re-render on-screen", "verify the file that came out"]);
+ const argv = steps[0].argv;
+ assert.ok(argv.includes("--chrome-only"));
+ assert.deepEqual(argv.slice(argv.indexOf("--variant"), argv.indexOf("--variant") + 2), ["--variant", "full"]);
+ assert.ok(!argv.includes("--chapters-only"));
+ // A render plus a concat of the whole cut: the build's timeout, not the quick cap.
+ assert.ok(steps[0].timeoutMs > 5 * 60_000);
+});
+
+test("without chromeOnly nothing changes: the preflight leads and no --chrome-only", () => {
+ const steps = buildSteps(project, { preset: "final" });
+ assert.equal(steps[0].label, "check every source is still fetchable");
+ assert.ok(steps.every((s) => !s.argv.includes("--chrome-only")));
+});
diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs
@@ -30,6 +30,7 @@ import {
rolesGaps,
} from "umtool-report-to-video/ledger-totals";
import { isCalendarDate } from "umtool-report-to-video/attribution";
+import { normalizeOnscreen, validateChrome } from "umtool-report-to-video/deck";
// Its own write queue, not lib/state.ts's.
//
@@ -317,6 +318,13 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) {
throw new Error("an incorrect verdict needs its note: say what the report got wrong");
}
+ // ---- what the deck will say ---------------------------------------------
+ //
+ // The bench's On-screen fields, written in the same sitting as the header
+ // fields above. setOnscreen is updateOnscreen's rule, so a clip edited here
+ // and a row saved from the On-screen table cannot store different shapes.
+ if (patch.onscreen !== undefined) setOnscreen(entry, patch.onscreen);
+
const nextToken = await writeManifestAtomic(dir, manifest);
return { entry, before, token: nextToken };
});
@@ -420,3 +428,127 @@ export async function updateClaim(dir, claimId, patch, { token = null } = {}) {
return { entry, token: nextToken };
});
}
+
+
+// ---------------------------------------------------------------------------
+// The DECK: per-entry on-screen text, and the render.chrome block that turns
+// the deck on.
+//
+// Both are checked by deck.mjs, imported rather than restated -- the build
+// refuses a manifest with the same two functions, so a value this file accepts
+// is one the build accepts, and the other way round.
+// ---------------------------------------------------------------------------
+
+/**
+ * Store one entry's `onscreen`, normalised. The value REPLACES the entry's
+ * whole `onscreen` -- `{ title }` alone clears a subtitle override -- because
+ * the editors send a row, not a field. Nothing left (null, `{}`, blanks)
+ * DELETES the key, the way an empty attribution field does.
+ */
+function setOnscreen(entry, value) {
+ const v = normalizeOnscreen(value);
+ if (v) entry.onscreen = v;
+ else delete entry.onscreen;
+ return v;
+}
+
+/**
+ * Patch the on-screen text of any number of timeline entries, in one write.
+ *
+ * Any entry type: a card's or a still's deck title is as much the author's as
+ * a clip's. The batch is ALL OR NOTHING -- an unknown id, or a value
+ * normalizeOnscreen refuses, fails the whole call before anything is written,
+ * because a table saved with one row silently dropped reads as saved.
+ *
+ * Ids are matched against the WHOLE timeline, every variant's entries
+ * included: an entry only the `full` cut shows still has a title.
+ *
+ * @param {string} dir
+ * @param {Record<string, { title?: string, subtitle?: string } | null>} onscreen
+ * @param {{ token?: string | null }} [opts]
+ * @returns {Promise<{ onscreen: Record<string, { title?: string, subtitle?: string } | null>, token: string | null }>}
+ */
+export async function updateOnscreen(dir, onscreen, { token = null } = {}) {
+ if (!onscreen || typeof onscreen !== "object" || Array.isArray(onscreen)) {
+ throw new Error("onscreen must be an object of entry id → { title, subtitle } or null");
+ }
+ const ids = Object.keys(onscreen);
+ if (!ids.length) throw new Error("nothing to change");
+ // Normalised BEFORE the lock: a bad value is the caller's error whatever the
+ // file says, and refusing it needs no read.
+ const next = {};
+ for (const id of ids) {
+ try {
+ next[id] = normalizeOnscreen(onscreen[id]);
+ } catch (e) {
+ throw new Error(`${id}: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ }
+
+ return withManifestLock(async () => {
+ const current = await manifestToken(dir);
+ if (token !== null && current !== token) throw new StaleToken(token, current);
+
+ const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8"));
+ const byId = new Map((manifest.timeline ?? []).map((e) => [e.id, e]));
+ const unknown = ids.filter((id) => !byId.has(id));
+ if (unknown.length) {
+ throw new Error(`no timeline entry with id ${unknown.join(", ")} — nothing was written`);
+ }
+ // A timeline can repeat an id across variants (`variant: "sourced"` and
+ // `variant: "full"` twins). Every entry with the id gets the text: they
+ // are one moment in two cuts.
+ for (const e of manifest.timeline) {
+ if (e.id in next) setOnscreen(e, next[e.id]);
+ }
+
+ const nextToken = await writeManifestAtomic(dir, manifest);
+ return { onscreen: next, token: nextToken };
+ });
+}
+
+/**
+ * Set, replace or remove `render.chrome`.
+ *
+ * `null` REMOVES it, which turns the deck off and puts the cut back on the
+ * legacy chrome. Anything else is stored as given -- a manifest names only the
+ * settings it changes, so the defaults are not written out -- once
+ * validateChrome has nothing to say about it against the rest of the render
+ * block (a rail or a legacy `chromeEngine` beside the deck is refused there,
+ * and so is footage that does not fit above it).
+ *
+ * @param {string} dir
+ * @param {Record<string, unknown> | null} chrome
+ * @param {{ token?: string | null }} [opts]
+ * @returns {Promise<{ chrome: Record<string, unknown> | null, token: string | null }>}
+ */
+export async function updateChrome(dir, chrome, { token = null } = {}) {
+ if (chrome === undefined) throw new Error("chrome must be an object, or null to remove it");
+ return withManifestLock(async () => {
+ const current = await manifestToken(dir);
+ if (token !== null && current !== token) throw new StaleToken(token, current);
+
+ const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8"));
+ const { chrome: _old, ...renderWithoutChrome } = manifest.render ?? {};
+ if (chrome === null) {
+ if (manifest.render) delete manifest.render.chrome;
+ } else {
+ const errors = validateChrome(chrome, renderWithoutChrome);
+ if (errors.length) throw new ChromeRefused(errors);
+ manifest.render = { ...(manifest.render ?? {}), chrome };
+ }
+
+ const nextToken = await writeManifestAtomic(dir, manifest);
+ return { chrome: manifest.render?.chrome ?? null, token: nextToken };
+ });
+}
+
+/** validateChrome's sentences, thrown whole so a route can return each one. */
+export class ChromeRefused extends Error {
+ /** @param {string[]} errors */
+ constructor(errors) {
+ super(`render.chrome: ${errors.join("; ")}`);
+ this.name = "ChromeRefused";
+ this.errors = errors;
+ }
+}
diff --git a/umtool/lib/report/manifest.test.mjs b/umtool/lib/report/manifest.test.mjs
@@ -0,0 +1,296 @@
+// The deck's writers: updateOnscreen, updateChrome, and updateClip's
+// `onscreen`. Each one goes through the lock, the backup, the atomic write and
+// the stale-token guard, and each refusal leaves the file byte-for-byte as it
+// was.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ ChromeRefused,
+ MANIFEST_NAME,
+ StaleToken,
+ manifestToken,
+ updateChrome,
+ updateClip,
+ updateOnscreen,
+} from "./manifest.mjs";
+
+const base = () => ({
+ slug: "t",
+ provenance: { siteOrigin: "https://example.test", channelSlug: "chan" },
+ render: { width: 1920, height: 1080, fps: 30, transition: 0.5 },
+ timeline: [
+ { type: "card", id: "k1", heading: "Opening", sub: "a card" },
+ { type: "clip", id: "c01", video: "v1", start: 10, end: 20 },
+ { type: "image", id: "i1", file: "x.png", seconds: 4 },
+ { type: "clip", id: "c02", video: "v2", start: 30, end: 41, onscreen: { title: "Kept" } },
+ ],
+});
+
+async function project(manifest = base()) {
+ const dir = await mkdtemp(path.join(tmpdir(), "umtool-deck-"));
+ await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n");
+ return dir;
+}
+const readRaw = (dir) => readFile(path.join(dir, MANIFEST_NAME), "utf8");
+const read = async (dir) => JSON.parse(await readRaw(dir));
+const entry = (m, id) => m.timeline.find((e) => e.id === id);
+
+test("updateOnscreen: round trip on every entry type, trimmed, in the CLI's formatting", async () => {
+ const dir = await project();
+ try {
+ const token = await manifestToken(dir);
+ const res = await updateOnscreen(
+ dir,
+ {
+ k1: { title: " Card title " },
+ c01: { title: "County approves pre-application", subtitle: " Override · 2024 " },
+ i1: { subtitle: "Still" },
+ },
+ { token },
+ );
+ assert.deepEqual(res.onscreen.k1, { title: "Card title" });
+ assert.equal(res.token, await manifestToken(dir));
+
+ const raw = await readRaw(dir);
+ assert.ok(raw.endsWith("}\n"), "trailing newline kept");
+ assert.ok(raw.startsWith('{\n "slug"'), "two-space indent kept");
+ const m = JSON.parse(raw);
+ assert.deepEqual(entry(m, "k1").onscreen, { title: "Card title" });
+ assert.deepEqual(entry(m, "c01").onscreen, {
+ title: "County approves pre-application",
+ subtitle: "Override · 2024",
+ });
+ assert.deepEqual(entry(m, "i1").onscreen, { subtitle: "Still" });
+ // Untouched rows stay as they were.
+ assert.deepEqual(entry(m, "c02").onscreen, { title: "Kept" });
+ // The backup of the previous state sits beside it.
+ assert.ok(await stat(path.join(dir, `${MANIFEST_NAME}.bak`)));
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updateOnscreen: a row replaces the whole onscreen; empty and null delete the key", async () => {
+ const dir = await project();
+ try {
+ await updateOnscreen(dir, { c01: { title: "T", subtitle: "S" } });
+ await updateOnscreen(dir, { c01: { title: "T2" } });
+ assert.deepEqual(entry(await read(dir), "c01").onscreen, { title: "T2" });
+
+ await updateOnscreen(dir, { c01: { title: " ", subtitle: "" }, c02: null });
+ const m = await read(dir);
+ assert.equal("onscreen" in entry(m, "c01"), false);
+ assert.equal("onscreen" in entry(m, "c02"), false);
+
+ await updateOnscreen(dir, { k1: { title: "x" } });
+ await updateOnscreen(dir, { k1: {} });
+ assert.equal("onscreen" in entry(await read(dir), "k1"), false);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updateOnscreen: an unknown id refuses the WHOLE batch and writes nothing", async () => {
+ const dir = await project();
+ try {
+ const before = await readRaw(dir);
+ await assert.rejects(
+ updateOnscreen(dir, { c01: { title: "would be written" }, nope: { title: "x" } }),
+ /no timeline entry with id nope/,
+ );
+ assert.equal(await readRaw(dir), before);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updateOnscreen: a value the deck refuses fails the batch, naming the entry", async () => {
+ const dir = await project();
+ try {
+ const before = await readRaw(dir);
+ await assert.rejects(updateOnscreen(dir, { c01: { title: "ok" }, k1: { title: "two\nlines" } }), /k1: .*one line/);
+ await assert.rejects(updateOnscreen(dir, { c01: { heading: "x" } }), /c01: onscreen\.heading is not/);
+ await assert.rejects(updateOnscreen(dir, { c01: { title: "x".repeat(201) } }), /at most 200/);
+ await assert.rejects(updateOnscreen(dir, {}), /nothing to change/);
+ await assert.rejects(updateOnscreen(dir, null), /must be an object/);
+ assert.equal(await readRaw(dir), before);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updateOnscreen: a stale token is refused and writes nothing", async () => {
+ const dir = await project();
+ try {
+ const before = await readRaw(dir);
+ await assert.rejects(updateOnscreen(dir, { c01: { title: "x" } }, { token: "1" }), (e) => {
+ assert.ok(e instanceof StaleToken);
+ assert.equal(e.expected, "1");
+ return true;
+ });
+ assert.equal(await readRaw(dir), before);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updateOnscreen: every variant twin with the id gets the text", async () => {
+ const m = base();
+ m.timeline.push({ type: "clip", id: "c01", variant: "full", video: "v1", start: 9, end: 22 });
+ m.timeline[1].variant = "sourced";
+ const dir = await project(m);
+ try {
+ await updateOnscreen(dir, { c01: { title: "Both" } });
+ const out = (await read(dir)).timeline.filter((e) => e.id === "c01");
+ assert.equal(out.length, 2);
+ for (const e of out) assert.deepEqual(e.onscreen, { title: "Both" });
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updateClip: onscreen is normalised, and empty or null deletes the key", async () => {
+ const dir = await project();
+ try {
+ const res = await updateClip(dir, "c01", { onscreen: { title: " Bench title " } });
+ assert.deepEqual(res.entry.onscreen, { title: "Bench title" });
+ assert.deepEqual(entry(await read(dir), "c01").onscreen, { title: "Bench title" });
+
+ await updateClip(dir, "c01", { onscreen: { title: "", subtitle: " " } });
+ assert.equal("onscreen" in entry(await read(dir), "c01"), false);
+
+ await updateClip(dir, "c02", { onscreen: null });
+ assert.equal("onscreen" in entry(await read(dir), "c02"), false);
+
+ const before = await readRaw(dir);
+ await assert.rejects(updateClip(dir, "c01", { onscreen: { title: "a\nb" } }), /one line/);
+ await assert.rejects(updateClip(dir, "c01", { onscreen: "a string" }), /must be an object/);
+ // A refused value writes nothing.
+ assert.equal(await readRaw(dir), before);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+const DECK = { engine: "hyperframes", layout: "deck", deck: { height: 180, title: { size: 60 } } };
+
+test("updateChrome: round trip stores the block as given; null removes it", async () => {
+ const dir = await project();
+ try {
+ const token = await manifestToken(dir);
+ const res = await updateChrome(dir, DECK, { token });
+ assert.deepEqual(res.chrome, DECK);
+ assert.equal(res.token, await manifestToken(dir));
+ let m = await read(dir);
+ assert.deepEqual(m.render.chrome, DECK);
+ // The rest of the render block is untouched and keeps its order.
+ assert.deepEqual(Object.keys(m.render), ["width", "height", "fps", "transition", "chrome"]);
+
+ // Replacing keeps the key where it was.
+ await updateChrome(dir, { engine: "hyperframes", layout: "deck" });
+ m = await read(dir);
+ assert.deepEqual(m.render.chrome, { engine: "hyperframes", layout: "deck" });
+
+ const off = await updateChrome(dir, null);
+ assert.equal(off.chrome, null);
+ m = await read(dir);
+ assert.equal("chrome" in m.render, false);
+ assert.equal(m.render.fps, 30);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updateChrome: a manifest with no render block gets one", async () => {
+ const m = base();
+ delete m.render;
+ const dir = await project(m);
+ try {
+ await updateChrome(dir, DECK);
+ assert.deepEqual((await read(dir)).render, { chrome: DECK });
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updateChrome: validateChrome's sentences come back whole, and nothing is written", async () => {
+ const dir = await project();
+ try {
+ const before = await readRaw(dir);
+ const refused = async (chrome, ...want) => {
+ await assert.rejects(updateChrome(dir, chrome), (e) => {
+ assert.ok(e instanceof ChromeRefused, String(e));
+ for (const w of want) assert.ok(e.errors.some((s) => w.test(s)), `${w} in ${JSON.stringify(e.errors)}`);
+ return true;
+ });
+ assert.equal(await readRaw(dir), before);
+ };
+ await refused(
+ { engine: "hyperframes", layout: "deck", deck: { height: 50, footageScle: 0.8 } },
+ /deck\.height must be a whole number/,
+ /deck\.footageScle is not a deck setting/,
+ );
+ await refused({ engine: "ffmpeg", layout: "deck" }, /engine must be "hyperframes"/);
+ await refused({ engine: "hyperframes", layout: "deck", deck: { qr: { size: 300 } } }, /qr\.size 300 does not fit/);
+ await refused("deck", /must be an object/);
+ await assert.rejects(updateChrome(dir, undefined), /an object, or null/);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updateChrome: refused beside a rail or the legacy chromeEngine — checked against the REST of render", async () => {
+ const m = base();
+ m.render.rail = { kind: "x" };
+ m.render.chromeEngine = "hyperframes";
+ const dir = await project(m);
+ try {
+ await assert.rejects(updateChrome(dir, DECK), (e) => {
+ assert.ok(e instanceof ChromeRefused);
+ assert.ok(e.errors.some((s) => /render\.rail cannot both be set/.test(s)));
+ assert.ok(e.errors.some((s) => /remove chromeEngine/.test(s)));
+ return true;
+ });
+ // Turning the deck OFF is never refused: it is how such a manifest is fixed.
+ await updateChrome(dir, null);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("updateChrome: a stale token is refused and writes nothing", async () => {
+ const dir = await project();
+ try {
+ const before = await readRaw(dir);
+ await assert.rejects(updateChrome(dir, DECK, { token: "1" }), StaleToken);
+ await assert.rejects(updateChrome(dir, null, { token: "1" }), StaleToken);
+ assert.equal(await readRaw(dir), before);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("the writers queue: concurrent saves of different fields all land", async () => {
+ const dir = await project();
+ try {
+ await Promise.all([
+ updateOnscreen(dir, { k1: { title: "A" } }),
+ updateChrome(dir, DECK),
+ updateClip(dir, "c01", { onscreen: { title: "B" } }),
+ updateOnscreen(dir, { i1: { title: "C" } }),
+ ]);
+ const m = await read(dir);
+ assert.deepEqual(entry(m, "k1").onscreen, { title: "A" });
+ assert.deepEqual(entry(m, "c01").onscreen, { title: "B" });
+ assert.deepEqual(entry(m, "i1").onscreen, { title: "C" });
+ assert.deepEqual(m.render.chrome, DECK);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs
@@ -0,0 +1,265 @@
+// The deck's preview and stills, as umtool asks for them.
+//
+// Everything about WHAT the deck says and WHERE it sits is deck.mjs's; how it
+// is DRAWN is compose-chrome's. This module only decides which schedule a
+// preview is drawn from and hands it over:
+//
+// - the BUILD's schedule (out/<variant>/schedule.json, `kind: "deck"`) when
+// there is one and it still lists this cut's entries in this order -- its
+// durations are probed, so a handover lands where the render puts it;
+// - else an ESTIMATE from the manifest alone (`estimated: true`), so the
+// section previews before anything has been built.
+//
+// Either way a request's DRAFT -- the On-screen table's unsaved rows -- is
+// overlaid, so the preview shows what a save would build.
+//
+// The preview never renders and never touches the build's project or cache:
+// compose-chrome's `preview: true` writes out/<variant>/chrome/deck-preview/.
+import { mkdir, readFile, rm } from "node:fs/promises";
+import path from "node:path";
+import { composeChrome } from "umtool-report-to-video/compose-chrome";
+import { selectVariant } from "umtool-report-to-video/build-video";
+import { deckText, estimateSchedule, normalizeOnscreen, resolveDeck } from "umtool-report-to-video/deck";
+import {
+ channelsDirFor,
+ cuePathFor,
+ hasShadowChannels,
+ manifestPath,
+ readCues,
+} from "../projects/report.mjs";
+import { deckPreviewDir } from "./serve.mjs";
+
+/** The schedule document deck.mjs defines, built or estimated. */
+/** @typedef {ReturnType<typeof estimateSchedule>} DeckSchedule */
+
+/**
+ * A draft from the client, normalised: entry id → `{title?, subtitle?}` or
+ * null. Same rule as updateOnscreen -- a row REPLACES the entry's whole
+ * `onscreen` -- so the preview of a draft is the build of its save. Throws,
+ * naming the id, on a value the writer would refuse.
+ *
+ * @param {unknown} draft
+ * @returns {Map<string, { title?: string, subtitle?: string } | null>}
+ */
+export function normalizeDraft(draft) {
+ const out = new Map();
+ if (draft === undefined || draft === null) return out;
+ if (typeof draft !== "object" || Array.isArray(draft)) {
+ throw new Error("draft must be an object of entry id → { title, subtitle } or null");
+ }
+ for (const [id, v] of Object.entries(draft)) {
+ try {
+ out.set(id, normalizeOnscreen(v));
+ } catch (e) {
+ throw new Error(`${id}: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ }
+ return out;
+}
+
+/**
+ * Each entry's source metadata, aligned with the cut's timeline: what the
+ * auto subtitle needs (`{ title, uploadDate, channel }`) or null.
+ *
+ * From the archive's cue files -- the same documents, through the same
+ * memoised reader, that give the clip bench its source title and upload date.
+ * The build reads the fetched file's own metadata instead, which is why an
+ * estimate's subtitle can differ from the built one where the two disagree.
+ *
+ * @param {string} dir the project directory
+ * @param {Record<string, any>} manifest the WHOLE manifest (channels, provenance)
+ * @param {Array<Record<string, any>>} entries the cut's timeline
+ */
+export async function deckMetas(dir, manifest, entries) {
+ const channelsDir = channelsDirFor(dir, manifest, { shadowExists: await hasShadowChannels(dir) });
+ const reads = new Map();
+ return Promise.all(
+ entries.map(async (e) => {
+ if (e.type !== "clip" || !e.video) return null;
+ const file = cuePathFor(manifest, e, channelsDir);
+ if (!file) return null;
+ if (!reads.has(file)) reads.set(file, readCues(file).catch(() => null));
+ const doc = await reads.get(file);
+ return doc ? { title: doc.title ?? null, uploadDate: doc.uploadDate ?? null, channel: doc.channel ?? null } : null;
+ }),
+ );
+}
+
+/**
+ * Is this build schedule still the cut's? Same entries, same order. A clip
+ * added, dropped or moved since the build makes every later start wrong, and
+ * then the estimate is the better answer.
+ */
+export function scheduleMatches(schedule, entries) {
+ if (schedule?.kind !== "deck" || !Array.isArray(schedule.segments)) return false;
+ if (schedule.segments.length !== entries.length) return false;
+ return schedule.segments.every((s, i) => s.id === entries[i].id);
+}
+
+/**
+ * The schedule a preview draws. Pure: the caller reads the files.
+ *
+ * With a build schedule, its timings and QRs are kept and every segment's
+ * title and subtitle are re-derived with deckText -- the function the build
+ * used -- from the manifest as it is NOW with the draft applied. Not only the
+ * drafted rows: a row saved since the build would otherwise preview as the
+ * text the build drew. One exception, toward the build: a clip with no
+ * subtitle override and no cue file to read keeps the build's auto subtitle,
+ * which came from the fetched file's own metadata.
+ *
+ * Without a build schedule the draft is applied to the entries and the whole
+ * cut is estimated.
+ *
+ * @param {{ variantManifest: Record<string, any>, built: Record<string, any> | null,
+ * draft: Map<string, { title?: string, subtitle?: string } | null>,
+ * metas: Array<Record<string, any> | null> }} args
+ * @returns {DeckSchedule}
+ */
+export function previewSchedule({ variantManifest, built, draft, metas }) {
+ const entries = variantManifest.timeline ?? [];
+ const patched = (e) => {
+ if (!draft.has(e.id)) return e;
+ const v = draft.get(e.id);
+ const { onscreen: _o, ...rest } = e;
+ return v ? { ...rest, onscreen: v } : rest;
+ };
+
+ if (built && scheduleMatches(built, entries)) {
+ const render = variantManifest.render ?? {};
+ const deck = resolveDeck(render);
+ const provenance = variantManifest.provenance ?? {};
+ return {
+ ...built,
+ segments: built.segments.map((s, i) => {
+ const e = patched(entries[i]);
+ const meta = metas[i] ?? null;
+ const { title, subtitle } = deckText(e, meta, provenance, deck, built.multiChannel);
+ const keepBuilt = e.type === "clip" && e.onscreen?.subtitle === undefined && !meta;
+ return { ...s, title, subtitle: keepBuilt ? s.subtitle : subtitle };
+ }),
+ };
+ }
+ return estimateSchedule({ ...variantManifest, timeline: entries.map(patched) }, { metas });
+}
+
+/** out/<variant>/schedule.json when it is the deck's, else null. */
+async function readBuiltSchedule(dir, variant) {
+ try {
+ const doc = JSON.parse(await readFile(path.join(dir, "out", variant, "schedule.json"), "utf8"));
+ return doc?.kind === "deck" ? doc : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The cut, the schedule a preview of it draws, and the render block the
+ * geometry comes from.
+ *
+ * @param {{ dir: string }} project
+ * @param {Record<string, any>} manifest
+ * @param {string} variant
+ * @param {Map<string, { title?: string, subtitle?: string } | null>} draft
+ */
+export async function scheduleForPreview(project, manifest, variant, draft = new Map()) {
+ const variantManifest = selectVariant(manifest, variant);
+ const entries = variantManifest.timeline ?? [];
+ const [built, metas] = await Promise.all([
+ readBuiltSchedule(project.dir, variant),
+ deckMetas(project.dir, manifest, entries),
+ ]);
+ return { variantManifest, schedule: previewSchedule({ variantManifest, built, draft, metas }) };
+}
+
+// compose-chrome writes one directory per cut. Two requests composing into it
+// at once would interleave their writes, so they queue per directory.
+/** @type {Map<string, Promise<unknown>>} */
+const queues = new Map();
+/**
+ * @template T
+ * @param {string} key
+ * @param {() => Promise<T>} fn
+ * @returns {Promise<T>}
+ */
+function serialised(key, fn) {
+ const prev = queues.get(key) ?? Promise.resolve();
+ const run = prev.then(fn, fn);
+ const tail = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ queues.set(key, tail);
+ tail.then(() => {
+ if (queues.get(key) === tail) queues.delete(key);
+ });
+ return run;
+}
+
+/**
+ * Compose the preview project for a cut from `schedule`. No render.
+ *
+ * compose-chrome decides where the project goes; the files route serves
+ * deckPreviewDir. If the two ever disagree the iframe would load nothing and
+ * say nothing, so a mismatch is an error here instead.
+ *
+ * @param {{ dir: string }} project
+ * @param {string} variant
+ * @param {Record<string, any>} schedule
+ */
+export async function composeDeckPreview(project, variant, schedule) {
+ const outDir = path.join(project.dir, "out", variant);
+ const want = deckPreviewDir(project.dir, variant);
+ return serialised(want, async () => {
+ /** @type {Record<string, unknown>} */
+ const args = { manifestPath: manifestPath(project.dir), outDir, variant, region: "deck", schedule, preview: true };
+ const r = await composeChrome(/** @type {any} */ (args));
+ if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) {
+ throw new Error(`compose-chrome wrote the preview to ${r.projDir}, not ${want}`);
+ }
+ return r;
+ });
+}
+
+/**
+ * A true still of the deck at `t`: the composition, screenshotted by the
+ * render browser, as PNG bytes. Composed into the preview project (never the
+ * build's), into a scratch file that is removed once read.
+ *
+ * @param {{ dir: string }} project
+ * @param {string} variant
+ * @param {Record<string, any>} schedule
+ * @param {number} t seconds in the cut's clock
+ * @returns {Promise<Buffer>}
+ */
+export async function deckStill(project, variant, schedule, t) {
+ const outDir = path.join(project.dir, "out", variant);
+ const want = deckPreviewDir(project.dir, variant);
+ const stills = path.join(outDir, "chrome", "deck-stills");
+ return serialised(want, async () => {
+ await mkdir(stills, { recursive: true });
+ const png = path.join(stills, `still-${process.pid}-${Math.random().toString(36).slice(2, 8)}.png`);
+ try {
+ /** @type {Record<string, unknown>} */
+ const args = {
+ manifestPath: manifestPath(project.dir),
+ outDir,
+ variant,
+ region: "deck",
+ schedule,
+ preview: true,
+ still: t,
+ png,
+ };
+ await composeChrome(/** @type {any} */ (args));
+ return await readFile(png);
+ } finally {
+ await rm(png, { force: true });
+ }
+ });
+}
+
+/** The moment a still of one entry shows: the middle of its segment. */
+export function stillTimeOf(schedule, id) {
+ const s = (schedule.segments ?? []).find((x) => x.id === id);
+ return s ? Math.round((s.start + s.duration / 2) * 1000) / 1000 : null;
+}
diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs
@@ -0,0 +1,105 @@
+// Which schedule umtool's deck preview draws, and what a draft does to it.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { estimateSchedule } from "umtool-report-to-video/deck";
+import { normalizeDraft, previewSchedule, scheduleMatches, stillTimeOf } from "./onscreen.mjs";
+
+const cut = () => ({
+ slug: "t",
+ variant: "sourced",
+ provenance: { siteOrigin: "https://example.test", channelSlug: "chan" },
+ render: { fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck" } },
+ timeline: [
+ { type: "card", id: "k1", heading: "Opening", sub: "a card", seconds: 5 },
+ { type: "clip", id: "c01", video: "v1", start: 10, end: 20 },
+ { type: "clip", id: "c02", video: "v2", start: 30, end: 41, onscreen: { title: "Saved", subtitle: "Saved sub" } },
+ ],
+});
+const metas = [null, { title: "Stream one", uploadDate: "20240903" }, { title: "Stream two", uploadDate: "20240910" }];
+
+/** A schedule as the build writes it: probed durations, the build's text. */
+const built = () => ({
+ version: 1,
+ kind: "deck",
+ estimated: false,
+ fps: 30,
+ transition: 0.5,
+ total: 25.9,
+ multiChannel: false,
+ segments: [
+ { id: "k1", type: "card", start: 0, duration: 5, end: 5, title: "Opening", subtitle: "a card", qrUrl: null, hideDeck: true },
+ { id: "c01", type: "clip", start: 4.5, duration: 9.9, end: 14.4, title: "", subtitle: "Built sub one", qrUrl: "https://q/1", hideDeck: false },
+ { id: "c02", type: "clip", start: 13.9, duration: 12, end: 25.9, title: "Old", subtitle: "Old sub", qrUrl: "https://q/2", hideDeck: false },
+ ],
+});
+
+test("no build schedule: an estimate from the manifest, with the cue metadata in its subtitles", () => {
+ const s = previewSchedule({ variantManifest: cut(), built: null, draft: new Map(), metas });
+ assert.equal(s.estimated, true);
+ assert.deepEqual(s, estimateSchedule(cut(), { metas }));
+ const c01 = s.segments.find((x) => x.id === "c01");
+ assert.match(c01.subtitle, /Stream one/);
+ assert.match(c01.subtitle, /Sep 3, 2024/);
+});
+
+test("no build schedule: the draft is applied before estimating, a row replacing the whole onscreen", () => {
+ const draft = normalizeDraft({ c01: { title: " Drafted " }, c02: { title: "Only a title" }, k1: null });
+ const s = previewSchedule({ variantManifest: cut(), built: null, draft, metas });
+ const by = Object.fromEntries(s.segments.map((x) => [x.id, x]));
+ assert.equal(by.c01.title, "Drafted");
+ assert.equal(by.c02.title, "Only a title");
+ // c02's saved subtitle override is gone with the row: auto again.
+ assert.match(by.c02.subtitle, /Stream two/);
+ assert.equal(by.k1.title, "Opening");
+});
+
+test("a matching build schedule keeps its timings and QRs; the text is the manifest's NOW", () => {
+ const draft = normalizeDraft({ c01: { title: "Drafted" } });
+ const s = previewSchedule({ variantManifest: cut(), built: built(), draft, metas });
+ assert.equal(s.estimated, false);
+ assert.equal(s.total, 25.9);
+ const by = Object.fromEntries(s.segments.map((x) => [x.id, x]));
+ assert.equal(by.c01.start, 4.5);
+ assert.equal(by.c01.duration, 9.9);
+ assert.equal(by.c01.qrUrl, "https://q/1");
+ assert.equal(by.c01.title, "Drafted");
+ // Saved since the build: the preview shows the save, not the build's text.
+ assert.equal(by.c02.title, "Saved");
+ assert.equal(by.c02.subtitle, "Saved sub");
+ assert.equal(by.k1.hideDeck, true);
+});
+
+test("a clip with no override and no cue file keeps the build's auto subtitle", () => {
+ const s = previewSchedule({ variantManifest: cut(), built: built(), draft: new Map(), metas: [null, null, null] });
+ const by = Object.fromEntries(s.segments.map((x) => [x.id, x]));
+ assert.equal(by.c01.subtitle, "Built sub one");
+ assert.equal(by.c02.subtitle, "Saved sub");
+});
+
+test("a build schedule that no longer lists the cut's entries in order is not used", () => {
+ const m = cut();
+ m.timeline.reverse();
+ assert.equal(scheduleMatches(built(), m.timeline), false);
+ const s = previewSchedule({ variantManifest: m, built: built(), draft: new Map(), metas: [] });
+ assert.equal(s.estimated, true);
+ assert.deepEqual(s.segments.map((x) => x.id), ["c02", "c01", "k1"]);
+
+ assert.equal(scheduleMatches({ ...built(), kind: "rail" }, cut().timeline), false);
+ assert.equal(scheduleMatches(built(), cut().timeline), true);
+});
+
+test("normalizeDraft: names the entry a bad value belongs to", () => {
+ assert.equal(normalizeDraft(undefined).size, 0);
+ assert.equal(normalizeDraft(null).size, 0);
+ assert.equal(normalizeDraft({ a: { title: " " } }).get("a"), null);
+ assert.throws(() => normalizeDraft({ c01: { title: "a\nb" } }), /c01: .*one line/);
+ assert.throws(() => normalizeDraft([1]), /must be an object/);
+});
+
+test("stillTimeOf: the middle of an entry's segment", () => {
+ assert.equal(stillTimeOf(built(), "c01"), 9.45);
+ assert.equal(stillTimeOf(built(), "nope"), null);
+});
diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs
@@ -6,10 +6,12 @@
// name that is simply not there fails, and a traversal fails twice: once on the
// membership check and once on resolveInRoots.
import path from "node:path";
-import { stat } from "node:fs/promises";
-import { REPORTS_ROOT, resolveInRoots } from "../paths.mjs";
+import { createReadStream } from "node:fs";
+import { realpath, stat } from "node:fs/promises";
+import { Readable } from "node:stream";
+import { REPORTS_ROOT, inside, resolveInRoots } from "../paths.mjs";
import { walkProjects } from "../projects/walk.mjs";
-import { DEFAULT_VARIANT, WIN_EPS } from "umtool-report-to-video/build-video";
+import { DEFAULT_VARIANT, VARIANTS, WIN_EPS, variantPaths } from "umtool-report-to-video/build-video";
import { rawCacheOf } from "./raw-cache.mjs";
import {
channelsDirFor,
@@ -30,6 +32,27 @@ export async function resolveClip(projectId, clipId) {
return { project, manifest, clip };
}
+/**
+ * The same membership rule for a request that names a PROJECT and a cut, and
+ * no clip: the deck's routes. `variant` is checked against the pipeline's own
+ * list; absent or empty it is the default cut.
+ *
+ * @param {string} projectId
+ * @param {string | null} [variant]
+ */
+export async function resolveReport(projectId, variant = null) {
+ const v = variant || DEFAULT_VARIANT;
+ if (!VARIANTS.includes(v)) {
+ return { error: `variant must be one of ${VARIANTS.join(", ")}`, status: 400 };
+ }
+ const projects = await walkProjects(REPORTS_ROOT);
+ const project = projects.find((p) => p.id === projectId);
+ if (!project) return { error: "no such project", status: 404 };
+ const manifest = await readManifest(project.dir);
+ if (!manifest) return { error: "no manifest", status: 404 };
+ return { project, manifest, variant: v };
+}
+
/** The clips-raw cache, re-exported so `serve.mjs` stays the bench's one door. */
export { rawCacheOf } from "./raw-cache.mjs";
@@ -147,3 +170,145 @@ export async function resolveClaim(projectId, claimId) {
if (!claim) return { error: "no such claim", status: 404 };
return { project, manifest, claim };
}
+
+
+// ---------------------------------------------------------------------------
+// Byte ranges.
+//
+// One implementation for every route here that hands an mp4 to a <video>:
+// the built segment, the cut and its preview. Without a 206 the element will
+// not seek in a stream it did not fully download.
+// ---------------------------------------------------------------------------
+
+/**
+ * The response for one file the caller has ALREADY authorised and stat'ed.
+ *
+ * A `Range` this does not parse is ignored and the whole file is sent; one it
+ * parses but cannot satisfy is a 416 carrying only `content-range`. A suffix
+ * range (`bytes=-500`) is the LAST n bytes -- Chrome asks for one to find an
+ * mp4's moov atom when it is not at the front.
+ *
+ * @param {Request} request
+ * @param {{ abs: string, size: number, headers?: Record<string, string> }} file
+ * @returns {Response}
+ */
+export function rangeResponse(request, { abs, size, headers = {} }) {
+ const range = request.headers.get("range");
+ const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
+ if (m) {
+ let start = m[1] ? Number(m[1]) : 0;
+ let end = m[2] ? Number(m[2]) : size - 1;
+ if (!m[1] && m[2]) {
+ // A suffix range: the LAST n bytes.
+ start = Math.max(0, size - Number(m[2]));
+ end = size - 1;
+ }
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) {
+ return new Response(null, { status: 416, headers: { "content-range": `bytes */${size}` } });
+ }
+ end = Math.min(end, size - 1);
+ return new Response(/** @type {ReadableStream} */ (Readable.toWeb(createReadStream(abs, { start, end }))), {
+ status: 206,
+ headers: {
+ ...headers,
+ "content-range": `bytes ${start}-${end}/${size}`,
+ "content-length": String(end - start + 1),
+ },
+ });
+ }
+ return new Response(/** @type {ReadableStream} */ (Readable.toWeb(createReadStream(abs))), {
+ headers: { ...headers, "content-length": String(size) },
+ });
+}
+
+/**
+ * The deliverable of one cut, or the short window `--chrome-preview` writes.
+ *
+ * Built from the manifest's slug and the checked variant through the
+ * pipeline's own variantPaths(), never from anything the client typed: `final`
+ * is `out/<slug>.mp4` (`out/<slug>-full.mp4` for `full`), `preview` is
+ * `out/<variant>/<slug>.preview.mp4`. The mtime rides along for the same
+ * reason segmentFor's does -- a re-render writes the same path.
+ *
+ * @param {{ dir: string }} project
+ * @param {{ slug?: string }} manifest
+ * @param {string} variant
+ * @param {"final" | "preview"} kind
+ */
+export async function videoFor(project, manifest, variant, kind) {
+ const slug = manifest.slug ?? path.basename(project.dir);
+ const dirs = variantPaths(path.join(project.dir, "out"), slug, variant);
+ const file = kind === "preview" ? path.join(dirs.dir, `${slug}.preview.mp4`) : dirs.final;
+ const abs = resolveInRoots(file);
+ if (!abs) return null;
+ const st = await stat(abs).catch(() => null);
+ if (!st?.isFile()) return null;
+ return {
+ rel: path.relative(project.dir, abs).split(path.sep).join("/"),
+ abs,
+ size: st.size,
+ mtimeMs: Math.round(st.mtimeMs),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// The deck's preview composition.
+//
+// compose-chrome writes it under out/<variant>/chrome/deck-preview/, and the
+// page loads it in an iframe -- so its index.html, and the assets it names by
+// RELATIVE url, are served from one prefix by GET /api/report/chrome/files/.
+// That route takes a path from the client, which nothing else here does, so
+// the whole rule is in deckPreviewFile and it is tested.
+// ---------------------------------------------------------------------------
+
+/** The preview project directory of one cut. Never a build's `chrome/deck/`. */
+export const deckPreviewDir = (projectDir, variant) =>
+ path.join(projectDir, "out", variant, "chrome", "deck-preview");
+
+/**
+ * The project id as ONE url segment. Ids are relative paths (`folder/name`),
+ * and the composition's relative asset urls only resolve under a prefix with
+ * no query string, so the id cannot ride as a parameter or as raw segments.
+ */
+export const encodeProjectSegment = (id) => Buffer.from(String(id), "utf8").toString("base64url");
+export const decodeProjectSegment = (seg) => {
+ if (!/^[A-Za-z0-9_-]+$/.test(String(seg ?? ""))) return null;
+ return Buffer.from(seg, "base64url").toString("utf8");
+};
+
+/** The iframe src for a cut's preview composition. */
+export const deckPreviewSrc = (projectId, variant) =>
+ `/api/report/chrome/files/${encodeProjectSegment(projectId)}/${variant}/index.html`;
+
+/**
+ * Resolve the url segments after `<project>/<variant>/` to a file INSIDE the
+ * preview directory, or null.
+ *
+ * Refused: an empty, `.` or `..` segment; one carrying a slash, a backslash or
+ * a NUL (a segment the router decoded from `%2F` is still one segment); an
+ * absolute path; and anything whose REAL path -- symlinks followed -- is not
+ * under the directory's real path. A symlink inside the directory pointing
+ * out of it is the case the realpath is for. A directory is not a file.
+ *
+ * @param {string} dir the preview directory (deckPreviewDir)
+ * @param {string[]} segments
+ * @returns {Promise<{ abs: string, size: number } | null>}
+ */
+export async function deckPreviewFile(dir, segments) {
+ if (!Array.isArray(segments) || !segments.length) return null;
+ for (const seg of segments) {
+ if (typeof seg !== "string" || !seg || seg === "." || seg === "..") return null;
+ if (/[\/\\\0]/.test(seg) || path.isAbsolute(seg)) return null;
+ }
+ const base = path.resolve(dir);
+ const abs = path.resolve(base, ...segments);
+ if (!inside(base, abs) || abs === base) return null;
+ const [realBase, realAbs] = await Promise.all([
+ realpath(base).catch(() => null),
+ realpath(abs).catch(() => null),
+ ]);
+ if (!realBase || !realAbs || !inside(realBase, realAbs) || realAbs === realBase) return null;
+ const st = await stat(realAbs).catch(() => null);
+ if (!st?.isFile()) return null;
+ return { abs: realAbs, size: st.size };
+}
diff --git a/umtool/lib/report/serve.test.mjs b/umtool/lib/report/serve.test.mjs
@@ -0,0 +1,185 @@
+// What the report routes serve: rangeResponse (the segment and video routes'
+// byte ranges) and deckPreviewFile (the one route that takes a path from the
+// client, confined to the deck's preview directory).
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ decodeProjectSegment,
+ deckPreviewDir,
+ deckPreviewFile,
+ deckPreviewSrc,
+ encodeProjectSegment,
+ rangeResponse,
+} from "./serve.mjs";
+
+const req = (range) => new Request("http://x/", range ? { headers: { range } } : {});
+const bytes = async (res) => Buffer.from(await res.arrayBuffer());
+
+test("rangeResponse: whole file, explicit, open-ended, suffix and clamped ranges", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "umtool-range-"));
+ try {
+ const data = Buffer.from(Array.from({ length: 1000 }, (_, i) => i % 251));
+ const abs = path.join(dir, "a.mp4");
+ await writeFile(abs, data);
+ const file = { abs, size: data.length, headers: { "content-type": "video/mp4", "x-k": "v" } };
+
+ let res = rangeResponse(req(null), file);
+ assert.equal(res.status, 200);
+ assert.equal(res.headers.get("content-length"), "1000");
+ assert.equal(res.headers.get("content-type"), "video/mp4");
+ assert.equal(res.headers.get("x-k"), "v");
+ assert.deepEqual(await bytes(res), data);
+
+ res = rangeResponse(req("bytes=0-99"), file);
+ assert.equal(res.status, 206);
+ assert.equal(res.headers.get("content-range"), "bytes 0-99/1000");
+ assert.equal(res.headers.get("content-length"), "100");
+ assert.equal(res.headers.get("x-k"), "v");
+ assert.deepEqual(await bytes(res), data.subarray(0, 100));
+
+ res = rangeResponse(req("bytes=900-"), file);
+ assert.equal(res.status, 206);
+ assert.equal(res.headers.get("content-range"), "bytes 900-999/1000");
+ assert.deepEqual(await bytes(res), data.subarray(900));
+
+ // A suffix range is the LAST n bytes, not an offset.
+ res = rangeResponse(req("bytes=-100"), file);
+ assert.equal(res.status, 206);
+ assert.equal(res.headers.get("content-range"), "bytes 900-999/1000");
+ assert.deepEqual(await bytes(res), data.subarray(900));
+
+ res = rangeResponse(req("bytes=-5000"), file);
+ assert.equal(res.headers.get("content-range"), "bytes 0-999/1000");
+
+ // An end past the file is clamped.
+ res = rangeResponse(req("bytes=990-5000"), file);
+ assert.equal(res.headers.get("content-range"), "bytes 990-999/1000");
+ assert.equal((await bytes(res)).length, 10);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("rangeResponse: unsatisfiable is a bare 416; unparseable is the whole file", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "umtool-range-"));
+ try {
+ const abs = path.join(dir, "a.mp4");
+ await writeFile(abs, Buffer.alloc(1000, 1));
+ const file = { abs, size: 1000, headers: { "content-type": "video/mp4" } };
+ for (const r of ["bytes=1000-", "bytes=5-2", "bytes=2000-3000"]) {
+ const res = rangeResponse(req(r), file);
+ assert.equal(res.status, 416, r);
+ assert.equal(res.headers.get("content-range"), "bytes */1000");
+ assert.equal(res.headers.get("content-type"), null, "the 416 carries content-range only");
+ }
+ for (const r of ["items=0-1", "bytes=0-1,5-6", "nonsense"]) {
+ const res = rangeResponse(req(r), file);
+ assert.equal(res.status, 200, r);
+ assert.equal(res.headers.get("content-length"), "1000");
+ await res.arrayBuffer();
+ }
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("project ids ride as one url segment and come back exactly", () => {
+ for (const id of ["ferret-rescue", "folder/name", "a b/ç"]) {
+ const seg = encodeProjectSegment(id);
+ assert.match(seg, /^[A-Za-z0-9_-]+$/);
+ assert.equal(decodeProjectSegment(seg), id);
+ }
+ for (const bad of ["", "..", "a/b", "a.b", null, undefined]) assert.equal(decodeProjectSegment(bad), null);
+ assert.equal(
+ deckPreviewSrc("folder/name", "sourced"),
+ `/api/report/chrome/files/${encodeProjectSegment("folder/name")}/sourced/index.html`,
+ );
+ assert.equal(deckPreviewDir("/p", "full"), path.join("/p", "out", "full", "chrome", "deck-preview"));
+});
+
+test("deckPreviewFile: files inside the preview directory, and nothing else", async () => {
+ const root = await mkdtemp(path.join(tmpdir(), "umtool-deckfiles-"));
+ try {
+ const project = path.join(root, "proj");
+ const dir = deckPreviewDir(project, "sourced");
+ await mkdir(path.join(dir, "assets"), { recursive: true });
+ await writeFile(path.join(dir, "index.html"), "<html></html>");
+ await writeFile(path.join(dir, "assets", "gsap.min.js"), "x");
+ // Beside the preview: a build's own project, the manifest, and a secret
+ // outside the project altogether.
+ await mkdir(path.join(project, "out", "sourced", "chrome", "deck"), { recursive: true });
+ await writeFile(path.join(project, "out", "sourced", "chrome", "deck", "index.html"), "build");
+ await writeFile(path.join(project, "video.manifest.json"), "{}");
+ await writeFile(path.join(root, "secret.txt"), "secret");
+ // Links inside the directory: one out, one to a directory out, one in.
+ await symlink(path.join(root, "secret.txt"), path.join(dir, "out-link.txt"));
+ await symlink(root, path.join(dir, "out-dir"));
+ await symlink(path.join(dir, "index.html"), path.join(dir, "assets", "in-link.html"));
+
+ const ok = async (segs) => {
+ const f = await deckPreviewFile(dir, segs);
+ assert.ok(f, JSON.stringify(segs));
+ return f;
+ };
+ const refused = async (segs) => assert.equal(await deckPreviewFile(dir, segs), null, JSON.stringify(segs));
+
+ assert.equal((await ok(["index.html"])).size, "<html></html>".length);
+ await ok(["assets", "gsap.min.js"]);
+ // A link that stays inside is fine; it resolves to the real file.
+ assert.equal((await ok(["assets", "in-link.html"])).abs, path.join(await realDir(dir), "index.html"));
+
+ await refused([]);
+ await refused([".."]);
+ await refused(["..", "deck", "index.html"]);
+ await refused(["assets", "..", "..", "deck", "index.html"]);
+ await refused(["..", "..", "..", "video.manifest.json"]);
+ await refused(["..", "..", "..", "..", "secret.txt"]);
+ await refused(["."]);
+ await refused(["", "index.html"]);
+ // A segment the router decoded from %2F or %5C is still one segment.
+ await refused(["assets/gsap.min.js"]);
+ await refused(["../../../../secret.txt"]);
+ await refused(["assets\\gsap.min.js"]);
+ await refused(["index.html\0.png"]);
+ // Absolute paths, however they arrive.
+ await refused([path.join(root, "secret.txt")]);
+ await refused(["/etc/passwd"]);
+ // Symlinks out of the directory.
+ await refused(["out-link.txt"]);
+ await refused(["out-dir", "secret.txt"]);
+ // A directory is not a file; a missing file is not one either.
+ await refused(["assets"]);
+ await refused(["nope.html"]);
+ await refused("index.html");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("deckPreviewFile: an out/ that is itself a symlink (media on another drive) still serves", async () => {
+ const root = await mkdtemp(path.join(tmpdir(), "umtool-deckfiles-"));
+ try {
+ const real = path.join(root, "elsewhere", "out");
+ await mkdir(path.join(real, "sourced", "chrome", "deck-preview"), { recursive: true });
+ await writeFile(path.join(real, "sourced", "chrome", "deck-preview", "index.html"), "x");
+ const project = path.join(root, "proj");
+ await mkdir(project);
+ await symlink(real, path.join(project, "out"));
+ const f = await deckPreviewFile(deckPreviewDir(project, "sourced"), ["index.html"]);
+ assert.ok(f);
+ assert.equal(await deckPreviewFile(deckPreviewDir(project, "sourced"), ["..", "..", "..", "..", "proj"]), null);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+async function realDir(p) {
+ const { realpath } = await import("node:fs/promises");
+ return realpath(p);
+}
diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts
@@ -79,6 +79,11 @@ export default defineConfig({
// Stub binaries, so a build spec is offline and deterministic. The
// pipeline already reads both as overrides; the fixture writes them.
`YTDLP_BIN=${FIXTURE}/bin/yt-dlp QRENCODE_BIN=${FIXTURE}/bin/qrencode ` +
+ // The on-screen deck's renderer, stubbed the same way: it writes the
+ // frame sequence the build checks for, so a deck build is offline and
+ // takes a second (onscreen.spec.ts). Its review stills use the system
+ // chromium (CHROME, default /usr/bin/chromium), which is not stubbed.
+ `HYPERFRAMES_BIN=${FIXTURE}/bin/hyperframes ` +
// Where a clip window is fetched FROM, and the shared secret it is asked
// with. Set here rather than in a step's env: jobView() echoes a step's
// env back to the browser, and this is a token.
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -202,6 +202,57 @@ window:
handles the reverse case — a clip whose lead-in would drag in seconds of some
*other* audio (a news package playing before the speaker starts).
+### `render.chrome` — the on-screen deck's settings, and per-entry `onscreen`
+
+Opt-in (`render.chrome.engine: "hyperframes"`, `layout: "deck"`) — see
+[The on-screen deck](#renderchrome--the-on-screen-deck) below for what it draws
+and how a build renders it. Every key is optional; `resolveDeck()` fills
+whatever a manifest omits, one level deep:
+
+```jsonc
+"render": {
+ "chrome": {
+ "engine": "hyperframes", "layout": "deck",
+ "deck": {
+ "height": 190, // px, whole number, 120–400
+ "footageScale": 0.82, // 0.5–1; the footage must still fit above the deck
+ "background": "panel", // "panel" (lifted) | "flush"
+ "pip": { "spacing": "even", "size": 6, "activeSize": 12 }, // spacing "even" | "time"; activeSize ≥ size
+ "title": { "size": 54, "maxChars": 48 }, // size 24–96; maxChars 10–120 (the editor's counter, not a refusal)
+ "subtitle": { "parts": "auto", "dateFormat": "long" }, // parts "auto" | a distinct list of channel/title/date/clock; dateFormat "long" | "iso"
+ "qr": { "show": true, "size": 150 }, // size 80–380, and at most height − 20
+ "overCards": "hide", // "hide" | "show"
+ "motion": { "out": 0.3, "in": 0.45, "pip": 0.7 } // seconds; out/in 0–2, pip 0–3
+ }
+ }
+}
+```
+
+`validateChrome()` (`deck.mjs`) is the ONE validator — umtool's writer refuses
+with it and the build refuses with it before a single fetch, so a manifest
+umtool accepted is one the build accepts. It refuses an **unknown** key rather
+than ignoring it (`footageScle` silently meaning the default is the bug this
+catches), and refuses `render.chrome` beside `render.rail` or the legacy
+`render.chromeEngine` — the deck replaces both, it does not layer onto them.
+
+Any `clip`, `image` or `card` entry may carry `onscreen`:
+
+```jsonc
+{ "type": "clip", "id": "c04", …,
+ "onscreen": { "title": "County approves pre-application",
+ "subtitle": "…optional override…" } }
+```
+
+Nested — `onscreen.title`, not `title` — because `entry.title` already means
+the *stream's* title in the header and the chapters. One line each, ≤ 200
+characters, trimmed; an empty value deletes the key, the way the attribution
+fields do. Without it the deck's title is empty (a card falls back to
+`heading`) and the subtitle is auto-built: a clip's channel · title · date (the
+channel only when the cut spans more than one), an image's `title · date`, a
+card's `sub`. The QR follows a clip's corner-QR rule unchanged (`citeUrl`, else
+the site link at the clip's start); an image draws one only with an explicit
+`citeUrl`; a card never does.
+
### The `image` entry type
A still: the receipts a clip cannot say out loud — a post, a thread, a DM, a
@@ -288,6 +339,10 @@ uses none of them. `render` holds resolution, fps, fonts, palette and the knobs
sweep's scope and counts. Two further entry `type`s, `scroll` and `chart`, close a
cut off a top-level `ledger[]` — see [The claim rail](#the-claim-rail-renderrail).
+**None of the header, footer, marker or corner QR described above is drawn
+under `render.chrome`'s deck layout.** The deck replaces all of it with one
+persistent panel — see [The on-screen deck](#renderchrome--the-on-screen-deck).
+
## Chrome, not cards
**The ferret-rescue cut has no cards at all** — no title, no chapter breaks, no
@@ -797,6 +852,145 @@ total's `#EDF0EC` fails the categorical lightness and chroma checks *by design*
it is an aggregate, not a categorical peer, so it is encoded by weight and
consumes no palette slot.
+## `render.chrome` — the on-screen deck
+
+Opt-in (`render.chrome = { engine: "hyperframes", layout: "deck", deck: {...} }`,
+every key in [Manifest shape](#renderchrome--the-on-screen-decks-settings-and-per-entry-onscreen));
+absent, nothing here runs and the ffmpeg chrome path — header, footer, corner
+QR — is byte-for-byte unchanged. It **replaces** that chrome rather than
+joining it: a deck manifest draws none of the citation header, the footer's
+node track or the per-clip corner QR, because the deck carries all three
+itself, and it refuses a `render.rail` or the legacy `render.chromeEngine`
+beside it.
+
+A persistent bottom panel, rendered as ONE HyperFrames composition over the
+WHOLE concat — a pip timeline (one subtle, unlabelled pip per segment the deck
+is shown over), a big authored title per clip, an automatic source-and-date
+subtitle, and the clip's QR — while the footage is scaled into a box above it
+(`footageScale`, default 0.82: 1574×886 at (173, 2) for a 1920×1080 frame with
+the default 190 px deck). At every clip boundary the pip travels and the
+title, subtitle and QR hand over to the next clip's, centred on the cut's own
+mid-dissolve; a card plays under the deck when `overCards: "show"`, or the
+deck slides away for its duration when it is `"hide"` (the default) — no text
+handover happens across a hidden segment, because there is nothing on screen
+to animate.
+
+`out/<variant>/schedule.json` (`deckSchedule()`) is the one source of *when*:
+the build writes it from PROBED segment durations and real source metadata;
+`estimateSchedule()` produces the same shape (`estimated: true`) from the
+manifest alone, for a preview that runs before anything has been built.
+`deckChoreography()` turns a schedule into the handover times the
+composition, the preview and a reviewer all read — there is no second
+implementation of the arithmetic to drift.
+
+### Rendering it
+
+`compose-chrome.mjs --region deck` is `--region chart`'s sibling: the same
+CLI, the same render cache, the same vendored GSAP, a different composition
+(`chrome-deck.mjs` instead of the chart band's SVG).
+
+```
+node umtool/report-to-video/compose-chrome.mjs <manifest.json> [--region chart|deck] [--variant sourced|full]
+ [--from <s>] [--duration <s>] [--out <dir>] [--preview]
+ [--still <s> --png <path>]
+ [--render] [--workers 4] [--quality high] [--format png-sequence] [--fps 30]
+```
+
+- **`--preview`** composes into `chrome/deck-preview/` and never renders —
+ umtool's live settings preview, which must never disturb a build's own
+ project directory or its frame cache.
+- **`--still <s> --png <path>`** is the review still: a headless Chromium
+ screenshot of `index.html?still=<s>`, the SAME seek the renderer performs for
+ every frame — about 2 s instead of a render.
+- **`--render`** writes `chrome/deck-frames/frame_%06d.png` plus a `.key` file
+ holding `chromeCacheKey({html, assets, fps, frames, version})`. A render is
+ SKIPPED when the key on disk matches and the frame count on disk equals
+ `frameCount(total, fps)`: a full ~6-minute deck is ~10,800 frames, about
+ 7–8 minutes with the default 4 workers and ~400 MB of PNGs, and an
+ unchanged re-run is about 1 s. The renderer is pinned (`HYPERFRAMES_PKG`,
+ default `hyperframes@0.8.24`; `HYPERFRAMES_BIN` overrides it for a stub, an
+ e2e fixture's), and the version is part of the key — a new renderer is new
+ frames.
+- **`--from <s>` / `--duration <s>`** window a compose to less than the whole
+ cut, used by `--chrome-preview` below. A windowed project and its frames get
+ their own names, `deck-from<s>[-frames]`, so a window never overwrites the
+ full-cut sequence.
+
+`build-video.mjs` drives all of it as one command — schedule, compose, render
+(cached), overlay — with three flags beyond the ones already
+[listed](#driven-from-umtool):
+
+- **`--chrome-only`** — re-lay the deck over the segments already on disk:
+ re-probe, rewrite the schedule, recompose (re-render only if the key
+ changed), re-concat with the overlay, re-mux the chapters. **No segment is
+ rebuilt and nothing is fetched.** The driver labels this "re-render
+ on-screen".
+- **`--no-chrome`** — the deck's framing with no overlay: a fast picture check
+ of the letterboxing, with nothing composed or rendered.
+- **`--chrome-preview <at> <dur>`** — renders only that window of the deck and
+ writes `out/<variant>/<slug>.preview.mp4` of it, from a cached concat when
+ there is one, else built straight from the segments the window touches.
+
+`assertChrome(render.chrome, render)` runs at the top of every build whenever
+`render.chrome` is set at all — not only when the deck is on — so a block that
+is present but malformed (a bad `engine` or `layout`, as well as a bad `deck`)
+is refused before a single fetch, the same guarantee a deck build gets.
+
+**`transition: 0` (hard cuts) now works through the deck.** A hard-cut concat
+to `<slug>.prerail-hardcut.mp4`, then ONE overlay re-encode (`applyChrome`,
+ported from the diet fork) lays the panel on afterwards, because the concat
+demuxer's stream copy cannot host a filtergraph. The legacy chart band still
+refuses a hard-cut transition outright.
+
+### Two ffmpeg traps that are the deck's alone
+
+- **Mixed RGB/RGBA frames restart the whole filtergraph.** HyperFrames writes
+ a frame with nothing transparent in it as opaque RGB and every other frame
+ as RGBA; ffmpeg's default response to the decoded stream switching kind
+ mid-sequence is to REINITIALISE the filtergraph, which drops whatever was
+ buffered and ends the output early — measured, a 3.5 s xfade+overlay came
+ out at 2.0 s. The deck's input alone gets `-reinit_filter 0` plus
+ `format=rgba` straight after it; the chart band's chain and inputs are
+ untouched.
+- **A relative `--out` broke every hard-cut concat.** The concat demuxer
+ resolves a list entry against the LIST FILE's own directory, not the cwd, so
+ a relative output root named every segment twice over and the first input
+ failed to open. `concatHardCut` now writes every list entry as an absolute
+ path — unchanged for a build that already worked, and not only the deck's.
+
+Both are in [quirks.md](../docs/quirks.md)'s running list.
+
+### Fonts
+
+Copied into the composition's `assets/` as private families `DeckSans` /
+`DeckSansBold` (from `render.fontRegular` / `render.fontBold`), with
+`sans-serif` as the only fallback — the same trap the chart band's `'Band'`
+dodges (a bare `local()` resolves on a desktop browser and silently fails in
+the render browser, shifting every metric). The deck is **strict** where the
+band is lenient, though: a face that cannot be copied is a build error, not a
+quiet fallback, because the deck has no other text on screen to notice a
+substitution happened.
+
+### `verify-build.mjs`'s deck checks, and their limit
+
+Confirms `schedule.json` is a *measured* (not estimated) deck schedule, that
+`chrome/deck-frames` holds exactly `frameCount(schedule.total, fps)` frames,
+and that the finished file's video stream is that many frames long — laid
+with `shortest=1`, so a short sequence shortens the cut silently rather than
+erroring. It **cannot tell whether the overlay was actually laid onto the
+concat**: a `--no-chrome` final passes as long as old frames happen to be on
+disk from an earlier render. `--chrome-only` and `--rail-only` both write over
+the final file while it is still encoding, the way they always have, so a
+check that races one of them is checking a half-written file.
+
+### Driven from umtool
+
+Writers (`updateClip`'s `onscreen`, `updateOnscreen`, `updateChrome`), routes
+(`/api/report/{chrome,onscreen,chrome/preview,chrome/files,still,video}`) and
+the UI (the "On-screen" section, never "deck" — that name is already the song
+kind's) are [report-video.md](../docs/report-video.md#the-on-screen-deck-renderchrome)'s
+to describe; this file stays the pipeline's own contract.
+
## The Archilyzer Media preset (`render.brand`)
The channel's own videos wear its brand; a report cut for anybody else does not.
@@ -980,13 +1174,18 @@ usual reason a naive concat produces a broken or audio-desynced file.
a speaker who does not pause gets the unsnapped cut. Forced alignment against
the transcript would be exact; `silencedetect` is a tenth of the work and
handles the cases that were actually audible.
-- **Only the chart band is a HyperFrames region.** `chromeRegions()` returns one
- entry. The rail and the header are still drawn by the ffmpeg chain, and porting
- them is the rest of the job — the rail needs the ghost/hop convention (a row
- with no clip fades in dimmed under a dashed rule and the amber highlight *hops
- over* it to the next cited row) which the strip builders cannot express. Until
- then `railFilterChain` and `renderFooterAssets` stay; they must not be retired
- on the strength of the band alone.
+- **`chromeRegions()` returns one entry, never two.** The chart band and the
+ deck are each a single HyperFrames region, and a manifest gets at most one of
+ them (`render.chrome` and the legacy `render.chromeEngine` refuse each
+ other). The deck excludes a rail outright (`validateChrome` refuses
+ `render.chrome` beside `render.rail`) and replaces the header and the
+ footer's node track with its own panel; for every manifest that is NOT a
+ deck, the rail and the header are still drawn by the ffmpeg chain, and
+ porting them into a region of their own is the rest of that job — the rail needs the ghost/hop convention (a row with no clip fades
+ in dimmed under a dashed rule and the amber highlight *hops over* it to the
+ next cited row) which the strip builders cannot express. Until then
+ `railFilterChain` and `renderFooterAssets` stay; they must not be retired on
+ the strength of either region alone.
- **`timelineNodes` / `section` / `sectionEnter` are still live.** They lose their
only consumer when the ffmpeg footer goes, not when the band arrives — so they
retire with `renderFooterAssets`, in that same commit.
diff --git a/umtool/report-to-video/assets/gsap.min.js b/umtool/report-to-video/assets/gsap.min.js
@@ -0,0 +1,11 @@
+/*!
+ * GSAP 3.14.2
+ * https://gsap.com
+ *
+ * @license Copyright 2025, GreenSock. All rights reserved.
+ * Subject to the terms at https://gsap.com/standard-license.
+ * @author: Jack Doyle, jack@greensock.com
+ */
+
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).window=t.window||{})}(this,function(e){"use strict";function _inheritsLoose(t,e){t.prototype=Object.create(e.prototype),(t.prototype.constructor=t).__proto__=e}function _assertThisInitialized(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}function r(t){return"string"==typeof t}function s(t){return"function"==typeof t}function t(t){return"number"==typeof t}function u(t){return void 0===t}function v(t){return"object"==typeof t}function w(t){return!1!==t}function x(){return"undefined"!=typeof window}function y(t){return s(t)||r(t)}function R(t){return(i=bt(t,ht))&&Fe}function S(t,e){return console.warn("Invalid property",t,"set to",e,"Missing plugin? gsap.registerPlugin()")}function T(t,e){return!e&&console.warn(t)}function U(t,e){return t&&(ht[t]=e)&&i&&(i[t]=e)||ht}function V(){return 0}function ga(t){var e,r,i=t[0];if(v(i)||s(i)||(t=[t]),!(e=(i._gsap||{}).harness)){for(r=yt.length;r--&&!yt[r].targetTest(i););e=yt[r]}for(r=t.length;r--;)t[r]&&(t[r]._gsap||(t[r]._gsap=new Xt(t[r],e)))||t.splice(r,1);return t}function ha(t){return t._gsap||ga(Pt(t))[0]._gsap}function ia(t,e,r){return(r=t[e])&&s(r)?t[e]():u(r)&&t.getAttribute&&t.getAttribute(e)||r}function ja(t,e){return(t=t.split(",")).forEach(e)||t}function ka(t){return Math.round(1e5*t)/1e5||0}function la(t){return Math.round(1e7*t)/1e7||0}function ma(t,e){var r=e.charAt(0),i=parseFloat(e.substr(2));return t=parseFloat(t),"+"===r?t+i:"-"===r?t-i:"*"===r?t*i:t/i}function na(t,e){for(var r=e.length,i=0;t.indexOf(e[i])<0&&++i<r;);return i<r}function oa(){var t,e,r=pt.length,i=pt.slice(0);for(_t={},t=pt.length=0;t<r;t++)(e=i[t])&&e._lazy&&(e.render(e._lazy[0],e._lazy[1],!0)._lazy=0)}function pa(t){return!!(t._initted||t._startAt||t.add)}function qa(t,e,r,i){pt.length&&!I&&oa(),t.render(e,r,i||!!(I&&e<0&&pa(t))),pt.length&&!I&&oa()}function ra(t){var e=parseFloat(t);return(e||0===e)&&(t+"").match(ot).length<2?e:r(t)?t.trim():t}function sa(t){return t}function ta(t,e){for(var r in e)r in t||(t[r]=e[r]);return t}function wa(t,e){for(var r in e)"__proto__"!==r&&"constructor"!==r&&"prototype"!==r&&(t[r]=v(e[r])?wa(t[r]||(t[r]={}),e[r]):e[r]);return t}function xa(t,e){var r,i={};for(r in t)r in e||(i[r]=t[r]);return i}function ya(t){var e=t.parent||L,r=t.keyframes?function _setKeyframeDefaults(i){return function(t,e){for(var r in e)r in t||"duration"===r&&i||"ease"===r||(t[r]=e[r])}}($(t.keyframes)):ta;if(w(t.inherit))for(;e;)r(t,e.vars.defaults),e=e.parent||e._dp;return t}function Aa(t,e,r,i,n){void 0===r&&(r="_first"),void 0===i&&(i="_last");var a,s=t[i];if(n)for(a=e[n];s&&s[n]>a;)s=s._prev;return s?(e._next=s._next,s._next=e):(e._next=t[r],t[r]=e),e._next?e._next._prev=e:t[i]=e,e._prev=s,e.parent=e._dp=t,e}function Ba(t,e,r,i){void 0===r&&(r="_first"),void 0===i&&(i="_last");var n=e._prev,a=e._next;n?n._next=a:t[r]===e&&(t[r]=a),a?a._prev=n:t[i]===e&&(t[i]=n),e._next=e._prev=e.parent=null}function Ca(t,e){t.parent&&(!e||t.parent.autoRemoveChildren)&&t.parent.remove&&t.parent.remove(t),t._act=0}function Da(t,e){if(t&&(!e||e._end>t._dur||e._start<0))for(var r=t;r;)r._dirty=1,r=r.parent;return t}function Fa(t,e,r,i){return t._startAt&&(I?t._startAt.revert(ft):t.vars.immediateRender&&!t.vars.autoRevert||t._startAt.render(e,!0,i))}function Ha(t){return t._repeat?wt(t._tTime,t=t.duration()+t._rDelay)*t:0}function Ja(t,e){return(t-e._start)*e._ts+(0<=e._ts?0:e._dirty?e.totalDuration():e._tDur)}function Ka(t){return t._end=la(t._start+(t._tDur/Math.abs(t._ts||t._rts||q)||0))}function La(t,e){var r=t._dp;return r&&r.smoothChildTiming&&t._ts&&(t._start=la(r._time-(0<t._ts?e/t._ts:((t._dirty?t.totalDuration():t._tDur)-e)/-t._ts)),Ka(t),r._dirty||Da(r,t)),t}function Ma(t,e){var r;if((e._time||!e._dur&&e._initted||e._start<t._time&&(e._dur||!e.add))&&(r=Ja(t.rawTime(),e),(!e._dur||Mt(0,e.totalDuration(),r)-e._tTime>q)&&e.render(r,!0)),Da(t,e)._dp&&t._initted&&t._time>=t._dur&&t._ts){if(t._dur<t.duration())for(r=t;r._dp;)0<=r.rawTime()&&r.totalTime(r._tTime),r=r._dp;t._zTime=-q}}function Na(e,r,i,n){return r.parent&&Ca(r),r._start=la((t(i)?i:i||e!==L?Ot(e,i,r):e._time)+r._delay),r._end=la(r._start+(r.totalDuration()/Math.abs(r.timeScale())||0)),Aa(e,r,"_first","_last",e._sort?"_start":0),xt(r)||(e._recent=r),n||Ma(e,r),e._ts<0&&La(e,e._tTime),e}function Oa(t,e){return(ht.ScrollTrigger||S("scrollTrigger",e))&&ht.ScrollTrigger.create(e,t)}function Pa(t,e,r,i,n){return Qt(t,e,n),t._initted?!r&&t._pt&&!I&&(t._dur&&!1!==t.vars.lazy||!t._dur&&t.vars.lazy)&&f!==It.frame?(pt.push(t),t._lazy=[n,i],1):void 0:1}function Ua(t,e,r,i){var n=t._repeat,a=la(e)||0,s=t._tTime/t._tDur;return s&&!i&&(t._time*=a/t._dur),t._dur=a,t._tDur=n?n<0?1e10:la(a*(n+1)+t._rDelay*n):a,0<s&&!i&&La(t,t._tTime=t._tDur*s),t.parent&&Ka(t),r||Da(t.parent,t),t}function Va(t){return t instanceof Zt?Da(t):Ua(t,t._dur)}function Ya(e,r,i){var n,a,s=t(r[1]),o=(s?2:1)+(e<2?0:1),u=r[o];if(s&&(u.duration=r[1]),u.parent=i,e){for(n=u,a=i;a&&!("immediateRender"in n);)n=a.vars.defaults||{},a=w(a.vars.inherit)&&a.parent;u.immediateRender=w(n.immediateRender),e<2?u.runBackwards=1:u.startAt=r[o-1]}return new te(r[0],u,r[1+o])}function Za(t,e){return t||0===t?e(t):e}function _a(t,e){return r(t)&&(e=ut.exec(t))?e[1]:""}function cb(t,e){return t&&v(t)&&"length"in t&&(!e&&!t.length||t.length-1 in t&&v(t[0]))&&!t.nodeType&&t!==h}function fb(r){return r=Pt(r)[0]||T("Invalid scope")||{},function(t){var e=r.current||r.nativeElement||r;return Pt(t,e.querySelectorAll?e:e===r?T("Invalid scope")||a.createElement("div"):r)}}function gb(t){return t.sort(function(){return.5-Math.random()})}function hb(t){if(s(t))return t;var p=v(t)?t:{each:t},_=Vt(p.ease),m=p.from||0,g=parseFloat(p.base)||0,y={},e=0<m&&m<1,T=isNaN(m)||e,b=p.axis,w=m,x=m;return r(m)?w=x={center:.5,edges:.5,end:1}[m]||0:!e&&T&&(w=m[0],x=m[1]),function(t,e,r){var i,n,a,s,o,u,h,l,f,d=(r||p).length,c=y[d];if(!c){if(!(f="auto"===p.grid?0:(p.grid||[1,X])[1])){for(h=-X;h<(h=r[f++].getBoundingClientRect().left)&&f<d;);f<d&&f--}for(c=y[d]=[],i=T?Math.min(f,d)*w-.5:m%f,n=f===X?0:T?d*x/f-.5:m/f|0,l=X,u=h=0;u<d;u++)a=u%f-i,s=n-(u/f|0),c[u]=o=b?Math.abs("y"===b?s:a):J(a*a+s*s),h<o&&(h=o),o<l&&(l=o);"random"===m&&gb(c),c.max=h-l,c.min=l,c.v=d=(parseFloat(p.amount)||parseFloat(p.each)*(d<f?d-1:b?"y"===b?d/f:f:Math.max(f,d/f))||0)*("edges"===m?-1:1),c.b=d<0?g-d:g,c.u=_a(p.amount||p.each)||0,_=_&&d<0?jt(_):_}return d=(c[t]-c.min)/c.max||0,la(c.b+(_?_(d):d)*c.v)+c.u}}function ib(i){var n=Math.pow(10,((i+"").split(".")[1]||"").length);return function(e){var r=la(Math.round(parseFloat(e)/i)*i*n);return(r-r%1)/n+(t(e)?0:_a(e))}}function jb(h,e){var l,f,r=$(h);return!r&&v(h)&&(l=r=h.radius||X,h.values?(h=Pt(h.values),(f=!t(h[0]))&&(l*=l)):h=ib(h.increment)),Za(e,r?s(h)?function(t){return f=h(t),Math.abs(f-t)<=l?f:t}:function(e){for(var r,i,n=parseFloat(f?e.x:e),a=parseFloat(f?e.y:0),s=X,o=0,u=h.length;u--;)(r=f?(r=h[u].x-n)*r+(i=h[u].y-a)*i:Math.abs(h[u]-n))<s&&(s=r,o=u);return o=!l||s<=l?h[o]:e,f||o===e||t(e)?o:o+_a(e)}:ib(h))}function kb(t,e,r,i){return Za($(t)?!e:!0===r?!!(r=0):!i,function(){return $(t)?t[~~(Math.random()*t.length)]:(r=r||1e-5)&&(i=r<1?Math.pow(10,(r+"").length-2):1)&&Math.floor(Math.round((t-r/2+Math.random()*(e-t+.99*r))/r)*r*i)/i})}function ob(e,r,t){return Za(t,function(t){return e[~~r(t)]})}function rb(t){return t.replace(tt,function(t){var e=t.indexOf("[")+1,r=t.substring(e||7,e?t.indexOf("]"):t.length-1).split(et);return kb(e?r:+r[0],e?0:+r[1],+r[2]||1e-5)})}function ub(t,e,r){var i,n,a,s=t.labels,o=X;for(i in s)(n=s[i]-e)<0==!!r&&n&&o>(n=Math.abs(n))&&(a=i,o=n);return a}function wb(t){return Ca(t),t.scrollTrigger&&t.scrollTrigger.kill(!!I),t.progress()<1&&Dt(t,"onInterrupt"),t}function zb(t){if(t)if(t=!t.name&&t.default||t,x()||t.headless){var e=t.name,r=s(t),i=e&&!r&&t.init?function(){this._props=[]}:t,n={init:V,render:ve,add:Jt,kill:Te,modifier:ye,rawVars:0},a={targetTest:0,get:0,getSetter:le,aliases:{},register:0};if(Lt(),t!==i){if(mt[e])return;ta(i,ta(xa(t,n),a)),bt(i.prototype,bt(n,xa(t,a))),mt[i.prop=e]=i,t.targetTest&&(yt.push(i),ct[e]=1),e=("css"===e?"CSS":e.charAt(0).toUpperCase()+e.substr(1))+"Plugin"}U(e,i),t.register&&t.register(Fe,i,we)}else St.push(t)}function Cb(t,e,r){return(6*(t+=t<0?1:1<t?-1:0)<1?e+(r-e)*t*6:t<.5?r:3*t<2?e+(r-e)*(2/3-t)*6:e)*zt+.5|0}function Db(e,r,i){var n,a,s,o,u,h,l,f,d,c,p=e?t(e)?[e>>16,e>>8&zt,e&zt]:0:Et.black;if(!p){if(","===e.substr(-1)&&(e=e.substr(0,e.length-1)),Et[e])p=Et[e];else if("#"===e.charAt(0)){if(e.length<6&&(e="#"+(n=e.charAt(1))+n+(a=e.charAt(2))+a+(s=e.charAt(3))+s+(5===e.length?e.charAt(4)+e.charAt(4):"")),9===e.length)return[(p=parseInt(e.substr(1,6),16))>>16,p>>8&zt,p&zt,parseInt(e.substr(7),16)/255];p=[(e=parseInt(e.substr(1),16))>>16,e>>8&zt,e&zt]}else if("hsl"===e.substr(0,3))if(p=c=e.match(rt),r){if(~e.indexOf("="))return p=e.match(it),i&&p.length<4&&(p[3]=1),p}else o=+p[0]%360/360,u=p[1]/100,n=2*(h=p[2]/100)-(a=h<=.5?h*(u+1):h+u-h*u),3<p.length&&(p[3]*=1),p[0]=Cb(o+1/3,n,a),p[1]=Cb(o,n,a),p[2]=Cb(o-1/3,n,a);else p=e.match(rt)||Et.transparent;p=p.map(Number)}return r&&!c&&(n=p[0]/zt,a=p[1]/zt,s=p[2]/zt,h=((l=Math.max(n,a,s))+(f=Math.min(n,a,s)))/2,l===f?o=u=0:(d=l-f,u=.5<h?d/(2-l-f):d/(l+f),o=l===n?(a-s)/d+(a<s?6:0):l===a?(s-n)/d+2:(n-a)/d+4,o*=60),p[0]=~~(o+.5),p[1]=~~(100*u+.5),p[2]=~~(100*h+.5)),i&&p.length<4&&(p[3]=1),p}function Eb(t){var r=[],i=[],n=-1;return t.split(Rt).forEach(function(t){var e=t.match(nt)||[];r.push.apply(r,e),i.push(n+=e.length+1)}),r.c=i,r}function Fb(t,e,r){var i,n,a,s,o="",u=(t+o).match(Rt),h=e?"hsla(":"rgba(",l=0;if(!u)return t;if(u=u.map(function(t){return(t=Db(t,e,1))&&h+(e?t[0]+","+t[1]+"%,"+t[2]+"%,"+t[3]:t.join(","))+")"}),r&&(a=Eb(t),(i=r.c).join(o)!==a.c.join(o)))for(s=(n=t.replace(Rt,"1").split(nt)).length-1;l<s;l++)o+=n[l]+(~i.indexOf(l)?u.shift()||h+"0,0,0,0)":(a.length?a:u.length?u:r).shift());if(!n)for(s=(n=t.split(Rt)).length-1;l<s;l++)o+=n[l]+u[l];return o+n[s]}function Ib(t){var e,r=t.join(" ");if(Rt.lastIndex=0,Rt.test(r))return e=Ft.test(r),t[1]=Fb(t[1],e),t[0]=Fb(t[0],e,Eb(t[1])),!0}function Rb(t){var e=(t+"").split("("),r=Bt[e[0]];return r&&1<e.length&&r.config?r.config.apply(null,~t.indexOf("{")?[function _parseObjectInString(t){for(var e,r,i,n={},a=t.substr(1,t.length-3).split(":"),s=a[0],o=1,u=a.length;o<u;o++)r=a[o],e=o!==u-1?r.lastIndexOf(","):r.length,i=r.substr(0,e),n[s]=isNaN(i)?i.replace(Nt,"").trim():+i,s=r.substr(e+1).trim();return n}(e[1])]:function _valueInParentheses(t){var e=t.indexOf("(")+1,r=t.indexOf(")"),i=t.indexOf("(",e);return t.substring(e,~i&&i<r?t.indexOf(")",r+1):r)}(t).split(",").map(ra)):Bt._CE&&Yt.test(t)?Bt._CE("",t):r}function Tb(t,e){for(var r,i=t._first;i;)i instanceof Zt?Tb(i,e):!i.vars.yoyoEase||i._yoyo&&i._repeat||i._yoyo===e||(i.timeline?Tb(i.timeline,e):(r=i._ease,i._ease=i._yEase,i._yEase=r,i._yoyo=e)),i=i._next}function Vb(t,e,r,i){void 0===r&&(r=function easeOut(t){return 1-e(1-t)}),void 0===i&&(i=function easeInOut(t){return t<.5?e(2*t)/2:1-e(2*(1-t))/2});var n,a={easeIn:e,easeOut:r,easeInOut:i};return ja(t,function(t){for(var e in Bt[t]=ht[t]=a,Bt[n=t.toLowerCase()]=r,a)Bt[n+("easeIn"===e?".in":"easeOut"===e?".out":".inOut")]=Bt[t+"."+e]=a[e]}),a}function Wb(e){return function(t){return t<.5?(1-e(1-2*t))/2:.5+e(2*(t-.5))/2}}function Xb(r,t,e){function Lm(t){return 1===t?1:i*Math.pow(2,-10*t)*G((t-a)*n)+1}var i=1<=t?t:1,n=(e||(r?.3:.45))/(t<1?t:1),a=n/Z*(Math.asin(1/i)||0),s="out"===r?Lm:"in"===r?function(t){return 1-Lm(1-t)}:Wb(Lm);return n=Z/n,s.config=function(t,e){return Xb(r,t,e)},s}function Yb(e,r){function Tm(t){return t?--t*t*((r+1)*t+r)+1:0}void 0===r&&(r=1.70158);var t="out"===e?Tm:"in"===e?function(t){return 1-Tm(1-t)}:Wb(Tm);return t.config=function(t){return Yb(e,t)},t}var F,I,l,L,h,n,a,i,o,f,d,c,p,_,m,g,b,k,O,M,C,P,A,D,z,E,B,Y,N={autoSleep:120,force3D:"auto",nullTargetWarn:1,units:{lineHeight:""}},j={duration:.5,overwrite:!1,delay:0},X=1e8,q=1/X,Z=2*Math.PI,W=Z/4,H=0,J=Math.sqrt,Q=Math.cos,G=Math.sin,K="function"==typeof ArrayBuffer&&ArrayBuffer.isView||function(){},$=Array.isArray,tt=/random\([^)]+\)/g,et=/,\s*/g,rt=/(?:-?\.?\d|\.)+/gi,it=/[-+=.]*\d+[.e\-+]*\d*[e\-+]*\d*/g,nt=/[-+=.]*\d+[.e-]*\d*[a-z%]*/g,at=/[-+=.]*\d+\.?\d*(?:e-|e\+)?\d*/gi,st=/[+-]=-?[.\d]+/,ot=/[^,'"\[\]\s]+/gi,ut=/^[+\-=e\s\d]*\d+[.\d]*([a-z]*|%)\s*$/i,ht={},lt={suppressEvents:!0,isStart:!0,kill:!1},ft={suppressEvents:!0,kill:!1},dt={suppressEvents:!0},ct={},pt=[],_t={},mt={},gt={},vt=30,yt=[],Tt="",bt=function _merge(t,e){for(var r in e)t[r]=e[r];return t},wt=function _animationCycle(t,e){var r=Math.floor(t=la(t/e));return t&&r===t?r-1:r},xt=function _isFromOrFromStart(t){var e=t.data;return"isFromStart"===e||"isStart"===e},kt={_start:0,endTime:V,totalDuration:V},Ot=function _parsePosition(t,e,i){var n,a,s,o=t.labels,u=t._recent||kt,h=t.duration()>=X?u.endTime(!1):t._dur;return r(e)&&(isNaN(e)||e in o)?(a=e.charAt(0),s="%"===e.substr(-1),n=e.indexOf("="),"<"===a||">"===a?(0<=n&&(e=e.replace(/=/,"")),("<"===a?u._start:u.endTime(0<=u._repeat))+(parseFloat(e.substr(1))||0)*(s?(n<0?u:i).totalDuration()/100:1)):n<0?(e in o||(o[e]=h),o[e]):(a=parseFloat(e.charAt(n-1)+e.substr(n+1)),s&&i&&(a=a/100*($(i)?i[0]:i).totalDuration()),1<n?_parsePosition(t,e.substr(0,n-1),i)+a:h+a)):null==e?h:+e},Mt=function _clamp(t,e,r){return r<t?t:e<r?e:r},Ct=[].slice,Pt=function toArray(t,e,i){return l&&!e&&l.selector?l.selector(t):!r(t)||i||!n&&Lt()?$(t)?function _flatten(t,e,i){return void 0===i&&(i=[]),t.forEach(function(t){return r(t)&&!e||cb(t,1)?i.push.apply(i,Pt(t)):i.push(t)})||i}(t,i):cb(t)?Ct.call(t,0):t?[t]:[]:Ct.call((e||a).querySelectorAll(t),0)},At=function mapRange(e,t,r,i,n){var a=t-e,s=i-r;return Za(n,function(t){return r+((t-e)/a*s||0)})},Dt=function _callback(t,e,r){var i,n,a,s=t.vars,o=s[e],u=l,h=t._ctx;if(o)return i=s[e+"Params"],n=s.callbackScope||t,r&&pt.length&&oa(),h&&(l=h),a=i?o.apply(n,i):o.call(n),l=u,a},St=[],zt=255,Et={aqua:[0,zt,zt],lime:[0,zt,0],silver:[192,192,192],black:[0,0,0],maroon:[128,0,0],teal:[0,128,128],blue:[0,0,zt],navy:[0,0,128],white:[zt,zt,zt],olive:[128,128,0],yellow:[zt,zt,0],orange:[zt,165,0],gray:[128,128,128],purple:[128,0,128],green:[0,128,0],red:[zt,0,0],pink:[zt,192,203],cyan:[0,zt,zt],transparent:[zt,zt,zt,0]},Rt=function(){var t,e="(?:\\b(?:(?:rgb|rgba|hsl|hsla)\\(.+?\\))|\\B#(?:[0-9a-f]{3,4}){1,2}\\b";for(t in Et)e+="|"+t+"\\b";return new RegExp(e+")","gi")}(),Ft=/hsl[a]?\(/,It=(O=Date.now,M=500,C=33,P=O(),A=P,z=D=1e3/240,g={time:0,frame:0,tick:function tick(){Al(!0)},deltaRatio:function deltaRatio(t){return b/(1e3/(t||60))},wake:function wake(){o&&(!n&&x()&&(h=n=window,a=h.document||{},ht.gsap=Fe,(h.gsapVersions||(h.gsapVersions=[])).push(Fe.version),R(i||h.GreenSockGlobals||!h.gsap&&h||{}),St.forEach(zb)),m="undefined"!=typeof requestAnimationFrame&&requestAnimationFrame,p&&g.sleep(),_=m||function(t){return setTimeout(t,z-1e3*g.time+1|0)},c=1,Al(2))},sleep:function sleep(){(m?cancelAnimationFrame:clearTimeout)(p),c=0,_=V},lagSmoothing:function lagSmoothing(t,e){M=t||1/0,C=Math.min(e||33,M)},fps:function fps(t){D=1e3/(t||240),z=1e3*g.time+D},add:function add(n,t,e){var a=t?function(t,e,r,i){n(t,e,r,i),g.remove(a)}:n;return g.remove(n),E[e?"unshift":"push"](a),Lt(),a},remove:function remove(t,e){~(e=E.indexOf(t))&&E.splice(e,1)&&e<=k&&k--},_listeners:E=[]}),Lt=function _wake(){return!c&&It.wake()},Bt={},Yt=/^[\d.\-M][\d.\-,\s]/,Nt=/["']/g,jt=function _invertEase(e){return function(t){return 1-e(1-t)}},Vt=function _parseEase(t,e){return t&&(s(t)?t:Bt[t]||Rb(t))||e};function Al(t){var e,r,i,n,a=O()-A,s=!0===t;if((M<a||a<0)&&(P+=a-C),(0<(e=(i=(A+=a)-P)-z)||s)&&(n=++g.frame,b=i-1e3*g.time,g.time=i/=1e3,z+=e+(D<=e?4:D-e),r=1),s||(p=_(Al)),r)for(k=0;k<E.length;k++)E[k](i,b,n,t)}function jn(t){return t<Y?B*t*t:t<.7272727272727273?B*Math.pow(t-1.5/2.75,2)+.75:t<.9090909090909092?B*(t-=2.25/2.75)*t+.9375:B*Math.pow(t-2.625/2.75,2)+.984375}ja("Linear,Quad,Cubic,Quart,Quint,Strong",function(t,e){var r=e<5?e+1:e;Vb(t+",Power"+(r-1),e?function(t){return Math.pow(t,r)}:function(t){return t},function(t){return 1-Math.pow(1-t,r)},function(t){return t<.5?Math.pow(2*t,r)/2:1-Math.pow(2*(1-t),r)/2})}),Bt.Linear.easeNone=Bt.none=Bt.Linear.easeIn,Vb("Elastic",Xb("in"),Xb("out"),Xb()),B=7.5625,Y=1/2.75,Vb("Bounce",function(t){return 1-jn(1-t)},jn),Vb("Expo",function(t){return Math.pow(2,10*(t-1))*t+t*t*t*t*t*t*(1-t)}),Vb("Circ",function(t){return-(J(1-t*t)-1)}),Vb("Sine",function(t){return 1===t?1:1-Q(t*W)}),Vb("Back",Yb("in"),Yb("out"),Yb()),Bt.SteppedEase=Bt.steps=ht.SteppedEase={config:function config(t,e){void 0===t&&(t=1);var r=1/t,i=t+(e?0:1),n=e?1:0;return function(t){return((i*Mt(0,.99999999,t)|0)+n)*r}}},j.ease=Bt["quad.out"],ja("onComplete,onUpdate,onStart,onRepeat,onReverseComplete,onInterrupt",function(t){return Tt+=t+","+t+"Params,"});var Ut,Xt=function GSCache(t,e){this.id=H++,(t._gsap=this).target=t,this.harness=e,this.get=e?e.get:ia,this.set=e?e.getSetter:le},qt=((Ut=Animation.prototype).delay=function delay(t){return t||0===t?(this.parent&&this.parent.smoothChildTiming&&this.startTime(this._start+t-this._delay),this._delay=t,this):this._delay},Ut.duration=function duration(t){return arguments.length?this.totalDuration(0<this._repeat?t+(t+this._rDelay)*this._repeat:t):this.totalDuration()&&this._dur},Ut.totalDuration=function totalDuration(t){return arguments.length?(this._dirty=0,Ua(this,this._repeat<0?t:(t-this._repeat*this._rDelay)/(this._repeat+1))):this._tDur},Ut.totalTime=function totalTime(t,e){if(Lt(),!arguments.length)return this._tTime;var r=this._dp;if(r&&r.smoothChildTiming&&this._ts){for(La(this,t),!r._dp||r.parent||Ma(r,this);r&&r.parent;)r.parent._time!==r._start+(0<=r._ts?r._tTime/r._ts:(r.totalDuration()-r._tTime)/-r._ts)&&r.totalTime(r._tTime,!0),r=r.parent;!this.parent&&this._dp.autoRemoveChildren&&(0<this._ts&&t<this._tDur||this._ts<0&&0<t||!this._tDur&&!t)&&Na(this._dp,this,this._start-this._delay)}return(this._tTime!==t||!this._dur&&!e||this._initted&&Math.abs(this._zTime)===q||!this._initted&&this._dur&&t||!t&&!this._initted&&(this.add||this._ptLookup))&&(this._ts||(this._pTime=t),qa(this,t,e)),this},Ut.time=function time(t,e){return arguments.length?this.totalTime(Math.min(this.totalDuration(),t+Ha(this))%(this._dur+this._rDelay)||(t?this._dur:0),e):this._time},Ut.totalProgress=function totalProgress(t,e){return arguments.length?this.totalTime(this.totalDuration()*t,e):this.totalDuration()?Math.min(1,this._tTime/this._tDur):0<=this.rawTime()&&this._initted?1:0},Ut.progress=function progress(t,e){return arguments.length?this.totalTime(this.duration()*(!this._yoyo||1&this.iteration()?t:1-t)+Ha(this),e):this.duration()?Math.min(1,this._time/this._dur):0<this.rawTime()?1:0},Ut.iteration=function iteration(t,e){var r=this.duration()+this._rDelay;return arguments.length?this.totalTime(this._time+(t-1)*r,e):this._repeat?wt(this._tTime,r)+1:1},Ut.timeScale=function timeScale(t,e){if(!arguments.length)return this._rts===-q?0:this._rts;if(this._rts===t)return this;var r=this.parent&&this._ts?Ja(this.parent._time,this):this._tTime;return this._rts=+t||0,this._ts=this._ps||t===-q?0:this._rts,this.totalTime(Mt(-Math.abs(this._delay),this.totalDuration(),r),!1!==e),Ka(this),function _recacheAncestors(t){for(var e=t.parent;e&&e.parent;)e._dirty=1,e.totalDuration(),e=e.parent;return t}(this)},Ut.paused=function paused(t){return arguments.length?(this._ps!==t&&((this._ps=t)?(this._pTime=this._tTime||Math.max(-this._delay,this.rawTime()),this._ts=this._act=0):(Lt(),this._ts=this._rts,this.totalTime(this.parent&&!this.parent.smoothChildTiming?this.rawTime():this._tTime||this._pTime,1===this.progress()&&Math.abs(this._zTime)!==q&&(this._tTime-=q)))),this):this._ps},Ut.startTime=function startTime(t){if(arguments.length){this._start=la(t);var e=this.parent||this._dp;return!e||!e._sort&&this.parent||Na(e,this,this._start-this._delay),this}return this._start},Ut.endTime=function endTime(t){return this._start+(w(t)?this.totalDuration():this.duration())/Math.abs(this._ts||1)},Ut.rawTime=function rawTime(t){var e=this.parent||this._dp;return e?t&&(!this._ts||this._repeat&&this._time&&this.totalProgress()<1)?this._tTime%(this._dur+this._rDelay):this._ts?Ja(e.rawTime(t),this):this._tTime:this._tTime},Ut.revert=function revert(t){void 0===t&&(t=dt);var e=I;return I=t,pa(this)&&(this.timeline&&this.timeline.revert(t),this.totalTime(-.01,t.suppressEvents)),"nested"!==this.data&&!1!==t.kill&&this.kill(),I=e,this},Ut.globalTime=function globalTime(t){for(var e=this,r=arguments.length?t:e.rawTime();e;)r=e._start+r/(Math.abs(e._ts)||1),e=e._dp;return!this.parent&&this._sat?this._sat.globalTime(t):r},Ut.repeat=function repeat(t){return arguments.length?(this._repeat=t===1/0?-2:t,Va(this)):-2===this._repeat?1/0:this._repeat},Ut.repeatDelay=function repeatDelay(t){if(arguments.length){var e=this._time;return this._rDelay=t,Va(this),e?this.time(e):this}return this._rDelay},Ut.yoyo=function yoyo(t){return arguments.length?(this._yoyo=t,this):this._yoyo},Ut.seek=function seek(t,e){return this.totalTime(Ot(this,t),w(e))},Ut.restart=function restart(t,e){return this.play().totalTime(t?-this._delay:0,w(e)),this._dur||(this._zTime=-q),this},Ut.play=function play(t,e){return null!=t&&this.seek(t,e),this.reversed(!1).paused(!1)},Ut.reverse=function reverse(t,e){return null!=t&&this.seek(t||this.totalDuration(),e),this.reversed(!0).paused(!1)},Ut.pause=function pause(t,e){return null!=t&&this.seek(t,e),this.paused(!0)},Ut.resume=function resume(){return this.paused(!1)},Ut.reversed=function reversed(t){return arguments.length?(!!t!==this.reversed()&&this.timeScale(-this._rts||(t?-q:0)),this):this._rts<0},Ut.invalidate=function invalidate(){return this._initted=this._act=0,this._zTime=-q,this},Ut.isActive=function isActive(){var t,e=this.parent||this._dp,r=this._start;return!(e&&!(this._ts&&this._initted&&e.isActive()&&(t=e.rawTime(!0))>=r&&t<this.endTime(!0)-q))},Ut.eventCallback=function eventCallback(t,e,r){var i=this.vars;return 1<arguments.length?(e?(i[t]=e,r&&(i[t+"Params"]=r),"onUpdate"===t&&(this._onUpdate=e)):delete i[t],this):i[t]},Ut.then=function then(t){var i=this,n=i._prom;return new Promise(function(e){function Fo(){var t=i.then;i.then=null,n&&n(),s(r)&&(r=r(i))&&(r.then||r===i)&&(i.then=t),e(r),i.then=t}var r=s(t)?t:sa;i._initted&&1===i.totalProgress()&&0<=i._ts||!i._tTime&&i._ts<0?Fo():i._prom=Fo})},Ut.kill=function kill(){wb(this)},Animation);function Animation(t){this.vars=t,this._delay=+t.delay||0,(this._repeat=t.repeat===1/0?-2:t.repeat||0)&&(this._rDelay=t.repeatDelay||0,this._yoyo=!!t.yoyo||!!t.yoyoEase),this._ts=1,Ua(this,+t.duration,1,1),this.data=t.data,l&&(this._ctx=l).data.push(this),c||It.wake()}ta(qt.prototype,{_time:0,_start:0,_end:0,_tTime:0,_tDur:0,_dirty:0,_repeat:0,_yoyo:!1,parent:null,_initted:!1,_rDelay:0,_ts:1,_dp:0,ratio:0,_zTime:-q,_prom:0,_ps:!1,_rts:1});var Zt=function(i){function Timeline(t,e){var r;return void 0===t&&(t={}),(r=i.call(this,t)||this).labels={},r.smoothChildTiming=!!t.smoothChildTiming,r.autoRemoveChildren=!!t.autoRemoveChildren,r._sort=w(t.sortChildren),L&&Na(t.parent||L,_assertThisInitialized(r),e),t.reversed&&r.reverse(),t.paused&&r.paused(!0),t.scrollTrigger&&Oa(_assertThisInitialized(r),t.scrollTrigger),r}_inheritsLoose(Timeline,i);var e=Timeline.prototype;return e.to=function to(t,e,r){return Ya(0,arguments,this),this},e.from=function from(t,e,r){return Ya(1,arguments,this),this},e.fromTo=function fromTo(t,e,r,i){return Ya(2,arguments,this),this},e.set=function set(t,e,r){return e.duration=0,e.parent=this,ya(e).repeatDelay||(e.repeat=0),e.immediateRender=!!e.immediateRender,new te(t,e,Ot(this,r),1),this},e.call=function call(t,e,r){return Na(this,te.delayedCall(0,t,e),r)},e.staggerTo=function staggerTo(t,e,r,i,n,a,s){return r.duration=e,r.stagger=r.stagger||i,r.onComplete=a,r.onCompleteParams=s,r.parent=this,new te(t,r,Ot(this,n)),this},e.staggerFrom=function staggerFrom(t,e,r,i,n,a,s){return r.runBackwards=1,ya(r).immediateRender=w(r.immediateRender),this.staggerTo(t,e,r,i,n,a,s)},e.staggerFromTo=function staggerFromTo(t,e,r,i,n,a,s,o){return i.startAt=r,ya(i).immediateRender=w(i.immediateRender),this.staggerTo(t,e,i,n,a,s,o)},e.render=function render(t,e,r){var i,n,a,s,o,u,h,l,f,d,c,p,_=this._time,m=this._dirty?this.totalDuration():this._tDur,g=this._dur,v=t<=0?0:la(t),y=this._zTime<0!=t<0&&(this._initted||!g);if(this!==L&&m<v&&0<=t&&(v=m),v!==this._tTime||r||y){if(_!==this._time&&g&&(v+=this._time-_,t+=this._time-_),i=v,f=this._start,u=!(l=this._ts),y&&(g||(_=this._zTime),!t&&e||(this._zTime=t)),this._repeat){if(c=this._yoyo,o=g+this._rDelay,this._repeat<-1&&t<0)return this.totalTime(100*o+t,e,r);if(i=la(v%o),v===m?(s=this._repeat,i=g):((s=~~(d=la(v/o)))&&s===d&&(i=g,s--),g<i&&(i=g)),d=wt(this._tTime,o),!_&&this._tTime&&d!==s&&this._tTime-d*o-this._dur<=0&&(d=s),c&&1&s&&(i=g-i,p=1),s!==d&&!this._lock){var T=c&&1&d,b=T===(c&&1&s);if(s<d&&(T=!T),_=T?0:v%g?g:v,this._lock=1,this.render(_||(p?0:la(s*o)),e,!g)._lock=0,this._tTime=v,!e&&this.parent&&Dt(this,"onRepeat"),this.vars.repeatRefresh&&!p&&(this.invalidate()._lock=1,d=s),_&&_!==this._time||u!=!this._ts||this.vars.onRepeat&&!this.parent&&!this._act)return this;if(g=this._dur,m=this._tDur,b&&(this._lock=2,_=T?g:-1e-4,this.render(_,!0),this.vars.repeatRefresh&&!p&&this.invalidate()),this._lock=0,!this._ts&&!u)return this;Tb(this,p)}}if(this._hasPause&&!this._forcing&&this._lock<2&&(h=function _findNextPauseTween(t,e,r){var i;if(e<r)for(i=t._first;i&&i._start<=r;){if("isPause"===i.data&&i._start>e)return i;i=i._next}else for(i=t._last;i&&i._start>=r;){if("isPause"===i.data&&i._start<e)return i;i=i._prev}}(this,la(_),la(i)))&&(v-=i-(i=h._start)),this._tTime=v,this._time=i,this._act=!l,this._initted||(this._onUpdate=this.vars.onUpdate,this._initted=1,this._zTime=t,_=0),!_&&v&&g&&!e&&!d&&(Dt(this,"onStart"),this._tTime!==v))return this;if(_<=i&&0<=t)for(n=this._first;n;){if(a=n._next,(n._act||i>=n._start)&&n._ts&&h!==n){if(n.parent!==this)return this.render(t,e,r);if(n.render(0<n._ts?(i-n._start)*n._ts:(n._dirty?n.totalDuration():n._tDur)+(i-n._start)*n._ts,e,r),i!==this._time||!this._ts&&!u){h=0,a&&(v+=this._zTime=-q);break}}n=a}else{n=this._last;for(var w=t<0?t:i;n;){if(a=n._prev,(n._act||w<=n._end)&&n._ts&&h!==n){if(n.parent!==this)return this.render(t,e,r);if(n.render(0<n._ts?(w-n._start)*n._ts:(n._dirty?n.totalDuration():n._tDur)+(w-n._start)*n._ts,e,r||I&&pa(n)),i!==this._time||!this._ts&&!u){h=0,a&&(v+=this._zTime=w?-q:q);break}}n=a}}if(h&&!e&&(this.pause(),h.render(_<=i?0:-q)._zTime=_<=i?1:-1,this._ts))return this._start=f,Ka(this),this.render(t,e,r);this._onUpdate&&!e&&Dt(this,"onUpdate",!0),(v===m&&this._tTime>=this.totalDuration()||!v&&_)&&(f!==this._start&&Math.abs(l)===Math.abs(this._ts)||this._lock||(!t&&g||!(v===m&&0<this._ts||!v&&this._ts<0)||Ca(this,1),e||t<0&&!_||!v&&!_&&m||(Dt(this,v===m&&0<=t?"onComplete":"onReverseComplete",!0),!this._prom||v<m&&0<this.timeScale()||this._prom())))}return this},e.add=function add(e,i){var n=this;if(t(i)||(i=Ot(this,i,e)),!(e instanceof qt)){if($(e))return e.forEach(function(t){return n.add(t,i)}),this;if(r(e))return this.addLabel(e,i);if(!s(e))return this;e=te.delayedCall(0,e)}return this!==e?Na(this,e,i):this},e.getChildren=function getChildren(t,e,r,i){void 0===t&&(t=!0),void 0===e&&(e=!0),void 0===r&&(r=!0),void 0===i&&(i=-X);for(var n=[],a=this._first;a;)a._start>=i&&(a instanceof te?e&&n.push(a):(r&&n.push(a),t&&n.push.apply(n,a.getChildren(!0,e,r)))),a=a._next;return n},e.getById=function getById(t){for(var e=this.getChildren(1,1,1),r=e.length;r--;)if(e[r].vars.id===t)return e[r]},e.remove=function remove(t){return r(t)?this.removeLabel(t):s(t)?this.killTweensOf(t):(t.parent===this&&Ba(this,t),t===this._recent&&(this._recent=this._last),Da(this))},e.totalTime=function totalTime(t,e){return arguments.length?(this._forcing=1,!this._dp&&this._ts&&(this._start=la(It.time-(0<this._ts?t/this._ts:(this.totalDuration()-t)/-this._ts))),i.prototype.totalTime.call(this,t,e),this._forcing=0,this):this._tTime},e.addLabel=function addLabel(t,e){return this.labels[t]=Ot(this,e),this},e.removeLabel=function removeLabel(t){return delete this.labels[t],this},e.addPause=function addPause(t,e,r){var i=te.delayedCall(0,e||V,r);return i.data="isPause",this._hasPause=1,Na(this,i,Ot(this,t))},e.removePause=function removePause(t){var e=this._first;for(t=Ot(this,t);e;)e._start===t&&"isPause"===e.data&&Ca(e),e=e._next},e.killTweensOf=function killTweensOf(t,e,r){for(var i=this.getTweensOf(t,r),n=i.length;n--;)Wt!==i[n]&&i[n].kill(t,e);return this},e.getTweensOf=function getTweensOf(e,r){for(var i,n=[],a=Pt(e),s=this._first,o=t(r);s;)s instanceof te?na(s._targets,a)&&(o?(!Wt||s._initted&&s._ts)&&s.globalTime(0)<=r&&s.globalTime(s.totalDuration())>r:!r||s.isActive())&&n.push(s):(i=s.getTweensOf(a,r)).length&&n.push.apply(n,i),s=s._next;return n},e.tweenTo=function tweenTo(t,e){e=e||{};var r,i=this,n=Ot(i,t),a=e.startAt,s=e.onStart,o=e.onStartParams,u=e.immediateRender,h=te.to(i,ta({ease:e.ease||"none",lazy:!1,immediateRender:!1,time:n,overwrite:"auto",duration:e.duration||Math.abs((n-(a&&"time"in a?a.time:i._time))/i.timeScale())||q,onStart:function onStart(){if(i.pause(),!r){var t=e.duration||Math.abs((n-(a&&"time"in a?a.time:i._time))/i.timeScale());h._dur!==t&&Ua(h,t,0,1).render(h._time,!0,!0),r=1}s&&s.apply(h,o||[])}},e));return u?h.render(0):h},e.tweenFromTo=function tweenFromTo(t,e,r){return this.tweenTo(e,ta({startAt:{time:Ot(this,t)}},r))},e.recent=function recent(){return this._recent},e.nextLabel=function nextLabel(t){return void 0===t&&(t=this._time),ub(this,Ot(this,t))},e.previousLabel=function previousLabel(t){return void 0===t&&(t=this._time),ub(this,Ot(this,t),1)},e.currentLabel=function currentLabel(t){return arguments.length?this.seek(t,!0):this.previousLabel(this._time+q)},e.shiftChildren=function shiftChildren(t,e,r){void 0===r&&(r=0);var i,n=this._first,a=this.labels;for(t=la(t);n;)n._start>=r&&(n._start+=t,n._end+=t),n=n._next;if(e)for(i in a)a[i]>=r&&(a[i]+=t);return Da(this)},e.invalidate=function invalidate(t){var e=this._first;for(this._lock=0;e;)e.invalidate(t),e=e._next;return i.prototype.invalidate.call(this,t)},e.clear=function clear(t){void 0===t&&(t=!0);for(var e,r=this._first;r;)e=r._next,this.remove(r),r=e;return this._dp&&(this._time=this._tTime=this._pTime=0),t&&(this.labels={}),Da(this)},e.totalDuration=function totalDuration(t){var e,r,i,n=0,a=this,s=a._last,o=X;if(arguments.length)return a.timeScale((a._repeat<0?a.duration():a.totalDuration())/(a.reversed()?-t:t));if(a._dirty){for(i=a.parent;s;)e=s._prev,s._dirty&&s.totalDuration(),o<(r=s._start)&&a._sort&&s._ts&&!a._lock?(a._lock=1,Na(a,s,r-s._delay,1)._lock=0):o=r,r<0&&s._ts&&(n-=r,(!i&&!a._dp||i&&i.smoothChildTiming)&&(a._start+=la(r/a._ts),a._time-=r,a._tTime-=r),a.shiftChildren(-r,!1,-Infinity),o=0),s._end>n&&s._ts&&(n=s._end),s=e;Ua(a,a===L&&a._time>n?a._time:n,1,1),a._dirty=0}return a._tDur},Timeline.updateRoot=function updateRoot(t){if(L._ts&&(qa(L,Ja(t,L)),f=It.frame),It.frame>=vt){vt+=N.autoSleep||120;var e=L._first;if((!e||!e._ts)&&N.autoSleep&&It._listeners.length<2){for(;e&&!e._ts;)e=e._next;e||It.sleep()}}},Timeline}(qt);ta(Zt.prototype,{_lock:0,_hasPause:0,_forcing:0});function dc(t,e,i,n,a,o){var u,h,l,f;if(mt[t]&&!1!==(u=new mt[t]).init(a,u.rawVars?e[t]:function _processVars(t,e,i,n,a){if(s(t)&&(t=Gt(t,a,e,i,n)),!v(t)||t.style&&t.nodeType||$(t)||K(t))return r(t)?Gt(t,a,e,i,n):t;var o,u={};for(o in t)u[o]=Gt(t[o],a,e,i,n);return u}(e[t],n,a,o,i),i,n,o)&&(i._pt=h=new we(i._pt,a,t,0,1,u.render,u,0,u.priority),i!==d))for(l=i._ptLookup[i._targets.indexOf(a)],f=u._props.length;f--;)l[u._props[f]]=h;return u}function jc(t,r,e,i){var n,a,s=r.ease||i||"power1.inOut";if($(r))a=e[t]||(e[t]=[]),r.forEach(function(t,e){return a.push({t:e/(r.length-1)*100,v:t,e:s})});else for(n in r)a=e[n]||(e[n]=[]),"ease"===n||a.push({t:parseFloat(t),v:r[n],e:s})}var Wt,Ht,Jt=function _addPropTween(t,e,i,n,a,o,u,h,l,f){s(n)&&(n=n(a||0,t,o));var d,c=t[e],p="get"!==i?i:s(c)?l?t[e.indexOf("set")||!s(t["get"+e.substr(3)])?e:"get"+e.substr(3)](l):t[e]():c,_=s(c)?l?ue:re:ee;if(r(n)&&(~n.indexOf("random(")&&(n=rb(n)),"="===n.charAt(1)&&(!(d=ma(p,n)+(_a(p)||0))&&0!==d||(n=d))),!f||p!==n||Ht)return isNaN(p*n)||""===n?(c||e in t||S(e,n),function _addComplexStringPropTween(t,e,r,i,n,a,s){var o,u,h,l,f,d,c,p,_=new we(this._pt,t,e,0,1,ge,null,n),m=0,g=0;for(_.b=r,_.e=i,r+="",(c=~(i+="").indexOf("random("))&&(i=rb(i)),a&&(a(p=[r,i],t,e),r=p[0],i=p[1]),u=r.match(at)||[];o=at.exec(i);)l=o[0],f=i.substring(m,o.index),h?h=(h+1)%5:"rgba("===f.substr(-5)&&(h=1),l!==u[g++]&&(d=parseFloat(u[g-1])||0,_._pt={_next:_._pt,p:f||1===g?f:",",s:d,c:"="===l.charAt(1)?ma(d,l)-d:parseFloat(l)-d,m:h&&h<4?Math.round:0},m=at.lastIndex);return _.c=m<i.length?i.substring(m,i.length):"",_.fp=s,(st.test(i)||c)&&(_.e=0),this._pt=_}.call(this,t,e,p,n,_,h||N.stringFilter,l)):(d=new we(this._pt,t,e,+p||0,n-(p||0),"boolean"==typeof c?_e:ce,0,_),l&&(d.fp=l),u&&d.modifier(u,this,t),this._pt=d)},Qt=function _initTween(t,e,r){var i,n,a,s,o,u,h,l,f,d,c,p,_,m=t.vars,g=m.ease,v=m.startAt,y=m.immediateRender,T=m.lazy,b=m.onUpdate,x=m.runBackwards,k=m.yoyoEase,O=m.keyframes,M=m.autoRevert,C=t._dur,P=t._startAt,A=t._targets,D=t.parent,S=D&&"nested"===D.data?D.vars.targets:A,z="auto"===t._overwrite&&!F,E=t.timeline;if(!E||O&&g||(g="none"),t._ease=Vt(g,j.ease),t._yEase=k?jt(Vt(!0===k?g:k,j.ease)):0,k&&t._yoyo&&!t._repeat&&(k=t._yEase,t._yEase=t._ease,t._ease=k),t._from=!E&&!!m.runBackwards,!E||O&&!m.stagger){if(p=(l=A[0]?ha(A[0]).harness:0)&&m[l.prop],i=xa(m,ct),P&&(P._zTime<0&&P.progress(1),e<0&&x&&y&&!M?P.render(-1,!0):P.revert(x&&C?ft:lt),P._lazy=0),v){if(Ca(t._startAt=te.set(A,ta({data:"isStart",overwrite:!1,parent:D,immediateRender:!0,lazy:!P&&w(T),startAt:null,delay:0,onUpdate:b&&function(){return Dt(t,"onUpdate")},stagger:0},v))),t._startAt._dp=0,t._startAt._sat=t,e<0&&(I||!y&&!M)&&t._startAt.revert(ft),y&&C&&e<=0&&r<=0)return void(e&&(t._zTime=e))}else if(x&&C&&!P)if(e&&(y=!1),a=ta({overwrite:!1,data:"isFromStart",lazy:y&&!P&&w(T),immediateRender:y,stagger:0,parent:D},i),p&&(a[l.prop]=p),Ca(t._startAt=te.set(A,a)),t._startAt._dp=0,t._startAt._sat=t,e<0&&(I?t._startAt.revert(ft):t._startAt.render(-1,!0)),t._zTime=e,y){if(!e)return}else _initTween(t._startAt,q,q);for(t._pt=t._ptCache=0,T=C&&w(T)||T&&!C,n=0;n<A.length;n++){if(h=(o=A[n])._gsap||ga(A)[n]._gsap,t._ptLookup[n]=d={},_t[h.id]&&pt.length&&oa(),c=S===A?n:S.indexOf(o),l&&!1!==(f=new l).init(o,p||i,t,c,S)&&(t._pt=s=new we(t._pt,o,f.name,0,1,f.render,f,0,f.priority),f._props.forEach(function(t){d[t]=s}),f.priority&&(u=1)),!l||p)for(a in i)mt[a]&&(f=dc(a,i,t,c,o,S))?f.priority&&(u=1):d[a]=s=Jt.call(t,o,a,"get",i[a],c,S,0,m.stringFilter);t._op&&t._op[n]&&t.kill(o,t._op[n]),z&&t._pt&&(Wt=t,L.killTweensOf(o,d,t.globalTime(e)),_=!t.parent,Wt=0),t._pt&&T&&(_t[h.id]=1)}u&&be(t),t._onInit&&t._onInit(t)}t._onUpdate=b,t._initted=(!t._op||t._pt)&&!_,O&&e<=0&&E.render(X,!0,!0)},Gt=function _parseFuncOrString(t,e,i,n,a){return s(t)?t.call(e,i,n,a):r(t)&&~t.indexOf("random(")?rb(t):t},Kt=Tt+"repeat,repeatDelay,yoyo,repeatRefresh,yoyoEase,autoRevert",$t={};ja(Kt+",id,stagger,delay,duration,paused,scrollTrigger",function(t){return $t[t]=1});var te=function(R){function Tween(e,r,i,n){var a;"number"==typeof r&&(i.duration=r,r=i,i=null);var s,o,u,h,l,f,d,c,p=(a=R.call(this,n?r:ya(r))||this).vars,_=p.duration,m=p.delay,g=p.immediateRender,b=p.stagger,x=p.overwrite,k=p.keyframes,O=p.defaults,M=p.scrollTrigger,C=p.yoyoEase,P=r.parent||L,A=($(e)||K(e)?t(e[0]):"length"in r)?[e]:Pt(e);if(a._targets=A.length?ga(A):T("GSAP target "+e+" not found. https://gsap.com",!N.nullTargetWarn)||[],a._ptLookup=[],a._overwrite=x,k||b||y(_)||y(m)){if(r=a.vars,(s=a.timeline=new Zt({data:"nested",defaults:O||{},targets:P&&"nested"===P.data?P.vars.targets:A})).kill(),s.parent=s._dp=_assertThisInitialized(a),s._start=0,b||y(_)||y(m)){if(h=A.length,d=b&&hb(b),v(b))for(l in b)~Kt.indexOf(l)&&((c=c||{})[l]=b[l]);for(o=0;o<h;o++)(u=xa(r,$t)).stagger=0,C&&(u.yoyoEase=C),c&&bt(u,c),f=A[o],u.duration=+Gt(_,_assertThisInitialized(a),o,f,A),u.delay=(+Gt(m,_assertThisInitialized(a),o,f,A)||0)-a._delay,!b&&1===h&&u.delay&&(a._delay=m=u.delay,a._start+=m,u.delay=0),s.to(f,u,d?d(o,f,A):0),s._ease=Bt.none;s.duration()?_=m=0:a.timeline=0}else if(k){ya(ta(s.vars.defaults,{ease:"none"})),s._ease=Vt(k.ease||r.ease||"none");var D,S,z,E=0;if($(k))k.forEach(function(t){return s.to(A,t,">")}),s.duration();else{for(l in u={},k)"ease"===l||"easeEach"===l||jc(l,k[l],u,k.easeEach);for(l in u)for(D=u[l].sort(function(t,e){return t.t-e.t}),o=E=0;o<D.length;o++)(z={ease:(S=D[o]).e,duration:(S.t-(o?D[o-1].t:0))/100*_})[l]=S.v,s.to(A,z,E),E+=z.duration;s.duration()<_&&s.to({},{duration:_-s.duration()})}}_||a.duration(_=s.duration())}else a.timeline=0;return!0!==x||F||(Wt=_assertThisInitialized(a),L.killTweensOf(A),Wt=0),Na(P,_assertThisInitialized(a),i),r.reversed&&a.reverse(),r.paused&&a.paused(!0),(g||!_&&!k&&a._start===la(P._time)&&w(g)&&function _hasNoPausedAncestors(t){return!t||t._ts&&_hasNoPausedAncestors(t.parent)}(_assertThisInitialized(a))&&"nested"!==P.data)&&(a._tTime=-q,a.render(Math.max(0,-m)||0)),M&&Oa(_assertThisInitialized(a),M),a}_inheritsLoose(Tween,R);var e=Tween.prototype;return e.render=function render(t,e,r){var i,n,a,s,o,u,h,l,f,d=this._time,c=this._tDur,p=this._dur,_=t<0,m=c-q<t&&!_?c:t<q?0:t;if(p){if(m!==this._tTime||!t||r||!this._initted&&this._tTime||this._startAt&&this._zTime<0!=_||this._lazy){if(i=m,l=this.timeline,this._repeat){if(s=p+this._rDelay,this._repeat<-1&&_)return this.totalTime(100*s+t,e,r);if(i=la(m%s),m===c?(a=this._repeat,i=p):(a=~~(o=la(m/s)))&&a===o?(i=p,a--):p<i&&(i=p),(u=this._yoyo&&1&a)&&(f=this._yEase,i=p-i),o=wt(this._tTime,s),i===d&&!r&&this._initted&&a===o)return this._tTime=m,this;a!==o&&(l&&this._yEase&&Tb(l,u),this.vars.repeatRefresh&&!u&&!this._lock&&i!==s&&this._initted&&(this._lock=r=1,this.render(la(s*a),!0).invalidate()._lock=0))}if(!this._initted){if(Pa(this,_?t:i,r,e,m))return this._tTime=0,this;if(!(d===this._time||r&&this.vars.repeatRefresh&&a!==o))return this;if(p!==this._dur)return this.render(t,e,r)}if(this._tTime=m,this._time=i,!this._act&&this._ts&&(this._act=1,this._lazy=0),this.ratio=h=(f||this._ease)(i/p),this._from&&(this.ratio=h=1-h),!d&&m&&!e&&!o&&(Dt(this,"onStart"),this._tTime!==m))return this;for(n=this._pt;n;)n.r(h,n.d),n=n._next;l&&l.render(t<0?t:l._dur*l._ease(i/this._dur),e,r)||this._startAt&&(this._zTime=t),this._onUpdate&&!e&&(_&&Fa(this,t,0,r),Dt(this,"onUpdate")),this._repeat&&a!==o&&this.vars.onRepeat&&!e&&this.parent&&Dt(this,"onRepeat"),m!==this._tDur&&m||this._tTime!==m||(_&&!this._onUpdate&&Fa(this,t,0,!0),!t&&p||!(m===this._tDur&&0<this._ts||!m&&this._ts<0)||Ca(this,1),e||_&&!d||!(m||d||u)||(Dt(this,m===c?"onComplete":"onReverseComplete",!0),!this._prom||m<c&&0<this.timeScale()||this._prom()))}}else!function _renderZeroDurationTween(t,e,r,i){var n,a,s,o=t.ratio,u=e<0||!e&&(!t._start&&function _parentPlayheadIsBeforeStart(t){var e=t.parent;return e&&e._ts&&e._initted&&!e._lock&&(e.rawTime()<0||_parentPlayheadIsBeforeStart(e))}(t)&&(t._initted||!xt(t))||(t._ts<0||t._dp._ts<0)&&!xt(t))?0:1,h=t._rDelay,l=0;if(h&&t._repeat&&(l=Mt(0,t._tDur,e),a=wt(l,h),t._yoyo&&1&a&&(u=1-u),a!==wt(t._tTime,h)&&(o=1-u,t.vars.repeatRefresh&&t._initted&&t.invalidate())),u!==o||I||i||t._zTime===q||!e&&t._zTime){if(!t._initted&&Pa(t,e,i,r,l))return;for(s=t._zTime,t._zTime=e||(r?q:0),r=r||e&&!s,t.ratio=u,t._from&&(u=1-u),t._time=0,t._tTime=l,n=t._pt;n;)n.r(u,n.d),n=n._next;e<0&&Fa(t,e,0,!0),t._onUpdate&&!r&&Dt(t,"onUpdate"),l&&t._repeat&&!r&&t.parent&&Dt(t,"onRepeat"),(e>=t._tDur||e<0)&&t.ratio===u&&(u&&Ca(t,1),r||I||(Dt(t,u?"onComplete":"onReverseComplete",!0),t._prom&&t._prom()))}else t._zTime||(t._zTime=e)}(this,t,e,r);return this},e.targets=function targets(){return this._targets},e.invalidate=function invalidate(t){return t&&this.vars.runBackwards||(this._startAt=0),this._pt=this._op=this._onUpdate=this._lazy=this.ratio=0,this._ptLookup=[],this.timeline&&this.timeline.invalidate(t),R.prototype.invalidate.call(this,t)},e.resetTo=function resetTo(t,e,r,i,n){c||It.wake(),this._ts||this.play();var a,s=Math.min(this._dur,(this._dp._time-this._start)*this._ts);return this._initted||Qt(this,s),a=this._ease(s/this._dur),function _updatePropTweens(t,e,r,i,n,a,s,o){var u,h,l,f,d=(t._pt&&t._ptCache||(t._ptCache={}))[e];if(!d)for(d=t._ptCache[e]=[],l=t._ptLookup,f=t._targets.length;f--;){if((u=l[f][e])&&u.d&&u.d._pt)for(u=u.d._pt;u&&u.p!==e&&u.fp!==e;)u=u._next;if(!u)return Ht=1,t.vars[e]="+=0",Qt(t,s),Ht=0,o?T(e+" not eligible for reset"):1;d.push(u)}for(f=d.length;f--;)(u=(h=d[f])._pt||h).s=!i&&0!==i||n?u.s+(i||0)+a*u.c:i,u.c=r-u.s,h.e&&(h.e=ka(r)+_a(h.e)),h.b&&(h.b=u.s+_a(h.b))}(this,t,e,r,i,a,s,n)?this.resetTo(t,e,r,i,1):(La(this,0),this.parent||Aa(this._dp,this,"_first","_last",this._dp._sort?"_start":0),this.render(0))},e.kill=function kill(t,e){if(void 0===e&&(e="all"),!(t||e&&"all"!==e))return this._lazy=this._pt=0,this.parent?wb(this):this.scrollTrigger&&this.scrollTrigger.kill(!!I),this;if(this.timeline){var i=this.timeline.totalDuration();return this.timeline.killTweensOf(t,e,Wt&&!0!==Wt.vars.overwrite)._first||wb(this),this.parent&&i!==this.timeline.totalDuration()&&Ua(this,this._dur*this.timeline._tDur/i,0,1),this}var n,a,s,o,u,h,l,f=this._targets,d=t?Pt(t):f,c=this._ptLookup,p=this._pt;if((!e||"all"===e)&&function _arraysMatch(t,e){for(var r=t.length,i=r===e.length;i&&r--&&t[r]===e[r];);return r<0}(f,d))return"all"===e&&(this._pt=0),wb(this);for(n=this._op=this._op||[],"all"!==e&&(r(e)&&(u={},ja(e,function(t){return u[t]=1}),e=u),e=function _addAliasesToVars(t,e){var r,i,n,a,s=t[0]?ha(t[0]).harness:0,o=s&&s.aliases;if(!o)return e;for(i in r=bt({},e),o)if(i in r)for(n=(a=o[i].split(",")).length;n--;)r[a[n]]=r[i];return r}(f,e)),l=f.length;l--;)if(~d.indexOf(f[l]))for(u in a=c[l],"all"===e?(n[l]=e,o=a,s={}):(s=n[l]=n[l]||{},o=e),o)(h=a&&a[u])&&("kill"in h.d&&!0!==h.d.kill(u)||Ba(this,h,"_pt"),delete a[u]),"all"!==s&&(s[u]=1);return this._initted&&!this._pt&&p&&wb(this),this},Tween.to=function to(t,e,r){return new Tween(t,e,r)},Tween.from=function from(t,e){return Ya(1,arguments)},Tween.delayedCall=function delayedCall(t,e,r,i){return new Tween(e,0,{immediateRender:!1,lazy:!1,overwrite:!1,delay:t,onComplete:e,onReverseComplete:e,onCompleteParams:r,onReverseCompleteParams:r,callbackScope:i})},Tween.fromTo=function fromTo(t,e,r){return Ya(2,arguments)},Tween.set=function set(t,e){return e.duration=0,e.repeatDelay||(e.repeat=0),new Tween(t,e)},Tween.killTweensOf=function killTweensOf(t,e,r){return L.killTweensOf(t,e,r)},Tween}(qt);ta(te.prototype,{_targets:[],_lazy:0,_startAt:0,_op:0,_onInit:0}),ja("staggerTo,staggerFrom,staggerFromTo",function(r){te[r]=function(){var t=new Zt,e=Ct.call(arguments,0);return e.splice("staggerFromTo"===r?5:4,0,0),t[r].apply(t,e)}});function rc(t,e,r){return t.setAttribute(e,r)}function zc(t,e,r,i){i.mSet(t,e,i.m.call(i.tween,r,i.mt),i)}var ee=function _setterPlain(t,e,r){return t[e]=r},re=function _setterFunc(t,e,r){return t[e](r)},ue=function _setterFuncWithParam(t,e,r,i){return t[e](i.fp,r)},le=function _getSetter(t,e){return s(t[e])?re:u(t[e])&&t.setAttribute?rc:ee},ce=function _renderPlain(t,e){return e.set(e.t,e.p,Math.round(1e6*(e.s+e.c*t))/1e6,e)},_e=function _renderBoolean(t,e){return e.set(e.t,e.p,!!(e.s+e.c*t),e)},ge=function _renderComplexString(t,e){var r=e._pt,i="";if(!t&&e.b)i=e.b;else if(1===t&&e.e)i=e.e;else{for(;r;)i=r.p+(r.m?r.m(r.s+r.c*t):Math.round(1e4*(r.s+r.c*t))/1e4)+i,r=r._next;i+=e.c}e.set(e.t,e.p,i,e)},ve=function _renderPropTweens(t,e){for(var r=e._pt;r;)r.r(t,r.d),r=r._next},ye=function _addPluginModifier(t,e,r,i){for(var n,a=this._pt;a;)n=a._next,a.p===i&&a.modifier(t,e,r),a=n},Te=function _killPropTweensOf(t){for(var e,r,i=this._pt;i;)r=i._next,i.p===t&&!i.op||i.op===t?Ba(this,i,"_pt"):i.dep||(e=1),i=r;return!e},be=function _sortPropTweensByPriority(t){for(var e,r,i,n,a=t._pt;a;){for(e=a._next,r=i;r&&r.pr>a.pr;)r=r._next;(a._prev=r?r._prev:n)?a._prev._next=a:i=a,(a._next=r)?r._prev=a:n=a,a=e}t._pt=i},we=(PropTween.prototype.modifier=function modifier(t,e,r){this.mSet=this.mSet||this.set,this.set=zc,this.m=t,this.mt=r,this.tween=e},PropTween);function PropTween(t,e,r,i,n,a,s,o,u){this.t=e,this.s=i,this.c=n,this.p=r,this.r=a||ce,this.d=s||this,this.set=o||ee,this.pr=u||0,(this._next=t)&&(t._prev=this)}ja(Tt+"parent,duration,ease,delay,overwrite,runBackwards,startAt,yoyo,immediateRender,repeat,repeatDelay,data,paused,reversed,lazy,callbackScope,stringFilter,id,yoyoEase,stagger,inherit,repeatRefresh,keyframes,autoRevert,scrollTrigger",function(t){return ct[t]=1}),ht.TweenMax=ht.TweenLite=te,ht.TimelineLite=ht.TimelineMax=Zt,L=new Zt({sortChildren:!1,defaults:j,autoRemoveChildren:!0,id:"root",smoothChildTiming:!0}),N.stringFilter=Ib;function Hc(t){return(Oe[t]||Me).map(function(t){return t()})}function Ic(){var t=Date.now(),o=[];2<t-Ce&&(Hc("matchMediaInit"),ke.forEach(function(t){var e,r,i,n,a=t.queries,s=t.conditions;for(r in a)(e=h.matchMedia(a[r]).matches)&&(i=1),e!==s[r]&&(s[r]=e,n=1);n&&(t.revert(),i&&o.push(t))}),Hc("matchMediaRevert"),o.forEach(function(e){return e.onMatch(e,function(t){return e.add(null,t)})}),Ce=t,Hc("matchMedia"))}var xe,ke=[],Oe={},Me=[],Ce=0,Pe=0,De=((xe=Context.prototype).add=function add(t,i,n){function Jw(){var t,e=l,r=a.selector;return e&&e!==a&&e.data.push(a),n&&(a.selector=fb(n)),l=a,t=i.apply(a,arguments),s(t)&&a._r.push(t),l=e,a.selector=r,a.isReverted=!1,t}s(t)&&(n=i,i=t,t=s);var a=this;return a.last=Jw,t===s?Jw(a,function(t){return a.add(null,t)}):t?a[t]=Jw:Jw},xe.ignore=function ignore(t){var e=l;l=null,t(this),l=e},xe.getTweens=function getTweens(){var e=[];return this.data.forEach(function(t){return t instanceof Context?e.push.apply(e,t.getTweens()):t instanceof te&&!(t.parent&&"nested"===t.parent.data)&&e.push(t)}),e},xe.clear=function clear(){this._r.length=this.data.length=0},xe.kill=function kill(i,t){var n=this;if(i?function(){for(var t,e=n.getTweens(),r=n.data.length;r--;)"isFlip"===(t=n.data[r]).data&&(t.revert(),t.getChildren(!0,!0,!1).forEach(function(t){return e.splice(e.indexOf(t),1)}));for(e.map(function(t){return{g:t._dur||t._delay||t._sat&&!t._sat.vars.immediateRender?t.globalTime(0):-1/0,t:t}}).sort(function(t,e){return e.g-t.g||-1/0}).forEach(function(t){return t.t.revert(i)}),r=n.data.length;r--;)(t=n.data[r])instanceof Zt?"nested"!==t.data&&(t.scrollTrigger&&t.scrollTrigger.revert(),t.kill()):t instanceof te||!t.revert||t.revert(i);n._r.forEach(function(t){return t(i,n)}),n.isReverted=!0}():this.data.forEach(function(t){return t.kill&&t.kill()}),this.clear(),t)for(var e=ke.length;e--;)ke[e].id===this.id&&ke.splice(e,1)},xe.revert=function revert(t){this.kill(t||{})},Context);function Context(t,e){this.selector=e&&fb(e),this.data=[],this._r=[],this.isReverted=!1,this.id=Pe++,t&&this.add(t)}var Se,Ee=((Se=MatchMedia.prototype).add=function add(t,e,r){v(t)||(t={matches:t});var i,n,a,s=new De(0,r||this.scope),o=s.conditions={};for(n in l&&!s.selector&&(s.selector=l.selector),this.contexts.push(s),e=s.add("onMatch",e),s.queries=t)"all"===n?a=1:(i=h.matchMedia(t[n]))&&(ke.indexOf(s)<0&&ke.push(s),(o[n]=i.matches)&&(a=1),i.addListener?i.addListener(Ic):i.addEventListener("change",Ic));return a&&e(s,function(t){return s.add(null,t)}),this},Se.revert=function revert(t){this.kill(t||{})},Se.kill=function kill(e){this.contexts.forEach(function(t){return t.kill(e,!0)})},MatchMedia);function MatchMedia(t){this.contexts=[],this.scope=t,l&&l.data.push(this)}var Re={registerPlugin:function registerPlugin(){for(var t=arguments.length,e=new Array(t),r=0;r<t;r++)e[r]=arguments[r];e.forEach(function(t){return zb(t)})},timeline:function timeline(t){return new Zt(t)},getTweensOf:function getTweensOf(t,e){return L.getTweensOf(t,e)},getProperty:function getProperty(i,t,e,n){r(i)&&(i=Pt(i)[0]);var a=ha(i||{}).get,s=e?sa:ra;return"native"===e&&(e=""),i?t?s((mt[t]&&mt[t].get||a)(i,t,e,n)):function(t,e,r){return s((mt[t]&&mt[t].get||a)(i,t,e,r))}:i},quickSetter:function quickSetter(r,e,i){if(1<(r=Pt(r)).length){var n=r.map(function(t){return Fe.quickSetter(t,e,i)}),a=n.length;return function(t){for(var e=a;e--;)n[e](t)}}r=r[0]||{};var s=mt[e],o=ha(r),u=o.harness&&(o.harness.aliases||{})[e]||e,h=s?function(t){var e=new s;d._pt=0,e.init(r,i?t+i:t,d,0,[r]),e.render(1,e),d._pt&&ve(1,d)}:o.set(r,u);return s?h:function(t){return h(r,u,i?t+i:t,o,1)}},quickTo:function quickTo(t,i,e){function by(t,e,r){return n.resetTo(i,t,e,r)}var r,n=Fe.to(t,ta(((r={})[i]="+=0.1",r.paused=!0,r.stagger=0,r),e||{}));return by.tween=n,by},isTweening:function isTweening(t){return 0<L.getTweensOf(t,!0).length},defaults:function defaults(t){return t&&t.ease&&(t.ease=Vt(t.ease,j.ease)),wa(j,t||{})},config:function config(t){return wa(N,t||{})},registerEffect:function registerEffect(t){var i=t.name,n=t.effect,e=t.plugins,a=t.defaults,r=t.extendTimeline;(e||"").split(",").forEach(function(t){return t&&!mt[t]&&!ht[t]&&T(i+" effect requires "+t+" plugin.")}),gt[i]=function(t,e,r){return n(Pt(t),ta(e||{},a),r)},r&&(Zt.prototype[i]=function(t,e,r){return this.add(gt[i](t,v(e)?e:(r=e)&&{},this),r)})},registerEase:function registerEase(t,e){Bt[t]=Vt(e)},parseEase:function parseEase(t,e){return arguments.length?Vt(t,e):Bt},getById:function getById(t){return L.getById(t)},exportRoot:function exportRoot(t,e){void 0===t&&(t={});var r,i,n=new Zt(t);for(n.smoothChildTiming=w(t.smoothChildTiming),L.remove(n),n._dp=0,n._time=n._tTime=L._time,r=L._first;r;)i=r._next,!e&&!r._dur&&r instanceof te&&r.vars.onComplete===r._targets[0]||Na(n,r,r._start-r._delay),r=i;return Na(L,n,0),n},context:function context(t,e){return t?new De(t,e):l},matchMedia:function matchMedia(t){return new Ee(t)},matchMediaRefresh:function matchMediaRefresh(){return ke.forEach(function(t){var e,r,i=t.conditions;for(r in i)i[r]&&(i[r]=!1,e=1);e&&t.revert()})||Ic()},addEventListener:function addEventListener(t,e){var r=Oe[t]||(Oe[t]=[]);~r.indexOf(e)||r.push(e)},removeEventListener:function removeEventListener(t,e){var r=Oe[t],i=r&&r.indexOf(e);0<=i&&r.splice(i,1)},utils:{wrap:function wrap(e,t,r){var i=t-e;return $(e)?ob(e,wrap(0,e.length),t):Za(r,function(t){return(i+(t-e)%i)%i+e})},wrapYoyo:function wrapYoyo(e,t,r){var i=t-e,n=2*i;return $(e)?ob(e,wrapYoyo(0,e.length-1),t):Za(r,function(t){return e+(i<(t=(n+(t-e)%n)%n||0)?n-t:t)})},distribute:hb,random:kb,snap:jb,normalize:function normalize(t,e,r){return At(t,e,0,1,r)},getUnit:_a,clamp:function clamp(e,r,t){return Za(t,function(t){return Mt(e,r,t)})},splitColor:Db,toArray:Pt,selector:fb,mapRange:At,pipe:function pipe(){for(var t=arguments.length,e=new Array(t),r=0;r<t;r++)e[r]=arguments[r];return function(t){return e.reduce(function(t,e){return e(t)},t)}},unitize:function unitize(e,r){return function(t){return e(parseFloat(t))+(r||_a(t))}},interpolate:function interpolate(e,i,t,n){var a=isNaN(e+i)?0:function(t){return(1-t)*e+t*i};if(!a){var s,o,u,h,l,f=r(e),d={};if(!0===t&&(n=1)&&(t=null),f)e={p:e},i={p:i};else if($(e)&&!$(i)){for(u=[],h=e.length,l=h-2,o=1;o<h;o++)u.push(interpolate(e[o-1],e[o]));h--,a=function func(t){t*=h;var e=Math.min(l,~~t);return u[e](t-e)},t=i}else n||(e=bt($(e)?[]:{},e));if(!u){for(s in i)Jt.call(d,e,s,"get",i[s]);a=function func(t){return ve(t,d)||(f?e.p:e)}}}return Za(t,a)},shuffle:gb},install:R,effects:gt,ticker:It,updateRoot:Zt.updateRoot,plugins:mt,globalTimeline:L,core:{PropTween:we,globals:U,Tween:te,Timeline:Zt,Animation:qt,getCache:ha,_removeLinkedListItem:Ba,reverting:function reverting(){return I},context:function context(t){return t&&l&&(l.data.push(t),t._ctx=l),l},suppressOverwrites:function suppressOverwrites(t){return F=t}}};ja("to,from,fromTo,delayedCall,set,killTweensOf",function(t){return Re[t]=te[t]}),It.add(Zt.updateRoot),d=Re.to({},{duration:0});function Mc(t,e){for(var r=t._pt;r&&r.p!==e&&r.op!==e&&r.fp!==e;)r=r._next;return r}function Oc(t,a){return{name:t,headless:1,rawVars:1,init:function init(t,n,e){e._onInit=function(t){var e,i;if(r(n)&&(e={},ja(n,function(t){return e[t]=1}),n=e),a){for(i in e={},n)e[i]=a(n[i]);n=e}!function _addModifiers(t,e){var r,i,n,a=t._targets;for(r in e)for(i=a.length;i--;)(n=(n=t._ptLookup[i][r])&&n.d)&&(n._pt&&(n=Mc(n,r)),n&&n.modifier&&n.modifier(e[r],t,a[i],r))}(t,n)}}}}var Fe=Re.registerPlugin({name:"attr",init:function init(t,e,r,i,n){var a,s,o;for(a in this.tween=r,e)o=t.getAttribute(a)||"",(s=this.add(t,"setAttribute",(o||0)+"",e[a],i,n,0,0,a)).op=a,s.b=o,this._props.push(a)},render:function render(t,e){for(var r=e._pt;r;)I?r.set(r.t,r.p,r.b,r):r.r(t,r.d),r=r._next}},{name:"endArray",headless:1,init:function init(t,e){for(var r=e.length;r--;)this.add(t,r,t[r]||0,e[r],0,0,0,0,0,1)}},Oc("roundProps",ib),Oc("modifiers"),Oc("snap",jb))||Re;te.version=Zt.version=Fe.version="3.14.2",o=1,x()&&Lt();function yd(t,e){return e.set(e.t,e.p,Math.round(1e4*(e.s+e.c*t))/1e4+e.u,e)}function zd(t,e){return e.set(e.t,e.p,1===t?e.e:Math.round(1e4*(e.s+e.c*t))/1e4+e.u,e)}function Ad(t,e){return e.set(e.t,e.p,t?Math.round(1e4*(e.s+e.c*t))/1e4+e.u:e.b,e)}function Bd(t,e){return e.set(e.t,e.p,1===t?e.e:t?Math.round(1e4*(e.s+e.c*t))/1e4+e.u:e.b,e)}function Cd(t,e){var r=e.s+e.c*t;e.set(e.t,e.p,~~(r+(r<0?-.5:.5))+e.u,e)}function Dd(t,e){return e.set(e.t,e.p,t?e.e:e.b,e)}function Ed(t,e){return e.set(e.t,e.p,1!==t?e.b:e.e,e)}function Fd(t,e,r){return t.style[e]=r}function Gd(t,e,r){return t.style.setProperty(e,r)}function Hd(t,e,r){return t._gsap[e]=r}function Id(t,e,r){return t._gsap.scaleX=t._gsap.scaleY=r}function Jd(t,e,r,i,n){var a=t._gsap;a.scaleX=a.scaleY=r,a.renderTransform(n,a)}function Kd(t,e,r,i,n){var a=t._gsap;a[e]=r,a.renderTransform(n,a)}function Nd(t,e){var r=this,i=this.target,n=i.style,a=i._gsap;if(t in hr&&n){if(this.tfm=this.tfm||{},"transform"===t)return mr.transform.split(",").forEach(function(t){return Nd.call(r,t,e)});if(~(t=mr[t]||t).indexOf(",")?t.split(",").forEach(function(t){return r.tfm[t]=xr(i,t)}):this.tfm[t]=a.x?a[t]:xr(i,t),t===vr&&(this.tfm.zOrigin=a.zOrigin),0<=this.props.indexOf(gr))return;a.svg&&(this.svgo=i.getAttribute("data-svg-origin"),this.props.push(vr,e,"")),t=gr}(n||e)&&this.props.push(t,e,n[t])}function Od(t){t.translate&&(t.removeProperty("translate"),t.removeProperty("scale"),t.removeProperty("rotate"))}function Pd(){var t,e,r=this.props,i=this.target,n=i.style,a=i._gsap;for(t=0;t<r.length;t+=3)r[t+1]?2===r[t+1]?i[r[t]](r[t+2]):i[r[t]]=r[t+2]:r[t+2]?n[r[t]]=r[t+2]:n.removeProperty("--"===r[t].substr(0,2)?r[t]:r[t].replace(cr,"-$1").toLowerCase());if(this.tfm){for(e in this.tfm)a[e]=this.tfm[e];a.svg&&(a.renderTransform(),i.setAttribute("data-svg-origin",this.svgo||"")),(t=Ue())&&t.isStart||n[gr]||(Od(n),a.zOrigin&&n[vr]&&(n[vr]+=" "+a.zOrigin+"px",a.zOrigin=0,a.renderTransform()),a.uncache=1)}}function Qd(t,e){var r={target:t,props:[],revert:Pd,save:Nd};return t._gsap||Fe.core.getCache(t),e&&t.style&&t.nodeType&&e.split(",").forEach(function(t){return r.save(t)}),r}function Sd(t,e){var r=Le.createElementNS?Le.createElementNS((e||"http://www.w3.org/1999/xhtml").replace(/^https/,"http"),t):Le.createElement(t);return r&&r.style?r:Le.createElement(t)}function Td(t,e,r){var i=getComputedStyle(t);return i[e]||i.getPropertyValue(e.replace(cr,"-$1").toLowerCase())||i.getPropertyValue(e)||!r&&Td(t,Tr(e)||e,1)||""}function Wd(){(function _windowExists(){return"undefined"!=typeof window})()&&window.document&&(Ie=window,Le=Ie.document,Ye=Le.documentElement,je=Sd("div")||{style:{}},Sd("div"),gr=Tr(gr),vr=gr+"Origin",je.style.cssText="border-width:0;line-height:0;position:absolute;padding:0",Xe=!!Tr("perspective"),Ue=Fe.core.reverting,Ne=1)}function Xd(t){var e,r=t.ownerSVGElement,i=Sd("svg",r&&r.getAttribute("xmlns")||"http://www.w3.org/2000/svg"),n=t.cloneNode(!0);n.style.display="block",i.appendChild(n),Ye.appendChild(i);try{e=n.getBBox()}catch(t){}return i.removeChild(n),Ye.removeChild(i),e}function Yd(t,e){for(var r=e.length;r--;)if(t.hasAttribute(e[r]))return t.getAttribute(e[r])}function Zd(e){var r,i;try{r=e.getBBox()}catch(t){r=Xd(e),i=1}return r&&(r.width||r.height)||i||(r=Xd(e)),!r||r.width||r.x||r.y?r:{x:+Yd(e,["x","cx","x1"])||0,y:+Yd(e,["y","cy","y1"])||0,width:0,height:0}}function $d(t){return!(!t.getCTM||t.parentNode&&!t.ownerSVGElement||!Zd(t))}function _d(t,e){if(e){var r,i=t.style;e in hr&&e!==vr&&(e=gr),i.removeProperty?("ms"!==(r=e.substr(0,2))&&"webkit"!==e.substr(0,6)||(e="-"+e),i.removeProperty("--"===r?e:e.replace(cr,"-$1").toLowerCase())):i.removeAttribute(e)}}function ae(t,e,r,i,n,a){var s=new we(t._pt,e,r,0,1,a?Ed:Dd);return(t._pt=s).b=i,s.e=n,t._props.push(r),s}function de(t,e,r,i){var n,a,s,o,u=parseFloat(r)||0,h=(r+"").trim().substr((u+"").length)||"px",l=je.style,f=pr.test(e),d="svg"===t.tagName.toLowerCase(),c=(d?"client":"offset")+(f?"Width":"Height"),p="px"===i,_="%"===i;if(i===h||!u||br[i]||br[h])return u;if("px"===h||p||(u=de(t,e,r,"px")),o=t.getCTM&&$d(t),(_||"%"===h)&&(hr[e]||~e.indexOf("adius")))return n=o?t.getBBox()[f?"width":"height"]:t[c],ka(_?u/n*100:u/100*n);if(l[f?"width":"height"]=100+(p?h:i),a="rem"!==i&&~e.indexOf("adius")||"em"===i&&t.appendChild&&!d?t:t.parentNode,o&&(a=(t.ownerSVGElement||{}).parentNode),a&&a!==Le&&a.appendChild||(a=Le.body),(s=a._gsap)&&_&&s.width&&f&&s.time===It.time&&!s.uncache)return ka(u/s.width*100);if(!_||"height"!==e&&"width"!==e)!_&&"%"!==h||wr[Td(a,"display")]||(l.position=Td(t,"position")),a===t&&(l.position="static"),a.appendChild(je),n=je[c],a.removeChild(je),l.position="absolute";else{var m=t.style[e];t.style[e]=100+i,n=t[c],m?t.style[e]=m:_d(t,e)}return f&&_&&((s=ha(a)).time=It.time,s.width=a[c]),ka(p?n*u/100:n&&u?100/n*u:0)}function fe(t,e,r,i){if(!r||"none"===r){var n=Tr(e,t,1),a=n&&Td(t,n,1);a&&a!==r?(e=n,r=a):"borderColor"===e&&(r=Td(t,"borderTopColor"))}var s,o,u,h,l,f,d,c,p,_,m,g=new we(this._pt,t.style,e,0,1,ge),v=0,y=0;if(g.b=r,g.e=i,r+="","var(--"===(i+="").substring(0,6)&&(i=Td(t,i.substring(4,i.indexOf(")")))),"auto"===i&&(f=t.style[e],t.style[e]=i,i=Td(t,e)||i,f?t.style[e]=f:_d(t,e)),Ib(s=[r,i]),i=s[1],u=(r=s[0]).match(nt)||[],(i.match(nt)||[]).length){for(;o=nt.exec(i);)d=o[0],p=i.substring(v,o.index),l?l=(l+1)%5:"rgba("!==p.substr(-5)&&"hsla("!==p.substr(-5)||(l=1),d!==(f=u[y++]||"")&&(h=parseFloat(f)||0,m=f.substr((h+"").length),"="===d.charAt(1)&&(d=ma(h,d)+m),c=parseFloat(d),_=d.substr((c+"").length),v=nt.lastIndex-_.length,_||(_=_||N.units[e]||m,v===i.length&&(i+=_,g.e+=_)),m!==_&&(h=de(t,e,f,_)||0),g._pt={_next:g._pt,p:p||1===y?p:",",s:h,c:c-h,m:l&&l<4||"zIndex"===e?Math.round:0});g.c=v<i.length?i.substring(v,i.length):""}else g.r="display"===e&&"none"===i?Ed:Dd;return st.test(i)&&(g.e=0),this._pt=g}function he(t){var e=t.split(" "),r=e[0],i=e[1]||"50%";return"top"!==r&&"bottom"!==r&&"left"!==i&&"right"!==i||(t=r,r=i,i=t),e[0]=kr[r]||r,e[1]=kr[i]||i,e.join(" ")}function ie(t,e){if(e.tween&&e.tween._time===e.tween._dur){var r,i,n,a=e.t,s=a.style,o=e.u,u=a._gsap;if("all"===o||!0===o)s.cssText="",i=1;else for(n=(o=o.split(",")).length;-1<--n;)r=o[n],hr[r]&&(i=1,r="transformOrigin"===r?vr:gr),_d(a,r);i&&(_d(a,gr),u&&(u.svg&&a.removeAttribute("transform"),s.scale=s.rotate=s.translate="none",Pr(a,1),u.uncache=1,Od(s)))}}function me(t){return"matrix(1, 0, 0, 1, 0, 0)"===t||"none"===t||!t}function ne(t){var e=Td(t,gr);return me(e)?Mr:e.substr(7).match(it).map(ka)}function oe(t,e){var r,i,n,a,s=t._gsap||ha(t),o=t.style,u=ne(t);return s.svg&&t.getAttribute("transform")?"1,0,0,1,0,0"===(u=[(n=t.transform.baseVal.consolidate().matrix).a,n.b,n.c,n.d,n.e,n.f]).join(",")?Mr:u:(u!==Mr||t.offsetParent||t===Ye||s.svg||(n=o.display,o.display="block",(r=t.parentNode)&&(t.offsetParent||t.getBoundingClientRect().width)||(a=1,i=t.nextElementSibling,Ye.appendChild(t)),u=ne(t),n?o.display=n:_d(t,"display"),a&&(i?r.insertBefore(t,i):r?r.appendChild(t):Ye.removeChild(t))),e&&6<u.length?[u[0],u[1],u[4],u[5],u[12],u[13]]:u)}function pe(t,e,r,i,n,a){var s,o,u,h=t._gsap,l=n||oe(t,!0),f=h.xOrigin||0,d=h.yOrigin||0,c=h.xOffset||0,p=h.yOffset||0,_=l[0],m=l[1],g=l[2],v=l[3],y=l[4],T=l[5],b=e.split(" "),w=parseFloat(b[0])||0,x=parseFloat(b[1])||0;r?l!==Mr&&(o=_*v-m*g)&&(u=w*(-m/o)+x*(_/o)-(_*T-m*y)/o,w=w*(v/o)+x*(-g/o)+(g*T-v*y)/o,x=u):(w=(s=Zd(t)).x+(~b[0].indexOf("%")?w/100*s.width:w),x=s.y+(~(b[1]||b[0]).indexOf("%")?x/100*s.height:x)),i||!1!==i&&h.smooth?(y=w-f,T=x-d,h.xOffset=c+(y*_+T*g)-y,h.yOffset=p+(y*m+T*v)-T):h.xOffset=h.yOffset=0,h.xOrigin=w,h.yOrigin=x,h.smooth=!!i,h.origin=e,h.originIsAbsolute=!!r,t.style[vr]="0px 0px",a&&(ae(a,h,"xOrigin",f,w),ae(a,h,"yOrigin",d,x),ae(a,h,"xOffset",c,h.xOffset),ae(a,h,"yOffset",p,h.yOffset)),t.setAttribute("data-svg-origin",w+" "+x)}function se(t,e,r){var i=_a(e);return ka(parseFloat(e)+parseFloat(de(t,"x",r+"px",i)))+i}function ze(t,e,i,n,a){var s,o,u=360,h=r(a),l=parseFloat(a)*(h&&~a.indexOf("rad")?lr:1)-n,f=n+l+"deg";return h&&("short"===(s=a.split("_")[1])&&(l%=u)!==l%180&&(l+=l<0?u:-u),"cw"===s&&l<0?l=(l+36e9)%u-~~(l/u)*u:"ccw"===s&&0<l&&(l=(l-36e9)%u-~~(l/u)*u)),t._pt=o=new we(t._pt,e,i,n,l,zd),o.e=f,o.u="deg",t._props.push(i),o}function Ae(t,e){for(var r in e)t[r]=e[r];return t}function Be(t,e,r){var i,n,a,s,o,u,h,l=Ae({},r._gsap),f=r.style;for(n in l.svg?(a=r.getAttribute("transform"),r.setAttribute("transform",""),f[gr]=e,i=Pr(r,1),_d(r,gr),r.setAttribute("transform",a)):(a=getComputedStyle(r)[gr],f[gr]=e,i=Pr(r,1),f[gr]=a),hr)(a=l[n])!==(s=i[n])&&"perspective,force3D,transformOrigin,svgOrigin".indexOf(n)<0&&(o=_a(a)!==(h=_a(s))?de(r,n,a,h):parseFloat(a),u=parseFloat(s),t._pt=new we(t._pt,i,n,o,u-o,yd),t._pt.u=h||0,t._props.push(n));Ae(i,l)}var Ie,Le,Ye,Ne,je,Ve,Ue,Xe,qe=Bt.Power0,Ze=Bt.Power1,We=Bt.Power2,He=Bt.Power3,Je=Bt.Power4,Qe=Bt.Linear,Ge=Bt.Quad,Ke=Bt.Cubic,$e=Bt.Quart,tr=Bt.Quint,er=Bt.Strong,rr=Bt.Elastic,ir=Bt.Back,nr=Bt.SteppedEase,ar=Bt.Bounce,sr=Bt.Sine,or=Bt.Expo,ur=Bt.Circ,hr={},lr=180/Math.PI,fr=Math.PI/180,dr=Math.atan2,cr=/([A-Z])/g,pr=/(left|right|width|margin|padding|x)/i,_r=/[\s,\(]\S/,mr={autoAlpha:"opacity,visibility",scale:"scaleX,scaleY",alpha:"opacity"},gr="transform",vr=gr+"Origin",yr="O,Moz,ms,Ms,Webkit".split(","),Tr=function _checkPropPrefix(t,e,r){var i=(e||je).style,n=5;if(t in i&&!r)return t;for(t=t.charAt(0).toUpperCase()+t.substr(1);n--&&!(yr[n]+t in i););return n<0?null:(3===n?"ms":0<=n?yr[n]:"")+t},br={deg:1,rad:1,turn:1},wr={grid:1,flex:1},xr=function _get(t,e,r,i){var n;return Ne||Wd(),e in mr&&"transform"!==e&&~(e=mr[e]).indexOf(",")&&(e=e.split(",")[0]),hr[e]&&"transform"!==e?(n=Pr(t,i),n="transformOrigin"!==e?n[e]:n.svg?n.origin:Ar(Td(t,vr))+" "+n.zOrigin+"px"):(n=t.style[e])&&"auto"!==n&&!i&&!~(n+"").indexOf("calc(")||(n=Or[e]&&Or[e](t,e,r)||Td(t,e)||ia(t,e)||("opacity"===e?1:0)),r&&!~(n+"").trim().indexOf(" ")?de(t,e,n,r)+r:n},kr={top:"0%",bottom:"100%",left:"0%",right:"100%",center:"50%"},Or={clearProps:function clearProps(t,e,r,i,n){if("isFromStart"!==n.data){var a=t._pt=new we(t._pt,e,r,0,0,ie);return a.u=i,a.pr=-10,a.tween=n,t._props.push(r),1}}},Mr=[1,0,0,1,0,0],Cr={},Pr=function _parseTransform(t,e){var r=t._gsap||new Xt(t);if("x"in r&&!e&&!r.uncache)return r;var i,n,a,s,o,u,h,l,f,d,c,p,_,m,g,v,y,T,b,w,x,k,O,M,C,P,A,D,S,z,E,R,F=t.style,I=r.scaleX<0,L="deg",B=getComputedStyle(t),Y=Td(t,vr)||"0";return i=n=a=u=h=l=f=d=c=0,s=o=1,r.svg=!(!t.getCTM||!$d(t)),B.translate&&("none"===B.translate&&"none"===B.scale&&"none"===B.rotate||(F[gr]=("none"!==B.translate?"translate3d("+(B.translate+" 0 0").split(" ").slice(0,3).join(", ")+") ":"")+("none"!==B.rotate?"rotate("+B.rotate+") ":"")+("none"!==B.scale?"scale("+B.scale.split(" ").join(",")+") ":"")+("none"!==B[gr]?B[gr]:"")),F.scale=F.rotate=F.translate="none"),m=oe(t,r.svg),r.svg&&(M=r.uncache?(C=t.getBBox(),Y=r.xOrigin-C.x+"px "+(r.yOrigin-C.y)+"px",""):!e&&t.getAttribute("data-svg-origin"),pe(t,M||Y,!!M||r.originIsAbsolute,!1!==r.smooth,m)),p=r.xOrigin||0,_=r.yOrigin||0,m!==Mr&&(T=m[0],b=m[1],w=m[2],x=m[3],i=k=m[4],n=O=m[5],6===m.length?(s=Math.sqrt(T*T+b*b),o=Math.sqrt(x*x+w*w),u=T||b?dr(b,T)*lr:0,(f=w||x?dr(w,x)*lr+u:0)&&(o*=Math.abs(Math.cos(f*fr))),r.svg&&(i-=p-(p*T+_*w),n-=_-(p*b+_*x))):(R=m[6],z=m[7],A=m[8],D=m[9],S=m[10],E=m[11],i=m[12],n=m[13],a=m[14],h=(g=dr(R,S))*lr,g&&(M=k*(v=Math.cos(-g))+A*(y=Math.sin(-g)),C=O*v+D*y,P=R*v+S*y,A=k*-y+A*v,D=O*-y+D*v,S=R*-y+S*v,E=z*-y+E*v,k=M,O=C,R=P),l=(g=dr(-w,S))*lr,g&&(v=Math.cos(-g),E=x*(y=Math.sin(-g))+E*v,T=M=T*v-A*y,b=C=b*v-D*y,w=P=w*v-S*y),u=(g=dr(b,T))*lr,g&&(M=T*(v=Math.cos(g))+b*(y=Math.sin(g)),C=k*v+O*y,b=b*v-T*y,O=O*v-k*y,T=M,k=C),h&&359.9<Math.abs(h)+Math.abs(u)&&(h=u=0,l=180-l),s=ka(Math.sqrt(T*T+b*b+w*w)),o=ka(Math.sqrt(O*O+R*R)),g=dr(k,O),f=2e-4<Math.abs(g)?g*lr:0,c=E?1/(E<0?-E:E):0),r.svg&&(M=t.getAttribute("transform"),r.forceCSS=t.setAttribute("transform","")||!me(Td(t,gr)),M&&t.setAttribute("transform",M))),90<Math.abs(f)&&Math.abs(f)<270&&(I?(s*=-1,f+=u<=0?180:-180,u+=u<=0?180:-180):(o*=-1,f+=f<=0?180:-180)),e=e||r.uncache,r.x=i-((r.xPercent=i&&(!e&&r.xPercent||(Math.round(t.offsetWidth/2)===Math.round(-i)?-50:0)))?t.offsetWidth*r.xPercent/100:0)+"px",r.y=n-((r.yPercent=n&&(!e&&r.yPercent||(Math.round(t.offsetHeight/2)===Math.round(-n)?-50:0)))?t.offsetHeight*r.yPercent/100:0)+"px",r.z=a+"px",r.scaleX=ka(s),r.scaleY=ka(o),r.rotation=ka(u)+L,r.rotationX=ka(h)+L,r.rotationY=ka(l)+L,r.skewX=f+L,r.skewY=d+L,r.transformPerspective=c+"px",(r.zOrigin=parseFloat(Y.split(" ")[2])||!e&&r.zOrigin||0)&&(F[vr]=Ar(Y)),r.xOffset=r.yOffset=0,r.force3D=N.force3D,r.renderTransform=r.svg?Fr:Xe?Rr:Dr,r.uncache=0,r},Ar=function _firstTwoOnly(t){return(t=t.split(" "))[0]+" "+t[1]},Dr=function _renderNon3DTransforms(t,e){e.z="0px",e.rotationY=e.rotationX="0deg",e.force3D=0,Rr(t,e)},Sr="0deg",zr="0px",Er=") ",Rr=function _renderCSSTransforms(t,e){var r=e||this,i=r.xPercent,n=r.yPercent,a=r.x,s=r.y,o=r.z,u=r.rotation,h=r.rotationY,l=r.rotationX,f=r.skewX,d=r.skewY,c=r.scaleX,p=r.scaleY,_=r.transformPerspective,m=r.force3D,g=r.target,v=r.zOrigin,y="",T="auto"===m&&t&&1!==t||!0===m;if(v&&(l!==Sr||h!==Sr)){var b,w=parseFloat(h)*fr,x=Math.sin(w),k=Math.cos(w);w=parseFloat(l)*fr,b=Math.cos(w),a=se(g,a,x*b*-v),s=se(g,s,-Math.sin(w)*-v),o=se(g,o,k*b*-v+v)}_!==zr&&(y+="perspective("+_+Er),(i||n)&&(y+="translate("+i+"%, "+n+"%) "),!T&&a===zr&&s===zr&&o===zr||(y+=o!==zr||T?"translate3d("+a+", "+s+", "+o+") ":"translate("+a+", "+s+Er),u!==Sr&&(y+="rotate("+u+Er),h!==Sr&&(y+="rotateY("+h+Er),l!==Sr&&(y+="rotateX("+l+Er),f===Sr&&d===Sr||(y+="skew("+f+", "+d+Er),1===c&&1===p||(y+="scale("+c+", "+p+Er),g.style[gr]=y||"translate(0, 0)"},Fr=function _renderSVGTransforms(t,e){var r,i,n,a,s,o=e||this,u=o.xPercent,h=o.yPercent,l=o.x,f=o.y,d=o.rotation,c=o.skewX,p=o.skewY,_=o.scaleX,m=o.scaleY,g=o.target,v=o.xOrigin,y=o.yOrigin,T=o.xOffset,b=o.yOffset,w=o.forceCSS,x=parseFloat(l),k=parseFloat(f);d=parseFloat(d),c=parseFloat(c),(p=parseFloat(p))&&(c+=p=parseFloat(p),d+=p),d||c?(d*=fr,c*=fr,r=Math.cos(d)*_,i=Math.sin(d)*_,n=Math.sin(d-c)*-m,a=Math.cos(d-c)*m,c&&(p*=fr,s=Math.tan(c-p),n*=s=Math.sqrt(1+s*s),a*=s,p&&(s=Math.tan(p),r*=s=Math.sqrt(1+s*s),i*=s)),r=ka(r),i=ka(i),n=ka(n),a=ka(a)):(r=_,a=m,i=n=0),(x&&!~(l+"").indexOf("px")||k&&!~(f+"").indexOf("px"))&&(x=de(g,"x",l,"px"),k=de(g,"y",f,"px")),(v||y||T||b)&&(x=ka(x+v-(v*r+y*n)+T),k=ka(k+y-(v*i+y*a)+b)),(u||h)&&(s=g.getBBox(),x=ka(x+u/100*s.width),k=ka(k+h/100*s.height)),s="matrix("+r+","+i+","+n+","+a+","+x+","+k+")",g.setAttribute("transform",s),w&&(g.style[gr]=s)};ja("padding,margin,Width,Radius",function(e,r){var t="Right",i="Bottom",n="Left",o=(r<3?["Top",t,i,n]:["Top"+n,"Top"+t,i+t,i+n]).map(function(t){return r<2?e+t:"border"+t+e});Or[1<r?"border"+e:e]=function(e,t,r,i,n){var a,s;if(arguments.length<4)return a=o.map(function(t){return xr(e,t,r)}),5===(s=a.join(" ")).split(a[0]).length?a[0]:s;a=(i+"").split(" "),s={},o.forEach(function(t,e){return s[t]=a[e]=a[e]||a[(e-1)/2|0]}),e.init(t,s,n)}});var Ir,Lr,Br,Yr={name:"css",register:Wd,targetTest:function targetTest(t){return t.style&&t.nodeType},init:function init(t,e,i,n,a){var s,o,u,h,l,f,d,c,p,_,m,g,v,y,T,b,w,x=this._props,k=t.style,O=i.vars.startAt;for(d in Ne||Wd(),this.styles=this.styles||Qd(t),b=this.styles.props,this.tween=i,e)if("autoRound"!==d&&(o=e[d],!mt[d]||!dc(d,e,i,n,t,a)))if(l=typeof o,f=Or[d],"function"===l&&(l=typeof(o=o.call(i,n,t,a))),"string"===l&&~o.indexOf("random(")&&(o=rb(o)),f)f(this,t,d,o,i)&&(T=1);else if("--"===d.substr(0,2))s=(getComputedStyle(t).getPropertyValue(d)+"").trim(),o+="",Rt.lastIndex=0,Rt.test(s)||(c=_a(s),(p=_a(o))?c!==p&&(s=de(t,d,s,p)+p):c&&(o+=c)),this.add(k,"setProperty",s,o,n,a,0,0,d),x.push(d),b.push(d,0,k[d]);else if("undefined"!==l){if(O&&d in O?(s="function"==typeof O[d]?O[d].call(i,n,t,a):O[d],r(s)&&~s.indexOf("random(")&&(s=rb(s)),_a(s+"")||"auto"===s||(s+=N.units[d]||_a(xr(t,d))||""),"="===(s+"").charAt(1)&&(s=xr(t,d))):s=xr(t,d),h=parseFloat(s),(_="string"===l&&"="===o.charAt(1)&&o.substr(0,2))&&(o=o.substr(2)),u=parseFloat(o),d in mr&&("autoAlpha"===d&&(1===h&&"hidden"===xr(t,"visibility")&&u&&(h=0),b.push("visibility",0,k.visibility),ae(this,k,"visibility",h?"inherit":"hidden",u?"inherit":"hidden",!u)),"scale"!==d&&"transform"!==d&&~(d=mr[d]).indexOf(",")&&(d=d.split(",")[0])),m=d in hr){if(this.styles.save(d),w=o,"string"===l&&"var(--"===o.substring(0,6)){if("calc("===(o=Td(t,o.substring(4,o.indexOf(")")))).substring(0,5)){var M=t.style.perspective;t.style.perspective=o,o=Td(t,"perspective"),M?t.style.perspective=M:_d(t,"perspective")}u=parseFloat(o)}if(g||((v=t._gsap).renderTransform&&!e.parseTransform||Pr(t,e.parseTransform),y=!1!==e.smoothOrigin&&v.smooth,(g=this._pt=new we(this._pt,k,gr,0,1,v.renderTransform,v,0,-1)).dep=1),"scale"===d)this._pt=new we(this._pt,v,"scaleY",v.scaleY,(_?ma(v.scaleY,_+u):u)-v.scaleY||0,yd),this._pt.u=0,x.push("scaleY",d),d+="X";else{if("transformOrigin"===d){b.push(vr,0,k[vr]),o=he(o),v.svg?pe(t,o,0,y,0,this):((p=parseFloat(o.split(" ")[2])||0)!==v.zOrigin&&ae(this,v,"zOrigin",v.zOrigin,p),ae(this,k,d,Ar(s),Ar(o)));continue}if("svgOrigin"===d){pe(t,o,1,y,0,this);continue}if(d in Cr){ze(this,v,d,h,_?ma(h,_+o):o);continue}if("smoothOrigin"===d){ae(this,v,"smooth",v.smooth,o);continue}if("force3D"===d){v[d]=o;continue}if("transform"===d){Be(this,o,t);continue}}}else d in k||(d=Tr(d)||d);if(m||(u||0===u)&&(h||0===h)&&!_r.test(o)&&d in k)u=u||0,(c=(s+"").substr((h+"").length))!==(p=_a(o)||(d in N.units?N.units[d]:c))&&(h=de(t,d,s,p)),this._pt=new we(this._pt,m?v:k,d,h,(_?ma(h,_+u):u)-h,m||"px"!==p&&"zIndex"!==d||!1===e.autoRound?yd:Cd),this._pt.u=p||0,m&&w!==o?(this._pt.b=s,this._pt.e=w,this._pt.r=Bd):c!==p&&"%"!==p&&(this._pt.b=s,this._pt.r=Ad);else if(d in k)fe.call(this,t,d,s,_?_+o:o);else if(d in t)this.add(t,d,s||t[d],_?_+o:o,n,a);else if("parseTransform"!==d){S(d,o);continue}m||(d in k?b.push(d,0,k[d]):"function"==typeof t[d]?b.push(d,2,t[d]()):b.push(d,1,s||t[d])),x.push(d)}T&&be(this)},render:function render(t,e){if(e.tween._time||!Ue())for(var r=e._pt;r;)r.r(t,r.d),r=r._next;else e.styles.revert()},get:xr,aliases:mr,getSetter:function getSetter(t,e,r){var i=mr[e];return i&&i.indexOf(",")<0&&(e=i),e in hr&&e!==vr&&(t._gsap.x||xr(t,"x"))?r&&Ve===r?"scale"===e?Id:Hd:(Ve=r||{})&&("scale"===e?Jd:Kd):t.style&&!u(t.style[e])?Fd:~e.indexOf("-")?Gd:le(t,e)},core:{_removeProperty:_d,_getMatrix:oe}};Fe.utils.checkPrefix=Tr,Fe.core.getStyleSaver=Qd,Br=ja((Ir="x,y,z,scale,scaleX,scaleY,xPercent,yPercent")+","+(Lr="rotation,rotationX,rotationY,skewX,skewY")+",transform,transformOrigin,svgOrigin,force3D,smoothOrigin,transformPerspective",function(t){hr[t]=1}),ja(Lr,function(t){N.units[t]="deg",Cr[t]=1}),mr[Br[13]]=Ir+","+Lr,ja("0:translateX,1:translateY,2:translateZ,8:rotate,8:rotationZ,8:rotateZ,9:rotateX,10:rotateY",function(t){var e=t.split(":");mr[e[1]]=Br[e[0]]}),ja("x,y,z,top,right,bottom,left,width,height,fontSize,padding,margin,perspective",function(t){N.units[t]="px"}),Fe.registerPlugin(Yr);var Nr=Fe.registerPlugin(Yr)||Fe,jr=Nr.core.Tween;e.Back=ir,e.Bounce=ar,e.CSSPlugin=Yr,e.Circ=ur,e.Cubic=Ke,e.Elastic=rr,e.Expo=or,e.Linear=Qe,e.Power0=qe,e.Power1=Ze,e.Power2=We,e.Power3=He,e.Power4=Je,e.Quad=Ge,e.Quart=$e,e.Quint=tr,e.Sine=sr,e.SteppedEase=nr,e.Strong=er,e.TimelineLite=Zt,e.TimelineMax=Zt,e.TweenLite=te,e.TweenMax=jr,e.default=Nr,e.gsap=Nr;if (typeof(window)==="undefined"||window!==e){Object.defineProperty(e,"__esModule",{value:!0})} else {delete e.default}});
+
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
@@ -51,6 +51,18 @@
// --preview <s> <d> Rail-only, over a <d>-second window starting at <s>
// --thumbnail A brand preset's thumbnail (manifest.thumbnail) and stop
//
+// The deck (`render.chrome`, see plans/onscreen-deck.md): a full build writes
+// out/<variant>/schedule.json, composes and renders the deck (compose-chrome,
+// cached by key) and overlays it in the concat -- one command.
+// --chrome-only Re-lay the deck over the segments already on disk: re-probe,
+// rewrite the schedule, recompose (re-render only when the key
+// changed), re-concat with the overlay, re-mux the chapters.
+// No segment is rebuilt, nothing is fetched.
+// --no-chrome The deck's framing without the overlay (a fast picture check)
+// --chrome-preview <at> <dur> Render only that window of the deck and write
+// out/<variant>/<slug>.preview.mp4 of it, from the cached
+// concat when there is one, else from the segments it touches
+//
// Requires: yt-dlp, ffmpeg/ffprobe, ImageMagick with Pango.
import { execFile } from "node:child_process";
@@ -64,6 +76,12 @@ import {
cardWidth, contentWidth, reservedFooterHeight,
} from "./render-cards.mjs";
import { createCueSource, siteOriginFromManifest } from "./cues.mjs";
+// The deck (`render.chrome`): its geometry, validation and schedule are pure
+// and live in deck.mjs. This file only frames segments into its box and writes
+// the schedule down -- it never has a copy of the arithmetic.
+import {
+ assertChrome, deckGeometry, deckOn, deckSchedule, frameCount, resolveDeck, scheduleFrom,
+} from "./deck.mjs";
// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table,
// in common, plain JS so bare `node` can load it.
import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs";
@@ -195,6 +213,41 @@ export function headerFilters(render, attribPath, channelPath = null) {
}
/**
+ * The deck's framing, as two runs of filters.
+ *
+ * `fit` scales a picture into the footage box (`deckGeometry().footage`),
+ * keeping its aspect, and pads it to the box in the palette background. `place`
+ * pads the box out to the whole frame at the box's own origin, so the bottom
+ * `deck.height` rows -- where the composition is overlaid -- are plain ground,
+ * then ends the way every segment ends: `setsar=1` and the cut's fps. xfade
+ * refuses a link whose parameters differ from its neighbour's, and that would
+ * surface only at concat time, after every fetch has been paid for.
+ *
+ * Two runs, not one string, because a still lays itself out INTO the box (a
+ * row of panels, a crawl) and only needs the second half.
+ *
+ * Pure, and exported, so the numbers can be tested without an encoder.
+ */
+export function deckFraming(render) {
+ const { W, H, footage: f } = deckGeometry(render);
+ const bg = render.palette.bg;
+ return {
+ box: f,
+ fit: [
+ `scale=${f.width}:${f.height}:force_original_aspect_ratio=decrease`,
+ `pad=${f.width}:${f.height}:(ow-iw)/2:(oh-ih)/2:color=${bg}`,
+ ],
+ place: [`pad=${W}:${H}:${f.x}:${f.y}:color=${bg}`, "setsar=1", `fps=${render.fps}`],
+ };
+}
+
+/** `deckFraming`'s two runs joined: a whole picture into the box, then the frame. */
+export function deckFramingFilter(render) {
+ const { fit, place } = deckFraming(render);
+ return [...fit, ...place].join(",");
+}
+
+/**
* Where a variant's own working files live.
*
* `clips-raw` stays at the ROOT and is shared: it holds the only expensive
@@ -222,7 +275,7 @@ export function variantPaths(outRoot, slug, variant) {
// printed -- this is a formatting switch, not new instrumentation.
//
// Events: start, card, clip, fetch, snap, segment, entry-failed, concat,
-// chapters, note, done.
+// chapters, chrome, note, done.
const HUMAN = {
start: (e) => `${e.title} — ${e.entries} entr(ies)`,
card: (e) => `card ${e.id}`,
@@ -240,6 +293,16 @@ const HUMAN = {
"entry-failed": (e) => ` ** ${e.id} failed: ${e.message}`,
concat: (e) => `${e.mode === "xfade" ? "crossfading" : "hard-cutting"} ${e.n} segments…`,
chapters: (e) => `chapters: ${e.n} marker(s) -> ${e.file}`,
+ // The deck's steps. `phase` is schedule | compose | render | cached | overlay.
+ chrome: (e) =>
+ `chrome ${e.phase}` +
+ (e.segments !== undefined ? `: ${e.segments} segment(s)` : "") +
+ (e.total !== undefined ? `, ${Number(e.total).toFixed(3)}s` : "") +
+ (e.duration !== undefined ? ` window ${e.from}s +${e.duration}s` : "") +
+ (e.frames !== undefined ? `: ${e.frames} frame(s)` : "") +
+ (e.seconds !== undefined ? ` in ${e.seconds}s` : "") +
+ (e.key ? ` (key ${String(e.key).slice(0, 12)})` : "") +
+ (e.base ? ` over ${e.base}` : ""),
note: (e) => e.message,
done: (e) =>
// A run that produced no file still emits `done` -- a consumer of the
@@ -287,7 +350,11 @@ function wrap(text, cols) {
// The published shard record carries the same fields as a local cue file, so this
// reads identically whichever source answered.
-async function videoMeta(videoId, channelSlug, hints = {}) {
+//
+// Exported for the deck's schedule writer and for umtool; it reads through the
+// cue source buildVideo() builds, so it answers only inside a build.
+export async function videoMeta(videoId, channelSlug, hints = {}) {
+ if (!CUES) throw new Error("videoMeta: no cue source — it is set up by buildVideo()");
const d = await CUES.load(channelSlug, videoId, hints);
// `channel` is the uploader's DISPLAY name, and it heads the attribution
// line. It costs nothing to carry: both sources -- a local
@@ -700,6 +767,28 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes,
const channelPath = who ? path.join(outDir, "segments", `${entry.id}.channel.txt`) : null;
if (channelPath) await writeFile(channelPath, who, "utf8");
+ // THE DECK. The citation header, the corner QR and the section footer are
+ // all things the deck says instead -- the source and date in its subtitle,
+ // the code in its QR, the position in its pips -- so none of them is drawn,
+ // and the segment is the picture alone, framed into the box above the deck.
+ // The composition is overlaid on the whole concat later; nothing here knows
+ // about it. Same cut, same audio map, same encode as every other segment.
+ if (deckOn(render)) {
+ await execFileP(
+ FFMPEG,
+ [
+ "-nostdin", "-v", "error", "-y",
+ ...cutArgs(raw, cutA, cutB),
+ "-filter_complex", `[0:v]${deckFramingFilter(render)}[v]`,
+ "-map", "[v]", "-map", "0:a",
+ ...encodeArgs(render),
+ seg,
+ ],
+ { maxBuffer: 1 << 24 },
+ );
+ return seg;
+ }
+
// The picture is the point. Nothing is drawn over it: the video is letterboxed
// between a thin citation header and a thin timeline footer, so the source
// material plays unobstructed and the additions stay subtle.
@@ -864,6 +953,13 @@ async function buildCardSegment(card, render, outDir, nodes) {
const png = await renderCard(card, render, outDir, nodes);
const seg = path.join(outDir, "segments", `${card.id}.mp4`);
const dur = String(card.seconds);
+ // Under the deck a card is either full frame -- `overCards: "hide"`, the
+ // deck slides away over it, and the card encodes exactly as it always has --
+ // or framed into the footage box like a clip, so the deck can stay up over
+ // it without covering its bottom rows.
+ const vf = deckOn(render) && resolveDeck(render).overCards === "show"
+ ? deckFramingFilter(render)
+ : `fps=${render.fps},setsar=1`;
await execFileP(
FFMPEG,
@@ -875,7 +971,7 @@ async function buildCardSegment(card, render, outDir, nodes) {
"-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", png,
"-f", "lavfi", "-t", dur,
"-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`,
- "-vf", `fps=${render.fps},setsar=1`,
+ "-vf", vf,
...encodeArgs(render),
"-shortest",
seg,
@@ -939,10 +1035,14 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
throw new Error(`${entry.id}: seconds must be a positive number, got ${entry.seconds}`);
}
+ // Under the deck the picture area is the deck's footage box -- the box a clip
+ // is framed into, so a still between two clips does not move either -- and
+ // there is no header, footer or corner code: the deck carries all three.
+ const deck = deckOn(render) ? deckFraming(render) : null;
const HH = render.headerHeight ?? 56;
- const VW = contentWidth(render);
+ const VW = deck ? deck.box.width : contentWidth(render);
const FH = chrome.footerHeight;
- const VH = height - HH - FH;
+ const VH = deck ? deck.box.height : height - HH - FH;
// Each picture carries its OWN redactions and crop, measured in its own
// source pixels -- a panel's boxes were taken off that file in an image
@@ -981,7 +1081,7 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
// the entry alone. Nothing to say means no header at all: drawtext refuses an
// empty textfile outright.
const line = imageAttributionLine(entry);
- const hasHeader = HH > 0 && line.length > 0;
+ const hasHeader = !deck && HH > 0 && line.length > 0;
const attribPath = path.join(outDir, "segments", `${entry.id}.attrib.txt`);
if (hasHeader) await writeFile(attribPath, line, "utf8");
@@ -1036,20 +1136,25 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
];
}
- const base = [
- ...head,
- // Widen back to the full frame, leaving the rail column (if any) as ground.
- `pad=${width}:${VH}:0:0:color=${pal.bg}`,
- `pad=${width}:${height}:0:${HH}:color=${pal.bg}`,
- "setsar=1",
- `fps=${render.fps}`,
- ...(hasHeader ? headerFilters(render, attribPath) : []),
- ].join(",");
+ const base = (
+ deck
+ ? [...head, ...deck.place]
+ : [
+ ...head,
+ // Widen back to the full frame, leaving the rail column (if any) as ground.
+ `pad=${width}:${VH}:0:0:color=${pal.bg}`,
+ `pad=${width}:${height}:0:${HH}:color=${pal.bg}`,
+ "setsar=1",
+ `fps=${render.fps}`,
+ ...(hasHeader ? headerFilters(render, attribPath) : []),
+ ]
+ ).join(",");
// `qrForEntry` already prefers `citeUrl`; the guard is that we never reach it
- // without one, so no still can be given a derived code.
+ // without one, so no still can be given a derived code. Under the deck the
+ // deck shows a still's code (the same `citeUrl` rule, deckQrUrl).
const qr =
- render.qr === false || render.rail || !entry.citeUrl
+ deck || render.qr === false || render.rail || !entry.citeUrl
? null
: await qrForEntry(entry, provenance, render, outDir);
const qrM = render.qr?.margin ?? 28;
@@ -1608,7 +1713,20 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
const parts = [];
let lab = inLabel;
regions.forEach((r, i) => {
+ // The deck's sequence is MIXED: HyperFrames writes a frame with nothing
+ // transparent in it as RGB and every other frame as RGBA, so the decoded
+ // stream changes pixel format part-way through. By default ffmpeg answers
+ // that by REINITIALISING THE WHOLE filtergraph -- every xfade with it --
+ // which drops what was buffered and ends the output early (measured: a
+ // 3.5 s xfade+overlay came out 2.0 s). `-reinit_filter 0` keeps the graph
+ // and converts the odd frames instead, and `format=rgba` straight after
+ // the input pins the overlay's secondary to one format with alpha
+ // whichever kind of frame comes first. The `format` filter alone does not
+ // stop the reinit. The deck only: the chart band's chain and inputs stay
+ // byte-for-byte as they shipped.
+ const deck = r.name === "deck";
inputs.push(
+ ...(deck ? ["-reinit_filter", "0"] : []),
"-framerate", String(render.fps),
"-start_number", "1",
"-i", path.join(r.frames, "frame_%06d.png"),
@@ -1616,7 +1734,12 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
const idx = firstInputIdx + i;
const last = i === regions.length - 1;
const out = last && !final ? outLabel : `[hf${i}]`;
- parts.push(`${lab}[${idx}:v]overlay=x=${r.x}:y=${r.y}:format=yuv444:shortest=1${out}`);
+ let src = `[${idx}:v]`;
+ if (deck) {
+ parts.push(`${src}format=rgba[hfa${i}]`);
+ src = `[hfa${i}]`;
+ }
+ parts.push(`${lab}${src}overlay=x=${r.x}:y=${r.y}:format=yuv444:shortest=1${out}`);
lab = out;
});
if (final) parts.push(`${lab}format=yuv420p[vout]`);
@@ -1635,8 +1758,16 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
* track only moved at section handovers, which is precisely the fault the band
* exists to fix. So it takes the footer's ground and 100px more of it, and the
* picture loses that height.
+ *
+ * The deck is one region, full width, at the bottom of the frame -- where its
+ * framing left plain ground (`deckGeometry().deck`). It replaces the chart
+ * band's branch rather than joining it: the deck refuses `chromeEngine`.
*/
export function chromeRegions(render, outDir) {
+ if (deckOn(render)) {
+ const { deck } = deckGeometry(render);
+ return [{ name: "deck", frames: path.join(outDir, "chrome", "deck-frames"), ...deck }];
+ }
const H = render.chart?.height ?? 200;
return [
{
@@ -1669,6 +1800,22 @@ function reservedFooter(render) {
}
/**
+ * The footer's stand-in under the deck: none at all.
+ *
+ * The deck draws the position in the cut itself (its pips), and the segments
+ * frame into the deck's own box rather than letterboxing above a footer, so
+ * nothing reserves the footer's rows -- `footerHeight: 0` -- and nothing of the
+ * ffmpeg footer is rendered. The shape is reservedFooter()'s, and the one
+ * renderFooterAssets returns for a manifest with no nodes.
+ */
+function deckFooter() {
+ return {
+ footer: null, marker: null, bar: null, trackLen: 0,
+ footerHeight: 0, trackY: 0, xs: [], x0: 0, markerRadius: 0,
+ };
+}
+
+/**
* Everything the rail chain needs that depends on the built segments. Returns
* null when the manifest does not ask for a rail — which is what keeps this
* whole feature opt-in and every existing report byte-for-byte unchanged.
@@ -2021,6 +2168,167 @@ async function applyRail(inPath, outPath, render, railPlan, preview) {
);
}
+// ---- the deck's overlay ----------------------------------------------------
+// The deck (`render.chrome`) is ONE rendered composition over the whole cut.
+// With a transition it rides the crossfade's own encode (concatWithXfade's
+// `chrome` argument); with hard cuts the concat is `-c copy`, which cannot
+// host a filtergraph, so it is a second pass over the concatenated file --
+// applyChrome, below. Either way the overlay chain is chromeOverlayChain's.
+
+/**
+ * applyChrome's ffmpeg argv: the rendered regions over an already
+ * concatenated file, one video re-encode, the audio copied.
+ *
+ * `preview` (`{ start, dur }`) cuts a window out of the input. The regions it
+ * is given must then be a WINDOW render (`compose-chrome --from`), whose frame
+ * 1 is cut time `start` -- the base is seeked to the same second and starts at
+ * 0, so the two line up with no timestamp shifting. (The rail's preview has to
+ * shift because its expressions read absolute `t`; a frame sequence has no `t`.)
+ *
+ * The rail is not ported here: the deck refuses `render.rail`, and the chart
+ * band keeps its refusal of `transition: 0`, so nothing that reaches this
+ * function draws one.
+ */
+export function applyChromeArgs(inPath, outPath, render, chromePlan, preview = null) {
+ const hf = chromeOverlayChain(render, chromePlan.regions, "[0:v]", 1, { final: true });
+ return [
+ "-nostdin", "-v", "error", "-y",
+ ...(preview ? ["-ss", String(preview.start), "-t", String(preview.dur)] : []),
+ "-i", inPath,
+ ...hf.inputs,
+ "-filter_complex", hf.chain,
+ "-map", hf.outLabel, "-map", "0:a",
+ ...encodeArgsVideoOnly(render),
+ outPath,
+ ];
+}
+
+/**
+ * The chrome over a concatenated file: the hard-cut build's overlay pass, and
+ * `--chrome-preview` over a cached concat. Ported from the diet fork's
+ * applyChrome, whose point stands: the CONCAT cannot host a filtergraph, the
+ * pass after it always could.
+ */
+async function applyChrome(inPath, outPath, render, chromePlan, preview = null) {
+ await execFileP(FFMPEG, applyChromeArgs(inPath, outPath, render, chromePlan, preview), {
+ maxBuffer: 1 << 26,
+ });
+}
+
+/**
+ * Which segments a window of the cut touches, and where the window starts in
+ * their own local timeline. `starts`/`durs` are segmentOffsets'; a segment
+ * occupies [starts[i], starts[i] + durs[i]] (crossfades overlap neighbours).
+ */
+export function windowSegments(starts, durs, at, dur) {
+ const idx = [];
+ for (let i = 0; i < starts.length; i += 1) {
+ if (starts[i] < at + dur && starts[i] + durs[i] > at) idx.push(i);
+ }
+ if (!idx.length) throw new Error(`no segment covers ${at}s–${at + dur}s`);
+ return { first: idx[0], last: idx[idx.length - 1], offset: at - starts[idx[0]] };
+}
+
+/**
+ * `--chrome-preview` with no cached concat: the window, built straight from
+ * the few segments it touches -- the SAME xfade arithmetic concatWithXfade
+ * runs over the whole cut (or a plain concat for hard cuts), trimmed to the
+ * window, the window's deck frames over it. Seconds, not a whole-cut encode.
+ */
+export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, render, chromePlan, outPath }) {
+ const { first, last, offset } = windowSegments(starts, durs, at, dur);
+ const segs = segments.slice(first, last + 1);
+ const ds = durs.slice(first, last + 1);
+ const parts = [];
+ let vlab = "[0:v]";
+ let alab = "[0:a]";
+ if (segs.length > 1 && D > 0) {
+ let acc = ds[0];
+ for (let i = 1; i < segs.length; i += 1) {
+ const off = acc - D;
+ parts.push(`${vlab}[${i}:v]xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`);
+ parts.push(`${alab}[${i}:a]acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`);
+ vlab = `[v${i}]`;
+ alab = `[a${i}]`;
+ acc = acc + ds[i] - D;
+ }
+ } else if (segs.length > 1) {
+ parts.push(`${segs.map((_, i) => `[${i}:v][${i}:a]`).join("")}concat=n=${segs.length}:v=1:a=1[vc][ac]`);
+ vlab = "[vc]";
+ alab = "[ac]";
+ }
+ const S = offset.toFixed(3);
+ const T = Number(dur).toFixed(3);
+ parts.push(`${vlab}trim=start=${S}:duration=${T},setpts=PTS-STARTPTS[vw]`);
+ parts.push(`${alab}atrim=start=${S}:duration=${T},asetpts=PTS-STARTPTS[aw]`);
+ const hf = chromeOverlayChain(render, chromePlan.regions, "[vw]", segs.length, { final: true });
+ parts.push(hf.chain);
+ return [
+ "-nostdin", "-v", "error", "-y",
+ ...segs.flatMap((sg) => ["-i", sg]),
+ ...hf.inputs,
+ "-filter_complex", parts.join(";"),
+ "-map", hf.outLabel, "-map", "[aw]",
+ ...encodeArgs(render),
+ outPath,
+ ];
+}
+
+/**
+ * Compose and render the deck (cached by compose-chrome's key), and check the
+ * sequence is as long as the cut -- or the window -- it will be laid over.
+ * Dynamic import: compose-chrome imports this file.
+ */
+async function renderDeck({ manifestPath, render, outDir, variant, schedule, from = 0, duration = null }) {
+ const { composeChrome } = await import("./compose-chrome.mjs");
+ EMIT("chrome", { phase: "compose", ...(duration != null ? { from, duration } : {}) });
+ const t0 = Date.now();
+ const r = await composeChrome({
+ manifestPath, outDir, variant, region: "deck", doRender: true,
+ fps: render.fps, workers: 4, quality: "high", format: "png-sequence",
+ ...(duration != null ? { from, duration } : {}),
+ });
+ const want = frameCount(duration ?? schedule.total, render.fps);
+ if (r.frameCount !== want) {
+ throw new Error(
+ `the deck's sequence is ${r.frameCount} frames but the ${duration != null ? "window" : "cut"} ` +
+ `is ${want} (${(duration ?? schedule.total).toFixed(3)}s at ${render.fps} fps)`,
+ );
+ }
+ EMIT("chrome", {
+ phase: r.cached ? "cached" : "render",
+ frames: r.frameCount, key: r.key, dir: r.frames,
+ seconds: Number(((Date.now() - t0) / 1000).toFixed(1)),
+ });
+ const regions = chromeRegions(render, outDir).map((g) => ({ ...g, frames: r.frames }));
+ return { regions, outLabel: "[hfout]" };
+}
+
+/**
+ * A cached concat is a base for the overlay only when it is as long as the
+ * schedule says AND no segment is newer than it: a re-trimmed clip that kept
+ * its length would otherwise pass the length check and play the old cut.
+ */
+async function freshConcat(file, segments, total, fps) {
+ const st = await stat(file).catch(() => null);
+ if (!st) return false;
+ // Same segments, same order. Length and age alone would take a REORDERED
+ // timeline's old concat -- every title, QR and chapter then lands on the
+ // wrong footage while the length check still passes.
+ const recorded = await readFile(`${file}.segments`, "utf8").catch(() => null);
+ if (!sameConcatList(recorded, segments)) return false;
+ for (const s of segments) {
+ if ((await stat(s)).mtimeMs > st.mtimeMs) return false;
+ }
+ const got = await probeDuration(file, fps).catch(() => null);
+ return got != null && Math.abs(got - total) <= 1.5 / fps;
+}
+
+/** Does a recorded concat list name exactly these segments, in this order? */
+export function sameConcatList(recorded, segments) {
+ return recorded != null && recorded === concatListText(segments);
+}
+
// ---- chapter markers -----------------------------------------------------
// A compilation like this is a reference document as much as a video: the report
// cites moments, and a viewer wants to jump to them. Every clip therefore becomes
@@ -2034,17 +2342,23 @@ 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) {
+/**
+ * One entry's chapter name.
+ *
+ * An authored `chapter` wins; then, when the cut wears the deck, the entry's
+ * on-screen title -- the words the viewer was shown at that moment, which is
+ * what a chapter list is for; then the line derived from the record. Without
+ * the deck an `onscreen` value is not drawn, so it does not name a chapter
+ * either: a manifest without `render.chrome` builds exactly as it did.
+ */
+export async function chapterTitle(entry, index, provenance, { deck = false } = {}) {
if (entry.chapter) return entry.chapter;
+ if (deck && entry.onscreen?.title) return entry.onscreen.title;
// A still's chapter is the SAME line it burns into the header, for the reason
// a clip's is: the chapter list and the picture are two views of one cut, and
// a viewer jumping by chapter should land on the words they were shown.
@@ -2070,7 +2384,41 @@ async function chapterTitle(entry, index, provenance) {
}
}
-async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps) {
+/**
+ * The deck's schedule, written down: `out/<variant>/schedule.json`.
+ *
+ * From the PROBED segment durations (segmentOffsets, the same sum the concat
+ * and the chapters use) and each clip's real source metadata, through
+ * deckSchedule -- so the composition's handovers land on the frames the concat
+ * actually cuts on. Never hand-written, and never estimated here: umtool's
+ * preview estimates, a build measures.
+ *
+ * A clip whose metadata cannot be read gets `null`, as its chapter does, and
+ * its subtitle falls back to what the entry itself says.
+ *
+ * @returns the schedule document (deck.mjs's shape)
+ */
+export async function writeChromeSchedule({ manifest, entries, segments, D, outDir }) {
+ const { render, provenance = {} } = manifest;
+ const { durs } = await segmentOffsets(segments, D, render.fps);
+ const metas = [];
+ for (const e of entries) {
+ if (e.type !== "clip") {
+ metas.push(null);
+ continue;
+ }
+ metas.push(
+ await videoMeta(e.video, e.channel ?? provenance.channelSlug, {
+ siteChannel: e.siteChannel, siteVideo: e.siteVideo,
+ }).catch(() => null),
+ );
+ }
+ const doc = deckSchedule({ entries, durs, D, render, provenance, metas });
+ await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n");
+ return doc;
+}
+
+async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false) {
if (segments.length < 2) return;
const { starts, total } = await segmentOffsets(segments, D, fps);
const lines = [";FFMETADATA1", ""];
@@ -2084,7 +2432,7 @@ async function muxChapters(finalPath, entries, segments, D, outDir, provenance,
"TIMEBASE=1/1000",
`START=${Math.round(start * 1000)}`,
`END=${Math.round(end * 1000)}`,
- `title=${ffmetaEscape(await chapterTitle(entries[i], i, provenance))}`,
+ `title=${ffmetaEscape(await chapterTitle(entries[i], i, provenance, { deck }))}`,
"",
);
}
@@ -2103,9 +2451,19 @@ async function muxChapters(finalPath, entries, segments, D, outDir, provenance,
EMIT("chapters", { n: entries.length, file: path.basename(metaPath) });
}
-async function concatHardCut(segments, outDir, outPath) {
+/**
+ * The hard-cut concat's list, as written. Absolute: the concat demuxer resolves
+ * a relative entry against the LIST's directory, so a relative `--out` named
+ * every segment twice over and the hard-cut concat failed to open its first
+ * input. An absolute path is unchanged by this, and so is every build that
+ * already worked.
+ */
+export const concatListText = (segments) =>
+ segments.map((s) => `file '${path.resolve(s)}'`).join("\n") + "\n";
+
+async function concatHardCut(segments, outDir, outPath, { record = false } = {}) {
const listPath = path.join(outDir, "concat.txt");
- await writeFile(listPath, segments.map((s) => `file '${s}'`).join("\n") + "\n", "utf8");
+ await writeFile(listPath, concatListText(segments), "utf8");
await execFileP(
FFMPEG,
["-nostdin", "-v", "error", "-y", "-f", "concat", "-safe", "0",
@@ -2116,6 +2474,9 @@ async function concatHardCut(segments, outDir, outPath) {
"-i", listPath, "-c", "copy", outPath],
{ maxBuffer: 1 << 24 },
);
+ // The deck reuses this file across --chrome-only runs, so it records exactly
+ // which segments, in which order, it was made from (freshConcat reads it).
+ if (record) await writeFile(`${outPath}.segments`, concatListText(segments), "utf8");
}
// A hard-cut concat and a crossfaded one are different lengths, so a cached
@@ -2151,6 +2512,10 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const whole = JSON.parse(await readFile(manifestPath, "utf8"));
const manifest = selectVariant(whole, variant);
const { render, provenance } = manifest;
+ // A `render.chrome` that cannot be built is refused here, before a single
+ // fetch is spent. Absent, validateChrome has nothing to say.
+ if (render.chrome !== undefined && render.chrome !== null) assertChrome(render.chrome, render);
+ const deck = deckOn(render);
// An `image` entry's `src` is relative to the MANIFEST, which is checked in
// beside the pictures it cites -- not to the cwd the build was started from.
const manifestDir = path.dirname(path.resolve(manifestPath));
@@ -2229,11 +2594,16 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
return { out: r.out, failures: [] };
}
- // Footer chrome is shared by every clip, so build it once up front.
- const hyper = render.chromeEngine === "hyperframes";
- const chrome = hyper
- ? reservedFooter(render)
- : await renderFooterAssets(render, manifest.timelineNodes, outDir);
+ // Footer chrome is shared by every clip, so build it once up front. The deck
+ // has none: it is the chrome. (`hyper` is the chart band's legacy switch,
+ // which assertChrome already refuses beside a deck; the `!deck` says so here
+ // too.)
+ const hyper = !deck && render.chromeEngine === "hyperframes";
+ const chrome = deck
+ ? deckFooter()
+ : hyper
+ ? reservedFooter(render)
+ : await renderFooterAssets(render, manifest.timelineNodes, outDir);
const entries = manifest.timeline.filter((e) => !only || e.id === only);
if (only && !entries.length) throw new Error(`no timeline entry with id ${only}`);
@@ -2263,7 +2633,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (!(await exists(seg)))
throw new Error(`--chapters-only needs ${seg}, which is missing — run a full build first`);
}
- await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps);
+ await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render));
return { out: finalPath, failures: [] };
}
@@ -2295,13 +2665,76 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// applyRail re-encodes, so the chapters muxed onto the previous final are
// gone. Put them back, or --rail-only quietly ships a chapterless cut.
if (!opts.noChapters) {
- await muxChapters(out, entries, segs, D, outDir, provenance, render.fps);
+ await muxChapters(out, entries, segs, D, outDir, provenance, render.fps, deckOn(render));
}
}
EMIT("done", { out, failures: [] });
return { out, failures: [] };
}
+ // The deck again, over the segments already on disk: `--chrome-only` re-lays
+ // it on the whole cut, `--chrome-preview <at> <dur>` on a window of it. No
+ // segment is rebuilt and nothing is fetched; the schedule is re-measured from
+ // the segments, so a re-render can never use durations a rebuild changed.
+ if (opts.chromeOnly || opts.chromePreview) {
+ const what = opts.chromeOnly ? "--chrome-only" : "--chrome-preview";
+ if (!deck) throw new Error(`${what} needs the deck: render.chrome is not set in the manifest`);
+ if (opts.noChrome) throw new Error(`${what} and --no-chrome contradict each other`);
+ if (only) throw new Error(`${what} lays the deck over the whole cut; --only does not apply`);
+ const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`));
+ for (const seg of segs) {
+ if (!(await exists(seg)))
+ throw new Error(`${what} needs ${seg}, which is missing — run a full build first`);
+ }
+ const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir });
+ EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length });
+ const prerail = prerailPath(outDir, manifest.slug, D);
+
+ if (opts.chromePreview) {
+ const at = Math.max(0, Math.min(opts.chromePreview.at, schedule.total - 1 / render.fps));
+ const dur = Math.min(opts.chromePreview.dur, schedule.total - at);
+ const plan = await renderDeck({
+ manifestPath, render, outDir, variant, schedule, from: at, duration: dur,
+ });
+ const out = path.join(outDir, `${manifest.slug}.preview.mp4`);
+ if (await freshConcat(prerail, segs, schedule.total, render.fps)) {
+ EMIT("chrome", { phase: "overlay", base: path.basename(prerail) });
+ await applyChrome(prerail, out, render, plan, { start: at, dur });
+ } else {
+ const { starts, durs } = await segmentOffsets(segs, D, render.fps);
+ EMIT("chrome", { phase: "overlay", base: "segments" });
+ await execFileP(FFMPEG, previewFromSegmentsArgs({
+ segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out,
+ }), { maxBuffer: 1 << 26 });
+ }
+ EMIT("done", { out, failures: [] });
+ return { out, failures: [] };
+ }
+
+ const plan = await renderDeck({ manifestPath, render, outDir, variant, schedule });
+ EMIT("chrome", { phase: "overlay" });
+ EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length });
+ if (D === 0) {
+ // The hard-cut concat is a stream copy of these very segments; when
+ // nothing changed since it was made it is reused, and the overlay is
+ // the only encode.
+ if (await freshConcat(prerail, segs, schedule.total, render.fps)) {
+ EMIT("note", { message: `reusing ${path.basename(prerail)}` });
+ } else {
+ await concatHardCut(segs, outDir, prerail, { record: true });
+ }
+ await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat");
+ await applyChrome(prerail, dirs.final, render, plan, null);
+ } else {
+ await concatWithXfade(segs, render, dirs.final, null, plan);
+ }
+ await assertConcatLength(dirs.final, schedule.total, render.fps, "deck build");
+ // The overlay re-encodes, so the chapters on the previous final are gone.
+ if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render));
+ EMIT("done", { out: dirs.final, failures: [] });
+ return { out: dirs.final, failures: [] };
+ }
+
EMIT("start", { title: manifest.title, entries: entries.length, out: outDir });
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
@@ -2369,6 +2802,16 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
}
const final = dirs.final;
+
+ // The deck's schedule, from the segments just built. Written before the
+ // concat because the composition is made from it; the rendered deck is then
+ // laid in the concat itself (or, for hard cuts, in one pass after it).
+ let schedule = null;
+ if (deck) {
+ schedule = await writeChromeSchedule({ manifest, entries, segments, D, outDir });
+ EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length });
+ }
+
const railPlan = opts.noRail ? null : await buildRailPlan(manifest, render, entries, segments, D, outDir);
// The rendered chrome, if this manifest asks for it. Absent, `chromePlan` is
@@ -2388,13 +2831,22 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
chromePlan = { regions, outLabel: "[hfout]" };
EMIT("note", { message: `chrome: ${regions.map((r) => `${r.name} ${r.width}x${r.height}`).join(", ")} as png-sequence` });
}
+ // The deck: composed and rendered here, in the same command (compose-chrome
+ // skips the render when its key and frame count match). `--no-chrome` keeps
+ // the deck's framing and leaves the panel off -- a fast look at the picture.
+ if (deck && !opts.noChrome) {
+ chromePlan = await renderDeck({ manifestPath, render, outDir, variant, schedule });
+ EMIT("chrome", { phase: "overlay" });
+ }
// `transition: 0` is a real editorial choice, not just a speed knob: hard cuts
// hit harder on a compilation whose point is repetition. Honouring it here keeps
// the manifest the source of truth, so a rebuild does not silently re-add fades.
EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segments.length });
if (D === 0) {
- if (chromePlan) {
+ // The chart band keeps this refusal. The deck does not need it: its
+ // overlay is the second pass below, which can host anything.
+ if (chromePlan && !deck) {
throw new Error(
'render.chromeEngine "hyperframes" needs a filtergraph, and `transition: 0` concatenates with ' +
"-c copy, which cannot host one. Give the manifest a transition, or drop chromeEngine.",
@@ -2403,21 +2855,33 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// concatHardCut is `-c copy`, which cannot host a filtergraph, so the rail
// has to be a second pass here whether we like it or not.
const prerail = prerailPath(outDir, manifest.slug, D);
- await concatHardCut(segments, outDir, railPlan ? prerail : final);
if (railPlan) {
+ await concatHardCut(segments, outDir, prerail);
await assertConcatLength(prerail, railPlan.total, render.fps, "hard-cut concat");
await applyRail(prerail, final, render, railPlan, null);
+ } else if (chromePlan) {
+ // Only the deck reaches here (the band refused above, and the deck
+ // refuses a rail). Hard-cut concat to the prerail, then ONE overlay
+ // re-encode to the final.
+ await concatHardCut(segments, outDir, prerail, { record: true });
+ await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat");
+ await applyChrome(prerail, final, render, chromePlan, null);
+ } else {
+ await concatHardCut(segments, outDir, final);
}
} else {
await concatWithXfade(segments, render, final, railPlan, chromePlan);
}
+ // The deck's sequence is laid with shortest=1, so a sequence a frame short
+ // would shorten the cut without a word; the schedule is the length to hold.
+ if (deck) await assertConcatLength(final, schedule.total, render.fps, chromePlan ? "deck build" : "deck concat");
// Length is the canary for the two ways a rail input can go wrong: a file
// LONGER than the timeline means a strip outran the main (a missing
// shortest=1), and a hang means an unbounded -loop 1.
if (railPlan) await assertConcatLength(final, railPlan.total, render.fps, "rail build");
- if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps);
+ if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render));
// A branded cut with a `thumbnail` gets one beside it. The cut is already
// done, so a thumbnail that cannot be made is said, not thrown.
@@ -2500,6 +2964,34 @@ async function buildThumbnail(manifest, dirs, manifestDir) {
return { out, jpg };
}
+/**
+ * The deck's three flags, read off argv. Pure, so the parser is tested
+ * without running a build; throws a sentence on a malformed one.
+ *
+ * --chrome-only re-lay the deck over the segments on disk
+ * --no-chrome the deck's framing, no overlay
+ * --chrome-preview <at> <dur> a window, to out/<variant>/<slug>.preview.mp4
+ */
+export function chromeFlags(argv) {
+ const out = {
+ chromeOnly: argv.includes("--chrome-only"),
+ noChrome: argv.includes("--no-chrome"),
+ chromePreview: null,
+ };
+ const i = argv.indexOf("--chrome-preview");
+ if (i >= 0) {
+ const at = Number(argv[i + 1]);
+ const dur = Number(argv[i + 2]);
+ if (argv[i + 1] === undefined || argv[i + 2] === undefined || !Number.isFinite(at) || !Number.isFinite(dur) || at < 0 || dur <= 0) {
+ throw new Error("--chrome-preview takes <at> <dur> in seconds (at ≥ 0, dur > 0)");
+ }
+ out.chromePreview = { at, dur };
+ }
+ const runs = [out.chromeOnly, out.noChrome, out.chromePreview].filter(Boolean).length;
+ if (runs > 1) throw new Error("--chrome-only, --no-chrome and --chrome-preview are different runs — pick one");
+ return out;
+}
+
async function main() {
const argv = process.argv.slice(2);
const manifestPath = argv.find((a) => !a.startsWith("--"));
@@ -2510,6 +3002,7 @@ async function main() {
" [--pad <s>] [--pad-before <s>] [--pad-after <s>] [--skip-fetch] [--no-xfade] [--no-chapters] [--chapters-only]\n" +
" [--progress ndjson] [--continue-on-error] [--no-reuse]\n" +
" [--no-rail] [--rail-only] [--preview <start> <dur>]\n" +
+ " [--chrome-only] [--no-chrome] [--chrome-preview <at> <dur>] (render.chrome, the deck)\n" +
" [--site-origin <url>] [--resolve-site-ids] [--cue-source auto|local|http]\n" +
" [--thumbnail] (render.brand only: out/<slug>.thumbnail.png and stop)",
);
@@ -2542,6 +3035,12 @@ async function main() {
cueSource: flag("--cue-source"),
thumbnailOnly: argv.includes("--thumbnail"),
};
+ try {
+ Object.assign(opts, chromeFlags(argv));
+ } catch (err) {
+ console.error(err.message);
+ process.exit(2);
+ }
const pv = argv.indexOf("--preview");
if (pv >= 0) {
opts.preview = { start: Number(argv[pv + 1]), dur: Number(argv[pv + 2]) };
diff --git a/umtool/report-to-video/chrome-deck.mjs b/umtool/report-to-video/chrome-deck.mjs
@@ -0,0 +1,574 @@
+// The bottom deck's composition: one HyperFrames page for a whole report cut.
+//
+// PURE, like deck.mjs: a schedule and a render block in, an HTML string out.
+// compose-chrome.mjs copies the assets in beside it, writes it and renders it;
+// nothing here touches a file, so a test can read the page it would draw.
+//
+// ---------------------------------------------------------------------------
+// Why the timeline is a list of cues computed here, not tweens written there
+// ---------------------------------------------------------------------------
+// A HyperFrames render is a SEEK per frame, from parallel workers, in any
+// order. A GSAP `set` or `to` remembers the value it found the first time it
+// rendered -- which, on a seek, is whatever the last seek left behind. Every
+// cue below is therefore a fromTo whose FROM is stated, and the from of each
+// is the to of the cue before it on the same element: `deckCues` walks the
+// cues in time order and carries every property's value forward. The page's
+// script is a dumb interpreter of that list. So the browser holds no logic a
+// test cannot see, and the times in it are deckChoreography's, untouched --
+// there is no second copy of the timing to drift.
+//
+// ---------------------------------------------------------------------------
+// Why the wipe is two translates and not a clip-path
+// ---------------------------------------------------------------------------
+// A wipe is a window sliding across text that stays put. The clip box (an
+// inline-block, so it is exactly as wide as its fitted title) moves by
+// xPercent and its child moves by the opposite xPercent: the two cancel for
+// the glyphs and not for the window. xPercent is relative to the element's OWN
+// width, so it follows the load-time text fit with nothing measured at tween
+// time, and it is a transform -- on the render's animatable allowlist, which
+// clip-path is not.
+//
+// ---------------------------------------------------------------------------
+// The progress fill is a fuse
+// ---------------------------------------------------------------------------
+// While clip k plays the accent fill burns from pip k toward pip k+1, reaching
+// it as the marker sets off. The marker then travels over track that is
+// already lit, and the fill states "how far through this clip" without a
+// second clock or a label.
+import { fileURLToPath } from "node:url";
+
+import { deckChoreography, deckLayout, pipSegments, pipXs, resolveDeck } from "./deck.mjs";
+
+/**
+ * GSAP, vendored, for every chrome region.
+ *
+ * It used to come off a CDN at render time. A deck render is ten thousand
+ * frames; one DNS failure in the middle of it is blank frames and no error
+ * anybody would recognise. The file is in the repo at the version the chart
+ * band was written against, and compose-chrome copies it in beside each page.
+ *
+ * Declared here, not in compose-chrome, and as `new URL(…, import.meta.url)`:
+ * a bundler resolves that form to the one file, and a name derived from
+ * import.meta in the module that does the fs work would mark every path that
+ * module joins (scripts/next-build-trace.test.mjs).
+ */
+export const GSAP_FILE = fileURLToPath(new URL("./assets/gsap.min.js", import.meta.url));
+
+/** Instant cues still take a millisecond: a zero-duration tween has its own seek rules. */
+const INSTANT = 0.001;
+
+const esc = (s) =>
+ String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """);
+
+const r4 = (v) => Math.round(v * 10000) / 10000;
+
+/** The host a QR resolves to -- the one thing on the tile a viewer cannot read off the code. */
+export function hostOf(url) {
+ try {
+ return new URL(url).host.replace(/^www\./, "");
+ } catch {
+ return "";
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Colour. The palette is five hexes; the panel's lift is arithmetic on them.
+// ---------------------------------------------------------------------------
+
+function rgbOf(hex) {
+ const h = String(hex).replace("#", "");
+ const full = h.length === 3 ? [...h].map((c) => c + c).join("") : h.slice(0, 6);
+ const n = Number.parseInt(full, 16);
+ if (!Number.isFinite(n)) throw new Error(`deck: palette colour ${hex} is not a hex colour`);
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+}
+
+/** `a` moved toward `b` by `t` (0..1), as a hex. */
+export function mix(a, b, t) {
+ const x = rgbOf(a), y = rgbOf(b);
+ return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("")}`;
+}
+
+/** A palette colour at an alpha, as rgba(). */
+export function rgba(hex, a) {
+ const [r, g, b] = rgbOf(hex);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+}
+
+// ---------------------------------------------------------------------------
+// The cue list.
+// ---------------------------------------------------------------------------
+
+/**
+ * Segment i's two text states. "before" is wiped to the LEFT of its window and
+ * not yet visible; "shown" is at rest; "after" is wiped off to the right.
+ * The QR is edge-on at ±90° on either side, so the flip turns one way.
+ */
+const SEG_STATE = {
+ before: (lift) => ({
+ seg: { autoAlpha: 0 },
+ tc: { xPercent: -100 }, ti: { xPercent: 100, y: lift }, blade: { opacity: 0 },
+ sc: { xPercent: -100 }, si: { xPercent: 100 },
+ rule: { scaleX: 0, transformOrigin: "0% 50%" },
+ qr: { rotationY: -90 },
+ }),
+ shown: () => ({
+ seg: { autoAlpha: 1 },
+ tc: { xPercent: 0 }, ti: { xPercent: 0, y: 0 }, blade: { opacity: 0 },
+ sc: { xPercent: 0 }, si: { xPercent: 0 },
+ rule: { scaleX: 1, transformOrigin: "0% 50%" },
+ qr: { rotationY: 0 },
+ }),
+};
+
+const PARTS = ["tc", "ti", "blade", "sc", "si", "rule", "qr"];
+const keyOf = (i, part) => (part === "seg" ? `s${i}` : `s${i}.${part}`);
+
+/**
+ * Everything the deck's timeline does, as data.
+ *
+ * @returns {{ init: Record<string, object>, cues: Array<{ k: string, at: number,
+ * dur: number, from: object, to: object, ease: string, why: string }>,
+ * pips: Array<{ id: string, x: number }>, choreography: object }}
+ *
+ * `init` is each element's state at t = 0 (applied once at load); `cues` are in
+ * time order and each states its own from. `why` names the choreography entry
+ * a cue came from -- the tests match on it, and a reader of the page can too.
+ */
+export function deckCues(schedule, render) {
+ const deck = resolveDeck(render);
+ const lay = deckLayout(render);
+ const segs = schedule.segments;
+ const ch = deckChoreography(schedule, render);
+ const pipped = pipSegments(schedule);
+ const xs = pipXs(
+ pipped.length, lay.pipTrack.x0, lay.pipTrack.x1, deck.pip.spacing,
+ deck.pip.spacing === "time" ? { total: schedule.total, segments: pipped } : null,
+ );
+ const pips = pipped.map((s, k) => ({ id: s.id, x: r4(xs[k]) }));
+ const pipOf = new Map(pipped.map((s, k) => [s.id, k]));
+ const fuseX0 = xs[0] ?? 0;
+ const fuseW = xs.length > 1 ? xs[xs.length - 1] - xs[0] : 0;
+ const frac = (k) => (fuseW > 0 ? r4((xs[k] - fuseX0) / fuseW) : 0);
+ const lift = Math.round(lay.text.titleSize * 0.16);
+ const slideY = lay.height + 12;
+
+ // ---- t = 0 -------------------------------------------------------------
+ const init = {};
+ const put = (k, v) => { init[k] = { ...(init[k] ?? {}), ...v }; };
+ const startsHidden = !!segs[0]?.hideDeck;
+ put("panel", { y: startsHidden ? slideY : 0 });
+ put("marker", { x: r4(xs[0] ?? lay.pipTrack.x0) });
+ if (fuseW > 0) put("fuse", { scaleX: 0, transformOrigin: "0% 50%" });
+ pips.forEach((_, k) => put(`pip${k}`, { opacity: 0 }));
+ segs.forEach((s, i) => {
+ const st = i === 0 && !s.hideDeck ? SEG_STATE.shown() : SEG_STATE.before(lift);
+ put(keyOf(i, "seg"), st.seg);
+ for (const p of PARTS) put(keyOf(i, p), st[p]);
+ });
+
+ // ---- events, unordered; froms are filled in below ----------------------
+ const ev = [];
+ const add = (k, at, dur, to, ease, why) => ev.push({ k, at: r4(at), dur: r4(Math.max(INSTANT, dur)), to, ease, why });
+ const setSeg = (i, state, at, why) => {
+ add(keyOf(i, "seg"), at, INSTANT, state.seg, "none", why);
+ for (const p of PARTS) add(keyOf(i, p), at, INSTANT, state[p], "none", why);
+ };
+ const idx = new Map(segs.map((s, i) => [s.id, i]));
+ const filled = new Set();
+ const fillPipsBefore = (k, at, dur, why) => {
+ for (let j = 0; j < k; j += 1) {
+ if (filled.has(j)) continue;
+ filled.add(j);
+ add(`pip${j}`, at, dur, { opacity: 1 }, "power1.out", why);
+ }
+ };
+ // When the marker ARRIVES at each pip, and when it LEAVES: the fuse burns between.
+ const arrive = new Map();
+ const depart = new Map();
+ if (pips.length && !startsHidden) arrive.set(0, 0);
+
+ for (const h of ch.handovers) {
+ const a = idx.get(h.from), b = idx.get(h.to);
+ const why = `handover ${h.from}->${h.to}`;
+ // Out. A segment shorter than in + out would start leaving before it had
+ // arrived; the clamp below (per element) keeps the two from overlapping.
+ add(keyOf(a, "tc"), h.out[0], h.out[1] - h.out[0], { xPercent: 100 }, "power2.in", `${why} out`);
+ add(keyOf(a, "ti"), h.out[0], h.out[1] - h.out[0], { xPercent: -100 }, "power2.in", `${why} out`);
+ add(keyOf(a, "sc"), h.out[0], h.out[1] - h.out[0], { xPercent: 100 }, "power2.in", `${why} out`);
+ add(keyOf(a, "si"), h.out[0], h.out[1] - h.out[0], { xPercent: -100 }, "power2.in", `${why} out`);
+ add(keyOf(a, "rule"), h.out[0], h.out[1] - h.out[0], { scaleX: 0, transformOrigin: "100% 50%" }, "power2.in", `${why} out`);
+ add(keyOf(a, "qr"), h.qr[0], h.m - h.qr[0], { rotationY: 90 }, "power1.in", `${why} qr`);
+ add(keyOf(a, "seg"), h.m, INSTANT, { autoAlpha: 0 }, "none", `${why} out`);
+ // In.
+ add(keyOf(b, "seg"), h.m, INSTANT, { autoAlpha: 1 }, "none", `${why} in`);
+ add(keyOf(b, "tc"), h.in[0], h.in[1] - h.in[0], { xPercent: 0 }, "power3.out", `${why} in`);
+ add(keyOf(b, "ti"), h.in[0], h.in[1] - h.in[0], { xPercent: 0, y: 0 }, "power3.out", `${why} in`);
+ // The blade rides the reveal's leading edge and is gone as the title settles.
+ add(keyOf(b, "blade"), h.in[0], INSTANT, { opacity: 1 }, "none", `${why} in`);
+ add(keyOf(b, "blade"), h.in[0] + (h.in[1] - h.in[0]) * 0.35, (h.in[1] - h.in[0]) * 0.65, { opacity: 0 }, "power1.in", `${why} in`);
+ add(keyOf(b, "sc"), h.sub[0], h.sub[1] - h.sub[0], { xPercent: 0 }, "power3.out", `${why} sub`);
+ add(keyOf(b, "si"), h.sub[0], h.sub[1] - h.sub[0], { xPercent: 0 }, "power3.out", `${why} sub`);
+ add(keyOf(b, "rule"), h.in[0], h.in[1] - h.in[0], { scaleX: 1, transformOrigin: "0% 50%" }, "power3.out", `${why} in`);
+ add(keyOf(b, "qr"), h.m, h.qr[1] - h.m, { rotationY: 0 }, "power1.out", `${why} qr`);
+ // The pip.
+ const pa = pipOf.get(h.from), pb = pipOf.get(h.to);
+ add("marker", h.pip[0], h.pip[1] - h.pip[0], { x: pips[pb].x }, "power3.inOut", `${why} pip`);
+ fillPipsBefore(pb, h.pip[0], Math.min(0.25, h.pip[1] - h.pip[0]), `${why} pip`);
+ depart.set(pa, h.pip[0]);
+ arrive.set(pb, h.pip[1]);
+ }
+
+ for (const v of ch.visibility) {
+ if (v.at[1] - v.at[0] <= 0 && v.i === 0) continue; // hidden from the first frame: that is init
+ const why = `${v.hide ? "hide" : "show"} @${segs[v.i].id}`;
+ add("panel", v.at[0], v.at[1] - v.at[0], { y: v.hide ? slideY : 0 }, v.hide ? "power2.in" : "power3.out", why);
+ if (v.hide) {
+ // The text on the way down stays until it is off screen, then is gone
+ // for good -- nothing is handed over behind a card.
+ const prev = segs.slice(0, v.i).map((s, j) => j).filter((j) => !segs[j].hideDeck).pop();
+ if (prev !== undefined) {
+ add(keyOf(prev, "seg"), v.at[1], INSTANT, { autoAlpha: 0 }, "none", why);
+ const pk = pipOf.get(segs[prev].id);
+ if (!depart.has(pk)) depart.set(pk, v.at[0]);
+ }
+ } else {
+ // Whatever came before is already set when the deck rises: the new text
+ // is in place, the marker is on its pip and every pip before it is lit.
+ setSeg(v.i, SEG_STATE.shown(), v.at[0], why);
+ const k = pipOf.get(segs[v.i].id);
+ add("marker", v.at[0], INSTANT, { x: pips[k].x }, "none", why);
+ fillPipsBefore(k, v.at[0], INSTANT, why);
+ arrive.set(k, v.at[0]);
+ }
+ }
+
+ // The fuse: from pip k to pip k+1 over the time the marker rests on k.
+ if (fuseW > 0) {
+ for (let k = 0; k < pips.length - 1; k += 1) {
+ const a = arrive.get(k), b = depart.get(k);
+ if (a === undefined || b === undefined) continue;
+ add("fuse", a, b - a, { scaleX: frac(k + 1), transformOrigin: "0% 50%" }, "none", `fuse ${pips[k].id}`);
+ }
+ }
+
+ // ---- order, clamp, and state the froms ---------------------------------
+ ev.forEach((e, n) => { e.n = n; });
+ ev.sort((x, y) => x.at - y.at || x.n - y.n);
+ const state = Object.fromEntries(Object.entries(init).map(([k, v]) => [k, { ...v }]));
+ const freeAt = new Map();
+ const cues = [];
+ for (const e of ev) {
+ const free = freeAt.get(e.k) ?? 0;
+ let { at, dur } = e;
+ if (at < free) {
+ const end = at + dur;
+ at = r4(free);
+ dur = r4(Math.max(INSTANT, end - at));
+ }
+ const cur = state[e.k] ?? (state[e.k] = {});
+ const from = {};
+ for (const p of Object.keys(e.to)) from[p] = cur[p];
+ Object.assign(cur, e.to);
+ freeAt.set(e.k, r4(at + dur));
+ cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why });
+ }
+ return { init, cues, pips, slideY, choreography: ch };
+}
+
+// ---------------------------------------------------------------------------
+// The page.
+// ---------------------------------------------------------------------------
+
+/** The subtitle's markup: its " · " separators become accent dots. */
+export function subtitleMarkup(text) {
+ return String(text ?? "")
+ .split(" · ")
+ .map((p) => `<span class="part">${esc(p)}</span>`)
+ .join('<span class="sep">·</span>');
+}
+
+/**
+ * The deck composition's HTML.
+ *
+ * `fonts` = `{ regular, bold }` asset-relative paths (DeckSans / DeckSansBold),
+ * `qrSrcs` = `{ [segmentId]: "assets/qrNN.png" }`, `gsap` = the vendored
+ * script's asset path. `from`/`duration` render a window of the cut: the root
+ * declares `duration` seconds and the timeline plays the cut's [from,
+ * from + duration] -- the page is otherwise identical.
+ */
+export function deckHtml(schedule, render, opts = {}) {
+ const deck = resolveDeck(render);
+ const lay = deckLayout(render);
+ const pal = render.palette;
+ const W = lay.width, H = lay.height;
+ const fonts = opts.fonts ?? {};
+ const qrSrcs = opts.qrSrcs ?? {};
+ const gsapSrc = opts.gsap ?? "assets/gsap.min.js";
+ const from = Number(opts.from ?? 0);
+ const total = schedule.total;
+ const dur = opts.duration != null ? Number(opts.duration) : r4(total - from);
+ if (!(dur > 0)) throw new Error(`deck: nothing to render from ${from}s of a ${total}s cut`);
+ const windowed = from > 0 || Math.abs(dur - total) > 1e-6;
+
+ const { init, cues, pips } = deckCues(schedule, render);
+ const t = lay.text;
+ const tr = lay.pipTrack;
+ const qr = lay.qr;
+ const pip = deck.pip;
+ const panel = deck.background === "panel";
+
+ // The lift: a few percent toward the foreground, so the deck reads as a
+ // surface standing on the frame's ground rather than a hole cut in it.
+ const top = panel ? mix(pal.bg, pal.fg, 0.095) : pal.bg;
+ const bottom = panel ? mix(pal.bg, pal.fg, 0.04) : pal.bg;
+ // The untitled arrangement, centred on the same block.
+ const uSubSize = Math.round(t.subtitleSize * 1.35);
+ const uSubBox = Math.round(uSubSize * 1.3);
+ const uSubY = Math.round(t.titleY + (t.subtitleY + t.subtitleBox - t.titleY - uSubBox - 14) / 2);
+ const uRuleY = uSubY + uSubBox + 6;
+ const plateX = qr ? qr.x - 46 : W;
+ const ink = panel ? mix(pal.bg, pal.fg, 0.085) : pal.bg; // what the marker's ring cuts back to
+
+ const segHtml = schedule.segments
+ .map((s, i) => {
+ const src = qrSrcs[s.id];
+ return (
+ `<div class="seg${s.title ? "" : " untitled"}" data-seg="${esc(s.id)}" data-k="s${i}">` +
+ `<div class="tc" data-k="s${i}.tc"><div class="ti" data-k="s${i}.ti">` +
+ `<div class="deck-title">${esc(s.title)}</div></div>` +
+ `<div class="blade" data-k="s${i}.blade"></div></div>` +
+ `<div class="rule" data-k="s${i}.rule"></div>` +
+ `<div class="sc" data-k="s${i}.sc"><div class="si" data-k="s${i}.si">` +
+ `<div class="deck-sub">${subtitleMarkup(s.subtitle)}</div></div></div>` +
+ (qr
+ ? `<div class="deck-qr${src ? "" : " empty"}" data-k="s${i}.qr">` +
+ (src ? `<img src="${esc(src)}" width="${qr.size}" height="${qr.size}" alt="">` : "") +
+ `</div>` +
+ (src && hostOf(s.qrUrl) ? `<div class="deck-qr-host">${esc(hostOf(s.qrUrl))}</div>` : "")
+ : `<div class="deck-qr empty" data-k="s${i}.qr"></div>`) +
+ `</div>`
+ );
+ })
+ .join("\n ");
+
+ const pipHtml = pips
+ .map(
+ (p, k) =>
+ `<div class="pip" style="left:${r4(p.x - pip.size / 2)}px"></div>` +
+ `<div class="pip lit" data-k="pip${k}" style="left:${r4(p.x - pip.size / 2)}px"></div>`,
+ )
+ .join("");
+ const fuseLeft = pips[0]?.x ?? tr.x0;
+ const fuseWidth = pips.length > 1 ? pips[pips.length - 1].x - fuseLeft : 0;
+
+ const data = {
+ total,
+ window: windowed ? { from, dur } : null,
+ titleSize: t.titleSize,
+ floor: Math.ceil(t.titleSize * 0.6),
+ ids: schedule.segments.map((s) => s.id),
+ init,
+ cues: cues.map(({ why, ...c }) => c),
+ };
+ // `</script>` inside a JSON string would close the tag; a title may say anything.
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
+
+ return `<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=${W}, height=${H}" />
+ <script src="${esc(gsapSrc)}"></script>
+ <style>
+ /* Private family names, real files beside the page. A bare local() falls
+ back silently in the render browser, and naming a real family anywhere
+ in the stack makes the compiler fetch it from the network -- see
+ docs/quirks.md. sans-serif is the only fallback. */
+ @font-face { font-family: 'DeckSans'; font-weight: 400; font-style: normal;
+ src: url('${esc(fonts.regular ?? "")}'); }
+ @font-face { font-family: 'DeckSansBold'; font-weight: 400; font-style: normal;
+ src: url('${esc(fonts.bold ?? "")}'); }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: transparent; }
+ body { font-family: 'DeckSans', sans-serif; font-synthesis: none; color: ${pal.fg};
+ -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; }
+ #root { position: relative; width: ${W}px; height: ${H}px; overflow: hidden; }
+ #deck-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ .panel { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ .ground { position: absolute; inset: 0;
+ background: linear-gradient(180deg, ${top} 0%, ${bottom} 100%); }
+ ${panel ? `
+ /* The top edge: a hairline that carries the accent in from the left and
+ settles to a quiet rule -- the one line that says "panel", not "band". */
+ .edge { position: absolute; left: 0; top: 0; width: ${W}px; height: 1px;
+ background: linear-gradient(90deg, ${rgba(pal.accent, 0.9)} 0px, ${rgba(pal.accent, 0.35)} ${Math.round(W * 0.18)}px,
+ ${rgba(pal.fg, 0.14)} ${Math.round(W * 0.42)}px, ${rgba(pal.fg, 0.14)} ${W}px); }
+ .sheen { position: absolute; left: 0; top: 1px; width: ${W}px; height: 28px;
+ background: linear-gradient(180deg, ${rgba(pal.fg, 0.035)} 0%, ${rgba(pal.fg, 0)} 100%); }` : ""}
+ /* The source cell: the QR and its host stand in a cell of their own,
+ a shade down from the panel, so the deck reads as two fields -- what
+ this is, and where to check it -- rather than a caption beside a code. */
+ .plate { position: absolute; left: ${plateX}px; top: 0; width: ${W - plateX}px; height: ${H}px;
+ background: linear-gradient(180deg, ${mix(top, pal.bg, 0.55)} 0%, ${mix(bottom, pal.bg, 0.6)} 100%);
+ border-left: 1px solid ${rgba(pal.fg, 0.07)}; }
+ .track { position: absolute; left: 0; top: ${tr.y}px; width: ${W}px; height: 0; }
+ .rail { position: absolute; left: ${tr.x0}px; top: -1px; width: ${tr.x1 - tr.x0}px; height: 2px;
+ border-radius: 1px; background: ${rgba(pal.fg, 0.1)}; }
+ .fuse { position: absolute; left: ${r4(fuseLeft)}px; top: -1px; width: ${r4(fuseWidth)}px; height: 2px;
+ border-radius: 1px; background: ${pal.accent}; }
+ .pip { position: absolute; top: ${-pip.size / 2}px; width: ${pip.size}px; height: ${pip.size}px;
+ border-radius: 50%; background: ${mix(bottom, pal.fg, 0.32)}; }
+ .pip.lit { background: ${pal.accent}; }
+ .marker { position: absolute; left: ${-pip.activeSize / 2}px; top: ${-pip.activeSize / 2}px;
+ width: ${pip.activeSize}px; height: ${pip.activeSize}px; border-radius: 50%;
+ background: ${pal.fg};
+ box-shadow: 0 0 0 3px ${ink}, 0 0 0 5px ${rgba(pal.accent, 0.95)}, 0 0 18px 4px ${rgba(pal.accent, 0.45)}; }
+ .seg { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ .tc, .sc { position: absolute; left: ${t.x}px; display: inline-block; overflow: hidden;
+ max-width: ${t.width}px; vertical-align: top; }
+ .tc { top: ${t.titleY}px; height: ${t.titleBox}px; }
+ .sc { top: ${t.subtitleY}px; height: ${t.subtitleBox}px; }
+ .ti, .si { display: block; }
+ .deck-title { display: block; max-width: ${t.width}px; white-space: nowrap; overflow: hidden;
+ text-overflow: ellipsis; font-family: 'DeckSansBold', sans-serif;
+ font-size: ${t.titleSize}px; line-height: ${t.titleBox}px; letter-spacing: -0.012em;
+ color: ${pal.fg}; }
+ .deck-sub { display: block; max-width: ${t.width}px; white-space: nowrap; overflow: hidden;
+ text-overflow: ellipsis; font-size: ${t.subtitleSize}px; line-height: ${t.subtitleBox}px;
+ letter-spacing: 0.005em; color: ${pal.muted}; }
+ .deck-sub .sep { color: ${pal.accent}; padding: 0 0.42em; font-family: 'DeckSansBold', sans-serif; }
+ .blade { position: absolute; right: 0; top: ${Math.round(t.titleBox * 0.14)}px; width: 4px;
+ height: ${Math.round(t.titleBox * 0.72)}px; border-radius: 2px; background: ${pal.accent};
+ box-shadow: 0 0 14px 2px ${rgba(pal.accent, 0.55)}; }
+ /* Untitled: the source line is promoted into the title's band rather than
+ sitting under a blank one. */
+ .seg.untitled .tc { visibility: hidden; }
+ .seg.untitled .sc { top: ${uSubY}px; height: ${uSubBox}px; }
+ .seg.untitled .deck-sub { font-size: ${uSubSize}px; line-height: ${uSubBox}px; color: ${mix(pal.muted, pal.fg, 0.55)}; }
+ .seg.untitled .rule { top: ${uRuleY}px; }
+ .rule { position: absolute; left: ${t.x}px; top: ${t.ruleY}px; width: ${Math.round(t.titleSize * 1.5)}px;
+ height: 4px; border-radius: 2px; background: ${pal.accent}; }
+ .deck-qr { position: absolute; left: ${qr?.x ?? 0}px; top: ${qr?.y ?? 0}px;
+ width: ${qr?.size ?? 0}px; height: ${qr?.size ?? 0}px; transform-style: preserve-3d; }
+ .deck-qr-host { position: absolute; left: ${(qr?.x ?? 0) - 30}px; top: ${qr?.y ?? 0}px; width: 18px;
+ height: ${qr?.size ?? 0}px; writing-mode: vertical-rl; transform: rotate(180deg);
+ text-align: left; white-space: nowrap; font-size: 11px; line-height: 18px;
+ letter-spacing: 0.1em; text-transform: uppercase; color: ${rgba(pal.muted, 0.85)}; }
+ .deck-qr img { display: block; width: ${qr?.size ?? 0}px; height: ${qr?.size ?? 0}px; border-radius: 6px;
+ image-rendering: pixelated; backface-visibility: hidden;
+ box-shadow: 0 0 0 1px ${rgba(pal.fg, 0.25)}, 0 6px 18px rgba(0, 0, 0, 0.35); }
+ </style>
+ </head>
+ <body>
+ <div id="root" data-composition-id="deck" data-start="0" data-duration="${r4(dur)}"
+ data-width="${W}" data-height="${H}">
+ <div id="deck-clip" class="clip" data-start="0" data-duration="${r4(dur)}" data-track-index="1">
+ <div class="panel" data-k="panel">
+ <div class="ground"></div>
+ ${panel && qr ? `<div class="plate"></div>` : ""}
+ ${panel ? `<div class="edge"></div><div class="sheen"></div>` : ""}
+ <div class="track">
+ <div class="rail"></div>
+ ${fuseWidth > 0 ? `<div class="fuse" data-k="fuse"></div>` : ""}
+ ${pipHtml}
+ <div class="marker" data-k="marker"></div>
+ </div>
+ ${segHtml}
+ </div>
+ </div>
+ </div>
+
+ <script id="deck-data" type="application/json">${json}</script>
+ <script>
+ const D = JSON.parse(document.getElementById("deck-data").textContent);
+ const byK = {};
+ for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el;
+ const QR_PERSPECTIVE = 700;
+
+ // t = 0, then the cues. Every cue states its from, so a seek from
+ // anywhere to anywhere lands on the same pixels.
+ for (const k of Object.keys(D.init)) {
+ if (byK[k]) gsap.set(byK[k], k.endsWith(".qr") ? { transformPerspective: QR_PERSPECTIVE, ...D.init[k] } : D.init[k]);
+ }
+ const inner = gsap.timeline({ paused: true });
+ for (const c of D.cues) {
+ const el = byK[c.k];
+ if (!el) continue;
+ inner.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at);
+ }
+ // A window of the cut plays the whole timeline's [from, from + dur]
+ // through one tween; seeking the window seeks the cut.
+ let tl = inner;
+ if (D.window) {
+ tl = gsap.timeline({ paused: true });
+ tl.add(inner.tweenFromTo(D.window.from, D.window.from + D.window.dur,
+ { duration: D.window.dur, ease: "none" }), 0);
+ }
+ window.__timelines = window.__timelines || {};
+ window.__timelines["deck"] = tl;
+
+ // The text fit. A title wider than its column shrinks a pixel at a time
+ // to 60 % of its size, then ellipsizes (the CSS already does). It runs
+ // once the faces are in: measuring the fallback face would fit the wrong
+ // glyphs. The renderer waits on document.fonts.ready, and these loads are
+ // requested before it looks.
+ function fitTitle(node) {
+ let s = D.titleSize;
+ node.style.fontSize = s + "px";
+ while (s > D.floor && node.scrollWidth > node.clientWidth + 0.5) {
+ s -= 1;
+ node.style.fontSize = s + "px";
+ }
+ }
+ const fitAll = () => document.querySelectorAll(".deck-title").forEach(fitTitle);
+ const ready = Promise.all([
+ document.fonts.load(D.titleSize + "px DeckSansBold"),
+ document.fonts.load("26px DeckSans"),
+ ]).catch(() => {}).then(() => { fitAll(); document.documentElement.dataset.fit = "1"; });
+
+ const params = new URLSearchParams(location.search);
+ // The review still: a seek and nothing else -- the same seek the renderer
+ // makes for every frame.
+ const still = params.get("still");
+ if (still !== null) {
+ tl.seek(Number(still), false);
+ ready.then(() => tl.seek(Number(still), false));
+ }
+
+ // umtool's live preview. Never on in a render: nothing here moves a
+ // pixel unless a parent frame asks.
+ if (params.get("preview") === "1") {
+ const esc = (s) => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
+ const subMarkup = (s) => String(s ?? "").split(" · ").map((p) => '<span class="part">' + esc(p) + "</span>")
+ .join('<span class="sep">·</span>');
+ window.addEventListener("message", (e) => {
+ const m = e.data || {};
+ if (m.type === "deck:seek") tl.seek(Math.max(0, Number(m.t) || 0), false);
+ if (m.type === "deck:text") {
+ const seg = [...document.querySelectorAll("[data-seg]")].find((n) => n.dataset.seg === m.id);
+ if (!seg) return;
+ if (typeof m.title === "string") {
+ const node = seg.querySelector(".deck-title");
+ node.textContent = m.title;
+ seg.classList.toggle("untitled", !m.title);
+ fitTitle(node);
+ }
+ if (typeof m.subtitle === "string") seg.querySelector(".deck-sub").innerHTML = subMarkup(m.subtitle);
+ }
+ });
+ ready.then(() => {
+ if (window.parent !== window) {
+ window.parent.postMessage({ type: "deck:ready", total: D.total, ids: D.ids }, "*");
+ }
+ });
+ }
+ </script>
+ </body>
+</html>
+`;
+}
diff --git a/umtool/report-to-video/chrome-deck.test.mjs b/umtool/report-to-video/chrome-deck.test.mjs
@@ -0,0 +1,309 @@
+// Tests for the deck composition (chrome-deck.mjs) and compose-chrome's deck
+// path: the page has every segment's nodes, the timeline's times are
+// deckChoreography's, nothing leaves the machine, and the render cache skips
+// what it should.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import { deckCues, deckHtml, GSAP_FILE, hostOf, mix, subtitleMarkup } from "./chrome-deck.mjs";
+import { deckChoreography, deckLayout } from "./deck.mjs";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+
+const RENDER = {
+ width: 1920,
+ height: 1080,
+ fps: 30,
+ transition: 0.5,
+ palette: { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" },
+ chrome: { engine: "hyperframes", layout: "deck", deck: {} },
+};
+
+/** A cut that opens and closes on a card, with an untitled clip and a hostile title. */
+function schedule() {
+ const seg = (id, type, start, duration, extra = {}) => ({
+ id, type, start, duration, end: start + duration,
+ title: "", subtitle: "", qrUrl: null, hideDeck: false, ...extra,
+ });
+ return {
+ version: 1, kind: "deck", estimated: true, fps: 30, transition: 0.5, total: 40, multiChannel: false,
+ segments: [
+ seg("t00", "card", 0, 5, { title: "Opening", subtitle: "a card", hideDeck: true }),
+ seg("c01", "clip", 4.5, 10, { title: 'He said "largest" </script><b>', subtitle: "Ferret Rescue · Sep 5, 2024", qrUrl: "https://jasolyzer.pages.dev/?v=a&t=51" }),
+ seg("c02", "clip", 14, 8, { title: "A sanctuary", subtitle: "Ferret Rescue · Sep 5, 2024", qrUrl: "https://jasolyzer.pages.dev/?v=a&t=62" }),
+ seg("c03", "clip", 21.5, 13, { subtitle: "Untitled source · Sep 29, 2024", qrUrl: "https://www.youtube.com/watch?v=b" }),
+ seg("end", "card", 34, 6, { title: "Close", subtitle: "", hideDeck: true }),
+ ],
+ };
+}
+
+const QRS = { c01: "assets/qr00.png", c02: "assets/qr01.png", c03: "assets/qr02.png" };
+const FONTS = { regular: "assets/DeckSans.ttf", bold: "assets/DeckSansBold.ttf" };
+
+const dataOf = (html) => JSON.parse(/<script id="deck-data" type="application\/json">(.*?)<\/script>/s.exec(html)[1]);
+const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-3, `${msg}: ${a} != ${b}`);
+
+test("every segment has its own nodes: title, subtitle, QR", () => {
+ const s = schedule();
+ const html = deckHtml(s, RENDER, { fonts: FONTS, qrSrcs: QRS });
+ for (const seg of s.segments) {
+ const open = html.indexOf(`data-seg="${seg.id}"`);
+ assert.ok(open > 0, `no node for ${seg.id}`);
+ const body = html.slice(open, html.indexOf('<div class="seg', open + 1) > 0 ? html.indexOf('<div class="seg', open + 1) : undefined);
+ assert.match(body, /class="deck-title"/, `${seg.id} title`);
+ assert.match(body, /class="deck-sub"/, `${seg.id} subtitle`);
+ assert.match(body, /class="deck-qr/, `${seg.id} qr`);
+ if (QRS[seg.id]) assert.ok(body.includes(`<img src="${QRS[seg.id]}"`), `${seg.id} qr img`);
+ }
+ // The title is text, not markup, and cannot close the page's script.
+ assert.ok(html.includes("He said "largest" </script><b>"));
+ assert.equal((html.match(/<\/script>/g) ?? []).length, 3, "gsap, data, runtime -- and no fourth");
+ // An untitled clip is marked, so its source line takes the title's band.
+ assert.match(html, /class="seg untitled" data-seg="c03"/);
+ // The composition contract.
+ assert.match(html, /data-composition-id="deck" data-start="0" data-duration="40"/);
+ assert.match(html, /data-width="1920" data-height="190"/);
+ assert.match(html, /window\.__timelines\["deck"\] = tl/);
+ assert.match(html, /font-family: 'DeckSans'/);
+ assert.match(html, /font-family: 'DeckSansBold'/);
+});
+
+test("nothing on the page leaves the machine", () => {
+ const html = deckHtml(schedule(), RENDER, { fonts: FONTS, qrSrcs: QRS });
+ assert.doesNotMatch(html, /https?:\/\//, "a URL in the page");
+ assert.doesNotMatch(html, /(?:src|href)="\/\//, "a protocol-relative URL");
+ assert.doesNotMatch(html, /@import|fonts\.googleapis|cdn\./);
+ assert.match(html, /<script src="assets\/gsap\.min\.js"><\/script>/);
+ // Every font stack ends in sans-serif and names no real family.
+ for (const m of html.matchAll(/font-family: ([^;]+);/g)) {
+ assert.match(m[1], /^'DeckSans(?:Bold)?'(?:, sans-serif)?$/, m[1]);
+ }
+ // The QR's host is printed as a host, not a link.
+ assert.ok(html.includes(">jasolyzer.pages.dev<"));
+ assert.ok(html.includes(">youtube.com<"));
+});
+
+test("the chart band's GSAP is vendored too", () => {
+ const src = readFileSync(path.join(HERE, "compose-chrome.mjs"), "utf8");
+ assert.doesNotMatch(src, /cdn\.jsdelivr|unpkg|cdnjs/);
+ assert.match(src, /<script src="assets\/gsap\.min\.js"><\/script>/);
+ assert.match(readFileSync(GSAP_FILE, "utf8").slice(0, 200), /GSAP 3\.14\.2/);
+});
+
+test("the timeline's times are deckChoreography's", () => {
+ const s = schedule();
+ const ch = deckChoreography(s, RENDER);
+ const { cues } = deckCues(s, RENDER);
+ const ids = s.segments.map((x) => x.id);
+ const at = (k, why) => cues.filter((c) => c.k === k && c.why.startsWith(why));
+ assert.equal(ch.handovers.length, 2);
+ for (const h of ch.handovers) {
+ const a = ids.indexOf(h.from), b = ids.indexOf(h.to);
+ const why = `handover ${h.from}->${h.to}`;
+ const [out] = at(`s${a}.tc`, `${why} out`);
+ near(out.at, h.out[0], "title out starts");
+ near(out.at + out.dur, h.out[1], "title out ends");
+ const [inn] = at(`s${b}.tc`, `${why} in`);
+ near(inn.at, h.in[0], "title in starts");
+ near(inn.at + inn.dur, h.in[1], "title in ends");
+ assert.equal(inn.ease, "power3.out");
+ const [sub] = at(`s${b}.sc`, `${why} sub`);
+ near(sub.at, h.sub[0], "subtitle trails");
+ near(sub.at + sub.dur, h.sub[1], "subtitle ends");
+ const [rule] = at(`s${b}.rule`, `${why} in`);
+ near(rule.at, h.in[0], "the rule grows with the title");
+ const [qrOut] = at(`s${a}.qr`, `${why} qr`);
+ const [qrIn] = at(`s${b}.qr`, `${why} qr`);
+ near(qrOut.at, h.qr[0], "qr flips out");
+ near(qrOut.at + qrOut.dur, h.m, "qr edge-on at m");
+ near(qrIn.at, h.m, "qr flips in from m");
+ near(qrIn.at + qrIn.dur, h.qr[1], "qr square again");
+ const [pip] = at("marker", why);
+ near(pip.at, h.pip[0], "marker leaves");
+ near(pip.at + pip.dur, h.pip[1], "marker arrives");
+ }
+ // The slides: down off the first card from the first frame, up into c01, down into the end card.
+ const { init } = deckCues(s, RENDER);
+ assert.equal(init.panel.y, 202);
+ const slides = cues.filter((c) => c.k === "panel");
+ const vis = ch.visibility.filter((v) => v.at[1] > v.at[0]);
+ assert.equal(slides.length, vis.length);
+ slides.forEach((c, n) => {
+ near(c.at, vis[n].at[0], "slide starts");
+ near(c.at + c.dur, vis[n].at[1], "slide ends");
+ assert.equal(c.to.y, vis[n].hide ? 202 : 0);
+ });
+});
+
+test("the page carries exactly deckCues' list", () => {
+ const s = schedule();
+ const { init, cues } = deckCues(s, RENDER);
+ const d = dataOf(deckHtml(s, RENDER, { fonts: FONTS, qrSrcs: QRS }));
+ assert.deepEqual(d.init, init);
+ assert.deepEqual(d.cues, cues.map(({ why, ...c }) => c));
+ assert.equal(d.window, null);
+ assert.deepEqual(d.ids, s.segments.map((x) => x.id));
+ assert.equal(d.floor, Math.ceil(54 * 0.6));
+});
+
+test("every cue states the from the cue before it left, and none overlaps another on one element", () => {
+ const { init, cues } = deckCues(schedule(), RENDER);
+ const state = JSON.parse(JSON.stringify(init));
+ const busy = new Map();
+ for (const c of cues) {
+ for (const [p, v] of Object.entries(c.from)) assert.deepEqual(v, state[c.k]?.[p], `${c.k}.${p} at ${c.at}`);
+ Object.assign((state[c.k] ??= {}), c.to);
+ assert.ok(c.at >= (busy.get(c.k) ?? 0) - 1e-9, `${c.k} overlaps at ${c.at}`);
+ busy.set(c.k, c.at + c.dur);
+ }
+ for (let i = 1; i < cues.length; i += 1) assert.ok(cues[i].at >= cues[i - 1].at, "in time order");
+});
+
+test("pips: one per segment shown with the deck, none for cards, all lit by the end", () => {
+ const s = schedule();
+ const { pips, cues, init } = deckCues(s, RENDER);
+ assert.deepEqual(pips.map((p) => p.id), ["c01", "c02", "c03"]);
+ const lay = deckLayout(RENDER);
+ assert.equal(pips[0].x, lay.pipTrack.x0);
+ assert.equal(pips[2].x, lay.pipTrack.x1);
+ for (let k = 0; k < 2; k += 1) {
+ assert.equal(init[`pip${k}`].opacity, 0);
+ assert.ok(cues.some((c) => c.k === `pip${k}` && c.to.opacity === 1), `pip ${k} is lit once passed`);
+ }
+ // The fuse reaches each next pip as the marker sets off for it.
+ const fuse = cues.filter((c) => c.k === "fuse");
+ const marker = cues.filter((c) => c.k === "marker" && c.dur > 0.01);
+ assert.equal(fuse.length, 2);
+ near(fuse[0].at + fuse[0].dur, marker[0].at, "fuse meets the marker");
+ assert.equal(fuse[1].to.scaleX, 1);
+});
+
+test("a window plays the cut's own clock from `from`", () => {
+ const s = schedule();
+ const html = deckHtml(s, RENDER, { fonts: FONTS, qrSrcs: QRS, from: 12, duration: 6 });
+ assert.match(html, /data-composition-id="deck" data-start="0" data-duration="6"/);
+ assert.deepEqual(dataOf(html).window, { from: 12, dur: 6 });
+ assert.throws(() => deckHtml(s, RENDER, { from: 40 }), /nothing to render/);
+});
+
+test("flush has no lift; panel does", () => {
+ const flush = { ...RENDER, chrome: { ...RENDER.chrome, deck: { background: "flush" } } };
+ const f = deckHtml(schedule(), flush, { fonts: FONTS, qrSrcs: QRS });
+ assert.match(f, new RegExp(`linear-gradient\\(180deg, ${RENDER.palette.bg} 0%, ${RENDER.palette.bg} 100%\\)`));
+ assert.doesNotMatch(f, /class="edge"/);
+ const p = deckHtml(schedule(), RENDER, { fonts: FONTS, qrSrcs: QRS });
+ assert.match(p, /class="edge"/);
+ assert.ok(p.includes(mix(RENDER.palette.bg, RENDER.palette.fg, 0.095)));
+});
+
+test("qr.show false: no codes anywhere", () => {
+ const off = { ...RENDER, chrome: { ...RENDER.chrome, deck: { qr: { show: false } } } };
+ const html = deckHtml(schedule(), off, { fonts: FONTS, qrSrcs: {} });
+ assert.doesNotMatch(html, /<img/);
+ assert.doesNotMatch(html, /class="plate"/);
+});
+
+test("subtitleMarkup and hostOf", () => {
+ assert.equal(
+ subtitleMarkup("A <b> · Sep 5, 2024"),
+ '<span class="part">A <b></span><span class="sep">·</span><span class="part">Sep 5, 2024</span>',
+ );
+ assert.equal(subtitleMarkup(""), '<span class="part"></span>');
+ assert.equal(hostOf("https://www.youtube.com/watch?v=x"), "youtube.com");
+ assert.equal(hostOf("not a url"), "");
+});
+
+// ---------------------------------------------------------------------------
+// compose-chrome's deck path, end to end with a stub renderer.
+// ---------------------------------------------------------------------------
+
+const haveTools = ["qrencode", "magick"].every(
+ (b) => spawnSync(b, [b === "magick" ? "-version" : "-V"], { stdio: "ignore" }).status === 0,
+);
+
+test("composeChrome(deck): project, frames, cache hit, and a changed title re-renders", { skip: !haveTools }, async () => {
+ const { composeChrome } = await import("./compose-chrome.mjs");
+ const dir = mkdtempSync(path.join(tmpdir(), "deck-s2-"));
+ const prevBin = process.env.HYPERFRAMES_BIN;
+ try {
+ // A renderer stub: writes as many frames as the root declares and logs each run.
+ const stub = path.join(dir, "hf-stub.mjs");
+ writeFileSync(stub, `#!/usr/bin/env node
+import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+const a = process.argv.slice(2);
+const out = a[a.indexOf("--output") + 1], fps = Number(a[a.indexOf("--fps") + 1]), proj = a[a.length - 1];
+const dur = Number(/data-duration="([\\d.]+)"/.exec(readFileSync(path.join(proj, "index.html"), "utf8"))[1]);
+mkdirSync(out, { recursive: true });
+for (let i = 1; i <= Math.round(dur * fps); i += 1) writeFileSync(path.join(out, "frame_" + String(i).padStart(6, "0") + ".png"), "");
+appendFileSync(${JSON.stringify(path.join(dir, "runs.log"))}, a.join(" ") + "\\n");
+`);
+ chmodSync(stub, 0o755);
+ process.env.HYPERFRAMES_BIN = stub;
+
+ const manifest = {
+ slug: "t", title: "t", provenance: {},
+ render: {
+ ...RENDER,
+ fontRegular: path.join(HERE, "fonts", "IBMPlexMono-Regular.ttf"),
+ fontBold: path.join(HERE, "fonts", "IBMPlexMono-Bold.ttf"),
+ },
+ timeline: [],
+ };
+ const manifestPath = path.join(dir, "video.manifest.json");
+ writeFileSync(manifestPath, JSON.stringify(manifest));
+ const sched = { ...schedule(), total: 2, segments: schedule().segments.slice(1, 3).map((x, i) => ({ ...x, start: i * 1, end: i + 1, duration: 1 })) };
+ const runs = () => { try { return readFileSync(path.join(dir, "runs.log"), "utf8").trim().split("\n").length; } catch { return 0; } };
+
+ const first = await composeChrome({ manifestPath, region: "deck", schedule: sched, doRender: true, workers: 2 });
+ assert.equal(first.cached, false);
+ assert.equal(first.frameCount, 60);
+ assert.equal(first.projDir, path.join(dir, "out", "sourced", "chrome", "deck"));
+ assert.equal(first.frames, path.join(dir, "out", "sourced", "chrome", "deck-frames"));
+ assert.equal(readFileSync(path.join(first.frames, ".key"), "utf8").trim(), first.key);
+ assert.equal(runs(), 1);
+ const html = readFileSync(path.join(first.projDir, "index.html"), "utf8");
+ assert.match(html, /src="assets\/qr00\.png"/);
+ assert.match(html, /url\('assets\/DeckSansBold\.ttf'\)/);
+ assert.match(readFileSync(path.join(dir, "runs.log"), "utf8"), /render --format png-sequence --quality high --fps 30 -w 2 --no-browser-gpu --output/);
+
+ const again = await composeChrome({ manifestPath, region: "deck", schedule: sched, doRender: true, workers: 2 });
+ assert.equal(again.cached, true);
+ assert.equal(again.key, first.key, "same inputs, same key (the QR PNGs are byte-stable)");
+ assert.equal(runs(), 1);
+
+ const edited = { ...sched, segments: sched.segments.map((x, i) => (i ? x : { ...x, title: "Edited" })) };
+ const third = await composeChrome({ manifestPath, region: "deck", schedule: edited, doRender: true, workers: 2 });
+ assert.equal(third.cached, false);
+ assert.notEqual(third.key, first.key);
+ assert.equal(runs(), 2);
+
+ // A preview composes elsewhere and never renders.
+ const prev = await composeChrome({ manifestPath, region: "deck", schedule: sched, preview: true, doRender: true });
+ assert.equal(prev.projDir, path.join(dir, "out", "sourced", "chrome", "deck-preview"));
+ assert.equal(prev.frames, null);
+ assert.equal(runs(), 2);
+
+ // A window is its own project and frames.
+ const win = await composeChrome({ manifestPath, region: "deck", schedule: sched, doRender: true, from: 0.5, duration: 1 });
+ assert.equal(win.frames, path.join(dir, "out", "sourced", "chrome", "deck-from0.5-frames"));
+ assert.equal(win.frameCount, 30);
+
+ // Without a schedule passed in, it reads out/<variant>/schedule.json -- and says so when there is none.
+ await assert.rejects(
+ composeChrome({ manifestPath, region: "deck", outDir: path.join(dir, "nowhere") }),
+ /the deck needs .*schedule\.json/,
+ );
+ } finally {
+ if (prevBin === undefined) delete process.env.HYPERFRAMES_BIN;
+ else process.env.HYPERFRAMES_BIN = prevBin;
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs
@@ -31,16 +31,23 @@
// and the crossfade -- so the build writes the schedule and this reads it.
// Recomputing it here would be a second implementation of segmentOffsets() and
// would drift the first time the transition changed.
-import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
-import { execFile } from "node:child_process";
+import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
+import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import { ledgerTotals, dateKey } from "./ledger-totals.mjs";
import { selectVariant } from "./build-video.mjs";
+import { chromeCacheKey, deckLayout, frameCount, hyperframesCommand, sha256 } from "./deck.mjs";
+import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs";
const run = promisify(execFile);
+/** Chromium, for the review still. `CHROME` overrides it. */
+const CHROME = process.env.CHROME ?? "/usr/bin/chromium";
+const QRENCODE = process.env.QRENCODE_BIN ?? "qrencode";
+const MAGICK = process.env.MAGICK_BIN ?? "magick";
+
const esc = (s) =>
String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
@@ -345,7 +352,7 @@ export function chartBandHtml(manifest, totals, schedule, opts = {}) {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=${W}, height=${H}" />
- <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
+ <script src="assets/gsap.min.js"></script>
<style>
/* The REAL files, copied in beside the composition. A bare local()
resolves in a desktop browser and FAILS in the render browser, which
@@ -523,7 +530,44 @@ export function chartBandHtml(manifest, totals, schedule, opts = {}) {
}
// ---------------------------------------------------------------------------
-// CLI
+// QR codes, for the deck.
+// ---------------------------------------------------------------------------
+
+/**
+ * One QR as a PNG of exactly `size` px.
+ *
+ * `-filter point`: any resampling filter blurs the module edges, and a blurred
+ * QR stops scanning. `-strip`: ImageMagick stamps a creation date into every
+ * PNG, and an asset whose bytes change each run is a render cache that never
+ * hits. Fully opaque, with its quiet zone -- the white border is part of the
+ * symbol, not decoration.
+ */
+export async function qrPng(url, render, outPath, size) {
+ const q = render.qr ?? {};
+ const raw = `${outPath}.raw.png`;
+ await run(QRENCODE, ["-o", raw, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", url]);
+ await run(MAGICK, [raw, "-filter", "point", "-resize", `${size}x${size}!`, "-strip", outPath]);
+ await rm(raw, { force: true });
+ return outPath;
+}
+
+/**
+ * One PNG per DISTINCT url into `assetsDir`, named in first-seen order.
+ * Returns `Map(url -> "assets/qrNN.png")`.
+ */
+export async function qrPngsFor(urls, render, size, assetsDir) {
+ const out = new Map();
+ for (const url of urls) {
+ if (!url || out.has(url)) continue;
+ const name = `qr${String(out.size).padStart(2, "0")}.png`;
+ await qrPng(url, render, path.join(assetsDir, name), size);
+ out.set(url, `assets/${name}`);
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Regions.
// ---------------------------------------------------------------------------
const HF_JSON = JSON.stringify(
@@ -532,68 +576,273 @@ const HF_JSON = JSON.stringify(
2,
);
-export async function composeChrome({ manifestPath, outDir, region = "chart", duration = null, doRender = false, fps = null, variant = "sourced" }) {
- // The variant's view, and its own out directory. The band plots the ledger
- // the cut carries; handed the whole manifest it would draw marks for claims
- // this cut never makes and put the playhead schedule out by that many.
- const manifest = selectVariant(JSON.parse(await readFile(manifestPath, "utf8")), variant);
- const base = outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out", variant);
- const schedule = JSON.parse(await readFile(path.join(base, "schedule.json"), "utf8"));
- const totals = ledgerTotals(manifest.ledger);
-
- const projDir = path.join(base, "chrome", region);
- await mkdir(path.join(projDir, "assets"), { recursive: true });
- if (region !== "chart") throw new Error(`unknown chrome region: ${region}`);
-
- // The SAME faces the ffmpeg cards use, copied in beside the composition.
- // Chrome will not resolve a bare local() in the render browser, and the
- // failure is silent: it falls back and every metric in the band shifts.
+/**
+ * The faces a region draws in, copied in beside it under fixed names.
+ *
+ * Chrome will not resolve a bare local() in the render browser, and the failure
+ * is silent: it falls back and every metric shifts. The deck refuses a missing
+ * face outright -- the band, shipped before this rule, keeps its old leniency.
+ */
+async function copyFonts(render, assetsDir, names, { strict }) {
const fonts = {};
- for (const [slot, src] of [["regular", manifest.render.fontRegular], ["bold", manifest.render.fontBold]]) {
- if (!src) continue;
- const name = `${slot}${path.extname(src) || ".ttf"}`;
- await copyFile(src, path.join(projDir, "assets", name)).catch(() => {});
+ for (const [slot, src, base] of [
+ ["regular", render.fontRegular, names.regular],
+ ["bold", render.fontBold, names.bold],
+ ]) {
+ if (!src) {
+ if (strict) throw new Error(`the deck needs render.${slot === "regular" ? "fontRegular" : "fontBold"}`);
+ continue;
+ }
+ const name = `${base}${path.extname(src) || ".ttf"}`;
+ try {
+ await copyFile(src, path.join(assetsDir, name));
+ } catch (e) {
+ if (strict) throw new Error(`the deck's ${slot} face ${src} cannot be copied: ${e.message}`);
+ // The band as it shipped: the url stays, and the browser falls back.
+ }
fonts[slot] = `assets/${name}`;
}
+ return fonts;
+}
- const html = chartBandHtml(manifest, totals, schedule, {
- fonts,
- ...(duration ? { duration } : {}),
+/**
+ * Which HTML each chrome region is, with the assets it needs written into
+ * `projDir/assets`. The chart's branch is the band as it shipped; only where
+ * its GSAP comes from has changed.
+ */
+async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from }) {
+ if (region === "chart") {
+ const sched = schedule ?? JSON.parse(await readFile(path.join(base, "schedule.json"), "utf8"));
+ const fonts = await copyFonts(manifest.render, assetsDir, { regular: "regular", bold: "bold" }, { strict: false });
+ const totals = ledgerTotals(manifest.ledger);
+ return chartBandHtml(manifest, totals, sched, { fonts, ...(duration ? { duration } : {}) });
+ }
+ if (region === "deck") {
+ const render = manifest.render;
+ const fonts = await copyFonts(render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true });
+ const lay = deckLayout(render);
+ const qrSrcs = {};
+ if (lay.qr) {
+ const byUrl = await qrPngsFor(schedule.segments.map((s) => s.qrUrl), render, lay.qr.size, assetsDir);
+ for (const s of schedule.segments) if (s.qrUrl) qrSrcs[s.id] = byUrl.get(s.qrUrl);
+ }
+ return deckHtml(schedule, render, { fonts, qrSrcs, from, duration });
+ }
+ throw new Error(`unknown chrome region: ${region}`);
+}
+
+/** The root's declared size and length, read back off the HTML rather than re-derived. */
+function compositionSize(html) {
+ const w = /data-width="(\d+)"/.exec(html);
+ const h = /data-height="(\d+)"/.exec(html);
+ const d = /data-composition-id="[^"]*"[^>]*data-duration="([\d.]+)"/.exec(html);
+ return { width: Number(w?.[1] ?? 1920), height: Number(h?.[1] ?? 1080), duration: Number(d?.[1] ?? 0) };
+}
+
+const fmtSeconds = (v) => String(Math.round(v * 1000) / 1000);
+
+async function framesOnDisk(dir) {
+ try {
+ return (await readdir(dir)).filter((f) => /^frame_\d+\.png$/.test(f)).length;
+ } catch {
+ return 0;
+ }
+}
+
+/** Run the renderer, its chatter to stderr (stdout may be a build's NDJSON). */
+function runRenderer(cmd, args) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
+ let tail = "";
+ const keep = (b) => {
+ process.stderr.write(b);
+ tail = (tail + b.toString()).slice(-4000);
+ };
+ child.stdout.on("data", keep);
+ child.stderr.on("data", keep);
+ child.on("error", reject);
+ child.on("close", (code) =>
+ code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited ${code}\n${tail}`)),
+ );
+ });
+}
+
+/**
+ * Compose a chrome region and, optionally, render it or take a still of it.
+ *
+ * Deck (`region: "deck"`):
+ * - project `out/<variant>/chrome/deck/` (`deck-preview/` when `preview`, which
+ * never renders and so never disturbs a build's project or cache);
+ * - frames `chrome/deck-frames/frame_%06d.png` and `deck-frames/.key`;
+ * - a window (`from` > 0, or a `duration` shorter than the cut) is its own
+ * project and frames, `deck-from<s>[-frames]`, so a preview slice never
+ * overwrites the whole cut's sequence;
+ * - the render is SKIPPED when `.key` equals this compose's `chromeCacheKey`
+ * and the frame count on disk is `frameCount(duration, fps)`.
+ *
+ * `schedule` (an object) overrides reading `out/<variant>/schedule.json`.
+ *
+ * @returns {Promise<{ projDir: string, frames: string|null, still: string|null,
+ * cached: boolean, key: string|null, frameCount: number|null }>}
+ */
+export async function composeChrome({
+ manifestPath, outDir = null, variant = "sourced", region = "chart",
+ schedule = null, preview = false, doRender = false,
+ fps = null, workers = null, quality = "high", format = "png-sequence",
+ still = null, png = null, from = 0, duration = null,
+}) {
+ // The variant's view, and its own out directory. Handed the whole manifest
+ // the band would draw claims this cut never makes, and the deck would name
+ // clips it does not play.
+ const manifest = selectVariant(JSON.parse(await readFile(manifestPath, "utf8")), variant);
+ // Absolute: the still is a file:// URL, and a relative one is no page at all.
+ const base = path.resolve(outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out", variant));
+ from = Number(from ?? 0);
+
+ let sched = schedule;
+ if (region === "deck" && !sched) {
+ const p = path.join(base, "schedule.json");
+ try {
+ sched = JSON.parse(await readFile(p, "utf8"));
+ } catch (e) {
+ throw new Error(`the deck needs ${p} (the build writes it) or a schedule passed in: ${e.message}`);
+ }
+ if (sched.kind !== "deck") throw new Error(`${p} is not a deck schedule (kind ${sched.kind ?? "missing"})`);
+ }
+ const total = region === "deck" ? sched.total : null;
+ const windowed =
+ region === "deck" && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6));
+ const suffix = windowed ? `-from${fmtSeconds(from)}` : "";
+
+ const projName = region === "deck" && preview ? "deck-preview" : `${region}${suffix}`;
+ const projDir = path.join(base, "chrome", projName);
+ const assetsDir = path.join(projDir, "assets");
+ // The deck's assets are rebuilt every time: a QR from a clip that has since
+ // left the cut must not sit in the directory the cache key hashes.
+ if (region === "deck") await rm(assetsDir, { recursive: true, force: true });
+ await mkdir(assetsDir, { recursive: true });
+ await copyFile(GSAP_FILE, path.join(assetsDir, "gsap.min.js"));
+
+ const html = await regionHtml(region, {
+ manifest, base, projDir, assetsDir, schedule: sched,
+ duration: duration != null ? Number(duration) : null, from,
});
await writeFile(path.join(projDir, "index.html"), html, "utf8");
await writeFile(path.join(projDir, "hyperframes.json"), HF_JSON + "\n", "utf8");
- if (!doRender) return { projDir, frames: null };
+ const size = compositionSize(html);
+ const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30);
+ const hf = hyperframesCommand(process.env);
+ let key = null;
+ let frames = null;
+ if (region === "deck") {
+ frames = frameCount(size.duration, rate);
+ const assets = [];
+ for (const name of (await readdir(assetsDir)).sort()) {
+ assets.push([name, sha256(await readFile(path.join(assetsDir, name)))]);
+ }
+ key = chromeCacheKey({ html, assets, fps: rate, frames, version: hf.version });
+ }
+ const result = { projDir, frames: null, still: null, cached: false, key, frameCount: frames };
+
+ // The review still: seek and screenshot, no HyperFrames, ~2 s. It is the SAME
+ // seek the renderer performs for every frame, which is why a still that is
+ // right is evidence about the frames and not just about the markup. The
+ // background is transparent, as the frames' is.
+ if (still != null) {
+ const out = path.resolve(png ?? path.join(projDir, `still-${fmtSeconds(Number(still))}.png`));
+ await mkdir(path.dirname(out), { recursive: true });
+ await run(CHROME, [
+ "--headless", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
+ "--default-background-color=00000000",
+ `--window-size=${size.width},${size.height}`,
+ "--virtual-time-budget=6000",
+ `--screenshot=${out}`,
+ `file://${path.join(projDir, "index.html")}?still=${Number(still)}`,
+ ], { maxBuffer: 1 << 26 });
+ return { ...result, still: out };
+ }
+
+ if (!doRender || (region === "deck" && preview)) return result;
- const frames = path.join(base, "chrome", `${region}-frames`);
- await run("npx", [
- "--yes", "hyperframes@latest", "render",
- "--format", "png-sequence", "--quality", "high",
- "--fps", String(fps ?? manifest.render.fps),
- "--output", frames, projDir,
- ], { maxBuffer: 1 << 26 });
- return { projDir, frames };
+ // A sequence is a directory; every other format is a file.
+ const sequence = format === "png-sequence";
+ const target = sequence
+ ? path.join(base, "chrome", `${region}${suffix}-frames`)
+ : path.join(base, "chrome", `${region}${suffix}.${format}`);
+ const keyFile = path.join(target, ".key");
+
+ if (region === "deck" && sequence) {
+ const onDisk = await readFile(keyFile, "utf8").then((s) => s.trim(), () => null);
+ if (onDisk === key && (await framesOnDisk(target)) === frames) {
+ return { ...result, frames: target, cached: true };
+ }
+ // Stale frames past the new count would be overlaid as the tail of the cut.
+ await rm(target, { recursive: true, force: true });
+ }
+
+ const args = [
+ ...hf.args, "render",
+ "--format", format, "--quality", quality,
+ "--fps", String(rate),
+ ...(workers != null ? ["-w", String(workers)] : []),
+ // The deck is flat colour and text: software GL is deterministic and the
+ // GPU probe is a second per worker for nothing.
+ ...(region === "deck" ? ["--no-browser-gpu"] : []),
+ "--output", target, projDir,
+ ];
+ await runRenderer(hf.cmd, args);
+
+ if (region === "deck" && sequence) {
+ const got = await framesOnDisk(target);
+ if (got !== frames) {
+ throw new Error(`the deck render wrote ${got} frames to ${target}, expected ${frames} (${size.duration}s at ${rate} fps)`);
+ }
+ await writeFile(keyFile, key + "\n", "utf8");
+ }
+ return { ...result, frames: target };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const argv = process.argv.slice(2);
const flag = (n) => { const i = argv.indexOf(n); return i < 0 ? null : argv[i + 1]; };
- const VALUED = new Set(["--out", "--region", "--duration", "--variant"]);
+ const VALUED = new Set([
+ "--out", "--region", "--duration", "--variant", "--from",
+ "--still", "--png", "--workers", "--quality", "--format", "--fps",
+ ]);
const manifestPath = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1]));
if (!manifestPath) {
console.error(
- "usage: compose-chrome.mjs <manifest.json> [--region chart] [--variant sourced|full]\n" +
- " [--duration <s>] [--out <dir>] [--render]",
+ "usage: compose-chrome.mjs <manifest.json> [--region chart|deck] [--variant sourced|full]\n" +
+ " [--from <s>] [--duration <s>] [--out <dir>] [--preview]\n" +
+ " [--still <s> --png <path>]\n" +
+ " [--render] [--workers 4] [--quality high] [--format png-sequence] [--fps 30]",
);
process.exit(2);
}
+ const num = (n) => (flag(n) == null ? null : Number(flag(n)));
+ const region = flag("--region") ?? "chart";
+ const started = Date.now();
const r = await composeChrome({
manifestPath,
outDir: flag("--out"),
- region: flag("--region") ?? "chart",
+ region,
variant: flag("--variant") ?? "sourced",
- duration: flag("--duration") ? Number(flag("--duration")) : null,
+ preview: argv.includes("--preview"),
+ duration: num("--duration"),
+ from: num("--from") ?? 0,
+ still: num("--still"),
+ png: flag("--png"),
+ // The deck renders four-wide by default; the band keeps the renderer's own default.
+ workers: num("--workers") ?? (region === "deck" ? 4 : null),
+ quality: flag("--quality") ?? "high",
+ format: flag("--format") ?? "png-sequence",
+ fps: num("--fps"),
doRender: argv.includes("--render"),
});
- console.log(r.frames ? `frames -> ${r.frames}` : `project -> ${r.projDir}`);
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
+ if (r.still) console.log(`still -> ${r.still} (${secs}s)`);
+ else if (r.frames) console.log(`frames -> ${r.frames}${r.cached ? " (cached)" : ""} (${r.frameCount ?? "?"} frames, ${secs}s)`);
+ else console.log(`project -> ${r.projDir}`);
+ if (r.key) console.log(`key ${r.key}`);
}
diff --git a/umtool/report-to-video/deck-build.test.mjs b/umtool/report-to-video/deck-build.test.mjs
@@ -0,0 +1,113 @@
+// Tests for the build's half of the deck: the framing filters, the regions the
+// overlay is placed by, the footer a card reserves, and the chapter name. The
+// geometry itself is deck.mjs's and is tested there; these check the build
+// reads it rather than restating it.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+import path from "node:path";
+
+import { chapterTitle, chromeRegions, concatListText, deckFraming, deckFramingFilter, sameConcatList } from "./build-video.mjs";
+import { deckGeometry } from "./deck.mjs";
+import { reservedFooterHeight } from "./render-cards.mjs";
+
+const PALETTE = { bg: "#15121c", fg: "#ece8f4", muted: "#9a93ad", accent: "#7c5cff", amber: "#f2b84b" };
+const BASE = { width: 1920, height: 1080, fps: 30, palette: PALETTE };
+const deck = (d = {}) => ({ ...BASE, chrome: { engine: "hyperframes", layout: "deck", deck: d } });
+
+test("deckFraming: the default box is 1574x886 at (173,2), the rest is ground", () => {
+ const f = deckFraming(deck());
+ assert.deepEqual(f.box, { x: 173, y: 2, width: 1574, height: 886 });
+ assert.deepEqual(f.fit, [
+ "scale=1574:886:force_original_aspect_ratio=decrease",
+ `pad=1574:886:(ow-iw)/2:(oh-ih)/2:color=${PALETTE.bg}`,
+ ]);
+ assert.deepEqual(f.place, [`pad=1920:1080:173:2:color=${PALETTE.bg}`, "setsar=1", "fps=30"]);
+});
+
+test("deckFraming: follows deckGeometry for other settings, never its own numbers", () => {
+ const r = deck({ height: 240, footageScale: 0.7 });
+ const g = deckGeometry(r);
+ const f = deckFraming(r);
+ assert.deepEqual(f.box, g.footage);
+ assert.equal(f.place[0], `pad=1920:1080:${g.footage.x}:${g.footage.y}:color=${PALETTE.bg}`);
+ // The bottom of the box sits at or above the deck's top edge.
+ assert.ok(g.footage.y + g.footage.height <= g.deck.y);
+});
+
+test("deckFramingFilter: fit then place, one chain", () => {
+ assert.equal(
+ deckFramingFilter(deck()),
+ [
+ "scale=1574:886:force_original_aspect_ratio=decrease",
+ `pad=1574:886:(ow-iw)/2:(oh-ih)/2:color=${PALETTE.bg}`,
+ `pad=1920:1080:173:2:color=${PALETTE.bg}`,
+ "setsar=1",
+ "fps=30",
+ ].join(","),
+ );
+});
+
+test("reservedFooterHeight: the deck reserves nothing over hidden cards, its height over shown ones", () => {
+ assert.equal(reservedFooterHeight(deck()), 0);
+ assert.equal(reservedFooterHeight(deck({ overCards: "hide" })), 0);
+ assert.equal(reservedFooterHeight(deck({ overCards: "show" })), 190);
+ assert.equal(reservedFooterHeight(deck({ overCards: "show", height: 240 })), 240);
+});
+
+test("reservedFooterHeight: without a deck, unchanged", () => {
+ assert.equal(reservedFooterHeight(BASE), 100);
+ assert.equal(reservedFooterHeight({ ...BASE, footerHeight: 92 }), 92);
+ assert.equal(reservedFooterHeight({ ...BASE, chromeEngine: "hyperframes" }), 200);
+ assert.equal(reservedFooterHeight({ ...BASE, chromeEngine: "hyperframes", chart: { height: 240 } }), 240);
+});
+
+test("chromeRegions: the deck is one full-width region at the bottom", () => {
+ assert.deepEqual(chromeRegions(deck(), "/o/sourced"), [
+ { name: "deck", frames: "/o/sourced/chrome/deck-frames", x: 0, y: 890, width: 1920, height: 190 },
+ ]);
+ assert.deepEqual(chromeRegions(deck({ height: 240 }), "/o")[0], {
+ name: "deck", frames: "/o/chrome/deck-frames", x: 0, y: 840, width: 1920, height: 240,
+ });
+});
+
+test("chromeRegions: the chart band's branch is untouched", () => {
+ assert.deepEqual(chromeRegions({ ...BASE, chromeEngine: "hyperframes" }, "/o"), [
+ { name: "chart", frames: "/o/chrome/chart-frames", x: 0, y: 880, width: 1920, height: 200 },
+ ]);
+});
+
+test("chapterTitle: chapter, then (under the deck) the on-screen title, then the derived line", async () => {
+ const PROV = { siteOrigin: "https://example.pages.dev", channelSlug: "chan" };
+ const DECK = { deck: true };
+ // A clip with an on-screen title under the deck never reaches the metadata
+ // lookup, so this needs no cue source and no network.
+ const clip = { type: "clip", id: "c01", video: "abc", start: 1, end: 9, onscreen: { title: "County says yes" } };
+ assert.equal(await chapterTitle(clip, 0, PROV, DECK), "County says yes");
+ assert.equal(await chapterTitle({ ...clip, chapter: "Authored" }, 0, PROV, DECK), "Authored");
+ const card = { type: "card", id: "t00", heading: "The heading", title: "The title" };
+ assert.equal(await chapterTitle(card, 0, PROV, DECK), "The title");
+ assert.equal(await chapterTitle({ ...card, onscreen: { title: "On screen" } }, 0, PROV, DECK), "On screen");
+ assert.equal(await chapterTitle({ type: "card", id: "x" }, 4, PROV, DECK), "Card 5");
+ // A subtitle alone is not a title.
+ assert.equal(await chapterTitle({ ...card, onscreen: { subtitle: "only" } }, 0, PROV, DECK), "The title");
+ // Without the deck an on-screen title is not drawn, so it names no chapter:
+ // a manifest without render.chrome builds exactly as it did.
+ assert.equal(await chapterTitle({ ...card, onscreen: { title: "On screen" } }, 0, PROV), "The title");
+ assert.equal(await chapterTitle({ ...card, onscreen: { title: "On screen" } }, 0, PROV, { deck: false }), "The title");
+});
+
+test("sameConcatList: a cached hard-cut concat is reused only for the same segments in the same order", () => {
+ const segs = ["out/sourced/segments/c01.mp4", "out/sourced/segments/c02.mp4", "out/sourced/segments/c03.mp4"];
+ const recorded = concatListText(segs);
+ assert.equal(sameConcatList(recorded, segs), true);
+ // Reordered: same files, same total length -- the case length and age miss.
+ assert.equal(sameConcatList(recorded, [segs[1], segs[0], segs[2]]), false);
+ assert.equal(sameConcatList(recorded, segs.slice(0, 2)), false);
+ assert.equal(sameConcatList(recorded, [...segs, "out/sourced/segments/c04.mp4"]), false);
+ // No record (a concat made before this check existed) is never reused.
+ assert.equal(sameConcatList(null, segs), false);
+ // Absolute and relative spellings of the same files are the same list.
+ assert.equal(sameConcatList(recorded, segs.map((x) => path.resolve(x))), true);
+});
diff --git a/umtool/report-to-video/deck-overlay.test.mjs b/umtool/report-to-video/deck-overlay.test.mjs
@@ -0,0 +1,132 @@
+// Tests for the deck's overlay in the build (slice S3): the overlay chain the
+// deck's frames go through, the hard-cut pass that lays it (applyChrome), the
+// preview window, and the CLI flags. All pure -- argv and filtergraph strings,
+// no ffmpeg run.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ applyChromeArgs, chromeFlags, chromeOverlayChain, chromeRegions, previewFromSegmentsArgs,
+ windowSegments,
+} from "./build-video.mjs";
+
+const PALETTE = { bg: "#15121c", fg: "#ece8f4", muted: "#9a93ad", accent: "#7c5cff", amber: "#f2b84b" };
+const BASE = { width: 1920, height: 1080, fps: 30, palette: PALETTE, crf: 21, preset: "slow" };
+const DECK = { ...BASE, chrome: { engine: "hyperframes", layout: "deck", deck: {} } };
+const CHART = { ...BASE, chromeEngine: "hyperframes" };
+
+test("chromeOverlayChain: the chart band's chain and inputs are as they shipped", () => {
+ const hf = chromeOverlayChain(CHART, chromeRegions(CHART, "/o"), "[v3]", 4, { outLabel: "[hfout]", final: false });
+ assert.deepEqual(hf.inputs, ["-framerate", "30", "-start_number", "1", "-i", "/o/chrome/chart-frames/frame_%06d.png"]);
+ assert.equal(hf.chain, "[v3][4:v]overlay=x=0:y=880:format=yuv444:shortest=1[hfout]");
+ const fin = chromeOverlayChain(CHART, chromeRegions(CHART, "/o"), "[v3]", 4);
+ assert.equal(fin.chain, "[v3][4:v]overlay=x=0:y=880:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]");
+ assert.equal(fin.outLabel, "[vout]");
+});
+
+test("chromeOverlayChain: the deck's input keeps the graph (-reinit_filter 0) and is pinned to rgba", () => {
+ const hf = chromeOverlayChain(DECK, chromeRegions(DECK, "/o/sourced"), "[v16]", 17);
+ assert.deepEqual(hf.inputs, [
+ "-reinit_filter", "0",
+ "-framerate", "30", "-start_number", "1",
+ "-i", "/o/sourced/chrome/deck-frames/frame_%06d.png",
+ ]);
+ assert.equal(
+ hf.chain,
+ "[17:v]format=rgba[hfa0];[v16][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]",
+ );
+ // The reinit option is an INPUT option: it has to precede its own -i.
+ assert.ok(hf.inputs.indexOf("-reinit_filter") < hf.inputs.indexOf("-i"));
+});
+
+test("applyChromeArgs: one video encode over the concat, audio copied, deck frames second input", () => {
+ const plan = { regions: chromeRegions(DECK, "/o/sourced"), outLabel: "[hfout]" };
+ const args = applyChromeArgs("/o/sourced/x.prerail-hardcut.mp4", "/o/x.mp4", DECK, plan);
+ assert.deepEqual(args.slice(0, 6), ["-nostdin", "-v", "error", "-y", "-i", "/o/sourced/x.prerail-hardcut.mp4"]);
+ const fc = args[args.indexOf("-filter_complex") + 1];
+ assert.equal(fc, "[1:v]format=rgba[hfa0];[0:v][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]");
+ assert.deepEqual(args.slice(args.indexOf("-map"), args.indexOf("-map") + 4), ["-map", "[vout]", "-map", "0:a"]);
+ assert.equal(args[args.indexOf("-c:a") + 1], "copy");
+ assert.equal(args[args.indexOf("-crf") + 1], "21");
+ assert.equal(args.at(-1), "/o/x.mp4");
+ assert.ok(!args.includes("-ss"));
+});
+
+test("applyChromeArgs: a preview seeks the base and needs no timestamp shift", () => {
+ const plan = { regions: chromeRegions(DECK, "/o").map((r) => ({ ...r, frames: "/o/chrome/deck-from20-frames" })), outLabel: "[hfout]" };
+ const args = applyChromeArgs("/in.mp4", "/out.mp4", DECK, plan, { start: 20, dur: 8 });
+ assert.deepEqual(args.slice(4, 10), ["-ss", "20", "-t", "8", "-i", "/in.mp4"]);
+ assert.ok(args.includes("/o/chrome/deck-from20-frames/frame_%06d.png"));
+ assert.ok(!args[args.indexOf("-filter_complex") + 1].includes("setpts"));
+});
+
+test("windowSegments: every segment the window touches, and the window's offset into the first", () => {
+ // Three segments of 10 s crossfaded by 0.5: starts 0, 9.5, 19.
+ const starts = [0, 9.5, 19];
+ const durs = [10, 10, 10];
+ assert.deepEqual(windowSegments(starts, durs, 2, 3), { first: 0, last: 0, offset: 2 });
+ // Inside the dissolve both neighbours are on screen.
+ assert.deepEqual(windowSegments(starts, durs, 9.7, 1), { first: 0, last: 1, offset: 9.7 });
+ assert.deepEqual(windowSegments(starts, durs, 12, 10), { first: 1, last: 2, offset: 2.5 });
+ assert.throws(() => windowSegments(starts, durs, 40, 2), /no segment covers/);
+});
+
+test("previewFromSegmentsArgs: the window's segments crossfaded as the full concat does, trimmed, deck over", () => {
+ const plan = { regions: chromeRegions(DECK, "/o").map((r) => ({ ...r, frames: "/o/chrome/deck-from12-frames" })), outLabel: "[hfout]" };
+ const args = previewFromSegmentsArgs({
+ segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs: [10, 10, 10], starts: [0, 9.5, 19],
+ D: 0.5, at: 12, dur: 10, render: DECK, chromePlan: plan, outPath: "/o/x.preview.mp4",
+ });
+ // Only b and c are inputs; the deck frames are the third.
+ assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), [
+ "/s/b.mp4", "/s/c.mp4", "/o/chrome/deck-from12-frames/frame_%06d.png",
+ ]);
+ assert.equal(
+ args[args.indexOf("-filter_complex") + 1],
+ [
+ "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]",
+ "[0:a][1:a]acrossfade=d=0.5:c1=tri:c2=tri[a1]",
+ "[v1]trim=start=2.500:duration=10.000,setpts=PTS-STARTPTS[vw]",
+ "[a1]atrim=start=2.500:duration=10.000,asetpts=PTS-STARTPTS[aw]",
+ "[2:v]format=rgba[hfa0];[vw][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]",
+ ].join(";"),
+ );
+ assert.equal(args.at(-1), "/o/x.preview.mp4");
+});
+
+test("previewFromSegmentsArgs: hard cuts concatenate; one segment needs neither", () => {
+ const plan = { regions: chromeRegions(DECK, "/o"), outLabel: "[hfout]" };
+ const two = previewFromSegmentsArgs({
+ segments: ["/s/a.mp4", "/s/b.mp4"], durs: [10, 10], starts: [0, 10],
+ D: 0, at: 8, dur: 4, render: DECK, chromePlan: plan, outPath: "/p.mp4",
+ });
+ const fc2 = two[two.indexOf("-filter_complex") + 1];
+ assert.match(fc2, /^\[0:v\]\[0:a\]\[1:v\]\[1:a\]concat=n=2:v=1:a=1\[vc\]\[ac\];\[vc\]trim=start=8\.000:duration=4\.000/);
+ const one = previewFromSegmentsArgs({
+ segments: ["/s/a.mp4", "/s/b.mp4"], durs: [10, 10], starts: [0, 10],
+ D: 0, at: 1, dur: 4, render: DECK, chromePlan: plan, outPath: "/p.mp4",
+ });
+ assert.match(one[one.indexOf("-filter_complex") + 1], /^\[0:v\]trim=start=1\.000:duration=4\.000/);
+});
+
+test("chromeFlags: the three deck flags, one at a time", () => {
+ assert.deepEqual(chromeFlags(["m.json", "--skip-fetch"]), { chromeOnly: false, noChrome: false, chromePreview: null });
+ assert.deepEqual(chromeFlags(["m.json", "--chrome-only"]), { chromeOnly: true, noChrome: false, chromePreview: null });
+ assert.deepEqual(chromeFlags(["m.json", "--no-chrome"]), { chromeOnly: false, noChrome: true, chromePreview: null });
+ assert.deepEqual(chromeFlags(["m.json", "--chrome-preview", "20", "8", "--out", "o"]), {
+ chromeOnly: false, noChrome: false, chromePreview: { at: 20, dur: 8 },
+ });
+ assert.throws(() => chromeFlags(["m.json", "--chrome-preview", "20"]), /<at> <dur>/);
+ assert.throws(() => chromeFlags(["m.json", "--chrome-preview", "x", "8"]), /<at> <dur>/);
+ assert.throws(() => chromeFlags(["m.json", "--chrome-preview", "5", "0"]), /<at> <dur>/);
+ assert.throws(() => chromeFlags(["m.json", "--chrome-only", "--no-chrome"]), /pick one/);
+});
+
+test("the driver's chromeOnly produces the flag this parser reads", async () => {
+ const { buildSteps } = await import("../lib/report/driver.mjs");
+ const steps = buildSteps({ id: "p", dir: "/p" }, { preset: "final", options: { chromeOnly: true } });
+ const build = steps.find((s) => s.argv.some((a) => a.endsWith("build-video.mjs")));
+ assert.equal(chromeFlags(build.argv).chromeOnly, true);
+});
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -0,0 +1,572 @@
+// 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 trackY = 16;
+ // The body is everything under the pip track: the text block and the QR are
+ // both centred in it, so the title's optical centre and the code's agree
+ // whatever the deck's height. 14 px clears the active pip's halo.
+ const bodyTop = trackY + 14;
+ const bodyH = rect.height - bodyTop - 8;
+ const qr = d.qr.show
+ ? { x: W - padX - d.qr.size, y: Math.round(bodyTop + (bodyH - d.qr.size) / 2), size: d.qr.size }
+ : null;
+ const textX = padX;
+ const textRight = qr ? qr.x - 48 : W - padX;
+ // Line boxes, not font sizes: the title's box holds its descenders inside the
+ // wipe's overflow clip, and the rule sits in the gap between the two lines.
+ // The subtitle scales with the title (26 px at the default 54).
+ const titleSize = d.title.size;
+ const subtitleSize = Math.round(Math.max(18, Math.min(34, titleSize * 0.48)));
+ const titleBox = Math.round(titleSize * 1.2);
+ const subtitleBox = Math.round(subtitleSize * 1.3);
+ const gap = Math.round(titleSize * 0.3);
+ const block = titleBox + gap + subtitleBox;
+ const titleY = Math.round(bodyTop + Math.max(0, (bodyH - block) / 2));
+ return {
+ width: W,
+ height: rect.height,
+ pipTrack: { x0: padX, x1: W - padX, y: trackY },
+ text: {
+ x: textX,
+ width: textRight - textX,
+ titleSize,
+ subtitleSize,
+ titleY,
+ titleBox,
+ ruleY: titleY + titleBox + Math.round((gap - 4) / 2),
+ subtitleY: titleY + titleBox + gap,
+ subtitleBox,
+ },
+ 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" });
+});
diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json
@@ -22,6 +22,7 @@
"./check-availability": "./check-availability.mjs",
"./compose-chrome": "./compose-chrome.mjs",
"./cues": "./cues.mjs",
+ "./deck": "./deck.mjs",
"./ledger-totals": "./ledger-totals.mjs",
"./package.json": "./package.json",
"./render-cards": "./render-cards.mjs",
diff --git a/umtool/report-to-video/render-cards.mjs b/umtool/report-to-video/render-cards.mjs
@@ -37,6 +37,7 @@ import { dateKey, ledgerTotals, rosterLine } from "./ledger-totals.mjs";
import { brandFaces, brandManifest, brandSvgFace, childOpts } from "./brand.mjs";
import { BRAND_CARD_STYLES, renderBrandCard } from "./brand-cards.mjs";
import { FIRA_SANS, textWidth } from "./svg-faces.mjs";
+import { deckOn, resolveDeck } from "./deck.mjs";
const execFileP = promisify(execFile);
@@ -526,8 +527,16 @@ export function railGeometry(render, nClaims) {
* of which are wrong the moment the band takes 200. The symptom is a card that
* looks finished in isolation and has its last two lines sitting under the
* chart in the cut.
+ *
+ * Under the deck a card either has the whole frame (`overCards: "hide"` -- the
+ * deck slides away over it) or leaves the deck's height free at the bottom
+ * (`"show"`).
*/
export function reservedFooterHeight(render) {
+ if (deckOn(render)) {
+ const deck = resolveDeck(render);
+ return deck.overCards === "show" ? deck.height : 0;
+ }
return render.chromeEngine === "hyperframes"
? (render.chart?.height ?? 200)
: (render.footerHeight ?? 100);
diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs
@@ -15,10 +15,11 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
-import { readFile, stat } from "node:fs/promises";
+import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { selectVariant, variantPaths } from "./build-video.mjs";
+import { deckOn, frameCount } from "./deck.mjs";
const execFileP = promisify(execFile);
const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe";
@@ -80,7 +81,54 @@ export async function verifyBuild(manifestPath, { outDir, variant = "sourced" }
problems.push(`${duration.toFixed(1)}s out of a timeline that asks for about ${wanted.toFixed(0)}s`);
}
- return { ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, problems };
+ // The deck (`render.chrome`). Its frames are laid with shortest=1, so a
+ // sequence that came up short shortens the cut without a word, and one that
+ // is missing means the file was built --no-chrome -- a picture check, not
+ // the deliverable. Both are measured against the schedule the build wrote.
+ let deck = null;
+ if (deckOn(manifest.render)) {
+ deck = await verifyDeck(path.join(root, variant), manifest.render, file, problems);
+ }
+
+ return { ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, deck, problems };
+}
+
+/**
+ * The deck's half of the check: schedule.json is there and is a measured deck
+ * schedule, `chrome/deck-frames` holds frameCount(total, fps) frames, and the
+ * file is as long as the schedule.
+ */
+export async function verifyDeck(variantDir, render, file, problems) {
+ const schedPath = path.join(variantDir, "schedule.json");
+ const schedule = await readFile(schedPath, "utf8").then(JSON.parse, () => null);
+ if (!schedule || schedule.kind !== "deck") {
+ problems.push(`the deck is on but ${schedPath} is missing or not a deck schedule`);
+ return null;
+ }
+ if (schedule.estimated) problems.push(`${schedPath} is an estimate; a build writes a measured one`);
+ const fps = Number(schedule.fps ?? render.fps);
+ const want = frameCount(schedule.total, fps);
+ const framesDir = path.join(variantDir, "chrome", "deck-frames");
+ const frames = await readdir(framesDir).then(
+ (fs) => fs.filter((f) => /^frame_\d+\.png$/.test(f)).length,
+ () => 0,
+ );
+ if (frames === 0) {
+ problems.push(`the deck is on but ${framesDir} has no frames — built with --no-chrome?`);
+ } else if (frames !== want) {
+ problems.push(`${framesDir} holds ${frames} frames; the cut is ${want} (${schedule.total}s at ${fps} fps)`);
+ }
+ // The VIDEO stream's length, in frames -- the build's own measure
+ // (assertConcatLength). The container's duration runs on with the audio.
+ const { stdout } = await execFileP(FFPROBE, [
+ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames",
+ "-of", "default=nw=1:nk=1", file,
+ ]);
+ const videoFrames = Number(stdout.trim());
+ if (!(Math.abs(videoFrames - schedule.total * fps) <= 1.5)) {
+ problems.push(`the picture is ${videoFrames} frames for a ${schedule.total.toFixed(3)}s schedule (${want} frames)`);
+ }
+ return { total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length };
}
async function main() {
@@ -103,6 +151,9 @@ async function main() {
`${res.file} (${res.variant})\n ${res.duration?.toFixed(1) ?? "?"}s · ${res.chapters ?? 0} chapter(s) for ` +
`${res.entries ?? 0} entr(ies) · ${((res.size ?? 0) / 1e6).toFixed(1)} MB`,
);
+ if (res.deck) {
+ console.log(` deck: ${res.deck.frames}/${res.deck.expectedFrames} frame(s) over ${res.deck.segments} segment(s), ${res.deck.total}s`);
+ }
for (const p of res.problems) console.log(` ** ${p}`);
if (res.ok) console.log(" ok");
}