commit ea5277a0da6b107577e3fdf52138cd0b1c31aed4
parent 466b5043ff18c40f4e162351784e75f130b14b8a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 01:03:36 -0400
report-to-video: posts on the deck — README, quirks and changelog
The README documents the `posts` manifest key, the `deck.posts` settings,
the attachment and timing rules, and how the windows are composed, cached,
overlaid and verified. quirks.md gains four traps: eof_action=pass (never
shortest=1) for a short sequence partway through the cut, line-clamp over
blank lines, planning a measured layout after the faces load, and keeping
chrome page modules out of build-video's static imports. One [Unreleased]
changelog bullet.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 130 insertions(+), 1 deletion(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,6 +2,7 @@
## [Unreleased]
- **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. A manifest without `render.chrome` builds exactly as before, byte for byte.
+- **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear two seconds apart, stack down a column at the footage's top right, and leave together in the change to the next clip; when the column is full the oldest slide up and out. Each card shows the post's date, `@handle · Bluesky` (or X), its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The timing, the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither. A cut without `posts` builds exactly as before, and without the deck `posts` is not drawn at all.
- **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/umtool/docs/quirks.md b/umtool/docs/quirks.md
@@ -202,6 +202,46 @@ fonts in as private families to dodge the `local()` trap above, but a missing
silently to the browser default, because the deck has no other on-screen text
to notice a wrong metric by.
+**A short sequence laid partway through the cut takes `eof_action=pass`,
+never `shortest=1` — the posts windows.** `shortest=1` ends the overlay's
+OUTPUT when its shorter input ends, so a five-second window would end the
+whole cut there. `-itsoffset <s>` on the window's input puts its frame 1 at
+that second; before it the overlay has no secondary frame and passes the main
+through, and after its last frame `eof_action=pass` does the same. Measured
+with framemd5: every frame outside the window is bit-identical to the
+deck-only frame, the length is unchanged, a window running past the cut's end
+does not lengthen it, and a negative `-itsoffset` (a `--chrome-preview` that
+starts after the window does) works. A window's sequence mixes RGB and RGBA
+frames like the deck's, so it takes the same `-reinit_filter 0` +
+`format=rgba`. `chrome-posts.test.mjs` runs ffmpeg to keep all of this true.
+
+**`-webkit-line-clamp` over text with blank lines can put the ellipsis on an
+empty line.** A post's paragraphs are separated by blank lines; clamped as one
+`pre-line` block, a clamp that lands on the blank line draws a lone "…" under
+the last words. Each paragraph is its own block, a part-line apart, clamped to
+what is left of `maxLines` once the faces are in; a blank line costs nothing,
+and a dropped paragraph puts the ellipsis on the last one drawn.
+
+**A layout that depends on measured text is planned after the faces load, and
+the timeline registered at the END of that callback.** The posts stack needs
+each card's height, which is how its words wrap in the deck's face. The page
+builds its timeline inside the fonts-loaded callback and only then assigns
+`window.__timelines["posts"]` (and calls `__hfForceTimelineRebind` when the
+runtime has it): HyperFrames' own lint calls registering an empty timeline
+first and filling it later an error (`gsap_timeline_registered_before_async_build`).
+The renderer awaits `document.fonts.ready` before its first seek, so every
+frame sees the built timeline.
+
+**`build-video.mjs` must not import a chrome PAGE module statically.**
+umtool's server bundles build-video into every report route, and Turbopack
+turns `chrome-deck.mjs`'s `new URL("./assets/gsap.min.js", import.meta.url)`
+into an asset URL that `fileURLToPath` refuses at module load ("Received an
+instance of URL"), failing `next build` while it collects page data. The page
+modules are reached only through compose-chrome's dynamic import; the posts
+windows' frame arithmetic the build needs (`snapWindow`) lives in `deck.mjs`
+for that reason. A capped umtool build is the gate that catches it — the unit
+tests run in plain Node and pass either way.
+
## 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/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -222,7 +222,9 @@ whatever a manifest omits, one level deep:
"subtitle": { "parts": "auto", "dateFormat": "long" }, // parts "auto" | a distinct list of channel/title/date/clock; dateFormat "long" | "iso"
"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
+ "motion": { "out": 0.3, "in": 0.45, "pip": 0.7 }, // seconds; out/in 0–2, pip 0–3
+ "posts": { "show": true, "seconds": 2, "position": "top-right", // the manifest's `posts` (below); position | "top-left"
+ "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } // seconds 0.5–10; width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80
}
}
}
@@ -253,6 +255,48 @@ card's `sub`. The QR follows a clip's corner-QR rule unchanged (`citeUrl`, else
the site link at the clip's start); an image draws one only with an explicit
`citeUrl`; a card never does.
+### `posts` — written statements on the deck
+
+A cut that wears the deck can carry a post — a Bluesky or X statement — as a
+card over the footage, near the end of the clip it belongs with
+(`plans/deck-posts.md` has the rulings). A post is not a segment: it has no
+footage, so it rides on a clip.
+
+```jsonc
+"posts": [
+ { "id": "bs-3msydljwjis2a", "platform": "bluesky", // "bluesky" | "x"
+ "author": "Pirate Software", "handle": "piratesoftware.live",
+ "date": "2026-08-13T19:05:26.424Z", // ISO date or date-time
+ "text": "We just signed off on 51 page document …", // newlines kept; ≤ 3000 characters
+ "url": "https://bsky.app/profile/piratesoftware.live/post/3msydljwjis2a", // the QR
+ "attachTo": null, // a clip id, to override the date rule
+ "hide": false } ]
+```
+
+- **Which clip.** The one whose recording most closely PRECEDES the post: the
+ latest day on or before the post's (a clip's day is its own `date`, else its
+ record's upload date — the build reads the real one), ties 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. A variant that drops the named clip falls back
+ to the date rule.
+- **When.** A clip's posts, oldest first, stack: post j of k appears at
+ A − seconds·(k − j), where A is the start of the outgoing transition (the next
+ segment's start under a crossfade, 0.3 s before a hard cut, 0.3 s before the end
+ of the cut). Each has `seconds` alone before the next stacks on, and the last
+ has the clip's final `seconds`; a clip too short for that shares what it has
+ after its incoming dissolve. They all leave together over the transition.
+- **What.** The post's date (as the deck writes dates; a date-time is drawn as
+ its day), `@handle · Bluesky` (or X), the words — paragraphs kept, clamped to
+ `maxLines` with an ellipsis — and a QR of the post's own `url`.
+- **Where.** A column inside the footage box, `inset` from its top and from the
+ `position` side, `width` wide. Cards stack top-down; when the next would
+ overflow the column, the oldest slide up and out.
+
+`validatePosts()` (`deck.mjs`) is the one validator, unknown keys refused; a
+deck build refuses a bad `posts` before a single fetch. Posts are drawn only
+under the deck: without `render.chrome` they are data for the report, and
+nothing in a build reads them.
+
### The `image` entry type
A still: the receipts a clip cannot say out loud — a post, a thread, a DM, a
@@ -942,6 +986,50 @@ ported from the diet fork) lays the panel on afterwards, because the concat
demuxer's stream copy cannot host a filtergraph. The legacy chart band still
refuses a hard-cut transition outright.
+### Posts on the deck
+
+When the schedule carries `posts` (`deckSchedule` adds the key only when there
+are some, so a cut without them writes the schedule it always did), the build
+renders one short sequence per clip that carries posts — a WINDOW
+(`postWindows`), from that clip's first card appearing to the end of its
+leave — and lays each over the cut at its own second, after the deck's own
+region. `chrome-posts.mjs` is the page (pure, like `chrome-deck.mjs`).
+
+```
+node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts --segment <clip id>
+ [--still <cut s> --png <path>] [--render] [--preview]
+```
+
+- **The window** is snapped outward to the frame grid (`snapWindow`): frame 1
+ lands exactly on cut frame `f0`, and the sequence is `frameCount(to − from,
+ fps)` of the snapped window, never past the cut's end.
+- **Files:** project `chrome/posts-<segment>/`, frames
+ `chrome/posts-<segment>-frames/` with their own `.key` (html, assets, fps,
+ frames, renderer version — the deck's cache, per window); `--preview`
+ composes `chrome/posts-preview-<segment>/` and never renders.
+- **The page** is region-local (`postsGeometry`, 600×838 at (1123, 26) by
+ default), transparent outside the cards. `?still=<t>` and the preview's
+ `deck:seek` take CUT seconds; a preview page answers with
+ `{type: "posts:ready", segment, from, to, ids}`.
+- **The stack is planned in the page.** Whether the next card overflows the
+ column depends on how its words wrap in the deck's face, so the page
+ measures the cards once the fonts are in and hands the heights to
+ `postsCues` — the module's own function, carried in by its source text, so
+ the tests run the exact plan the page runs. The timeline is built and
+ registered in that callback; the renderer awaits `document.fonts.ready`
+ before its first seek.
+- **The overlay:** each window is an input with `-itsoffset <from>` (negative
+ in a `--chrome-preview` that starts after the window does), the deck's
+ `-reinit_filter 0` + `format=rgba`, and
+ `overlay=…:format=yuv444:eof_action=pass` — **not** `shortest=1`, which would
+ end the whole cut where the window ends. Outside its window every frame is
+ the deck-only frame, bit for bit, and the length is unchanged (a unit test
+ runs ffmpeg to say so). The crossfade concat, the hard-cut `applyChrome`
+ pass, `--chrome-only` and `--chrome-preview` (only the windows it touches)
+ all lay them; `--no-chrome` lays neither.
+- **`verify-build`** also checks each window's `chrome/posts-<segment>-frames`
+ holds that window's frame count.
+
### Two ffmpeg traps that are the deck's alone
- **Mixed RGB/RGBA frames restart the whole filtergraph.** HyperFrames writes