commit 6017470e4dc57c6e465aa2fd0624159d846a783b
parent d940b846aa7d055dd4d13b2d3c8532887eb24515
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 15:48:10 -0400
Merge deck/teaser-t1 (slice T1) — the teaser timeline entry: a full-frame season-teaser card rendered by HyperFrames (lines popping top to bottom, a two-tier title, the tail fading in after), synthesized trailer hits on each pop from the same cue times, cached by content; umtool shows it as a card row; reviewed by contact sheet
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
15 files changed, 1362 insertions(+), 23 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -5,6 +5,7 @@
- **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 that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear four seconds apart and stack down a column at the frame's top right; as the first appears, the footage eases aside (to 86 % of its box, at the far side) to make room, and the clip's last frame is held, in silence, for 2.5 seconds so the last post can be read; then they all leave together in the change to the next clip, which comes in at the normal size. When the column is full the oldest slide up and out. Each card slides in from the edge of the frame and flares in the deck's accent as it lands; it has an accent rail down its edge and shows the post's date, a platform label ("Bluesky" or "X") beside `@handle`, its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The hold and the move are made where the cut is joined, not in a clip, so `--chrome-only` changes them without rebuilding one; chapters and the deck's timing count the hold. The timing, the hold (`hold`, 0 turns it off), the move (`shift`: its scale and seconds, or `false`), the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither — though it still holds and moves the footage, which are part of the cut rather than the chrome. A first post that appears inside the hold still moves the footage, and a hold is a whole number of frames. A cut without `posts` builds exactly as before, and without the deck `posts` is not drawn at all.
- **A report clip can go silent partway through, a report cut can fade out at its end, and the deck's QR names its site in larger type.** A clip's `muteFrom` (in the recording's own seconds, inside the clip) silences it from that second to its end while the picture plays on, after a 40 ms fade that ends there, so nothing clicks and no next word leaks in; a hold on that clip stays silent. `render.endFade` (seconds; 0, the default, is off) fades the cut's last segment to the background colour and to silence over its final seconds, the hold included, and the deck stays drawn over it; once a closing card follows the last clip, the ordinary crossfade into it does that job instead. Both are applied where the cut is joined, so `--chrome-only` changes them without rebuilding a clip, and a value out of range is refused with a sentence before a build fetches anything. Each clip build now writes `<id>.cut.json` beside its segment, saying where in the recording the segment really starts after its cut was snapped to a silence; `muteFrom` is measured from it, and a segment built before this measures from the clip's unsnapped start and says so. The site's name beside the deck's QR is now exactly as long as the code is tall, for any site. A cut without `muteFrom` or `endFade` builds exactly as before.
+- **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in after the last line on its own. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest.
- **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.
diff --git a/plans/deck-posts.md b/plans/deck-posts.md
@@ -267,3 +267,64 @@ Found and left:
the last clip (2.0 s on revision 2, with cards). `xfadeGraph` now pins each input's sound to its
length in the cut (`apad=whole_dur`, `atrim=end`) before the join, so a `muteFrom` lands on its
picture too. Every crossfaded cut's graph changed; `av-sync.test.mjs` holds it with real ffmpeg.
+
+## Teaser T1, as built
+
+Branch `deck/teaser-t1` from 810e423e. A `teaser` timeline entry: a full-frame season-teaser card
+after the last clip, its words the manifest's, a trailer hit under each pop.
+
+| Commit | What |
+|---|---|
+| 3c4ab080 | `deck.mjs`: `teaser` in `CARD_TYPES` (the deck hides over it whatever `overCards` says), `teaserLines`/`teaserTail`/`teaserTitle`, `validateTeaser`/`validateTeasers`, `TEASER_MOTION` + `teaserTimes` (the one copy of the timing) and `teaserHits`; `chrome-teaser.mjs` (the page, `teaserCues`); compose-chrome region `teaser` by dynamic import; build-video `buildTeaserSegment`, `teaserAudioGraph`, `teaserEncodeArgs`, `teaserSegmentKey`, the chapter, `--chrome-only` building teasers, `--fetch-only` a no-op on one; verify-build `verifyTeasers`; `chrome-teaser.test.mjs`, `teaser-audio.test.mjs` |
+| a82aae0e | umtool: the report page's row and the export's fallback chapter name a teaser by its lines; `onscreen-fixture` carries a teaser and `onscreen.spec` checks the table, the preview's node and the row; the supporting lines a size up |
+| c51925ae | README section, four quirks, one `[Unreleased]` bullet |
+
+Where it adds to, or differs from, the brief:
+
+- **A line is a string or `{ text, break }`** — `break` is the END of `text`, drawn as a smaller,
+ wide-tracked second tier 0.3 s after the rest; the whole `text` is what the chapter and umtool
+ show. There are no quotation marks and no `quote` field (the operator dropped them).
+- **Roles follow position**: with three or more lines the first is the overline, the last the
+ kicker, the rest titles; two are overline + title; one is a title.
+- **`hits`** (default true): a synthesised hit under each pop and a swell under the tail; false is
+ digital silence. The hits are placed by `teaserTimes`, the times the cues are built from, and
+ land on their pop's sample (a real-ffmpeg test differences the graph with and without each hit).
+- **`--chrome-only` builds teaser segments** instead of refusing: a teaser is chrome (graphics made
+ from the manifest, nothing fetched). Its segment is re-encoded only when its key — the frames'
+ render key and the whole sound graph — differs from `<id>.teaser.json`'s.
+- **The deck hides over a teaser even with `overCards: "show"`**: it is full frame, never framed
+ into the footage box.
+- The fit (a line wider than 80 % of the frame shrinks) runs once the face is in, as the deck's
+ title fit does; it changes sizes, never a time.
+
+Gates on c51925ae:
+
+- Workspace tsc clean (102 s). `test:scripts` 355 pass, 1 skipped (356). Capped umtool
+ `next build` with the corpus linked exit 0 (58 s, under a concurrent encode), link removed;
+ Turbopack bundles the teaser page and its face as their own server chunk.
+- Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5
+ `6a92235fa12ca181bb81993129c9ee9d`. The ferret deck manifest without a teaser measures the same
+ schedule at 810e423e and at the tip (`measureChromeSchedule`, byte-equal); with the teaser, the
+ first 17 segments, the posts and the moves equal the ferret's built `schedule.json`.
+- Ferret, scratch copy with the teaser after c20, `--chrome-only` over copied segments:
+ - the teaser rendered in 77–86 s (210 frames), the deck (11,001 frames) in 4 min 7 s; a
+ re-run after a design change re-rendered the teaser only (deck and posts `cached`, 573 s);
+ - 366.7 s (360.2 + 7 − 0.5), video and audio both 366.700 s; verify-build ok, every hold
+ frozen, `teaser fin: 210/210 frame(s), segment encoded from them`;
+ - QR 17/17 deck and 7/7 posts; 18 chapters, the last "Pirate Software — The Largest Ferret
+ Rescue in the United States — February 2027 ?" at 360.2 s;
+ - the last frame is bg (Y′CbCr 30/132/128, uniform, against 31/132/128); the sound is digital
+ silence for its last 39 ms;
+ - loudness: the cut −17.7 LUFS integrated, the teaser's 7 s −19.7 LUFS, sample peak −6.0 dBFS.
+- umtool e2e `onscreen onscreen-posts build projects`: 43 passed, 1 failed (2.9 min). The failure
+ is `onscreen-posts.spec.ts:264`, and it predates this branch: 2f0e852b rounds a hold to whole
+ frames, and at the posts fixture's 15 fps 2.5 s is 38 frames (2.533 s), while the spec still
+ expects 2.5. No teaser is in that fixture.
+
+Found and left:
+
+- umtool cannot edit a teaser's lines. It needs a writer (`updateTeaser` through
+ `withManifestLock` and `validateTeaser`), a route, a form (a field per line with a break
+ picker) and a still of the composition (compose-chrome's `--still`, as the deck's still route
+ does).
+- `onscreen-posts.spec.ts`'s timings at 15 fps (above).
diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md
@@ -318,6 +318,36 @@ not a wall around the page modules: umtool's preview helper
posts, and its routes build. A capped umtool build is the gate that catches it — the unit
tests run in plain Node and pass either way.
+**A teaser's page module is reached by a dynamic import from compose-chrome
+too.** `chrome-teaser.mjs` carries the display face as `new URL(…,
+import.meta.url)`, the same kind of asset URL as the deck's GSAP. compose-chrome
+is imported statically by umtool's preview helper, so it loads the teaser page
+only inside the `teaser` region's branch; nothing umtool bundles evaluates that
+URL unless a teaser is composed. The face's file name has brackets
+(`Archivo[wdth,wght].ttf`), so the copy in the project is
+`assets/TeaserDisplay.ttf`, and the page never puts the vendored name in a URL.
+
+**`random()` in an ffmpeg expression advances only where it is evaluated.**
+Its state is a variable that each call updates, and `if()` evaluates one
+branch: a noise term gated to a hit's span draws its numbers only inside that
+span, so adding or removing one hit changes the noise of every hit after it.
+The teaser's noise is a hash of the sample number
+(`fract(sin(n·12.9898+78.233)·43758.5453)`), the same whatever else is in the
+graph, which is also what lets the onset test difference the graph with and
+without one hit.
+
+**`alimiter` auto-levels and delays by default.** `level` is on by default and
+normalises the output upward toward the limit, so a quiet card comes out loud.
+`latency` is off by default, which leaves the output late by the lookahead
+(the attack). The teaser's limiter is `level=0:latency=1`: measured, every
+hit's onset is then on its pop's sample.
+
+**Sub-bass barely registers in LUFS.** K-weighting rolls off below ~100 Hz, so
+a boom at 40 Hz that peaks at −6 dBFS measures far quieter than dialogue at the
+same peak. The teaser's hits carry an octave for body and a band-passed noise
+punch, and the limiter takes their transients, so the card can sit within
+about 2 LU of the cut and still peak at −6 dBFS.
+
## 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/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -1483,13 +1483,19 @@ const deckManifest = (slug, title, timeline) => {
// 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.
+// first written. A card, because a row is any entry and not only a clip; a
+// teaser last, because the page and the table must name one by its lines.
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" },
+ {
+ type: "teaser", id: "t01", seconds: 6,
+ lines: ["Next Season", { text: "The Big Build in the Valley", break: "in the Valley" }, "Spring 2027"],
+ tail: "?",
+ },
]),
);
diff --git a/umtool/e2e/onscreen.spec.ts b/umtool/e2e/onscreen.spec.ts
@@ -268,6 +268,11 @@ test("the preview is the composition: it reports ready, and typing reaches it be
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!);
+ // A teaser is a card row named by its lines, here and on the report page's timeline.
+ const teaserTitle = "Next Season — The Big Build in the Valley — Spring 2027 ?";
+ await expect(frame.locator('[data-seg="t01"]')).toHaveCount(1);
+ expect(await row(page, "t01").getByTestId("onscreen-title").getAttribute("placeholder")).toBe(teaserTitle);
+ await expect(page.locator('li[data-entry="t01"][data-kind="teaser"]')).toContainText(teaserTitle);
// Typing is patched into the frame by postMessage: nothing is saved.
await fillSure(row(page, "c02").getByTestId("onscreen-title"), "Typed, not saved");
diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs
@@ -11,6 +11,7 @@ import { DEFAULT_VARIANT, cachedWindowsFor } from "umtool-report-to-video/build-
import { rawCacheOf } from "../report/raw-cache.mjs";
import { CHANNELS_DIR } from "../paths.mjs";
import { channelName, cleanTitle } from "umtool-report-to-video/attribution";
+import { teaserTitle } from "umtool-report-to-video/deck";
/**
* Where a build's per-entry segments live.
@@ -1110,7 +1111,8 @@ export async function readClipDetail(dir, { manifest = null } = {}) {
// and 500'd the whole project page. Every other real manifest is clips only,
// which is exactly why this survived testing.
if (e.type !== "clip") {
- entries.push({ ...e, kind: e.type ?? "entry" });
+ // A teaser has no heading or title of its own: its row names its lines.
+ entries.push({ ...e, kind: e.type ?? "entry", ...(e.type === "teaser" ? { label: teaserTitle(e) } : {}) });
continue;
}
const chan = channelFor(m, e);
diff --git a/umtool/lib/report/export.mjs b/umtool/lib/report/export.mjs
@@ -18,6 +18,7 @@ import { readFile, readdir, stat } from "node:fs/promises";
import path from "node:path";
import { DEFAULT_VARIANT, selectVariant, segmentOffsets } from "umtool-report-to-video/build-video";
import { citeUrlFor, channelFor, readAvailability, readManifest } from "../projects/report.mjs";
+import { teaserTitle } from "umtool-report-to-video/deck";
export const EXPORT_FORMATS = ["toc-bbcode", "toc-markdown", "description", "chapters"];
@@ -60,7 +61,9 @@ export function parseFfmeta(text) {
}
/** The chapter title the build would print: `chapter`, else the entry's own words. */
-const titleOf = (e, i) => e.chapter ?? (e.type === "clip" ? `${i + 1}. ${e.video}` : e.title ?? e.heading ?? `Card ${i + 1}`);
+const titleOf = (e, i) =>
+ e.chapter ??
+ (e.type === "clip" ? `${i + 1}. ${e.video}` : e.type === "teaser" ? teaserTitle(e) : e.title ?? e.heading ?? `Card ${i + 1}`);
/**
* Deliverable-second offsets for every entry of the chosen variant.
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -174,7 +174,7 @@ the manifest names the MCP video id while the cue file lives under the URL slug.
## Manifest shape
`timeline` is an ordered list; entries are `card`, `clip` or `image` (plus
-`scroll`, `chart` and `ledger` — the vocabulary is open).
+`scroll`, `chart`, `ledger` and `teaser` — the vocabulary is open).
```jsonc
{ "type": "card", "id": "ch3", "style": "chapter", "seconds": 4.0,
@@ -420,6 +420,88 @@ cut off a top-level `ledger[]` — see [The claim rail](#the-claim-rail-renderra
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).
+### The `teaser` entry type
+
+A season teaser's "coming soon" card: a full-frame graphic, its words the
+manifest's, popping in one line at a time over a dark cinematic ground, with a
+trailer hit under each pop. Put it after the last clip; the ordinary crossfade
+joins them, and `render.endFade` — which fades the cut's last segment — now
+falls on the teaser.
+
+```jsonc
+{ "type": "teaser", "id": "fin", "seconds": 7,
+ "lines": ["Pirate Software",
+ { "text": "The Largest Ferret Rescue in the United States",
+ "break": "in the United States" },
+ "February 2027"],
+ "tail": "?", // optional: appended to the LAST line, fades in on its own
+ "hits": true } // optional, default true: false makes the card silent
+```
+
+- **`lines`** — 1 to 5, each one line of at most 80 characters: a string, or
+ `{ text, break }`, where `break` is the END of `text` drawn as a smaller,
+ wide-tracked second tier under the rest that pops a beat (0.3 s) after it.
+ The words are data: they are drawn uppercase, and kept as written everywhere
+ else. **Roles follow position:** with three or more lines the first is a
+ small wide-tracked overline between two accent rules, the last a mid-size
+ kicker (a date), and everything between a big heavy title; two lines are an
+ overline and a title; one is a title.
+- **`seconds`** — 3 to 20. The pops land 0.55 s in (after the incoming
+ dissolve) and 0.7 s apart; the tail starts 0.8 s after the last and fades in
+ over 1.7 s; the last 1.2 s are a still hold for the end fade. A card too
+ short for all of it plays every beat proportionally faster.
+- **`tail`** — at most 8 characters, in the accent, set a little apart from
+ the last line.
+- **The chapter** is the lines joined with " — ", the tail after the last
+ (an authored `chapter` still wins). The deck slides away over a teaser
+ whatever `overCards` says — it is full frame, never framed into the footage
+ box — and it has no pip and no QR.
+
+**How it is drawn.** `chrome-teaser.mjs` builds the page (pure; the cue list
+is `teaserCues`, every cue a `fromTo` with its from stated, as the deck's are),
+compose-chrome renders it as region `teaser` (`chrome/teaser-<id>/`, frames in
+`chrome/teaser-<id>-frames/`, cached by the page's content key — changed words
+are a new render), and the build encodes the frames into `segments/<id>.mp4`
+at the parameters every segment shares. The display face is the vendored
+Archivo (`fonts/Archivo[wdth,wght].ttf`, weight 600–900 and width 112–125 %),
+copied in as the private family `TeaserDisplay`. The ground is `palette.bg`
+lifted toward the accent at the centre and falling toward black at the edges,
+a vignette, seeded film grain, a soft light leak drifting across, letterbox
+bars that close in over the dissolve, and a slow push-in over the whole card.
+Each line slams in from 1.42× (the overline from 1.25×), blurred, undershoots
+to 0.968× as it lands — a flash of the accent behind it and a streak of light
+through it — and settles to rest. Rendered on the ferret cut: 210 frames in
+about 90 s.
+
+**The sound.** Synthesised in ffmpeg, no samples (`teaserAudioGraph`): under
+each pop a trailer hit — a sub sine dropping from ~92 to ~40 Hz with its octave
+for body on an exponential decay, a band-passed noise burst for the punch, a
+low noise tail and a short low-passed echo. The title's hit is the biggest,
+the overline's and the date's a little smaller, the second tier's lighter and
+shorter; under the tail a low swell rises and settles as it fades in. The times
+are `teaserTimes` (`deck.mjs`), the SAME the composition's cues are built from,
+and each hit starts on the sample of its pop. The sum is limited at −6 dBFS
+(`alimiter`, no auto-level, latency compensated) and levelled against the ferret
+cut: the cut measures −17.7 LUFS integrated, the teaser −19.6. `hits: false` is
+digital silence, as a card's is.
+
+**When it is rebuilt.** The segment is re-encoded only when its key — the
+frames' render key and the whole sound graph — differs from the one recorded
+beside it (`<id>.teaser.json`). Any build that reaches a teaser rebuilds it
+when its words, motion or sound changed: a full build, `--only <id>`, and
+**`--chrome-only`**, which builds teaser segments (they are chrome — graphics
+made from the manifest, nothing fetched) while still rebuilding no clip.
+`verify-build` checks each teaser's frame count and that its segment was
+encoded from the frames on disk.
+
+**umtool** shows a teaser as a card row named by its lines (the report page,
+the On-screen table, the timeline strip). Editing its lines there is not
+built: it needs a writer (`updateTeaser(dir, id, {lines, tail, seconds, hits},
+{token})` through `withManifestLock` and `validateTeaser`), a route, a small
+form (one field per line with a break picker), and a preview — a still of the
+composition at a chosen second through compose-chrome's `--still`, which the
+deck's true-still route already does for its region.
+
## Chrome, not cards
**The ferret-rescue cut has no cards at all** — no title, no chapter breaks, no
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -66,6 +66,7 @@
// Requires: yt-dlp, ffmpeg/ffprobe, ImageMagick with Pango.
import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
import { promisify } from "node:util";
import { mkdir, writeFile, readFile, access, readdir, rename, stat } from "node:fs/promises";
import path from "node:path";
@@ -81,7 +82,8 @@ import { createCueSource, siteOriginFromManifest } from "./cues.mjs";
// the schedule down -- it never has a copy of the arithmetic.
import {
assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, frameCount, MUTE_FADE, muteSegmentSeconds,
- playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, validateCutEdits, validatePosts,
+ playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserTitle,
+ validateCutEdits, validatePosts, validateTeasers,
} 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.
@@ -1004,6 +1006,155 @@ async function buildCardSegment(card, render, outDir, nodes) {
return seg;
}
+// ---- the teaser -----------------------------------------------------------
+// A `teaser` entry is a full-frame graphic card -- a season teaser's "coming
+// soon" screen -- drawn by a HyperFrames composition from the entry's words
+// (chrome-teaser.mjs) and rendered once, cached by the page's content key
+// (compose-chrome). This encodes those frames into the segment at the
+// parameters every segment shares, with a sound track: a trailer hit under
+// each pop and a swell under the tail (`teaserHits`, deck.mjs -- the same
+// times the composition's cues land on), or digital silence with `hits: false`.
+//
+// The segment is re-encoded only when its key changes: the frames' key and the
+// sound's graph, recorded beside it (`<id>.teaser.json`). So a teaser whose
+// words changed is re-rendered and re-encoded by any build that reaches it --
+// `--chrome-only` included, which builds teaser segments (they are chrome:
+// graphics made from the manifest, nothing fetched) -- and an unchanged one is
+// neither.
+
+/** The record beside a teaser's segment: the key it was encoded from. */
+export const teaserRecordPath = (seg) => seg.replace(/\.mp4$/, ".teaser.json");
+
+/** The teaser sound's level and ceiling: `limit` is −6 dBFS; `level` is set against the ferret cut's loudness (README). */
+export const TEASER_AUDIO = Object.freeze({ level: 1.4, limit: 0.5 });
+
+/** A number for an aevalsrc expression: 6 decimals, no trailing zeros. */
+const ev6 = (v) => {
+ const s = (Math.round(Number(v) * 1e6) / 1e6).toFixed(6).replace(/\.?0+$/, "");
+ return s === "-0" ? "0" : s;
+};
+
+/**
+ * The teaser's sound, as one filtergraph fragment from no inputs to `[ta]`:
+ * `hits` (`teaserHits`) synthesised in ffmpeg, no samples.
+ *
+ * A hit is three layers, each summed over every hit in one `aevalsrc` and
+ * gated to its own span, so each starts on the sample its time names:
+ * - the boom: a sine whose pitch drops from f0 to f1 (most of the way in
+ * ~0.4 s), with its octave for body, on an exponential decay (`decay` is
+ * the time constant; it is inaudible by ~7×) after a 3 ms attack;
+ * - the punch: a burst of noise (50 ms time constant), band-passed (180 Hz–3.2 kHz);
+ * - the tail: a low noise decay (low-passed at 260 Hz) under it.
+ * A swell is the boom's sine rising f0 → f1 under an envelope that peaks
+ * three quarters of the way through `dur` and settles, with a breath of the
+ * low noise. The noise is a hash of the sample number, not `random()`, so it
+ * is the same whatever else is in the graph. The sum takes a short low-passed
+ * echo, then `level`, then a limiter at −6 dBFS (`limit`, no auto-level,
+ * latency compensated) so hits that overlap still sum cleanly; trimmed and
+ * padded to exactly `seconds`. No hits: digital silence.
+ */
+export function teaserAudioGraph(hits, { seconds, render, level = TEASER_AUDIO.level, limit = TEASER_AUDIO.limit }) {
+ const rate = render.audioRate;
+ const layout = render.audioChannels === 1 ? "mono" : "stereo";
+ const len = ev6(seconds);
+ const tail = `atrim=end=${len},apad=whole_dur=${len},asetpts=PTS-STARTPTS[ta]`;
+ if (!hits.length) return `anullsrc=channel_layout=${layout}:sample_rate=${rate},${tail}`;
+ const noise = "(2*(sin(n*12.9898+78.233)*43758.5453-floor(sin(n*12.9898+78.233)*43758.5453))-1)";
+ const boom = [];
+ const punch = [];
+ const rumble = [];
+ for (const h of hits) {
+ const a = ev6(h.at);
+ const u = `(t-${a})`;
+ const g = ev6(h.gain);
+ if (h.kind === "swell") {
+ const D = h.dur;
+ const peak = ev6(D * 0.75);
+ const v = `min(${u},${ev6(D)})`;
+ const phase = `(${ev6(h.f0)}*${v}+${ev6((h.f1 - h.f0) / (2 * D))}*${v}*${v}+${ev6(h.f1)}*(${u}-${v}))`;
+ const env = `pow(sin(PI/2*min(1,${u}/${peak})),2)*exp(-max(0,${u}-${peak})/${ev6(h.decay)})`;
+ const span = ev6(D * 0.75 + h.decay * 7);
+ boom.push(`if(between(t,${a},${a}+${span}),${g}*0.42*${env}*sin(2*PI*${phase}),0)`);
+ rumble.push(`if(between(t,${a},${a}+${span}),${g}*0.5*${env}*${noise},0)`);
+ continue;
+ }
+ const k = 0.13; // the pitch drop's time constant
+ const phase = `(${ev6(h.f1)}*${u}+${ev6((h.f0 - h.f1) * k)}*(1-exp(-${u}/${k})))`;
+ const span = ev6(h.decay * 7);
+ boom.push(
+ `if(between(t,${a},${a}+${span}),${g}*0.3*min(1,${u}/0.003)*exp(-${u}/${ev6(h.decay)})*` +
+ `(sin(2*PI*${phase})+0.6*exp(-${u}/${ev6(h.decay * 0.6)})*sin(4*PI*${phase})),0)`,
+ );
+ punch.push(`if(between(t,${a},${a}+0.25),${g}*0.5*exp(-${u}/0.05)*${noise},0)`);
+ rumble.push(`if(between(t,${a},${a}+${ev6(Math.min(2.6, h.decay * 6))}),${g}*0.32*min(1,${u}/0.02)*exp(-${u}/${ev6(h.decay * 1.3)})*${noise},0)`);
+ }
+ const src = (terms) => `aevalsrc=exprs='${terms.length ? terms.join("+") : "0"}':s=${rate}:c=${layout}:d=${len}`;
+ return [
+ `${src(boom)}[tb]`,
+ `${src(punch)},highpass=f=180,lowpass=f=3200[tp]`,
+ `${src(rumble)},lowpass=f=260,lowpass=f=260[tr]`,
+ `[tb][tp][tr]amix=inputs=3:normalize=0,aecho=1:1:90|190:0.18|0.09,lowpass=f=5000,` +
+ `volume=${ev6(level)},alimiter=limit=${ev6(limit)}:level=0:latency=1:attack=2:release=80,${tail}`,
+ ].join(";");
+}
+
+/** The teaser segment's encode: its frames, its sound, the shared parameters. */
+export function teaserEncodeArgs({ framesDir, seconds, render, audio, outPath }) {
+ return [
+ "-nostdin", "-v", "error", "-y",
+ // The renderer writes an all-opaque frame as RGB and any other as RGBA;
+ // a switch mid-sequence would reinitialise the graph and end it early.
+ "-framerate", String(render.fps), "-reinit_filter", "0", "-start_number", "1",
+ "-i", path.join(framesDir, "frame_%06d.png"),
+ "-filter_complex", `[0:v]format=rgba,fps=${render.fps},setsar=1[tv];${audio}`,
+ "-map", "[tv]", "-map", "[ta]",
+ ...encodeArgs(render),
+ "-frames:v", String(frameCount(seconds, render.fps)),
+ "-shortest",
+ outPath,
+ ];
+}
+
+/**
+ * A teaser segment's key: its frames' render key and its sound's whole graph
+ * (every hit's time and parameters, `hits: false`'s silence, the level). A
+ * segment whose recorded key differs is re-encoded.
+ */
+export const teaserSegmentKey = (framesKey, audioGraph) =>
+ createHash("sha256").update(JSON.stringify({ v: 1, frames: framesKey, audio: audioGraph })).digest("hex");
+
+/**
+ * Compose and render the teaser (cached by compose-chrome's key), then encode
+ * its segment unless the one on disk was made from the same frames and sound.
+ * Dynamic import: compose-chrome imports this file, and its page module must
+ * not reach umtool's bundle through the build (docs/quirks.md).
+ */
+async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant }) {
+ const { composeChrome } = await import("./compose-chrome.mjs");
+ const t0 = Date.now();
+ const r = await composeChrome({
+ manifestPath, outDir, variant, region: "teaser", segment: entry.id, doRender: true,
+ fps: render.fps, workers: 4, quality: "high", format: "png-sequence",
+ });
+ EMIT("chrome", {
+ phase: r.cached ? "cached" : "render", region: "teaser", segment: entry.id,
+ frames: r.frameCount, key: r.key, dir: r.frames, seconds: Number(((Date.now() - t0) / 1000).toFixed(1)),
+ });
+ const seconds = Number(entry.seconds);
+ const audio = teaserAudioGraph(teaserHits(entry), { seconds, render });
+ const key = teaserSegmentKey(r.key, audio);
+ const seg = path.join(outDir, "segments", `${entry.id}.mp4`);
+ const recPath = teaserRecordPath(seg);
+ const rec = await readFile(recPath, "utf8").then(JSON.parse, () => null);
+ if (rec?.key === key && (await exists(seg))) {
+ EMIT("note", { id: entry.id, message: `${entry.id}: teaser segment unchanged (key ${key.slice(0, 12)})` });
+ return seg;
+ }
+ await execFileP(FFMPEG, teaserEncodeArgs({ framesDir: r.frames, seconds, render, audio, outPath: seg }), { maxBuffer: 1 << 26 });
+ await writeFile(recPath, JSON.stringify({ key, frames: r.key, hits: teaserHits(entry).length }) + "\n", "utf8");
+ return seg;
+}
+
// ---- stills --------------------------------------------------------------
// An `image` entry is a screenshot in the cut: the receipts a clip cannot say
// out loud -- a post, a thread, a DM -- shown for `seconds` and then gone.
@@ -2745,6 +2896,8 @@ export async function segmentOffsets(segments, D, fps) {
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 teaser is named by its own words, with or without the deck.
+ if (entry.type === "teaser") return teaserTitle(entry) || `Teaser ${index + 1}`;
// 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.
@@ -2943,7 +3096,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// A clip's `muteFrom` and `render.endFade`, checked against the WHOLE
// manifest, deck or not: both are made where the cut is joined.
{
- const errors = validateCutEdits(whole);
+ const errors = [...validateCutEdits(whole), ...validateTeasers(whole)];
if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`);
}
const deck = deckOn(render);
@@ -2985,8 +3138,8 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// A still has nothing to fetch and is already on disk, so this is a no-op
// rather than an error: a bench that walks the timeline asking for each
// entry's window should not have to know which kinds have one.
- if (entry?.type === "image") {
- EMIT("note", { message: `${fetchOnly} is an image entry — nothing to fetch` });
+ if (entry?.type === "image" || entry?.type === "teaser") {
+ EMIT("note", { message: `${fetchOnly} is ${entry.type === "image" ? "an image" : "a teaser"} entry — nothing to fetch` });
EMIT("done", { out: null, nothingToFetch: true });
return { out: null, failures: [] };
}
@@ -3121,6 +3274,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
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`);
+ // A teaser's segment is chrome too -- graphics made from the manifest's
+ // words, nothing fetched -- so it is (re)built here: re-rendered and
+ // re-encoded only when its words, motion or sound changed.
+ for (const e of entries) {
+ if (e.type !== "teaser") continue;
+ EMIT("card", { id: e.id, i: entries.indexOf(e), n: entries.length });
+ await buildTeaserSegment(e, { manifestPath, render, outDir, variant });
+ }
const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`));
for (const seg of segs) {
if (!(await exists(seg)))
@@ -3185,6 +3346,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (entry.type === "card") {
EMIT("card", { id: entry.id, i, n: entries.length });
segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes));
+ } else if (entry.type === "teaser") {
+ EMIT("card", { id: entry.id, i, n: entries.length });
+ segments.push(await buildTeaserSegment(entry, { manifestPath, render, outDir, variant }));
} else if (entry.type === "image") {
// `card`, not a new event name: umtool's activity feed and build chain
// key off this one to mean "a segment that needs no network", and a
diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs
@@ -0,0 +1,397 @@
+// The teaser's composition: one full-frame HyperFrames page per `teaser`
+// entry -- a season teaser's "coming soon" card, its words the manifest's.
+//
+// PURE, like chrome-deck.mjs: an entry and a render block in, an HTML string
+// out. compose-chrome.mjs copies the face and GSAP in beside it, writes it and
+// renders it; the build encodes the frames into the entry's segment.
+//
+// ---------------------------------------------------------------------------
+// Why it is reached only through a dynamic import
+// ---------------------------------------------------------------------------
+// TEASER_FONT_FILE is `new URL(…, import.meta.url)`, which umtool's bundler
+// turns into an asset reference. build-video must not import a page module at
+// load (docs/quirks.md), and compose-chrome -- which umtool's preview helper
+// imports statically -- loads this one only when a teaser is composed.
+//
+// ---------------------------------------------------------------------------
+// Why the timeline is a cue list computed here
+// ---------------------------------------------------------------------------
+// The deck's reason (chrome-deck.mjs): a render is a seek per frame, from
+// parallel workers, in any order. Every cue is a fromTo whose FROM is stated,
+// carried forward from the cue before it on the same element; the page is a
+// dumb interpreter of `teaserCues`, so the tests read every time it uses.
+// The blur is a CSS variable (`--blur`) read by `filter`, tweened like any
+// other number; the grain's jitter is a seeded sequence of instant sets.
+import { fileURLToPath } from "node:url";
+
+import { TEASER_MOTION, teaserLines, teaserTail, teaserTimes } from "./deck.mjs";
+
+export { TEASER_MOTION };
+import { mix, rgba } from "./chrome-deck.mjs";
+
+/**
+ * The display face: Archivo, a variable font (wght 100–900, wdth 62–125),
+ * vendored beside the cards' faces. Copied in as `assets/TeaserDisplay.ttf`
+ * under a private family name, as every chrome face is.
+ */
+export const TEASER_FONT_FILE = fileURLToPath(new URL("./fonts/Archivo[wdth,wght].ttf", import.meta.url));
+
+/** The face's name in the page and in the project's assets. */
+export const TEASER_FONT_ASSET = "assets/TeaserDisplay.ttf";
+
+/** Instant cues still take a millisecond, as on the deck. */
+const INSTANT = 0.001;
+
+const r4 = (v) => Math.round(v * 10000) / 10000;
+
+const esc = (s) =>
+ String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+/** A small seeded PRNG (mulberry32): the grain jitters the same way on every seek of every render. */
+export function seeded(seed) {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * Everything the teaser's timeline does, as data.
+ *
+ * `lines` are `teaserLines(entry)`, `tail` the tail ("" for none), `seconds`
+ * the card's length. Keys name elements by `data-k`: `stage` (the slow push-in
+ * over the whole card), `barT`/`barB` (the letterbox closing in), `leak` (a
+ * soft light drifting across), `grain`, and per line i `l<i>.o` (its
+ * visibility), `l<i>` (the slam's scale), `l<i>.t` (its blur), `l<i>.flash`,
+ * `l<i>.streak`, `l<i>.rules` (an overline's accent rules), `l<i>.sub` and
+ * `l<i>.subt` (the second tier); `tail`, `tail.t`, `tail.glow`.
+ *
+ * @returns {{ init: Record<string, object>, cues: Array<{ k: string, at: number, dur: number,
+ * from: object, to: object, ease: string, why: string }>, beats: object, scale: number }}
+ */
+export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION }) {
+ const m = motion;
+ // The times are deck.mjs's, the same the build places the hits by.
+ const beats = teaserTimes(lines, tail, seconds, m);
+ const { T } = beats;
+
+ const init = {};
+ const put = (k, v) => { init[k] = { ...(init[k] ?? {}), ...v }; };
+ 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 });
+
+ // ---- the ground: letterbox, push-in, light, grain ----------------------
+ put("stage", { scale: 1 });
+ add("stage", 0, seconds, { scale: m.push }, "none", "push-in");
+ put("barT", { yPercent: -100 });
+ put("barB", { yPercent: 100 });
+ add("barT", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox");
+ add("barB", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox");
+ put("leak", { x: -420, autoAlpha: 0 });
+ add("leak", 0, T(1.4), { autoAlpha: 1 }, "power1.out", "leak in");
+ add("leak", T(1.4), Math.max(INSTANT, seconds - T(1.4)), { x: 420 }, "none", "leak drift");
+ const rnd = seeded(0x7ea5e);
+ put("grain", { x: 0, y: 0 });
+ const steps = Math.floor(seconds * m.grainHz);
+ for (let s = 1; s < steps; s += 1) {
+ add("grain", s / m.grainHz, INSTANT, { x: Math.round((rnd() - 0.5) * 360), y: Math.round((rnd() - 0.5) * 220) }, "none", "grain");
+ }
+
+ // ---- the lines, top to bottom ------------------------------------------
+ lines.forEach((l, i) => {
+ const b = beats.lines[i];
+ const why = `line ${i}`;
+ const slam = l.role === "overline" ? 1 + (m.slam - 1) * 0.6 : m.slam;
+ put(`l${i}.o`, { autoAlpha: 0 });
+ put(`l${i}`, { scale: slam });
+ put(`l${i}.t`, { "--blur": `${m.blur}px` });
+ put(`l${i}.flash`, { autoAlpha: 0, scaleX: 0.55 });
+ put(`l${i}.streak`, { autoAlpha: 0, scaleX: 0 });
+ add(`l${i}.o`, b.at, T(0.12), { autoAlpha: 1 }, "power1.out", `${why} in`);
+ // The slam: down past rest by the hit, then a soft settle up to it.
+ add(`l${i}`, b.at, T(m.hit), { scale: m.under }, "power3.in", `${why} slam`);
+ add(`l${i}`, b.at + T(m.hit), T(m.settle), { scale: 1 }, "power2.out", `${why} settle`);
+ add(`l${i}.t`, b.at, T(m.hit + 0.12), { "--blur": "0px" }, "power2.out", `${why} focus`);
+ // The hit: a flash of the accent behind the words and a streak through them.
+ const hit = b.impact;
+ add(`l${i}.flash`, hit - T(0.04), T(0.08), { autoAlpha: 1, scaleX: 1 }, "power2.out", `${why} flash`);
+ add(`l${i}.flash`, hit + T(0.04), T(0.75), { autoAlpha: 0, scaleX: 1.25 }, "power2.out", `${why} flash out`);
+ add(`l${i}.streak`, hit - T(0.06), T(0.32), { autoAlpha: 1, scaleX: 1 }, "expo.out", `${why} streak`);
+ add(`l${i}.streak`, hit + T(0.26), T(0.5), { autoAlpha: 0 }, "power2.in", `${why} streak out`);
+ if (l.role === "overline") {
+ put(`l${i}.rules`, { scaleX: 0 });
+ add(`l${i}.rules`, hit - T(0.04), T(0.6), { scaleX: 1 }, "expo.out", `${why} rules`);
+ }
+ if (l.sub && b.subAt != null) {
+ put(`l${i}.sub`, { autoAlpha: 0, y: 16, scale: 1.12 });
+ put(`l${i}.subt`, { "--blur": "10px" });
+ add(`l${i}.sub`, b.subAt, T(0.5), { autoAlpha: 1, y: 0, scale: 1 }, "expo.out", `${why} second tier`);
+ add(`l${i}.subt`, b.subAt, T(0.32), { "--blur": "0px" }, "power2.out", `${why} second tier focus`);
+ }
+ });
+
+ // ---- the tail: slowly, on its own, after the last line has settled ------
+ if (tail && beats.tailAt != null) {
+ const d = beats.tailDur;
+ put("tail", { autoAlpha: 0, scale: 1.18 });
+ put("tail.t", { "--blur": "12px" });
+ put("tail.glow", { autoAlpha: 0 });
+ add("tail", beats.tailAt, d, { autoAlpha: 1, scale: 1 }, "sine.inOut", "tail");
+ add("tail.t", beats.tailAt, d * 0.85, { "--blur": "0px" }, "power2.out", "tail focus");
+ add("tail.glow", beats.tailAt + d * 0.3, d * 0.9, { autoAlpha: 1 }, "sine.inOut", "tail glow");
+ }
+
+ // ---- order, clamp, state the froms (the deck's walk) --------------------
+ 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 });
+ }
+ const { T: _T, ...times } = beats;
+ return { init, cues, beats: times, scale: beats.scale };
+}
+
+/** Each role's type: size (px, the most it may be), weight, width (%), tracking (em), and the fit floor. */
+export const TEASER_TYPE = Object.freeze({
+ overline: Object.freeze({ size: 34, weight: 600, stretch: 125, tracking: 0.48, floor: 18 }),
+ title: Object.freeze({ size: 148, weight: 900, stretch: 112, tracking: -0.006, floor: 56 }),
+ sub: Object.freeze({ size: 38, weight: 600, stretch: 125, tracking: 0.4, floor: 18 }),
+ kicker: Object.freeze({ size: 84, weight: 800, stretch: 118, tracking: 0.04, floor: 32 }),
+});
+
+/**
+ * The teaser composition's HTML: 1920×1080 (the render's frame), opaque,
+ * `seconds` long. `font` is the display face's asset path, `gsap` the
+ * vendored script's. `?still=<t>` seeks to t and holds, as the deck's does.
+ */
+export function teaserHtml(entry, render, opts = {}) {
+ const pal = render.palette;
+ const W = render.width ?? 1920;
+ const H = render.height ?? 1080;
+ const seconds = Number(entry.seconds);
+ if (!(seconds > 0)) throw new Error(`teaser ${entry.id}: seconds must be positive`);
+ const font = opts.font ?? TEASER_FONT_ASSET;
+ const gsapSrc = opts.gsap ?? "assets/gsap.min.js";
+ const lines = teaserLines(entry);
+ if (!lines.length) throw new Error(`teaser ${entry.id}: no lines`);
+ const tail = teaserTail(entry);
+ const { init, cues, beats } = teaserCues({ lines, tail, seconds });
+
+ // The ground: the palette's bg, lifted a touch toward the accent at the
+ // centre and falling toward black at the edges.
+ const black = "#000000";
+ const core = mix(mix(pal.bg, pal.accent, 0.13), pal.fg, 0.02);
+ const mid = pal.bg;
+ const edge = mix(pal.bg, black, 0.62);
+ const bar = mix(pal.bg, black, 0.72);
+ const barH = Math.round(H * 0.105);
+ const maxW = Math.round(W * 0.8);
+ const ty = TEASER_TYPE;
+
+ const lineHtml = lines
+ .map((l, i) => {
+ const k = `l${i}`;
+ const isLast = i === lines.length - 1;
+ const rules = l.role === "overline"
+ ? `<div class="rules" data-k="${k}.rules"><span class="rule l"></span><span class="rule r"></span></div>`
+ : "";
+ const tailHtml = isLast && tail
+ ? `<span class="tail" data-k="tail"><span class="tail-glow" data-k="tail.glow"></span>` +
+ `<span class="tail-t" data-k="tail.t">${esc(tail)}</span></span>`
+ : "";
+ return (
+ `<div class="line ${l.role}" data-line="${i}" data-role="${l.role}" data-k="${k}.o">` +
+ `<div class="flash" data-k="${k}.flash"></div>` +
+ `<div class="streak" data-k="${k}.streak"></div>` +
+ rules +
+ `<div class="pop" data-k="${k}"><div class="row">` +
+ `<span class="txt" data-k="${k}.t">${esc(l.head)}</span>${l.sub ? "" : tailHtml}</div></div>` +
+ (l.sub
+ ? `<div class="sub" data-k="${k}.sub"><div class="row"><span class="subt" data-k="${k}.subt">${esc(l.sub)}</span>${tailHtml}</div></div>`
+ : "") +
+ `</div>`
+ );
+ })
+ .join("\n ");
+
+ const data = {
+ seconds,
+ maxW,
+ init,
+ cues: cues.map(({ why, ...c }) => c),
+ beats,
+ floors: Object.fromEntries(Object.entries(ty).map(([r, t]) => [r, t.floor])),
+ };
+ // `</script>` in a JSON string would close the tag; the words may say anything.
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
+ const typeCss = (sel, t) =>
+ `${sel} { font-size: ${t.size}px; font-weight: ${t.weight}; font-stretch: ${t.stretch}%; letter-spacing: ${t.tracking}em; }`;
+
+ 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>
+ /* One variable face under a private name, the real file beside the page:
+ a bare local() falls back silently in the render browser, and a real
+ family name in the stack is fetched from the network (docs/quirks.md). */
+ @font-face { font-family: 'TeaserDisplay'; font-style: normal; font-weight: 100 900; font-stretch: 62% 125%;
+ src: url('${esc(font)}'); }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: ${pal.bg}; }
+ body { font-family: 'TeaserDisplay', 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; }
+ #teaser-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; overflow: hidden; }
+ .ground { position: absolute; inset: 0;
+ background: radial-gradient(ellipse 62% 58% at 50% 47%, ${core} 0%, ${mid} 58%, ${edge} 100%); }
+ .stage { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; transform-origin: 50% 48%; }
+ .leak { position: absolute; left: ${Math.round(W * 0.08)}px; top: ${Math.round(-H * 0.32)}px;
+ width: ${Math.round(W * 0.7)}px; height: ${Math.round(H * 0.95)}px; border-radius: 50%;
+ background: radial-gradient(ellipse at center, ${rgba(pal.accent, 0.2)} 0%, ${rgba(pal.amber ?? pal.accent, 0.06)} 45%, ${rgba(pal.accent, 0)} 70%);
+ filter: blur(30px); mix-blend-mode: screen; }
+ .column { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px;
+ display: flex; flex-direction: column; align-items: center; justify-content: center; }
+ .line { position: relative; display: flex; flex-direction: column; align-items: center; max-width: ${maxW}px; }
+ .pop, .sub { display: block; transform-origin: 50% 55%; }
+ .row { display: flex; align-items: baseline; justify-content: center; white-space: nowrap; }
+ .txt, .subt, .tail-t { display: inline-block; filter: blur(var(--blur, 0px)); }
+ .txt, .subt { text-transform: uppercase; white-space: nowrap; }
+ ${typeCss(".overline > .pop > .row", ty.overline)}
+ .overline .txt { color: ${mix(pal.muted, pal.fg, 0.4)}; padding-left: ${ty.overline.tracking}em; }
+ ${typeCss(".title > .pop > .row", ty.title)}
+ .title > .pop > .row { line-height: 1.02; }
+ .title .txt { color: ${pal.fg}; }
+ ${typeCss(".sub > .row", ty.sub)}
+ .sub > .row { line-height: 1.2; }
+ .sub .subt { color: ${mix(pal.fg, pal.muted, 0.25)}; padding-left: ${ty.sub.tracking}em; }
+ ${typeCss(".kicker > .pop > .row", ty.kicker)}
+ .kicker > .pop > .row { line-height: 1.1; }
+ .kicker .txt { color: ${pal.fg}; }
+ .overline { margin-bottom: 38px; }
+ .title + .title { margin-top: 10px; }
+ .title .sub { margin-top: 14px; }
+ .kicker { margin-top: 70px; }
+ /* An overline between two hairline rules in the accent. */
+ .rules { position: absolute; left: -132px; right: -132px; top: 50%; height: 2px; transform-origin: 50% 50%; }
+ .rule { position: absolute; top: 0; width: 96px; height: 2px; border-radius: 1px; }
+ .rule.l { left: 0; background: linear-gradient(90deg, ${rgba(pal.accent, 0)} 0%, ${pal.accent} 100%); }
+ .rule.r { right: 0; background: linear-gradient(270deg, ${rgba(pal.accent, 0)} 0%, ${pal.accent} 100%); }
+ /* The hit: a bloom of the accent behind the words, a streak of light through them. */
+ .flash { position: absolute; left: -18%; right: -18%; top: -55%; bottom: -55%;
+ background: radial-gradient(closest-side, ${rgba(pal.accent, 0.34)} 0%, ${rgba(pal.accent, 0.14)} 35%, ${rgba(pal.accent, 0.04)} 70%, ${rgba(pal.accent, 0)} 100%);
+ mix-blend-mode: screen; transform-origin: 50% 50%; }
+ .streak { position: absolute; left: -24%; right: -24%; top: 52%; height: 3px; margin-top: -1px;
+ background: linear-gradient(90deg, ${rgba(pal.accent, 0)} 0%, ${rgba(pal.accent, 0.9)} 30%, ${rgba(pal.fg, 0.95)} 50%, ${rgba(pal.accent, 0.9)} 70%, ${rgba(pal.accent, 0)} 100%);
+ box-shadow: 0 0 18px 3px ${rgba(pal.accent, 0.55)}; transform-origin: 50% 50%; mix-blend-mode: screen; }
+ /* The tail: apart from the words, in the accent, arriving on its own. */
+ .tail { position: relative; display: inline-block; margin-left: 0.32em; transform-origin: 30% 60%; }
+ .tail-t { font-weight: 900; font-stretch: 112%; letter-spacing: 0; color: ${pal.accent}; font-size: 1.18em; line-height: 1; }
+ .tail-glow { position: absolute; left: -140%; right: -140%; top: -90%; bottom: -90%;
+ background: radial-gradient(ellipse closest-side at 50% 52%, ${rgba(pal.accent, 0.26)} 0%, ${rgba(pal.accent, 0.08)} 45%, ${rgba(pal.accent, 0)} 100%); }
+ .bar { position: absolute; left: 0; width: ${W}px; height: ${barH}px; background: ${bar}; }
+ .bar.t { top: 0; box-shadow: 0 1px 0 ${rgba(pal.fg, 0.05)}; }
+ .bar.b { bottom: 0; box-shadow: 0 -1px 0 ${rgba(pal.fg, 0.05)}; }
+ .vignette { position: absolute; inset: 0;
+ background: radial-gradient(ellipse 75% 70% at 50% 50%, rgba(0, 0, 0, 0) 55%, rgba(0, 0, 0, 0.55) 100%); }
+ .grain { position: absolute; left: -240px; top: -160px; width: ${W + 480}px; height: ${H + 320}px;
+ opacity: 0.11; mix-blend-mode: overlay; }
+ </style>
+ </head>
+ <body>
+ <div id="root" data-composition-id="teaser" data-start="0" data-duration="${r4(seconds)}"
+ data-width="${W}" data-height="${H}" data-entry="${esc(entry.id)}">
+ <div id="teaser-clip" class="clip" data-start="0" data-duration="${r4(seconds)}" data-track-index="1">
+ <div class="ground"></div>
+ <div class="stage" data-k="stage">
+ <div class="leak" data-k="leak"></div>
+ <div class="column">
+ ${lineHtml}
+ </div>
+ </div>
+ <div class="vignette"></div>
+ <svg class="grain" data-k="grain" width="${W + 480}" height="${H + 320}" aria-hidden="true">
+ <filter id="teaser-grain"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="7" stitchTiles="stitch"/>
+ <feColorMatrix type="saturate" values="0"/></filter>
+ <rect width="100%" height="100%" filter="url(#teaser-grain)"/>
+ </svg>
+ <div class="bar t" data-k="barT"></div>
+ <div class="bar b" data-k="barB"></div>
+ </div>
+ </div>
+
+ <script id="teaser-data" type="application/json">${json}</script>
+ <script>
+ const D = JSON.parse(document.getElementById("teaser-data").textContent);
+ const byK = {};
+ for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el;
+
+ // t = 0, then the cues: every one states its from, so any seek from
+ // anywhere lands on the same pixels.
+ for (const k of Object.keys(D.init)) if (byK[k]) gsap.set(byK[k], D.init[k]);
+ const tl = gsap.timeline({ paused: true });
+ for (const c of D.cues) {
+ const el = byK[c.k];
+ if (!el) continue;
+ tl.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at);
+ }
+ window.__timelines = window.__timelines || {};
+ window.__timelines["teaser"] = tl;
+
+ // The fit: a line wider than the column shrinks a pixel at a time, to
+ // its role's floor. It runs once the face is in -- measuring the
+ // fallback would fit the wrong glyphs -- and changes sizes only, never
+ // a time; the renderer waits on document.fonts.ready.
+ function fit(row) {
+ const role = row.parentElement.classList.contains("sub") ? "sub" : row.closest(".line").dataset.role;
+ let s = parseFloat(getComputedStyle(row).fontSize);
+ const floor = D.floors[role] || 12;
+ while (s > floor && row.scrollWidth > D.maxW + 0.5) {
+ s -= 1;
+ row.style.fontSize = s + "px";
+ }
+ }
+ const ready = Promise.all([
+ document.fonts.load("900 100px TeaserDisplay"),
+ document.fonts.load("600 30px TeaserDisplay"),
+ ]).catch(() => {}).then(() => {
+ document.querySelectorAll(".row").forEach(fit);
+ document.documentElement.dataset.fit = "1";
+ });
+
+ const still = new URLSearchParams(location.search).get("still");
+ if (still !== null) {
+ tl.seek(Number(still), false);
+ ready.then(() => tl.seek(Number(still), false));
+ }
+ </script>
+ </body>
+</html>
+`;
+}
diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs
@@ -0,0 +1,234 @@
+// The teaser: its validation, its title, the deck hiding over it, the page it
+// draws, its cue times, and the render cache key that changed words change.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ CARD_TYPES, deckChoreography, deckSchedule, deckText, hidesDeck, resolveDeck, TEASER_MOTION, teaserHits,
+ teaserLines, teaserTimes, teaserTitle, validateTeaser, validateTeasers,
+} from "./deck.mjs";
+import { teaserCues, teaserHtml } from "./chrome-teaser.mjs";
+import { chapterTitle, teaserAudioGraph, teaserSegmentKey } from "./build-video.mjs";
+import { composeChrome } from "./compose-chrome.mjs";
+
+const FERRET = Object.freeze({
+ type: "teaser", id: "fin", seconds: 7,
+ lines: ["Pirate Software", { text: "The Largest Ferret Rescue in the United States", break: "in the United States" }, "February 2027"],
+ tail: "?",
+});
+const RENDER = {
+ width: 1920, height: 1080, fps: 30, audioRate: 48000, audioChannels: 2,
+ palette: { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" },
+};
+
+test("a valid teaser has nothing to say; every bad shape is a sentence", () => {
+ assert.deepEqual(validateTeaser(FERRET), []);
+ assert.deepEqual(validateTeaser({ ...FERRET, tail: undefined, hits: false }), []);
+ const bad = (patch) => validateTeaser({ ...FERRET, ...patch }).join(" | ");
+ assert.match(bad({ lines: [] }), /lines must be a list of 1 to 5/);
+ assert.match(bad({ lines: ["a", "b", "c", "d", "e", "f"] }), /1 to 5/);
+ assert.match(bad({ lines: ["ok", ""] }), /lines\[1\] must be words/);
+ assert.match(bad({ lines: ["two\nlines"] }), /one line/);
+ assert.match(bad({ lines: ["x".repeat(81)] }), /81 characters/);
+ assert.match(bad({ lines: [{ text: "Abc", brk: "c" }] }), /lines\[0\]\.brk is not a teaser line field/);
+ assert.match(bad({ lines: [{ text: "The big one", break: "small" }] }), /must be the end of its text/);
+ assert.match(bad({ lines: [{ text: "whole", break: "whole" }] }), /leaves nothing for the first tier/);
+ assert.match(bad({ lines: [42] }), /string or \{ text, break \}/);
+ assert.match(bad({ seconds: 2 }), /seconds must be from 3 to 20/);
+ assert.match(bad({ seconds: 21 }), /from 3 to 20/);
+ assert.match(bad({ tail: "" }), /tail must be a short string/);
+ assert.match(bad({ tail: "?????????" }), /tail is 9 characters/);
+ assert.match(bad({ hits: "yes" }), /hits must be true or false/);
+ assert.match(bad({ id: "../x" }), /id must be letters/);
+ // The manifest's validator names where.
+ const errs = validateTeasers({ timeline: [{ type: "clip", id: "c1" }, { ...FERRET, seconds: 1 }] });
+ assert.equal(errs.length, 1);
+ assert.match(errs[0], /^timeline\[1\] \(fin\)\.seconds/);
+});
+
+test("lines: roles by position, the break is the second tier, the title joins them", () => {
+ const l = teaserLines(FERRET);
+ assert.deepEqual(l.map((x) => x.role), ["overline", "title", "kicker"]);
+ assert.equal(l[1].head, "The Largest Ferret Rescue");
+ assert.equal(l[1].sub, "in the United States");
+ assert.equal(l[0].sub, null);
+ assert.deepEqual(teaserLines({ lines: ["A", "B"] }).map((x) => x.role), ["overline", "title"]);
+ assert.deepEqual(teaserLines({ lines: ["A"] }).map((x) => x.role), ["title"]);
+ assert.deepEqual(teaserLines({ lines: ["A", "B", "C", "D"] }).map((x) => x.role), ["overline", "title", "title", "kicker"]);
+ assert.equal(
+ teaserTitle(FERRET),
+ "Pirate Software — The Largest Ferret Rescue in the United States — February 2027 ?",
+ );
+ assert.equal(teaserTitle({ ...FERRET, tail: undefined }).endsWith("February 2027"), true);
+});
+
+test("the chapter is the teaser's title, with or without the deck; an authored chapter wins", async () => {
+ assert.equal(await chapterTitle(FERRET, 17, {}), teaserTitle(FERRET));
+ assert.equal(await chapterTitle(FERRET, 17, {}, { deck: true }), teaserTitle(FERRET));
+ assert.equal(await chapterTitle({ ...FERRET, chapter: "Next season" }, 17, {}, { deck: true }), "Next season");
+});
+
+test("the deck slides away over a teaser whatever overCards says; no pip, no QR", () => {
+ assert.ok(CARD_TYPES.includes("teaser"));
+ for (const overCards of ["hide", "show"]) {
+ const deck = resolveDeck({ chrome: { engine: "hyperframes", layout: "deck", deck: { overCards } } });
+ assert.equal(hidesDeck(FERRET, deck), true, overCards);
+ assert.equal(hidesDeck({ type: "card" }, deck), overCards === "hide");
+ }
+ const render = { ...RENDER, chrome: { engine: "hyperframes", layout: "deck", deck: {} } };
+ const clip = { type: "clip", id: "c20", video: "v", start: 10, end: 20, citeUrl: "https://example.org/c20" };
+ const sched = deckSchedule({ entries: [clip, FERRET], durs: [10, 7], D: 0.5, render });
+ const fin = sched.segments[1];
+ assert.equal(fin.hideDeck, true);
+ assert.equal(fin.qrUrl, null);
+ assert.equal(fin.title, teaserTitle(FERRET));
+ assert.equal(fin.subtitle, "");
+ // Into the teaser the deck hides (a visibility change), no text handover.
+ const ch = deckChoreography(sched, render);
+ assert.equal(ch.handovers.length, 0);
+ assert.deepEqual(ch.visibility.map((v) => [v.i, v.hide]), [[1, true]]);
+ assert.deepEqual(deckText(FERRET, null, {}, resolveDeck(render), false), { title: teaserTitle(FERRET), subtitle: "" });
+});
+
+test("the cues: one per pop at the shared times, top to bottom, every from stated", () => {
+ const lines = teaserLines(FERRET);
+ const { cues, init, beats } = teaserCues({ lines, tail: "?", seconds: 7 });
+ const m = TEASER_MOTION;
+ // The ferret card needs no compression: the times are the motion's own.
+ assert.deepEqual(beats.lines.map((b) => b.at), [m.first, m.first + m.gap, m.first + m.gap + m.sub + m.gap]);
+ assert.equal(beats.lines[1].subAt, m.first + m.gap + m.sub);
+ // About 0.6–0.8 s apart, in order.
+ const ats = beats.lines.map((b) => b.at);
+ for (let i = 1; i < ats.length; i += 1) assert.ok(ats[i] - ats[i - 1] >= 0.6 && ats[i] - ats[i - 1] <= 1.0001);
+ // The tail starts after the date has settled and is in before the end fade's hold.
+ assert.ok(beats.tailAt >= beats.lines[2].impact + m.settle - 1e-9);
+ assert.ok(beats.tailAt + beats.tailDur <= 7 - m.endRoom + 1e-9);
+ // The slam lands on the impact, and the flash is centred on it.
+ lines.forEach((_, i) => {
+ const slam = cues.find((c) => c.why === `line ${i} slam`);
+ assert.equal(Math.round((slam.at + slam.dur) * 1e4) / 1e4, beats.lines[i].impact);
+ const flash = cues.find((c) => c.why === `line ${i} flash`);
+ assert.equal(Math.round((flash.at + flash.dur / 2) * 1e4) / 1e4, beats.lines[i].impact);
+ });
+ // Every cue's from is stated, and is the state the element was left in.
+ const state = JSON.parse(JSON.stringify(init));
+ 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);
+ }
+ // Seek-safe: no cue on an element starts before the one before it has ended.
+ const free = new Map();
+ for (const c of cues) {
+ assert.ok(c.at >= (free.get(c.k) ?? 0) - 1e-9, `${c.k} at ${c.at}`);
+ free.set(c.k, c.at + c.dur);
+ }
+ // The end state: every line and the tail fully shown.
+ for (let i = 0; i < lines.length; i += 1) assert.equal(state[`l${i}.o`].autoAlpha, 1);
+ assert.equal(state.tail.autoAlpha, 1);
+ assert.equal(state["l1.sub"].autoAlpha, 1);
+});
+
+test("a short card plays every beat faster, and still leaves the end fade its room", () => {
+ const lines = teaserLines({ lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] });
+ const t = teaserTimes(lines, "?", 3);
+ assert.ok(t.scale < 1);
+ assert.ok(t.tailAt + t.tailDur <= 3 - TEASER_MOTION.endRoom + 1e-6);
+ const { cues } = teaserCues({ lines, tail: "?", seconds: 3 });
+ assert.ok(cues.every((c) => c.at + c.dur <= 3 + 1e-6));
+});
+
+test("the hits sit on the pops: the cue list's times, no second copy", () => {
+ const lines = teaserLines(FERRET);
+ const { beats } = teaserCues({ lines, tail: "?", seconds: 7 });
+ const hits = teaserHits(FERRET);
+ assert.deepEqual(hits.filter((h) => h.kind === "hit").map((h) => [h.role, h.at]), [
+ ["overline", beats.lines[0].impact],
+ ["title", beats.lines[1].impact],
+ ["sub", beats.lines[1].subAt],
+ ["kicker", beats.lines[2].impact],
+ ]);
+ // The main title's is the biggest; the second tier's the lightest and shortest.
+ const by = Object.fromEntries(hits.map((h) => [h.role, h]));
+ assert.ok(by.title.gain > by.kicker.gain && by.kicker.gain > by.sub.gain && by.overline.gain > by.sub.gain);
+ assert.ok(by.sub.decay < by.title.decay);
+ // The tail gets a swell, not a hit, starting with its fade.
+ assert.deepEqual([by.tail.kind, by.tail.at, by.tail.dur], ["swell", beats.tailAt, beats.tailDur]);
+ assert.deepEqual(teaserHits({ ...FERRET, hits: false }), []);
+});
+
+test("the page: every line's nodes, escaped words, the tail, nothing fetched from anywhere", () => {
+ const evil = {
+ ...FERRET,
+ lines: ["<b>Pirate</b> & \"Co\"", { text: "Title </script><script>x()</script> end", break: "end" }, "Feb's 2027"],
+ };
+ const html = teaserHtml(evil, RENDER);
+ assert.ok(html.includes("<b>Pirate</b> & "Co""));
+ assert.ok(html.includes("Feb's 2027"));
+ assert.ok(!html.includes("<b>Pirate"));
+ // One script open per script; the words cannot close the data block.
+ assert.equal((html.match(/<script/g) ?? []).length, 3);
+ assert.ok(!/<\/script><script>x\(\)/.test(html));
+ for (let i = 0; i < 3; i += 1) {
+ for (const k of [`l${i}.o`, `l${i}`, `l${i}.t`, `l${i}.flash`, `l${i}.streak`]) {
+ assert.ok(html.includes(`data-k="${k}"`), k);
+ }
+ }
+ assert.ok(html.includes('data-k="l0.rules"'));
+ assert.ok(html.includes('data-k="l1.sub"') && html.includes('data-k="l1.subt"'));
+ assert.ok(html.includes('data-k="tail"') && html.includes('data-k="tail.glow"'));
+ // The tail sits in the LAST line.
+ assert.ok(html.indexOf('data-line="2"') < html.indexOf('data-k="tail"'));
+ // The contract: one composition, its duration, one paused timeline.
+ assert.match(html, /data-composition-id="teaser" data-start="0" data-duration="7"/);
+ assert.match(html, /window\.__timelines\["teaser"\] = tl/);
+ assert.match(html, /gsap\.timeline\(\{ paused: true \}\)/);
+ // No URL that leaves the project: the face and GSAP are local files.
+ assert.deepEqual(html.match(/\b(?:https?:|\/\/[a-z])[^\s"')]*/gi) ?? [], []);
+ assert.match(html, /url\('assets\/TeaserDisplay\.ttf'\)/);
+ assert.match(html, /<script src="assets\/gsap\.min\.js">/);
+ // The cue data in the page is the cue list.
+ const json = JSON.parse(html.match(/<script id="teaser-data" type="application\/json">([\s\S]*?)<\/script>/)[1]);
+ const { cues } = teaserCues({ lines: teaserLines(evil), tail: "?", seconds: 7 });
+ assert.deepEqual(json.cues, cues.map(({ why, ...c }) => c));
+ // No tail, no tail nodes.
+ assert.ok(!teaserHtml({ ...FERRET, tail: undefined }, RENDER).includes('data-k="tail"'));
+});
+
+test("the cache key: the composed page changes with the words, the segment key with the sound", async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "teaser-"));
+ try {
+ const manifest = (entry) => ({ slug: "t", render: RENDER, timeline: [entry] });
+ const mp = path.join(dir, "video.manifest.json");
+ const compose = async (entry) => {
+ writeFileSync(mp, JSON.stringify(manifest(entry)));
+ return composeChrome({ manifestPath: mp, region: "teaser", segment: "fin", preview: false, doRender: false });
+ };
+ const a = await compose(FERRET);
+ const again = await compose(FERRET);
+ const b = await compose({ ...FERRET, lines: ["Pirate Software", "Another Arc", "February 2027"] });
+ assert.equal(a.key, again.key);
+ assert.notEqual(a.key, b.key);
+ assert.equal(a.frameCount, 210);
+ assert.ok(a.projDir.endsWith(path.join("out", "sourced", "chrome", "teaser-fin")));
+ assert.ok(existsSync(path.join(a.projDir, "assets", "TeaserDisplay.ttf")));
+ assert.ok(existsSync(path.join(a.projDir, "assets", "gsap.min.js")));
+ assert.ok(readFileSync(path.join(a.projDir, "index.html"), "utf8").includes("Another Arc"));
+ await assert.rejects(
+ () => composeChrome({ manifestPath: mp, region: "teaser", segment: "nope" }),
+ /no teaser entry nope/,
+ );
+ // The sound is in the segment's key: hits on and off are different segments.
+ const on = teaserAudioGraph(teaserHits(FERRET), { seconds: 7, render: RENDER });
+ const off = teaserAudioGraph(teaserHits({ ...FERRET, hits: false }), { seconds: 7, render: RENDER });
+ assert.notEqual(teaserSegmentKey(a.key, on), teaserSegmentKey(a.key, off));
+ assert.notEqual(teaserSegmentKey(a.key, on), teaserSegmentKey(b.key, on));
+ assert.equal(teaserSegmentKey(a.key, on), teaserSegmentKey(again.key, on));
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs
@@ -39,7 +39,7 @@ import path from "node:path";
import { ledgerTotals, dateKey } from "./ledger-totals.mjs";
import { selectVariant } from "./build-video.mjs";
import {
- chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256,
+ chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256, validateTeaser,
} from "./deck.mjs";
import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs";
import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs";
@@ -613,7 +613,7 @@ async function copyFonts(render, assetsDir, names, { strict }) {
* `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, window }) {
+async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from, window, teaser }) {
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 });
@@ -642,6 +642,14 @@ async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule
for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl);
return postsHtml(schedule, render, window, { fonts, qrSrcs });
}
+ if (region === "teaser") {
+ // A page module reached by a dynamic import, so nothing that imports this
+ // file -- umtool's preview helper, the build -- loads its face's URL
+ // unless a teaser is being composed (docs/quirks.md).
+ const { teaserHtml, TEASER_FONT_FILE, TEASER_FONT_ASSET } = await import("./chrome-teaser.mjs");
+ await copyFile(TEASER_FONT_FILE, path.join(projDir, TEASER_FONT_ASSET));
+ return teaserHtml(teaser, manifest.render, { font: TEASER_FONT_ASSET });
+ }
throw new Error(`unknown chrome region: ${region}`);
}
@@ -703,6 +711,14 @@ function runRenderer(cmd, args) {
* their `.key`, cached exactly as the deck's are;
* - `still` is in CUT seconds.
*
+ * Teaser (`region: "teaser"`, `segment` = the entry's id): the whole frame,
+ * `seconds` long, drawn from the entry alone (no schedule):
+ * - project `chrome/teaser-<id>/` (`teaser-preview-<id>/` when `preview`),
+ * frames `chrome/teaser-<id>-frames/` and their `.key`, cached as the deck's
+ * are -- the key hashes the page, so changed words are a new render;
+ * - the build encodes the frames into `segments/<id>.mp4` (build-video's
+ * `buildTeaserSegment`).
+ *
* `schedule` (an object) overrides reading `out/<variant>/schedule.json`.
*
* @returns {Promise<{ projDir: string, frames: string|null, still: string|null,
@@ -723,10 +739,18 @@ export async function composeChrome({
const base = path.resolve(outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out", variant));
from = Number(from ?? 0);
- // The two regions drawn from the deck's schedule, and keyed by the render cache.
- const keyed = region === "deck" || region === "posts";
+ // The regions keyed by the render cache: the two drawn from the deck's
+ // schedule, and a teaser, drawn from its own timeline entry.
+ const keyed = region === "deck" || region === "posts" || region === "teaser";
+ let teaser = null;
+ if (region === "teaser") {
+ teaser = (manifest.timeline ?? []).find((e) => e.id === segment && e.type === "teaser") ?? null;
+ if (!teaser) throw new Error(`no teaser entry ${segment ?? "(none named)"} in the ${variant} cut`);
+ const errors = validateTeaser(teaser);
+ if (errors.length) throw new Error(`teaser ${teaser.id}: ${errors.join("; ")}`);
+ }
let sched = schedule;
- if (keyed && !sched) {
+ if ((region === "deck" || region === "posts") && !sched) {
const p = path.join(base, "schedule.json");
try {
sched = JSON.parse(await readFile(p, "utf8"));
@@ -735,7 +759,7 @@ export async function composeChrome({
}
if (sched.kind !== "deck") throw new Error(`${p} is not a deck schedule (kind ${sched.kind ?? "missing"})`);
}
- const total = keyed ? sched.total : null;
+ const total = teaser ? Number(teaser.seconds) : keyed ? sched.total : null;
const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30);
const windowed =
region === "deck" && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6));
@@ -757,7 +781,9 @@ export async function composeChrome({
const projName =
region === "posts"
? `posts-${preview ? "preview-" : ""}${win.segment}`
- : region === "deck" && preview ? "deck-preview" : `${region}${suffix}`;
+ : region === "teaser"
+ ? `teaser-${preview ? "preview-" : ""}${teaser.id}`
+ : 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
@@ -768,7 +794,7 @@ export async function composeChrome({
const html = await regionHtml(region, {
manifest, base, projDir, assetsDir, schedule: sched,
- duration: duration != null ? Number(duration) : null, from, window: win,
+ duration: duration != null ? Number(duration) : null, from, window: win, teaser,
});
await writeFile(path.join(projDir, "index.html"), html, "utf8");
await writeFile(path.join(projDir, "hyperframes.json"), HF_JSON + "\n", "utf8");
@@ -809,7 +835,7 @@ export async function composeChrome({
// A sequence is a directory; every other format is a file.
const sequence = format === "png-sequence";
- const stem = region === "posts" ? `posts-${win.segment}` : `${region}${suffix}`;
+ const stem = region === "posts" ? `posts-${win.segment}` : region === "teaser" ? `teaser-${teaser.id}` : `${region}${suffix}`;
const target = sequence
? path.join(base, "chrome", `${stem}-frames`)
: path.join(base, "chrome", `${stem}.${format}`);
@@ -856,8 +882,8 @@ if (import.meta.url === `file://${process.argv[1]}`) {
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|deck|posts] [--variant sourced|full]\n" +
- " [--segment <id>] (posts: the clip whose window to compose)\n" +
+ "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|posts|teaser] [--variant sourced|full]\n" +
+ " [--segment <id>] (posts: the clip whose window to compose; teaser: its entry)\n" +
" [--from <s>] [--duration <s>] [--out <dir>] [--preview]\n" +
" [--still <s> --png <path>]\n" +
" [--render] [--workers 4] [--quality high] [--format png-sequence] [--fps 30]",
@@ -879,7 +905,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
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 : region === "posts" ? 2 : null),
+ workers: num("--workers") ?? (region === "deck" || region === "teaser" ? 4 : region === "posts" ? 2 : null),
quality: flag("--quality") ?? "high",
format: flag("--format") ?? "png-sequence",
fps: num("--fps"),
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -51,8 +51,12 @@ export const DECK_DEFAULTS = Object.freeze({
/** 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"]);
+/**
+ * Segment types the deck slides away over when `overCards: "hide"`. A
+ * `teaser` is one too, and the deck slides away over it whatever `overCards`
+ * says: it is a full-frame finale, never framed into the footage box.
+ */
+export const CARD_TYPES = Object.freeze(["card", "scroll", "chart", "ledger", "teaser"]);
/** Does this render block ask for the deck? Absent, nothing in this file runs. */
export function deckOn(render) {
@@ -437,6 +441,7 @@ export function isMultiChannel(entries, provenance = {}) {
/** Is the deck hidden over this segment? */
export function hidesDeck(entry, deck) {
+ if (entry.type === "teaser") return true;
return deck.overCards === "hide" && CARD_TYPES.includes(entry.type);
}
@@ -448,6 +453,11 @@ export function deckText(entry, meta, provenance, deck, multiChannel) {
if (subtitle === undefined) {
if (entry.type === "clip") {
subtitle = deckSubtitle(attributionParts(entry, meta ?? {}, provenance), deck.subtitle, multiChannel);
+ } else if (entry.type === "teaser") {
+ // The deck is never up over a teaser; its words are what a table of
+ // the cut (umtool's On-screen rows, the schedule) names it by.
+ if (!title) title = teaserTitle(entry);
+ subtitle = "";
} else if (entry.type === "image") {
subtitle = deckSubtitle(
{ channel: "", title: String(entry.title ?? "").trim(), date: String(entry.date ?? "").trim(), at: null },
@@ -972,6 +982,217 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds
}
// ---------------------------------------------------------------------------
+// The teaser: a full-frame graphic card -- a season teaser's "coming soon"
+// screen -- whose words are the manifest's. Its segment is a HyperFrames
+// render (chrome-teaser.mjs draws it, compose-chrome renders it, the build
+// encodes it). Pure here: what the words are, and why they cannot be drawn.
+//
+// { "type": "teaser", "id": "fin", "seconds": 7,
+// "lines": ["Pirate Software",
+// { "text": "The Largest Ferret Rescue in the United States",
+// "break": "in the United States" },
+// "February 2027"],
+// "tail": "?", "hits": true }
+//
+// A line is a string, or `{ text, break }`: `break` is the END of `text` set
+// as a smaller second tier under the rest, a beat later. The tail is appended
+// to the last line and fades in on its own. `hits` (default true) puts a
+// trailer hit under each pop and a swell under the tail; false is silence.
+// Roles follow position: with three
+// or more lines the first is the overline and the last the kicker (a date),
+// everything between is a title; two lines are an overline and a title; one
+// is a title.
+// ---------------------------------------------------------------------------
+
+/** The teaser's limits: lines, seconds, characters per line, the tail's length. */
+export const TEASER_LIMITS = Object.freeze({ lines: [1, 5], seconds: [3, 20], chars: 80, tail: 8 });
+
+const LINE_KEYS = ["text", "break"];
+
+/**
+ * A teaser's lines, normalised: `{ text, head, sub, role }` each, `head` the
+ * part drawn on the first tier and `sub` the second tier (`break`) or null.
+ * Trims; assumes `validateTeaser` passed.
+ *
+ * @returns {Array<{ text: string, head: string, sub: string|null, role: "overline"|"title"|"kicker" }>}
+ */
+export function teaserLines(entry) {
+ const lines = Array.isArray(entry?.lines) ? entry.lines : [];
+ const n = lines.length;
+ return lines.map((l, i) => {
+ const text = String(isObj(l) ? l.text ?? "" : l ?? "").trim();
+ const brk = isObj(l) && typeof l.break === "string" ? l.break.trim() : "";
+ const sub = brk && text.endsWith(brk) && text.length > brk.length ? brk : null;
+ const head = sub ? text.slice(0, text.length - sub.length).trim() : text;
+ const role = n >= 3 ? (i === 0 ? "overline" : i === n - 1 ? "kicker" : "title")
+ : n === 2 ? (i === 0 ? "overline" : "title")
+ : "title";
+ return { text, head, sub, role };
+ });
+}
+
+/** The teaser's tail, trimmed, or "" for none. */
+export const teaserTail = (entry) => (typeof entry?.tail === "string" ? entry.tail.trim() : "");
+
+/**
+ * What a teaser is called where a cut names its entries -- its chapter, and
+ * its row in umtool: the lines joined with " — ", the tail after the last.
+ */
+export function teaserTitle(entry) {
+ const texts = teaserLines(entry).map((l) => l.text).filter(Boolean);
+ const tail = teaserTail(entry);
+ if (tail && texts.length) texts[texts.length - 1] = `${texts[texts.length - 1]} ${tail}`;
+ return texts.join(" — ");
+}
+
+/**
+ * The teaser's motion, in seconds -- ONE copy, read by the composition (the
+ * cues, chrome-teaser.mjs) and by the build (the hits under them). The first
+ * line lands `first` into the card (after the incoming dissolve); each next
+ * one `gap` after the one before, or after its second tier, which pops `sub`
+ * after its first. A line slams in from `slam`× its size and blurred and hits
+ * -- undershooting to `under` -- `hit` after it starts, then settles to rest
+ * over `settle`. The tail starts `tailAfter` after the last line's pop and
+ * fades in over `tailDur`. The last `endRoom` seconds hold still for the
+ * cut's end fade; a card too short for all of it plays every beat
+ * proportionally faster (`teaserTimes`).
+ */
+export const TEASER_MOTION = Object.freeze({
+ first: 0.55, gap: 0.7, sub: 0.3, slam: 1.42, under: 0.968, hit: 0.2, settle: 0.5,
+ blur: 18, tailAfter: 0.8, tailDur: 1.7, endRoom: 1.2, push: 1.065, grainHz: 12,
+});
+
+/**
+ * When everything in a teaser happens, in the card's clock: per line its
+ * start (`at`), its impact (`impact` = at + hit, where the slam lands, the
+ * flash fires and the hit sounds) and its second tier's pop (`subAt`, null
+ * without one); the tail's start and length; and `scale` (< 1 when the beats
+ * were compressed to fit the card). `T` scales any motion length the same way.
+ *
+ * @returns {{ lines: Array<{ at: number, impact: number, subAt: number|null }>,
+ * tailAt: number|null, tailDur: number, end: number, scale: number, T: (v: number) => number }}
+ */
+export function teaserTimes(lines, tail, seconds, m = TEASER_MOTION) {
+ let t = m.first;
+ const raw = [];
+ lines.forEach((l, i) => {
+ if (i > 0) t += m.gap;
+ const at = t;
+ const subAt = l.sub ? at + m.sub : null;
+ if (subAt != null) t = subAt;
+ raw.push({ at, subAt });
+ });
+ const tailRaw = tail ? t + m.tailAfter : null;
+ const endRaw = tailRaw != null ? tailRaw + m.tailDur : t + m.hit + m.settle;
+ const room = Math.max(0.5, seconds - m.endRoom);
+ const scale = endRaw > room ? room / endRaw : 1;
+ const r = (v) => Math.round(v * 10000) / 10000;
+ const T = (v) => r(v * scale);
+ return {
+ lines: raw.map((b) => ({ at: T(b.at), impact: r(T(b.at) + T(m.hit)), subAt: b.subAt == null ? null : T(b.subAt) })),
+ tailAt: tailRaw == null ? null : T(tailRaw),
+ tailDur: T(m.tailDur),
+ end: T(endRaw),
+ scale: r(scale),
+ T,
+ };
+}
+
+/**
+ * The teaser's sound design, as data: one trailer hit under each pop, at the
+ * moment the composition says it lands, and a low swell under the tail's
+ * slow entrance. Empty when `hits: false`.
+ *
+ * Gains are relative (the main title is 1): a title's hit is the biggest, an
+ * overline's and a kicker's a little smaller, a second tier's lighter and
+ * shorter. The build turns this into one ffmpeg graph (`teaserAudioGraph`).
+ *
+ * @returns {Array<{ kind: "hit"|"swell", at: number, role: string, gain: number,
+ * decay: number, f0: number, f1: number, dur?: number }>}
+ */
+export function teaserHits(entry) {
+ if (entry?.hits === false) return [];
+ const lines = teaserLines(entry);
+ const tail = teaserTail(entry);
+ const times = teaserTimes(lines, tail, Number(entry.seconds));
+ const out = [];
+ const HIT = {
+ title: { gain: 1, decay: 0.42, f0: 92, f1: 40 },
+ overline: { gain: 0.72, decay: 0.34, f0: 96, f1: 44 },
+ kicker: { gain: 0.8, decay: 0.36, f0: 94, f1: 42 },
+ sub: { gain: 0.42, decay: 0.16, f0: 120, f1: 64 },
+ };
+ lines.forEach((l, i) => {
+ out.push({ kind: "hit", at: times.lines[i].impact, role: l.role, ...HIT[l.role] });
+ if (l.sub && times.lines[i].subAt != null) out.push({ kind: "hit", at: times.lines[i].subAt, role: "sub", ...HIT.sub });
+ });
+ if (tail && times.tailAt != null) {
+ out.push({ kind: "swell", at: times.tailAt, role: "tail", gain: 0.34, decay: 0.7, f0: 46, f1: 62, dur: times.tailDur });
+ }
+ return out;
+}
+
+/**
+ * Why one teaser entry cannot be built, as sentences (empty: it can).
+ *
+ * @returns {string[]}
+ */
+export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"}`) {
+ const errors = [];
+ const [lo, hi] = TEASER_LIMITS.lines;
+ const [slo, shi] = TEASER_LIMITS.seconds;
+ if (typeof entry?.id !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(entry.id)) {
+ errors.push(`${where}.id must be letters, digits, dashes or underscores (it names the segment's file)`);
+ }
+ if (!numIn(entry?.seconds, slo, shi)) errors.push(`${where}.seconds must be from ${slo} to ${shi}`);
+ const oneLine = (s, w) => {
+ if (typeof s !== "string" || !s.trim()) { errors.push(`${w} must be words, not empty`); return false; }
+ if (/[\r\n]/.test(s)) { errors.push(`${w} must be one line`); return false; }
+ if (s.trim().length > TEASER_LIMITS.chars) {
+ errors.push(`${w} is ${s.trim().length} characters (at most ${TEASER_LIMITS.chars})`);
+ return false;
+ }
+ return true;
+ };
+ const lines = entry?.lines;
+ if (!Array.isArray(lines) || lines.length < lo || lines.length > hi) {
+ errors.push(`${where}.lines must be a list of ${lo} to ${hi} lines`);
+ } else {
+ lines.forEach((l, i) => {
+ const w = `${where}.lines[${i}]`;
+ if (typeof l === "string") { oneLine(l, w); return; }
+ if (!isObj(l)) { errors.push(`${w} must be a string or { text, break }`); return; }
+ for (const k of Object.keys(l)) if (!LINE_KEYS.includes(k)) errors.push(`${w}.${k} is not a teaser line field`);
+ if (!oneLine(l.text, `${w}.text`)) return;
+ if (l.break === undefined || l.break === null) return;
+ if (!oneLine(l.break, `${w}.break`)) return;
+ const text = l.text.trim();
+ const brk = l.break.trim();
+ if (!text.endsWith(brk)) errors.push(`${w}.break must be the end of its text ("${brk}" is not how "${text}" ends)`);
+ else if (!text.slice(0, text.length - brk.length).trim()) errors.push(`${w}.break leaves nothing for the first tier`);
+ });
+ }
+ if (entry?.hits !== undefined && typeof entry.hits !== "boolean") errors.push(`${where}.hits must be true or false`);
+ if (entry?.tail !== undefined && entry?.tail !== null) {
+ if (typeof entry.tail !== "string" || !entry.tail.trim()) errors.push(`${where}.tail must be a short string, or absent`);
+ else if (/[\r\n]/.test(entry.tail)) errors.push(`${where}.tail must be one line`);
+ else if (entry.tail.trim().length > TEASER_LIMITS.tail) {
+ errors.push(`${where}.tail is ${entry.tail.trim().length} characters (at most ${TEASER_LIMITS.tail})`);
+ }
+ }
+ return errors;
+}
+
+/** Every teaser in the timeline, checked: the build refuses with these before it fetches. */
+export function validateTeasers(manifest) {
+ const errors = [];
+ (manifest?.timeline ?? []).forEach((e, i) => {
+ if (e?.type === "teaser") errors.push(...validateTeaser(e, `timeline[${i}] (${e.id ?? "?"})`));
+ });
+ return errors;
+}
+
+// ---------------------------------------------------------------------------
// The render cache and the renderer command.
// ---------------------------------------------------------------------------
diff --git a/umtool/report-to-video/teaser-audio.test.mjs b/umtool/report-to-video/teaser-audio.test.mjs
@@ -0,0 +1,75 @@
+// The teaser's sound, through real ffmpeg: a hit starts on the frame its pop
+// lands on, nothing clips, and `hits: false` is digital silence.
+//
+// The onset of hit i is measured as the first sample where the graph WITH it
+// differs from the same graph WITHOUT it: the hits overlap (the second tier
+// lands 0.1 s into the title's decay), so "the first loud sample after the
+// cue" would find the previous hit's tail. Every layer up to the limiter is
+// linear and the noise is a hash of the sample number, so the difference is
+// hit i alone until the limiter engages -- after its onset.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import test from "node:test";
+
+import { teaserAudioGraph } from "./build-video.mjs";
+import { teaserHits } from "./deck.mjs";
+
+const have = spawnSync("ffmpeg", ["-version"]).status === 0;
+const RENDER = { fps: 30, audioRate: 48000, audioChannels: 2 };
+const FERRET = Object.freeze({
+ type: "teaser", id: "fin", seconds: 7,
+ lines: ["Pirate Software", { text: "The Largest Ferret Rescue in the United States", break: "in the United States" }, "February 2027"],
+ tail: "?",
+});
+
+/** The graph rendered to raw float samples, channel 0. */
+function samples(graph) {
+ const r = spawnSync("ffmpeg", [
+ "-nostdin", "-v", "error", "-filter_complex", graph, "-map", "[ta]", "-f", "f32le", "-ac", "2", "-",
+ ], { maxBuffer: 1 << 26 });
+ assert.equal(r.status, 0, String(r.stderr));
+ const f = new Float32Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 4);
+ const ch0 = new Float32Array(f.length / 2);
+ for (let i = 0; i < ch0.length; i += 1) ch0[i] = f[2 * i];
+ return { ch0, all: f };
+}
+
+test("each hit's onset lands within a frame of its pop", { skip: !have && "no ffmpeg" }, () => {
+ const hits = teaserHits(FERRET);
+ const full = samples(teaserAudioGraph(hits, { seconds: 7, render: RENDER })).ch0;
+ assert.equal(full.length, 7 * 48000);
+ const frame = 1 / RENDER.fps;
+ hits.forEach((h, i) => {
+ if (h.kind !== "hit") return;
+ const without = samples(teaserAudioGraph(hits.filter((_, j) => j !== i), { seconds: 7, render: RENDER })).ch0;
+ let first = -1;
+ for (let n = 0; n < full.length; n += 1) {
+ if (Math.abs(full[n] - without[n]) > 1e-4) { first = n; break; }
+ }
+ assert.ok(first >= 0, `${h.role} made no sound`);
+ const onset = first / 48000;
+ assert.ok(Math.abs(onset - h.at) <= frame, `${h.role}: onset ${onset.toFixed(4)}s, pop ${h.at}s`);
+ });
+});
+
+test("nothing clips: the sum stays under −6 dBFS (about) and well under full scale", { skip: !have && "no ffmpeg" }, () => {
+ const { all } = samples(teaserAudioGraph(teaserHits(FERRET), { seconds: 7, render: RENDER }));
+ let peak = 0;
+ for (const v of all) peak = Math.max(peak, Math.abs(v));
+ assert.ok(peak > 0.2, `peak ${peak}`); // it is not silent
+ assert.ok(peak <= 0.5 * 1.03, `peak ${peak} (${(20 * Math.log10(peak)).toFixed(2)} dBFS)`);
+ // A short card packs the hits together; they still sum cleanly.
+ const short = { ...FERRET, seconds: 3, lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] };
+ const s = samples(teaserAudioGraph(teaserHits(short), { seconds: 3, render: RENDER })).all;
+ let p2 = 0;
+ for (const v of s) p2 = Math.max(p2, Math.abs(v));
+ assert.ok(p2 <= 0.5 * 1.03, `short card peak ${p2}`);
+});
+
+test("hits: false is digital silence, exactly as long", { skip: !have && "no ffmpeg" }, () => {
+ const { all } = samples(teaserAudioGraph(teaserHits({ ...FERRET, hits: false }), { seconds: 7, render: RENDER }));
+ assert.equal(all.length, 7 * 48000 * 2);
+ assert.ok(all.every((v) => v === 0));
+});
diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs
@@ -90,8 +90,12 @@ export async function verifyBuild(manifestPath, { outDir, variant = "sourced" }
if (deckOn(manifest.render)) {
deck = await verifyDeck(path.join(root, variant), manifest.render, file, problems);
}
+ const teasers = await verifyTeasers(path.join(root, variant), manifest, problems);
- return { ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, deck, problems };
+ return {
+ ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, deck,
+ ...(teasers.length ? { teasers } : {}), problems,
+ };
}
/**
@@ -149,6 +153,31 @@ export async function verifyDeck(variantDir, render, file, problems) {
};
}
+/**
+ * Each `teaser` entry's segment is the render it claims to be: its frames
+ * (`chrome/teaser-<id>-frames`) are `frameCount(seconds, fps)` long, and the
+ * record beside its segment (`<id>.teaser.json`) names those frames' key -- a
+ * segment encoded from an older render (changed words) fails here.
+ */
+export async function verifyTeasers(variantDir, manifest, problems) {
+ const fps = Number(manifest.render?.fps ?? 30);
+ const out = [];
+ for (const e of manifest.timeline ?? []) {
+ if (e.type !== "teaser") continue;
+ const dir = path.join(variantDir, "chrome", `teaser-${e.id}-frames`);
+ const frames = await readdir(dir).then((fs) => fs.filter((f) => /^frame_\d+\.png$/.test(f)).length, () => 0);
+ const want = frameCount(Number(e.seconds), fps);
+ const key = await readFile(path.join(dir, ".key"), "utf8").then((s) => s.trim(), () => null);
+ const seg = path.join(variantDir, "segments", `${e.id}.mp4`);
+ const rec = await readFile(seg.replace(/\.mp4$/, ".teaser.json"), "utf8").then(JSON.parse, () => null);
+ if (frames !== want) problems.push(`${dir} holds ${frames} frames; the teaser ${e.id} is ${want} (${e.seconds}s at ${fps} fps)`);
+ if (!rec) problems.push(`the teaser ${e.id} has no record beside ${seg} — rebuild it`);
+ else if (key && rec.frames !== key) problems.push(`the teaser ${e.id}'s segment was encoded from another render of it — rebuild it`);
+ out.push({ id: e.id, frames, expectedFrames: want, current: !!rec && rec.frames === key });
+ }
+ return out;
+}
+
/** The mean absolute difference allowed between two frames of one freeze (8-bit luma; re-encoding noise). */
export const FREEZE_TOLERANCE = 1.5;
@@ -262,6 +291,9 @@ async function main() {
: ` hold on ${h.segment}: ${h.hold}s, frozen (${h.at.join("s ≈ ")}s, mean diff ${h.diff})`);
}
}
+ for (const t of res.teasers ?? []) {
+ console.log(` teaser ${t.id}: ${t.frames}/${t.expectedFrames} frame(s)${t.current ? ", segment encoded from them" : ""}`);
+ }
for (const p of res.problems) console.log(` ** ${p}`);
if (res.ok) console.log(" ok");
}