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:
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 });
}