Trim a Video With ffmpeg Without Re-encoding
📅 October 11, 2026 · ⏱️ 15 min read
To trim a video with ffmpeg without re-encoding, put the start and end before the input and copy the streams: ffmpeg -ss START -to END -i in.mp4 -c copy out.mp4. It is fast because nothing is decoded or encoded, but the file can only start on a keyframe, so ffmpeg copies from the keyframe before START and the MP4 marks the extra part to be skipped. To start on the exact frame, re-encode instead: ffmpeg -ss START -to END -i in.mp4 -c:v libx264 -c:a aac out.mp4. That decodes from the keyframe, throws away the frames before START, and encodes a new file that begins exactly there.
ffmpeg -ss 3.5 -to 6.5 -i in.mp4 -c copy out.mp4
ffmpeg -ss 3.5 -to 6.5 -i in.mp4 -c:v libx264 -c:a aac exact.mp4
Every command on this page ran with ffmpeg 4.4.2, the version Ubuntu 22.04 ships, in an empty directory. The input is made by ffmpeg itself, so you can rebuild it. It is a 10-second testsrc2 pattern at 30 fps, encoded with a keyframe every 60 frames (2 s). The frame number is drawn in the middle of every frame by drawtext, using DejaVuSans-Bold.ttf, a font file already installed on this machine. testsrc2 also prints its own timestamp and frame number in the top-left corner. The audio is a 440 Hz tone. The ffprobe line lists the keyframe timestamps.
# A 10-second 640x360 test clip at 30 fps with the frame number burned in,
# a keyframe every 60 frames (2 s), and a 440 Hz tone; then list its keyframes
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "testsrc2=size=640x360:rate=30:duration=10,drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf:text='frame %{frame_num}':fontsize=64:fontcolor=white:box=1:boxcolor=black:x=(w-tw)/2:y=(h-th)/2" \
-f lavfi -i "sine=frequency=440:sample_rate=48000:duration=10" \
-c:v libx264 -g 60 -keyint_min 60 -sc_threshold 0 -pix_fmt yuv420p -c:a aac in.mp4
ffprobe -v error -select_streams v -skip_frame nokey \
-show_entries frame=pkt_pts_time -of default=nw=1:nk=1 in.mp4
0.000000
2.000000
4.000000
6.000000
8.000000
The cut asks for 3.5 s to 6.5 s, which is frame 105 up to frame 195. The nearest keyframe before the start is at 2.000000 s (frame 60), and the next one after it is at 4.000000 s (frame 120).
Where each cut really starts, measured
Three ways to cut the same 3 seconds: -ss before -i with -c copy (out.mp4, from the answer above), -ss after -i with -c copy (after.mp4), and -ss before -i with a re-encode (exact.mp4). For each file, the first ffprobe line is the timestamp and picture type of the first frame it decodes. The next two lines give each stream’s start time, duration, the frames that decode, and the packets stored in the file.
ffmpeg -hide_banner -loglevel error -y -i in.mp4 -ss 3.5 -to 6.5 -c copy after.mp4
for f in out.mp4 after.mp4 exact.mp4; do
echo "$f"
ffprobe -v error -select_streams v -show_entries frame=pkt_pts_time,pict_type -of csv=p=0 "$f" | sed -n 1p
ffprobe -v error -count_frames -count_packets -of csv=p=0 \
-show_entries stream=codec_type,start_time,duration,nb_read_frames,nb_read_packets "$f"
done
out.mp4
0.000000,B
video,0.000000,3.100000,92,137
audio,0.000000,3.006667,141,188
after.mp4
0.500000,I
video,0.500000,2.566667,77,77
audio,0.020000,2.986667,140,140
exact.mp4
0.000000,I
video,0.000000,3.000000,90,90
audio,0.000000,3.000000,141,142
Which frame is on screen first? The number in the picture answers that, but a script can check it too. This one shrinks each output’s first frame to 160x90 grey and compares it with every frame of in.mp4. hidden is the first frame of out.mp4 when the MP4 edit list is ignored (more on that below).
# Which source frame does each output open on? Compare its first frame,
# shrunk to 160x90 grey, with every frame of in.mp4
ffmpeg -v error -i in.mp4 -vf scale=160:90,format=gray -f rawvideo src.gray
for f in out after exact; do
ffmpeg -v error -y -i $f.mp4 -frames:v 1 -vf scale=160:90,format=gray -f rawvideo $f.gray
done
ffmpeg -v error -y -ignore_editlist 1 -i out.mp4 -frames:v 1 -vf scale=160:90,format=gray -f rawvideo hidden.gray
python3 - <<'EOF'
size = 160 * 90
src = open('src.gray', 'rb').read()
frames = [src[i:i + size] for i in range(0, len(src), size)]
for name in ['out', 'hidden', 'after', 'exact']:
first = open(name + '.gray', 'rb').read()
diffs = sorted((sum(abs(a - b) for a, b in zip(first, f)) / size, n) for n, f in enumerate(frames))
(d0, n0), (d1, n1) = diffs[0], diffs[1]
print(f'{name}: source frame {n0} (mean diff {d0:.2f}; next best {n1} at {d1:.2f})')
EOF
out: source frame 105 (mean diff 0.00; next best 106 at 4.08)
hidden: source frame 60 (mean diff 0.00; next best 59 at 3.20)
after: source frame 120 (mean diff 0.00; next best 119 at 3.71)
exact: source frame 105 (mean diff 0.30; next best 106 at 4.17)
The two copy cuts match their source frame exactly (a mean difference of 0.00), because they decode the very same packets. The re-encode is a new encoding of frame 105, so it is close (0.30) but not identical, and still far closer to 105 than to its neighbour (4.17).
| Cut | First frame timestamp | First frame shown | Video duration | Frames shown / packets stored |
|---|---|---|---|---|
-ss before -i, -c copy | 0.000000 s | frame 105 | 3.100000 s | 92 / 137 |
-ss after -i, -c copy | 0.500000 s | frame 120 | 2.566667 s | 77 / 77 |
-ss before -i, re-encode | 0.000000 s | frame 105 | 3.000000 s | 90 / 90 |
The PNGs below are the first frame of each file, written by this block. The hash lines are the files committed to this page.
ffmpeg -v error -y -i out.mp4 -frames:v 1 row-copy-input-seek.png
ffmpeg -v error -y -ignore_editlist 1 -i out.mp4 -frames:v 1 row-copy-hidden-keyframe.png
ffmpeg -v error -y -i after.mp4 -frames:v 1 row-copy-output-seek.png
ffmpeg -v error -y -i exact.mp4 -frames:v 1 row-reencode.png
sha256sum row-*.png
edef13a088f24b3ee224488000991e297dc907e4cd4391da7f4d66eda3d280f2 row-copy-hidden-keyframe.png
7e914bccbffee25cfbe0f220c6392c74eedf3e11e04a5bb0ecb3bf04c01df59e row-copy-input-seek.png
540930ac87c463d7f90f1af958e2d8fdb332f2028b7a52a6bb4a464d637de22a row-copy-output-seek.png
173580ea2c7374d35bc878c81234b6841e3edabf0a613ad3be6f3e8db4df37cf row-reencode.png
-ss before -i, with -c copy

