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. Any short MP4 works the same way.
The three commands
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 Gotcha 1: -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.
Gotcha 2: -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.
Gotcha 3: a loop restarts the clip, it does not make it seamless
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".
A small Python wrapper
"""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) Tests that catch the gotchas
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.
When to stream-copy and when to re-encode
| 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 <track> | Yes (video) | 25.000 s video and audio |
Stream copy is fast and lossless but can only cut where packets end. For an exact length, or when the clip has only a keyframe at the start (this one does), re-encode.
Where the test clip came from
The fixture is a real generated example from Voor's Bad Times AI page: one photo of a pet, baby or person turned into a 10-second 9:16 "argument with the camera" clip with the song included. On November 12, 2026 the page quoted 439 credits for one clip (Pet routine selected, Public · watermarked). If you want your own short clip to loop on a profile page or a Story, you can make a Bad Times clip from one photo and run the commands above on the MP4 you download.




