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.
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:
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";
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:
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
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.
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"
228 frames identicalThe 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:
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; done41d023f8dfee367c4e74f1e13dae7ff8 - 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.