This is the answer command, and it looks right: the first frame shown is frame 105, at 0.000000 s. The file is not what it looks like, though. It stores 137 video packets but decodes only 92 frames. The 45 extra packets are frames 60 to 104: ffmpeg copied from the keyframe at 2 s, because frame 105 cannot be decoded without it. The documentation for -ss says so: “When doing stream copy or when -noaccurate_seek is used, it will be preserved.” The first frame decoded is a B-frame (B), which is only possible because its keyframe is in the file, hidden. The end moved too: 92 frames came out where 90 were asked for, and the video stream is 3.100000 s long against 3.0 requested.
-ss after -i, with -c copy

Placed after -i, -ss is an output option: ffmpeg “decodes but discards input until the timestamps reach position”. With -c copy, nothing is decoded, so it drops packets until 3.5 s and then waits for the next keyframe, at 4 s. The video starts 15 frames late, at frame 120, and its first timestamp is 0.500000 s. The file has no hidden packets (77 stored, 77 shown), and the video is 2.566667 s long, not 3.
-ss before -i, re-encoded

The re-encode starts at frame 105, at 0.000000 s, with an I frame. It is exactly 3.000000 s, 90 frames, and every packet is shown. This is the only one of the three that is frame-accurate at both ends. The cost is a full encode and a second generation of compression. -accurate_seek is the default, and the docs say that “this extra segment between the seek point and position will be decoded and discarded”.
No copy cut on this page was frame-accurate. Both started on keyframe material, and one of them hid it.
The hidden keyframe, and audio sync in the copy cut
The out.mp4 copy cut opens on frame 105 because the MP4 muxer wrote an edit list, a note in the file that says which part of each track to play. ffmpeg’s MP4 demuxer has an option to ignore it, ignore_editlist, which the format docs describe as “Ignore any edit list atoms. The demuxer, by default, modifies the stream index to reflect the timeline described by the edit list.” With it on, this is what is in the file:
ffprobe -v error -ignore_editlist 1 -count_packets -of csv=p=0 \
-show_entries stream=codec_type,start_time,duration,nb_read_packets out.mp4
video,0.066667,3.100000,137
audio,0.000000,3.006667,215

