diff --git a/backend/app/render/ffmpeg.py b/backend/app/render/ffmpeg.py index 97c92ea..bf73988 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] @@ -1249,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 da0eb01..1912106 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. @@ -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)