From cc73fc37be95ed5ca8102b1e44836f516eac595b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 00:22:23 +0000 Subject: [PATCH 1/2] Keep blends working on ffmpeg 7: set the frame rate back after a trim ffmpeg 7 leaves a trimmed stream with no frame rate (1/0), and xfade refuses one: "The inputs needs to be a constant frame rate". Every run of a chunked stitch that starts or ends inside a shot trims it, so on the Docker image's ffmpeg 7.1 the blended stitch failed and the render fell back to joining every shot with a cut. ffmpeg 6.1, which the tests and CI run, keeps the rate, so nothing caught it. An fps filter at the shot's own rate puts it back without dropping or repeating a frame. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WJkfhgbVRktPf2Lg6XXfNs --- backend/app/render/ffmpeg.py | 6 +++++- backend/tests/test_render_ffmpeg.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/app/render/ffmpeg.py b/backend/app/render/ffmpeg.py index 97c92ea..f960b9c 100644 --- a/backend/app/render/ffmpeg.py +++ b/backend/app/render/ffmpeg.py @@ -1044,7 +1044,11 @@ def build_stitch_command( if hi is not None: bounds.append(f"end_frame={hi}") samples.append(f"end_sample={round(hi * 48000 / fps)}") - chains.append(f"[{i}:v]trim={':'.join(bounds)},setpts=PTS-STARTPTS,settb=AVTB[s{i}]") + # ffmpeg 7 leaves a trimmed stream with no frame rate (1/0), and `xfade` + # refuses one, so it is set back: the frames are already at it, so `fps` + # neither drops nor repeats any. + chains.append(f"[{i}:v]trim={':'.join(bounds)},setpts=PTS-STARTPTS," + f"fps={fps},settb=AVTB[s{i}]") chains.append(f"[{i}:a]atrim={':'.join(samples)},asetpts=PTS-STARTPTS[st{i}]") sounds.append(f"st{i}") video, audio = "s0", sounds[0] diff --git a/backend/tests/test_render_ffmpeg.py b/backend/tests/test_render_ffmpeg.py index da0eb01..41a36cf 100644 --- a/backend/tests/test_render_ffmpeg.py +++ b/backend/tests/test_render_ffmpeg.py @@ -1342,7 +1342,7 @@ def test_a_stitch_run_trims_its_ends_by_frame_and_sample_and_decodes_single_thre options=RenderOptions(), preview=True, start_frame=30, end_frame=60) graph = _graph(argv) assert argv.count("-threads") == 3 - assert "[0:v]trim=start_frame=30,setpts=PTS-STARTPTS,settb=AVTB[s0]" in graph + assert "[0:v]trim=start_frame=30,setpts=PTS-STARTPTS,fps=30,settb=AVTB[s0]" in graph assert "[0:a]atrim=start_sample=48000,asetpts=PTS-STARTPTS[st0]" in graph assert "[2:v]trim=end_frame=60," in graph and "atrim=end_sample=96000" in graph # The first shot is 3 s long once trimmed, so the first blend starts at 2.5 s. From 2d6f608d49a7675216bf690d29ec4c5955771089 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 00:42:39 +0000 Subject: [PATCH 2/2] Stop the overlay pass deadlocking on ffmpeg 7.1.5 Each overlay was an input that started late, via -itsoffset. On Debian's ffmpeg 7.1.5 that deadlocked when the picture reached the first layer: ffmpeg sat idle, the preview stuck on "Drawing overlays", until the pass timed out an hour later. It did not reproduce on 6.1, 7.0.2 or 7.1.1. A layer is now its PNG read once as a single frame and repeated inside the graph for its window only (loop, trim, setpts), so no input waits on ffmpeg's scheduling and the overlay pulls frames as it needs them. The window keeps its last frame (ceil), and the frames sit on AV_TIME_BASE rather than the image demuxer's 1/25, which would round the fades to 40 ms. Against the -itsoffset output on 7.1.1, 59.6 dB average, with the remaining difference inside the fades: the layer now starts at its own time instead of the 1/30 s grid. 30 layers: 359 -> 293 MB, 7 s -> 3-5 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WJkfhgbVRktPf2Lg6XXfNs --- backend/app/render/ffmpeg.py | 34 +++++++++++++++++++---------- backend/tests/test_render_ffmpeg.py | 11 ++++++---- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/backend/app/render/ffmpeg.py b/backend/app/render/ffmpeg.py index f960b9c..bf73988 100644 --- a/backend/app/render/ffmpeg.py +++ b/backend/app/render/ffmpeg.py @@ -1253,25 +1253,35 @@ def build_overlay_pass( So this is one re-encode of the whole picture after the stitch, done only when a video has timed overlays at all. - Each layer is a looped still that only exists for its window: `-itsoffset` - starts it at the layer's start and `-t` ends it there, so its fades are alpha - fades at absolute times on the video's own clock. Looping every layer for the - whole video instead decoded each one on every frame, which made this pass's - memory and time grow with the overlay count — thirty layers took 560 MB and - twenty times as long. `enable` still keeps it off outside its window, and - `eof_action=pass` lets the picture through once it has ended. + Each layer is its PNG read once, as a single frame, and repeated inside the + graph for its window only: `loop` repeats it, `trim` stops it after the + window's frames and `setpts` places them from the layer's start. Its fades are + then alpha fades at absolute times on the video's own clock, `enable` keeps it + off outside its window, and `eof_action=pass` lets the picture through after. + + Two ways of doing this have failed. Looping each PNG as an input for the whole + video decoded it on every frame, which made this pass's memory and time grow + with the overlay count: thirty layers took 560 MB and twenty times as long. An + input per layer that starts late (`-itsoffset`) deadlocked Debian's ffmpeg + 7.1.5 at the first layer's start: idle, never finishing, until the timeout. A + one-frame input is finished before the picture starts, and from then on the + overlay only pulls the layer's frames through the graph as it needs them, so + nothing is left waiting on ffmpeg's scheduling of inputs. """ - fps = options.fps + fps = max(1, options.fps) total = max(0.1, duration) argv = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", source] chains: List[str] = [] stage = "0:v" for position, layer in enumerate(layers, start=1): start = max(0.0, min(layer.start, total)) - length = max(1.0 / max(1, fps), min(layer.end, total) - start) - argv += ["-threads", "1", "-itsoffset", f"{start:.3f}", "-loop", "1", - "-framerate", str(fps), "-t", f"{length:.3f}", "-i", layer.path] - chain = f"[{position}:v]format=rgba" + # Every frame that starts inside the window, the last one included. + frames = max(1, math.ceil((min(layer.end, total) - start) * fps - 1e-6)) + argv += ["-i", layer.path] + # `settb` first: a lone PNG comes in on the image demuxer's 1/25 timebase, + # which would round every frame's time, and with it the fades, to 40 ms. + chain = (f"[{position}:v]format=rgba,loop=loop=-1:size=1,trim=end_frame={frames},settb=AVTB," + f"setpts=N/({fps}*TB)+{start:.3f}/TB") if layer.fade > 0: chain += (f",fade=t=in:st={layer.start:.3f}:d={layer.fade:.3f}:alpha=1" f",fade=t=out:st={layer.end - layer.fade:.3f}:d={layer.fade:.3f}:alpha=1") diff --git a/backend/tests/test_render_ffmpeg.py b/backend/tests/test_render_ffmpeg.py index 41a36cf..1912106 100644 --- a/backend/tests/test_render_ffmpeg.py +++ b/backend/tests/test_render_ffmpeg.py @@ -1350,10 +1350,13 @@ def test_a_stitch_run_trims_its_ends_by_frame_and_sample_and_decodes_single_thre assert "[at2][st2]concat" in graph -def test_each_overlay_layer_exists_only_for_its_window(): +def test_each_overlay_layer_is_one_frame_repeated_for_its_window_only(): + """A layer looped as an input for the whole video was decoded on every frame, + and one started late with `-itsoffset` deadlocked ffmpeg 7.1.5.""" argv = F.build_overlay_pass( "stitched.mp4", [F.TimedLayer("a.png", 1.0, 4.5, 0.3)], "out.mp4", options=RenderOptions(), preview=True, duration=10.0) - layer = argv[argv.index("a.png") - 12:argv.index("a.png")] - assert layer[layer.index("-itsoffset") + 1] == "1.000" - assert layer[layer.index("-t") + 1] == "3.500" + assert argv[argv.index("a.png") - 1] == "-i" + assert "-itsoffset" not in argv and "-loop" not in argv + assert ("[1:v]format=rgba,loop=loop=-1:size=1,trim=end_frame=105,settb=AVTB," + "setpts=N/(30*TB)+1.000/TB,") in _graph(argv)