cd /news/developer-tools/how-to-loop-a-video-with-ffmpeg-coun… · home › topics › developer-tools › article
[ARTICLE · art-168057] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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.

by read7 min views1 publishedNov 12, 2026

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @ffmpeg 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-loop-a-video-…] indexed:0 read:7min 2026-11-12 · —