Without the edit list, the first frame is frame 60, the keyframe at 2 s. A player that honours the edit list starts at frame 105. One that does not will show 1.5 s of picture you cut away. No player was tested for this page, so it does not say which players do which.
For audio sync, compare each stream’s start_time from the measuring block:
out.mp4(-ssbefore-i, copy): video 0.000000 s and audio 0.000000 s, so the two start together. The audio was copied from early too: 215 audio packets without the edit list, against 142 in the re-encoded 3-second cut. The edit list hides that part as well. The streams end 0.093333 s apart (3.100000 s of video, 3.006667 s of audio).after.mp4(-ssafter-i, copy): video 0.500000 s and audio 0.020000 s. The audio started 0.02 s in, but the video had to wait for the keyframe at 4 s, so the first 0.48 s has sound and no picture.exact.mp4(re-encode): 0.000000 s and 0.000000 s, both 3.000000 s long.
-t or -to
-t is a duration, and -to is an end position. The docs: “-to and -t are mutually exclusive and -t has priority.” Where you put -to matters, because after an input -ss the timestamps start again at 0. You can see that above: exact.mp4 opens at 0.000000 s, not at 3.5. So -to 5.5 before -i stops reading the input at 5.5 s, but -to 5.5 after -i stops writing the output when the output reaches 5.5 s.
ffmpeg -hide_banner -loglevel error -y -ss 3.5 -i in.mp4 -t 2 -c:v libx264 -c:a aac t.mp4
ffmpeg -hide_banner -loglevel error -y -ss 3.5 -to 5.5 -i in.mp4 -c:v libx264 -c:a aac to-in.mp4
ffmpeg -hide_banner -loglevel error -y -ss 3.5 -i in.mp4 -to 5.5 -c:v libx264 -c:a aac to-out.mp4
for f in t.mp4 to-in.mp4 to-out.mp4; do
printf '%s ' "$f"; ffprobe -v error -show_entries format=duration -of csv=p=0 "$f"
done
t.mp4 2.000000
to-in.mp4 2.000000
to-out.mp4 5.500000
-t 2 and -to 5.5 before -i both give the 2 seconds from 3.5 s to 5.5 s. -to 5.5 after -i gave 5.500000 s, everything from 3.5 s to the 9 s mark, which is the classic surprise. If you keep -ss before -i, put -to there too, or use -t.
Trim by frame number
To cut by frame number instead of time, use the trim filter. That means a re-encode, because filters work on decoded frames. The trim docs define start_frame as “The number of the first frame that should be passed to the output” and end_frame as “The number of the first frame that should be dropped”. The filter “does not modify the timestamps”, so add setpts=PTS-STARTPTS to make the output start at 0. Audio has no frame numbers in this sense, so atrim cuts it by time, 105 / 30 = 3.5 s to 195 / 30 = 6.5 s. The last line hashes the decoded video of this file and of exact.mp4.
ffmpeg -hide_banner -loglevel error -y -i in.mp4 \
-vf "trim=start_frame=105:end_frame=195,setpts=PTS-STARTPTS" \
-af "atrim=start=3.5:end=6.5,asetpts=PTS-STARTPTS" \
-c:v libx264 -c:a aac frames.mp4
ffprobe -v error -count_packets -of csv=p=0 \
-show_entries stream=codec_type,start_time,duration,nb_read_packets frames.mp4
for f in frames.mp4 exact.mp4; do ffmpeg -v error -i "$f" -map 0:v -f md5 -; done
video,0.000000,3.000000,90
audio,0.000000,3.000000,142
MD5=03fb4571ee89932e465014ee42c7860d
MD5=03fb4571ee89932e465014ee42c7860d
Frames 105 to 194 came out, 90 frames in 3.000000 s, and the decoded picture is identical to the -ss 3.5 -to 6.5 re-encode. On a constant-frame-rate clip, cutting by frame number and cutting by time land on the same frames. Use start_frame when you know the frame numbers, for example from a tool that shows them. Without setpts, the output would keep the original timestamps and start 3.5 s in.
Which one to use for a Short
In the YouTube Shorts automation on a Raspberry Pi pipeline, every clip is trimmed to the length of its narration and then re-encoded anyway for the final 1080x1920 file. When a clip is going to be re-encoded, the copy cut saves nothing. Trim with the re-encode, or with trim, and the cut lands where the narration needs it. The Shorts ffmpeg command builder does it that way: its “Cut at 60 s if the clip is longer” box adds -t 60 to a command that already re-encodes.
-c copy is for when you want a quick, lossless cut and can live with the start moving back to a keyframe. On this clip that is up to 2 s, the keyframe interval. Your camera or encoder decides your interval, and the keyframe list from the first block shows where yours are. If START is a keyframe, a copy cut has nothing to hide. Here is the same copy cut from 4 s, the keyframe at frame 120:
ffmpeg -hide_banner -loglevel error -y -ss 4 -to 6.5 -i in.mp4 -c copy key.mp4
ffprobe -v error -select_streams v -show_entries frame=pkt_pts_time,pict_type -of csv=p=0 key.mp4 | sed -n 1p
ffprobe -v error -count_frames -count_packets -of csv=p=0 \
-show_entries stream=codec_type,start_time,duration,nb_read_frames,nb_read_packets key.mp4
ffmpeg -v error -y -i key.mp4 -frames:v 1 -vf scale=160:90,format=gray -f rawvideo key.gray
cmp key.gray after.gray && echo "key.mp4 opens on the same frame as after.mp4"
0.000000,I
video,0.000000,2.566667,77,77
audio,0.000000,2.506667,118,121
key.mp4 opens on the same frame as after.mp4
It opens on an I frame at 0.000000 s, frame 120 like after.mp4, and every one of its 77 video packets is shown. The end still runs long: 2.5 s at 30 fps is 75 frames, and 77 came out, the same 2 extra frames as in out.mp4. A copy cut is only as exact as the keyframes and packets around it.
Where the docs and ffmpeg 4.4.2 differ
The -ss, -t and -to text in today’s ffmpeg docs and the trim text in the filter docs are word for word the same as the manual pages installed with 4.4.2. One difference did show up, in ffprobe. On 4.4.2, a frame’s timestamp is pkt_pts_time, and frame=pts_time prints empty lines. Today’s ffprobe output schema lists pts_time for a frame and has no pkt_pts_time. With a newer ffprobe, use frame=pts_time in the commands above.
What this test does not tell you
- One clip, one encoder. The keyframe interval was set to 2 s with
-g 60. A phone or camera file has its own interval, and it may vary. The size of the jump is the distance to your keyframes, not to these. - No player was tested. The edit-list behaviour is shown with ffmpeg and ffprobe only.
- Audio sync was checked by start times only. The page compares where each stream starts, not what is heard.
- ffmpeg 4.4.2 only. Other versions may place the copy cut’s start and end packets differently.
Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.