There are two ways to trim a video with FFmpeg, and they don't do the same thing:
- Fast, no re-encode:
ffmpeg -ss 1 -i in.mp4 -t 2 -c copy out.mp4. This is fast, but the video can only start on a keyframe. - Exact, re-encode:
ffmpeg -ss 1 -i in.mp4 -t 2 -c:v libx264 -crf 18 -c:a aac out.mp4. This starts on the frame you asked for, but the video is encoded again.
Most "my trim is off by a second" bugs come from using the first command and expecting the second. Below, I trim a real clip both ways, measure which source frame each output really starts on, and turn the results into pytest checks you can keep in a media pipeline. Everything was run with FFmpeg 9.0.1 on macOS.
The test clip
The fixtures are three short AI-generated clips published as examples on Voor's Vidu Q3 page: 1952×1062, 30 fps, H.264 + AAC. These clips have very few keyframes, which makes the difference between the two methods easy to see. List the keyframes first:
ffprobe -v error -select_streams v:0 -skip_frame nokey \ -show_entries frame=pts_time -of csv=p=0 vidu-q3-2.mp4 # 0.000000 # 2.200000 vidu-q3-2.mp4 (5.108 s, 153 frames) has only two keyframes, at 0.0 s and 2.2 s. vidu-q3-1.mp4 (4.11 s) has just one, at 0.0 s. That tells you in advance which cuts -c copy can make cleanly: almost none.
The helpers
All measurements come from ffprobe and from decoding frames, not from the command line we meant to run:
def run(cmd): return subprocess.run(cmd, capture_output=True, text=True) def probe(path): r = run([FFPROBE, "-v", "error", "-show_entries", "format=duration:stream=index,codec_type,codec_name,duration,nb_frames,start_time", "-of", "json", str(path)]) return json.loads(r.stdout) def keyframes(path): r = run([FFPROBE, "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey", "-show_entries", "frame=pts_time", "-of", "csv=p=0", str(path)]) return [float(x.strip(",")) for x in r.stdout.split() if x.strip(",")] def frame_count(path): """Frames a player will actually show (decoded, after edit lists).""" r = run([FFPROBE, "-v", "error", "-select_streams", "v:0", "-count_frames", "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", str(path)]) return int(r.stdout.strip()) To find out which source frame an output really starts on, decode both as tiny grayscale images and pick the closest match:
def gray_frame(path, index=0, size=(64, 36)): """Decode one displayed frame as small grayscale bytes for comparison.""" w, h = size r = subprocess.run([FFMPEG, "-v", "error", "-i", str(path), "-vf", f"select=eq(n\\,{index}),scale={w}:{h},format=gray", "-fps_mode", "passthrough", "-frames:v", "1", "-f", "rawvideo", "-"], capture_output=True) return r.stdout def diff(a, b): """Mean absolute pixel difference between two gray frames (0 = identical).""" return sum(abs(x - y) for x, y in zip(a, b)) / max(1, len(a)) def nearest_source_frame(src, clip, clip_index=0, search=range(0, 160)): """Index of the source frame that looks most like frame `clip_index` of `clip`.""" g = gray_frame(clip, clip_index) best, score = None, 1e9 for i in search: s = gray_frame(src, i) if not s: break d = diff(g, s) if d < score: best, score = i, d return best, score At 64×36 the comparison is cheap, and in this clip neighbouring frames differ enough that the best match is unambiguous. Re-encoded frames score about 0.1, unrelated frames score in the tens.
Trim 1: stream copy
def trim_copy(src, dst, start, length): """Fast: no re-encode. Video can only begin on a keyframe.""" return run([FFMPEG, "-y", "-v", "error", "-ss", str(start), "-i", str(src), "-t", str(length), "-map", "0", "-c", "copy", "-avoid_negative_ts", "make_zero", str(dst)]) I asked for 1.000 s → 3.000 s. What came back:
| Asked | Got | |
|---|---|---|
| Duration | 2.00 s | 3.09 s |
| Frames | 60 | 92 |
| First frame | source frame 30 (1.000 s) | source frame 0 (0.000 s) |
With -c copy FFmpeg can't create a new keyframe at 1.0 s, so the output starts at the previous keyframe, 0.0 s, and keeps everything up to 3.0 s. The cover image shows the difference: the frame you wanted is the mushrooms, the frame you got is the insect close-up from the start of the clip.
Trim 2: re-encode
def trim_exact(src, dst, start, length, crf=18): """Frame-accurate: decode from the seek point and re-encode.""" return run([FFMPEG, "-y", "-v", "error", "-ss", str(start), "-i", str(src), "-t", str(length), "-map", "0", "-c:v", "libx264", "-crf", str(crf), "-preset", "medium", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", str(dst)]) Now the first frame matches source frame 30 (score 0.1), there are exactly 60 frames, and both audio and video streams last 2.000 s. The cost is encode time and one generation of quality loss. -crf 18 keeps that loss below what you'll notice on a clip like this, but it's still not a bit-exact copy.
Trim 3: snap to a keyframe and copy
If you don't need a specific frame, for example when cutting a preview or removing a slow start, move the cut to a keyframe and keep the speed of -c copy:
def snap_to_keyframe(path, start): """Latest keyframe at or before `start` - the only place stream copy can begin cleanly.""" ks = [k for k in keyframes(path) if k <= start + 1e-6] return ks[-1] if ks else 0.0 snap_to_keyframe(clip, 2.5) returns 2.2. Copying from 2.2 s gives 62 frames that start exactly on source frame 66 (2.2 × 30), with no re-encoding. The rule is simple: stream copy is exact only when the start time is a keyframe.
The -to trap
-to is an output timestamp, so where you put -ss changes what it means:
def trim_to(src, dst, start, end, input_seek=True): """-to is an end *timestamp*; where -ss sits changes what that timestamp means.""" if input_seek: cmd = [FFMPEG, "-y", "-v", "error", "-ss", str(start), "-i", str(src), "-to", str(end)] else: cmd = [FFMPEG, "-y", "-v", "error", "-i", str(src), "-ss", str(start), "-to", str(end)] return run(cmd + ["-c:v", "libx264", "-crf", "18", "-c:a", "aac", str(dst)]) | Command | Duration |
|---|---|
ffmpeg -ss 1 -i in.mp4 -to 3 … | 3.00 s (1.0 → 4.0 s) |
ffmpeg -i in.mp4 -ss 1 -to 3 … | 2.00 s (1.0 → 3.0 s) |
With -ss before -i, the output clock restarts at 0, so -to 3 behaves like -t 3. With -ss after -i, the timestamps stay on the source clock and -to 3 stops at 3.0 s. Output seeking decodes everything before the cut, so it's slower on long files. If you want an end time, the safest habit is to compute -t end-start yourself.
The tests
These are the checks I keep next to any trimming code. Each one encodes a fact measured above:
import pytest import trim as T CLIP = "inputs/vidu-q3-2.mp4" # 30 fps, 5.108 s, keyframes at 0.0 and 2.2 s ONE_GOP = "inputs/vidu-q3-1.mp4" # 30 fps, 4.11 s, a single keyframe at 0.0 s FPS = 30 @pytest.fixture(scope="module") def out(tmp_path_factory): return tmp_path_factory.mktemp("trim") def test_fixture_keyframes(): assert T.keyframes(CLIP) == [0.0, 2.2] assert T.keyframes(ONE_GOP) == [0.0] def test_stream_copy_starts_at_previous_keyframe(out): dst = out / "copy.mp4" assert T.trim_copy(CLIP, dst, 1.0, 2.0).returncode == 0 first, _ = T.nearest_source_frame(CLIP, dst, search=range(0, 45)) assert first == 0 # asked for 1.0 s, got the 0.0 s keyframe assert T.frame_count(dst) > 2.0 * FPS + 20 # and about a second of extra video def test_reencode_starts_on_requested_frame(out): dst = out / "exact.mp4" assert T.trim_exact(CLIP, dst, 1.0, 2.0).returncode == 0 first, score = T.nearest_source_frame(CLIP, dst, search=range(20, 41)) assert first == 30 and score < 1.0 # frame 30 = 1.000 s assert T.frame_count(dst) == 60 assert abs(T.duration(dst) - 2.0) < 0.02 def test_audio_matches_video_after_exact_trim(out): dst = out / "exact-av.mp4" T.trim_exact(CLIP, dst, 1.0, 2.0) s = {x["codec_type"]: float(x["duration"]) for x in T.probe(dst)["streams"]} assert abs(s["audio"] - s["video"]) < 0.05 def test_snapping_makes_stream_copy_clean(out): start = T.snap_to_keyframe(CLIP, 2.5) assert start == 2.2 dst = out / "snap.mp4" T.trim_copy(CLIP, dst, start, 2.0) first, score = T.nearest_source_frame(CLIP, dst, search=range(55, 80)) assert first == 66 and score < 0.5 # 2.2 s * 30 fps assert abs(T.frame_count(dst) - 2.0 * FPS) <= 2 def test_single_keyframe_clip_cannot_be_copy_cut(out): dst = out / "one-gop.mp4" T.trim_copy(ONE_GOP, dst, 1.5, 1.0) first, _ = T.nearest_source_frame(ONE_GOP, dst, search=range(0, 60)) assert first == 0 # whole GOP kept from the start @pytest.mark.parametrize("input_seek,expected", [(True, 3.0), (False, 2.0)]) def test_to_depends_on_where_ss_is(out, input_seek, expected): dst = out / f"to-{input_seek}.mp4" T.trim_to(CLIP, dst, 1.0, 3.0, input_seek=input_seek) assert abs(T.duration(dst) - expected) < 0.05 $ pytest -v test_trim.py test_trim.py::test_fixture_keyframes PASSED test_trim.py::test_stream_copy_starts_at_previous_keyframe PASSED test_trim.py::test_reencode_starts_on_requested_frame PASSED test_trim.py::test_audio_matches_video_after_exact_trim PASSED test_trim.py::test_snapping_makes_stream_copy_clean PASSED test_trim.py::test_single_keyframe_clip_cannot_be_copy_cut PASSED test_trim.py::test_to_depends_on_where_ss_is[True-3.0] PASSED test_trim.py::test_to_depends_on_where_ss_is[False-2.0] PASSED 8 passed in 15.14s Which one to use
- Exact frame matters (a cut on an action, a loop point, a clip that must be exactly N frames): re-encode.
- Speed matters and the start can move: snap to a keyframe, then
-c copy. - Never use a bare
-ss … -c copyand assume the start is where you asked. Check the keyframes first. - If you need
-to, know which side of-iyour-ssis on, or use-t.
Limits
- Results are for FFmpeg 9.0.1 with MP4/H.264 input. Older builds and other containers handle copy-trims differently (some write an edit list that hides the extra frames in some players but not others), which is exactly why the tests decode frames instead of trusting the command.
- Frame matching by small grayscale images works when nearby frames differ. On a static shot, several frames will score the same, so check
frame_countand timestamps instead. - Re-encoding changes bitrate and quality. Pin
-crfand-presetin the function so batches stay consistent.
Generate a little extra, then trim
AI video tools give you whole seconds, and the best moment rarely starts at 0.0 s. A practical workflow is to generate slightly longer than you need and trim with the exact method above. On Voor's Vidu Q3 generator the duration slider runs from 1 to 16 seconds, and the default 5-second 360p draft was quoted at 61 credits on 3 November 2026. That's enough to pick the best two-second window with trim_exact and keep the tests green.


