commit f0c57c7ebe835996454e4fa80b205cd02bdf14dc
parent ff8449185ef7ba3a465e733dfd17174333c68f64
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 13:33:05 -0400
report-to-video: docs for room for posts — the hold, the move, 4 s each, the cards
README: posts.hold, posts.shift and the 4 s default; how the hold and the
move are joined on the carrying clip's input; the column at the frame's edge;
the cards' entrance and styling; verify-build's freeze check. Quirks:
perspective's frame counter and edge clamping, a hold no longer than the
crossfade, xfade's own pixel format. The [Unreleased] posts entry describes
the hold, the move and the 4 s spacing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 89 insertions(+), 12 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,7 +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 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. 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
@@ -232,6 +232,34 @@ first and filling it later an error (`gsap_timeline_registered_before_async_buil
The renderer awaits `document.fonts.ready` before its first seek, so every
frame sees the built timeline.
+**`perspective` has no `t`, and its `in` counts from 1.** The footage move
+for posts animates one `perspective` filter (`eval=frame`), whose expressions
+see only `W`, `H`, `in` and `on`. Measured: the first frame has `in = 1`, and
+the count runs on through frames a timeline `enable` passed by. The segment's
+own clock is therefore `(in-1)/fps`; written as `in/fps` the move starts a
+frame late. An identity map (every corner at its default) copies the frame bit
+for bit, so the frames before the move need no `enable` at all.
+
+**`perspective` fills what a shrink uncovers by CLAMPING to the input's edge.**
+It does not paint a colour: the band the footage leaves behind is the input's
+outermost row or column, smeared. The deck framing puts `palette.bg` there,
+but a coded frame's edge pixels are only approximately that colour, so
+`fillborders=…:mode=fixed:color=<bg>` pins the outer 2 px first. Without the
+deck framing (footage to the frame's edge) the same filter would smear footage
+across the gap.
+
+**A hold no longer than the crossfade is never seen.** The hold is appended to
+the outgoing clip and the dissolve into the next one starts `transition`
+seconds before that clip's end, so with `transition: 0.5` a 2.5 s hold shows
+2.0 s of still frame and then dissolves out of it; a 0.5 s hold is consumed
+entirely. The real-ffmpeg test holds 1 s for this reason.
+
+**`xfade` hands on its own pixel format.** Even the frames before its offset,
+which are the first input's, come out as yuv444 rather than the input's
+yuv420p, so a framemd5 of the crossfade's output never equals the segment's
+own. "Untouched" for a crossfaded input means equal to the graph the build ran
+before (the test compares the two graphs), not equal to the file.
+
**`build-video.mjs` must not import a chrome PAGE module statically.**
umtool's server bundles build-video into every report route, and Turbopack
turns `chrome-deck.mjs`'s `new URL("./assets/gsap.min.js", import.meta.url)`
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -223,8 +223,10 @@ whatever a manifest omits, one level deep:
"qr": { "show": true, "size": 150 }, // size 80–380, and at most height − 20
"overCards": "hide", // "hide" | "show"
"motion": { "out": 0.3, "in": 0.45, "pip": 0.7 }, // seconds; out/in 0–2, pip 0–3
- "posts": { "show": true, "seconds": 2, "position": "top-right", // the manifest's `posts` (below); position | "top-left"
- "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } // seconds 0.5–10; width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80
+ "posts": { "show": true, "seconds": 4, "hold": 2.5, // the manifest's `posts` (below); seconds 0.5–10; hold 0–10 (0: none)
+ "shift": { "scale": 0.86, "seconds": 0.6 }, // the footage makes room: scale 0.5–1, seconds 0–3; or false
+ "position": "top-right", // | "top-left"
+ "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } // width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80
}
}
}
@@ -282,15 +284,33 @@ footage, so it rides on a clip.
- **When.** A clip's posts, oldest first, stack: post j of k appears at
A − seconds·(k − j), where A is the start of the outgoing transition (the next
segment's start under a crossfade, 0.3 s before a hard cut, 0.3 s before the end
- of the cut). Each has `seconds` alone before the next stacks on, and the last
- has the clip's final `seconds`; a clip too short for that shares what it has
- after its incoming dissolve. They all leave together over the transition.
+ of the cut). Each has `seconds` (4 by default) alone before the next stacks on,
+ and the last has the clip's final `seconds`; a clip too short for that shares
+ what it has after its incoming dissolve. They all leave together over the
+ transition.
+- **The hold.** A clip that carries posts is held on its last frame, in silence,
+ for `hold` seconds (2.5) before its outgoing transition, so the last post can
+ be read. The hold is part of the clip's length in the CUT: every start after
+ it, the total, the chapters and the posts' own timing are measured with it
+ (the schedule's `segments[i].hold`).
+- **The move.** With `shift` on (the default), the footage eases over
+ `shift.seconds` (0.6) from its box to `shift.scale` of it (0.86), its far edge
+ `inset` from the frame's edge away from the column and centred above the deck,
+ as the clip's first post appears; it stays there to the end of the clip, hold
+ included, and the next clip comes in at the normal box through the transition
+ (the schedule's `moves`). At 1920×1080: 1574×886 at (173, 2) → 1354×762 at
+ (24, 64).
- **What.** The post's date (as the deck writes dates; a date-time is drawn as
its day), `@handle · Bluesky` (or X), the words — paragraphs kept, clamped to
`maxLines` with an ellipsis — and a QR of the post's own `url`.
-- **Where.** A column inside the footage box, `inset` from its top and from the
- `position` side, `width` wide. Cards stack top-down; when the next would
- overflow the column, the oldest slide up and out.
+- **Where.** A column `inset` from the top of the footage box and from the
+ FRAME's edge on the `position` side (inside the footage box when `shift` is
+ `false`), `width` wide. Cards stack top-down; when the next would overflow the
+ column, the oldest slide up and out.
+- **How they read.** Each card slides in from past the frame's edge on the
+ column's side and its accent rim flares as it lands, then settles to a quiet
+ glow; an accent rail runs down its leading edge and the platform is a pill
+ beside the handle. The QR is fully opaque once the card is in.
`validatePosts()` (`deck.mjs`) is the one validator, unknown keys refused; a
deck build refuses a bad `posts` before a single fetch. Posts are drawn only
@@ -1007,8 +1027,8 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts --
`chrome/posts-<segment>-frames/` with their own `.key` (html, assets, fps,
frames, renderer version — the deck's cache, per window); `--preview`
composes `chrome/posts-preview-<segment>/` and never renders.
-- **The page** is region-local (`postsGeometry`, 600×838 at (1123, 26) by
- default), transparent outside the cards. `?still=<t>` and the preview's
+- **The page** is region-local (`postsGeometry`, 600×838 at (1296, 26) by
+ default; at (1123, 26) with `shift: false`), transparent outside the cards. `?still=<t>` and the preview's
`deck:seek` take CUT seconds; a preview page answers with
`{type: "posts:ready", segment, from, to, ids}`.
- **The stack is planned in the page.** Whether the next card overflows the
@@ -1028,7 +1048,36 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts --
pass, `--chrome-only` and `--chrome-preview` (only the windows it touches)
all lay them; `--no-chrome` lays neither.
- **`verify-build`** also checks each window's `chrome/posts-<segment>-frames`
- holds that window's frame count.
+ holds that window's frame count, and that each held clip's freeze is in the
+ file (the frame at `end − hold/2` is the frame at `end − hold + ε`, outside
+ the deck and the column, within re-encoding noise).
+
+### The hold and the move, where the cut is joined
+
+`segmentJoins(schedule)` turns the schedule's holds and `moves` into one join
+per carrying clip; the segment FILES are never touched, so `--chrome-only`
+changes a hold or a move without rebuilding a clip, and a cut without posts has
+no joins and runs the graphs it always did.
+
+- **The hold** is `tpad=stop_mode=clone:stop_duration=<hold>` on the input's
+ picture and `apad=pad_dur=<hold>` (silence) on its sound, before the
+ xfade/acrossfade.
+- **The move** is one `perspective` filter on that input (`sense=destination`,
+ `eval=frame`): the input frame's corners are placed so the footage box goes
+ from `from` to `to`, eased by smoothstep over `[segmentAt, segmentAt +
+ seconds]` in the segment's own clock, then held there. Perspective resamples at
+ 1/256 px, so the box glides with no whole-pixel stepping, where `scale` +
+ `overlay` and `zoompan` round to whole pixels. Before the move the map is the
+ identity, which perspective copies bit for bit. `fillborders` pins the
+ frame's outer 2 px to `palette.bg` first, because perspective fills what the
+ shrink uncovers from the input's edge.
+- **Hard cuts** with a hold or a move concatenate through the concat FILTER
+ (`hardCutFilterArgs`, one encode) rather than the demuxer's stream copy; the
+ prerail's `.segments` record then names each join, so a changed hold is never
+ served from a stale concat. Without joins the stream copy is unchanged.
+- **Every length is the schedule's** under the deck: the xfade offsets, the
+ chapters, a `--chrome-preview` window and `--chapters-only` all add the holds
+ to the probed lengths (`cutOffsets`), the same sum `deckSchedule` makes.
### Two ffmpeg traps that are the deck's alone