Archilyzer · Source

archilyzer

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

commit 0e350ef225ddec5ceede6314741ad5028fcd26ef
parent 2e37008f7bdf8e1d6f28cdce52dcf2f275f5ea56
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 16:33:12 -0400

report-to-video: a teaser's `beat` sets the seconds between its pops; nothing is squeezed to fit

A teaser entry takes an optional `beat` (0.4–2.5 s, default TEASER_MOTION.gap,
0.7): the time from one line's pop to the next, hits included. The second
tier's delay and the tail's wait scale with it in the default's proportion
(3/7 and 8/7 of the beat); the first landing, the slam, the tail's fade and
the end fade's room do not. The hits still come from teaserTimes.

No compression: a `seconds` shorter than the beats need is refused with the
length they need. Without `seconds` the card is as long as its beats need
(the last pop, the tail's fade, 1.2 s still for the end fade), rounded up to
a tenth and at least 3 s; more than 20 s is refused. The beat is in the page
and so in the render and segment keys; a teaser without `beat`, or with the
default's own, composes a byte-identical page (pinned in a test).

buildTeaserSegment is exported for scratch builds of one teaser.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/report-to-video/build-video.mjs | 6+++---
Mumtool/report-to-video/chrome-teaser.mjs | 14++++++++------
Mumtool/report-to-video/chrome-teaser.test.mjs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mumtool/report-to-video/compose-chrome.mjs | 4++--
Mumtool/report-to-video/deck.mjs | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mumtool/report-to-video/teaser-audio.test.mjs | 27++++++++++++++++++++++-----
Mumtool/report-to-video/verify-build.mjs | 7++++---
7 files changed, 228 insertions(+), 41 deletions(-)

diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -82,7 +82,7 @@ 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, teaserHits, teaserTitle, + playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserSeconds, teaserTitle, validateCutEdits, validatePosts, validateTeasers, } from "./deck.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, @@ -1134,7 +1134,7 @@ export const teaserSegmentKey = (framesKey, audioGraph, render) => * 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 }) { +export async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant }) { const { composeChrome } = await import("./compose-chrome.mjs"); const t0 = Date.now(); const r = await composeChrome({ @@ -1145,7 +1145,7 @@ async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant 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 seconds = teaserSeconds(entry); const audio = teaserAudioGraph(teaserHits(entry), { seconds, render }); const key = teaserSegmentKey(r.key, audio, render); const seg = path.join(outDir, "segments", `${entry.id}.mp4`); diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs @@ -24,7 +24,7 @@ // 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"; +import { TEASER_MOTION, teaserLines, teaserMotion, teaserSeconds, teaserTail, teaserTimes } from "./deck.mjs"; export { TEASER_MOTION }; import { mix, rgba } from "./chrome-deck.mjs"; @@ -68,7 +68,8 @@ export function seeded(seed) { * 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 + * the card's length (`teaserSeconds`), `motion` the entry's (`teaserMotion` + * of its `beat`). 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`, @@ -81,7 +82,7 @@ export function seeded(seed) { 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 beats = teaserTimes(lines, tail, m); const { T } = beats; const init = {}; @@ -171,7 +172,8 @@ export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION } 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; + // `need` is the validator's; the page's data is what it always was. + const { T: _T, need: _need, ...times } = beats; return { init, cues, beats: times, scale: beats.scale }; } @@ -192,14 +194,14 @@ 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); + const seconds = teaserSeconds(entry); 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 }); + const { init, cues, beats } = teaserCues({ lines, tail, seconds, motion: teaserMotion(entry.beat) }); // The ground: the palette's bg, lifted a touch toward the accent at the // centre and falling toward black at the edges. diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs @@ -8,9 +8,12 @@ import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; +import { createHash } from "node:crypto"; + import { - CARD_TYPES, deckChoreography, deckSchedule, deckText, hidesDeck, resolveDeck, TEASER_MOTION, teaserHits, - teaserLines, teaserTimes, teaserTitle, validateTeaser, validateTeasers, + CARD_TYPES, deckChoreography, deckSchedule, deckText, estimatedDuration, hidesDeck, resolveDeck, TEASER_BEAT, + TEASER_MOTION, teaserHits, teaserLines, teaserMotion, teaserSeconds, teaserTail, teaserTimes, teaserTitle, + validateTeaser, validateTeasers, } from "./deck.mjs"; import { teaserCues, teaserHtml } from "./chrome-teaser.mjs"; import { chapterTitle, teaserAudioGraph, teaserSegmentKey } from "./build-video.mjs"; @@ -40,6 +43,12 @@ test("a valid teaser has nothing to say; every bad shape is a sentence", () => { 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: "7" }), /seconds must be from 3 to 20, or absent/); + assert.match(bad({ beat: 0.3 }), /beat must be from 0\.4 to 2\.5 seconds/); + assert.match(bad({ beat: 2.6 }), /beat must be from 0\.4 to 2\.5/); + assert.match(bad({ beat: "slow" }), /beat must be/); + assert.deepEqual(validateTeaser({ ...FERRET, beat: 0.9 }), []); + assert.deepEqual(validateTeaser({ ...FERRET, seconds: undefined }), []); 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/); @@ -150,13 +159,92 @@ test("the cues: one per pop at the shared times, top to bottom, every from state 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("nothing is squeezed: a card too short for its beats is refused with the length they need", () => { + const FIVE = { ...FERRET, lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] }; + const t = teaserTimes(teaserLines(FIVE), "?"); + assert.equal(t.scale, 1); + // 0.55 + four beats + the second tier (0.3) + the tail (0.8 after, 1.7 in) + 1.2 still. + assert.equal(t.need, 7.35); + assert.match(validateTeaser({ ...FIVE, seconds: 3 }).join(" | "), + /seconds is 3, and at a beat of 0\.7s its lines need 7\.4s \(the last pop, the tail's fade and 1\.2s still for the end fade\) -- set it to 7\.4 or more, or leave it out for exactly that/); + assert.deepEqual(validateTeaser({ ...FIVE, seconds: 7.35 }), []); + // The ferret's 7 s holds its beats up to about 0.98 s; past that it is refused, not compressed. + assert.deepEqual(validateTeaser({ ...FERRET, beat: 0.9 }), []); + assert.match(validateTeaser({ ...FERRET, beat: 1.05 }).join(), /seconds is 7, and at a beat of 1\.05s its lines need 7\.2s/); + // Without `seconds`, a card needing more than a teaser may run says so. + const broken = { ...FERRET, seconds: undefined, beat: 2.5, lines: ["A b", "C d", "E f", "G h", "I j"].map((t) => ({ text: t, break: t.slice(-1) })) }; + assert.match(validateTeaser(broken).join(), + /needs 21\.7s at a beat of 2\.5s, and a teaser runs at most 20s -- a shorter beat, or fewer lines/); + assert.deepEqual(validateTeaser({ ...broken, beat: 2.2 }), []); + // The cues run to their own end, inside the card. + const { cues } = teaserCues({ lines: teaserLines(FIVE), tail: "?", seconds: 7.35 }); + assert.ok(cues.every((c) => c.at + c.dur <= 7.35 + 1e-6)); +}); + +test("the beat: the gap between pops, the second tier and the tail's wait in proportion", () => { + const m = TEASER_MOTION; + // No beat, or the default's own, is the motion itself. + assert.equal(teaserMotion(undefined), m); + assert.equal(teaserMotion(null), m); + assert.equal(teaserMotion(m.gap), m); + assert.deepEqual([TEASER_BEAT.min, TEASER_BEAT.max], [0.4, 2.5]); + const slow = teaserMotion(1.05); // +50 % + assert.deepEqual([slow.gap, slow.sub, slow.tailAfter], [1.05, 0.45, 1.2]); + // What is not the beat stays put. + for (const k of ["first", "hit", "settle", "tailDur", "endRoom", "slam", "under", "blur", "push", "grainHz"]) { + assert.equal(slow[k], m[k], k); + } + assert.ok(Object.isFrozen(slow)); + // The hits come from teaserTimes at the beat: each pop a beat after the one + // before (or after its second tier), the second tier 3/7 of a beat after its line. + const hitsAt = (beat) => teaserHits({ ...FERRET, beat, seconds: undefined }).map((h) => h.at); + assert.deepEqual(hitsAt(undefined), [0.75, 1.45, 1.55, 2.45, 3.05]); + assert.deepEqual(hitsAt(1.05), [0.75, 1.8, 2.05, 3.3, 4.3]); + const times = teaserTimes(teaserLines(FERRET), "?", slow); + assert.deepEqual(hitsAt(1.05), [ + times.lines[0].impact, times.lines[1].impact, times.lines[1].subAt, times.lines[2].impact, times.tailAt, + ]); + // The spacing between the pops grows with the beat, and only the beat. + const at = (beat) => teaserTimes(teaserLines(FERRET), "?", teaserMotion(beat)).lines.map((l) => l.at); + for (const beat of [0.4, 0.9, 1.3, 2.5]) { + const [a, b, c] = at(beat); + assert.equal(a, m.first); + assert.ok(Math.abs(b - a - beat) < 1e-4, `${beat}`); + assert.ok(Math.abs(c - b - beat * (1 + m.sub / m.gap)) < 1e-3, `${beat}`); + } +}); + +test("the length: `seconds` when set, else what the beats need, up to a tenth and at least 3 s", () => { + const free = { ...FERRET, seconds: undefined }; + assert.equal(teaserSeconds(FERRET), 7); + assert.equal(teaserSeconds({ ...FERRET, beat: 0.9 }), 7); + assert.equal(teaserTimes(teaserLines(free), "?").need, 5.95); + assert.equal(teaserSeconds(free), 6); + assert.equal(teaserSeconds({ ...free, beat: 0.9 }), 6.7); // needs 6.6643 + assert.equal(teaserSeconds({ ...free, beat: 1.05 }), 7.2); // needs exactly 7.2 + assert.equal(teaserSeconds({ ...free, beat: 1.3 }), 8.1); + // One line, no tail: 0.55 + the slam and settle + 1.2 still is 2.45 -- the floor is 3. + assert.equal(teaserSeconds({ type: "teaser", id: "x", lines: ["Solo"] }), 3); + // The schedule's estimate is the same length. + assert.equal(estimatedDuration(free), 6); + assert.equal(estimatedDuration(FERRET), 7); + // The page is that long, and its tail is in before the end fade's hold. + const html = teaserHtml({ ...free, beat: 1.3 }, RENDER); + assert.match(html, /data-duration="8\.1"/); + const t = teaserTimes(teaserLines(free), teaserTail(free), teaserMotion(1.3)); + assert.ok(t.tailAt + t.tailDur <= 8.1 - TEASER_MOTION.endRoom + 1e-9); +}); + +test("a teaser without a beat composes the page it did before beats existed, byte for byte", () => { + const sha = (s) => createHash("sha256").update(s).digest("hex"); + // The ferret teaser's page at 07d1fa08, before `beat`: its render key and frames are these. + const BEFORE = "a683c6414b5cff9816608829b71d8b3e09f8755e86e3b9b0e7300c132a413810"; + assert.equal(sha(teaserHtml(FERRET, RENDER)), BEFORE); + assert.equal(sha(teaserHtml({ ...FERRET, beat: TEASER_MOTION.gap }, RENDER)), BEFORE); + assert.notEqual(sha(teaserHtml({ ...FERRET, beat: 0.9 }, RENDER)), BEFORE); + // The page's data carries the times it always did, and nothing new. + const json = JSON.parse(teaserHtml(FERRET, RENDER).match(/<script id="teaser-data" type="application\/json">([\s\S]*?)<\/script>/)[1]); + assert.deepEqual(Object.keys(json.beats).sort(), ["end", "lines", "scale", "tailAt", "tailDur"]); }); test("the hits sit on the pops: the cue list's times, no second copy", () => { @@ -235,6 +323,14 @@ test("the cache key: the composed page changes with the words, the segment key w 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")); + // The beat is in the key; its default's own is the same page. Without + // `seconds` the render is as long as the beats need. + const slow = await compose({ ...FERRET, beat: 0.9 }); + assert.notEqual(slow.key, a.key); + assert.equal((await compose({ ...FERRET, beat: TEASER_MOTION.gap })).key, a.key); + const free = await compose({ ...FERRET, seconds: undefined, beat: 1.3 }); + assert.equal(free.frameCount, 243); + await assert.rejects(() => compose({ ...FERRET, beat: 1.3 }), /seconds is 7, and at a beat of 1\.3s its lines need 8\.1s/); await assert.rejects( () => composeChrome({ manifestPath: mp, region: "teaser", segment: "nope" }), /no teaser entry nope/, @@ -246,6 +342,10 @@ test("the cache key: the composed page changes with the words, the segment key w assert.notEqual(key(a.key, on), key(a.key, off)); assert.notEqual(key(a.key, on), key(b.key, on)); assert.equal(key(a.key, on), key(again.key, on)); + // The beat moves the hits, so the sound's graph -- and the segment's key -- with it. + const onSlow = teaserAudioGraph(teaserHits({ ...FERRET, beat: 0.9 }), { seconds: 7, render: RENDER }); + assert.notEqual(key(a.key, on), key(a.key, onSlow)); + assert.equal(teaserAudioGraph(teaserHits({ ...FERRET, beat: TEASER_MOTION.gap }), { seconds: 7, render: RENDER }), on); // So are the encode's parameters: a rebuild that re-encodes every clip // re-encodes the teaser too. assert.equal(key(a.key, on), key(a.key, on, { ...RENDER })); 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, validateTeaser, + chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256, teaserSeconds, validateTeaser, } from "./deck.mjs"; import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs"; import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs"; @@ -759,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 = teaser ? Number(teaser.seconds) : keyed ? sched.total : null; + const total = teaser ? teaserSeconds(teaser) : 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)); diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -402,6 +402,7 @@ export function estimatedDuration(entry, render = {}) { return Math.max(1, to - from); } if (entry.type === "image") return Number(entry.seconds ?? 4); + if (entry.type === "teaser") return teaserSeconds(entry); return Number(entry.seconds ?? 5); } @@ -987,7 +988,7 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds // 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, +// { "type": "teaser", "id": "fin", "seconds": 7, "beat": 0.7, // "lines": ["Pirate Software", // { "text": "The Largest Ferret Rescue in the United States", // "break": "in the United States" }, @@ -998,6 +999,10 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds // 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. +// `beat` (optional) is the seconds from one line's pop to the next +// (`teaserMotion`); `seconds` (optional) is the card's length, and without it +// the card is as long as its beats need (`teaserSeconds`). Nothing is ever +// squeezed to fit: a `seconds` too short for the beats is refused. // 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 @@ -1068,25 +1073,53 @@ export function teaserTitle(entry) { * -- 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`). + * cut's end fade. `gap` is the default beat; an entry's `beat` replaces it + * (`teaserMotion`). */ 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, }); +/** The beats an entry's `beat` may be: from 0.4 s (packed) to 2.5 s (a pause between each). */ +export const TEASER_BEAT = Object.freeze({ min: 0.4, max: 2.5 }); + +/** + * The motion at an entry's beat: `gap` is the beat, and the two waits that + * read as part of it scale with it in the default's proportion -- the second + * tier's `sub` stays 3/7 of the beat (0.3 s of 0.7) and the tail's + * `tailAfter` 8/7 (0.8 s of 0.7), so a slower beat is the same rhythm slowed, + * not three faster pops with longer pauses between. What is NOT the beat stays + * put: the first line's landing (`first`, timed to the incoming dissolve), the + * slam's `hit` and `settle`, the tail's fade (`tailDur`, the swell under it) + * and the end fade's room. No beat, or the default's own, is TEASER_MOTION + * itself -- an entry without `beat` composes the page it always did. + */ +export function teaserMotion(beat) { + const m = TEASER_MOTION; + if (beat === undefined || beat === null || Number(beat) === m.gap) return m; + const k = Number(beat) / m.gap; + const r = (v) => Math.round(v * 10000) / 10000; + return Object.freeze({ ...m, gap: r(Number(beat)), sub: r(m.sub * k), tailAfter: r(m.tailAfter * k) }); +} + /** * 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. + * without one); the tail's start and length; `end`, when the last thing has + * arrived; and `need`, the card's least length -- `end` plus the end fade's + * still room. Nothing is compressed: a card shorter than `need` is refused by + * `validateTeaser`, never squeezed. `scale` is always 1 and `T` only rounds; + * both stay because the page carries `scale` in its data and the cues are + * written through `T` -- an unchanged teaser's page, and so its render key, + * are unchanged. * * @returns {{ lines: Array<{ at: number, impact: number, subAt: number|null }>, - * tailAt: number|null, tailDur: number, end: number, scale: number, T: (v: number) => number }} + * tailAt: number|null, tailDur: number, end: number, need: number, scale: number, + * T: (v: number) => number }} */ -export function teaserTimes(lines, tail, seconds, m = TEASER_MOTION) { +export function teaserTimes(lines, tail, m = TEASER_MOTION) { let t = m.first; const raw = []; lines.forEach((l, i) => { @@ -1098,21 +1131,32 @@ export function teaserTimes(lines, tail, seconds, m = TEASER_MOTION) { }); 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); + const T = r; 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), + need: r(endRaw + m.endRoom), + scale: 1, T, }; } /** + * A teaser's length in seconds: its `seconds` when it sets one, else what its + * beats need (`teaserTimes(...).need`: the last pop, the tail's fade, the end + * fade's still room), rounded UP to a tenth of a second and at least the + * shortest a teaser may be. Assumes `validateTeaser` passed. + */ +export function teaserSeconds(entry) { + if (entry?.seconds !== undefined && entry?.seconds !== null) return Number(entry.seconds); + const need = teaserTimes(teaserLines(entry), teaserTail(entry), teaserMotion(entry?.beat)).need; + return Math.max(TEASER_LIMITS.seconds[0], Math.ceil(need * 10 - 1e-6) / 10); +} + +/** * 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`. @@ -1128,7 +1172,7 @@ 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 times = teaserTimes(lines, tail, teaserMotion(entry.beat)); const out = []; const HIT = { title: { gain: 1, decay: 0.42, f0: 92, f1: 40 }, @@ -1158,7 +1202,12 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" 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 hasSeconds = entry?.seconds !== undefined && entry?.seconds !== null; + if (hasSeconds && !numIn(entry.seconds, slo, shi)) errors.push(`${where}.seconds must be from ${slo} to ${shi}, or absent`); + const hasBeat = entry?.beat !== undefined && entry?.beat !== null; + if (hasBeat && !numIn(entry.beat, TEASER_BEAT.min, TEASER_BEAT.max)) { + errors.push(`${where}.beat must be from ${TEASER_BEAT.min} to ${TEASER_BEAT.max} seconds, or absent`); + } 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; } @@ -1218,6 +1267,24 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" } }); } + // The length the beats need, once the lines, the tail and the beat are + // sound: never squeezed, so a `seconds` short of it is refused with it, and + // a card that needs more than a teaser may run says so. + if (!errors.length) { + const m = teaserMotion(entry.beat); + const need = teaserTimes(teaserLines(entry), teaserTail(entry), m).need; + const least = teaserSeconds({ ...entry, seconds: undefined }); + const at = `at a beat of ${m.gap}s`; + if (hasSeconds && entry.seconds < need - 1e-9) { + errors.push( + `${where}.seconds is ${entry.seconds}, and ${at} its lines need ${least}s ` + + `(the last pop, the tail's fade and ${TEASER_MOTION.endRoom}s still for the end fade) -- ` + + `set it to ${least} or more, or leave it out for exactly that`, + ); + } else if (!hasSeconds && least > shi) { + errors.push(`${where} needs ${least}s ${at}, and a teaser runs at most ${shi}s -- a shorter beat, or fewer lines`); + } + } return errors; } diff --git a/umtool/report-to-video/teaser-audio.test.mjs b/umtool/report-to-video/teaser-audio.test.mjs @@ -14,7 +14,7 @@ import { spawnSync } from "node:child_process"; import test from "node:test"; import { teaserAudioGraph } from "./build-video.mjs"; -import { teaserHits } from "./deck.mjs"; +import { teaserHits, teaserSeconds } from "./deck.mjs"; const have = spawnSync("ffmpeg", ["-version"]).status === 0; const RENDER = { fps: 30, audioRate: 48000, audioChannels: 2 }; @@ -60,12 +60,29 @@ test("nothing clips: the sum stays under −6 dBFS (about) and well under full s 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; + // The tightest beat packs five lines' hits together; they still sum cleanly. + const packed = { ...FERRET, seconds: undefined, beat: 0.4, lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] }; + const seconds = teaserSeconds(packed); + const s = samples(teaserAudioGraph(teaserHits(packed), { seconds, render: RENDER })).all; + assert.equal(s.length, Math.round(seconds * 48000) * 2); 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}`); + assert.ok(p2 > 0.2 && p2 <= 0.5 * 1.03, `packed card peak ${p2}`); +}); + +test("a wider beat moves every hit's onset with it", { skip: !have && "no ffmpeg" }, () => { + const slow = { ...FERRET, beat: 1.3, seconds: undefined }; + const seconds = teaserSeconds(slow); + const hits = teaserHits(slow); + const full = samples(teaserAudioGraph(hits, { seconds, render: RENDER })).ch0; + const frame = 1 / RENDER.fps; + hits.forEach((h, i) => { + if (h.kind !== "hit") return; + const without = samples(teaserAudioGraph(hits.filter((_, j) => j !== i), { seconds, render: RENDER })).ch0; + const first = full.findIndex((v, n) => Math.abs(v - without[n]) > 1e-4); + assert.ok(first >= 0, `${h.role} made no sound`); + assert.ok(Math.abs(first / 48000 - h.at) <= frame, `${h.role}: onset ${(first / 48000).toFixed(4)}s, pop ${h.at}s`); + }); }); test("hits: false is digital silence, exactly as long", { skip: !have && "no ffmpeg" }, () => { diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs @@ -19,7 +19,7 @@ import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { postsRegions, selectVariant, variantPaths } from "./build-video.mjs"; -import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck } from "./deck.mjs"; +import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck, teaserSeconds } from "./deck.mjs"; const execFileP = promisify(execFile); const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe"; @@ -166,11 +166,12 @@ export async function verifyTeasers(variantDir, manifest, problems) { 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 seconds = teaserSeconds(e); + const want = frameCount(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 (frames !== want) problems.push(`${dir} holds ${frames} frames; the teaser ${e.id} is ${want} (${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 });