Recursive AI

Trim a video in C# without re-encoding (frame-accurate)

All onemedia recipes

Media.Trim cuts an MP4, MOV, 3GP or Matroska/WebM file from one time to another and copies the compressed frames, so it is fast and loses no quality. By default the cut is frame-accurate: the clip shows exactly the frames from the start time, even when that falls between keyframes. Media.Split and Media.Concat cut a file into parts and join files back together the same way. The input is the Sintel trailer (CC BY 3.0, © copyright Blender Foundation | durian.blender.org), H.264 at 24 fps with AAC audio.

Terminal
dotnet new console -o TrimVideo && cd TrimVideo
dotnet add package RecursiveAI.OneMedia --version 0.3.0-preview.1
curl -L -o sintel.mp4 https://download.blender.org/durian/trailer/sintel_trailer-480p.mp4

Put this in Program.cs and run dotnet run:

C#
using RecursiveAI.OneMedia;
using RecursiveAI.OneMedia.Editing;
 
TimeSpan from = TimeSpan.FromSeconds(10.5), to = TimeSpan.FromSeconds(20);
 
// Frame-accurate (the default): the clip starts at the first frame at or after 10.5 s.
ClipReport clip = Media.Trim("sintel.mp4", "clip.mp4", from, to);
Print("clip.mp4", clip);
 
// Keyframe mode: starts at the keyframe at or before 10.5 s, nothing hidden.
ClipReport keyframeClip = Media.Trim("sintel.mp4", "clip-keyframe.mp4", from, to, TrimMode.Keyframe);
Print("clip-keyframe.mp4", keyframeClip);
 
// Split at 15 s and 30 s (on the keyframes at or before them); {0:00} is the part number.
IReadOnlyList<ClipReport> parts = Media.Split("sintel.mp4", "part{0:00}.mp4",
    [TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30)]);
for (int i = 0; i < parts.Count; i++) Print($"part{i + 1:00}.mp4", parts[i]);
 
// Join the parts again.
ClipReport joined = Media.Concat("joined.mp4", "part01.mp4", "part02.mp4", "part03.mp4");
Print("joined.mp4", joined);
 
static void Print(string name, ClipReport r)
{
    Console.WriteLine($"{name}: {Seconds(r.Duration)} from {Seconds(r.SourceStart)} of the source, "
                      + $"{r.HiddenVideoPackets} lead-in frames hidden");
    foreach (string note in r.Notes) Console.WriteLine($"  {note}");
}
 
static string Seconds(MediaTime t) => $"{t.ToTimeSpan().TotalSeconds:0.000} s";
Text
clip.mp4: 9.500 s from 10.500 s of the source, 28 lead-in frames hidden
clip-keyframe.mp4: 10.667 s from 9.333 s of the source, 0 lead-in frames hidden
  The clip starts on a keyframe 1166.667 ms before the requested start.
part01.mp4: 13.917 s from 0.000 s of the source, 0 lead-in frames hidden
part02.mp4: 13.500 s from 13.917 s of the source, 0 lead-in frames hidden
part03.mp4: 24.792 s from 27.417 s of the source, 0 lead-in frames hidden
joined.mp4: 52.208 s from 0.000 s of the source, 0 lead-in frames hidden

The frame-accurate clip is 9.5 s long and starts at 10.5 s, between two keyframes. H.264 can only start decoding at a keyframe, so the clip holds the 28 frames from the keyframe before 10.5 s and an edit list hides them: a player decodes them and shows the clip from the requested frame. The audio is cut to the sample, with one AAC packet of pre-roll. In keyframe mode nothing is hidden, so the clip starts 1.167 s early, at the keyframe, and the note says so. Split parts start on keyframes too; Concat joins files whose tracks match (codec, configuration, frame size), and refuses others with EditRefusalReason.NeedsReencode.

Check it with ffprobe

Frames decoded from each file (nb_read_frames, edit lists applied) against packets stored (nb_read_packets), and the durations:

Terminal
for f in sintel.mp4 clip.mp4 clip-keyframe.mp4 part01.mp4 part02.mp4 part03.mp4 joined.mp4; do
  echo "$f $(ffprobe -v error -count_frames -count_packets -select_streams v:0 -show_entries stream=nb_read_frames,nb_read_packets:format=duration -of csv=p=0 $f | tr '\n' ' ')"
done
Text
sintel.mp4 1253,1253 52.208333
clip.mp4 228,258 9.500000
clip-keyframe.mp4 256,258 10.666667
part01.mp4 334,334 13.916667
part02.mp4 324,324 13.500000
part03.mp4 595,595 24.791667
joined.mp4 1253,1253 52.208333

clip.mp4 decodes to 228 frames, exactly 9.5 s at 24 fps, from 258 stored packets: the 28 hidden lead-in frames plus two later frames the last shown ones refer to. The 228 frames are the source’s frames 10.5 s to 20 s, picture for picture: their decoded hashes equal those of the same range decoded from the original.

Terminal
ffmpeg -v error -i sintel.mp4 -vf trim=start=10.5:end=20 -an -f framemd5 - | grep -v '^#' | cut -d, -f6 > source.md5
ffmpeg -v error -i clip.mp4 -an -f framemd5 - | grep -v '^#' | cut -d, -f6 > clip.md5
cmp source.md5 clip.md5 && echo "$(wc -l < clip.md5) frames identical"
Text
228 frames identical

The three parts hold every packet once, so joining them gives back the source’s 1,253 frames, and the joined file’s video and audio packets equal the original’s:

Terminal
for s in v a; do for f in sintel.mp4 joined.mp4; do ffprobe -v error -select_streams $s -show_entries packet=size,flags -show_data_hash MD5 -of csv $f | md5sum; done; done
Text
41d023f8dfee367c4e74f1e13dae7ff8  -
41d023f8dfee367c4e74f1e13dae7ff8  -
46c2308b9f786f74f05770bc3e01f67f  -
46c2308b9f786f74f05770bc3e01f67f  -

What frame-accurate means here

The trim rules define it: the first shown frame is the first video frame whose presentation time is at or after the start, decoding starts at the last keyframe before it, and the frames in between are written and hidden by the video track’s edit list. The last shown frame is the last one that starts before the end time, which is exclusive.

Whether the hidden frames stay hidden is up to the player. The project’s own playback check (a clip cut from frame 53 to 143 of a 25 fps source): Chrome and WebKit start at the requested frame and play exactly 3.600 s; Firefox ignores the edit list’s start and shows from the keyframe (frame 48), playing 3.84 s. A clip that must look the same everywhere is cut with TrimMode.Keyframe.

Limits

  • Containers. MP4, MOV, 3GP, M4A (fragmented MP4 as input) and Matroska/WebM; the output has the input’s container. Other formats are refused with UnsupportedSource; remux them to MP4 first.
  • Matroska. It has no edit lists, so a frame-accurate start between keyframes is written with negative timestamps for the lead-in. For H.264, HEVC and other parser-timed video that start is refused (ExactStartNotSupported) because FFmpeg-based players, Chrome among them, can’t play it: cut those in keyframe mode, or write MP4. VP8, VP9 and AV1 cut frame-accurately. A Matroska trim drops tags, chapters and attachments.
  • One video track. A cut follows the first video track; a second one is dropped and a note says so.
  • Concat. Files must match track by track; the MP4 muxer writes one sample description per track, so files with different codec setups are refused, never re-encoded.
  • Splits have no audio pre-roll, so a few milliseconds of audio at each cut are not played.