TL;DR
-c copycan only cut on keyframes, so your 12.4s trim starts wherever the last keyframe was.
We'll build asmart-trim
script that probes keyframe positions withffprobe, re-encodes only the head and tail fragments,
stream copies everything between them, and concatenates the three. Frame accurate output, encoding
cost proportional to two GOPs instead of the whole file.
Tested with FFmpeg 9.0 "Lei" (released 2026-08-04) and Node 22.x. The JS is ESM, so put
"type": "module" in your package.json before running any of it. Everything here also works on
FFmpeg 7.x and 8.x; nothing we use is new.
The problem, in two commands 🎬
# fast, and wrong
ffmpeg -ss 12.4 -i input.mp4 -t 20 -c copy fast.mp4
ffprobe -v error -show_entries format=start_time,duration -of default=nw=1 fast.mp4
# start_time=0.000000
# duration=20.388000 <- we asked for 20, starting at 12.4
The clip is long by the distance from our requested start back to the previous keyframe, and every
frame in it is shifted earlier than the user asked for.
Stream copy moves compressed packets without decoding them. Most frames in a compressed stream only
describe the difference from their neighbors, so the only place you can start is a keyframe. FFmpeg
snaps back to the nearest preceding one, and your clip starts early.
# accurate, and slow on a long source
ffmpeg -ss 12.4 -i input.mp4 -t 20 -c:v libx264 -crf 20 -c:a aac slow.mp4
We want the accuracy of the second and roughly the cost of the first.
1. Look at your keyframes first
Before writing any code, find out how bad the problem is for your content:
ffprobe -v error -select_streams v:0 \
-show_entries packet=pts_time,flags \
-of csv=print_section=0 input.mp4 | grep 'K' | head -20
0.000000,K__
2.002000,K__
4.004000,K__
6.006000,K__
Two second GOPs here, so worst-case error is about two seconds. Screen recorders and some camera
output emit keyframes on scene change only, and there the gaps can be 30 seconds or more. That
distribution is the real spec for your trim feature.
2. Find the keyframes bracketing our cut
// keyframes.js
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
export async function keyframeTimes(input) {
const { stdout } = await run('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'packet=pts_time,flags',
'-of', 'csv=print_section=0',
input
], { maxBuffer: 64 * 1024 * 1024 });
return stdout.trim().split('\n')
.map(l => l.split(','))
.filter(([, flags]) => flags && flags.includes('K'))
.map(([t]) => Number(t))
.filter(Number.isFinite)
.sort((a, b) => a - b);
}
// first keyframe at or after t
export const kfCeil = (kfs, t) => kfs.find(k => k >= t - 1e-6) ?? null;
// last keyframe at or before t
export const kfFloor = (kfs, t) => [...kfs].reverse().find(k => k <= t + 1e-6) ?? null;
⚠️ Note: on a two hour file this reads every packet header. It is fast (no decoding) but not free.
Cache the result per asset; keyframe positions never change for a given file.
3. Probe the source so the edges match
The re-encoded fragments have to be concat-compatible with the copied middle: same codec, resolution,
pixel format, and audio parameters. So read them off the source instead of hardcoding.
// probe.js
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
export async function probe(input) {
const { stdout } = await run('ffprobe', [
'-v', 'error', '-print_format', 'json',
'-show_format', '-show_streams', input
]);
const j = JSON.parse(stdout);
const v = j.streams.find(s => s.codec_type === 'video');
const a = j.streams.find(s => s.codec_type === 'audio');
if (!v) throw new Error('no video stream');
const [num, den] = (v.r_frame_rate ?? '0/1').split('/').map(Number);
return {
duration: Number(j.format.duration),
width: v.width, height: v.height,
pixFmt: v.pix_fmt,
fps: den ? num / den : null, // "30000/1001" -> 29.97, no eval()
vCodec: v.codec_name,
aCodec: a?.codec_name ?? null,
aRate: a ? Number(a.sample_rate) : null,
aChannels: a?.channels ?? null
};
}
4. The three-piece cut ✂️
Given a requested [start, end]:
-
head:
startto the next keyframe afterstart, re-encoded -
middle: that keyframe to the last keyframe before
end, stream copied -
tail: that keyframe to
end, re-encoded
If start and end fall inside the same GOP there is no middle, and we just re-encode the whole
(short) span.
// smart-trim.js
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { writeFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { keyframeTimes, kfCeil, kfFloor } from './keyframes.js';
import { probe } from './probe.js';
const run = promisify(execFile);
const ff = args => run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...args]);
// The head/tail have to concat-demux with the copied middle, so they must come out
// in the same codec the source is already in. Extend this map as your library needs.
const ENCODER_FOR = {
h264: ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18'],
hevc: ['-c:v', 'libx265', '-preset', 'veryfast', '-crf', '22'],
vp9: ['-c:v', 'libvpx-vp9', '-crf', '30', '-b:v', '0'],
av1: ['-c:v', 'libsvtav1', '-preset', '8', '-crf', '30'],
};
function encodeArgs(meta) {
const venc = ENCODER_FOR[meta.vCodec];
if (!venc) throw new Error(`no encoder mapped for source codec ${meta.vCodec}`);
const a = [
...venc,
'-pix_fmt', meta.pixFmt,
'-vf', `scale=${meta.width}:${meta.height}`
];
if (meta.aCodec) {
a.push('-c:a', 'aac', '-ar', String(meta.aRate), '-ac', String(meta.aChannels), '-b:a', '192k');
} else {
a.push('-an');
}
return a;
}
export async function smartTrim(input, start, end, output) {
const [kfs, meta] = await Promise.all([keyframeTimes(input), probe(input)]);
const work = await mkdtemp(path.join(tmpdir(), 'trim-'));
const midStart = kfCeil(kfs, start);
const midEnd = kfFloor(kfs, end);
const hasMiddle = midStart !== null && midEnd !== null && midEnd - midStart > 0.05;
try {
if (!hasMiddle) {
// start and end share a GOP: one short re-encode, done
await ff(['-ss', String(start), '-i', input, '-t', String(end - start),
...encodeArgs(meta), output]);
return { mode: 'reencode-only', reencodedSeconds: end - start };
}
const parts = [];
if (midStart - start > 0.01) {
const head = path.join(work, 'head.mp4');
await ff(['-ss', String(start), '-i', input, '-t', String(midStart - start),
...encodeArgs(meta), head]);
parts.push(head);
}
const mid = path.join(work, 'mid.mp4');
await ff(['-ss', String(midStart), '-i', input, '-t', String(midEnd - midStart),
'-c', 'copy', '-avoid_negative_ts', 'make_zero', mid]);
parts.push(mid);
if (end - midEnd > 0.01) {
const tail = path.join(work, 'tail.mp4');
await ff(['-ss', String(midEnd), '-i', input, '-t', String(end - midEnd),
...encodeArgs(meta), tail]);
parts.push(tail);
}
const list = path.join(work, 'concat.txt');
await writeFile(list, parts.map(p => `file '${p}'`).join('\n'));
await ff(['-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy',
'-movflags', '+faststart', output]);
return {
mode: 'hybrid',
reencodedSeconds: (midStart - start) + (end - midEnd),
copiedSeconds: midEnd - midStart
};
} finally {
await rm(work, { recursive: true, force: true });
}
}
$ node -e "import('./smart-trim.js').then(m=>m.smartTrim('input.mp4',12.4,32.4,'out.mp4').then(console.log))"
{ mode: 'hybrid', reencodedSeconds: 1.982, copiedSeconds: 18.018 }
$ ffprobe -v error -show_entries format=duration -of csv=p=0 out.mp4
20.033000
Twenty seconds requested, twenty seconds delivered to within a frame, and only about two seconds of
video actually went through an encoder. The remaining eighteen were copied. (The output lands a frame
or two long because each fragment is rounded to a whole frame; if you need exact durations downstream,
clamp during the concat step.)
5. The errors you will hit 🐛
Non-monotonous DTS in output stream during concat. Almost always timestamps from the copied
middle. -avoid_negative_ts make_zero on the middle segment (already in the code above) fixes the
common case. If it persists, add -fflags +genpts to the concat step.
Audio drift at the seams. The audio encoder's frame size does not line up with the video frame
boundary, so each fragment gets a few milliseconds of padding. Three fragments means it can add up
audibly. The fix is to re-encode audio for the whole output instead of copying it in the concat step:
change the final command's -c copy to -c:v copy -c:a aac -b:a 192k. Audio re-encoding on a
20 second clip is cheap.
Could not find codec parameters on the head fragment. Your -ss landed past the end of the
stream, usually from a duration mismatch on a variable frame rate source. Normalize to constant frame
rate on ingest, or clamp end to the probed duration.
6. The cheaper fix, if you control ingest
Everything above exists because your source has unpredictable keyframes. If you also own the encode,
force them at a known interval and the problem mostly disappears:
ffmpeg -i source.mov \
-c:v libx264 -crf 20 -preset medium \
-force_key_frames "expr:gte(t,n_forced*2)" \
-c:a aac -b:a 192k \
mezzanine.mp4
Now every file in your library has a keyframe every two seconds, trimming, segmenting, and clipping
all behave consistently, and you pay a small bitrate penalty once. If you already produce an ABR
ladder you are doing this anyway, so trim against the mezzanine rather than the raw upload.
What's next
- Wire
smartTriminto a job queue. The head/tail encodes are short but they are still CPU work, and you do not want them running inline on a request thread. - The segment muxer (
-f segment -segment_time 60 -reset_timestamps 1) has the exact same keyframe behavior: your "60 second" segments will be 60-ish. If downstream code assumes uniform durations, fix that assumption now. - Skim the FFmpeg 9.0 announcement on ffmpeg.org. Native VVC decoding and more Vulkan acceleration landed, though none of it changes the keyframe constraint, which is a property of compressed video rather than of the tool.
Anyone promising a frame accurate cut with zero re-encoding is either re-encoding behind your back or
shipping the off-by-two-seconds bug.
Top comments (0)