Archilyzer · Source

archilyzer

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

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:
Mumtool/report-to-video/build-video.mjs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aumtool/report-to-video/chrome-teaser.mjs | 397+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/chrome-teaser.test.mjs | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 50++++++++++++++++++++++++++++++++++++++------------
Mumtool/report-to-video/deck.mjs | 225++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aumtool/report-to-video/teaser-audio.test.mjs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/verify-build.mjs | 34+++++++++++++++++++++++++++++++++-
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, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;") + .replace(/'/g, "&#39;"); + +/** 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("&lt;b&gt;Pirate&lt;/b&gt; &amp; &quot;Co&quot;")); + assert.ok(html.includes("Feb&#39;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"); }