Document FFmpeg Encoder And Decoder

This commit is contained in:
copecone committed 2026-10-15 16:17:12 +09:00
1 parent 19d0e258ac
commit 354c42fdd9
3 files changed
+636 -115

No files matched your search

+229 -28
View File
@@ -10,9 +10,19 @@ namespace ADOFAIRenderer.Util.FFmpeg;
/// <summary>
/// High-performance video decoder that directly invokes FFmpeg native C libraries via P/Invoke.
/// Supports both GPU hardware-accelerated decoding (D3D11VA, DXVA2, QSV, CUDA, D3D12VA) and
/// Supports both GPU hardware-accelerated decoding (device types of <see cref="PreferredHwDeviceTypes"/>: D3D11VA, DXVA2,
/// CUDA, D3D12VA on Windows; VAAPI, CUDA or VideoToolbox on other platforms; others the decoder reports) and
/// CPU multi-threaded software decoding (FF_THREAD_FRAME) with automatic fallback.
///
/// Used by VideoBGPlayer to decode the background video of a level. Only the first video stream is decoded;
/// audio and other streams are discarded by the demuxer.
///
/// Threading: every public member takes <c>_lock</c>, so calls from different threads are serialized.
/// Width, Height, FPS, Duration and TotalFrames are set once in the constructor and read without the lock.
///
/// Timestamps returned by the TryRead* methods are the presentation times of the stream in seconds
/// (PTS × stream time base), so a stream with a non-zero start time returns timestamps starting at that time.
///
/// ── Architecture &amp; Lifecycle Flow ──────────────────────────────────────────────
///
/// 1. Construction &amp; Demuxing:
@@ -42,6 +52,7 @@ namespace ADOFAIRenderer.Util.FFmpeg;
/// - TryReadFrame: sws_scale conversion to RGBA32 with optional vertical flip (vflip).
///
/// 4. Seeking &amp; Scrubbing (SeekToTime / SeekToFrame):
/// (Short forward distances skip a) and b) and only decode forward; see SeekToTime.)
/// a) av_seek_frame: Fast backward keyframe seek.
/// b) avcodec_flush_buffers: Clear internal decoder state.
/// c) Forward decoding loop: Discards non-target frames without GPU transfer overhead;
@@ -72,7 +83,10 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// </summary>
private record struct HwConfigCandidate
{
/// <summary>Hardware device type to create with av_hwdevice_ctx_create (AVCodecHWConfig.device_type).</summary>
public AVHWDeviceType DeviceType;
/// <summary>Hardware surface pixel format the decoder outputs with this device (AVCodecHWConfig.pix_fmt, e.g. AV_PIX_FMT_D3D11).</summary>
public AVPixelFormat PixelFormat;
}
@@ -80,9 +94,10 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// Preferred hardware device types in priority order.
/// The candidates are the hardware configurations the selected decoder reports (avcodec_get_hw_config),
/// sorted by this list; configurations not in the list follow in reported order.
/// Windows: D3D11VA, then DXVA2 (DirectX 9), CUDA and D3D12VA. Other platforms: VAAPI or VideoToolbox.
/// Windows: D3D11VA, then DXVA2 (DirectX 9), CUDA and D3D12VA. Linux: CUDA (NVIDIA), then VAAPI. macOS: VideoToolbox.
/// A device type that cannot be created on the machine (no driver, wrong platform) fails creation and is skipped.
/// The QSV entry matches only decoders that report a QSV configuration. The native decoders returned by
/// avcodec_find_decoder report DXVA2/D3D11VA/D3D12VA/CUDA/VAAPI/Vulkan in the bundled FFmpeg 8 build;
/// avcodec_find_decoder report hwaccel device types such as DXVA2/D3D11VA/D3D12VA/CUDA/VAAPI/Vulkan (depending on the build);
/// the QSV decoders (h264_qsv, etc.) are separate decoders selected by name.
/// </summary>
private static readonly AVHWDeviceType[] PreferredHwDeviceTypes = new[]
@@ -116,6 +131,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reference-counted context for the GPU hardware device (D3D11, DXVA2, QSV, CUDA, etc.).
/// This class owns one reference; the codec context holds a second one (hw_device_ctx).
/// Both are released in Dispose (or when a hardware candidate fails). Null in software decoding.
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVHWDeviceContext.html
/// </summary>
private AVBufferRef* _hwDeviceCtx = null;
@@ -137,6 +154,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Frame structure holding converted pixel data produced by <c>sws_scale</c> (e.g., RGBA or fallback YUV).
/// Owns no pixel memory: during one conversion its data[]/linesize[] point to the caller's output buffers,
/// and they are cleared before and after (ClearConvertedFramePointers).
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVFrame.html
/// </summary>
private AVFrame* _pConvertedFrame = null;
@@ -147,6 +166,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVFrame.html
/// </summary>
private AVFrame* _pCachedFrame = null;
/// <summary>
/// Whether <see cref="_pCachedFrame"/> holds a frame that the next <c>TryRead*</c> call returns
/// before any new frame is decoded. Set by DecodeToTarget, cleared when the frame is consumed or dropped.
/// </summary>
private bool _hasCachedFrame;
/// <summary>
@@ -172,7 +196,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// </summary>
private AVCodecContext_get_format? _getFormatDelegate;
/// <summary>Whether hardware-accelerated decoding is currently active.</summary>
/// <summary>
/// Whether hardware-accelerated decoding is currently active.
/// Set when a hardware candidate is configured; cleared when it fails, or by GetFormat when the stream
/// cannot be decoded on the hardware surface format (the decoder then outputs software frames).
/// </summary>
private bool _isHwDecoding;
/// <summary>Hardware device type in use (e.g. D3D11VA, DXVA2, CUDA, QSV).</summary>
@@ -183,6 +211,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Pixel format of the frame after downloading from GPU surface to system RAM (typically NV12).
/// Assumed NV12 when hardware decoding is configured, then updated from every transferred frame
/// (e.g. P010 for 10-bit streams). AV_PIX_FMT_NONE in software decoding.
/// </summary>
private AVPixelFormat _downloadedPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
@@ -190,11 +220,28 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// Playback, Stream &amp; Scaler Synchronization Fields
// ──────────────────────────────────────────────────────────────
/// <summary>Index of the decoded video stream in the format context. -1 until a video stream is found.</summary>
private int _videoStreamIndex = -1;
/// <summary>
/// Index of the frame most recently returned (or skipped while seeking): round(timestamp × FPS) for frames with a timestamp,
/// the previous value + 1 for frames without one. 0 after opening.
/// </summary>
private long _currentFrameIndex;
/// <summary>Time base of the video stream (seconds per PTS unit). Used to convert PTS to seconds and seek targets to PTS.</summary>
private AVRational _timeBase;
/// <summary>Whether Open completed. The read and seek methods do nothing until then and after Dispose.</summary>
private bool _isOpened;
/// <summary>Whether Dispose has run. Prevents duplicate cleanup.</summary>
private bool _disposed;
/// <summary>
/// Whether the end of the input has been reached and the decoder was put into draining mode
/// (avcodec_send_packet with null). Reset by a keyframe seek (after avcodec_flush_buffers).
/// </summary>
private bool _isDraining;
/// <summary>Number of video packets skipped because the decoder rejected them as invalid data.</summary>
@@ -203,12 +250,21 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>Number of skipped packets for which a warning is logged.</summary>
private const int MaxLoggedSkippedPackets = 5;
/// <summary>Lock object serializing all public members (decoder state and native resources).</summary>
private readonly object _lock = new();
// Fields to detect SwsContext updates
// Parameters the current _pSwsContext was created with; a frame with different values recreates the context.
/// <summary>Source pixel format of the current scaler context.</summary>
private AVPixelFormat _srcPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
/// <summary>Destination pixel format of the current scaler context (RGBA, NV12 or YUV420P).</summary>
private AVPixelFormat _dstPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
/// <summary>Source width of the current scaler context in pixels (the destination is always Width × Height).</summary>
private int _srcWidth;
/// <summary>Source height of the current scaler context in pixels.</summary>
private int _srcHeight;
/// <summary>YUV→RGB matrix id (<c>SWS_CS_*</c>) applied to the context. 0 when no matrix is set (non-RGBA output).</summary>
@@ -221,22 +277,31 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// Public Properties
// ──────────────────────────────────────────────────────────────
/// <summary>Video width in pixels.</summary>
/// <summary>
/// Video width in pixels (codec context width after opening, or the stream parameters).
/// Every output of the TryRead* methods has this width; frames of another size are scaled to it.
/// </summary>
public int Width { get; private set; }
/// <summary>Video height in pixels.</summary>
/// <summary>Video height in pixels. Every output of the TryRead* methods has this height.</summary>
public int Height { get; private set; }
/// <summary>Frames per second.</summary>
/// <summary>
/// Frames per second: r_frame_rate of the stream, else avg_frame_rate, else 30.
/// For variable frame rate streams both values are approximations.
/// </summary>
public double FPS { get; private set; }
/// <summary>Total duration in seconds.</summary>
/// <summary>Total duration in seconds (stream duration, else container duration, else 0).</summary>
public double Duration { get; private set; }
/// <summary>Estimated total number of frames.</summary>
/// <summary>
/// Estimated total number of frames: the frame count of the stream header (nb_frames) when present,
/// otherwise round(duration × FPS); 0 when the duration is unknown.
/// </summary>
public long TotalFrames { get; private set; }
/// <summary>Index of the currently read frame.</summary>
/// <summary>Index of the currently read frame (the frame most recently returned or skipped; see _currentFrameIndex).</summary>
public long CurrentFrameIndex
{
get
@@ -248,7 +313,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
}
/// <summary>Current playback time in seconds.</summary>
/// <summary>Current playback time in seconds: CurrentFrameIndex / FPS (0 when FPS is not positive).</summary>
public double CurrentTime
{
get
@@ -266,6 +331,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// in system RAM (typically <see cref="AVPixelFormat.AV_PIX_FMT_NV12"/>) rather than the GPU surface format
/// (e.g., D3D11VA, DXVA2, CUDA), allowing seamless binding to Compute Shader NV12 pipelines.
/// In software mode, returns the codec context's pixel format (e.g., YUV420P).
/// Before the first hardware frame is transferred, NV12 is returned as the expected download format;
/// the actual format (e.g. P010 for 10-bit video) is known after the first transfer.
/// The TryRead* methods convert any decoded format to their output format, so this value only selects the fastest path.
/// </summary>
public AVPixelFormat PixelFormat
{
@@ -287,6 +355,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Color range of the video stream (MPEG limited [16-235] or JPEG full [0-255]).
/// Taken from the codec context, else from the stream parameters; AVCOL_RANGE_UNSPECIFIED when neither carries a tag.
/// This property reads the color_range tags only; it does not inspect the pixel format (YUVJ* formats are full range).
/// </summary>
public AVColorRange ColorRange
{
@@ -394,10 +464,14 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Initializes a new instance of <see cref="FFmpegNativeDecoder"/> and opens the specified video file.
/// Automatically attempts hardware acceleration (D3D11VA, DXVA2, QSV, CUDA), falling back
/// to multi-threaded software decoding if unavailable.
/// Automatically attempts hardware acceleration (device types reported by the decoder, in the order of
/// <see cref="PreferredHwDeviceTypes"/>), falling back to multi-threaded software decoding if unavailable.
/// </summary>
/// <param name="videoPath">Absolute or relative file path to the video file.</param>
/// <exception cref="Exception">
/// The file cannot be opened, has no video stream, or its codec has no decoder or cannot be opened.
/// All native resources allocated so far are released before the exception propagates.
/// </exception>
public FFmpegNativeDecoder(string videoPath)
{
Open(videoPath);
@@ -425,10 +499,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
// 2. Retrieve stream information
// Reads (and buffers) the start of the file to fill in codec parameters, frame rates and durations.
if (ffmpeg.avformat_find_stream_info(_pFormatContext, null) < 0)
throw new Exception("Failed to retrieve stream info.");
// 3. Find primary video stream
// 3. Find primary video stream (the first stream whose codec type is video)
_videoStreamIndex = -1;
for (int i = 0; i < _pFormatContext->nb_streams; i++)
{
@@ -456,6 +531,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
_timeBase = stream->time_base;
// 4. Calculate FPS
// r_frame_rate: lowest frame rate that represents all timestamps accurately (guessed by libavformat).
// avg_frame_rate: average frame rate. Both are approximations for variable frame rate streams.
// Reference: https://ffmpeg.org/doxygen/trunk/structAVStream.html
if (stream->r_frame_rate.num > 0 && stream->r_frame_rate.den > 0)
{
FPS = ffmpeg.av_q2d(stream->r_frame_rate);
@@ -470,6 +548,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
// 5. Find decoder by codec ID
// avcodec_find_decoder returns the default decoder registered for the ID (e.g. libdav1d for AV1
// when the build includes it, which reports no hardware configuration).
var codecId = stream->codecpar->codec_id;
var decoder = ffmpeg.avcodec_find_decoder(codecId);
if (decoder == null)
@@ -480,6 +560,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
if (_pCodecContext == null)
throw new Exception("Failed to allocate codec context.");
// Copies the stream parameters (size, pixel format, extradata, color tags, ...) into the codec context.
if (ffmpeg.avcodec_parameters_to_context(_pCodecContext, stream->codecpar) < 0)
throw new Exception("Failed to copy codec parameters.");
@@ -492,6 +573,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
_downloadedPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
// Multi-threading settings for improved software decoding performance (auto thread count)
// thread_type = FF_THREAD_FRAME selects frame threading only (several frames decoded in parallel,
// adds one frame of delay per thread); slice threading is not enabled.
// TryInitializeHwDecoder leaves a fresh codec context when every hardware candidate fails.
// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
_pCodecContext->thread_count = 0;
_pCodecContext->thread_type = ffmpeg.FF_THREAD_FRAME;
@@ -505,6 +589,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
Height = _pCodecContext->height > 0 ? _pCodecContext->height : stream->codecpar->height;
// 8. Calculate total frames and duration
// nb_frames is the frame count stored in the container (0 when unknown);
// otherwise the count is estimated from the stream or container duration and FPS.
if (stream->nb_frames > 0)
{
TotalFrames = stream->nb_frames;
@@ -531,6 +617,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
: _pFormatContext->duration > 0 ? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE : 0;
// 9. Allocate frame and packet buffers
// Structures only; pixel buffers are attached by the decoder, av_hwframe_transfer_data or av_frame_move_ref.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
_pDecodedFrame = ffmpeg.av_frame_alloc();
_pHwTransferFrame = ffmpeg.av_frame_alloc();
@@ -552,8 +639,14 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Searches for supported hardware device configurations via <c>avcodec_get_hw_config</c>,
/// creates the appropriate hardware device context (D3D11VA, DXVA2, QSV, CUDA),
/// creates the appropriate hardware device context (D3D11VA, DXVA2, QSV, CUDA, VAAPI, ...),
/// registers the <c>get_format</c> callback, and attempts to open the hardware decoder.
///
/// Candidates are tried in the order of <see cref="PreferredHwDeviceTypes"/>, then the remaining reported order.
/// When a candidate fails to open, its codec context and device are freed and a fresh codec context
/// (with the stream parameters) is allocated for the next candidate or for the software fallback.
/// On success, <see cref="_hwDeviceCtx"/>, <see cref="_hwPixFmt"/> and <see cref="_hwDeviceType"/> describe the device
/// and the codec context is open.
/// </summary>
/// <param name="decoder">Target decoder.</param>
/// <param name="stream">Source video stream.</param>
@@ -562,6 +655,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
{
// ReSharper disable once InconsistentNaming
// Collect all supported hardware configs from decoder
// AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX (0x01): the decoder supports the hardware format through hw_device_ctx,
// which has to be set on the codec context before avcodec_open2. Only these configurations are used here.
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/codec.h
const int AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX = 0x01;
var candidates = new List<HwConfigCandidate>();
for (int i = 0; ; i++)
@@ -616,6 +712,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
if (candidate.DeviceType == AVHWDeviceType.AV_HWDEVICE_TYPE_QSV)
{
// QSV: first with an explicit child device (D3D11VA on Windows, VAAPI elsewhere),
// then without options (FFmpeg selects the child device type).
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
string childDeviceName = isWindows ? "d3d11va" : "vaapi";
AVDictionary* opts = null;
@@ -631,6 +729,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
else
{
// Default device of the type (device = null: the first / default adapter chosen by FFmpeg).
err = ffmpeg.av_hwdevice_ctx_create(&hwDeviceCtx, candidate.DeviceType, null, null, 0);
}
@@ -646,6 +745,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
// Assign hardware context and format selection callback
// _hwDeviceCtx takes over the reference created above; the codec context receives its own reference.
_hwDeviceCtx = hwDeviceCtx;
_hwPixFmt = candidate.PixelFormat;
_hwDeviceType = candidate.DeviceType;
@@ -663,6 +763,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
continue;
}
// The delegate is kept in a field so that it is not collected while native code can still call it
// (FFmpeg.AutoGen marshals it to a function pointer). With thread_count = 1 it is called on the calling
// thread inside avcodec_send_packet / avcodec_receive_frame, which run under _lock.
_getFormatDelegate = GetFormat;
_pCodecContext->get_format = _getFormatDelegate;
@@ -681,7 +784,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
return true;
}
// Failed to open codec with this hardware configuration, clean up and try next
// Failed to open codec with this hardware configuration, clean up and try next.
// Freeing the codec context releases its hw_device_ctx reference; _hwDeviceCtx holds the last one.
Melon<RenderMod>.Logger.Warning($"[FFmpegNativeDecoder] avcodec_open2 failed with HW device {candidate.DeviceType}: {GetFFmpegErrorString(openRet)}. Retrying next candidate...");
if (_pCodecContext != null)
@@ -717,8 +821,14 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// Callback invoked by libavcodec during decoding to negotiate the output pixel format.
/// Confirms the hardware surface format (<see cref="_hwPixFmt"/>) if offered by the decoder,
/// or falls back to software pixel formats via <c>avcodec_default_get_format</c>.
/// Called at the start of decoding and again when stream parameters change (e.g. resolution).
/// The fallback clears <see cref="_isHwDecoding"/>; the decoder then outputs frames in system memory
/// with the thread count set for hardware decoding (1).
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
/// </summary>
/// <param name="ctx">Codec context negotiating the format.</param>
/// <param name="pixFmts">Formats the decoder can output for the stream, terminated by AV_PIX_FMT_NONE (hardware formats first).</param>
/// <returns>The selected pixel format.</returns>
private AVPixelFormat GetFormat(AVCodecContext* ctx, AVPixelFormat* pixFmts)
{
for (var p = pixFmts; *p != AVPixelFormat.AV_PIX_FMT_NONE; p++)
@@ -744,9 +854,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// then sends it to the decoder via <c>avcodec_send_packet</c>.
/// A packet that the decoder rejects as invalid data (<c>AVERROR_INVALIDDATA</c>, e.g. a damaged packet)
/// is skipped and the next packet is read. Any other decoder error is thrown.
/// Packets of other streams are discarded (they are normally not returned, see AVDISCARD_ALL in Open).
/// Called only after avcodec_receive_frame returned EAGAIN, so avcodec_send_packet accepts the packet.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
/// </summary>
/// <returns>True if a video packet was sent; false on EOF.</returns>
/// <returns>True if a video packet was sent; false on EOF or on a read error (both end the stream).</returns>
private bool ReadAndSendPacket()
{
while (true)
@@ -757,6 +869,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
if (_pPacket->stream_index == _videoStreamIndex)
{
// avcodec_send_packet takes its own reference to (or copy of) the packet data,
// so the packet is unreferenced right away for the next av_read_frame.
ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket);
ffmpeg.av_packet_unref(_pPacket);
@@ -785,9 +899,16 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// If the frame is decoded on a GPU hardware surface (D3D11VA, DXVA2, CUDA, QSV),
/// it is automatically downloaded to system RAM via <c>av_hwframe_transfer_data</c> into <c>_pHwTransferFrame</c>,
/// preserving presentation timestamps via <c>av_frame_copy_props</c>.
///
/// Decode loop: avcodec_receive_frame → on EAGAIN read and send the next packet → retry.
/// When the input ends, a null packet switches the decoder to draining mode; the remaining delayed frames
/// are returned, then avcodec_receive_frame returns AVERROR_EOF.
/// The returned frame (<see cref="_pDecodedFrame"/> or <see cref="_pHwTransferFrame"/>) holds its data until
/// the caller unreferences it (av_frame_unref) or the next frame is received.
/// </summary>
/// <param name="outFrame">The software frame containing pixel data in system RAM.</param>
/// <returns>True if a frame was successfully decoded and available; false on EOF or draining.</returns>
/// <exception cref="Exception">Decoding, packet sending or the GPU-to-host transfer fails.</exception>
private bool TryReceiveFrame(out AVFrame* outFrame)
{
outFrame = null;
@@ -800,6 +921,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// Check if the received frame is on a hardware surface
if (_isHwDecoding && _pDecodedFrame->format == (int)_hwPixFmt)
{
// av_hwframe_transfer_data with an empty destination frame allocates a system memory buffer
// in the first format the device supports for download (NV12 for 8-bit 4:2:0 on the common APIs).
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html (av_hwframe_transfer_data)
ffmpeg.av_frame_unref(_pHwTransferFrame);
int transferRet = ffmpeg.av_hwframe_transfer_data(_pHwTransferFrame, _pDecodedFrame, 0);
if (transferRet < 0)
@@ -809,6 +933,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
// Copy presentation timestamp and metadata properties
// (pts, best_effort_timestamp, color tags; av_hwframe_transfer_data copies pixel data only).
// The hardware surface is then released back to the decoder's pool.
ffmpeg.av_frame_copy_props(_pHwTransferFrame, _pDecodedFrame);
ffmpeg.av_frame_unref(_pDecodedFrame);
@@ -822,6 +948,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
return true;
}
// EAGAIN: the decoder needs more input. Feed the next video packet, or start draining at the end of the input.
// The return value of the flush call (null packet) is not checked; a failure shows up as an error or EOF
// from the next avcodec_receive_frame.
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
if (_isDraining) return false;
@@ -832,6 +961,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
ffmpeg.avcodec_send_packet(_pCodecContext, null);
}
}
// EOF: every delayed frame has been returned after draining.
else if (ret == ffmpeg.AVERROR_EOF)
{
return false;
@@ -886,8 +1016,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// Lazily initializes or updates the <see cref="SwsContext"/> when source/destination formats, dimensions,
/// or (for RGBA output) the colorspace / range of the source frame change.
/// For RGBA output the YUV→RGB matrix and the source range are taken from <paramref name="colorFrame"/>.
/// For NV12/YUV420P output no matrix is set; libswscale converts between the formats with its default range handling.
/// The destination size is always <see cref="Width"/> × <see cref="Height"/>; SWS_BILINEAR filters scaling and chroma resampling.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html
/// </summary>
/// <exception cref="Exception">sws_getContext cannot create a context for the conversion.</exception>
/// <param name="srcFormat">Pixel format of the source frame.</param>
/// <param name="srcWidth">Width of the source frame in pixels.</param>
/// <param name="srcHeight">Height of the source frame in pixels.</param>
@@ -933,6 +1066,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
throw new Exception($"Failed to initialize SwsContext (src: {srcFormat} {srcWidth}x{srcHeight} -> dst: {dstFormat} {Width}x{Height}).");
}
// A new context uses the BT.601 matrix and the range implied by the source pixel format (full for YUVJ*,
// limited otherwise); set the frame's matrix and range for YUV → RGBA.
if (swsColorspace != 0)
{
ApplySwsColorspace(swsColorspace, fullRange);
@@ -952,13 +1087,15 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
int* currentTable;
int srcRange, dstRange, brightness, contrast, saturation;
// Returns a negative value when the context cannot change its colorspace details (e.g. YUV to YUV conversion).
// A negative return value means the details cannot be read; the context then keeps its default matrix and range.
if (ffmpeg.sws_getColorspaceDetails(_pSwsContext, &currentInvTable, &srcRange, &currentTable, &dstRange,
&brightness, &contrast, &saturation) < 0)
{
return;
}
// inv_table: coefficients of the source YUV space (used for YUV → RGB); table: coefficients of the destination.
// A failure of sws_setColorspaceDetails leaves the defaults of the context and is not reported.
var coefficients = ffmpeg.sws_getCoefficients(swsColorspace);
var invTable = new int_array4();
var table = new int_array4();
@@ -975,6 +1112,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Computes presentation timestamp (PTS) in seconds from frame metadata without modifying decoder state.
/// Used by seeking to inspect candidate frames without falsely incrementing frame indices.
/// Uses best_effort_timestamp (libavcodec's estimate from PTS/DTS), else pts, converted with the stream time base.
/// A frame without either timestamp gets <see cref="_currentFrameIndex"/> / FPS.
/// </summary>
private double GetFrameTimestamp(AVFrame* frame)
{
@@ -994,6 +1133,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Computes playback presentation timestamp (PTS) in seconds from frame metadata and updates the frame counter.
/// With a timestamp, <see cref="_currentFrameIndex"/> = round(timestamp × FPS); without one, it is incremented by 1.
/// </summary>
private double GetTimestampAndUpdateIndex(AVFrame* frame)
{
@@ -1016,6 +1156,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Resets all data plane pointers and strides in <see cref="_pConvertedFrame"/> to zero.
/// Prevents stale or dangling pointers from previous conversions being retained across frames.
/// The frame never owns buffers (buf[] stays empty), so av_frame_free in Dispose does not touch caller memory.
/// 8 = AV_NUM_DATA_POINTERS, the size of AVFrame.data / linesize.
/// </summary>
private void ClearConvertedFramePointers()
{
@@ -1034,12 +1176,15 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies it in 32-bit RGBA format using libswscale.
/// Safely processes both hardware-downloaded frames (NV12) and software-decoded frames (YUV420P).
/// The YUV→RGB matrix and range follow the frame's tags (see InitOrUpdateSwsContext). Frames of another size
/// are scaled to Width × Height. A frame cached by a seek is returned first.
/// </summary>
/// <param name="outBuffer">Pointer to destination memory storing RGBA data.</param>
/// <param name="outBuffer">Pointer to destination memory storing RGBA data (at least outStride × Height bytes).</param>
/// <param name="outStride">Byte stride per row of destination memory (Width * 4).</param>
/// <param name="vflip">Whether to vertically flip the output to match Unity texture coordinates.</param>
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
/// <returns>True if a frame was successfully retrieved; false on EOF.</returns>
/// <returns>True if a frame was successfully retrieved; false on EOF, before opening, after disposal or for a null buffer.</returns>
/// <exception cref="Exception">Decoding, transfer or scaler creation fails.</exception>
public bool TryReadFrame(byte* outBuffer, int outStride, bool vflip, out double timestamp)
{
timestamp = 0;
@@ -1063,6 +1208,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
timestamp = GetTimestampAndUpdateIndex(frame);
InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_RGBA, frame);
// Destination: the caller's buffer. vflip uses a negative stride starting at the last row,
// so sws_scale writes the rows bottom-up (Unity textures have their origin at the bottom-left).
ClearConvertedFramePointers();
if (vflip)
{
@@ -1082,6 +1229,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
_pConvertedFrame->data, _pConvertedFrame->linesize
);
// Release the decoded (or cached) frame's buffers; the output is now in the caller's buffer.
ClearConvertedFramePointers();
ffmpeg.av_frame_unref(frame);
return true;
@@ -1090,6 +1238,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies it in RGBA format to an unmanaged memory pointer.
/// Same as the byte* overload.
/// </summary>
public bool TryReadFrame(IntPtr outBuffer, int outStride, bool vflip, out double timestamp)
{
@@ -1098,7 +1247,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies it in RGBA format to a Unity NativeArray.
/// The rows are tightly packed (stride Width × 4).
/// </summary>
/// <exception cref="ArgumentException">The array is not created or is smaller than Width × Height × 4 bytes.</exception>
public bool TryReadFrame(NativeArray<byte> outBuffer, bool vflip, out double timestamp)
{
if (!outBuffer.IsCreated)
@@ -1119,16 +1270,19 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies its Y and interleaved UV channel data separately in NV12 format.
/// When hardware decoding is active (producing NV12 in RAM), CPU color conversion is completely bypassed,
/// enabling maximum memory copy throughput directly into Unity textures for Compute Shader processing.
/// When hardware decoding is active (producing NV12 in RAM), CPU color conversion is completely bypassed:
/// the planes are copied row by row into the caller's buffers (e.g. texture data for Compute Shader processing).
/// Other formats or mismatched dimensions are automatically converted and scaled to NV12 via libswscale.
/// The Y/UV values keep the range of the source (see <see cref="ColorRange"/>); no YUV→RGB conversion happens here.
/// A frame cached by a seek is returned first.
/// </summary>
/// <param name="yBuffer">Pointer to destination buffer for Y plane (Width * Height bytes).</param>
/// <param name="yBuffer">Pointer to destination buffer for Y plane (yStride × Height bytes; Width bytes per row are written).</param>
/// <param name="yStride">Row stride in bytes for Y plane.</param>
/// <param name="uvBuffer">Pointer to destination buffer for interleaved UV plane (((Width + 1) / 2) * 2 * ((Height + 1) / 2) bytes).</param>
/// <param name="uvBuffer">Pointer to destination buffer for interleaved UV plane (uvStride × ceil(Height / 2) bytes; ceil(Width / 2) × 2 bytes per row are written).</param>
/// <param name="uvStride">Row stride in bytes for UV plane.</param>
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
/// <returns>True if a frame was successfully retrieved; false on EOF.</returns>
/// <returns>True if a frame was successfully retrieved; false on EOF, before opening, after disposal or for a null buffer.</returns>
/// <exception cref="Exception">Decoding, transfer or scaler creation fails.</exception>
// ReSharper disable once InconsistentNaming
public bool TryReadNV12Frame(
byte* yBuffer, int yStride,
@@ -1155,6 +1309,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
timestamp = GetTimestampAndUpdateIndex(frame);
// Conversion path: any other format (e.g. YUV420P from software decoding, P010 from 10-bit hardware decoding)
// or a frame size different from Width × Height. sws_scale writes directly into the caller's planes.
if (frame->format != (int)AVPixelFormat.AV_PIX_FMT_NV12 || frame->width != Width || frame->height != Height)
{
InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_NV12);
@@ -1175,12 +1331,16 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
else
{
// Direct copy path: NV12 frame of the output size.
// The UV plane has ceil(Height / 2) rows of ceil(Width / 2) U/V byte pairs.
int w = Width;
int h = Height;
int uvH = (h + 1) / 2;
int uvRowBytes = ((w + 1) / 2) * 2;
// Copy Y plane (row by row to safely skip linesize GPU padding)
// Buffer.MemoryCopy(source, destination, destinationSizeInBytes, bytesToCopy): the destination size
// argument is the stride, so a stride smaller than the row width throws instead of overrunning.
for (int y = 0; y < h; y++)
{
Buffer.MemoryCopy(
@@ -1208,6 +1368,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies its NV12 Y and interleaved UV channel data to unmanaged memory pointers.
/// Same as the byte* overload.
/// </summary>
// ReSharper disable once InconsistentNaming
public bool TryReadNV12Frame(
@@ -1224,7 +1385,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies its NV12 Y and interleaved UV channel data into Unity NativeArrays.
/// The planes are tightly packed: Y stride Width, UV stride ceil(Width / 2) × 2 with ceil(Height / 2) rows.
/// </summary>
/// <exception cref="ArgumentException">An array is not created or is smaller than its plane.</exception>
// ReSharper disable once InconsistentNaming
public bool TryReadNV12Frame(
NativeArray<byte> yBuffer,
@@ -1261,7 +1424,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// Reads the next video frame and copies its planar Y, U, and V channel data separately.
/// For native YUV420P sources, color conversion is bypassed and memory is copied directly.
/// Non-YUV420P frames (including NV12 from HW decoding) or mismatched dimensions are converted via libswscale.
/// YUVJ420P (full range YUV420P) is copied as well; the values keep the source range (see <see cref="ColorRange"/>).
/// A frame cached by a seek is returned first.
/// </summary>
/// <param name="yBuffer">Destination of the Y plane (yStride × Height bytes; Width bytes per row are written).</param>
/// <param name="yStride">Row stride in bytes of the Y plane.</param>
/// <param name="uBuffer">Destination of the U plane (uStride × ceil(Height / 2) bytes; ceil(Width / 2) bytes per row).</param>
/// <param name="uStride">Row stride in bytes of the U plane.</param>
/// <param name="vBuffer">Destination of the V plane (same size as U).</param>
/// <param name="vStride">Row stride in bytes of the V plane.</param>
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
/// <returns>True if a frame was successfully retrieved; false on EOF, before opening, after disposal or for a null buffer.</returns>
/// <exception cref="Exception">Decoding, transfer or scaler creation fails.</exception>
// ReSharper disable once InconsistentNaming
public bool TryReadYUV420PFrame(
byte* yBuffer, int yStride,
@@ -1289,6 +1463,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
timestamp = GetTimestampAndUpdateIndex(frame);
// Conversion path: any format other than YUV420P/YUVJ420P, or a frame size different from Width × Height.
if ((frame->format != (int)AVPixelFormat.AV_PIX_FMT_YUV420P && frame->format != (int)AVPixelFormat.AV_PIX_FMT_YUVJ420P) || frame->width != Width || frame->height != Height)
{
InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_YUV420P);
@@ -1311,6 +1486,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
else
{
// Direct copy path: each plane row by row (source rows can be padded, linesize ≥ row width).
int w = Width;
int h = Height;
int uvW = (w + 1) / 2;
@@ -1354,6 +1530,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies its planar Y, U, and V channel data to unmanaged memory pointers.
/// Same as the byte* overload.
/// </summary>
// ReSharper disable once InconsistentNaming
public bool TryReadYUV420PFrame(
@@ -1372,7 +1549,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies its planar Y, U, and V channel data into Unity NativeArrays.
/// The planes are tightly packed: Y stride Width, U/V stride ceil(Width / 2) with ceil(Height / 2) rows.
/// </summary>
/// <exception cref="ArgumentException">An array is not created or is smaller than its plane.</exception>
// ReSharper disable once InconsistentNaming
public bool TryReadYUV420PFrame(
NativeArray<byte> yBuffer,
@@ -1419,8 +1598,10 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// for the next <c>TryRead*</c> call.
/// Frames before the target are discarded without GPU-to-host memory transfer; only the target frame is transferred.
/// When the end of the stream is reached before the target, no frame is cached.
/// Every discarded frame updates <see cref="_currentFrameIndex"/>; the cached target frame updates it when it is read.
/// </summary>
/// <param name="timeSeconds">Target time in seconds.</param>
/// <exception cref="Exception">Decoding or the GPU-to-host transfer of the target frame fails.</exception>
private void DecodeToTarget(double timeSeconds)
{
while (true)
@@ -1451,6 +1632,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
else
{
// System memory frame: move the buffer references into the cache without copying
// (_pDecodedFrame is left blank for the next receive).
ffmpeg.av_frame_unref(_pCachedFrame);
ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame);
}
@@ -1465,6 +1648,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
continue;
}
// Same input feeding and draining as TryReceiveFrame.
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
if (_isDraining) return;
@@ -1496,8 +1680,12 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// - Keyframe seek: all other cases (target before the current position, far ahead, or the target frame already read).
/// A backward keyframe seek is followed by a forward decode up to the target.
/// The target frame is transferred and preserved in <see cref="_pCachedFrame"/>; when it is already there, nothing is decoded.
/// Negative times are treated as 0. The time is in the timeline of the stream timestamps (a stream that starts at
/// a non-zero time has no frame before its start; the first frame is returned for earlier targets).
/// When the target is past the last frame, no frame is cached and the next TryRead* call returns false.
/// </summary>
/// <param name="timeSeconds">Target time in seconds to seek to.</param>
/// <exception cref="Exception">Seeking (including the fallback seek to the start) or decoding fails.</exception>
public void SeekToTime(double timeSeconds)
{
lock (_lock)
@@ -1546,6 +1734,10 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
_hasCachedFrame = false;
}
// Keyframe seek. With a stream index, the timestamp is in the stream time base.
// AVSEEK_FLAG_BACKWARD: seek to the nearest keyframe at or before the target, so that decoding forward
// from there reaches the target frame.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__decoding.html (av_seek_frame)
long targetPts = (long)Math.Round(timeSeconds / ffmpeg.av_q2d(_timeBase));
int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
@@ -1559,6 +1751,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
// Flush decoder internal buffers
// Drops the frames buffered from before the seek and ends draining mode, so new packets are accepted again.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
ffmpeg.avcodec_flush_buffers(_pCodecContext);
_isDraining = false;
@@ -1569,9 +1762,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
/// <summary>
/// Seeks to a specific frame index.
/// Seeks to a specific frame index: <see cref="SeekToTime"/> with frameIndex / FPS seconds.
/// </summary>
/// <param name="frameIndex">Target frame index.</param>
/// <param name="frameIndex">Target frame index (0-based, in units of 1 / FPS).</param>
public void SeekToFrame(long frameIndex)
{
SeekToTime(frameIndex / FPS);
@@ -1582,7 +1775,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Translates an FFmpeg integer error code to a readable string using <c>av_strerror</c>.
/// Translates an FFmpeg integer error code to a readable string using <c>av_strerror</c>
/// (1024-byte stack buffer; "Unknown error (Code: n)" when there is no description).
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__error.html
/// </summary>
private string GetFFmpegErrorString(int errnum)
@@ -1598,6 +1792,10 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Releases all native FFmpeg decoder resources, hardware contexts, frame buffers, and scaler structures.
/// Safe to call more than once; also called by the constructor on failure and by the finalizer.
/// Order: scaler → frames → packet → codec context (releases its hw_device_ctx reference)
/// → this object's device reference → input file and format context.
/// After Dispose, the read and seek methods return without doing anything.
/// </summary>
public void Dispose()
{
@@ -1681,6 +1879,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
}
/// <summary>
/// Finalizer. Releases the native resources when the decoder was not disposed (runs on the GC finalizer thread).
/// </summary>
~FFmpegNativeDecoder()
{
Dispose();