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.

Three frames from a real clip looped with -stream_loop 1: the last frame of pass 1, the first frame of pass 2, and the source frame 0, with PSNR values

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".

Measured ffprobe durations for the source, a -stream_loop 3 copy, a 30 s exact loop and a loop under a 25 s soundtrack, plus 5 passing pytest checks

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.