commit 78379b24091fe0fcb1e5ac64117d11edce3dad8f
parent 5f42c57ecb7910e5b4eb427371bd86edab88955f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 15:19:56 -0400
report-to-video: a `teaser` entry — a full-frame season-teaser card drawn from the manifest's words, with a trailer hit under each pop
A teaser's lines (1–5, a string or { text, break }, the break drawn as a
smaller second tier a beat later) pop in top to bottom over a dark cinematic
ground; its tail fades in after the last on its own. The composition is
chrome-teaser.mjs, reached through compose-chrome's dynamic import (region
`teaser`), rendered once and cached by the page's content key; the build
encodes the frames into segments/<id>.mp4 at the shared parameters, with a
sound track synthesised in ffmpeg: a hit under each pop at the moment the
cues land (teaserTimes in deck.mjs is the one copy of the timing), a swell
under the tail, limited at −6 dBFS; `hits: false` is digital silence.
The segment is re-encoded only when its key (frames and sound graph) changes;
--chrome-only builds teaser segments, since they are chrome. The deck slides
away over a teaser whatever overCards says; no pip, no QR; its chapter is its
lines joined with " — ". verify-build checks each teaser's frames and that its
segment was encoded from them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 1168 insertions(+), 19 deletions(-)
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: 30, 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: 34, weight: 600, stretch: 125, tracking: 0.4, floor: 18 }),
+ kicker: Object.freeze({ size: 76, 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: 34px; }
+ .title + .title { margin-top: 10px; }
+ .title .sub { margin-top: 14px; }
+ .kicker { margin-top: 64px; }
+ /* 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");
}