Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mplans/onscreen-deck.md | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/docs/build.md | 1+
Mumtool/docs/clip-bench.md | 15+++++++++++++++
Mumtool/docs/quirks.md | 22++++++++++++++++++++++
Mumtool/docs/report-video.md | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/README.md | 213++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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.