Archilyzer · Source

archilyzer

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

commit 0ac038738d4d921dd325c267e7f282d9b3680739
parent b8a6acb2cc043c6bfa72ebf43edfffdf49aecd67
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 16:46:53 -0400

Merge main (a210dace, the deck/posts-room branch) into r17/umtool-media-root

One conflict, umtool/lib/report/export.mjs's imports: both kept
(teaserTitle from main, outDirState from this branch).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 8++++++--
Mplans/deck-posts.md | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/audio/route.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/chrome/preview/route.ts | 7+++++++
Mumtool/app/api/report/clip/route.ts | 1+
Mumtool/app/api/report/window/route.ts | 4++++
Mumtool/components/projects/ClipBench.tsx | 793++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/components/projects/ClipBenchPage.tsx | 5+++++
Mumtool/components/projects/OnscreenSection.tsx | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mumtool/docs/quirks.md | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/clip-bench.spec.ts | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mumtool/e2e/fixtures/make-fixture.mjs | 8+++++++-
Mumtool/e2e/onscreen-posts.spec.ts | 215++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/e2e/onscreen.spec.ts | 5+++++
Mumtool/lib/projects/report.mjs | 4+++-
Mumtool/lib/report/export.mjs | 22++++++++++++++++++++--
Aumtool/lib/report/footage-move.mjs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/footage-move.test.mjs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/manifest.mjs | 29+++++++++++++++++++++++++++++
Mumtool/lib/report/manifest.test.mjs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/onscreen.mjs | 48++++++++++++++++++++++++++++++++++++------------
Mumtool/lib/report/onscreen.test.mjs | 51++++++++++++++++++++++++++++++++++++++++++++++++++-
Aumtool/lib/report/playback.mjs | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/playback.test.mjs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/README.md | 234++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Aumtool/report-to-video/av-sync.test.mjs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/build-video.mjs | 679+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mumtool/report-to-video/chrome-deck.mjs | 41++++++++++++++++++++++++++++++++++++++---
Mumtool/report-to-video/chrome-deck.test.mjs | 37+++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/chrome-posts.mjs | 103++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mumtool/report-to-video/chrome-posts.test.mjs | 17+++++++++++++++--
Aumtool/report-to-video/chrome-teaser.mjs | 397+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/chrome-teaser.test.mjs | 258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 50++++++++++++++++++++++++++++++++++++++------------
Aumtool/report-to-video/cut-edits.test.mjs | 400+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/deck-overlay.test.mjs | 4+++-
Aumtool/report-to-video/deck-room.test.mjs | 469+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/deck.mjs | 479++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mumtool/report-to-video/deck.test.mjs | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Aumtool/report-to-video/mute.mjs | 7+++++++
Mumtool/report-to-video/package.json | 1+
Aumtool/report-to-video/teaser-audio.test.mjs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/verify-build.mjs | 131++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
43 files changed, 6029 insertions(+), 277 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,8 +1,12 @@ # Changelog ## [Unreleased] -- **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. A manifest without `render.chrome` builds exactly as before, byte for byte. -- **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear two seconds apart, stack down a column at the footage's top right, and leave together in the change to the next clip; when the column is full the oldest slide up and out. Each card shows the post's date, `@handle · Bluesky` (or X), its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The timing, the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither. A cut without `posts` builds exactly as before, and without the deck `posts` is not drawn at all. +- **umtool's report videos keep every clip's sound on its picture.** In a crossfaded cut each clip's audio was placed by the audio's own length and its picture by the picture's, and an encoded clip's audio is routinely a few to twenty milliseconds shorter or longer than its video, so the sound drifted further ahead clip by clip: by the end of a seventeen-clip cut it was a third of a second early, and two seconds on one with title and sources cards. Each clip's sound is now padded or trimmed to exactly its picture's length before the crossfade. Every crossfaded report video changes when it is rebuilt, and is in sync; a hard-cut video was not affected. +- **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. The deck changes nothing, byte for byte, in a cut whose manifest has no `render.chrome`. +- **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear four seconds apart and stack down a column at the frame's top right; as the first appears, the footage eases aside (to 86 % of its box, at the far side) to make room, and the clip's last frame is held, in silence, for 2.5 seconds so the last post can be read; then they all leave together in the change to the next clip, which comes in at the normal size. When the column is full the oldest slide up and out. Each card slides in from the edge of the frame and flares in the deck's accent as it lands; it has an accent rail down its edge and shows the post's date, a platform label ("Bluesky" or "X") beside `@handle`, its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The hold and the move are made where the cut is joined, not in a clip, so `--chrome-only` changes them without rebuilding one; chapters and the deck's timing count the hold. The timing, the hold (`hold`, 0 turns it off), the move (`shift`: its scale and seconds, or `false`), the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither — though it still holds and moves the footage, which are part of the cut rather than the chrome. A first post that appears inside the hold still moves the footage, and a hold is a whole number of frames. `posts` changes nothing in a cut that has none, and without the deck it is not drawn at all. +- **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 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 @@ -125,3 +125,278 @@ Branch `deck/posts` from `main` 2cf43a69; slices merged `--no-ff` after review. Umtool-only, like the deck: a umtool rebuild and restart. Nothing under `export/`, `homepage/`, `common/` or editor code. + +## Room for posts (second pass) + +Rulings, on top of the above: + +- **Longer.** `posts.seconds` defaults to 4 (was 2). +- **Hold.** A clip that carries posts is held on its last frame, in silence, for `posts.hold` (default + 2.5 s) before its outgoing transition, so the last post can be read. The hold is part of the + segment's length in the CUT: `deckSchedule` adds it to the carrying segments (`segments[i].hold`, + present only when > 0) and every start, the total and the posts' timing are measured with it. + The segment FILES are unchanged; the hold is applied where the cut is joined (`tpad` clone + + `apad`), so `--chrome-only` changes it without rebuilding a clip. +- **Make room.** With `posts.shift` (default `{ scale: 0.86, seconds: 0.6 }`; `false` turns it + off), the footage eases from its box to `shiftedFootage(render)` — scaled, its far edge `inset` + from the frame edge away from the column, centred above the deck — as a clip's first post + appears, and stays there to the end of the segment; the next segment comes in at the normal box + through the transition. The posts column then sits at the FRAME's edge (`postsGeometry`). At + 1920×1080 the footage goes 1574×886 at (173,2) → 1354×762 at (24,64); the column is 600 wide at + x 1296, overlapping the moved footage by 82 px instead of 600. +- **More noticeable.** The cards themselves read as a highlighted interruption, not a caption. + +Core additions (`deck.mjs`): `shiftedFootage`, `postHolds`, `footageMoves` (the schedule's `moves`: +`[{segment, at, segmentAt, seconds, from, to}]`, present only when there are moves), `posts.hold` +and `posts.shift` settings and validation; `resolveDeck` fills `posts.shift` from its default. + +### R1, as built + +Branch `deck/room-r1` from 69cb37d7. + +| Commit | What | +|---|---| +| 8d86edc6 | The hold and the move joined on the carrying clip's input (`segmentJoins`, `joinInputChain`, `moveFilter`, `xfadeGraph`, `hardCutFilterArgs`, `cutOffsets`); every length under the deck is the schedule's; the hard-cut record names its joins; verify-build's freeze check; `deck-room.test.mjs` | +| f5067b04 | The cards: slide in from past the frame's edge, an accent flare that settles, an accent rail, a platform pill; text 24 px | +| 4fb19848 | README, quirks, the `[Unreleased]` entry | + +Where it differs from, or adds to, the rulings above: + +- **The move is one `perspective` filter** (`sense=destination`, `eval=frame`): the input frame's + corners are eased by smoothstep so the footage box goes from `from` to `to`. It resamples at + 1/256 px; `scale` + `overlay` and `zoompan` round to whole pixels. Before the move it is the + identity, copied bit for bit. It has no `t` and `in` counts from 1, so the clock is `(in-1)/fps`. + It fills the uncovered band by clamping to the input's edge, so `fillborders` pins the outer + 2 px to `palette.bg` first. Measured on c06: the box glides 172→22 px over 18 frames with no + stepping. +- **A hold shows `hold − transition` of still frame** under a crossfade, because the dissolve + out starts inside it (2.0 s at the defaults). +- **Text is 24 px, not more.** At 25 px the ferret cut's c06 stack (3 posts) measured 918 px + against an 838 px column, and the oldest would have slid out before the hold. 24 px with + tighter padding measures 829. +- **verify-build's freeze check** compares luma outside the deck and the posts column, because + the deck's progress fuse keeps moving during a hold. It allows a mean difference of 1.5; + ferret measured 0.01–1.02. + +Gates on 4fb19848: + +- Workspace tsc clean. `test:scripts` 318 pass, 2 skipped. Capped umtool `next build` with the + corpus linked exit 0 (58 s, under load from a concurrent encode), link removed, and the + server bundle carries `const postsCues = (` (2 files). +- Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5 + `6a92235fa12ca181bb81993129c9ee9d`. A deck manifest without posts gives a `schedule.json` + byte-equal to the base's, and the base and the branch give the same overlay chain, + `applyChromeArgs` and `previewFromSegmentsArgs` (`segmentJoins` returns null). +- Ferret, scratch copy, `--chrome-only` (crossfade): + - 360.2 s, holds on c03, c06, c12 and c17; + - posts and moves at the predicted seconds; + - verify-build ok, with all four freezes found; + - 17 chapters at the schedule's starts; + - QR 7/7 posts and 17/17 deck. +- Transition-0 copy through the concat-filter prerail: 368.2 s, verify-build ok, QR 7/7 posts. + The `.segments` record names each join. +- `--chrome-preview 126 14` gives 14.000 s (420 frames) from the segments: the dissolve in, + the move, the stack and the hold. + +Left for R2 (umtool): + +- `umtool/lib/report/export.mjs`'s fallback, used when there is no `chapters.ffmeta`, still sums + `segmentOffsets` without the holds. A build always writes the ffmeta, so it is reached only + for a cut that was never fully built. +- The preview's posts geometry is now the frame's edge (`postsGeometry`). The preview does not + show the footage move or the hold; both happen in ffmpeg, at the join. + +Both items above were done after R1: R2 (b584c129) shows the move and the hold in the preview, and +df062b37 reads a deck cut's offsets from its `schedule.json` when there is no `chapters.ffmeta`. + +## Finale B2, as built + +Branch `deck/finale-b2` from df062b37. It starts with the fixes from the read-only review of the +room work (SHIP AFTER FIXES). + +| Commit | What | +|---|---| +| 2f0e852b | Review fixes. The footage move now runs AFTER the hold, so a first post inside the hold still moves the frozen frame (before, it never moved or froze part-way). verify-build's freeze check samples only between the hold's start (or the move's landing) and the dissolve, and reports under three frames of still picture as not checked. `postHolds` rounds a hold to whole frames. The README and changelog say `--no-chrome` still holds and moves. | +| 2ea53246 | A clip's `muteFrom` and `render.endFade`, joined on that input's chain; the `<id>.cut.json` record beside each clip segment; validation; the QR host label fitted to the code's height | +| ebe10c84 | README, quirks, one `[Unreleased]` bullet | + +Where it adds to the rulings above: + +- **`muteFrom`** is in SOURCE seconds and must lie within the clip's `start`–`end`. The sound is + silent from that second, after a 40 ms `afade` that ENDS there, and it is digital zeros after + that. A hold on the clip stays silent. The mute is mapped to the segment's clock through + `<id>.cut.json`, which every clip build now writes: the source seconds the segment was really + cut from after snapping. A segment with no record, or one whose record does not match its + length, falls back to the unsnapped `playWindow` start, and the build says so in a note. +- **`render.endFade`** applies to the cut's last segment, whatever it is, hold included. The + picture reaches `palette.bg` and the sound reaches silence on the last frame. The picture fade + is a `geq` blend in yuv420p, enabled from its first frame. `fade=…:color=` would work too, but + only in RGB, and the concat filter would then convert every segment of the cut to rgb24 and back. +- Both are joins (`withCutEdits`, `cutJoins`), so they apply with or without the deck, on + crossfades and hard cuts, and `--chrome-only` changes them without rebuilding a segment. The + hard-cut record names them only when present. `validateCutEdits` (in `deck.mjs`) is what the + build refuses with, and `validateChrome` also checks `endFade`. +- **The QR's host label** is sized at load from the string's ink in the loaded face (canvas + `measureText`). Tracking counts between letters only, and the first side bearing is indented + away. On the ferret cut its ink covers rows 31–180, the same rows as the code. Before, it + covered 54–180. + +Gates on ebe10c84: + +- Workspace tsc clean. `test:scripts` 341 pass, 2 skipped. Capped umtool `next build` with the + corpus linked exit 0 (31 s), link removed. +- Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5 + `6a92235fa12ca181bb81993129c9ee9d`. The unchanged ferret deck manifest writes a `schedule.json` + byte-equal at df062b37 and at the tip. With no muteFrom or endFade, the crossfade graph, the + hard-cut graph and the record equal 2f0e852b's. Against df062b37 the only difference is the + move and hold order on the four carrying clips. +- Ferret, scratch copy with c20 `muteFrom: 24029.30` and `endFade: 1.0`, `--chrome-only` over + copied segments with no cut records: + - c20 muted 6.70 s into its segment, falling back to the unsnapped start, which the run notes; + - 360.2 s, verify-build ok, all four freezes found; + - QR 17/17 deck and 7/7 posts; + - the last sample that is not zero is at 359.613 s of the audio, and everything after it is + digital silence; + - the last frame above the deck is bg (mean 1.0, worst 3 levels over rows 0–881). + +Found and left: + +- **The crossfade concat drifted the sound ahead of the picture — fixed in 6771cfb8.** Encoded + segments' audio is routinely a few to ~20 ms off their video, and `acrossfade` joined the sound + by its own lengths while `xfade` used the picture's: the ferret cut's sound ran 0.3 s early by + the last clip (2.0 s on revision 2, with cards). `xfadeGraph` now pins each input's sound to its + length in the cut (`apad=whole_dur`, `atrim=end`) before the join, so a `muteFrom` lands on its + picture too. Every crossfaded cut's graph changed; `av-sync.test.mjs` holds it with real ffmpeg. + +## Bench B1, as built + +Branch `deck/bench-b1`, merged as f619d659. umtool's clip bench plays every bounded range exactly, +and sets the clip's `muteFrom` that B2 builds. + +| Commit | What | +|---|---| +| 5d7926f4 | `updateClip` takes `muteFrom` (source seconds inside the clip after the patch, rounded like an edge; null or empty deletes it), and a window save that would leave the mark outside the new extent is refused unless the same patch moves or clears it. `PUT /api/report/window` whitelists it and `/api/report/clip` returns it. `GET /api/report/audio` serves a cached window's sound, picked by `/api/report/raw`'s membership rule, decoded by ffmpeg to 16-bit PCM WAV over an absolute span of at most 120 s. `lib/report/playback.mjs` is the arithmetic both sides use (decode span, buffer schedule, playhead, mute ramp, the element fallback's stop test, the muteFrom rule, the WAV header), unit-tested | +| bf927658 | The bench: **play selection**, the edge auditions, the auto-audition and a transcript line play from the decoded window with an `AudioBufferSourceNode`, started at an exact buffer offset and stopped at a context time (exact at every speed); the picture follows muted and the playhead reads the audio clock. One decode per window (the whole window up to 120 s, else the selection plus 30 s each side). When the decode fails, the element plays, stopped per animation frame by remaining time, and the bench says the playback is approximate and why. The mute mark: `m` at the playhead, **pick on waveform**, `;`/`'` to nudge (shift for 0.5 s), `M`/**clear mute**; drafted like the edges, saved by **save window** or a confirmation. Each playback is recorded as `data-play-*` attributes, and the specs check the stop against an AudioWorklet tap of the bench's output | +| b9debbd7 | The audio-clock playhead paints at about 30 Hz; `m` reads it through a ref; the exact-stop spec checks the start against the schedule rather than the first non-zero frame | +| e74c7a90 | The muted picture is re-seeked when it drifts more than 0.25 s from the audio clock; the element's own pause at the end of a playback no longer moves the playhead | + +After the merge, on the main line: + +- 3e3f251f: the bench's mute preview is the build's — one `MUTE_FADE` (0.04 s, from `deck.mjs`), + a ramp that ends at the mark. B1 had faded over 0.05 s. +- 3c70c6d3: the posts and mute specs follow whole-frame holds and the build's mute fade. + +Gates (from the merge): the stop measured on the ferret c20 window, 0 of 20 trials off its +schedule, where the element it replaces overran by about 230 ms; the specs check the stop to within +one render quantum at 1× and 2×. umtool +e2e `clip-bench onscreen onscreen-posts build projects report-fetch-via-editor` 97/97. Reviewed. + +Found after the merge, by the finale review, and fixed in the fix pass below: the build refused a +`muteFrom` that `updateClip` accepted within its 0.02 s tolerance (LOW-1); `MUTE_FADE` pulled +`deck.mjs` into the bench's client bundle (LOW-5); the audio route spawned a bare `ffmpeg` and did +not stop the decode when the request was aborted (LOW-6). + +## Teaser T1, as built + +Branch `deck/teaser-t1` from 810e423e. A `teaser` timeline entry: a full-frame season-teaser card +after the last clip, its words the manifest's, a trailer hit under each pop. + +| Commit | What | +|---|---| +| 3c4ab080 | `deck.mjs`: `teaser` in `CARD_TYPES` (the deck hides over it whatever `overCards` says), `teaserLines`/`teaserTail`/`teaserTitle`, `validateTeaser`/`validateTeasers`, `TEASER_MOTION` + `teaserTimes` (the one copy of the timing) and `teaserHits`; `chrome-teaser.mjs` (the page, `teaserCues`); compose-chrome region `teaser` by dynamic import; build-video `buildTeaserSegment`, `teaserAudioGraph`, `teaserEncodeArgs`, `teaserSegmentKey`, the chapter, `--chrome-only` building teasers, `--fetch-only` a no-op on one; verify-build `verifyTeasers`; `chrome-teaser.test.mjs`, `teaser-audio.test.mjs` | +| a82aae0e | umtool: the report page's row and the export's fallback chapter name a teaser by its lines; `onscreen-fixture` carries a teaser and `onscreen.spec` checks the table, the preview's node and the row; the supporting lines a size up | +| c51925ae | README section, four quirks, one `[Unreleased]` bullet | + +Where it adds to, or differs from, the brief: + +- **A line is a string or `{ text, break }`** — `break` is the END of `text`, drawn as a smaller, + wide-tracked second tier 0.3 s after the rest; the whole `text` is what the chapter and umtool + show. There are no quotation marks and no `quote` field (the operator dropped them). +- **Roles follow position**: with three or more lines the first is the overline, the last the + kicker, the rest titles; two are overline + title; one is a title. +- **`hits`** (default true): a synthesised hit under each pop and a swell under the tail; false is + digital silence. The hits are placed by `teaserTimes`, the times the cues are built from, and + land on their pop's sample (a real-ffmpeg test differences the graph with and without each hit). +- **`--chrome-only` builds teaser segments** instead of refusing: a teaser is chrome (graphics made + from the manifest, nothing fetched). Its segment is re-encoded only when its key — the frames' + render key and the whole sound graph — differs from `<id>.teaser.json`'s. +- **The deck hides over a teaser even with `overCards: "show"`**: it is full frame, never framed + into the footage box. +- The fit (a line wider than 80 % of the frame shrinks) runs once the face is in, as the deck's + title fit does; it changes sizes, never a time. + +Gates on c51925ae: + +- Workspace tsc clean (102 s). `test:scripts` 355 pass, 1 skipped (356). Capped umtool + `next build` with the corpus linked exit 0 (58 s, under a concurrent encode), link removed; + Turbopack bundles the teaser page and its face as their own server chunk. +- Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5 + `6a92235fa12ca181bb81993129c9ee9d`. The ferret deck manifest without a teaser measures the same + schedule at 810e423e and at the tip (`measureChromeSchedule`, byte-equal); with the teaser, the + first 17 segments, the posts and the moves equal the ferret's built `schedule.json`. +- Ferret, scratch copy with the teaser after c20, `--chrome-only` over copied segments: + - the teaser rendered in 77–86 s (210 frames), the deck (11,001 frames) in 4 min 7 s; a + re-run after a design change re-rendered the teaser only (deck and posts `cached`, 573 s); + - 366.7 s (360.2 + 7 − 0.5), video and audio both 366.700 s; verify-build ok, every hold + frozen, `teaser fin: 210/210 frame(s), segment encoded from them`; + - QR 17/17 deck and 7/7 posts; 18 chapters, the last "Pirate Software — The Largest Ferret + Rescue in the United States — February 2027 ?" at 360.2 s; + - the last frame is bg (Y′CbCr 30/132/128, uniform, against 31/132/128); the sound is digital + silence for its last 39 ms; + - loudness: the cut −17.7 LUFS integrated, the teaser's 7 s −19.7 LUFS, sample peak −6.0 dBFS. +- umtool e2e `onscreen onscreen-posts build projects`: 43 passed, 1 failed (2.9 min). The failure + is `onscreen-posts.spec.ts:264`, and it predates this branch: 2f0e852b rounds a hold to whole + frames, and at the posts fixture's 15 fps 2.5 s is 38 frames (2.533 s), while the spec still + expects 2.5. No teaser is in that fixture. + +Found and left: + +- umtool cannot edit a teaser's lines. It needs a writer (`updateTeaser` through + `withManifestLock` and `validateTeaser`), a route, a form (a field per line with a break + picker) and a still of the composition (compose-chrome's `--still`, as the deck's still route + does). +- `onscreen-posts.spec.ts`'s timings at 15 fps (above) — resolved: 3c70c6d3 on the main line made + the posts and mute specs follow whole-frame holds and the build's mute fade, and the merge + carries it. + +## Fix pass after the finale review + +The read-only review of 1aecf54f (everything after df062b37: B2, the A/V pin, B1, T1) said SHIP +AFTER FIXES. A capped umtool `next build` with the corpus linked passed at 1aecf54f: exit 0, 26 s. + +| Commit | What | +|---|---| +| 3465184d | FIX-A: an `endFade` longer than the last segment is the segment. `endFadeFrames` clamps the fade's frames to `lastFrame`, and the sound fades over the same frames, so the last frame is bg and the sound silent together (a 3 s segment under `endFade: 5` was 59 % of the way to bg) | +| 3d9d5afd | FIX-B: "Bench B1, as built" above; one `[Unreleased]` bullet | +| 083ee2d6 | LOW-5: `MUTE_FADE` lives in the dependency-free `report-to-video/mute.mjs` (`umtool-report-to-video/mute`), re-exported by `deck.mjs`; the bench's `playback.mjs` imports it from there | +| ece9c818 | LOW-6: `/api/report/audio` spawns the build's `FFMPEG_BIN`, and the request's abort signal kills the decode | +| 46a4d417 | LOW-1: a window save clamps a mute mark within the writer's 0.02 s of the moved edge onto it and stores it. Chosen over loosening the build: the build's check stays strict for every writer, and the clamp is what a patched mark already got | +| 0ffed4f2 | LOW-2: a `muteFrom` at or past its segment's length (inside the extent, past `cutEnd`) is noted as "will not be heard", not "muted from" | +| c56f1a74 | LOW-3: the teaser segment's key hashes `encodeArgs(render)` (crf, preset, audio bitrate, rate, channels) | +| 964585a1 | LOW-4: `TEASER_LIMITS.fit` — title 34, kicker 56, overline 64, second tier 66 characters per row, the tail and its gap counted on its row; `validateTeaser` refuses a longer row with a sentence | +| 07d1fa08 | LOW-7: the records | + +- **LOW-4, rendered.** A teaser whose title line is the 80 characters the limit allowed, still at + 6.5 s through `compose-chrome --region teaser --still`: shrunk to its 56 px floor, the line ran + past both edges of the frame (ink in columns 0–1919). Measured in the face at each role's floor + on ordinary headline words in capitals, a row holds: title 36–37, kicker 58–60, overline 65–67, + second tier 69–71. Each limit is a little under that. Stills at the limits, the tail included + where it sits, keep their ink within columns 114–1804. A row of only wide capitals (M, W) can + still spill at these counts, and the README says so. +- **LOW-5, the client bundle.** Client chunks (`umtool/.next/static`) at 1aecf54f: `crypto-browserify` + in 1 chunk (3 hits), `createHash` 1, 1,505,165 bytes in all. After: 0, 0, 1,043,591 bytes. + `chromeCacheKey` is 0 both times, because the minifier renames it. +- **LOW-7.** The changelog no longer says a cut without the deck, posts, `muteFrom` or `endFade` + "builds exactly as before" (the pin changed every crossfaded graph). The end fade falls on + whatever the last segment is, a closing teaser included. The README's "what it was" line is + corrected. The teaser's loudness is the segment's measured −19.7 LUFS in both. The 15 fps spec + item is marked resolved. `quirks.md` has the acrossfade-by-sound vs xfade-by-picture drift. + +Gates at 07d1fa08: + +- Workspace tsc clean (49 s). `test:scripts` 368 pass, 2 skipped (370). +- Capped umtool `next build` with the corpus linked: exit 0 (23 s), link removed. +- Byte-identical: `--only c07 --skip-fetch` on the unchanged ferret manifest copy without + `render.chrome` gives md5 `6a92235fa12ca181bb81993129c9ee9d`. +- umtool e2e `onscreen-posts onscreen clip-bench build projects report-fetch-via-editor`: 97 + passed (4.9 min). diff --git a/umtool/app/api/report/audio/route.ts b/umtool/app/api/report/audio/route.ts @@ -0,0 +1,119 @@ +import { spawn } from "node:child_process"; +import { stat } from "node:fs/promises"; +import { absOf, pickWindow, resolveClip, windowsFor } from "@/lib/report/serve.mjs"; +import { MAX_DECODE_SPAN, wavHeader } from "@/lib/report/playback.mjs"; +import { FFMPEG_BIN } from "umtool-report-to-video/build-video"; + +export const dynamic = "force-dynamic"; + +// A cached source window's SOUND, decoded, for the bench to play exactly. +// +// The bench plays every bounded range -- a selection, an edge, a line -- from +// a decoded buffer with Web Audio, because an <video> element stopped from +// `timeupdate` overran the end by up to a quarter of a second. This is that +// buffer: the same file /api/report/raw serves (picked by the same membership +// rule), as 16-bit PCM WAV, which every browser decodes without a codec. +// +// DECODED BY FFMPEG, NOT BY THE BROWSER. The build cuts with ffmpeg, so a cut +// set by ear has to be set against ffmpeg's timeline: a browser's own AAC +// decode may or may not honour the file's edit list, and the encoder's +// priming samples are tens of milliseconds -- the size of the error this +// exists to remove. +// +// `from`/`to` are ABSOLUTE source seconds, clamped to the file. A span longer +// than MAX_DECODE_SPAN is refused rather than served: a whole recording in the +// saved-video store is hours, and the bench asks for a window around the +// selection instead (decodeSpan). + +/** Matches the build's `render.audioRate` default; the browser resamples anyway. */ +const RATE = 48000; +const CHANNELS = 2; + +export async function GET(request: Request) { + const url = new URL(request.url); + const r = await resolveClip(url.searchParams.get("project") ?? "", url.searchParams.get("clip") ?? ""); + if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); + + const windows = await windowsFor(r.project, r.clip); + const win = pickWindow(windows, url.searchParams.get("file")); + if (!win) return Response.json({ error: "no cached window for this clip" }, { status: 404 }); + const abs = absOf(win); + if (!abs) return Response.json({ error: "outside the roots" }, { status: 400 }); + const st = await stat(abs).catch(() => null); + if (!st) return Response.json({ error: "gone" }, { status: 404 }); + + const num = (k: string, dflt: number) => { + const v = url.searchParams.get(k); + const n = v == null || v === "" ? dflt : Number(v); + return Number.isFinite(n) ? n : Number.NaN; + }; + const from = Math.max(win.from, num("from", win.from)); + const to = Math.min(win.to, num("to", win.to)); + if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) { + return Response.json({ error: "bad span" }, { status: 400 }); + } + if (to - from > MAX_DECODE_SPAN + 0.5) { + return Response.json( + { error: `asked for ${Math.round(to - from)} s; the bench decodes at most ${MAX_DECODE_SPAN} s at once` }, + { status: 400 }, + ); + } + + // Input seeking with a decode is sample-accurate in ffmpeg: it seeks to the + // keyframe before and discards up to the requested time. The build's ffmpeg + // (FFMPEG_BIN), so an override reaches this decode too; killed when the + // request is aborted (the bench moved on to another window or left). + const chunks: Buffer[] = []; + let err = ""; + const code = await new Promise<number>((resolve) => { + const ff = spawn(FFMPEG_BIN, [ + "-nostdin", "-v", "error", + "-ss", (from - win.from).toFixed(6), + "-i", abs, + "-t", (to - from).toFixed(6), + "-map", "a:0", + "-ac", String(CHANNELS), "-ar", String(RATE), + "-f", "s16le", "-acodec", "pcm_s16le", "-", + ], { signal: request.signal }); + ff.stdout.on("data", (b: Buffer) => chunks.push(b)); + ff.stderr.on("data", (b: Buffer) => { + err += b.toString(); + }); + ff.on("error", (e) => { + err += String(e); + resolve(-1); + }); + ff.on("close", (c) => resolve(c ?? -1)); + }); + // Nobody is waiting for it: the decode was killed, say so and stop. + if (request.signal.aborted) return new Response(null, { status: 499 }); + const pcm = Buffer.concat(chunks); + const frameBytes = CHANNELS * 2; + const dataBytes = pcm.length - (pcm.length % frameBytes); + if (code !== 0 || dataBytes === 0) { + // Said in the bench, which then plays through the element: the message is + // the reason the playback is approximate. + const why = /matches no streams|does not contain any stream/i.test(err) + ? "this file has no audio track" + : err.trim().split("\n").pop() || `ffmpeg exited ${code}`; + return Response.json({ error: why }, { status: 422 }); + } + + const body = new Uint8Array(44 + dataBytes); + body.set(wavHeader({ channels: CHANNELS, sampleRate: RATE, dataBytes }), 0); + body.set(pcm.subarray(0, dataBytes), 44); + return new Response(body, { + headers: { + "content-type": "audio/wav", + // The window is in the file's name, so the same request is the same + // sound: it may be cached for as long as the raw file is. + "cache-control": "private, max-age=3600, immutable", + // What was actually decoded, in absolute source seconds: the client + // places the buffer on the source clock from these, not from what it + // asked for. + "x-audio-from": String(from), + "x-audio-to": String(from + dataBytes / frameBytes / RATE), + "x-window": win.name, + }, + }); +} diff --git a/umtool/app/api/report/chrome/preview/route.ts b/umtool/app/api/report/chrome/preview/route.ts @@ -23,6 +23,11 @@ export const dynamic = "force-dynamic"; // `draft` -- unsaved rows, id → { title?, subtitle? } | null, the shape PUT // /api/report/onscreen takes -- is applied on top. // +// The schedule carries the posts' holds (a carrying clip's `hold`, part of its +// length) and the footage's `moves` when `posts.shift` is on; the page eases +// its backdrop along them, and the posts region sits at `postsGeometry`, at +// the frame's edge when the footage makes room. +// // The posts region is composed beside it, one project per window // (postWindows: a clip that carries posts, from its first post's appearance to // the end of their leave), each loaded in its own iframe at @@ -87,6 +92,8 @@ export async function POST(request: Request) { src: `${deckPreviewSrc(r.project.id, r.variant)}?v=${stamp}`, variant: r.variant, geometry: deckGeometry(render), + // The ground a footage moved aside for the posts (schedule.moves) leaves showing. + background: typeof (render.palette as { bg?: unknown } | undefined)?.bg === "string" ? (render.palette as { bg: string }).bg : null, layout: deckLayout(render), schedule, posts: { diff --git a/umtool/app/api/report/clip/route.ts b/umtool/app/api/report/clip/route.ts @@ -59,6 +59,7 @@ export async function GET(request: Request) { cutStart: clip.cutStart ?? null, cutEnd: clip.cutEnd ?? null, lockCut: !!clip.lockCut, + muteFrom: clip.muteFrom ?? null, }, view, windows: windows.map((w: { name: string; from: number; to: number }) => ({ diff --git a/umtool/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts @@ -47,6 +47,10 @@ export async function PUT(request: Request) { "cutStart", "cutEnd", "lockCut", + // Where the sound fades out for the rest of the clip while the picture + // plays on, in source seconds; empty or null clears it. Checked by the + // writer against the clip's window, like the cut. + "muteFrom", // Whether the walk has looked at this clip: "confirmed", or empty to clear // it. A non-empty `correction` is the other answer and needs no value. "verdict", diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -11,6 +11,16 @@ import { buttonVariants } from "@/components/ui/button"; // would only find out twenty minutes into a build. import { attributionLine } from "umtool-report-to-video/attribution"; import { + MUTE_FADE, + START_LEAD, + bufferSchedule, + covers, + decodeSpan, + elementShouldStop, + muteRamp, + playheadAt, +} from "@/lib/report/playback.mjs"; +import { DeckFrame, NeutralFrame, composePreview, @@ -100,6 +110,12 @@ type Clip = { cutEnd: number | null; /** The cut is deliberate; `resolve-windows --cut-to-quote` leaves it alone. */ lockCut: boolean; + /** + * The MUTE MARK, in source seconds: from here to the end of the clip the + * sound fades out and the picture plays on. Set by ear, at the last silence + * before a finale's ending sound. Absent means the clip plays with its sound. + */ + muteFrom: number | null; /** "confirmed" / "incorrect", or null for "nobody has looked at this yet". */ verdict: "confirmed" | "incorrect" | null; /** What the on-screen panel says over this clip. Absent means the auto text. */ @@ -180,6 +196,7 @@ const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({ cutStart: e.cutStart == null ? null : Number(e.cutStart), cutEnd: e.cutEnd == null ? null : Number(e.cutEnd), lockCut: !!e.lockCut, + muteFrom: e.muteFrom == null ? null : Number(e.muteFrom), verdict: e.verdict === "confirmed" || e.verdict === "incorrect" ? e.verdict : null, onscreen: (e.onscreen as Onscreen | undefined) ?? null, }); @@ -303,6 +320,62 @@ const clock = (t: number) => { return `${Math.floor(m / 60) > 0 ? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}` : m}:${String(s % 60).padStart(2, "0")}`; }; +/** What `save window` (and a confirmation that moved something) writes. */ +type WindowPatch = { + start: number; + end: number; + cutStart?: string; + cutEnd?: string; + /** A number to set the mark, "" to clear it; absent leaves it alone. */ + muteFrom?: number | string; +}; + +/** A decoded span of the cached window, placed on the source clock. */ +type Decoded = { name: string; span: { from: number; to: number }; buf: AudioBuffer }; + +/** + * What is playing, and what the last playback measured. + * + * On the page as data attributes (`data-play-*`), because "did it stop where + * the selection ends" is the claim this bench now makes, and a spec has to be + * able to check it against the audio clock rather than against a feeling. + */ +type PlayInfo = { + engine: "webaudio" | "element"; + state: "playing" | "stopped"; + from: number; + /** The scheduled end, in source seconds. */ + to: number; + rate: number; + /** Context clock: when the source starts and when it is told to stop. */ + ctxStart: number | null; + ctxStop: number | null; + /** + * Where the playback had reached, in source seconds, when the page heard it + * end: the element's own position at its pause, or the context clock at + * `ended` -- which reaches the page a task later than the sound stopped, so + * for Web Audio it is an upper bound and `ctxStop` is the stop itself. + */ + endedAt: number | null; +}; + +/** The playback in flight. One at a time: a new one stops the last. */ +type Session = { + gen: number; + engine: "webaudio" | "element"; + node: AudioBufferSourceNode | null; + gain: GainNode | null; + raf: number; + timers: number[]; + from: number; + to: number; + rate: number; + t0: number; + ctxStop: number; + /** The element's own mute, put back when the picture stops following. */ + mutedBefore: boolean; +}; + export default function ClipBench({ data }: { data: ClipBenchData }) { const [clip, setClip] = useState<Clip>(data.clip); const [windows, setWindows] = useState<Win[]>(data.windows); @@ -310,6 +383,13 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const [proposed, setProposed] = useState(data.proposed); const [view, setView] = useState(data.view); const [sel, setSel] = useState({ from: data.clip.start, to: data.clip.end }); + // The mute mark as drafted. Like the edges it is unsaved until `save window` + // or `y`, and it is what the bench's own playback mutes at -- so a mark is + // heard before it is written. + const [mute, setMute] = useState<number | null>(data.clip.muteFrom); + // Armed: the next click on the waveform places the mark instead of moving + // an edge. + const [mutePick, setMutePick] = useState(false); const [peaks, setPeaks] = useState<Peaks | null>(null); // The cues AROUND the cached window: what is coming, read before paying for // the media. One request, widened only when somebody asks. @@ -319,6 +399,10 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const [cutScore, setCutScore] = useState<number | null>(null); const [peekPad, setPeekPad] = useState(PEEK_STEP); const [playhead, setPlayhead] = useState<number | null>(null); + const playheadRef = useRef<number | null>(null); + useEffect(() => { + playheadRef.current = playhead; + }, [playhead]); const [note, setNote] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null); const [dirty, setDirty] = useState(false); @@ -356,7 +440,6 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const token = useRef(data.token); // And they must not interleave: same read-modify-write, one clip. const saving = useRef<Promise<boolean>>(Promise.resolve(true)); - const stopAt = useRef<number | null>(null); // `x` answers "no" by putting the cursor in the note, which is the answer. const correctionBox = useRef<HTMLTextAreaElement | null>(null); const segVideo = useRef<HTMLVideoElement | null>(null); @@ -532,20 +615,366 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { }, [playback, segment, cached, renderedOpen, deckPreview]); // ---- audition ----------------------------------------------------------- - const play = useCallback( - (from: number, to: number) => { + // + // EVERY BOUNDED RANGE PLAYS FROM DECODED AUDIO. A selection, an edge, a line + // of the transcript, the auto-audition: each is an AudioBufferSourceNode + // started at an exact offset and stopped at an exact time on the audio + // clock, so what you hear ends where the selection ends, to the sample. The + // <video> used to play these itself and was stopped from `timeupdate`, which + // fires every 15-250 ms -- the stop overran by up to a quarter of a second, + // and by a different amount every time, which is exactly what makes a cut + // between two words impossible to judge by ear. (The old HTML chooser had + // this right; it is the same technique.) + // + // The picture FOLLOWS, muted, and the playhead is read off the audio clock. + // When the decode fails the element plays instead, stopped per animation + // frame by remaining time, and the bench says so. + + /** The decoded audio for the cached window: one decode per window. */ + const decoded = useRef<Decoded | null>(null); + const decoding = useRef<{ key: string; p: Promise<Decoded> } | null>(null); + const actx = useRef<AudioContext | null>(null); + const session = useRef<Session | null>(null); + const playGen = useRef(0); + // Pauses the bench asked for itself and has not yet heard the event of. A + // `pause` event is a task, so it arrives AFTER the next playback has started: + // without the count, stopping one playback to start the next would read as + // somebody pausing the picture, and stop the new one. + const selfPauses = useRef(0); + const pauseVideo = useCallback((el: HTMLVideoElement) => { + if (el.paused) return; + selfPauses.current += 1; + el.pause(); + }, []); + // Read by play() at the moment it schedules, so a mark moved since the last + // render is the one you hear -- without re-creating play() on every nudge. + const muteRef = useRef<number | null>(data.clip.muteFrom); + const [audio, setAudio] = useState<{ state: "idle" | "loading" | "ready" | "failed"; why?: string }>({ + state: "idle", + }); + const [playInfo, setPlayInfo] = useState<PlayInfo | null>(null); + + /** + * The cached window's sound over (at least) `want`, decoded once. + * + * Through /api/report/audio -- ffmpeg's decode of the same file the player + * loads, as PCM -- and decoded here on an OfflineAudioContext, so no audio + * output is opened before somebody asks to hear something. A buffer belongs + * to no context and plays in the real one. + */ + const ensureDecoded = useCallback( + async (want: { from: number; to: number }): Promise<Decoded> => { + if (!cached) throw new Error("nothing is cached for this clip"); + const have = decoded.current; + if (have && have.name === cached.name && covers(have.span, want.from, want.to)) return have; + const span = decodeSpan({ from: cached.from, to: cached.to }, want); + const key = `${cached.name}|${span.from}|${span.to}`; + if (decoding.current?.key === key) return decoding.current.p; + const p = (async () => { + setAudio({ state: "loading" }); + const r = await fetch( + `/api/report/audio?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}` + + `&file=${encodeURIComponent(cached.name)}&from=${span.from}&to=${span.to}`, + ); + if (!r.ok) { + const j = (await r.json().catch(() => null)) as { error?: string } | null; + throw new Error(j?.error ?? `the audio route answered ${r.status}`); + } + const from = Number(r.headers.get("x-audio-from") ?? span.from); + const bytes = await r.arrayBuffer(); + const Offline = + typeof window !== "undefined" ? (window.OfflineAudioContext ?? null) : null; + if (!Offline) throw new Error("this browser has no Web Audio"); + const buf = await new Offline(2, 1, 48000).decodeAudioData(bytes); + const d: Decoded = { name: cached.name, span: { from, to: from + buf.duration }, buf }; + decoded.current = d; + setAudio({ state: "ready" }); + return d; + })(); + decoding.current = { key, p }; + p.catch((e: unknown) => { + if (decoding.current?.p === p) decoding.current = null; + setAudio({ state: "failed", why: e instanceof Error ? e.message : String(e) }); + }); + return p; + }, + [cached, data.project, clip.id], + ); + + // A different window -- a wider one after "fetch more", or the first after a + // fetch -- is different audio: drop the old decode and start the new one + // now, so the first play after it does not wait. + const cachedName = cached?.name ?? null; + useEffect(() => { + decoded.current = null; + decoding.current = null; + setAudio({ state: "idle" }); + if (!cachedName) return; + ensureDecoded({ from: clip.start, to: clip.end }).catch(() => { + /* said in the bench; play() falls back to the element */ + }); + // ensureDecoded changes identity with `cached`, which is this effect's + // whole subject; keying on the NAME is what stops a refresh() that returns + // the same window from decoding it again. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cachedName]); + + /** Stop whatever is playing, Web Audio or element, and let go of it. */ + const stopPlayback = useCallback(() => { + const s = session.current; + if (!s) return; + session.current = null; + cancelAnimationFrame(s.raf); + for (const t of s.timers) clearTimeout(t); + if (s.node) { + s.node.onended = null; + try { + s.node.stop(); + } catch { + /* never started, or already stopped */ + } + s.node.disconnect(); + s.gain?.disconnect(); + } + const el = video.current; + if (el) { + pauseVideo(el); + el.muted = s.mutedBefore; + } + setPlayInfo((pi) => (pi && pi.state === "playing" ? { ...pi, state: "stopped" } : pi)); + }, [pauseVideo]); + + /** + * The fallback: the element, stopped per animation frame by REMAINING TIME. + * + * `timeupdate` was the old stop and is the overrun this replaces; a frame + * tick that stops once the end is under half a frame away is the best the + * element can do, and the bench says that this is what is playing. + */ + const playElement = useCallback( + (from: number, to: number, gen: number) => { const el = video.current; if (!el || !cached) return; + const rate = playback.rate; el.currentTime = Math.max(0, from - fetchStart); - el.playbackRate = playback.rate; - stopAt.current = to; + el.playbackRate = rate; + const s: Session = { + gen, + engine: "element", + node: null, + gain: null, + raf: 0, + timers: [], + from, + to, + rate, + t0: 0, + ctxStop: 0, + mutedBefore: el.muted, + }; + session.current = s; + setPlayInfo({ engine: "element", state: "playing", from, to, rate, ctxStart: null, ctxStop: null, endedAt: null }); + const stopNow = () => { + if (session.current !== s) return; + pauseVideo(el); + session.current = null; + cancelAnimationFrame(s.raf); + for (const t of s.timers) clearTimeout(t); + el.muted = s.mutedBefore; + setPlayInfo((pi) => (pi ? { ...pi, state: "stopped", endedAt: el.currentTime + fetchStart } : pi)); + }; + const tick = () => { + if (session.current !== s) return; + const t = el.currentTime + fetchStart; + setPlayhead(t); + // The mark, as near as a frame tick gets it. + const m = muteRef.current; + el.muted = s.mutedBefore || (m != null && t >= m); + if (!el.paused && elementShouldStop(t, to, rate)) { + stopNow(); + return; + } + // Inside the last few frames, a timer for the REMAINING time: it lands + // between frames, where the next tick would land up to a frame late. + const remaining = (to - t) / rate; + if (!el.paused && remaining < 0.1 && !s.timers.length) { + s.timers.push(window.setTimeout(stopNow, remaining * 1000)); + } + s.raf = requestAnimationFrame(tick); + }; + s.raf = requestAnimationFrame(tick); // A rejected play() is normal, not a bug: Chrome refuses unmuted audio on // a document nobody has interacted with (a typed URL, a fresh tab), and // an unhandled rejection in that case would be noise. The seek has // already happened either way. void el.play().catch(() => {}); }, - [cached, fetchStart, playback.rate], + [cached, fetchStart, playback.rate, pauseVideo], + ); + + const play = useCallback( + async (from: number, to: number) => { + const gen = (playGen.current += 1); + stopPlayback(); + if (!cached || !(to > from)) return; + const rate = playback.rate; + // The context is opened HERE, inside the click or the key that asked: + // that gesture is what lets it start. + let ac = actx.current; + if (!ac && typeof window !== "undefined" && window.AudioContext) { + ac = new window.AudioContext(); + actx.current = ac; + } + const resumed = ac && ac.state !== "running" ? ac.resume().catch(() => {}) : null; + let d: Decoded; + try { + if (!ac) throw new Error("this browser has no Web Audio"); + d = await ensureDecoded({ from, to }); + } catch { + if (gen === playGen.current) playElement(from, to, gen); + return; + } + if (gen !== playGen.current) return; + // Longer than one decode: the element, rather than a second decode in + // the middle of a click. + if (!covers(d.span, from, to)) { + playElement(from, to, gen); + return; + } + if (resumed) await Promise.race([resumed, new Promise((res) => setTimeout(res, 300))]); + // No gesture has ever reached this document, so the context may not + // start: the same refusal a muted-autoplay policy gives the element, and + // just as silent. + if (gen !== playGen.current || ac!.state !== "running") return; + const ctx = ac!; + const sch = bufferSchedule(d.span, d.buf.duration, from, to, rate); + if (!sch) return; + + const node = ctx.createBufferSource(); + node.buffer = d.buf; + node.playbackRate.value = rate; + const gain = ctx.createGain(); + node.connect(gain); + gain.connect(ctx.destination); + // Ahead of now, so start and stop stay on the clock they were computed + // on: a start in the past begins late at the SAME offset. + const t0 = ctx.currentTime + START_LEAD; + const ctxStop = t0 + sch.wall; + const m = muteRamp(muteRef.current, from, from + sch.duration, rate); + if (m) { + if (m.at <= 0) gain.gain.setValueAtTime(0, t0); + else { + gain.gain.setValueAtTime(1, t0 + m.at); + gain.gain.linearRampToValueAtTime(0, t0 + m.at + m.fade); + } + } + // The stop is a TIME on the context's clock, not start()'s duration + // argument: that one is buffer content, and a clock time means the same + // thing at every speed. + node.start(t0, sch.offset); + node.stop(ctxStop); + + const el = video.current; + const s: Session = { + gen, + engine: "webaudio", + node, + gain, + raf: 0, + timers: [], + from, + to: from + sch.duration, + rate, + t0, + ctxStop, + mutedBefore: el?.muted ?? false, + }; + session.current = s; + setPlayInfo({ + engine: "webaudio", + state: "playing", + from, + to: s.to, + rate, + ctxStart: t0, + ctxStop, + endedAt: null, + }); + + // The picture follows, muted. It is not what is being judged; a few + // milliseconds of drift between the two is not worth a sync loop. + if (el) { + el.muted = true; + el.playbackRate = rate; + el.currentTime = Math.max(0, from - fetchStart); + s.timers.push( + window.setTimeout(() => { + if (session.current === s) void el.play().catch(() => {}); + }, START_LEAD * 1000), + ); + } + + node.onended = () => { + if (session.current !== s) return; + // How late the context clock reads at `ended`, mapped back onto the + // source: what the playback measured, for the bench's own record. + const late = ctx.currentTime - ctxStop; + session.current = null; + cancelAnimationFrame(s.raf); + for (const t of s.timers) clearTimeout(t); + node.disconnect(); + gain.disconnect(); + if (el) { + pauseVideo(el); + el.muted = s.mutedBefore; + } + setPlayhead(s.to); + setPlayInfo((pi) => (pi ? { ...pi, state: "stopped", endedAt: s.to + Math.max(0, late) * rate } : pi)); + }; + + // The playhead at ~30 Hz, not every frame: each update re-renders the + // bench, and the line moving smoothly is not worth that at 60. + // + // And the picture is pulled back to the sound when it drifts by more + // than a quarter second -- which it does after a seek into a file that + // is only partly loaded, where the element starts late by however long + // the bytes took. + let painted = -1; + let checked = t0; + const tick = () => { + if (session.current !== s) return; + const now = ctx.currentTime; + const pos = playheadAt(from, t0, now, rate, sch.duration); + if (now - painted >= 1 / 30) { + painted = now; + setPlayhead(pos); + } + if (el && now - checked >= 0.5 && now > t0) { + checked = now; + const want = pos - fetchStart; + if (!el.seeking && Math.abs(el.currentTime - want) > 0.25) el.currentTime = want; + } + s.raf = requestAnimationFrame(tick); + }; + s.raf = requestAnimationFrame(tick); + }, + [cached, fetchStart, playback.rate, ensureDecoded, playElement, stopPlayback, pauseVideo], + ); + + // A speed change mid-playback would leave the picture and the sound at two + // rates: stop, and the next play is at the new one. + useEffect(() => { + stopPlayback(); + }, [playback.rate, stopPlayback]); + + // Let go of the audio output with the bench. + useEffect( + () => () => { + stopPlayback(); + void actx.current?.close().catch(() => {}); + actx.current = null; + }, + [stopPlayback], ); // ---- auto-audition ------------------------------------------------------- @@ -579,20 +1008,40 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { }; }, [playback.auto, cached, clip.id, clip.start, clip.end, play]); + // The element's own controls: unbounded "play from here", which stays on the + // element. Its clock moves the playhead only while nothing bounded is + // playing -- during a Web Audio playback the muted picture would otherwise + // fight the audio clock for it. Pausing the picture by hand stops the sound + // it is following. useEffect(() => { const el = video.current; if (!el) return; + // While it plays by itself. A paused element's `timeupdate` is the one its + // own pause fires -- after a Web Audio playback, that is the muted picture + // stopping wherever it had got to, which is not where the sound stopped. const tick = () => { - const t = el.currentTime + fetchStart; - setPlayhead(t); - if (stopAt.current != null && t >= stopAt.current) { - el.pause(); - stopAt.current = null; + if (!session.current && !el.paused) setPlayhead(el.currentTime + fetchStart); + }; + // A scrub on the paused element's own bar. + const seeked = () => { + if (!session.current) setPlayhead(el.currentTime + fetchStart); + }; + const paused = () => { + if (selfPauses.current > 0) { + selfPauses.current -= 1; + return; } + if (session.current) stopPlayback(); }; el.addEventListener("timeupdate", tick); - return () => el.removeEventListener("timeupdate", tick); - }, [fetchStart]); + el.addEventListener("seeked", seeked); + el.addEventListener("pause", paused); + return () => { + el.removeEventListener("timeupdate", tick); + el.removeEventListener("seeked", seeked); + el.removeEventListener("pause", paused); + }; + }, [fetchStart, stopPlayback, cachedName]); // ---- the selection ------------------------------------------------------ // @@ -655,6 +1104,12 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { setSel({ from: next.start, to: next.end }); setDirty(false); } + // The mark goes back to what was STORED -- rounded, or cleared because + // the window no longer held it. + if (patch.muteFrom !== undefined) { + setMute(next.muteFrom); + muteRef.current = next.muteFrom; + } // Re-sync only the fields this save carried, and from what the writer // actually stored -- which is trimmed, rounded, or gone. setDraft((d) => { @@ -745,13 +1200,13 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // longer contains, so the rule lives here rather than in each of them. const windowMoved = Math.abs(round2(sel.from) - clip.start) > 0.02 || Math.abs(round2(sel.to) - clip.end) > 0.02; + // The mark moved, set or cleared since the last save. + const muteMoved = + (mute == null) !== (clip.muteFrom == null) || + (mute != null && clip.muteFrom != null && Math.abs(round2(mute) - clip.muteFrom) > 0.005); + const unsaved = dirty || muteMoved; - const windowPatch = useCallback((): { - start: number; - end: number; - cutStart?: string; - cutEnd?: string; - } => { + const windowPatch = useCallback((): WindowPatch => { const start = round2(sel.from); const end = round2(sel.to); const cutOutside = @@ -761,8 +1216,16 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // The extent is the judgement being made right now; the cut was derived // from a wider one and is no longer inside it. Clearing it in the SAME // patch is what keeps the writer's rule and the screen agreeing. - return cutOutside ? { start, end, cutStart: "", cutEnd: "" } : { start, end }; - }, [sel.from, sel.to, clip.cutStart, clip.cutEnd]); + const out: WindowPatch = cutOutside + ? { start, end, cutStart: "", cutEnd: "" } + : { start, end }; + // The mute mark rides the same patch, by the same rule: a mark the new + // extent does not hold is cleared rather than refused. + const markOutside = mute != null && (mute < start - 0.02 || mute > end + 0.02); + if (markOutside) out.muteFrom = ""; + else if (muteMoved) out.muteFrom = mute == null ? "" : round2(mute); + return out; + }, [sel.from, sel.to, clip.cutStart, clip.cutEnd, mute, muteMoved]); // ---- the walk's verdict --------------------------------------------------- // @@ -776,7 +1239,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // is a judgement about THAT window, and the advance would otherwise walk // away from it -- so the edges go in the SAME patch as the verdict rather // than needing `save window` pressed first. One write, one token. - const win = windowMoved ? windowPatch() : null; + const win = windowMoved || muteMoved ? windowPatch() : null; // A note survives a confirmation. It stops being a complaint and becomes // what it now says it is: why this clip is here in the shape it is in. const ok = await save({ verdict: "confirmed", ...(win ?? {}) }); @@ -788,7 +1251,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { : "saved — the window you moved was saved with it", ); if (ok && data.next) router.push(`/browse/${data.project}/clip/${data.next}`); - }, [save, router, data.project, data.next, windowMoved, windowPatch]); + }, [save, router, data.project, data.next, windowMoved, muteMoved, windowPatch]); const rejectClip = useCallback(() => { setNeedNote(true); @@ -807,6 +1270,76 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { el.setSelectionRange(el.value.length, el.value.length); }, [clip, save]); + // ---- the mute mark --------------------------------------------------------- + // + // A finale's last clip plays its picture to the end, but its ending sound is + // not wanted: `muteFrom` fades the sound out from a source second to the end + // of the clip. It is set BY EAR, at the last silence before that sound, so it + // is placed the ways an edge is -- at the playhead, by a click on the + // waveform, by a nudge -- and every playback here mutes at it, so a mark is + // heard before it is saved. + + /** Re-aim the playback in flight at a new mark, from now. */ + const retargetMute = useCallback((t: number | null) => { + const s = session.current; + const ctx = actx.current; + if (!s || s.engine !== "webaudio" || !s.gain || !ctx) return; + const now = ctx.currentTime; + const g = s.gain.gain; + g.cancelScheduledValues(now); + const pos = playheadAt(s.from, s.t0, now, s.rate, s.to - s.from); + const m = t == null ? null : muteRamp(t, pos, s.to, s.rate); + if (!m) { + g.setValueAtTime(1, now); + return; + } + if (m.at <= 0) { + // Already past the mark: fade out from here. + g.setValueAtTime(g.value, now); + g.linearRampToValueAtTime(0, now + MUTE_FADE / s.rate); + return; + } + g.setValueAtTime(1, now); + g.setValueAtTime(1, now + m.at); + g.linearRampToValueAtTime(0, now + m.at + m.fade); + }, []); + + /** + * Put the mark at `t`, inside the selection. `audition` plays across it -- + * three seconds of sound and two of what should now be silence -- the way a + * moved edge plays the edge it moved. + */ + const placeMute = useCallback( + (t: number, audition: boolean) => { + const at = round2(Math.min(sel.to, Math.max(sel.from, t))); + setMute(at); + muteRef.current = at; + setMutePick(false); + if (audition) void play(Math.max(sel.from, at - 3), Math.min(sel.to, at + 2)); + else retargetMute(at); + }, + [sel.from, sel.to, play, retargetMute], + ); + + const clearMute = useCallback(() => { + setMute(null); + muteRef.current = null; + setMutePick(false); + retargetMute(null); + }, [retargetMute]); + + /** `m`: where you are listening. Anywhere outside the selection is refused. */ + const muteAtPlayhead = useCallback(() => { + // Through a ref: the playhead moves every frame while something plays, + // and a callback keyed on it would re-bind the keyboard every frame. + const at = playheadRef.current; + if (at == null || at < sel.from - 0.02 || at > sel.to + 0.02) { + setNote("the playhead is not inside the selection — play to the spot, or pick it on the waveform"); + return; + } + placeMute(at, false); + }, [sel.from, sel.to, placeMute]); + // ---- keyboard ----------------------------------------------------------- useEffect(() => { const nudge = (which: "from" | "to", by: number) => @@ -841,12 +1374,14 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { case ">": nudge("to", step); break; case " ": e.preventDefault(); - play(sel.from, sel.to); + void play(sel.from, sel.to); break; case "r": case "R": setSel({ from: clip.start, to: clip.end }); setDirty(false); + setMute(clip.muteFrom); + muteRef.current = clip.muteFrom; break; // Walking the cut. Reviewing a whole video is nineteen clips in a row, // and going back to the project page between each one is nineteen round @@ -882,6 +1417,28 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { case "A": choosePlayback((pb) => ({ ...pb, auto: !pb.auto })); break; + // The mute mark: `m` at the playhead, `M` clears it, and `;` `'` move + // it like an edge (shift for 0.5 s), each playing across it. + case "m": + muteAtPlayhead(); + break; + case "M": + clearMute(); + break; + case ";": + case ":": + case "'": + case '"': + if (mute == null) { + setNote("no mute mark yet — m sets one at the playhead"); + break; + } + placeMute(mute + (e.key === ";" || e.key === ":" ? -step : step), true); + break; + case "Escape": + if (!mutePick) return; + setMutePick(false); + break; default: return; } @@ -893,6 +1450,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { sel, clip.start, clip.end, + clip.muteFrom, onSel, play, router, @@ -902,6 +1460,11 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { confirmClip, rejectClip, choosePlayback, + mute, + mutePick, + muteAtPlayhead, + clearMute, + placeMute, ]); const refresh = useCallback(async (): Promise<ClipBenchData | null> => { @@ -1129,10 +1692,14 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const saveWindow = useCallback(() => { const patch = windowPatch(); void save(patch).then((ok) => { - if (ok && patch.cutStart !== undefined) - setNote("saved — the cut no longer fitted this window and was cleared"); + if (!ok) return; + const cleared = [ + patch.cutStart !== undefined ? "the cut" : null, + patch.muteFrom === "" && mute != null ? "the mute mark" : null, + ].filter(Boolean); + if (cleared.length) setNote(`saved — ${cleared.join(" and ")} no longer fitted this window and ${cleared.length > 1 ? "were" : "was"} cleared`); }); - }, [windowPatch, save]); + }, [windowPatch, save, mute]); // ---- the warnings -------------------------------------------------------- const endCue = cues.find((c) => sel.to >= c.start - 0.02 && sel.to <= c.end + 0.02) ?? null; @@ -1255,10 +1822,12 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const osOver = osShown.title.length > osMax; // The strip follows the player when the player is inside what the cut // plays, mapped onto the cut's clock; anywhere else it holds mid-segment. + // A clip that carries posts is held on its last frame in the cut (`hold`, + // part of its duration): the source plays only the rest. const playFrom = clip.cutStart ?? clip.start; const stripT = !deckSeg ? 0 - : playhead != null && playhead >= playFrom - 0.05 && playhead <= playFrom + deckSeg.duration + : playhead != null && playhead >= playFrom - 0.05 && playhead <= playFrom + deckSeg.duration - (deckSeg.hold ?? 0) ? deckSeg.start + Math.max(0, playhead - playFrom) : midOf(deckSeg); const overlayT = deckSeg ? deckSeg.start + Math.min(segT ?? deckSeg.duration / 4, deckSeg.duration) : 0; @@ -1371,29 +1940,38 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { unsaved — was {hms(clip.start)} – {hms(clip.end)} </span> )} + {muteMoved && ( + <span data-mute-unsaved="" className="num text-[var(--color-dirty)]"> + mute mark unsaved — was {clip.muteFrom == null ? "none" : hms(clip.muteFrom)} + </span> + )} <span className="flex flex-wrap items-center gap-1.5"> <button type="button" + data-save-window="" className={buttonVariants({ variant: "primary", size: "sm" })} - disabled={!dirty || !!busy} + disabled={!unsaved || !!busy} onClick={saveWindow} > save window </button> <button type="button" + data-play-selection="" className={buttonVariants({ size: "sm" })} - onClick={() => play(sel.from, sel.to)} + onClick={() => void play(sel.from, sel.to)} > play selection </button> <button type="button" className={buttonVariants({ size: "sm" })} - disabled={!dirty} + disabled={!unsaved} onClick={() => { setSel({ from: clip.start, to: clip.end }); setDirty(false); + setMute(clip.muteFrom); + muteRef.current = clip.muteFrom; }} > reset @@ -1444,7 +2022,41 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { > auto-audition {playback.auto ? "on" : "off"} </button> + {/* What is playing, and what it measured. The attributes are the + bench's record of its last playback, on the audio clock -- + the claim is "stops where the selection ends", and this is + where it can be checked. */} + <span + data-playback="" + data-audio-state={audio.state} + data-play-engine={playInfo?.engine ?? ""} + data-play-state={playInfo?.state ?? ""} + data-play-from={playInfo?.from ?? ""} + data-play-to={playInfo?.to ?? ""} + data-play-rate={playInfo?.rate ?? ""} + data-play-ctx-start={playInfo?.ctxStart ?? ""} + data-play-ctx-stop={playInfo?.ctxStop ?? ""} + data-play-ended-at={playInfo?.endedAt ?? ""} + className="micro" + title={ + audio.state === "failed" + ? undefined + : "every bounded playback is the decoded audio, started and stopped on the audio clock: it ends where the selection ends, to the sample" + } + > + {audio.state === "ready" + ? "exact playback" + : audio.state === "loading" + ? "decoding the audio…" + : null} + </span> </span> + {audio.state === "failed" && ( + <span data-playback-fallback="" className="text-[11px] text-[var(--color-dirty)]"> + exact playback unavailable — {audio.why}. The video plays instead, stopped by the + frame: up to half a frame either side of the end. + </span> + )} {/* ---- EXTENT above, CUT here ---- The window row says how much of the recording is worth having. This says what will actually play, and offers to derive it @@ -1491,10 +2103,60 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </button> )} </span> + {/* ---- the MUTE MARK ---- + Beside the cut because it is the same kind of decision -- what + the finished clip does with its seconds -- and drafted like the + edges: heard at once, written by `save window` or `y`. */} + <span + data-mute={mute ?? ""} + className="flex flex-wrap items-center gap-1.5" + title="From the mark to the end of the clip the sound fades out and the picture plays on. Set it at the last silence before the ending sound." + > + {mute != null ? ( + <span data-mute-from={mute} className="num text-[var(--color-text)]"> + mute from {hms(mute)}{" "} + <span className="text-[var(--color-dim)]"> + ({Math.max(0, sel.to - mute).toFixed(2)}s silent to the end) + </span> + </span> + ) : ( + <span className="text-[var(--color-dim)]">sound to the end</span> + )} + <button + type="button" + data-mute-set="" + className={buttonVariants({ size: "sm" })} + title="put the mute mark at the playhead" + onClick={muteAtPlayhead} + > + mute from here + </button> + <button + type="button" + data-mute-pick={mutePick ? "armed" : ""} + aria-pressed={mutePick} + className={buttonVariants({ variant: mutePick ? "primary" : "outline", size: "sm" })} + title="the next click on the waveform places the mute mark (Esc cancels)" + onClick={() => setMutePick((v) => !v)} + > + {mutePick ? "click the waveform…" : "pick on waveform"} + </button> + {mute != null && ( + <button + type="button" + data-mute-clear="" + className={buttonVariants({ size: "sm" })} + onClick={clearMute} + > + clear mute + </button> + )} + </span> <span className="micro"> <kbd>[</kbd> <kbd>]</kbd> start · <kbd>,</kbd> <kbd>.</kbd> end — each plays the edge it moved · <kbd>space</kbd> the whole selection · <kbd>R</kbd> reset · <kbd>-</kbd>{" "} - <kbd>=</kbd> speed · <kbd>a</kbd> auto — shift for 0.5s + <kbd>=</kbd> speed · <kbd>a</kbd> auto · <kbd>m</kbd> mute from the playhead,{" "} + <kbd>;</kbd> <kbd>&apos;</kbd> move it, <kbd>M</kbd> clear — shift for 0.5s </span> {busy && <span className="text-[var(--color-meter)]">{busy}</span>} {note && ( @@ -1505,19 +2167,58 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </div> {/* ---- the instrument ---- */} - <Waveform - view={view} - sel={sel} - cand={{ from: clip.start, to: clip.end }} - words={cues.map((c) => ({ start: c.start, end: c.end, w: c.text.slice(0, 24) }))} - peaks={peaks} - playhead={playhead} - height={104} - onSel={onSel} - onReachEdge={() => { - /* widening is a FETCH here, not a redraw -- see the button below */ - }} - /> + <div className="relative"> + <Waveform + view={view} + sel={sel} + cand={{ from: clip.start, to: clip.end }} + words={cues.map((c) => ({ start: c.start, end: c.end, w: c.text.slice(0, 24) }))} + peaks={peaks} + playhead={playhead} + height={104} + onSel={onSel} + onReachEdge={() => { + /* widening is a FETCH here, not a redraw -- see the button below */ + }} + /> + {/* The mute mark over the waveform: a line where the sound stops + and a hatch over what is silent to the end of the selection. + In the interaction colour, like the handles -- it is an edit, + not a reading -- and never in the way of a drag. */} + {mute != null && mute >= view.from && mute <= view.to && ( + <> + <div + data-mute-span="" + className="pointer-events-none absolute top-0 h-full [background:repeating-linear-gradient(135deg,color-mix(in_srgb,var(--color-dim)_22%,transparent)_0_3px,transparent_3px_7px)]" + style={{ + left: pct(mute), + width: `calc(${pct(Math.max(mute, sel.to))} - ${pct(mute)})`, + }} + /> + <div + data-mute-marker={mute} + className="pointer-events-none absolute top-0 h-full border-l-2 border-dashed border-[var(--color-sel)]" + style={{ left: pct(mute) }} + > + <span className="absolute left-1 top-0.5 rounded bg-[var(--color-panel-2)] px-1 font-mono text-[9px] leading-tight text-[var(--color-sel)]"> + mute + </span> + </div> + </> + )} + {/* Armed: the next click places the mark rather than an edge. */} + {mutePick && ( + <div + data-mute-pick-layer="" + className="absolute inset-0 cursor-crosshair rounded-md outline outline-1 outline-[var(--color-sel)]" + onPointerDown={(e) => { + const r = e.currentTarget.getBoundingClientRect(); + const frac = Math.min(1, Math.max(0, (e.clientX - r.left) / Math.max(1, r.width))); + placeMute(view.from + frac * span, true); + }} + /> + )} + </div> {/* Offered at the edge of the cache, as always -- and also whenever the rail has something to read past it. Having just read the next @@ -1765,7 +2466,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { if (!atMaxPad) void fetchMore(padsForCue(c)); return; } - play(c.start, Math.min(c.end + 4, view.to)); + void play(c.start, Math.min(c.end + 4, view.to)); }} className={`flex w-full gap-2 px-2 py-1 text-left hover:bg-[color-mix(in_srgb,var(--color-sel)_10%,transparent)] ${ inSel ? "bg-[color-mix(in_srgb,var(--color-sel)_14%,transparent)]" : "" diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -88,6 +88,11 @@ export default async function ClipBenchPage({ cutStart: entry.cutStart ?? null, cutEnd: entry.cutEnd ?? null, lockCut: !!entry.lockCut, + // The mute mark, from the manifest's own entry: readClipDetail's rows + // carry the fields it reads, and this is not one of them. + muteFrom: + ((manifest.timeline ?? []) as { id: string; muteFrom?: number }[]).find((e) => e.id === clipId) + ?.muteFrom ?? null, verdict: entry.verdict === "confirmed" || entry.verdict === "incorrect" ? entry.verdict : null, // From the manifest's own entry: what the on-screen panel says over this diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { badgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; +import { backdropTransform, footageAt } from "@/lib/report/footage-move.mjs"; // --------------------------------------------------------------------------- // ON-SCREEN: the persistent panel under the footage of a report cut. @@ -51,7 +52,11 @@ export type DeckSegment = { subtitle: string; qrUrl: string | null; hideDeck: boolean; + /** Seconds this segment is held on its last frame for its posts (part of `duration`); absent when none. */ + hold?: number; }; +/** The footage moving aside for a clip's posts (`posts.shift`), as the schedule says. */ +export type FootageMove = { segment: string; at: number; segmentAt: number; seconds: number; from: Rect; to: Rect }; export type DeckSchedule = { estimated?: boolean; fps: number; @@ -59,6 +64,7 @@ export type DeckSchedule = { total: number; multiChannel: boolean; segments: DeckSegment[]; + moves?: FootageMove[]; }; export type PostSlot = { id: string; segment: string; slot: number; of: number; appear: number; out: [number, number] }; /** One posts window's preview composition: `src` when it composed, `error` when it did not. */ @@ -67,6 +73,8 @@ export type DeckPreviewDoc = { src: string; variant: string; geometry: DeckGeometry; + /** The palette's background: the ground a moved footage leaves showing. */ + background?: string | null; schedule: DeckSchedule & { posts?: PostSlot[] }; /** The posts region: where it sits in the frame, and one composition per window. */ posts?: { geometry: Rect; windows: PostsWindow[] }; @@ -383,14 +391,26 @@ type DeckSettings = { qr: { show: boolean; size: number }; overCards: string; motion: { out: number; in: number; pip: number }; - posts: { show: boolean; seconds: number; position: string; width: number; qrSize: number; maxLines: number; inset: number }; + posts: { + show: boolean; + seconds: number; + hold: number; + position: string; + width: number; + qrSize: number; + maxLines: number; + inset: number; + shift: false | { scale: number; seconds: number }; + }; }; -type Field = +/** `when`: the bool field that must be on for this one to apply (and be shown). */ +type Field = { when?: string } & ( | { key: string; label: string; kind: "int" | "num"; step?: number; hint: string } | { key: string; label: string; kind: "select"; options: string[]; hint: string } | { key: string; label: string; kind: "bool"; hint: string } - | { key: string; label: string; kind: "parts"; hint: string }; + | { key: string; label: string; kind: "parts"; hint: string } +); /** Grouped as the form shows them. The hints are deck.mjs's ranges. */ const GROUPS: { name: string; fields: Field[] }[] = [ @@ -437,11 +457,15 @@ const GROUPS: { name: string; fields: Field[] }[] = [ fields: [ { key: "posts.show", label: "show", kind: "bool", hint: "off leaves every post out of the cut" }, { key: "posts.seconds", label: "seconds", kind: "num", step: 0.5, hint: "s, 0.5–10: each post alone before the next stacks on" }, - { key: "posts.position", label: "side", kind: "select", options: ["top-right", "top-left"], hint: "the corner of the footage the column hangs from" }, - { key: "posts.width", label: "width", kind: "int", hint: "px, 320–900, inside the footage" }, + { key: "posts.hold", label: "hold", kind: "num", step: 0.5, hint: "s, 0–10: the clip that carries posts is held on its last frame, silent, so the last post can be read; part of the cut's length" }, + { key: "posts.position", label: "side", kind: "select", options: ["top-right", "top-left"], hint: "the side the column hangs from: the frame's edge when making room, else the footage's" }, + { key: "posts.width", label: "width", kind: "int", hint: "px, 320–900" }, { key: "posts.qrSize", label: "qr", kind: "num", hint: "px, 80–200, at most half the card" }, { key: "posts.maxLines", label: "lines", kind: "int", hint: "2–14: longer posts end in an ellipsis" }, - { key: "posts.inset", label: "inset", kind: "num", hint: "px, 0–80 from the footage's edges" }, + { key: "posts.inset", label: "inset", kind: "num", hint: "px, 0–80 from the edges" }, + { key: "posts.shift", label: "make room", kind: "bool", hint: "the footage moves aside while a clip's posts are up; off leaves it in its box under the column" }, + { key: "posts.shift.scale", label: "scale", kind: "num", step: 0.01, when: "posts.shift", hint: "0.5–1: the footage's size while it is aside" }, + { key: "posts.shift.seconds", label: "move", kind: "num", step: 0.1, when: "posts.shift", hint: "s, 0–3: the move aside" }, ], }, { @@ -460,26 +484,57 @@ type Form = Record<string, string | boolean>; const getPath = (o: unknown, key: string): unknown => key.split(".").reduce<unknown>((v, k) => (v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined), o); -const formOf = (deck: DeckSettings): Form => +/** + * The form for a deck. A field under a switch that is off (`posts.shift: + * false` has no scale) shows the default, so turning the switch on starts + * from it. + */ +const formOf = (deck: DeckSettings, defaults: DeckSettings): Form => Object.fromEntries( FIELDS.map((f) => { - const v = getPath(deck, f.key); + const own = getPath(deck, f.key); + const v = f.kind === "bool" ? own : own ?? getPath(defaults, f.key); if (f.kind === "bool") return [f.key, !!v]; if (f.kind === "parts") return [f.key, Array.isArray(v) ? v.join(", ") : String(v ?? "auto")]; return [f.key, v == null ? "" : String(v)]; }), ); +/** Set `o.a.b.c` from "a.b.c", making the objects on the way. */ +const setPath = (o: Record<string, unknown>, key: string, v: unknown) => { + const ks = key.split("."); + let at = o; + for (const k of ks.slice(0, -1)) { + const next = at[k]; + if (!next || typeof next !== "object") at[k] = {}; + at = at[k] as Record<string, unknown>; + } + at[ks[ks.length - 1]] = v; +}; + +/** Fields that are a switch over an object setting: on is the object (its fields), off is `false`. */ +const SWITCHES = new Set(FIELDS.filter((f) => FIELDS.some((g) => g.when === f.key)).map((f) => f.key)); + /** * The form, back into a `render.chrome.deck` block: ONLY what differs from * the defaults, so a manifest says what somebody chose and a default that * moves later still reaches it. Anything that does not parse is sent as typed * -- the validator's sentence is a better answer than a silent clamp here. + * + * A switch over an object setting (`posts.shift`) is written as `false` when + * off -- never dropped, since absent means the default, which is on -- and as + * the fields under it that differ when on; the fields under a switch that is + * off are not written at all. */ const deckOf = (form: Form, defaults: DeckSettings): Record<string, unknown> => { const out: Record<string, unknown> = {}; for (const f of FIELDS) { const raw = form[f.key]; + if (SWITCHES.has(f.key)) { + if (!raw) setPath(out, f.key, false); + continue; + } + if (f.when && !form[f.when]) continue; let v: unknown; if (f.kind === "bool") v = !!raw; else if (f.kind === "parts") { @@ -492,9 +547,7 @@ const deckOf = (form: Form, defaults: DeckSettings): Record<string, unknown> => v = Number.isFinite(Number(s)) ? Number(s) : s; } if (JSON.stringify(v) === JSON.stringify(getPath(defaults, f.key))) continue; - const [a, b] = f.key.split("."); - if (b) out[a] = { ...((out[a] as Record<string, unknown>) ?? {}), [b]: v }; - else out[a] = v; + setPath(out, f.key, v); } return out; }; @@ -643,7 +696,7 @@ export default function OnscreenSection({ } setDoc(j); token.current = j.token; - setForm(formOf(j.deck ?? j.defaults)); + setForm(formOf(j.deck ?? j.defaults, j.defaults)); setFormDirty(false); setErrors(j.errors ?? []); return j; @@ -998,6 +1051,24 @@ export default function OnscreenSection({ const current = schedule ? segmentAt(schedule, t) : null; const postsWin = preview?.posts ? postsWindowAt(preview.posts.windows, t) : null; const backdropId = current && segmentOf.has(current.id) ? current.id : null; + // The footage moving aside for the posts (`posts.shift`): the backdrop -- + // the built segment or the neutral frame, both a whole frame with the + // footage in its box -- is clipped to that box and carried to where the + // build puts it at this moment, on the build's curve. + const move = schedule ? footageAt(schedule, t) : null; + const backdropStyle: React.CSSProperties | undefined = + move && preview + ? (() => { + const { W, H } = preview.geometry; + const f = move.from; + const pc = (v: number, of: number) => `${(v / of) * 100}%`; + return { + transform: backdropTransform(move.from, move.rect, preview.geometry), + transformOrigin: "0 0", + clipPath: `inset(${pc(f.y, H)} ${pc(W - f.x - f.width, W)} ${pc(H - f.y - f.height, H)} ${pc(f.x, W)})`, + }; + })() + : undefined; // The backdrop follows the scrubber: the built segment of whichever clip is // on screen, at the same offset into it. @@ -1404,20 +1475,29 @@ export default function OnscreenSection({ <div className="min-w-0 space-y-2"> {preview ? ( <DeckFrame preview={preview} t={t} texts={texts} testid="onscreen-preview"> - {backdropId ? ( - <video - ref={backdrop} - key={backdropId} - data-testid="onscreen-backdrop" - src={`/api/report/segment?project=${encodeURIComponent(project)}&clip=${encodeURIComponent(backdropId)}`} - muted - playsInline - preload="auto" - className="absolute inset-0 h-full w-full object-contain" - /> - ) : ( - <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} /> - )} + {move && <div className="absolute inset-0" style={{ background: preview.background ?? "#000" }} />} + <div + className="absolute inset-0" + data-testid="onscreen-backdrop-frame" + data-move={move ? move.segment : ""} + data-move-progress={move ? String(Math.round(move.progress * 1000) / 1000) : ""} + style={backdropStyle} + > + {backdropId ? ( + <video + ref={backdrop} + key={backdropId} + data-testid="onscreen-backdrop" + src={`/api/report/segment?project=${encodeURIComponent(project)}&clip=${encodeURIComponent(backdropId)}`} + muted + playsInline + preload="auto" + className="absolute inset-0 h-full w-full object-contain" + /> + ) : ( + <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} /> + )} + </div> {postsWin && preview.posts && ( <PostsOverlay key={`${postsWin.segment}:${postsWin.src ?? "none"}`} @@ -1448,10 +1528,11 @@ export default function OnscreenSection({ <button key={s.id} type="button" - title={`${s.id}${s.hideDeck ? " · panel hidden" : ""}`} + title={`${s.id}${s.hideDeck ? " · panel hidden" : ""}${s.hold ? ` · held ${s.hold} s for its posts` : ""}`} data-seg-jump={s.id} + data-hold={s.hold ?? 0} onClick={() => setT(midOf(s))} - className={`h-full border-r border-[var(--color-ink)] ${ + className={`relative h-full border-r border-[var(--color-ink)] ${ current?.id === s.id ? "bg-[var(--color-sel)]" : s.hideDeck @@ -1461,7 +1542,22 @@ export default function OnscreenSection({ : "bg-[var(--color-line)]" }`} style={{ width: `${(s.duration / schedule.total) * 100}%` }} - /> + > + {s.hold ? ( + // The held tail: the clip's last frame, frozen for its posts. + <span + aria-hidden + data-testid="onscreen-segment-hold" + data-seg-hold={s.id} + className="pointer-events-none absolute inset-y-0 right-0" + style={{ + width: `${(s.hold / s.duration) * 100}%`, + backgroundImage: + "repeating-linear-gradient(135deg, rgba(0,0,0,0.55) 0 2px, rgba(255,255,255,0.18) 2px 4px)", + }} + /> + ) : null} + </button> ))} </div> {(preview?.posts?.windows.length ?? 0) > 0 && ( @@ -1649,7 +1745,7 @@ export default function OnscreenSection({ className={buttonVariants({ size: "sm" })} disabled={!!busy} onClick={() => { - setForm(formOf(doc.defaults)); + setForm(formOf(doc.defaults, doc.defaults)); setFormDirty(true); }} title="Fill the form with the defaults; nothing is written until you save" @@ -1662,8 +1758,10 @@ export default function OnscreenSection({ {GROUPS.map((group) => ( <div key={group.name} className="contents"> <span className="micro pt-1">{group.name}</span> - <div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-1"> + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-1" data-setting-group={group.name}> {group.fields.map((f) => { + // Under a switch that is off: nothing to set, so nothing shown. + if (f.when && !form[f.when]) return null; const v = form[f.key]; const set = (nv: string | boolean) => { setForm((prev) => ({ ...prev, [f.key]: nv })); diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md @@ -54,6 +54,18 @@ find and silently does nothing. The clip bench says so explicitly rather than leaving you to wonder why "extend to sentence end" is inert; set the edge by ear and lock it. +**`xfade` places a segment by its picture's length, `acrossfade` by its sound's — +and they are not the same length.** An encoded segment's audio routinely runs a +few to ~20 ms shorter or longer than its video (AAC frames, priming, the encoder's +own rounding). Chained, the two filters each add their own lengths, so the sound +drifts away from the picture clip by clip and nothing errors: 0.30 s early by the +end of a 17-clip cut, 2.0 s on one with cards. Every frame and every sample is +there; only the sync is wrong, and it is worst at the end, where nobody spot-checks. +Pin each input's sound to its picture's length before the join +(`apad=whole_dur=<d>,atrim=end=<d>,asetpts=PTS-STARTPTS`, `d` the length the +xfade offsets use); `xfadeGraph` does (6771cfb8), and `av-sync.test.mjs` shows +the unpinned graph drifting so the test is known to discriminate. + ## Manifests **`resolve-windows.mjs` is a fixed point, and that was not free.** The manifest @@ -232,6 +244,79 @@ first and filling it later an error (`gsap_timeline_registered_before_async_buil The renderer awaits `document.fonts.ready` before its first seek, so every frame sees the built timeline. +**`perspective` has no `t`, and its `in` counts from 1.** The footage move +for posts animates one `perspective` filter (`eval=frame`), whose expressions +see only `W`, `H`, `in` and `on`. Measured: the first frame has `in = 1`, and +the count runs on through frames a timeline `enable` passed by. The segment's +own clock is therefore `(in-1)/fps`; written as `in/fps` the move starts a +frame late. An identity map (every corner at its default) copies the frame bit +for bit, so the frames before the move need no `enable` at all. + +**`perspective` fills what a shrink uncovers by CLAMPING to the input's edge.** +It does not paint a colour: the band the footage leaves behind is the input's +outermost row or column, smeared. The deck framing puts `palette.bg` there, +but a coded frame's edge pixels are only approximately that colour, so +`fillborders=…:mode=fixed:color=<bg>` pins the outer 2 px first. Without the +deck framing (footage to the frame's edge) the same filter would smear footage +across the gap. + +**A hold no longer than the crossfade is never seen.** The hold is appended to +the outgoing clip and the dissolve into the next one starts `transition` +seconds before that clip's end, so with `transition: 0.5` a 2.5 s hold shows +2.0 s of still frame and then dissolves out of it; a 0.5 s hold is consumed +entirely. The real-ffmpeg test holds 1 s for this reason. verify-build's +freeze check samples only between the hold's start (or the move's landing) +and the dissolve, and skips a hold with under three frames of still picture +there, saying so. + +**A footage move placed BEFORE the hold never reaches the held frames.** The +move's clock is `perspective`'s `in`, which counts the frames that reach it. +Before `tpad` that is the clip's own frames only: a first post that appears +inside the hold (`posts.seconds: 2` with the 2.5 s hold puts it exactly at +the clip's last frame) never moved the footage, and one in the clip's last +half-second froze part-way, while umtool's preview showed it moving. The +move runs after `tpad`, so its clock counts the clones and the frozen frame +glides too. + +**`tpad` holds whole frames; `apad` holds exact seconds.** `stop_duration=2.5` +at 25 fps clones 63 frames (2.52 s) while `apad=pad_dur=2.5` adds exactly +2.5 s, and the concat filter pads the short stream to the long one, so each +held clip in a hard cut grew by up to half a frame and several of them failed +the length check. `postHolds` rounds a hold to whole frames before either +filter sees it. + +**A coloured `fade` converts the whole hard cut to RGB.** `fade=t=out:…:color=<bg>` +accepts RGB formats only (a fade to black also takes YUV), so ffmpeg inserts a +yuv420p→rgb24 scale before it -- and the concat filter, which needs every +segment in one format, then negotiates EVERY other segment to rgb24 too. The +cut's untouched frames came out different (framemd5) from the same graph +without the fade. The end fade is a `geq` blend toward bg's limited-range +BT.601 Y′CbCr instead (`#12101a` is 31/132/128, what `pad` wrote into the +segments), enabled only from its first frame; `geq` truncates, so each plane +adds 0.5 to round. + +**`afade` out writes digital silence after its fade, and copies every sample +before it.** That makes it the mute for `muteFrom`: samples before the fade are +the clip's own bit for bit (A/V sync cannot move), and samples after it are +zeros, not a quiet signal. A fade that ENDS at the mute point keeps a sound +that starts there out entirely. + +**A label fitted to a length is measured as ink, not as a box.** CSS +`letter-spacing` is added after the LAST letter too, and a box's length +includes each end glyph's side bearing, so sizing the deck's QR host by its +element's length leaves it short of the code by the trailing tracking and the +bearings. `fitHost` measures the string's ink in the loaded face with canvas +`measureText` (`actualBoundingBoxLeft + actualBoundingBoxRight`), adds the +tracking between letters only, scales the font size (everything in it is in em) +and indents the first bearing away. Measured on the ferret cut: ink rows 31–180, +the code's 31–180. + +**`xfade` hands on its own pixel format.** Even the frames before its offset, +which are the first input's, come out as yuv444 rather than the input's +yuv420p, so a framemd5 of the crossfade's output never equals the segment's +own. "Untouched" for a crossfaded input means equal to the graph the build ran +before (the test compares the two graphs), not equal to the file. + **`build-video.mjs` must not import a chrome PAGE module statically.** umtool's server bundles build-video into every report route, and Turbopack turns `chrome-deck.mjs`'s `new URL("./assets/gsap.min.js", import.meta.url)` @@ -245,6 +330,36 @@ not a wall around the page modules: umtool's preview helper posts, and its routes build. A capped umtool build is the gate that catches it — the unit tests run in plain Node and pass either way. +**A teaser's page module is reached by a dynamic import from compose-chrome +too.** `chrome-teaser.mjs` carries the display face as `new URL(…, +import.meta.url)`, the same kind of asset URL as the deck's GSAP. compose-chrome +is imported statically by umtool's preview helper, so it loads the teaser page +only inside the `teaser` region's branch; nothing umtool bundles evaluates that +URL unless a teaser is composed. The face's file name has brackets +(`Archivo[wdth,wght].ttf`), so the copy in the project is +`assets/TeaserDisplay.ttf`, and the page never puts the vendored name in a URL. + +**`random()` in an ffmpeg expression advances only where it is evaluated.** +Its state is a variable that each call updates, and `if()` evaluates one +branch: a noise term gated to a hit's span draws its numbers only inside that +span, so adding or removing one hit changes the noise of every hit after it. +The teaser's noise is a hash of the sample number +(`fract(sin(n·12.9898+78.233)·43758.5453)`), the same whatever else is in the +graph, which is also what lets the onset test difference the graph with and +without one hit. + +**`alimiter` auto-levels and delays by default.** `level` is on by default and +normalises the output upward toward the limit, so a quiet card comes out loud. +`latency` is off by default, which leaves the output late by the lookahead +(the attack). The teaser's limiter is `level=0:latency=1`: measured, every +hit's onset is then on its pop's sample. + +**Sub-bass barely registers in LUFS.** K-weighting rolls off below ~100 Hz, so +a boom at 40 Hz that peaks at −6 dBFS measures far quieter than dialogue at the +same peak. The teaser's hits carry an octave for body and a band-passed noise +punch, and the limiter takes their transients, so the card can sit within +about 2 LU of the cut and still peak at −6 dBFS. + ## Rail strips and rolling counters **A slab that slides moves text that did not change.** The tally used to be four diff --git a/umtool/e2e/clip-bench.spec.ts b/umtool/e2e/clip-bench.spec.ts @@ -57,6 +57,7 @@ const readClipIn = (project: string, id: string) => { verdict?: string; cutStart?: number; cutEnd?: number; + muteFrom?: number; }[]; }; return m.timeline.find((e) => e.id === id)!; @@ -804,13 +805,266 @@ test("moving the end auditions the END", async ({ page }) => { await page.locator("body").press("."); // Clamped to the cached file, which ends at 9.00 -- a drag never downloads. const to = Math.min(before.end + 0.05, 9); - const t = await page - .getByTestId("clip-video") - .evaluate((el: HTMLVideoElement) => el.currentTime); - // vid1_0.00-9.00 starts at 0.00, so file time IS source time here. The four - // seconds ENDING on the new edge, not the four after the start. - expect(t).toBeGreaterThan(to - 4 - 0.4); - expect(t).toBeLessThan(to - 4 + 2); + // The four seconds ENDING on the new edge, not the four after the start -- + // read from the bench's own record of what it scheduled, on the audio clock. + const playback = page.locator("[data-playback]"); + await expect(playback).toHaveAttribute("data-play-engine", "webaudio", { timeout: 15_000 }); + expect(Number(await playback.getAttribute("data-play-to"))).toBeCloseTo(to, 2); + expect(Number(await playback.getAttribute("data-play-from"))).toBeCloseTo(to - 4, 2); + // And the picture follows it there, muted: vid1_0.00-9.00 starts at 0.00, + // so file time IS source time. + await expect + .poll(() => page.getByTestId("clip-video").evaluate((el: HTMLVideoElement) => el.currentTime)) + .toBeGreaterThan(to - 4 - 0.4); +}); + +// --------------------------------------------------------------------------- +// Exact playback. +// +// Every bounded range plays from the DECODED audio with Web Audio, started and +// stopped on the audio clock. The element it replaces was stopped from +// `timeupdate`, which overran the end by up to a quarter of a second -- the +// difference between a cut between two words and one into the next. +// +// The proof is the signal, not the bench's word for it: TAP (below) is an +// AudioWorklet put between the bench's audio and the speakers that records the +// first and last NON-ZERO frame it is handed, on the context's own frame +// clock. The bench publishes when it scheduled the stop (`data-play-ctx-stop`); +// the two must agree to within one render quantum (128 frames). +// +// vid1 is a 440 Hz tone with silences at 2.9-3.1 and 5.9-6.1, so a selection +// ending at 5.00 ends INSIDE the tone: the last sound is the stop, not a +// silence that happened to come first. +// --------------------------------------------------------------------------- + +const TAP = `(() => { + const code = \`class Tap extends AudioWorkletProcessor { + constructor() { super(); this.first = -1; this.last = -1; + this.port.onmessage = (e) => { + if (e.data === "reset") { this.first = -1; this.last = -1; } + if (e.data === "read") this.port.postMessage({ first: this.first, last: this.last, sr: sampleRate }); + }; + } + process(inputs) { + const ch = inputs[0] && inputs[0][0]; + if (ch) for (let i = 0; i < ch.length; i += 1) if (ch[i] !== 0) { + const f = currentFrame + i; if (this.first < 0) this.first = f; this.last = f; + } + return true; + } + } + registerProcessor("tap", Tap);\`; + const url = URL.createObjectURL(new Blob([code], { type: "application/javascript" })); + const Orig = window.AudioContext; + if (!Orig) return; + const conn = AudioNode.prototype.connect; + window.__tapRead = () => new Promise((res) => { + const n = window.__tapNode; + if (!n) return res(null); + n.port.onmessage = (e) => res(e.data); + n.port.postMessage("read"); + }); + window.__tapReset = () => window.__tapNode && window.__tapNode.port.postMessage("reset"); + window.AudioContext = class extends Orig { + constructor(...a) { + super(...a); + const ctx = this; + ctx.audioWorklet.addModule(url).then(() => { + const n = new AudioWorkletNode(ctx, "tap", { outputChannelCount: [1] }); + conn.call(n, ctx.destination); + ctx.__tap = n; + window.__tapNode = n; + for (const src of ctx.__pending || []) conn.call(src, n); + ctx.__pending = []; + }); + } + }; + AudioNode.prototype.connect = function (dest, ...rest) { + const r = conn.call(this, dest, ...rest); + const tap = this.context && this.context.__tap; + if (dest === this.context.destination && this !== tap) { + if (tap) conn.call(this, tap); + else (this.context.__pending = this.context.__pending || []).push(this); + } + return r; + }; +})();`; + +type Tap = { first: number; last: number; sr: number } | null; +const tapRead = (page: import("@playwright/test").Page) => + page.evaluate(() => (window as unknown as { __tapRead: () => Promise<Tap> }).__tapRead()); +const tapReset = (page: import("@playwright/test").Page) => + page.evaluate(() => (window as unknown as { __tapReset: () => void }).__tapReset()); + +/** The bench's record of its last playback, as numbers. */ +const lastPlay = async (page: import("@playwright/test").Page) => { + const p = page.locator("[data-playback]"); + const n = async (k: string) => Number(await p.getAttribute(`data-play-${k}`)); + return { + engine: await p.getAttribute("data-play-engine"), + from: await n("from"), + to: await n("to"), + rate: await n("rate"), + ctxStart: await n("ctx-start"), + ctxStop: await n("ctx-stop"), + }; +}; + +/** Start a playback with `go` and wait until the bench says it has stopped. */ +const playThrough = async (page: import("@playwright/test").Page, go: () => Promise<void>) => { + const p = page.locator("[data-playback]"); + await tapReset(page); + await go(); + await expect(p).toHaveAttribute("data-play-state", "playing", { timeout: 10_000 }); + await expect(p).toHaveAttribute("data-play-state", "stopped", { timeout: 20_000 }); + // `ended` reaches the page a task after the audio thread stopped; the tap's + // reply is one more message behind it. + await page.waitForTimeout(100); + return { play: await lastPlay(page), tap: await tapRead(page) }; +}; + +/** Put c01 back to its fixture window with no mute mark. */ +const resetC01 = async (request: import("@playwright/test").APIRequestContext) => { + const { token: t } = await token(request, "c01"); + const r = await request.put("/api/report/window", { + data: { project: PROJECT, clip: "c01", start: 3, end: 6, muteFrom: "", token: t }, + }); + expect(r.ok()).toBeTruthy(); +}; + +test("a play-selection stops within one audio render quantum of its end, at any speed", async ({ + page, + request, +}) => { + await resetC01(request); + await page.addInitScript(TAP); + await page.goto(bench("c01")); + await expect(page.locator("[data-playback]")).toHaveAttribute("data-audio-state", "ready", { + timeout: 15_000, + }); + await keyboardLive(page); + // 6.00 -> 5.00 (shift-, is half a second): the end now lies in the tone. + // Each press auditions the end it moved, which also opens the audio output + // and puts the tap in the path. + await page.locator("body").press("Shift+Comma"); + await page.locator("body").press("Shift+Comma"); + await expect + .poll(async () => Number(await page.locator("[data-playback]").getAttribute("data-play-to")), { + timeout: 10_000, + }) + .toBeCloseTo(5, 3); + await expect(page.locator("[data-playback]")).toHaveAttribute("data-play-state", "stopped", { + timeout: 10_000, + }); + + for (const rate of ["1", "2"]) { + await page.locator("[data-playback-rate]").selectOption(rate); + const { play, tap } = await playThrough(page, () => page.locator("[data-play-selection]").click()); + expect(play.engine).toBe("webaudio"); + expect(play.from).toBeCloseTo(3, 3); + expect(play.to).toBeCloseTo(5, 3); + expect(play.rate).toBe(Number(rate)); + expect(tap, "the tap saw the bench's audio").not.toBeNull(); + const { first, last, sr } = tap!; + expect(last).toBeGreaterThan(first); + const quantum = 128; + // The STOP: the last sound is the scheduled stop, to within a quantum. + const stopFrame = Math.round(play.ctxStop * sr); + expect(Math.abs(last + 1 - stopFrame), `stopped ${last + 1 - stopFrame} frames from the end at ${rate}x`).toBeLessThanOrEqual(quantum); + // And nothing sounded before the scheduled start. (Not "the first sound + // IS the start": 3.00 is inside the silence at 2.9-3.1, so the first + // non-zero frame is the tone coming back.) + expect(first).toBeGreaterThanOrEqual(Math.round(play.ctxStart * sr) - quantum); + } +}); + +test("the mute mark: picked on the waveform, heard at once, saved, shown, cleared", async ({ + page, + request, +}) => { + await resetC01(request); + await page.addInitScript(TAP); + await page.goto(bench("c01")); + const playback = page.locator("[data-playback]"); + await expect(playback).toHaveAttribute("data-audio-state", "ready", { timeout: 15_000 }); + await expect(page.locator("[data-mute]")).toHaveAttribute("data-mute", ""); + await expect(page.locator("[data-mute-marker]")).toHaveCount(0); + + // Armed, the next click on the waveform places the mark: at 5.00 of the + // cached 0.00-9.00, the tone between the two silences. + await page.locator("[data-mute-pick]").click(); + await expect(page.locator("[data-mute-pick=armed]")).toBeVisible(); + const layer = page.locator("[data-mute-pick-layer]"); + const box = (await layer.boundingBox())!; + const { play, tap } = await playThrough(page, () => + page.mouse.click(box.x + (box.width * 5) / 9, box.y + box.height / 2), + ); + const marker = page.locator("[data-mute-marker]"); + await expect(marker).toBeVisible(); + const mark = Number(await marker.getAttribute("data-mute-marker")); + expect(mark).toBeGreaterThan(4.9); + expect(mark).toBeLessThan(5.1); + await expect(page.locator("[data-mute-pick-layer]")).toHaveCount(0); + await expect(page.locator("[data-mute-unsaved]")).toContainText("was none"); + + // The pick plays ACROSS the mark -- up to three seconds before it (here + // from the selection's start), to the end of the selection -- and the sound + // goes at the mark: the fade (MUTE_FADE, the build's) ENDS there, so the + // last non-zero frame is at the mark, not the 6.00 the tone runs on to. + expect(play.engine).toBe("webaudio"); + expect(play.from).toBeCloseTo(Math.max(3, mark - 3), 2); + expect(play.to).toBeCloseTo(6, 2); + const { last, sr } = tap!; + const silentAt = play.ctxStart + (mark - play.from); + expect(Math.abs((last + 1) / sr - silentAt), "silent at the mark, not at the end").toBeLessThan(0.01); + + // Saved by `save window`, like the edges; the manifest has it. + await page.locator("[data-save-window]").click(); + await expect(page.locator("[data-bench-note]")).toContainText("saved"); + expect(readClip("c01").muteFrom).toBeCloseTo(mark, 2); + await expect(page.locator("[data-mute-unsaved]")).toHaveCount(0); + + // A reload shows the saved mark. + await page.reload(); + await expect(page.locator("[data-mute-marker]")).toHaveAttribute("data-mute-marker", String(readClip("c01").muteFrom)); + await expect(page.locator("[data-mute-from]")).toBeVisible(); + + // Moved like an edge, and cleared. + await keyboardLive(page); + await page.locator("body").press("Shift+Semicolon"); + await expect(page.locator("[data-mute-marker]")).toHaveAttribute( + "data-mute-marker", + String(Number((readClip("c01").muteFrom! - 0.5).toFixed(2))), + ); + await page.locator("[data-mute-clear]").click(); + await expect(page.locator("[data-mute-marker]")).toHaveCount(0); + await expect(page.locator("[data-mute-unsaved]")).toBeVisible(); + await page.locator("[data-save-window]").click(); + await expect(page.locator("[data-bench-note]")).toContainText("saved"); + expect(readClip("c01").muteFrom).toBeUndefined(); + + // The writer's rule, through the route: inside the clip, or refused. + const { token: t } = await token(request, "c01"); + const out = await request.put("/api/report/window", { + data: { project: PROJECT, clip: "c01", muteFrom: 8, token: t }, + }); + expect(out.status()).toBe(400); + expect(((await out.json()) as { error: string }).error).toMatch(/must lie inside the clip 3–6/); +}); + +test("a decode that fails falls back to the element, and the bench says so", async ({ page }) => { + await page.route("**/api/report/audio**", (r) => + r.fulfill({ status: 422, json: { error: "this file has no audio track" } }), + ); + await page.goto(bench("c01")); + const fallback = page.locator("[data-playback-fallback]"); + await expect(fallback).toBeVisible({ timeout: 15_000 }); + await expect(fallback).toContainText("this file has no audio track"); + await page.locator("[data-play-selection]").click(); + await expect(page.locator("[data-playback]")).toHaveAttribute("data-play-engine", "element"); + await expect + .poll(() => page.getByTestId("clip-video").evaluate((el: HTMLVideoElement) => !el.paused)) + .toBe(true); }); test("the playback speed is this browser's, and it survives a reload", async ({ page }) => { @@ -841,6 +1095,11 @@ test("auto-audition plays the clip you walk onto", async ({ page, request }) => await page.locator("[data-clip-nav=next]").click(); await expect(page.locator("[data-bench=c04]")).toBeVisible(); + // The whole clip, from the decoded audio -- and the picture with it. + const playback = page.locator("[data-playback]"); + await expect(playback).toHaveAttribute("data-play-engine", "webaudio", { timeout: 15_000 }); + expect(Number(await playback.getAttribute("data-play-from"))).toBeCloseTo(15, 2); + expect(Number(await playback.getAttribute("data-play-to"))).toBeCloseTo(18, 2); await expect .poll( () => page.getByTestId("clip-video").evaluate((el: HTMLVideoElement) => !el.paused), diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -1483,13 +1483,19 @@ const deckManifest = (slug, title, timeline) => { // onscreen-fixture: the On-screen section and the bench's fields WRITE here -- // the switch, the settings, the table, a stale token. Never built: its // schedule is the estimate, which is the state a report is in when titles are -// first written. A card, because a row is any entry and not only a clip. +// first written. A card, because a row is any entry and not only a clip; a +// teaser last, because the page and the table must name one by its lines. const ONSCREEN = writeProject( "onscreen-fixture", deckManifest("onscreen-fixture", "The On-screen Fixture", [ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because" }, { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence" }, { type: "card", id: "k01", style: "chapter", seconds: 3, heading: "A card" }, + { + type: "teaser", id: "t01", seconds: 6, + lines: ["Next Season", { text: "The Big Build in the Valley", break: "in the Valley" }, "Spring 2027"], + tail: "?", + }, ]), ); diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts @@ -1,8 +1,8 @@ -import { test, expect, type APIRequestContext, type Page } from "@playwright/test"; +import { test, expect, type APIRequestContext, type Locator, type Page } from "@playwright/test"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { postWindows } from "umtool-report-to-video/deck"; +import { deckGeometry, postWindows, postsGeometry, shiftedFootage } from "umtool-report-to-video/deck"; // --------------------------------------------------------------------------- // POSTS on the on-screen deck, as umtool edits them: the Posts table under the @@ -12,7 +12,19 @@ import { postWindows } from "umtool-report-to-video/deck"; // One project, onscreen-posts-fixture (make-fixture.mjs): two dated clips and // a card, three posts whose dates put them on c01 ("first"), c01 ("date") and // c02 ("date"). Never built, so every timing is the estimate's. Each test puts -// the posts back to automatic and shown through the route before it starts. +// the posts back to automatic and shown, and the deck back to its defaults, +// through the routes before it starts. +// +// With the defaults (posts.seconds 4, hold 2.5, shift on) and the fixture's +// 0.2 s crossfade, the estimate is: +// c01 0 → 5.533 3 s clip + a 2.5 s hold, which at the fixture's +// 15 fps is 38 frames (2.533 s); two posts in +// 5.333 s share it, p-early at 0, p-mid at 2.667 +// c02 5.333 → 10.867 the same; p-late at 6.667, 4 s before its leave +// at 10.667 +// k01 10.667 → 13.667 the card +// and the footage moves aside as each clip's first post appears (c01 at 0, +// c02 at 6.667), with the posts column at the frame's right edge. // // The posts region's COMPOSITION is compose-chrome's (`region: "posts"`): the // preview test asserts every window composes and its page reports @@ -55,8 +67,54 @@ async function rows(request: APIRequestContext): Promise<Record<string, PostRow> return Object.fromEntries(j.posts.map((p) => [p.id, p])); } -const reset = (request: APIRequestContext) => - putPosts(request, Object.fromEntries(IDS.map((id) => [id, { attachTo: null, hide: false }]))); +const DECK_ON = { engine: "hyperframes", layout: "deck", deck: {} }; + +async function putChrome(request: APIRequestContext, chrome: unknown) { + const r = await request.put("/api/report/chrome", { data: { project: PROJECT, chrome, token: await token(request) } }); + expect(r.ok(), await r.text()).toBeTruthy(); +} + +const reset = async (request: APIRequestContext) => { + await putChrome(request, DECK_ON); + await putPosts(request, Object.fromEntries(IDS.map((id) => [id, { attachTo: null, hide: false }]))); +}; + +type Rect = { x: number; y: number; width: number; height: number }; +type Preview = { + schedule: { + total: number; + segments: { id: string; start: number; duration: number; end: number; hold?: number }[]; + posts: { id: string; segment: string; appear: number; out: [number, number] }[]; + moves?: { segment: string; at: number; seconds: number; from: Rect; to: Rect }[]; + }; + posts: { geometry: Rect; windows: { segment: string; from: number; to: number; src?: string; error?: string }[] }; +}; +const previewOf = async (request: APIRequestContext, extra: Record<string, unknown> = {}) => + (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT, ...extra } })).json()) as Preview; + +/** Put the scrubber at `t` (seconds) and wait for the clock to say so. */ +async function seek(page: Page, t: number) { + await page.getByTestId("onscreen-scrubber").fill(String(t)); + await expect(page.getByTestId("onscreen-scrubber")).toHaveValue(String(t)); +} + +/** Fill a field the page may still be re-rendering from a load: retry until the value holds. */ +async function fillSure(input: Locator, value: string) { + await expect(async () => { + await input.fill(value); + await expect(input).toHaveValue(value, { timeout: 1000 }); + }).toPass({ timeout: 15_000 }); +} + +/** The backdrop's computed transform as [scaleX, scaleY, translateX px, translateY px, frame width px], or null for none. */ +const backdropMatrix = (page: Page) => + page.getByTestId("onscreen-backdrop-frame").evaluate((el) => { + const tr = getComputedStyle(el).transform; + if (!tr || tr === "none") return null; + const m = new DOMMatrixReadOnly(tr); + // offsetWidth: the frame's laid-out width, before the transform scales it. + return [m.a, m.d, m.e, m.f, (el as HTMLElement).offsetWidth]; + }); async function openSection(page: Page) { await page.goto(`/browse/${PROJECT}`); @@ -210,16 +268,34 @@ test("the preview composes the posts region per window and overlays it while the }) => { // The route: one window per clip that carries posts, the deck's own // postWindows over the schedule it returns, at postsGeometry. - const pv = (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT } })).json()) as { - schedule: Parameters<typeof postWindows>[0] & { total: number }; - posts: { - geometry: { x: number; y: number; width: number; height: number }; - windows: { segment: string; from: number; to: number; src?: string; error?: string }[]; - }; - }; - expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule)); + const pv = await previewOf(request); + expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule as Parameters<typeof postWindows>[0])); expect(pv.posts.windows.map((w) => w.segment)).toEqual(["c01", "c02"]); expect(pv.posts.geometry.width).toBe(600); + + // The defaults' timing: 4 s per post, each carrying clip held 2.5 s, every + // start and the total measured with the holds. + expect(pv.schedule.segments.map((s) => [s.id, s.start, s.duration, s.hold ?? 0])).toEqual([ + // The hold is rounded to whole frames: 2.5 s at the fixture's 15 fps is + // 37.5 frames, so 38 -- 2.533 s. + ["c01", 0, 5.533, 2.533], + ["c02", 5.333, 5.533, 2.533], + ["k01", 10.667, 3, 0], + ]); + expect(pv.schedule.total).toBe(13.667); + expect(pv.schedule.posts.map((p) => [p.id, p.segment, p.appear, p.out])).toEqual([ + ["p-early", "c01", 0, [5.333, 5.533]], + ["p-mid", "c01", 2.667, [5.333, 5.533]], + ["p-late", "c02", 6.667, [10.667, 10.867]], + ]); + expect(pv.posts.windows.map(({ from, to }) => [from, to])).toEqual([[0, 5.533], [6.667, 10.867]]); + // The footage makes room: one move per carrying clip, and the column at the frame's edge. + const render = readManifest().render; + expect(pv.schedule.moves?.map((m) => [m.segment, m.at, m.seconds])).toEqual([["c01", 0, 0.6], ["c02", 6.667, 0.6]]); + expect(pv.schedule.moves?.[0].from).toEqual(deckGeometry(render).footage); + expect(pv.schedule.moves?.[0].to).toEqual(shiftedFootage(render)); + expect(pv.posts.geometry).toEqual(postsGeometry(render)); + expect(pv.posts.geometry.x).toBe(1920 - 24 - 600); // Every window composes: compose-chrome draws the posts region. for (const w of pv.posts.windows) { expect(w.error).toBeUndefined(); @@ -235,6 +311,28 @@ test("the preview composes the posts region per window and overlays it while the await openSection(page); await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); await expect(page.locator("[data-posts-window]")).toHaveCount(2); + + // The scrubber runs the held length, and the window marks sit on its clock. + await expect(page.getByTestId("onscreen-scrubber")).toHaveAttribute("max", "13.667"); + await expect(page.getByTestId("onscreen-time")).toContainText("/ 0:13.7"); + for (const [seg, from, to] of [["c01", 0, 5.533], ["c02", 6.667, 10.867]] as const) { + const style = await page.locator(`[data-posts-window="${seg}"]`).evaluate((el) => [ + parseFloat((el as HTMLElement).style.left), + parseFloat((el as HTMLElement).style.width), + ]); + expect(style[0]).toBeCloseTo((from / 13.667) * 100, 2); + expect(style[1]).toBeCloseTo(((to - from) / 13.667) * 100, 2); + } + + // A held segment shows its hold: a hatched tail, hold/duration of its block. + await expect(page.getByTestId("onscreen-segment-hold")).toHaveCount(2); + await expect(page.locator('[data-seg-jump="c01"]')).toHaveAttribute("data-hold", "2.533"); + await expect(page.locator('[data-seg-jump="k01"]')).toHaveAttribute("data-hold", "0"); + await expect(page.locator('[data-seg-jump="c01"]')).toHaveAttribute("title", /held 2\.533 s for its posts/); + const block = (await page.locator('[data-seg-jump="c02"]').boundingBox())!; + const tail = (await page.locator('[data-seg-hold="c02"]').boundingBox())!; + expect(tail.width / block.width).toBeCloseTo(2.533 / 5.533, 1); + expect(Math.abs(tail.x + tail.width - (block.x + block.width))).toBeLessThan(2); // Outside every window: nothing over the footage. k01 starts after the last. await page.locator('[data-seg-jump="k01"]').click(); await expect(page.getByTestId("onscreen-current")).toHaveText("k01"); @@ -255,4 +353,95 @@ test("the preview composes the posts region per window and overlays it while the const W = 1920; expect(Math.abs((box.x - frame.x) / frame.width - pv.posts.geometry.x / W)).toBeLessThan(0.01); expect(Math.abs(box.width / frame.width - pv.posts.geometry.width / W)).toBeLessThan(0.01); + + // The backdrop moves inside the window: the window mark put the scrubber at + // 8.7, past c02's move (6.667 → 7.267), so the footage is all the way aside... + const from = pv.schedule.moves![1].from; + const to = pv.schedule.moves![1].to; + const backdrop = page.getByTestId("onscreen-backdrop-frame"); + await expect(backdrop).toHaveAttribute("data-move", "c02"); + await expect(backdrop).toHaveAttribute("data-move-progress", "1"); + const aside = (await backdropMatrix(page))!; + expect(aside[0]).toBeCloseTo(to.width / from.width, 3); + expect(aside[1]).toBeCloseTo(to.height / from.height, 3); + // ...its box's left edge where the build puts it (in frame pixels)... + expect((aside[2] / aside[4]) * W + aside[0] * from.x).toBeCloseTo(to.x, 0); + // ...half way through the move at its middle, on the smoothstep curve... + // (the move's middle from the schedule itself; the scrubber steps in 0.01 s, + // so the progress lands within a step of 0.5, not on it) + const mid = pv.schedule.moves![1].at + pv.schedule.moves![1].seconds / 2; + await seek(page, Math.round(mid * 100) / 100); + await expect + .poll(async () => Math.abs(Number(await backdrop.getAttribute("data-move-progress")) - 0.5)) + .toBeLessThan(0.02); + const half = (await backdropMatrix(page))!; + expect(half[0]).toBeCloseTo((1 + to.width / from.width) / 2, 2); + // ...in its box before the move... + await seek(page, 6.5); + await expect(page.getByTestId("onscreen-current")).toHaveText("c02"); + await expect(backdrop).toHaveAttribute("data-move-progress", "0"); + expect((await backdropMatrix(page))?.[0] ?? 1).toBeCloseTo(1, 3); + // ...and not moved at all over a segment that carries no posts. + await seek(page, 12); + await expect(page.getByTestId("onscreen-current")).toHaveText("k01"); + await expect(backdrop).toHaveAttribute("data-move", ""); + expect(await backdropMatrix(page)).toBeNull(); +}); + +test("make room off in the settings writes shift: false, the form keeps it, and the column goes back inside the footage", async ({ + page, + request, +}) => { + await openSection(page); + await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); + const shift = page.getByTestId("onscreen-setting-posts.shift"); + await expect(shift).toBeChecked(); + await expect(page.getByTestId("onscreen-setting-posts.hold")).toHaveValue("2.5"); + await expect(page.getByTestId("onscreen-setting-posts.seconds")).toHaveValue("4"); + await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveValue("0.86"); + await expect(page.getByTestId("onscreen-setting-posts.shift.seconds")).toHaveValue("0.6"); + + await shift.uncheck(); + // Off has no scale or move to set. + await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveCount(0); + await page.getByTestId("onscreen-settings-save").click(); + await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { shift: false } }); + + // The schedule has no moves and the column is inside the footage box again. + const render = readManifest().render; + const pv = await previewOf(request); + expect(pv.schedule.moves).toBeUndefined(); + expect(pv.posts.geometry).toEqual(postsGeometry(render)); + const f = deckGeometry(render).footage; + expect(pv.posts.geometry.x).toBe(f.x + f.width - 24 - 600); + + // Saving another setting keeps it: the form never drops `shift: false`. + await page.reload(); + await expect(page.getByTestId("onscreen-setting-posts.shift")).not.toBeChecked(); + await fillSure(page.getByTestId("onscreen-setting-posts.hold"), "1.5"); + await page.getByTestId("onscreen-settings-save").click(); + await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ + posts: { hold: 1.5, shift: false }, + }); + + // In the live preview: the overlay inside the footage box, and the backdrop where it always was. + await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); + await page.locator('[data-posts-window="c02"]').click(); + const overlay = page.getByTestId("onscreen-posts-preview"); + await expect(overlay).toHaveAttribute("data-segment", "c02"); + await expect(overlay).toHaveAttribute("data-posts-ready", "1", { timeout: 30_000 }); + const frame = (await page.getByTestId("onscreen-preview").boundingBox())!; + const box = (await overlay.boundingBox())!; + const W = 1920; + expect((box.x - frame.x) / frame.width).toBeGreaterThanOrEqual(f.x / W - 0.005); + expect((box.x + box.width - frame.x) / frame.width).toBeLessThanOrEqual((f.x + f.width) / W + 0.005); + expect(Math.abs((box.x - frame.x) / frame.width - (f.x + f.width - 24 - 600) / W)).toBeLessThan(0.01); + await expect(page.getByTestId("onscreen-backdrop-frame")).toHaveAttribute("data-move", ""); + expect(await backdropMatrix(page)).toBeNull(); + + // On again: the default, so nothing under posts but the hold. + await page.getByTestId("onscreen-setting-posts.shift").check(); + await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveValue("0.86"); + await page.getByTestId("onscreen-settings-save").click(); + await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { hold: 1.5 } }); }); diff --git a/umtool/e2e/onscreen.spec.ts b/umtool/e2e/onscreen.spec.ts @@ -268,6 +268,11 @@ test("the preview is the composition: it reports ready, and typing reaches it be const cardAuto = await row(page, "k01").getByTestId("onscreen-title").getAttribute("placeholder"); expect(cardAuto).toBe("A card"); await expect(frame.locator('[data-seg="k01"] .deck-title')).toHaveText(cardAuto!); + // A teaser is a card row named by its lines, here and on the report page's timeline. + const teaserTitle = "Next Season — The Big Build in the Valley — Spring 2027 ?"; + await expect(frame.locator('[data-seg="t01"]')).toHaveCount(1); + expect(await row(page, "t01").getByTestId("onscreen-title").getAttribute("placeholder")).toBe(teaserTitle); + await expect(page.locator('li[data-entry="t01"][data-kind="teaser"]')).toContainText(teaserTitle); // Typing is patched into the frame by postMessage: nothing is saved. await fillSure(row(page, "c02").getByTestId("onscreen-title"), "Typed, not saved"); diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs @@ -11,6 +11,7 @@ import { DEFAULT_VARIANT, cachedWindowsFor } from "umtool-report-to-video/build- import { rawCacheOf } from "../report/raw-cache.mjs"; import { CHANNELS_DIR } from "../paths.mjs"; import { channelName, cleanTitle } from "umtool-report-to-video/attribution"; +import { teaserTitle } from "umtool-report-to-video/deck"; /** * Where a build's per-entry segments live. @@ -1110,7 +1111,8 @@ export async function readClipDetail(dir, { manifest = null } = {}) { // and 500'd the whole project page. Every other real manifest is clips only, // which is exactly why this survived testing. if (e.type !== "clip") { - entries.push({ ...e, kind: e.type ?? "entry" }); + // A teaser has no heading or title of its own: its row names its lines. + entries.push({ ...e, kind: e.type ?? "entry", ...(e.type === "teaser" ? { label: teaserTitle(e) } : {}) }); continue; } const chan = channelFor(m, e); diff --git a/umtool/lib/report/export.mjs b/umtool/lib/report/export.mjs @@ -18,6 +18,7 @@ import { readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_VARIANT, selectVariant, segmentOffsets } from "umtool-report-to-video/build-video"; import { citeUrlFor, channelFor, readAvailability, readManifest } from "../projects/report.mjs"; +import { teaserTitle } from "umtool-report-to-video/deck"; import { outDirState } from "./storage.mjs"; export const EXPORT_FORMATS = ["toc-bbcode", "toc-markdown", "description", "chapters"]; @@ -61,11 +62,13 @@ export function parseFfmeta(text) { } /** The chapter title the build would print: `chapter`, else the entry's own words. */ -const titleOf = (e, i) => e.chapter ?? (e.type === "clip" ? `${i + 1}. ${e.video}` : e.title ?? e.heading ?? `Card ${i + 1}`); +const titleOf = (e, i) => + e.chapter ?? + (e.type === "clip" ? `${i + 1}. ${e.video}` : e.type === "teaser" ? teaserTitle(e) : e.title ?? e.heading ?? `Card ${i + 1}`); /** * Deliverable-second offsets for every entry of the chosen variant. - * @returns {Promise<{ starts: number[], source: "ffmeta"|"segments", file: string, note?: string } | { error: string }>} + * @returns {Promise<{ starts: number[], source: "ffmeta"|"schedule"|"segments", file: string, note?: string } | { error: string }>} */ export async function chapterOffsets(dir, manifest, variant) { const entries = manifest.timeline ?? []; @@ -92,6 +95,21 @@ export async function chapterOffsets(dir, manifest, variant) { return { starts: chapters.map((c) => c.start), source: "ffmeta", file }; } + // No ffmeta, but a deck schedule for this cut: its starts are the cut's own, + // holds included -- the segment files alone do not know a clip is held. + const sched = await readFile(path.join(outDir, variant, "schedule.json"), "utf8") + .then((t) => JSON.parse(t), () => null); + if (sched?.kind === "deck" && Array.isArray(sched.segments) && + sched.segments.map((x) => x.id).join("\n") === entries.map((e) => e.id).join("\n")) { + const D = sched.transition ?? 0; + return { + starts: sched.segments.map((x, i) => (i === 0 ? 0 : x.start + D)), + source: "schedule", + file: path.join(outDir, variant, "schedule.json"), + note: "no chapters.ffmeta; offsets from the deck's schedule.json", + }; + } + // No ffmeta: the pipeline's own offset arithmetic over the segments on disk. const segDirs = [path.join(outDir, variant, "segments"), path.join(outDir, "segments")]; for (const segDir of segDirs) { diff --git a/umtool/lib/report/footage-move.mjs b/umtool/lib/report/footage-move.mjs @@ -0,0 +1,90 @@ +// Where the footage is at a moment of the cut, for umtool's live preview. +// +// `posts.shift` moves the footage aside while a clip's posts are up: the +// schedule's `moves` (deck.mjs `footageMoves`) say when and from which box to +// which. The BUILD draws the move; the preview only has to put its backdrop -- +// a still of the built segment, or the neutral frame -- where the build will +// have put the footage, so a scrubbed moment reads as the render. Pure: no +// DOM, no React, so the arithmetic is unit-tested and the page only applies it. +// +// The rule the build follows, read here the same way: +// - a move belongs to ONE segment, and only that segment's footage moves: +// the next one comes in at the normal box through the transition, so once +// the scrubber is in the next segment there is no move; +// - before `at` the footage is in its box; over `seconds` it eases to `to`; +// after that it stays at `to` to the end of the segment (the hold included). +// +// The easing is smoothstep, p²(3 − 2p): the curve the build's move uses. + +/** @typedef {{ x: number, y: number, width: number, height: number }} Rect */ +/** @typedef {{ segment: string, at: number, segmentAt: number, seconds: number, from: Rect, to: Rect }} Move */ + +/** smoothstep on [0, 1], clamped: 0 and 1 outside it. */ +export function ease(p) { + if (!(p > 0)) return 0; + if (p >= 1) return 1; + return p * p * (3 - 2 * p); +} + +/** + * The segment on screen at `t`: the last one that has started. Past the end, + * the last; before the first, the first. The preview's own rule (its + * `segmentAt`), so the backdrop and the move agree on whose footage it is. + * + * @param {{ segments: Array<{ id: string, start: number }> }} schedule + * @param {number} t + */ +export function segmentIdAt(schedule, t) { + const segs = schedule?.segments ?? []; + for (let i = segs.length - 1; i >= 0; i -= 1) if (t >= segs[i].start) return segs[i].id; + return segs[0]?.id ?? null; +} + +const lerp = (a, b, p) => a + (b - a) * p; + +/** + * The footage's box at `t`, when the segment on screen has a move: its + * eased progress (0 before the move, 1 after it) and the rect between `from` + * and `to`. null when the segment on screen has no move -- the footage is in + * its box and nothing needs drawing differently. + * + * @param {{ segments: Array<{ id: string, start: number }>, moves?: Move[] }} schedule + * @param {number} t seconds in the cut's clock + * @returns {{ segment: string, progress: number, rect: Rect, from: Rect, to: Rect } | null} + */ +export function footageAt(schedule, t) { + const moves = schedule?.moves ?? []; + if (!moves.length) return null; + const id = segmentIdAt(schedule, t); + const m = moves.find((x) => x.segment === id); + if (!m) return null; + const progress = m.seconds > 0 ? ease((t - m.at) / m.seconds) : t >= m.at ? 1 : 0; + const rect = { + x: lerp(m.from.x, m.to.x, progress), + y: lerp(m.from.y, m.to.y, progress), + width: lerp(m.from.width, m.to.width, progress), + height: lerp(m.from.height, m.to.height, progress), + }; + return { segment: m.segment, progress, rect, from: m.from, to: m.to }; +} + +/** + * The CSS transform that takes a whole-frame backdrop (W×H, `transform-origin: + * 0 0`) with its footage at `from` and puts that footage at `rect`. Translate + * is in percent of the element -- the frame -- so it holds at any displayed + * size. "none" when nothing moves. + * + * @param {Rect} from + * @param {Rect} rect + * @param {{ W: number, H: number }} frame + */ +export function backdropTransform(from, rect, { W, H }) { + const sx = rect.width / from.width; + const sy = rect.height / from.height; + const tx = rect.x - sx * from.x; + const ty = rect.y - sy * from.y; + if (Math.abs(sx - 1) < 1e-6 && Math.abs(sy - 1) < 1e-6 && Math.abs(tx) < 1e-6 && Math.abs(ty) < 1e-6) return "none"; + const pct = (v, of) => `${Math.round((v / of) * 100 * 10000) / 10000}%`; + const n = (v) => Math.round(v * 1e6) / 1e6; + return `translate(${pct(tx, W)}, ${pct(ty, H)}) scale(${n(sx)}, ${n(sy)})`; +} diff --git a/umtool/lib/report/footage-move.test.mjs b/umtool/lib/report/footage-move.test.mjs @@ -0,0 +1,93 @@ +// The footage's box at a moment of the cut, as umtool's preview draws it. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { estimateSchedule } from "umtool-report-to-video/deck"; +import { backdropTransform, ease, footageAt, segmentIdAt } from "./footage-move.mjs"; + +const FROM = { x: 173, y: 2, width: 1574, height: 886 }; +const TO = { x: 24, y: 64, width: 1354, height: 762 }; +const schedule = { + segments: [ + { id: "a", start: 0 }, + { id: "b", start: 9.5 }, + { id: "c", start: 19 }, + ], + moves: [{ segment: "a", at: 4, segmentAt: 4, seconds: 0.5, from: FROM, to: TO }], +}; + +test("ease: smoothstep, clamped", () => { + assert.equal(ease(-1), 0); + assert.equal(ease(0), 0); + assert.equal(ease(0.5), 0.5); + assert.equal(ease(0.25), 0.15625); + assert.equal(ease(1), 1); + assert.equal(ease(2), 1); + assert.equal(ease(Number.NaN), 0); +}); + +test("segmentIdAt: the last segment that has started", () => { + assert.equal(segmentIdAt(schedule, 0), "a"); + assert.equal(segmentIdAt(schedule, 9.49), "a"); + assert.equal(segmentIdAt(schedule, 9.5), "b"); + assert.equal(segmentIdAt(schedule, 100), "c"); + assert.equal(segmentIdAt(schedule, -1), "a"); + assert.equal(segmentIdAt({ segments: [] }, 1), null); +}); + +test("footageAt: the box before the move, eased through it, held at `to` to the end of the segment", () => { + assert.deepEqual(footageAt(schedule, 3.9).rect, FROM); + assert.equal(footageAt(schedule, 3.9).progress, 0); + const mid = footageAt(schedule, 4.25); + assert.equal(mid.progress, 0.5); + assert.deepEqual(mid.rect, { x: 98.5, y: 33, width: 1464, height: 824 }); + assert.equal(footageAt(schedule, 4.125).progress, 0.15625); + assert.deepEqual(footageAt(schedule, 4.5).rect, TO); + assert.deepEqual(footageAt(schedule, 9.4).rect, TO); + // The next segment comes in at the normal box: no move once it is on screen. + assert.equal(footageAt(schedule, 9.5), null); + assert.equal(footageAt(schedule, 20), null); + assert.equal(footageAt({ segments: schedule.segments }, 5), null); + // A zero-second move is a cut. + const snap = { ...schedule, moves: [{ ...schedule.moves[0], seconds: 0 }] }; + assert.equal(footageAt(snap, 3.99).progress, 0); + assert.equal(footageAt(snap, 4).progress, 1); +}); + +test("footageAt over deck.mjs's own schedule: the moves estimateSchedule emits", () => { + const m = { + render: { width: 1920, height: 1080, fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }, + timeline: [ + { type: "clip", id: "c01", video: "v", start: 0, end: 12, date: "2024-09-03" }, + { type: "clip", id: "c02", video: "v", start: 20, end: 30, date: "2024-09-10" }, + ], + posts: [{ id: "p", platform: "x", date: "2024-09-05", text: "t", url: "https://x.com/a/status/1" }], + }; + const s = estimateSchedule(m); + const [move] = s.moves; + assert.equal(move.segment, "c01"); + assert.deepEqual(footageAt(s, move.at - 0.01).rect, move.from); + assert.deepEqual(footageAt(s, move.at + move.seconds).rect, move.to); + // Through c01's hold, still aside; c02 is at its box. + const c02 = s.segments.find((x) => x.id === "c02"); + assert.deepEqual(footageAt(s, c02.start - 0.01).rect, move.to); + assert.equal(footageAt(s, c02.start), null); +}); + +test("backdropTransform: the frame scaled and moved so `from` lands on the rect", () => { + const frame = { W: 1920, H: 1080 }; + assert.equal(backdropTransform(FROM, FROM, frame), "none"); + const tr = backdropTransform(FROM, TO, frame); + // sx 1354/1574, tx 24 − sx·173 = −124.82 px = −6.501 % of 1920; ty 64 − sy·2 = 62.28 px. + assert.equal(tr, "translate(-6.501%, 5.7667%) scale(0.860229, 0.860045)"); + // Check the mapping itself: the box's corners land on TO's. + const sx = TO.width / FROM.width; + const sy = TO.height / FROM.height; + const tx = (-6.501 / 100) * 1920; + const ty = (5.7667 / 100) * 1080; + assert.ok(Math.abs(tx + sx * FROM.x - TO.x) < 0.01); + assert.ok(Math.abs(ty + sy * FROM.y - TO.y) < 0.01); + assert.ok(Math.abs(tx + sx * (FROM.x + FROM.width) - (TO.x + TO.width)) < 0.01); +}); diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -31,6 +31,7 @@ import { } from "umtool-report-to-video/ledger-totals"; import { isCalendarDate } from "umtool-report-to-video/attribution"; import { normalizeOnscreen, validateChrome, validatePosts } from "umtool-report-to-video/deck"; +import { parseMuteFrom } from "./playback.mjs"; // Its own write queue, not lib/state.ts's. // @@ -281,6 +282,34 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) { } } + // ---- the mute mark ------------------------------------------------------ + // + // `muteFrom`: from this source second to the end of the clip the sound + // fades out and the picture plays on -- set by ear, in the bench, at the + // last silence before a finale's ending sound. Inside the EXTENT, like the + // cut, and checked against the entry AFTER the patch: a window save that + // leaves the mark outside is refused rather than keeping a mark that no + // longer says anything about the clip. Empty or null deletes it. + if (patch.muteFrom !== undefined) { + const v = parseMuteFrom(patch.muteFrom, entry.start, entry.end); + if (v == null) delete entry.muteFrom; + else entry.muteFrom = v; + } else if ((patch.start !== undefined || patch.end !== undefined) && entry.muteFrom != null) { + // Clamped and STORED, as a patched mark is: a mark within the writer's + // 0.02 s of the moved edge lands on it (past the new start it still + // mutes the whole clip), and the build (validateMuteFrom, strict) + // accepts every manifest saved here. Left as it was, a start moved from 10.00 to 10.01 under a mark at + // 10.00 saved, and the next build refused the whole manifest. + try { + entry.muteFrom = parseMuteFrom(entry.muteFrom, entry.start, entry.end); + } catch { + throw new Error( + `the mute mark ${entry.muteFrom} must lie inside the window ` + + `${entry.start}–${entry.end} — widen the window, or clear the mark`, + ); + } + } + // ---- the walk's verdict ------------------------------------------------- // // Whether somebody has LOOKED at this clip and said the description is what diff --git a/umtool/lib/report/manifest.test.mjs b/umtool/lib/report/manifest.test.mjs @@ -22,6 +22,7 @@ import { updateOnscreen, updatePosts, } from "./manifest.mjs"; +import { validateCutEdits } from "umtool-report-to-video/deck"; const base = () => ({ slug: "t", @@ -181,6 +182,64 @@ test("updateClip: onscreen is normalised, and empty or null deletes the key", as } }); +test("updateClip: muteFrom is a number inside the clip, rounded; empty or null deletes the key", async () => { + const dir = await project(); + try { + const res = await updateClip(dir, "c01", { muteFrom: 18.456 }); + assert.equal(res.entry.muteFrom, 18.46); + assert.equal(entry(await read(dir), "c01").muteFrom, 18.46); + // A string from a form is a number too. + await updateClip(dir, "c01", { muteFrom: "17.5" }); + assert.equal(entry(await read(dir), "c01").muteFrom, 17.5); + + const before = await readRaw(dir); + // Outside [start, end], and not a number: refused, nothing written. + await assert.rejects(updateClip(dir, "c01", { muteFrom: 25 }), /must lie inside the clip 10–20/); + await assert.rejects(updateClip(dir, "c01", { muteFrom: 5 }), /must lie inside/); + await assert.rejects(updateClip(dir, "c01", { muteFrom: "later" }), /must be a number/); + // A window that would leave the mark outside it is refused too... + await assert.rejects(updateClip(dir, "c01", { end: 17 }), /mute mark 17.5 must lie inside the window 10–17/); + assert.equal(await readRaw(dir), before); + // ...unless the same patch moves or clears it. + await updateClip(dir, "c01", { end: 17, muteFrom: 16 }); + assert.equal(entry(await read(dir), "c01").muteFrom, 16); + // Checked against the window AFTER the patch: a wider window and a mark in + // the new seconds land together. + await updateClip(dir, "c01", { end: 22, muteFrom: 21 }); + assert.equal(entry(await read(dir), "c01").muteFrom, 21); + + await updateClip(dir, "c01", { muteFrom: "" }); + assert.equal("muteFrom" in entry(await read(dir), "c01"), false); + await updateClip(dir, "c01", { muteFrom: 12 }); + await updateClip(dir, "c01", { muteFrom: null }); + assert.equal("muteFrom" in entry(await read(dir), "c01"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateClip: a window edge moved within 0.02 s past the mute mark clamps the mark onto it, so the build accepts what was saved", async () => { + const dir = await project(); + try { + // c01 is 10–20. A mark on the start, then the start moved a hundredth on. + await updateClip(dir, "c01", { muteFrom: 10 }); + await updateClip(dir, "c01", { start: 10.01 }); + let m = await read(dir); + assert.equal(entry(m, "c01").muteFrom, 10.01, "the mark moves onto the new start"); + assert.deepEqual(validateCutEdits(m), [], "and the build's strict check takes it"); + // The same at the end. + await updateClip(dir, "c01", { muteFrom: 20 }); + await updateClip(dir, "c01", { end: 19.99 }); + m = await read(dir); + assert.equal(entry(m, "c01").muteFrom, 19.99); + assert.deepEqual(validateCutEdits(m), []); + // Further than the tolerance is still refused. + await assert.rejects(updateClip(dir, "c01", { end: 19.9 }), /mute mark 19.99 must lie inside the window/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + const DECK = { engine: "hyperframes", layout: "deck", deck: { height: 180, title: { size: 60 } } }; test("updateChrome: round trip stores the block as given; null removes it", async () => { diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs @@ -24,7 +24,9 @@ import { clipDay, deckText, estimateSchedule, + footageMoves, normalizeOnscreen, + postHolds, postSchedule, postWindows, resolveDeck, @@ -122,6 +124,15 @@ export function scheduleMatches(schedule, entries) { * build's segments: an override or a hide saved since the build moves them, * and the build's `posts` would show where they were. * + * So are the HOLDS (`posts.hold` on a clip that carries posts) and the + * footage's moves: a hold is part of its segment's length in the cut, so a + * post moved to another clip, a hide, a changed `posts.hold`, or a build that + * predates holds all move every later start. The build's probed length of a + * segment is its `duration` less the hold it was built with; each segment + * gains the difference between the hold it has now and that one, and every + * later start (and the total) moves by the sum before it. A build whose holds + * are still the manifest's is kept to the millisecond. + * * Without a build schedule the draft is applied to the entries and the whole * cut is estimated. * @@ -149,22 +160,34 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra const deck = resolveDeck(render); const provenance = variantManifest.provenance ?? {}; const patchedEntries = entries.map(patched); - const { posts: _builtPosts, ...rest } = built; + const { posts: _builtPosts, moves: _builtMoves, ...rest } = built; + const round = (v) => Math.round(v * 1000) / 1000; + const holds = deck.posts.show ? postHolds({ posts, entries: patchedEntries, metas, render }) : new Map(); + let shift = 0; + const segments = built.segments.map((s) => { + const hold = holds.get(s.id) ?? 0; + const delta = hold - (s.hold ?? 0); + const start = s.start + shift; + const duration = s.duration + delta; + shift += delta; + const { hold: _h, ...bare } = s; + return { + ...bare, + start: round(start), + duration: round(duration), + end: round(start + duration), + ...(hold > 0 ? { hold: round(hold) } : {}), + }; + }); + const total = round(built.total + shift); const placed = deck.posts.show - ? postSchedule({ - posts, - entries: patchedEntries, - metas, - segments: built.segments, - D: built.transition, - total: built.total, - render, - }) + ? postSchedule({ posts, entries: patchedEntries, metas, segments, D: built.transition, total, render }) : []; - const round = (v) => Math.round(v * 1000) / 1000; + const moves = placed.length ? footageMoves({ posts: placed, segments, render }) : []; return { ...rest, - segments: built.segments.map((s, i) => { + total, + segments: segments.map((s, i) => { const e = patchedEntries[i]; const meta = metas[i] ?? null; const { title, subtitle } = deckText(e, meta, provenance, deck, built.multiChannel); @@ -172,6 +195,7 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra return { ...s, title, subtitle: keepBuilt ? s.subtitle : subtitle }; }), ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}), + ...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}), }; } return estimateSchedule({ ...variantManifest, posts, timeline: entries.map(patched) }, { metas }); diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs @@ -24,7 +24,7 @@ const cut = () => ({ slug: "t", variant: "sourced", provenance: { siteOrigin: "https://example.test", channelSlug: "chan" }, - render: { fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck" } }, + render: { fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck", deck: { posts: { seconds: 2, hold: 0, shift: false } } } }, timeline: [ { type: "card", id: "k1", heading: "Opening", sub: "a card", seconds: 5 }, { type: "clip", id: "c01", video: "v1", start: 10, end: 20 }, @@ -162,6 +162,55 @@ test("no build schedule: the estimate places the posts with the draft applied", assert.deepEqual(s.posts.map((p) => [p.id, p.segment]), [["p1", "c01"], ["p2", "c02"]]); }); +/** `cut()` with posts and the room-for-posts defaults (4 s, hold 2.5, shift on) instead of the old ones. */ +const roomy = (posts = POSTS) => { + const m = withPosts(posts); + m.render.chrome.deck = {}; + return m; +}; + +test("a build that predates holds: each carrying clip gains the hold, every later start moves, and the moves follow", () => { + // built(): k1 0+5, c01 4.5+9.9, c02 13.9+12 = 25.9. p3 and p1 ride on c01, p2 on c02. + const s = previewSchedule({ variantManifest: roomy(), built: built(), draft: new Map(), metas }); + const by = Object.fromEntries(s.segments.map((x) => [x.id, x])); + assert.ok(!("hold" in by.k1)); + assert.deepEqual([by.k1.start, by.k1.duration, by.k1.end], [0, 5, 5]); + assert.deepEqual([by.c01.start, by.c01.duration, by.c01.end, by.c01.hold], [4.5, 12.4, 16.9, 2.5]); + assert.deepEqual([by.c02.start, by.c02.duration, by.c02.end, by.c02.hold], [16.4, 14.5, 30.9, 2.5]); + assert.equal(s.total, 30.9); + // The posts are measured with the holds: c01's leave is c02's (held) start. + const p = Object.fromEntries(s.posts.map((x) => [x.id, x])); + assert.deepEqual(p.p1.out, [16.4, 16.9]); + assert.equal(p.p1.appear, 12.4); + assert.equal(p.p3.appear, 8.4); + assert.deepEqual(p.p2.out, [30.6, 30.9]); + // One move per carrying clip, at its first post, from the box to the shifted box. + assert.deepEqual(s.moves.map((m) => [m.segment, m.at, m.segmentAt, m.seconds]), [["c01", 8.4, 3.9, 0.6], ["c02", 26.6, 10.2, 0.6]]); + assert.deepEqual(s.moves[0].to, { x: 24, y: 64, width: 1354, height: 762 }); + + // A build WITH those holds is kept to the millisecond: the same schedule back. + const again = previewSchedule({ variantManifest: roomy(), built: s, draft: new Map(), metas }); + assert.deepEqual(again.segments.map((x) => [x.id, x.start, x.duration, x.hold]), s.segments.map((x) => [x.id, x.start, x.duration, x.hold])); + assert.equal(again.total, s.total); + + // Hiding c01's posts takes its hold away again, and the later starts come back. + const hidden = previewSchedule({ + variantManifest: roomy(), built: s, draft: new Map(), metas, + postsDraft: { p1: { hide: true }, p3: { hide: true } }, + }); + const h = Object.fromEntries(hidden.segments.map((x) => [x.id, x])); + assert.ok(!("hold" in h.c01)); + assert.deepEqual([h.c01.duration, h.c02.start, hidden.total], [9.9, 13.9, 28.4]); + assert.deepEqual(hidden.moves.map((m) => m.segment), ["c02"]); + + // shift off: the holds, and no moves. + const fixed = roomy(); + fixed.render.chrome.deck = { posts: { shift: false } }; + const off = previewSchedule({ variantManifest: fixed, built: built(), draft: new Map(), metas }); + assert.equal(off.total, 30.9); + assert.ok(!("moves" in off)); +}); + test("applyPostsDraft: the writer's rule, on a copy", () => { const posts = withPosts().posts; posts[0].hide = true; diff --git a/umtool/lib/report/playback.mjs b/umtool/lib/report/playback.mjs @@ -0,0 +1,234 @@ +// The clip bench's bounded playback, as arithmetic. +// +// The bench used to play a range on the <video> element and stop it from +// `timeupdate`, which fires every 15–250 ms: what you heard ran past the end +// by up to a quarter of a second, and by a different amount every time. That +// is the difference between a cut that lands between two words and one that +// swallows the next syllable, and it could not be judged by ear. +// +// So a bounded range plays from the DECODED audio with Web Audio, the old +// um-triage chooser's technique: an AudioBufferSourceNode started at an exact +// buffer offset and stopped at an exact context time, sample-accurately. The +// picture follows along, muted. These are the numbers that decide where in the +// buffer to start, when to stop, where the playhead is, and when the finale's +// mute mark goes quiet. Pure: no DOM, no Web Audio, so they are unit-tested and +// the component only applies them. +// +// EVERYTHING IS IN ABSOLUTE SOURCE SECONDS except where a name says `wall` +// (seconds of the listener's time, which is source time divided by the rate) +// or `ctx` (the AudioContext's clock). + +/** Decoding is per window, but never more than this many seconds at once. */ +export const MAX_DECODE_SPAN = 120; + +/** + * Around the selection, when a window is too long to decode whole (a whole + * recording fetched into the saved-video store): enough to drag an edge and + * hear it without a second decode. + */ +export const DECODE_MARGIN = 30; + +/** + * How far ahead of "now" a playback is scheduled, in wall seconds. + * + * A start scheduled in the past is started late at the SAME offset, so every + * time computed from it would be early by however late it was. Scheduling a + * little ahead keeps `start` and `stop` on the clock they were computed on. + */ +export const START_LEAD = 0.03; + +/** + * The mute mark's fade, in source seconds: the BUILD's, so what the bench plays + * is what the cut does -- a ramp that ENDS at the mark, silent from the mark on. + * From `mute.mjs`, not `deck.mjs`: this module is in the bench's client bundle, + * and deck.mjs would bring node:crypto and attribution with it. + */ +export { MUTE_FADE } from "umtool-report-to-video/mute"; +import { MUTE_FADE } from "umtool-report-to-video/mute"; + +/** One Web Audio render quantum, in frames. */ +export const RENDER_QUANTUM = 128; + +const round3 = (n) => Math.round(n * 1000) / 1000; + +/** + * Which span of a cached window to decode. + * + * The whole window when it is short enough -- one decode then serves every + * drag. A longer one decodes the selection plus a margin each side, clamped to + * the window, and capped: a decode is held in memory as 32-bit floats, and two + * minutes of stereo is already ~46 MB. + * + * @param {{from: number, to: number}} win the cached file's span + * @param {{from: number, to: number}} want the range about to be played + * @param {{max?: number, margin?: number}} [opts] + * @returns {{from: number, to: number}} + */ +export function decodeSpan(win, want, { max = MAX_DECODE_SPAN, margin = DECODE_MARGIN } = {}) { + if (win.to - win.from <= max) return { from: win.from, to: win.to }; + let from = Math.max(win.from, want.from - margin); + let to = Math.min(win.to, Math.max(want.to, want.from) + margin); + if (to - from > max) { + // A selection wider than the cap: from its start, as much as fits. + from = Math.max(win.from, Math.min(want.from, win.to - max)); + to = Math.min(win.to, from + max); + } + return { from: round3(from), to: round3(to) }; +} + +/** + * Does a decoded span hold this whole range? A hair of tolerance, because the + * decoded span's end is a frame count divided by a rate. + * + * @param {{from: number, to: number} | null} span + * @param {number} from + * @param {number} to + */ +export function covers(span, from, to) { + if (!span) return false; + return from >= span.from - 0.005 && to <= span.to + 0.005; +} + +/** + * Where in the buffer to start, how much source to play, and how long that + * takes at this rate. + * + * `duration` is SOURCE seconds and `wall` is how long it lasts at `rate`. The + * stop is scheduled at `start + wall` on the context's clock rather than + * passed to `start()` as a duration: the spec reads that argument as buffer + * content, but a stop time on the context clock means one thing at any rate. + * + * @param {{from: number, to: number}} span the decoded buffer's absolute span + * @param {number} bufferSeconds the buffer's own duration + * @param {number} from + * @param {number} to + * @param {number} [rate] + * @returns {{offset: number, duration: number, wall: number} | null} null when + * nothing of the range is in the buffer + */ +export function bufferSchedule(span, bufferSeconds, from, to, rate = 1) { + const r = rate > 0 ? rate : 1; + const offset = Math.max(0, Math.min(bufferSeconds, from - span.from)); + const duration = Math.min(bufferSeconds - offset, to - Math.max(from, span.from)); + if (!(duration > 0)) return null; + return { offset, duration, wall: duration / r }; +} + +/** + * The playhead, in source seconds, from the audio clock. + * + * @param {number} from where the playback started, in source seconds + * @param {number} t0 the context time it started at + * @param {number} now the context time now + * @param {number} rate + * @param {number} duration source seconds the playback lasts + */ +export function playheadAt(from, t0, now, rate, duration) { + const r = rate > 0 ? rate : 1; + const played = Math.max(0, Math.min(duration, (now - t0) * r)); + return from + played; +} + +/** + * When the mute mark silences a playback of [from, to], as offsets in WALL + * seconds from its start. + * + * null the mark is not in this range (or there is none) + * { at: 0, fade: 0 } the range starts at or after the mark: silent + * from the first sample, the picture still plays + * { at, fade } full level until `at`, then a linear ramp to + * nothing over `fade`, reaching it AT the mark + * (as the build's afade does); a range that starts + * inside the fade ramps from its first sample + * + * @param {number | null | undefined} muteFrom + * @param {number} from + * @param {number} to + * @param {number} [rate] + * @param {number} [fade] source seconds + * @returns {{at: number, fade: number} | null} + */ +export function muteRamp(muteFrom, from, to, rate = 1, fade = MUTE_FADE) { + if (muteFrom == null || !Number.isFinite(muteFrom)) return null; + const r = rate > 0 ? rate : 1; + if (muteFrom >= to) return null; + if (muteFrom <= from) return { at: 0, fade: 0 }; + const st = Math.max(from, muteFrom - fade); + return { at: (st - from) / r, fade: (muteFrom - st) / r }; +} + +/** + * The element fallback's stop test, run once per animation frame. + * + * Stopping at the first frame PAST the end overruns by up to a frame (and the + * element's own clock reports late on top of that). Stopping as soon as the + * end is less than half a frame away splits the error both ways instead: never + * more than half a frame early or late, at any rate. + * + * @param {number} t the element's position, in source seconds + * @param {number} stopAt + * @param {number} [rate] + * @param {number} [frame] wall seconds per animation frame + */ +export function elementShouldStop(t, stopAt, rate = 1, frame = 1 / 60) { + const r = rate > 0 ? rate : 1; + return t + (frame * r) / 2 >= stopAt; +} + +/** + * A mute mark as the WRITER reads it: a number of source seconds inside the + * clip's extent, rounded like an edge -- or `null` to delete the key. + * + * `null` and `""` mean "no mark". Anything else must be a finite number in + * [start, end] (a hair of tolerance, the same 0.02 the cut's check allows). + * + * @param {unknown} raw + * @param {number} start + * @param {number} end + * @returns {number | null} + */ +export function parseMuteFrom(raw, start, end) { + if (raw === null || raw === "") return null; + const v = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw.trim()) : Number.NaN; + if (!Number.isFinite(v)) { + throw new Error(`muteFrom must be a number of source seconds, or empty to clear it (got \`${String(raw)}\`)`); + } + if (v < start - 0.02 || v > end + 0.02) { + throw new Error( + `muteFrom ${v} must lie inside the clip ${start}–${end} — the mark mutes from there to the clip's end`, + ); + } + return Math.round(Math.min(end, Math.max(start, v)) * 100) / 100; +} + +/** + * A RIFF/WAVE header for interleaved 16-bit PCM. + * + * Written by hand because ffmpeg writing WAV to a pipe cannot seek back to fill + * in the sizes, and a header that says "unknown length" is one more thing a + * decoder may or may not forgive. + * + * @param {{channels: number, sampleRate: number, dataBytes: number}} f + * @returns {Uint8Array} 44 bytes + */ +export function wavHeader({ channels, sampleRate, dataBytes }) { + const b = new Uint8Array(44); + const v = new DataView(b.buffer); + const tag = (at, s) => { + for (let i = 0; i < 4; i += 1) b[at + i] = s.charCodeAt(i); + }; + tag(0, "RIFF"); + v.setUint32(4, 36 + dataBytes, true); + tag(8, "WAVE"); + tag(12, "fmt "); + v.setUint32(16, 16, true); + v.setUint16(20, 1, true); // PCM + v.setUint16(22, channels, true); + v.setUint32(24, sampleRate, true); + v.setUint32(28, sampleRate * channels * 2, true); + v.setUint16(32, channels * 2, true); + v.setUint16(34, 16, true); + tag(36, "data"); + v.setUint32(40, dataBytes, true); + return b; +} diff --git a/umtool/lib/report/playback.test.mjs b/umtool/lib/report/playback.test.mjs @@ -0,0 +1,158 @@ +// The clip bench's bounded playback, as arithmetic. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MAX_DECODE_SPAN, + MUTE_FADE, + bufferSchedule, + covers, + decodeSpan, + elementShouldStop, + muteRamp, + parseMuteFrom, + playheadAt, + wavHeader, +} from "./playback.mjs"; + +const near = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) <= eps, `${a} ≉ ${b}`); + +test("decodeSpan: a short window decodes whole, whatever is being played", () => { + assert.deepEqual(decodeSpan({ from: 24019.6, to: 24032.6 }, { from: 24022.6, to: 24029.6 }), { + from: 24019.6, + to: 24032.6, + }); + // Exactly at the cap is still whole. + assert.deepEqual(decodeSpan({ from: 0, to: MAX_DECODE_SPAN }, { from: 5, to: 6 }), { + from: 0, + to: MAX_DECODE_SPAN, + }); +}); + +test("decodeSpan: a long window decodes the selection plus a margin, clamped and capped", () => { + const whole = { from: 0, to: 7200 }; + assert.deepEqual(decodeSpan(whole, { from: 3600, to: 3610 }), { from: 3570, to: 3640 }); + // Clamped at the recording's start. + assert.deepEqual(decodeSpan(whole, { from: 10, to: 20 }), { from: 0, to: 50 }); + // Clamped at its end. + assert.deepEqual(decodeSpan(whole, { from: 7190, to: 7200 }), { from: 7160, to: 7200 }); + // A selection wider than the cap: from its start, as much as fits. + const wide = decodeSpan(whole, { from: 1000, to: 1300 }); + assert.deepEqual(wide, { from: 1000, to: 1000 + MAX_DECODE_SPAN }); + // ...and never past the window's end. + assert.deepEqual(decodeSpan(whole, { from: 7150, to: 7400 }), { from: 7120, to: 7200 }); + assert.deepEqual(decodeSpan(whole, { from: 7000, to: 7400 }), { from: 7000, to: 7000 + MAX_DECODE_SPAN }); +}); + +test("covers: the decoded span holds the whole range, with a hair of tolerance", () => { + const span = { from: 10, to: 20 }; + assert.equal(covers(span, 10, 20), true); + assert.equal(covers(span, 12, 19.999), true); + assert.equal(covers(span, 9.9, 15), false); + assert.equal(covers(span, 15, 20.1), false); + assert.equal(covers(null, 1, 2), false); +}); + +test("bufferSchedule: offset into the buffer, source seconds to play, and how long that takes", () => { + const span = { from: 24019.6, to: 24032.6 }; + const s = bufferSchedule(span, 13, 24022.6, 24029.6, 1); + near(s.offset, 3, 1e-6); + near(s.duration, 7, 1e-6); + near(s.wall, 7, 1e-6); + // Speed changes how long it takes, not what is played. + const fast = bufferSchedule(span, 13, 24022.6, 24029.6, 1.5); + near(fast.offset, 3, 1e-6); + near(fast.duration, 7, 1e-6); + near(fast.wall, 7 / 1.5, 1e-6); + const slow = bufferSchedule(span, 13, 24022.6, 24029.6, 0.75); + near(slow.wall, 7 / 0.75, 1e-6); + // A range running past the buffer stops at the buffer. + near(bufferSchedule(span, 13, 24030, 24040, 1).duration, 2.6, 1e-6); + // Nothing of the range in the buffer. + assert.equal(bufferSchedule(span, 13, 24040, 24050, 1), null); + // A nonsense rate is read as 1, not as a division by zero. + near(bufferSchedule(span, 13, 24022.6, 24029.6, 0).wall, 7, 1e-6); +}); + +test("playheadAt: from the audio clock, in source seconds, held at the end", () => { + near(playheadAt(100, 5, 5, 1, 4), 100); + near(playheadAt(100, 5, 6, 1, 4), 101); + near(playheadAt(100, 5, 6, 2, 4), 102); + // Before the scheduled start (the lead): at the start, not before it. + near(playheadAt(100, 5, 4.98, 1, 4), 100); + // Past the end: held at the end. + near(playheadAt(100, 5, 60, 1, 4), 104); +}); + +test("muteRamp: when the mark silences a playback, in wall seconds from its start", () => { + assert.equal(muteRamp(null, 10, 20), null); + assert.equal(muteRamp(undefined, 10, 20), null); + // At or past the end of the range: nothing to mute in it. + assert.equal(muteRamp(20, 10, 20), null); + assert.equal(muteRamp(25, 10, 20), null); + // At or before the start: silent throughout, the picture still plays. + assert.deepEqual(muteRamp(10, 10, 20), { at: 0, fade: 0 }); + assert.deepEqual(muteRamp(5, 10, 20), { at: 0, fade: 0 }); + // Inside: full level until the fade, which ENDS at the mark (the build's). + assert.equal(MUTE_FADE, 0.04); + const m = muteRamp(16, 10, 20, 1); + near(m.at, 6 - MUTE_FADE); + near(m.fade, MUTE_FADE); + near(m.at + m.fade, 6); + // At 2x the mark arrives in half the time, and so does the fade. + const f = muteRamp(16, 10, 20, 2); + near(f.at, (6 - MUTE_FADE) / 2); + near(f.fade, MUTE_FADE / 2); + // A range starting inside the fade ramps from its first sample to the mark. + const g = muteRamp(16, 15.98, 20, 1); + near(g.at, 0); + near(g.fade, 0.02); +}); + +test("elementShouldStop: stops when the end is under half a frame away, at any rate", () => { + const frame = 1 / 60; + assert.equal(elementShouldStop(9.9, 10, 1, frame), false); + assert.equal(elementShouldStop(10 - frame / 2 - 0.001, 10, 1, frame), false); + assert.equal(elementShouldStop(10 - frame / 2 + 0.001, 10, 1, frame), true); + assert.equal(elementShouldStop(10.2, 10, 1, frame), true); + // At 2x a frame covers twice the source, so the stop comes a frame earlier. + assert.equal(elementShouldStop(10 - frame + 0.001, 10, 2, frame), true); +}); + +test("parseMuteFrom: a number inside the clip, rounded like an edge, or null to delete", () => { + assert.equal(parseMuteFrom(null, 10, 20), null); + assert.equal(parseMuteFrom("", 10, 20), null); + assert.equal(parseMuteFrom(15.456, 10, 20), 15.46); + assert.equal(parseMuteFrom(" 15.5 ", 10, 20), 15.5); + assert.equal(parseMuteFrom(10, 10, 20), 10); + assert.equal(parseMuteFrom(20, 10, 20), 20); + // The 0.02 tolerance an edge gets, clamped back inside. + assert.equal(parseMuteFrom(20.01, 10, 20), 20); + assert.equal(parseMuteFrom(9.99, 10, 20), 10); + assert.throws(() => parseMuteFrom(20.5, 10, 20), /must lie inside the clip 10–20/); + assert.throws(() => parseMuteFrom(9, 10, 20), /must lie inside/); + assert.throws(() => parseMuteFrom("soon", 10, 20), /must be a number/); + assert.throws(() => parseMuteFrom(true, 10, 20), /must be a number/); + assert.throws(() => parseMuteFrom(Number.NaN, 10, 20), /must be a number/); +}); + +test("wavHeader: 44 bytes of RIFF/WAVE for interleaved 16-bit PCM", () => { + const h = wavHeader({ channels: 2, sampleRate: 48000, dataBytes: 192000 }); + assert.equal(h.length, 44); + const v = new DataView(h.buffer); + const tag = (at) => String.fromCharCode(...h.slice(at, at + 4)); + assert.equal(tag(0), "RIFF"); + assert.equal(v.getUint32(4, true), 36 + 192000); + assert.equal(tag(8), "WAVE"); + assert.equal(tag(12), "fmt "); + assert.equal(v.getUint16(20, true), 1); + assert.equal(v.getUint16(22, true), 2); + assert.equal(v.getUint32(24, true), 48000); + assert.equal(v.getUint32(28, true), 48000 * 4); + assert.equal(v.getUint16(32, true), 4); + assert.equal(v.getUint16(34, true), 16); + assert.equal(tag(36), "data"); + assert.equal(v.getUint32(40, true), 192000); +}); diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -174,7 +174,7 @@ the manifest names the MCP video id while the cue file lives under the URL slug. ## Manifest shape `timeline` is an ordered list; entries are `card`, `clip` or `image` (plus -`scroll`, `chart` and `ledger` — the vocabulary is open). +`scroll`, `chart`, `ledger` and `teaser` — the vocabulary is open). ```jsonc { "type": "card", "id": "ch3", "style": "chapter", "seconds": 4.0, @@ -187,7 +187,7 @@ the manifest names the MCP video id while the cue file lives under the URL slug. "quote": "The pre-application screening was approved by the county, dude." } ``` -Two per-clip fields exist for compilations that span sources or need a hand-cut +Three per-clip fields exist for compilations that span sources or need a hand-cut window: - **`channel`** — the archived channel this clip's cue file lives under, overriding @@ -201,6 +201,22 @@ window: sentence is an editorial decision that widening would silently undo. `lock` also handles the reverse case — a clip whose lead-in would drag in seconds of some *other* audio (a news package playing before the speaker starts). +- **`muteFrom`** — SOURCE seconds, like `start`/`end`/`cutEnd`, within the clip's + `start`–`end`: the clip's sound goes silent from that second to the end of the + clip while the picture plays on. A 40 ms fade ends exactly at `muteFrom`, so + nothing of a sound that starts there gets through and nothing clicks; from it on + the sound is digital silence, and a hold on that clip stays silent. It is made + where the cut is joined (see [The cut's edits](#the-cuts-edits-mutefrom-and-renderendfade)), + so changing it rebuilds no segment: `--chrome-only` applies it under the deck. + Only the played window is in the segment, so a mark past `cutEnd` (inside the + extent, so it validates) mutes nothing; the build says it will not be heard. + +`render.endFade` (seconds, default 0 = off, at most 10) fades the cut's LAST +segment — whatever it is — picture to `palette.bg` and sound to silence over its +final `endFade` seconds (all of it, when the segment is shorter), reaching both +on the last frame. It is for a cut that +ends on a clip; once a finale entry follows the last clip, the ordinary +crossfade into it does the job and `endFade` fades the finale instead. ### `render.chrome` — the on-screen deck's settings, and per-entry `onscreen` @@ -223,8 +239,10 @@ whatever a manifest omits, one level deep: "qr": { "show": true, "size": 150 }, // size 80–380, and at most height − 20 "overCards": "hide", // "hide" | "show" "motion": { "out": 0.3, "in": 0.45, "pip": 0.7 }, // seconds; out/in 0–2, pip 0–3 - "posts": { "show": true, "seconds": 2, "position": "top-right", // the manifest's `posts` (below); position | "top-left" - "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } // seconds 0.5–10; width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80 + "posts": { "show": true, "seconds": 4, "hold": 2.5, // the manifest's `posts` (below); seconds 0.5–10; hold 0–10 (0: none) + "shift": { "scale": 0.86, "seconds": 0.6 }, // the footage makes room: scale 0.5–1, seconds 0–3; or false + "position": "top-right", // | "top-left" + "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } // width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80 } } } @@ -282,15 +300,33 @@ footage, so it rides on a clip. - **When.** A clip's posts, oldest first, stack: post j of k appears at A − seconds·(k − j), where A is the start of the outgoing transition (the next segment's start under a crossfade, 0.3 s before a hard cut, 0.3 s before the end - of the cut). Each has `seconds` alone before the next stacks on, and the last - has the clip's final `seconds`; a clip too short for that shares what it has - after its incoming dissolve. They all leave together over the transition. + of the cut). Each has `seconds` (4 by default) alone before the next stacks on, + and the last has the clip's final `seconds`; a clip too short for that shares + what it has after its incoming dissolve. They all leave together over the + transition. +- **The hold.** A clip that carries posts is held on its last frame, in silence, + for `hold` seconds (2.5) before its outgoing transition, so the last post can + be read. The hold is part of the clip's length in the CUT: every start after + it, the total, the chapters and the posts' own timing are measured with it + (the schedule's `segments[i].hold`). +- **The move.** With `shift` on (the default), the footage eases over + `shift.seconds` (0.6) from its box to `shift.scale` of it (0.86), its far edge + `inset` from the frame's edge away from the column and centred above the deck, + as the clip's first post appears; it stays there to the end of the clip, hold + included, and the next clip comes in at the normal box through the transition + (the schedule's `moves`). At 1920×1080: 1574×886 at (173, 2) → 1354×762 at + (24, 64). - **What.** The post's date (as the deck writes dates; a date-time is drawn as its day), `@handle · Bluesky` (or X), the words — paragraphs kept, clamped to `maxLines` with an ellipsis — and a QR of the post's own `url`. -- **Where.** A column inside the footage box, `inset` from its top and from the - `position` side, `width` wide. Cards stack top-down; when the next would - overflow the column, the oldest slide up and out. +- **Where.** A column `inset` from the top of the footage box and from the + FRAME's edge on the `position` side (inside the footage box when `shift` is + `false`), `width` wide. Cards stack top-down; when the next would overflow the + column, the oldest slide up and out. +- **How they read.** Each card slides in from past the frame's edge on the + column's side and its accent rim flares as it lands, then settles to a quiet + glow; an accent rail runs down its leading edge and the platform is a pill + beside the handle. The QR is fully opaque once the card is in. `validatePosts()` (`deck.mjs`) is the one validator, unknown keys refused; a deck build refuses a bad `posts` before a single fetch. Posts are drawn only @@ -387,6 +423,98 @@ cut off a top-level `ledger[]` — see [The claim rail](#the-claim-rail-renderra under `render.chrome`'s deck layout.** The deck replaces all of it with one persistent panel — see [The on-screen deck](#renderchrome--the-on-screen-deck). +### The `teaser` entry type + +A season teaser's "coming soon" card: a full-frame graphic, its words the +manifest's, popping in one line at a time over a dark cinematic ground, with a +trailer hit under each pop. Put it after the last clip; the ordinary crossfade +joins them, and `render.endFade` — which fades the cut's last segment — now +falls on the teaser. + +```jsonc +{ "type": "teaser", "id": "fin", "seconds": 7, + "lines": ["Pirate Software", + { "text": "The Largest Ferret Rescue in the United States", + "break": "in the United States" }, + "February 2027"], + "tail": "?", // optional: appended to the LAST line, fades in on its own + "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. + 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 + kicker (a date), and everything between a big heavy title; two lines are an + overline and a title; one is a title. +- **What fits the frame** (`TEASER_LIMITS.fit`): a row wider than 80 % of the + frame shrinks to its role's floor and no further, so each row is also held to + what fits at that floor — a **title** at most **34** characters, a + **kicker** 56, an **overline** 64, a `break`'s second tier 66; the tail and + the space before it count on the row that carries it. An 80-character title + spilled past both edges of the frame. A longer title fits by setting its end + 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. +- **`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 + (an authored `chapter` still wins). The deck slides away over a teaser + whatever `overCards` says — it is full frame, never framed into the footage + box — and it has no pip and no QR. + +**How it is drawn.** `chrome-teaser.mjs` builds the page (pure; the cue list +is `teaserCues`, every cue a `fromTo` with its from stated, as the deck's are), +compose-chrome renders it as region `teaser` (`chrome/teaser-<id>/`, frames in +`chrome/teaser-<id>-frames/`, cached by the page's content key — changed words +are a new render), and the build encodes the frames into `segments/<id>.mp4` +at the parameters every segment shares. The display face is the vendored +Archivo (`fonts/Archivo[wdth,wght].ttf`, weight 600–900 and width 112–125 %), +copied in as the private family `TeaserDisplay`. The ground is `palette.bg` +lifted toward the accent at the centre and falling toward black at the edges, +a vignette, seeded film grain, a soft light leak drifting across, letterbox +bars that close in over the dissolve, and a slow push-in over the whole card. +Each line slams in from 1.42× (the overline from 1.25×), blurred, undershoots +to 0.968× as it lands — a flash of the accent behind it and a streak of light +through it — and settles to rest. Rendered on the ferret cut: 210 frames in +about 90 s. + +**The sound.** Synthesised in ffmpeg, no samples (`teaserAudioGraph`): under +each pop a trailer hit — a sub sine dropping from ~92 to ~40 Hz with its octave +for body on an exponential decay, a band-passed noise burst for the punch, a +low noise tail and a short low-passed echo. The title's hit is the biggest, +the overline's and the date's a little smaller, the second tier's lighter and +shorter; under the tail a low swell rises and settles as it fades in. The times +are `teaserTimes` (`deck.mjs`), the SAME the composition's cues are built from, +and each hit starts on the sample of its pop. The sum is limited at −6 dBFS +(`alimiter`, no auto-level, latency compensated) and levelled against the ferret +cut: the cut measures −17.7 LUFS integrated, the teaser's segment −19.7. `hits: false` is +digital silence, as a card's is. + +**When it is rebuilt.** The segment is re-encoded only when its key — the +frames' render key, the whole sound graph and the encode's parameters (`crf`, +`preset`, `audioBitrate`, `audioRate`, `audioChannels`) — differs from the one +recorded beside it (`<id>.teaser.json`). Any build that reaches a teaser +rebuilds it when its words, motion, sound or encoding changed: a full build, `--only <id>`, and +**`--chrome-only`**, which builds teaser segments (they are chrome — graphics +made from the manifest, nothing fetched) while still rebuilding no clip. +`verify-build` checks each teaser's frame count and that its segment was +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}, +{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 +deck's true-still route already does for its region. + ## Chrome, not cards **The ferret-rescue cut has no cards at all** — no title, no chapter breaks, no @@ -919,6 +1047,13 @@ deck slides away for its duration when it is `"hide"` (the default) — no text handover happens across a hidden segment, because there is nothing on screen to animate. +The code's host (`JASOLYZER.PAGES.DEV`, muted, tracked 0.1 em) runs up the QR's +left side and is exactly as long as the code is tall (`deckLayout(render).qr.size`, +150 px by default), whatever the host: the page measures the string's ink in the +loaded face once at load, scales its size to fit, and indents the first +letter's side bearing away, so the ink starts on the code's bottom edge and +ends on its top. + `out/<variant>/schedule.json` (`deckSchedule()`) is the one source of *when*: the build writes it from PROBED segment durations and real source metadata; `estimateSchedule()` produces the same shape (`estimated: true`) from the @@ -970,7 +1105,10 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> [--region chart|d rebuilt and nothing is fetched.** The driver labels this "re-render on-screen". - **`--no-chrome`** — the deck's framing with no overlay: a fast picture check - of the letterboxing, with nothing composed or rendered. + of the letterboxing, with nothing composed or rendered. Holds and footage + moves are part of the CUT, not the chrome, so `--no-chrome` still applies + them (the footage moves aside for cards it does not draw), and its length is + the schedule's. - **`--chrome-preview <at> <dur>`** — renders only that window of the deck and writes `out/<variant>/<slug>.preview.mp4` of it, from a cached concat when there is one, else built straight from the segments the window touches. @@ -1007,8 +1145,8 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts -- `chrome/posts-<segment>-frames/` with their own `.key` (html, assets, fps, frames, renderer version — the deck's cache, per window); `--preview` composes `chrome/posts-preview-<segment>/` and never renders. -- **The page** is region-local (`postsGeometry`, 600×838 at (1123, 26) by - default), transparent outside the cards. `?still=<t>` and the preview's +- **The page** is region-local (`postsGeometry`, 600×838 at (1296, 26) by + default; at (1123, 26) with `shift: false`), transparent outside the cards. `?still=<t>` and the preview's `deck:seek` take CUT seconds; a preview page answers with `{type: "posts:ready", segment, from, to, ids}`. - **The stack is planned in the page.** Whether the next card overflows the @@ -1028,7 +1166,75 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts -- pass, `--chrome-only` and `--chrome-preview` (only the windows it touches) all lay them; `--no-chrome` lays neither. - **`verify-build`** also checks each window's `chrome/posts-<segment>-frames` - holds that window's frame count. + holds that window's frame count, and that each held clip's freeze is in the + file: two frames inside the still part of the hold -- after the hold starts + and the move lands, before the outgoing dissolve (or the end fade) -- are the + same, outside the deck and the column, within re-encoding noise. A hold with + under three frames of still picture there (0.5 s under a 0.5 s crossfade) is + reported as not checked. + +### The hold and the move, where the cut is joined + +`segmentJoins(schedule)` turns the schedule's holds and `moves` into one join +per carrying clip; the segment FILES are never touched, so `--chrome-only` +changes a hold or a move without rebuilding a clip, and a cut without posts has +no joins and runs the graphs it always did. + +- **The hold** is `tpad=stop_mode=clone:stop_duration=<hold>` on the input's + picture and `apad=pad_dur=<hold>` (silence) on its sound, before the + xfade/acrossfade. A hold is a whole number of frames (`postHolds` rounds it: + 2.5 s at 25 fps is 2.52 s), because `tpad` clones whole frames and `apad` + pads exact seconds. +- **The move** is one `perspective` filter on that input, AFTER the hold + (`sense=destination`, `eval=frame`): the input frame's corners are placed so + the footage box goes from `from` to `to`, eased by smoothstep over + `[segmentAt, segmentAt + seconds]` in the segment's own clock, hold included, + then held there. A first post that appears inside the hold therefore still + moves the footage, the frozen frame with it. Perspective resamples at + 1/256 px, so the box glides with no whole-pixel stepping, where `scale` + + `overlay` and `zoompan` round to whole pixels. Before the move the map is the + identity, which perspective copies bit for bit. `fillborders` pins the + frame's outer 2 px to `palette.bg` first, because perspective fills what the + shrink uncovers from the input's edge. +- **Hard cuts** with a hold or a move concatenate through the concat FILTER + (`hardCutFilterArgs`, one encode) rather than the demuxer's stream copy; the + prerail's `.segments` record then names each join, so a changed hold is never + served from a stale concat. Without joins the stream copy is unchanged. +- **Every length is the schedule's** under the deck: the xfade offsets, the + chapters, a `--chrome-preview` window and `--chapters-only` all add the holds + to the probed lengths (`cutOffsets`), the same sum `deckSchedule` makes. + +### The cut's edits: `muteFrom` and `render.endFade` + +Both are made on one input's chain before the join, beside the hold and the +move (`cutJoins` merges them into the deck's joins; `withCutEdits` is the pure +merge), so neither touches a segment file, and a cut that sets neither gets no +join for them: its graph, record and schedule are what they are without the two +keys. (Every crossfaded graph did change once, separately, when each input's +sound was pinned to its picture's length — `umtool/docs/quirks.md`.) They work +with or without the deck, on crossfades and hard cuts. + +- **`muteFrom` is mapped through the segment's cut record.** The build snaps a + clip's cut to the nearest silence, so its segment starts up to `snapWindow` + seconds from the manifest's `start` (or `cutStart − leadIn`). Every clip build + writes `segments/<id>.cut.json` — `{ video, start, end }`, the source seconds + it was really cut from — and `muteFrom − start` is the mute point in the + segment's clock (`muteSegmentSeconds`, in `deck.mjs`). A segment with no record + (built before records existed, or copied without it), or one whose record is + not as long as the segment, is measured from the unsnapped start instead, and + the build says so in a note. The sound chain is + `afade=t=out:st=<at − 0.04>:d=0.04` (then the hold's `apad`, if any); a mute at + or before the segment's start is `volume=0`. +- **The end fade's picture is a `geq` blend toward bg's Y′CbCr**, enabled from + the fade's first frame: frame `lastFrame − n` is the last untouched one and + `lastFrame` (the hold's clones counted) is bg. Not `fade=…:color=`, which works + in RGB only (`umtool/docs/quirks.md`). The sound is `afade` out to silence at the + last frame's time, over the same `n` frames. A fade longer than the segment is + the segment (`endFadeFrames` clamps `n` to `lastFrame`), so a 3 s finale under + `endFade: 5` still ends on bg and in silence together. +- **The hard-cut record names a mute and a fade** (`"mute"`, `"fade"` on a + `# join` line, only when there is one), so a cached prerail made without them, + or with other values, is never reused. ### Two ffmpeg traps that are the deck's alone diff --git a/umtool/report-to-video/av-sync.test.mjs b/umtool/report-to-video/av-sync.test.mjs @@ -0,0 +1,87 @@ +// The crossfade concat keeps every clip's sound on its picture. +// +// An encoded segment's audio is routinely a few to ~20 ms off its video, and +// `acrossfade` joins by the SOUND's length while `xfade` offsets come from the +// PICTURE's. Unpinned, the difference accumulates clip by clip. These segments +// make it large on purpose -- each one's audio is 30 ms short -- and put a tone +// exactly 1.0 s into each: in the joined cut, the third tone must still start +// 1.0 s after the third segment does. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { xfadeGraph } from "./build-video.mjs"; + +const have = spawnSync("ffmpeg", ["-version"]).status === 0; +const RENDER = { width: 320, height: 180, fps: 30, audioRate: 48000, audioChannels: 2 }; + +function segment(dir, name, seconds) { + const out = path.join(dir, `${name}.mp4`); + const a = (seconds - 0.03).toFixed(3); + const r = spawnSync("ffmpeg", [ + "-v", "error", "-y", + "-f", "lavfi", "-i", `color=c=gray:s=320x180:r=30:d=${seconds}`, + // Silence, then a tone from exactly 1.0 s; the stream is 30 ms short. + "-f", "lavfi", "-i", `sine=f=1000:r=48000:d=${a}`, + "-filter_complex", `[1:a]volume=enable='lt(t,1)':volume=0,aformat=channel_layouts=stereo[a]`, + "-map", "0:v", "-map", "[a]", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", + out, + ]); + assert.equal(r.status, 0, String(r.stderr)); + return out; +} + +/** The time (s) the tone's third onset starts in a file: the first loud window after `after`. */ +function onsetAfter(file, after) { + const r = spawnSync("ffmpeg", ["-v", "error", "-i", file, "-ac", "1", "-ar", "8000", "-f", "s16le", "-"], { maxBuffer: 1 << 26 }); + const pcm = new Int16Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 2); + const w = 40; // 5 ms windows + for (let i = Math.floor(after * 8000); i + w < pcm.length; i += w) { + let sum = 0; + for (let k = i; k < i + w; k += 1) sum += pcm[k] * pcm[k]; + if (Math.sqrt(sum / w) > 1000) return i / 8000; + } + return null; +} + +test("the crossfade concat keeps every clip's sound on its picture", { skip: !have && "no ffmpeg" }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "av-sync-")); + try { + const durs = [4, 4, 4]; + const segs = durs.map((d, i) => segment(dir, `s${i}`, d)); + const D = 0.5; + const run = (graph, out) => { + const r = spawnSync("ffmpeg", [ + "-v", "error", "-y", ...segs.flatMap((s) => ["-i", s]), + "-filter_complex", graph.parts.join(";"), + "-map", graph.vlab, "-map", graph.alab, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", out, + ]); + assert.equal(r.status, 0, String(r.stderr)); + return out; + }; + // The third segment starts at 2 × (4 − 0.5) = 7.0 s; its tone at 8.0 s. + const pinned = onsetAfter(run(xfadeGraph(durs, D, null, RENDER), path.join(dir, "pinned.mp4")), 7.6); + assert.ok(Math.abs(pinned - 8.0) <= 0.01, `pinned onset ${pinned}, expected 8.0`); + + // The unpinned graph (what the concat wrote before) lands each later clip + // 30 ms earlier per clip before it: the drift this guards against. + const unpinned = { + parts: [ + `[0:v][1:v]xfade=transition=fade:duration=${D}:offset=3.500[v1]`, + `[0:a][1:a]acrossfade=d=${D}:c1=tri:c2=tri[a1]`, + `[v1][2:v]xfade=transition=fade:duration=${D}:offset=7.000[v2]`, + `[a1][2:a]acrossfade=d=${D}:c1=tri:c2=tri[a2]`, + ], + vlab: "[v2]", alab: "[a2]", + }; + const drift = onsetAfter(run(unpinned, path.join(dir, "unpinned.mp4")), 7.6); + assert.ok(8.0 - drift >= 0.045, `unpinned onset ${drift} should be ~60 ms early`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -66,6 +66,7 @@ // Requires: yt-dlp, ffmpeg/ffprobe, ImageMagick with Pango. import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { promisify } from "node:util"; import { mkdir, writeFile, readFile, access, readdir, rename, stat } from "node:fs/promises"; import path from "node:path"; @@ -81,8 +82,9 @@ import { ensureWriteDir } from "../lib/report/storage.mjs"; // and live in deck.mjs. This file only frames segments into its box and writes // the schedule down -- it never has a copy of the arithmetic. import { - assertChrome, deckGeometry, deckOn, deckSchedule, frameCount, postsGeometry, postWindows, resolveDeck, - scheduleFrom, snapWindow, validatePosts, + assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, frameCount, MUTE_FADE, muteSegmentSeconds, + playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserTitle, + validateCutEdits, validatePosts, validateTeasers, } from "./deck.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, // in common, plain JS so bare `node` can load it. @@ -714,10 +716,8 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, // The lead-in is a breath before the first word, clamped into the extent: // starting exactly on the quote's first syllable sounds like a dropped // frame. - const lead = render.leadIn ?? 0.4; const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); - const playFrom = hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start; - const playTo = hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end; + const { from: playFrom, to: playTo } = playWindow(entry, render); if (hasCut) { EMIT("cut", { id: entry.id, @@ -750,6 +750,14 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, const cutA = Math.min(a.at, wantB - 1); const cutB = Math.max(b.at, cutA + 1); EMIT("snap", { id: entry.id, start: a.snapped, end: b.snapped, seconds: cutB - cutA }); + // Where in the SOURCE this segment really starts and ends, snapped: what a + // `muteFrom` (source seconds) is measured from at the join, long after this + // function is gone (`--chrome-only` rebuilds no segment). + const cutRecord = { + version: 1, id: entry.id, video: entry.video, + start: Number((fetchStart + cutA).toFixed(3)), end: Number((fetchStart + cutB).toFixed(3)), + snapped: { start: a.snapped, end: b.snapped }, + }; const quotePath = path.join(outDir, "segments", `${entry.id}.quote.txt`); const attribPath = path.join(outDir, "segments", `${entry.id}.attrib.txt`); @@ -789,6 +797,7 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, ], { maxBuffer: 1 << 24 }, ); + await writeCutRecord(seg, cutRecord); return seg; } @@ -919,9 +928,22 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, ], { maxBuffer: 1 << 24 }, ); + await writeCutRecord(seg, cutRecord); return seg; } +/** Beside `segments/<id>.mp4`: `<id>.cut.json`, the source seconds it was cut from. */ +export const cutRecordPath = (seg) => seg.replace(/\.mp4$/, ".cut.json"); + +async function writeCutRecord(seg, record) { + await writeFile(cutRecordPath(seg), JSON.stringify(record) + "\n", "utf8"); +} + +/** A segment's cut record, or null when there is none (a segment built before records existed). */ +export async function readCutRecord(seg) { + return readFile(cutRecordPath(seg), "utf8").then(JSON.parse, () => null); +} + // ---- QR provenance code -------------------------------------------------- // A compilation asks the viewer to take the edit on trust. The QR is the antidote: // it resolves to this clip's exact START in the archive's own viewer, so anyone can @@ -985,6 +1007,160 @@ async function buildCardSegment(card, render, outDir, nodes) { return seg; } +// ---- the teaser ----------------------------------------------------------- +// A `teaser` entry is a full-frame graphic card -- a season teaser's "coming +// soon" screen -- drawn by a HyperFrames composition from the entry's words +// (chrome-teaser.mjs) and rendered once, cached by the page's content key +// (compose-chrome). This encodes those frames into the segment at the +// parameters every segment shares, with a sound track: a trailer hit under +// each pop and a swell under the tail (`teaserHits`, deck.mjs -- the same +// times the composition's cues land on), or digital silence with `hits: false`. +// +// The segment is re-encoded only when its key changes: the frames' key and the +// sound's graph, recorded beside it (`<id>.teaser.json`). So a teaser whose +// words changed is re-rendered and re-encoded by any build that reaches it -- +// `--chrome-only` included, which builds teaser segments (they are chrome: +// graphics made from the manifest, nothing fetched) -- and an unchanged one is +// neither. + +/** The record beside a teaser's segment: the key it was encoded from. */ +export const teaserRecordPath = (seg) => seg.replace(/\.mp4$/, ".teaser.json"); + +/** The teaser sound's level and ceiling: `limit` is −6 dBFS; `level` is set against the ferret cut's loudness (README). */ +export const TEASER_AUDIO = Object.freeze({ level: 1.4, limit: 0.5 }); + +/** A number for an aevalsrc expression: 6 decimals, no trailing zeros. */ +const ev6 = (v) => { + const s = (Math.round(Number(v) * 1e6) / 1e6).toFixed(6).replace(/\.?0+$/, ""); + return s === "-0" ? "0" : s; +}; + +/** + * The teaser's sound, as one filtergraph fragment from no inputs to `[ta]`: + * `hits` (`teaserHits`) synthesised in ffmpeg, no samples. + * + * A hit is three layers, each summed over every hit in one `aevalsrc` and + * gated to its own span, so each starts on the sample its time names: + * - the boom: a sine whose pitch drops from f0 to f1 (most of the way in + * ~0.4 s), with its octave for body, on an exponential decay (`decay` is + * the time constant; it is inaudible by ~7×) after a 3 ms attack; + * - the punch: a burst of noise (50 ms time constant), band-passed (180 Hz–3.2 kHz); + * - the tail: a low noise decay (low-passed at 260 Hz) under it. + * A swell is the boom's sine rising f0 → f1 under an envelope that peaks + * three quarters of the way through `dur` and settles, with a breath of the + * low noise. The noise is a hash of the sample number, not `random()`, so it + * is the same whatever else is in the graph. The sum takes a short low-passed + * echo, then `level`, then a limiter at −6 dBFS (`limit`, no auto-level, + * latency compensated) so hits that overlap still sum cleanly; trimmed and + * padded to exactly `seconds`. No hits: digital silence. + */ +export function teaserAudioGraph(hits, { seconds, render, level = TEASER_AUDIO.level, limit = TEASER_AUDIO.limit }) { + const rate = render.audioRate; + const layout = render.audioChannels === 1 ? "mono" : "stereo"; + const len = ev6(seconds); + const tail = `atrim=end=${len},apad=whole_dur=${len},asetpts=PTS-STARTPTS[ta]`; + if (!hits.length) return `anullsrc=channel_layout=${layout}:sample_rate=${rate},${tail}`; + const noise = "(2*(sin(n*12.9898+78.233)*43758.5453-floor(sin(n*12.9898+78.233)*43758.5453))-1)"; + const boom = []; + const punch = []; + const rumble = []; + for (const h of hits) { + const a = ev6(h.at); + const u = `(t-${a})`; + const g = ev6(h.gain); + if (h.kind === "swell") { + const D = h.dur; + const peak = ev6(D * 0.75); + const v = `min(${u},${ev6(D)})`; + const phase = `(${ev6(h.f0)}*${v}+${ev6((h.f1 - h.f0) / (2 * D))}*${v}*${v}+${ev6(h.f1)}*(${u}-${v}))`; + const env = `pow(sin(PI/2*min(1,${u}/${peak})),2)*exp(-max(0,${u}-${peak})/${ev6(h.decay)})`; + const span = ev6(D * 0.75 + h.decay * 7); + boom.push(`if(between(t,${a},${a}+${span}),${g}*0.42*${env}*sin(2*PI*${phase}),0)`); + rumble.push(`if(between(t,${a},${a}+${span}),${g}*0.5*${env}*${noise},0)`); + continue; + } + const k = 0.13; // the pitch drop's time constant + const phase = `(${ev6(h.f1)}*${u}+${ev6((h.f0 - h.f1) * k)}*(1-exp(-${u}/${k})))`; + const span = ev6(h.decay * 7); + boom.push( + `if(between(t,${a},${a}+${span}),${g}*0.3*min(1,${u}/0.003)*exp(-${u}/${ev6(h.decay)})*` + + `(sin(2*PI*${phase})+0.6*exp(-${u}/${ev6(h.decay * 0.6)})*sin(4*PI*${phase})),0)`, + ); + punch.push(`if(between(t,${a},${a}+0.25),${g}*0.5*exp(-${u}/0.05)*${noise},0)`); + rumble.push(`if(between(t,${a},${a}+${ev6(Math.min(2.6, h.decay * 6))}),${g}*0.32*min(1,${u}/0.02)*exp(-${u}/${ev6(h.decay * 1.3)})*${noise},0)`); + } + const src = (terms) => `aevalsrc=exprs='${terms.length ? terms.join("+") : "0"}':s=${rate}:c=${layout}:d=${len}`; + return [ + `${src(boom)}[tb]`, + `${src(punch)},highpass=f=180,lowpass=f=3200[tp]`, + `${src(rumble)},lowpass=f=260,lowpass=f=260[tr]`, + `[tb][tp][tr]amix=inputs=3:normalize=0,aecho=1:1:90|190:0.18|0.09,lowpass=f=5000,` + + `volume=${ev6(level)},alimiter=limit=${ev6(limit)}:level=0:latency=1:attack=2:release=80,${tail}`, + ].join(";"); +} + +/** The teaser segment's encode: its frames, its sound, the shared parameters. */ +export function teaserEncodeArgs({ framesDir, seconds, render, audio, outPath }) { + return [ + "-nostdin", "-v", "error", "-y", + // The renderer writes an all-opaque frame as RGB and any other as RGBA; + // a switch mid-sequence would reinitialise the graph and end it early. + "-framerate", String(render.fps), "-reinit_filter", "0", "-start_number", "1", + "-i", path.join(framesDir, "frame_%06d.png"), + "-filter_complex", `[0:v]format=rgba,fps=${render.fps},setsar=1[tv];${audio}`, + "-map", "[tv]", "-map", "[ta]", + ...encodeArgs(render), + "-frames:v", String(frameCount(seconds, render.fps)), + "-shortest", + outPath, + ]; +} + +/** + * A teaser segment's key: its frames' render key, its sound's whole graph + * (every hit's time and parameters, `hits: false`'s silence, the level) and + * the encode's parameters (`encodeArgs`: crf, preset, the audio's bitrate, + * rate and channels -- the graph says "stereo" whatever `audioChannels` is). + * A segment whose recorded key differs is re-encoded, as every clip is when + * one of those changes. + */ +export const teaserSegmentKey = (framesKey, audioGraph, render) => + createHash("sha256") + .update(JSON.stringify({ v: 2, frames: framesKey, audio: audioGraph, encode: encodeArgs(render) })) + .digest("hex"); + +/** + * Compose and render the teaser (cached by compose-chrome's key), then encode + * its segment unless the one on disk was made from the same frames and sound. + * Dynamic import: compose-chrome imports this file, and its page module must + * not reach umtool's bundle through the build (docs/quirks.md). + */ +async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant }) { + const { composeChrome } = await import("./compose-chrome.mjs"); + const t0 = Date.now(); + const r = await composeChrome({ + manifestPath, outDir, variant, region: "teaser", segment: entry.id, doRender: true, + fps: render.fps, workers: 4, quality: "high", format: "png-sequence", + }); + EMIT("chrome", { + phase: r.cached ? "cached" : "render", region: "teaser", segment: entry.id, + frames: r.frameCount, key: r.key, dir: r.frames, seconds: Number(((Date.now() - t0) / 1000).toFixed(1)), + }); + const seconds = Number(entry.seconds); + const audio = teaserAudioGraph(teaserHits(entry), { seconds, render }); + const key = teaserSegmentKey(r.key, audio, render); + const seg = path.join(outDir, "segments", `${entry.id}.mp4`); + const recPath = teaserRecordPath(seg); + const rec = await readFile(recPath, "utf8").then(JSON.parse, () => null); + if (rec?.key === key && (await exists(seg))) { + EMIT("note", { id: entry.id, message: `${entry.id}: teaser segment unchanged (key ${key.slice(0, 12)})` }); + return seg; + } + await execFileP(FFMPEG, teaserEncodeArgs({ framesDir: r.frames, seconds, render, audio, outPath: seg }), { maxBuffer: 1 << 26 }); + await writeFile(recPath, JSON.stringify({ key, frames: r.key, hits: teaserHits(entry).length }) + "\n", "utf8"); + return seg; +} + // ---- stills -------------------------------------------------------------- // An `image` entry is a screenshot in the cut: the receipts a clip cannot say // out loud -- a post, a thread, a DM -- shown for `seconds` and then gone. @@ -2106,28 +2282,329 @@ async function buildChartSegment(card, render, outDir, ledger) { return seg; } -// Crossfade every segment into the next. This is a full re-encode of the -// timeline — the concat demuxer can only stream-copy hard cuts — so --no-xfade -// stays available for quick iteration. -async function concatWithXfade(segments, render, outPath, railPlan, chrome = null) { - const D = render.transition ?? 0.5; - const durs = []; - for (const s of segments) durs.push(await probeDuration(s, render.fps)); +// ---- room for posts: the hold and the footage move, where the cut is joined -- +// A clip that carries posts is HELD on its last frame (`segments[i].hold` in +// the deck's schedule) and its footage MOVES aside as its first post appears +// (`schedule.moves`). Both happen on that segment's input chain, before the +// join -- never in the segment file -- so `--chrome-only` changes them +// without rebuilding a clip, and every other input's chain is exactly what it +// was. A cut without posts has no joins (`segmentJoins` returns null) and +// every line below behaves as it always did. - const inputs = segments.flatMap((s) => ["-i", s]); +/** + * Per-input join work from the deck's schedule, aligned with its segments: + * `{ hold, move }` for a carrying clip, null for every other; null overall when + * no segment has either -- the switch that keeps a cut without posts on the + * paths it always took. + * + * @returns {Array<{ hold: number, move: object|null } | null> | null} + */ +export function segmentJoins(schedule) { + if (!schedule?.segments) return null; + const moves = new Map((schedule.moves ?? []).map((m) => [m.segment, m])); + const joins = schedule.segments.map((s) => { + const hold = s.hold > 0 ? s.hold : 0; + const move = moves.get(s.id) ?? null; + return hold || move ? { hold, move } : null; + }); + return joins.some(Boolean) ? joins : null; +} + +/** A number for an ffmpeg expression or option: at most 4 decimals, no trailing zeros. */ +const exprNum = (v) => { + const s = (Math.round(Number(v) * 1e4) / 1e4).toFixed(4).replace(/\.?0+$/, ""); + return s === "-0" ? "0" : s; +}; + +/** + * The footage move as one `perspective` filter (destination sense, evaluated + * per frame): an affine map of the WHOLE frame that takes the `from` box to + * the box eased toward `to`. Each corner of the input frame is placed at + * `base + d·e(t)`, where e is smoothstep over [segmentAt, segmentAt + seconds] + * in the segment's own clock (its hold included: the move runs after the + * `tpad`) and d is where that corner has gone at e = 1 -- + * scale `to.width / from.width` (and height), then translate. Before the move + * e = 0 and the map is the identity, which perspective copies bit for bit; + * after it e = 1 and the frame holds at `to`. + * + * Perspective resamples at 1/256 px, so the box glides with no whole-pixel + * stepping (scale + overlay and zoompan both round to whole pixels). It has no + * `t`, and its `in` counts frames from 1 -- hence `(in-1)/fps`. What the + * shrink uncovers is filled from the input's edge (perspective clamps), which + * the deck framing made `palette.bg`; `fillborders` pins the outermost pixels + * to it exactly, so a coding artefact at the edge cannot be smeared across + * the uncovered band. + */ +export function moveFilter(move, render) { + const { from: F, to: T } = move; + const kx = T.width / F.width - 1; + const ky = T.height / F.height - 1; + const dx = (u) => T.x - F.x + (u - F.x) * kx; + const dy = (v) => T.y - F.y + (v - F.y) * ky; + const W = render.width ?? 1920; + const H = render.height ?? 1080; + const t = `(in-1)/${render.fps}`; + const a = exprNum(move.segmentAt); + const p = move.seconds > 0 ? `clip((${t}-${a})/${exprNum(move.seconds)},0,1)` : `gte(${t},${a})`; + // smoothstep, 3p² − 2p³: flat at both ends, so the glide starts and lands without a jolt. + const e = (base, d) => `'st(0,${p});${base}+(${exprNum(d)})*ld(0)*ld(0)*(3-2*ld(0))'`; + const corners = [ + ["x0", e("0", dx(0))], ["y0", e("0", dy(0))], + ["x1", e("W", dx(W))], ["y1", e("0", dy(0))], + ["x2", e("0", dx(0))], ["y2", e("H", dy(H))], + ["x3", e("W", dx(W))], ["y3", e("H", dy(H))], + ]; + return [ + `fillborders=left=2:right=2:top=2:bottom=2:mode=fixed:color=${render.palette.bg}`, + `perspective=${corners.map(([k, v]) => `${k}=${v}`).join(":")}:interpolation=linear:sense=destination:eval=frame`, + ].join(","); +} + +/** The hold on a segment's picture: its last frame, cloned for `hold` seconds. */ +export const holdVideoFilter = (hold) => `tpad=stop_mode=clone:stop_duration=${exprNum(hold)}`; +/** The hold on its sound: silence, for the same `hold` seconds. */ +export const holdAudioFilter = (hold) => `apad=pad_dur=${exprNum(hold)}`; + +/** + * A `muteFrom` on a segment's sound: silent from `at` (segment seconds) to its + * end, after a MUTE_FADE that ENDS at `at`, so nothing of a sound that starts + * there gets through and there is no click. `afade` out writes digital silence + * (zeros) after its fade and copies every sample before it. At 0 the whole + * segment is silent. + */ +export const muteAudioFilter = (at) => { + if (!(at > 0)) return "volume=0"; + const st = Math.max(0, at - MUTE_FADE); + return `afade=t=out:st=${exprNum(st)}:d=${exprNum(at - st)}`; +}; + +/** + * A `#rrggbb` colour as 8-bit limited-range BT.601 Y′CbCr -- what `pad` and a + * `color` source write for it into the segments' yuv420p (`#12101a` is + * 31/132/128 in both, measured). + */ +export function yuv601(hex) { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex)); + if (!m) throw new Error(`not a #rrggbb colour: ${hex}`); + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + return { + y: Math.round(16 + 65.481 * r + 128.553 * g + 24.966 * b), + u: Math.round(128 - 37.797 * r - 74.203 * g + 112 * b), + v: Math.round(128 + 112 * r - 93.786 * g - 18.214 * b), + }; +} + +/** + * The end fade on the cut's last segment, over its final `seconds`: the + * picture eased to `palette.bg`, so the LAST frame (`lastFrame`, 0-based, the + * hold's clones included) is exactly bg; the sound faded to silence at that + * frame's time. + * + * Not `fade=…:color=`: a coloured fade takes RGB only, so ffmpeg converts the + * segment to rgb24 -- and the concat filter then negotiates every OTHER + * segment to rgb24 too, a lossy round trip for the whole cut. `geq` blends + * each plane toward bg's Y′CbCr in the segment's own yuv420p, and only from + * the fade's first frame (`enable`): every frame before it passes untouched. + * Frame f's weight is (f − s)/n with s = lastFrame − n, so s is the last + * frame untouched and lastFrame is bg; `+0.5` rounds where geq truncates. + * + * A fade longer than the segment is the segment: n is clamped to lastFrame + * (`endFadeFrames`), so the fade runs from its first frame and its last is + * still exactly bg. Unclamped, the weight's denominator stayed n while s + * clamped to 0, and the last frame of a 3 s segment under `endFade: 5` was + * only 59 % of the way there while the sound did reach silence. + */ +export const endFadeFrames = (fade, fps) => + Math.max(1, Math.min(Math.round(fade.seconds * fps), fade.lastFrame)); +export const endFadeVideoFilter = (fade, render) => { + const fps = render.fps; + const n = endFadeFrames(fade, fps); + const st = exprNum(Math.max(0, fade.lastFrame - n) / fps); + const k = `clip((T-${st})/${exprNum(n / fps)},0,1)`; + const bg = yuv601(render.palette.bg); + const plane = (p, c) => `'${p}(X,Y)+(${c}-${p}(X,Y))*${k}+0.5'`; + return `geq=lum=${plane("lum", bg.y)}:cb=${plane("cb", bg.u)}:cr=${plane("cr", bg.v)}:enable='gte(t,${st})'`; +}; +/** The sound's half: the same frames as the picture's, so the two end together. */ +export const endFadeAudioFilter = (fade, render) => { + const end = fade.lastFrame / render.fps; + const st = Math.max(0, end - endFadeFrames(fade, render.fps) / render.fps); + return `afade=t=out:st=${exprNum(st)}:d=${exprNum(Math.max(1e-3, end - st))}`; +}; + +/** + * Input `i`'s chains before the join. Without a join the labels are the + * input's own (`[i:v]`, `[i:a]`) and there is no chain at all, so a cut + * without posts writes the graph it always did. + * + * The move goes AFTER the hold: its clock (`in`) then counts the held frames + * too, so a first post that appears inside the hold -- or so late that the + * glide runs past the clip's own last frame -- still moves the footage, the + * frozen frame with it, exactly when the schedule (and umtool's preview) say. + * Before the hold, such a move never started or froze part-way. + * + * @returns {{ parts: string[], v: string, a: string }} + */ +export function joinInputChain(i, join, render) { + if (!join) return { parts: [], v: `[${i}:v]`, a: `[${i}:a]` }; const parts = []; - let vlab = "[0:v]"; - let alab = "[0:a]"; - let acc = durs[0]; + // Picture: hold, move, end fade. Sound: mute, hold, end fade -- the mute is + // in the clip's own clock and the hold is silence anyway; the end fade is + // last on both, over the segment's final seconds as the cut plays them. + const vf = [ + join.hold > 0 ? holdVideoFilter(join.hold) : null, + join.move ? moveFilter(join.move, render) : null, + join.fade ? endFadeVideoFilter(join.fade, render) : null, + ].filter(Boolean); + const v = vf.length ? `[j${i}v]` : `[${i}:v]`; + if (vf.length) parts.push(`[${i}:v]${vf.join(",")}${v}`); + const af = [ + join.mute != null ? muteAudioFilter(join.mute) : null, + join.hold > 0 ? holdAudioFilter(join.hold) : null, + join.fade ? endFadeAudioFilter(join.fade, render) : null, + ].filter(Boolean); + const a = af.length ? `[j${i}a]` : `[${i}:a]`; + if (af.length) parts.push(`[${i}:a]${af.join(",")}${a}`); + return { parts, v, a }; +} + +/** + * The joins with the cut's edits merged in: a `muteFrom` (`mutes`: segment + * index → segment seconds) and the end fade on the LAST segment + * (`fade`: `{ seconds, lastFrame }`). A join gains `mute` / `fade` only when it + * has one, so a cut without either keeps exactly the joins (and the graph, + * and the hard-cut record) it had; null when nothing is joined at all. + */ +export function withCutEdits(joins, n, { mutes = new Map(), fade = null } = {}) { + if (!mutes.size && !fade) return joins; + const out = Array.from({ length: n }, (_, i) => joins?.[i] ?? null); + for (const [i, at] of mutes) out[i] = { hold: 0, move: null, ...(out[i] ?? {}), mute: at }; + if (fade) out[n - 1] = { hold: 0, move: null, ...(out[n - 1] ?? {}), fade }; + return out.some(Boolean) ? out : null; +} + +/** + * Every join the cut makes: the deck's holds and moves (`segmentJoins` of its + * schedule; none without the deck), each clip's `muteFrom` mapped to its + * segment's clock through the segment's cut record, and `render.endFade` on + * the last segment. Reads the records and probes what it needs; null when + * nothing is joined, so every concat then runs as it always did. + */ +export async function cutJoins({ schedule = null, entries, segments, render }) { + const base = schedule ? segmentJoins(schedule) : null; + const mutes = new Map(); + for (let i = 0; i < entries.length; i += 1) { + const e = entries[i]; + if (e.type !== "clip" || e.muteFrom == null) continue; + const seconds = await probeDuration(segments[i], render.fps); + const m = muteSegmentSeconds({ entry: e, record: await readCutRecord(segments[i]), render, seconds }); + if (m.note) EMIT("note", { id: e.id, message: m.note }); + const from = m.source === "record" ? "from its cut record" : "from the unsnapped start"; + // Validation allows the clip's whole extent, but only the played window is + // in the segment: a mark past the cut's end mutes nothing, and saying + // "muted from" would claim otherwise. + EMIT("note", { + id: e.id, + message: m.at >= seconds + ? `${e.id}: muteFrom ${e.muteFrom} will not be heard -- it lies ${m.at}s into a segment ${seconds}s long, past the cut's end (${from})` + : `${e.id}: muted from ${m.at}s into its segment (muteFrom ${e.muteFrom}, ${from})`, + }); + mutes.set(i, m.at); + } + const seconds = endFadeOf(render); + let fade = null; + if (seconds > 0 && segments.length) { + const last = segments.length - 1; + const frames = Math.round((await probeDuration(segments[last], render.fps)) * render.fps) + + Math.round((base?.[last]?.hold ?? 0) * render.fps); + fade = { seconds, lastFrame: frames - 1 }; + } + return withCutEdits(base, segments.length, { mutes, fade }); +} - for (let i = 1; i < segments.length; i += 1) { +/** + * The segments' lengths IN THE CUT: probed, plus each one's hold -- the sum + * `deckSchedule` makes (it is handed the same probed lengths and adds the same + * holds), so the xfade offsets, the chapters and a preview's window agree with + * the schedule's starts. Without joins this is segmentOffsets, unchanged. + */ +export async function cutOffsets(segments, D, fps, joins = null) { + const { durs } = await segmentOffsets(segments, D, fps); + const full = durs.map((d, i) => d + (joins?.[i]?.hold ?? 0)); + return { ...scheduleFrom(full, D), durs: full }; +} + +/** + * The crossfade concat's filtergraph from the cut's segment lengths (`durs`, + * holds included) and the per-input joins. Without joins, the graph it always was. + */ +export function xfadeGraph(durs, D, joins = null, render = null) { + const parts = []; + const ins = durs.map((d, i) => { + const c = joinInputChain(i, joins?.[i] ?? null, render); + parts.push(...c.parts); + // The sound is pinned to the picture's length before it is crossfaded. + // `xfade` places segment i+1 by the PICTURE's length, `acrossfade` by the + // SOUND's, and an encoded segment's audio is routinely a few to ~20 ms off + // its video (AAC frames do not end on video frames). Unpinned, the error + // accumulates segment by segment: measured 0.30 s early by the last clip of + // a 17-clip cut, 2.0 s on one with title and sources cards. Padded with + // silence and trimmed to exactly `d`, every clip's sound starts with its + // picture. `d` is the segment's length in the cut (frames ÷ fps, hold + // included), the same number the xfade offsets are summed from. + const len = d.toFixed(6); + const a = `[p${i}a]`; + parts.push(`${c.a}apad=whole_dur=${len},atrim=end=${len},asetpts=PTS-STARTPTS${a}`); + return { v: c.v, a }; + }); + let vlab = ins[0].v; + let alab = ins[0].a; + let acc = durs[0]; + for (let i = 1; i < durs.length; i += 1) { const off = acc - D; - parts.push(`${vlab}[${i}:v]xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`); - parts.push(`${alab}[${i}:a]acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`); + parts.push(`${vlab}${ins[i].v}xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`); + parts.push(`${alab}${ins[i].a}acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`); vlab = `[v${i}]`; alab = `[a${i}]`; acc = acc + durs[i] - D; } + return { parts, vlab, alab }; +} + +/** + * A hard-cut concat through the concat FILTER, for a cut whose joins need a + * filtergraph (the demuxer's stream copy cannot host one): every input's + * chain, then `concat`, one encode at the parameters every segment shares. + */ +export function hardCutFilterArgs(segments, joins, render, outPath) { + const parts = []; + const pairs = segments.map((_, i) => { + const c = joinInputChain(i, joins?.[i] ?? null, render); + parts.push(...c.parts); + return `${c.v}${c.a}`; + }); + parts.push(`${pairs.join("")}concat=n=${segments.length}:v=1:a=1[vc][ac]`); + return [ + "-nostdin", "-v", "error", "-y", + ...segments.flatMap((s) => ["-i", s]), + "-filter_complex", parts.join(";"), + "-map", "[vc]", "-map", "[ac]", + ...encodeArgs(render), + outPath, + ]; +} + +// Crossfade every segment into the next. This is a full re-encode of the +// timeline — the concat demuxer can only stream-copy hard cuts — so --no-xfade +// stays available for quick iteration. `joins` (the deck's holds and moves, +// `segmentJoins`) go on their inputs before the join; null leaves the graph +// exactly as it was. +async function concatWithXfade(segments, render, outPath, railPlan, chrome = null, joins = null) { + const D = render.transition ?? 0.5; + const { durs } = await cutOffsets(segments, D, render.fps, joins); + + const inputs = segments.flatMap((s) => ["-i", s]); + const { parts, vlab, alab } = xfadeGraph(durs, D, joins, render); // The rail attaches to the LAST xfade node, so it runs after every dissolve // and sees an absolute, continuous `t`. One encode, not two. @@ -2282,27 +2759,28 @@ export function windowSegments(starts, durs, at, dur) { * runs over the whole cut (or a plain concat for hard cuts), trimmed to the * window, the window's deck frames over it. Seconds, not a whole-cut encode. */ -export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, render, chromePlan, outPath }) { +export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, render, chromePlan, outPath, joins = null }) { + // `durs`/`starts` are the CUT's (cutOffsets: holds included), and the + // window's inputs carry their joins as the full concat's do. const { first, last, offset } = windowSegments(starts, durs, at, dur); const segs = segments.slice(first, last + 1); const ds = durs.slice(first, last + 1); - const parts = []; - let vlab = "[0:v]"; - let alab = "[0:a]"; + const js = joins ? joins.slice(first, last + 1) : null; + let parts = []; + let vlab; + let alab; if (segs.length > 1 && D > 0) { - let acc = ds[0]; - for (let i = 1; i < segs.length; i += 1) { - const off = acc - D; - parts.push(`${vlab}[${i}:v]xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`); - parts.push(`${alab}[${i}:a]acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`); - vlab = `[v${i}]`; - alab = `[a${i}]`; - acc = acc + ds[i] - D; + ({ parts, vlab, alab } = xfadeGraph(ds, D, js, render)); + } else { + const ins = segs.map((_, i) => joinInputChain(i, js?.[i] ?? null, render)); + for (const c of ins) parts.push(...c.parts); + vlab = ins[0].v; + alab = ins[0].a; + if (segs.length > 1) { + parts.push(`${ins.map((c) => `${c.v}${c.a}`).join("")}concat=n=${segs.length}:v=1:a=1[vc][ac]`); + vlab = "[vc]"; + alab = "[ac]"; } - } else if (segs.length > 1) { - parts.push(`${segs.map((_, i) => `[${i}:v][${i}:a]`).join("")}concat=n=${segs.length}:v=1:a=1[vc][ac]`); - vlab = "[vc]"; - alab = "[ac]"; } const S = offset.toFixed(3); const T = Number(dur).toFixed(3); @@ -2386,14 +2864,15 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro * schedule says AND no segment is newer than it: a re-trimmed clip that kept * its length would otherwise pass the length check and play the old cut. */ -async function freshConcat(file, segments, total, fps) { +async function freshConcat(file, segments, total, fps, joins = null) { const st = await stat(file).catch(() => null); if (!st) return false; - // Same segments, same order. Length and age alone would take a REORDERED - // timeline's old concat -- every title, QR and chapter then lands on the - // wrong footage while the length check still passes. + // Same segments, same order, and the same holds and moves on them. Length + // and age alone would take a REORDERED timeline's old concat -- every title, + // QR and chapter then lands on the wrong footage while the length check + // still passes -- or one whose holds were joined differently. const recorded = await readFile(`${file}.segments`, "utf8").catch(() => null); - if (!sameConcatList(recorded, segments)) return false; + if (!sameConcatList(recorded, segments, joins)) return false; for (const s of segments) { if ((await stat(s)).mtimeMs > st.mtimeMs) return false; } @@ -2401,9 +2880,12 @@ async function freshConcat(file, segments, total, fps) { return got != null && Math.abs(got - total) <= 1.5 / fps; } -/** Does a recorded concat list name exactly these segments, in this order? */ -export function sameConcatList(recorded, segments) { - return recorded != null && recorded === concatListText(segments); +/** + * Does a recorded concat list name exactly these segments, in this order, + * joined the same way (`joins`, the deck's holds and moves)? + */ +export function sameConcatList(recorded, segments, joins = null) { + return recorded != null && recorded === concatRecordText(segments, joins); } // ---- chapter markers ----------------------------------------------------- @@ -2436,6 +2918,8 @@ export async function segmentOffsets(segments, D, fps) { export async function chapterTitle(entry, index, provenance, { deck = false } = {}) { if (entry.chapter) return entry.chapter; if (deck && entry.onscreen?.title) return entry.onscreen.title; + // A teaser is named by its own words, with or without the deck. + if (entry.type === "teaser") return teaserTitle(entry) || `Teaser ${index + 1}`; // A still's chapter is the SAME line it burns into the header, for the reason // a clip's is: the chapter list and the picture are two views of one cut, and // a viewer jumping by chapter should land on the words they were shown. @@ -2476,6 +2960,13 @@ export async function chapterTitle(entry, index, provenance, { deck = false } = * @returns the schedule document (deck.mjs's shape) */ export async function writeChromeSchedule({ manifest, entries, segments, D, outDir }) { + const doc = await measureChromeSchedule({ manifest, entries, segments, D }); + await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n"); + return doc; +} + +/** writeChromeSchedule's document, not written: what `--chapters-only` measures the cut by. */ +export async function measureChromeSchedule({ manifest, entries, segments, D }) { const { render, provenance = {} } = manifest; const { durs } = await segmentOffsets(segments, D, render.fps); const metas = []; @@ -2492,14 +2983,14 @@ export async function writeChromeSchedule({ manifest, entries, segments, D, outD } // The variant's posts, placed on the clips this cut plays with their real // upload dates. A manifest without posts writes the schedule it always did. - const doc = deckSchedule({ entries, durs, D, render, provenance, metas, posts: manifest.posts ?? [] }); - await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n"); - return doc; + return deckSchedule({ entries, durs, D, render, provenance, metas, posts: manifest.posts ?? [] }); } -async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false) { +async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false, joins = null) { if (segments.length < 2) return; - const { starts, total } = await segmentOffsets(segments, D, fps); + // The cut's own offsets: under the deck a held clip is longer in the cut + // than its file, and every chapter after it starts that much later. + const { starts, total } = await cutOffsets(segments, D, fps, joins); const lines = [";FFMETADATA1", ""]; for (let i = 0; i < entries.length; i += 1) { // Land just PAST the crossfade, so the marker opens on the incoming clip @@ -2540,7 +3031,37 @@ async function muxChapters(finalPath, entries, segments, D, outDir, provenance, export const concatListText = (segments) => segments.map((s) => `file '${path.resolve(s)}'`).join("\n") + "\n"; -async function concatHardCut(segments, outDir, outPath, { record = false } = {}) { +/** + * What a cached hard-cut concat records it was made from: the list, and -- + * only when there are joins -- one line per joined input with its hold and + * move. Without joins it is the list alone, as it always was. + */ +export function concatRecordText(segments, joins = null) { + const list = concatListText(segments); + if (!joins) return list; + // `mute` and `fade` only when a join has them: a record made before they + // existed, of a cut without them, still matches. + const lines = segments.flatMap((s, i) => (joins[i] + ? [`# join ${i} ${JSON.stringify({ + hold: joins[i].hold, move: joins[i].move, + ...(joins[i].mute != null ? { mute: joins[i].mute } : {}), + ...(joins[i].fade ? { fade: joins[i].fade } : {}), + })}`] + : [])); + return list + lines.join("\n") + "\n"; +} + +/** + * The hard-cut concat. Without joins, the demuxer's stream copy, as always; + * with them (the deck's holds and moves) the concat filter over each input's + * chain, one encode -- the copy cannot host a filtergraph. + */ +async function concatHardCut(segments, outDir, outPath, { record = false, joins = null, render = null } = {}) { + if (joins) { + await execFileP(FFMPEG, hardCutFilterArgs(segments, joins, render, outPath), { maxBuffer: 1 << 26 }); + if (record) await writeFile(`${outPath}.segments`, concatRecordText(segments, joins), "utf8"); + return; + } const listPath = path.join(outDir, "concat.txt"); await writeFile(listPath, concatListText(segments), "utf8"); await execFileP( @@ -2594,6 +3115,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // A `render.chrome` that cannot be built is refused here, before a single // fetch is spent. Absent, validateChrome has nothing to say. if (render.chrome !== undefined && render.chrome !== null) assertChrome(render.chrome, render); + // A clip's `muteFrom` and `render.endFade`, checked against the WHOLE + // manifest, deck or not: both are made where the cut is joined. + { + const errors = [...validateCutEdits(whole), ...validateTeasers(whole)]; + if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`); + } const deck = deckOn(render); // Posts are drawn only under the deck, so only the deck refuses bad ones -- // against the WHOLE timeline, where an `attachTo` has to name a clip. @@ -2638,8 +3165,8 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // A still has nothing to fetch and is already on disk, so this is a no-op // rather than an error: a bench that walks the timeline asking for each // entry's window should not have to know which kinds have one. - if (entry?.type === "image") { - EMIT("note", { message: `${fetchOnly} is an image entry — nothing to fetch` }); + if (entry?.type === "image" || entry?.type === "teaser") { + EMIT("note", { message: `${fetchOnly} is ${entry.type === "image" ? "an image" : "a teaser"} entry — nothing to fetch` }); EMIT("done", { out: null, nothingToFetch: true }); return { out: null, failures: [] }; } @@ -2723,7 +3250,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly if (!(await exists(seg))) throw new Error(`--chapters-only needs ${seg}, which is missing — run a full build first`); } - await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render)); + // Under the deck the cut's offsets include the holds: measured, not written. + const joins = deck ? segmentJoins(await measureChromeSchedule({ manifest, entries, segments: segs, D })) : null; + await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render), joins); return { out: finalPath, failures: [] }; } @@ -2740,8 +3269,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly } if (!(await exists(prerail))) { EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length }); - if (D === 0) await concatHardCut(segs, outDir, prerail); - else await concatWithXfade(segs, render, prerail, null); + const joins = await cutJoins({ entries, segments: segs, render }); + if (D === 0) await concatHardCut(segs, outDir, prerail, { joins, render }); + else await concatWithXfade(segs, render, prerail, null, null, joins); } const railPlan = await buildRailPlan(manifest, render, entries, segs, D, outDir); await assertConcatLength(prerail, railPlan.total, render.fps, @@ -2771,6 +3301,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly if (!deck) throw new Error(`${what} needs the deck: render.chrome is not set in the manifest`); if (opts.noChrome) throw new Error(`${what} and --no-chrome contradict each other`); if (only) throw new Error(`${what} lays the deck over the whole cut; --only does not apply`); + // A teaser's segment is chrome too -- graphics made from the manifest's + // words, nothing fetched -- so it is (re)built here: re-rendered and + // re-encoded only when its words, motion or sound changed. + for (const e of entries) { + if (e.type !== "teaser") continue; + EMIT("card", { id: e.id, i: entries.indexOf(e), n: entries.length }); + await buildTeaserSegment(e, { manifestPath, render, outDir, variant }); + } const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`)); for (const seg of segs) { if (!(await exists(seg))) @@ -2778,6 +3316,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly } const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); + // The holds, the moves, the mutes and the end fade, joined on their + // inputs (null without any). + const joins = await cutJoins({ schedule, entries, segments: segs, render }); const prerail = prerailPath(outDir, manifest.slug, D); if (opts.chromePreview) { @@ -2787,14 +3328,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly manifestPath, render, outDir, variant, schedule, from: at, duration: dur, }); const out = path.join(outDir, `${manifest.slug}.preview.mp4`); - if (await freshConcat(prerail, segs, schedule.total, render.fps)) { + if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) { EMIT("chrome", { phase: "overlay", base: path.basename(prerail) }); await applyChrome(prerail, out, render, plan, { start: at, dur }); } else { - const { starts, durs } = await segmentOffsets(segs, D, render.fps); + const { starts, durs } = await cutOffsets(segs, D, render.fps, joins); EMIT("chrome", { phase: "overlay", base: "segments" }); await execFileP(FFMPEG, previewFromSegmentsArgs({ - segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out, + segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out, joins, }), { maxBuffer: 1 << 26 }); } EMIT("done", { out, failures: [] }); @@ -2808,19 +3349,19 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // The hard-cut concat is a stream copy of these very segments; when // nothing changed since it was made it is reused, and the overlay is // the only encode. - if (await freshConcat(prerail, segs, schedule.total, render.fps)) { + if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) { EMIT("note", { message: `reusing ${path.basename(prerail)}` }); } else { - await concatHardCut(segs, outDir, prerail, { record: true }); + await concatHardCut(segs, outDir, prerail, { record: true, joins, render }); } await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, dirs.final, render, plan, null); } else { - await concatWithXfade(segs, render, dirs.final, null, plan); + await concatWithXfade(segs, render, dirs.final, null, plan, joins); } await assertConcatLength(dirs.final, schedule.total, render.fps, "deck build"); // The overlay re-encodes, so the chapters on the previous final are gone. - if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render)); + if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render), joins); EMIT("done", { out: dirs.final, failures: [] }); return { out: dirs.final, failures: [] }; } @@ -2832,6 +3373,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly if (entry.type === "card") { EMIT("card", { id: entry.id, i, n: entries.length }); segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes)); + } else if (entry.type === "teaser") { + EMIT("card", { id: entry.id, i, n: entries.length }); + segments.push(await buildTeaserSegment(entry, { manifestPath, render, outDir, variant })); } else if (entry.type === "image") { // `card`, not a new event name: umtool's activity feed and build chain // key off this one to mean "a segment that needs no network", and a @@ -2901,6 +3445,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly schedule = await writeChromeSchedule({ manifest, entries, segments, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); } + // The deck's holds and moves, each clip's muteFrom and the end fade, joined + // on their inputs; null without any, which leaves every concat as it was. + const joins = await cutJoins({ schedule: deck ? schedule : null, entries, segments, render }); const railPlan = opts.noRail ? null : await buildRailPlan(manifest, render, entries, segments, D, outDir); @@ -2946,21 +3493,21 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // has to be a second pass here whether we like it or not. const prerail = prerailPath(outDir, manifest.slug, D); if (railPlan) { - await concatHardCut(segments, outDir, prerail); + await concatHardCut(segments, outDir, prerail, { joins, render }); await assertConcatLength(prerail, railPlan.total, render.fps, "hard-cut concat"); await applyRail(prerail, final, render, railPlan, null); } else if (chromePlan) { // Only the deck reaches here (the band refused above, and the deck // refuses a rail). Hard-cut concat to the prerail, then ONE overlay // re-encode to the final. - await concatHardCut(segments, outDir, prerail, { record: true }); + await concatHardCut(segments, outDir, prerail, { record: true, joins, render }); await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, final, render, chromePlan, null); } else { - await concatHardCut(segments, outDir, final); + await concatHardCut(segments, outDir, final, { joins, render }); } } else { - await concatWithXfade(segments, render, final, railPlan, chromePlan); + await concatWithXfade(segments, render, final, railPlan, chromePlan, joins); } // The deck's sequence is laid with shortest=1, so a sequence a frame short // would shorten the cut without a word; the schedule is the length to hold. @@ -2971,7 +3518,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // shortest=1), and a hang means an unbounded -loop 1. if (railPlan) await assertConcatLength(final, railPlan.total, render.fps, "rail build"); - if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render)); + if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render), joins); // A branded cut with a `thumbnail` gets one beside it. The cut is already // done, so a thumbnail that cannot be made is said, not thrown. diff --git a/umtool/report-to-video/chrome-deck.mjs b/umtool/report-to-video/chrome-deck.mjs @@ -66,6 +66,9 @@ const esc = (s) => const r4 = (v) => Math.round(v * 10000) / 10000; +/** The tracking of the QR's host label, in em: part of the length `fitHost` fits to the code. */ +export const HOST_TRACKING = 0.1; + /** The host a QR resolves to -- the one thing on the tile a viewer cannot read off the code. */ export function hostOf(url) { try { @@ -371,6 +374,9 @@ export function deckHtml(schedule, render, opts = {}) { window: windowed ? { from, dur } : null, titleSize: t.titleSize, floor: Math.ceil(t.titleSize * 0.6), + // The QR's host label is fitted to run the code's full height. + hostLength: qr?.size ?? 0, + hostTracking: HOST_TRACKING, ids: schedule.segments.map((s) => s.id), init, cues: cues.map(({ why, ...c }) => c), @@ -455,10 +461,13 @@ export function deckHtml(schedule, render, opts = {}) { height: 4px; border-radius: 2px; background: ${pal.accent}; } .deck-qr { position: absolute; left: ${qr?.x ?? 0}px; top: ${qr?.y ?? 0}px; width: ${qr?.size ?? 0}px; height: ${qr?.size ?? 0}px; transform-style: preserve-3d; } + /* The QR's host, reading up its left side. fitHost sizes it at load so + its ink runs the code's full height, bottom edge to top edge; 11px is + only what shows before the face is in. */ .deck-qr-host { position: absolute; left: ${(qr?.x ?? 0) - 30}px; top: ${qr?.y ?? 0}px; width: 18px; height: ${qr?.size ?? 0}px; writing-mode: vertical-rl; transform: rotate(180deg); - text-align: left; white-space: nowrap; font-size: 11px; line-height: 18px; - letter-spacing: 0.1em; text-transform: uppercase; color: ${rgba(pal.muted, 0.85)}; } + text-align: start; white-space: nowrap; font-size: 11px; line-height: 18px; + letter-spacing: ${HOST_TRACKING}em; text-transform: uppercase; color: ${rgba(pal.muted, 0.85)}; } .deck-qr img { display: block; width: ${qr?.size ?? 0}px; height: ${qr?.size ?? 0}px; border-radius: 6px; image-rendering: pixelated; backface-visibility: hidden; box-shadow: 0 0 0 1px ${rgba(pal.fg, 0.25)}, 0 6px 18px rgba(0, 0, 0, 0.35); } @@ -526,10 +535,36 @@ export function deckHtml(schedule, render, opts = {}) { } } const fitAll = () => document.querySelectorAll(".deck-title").forEach(fitTitle); + + // The QR's host runs up beside the code, and its INK is exactly as long + // as the code is tall, whatever the host. Every length in it scales with + // the font size (the tracking is in em), so one measurement in the + // loaded face solves it: the string's ink at a reference size, plus the + // tracking between its letters (not after the last), scaled to the + // code's height. The first letter's side bearing is indented away, so + // the ink starts on the code's bottom edge and ends on its top. + const hostCanvas = document.createElement("canvas").getContext("2d"); + function fitHost(node) { + // Measured as drawn: the CSS uppercases it. + const text = node.textContent.toUpperCase(); + if (!text || !(D.hostLength > 0)) return; + const ref = 100; + hostCanvas.font = ref + "px DeckSans"; + const m = hostCanvas.measureText(text); + const ink = m.actualBoundingBoxLeft + m.actualBoundingBoxRight + D.hostTracking * ref * (text.length - 1); + if (!(ink > 0)) return; + const k = D.hostLength / ink; + node.style.fontSize = ref * k + "px"; + node.style.textIndent = m.actualBoundingBoxLeft * k + "px"; + } const ready = Promise.all([ document.fonts.load(D.titleSize + "px DeckSansBold"), document.fonts.load("26px DeckSans"), - ]).catch(() => {}).then(() => { fitAll(); document.documentElement.dataset.fit = "1"; }); + ]).catch(() => {}).then(() => { + fitAll(); + document.querySelectorAll(".deck-qr-host").forEach(fitHost); + document.documentElement.dataset.fit = "1"; + }); const params = new URLSearchParams(location.search); // The review still: a seek and nothing else -- the same seek the renderer diff --git a/umtool/report-to-video/chrome-deck.test.mjs b/umtool/report-to-video/chrome-deck.test.mjs @@ -307,3 +307,40 @@ appendFileSync(${JSON.stringify(path.join(dir, "runs.log"))}, a.join(" ") + "\\n rmSync(dir, { recursive: true, force: true }); } }); + +const haveChromium = haveTools && spawnSync(process.env.CHROME ?? "/usr/bin/chromium", ["--version"], { stdio: "ignore" }).status === 0; + +test("the QR's host label: its ink runs the code's full height, top edge to bottom edge, for a long host and a short one", + { skip: !haveChromium }, async () => { + const { composeChrome } = await import("./compose-chrome.mjs"); + const dir = mkdtempSync(path.join(tmpdir(), "deck-host-")); + try { + const manifest = { + slug: "t", title: "t", provenance: {}, + render: { ...RENDER, fontRegular: path.join(HERE, "fonts", "IBMPlexMono-Regular.ttf"), fontBold: path.join(HERE, "fonts", "IBMPlexMono-Bold.ttf") }, + timeline: [], + }; + const manifestPath = path.join(dir, "video.manifest.json"); + writeFileSync(manifestPath, JSON.stringify(manifest)); + const qr = deckLayout(RENDER).qr; + // c01's code goes to jasolyzer.pages.dev, c03's to youtube.com. + for (const [t, host] of [[9, "jasolyzer.pages.dev"], [28, "youtube.com"]]) { + const png = path.join(dir, `still-${t}.png`); + await composeChrome({ manifestPath, region: "deck", schedule: schedule(), still: t, png }); + // The column beside the code, as 8-bit grey rows, against the plate's own shade a little left of it. + const cols = { x: qr.x - 40, w: 36 }; + const raw = spawnSync("magick", [png, "-crop", `${cols.w}x190+${cols.x}+0`, "+repage", "-colorspace", "gray", "-depth", "8", "gray:-"], { maxBuffer: 1 << 24 }).stdout; + const rows = []; + for (let y = 0; y < 190; y += 1) { + const ref = raw[y * cols.w]; + for (let x = 4; x < cols.w; x += 1) if (raw[y * cols.w + x] - ref > 25) { rows.push(y); break; } + } + assert.ok(rows.length, `${host}: no label found`); + const top = Math.min(...rows), bottom = Math.max(...rows); + assert.ok(Math.abs(top - qr.y) <= 1, `${host}: ink starts at ${top}, the code at ${qr.y}`); + assert.ok(Math.abs(bottom - (qr.y + qr.size - 1)) <= 1, `${host}: ink ends at ${bottom}, the code at ${qr.y + qr.size - 1}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs @@ -53,10 +53,13 @@ const r4 = (v) => Math.round(v * 10000) / 10000; export const PLATFORM_LABEL = Object.freeze({ bluesky: "Bluesky", x: "X" }); /** - * The motion. Seconds; `rise` is how far (px) a card comes up as it fades in, - * `gap` the space between two cards in the column. + * The motion. Seconds. A card slides in from the frame's edge over `enter`; + * its accent glow flares to full over `glowUp`, starting `glowAt` into the + * entrance, then settles to `glowRest` over `glowDown`. `gap` is the space between two cards in the column. */ -export const POSTS_MOTION = Object.freeze({ enter: 0.35, slide: 0.35, rise: 18, gap: 14 }); +export const POSTS_MOTION = Object.freeze({ + enter: 0.55, slide: 0.35, gap: 14, glowAt: 0.3, glowUp: 0.18, glowDown: 1.1, glowRest: 0.3, +}); /** The schedule's posts for one window's segment, in slot order. */ export function windowPosts(schedule, segment) { @@ -82,16 +85,21 @@ export function embedFn(name, fn) { * * `posts` are `[{ id, appear, out: [a, b] }]` in slot order (oldest first), * times in the CUT's clock; `heights` are the cards' heights in px; `column` - * is the region's height. Card j enters at its `appear` (a fade and a rise of - * `rise` px over `enter` s); cards stack top-down `gap` apart; when card j - * would overflow the column, the oldest slide up and out (the whole stack - * moves up over `slide` s, the departing cards fading as they go); every card - * still up leaves over its `out` (a front-loaded fade and a slight shrink). + * is the region's height. Card j slides in at its `appear` from `enterX` px + * to the side (the frame's edge) over `enter` s, and its glow (`g<j>`) flares + * as it lands and settles to `glowRest`; cards stack top-down `gap` apart; + * when card j would overflow the column, the oldest slide up and out (the + * whole stack moves up over `slide` s, the departing cards fading as they go); + * every card still up leaves over its `out` (a front-loaded fade and a slight + * shrink). * * @returns {{ tops: number[], init: Record<string, object>, * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }} */ -export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slide = 0.35, rise = 18 }) { +export function postsCues({ + posts, heights, column, gap = 14, enter = 0.55, slide = 0.35, enterX = 624, + glowAt = 0.3, glowUp = 0.18, glowDown = 1.1, glowRest = 0.3, +}) { const R = (v) => Math.round(v * 10000) / 10000; const MIN = 0.001; const tops = []; @@ -101,7 +109,10 @@ export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slid acc += (heights[j] || 0) + gap; } const init = { stack: { y: 0 } }; - for (let j = 0; j < posts.length; j += 1) init[`c${j}`] = { autoAlpha: 0, y: rise, scale: 1 }; + for (let j = 0; j < posts.length; j += 1) { + init[`c${j}`] = { autoAlpha: 0, x: enterX, scale: 1 }; + init[`g${j}`] = { opacity: 0 }; + } const ev = []; const add = (k, at, dur, to, ease, why) => ev.push({ k, at: R(at), dur: R(Math.max(MIN, dur)), to, ease, why }); @@ -120,7 +131,10 @@ export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slid add("stack", t, slide, { y: -shift }, "power2.inOut", `slide for ${p.id}`); for (const g of gone) add(`c${g}`, t, slide, { autoAlpha: 0 }, "power1.in", `slide for ${p.id}`); } - add(`c${j}`, t, enter, { autoAlpha: 1, y: 0 }, "power3.out", `enter ${p.id}`); + add(`c${j}`, t, enter, { autoAlpha: 1, x: 0 }, "expo.out", `enter ${p.id}`); + // The flare, as the card lands; then it settles to a quiet rim. + add(`g${j}`, t + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${p.id}`); + add(`g${j}`, t + glowAt + glowUp, glowDown, { opacity: glowRest }, "power2.inOut", `settle ${p.id}`); visible.push(j); } for (const j of visible) { @@ -209,11 +223,12 @@ export function postsHtml(schedule, render, window, opts = {}) { const pad = 18; const plateW = set.qrSize + 2 * pad; - const metaSize = 17; - const textSize = 23; + const rail = 6; + const metaSize = 18; + const textSize = 24; const lineH = Math.round(textSize * 1.36); - const top = mix(pal.bg, pal.fg, 0.095); - const bottom = mix(pal.bg, pal.fg, 0.04); + const top = mix(mix(pal.bg, pal.fg, 0.1), pal.accent, 0.07); + const bottom = mix(mix(pal.bg, pal.fg, 0.045), pal.accent, 0.04); const cardHtml = posts .map((p, j) => { @@ -221,16 +236,17 @@ export function postsHtml(schedule, render, window, opts = {}) { const src = qrSrcs[p.id]; return ( `<article class="post" data-post="${esc(p.id)}" data-k="c${j}">` + - `<div class="edge"></div>` + `<div class="body">` + - `<div class="meta"><span class="who"><span class="handle">${esc(name)}</span>` + - (platform ? `<span class="sep">·</span><span class="platform">${esc(platform)}</span>` : "") + - `</span><span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` + + `<div class="meta">` + + (platform ? `<span class="platform">${esc(platform)}</span>` : "") + + `<span class="who"><span class="handle">${esc(name)}</span></span>` + + `<span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` + `<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` + `</div>` + `<div class="plate">` + (src ? `<img src="${esc(src)}" width="${set.qrSize}" height="${set.qrSize}" alt="">` : "") + `</div>` + + `<div class="glow" data-k="g${j}"></div>` + `</article>` ); }) @@ -247,7 +263,13 @@ export function postsHtml(schedule, render, window, opts = {}) { gap: POSTS_MOTION.gap, enter: POSTS_MOTION.enter, slide: POSTS_MOTION.slide, - rise: POSTS_MOTION.rise, + // From just past the frame's own edge on the column's side, so a card + // comes in from outside the picture, not out of the region's boundary. + enterX: set.position === "top-left" ? -(geo.x + W) : (render.width ?? 1920) - geo.x, + glowAt: POSTS_MOTION.glowAt, + glowUp: POSTS_MOTION.glowUp, + glowDown: POSTS_MOTION.glowDown, + glowRest: POSTS_MOTION.glowRest, ids: posts.map((p) => p.id), posts: posts.map((p) => ({ id: p.id, appear: p.appear, out: p.out })), }; @@ -275,30 +297,36 @@ export function postsHtml(schedule, render, window, opts = {}) { #posts-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; } .stack { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; } /* Hidden until the timeline places it: a frame taken before the faces - are in shows nothing rather than an unplaced card. */ + are in shows nothing rather than an unplaced card. A card is an + interruption, not a caption: lifted off the ground with a touch of + the accent, an accent rail down its leading edge. */ .post { position: absolute; left: 0; top: 0; width: ${W}px; min-height: ${set.qrSize + 2 * pad}px; visibility: hidden; opacity: 0; border-radius: 10px; overflow: hidden; + border-left: ${rail}px solid ${pal.accent}; background: linear-gradient(180deg, ${top} 0%, ${bottom} 100%); - box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.1)}; transform-origin: 50% 0%; } - /* The deck's top edge, the same hairline: the accent in from the left, - settling to a quiet rule. */ - .edge { position: absolute; left: 0; top: 0; width: ${W}px; height: 2px; - background: linear-gradient(90deg, ${rgba(pal.accent, 0.95)} 0px, ${rgba(pal.accent, 0.4)} ${Math.round(W * 0.28)}px, - ${rgba(pal.fg, 0.14)} ${Math.round(W * 0.62)}px, ${rgba(pal.fg, 0.14)} ${W}px); } - .body { position: relative; width: ${W - plateW}px; padding: ${pad}px ${pad + 4}px ${pad + 2}px ${pad + 4}px; } - .meta { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; + box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.12)}; transform-origin: 50% 0%; } + /* The flare as a card lands, settling to a quiet accent rim. Last in the + card, over the QR's cell -- its blur stays inside the cell's padding, + clear of the code. */ + .glow { position: absolute; left: 0; top: 0; right: 0; bottom: 0; opacity: 0; pointer-events: none; + box-shadow: inset 0 0 0 2px ${rgba(pal.accent, 0.95)}, inset 0 0 14px ${rgba(pal.accent, 0.55)}; } + .body { position: relative; width: ${W - plateW - rail}px; padding: ${pad - 4}px ${pad + 2}px ${pad - 3}px ${pad + 2}px; } + .meta { display: flex; align-items: center; gap: 10px; font-size: ${metaSize}px; line-height: ${Math.round(metaSize * 1.3)}px; white-space: nowrap; } - .who { overflow: hidden; text-overflow: ellipsis; min-width: 0; } + .who { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; } .handle { font-family: 'DeckSansBold', sans-serif; color: ${pal.fg}; letter-spacing: 0.005em; } - .sep { color: ${pal.accent}; padding: 0 0.42em; font-family: 'DeckSansBold', sans-serif; } - .platform { color: ${pal.muted}; } + /* Which platform, as a label you cannot miss. */ + .platform { flex: none; font-family: 'DeckSansBold', sans-serif; font-size: ${metaSize - 3}px; + line-height: ${metaSize + 3}px; letter-spacing: 0.03em; color: ${pal.fg}; + padding: 1px 10px 2px; border-radius: 999px; + background: ${rgba(pal.accent, 0.26)}; box-shadow: inset 0 0 0 1px ${rgba(pal.accent, 0.7)}; } .date { color: ${pal.muted}; flex: none; font-variant-numeric: tabular-nums; } - .text { margin-top: 10px; font-size: ${textSize}px; line-height: ${lineH}px; color: ${pal.fg}; } + .text { margin-top: 8px; font-size: ${textSize}px; line-height: ${lineH}px; color: ${pal.fg}; } /* Each paragraph is clamped to what is left of maxLines when the page measures (clampText), so the ellipsis always ends words, never a blank line. */ .para { white-space: pre-line; overflow-wrap: anywhere; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${set.maxLines}; } - .para + .para { margin-top: ${Math.round(lineH * 0.42)}px; } + .para + .para { margin-top: ${Math.round(lineH * 0.36)}px; } .para.gone { display: none; } /* The source cell: the QR in a cell a shade down, as on the deck. */ .plate { position: absolute; right: 0; top: 0; bottom: 0; width: ${plateW}px; @@ -359,14 +387,15 @@ export function postsHtml(schedule, render, window, opts = {}) { } const ready = Promise.all([ - document.fonts.load("23px DeckSans"), - document.fonts.load("17px DeckSansBold"), + document.fonts.load("${textSize}px DeckSans"), + document.fonts.load("${metaSize}px DeckSansBold"), ]).catch(() => {}).then(() => { const cards = P.ids.map((_, j) => byK["c" + j]); cards.forEach(clampText); const heights = cards.map((c) => c.offsetHeight); const plan = postsCues({ posts: P.posts, heights, column: P.column, gap: P.gap, - enter: P.enter, slide: P.slide, rise: P.rise }); + enter: P.enter, slide: P.slide, enterX: P.enterX, glowAt: P.glowAt, + glowUp: P.glowUp, glowDown: P.glowDown, glowRest: P.glowRest }); cards.forEach((c, j) => { c.style.top = plan.tops[j] + "px"; }); for (const k of Object.keys(plan.init)) if (byK[k]) gsap.set(byK[k], plan.init[k]); // The cues are in the cut's clock; the window plays [from, from + dur] of it. diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs @@ -140,11 +140,24 @@ test("the page's times are postSchedule's: data, enter and leave cues", () => { assert.equal(d.from, win.from); near(d.dur, win.to - win.from, "the window's length"); // Whatever the heights, each card enters at its appear and leaves over its out. - const plan = postsCues({ posts: d.posts, heights: [180, 220], column: d.column, gap: d.gap, enter: d.enter, slide: d.slide, rise: d.rise }); + const plan = postsCues({ + posts: d.posts, heights: [180, 220], column: d.column, gap: d.gap, enter: d.enter, slide: d.slide, + enterX: d.enterX, glowAt: d.glowAt, glowUp: d.glowUp, glowDown: d.glowDown, glowRest: d.glowRest, + }); + // The column is at the frame's right edge: a card comes in from past it. + assert.equal(d.enterX, 1920 - postsGeometry(RENDER).x); posts.forEach((p, j) => { const enter = plan.cues.find((c) => c.k === `c${j}` && c.why === `enter ${p.id}`); near(enter.at, p.appear, `${p.id} enters`); - assert.deepEqual(enter.to, { autoAlpha: 1, y: 0 }); + assert.deepEqual(enter.from, { autoAlpha: 0, x: d.enterX }); + assert.deepEqual(enter.to, { autoAlpha: 1, x: 0 }); + // The glow flares as it lands and settles, never to nothing while the card is up. + const flare = plan.cues.find((c) => c.k === `g${j}` && c.why === `glow ${p.id}`); + const settle = plan.cues.find((c) => c.k === `g${j}` && c.why === `settle ${p.id}`); + near(flare.at, p.appear + d.glowAt, `${p.id} flares`); + assert.deepEqual([flare.from, flare.to], [{ opacity: 0 }, { opacity: 1 }]); + assert.deepEqual([settle.from, settle.to], [{ opacity: 1 }, { opacity: d.glowRest }]); + assert.ok(d.glowRest > 0); const leave = plan.cues.find((c) => c.k === `c${j}` && c.why === `leave ${p.id}`); near(leave.at, p.out[0], `${p.id} leaves`); near(leave.at + leave.dur, p.out[1], `${p.id} gone`); diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs @@ -0,0 +1,397 @@ +// The teaser's composition: one full-frame HyperFrames page per `teaser` +// entry -- a season teaser's "coming soon" card, its words the manifest's. +// +// PURE, like chrome-deck.mjs: an entry and a render block in, an HTML string +// out. compose-chrome.mjs copies the face and GSAP in beside it, writes it and +// renders it; the build encodes the frames into the entry's segment. +// +// --------------------------------------------------------------------------- +// Why it is reached only through a dynamic import +// --------------------------------------------------------------------------- +// TEASER_FONT_FILE is `new URL(…, import.meta.url)`, which umtool's bundler +// turns into an asset reference. build-video must not import a page module at +// load (docs/quirks.md), and compose-chrome -- which umtool's preview helper +// imports statically -- loads this one only when a teaser is composed. +// +// --------------------------------------------------------------------------- +// Why the timeline is a cue list computed here +// --------------------------------------------------------------------------- +// The deck's reason (chrome-deck.mjs): a render is a seek per frame, from +// parallel workers, in any order. Every cue is a fromTo whose FROM is stated, +// carried forward from the cue before it on the same element; the page is a +// dumb interpreter of `teaserCues`, so the tests read every time it uses. +// The blur is a CSS variable (`--blur`) read by `filter`, tweened like any +// other number; the grain's jitter is a seeded sequence of instant sets. +import { fileURLToPath } from "node:url"; + +import { TEASER_MOTION, teaserLines, teaserTail, teaserTimes } from "./deck.mjs"; + +export { TEASER_MOTION }; +import { mix, rgba } from "./chrome-deck.mjs"; + +/** + * The display face: Archivo, a variable font (wght 100–900, wdth 62–125), + * vendored beside the cards' faces. Copied in as `assets/TeaserDisplay.ttf` + * under a private family name, as every chrome face is. + */ +export const TEASER_FONT_FILE = fileURLToPath(new URL("./fonts/Archivo[wdth,wght].ttf", import.meta.url)); + +/** The face's name in the page and in the project's assets. */ +export const TEASER_FONT_ASSET = "assets/TeaserDisplay.ttf"; + +/** Instant cues still take a millisecond, as on the deck. */ +const INSTANT = 0.001; + +const r4 = (v) => Math.round(v * 10000) / 10000; + +const esc = (s) => + String(s ?? "") + .replace(/&/g, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;") + .replace(/'/g, "&#39;"); + +/** A small seeded PRNG (mulberry32): the grain jitters the same way on every seek of every render. */ +export function seeded(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Everything the teaser's timeline does, as data. + * + * `lines` are `teaserLines(entry)`, `tail` the tail ("" for none), `seconds` + * the card's length. Keys name elements by `data-k`: `stage` (the slow push-in + * over the whole card), `barT`/`barB` (the letterbox closing in), `leak` (a + * soft light drifting across), `grain`, and per line i `l<i>.o` (its + * visibility), `l<i>` (the slam's scale), `l<i>.t` (its blur), `l<i>.flash`, + * `l<i>.streak`, `l<i>.rules` (an overline's accent rules), `l<i>.sub` and + * `l<i>.subt` (the second tier); `tail`, `tail.t`, `tail.glow`. + * + * @returns {{ init: Record<string, object>, cues: Array<{ k: string, at: number, dur: number, + * from: object, to: object, ease: string, why: string }>, beats: object, scale: number }} + */ +export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION }) { + const m = motion; + // The times are deck.mjs's, the same the build places the hits by. + const beats = teaserTimes(lines, tail, seconds, m); + const { T } = beats; + + const init = {}; + const put = (k, v) => { init[k] = { ...(init[k] ?? {}), ...v }; }; + const ev = []; + const add = (k, at, dur, to, ease, why) => ev.push({ k, at: r4(at), dur: r4(Math.max(INSTANT, dur)), to, ease, why }); + + // ---- the ground: letterbox, push-in, light, grain ---------------------- + put("stage", { scale: 1 }); + add("stage", 0, seconds, { scale: m.push }, "none", "push-in"); + put("barT", { yPercent: -100 }); + put("barB", { yPercent: 100 }); + add("barT", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox"); + add("barB", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox"); + put("leak", { x: -420, autoAlpha: 0 }); + add("leak", 0, T(1.4), { autoAlpha: 1 }, "power1.out", "leak in"); + add("leak", T(1.4), Math.max(INSTANT, seconds - T(1.4)), { x: 420 }, "none", "leak drift"); + const rnd = seeded(0x7ea5e); + put("grain", { x: 0, y: 0 }); + const steps = Math.floor(seconds * m.grainHz); + for (let s = 1; s < steps; s += 1) { + add("grain", s / m.grainHz, INSTANT, { x: Math.round((rnd() - 0.5) * 360), y: Math.round((rnd() - 0.5) * 220) }, "none", "grain"); + } + + // ---- the lines, top to bottom ------------------------------------------ + lines.forEach((l, i) => { + const b = beats.lines[i]; + const why = `line ${i}`; + const slam = l.role === "overline" ? 1 + (m.slam - 1) * 0.6 : m.slam; + put(`l${i}.o`, { autoAlpha: 0 }); + put(`l${i}`, { scale: slam }); + put(`l${i}.t`, { "--blur": `${m.blur}px` }); + put(`l${i}.flash`, { autoAlpha: 0, scaleX: 0.55 }); + put(`l${i}.streak`, { autoAlpha: 0, scaleX: 0 }); + add(`l${i}.o`, b.at, T(0.12), { autoAlpha: 1 }, "power1.out", `${why} in`); + // The slam: down past rest by the hit, then a soft settle up to it. + add(`l${i}`, b.at, T(m.hit), { scale: m.under }, "power3.in", `${why} slam`); + add(`l${i}`, b.at + T(m.hit), T(m.settle), { scale: 1 }, "power2.out", `${why} settle`); + add(`l${i}.t`, b.at, T(m.hit + 0.12), { "--blur": "0px" }, "power2.out", `${why} focus`); + // The hit: a flash of the accent behind the words and a streak through them. + const hit = b.impact; + add(`l${i}.flash`, hit - T(0.04), T(0.08), { autoAlpha: 1, scaleX: 1 }, "power2.out", `${why} flash`); + add(`l${i}.flash`, hit + T(0.04), T(0.75), { autoAlpha: 0, scaleX: 1.25 }, "power2.out", `${why} flash out`); + add(`l${i}.streak`, hit - T(0.06), T(0.32), { autoAlpha: 1, scaleX: 1 }, "expo.out", `${why} streak`); + add(`l${i}.streak`, hit + T(0.26), T(0.5), { autoAlpha: 0 }, "power2.in", `${why} streak out`); + if (l.role === "overline") { + put(`l${i}.rules`, { scaleX: 0 }); + add(`l${i}.rules`, hit - T(0.04), T(0.6), { scaleX: 1 }, "expo.out", `${why} rules`); + } + if (l.sub && b.subAt != null) { + put(`l${i}.sub`, { autoAlpha: 0, y: 16, scale: 1.12 }); + put(`l${i}.subt`, { "--blur": "10px" }); + add(`l${i}.sub`, b.subAt, T(0.5), { autoAlpha: 1, y: 0, scale: 1 }, "expo.out", `${why} second tier`); + add(`l${i}.subt`, b.subAt, T(0.32), { "--blur": "0px" }, "power2.out", `${why} second tier focus`); + } + }); + + // ---- the tail: slowly, on its own, after the last line has settled ------ + if (tail && beats.tailAt != null) { + const d = beats.tailDur; + put("tail", { autoAlpha: 0, scale: 1.18 }); + put("tail.t", { "--blur": "12px" }); + put("tail.glow", { autoAlpha: 0 }); + add("tail", beats.tailAt, d, { autoAlpha: 1, scale: 1 }, "sine.inOut", "tail"); + add("tail.t", beats.tailAt, d * 0.85, { "--blur": "0px" }, "power2.out", "tail focus"); + add("tail.glow", beats.tailAt + d * 0.3, d * 0.9, { autoAlpha: 1 }, "sine.inOut", "tail glow"); + } + + // ---- order, clamp, state the froms (the deck's walk) -------------------- + ev.forEach((e, n) => { e.n = n; }); + ev.sort((x, y) => x.at - y.at || x.n - y.n); + const state = Object.fromEntries(Object.entries(init).map(([k, v]) => [k, { ...v }])); + const freeAt = new Map(); + const cues = []; + for (const e of ev) { + const free = freeAt.get(e.k) ?? 0; + let { at, dur } = e; + if (at < free) { + const end = at + dur; + at = r4(free); + dur = r4(Math.max(INSTANT, end - at)); + } + const cur = state[e.k] ?? (state[e.k] = {}); + const from = {}; + for (const p of Object.keys(e.to)) from[p] = cur[p]; + Object.assign(cur, e.to); + freeAt.set(e.k, r4(at + dur)); + cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why }); + } + const { T: _T, ...times } = beats; + return { init, cues, beats: times, scale: beats.scale }; +} + +/** Each role's type: size (px, the most it may be), weight, width (%), tracking (em), and the fit floor. */ +export const TEASER_TYPE = Object.freeze({ + overline: Object.freeze({ size: 34, weight: 600, stretch: 125, tracking: 0.48, floor: 18 }), + title: Object.freeze({ size: 148, weight: 900, stretch: 112, tracking: -0.006, floor: 56 }), + sub: Object.freeze({ size: 38, weight: 600, stretch: 125, tracking: 0.4, floor: 18 }), + kicker: Object.freeze({ size: 84, weight: 800, stretch: 118, tracking: 0.04, floor: 32 }), +}); + +/** + * The teaser composition's HTML: 1920×1080 (the render's frame), opaque, + * `seconds` long. `font` is the display face's asset path, `gsap` the + * vendored script's. `?still=<t>` seeks to t and holds, as the deck's does. + */ +export function teaserHtml(entry, render, opts = {}) { + const pal = render.palette; + const W = render.width ?? 1920; + const H = render.height ?? 1080; + const seconds = Number(entry.seconds); + if (!(seconds > 0)) throw new Error(`teaser ${entry.id}: seconds must be positive`); + const font = opts.font ?? TEASER_FONT_ASSET; + const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; + const lines = teaserLines(entry); + if (!lines.length) throw new Error(`teaser ${entry.id}: no lines`); + const tail = teaserTail(entry); + const { init, cues, beats } = teaserCues({ lines, tail, seconds }); + + // The ground: the palette's bg, lifted a touch toward the accent at the + // centre and falling toward black at the edges. + const black = "#000000"; + const core = mix(mix(pal.bg, pal.accent, 0.13), pal.fg, 0.02); + const mid = pal.bg; + const edge = mix(pal.bg, black, 0.62); + const bar = mix(pal.bg, black, 0.72); + const barH = Math.round(H * 0.105); + const maxW = Math.round(W * 0.8); + const ty = TEASER_TYPE; + + const lineHtml = lines + .map((l, i) => { + const k = `l${i}`; + const isLast = i === lines.length - 1; + const rules = l.role === "overline" + ? `<div class="rules" data-k="${k}.rules"><span class="rule l"></span><span class="rule r"></span></div>` + : ""; + const tailHtml = isLast && tail + ? `<span class="tail" data-k="tail"><span class="tail-glow" data-k="tail.glow"></span>` + + `<span class="tail-t" data-k="tail.t">${esc(tail)}</span></span>` + : ""; + return ( + `<div class="line ${l.role}" data-line="${i}" data-role="${l.role}" data-k="${k}.o">` + + `<div class="flash" data-k="${k}.flash"></div>` + + `<div class="streak" data-k="${k}.streak"></div>` + + rules + + `<div class="pop" data-k="${k}"><div class="row">` + + `<span class="txt" data-k="${k}.t">${esc(l.head)}</span>${l.sub ? "" : tailHtml}</div></div>` + + (l.sub + ? `<div class="sub" data-k="${k}.sub"><div class="row"><span class="subt" data-k="${k}.subt">${esc(l.sub)}</span>${tailHtml}</div></div>` + : "") + + `</div>` + ); + }) + .join("\n "); + + const data = { + seconds, + maxW, + init, + cues: cues.map(({ why, ...c }) => c), + beats, + floors: Object.fromEntries(Object.entries(ty).map(([r, t]) => [r, t.floor])), + }; + // `</script>` in a JSON string would close the tag; the words may say anything. + const json = JSON.stringify(data).replace(/</g, "\\u003c"); + const typeCss = (sel, t) => + `${sel} { font-size: ${t.size}px; font-weight: ${t.weight}; font-stretch: ${t.stretch}%; letter-spacing: ${t.tracking}em; }`; + + return `<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=${W}, height=${H}" /> + <script src="${esc(gsapSrc)}"></script> + <style> + /* One variable face under a private name, the real file beside the page: + a bare local() falls back silently in the render browser, and a real + family name in the stack is fetched from the network (docs/quirks.md). */ + @font-face { font-family: 'TeaserDisplay'; font-style: normal; font-weight: 100 900; font-stretch: 62% 125%; + src: url('${esc(font)}'); } + * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: ${pal.bg}; } + body { font-family: 'TeaserDisplay', sans-serif; font-synthesis: none; color: ${pal.fg}; + -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; } + #root { position: relative; width: ${W}px; height: ${H}px; overflow: hidden; } + #teaser-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; overflow: hidden; } + .ground { position: absolute; inset: 0; + background: radial-gradient(ellipse 62% 58% at 50% 47%, ${core} 0%, ${mid} 58%, ${edge} 100%); } + .stage { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; transform-origin: 50% 48%; } + .leak { position: absolute; left: ${Math.round(W * 0.08)}px; top: ${Math.round(-H * 0.32)}px; + width: ${Math.round(W * 0.7)}px; height: ${Math.round(H * 0.95)}px; border-radius: 50%; + background: radial-gradient(ellipse at center, ${rgba(pal.accent, 0.2)} 0%, ${rgba(pal.amber ?? pal.accent, 0.06)} 45%, ${rgba(pal.accent, 0)} 70%); + filter: blur(30px); mix-blend-mode: screen; } + .column { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; + display: flex; flex-direction: column; align-items: center; justify-content: center; } + .line { position: relative; display: flex; flex-direction: column; align-items: center; max-width: ${maxW}px; } + .pop, .sub { display: block; transform-origin: 50% 55%; } + .row { display: flex; align-items: baseline; justify-content: center; white-space: nowrap; } + .txt, .subt, .tail-t { display: inline-block; filter: blur(var(--blur, 0px)); } + .txt, .subt { text-transform: uppercase; white-space: nowrap; } + ${typeCss(".overline > .pop > .row", ty.overline)} + .overline .txt { color: ${mix(pal.muted, pal.fg, 0.4)}; padding-left: ${ty.overline.tracking}em; } + ${typeCss(".title > .pop > .row", ty.title)} + .title > .pop > .row { line-height: 1.02; } + .title .txt { color: ${pal.fg}; } + ${typeCss(".sub > .row", ty.sub)} + .sub > .row { line-height: 1.2; } + .sub .subt { color: ${mix(pal.fg, pal.muted, 0.25)}; padding-left: ${ty.sub.tracking}em; } + ${typeCss(".kicker > .pop > .row", ty.kicker)} + .kicker > .pop > .row { line-height: 1.1; } + .kicker .txt { color: ${pal.fg}; } + .overline { margin-bottom: 38px; } + .title + .title { margin-top: 10px; } + .title .sub { margin-top: 14px; } + .kicker { margin-top: 70px; } + /* An overline between two hairline rules in the accent. */ + .rules { position: absolute; left: -132px; right: -132px; top: 50%; height: 2px; transform-origin: 50% 50%; } + .rule { position: absolute; top: 0; width: 96px; height: 2px; border-radius: 1px; } + .rule.l { left: 0; background: linear-gradient(90deg, ${rgba(pal.accent, 0)} 0%, ${pal.accent} 100%); } + .rule.r { right: 0; background: linear-gradient(270deg, ${rgba(pal.accent, 0)} 0%, ${pal.accent} 100%); } + /* The hit: a bloom of the accent behind the words, a streak of light through them. */ + .flash { position: absolute; left: -18%; right: -18%; top: -55%; bottom: -55%; + background: radial-gradient(closest-side, ${rgba(pal.accent, 0.34)} 0%, ${rgba(pal.accent, 0.14)} 35%, ${rgba(pal.accent, 0.04)} 70%, ${rgba(pal.accent, 0)} 100%); + mix-blend-mode: screen; transform-origin: 50% 50%; } + .streak { position: absolute; left: -24%; right: -24%; top: 52%; height: 3px; margin-top: -1px; + background: linear-gradient(90deg, ${rgba(pal.accent, 0)} 0%, ${rgba(pal.accent, 0.9)} 30%, ${rgba(pal.fg, 0.95)} 50%, ${rgba(pal.accent, 0.9)} 70%, ${rgba(pal.accent, 0)} 100%); + box-shadow: 0 0 18px 3px ${rgba(pal.accent, 0.55)}; transform-origin: 50% 50%; mix-blend-mode: screen; } + /* The tail: apart from the words, in the accent, arriving on its own. */ + .tail { position: relative; display: inline-block; margin-left: 0.32em; transform-origin: 30% 60%; } + .tail-t { font-weight: 900; font-stretch: 112%; letter-spacing: 0; color: ${pal.accent}; font-size: 1.18em; line-height: 1; } + .tail-glow { position: absolute; left: -140%; right: -140%; top: -90%; bottom: -90%; + background: radial-gradient(ellipse closest-side at 50% 52%, ${rgba(pal.accent, 0.26)} 0%, ${rgba(pal.accent, 0.08)} 45%, ${rgba(pal.accent, 0)} 100%); } + .bar { position: absolute; left: 0; width: ${W}px; height: ${barH}px; background: ${bar}; } + .bar.t { top: 0; box-shadow: 0 1px 0 ${rgba(pal.fg, 0.05)}; } + .bar.b { bottom: 0; box-shadow: 0 -1px 0 ${rgba(pal.fg, 0.05)}; } + .vignette { position: absolute; inset: 0; + background: radial-gradient(ellipse 75% 70% at 50% 50%, rgba(0, 0, 0, 0) 55%, rgba(0, 0, 0, 0.55) 100%); } + .grain { position: absolute; left: -240px; top: -160px; width: ${W + 480}px; height: ${H + 320}px; + opacity: 0.11; mix-blend-mode: overlay; } + </style> + </head> + <body> + <div id="root" data-composition-id="teaser" data-start="0" data-duration="${r4(seconds)}" + data-width="${W}" data-height="${H}" data-entry="${esc(entry.id)}"> + <div id="teaser-clip" class="clip" data-start="0" data-duration="${r4(seconds)}" data-track-index="1"> + <div class="ground"></div> + <div class="stage" data-k="stage"> + <div class="leak" data-k="leak"></div> + <div class="column"> + ${lineHtml} + </div> + </div> + <div class="vignette"></div> + <svg class="grain" data-k="grain" width="${W + 480}" height="${H + 320}" aria-hidden="true"> + <filter id="teaser-grain"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="7" stitchTiles="stitch"/> + <feColorMatrix type="saturate" values="0"/></filter> + <rect width="100%" height="100%" filter="url(#teaser-grain)"/> + </svg> + <div class="bar t" data-k="barT"></div> + <div class="bar b" data-k="barB"></div> + </div> + </div> + + <script id="teaser-data" type="application/json">${json}</script> + <script> + const D = JSON.parse(document.getElementById("teaser-data").textContent); + const byK = {}; + for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el; + + // t = 0, then the cues: every one states its from, so any seek from + // anywhere lands on the same pixels. + for (const k of Object.keys(D.init)) if (byK[k]) gsap.set(byK[k], D.init[k]); + const tl = gsap.timeline({ paused: true }); + for (const c of D.cues) { + const el = byK[c.k]; + if (!el) continue; + tl.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at); + } + window.__timelines = window.__timelines || {}; + window.__timelines["teaser"] = tl; + + // The fit: a line wider than the column shrinks a pixel at a time, to + // its role's floor. It runs once the face is in -- measuring the + // fallback would fit the wrong glyphs -- and changes sizes only, never + // a time; the renderer waits on document.fonts.ready. + function fit(row) { + const role = row.parentElement.classList.contains("sub") ? "sub" : row.closest(".line").dataset.role; + let s = parseFloat(getComputedStyle(row).fontSize); + const floor = D.floors[role] || 12; + while (s > floor && row.scrollWidth > D.maxW + 0.5) { + s -= 1; + row.style.fontSize = s + "px"; + } + } + const ready = Promise.all([ + document.fonts.load("900 100px TeaserDisplay"), + document.fonts.load("600 30px TeaserDisplay"), + ]).catch(() => {}).then(() => { + document.querySelectorAll(".row").forEach(fit); + document.documentElement.dataset.fit = "1"; + }); + + const still = new URLSearchParams(location.search).get("still"); + if (still !== null) { + tl.seek(Number(still), false); + ready.then(() => tl.seek(Number(still), false)); + } + </script> + </body> +</html> +`; +} diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs @@ -0,0 +1,258 @@ +// The teaser: its validation, its title, the deck hiding over it, the page it +// draws, its cue times, and the render cache key that changed words change. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + CARD_TYPES, deckChoreography, deckSchedule, deckText, hidesDeck, resolveDeck, TEASER_MOTION, teaserHits, + teaserLines, teaserTimes, teaserTitle, validateTeaser, validateTeasers, +} from "./deck.mjs"; +import { teaserCues, teaserHtml } from "./chrome-teaser.mjs"; +import { chapterTitle, teaserAudioGraph, teaserSegmentKey } from "./build-video.mjs"; +import { composeChrome } from "./compose-chrome.mjs"; + +const FERRET = Object.freeze({ + type: "teaser", id: "fin", seconds: 7, + lines: ["Pirate Software", { text: "The Largest Ferret Rescue in the United States", break: "in the United States" }, "February 2027"], + tail: "?", +}); +const RENDER = { + width: 1920, height: 1080, fps: 30, audioRate: 48000, audioChannels: 2, + palette: { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }, +}; + +test("a valid teaser has nothing to say; every bad shape is a sentence", () => { + assert.deepEqual(validateTeaser(FERRET), []); + assert.deepEqual(validateTeaser({ ...FERRET, tail: undefined, hits: false }), []); + const bad = (patch) => validateTeaser({ ...FERRET, ...patch }).join(" | "); + assert.match(bad({ lines: [] }), /lines must be a list of 1 to 5/); + assert.match(bad({ lines: ["a", "b", "c", "d", "e", "f"] }), /1 to 5/); + assert.match(bad({ lines: ["ok", ""] }), /lines\[1\] must be words/); + assert.match(bad({ lines: ["two\nlines"] }), /one line/); + assert.match(bad({ lines: ["x".repeat(81)] }), /81 characters/); + assert.match(bad({ lines: [{ text: "Abc", brk: "c" }] }), /lines\[0\]\.brk is not a teaser line field/); + assert.match(bad({ lines: [{ text: "The big one", break: "small" }] }), /must be the end of its text/); + assert.match(bad({ lines: [{ text: "whole", break: "whole" }] }), /leaves nothing for the first tier/); + assert.match(bad({ lines: [42] }), /string or \{ text, break \}/); + assert.match(bad({ seconds: 2 }), /seconds must be from 3 to 20/); + assert.match(bad({ seconds: 21 }), /from 3 to 20/); + assert.match(bad({ tail: "" }), /tail must be a short string/); + assert.match(bad({ tail: "?????????" }), /tail is 9 characters/); + assert.match(bad({ hits: "yes" }), /hits must be true or false/); + // What fits the frame at each role's floor (TEASER_LIMITS.fit): an + // 80-character title spilled past both edges. + const T80 = "The Largest Ferret Rescue Operation Ever Attempted Anywhere in the United States"; + assert.match(bad({ lines: ["Pirate Software", T80, "February 2027"] }), + /lines\[1\] is 80 characters, and at most 34 fit the frame as the title -- shorten it, or set its end as a break/); + assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "x".repeat(34), "February 2027"] }), []); + // The tail counts on the row that carries it: the last, or its second tier. + assert.match(bad({ lines: ["Pirate Software", "x".repeat(34)] }), /lines\[1\] is 36 characters with the tail/); + assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "x".repeat(34)], tail: undefined }), []); + assert.match(bad({ lines: ["Pirate Software", "Title", "y".repeat(56)] }), /lines\[2\] is 58 characters with the tail.*as the kicker/); + assert.match(bad({ lines: ["o".repeat(65), "Title", "Date"] }), /lines\[0\] is 65 characters.*as the overline/); + // A break: the head in the line's role, the break in the second tier. + assert.deepEqual(validateTeaser({ ...FERRET, lines: ["P", { text: T80, break: "Operation Ever Attempted Anywhere in the United States" }, "D"] }), []); + assert.match(bad({ lines: ["P", { text: T80, break: "in the United States" }, "D"] }), + /lines\[1\] before its break is 59 characters, and at most 34 fit the frame as the title$/m); + assert.match(bad({ lines: ["P", { text: `A ${"s".repeat(66)}`, break: "s".repeat(66) }] }), + /lines\[1\]\.break is 68 characters with the tail, and at most 66 fit the frame as the second tier/); + assert.match(bad({ id: "../x" }), /id must be letters/); + // The manifest's validator names where. + const errs = validateTeasers({ timeline: [{ type: "clip", id: "c1" }, { ...FERRET, seconds: 1 }] }); + assert.equal(errs.length, 1); + assert.match(errs[0], /^timeline\[1\] \(fin\)\.seconds/); +}); + +test("lines: roles by position, the break is the second tier, the title joins them", () => { + const l = teaserLines(FERRET); + assert.deepEqual(l.map((x) => x.role), ["overline", "title", "kicker"]); + assert.equal(l[1].head, "The Largest Ferret Rescue"); + assert.equal(l[1].sub, "in the United States"); + assert.equal(l[0].sub, null); + assert.deepEqual(teaserLines({ lines: ["A", "B"] }).map((x) => x.role), ["overline", "title"]); + assert.deepEqual(teaserLines({ lines: ["A"] }).map((x) => x.role), ["title"]); + assert.deepEqual(teaserLines({ lines: ["A", "B", "C", "D"] }).map((x) => x.role), ["overline", "title", "title", "kicker"]); + assert.equal( + teaserTitle(FERRET), + "Pirate Software — The Largest Ferret Rescue in the United States — February 2027 ?", + ); + assert.equal(teaserTitle({ ...FERRET, tail: undefined }).endsWith("February 2027"), true); +}); + +test("the chapter is the teaser's title, with or without the deck; an authored chapter wins", async () => { + assert.equal(await chapterTitle(FERRET, 17, {}), teaserTitle(FERRET)); + assert.equal(await chapterTitle(FERRET, 17, {}, { deck: true }), teaserTitle(FERRET)); + assert.equal(await chapterTitle({ ...FERRET, chapter: "Next season" }, 17, {}, { deck: true }), "Next season"); +}); + +test("the deck slides away over a teaser whatever overCards says; no pip, no QR", () => { + assert.ok(CARD_TYPES.includes("teaser")); + for (const overCards of ["hide", "show"]) { + const deck = resolveDeck({ chrome: { engine: "hyperframes", layout: "deck", deck: { overCards } } }); + assert.equal(hidesDeck(FERRET, deck), true, overCards); + assert.equal(hidesDeck({ type: "card" }, deck), overCards === "hide"); + } + const render = { ...RENDER, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }; + const clip = { type: "clip", id: "c20", video: "v", start: 10, end: 20, citeUrl: "https://example.org/c20" }; + const sched = deckSchedule({ entries: [clip, FERRET], durs: [10, 7], D: 0.5, render }); + const fin = sched.segments[1]; + assert.equal(fin.hideDeck, true); + assert.equal(fin.qrUrl, null); + assert.equal(fin.title, teaserTitle(FERRET)); + assert.equal(fin.subtitle, ""); + // Into the teaser the deck hides (a visibility change), no text handover. + const ch = deckChoreography(sched, render); + assert.equal(ch.handovers.length, 0); + assert.deepEqual(ch.visibility.map((v) => [v.i, v.hide]), [[1, true]]); + assert.deepEqual(deckText(FERRET, null, {}, resolveDeck(render), false), { title: teaserTitle(FERRET), subtitle: "" }); +}); + +test("the cues: one per pop at the shared times, top to bottom, every from stated", () => { + const lines = teaserLines(FERRET); + const { cues, init, beats } = teaserCues({ lines, tail: "?", seconds: 7 }); + const m = TEASER_MOTION; + // The ferret card needs no compression: the times are the motion's own. + assert.deepEqual(beats.lines.map((b) => b.at), [m.first, m.first + m.gap, m.first + m.gap + m.sub + m.gap]); + assert.equal(beats.lines[1].subAt, m.first + m.gap + m.sub); + // About 0.6–0.8 s apart, in order. + const ats = beats.lines.map((b) => b.at); + for (let i = 1; i < ats.length; i += 1) assert.ok(ats[i] - ats[i - 1] >= 0.6 && ats[i] - ats[i - 1] <= 1.0001); + // The tail starts after the date has settled and is in before the end fade's hold. + assert.ok(beats.tailAt >= beats.lines[2].impact + m.settle - 1e-9); + assert.ok(beats.tailAt + beats.tailDur <= 7 - m.endRoom + 1e-9); + // The slam lands on the impact, and the flash is centred on it. + lines.forEach((_, i) => { + const slam = cues.find((c) => c.why === `line ${i} slam`); + assert.equal(Math.round((slam.at + slam.dur) * 1e4) / 1e4, beats.lines[i].impact); + const flash = cues.find((c) => c.why === `line ${i} flash`); + assert.equal(Math.round((flash.at + flash.dur / 2) * 1e4) / 1e4, beats.lines[i].impact); + }); + // Every cue's from is stated, and is the state the element was left in. + const state = JSON.parse(JSON.stringify(init)); + for (const c of cues) { + for (const [p, v] of Object.entries(c.from)) assert.deepEqual(v, state[c.k][p], `${c.k}.${p} at ${c.at}`); + Object.assign(state[c.k], c.to); + } + // Seek-safe: no cue on an element starts before the one before it has ended. + const free = new Map(); + for (const c of cues) { + assert.ok(c.at >= (free.get(c.k) ?? 0) - 1e-9, `${c.k} at ${c.at}`); + free.set(c.k, c.at + c.dur); + } + // The end state: every line and the tail fully shown. + for (let i = 0; i < lines.length; i += 1) assert.equal(state[`l${i}.o`].autoAlpha, 1); + assert.equal(state.tail.autoAlpha, 1); + assert.equal(state["l1.sub"].autoAlpha, 1); +}); + +test("a short card plays every beat faster, and still leaves the end fade its room", () => { + const lines = teaserLines({ lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] }); + const t = teaserTimes(lines, "?", 3); + assert.ok(t.scale < 1); + assert.ok(t.tailAt + t.tailDur <= 3 - TEASER_MOTION.endRoom + 1e-6); + const { cues } = teaserCues({ lines, tail: "?", seconds: 3 }); + assert.ok(cues.every((c) => c.at + c.dur <= 3 + 1e-6)); +}); + +test("the hits sit on the pops: the cue list's times, no second copy", () => { + const lines = teaserLines(FERRET); + const { beats } = teaserCues({ lines, tail: "?", seconds: 7 }); + const hits = teaserHits(FERRET); + assert.deepEqual(hits.filter((h) => h.kind === "hit").map((h) => [h.role, h.at]), [ + ["overline", beats.lines[0].impact], + ["title", beats.lines[1].impact], + ["sub", beats.lines[1].subAt], + ["kicker", beats.lines[2].impact], + ]); + // The main title's is the biggest; the second tier's the lightest and shortest. + const by = Object.fromEntries(hits.map((h) => [h.role, h])); + assert.ok(by.title.gain > by.kicker.gain && by.kicker.gain > by.sub.gain && by.overline.gain > by.sub.gain); + assert.ok(by.sub.decay < by.title.decay); + // The tail gets a swell, not a hit, starting with its fade. + assert.deepEqual([by.tail.kind, by.tail.at, by.tail.dur], ["swell", beats.tailAt, beats.tailDur]); + assert.deepEqual(teaserHits({ ...FERRET, hits: false }), []); +}); + +test("the page: every line's nodes, escaped words, the tail, nothing fetched from anywhere", () => { + const evil = { + ...FERRET, + lines: ["<b>Pirate</b> & \"Co\"", { text: "Title </script><script>x()</script> end", break: "end" }, "Feb's 2027"], + }; + const html = teaserHtml(evil, RENDER); + assert.ok(html.includes("&lt;b&gt;Pirate&lt;/b&gt; &amp; &quot;Co&quot;")); + assert.ok(html.includes("Feb&#39;s 2027")); + assert.ok(!html.includes("<b>Pirate")); + // One script open per script; the words cannot close the data block. + assert.equal((html.match(/<script/g) ?? []).length, 3); + assert.ok(!/<\/script><script>x\(\)/.test(html)); + for (let i = 0; i < 3; i += 1) { + for (const k of [`l${i}.o`, `l${i}`, `l${i}.t`, `l${i}.flash`, `l${i}.streak`]) { + assert.ok(html.includes(`data-k="${k}"`), k); + } + } + assert.ok(html.includes('data-k="l0.rules"')); + assert.ok(html.includes('data-k="l1.sub"') && html.includes('data-k="l1.subt"')); + assert.ok(html.includes('data-k="tail"') && html.includes('data-k="tail.glow"')); + // The tail sits in the LAST line. + assert.ok(html.indexOf('data-line="2"') < html.indexOf('data-k="tail"')); + // The contract: one composition, its duration, one paused timeline. + assert.match(html, /data-composition-id="teaser" data-start="0" data-duration="7"/); + assert.match(html, /window\.__timelines\["teaser"\] = tl/); + assert.match(html, /gsap\.timeline\(\{ paused: true \}\)/); + // No URL that leaves the project: the face and GSAP are local files. + assert.deepEqual(html.match(/\b(?:https?:|\/\/[a-z])[^\s"')]*/gi) ?? [], []); + assert.match(html, /url\('assets\/TeaserDisplay\.ttf'\)/); + assert.match(html, /<script src="assets\/gsap\.min\.js">/); + // The cue data in the page is the cue list. + const json = JSON.parse(html.match(/<script id="teaser-data" type="application\/json">([\s\S]*?)<\/script>/)[1]); + const { cues } = teaserCues({ lines: teaserLines(evil), tail: "?", seconds: 7 }); + assert.deepEqual(json.cues, cues.map(({ why, ...c }) => c)); + // No tail, no tail nodes. + assert.ok(!teaserHtml({ ...FERRET, tail: undefined }, RENDER).includes('data-k="tail"')); +}); + +test("the cache key: the composed page changes with the words, the segment key with the sound", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "teaser-")); + try { + const manifest = (entry) => ({ slug: "t", render: RENDER, timeline: [entry] }); + const mp = path.join(dir, "video.manifest.json"); + const compose = async (entry) => { + writeFileSync(mp, JSON.stringify(manifest(entry))); + return composeChrome({ manifestPath: mp, region: "teaser", segment: "fin", preview: false, doRender: false }); + }; + const a = await compose(FERRET); + const again = await compose(FERRET); + const b = await compose({ ...FERRET, lines: ["Pirate Software", "Another Arc", "February 2027"] }); + assert.equal(a.key, again.key); + assert.notEqual(a.key, b.key); + assert.equal(a.frameCount, 210); + assert.ok(a.projDir.endsWith(path.join("out", "sourced", "chrome", "teaser-fin"))); + assert.ok(existsSync(path.join(a.projDir, "assets", "TeaserDisplay.ttf"))); + assert.ok(existsSync(path.join(a.projDir, "assets", "gsap.min.js"))); + assert.ok(readFileSync(path.join(a.projDir, "index.html"), "utf8").includes("Another Arc")); + await assert.rejects( + () => composeChrome({ manifestPath: mp, region: "teaser", segment: "nope" }), + /no teaser entry nope/, + ); + // The sound is in the segment's key: hits on and off are different segments. + const on = teaserAudioGraph(teaserHits(FERRET), { seconds: 7, render: RENDER }); + const off = teaserAudioGraph(teaserHits({ ...FERRET, hits: false }), { seconds: 7, render: RENDER }); + const key = (k, g, r = RENDER) => teaserSegmentKey(k, g, r); + 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)); + // 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 })); + for (const change of [{ crf: 30 }, { preset: "veryslow" }, { audioBitrate: "96k" }, { audioChannels: 6 }, { audioRate: 44100 }]) { + assert.notEqual(key(a.key, on), key(a.key, on, { ...RENDER, ...change }), JSON.stringify(change)); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs @@ -40,7 +40,7 @@ import { ledgerTotals, dateKey } from "./ledger-totals.mjs"; import { selectVariant } from "./build-video.mjs"; import { ensureWriteDir } from "../lib/report/storage.mjs"; import { - chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256, + chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256, validateTeaser, } from "./deck.mjs"; import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs"; import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs"; @@ -614,7 +614,7 @@ async function copyFonts(render, assetsDir, names, { strict }) { * `projDir/assets`. The chart's branch is the band as it shipped; only where * its GSAP comes from has changed. */ -async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from, window }) { +async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from, window, teaser }) { if (region === "chart") { const sched = schedule ?? JSON.parse(await readFile(path.join(base, "schedule.json"), "utf8")); const fonts = await copyFonts(manifest.render, assetsDir, { regular: "regular", bold: "bold" }, { strict: false }); @@ -643,6 +643,14 @@ async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl); return postsHtml(schedule, render, window, { fonts, qrSrcs }); } + if (region === "teaser") { + // A page module reached by a dynamic import, so nothing that imports this + // file -- umtool's preview helper, the build -- loads its face's URL + // unless a teaser is being composed (docs/quirks.md). + const { teaserHtml, TEASER_FONT_FILE, TEASER_FONT_ASSET } = await import("./chrome-teaser.mjs"); + await copyFile(TEASER_FONT_FILE, path.join(projDir, TEASER_FONT_ASSET)); + return teaserHtml(teaser, manifest.render, { font: TEASER_FONT_ASSET }); + } throw new Error(`unknown chrome region: ${region}`); } @@ -704,6 +712,14 @@ function runRenderer(cmd, args) { * their `.key`, cached exactly as the deck's are; * - `still` is in CUT seconds. * + * Teaser (`region: "teaser"`, `segment` = the entry's id): the whole frame, + * `seconds` long, drawn from the entry alone (no schedule): + * - project `chrome/teaser-<id>/` (`teaser-preview-<id>/` when `preview`), + * frames `chrome/teaser-<id>-frames/` and their `.key`, cached as the deck's + * are -- the key hashes the page, so changed words are a new render; + * - the build encodes the frames into `segments/<id>.mp4` (build-video's + * `buildTeaserSegment`). + * * `schedule` (an object) overrides reading `out/<variant>/schedule.json`. * * @returns {Promise<{ projDir: string, frames: string|null, still: string|null, @@ -728,10 +744,18 @@ export async function composeChrome({ await ensureWriteDir(base); from = Number(from ?? 0); - // The two regions drawn from the deck's schedule, and keyed by the render cache. - const keyed = region === "deck" || region === "posts"; + // The regions keyed by the render cache: the two drawn from the deck's + // schedule, and a teaser, drawn from its own timeline entry. + const keyed = region === "deck" || region === "posts" || region === "teaser"; + let teaser = null; + if (region === "teaser") { + teaser = (manifest.timeline ?? []).find((e) => e.id === segment && e.type === "teaser") ?? null; + if (!teaser) throw new Error(`no teaser entry ${segment ?? "(none named)"} in the ${variant} cut`); + const errors = validateTeaser(teaser); + if (errors.length) throw new Error(`teaser ${teaser.id}: ${errors.join("; ")}`); + } let sched = schedule; - if (keyed && !sched) { + if ((region === "deck" || region === "posts") && !sched) { const p = path.join(base, "schedule.json"); try { sched = JSON.parse(await readFile(p, "utf8")); @@ -740,7 +764,7 @@ export async function composeChrome({ } if (sched.kind !== "deck") throw new Error(`${p} is not a deck schedule (kind ${sched.kind ?? "missing"})`); } - const total = keyed ? sched.total : null; + const total = teaser ? Number(teaser.seconds) : keyed ? sched.total : null; const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30); const windowed = region === "deck" && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6)); @@ -762,7 +786,9 @@ export async function composeChrome({ const projName = region === "posts" ? `posts-${preview ? "preview-" : ""}${win.segment}` - : region === "deck" && preview ? "deck-preview" : `${region}${suffix}`; + : region === "teaser" + ? `teaser-${preview ? "preview-" : ""}${teaser.id}` + : region === "deck" && preview ? "deck-preview" : `${region}${suffix}`; const projDir = path.join(base, "chrome", projName); const assetsDir = path.join(projDir, "assets"); // The deck's assets are rebuilt every time: a QR from a clip that has since @@ -773,7 +799,7 @@ export async function composeChrome({ const html = await regionHtml(region, { manifest, base, projDir, assetsDir, schedule: sched, - duration: duration != null ? Number(duration) : null, from, window: win, + duration: duration != null ? Number(duration) : null, from, window: win, teaser, }); await writeFile(path.join(projDir, "index.html"), html, "utf8"); await writeFile(path.join(projDir, "hyperframes.json"), HF_JSON + "\n", "utf8"); @@ -814,7 +840,7 @@ export async function composeChrome({ // A sequence is a directory; every other format is a file. const sequence = format === "png-sequence"; - const stem = region === "posts" ? `posts-${win.segment}` : `${region}${suffix}`; + const stem = region === "posts" ? `posts-${win.segment}` : region === "teaser" ? `teaser-${teaser.id}` : `${region}${suffix}`; const target = sequence ? path.join(base, "chrome", `${stem}-frames`) : path.join(base, "chrome", `${stem}.${format}`); @@ -861,8 +887,8 @@ if (import.meta.url === `file://${process.argv[1]}`) { const manifestPath = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1])); if (!manifestPath) { console.error( - "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|posts] [--variant sourced|full]\n" + - " [--segment <id>] (posts: the clip whose window to compose)\n" + + "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|posts|teaser] [--variant sourced|full]\n" + + " [--segment <id>] (posts: the clip whose window to compose; teaser: its entry)\n" + " [--from <s>] [--duration <s>] [--out <dir>] [--preview]\n" + " [--still <s> --png <path>]\n" + " [--render] [--workers 4] [--quality high] [--format png-sequence] [--fps 30]", @@ -884,7 +910,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { still: num("--still"), png: flag("--png"), // The deck renders four-wide by default; the band keeps the renderer's own default. - workers: num("--workers") ?? (region === "deck" ? 4 : region === "posts" ? 2 : null), + workers: num("--workers") ?? (region === "deck" || region === "teaser" ? 4 : region === "posts" ? 2 : null), quality: flag("--quality") ?? "high", format: flag("--format") ?? "png-sequence", fps: num("--fps"), diff --git a/umtool/report-to-video/cut-edits.test.mjs b/umtool/report-to-video/cut-edits.test.mjs @@ -0,0 +1,400 @@ +// Tests for the cut's edits made where it is joined (slice B2): a clip's +// `muteFrom` (source seconds → the segment's clock, through the cut record +// the build writes beside each segment) and `render.endFade` on the cut's +// last segment. The chains as strings, unchanged without them; the mapping; +// validation; and real ffmpeg runs showing the sound after `muteFrom` is +// digital silence, the picture is untouched, the end fade reaches bg and +// silence on the last frame, and the length and A/V sync do not move. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + concatListText, concatRecordText, cutJoins, cutRecordPath, endFadeAudioFilter, endFadeFrames, endFadeVideoFilter, + hardCutFilterArgs, joinInputChain, muteAudioFilter, sameConcatList, withCutEdits, xfadeGraph, yuv601, +} from "./build-video.mjs"; +import { + endFadeOf, MUTE_FADE, muteSegmentSeconds, playWindow, validateChrome, validateCutEdits, validateEndFade, + validateMuteFrom, +} from "./deck.mjs"; + +const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; +const RENDER = { + width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, + audioRate: 48000, audioChannels: 2, chrome: { engine: "hyperframes", layout: "deck", deck: {} }, +}; +const CLIP = { id: "c20", type: "clip", video: "B36", start: 24022.6, end: 24029.6 }; + +// ---- validation ------------------------------------------------------------- + +test("validateMuteFrom: a number of source seconds within the clip's extent; only on a clip", () => { + assert.deepEqual(validateMuteFrom(CLIP), []); + assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: null }), []); + assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: 24029.3 }), []); + assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: 24022.6 }), [], "the start is inside"); + assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: 24029.6 }), [], "the end is inside"); + assert.match(validateMuteFrom({ ...CLIP, muteFrom: 24029.7 })[0], /muteFrom 24029\.7 is outside the clip's 24022\.6–24029\.6/); + assert.match(validateMuteFrom({ ...CLIP, muteFrom: 3 })[0], /outside/); + assert.match(validateMuteFrom({ ...CLIP, muteFrom: "24029" })[0], /must be a number of source seconds/); + assert.match(validateMuteFrom({ ...CLIP, muteFrom: NaN })[0], /must be a number/); + assert.match(validateMuteFrom({ id: "t1", type: "card", muteFrom: 2 })[0], /only a clip has sound to mute/); +}); + +test("validateEndFade and validateCutEdits: seconds from 0 to 10; every entry named by its place", () => { + assert.deepEqual(validateEndFade({}), []); + assert.deepEqual(validateEndFade({ endFade: 0 }), []); + assert.deepEqual(validateEndFade({ endFade: 1.5 }), []); + for (const bad of [-1, 11, "1", NaN]) assert.match(validateEndFade({ endFade: bad })[0], /render\.endFade must be from 0 to 10 seconds/); + assert.equal(endFadeOf({}), 0); + assert.equal(endFadeOf({ endFade: 1 }), 1); + assert.equal(endFadeOf({ endFade: -1 }), 0); + const errs = validateCutEdits({ + render: { endFade: 20 }, + timeline: [CLIP, { ...CLIP, id: "c21", muteFrom: 1 }], + }); + assert.equal(errs.length, 2); + assert.match(errs[0], /^timeline\[1\] \(c21\)\.muteFrom 1 is outside/); + assert.match(errs[1], /render\.endFade/); + assert.deepEqual(validateCutEdits({ render: {}, timeline: [CLIP] }), []); + // The deck's validator refuses a bad end fade too; a good one changes nothing. + assert.ok(validateChrome(RENDER.chrome, { ...RENDER, endFade: 99 }).some((e) => /render\.endFade/.test(e))); + assert.deepEqual(validateChrome(RENDER.chrome, { ...RENDER, endFade: 1 }), []); +}); + +// ---- the mapping, source → segment ------------------------------------------- + +test("muteSegmentSeconds: from the cut record's snapped start when it matches the segment", () => { + const record = { version: 1, id: "c20", video: "B36", start: 24022.5, end: 24029.5 }; + assert.deepEqual( + muteSegmentSeconds({ entry: { ...CLIP, muteFrom: 24029.3 }, record, render: RENDER, seconds: 7 }), + { at: 6.8, source: "record" }, + ); + // A muteFrom before the segment's real start mutes it from its first sample. + assert.equal(muteSegmentSeconds({ entry: { ...CLIP, muteFrom: 24022.6 }, record: { ...record, start: 24022.7, end: 24029.7 }, render: RENDER, seconds: 7 }).at, 0); +}); + +test("muteSegmentSeconds: no record, a stale one or another video's -- the unsnapped start, and a note that says so", () => { + const entry = { ...CLIP, muteFrom: 24029.3 }; + const none = muteSegmentSeconds({ entry, record: null, render: RENDER, seconds: 7 }); + assert.equal(none.at, 6.7); + assert.equal(none.source, "window"); + assert.match(none.note, /^c20: no cut record beside the segment — muteFrom measured from the unsnapped start 24022\.6; the real start may differ by up to 1\.6s/); + const stale = muteSegmentSeconds({ entry, record: { video: "B36", start: 24020, end: 24030 }, render: RENDER, seconds: 7 }); + assert.equal(stale.source, "window"); + assert.match(stale.note, /the cut record does not match the segment/); + assert.equal(muteSegmentSeconds({ entry, record: { video: "other", start: 24022.5, end: 24029.5 }, render: RENDER, seconds: 7 }).source, "window"); + // Within two frames of the segment's length is a match. + assert.equal(muteSegmentSeconds({ entry, record: { video: "B36", start: 24022.6, end: 24029.65 }, render: RENDER, seconds: 7 }).source, "record"); + // A clip with a tight cut plays from cutStart less the lead-in. + const cut = { ...CLIP, cutStart: 24025, cutEnd: 24029, muteFrom: 24028 }; + assert.deepEqual(playWindow(cut, RENDER), { from: 24024.6, to: 24029 }); + assert.equal(muteSegmentSeconds({ entry: cut, render: RENDER }).at, 3.4); +}); + +// ---- the chains, as strings ------------------------------------------------- + +test("muteAudioFilter: afade out ENDING at the mute point, silent after; volume=0 from the first sample", () => { + assert.equal(MUTE_FADE, 0.04); + assert.equal(muteAudioFilter(6.7), "afade=t=out:st=6.66:d=0.04"); + assert.equal(muteAudioFilter(0.02), "afade=t=out:st=0:d=0.02"); + assert.equal(muteAudioFilter(0), "volume=0"); +}); + +test("the end fade: a yuv blend toward bg from frame s = last − n, so the LAST frame is bg; silence at that frame's time", () => { + assert.deepEqual(yuv601("#12101a"), { y: 31, u: 132, v: 128 }, "what pad wrote into the ferret segments"); + assert.deepEqual(yuv601("#000000"), { y: 16, u: 128, v: 128 }); + assert.deepEqual(yuv601("#ffffff"), { y: 235, u: 128, v: 128 }); + const fade = { seconds: 1, lastFrame: 284 }; // 7 s + 2.5 s hold at 30 fps = 285 frames + assert.equal(endFadeVideoFilter(fade, RENDER), "geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-8.4667)/1,0,1)+0.5':enable='gte(t,8.4667)'"); + assert.equal(endFadeAudioFilter(fade, RENDER), "afade=t=out:st=8.4667:d=1"); +}); + +test("the end fade: longer than its segment, it is the segment -- frames and seconds clamped alike, so the last frame is bg", () => { + // A 3 s segment at 30 fps (frames 0..89) under endFade 5: the fade spans + // the segment, from frame 0 (weight 0) to frame 89 (weight 1). + const fade = { seconds: 5, lastFrame: 89 }; + assert.equal(endFadeFrames(fade, 30), 89); + assert.equal(endFadeFrames({ seconds: 1, lastFrame: 89 }, 30), 30, "a shorter fade is its own length"); + assert.equal(endFadeFrames({ seconds: 5, lastFrame: 0 }, 30), 1, "never zero frames"); + assert.equal(endFadeVideoFilter(fade, RENDER), "geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-0)/2.9667,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-0)/2.9667,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-0)/2.9667,0,1)+0.5':enable='gte(t,0)'"); + assert.equal(endFadeAudioFilter(fade, RENDER), "afade=t=out:st=0:d=2.9667", "the sound over the same frames"); +}); + +test("joinInputChain: a mute alone is a chain on the sound only; the picture is the input's own", () => { + assert.deepEqual(joinInputChain(2, { hold: 0, move: null, mute: 6.7 }, RENDER), { + parts: ["[2:a]afade=t=out:st=6.66:d=0.04[j2a]"], v: "[2:v]", a: "[j2a]", + }); + // Mute, then the hold's silence, then the end fade, in that order; the + // picture holds, moves (none here) and fades. + const fade = { seconds: 1, lastFrame: 284 }; + assert.deepEqual(joinInputChain(0, { hold: 2.5, move: null, mute: 6.7, fade }, RENDER), { + parts: [ + "[0:v]tpad=stop_mode=clone:stop_duration=2.5,geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-8.4667)/1,0,1)+0.5':enable='gte(t,8.4667)'[j0v]", + "[0:a]afade=t=out:st=6.66:d=0.04,apad=pad_dur=2.5,afade=t=out:st=8.4667:d=1[j0a]", + ], + v: "[j0v]", + a: "[j0a]", + }); +}); + +test("withCutEdits: nothing to add leaves the joins as they were (null stays null); edits land on their segments", () => { + assert.equal(withCutEdits(null, 3), null); + const joins = [null, { hold: 2.5, move: null }, null]; + assert.equal(withCutEdits(joins, 3, {}), joins, "the same joins, not a copy"); + const fade = { seconds: 1, lastFrame: 209 }; + const out = withCutEdits(joins, 3, { mutes: new Map([[1, 4], [2, 6.7]]), fade }); + assert.deepEqual(out, [ + null, + { hold: 2.5, move: null, mute: 4 }, + { hold: 0, move: null, mute: 6.7, fade }, + ]); + assert.deepEqual(joins[1], { hold: 2.5, move: null }, "the schedule's joins are not written to"); + assert.deepEqual(withCutEdits(null, 2, { fade }), [null, { hold: 0, move: null, fade }]); +}); + +test("the graphs: unchanged without edits; the hard cut's record names a mute and a fade, so a changed one is never reused", () => { + // No edits: the plain crossfade graph (each sound pinned to its picture). + assert.deepEqual(xfadeGraph([10, 12], 0.5, withCutEdits(null, 2), RENDER).parts, [ + "[0:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p0a]", + "[1:a]apad=whole_dur=12.000000,atrim=end=12.000000,asetpts=PTS-STARTPTS[p1a]", + "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", + "[p0a][p1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + ]); + const segs = ["/s/a.mp4", "/s/b.mp4"]; + const holdOnly = [null, { hold: 2.5, move: null }]; + assert.equal(concatRecordText(segs, withCutEdits(null, 2)), concatListText(segs)); + assert.equal( + concatRecordText(segs, holdOnly), + concatListText(segs) + '# join 1 {"hold":2.5,"move":null}\n', + "a hold's record line is the one it always was", + ); + const fade = { seconds: 1, lastFrame: 359 }; + const edited = withCutEdits(holdOnly, 2, { mutes: new Map([[0, 3]]), fade }); + const rec = concatRecordText(segs, edited); + assert.match(rec, /^# join 0 \{"hold":0,"move":null,"mute":3\}$/m); + assert.match(rec, /^# join 1 \{"hold":2\.5,"move":null,"fade":\{"seconds":1,"lastFrame":359\}\}$/m); + assert.equal(sameConcatList(rec, segs, edited), true); + assert.equal(sameConcatList(rec, segs, holdOnly), false); + assert.equal(sameConcatList(rec, segs, withCutEdits(holdOnly, 2, { mutes: new Map([[0, 3.5]]), fade })), false); + assert.equal(sameConcatList(rec, segs, withCutEdits(holdOnly, 2, { mutes: new Map([[0, 3]]) })), false); + const fc = hardCutFilterArgs(segs, edited, RENDER, "/o.mp4"); + assert.equal(fc[fc.indexOf("-filter_complex") + 1], + "[0:a]afade=t=out:st=2.96:d=0.04[j0a];" + + "[1:v]tpad=stop_mode=clone:stop_duration=2.5,geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-10.9667)/1,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-10.9667)/1,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-10.9667)/1,0,1)+0.5':enable='gte(t,10.9667)'[j1v];" + + "[1:a]apad=pad_dur=2.5,afade=t=out:st=10.9667:d=1[j1a];" + + "[0:v][j0a][j1v][j1a]concat=n=2:v=1:a=1[vc][ac]"); +}); + +// ---- ffmpeg, for real ------------------------------------------------------- + +const haveFfmpeg = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0; +const R = { width: 320, height: 180, fps: 30, palette: PALETTE, crf: 21, preset: "veryfast", audioRate: 48000, audioChannels: 2 }; +const ff = (args, opts = {}) => { + const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args], { maxBuffer: 1 << 28, ...opts }); + assert.equal(r.status, 0, String(r.stderr)); + return r.stdout; +}; +const md5s = (out, stream = 0) => String(out).split("\n").filter((l) => l && !l.startsWith("#")) + .map((l) => l.split(",")).filter((f) => Number(f[0]) === stream).map((f) => f.at(-1).trim()); + +/** Three 2 s segments, framed as the deck frames them, each with a tone. */ +function segments(dir, ext = "mov") { + const make = (name, src, hz) => { + const f = path.join(dir, `${name}.${ext}`); + const codec = ext === "mov" ? ["-c:v", "ffv1", "-c:a", "pcm_s16le"] : ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac"]; + ff([ + "-f", "lavfi", "-i", `${src}=s=280x150:r=30:d=2`, + "-f", "lavfi", "-i", `sine=frequency=${hz}:sample_rate=48000:duration=2`, + "-filter_complex", `[0:v]pad=320:180:20:10:color=${PALETTE.bg},format=yuv420p[v];[1:a]aformat=channel_layouts=stereo[a]`, + "-map", "[v]", "-map", "[a]", ...codec, f, + ]); + return f; + }; + return [make("a", "testsrc2", 440), make("b", "smptebars", 550), make("c", "rgbtestsrc", 660)]; +} + +/** Run a graph to mono s16 PCM (the picture sunk), and to frame hashes. */ +const pcmOf = (inputs, parts, v, a) => + ff([...inputs, "-filter_complex", `${parts.join(";")};${v}nullsink`, "-map", a, "-f", "s16le", "-ac", "1", "-ar", "48000", "-"], { encoding: "buffer" }); +const framesOf = (inputs, parts, v, a) => + md5s(ff([...inputs, "-filter_complex", `${parts.join(";")};${a}anullsink`, "-map", v, "-f", "framemd5", "-"])); +const sample = (pcm, i) => pcm.readInt16LE(i * 2); +const peak = (pcm, a, b) => { + let m = 0; + for (let i = Math.round(a * 48000); i < Math.min(pcm.length / 2, Math.round(b * 48000)); i += 1) m = Math.max(m, Math.abs(sample(pcm, i))); + return m; +}; + +test("ffmpeg: after muteFrom the sound is digital silence; before its fade it is the clip's own; the picture is untouched", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "cut-mute-")); + try { + const [a, b, c] = segments(dir); + const inputs = [a, b, c].flatMap((s) => ["-i", s]); + const D = 0.5; + const plain = xfadeGraph([2, 2, 2], D, null, R); + const joins = withCutEdits(null, 3, { mutes: new Map([[1, 1.2]]) }); + const muted = xfadeGraph([2, 2, 2], D, joins, R); + // The picture: frame for frame the graph without the mute. + assert.deepEqual(framesOf(inputs, muted.parts, muted.vlab, muted.alab), framesOf(inputs, plain.parts, plain.vlab, plain.alab)); + const p0 = pcmOf(inputs, plain.parts, plain.vlab, plain.alab); + const p1 = pcmOf(inputs, muted.parts, muted.vlab, muted.alab); + assert.equal(p1.length, p0.length, "the sound is as long as it was"); + // b plays from 1.5 s in the cut, so its mute point is 2.7 s; the dissolve + // into c starts at 3.0 s. Before the fade (2.66 s), every sample is the + // unmuted cut's -- nothing moved, so A/V sync is what it was. + const fadeAt = Math.round((1.5 + 1.2 - MUTE_FADE) * 48000); + assert.ok(p1.subarray(0, fadeAt * 2).equals(p0.subarray(0, fadeAt * 2)), "untouched before the fade"); + assert.ok(peak(p0, 2.7, 3.0) > 1000, "b sounds there without the mute"); + assert.equal(peak(p1, 2.7, 3.0), 0, "digital silence from the mute point to the dissolve"); + assert.ok(peak(p1, 2.66, 2.7) > 0 && peak(p1, 2.66, 2.7) < peak(p0, 2.66, 2.7), "a fade, not a click"); + // From the dissolve on, only c's sound: the cut after it is c's own. + const cOnly = pcmOf(["-i", c], ["[0:a]anull[a]"], "[0:v]", "[a]"); + const tail = p1.subarray(Math.round(3.5 * 48000) * 2, Math.round(5.5 * 48000) * 2); + const own = cOnly.subarray(Math.round(0.5 * 48000) * 2, Math.round(2.5 * 48000) * 2); + assert.ok(peak(p1, 3.6, 5.4) > 1000); + assert.equal(tail.length, own.length); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("ffmpeg: the end fade -- the last frame is bg and the sound silent there; with a hold and a hard cut, the length is unchanged", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "cut-fade-")); + try { + const segs = segments(dir); + const inputs = segs.flatMap((s) => ["-i", s]); + // c is 60 frames, held 0.5 s (15 frames): its last frame is 74. + const base = [null, null, { hold: 0.5, move: null }]; + const fade = { seconds: 1, lastFrame: 74 }; + const joins = withCutEdits(base, 3, { fade }); + const plainArgs = hardCutFilterArgs(segs, base, R, "-"); + const fadeArgs = hardCutFilterArgs(segs, joins, R, "-"); + const fc = (args) => [args[args.indexOf("-filter_complex") + 1]]; + const f0 = framesOf(inputs, fc(plainArgs), "[vc]", "[ac]"); + const f1 = framesOf(inputs, fc(fadeArgs), "[vc]", "[ac]"); + assert.equal(f1.length, f0.length, "as many frames as without the fade"); + assert.equal(f1.length, 60 + 60 + 75); + assert.deepEqual(f1.slice(0, 120 + 44), f0.slice(0, 120 + 44), "every frame before the fade is the same"); + assert.notDeepEqual(f1[120 + 50], f0[120 + 50], "fading"); + // The last frame, decoded: bg everywhere. + const last = ff([...inputs, "-filter_complex", `${fc(fadeArgs).join(";")};[ac]anullsink;[vc]select=eq(n\\,194)[o]`, + "-map", "[o]", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const bg = [0x12, 0x10, 0x1a]; + let worst = 0; + for (let i = 0; i < last.length; i += 1) worst = Math.max(worst, Math.abs(last[i] - bg[i % 3])); + assert.ok(worst <= 2, `the last frame is bg (worst channel off by ${worst})`); + // The sound: as long as without the fade, the same up to it, silent from the last frame's time. + const p0 = pcmOf(inputs, fc(plainArgs), "[vc]", "[ac]"); + const p1 = pcmOf(inputs, fc(fadeArgs), "[vc]", "[ac]"); + assert.equal(p1.length, p0.length); + // The hold is silent already; give c's own tone the fade instead. + const toneJoins = withCutEdits([null, null, null], 3, { fade: { seconds: 1, lastFrame: 59 } }); + const t0 = pcmOf(inputs, fc(hardCutFilterArgs(segs, [null, null, null], R, "-")), "[vc]", "[ac]"); + const t1 = pcmOf(inputs, fc(hardCutFilterArgs(segs, toneJoins, R, "-")), "[vc]", "[ac]"); + assert.equal(t1.length, t0.length); + const lastAt = 4 + 59 / 30; // c starts at 4 s in the hard cut + const fadeStart = Math.round((lastAt - 1) * 48000); + assert.ok(t1.subarray(0, fadeStart * 2).equals(t0.subarray(0, fadeStart * 2)), "the same sound up to the fade"); + assert.ok(peak(t1, lastAt - 0.9, lastAt - 0.8) < peak(t0, lastAt - 0.9, lastAt - 0.8), "fading"); + assert.equal(peak(t1, lastAt, 6), 0, "silent from the last frame on"); + assert.ok(peak(t0, lastAt, 6) > 1000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("ffmpeg: an end fade longer than the last segment -- a 3 s segment under endFade 5 still ends on bg and in silence", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "cut-longfade-")); + try { + const seg = path.join(dir, "t.mov"); + ff([ + "-f", "lavfi", "-i", "testsrc2=s=280x150:r=30:d=3", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=3", + "-filter_complex", `[0:v]pad=320:180:20:10:color=${PALETTE.bg},format=yuv420p[v];[1:a]aformat=channel_layouts=stereo[a]`, + "-map", "[v]", "-map", "[a]", "-c:v", "ffv1", "-c:a", "pcm_s16le", seg, + ]); + const joins = withCutEdits([null], 1, { fade: { seconds: 5, lastFrame: 89 } }); + const { parts, v, a } = joinInputChain(0, joins[0], R); + const inputs = ["-i", seg]; + assert.equal(framesOf(inputs, parts, v, a).length, 90, "the length is unchanged"); + const frame = (n) => ff([...inputs, "-filter_complex", `${parts.join(";")};${a}anullsink;${v}select=eq(n\\,${n})[o]`, + "-map", "[o]", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const bg = [0x12, 0x10, 0x1a]; + const off = (buf) => { + let worst = 0; + for (let i = 0; i < buf.length; i += 1) worst = Math.max(worst, Math.abs(buf[i] - bg[i % 3])); + return worst; + }; + assert.ok(off(frame(89)) <= 2, `the last frame is bg (worst channel off by ${off(frame(89))})`); + assert.ok(off(frame(44)) > 20, "halfway, still fading"); + const pcm = pcmOf(inputs, parts, v, a); + const lastAt = 89 / 30; + assert.equal(peak(pcm, lastAt, 3), 0, "silent from the last frame's time"); + assert.ok(peak(pcm, 0.1, 0.2) > 1000, "sounding at the start"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("cutJoins: the record beside the segment places muteFrom; the end fade counts the last segment's frames and hold", + { skip: !haveFfmpeg }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "cut-joins-")); + try { + const segs = segments(dir, "mp4"); + const entries = [ + { id: "a", type: "clip", video: "va", start: 100, end: 102 }, + { id: "b", type: "clip", video: "vb", start: 200, end: 203, muteFrom: 201.5 }, + { id: "c", type: "clip", video: "vc", start: 300, end: 302 }, + ]; + // No record for b: the unsnapped start (200), 1.5 s in. + assert.equal(await cutJoins({ entries: entries.slice(0, 1), segments: segs.slice(0, 1), render: R }), null, "nothing to join"); + let j = await cutJoins({ entries, segments: segs, render: R }); + assert.deepEqual(j, [null, { hold: 0, move: null, mute: 1.5 }, null]); + // b's record says it was cut from 200.3: 1.2 s in. + writeFileSync(cutRecordPath(segs[1]), JSON.stringify({ version: 1, id: "b", video: "vb", start: 200.3, end: 202.3 })); + j = await cutJoins({ entries, segments: segs, render: R }); + assert.equal(j[1].mute, 1.2); + // The end fade on c, with the deck's hold on it: 60 + 15 frames. + const schedule = { segments: [{ id: "a" }, { id: "b" }, { id: "c", hold: 0.5 }] }; + j = await cutJoins({ schedule, entries, segments: segs, render: { ...R, endFade: 1 } }); + assert.deepEqual(j[2], { hold: 0.5, move: null, fade: { seconds: 1, lastFrame: 74 } }); + assert.equal(j[0], null); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("cutJoins: a muteFrom past the cut's end says it will not be heard, not that the clip is muted from there", + { skip: !haveFfmpeg }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "cut-latemute-")); + const said = []; + const log = console.log; + console.log = (line) => said.push(String(line)); + try { + const segs = segments(dir, "mp4"); + // b's segment is 2 s, cut from 200; the clip's extent runs to 203, so a + // mark at 202.5 validates but lies 2.5 s into a 2 s segment. + writeFileSync(cutRecordPath(segs[1]), JSON.stringify({ version: 1, id: "b", video: "vb", start: 200, end: 202 })); + const entries = [ + { id: "a", type: "clip", video: "va", start: 100, end: 102, muteFrom: 101 }, + { id: "b", type: "clip", video: "vb", start: 200, end: 203, cutEnd: 202, muteFrom: 202.5 }, + ]; + const j = await cutJoins({ entries, segments: segs.slice(0, 2), render: R }); + assert.equal(j[1].mute, 2.5); + const b = said.filter((l) => l.startsWith("b:")); + assert.equal(b.length, 1, b.join("\n")); + assert.match(b[0], /^b: muteFrom 202\.5 will not be heard -- it lies 2\.5s into a segment 2s long, past the cut's end/); + assert.ok(said.some((l) => /^a: muted from 1s into its segment/.test(l)), said.join("\n")); + } finally { + console.log = log; + rmSync(dir, { recursive: true, force: true }); + } + }); diff --git a/umtool/report-to-video/deck-overlay.test.mjs b/umtool/report-to-video/deck-overlay.test.mjs @@ -86,8 +86,10 @@ test("previewFromSegmentsArgs: the window's segments crossfaded as the full conc assert.equal( args[args.indexOf("-filter_complex") + 1], [ + "[0:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p0a]", + "[1:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p1a]", "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", - "[0:a][1:a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[p0a][p1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", "[v1]trim=start=2.500:duration=10.000,setpts=PTS-STARTPTS[vw]", "[a1]atrim=start=2.500:duration=10.000,asetpts=PTS-STARTPTS[aw]", "[2:v]format=rgba[hfa0];[vw][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]", diff --git a/umtool/report-to-video/deck-room.test.mjs b/umtool/report-to-video/deck-room.test.mjs @@ -0,0 +1,469 @@ +// Tests for "room for posts" in the build (slice R1): the hold and the footage +// move on a carrying clip's input chain, where the cut is joined; the +// hard-cut concat that hosts them; the record a cached concat keeps of them; +// the cut's lengths with holds; and real ffmpeg runs showing a held segment +// freezes for exactly hold·fps frames in silence while every other input's +// frames pass through untouched. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + concatListText, concatRecordText, hardCutFilterArgs, holdAudioFilter, holdVideoFilter, joinInputChain, + moveFilter, previewFromSegmentsArgs, sameConcatList, segmentJoins, windowSegments, xfadeGraph, +} from "./build-video.mjs"; +import { deckGeometry, deckSchedule, scheduleFrom, shiftedFootage } from "./deck.mjs"; + +const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; +const RENDER = { + width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, crf: 21, preset: "slow", + audioRate: 48000, audioChannels: 2, chrome: { engine: "hyperframes", layout: "deck", deck: {} }, +}; +const PROV = { siteOrigin: "https://example.test", channelSlug: "chan" }; +const CLIPS = [ + { id: "c1", type: "clip", video: "v1", start: 0, end: 10, date: "2024-09-05" }, + { id: "c2", type: "clip", video: "v2", start: 0, end: 12, date: "2024-10-01" }, + { id: "c3", type: "clip", video: "v3", start: 0, end: 4, date: "2025-06-01" }, +]; +const POST = (id, date) => ({ + id, platform: "bluesky", date, text: `post ${id}`, url: `https://bsky.app/profile/a/post/${id}`, +}); +const MOVE = (segmentAt, extra = {}) => ({ + segment: "c2", at: 9.5 + segmentAt, segmentAt, seconds: 0.6, + from: deckGeometry(RENDER).footage, to: shiftedFootage(RENDER), ...extra, +}); + +// ---- the join plan ---------------------------------------------------------- + +test("segmentJoins: null without posts; a hold and a move on each carrying clip, aligned with the segments", () => { + const durs = [10, 12, 4]; + const plain = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV }); + assert.equal(segmentJoins(plain), null); + assert.equal(segmentJoins(null), null); + const s = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); + const joins = segmentJoins(s); + assert.equal(joins.length, 3); + assert.equal(joins[0], null); + assert.equal(joins[2], null); + assert.equal(joins[1].hold, 2.5); + assert.deepEqual(joins[1].move, s.moves[0]); + // A hold alone (shift off) and a move alone (hold 0) are each a join. + const noShift = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { shift: false } } } }; + const h = segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: noShift, provenance: PROV, posts: [POST("a", "2024-10-19")] })); + assert.deepEqual(h[1], { hold: 2.5, move: null }); + const noHold = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { hold: 0 } } } }; + const m = segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: noHold, provenance: PROV, posts: [POST("a", "2024-10-19")] })); + assert.equal(m[1].hold, 0); + assert.ok(m[1].move); + // Neither: no joins at all. + const neither = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { hold: 0, shift: false } } } }; + assert.equal(segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: neither, provenance: PROV, posts: [POST("a", "2024-10-19")] })), null); +}); + +test("the cut's lengths: the schedule's starts are the probed lengths plus the holds, as the joins carry them", () => { + const durs = [10, 12, 4]; + const s = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); + const joins = segmentJoins(s); + // cutOffsets' sum, done by hand: probed + hold, then scheduleFrom. + const cut = scheduleFrom(durs.map((d, i) => d + (joins[i]?.hold ?? 0)), 0.5); + assert.deepEqual(cut.starts, s.segments.map((x) => x.start)); + assert.equal(cut.total, s.total); + assert.equal(s.total, 10 + 12 + 4 - 1 + 2.5); +}); + +test("a first post inside the hold: the move's segmentAt is past the clip's own last frame, in the held clock", () => { + // c2 is 12 s on disk; one post at 2 s with the 2.5 s hold appears 12.0 s + // into the segment -- inside the hold. The move runs after the hold, so + // that is a moment its clock reaches. + const two = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { seconds: 2 } } } }; + const s = deckSchedule({ entries: CLIPS, durs: [10, 12, 4], D: 0.5, render: two, provenance: PROV, posts: [POST("a", "2024-10-19")] }); + const [m] = s.moves; + assert.equal(m.segment, "c2"); + assert.equal(m.segmentAt, 12); + assert.equal(s.segments[1].duration, 14.5); + assert.ok(m.segmentAt + m.seconds <= s.segments[1].duration - 0.5, "the glide lands before the dissolve out"); + const c = joinInputChain(1, segmentJoins(s)[1], two); + assert.ok(c.parts[0].indexOf("tpad=") < c.parts[0].indexOf("perspective="), "hold, then move"); +}); + +test("holds are whole frames: 2.5 s at 25 fps is 63 frames (2.52 s), and the cut's length counts that", () => { + const r25 = { ...RENDER, fps: 25 }; + const s = deckSchedule({ + entries: CLIPS, durs: [10, 12, 4], D: 0, render: r25, provenance: PROV, + posts: [POST("a", "2024-10-19"), POST("b", "2025-07-01")], + }); + assert.equal(s.segments[1].hold, 2.52); + assert.equal(s.segments[2].hold, 2.52); + assert.equal(s.total, 10 + 12 + 4 + 2 * 2.52); + assert.equal(holdVideoFilter(segmentJoins(s)[1].hold), "tpad=stop_mode=clone:stop_duration=2.52"); + // At 30 fps 2.5 s is already 75 frames: unchanged. + const s30 = deckSchedule({ entries: CLIPS, durs: [10, 12, 4], D: 0, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); + assert.equal(s30.segments[1].hold, 2.5); +}); + +test("freezeSamples: between the hold's start (or the move's end) and the dissolve; skipped when under three frames", async () => { + const { freezeSamples } = await import("./verify-build.mjs"); + const seg = { id: "c2", start: 9.5, end: 24, hold: 2.5 }; + const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`); + const a = freezeSamples(seg, { fps: 30, D: 0.5 }); + close(a.at[0], 21.5 + 0.05); + close(a.at[1], 23.5 - 0.05); + // A move that ends inside the hold: the still span starts where it lands. + close(freezeSamples(seg, { fps: 30, D: 0.5, moveEnd: 22.1 }).at[0], 22.1 + 0.05); + // Hold 0.5 under a 0.5 s crossfade: all of it is the dissolve. + assert.match(freezeSamples({ ...seg, hold: 0.5 }, { fps: 30, D: 0.5 }).skip, /^0\.000s of still picture/); + // Hard cut: up to the end. The last segment: up to its end fade. + close(freezeSamples({ ...seg, hold: 0.5 }, { fps: 30, D: 0 }).at[1], 24 - 0.05); + close(freezeSamples(seg, { fps: 30, D: 0.5, last: true, endFade: 1 }).at[1], 23 - 0.05); + assert.ok(freezeSamples({ ...seg, hold: 1 }, { fps: 30, D: 0.5, last: true, endFade: 1 }).skip); +}); + +// ---- the chains, as strings ------------------------------------------------- + +test("joinInputChain: no join, no chain -- the input's own labels", () => { + assert.deepEqual(joinInputChain(3, null, RENDER), { parts: [], v: "[3:v]", a: "[3:a]" }); +}); + +test("joinInputChain: a hold is tpad clone on the picture and apad silence on the sound", () => { + assert.equal(holdVideoFilter(2.5), "tpad=stop_mode=clone:stop_duration=2.5"); + assert.equal(holdAudioFilter(2.5), "apad=pad_dur=2.5"); + assert.deepEqual(joinInputChain(1, { hold: 2.5, move: null }, RENDER), { + parts: ["[1:v]tpad=stop_mode=clone:stop_duration=2.5[j1v]", "[1:a]apad=pad_dur=2.5[j1a]"], + v: "[j1v]", + a: "[j1a]", + }); +}); + +test("joinInputChain: the move goes after the hold, so its clock counts the held frames; a move alone leaves the sound alone", () => { + const both = joinInputChain(1, { hold: 2.5, move: MOVE(6) }, RENDER); + assert.equal(both.parts.length, 2); + assert.equal(both.parts[0], `[1:v]tpad=stop_mode=clone:stop_duration=2.5,${moveFilter(MOVE(6), RENDER)}[j1v]`); + assert.equal(both.parts[1], "[1:a]apad=pad_dur=2.5[j1a]"); + const move = joinInputChain(1, { hold: 0, move: MOVE(6) }, RENDER); + assert.deepEqual(move, { parts: [`[1:v]${moveFilter(MOVE(6), RENDER)}[j1v]`], v: "[j1v]", a: "[1:a]" }); +}); + +test("moveFilter: perspective places the frame's corners so the footage box eases from `from` to `to`", () => { + const f = moveFilter(MOVE(6), RENDER); + const [fill, persp] = f.split(/,(?=perspective=)/); + assert.equal(fill, "fillborders=left=2:right=2:top=2:bottom=2:mode=fixed:color=#12101a"); + assert.match(persp, /:interpolation=linear:sense=destination:eval=frame$/); + // 1574×886 at (173,2) → 1354×762 at (24,64): the input frame's corners at e = 1. + const e = "st(0,clip(((in-1)/30-6)/0.6,0,1))"; + const ease = "*ld(0)*ld(0)*(3-2*ld(0))"; + assert.equal( + persp, + "perspective=" + [ + `x0='${e};0+(-124.8196)${ease}'`, `y0='${e};0+(62.2799)${ease}'`, + `x1='${e};W+(-393.1804)${ease}'`, `y1='${e};0+(62.2799)${ease}'`, + `x2='${e};0+(-124.8196)${ease}'`, `y2='${e};H+(-88.8713)${ease}'`, + `x3='${e};W+(-393.1804)${ease}'`, `y3='${e};H+(-88.8713)${ease}'`, + ].join(":") + ":interpolation=linear:sense=destination:eval=frame", + ); + // The corner offsets ARE the box map: the from box's corners land on the to box's. + const F = deckGeometry(RENDER).footage; + const T = shiftedFootage(RENDER); + const X = (u) => -124.8196 + (u * (1920 - 393.1804 - -124.8196)) / 1920; + const Y = (v) => 62.2799 + (v * (1080 - 88.8713 - 62.2799)) / 1080; + near(X(F.x), T.x, "left"); + near(X(F.x + F.width), T.x + T.width, "right"); + near(Y(F.y), T.y, "top"); + near(Y(F.y + F.height), T.y + T.height, "bottom"); + // No easing time: a cut. + assert.match(moveFilter(MOVE(6, { seconds: 0 }), RENDER), /x0='st\(0,gte\(\(in-1\)\/30,6\)\);0\+/); +}); + +function near(a, b, msg) { assert.ok(Math.abs(a - b) < 0.01, `${msg}: ${a} != ${b}`); } + +test("xfadeGraph: without joins, the graph the crossfade concat always wrote", () => { + const g = xfadeGraph([10, 12, 4], 0.5, null, RENDER); + assert.deepEqual(g.parts, [ + // Each input's sound pinned to its picture's length before the crossfade. + "[0:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p0a]", + "[1:a]apad=whole_dur=12.000000,atrim=end=12.000000,asetpts=PTS-STARTPTS[p1a]", + "[2:a]apad=whole_dur=4.000000,atrim=end=4.000000,asetpts=PTS-STARTPTS[p2a]", + "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", + "[p0a][p1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[v1][2:v]xfade=transition=fade:duration=0.5:offset=21.000[v2]", + "[a1][p2a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", + ]); + assert.equal(g.vlab, "[v2]"); + assert.equal(g.alab, "[a2]"); + // An all-null join list is the same graph. + assert.deepEqual(xfadeGraph([10, 12, 4], 0.5, [null, null, null], RENDER), g); +}); + +test("xfadeGraph: a held input joins through its chain, and the offsets after it move by the hold", () => { + const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; + const g = xfadeGraph([10, 14.5, 4], 0.5, joins, RENDER); + const chain = joinInputChain(1, joins[1], RENDER).parts; + assert.deepEqual(g.parts, [ + "[0:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p0a]", + ...chain, + // The held input's sound is pinned to its length WITH the hold. + "[j1a]apad=whole_dur=14.500000,atrim=end=14.500000,asetpts=PTS-STARTPTS[p1a]", + "[2:a]apad=whole_dur=4.000000,atrim=end=4.000000,asetpts=PTS-STARTPTS[p2a]", + "[0:v][j1v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", + "[p0a][p1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[v1][2:v]xfade=transition=fade:duration=0.5:offset=23.500[v2]", + "[a1][p2a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", + ]); +}); + +test("hardCutFilterArgs: the concat filter over each input's chain, one encode", () => { + const joins = [null, { hold: 2.5, move: null }, null]; + const args = hardCutFilterArgs(["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], joins, RENDER, "/o/x.prerail-hardcut.mp4"); + assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"]); + assert.equal( + args[args.indexOf("-filter_complex") + 1], + "[1:v]tpad=stop_mode=clone:stop_duration=2.5[j1v];[1:a]apad=pad_dur=2.5[j1a];" + + "[0:v][0:a][j1v][j1a][2:v][2:a]concat=n=3:v=1:a=1[vc][ac]", + ); + assert.deepEqual(args.slice(args.indexOf("-map"), args.indexOf("-map") + 4), ["-map", "[vc]", "-map", "[ac]"]); + assert.equal(args[args.indexOf("-c:v") + 1], "libx264"); + assert.equal(args[args.indexOf("-c:a") + 1], "aac"); + assert.equal(args.at(-1), "/o/x.prerail-hardcut.mp4"); +}); + +test("the hard-cut record: the list alone without joins; the joins with them, so a changed hold is never reused", () => { + const segs = ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"]; + assert.equal(concatRecordText(segs, null), concatListText(segs)); + const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; + const rec = concatRecordText(segs, joins); + assert.ok(rec.startsWith(concatListText(segs))); + assert.match(rec, /^# join 1 \{"hold":2\.5,"move":\{"segment":"c2"/m); + assert.equal(sameConcatList(rec, segs, joins), true); + // The list a deck without posts records is not the record of a joined one, either way round. + assert.equal(sameConcatList(rec, segs), false); + assert.equal(sameConcatList(concatListText(segs), segs, joins), false); + // A changed hold, a changed move, a hold moved to another clip: all stale. + assert.equal(sameConcatList(rec, segs, [null, { hold: 3, move: MOVE(6) }, null]), false); + assert.equal(sameConcatList(rec, segs, [null, { hold: 2.5, move: MOVE(6.5) }, null]), false); + assert.equal(sameConcatList(rec, segs, [{ hold: 2.5, move: MOVE(6) }, null, null]), false); +}); + +// ---- window maths with holds ------------------------------------------------ + +test("a preview window over a held clip: picked by the cut's lengths, its inputs joined as the full concat's", () => { + const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; + const durs = [10, 14.5, 4]; // c2 is 12 s on disk, 14.5 in the cut + const { starts } = scheduleFrom(durs, 0.5); + assert.deepEqual(starts, [0, 9.5, 23.5]); + // 21.5–23.0 is c2's hold: by the files' own lengths it would reach into c3. + assert.deepEqual(windowSegments(starts, durs, 21.5, 1.5), { first: 1, last: 1, offset: 12 }); + const plan = { regions: [], outLabel: "[hfout]" }; + const args = previewFromSegmentsArgs({ + segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs, starts, D: 0.5, at: 20, dur: 6, + render: RENDER, chromePlan: plan, outPath: "/p.mp4", joins, + }); + assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), ["/s/b.mp4", "/s/c.mp4"]); + const fc = args[args.indexOf("-filter_complex") + 1]; + // b is input 0 here, joined as input 1 is in the full concat. + assert.ok(fc.startsWith(joinInputChain(0, joins[1], RENDER).parts.join(";") + ";")); + assert.match(fc, /\[j0v\]\[1:v\]xfade=transition=fade:duration=0\.5:offset=14\.000\[v1\]/); + assert.match(fc, /\[v1\]trim=start=10\.500:duration=6\.000/); + // One held segment alone: its chain, then the trim. + const one = previewFromSegmentsArgs({ + segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs, starts, D: 0.5, at: 21.5, dur: 1.5, + render: RENDER, chromePlan: plan, outPath: "/p.mp4", joins, + }); + assert.match(one[one.indexOf("-filter_complex") + 1], /\[j0v\]trim=start=12\.000:duration=1\.500,setpts=PTS-STARTPTS\[vw\];\[j0a\]atrim=/); + const plain = previewFromSegmentsArgs({ + segments: ["/s/a.mp4", "/s/b.mp4"], durs: [10, 10], starts: [0, 9.5], D: 0.5, at: 8, dur: 4, + render: RENDER, chromePlan: plan, outPath: "/p.mp4", + }); + // Without joins, the window's graph is the crossfade concat's: each sound + // pinned to its picture, then the fades. + assert.match(plain[plain.indexOf("-filter_complex") + 1], /^\[0:a\]apad=whole_dur=10\.000000,atrim=end=10\.000000,asetpts=PTS-STARTPTS\[p0a\];\[1:a\]apad=whole_dur=10\.000000[^;]*\[p1a\];\[0:v\]\[1:v\]xfade=transition=fade:duration=0\.5:offset=9\.500\[v1\];\[p0a\]\[p1a\]acrossfade/); +}); + +// ---- ffmpeg, for real ------------------------------------------------------- + +const have = (b, a) => spawnSync(b, a, { stdio: "ignore" }).status === 0; +const haveFfmpeg = have("ffmpeg", ["-version"]); + +const R = { + width: 320, height: 180, fps: 30, palette: PALETTE, crf: 21, preset: "veryfast", + audioRate: 48000, audioChannels: 2, +}; +const ff = (args, opts = {}) => { + const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args], { maxBuffer: 1 << 28, ...opts }); + assert.equal(r.status, 0, String(r.stderr)); + return r.stdout; +}; +/** framemd5's hashes for one output stream (the picture is stream 0). */ +const md5s = (out, stream = 0) => String(out).split("\n").filter((l) => l && !l.startsWith("#")) + .map((l) => l.split(",")).filter((f) => Number(f[0]) === stream).map((f) => f.at(-1).trim()); + +/** Three 2 s segments as the deck frames them: footage in a box over bg, a tone under it. */ +function segments(dir) { + const make = (name, src, hz) => { + const f = path.join(dir, `${name}.mov`); + ff([ + "-f", "lavfi", "-i", `${src}=s=280x150:r=30:d=2`, + "-f", "lavfi", "-i", `sine=frequency=${hz}:sample_rate=48000:duration=2`, + "-filter_complex", `[0:v]pad=320:180:20:10:color=${PALETTE.bg},format=yuv420p[v];[1:a]aformat=channel_layouts=stereo[a]`, + "-map", "[v]", "-map", "[a]", "-c:v", "ffv1", "-c:a", "pcm_s16le", f, + ]); + return f; + }; + return [make("a", "testsrc2", 440), make("b", "smptebars", 550), make("c", "rgbtestsrc", 660)]; +} + +test("ffmpeg: a held segment's last frame repeats for exactly hold·fps frames, in silence; the other inputs pass through untouched", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-")); + try { + const segs = segments(dir); + const own = segs.map((s) => md5s(ff(["-i", s, "-map", "0:v", "-f", "framemd5", "-"]))); + own.forEach((m) => assert.equal(m.length, 60)); + const joins = [null, { hold: 1, move: null }, null]; + // The hard cut's graph, run to frame hashes instead of an encode. + const args = hardCutFilterArgs(segs, joins, R, "-"); + const fc = args[args.indexOf("-filter_complex") + 1]; + const inputs = segs.flatMap((s) => ["-i", s]); + const out = md5s(ff([...inputs, "-filter_complex", fc, "-map", "[vc]", "-map", "[ac]", "-f", "framemd5", "-"])); + assert.equal(out.length, 60 + 60 + 30 + 60, "30 held frames and not one more"); + // a and c: their own frames, bit for bit. + assert.deepEqual(out.slice(0, 60), own[0]); + assert.deepEqual(out.slice(150), own[2]); + // b, then its last frame 15 more times. + assert.deepEqual(out.slice(60, 120), own[1]); + assert.deepEqual(out.slice(119, 150), Array(31).fill(own[1][59])); + // The sound under the hold is silence; around it, the tones. + const pcm = ff([...inputs, "-filter_complex", `${fc};[vc]nullsink`, "-map", "[ac]", "-f", "s16le", "-ac", "1", "-"], { encoding: "buffer" }); + const at = (s) => pcm.readInt16LE(Math.round(s * 48000) * 2); + const peak = (a, b) => { + let m = 0; + for (let i = Math.round(a * 48000); i < Math.round(b * 48000); i += 1) m = Math.max(m, Math.abs(pcm.readInt16LE(i * 2))); + return m; + }; + assert.equal(pcm.length / 2, 5 * 48000 + 96000, "the sound is held as long as the picture"); + assert.equal(peak(4.0, 5.0), 0, "silence under the hold"); + assert.ok(peak(3.5, 4.0) > 1000, "b's tone before it"); + assert.ok(peak(5.0, 5.5) > 1000, "c's tone after it"); + assert.ok(Number.isFinite(at(0))); + + // The crossfade concat (xfade hands on its own pixel format, so frames + // are compared with the graph the build ran before there were joins): + // a and c come out exactly as they did -- c 30 frames later -- and b's + // last frame is held up to the next dissolve. + const D = 0.5; + const legacy = md5s(ff([...inputs, "-filter_complex", [ + "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=1.500[v1]", + "[0:a][1:a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[v1][2:v]xfade=transition=fade:duration=0.5:offset=3.000[v2]", + "[a1][2:a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", + ].join(";"), "-map", "[v2]", "-map", "[a2]", "-f", "framemd5", "-"])); + assert.equal(legacy.length, 150); + const xg = xfadeGraph([2, 3, 2], D, joins, R); + const xo = md5s(ff([...inputs, "-filter_complex", xg.parts.join(";"), "-map", xg.vlab, "-map", xg.alab, "-f", "framemd5", "-"])); + assert.equal(xo.length, 60 + 90 + 60 - 30); + assert.deepEqual(xo.slice(0, 60), legacy.slice(0, 60), "a and the dissolve into b"); + assert.deepEqual(xo.slice(60, 90), legacy.slice(60, 90), "b, to where the old cut dissolved"); + assert.deepEqual(xo.slice(104, 120), Array(16).fill(xo[104]), "b's last frame, held to the next dissolve"); + assert.deepEqual(xo.slice(135), legacy.slice(105), "c after its dissolve, 30 frames later"); + + // Without joins the graph is exactly the one above. + const plain = xfadeGraph([2, 2, 2], D, null, R); + assert.deepEqual( + md5s(ff([...inputs, "-filter_complex", plain.parts.join(";"), "-map", plain.vlab, "-map", plain.alab, "-f", "framemd5", "-"])), + legacy, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("ffmpeg: the move is the identity before it starts, lands on the target box, and leaves the uncovered ground bg", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-mv-")); + try { + const [, b] = segments(dir); + const move = { segment: "b", at: 0, segmentAt: 0.5, seconds: 0.6, + from: { x: 20, y: 10, width: 280, height: 150 }, to: { x: 8, y: 20, width: 240, height: 128 } }; + const c = joinInputChain(0, { hold: 0, move }, R); + const rgb = (n) => ff(["-i", b, "-filter_complex", `${c.parts.join(";")};${c.v}select=eq(n\\,${n})[o]`, "-map", "[o]", + "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const src = (n) => ff(["-i", b, "-vf", `select=eq(n\\,${n})`, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const px = (buf, x, y) => [...buf.subarray((y * 320 + x) * 3, (y * 320 + x) * 3 + 3)]; + // Before the move, inside the 2 px border, the frame is the input's. + const pre = rgb(10), pin = src(10); + for (const [x, y] of [[20, 10], [160, 90], [299, 159], [100, 40]]) assert.deepEqual(px(pre, x, y), px(pin, x, y), `pre ${x},${y}`); + // After it (0.5 + 0.6 s → frame 33 on), the footage's top-left corner is + // at (8,20) and everything right of and below the target box is ground. + const post = rgb(45); + const bg = [0x12, 0x10, 0x1a]; + const close = (p, q, tol = 3) => p.every((v, i) => Math.abs(v - q[i]) <= tol); + for (const [x, y] of [[300, 10], [310, 170], [4, 4], [160, 160], [260, 100], [100, 15]]) { + assert.ok(close(px(post, x, y), bg), `ground at ${x},${y}: ${px(post, x, y)}`); + } + // The footage's own first column/row (smptebars' left edge) now starts at the target corner. + assert.ok(!close(px(post, 10, 22), bg), "footage at the target box"); + assert.ok(!close(px(post, 245, 145), bg), "footage to the target box's far corner"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("ffmpeg: a move that starts inside the hold glides the frozen frame to the target box", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-mh-")); + try { + const [, b] = segments(dir); + // b is 2 s (60 frames), held 1 s; the move starts at 2.1 s -- inside the hold. + const move = { segment: "b", at: 0, segmentAt: 2.1, seconds: 0.6, + from: { x: 20, y: 10, width: 280, height: 150 }, to: { x: 8, y: 20, width: 240, height: 128 } }; + const c = joinInputChain(0, { hold: 1, move }, R); + c.parts.push(`${c.a}anullsink`); + const all = md5s(ff(["-i", b, "-filter_complex", c.parts.join(";"), "-map", c.v, "-f", "framemd5", "-"])); + assert.equal(all.length, 90, "the hold's 30 frames are all there"); + const rgb = (n) => ff(["-i", b, "-filter_complex", `${c.parts.join(";")};${c.v}select=eq(n\\,${n})[o]`, "-map", "[o]", + "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const px = (buf, x, y) => [...buf.subarray((y * 320 + x) * 3, (y * 320 + x) * 3 + 3)]; + const bg = [0x12, 0x10, 0x1a]; + const close = (p, q, tol = 3) => p.every((v, i) => Math.abs(v - q[i]) <= tol); + // Frozen and not yet moved at 2.0 s; moving from 2.1 s; landed by 2.7 s. + assert.equal(all[60], all[62], "held, before the move"); + assert.ok(!close(px(rgb(62), 290, 150), bg), "footage still at the from box"); + assert.notEqual(all[66], all[62], "the held frame moves"); + const post = rgb(85); + for (const [x, y] of [[300, 10], [310, 170], [260, 100]]) assert.ok(close(px(post, x, y), bg), `ground at ${x},${y}`); + assert.ok(!close(px(post, 245, 145), bg), "footage to the target box's far corner"); + assert.deepEqual(all.slice(82), Array(8).fill(all[82]), "landed, then still"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("verify-build: a held clip's freeze is found in the file, and a cut that dropped it is refused", { skip: !haveFfmpeg }, async () => { + const { verifyHolds } = await import("./verify-build.mjs"); + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-vb-")); + try { + const render = { ...R, width: 640, height: 360, chrome: { engine: "hyperframes", layout: "deck", deck: { height: 120, posts: { width: 320 } } } }; + const held = path.join(dir, "held.mp4"); + const moving = path.join(dir, "moving.mp4"); + const enc = ["-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "18"]; + ff(["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=2", "-vf", "tpad=stop_mode=clone:stop_duration=1", ...enc, held]); + ff(["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=3", ...enc, moving]); + const schedule = { fps: 30, total: 3, segments: [{ id: "c1", start: 0, duration: 3, end: 3, hold: 1 }] }; + const ok = []; + const got = await verifyHolds(held, schedule, render, ok); + assert.deepEqual(ok, []); + assert.equal(got.length, 1); + assert.ok(got[0].diff <= 1.5, `diff ${got[0].diff}`); + const bad = []; + await verifyHolds(moving, schedule, render, bad); + assert.equal(bad.length, 1); + assert.match(bad[0], /c1 is held 1s but its picture moves during the hold/); + // Nothing held, nothing to check. + assert.deepEqual(await verifyHolds(moving, { ...schedule, segments: [{ id: "c1", start: 0, duration: 3, end: 3 }] }, render, bad), []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -38,14 +38,25 @@ export const DECK_DEFAULTS = Object.freeze({ motion: Object.freeze({ out: 0.3, in: 0.45, pip: 0.7 }), // The manifest's `posts`, drawn as cards over the footage at the end of the // clip each one is attached to. position | "top-left". - posts: Object.freeze({ show: true, seconds: 2, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24 }), + // `hold` freezes the last frame of a clip that carries posts for that long, + // so the last of them can be read before the next clip; `shift` moves the + // footage away from the posts column (scaled to `scale`, over `seconds`) + // while they are up, or is `false`. + posts: Object.freeze({ + show: true, seconds: 4, hold: 2.5, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24, + shift: Object.freeze({ scale: 0.86, seconds: 0.6 }), + }), }); /** Subtitle tokens a `subtitle.parts` array may name. "auto" picks from these. */ export const SUBTITLE_TOKENS = Object.freeze(["channel", "title", "date", "clock"]); -/** Segment types the deck slides away over when `overCards: "hide"`. */ -export const CARD_TYPES = Object.freeze(["card", "scroll", "chart", "ledger"]); +/** + * Segment types the deck slides away over when `overCards: "hide"`. A + * `teaser` is one too, and the deck slides away over it whatever `overCards` + * says: it is a full-frame finale, never framed into the footage box. + */ +export const CARD_TYPES = Object.freeze(["card", "scroll", "chart", "ledger", "teaser"]); /** Does this render block ask for the deck? Absent, nothing in this file runs. */ export function deckOn(render) { @@ -63,6 +74,12 @@ export function resolveDeck(render) { ? { ...base, ...v } : v; } + // `posts.shift` is the one setting two levels down: `false` turns it off, + // an object fills from the default's. + const sh = d.posts?.shift; + if (sh !== undefined && sh !== null) { + merged.posts = { ...merged.posts, shift: sh === false ? false : { ...DECK_DEFAULTS.posts.shift, ...sh } }; + } return merged; } @@ -101,6 +118,9 @@ export function validateChrome(chrome, render = {}) { if (render.chromeEngine !== undefined) { errors.push("render.chrome replaces render.chromeEngine — remove chromeEngine"); } + // The end fade is a render key the deck's cut is finished with; checked + // here too, so a writer that validates the deck refuses a bad one. + errors.push(...validateEndFade(render)); const d = chrome.deck ?? {}; if (!isObj(d)) return [...errors, "render.chrome.deck must be an object"]; const w = "render.chrome.deck"; @@ -167,7 +187,16 @@ export function validateChrome(chrome, render = {}) { errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`); } }); - sub("posts", ["show", "seconds", "position", "width", "qrSize", "maxLines", "inset"], (p) => { + sub("posts", ["show", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift"], (p) => { + if (p.hold !== undefined && !numIn(p.hold, 0, 10)) errors.push(`${w}.posts.hold must be from 0 to 10 seconds`); + if (p.shift !== undefined && p.shift !== false) { + if (!isObj(p.shift)) errors.push(`${w}.posts.shift must be false or { scale, seconds }`); + else { + unknownKeys(p.shift, ["scale", "seconds"], `${w}.posts.shift`, errors); + if (p.shift.scale !== undefined && !numIn(p.shift.scale, 0.5, 1)) errors.push(`${w}.posts.shift.scale must be from 0.5 to 1`); + if (p.shift.seconds !== undefined && !numIn(p.shift.seconds, 0, 3)) errors.push(`${w}.posts.shift.seconds must be from 0 to 3`); + } + } if (p.show !== undefined && typeof p.show !== "boolean") errors.push(`${w}.posts.show must be true or false`); if (p.seconds !== undefined && !numIn(p.seconds, 0.5, 10)) errors.push(`${w}.posts.seconds must be from 0.5 to 10`); if (p.position !== undefined && !["top-right", "top-left"].includes(p.position)) { @@ -369,16 +398,30 @@ export function scheduleFrom(durs, D) { */ export function estimatedDuration(entry, render = {}) { if (entry.type === "clip") { - const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); - const lead = render.leadIn ?? 0.4; - const a = hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start; - const b = hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end; - return Math.max(1, b - a); + const { from, to } = playWindow(entry, render); + return Math.max(1, to - from); } if (entry.type === "image") return Number(entry.seconds ?? 4); return Number(entry.seconds ?? 5); } +/** + * The SOURCE seconds a clip asks to play, before silence snapping: the cut + * (`cutStart`/`cutEnd`) with the lead-in breath before it, clamped into the + * extent, when there is one; else the extent (`start`/`end`). The build + * snaps each end to a nearby silence, so the segment's true start is this + * `from` moved by up to `snapWindow` -- which is why a build records the real + * one beside the segment (`<id>.cut.json`). + */ +export function playWindow(entry, render = {}) { + const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); + const lead = render.leadIn ?? 0.4; + return { + from: hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start, + to: hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end, + }; +} + /** The crossfade a build of this render block will use. */ export function transitionOf(render, { noXfade = false } = {}) { const t = render?.transition ?? 0.5; @@ -398,6 +441,7 @@ export function isMultiChannel(entries, provenance = {}) { /** Is the deck hidden over this segment? */ export function hidesDeck(entry, deck) { + if (entry.type === "teaser") return true; return deck.overCards === "hide" && CARD_TYPES.includes(entry.type); } @@ -409,6 +453,11 @@ export function deckText(entry, meta, provenance, deck, multiChannel) { if (subtitle === undefined) { if (entry.type === "clip") { subtitle = deckSubtitle(attributionParts(entry, meta ?? {}, provenance), deck.subtitle, multiChannel); + } else if (entry.type === "teaser") { + // The deck is never up over a teaser; its words are what a table of + // the cut (umtool's On-screen rows, the schedule) names it by. + if (!title) title = teaserTitle(entry); + subtitle = ""; } else if (entry.type === "image") { subtitle = deckSubtitle( { channel: "", title: String(entry.title ?? "").trim(), date: String(entry.date ?? "").trim(), at: null }, @@ -457,11 +506,18 @@ export function deckSchedule({ entries, durs, D, render, provenance = {}, metas = [], estimated = false, posts = [], }) { const deck = resolveDeck(render); - const { starts, total } = scheduleFrom(durs, D); + // A clip that carries posts is held on its last frame for `posts.hold`: the + // hold is part of the segment's length in the cut, so every start, the total + // and the posts' timing below are measured with it. `durs` are the segments' + // own (probed or estimated) lengths. + const holds = deck.posts.show ? postHolds({ posts, entries, metas, render }) : new Map(); + const full = durs.map((d, i) => d + (holds.get(entries[i].id) ?? 0)); + const { starts, total } = scheduleFrom(full, D); const multiChannel = isMultiChannel(entries, provenance); const round = (v) => Math.round(v * 1000) / 1000; - const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: durs[i] })); + const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: full[i] })); const placed = deck.posts.show ? postSchedule({ posts, entries, metas, segments: segs, D, total, render }) : []; + const moves = placed.length ? footageMoves({ posts: placed, segments: segs, render }) : []; return { version: 1, kind: "deck", @@ -476,8 +532,10 @@ export function deckSchedule({ id: e.id, type: e.type, start: round(starts[i]), - duration: round(durs[i]), - end: round(starts[i] + durs[i]), + duration: round(full[i]), + end: round(starts[i] + full[i]), + // Only on a held clip, so a cut without posts writes the schedule it always did. + ...(holds.get(e.id) ? { hold: round(holds.get(e.id)) } : {}), title, subtitle, qrUrl: deck.qr.show ? deckQrUrl(e, provenance) : null, @@ -487,6 +545,7 @@ export function deckSchedule({ // Present only when there are posts to draw, so a cut without them writes // the schedule it always did. ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}), + ...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}), }; } @@ -773,14 +832,404 @@ export function snapWindow(window, { fps, total }) { * height less the insets. Cards stack top-down inside it. */ export function postsGeometry(render) { - const { footage: f } = deckGeometry(render); + const { W, footage: f } = deckGeometry(render); const p = resolveDeck(render).posts; const width = even(p.width); const height = even(f.height - 2 * p.inset); - const x = p.position === "top-left" ? f.x + p.inset : f.x + f.width - p.inset - width; + const left = p.position === "top-left"; + // With `shift` the footage makes room, so the column sits at the FRAME's + // edge; without it, inside the footage box as before. + const x = p.shift + ? (left ? p.inset : W - p.inset - width) + : (left ? f.x + p.inset : f.x + f.width - p.inset - width); return { x: Math.round(x), y: Math.round(f.y + p.inset), width, height }; } +/** + * The footage box while a clip's posts are up (`posts.shift`): scaled by + * `shift.scale` about nothing in particular, its far edge `inset` from the + * frame edge AWAY from the posts column, centred in the height above the deck. + * null when shift is off. + */ +export function shiftedFootage(render) { + const { W, H, deck: d, footage: f } = deckGeometry(render); + const p = resolveDeck(render).posts; + if (!p.shift) return null; + const width = even(f.width * p.shift.scale); + const height = even(f.height * p.shift.scale); + const x = p.position === "top-left" ? W - p.inset - width : p.inset; + return { x: Math.round(x), y: Math.floor((H - d.height - height) / 2), width, height }; +} + +/** + * How long each clip that carries posts is held on its last frame (entry id → + * seconds), in WHOLE FRAMES: `tpad` clones a whole number of frames (2.5 s at + * 25 fps is 63, not 62.5) while `apad` pads exactly, so an unrounded hold + * would make a hard cut with several held clips longer than its schedule. + */ +export function postHolds({ posts = [], entries = [], metas = [], render }) { + const fps = render?.fps ?? 30; + const hold = Math.round(resolveDeck(render).posts.hold * fps) / fps; + const out = new Map(); + if (!(hold > 0)) return out; + for (const a of attachPosts({ posts, entries, metas })) out.set(a.entryId, hold); + return out; +} + +/** + * When the footage moves aside for a clip's posts: one move per carrying clip, + * starting as its first post appears (`at`, cut clock; `segmentAt`, the + * segment's own clock, its hold included) and easing over `shift.seconds` + * from the footage box to `shiftedFootage`. It stays there to the end of the + * segment; the next segment comes in at the normal box through the + * transition. `segmentAt` may fall inside the hold: the build runs the move + * after the hold, so the frozen frame moves too. + * + * @returns {Array<{ segment, at, segmentAt, seconds, from, to }>} + */ +export function footageMoves({ posts, segments, render }) { + const to = shiftedFootage(render); + if (!to) return []; + const from = deckGeometry(render).footage; + const seconds = resolveDeck(render).posts.shift.seconds; + const first = new Map(); + for (const p of posts) first.set(p.segment, Math.min(first.get(p.segment) ?? Infinity, p.appear)); + return segments + .filter((s) => first.has(s.id)) + .map((s) => ({ segment: s.id, at: first.get(s.id), segmentAt: first.get(s.id) - s.start, seconds, from, to })); +} + +// --------------------------------------------------------------------------- +// Cut edits made where the cut is joined, like the hold: a clip's `muteFrom` +// and the cut's `render.endFade`. Neither touches a segment file, so +// `--chrome-only` changes either without rebuilding a clip. Pure here: the +// validators (umtool's writers and the build share them) and the arithmetic. +// --------------------------------------------------------------------------- + +/** The fade into a `muteFrom`'s silence (`mute.mjs`, dependency-free so a client bundle can take it alone). */ +export { MUTE_FADE } from "./mute.mjs"; + +/** `render.endFade`'s upper bound, in seconds. */ +export const END_FADE_MAX = 10; + +/** + * Why one timeline entry's `muteFrom` cannot be built, as sentences. It is in + * SOURCE seconds, like `start`/`end`/`cutEnd`: a number within the clip's + * extent. Absent (or null) is fine. + */ +export function validateMuteFrom(entry, where = `timeline entry ${entry?.id ?? "?"}`) { + const v = entry?.muteFrom; + if (v === undefined || v === null) return []; + if (entry.type !== "clip") return [`${where}.muteFrom: only a clip has sound to mute`]; + if (typeof v !== "number" || !Number.isFinite(v)) return [`${where}.muteFrom must be a number of source seconds`]; + if (v < entry.start || v > entry.end) { + return [`${where}.muteFrom ${v} is outside the clip's ${entry.start}–${entry.end}`]; + } + return []; +} + +/** Why `render.endFade` cannot be built, as sentences: seconds from 0 (off) to END_FADE_MAX. */ +export function validateEndFade(render) { + const v = render?.endFade; + if (v === undefined || v === null) return []; + if (!numIn(v, 0, END_FADE_MAX)) return [`render.endFade must be from 0 to ${END_FADE_MAX} seconds`]; + return []; +} + +/** Every `muteFrom` in the timeline and `render.endFade`, checked: the build refuses with these before it fetches. */ +export function validateCutEdits(manifest) { + const errors = []; + (manifest?.timeline ?? []).forEach((e, i) => errors.push(...validateMuteFrom(e, `timeline[${i}] (${e?.id ?? "?"})`))); + errors.push(...validateEndFade(manifest?.render)); + return errors; +} + +/** The end fade a render block asks for, in seconds (0 = none). */ +export const endFadeOf = (render) => (numIn(render?.endFade, 0, END_FADE_MAX) ? render.endFade : 0); + +/** + * Where a clip's `muteFrom` falls in its SEGMENT's clock, from the source + * second the segment really starts at. + * + * The build cuts a segment from the snapped start, and records that start + * beside it (`<id>.cut.json`: `{ video, start, end }`, source seconds). A + * record is believed when it names this clip's video and is as long as the + * segment (`seconds`, its probed length) to within two frames; otherwise -- + * a segment built before records existed, or one copied without its record -- + * the start is the unsnapped `playWindow` start, and `note` says so: snapping + * may have moved the true start by up to `snapWindow` seconds. + * + * @returns {{ at: number, source: "record" | "window", note?: string }} + * `at` in segment seconds, never below 0 (a muteFrom before the segment's + * start mutes it from its first sample). + */ +export function muteSegmentSeconds({ entry, record = null, render = {}, seconds = null }) { + const fps = render.fps ?? 30; + const ok = record && record.video === entry.video && + Number.isFinite(record.start) && Number.isFinite(record.end) && + (seconds == null || Math.abs(record.end - record.start - seconds) <= 2 / fps); + const at = (from) => Math.max(0, Math.round((entry.muteFrom - from) * 1000) / 1000); + if (ok) return { at: at(record.start), source: "record" }; + const { from } = playWindow(entry, render); + return { + at: at(from), + source: "window", + note: + `${entry.id}: ${record ? "the cut record does not match the segment" : "no cut record beside the segment"} — ` + + `muteFrom measured from the unsnapped start ${from}; the real start may differ by up to ` + + `${render.snapWindow ?? 1.6}s. Rebuild the clip to record it.`, + }; +} + +// --------------------------------------------------------------------------- +// The teaser: a full-frame graphic card -- a season teaser's "coming soon" +// screen -- whose words are the manifest's. Its segment is a HyperFrames +// render (chrome-teaser.mjs draws it, compose-chrome renders it, the build +// encodes it). Pure here: what the words are, and why they cannot be drawn. +// +// { "type": "teaser", "id": "fin", "seconds": 7, +// "lines": ["Pirate Software", +// { "text": "The Largest Ferret Rescue in the United States", +// "break": "in the United States" }, +// "February 2027"], +// "tail": "?", "hits": true } +// +// A line is a string, or `{ text, break }`: `break` is the END of `text` set +// as a smaller second tier under the rest, a beat later. The tail is appended +// to the last line and fades in on its own. `hits` (default true) puts a +// trailer hit under each pop and a swell under the tail; false is silence. +// Roles follow position: with three +// or more lines the first is the overline and the last the kicker (a date), +// everything between is a title; two lines are an overline and a title; one +// is a title. +// --------------------------------------------------------------------------- + +/** + * The teaser's limits: lines, seconds, characters per line, the tail's length, + * and `fit`: the characters one ROW may hold in its role -- the first tier + * (`head`) in the line's role, a `break` in `sub`, the tail and its gap + * counted on the row that carries it. A row wider than 80 % of the frame + * shrinks to its role's floor (TEASER_TYPE in chrome-teaser.mjs) and no + * further, so a longer row spilled past the frame's edges. Measured at the + * floor in the face, on ordinary headline words in capitals: the title holds + * 36–37 there, the kicker 58–60, the overline 65–67, the second tier 69–71; + * each limit is a little under. A row of only wide capitals (M, W) can still + * spill at these counts. + */ +export const TEASER_LIMITS = Object.freeze({ + lines: [1, 5], seconds: [3, 20], chars: 80, tail: 8, + fit: Object.freeze({ overline: 64, title: 34, kicker: 56, sub: 66 }), +}); + +const LINE_KEYS = ["text", "break"]; + +/** + * A teaser's lines, normalised: `{ text, head, sub, role }` each, `head` the + * part drawn on the first tier and `sub` the second tier (`break`) or null. + * Trims; assumes `validateTeaser` passed. + * + * @returns {Array<{ text: string, head: string, sub: string|null, role: "overline"|"title"|"kicker" }>} + */ +export function teaserLines(entry) { + const lines = Array.isArray(entry?.lines) ? entry.lines : []; + const n = lines.length; + return lines.map((l, i) => { + const text = String(isObj(l) ? l.text ?? "" : l ?? "").trim(); + const brk = isObj(l) && typeof l.break === "string" ? l.break.trim() : ""; + const sub = brk && text.endsWith(brk) && text.length > brk.length ? brk : null; + const head = sub ? text.slice(0, text.length - sub.length).trim() : text; + const role = n >= 3 ? (i === 0 ? "overline" : i === n - 1 ? "kicker" : "title") + : n === 2 ? (i === 0 ? "overline" : "title") + : "title"; + return { text, head, sub, role }; + }); +} + +/** The teaser's tail, trimmed, or "" for none. */ +export const teaserTail = (entry) => (typeof entry?.tail === "string" ? entry.tail.trim() : ""); + +/** + * What a teaser is called where a cut names its entries -- its chapter, and + * its row in umtool: the lines joined with " — ", the tail after the last. + */ +export function teaserTitle(entry) { + const texts = teaserLines(entry).map((l) => l.text).filter(Boolean); + const tail = teaserTail(entry); + if (tail && texts.length) texts[texts.length - 1] = `${texts[texts.length - 1]} ${tail}`; + return texts.join(" — "); +} + +/** + * The teaser's motion, in seconds -- ONE copy, read by the composition (the + * cues, chrome-teaser.mjs) and by the build (the hits under them). The first + * line lands `first` into the card (after the incoming dissolve); each next + * one `gap` after the one before, or after its second tier, which pops `sub` + * after its first. A line slams in from `slam`× its size and blurred and hits + * -- undershooting to `under` -- `hit` after it starts, then settles to rest + * over `settle`. The tail starts `tailAfter` after the last line's pop and + * fades in over `tailDur`. The last `endRoom` seconds hold still for the + * cut's end fade; a card too short for all of it plays every beat + * proportionally faster (`teaserTimes`). + */ +export const TEASER_MOTION = Object.freeze({ + first: 0.55, gap: 0.7, sub: 0.3, slam: 1.42, under: 0.968, hit: 0.2, settle: 0.5, + blur: 18, tailAfter: 0.8, tailDur: 1.7, endRoom: 1.2, push: 1.065, grainHz: 12, +}); + +/** + * When everything in a teaser happens, in the card's clock: per line its + * start (`at`), its impact (`impact` = at + hit, where the slam lands, the + * flash fires and the hit sounds) and its second tier's pop (`subAt`, null + * without one); the tail's start and length; and `scale` (< 1 when the beats + * were compressed to fit the card). `T` scales any motion length the same way. + * + * @returns {{ lines: Array<{ at: number, impact: number, subAt: number|null }>, + * tailAt: number|null, tailDur: number, end: number, scale: number, T: (v: number) => number }} + */ +export function teaserTimes(lines, tail, seconds, m = TEASER_MOTION) { + let t = m.first; + const raw = []; + lines.forEach((l, i) => { + if (i > 0) t += m.gap; + const at = t; + const subAt = l.sub ? at + m.sub : null; + if (subAt != null) t = subAt; + raw.push({ at, subAt }); + }); + const tailRaw = tail ? t + m.tailAfter : null; + const endRaw = tailRaw != null ? tailRaw + m.tailDur : t + m.hit + m.settle; + const room = Math.max(0.5, seconds - m.endRoom); + const scale = endRaw > room ? room / endRaw : 1; + const r = (v) => Math.round(v * 10000) / 10000; + const T = (v) => r(v * scale); + return { + lines: raw.map((b) => ({ at: T(b.at), impact: r(T(b.at) + T(m.hit)), subAt: b.subAt == null ? null : T(b.subAt) })), + tailAt: tailRaw == null ? null : T(tailRaw), + tailDur: T(m.tailDur), + end: T(endRaw), + scale: r(scale), + T, + }; +} + +/** + * The teaser's sound design, as data: one trailer hit under each pop, at the + * moment the composition says it lands, and a low swell under the tail's + * slow entrance. Empty when `hits: false`. + * + * Gains are relative (the main title is 1): a title's hit is the biggest, an + * overline's and a kicker's a little smaller, a second tier's lighter and + * shorter. The build turns this into one ffmpeg graph (`teaserAudioGraph`). + * + * @returns {Array<{ kind: "hit"|"swell", at: number, role: string, gain: number, + * decay: number, f0: number, f1: number, dur?: number }>} + */ +export function teaserHits(entry) { + if (entry?.hits === false) return []; + const lines = teaserLines(entry); + const tail = teaserTail(entry); + const times = teaserTimes(lines, tail, Number(entry.seconds)); + const out = []; + const HIT = { + title: { gain: 1, decay: 0.42, f0: 92, f1: 40 }, + overline: { gain: 0.72, decay: 0.34, f0: 96, f1: 44 }, + kicker: { gain: 0.8, decay: 0.36, f0: 94, f1: 42 }, + sub: { gain: 0.42, decay: 0.16, f0: 120, f1: 64 }, + }; + lines.forEach((l, i) => { + out.push({ kind: "hit", at: times.lines[i].impact, role: l.role, ...HIT[l.role] }); + if (l.sub && times.lines[i].subAt != null) out.push({ kind: "hit", at: times.lines[i].subAt, role: "sub", ...HIT.sub }); + }); + if (tail && times.tailAt != null) { + out.push({ kind: "swell", at: times.tailAt, role: "tail", gain: 0.34, decay: 0.7, f0: 46, f1: 62, dur: times.tailDur }); + } + return out; +} + +/** + * Why one teaser entry cannot be built, as sentences (empty: it can). + * + * @returns {string[]} + */ +export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"}`) { + const errors = []; + const [lo, hi] = TEASER_LIMITS.lines; + const [slo, shi] = TEASER_LIMITS.seconds; + if (typeof entry?.id !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(entry.id)) { + errors.push(`${where}.id must be letters, digits, dashes or underscores (it names the segment's file)`); + } + if (!numIn(entry?.seconds, slo, shi)) errors.push(`${where}.seconds must be from ${slo} to ${shi}`); + const oneLine = (s, w) => { + if (typeof s !== "string" || !s.trim()) { errors.push(`${w} must be words, not empty`); return false; } + if (/[\r\n]/.test(s)) { errors.push(`${w} must be one line`); return false; } + if (s.trim().length > TEASER_LIMITS.chars) { + errors.push(`${w} is ${s.trim().length} characters (at most ${TEASER_LIMITS.chars})`); + return false; + } + return true; + }; + const lines = entry?.lines; + if (!Array.isArray(lines) || lines.length < lo || lines.length > hi) { + errors.push(`${where}.lines must be a list of ${lo} to ${hi} lines`); + } else { + lines.forEach((l, i) => { + const w = `${where}.lines[${i}]`; + if (typeof l === "string") { oneLine(l, w); return; } + if (!isObj(l)) { errors.push(`${w} must be a string or { text, break }`); return; } + for (const k of Object.keys(l)) if (!LINE_KEYS.includes(k)) errors.push(`${w}.${k} is not a teaser line field`); + if (!oneLine(l.text, `${w}.text`)) return; + if (l.break === undefined || l.break === null) return; + if (!oneLine(l.break, `${w}.break`)) return; + const text = l.text.trim(); + const brk = l.break.trim(); + if (!text.endsWith(brk)) errors.push(`${w}.break must be the end of its text ("${brk}" is not how "${text}" ends)`); + else if (!text.slice(0, text.length - brk.length).trim()) errors.push(`${w}.break leaves nothing for the first tier`); + }); + } + if (entry?.hits !== undefined && typeof entry.hits !== "boolean") errors.push(`${where}.hits must be true or false`); + if (entry?.tail !== undefined && entry?.tail !== null) { + if (typeof entry.tail !== "string" || !entry.tail.trim()) errors.push(`${where}.tail must be a short string, or absent`); + else if (/[\r\n]/.test(entry.tail)) errors.push(`${where}.tail must be one line`); + else if (entry.tail.trim().length > TEASER_LIMITS.tail) { + errors.push(`${where}.tail is ${entry.tail.trim().length} characters (at most ${TEASER_LIMITS.tail})`); + } + } + // What fits the frame, row by row, once every line and the tail are sound. + if (!errors.length) { + const rows = teaserLines(entry); + const tail = teaserTail(entry); + const fit = TEASER_LIMITS.fit; + rows.forEach((l, i) => { + const w = `${where}.lines[${i}]`; + const tailHere = tail && i === rows.length - 1 ? tail.length + 1 : 0; + const head = l.head.length + (l.sub ? 0 : tailHere); + if (head > fit[l.role]) { + errors.push( + `${w} ${l.sub ? "before its break " : ""}is ${head} characters${!l.sub && tailHere ? " with the tail" : ""}, ` + + `and at most ${fit[l.role]} fit the frame as the ${l.role}` + + (l.sub ? "" : " -- shorten it, or set its end as a break"), + ); + } + if (l.sub && l.sub.length + tailHere > fit.sub) { + errors.push( + `${w}.break is ${l.sub.length + tailHere} characters${tailHere ? " with the tail" : ""}, ` + + `and at most ${fit.sub} fit the frame as the second tier`, + ); + } + }); + } + return errors; +} + +/** Every teaser in the timeline, checked: the build refuses with these before it fetches. */ +export function validateTeasers(manifest) { + const errors = []; + (manifest?.timeline ?? []).forEach((e, i) => { + if (e?.type === "teaser") errors.push(...validateTeaser(e, `timeline[${i}] (${e.id ?? "?"})`)); + }); + return errors; +} + // --------------------------------------------------------------------------- // The render cache and the renderer command. // --------------------------------------------------------------------------- diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs @@ -241,8 +241,10 @@ test("hyperframesCommand: pinned by default, overridable", () => { }); // ---- posts ----------------------------------------------------------------- -import { attachPosts, clipDay, postSchedule, postsGeometry, postWindows, validatePosts } from "./deck.mjs"; +import { attachPosts, clipDay, postSchedule, postsGeometry, postWindows, shiftedFootage, validatePosts } from "./deck.mjs"; +// The first posts release's settings, which the timing tests below were written for. +const POSTS2 = { ...RENDER, chrome: { ...CHROME, deck: { posts: { seconds: 2, hold: 0, shift: false } } } }; const POST = (id, date, extra = {}) => ({ id, platform: "bluesky", date, text: `post ${id}`, url: `https://bsky.app/profile/a/post/${id}`, ...extra, }); @@ -290,7 +292,7 @@ test("postSchedule: stacked from the end, one every `seconds`, leaving in the tr { id: "c3", start: 24.5, duration: 4 }, ]; const posts = [POST("a", "2024-10-19"), POST("b", "2024-11-27"), POST("z", "2026-01-22")]; - const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: RENDER }); + const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: POSTS2 }); // c2 carries a and b; its leave is the dissolve into k1 at 21. assert.deepEqual(s.filter((p) => p.segment === "c2").map((p) => [p.id, p.slot, p.of, p.appear, p.out]), [ ["a", 0, 2, 17, [21, 21.5]], @@ -305,11 +307,11 @@ test("postSchedule: stacked from the end, one every `seconds`, leaving in the tr const h = postSchedule({ posts, entries: CLIPS, metas: METAS, segments: [ { id: "c1", start: 0, duration: 10 }, { id: "c2", start: 10, duration: 12 }, { id: "k1", start: 22, duration: 4 }, { id: "c3", start: 26, duration: 4 }, - ], D: 0, total: 30, render: RENDER }); + ], D: 0, total: 30, render: POSTS2 }); assert.deepEqual(h.find((p) => p.id === "b").out, [21.7, 22]); // Too short for k × seconds: what is left after the incoming dissolve is shared. const many = ["m1", "m2", "m3", "m4"].map((id) => POST(id, "2026-01-01")); - const sq = postSchedule({ posts: many, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: RENDER }); + const sq = postSchedule({ posts: many, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: POSTS2 }); assert.deepEqual(sq.map((p) => Number(p.appear.toFixed(3))), [25, 25.8, 26.6, 27.4]); // Windows: one per carrying clip, first appearance to the end of the leave. assert.deepEqual(postWindows({ posts: s }), [ @@ -329,10 +331,12 @@ test("deckSchedule carries posts only when there are some; posts.show false drop assert.equal("posts" in estimateSchedule({ render: off, provenance: PROV, timeline, posts: [POST("p", "2026-01-01")] }), false); }); -test("postsGeometry: a column inside the footage box", () => { - assert.deepEqual(postsGeometry(RENDER), { x: 173 + 1574 - 24 - 600, y: 26, width: 600, height: 838 }); - const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left", width: 500, inset: 10 } } } }; +test("postsGeometry: a column inside the footage box (no shift), at the frame's edge (shift)", () => { + assert.deepEqual(postsGeometry(POSTS2), { x: 173 + 1574 - 24 - 600, y: 26, width: 600, height: 838 }); + const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left", width: 500, inset: 10, shift: false } } } }; assert.deepEqual(postsGeometry(left), { x: 183, y: 12, width: 500, height: 866 }); + // Shift on (the default): the column moves to the frame's right edge. + assert.deepEqual(postsGeometry(RENDER), { x: 1920 - 24 - 600, y: 26, width: 600, height: 838 }); }); test("validatePosts and the posts settings refuse in sentences", () => { @@ -366,3 +370,46 @@ test("posts fit: a deck without posts is never refused for the column; drawn pos // ...unless it switches them off. assert.deepEqual(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { show: false } } }, narrow), []); }); + +test("posts hold: a carrying clip is held on its last frame, and every start after it moves", () => { + // CLIPS: c1 (2024-09-05), c2 (2024-09-29 by record), k1 card, c3 (2025-12-08). + const posts = [POST("a", "2024-10-19"), POST("z", "2026-01-22")]; + const durs = [10, 12, 4, 4]; + const base = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, metas: METAS }); + const held = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, metas: METAS, posts }); + assert.equal("hold" in base.segments[1], false); + assert.deepEqual(held.segments.map((x) => x.hold ?? 0), [0, 2.5, 0, 2.5]); + assert.deepEqual(held.segments.map((x) => x.duration), [10, 14.5, 4, 6.5]); + assert.deepEqual(held.segments.map((x) => x.start), [0, 9.5, 23.5, 27]); + assert.equal(held.total, base.total + 5); + // Four seconds each by default: c2's one post is up for its last 4 s, hold included. + const a = held.posts.find((p) => p.id === "a"); + assert.deepEqual([a.segment, a.appear, a.out], ["c2", 19.5, [23.5, 24]]); + // hold 0: nothing held, the schedule's lengths are the segments'. + const none = { ...RENDER, chrome: { ...CHROME, deck: { posts: { hold: 0 } } } }; + const h0 = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: none, provenance: PROV, metas: METAS, posts }); + assert.deepEqual(h0.segments.map((x) => x.duration), durs); +}); + +test("posts shift: the footage moves aside from the column while a clip's posts are up", () => { + const to = shiftedFootage(RENDER); + // 86 % of the 1574×886 box, 24 px from the left, centred above the deck. + assert.deepEqual(to, { x: 24, y: 64, width: 1354, height: 762 }); + // Room: the column at the frame edge starts at 1296; the footage ends at 1378. + assert.equal(postsGeometry(RENDER).x - (to.x + to.width), -82); + const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left" } } } }; + assert.equal(shiftedFootage(left).x, 1920 - 24 - 1354); + assert.equal(shiftedFootage(POSTS2), null); + const posts = [POST("a", "2024-10-19"), POST("b", "2024-11-27")]; + const s = deckSchedule({ entries: CLIPS, durs: [10, 12, 4, 4], D: 0.5, render: RENDER, provenance: PROV, metas: METAS, posts }); + assert.deepEqual(s.moves, [{ + segment: "c2", at: 15.5, segmentAt: 6, seconds: 0.6, + from: { x: 173, y: 2, width: 1574, height: 886 }, to, + }]); + assert.equal("moves" in deckSchedule({ entries: CLIPS, durs: [10, 12, 4, 4], D: 0.5, render: POSTS2, provenance: PROV, metas: METAS, posts }), false); + // Validation of the new keys. + assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { shift: false, hold: 0 } } }, RENDER), []); + assert.match(validateChrome({ ...CHROME, deck: { posts: { shift: { scale: 0.2 } } } }, RENDER)[0], /shift.scale/); + assert.match(validateChrome({ ...CHROME, deck: { posts: { shift: { speed: 1 } } } }, RENDER)[0], /speed is not a deck setting/); + assert.match(validateChrome({ ...CHROME, deck: { posts: { hold: 20 } } }, RENDER)[0], /hold/); +}); diff --git a/umtool/report-to-video/mute.mjs b/umtool/report-to-video/mute.mjs @@ -0,0 +1,7 @@ +// The mute mark's fade, alone: no imports, so a client bundle (umtool's clip +// bench previews the mute as the build makes it) can take it without pulling +// in deck.mjs and what deck.mjs imports (node:crypto, attribution). +// deck.mjs re-exports it; the build and the bench read the same number. + +/** The fade into a `muteFrom`'s silence, in seconds: long enough not to click, short enough to keep the next word out. */ +export const MUTE_FADE = 0.04; diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json @@ -24,6 +24,7 @@ "./cues": "./cues.mjs", "./deck": "./deck.mjs", "./ledger-totals": "./ledger-totals.mjs", + "./mute": "./mute.mjs", "./package.json": "./package.json", "./render-cards": "./render-cards.mjs", "./resolve-windows": "./resolve-windows.mjs", diff --git a/umtool/report-to-video/teaser-audio.test.mjs b/umtool/report-to-video/teaser-audio.test.mjs @@ -0,0 +1,75 @@ +// The teaser's sound, through real ffmpeg: a hit starts on the frame its pop +// lands on, nothing clips, and `hits: false` is digital silence. +// +// The onset of hit i is measured as the first sample where the graph WITH it +// differs from the same graph WITHOUT it: the hits overlap (the second tier +// lands 0.1 s into the title's decay), so "the first loud sample after the +// cue" would find the previous hit's tail. Every layer up to the limiter is +// linear and the noise is a hash of the sample number, so the difference is +// hit i alone until the limiter engages -- after its onset. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { teaserAudioGraph } from "./build-video.mjs"; +import { teaserHits } from "./deck.mjs"; + +const have = spawnSync("ffmpeg", ["-version"]).status === 0; +const RENDER = { fps: 30, audioRate: 48000, audioChannels: 2 }; +const FERRET = Object.freeze({ + type: "teaser", id: "fin", seconds: 7, + lines: ["Pirate Software", { text: "The Largest Ferret Rescue in the United States", break: "in the United States" }, "February 2027"], + tail: "?", +}); + +/** The graph rendered to raw float samples, channel 0. */ +function samples(graph) { + const r = spawnSync("ffmpeg", [ + "-nostdin", "-v", "error", "-filter_complex", graph, "-map", "[ta]", "-f", "f32le", "-ac", "2", "-", + ], { maxBuffer: 1 << 26 }); + assert.equal(r.status, 0, String(r.stderr)); + const f = new Float32Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 4); + const ch0 = new Float32Array(f.length / 2); + for (let i = 0; i < ch0.length; i += 1) ch0[i] = f[2 * i]; + return { ch0, all: f }; +} + +test("each hit's onset lands within a frame of its pop", { skip: !have && "no ffmpeg" }, () => { + const hits = teaserHits(FERRET); + const full = samples(teaserAudioGraph(hits, { seconds: 7, render: RENDER })).ch0; + assert.equal(full.length, 7 * 48000); + const frame = 1 / RENDER.fps; + hits.forEach((h, i) => { + if (h.kind !== "hit") return; + const without = samples(teaserAudioGraph(hits.filter((_, j) => j !== i), { seconds: 7, render: RENDER })).ch0; + let first = -1; + for (let n = 0; n < full.length; n += 1) { + if (Math.abs(full[n] - without[n]) > 1e-4) { first = n; break; } + } + assert.ok(first >= 0, `${h.role} made no sound`); + const onset = first / 48000; + assert.ok(Math.abs(onset - h.at) <= frame, `${h.role}: onset ${onset.toFixed(4)}s, pop ${h.at}s`); + }); +}); + +test("nothing clips: the sum stays under −6 dBFS (about) and well under full scale", { skip: !have && "no ffmpeg" }, () => { + const { all } = samples(teaserAudioGraph(teaserHits(FERRET), { seconds: 7, render: RENDER })); + let peak = 0; + for (const v of all) peak = Math.max(peak, Math.abs(v)); + assert.ok(peak > 0.2, `peak ${peak}`); // it is not silent + assert.ok(peak <= 0.5 * 1.03, `peak ${peak} (${(20 * Math.log10(peak)).toFixed(2)} dBFS)`); + // A short card packs the hits together; they still sum cleanly. + const short = { ...FERRET, seconds: 3, lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] }; + const s = samples(teaserAudioGraph(teaserHits(short), { seconds: 3, render: RENDER })).all; + let p2 = 0; + for (const v of s) p2 = Math.max(p2, Math.abs(v)); + assert.ok(p2 <= 0.5 * 1.03, `short card peak ${p2}`); +}); + +test("hits: false is digital silence, exactly as long", { skip: !have && "no ffmpeg" }, () => { + const { all } = samples(teaserAudioGraph(teaserHits({ ...FERRET, hits: false }), { seconds: 7, render: RENDER })); + assert.equal(all.length, 7 * 48000 * 2); + assert.ok(all.every((v) => v === 0)); +}); diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs @@ -19,10 +19,11 @@ import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { postsRegions, selectVariant, variantPaths } from "./build-video.mjs"; -import { deckOn, frameCount } from "./deck.mjs"; +import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck } from "./deck.mjs"; const execFileP = promisify(execFile); const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe"; +const FFMPEG = process.env.FFMPEG_BIN ?? "ffmpeg"; export async function verifyBuild(manifestPath, { outDir, variant = "sourced" } = {}) { // The SAME filter the build ran. Verifying the whole manifest against one @@ -89,15 +90,20 @@ export async function verifyBuild(manifestPath, { outDir, variant = "sourced" } if (deckOn(manifest.render)) { deck = await verifyDeck(path.join(root, variant), manifest.render, file, problems); } + const teasers = await verifyTeasers(path.join(root, variant), manifest, problems); - return { ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, deck, problems }; + return { + ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, deck, + ...(teasers.length ? { teasers } : {}), problems, + }; } /** * The deck's half of the check: schedule.json is there and is a measured deck * schedule, `chrome/deck-frames` holds frameCount(total, fps) frames, and the - * file is as long as the schedule. When the schedule carries posts, each - * window's `chrome/posts-<segment>-frames` holds that window's frame count. + * file is as long as the schedule -- the SCHEDULE's total, holds included. + * When the schedule carries posts, each window's `chrome/posts-<segment>-frames` + * holds that window's frame count, and each held clip's freeze is in the file. */ export async function verifyDeck(variantDir, render, file, problems) { const schedPath = path.join(variantDir, "schedule.json"); @@ -139,12 +145,121 @@ export async function verifyDeck(variantDir, render, file, problems) { problems.push(`${r.frames} holds ${got} frames; the posts window on ${r.segment} is ${r.frameCount}`); } } + const holds = await verifyHolds(file, schedule, render, problems); return { total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length, ...(posts.length ? { posts } : {}), + ...(holds.length ? { holds } : {}), }; } +/** + * Each `teaser` entry's segment is the render it claims to be: its frames + * (`chrome/teaser-<id>-frames`) are `frameCount(seconds, fps)` long, and the + * record beside its segment (`<id>.teaser.json`) names those frames' key -- a + * segment encoded from an older render (changed words) fails here. + */ +export async function verifyTeasers(variantDir, manifest, problems) { + const fps = Number(manifest.render?.fps ?? 30); + const out = []; + for (const e of manifest.timeline ?? []) { + if (e.type !== "teaser") continue; + const dir = path.join(variantDir, "chrome", `teaser-${e.id}-frames`); + const frames = await readdir(dir).then((fs) => fs.filter((f) => /^frame_\d+\.png$/.test(f)).length, () => 0); + const want = frameCount(Number(e.seconds), fps); + const key = await readFile(path.join(dir, ".key"), "utf8").then((s) => s.trim(), () => null); + const seg = path.join(variantDir, "segments", `${e.id}.mp4`); + const rec = await readFile(seg.replace(/\.mp4$/, ".teaser.json"), "utf8").then(JSON.parse, () => null); + if (frames !== want) problems.push(`${dir} holds ${frames} frames; the teaser ${e.id} is ${want} (${e.seconds}s at ${fps} fps)`); + if (!rec) problems.push(`the teaser ${e.id} has no record beside ${seg} — rebuild it`); + else if (key && rec.frames !== key) problems.push(`the teaser ${e.id}'s segment was encoded from another render of it — rebuild it`); + out.push({ id: e.id, frames, expectedFrames: want, current: !!rec && rec.frames === key }); + } + return out; +} + +/** The mean absolute difference allowed between two frames of one freeze (8-bit luma; re-encoding noise). */ +export const FREEZE_TOLERANCE = 1.5; + +/** + * Where a held segment's freeze can be sampled: the still span is from the + * later of the hold's start and the end of the footage move (the move runs + * after the hold, so a late one glides over the frozen frame) to the start of + * the outgoing dissolve (`end − D`), or the end fade on the last segment + * (`end − endFade`), or the segment's end. The two samples sit a frame and a + * half inside it. A span under three frames has nothing still to compare -- + * at hold 0.5 under a 0.5 s crossfade the dissolve takes all of it -- and is + * skipped, with the reason. + * + * @returns {{ at: [number, number] } | { skip: string }} + */ +export function freezeSamples(segment, { fps, D = 0, last = false, endFade = 0, moveEnd = -Infinity }) { + const lo = Math.max(segment.end - segment.hold, moveEnd); + const hi = last ? segment.end - (endFade > 0 ? endFade : 0) : segment.end - D; + if (!(hi - lo >= 3 / fps)) { + return { + skip: `${Math.max(0, hi - lo).toFixed(3)}s of still picture between ${lo.toFixed(3)}s and ${hi.toFixed(3)}s ` + + `(the rest of the hold is under the ${last ? "end fade" : "dissolve"}${moveEnd > segment.end - segment.hold ? " or the move" : ""})`, + }; + } + return { at: [lo + 1.5 / fps, hi - 1.5 / fps] }; +} + +/** + * Each held segment's freeze is in the file: two frames inside its still span + * (`freezeSamples`) are the same frame. Compared over the picture outside the + * deck's panel and the posts column -- both still move during a hold (the + * deck's progress fuse burns on) -- on luma, within FREEZE_TOLERANCE of + * re-encoding noise. A cut whose holds were dropped plays on there and differs + * by far more. + */ +export async function verifyHolds(file, schedule, render, problems) { + const segs = schedule.segments ?? []; + const held = segs.filter((s) => s.hold > 0); + if (!held.length) return []; + const fps = Number(schedule.fps ?? render.fps); + const D = Number(schedule.transition ?? 0); + const endFade = Number(render.endFade ?? 0); + const moveEnd = new Map((schedule.moves ?? []).map((m) => [m.segment, m.at + m.seconds])); + const g = deckGeometry(render); + const col = postsGeometry(render); + const left = resolveDeck(render).posts.position === "top-left"; + const crop = left + ? { x: col.x + col.width, y: 0, w: g.W - col.x - col.width, h: g.deck.y } + : { x: 0, y: 0, w: col.x, h: g.deck.y }; + const luma = async (t) => { + const { stdout } = await execFileP(FFMPEG, [ + "-nostdin", "-v", "error", "-ss", t.toFixed(3), "-i", file, "-frames:v", "1", + "-vf", `crop=${crop.w}:${crop.h}:${crop.x}:${crop.y},format=gray`, "-f", "rawvideo", "-", + ], { encoding: "buffer", maxBuffer: 1 << 26 }); + return stdout; + }; + const out = []; + for (const s of held) { + const span = freezeSamples(s, { + fps, D, last: s === segs.at(-1), endFade, moveEnd: moveEnd.get(s.id) ?? -Infinity, + }); + if (span.skip) { + out.push({ segment: s.id, hold: s.hold, skipped: span.skip }); + continue; + } + const [a, b] = span.at; + const [x, y] = await Promise.all([luma(a), luma(b)]); + let diff = 0; + if (x.length !== y.length || !x.length) diff = Infinity; + else { + for (let i = 0; i < x.length; i += 1) diff += Math.abs(x[i] - y[i]); + diff /= x.length; + } + out.push({ segment: s.id, hold: s.hold, at: [Number(a.toFixed(3)), Number(b.toFixed(3))], diff: Number(diff.toFixed(3)) }); + if (!(diff <= FREEZE_TOLERANCE)) { + problems.push(`${s.id} is held ${s.hold}s but its picture moves during the hold ` + + `(frames at ${a.toFixed(3)}s and ${b.toFixed(3)}s differ by ${diff.toFixed(2)} on average)`); + } + } + return out; +} + async function main() { const argv = process.argv.slice(2); const manifestPath = argv.find((a) => !a.startsWith("--")); @@ -170,6 +285,14 @@ async function main() { for (const w of res.deck.posts ?? []) { console.log(` posts on ${w.segment}: ${w.frames}/${w.expectedFrames} frame(s) at ${w.at.toFixed(3)}s`); } + for (const h of res.deck.holds ?? []) { + console.log(h.skipped + ? ` hold on ${h.segment}: ${h.hold}s, not checked — ${h.skipped}` + : ` hold on ${h.segment}: ${h.hold}s, frozen (${h.at.join("s ≈ ")}s, mean diff ${h.diff})`); + } + } + for (const t of res.teasers ?? []) { + console.log(` teaser ${t.id}: ${t.frames}/${t.expectedFrames} frame(s)${t.current ? ", segment encoded from them" : ""}`); } for (const p of res.problems) console.log(` ** ${p}`); if (res.ok) console.log(" ok");