Short answer: to split a video with FFmpeg, use the segment muxer:
ffmpeg -i input.mp4 -map 0 -c copy -f segment -segment_times 4.041667,7.083333 -reset_timestamps 1 part_%03d.mp4 That is lossless and fast, but with -c copy FFmpeg can only cut on keyframes, so -segment_time 2 does not mean "2-second parts". If you need exact lengths you must re-encode and force a keyframe at every boundary. Below are the measurements on a real 10-second clip, a small Python module, and five pytest checks that prove the parts add back up to the source.
The test clip
The fixture is a real generated example from Voor's Gas Station Dance page: a vertical 720×1280 H.264 + AAC clip, 24 fps, 243 frames, 10.125 s, made of three shots (dancer, selfie, friend stepping out of the car).
curl -O https://cdn.voor.ai/voor/keyword-landing/gas-station-dance/v20261016/trio-original-v3.mp4 ffprobe -v error -select_streams v:0 -skip_frame nokey \ -show_entries frame=pts_time -of csv=p=0 trio-original-v3.mp4 # 0.000000 # 4.041667 # 7.083333 Only three keyframes, and they sit exactly on the shot cuts. That one fact explains everything that follows. (Tested with FFmpeg 9.0.1, Python 3, pytest.)
Mistake 1: expecting -segment_time to be exact with -c copy
ffmpeg -i trio-original-v3.mp4 -map 0 -c copy -f segment -segment_time 2 \ -reset_timestamps 1 p_%03d.mp4 I asked for 2-second parts and got three files: 4.06 s, 3.04 s and 3.04 s. Stream copy cannot start a part on a frame that depends on earlier frames, so the muxer waits for the next keyframe after each 2-second mark. On phone footage with a keyframe every 1–2 s the drift is small and easy to miss; on AI-generated or heavily compressed clips with few keyframes it is huge.
Mistake 2: forcing keyframes every N seconds without checking the tail
Re-encode and force keyframes on the grid:
ffmpeg -i trio-original-v3.mp4 -map 0 -c:v libx264 -crf 18 -preset veryfast \ -force_key_frames "expr:gte(t,n_forced*2)" -c:a aac \ -f segment -segment_time 2 -reset_timestamps 1 p_%03d.mp4 Now the cuts are exact: five parts of 48 frames (2.0 s) — plus a sixth part of 3 frames (0.125 s), because 243 frames do not divide by 48. A 0.125-second file is useless in most upload flows. If what you want is "split into N parts", compute the boundaries from the duration instead of picking a fixed length.
Mistake 3: forgetting -reset_timestamps 1
Without it, every part keeps its original timestamps. On the scene split below, part 2 reports start_time=4.053946 and part 3 7.095760. Some players and editors show a black or frozen first few seconds, and concatenating the parts later gets confusing. Always reset.
Mistake 4: cutting halves with -ss/-t and stream copy
ffmpeg -i trio-original-v3.mp4 -t 5.0625 -c copy first.mp4 ffmpeg -ss 5.0625 -i trio-original-v3.mp4 -c copy second.mp4 The halves decode to 124 + 121 = 245 frames from a 243-frame source: the copy cut snaps to keyframes, so frames around the midpoint appear in both files. Splitting with one segment-muxer command avoids the overlap.
A small module that splits correctly
split.py wraps the three operations you actually need: find keyframes and scene cuts, split losslessly at given times, and split into N equal parts with forced keyframes. check_parts() is the guard: the frame counts must add up to the source and every part must start at 0.
"""Split a video with FFmpeg and prove the parts add back up to the source.""" from __future__ import annotations import json import subprocess from pathlib import Path def _run(cmd: list[str]) -> str: return subprocess.run(cmd, check=True, capture_output=True, text=True).stdout def probe(path: Path) -> dict: """Container duration/start and the decoded video frame count.""" out = _run([ "ffprobe", "-v", "error", "-count_frames", "-select_streams", "v:0", "-show_entries", "format=duration,start_time:stream=nb_read_frames", "-of", "json", str(path), ]) data = json.loads(out) return { "duration": float(data["format"]["duration"]), "start": float(data["format"].get("start_time", 0.0)), "frames": int(data["streams"][0]["nb_read_frames"]), } def _times(out: str) -> list[float]: # ffprobe may append side-data columns ("0.000000,"), so keep the first field only. return [round(float(line.split(",")[0]), 6) for line in out.splitlines() if line.strip()] def keyframe_times(path: Path) -> list[float]: out = _run([ "ffprobe", "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey", "-show_entries", "frame=pts_time", "-of", "csv=p=0", str(path), ]) return _times(out) def scene_cuts(path: Path, threshold: float = 0.3) -> list[float]: """Timestamps where the picture changes by more than `threshold` (0..1).""" src = str(path).replace("\\", "/").replace(":", "\\:") out = _run([ "ffprobe", "-v", "error", "-f", "lavfi", "-i", f"movie={src},select='gt(scene\\,{threshold})'", "-show_entries", "frame=pts_time", "-of", "csv=p=0", ]) return _times(out) def split_at(path: Path, times: list[float], outdir: Path, *, reset: bool = True) -> list[Path]: """Lossless split (stream copy) at the given times. Cuts land on keyframes.""" outdir.mkdir(parents=True, exist_ok=True) cmd = [ "ffmpeg", "-v", "error", "-y", "-i", str(path), "-map", "0", "-c", "copy", "-f", "segment", "-segment_times", ",".join(f"{t:.6f}" for t in times), ] if reset: cmd += ["-reset_timestamps", "1"] subprocess.run(cmd + [str(outdir / "part_%03d.mp4")], check=True) return sorted(outdir.glob("part_*.mp4")) def split_equal(path: Path, n: int, outdir: Path) -> list[Path]: """N equal parts: re-encode and force a keyframe at every boundary.""" duration = probe(path)["duration"] times = [round(duration * k / n, 6) for k in range(1, n)] outdir.mkdir(parents=True, exist_ok=True) subprocess.run([ "ffmpeg", "-v", "error", "-y", "-i", str(path), "-map", "0", "-c:v", "libx264", "-crf", "18", "-preset", "veryfast", "-force_key_frames", ",".join(f"{t:.6f}" for t in times), "-c:a", "aac", "-f", "segment", "-segment_times", ",".join(f"{t:.6f}" for t in times), "-reset_timestamps", "1", str(outdir / "part_%03d.mp4"), ], check=True) return sorted(outdir.glob("part_*.mp4")) def check_parts(source: Path, parts: list[Path], *, max_start: float = 0.1) -> list[dict]: """Every part starts near 0 and the frame counts add up to the source.""" info = [probe(p) for p in parts] total = sum(i["frames"] for i in info) expected = probe(source)["frames"] if total != expected: raise ValueError(f"parts have {total} frames, source has {expected}") late = [p.name for p, i in zip(parts, info) if i["start"] > max_start] if late: raise ValueError(f"parts do not start at 0: {late}") return info Recipes
Split by scene, lossless. When the cuts are keyframes (common for generated clips and for exports from editors), stream copy gives exact shots with no quality loss:
from pathlib import Path from split import scene_cuts, split_at, check_parts src = Path("trio-original-v3.mp4") parts = split_at(src, scene_cuts(src), Path("shots")) print([p["frames"] for p in check_parts(src, parts)]) # [97, 73, 73] Split into N equal parts. Boundaries at duration * k / n, keyframes forced exactly there, then the segment muxer cuts on them:
from split import split_equal parts = split_equal(src, 3, Path("thirds")) print([p["frames"] for p in check_parts(src, parts)]) # [81, 81, 81] Each third is 3.375 s. This re-encodes the video (CRF 18), so use it when exact lengths matter more than bit-for-bit copies.
Split into 2 parts. split_equal(src, 2, ...) — not two -ss/-t commands (see Mistake 4).
The tests
import subprocess from pathlib import Path import pytest from split import check_parts, keyframe_times, probe, scene_cuts, split_at, split_equal SRC = Path(__file__).parent / "inputs" / "trio-original-v3.mp4" # real Gas Station Dance example, 243 frames @ 24 fps def test_stream_copy_cannot_cut_between_keyframes(tmp_path): # Ask for 2-second parts with -c copy: FFmpeg can only cut on keyframes. subprocess.run([ "ffmpeg", "-v", "error", "-i", str(SRC), "-map", "0", "-c", "copy", "-f", "segment", "-segment_time", "2", "-reset_timestamps", "1", str(tmp_path / "p_%03d.mp4"), ], check=True) parts = sorted(tmp_path.glob("p_*.mp4")) assert len(parts) == 3 # not 5 assert [round(probe(p)["duration"], 2) for p in parts] == [4.06, 3.04, 3.04] def test_scene_cuts_are_the_keyframes(): assert keyframe_times(SRC) == [0.0, 4.041667, 7.083333] assert scene_cuts(SRC) == [4.041667, 7.083333] def test_split_by_scene_is_lossless(tmp_path): parts = split_at(SRC, scene_cuts(SRC), tmp_path) info = check_parts(SRC, parts) assert [i["frames"] for i in info] == [97, 73, 73] def test_without_reset_timestamps_parts_start_late(tmp_path): parts = split_at(SRC, scene_cuts(SRC), tmp_path, reset=False) assert probe(parts[1])["start"] == pytest.approx(4.05, abs=0.01) with pytest.raises(ValueError, match="do not start at 0"): check_parts(SRC, parts) def test_split_into_three_equal_parts(tmp_path): parts = split_equal(SRC, 3, tmp_path) info = check_parts(SRC, parts) assert [i["frames"] for i in info] == [81, 81, 81] assert all(i["duration"] == pytest.approx(3.375, abs=0.07) for i in info) test_split.py::test_stream_copy_cannot_cut_between_keyframes PASSED test_split.py::test_scene_cuts_are_the_keyframes PASSED test_split.py::test_split_by_scene_is_lossless PASSED test_split.py::test_without_reset_timestamps_parts_start_late PASSED test_split.py::test_split_into_three_equal_parts PASSED 5 passed in 4.73s Run them with uvx --with pytest pytest -v test_split.py (or pip install pytest). The exact numbers are tied to this fixture; for your own clips keep check_parts() and replace the hard-coded frame counts with the values probe() reports.
Checklist
- Probe keyframes first. If you need cuts between them, re-encode.
-
-c copy+ segment muxer for lossless splits at keyframes or scene cuts. -
-force_key_frameswith the same times you pass to-segment_timesfor exact cuts. - Compute N-part boundaries from the duration; check for a tiny tail if you use a fixed length.
- Always
-reset_timestamps 1. - Assert that part frame counts sum to the source frame count.
-
-map 0keeps the audio; checkffprobethat each part has both streams.
Limits
- Scene detection with
gt(scene,0.3)is a threshold, not magic: fades and fast camera moves can trigger false cuts or miss soft ones. Print the times before you split. - AAC priming means the first part may report a small
start_time(0.06 s here); that is the audio encoder delay, not a timestamp bug. - Frame-exact splitting of variable-frame-rate phone footage needs a re-encode with a fixed
-r.
Where the test clip came from
The fixture is a real Gas Station Dance example generated on Voor: one to three photos (one per person) become the three-shot gas station dance as a 10-second 9:16 clip; with one photo the generator quoted 439 credits for a public, watermarked clip on November 15, 2026. If you want your own multi-shot clip to practice on, the Gas Station Dance generator is where it came from — then split it by scene with the code above.



