Archilyzer · Source

archilyzer

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

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:
Mumtool/report-to-video/README.md | 14++++++++++----
Mumtool/report-to-video/build-video.mjs | 12++++++++++--
Mumtool/report-to-video/dip.test.mjs | 15+++++++++++++++
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");