How to Loop a Video With FFmpeg (Count the Plays, Cut to an Exact Length, Check the Seam With pytest) A developer documented how to loop video with FFmpeg, showing that -stream_loop N must precede the -i it applies to and counts extra repeats rather than total plays, so -stream_loop 3 on a 10.04-second clip yields a 40.236-second file. Testing revealed FFmpeg's loop is a hard cut back to frame 0: the frame after the loop boundary matched frame 0 at infinite PSNR, while the last frame of the first pass scored only 15.9 dB against frame 0, producing a visible jump. The writeup includes a pytest suite and wrapper functions that convert play counts to loop counts, cut to an exact length via -t, and loop video under a longer audio track. To loop a video with FFmpeg, put -stream loop N before the -i it applies to. N is the number of extra plays, so -stream loop 3 gives you four plays, -stream loop -1 loops forever, and you stop the infinite version with -t . That is the whole trick; the rest of this post is about the three ways it quietly goes wrong and a small pytest suite that catches them. Everything below was run on November 12, 2026 with Homebrew FFmpeg 9.0.1 on macOS against a real 10.04-second clip 720x1280, 24 fps, AAC audio : the public cat example from Voor's Bad Times AI page https://voor.ai/bad-times-ai/?utm source=dev&utm medium=referral&utm campaign=loop video ffmpeg&utm content=workspace link . Any short MP4 works the same way. Loop a fixed number of times, no re-encode: 3 plays in total = 2 extra plays ffmpeg -stream loop 2 -i clip.mp4 -c copy -movflags +faststart clip x3.mp4 Loop to an exact length re-encode so the cut lands on a frame, not on a packet : ffmpeg -stream loop -1 -i clip.mp4 -t 30 \ -c:v libx264 -pix fmt yuv420p -crf 20 -preset veryfast \ -c:a aac -b:a 160k -movflags +faststart clip 30s.mp4 Loop the picture under a longer soundtrack and end exactly when the soundtrack ends: measure the track first ffprobe -v error -select streams a:0 -show entries stream=duration -of csv=p=0 track.m4a then pass that number to -t 25.000 here ffmpeg -stream loop -1 -i clip.mp4 -i track.m4a \ -map 0:v:0 -map 1:a:0 -t 25.000 \ -c:v libx264 -pix fmt yuv420p -crf 20 -preset veryfast \ -c:a aac -b:a 160k -movflags +faststart looped under track.mp4 -stream loop 3 is four plays The option counts repeats, not plays. On the 10.04 s clip, -stream loop 3 -c copy produced a 40.236 s file. If you want "play it three times", pass plays - 1 . The wrapper below does that for you and refuses plays=0 . -stream loop goes before -i It is an input option and applies only to the -i that follows it. Put it after -i clip.mp4 , right before the output name, and FFmpeg 9.0.1 refuses to run: "Option stream loop ... cannot be applied to output url ... you are trying to apply an input option to an output file". With two inputs, only the one directly after -stream loop loops; that is how the soundtrack command keeps the music playing once while the picture repeats. FFmpeg's loop is a hard cut back to frame 0. To prove the restart is exact, I decoded the frame just after the 10.04 s boundary of a 2-play file and compared it with frame 0 of the source: PSNR was inf identical . The last frame of pass 1, compared with frame 0, scored 15.9 dB : a completely different picture. In this clip the cat ends with both paws on the lens and starts sitting back, so every loop shows a visible jump. That is not a bug to fix with flags. If you need a seamless loop, the clip itself has to end where it began or you crossfade the tail into the head, which changes the content . Measure it before you promise "seamless". """Loop a video with FFmpeg: N times, to an exact length, or until an audio track ends.""" from future import annotations import json import subprocess from pathlib import Path def probe path: Path - dict: r = subprocess.run "ffprobe", "-v", "error", "-show streams", "-show format", "-of", "json", str path , capture output=True, text=True, check=True, return json.loads r.stdout def duration path: Path, kind: str | None = None - float: """Container duration, or the duration of the first video/audio stream.""" info = probe path if kind is None: return float info "format" "duration" for s in info "streams" : if s "codec type" == kind: return float s "duration" raise ValueError f"no {kind} stream in {path}" def has audio path: Path - bool: return any s "codec type" == "audio" for s in probe path "streams" def loop times src: Path, dst: Path, plays: int - Path: """Play the clip plays times in total. -stream loop counts EXTRA plays, so pass plays - 1.""" if plays < 1: raise ValueError "plays must be = 1" subprocess.run "ffmpeg", "-v", "error", "-y", "-stream loop", str plays - 1 , "-i", str src , "-c", "copy", "-movflags", "+faststart", str dst , check=True, return dst def loop to length src: Path, dst: Path, seconds: float - Path: """Loop forever and cut at an exact length. Re-encode so the cut is frame-accurate.""" cmd = "ffmpeg", "-v", "error", "-y", "-stream loop", "-1", "-i", str src , "-t", f"{seconds:.3f}", "-c:v", "libx264", "-pix fmt", "yuv420p", "-crf", "20", "-preset", "veryfast" cmd += "-c:a", "aac", "-b:a", "160k" if has audio src else "-an" cmd += "-movflags", "+faststart", str dst subprocess.run cmd, check=True return dst def loop under audio video: Path, audio: Path, dst: Path - Path: """Repeat the picture under a longer soundtrack; the output ends when the soundtrack ends. Measure the soundtrack and pass that exact length with -t, so the end point is explicit instead of depending on which stream -shortest sees finish first. """ track = duration audio, "audio" subprocess.run "ffmpeg", "-v", "error", "-y", "-stream loop", "-1", "-i", str video , "-i", str audio , "-map", "0:v:0", "-map", "1:a:0", "-t", f"{track:.3f}", "-c:v", "libx264", "-pix fmt", "yuv420p", "-crf", "20", "-preset", "veryfast", "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", str dst , check=True, return dst def frame at path: Path, t: float, out: Path - Path: """Decode one frame at time t seconds to a PNG.""" subprocess.run "ffmpeg", "-v", "error", "-y", "-ss", f"{t:.3f}", "-i", str path , "-frames:v", "1", str out , check=True, return out def psnr a: Path, b: Path - float: """Average PSNR between two images inf when identical .""" r = subprocess.run "ffmpeg", "-v", "info", "-i", str a , "-i", str b , "-lavfi", "psnr", "-f", "null", "-" , capture output=True, text=True, check=True, line = l for l in r.stderr.splitlines if "average:" in l -1 value = line.split "average:" 1 .split 0 return float "inf" if value == "inf" else float value python from pathlib import Path import subprocess import pytest from loop import duration, frame at, loop times, loop to length, loop under audio, psnr HERE = Path file .parent CLIP = HERE / "fixtures" / "pet-cat.mp4" real Voor Bad Times AI example, ~10.04 s, 24 fps FRAME = 1 / 24 @pytest.fixture scope="module" def clip len - float: return duration CLIP, "video" def test stream loop counts extra plays tmp path, clip len : out = loop times CLIP, tmp path / "x3.mp4", plays=3 assert duration out, "video" == pytest.approx 3 clip len, abs=2 FRAME def test loop to exact length tmp path : out = loop to length CLIP, tmp path / "30s.mp4", 30.0 assert duration out, "video" == pytest.approx 30.0, abs=FRAME def test second pass starts on frame zero tmp path, clip len : out = loop times CLIP, tmp path / "x2.mp4", plays=2 first = frame at CLIP, 0.0, tmp path / "f0.png" seam = frame at out, clip len + FRAME / 2, tmp path / "seam.png" assert psnr first, seam 35 the loop really restarts the clip def test loop under longer audio tmp path : track = tmp path / "track.m4a" subprocess.run "ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=25", "-c:a", "aac", str track , check=True out = loop under audio CLIP, track, tmp path / "under.mp4" assert duration out, "audio" == pytest.approx 25.0, abs=0.05 assert duration out, "video" == pytest.approx 25.0, abs=2 FRAME def test rejects zero plays tmp path : with pytest.raises ValueError : loop times CLIP, tmp path / "bad.mp4", plays=0 Run it with any pytest; I used uvx : uvx --with pytest pytest -q test loop.py ..... 100% 5 passed in 2.04s The 25 s soundtrack in the test is a generated sine tone, so the suite needs nothing but FFmpeg and the one clip. | Goal | Command shape | Re-encode? | Measured on the 10.04 s clip | |---|---|---|---| | N plays | -stream loop N-1 -c copy | No | 4 plays = 40.236 s | | Exact length | -stream loop -1 -t 30 | Yes | 30.000 s | | Under a soundtrack | -stream loop -1 -i clip -i track -t