commit 5c877a7f8351f6d26fdab95e47543fd8ffbc84f1
parent 10a77d14fc1675b968174cdb1bc7625ff8b84a94
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 2 Oct 2026 00:27:40 -0400
Merge deck/finale-dip — the on-screen deck's posts feed layout (a persistent posts column beside reframed footage, a framing record in cut.json), the teaser's beat and its dip to black with a riser into the first hit, the review fix pass and the frame-grid fix; a manifest without the vocabulary builds byte-identically; reviewed SHIP
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
28 files changed, 3663 insertions(+), 225 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -6,9 +6,11 @@
- **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 cut's posts can be a feed: a column beside the footage for the whole cut, each post ticking in as its clip starts, with no pause.** `render.chrome.deck.posts.layout: "feed"` (the default, `"popup"`, is the cards described above) puts every post in one column on the right, standing on the deck so the two read as one L-shaped panel around the picture. The footage of every clip and still is framed, for the whole cut, into the box left beside the column (1272×716 at 1920×1080 with the default 600 px column, against 1574×886 under the deck alone); nothing moves and nothing is held, so the cut is as long as its clips. Before the first post the column shows its header — the platform and the handle, or "Posts" when there are several authors — and an empty state. Each post ticks in at the start of the clip it belongs with, a crossfade's length in (just after the crossfade into it, and as far into the first clip, which has none), a clip's next ones `posts.seconds` apart (closer on a short clip): it lands at the top with an accent flare and keeps a lit rail while it is the newest, and the posts already up slide down to make room; when the column is full the oldest fade out at the bottom. Each card shows the post's date, its words up to `maxLines`, and its QR. Over a card or the teaser the column slides out of the frame with the deck and comes back after. The column is drawn by one composition for the whole cut, cached like the deck's. Switching the layout reframes every segment, so it takes a normal build (with `--skip-fetch` it re-cuts from the cached windows); `--chrome-only` over segments framed for the other layout is refused with a sentence naming them, because each segment's `<id>.cut.json` now records the box it was framed into. In umtool, the posts settings have a **layout** switch, and the live preview shows the column for the whole scrub with the footage in its box. A cut in the popup layout, or without posts, builds exactly as before.
- **A report clip can go silent partway through, a report cut can fade out at its end, and the deck's QR names its site in larger type.** A clip's `muteFrom` (in the recording's own seconds, inside the clip) silences it from that second to its end while the picture plays on, after a 40 ms fade that ends there, so nothing clicks and no next word leaks in; a hold on that clip stays silent. `render.endFade` (seconds; 0, the default, is off) fades the cut's last segment, whatever it is — a clip with its hold, a closing card or a teaser — to the background colour and to silence over its final seconds, all of it when the segment is shorter, and the deck stays drawn over it. Both are applied where the cut is joined, so `--chrome-only` changes them without rebuilding a clip, and a value out of range is refused with a sentence before a build fetches anything. Each clip build now writes `<id>.cut.json` beside its segment, saying where in the recording the segment really starts after its cut was snapped to a silence; `muteFrom` is measured from it, and a segment built before this measures from the clip's unsnapped start and says so. The site's name beside the deck's QR is now exactly as long as the code is tall, for any site. Neither key changes a cut that does not set it.
- **umtool's clip bench stops exactly where a range ends, and sets a clip's mute mark.** The bench's **play selection**, the edge auditions, the auto-audition and a click on a transcript line now play the window's sound through the browser's Web Audio, from a decode made on the server by ffmpeg — the same timeline the build cuts on — and each stops on the audio clock where its range ends, at every speed. They used to play on the video element and were stopped when it next reported its time, which overran the end by up to a quarter of a second, by a different amount each time. The picture follows, muted. If the sound cannot be decoded, the video element plays as before and the bench says the playback is approximate and why. The mute mark sets the clip's `muteFrom`: `m` puts it at the playhead, **pick on waveform** puts it where you click, `;` and `'` nudge it (with shift, by half a second), and `M` or **clear mute** removes it. It is saved with the window like the edges, every playback goes silent at it with the build's own 40 ms fade, and a window save that would leave it outside the clip is refused unless the same save moves or clears it — or, when it is within 0.02 s of the new edge, moves it onto that edge. The decoded sound is served by a new `GET /api/report/audio`, at most 120 seconds of a cached window at a time, as WAV.
-- **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in after the last line on its own. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. A line too long to fit the frame at its smallest size is refused with a sentence saying how many characters fit (a title holds 34). It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest.
+- **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in after the last line on its own. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. `"beat"` (0.4–2.5 seconds, default 0.7) sets the time from one pop — and its hit — to the next, the second tier and the tail's wait slowing with it; `seconds` may be left out for exactly the length the beats need, and a `seconds` too short for them is refused with that length rather than played faster. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. A line too long to fit the frame at its smallest size is refused with a sentence saying how many characters fit (a title holds 34). It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest.
+- **A report cut can go to black before its teaser, and the teaser rises out of the black.** A teaser entry's `"dip": { "fade": …, "black": … }` fades the whole frame before it — the footage, the on-screen deck, the posts feed and anything else drawn over the cut — to black over the previous segment's last `fade` seconds (0.3–4), its sound to silence with it, then holds `black` seconds (0–3) of black, taken to the nearest whole frame at the cut's frame rate. The teaser then opens out of it: the letterbox is already closed, the ground and its light stay dark until the first line slams in and come up with its hit, and a synthesised riser swells under the black into that first hit. The deck and the feed leave under the black instead of sliding away over the crossfade. The black is the start of the teaser's own segment, so the teaser is that much longer and nothing else moves; with a dip, the teaser's `seconds` counts from where the light comes up. The fade is made where the cut is joined, so `--chrome-only` changes it without rebuilding a clip. A dip anywhere but on a teaser, on the first entry, or out of range is refused with a sentence before a build fetches anything. A cut without a dip builds exactly as before.
- **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
@@ -400,3 +400,275 @@ Gates at 07d1fa08:
`render.chrome` gives md5 `6a92235fa12ca181bb81993129c9ee9d`.
- umtool e2e `onscreen-posts onscreen clip-bench build projects report-fetch-via-editor`: 97
passed (4.9 min).
+
+## Feed F1, as built
+
+Branch `deck/feed-f1` from 07d1fa08. The operator's ask: the posts as a persistent, ticking feed
+in a column on the right for the whole cut — with the deck, an L-shaped interface — and no
+pausing. `render.chrome.deck.posts.layout: "popup" | "feed"`, default `"popup"` (everything
+above, unchanged).
+
+| Commit | What |
+|---|---|
+| 17c5d446 | `deck.mjs`: `posts.layout`, `POST_LAYOUTS`, `feedGeometry`, `feedOn`, the feed's `in` in `postSchedule`, `roundPosts`; no hold, move or windows under the feed; the schedule's `layout: "feed"`. `chrome-feed.mjs` (the page, `feedCues`, `feedLayout`, `feedWho`, `FEED_MOTION`). compose-chrome region `feed`. build-video: feed framing (`deckFraming(render, {feed})`, `segmentFraming`, `framedUnderDeck`), each framed segment's `cut.json` records `framing`, `framingProblems` refuses `--chrome-only`/`--chrome-preview` over another layout's segments, `feedRegion` laid like the deck. verify-build's feed check. `chrome-feed.test.mjs` |
+| 220a84f5 | umtool: the `layout` switch; the preview route composes `chrome/feed-preview` and returns `feed: {src, geometry, footage, boxes}` (`composeFeedPreview`, `segmentBoxes`, `feedPreviewDir/Src`, the files route's `feed-preview/`); `previewSchedule` follows the layout; `FeedOverlay`; the backdrop carried into the feed's box; the posts table's jump uses `in`; e2e (the posts fixture in feed, and a built `onscreen-feed-fixture`) |
+| 55ac8ab4 | The room opens before a post comes in over it (push 0.4 s front-loaded, entrance 0.22 s after `in`): the first ferret render's +0.3 s still showed the new card over the ones it was pushing |
+| 211f0d87 | `framingProblems` builds its sentence without a template nested in a template's `${}`: the build-trace check (`scripts/next-build-trace.test.mjs`) read the rest of build-video as one call reaching the CLI guard's `import.meta.url` (69 false findings; quirk recorded) |
+| (this) | README, quirks, one `[Unreleased]` bullet, this section |
+
+Rulings as built:
+
+- **Geometry** (`feedGeometry`, numbers at 1920×1080 with the defaults): the column is
+ `posts.width` 600 wide, flush with the right edge and the top, down to the deck — 600×890 at
+ (1320, 0). The footage is as large as fits in the 1320×890 left of it with `posts.inset` 24
+ clear on every side, the frame's aspect, centred: 1272×716 at (24, 87) — 66 % of the frame's
+ width (the deck alone: 1574×886, 82 %). Gap footage → column 24 px. `top-left` mirrors it. A
+ feed whose footage would be under half the frame's width is refused. Inside the column: 22 px
+ side padding, a 66 px header from y 26 (platform pill, the handle at 26 px or "Posts" over
+ several authors, an "n of N" count, "posts as the timeline reaches them"), a rule at y 102,
+ the stack from y 120 to 22 px off the bottom (748 px). Cards are the popup's design sized for
+ the column: 556 wide, 6 px rail, 24 px words on 33 px lines (≈ 30 characters a line),
+ `maxLines`, the date at 18 px (handle and platform only with several authors), the
+ `qrSize` 120 QR in a 148 px cell.
+- **Timing:** post j of k on a clip ticks in at start + D + step·j, step = min(`seconds`,
+ (A − start − D)/k), A the clip's outgoing transition. D counts on the first entry too, which
+ has no incoming dissolve (the popup's first clip uses 0). The schedule carries `in` per post and
+ `layout: "feed"`, only when there are posts to draw.
+- **Motion** (`FEED_MOTION`): at `in` the cards already in move down by the new card's height
+ plus 16 px over 0.4 s (power3.out); the new card enters from the column's outer edge 0.22 s
+ later over 0.6 s (expo.out); its rim and lit rail flare to 1 and settle to 0.55 while it is the
+ newest, and go out over 0.8 s when the next one arrives. A card pushed past the stack's bottom
+ fades as it moves (0.45 s) and is gone: a card is either whole in the column or out of it.
+ The empty state fades with the first post. Over a hidden-deck segment the column slides
+ 624 px out of the frame's right edge on the deck's own times (`deckChoreography`'s visibility).
+- **Framing is the build's, per segment, and recorded:** a feed cut frames clips, stills and
+ shown cards into the feed's box; `framing: {layout, box}` in each framed segment's
+ `<id>.cut.json`. `--chrome-only` over segments whose box is not the cut's is refused, naming
+ each; a record-less segment counts as the deck's. Switching layouts is a normal build with
+ `--skip-fetch`.
+- **The feed is one region for the whole cut**, `chrome/feed[-frames]` (`feed-preview`,
+ `feed-from<s>`), cached like the deck's, laid like the deck's (`-reinit_filter 0`,
+ `format=rgba`, `shortest=1`) after it; the build checks it is as many frames as the deck's.
+- **umtool:** the preview's backdrop is a built segment carried from the box its record names
+ into the feed's (or the neutral frame drawn in the feed's box); the feed's iframe is up for
+ the whole scrub.
+
+Gates:
+
+- Unit: `chrome-feed.test.mjs` 16 (geometry, validation, `feedOn`, the feed schedule's tick-in
+ times, popup/no-posts schedule identity, `feedCues` stagger/push/overflow/highlight/visibility
+ and seek-safety, the page's nodes, escaping, cue times and no remote URL, the window, framing
+ and `framingProblems`, the overlay chain, compose-chrome's feed region through a stub), and 6
+ in `onscreen.test.mjs`/`serve.test.mjs`.
+- Identity, against 07d1fa08's modules over the ferret manifest: the popup, the hard-cut popup,
+ no posts, and a feed with no posts give byte-equal schedules, xfade graphs, overlay chains and
+ framing filters. `--only c07 --skip-fetch` without `render.chrome`: md5
+ `6a92235fa12ca181bb81993129c9ee9d`.
+- Ferret, scratch copy with `posts.layout: "feed"`, a FULL `--skip-fetch` build (segments
+ reframed) from cached windows: 356.7 s (no holds; the popup cut was 366.7 s), video 356.700 s
+ and audio 356.700 s; verify-build ok — deck 10,701/10,701, feed 10,701/10,701, 7 posts, no
+ hold, teaser 210/210; 18 chapters. Posts in at 25.7 / 29.7 (c03), 124.6 / 128.367 / 132.133
+ (c06, 3.77 s apart: c06 is short), 219.333 (c12), 285.533 (c17). QR 7/7 posts (each read off
+ its settled frame) and 17/17 deck. The column overflows at c06's posts (the c03 cards fade out
+ at the bottom). Build 52.5 min wall clock under a load average of ~25: the deck rendered in
+ 309 s, the feed in 487 s (1.2 GB of frames), the teaser in 159 s.
+- Repo gates on 211f0d87: workspace tsc clean (69 s); `test:scripts` 389 pass, 2 skipped (391);
+ capped umtool `next build` with the corpus linked exit 0 (41 s; 50 s on 220a84f5), link
+ removed. umtool e2e `onscreen-posts onscreen clip-bench build projects`: 96 passed (4.9 min,
+ after 27 min in the queue). A first run on 220a84f5 while the ferret build encoded (load ~25)
+ gave 87 passed, 9 failed — clip-bench and projects timeouts, none in the onscreen specs, both
+ feed tests green — and passed whole once the encode was done.
+
+Found and left:
+
+- **The popup page's clamp regex reaches the page as `/s+$/`** (the template literal eats the
+ backslash; quirk recorded). It runs only for a clamped card whose later paragraphs were dropped
+ after one that fit exactly, and strips trailing letters "s" rather than spaces. Not fixed here:
+ it would change the popup page and its cached windows; the feed's page writes `\\s`. (Fixed
+ after the F2 review: "Dip F2, as built", "Fix pass after the review".)
+- A card is in the feed whole or not at all, so an overflowing column can show a gap at its
+ bottom (on ferret at 133 s: 216 px free under two cards, the third needing 288).
+- The umtool preview shows the feed's composition and frames the backdrop in the feed's box; it
+ does not show the segments reframed (that is the build's), so a backdrop built for the deck is
+ carried, scaled, into the feed's box until a build reframes it.
+
+
+## Teaser V1, as built
+
+Branch `deck/teaser-v1` from 07d1fa08. The ask: a little more delay between the teaser's hits, and
+variation clips to judge before the full stitch.
+
+| Commit | What |
+|---|---|
+| 6869bbc7 | `deck.mjs`: `TEASER_BEAT` (0.4–2.5 s), `teaserMotion(beat)`, `teaserSeconds(entry)`; `teaserTimes(lines, tail, m)` no longer compresses and reports `need`; `validateTeaser` checks `beat` and refuses a `seconds` short of `need`; `estimatedDuration` of a teaser is `teaserSeconds`. chrome-teaser, compose-chrome, verify-build and `buildTeaserSegment` (now exported) read `teaserSeconds` / `teaserMotion`. Tests in `chrome-teaser.test.mjs` and `teaser-audio.test.mjs` |
+| 8a0b1b7e | README (`beat`, `seconds` optional) and the teaser's `[Unreleased]` bullet amended in place |
+
+Rulings, as built:
+
+- **`beat`** is the seconds from one line's pop to the next, hits included; default
+ `TEASER_MOTION.gap` (0.7). The second tier's delay stays 3/7 of the beat and the tail's wait
+ 8/7 (the default's 0.3 and 0.8 of 0.7), so a slower beat is the same rhythm slowed. The first
+ landing (timed to the dissolve), the slam's hit and settle, the tail's 1.7 s fade (and its
+ swell) and the 1.2 s end room do not scale. The hits are still `teaserHits` from `teaserTimes`.
+- **Nothing is squeezed.** A `seconds` shorter than `need` (the last pop, the tail's fade, the
+ 1.2 s hold) is refused with the length needed, rounded up to a tenth. Without `seconds` the
+ card is that length, at least 3 s; one that would need more than 20 s is refused.
+- `teaserTimes` keeps `scale` (always 1) and `T` (now only rounding): the page carries `scale` in
+ its data, and `need` is stripped from it, so a teaser without `beat` — or with `beat: 0.7` —
+ composes a byte-identical page. A test pins the ferret page's sha256 at 07d1fa08.
+
+Gates on 8a0b1b7e:
+
+- Workspace tsc clean (21 s). `test:scripts` 370 pass, 2 skipped, 2 failed: the queue-lock FIFO
+ and banner cases under load; `queue-lock.test.mjs` alone 11/11, three times.
+- Capped umtool `next build` with the corpus linked: exit 0 (58 s), link removed.
+- Byte-identical without `beat`: the ferret teaser's frames key is `b8ebcaf5…` at 07d1fa08, at the
+ tip and with `beat: 0.7`; the segment key `7c87943c…` the same at both. A fresh render at the tip
+ wrote 210 frames whose PNGs are byte-identical to the ones the cut was built from, and its
+ `fin.mp4` is the same file as the cut's (same sha256, same decoded video and audio md5).
+
+The variants (scratch build through `buildTeaserSegment`, `cutJoins`, `cutOffsets`, `xfadeGraph`;
+the deck's own frames laid over c20; trimmed to c20's last 3 s; the encode's `encodeArgs`):
+the reference is the manifest's entry, and the others keep its still hold after the tail
+(about 1.05 s past `need`), so their `seconds` grows. The manifest's `seconds: 7` holds beats up to
+about 0.99; at 1.05 and 1.3 it is refused with 7.2 and 8.1.
+
+| Beat | `seconds` | `need` | Clip | Hits in the teaser's clock (overline, title, second tier, kicker; swell) |
+|---|---|---|---|---|
+| 0.7 (none set) | 7 | 5.95 | 9.5 s | 0.75, 1.45, 1.55, 2.45; 3.05 |
+| 0.9 (+29 %) | 7.7 | 6.66 | 10.2 s | 0.75, 1.65, 1.84, 2.94; 3.76 |
+| 1.05 (+50 %) | 8.3 | 7.2 | 10.8 s | 0.75, 1.80, 2.05, 3.30; 4.30 |
+| 1.3 (+86 %) | 9.1 | 8.09 | 11.6 s | 0.75, 2.05, 2.41, 3.91; 5.19 |
+
+Each clip's video and audio are the same length; the measured level onsets (an 8 dB rise per
+50 ms window) land on the overline, title and kicker hits at every beat — the second tier's
+lighter hit falls inside the title's decay, as designed.
+
+## Dip F2, as built
+
+Branch `deck/finale-dip` from e04795b7 (main 90bd8384 with `deck/feed-f1` and `deck/teaser-v1`
+merged). The ask: about one to two seconds of fade to black between the last clip and the
+teaser finale, for a little suspense, and a good transition from black into the finale. A
+teaser entry's optional `"dip": { "fade": <s>, "black": <s> }`.
+
+| Commit | What |
+|---|---|
+| a972f050 | `deck.mjs`: `DIP_LIMITS` (fade 0.3–4 s, black 0–3 s), `DIP_RISE`, `DIP_RISER`, `validateDip` (only a teaser; both keys; no other), `dipOf`, `teaserLead(entry, D)`, `dipHideAt`, `teaserMotionOf(entry, D)`, `teaserCardSeconds`; `teaserSeconds(entry, D)` and `teaserHits(entry, D)` take the cut's transition; `validateTeaser` checks the dip and measures `seconds` as the card's; `validateTeasers` refuses a dip on the first entry, `validateCutEdits` a dip on anything but a teaser; `estimatedDuration(entry, render, D)`; the schedule's teaser segment carries `dip`; `deckChoreography` hides in an instant at `dipHideAt`. chrome-teaser: the page out of black (`dip` in `teaserCues`, the veil node and rule). compose-chrome and verify-build take the transition |
+| 95663453 | build-video: `withCutEdits`' `dips`, `cutJoins` puts `dip` on the segment before a dipped teaser, its sound by `endFadeAudioFilter`; `dipWindows`, `dipVideoFilter`, `dipParts` laid last in every concat path (`xfadeConcatArgs`, `hardCutFilterArgs`, `applyChromeArgs`, `applyRail`, `previewFromSegmentsArgs`); `concatRecordText` names a dip; `buildTeaserSegment` takes the transition; the teaser's audio graph gains the riser's layer |
+| 29d6581f | The riser at gain 0.22; `dip.test.mjs` |
+| f002d76f | The picture by `fade` to black instead of `geq` (the `geq` form kept for a preview window that opens inside the fade); README |
+| 47761930 | The join into a dipped teaser holds the outgoing segment through the overlap (`xfade` custom `expr='A'`, `acrossfade` `nofade` both sides) |
+| (this) | quirks, one `[Unreleased]` bullet, this section |
+
+Rulings as built:
+
+- **The fade is the whole finished frame.** Over the previous segment's last `fade` seconds,
+ ending on its last frame `last` (frame `s = last − n`, n = `endFadeFrames`, is the last
+ untouched one), ffmpeg's `fade` out to black on the composite after every overlay (deck, feed,
+ posts, rail), enabled on frames (s, until): `until = last + 1 + round(black·fps)`. A fade to
+ black stays in yuv420p (luma 16, chroma 128); only a coloured fade needs RGB. `geq` was the
+ first form and cost about 0.25 s per 1080p frame (quirks); it remains only for a
+ `--chrome-preview` window that starts inside the fade, where `fade` cannot start. The sound is
+ `endFadeAudioFilter` on that segment's join, silent at the last frame's time. Both are made
+ where the cut is joined, so `--chrome-only` changes them; the hard-cut record names the dip.
+- **No dissolve into a dipped teaser.** Over the overlap the outgoing segment plays untouched
+ and the teaser takes over at its end, under the black. With the ordinary dissolve the footage
+ was blended with the teaser's black lead and darkened by (1 − p)(1 − k) while the deck and the
+ feed, over it, darkened by (1 − k) alone: measured on the ferret ending, the panels stayed
+ visibly lit over a nearly black picture through the last half second of the fade. The
+ overlap's length is unchanged, so every offset, chapter and schedule time is too.
+- **The black is the teaser's own lead.** `teaserLead = D + black` (D the transition as built,
+ 0 under `--no-xfade`): its page is black and its sound silent but for the riser until then;
+ the segment is the lead plus the card. No hold anywhere. The deck and feed hide in an instant
+ at `dipHideAt` (the dipped segment's last frame, black), not over the overlap.
+- **The rise.** Bars closed from the first frame, `autoAlpha` 0 → 1 with the light; a black
+ `veil` over the ground and the leak, under the words: 1 → 0.45 from the end of the black to the
+ first impact (0.35 s, `power2.in`), → 0 over 0.3 s after it (`power2.out`). The first line's
+ slam starts 0.15 s after the black (the impact 0.35 s), so with a dip the card needs 0.4 s
+ less than without, and `seconds` (when set) is the card's from the end of the black. The leak
+ enters after the lead. A riser: a sub 30 → 55 Hz (squared envelope) and noise band-passed
+ 400 Hz–6.5 kHz (cubed), up to 1 s, ending on the first impact, released over 40 ms; its last
+ 100 ms measure −18.3 dBFS RMS against the hit's −13.0. The dipped teaser (B) measures
+ −19.7 LUFS integrated, peak −6.0 dBFS; without a dip it measures −19.4 LUFS at the same beat.
+- **Without a dip nothing changes:** the ferret teaser's page sha256 at 07d1fa08 still holds
+ (`chrome-teaser.test.mjs`), the audio graph has no riser layer, every concat path writes the
+ graph it did (`dip.test.mjs` compares them), and the schedule has no `dip` key.
+
+The ferret ending at `beat: 1.05`, no `seconds` (card 6.8 s), D 0.5. c20 is 7.0 s (frames
+0–209, its last at 6.967 s); the teaser starts at 6.5 s.
+
+| Dip | Teaser | Fade (frames → black) | Black to | Veil lifts | First impact | Riser | Cut |
+|---|---|---|---|---|---|---|---|
+| A `{0.9, 0.4}` | 7.7 s (lead 0.9) | 6.067 → 6.967 | 7.4 | 7.4 | 7.75 | 6.75–7.75 | 14.2 s |
+| B `{1.2, 0.6}` | 7.9 s (lead 1.1) | 5.767 → 6.967 | 7.6 | 7.6 | 7.95 | 6.95–7.95 | 14.4 s |
+| C `{1.6, 1.0}` | 8.3 s (lead 1.5) | 5.367 → 6.967 | 8.0 | 8.0 | 8.35 | 7.35–8.35 | 14.8 s |
+
+The previews (scratch copies of the project with the timeline `[c20, fin]`, the feed layout,
+`render.endFade` 1, built with `--skip-fetch` from the cached windows, nothing fetched): video
+and audio the same length in each (14.200, 14.400, 14.800 s). Every frame from c20's last
+through the end of the black has YMAX 16 across the whole frame, and the mid-black frame
+Y 16/16, U 128/128, V 128/128 (signalstats), deck panel and feed column included; the
+contact sheets show the panel and the column dimming with the footage at mid-fade. The sound in
+C is −135 dBFS from c20's last frame to the riser.
+
+Gates:
+
+- Workspace tsc (`pnpm -r exec tsc --noEmit`) clean (44 s, on a loaded machine).
+- `test:scripts` on 47761930: 412 tests, 409 pass, 2 skipped, 1 failed: the queue-lock banner
+ case under load; `queue-lock.test.mjs` alone 11/11, three times. `dip.test.mjs` 17/17; the
+ report-to-video suite alone 291/291.
+- e2e and the capped umtool `next build` are run at the merge, not here.
+
+Found and left:
+
+- At the bottom of the veil's lift the dark radial ground shows 8-bit banding rings for a few
+ frames (x264 at crf 21 over a gradient under a near-black veil); the grain sits above the
+ veil, where an overlay blend over black adds nothing.
+- `deck.motion.in` longer than the transition no longer matters to a dipped boundary (the hide
+ is instant), but a popup-layout clip's posts still leave over the overlap as they always do,
+ now under the fade.
+
+### Fix pass after the review
+
+The read-only review of 90bd8384..76c93607 (F1, V1, F2) said SHIP, with M1 (the umtool e2e and
+the capped `next build` for F2, owed at the merge) and eight LOWs. L8 (a dip's `black` alone
+changes the hard-cut prerail record) is left: a `black` change rebuilds the prerail anyway.
+
+| Commit | What |
+|---|---|
+| 6dc0c464 | L1: the popup page's clamp writes `\\s` in its template, so the page gets `/\s+$/` and a clamped quoted paragraph ending in "s" keeps it. A test reads the regex off a composed page. The popup page's hash changes, so cached popup windows re-render once |
+| 3e3980c4 | L2: `--chrome-only` / `--chrome-preview` check the segments on disk (all but the teasers', which are built next) and `framingProblems` before rendering any teaser. A test drives `buildVideo` over deck-framed segments under a feed with a stub renderer that is never run |
+| b096557c | L4: `dipOf(entry, fps)` snaps `black` to whole frames (`snapToFrames`; a value already whole, 0.6 at 30 fps, is returned as given). `teaserLead`, `teaserSeconds`, `teaserMotionOf`, `teaserHits`, the page, the schedule's `dip` and `cutJoins` take the render's fps, so the lead, the page's rise, the frame count and `dipWindows`' `until` agree (0.45 at 30 fps is 14 frames, 0.4667 s) |
+| 2504823b | L5: the teaser's record (`<id>.teaser.json`) names the `transition` it was built at, and verify-build counts its frames at it; an older record falls back to the schedule's, then 0 under verify-build's new `--no-xfade`, which umtool's driver passes whenever the build had it |
+| 41980cb9 | L6: wording only. A feed post ticks in at its clip's start + D on every clip, the first included; the JSDoc, the page module's header, the README, the `[Unreleased]` bullet and the timing line above say so. No schedule changed |
+| e23c9b89 | L7: the umtool layout hint says a switch takes a full build (cached windows reused) and re-render on-screen is refused until one has run. No e2e spec asserts the hint's text |
+| 2093ef9b | L3: documented only (quirks): `overCards` toggles on full-frame cards are not in the framing record, and `scroll` / `chart` / `ledger` are never framed, so under the feed with `overCards: "show"` the column covers their right third |
+| (this) | this subsection |
+
+Gates at 2093ef9b:
+
+- Workspace tsc (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) clean (73 s).
+- `test:scripts`: 417 tests, 415 pass, 2 skipped, 0 failed (62 s).
+- e2e and the capped umtool `next build` (M1) are run at the merge, not here.
+
+### Handoff: the frame grid, main merged in, the gates
+
+- dec03bd8: every chrome page states `pageDuration` (its whole frames floored to 4 decimals).
+ HyperFrames renders ceil(duration × fps) frames and the build expects round: a cut of 434
+ frames (schedule total 14.467 s) rendered 435 deck frames and the build refused it. A duration
+ already on the grid (14.4, 7.9, 356.7) is written as before; the teaser page hash pin holds.
+- c78c9038: `main` (173dd42b, release 17 D0/U1 included) merged in. One conflict, an import
+ list in `umtool/lib/report/onscreen.mjs` (the feed preview's dir beside `ensureWriteDir`).
+ The branch adds no directory write that bypasses `ensureOutDir`/`ensureWriteDir`.
+- Gates at c78c9038: workspace tsc clean; `test:scripts` 443 tests, 441 pass, 2 skipped,
+ 0 failed; capped umtool `next build` with the corpus linked exit 0, link removed; umtool e2e
+ `onscreen-posts onscreen clip-bench build projects report-fetch-via-editor` 99 passed
+ (7.3 min); byte-identical `--only c07 --skip-fetch` without `render.chrome` md5
+ `6a92235fa12ca181bb81993129c9ee9d`.
+- First use: the ferret-rescue cut with `posts.layout: "feed"`, teaser `beat: 1.05` (no
+ `seconds`), `dip: {fade: 0.6, black: 0.6}` and c20's end at muteFrom + 0.6, so the picture
+ runs on muted through the fade. Built in full with `--skip-fetch --pad-after 2.6` (every
+ window reused from the cache): 357.967 s, audio = video, 18 chapters, verify-build ok (deck
+ and feed 10739/10739 frames, teaser 237/237), QR 17/17 deck and 7/7 posts; the fade starts
+ the frame after the mute mark and the frame is black (Y 16) from c20's last frame through
+ the black.
diff --git a/umtool/app/api/report/chrome/preview/route.ts b/umtool/app/api/report/chrome/preview/route.ts
@@ -1,12 +1,14 @@
import {
composeDeckPreview,
+ composeFeedPreview,
composePostsPreviews,
normalizeDraft,
normalizePostsDraft,
scheduleForPreview,
+ segmentBoxes,
} from "@/lib/report/onscreen.mjs";
-import { deckPreviewSrc, postsPreviewSrc, resolveReport } from "@/lib/report/serve.mjs";
-import { deckGeometry, deckLayout, deckOn, postsGeometry, validateChrome } from "umtool-report-to-video/deck";
+import { deckPreviewSrc, feedPreviewSrc, postsPreviewSrc, resolveReport } from "@/lib/report/serve.mjs";
+import { deckGeometry, deckLayout, deckOn, feedGeometry, postsGeometry, validateChrome } from "umtool-report-to-video/deck";
export const dynamic = "force-dynamic";
@@ -36,6 +38,13 @@ export const dynamic = "force-dynamic";
// is applied first. A window that does not compose says why in its row; the
// deck's preview is returned either way.
//
+// The posts FEED (`posts.layout: "feed"`, when the schedule says `layout:
+// "feed"`) is one more composition for the whole cut, `feed.src`, loaded at
+// `feed.geometry` (the column) for the whole scrub; the footage of a feed cut
+// sits in `feed.footage`, and `feed.boxes` says which box each built segment
+// was framed into, so a backdrop built for the deck is carried into the
+// feed's box. A feed has no windows.
+//
// The client sends a project id, a variant and the drafts. Never a path.
export async function POST(request: Request) {
let body: Record<string, unknown>;
@@ -84,6 +93,23 @@ export async function POST(request: Request) {
// `posts: false` -- the clip bench's strip, which has no footage to lay them on.
const windows = body.posts === false ? [] : await composePostsPreviews(r.project, r.variant, schedule);
const stamp = Date.now();
+ const isFeed = body.posts !== false && (schedule as { layout?: string }).layout === "feed";
+ let feed: Record<string, unknown> | null = null;
+ if (isFeed) {
+ const g = feedGeometry(render);
+ let error: string | null = null;
+ try {
+ await composeFeedPreview(r.project, r.variant, schedule);
+ } catch (e) {
+ error = e instanceof Error ? e.message : String(e);
+ }
+ feed = {
+ geometry: g.column,
+ footage: g.footage,
+ boxes: await segmentBoxes(r.project, r.variant, (variantManifest.timeline ?? []) as { id: string }[], render),
+ ...(error ? { error } : { src: `${feedPreviewSrc(r.project.id, r.variant)}?v=${stamp}` }),
+ };
+ }
return Response.json(
{
@@ -105,6 +131,7 @@ export async function POST(request: Request) {
...(w.ok ? { src: `${postsPreviewSrc(r.project.id, r.variant, w.segment)}?v=${stamp}` } : { error: w.error }),
})),
},
+ ...(feed ? { feed } : {}),
},
{ headers: { "cache-control": "no-store" } },
);
diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx
@@ -59,6 +59,8 @@ export type DeckSegment = {
export type FootageMove = { segment: string; at: number; segmentAt: number; seconds: number; from: Rect; to: Rect };
export type DeckSchedule = {
estimated?: boolean;
+ /** "feed" when the posts are a column for the whole cut (`posts.layout: "feed"`, posts to draw). */
+ layout?: "feed";
fps: number;
transition: number;
total: number;
@@ -66,7 +68,10 @@ export type DeckSchedule = {
segments: DeckSegment[];
moves?: FootageMove[];
};
-export type PostSlot = { id: string; segment: string; slot: number; of: number; appear: number; out: [number, number] };
+/** A placed post: the popup's `appear` and `out`, or the feed's tick-in, `in`. */
+export type PostSlot = { id: string; segment: string; slot: number; of: number; appear?: number; out?: [number, number]; in?: number };
+/** The posts feed's preview: the column, the footage box beside it, each built segment's framing box, the composition. */
+export type FeedPreview = { geometry: Rect; footage: Rect; boxes: Record<string, Rect>; src?: string; error?: string };
/** One posts window's preview composition: `src` when it composed, `error` when it did not. */
export type PostsWindow = { segment: string; from: number; to: number; src?: string; error?: string };
export type DeckPreviewDoc = {
@@ -78,6 +83,8 @@ export type DeckPreviewDoc = {
schedule: DeckSchedule & { posts?: PostSlot[] };
/** The posts region: where it sits in the frame, and one composition per window. */
posts?: { geometry: Rect; windows: PostsWindow[] };
+ /** The posts feed, when the cut is one: a single composition for the whole cut. */
+ feed?: FeedPreview;
};
/** An unsaved change to a post, as PUT /api/report/posts takes it. */
export type PostPatch = { attachTo?: string | null; hide?: boolean };
@@ -251,6 +258,7 @@ export function DeckFrame({
/** Where the footage goes, drawn as a box: the backdrop when there is no picture. */
export function NeutralFrame({ geometry: g, label = "footage" }: { geometry: DeckGeometry; label?: string }) {
+ // (`geometry.footage` is the feed's box for a feed cut: the caller passes it.)
const pct = (n: number, of: number) => `${(n / of) * 100}%`;
return (
<div className="absolute inset-0 bg-[#0d0f14]" data-testid="onscreen-neutral-frame">
@@ -378,6 +386,94 @@ export function PostsOverlay({
}
// ---------------------------------------------------------------------------
+// FeedOverlay: the posts FEED's composition (`posts.layout: "feed"`), one for
+// the whole cut, at the column's rect inside the 16:9 frame for every moment
+// of the scrub. PostsOverlay's contract with one message renamed: the page
+// posts `{type: "feed:ready"}` once it can be seeked and takes `deck:seek` in
+// the cut's clock. Laid out at its own pixel size and scaled.
+// ---------------------------------------------------------------------------
+export function FeedOverlay({ feed, frame: g, t }: { feed: FeedPreview; frame: DeckGeometry; t: number }) {
+ const box = useRef<HTMLDivElement | null>(null);
+ const el = useRef<HTMLIFrameElement | null>(null);
+ const [width, setWidth] = useState(0);
+ const [readySrc, setReadySrc] = useState<string | null>(null);
+ const src = feed.src ? `${feed.src}${feed.src.includes("?") ? "&" : "?"}preview=1` : null;
+ const geometry = feed.geometry;
+ const pct = (n: number, of: number) => `${(n / of) * 100}%`;
+
+ useEffect(() => {
+ const node = box.current;
+ if (!node) return;
+ const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
+ ro.observe(node);
+ return () => ro.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const onMsg = (e: MessageEvent) => {
+ if (e.source !== el.current?.contentWindow || e.origin !== window.location.origin) return;
+ if ((e.data as { type?: string } | null)?.type === "feed:ready") setReadySrc(src);
+ };
+ window.addEventListener("message", onMsg);
+ return () => window.removeEventListener("message", onMsg);
+ }, [src]);
+
+ const live = !!src && readySrc === src;
+ useEffect(() => {
+ if (live) el.current?.contentWindow?.postMessage({ type: "deck:seek", t }, window.location.origin);
+ }, [live, t]);
+
+ const scale = width > 0 ? width / geometry.width : 0;
+ return (
+ <div
+ ref={box}
+ data-testid="onscreen-feed-preview"
+ data-feed-ready={live ? "1" : "0"}
+ className="pointer-events-none absolute"
+ style={{
+ left: pct(geometry.x, g.W),
+ top: pct(geometry.y, g.H),
+ width: pct(geometry.width, g.W),
+ height: pct(geometry.height, g.H),
+ }}
+ >
+ {src ? (
+ <iframe
+ ref={el}
+ key={src}
+ src={src}
+ title="posts feed preview"
+ data-testid="onscreen-feed-preview-iframe"
+ tabIndex={-1}
+ aria-hidden
+ style={{
+ position: "absolute",
+ left: 0,
+ top: 0,
+ width: geometry.width,
+ height: geometry.height,
+ transform: `scale(${scale})`,
+ transformOrigin: "0 0",
+ border: 0,
+ background: "transparent",
+ colorScheme: "normal",
+ visibility: scale > 0 ? "visible" : "hidden",
+ }}
+ />
+ ) : (
+ <div
+ data-testid="onscreen-feed-preview-error"
+ className="absolute inset-0 flex items-start justify-center border border-dashed border-[var(--color-dirty)] p-2 text-center text-[11px] text-[var(--color-dirty)]"
+ title={feed.error}
+ >
+ <span className="rounded bg-black/70 px-1.5 py-0.5">posts feed: not composed — {feed.error}</span>
+ </div>
+ )}
+ </div>
+ );
+}
+
+// ---------------------------------------------------------------------------
// The settings form: every key of render.chrome.deck, flattened.
// ---------------------------------------------------------------------------
@@ -393,6 +489,7 @@ type DeckSettings = {
motion: { out: number; in: number; pip: number };
posts: {
show: boolean;
+ layout: string;
seconds: number;
hold: number;
position: string;
@@ -456,6 +553,7 @@ const GROUPS: { name: string; fields: Field[] }[] = [
name: "posts",
fields: [
{ key: "posts.show", label: "show", kind: "bool", hint: "off leaves every post out of the cut" },
+ { key: "posts.layout", label: "layout", kind: "select", options: ["popup", "feed"], hint: "popup: cards at the end of each clip that carries them (held, footage moved aside); feed: a column beside the footage for the whole cut, each post ticking in as its clip starts — never held. Switching it reframes the footage: that takes a full build (cached windows are reused), and re-render on-screen is refused until one has run" },
{ 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.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" },
@@ -582,7 +680,7 @@ type PostRow = {
hide: boolean;
auto: PostWhere | null;
effective: PostWhere | null;
- timing: { segment: string; slot: number; of: number; appear: number; out: [number, number] } | null;
+ timing: { segment: string; slot: number; of: number; appear?: number; out?: [number, number]; in?: number } | null;
};
type ClipOption = { id: string; label: string; day: string | null };
/** A post's row as the form holds it: `attachTo` "" is the automatic clip. */
@@ -1056,14 +1154,24 @@ export default function OnscreenSection({
// 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;
+ // The posts FEED: the footage sits in the feed's box for the whole cut. A
+ // backdrop built for another box (the deck's, before the feed was switched
+ // on) is carried into it, as a move would be; the neutral frame is drawn there.
+ const feed = preview?.feed ?? null;
+ const feedFrom = feed && backdropId ? feed.boxes[backdropId] ?? preview!.geometry.footage : null;
+ const reframe =
+ feed && feedFrom && backdropTransform(feedFrom, feed.footage, preview!.geometry) !== "none"
+ ? { from: feedFrom, rect: feed.footage }
+ : null;
+ const shiftBox = move ?? reframe;
const backdropStyle: React.CSSProperties | undefined =
- move && preview
+ shiftBox && preview
? (() => {
const { W, H } = preview.geometry;
- const f = move.from;
+ const f = shiftBox.from;
const pc = (v: number, of: number) => `${(v / of) * 100}%`;
return {
- transform: backdropTransform(move.from, move.rect, preview.geometry),
+ transform: backdropTransform(shiftBox.from, shiftBox.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)})`,
};
@@ -1370,10 +1478,10 @@ export default function OnscreenSection({
type="button"
data-testid="onscreen-post-jump"
className="num font-mono text-[var(--color-sel)] hover:underline"
- onClick={() => setT(Math.min(schedule.total, Math.round((p.timing!.appear + 0.25) * 1000) / 1000))}
+ onClick={() => setT(Math.min(schedule.total, Math.round(((p.timing!.in ?? p.timing!.appear ?? 0) + (p.timing!.in != null ? 1.5 : 0.25)) * 1000) / 1000))}
title="show this post in the preview"
>
- at {clock(p.timing.appear)}
+ at {clock(p.timing.in ?? p.timing.appear ?? 0)}
</button>
)}
</>
@@ -1475,12 +1583,13 @@ export default function OnscreenSection({
<div className="min-w-0 space-y-2">
{preview ? (
<DeckFrame preview={preview} t={t} texts={texts} testid="onscreen-preview">
- {move && <div className="absolute inset-0" style={{ background: preview.background ?? "#000" }} />}
+ {shiftBox && <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) : ""}
+ data-reframed={reframe ? "1" : "0"}
style={backdropStyle}
>
{backdropId ? (
@@ -1495,9 +1604,13 @@ export default function OnscreenSection({
className="absolute inset-0 h-full w-full object-contain"
/>
) : (
- <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} />
+ <NeutralFrame
+ geometry={feed ? { ...preview.geometry, footage: feed.footage } : preview.geometry}
+ label={current ? `${current.id} · no segment built` : "footage"}
+ />
)}
</div>
+ {feed && <FeedOverlay key={feed.src ?? "none"} feed={feed} frame={preview.geometry} t={t} />}
{postsWin && preview.posts && (
<PostsOverlay
key={`${postsWin.segment}:${postsWin.src ?? "none"}`}
diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md
@@ -244,6 +244,53 @@ 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.
+**A backslash in a page's script is a template literal's first.** The page
+modules write their runtime script inside a JS template literal, where `\s`
+is not an escape and becomes a plain `s`: the popup's `replace(/\s+$/, "")`
+reached the page as `replace(/s+$/, "")` and stripped trailing letters s, not
+spaces, in the one case it runs (a clamped card whose paragraphs were dropped
+after one that fit exactly), so a quoted word lost its last letter. Both the
+popup's and the feed's pages write `\\s` in the module; read in the module a
+single backslash looks right, and only the composed page shows the wrong one,
+which is why `chrome-posts.test.mjs` reads the regex off a composed page.
+
+**Switching the posts layout moves the footage, so it is a rebuild, not a
+re-render.** The feed frames every footage segment into its own box when the
+segment is built; the popup frames into the deck's. `--chrome-only` lays
+chrome over the segments on disk, so over segments framed for the other
+layout it would draw the column over footage (or leave a gap where it
+expected some). Each framed segment's `<id>.cut.json` records `framing:
+{ layout, box }`, and `--chrome-only` / `--chrome-preview` refuse, by name,
+any segment whose box is not the cut's; a record-less segment was built for
+the deck's box. A normal build with `--skip-fetch` reframes from the cached
+windows.
+
+**The framing record does not cover `overCards`, and full-frame segments are
+never framed.** A card built under `overCards: "hide"` is full frame and
+writes no framing record; switch `overCards` to `"show"` and `framingProblems`
+reads the missing record as the deck's box, so `--chrome-only` lets it through
+and lays the deck (and the feed) over a card that fills the frame. The other
+way, a card framed under `"show"` is no longer checked once `"hide"` makes it
+full frame, and keeps its small box with nothing drawn around it. Either
+toggle needs a normal build. Separately, `scroll`, `chart` and `ledger`
+segments are never framed: with `overCards: "show"` the deck covers their
+bottom 190 px, and under the posts feed the 600 px column also covers their
+right third for the whole segment.
+
+**The build-trace check reads a template literal nested in another's `${}` as
+the end of the string.** `scripts/next-build-trace.test.mjs` scans each module
+for path and fs calls on `import.meta.url`-derived values. One
+`` `${a} (${ok ? `x ${b}` : `y`})` `` in build-video threw its string tracking
+out of step, and it then read the rest of the file as one call reaching the
+`import.meta.url` of the CLI guard: 69 false findings. Hoist the inner
+template to a const; nothing at run time changes.
+
+**A whole-cut overlay is laid like the deck's, whatever it draws.** The feed's
+column is a second full-length sequence; it takes the deck's `-reinit_filter
+0`, `format=rgba` and `shortest=1` (its frames mix RGB and RGBA like every
+HyperFrames sequence), not the posts windows' `-itsoffset` and
+`eof_action=pass`, and it must be exactly as many frames as the deck's.
+
**`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
@@ -360,6 +407,19 @@ 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.
+**`geq` costs about a quarter of a second per 1080p frame.** It evaluates its
+expressions per pixel through the expression parser: 90 frames of a three-plane
+blend took 23 s wall on eight threads, where `fade` out to black took under a
+second. A fade to BLACK stays in yuv420p (luma to 16, chroma to 128, for any
+studio-range format); only `fade=…:color=` needs RGB. So a teaser's `dip` is
+`fade` with an `enable` window, and the end fade (toward bg, a colour) stays a
+`geq` over its one second. `fade` cannot start before its stream does, so a
+`--chrome-preview` window that opens inside a dip takes the `geq` form.
+
+**`-ac 1` sums a stereo graph's two channels at −3 dB each.** Reading the
+teaser's sound downmixed to mono measures its −6 dBFS ceiling as 0.707; read
+channel 0 of a stereo decode to check the limiter.
+
## 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/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -1547,6 +1547,34 @@ const ONSCREEN_POSTS = writeProject(
},
);
+// onscreen-feed-fixture: the posts FEED (`posts.layout: "feed"`), BUILT by
+// onscreen-posts.spec.ts -- every segment framed into the feed's box, one feed
+// sequence for the whole cut from the stub renderer, no hold -- then refused
+// a --chrome-only once the layout says popup. Clips only, as the build
+// fixture above: a card needs Pango.
+const ONSCREEN_FEED = (() => {
+ const m = deckManifest("onscreen-feed-fixture", "The On-screen Feed Fixture", [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because", date: "2024-09-03" },
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence", date: "2024-09-10" },
+ ]);
+ m.render.chrome.deck = { posts: { layout: "feed" } };
+ return writeProject("onscreen-feed-fixture", {
+ ...m,
+ posts: [
+ {
+ id: "f-one", platform: "bluesky", author: "Fixture Author", handle: "fixture.example",
+ date: "2024-09-05T09:30:00.000Z", text: "Rides on the first clip, in from its start.",
+ url: "https://bsky.app/profile/fixture.example/post/one",
+ },
+ {
+ id: "f-two", platform: "bluesky", author: "Fixture Author", handle: "fixture.example",
+ date: "2024-09-12T18:00:00.000Z", text: "Rides on the second clip.",
+ url: "https://bsky.app/profile/fixture.example/post/two",
+ },
+ ],
+ });
+})();
+
mkdirSync(path.join(reports, "bike-fixture"), { recursive: true });
writeFileSync(
path.join(reports, "bike-fixture", "sweep-report.md"),
@@ -1586,7 +1614,7 @@ ff([
// intermediates and are excluded by name.
mkdirSync(path.join(reports, "no-origin-fixture", "out"), { recursive: true });
-for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD, ONSCREEN_POSTS]) {
+for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD, ONSCREEN_POSTS, ONSCREEN_FEED]) {
mkdirSync(path.join(dir, "out", "clips-raw"), { recursive: true });
copyFileSync(
path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
@@ -1738,5 +1766,6 @@ console.log(` longform-fixture (cue gap, legacy .bak, ffmeta), longfo
console.log(` deliver-fixture (writable: a01/a02 to cut, a03 unfetched, b01 shared, b02 incorrect, b03 unjudged)`);
console.log(` onscreen-fixture (writable, deck on, unbuilt), onscreen-build-fixture (built with the deck)`);
console.log(` onscreen-posts-fixture (writable, deck on, three posts, unbuilt)`);
+console.log(` onscreen-feed-fixture (writable, posts feed, built by the spec)`);
console.log(` deliver-stop-fixture (writable: six confirmed clips to cut, for Stop and resume)`);
console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`);
diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts
@@ -1,8 +1,9 @@
import { test, expect, type APIRequestContext, type Locator, type Page } from "@playwright/test";
-import { readFileSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
-import { deckGeometry, postWindows, postsGeometry, shiftedFootage } from "umtool-report-to-video/deck";
+import { deckGeometry, feedGeometry, postWindows, postsGeometry, shiftedFootage } from "umtool-report-to-video/deck";
// ---------------------------------------------------------------------------
// POSTS on the on-screen deck, as umtool edits them: the Posts table under the
@@ -82,12 +83,14 @@ const reset = async (request: APIRequestContext) => {
type Rect = { x: number; y: number; width: number; height: number };
type Preview = {
schedule: {
+ layout?: string;
total: number;
segments: { id: string; start: number; duration: number; end: number; hold?: number }[];
- posts: { id: string; segment: string; appear: number; out: [number, number] }[];
+ posts: { id: string; segment: string; appear: number; out: [number, number]; in?: 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 }[] };
+ feed?: { geometry: Rect; footage: Rect; boxes: Record<string, Rect>; 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;
@@ -445,3 +448,181 @@ test("make room off in the settings writes shift: false, the form keeps it, and
await page.getByTestId("onscreen-settings-save").click();
await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { hold: 1.5 } });
});
+
+// ---- the posts FEED (`posts.layout: "feed"`) -----------------------------------
+//
+// The same fixture with the layout switched to feed: no hold and no move, each
+// post in at its clip's start after the 0.2 s dissolve (two on c01 share what
+// c01 has before its leave), and ONE composition for the whole cut, laid at
+// the column for every moment of the scrub, with the footage in the feed's
+// box beside it. Estimated:
+// c01 0 → 3 p-early in at 0.2, p-mid at 1.5 (2.6 s shared by two)
+// c02 2.8 → 5.8 p-late in at 3.0
+// k01 5.6 → 8.6
+
+test("the feed layout: the switch writes posts.layout, posts tick in at their clip's start, and the preview lays the feed over the whole cut", async ({
+ page,
+ request,
+}) => {
+ await openSection(page);
+ await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ const layout = page.getByTestId("onscreen-setting-posts.layout");
+ await expect(layout).toHaveValue("popup");
+ await layout.selectOption("feed");
+ await page.getByTestId("onscreen-settings-save").click();
+ await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { layout: "feed" } });
+
+ // The route: the feed's schedule and its composition, no windows.
+ const render = readManifest().render;
+ const g = feedGeometry(render);
+ const pv = await previewOf(request);
+ expect(pv.schedule.layout).toBe("feed");
+ expect(pv.schedule.segments.map((s) => [s.id, s.start, s.duration, s.hold ?? 0])).toEqual([
+ ["c01", 0, 3, 0],
+ ["c02", 2.8, 3, 0],
+ ["k01", 5.6, 3, 0],
+ ]);
+ expect(pv.schedule.total).toBe(8.6);
+ expect(pv.schedule.posts.map((p) => [p.id, p.segment, p.in])).toEqual([
+ ["p-early", "c01", 0.2],
+ ["p-mid", "c01", 1.5],
+ ["p-late", "c02", 3],
+ ]);
+ expect(pv.schedule.moves).toBeUndefined();
+ expect(pv.posts.windows).toEqual([]);
+ expect(pv.feed?.error).toBeUndefined();
+ expect(pv.feed?.src).toMatch(/\/feed-preview\/index\.html\?v=\d+$/);
+ expect(pv.feed?.geometry).toEqual(g.column);
+ expect(pv.feed?.footage).toEqual(g.footage);
+ expect(g.column).toEqual({ x: 1320, y: 0, width: 600, height: 890 });
+ // Never built: every segment's box is the deck's, which the preview carries into the feed's.
+ expect(pv.feed?.boxes.c01).toEqual(deckGeometry(render).footage);
+ // The page is served and draws a card per post.
+ const html = await (await request.get(pv.feed!.src!)).text();
+ expect(html).toContain('data-composition-id="feed"');
+ expect((html.match(/<article class="post"/g) ?? []).length).toBe(3);
+
+ // "rides on" still moves a post, and its tick-in with it.
+ await putPosts(request, { "p-mid": { attachTo: "c02" } });
+ const moved = await previewOf(request);
+ expect(moved.schedule.posts.map((p) => [p.id, p.segment, p.in])).toEqual([
+ ["p-early", "c01", 0.2],
+ ["p-mid", "c02", 3],
+ ["p-late", "c02", 4.3],
+ ]);
+ const r = await rows(request);
+ expect(r["p-mid"].effective).toMatchObject({ entryId: "c02", rule: "attachTo" });
+ await putPosts(request, { "p-mid": { attachTo: null } });
+
+ // The page: the feed's overlay for the whole cut -- before any post, among
+ // them, and over the card -- at the column's rect, ready.
+ await page.reload();
+ await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ const feed = page.getByTestId("onscreen-feed-preview");
+ await expect(feed).toHaveAttribute("data-feed-ready", "1", { timeout: 30_000 });
+ await expect(page.getByTestId("onscreen-feed-preview-iframe")).toHaveAttribute("src", /feed-preview\/index\.html/);
+ await expect(page.getByTestId("onscreen-posts-preview")).toHaveCount(0);
+ await expect(page.locator("[data-posts-window]")).toHaveCount(0);
+ await expect(page.getByTestId("onscreen-segment-hold")).toHaveCount(0);
+ await expect(page.getByTestId("onscreen-scrubber")).toHaveAttribute("max", "8.6");
+ const W = 1920, H = 1080;
+ for (const t of [0.05, 2, 7]) {
+ await seek(page, t);
+ await expect(feed).toBeVisible();
+ const frame = (await page.getByTestId("onscreen-preview").boundingBox())!;
+ const box = (await feed.boundingBox())!;
+ expect(Math.abs((box.x - frame.x) / frame.width - g.column.x / W)).toBeLessThan(0.01);
+ expect(Math.abs(box.width / frame.width - g.column.width / W)).toBeLessThan(0.01);
+ expect(Math.abs(box.height / frame.height - g.column.height / H)).toBeLessThan(0.01);
+ }
+ // The posts table's jump lands just after the post is in.
+ await expect(postRow(page, "p-late").getByTestId("onscreen-post-jump")).toHaveText("at 0:03.0");
+ // The footage is drawn in the feed's box: no segment is built, so the
+ // neutral frame's box sits beside the column.
+ await seek(page, 2);
+ const neutral = page.getByTestId("onscreen-neutral-frame").locator("div").first();
+ const frame = (await page.getByTestId("onscreen-preview").boundingBox())!;
+ const nb = (await neutral.boundingBox())!;
+ expect(Math.abs((nb.x - frame.x) / frame.width - g.footage.x / W)).toBeLessThan(0.01);
+ expect(Math.abs(nb.width / frame.width - g.footage.width / W)).toBeLessThan(0.01);
+ expect(Math.abs((nb.y - frame.y) / frame.height - g.footage.y / H)).toBeLessThan(0.01);
+});
+
+// ---- a feed cut, built -------------------------------------------------------------
+
+const FEED_PROJECT = "reports/onscreen-feed-fixture";
+const FEED_DIR = path.join(FIXTURE, "reports", "onscreen-feed-fixture");
+const FEED_OUT = path.join(FEED_DIR, "out", "sourced");
+type Job = { id: string; state: string; error: string | null; log?: string[]; events: { ev: string; phase?: string; region?: string }[] };
+
+async function waitForJob(request: APIRequestContext, id: string, ms = 180_000): Promise<Job> {
+ const until = Date.now() + ms;
+ while (Date.now() < until) {
+ const j = (await (await request.get(`/api/report/build?job=${id}`)).json()) as Job;
+ if (j.state !== "running") return j;
+ await new Promise((r) => setTimeout(r, 400));
+ }
+ throw new Error("the job never finished");
+}
+
+test("a feed cut builds: every clip framed into the feed's box, one feed sequence for the whole cut, never held; --chrome-only over another layout's segments is refused", async ({
+ request,
+}) => {
+ test.setTimeout(300_000);
+ const feedManifest = () => JSON.parse(readFileSync(path.join(FEED_DIR, "video.manifest.json"), "utf8")) as Manifest;
+ const tokenOf = async () =>
+ ((await (await request.get(`/api/report/chrome?project=${enc(FEED_PROJECT)}`)).json()) as { token: string }).token;
+ const putFeedChrome = async (chrome: unknown) => {
+ const r = await request.put("/api/report/chrome", { data: { project: FEED_PROJECT, chrome, token: await tokenOf() } });
+ expect(r.ok(), await r.text()).toBeTruthy();
+ };
+ await putFeedChrome({ engine: "hyperframes", layout: "deck", deck: { posts: { layout: "feed" } } });
+
+ const start = await request.post("/api/report/build?replace=1", { data: { project: FEED_PROJECT, preset: "fast" } });
+ expect(start.ok(), await start.text()).toBeTruthy();
+ const built = await waitForJob(request, ((await start.json()) as { job: Job }).job.id);
+ expect(built.state, `${built.error ?? ""}\n${built.log?.slice(-20).join("\n")}`).toBe("done");
+ // The feed is composed and rendered (by the stub) beside the deck.
+ expect(built.events.filter((e) => e.ev === "chrome" && e.region === "feed").map((e) => e.phase)).toEqual(
+ expect.arrayContaining(["compose", "render"]),
+ );
+
+ const render = feedManifest().render;
+ const g = feedGeometry(render);
+ const schedule = JSON.parse(readFileSync(path.join(FEED_OUT, "schedule.json"), "utf8")) as Preview["schedule"] & { fps: number; transition: number };
+ expect(schedule.layout).toBe("feed");
+ expect(schedule.segments.some((s) => (s.hold ?? 0) > 0)).toBe(false);
+ expect(schedule.moves).toBeUndefined();
+ // Each post is in at its clip's start, after the incoming transition.
+ for (const p of schedule.posts) {
+ const seg = schedule.segments.find((s) => s.id === p.segment)!;
+ expect(p.in).toBeCloseTo(seg.start + schedule.transition, 3);
+ }
+ // One sequence for the whole cut, as long as the deck's.
+ const count = (dir: string) => readdirSync(dir).filter((f) => /^frame_\d{6}\.png$/.test(f)).length;
+ const frames = Math.round(schedule.total * schedule.fps);
+ expect(count(path.join(FEED_OUT, "chrome", "feed-frames"))).toBe(frames);
+ expect(count(path.join(FEED_OUT, "chrome", "deck-frames"))).toBe(frames);
+ expect(existsSync(path.join(FEED_OUT, "chrome", "posts-c01-frames"))).toBe(false);
+ // Every clip framed into the feed's box, and its record says so.
+ for (const id of ["c01", "c02"]) {
+ const rec = JSON.parse(readFileSync(path.join(FEED_OUT, "segments", `${id}.cut.json`), "utf8"));
+ expect(rec.framing).toEqual({ layout: "feed", box: g.footage });
+ }
+ // The final is as long as the schedule: nothing held.
+ const final = path.join(FEED_DIR, "out", "onscreen-feed-fixture.mp4");
+ const secs = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", final]).toString().trim());
+ expect(Math.abs(secs - schedule.total)).toBeLessThan(2 / schedule.fps);
+
+ // The layout switched back to popup: the segments are framed for the feed,
+ // so laying the deck over them alone is refused, by name.
+ await putFeedChrome({ engine: "hyperframes", layout: "deck", deck: {} });
+ const again = await request.post("/api/report/build?replace=1", {
+ data: { project: FEED_PROJECT, preset: "fast", options: { chromeOnly: true } },
+ });
+ expect(again.ok(), await again.text()).toBeTruthy();
+ const refused = await waitForJob(request, ((await again.json()) as { job: Job }).job.id);
+ expect(refused.state).toBe("failed");
+ expect(`${refused.error ?? ""}\n${(refused.log ?? []).join("\n")}`).toMatch(/framed for another layout than this cut's deck/);
+ await putFeedChrome({ engine: "hyperframes", layout: "deck", deck: { posts: { layout: "feed" } } });
+});
diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs
@@ -150,6 +150,7 @@ export function buildSteps(project, { preset = "fast", only = null, skipFetch =
if ((!p.only || !only) && !options.preview) {
const verify = ["node", script("verify-build.mjs"), manifest, "--out", outDir];
if (options.variant) verify.push("--variant", options.variant);
+ if (buildArgv.includes("--no-xfade")) verify.push("--no-xfade");
steps.push({
...base,
label: "verify the file that came out",
diff --git a/umtool/lib/report/driver.test.mjs b/umtool/lib/report/driver.test.mjs
@@ -24,3 +24,12 @@ test("without chromeOnly nothing changes: the preflight leads and no --chrome-on
assert.equal(steps[0].label, "check every source is still fetchable");
assert.ok(steps.every((s) => !s.argv.includes("--chrome-only")));
});
+
+test("the verify is told when the build joined without crossfades", () => {
+ const verifyOf = (steps) => steps.at(-1).argv;
+ const fast = buildSteps(project, { preset: "fast" });
+ assert.ok(fast.some((s) => s.argv.includes("--no-xfade") && !s.argv.some((a) => a.endsWith("verify-build.mjs"))));
+ assert.ok(verifyOf(fast).includes("--no-xfade"));
+ assert.ok(verifyOf(buildSteps(project, { preset: "final", options: { xfade: false } })).includes("--no-xfade"));
+ assert.ok(!verifyOf(buildSteps(project, { preset: "final" })).includes("--no-xfade"));
+});
diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs
@@ -22,6 +22,7 @@ import { selectVariant } from "umtool-report-to-video/build-video";
import {
attachPosts,
clipDay,
+ deckGeometry,
deckText,
estimateSchedule,
footageMoves,
@@ -30,6 +31,7 @@ import {
postSchedule,
postWindows,
resolveDeck,
+ roundPosts,
} from "umtool-report-to-video/deck";
import {
channelsDirFor,
@@ -39,7 +41,7 @@ import {
readCues,
} from "../projects/report.mjs";
import { normalizePostPatches } from "./manifest.mjs";
-import { deckPreviewDir, postsPreviewDir } from "./serve.mjs";
+import { deckPreviewDir, feedPreviewDir, postsPreviewDir } from "./serve.mjs";
import { ensureWriteDir } from "./storage.mjs";
/** The schedule document deck.mjs defines, built or estimated. */
@@ -160,7 +162,7 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra
const deck = resolveDeck(render);
const provenance = variantManifest.provenance ?? {};
const patchedEntries = entries.map(patched);
- const { posts: _builtPosts, moves: _builtMoves, ...rest } = built;
+ const { posts: _builtPosts, moves: _builtMoves, layout: _builtLayout, ...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;
@@ -183,9 +185,12 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra
const placed = deck.posts.show
? postSchedule({ posts, entries: patchedEntries, metas, segments, D: built.transition, total, render })
: [];
- const moves = placed.length ? footageMoves({ posts: placed, segments, render }) : [];
+ // The layout as it is NOW: a feed (no holds, no moves) only with posts to draw.
+ const feed = placed.length > 0 && deck.posts.layout === "feed";
+ const moves = placed.length && !feed ? footageMoves({ posts: placed, segments, render }) : [];
return {
...rest,
+ ...(feed ? { layout: "feed" } : {}),
total,
segments: segments.map((s, i) => {
const e = patchedEntries[i];
@@ -194,7 +199,7 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra
const keepBuilt = e.type === "clip" && e.onscreen?.subtitle === undefined && !meta;
return { ...s, title, subtitle: keepBuilt ? s.subtitle : subtitle };
}),
- ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}),
+ ...(placed.length ? { posts: roundPosts(placed) } : {}),
...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}),
};
}
@@ -289,7 +294,10 @@ export function postRows({ variantManifest, metas, schedule = null }) {
hide: p.hide === true,
auto: where(auto.get(p.id)),
effective: p.hide ? null : where(effective.get(p.id)),
- timing: t ? { segment: t.segment, slot: t.slot, of: t.of, appear: t.appear, out: t.out } : null,
+ // A popup post's appear and leave, or a feed post's tick-in (`in`).
+ timing: t
+ ? { segment: t.segment, slot: t.slot, of: t.of, ...("in" in t ? { in: t.in } : { appear: t.appear, out: t.out }) }
+ : null,
};
}),
clips: entries
@@ -379,6 +387,61 @@ export async function composeDeckPreview(project, variant, schedule) {
}
/**
+ * Compose the PREVIEW of the posts FEED (a schedule with `layout: "feed"`):
+ * compose-chrome's `feed` region, one project for the whole cut under
+ * out/<variant>/chrome/feed-preview/. No render. `compose` is injectable for
+ * the unit test; the routes never pass it.
+ *
+ * @param {{ dir: string }} project
+ * @param {string} variant
+ * @param {Record<string, any>} schedule
+ * @param {{ compose?: (args: Record<string, unknown>) => Promise<any> }} [opts]
+ */
+export async function composeFeedPreview(project, variant, schedule, { compose = composeChrome } = {}) {
+ const outDir = path.join(project.dir, "out", variant);
+ const want = feedPreviewDir(project.dir, variant);
+ return serialised(want, async () => {
+ /** @type {Record<string, unknown>} */
+ const args = { manifestPath: manifestPath(project.dir), outDir, variant, region: "feed", schedule, preview: true };
+ const r = await compose(/** @type {any} */ (args));
+ if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) {
+ throw new Error(`compose-chrome wrote the feed preview to ${r.projDir}, not ${want}`);
+ }
+ return r;
+ });
+}
+
+/**
+ * The box each built segment's footage was framed into, by entry id: the
+ * `framing` its cut record names (build-video's segmentFraming), else -- a
+ * segment built before records named it, or none built yet -- the deck's own
+ * box. The preview carries a backdrop built for one layout into the other's
+ * box (the feed switched on since the build).
+ *
+ * @param {{ dir: string }} project
+ * @param {string} variant
+ * @param {Array<{ id: string }>} entries
+ * @param {Record<string, any>} render
+ * @returns {Promise<Record<string, { x: number, y: number, width: number, height: number }>>}
+ */
+export async function segmentBoxes(project, variant, entries, render) {
+ const segs = path.join(project.dir, "out", variant, "segments");
+ const deckBox = deckGeometry(render).footage;
+ const out = /** @type {Record<string, any>} */ ({});
+ await Promise.all(entries.map(async (e) => {
+ // An id names a file here: only a plain one is read.
+ if (!/^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(String(e.id)) || String(e.id).includes("..")) {
+ out[e.id] = deckBox;
+ return;
+ }
+ const rec = await readFile(path.join(segs, `${e.id}.cut.json`), "utf8").then(JSON.parse, () => null);
+ const box = rec?.framing?.box;
+ out[e.id] = box && [box.x, box.y, box.width, box.height].every(Number.isFinite) ? box : deckBox;
+ }));
+ return out;
+}
+
+/**
* Compose the PREVIEW of the posts region for one window. No render.
*
* The posts region is compose-chrome's (`region: "posts"`, one project per
diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs
@@ -10,6 +10,7 @@ import { postWindows } from "umtool-report-to-video/deck";
import {
applyPostsDraft,
clipLabel,
+ composeFeedPreview,
composePostsPreview,
composePostsPreviews,
normalizeDraft,
@@ -17,8 +18,11 @@ import {
postRows,
previewSchedule,
scheduleMatches,
+ segmentBoxes,
stillTimeOf,
} from "./onscreen.mjs";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
const cut = () => ({
slug: "t",
@@ -293,3 +297,90 @@ test("composePostsPreview: ONE call per window, region posts, preview, into post
const failed = await composePostsPreviews(project, "sourced", schedule, { compose: failing });
assert.deepEqual(failed.map((w) => [w.ok, w.error]), [[false, "unknown chrome region: posts"], [false, "unknown chrome region: posts"]]);
});
+
+// ---- the posts FEED (`posts.layout: "feed"`) ------------------------------------
+
+/** `cut()` with posts in the feed layout. */
+const feedy = (posts = POSTS) => {
+ const m = withPosts(posts);
+ m.render.chrome.deck = { posts: { layout: "feed" } };
+ return m;
+};
+
+test("the feed: a popup build's holds come off, the posts tick in at their clip's start, no moves, no windows", () => {
+ // A build made under the popup, with holds; the manifest says feed now.
+ const popupBuilt = previewSchedule({ variantManifest: roomy(), built: built(), draft: new Map(), metas });
+ assert.ok(popupBuilt.segments.some((x) => x.hold > 0));
+ const s = previewSchedule({ variantManifest: feedy(), built: popupBuilt, draft: new Map(), metas });
+ assert.equal(s.layout, "feed");
+ // The segments are their probed lengths again (built()'s): no hold anywhere.
+ assert.deepEqual(s.segments.map((x) => [x.id, x.start, x.duration, x.hold ?? 0]), [
+ ["k1", 0, 5, 0], ["c01", 4.5, 9.9, 0], ["c02", 13.9, 12, 0],
+ ]);
+ assert.equal(s.total, 25.9);
+ assert.ok(!("moves" in s));
+ // c01 (in 5.0 after its 0.5 dissolve) carries p3 and p1, 4 s apart; c02 p2 at 14.4.
+ assert.deepEqual(s.posts.map((p) => [p.id, p.segment, p.in]), [["p3", "c01", 5], ["p1", "c01", 9], ["p2", "c02", 14.4]]);
+ assert.ok(s.posts.every((p) => !("appear" in p)));
+ assert.deepEqual(postWindows(s), []);
+ // Back to the popup: no layout key, the holds again.
+ const back = previewSchedule({ variantManifest: roomy(), built: s, draft: new Map(), metas });
+ assert.ok(!("layout" in back));
+ assert.ok(back.segments.some((x) => x.hold > 0));
+ // The estimate says the same about the layout.
+ assert.equal(previewSchedule({ variantManifest: feedy(), built: null, draft: new Map(), metas }).layout, "feed");
+ // All posts hidden: a feed with nothing to draw is the deck alone.
+ const none = previewSchedule({
+ variantManifest: feedy(), built: s, draft: new Map(), metas,
+ postsDraft: { p1: { hide: true }, p2: { hide: true }, p3: { hide: true } },
+ });
+ assert.ok(!("layout" in none) && !("posts" in none));
+});
+
+test("the feed: the posts table's timing is the tick-in", () => {
+ const m = feedy();
+ const schedule = previewSchedule({ variantManifest: m, built: built(), draft: new Map(), metas });
+ const by = Object.fromEntries(postRows({ variantManifest: m, metas, schedule }).posts.map((p) => [p.id, p]));
+ assert.deepEqual(by.p2.timing, { segment: "c02", slot: 0, of: 1, in: 14.4 });
+ assert.equal(by.p2.effective.entryId, "c02");
+});
+
+test("composeFeedPreview: compose-chrome's feed region, into feed-preview, refused anywhere else", async () => {
+ const calls = [];
+ const project = { dir: "/proj" };
+ const schedule = previewSchedule({ variantManifest: feedy(), built: built(), draft: new Map(), metas });
+ const compose = async (args) => {
+ calls.push(args);
+ return { projDir: path.join(args.outDir, "chrome", "feed-preview") };
+ };
+ await composeFeedPreview(project, "sourced", schedule, { compose });
+ assert.deepEqual(calls[0], {
+ manifestPath: path.join("/proj", "video.manifest.json"),
+ outDir: path.join("/proj", "out", "sourced"),
+ variant: "sourced",
+ region: "feed",
+ schedule,
+ preview: true,
+ });
+ await assert.rejects(
+ composeFeedPreview(project, "sourced", schedule, { compose: async () => ({ projDir: "/elsewhere" }) }),
+ /not \/proj\/out\/sourced\/chrome\/feed-preview/,
+ );
+});
+
+test("segmentBoxes: each segment's recorded framing box, else the deck's; an odd id is never read", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "boxes-"));
+ try {
+ const segs = path.join(dir, "out", "sourced", "segments");
+ await mkdir(segs, { recursive: true });
+ const feedBox = { x: 24, y: 87, width: 1272, height: 716 };
+ await writeFile(path.join(segs, "c01.cut.json"), JSON.stringify({ version: 1, framing: { layout: "feed", box: feedBox } }));
+ await writeFile(path.join(segs, "c02.cut.json"), JSON.stringify({ version: 1, start: 1, end: 2 }));
+ const render = { width: 1920, height: 1080, chrome: { engine: "hyperframes", layout: "deck", deck: {} } };
+ const deckBox = { x: 173, y: 2, width: 1574, height: 886 };
+ const boxes = await segmentBoxes({ dir }, "sourced", [{ id: "c01" }, { id: "c02" }, { id: "k1" }, { id: "../x" }], render);
+ assert.deepEqual(boxes, { c01: feedBox, c02: deckBox, k1: deckBox, "../x": deckBox });
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs
@@ -323,6 +323,19 @@ export async function deckPreviewFile(dir, segments) {
const POSTS_PREVIEW_PREFIX = "posts-preview-";
+// The posts FEED's preview composition (`posts.layout: "feed"`): one project
+// for the whole cut, out/<variant>/chrome/feed-preview/, served one segment
+// deeper under the same prefix, `feed-preview/…`.
+const FEED_PREVIEW = "feed-preview";
+
+/** The preview project of a cut's posts feed. */
+export const feedPreviewDir = (projectDir, variant) =>
+ path.join(projectDir, "out", variant, "chrome", FEED_PREVIEW);
+
+/** The iframe src for a cut's posts-feed preview composition. */
+export const feedPreviewSrc = (projectId, variant) =>
+ `/api/report/chrome/files/${encodeProjectSegment(projectId)}/${variant}/${FEED_PREVIEW}/index.html`;
+
/** The preview project of the posts window on one segment. */
export const postsPreviewDir = (projectDir, variant, segment) =>
path.join(projectDir, "out", variant, "chrome", `${POSTS_PREVIEW_PREFIX}${segment}`);
@@ -335,6 +348,7 @@ export const postsPreviewSrc = (projectId, variant, segment) =>
* Which preview directory a files request is for, and the segments left to
* resolve inside it.
*
+ * `feed-preview/…` is the posts feed's project (one per cut).
* `posts-preview-<segment>/…` is a posts window's project when `<segment>` is
* one of `segmentIds` -- the cut's own entry ids, which the caller reads from
* the manifest, so a name the client made up is not a directory this serves.
@@ -352,6 +366,7 @@ export const postsPreviewSrc = (projectId, variant, segment) =>
export function previewDirFor(projectDir, variant, rest, segmentIds) {
if (!Array.isArray(rest) || !rest.length) return null;
const head = rest[0];
+ if (head === FEED_PREVIEW) return { dir: feedPreviewDir(projectDir, variant), rest: rest.slice(1) };
if (typeof head === "string" && head.startsWith(POSTS_PREVIEW_PREFIX)) {
const seg = head.slice(POSTS_PREVIEW_PREFIX.length);
if (!seg || /[\/\\\0]/.test(seg) || seg === "." || seg === "..") return null;
diff --git a/umtool/lib/report/serve.test.mjs b/umtool/lib/report/serve.test.mjs
@@ -12,6 +12,8 @@ import test from "node:test";
import {
decodeProjectSegment,
deckPreviewDir,
+ feedPreviewDir,
+ feedPreviewSrc,
deckPreviewFile,
deckPreviewSrc,
encodeProjectSegment,
@@ -188,6 +190,15 @@ async function realDir(p) {
}
+test("previewDirFor: feed-preview/ is the posts feed's project", () => {
+ assert.deepEqual(previewDirFor("/p", "sourced", ["feed-preview", "assets", "qr00.png"], []), {
+ dir: feedPreviewDir("/p", "sourced"),
+ rest: ["assets", "qr00.png"],
+ });
+ assert.equal(feedPreviewDir("/p", "full"), path.join("/p", "out", "full", "chrome", "feed-preview"));
+ assert.match(feedPreviewSrc("reports/x", "sourced"), /\/sourced\/feed-preview\/index\.html$/);
+});
+
test("previewDirFor: posts-preview-<segment> is a window's project only for an entry of the cut", () => {
const ids = ["c01", "c02", "k1"];
assert.deepEqual(previewDirFor("/p", "sourced", ["posts-preview-c02", "index.html"], ids), {
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -239,7 +239,8 @@ 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": 4, "hold": 2.5, // the manifest's `posts` (below); seconds 0.5–10; hold 0–10 (0: none)
+ "posts": { "show": true, "layout": "popup", // | "feed": a column beside the footage for the whole cut (below)
+ "seconds": 4, "hold": 2.5, // the manifest's `posts` (below); seconds 0.5–10; hold 0–10 (0: none; the feed never holds)
"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
@@ -438,12 +439,15 @@ falls on the teaser.
"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
+ "beat": 0.7, // optional, default 0.7: seconds from one pop to the next
+ "hits": true, // optional, default true: false makes the card silent
+ "dip": { "fade": 1.2, "black": 0.6 } } // optional: go to black before it (below)
```
- **`lines`** — 1 to 5, each one line of at most 80 characters: a string, or
`{ text, break }`, where `break` is the END of `text` drawn as a smaller,
- wide-tracked second tier under the rest that pops a beat (0.3 s) after it.
+ wide-tracked second tier under the rest that pops 3/7 of a beat after it
+ (0.3 s at the default `beat`).
The words are data: they are drawn uppercase, and kept as written everywhere
else. **Roles follow position:** with three or more lines the first is a
small wide-tracked overline between two accent rules, the last a mid-size
@@ -458,10 +462,25 @@ falls on the teaser.
as a `break`. The counts were measured in the face on ordinary words in
capitals (a title holds 36–37 there); a row of only wide capitals (M, W) can
still spill.
-- **`seconds`** — 3 to 20. The pops land 0.55 s in (after the incoming
- dissolve) and 0.7 s apart; the tail starts 0.8 s after the last and fades in
- over 1.7 s; the last 1.2 s are a still hold for the end fade. A card too
- short for all of it plays every beat proportionally faster.
+- **`beat`** — 0.4 to 2.5 seconds, default 0.7 (`TEASER_MOTION.gap`): the
+ time from one line's pop to the next, and so from one hit to the next. The
+ pops land 0.55 s in (after the incoming dissolve) and a beat apart, counted
+ from the line before or from its second tier. Two waits scale with the beat
+ in the default's proportion, so a slower beat is the same rhythm slowed: a
+ second tier pops 3/7 of a beat after its line (0.3 s at 0.7) and the tail
+ starts 8/7 of a beat after the last pop (0.8 s). What is not the beat stays
+ put: the first landing, each slam (0.2 s to its hit, 0.5 s to settle), the
+ tail's 1.7 s fade and the swell under it, and the last 1.2 s, a still hold
+ for the end fade. A teaser without `beat`, or with 0.7, is the page it
+ always was — the same render key and the same frames.
+- **`seconds`** — optional, 3 to 20. **Nothing is squeezed to fit**: a
+ `seconds` shorter than the beats need (the last pop, the tail's fade and the
+ 1.2 s hold) is refused with the length they need. Left out, the card is
+ exactly that long, rounded up to a tenth of a second and at least 3 s; a
+ card that would need more than 20 s is refused (a shorter beat, or fewer
+ lines). The ferret card needs 5.95 s at 0.7, 6.7 at 0.9, 7.2 at 1.05 and
+ 8.1 at 1.3 — its `"seconds": 7` (a little over a second more still at the
+ end) holds beats up to about 0.99.
- **`tail`** — at most 8 characters, in the accent, set a little apart from
the last line.
- **The chapter** is the lines joined with " — ", the tail after the last
@@ -507,9 +526,80 @@ 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.
+#### `dip` — to black before the teaser, and up out of it
+
+```jsonc
+"dip": { "fade": 1.2, "black": 0.6 } // fade 0.3–4 s, black 0–3 s; both required
+```
+
+The cut goes to black before the teaser and the teaser comes up out of it, like
+a trailer. Only a teaser takes a `dip` (anywhere else it is refused, and so is a
+dip on the first entry, which has nothing before it to fade).
+
+1. **The fade — the whole frame.** Over the previous segment's last `fade`
+ seconds, ending on its last frame (where the dissolve into the teaser ends),
+ everything on screen eases to black: the footage, the deck, the feed column,
+ popup posts, a rail. Its sound fades to silence over the same frames. The
+ picture is ffmpeg's `fade` out to black laid on the FINISHED picture, after
+ every overlay (`dipWindows`, `dipVideoFilter` in `build-video.mjs`), and
+ only inside the dip's window: every other frame passes untouched. A fade to
+ BLACK stays in the stream's yuv420p (luma 16, chroma 128) — only a coloured
+ fade goes through RGB, which is why the end fade is a `geq` — and it is
+ about 25× faster than the same blend in `geq` at 1080p (a preview window
+ that starts inside the fade uses the `geq` form). The sound is the end
+ fade's `afade` on that segment's join. Both are made where the cut is joined, so `--chrome-only` changes a
+ dip without re-encoding a clip.
+ The crossfade into the teaser does not dissolve: over the overlap the
+ outgoing segment plays untouched (`xfade=transition=custom:expr='A'`, and
+ `acrossfade` with `nofade` on both sides) and the teaser takes over at its
+ end, under the black. A dissolve there blended the footage with the
+ teaser's black lead and darkened it faster than the deck and the feed,
+ which only the dip fades — the panels were left lit over a darker picture.
+2. **The black.** The blend stays fully black from that last frame for
+ `black` seconds more — whole frames at the cut's fps: a `black` that falls
+ between frames is taken to the nearest one (0.45 at 30 fps is 14 frames,
+ 0.4667 s; `dipOf`), and one that is already whole frames (0.6 at 30 fps) is
+ used as given, so the joined cut's black, the teaser's frame count and the
+ page's rise all end on the same frame. Under it, the teaser's own first `transition + black`
+ seconds — its **lead** (`teaserLead`) — are black and silent but for the
+ riser, so the teaser takes over in black and nothing is held: the teaser
+ segment is simply longer by the lead (`teaserSeconds(entry, D)`), and the
+ schedule, the chapters and the pips count it as they count any segment's
+ length (the crossfade's overlap included, as everywhere). The deck and the feed are gone in an instant on the
+ faded segment's last frame (`dipHideAt`), under the black, instead of
+ sliding away over the dissolve.
+3. **The rise.** The card opens with the letterbox already closed and dark; a
+ black veil over the ground and the light leak (under the words) starts
+ lifting as the black ends, slowly, to 45 % by the first line's impact
+ 0.35 s later, then blooms away over 0.3 s (`DIP_RISE`), so the first hit is
+ the moment the light comes on. The first line's slam starts 0.15 s after
+ the black, not 0.55 s (there is no dissolve to land after), and every later
+ time follows it. Under the black, a **riser** (`DIP_RISER`, synthesised like
+ the hits): a sub climbing 30 → 55 Hz and a band-passed noise swell, rising
+ for up to a second into the first hit and released 40 ms after it, about
+ 6 dB under the hit.
+
+With a dip, `seconds` is the CARD's — from where the light comes up — and the
+segment is the lead plus it; left out, the card is what its beats need from
+there (0.4 s less than without a dip). The ferret finale at `beat: 1.05`: the
+card 6.8 s, the segment 7.7 s at `{0.9, 0.4}`, 7.9 s at `{1.2, 0.6}`, 8.3 s at
+`{1.6, 1}`. With `{1.2, 0.6}` after c20 (the cut's 0.5 s dissolve): c20 fades
+over its last 1.2 s to black on its last frame, black holds 0.6 s more, the
+veil starts lifting 1.1 s into the teaser and its first impact is 1.45 s in.
+
+The lead counts the cut's transition AS BUILT: a `--no-xfade` build composes a
+teaser whose lead is the black alone (`buildTeaserSegment` and compose-chrome
+take the build's transition; compose-chrome's CLI uses the manifest's). The
+teaser's record beside its segment (`<id>.teaser.json`) names that transition,
+and `verify-build.mjs` counts the teaser's frames at it (an older record
+without it: the schedule's, else 0 under `--no-xfade`, which umtool's driver
+passes on whenever the build had it, else the manifest's). A
+teaser without `dip` composes the page, the sound and the segment key it
+always did, and a cut without one writes every graph it did.
+
**umtool** shows a teaser as a card row named by its lines (the report page,
the On-screen table, the timeline strip). Editing its lines there is not
-built: it needs a writer (`updateTeaser(dir, id, {lines, tail, seconds, hits},
+built: it needs a writer (`updateTeaser(dir, id, {lines, tail, seconds, beat, hits},
{token})` through `withManifestLock` and `validateTeaser`), a route, a small
form (one field per line with a break picker), and a preview — a still of the
composition at a chosen second through compose-chrome's `--still`, which the
@@ -1173,6 +1263,67 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts --
under three frames of still picture there (0.5 s under a 0.5 s crossfade) is
reported as not checked.
+### The posts feed (`posts.layout: "feed"`)
+
+The other way to draw the posts: a column for the WHOLE cut beside the
+footage, instead of cards over the end of each clip. The deck stays full
+width at the bottom; the column stands on it from the frame's top, so the two
+read as one L-shaped surface around the picture. Nothing pauses and nothing
+moves: no hold, no footage move, and the cut is as long as its segments.
+
+- **Geometry** (`feedGeometry`): the column is `posts.width` (600) wide, flush
+ with the `position` side's edge and the frame's top, down to the deck's top
+ edge — 600×890 at (1320, 0) at 1920×1080. The footage keeps the frame's
+ aspect and is as large as fits beside it with `posts.inset` (24) clear on
+ every side, centred: 1272×716 at (24, 87), 66 % of the frame's width against
+ the deck alone's 82 %. A feed whose footage would be under half the frame's
+ width is refused (`postsFitErrors`).
+- **Framing.** Every footage segment of a feed cut — clips, stills, cards with
+ `overCards: "show"` — is framed into that box when it is built
+ (`deckFraming(render, { feed: true })`), for the whole cut. A cut is a feed
+ (`feedOn`) when the deck is on, `posts.show`, `layout: "feed"`, and at least
+ one post is drawn on a clip of the cut's whole timeline (an `--only` rebuild
+ frames as the full build would). Each framed segment's `<id>.cut.json`
+ records `framing: { layout, box }`; a still's and a card's record holds only
+ that.
+- **Switching the layout reframes every segment**, so it takes a normal build
+ (with `--skip-fetch` it re-cuts from the cached windows). `--chrome-only` and
+ `--chrome-preview` read the records first and refuse segments framed for the
+ other layout, naming each one (`framingProblems`); a segment with no
+ framing record was built for the deck's box.
+- **When** (`postSchedule`): a post has one time, `in` — its clip's start
+ plus the transition (start + D): after the incoming dissolve, and D in on
+ the first clip too, which has none; a clip's next posts follow
+ `seconds` apart, or closer on a clip too short for that, so the last is in at
+ least that long before the clip leaves. Once in, a post stays. The schedule
+ says `layout: "feed"` and carries each post's `in` — only when there are
+ posts to draw, so a popup schedule and one without posts are byte-identical
+ to what they were. `postWindows` is empty for a feed.
+- **The column** (`chrome-feed.mjs`, one composition for the whole cut,
+ region-local at the column, transparent outside its panel): a header (the
+ platform and `@handle`, or "Posts" over several authors, a count, "posts as
+ the timeline reaches them"), then an empty state until the first post. Each
+ post TICKS IN at the top, newest first: the cards already in slide down by
+ its height over `push` while it enters from the column's outer edge, its
+ accent rim flares and settles to a lit rail, which goes out when the next
+ one arrives. A card pushed past the column's bottom fades out as it goes —
+ the oldest scroll out. Over a segment the deck hides for (a full-frame card,
+ the teaser) the column slides out of the frame's side with the deck and
+ back after (`deckChoreography`'s visibility). Cards are the popup's design
+ sized for the column: 24 px words on 33 px lines, `maxLines`, the post's
+ date (the handle and platform too when there is more than one author), a
+ `qrSize` QR in its own cell. The plan (`feedCues`) runs in the page on the
+ measured card heights, embedded by `embedFn`, like the popup's.
+- **Files:** `compose-chrome.mjs <manifest> --region feed` — project
+ `chrome/feed/`, frames `chrome/feed-frames/` and their `.key`, a window
+ `chrome/feed-from<s>[-frames]`, the preview `chrome/feed-preview/` (never
+ rendered); cached exactly as the deck's. The build renders it after the
+ deck, checks it is as many frames as the deck's, and lays it the deck's way
+ (`-reinit_filter 0`, `format=rgba`, `shortest=1`) in the crossfade concat,
+ the hard-cut `applyChrome` pass and `--chrome-preview`.
+- **`verify-build`** checks `chrome/feed-frames` holds the whole cut's frames
+ and that the schedule holds and moves nothing.
+
### The hold and the move, where the cut is joined
`segmentJoins(schedule)` turns the schedule's holds and `moves` into one join
@@ -1234,7 +1385,10 @@ with or without the deck, on crossfades and hard cuts.
`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.
+ or with other values, is never reused. A teaser's `dip` is recorded the same
+ way (`"dip"`, on the segment before it).
+- **A teaser's `dip`** is a third edit made here: its sound on the segment
+ before the teaser, its picture after every overlay (the teaser section above).
### Two ffmpeg traps that are the deck's alone
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -82,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, endFadeOf, frameCount, MUTE_FADE, muteSegmentSeconds,
- playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserTitle,
+ assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, feedGeometry, feedOn, frameCount, hidesDeck, MUTE_FADE,
+ dipOf, muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserSeconds,
+ teaserTitle,
validateCutEdits, validatePosts, validateTeasers,
} from "./deck.mjs";
// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table,
@@ -232,8 +233,11 @@ export function headerFilters(render, attribPath, channelPath = null) {
*
* Pure, and exported, so the numbers can be tested without an encoder.
*/
-export function deckFraming(render) {
- const { W, H, footage: f } = deckGeometry(render);
+export function deckFraming(render, { feed = false } = {}) {
+ const { W, H, footage: deckBox } = deckGeometry(render);
+ // The posts feed's footage box stands beside its column (feedGeometry);
+ // every footage segment of a feed cut is framed there, for the whole cut.
+ const f = feed ? feedGeometry(render).footage : deckBox;
const bg = render.palette.bg;
return {
box: f,
@@ -246,12 +250,62 @@ export function deckFraming(render) {
}
/** `deckFraming`'s two runs joined: a whole picture into the box, then the frame. */
-export function deckFramingFilter(render) {
- const { fit, place } = deckFraming(render);
+export function deckFramingFilter(render, opts = {}) {
+ const { fit, place } = deckFraming(render, opts);
return [...fit, ...place].join(",");
}
/**
+ * The framing a cut's footage segments are built with under the deck -- the
+ * layout and its box -- recorded in each one's `<id>.cut.json` (`framing`) so
+ * that `--chrome-only`, which rebuilds no segment, can refuse segments framed
+ * for another layout. `feed` is feedOn's answer for the cut's WHOLE timeline.
+ */
+export function segmentFraming(render, feed = false) {
+ return { layout: feed ? "feed" : "deck", box: deckFraming(render, { feed }).box };
+}
+
+/** Is this entry's segment framed into the footage box under the deck (rather than full frame)? */
+export function framedUnderDeck(entry, render) {
+ if (entry.type === "clip" || entry.type === "image") return true;
+ return entry.type === "card" && !hidesDeck(entry, resolveDeck(render));
+}
+
+/** A framing layout as a refusal names it. */
+const layoutName = (layout) => (layout === "feed" ? "posts feed" : "deck");
+
+/**
+ * Why segments on disk cannot carry this cut's chrome, as sentences: each
+ * framed segment's record names the box it was framed into, and it is not the
+ * one this cut frames footage into (`want`, segmentFraming's). A segment with
+ * no framing record was built before records named it -- framed for the
+ * deck's box -- so it passes for the deck and fails for the feed.
+ *
+ * @param {{ entries: object[], records: Array<object|null>, render: object, want: { layout: string, box: object } }} args
+ * @returns {string[]}
+ */
+export function framingProblems({ entries, records, render, want }) {
+ const deckBox = deckGeometry(render).footage;
+ const same = (a, b) => a && b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
+ const where = (b) => `${b.width}×${b.height} at (${b.x}, ${b.y})`;
+ const wrong = [];
+ entries.forEach((e, i) => {
+ if (!framedUnderDeck(e, render)) return;
+ const got = records[i]?.framing ?? null;
+ const box = got?.box ?? deckBox;
+ if (same(box, want.box)) return;
+ const why = got ? `framed for the ${got.layout}, ${where(box)}` : `no framing record: built for the deck's ${where(box)}`;
+ wrong.push(`${e.id} (${why})`);
+ });
+ if (!wrong.length) return [];
+ return [
+ `${wrong.length} segment(s) are framed for another layout than this cut's ` +
+ `${layoutName(want.layout)} (footage ${where(want.box)}): ${wrong.join(", ")}. ` +
+ "Reframing rebuilds them -- run a normal build (with --skip-fetch it re-cuts from the cached windows), not --chrome-only.",
+ ];
+}
+
+/**
* Where a variant's own working files live.
*
* `clips-raw` stays at the ROOT and is shared: it holds the only expensive
@@ -697,7 +751,7 @@ const encodeArgsVideoOnly = (render) => [
"-movflags", "+faststart",
];
-async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, provenance) {
+async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, provenance, framing = null) {
const outDir = dirs.dir;
const { path: raw, fetchStart } = await fetchClip(entry, meta, render, dirs.rawDir, opts);
const seg = path.join(outDir, "segments", `${entry.id}.mp4`);
@@ -785,19 +839,21 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes,
// The composition is overlaid on the whole concat later; nothing here knows
// about it. Same cut, same audio map, same encode as every other segment.
if (deckOn(render)) {
+ const fr = framing ?? segmentFraming(render);
await execFileP(
FFMPEG,
[
"-nostdin", "-v", "error", "-y",
...cutArgs(raw, cutA, cutB),
- "-filter_complex", `[0:v]${deckFramingFilter(render)}[v]`,
+ "-filter_complex", `[0:v]${deckFramingFilter(render, { feed: fr.layout === "feed" })}[v]`,
"-map", "[v]", "-map", "0:a",
...encodeArgs(render),
seg,
],
{ maxBuffer: 1 << 24 },
);
- await writeCutRecord(seg, cutRecord);
+ // The box it was framed into, so --chrome-only can refuse another layout's.
+ await writeCutRecord(seg, { ...cutRecord, framing: fr });
return seg;
}
@@ -975,7 +1031,7 @@ async function qrForEntry(entry, provenance, render, outDir) {
return { png, url };
}
-async function buildCardSegment(card, render, outDir, nodes) {
+async function buildCardSegment(card, render, outDir, nodes, framing = null) {
const png = await renderCard(card, render, outDir, nodes);
const seg = path.join(outDir, "segments", `${card.id}.mp4`);
const dur = String(card.seconds);
@@ -983,8 +1039,10 @@ async function buildCardSegment(card, render, outDir, nodes) {
// deck slides away over it, and the card encodes exactly as it always has --
// or framed into the footage box like a clip, so the deck can stay up over
// it without covering its bottom rows.
- const vf = deckOn(render) && resolveDeck(render).overCards === "show"
- ? deckFramingFilter(render)
+ const framed = deckOn(render) && resolveDeck(render).overCards === "show";
+ const fr = framed ? framing ?? segmentFraming(render) : null;
+ const vf = framed
+ ? deckFramingFilter(render, { feed: fr.layout === "feed" })
: `fps=${render.fps},setsar=1`;
await execFileP(
@@ -1004,6 +1062,7 @@ async function buildCardSegment(card, render, outDir, nodes) {
],
{ maxBuffer: 1 << 24 },
);
+ if (fr) await writeCutRecord(seg, { version: 1, id: card.id, framing: fr });
return seg;
}
@@ -1048,7 +1107,11 @@ const ev6 = (v) => {
* - 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
+ * low noise. A riser (a dip's, under the black) is a sub whose pitch climbs
+ * f0 → f1 over `dur` and a noise swell -- its own layer, band-passed
+ * 400 Hz–6.5 kHz, present only when there is a riser -- both rising (the sub
+ * squared, the noise cubed) to their peak at the first hit and released over
+ * `decay`. 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
@@ -1064,10 +1127,21 @@ export function teaserAudioGraph(hits, { seconds, render, level = TEASER_AUDIO.l
const boom = [];
const punch = [];
const rumble = [];
+ const whoosh = [];
for (const h of hits) {
const a = ev6(h.at);
const u = `(t-${a})`;
const g = ev6(h.gain);
+ if (h.kind === "riser") {
+ const D = ev6(h.dur);
+ const v = `min(${u},${D})`;
+ const phase = `(${ev6(h.f0)}*${v}+${ev6((h.f1 - h.f0) / (2 * h.dur))}*${v}*${v}+${ev6(h.f1)}*(${u}-${v}))`;
+ const rel = `clip((${ev6(h.dur + h.decay)}-${u})/${ev6(h.decay)},0,1)`;
+ const span = ev6(h.dur + h.decay);
+ boom.push(`if(between(t,${a},${a}+${span}),${g}*0.5*pow(${v}/${D},2)*${rel}*sin(2*PI*${phase}),0)`);
+ whoosh.push(`if(between(t,${a},${a}+${span}),${g}*0.6*pow(${v}/${D},3)*${rel}*${noise},0)`);
+ continue;
+ }
if (h.kind === "swell") {
const D = h.dur;
const peak = ev6(D * 0.75);
@@ -1090,11 +1164,14 @@ export function teaserAudioGraph(hits, { seconds, render, level = TEASER_AUDIO.l
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}`;
+ // The riser's layer only when there is one: a teaser without a dip writes the graph it always did.
+ const w = whoosh.length ? [`${src(whoosh)},highpass=f=400,lowpass=f=6500[tw]`] : [];
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,` +
+ ...w,
+ `[tb][tp][tr]${w.length ? "[tw]" : ""}amix=inputs=${3 + w.length}: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(";");
}
@@ -1132,22 +1209,25 @@ export const teaserSegmentKey = (framesKey, audioGraph, render) =>
/**
* 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).
+ * `transition` is the cut's crossfade as this build plays it (0 under
+ * `--no-xfade`): a dip's lead is that dissolve and the black, in the page and
+ * in the sound alike. Dynamic import: compose-chrome imports this file, and
+ * its page module must not reach umtool's bundle through the build (docs/quirks.md).
*/
-async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant }) {
+export async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant, transition = render.transition ?? 0.5 }) {
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",
+ fps: render.fps, workers: 4, quality: "high", format: "png-sequence", transition,
});
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 seconds = teaserSeconds(entry, transition, render.fps);
+ const hits = teaserHits(entry, transition, render.fps);
+ const audio = teaserAudioGraph(hits, { seconds, render });
const key = teaserSegmentKey(r.key, audio, render);
const seg = path.join(outDir, "segments", `${entry.id}.mp4`);
const recPath = teaserRecordPath(seg);
@@ -1157,7 +1237,9 @@ async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant
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");
+ // `transition` is the cut's as built (0 under --no-xfade): a dip's lead
+ // counts it, and verify-build reads it back from here.
+ await writeFile(recPath, JSON.stringify({ key, frames: r.key, hits: hits.length, transition }) + "\n", "utf8");
return seg;
}
@@ -1181,7 +1263,7 @@ async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant
// * It reserves the footer's rows but draws nothing in them. The marker's
// position is a function of `section`, which a still does not have, and a
// timeline strip whose marker vanishes for six seconds reads as a bug.
-async function buildImageSegment(entry, render, outDir, chrome, provenance, baseDir) {
+async function buildImageSegment(entry, render, outDir, chrome, provenance, baseDir, framing = null) {
const seg = path.join(outDir, "segments", `${entry.id}.mp4`);
const pal = render.palette;
const { width, height } = render;
@@ -1218,7 +1300,8 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
// Under the deck the picture area is the deck's footage box -- the box a clip
// is framed into, so a still between two clips does not move either -- and
// there is no header, footer or corner code: the deck carries all three.
- const deck = deckOn(render) ? deckFraming(render) : null;
+ const fr = deckOn(render) ? framing ?? segmentFraming(render) : null;
+ const deck = fr ? deckFraming(render, { feed: fr.layout === "feed" }) : null;
const HH = render.headerHeight ?? 56;
const VW = deck ? deck.box.width : contentWidth(render);
const FH = chrome.footerHeight;
@@ -1390,6 +1473,8 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
const what = resolved.map((r) => r.src).join(", ");
throw new Error(`${entry.id}: ffmpeg failed on ${what}\n${detail}`);
}
+ // Under the deck, the box it was framed into (see segmentFraming).
+ if (fr) await writeCutRecord(seg, { version: 1, id: entry.id, framing: fr });
return seg;
}
@@ -1914,7 +1999,9 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
// (snapWindow), and nothing past the main's end is drawn: the deck's
// `shortest=1` overlay ahead of it already ends the stream there. Same
// frame-kind trap as the deck, same two guards.
- const deck = r.name === "deck";
+ // The posts feed (`name: "feed"`) is a whole-cut sequence like the deck's,
+ // laid exactly as the deck's is.
+ const deck = r.name === "deck" || r.name === "feed";
const posts = r.name === "posts";
inputs.push(
...(deck || posts ? ["-reinit_filter", "0"] : []),
@@ -1974,6 +2061,11 @@ export function postsRegions(render, outDir, schedule, { shift = 0, clip = null
}));
}
+/** The posts feed as an overlay region: its frames at feedGeometry's column. */
+export function feedRegion(render, frames) {
+ return { name: "feed", frames, ...feedGeometry(render).column };
+}
+
/**
* Where each rendered chrome region sits in the frame.
*
@@ -2448,9 +2540,10 @@ export const endFadeAudioFilter = (fade, render) => {
export function joinInputChain(i, join, render) {
if (!join) return { parts: [], v: `[${i}:v]`, a: `[${i}:a]` };
const parts = [];
- // 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.
+ // Picture: hold, move, end fade. Sound: mute, hold, end fade, dip -- the
+ // mute is in the clip's own clock and the hold is silence anyway; the end
+ // fade (the last segment) and the dip (one before a teaser that dips) are
+ // last, 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,
@@ -2462,6 +2555,9 @@ export function joinInputChain(i, join, render) {
join.mute != null ? muteAudioFilter(join.mute) : null,
join.hold > 0 ? holdAudioFilter(join.hold) : null,
join.fade ? endFadeAudioFilter(join.fade, render) : null,
+ // A dip into the next segment: the sound's half, the end fade's arithmetic.
+ // Its picture is the whole frame's, made after the overlays (dipWindows).
+ join.dip ? endFadeAudioFilter(join.dip, render) : null,
].filter(Boolean);
const a = af.length ? `[j${i}a]` : `[${i}:a]`;
if (af.length) parts.push(`[${i}:a]${af.join(",")}${a}`);
@@ -2470,25 +2566,29 @@ export function joinInputChain(i, join, render) {
/**
* 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.
+ * index → segment seconds), the end fade on the LAST segment
+ * (`fade`: `{ seconds, lastFrame }`) and a dip on the segment BEFORE a teaser
+ * that dips (`dips`: segment index → `{ seconds, lastFrame, black }`). A join
+ * gains `mute` / `fade` / `dip` only when it has one, so a cut without any
+ * 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;
+export function withCutEdits(joins, n, { mutes = new Map(), fade = null, dips = new Map() } = {}) {
+ if (!mutes.size && !fade && !dips.size) 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 };
+ for (const [i, dip] of dips) out[i] = { hold: 0, move: null, ...(out[i] ?? {}), dip };
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.
+ * segment's clock through the segment's cut record, `render.endFade` on
+ * the last segment, and each teaser's `dip` on the segment before it. 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;
@@ -2511,15 +2611,90 @@ export async function cutJoins({ schedule = null, entries, segments, render }) {
});
mutes.set(i, m.at);
}
+ // A segment's frames in the cut, its hold's clones included.
+ const cutFrames = async (i) => Math.round((await probeDuration(segments[i], render.fps)) * render.fps) +
+ Math.round((base?.[i]?.hold ?? 0) * render.fps);
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 };
+ fade = { seconds, lastFrame: (await cutFrames(last)) - 1 };
+ }
+ // A teaser's dip fades the segment before it, over its last `fade` seconds.
+ const dips = new Map();
+ for (let i = 1; i < entries.length && i < segments.length; i += 1) {
+ const dip = dipOf(entries[i], render.fps);
+ if (!dip) continue;
+ dips.set(i - 1, { seconds: dip.fade, lastFrame: (await cutFrames(i - 1)) - 1, black: dip.black });
}
- return withCutEdits(base, segments.length, { mutes, fade });
+ return withCutEdits(base, segments.length, { mutes, fade, dips });
+}
+
+/**
+ * Where the cut dips to black, from its joins (`withCutEdits`' `dip`s) and its
+ * segments' lengths in the cut (`cutOffsets`' `durs`, holds included), in the
+ * cut's FRAMES: `s` the last frame untouched, `last` the dipped segment's last
+ * frame -- black, as the end fade's last is bg -- and `until` the first frame
+ * NOT held black: the dissolve's end plus `black`. Frame f in (s, last] is
+ * (f − s)/(last − s) of the way to black; (last, until) is black. Null with
+ * no dip, so every graph is the one it always was.
+ *
+ * The fade's frames are the end fade's (`endFadeFrames`: clamped to the
+ * segment), so the picture and the sound -- `endFadeAudioFilter` on the same
+ * join -- end together; the picture is `dipVideoFilter`'s. The teaser after it starts its dissolve inside the
+ * fade, black (its lead, `teaserLead`), and stays black past `until`.
+ *
+ * @returns {Array<{ segment: number, s: number, last: number, until: number }> | null}
+ */
+export function dipWindows(joins, durs, D, fps) {
+ if (!joins?.some((j) => j?.dip)) return null;
+ const { starts } = scheduleFrom(durs, D);
+ const out = [];
+ joins.forEach((j, i) => {
+ if (!j?.dip) return;
+ const last = Math.round(starts[i] * fps) + j.dip.lastFrame;
+ const s = last - endFadeFrames(j.dip, fps);
+ out.push({ segment: i, s, last, until: last + 1 + Math.round(j.dip.black * fps) });
+ });
+ return out;
+}
+
+/**
+ * The dips as one filter chain on the FINISHED picture -- after every overlay
+ * (deck, feed, posts, rail), so the whole frame goes to black, not the footage
+ * under a lit panel. `fade` out to BLACK, which ffmpeg does in the stream's
+ * own yuv420p (luma to 16, chroma to 128; only a COLOURED fade needs RGB, the
+ * end fade's reason for `geq`), slice-threaded -- about 25× faster than the
+ * same blend in `geq` at 1080p. It runs from frame `s` (time s/fps) over
+ * (last − s) frames, so `last` is black, and once done it writes black; its
+ * `enable` window is (s, until) by half a frame each side, so every frame
+ * before the fade and from `until` on passes untouched. `shift` is the second
+ * the stream's own clock starts at in the cut's (a preview's window); a
+ * window whose fade began before that is the same blend in `geq`.
+ *
+ * @returns {string|null} a `fade,…` chain, or null with no dips
+ */
+export function dipVideoFilter(windows, fps, shift = 0) {
+ if (!windows?.length) return null;
+ const n6 = (v) => (Math.round(v * 1e6) / 1e6).toFixed(6).replace(/\.?0+$/, "") || "0";
+ return windows.map((w) => {
+ const st = w.s / fps - shift;
+ const d = (w.last - w.s) / fps;
+ const on = `enable='between(t,${n6((w.s + 0.5) / fps - shift)},${n6((w.until - 0.5) / fps - shift)})'`;
+ if (st >= 0) return `fade=t=out:st=${n6(st)}:d=${n6(d)}:${on}`;
+ // A preview that starts inside the fade: `fade` cannot start before its
+ // stream does, so the same blend in `geq` (a few seconds of preview, where
+ // its cost does not matter).
+ const k = `clip((T-(${n6(st)}))/${n6(d)},0,1)`;
+ const plane = (p, c) => `'${p}(X,Y)+(${c}-${p}(X,Y))*${k}+0.5'`;
+ return `geq=lum=${plane("lum", 16)}:cb=${plane("cb", 128)}:cr=${plane("cr", 128)}:${on}`;
+ }).join(",");
+}
+
+/** `inLabel` through the dips to `outLabel`, as graph parts: none when there are no dips. */
+export function dipParts(inLabel, windows, fps, { shift = 0, outLabel = "[vdip]" } = {}) {
+ const f = dipVideoFilter(windows, fps, shift);
+ return f ? { parts: [`${inLabel}${f}${outLabel}`], label: outLabel } : { parts: [], label: inLabel };
}
/**
@@ -2562,8 +2737,16 @@ export function xfadeGraph(durs, D, joins = null, render = null) {
let acc = durs[0];
for (let i = 1; i < durs.length; i += 1) {
const off = acc - D;
- 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}]`);
+ // Into a teaser that dips, the outgoing segment plays the whole overlap
+ // untouched -- picture (`expr='A'`) and sound (`nofade`) -- and the
+ // teaser takes over at its end, under the dip's black. A dissolve there
+ // would blend the footage with the teaser's black lead and darken it
+ // faster than the deck and feed drawn over it, which only the dip fades.
+ const dip = !!joins?.[i - 1]?.dip;
+ const vx = dip ? "transition=custom:expr='A'" : "transition=fade";
+ const ax = dip ? "c1=nofade:c2=nofade" : "c1=tri:c2=tri";
+ parts.push(`${vlab}${ins[i].v}xfade=${vx}:duration=${D}:offset=${off.toFixed(3)}[v${i}]`);
+ parts.push(`${alab}${ins[i].a}acrossfade=d=${D}:${ax}[a${i}]`);
vlab = `[v${i}]`;
alab = `[a${i}]`;
acc = acc + durs[i] - D;
@@ -2575,8 +2758,10 @@ export function xfadeGraph(durs, D, joins = null, render = null) {
* 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.
+ * `dips` (`dipWindows`) go on the joined picture when this file IS the cut --
+ * nothing is laid over it after; a base for an overlay pass leaves them to it.
*/
-export function hardCutFilterArgs(segments, joins, render, outPath) {
+export function hardCutFilterArgs(segments, joins, render, outPath, dips = null) {
const parts = [];
const pairs = segments.map((_, i) => {
const c = joinInputChain(i, joins?.[i] ?? null, render);
@@ -2584,11 +2769,13 @@ export function hardCutFilterArgs(segments, joins, render, outPath) {
return `${c.v}${c.a}`;
});
parts.push(`${pairs.join("")}concat=n=${segments.length}:v=1:a=1[vc][ac]`);
+ const dp = dipParts("[vc]", dips, render.fps);
+ parts.push(...dp.parts);
return [
"-nostdin", "-v", "error", "-y",
...segments.flatMap((s) => ["-i", s]),
"-filter_complex", parts.join(";"),
- "-map", "[vc]", "-map", "[ac]",
+ "-map", dp.label, "-map", "[ac]",
...encodeArgs(render),
outPath,
];
@@ -2599,10 +2786,19 @@ export function hardCutFilterArgs(segments, joins, render, outPath) {
// 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) {
+async function concatWithXfade(segments, render, outPath, railPlan, chrome = null, joins = null, { dip = true } = {}) {
const D = render.transition ?? 0.5;
const { durs } = await cutOffsets(segments, D, render.fps, joins);
+ await execFileP(FFMPEG, xfadeConcatArgs({ segments, durs, render, outPath, railPlan, chrome, joins, dip }), { maxBuffer: 1 << 26 });
+}
+/**
+ * concatWithXfade's ffmpeg argv, from the cut's segment lengths (`durs`,
+ * `cutOffsets`'): the crossfades, the chrome, the rail, then the dips over all
+ * of it. Pure, so a test can run the very graph the build does.
+ */
+export function xfadeConcatArgs({ segments, durs, render, outPath, railPlan = null, chrome = null, joins = null, dip = true }) {
+ const D = render.transition ?? 0.5;
const inputs = segments.flatMap((s) => ["-i", s]);
const { parts, vlab, alab } = xfadeGraph(durs, D, joins, render);
@@ -2632,22 +2828,22 @@ async function concatWithXfade(segments, render, outPath, railPlan, chrome = nul
if (hf) parts.push(hf.chain);
if (rc) parts.push(rc.chain);
- const tail = rc ? rc.outLabel : hf ? hf.outLabel : vlab;
+ // The dips last, over everything drawn: the whole frame goes to black.
+ // (`dip: false` -- a base the rail is laid over later, which dips then.)
+ const dp = dipParts(rc ? rc.outLabel : hf ? hf.outLabel : vlab, dip ? dipWindows(joins, durs, D, render.fps) : null, render.fps);
+ parts.push(...dp.parts);
+ const tail = dp.label;
- await execFileP(
- FFMPEG,
- [
- "-nostdin", "-v", "error", "-y",
- ...inputs,
- ...(rc ? rc.inputs : []),
- ...(hf ? hf.inputs : []),
- "-filter_complex", parts.join(";"),
- "-map", tail, "-map", alab,
- ...encodeArgs(render),
- outPath,
- ],
- { maxBuffer: 1 << 26 },
- );
+ return [
+ "-nostdin", "-v", "error", "-y",
+ ...inputs,
+ ...(rc ? rc.inputs : []),
+ ...(hf ? hf.inputs : []),
+ "-filter_complex", parts.join(";"),
+ "-map", tail, "-map", alab,
+ ...encodeArgs(render),
+ outPath,
+ ];
}
/**
@@ -2658,7 +2854,7 @@ async function concatWithXfade(segments, render, outPath, railPlan, chrome = nul
* concatHardCut is `-c copy` and a stream-copy mux cannot host a filtergraph
* at all.
*/
-async function applyRail(inPath, outPath, render, railPlan, preview) {
+async function applyRail(inPath, outPath, render, railPlan, preview, dips = null) {
const rc = railFilterChain(
render.rail, railPlan.assets, railPlan.times, render,
preview ? "[base]" : "[0:v]", 1, railPlan.total + 2,
@@ -2673,8 +2869,11 @@ async function applyRail(inPath, outPath, render, railPlan, preview) {
parts.push(`[0:v]setpts=PTS+${preview.start.toFixed(3)}/TB[base]`);
}
parts.push(rc.chain);
- const tail = preview ? "[vshift]" : rc.outLabel;
- if (preview) parts.push(`${rc.outLabel}setpts=PTS-STARTPTS[vshift]`);
+ // The dips over the rail, in the cut's clock (a preview's is shifted back to it above).
+ const dp = dipParts(rc.outLabel, dips, render.fps);
+ parts.push(...dp.parts);
+ const tail = preview ? "[vshift]" : dp.label;
+ if (preview) parts.push(`${dp.label}setpts=PTS-STARTPTS[vshift]`);
await execFileP(
FFMPEG,
@@ -2713,15 +2912,17 @@ async function applyRail(inPath, outPath, render, railPlan, preview) {
* band keeps its refusal of `transition: 0`, so nothing that reaches this
* function draws one.
*/
-export function applyChromeArgs(inPath, outPath, render, chromePlan, preview = null) {
+export function applyChromeArgs(inPath, outPath, render, chromePlan, preview = null, dips = null) {
const hf = chromeOverlayChain(render, chromePlan.regions, "[0:v]", 1, { final: true });
+ // The dips over the overlay: the whole frame, the panel with the footage.
+ const dp = dipParts(hf.outLabel, dips, render.fps, { shift: preview ? Number(preview.start) : 0 });
return [
"-nostdin", "-v", "error", "-y",
...(preview ? ["-ss", String(preview.start), "-t", String(preview.dur)] : []),
"-i", inPath,
...hf.inputs,
- "-filter_complex", hf.chain,
- "-map", hf.outLabel, "-map", "0:a",
+ "-filter_complex", [hf.chain, ...dp.parts].join(";"),
+ "-map", dp.label, "-map", "0:a",
...encodeArgsVideoOnly(render),
outPath,
];
@@ -2733,8 +2934,8 @@ export function applyChromeArgs(inPath, outPath, render, chromePlan, preview = n
* applyChrome, whose point stands: the CONCAT cannot host a filtergraph, the
* pass after it always could.
*/
-async function applyChrome(inPath, outPath, render, chromePlan, preview = null) {
- await execFileP(FFMPEG, applyChromeArgs(inPath, outPath, render, chromePlan, preview), {
+async function applyChrome(inPath, outPath, render, chromePlan, preview = null, dips = null) {
+ await execFileP(FFMPEG, applyChromeArgs(inPath, outPath, render, chromePlan, preview, dips), {
maxBuffer: 1 << 26,
});
}
@@ -2788,12 +2989,15 @@ export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, re
parts.push(`${alab}atrim=start=${S}:duration=${T},asetpts=PTS-STARTPTS[aw]`);
const hf = chromeOverlayChain(render, chromePlan.regions, "[vw]", segs.length, { final: true });
parts.push(hf.chain);
+ // The cut's dips (its whole joins and lengths), in the window's clock.
+ const dp = dipParts(hf.outLabel, dipWindows(joins, durs, D, render.fps), render.fps, { shift: Number(at) });
+ parts.push(...dp.parts);
return [
"-nostdin", "-v", "error", "-y",
...segs.flatMap((sg) => ["-i", sg]),
...hf.inputs,
"-filter_complex", parts.join(";"),
- "-map", hf.outLabel, "-map", "[aw]",
+ "-map", dp.label, "-map", "[aw]",
...encodeArgs(render),
outPath,
];
@@ -2829,6 +3033,29 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro
});
const regions = chromeRegions(render, outDir).map((g) => ({ ...g, frames: r.frames }));
+ // The posts FEED (a schedule with `layout: "feed"`): one sequence for the
+ // whole cut -- or the same window as the deck's -- at feedGeometry's column,
+ // laid like the deck's. postWindows has none for a feed, so the loop below
+ // adds nothing.
+ if (schedule.layout === "feed") {
+ EMIT("chrome", { phase: "compose", region: "feed", ...(duration != null ? { from, duration } : {}) });
+ const t1 = Date.now();
+ const f = await composeChrome({
+ manifestPath, outDir, variant, region: "feed", doRender: true,
+ fps: render.fps, workers: 4, quality: "high", format: "png-sequence",
+ ...(duration != null ? { from, duration } : {}),
+ });
+ if (f.frameCount !== want) {
+ throw new Error(`the feed's sequence is ${f.frameCount} frames but the deck's is ${want}`);
+ }
+ EMIT("chrome", {
+ phase: f.cached ? "cached" : "render", region: "feed",
+ frames: f.frameCount, key: f.key, dir: f.frames,
+ seconds: Number(((Date.now() - t1) / 1000).toFixed(1)),
+ });
+ regions.push(feedRegion(render, f.frames));
+ }
+
// Then the posts: one short sequence per clip that carries them, each with
// its own cache, laid over the deck at its own second. A preview window
// (`duration` given) takes only the windows it intersects, shifted into its
@@ -3039,13 +3266,14 @@ export const concatListText = (segments) =>
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.
+ // `mute`, `fade` and `dip` 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 } : {}),
+ ...(joins[i].dip ? { dip: joins[i].dip } : {}),
})}`]
: []));
return list + lines.join("\n") + "\n";
@@ -3056,9 +3284,9 @@ export function concatRecordText(segments, joins = null) {
* 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 } = {}) {
+async function concatHardCut(segments, outDir, outPath, { record = false, joins = null, render = null, dips = null } = {}) {
if (joins) {
- await execFileP(FFMPEG, hardCutFilterArgs(segments, joins, render, outPath), { maxBuffer: 1 << 26 });
+ await execFileP(FFMPEG, hardCutFilterArgs(segments, joins, render, outPath, dips), { maxBuffer: 1 << 26 });
if (record) await writeFile(`${outPath}.segments`, concatRecordText(segments, joins), "utf8");
return;
}
@@ -3224,6 +3452,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const entries = manifest.timeline.filter((e) => !only || e.id === only);
if (only && !entries.length) throw new Error(`no timeline entry with id ${only}`);
+ // Under the deck, the box every footage segment is framed into: the posts
+ // feed's when this cut is a feed -- decided on the cut's WHOLE timeline, so
+ // an `--only` rebuild frames a clip as the full build would.
+ const framing = deck
+ ? segmentFraming(render, feedOn({ render, posts: manifest.posts ?? [], entries: manifest.timeline ?? [] }))
+ : null;
// Read once, at the ROOT: a source's state is a fact about the manifest, not
// about a variant. A stacked ledger card says why each claim is text rather
@@ -3240,6 +3474,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const failures = [];
const D = opts.noXfade || (render.transition ?? 0.5) === 0 ? 0 : render.transition ?? 0.5;
+ // Where the cut dips to black (a teaser's `dip`), in its frames: for the
+ // passes that lay the picture's last layer over an already joined base.
+ // (The crossfade's own encode finds them from its joins.)
+ const cutDips = async (segs, joins) => (joins?.some((j) => j?.dip)
+ ? dipWindows(joins, (await cutOffsets(segs, D, render.fps, joins)).durs, D, render.fps)
+ : null);
// Retro-fit chapters onto an already-built file without re-encoding it. The
// per-clip segments are still on disk, which is all the offsets need.
@@ -3267,11 +3507,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (!(await exists(seg)))
throw new Error(`--rail-only needs ${seg}, which is missing — run a full build first`);
}
+ const joins = await cutJoins({ entries, segments: segs, render });
if (!(await exists(prerail))) {
EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length });
- const joins = await cutJoins({ entries, segments: segs, render });
+ // A base for the rail: its dips are laid with the rail, over it.
if (D === 0) await concatHardCut(segs, outDir, prerail, { joins, render });
- else await concatWithXfade(segs, render, prerail, null, null, joins);
+ else await concatWithXfade(segs, render, prerail, null, null, joins, { dip: false });
}
const railPlan = await buildRailPlan(manifest, render, entries, segs, D, outDir);
await assertConcatLength(prerail, railPlan.total, render.fps,
@@ -3279,7 +3520,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const out = opts.preview
? path.join(outDir, `${manifest.slug}.preview.mp4`)
: finalPath;
- await applyRail(prerail, out, render, railPlan, opts.preview ?? null);
+ await applyRail(prerail, out, render, railPlan, opts.preview ?? null, await cutDips(segs, joins));
if (!opts.preview) {
await assertConcatLength(out, railPlan.total, render.fps, "rail build");
// applyRail re-encodes, so the chapters muxed onto the previous final are
@@ -3301,18 +3542,29 @@ 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`);
+ const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`));
+ // Every refusal before any render: a teaser's segment is built below, so
+ // only the others must be on disk already.
+ for (const [i, seg] of segs.entries()) {
+ if (entries[i].type === "teaser") continue;
+ if (!(await exists(seg)))
+ throw new Error(`${what} needs ${seg}, which is missing — run a full build first`);
+ }
+ // Segments framed for another layout (the posts feed switched on or off
+ // since they were built) cannot take this cut's chrome: refused, by name.
+ // A teaser is full frame, so its record is not read for this.
+ {
+ const records = await Promise.all(segs.map((sg) => readCutRecord(sg)));
+ const problems = framingProblems({ entries, records, render, want: framing });
+ if (problems.length) throw new Error(`${what}: ${problems.join("; ")}`);
+ }
// 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)))
- throw new Error(`${what} needs ${seg}, which is missing — run a full build first`);
+ await buildTeaserSegment(e, { manifestPath, render, outDir, variant, transition: D });
}
const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir });
EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length });
@@ -3330,7 +3582,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
const out = path.join(outDir, `${manifest.slug}.preview.mp4`);
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 });
+ await applyChrome(prerail, out, render, plan, { start: at, dur }, await cutDips(segs, joins));
} else {
const { starts, durs } = await cutOffsets(segs, D, render.fps, joins);
EMIT("chrome", { phase: "overlay", base: "segments" });
@@ -3355,7 +3607,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
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);
+ await applyChrome(prerail, dirs.final, render, plan, null, await cutDips(segs, joins));
} else {
await concatWithXfade(segs, render, dirs.final, null, plan, joins);
}
@@ -3372,17 +3624,17 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
try {
if (entry.type === "card") {
EMIT("card", { id: entry.id, i, n: entries.length });
- segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes));
+ segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes, framing));
} else if (entry.type === "teaser") {
EMIT("card", { id: entry.id, i, n: entries.length });
- segments.push(await buildTeaserSegment(entry, { manifestPath, render, outDir, variant }));
+ segments.push(await buildTeaserSegment(entry, { manifestPath, render, outDir, variant, transition: D }));
} 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
// third word there would show as an unknown step rather than as work.
EMIT("card", { id: entry.id, i, n: entries.length });
segments.push(
- await buildImageSegment(entry, render, outDir, chrome, provenance, manifestDir),
+ await buildImageSegment(entry, render, outDir, chrome, provenance, manifestDir, framing),
);
} else if (entry.type === "scroll" || entry.type === "chart" || entry.type === "ledger") {
EMIT("card", { id: entry.id, i, n: entries.length });
@@ -3403,7 +3655,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
});
segments.push(
await buildClipSegment(
- entry, meta, render, dirs, opts, chrome, manifest.timelineNodes, provenance,
+ entry, meta, render, dirs, opts, chrome, manifest.timelineNodes, provenance, framing,
),
);
}
@@ -3495,16 +3747,16 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (railPlan) {
await concatHardCut(segments, outDir, prerail, { joins, render });
await assertConcatLength(prerail, railPlan.total, render.fps, "hard-cut concat");
- await applyRail(prerail, final, render, railPlan, null);
+ await applyRail(prerail, final, render, railPlan, null, await cutDips(segments, joins));
} 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, joins, render });
await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat");
- await applyChrome(prerail, final, render, chromePlan, null);
+ await applyChrome(prerail, final, render, chromePlan, null, await cutDips(segments, joins));
} else {
- await concatHardCut(segments, outDir, final, { joins, render });
+ await concatHardCut(segments, outDir, final, { joins, render, dips: await cutDips(segments, joins) });
}
} else {
await concatWithXfade(segments, render, final, railPlan, chromePlan, joins);
diff --git a/umtool/report-to-video/chrome-deck.mjs b/umtool/report-to-video/chrome-deck.mjs
@@ -37,7 +37,7 @@
// second clock or a label.
import { fileURLToPath } from "node:url";
-import { deckChoreography, deckLayout, pipSegments, pipXs, resolveDeck } from "./deck.mjs";
+import { deckChoreography, deckLayout, pageDuration, pipSegments, pipXs, resolveDeck } from "./deck.mjs";
/**
* GSAP, vendored, for every chrome region.
@@ -474,9 +474,9 @@ export function deckHtml(schedule, render, opts = {}) {
</style>
</head>
<body>
- <div id="root" data-composition-id="deck" data-start="0" data-duration="${r4(dur)}"
+ <div id="root" data-composition-id="deck" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}"
data-width="${W}" data-height="${H}">
- <div id="deck-clip" class="clip" data-start="0" data-duration="${r4(dur)}" data-track-index="1">
+ <div id="deck-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}" data-track-index="1">
<div class="panel" data-k="panel">
<div class="ground"></div>
${panel && qr ? `<div class="plate"></div>` : ""}
diff --git a/umtool/report-to-video/chrome-feed.mjs b/umtool/report-to-video/chrome-feed.mjs
@@ -0,0 +1,501 @@
+// The posts FEED's composition (`posts.layout: "feed"`): one HyperFrames page
+// for the whole cut -- a column of posts beside the footage, standing on the
+// deck, so the two make an L around the picture.
+//
+// PURE, like chrome-deck.mjs and chrome-posts.mjs: a schedule and a render
+// block in, an HTML string out. compose-chrome.mjs copies the assets in beside
+// it, writes it and renders it; nothing here touches a file.
+//
+// ---------------------------------------------------------------------------
+// What the column does
+// ---------------------------------------------------------------------------
+// Before the first post it shows its header (who posted, on what) and an
+// empty state. Each post TICKS IN at its `in` (deck.mjs postSchedule: its
+// clip's start + D, the transition's length -- the first clip's too, which
+// has no dissolve into it; several on one clip a `posts.seconds` apart): it lands at the TOP, newest first, as a timeline
+// reads, and every card already in slides DOWN by its height. The newest
+// wears the highlight -- an accent flare that settles to a lit rail and rim --
+// until the next one takes it, then rests. A card that no longer fits the
+// column fades out as the stack pushes it past the bottom: the oldest scroll
+// out. Over a segment the deck hides for (a full-frame card, the teaser) the
+// whole column slides off the frame's side edge with it, and back after.
+//
+// The cut is never paused for it, and nothing moves the footage: the feed's
+// footage box is fixed for the whole cut (deck.mjs feedGeometry).
+//
+// ---------------------------------------------------------------------------
+// Why the stack is planned in the page, by a function that lives here
+// ---------------------------------------------------------------------------
+// How far a card pushes the others, and which ones fall out of the column,
+// depend on how tall each card is -- how its words wrap in the deck's own
+// face. So the page measures every card once the faces are in and hands the
+// heights to `feedCues`, THIS module's function written into the page under a
+// fixed name (`embedFn`, docs/quirks.md); the tests call the same function
+// with heights of their choosing. Every cue is a fromTo whose FROM is stated:
+// a render is a seek per frame, from parallel workers, in any order.
+import { deckChoreography, feedGeometry, pageDuration, resolveDeck } from "./deck.mjs";
+import { mix, rgba } from "./chrome-deck.mjs";
+import { embedFn, PLATFORM_LABEL, postDate, postParagraphs, postWho } from "./chrome-posts.mjs";
+
+const esc = (s) =>
+ String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+const r4 = (v) => Math.round(v * 10000) / 10000;
+
+/**
+ * The feed's motion, in seconds (and the gap between cards, in px). A new
+ * card starts entering `lag` after its `in`, from the column's outer edge,
+ * over `enter`; the cards below are pushed down over `push` from the `in`
+ * itself, front-loaded, so the room is open (≈ 90 %) before the card comes
+ * in over it -- the two never overlap on screen. Its highlight flares `glowAt`
+ * into the entrance over `glowUp`, settles to `newest` over `glowDown`, and
+ * goes out over `calm` when the next post arrives. A card pushed past the
+ * column's bottom fades over `leave`. The empty state fades over `emptyOut`.
+ */
+export const FEED_MOTION = Object.freeze({
+ gap: 16, push: 0.4, lag: 0.22, enter: 0.6, glowAt: 0.2, glowUp: 0.2, glowDown: 1.2, newest: 0.55, calm: 0.8,
+ leave: 0.45, emptyOut: 0.35,
+});
+
+/**
+ * The column's inner layout, region-local px: the padding, the header's rows
+ * and the stack area under it (`stack`: where cards are, and its height --
+ * what `feedCues` fits them to). The card's own sizes: its text, meta and QR
+ * cell.
+ */
+export function feedLayout(render) {
+ const g = feedGeometry(render);
+ const p = resolveDeck(render).posts;
+ const W = g.column.width, H = g.column.height;
+ const padX = 22;
+ const headerTop = 26;
+ const headerH = 66;
+ const ruleY = headerTop + headerH + 10;
+ const stackY = ruleY + 18;
+ const bottom = 22;
+ const textSize = 24;
+ return {
+ width: W,
+ height: H,
+ padX,
+ header: { top: headerTop, height: headerH, ruleY },
+ stack: { x: padX, y: stackY, width: W - 2 * padX, height: H - stackY - bottom },
+ card: {
+ rail: 6, pad: 16, qrSize: p.qrSize, plate: p.qrSize + 28, metaSize: 18,
+ textSize, lineH: Math.round(textSize * 1.36), maxLines: p.maxLines,
+ },
+ };
+}
+
+/**
+ * Everything the feed's timeline does, as data. PURE and SELF-CONTAINED: the
+ * page carries this function's own source text (`embedFn`) and calls it with
+ * the heights it measured, so it may reference nothing outside its own body.
+ *
+ * `posts` are `[{ id, in }]` oldest first, `in` in the CUT's clock; `heights`
+ * the cards' heights in px; `column` the stack area's height. `visibility` is
+ * deckChoreography's (`[{ hide, at: [a, b] }]`): the column slides `slideX` px
+ * sideways out of the frame with the deck and back. `enterX` is where a card
+ * enters from (the column's outer edge).
+ *
+ * Card j of n sits at y = Σ (height + gap) of the cards newer than it that
+ * are in; it is `c<j>` (autoAlpha, x, y), its highlight `h<j>` (opacity), the
+ * count `n<k>` (k posts in; autoAlpha), the empty state `empty`, the whole
+ * column `col` (x).
+ *
+ * @returns {{ init: Record<string, object>, gone: Array<number|null>,
+ * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }}
+ */
+export function feedCues({
+ posts, heights, column, visibility = [], startsHidden = false, slideX = 600, enterX = 600,
+ gap = 16, push = 0.4, lag = 0.22, enter = 0.6, glowAt = 0.2, glowUp = 0.2, glowDown = 1.2, newest = 0.55,
+ calm = 0.8, leave = 0.45, emptyOut = 0.35,
+}) {
+ const R = (v) => Math.round(v * 10000) / 10000;
+ const MIN = 0.001;
+ const n = posts.length;
+ const init = { col: { x: startsHidden ? slideX : 0 }, empty: { autoAlpha: 1 } };
+ for (let j = 0; j < n; j += 1) {
+ init[`c${j}`] = { autoAlpha: 0, x: enterX, y: 0 };
+ init[`h${j}`] = { opacity: 0 };
+ }
+ for (let k = 0; k <= n; k += 1) init[`n${k}`] = { autoAlpha: k === 0 ? 1 : 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 });
+ const y = new Array(n).fill(0);
+ const visible = [];
+ const gone = new Array(n).fill(null);
+ for (let m = 0; m < n; m += 1) {
+ const t = posts[m].in;
+ const id = posts[m].id;
+ const room = (heights[m] || 0) + gap;
+ // Room at the top: every card in moves down by the new one's height, and
+ // the oldest that no longer fit fade as they are pushed out of the column
+ // (one cue each, so the fade rides the push).
+ for (let v = visible.length - 1; v >= 0; v -= 1) {
+ const j = visible[v];
+ y[j] += room;
+ if (y[j] + (heights[j] || 0) <= column) {
+ add(`c${j}`, t, push, { y: y[j] }, "power3.out", `push for ${id}`);
+ continue;
+ }
+ add(`c${j}`, t, Math.max(push, leave), { y: y[j], autoAlpha: 0 }, "power2.out", `out for ${id}`);
+ gone[j] = t;
+ visible.splice(v, 1);
+ }
+ // The newest hands its highlight on.
+ if (m > 0 && gone[m - 1] === null) add(`h${m - 1}`, t, calm, { opacity: 0 }, "power1.out", `calm for ${id}`);
+ if (m === 0) add("empty", t, emptyOut, { autoAlpha: 0 }, "power1.out", `first ${id}`);
+ add(`n${m}`, t + lag, MIN, { autoAlpha: 0 }, "none", `count ${id}`);
+ add(`n${m + 1}`, t + lag, MIN, { autoAlpha: 1 }, "none", `count ${id}`);
+ add(`c${m}`, t + lag, enter, { autoAlpha: 1, x: 0 }, "expo.out", `enter ${id}`);
+ add(`h${m}`, t + lag + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${id}`);
+ add(`h${m}`, t + lag + glowAt + glowUp, glowDown, { opacity: newest }, "power2.inOut", `settle ${id}`);
+ visible.unshift(m);
+ }
+ for (const v of visibility) {
+ if (v.at[1] - v.at[0] <= 0 && v.i === 0) continue; // hidden from the first frame: that is init
+ add("col", v.at[0], v.at[1] - v.at[0], { x: v.hide ? slideX : 0 }, v.hide ? "power2.in" : "power3.out",
+ `${v.hide ? "hide" : "show"} @${v.i}`);
+ }
+
+ // Order, clamp (a cue never starts before the last one on its element has
+ // ended), and state every from.
+ ev.forEach((e, i) => { e.n = i; });
+ ev.sort((a, b) => a.at - b.at || a.n - b.n);
+ const state = {};
+ for (const k of Object.keys(init)) state[k] = { ...init[k] };
+ const freeAt = {};
+ const cues = [];
+ for (const e of ev) {
+ let { at, dur } = e;
+ const free = freeAt[e.k] ?? -Infinity;
+ if (at < free) {
+ const end = at + dur;
+ at = R(free);
+ dur = R(Math.max(MIN, end - at));
+ }
+ const cur = state[e.k] ?? (state[e.k] = {});
+ const from = {};
+ for (const q of Object.keys(e.to)) from[q] = cur[q];
+ Object.assign(cur, e.to);
+ freeAt[e.k] = R(at + dur);
+ cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why, i: cues.length });
+ }
+ cues.sort((a, b) => a.at - b.at || a.i - b.i);
+ for (const c of cues) delete c.i;
+ return { init, gone, cues };
+}
+
+/**
+ * Who the feed is, for its header: the one author's name and platform when
+ * every post is theirs, else "Posts" and every platform there is.
+ */
+export function feedWho(posts) {
+ const names = [...new Set(posts.map((p) => postWho(p).name).filter(Boolean))];
+ const platforms = [...new Set(posts.map((p) => PLATFORM_LABEL[p.platform] ?? String(p.platform ?? "")).filter(Boolean))];
+ const single = names.length === 1 && platforms.length === 1;
+ return { title: single ? names[0] : "Posts", platforms, single };
+}
+
+/**
+ * The feed composition's HTML, for the whole cut -- or a window of it
+ * (`from`/`duration`, a `--chrome-preview`): the root then declares
+ * `duration` seconds and the timeline plays the cut's [from, from + duration].
+ *
+ * `fonts` = `{ regular, bold }` asset-relative paths (DeckSans / DeckSansBold),
+ * `qrSrcs` = `{ [postId]: "assets/qrNN.png" }`, `gsap` = the vendored script.
+ * The region is `feedGeometry(render).column`, region-local, transparent
+ * outside the panel. `?still=<t>` and the preview's `deck:seek` take CUT seconds.
+ */
+export function feedHtml(schedule, render, opts = {}) {
+ if (schedule.layout !== "feed") throw new Error("feed: the schedule is not a feed's (layout \"feed\")");
+ const deck = resolveDeck(render);
+ const lay = feedLayout(render);
+ const g = feedGeometry(render);
+ const pal = render.palette;
+ const W = lay.width, H = lay.height;
+ const fonts = opts.fonts ?? {};
+ const qrSrcs = opts.qrSrcs ?? {};
+ const gsapSrc = opts.gsap ?? "assets/gsap.min.js";
+ const total = schedule.total;
+ const from = Number(opts.from ?? 0);
+ const dur = opts.duration != null ? r4(Number(opts.duration)) : r4(total - from);
+ if (!(dur > 0)) throw new Error(`feed: nothing to render from ${from}s of a ${total}s cut`);
+ const windowed = from > 0 || Math.abs(dur - total) > 1e-6;
+ const posts = [...(schedule.posts ?? [])].sort((a, b) => a.in - b.in || a.slot - b.slot);
+ if (!posts.length) throw new Error("feed: the schedule places no posts");
+ const left = deck.posts.position === "top-left";
+ const c = lay.card;
+ const who = feedWho(posts);
+ const panel = deck.background === "panel";
+
+ // The column's ground meets the deck's top edge in the deck's own colour,
+ // so the two read as one surface; the cards stand a step up from it.
+ const deckTop = panel ? mix(pal.bg, pal.fg, 0.095) : pal.bg;
+ const groundTop = panel ? mix(pal.bg, pal.fg, 0.05) : pal.bg;
+ const cardTop = mix(mix(pal.bg, pal.fg, 0.14), pal.accent, 0.06);
+ const cardBottom = mix(mix(pal.bg, pal.fg, 0.11), pal.accent, 0.03);
+
+ const cardHtml = posts
+ .map((p, j) => {
+ const { name, platform } = postWho(p);
+ const src = qrSrcs[p.id];
+ return (
+ `<article class="post" data-post="${esc(p.id)}" data-k="c${j}">` +
+ `<div class="body">` +
+ `<div class="meta">` +
+ (who.single ? "" : (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="${c.qrSize}" height="${c.qrSize}" alt="">` : "") +
+ `</div>` +
+ `<div class="hl" data-k="h${j}"></div>` +
+ `</article>`
+ );
+ })
+ .join("\n ");
+ const countHtml = Array.from({ length: posts.length + 1 }, (_, k) =>
+ `<span class="n" data-k="n${k}">${k} of ${posts.length}</span>`).join("");
+
+ const vis = deckChoreography(schedule, render).visibility;
+ const data = {
+ total: r4(total),
+ window: windowed ? { from: r4(from), dur } : null,
+ column: lay.stack.height,
+ maxLines: c.maxLines,
+ lineH: c.lineH,
+ textSize: c.textSize,
+ metaSize: c.metaSize,
+ startsHidden: !!schedule.segments?.[0]?.hideDeck,
+ // Off the frame's own side edge, a little past it.
+ slideX: left ? -(W + 24) : W + 24,
+ enterX: left ? -(lay.stack.width + lay.padX) : lay.stack.width + lay.padX,
+ visibility: vis.map((v) => ({ i: v.i, hide: v.hide, at: v.at.map(r4) })),
+ motion: FEED_MOTION,
+ ids: posts.map((p) => p.id),
+ posts: posts.map((p) => ({ id: p.id, in: p.in })),
+ };
+ // `</script>` inside a JSON string would close the tag; an id is the manifest's.
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
+
+ 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>
+ /* The deck's faces, under the deck's private names -- see docs/quirks.md. */
+ @font-face { font-family: 'DeckSans'; font-weight: 400; font-style: normal;
+ src: url('${esc(fonts.regular ?? "")}'); }
+ @font-face { font-family: 'DeckSansBold'; font-weight: 400; font-style: normal;
+ src: url('${esc(fonts.bold ?? "")}'); }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: transparent; }
+ body { font-family: 'DeckSans', 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; }
+ #feed-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ .col { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ /* The column's ground: the deck's surface carried up the side. */
+ .ground { position: absolute; inset: 0;
+ background: linear-gradient(180deg, ${groundTop} 0%, ${deckTop} 100%); }
+ ${panel ? `.edge { position: absolute; ${left ? "right" : "left"}: 0; top: 0; width: 1px; height: ${H}px;
+ background: linear-gradient(0deg, ${rgba(pal.accent, 0.8)} 0px, ${rgba(pal.accent, 0.3)} ${Math.round(H * 0.16)}px,
+ ${rgba(pal.fg, 0.14)} ${Math.round(H * 0.4)}px, ${rgba(pal.fg, 0.14)} ${H}px); }` : ""}
+ .head { position: absolute; left: ${lay.padX}px; top: ${lay.header.top}px; width: ${W - 2 * lay.padX}px;
+ height: ${lay.header.height}px; }
+ .who-row { display: flex; align-items: center; gap: 12px; height: 36px; white-space: nowrap; }
+ .platform { flex: none; font-family: 'DeckSansBold', sans-serif; font-size: 16px; line-height: 23px;
+ letter-spacing: 0.03em; color: ${pal.fg}; padding: 1px 11px 2px; border-radius: 999px;
+ background: ${rgba(pal.accent, 0.26)}; box-shadow: inset 0 0 0 1px ${rgba(pal.accent, 0.7)}; }
+ .title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
+ font-family: 'DeckSansBold', sans-serif; font-size: 26px; line-height: 36px; letter-spacing: -0.005em; }
+ .count { flex: none; position: relative; width: 84px; height: 36px; font-size: 16px; line-height: 36px;
+ color: ${pal.muted}; font-variant-numeric: tabular-nums; }
+ .count .n { position: absolute; right: 0; top: 0; visibility: hidden; opacity: 0; }
+ .caption { margin-top: 8px; font-size: 14px; line-height: 20px; letter-spacing: 0.12em; text-transform: uppercase;
+ color: ${rgba(pal.muted, 0.9)}; white-space: nowrap; }
+ .rule { position: absolute; left: ${lay.padX}px; top: ${lay.header.ruleY}px; width: ${W - 2 * lay.padX}px;
+ height: 1px; background: ${rgba(pal.fg, 0.12)}; }
+ .stack { position: absolute; left: ${lay.stack.x}px; top: ${lay.stack.y}px; width: ${lay.stack.width}px;
+ height: ${lay.stack.height}px; overflow: hidden; }
+ .empty { position: absolute; left: 0; top: 0; width: ${lay.stack.width}px; height: ${c.plate}px;
+ border-radius: 10px; border: 1px dashed ${rgba(pal.fg, 0.22)};
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
+ visibility: hidden; opacity: 0; }
+ .empty b { font-family: 'DeckSansBold', sans-serif; font-weight: 400; font-size: 20px; color: ${rgba(pal.fg, 0.7)}; }
+ .empty span { font-size: 16px; color: ${pal.muted}; }
+ /* A card: lifted off the column with a touch of the accent, its rail
+ dim until it is the newest. Hidden until the timeline places it. */
+ .post { position: absolute; left: 0; top: 0; width: ${lay.stack.width}px; min-height: ${c.plate}px;
+ visibility: hidden; opacity: 0; border-radius: 10px; overflow: hidden;
+ border-${left ? "right" : "left"}: ${c.rail}px solid ${rgba(pal.accent, 0.38)};
+ background: linear-gradient(180deg, ${cardTop} 0%, ${cardBottom} 100%);
+ box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.1)}; }
+ /* The newest's highlight: a lit rail and an accent rim that flare as it
+ lands and settle; out when the next one arrives. Clear of the code. */
+ .hl { 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 16px ${rgba(pal.accent, 0.5)}; }
+ .hl::before { content: ""; position: absolute; ${left ? "right" : "left"}: 0; top: 0; bottom: 0; width: 4px;
+ background: ${pal.accent}; box-shadow: 0 0 14px 2px ${rgba(pal.accent, 0.6)}; }
+ .body { position: relative; width: ${lay.stack.width - c.plate - c.rail}px;${left ? ` margin-left: ${c.plate}px;` : ""}
+ padding: ${c.pad - 3}px ${c.pad + 2}px ${c.pad - 2}px ${c.pad + 2}px; }
+ .meta { display: flex; align-items: center; gap: 10px;
+ font-size: ${c.metaSize}px; line-height: ${Math.round(c.metaSize * 1.3)}px; white-space: nowrap; }
+ .meta .platform { font-size: ${c.metaSize - 3}px; line-height: ${c.metaSize + 3}px; padding: 1px 9px 2px; }
+ .who { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+ .handle { font-family: 'DeckSansBold', sans-serif; color: ${pal.fg}; }
+ .date { color: ${mix(pal.muted, pal.fg, 0.35)}; flex: none; font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
+ .text { margin-top: 6px; font-size: ${c.textSize}px; line-height: ${c.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: ${c.maxLines}; }
+ .para + .para { margin-top: ${Math.round(c.lineH * 0.34)}px; }
+ .para.gone { display: none; }
+ /* The source cell: the QR in a cell a shade down, as on the deck. */
+ .plate { position: absolute; ${left ? "left" : "right"}: 0; top: 0; bottom: 0; width: ${c.plate}px;
+ display: flex; align-items: center; justify-content: center;
+ background: linear-gradient(180deg, ${mix(cardTop, pal.bg, 0.55)} 0%, ${mix(cardBottom, pal.bg, 0.6)} 100%);
+ border-${left ? "right" : "left"}: 1px solid ${rgba(pal.fg, 0.07)}; }
+ .plate img { display: block; width: ${c.qrSize}px; height: ${c.qrSize}px; border-radius: 6px;
+ image-rendering: pixelated;
+ box-shadow: 0 0 0 1px ${rgba(pal.fg, 0.25)}, 0 6px 18px rgba(0, 0, 0, 0.35); }
+ </style>
+ </head>
+ <body>
+ <div id="root" data-composition-id="feed" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}"
+ data-width="${W}" data-height="${H}">
+ <div id="feed-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}" data-track-index="1">
+ <div class="col" data-k="col">
+ <div class="ground"></div>
+ ${panel ? `<div class="edge"></div>` : ""}
+ <div class="head">
+ <div class="who-row">
+ ${who.platforms.map((p) => `<span class="platform">${esc(p)}</span>`).join("")}
+ <span class="title">${esc(who.title)}</span>
+ <span class="count">${countHtml}</span>
+ </div>
+ <div class="caption">Posts as the timeline reaches them</div>
+ </div>
+ <div class="rule"></div>
+ <div class="stack">
+ <div class="empty" data-k="empty"><b>No posts yet</b><span>They appear here as the cut reaches them</span></div>
+ ${cardHtml}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script id="feed-data" type="application/json">${json}</script>
+ <script>
+ const F = JSON.parse(document.getElementById("feed-data").textContent);
+ const byK = {};
+ for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el;
+ ${embedFn("feedCues", feedCues)}
+
+ const params = new URLSearchParams(location.search);
+ // A window plays the cut's [from, from + dur]; the page is seeked in its own seconds.
+ const local = (t) => {
+ const v = Number(t) || 0;
+ return F.window ? Math.max(0, Math.min(F.window.dur, v - F.window.from)) : Math.max(0, v);
+ };
+ let tl = null;
+ let pending = null;
+
+ // maxLines across a card's paragraphs: each is clamped to the lines
+ // still unspent; once they are spent the rest are dropped, and the last
+ // one drawn ends in an ellipsis when anything was dropped.
+ function clampText(card) {
+ let left = F.maxLines;
+ let shown = null;
+ let cut = false;
+ for (const p of card.querySelectorAll(".para")) {
+ if (left <= 0) { p.classList.add("gone"); cut = true; continue; }
+ p.style.webkitLineClamp = String(left);
+ const lines = Math.round(p.getBoundingClientRect().height / F.lineH);
+ if (p.scrollHeight > p.clientHeight + 1) cut = true;
+ left -= lines;
+ shown = { p, lines };
+ }
+ if (cut && shown && !(shown.p.scrollHeight > shown.p.clientHeight + 1)) {
+ shown.p.textContent = shown.p.textContent.replace(/\\s+$/, "") + " …";
+ shown.p.style.webkitLineClamp = String(shown.lines);
+ }
+ }
+
+ // Measure once the faces are in, then plan, place, build and register.
+ // The renderer awaits document.fonts.ready before it seeks a frame.
+ const ready = Promise.all([
+ document.fonts.load(F.textSize + "px DeckSans"),
+ document.fonts.load(F.metaSize + "px DeckSansBold"),
+ ]).catch(() => {}).then(() => {
+ const cards = F.ids.map((_, j) => byK["c" + j]);
+ cards.forEach(clampText);
+ const heights = cards.map((c) => c.offsetHeight);
+ const M = F.motion;
+ const plan = feedCues({
+ posts: F.posts, heights, column: F.column, visibility: F.visibility, startsHidden: F.startsHidden,
+ slideX: F.slideX, enterX: F.enterX, gap: M.gap, push: M.push, lag: M.lag, enter: M.enter,
+ glowAt: M.glowAt, glowUp: M.glowUp, glowDown: M.glowDown, newest: M.newest, calm: M.calm,
+ leave: M.leave, emptyOut: M.emptyOut,
+ });
+ for (const k of Object.keys(plan.init)) if (byK[k]) gsap.set(byK[k], plan.init[k]);
+ const inner = gsap.timeline({ paused: true });
+ for (const c of plan.cues) {
+ const el = byK[c.k];
+ if (!el) continue;
+ inner.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at);
+ }
+ // The timeline runs to the cut's end, whatever its last cue.
+ inner.set({}, {}, F.total);
+ tl = inner;
+ if (F.window) {
+ tl = gsap.timeline({ paused: true });
+ tl.add(inner.tweenFromTo(F.window.from, F.window.from + F.window.dur, { duration: F.window.dur, ease: "none" }), 0);
+ }
+ window.__timelines = window.__timelines || {};
+ window.__timelines["feed"] = tl;
+ if (typeof window.__hfForceTimelineRebind === "function") window.__hfForceTimelineRebind();
+ document.documentElement.dataset.heights = heights.join(",");
+ tl.seek(pending ?? 0, false);
+ document.documentElement.dataset.ready = "1";
+ });
+
+ // The review still, in cut seconds.
+ const still = params.get("still");
+ if (still !== null) pending = local(still);
+
+ // umtool's live preview: the parent seeks in cut seconds, as it seeks the deck.
+ if (params.get("preview") === "1") {
+ window.addEventListener("message", (e) => {
+ const m = e.data || {};
+ if (m.type !== "deck:seek") return;
+ pending = local(m.t);
+ if (tl) tl.seek(pending, false);
+ });
+ ready.then(() => {
+ if (window.parent !== window) {
+ window.parent.postMessage({ type: "feed:ready", total: F.total, ids: F.ids }, "*");
+ }
+ });
+ }
+ </script>
+ </body>
+</html>
+`;
+}
+
+/** The feed's region in the frame, for the overlay: feedGeometry's column. */
+export const feedRegion = (render) => feedGeometry(render).column;
diff --git a/umtool/report-to-video/chrome-feed.test.mjs b/umtool/report-to-video/chrome-feed.test.mjs
@@ -0,0 +1,428 @@
+// Tests for the posts FEED (slice F1, `posts.layout: "feed"`): the core's
+// geometry and schedule for it (deck.mjs), the page that draws it
+// (chrome-feed.mjs) and the plan it runs, compose-chrome's feed region, the
+// build's framing record and its refusal, and the overlay of the feed's frames.
+// The popup and the deck without posts are held to what they always were.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { FEED_MOTION, feedCues, feedHtml, feedLayout, feedWho } from "./chrome-feed.mjs";
+import {
+ deckChoreography, deckGeometry, deckSchedule, estimateSchedule, feedGeometry, feedOn, postHolds, postSchedule,
+ postsGeometry, postWindows, validateChrome, validatePosts,
+} from "./deck.mjs";
+import {
+ buildVideo, chromeOverlayChain, chromeRegions, deckFraming, deckFramingFilter, feedRegion, framedUnderDeck, framingProblems,
+ postsRegions, segmentFraming, segmentJoins,
+} from "./build-video.mjs";
+
+const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" };
+const CHROME = { engine: "hyperframes", layout: "deck" };
+const BASE = { width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE };
+const RENDER = { ...BASE, chrome: { ...CHROME, deck: {} } };
+const FEED = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed" } } } };
+const PROV = { siteOrigin: "https://example.test", channelSlug: "chan" };
+
+const CLIPS = [
+ { id: "c1", type: "clip", video: "a", start: 0, end: 10, date: "2024-09-05" },
+ { id: "c2", type: "clip", video: "b", start: 0, end: 20, date: "2024-10-01" },
+ { id: "k1", type: "card", seconds: 4 },
+ { id: "c3", type: "clip", video: "c", start: 0, end: 6, date: "2025-12-08" },
+];
+const DURS = [10, 20, 4, 6];
+const POST = (id, date, extra = {}) => ({
+ id, platform: "bluesky", author: "Pirate Software", handle: "piratesoftware.live", date,
+ text: `words of ${id}`, url: `https://bsky.app/profile/piratesoftware.live/post/${id}`, ...extra,
+});
+// c2 carries a, b, c (October/November 2024); c3 carries z.
+const POSTS = [POST("a", "2024-10-19"), POST("b", "2024-11-27"), POST("c", "2024-12-01"), POST("z", "2026-01-22")];
+const sched = (render = FEED, posts = POSTS, durs = DURS, D = 0.5) =>
+ deckSchedule({ entries: CLIPS, durs, D, render, provenance: PROV, posts });
+
+// ---- the core ---------------------------------------------------------------
+
+test("feedGeometry: a column flush with the side and top down to the deck, the footage beside it", () => {
+ const g = feedGeometry(FEED);
+ assert.deepEqual(g.column, { x: 1320, y: 0, width: 600, height: 890 });
+ // 1320 × 890 left of the column; inset 24 all round; the frame's aspect; centred.
+ assert.deepEqual(g.footage, { x: 24, y: 87, width: 1272, height: 716 });
+ assert.deepEqual(g.deck, deckGeometry(FEED).deck);
+ // Nothing overlaps: footage, gap, column; footage above the deck.
+ assert.equal(g.column.x - (g.footage.x + g.footage.width), 24);
+ assert.ok(g.footage.y + g.footage.height <= g.deck.y);
+ // top-left mirrors it; a narrower column grows the footage.
+ const left = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed", position: "top-left", width: 520 } } } };
+ const l = feedGeometry(left);
+ assert.deepEqual(l.column, { x: 0, y: 0, width: 520, height: 890 });
+ assert.deepEqual(l.footage, { x: 520 + 24, y: 65, width: 1352, height: 760 });
+ // The posts region IS the column under the feed; the popup's is unchanged.
+ assert.deepEqual(postsGeometry(FEED), g.column);
+ assert.deepEqual(postsGeometry(RENDER), { x: 1920 - 24 - 600, y: 26, width: 600, height: 838 });
+});
+
+test("validation: layout is popup or feed; the feed is held to the footage left beside it", () => {
+ assert.deepEqual(validateChrome(FEED.chrome, FEED), []);
+ assert.match(validateChrome({ ...CHROME, deck: { posts: { layout: "sidebar" } } }, BASE)[0], /posts.layout must be "popup" or "feed"/);
+ // 900 + 2·24 at 1920 leaves 972 px: allowed. At 1280×720 a 600 column leaves 632 < 640.
+ assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { layout: "feed", width: 900 } } }, BASE), []);
+ const small = { ...BASE, width: 1280, height: 720, chrome: { ...CHROME, deck: { posts: { layout: "feed" } } } };
+ assert.match(validateChrome(small.chrome, small).join(" "), /leaves the footage 632px wide beside the feed/);
+ assert.match(validatePosts([POST("p", "2024-01-01")], CLIPS, small).join(" "), /beside the feed/);
+});
+
+test("feedOn: the deck, the feed layout, shown, and a post to draw on a clip", () => {
+ assert.equal(feedOn({ render: FEED, posts: POSTS, entries: CLIPS }), true);
+ assert.equal(feedOn({ render: RENDER, posts: POSTS, entries: CLIPS }), false);
+ assert.equal(feedOn({ render: FEED, posts: [], entries: CLIPS }), false);
+ assert.equal(feedOn({ render: FEED, posts: [POST("h", "2024-01-01", { hide: true })], entries: CLIPS }), false);
+ assert.equal(feedOn({ render: FEED, posts: POSTS, entries: [{ id: "k", type: "card" }] }), false);
+ const off = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed", show: false } } } };
+ assert.equal(feedOn({ render: off, posts: POSTS, entries: CLIPS }), false);
+ assert.equal(feedOn({ render: { ...FEED, chrome: undefined }, posts: POSTS, entries: CLIPS }), false);
+});
+
+test("the feed schedule: each post ticks in at its clip's start + D, a clip's next ones `seconds` apart", () => {
+ const s = sched();
+ assert.equal(s.layout, "feed");
+ // No hold, no move: the segments are their own lengths.
+ assert.deepEqual(s.segments.map((x) => x.duration), DURS);
+ assert.equal(s.segments.some((x) => "hold" in x), false);
+ assert.equal("moves" in s, false);
+ assert.equal(postHolds({ posts: POSTS, entries: CLIPS, render: FEED }).size, 0);
+ // c2 starts at 9.5: a at 10 (after the dissolve), b and c 4 s apart.
+ const at = Object.fromEntries(s.posts.map((p) => [p.id, [p.segment, p.slot, p.of, p.in]]));
+ assert.deepEqual(at, { a: ["c2", 0, 3, 10], b: ["c2", 1, 3, 14], c: ["c2", 2, 3, 18], z: ["c3", 0, 1, 33] });
+ // A feed post has `in`, not the popup's appear/out.
+ for (const p of s.posts) assert.equal("appear" in p || "out" in p, false);
+ // No windows: the feed is one composition.
+ assert.deepEqual(postWindows(s), []);
+ assert.deepEqual(postsRegions(FEED, "/o", s), []);
+ assert.equal(segmentJoins(s), null);
+ // A clip too short for k × seconds shares what it has before its leave.
+ const short = sched(FEED, POSTS, [10, 8, 4, 6]);
+ assert.deepEqual(short.posts.filter((p) => p.segment === "c2").map((p) => p.in), [10, 12.333, 14.667]);
+ // A hard cut: D = 0, the post is in at the clip's first frame.
+ const hard = sched(FEED, POSTS, DURS, 0);
+ assert.deepEqual(hard.posts.map((p) => p.in), [10, 14, 18, 34]);
+ // The core function, unrounded, says the same.
+ const segs = s.segments.map((x) => ({ id: x.id, start: x.start, duration: x.duration }));
+ assert.equal(postSchedule({ posts: POSTS, entries: CLIPS, segments: segs, D: 0.5, total: s.total, render: FEED })[0].in, 10);
+});
+
+test("the popup and the deck without posts write the schedule they always did", () => {
+ // A feed with nothing to draw is the deck alone, byte for byte.
+ const plain = JSON.stringify(sched(RENDER, []));
+ assert.equal(JSON.stringify(sched(FEED, [])), plain);
+ assert.equal(JSON.stringify(sched(FEED, [POST("h", "2024-10-19", { hide: true })])), plain);
+ const noShow = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed", show: false } } } };
+ assert.equal(JSON.stringify(sched(noShow, POSTS)), plain);
+ // "popup", named, is the default: the same schedule, holds and moves and all.
+ const named = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "popup" } } } };
+ assert.equal(JSON.stringify(sched(named)), JSON.stringify(sched(RENDER)));
+ const popup = sched(RENDER);
+ assert.equal("layout" in popup, false);
+ assert.ok(popup.moves.length > 0);
+ assert.ok(popup.posts.every((p) => "appear" in p && "out" in p && !("in" in p)));
+ assert.equal(estimateSchedule({ render: FEED, provenance: PROV, timeline: CLIPS, posts: POSTS }).layout, "feed");
+});
+
+// ---- the plan the page runs --------------------------------------------------
+
+const P = (posts = sched().posts) => posts.map((p) => ({ id: p.id, in: p.in }));
+const byWhy = (cues, re) => cues.filter((c) => re.test(c.why));
+
+test("feedCues: each card ticks in at its time, at the top, and the ones in move down by its height", () => {
+ const plan = feedCues({ posts: P(), heights: [150, 200, 160, 180], column: 1000, ...FEED_MOTION });
+ const g = FEED_MOTION.gap;
+ // Every card enters `lag` after its `in`, from the side, to x 0.
+ P().forEach((p, j) => {
+ const [e] = byWhy(plan.cues, new RegExp(`^enter ${p.id}$`));
+ assert.equal(e.k, `c${j}`);
+ assert.equal(e.at, Math.round((p.in + FEED_MOTION.lag) * 1e4) / 1e4);
+ assert.deepEqual(e.to, { autoAlpha: 1, x: 0 });
+ });
+ // b pushes a down by b's height; c pushes a and b by c's.
+ assert.deepEqual(byWhy(plan.cues, /^push for b$/).map((c) => [c.k, c.at, c.to.y]), [["c0", 14, 200 + g]]);
+ assert.deepEqual(byWhy(plan.cues, /^push for c$/).map((c) => [c.k, c.to.y]).sort(), [["c0", 200 + 160 + 2 * g], ["c1", 160 + g]]);
+ // The highlight: the newest flares and settles; the one before it goes out.
+ assert.deepEqual(byWhy(plan.cues, /^calm for b$/).map((c) => [c.k, c.to.opacity]), [["h0", 0]]);
+ assert.deepEqual(byWhy(plan.cues, /^settle z$/).map((c) => [c.k, c.to.opacity]), [["h3", FEED_MOTION.newest]]);
+ // The empty state leaves with the first post; the count follows each one.
+ assert.deepEqual(byWhy(plan.cues, /^first a$/).map((c) => [c.k, c.at, c.to.autoAlpha]), [["empty", 10, 0]]);
+ assert.deepEqual(byWhy(plan.cues, /^count z$/).map((c) => [c.k, c.to.autoAlpha]), [["n3", 0], ["n4", 1]]);
+ assert.equal(plan.gone.every((x) => x === null), true);
+});
+
+test("feedCues: when the column overflows the oldest fade out as they are pushed past it", () => {
+ // A column of 400: a (150) and b (200) fit; c (160) pushes a to 376 + 150 > 400.
+ const plan = feedCues({ posts: P(), heights: [150, 200, 160, 180], column: 400, ...FEED_MOTION });
+ const out = byWhy(plan.cues, /^out for /);
+ assert.deepEqual(out.map((c) => [c.k, c.why, c.to.autoAlpha]), [["c0", "out for c", 0], ["c1", "out for z", 0]]);
+ assert.deepEqual(plan.gone, [18, 33, null, null]);
+ // A gone card is never moved again.
+ assert.equal(plan.cues.some((c) => c.k === "c0" && c.at > 18), false);
+});
+
+test("feedCues: every cue states its from, the last to on its element -- seek-safe", () => {
+ const vis = deckChoreography({ ...sched(), segments: sched().segments.map((s) => ({ ...s, hideDeck: s.id === "k1" })) }, FEED).visibility;
+ const plan = feedCues({ posts: P(), heights: [150, 200, 160, 180], column: 400, visibility: vis, slideX: 624, ...FEED_MOTION });
+ const state = Object.fromEntries(Object.entries(plan.init).map(([k, v]) => [k, { ...v }]));
+ let last = -Infinity;
+ const ends = {};
+ for (const c of plan.cues) {
+ assert.ok(c.at >= last, "cues in time order");
+ last = c.at;
+ for (const [q, v] of Object.entries(c.from)) assert.equal(v, state[c.k][q], `${c.k}.${q} from at ${c.at}`);
+ assert.ok(c.at >= (ends[c.k] ?? -Infinity) - 1e-9, `${c.k} overlaps itself at ${c.at}`);
+ ends[c.k] = c.at + c.dur;
+ Object.assign(state[c.k], c.to);
+ }
+ // The column slides off with the deck over the card and back after it.
+ const col = plan.cues.filter((c) => c.k === "col");
+ assert.deepEqual(col.map((c) => c.to.x), [624, 0]);
+ assert.deepEqual(col.map((c) => c.at), vis.map((v) => v.at[0]));
+ // Hidden from the first frame: that is the init, no cue.
+ const first = feedCues({ posts: P(), heights: [1, 1, 1, 1], column: 400, startsHidden: true, slideX: 624,
+ visibility: [{ i: 0, hide: true, at: [0, 0] }] });
+ assert.deepEqual(first.init.col, { x: 624 });
+ assert.equal(first.cues.some((c) => c.k === "col"), false);
+});
+
+// ---- the page ------------------------------------------------------------------
+
+const FONTS = { regular: "assets/DeckSans.ttf", bold: "assets/DeckSansBold.ttf" };
+const HOSTILE = POST("evil", "2024-11-27T02:36:13.745Z", {
+ platform: "x", handle: '"><img src=x onerror=alert(1)>', author: "<b>bold</b>",
+ text: "</script><script>alert(1)</script>\nsecond line & 'quotes'\n\nnew paragraph",
+});
+const dataOf = (html) => JSON.parse(/<script id="feed-data" type="application\/json">(.*?)<\/script>/s.exec(html)[1]);
+const page = (posts = POSTS, opts = {}) => {
+ const s = sched(FEED, posts);
+ const qrSrcs = Object.fromEntries(s.posts.map((p, i) => [p.id, `assets/qr0${i}.png`]));
+ return { s, qrSrcs, html: feedHtml(s, FEED, { fonts: FONTS, qrSrcs, ...opts }) };
+};
+
+test("the page: one card per post with its date, words and QR; the header; the composition contract", () => {
+ const { s, html, qrSrcs } = page();
+ const col = feedGeometry(FEED).column;
+ assert.match(html, new RegExp(`data-composition-id="feed" data-start="0" data-duration="${s.total}"\\s+data-width="${col.width}" data-height="${col.height}"`));
+ assert.match(html, /window\.__timelines\["feed"\] = tl/);
+ s.posts.forEach((p, j) => {
+ const open = html.indexOf(`data-post="${p.id}" data-k="c${j}"`);
+ assert.ok(open > 0, `no card for ${p.id}`);
+ const next = html.indexOf("<article", open + 1);
+ const card = html.slice(open, next > 0 ? next : undefined);
+ assert.match(card, /class="date"/);
+ assert.match(card, /class="para">words of /);
+ assert.ok(card.includes(`<img src="${qrSrcs[p.id]}" width="120" height="120"`), `${p.id} qr`);
+ assert.ok(card.includes(`data-k="h${j}"`));
+ });
+ // One author: the header names them once, and the cards only date themselves.
+ assert.ok(html.includes('<span class="title">@piratesoftware.live</span>'));
+ assert.ok(html.includes('<span class="platform">Bluesky</span>'));
+ assert.equal((html.match(/class="handle"/g) ?? []).length, 0);
+ assert.ok(html.includes('<span class="date">Oct 19, 2024</span>'));
+ assert.ok(html.includes('data-k="empty"'));
+ assert.ok(html.includes('<span class="n" data-k="n4">4 of 4</span>'));
+ // The cue times are the schedule's.
+ assert.deepEqual(dataOf(html).posts, s.posts.map((p) => ({ id: p.id, in: p.in })));
+ assert.equal(dataOf(html).column, feedLayout(FEED).stack.height);
+ // The planner is in the page under its fixed name.
+ assert.ok(html.includes("const feedCues = (function feedCues("));
+ // Not a feed's schedule: refused.
+ assert.throws(() => feedHtml(sched(RENDER), FEED, { fonts: FONTS }), /not a feed/);
+});
+
+test("the page: post strings are text, escaped in elements, attributes and the inline JSON", () => {
+ const { html } = page([...POSTS, HOSTILE]);
+ assert.ok(html.includes("</script><script>alert(1)</script>\nsecond line & 'quotes'"));
+ assert.ok(html.includes("@"><img src=x onerror=alert(1)>"));
+ assert.doesNotMatch(html, /<img src=x/);
+ assert.doesNotMatch(html, /<b>bold<\/b>/);
+ // Two authors, two platforms: the header says "Posts" and each card its own.
+ assert.ok(html.includes('<span class="title">Posts</span>'));
+ assert.ok(html.includes('<span class="platform">X</span>'));
+ assert.equal((html.match(/<\/script>/g) ?? []).length, 3);
+ const json = /<script id="feed-data" type="application\/json">(.*?)<\/script>/s.exec(html)[1];
+ assert.doesNotMatch(json, /</);
+ assert.doesNotMatch(json, /alert|words of/);
+ assert.deepEqual(feedWho([POST("a", "2024-01-01")]), { title: "@piratesoftware.live", platforms: ["Bluesky"], single: true });
+});
+
+test("the page: nothing leaves the machine; the posts' links are only in their QRs", () => {
+ const { html } = page([POST("a", "2024-10-19", { text: "see https://example.com/x and ferrets.live" })]);
+ // The words may name a link; no element loads one.
+ assert.doesNotMatch(html, /(?:src|href)="(?:https?:)?\/\//, "a remote src or href");
+ assert.doesNotMatch(html, /url\('(?:https?:)?\/\//, "a remote font");
+ assert.doesNotMatch(html, /@import|fonts\.googleapis|cdn\.|<link/);
+ assert.match(html, /<script src="assets\/gsap\.min\.js"><\/script>/);
+ for (const m of html.matchAll(/font-family: ([^;]+);/g)) {
+ assert.match(m[1], /^'DeckSans(?:Bold)?'(?:, sans-serif)?$/, m[1]);
+ }
+});
+
+test("the page: a window of the cut plays the cut's own clock; visibility is the deck's", () => {
+ const { html } = page(POSTS, { from: 12, duration: 5 });
+ const d = dataOf(html);
+ assert.deepEqual(d.window, { from: 12, dur: 5 });
+ assert.match(html, /data-composition-id="feed" data-start="0" data-duration="5"/);
+ const hidden = { ...sched(), segments: sched().segments.map((s) => ({ ...s, hideDeck: s.id === "k1" })) };
+ const v = dataOf(feedHtml(hidden, FEED, { fonts: FONTS })).visibility;
+ assert.deepEqual(v.map((x) => [x.hide, x.at[0]]), deckChoreography(hidden, FEED).visibility.map((x) => [x.hide, x.at[0]]));
+ assert.equal(dataOf(feedHtml(hidden, FEED, { fonts: FONTS })).slideX, 600 + 24);
+});
+
+// ---- the build: framing, its record and the refusal ------------------------------
+
+test("deckFraming: a feed frames footage into feedGeometry's box; the deck's is unchanged", () => {
+ const f = feedGeometry(FEED).footage;
+ assert.deepEqual(deckFraming(FEED, { feed: true }).box, f);
+ assert.equal(
+ deckFramingFilter(FEED, { feed: true }),
+ "scale=1272:716:force_original_aspect_ratio=decrease,pad=1272:716:(ow-iw)/2:(oh-ih)/2:color=#12101a," +
+ "pad=1920:1080:24:87:color=#12101a,setsar=1,fps=30",
+ );
+ // Without the feed (or under the popup) it is the deck's box, the filter it always was.
+ assert.equal(deckFramingFilter(FEED), deckFramingFilter(RENDER));
+ assert.deepEqual(segmentFraming(FEED, true), { layout: "feed", box: f });
+ assert.deepEqual(segmentFraming(RENDER), { layout: "deck", box: deckGeometry(RENDER).footage });
+ assert.equal(framedUnderDeck({ type: "clip" }, FEED), true);
+ assert.equal(framedUnderDeck({ type: "image" }, FEED), true);
+ assert.equal(framedUnderDeck({ type: "card" }, FEED), false);
+ assert.equal(framedUnderDeck({ type: "teaser" }, FEED), false);
+ const shown = { ...BASE, chrome: { ...CHROME, deck: { overCards: "show" } } };
+ assert.equal(framedUnderDeck({ type: "card" }, shown), true);
+ assert.equal(framedUnderDeck({ type: "teaser" }, shown), false);
+});
+
+test("framingProblems: segments framed for another layout are named; a record-less one is the deck's", () => {
+ const entries = [...CLIPS, { id: "t", type: "teaser" }];
+ const feed = segmentFraming(FEED, true);
+ const deck = segmentFraming(FEED, false);
+ const rec = (fr) => ({ version: 1, framing: fr });
+ // All framed for the feed, wanted for the feed: nothing to say. Cards and teasers are full frame.
+ assert.deepEqual(framingProblems({ entries, records: [rec(feed), rec(feed), null, rec(feed), null], render: FEED, want: feed }), []);
+ // Built under the deck (with records, or before records named it) and now a feed: refused, by name.
+ const [msg] = framingProblems({ entries, records: [rec(deck), null, null, rec(feed), null], render: FEED, want: feed });
+ assert.match(msg, /^2 segment\(s\) are framed for another layout than this cut's posts feed \(footage 1272×716 at \(24, 87\)\)/);
+ assert.match(msg, /c1 \(framed for the deck, 1574×886 at \(173, 2\)\)/);
+ assert.match(msg, /c2 \(no framing record: built for the deck's 1574×886 at \(173, 2\)\)/);
+ assert.match(msg, /run a normal build \(with --skip-fetch/);
+ // The other way: feed-framed segments under a popup deck.
+ assert.match(framingProblems({ entries, records: [rec(feed), rec(deck), null, rec(deck), null], render: RENDER, want: deck })[0],
+ /1 segment\(s\) are framed for another layout than this cut's deck .*c1 \(framed for the feed, 1272×716 at \(24, 87\)\)/);
+ // Legacy segments with no record pass under the deck.
+ assert.deepEqual(framingProblems({ entries, records: entries.map(() => null), render: RENDER, want: deck }), []);
+});
+
+test("--chrome-only and --chrome-preview refuse a framing mismatch before rendering a teaser", async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "feed-refuse-"));
+ const env = process.env.HYPERFRAMES_BIN;
+ try {
+ // A renderer that only leaves a mark: the refusal must come first.
+ const mark = path.join(dir, "rendered");
+ const stub = path.join(dir, "hf-stub.sh");
+ writeFileSync(stub, `#!/bin/sh\ntouch '${mark}'\nexit 1\n`);
+ chmodSync(stub, 0o755);
+ process.env.HYPERFRAMES_BIN = stub;
+ const manifestPath = path.join(dir, "m.json");
+ writeFileSync(manifestPath, JSON.stringify({
+ slug: "refuse", render: FEED, provenance: PROV, posts: POSTS,
+ timeline: [...CLIPS, { id: "t", type: "teaser", lines: ["Soon"] }],
+ }));
+ // Every footage segment on disk, framed for the deck: the cut is now a feed.
+ const segDir = path.join(dir, "out", "sourced", "segments");
+ mkdirSync(segDir, { recursive: true });
+ for (const e of CLIPS) {
+ writeFileSync(path.join(segDir, `${e.id}.mp4`), "");
+ writeFileSync(path.join(segDir, `${e.id}.cut.json`), JSON.stringify({ version: 1, framing: segmentFraming(FEED, false) }));
+ }
+ for (const opts of [{ chromeOnly: true }, { chromePreview: { at: 1, dur: 2 } }]) {
+ await assert.rejects(buildVideo({ manifestPath, opts }), /framed for another layout than this cut's posts feed/);
+ }
+ assert.equal(existsSync(mark), false, "a teaser was rendered before the refusal");
+ assert.equal(existsSync(path.join(segDir, "t.mp4")), false);
+ } finally {
+ if (env === undefined) delete process.env.HYPERFRAMES_BIN;
+ else process.env.HYPERFRAMES_BIN = env;
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("the feed's overlay is laid like the deck's: whole cut, reinit off, rgba, shortest", () => {
+ const regions = [...chromeRegions(FEED, "/o"), feedRegion(FEED, "/o/chrome/feed-frames")];
+ assert.deepEqual(regions[1], { name: "feed", frames: "/o/chrome/feed-frames", x: 1320, y: 0, width: 600, height: 890 });
+ const hf = chromeOverlayChain(FEED, regions, "[v]", 3);
+ assert.deepEqual(hf.inputs.slice(8), [
+ "-reinit_filter", "0", "-framerate", "30", "-start_number", "1", "-i", "/o/chrome/feed-frames/frame_%06d.png",
+ ]);
+ assert.match(hf.chain, /\[4:v\]format=rgba\[hfa1\];\[hf0\]\[hfa1\]overlay=x=1320:y=0:format=yuv444:shortest=1\[hf1\]/);
+ // The deck alone writes the chain it always did.
+ assert.equal(chromeOverlayChain(RENDER, chromeRegions(RENDER, "/o"), "[v]", 3).chain,
+ "[3:v]format=rgba[hfa0];[v][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]");
+});
+
+// ---- compose-chrome's feed region, with the renderer stubbed ----------------------
+
+test("compose-chrome: the feed region composes, renders through the stub and caches by its key", async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "feed-compose-"));
+ const env = { HYPERFRAMES_BIN: process.env.HYPERFRAMES_BIN, QRENCODE_BIN: process.env.QRENCODE_BIN };
+ try {
+ // A renderer that writes the frames the root declares, and a qrencode
+ // that writes a PNG: what the e2e stubs do.
+ const stub = path.join(dir, "hf-stub.mjs");
+ writeFileSync(stub, `#!/usr/bin/env node
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+const a = process.argv.slice(2);
+const out = a[a.indexOf("--output") + 1];
+const fps = Number(a[a.indexOf("--fps") + 1]);
+const html = readFileSync(a.at(-1) + "/index.html", "utf8");
+const dur = Number(/data-composition-id="[^"]*"[^>]*data-duration="([\\d.]+)"/.exec(html)[1]);
+mkdirSync(out, { recursive: true });
+for (let i = 1; i <= Math.round(dur * fps); i += 1) writeFileSync(out + "/frame_" + String(i).padStart(6, "0") + ".png", "x");
+`);
+ chmodSync(stub, 0o755);
+ process.env.HYPERFRAMES_BIN = stub;
+ const s = sched();
+ const font = "/usr/share/fonts/TTF/FiraSans-Regular.ttf";
+ const manifest = { slug: "f", render: { ...FEED, fontRegular: font, fontBold: font, qr: { scale: 3, quiet: 3 } }, timeline: CLIPS, posts: POSTS };
+ const mp = path.join(dir, "video.manifest.json");
+ writeFileSync(mp, JSON.stringify(manifest));
+ const out = path.join(dir, "out", "sourced");
+ mkdirSync(out, { recursive: true });
+ writeFileSync(path.join(out, "schedule.json"), JSON.stringify(s));
+ const { composeChrome } = await import("./compose-chrome.mjs");
+ let r;
+ try {
+ r = await composeChrome({ manifestPath: mp, region: "feed", doRender: true, fps: 30 });
+ } catch (e) {
+ // No qrencode or font on this machine: the page is what the tests above hold.
+ if (/qrencode|ENOENT|face/.test(String(e.message))) return;
+ throw e;
+ }
+ assert.equal(r.projDir, path.join(out, "chrome", "feed"));
+ assert.equal(r.frames, path.join(out, "chrome", "feed-frames"));
+ assert.equal(r.frameCount, Math.round(s.total * 30));
+ assert.equal(r.cached, false);
+ const html = readFileSync(path.join(r.projDir, "index.html"), "utf8");
+ assert.match(html, /src="assets\/qr00\.png"/);
+ const again = await composeChrome({ manifestPath: mp, region: "feed", doRender: true, fps: 30 });
+ assert.equal(again.cached, true);
+ // The preview project and a window each have their own name.
+ const pv = await composeChrome({ manifestPath: mp, region: "feed", preview: true });
+ assert.equal(pv.projDir, path.join(out, "chrome", "feed-preview"));
+ const w = await composeChrome({ manifestPath: mp, region: "feed", from: 9, duration: 4 });
+ assert.equal(w.projDir, path.join(out, "chrome", "feed-from9"));
+ // A popup's schedule is not a feed's.
+ writeFileSync(path.join(out, "schedule.json"), JSON.stringify(sched(RENDER)));
+ await assert.rejects(composeChrome({ manifestPath: mp, region: "feed" }), /needs a feed's schedule/);
+ } finally {
+ for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs
@@ -32,7 +32,7 @@
// Every cue is a fromTo whose FROM is stated, for the deck's reason: a render
// is a seek per frame, from parallel workers, in any order.
import { formatDeckDate } from "./attribution.mjs";
-import { postsGeometry, resolveDeck, snapWindow } from "./deck.mjs";
+import { pageDuration, postsGeometry, resolveDeck, snapWindow } from "./deck.mjs";
import { mix, rgba } from "./chrome-deck.mjs";
// The window arithmetic is deck.mjs's (pure, and loaded by the build without
@@ -339,9 +339,9 @@ export function postsHtml(schedule, render, window, opts = {}) {
</style>
</head>
<body>
- <div id="root" data-composition-id="posts" data-start="0" data-duration="${dur}"
+ <div id="root" data-composition-id="posts" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}"
data-width="${W}" data-height="${H}" data-segment="${esc(window.segment)}">
- <div id="posts-clip" class="clip" data-start="0" data-duration="${dur}" data-track-index="1">
+ <div id="posts-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, schedule.fps ?? render.fps ?? 30)}" data-track-index="1">
<div class="stack" data-k="stack">
${cardHtml}
</div>
@@ -381,7 +381,7 @@ export function postsHtml(schedule, render, window, opts = {}) {
if (cut && shown && !(shown.p.scrollHeight > shown.p.clientHeight + 1)) {
// Dropped paragraphs after one that fit exactly: say so on it. The
// clamp it already has turns an overflowing "…" into the ellipsis.
- shown.p.textContent = shown.p.textContent.replace(/\s+$/, "") + " …";
+ shown.p.textContent = shown.p.textContent.replace(/\\s+$/, "") + " …";
shown.p.style.webkitLineClamp = String(shown.lines);
}
}
diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs
@@ -132,6 +132,18 @@ test("nothing on the page leaves the machine; the post's own link is only in its
}
});
+test("the page's clamp trims trailing whitespace only: a word ending in s keeps its s", () => {
+ const { html } = c2Page();
+ // The template literal must emit the regex's backslash: written as /\s+$/
+ // in the template, the page received /s+$/ and cut a quoted word's last s.
+ const m = /shown\.p\.textContent\.replace\((\/.*?\/), ""\)/.exec(html);
+ assert.ok(m, "the clamp's ellipsis line is on the page");
+ assert.equal(m[1], "/\\s+$/");
+ const re = new Function(`return ${m[1]};`)();
+ assert.equal("three Rescues".replace(re, ""), "three Rescues");
+ assert.equal("three Rescues \n ".replace(re, ""), "three Rescues");
+});
+
test("the page's times are postSchedule's: data, enter and leave cues", () => {
const { sched, html, win } = c2Page();
const d = dataOf(html);
diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs
@@ -24,7 +24,10 @@
// other number; the grain's jitter is a seeded sequence of instant sets.
import { fileURLToPath } from "node:url";
-import { TEASER_MOTION, teaserLines, teaserTail, teaserTimes } from "./deck.mjs";
+import {
+ DIP_RISE, dipOf, pageDuration, TEASER_MOTION, teaserLead, teaserLines, teaserMotionOf, teaserSeconds, teaserTail, teaserTimes,
+ transitionOf,
+} from "./deck.mjs";
export { TEASER_MOTION };
import { mix, rgba } from "./chrome-deck.mjs";
@@ -68,20 +71,31 @@ export function seeded(seed) {
* Everything the teaser's timeline does, as data.
*
* `lines` are `teaserLines(entry)`, `tail` the tail ("" for none), `seconds`
- * the card's length. Keys name elements by `data-k`: `stage` (the slow push-in
+ * the card's length (`teaserSeconds`), `motion` the entry's (`teaserMotion`
+ * of its `beat`). Keys name elements by `data-k`: `stage` (the slow push-in
* over the whole card), `barT`/`barB` (the letterbox closing in), `leak` (a
* soft light drifting across), `grain`, and per line i `l<i>.o` (its
* visibility), `l<i>` (the slam's scale), `l<i>.t` (its blur), `l<i>.flash`,
* `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`.
*
+ * `dip` (`{ lead }`, a teaser that dips; `motion` is then `teaserMotionOf`'s,
+ * its lines already after the lead) opens the card out of black like a
+ * trailer: the letterbox is closed from the first frame and comes up with the
+ * light; `veil`, a black layer over the ground and the light leak (under the
+ * words), holds the frame black for the `lead` and lifts around the first
+ * line's impact (DIP_RISE: from `before` ahead of it, slowly at first, to
+ * `after` past it, the bloom), so the first hit is the moment the light comes
+ * on; the leak's entrance waits for the lead. Without `dip` the cues are
+ * exactly what they always were.
+ *
* @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 }) {
+export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION, dip = null }) {
const m = motion;
// The times are deck.mjs's, the same the build places the hits by.
- const beats = teaserTimes(lines, tail, seconds, m);
+ const beats = teaserTimes(lines, tail, m);
const { T } = beats;
const init = {};
@@ -92,13 +106,32 @@ export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION }
// ---- 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");
+ if (dip) {
+ // Out of black: the bars are closed already and come up with the light;
+ // the veil over the ground lifts in two strokes either side of the first
+ // impact -- slowly, then the bloom.
+ const lead = r4(dip.lead);
+ const hit = beats.lines[0]?.impact ?? r4(lead + DIP_RISE.before);
+ const up = r4(hit + DIP_RISE.after);
+ put("barT", { yPercent: 0, autoAlpha: 0 });
+ put("barB", { yPercent: 0, autoAlpha: 0 });
+ add("barT", lead, up - lead, { autoAlpha: 1 }, "power2.in", "letterbox up");
+ add("barB", lead, up - lead, { autoAlpha: 1 }, "power2.in", "letterbox up");
+ put("veil", { autoAlpha: 1 });
+ add("veil", lead, hit - lead, { autoAlpha: 0.45 }, "power2.in", "rise");
+ add("veil", hit, up - hit, { autoAlpha: 0 }, "power2.out", "bloom");
+ put("leak", { x: -420, autoAlpha: 0 });
+ add("leak", lead, T(1.4), { autoAlpha: 1 }, "power1.out", "leak in");
+ add("leak", lead + T(1.4), Math.max(INSTANT, seconds - lead - T(1.4)), { x: 420 }, "none", "leak drift");
+ } else {
+ 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);
@@ -171,7 +204,8 @@ export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION }
freeAt.set(e.k, r4(at + dur));
cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why });
}
- const { T: _T, ...times } = beats;
+ // `need` is the validator's; the page's data is what it always was.
+ const { T: _T, need: _need, ...times } = beats;
return { init, cues, beats: times, scale: beats.scale };
}
@@ -192,14 +226,20 @@ 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);
+ // The cut's transition, read only for a dip: its lead is the dissolve and the black.
+ const D = opts.transition ?? transitionOf(render);
+ const fps = render.fps ?? 30;
+ const seconds = teaserSeconds(entry, D, fps);
if (!(seconds > 0)) throw new Error(`teaser ${entry.id}: seconds must be positive`);
const font = opts.font ?? TEASER_FONT_ASSET;
const gsapSrc = opts.gsap ?? "assets/gsap.min.js";
const lines = teaserLines(entry);
if (!lines.length) throw new Error(`teaser ${entry.id}: no lines`);
const tail = teaserTail(entry);
- const { init, cues, beats } = teaserCues({ lines, tail, seconds });
+ const dipped = !!dipOf(entry, fps);
+ const { init, cues, beats } = teaserCues({
+ lines, tail, seconds, motion: teaserMotionOf(entry, D, fps), dip: dipped ? { lead: teaserLead(entry, D, fps) } : null,
+ });
// The ground: the palette's bg, lifted a touch toward the accent at the
// centre and falling toward black at the edges.
@@ -321,16 +361,19 @@ export function teaserHtml(entry, render, opts = {}) {
.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; }
+ opacity: 0.11; mix-blend-mode: overlay; }${dipped ? `
+ /* The dip's veil: pure black over the ground and the light, under the
+ words; the stage's push only ever grows it past the frame. */
+ .veil { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; background: #000000; }` : ""}
</style>
</head>
<body>
- <div id="root" data-composition-id="teaser" data-start="0" data-duration="${r4(seconds)}"
+ <div id="root" data-composition-id="teaser" data-start="0" data-duration="${pageDuration(seconds, render.fps ?? 30)}"
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 id="teaser-clip" class="clip" data-start="0" data-duration="${pageDuration(seconds, render.fps ?? 30)}" data-track-index="1">
<div class="ground"></div>
<div class="stage" data-k="stage">
- <div class="leak" data-k="leak"></div>
+ <div class="leak" data-k="leak"></div>${dipped ? '\n <div class="veil" data-k="veil"></div>' : ""}
<div class="column">
${lineHtml}
</div>
diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs
@@ -8,9 +8,12 @@ import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
+import { createHash } from "node:crypto";
+
import {
- CARD_TYPES, deckChoreography, deckSchedule, deckText, hidesDeck, resolveDeck, TEASER_MOTION, teaserHits,
- teaserLines, teaserTimes, teaserTitle, validateTeaser, validateTeasers,
+ CARD_TYPES, deckChoreography, deckSchedule, deckText, estimatedDuration, hidesDeck, resolveDeck, TEASER_BEAT,
+ TEASER_MOTION, teaserHits, teaserLines, teaserMotion, teaserSeconds, teaserTail, teaserTimes, teaserTitle,
+ validateTeaser, validateTeasers,
} from "./deck.mjs";
import { teaserCues, teaserHtml } from "./chrome-teaser.mjs";
import { chapterTitle, teaserAudioGraph, teaserSegmentKey } from "./build-video.mjs";
@@ -40,6 +43,12 @@ test("a valid teaser has nothing to say; every bad shape is a sentence", () => {
assert.match(bad({ lines: [{ text: "whole", break: "whole" }] }), /leaves nothing for the first tier/);
assert.match(bad({ lines: [42] }), /string or \{ text, break \}/);
assert.match(bad({ seconds: 2 }), /seconds must be from 3 to 20/);
+ assert.match(bad({ seconds: "7" }), /seconds must be from 3 to 20, or absent/);
+ assert.match(bad({ beat: 0.3 }), /beat must be from 0\.4 to 2\.5 seconds/);
+ assert.match(bad({ beat: 2.6 }), /beat must be from 0\.4 to 2\.5/);
+ assert.match(bad({ beat: "slow" }), /beat must be/);
+ assert.deepEqual(validateTeaser({ ...FERRET, beat: 0.9 }), []);
+ assert.deepEqual(validateTeaser({ ...FERRET, seconds: undefined }), []);
assert.match(bad({ seconds: 21 }), /from 3 to 20/);
assert.match(bad({ tail: "" }), /tail must be a short string/);
assert.match(bad({ tail: "?????????" }), /tail is 9 characters/);
@@ -116,7 +125,7 @@ test("the cues: one per pop at the shared times, top to bottom, every from state
const lines = teaserLines(FERRET);
const { cues, init, beats } = teaserCues({ lines, tail: "?", seconds: 7 });
const m = TEASER_MOTION;
- // The ferret card needs no compression: the times are the motion's own.
+ // The times are the motion's own: nothing is ever compressed.
assert.deepEqual(beats.lines.map((b) => b.at), [m.first, m.first + m.gap, m.first + m.gap + m.sub + m.gap]);
assert.equal(beats.lines[1].subAt, m.first + m.gap + m.sub);
// About 0.6–0.8 s apart, in order.
@@ -150,13 +159,92 @@ test("the cues: one per pop at the shared times, top to bottom, every from state
assert.equal(state["l1.sub"].autoAlpha, 1);
});
-test("a short card plays every beat faster, and still leaves the end fade its room", () => {
- const lines = teaserLines({ lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] });
- const t = teaserTimes(lines, "?", 3);
- assert.ok(t.scale < 1);
- assert.ok(t.tailAt + t.tailDur <= 3 - TEASER_MOTION.endRoom + 1e-6);
- const { cues } = teaserCues({ lines, tail: "?", seconds: 3 });
- assert.ok(cues.every((c) => c.at + c.dur <= 3 + 1e-6));
+test("nothing is squeezed: a card too short for its beats is refused with the length they need", () => {
+ const FIVE = { ...FERRET, lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] };
+ const t = teaserTimes(teaserLines(FIVE), "?");
+ assert.equal(t.scale, 1);
+ // 0.55 + four beats + the second tier (0.3) + the tail (0.8 after, 1.7 in) + 1.2 still.
+ assert.equal(t.need, 7.35);
+ assert.match(validateTeaser({ ...FIVE, seconds: 3 }).join(" | "),
+ /seconds is 3, and at a beat of 0\.7s its lines need 7\.4s \(the last pop, the tail's fade and 1\.2s still for the end fade\) -- set it to 7\.4 or more, or leave it out for exactly that/);
+ assert.deepEqual(validateTeaser({ ...FIVE, seconds: 7.35 }), []);
+ // The ferret's 7 s holds its beats up to about 0.99 s; past that it is refused, not compressed.
+ assert.deepEqual(validateTeaser({ ...FERRET, beat: 0.9 }), []);
+ assert.match(validateTeaser({ ...FERRET, beat: 1.05 }).join(), /seconds is 7, and at a beat of 1\.05s its lines need 7\.2s/);
+ // Without `seconds`, a card needing more than a teaser may run says so.
+ const broken = { ...FERRET, seconds: undefined, beat: 2.5, lines: ["A b", "C d", "E f", "G h", "I j"].map((t) => ({ text: t, break: t.slice(-1) })) };
+ assert.match(validateTeaser(broken).join(),
+ /needs 21\.7s at a beat of 2\.5s, and a teaser runs at most 20s -- a shorter beat, or fewer lines/);
+ assert.deepEqual(validateTeaser({ ...broken, beat: 2.2 }), []);
+ // The cues run to their own end, inside the card.
+ const { cues } = teaserCues({ lines: teaserLines(FIVE), tail: "?", seconds: 7.35 });
+ assert.ok(cues.every((c) => c.at + c.dur <= 7.35 + 1e-6));
+});
+
+test("the beat: the gap between pops, the second tier and the tail's wait in proportion", () => {
+ const m = TEASER_MOTION;
+ // No beat, or the default's own, is the motion itself.
+ assert.equal(teaserMotion(undefined), m);
+ assert.equal(teaserMotion(null), m);
+ assert.equal(teaserMotion(m.gap), m);
+ assert.deepEqual([TEASER_BEAT.min, TEASER_BEAT.max], [0.4, 2.5]);
+ const slow = teaserMotion(1.05); // +50 %
+ assert.deepEqual([slow.gap, slow.sub, slow.tailAfter], [1.05, 0.45, 1.2]);
+ // What is not the beat stays put.
+ for (const k of ["first", "hit", "settle", "tailDur", "endRoom", "slam", "under", "blur", "push", "grainHz"]) {
+ assert.equal(slow[k], m[k], k);
+ }
+ assert.ok(Object.isFrozen(slow));
+ // The hits come from teaserTimes at the beat: each pop a beat after the one
+ // before (or after its second tier), the second tier 3/7 of a beat after its line.
+ const hitsAt = (beat) => teaserHits({ ...FERRET, beat, seconds: undefined }).map((h) => h.at);
+ assert.deepEqual(hitsAt(undefined), [0.75, 1.45, 1.55, 2.45, 3.05]);
+ assert.deepEqual(hitsAt(1.05), [0.75, 1.8, 2.05, 3.3, 4.3]);
+ const times = teaserTimes(teaserLines(FERRET), "?", slow);
+ assert.deepEqual(hitsAt(1.05), [
+ times.lines[0].impact, times.lines[1].impact, times.lines[1].subAt, times.lines[2].impact, times.tailAt,
+ ]);
+ // The spacing between the pops grows with the beat, and only the beat.
+ const at = (beat) => teaserTimes(teaserLines(FERRET), "?", teaserMotion(beat)).lines.map((l) => l.at);
+ for (const beat of [0.4, 0.9, 1.3, 2.5]) {
+ const [a, b, c] = at(beat);
+ assert.equal(a, m.first);
+ assert.ok(Math.abs(b - a - beat) < 1e-4, `${beat}`);
+ assert.ok(Math.abs(c - b - beat * (1 + m.sub / m.gap)) < 1e-3, `${beat}`);
+ }
+});
+
+test("the length: `seconds` when set, else what the beats need, up to a tenth and at least 3 s", () => {
+ const free = { ...FERRET, seconds: undefined };
+ assert.equal(teaserSeconds(FERRET), 7);
+ assert.equal(teaserSeconds({ ...FERRET, beat: 0.9 }), 7);
+ assert.equal(teaserTimes(teaserLines(free), "?").need, 5.95);
+ assert.equal(teaserSeconds(free), 6);
+ assert.equal(teaserSeconds({ ...free, beat: 0.9 }), 6.7); // needs 6.6643
+ assert.equal(teaserSeconds({ ...free, beat: 1.05 }), 7.2); // needs exactly 7.2
+ assert.equal(teaserSeconds({ ...free, beat: 1.3 }), 8.1);
+ // One line, no tail: 0.55 + the slam and settle + 1.2 still is 2.45 -- the floor is 3.
+ assert.equal(teaserSeconds({ type: "teaser", id: "x", lines: ["Solo"] }), 3);
+ // The schedule's estimate is the same length.
+ assert.equal(estimatedDuration(free), 6);
+ assert.equal(estimatedDuration(FERRET), 7);
+ // The page is that long, and its tail is in before the end fade's hold.
+ const html = teaserHtml({ ...free, beat: 1.3 }, RENDER);
+ assert.match(html, /data-duration="8\.1"/);
+ const t = teaserTimes(teaserLines(free), teaserTail(free), teaserMotion(1.3));
+ assert.ok(t.tailAt + t.tailDur <= 8.1 - TEASER_MOTION.endRoom + 1e-9);
+});
+
+test("a teaser without a beat composes the page it did before beats existed, byte for byte", () => {
+ const sha = (s) => createHash("sha256").update(s).digest("hex");
+ // The ferret teaser's page at 07d1fa08, before `beat`: its render key and frames are these.
+ const BEFORE = "a683c6414b5cff9816608829b71d8b3e09f8755e86e3b9b0e7300c132a413810";
+ assert.equal(sha(teaserHtml(FERRET, RENDER)), BEFORE);
+ assert.equal(sha(teaserHtml({ ...FERRET, beat: TEASER_MOTION.gap }, RENDER)), BEFORE);
+ assert.notEqual(sha(teaserHtml({ ...FERRET, beat: 0.9 }, RENDER)), BEFORE);
+ // The page's data carries the times it always did, and nothing new.
+ const json = JSON.parse(teaserHtml(FERRET, RENDER).match(/<script id="teaser-data" type="application\/json">([\s\S]*?)<\/script>/)[1]);
+ assert.deepEqual(Object.keys(json.beats).sort(), ["end", "lines", "scale", "tailAt", "tailDur"]);
});
test("the hits sit on the pops: the cue list's times, no second copy", () => {
@@ -235,6 +323,14 @@ test("the cache key: the composed page changes with the words, the segment key w
assert.ok(existsSync(path.join(a.projDir, "assets", "TeaserDisplay.ttf")));
assert.ok(existsSync(path.join(a.projDir, "assets", "gsap.min.js")));
assert.ok(readFileSync(path.join(a.projDir, "index.html"), "utf8").includes("Another Arc"));
+ // The beat is in the key; its default's own is the same page. Without
+ // `seconds` the render is as long as the beats need.
+ const slow = await compose({ ...FERRET, beat: 0.9 });
+ assert.notEqual(slow.key, a.key);
+ assert.equal((await compose({ ...FERRET, beat: TEASER_MOTION.gap })).key, a.key);
+ const free = await compose({ ...FERRET, seconds: undefined, beat: 1.3 });
+ assert.equal(free.frameCount, 243);
+ await assert.rejects(() => compose({ ...FERRET, beat: 1.3 }), /seconds is 7, and at a beat of 1\.3s its lines need 8\.1s/);
await assert.rejects(
() => composeChrome({ manifestPath: mp, region: "teaser", segment: "nope" }),
/no teaser entry nope/,
@@ -246,6 +342,10 @@ test("the cache key: the composed page changes with the words, the segment key w
assert.notEqual(key(a.key, on), key(a.key, off));
assert.notEqual(key(a.key, on), key(b.key, on));
assert.equal(key(a.key, on), key(again.key, on));
+ // The beat moves the hits, so the sound's graph -- and the segment's key -- with it.
+ const onSlow = teaserAudioGraph(teaserHits({ ...FERRET, beat: 0.9 }), { seconds: 7, render: RENDER });
+ assert.notEqual(key(a.key, on), key(a.key, onSlow));
+ assert.equal(teaserAudioGraph(teaserHits({ ...FERRET, beat: TEASER_MOTION.gap }), { seconds: 7, render: RENDER }), on);
// So are the encode's parameters: a rebuild that re-encodes every clip
// re-encodes the teaser too.
assert.equal(key(a.key, on), key(a.key, on, { ...RENDER }));
diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs
@@ -40,10 +40,12 @@ 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, validateTeaser,
+ chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256, teaserSeconds, transitionOf,
+ validateTeaser,
} from "./deck.mjs";
import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs";
import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs";
+import { feedHtml } from "./chrome-feed.mjs";
const run = promisify(execFile);
@@ -614,7 +616,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, teaser }) {
+async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from, window, teaser, transition }) {
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,13 +645,23 @@ 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 === "feed") {
+ // The posts feed: the deck's faces and QR maker, one page for the whole cut.
+ const render = manifest.render;
+ const fonts = await copyFonts(render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true });
+ const posts = schedule.posts ?? [];
+ const byUrl = await qrPngsFor(posts.map((p) => p.qrUrl), render, resolveDeck(render).posts.qrSize, assetsDir);
+ const qrSrcs = {};
+ for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl);
+ return feedHtml(schedule, render, { fonts, qrSrcs, from, duration });
+ }
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 });
+ return teaserHtml(teaser, manifest.render, { font: TEASER_FONT_ASSET, transition });
}
throw new Error(`unknown chrome region: ${region}`);
}
@@ -712,13 +724,21 @@ function runRenderer(cmd, args) {
* their `.key`, cached exactly as the deck's are;
* - `still` is in CUT seconds.
*
+ * Feed (`region: "feed"`, a schedule with `layout: "feed"`): the posts column
+ * for the whole cut, exactly as the deck -- project `chrome/feed/`
+ * (`feed-preview/` when `preview`), frames `chrome/feed-frames/` and their
+ * `.key`, a window `feed-from<s>[-frames]`, cached as the deck's are.
+ *
* 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`).
+ * `buildTeaserSegment`);
+ * - `transition` is the cut's crossfade, read only by a teaser that dips (its
+ * lead is the dissolve and the black): the build passes its own, so a
+ * `--no-xfade` build composes the lead it plays; default the manifest's.
*
* `schedule` (an object) overrides reading `out/<variant>/schedule.json`.
*
@@ -730,7 +750,7 @@ export async function composeChrome({
manifestPath, outDir = null, variant = "sourced", region = "chart",
schedule = null, preview = false, doRender = false,
fps = null, workers = null, quality = "high", format = "png-sequence",
- still = null, png = null, from = 0, duration = null, window = null, segment = null,
+ still = null, png = null, from = 0, duration = null, window = null, segment = null, transition = null,
}) {
// The variant's view, and its own out directory. Handed the whole manifest
// the band would draw claims this cut never makes, and the deck would name
@@ -746,7 +766,7 @@ export async function composeChrome({
// 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";
+ const keyed = region === "deck" || region === "feed" || region === "posts" || region === "teaser";
let teaser = null;
if (region === "teaser") {
teaser = (manifest.timeline ?? []).find((e) => e.id === segment && e.type === "teaser") ?? null;
@@ -755,7 +775,7 @@ export async function composeChrome({
if (errors.length) throw new Error(`teaser ${teaser.id}: ${errors.join("; ")}`);
}
let sched = schedule;
- if ((region === "deck" || region === "posts") && !sched) {
+ if ((region === "deck" || region === "feed" || region === "posts") && !sched) {
const p = path.join(base, "schedule.json");
try {
sched = JSON.parse(await readFile(p, "utf8"));
@@ -764,10 +784,15 @@ export async function composeChrome({
}
if (sched.kind !== "deck") throw new Error(`${p} is not a deck schedule (kind ${sched.kind ?? "missing"})`);
}
- const total = teaser ? Number(teaser.seconds) : keyed ? sched.total : null;
+ if (region === "feed" && sched.layout !== "feed") {
+ throw new Error("the feed region needs a feed's schedule (layout \"feed\": posts.layout \"feed\" and posts to draw)");
+ }
+ const D = transition ?? transitionOf(manifest.render);
const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30);
+ const total = teaser ? teaserSeconds(teaser, D, rate) : keyed ? sched.total : null;
const windowed =
- region === "deck" && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6));
+ (region === "deck" || region === "feed") &&
+ (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6));
const suffix = windowed ? `-from${fmtSeconds(from)}` : "";
// A posts window: the one asked for, or the segment's from the schedule.
@@ -788,7 +813,7 @@ export async function composeChrome({
? `posts-${preview ? "preview-" : ""}${win.segment}`
: region === "teaser"
? `teaser-${preview ? "preview-" : ""}${teaser.id}`
- : region === "deck" && preview ? "deck-preview" : `${region}${suffix}`;
+ : (region === "deck" || region === "feed") && preview ? `${region}-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
@@ -799,7 +824,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, teaser,
+ duration: duration != null ? Number(duration) : null, from, window: win, teaser, transition: D,
});
await writeFile(path.join(projDir, "index.html"), html, "utf8");
await writeFile(path.join(projDir, "hyperframes.json"), HF_JSON + "\n", "utf8");
@@ -887,7 +912,7 @@ 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|teaser] [--variant sourced|full]\n" +
+ "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|feed|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" +
@@ -910,7 +935,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" || region === "teaser" ? 4 : region === "posts" ? 2 : null),
+ workers: num("--workers") ?? (region === "deck" || region === "feed" || region === "teaser" ? 4 : region === "posts" ? 2 : null),
quality: flag("--quality") ?? "high",
format: flag("--format") ?? "png-sequence",
fps: num("--fps"),
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -42,9 +42,12 @@ export const DECK_DEFAULTS = Object.freeze({
// 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`.
+ // `layout` "popup" draws them as above; "feed" keeps them in a column of
+ // their own beside the footage for the whole cut (feedGeometry), each one
+ // ticking in as its clip starts -- no hold, no move.
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 }),
+ show: true, layout: "popup", 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 }),
}),
});
@@ -187,7 +190,10 @@ 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", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift"], (p) => {
+ sub("posts", ["show", "layout", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift"], (p) => {
+ if (p.layout !== undefined && !POST_LAYOUTS.includes(p.layout)) {
+ errors.push(`${w}.posts.layout must be ${POST_LAYOUTS.map((l) => `"${l}"`).join(" or ")}`);
+ }
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 }`);
@@ -396,12 +402,14 @@ export function scheduleFrom(durs, D) {
* snapping moves the real one by a fraction of a second, which is why a
* schedule built from these says `estimated: true`.
*/
-export function estimatedDuration(entry, render = {}) {
+export function estimatedDuration(entry, render = {}, D = transitionOf(render)) {
if (entry.type === "clip") {
const { from, to } = playWindow(entry, render);
return Math.max(1, to - from);
}
if (entry.type === "image") return Number(entry.seconds ?? 4);
+ // A teaser's dip makes its segment longer by the dissolve and the black (`teaserLead`).
+ if (entry.type === "teaser") return teaserSeconds(entry, D, render.fps ?? 30);
return Number(entry.seconds ?? 5);
}
@@ -506,10 +514,11 @@ export function deckSchedule({
entries, durs, D, render, provenance = {}, metas = [], estimated = false, posts = [],
}) {
const deck = resolveDeck(render);
+ const fps = render.fps ?? 30;
// 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.
+ // own (probed or estimated) lengths. (The feed holds nothing: postHolds.)
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);
@@ -517,15 +526,19 @@ export function deckSchedule({
const round = (v) => Math.round(v * 1000) / 1000;
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 }) : [];
+ // The feed is a layout only when it has posts to draw: a feed with none is
+ // the deck alone, and writes the schedule a deck without posts always did.
+ const feed = placed.length > 0 && deck.posts.layout === "feed";
+ const moves = placed.length && !feed ? footageMoves({ posts: placed, segments: segs, render }) : [];
return {
version: 1,
kind: "deck",
estimated,
- fps: render.fps ?? 30,
+ fps,
transition: D,
total: round(total),
multiChannel,
+ ...(feed ? { layout: "feed" } : {}),
segments: entries.map((e, i) => {
const { title, subtitle } = deckText(e, metas[i] ?? null, provenance, deck, multiChannel);
return {
@@ -540,24 +553,37 @@ export function deckSchedule({
subtitle,
qrUrl: deck.qr.show ? deckQrUrl(e, provenance) : null,
hideDeck: hidesDeck(e, deck),
+ // Only on a teaser that dips, so a cut without one writes the schedule it always did.
+ ...(dipOf(e, fps) ? { dip: dipOf(e, fps) } : {}),
};
}),
// 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) })) } : {}),
+ ...(placed.length ? { posts: roundPosts(placed) } : {}),
...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}),
};
}
+/**
+ * `postSchedule`'s placements as a schedule writes them: their seconds to the
+ * millisecond -- a popup's `appear` and `out`, a feed's `in`. umtool's preview
+ * re-places posts and writes them through this too.
+ */
+export function roundPosts(placed) {
+ const round = (v) => Math.round(v * 1000) / 1000;
+ return placed.map((p) => ("in" in p ? { ...p, in: round(p.in) } : { ...p, appear: round(p.appear), out: p.out.map(round) }));
+}
+
/** `deckSchedule` from the manifest alone, for a preview before any build. */
export function estimateSchedule(manifest, { metas = [], noXfade = false } = {}) {
const render = manifest.render ?? {};
const entries = manifest.timeline ?? [];
+ const D = transitionOf(render, { noXfade });
return deckSchedule({
entries,
posts: manifest.posts ?? [],
- durs: entries.map((e) => estimatedDuration(e, render)),
- D: transitionOf(render, { noXfade }),
+ durs: entries.map((e) => estimatedDuration(e, render, D)),
+ D,
render,
provenance: manifest.provenance ?? {},
metas,
@@ -602,8 +628,13 @@ export function deckChoreography(schedule, render) {
const a = segs[i - 1].hideDeck;
const b = segs[i].hideDeck;
if (a !== b) {
- // Gone before the card is fully up; back once the footage is.
- const at = D > 0 ? [segs[i].start, segs[i].start + slide] : b ? [m - slide, m] : [m, m + slide];
+ // Gone before the card is fully up; back once the footage is. Into a
+ // teaser that dips, the deck is gone in an instant at the frame the
+ // dip has made black -- the previous segment's last (`dipHideAt`) --
+ // so nothing slides over the fade or the rise.
+ const at = b && segs[i].dip
+ ? [dipHideAt(segs[i], D, schedule.fps), dipHideAt(segs[i], D, schedule.fps)]
+ : D > 0 ? [segs[i].start, segs[i].start + slide] : b ? [m - slide, m] : [m, m + slide];
visibility.push({ i, hide: b, at });
continue;
}
@@ -638,6 +669,8 @@ export function pipSegments(schedule) {
// ---------------------------------------------------------------------------
export const POST_PLATFORMS = Object.freeze(["bluesky", "x"]);
+/** How posts are drawn (`posts.layout`): cards at the end of a clip, or a column for the whole cut. */
+export const POST_LAYOUTS = Object.freeze(["popup", "feed"]);
const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide"];
/**
@@ -688,7 +721,17 @@ export function postsFitErrors(render) {
const g = deckGeometry(render);
const pp = resolveDeck(render).posts;
const errors = [];
- if (pp.width + 2 * pp.inset > g.footage.width) {
+ if (pp.layout === "feed") {
+ // The feed's column stands beside the footage, not over it: what has to
+ // fit is the footage left beside it.
+ const f = feedGeometry(render);
+ if (f.footage.width < g.W / 2) {
+ errors.push(
+ `${w}.posts.width ${pp.width} with inset ${pp.inset} leaves the footage ${f.footage.width}px wide beside the feed ` +
+ `(at least half the frame, ${g.W / 2}px)`,
+ );
+ }
+ } else if (pp.width + 2 * pp.inset > g.footage.width) {
errors.push(`${w}.posts.width ${pp.width} with inset ${pp.inset} does not fit the ${g.footage.width}px footage box`);
}
if (pp.qrSize > pp.width / 2) errors.push(`${w}.posts.qrSize ${pp.qrSize} is more than half the card's width`);
@@ -739,7 +782,16 @@ export function attachPosts({ posts = [], entries = [], metas = [] }) {
/**
* When each placed post is on screen, in the cut's clock.
*
- * A clip's posts (oldest first) share an anchor A: the start of the outgoing
+ * In the FEED (`posts.layout: "feed"`) a post has one time, `in`: when it
+ * ticks into the column -- its clip's start + D, after the incoming dissolve,
+ * and D in on the FIRST clip as well, which has no dissolve into it (the
+ * popup's first clip starts at 0). A clip's posts (oldest first) follow one
+ * every `posts.seconds`, or closer when the clip is too short for that: post j
+ * of k ticks in at start + D + step·j, step = min(seconds, (A − start − D)/k),
+ * A the start of the outgoing transition as below -- so the last one is in at
+ * least `step` before its clip leaves. Once in, a post stays to the end.
+ *
+ * In the POPUP, a clip's posts (oldest first) share an anchor A: the start of the outgoing
* transition -- the next segment's start with a crossfade, 0.3 s before the cut
* on a hard cut, 0.3 s before the end on the last segment. Post j of k appears
* at A − step·(k − j), step = `posts.seconds`, so each has its seconds alone
@@ -752,6 +804,7 @@ export function attachPosts({ posts = [], entries = [], metas = [] }) {
*/
export function postSchedule({ posts = [], entries, metas = [], segments, D, total, render }) {
const settings = resolveDeck(render).posts;
+ const feed = settings.layout === "feed";
const byId = new Map(posts.map((p) => [p.id, p]));
const groups = new Map();
for (const a of attachPosts({ posts, entries, metas })) {
@@ -770,8 +823,14 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot
? [segments[i + 1].start, segments[i + 1].start + D]
: [segments[i + 1].start - 0.3, segments[i + 1].start];
const A = leave[0];
- const from = seg.start + (i > 0 ? D : 0);
const k = group.length;
+ if (feed) {
+ const from = seg.start + D;
+ const step = Math.min(settings.seconds, Math.max(0, A - from) / k);
+ group.forEach((p, j) => out.push({ id: p.id, segment: seg.id, slot: j, of: k, in: from + step * j, ...postFields(p) }));
+ return;
+ }
+ const from = seg.start + (i > 0 ? D : 0);
const step = Math.min(settings.seconds, Math.max(0, A - from) / k);
group.forEach((p, j) => {
out.push({
@@ -781,25 +840,34 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot
of: k,
appear: A - step * (k - j),
out: leave,
- date: p.date,
- text: p.text,
- author: p.author ?? "",
- handle: p.handle ?? "",
- platform: p.platform,
- url: p.url,
- qrUrl: p.url,
+ ...postFields(p),
});
});
});
return out;
}
+/** What a placed post carries for the page that draws it. */
+function postFields(p) {
+ return {
+ date: p.date,
+ text: p.text,
+ author: p.author ?? "",
+ handle: p.handle ?? "",
+ platform: p.platform,
+ url: p.url,
+ qrUrl: p.url,
+ };
+}
+
/**
* The windows the posts region is rendered for: one per clip that carries
* posts, from its first post's appearance to the end of its leave. Frames are
* only made for these seconds; the overlay places each at its `from`.
*/
export function postWindows(schedule) {
+ // The feed is one composition for the whole cut, not windows.
+ if (schedule.layout === "feed") return [];
const by = new Map();
for (const p of schedule.posts ?? []) {
const w = by.get(p.segment) ?? { segment: p.segment, from: Infinity, to: -Infinity };
@@ -834,6 +902,7 @@ export function snapWindow(window, { fps, total }) {
export function postsGeometry(render) {
const { W, footage: f } = deckGeometry(render);
const p = resolveDeck(render).posts;
+ if (p.layout === "feed") return feedGeometry(render).column;
const width = even(p.width);
const height = even(f.height - 2 * p.inset);
const left = p.position === "top-left";
@@ -862,6 +931,59 @@ export function shiftedFootage(render) {
}
/**
+ * The FEED's frame (`posts.layout: "feed"`): a column of posts for the whole
+ * cut on the `position` side, standing on the deck from the frame's top, and
+ * the footage box beside it. The deck stays full width at the bottom, so the
+ * column and the deck read as one L-shaped surface around the picture.
+ *
+ * - The column (`column`, the feed region) is `posts.width` wide, flush with
+ * the frame's side edge and top, down to the deck's top edge.
+ * - The footage keeps the frame's aspect and is as large as fits in what is
+ * left above the deck with `posts.inset` clear on every side (evened), and
+ * is centred there. At 1920×1080 with the defaults (190 px deck, 600 px
+ * column, inset 24): the column is 600×890 at (1320, 0) and the footage
+ * 1272×716 at (24, 87) -- 66 % of the frame's width, against the deck
+ * alone's 82 %.
+ *
+ * Nothing in it moves: every footage segment of a feed cut is framed into
+ * this box when it is built (build-video's deckFraming), and stays there.
+ *
+ * @returns {{ W: number, H: number, column: {x:number,y:number,width:number,height:number},
+ * footage: {x:number,y:number,width:number,height:number}, deck: {x:number,y:number,width:number,height:number} }}
+ */
+export function feedGeometry(render) {
+ const { W, H, deck } = deckGeometry(render);
+ const p = resolveDeck(render).posts;
+ const left = p.position === "top-left";
+ const cw = even(p.width);
+ const room = { x: left ? cw : 0, width: W - cw, height: H - deck.height };
+ const fit = Math.min(room.width - 2 * p.inset, ((room.height - 2 * p.inset) * W) / H);
+ const fw = even(Math.max(2, fit));
+ const fh = even((fw * H) / W);
+ return {
+ W, H,
+ column: { x: left ? 0 : W - cw, y: 0, width: cw, height: room.height },
+ footage: {
+ x: room.x + Math.floor((room.width - fw) / 2), y: Math.floor((room.height - fh) / 2), width: fw, height: fh,
+ },
+ deck,
+ };
+}
+
+/**
+ * Is this cut a FEED: the deck on, its posts shown in the `feed` layout, and
+ * at least one post to draw on a clip of `entries` (the cut's whole timeline,
+ * never an `--only` slice of it)? The build frames its footage into
+ * `feedGeometry` exactly when this is true, and the schedule says
+ * `layout: "feed"` exactly then -- a feed with nothing to draw is the deck alone.
+ */
+export function feedOn({ render, posts = [], entries = [] }) {
+ if (!deckOn(render)) return false;
+ const p = resolveDeck(render).posts;
+ return p.show && p.layout === "feed" && attachPosts({ posts: posts ?? [], entries }).length > 0;
+}
+
+/**
* 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
@@ -869,9 +991,11 @@ export function shiftedFootage(render) {
*/
export function postHolds({ posts = [], entries = [], metas = [], render }) {
const fps = render?.fps ?? 30;
- const hold = Math.round(resolveDeck(render).posts.hold * fps) / fps;
+ const settings = resolveDeck(render).posts;
+ const hold = Math.round(settings.hold * fps) / fps;
const out = new Map();
- if (!(hold > 0)) return out;
+ // The feed never pauses the cut: a post is in its column while the clip plays on.
+ if (!(hold > 0) || settings.layout === "feed") return out;
for (const a of attachPosts({ posts, entries, metas })) out.set(a.entryId, hold);
return out;
}
@@ -936,10 +1060,17 @@ export function validateEndFade(render) {
return [];
}
-/** Every `muteFrom` in the timeline and `render.endFade`, checked: the build refuses with these before it fetches. */
+/**
+ * Every `muteFrom` in the timeline, a `dip` on anything but a teaser, 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 ?? "?"})`)));
+ (manifest?.timeline ?? []).forEach((e, i) => {
+ errors.push(...validateMuteFrom(e, `timeline[${i}] (${e?.id ?? "?"})`));
+ // A teaser's dip is validateTeasers'; a dip anywhere else is refused here.
+ if (e?.type !== "teaser") errors.push(...validateDip(e, `timeline[${i}] (${e?.id ?? "?"})`));
+ });
errors.push(...validateEndFade(manifest?.render));
return errors;
}
@@ -987,7 +1118,7 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds
// render (chrome-teaser.mjs draws it, compose-chrome renders it, the build
// encodes it). Pure here: what the words are, and why they cannot be drawn.
//
-// { "type": "teaser", "id": "fin", "seconds": 7,
+// { "type": "teaser", "id": "fin", "seconds": 7, "beat": 0.7,
// "lines": ["Pirate Software",
// { "text": "The Largest Ferret Rescue in the United States",
// "break": "in the United States" },
@@ -998,6 +1129,10 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds
// as a smaller second tier under the rest, a beat later. The tail is appended
// to the last line and fades in on its own. `hits` (default true) puts a
// trailer hit under each pop and a swell under the tail; false is silence.
+// `beat` (optional) is the seconds from one line's pop to the next
+// (`teaserMotion`); `seconds` (optional) is the card's length, and without it
+// the card is as long as its beats need (`teaserSeconds`). Nothing is ever
+// squeezed to fit: a `seconds` too short for the beats is refused.
// Roles follow position: with three
// or more lines the first is the overline and the last the kicker (a date),
// everything between is a title; two lines are an overline and a title; one
@@ -1068,25 +1203,53 @@ export function teaserTitle(entry) {
* -- undershooting to `under` -- `hit` after it starts, then settles to rest
* over `settle`. The tail starts `tailAfter` after the last line's pop and
* fades in over `tailDur`. The last `endRoom` seconds hold still for the
- * cut's end fade; a card too short for all of it plays every beat
- * proportionally faster (`teaserTimes`).
+ * cut's end fade. `gap` is the default beat; an entry's `beat` replaces it
+ * (`teaserMotion`).
*/
export const TEASER_MOTION = Object.freeze({
first: 0.55, gap: 0.7, sub: 0.3, slam: 1.42, under: 0.968, hit: 0.2, settle: 0.5,
blur: 18, tailAfter: 0.8, tailDur: 1.7, endRoom: 1.2, push: 1.065, grainHz: 12,
});
+/** The beats an entry's `beat` may be: from 0.4 s (packed) to 2.5 s (a pause between each). */
+export const TEASER_BEAT = Object.freeze({ min: 0.4, max: 2.5 });
+
+/**
+ * The motion at an entry's beat: `gap` is the beat, and the two waits that
+ * read as part of it scale with it in the default's proportion -- the second
+ * tier's `sub` stays 3/7 of the beat (0.3 s of 0.7) and the tail's
+ * `tailAfter` 8/7 (0.8 s of 0.7), so a slower beat is the same rhythm slowed,
+ * not three faster pops with longer pauses between. What is NOT the beat stays
+ * put: the first line's landing (`first`, timed to the incoming dissolve), the
+ * slam's `hit` and `settle`, the tail's fade (`tailDur`, the swell under it)
+ * and the end fade's room. No beat, or the default's own, is TEASER_MOTION
+ * itself -- an entry without `beat` composes the page it always did.
+ */
+export function teaserMotion(beat) {
+ const m = TEASER_MOTION;
+ if (beat === undefined || beat === null || Number(beat) === m.gap) return m;
+ const k = Number(beat) / m.gap;
+ const r = (v) => Math.round(v * 10000) / 10000;
+ return Object.freeze({ ...m, gap: r(Number(beat)), sub: r(m.sub * k), tailAfter: r(m.tailAfter * k) });
+}
+
/**
* When everything in a teaser happens, in the card's clock: per line its
* start (`at`), its impact (`impact` = at + hit, where the slam lands, the
* flash fires and the hit sounds) and its second tier's pop (`subAt`, null
- * without one); the tail's start and length; and `scale` (< 1 when the beats
- * were compressed to fit the card). `T` scales any motion length the same way.
+ * without one); the tail's start and length; `end`, when the last thing has
+ * arrived; and `need`, the card's least length -- `end` plus the end fade's
+ * still room. Nothing is compressed: a card shorter than `need` is refused by
+ * `validateTeaser`, never squeezed. `scale` is always 1 and `T` only rounds;
+ * both stay because the page carries `scale` in its data and the cues are
+ * written through `T` -- an unchanged teaser's page, and so its render key,
+ * are unchanged.
*
* @returns {{ lines: Array<{ at: number, impact: number, subAt: number|null }>,
- * tailAt: number|null, tailDur: number, end: number, scale: number, T: (v: number) => number }}
+ * tailAt: number|null, tailDur: number, end: number, need: number, scale: number,
+ * T: (v: number) => number }}
*/
-export function teaserTimes(lines, tail, seconds, m = TEASER_MOTION) {
+export function teaserTimes(lines, tail, m = TEASER_MOTION) {
let t = m.first;
const raw = [];
lines.forEach((l, i) => {
@@ -1098,20 +1261,153 @@ export function teaserTimes(lines, tail, seconds, m = TEASER_MOTION) {
});
const tailRaw = tail ? t + m.tailAfter : null;
const endRaw = tailRaw != null ? tailRaw + m.tailDur : t + m.hit + m.settle;
- const room = Math.max(0.5, seconds - m.endRoom);
- const scale = endRaw > room ? room / endRaw : 1;
const r = (v) => Math.round(v * 10000) / 10000;
- const T = (v) => r(v * scale);
+ const T = r;
return {
lines: raw.map((b) => ({ at: T(b.at), impact: r(T(b.at) + T(m.hit)), subAt: b.subAt == null ? null : T(b.subAt) })),
tailAt: tailRaw == null ? null : T(tailRaw),
tailDur: T(m.tailDur),
end: T(endRaw),
- scale: r(scale),
+ need: r(endRaw + m.endRoom),
+ scale: 1,
T,
};
}
+// ---- the dip: the cut goes to black before a teaser ------------------------
+//
+// { "type": "teaser", "id": "fin", "dip": { "fade": 1.2, "black": 0.6 }, … }
+//
+// The previous segment's last `fade` seconds -- the WHOLE frame as the cut
+// plays it, footage and every overlay, and its sound -- ease to black and
+// silence, ending on its last frame; then `black` seconds of black; then the
+// teaser comes up out of it. The black is the teaser's own LEAD
+// (`teaserLead`): its page and its sound start with the dissolve into it and
+// the black, both dark, so the dissolve is black on black and no hold is
+// needed anywhere. The fade is made where the cut is joined (build-video's
+// `dipWindows`), so `--chrome-only` changes it without touching a clip.
+
+/** A dip's limits, in seconds: the fade out, and the black after it. */
+export const DIP_LIMITS = Object.freeze({ fade: Object.freeze([0.3, 4]), black: Object.freeze([0, 3]) });
+
+/**
+ * The rise out of a dip, in seconds around the first line's impact: the veil
+ * over the ground starts lifting `before` it, as the black ends, and is gone
+ * `after` it -- so the first hit is the moment the light comes on. The first
+ * line's slam starts `before − hit` after the black (0.15 s).
+ */
+export const DIP_RISE = Object.freeze({ before: 0.35, after: 0.3 });
+
+/** The riser under the black: at most `seconds` long, ending on the first hit; a noise swell over a low sub. */
+export const DIP_RISER = Object.freeze({ seconds: 1, gain: 0.22, f0: 30, f1: 55 });
+
+const DIP_KEYS = ["fade", "black"];
+
+/**
+ * Why one entry's `dip` cannot be built, as sentences. Only a teaser dips (for
+ * now); `fade` and `black` are both required, in DIP_LIMITS. Absent is fine.
+ */
+export function validateDip(entry, where = `timeline entry ${entry?.id ?? "?"}`) {
+ const v = entry?.dip;
+ if (v === undefined || v === null) return [];
+ if (entry.type !== "teaser") {
+ return [`${where}.dip: only a teaser dips to black before it -- move the dip onto the teaser that follows`];
+ }
+ if (!isObj(v)) return [`${where}.dip must be { fade, black } in seconds`];
+ const errors = [];
+ for (const k of Object.keys(v)) if (!DIP_KEYS.includes(k)) errors.push(`${where}.dip.${k} is not a dip setting (fade, black)`);
+ const [flo, fhi] = DIP_LIMITS.fade;
+ const [blo, bhi] = DIP_LIMITS.black;
+ if (!numIn(v.fade, flo, fhi)) errors.push(`${where}.dip.fade must be from ${flo} to ${fhi} seconds`);
+ if (!numIn(v.black, blo, bhi)) errors.push(`${where}.dip.black must be from ${blo} to ${bhi} seconds`);
+ return errors;
+}
+
+/**
+ * Seconds as a whole number of frames at `fps`: unchanged when they already
+ * are one (0.6 at 30 fps stays 0.6, byte for byte), else the nearest frame,
+ * to the ten-thousandth.
+ */
+export function snapToFrames(seconds, fps = 30) {
+ const n = Math.round(seconds * fps);
+ if (Math.abs(seconds * fps - n) < 1e-6) return seconds;
+ return Math.round((n / fps) * 10000) / 10000;
+}
+
+/**
+ * An entry's dip, `{ fade, black }`, or null: only a teaser's, and only a
+ * sound one. `black` is snapped to whole frames at `fps` (`snapToFrames`), so
+ * the teaser's lead, its page's rise, its frame count and the joined cut's
+ * black (`dipWindows`' `until`) all fall on the same frame.
+ */
+export function dipOf(entry, fps = 30) {
+ if (entry?.type !== "teaser" || !isObj(entry.dip)) return null;
+ const { fade, black } = entry.dip;
+ if (!numIn(fade, ...DIP_LIMITS.fade) || !numIn(black, ...DIP_LIMITS.black)) return null;
+ return { fade, black: snapToFrames(black, fps) };
+}
+
+/**
+ * The dark start of a teaser that dips, in its own clock: the dissolve into it
+ * (`D`, the cut's transition) plus the dip's black (whole frames at `fps`).
+ * Its page is black and its sound silent (but for the riser) until then; 0
+ * without a dip.
+ */
+export function teaserLead(entry, D = 0.5, fps = 30) {
+ const dip = dipOf(entry, fps);
+ return dip ? Math.round((D + dip.black) * 10000) / 10000 : 0;
+}
+
+/**
+ * The cut second the deck and the feed are gone at, over a teaser that dips:
+ * the previous segment's last frame (the dissolve's end less a frame), which
+ * the dip has made black. `seg` is the teaser's schedule segment.
+ */
+export function dipHideAt(seg, D, fps = 30) {
+ return Math.round((seg.start + D - 1 / fps) * 10000) / 10000;
+}
+
+/**
+ * The motion of an entry's teaser in its SEGMENT's clock: `teaserMotion` of its
+ * beat, and with a dip the first line's start moved to `lead + before − hit`
+ * -- after the black, timed to the rise (DIP_RISE) instead of the incoming
+ * dissolve. Without a dip it is `teaserMotion(beat)` itself.
+ */
+export function teaserMotionOf(entry, D = 0.5, fps = 30) {
+ return dippedMotion(entry, teaserLead(entry, D, fps));
+}
+
+/** `teaserMotion(beat)`, its first line moved to `lead + before − hit` when the entry dips. */
+function dippedMotion(entry, lead) {
+ const m = teaserMotion(entry?.beat);
+ if (!dipOf(entry)) return m;
+ return Object.freeze({ ...m, first: Math.round((lead + DIP_RISE.before - m.hit) * 10000) / 10000 });
+}
+
+/**
+ * The card's length -- from where the light comes up, after any dip's black:
+ * its `seconds` when it sets one, else what its beats need (`teaserTimes(...).need`:
+ * the last pop, the tail's fade, the end fade's still room), rounded UP to a
+ * tenth of a second and at least the shortest a teaser may be. The same
+ * whatever the transition. Assumes `validateTeaser` passed.
+ */
+export function teaserCardSeconds(entry) {
+ if (entry?.seconds !== undefined && entry?.seconds !== null) return Number(entry.seconds);
+ const need = teaserTimes(teaserLines(entry), teaserTail(entry), dippedMotion(entry, 0)).need;
+ return Math.max(TEASER_LIMITS.seconds[0], Math.ceil(need * 10 - 1e-6) / 10);
+}
+
+/**
+ * A teaser's length in seconds: its lead (`teaserLead`: the dissolve and a
+ * dip's black, 0 without a dip) and its card (`teaserCardSeconds`). `D` is
+ * the cut's transition, read only for a dip. Without a dip it is
+ * `teaserCardSeconds`, as it always was. Assumes `validateTeaser` passed.
+ */
+export function teaserSeconds(entry, D = 0.5, fps = 30) {
+ const lead = teaserLead(entry, D, fps);
+ return lead ? Math.round((lead + teaserCardSeconds(entry)) * 10000) / 10000 : teaserCardSeconds(entry);
+}
+
/**
* 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
@@ -1121,15 +1417,26 @@ export function teaserTimes(lines, tail, seconds, m = TEASER_MOTION) {
* 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,
+ * A teaser that dips (`D` is the cut's transition, read only then) has every
+ * time in its segment's clock, after the lead (`teaserMotionOf`), and a riser
+ * first: DIP_RISER's sub and noise swelling up through the black for at most
+ * `seconds`, ending on the first line's impact.
+ *
+ * @returns {Array<{ kind: "hit"|"swell"|"riser", at: number, role: string, gain: number,
* decay: number, f0: number, f1: number, dur?: number }>}
*/
-export function teaserHits(entry) {
+export function teaserHits(entry, D = 0.5, fps = 30) {
if (entry?.hits === false) return [];
const lines = teaserLines(entry);
const tail = teaserTail(entry);
- const times = teaserTimes(lines, tail, Number(entry.seconds));
+ const times = teaserTimes(lines, tail, teaserMotionOf(entry, D, fps));
const out = [];
+ if (dipOf(entry) && times.lines.length) {
+ const end = times.lines[0].impact;
+ const at = Math.round(Math.max(0, end - DIP_RISER.seconds) * 10000) / 10000;
+ const { gain, f0, f1 } = DIP_RISER;
+ out.push({ kind: "riser", at, role: "dip", gain, decay: 0.04, f0, f1, dur: Math.round((end - at) * 10000) / 10000 });
+ }
const HIT = {
title: { gain: 1, decay: 0.42, f0: 92, f1: 40 },
overline: { gain: 0.72, decay: 0.34, f0: 96, f1: 44 },
@@ -1158,7 +1465,12 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"
if (typeof entry?.id !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(entry.id)) {
errors.push(`${where}.id must be letters, digits, dashes or underscores (it names the segment's file)`);
}
- if (!numIn(entry?.seconds, slo, shi)) errors.push(`${where}.seconds must be from ${slo} to ${shi}`);
+ const hasSeconds = entry?.seconds !== undefined && entry?.seconds !== null;
+ if (hasSeconds && !numIn(entry.seconds, slo, shi)) errors.push(`${where}.seconds must be from ${slo} to ${shi}, or absent`);
+ const hasBeat = entry?.beat !== undefined && entry?.beat !== null;
+ if (hasBeat && !numIn(entry.beat, TEASER_BEAT.min, TEASER_BEAT.max)) {
+ errors.push(`${where}.beat must be from ${TEASER_BEAT.min} to ${TEASER_BEAT.max} seconds, or absent`);
+ }
const oneLine = (s, w) => {
if (typeof s !== "string" || !s.trim()) { errors.push(`${w} must be words, not empty`); return false; }
if (/[\r\n]/.test(s)) { errors.push(`${w} must be one line`); return false; }
@@ -1187,6 +1499,7 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"
});
}
if (entry?.hits !== undefined && typeof entry.hits !== "boolean") errors.push(`${where}.hits must be true or false`);
+ errors.push(...validateDip(entry, where));
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`);
@@ -1218,6 +1531,27 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"
}
});
}
+ // The length the beats need, once the lines, the tail and the beat are
+ // sound: never squeezed, so a `seconds` short of it is refused with it, and
+ // a card that needs more than a teaser may run says so.
+ // With a dip, `seconds` and the need are the card's, from where the light
+ // comes up -- the lead before it is the dip's, not the lines'.
+ if (!errors.length) {
+ const m = dippedMotion(entry, 0);
+ const need = teaserTimes(teaserLines(entry), teaserTail(entry), m).need;
+ const least = teaserCardSeconds({ ...entry, seconds: undefined });
+ const at = `at a beat of ${m.gap}s`;
+ if (hasSeconds && entry.seconds < need - 1e-9) {
+ errors.push(
+ `${where}.seconds is ${entry.seconds}, and ${at} its lines need ${least}s ` +
+ `(the last pop, the tail's fade and ${TEASER_MOTION.endRoom}s still for the end fade` +
+ `${dipOf(entry) ? ", counted from the end of the dip's black" : ""}) -- ` +
+ `set it to ${least} or more, or leave it out for exactly that`,
+ );
+ } else if (!hasSeconds && least > shi) {
+ errors.push(`${where} needs ${least}s ${at}, and a teaser runs at most ${shi}s -- a shorter beat, or fewer lines`);
+ }
+ }
return errors;
}
@@ -1225,7 +1559,13 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"
export function validateTeasers(manifest) {
const errors = [];
(manifest?.timeline ?? []).forEach((e, i) => {
- if (e?.type === "teaser") errors.push(...validateTeaser(e, `timeline[${i}] (${e.id ?? "?"})`));
+ if (e?.type !== "teaser") return;
+ const where = `timeline[${i}] (${e.id ?? "?"})`;
+ errors.push(...validateTeaser(e, where));
+ // A dip fades the segment before the teaser: the first entry has none.
+ if (i === 0 && e.dip !== undefined && e.dip !== null) {
+ errors.push(`${where}.dip: a dip fades the entry before the teaser to black, and the first entry has none before it`);
+ }
});
return errors;
}
@@ -1250,6 +1590,18 @@ export function chromeCacheKey({ html, assets = [], fps, frames, version }) {
export const frameCount = (total, fps) => Math.round(total * fps);
/**
+ * The `data-duration` a page states for `seconds` at `fps`: its whole frames
+ * (`frameCount`) back in seconds, FLOORED to 4 decimals. HyperFrames renders
+ * ceil(duration × fps) frames, so a duration rounded UP past a frame boundary
+ * -- a cut of 434 frames is 14.4667 s, and its schedule's 14.467 -- rendered
+ * one frame more than the build expects (435) and the build refused it. A
+ * duration already on the 4-decimal grid of whole frames (14.4, 7.9, 356.7) is
+ * written as it always was.
+ */
+export const pageDuration = (seconds, fps) =>
+ Math.floor((frameCount(seconds, fps) / fps) * 10000 + 1e-6) / 10000;
+
+/**
* How to invoke HyperFrames. `HYPERFRAMES_BIN` (an executable taking the CLI's
* own arguments, e.g. an e2e stub) wins; else `npx --yes <HYPERFRAMES_PKG>`,
* pinned by default. `version` is what the cache key records.
diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs
@@ -413,3 +413,32 @@ test("posts shift: the footage moves aside from the column while a clip's posts
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/);
});
+
+import { pageDuration } from "./deck.mjs";
+import { deckHtml } from "./chrome-deck.mjs";
+
+test("pageDuration: whole frames, floored to 4 decimals, so HyperFrames' ceil(duration × fps) is the build's frame count", () => {
+ // On the grid: written as before.
+ for (const v of [14.4, 7.9, 356.7, 7, 0.5, 14.2]) assert.equal(pageDuration(v, 30), v);
+ // 434 frames is 14.4667 s, and a schedule writes 14.467: both read as 434 frames, never 435.
+ for (const v of [14.467, 14.4667, 434 / 30]) {
+ const d = pageDuration(v, 30);
+ assert.equal(d, 14.4666);
+ assert.equal(Math.ceil(d * 30), 434);
+ assert.equal(frameCount(d, 30), frameCount(v, 30));
+ }
+ assert.equal(pageDuration(14.767, 30), 14.7666);
+ assert.equal(Math.ceil(pageDuration(14.633, 30) * 30), 439);
+ assert.equal(pageDuration(10.04, 25), 10.04);
+});
+
+test("the deck page states pageDuration of the cut, not its millisecond total", () => {
+ const timeline = [
+ { id: "a", type: "clip", video: "v", start: 0, end: 7.0667 },
+ { id: "b", type: "clip", video: "v", start: 10, end: 17.9 },
+ ];
+ const R = { ...RENDER, palette: { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" } };
+ const s = deckSchedule({ entries: timeline, durs: [212 / 30, 7.9], D: 0.5, render: R, provenance: PROV });
+ assert.equal(s.total, 14.467);
+ assert.match(deckHtml(s, R), /data-composition-id="deck" data-start="0" data-duration="14\.4666"/);
+});
diff --git a/umtool/report-to-video/dip.test.mjs b/umtool/report-to-video/dip.test.mjs
@@ -0,0 +1,625 @@
+// The dip: a teaser's `dip: { fade, black }` takes the cut to black before it.
+// Its validation; the arithmetic (the lead, the card, the hits and the riser
+// after it, the schedule's instant hide); the page that rises out of the
+// black; the graphs as strings, unchanged without a dip; and real ffmpeg runs
+// showing a dipped join reaches black across the WHOLE frame -- a stand-in
+// deck and feed overlaid on it included -- and silence for the black span,
+// while everything before the fade is the undipped cut's own, frame for frame.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ applyChromeArgs, chromeOverlayChain, concatRecordText, cutJoins, cutOffsets, dipParts, dipVideoFilter, dipWindows,
+ endFadeAudioFilter, hardCutFilterArgs, joinInputChain, teaserAudioGraph, withCutEdits, xfadeConcatArgs, xfadeGraph,
+} from "./build-video.mjs";
+import {
+ deckChoreography, deckSchedule, DIP_LIMITS, DIP_RISE, DIP_RISER, dipHideAt, dipOf, estimatedDuration,
+ estimateSchedule, TEASER_MOTION, teaserCardSeconds, teaserHits, teaserLead, teaserLines, teaserMotion, teaserMotionOf,
+ teaserSeconds, teaserTimes, validateCutEdits, validateDip, validateTeaser, validateTeasers,
+} from "./deck.mjs";
+import { teaserCues, teaserHtml } from "./chrome-teaser.mjs";
+import { composeChrome } from "./compose-chrome.mjs";
+import { verifyTeasers } from "./verify-build.mjs";
+
+const have = spawnSync("ffmpeg", ["-version"]).status === 0;
+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: {} },
+};
+// The ferret finale at the operator's beat, no `seconds`: as long as its beats need.
+const FIN = Object.freeze({
+ type: "teaser", id: "fin", beat: 1.05,
+ lines: ["Pirate Software", { text: "The Largest Ferret Rescue in the United States", break: "in the United States" }, "February 2027"],
+ tail: "?",
+});
+const dipped = (fade = 1.2, black = 0.6, e = FIN) => ({ ...e, dip: { fade, black } });
+const CLIP = { id: "c20", type: "clip", video: "B36", start: 24022.6, end: 24029.6 };
+const r4 = (v) => Math.round(v * 10000) / 10000;
+
+// ---- validation -------------------------------------------------------------
+
+test("validateDip: only a teaser, { fade, black } in their ranges, no other key; absent is fine", () => {
+ assert.deepEqual(validateDip(FIN), []);
+ assert.deepEqual(validateDip(dipped()), []);
+ assert.deepEqual(validateDip(dipped(DIP_LIMITS.fade[0], DIP_LIMITS.black[0])), []);
+ assert.deepEqual(validateDip(dipped(DIP_LIMITS.fade[1], DIP_LIMITS.black[1])), []);
+ const bad = (dip, e = FIN) => validateDip({ ...e, dip }, "fin").join(" | ");
+ assert.match(bad({ fade: 0.2, black: 0.5 }), /^fin\.dip\.fade must be from 0\.3 to 4 seconds$/);
+ assert.match(bad({ fade: 4.5, black: 0.5 }), /fade must be from 0\.3 to 4/);
+ assert.match(bad({ fade: 1, black: -0.1 }), /^fin\.dip\.black must be from 0 to 3 seconds$/);
+ assert.match(bad({ fade: 1, black: 3.5 }), /black must be from 0 to 3/);
+ assert.match(bad({ fade: 1 }), /black must be from 0 to 3/);
+ assert.match(bad({ fade: "1", black: 0 }), /fade must be/);
+ assert.match(bad({ fade: 1, black: 0, colour: "red" }), /^fin\.dip\.colour is not a dip setting \(fade, black\)$/);
+ assert.match(bad(1.2), /^fin\.dip must be \{ fade, black \} in seconds$/);
+ assert.match(bad([1, 0]), /must be \{ fade, black \}/);
+ // Only a teaser dips; dipOf reads only a sound one.
+ assert.match(bad({ fade: 1, black: 0 }, CLIP), /^fin\.dip: only a teaser dips to black before it -- move the dip onto the teaser that follows$/);
+ assert.equal(dipOf({ ...CLIP, dip: { fade: 1, black: 0 } }), null);
+ assert.equal(dipOf({ ...FIN, dip: { fade: 9, black: 0 } }), null);
+ assert.deepEqual(dipOf(dipped(0.9, 0.4)), { fade: 0.9, black: 0.4 });
+});
+
+test("the timeline: a teaser's dip in validateTeaser(s), anywhere else in validateCutEdits; never on the first entry", () => {
+ assert.deepEqual(validateTeaser(dipped()), []);
+ assert.match(validateTeaser({ ...FIN, dip: { fade: 9, black: 0 } }).join(" | "), /dip\.fade must be from 0\.3 to 4/);
+ const ok = { render: RENDER, timeline: [CLIP, dipped()] };
+ assert.deepEqual(validateTeasers(ok), []);
+ assert.deepEqual(validateCutEdits(ok), []);
+ assert.deepEqual(validateTeasers({ timeline: [dipped(), CLIP] }), [
+ "timeline[0] (fin).dip: a dip fades the entry before the teaser to black, and the first entry has none before it",
+ ]);
+ assert.deepEqual(validateCutEdits({ timeline: [{ ...CLIP, dip: { fade: 1, black: 0 } }, FIN] }), [
+ "timeline[0] (c20).dip: only a teaser dips to black before it -- move the dip onto the teaser that follows",
+ ]);
+ // validateCutEdits leaves a teaser's own dip to validateTeasers: one sentence, not two.
+ assert.deepEqual(validateCutEdits({ timeline: [CLIP, { ...FIN, dip: { fade: 9, black: 0 } }] }), []);
+});
+
+test("with a dip, `seconds` is the card's, from where the light comes up: the first line no longer waits for a dissolve", () => {
+ // Without a dip the ferret lines at beat 1.05 need 7.2 s; 7 is refused.
+ assert.match(validateTeaser({ ...FIN, seconds: 7 }).join(" | "), /seconds is 7, and at a beat of 1\.05s its lines need 7\.2s/);
+ // Out of black the first line lands DIP_RISE.before − hit after the black
+ // (0.15 s, not 0.55): the card needs 0.4 s less, and 7 is enough.
+ assert.deepEqual(validateTeaser({ ...dipped(), seconds: 7 }), []);
+ assert.match(
+ validateTeaser({ ...dipped(), seconds: 6.5 }).join(" | "),
+ /seconds is 6\.5, and at a beat of 1\.05s its lines need 6\.8s \(the last pop, the tail's fade and 1\.2s still for the end fade, counted from the end of the dip's black\)/,
+ );
+});
+
+// ---- the arithmetic -----------------------------------------------------------
+
+test("the lead: the dissolve plus the black; the segment is the lead plus the card, auto or set", () => {
+ assert.equal(teaserLead(FIN, 0.5), 0);
+ assert.equal(teaserLead(dipped(0.9, 0.4), 0.5), 0.9);
+ assert.equal(teaserLead(dipped(1.2, 0.6), 0.5), 1.1);
+ assert.equal(teaserLead(dipped(1.6, 1.0), 0.5), 1.5);
+ assert.equal(teaserLead(dipped(1.2, 0.6), 0), 0.6); // a hard cut has no dissolve to hide
+ assert.equal(teaserLead(dipped(1.2, 0), 0), 0);
+ // No dip: the card, as it always was, whatever the transition.
+ assert.equal(teaserSeconds(FIN), 7.2);
+ assert.equal(teaserSeconds(FIN, 0), 7.2);
+ assert.equal(teaserCardSeconds(FIN), 7.2);
+ // A dip: the card (6.8 s, the same for every dip and transition) after the lead.
+ for (const [fade, black, total] of [[0.9, 0.4, 7.7], [1.2, 0.6, 7.9], [1.6, 1, 8.3]]) {
+ assert.equal(teaserCardSeconds(dipped(fade, black)), 6.8);
+ assert.equal(teaserSeconds(dipped(fade, black), 0.5), total);
+ }
+ assert.equal(teaserSeconds(dipped(1.2, 0.6), 0), 7.4);
+ // A set `seconds` is the card's.
+ assert.equal(teaserSeconds({ ...dipped(1.2, 0.6), seconds: 7 }, 0.5), 8.1);
+ // The schedule's estimate counts the lead, at the cut's transition (or a hard cut's).
+ assert.equal(estimatedDuration(dipped(), RENDER), 7.9);
+ assert.equal(estimatedDuration(dipped(), { ...RENDER, transition: 0 }), 7.4);
+ assert.equal(estimatedDuration(dipped(), RENDER, 0), 7.4);
+ assert.equal(estimatedDuration(FIN, RENDER), 7.2);
+});
+
+test("the black is whole frames at the cut's fps: the lead, the page's rise, the frame count and the cut's black agree", () => {
+ const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 0.01, `${msg}: ${a} != ${b}`);
+ // Already whole frames: the very number given, so nothing built from it changes.
+ for (const [black, fps] of [[0.6, 30], [0.4, 30], [1, 30], [0, 30], [3, 30], [0.6, 25], [0.45, 60]]) {
+ assert.ok(Object.is(dipOf(dipped(1.2, black), fps).black, black), `${black} at ${fps}`);
+ }
+ assert.deepEqual(dipOf(dipped()), { fade: 1.2, black: 0.6 }); // fps defaults to 30
+ // Between frames: the nearest one (13.5 frames → 14 at 30 fps; 11.25 → 11 at 25).
+ assert.equal(dipOf(dipped(1, 0.45), 30).black, 0.4667);
+ assert.equal(dipOf(dipped(1, 0.45), 25).black, 0.44);
+ assert.equal(dipOf(dipped(1, 0.4667), 30).black, 0.4667); // snapping is idempotent
+ // Only the black is snapped: the fade's frames are endFadeFrames', on the joined segment.
+ assert.equal(dipOf(dipped(1.05, 0.45), 30).fade, 1.05);
+ const e = dipped(1, 0.45);
+ for (const fps of [25, 30]) {
+ const black = dipOf(e, fps).black;
+ const lead = teaserLead(e, 0.5, fps);
+ assert.equal(lead, r4(0.5 + black));
+ // The lead is whole frames past the dissolve; at 30 fps (where a 0.5 s
+ // dissolve and a card in tenths are whole frames too) so is the segment.
+ near(lead * fps - 0.5 * fps, Math.round(black * fps), `lead at ${fps}`);
+ const seconds = teaserSeconds(e, 0.5, fps);
+ assert.equal(seconds, r4(lead + teaserCardSeconds(e)));
+ if (fps === 30) near(seconds * fps, Math.round(seconds * fps), "segment at 30");
+ // The light comes up where the black ends: the motion and the page read the snapped lead.
+ assert.equal(teaserMotionOf(e, 0.5, fps).first, r4(lead + DIP_RISE.before - teaserMotion(e.beat).hit));
+ assert.equal(
+ teaserHtml(e, { ...RENDER, fps }, { transition: 0.5 }),
+ teaserHtml(dipped(1, black), { ...RENDER, fps }, { transition: 0.5 }),
+ );
+ // The joined cut's black ends on the frame the teaser's lead does.
+ const joins = withCutEdits(null, 2, { dips: new Map([[0, { seconds: 1, lastFrame: 89, black }]]) });
+ const [w] = dipWindows(joins, [3, seconds], 0.5, fps);
+ near(w.until - (w.last + 1), black * fps, `until at ${fps}`);
+ }
+ // The schedule carries the snapped black, and the estimate counts it.
+ const s = deckSchedule({ entries: [CLIP, e], durs: [7, teaserSeconds(e, 0.5, 30)], D: 0.5, render: RENDER });
+ assert.deepEqual(s.segments[1].dip, { fade: 1, black: 0.4667 });
+ assert.equal(estimatedDuration(e, RENDER), teaserSeconds(e, 0.5, 30));
+ assert.equal(estimatedDuration(e, { ...RENDER, fps: 25 }), teaserSeconds(e, 0.5, 25));
+});
+
+test("the motion: every line after the lead, the first's impact DIP_RISE.before after the black; nothing else moves", () => {
+ const plain = teaserTimes(teaserLines(FIN), "?", teaserMotion(1.05));
+ assert.deepEqual(teaserMotionOf(FIN, 0.5), teaserMotion(1.05));
+ const m = teaserMotionOf(dipped(1.2, 0.6), 0.5);
+ assert.deepEqual({ ...m, first: 0 }, { ...teaserMotion(1.05), first: 0 });
+ assert.equal(m.first, r4(1.1 + DIP_RISE.before - TEASER_MOTION.hit)); // 1.25
+ const t = teaserTimes(teaserLines(FIN), "?", m);
+ assert.equal(t.lines[0].impact, 1.45);
+ // Every time is the undipped one moved by lead − (first − before + hit) = 1.1 − 0.4.
+ const k = 0.7;
+ t.lines.forEach((l, i) => {
+ assert.equal(r4(l.at - plain.lines[i].at), k);
+ assert.equal(r4(l.impact - plain.lines[i].impact), k);
+ assert.equal(l.subAt == null ? null : r4(l.subAt - plain.lines[i].subAt), plain.lines[i].subAt == null ? null : k);
+ });
+ assert.equal(r4(t.tailAt - plain.tailAt), k);
+ assert.ok(t.need <= teaserSeconds(dipped(1.2, 0.6), 0.5) + 1e-9);
+});
+
+test("the hits: the undipped ones moved by the lead, and a riser before the first, ending on its impact", () => {
+ const plain = teaserHits(FIN);
+ assert.deepEqual(teaserHits(FIN, 0), plain, "no dip: the transition changes nothing");
+ assert.ok(!plain.some((h) => h.kind === "riser"));
+ const hits = teaserHits(dipped(1.2, 0.6), 0.5);
+ const [riser, ...rest] = hits;
+ assert.deepEqual(rest.map((h) => ({ ...h, at: r4(h.at - 0.7) })), plain);
+ assert.deepEqual(riser, {
+ kind: "riser", at: 0.45, role: "dip", gain: DIP_RISER.gain, decay: 0.04, f0: DIP_RISER.f0, f1: DIP_RISER.f1, dur: 1,
+ });
+ assert.equal(r4(riser.at + riser.dur), rest[0].at);
+ // A short lead: the riser starts at the segment's first sample, still ending on the hit.
+ const short = teaserHits(dipped(1, 0.2), 0)[0];
+ assert.deepEqual([short.at, short.dur], [0, 0.55]);
+ assert.deepEqual(teaserHits({ ...dipped(), hits: false }), []);
+});
+
+test("the schedule names the dip on its teaser only; the deck and the feed hide in an instant on the faded frame", () => {
+ const entries = [CLIP, dipped(1.2, 0.6)];
+ const s = deckSchedule({ entries, durs: [7, 7.9], D: 0.5, render: RENDER });
+ assert.equal(s.segments[0].dip, undefined);
+ assert.deepEqual(s.segments[1].dip, { fade: 1.2, black: 0.6 });
+ assert.equal(s.segments[1].hideDeck, true);
+ assert.equal(s.total, 14.4);
+ // Into a dipped teaser: gone at the clip's last frame (6.5 + 0.5 − 1/30),
+ // which the dip has made black, not slid over the fade.
+ const { visibility } = deckChoreography(s, RENDER);
+ assert.equal(dipHideAt(s.segments[1], 0.5, 30), 6.9667);
+ assert.deepEqual(visibility, [{ i: 1, hide: true, at: [6.9667, 6.9667] }]);
+ // Without a dip: the slide over the dissolve, and no `dip` key anywhere.
+ const plain = deckSchedule({ entries: [CLIP, FIN], durs: [7, 7.2], D: 0.5, render: RENDER });
+ assert.ok(plain.segments.every((x) => !("dip" in x)));
+ assert.deepEqual(deckChoreography(plain, RENDER).visibility, [{ i: 1, hide: true, at: [6.5, 7] }]);
+ // A hard cut: the cut itself less a frame.
+ const hard = deckSchedule({ entries, durs: [7, 7.4], D: 0, render: { ...RENDER, transition: 0 } });
+ assert.deepEqual(deckChoreography(hard, { ...RENDER, transition: 0 }).visibility, [{ i: 1, hide: true, at: [6.9667, 6.9667] }]);
+ // The estimate measures the teaser with its lead.
+ const est = estimateSchedule({ render: RENDER, timeline: entries });
+ assert.equal(est.segments[1].duration, 7.9);
+});
+
+// ---- the page -----------------------------------------------------------------
+
+test("the page out of black: bars closed and dark, a veil lifting around the first impact, the leak after the lead", () => {
+ const e = dipped(1.2, 0.6);
+ const lines = teaserLines(e);
+ const seconds = teaserSeconds(e, 0.5);
+ const { init, cues, beats } = teaserCues({ lines, tail: "?", seconds, motion: teaserMotionOf(e, 0.5), dip: { lead: 1.1 } });
+ const of = (k) => cues.filter((c) => c.k === k).map(({ at, dur, from, to, why }) => ({ at, dur, from, to, why }));
+ // The bars: in place from the first frame, invisible, up with the light.
+ assert.deepEqual(init.barT, { yPercent: 0, autoAlpha: 0 });
+ assert.deepEqual(init.barB, { yPercent: 0, autoAlpha: 0 });
+ assert.ok(!cues.some((c) => c.why === "letterbox"), "no bars closing in from black");
+ assert.deepEqual(of("barT"), [{ at: 1.1, dur: 0.65, from: { autoAlpha: 0 }, to: { autoAlpha: 1 }, why: "letterbox up" }]);
+ // The veil: black until the lead ends, then slowly to the hit, then the bloom.
+ assert.equal(beats.lines[0].impact, 1.45);
+ assert.deepEqual(init.veil, { autoAlpha: 1 });
+ assert.deepEqual(of("veil"), [
+ { at: 1.1, dur: 0.35, from: { autoAlpha: 1 }, to: { autoAlpha: 0.45 }, why: "rise" },
+ { at: 1.45, dur: 0.3, from: { autoAlpha: 0.45 }, to: { autoAlpha: 0 }, why: "bloom" },
+ ]);
+ // The leak waits for the lead; the first line's slam starts 0.15 s after it.
+ assert.deepEqual(of("leak").map((c) => [c.at, c.dur, c.why]), [[1.1, 1.4, "leak in"], [2.5, r4(seconds - 2.5), "leak drift"]]);
+ assert.equal(of("l0.o")[0].at, 1.25);
+ // Nothing of the words is up before the light: every line cue is after the lead.
+ assert.ok(cues.filter((c) => /^l\d/.test(c.k)).every((c) => c.at >= 1.1));
+ // Without a dip: the cues they always were (no veil, the bars closing in).
+ const plain = teaserCues({ lines, tail: "?", seconds: teaserSeconds(FIN), motion: teaserMotion(1.05) });
+ assert.ok(!plain.cues.some((c) => c.k === "veil") && !("veil" in plain.init));
+ assert.deepEqual(plain.init.barT, { yPercent: -100 });
+});
+
+test("the page's HTML: a veil node and its rule only with a dip; its length counts the lead at the transition given", () => {
+ const html = teaserHtml(dipped(1.2, 0.6), RENDER);
+ assert.match(html, /data-duration="7\.9"/);
+ assert.match(html, /<div class="leak" data-k="leak"><\/div>\n {10}<div class="veil" data-k="veil"><\/div>\n {10}<div class="column">/);
+ assert.match(html, /\.veil \{ position: absolute;[^}]*background: #000000; \}/);
+ assert.match(teaserHtml(dipped(1.2, 0.6), RENDER, { transition: 0 }), /data-duration="7\.4"/);
+ const plain = teaserHtml(FIN, RENDER);
+ assert.ok(!plain.includes("veil"));
+ assert.equal(teaserHtml(FIN, RENDER, { transition: 0 }), plain, "no dip: the transition is not read");
+});
+
+test("verify-build: a dipped teaser built --no-xfade is counted at the build's transition, not the manifest's", async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "dip-verify-"));
+ try {
+ // A cut without the deck (no schedule.json) whose manifest crossfades 0.5 s.
+ const manifest = { render: { ...RENDER, chrome: undefined }, timeline: [CLIP, dipped()] };
+ const frames = path.join(dir, "chrome", "teaser-fin-frames");
+ mkdirSync(frames, { recursive: true });
+ mkdirSync(path.join(dir, "segments"));
+ // Built --no-xfade: the lead is the black alone, 7.4 s = 222 frames.
+ const n = Math.round(teaserSeconds(dipped(), 0, 30) * 30);
+ assert.equal(n, 222);
+ for (let i = 1; i <= n; i += 1) writeFileSync(path.join(frames, `frame_${String(i).padStart(6, "0")}.png`), "");
+ writeFileSync(path.join(frames, ".key"), "k1\n");
+ const rec = path.join(dir, "segments", "fin.teaser.json");
+ const run = async (record, opts) => {
+ writeFileSync(rec, JSON.stringify(record) + "\n");
+ const problems = [];
+ const [t] = await verifyTeasers(dir, manifest, problems, opts);
+ return { problems, t };
+ };
+ // The record names the transition it was built at: nothing to say, whatever the flag.
+ for (const opts of [undefined, { noXfade: true }]) {
+ const { problems, t } = await run({ key: "s", frames: "k1", hits: 4, transition: 0 }, opts);
+ assert.deepEqual(problems, []);
+ assert.deepEqual(t, { id: "fin", frames: 222, expectedFrames: 222, current: true });
+ }
+ // An older record without it: --no-xfade says what the build did.
+ assert.deepEqual((await run({ key: "s", frames: "k1", hits: 4 }, { noXfade: true })).problems, []);
+ // Without either, the manifest's 0.5 s is assumed and the 15 frames are missing.
+ const { problems } = await run({ key: "s", frames: "k1", hits: 4 });
+ assert.deepEqual(problems, [`${frames} holds 222 frames; the teaser fin is 237 (7.9s at 30 fps)`]);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("compose-chrome: a dipped teaser renders lead + card frames, keyed by the dip and the transition", async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "dip-compose-"));
+ try {
+ const mp = path.join(dir, "video.manifest.json");
+ const compose = async (entry, extra = {}) => {
+ writeFileSync(mp, JSON.stringify({ slug: "t", render: RENDER, timeline: [CLIP, entry] }));
+ return composeChrome({ manifestPath: mp, region: "teaser", segment: "fin", doRender: false, ...extra });
+ };
+ const plain = await compose(FIN);
+ const b = await compose(dipped(1.2, 0.6));
+ const c = await compose(dipped(1.6, 1));
+ const hard = await compose(dipped(1.2, 0.6), { transition: 0 });
+ assert.deepEqual([plain.frameCount, b.frameCount, c.frameCount, hard.frameCount], [216, 237, 249, 222]);
+ assert.equal(new Set([plain.key, b.key, c.key, hard.key]).size, 4);
+ assert.equal((await compose(FIN, { transition: 0 })).key, plain.key);
+ await assert.rejects(() => compose({ ...FIN, dip: { fade: 1 } }), /dip\.black must be from 0 to 3/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+// ---- the graphs, as strings -----------------------------------------------------
+
+test("the teaser's sound: a riser layer only with a dip; without one the graph it always was", () => {
+ const plain = teaserAudioGraph(teaserHits(FIN), { seconds: 7.2, render: RENDER });
+ assert.ok(!plain.includes("[tw]"));
+ assert.match(plain, /\[tb\]\[tp\]\[tr\]amix=inputs=3:normalize=0,/);
+ const g = teaserAudioGraph(teaserHits(dipped(), 0.5), { seconds: 7.9, render: RENDER });
+ assert.match(g, /,highpass=f=400,lowpass=f=6500\[tw\];\[tb\]\[tp\]\[tr\]\[tw\]amix=inputs=4:normalize=0,/);
+ // The riser's sub is in the boom layer, from 0.45 s to the hit and its release.
+ assert.match(g, /if\(between\(t,0\.45,0\.45\+1\.04\),0\.22\*0\.5\*pow\(min\(\(t-0\.45\),1\)\/1,2\)\*clip\(\(1\.04-\(t-0\.45\)\)\/0\.04,0,1\)\*sin\(2\*PI\*/);
+});
+
+test("the joins: the dip's sound on the segment before the teaser; its record names it; nothing else moves", () => {
+ const dip = { seconds: 1, lastFrame: 89, black: 0.5 };
+ assert.equal(withCutEdits(null, 2), null);
+ const joins = withCutEdits(null, 2, { dips: new Map([[0, dip]]) });
+ assert.deepEqual(joins, [{ hold: 0, move: null, dip }, null]);
+ // The sound fades over the segment's last second, silent at its last frame; the picture has no chain.
+ const c = joinInputChain(0, joins[0], RENDER);
+ assert.equal(endFadeAudioFilter(dip, RENDER), "afade=t=out:st=1.9667:d=1");
+ assert.deepEqual(c, { parts: ["[0:a]afade=t=out:st=1.9667:d=1[j0a]"], v: "[0:v]", a: "[j0a]" });
+ // A hold and a dip on one input: the dip is last.
+ const both = withCutEdits([{ hold: 0.5, move: null }, null], 2, { dips: new Map([[0, dip]]) });
+ assert.match(joinInputChain(0, both[0], RENDER).parts[1], /^\[0:a\]apad=pad_dur=0\.5,afade=t=out:/);
+ // The hard-cut record names the dip, so a changed one is never reused.
+ assert.match(concatRecordText(["a.mp4", "b.mp4"], joins), /# join 0 \{"hold":0,"move":null,"dip":\{"seconds":1,"lastFrame":89,"black":0\.5\}\}/);
+});
+
+test("dipWindows and dipVideoFilter: the cut's frames, a fade to black in yuv after every overlay; none without a dip", () => {
+ const joins = withCutEdits(null, 2, { dips: new Map([[0, { seconds: 1, lastFrame: 89, black: 0.5 }]]) });
+ // a is 3 s (frames 0–89), the teaser starts at 2.5 s: frames 59 (untouched) → 89 (black), black to 104.
+ assert.deepEqual(dipWindows(joins, [3, 2.5], 0.5, 30), [{ segment: 0, s: 59, last: 89, until: 105 }]);
+ // A held segment in the middle, fade clamped to nothing more than it, black 0.
+ const mid = [null, { hold: 0.5, move: null, dip: { seconds: 0.5, lastFrame: 104, black: 0 } }, null];
+ assert.deepEqual(dipWindows(mid, [3, 3.5, 2], 0.5, 30), [{ segment: 1, s: 164, last: 179, until: 180 }]);
+ assert.equal(dipWindows(null, [3], 0.5, 30), null);
+ assert.equal(dipWindows([{ hold: 1, move: null }], [3], 0.5, 30), null);
+ const w = dipWindows(joins, [3, 2.5], 0.5, 30);
+ // `fade` to black, in the stream's own yuv420p: from frame 59 over 30 frames, on from 59.5 to 104.5.
+ assert.equal(dipVideoFilter(w, 30), "fade=t=out:st=1.966667:d=1:enable='between(t,1.983333,3.483333)'");
+ // In a preview window's clock.
+ assert.equal(dipVideoFilter(w, 30, 1.5), "fade=t=out:st=0.466667:d=1:enable='between(t,0.483333,1.983333)'");
+ // A preview that starts inside the fade: the same blend in geq, from where it already is.
+ const k = "clip((T-(-0.533333))/1,0,1)";
+ assert.equal(dipVideoFilter(w, 30, 2.5),
+ `geq=lum='lum(X,Y)+(16-lum(X,Y))*${k}+0.5':cb='cb(X,Y)+(128-cb(X,Y))*${k}+0.5':cr='cr(X,Y)+(128-cr(X,Y))*${k}+0.5'` +
+ ":enable='between(t,-0.516667,0.983333)'");
+ assert.equal(dipVideoFilter(null, 30), null);
+ assert.deepEqual(dipParts("[x]", null, 30), { parts: [], label: "[x]" });
+});
+
+test("every concat path lays the dips last, and writes the graph it always did without one", () => {
+ const segs = ["a.mp4", "t.mp4"];
+ const regions = [{ name: "deck", frames: "/f/deck-frames", x: 0, y: 890, width: 1920, height: 190 }];
+ const chrome = { regions, outLabel: "[hfout]" };
+ const joins = withCutEdits(null, 2, { dips: new Map([[0, { seconds: 1, lastFrame: 89, black: 0.5 }]]) });
+ const dips = dipWindows(joins, [3, 2.5], 0.5, 30);
+ const fc = (args) => args[args.indexOf("-filter_complex") + 1];
+ const maps = (args) => args.filter((_, i) => args[i - 1] === "-map");
+ // The crossfade: the xfades, the overlay, then the dip.
+ const plain = xfadeConcatArgs({ segments: segs, durs: [3, 2.5], render: RENDER, outPath: "o.mp4", chrome });
+ const g = xfadeGraph([3, 2.5], 0.5, null, RENDER);
+ assert.equal(fc(plain), [...g.parts, chromeOverlayChain(RENDER, regions, g.vlab, 2, { outLabel: "[hfout]", final: true }).chain].join(";"));
+ assert.deepEqual(maps(plain), ["[vout]", g.alab]);
+ const dippedArgs = xfadeConcatArgs({ segments: segs, durs: [3, 2.5], render: RENDER, outPath: "o.mp4", chrome, joins });
+ assert.ok(fc(dippedArgs).endsWith(`format=yuv420p[vout];[vout]${dipVideoFilter(dips, 30)}[vdip]`));
+ assert.deepEqual(maps(dippedArgs), ["[vdip]", "[a1]"]);
+ // Into the dipped teaser the outgoing segment plays the whole overlap, picture and sound.
+ assert.match(fc(dippedArgs), /\[0:v\]\[1:v\]xfade=transition=custom:expr='A':duration=0\.5:offset=2\.500\[v1\]/);
+ assert.match(fc(dippedArgs), /\[p0a\]\[p1a\]acrossfade=d=0\.5:c1=nofade:c2=nofade\[a1\]/);
+ // Only that join: a three-segment cut dipping into the last keeps its first dissolve.
+ const three = xfadeGraph([3, 3, 2.5], 0.5, [null, { hold: 0, move: null, dip: { seconds: 1, lastFrame: 89, black: 0 } }, null], RENDER);
+ assert.match(three.parts.join(";"), /\[0:v\]\[1:v\]xfade=transition=fade:.*\[v1\]\[2:v\]xfade=transition=custom:expr='A'/);
+ // A base for the rail: no dip (the rail pass lays it).
+ assert.ok(!fc(xfadeConcatArgs({ segments: segs, durs: [3, 2.5], render: RENDER, outPath: "o", joins, dip: false })).includes("geq"));
+ // The hard cut that is the cut: the dip on the joined picture.
+ assert.equal(maps(hardCutFilterArgs(segs, joins, RENDER, "o.mp4"))[0], "[vc]");
+ const hc = hardCutFilterArgs(segs, joins, RENDER, "o.mp4", dips);
+ assert.ok(fc(hc).endsWith(`concat=n=2:v=1:a=1[vc][ac];[vc]${dipVideoFilter(dips, 30)}[vdip]`));
+ assert.deepEqual(maps(hc), ["[vdip]", "[ac]"]);
+ // The overlay pass over a hard cut: unchanged without dips; with them, after the overlay, in a preview's clock.
+ const ac0 = applyChromeArgs("in.mp4", "o.mp4", RENDER, chrome);
+ assert.equal(fc(ac0), chromeOverlayChain(RENDER, regions, "[0:v]", 1, { final: true }).chain);
+ assert.deepEqual(maps(ac0), ["[vout]", "0:a"]);
+ const ac = applyChromeArgs("in.mp4", "o.mp4", RENDER, chrome, { start: 1.5, dur: 3 }, dips);
+ assert.ok(fc(ac).endsWith(`[vout]${dipVideoFilter(dips, 30, 1.5)}[vdip]`));
+ assert.deepEqual(maps(ac), ["[vdip]", "0:a"]);
+});
+
+// ---- real ffmpeg ------------------------------------------------------------------
+
+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;
+};
+
+/**
+ * Two lossless segments at 320×180: `a`, 3 s of bright test pattern and a
+ * tone; `t`, a stand-in teaser whose first `lead` seconds are black and
+ * silent, then a pattern and another tone. And a bright, opaque PNG sequence
+ * for each of a stand-in deck (the bottom band) and feed (a right column), as
+ * long as the cut -- never hidden, so only the dip can darken them.
+ */
+function material(dir, { lead, frames }) {
+ const make = (name, seconds, src, hz, dark) => {
+ const f = path.join(dir, `${name}.mov`);
+ ff([
+ "-f", "lavfi", "-i", `${src}=s=320x180:r=30:d=${seconds}`,
+ "-f", "lavfi", "-i", `sine=frequency=${hz}:sample_rate=48000:duration=${seconds}`,
+ "-filter_complex",
+ `[0:v]format=yuv420p${dark ? `,drawbox=x=0:y=0:w=iw:h=ih:color=black:t=fill:enable='lt(t,${dark})'` : ""}[v];` +
+ `[1:a]aformat=channel_layouts=stereo${dark ? `,volume=enable='lt(t,${dark})':volume=0` : ""}[a]`,
+ "-map", "[v]", "-map", "[a]", "-c:v", "ffv1", "-c:a", "pcm_s16le", f,
+ ]);
+ return f;
+ };
+ const a = make("a", 3, "testsrc2", 440, 0);
+ const t = make("t", 2.5, "smptebars", 660, lead);
+ const seq = (name, w, h) => {
+ const d = path.join(dir, `${name}-frames`);
+ mkdirSync(d);
+ ff(["-f", "lavfi", "-i", `color=c=white:s=${w}x${h}:r=30`, "-frames:v", String(frames), "-start_number", "1", path.join(d, "frame_%06d.png")]);
+ return d;
+ };
+ const regions = [
+ { name: "deck", frames: seq("deck", 320, 40), x: 0, y: 140, width: 320, height: 40 },
+ { name: "feed", frames: seq("feed", 60, 140), x: 260, y: 0, width: 60, height: 140 },
+ ];
+ return { segs: [a, t], regions };
+}
+
+/** Run argv's graph: the picture as raw yuv420p frames, the sound as mono s16 PCM. */
+function run(args) {
+ const inputs = args.slice(4, args.indexOf("-filter_complex"));
+ const fc = args[args.indexOf("-filter_complex") + 1];
+ const [v, a] = args.filter((_, i) => args[i - 1] === "-map");
+ const pic = ff([...inputs, "-filter_complex", `${fc};${a}anullsink`, "-map", v, "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]);
+ const pcm = ff([...inputs, "-filter_complex", `${fc};${v}nullsink`, "-map", a, "-f", "s16le", "-ac", "1", "-ar", "48000", "-"]);
+ const size = 320 * 180 * 1.5;
+ const frames = [];
+ for (let o = 0; o + size <= pic.length; o += size) frames.push(pic.subarray(o, o + size));
+ return { frames, pcm };
+}
+
+/** A frame's worst distance from black (Y 16, Cb/Cr 128) over EVERY pixel, and its mean luma. */
+function darkness(frame) {
+ const Y = 320 * 180;
+ let worst = 0;
+ let sum = 0;
+ for (let i = 0; i < frame.length; i += 1) {
+ const d = Math.abs(frame[i] - (i < Y ? 16 : 128));
+ if (d > worst) worst = d;
+ if (i < Y) sum += frame[i];
+ }
+ return { worst, mean: sum / Y };
+}
+const lumaAt = (frame, x, y) => frame[y * 320 + x];
+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(pcm.readInt16LE(i * 2)));
+ return m;
+};
+
+test("ffmpeg: a dipped crossfade -- the whole frame, overlays included, black from the clip's last frame for the black span; silent there; untouched before",
+ { skip: !have && "no ffmpeg" }, async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "dip-xfade-"));
+ try {
+ // fade 1 s, black 0.5 s, dissolve 0.5 s: the stand-in's lead is 1.0 s.
+ const black = 0.5;
+ const D = 0.5;
+ const { segs, regions } = material(dir, { lead: D + black, frames: 150 });
+ const entries = [{ type: "clip", id: "a" }, { type: "teaser", id: "t", lines: ["x"], dip: { fade: 1, black } }];
+ const joins = await cutJoins({ entries, segments: segs, render: RENDER });
+ assert.deepEqual(joins, [{ hold: 0, move: null, dip: { seconds: 1, lastFrame: 89, black } }, null]);
+ const { durs } = await cutOffsets(segs, D, 30, joins);
+ assert.deepEqual(durs, [3, 2.5]);
+ const chrome = { regions, outLabel: "[hfout]" };
+ const R = { ...RENDER, width: 320, height: 180 };
+ const without = run(xfadeConcatArgs({ segments: segs, durs, render: R, outPath: "-", chrome }));
+ const withDip = run(xfadeConcatArgs({ segments: segs, durs, render: R, outPath: "-", chrome, joins }));
+ assert.equal(withDip.frames.length, 150);
+ assert.equal(without.frames.length, 150, "the dip changes no length");
+ assert.equal(withDip.pcm.length, without.pcm.length);
+ const [w] = dipWindows(joins, durs, D, 30);
+ assert.deepEqual(w, { segment: 0, s: 59, last: 89, until: 105 });
+ // Before the fade: the undipped cut, frame for frame.
+ for (let f = 0; f <= 59; f += 1) assert.ok(withDip.frames[f].equals(without.frames[f]), `frame ${f} untouched`);
+ // Half way: the footage AND the stand-in deck (white, Y 235) half way to black.
+ const mid = withDip.frames[74];
+ assert.ok(Math.abs(lumaAt(mid, 160, 160) - (16 + (235 - 16) * 0.5)) <= 2, `deck at mid-fade: ${lumaAt(mid, 160, 160)}`);
+ assert.ok(Math.abs(lumaAt(mid, 290, 70) - (16 + (235 - 16) * 0.5)) <= 2, `feed at mid-fade: ${lumaAt(mid, 290, 70)}`);
+ // Through the overlap (frames 75–89) the clip is not dissolved into the
+ // teaser's black: the footage and the overlays fade in step, by the dip alone.
+ const own = ff(["-i", segs[0], "-vf", "select=eq(n\\,82)", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]);
+ const k = (82 - 59) / 30;
+ const f82 = withDip.frames[82];
+ assert.ok(Math.abs(lumaAt(f82, 100, 60) - (16 + (lumaAt(own, 100, 60) - 16) * (1 - k))) <= 2, "footage by the dip alone");
+ assert.ok(Math.abs(lumaAt(f82, 160, 160) - (16 + 219 * (1 - k))) <= 2, `deck in step: ${lumaAt(f82, 160, 160)}`);
+ // The clip's last frame through the black: every pixel black, the lit overlays with it.
+ for (let f = 89; f < 105; f += 1) {
+ const d = darkness(withDip.frames[f]);
+ assert.equal(d.worst, 0, `frame ${f} is black across the whole frame (worst ${d.worst})`);
+ assert.ok(darkness(without.frames[f]).worst > 200, `frame ${f} is lit without the dip`);
+ }
+ // The dip ends where the black does: the frame after is the undipped one.
+ assert.ok(withDip.frames[105].equals(without.frames[105]));
+ assert.ok(darkness(withDip.frames[105]).mean > 60);
+ // The sound: the same up to the fade, faded by the clip's last frame, silent through the black.
+ const st = Math.round((59 / 30) * 48000) * 2;
+ assert.ok(withDip.pcm.subarray(0, st).equals(without.pcm.subarray(0, st)), "the same sound before the fade");
+ assert.ok(peak(withDip.pcm, 2.5, 2.6) < peak(without.pcm, 2.5, 2.6) * 0.75, "fading");
+ // In the overlap the clip's sound is its own fade's alone (no crossfade on top): about (1 − k) of it.
+ assert.ok(peak(withDip.pcm, 2.8, 2.85) > 0.08 * peak(without.pcm, 1.0, 1.5), "not crossfaded away early");
+ assert.equal(peak(withDip.pcm, 89 / 30, 105 / 30), 0, "digital silence from the last frame through the black");
+ assert.ok(peak(without.pcm, 89 / 30, 3) > 100, "the clip still sounds there without the dip");
+ assert.ok(peak(withDip.pcm, 3.6, 4.9) > 1000, "the teaser's own sound after its lead");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+test("ffmpeg: the hard cut and the overlay pass over it -- black across the whole frame, then the teaser",
+ { skip: !have && "no ffmpeg" }, async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "dip-hard-"));
+ try {
+ // No dissolve: the lead is the black alone.
+ const black = 0.5;
+ const { segs, regions } = material(dir, { lead: black, frames: 165 });
+ const R = { ...RENDER, width: 320, height: 180, transition: 0 };
+ const entries = [{ type: "clip", id: "a" }, { type: "teaser", id: "t", lines: ["x"], dip: { fade: 1, black } }];
+ const joins = await cutJoins({ entries, segments: segs, render: R });
+ const { durs } = await cutOffsets(segs, 0, 30, joins);
+ const dips = dipWindows(joins, durs, 0, 30);
+ assert.deepEqual(dips, [{ segment: 0, s: 59, last: 89, until: 105 }]);
+ // The concat is the cut: the dip goes on it.
+ const cut = run(hardCutFilterArgs(segs, joins, R, "-", dips));
+ assert.equal(cut.frames.length, 165);
+ for (let f = 89; f < 105; f += 1) assert.equal(darkness(cut.frames[f]).worst, 0, `frame ${f}`);
+ assert.ok(darkness(cut.frames[105]).mean > 60);
+ assert.equal(peak(cut.pcm, 89 / 30, 105 / 30), 0);
+ // The overlay pass over a hard-cut base (no dip in it): the stand-ins go black with the picture.
+ const base = path.join(dir, "base.mov");
+ const hb = hardCutFilterArgs(segs, joins, R, base);
+ ff([...hb.slice(4, hb.indexOf("-map")), "-map", "[vc]", "-map", "[ac]", "-c:v", "ffv1", "-c:a", "pcm_s16le", base]);
+ const over = applyChromeArgs(base, "-", R, { regions, outLabel: "[hfout]" }, null, dips);
+ const pic = ff([...over.slice(4, over.indexOf("-filter_complex")), "-filter_complex", over[over.indexOf("-filter_complex") + 1],
+ "-map", "[vdip]", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]);
+ const size = 320 * 180 * 1.5;
+ const frame = (f) => pic.subarray(f * size, (f + 1) * size);
+ assert.ok(lumaAt(frame(30), 160, 160) > 200, "the stand-in deck is lit before the dip");
+ for (let f = 89; f < 105; f += 1) assert.equal(darkness(frame(f)).worst, 0, `overlay frame ${f}`);
+ assert.ok(lumaAt(frame(110), 160, 160) > 200, "and lit after it (a real deck has hidden by then)");
+ // A preview window starting inside the fade (2.5 s in, the geq form): the same pictures as the cut's.
+ const pv = applyChromeArgs(base, "-", R, { regions, outLabel: "[hfout]" }, { start: 2.5, dur: 1.5 }, dips);
+ assert.match(pv[pv.indexOf("-filter_complex") + 1], /\[vout\]geq=/);
+ const pp = ff([...pv.slice(4, pv.indexOf("-filter_complex")), "-filter_complex", pv[pv.indexOf("-filter_complex") + 1],
+ "-map", "[vdip]", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]);
+ const pframe = (f) => pp.subarray((f - 75) * size, (f - 74) * size);
+ assert.equal(pp.length / size, 45);
+ for (const [x, y] of [[160, 160], [290, 70], [100, 60]]) {
+ assert.ok(Math.abs(lumaAt(pframe(80), x, y) - lumaAt(frame(80), x, y)) <= 1, `preview mid-fade at ${x},${y}`);
+ }
+ for (let f = 89; f < 105; f += 1) assert.equal(darkness(pframe(f)).worst, 0, `preview frame ${f}`);
+ assert.ok(pframe(105).equals(frame(105)));
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+test("ffmpeg: the dipped teaser's sound -- silence until the riser, the riser rising into the first hit, under the ceiling",
+ { skip: !have && "no ffmpeg" }, () => {
+ const e = dipped(1.2, 0.6);
+ const seconds = teaserSeconds(e, 0.5);
+ const hits = teaserHits(e, 0.5);
+ const graph = teaserAudioGraph(hits, { seconds, render: RENDER });
+ 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));
+ // Channel 0 (a downmix to mono would sum the two at −3 dB each).
+ const both = new Float32Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 4);
+ const f = both.filter((_, i) => i % 2 === 0);
+ assert.ok(Math.abs(f.length / 48000 - seconds) < 0.001);
+ const rms = (a, b) => {
+ let s = 0;
+ const i0 = Math.round(a * 48000);
+ const i1 = Math.round(b * 48000);
+ for (let i = i0; i < i1; i += 1) s += f[i] * f[i];
+ return Math.sqrt(s / (i1 - i0));
+ };
+ let pk = 0;
+ for (const v of f) pk = Math.max(pk, Math.abs(v));
+ const [riser, first] = hits;
+ let lead = 0;
+ for (let i = 0; i < Math.round(riser.at * 48000) - 48; i += 1) lead = Math.max(lead, Math.abs(f[i]));
+ assert.equal(lead, 0, "digital silence before the riser");
+ assert.ok(rms(first.at - 0.15, first.at) > 4 * rms(riser.at, riser.at + 0.3), "the riser builds into the hit");
+ assert.ok(rms(first.at, first.at + 0.15) > rms(first.at - 0.15, first.at), "and the hit lands on top of it");
+ assert.ok(pk <= 0.52, `under −6 dBFS (peak ${pk.toFixed(3)})`);
+ });
diff --git a/umtool/report-to-video/teaser-audio.test.mjs b/umtool/report-to-video/teaser-audio.test.mjs
@@ -14,7 +14,7 @@ import { spawnSync } from "node:child_process";
import test from "node:test";
import { teaserAudioGraph } from "./build-video.mjs";
-import { teaserHits } from "./deck.mjs";
+import { teaserHits, teaserSeconds } from "./deck.mjs";
const have = spawnSync("ffmpeg", ["-version"]).status === 0;
const RENDER = { fps: 30, audioRate: 48000, audioChannels: 2 };
@@ -60,12 +60,29 @@ test("nothing clips: the sum stays under −6 dBFS (about) and well under full s
for (const v of all) peak = Math.max(peak, Math.abs(v));
assert.ok(peak > 0.2, `peak ${peak}`); // it is not silent
assert.ok(peak <= 0.5 * 1.03, `peak ${peak} (${(20 * Math.log10(peak)).toFixed(2)} dBFS)`);
- // A short card packs the hits together; they still sum cleanly.
- const short = { ...FERRET, seconds: 3, lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] };
- const s = samples(teaserAudioGraph(teaserHits(short), { seconds: 3, render: RENDER })).all;
+ // The tightest beat packs five lines' hits together; they still sum cleanly.
+ const packed = { ...FERRET, seconds: undefined, beat: 0.4, lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] };
+ const seconds = teaserSeconds(packed);
+ const s = samples(teaserAudioGraph(teaserHits(packed), { seconds, render: RENDER })).all;
+ assert.equal(s.length, Math.round(seconds * 48000) * 2);
let p2 = 0;
for (const v of s) p2 = Math.max(p2, Math.abs(v));
- assert.ok(p2 <= 0.5 * 1.03, `short card peak ${p2}`);
+ assert.ok(p2 > 0.2 && p2 <= 0.5 * 1.03, `packed card peak ${p2}`);
+});
+
+test("a wider beat moves every hit's onset with it", { skip: !have && "no ffmpeg" }, () => {
+ const slow = { ...FERRET, beat: 1.3, seconds: undefined };
+ const seconds = teaserSeconds(slow);
+ const hits = teaserHits(slow);
+ const full = samples(teaserAudioGraph(hits, { seconds, render: RENDER })).ch0;
+ const frame = 1 / RENDER.fps;
+ hits.forEach((h, i) => {
+ if (h.kind !== "hit") return;
+ const without = samples(teaserAudioGraph(hits.filter((_, j) => j !== i), { seconds, render: RENDER })).ch0;
+ const first = full.findIndex((v, n) => Math.abs(v - without[n]) > 1e-4);
+ assert.ok(first >= 0, `${h.role} made no sound`);
+ assert.ok(Math.abs(first / 48000 - h.at) <= frame, `${h.role}: onset ${(first / 48000).toFixed(4)}s, pop ${h.at}s`);
+ });
});
test("hits: false is digital silence, exactly as long", { skip: !have && "no ffmpeg" }, () => {
diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs
@@ -11,7 +11,7 @@
// manifest. Cheap (one ffprobe) and the only thing that closes the loop.
//
// node umtool/report-to-video/verify-build.mjs <manifest.json> [--out <dir>]
-// [--variant sourced|full] [--json]
+// [--variant sourced|full] [--no-xfade] [--json]
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -19,13 +19,13 @@ import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { postsRegions, selectVariant, variantPaths } from "./build-video.mjs";
-import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck } from "./deck.mjs";
+import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck, teaserSeconds, transitionOf } 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" } = {}) {
+export async function verifyBuild(manifestPath, { outDir, variant = "sourced", noXfade = false } = {}) {
// The SAME filter the build ran. Verifying the whole manifest against one
// variant's file would report a missing chapter for every entry the other cut
// carries -- i.e. it would be red exactly when the build was right.
@@ -90,7 +90,7 @@ 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);
+ const teasers = await verifyTeasers(path.join(root, variant), manifest, problems, { noXfade });
return {
ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, deck,
@@ -136,6 +136,19 @@ export async function verifyDeck(variantDir, render, file, problems) {
if (!(Math.abs(videoFrames - schedule.total * fps) <= 1.5)) {
problems.push(`the picture is ${videoFrames} frames for a ${schedule.total.toFixed(3)}s schedule (${want} frames)`);
}
+ // The posts feed: one sequence for the whole cut, as long as the deck's --
+ // and a cut that never pauses: no hold and no footage move in its schedule.
+ let feed = null;
+ if (schedule.layout === "feed") {
+ const dir = path.join(variantDir, "chrome", "feed-frames");
+ const got = await countFrames(dir);
+ feed = { frames: got, expectedFrames: want, posts: (schedule.posts ?? []).length };
+ if (got !== want) problems.push(`${dir} holds ${got} frames; the posts feed runs the whole cut, ${want}`);
+ const held = (schedule.segments ?? []).filter((s) => s.hold > 0).map((s) => s.id);
+ if (held.length || schedule.moves?.length) {
+ problems.push(`the posts feed never pauses the cut, but the schedule holds ${held.join(", ") || "nothing"} and moves ${schedule.moves?.length ?? 0}`);
+ }
+ }
// The posts windows: each laid at its own second, each as long as snapWindow says.
const posts = [];
for (const r of postsRegions(render, variantDir, schedule)) {
@@ -148,6 +161,7 @@ export async function verifyDeck(variantDir, render, file, problems) {
const holds = await verifyHolds(file, schedule, render, problems);
return {
total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length,
+ ...(feed ? { feed } : {}),
...(posts.length ? { posts } : {}),
...(holds.length ? { holds } : {}),
};
@@ -159,18 +173,25 @@ export async function verifyDeck(variantDir, render, file, problems) {
* 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) {
+export async function verifyTeasers(variantDir, manifest, problems, { noXfade = false } = {}) {
const fps = Number(manifest.render?.fps ?? 30);
+ // A dip's lead counts the cut's transition AS BUILT: the one the teaser's
+ // record names, else the one the build measured (its schedule, under the
+ // deck), else 0 under --no-xfade, else the manifest's.
+ const sched = await readFile(path.join(variantDir, "schedule.json"), "utf8").then(JSON.parse, () => null);
+ const cutD = Number.isFinite(sched?.transition) ? sched.transition : noXfade ? 0 : transitionOf(manifest.render);
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)`);
+ const D = Number.isFinite(rec?.transition) ? rec.transition : cutD;
+ const seconds = teaserSeconds(e, D, fps);
+ const want = frameCount(seconds, fps);
+ const key = await readFile(path.join(dir, ".key"), "utf8").then((s) => s.trim(), () => null);
+ if (frames !== want) problems.push(`${dir} holds ${frames} frames; the teaser ${e.id} is ${want} (${seconds}s at ${fps} fps)`);
if (!rec) problems.push(`the teaser ${e.id} has no record beside ${seg} — rebuild it`);
else if (key && rec.frames !== key) problems.push(`the teaser ${e.id}'s segment was encoded from another render of it — rebuild it`);
out.push({ id: e.id, frames, expectedFrames: want, current: !!rec && rec.frames === key });
@@ -264,13 +285,15 @@ async function main() {
const argv = process.argv.slice(2);
const manifestPath = argv.find((a) => !a.startsWith("--"));
if (!manifestPath) {
- console.error("usage: verify-build.mjs <manifest.json> [--out <dir>] [--variant sourced|full] [--json]");
+ console.error("usage: verify-build.mjs <manifest.json> [--out <dir>] [--variant sourced|full] [--no-xfade] [--json]");
process.exit(2);
}
const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; };
const res = await verifyBuild(manifestPath, {
outDir: flag("--out"),
variant: flag("--variant") ?? "sourced",
+ // The build's own flag: a cut joined without crossfades.
+ noXfade: argv.includes("--no-xfade"),
});
if (argv.includes("--json")) {
@@ -282,6 +305,9 @@ async function main() {
);
if (res.deck) {
console.log(` deck: ${res.deck.frames}/${res.deck.expectedFrames} frame(s) over ${res.deck.segments} segment(s), ${res.deck.total}s`);
+ if (res.deck.feed) {
+ console.log(` posts feed: ${res.deck.feed.frames}/${res.deck.feed.expectedFrames} frame(s), ${res.deck.feed.posts} post(s), no hold`);
+ }
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`);
}