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:
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 });