{"slug": "how-to-loop-a-video-with-ffmpeg-count-the-plays-cut-to-an-exact-length-check-the", "title": "How to Loop a Video With FFmpeg (Count the Plays, Cut to an Exact Length, Check the Seam With pytest)", "summary": "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.", "body_md": "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.\n\nEverything 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.\n\nLoop a fixed number of times, no re-encode:\n\n```\n# 3 plays in total = 2 extra plays\nffmpeg -stream_loop 2 -i clip.mp4 -c copy -movflags +faststart clip_x3.mp4\n```\n\nLoop to an exact length (re-encode so the cut lands on a frame, not on a packet):\n\n```\nffmpeg -stream_loop -1 -i clip.mp4 -t 30 \\\n  -c:v libx264 -pix_fmt yuv420p -crf 20 -preset veryfast \\\n  -c:a aac -b:a 160k -movflags +faststart clip_30s.mp4\n```\n\nLoop the picture under a longer soundtrack and end exactly when the soundtrack ends:\n\n```\n# measure the track first\nffprobe -v error -select_streams a:0 -show_entries stream=duration -of csv=p=0 track.m4a\n# then pass that number to -t (25.000 here)\nffmpeg -stream_loop -1 -i clip.mp4 -i track.m4a \\\n  -map 0:v:0 -map 1:a:0 -t 25.000 \\\n  -c:v libx264 -pix_fmt yuv420p -crf 20 -preset veryfast \\\n  -c:a aac -b:a 160k -movflags +faststart looped_under_track.mp4\n```\n\n`-stream_loop 3` is four plays\nThe 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`.\n\n`-stream_loop` goes before `-i`\nIt 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.\n\nFFmpeg'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.\n\nThat 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\".\n\n```\n\"\"\"Loop a video with FFmpeg: N times, to an exact length, or until an audio track ends.\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport subprocess\nfrom pathlib import Path\n\ndef probe(path: Path) -> dict:\n    r = subprocess.run(\n        [\"ffprobe\", \"-v\", \"error\", \"-show_streams\", \"-show_format\", \"-of\", \"json\", str(path)],\n        capture_output=True, text=True, check=True,\n    )\n    return json.loads(r.stdout)\n\ndef duration(path: Path, kind: str | None = None) -> float:\n    \"\"\"Container duration, or the duration of the first video/audio stream.\"\"\"\n    info = probe(path)\n    if kind is None:\n        return float(info[\"format\"][\"duration\"])\n    for s in info[\"streams\"]:\n        if s[\"codec_type\"] == kind:\n            return float(s[\"duration\"])\n    raise ValueError(f\"no {kind} stream in {path}\")\n\ndef has_audio(path: Path) -> bool:\n    return any(s[\"codec_type\"] == \"audio\" for s in probe(path)[\"streams\"])\n\ndef loop_times(src: Path, dst: Path, plays: int) -> Path:\n    \"\"\"Play the clip `plays` times in total. -stream_loop counts EXTRA plays, so pass plays - 1.\"\"\"\n    if plays < 1:\n        raise ValueError(\"plays must be >= 1\")\n    subprocess.run(\n        [\"ffmpeg\", \"-v\", \"error\", \"-y\", \"-stream_loop\", str(plays - 1), \"-i\", str(src),\n         \"-c\", \"copy\", \"-movflags\", \"+faststart\", str(dst)],\n        check=True,\n    )\n    return dst\n\ndef loop_to_length(src: Path, dst: Path, seconds: float) -> Path:\n    \"\"\"Loop forever and cut at an exact length. Re-encode so the cut is frame-accurate.\"\"\"\n    cmd = [\"ffmpeg\", \"-v\", \"error\", \"-y\", \"-stream_loop\", \"-1\", \"-i\", str(src), \"-t\", f\"{seconds:.3f}\",\n           \"-c:v\", \"libx264\", \"-pix_fmt\", \"yuv420p\", \"-crf\", \"20\", \"-preset\", \"veryfast\"]\n    cmd += [\"-c:a\", \"aac\", \"-b:a\", \"160k\"] if has_audio(src) else [\"-an\"]\n    cmd += [\"-movflags\", \"+faststart\", str(dst)]\n    subprocess.run(cmd, check=True)\n    return dst\n\ndef loop_under_audio(video: Path, audio: Path, dst: Path) -> Path:\n    \"\"\"Repeat the picture under a longer soundtrack; the output ends when the soundtrack ends.\n\n    Measure the soundtrack and pass that exact length with -t, so the end point is explicit\n    instead of depending on which stream -shortest sees finish first.\n    \"\"\"\n    track = duration(audio, \"audio\")\n    subprocess.run(\n        [\"ffmpeg\", \"-v\", \"error\", \"-y\", \"-stream_loop\", \"-1\", \"-i\", str(video), \"-i\", str(audio),\n         \"-map\", \"0:v:0\", \"-map\", \"1:a:0\", \"-t\", f\"{track:.3f}\",\n         \"-c:v\", \"libx264\", \"-pix_fmt\", \"yuv420p\", \"-crf\", \"20\", \"-preset\", \"veryfast\",\n         \"-c:a\", \"aac\", \"-b:a\", \"160k\", \"-movflags\", \"+faststart\", str(dst)],\n        check=True,\n    )\n    return dst\n\ndef frame_at(path: Path, t: float, out: Path) -> Path:\n    \"\"\"Decode one frame at time t (seconds) to a PNG.\"\"\"\n    subprocess.run(\n        [\"ffmpeg\", \"-v\", \"error\", \"-y\", \"-ss\", f\"{t:.3f}\", \"-i\", str(path), \"-frames:v\", \"1\", str(out)],\n        check=True,\n    )\n    return out\n\ndef psnr(a: Path, b: Path) -> float:\n    \"\"\"Average PSNR between two images (inf when identical).\"\"\"\n    r = subprocess.run(\n        [\"ffmpeg\", \"-v\", \"info\", \"-i\", str(a), \"-i\", str(b), \"-lavfi\", \"psnr\", \"-f\", \"null\", \"-\"],\n        capture_output=True, text=True, check=True,\n    )\n    line = [l for l in r.stderr.splitlines() if \"average:\" in l][-1]\n    value = line.split(\"average:\")[1].split()[0]\n    return float(\"inf\") if value == \"inf\" else float(value)\npython\nfrom pathlib import Path\nimport subprocess\n\nimport pytest\n\nfrom loop import duration, frame_at, loop_times, loop_to_length, loop_under_audio, psnr\n\nHERE = Path(__file__).parent\nCLIP = HERE / \"fixtures\" / \"pet-cat.mp4\"  # real Voor Bad Times AI example, ~10.04 s, 24 fps\nFRAME = 1 / 24\n\n@pytest.fixture(scope=\"module\")\ndef clip_len() -> float:\n    return duration(CLIP, \"video\")\n\ndef test_stream_loop_counts_extra_plays(tmp_path, clip_len):\n    out = loop_times(CLIP, tmp_path / \"x3.mp4\", plays=3)\n    assert duration(out, \"video\") == pytest.approx(3 * clip_len, abs=2 * FRAME)\n\ndef test_loop_to_exact_length(tmp_path):\n    out = loop_to_length(CLIP, tmp_path / \"30s.mp4\", 30.0)\n    assert duration(out, \"video\") == pytest.approx(30.0, abs=FRAME)\n\ndef test_second_pass_starts_on_frame_zero(tmp_path, clip_len):\n    out = loop_times(CLIP, tmp_path / \"x2.mp4\", plays=2)\n    first = frame_at(CLIP, 0.0, tmp_path / \"f0.png\")\n    seam = frame_at(out, clip_len + FRAME / 2, tmp_path / \"seam.png\")\n    assert psnr(first, seam) > 35  # the loop really restarts the clip\n\ndef test_loop_under_longer_audio(tmp_path):\n    track = tmp_path / \"track.m4a\"\n    subprocess.run([\"ffmpeg\", \"-v\", \"error\", \"-y\", \"-f\", \"lavfi\", \"-i\", \"sine=frequency=440:duration=25\",\n                    \"-c:a\", \"aac\", str(track)], check=True)\n    out = loop_under_audio(CLIP, track, tmp_path / \"under.mp4\")\n    assert duration(out, \"audio\") == pytest.approx(25.0, abs=0.05)\n    assert duration(out, \"video\") == pytest.approx(25.0, abs=2 * FRAME)\n\ndef test_rejects_zero_plays(tmp_path):\n    with pytest.raises(ValueError):\n        loop_times(CLIP, tmp_path / \"bad.mp4\", plays=0)\n```\n\nRun it with any pytest; I used `uvx`:\n\n```\nuvx --with pytest pytest -q test_loop.py\n# .....                                    [100%]\n# 5 passed in 2.04s\n```\n\nThe 25 s soundtrack in the test is a generated sine tone, so the suite needs nothing but FFmpeg and the one clip.\n\n| Goal | Command shape | Re-encode? | Measured on the 10.04 s clip | \n|---|---|---|---|\n| N plays | `-stream_loop N-1 -c copy` | No | 4 plays = 40.236 s | \n| Exact length | `-stream_loop -1 -t 30` | Yes | 30.000 s | \n| Under a soundtrack | `-stream_loop -1 -i clip -i track -t <track>` | Yes (video) | 25.000 s video and audio | \n\nStream 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.\n\nThe 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.", "url": "https://wpnews.pro/news/how-to-loop-a-video-with-ffmpeg-count-the-plays-cut-to-an-exact-length-check-the", "canonical_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_at": "2026-11-12 00:14:41+00:00", "updated_at": "2026-11-12 00:21:27.244341+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["FFmpeg", "pytest", "ffprobe", "Homebrew", "Voor"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-to-loop-a-video-with-ffmpeg-count-the-plays-cut-to-an-exact-length-check-the", "markdown": "https://wpnews.pro/news/how-to-loop-a-video-with-ffmpeg-count-the-plays-cut-to-an-exact-length-check-the.md", "text": "https://wpnews.pro/news/how-to-loop-a-video-with-ffmpeg-count-the-plays-cut-to-an-exact-length-check-the.txt", "jsonld": "https://wpnews.pro/news/how-to-loop-a-video-with-ffmpeg-count-the-plays-cut-to-an-exact-length-check-the.jsonld"}}