Video
Egami.Video reads and writes video with Egami rasters as frames, so resizing, color and drawing from Egami work on video with no copying. It is fully managed and ships only royalty-free codecs:
- Containers: WebM and Matroska, AVI (with OpenDML for files over 1 GB), IVF and YUV4MPEG2, read and written, and headerless raw frames.
- Codecs: AV1 (8, 10 and 12 bit, 4:0:0 to 4:4:4, film grain, equal to libaom frame for frame), VP9 (all four profiles, 8, 10 and 12 bit, equal to libvpx frame for frame), VP8 (with WebM alpha) and Motion JPEG decode; VP8 (see Encoding video), Motion JPEG and uncompressed encode. Audio codecs such as FLAC, Opus, ALAC and AC-3 (see Audio); other tracks are copied through untouched. H.264 decodes with the opt-in Egami.Video.H264 package (see H.264).
Probing a file
MediaFile.Probe reads the headers and returns a MediaInfo: the container's FormatName, the Duration, and the
Tracks. A TrackInfo gives a track's Number, Kind (a TrackKind) and CodecId (see CodecIds); a VideoTrackInfo
adds its size, bit depth, chroma format, FieldOrder, frame rate, color and HDR metadata, and an AudioTrackInfo its sample
rate and channels. MediaMetadata holds the tags, the chapters (ChapterInfo) and the attachments (AttachmentInfo).
// What a file holds, without decoding: its container, duration, tracks, tags, chapters and attachments.
MediaInfo info = MediaFile.Probe("metadata.mkv");
Console.WriteLine($"{info.FormatName}, {info.Duration?.ToTimeSpan()}");
foreach (TrackInfo track in info.Tracks)
{
string details = track switch
{
VideoTrackInfo v => $"{v.Width}x{v.Height} {v.BitDepth}-bit {v.ChromaFormat}, {v.FieldOrder}",
AudioTrackInfo a => $"{a.SampleRate} Hz, {a.Channels} channels",
_ => "",
};
Console.WriteLine($"#{track.Number} {track.Kind} {track.CodecId} {details}");
}
MediaMetadata metadata = info.Metadata;
foreach (ChapterInfo chapter in metadata.Chapters)
{
Console.WriteLine($"{chapter.Start.ToTimeSpan()} {chapter.Title}");
}
foreach (AttachmentInfo attachment in metadata.Attachments)
{
File.WriteAllBytes(attachment.Name, attachment.Data.ToArray());
}
Reading frames
VideoReader opens a file and decodes its first video track. ReadFrames<TPixel> gives VideoFrame<TPixel> frames of RGB
pixels, each with its timestamp, duration and color; dispose each frame when done with it.
// Every frame as RGB pixels: VP9, VP8, Motion JPEG or raw, from WebM, Matroska, AVI, IVF or YUV4MPEG2.
using VideoReader reader = VideoReader.Open("movie.webm");
Console.WriteLine($"{reader.Track.Width}x{reader.Track.Height} at {reader.Track.FrameRate?.ToDouble()} fps");
foreach (VideoFrame<Rgba8> frame in reader.ReadFrames<Rgba8>())
{
using (frame)
{
frame.Raster.Save($"frame-{frame.Timestamp.ToTimeSpan().TotalMilliseconds:0000}.png");
}
}
ReadFrame and ReadFrames without a pixel type give a YuvVideoFrame: the codec's own planes as a YuvRaster, at the
stream's subsampling and bit depth, with no conversion. Both kinds of frame are a VideoFrame, which is what encoders and
writers take.
// The codec's own planes, with no conversion: luma and chroma at the stream's subsampling and bit depth.
using VideoReader reader = VideoReader.Open("movie.webm");
using YuvVideoFrame first = reader.ReadFrame()!;
YuvRaster planes = first.Raster;
Console.WriteLine($"{planes.ChromaFormat} {planes.BitDepth}-bit, color {first.Color}");
VideoFrame frame = first; // the base type encoders and writers take
using Raster<Rgb8> rgb = first.ToRaster<Rgb8>();
VideoReaderOptions picks the track, the codec's VideoDecoderOptions (such as DecodeAlpha for WebM alpha), and what a
broken frame does, as a DecodeErrorMode: Skip (the default) reports it and drops frames up to the next keyframe, Conceal
repeats the last good frame, and Throw throws.
// How a reader decodes: which track, what a broken frame does (Skip, the default, drops it up to the next keyframe;
// Conceal repeats the last good frame; Throw throws) and the codec's own options.
VideoReaderOptions options = new()
{
TrackNumber = 1,
Errors = DecodeErrorMode.Conceal,
Decoder = new VideoDecoderOptions { DecodeAlpha = false },
};
using VideoReader reader = VideoReader.Open("alpha.webm", options);
using YuvVideoFrame frame = reader.ReadFrame()!;
// Opening reads the headers; the async forms do that off the calling thread.
using VideoReader reader = await VideoReader.OpenAsync("movie.webm", VideoReaderOptions.Default);
using YuvVideoFrame? frame = reader.ReadFrame();
Seeking
Seek takes a MediaTime and lands on the frame shown at that time, decoding from the keyframe before it; it is exact over
VP9's hidden and show-existing frames too. SeekMode.Keyframe stops at the keyframe instead, which is faster.
// An exact seek lands on the frame shown at the time; SeekMode.Keyframe stops at the keyframe before it, which is faster.
using VideoReader reader = VideoReader.Open("movie.webm");
reader.Seek(MediaTime.FromSeconds(0.5));
using YuvVideoFrame atHalf = reader.ReadFrame()!;
reader.Seek(MediaTime.FromSeconds(0.5), SeekMode.Keyframe);
using YuvVideoFrame keyframe = reader.ReadFrame()!;
Writing a clip
VideoWriter encodes images or frames into a file or stream; VideoWriterOptions sets the codec, the frame rate (a
Rational), the quality, the chroma format, the metadata and other tracks to copy in.
// Images into a Motion JPEG clip; the container comes from the extension.
VideoWriterOptions options = new() { Codec = VideoCodecs.MotionJpeg, FrameRate = new Rational(25, 1), Quality = 85 };
using (VideoWriter writer = VideoWriter.Create("duck.mkv", options))
{
for (int i = 0; i < 10; i++)
{
writer.WriteFrame(duck);
}
}
// Or into a stream, in a format by name.
using MemoryStream avi = new();
using (VideoWriter writer = VideoWriter.Create(avi, "avi", options, leaveOpen: true))
{
writer.WriteFrame(duck);
}
Remuxing
MediaFile.Remux copies a file's packets into another container without decoding. For more control, an IDemuxer reads
EncodedPackets (data, timestamps, the keyframe flag and PacketAdditions such as WebM alpha) and an IMuxer writes them.
// Copy the packets into another container without decoding; the sound and the tags come along.
long packets = MediaFile.Remux("camera.avi", "camera.mkv");
// Or packet by packet, choosing what goes through.
using IDemuxer demuxer = MediaFile.OpenDemuxer("movie.webm");
using (IMuxer muxer = MediaFile.CreateMuxer("movie.ivf", [demuxer.Info.VideoTrack!]))
{
while (demuxer.ReadPacket() is { } packet)
{
using (packet)
{
// Data, the time and whether it starts a keyframe; WebM alpha travels in Additions.
IReadOnlyList<PacketAddition> additions = packet.Additions;
muxer.WritePacket(packet);
}
}
muxer.Finish();
}
Trimming
MediaFile.Trim cuts the part of a file from a start up to an end into a new file whose timestamps start at 0. The cut's first
frame is the first at or after the start, and it ends after the last frame that starts before the end; a time within half a
tick of a frame, such as the millisecond Matroska keeps, counts as that frame's. TrimMode says what happens when the start
falls between keyframes:
TrimMode.Smart, the default, is frame-exact and encodes as little as it can. It decodes from the keyframe before the start and encodes the frames from the start up to the next keyframe again, as a new group that starts with a keyframe; every packet from that keyframe on is copied byte for byte. The new group matches the stream (codec, profile, size, bit depth, chroma format and color, and for AV1 the sequence header byte for byte, as MP4 and Matroska keep it in the track header) and is coded at the average quantizer of the packets it replaces, so the quality does not jump. A start on a keyframe, and Motion JPEG or uncompressed video, are a plain copy.TrimMode.Keyframedecodes nothing: the cut starts at the keyframe at or before the start, asffmpeg -ss -c copydoes.TrimMode.Reencodeencodes every frame of the cut again, with the same codec at the source's quantizer.
The end needs no encoding: VP8, VP9 and AV1 decode in packet order and code a hidden frame before the frames that show it, so the cut keeps every packet up to the one that shows its last frame and drops a hidden frame after it that nothing shows.
Smart cut falls back where it must and says so in a MediaWarning, in TrimResult.Warnings and through
MediaContext.Warnings: a codec Egami.Video cannot encode (H.264, HEVC) starts at the keyframe (trim-keyframe), as do
frames that change size, and a stream the encoder cannot match (a VP8 version other than 0, 4:4:0 or monochrome AV1, an AV1
sequence header with frame ids or a decoder model) is encoded again whole (trim-reencode). TrimResult tells where the cut
starts in the input, how many frames it holds and how many were encoded again.
Audio and subtitles are copied. Audio is cut at the sample where the format can say so: PCM by slicing its packets, MP4 by its
edit list, and Opus in Matroska and WebM by CodecDelay (with 80 ms of pre-roll) and DiscardPadding. Other audio is cut at the
packet whose middle is nearest (trim-audio-packet). Subtitle cues shown across the cut are clipped to it, and so are
chapters; tags and attachments are kept. A seekable input is read from a few seconds before the start. Trim takes a
Stream in and out, progress and a token as the other jobs do.
// Frame-exact: only the frames from 0.3 s up to the next keyframe are encoded again; every other packet is copied.
TrimResult result = MediaFile.Trim("show.webm", "cut.webm", MediaTime.FromSeconds(0.3), MediaTime.FromSeconds(0.8));
Console.WriteLine($"{result.Frames} frames from {result.Start.Seconds:0.000} s, {result.ReencodedFrames} encoded again");
foreach (MediaWarning warning in result.Warnings)
{
Console.WriteLine(warning.Message);
}
// No decoding at all: the cut starts at the keyframe at or before 0.3 s.
MediaFile.Trim("show.webm", "copy.webm", MediaTime.FromSeconds(0.3), MediaTime.FromSeconds(0.8), TrimMode.Keyframe);
// Streams in and out, such as cloud blobs: a non-seekable input is read from its start.
using Stream source = File.OpenRead("movie.webm");
using Stream upload = File.Create("cut.mkv");
IProgress<MediaProgress> progress = new Progress<MediaProgress>(p => Console.Write($"\r{p.Fraction ?? 0:P0}"));
TrimResult result = MediaFile.Trim(source, upload, "mkv", MediaTime.FromSeconds(1), null, TrimMode.Smart,
tracks: t => t.Kind == TrackKind.Video, leaveOpen: true, progress: progress, cancellationToken: cts.Token);
On the command line, egami-video trim --smart cuts this way, and segment --scenes --exact starts every part on its scene.
Progress, cancellation and streams
MediaFile.Remux and MediaFile.Transcode take an IProgress<MediaProgress> and a CancellationToken as their last
parameters. A MediaProgress report carries the position, the duration, a 0 to 1 Fraction, frames, packets, bytes read and
written, the elapsed time and the speed (media seconds per second, like FFmpeg's speed=). Reports come once the output is
open, then at most every 250 ms, then once more with IsFinal set and Fraction 1. The fraction comes from the time when the
input has a duration, else from the bytes read over the input's length, else it is null; it never goes backwards. The
handler runs on the job's thread; Progress<T> moves it to the UI thread. If the handler throws, the job stops with that
exception. Without a handler, nothing is built or timed.
// Reports arrive once the output is open, then at most every 250 ms, then once more with IsFinal set.
// Progress<T> runs the handler on the caller's synchronization context: the UI thread in WinForms, WPF or MAUI.
IProgress<MediaProgress> progress = new Progress<MediaProgress>(p =>
Console.Write($"\r{p.Position.ToTimeSpan():c} {p.Fraction ?? 0:P0} {p.Frames} frames {p.Speed:0.0}x"));
long packets = MediaFile.Remux("camera.avi", "camera.mkv", progress: progress);
The token is checked before each packet. A cancelled job throws OperationCanceledException and does not finish the
output: a file the job created is deleted, and a stream you passed in keeps what was written so far.
// Ctrl+C stops the job between two packets; the half-written camera.mkv is deleted.
using CancellationTokenSource cts = new();
ConsoleCancelEventHandler onCtrlC = (_, e) =>
{
e.Cancel = true;
cts.Cancel();
};
Console.CancelKeyPress += onCtrlC;
try
{
MediaFile.Remux("camera.avi", "camera.mkv", cancellationToken: cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancelled.");
}
finally
{
Console.CancelKeyPress -= onCtrlC;
}
Both jobs also take a Stream in and a Stream out, with the output container named, so they can read from and write to
cloud storage without a local copy. The input may be non-seekable; only MP4 files with moov after the media need a seekable
one. Matroska, WebM, IVF and YUV4MPEG2 write to a non-seekable stream as they go, MP4 keeps its media in a temporary file and
writes moov first, and AVI needs a seekable stream (NotSupportedException otherwise). leaveOpen keeps both streams open.
The jobs are synchronous and block a thread while the network is slow; run them with Task.Run to keep a UI responsive.
// Any Stream works, seekable or not: a cloud blob's read and upload streams, an HTTP response body, a pipe.
// Files stand in for them here.
using Stream source = File.OpenRead("movie.webm");
using Stream upload = File.Create("movie.mkv");
long packets = MediaFile.Remux(source, upload, "mkv", leaveOpen: true, cancellationToken: cts.Token);
Deinterlacing
Broadcast and camcorder video is often interlaced: each frame holds two fields, the even lines and the odd lines, shown a
field apart in time. Deinterlacer turns such frames into progressive ones: each frame it hands out keeps one field's lines
and makes the other's. DeinterlaceMode.Adaptive, the default, keeps the other field's lines where the picture holds still,
so fine detail stays sharp, and where it moves guesses each missing pixel along the edge through it, held within what the
fields before and after allow, as the published yadif method does. DeinterlaceMode.Bob guesses from the kept field alone:
it never combs, but halves the vertical detail of still parts.
DeinterlaceRate.Frame, the default, gives one frame per frame from the first field in time, so 50i becomes 25p;
DeinterlaceRate.Field gives one per field, at twice the rate and half the duration each, so motion stays as smooth as it was
shot. DeinterlaceFrames.Flagged, the default, deinterlaces the frames that say they are interlaced (H.264 flags each frame;
for other codecs the track's field order counts) and passes the rest unchanged; All deinterlaces every frame. The field
order comes from the frame, then the track, then top field first; DeinterlacerOptions.FieldOrder overrides it.
The deinterlacer is an IVideoFilter: frames go in with Send and come out with TryReceive, like a decoder, and it holds one
frame back to look at the next, so call Flush at the end. It works on the decoder's planes: 8 to 16 bits, any chroma format,
with alpha, each plane on its own lines, so a 4:2:0 field keeps its own chroma rows. On one thread it deinterlaces a 1080i
frame in about 3.3 ms, and it allocates no pixel memory per frame once running.
// Deinterlace a file: interlaced frames come out progressive, one per frame in, and progressive frames pass unchanged.
// The track gives the field order for frames that do not carry their own.
using VideoReader reader = VideoReader.Open("movie.webm");
using Deinterlacer deinterlacer = new(reader.Track, new DeinterlacerOptions
{
Mode = DeinterlaceMode.Adaptive, // or Bob: from each field alone
Rate = DeinterlaceRate.Frame, // or Field: one frame per field, 50i becomes 50p
Frames = DeinterlaceFrames.All, // also frames the decoder does not flag as interlaced
});
VideoWriterOptions options = new() { Codec = VideoCodecs.Ffv1, FrameRate = reader.Track.FrameRate ?? new Rational(25, 1) };
using (VideoWriter writer = VideoWriter.Create("progressive.mkv", options))
{
foreach (YuvVideoFrame frame in reader.ReadFrames())
{
using (frame)
{
deinterlacer.Send(frame);
}
while (deinterlacer.TryReceive(out YuvVideoFrame? output))
{
using (output)
{
writer.WriteFrame(output);
written++;
}
}
}
// The deinterlacer holds one frame back to look at the next; Flush hands it out.
deinterlacer.Flush();
while (deinterlacer.TryReceive(out YuvVideoFrame? output))
{
using (output)
{
writer.WriteFrame(output);
written++;
}
}
}
An edit deinterlaces a clip with Clip.Deinterlace, which is off by default; see Video editing.
Codecs by hand
VideoCodecs finds the codec for a track and creates an IVideoDecoder: send packets, receive frames. VideoCodecs.CreateDecoder<TPixel>
gives an IVideoDecoder<TPixel> that hands out RGB frames.
// A codec by hand: packets in, frames out. VideoCodecs finds the codec for a track's CodecId (CodecIds.Vp9 here).
using IDemuxer demuxer = MediaFile.OpenDemuxer("movie.webm");
VideoTrackInfo track = demuxer.Info.VideoTrack!;
bool isVp9 = track.CodecId == CodecIds.Vp9;
using IVideoDecoder decoder = VideoCodecs.CreateDecoder(track, new VideoDecoderOptions { Errors = DecodeErrorMode.Throw });
int frames = 0;
while (demuxer.ReadPacket() is { } packet)
{
using (packet)
{
decoder.Send(packet);
}
while (decoder.TryReceive(out YuvVideoFrame? frame))
{
using (frame)
{
frames++;
}
}
}
decoder.Flush();
Each codec is a VideoCodec: Av1Codec, Vp9Codec, Vp8Codec, Ffv1Codec, MotionJpegCodec and RawVideoCodec.
// The built-in codecs, for a MediaContext of your own or to create decoders directly.
VideoCodec vp9 = Vp9Codec.Instance, vp8 = Vp8Codec.Instance, mjpeg = MotionJpegCodec.Instance, raw = RawVideoCodec.Instance;
using IVideoDecoder decoder = vp9.CreateDecoder(track, VideoDecoderOptions.Default, MediaContext.Default);
// Decoders that hand out RGB frames instead of planes.
using IVideoDecoder<Rgba8> rgb = VideoCodecs.CreateDecoder<Rgba8>(track);
An IVideoEncoder takes frames and gives packets, with VideoEncoderOptions for the size, frame rate and quality.
// An encoder by hand: frames in, packets out, for a muxer of your own.
VideoEncoderOptions options = new() { Width = duck.Width, Height = duck.Height, FrameRate = new Rational(30, 1), Quality = 90 };
using IVideoEncoder encoder = MotionJpegCodec.Instance.CreateEncoder(options, MediaContext.Default);
using (VideoFrame<Rgb8> frame = new(Raster.Load<Rgb8>("duck.png"), MediaTime.Zero))
{
encoder.Send(frame);
}
encoder.Flush();
List<EncodedPacket> packets = [];
while (encoder.TryReceive(out EncodedPacket? packet))
{
packets.Add(packet);
}
// Uncompressed frames go the same way, losslessly.
using IVideoEncoder rawEncoder = RawVideoCodec.Instance.CreateEncoder(options with { ChromaFormat = ChromaFormat.Yuv444 }, MediaContext.Default);
CodecHeaders.Describe lists a packet's header fields as CodecHeaderFields under the codec's own names, for VP8, VP9, AV1,
FFV1 and Motion JPEG.
// A packet's header fields under the codec's own names, as a hex dump with meaning.
using IDemuxer demuxer = MediaFile.OpenDemuxer("movie.webm");
TrackInfo track = demuxer.Tracks[0];
using EncodedPacket packet = demuxer.ReadPacket()!;
if (CodecHeaders.CanDescribe(track))
{
foreach (CodecHeaderField field in CodecHeaders.Describe(track, packet))
{
Console.WriteLine($"{field.Name} = {field.Value}");
}
}
AV1
Av1Codec decodes AV1 from WebM, Matroska, MP4 and IVF with Egami's AV1 decoder: every profile, 8, 10 and 12 bit, 4:0:0, 4:2:0,
4:2:2 and 4:4:4, film grain, scaled references and scalable streams. Every frame equals libaom's. A packet is a temporal unit;
hidden frames give no frame, and a frame shown later with show_existing_frame comes out then. Frames come out at their own size,
8-bit planes for 8-bit streams and 16-bit planes above, 4:0:0 for monochrome.
- The color comes from the sequence header; the container's fills only what the header leaves unspecified. HDR metadata OBUs
give the frames'
HdrandHdr10Plus(see HDR). VideoDecoderOptions.ApplyFilmGrain = falsegives the frames as coded, without the film grain the stream asks for.- A keyframe is a temporal unit that starts with a shown key frame. A forward key frame, hidden and shown later, is not one, as
FFmpeg flags them too. When a container's keyframe flag disagrees, the stream wins and
Av1.KeyframeFlagMismatchis reported. - A track's av1C (its
CodecPrivate) gives the sequence header when the first unit has none. The IVF reader builds av1C from the first sequence header, and so does the Matroska writer for a track that has none, so IVF files remux to WebM, Matroska and MP4. - Seeking decodes from the shown key frame at or before the time, so exact seeks give the frame decoding from the start gives.
- Broken data follows
VideoReaderOptions.Errors:Skip(the default) reportsAv1.CorruptFrame, orAv1.MissingReferencefor a unit that needs frames from before the first key frame, and resumes at the next shown key frame;Concealrepeats the last good frame in between.
// AV1 from WebM, Matroska, MP4 or IVF reads like any other video: 10 and 12-bit streams give 16-bit planes.
using (VideoReader reader = VideoReader.Open("av1.webm"))
{
foreach (YuvVideoFrame frame in reader.ReadFrames())
{
using (frame)
{
Console.WriteLine($"{frame.Timestamp}: {frame.Width}x{frame.Height}, {frame.Raster.BitDepth} bit {frame.Raster.ChromaFormat}, {frame.Color}");
}
}
}
// The frames as coded, without the film grain the stream asks for; broken units are skipped to the next key frame and
// reported as Av1.CorruptFrame or Av1.MissingReference, or repeat the last good frame with Conceal.
VideoReaderOptions options = new()
{
Decoder = new VideoDecoderOptions { ApplyFilmGrain = false },
Errors = DecodeErrorMode.Conceal,
};
using VideoReader plain = VideoReader.Open("av1.webm", options);
using YuvVideoFrame first = plain.ReadFrame()!;
Raw AV1 streams are containers of their own: Av1ObuFormat reads and writes low-overhead OBU files (.obu, as aomenc --obu
writes and aomdec and dav1d read), and Av1AnnexBFormat the length-delimited files of the specification's Annex B (as the
conformance streams and aomenc --annexb=1 write). Both are detected from their first bytes; neither has timestamps, so frames are
timed at the format's FrameRate, 30 a second unless you register one with another rate. Reading a file and writing it back gives
the same bytes, and IVF, WebM, Matroska and MP4 tracks write to them.
// Raw AV1 streams have no timestamps: frames are timed at the format's frame rate, 30 a second unless you register another.
MediaContext context = MediaContext.Default.WithContainer(new Av1ObuFormat { FrameRate = new Rational(24, 1) });
// An IVF clip to a low-overhead .obu file (what aomdec and dav1d read) and to Annex B, then read back.
MediaFile.Remux("clip-av1.ivf", "clip.obu");
using (IDemuxer demuxer = MediaFile.OpenDemuxer("clip-av1.ivf"))
using (IMuxer annexB = Av1AnnexBFormat.Instance.CreateMuxer(File.Create("clip-annexb.obu"), demuxer.Tracks, MediaContext.Default, leaveOpen: false))
{
while (demuxer.ReadPacket() is { } packet)
{
using (packet)
{
annexB.WritePacket(packet);
}
}
}
using VideoReader reader = VideoReader.Open("clip.obu", context: context);
Console.WriteLine($"{MediaFile.Probe("clip-annexb.obu").FormatName}; {reader.Track.FrameRate} fps");
Av1Packets splits a temporal unit into its frames and merges them back, and builds the av1C record from a sequence header.
// An AV1 packet is a temporal unit: split it into its frames and join them back; build the av1C an MP4 or Matroska
// track needs from its sequence header.
using IDemuxer demuxer = MediaFile.OpenDemuxer("clip-av1.ivf");
using EncodedPacket packet = demuxer.ReadPacket()!;
IReadOnlyList<ReadOnlyMemory<byte>> frames = Av1Packets.SplitFrames(packet.Data);
byte[] unit = Av1Packets.MergeFrames(frames);
byte[]? av1C = Av1Packets.ExtractConfig(packet.Data.Span);
Console.WriteLine($"{frames.Count} frames, av1C of {av1C?.Length} bytes");
CodecHeaders.Describe lists an AV1 packet's OBUs, its sequence header and metadata fields, and the start of each frame header
(up to error_resilient_mode). The rest of a frame header depends on the frames before it, so a CodecHeaderReader reads a track's
packets in order, decoding them, and lists each frame header whole.
// An AV1 frame header depends on the frames before it: a CodecHeaderReader reads the packets in order and lists each whole.
using IDemuxer demuxer = MediaFile.OpenDemuxer("av1.webm");
using CodecHeaderReader headers = new(demuxer.Tracks[0]);
while (demuxer.ReadPacket() is { } packet)
{
using (packet)
{
IReadOnlyList<CodecHeaderField> fields = headers.Describe(packet);
Console.WriteLine(string.Join(", ", fields.Where(f => f.Name is "frame_type" or "order_hint" or "base_q_idx").Select(f => $"{f.Name} = {f.Value}")));
}
}
FFV1
Ffv1Codec decodes FFV1, the lossless codec archives use (RFC 9043), from Matroska (V_FFV1) and AVI (FourCC FFV1):
versions 0, 1 and 3, Golomb-Rice and the range coder, YCbCr 4:2:0, 4:2:2, 4:4:4, 4:1:1, 4:1:0 and gray, RGB, each with or
without alpha, at 8 to 16 bits. Every frame equals FFmpeg's sample for sample.
- Frames keep their planes: 8-bit planes, or 16-bit planes holding 10, 12 or 16 bits. Depths 9, 11, 13, 14 and 15 sit in the
next of those with their samples shifted up, so nothing is lost; the track's
BitDepthgives the source depth. RGB comes out as G, B and R planes taggedYuvMatrix.Identity, whichToRasterturns back into RGB exactly. - A version 3 track's configuration record (its
CodecPrivate) gives the depth and subsampling before any frame is decoded. - Every slice CRC is checked. Broken data follows
VideoReaderOptions.Errors: a damaged slice is reported asFfv1.SliceCrc(orFfv1.CorruptFramewhen it cannot be read), a frame with no keyframe before it asFfv1.MissingReferenceand a damaged configuration record asFfv1.ConfigCrc.Skipdrops the frame,Concealshows the last good frame where the slice was. CodecHeaders.Describelists the parameters, the keyframe flag and each slice's header and footer.
// FFV1 archives from Matroska or AVI read like any other video. A 10-bit master gives 16-bit planes holding 10 bits;
// the track says the depth and subsampling before any frame is decoded.
using VideoReader reader = VideoReader.Open("master.mkv", new VideoReaderOptions { Errors = DecodeErrorMode.Conceal }, context);
Console.WriteLine($"{reader.Track.CodecId}: {reader.Track.BitDepth} bit {reader.Track.ChromaFormat}");
foreach (YuvVideoFrame frame in reader.ReadFrames())
{
using (frame)
{
Console.WriteLine($"{frame.Timestamp}: {frame.Raster.BitDepth} bit {frame.Raster.ChromaFormat}");
}
}
// Every slice's CRC is checked: a damaged one is reported as Ffv1.SliceCrc and, with Conceal, shows the last good frame.
Console.WriteLine($"{reported.Count(d => d.Id == "Ffv1.SliceCrc")} damaged slices");
Slices decode in parallel up to EgamiContext.MaxParallelism, with the same frame at any thread count.
Ffv1Codec.Verify checks a file's fixity without decoding pictures: the configuration record's CRC and every slice's, far faster
than a decode. The Ffv1VerifyResult says how many frames and slices it checked and lists each Ffv1VerifyError; a version 0 or 1
file, or one written without slice CRCs, has HasSliceCrcs false.
// Check an archive's fixity without decoding pictures: the configuration record's CRC and every slice's.
Ffv1VerifyResult result = Ffv1Codec.Verify("master.mkv");
Console.WriteLine($"{result.Frames} frames, {result.Slices} slices checked: {(result.IsIntact ? "intact" : "damaged")}");
foreach (Ffv1VerifyError error in result.Errors)
{
Console.WriteLine($"frame {error.Frame} at {error.Timestamp}, slice {error.Slice}: {error.Problem}");
}
Writing FFV1 is in Encoding video; Lossless archiving with FFV1 puts writing, fixity checks and restores together.
HDR
A frame's Color and Hdr carry its color code points and static HDR metadata. YuvVideoFrame.ToRaster with a target
ColorProfile converts through Egami's color pipeline and tone maps PQ and HLG to SDR. VP9 frames in WebM carry HDR10+
dynamic metadata as Hdr10PlusMetadata, with its Hdr10PlusPercentile distribution and Hdr10PlusToneCurve. AV1 frames take
Hdr from the stream's content light level and mastering display metadata OBUs (or the track's av1C), and Hdr10Plus from an
ITU-T T.35 metadata OBU.
// An HDR10 frame tone mapped to SDR through Egami's color pipeline, from its mastering display and light levels.
using (VideoReader reader = VideoReader.Open("hdr10.mkv"))
using (YuvVideoFrame frame = reader.ReadFrame()!)
using (Raster<Rgba8> sdr = frame.ToRaster<Rgba8>(ColorProfile.Srgb))
{
sdr.Save("sdr.png");
}
// HDR10+ dynamic metadata, frame by frame.
using VideoReader plus = VideoReader.Open("hdr10plus.webm");
foreach (YuvVideoFrame frame in plus.ReadFrames())
{
using (frame)
{
if (frame.Hdr10Plus is { } meta)
{
double maxScl = meta.MaxScl.Max(); // cd/m²
Hdr10PlusPercentile median = meta.DistributionMaxRgb.First(p => p.Percentage == 50);
Hdr10PlusToneCurve? curve = meta.ToneCurve;
Console.WriteLine($"MaxSCL {maxScl}, median {median.Value} cd/m², knee {curve?.KneeX}");
}
}
}
Measuring quality
VideoQuality sums PSNR and SSIM over frames, plane by plane, as PlaneScores, and hashes frames the way libvpx's tests do.
// PSNR and SSIM of a decode against a reference, plane by plane, and the MD5 of a frame as libvpx's tests take it.
VideoQuality quality = new();
using (VideoReader reference = VideoReader.Open("movie.webm"))
using (VideoReader distorted = VideoReader.Open("movie.webm"))
{
foreach ((YuvVideoFrame a, YuvVideoFrame b) in reference.ReadFrames().Zip(distorted.ReadFrames()))
{
using (a)
using (b)
{
quality.Add(a.Raster, b.Raster);
string md5 = VideoQuality.Md5(a.Raster);
}
}
}
PlaneScores psnr = quality.Psnr, ssim = quality.Ssim;
Console.WriteLine($"{quality.Frames} frames: PSNR Y {psnr.Y:0.00} dB, SSIM {ssim.Average:0.0000}");
Scenes and analysis
MediaAnalysis.Run reads a file once and hands every frame to its detectors: the SceneDetector by default, and any
IVideoDetector or IAudioDetector you add. Each Scene has its first and last frame, its start and end time and how it
starts (SceneStart): at the first frame, at a cut, or inside a fade through black. Cuts lists each SceneCut and Fades
each fade out and fade in (FadeKind).
// One pass over the file: the scene detector runs by default, and any detectors of your own run with it.
MediaAnalysisResult result = MediaAnalysis.Run("shots.mkv");
foreach (Scene scene in result.Scenes)
{
Console.WriteLine($"Scene {scene.Number}: {scene.Start.ToTimeSpan()} to {scene.End.ToTimeSpan()}, starts with a {scene.StartKind}");
}
SceneOptions picks the method and its threshold. SceneMethod.Adaptive weighs each frame's change against the frames
around it, so a fast pan is not taken for a cut; Content compares with the frames just before, as ffmpeg's scene score does;
Histogram compares luma histograms, which ignores motion inside a steady picture. A cut closer than MinSceneLength to the
last is dropped, and with FlashMode.Merge a flash that comes back to the picture before it is no scene. A frame counts as black
for fades when its mean luma is at or under FadeLevel (2 % of the range, so dark scenes keep their cuts). KeepSeries keeps
every frame's score.
// Content compares each frame with the one before, as ffmpeg's scene score does; Adaptive (the default) also weighs the
// frames around it, so fast motion is not taken for a cut. Fades through black end a scene in the dark.
SceneOptions options = new()
{
Method = SceneMethod.Content,
Threshold = 0.3,
MinSceneLength = MediaTime.FromSeconds(0.5),
DetectFades = true,
KeepSeries = true,
};
MediaAnalysisResult result = MediaAnalysis.Run("shots.mkv", new MediaAnalysisOptions { Scenes = options });
SceneScore sharpest = result.SceneScores.MaxBy(s => s.Score);
Console.WriteLine($"{result.Cuts.Count} cuts; the sharpest at {sharpest.Time.ToTimeSpan()}");
A detector of your own takes frames in time order, and the SceneDetector runs on its own too:
// A detector takes frames in time order; this one finds the darkest frame. The scene detector can run on its own too.
DarkestFrame darkest = new();
SceneDetector scenes = new(new SceneOptions { Method = SceneMethod.Histogram });
using (VideoReader reader = VideoReader.Open("shots.mkv"))
{
foreach (YuvVideoFrame frame in reader.ReadFrames())
{
using (frame)
{
darkest.Add(frame);
if (scenes.Add(frame) is { } cut)
{
Console.WriteLine($"Cut at frame {cut.Frame}");
}
}
}
scenes.Complete(reader.Info.Duration ?? MediaTime.Zero);
}
Console.WriteLine($"Darkest frame at {darkest.Time.ToTimeSpan()}, {scenes.Scenes.Count} scenes");
// Audio detectors get the audio in blocks, interleaved with the video by time, in the same pass.
Peak peak = new();
MediaAnalysis.Run("shots.mkv", new MediaAnalysisOptions { AudioDetectors = [peak] });
Console.WriteLine($"Peak {20 * Math.Log10(peak.Value):0.0} dBFS");
SceneFrames.Read seeks to the first, middle or last frame of each scene, for thumbnails or a shot list:
// A frame from each scene, read by seeking: the first, the middle or the last.
MediaAnalysisResult result = MediaAnalysis.Run("shots.mkv");
using VideoReader reader = VideoReader.Open("shots.mkv");
foreach (SceneFrame scene in SceneFrames.Read(reader, result.Scenes, ScenePosition.Middle))
{
using (scene.Frame)
using (Raster<Rgba8> picture = scene.Frame.ToRaster<Rgba8>())
{
picture.Save($"scene-{scene.Scene.Number}.png");
}
}
TestSources makes pictures and sounds to test with: SMPTE color bars at their code values in any bit depth, a moving pattern
with a frame counter, solid colors, gradients, tones, white or pink noise (NoiseColor) and sweeps. TestVideoFormat and
TestAudioFormat set the size, rate and sample layout; ColorBarsStyle picks HD or SD bars.
// Generated pictures and sounds for tests and demos: bars, a moving pattern, solid colors, gradients, tones and noise.
TestVideoFormat format = new() { Width = 320, Height = 180, FrameRate = new Rational(25, 1) };
MediaTime second = MediaTime.FromSeconds(1);
using VideoWriter writer = VideoWriter.Create(path, new VideoWriterOptions
{
FrameRate = format.FrameRate,
AudioCodec = Egami.Video.Audio.Codecs.FlacCodec.Instance,
AudioSampleRate = 48000,
AudioLayout = ChannelLayout.Stereo,
});
IEnumerable<YuvVideoFrame> shots = TestSources.ColorBars(format, second)
.Concat(TestSources.Solid(format, second, new Rgba8(30, 40, 220, 255)))
.Concat(TestSources.Pattern(format, second));
int index = 0;
foreach (YuvVideoFrame frame in shots)
{
using (frame)
{
writer.WriteFrame(frame.Raster, new MediaTime(index++, format.FrameRate.Reciprocal()));
}
}
foreach (AudioFrame block in TestSources.Sine(null, MediaTime.FromSeconds(3), frequency: 1000, level: -18))
{
using (block)
{
writer.WriteAudio(block);
}
}
More detectors run in the same pass, each keeping ffmpeg's documented measure and defaults with thresholds as fractions of
the stream's range: BlackDetector (blackdetect) and FreezeDetector (freezedetect) return DetectedRanges,
CropDetector (cropdetect) a CropReport with the rectangle that keeps every frame's picture, InterlaceDetector (idet)
an InterlaceReport with field order counts, repeated fields and an InterlaceVerdict that spots 3:2 telecine, and
DuplicateDetector (mpdecimate's test) runs of repeated frames. Each takes its own options record (BlackOptions,
FreezeOptions, CropOptions with the runs of frames as CropRuns, InterlaceOptions, DuplicateOptions), and the black
detector keeps every frame's ratio as a FrameValue series on request.
// ffmpeg's detection filters as detectors, with their documented defaults: black and frozen stretches, black borders,
// field order and repeated frames, all from one decode.
BlackDetector black = new(new BlackOptions { MinDuration = MediaTime.FromSeconds(0.5) });
using FreezeDetector freeze = new(new FreezeOptions { MinDuration = MediaTime.FromSeconds(0.5) });
CropDetector crop = new();
InterlaceDetector fields = new();
DuplicateDetector duplicates = new();
MediaAnalysis.Run("shots.mkv", new MediaAnalysisOptions { VideoDetectors = [black, freeze, crop, fields, duplicates] });
foreach (DetectedRange range in freeze.Ranges)
{
Console.WriteLine($"Frozen from {range.Start.ToTimeSpan()} to {range.End.ToTimeSpan()}");
}
Console.WriteLine($"{black.Ranges.Count} black stretches, crop to {crop.Report().Crop}, {fields.Report().Verdict}");
Console.WriteLine($"{duplicates.DuplicateCount} repeated frames");
SignalMeter reports what signalstats and colordetect do: the lowest, 10 %, mean, 90 % and highest code values of each plane
as PlaneLevels, saturation, the share of pixels outside broadcast range, the SignalRange the samples really use and the
AlphaUse of an alpha plane. With Blur and Blockiness on it also scores edge width and coding blocks, and KeepSeries
keeps a SignalFrame per frame.
// Levels, saturation and broadcast range as signalstats gives them, the range really used, and optional blur and
// blockiness scores.
SignalMeter meter = new(new SignalOptions { Blur = true, Blockiness = true });
MediaAnalysis.Run("shots.mkv", new MediaAnalysisOptions { Scenes = null, VideoDetectors = [meter] });
SignalReport report = meter.Report();
Console.WriteLine($"Y {report.Y.Min}..{report.Y.Max} (mean {report.Y.Mean:0.0}), {report.OutOfRange:P1} out of range, {report.Range} range");
Console.WriteLine($"Edges {report.Blur:0.00} px wide, blockiness {report.Blockiness:0.00}");
On the audio side, SilenceDetector finds silent stretches as silencedetect does, over all channels or each one, with its
SilenceOptions; AudioLevelMeter returns an AudioLevelReport with ChannelLevels for each channel and overall: peak,
RMS, DC offset, crest factor, clipped samples, noise floor, zero crossings and a 1 dB histogram, as volumedetect and astats
report them.
// Silent stretches as silencedetect finds them (under -60 dBFS for 2 s by default), and the sample levels volumedetect
// and astats report, per channel and over all channels.
SilenceDetector silence = new(new SilenceOptions { Noise = -50, MinDuration = MediaTime.FromSeconds(0.5) });
AudioLevelMeter levels = new();
MediaAnalysis.Run("shots.mkv", new MediaAnalysisOptions { Scenes = null, AudioDetectors = [silence, levels] });
Console.WriteLine($"{silence.Ranges.Count} silent stretches");
AudioLevelReport report = levels.Report();
Console.WriteLine($"Peak {report.Overall.Peak:0.0} dBFS, RMS {report.Overall.Rms:0.0} dBFS, {report.Overall.Clipped} clipped");
LoudnessMeter measures loudness as ITU-R BS.1770 and EBU R 128 define it, at any rate from 8 to 384 kHz: momentary and
short-term loudness while audio comes in, and a LoudnessReport with the gated integrated loudness, the loudness range
(EBU Tech 3342) and the true peak. LoudnessOptions turns the true peak off or keeps a LoudnessValue every 100 ms.
// EBU R 128 loudness: integrated, range and true peak at the end, momentary and short-term while the audio plays.
LoudnessMeter meter = new(new LoudnessOptions { KeepSeries = true });
MediaAnalysis.Run("shots.mkv", new MediaAnalysisOptions { Scenes = null, AudioDetectors = [meter] });
LoudnessReport loudness = meter.Report();
Console.WriteLine($"I {loudness.Integrated:0.0} LUFS, LRA {loudness.Range:0.0} LU, true peak {loudness.TruePeak:0.0} dBTP");
foreach (LoudnessValue value in meter.ShortTermSeries)
{
Console.WriteLine($"{value.Time.ToTimeSpan()}: {value.Loudness:0.0} LUFS");
}
AudioPictures draws audio as a Raster<Rgba8>, reading it once with constant memory however long it runs. Waveform
shows each column's min-to-max outline with the RMS level shaded inside, linear or in dB (WaveformScale), with the
channels together or stacked (AudioPictureChannels); WaveformOptions sets the size and colors. Spectrogram shows
frequency over time with a Hann-windowed FFT per column, on a linear or logarithmic FrequencyScale, colored by level from
Floor to Ceiling with magma or grayscale (SpectrogramColors); SpectrogramOptions sets the window length too. The
pictures carry no text: add axes and labels with Egami.Drawing.
// A waveform with each channel in its own strip, and a spectrogram on a logarithmic frequency axis in magma colors.
using Raster<Rgba8> waveform = AudioPictures.Waveform("shots.mkv", new WaveformOptions
{
Width = 1200,
Height = 240,
Channels = AudioPictureChannels.Stacked,
});
waveform.Save("waveform.png");
using Raster<Rgba8> spectrogram = AudioPictures.Spectrogram("shots.mkv", new SpectrogramOptions
{
Width = 1200,
Height = 400,
Scale = FrequencyScale.Logarithmic,
Floor = -100,
});
spectrogram.Save("spectrogram.png");
MediaAnalysis.Run, AudioPictures.Waveform and AudioPictures.Spectrogram also take a Stream, with the same options, for
an upload or a download. A seekable stream is read from its position as a file would be. A stream that cannot seek is read
once: the analysis shares its video and audio packets out as they come and decodes up to Start instead of seeking, and the
pictures draw in the same pass when the container gives the duration, or else copy the stream to a temporary file on the
way, deleted after. The caller owns the stream; it is never disposed.
// From a stream, such as an upload or a download, with the same options as from a file. A stream that cannot seek
// is read once; the caller keeps the stream and disposes it.
using Raster<Rgba8> picture = AudioPictures.Waveform(download, new WaveformOptions { Width = 800, Height = 120 });
picture.Save("upload-waveform.png");
using FileStream file = File.OpenRead("shots.mkv");
MediaAnalysisResult result = MediaAnalysis.Run(file, new MediaAnalysisOptions { AudioDetectors = [new SilenceDetector()] });
Console.WriteLine($"{result.Scenes.Count} scenes in {result.VideoFrames} frames");
Limits and warnings
A MediaContext holds the containers and codecs, the Egami context (memory, threads, safety limits), the MediaLimits for
files from outside (packet, header and index sizes, tracks, nesting) and a handler for MediaWarnings: what a reader
recovered from. VP9 decodes tile columns and loop filter rows on up to MaxParallelism threads.
// Limits for files from outside, and a handler for what a reader recovers from (a broken index, a cut-off cluster).
List<MediaWarning> warnings = [];
MediaContext context = MediaContext.Default with
{
Limits = MediaLimits.Default with { MaxPacketBytes = 8 << 20, MaxTracks = 8 },
Warnings = warnings.Add,
Egami = EgamiContext.Default with { MaxParallelism = 2 },
};
using VideoReader reader = VideoReader.Open("movie.webm", null, context);
foreach (MediaWarning warning in warnings)
{
Console.WriteLine($"{warning.Code}: {warning.Message}");
}
MP4 and QuickTime
Mp4Format reads .mp4, .m4v, .m4a and .mov files, flat or fragmented (CMAF and DASH segments, phone recordings), and writes
ISO MP4. Every track is read: AV1, VP9, VP8, Motion JPEG, Opus, FLAC, PCM and mov_text with their settings, and H.264, HEVC,
AAC or any other codec passed through, so a remux keeps them byte for byte. Opening a long file reads only its index.
- Edit lists are applied, as FFmpeg shows them: a delayed track starts later, and packets before the start are marked
EncodedPacket.IsDiscardable(the reader decodes them and drops their frames).new Mp4Format { IgnoreEditLists = true }gives the media's own times. - The display matrix is
VideoTrackInfo.Orientation. Frames come out as coded;VideoReaderOptions.AutoOrientturns them. - A timecode track gives
MediaInfo.Timecode. Tags, cover art (as an attachment named cover.jpg or cover.png) and chapters (Nero chpl or a QuickTime chapter track) are read, and written back. MovTextSamplereads and writes mov_text subtitles with their style runs (MovTextStyle: the characters each covers,MovTextFacebold, italic or underline, size and color).- Writing puts moov before the media, so the file plays while it downloads. On a stream that can seek and read the media is
moved forward at the end; on any other stream the file is written fragmented (see Streaming MP4, which
also covers HLS and DASH segments).
new Mp4Format { MoovAtEnd = true }writes moov last, straight to a seekable stream. A QuickTime file is written as MP4.
// MP4 and QuickTime files read like any other: rotation from the display matrix, the start timecode, edit lists applied.
MediaInfo phone = MediaFile.Probe("phone.mov");
Console.WriteLine($"{phone.FormatName}: turn {phone.VideoTrack!.Orientation} to show upright");
Console.WriteLine($"starts at timecode {MediaFile.Probe("camera.mov").Timecode}");
// Frames come out as coded; AutoOrient turns each one upright.
using (VideoReader reader = VideoReader.Open("phone.mov", new VideoReaderOptions { AutoOrient = true }))
{
using VideoFrame<Rgb8> upright = reader.ReadFrames<Rgb8>().First();
Console.WriteLine($"{reader.Track.Width}x{reader.Track.Height} coded, {upright.Raster.Width}x{upright.Raster.Height} shown");
}
// Writing MP4: moov before the media (the file plays while it downloads), tags, chapters and cover art kept.
MediaFile.Remux("movie.webm", "movie.mp4");
MediaFile.Remux("metadata.mkv", "metadata.mp4");
// mov_text subtitles: each packet's text and style runs.
using IDemuxer demuxer = MediaFile.OpenDemuxer("subtitled.mp4");
TrackInfo subtitles = demuxer.Tracks.First(t => t.CodecId == CodecIds.MovText);
while (demuxer.ReadPacket() is { } packet)
{
using (packet)
{
if (packet.TrackNumber == subtitles.Number)
{
MovTextSample text = MovTextSample.Parse(packet.Data.Span);
Console.WriteLine($"{packet.Timestamp.ToTimeSpan()}: {text.Text} ({text.Styles.Count} styles)");
}
}
}
Broken files are read up to the damage, with a warning through MediaContext.Warnings: mp4-truncated (the file was cut
off), mp4-bad-track, mp4-bad-box and mp4-bad-fragment (broken boxes, skipped), mp4-sample-table (tables that disagree),
mp4-edit-list (an edit list with several media edits or another rate, not applied), mp4-sample-descriptions (a track that
changes its sample description), mp4-fragment-track and mp4-track-id. Writers report mp4-timestamps (a packet decoding
before the one before it), metadata-dropped, and for fragmented files mp4-fragmented and mp4-chapter-track. A file whose moov follows its media needs a stream that can seek.
Containers
Each container is a ContainerFormat: MatroskaFormat (WebM and Matroska), Mp4Format (MP4 and QuickTime), AviFormat,
IvfFormat, Y4mFormat, Av1ObuFormat and Av1AnnexBFormat for raw AV1, and for audio WavFormat and FlacFormat.
RawVideo reads and writes headerless frames in a layout you give.
// The containers, by name or extension, and headerless raw frames in a layout you give.
ContainerFormat? webm = MediaContext.Default.FindContainer("webm");
bool known = webm is MatroskaFormat && MediaContext.Default.FindContainer("avi") is AviFormat
&& MediaContext.Default.FindContainer("ivf") is IvfFormat && MediaContext.Default.FindContainer("y4m") is Y4mFormat;
VideoTrackInfo track = new()
{
Number = 1,
Kind = TrackKind.Video,
CodecId = CodecIds.Uncompressed,
FourCC = "I420",
TimeBase = new Rational(1, 25),
FrameRate = new Rational(25, 1),
Width = 64,
Height = 48,
};
using MemoryStream frames = new(new byte[64 * 48 * 3 / 2 * 2]);
using IDemuxer raw = RawVideo.OpenDemuxer(frames, track);