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.
Loop a fixed number of times, no re-encode:
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:
ffprobe -v error -select_streams a:0 -show_entries stream=duration -of csv=p=0 track.m4a
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
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 <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.
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.