commit b10db121fa0e28489fc8472b40c3ff94a7cc366e
parent 6017470e4dc57c6e465aa2fd0624159d846a783b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 16:11:35 -0400
report-to-video: an end fade longer than the last segment is the segment, so its last frame is bg
The picture's fade count is clamped to the segment's last frame
(endFadeFrames), and the sound fades over the same frames, so a 3 s finale
under endFade 5 ends on bg and in silence together. Before, the start
clamped to 0 while the weight's denominator stayed the full fade, and the
last frame was 59 % of the way to bg while the sound reached silence.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 60 insertions(+), 4 deletions(-)
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -1214,7 +1214,9 @@ crossfades and hard cuts.
the fade's first frame: frame `lastFrame − n` is the last untouched one and
`lastFrame` (the hold's clones counted) is bg. Not `fade=…:color=`, which works
in RGB only (`umtool/docs/quirks.md`). The sound is `afade` out to silence at the
- last frame's time.
+ last frame's time, over the same `n` frames. A fade longer than the segment is
+ the segment (`endFadeFrames` clamps `n` to `lastFrame`), so a 3 s finale under
+ `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.
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -2401,19 +2401,28 @@ export function yuv601(hex) {
* the fade's first frame (`enable`): every frame before it passes untouched.
* Frame f's weight is (f − s)/n with s = lastFrame − n, so s is the last
* frame untouched and lastFrame is bg; `+0.5` rounds where geq truncates.
+ *
+ * A fade longer than the segment is the segment: n is clamped to lastFrame
+ * (`endFadeFrames`), so the fade runs from its first frame and its last is
+ * still exactly bg. Unclamped, the weight's denominator stayed n while s
+ * clamped to 0, and the last frame of a 3 s segment under `endFade: 5` was
+ * only 59 % of the way there while the sound did reach silence.
*/
+export const endFadeFrames = (fade, fps) =>
+ Math.max(1, Math.min(Math.round(fade.seconds * fps), fade.lastFrame));
export const endFadeVideoFilter = (fade, render) => {
const fps = render.fps;
- const n = Math.max(1, Math.round(fade.seconds * fps));
+ const n = endFadeFrames(fade, fps);
const st = exprNum(Math.max(0, fade.lastFrame - n) / fps);
const k = `clip((T-${st})/${exprNum(n / fps)},0,1)`;
const bg = yuv601(render.palette.bg);
const plane = (p, c) => `'${p}(X,Y)+(${c}-${p}(X,Y))*${k}+0.5'`;
return `geq=lum=${plane("lum", bg.y)}:cb=${plane("cb", bg.u)}:cr=${plane("cr", bg.v)}:enable='gte(t,${st})'`;
};
+/** The sound's half: the same frames as the picture's, so the two end together. */
export const endFadeAudioFilter = (fade, render) => {
const end = fade.lastFrame / render.fps;
- const st = Math.max(0, end - fade.seconds);
+ const st = Math.max(0, end - endFadeFrames(fade, render.fps) / render.fps);
return `afade=t=out:st=${exprNum(st)}:d=${exprNum(Math.max(1e-3, end - st))}`;
};
diff --git a/umtool/report-to-video/cut-edits.test.mjs b/umtool/report-to-video/cut-edits.test.mjs
@@ -15,7 +15,7 @@ import path from "node:path";
import test from "node:test";
import {
- concatListText, concatRecordText, cutJoins, cutRecordPath, endFadeAudioFilter, endFadeVideoFilter,
+ concatListText, concatRecordText, cutJoins, cutRecordPath, endFadeAudioFilter, endFadeFrames, endFadeVideoFilter,
hardCutFilterArgs, joinInputChain, muteAudioFilter, sameConcatList, withCutEdits, xfadeGraph, yuv601,
} from "./build-video.mjs";
import {
@@ -114,6 +114,17 @@ test("the end fade: a yuv blend toward bg from frame s = last − n, so the LAST
assert.equal(endFadeAudioFilter(fade, RENDER), "afade=t=out:st=8.4667:d=1");
});
+test("the end fade: longer than its segment, it is the segment -- frames and seconds clamped alike, so the last frame is bg", () => {
+ // A 3 s segment at 30 fps (frames 0..89) under endFade 5: the fade spans
+ // the segment, from frame 0 (weight 0) to frame 89 (weight 1).
+ const fade = { seconds: 5, lastFrame: 89 };
+ assert.equal(endFadeFrames(fade, 30), 89);
+ assert.equal(endFadeFrames({ seconds: 1, lastFrame: 89 }, 30), 30, "a shorter fade is its own length");
+ assert.equal(endFadeFrames({ seconds: 5, lastFrame: 0 }, 30), 1, "never zero frames");
+ assert.equal(endFadeVideoFilter(fade, RENDER), "geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-0)/2.9667,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-0)/2.9667,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-0)/2.9667,0,1)+0.5':enable='gte(t,0)'");
+ assert.equal(endFadeAudioFilter(fade, RENDER), "afade=t=out:st=0:d=2.9667", "the sound over the same frames");
+});
+
test("joinInputChain: a mute alone is a chain on the sound only; the picture is the input's own", () => {
assert.deepEqual(joinInputChain(2, { hold: 0, move: null, mute: 6.7 }, RENDER), {
parts: ["[2:a]afade=t=out:st=6.66:d=0.04[j2a]"], v: "[2:v]", a: "[j2a]",
@@ -299,6 +310,40 @@ test("ffmpeg: the end fade -- the last frame is bg and the sound silent there; w
}
});
+test("ffmpeg: an end fade longer than the last segment -- a 3 s segment under endFade 5 still ends on bg and in silence",
+ { skip: !haveFfmpeg }, () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "cut-longfade-"));
+ try {
+ const seg = path.join(dir, "t.mov");
+ ff([
+ "-f", "lavfi", "-i", "testsrc2=s=280x150:r=30:d=3",
+ "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=3",
+ "-filter_complex", `[0:v]pad=320:180:20:10:color=${PALETTE.bg},format=yuv420p[v];[1:a]aformat=channel_layouts=stereo[a]`,
+ "-map", "[v]", "-map", "[a]", "-c:v", "ffv1", "-c:a", "pcm_s16le", seg,
+ ]);
+ const joins = withCutEdits([null], 1, { fade: { seconds: 5, lastFrame: 89 } });
+ const { parts, v, a } = joinInputChain(0, joins[0], R);
+ const inputs = ["-i", seg];
+ assert.equal(framesOf(inputs, parts, v, a).length, 90, "the length is unchanged");
+ const frame = (n) => ff([...inputs, "-filter_complex", `${parts.join(";")};${a}anullsink;${v}select=eq(n\\,${n})[o]`,
+ "-map", "[o]", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" });
+ const bg = [0x12, 0x10, 0x1a];
+ const off = (buf) => {
+ let worst = 0;
+ for (let i = 0; i < buf.length; i += 1) worst = Math.max(worst, Math.abs(buf[i] - bg[i % 3]));
+ return worst;
+ };
+ assert.ok(off(frame(89)) <= 2, `the last frame is bg (worst channel off by ${off(frame(89))})`);
+ assert.ok(off(frame(44)) > 20, "halfway, still fading");
+ const pcm = pcmOf(inputs, parts, v, a);
+ const lastAt = 89 / 30;
+ assert.equal(peak(pcm, lastAt, 3), 0, "silent from the last frame's time");
+ assert.ok(peak(pcm, 0.1, 0.2) > 1000, "sounding at the start");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
test("cutJoins: the record beside the segment places muteFrom; the end fade counts the last segment's frames and hold",
{ skip: !haveFfmpeg }, async () => {
const dir = mkdtempSync(path.join(tmpdir(), "cut-joins-"));