# Quirks Things that cost time to find out. Each one is here because it was discovered by getting it wrong, and none of them is guessable from the code. ## Fetching source clips **yt-dlp picks VP9 + Opus at these heights unless you pin the format.** Since `--force-keyframes-at-cuts` re-encodes, that means `libvpx-vp9`: 27 seconds to cut a 5-second clip. It also writes `.webm` and appends that to `-o`, so the file you asked for is not the file on disk. Pin `bv*[vcodec^=avc1][height<=N]+ba[acodec^=mp4a]` and `--merge-output-format mp4`. **`--ignore-config` is not optional.** The operator's own yt-dlp config redirects output and attaches thumbnail and metadata post-processors. Without it the clips land somewhere else entirely and the build reports success having produced nothing where it was looking. **yt-dlp exit 101 is success.** It is the clean early stop (`break-on-existing`, `--max-downloads`). Treat it as success, as the rest of the repo does. **Rumble HLS needs `-extension_picky 0`, as a RETRY and never as a default.** Rumble serves HLS whose segments are named `.tar`, which ffmpeg 8 rejects outright ("URL … is not in allowed_segment_extensions"), killing the fetch with exit 183 — and Rumble ships no progressive fallback, so every Rumble clip is unbuildable without it. But the option lives on the **HLS demuxer**: pass it against a progressive URL (YouTube's googlevideo mp4) and ffmpeg aborts with "Option extension_picky not found". Adding it unconditionally trades a Rumble failure for a YouTube one. The editor's window fetch keys it on the URL instead: a rumble.com URL gets it on the FIRST try (a retry loads the Rumble page again, and Cloudflare 403s a share of page loads), any other URL only as the retry. **A Rumble video gets Rumble's args from its URL, not its channel.** A channel with no platform of its own (community-notes) holds Rumble videos; fetched with that channel's args they went without `--impersonate` and Cloudflare 403'd them. **A 403 backs the platform off in a batch.** `fetch-via-editor.mjs --all` (the editor's `fetch-windows` job) treats one 403 as that clip's failure — a removed Rumble page answers 403 too — but two in a row back the platform off and stop the job, as a 429 does at once. The windows are 30–45 s apart; 20 s drew YouTube 403s. Rumble's are 120–180 s apart, across jobs too: its Cloudflare puts the whole IP behind a JS challenge ("Just a moment…", 403 to everything) after a handful of requests in a few minutes, and it lifts after a few quiet minutes. **`--force-keyframes-at-cuts` matters because the clip IS the citation.** Without it the cut snaps to the nearest preceding keyframe, which can be seconds early. Fine for scrubbing; not fine when someone is checking your quote. ## Cutting **The silence threshold has to be relative to the clip, not absolute.** These are game streams: the gaps between words are full of game audio and music. Measured on a typical clip — mean volume −21 dB, **0** silences found at −32 dB, **25** at −26 dB. Measure with `volumedetect` first and cut a few dB under the clip's own mean. **Snapping only proves the edges are quiet.** It finds gaps in audio, which is usually a word boundary and is not guaranteed to be. A speaker who does not pause gets the unsnapped cut. **ASR cue boundaries are line-wrap boundaries, not sentence boundaries.** They land mid-sentence routinely and mid-word often. That is the whole reason `resolve-windows.mjs` exists — and the reason a clip bench that shows you the cues is worth more than one that only shows you a waveform. **Some uploads carry no punctuation at all.** Sentence widening then has nothing to find and silently does nothing. The clip bench says so explicitly rather than leaving you to wonder why "extend to sentence end" is inert; set the edge by ear and lock it. **`xfade` places a segment by its picture's length, `acrossfade` by its sound's — and they are not the same length.** An encoded segment's audio routinely runs a few to ~20 ms shorter or longer than its video (AAC frames, priming, the encoder's own rounding). Chained, the two filters each add their own lengths, so the sound drifts away from the picture clip by clip and nothing errors: 0.30 s early by the end of a 17-clip cut, 2.0 s on one with cards. Every frame and every sample is there; only the sync is wrong, and it is worst at the end, where nobody spot-checks. Pin each input's sound to its picture's length before the join (`apad=whole_dur=,atrim=end=,asetpts=PTS-STARTPTS`, `d` the length the xfade offsets use); `xfadeGraph` does (6771cfb8), and `av-sync.test.mjs` shows the unpinned graph drifting so the test is known to discriminate. ## Manifests **`resolve-windows.mjs` is a fixed point, and that was not free.** The manifest stores times at 2 dp, so a value read back can sit a hair below the cue end it came from — which lands the end lookup on the *previous* cue, runs the forward search on to the *next* sentence, and grows the same clip a little on every run. Hence `EPS` in the lookup and the 0.05 s deadband on applying a change. **Anything that writes a window must round to 2 dp**, or it reintroduces exactly this bug. **`writeJsonAtomic` would vandalise a manifest.** It writes `JSON.stringify(v, null, 1)`; `resolve-windows.mjs` writes `null, 2` plus a trailing newline. Saving one window edit through the default writer reformats 600 lines and makes the diff unreadable. Manifest writes pass `{space: 2, newline: true}`. **`lock: true` is the norm, not the exception.** Measured across the six real manifests: ferret-rescue locks 1 of 10, every other manifest locks **100%**. A human-chosen window usually *is* the truth, and widening it would undo an editorial decision — cutting a quote short is a choice, and a single ASR cue often carries a whole paragraph. So `lock` is a first-class explained control in the bench, not an advanced toggle, and moving an edge to somewhere `widen()` would not produce offers to set the matching lock. **`siteOrigin` is unvalidated and has already shipped broken twice.** `quartering-employee-count` has none — 19 QR codes encoding `undefined/?v=…` — and `ferret-rescue` has `http://localhost:3000`, a shipped video whose codes resolve to nothing on anyone's phone. `umtool check` exists largely for this. **A clip may name its own `channel`.** The same streamer's VODs are mirrored across several archived channels, and cue files are keyed by channel, so one manifest-wide slug cannot find them all. **Building the Rumble site-id -> directory-slug map takes three seconds.** Every `transcript.cues.json` carries its site id in the first ~400 bytes, so you never have to parse the whole file — read the head of each and index it. On 7,925 Rumble videos that is 3.6 s, and it is what turns a report's share links (which carry the *site* id) into manifest `video` fields (which must be the *slug*): ```py for d in os.listdir("."): # transcripts/channels//data head = open(f"{d}/transcript.cues.json", "rb").read(400).decode("utf8", "replace") m = re.search(r'"id"\s*:\s*"([^"]+)"', head) if m: out[m.group(1)] = d # site id -> directory slug ``` **Rumble ids: the manifest's `video` must be the local directory slug.** Cue files live under the URL slug, not the MCP video id. A manifest using the id finds nothing — and finds it twenty minutes into a build, unless `umtool check` ran first. ## Rendering **ImageMagick's `-size` leaks into Pango.** It applies to the *next* image operation, so a stale `-size` silently changes the text raster. **drawtext does not wrap, and commas are structural in a filtergraph.** Wrap to a character budget, write the text to a file and use `textfile=`, so nothing needs shell or filter escaping. Single-quote any expression containing a comma. **Stream titles need emoji and `!command` suffixes stripped** or they render as tofu in the attribution line. **Segments are encoded to identical parameters on purpose**, so the final concat is a stream copy. Mismatched streams are the usual reason a naive concat produces a broken or audio-desynced file. **A QR must be fully opaque and must keep its quiet zone.** A translucent QR will not scan, and the white border is part of the symbol, not decoration. **A long URL cannot be a small QR, and the failure is silent.** The employee-count share link carries 23 channel filters and is ~1.4 k characters: that is a version-40 symbol, **177 modules**, which inside a 132 px tile is 0.7 px per module. It renders, it looks like a QR, and nothing on Earth scans it. Keep a short `provenance.qrLink` for card tiles (~200 chars → 63 modules → 2.1 px per module, which `zbarimg` reads off the raster) and **scan a rendered frame** rather than trusting that a code appeared. **Resize a QR with `-filter point`.** Any resampling filter blurs the module edges, and a blurred QR stops scanning. The geometry has to be decided before the code is generated, because the tile it sits in is sized in the rail's arithmetic. **An opaque curtain parks OUTSIDE the window it covers, which may be on top of something.** The rail's log curtain ends one window-height below the log — which was empty ground until the QR tile moved into the rail's foot. The fix is ordering, not geometry: the tile overlays after the curtain. **ffmetadata is line-based and `=`, `;`, `#`, `\` are structural.** A chapter title carrying any of them has to be escaped or the file silently mis-parses. ## Chrome rendered in a browser (`chromeEngine: "hyperframes"`) **A bare `local()` `@font-face` silently falls back in the render browser.** It resolves fine on a desktop, so a snapshot looks right and every metric in the rendered band is wrong. Copy the real `.ttf` in beside the composition and `url()` it. **Naming a real family anywhere in the fallback stack makes the compiler go and FETCH it from Google Fonts.** That is a network dependency at render time *and* a different cut of the face from the local file the ffmpeg cards use — so the two halves of the same frame disagree about metrics. Give the embedded face a private family name (`'Band'`) and let the stack fall through to `sans-serif`. **Animate the reveal with ONE clip-path, not a `stroke-dashoffset` per path.** The obvious build offsets each series' dash. It looks right for the strokes and wrong for everything else: a filled region (a gap band between two series) has no stroke to offset, so it appears whole the instant it fades in and the chart shows an answer the playhead has not reached. **PNG regions overlay BEFORE the rail chain, not after.** The rail chain ends in `format=yuv420p`, and overlaying an alpha sequence onto yuv420p is the same alpha-subsampling trap the rail already documents, one layer later. `format=yuv444` and `shortest=1` on every overlay; one `format=yuv420p` at the very end. **A finite image sequence needs no `-t`.** Unlike `-loop 1` it ends by itself, so the deadlock five chained loops hit cannot happen — but `shortest=1` is still required, because a secondary longer than the main extends the output. **Reserving the band's height is not the same as `render.footerHeight`.** Three renderers were reading the manifest's 100 while the band owned 200, so the closing chart drew its footnotes underneath it and the ledger scroll cropped 100 px short. One `reservedFooterHeight()` helper now serves all of them — and `railGeometry` was the fourth, found later: the rail column ran 100 px past the band's own top edge, about two rows of its log window. **Cost, measured.** 1500×200 alpha, 30 fps: ~30 ms/frame, ~32 KB/frame. The full 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. **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 ` 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. **A backslash in a page's script is a template literal's first.** The page modules write their runtime script inside a JS template literal, where `\s` is not an escape and becomes a plain `s`: the popup's `replace(/\s+$/, "")` reached the page as `replace(/s+$/, "")` and stripped trailing letters s, not spaces, in the one case it runs (a clamped card whose paragraphs were dropped after one that fit exactly), so a quoted word lost its last letter. Both the popup's and the feed's pages write `\\s` in the module; read in the module a single backslash looks right, and only the composed page shows the wrong one, which is why `chrome-posts.test.mjs` reads the regex off a composed page. **Switching the posts layout moves the footage, so it is a rebuild, not a re-render.** The feed frames every footage segment into its own box when the segment is built; the popup frames into the deck's. `--chrome-only` lays chrome over the segments on disk, so over segments framed for the other layout it would draw the column over footage (or leave a gap where it expected some). Each framed segment's `.cut.json` records `framing: { layout, box }`, and `--chrome-only` / `--chrome-preview` refuse, by name, any segment whose box is not the cut's; a record-less segment was built for the deck's box. A normal build with `--skip-fetch` reframes from the cached windows. **The framing record does not cover `overCards`, and full-frame segments are never framed.** A card built under `overCards: "hide"` is full frame and writes no framing record; switch `overCards` to `"show"` and `framingProblems` reads the missing record as the deck's box, so `--chrome-only` lets it through and lays the deck (and the feed) over a card that fills the frame. The other way, a card framed under `"show"` is no longer checked once `"hide"` makes it full frame, and keeps its small box with nothing drawn around it. Either toggle needs a normal build. Separately, `scroll`, `chart` and `ledger` segments are never framed: with `overCards: "show"` the deck covers their bottom 190 px, and under the posts feed the 600 px column also covers their right third for the whole segment. **The build-trace check reads a template literal nested in another's `${}` as the end of the string.** `scripts/next-build-trace.test.mjs` scans each module for path and fs calls on `import.meta.url`-derived values. One `` `${a} (${ok ? `x ${b}` : `y`})` `` in build-video threw its string tracking out of step, and it then read the rest of the file as one call reaching the `import.meta.url` of the CLI guard: 69 false findings. Hoist the inner template to a const; nothing at run time changes. **A whole-cut overlay is laid like the deck's, whatever it draws.** The feed's column is a second full-length sequence; it takes the deck's `-reinit_filter 0`, `format=rgba` and `shortest=1` (its frames mix RGB and RGBA like every HyperFrames sequence), not the posts windows' `-itsoffset` and `eof_action=pass`, and it must be exactly as many frames as the deck's. **`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=` 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. verify-build's freeze check samples only between the hold's start (or the move's landing) and the dissolve, and skips a hold with under three frames of still picture there, saying so. **A footage move placed BEFORE the hold never reaches the held frames.** The move's clock is `perspective`'s `in`, which counts the frames that reach it. Before `tpad` that is the clip's own frames only: a first post that appears inside the hold (`posts.seconds: 2` with the 2.5 s hold puts it exactly at the clip's last frame) never moved the footage, and one in the clip's last half-second froze part-way, while umtool's preview showed it moving. The move runs after `tpad`, so its clock counts the clones and the frozen frame glides too. **`tpad` holds whole frames; `apad` holds exact seconds.** `stop_duration=2.5` at 25 fps clones 63 frames (2.52 s) while `apad=pad_dur=2.5` adds exactly 2.5 s, and the concat filter pads the short stream to the long one, so each held clip in a hard cut grew by up to half a frame and several of them failed the length check. `postHolds` rounds a hold to whole frames before either filter sees it. **A coloured `fade` converts the whole hard cut to RGB.** `fade=t=out:…:color=` accepts RGB formats only (a fade to black also takes YUV), so ffmpeg inserts a yuv420p→rgb24 scale before it -- and the concat filter, which needs every segment in one format, then negotiates EVERY other segment to rgb24 too. The cut's untouched frames came out different (framemd5) from the same graph without the fade. The end fade is a `geq` blend toward bg's limited-range BT.601 Y′CbCr instead (`#12101a` is 31/132/128, what `pad` wrote into the segments), enabled only from its first frame; `geq` truncates, so each plane adds 0.5 to round. **`afade` out writes digital silence after its fade, and copies every sample before it.** That makes it the mute for `muteFrom`: samples before the fade are the clip's own bit for bit (A/V sync cannot move), and samples after it are zeros, not a quiet signal. A fade that ENDS at the mute point keeps a sound that starts there out entirely. **A label fitted to a length is measured as ink, not as a box.** CSS `letter-spacing` is added after the LAST letter too, and a box's length includes each end glyph's side bearing, so sizing the deck's QR host by its element's length leaves it short of the code by the trailing tracking and the bearings. `fitHost` measures the string's ink in the loaded face with canvas `measureText` (`actualBoundingBoxLeft + actualBoundingBoxRight`), adds the tracking between letters only, scales the font size (everything in it is in em) and indents the first bearing away. Measured on the ferret cut: ink rows 31–180, the code's 31–180. **`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)` into an asset URL that `fileURLToPath` refuses at module load ("Received an instance of URL"), failing `next build` while it collects page data. So build-video reaches the page modules only through a dynamic import of compose-chrome, and the posts windows' frame arithmetic it needs (`snapWindow`) lives in `deck.mjs`. This is about build-video's import chain, not a wall around the page modules: umtool's preview helper (`lib/report/onscreen.mjs`) imports compose-chrome statically, as it did before posts, and its routes build. A capped umtool build is the gate that catches it — the unit tests run in plain Node and pass either way. **A teaser's page module is reached by a dynamic import from compose-chrome too.** `chrome-teaser.mjs` carries the display face as `new URL(…, import.meta.url)`, the same kind of asset URL as the deck's GSAP. compose-chrome is imported statically by umtool's preview helper, so it loads the teaser page only inside the `teaser` region's branch; nothing umtool bundles evaluates that URL unless a teaser is composed. The face's file name has brackets (`Archivo[wdth,wght].ttf`), so the copy in the project is `assets/TeaserDisplay.ttf`, and the page never puts the vendored name in a URL. **`random()` in an ffmpeg expression advances only where it is evaluated.** Its state is a variable that each call updates, and `if()` evaluates one branch: a noise term gated to a hit's span draws its numbers only inside that span, so adding or removing one hit changes the noise of every hit after it. The teaser's noise is a hash of the sample number (`fract(sin(n·12.9898+78.233)·43758.5453)`), the same whatever else is in the graph, which is also what lets the onset test difference the graph with and without one hit. **`alimiter` auto-levels and delays by default.** `level` is on by default and normalises the output upward toward the limit, so a quiet card comes out loud. `latency` is off by default, which leaves the output late by the lookahead (the attack). The teaser's limiter is `level=0:latency=1`: measured, every hit's onset is then on its pop's sample. **Sub-bass barely registers in LUFS.** K-weighting rolls off below ~100 Hz, so a boom at 40 Hz that peaks at −6 dBFS measures far quieter than dialogue at the same peak. The teaser's hits carry an octave for body and a band-passed noise punch, and the limiter takes their transients, so the card can sit within about 2 LU of the cut and still peak at −6 dBFS. **`geq` costs about a quarter of a second per 1080p frame.** It evaluates its expressions per pixel through the expression parser: 90 frames of a three-plane blend took 23 s wall on eight threads, where `fade` out to black took under a second. A fade to BLACK stays in yuv420p (luma to 16, chroma to 128, for any studio-range format); only `fade=…:color=` needs RGB. So a teaser's `dip` is `fade` with an `enable` window, and the end fade (toward bg, a colour) stays a `geq` over its one second. `fade` cannot start before its stream does, so a `--chrome-preview` window that opens inside a dip takes the `geq` form. **`-ac 1` sums a stereo graph's two channels at −3 dB each.** Reading the teaser's sound downmixed to mono measures its −6 dBFS ceiling as 0.707; read channel 0 of a stereo decode to check the limiter. ## Rail strips and rolling counters **A slab that slides moves text that did not change.** The tally used to be four rows walked by one `crop`, so a coffee figure changing dragged "The Quartering" up the screen with it. Static parts (swatch, company label) belong in the chrome; only the cell that changes may move. **A crop can only walk, so the DIRECTION of a roll is a property of the strip's layout.** Lay the pair as `[old, new]` and the window walks down (content moves up, a rise); lay it as `[new, old]` and it walks up (a fall). There is no "direction" term in the ramp at all. **Two rows of a rolling strip must hold identical content wherever the crop steps.** Between transitions the window repositions instantly to the next pair's first row; that step is invisible only because the row it leaves and the row it arrives at are the same picture. It also means a delta chip has to ride on both rows — blanking it at the step is what makes an invisible reposition visible. **A manifest value beats a default, which is obvious and still cost twenty minutes.** Changing `rowHeight`'s default in `railGeometry` changed nothing, because the manifest set it explicitly. Print the geometry rather than reasoning about which value won. ## The tool itself **`ffmpeg` inside a `while read` loop eats the loop's stdin** and the loop stops early, silently, having "passed". Use `ffmpeg -nostdin`. (`ffprobe` has no such flag and errors if given one.) **`node -e console.log()` emits ANSI escapes on a TTY**, which corrupt ffmpeg filtergraphs and shell tests silently. **`pnpm lint` in `editor/` always fails** — there is no eslint config there. Use `pnpm exec tsc --noEmit`. **A value import that drags `node:fs` into a client component 500s every page** and passes typecheck. `pnpm build`, not just `tsc --noEmit`, is what catches it — which is why the registry's *types* live in `lib/project-types.ts`, separate from the `.mjs` that reads the disk. **e2e is serialized machine-wide.** A "waiting for the e2e queue" banner is normal, not a hang; the serial suite is long. A run that wins the lock but finds its ports bound aborts and names the offending pid.