Archilyzer · Source

archilyzer

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

commit 43a72390afbe551e94516e13a462ce52f0ac3f07
parent 0ca2039e4d1828b1418ac82979223ddefce8db9e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 20:13:29 -0400

Merge branch 'deck/teaser-v1' into deck/finale-dip

# Conflicts:
#	editor/CHANGELOG.md
#	plans/deck-posts.md
#	umtool/report-to-video/build-video.mjs
#	umtool/report-to-video/compose-chrome.mjs

Diffstat:
Meditor/CHANGELOG.md | 2+-
Mplans/deck-posts.md | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/README.md | 29+++++++++++++++++++++++------
Mumtool/report-to-video/build-video.mjs | 6+++---
Mumtool/report-to-video/chrome-teaser.mjs | 14++++++++------
Mumtool/report-to-video/chrome-teaser.test.mjs | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
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++++---
10 files changed, 304 insertions(+), 49 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -7,7 +7,7 @@ - **A report cut's posts can be a feed: a column beside the footage for the whole cut, each post ticking in as its clip starts, with no pause.** `render.chrome.deck.posts.layout: "feed"` (the default, `"popup"`, is the cards described above) puts every post in one column on the right, standing on the deck so the two read as one L-shaped panel around the picture. The footage of every clip and still is framed, for the whole cut, into the box left beside the column (1272×716 at 1920×1080 with the default 600 px column, against 1574×886 under the deck alone); nothing moves and nothing is held, so the cut is as long as its clips. Before the first post the column shows its header — the platform and the handle, or "Posts" when there are several authors — and an empty state. Each post ticks in at the start of the clip it belongs with, just after the crossfade into it, a clip's next ones `posts.seconds` apart (closer on a short clip): it lands at the top with an accent flare and keeps a lit rail while it is the newest, and the posts already up slide down to make room; when the column is full the oldest fade out at the bottom. Each card shows the post's date, its words up to `maxLines`, and its QR. Over a card or the teaser the column slides out of the frame with the deck and comes back after. The column is drawn by one composition for the whole cut, cached like the deck's. Switching the layout reframes every segment, so it takes a normal build (with `--skip-fetch` it re-cuts from the cached windows); `--chrome-only` over segments framed for the other layout is refused with a sentence naming them, because each segment's `<id>.cut.json` now records the box it was framed into. In umtool, the posts settings have a **layout** switch, and the live preview shows the column for the whole scrub with the footage in its box. A cut in the popup layout, or without posts, builds exactly as before. - **A report clip can go silent partway through, a report cut can fade out at its end, and the deck's QR names its site in larger type.** A clip's `muteFrom` (in the recording's own seconds, inside the clip) silences it from that second to its end while the picture plays on, after a 40 ms fade that ends there, so nothing clicks and no next word leaks in; a hold on that clip stays silent. `render.endFade` (seconds; 0, the default, is off) fades the cut's last segment, whatever it is — a clip with its hold, a closing card or a teaser — to the background colour and to silence over its final seconds, all of it when the segment is shorter, and the deck stays drawn over it. Both are applied where the cut is joined, so `--chrome-only` changes them without rebuilding a clip, and a value out of range is refused with a sentence before a build fetches anything. Each clip build now writes `<id>.cut.json` beside its segment, saying where in the recording the segment really starts after its cut was snapped to a silence; `muteFrom` is measured from it, and a segment built before this measures from the clip's unsnapped start and says so. The site's name beside the deck's QR is now exactly as long as the code is tall, for any site. Neither key changes a cut that does not set it. - **umtool's clip bench stops exactly where a range ends, and sets a clip's mute mark.** The bench's **play selection**, the edge auditions, the auto-audition and a click on a transcript line now play the window's sound through the browser's Web Audio, from a decode made on the server by ffmpeg — the same timeline the build cuts on — and each stops on the audio clock where its range ends, at every speed. They used to play on the video element and were stopped when it next reported its time, which overran the end by up to a quarter of a second, by a different amount each time. The picture follows, muted. If the sound cannot be decoded, the video element plays as before and the bench says the playback is approximate and why. The mute mark sets the clip's `muteFrom`: `m` puts it at the playhead, **pick on waveform** puts it where you click, `;` and `'` nudge it (with shift, by half a second), and `M` or **clear mute** removes it. It is saved with the window like the edges, every playback goes silent at it with the build's own 40 ms fade, and a window save that would leave it outside the clip is refused unless the same save moves or clears it — or, when it is within 0.02 s of the new edge, moves it onto that edge. The decoded sound is served by a new `GET /api/report/audio`, at most 120 seconds of a cached window at a time, as WAV. -- **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in after the last line on its own. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. A line too long to fit the frame at its smallest size is refused with a sentence saying how many characters fit (a title holds 34). It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest. +- **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in after the last line on its own. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. `"beat"` (0.4–2.5 seconds, default 0.7) sets the time from one pop — and its hit — to the next, the second tier and the tail's wait slowing with it; `seconds` may be left out for exactly the length the beats need, and a `seconds` too short for them is refused with that length rather than played faster. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. A line too long to fit the frame at its smallest size is refused with a sentence saying how many characters fit (a title holds 34). It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest. - **A report cut with `transition: 0` builds when its output folder was given as a relative path.** The hard-cut concat listed its segments relative to the working directory, and ffmpeg reads that list relative to the list file's own folder, so every hard-cut build with a relative `--out` failed at the concat. The list now names each segment by its full path. - **`archilyzer doctor` checks the image Build all builds sites in.** When a container engine answers, a new **build image** section says whether the image named under **Settings → Build pipeline** is there, when it was built and how big it is. It warns when the image is missing, or older than the last change to its Dockerfile, and prints the one command that rebuilds it. Build all still builds or refreshes the image itself before it builds any site; the warning tells you ahead of time that the next Build all will spend that time. With no container engine the check is skipped in one line, and with no corpus it is only a note. It never fails the doctor. - **The site build image runs Node 22 and pnpm 11**, the versions the rest of the workspace runs on, instead of Node 20 and pnpm 9, which did not read the workspace's install rules. The next Build all rebuilds the image from its first step, reinstalling every dependency, before it builds any site. diff --git a/plans/deck-posts.md b/plans/deck-posts.md @@ -490,3 +490,54 @@ Found and left: does not show the segments reframed (that is the build's), so a backdrop built for the deck is carried, scaled, into the feed's box until a build reframes it. + +## Teaser V1, as built + +Branch `deck/teaser-v1` from 07d1fa08. The ask: a little more delay between the teaser's hits, and +variation clips to judge before the full stitch. + +| Commit | What | +|---|---| +| 6869bbc7 | `deck.mjs`: `TEASER_BEAT` (0.4–2.5 s), `teaserMotion(beat)`, `teaserSeconds(entry)`; `teaserTimes(lines, tail, m)` no longer compresses and reports `need`; `validateTeaser` checks `beat` and refuses a `seconds` short of `need`; `estimatedDuration` of a teaser is `teaserSeconds`. chrome-teaser, compose-chrome, verify-build and `buildTeaserSegment` (now exported) read `teaserSeconds` / `teaserMotion`. Tests in `chrome-teaser.test.mjs` and `teaser-audio.test.mjs` | +| 8a0b1b7e | README (`beat`, `seconds` optional) and the teaser's `[Unreleased]` bullet amended in place | + +Rulings, as built: + +- **`beat`** is the seconds from one line's pop to the next, hits included; default + `TEASER_MOTION.gap` (0.7). The second tier's delay stays 3/7 of the beat and the tail's wait + 8/7 (the default's 0.3 and 0.8 of 0.7), so a slower beat is the same rhythm slowed. The first + landing (timed to the dissolve), the slam's hit and settle, the tail's 1.7 s fade (and its + swell) and the 1.2 s end room do not scale. The hits are still `teaserHits` from `teaserTimes`. +- **Nothing is squeezed.** A `seconds` shorter than `need` (the last pop, the tail's fade, the + 1.2 s hold) is refused with the length needed, rounded up to a tenth. Without `seconds` the + card is that length, at least 3 s; one that would need more than 20 s is refused. +- `teaserTimes` keeps `scale` (always 1) and `T` (now only rounding): the page carries `scale` in + its data, and `need` is stripped from it, so a teaser without `beat` — or with `beat: 0.7` — + composes a byte-identical page. A test pins the ferret page's sha256 at 07d1fa08. + +Gates on 8a0b1b7e: + +- Workspace tsc clean (21 s). `test:scripts` 370 pass, 2 skipped, 2 failed: the queue-lock FIFO + and banner cases under load; `queue-lock.test.mjs` alone 11/11, three times. +- Capped umtool `next build` with the corpus linked: exit 0 (58 s), link removed. +- Byte-identical without `beat`: the ferret teaser's frames key is `b8ebcaf5…` at 07d1fa08, at the + tip and with `beat: 0.7`; the segment key `7c87943c…` the same at both. A fresh render at the tip + wrote 210 frames whose PNGs are byte-identical to the ones the cut was built from, and its + `fin.mp4` is the same file as the cut's (same sha256, same decoded video and audio md5). + +The variants (scratch build through `buildTeaserSegment`, `cutJoins`, `cutOffsets`, `xfadeGraph`; +the deck's own frames laid over c20; trimmed to c20's last 3 s; the encode's `encodeArgs`): +the reference is the manifest's entry, and the others keep its still hold after the tail +(about 1.05 s past `need`), so their `seconds` grows. The manifest's `seconds: 7` holds beats up to +about 0.99; at 1.05 and 1.3 it is refused with 7.2 and 8.1. + +| Beat | `seconds` | `need` | Clip | Hits in the teaser's clock (overline, title, second tier, kicker; swell) | +|---|---|---|---|---| +| 0.7 (none set) | 7 | 5.95 | 9.5 s | 0.75, 1.45, 1.55, 2.45; 3.05 | +| 0.9 (+29 %) | 7.7 | 6.66 | 10.2 s | 0.75, 1.65, 1.84, 2.94; 3.76 | +| 1.05 (+50 %) | 8.3 | 7.2 | 10.8 s | 0.75, 1.80, 2.05, 3.30; 4.30 | +| 1.3 (+86 %) | 9.1 | 8.09 | 11.6 s | 0.75, 2.05, 2.41, 3.91; 5.19 | + +Each clip's video and audio are the same length; the measured level onsets (an 8 dB rise per +50 ms window) land on the overline, title and kicker hits at every beat — the second tier's +lighter hit falls inside the title's decay, as designed. diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -439,12 +439,14 @@ falls on the teaser. "break": "in the United States" }, "February 2027"], "tail": "?", // optional: appended to the LAST line, fades in on its own + "beat": 0.7, // optional, default 0.7: seconds from one pop to the next "hits": true } // optional, default true: false makes the card silent ``` - **`lines`** — 1 to 5, each one line of at most 80 characters: a string, or `{ text, break }`, where `break` is the END of `text` drawn as a smaller, - wide-tracked second tier under the rest that pops a beat (0.3 s) after it. + wide-tracked second tier under the rest that pops 3/7 of a beat after it + (0.3 s at the default `beat`). The words are data: they are drawn uppercase, and kept as written everywhere else. **Roles follow position:** with three or more lines the first is a small wide-tracked overline between two accent rules, the last a mid-size @@ -459,10 +461,25 @@ falls on the teaser. as a `break`. The counts were measured in the face on ordinary words in capitals (a title holds 36–37 there); a row of only wide capitals (M, W) can still spill. -- **`seconds`** — 3 to 20. The pops land 0.55 s in (after the incoming - dissolve) and 0.7 s apart; the tail starts 0.8 s after the last and fades in - over 1.7 s; the last 1.2 s are a still hold for the end fade. A card too - short for all of it plays every beat proportionally faster. +- **`beat`** — 0.4 to 2.5 seconds, default 0.7 (`TEASER_MOTION.gap`): the + time from one line's pop to the next, and so from one hit to the next. The + pops land 0.55 s in (after the incoming dissolve) and a beat apart, counted + from the line before or from its second tier. Two waits scale with the beat + in the default's proportion, so a slower beat is the same rhythm slowed: a + second tier pops 3/7 of a beat after its line (0.3 s at 0.7) and the tail + starts 8/7 of a beat after the last pop (0.8 s). What is not the beat stays + put: the first landing, each slam (0.2 s to its hit, 0.5 s to settle), the + tail's 1.7 s fade and the swell under it, and the last 1.2 s, a still hold + for the end fade. A teaser without `beat`, or with 0.7, is the page it + always was — the same render key and the same frames. +- **`seconds`** — optional, 3 to 20. **Nothing is squeezed to fit**: a + `seconds` shorter than the beats need (the last pop, the tail's fade and the + 1.2 s hold) is refused with the length they need. Left out, the card is + exactly that long, rounded up to a tenth of a second and at least 3 s; a + card that would need more than 20 s is refused (a shorter beat, or fewer + lines). The ferret card needs 5.95 s at 0.7, 6.7 at 0.9, 7.2 at 1.05 and + 8.1 at 1.3 — its `"seconds": 7` (a little over a second more still at the + end) holds beats up to about 0.99. - **`tail`** — at most 8 characters, in the accent, set a little apart from the last line. - **The chapter** is the lines joined with " — ", the tail after the last @@ -510,7 +527,7 @@ encoded from the frames on disk. **umtool** shows a teaser as a card row named by its lines (the report page, the On-screen table, the timeline strip). Editing its lines there is not -built: it needs a writer (`updateTeaser(dir, id, {lines, tail, seconds, hits}, +built: it needs a writer (`updateTeaser(dir, id, {lines, tail, seconds, beat, hits}, {token})` through `withManifestLock` and `validateTeaser`), a route, a small form (one field per line with a break picker), and a preview — a still of the composition at a chosen second through compose-chrome's `--still`, which the 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, feedGeometry, feedOn, frameCount, hidesDeck, MUTE_FADE, - muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserTitle, + muteSegmentSeconds, 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, @@ -1192,7 +1192,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({ @@ -1203,7 +1203,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/); @@ -116,7 +125,7 @@ test("the cues: one per pop at the shared times, top to bottom, every from state 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. + // The times are the motion's own: nothing is ever compressed. 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. @@ -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.99 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"; @@ -778,7 +778,7 @@ export async function composeChrome({ if (region === "feed" && sched.layout !== "feed") { throw new Error("the feed region needs a feed's schedule (layout \"feed\": posts.layout \"feed\" and posts to draw)"); } - 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" || region === "feed") && diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -408,6 +408,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); } @@ -1099,7 +1100,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" }, @@ -1110,6 +1111,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 @@ -1180,25 +1185,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) => { @@ -1210,21 +1243,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`. @@ -1240,7 +1284,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 }, @@ -1270,7 +1314,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; } @@ -1330,6 +1379,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"; @@ -180,11 +180,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 });