Archilyzer · Source

archilyzer

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

commit 35636fb6d9a4f78c94cdb2996436b17ada5f665b
parent d101f2885c62ac5ce596a99775ea839a204ede64
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 20:46:24 -0400

report-to-video: the dip's picture is ffmpeg's fade out to black (yuv420p native, slice-threaded, about 25x faster than geq at 1080p), the geq blend kept for a preview window that starts inside the fade; the README's dip section

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/report-to-video/README.md | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mumtool/report-to-video/build-video.mjs | 35++++++++++++++++++++++-------------
Mumtool/report-to-video/dip.test.mjs | 27+++++++++++++++++++++------
3 files changed, 106 insertions(+), 21 deletions(-)

diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -440,7 +440,8 @@ falls on the teaser. "February 2027"], "tail": "?", // optional: appended to the LAST line, fades in on its own "beat": 0.7, // optional, default 0.7: seconds from one pop to the next - "hits": true } // optional, default true: false makes the card silent + "hits": true, // optional, default true: false makes the card silent + "dip": { "fade": 1.2, "black": 0.6 } } // optional: go to black before it (below) ``` - **`lines`** — 1 to 5, each one line of at most 80 characters: a string, or @@ -525,6 +526,63 @@ made from the manifest, nothing fetched) while still rebuilding no clip. `verify-build` checks each teaser's frame count and that its segment was encoded from the frames on disk. +#### `dip` — to black before the teaser, and up out of it + +```jsonc +"dip": { "fade": 1.2, "black": 0.6 } // fade 0.3–4 s, black 0–3 s; both required +``` + +The cut goes to black before the teaser and the teaser comes up out of it, like +a trailer. Only a teaser takes a `dip` (anywhere else it is refused, and so is a +dip on the first entry, which has nothing before it to fade). + +1. **The fade — the whole frame.** Over the previous segment's last `fade` + seconds, ending on its last frame (where the dissolve into the teaser ends), + everything on screen eases to black: the footage, the deck, the feed column, + popup posts, a rail. Its sound fades to silence over the same frames. The + picture is ffmpeg's `fade` out to black laid on the FINISHED picture, after + every overlay (`dipWindows`, `dipVideoFilter` in `build-video.mjs`), and + only inside the dip's window: every other frame passes untouched. A fade to + BLACK stays in the stream's yuv420p (luma 16, chroma 128) — only a coloured + fade goes through RGB, which is why the end fade is a `geq` — and it is + about 25× faster than the same blend in `geq` at 1080p (a preview window + that starts inside the fade uses the `geq` form). The sound is the end + fade's `afade` on that segment's join. Both are made where the cut is joined, so `--chrome-only` changes a + dip without re-encoding a clip. +2. **The black.** The blend stays fully black from that last frame for + `black` seconds more. Under it, the teaser's own first `transition + black` + seconds — its **lead** (`teaserLead`) — are black and silent but for the + riser, so the dissolve into it is black on black and nothing is held: the + teaser segment is simply longer by the lead (`teaserSeconds(entry, D)`), + and the schedule, the chapters and the pips count it as they count any + segment's length. The deck and the feed are gone in an instant on the + faded segment's last frame (`dipHideAt`), under the black, instead of + sliding away over the dissolve. +3. **The rise.** The card opens with the letterbox already closed and dark; a + black veil over the ground and the light leak (under the words) starts + lifting as the black ends, slowly, to 45 % by the first line's impact + 0.35 s later, then blooms away over 0.3 s (`DIP_RISE`), so the first hit is + the moment the light comes on. The first line's slam starts 0.15 s after + the black, not 0.55 s (there is no dissolve to land after), and every later + time follows it. Under the black, a **riser** (`DIP_RISER`, synthesised like + the hits): a sub climbing 30 → 55 Hz and a band-passed noise swell, rising + for up to a second into the first hit and released 40 ms after it, about + 6 dB under the hit. + +With a dip, `seconds` is the CARD's — from where the light comes up — and the +segment is the lead plus it; left out, the card is what its beats need from +there (0.4 s less than without a dip). The ferret finale at `beat: 1.05`: the +card 6.8 s, the segment 7.7 s at `{0.9, 0.4}`, 7.9 s at `{1.2, 0.6}`, 8.3 s at +`{1.6, 1}`. With `{1.2, 0.6}` after c20 (the cut's 0.5 s dissolve): c20 fades +over its last 1.2 s to black on its last frame, black holds 0.6 s more, the +veil starts lifting 1.1 s into the teaser and its first impact is 1.45 s in. + +The lead counts the cut's transition AS BUILT: a `--no-xfade` build composes a +teaser whose lead is the black alone (`buildTeaserSegment` and compose-chrome +take the build's transition; compose-chrome's CLI uses the manifest's). A +teaser without `dip` composes the page, the sound and the segment key it +always did, and a cut without one writes every graph it did. + **umtool** shows a teaser as a card row named by its lines (the report page, the On-screen table, the timeline strip). Editing its lines there is not built: it needs a writer (`updateTeaser(dir, id, {lines, tail, seconds, beat, hits}, @@ -1312,7 +1370,10 @@ with or without the deck, on crossfades and hard cuts. `endFade: 5` still ends on bg and in silence together. - **The hard-cut record names a mute and a fade** (`"mute"`, `"fade"` on a `# join` line, only when there is one), so a cached prerail made without them, - or with other values, is never reused. + or with other values, is never reused. A teaser's `dip` is recorded the same + way (`"dip"`, on the segment before it). +- **A teaser's `dip`** is a third edit made here: its sound on the segment + before the teaser, its picture after every overlay (the teaser section above). ### Two ffmpeg traps that are the deck's alone diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -2638,7 +2638,7 @@ export async function cutJoins({ schedule = null, entries, segments, render }) { * * The fade's frames are the end fade's (`endFadeFrames`: clamped to the * segment), so the picture and the sound -- `endFadeAudioFilter` on the same - * join -- end together. The teaser after it starts its dissolve inside the + * join -- end together; the picture is `dipVideoFilter`'s. The teaser after it starts its dissolve inside the * fade, black (its lead, `teaserLead`), and stays black past `until`. * * @returns {Array<{ segment: number, s: number, last: number, until: number }> | null} @@ -2659,23 +2659,32 @@ export function dipWindows(joins, durs, D, fps) { /** * The dips as one filter chain on the FINISHED picture -- after every overlay * (deck, feed, posts, rail), so the whole frame goes to black, not the footage - * under a lit panel. `geq` toward Y′CbCr black (16/128/128) in the stream's - * own yuv420p, as the end fade blends toward bg (not `fade`, whose coloured - * form converts to RGB), and only inside each window (`enable`): every other - * frame passes untouched. `shift` is the second the stream's own clock - * starts at in the cut's (a preview's window). - * - * @returns {string|null} a `geq,…` chain, or null with no dips + * under a lit panel. `fade` out to BLACK, which ffmpeg does in the stream's + * own yuv420p (luma to 16, chroma to 128; only a COLOURED fade needs RGB, the + * end fade's reason for `geq`), slice-threaded -- about 25× faster than the + * same blend in `geq` at 1080p. It runs from frame `s` (time s/fps) over + * (last − s) frames, so `last` is black, and once done it writes black; its + * `enable` window is (s, until) by half a frame each side, so every frame + * before the fade and from `until` on passes untouched. `shift` is the second + * the stream's own clock starts at in the cut's (a preview's window); a + * window whose fade began before that is the same blend in `geq`. + * + * @returns {string|null} a `fade,…` chain, or null with no dips */ export function dipVideoFilter(windows, fps, shift = 0) { if (!windows?.length) return null; + const n6 = (v) => (Math.round(v * 1e6) / 1e6).toFixed(6).replace(/\.?0+$/, "") || "0"; return windows.map((w) => { - const st = exprNum(w.s / fps - shift); - const k = `clip((T-${st})/${exprNum((w.last - w.s) / fps)},0,1)`; + const st = w.s / fps - shift; + const d = (w.last - w.s) / fps; + const on = `enable='between(t,${n6((w.s + 0.5) / fps - shift)},${n6((w.until - 0.5) / fps - shift)})'`; + if (st >= 0) return `fade=t=out:st=${n6(st)}:d=${n6(d)}:${on}`; + // A preview that starts inside the fade: `fade` cannot start before its + // stream does, so the same blend in `geq` (a few seconds of preview, where + // its cost does not matter). + const k = `clip((T-(${n6(st)}))/${n6(d)},0,1)`; const plane = (p, c) => `'${p}(X,Y)+(${c}-${p}(X,Y))*${k}+0.5'`; - const a = exprNum((w.s + 0.5) / fps - shift); - const b = exprNum((w.until - 0.5) / fps - shift); - return `geq=lum=${plane("lum", 16)}:cb=${plane("cb", 128)}:cr=${plane("cr", 128)}:enable='between(t,${a},${b})'`; + return `geq=lum=${plane("lum", 16)}:cb=${plane("cb", 128)}:cr=${plane("cr", 128)}:${on}`; }).join(","); } diff --git a/umtool/report-to-video/dip.test.mjs b/umtool/report-to-video/dip.test.mjs @@ -273,7 +273,7 @@ test("the joins: the dip's sound on the segment before the teaser; its record na assert.match(concatRecordText(["a.mp4", "b.mp4"], joins), /# join 0 \{"hold":0,"move":null,"dip":\{"seconds":1,"lastFrame":89,"black":0\.5\}\}/); }); -test("dipWindows and dipVideoFilter: the cut's frames, a yuv blend to black after every overlay; none without a dip", () => { +test("dipWindows and dipVideoFilter: the cut's frames, a fade to black in yuv after every overlay; none without a dip", () => { const joins = withCutEdits(null, 2, { dips: new Map([[0, { seconds: 1, lastFrame: 89, black: 0.5 }]]) }); // a is 3 s (frames 0–89), the teaser starts at 2.5 s: frames 59 (untouched) → 89 (black), black to 104. assert.deepEqual(dipWindows(joins, [3, 2.5], 0.5, 30), [{ segment: 0, s: 59, last: 89, until: 105 }]); @@ -283,12 +283,15 @@ test("dipWindows and dipVideoFilter: the cut's frames, a yuv blend to black afte assert.equal(dipWindows(null, [3], 0.5, 30), null); assert.equal(dipWindows([{ hold: 1, move: null }], [3], 0.5, 30), null); const w = dipWindows(joins, [3, 2.5], 0.5, 30); - const k = "clip((T-1.9667)/1,0,1)"; - assert.equal(dipVideoFilter(w, 30), - `geq=lum='lum(X,Y)+(16-lum(X,Y))*${k}+0.5':cb='cb(X,Y)+(128-cb(X,Y))*${k}+0.5':cr='cr(X,Y)+(128-cr(X,Y))*${k}+0.5'` + - ":enable='between(t,1.9833,3.4833)'"); + // `fade` to black, in the stream's own yuv420p: from frame 59 over 30 frames, on from 59.5 to 104.5. + assert.equal(dipVideoFilter(w, 30), "fade=t=out:st=1.966667:d=1:enable='between(t,1.983333,3.483333)'"); // In a preview window's clock. - assert.match(dipVideoFilter(w, 30, 1.5), /clip\(\(T-0\.4667\)\/1,0,1\).*enable='between\(t,0\.4833,1\.9833\)'$/); + assert.equal(dipVideoFilter(w, 30, 1.5), "fade=t=out:st=0.466667:d=1:enable='between(t,0.483333,1.983333)'"); + // A preview that starts inside the fade: the same blend in geq, from where it already is. + const k = "clip((T-(-0.533333))/1,0,1)"; + assert.equal(dipVideoFilter(w, 30, 2.5), + `geq=lum='lum(X,Y)+(16-lum(X,Y))*${k}+0.5':cb='cb(X,Y)+(128-cb(X,Y))*${k}+0.5':cr='cr(X,Y)+(128-cr(X,Y))*${k}+0.5'` + + ":enable='between(t,-0.516667,0.983333)'"); assert.equal(dipVideoFilter(null, 30), null); assert.deepEqual(dipParts("[x]", null, 30), { parts: [], label: "[x]" }); }); @@ -480,6 +483,18 @@ test("ffmpeg: the hard cut and the overlay pass over it -- black across the whol assert.ok(lumaAt(frame(30), 160, 160) > 200, "the stand-in deck is lit before the dip"); for (let f = 89; f < 105; f += 1) assert.equal(darkness(frame(f)).worst, 0, `overlay frame ${f}`); assert.ok(lumaAt(frame(110), 160, 160) > 200, "and lit after it (a real deck has hidden by then)"); + // A preview window starting inside the fade (2.5 s in, the geq form): the same pictures as the cut's. + const pv = applyChromeArgs(base, "-", R, { regions, outLabel: "[hfout]" }, { start: 2.5, dur: 1.5 }, dips); + assert.match(pv[pv.indexOf("-filter_complex") + 1], /\[vout\]geq=/); + const pp = ff([...pv.slice(4, pv.indexOf("-filter_complex")), "-filter_complex", pv[pv.indexOf("-filter_complex") + 1], + "-map", "[vdip]", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]); + const pframe = (f) => pp.subarray((f - 75) * size, (f - 74) * size); + assert.equal(pp.length / size, 45); + for (const [x, y] of [[160, 160], [290, 70], [100, 60]]) { + assert.ok(Math.abs(lumaAt(pframe(80), x, y) - lumaAt(frame(80), x, y)) <= 1, `preview mid-fade at ${x},${y}`); + } + for (let f = 89; f < 105; f += 1) assert.equal(darkness(pframe(f)).worst, 0, `preview frame ${f}`); + assert.ok(pframe(105).equals(frame(105))); } finally { rmSync(dir, { recursive: true, force: true }); }