# Posts on the on-screen deck A report cut that wears the deck (`plans/onscreen-deck.md`) can carry written statements — a Bluesky or X post — as cards over the footage. A post is not a segment: it rides on a clip, appears near the end of it, and leaves in the transition to the next segment. ## Rulings - **Attachment.** A post rides on the clip whose recording most closely PRECEDES it: the clip with the latest day on or before the post's day (a clip's day is its own `date`, else its record's upload date); ties go to the later clip in the cut; a post older than every clip goes on the first. `attachTo: ""` overrides. `hide: true` leaves a post out. - **Timing.** A clip's posts, oldest first, stack: post j of k appears at A − seconds·(k − j), where A is the start of the outgoing transition (the next segment's start under a crossfade, 0.3 s before a hard cut, 0.3 s before the end of the cut). Each has `seconds` (default 2) alone before the next stacks on; the last has the clip's final `seconds`. A clip too short for that shares what it has after its incoming dissolve evenly. All of them leave together over the transition. - **Card.** The post's date, its words (clamped to `maxLines`, ellipsis), the author's handle and platform, and a QR of the post's own permalink (`url`). The deck's palette and fonts. - **Where.** A column inside the footage box, `inset` from its top and from the chosen side (`position`, default top-right), `width` wide. Cards stack top-down; when a new card would overflow the column, the oldest slide up and out. ## Manifest ```jsonc "posts": [ { "id": "bs-3msydljwjis2a", "platform": "bluesky", "author": "Pirate Software", "handle": "piratesoftware.live", "date": "2026-08-13T19:05:26.424Z", "text": "We just signed off on 51 page document …", "url": "https://bsky.app/profile/piratesoftware.live/post/3msydljwjis2a", "attachTo": null, "hide": false } ], "render": { "chrome": { "engine": "hyperframes", "layout": "deck", "deck": { "posts": { "show": true, "seconds": 2, "position": "top-right", "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } } } } ``` `validatePosts(posts, timeline)` and the `deck.posts` branch of `validateChrome` are the one validator (unknown keys refused). Posts are drawn only under the deck; without it they are data for the report and nothing in a build reads them. ## Core (in `umtool/report-to-video/deck.mjs`, pure) | Export | → | |---|---| | `validatePosts(posts, timeline)` | sentences | | `clipDay(entry, meta)` | `YYYY-MM-DD` or null | | `attachPosts({posts, entries, metas})` | `[{id, entryId, rule: "attachTo"\|"date"\|"first", clipDay}]` | | `postSchedule({posts, entries, metas, segments, D, total, render})` | `[{id, segment, slot, of, appear, out:[a,b], date, text, author, handle, platform, url, qrUrl}]` | | `postWindows(schedule)` | `[{segment, from, to}]` — one render window per carrying clip | | `postsGeometry(render)` | `{x, y, width, height}` in the frame | | `deckSchedule({…, posts})` / `estimateSchedule(manifest)` | the schedule gains `posts` (only when there are some) | ## Slices | Slice | What | Owns | |---|---|---| | P0 | This file; the core and its tests | `deck.mjs`, `deck.test.mjs` | | P1 | The posts region and its overlay: `chrome-posts.mjs` (card HTML, stack/slide choreography, `?still`, `?preview=1`), `composeChrome({region:"posts", window})` per `postWindows` entry with its own cache, the build passing `manifest.posts` to `writeChromeSchedule` and `validatePosts` at build start, the overlay of each window at its `from` in the crossfade concat, `applyChrome`, `--chrome-only` and `--chrome-preview`; verify-build; docs | `report-to-video/*` | | P2 | umtool: `updatePosts(dir, {[id]: {attachTo?, hide?}}, {token})`, the On-screen section lists posts with their automatic clip and an override + hide, the preview shows the posts region in its windows; e2e | `umtool/lib`, `umtool/app/api/report`, `umtool/components`, `umtool/e2e` | | P3 | First application: the ferret report's posts (report section + manifest) and a rebuild | outside the repo | ## As shipped Branch `deck/posts` from `main` 2cf43a69; slices merged `--no-ff` after review. | Commit | What | |---|---| | de072804 | P0 — this file; `deck.posts` settings, `validatePosts`, `clipDay`, `attachPosts`, `postSchedule`, `postWindows`, `postsGeometry`; `deckSchedule` carries `posts` only when there are some | | fd577d96 | P2 — umtool: `updatePosts` (attachTo/hide only), posts rows on `GET /api/report/onscreen` (auto and effective clip, timing), `PUT /api/report/posts`, the preview composing one posts window at a time and the files route serving them, the Posts table, the `deck.posts` settings group (saving settings no longer drops it), `onscreen-posts.spec.ts` | | a598a386 | P1 — `chrome-posts.mjs` (the cards; the stack planned in the page from measured heights by the same `postsCues` the tests run), `composeChrome` region `posts` per window with its own cache, `snapWindow` in `deck.mjs`, the overlay of every window at its `from` in the crossfade concat, `applyChrome`, `--chrome-only` and `--chrome-preview`, verify-build window checks, README, quirks, one `[Unreleased]` bullet | | f9625df7 | The posts preview e2e asserts real cards (the branch for a missing region is gone) | ### As built, where it differs from the rulings above - **Windows are snapped outward to the frame grid** (`snapWindow`, in `deck.mjs`): frame i of a window is cut frame f0 + i exactly. It lives in `deck.mjs`, not `chrome-posts.mjs`, because the build importing the page module pulled the composition's asset URL into umtool's bundle and failed its `next build` while collecting page data — a failure no unit test could see. - **The stack is planned in the page,** after the fonts load: whether a card overflows the column depends on measured heights. The planner's source is embedded in the page under a fixed name (`embedFn`: `const postsCues = ()`), so the page and the tests run one implementation even after umtool's production build renames the module function (the review's F1: the bare `toString()` declared the minified name and the page threw, drawing nothing in umtool's live preview; builds were unaffected). - **The column's fit is checked where posts are known** (review F2): `validateChrome` holds a deck to it only when the deck sets `deck.posts`, and `validatePosts(posts, timeline, render)` holds drawn posts to it. A deck with no posts is never refused for a column it does not draw. - `qrencode` takes `--` before the URL in all three callers: a URL is data, never an option. - **A post's date is drawn as the day in its own string** (UTC as archived). - The overlay of a window uses `-itsoffset`, `-reinit_filter 0`, `format=rgba` and `eof_action=pass`, never `shortest=1`; a framemd5 test shows every frame outside a window is bit-identical to the deck-only output and the length never changes. ### Gates on a598a386 (then f9625df7) - Workspace tsc clean (51 s); `test:scripts` 300 pass, 2 skipped (the queue-lock timing cases); capped umtool `next build` with the corpus linked exit 0 (23 s), link removed. - umtool e2e `onscreen-posts onscreen clip-bench build projects report-fetch-via-editor`: 93 passed (4.1 min); after f9625df7 the strict `onscreen-posts.spec.ts` 6 passed (real composition, `posts:ready`). - Byte-identical: a manifest without `posts` builds as before (`--only c07` md5 `6a92235fa12ca181bb81993129c9ee9d`, with and without `posts`); a deck without posts writes the same `schedule.json` and the same overlay chain and `applyChrome` argv. - First application (P1, scratch): 7 posts on c03 ×2, c06 ×3, c12, c17 at the predicted times; 350.200 s; verify-build windows 136/195/76/76 frames; QR 7/7 on the crossfade cut and on a transition-0 copy. - Review (read-only, Opus): SHIP AFTER FIXES — F1 (the live preview's page called a planner the production build had renamed) and F2 (a deck with no posts refused for the column), fixed in 7bb62683 with `--` before every qrencode URL. On 7bb62683: tsc clean (71 s); `test:scripts` 301 pass, 2 skipped, 1 queue-lock timing case (11/11 alone); capped umtool build exit 0 (26 s) and its server bundle carries `const postsCues = (` (2 files); umtool e2e 93 passed (3.8 min). ### Found and left - The ferret posts never fill the column, so "oldest slide up and out" is proven by unit tests, not by media. - There is no live text patching for posts in the umtool preview: an override or hide recomposes. - The true still covers the deck region only. - Adding a post is an agent's edit to the manifest; umtool edits only `attachTo` and `hide`. ### Rollout Umtool-only, like the deck: a umtool rebuild and restart. Nothing under `export/`, `homepage/`, `common/` or editor code. ## Room for posts (second pass) Rulings, on top of the above: - **Longer.** `posts.seconds` defaults to 4 (was 2). - **Hold.** A clip that carries posts is held on its last frame, in silence, for `posts.hold` (default 2.5 s) before its outgoing transition, so the last post can be read. The hold is part of the segment's length in the CUT: `deckSchedule` adds it to the carrying segments (`segments[i].hold`, present only when > 0) and every start, the total and the posts' timing are measured with it. The segment FILES are unchanged; the hold is applied where the cut is joined (`tpad` clone + `apad`), so `--chrome-only` changes it without rebuilding a clip. - **Make room.** With `posts.shift` (default `{ scale: 0.86, seconds: 0.6 }`; `false` turns it off), the footage eases from its box to `shiftedFootage(render)` — scaled, its far edge `inset` from the frame edge away from the column, centred above the deck — as a clip's first post appears, and stays there to the end of the segment; the next segment comes in at the normal box through the transition. The posts column then sits at the FRAME's edge (`postsGeometry`). At 1920×1080 the footage goes 1574×886 at (173,2) → 1354×762 at (24,64); the column is 600 wide at x 1296, overlapping the moved footage by 82 px instead of 600. - **More noticeable.** The cards themselves read as a highlighted interruption, not a caption. Core additions (`deck.mjs`): `shiftedFootage`, `postHolds`, `footageMoves` (the schedule's `moves`: `[{segment, at, segmentAt, seconds, from, to}]`, present only when there are moves), `posts.hold` and `posts.shift` settings and validation; `resolveDeck` fills `posts.shift` from its default. ### R1, as built Branch `deck/room-r1` from 69cb37d7. | Commit | What | |---|---| | 8d86edc6 | The hold and the move joined on the carrying clip's input (`segmentJoins`, `joinInputChain`, `moveFilter`, `xfadeGraph`, `hardCutFilterArgs`, `cutOffsets`); every length under the deck is the schedule's; the hard-cut record names its joins; verify-build's freeze check; `deck-room.test.mjs` | | f5067b04 | The cards: slide in from past the frame's edge, an accent flare that settles, an accent rail, a platform pill; text 24 px | | 4fb19848 | README, quirks, the `[Unreleased]` entry | Where it differs from, or adds to, the rulings above: - **The move is one `perspective` filter** (`sense=destination`, `eval=frame`): the input frame's corners are eased by smoothstep so the footage box goes from `from` to `to`. It resamples at 1/256 px; `scale` + `overlay` and `zoompan` round to whole pixels. Before the move it is the identity, copied bit for bit. It has no `t` and `in` counts from 1, so the clock is `(in-1)/fps`. It fills the uncovered band by clamping to the input's edge, so `fillborders` pins the outer 2 px to `palette.bg` first. Measured on c06: the box glides 172→22 px over 18 frames with no stepping. - **A hold shows `hold − transition` of still frame** under a crossfade, because the dissolve out starts inside it (2.0 s at the defaults). - **Text is 24 px, not more.** At 25 px the ferret cut's c06 stack (3 posts) measured 918 px against an 838 px column, and the oldest would have slid out before the hold. 24 px with tighter padding measures 829. - **verify-build's freeze check** compares luma outside the deck and the posts column, because the deck's progress fuse keeps moving during a hold. It allows a mean difference of 1.5; ferret measured 0.01–1.02. Gates on 4fb19848: - Workspace tsc clean. `test:scripts` 318 pass, 2 skipped. Capped umtool `next build` with the corpus linked exit 0 (58 s, under load from a concurrent encode), link removed, and the server bundle carries `const postsCues = (` (2 files). - Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5 `6a92235fa12ca181bb81993129c9ee9d`. A deck manifest without posts gives a `schedule.json` byte-equal to the base's, and the base and the branch give the same overlay chain, `applyChromeArgs` and `previewFromSegmentsArgs` (`segmentJoins` returns null). - Ferret, scratch copy, `--chrome-only` (crossfade): - 360.2 s, holds on c03, c06, c12 and c17; - posts and moves at the predicted seconds; - verify-build ok, with all four freezes found; - 17 chapters at the schedule's starts; - QR 7/7 posts and 17/17 deck. - Transition-0 copy through the concat-filter prerail: 368.2 s, verify-build ok, QR 7/7 posts. The `.segments` record names each join. - `--chrome-preview 126 14` gives 14.000 s (420 frames) from the segments: the dissolve in, the move, the stack and the hold. Left for R2 (umtool): - `umtool/lib/report/export.mjs`'s fallback, used when there is no `chapters.ffmeta`, still sums `segmentOffsets` without the holds. A build always writes the ffmeta, so it is reached only for a cut that was never fully built. - The preview's posts geometry is now the frame's edge (`postsGeometry`). The preview does not show the footage move or the hold; both happen in ffmpeg, at the join. Both items above were done after R1: R2 (b584c129) shows the move and the hold in the preview, and df062b37 reads a deck cut's offsets from its `schedule.json` when there is no `chapters.ffmeta`. ## Finale B2, as built Branch `deck/finale-b2` from df062b37. It starts with the fixes from the read-only review of the room work (SHIP AFTER FIXES). | Commit | What | |---|---| | 2f0e852b | Review fixes. The footage move now runs AFTER the hold, so a first post inside the hold still moves the frozen frame (before, it never moved or froze part-way). verify-build's freeze check samples only between the hold's start (or the move's landing) and the dissolve, and reports under three frames of still picture as not checked. `postHolds` rounds a hold to whole frames. The README and changelog say `--no-chrome` still holds and moves. | | 2ea53246 | A clip's `muteFrom` and `render.endFade`, joined on that input's chain; the `.cut.json` record beside each clip segment; validation; the QR host label fitted to the code's height | | ebe10c84 | README, quirks, one `[Unreleased]` bullet | Where it adds to the rulings above: - **`muteFrom`** is in SOURCE seconds and must lie within the clip's `start`–`end`. The sound is silent from that second, after a 40 ms `afade` that ENDS there, and it is digital zeros after that. A hold on the clip stays silent. The mute is mapped to the segment's clock through `.cut.json`, which every clip build now writes: the source seconds the segment was really cut from after snapping. A segment with no record, or one whose record does not match its length, falls back to the unsnapped `playWindow` start, and the build says so in a note. - **`render.endFade`** applies to the cut's last segment, whatever it is, hold included. The picture reaches `palette.bg` and the sound reaches silence on the last frame. The picture fade is a `geq` blend in yuv420p, enabled from its first frame. `fade=…:color=` would work too, but only in RGB, and the concat filter would then convert every segment of the cut to rgb24 and back. - Both are joins (`withCutEdits`, `cutJoins`), so they apply with or without the deck, on crossfades and hard cuts, and `--chrome-only` changes them without rebuilding a segment. The hard-cut record names them only when present. `validateCutEdits` (in `deck.mjs`) is what the build refuses with, and `validateChrome` also checks `endFade`. - **The QR's host label** is sized at load from the string's ink in the loaded face (canvas `measureText`). Tracking counts between letters only, and the first side bearing is indented away. On the ferret cut its ink covers rows 31–180, the same rows as the code. Before, it covered 54–180. Gates on ebe10c84: - Workspace tsc clean. `test:scripts` 341 pass, 2 skipped. Capped umtool `next build` with the corpus linked exit 0 (31 s), link removed. - Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5 `6a92235fa12ca181bb81993129c9ee9d`. The unchanged ferret deck manifest writes a `schedule.json` byte-equal at df062b37 and at the tip. With no muteFrom or endFade, the crossfade graph, the hard-cut graph and the record equal 2f0e852b's. Against df062b37 the only difference is the move and hold order on the four carrying clips. - Ferret, scratch copy with c20 `muteFrom: 24029.30` and `endFade: 1.0`, `--chrome-only` over copied segments with no cut records: - c20 muted 6.70 s into its segment, falling back to the unsnapped start, which the run notes; - 360.2 s, verify-build ok, all four freezes found; - QR 17/17 deck and 7/7 posts; - the last sample that is not zero is at 359.613 s of the audio, and everything after it is digital silence; - the last frame above the deck is bg (mean 1.0, worst 3 levels over rows 0–881). Found and left: - **The crossfade concat drifted the sound ahead of the picture — fixed in 6771cfb8.** Encoded segments' audio is routinely a few to ~20 ms off their video, and `acrossfade` joined the sound by its own lengths while `xfade` used the picture's: the ferret cut's sound ran 0.3 s early by the last clip (2.0 s on revision 2, with cards). `xfadeGraph` now pins each input's sound to its length in the cut (`apad=whole_dur`, `atrim=end`) before the join, so a `muteFrom` lands on its picture too. Every crossfaded cut's graph changed; `av-sync.test.mjs` holds it with real ffmpeg. ## Bench B1, as built Branch `deck/bench-b1`, merged as f619d659. umtool's clip bench plays every bounded range exactly, and sets the clip's `muteFrom` that B2 builds. | Commit | What | |---|---| | 5d7926f4 | `updateClip` takes `muteFrom` (source seconds inside the clip after the patch, rounded like an edge; null or empty deletes it), and a window save that would leave the mark outside the new extent is refused unless the same patch moves or clears it. `PUT /api/report/window` whitelists it and `/api/report/clip` returns it. `GET /api/report/audio` serves a cached window's sound, picked by `/api/report/raw`'s membership rule, decoded by ffmpeg to 16-bit PCM WAV over an absolute span of at most 120 s. `lib/report/playback.mjs` is the arithmetic both sides use (decode span, buffer schedule, playhead, mute ramp, the element fallback's stop test, the muteFrom rule, the WAV header), unit-tested | | bf927658 | The bench: **play selection**, the edge auditions, the auto-audition and a transcript line play from the decoded window with an `AudioBufferSourceNode`, started at an exact buffer offset and stopped at a context time (exact at every speed); the picture follows muted and the playhead reads the audio clock. One decode per window (the whole window up to 120 s, else the selection plus 30 s each side). When the decode fails, the element plays, stopped per animation frame by remaining time, and the bench says the playback is approximate and why. The mute mark: `m` at the playhead, **pick on waveform**, `;`/`'` to nudge (shift for 0.5 s), `M`/**clear mute**; drafted like the edges, saved by **save window** or a confirmation. Each playback is recorded as `data-play-*` attributes, and the specs check the stop against an AudioWorklet tap of the bench's output | | b9debbd7 | The audio-clock playhead paints at about 30 Hz; `m` reads it through a ref; the exact-stop spec checks the start against the schedule rather than the first non-zero frame | | e74c7a90 | The muted picture is re-seeked when it drifts more than 0.25 s from the audio clock; the element's own pause at the end of a playback no longer moves the playhead | After the merge, on the main line: - 3e3f251f: the bench's mute preview is the build's — one `MUTE_FADE` (0.04 s, from `deck.mjs`), a ramp that ends at the mark. B1 had faded over 0.05 s. - 3c70c6d3: the posts and mute specs follow whole-frame holds and the build's mute fade. Gates (from the merge): the stop measured on the ferret c20 window, 0 of 20 trials off its schedule, where the element it replaces overran by about 230 ms; the specs check the stop to within one render quantum at 1× and 2×. umtool e2e `clip-bench onscreen onscreen-posts build projects report-fetch-via-editor` 97/97. Reviewed. Found after the merge, by the finale review, and fixed in the fix pass below: the build refused a `muteFrom` that `updateClip` accepted within its 0.02 s tolerance (LOW-1); `MUTE_FADE` pulled `deck.mjs` into the bench's client bundle (LOW-5); the audio route spawned a bare `ffmpeg` and did not stop the decode when the request was aborted (LOW-6). ## Teaser T1, as built Branch `deck/teaser-t1` from 810e423e. A `teaser` timeline entry: a full-frame season-teaser card after the last clip, its words the manifest's, a trailer hit under each pop. | Commit | What | |---|---| | 3c4ab080 | `deck.mjs`: `teaser` in `CARD_TYPES` (the deck hides over it whatever `overCards` says), `teaserLines`/`teaserTail`/`teaserTitle`, `validateTeaser`/`validateTeasers`, `TEASER_MOTION` + `teaserTimes` (the one copy of the timing) and `teaserHits`; `chrome-teaser.mjs` (the page, `teaserCues`); compose-chrome region `teaser` by dynamic import; build-video `buildTeaserSegment`, `teaserAudioGraph`, `teaserEncodeArgs`, `teaserSegmentKey`, the chapter, `--chrome-only` building teasers, `--fetch-only` a no-op on one; verify-build `verifyTeasers`; `chrome-teaser.test.mjs`, `teaser-audio.test.mjs` | | a82aae0e | umtool: the report page's row and the export's fallback chapter name a teaser by its lines; `onscreen-fixture` carries a teaser and `onscreen.spec` checks the table, the preview's node and the row; the supporting lines a size up | | c51925ae | README section, four quirks, one `[Unreleased]` bullet | Where it adds to, or differs from, the brief: - **A line is a string or `{ text, break }`** — `break` is the END of `text`, drawn as a smaller, wide-tracked second tier 0.3 s after the rest; the whole `text` is what the chapter and umtool show. There are no quotation marks and no `quote` field (the operator dropped them). - **Roles follow position**: with three or more lines the first is the overline, the last the kicker, the rest titles; two are overline + title; one is a title. - **`hits`** (default true): a synthesised hit under each pop and a swell under the tail; false is digital silence. The hits are placed by `teaserTimes`, the times the cues are built from, and land on their pop's sample (a real-ffmpeg test differences the graph with and without each hit). - **`--chrome-only` builds teaser segments** instead of refusing: a teaser is chrome (graphics made from the manifest, nothing fetched). Its segment is re-encoded only when its key — the frames' render key and the whole sound graph — differs from `.teaser.json`'s. - **The deck hides over a teaser even with `overCards: "show"`**: it is full frame, never framed into the footage box. - The fit (a line wider than 80 % of the frame shrinks) runs once the face is in, as the deck's title fit does; it changes sizes, never a time. Gates on c51925ae: - Workspace tsc clean (102 s). `test:scripts` 355 pass, 1 skipped (356). Capped umtool `next build` with the corpus linked exit 0 (58 s, under a concurrent encode), link removed; Turbopack bundles the teaser page and its face as their own server chunk. - Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5 `6a92235fa12ca181bb81993129c9ee9d`. The ferret deck manifest without a teaser measures the same schedule at 810e423e and at the tip (`measureChromeSchedule`, byte-equal); with the teaser, the first 17 segments, the posts and the moves equal the ferret's built `schedule.json`. - Ferret, scratch copy with the teaser after c20, `--chrome-only` over copied segments: - the teaser rendered in 77–86 s (210 frames), the deck (11,001 frames) in 4 min 7 s; a re-run after a design change re-rendered the teaser only (deck and posts `cached`, 573 s); - 366.7 s (360.2 + 7 − 0.5), video and audio both 366.700 s; verify-build ok, every hold frozen, `teaser fin: 210/210 frame(s), segment encoded from them`; - QR 17/17 deck and 7/7 posts; 18 chapters, the last "Pirate Software — The Largest Ferret Rescue in the United States — February 2027 ?" at 360.2 s; - the last frame is bg (Y′CbCr 30/132/128, uniform, against 31/132/128); the sound is digital silence for its last 39 ms; - loudness: the cut −17.7 LUFS integrated, the teaser's 7 s −19.7 LUFS, sample peak −6.0 dBFS. - umtool e2e `onscreen onscreen-posts build projects`: 43 passed, 1 failed (2.9 min). The failure is `onscreen-posts.spec.ts:264`, and it predates this branch: 2f0e852b rounds a hold to whole frames, and at the posts fixture's 15 fps 2.5 s is 38 frames (2.533 s), while the spec still expects 2.5. No teaser is in that fixture. Found and left: - umtool cannot edit a teaser's lines. It needs a writer (`updateTeaser` through `withManifestLock` and `validateTeaser`), a route, a form (a field per line with a break picker) and a still of the composition (compose-chrome's `--still`, as the deck's still route does). - `onscreen-posts.spec.ts`'s timings at 15 fps (above) — resolved: 3c70c6d3 on the main line made the posts and mute specs follow whole-frame holds and the build's mute fade, and the merge carries it. ## Fix pass after the finale review The read-only review of 1aecf54f (everything after df062b37: B2, the A/V pin, B1, T1) said SHIP AFTER FIXES. A capped umtool `next build` with the corpus linked passed at 1aecf54f: exit 0, 26 s. | Commit | What | |---|---| | 3465184d | FIX-A: an `endFade` longer than the last segment is the segment. `endFadeFrames` clamps the fade's frames to `lastFrame`, and the sound fades over the same frames, so the last frame is bg and the sound silent together (a 3 s segment under `endFade: 5` was 59 % of the way to bg) | | 3d9d5afd | FIX-B: "Bench B1, as built" above; one `[Unreleased]` bullet | | 083ee2d6 | LOW-5: `MUTE_FADE` lives in the dependency-free `report-to-video/mute.mjs` (`umtool-report-to-video/mute`), re-exported by `deck.mjs`; the bench's `playback.mjs` imports it from there | | ece9c818 | LOW-6: `/api/report/audio` spawns the build's `FFMPEG_BIN`, and the request's abort signal kills the decode | | 46a4d417 | LOW-1: a window save clamps a mute mark within the writer's 0.02 s of the moved edge onto it and stores it. Chosen over loosening the build: the build's check stays strict for every writer, and the clamp is what a patched mark already got | | 0ffed4f2 | LOW-2: a `muteFrom` at or past its segment's length (inside the extent, past `cutEnd`) is noted as "will not be heard", not "muted from" | | c56f1a74 | LOW-3: the teaser segment's key hashes `encodeArgs(render)` (crf, preset, audio bitrate, rate, channels) | | 964585a1 | LOW-4: `TEASER_LIMITS.fit` — title 34, kicker 56, overline 64, second tier 66 characters per row, the tail and its gap counted on its row; `validateTeaser` refuses a longer row with a sentence | | 07d1fa08 | LOW-7: the records | - **LOW-4, rendered.** A teaser whose title line is the 80 characters the limit allowed, still at 6.5 s through `compose-chrome --region teaser --still`: shrunk to its 56 px floor, the line ran past both edges of the frame (ink in columns 0–1919). Measured in the face at each role's floor on ordinary headline words in capitals, a row holds: title 36–37, kicker 58–60, overline 65–67, second tier 69–71. Each limit is a little under that. Stills at the limits, the tail included where it sits, keep their ink within columns 114–1804. A row of only wide capitals (M, W) can still spill at these counts, and the README says so. - **LOW-5, the client bundle.** Client chunks (`umtool/.next/static`) at 1aecf54f: `crypto-browserify` in 1 chunk (3 hits), `createHash` 1, 1,505,165 bytes in all. After: 0, 0, 1,043,591 bytes. `chromeCacheKey` is 0 both times, because the minifier renames it. - **LOW-7.** The changelog no longer says a cut without the deck, posts, `muteFrom` or `endFade` "builds exactly as before" (the pin changed every crossfaded graph). The end fade falls on whatever the last segment is, a closing teaser included. The README's "what it was" line is corrected. The teaser's loudness is the segment's measured −19.7 LUFS in both. The 15 fps spec item is marked resolved. `quirks.md` has the acrossfade-by-sound vs xfade-by-picture drift. Gates at 07d1fa08: - Workspace tsc clean (49 s). `test:scripts` 368 pass, 2 skipped (370). - Capped umtool `next build` with the corpus linked: exit 0 (23 s), link removed. - Byte-identical: `--only c07 --skip-fetch` on the unchanged ferret manifest copy without `render.chrome` gives md5 `6a92235fa12ca181bb81993129c9ee9d`. - umtool e2e `onscreen-posts onscreen clip-bench build projects report-fetch-via-editor`: 97 passed (4.9 min). ## 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 `.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`), 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": , "black": }`. | 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 (`.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. ## Post links, as built A post's QR links its page on the archive by default, not bsky.app / x.com: the archive page survives the post or the platform going away, and it links on to the original ("Open original" in the post view; unchanged). - **The link** (`deck.mjs`, pure). `postQrUrl(post, provenance, { links })`: `links: "original"` is the post's `url`, always. Under `"archive"` (the default, `posts.links`): the post's `siteUrl`; else, with `provenance.siteOrigin`, a `siteChannel` and the post's id, `/?v=%2F&vm=post`; else its `url`. The id is `postNativeId`: `postId`, else the url's bsky `/post/` or X `/status/`. `postSchedule` takes `provenance` and every placed post's `qrUrl` is this; `url` stays beside it. - **The keys.** On a post, optional: `siteChannel` (a slug), `siteUrl` (http(s)), `postId` (letters, digits, `-`, `_`). On the deck, `posts.links` `"archive" | "original"`. `validatePosts` / `validateChrome` refuse anything else; umtool's deck form has the setting as **qr links**. - **The resolver** (`post-links.mjs`). A post channel is its own channel on the archive (`piratesoftware-bsky`), which a manifest rarely names, so `resolvePostLinks` finds it for every post that pins neither `siteChannel` nor `siteUrl`: `/corpus.json`'s channels with `manifests.posts`, handle-matching slugs/names first, the first whose `slugToPage` has the id. One note per shown post. A miss or an archive that does not answer links the original; it never fails a build. Hidden posts and a cut without the deck are not looked up. Reads go through `createJsonCache`, factored out of `cues.mjs` (the cue walk's memory + disk cache, which now also shares an in-flight fetch between callers): a miss in a cached copy is re-read fresh once per process (per `refreshAfterMs` for a server). 15 s a request. - **The failure memo** (after review). One memo, `url -> { err, at, refresh }`, read by both passes and expiring after `refreshAfterMs` (Infinity in a build, ten minutes in the server); a success clears it. A failed cached read went to the network, so it bars both passes; a failed fresh read bars only fresh reads, so the copy in hand still answers the cached pass (and a cached hit does not clear it). An archive that stalls therefore costs one timeout per URL per build, not one per post. `resolvePostLinks` takes `deadlineMs` for a bound on the whole call; the preview passes 10 s. - **The build.** `linkPosts()` runs once, just before the first `writeChromeSchedule` (full build, `--chrome-only`, `--chrome-preview`): after every refusal, so a refused run asks nothing of the network. It sets `siteChannel` on the run's copy of the posts only. `schedule.json` carries the resolved `qrUrl`, which compose-chrome reads; the QR is an asset of the page, so a changed link is a new chrome key. `opts.postResolver` is the injection point. - **umtool's preview: resolved, not pinned.** `scheduleForPreview` links the variant's posts, with the request's posts draft applied (so an un-hidden post is linked), through the same resolver (one per server process: memory across requests, the build's disk cache, 8 s a request, 10 s for the whole request, failures and misses retried at most every ten minutes) before `previewSchedule`, built schedule or estimate. The found `siteChannel` goes on the request's copy of the posts, not the draft's. Chosen over a writer action that pins `siteChannel`: a pin needs a route, a control and the same network lookup, and until someone pressed it the preview and the build would disagree. Pinning by hand still works and skips the lookup. - **e2e.** The onscreen-posts and onscreen-feed fixtures pin `siteChannel`, so neither the preview nor the feed build asks `archive.example` anything. The spec's one URL assertion is the table's "open the post" link, the original, unchanged. - **MCP.** `get_post` adds `- archive: ` (common `viewerPostUrl`) when the source has a viewer origin: the channel's member site on a hub, the site's own otherwise. - **`null` is unset** for `siteChannel`, `siteUrl` and `postId`, as for `attachTo`, so the README's example validates. - **The shared cache.** A post miss refreshes the cue walk's on-disk `corpus.json` (and the posts manifests); `cues.mjs`'s header says so. Manifest URLs carry no version, so no cue window moves; a later cue lookup can find a channel a stale copy lacked. - **Known, left as is.** - The archive is only ever `provenance.siteOrigin`: a hub origin (whose `corpus.json` lists no channels), a manifest naming its archive only as `corpus: "remote:…"`, or a build run with `--site-origin` all link the originals. Clip QRs behave the same way. The no-origin note says to set `provenance.siteOrigin`. - With no handle match, corpus order decides between channels that hold the same id, and a handle whose first label is generic (`bsky.app` → `bsky`) matches every `*-bsky` channel. X ids are global and bsky rkeys are TIDs, so a collision is unlikely. First use: the ferret-rescue manifest's seven Bluesky posts all resolve to `piratesoftware-bsky` on its archive (each id on page 0 of that channel's posts manifest, each record's `url` the post's own); a feed still at 140 s decodes to the two archive links on screen. ## Teaser tail and feed plate Branch `deck/post-links`. Two asks after the ferret previews: "2027" centred, held a couple of seconds, then the "?" fading in; and no "No posts yet" plate in the feed. | Commit | What | |---|---| | 43f7de98 | The tail hangs off its row: the last line is centred by its own words, the tail in a zero-width `tail-hang` box on its baseline at its 0.32 em gap. The validator counts the tail and its gap TWICE on the row that carries it (that room is needed on both sides of the centre), and the page's fit measures the words plus twice the tail, so a wide last line still cannot push the tail past the frame. `tailWait` (0.3–6 s, only with a tail): seconds from the last hit (the last line's impact, or its second tier's pop) to the tail; it sets the motion's `tailAfter`, so the swell and the card's length follow. Without it the beat's wait stands. The ferret page hash is re-pinned on purpose (the year moved to the centre) | | 2d75445d | The feed's empty plate is gone (element, rule, init, the first post's cue, `FEED_MOTION.emptyOut`): before the first post the column is its header over its ground. README and the `[Unreleased]` bullets | The ferret finale as the manifest now stands (`beat: 1.05`, dip `{0.6, 0.6}`, D 0.5) with `tailWait: 2`: the year's impact at 4.0 s into the teaser segment, the tail from 6.0 s (fading over 1.7 s), the card 7.8 s, the segment 8.9 s (7.9 s without `tailWait`). Stills from compose-chrome: the year's horizontal centre is 959.5 px at +1 s after its impact, mid tail fade and the last frame (the frame's centre is 960), the "?" to its right ending at 1193 px. Review of 14739ad7..b4a2fc09 (read-only): SHIP. M1 accepted as a one-time cost: the `.tail-hang` rule and the fit script's new text are in every teaser page, tail or not, so a teaser without a tail misses the render cache once and re-renders with the same frames (its data, hits, seconds and validation are unchanged). LOWs left: below beat ~0.47 a break on the last line pops before the line's impact, so `tailWait` counts from the earlier pop; the beat's own wait at 0.4 (0.257 s) is below `tailWait`'s 0.3 floor; the too-short and over-20 s refusals do not name `tailWait`; `teaserTailWait` and `dippedMotion` repeat one rule. The README's tail-wait wording now says the 0.2 s slam applies without a `break`, and its ferret numbers are the dipped ones. The ferret cut holds the finished line 2 s more with `seconds: 9.8` (card), a 10.9 s teaser segment, 360.967 s in all.