commit c2ccbfe8364587716a9d11a6ecdbb55747796ef2
parent 35636fb6d9a4f78c94cdb2996436b17ada5f665b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 20:53:22 -0400
report-to-video: the join into a dipped teaser holds the outgoing segment through the overlap (xfade custom expr A, acrossfade nofade both sides) -- a dissolve blended the footage with the teaser's black lead and darkened it faster than the deck and feed over it; tests that footage and overlays fade in step
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 35 insertions(+), 6 deletions(-)
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -549,13 +549,19 @@ dip on the first entry, which has nothing before it to fade).
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.
+ The crossfade into the teaser does not dissolve: over the overlap the
+ outgoing segment plays untouched (`xfade=transition=custom:expr='A'`, and
+ `acrossfade` with `nofade` on both sides) and the teaser takes over at its
+ end, under the black. A dissolve there blended the footage with the
+ teaser's black lead and darkened it faster than the deck and the feed,
+ which only the dip fades — the panels were left lit over a darker picture.
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
+ riser, so the teaser takes over in 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 crossfade's overlap included, as everywhere). 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
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -2734,8 +2734,16 @@ export function xfadeGraph(durs, D, joins = null, render = null) {
let acc = durs[0];
for (let i = 1; i < durs.length; i += 1) {
const off = acc - D;
- parts.push(`${vlab}${ins[i].v}xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`);
- parts.push(`${alab}${ins[i].a}acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`);
+ // Into a teaser that dips, the outgoing segment plays the whole overlap
+ // untouched -- picture (`expr='A'`) and sound (`nofade`) -- and the
+ // teaser takes over at its end, under the dip's black. A dissolve there
+ // would blend the footage with the teaser's black lead and darken it
+ // faster than the deck and feed drawn over it, which only the dip fades.
+ const dip = !!joins?.[i - 1]?.dip;
+ const vx = dip ? "transition=custom:expr='A'" : "transition=fade";
+ const ax = dip ? "c1=nofade:c2=nofade" : "c1=tri:c2=tri";
+ parts.push(`${vlab}${ins[i].v}xfade=${vx}:duration=${D}:offset=${off.toFixed(3)}[v${i}]`);
+ parts.push(`${alab}${ins[i].a}acrossfade=d=${D}:${ax}[a${i}]`);
vlab = `[v${i}]`;
alab = `[a${i}]`;
acc = acc + durs[i] - D;
diff --git a/umtool/report-to-video/dip.test.mjs b/umtool/report-to-video/dip.test.mjs
@@ -312,6 +312,12 @@ test("every concat path lays the dips last, and writes the graph it always did w
const dippedArgs = xfadeConcatArgs({ segments: segs, durs: [3, 2.5], render: RENDER, outPath: "o.mp4", chrome, joins });
assert.ok(fc(dippedArgs).endsWith(`format=yuv420p[vout];[vout]${dipVideoFilter(dips, 30)}[vdip]`));
assert.deepEqual(maps(dippedArgs), ["[vdip]", "[a1]"]);
+ // Into the dipped teaser the outgoing segment plays the whole overlap, picture and sound.
+ assert.match(fc(dippedArgs), /\[0:v\]\[1:v\]xfade=transition=custom:expr='A':duration=0\.5:offset=2\.500\[v1\]/);
+ assert.match(fc(dippedArgs), /\[p0a\]\[p1a\]acrossfade=d=0\.5:c1=nofade:c2=nofade\[a1\]/);
+ // Only that join: a three-segment cut dipping into the last keeps its first dissolve.
+ const three = xfadeGraph([3, 3, 2.5], 0.5, [null, { hold: 0, move: null, dip: { seconds: 1, lastFrame: 89, black: 0 } }, null], RENDER);
+ assert.match(three.parts.join(";"), /\[0:v\]\[1:v\]xfade=transition=fade:.*\[v1\]\[2:v\]xfade=transition=custom:expr='A'/);
// A base for the rail: no dip (the rail pass lays it).
assert.ok(!fc(xfadeConcatArgs({ segments: segs, durs: [3, 2.5], render: RENDER, outPath: "o", joins, dip: false })).includes("geq"));
// The hard cut that is the cut: the dip on the joined picture.
@@ -431,6 +437,13 @@ test("ffmpeg: a dipped crossfade -- the whole frame, overlays included, black fr
const mid = withDip.frames[74];
assert.ok(Math.abs(lumaAt(mid, 160, 160) - (16 + (235 - 16) * 0.5)) <= 2, `deck at mid-fade: ${lumaAt(mid, 160, 160)}`);
assert.ok(Math.abs(lumaAt(mid, 290, 70) - (16 + (235 - 16) * 0.5)) <= 2, `feed at mid-fade: ${lumaAt(mid, 290, 70)}`);
+ // Through the overlap (frames 75–89) the clip is not dissolved into the
+ // teaser's black: the footage and the overlays fade in step, by the dip alone.
+ const own = ff(["-i", segs[0], "-vf", "select=eq(n\\,82)", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]);
+ const k = (82 - 59) / 30;
+ const f82 = withDip.frames[82];
+ assert.ok(Math.abs(lumaAt(f82, 100, 60) - (16 + (lumaAt(own, 100, 60) - 16) * (1 - k))) <= 2, "footage by the dip alone");
+ assert.ok(Math.abs(lumaAt(f82, 160, 160) - (16 + 219 * (1 - k))) <= 2, `deck in step: ${lumaAt(f82, 160, 160)}`);
// The clip's last frame through the black: every pixel black, the lit overlays with it.
for (let f = 89; f < 105; f += 1) {
const d = darkness(withDip.frames[f]);
@@ -444,6 +457,8 @@ test("ffmpeg: a dipped crossfade -- the whole frame, overlays included, black fr
const st = Math.round((59 / 30) * 48000) * 2;
assert.ok(withDip.pcm.subarray(0, st).equals(without.pcm.subarray(0, st)), "the same sound before the fade");
assert.ok(peak(withDip.pcm, 2.5, 2.6) < peak(without.pcm, 2.5, 2.6) * 0.75, "fading");
+ // In the overlap the clip's sound is its own fade's alone (no crossfade on top): about (1 − k) of it.
+ assert.ok(peak(withDip.pcm, 2.8, 2.85) > 0.08 * peak(without.pcm, 1.0, 1.5), "not crossfaded away early");
assert.equal(peak(withDip.pcm, 89 / 30, 105 / 30), 0, "digital silence from the last frame through the black");
assert.ok(peak(without.pcm, 89 / 30, 3) > 100, "the clip still sounds there without the dip");
assert.ok(peak(withDip.pcm, 3.6, 4.9) > 1000, "the teaser's own sound after its lead");