commit 3ecb4864cdea7bf54b81d534e12d85d5f282b84d
parent 8c3a13f9857f395be8f40ad646048a6262d2f46f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 23:24:33 -0400
Merge deck/s7-docs (deck slice S7) — README render.chrome/onscreen reference and the deck section, report-video/build/clip-bench docs, three quirks (reinit_filter + rgba, absolute concat list, strict deck fonts), the plan's As built; reviewed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 367 insertions(+), 7 deletions(-)
diff --git a/plans/onscreen-deck.md b/plans/onscreen-deck.md
@@ -238,3 +238,55 @@ On-screen title/subtitle fields beside ATTRIB with a live deck preview fed by
m ± 0.2 s and mid-clip; `zbarimg` on each mid-clip QR decodes that clip's URL;
ffprobe chapters equal the on-screen titles; an edit-one-title `--chrome-only`
round trip re-renders the deck only.
+
+## As built
+
+Points where a slice's actual shape differs from, or adds detail this contract
+left open to, what is written above. None of these broke a verification gate;
+they are the things a reader of this file alone would not know.
+
+- **`deckLayout` fields were left open here on purpose ("S2 may tune the
+ numbers HERE"), and S2 did.** The shipped numbers: 48 px side padding, a
+ 16 px pip track with 14 px of clearance below it for the active pip's halo,
+ the title and subtitle boxes sized off `title.size` (title box
+ `1.2×`, subtitle `48%` of it clamped to 18–34 px, the gap between them
+ `30%`), and the QR (when shown) right-aligned with the text column ending
+ 48 px short of it. None of this is a second contract — `deckLayout` is still
+ the one place that draws it — but a reader of this file alone would not know
+ the actual pixels without reading `deck.mjs`.
+- **The per-clip progress fill is not a pure export.** The contract's
+ `deckChoreography` returns `{handovers, visibility}` and says in prose that
+ "a progress fill runs with the clock"; what ships is a `fuse` element in
+ `chrome-deck.mjs` whose `scaleX` is computed directly from the pip x
+ positions `deckLayout`/`pipXs` already handed it (burning from pip k to pip
+ k+1 while clip k plays), not a third field `deckChoreography` returns. The
+ timing is still handover-derived and still has no second implementation —
+ it is simply derived in the composition, where it is drawn, rather than
+ handed to it as data.
+- **`-reinit_filter 0` / `format=rgba` on the deck's overlay input isn't in
+ this contract at all.** S3 found it: HyperFrames' RGB/RGBA-mixed sequence
+ restarts ffmpeg's filtergraph mid-concat and truncates the output (a
+ 3.5 s xfade+overlay came out 2.0 s). It's now load-bearing in
+ `chromeOverlayChain`, on the deck's input only — see
+ `umtool/docs/quirks.md` and the pipeline README's deck section.
+- **`assertChrome` runs on any `render.chrome`, not only when `deckOn`.** The
+ contract's build section says "When `deckOn(render)`: 1. `assertChrome`
+ before any fetch" — read narrowly, a malformed block with a bad `engine` or
+ `layout` (so `deckOn` is false) would reach the fetch stage unchecked.
+ `buildVideo()` instead calls `assertChrome(render.chrome, render)` whenever
+ `render.chrome` is present at all, `deckOn` or not, which is the stricter
+ and correct reading.
+- **Two GET routes exist that the contract only specified as PUT.**
+ `GET /api/report/chrome` (the settings form's starting state: the saved
+ block, `deckOn`, the resolved deck, `DECK_DEFAULTS`, and `validateChrome`'s
+ errors against a block somebody hand-edited into an invalid shape) and
+ `GET /api/report/onscreen` (the On-screen table's rows: each entry's saved
+ `onscreen` beside the AUTO title/subtitle it falls back to, `maxChars`, and
+ the token) were needed for the UI to have anything to render and shipped
+ alongside the PUTs the contract named.
+- **A windowed compose gets its own name, not just its own flag.**
+ `compose-chrome.mjs --from`/`--duration` (used by `--chrome-preview`) writes
+ a project and frame sequence named `deck-from<s>[-frames]` rather than the
+ full cut's `deck`/`deck-frames`, so a preview window never overwrites or
+ races the whole-cut sequence's cache. The contract names `--from`/`--duration`
+ as composeChrome arguments but not this naming rule.
diff --git a/umtool/docs/build.md b/umtool/docs/build.md
@@ -45,6 +45,7 @@ command" prints the argv with them in it, which is the proof.
| **hard cuts** | `--no-xfade` | hard cuts on a preset that would crossfade | all four |
| **chapters only** | `--chapters-only` | retitle the chapters from the segments already on disk — no fetch, no encode | build only, 5-minute cap; refuses when a segment is missing |
| **rail preview** | `--preview <at> <dur>` | the rail alone over a window, to `<slug>.preview.mp4` | build only, 5-minute cap; no verify (nothing to verify) |
+| **re-render on-screen** | `--chrome-only` | re-lay the on-screen deck over the segments already on disk — re-probe, rewrite the schedule, recompose (re-render only if its cache key changed), re-concat with the overlay, re-mux the chapters | build only; skips the preflight and the dry resolve (nothing is fetched) but keeps the build's OWN timeout, not the 5-minute cap — a deck re-render is a render plus a concat of the whole cut |
An unknown variant is a 400 before anything runs. `chaptersOnly` and `preview`
skip the preflight and the dry resolve because neither touches a source.
diff --git a/umtool/docs/clip-bench.md b/umtool/docs/clip-bench.md
@@ -184,6 +184,21 @@ which date it would otherwise use. An empty field goes back to it.
`http(s)`; the message comes back from the writer and lands under the controls,
with what you typed left in the box to fix.
+## On-screen title and subtitle
+
+When the on-screen deck is on (`render.chrome` — see
+[report-video.md](report-video.md#the-on-screen-deck-renderchrome)) the bench
+gets two more fields beside the attribution ones: `onscreen.title` and
+`onscreen.subtitle`. Same save rule — on blur or <kbd>Enter</kbd>, empty
+deletes the key — and each shows the auto line it falls back to as its
+placeholder, so a blank box reads as "using the automatic line" rather than as
+"empty".
+
+Beside them is a live strip of the deck itself at this clip's moment, fed by
+`postMessage` rather than a server round trip: the SAME composition the build
+renders, so a keystroke is seen in its real face and its real text fit before
+anything is saved.
+
## `correction` — a message to the next report pass
The sixth field in that panel is not about this video at all. It records what the
diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md
@@ -180,6 +180,28 @@ edge, about two rows of its log window.
374 s band is 11,460 frames, 348 s of wall clock and 372 MB, on a box already
running something else.
+**Mixed RGB/RGBA frames in one sequence restart the whole filtergraph —
+the on-screen deck's own trap.** HyperFrames writes a frame with nothing
+transparent in it as opaque RGB and every other frame as RGBA. ffmpeg's
+default response to the decoded stream switching kind mid-sequence is to
+REINITIALISE the filtergraph, which drops whatever was buffered and ends the
+output early: measured, a 3.5 s xfade+overlay came out at 2.0 s. Fix: pass
+`-reinit_filter 0` on that input and `format=rgba` straight after it, on the
+deck's input only — the chart band's own chain never mixes frame kinds and is
+untouched.
+
+**The hard-cut concat list has to name its entries by absolute path.** The
+concat demuxer resolves a list entry against the LIST FILE's own directory,
+not the cwd, so a relative `--out` named every segment twice over and the
+first input failed to open. Fixed once, in `concatHardCut`, for every
+hard-cut build — not only a deck one.
+
+**The deck's font copy is strict where the band's is lenient.** Both copy
+fonts in as private families to dodge the `local()` trap above, but a missing
+`render.fontRegular` / `fontBold` throws on the deck instead of falling back
+silently to the browser default, because the deck has no other on-screen text
+to notice a wrong metric by.
+
## 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/docs/report-video.md b/umtool/docs/report-video.md
@@ -315,6 +315,77 @@ A still **never derives** a QR target. A clip's code is derived from the archive
that serves it; a screenshot has no such archive, and a code that resolves to the
wrong place is worse than no code — so only an explicit `citeUrl` draws one.
+## The on-screen deck (`render.chrome`)
+
+Opt-in, and it **replaces** the header, the footer's progress track and the
+corner QR described above — a deck manifest draws none of them, because the
+panel carries the citation itself. The full contract (every setting, the
+schedule document, the choreography) is the pipeline's own README
+([`render.chrome` — the on-screen deck](../report-to-video/README.md#renderchrome--the-on-screen-deck))
+and `plans/onscreen-deck.md`; this sheet covers what umtool reads, writes and
+serves.
+
+Any `clip`, `image` or `card` entry may carry `onscreen: { title?, subtitle? }`
+— nested, because `entry.title` already means the *stream's* title everywhere
+else on this page, the same reason `title`/`date` are their own fields above.
+`updateOnscreen(dir, { [id]: {title?, subtitle?} | null }, { token })` writes a
+whole batch through the same lock / backup / atomic-write / stale-token path
+as every other writer in this file; **one unknown id refuses the whole
+batch**, nothing partial is written. A value normalises to `null` — which
+DELETES the key — the same rule an empty attribution field follows.
+`updateClip` takes `onscreen` too, so a clip-bench save and an On-screen-table
+save are one rule, not two.
+
+`updateChrome(dir, chrome | null, { token })` writes `render.chrome` itself:
+`validateChrome()` first, so a block umtool saves is one the build accepts,
+and `null` removes the key — turning the deck off without touching anything
+else under `render`.
+
+### Preview, before and without a build
+
+`POST /api/report/chrome/preview` composes `out/<variant>/chrome/deck-preview/`
+— never the build's own `chrome/deck/` or its render cache — from `{ project,
+variant?, draft? }`. `draft` is the On-screen table's unsaved rows, id →
+`{title?, subtitle?}` or `null`, applied on top of the saved manifest before a
+schedule is built. The schedule it draws from is the **build's own**
+(`out/<variant>/schedule.json`, real probed durations) when one still lists
+this cut's entries in this order, else an **estimate** from the manifest
+alone (`estimated: true`) — so the section previews before anything has ever
+been built, and a finished build makes the preview's handovers land exactly
+where the render's will.
+
+`GET /api/report/chrome/files/[...path]` serves that preview directory,
+traversal-guarded — the composition's own HTML, loaded in an iframe that is
+**same-origin and unsandboxed** so its `@font-face` rules resolve (a sandboxed
+opaque origin fails every font fetch and falls back silently; see
+[quirks.md](quirks.md)). `GET /api/report/still?project&variant&(clip|at)`
+renders a **true** still, the same Chromium screenshot the pipeline's
+`--still` takes, of what is **saved** — not of the live preview's unsaved
+typing, which is the point of the two existing side by side.
+
+### What a build leaves behind
+
+`GET /api/report/video?project&variant&kind=final|preview` range-serves
+either the deliverable or the short window `--chrome-preview` wrote, through
+`rangeResponse()` (`lib/report/serve.mjs`), shared with the segment route and
+the same `v`-matches-mtime caching rule.
+
+In the build chain (see [build.md](build.md)), the driver's `chromeOnly`
+option is `--chrome-only`, labelled **"re-render on-screen"**: it skips the
+availability preflight and the dry resolve, same as chapters-only and rail
+preview (none of the three touches a source), but keeps the build's own
+timeout rather than their five-minute cap, because a deck re-render is a
+render plus a concat of the whole cut, not a seconds-long retitle. The
+pipeline's other two deck flags, `--no-chrome` and `--chrome-preview`, are
+CLI-only — the driver does not wire them up.
+
+### Naming
+
+The app calls this **"On-screen"** everywhere a person reads it. The manifest
+and the pipeline call it the deck (`render.chrome.layout: "deck"`), but "deck"
+is already the song kind's name in umtool, and one word meaning two things two
+pages apart is how somebody edits the wrong one.
+
## The three-stage window model
A clip's window passes through three different notions of "where the cut is", and
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -202,6 +202,57 @@ window:
handles the reverse case — a clip whose lead-in would drag in seconds of some
*other* audio (a news package playing before the speaker starts).
+### `render.chrome` — the on-screen deck's settings, and per-entry `onscreen`
+
+Opt-in (`render.chrome.engine: "hyperframes"`, `layout: "deck"`) — see
+[The on-screen deck](#renderchrome--the-on-screen-deck) below for what it draws
+and how a build renders it. Every key is optional; `resolveDeck()` fills
+whatever a manifest omits, one level deep:
+
+```jsonc
+"render": {
+ "chrome": {
+ "engine": "hyperframes", "layout": "deck",
+ "deck": {
+ "height": 190, // px, whole number, 120–400
+ "footageScale": 0.82, // 0.5–1; the footage must still fit above the deck
+ "background": "panel", // "panel" (lifted) | "flush"
+ "pip": { "spacing": "even", "size": 6, "activeSize": 12 }, // spacing "even" | "time"; activeSize ≥ size
+ "title": { "size": 54, "maxChars": 48 }, // size 24–96; maxChars 10–120 (the editor's counter, not a refusal)
+ "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
+ }
+ }
+}
+```
+
+`validateChrome()` (`deck.mjs`) is the ONE validator — umtool's writer refuses
+with it and the build refuses with it before a single fetch, so a manifest
+umtool accepted is one the build accepts. It refuses an **unknown** key rather
+than ignoring it (`footageScle` silently meaning the default is the bug this
+catches), and refuses `render.chrome` beside `render.rail` or the legacy
+`render.chromeEngine` — the deck replaces both, it does not layer onto them.
+
+Any `clip`, `image` or `card` entry may carry `onscreen`:
+
+```jsonc
+{ "type": "clip", "id": "c04", …,
+ "onscreen": { "title": "County approves pre-application",
+ "subtitle": "…optional override…" } }
+```
+
+Nested — `onscreen.title`, not `title` — because `entry.title` already means
+the *stream's* title in the header and the chapters. One line each, ≤ 200
+characters, trimmed; an empty value deletes the key, the way the attribution
+fields do. Without it the deck's title is empty (a card falls back to
+`heading`) and the subtitle is auto-built: a clip's channel · title · date (the
+channel only when the cut spans more than one), an image's `title · date`, a
+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.
+
### The `image` entry type
A still: the receipts a clip cannot say out loud — a post, a thread, a DM, a
@@ -288,6 +339,10 @@ uses none of them. `render` holds resolution, fps, fonts, palette and the knobs
sweep's scope and counts. Two further entry `type`s, `scroll` and `chart`, close a
cut off a top-level `ledger[]` — see [The claim rail](#the-claim-rail-renderrail).
+**None of the header, footer, marker or corner QR described above is drawn
+under `render.chrome`'s deck layout.** The deck replaces all of it with one
+persistent panel — see [The on-screen deck](#renderchrome--the-on-screen-deck).
+
## Chrome, not cards
**The ferret-rescue cut has no cards at all** — no title, no chapter breaks, no
@@ -797,6 +852,145 @@ total's `#EDF0EC` fails the categorical lightness and chroma checks *by design*
it is an aggregate, not a categorical peer, so it is encoded by weight and
consumes no palette slot.
+## `render.chrome` — the on-screen deck
+
+Opt-in (`render.chrome = { engine: "hyperframes", layout: "deck", deck: {...} }`,
+every key in [Manifest shape](#renderchrome--the-on-screen-decks-settings-and-per-entry-onscreen));
+absent, nothing here runs and the ffmpeg chrome path — header, footer, corner
+QR — is byte-for-byte unchanged. It **replaces** that chrome rather than
+joining it: a deck manifest draws none of the citation header, the footer's
+node track or the per-clip corner QR, because the deck carries all three
+itself, and it refuses a `render.rail` or the legacy `render.chromeEngine`
+beside it.
+
+A persistent bottom panel, rendered as ONE HyperFrames composition over the
+WHOLE concat — a pip timeline (one subtle, unlabelled pip per segment the deck
+is shown over), a big authored title per clip, an automatic source-and-date
+subtitle, and the clip's QR — while the footage is scaled into a box above it
+(`footageScale`, default 0.82: 1574×886 at (173, 2) for a 1920×1080 frame with
+the default 190 px deck). At every clip boundary the pip travels and the
+title, subtitle and QR hand over to the next clip's, centred on the cut's own
+mid-dissolve; a card plays under the deck when `overCards: "show"`, or the
+deck slides away for its duration when it is `"hide"` (the default) — no text
+handover happens across a hidden segment, because there is nothing on screen
+to animate.
+
+`out/<variant>/schedule.json` (`deckSchedule()`) is the one source of *when*:
+the build writes it from PROBED segment durations and real source metadata;
+`estimateSchedule()` produces the same shape (`estimated: true`) from the
+manifest alone, for a preview that runs before anything has been built.
+`deckChoreography()` turns a schedule into the handover times the
+composition, the preview and a reviewer all read — there is no second
+implementation of the arithmetic to drift.
+
+### Rendering it
+
+`compose-chrome.mjs --region deck` is `--region chart`'s sibling: the same
+CLI, the same render cache, the same vendored GSAP, a different composition
+(`chrome-deck.mjs` instead of the chart band's SVG).
+
+```
+node umtool/report-to-video/compose-chrome.mjs <manifest.json> [--region chart|deck] [--variant sourced|full]
+ [--from <s>] [--duration <s>] [--out <dir>] [--preview]
+ [--still <s> --png <path>]
+ [--render] [--workers 4] [--quality high] [--format png-sequence] [--fps 30]
+```
+
+- **`--preview`** composes into `chrome/deck-preview/` and never renders —
+ umtool's live settings preview, which must never disturb a build's own
+ project directory or its frame cache.
+- **`--still <s> --png <path>`** is the review still: a headless Chromium
+ screenshot of `index.html?still=<s>`, the SAME seek the renderer performs for
+ every frame — about 2 s instead of a render.
+- **`--render`** writes `chrome/deck-frames/frame_%06d.png` plus a `.key` file
+ holding `chromeCacheKey({html, assets, fps, frames, version})`. A render is
+ SKIPPED when the key on disk matches and the frame count on disk equals
+ `frameCount(total, fps)`: a full ~6-minute deck is ~10,800 frames, about
+ 7–8 minutes with the default 4 workers and ~400 MB of PNGs, and an
+ unchanged re-run is about 1 s. The renderer is pinned (`HYPERFRAMES_PKG`,
+ default `hyperframes@0.8.24`; `HYPERFRAMES_BIN` overrides it for a stub, an
+ e2e fixture's), and the version is part of the key — a new renderer is new
+ frames.
+- **`--from <s>` / `--duration <s>`** window a compose to less than the whole
+ cut, used by `--chrome-preview` below. A windowed project and its frames get
+ their own names, `deck-from<s>[-frames]`, so a window never overwrites the
+ full-cut sequence.
+
+`build-video.mjs` drives all of it as one command — schedule, compose, render
+(cached), overlay — with three flags beyond the ones already
+[listed](#driven-from-umtool):
+
+- **`--chrome-only`** — re-lay the deck over the segments already on disk:
+ re-probe, rewrite the schedule, recompose (re-render only if the key
+ changed), re-concat with the overlay, re-mux the chapters. **No segment is
+ rebuilt and nothing is fetched.** The driver labels this "re-render
+ on-screen".
+- **`--no-chrome`** — the deck's framing with no overlay: a fast picture check
+ of the letterboxing, with nothing composed or rendered.
+- **`--chrome-preview <at> <dur>`** — renders only that window of the deck and
+ writes `out/<variant>/<slug>.preview.mp4` of it, from a cached concat when
+ there is one, else built straight from the segments the window touches.
+
+`assertChrome(render.chrome, render)` runs at the top of every build whenever
+`render.chrome` is set at all — not only when the deck is on — so a block that
+is present but malformed (a bad `engine` or `layout`, as well as a bad `deck`)
+is refused before a single fetch, the same guarantee a deck build gets.
+
+**`transition: 0` (hard cuts) now works through the deck.** A hard-cut concat
+to `<slug>.prerail-hardcut.mp4`, then ONE overlay re-encode (`applyChrome`,
+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.
+
+### Two ffmpeg traps that are the deck's alone
+
+- **Mixed RGB/RGBA frames restart the whole filtergraph.** HyperFrames writes
+ a frame with nothing transparent in it as opaque RGB and every other frame
+ as RGBA; ffmpeg's default response to the decoded stream switching kind
+ mid-sequence is to REINITIALISE the filtergraph, which drops whatever was
+ buffered and ends the output early — measured, a 3.5 s xfade+overlay came
+ out at 2.0 s. The deck's input alone gets `-reinit_filter 0` plus
+ `format=rgba` straight after it; the chart band's chain and inputs are
+ untouched.
+- **A relative `--out` broke every hard-cut concat.** The concat demuxer
+ resolves a list entry against the LIST FILE's own directory, not the cwd, so
+ a relative output root named every segment twice over and the first input
+ failed to open. `concatHardCut` now writes every list entry as an absolute
+ path — unchanged for a build that already worked, and not only the deck's.
+
+Both are in [quirks.md](../docs/quirks.md)'s running list.
+
+### Fonts
+
+Copied into the composition's `assets/` as private families `DeckSans` /
+`DeckSansBold` (from `render.fontRegular` / `render.fontBold`), with
+`sans-serif` as the only fallback — the same trap the chart band's `'Band'`
+dodges (a bare `local()` resolves on a desktop browser and silently fails in
+the render browser, shifting every metric). The deck is **strict** where the
+band is lenient, though: a face that cannot be copied is a build error, not a
+quiet fallback, because the deck has no other text on screen to notice a
+substitution happened.
+
+### `verify-build.mjs`'s deck checks, and their limit
+
+Confirms `schedule.json` is a *measured* (not estimated) deck schedule, that
+`chrome/deck-frames` holds exactly `frameCount(schedule.total, fps)` frames,
+and that the finished file's video stream is that many frames long — laid
+with `shortest=1`, so a short sequence shortens the cut silently rather than
+erroring. It **cannot tell whether the overlay was actually laid onto the
+concat**: a `--no-chrome` final passes as long as old frames happen to be on
+disk from an earlier render. `--chrome-only` and `--rail-only` both write over
+the final file while it is still encoding, the way they always have, so a
+check that races one of them is checking a half-written file.
+
+### Driven from umtool
+
+Writers (`updateClip`'s `onscreen`, `updateOnscreen`, `updateChrome`), routes
+(`/api/report/{chrome,onscreen,chrome/preview,chrome/files,still,video}`) and
+the UI (the "On-screen" section, never "deck" — that name is already the song
+kind's) are [report-video.md](../docs/report-video.md#the-on-screen-deck-renderchrome)'s
+to describe; this file stays the pipeline's own contract.
+
## The Archilyzer Media preset (`render.brand`)
The channel's own videos wear its brand; a report cut for anybody else does not.
@@ -966,13 +1160,18 @@ usual reason a naive concat produces a broken or audio-desynced file.
a speaker who does not pause gets the unsnapped cut. Forced alignment against
the transcript would be exact; `silencedetect` is a tenth of the work and
handles the cases that were actually audible.
-- **Only the chart band is a HyperFrames region.** `chromeRegions()` returns one
- entry. The rail and the header are still drawn by the ffmpeg chain, and porting
- them is the rest of the job — the rail needs the ghost/hop convention (a row
- with no clip fades in dimmed under a dashed rule and the amber highlight *hops
- over* it to the next cited row) which the strip builders cannot express. Until
- then `railFilterChain` and `renderFooterAssets` stay; they must not be retired
- on the strength of the band alone.
+- **`chromeRegions()` returns one entry, never two.** The chart band and the
+ deck are each a single HyperFrames region, and a manifest gets at most one of
+ them (`render.chrome` and the legacy `render.chromeEngine` refuse each
+ other). The deck excludes a rail outright (`validateChrome` refuses
+ `render.chrome` beside `render.rail`) and replaces the header and the
+ footer's node track with its own panel; for every manifest that is NOT a
+ deck, the rail and the header are still drawn by the ffmpeg chain, and
+ porting them into a region of their own is the rest of that job — the rail needs the ghost/hop convention (a row with no clip fades
+ in dimmed under a dashed rule and the amber highlight *hops over* it to the
+ next cited row) which the strip builders cannot express. Until then
+ `railFilterChain` and `renderFooterAssets` stay; they must not be retired on
+ the strength of either region alone.
- **`timelineNodes` / `section` / `sectionEnter` are still live.** They lose their
only consumer when the ffmpeg footer goes, not when the band arrives — so they
retire with `renderFooterAssets`, in that same commit.