Files
ADOFAIRenderer/Util/FFmpeg/FFmpegNativeEncoder.cs
T

2099 lines
112 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Runtime.InteropServices;
using ADOFAIRenderer.Data;
using FFmpeg.AutoGen;
using MelonLoader;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
namespace ADOFAIRenderer.Util.FFmpeg
{
/// <summary>
/// Class that encodes video by directly invoking (P/Invoke) FFmpeg's C native API.
///
/// ── Lifecycle Flow ───────────────────────────────────────────────────
///
/// 1. Constructor: Stores encoding settings (resolution, FPS, codec, quality, etc.).
///
/// 2. Start(outputPath):
/// a) Create AVFormatContext for output container (mp4, etc.)
/// b) Add video stream (AVStream)
/// c) Try the candidate encoders of the codec in order and select the first one that opens
/// (encoders of the active GPU vendor → other hardware encoders → software encoders; see GetEncoderCandidates)
/// d) Write file header and initialize frame/scaler buffers
///
/// 3. WriteFrame*(): Called for every frame
/// a) Pre-converted NV12/YUV420P frame (GPU compute shader output): copied into the encoder frame
/// RGBA frame: converted to the encoder pixel format with sws_scale (CPU)
/// b) (In QSV hw_frames mode) Upload to GPU surface
/// c) avcodec_send_frame → avcodec_receive_packet → Write to file
///
/// 4. End(): Flush delayed frames remaining in the encoder and write trailer
///
/// 5. Dispose(): Release all FFmpeg resources (context, frames, scaler, I/O)
///
/// ── Reference Documentation ─────────────────────────────────────────
/// • FFmpeg Encoding Overview: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html
/// • Send/Receive Encoding API: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
/// • Hardware Acceleration Overview: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
/// • HW Acceleration Wiki: https://trac.ffmpeg.org/wiki/HWAccelIntro
/// </summary>
internal sealed unsafe class FFmpegNativeEncoder : IDisposable
{
/// <summary>
/// Struct containing configuration values to pass for audio encoding.
/// </summary>
public struct AudioSettings
{
/// <summary>Sample rate of input audio (Hz). e.g., 44100, 48000.</summary>
public int SampleRate;
/// <summary>Number of input audio channels. e.g., 1 (mono), 2 (stereo).</summary>
public int Channels;
/// <summary>
/// Sample format of input audio. Default is AV_SAMPLE_FMT_FLT (32-bit float).
/// Unity's AudioClip.GetData() returns float[], so FLT is suitable.
/// </summary>
public AVSampleFormat InputSampleFormat;
/// <summary>
/// Audio codec ID to use. If default (0), AAC is used.
/// e.g., AV_CODEC_ID_AAC, AV_CODEC_ID_FLAC, AV_CODEC_ID_OPUS, etc.
/// </summary>
public AVCodecID AudioCodecId;
/// <summary>
/// Audio bitrate (in bps). If default (0), 256000 bps is used.
/// For AAC, 128000–320000 bps is common.
/// </summary>
public int Bitrate;
}
/// <summary>
/// Struct containing configuration values to pass to the encoder.
/// </summary>
public struct EncoderSettings
{
/// <summary>Width resolution of output video (pixels).</summary>
public int Width;
/// <summary>Height resolution of output video (pixels).</summary>
public int Height;
/// <summary>Frames per second (e.g., 60.0, 29.97, etc.).</summary>
public double FPS;
/// <summary>Video codec type to use (H264, H265, AV1, VP9, VVC).</summary>
public Codec Codec;
/// <summary>Bitrate control mode (CQP, CBR, VBR).</summary>
public RateControlMode Mode;
/// <summary>Quality value used in CQP mode. Lower values mean higher quality. (e.g., H.264 CRF 18 ≈ visually lossless)</summary>
public int QualityValue;
/// <summary>Target bitrate in VBR/CBR mode (in kbps). Converted to bps internally for FFmpeg.</summary>
public int TargetBitrate;
/// <summary>Maximum bitrate in VBR mode (in kbps). Determines peak quality limit.</summary>
public int MaxBitrate;
/// <summary>GOP (Group of Pictures) size (in seconds). Represents keyframe interval.</summary>
public double GopSize;
/// <summary>Whether to force the use of software CPU encoder.</summary>
public bool ForceSoftware;
/// <summary>
/// Audio encoding settings. If null, encodes video only without audio stream.
/// If configured, video and audio are muxed together.
/// </summary>
public AudioSettings? Audio;
}
// ──────────────────────────────────────────────────────────────
// FFmpeg Core Struct Pointers
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Context managing format information for the output container (mp4, mkv, etc.).
/// Used for writing file headers/trailers, recording packets, etc.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html
/// </summary>
private AVFormatContext* _pFormatContext = null;
/// <summary>
/// Context managing codec (encoder) state.
/// All parameters required for encoding such as resolution, pixel format, framerate, and quality settings are configured here.
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
/// </summary>
private AVCodecContext* _pCodecContext = null;
/// <summary>
/// Video stream within the output file.
/// A single container (format context) can contain multiple streams (video, audio, etc.),
/// but here only one video stream is used.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html
/// </summary>
private AVStream* _pVideoStream = null;
// ──────────────────────────────────────────────────────────────
// Frame Buffers and Colorspace Converter (Scaler) Pointers
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Frame structure pointing to raw RGBA frame data captured from Unity.
/// Does not own pixel data; references external buffer via data[0] and linesize[0].
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
/// </summary>
private AVFrame* _pSrcFrame = null;
/// <summary>
/// Frame converted to the pixel format required by the encoder (YUV420P or NV12).
/// Actual pixel data memory is allocated via av_frame_get_buffer().
/// </summary>
private AVFrame* _pDstFrame = null;
/// <summary>
/// libswscale colorspace/resolution conversion context.
/// Performs RGBA (Unity) → YUV420P/NV12 (encoder) conversion.
/// sws_scale is used even when only converting colorspace without changing resolution.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html
/// </summary>
private SwsContext* _pSwsContext = null;
/// <summary>Accumulated index of encoded frames. Used as the PTS (Presentation Time Stamp) value.</summary>
private long _frameIndex;
/// <summary>
/// Whether the encoder accepts frames: set to true when Start() completes,
/// and to false when End() runs (End() is not repeated) or Dispose() releases the resources.
/// </summary>
private bool _isInitialized;
/// <summary>Whether Dispose() has already been called. Prevents duplicate cleanup.</summary>
private bool _disposed;
/// <summary>Lock object for synchronizing encoder state and resource access in multithreaded environments.</summary>
private readonly object _lock = new();
/// <summary>
/// Whether hw_frames_ctx mode (GPU frame pool) is active in the QSV encoder.
/// If true, NV12 → QSV hardware surface upload is required every frame.
/// If false, passes NV12 frames in system memory directly to the encoder.
/// </summary>
private bool _isQsvEncoder;
/// <summary>
/// Pixel format of the frames passed to the encoder (output of the color conversion).
/// - Hardware encoders (NVENC, AMF, QSV): NV12 (Y plane + UV interleaved plane)
/// - libvvenc: YUV420P10LE (10-bit, 3 planes)
/// - Other software encoders: YUV420P (3 planes Y/U/V, 4:2:0 chroma subsampling)
///
/// Reference - Major pixel format relationships:
/// RGBA (Unity source, 4-channel 8-bit)
/// ↓ sws_scale conversion
/// YUV420P (3 planes Y/U/V, 1/4 chroma resolution) or
/// NV12 (1 Y plane + 1 UV interleaved plane, 1/4 chroma resolution)
///
/// Reference: https://ffmpeg.org/doxygen/trunk/pixfmt_8h.html
/// </summary>
private AVPixelFormat _dstPixFmt = AVPixelFormat.AV_PIX_FMT_YUV420P;
/// <summary>Pixel format of the finally selected and running encoder.</summary>
public AVPixelFormat SelectedPixelFormat => _dstPixFmt;
/// <summary>
/// Packet structure to temporarily hold encoded packet data.
/// Cached as a member variable to prevent allocation/deallocation overhead for each frame.
/// </summary>
private AVPacket* _pPacket = null;
/// <summary>
/// GPU frame structure used in QSV hardware acceleration mode.
/// Cached as a member variable to prevent allocation/deallocation overhead for each frame.
/// </summary>
private AVFrame* _pHwFrame = null;
private bool _loggedConversionMode;
// ──────────────────────────────────────────────────────────────
// FFmpeg Struct Pointers for Audio Encoding
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Context managing audio codec (encoder) state.
/// Audio encoding parameters such as sample rate, channel layout, and sample format are configured here.
/// </summary>
private AVCodecContext* _pAudioCodecContext = null;
/// <summary>Audio stream within the output file.</summary>
private AVStream* _pAudioStream = null;
/// <summary>Packet structure to temporarily hold encoded audio packet data.</summary>
private AVPacket* _pAudioPacket = null;
/// <summary>
/// Audio resampling context (SwrContext).
/// Converts input PCM format (e.g., float interleaved) → encoder required format (e.g., float planar).
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html
/// </summary>
private SwrContext* _pSwrContext = null;
/// <summary>
/// Audio FIFO buffer. Collects input samples of arbitrary sizes and passes them in frame_size units required by the encoder.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__audiofifo.html
/// </summary>
private AVAudioFifo* _pAudioFifo = null;
/// <summary>Frame structure to pass to the audio encoder.</summary>
private AVFrame* _pAudioFrame = null;
/// <summary>Accumulated index of encoded audio samples. Used as the PTS value.</summary>
private long _audioSampleIndex;
/// <summary>Whether audio encoding is enabled.</summary>
private bool _audioEnabled;
/// <summary>Bytes of one input sample of every channel (interleaved input format × channel count).</summary>
private int _audioInputBytesPerSample;
/// <summary>Encoding settings passed at construction time (immutable).</summary>
private readonly EncoderSettings _settings;
public FFmpegNativeEncoder(EncoderSettings settings)
{
_settings = settings;
}
// ================================================================
// Utility Methods
// ================================================================
/// <summary>
/// Returns a list of candidate encoders to try for the selected codec in priority order.
///
/// Order (GPUUtil.GetPrioritizedEncoders):
/// 1. Hardware encoders of the active GPU vendor
/// 2. The remaining encoders in the declaration order of VideoEncoder.All
/// (per codec: NVENC → QSV → AMF → VideoToolbox → software, for the encoders the codec has)
/// With ForceSoftware, only the software encoders are returned.
///
/// - nvenc: Hardware encoder dedicated to NVIDIA GPUs (CUDA-based)
/// - amf: Hardware encoder dedicated to AMD GPUs (Advanced Media Framework)
/// - qsv: Hardware encoder dedicated to Intel GPUs/iGPUs (Quick Sync Video, Media SDK)
/// - lib*: CPU-based software encoders (always available, but slower)
///
/// Iterates through this list in Start() to use the first workable encoder.
/// </summary>
private VideoEncoder[] GetEncoderCandidates()
{
return GPUUtil.GetPrioritizedEncoders(_settings.Codec, _settings.ForceSoftware).ToArray();
}
/// <summary>
/// Expands the candidate list into (encoder, input mode) attempts in order.
/// A QSV encoder is attempted twice: system memory input mode (useFramePool = false) first,
/// then GPU frame pool input mode (useFramePool = true). Other encoders are attempted once (useFramePool = false).
/// </summary>
private static System.Collections.Generic.IEnumerable<(VideoEncoder Encoder, bool UseFramePool)> ExpandQsvInputModes(VideoEncoder[] candidates)
{
foreach (var encoder in candidates)
{
yield return (encoder, false);
if (encoder.Kind == EncoderKind.QSV)
{
yield return (encoder, true);
}
}
}
/// <summary>
/// Converts an FFmpeg error code (negative integer) to a human-readable string.
/// e.g., -1 → "Operation not permitted"
///
/// Calls av_strerror() internally to write the message to a stack-allocated buffer.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__error.html
/// </summary>
private string GetFFmpegErrorString(int errnum)
{
const ulong bufferSize = 1024;
byte* buffer = stackalloc byte[(int)bufferSize];
if (ffmpeg.av_strerror(errnum, buffer, bufferSize) >= 0)
{
return Marshal.PtrToStringAnsi((IntPtr)buffer) ?? $"Unknown error (Code: {errnum})";
}
return $"Unknown error (Code: {errnum})";
}
// ================================================================
// Encoder Initialization (Start)
// ================================================================
/// <summary>
/// Initializes the entire encoding pipeline.
///
/// Execution steps:
/// 1. Create container format context (AVFormatContext) for the output file
/// 2. Add video stream (AVStream)
/// 3. Iterate encoder candidates → configure codec context (AVCodecContext) → open encoder
/// 4. Copy codec parameters to stream
/// 5. Open output file and write header
/// 6. Initialize frame buffers (AVFrame) and colorspace converter (SwsContext)
///
/// On failure, calls Dispose() to clean up partially initialized resources before rethrowing the exception.
/// </summary>
/// <param name="outputPath">Output video file path (e.g., "C:\output\video.mp4")</param>
public void Start(string outputPath)
{
lock (_lock)
{
if (_isInitialized || _disposed) return;
try
{
// ── Step 1: Create output container format context ──
// avformat_alloc_output_context2 automatically selects an appropriate
// output format (mp4, mkv, etc.) based on the file extension (outputPath).
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html
fixed (AVFormatContext** ppFormatCtx = &_pFormatContext)
{
ffmpeg.avformat_alloc_output_context2(ppFormatCtx, null, null, outputPath);
}
if (_pFormatContext == null) throw new Exception("Failed to create output format context.");
// ── Step 2: Add video stream ──
// Creates a single video track (stream) inside the container.
// Additional audio streams can be created here if needed.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html
_pVideoStream = ffmpeg.avformat_new_stream(_pFormatContext, null);
if (_pVideoStream == null) throw new Exception("Failed to create video stream.");
// ── Step 3: Select and open encoder ──
// Iterates through the priority list returned by GetEncoderCandidates()
// to find the first available encoder.
var candidates = GetEncoderCandidates();
var openedSuccessfully = false;
foreach (var (encoder, useFramePool) in ExpandQsvInputModes(candidates))
{
// avcodec_find_encoder_by_name: Searches for an encoder by name (e.g., "h264_nvenc").
// Returns null if the encoder is not included in the build.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encoding.html
var pCodec = ffmpeg.avcodec_find_encoder_by_name(encoder.Name);
if (pCodec == null)
{
Melon<RenderMod>.Logger.Warning($"Encoder '{encoder.Name}' not found in FFmpeg library. Testing next candidate codec.");
continue;
}
// avcodec_alloc_context3: Allocates a codec context for the selected encoder.
// All encoding parameters such as resolution, framerate, quality, etc., are set on this context.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html
_pCodecContext = ffmpeg.avcodec_alloc_context3(pCodec);
if (_pCodecContext == null) continue;
_pCodecContext->strict_std_compliance = ffmpeg.FF_COMPLIANCE_EXPERIMENTAL;
// ── Basic video parameters configuration ──
_pCodecContext->width = _settings.Width;
_pCodecContext->height = _settings.Height;
_pCodecContext->color_range = AVColorRange.AVCOL_RANGE_JPEG;
// thread_count = 0: FFmpeg automatically allocates threads according to the system CPU core count.
// For libvvenc, the FFmpeg wrapper passes thread_count as the VVenC internal thread count,
// so setting 0 (auto) is required for parallel encoding utilizing all cores.
_pCodecContext->thread_count = 0;
// ── Pixel format configuration ──
// Video encoders generally expect input in YUV420P or NV12 format.
//
// • YUV420P: 3 independent planes Y/U/V. Used by the software encoders (libvvenc: YUV420P10LE).
// • NV12: Y plane + UV interleaved plane. Used by the hardware encoders (NVENC, AMF, QSV).
// • QSV: the converted frames are NV12 (selectedPixFmt). The codec context pix_fmt set here is
// AV_PIX_FMT_QSV and is replaced in the QSV device setup below according to the input mode:
// system memory input mode: pix_fmt = NV12, the encoder receives the NV12 frames directly.
// GPU frame pool input mode: pix_fmt stays AV_PIX_FMT_QSV, and SubmitDstFrame() uploads
// each NV12 frame to a QSV surface with av_hwframe_transfer_data.
//
// Reference: https://ffmpeg.org/doxygen/trunk/pixfmt_8h.html
var selectedPixFmt = AVPixelFormat.AV_PIX_FMT_YUV420P;
if (encoder.Kind == EncoderKind.QSV)
{
selectedPixFmt = AVPixelFormat.AV_PIX_FMT_NV12;
_pCodecContext->pix_fmt = AVPixelFormat.AV_PIX_FMT_QSV;
}
else if (encoder.Kind is EncoderKind.NVENC or EncoderKind.AMF)
{
selectedPixFmt = AVPixelFormat.AV_PIX_FMT_NV12;
_pCodecContext->pix_fmt = selectedPixFmt;
}
else if (encoder == VideoEncoder.LibVvenc)
{
// libvvenc: Only 10-bit YUV420P10LE supported (yuv420p verified unsupported in this build)
selectedPixFmt = AVPixelFormat.AV_PIX_FMT_YUV420P10LE;
_pCodecContext->pix_fmt = selectedPixFmt;
}
else
{
_pCodecContext->pix_fmt = selectedPixFmt;
}
// ── Framerate and timebase configuration ──
// time_base: Base unit of time interval between frames.
// e.g., 60fps → time_base = 1/60 sec (num=1, den=60)
// Multiplying PTS (Presentation Time Stamp) value by time_base yields actual time in seconds.
// PTS=150, time_base=1/60 → 150 * (1/60) = 2.5 seconds
//
// framerate: Framerate value giving hints to the encoder (reciprocal of time_base).
//
// RationalUtil: Converts FPS (double) into fractional form (numerator/denominator).
// e.g., 60.00 -> 60/1
//
// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
var fpsRational = RationalUtil.Double2MaxPrecisionInt(_settings.FPS);
_pCodecContext->time_base = new AVRational
{ num = fpsRational.Denominator, den = fpsRational.Numerator };
_pCodecContext->framerate = new AVRational
{ num = fpsRational.Numerator, den = fpsRational.Denominator };
// ── GOP (Group of Pictures) size configuration ──
// GOP: Keyframe (I-frame) interval. e.g., gop_size=120, 60fps → keyframe every 2 seconds.
// Keyframes contain full images and serve as seek points.
// Too large reduces seek precision, too small increases file size.
var calculatedGop = Math.Max(1, (int)Math.Round(_settings.FPS * _settings.GopSize));
if (encoder == VideoEncoder.LibVvenc)
{
// libvvenc (VVenC) requires IntraPeriod (gop_size) to be a multiple of its GOPSize (typically 32).
_pCodecContext->gop_size = Math.Max(32, ((calculatedGop + 15) / 32) * 32);
}
else
{
_pCodecContext->gop_size = calculatedGop;
}
// ── B-frame configuration ──
// B-frames (bidirectional predictive frames) reference both earlier and later frames.
// They raise the compression ratio and make the encoder output packets out of presentation order
// (dts < pts for reordered frames).
//
// Configuration per encoder:
// SVT-AV1: svtav1-params "pred-struct=1" (low delay prediction structure).
// Setting max_b_frames on AVCodecContext makes SVT-AV1 report a "bad parameter" error.
// libvvenc: max_b_frames = -1 (encoder default GOP structure).
// Others: max_b_frames = 0 (no B-frames, frames are encoded in presentation order).
//
// has_b_frames is set by the encoder itself and is not written here.
//
// Reference: https://gitlab.com/AOMediaCodec/SVT-AV1/-/blob/master/Docs/Parameters.md
// (PredStructure: 0 = all intra, 1 = low delay, 2 = random access, default 2)
if (encoder == VideoEncoder.LibSvtAv1)
{
ffmpeg.av_opt_set(_pCodecContext->priv_data, "svtav1-params", "pred-struct=1", 0);
}
else if (encoder == VideoEncoder.LibVvenc)
{
// libvvenc relies on hierarchical GOP structures; forcing max_b_frames = 0 causes initialization failure.
// We explicitly set max_b_frames to -1 to allow libvvenc to use its default GOP reference distance.
_pCodecContext->max_b_frames = -1;
}
else
{
_pCodecContext->max_b_frames = 0;
}
// ── Global header flag ──
// Some container formats (mp4, etc.) require codec configuration info (SPS/PPS, etc.)
// to be recorded once in the file header rather than in each packet.
// If format has AVFMT_GLOBALHEADER flag set, turn on AV_CODEC_FLAG_GLOBAL_HEADER
// so the encoder stores configuration info in extradata.
// Reference: https://ffmpeg.org/doxygen/trunk/avformat_8h.html
if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_GLOBALHEADER) != 0)
{
_pCodecContext->flags |= ffmpeg.AV_CODEC_FLAG_GLOBAL_HEADER;
}
// ── Apply quality/bitrate settings ──
ApplyQualitySettings(encoder);
// ── QSV Encoder Hardware Device Initialization ──
// QSV (Quick Sync Video) uses fixed-function encoding hardware on Intel GPUs.
// Using QSV in FFmpeg requires creating a hardware device context.
//
// 3 strategies are attempted sequentially below:
// Strategy 1) Create QSV device directly with child_device_type=d3d11va option
// → FFmpeg internally creates D3D11 device as child and connects to QSV
// Strategy 2) Create D3D11VA device first, then derive QSV device
// → Effective when Strategy 1 fails on some drivers
// Strategy 3) Create QSV device directly (legacy method)
// → Fallback on older drivers/systems
//
// After obtaining device, the input mode is configured (see ExpandQsvInputModes):
// Input mode 1, hw_device_ctx: Encoder receives NV12 frames from system memory (attempted first)
// Input mode 2, hw_frames_ctx: Frames are uploaded to a QSV surface pool in GPU memory
// (attempted when the encoder cannot be opened in input mode 1)
//
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
if (encoder.Kind == EncoderKind.QSV)
{
AVBufferRef* hwDeviceCtx = null;
var qsvReady = false;
int err;
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
string childDeviceName = isWindows ? "d3d11va" : "vaapi";
AVHWDeviceType childDeviceType = isWindows ? AVHWDeviceType.AV_HWDEVICE_TYPE_D3D11VA : AVHWDeviceType.AV_HWDEVICE_TYPE_VAAPI;
// ── Strategy 1: child_device_type option ──
// Passing "child_device_type" dictionary to av_hwdevice_ctx_create
// lets FFmpeg internally create an appropriate lower device (Windows: D3D11, Linux: VAAPI) and connect to QSV session.
{
AVDictionary* opts = null;
ffmpeg.av_dict_set(&opts, "child_device_type", childDeviceName, 0);
err = ffmpeg.av_hwdevice_ctx_create(&hwDeviceCtx, AVHWDeviceType.AV_HWDEVICE_TYPE_QSV, null, opts, 0);
ffmpeg.av_dict_free(&opts);
if (err >= 0)
{
qsvReady = true;
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: Successfully created QSV device using child_device_type={childDeviceName}");
}
else
{
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: child_device_type failed — {GetFFmpegErrorString(err)} (Code: {err})");
}
}
// ── Strategy 2: Lower device → QSV derive ──
// Create lower device (D3D11VA / VAAPI) first,
// then derive QSV device using av_hwdevice_ctx_create_derived.
if (!qsvReady)
{
AVBufferRef* childDeviceCtx = null;
err = ffmpeg.av_hwdevice_ctx_create(&childDeviceCtx, childDeviceType, null, null, 0);
if (err >= 0)
{
err = ffmpeg.av_hwdevice_ctx_create_derived(&hwDeviceCtx, AVHWDeviceType.AV_HWDEVICE_TYPE_QSV, childDeviceCtx, 0);
if (err >= 0)
{
qsvReady = true;
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: Successfully created device by deriving {childDeviceType} → QSV");
}
ffmpeg.av_buffer_unref(&childDeviceCtx);
}
}
// ── Strategy 3: Direct QSV creation (legacy) ──
if (!qsvReady)
{
err = ffmpeg.av_hwdevice_ctx_create(&hwDeviceCtx, AVHWDeviceType.AV_HWDEVICE_TYPE_QSV, null, null, 0);
if (err >= 0) qsvReady = true;
}
if (!qsvReady)
{
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: All QSV device creation strategies failed. Testing next candidate codec.");
{
var pCtx = _pCodecContext;
ffmpeg.avcodec_free_context(&pCtx);
}
_pCodecContext = null;
continue;
}
if (!useFramePool)
{
// ── Input mode 1 (primary): hw_device_ctx + NV12 system memory ──
// The encoder receives NV12 frames in system memory (RAM) and the QSV runtime uploads them to the GPU.
//
// pix_fmt is the software format NV12.
// _isQsvEncoder = false: WriteFrame() passes the NV12 frame to the encoder without uploading it.
_pCodecContext->pix_fmt = AVPixelFormat.AV_PIX_FMT_NV12;
_pCodecContext->hw_device_ctx = ffmpeg.av_buffer_ref(hwDeviceCtx);
_isQsvEncoder = false;
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: Using system memory input mode (hw_device_ctx)");
}
else
{
// ── Input mode 2 (fallback): hw_frames_ctx GPU frame pool ──
// Used when the encoder cannot be opened in input mode 1.
// AVHWFramesContext pre-allocates a pool of QSV surfaces in GPU memory (pix_fmt AV_PIX_FMT_QSV),
// and WriteFrame() uploads each NV12 frame to a surface of the pool (av_hwframe_transfer_data).
//
// Pool creation depends on the texture types the GPU/driver accepts
// (e.g. Intel Arc A770 rejects NV12 textures with D3D11_BIND_RENDER_TARGET → E_INVALIDARG 0x80070057).
//
// initial_pool_size: Number of surfaces in the pool (QsvFramePoolSize).
// The encoder keeps submitted surfaces until they are encoded, so the pool has to hold
// the frames in the encoder pipeline plus the frame being uploaded.
// Width/height are aligned to multiples of 32: texture alignment condition required by QSV hardware.
//
// Frame pool memory type (AVQSVFramesContext.frame_type, a combination of MFX_MEMTYPE_* flags):
// Windows (D3D11): MFX_MEMTYPE_VIDEO_MEMORY_DECODER_TARGET (0x0010) is tried first, then 0.
// 0x0010 creates the D3D11 textures with D3D11_BIND_DECODER.
// 0 lets FFmpeg select MFX_MEMTYPE_VIDEO_MEMORY_PROCESSOR_TARGET (0x0020),
// which creates the textures with D3D11_BIND_RENDER_TARGET.
// Other platforms (VAAPI): 0. FFmpeg sets the memory type of a VAAPI child pool itself.
// Both types are accepted by the QSV encoder as video memory input.
// When every type fails, the next candidate encoder is tested.
//
// Reference:
// AVHWFramesContext: https://ffmpeg.org/doxygen/trunk/structAVHWFramesContext.html
// av_hwframe_ctx_alloc: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
// AVQSVFramesContext.frame_type: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavutil/hwcontext_qsv.h
// frame_type → D3D11 bind flags (qsv_init_child_ctx, qsv_get_d3d11va_bind_flags): https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavutil/hwcontext_qsv.c
// MFX_MEMTYPE_* values: https://github.com/intel/libvpl/blob/main/api/vpl/mfxstructures.h
bool framesCtxReady = false;
int[] frameTypes = isWindows
? new[] { QsvMemTypeVideoMemoryDecoderTarget, 0 }
: new[] { 0 };
foreach (var frameType in frameTypes)
{
AVBufferRef* hwFramesRef = ffmpeg.av_hwframe_ctx_alloc(hwDeviceCtx);
if (hwFramesRef == null) break;
var framesCtx = (AVHWFramesContext*)hwFramesRef->data;
framesCtx->format = AVPixelFormat.AV_PIX_FMT_QSV; // Hardware surface format
framesCtx->sw_format = AVPixelFormat.AV_PIX_FMT_NV12; // Software-side format
framesCtx->width = (_settings.Width + 31) & ~31; // 32-multiple alignment (bitmask)
framesCtx->height = (_settings.Height + 31) & ~31; // 32-multiple alignment (bitmask)
framesCtx->initial_pool_size = QsvFramePoolSize;
SetQsvFrameType(framesCtx, frameType);
err = ffmpeg.av_hwframe_ctx_init(hwFramesRef);
if (err >= 0)
{
// Success: Connect hw_frames_ctx to codec context.
// Increment ref count with av_buffer_ref to keep valid after hwFramesRef is released.
_pCodecContext->hw_frames_ctx = ffmpeg.av_buffer_ref(hwFramesRef);
_isQsvEncoder = true;
framesCtxReady = true;
Melon<RenderMod>.Logger.Msg($"{encoder.Name}: Using GPU frame pool input mode (hw_frames_ctx, frame_type=0x{frameType:X4}, pool={QsvFramePoolSize})");
ffmpeg.av_buffer_unref(&hwFramesRef);
_pHwFrame = ffmpeg.av_frame_alloc();
if (_pHwFrame == null)
{
// The codec context (and the frames context it holds) is released by Dispose in the catch block of Start.
ffmpeg.av_buffer_unref(&hwDeviceCtx);
throw new Exception("Failed to allocate QSV hardware frame structure.");
}
break;
}
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: hw_frames_ctx failed with frame_type=0x{frameType:X4} — {GetFFmpegErrorString(err)} (Code: {err})");
ffmpeg.av_buffer_unref(&hwFramesRef);
}
if (!framesCtxReady)
{
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: GPU frame pool input mode is unavailable. Testing next candidate codec.");
ffmpeg.av_buffer_unref(&hwDeviceCtx);
{
var pCtx = _pCodecContext;
ffmpeg.avcodec_free_context(&pCtx);
}
_pCodecContext = null;
continue;
}
}
// Release the local reference of hwDeviceCtx.
// The device stays alive through the references taken above:
// system memory input mode: codec context hw_device_ctx (av_buffer_ref)
// GPU frame pool input mode: the frames context (av_hwframe_ctx_alloc keeps a device reference),
// held by codec context hw_frames_ctx
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__buffer.html
ffmpeg.av_buffer_unref(&hwDeviceCtx);
}
// ── Specify per-encoder speed preset ──
// Presets determine the tradeoff between encoding speed ↔ compression efficiency.
// Fast preset: Fast encoding, but larger file size
// Slow preset: Slow encoding, but smaller file size at same quality
//
// Reference: https://ffmpeg.org/doxygen/trunk/group__opt__set__funcs.html
if (encoder.Kind == EncoderKind.NVENC)
{
// NVENC preset range: p1 (fastest) ~ p7 (highest quality). p4 = medium (default)
ffmpeg.av_opt_set(_pCodecContext->priv_data, "preset", "p4", 0);
}
else if (encoder == VideoEncoder.Libx264 || encoder == VideoEncoder.Libx265)
{
// x264/x265 presets: ultrafast, superfast, veryfast, faster, fast, medium, slow, ...
// CPU encoding easily becomes a bottleneck, so use fast presets
ffmpeg.av_opt_set(_pCodecContext->priv_data, "preset", "veryfast", 0);
}
else if (encoder == VideoEncoder.LibSvtAv1)
{
// SVT-AV1 presets: 0 (highest quality/lowest speed) ~ 13 (highest speed/lowest quality)
ffmpeg.av_opt_set(_pCodecContext->priv_data, "preset", "10", 0);
}
else if (encoder == VideoEncoder.LibAomAv1)
{
// libaom cpu-used: 0 (highest quality) ~ 8 (highest speed)
ffmpeg.av_opt_set(_pCodecContext->priv_data, "cpu-used", "6", 0);
// "realtime" usage: Single pass, low latency encoding mode
ffmpeg.av_opt_set(_pCodecContext->priv_data, "usage", "realtime", 0);
}
else if (encoder == VideoEncoder.LibVvenc)
{
// libvvenc preset: slower, slow, medium, fast, faster. Default is medium, change to faster to gain performance
ffmpeg.av_opt_set(_pCodecContext->priv_data, "preset", "faster", 0);
}
// Other encoders (AMF, QSV, etc.) use default presets.
// Setting unsupported options can cause errors, so skip.
// ── Open encoder ──
// avcodec_open2: Initializes encoder based on all parameters configured above.
// Returns >= 0 on success, enabling frame encoding via avcodec_send_frame/avcodec_receive_packet afterwards.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html
Melon<RenderMod>.Logger.Msg($"[Encoder Diagnostic] {encoder.Name}: {_pCodecContext->width}x{_pCodecContext->height}, pix_fmt={_pCodecContext->pix_fmt}, gop={_pCodecContext->gop_size}, max_b_frames={_pCodecContext->max_b_frames}, framerate={_pCodecContext->framerate.num}/{_pCodecContext->framerate.den}, thread_count={_pCodecContext->thread_count}, bit_rate={_pCodecContext->bit_rate}, rc_max_rate={_pCodecContext->rc_max_rate}, rc_buffer_size={_pCodecContext->rc_buffer_size}, mode={_settings.Mode}");
var openResult = ffmpeg.avcodec_open2(_pCodecContext, pCodec, null);
if (openResult >= 0)
{
var isHardware = encoder.IsHardware;
var accelStatus = isHardware ? "Hardware Acceleration Enabled" : "Software CPU Usage";
Melon<RenderMod>.Logger.Msg($"Video encoder successfully initialized: {encoder.Name} ({accelStatus})");
_dstPixFmt = selectedPixFmt;
openedSuccessfully = true;
break;
}
// If open fails, free codec context and QSV frame state, then try next candidate.
var inputMode = encoder.Kind == EncoderKind.QSV ? (useFramePool ? " (GPU frame pool input mode)" : " (system memory input mode)") : "";
Melon<RenderMod>.Logger.Warning($"Failed to initialize encoder {encoder.Name}{inputMode}: {GetFFmpegErrorString(openResult)} (Code: {openResult}). Testing next candidate.");
var pCodecCtx = _pCodecContext;
ffmpeg.avcodec_free_context(&pCodecCtx);
_pCodecContext = null;
_isQsvEncoder = false;
if (_pHwFrame != null)
{
var pHwFrame = _pHwFrame;
ffmpeg.av_frame_free(&pHwFrame);
_pHwFrame = null;
}
}
if (!openedSuccessfully || _pCodecContext == null)
{
throw new Exception("Failed to initialize any available video encoder (both hardware accelerated and CPU software).");
}
// ── Step 4: Copy codec parameters to stream ──
// Copy parameters set in codec context (resolution, codec ID, profile, etc.)
// to stream's codecpar. Information to be written to container header.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html
var paramResult = ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext);
if (paramResult < 0)
throw new Exception($"Failed to copy codec parameters to stream — {GetFFmpegErrorString(paramResult)} (Code: {paramResult})");
// Stream time base: unit of the timestamps stored in the container.
// Set to the codec time base (1 / framerate) so that every frame interval is a whole number of ticks.
// When it is left unset, the muxer uses 1/90000, in which the interval of rates such as
// 59.94 fps (1501.5 ticks), 23.976 fps (3753.75 ticks) or 165 fps (545.45 ticks) is not an integer,
// and the frame durations alternate between two values.
// The muxer may still adjust the value in avformat_write_header (e.g. mp4 scales it to at least 10000);
// packet timestamps are rescaled to the final value in SendFrameAndWritePackets.
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/mux.c (init_muxer: default 1/90000)
_pVideoStream->time_base = _pCodecContext->time_base;
// ── Step 4.5 (Optional): Initialize audio stream ──
// If EncoderSettings.Audio is set, add audio stream.
if (_settings.Audio.HasValue)
{
InitializeAudioStream(_settings.Audio.Value);
}
// ── Step 5: Open output file I/O ──
// Formats without AVFMT_NOFILE flag require actual file I/O.
// Open file in write mode using avio_open and connect to pb (I/O context) of format context.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__io.html
if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_NOFILE) == 0)
{
var avioResult = ffmpeg.avio_open(&_pFormatContext->pb, outputPath, ffmpeg.AVIO_FLAG_WRITE);
if (avioResult < 0)
throw new Exception($"Cannot open output file {outputPath} — {GetFFmpegErrorString(avioResult)} (Code: {avioResult})");
}
// Write file header (ftyp, moov, container metadata).
// Packets can be written with av_interleaved_write_frame after this point.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html
var headerResult = ffmpeg.avformat_write_header(_pFormatContext, null);
if (headerResult < 0)
throw new Exception($"Failed to write file header — {GetFFmpegErrorString(headerResult)} (Code: {headerResult})");
// ── Step 6: Initialize frame buffers and colorspace converter ──
// av_frame_alloc: Allocates AVFrame structure on heap (no pixel data yet).
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
_pSrcFrame = ffmpeg.av_frame_alloc();
_pDstFrame = ffmpeg.av_frame_alloc();
_pPacket = ffmpeg.av_packet_alloc();
if (_pSrcFrame == null || _pDstFrame == null || _pPacket == null)
throw new Exception("Failed to allocate frame or packet structure.");
AllocateDstFrameBuffer();
// sws_getContext: Initialize software scaler (colorspace converter).
// Input: RGBA (Unity capture format, 4 bytes/pixel)
// Output: YUV420P or NV12 (encoder required format)
//
// Colorspace is converted without resolution change here, but sws_scale
// can also perform resizing simultaneously.
//
// SWS_POINT: Nearest neighbor sampling. The luma plane keeps the source resolution (1:1).
// The chroma planes are subsampled to half width and height (4:2:0) with the same filter,
// so each chroma sample takes one source pixel instead of averaging the 2x2 block.
// This scaler is used only by the CPU conversion path (WriteFrame with RGBA input).
//
// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html
_pSwsContext = ffmpeg.sws_getContext(
_settings.Width, _settings.Height, AVPixelFormat.AV_PIX_FMT_RGBA,
_settings.Width, _settings.Height, _dstPixFmt,
(int)SwsFlags.SWS_POINT, null, null, null);
if (_pSwsContext == null)
throw new Exception("Failed to initialize software scaler.");
_isInitialized = true;
_loggedConversionMode = false;
}
catch (NotSupportedException ex)
{
Dispose();
throw new Exception("Failed to load FFmpeg native library (DLL). Please check if FFmpeg DLL files (avcodec, avformat, etc.) in UserLibs folder are missing or corrupted, delete the DLL files in UserLibs, and restart the game.", ex);
}
catch (Exception)
{
// Clean up all partially allocated resources if failure occurs during initialization.
Dispose();
throw;
}
}
}
// ================================================================
// Quality / Bitrate Settings
// ================================================================
/// <summary>
/// Sets quality parameters on the codec context according to the rate control mode (CBR, VBR, CQP) selected by the user.
///
/// ── Bitrate Control Mode Description ──
///
/// • CBR (Constant Bitrate): Maintains a constant bitrate. Suitable for streaming.
/// → bit_rate = rc_max_rate = rc_min_rate (all identical)
///
/// • VBR (Variable Bitrate): Bitrate varies according to scene complexity.
/// → bit_rate = target value, rc_max_rate = maximum allowed value
///
/// • CQP (Constant Quantization Parameter): Uses a fixed quantization value.
/// → Lower value = higher quality/larger size, Higher value = lower quality/smaller size
/// → Configuration method varies per encoder (see below)
///
/// Reference - CQP options per encoder:
/// NVENC: "qp" (single value applied to I/P/B frames)
/// AMF: "qp_i" + "qp_p" (I-frame and P-frame configured separately)
/// QSV: AVCodecContext.global_quality (Intel ICQ mode)
/// Software: "crf" (Constant Rate Factor, smarter quality control)
///
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
/// </summary>
private void ApplyQualitySettings(VideoEncoder encoder)
{
switch (_settings.Mode)
{
case RateControlMode.CBR:
// kbps → bps conversion (FFmpeg bit_rate field is in bps)
_pCodecContext->bit_rate = _settings.TargetBitrate * 1000L;
// libvvenc does not support rc_min_rate/rc_max_rate/rc_buffer_size,
// so set bit_rate only and delegate the rest to internal rate control.
if (encoder != VideoEncoder.LibVvenc)
{
// In CBR, set target/max/min bitrates all equal.
// rc_buffer_size determines the bitrate buffering size.
_pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L;
_pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000;
// HRD (Hypothetical Reference Decoder) signaling for CBR output, used for streaming compatibility.
// libx264: "nal-hrd=cbr" writes the NAL HRD information and pads filler data to keep the bitrate constant.
// FFmpeg's option description: "Signal HRD information (requires vbv-bufsize; cbr not allowed in .mp4)".
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/libx264.c ("nal-hrd": none/vbr/cbr)
// libx265: "x265-params" = "hrd=1" (--hrd: "Enable the signaling of HRD parameters to the decoder")
// + "strict-cbr=1" (--strict-cbr: "Enables stricter conditions to control bitrate deviance
// from the target bitrate in ABR mode").
// Reference: https://x265.readthedocs.io/en/master/cli.html
if (encoder == VideoEncoder.Libx264)
{
TrySetOption("nal-hrd", "cbr");
}
else if (encoder == VideoEncoder.Libx265)
{
TrySetOption("x265-params", "hrd=1:strict-cbr=1");
}
}
break;
case RateControlMode.VBR:
// kbps → bps conversion
_pCodecContext->bit_rate = _settings.TargetBitrate * 1000L;
// Skip libvvenc as it does not support rc_max_rate/rc_buffer_size.
if (encoder != VideoEncoder.LibVvenc)
{
// If MaxBitrate is larger than TargetBitrate, use as is;
// otherwise set 1.5x TargetBitrate as max rate.
var maxRate = _settings.MaxBitrate > _settings.TargetBitrate
? _settings.MaxBitrate * 1000L : _settings.TargetBitrate * 1500L;
_pCodecContext->rc_max_rate = maxRate;
// Set buffer size to 2x max bitrate to accommodate temporary bitrate spikes.
_pCodecContext->rc_buffer_size = (int)(maxRate * 2);
}
break;
case RateControlMode.CQP:
// Valid quality range per encoder. The range of the private options is read from the
// encoder's option definition (AVOption min/max); a declared minimum of -1 means "auto".
// NVENC: "qp" H.264/HEVC 0~51, AV1 0~255
// (libavcodec/nvenc_h264.c, nvenc_hevc.c: "qp" -1~51 / libavcodec/nvenc_av1.c: "qp" -1~255)
// AMF: "qp_i" H.264/HEVC 0~51, AV1 0~255
// (libavcodec/amfenc_h264.c, amfenc_hevc.c: "qp_i" -1~51 / libavcodec/amfenc_av1.c: "qp_i" -1~255)
// QSV: AVCodecContext.global_quality 1~51
// VVC: "qp" 0~63 (libavcodec/libvvenc.c: "qp" -1~63)
// Software: "crf" x264/x265 0~51 (https://x265.readthedocs.io/en/master/cli.html: --crf 0..51.0),
// VP9/AV1 0~63 (libavcodec/libvpxenc.c, libaomenc.c: "crf" -1~63 / libavcodec/libsvtav1.c: "crf" 0~63)
// Reference: https://github.com/FFmpeg/FFmpeg/tree/master/libavcodec
int qpMin, qpMax;
if (encoder.Kind == EncoderKind.QSV)
{
// QSV runs in ICQ (Intelligent Constant Quality) mode when global_quality > 0 and rc_max_rate is 0;
// global_quality 0 selects the encoder's default CQP. The ICQ quality is clamped to 1~51 by FFmpeg:
// libavcodec/qsvenc.c: q->param.mfx.ICQQuality = av_clip(avctx->global_quality, 1, 51);
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/qsvenc.c
qpMin = 1;
qpMax = 51;
}
else if (encoder.Kind == EncoderKind.NVENC) (qpMin, qpMax) = GetQualityRange("qp");
else if (encoder.Kind == EncoderKind.AMF) (qpMin, qpMax) = GetQualityRange("qp_i");
else if (encoder == VideoEncoder.LibVvenc) (qpMin, qpMax) = GetQualityRange("qp");
else (qpMin, qpMax) = GetQualityRange("crf"); // Software encoder (libx264, libx265, libvpx-vp9, etc.)
// Default value 18: Quality close to "visually lossless" in H.264.
// Provides similar level of quality in other codecs as well.
const int defaultQuality = 18;
var requestedQp = _settings.QualityValue;
int qpValue;
if (requestedQp < 0)
{
qpValue = Math.Clamp(defaultQuality, qpMin, qpMax);
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: Invalid quality value {requestedQp}. Using {qpValue}.");
}
else
{
qpValue = Math.Clamp(requestedQp, qpMin, qpMax);
if (qpValue != requestedQp)
Melon<RenderMod>.Logger.Warning($"{encoder.Name}: Quality value {requestedQp} is outside the supported range {qpMin}~{qpMax}. Using {qpValue}.");
}
if (encoder.Kind == EncoderKind.NVENC)
{
// NVENC: Applies same QP to I/P/B frames with single "qp" option
TrySetOption("qp", qpValue.ToString());
}
else if (encoder.Kind == EncoderKind.AMF)
{
// AMF: QP for I-frame (qp_i) and P-frame (qp_p) must be set separately.
// Since B-frames are disabled, qp_b is not set.
TrySetOption("qp_i", qpValue.ToString());
TrySetOption("qp_p", qpValue.ToString());
}
else if (encoder.Kind == EncoderKind.QSV)
{
// QSV: ICQ (Intelligent Constant Quality) mode, configured with AVCodecContext.global_quality.
_pCodecContext->global_quality = qpValue;
}
else if (encoder == VideoEncoder.LibVvenc)
{
// libvvenc: Specifies Constant QP mode with "qp" option
TrySetOption("qp", qpValue.ToString());
}
else
{
// CRF (Constant Rate Factor): Smarter quality control than QP.
// Automatically adjusts bitrate according to scene complexity to maintain consistent visual quality.
// Range: 0 (lossless) ~ 51 (lowest quality) for x264/x265, 0~63 for VP9/AV1. Typically between 18 and 28.
TrySetOption("crf", qpValue.ToString());
}
break;
default:
throw new ArgumentOutOfRangeException();
}
}
/// <summary>
/// Sets a private option (priv_data) of the encoder.
/// Returns true when FFmpeg accepts the option. Otherwise logs a warning with the FFmpeg error and returns false.
/// </summary>
// ReSharper disable once UnusedMethodReturnValue.Local
private bool TrySetOption(string name, string value)
{
var ret = ffmpeg.av_opt_set(_pCodecContext->priv_data, name, value, 0);
if (ret >= 0) return true;
Melon<RenderMod>.Logger.Warning($"[Encoder] Option '{name}={value}' was not applied: {GetFFmpegErrorString(ret)} (Code: {ret})");
return false;
}
/// <summary>
/// MFX_MEMTYPE_VIDEO_MEMORY_DECODER_TARGET (0x0010): frames are in video memory and belong to video decoder render targets.
/// Reference: https://github.com/intel/libvpl/blob/main/api/vpl/mfxstructures.h
/// </summary>
private const int QsvMemTypeVideoMemoryDecoderTarget = 0x0010;
/// <summary>
/// Number of surfaces in the QSV GPU frame pool (AVHWFramesContext.initial_pool_size) of GPU frame pool input mode.
/// Same value as the encoder frame pool of the FFmpeg hardware encoding example (initial_pool_size = 20).
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/doc/examples/vaapi_encode.c
/// </summary>
private const int QsvFramePoolSize = 20;
/// <summary>
/// Writes AVQSVFramesContext.frame_type of a QSV frames context (AVHWFramesContext.hwctx).
/// FFmpeg.AutoGen has no AVQSVFramesContext type, so the field is written at its offset.
/// Field order of AVQSVFramesContext: mfxFrameSurface1* surfaces, int nb_surfaces, int frame_type, mfxFrameInfo* info
/// → frame_type is at offset sizeof(pointer) + sizeof(int) (12 bytes on 64-bit).
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavutil/hwcontext_qsv.h
/// </summary>
private static void SetQsvFrameType(AVHWFramesContext* framesCtx, int frameType)
{
if (framesCtx->hwctx == null) return;
*(int*)((byte*)framesCtx->hwctx + IntPtr.Size + sizeof(int)) = frameType;
}
/// <summary>
/// Largest declared maximum that is treated as an actual quality range.
/// A declared maximum above this value is treated as unbounded, and the fallback range 0~51 is used.
/// The largest bounded quality range among the supported encoders is 0~255 (AV1 NVENC/AMF), while
/// libx264/libx265 declare "crf" as -1~FLT_MAX (libavcodec/libx264.c, libx265.c).
/// </summary>
private const double MaxMeaningfulQualityLimit = 1000;
/// <summary>
/// Returns the valid range of a private quality option of the encoder from its option definition (AVOption min/max).
/// e.g. 0~51 for H.264/HEVC, 0~63 for VP9/AV1/VVC, 0~255 for AV1 NVENC/AMF.
/// The lower bound is at least 0 (a declared minimum of -1 means "auto").
/// When the option is missing or its declared maximum is unbounded (see <see cref="MaxMeaningfulQualityLimit"/>), returns 0~51,
/// the QP scale of H.264/HEVC 8-bit (x264/x265 CRF 0~51).
/// Reference: https://x265.readthedocs.io/en/master/cli.html (--crf 0..51.0)
/// </summary>
/// <param name="optionName">Name of the quality option in priv_data.</param>
private (int Min, int Max) GetQualityRange(string optionName)
{
const int fallbackMax = 51;
var option = ffmpeg.av_opt_find(_pCodecContext->priv_data, optionName, null, 0, 0);
if (option == null || !(option->max > 0 && option->max <= MaxMeaningfulQualityLimit))
return (0, fallbackMax);
var min = Math.Max(0, (int)Math.Ceiling(option->min));
var max = (int)Math.Floor(option->max);
return min <= max ? (min, max) : (0, fallbackMax);
}
// ================================================================
// Write Frame
// ================================================================
/// <summary>
/// Encodes a single frame receiving RGBA pixel data (byte* pointer) captured from Unity.
///
/// Unity textures use Bottom-Left origin ((0,0) at bottom-left of screen),
/// while video standards use Top-Left origin ((0,0) at top-left of screen).
/// To resolve this, a "negative stride trick" is used:
///
/// ── Negative stride (linesize) trick ──
/// • Set data[0] to the start address of the last row (bottom-most line) of the buffer
/// • Set linesize[0] to -stride (negative)
/// → sws_scale reads rows in reverse, naturally performing vertical flip (vflip)
/// → Highly efficient as no extra memory copy or separate flip operation is required
///
/// stride = Width × 4 (RGBA has 4 bytes per pixel)
/// </summary>
/// <param name="buffer">Starting address of Unity RGBA pixel data</param>
public void WriteFrameFromBuffer(byte* buffer)
{
lock (_lock)
{
if (_disposed || !_isInitialized) return;
var stride = _settings.Width * 4; // RGBA = 4 bytes per pixel
_pSrcFrame->data[0] = buffer + stride * (_settings.Height - 1); // Starting address of last row
_pSrcFrame->linesize[0] = -stride; // Vertical flip with negative stride
_pSrcFrame->width = _settings.Width;
_pSrcFrame->height = _settings.Height;
_pSrcFrame->format = (int)AVPixelFormat.AV_PIX_FMT_RGBA;
WriteFrame();
}
}
/// <summary>
/// Encodes a frame by obtaining a read-only pointer from Unity NativeArray&lt;byte&gt;.
/// NativeArray is managed by Unity's native memory allocator,
/// allowing processing of large amounts of data without GC (Garbage Collector) pressure.
/// </summary>
public void WriteFrameFromNativeBuffer(NativeArray<byte> buffer)
{
lock (_lock)
{
if (_disposed || !_isInitialized) return;
ValidateInputLength(buffer.Length, isColorConverted: false);
WriteFrameFromBuffer((byte*)buffer.GetUnsafeReadOnlyPtr());
}
}
/// <summary>
/// Number of bytes read from the input buffer of one frame.
/// RGBA input (isColorConverted = false): Width × Height × 4
/// Pre-converted NV12: Width × Height (Y) + Width × (Height / 2) (interleaved UV)
/// Pre-converted YUV420P: Width × Height (Y) + 2 × (Width / 2) × (Height / 2) (U, V)
/// Returns 0 for a pre-converted input of another format (WritePreConvertedFrame rejects it).
/// </summary>
private long GetRequiredInputLength(bool isColorConverted)
{
long w = _settings.Width, h = _settings.Height;
if (!isColorConverted) return w * h * 4;
return _dstPixFmt switch
{
AVPixelFormat.AV_PIX_FMT_NV12 => w * h + w * (h / 2),
AVPixelFormat.AV_PIX_FMT_YUV420P => w * h + 2 * (w / 2) * (h / 2),
_ => 0
};
}
/// <summary>
/// Throws an ArgumentException when the input buffer of a frame is smaller than the bytes read from it
/// (GetRequiredInputLength), so that the copy does not read outside the buffer.
/// </summary>
private void ValidateInputLength(long length, bool isColorConverted)
{
var required = GetRequiredInputLength(isColorConverted);
if (length < required)
throw new ArgumentException($"Frame buffer is too small: {length} bytes, {required} bytes required for {_settings.Width}x{_settings.Height} {(isColorConverted ? _dstPixFmt.ToString() : "RGBA")} input.");
}
/// <summary>
/// Encodes a frame from IntPtr (native pointer).
/// Can be used from external native plugins or custom capture sources.
/// </summary>
public void SendDataFromNativeBuffer(IntPtr pointer, int length, bool isColorConverted)
{
lock (_lock)
{
if (_disposed || !_isInitialized) return;
if (!_loggedConversionMode)
{
_loggedConversionMode = true;
Melon<RenderMod>.Logger.Msg(isColorConverted
? "[Encoder] GPU color conversion enabled (NV12/YUV420P Direct Write, bypassing sws_scale)"
: "[Encoder] CPU color conversion fallback enabled (RGBA Input, using sws_scale)");
}
ValidateInputLength(length, isColorConverted);
if (isColorConverted) WritePreConvertedFrame((byte*)pointer.ToPointer());
else WriteFrameFromBuffer((byte*)pointer.ToPointer());
}
}
/// <summary>
/// Encodes a frame from a managed byte[].
/// Pins the array using fixed keyword to prevent GC from moving it before passing the pointer.
/// </summary>
public void SendDataFromBuffer(byte[] buffer, int length, bool isColorConverted)
{
lock (_lock)
{
if (_disposed || !_isInitialized) return;
if (length > buffer.Length)
throw new ArgumentException($"Frame length ({length} bytes) exceeds the buffer size ({buffer.Length} bytes).");
ValidateInputLength(length, isColorConverted);
fixed (byte* pBuffer = buffer)
{
if (!_loggedConversionMode)
{
_loggedConversionMode = true;
Melon<RenderMod>.Logger.Msg(isColorConverted
? "[Encoder] High-speed GPU color conversion enabled (NV12/YUV420P Direct Write, bypassing sws_scale)"
: "[Encoder] CPU color conversion fallback enabled (RGBA Input, using sws_scale)");
}
if (isColorConverted) WritePreConvertedFrame(pBuffer);
else WriteFrameFromBuffer(pBuffer);
}
}
}
/// <summary>
/// Sets the format, size and color range of the destination frame (_pDstFrame) and allocates a new pixel buffer for it.
/// av_frame_get_buffer allocates memory matching format, width and height and sets the data[]/linesize[] pointers.
/// Second argument 0 means default alignment.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_get_buffer)
/// </summary>
private void AllocateDstFrameBuffer()
{
_pDstFrame->format = (int)_dstPixFmt;
_pDstFrame->width = _settings.Width;
_pDstFrame->height = _settings.Height;
_pDstFrame->color_range = AVColorRange.AVCOL_RANGE_JPEG;
var ret = ffmpeg.av_frame_get_buffer(_pDstFrame, 0);
if (ret < 0)
throw new Exception($"Failed to allocate {_dstPixFmt} frame buffer — {GetFFmpegErrorString(ret)} (Code: {ret})");
}
/// <summary>
/// Makes the pixel buffer of the destination frame (_pDstFrame) writable before the next frame is written into it.
///
/// After avcodec_send_frame, the encoder may keep a reference to the buffer of the submitted frame
/// until that frame is encoded (e.g. QSV in system memory input mode). The buffer is then shared and not writable
/// (av_frame_is_writable returns 0).
/// In that case the frame releases its reference (av_frame_unref) and a new buffer is allocated.
/// The encoder keeps the previous buffer until it no longer needs it. The previous contents are not copied,
/// because every plane is overwritten by the next frame.
///
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_is_writable, av_frame_unref)
/// </summary>
private void PrepareDstFrameForWrite()
{
if (ffmpeg.av_frame_is_writable(_pDstFrame) != 0) return;
ffmpeg.av_frame_unref(_pDstFrame);
AllocateDstFrameBuffer();
}
/// <summary>
/// Encodes a frame by directly copying already pre-converted NV12 or YUV420P frame data.
/// </summary>
private void WritePreConvertedFrame(byte* buffer)
{
PrepareDstFrameForWrite();
var width = _settings.Width;
var height = _settings.Height;
switch (_dstPixFmt)
{
case AVPixelFormat.AV_PIX_FMT_NV12:
{
// Copy Y Plane
var pSrcY = buffer;
var pDstY = _pDstFrame->data[0];
var dstStrideY = _pDstFrame->linesize[0];
if (width == dstStrideY)
{
Buffer.MemoryCopy(pSrcY, pDstY, dstStrideY * height, width * height);
}
else
{
for (var y = 0; y < height; y++)
{
Buffer.MemoryCopy(pSrcY + y * width, pDstY + y * dstStrideY, width, width);
}
}
// Copy UV Plane (positioned after Y plane size)
var pSrcUV = buffer + width * height;
var pDstUV = _pDstFrame->data[1];
var dstStrideUV = _pDstFrame->linesize[1];
var uvHeight = height / 2;
if (width == dstStrideUV)
{
Buffer.MemoryCopy(pSrcUV, pDstUV, dstStrideUV * uvHeight, width * uvHeight);
}
else
{
for (var y = 0; y < uvHeight; y++)
{
Buffer.MemoryCopy(pSrcUV + y * width, pDstUV + y * dstStrideUV, width, width);
}
}
break;
}
case AVPixelFormat.AV_PIX_FMT_YUV420P:
{
// Copy Y Plane
var pSrcY = buffer;
var pDstY = _pDstFrame->data[0];
var dstStrideY = _pDstFrame->linesize[0];
if (width == dstStrideY)
{
Buffer.MemoryCopy(pSrcY, pDstY, dstStrideY * height, width * height);
}
else
{
for (var y = 0; y < height; y++)
{
Buffer.MemoryCopy(pSrcY + y * width, pDstY + y * dstStrideY, width, width);
}
}
// Copy U Plane
var pSrcU = buffer + width * height;
var pDstU = _pDstFrame->data[1];
var dstStrideU = _pDstFrame->linesize[1];
var chromaWidth = width / 2;
var chromaHeight = height / 2;
if (chromaWidth == dstStrideU)
{
Buffer.MemoryCopy(pSrcU, pDstU, dstStrideU * chromaHeight, chromaWidth * chromaHeight);
}
else
{
for (var y = 0; y < chromaHeight; y++)
{
Buffer.MemoryCopy(pSrcU + y * chromaWidth, pDstU + y * dstStrideU, chromaWidth, chromaWidth);
}
}
// Copy V Plane
var pSrcV = pSrcU + chromaWidth * chromaHeight;
var pDstV = _pDstFrame->data[2];
var dstStrideV = _pDstFrame->linesize[2];
if (chromaWidth == dstStrideV)
{
Buffer.MemoryCopy(pSrcV, pDstV, dstStrideV * chromaHeight, chromaWidth * chromaHeight);
}
else
{
for (var y = 0; y < chromaHeight; y++)
{
Buffer.MemoryCopy(pSrcV + y * chromaWidth, pDstV + y * dstStrideV, chromaWidth, chromaWidth);
}
}
break;
}
default:
throw new NotSupportedException($"This pixel format ({_dstPixFmt}) does not support direct copying of pre-converted buffers.");
}
SubmitDstFrame();
}
/// <summary>
/// Core logic for actual frame encoding.
///
/// Processing flow:
/// 1. PrepareDstFrameForWrite: Makes the destination frame buffer writable
/// (Allocates a new buffer if encoder is still referencing previous frame's buffer)
/// 2. sws_scale: RGBA → YUV420P/NV12 colorspace conversion
/// 3. (QSV hw_frames mode) av_hwframe_transfer_data: Upload NV12 → QSV hardware surface
/// 4. Pass to encoder after setting PTS
///
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html (sws_scale)
/// </summary>
private void WriteFrame()
{
PrepareDstFrameForWrite();
// ── Colorspace conversion: RGBA → YUV420P or NV12 ──
// sws_scale parameters explanation:
// _pSwsContext: Pre-initialized conversion context
// _pSrcFrame->data: Input data plane array (RGBA uses data[0] only)
// _pSrcFrame->linesize: Input stride per plane (bytes/row)
// 0: First source row index to process
// _settings.Height: Number of rows to process (= full height)
// _pDstFrame->data: Output data plane array
// _pDstFrame->linesize: Output stride per plane
ffmpeg.sws_scale(
_pSwsContext,
_pSrcFrame->data, _pSrcFrame->linesize,
0, _settings.Height,
_pDstFrame->data, _pDstFrame->linesize
);
SubmitDstFrame();
}
private void SubmitDstFrame()
{
if (_isQsvEncoder)
{
// ── QSV hw_frames_ctx mode: System memory → GPU surface upload ──
//
// av_hwframe_get_buffer: Get an empty hardware frame from hw_frames_ctx frame pool.
// av_hwframe_transfer_data: Copy (upload) NV12 software frame data to QSV surface.
//
// The frame is copied from system memory to the GPU surface on every call;
// the encoder then reads the surface from GPU memory.
//
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
ffmpeg.av_frame_unref(_pHwFrame);
try
{
var ret = ffmpeg.av_hwframe_get_buffer(_pCodecContext->hw_frames_ctx, _pHwFrame, 0);
if (ret < 0)
throw new Exception($"Failed to allocate QSV hardware frame buffer — {GetFFmpegErrorString(ret)} (Code: {ret})");
ret = ffmpeg.av_hwframe_transfer_data(_pHwFrame, _pDstFrame, 0);
if (ret < 0)
throw new Exception($"Failed to upload QSV frame — {GetFFmpegErrorString(ret)} (Code: {ret})");
// PTS (Presentation Time Stamp): Point in time when this frame should be displayed.
// Increments _frameIndex by 1, actual time calculated as PTS × time_base.
_pHwFrame->pts = _frameIndex++;
SendFrameAndWritePackets(_pHwFrame);
}
finally
{
// Release reference immediately after use so it can be returned to pool.
ffmpeg.av_frame_unref(_pHwFrame);
}
}
else
{
// ── General Encoders (Software or hw_device_ctx mode) ──
// Pass software frame directly to encoder.
// In hw_device_ctx mode, encoder handles GPU upload internally.
_pDstFrame->pts = _frameIndex++;
SendFrameAndWritePackets(_pDstFrame);
}
}
// ================================================================
// Encoder Send/Receive and Packet Writing
// ================================================================
/// <summary>
/// Encodes frames using FFmpeg's Send/Receive pattern and writes result packets to file.
///
/// ── Send/Receive API Description ──
/// Asynchronous encoding API introduced in FFmpeg 3.x:
///
/// avcodec_send_frame(ctx, frame)
/// → Pushes frame into encoder's internal queue.
/// → Starts encoding once enough frames are gathered.
///
/// avcodec_receive_packet(ctx, packet)
/// → Pops encoded packet (compressed data).
/// → EAGAIN: Output not ready yet (more frames needed)
/// → EOF: Encoding complete (on flush)
///
/// Since zero or more packets can result from a single send_frame call,
/// receive_packet is repeated until EAGAIN/EOF is returned.
///
/// ── Packet Timestamp Handling ──
/// av_packet_rescale_ts: Converts PTS/DTS from codec's time_base to stream's time_base.
/// Mandatory because codec and container may use different time_bases.
/// e.g., codec time_base=1/60, stream time_base=1/90000 → PTS 150 → PTS 225000
///
/// Reference:
/// Send/Receive API: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
/// Packet: https://ffmpeg.org/doxygen/trunk/group__lavc__packet.html
/// Interleaved Write: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html
/// </summary>
/// <param name="pFrame">Frame to encode. Pass null to flush encoder (output delayed frames).</param>
private void SendFrameAndWritePackets(AVFrame* pFrame)
{
// Push frame to encoder internal queue
var response = ffmpeg.avcodec_send_frame(_pCodecContext, pFrame);
if (response < 0)
{
throw new Exception($"Error occurred while sending frame to encoder: {GetFFmpegErrorString(response)} (Code: {response})");
}
// Pop all encoded packets and write to file.
try
{
while (true)
{
response = ffmpeg.avcodec_receive_packet(_pCodecContext, _pPacket);
// EAGAIN: Encoder needs more input frames → exit loop
// EOF: Encoder flush complete (all delayed frames outputted) → exit loop
if (response == ffmpeg.AVERROR(ffmpeg.EAGAIN) || response == ffmpeg.AVERROR_EOF) break;
if (response < 0)
throw new Exception($"Error occurred while receiving packet from encoder: {GetFFmpegErrorString(response)} (Code: {response})");
// Convert PTS/DTS from codec timebase → stream timebase
ffmpeg.av_packet_rescale_ts(_pPacket, _pCodecContext->time_base, _pVideoStream->time_base);
_pPacket->stream_index = _pVideoStream->index;
// av_interleaved_write_frame: Write packet to container.
// "Interleaved" means packets from multiple streams (video+audio) are interleaved
// and written in time order. This function is used even when only video is present.
response = ffmpeg.av_interleaved_write_frame(_pFormatContext, _pPacket);
if (response < 0)
throw new Exception($"Error occurred while writing packet to file: {GetFFmpegErrorString(response)} (Code: {response})");
// Unref packet data reference for reuse in next receive_packet.
ffmpeg.av_packet_unref(_pPacket);
}
}
finally
{
// Safely unref packet reference even on abnormal exit (exception)
ffmpeg.av_packet_unref(_pPacket);
}
}
// ================================================================
// Encoding Termination (End)
// ================================================================
/// <summary>
/// Finalizes encoding.
///
/// Most encoders queue frames in internal buffers (delayed frames)
/// and perform encoding when enough frames have accumulated. In End():
///
/// 1. Send a null frame to switch encoder to "flush mode".
/// → Encoder encodes and outputs all remaining frames in internal buffer.
/// → Pops packets until receive_packet returns EOF.
///
/// 2. av_write_trailer: Writes container trailer (end-of-file metadata).
/// For MP4, the moov atom (index info) is completed here.
/// ⚠️ If trailer is not written, the file becomes corrupted and unplayable!
/// av_write_trailer also flushes the output buffer and returns the write error of the output (AVIOContext.error), if any.
///
/// Throws an exception when a flush or the trailer fails (e.g. the disk is full), because the file is then incomplete.
/// End() runs once: after it returns or throws, further calls return immediately.
///
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__encoding.html
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavformat/mux.c (av_write_trailer)
/// </summary>
public void End()
{
lock (_lock)
{
if (_disposed || !_isInitialized) return;
try
{
// 1. Flush delayed frames from video encoder (send null frame as "end of input" signal)
SendFrameAndWritePackets(null);
// 1.5. Flush remaining samples in audio FIFO + flush audio encoder
if (_audioEnabled)
{
FlushAudioEncoder();
}
// 2. Write container trailer (moov atom and file termination metadata)
if (_pFormatContext != null)
{
var ret = ffmpeg.av_write_trailer(_pFormatContext);
if (ret < 0)
throw new Exception($"Failed to write the container trailer — {GetFFmpegErrorString(ret)} (Code: {ret})");
}
}
finally
{
_isInitialized = false;
}
}
}
// ================================================================
// Resource Cleanup (Dispose)
// ================================================================
/// <summary>
/// Actual resource cleanup logic. Cleanup order is important:
///
/// 1. SwsContext (scaler) → independent, can be freed first
/// 2. AVFrame (src/dst) → freed before codec as codec may reference them
/// 3. AVCodecContext (codec) → hw_frames_ctx/hw_device_ctx freed together
/// 4. AVFormatContext (format/IO) → includes closing file handle
///
/// Dispose does not finish the file (no encoder flush, no trailer). A file is complete only after End() returned.
/// Without End(), the output file is incomplete and is left to the caller (the renderer deletes it).
///
/// Reference:
/// av_frame_free: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
/// avcodec_free_context: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html
/// avformat_free_context: https://ffmpeg.org/doxygen/trunk/group__lavf__core.html
/// avio_closep: https://ffmpeg.org/doxygen/trunk/group__lavf__io.html
/// </summary>
public void Dispose()
{
lock (_lock)
{
if (_disposed) return;
_disposed = true;
_isInitialized = false;
// Release Sws scaler
if (_pSwsContext != null)
{
ffmpeg.sws_freeContext(_pSwsContext);
_pSwsContext = null;
}
// Release source frame memory (frame referencing Unity RGBA data)
if (_pSrcFrame != null)
{
var pFrame = _pSrcFrame;
ffmpeg.av_frame_free(&pFrame);
_pSrcFrame = null;
}
// Release destination frame memory (YUV420P/NV12 converted frame)
if (_pDstFrame != null)
{
var pFrame = _pDstFrame;
ffmpeg.av_frame_free(&pFrame);
_pDstFrame = null;
}
// Release QSV hardware frame memory
if (_pHwFrame != null)
{
var pFrame = _pHwFrame;
ffmpeg.av_frame_free(&pFrame);
_pHwFrame = null;
}
// Release packet memory
if (_pPacket != null)
{
var pPacket = _pPacket;
ffmpeg.av_packet_free(&pPacket);
_pPacket = null;
}
// Release codec context
// This function internally frees hw_frames_ctx and hw_device_ctx as well.
if (_pCodecContext != null)
{
var pCodecCtx = _pCodecContext;
ffmpeg.avcodec_free_context(&pCodecCtx);
_pCodecContext = null;
}
// ── Release audio-related resources ──
DisposeAudioResources();
// Close format context and I/O
if (_pFormatContext != null)
{
// Must manually close avio when not AVFMT_NOFILE.
// (Opened with avio_open, so close with avio_closep)
if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_NOFILE) == 0)
{
var pPb = _pFormatContext->pb;
if (pPb != null)
{
ffmpeg.avio_closep(&pPb);
_pFormatContext->pb = null;
}
}
// Release format context (internal streams, etc. freed as well)
ffmpeg.avformat_free_context(_pFormatContext);
_pFormatContext = null;
}
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Finalizer (destructor). Executed when collected by GC without Dispose() being called.
/// Safety net to prevent native resource leaks.
/// ⚠️ Finalizer execution timing is not guaranteed, so always manage resources
/// using a using statement or explicit Dispose() call.
/// </summary>
~FFmpegNativeEncoder() { Dispose(); }
// ================================================================
// Audio Encoding
// ================================================================
/// <summary>
/// Initializes the audio stream.
///
/// Execution steps:
/// 1. Search audio encoder (codec) and allocate codec context
/// 2. Set sample rate, channel layout, sample format, bitrate
/// 3. Add audio stream (AVStream) and copy codec parameters
/// 4. Initialize resampler (SwrContext) (input format → encoder format conversion)
/// 5. Initialize FIFO buffer (arbitrary input size → collect in frame_size units)
/// 6. Allocate audio frame and packet structures
///
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encoding.html
/// </summary>
private void InitializeAudioStream(AudioSettings audioSettings)
{
// ── 1. Determine and search audio codec ──
var audioCodecId = audioSettings.AudioCodecId != 0
? audioSettings.AudioCodecId
: AVCodecID.AV_CODEC_ID_AAC;
var pAudioCodec = ffmpeg.avcodec_find_encoder(audioCodecId);
if (pAudioCodec == null)
throw new Exception($"Audio encoder not found: {audioCodecId}");
// ── 2. Allocate codec context and configure parameters ──
_pAudioCodecContext = ffmpeg.avcodec_alloc_context3(pAudioCodec);
if (_pAudioCodecContext == null)
throw new Exception("Failed to allocate audio codec context.");
var sampleRate = audioSettings.SampleRate > 0 ? audioSettings.SampleRate : 48000;
var channels = audioSettings.Channels > 0 ? audioSettings.Channels : 2;
var bitrate = audioSettings.Bitrate > 0 ? audioSettings.Bitrate : 256000;
// Select the first sample format supported by the encoder.
// Most encoders prefer AV_SAMPLE_FMT_FLTP (float planar).
// AVCodec.sample_fmts is deprecated but still functional; avcodec_get_supported_config() is the replacement
#pragma warning disable CS0618
var encoderSampleFmt = pAudioCodec->sample_fmts != null ? pAudioCodec->sample_fmts[0] : AVSampleFormat.AV_SAMPLE_FMT_FLTP;
#pragma warning restore CS0618
_pAudioCodecContext->sample_rate = sampleRate;
_pAudioCodecContext->sample_fmt = encoderSampleFmt;
_pAudioCodecContext->bit_rate = bitrate;
_pAudioCodecContext->strict_std_compliance = ffmpeg.FF_COMPLIANCE_EXPERIMENTAL;
// Configure channel layout (FFmpeg 7.x+: use AVChannelLayout)
ffmpeg.av_channel_layout_default(&_pAudioCodecContext->ch_layout, channels);
// time_base: Audio typically uses 1/sample_rate.
_pAudioCodecContext->time_base = new AVRational { num = 1, den = sampleRate };
// Global header flag
if ((_pFormatContext->oformat->flags & ffmpeg.AVFMT_GLOBALHEADER) != 0)
{
_pAudioCodecContext->flags |= ffmpeg.AV_CODEC_FLAG_GLOBAL_HEADER;
}
// Open encoder
var ret = ffmpeg.avcodec_open2(_pAudioCodecContext, pAudioCodec, null);
if (ret < 0)
throw new Exception($"Failed to initialize audio encoder: {GetFFmpegErrorString(ret)} (Code: {ret})");
Melon<RenderMod>.Logger.Msg($"Audio encoder successfully initialized: {Marshal.PtrToStringAnsi((IntPtr)pAudioCodec->name)} (sample_rate={sampleRate}, channels={channels}, fmt={encoderSampleFmt})");
// ── 3. Add audio stream ──
_pAudioStream = ffmpeg.avformat_new_stream(_pFormatContext, null);
if (_pAudioStream == null)
throw new Exception("Failed to create audio stream.");
if (ffmpeg.avcodec_parameters_from_context(_pAudioStream->codecpar, _pAudioCodecContext) < 0)
throw new Exception("Failed to copy audio codec parameters to stream.");
_pAudioStream->time_base = _pAudioCodecContext->time_base;
// ── 4. Initialize resampler (SwrContext) ──
// Input: interleaved float (Unity AudioClip.GetData default) → Output: Encoder required format
var inputSampleFmt = audioSettings.InputSampleFormat != 0
? audioSettings.InputSampleFormat
: AVSampleFormat.AV_SAMPLE_FMT_FLT;
// Bytes of one sample of every channel in the interleaved input (e.g. FLT stereo: 4 × 2 = 8 bytes).
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__sampfmts.html (av_get_bytes_per_sample)
_audioInputBytesPerSample = ffmpeg.av_get_bytes_per_sample(inputSampleFmt) * audioSettings.Channels;
// Allocate SwrContext
fixed (SwrContext** ppSwr = &_pSwrContext)
{
// Configure input channel layout
AVChannelLayout inLayout;
ffmpeg.av_channel_layout_default(&inLayout, channels);
ret = ffmpeg.swr_alloc_set_opts2(
ppSwr,
&_pAudioCodecContext->ch_layout, encoderSampleFmt, sampleRate,
&inLayout, inputSampleFmt, sampleRate,
0, null);
if (ret < 0 || _pSwrContext == null)
throw new Exception($"Failed to configure audio resampler options: {GetFFmpegErrorString(ret)} (Code: {ret})");
}
ret = ffmpeg.swr_init(_pSwrContext);
if (ret < 0)
throw new Exception($"Failed to initialize audio resampler: {GetFFmpegErrorString(ret)} (Code: {ret})");
// ── 5. Initialize FIFO buffer ──
_pAudioFifo = ffmpeg.av_audio_fifo_alloc(encoderSampleFmt, channels, 1);
if (_pAudioFifo == null)
throw new Exception("Failed to allocate audio FIFO buffer.");
// ── 6. Allocate audio frame and packet ──
_pAudioFrame = ffmpeg.av_frame_alloc();
_pAudioPacket = ffmpeg.av_packet_alloc();
if (_pAudioFrame == null || _pAudioPacket == null)
throw new Exception("Failed to allocate audio frame or packet structure.");
// Determine frame size: Some codecs (e.g. AAC) have a fixed frame_size.
// Use default 1024 if frame_size is 0 (e.g. PCM family).
var frameSize = _pAudioCodecContext->frame_size > 0 ? _pAudioCodecContext->frame_size : 1024;
_pAudioFrame->nb_samples = frameSize;
_pAudioFrame->format = (int)encoderSampleFmt;
ffmpeg.av_channel_layout_copy(&_pAudioFrame->ch_layout, &_pAudioCodecContext->ch_layout);
_pAudioFrame->sample_rate = sampleRate;
ret = ffmpeg.av_frame_get_buffer(_pAudioFrame, 0);
if (ret < 0)
throw new Exception($"Failed to allocate audio frame buffer: {GetFFmpegErrorString(ret)} (Code: {ret})");
_audioSampleIndex = 0;
_audioEnabled = true;
}
/// <summary>
/// Encodes raw PCM audio sample data into the audio stream.
///
/// Can pass float[] array returned by Unity's AudioClip.GetData() or other PCM sources.
/// Internally executes resampling → FIFO buffering → frame_size unit encoding pipeline.
///
/// Thread safety: Safe to call from multiple threads as it uses _lock internally.
/// </summary>
/// <param name="samples">PCM audio sample data. Must be in interleaved format (LRLRLR...).</param>
/// <param name="numSamples">Number of samples per channel (= total float count / channel count).</param>
public void WriteAudioSamples(float[] samples, int numSamples)
{
lock (_lock)
{
if (_disposed || !_isInitialized || !_audioEnabled) return;
if (numSamples < 0 || (long)numSamples * _audioInputBytesPerSample > (long)samples.Length * sizeof(float))
throw new ArgumentException($"Audio sample count ({numSamples} per channel) exceeds the buffer size ({samples.Length} floats).");
fixed (float* pSamples = samples)
{
WriteAudioSamplesInternal((byte*)pSamples, numSamples);
}
}
}
/// <summary>
/// Encodes raw PCM audio sample data from pointer into the audio stream.
/// </summary>
/// <param name="samples">Pointer to PCM audio sample data.</param>
/// <param name="numSamples">Number of samples per channel.</param>
public void WriteAudioSamples(IntPtr samples, int numSamples)
{
lock (_lock)
{
if (_disposed || !_isInitialized || !_audioEnabled) return;
WriteAudioSamplesInternal((byte*)samples.ToPointer(), numSamples);
}
}
/// <summary>
/// Internal implementation of audio sample encoding.
///
/// Processing flow:
/// 1. Resample input PCM → encoder format via swr_convert
/// 2. Add resampled data to FIFO buffer
/// 3. Pop from FIFO when frame_size or more is accumulated and pass to encoder
///
/// Reference:
/// swr_convert: https://ffmpeg.org/doxygen/trunk/group__lswr.html
/// av_audio_fifo: https://ffmpeg.org/doxygen/trunk/group__lavu__audiofifo.html
/// </summary>
/// <summary>
/// Makes the buffer of the reusable audio frame (_pAudioFrame) writable before samples are read into it.
/// When the audio encoder still references the buffer, av_frame_make_writable allocates a new buffer.
/// Throws an exception when the buffer cannot be allocated.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html (av_frame_make_writable)
/// </summary>
private void MakeAudioFrameWritable()
{
var ret = ffmpeg.av_frame_make_writable(_pAudioFrame);
if (ret < 0)
throw new Exception($"Failed to make the audio frame writable: {GetFFmpegErrorString(ret)} (Code: {ret})");
}
private void WriteAudioSamplesInternal(byte* inputBuffer, int numSamples)
{
// ── 1. Resampling: Input format → Encoder format ──
// Allocate frame for output buffer (temporary frame to hold resampling result)
var pConvertedFrame = ffmpeg.av_frame_alloc();
if (pConvertedFrame == null) return;
try
{
pConvertedFrame->nb_samples = numSamples;
pConvertedFrame->format = (int)_pAudioCodecContext->sample_fmt;
ffmpeg.av_channel_layout_copy(&pConvertedFrame->ch_layout, &_pAudioCodecContext->ch_layout);
pConvertedFrame->sample_rate = _pAudioCodecContext->sample_rate;
var ret = ffmpeg.av_frame_get_buffer(pConvertedFrame, 0);
if (ret < 0)
throw new Exception($"Failed to allocate resampling output frame buffer: {GetFFmpegErrorString(ret)} (Code: {ret})");
// swr_convert: Converts input data to format required by encoder.
var convertedSamples = ffmpeg.swr_convert(
_pSwrContext,
(byte**)&pConvertedFrame->data, numSamples,
&inputBuffer, numSamples);
switch (convertedSamples)
{
case < 0: throw new Exception($"Audio resampling failed: {GetFFmpegErrorString(convertedSamples)} (Code: {convertedSamples})");
case 0: return;
}
// ── 2. Add to FIFO buffer ──
var written = ffmpeg.av_audio_fifo_write(_pAudioFifo, (void**)&pConvertedFrame->data, convertedSamples);
if (written < convertedSamples) throw new Exception("Failed to write to audio FIFO");
// ── 3. Pop from FIFO in frame_size units and encode ──
var frameSize = _pAudioCodecContext->frame_size > 0 ? _pAudioCodecContext->frame_size : 1024;
while (ffmpeg.av_audio_fifo_size(_pAudioFifo) >= frameSize)
{
MakeAudioFrameWritable();
_pAudioFrame->nb_samples = frameSize;
var read = ffmpeg.av_audio_fifo_read(_pAudioFifo, (void**)&_pAudioFrame->data, frameSize);
if (read < frameSize) throw new Exception("Failed to read from audio FIFO");
_pAudioFrame->pts = _audioSampleIndex;
_audioSampleIndex += frameSize;
SendAudioFrameAndWritePackets(_pAudioFrame);
}
}
finally
{
ffmpeg.av_frame_free(&pConvertedFrame);
}
}
/// <summary>
/// Sends audio frame to encoder and writes result packets to file.
/// Uses same Send/Receive pattern as video SendFrameAndWritePackets.
/// </summary>
private void SendAudioFrameAndWritePackets(AVFrame* pFrame)
{
var response = ffmpeg.avcodec_send_frame(_pAudioCodecContext, pFrame);
if (response < 0)
{
throw new Exception($"Error occurred while sending frame to audio encoder: {GetFFmpegErrorString(response)} (Code: {response})");
}
try
{
while (true)
{
response = ffmpeg.avcodec_receive_packet(_pAudioCodecContext, _pAudioPacket);
if (response == ffmpeg.AVERROR(ffmpeg.EAGAIN) || response == ffmpeg.AVERROR_EOF) break;
if (response < 0)
{
throw new Exception($"Error occurred while receiving packet from audio encoder: {GetFFmpegErrorString(response)} (Code: {response})");
}
// Convert codec timebase → stream timebase
ffmpeg.av_packet_rescale_ts(_pAudioPacket, _pAudioCodecContext->time_base, _pAudioStream->time_base);
_pAudioPacket->stream_index = _pAudioStream->index;
response = ffmpeg.av_interleaved_write_frame(_pFormatContext, _pAudioPacket);
if (response < 0)
{
throw new Exception($"Error occurred while writing audio packet to file: {GetFFmpegErrorString(response)} (Code: {response})");
}
ffmpeg.av_packet_unref(_pAudioPacket);
}
}
finally
{
ffmpeg.av_packet_unref(_pAudioPacket);
}
}
/// <summary>
/// Flushes the audio encoder.
/// Sends the samples remaining in the FIFO (fewer than frame_size) as one last short frame,
/// then sends a null frame to output all delayed packets of the encoder.
///
/// The short frame is handled by libavcodec (encode_send_frame_internal):
/// encoders with AV_CODEC_CAP_SMALL_LAST_FRAME or AV_CODEC_CAP_VARIABLE_FRAME_SIZE receive it as is,
/// other encoders receive it padded with silence up to frame_size (pad_last_frame).
/// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavcodec/encode.c
/// </summary>
private void FlushAudioEncoder()
{
if (_pAudioFifo == null || _pAudioCodecContext == null) return;
// Encode remaining samples in FIFO (last chunk smaller than frame_size)
var remaining = ffmpeg.av_audio_fifo_size(_pAudioFifo);
if (remaining > 0)
{
MakeAudioFrameWritable();
_pAudioFrame->nb_samples = remaining;
var read = ffmpeg.av_audio_fifo_read(_pAudioFifo, (void**)&_pAudioFrame->data, remaining);
if (read > 0)
{
_pAudioFrame->pts = _audioSampleIndex;
_audioSampleIndex += read;
SendAudioFrameAndWritePackets(_pAudioFrame);
}
}
// Flush encoder by sending null frame
SendAudioFrameAndWritePackets(null);
}
/// <summary>
/// Releases audio-related FFmpeg resources.
/// Called inside Dispose().
/// </summary>
private void DisposeAudioResources()
{
if (_pSwrContext != null)
{
fixed (SwrContext** ppSwr = &_pSwrContext)
{
ffmpeg.swr_free(ppSwr);
}
_pSwrContext = null;
}
if (_pAudioFifo != null)
{
ffmpeg.av_audio_fifo_free(_pAudioFifo);
_pAudioFifo = null;
}
if (_pAudioFrame != null)
{
var pFrame = _pAudioFrame;
ffmpeg.av_frame_free(&pFrame);
_pAudioFrame = null;
}
if (_pAudioPacket != null)
{
var pPacket = _pAudioPacket;
ffmpeg.av_packet_free(&pPacket);
_pAudioPacket = null;
}
if (_pAudioCodecContext != null)
{
var pCtx = _pAudioCodecContext;
ffmpeg.avcodec_free_context(&pCtx);
_pAudioCodecContext = null;
}
_audioEnabled = false;
}
}
}