Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions backend/app/render/ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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")
Expand Down
13 changes: 8 additions & 5 deletions backend/tests/test_render_ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1342,18 +1342,21 @@ 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.
assert "offset=2.500" in graph
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)
Loading