# How to Loop a Video With FFmpeg (Count the Plays, Cut to an Exact Length, Check the Seam With pytest)

> Source: <https://dev.to/voorai/how-to-loop-a-video-with-ffmpeg-count-the-plays-cut-to-an-exact-length-check-the-seam-with-3dpf>
> Published: 2026-11-12 00:14:41+00:00

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 <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](https://voor.ai/bad-times-ai/?utm_source=dev&utm_medium=referral&utm_campaign=loop_video_ffmpeg&utm_content=final_cta) and run the commands above on the MP4 you download.
