5 Commits
4 changed files with 254 additions and 128 deletions

No files matched your search

+1
View File
@@ -48,3 +48,4 @@ desktop.ini
# Developer Utility Files # Developer Utility Files
/REVIEW_CHECKLIST.md /REVIEW_CHECKLIST.md
/REVIEW_FFMPEG.md
+1 -1
View File
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
using UnityEngine; using UnityEngine;
using Object = UnityEngine.Object; using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.6-INDEV", "ADOFAI Editor Forum")] [assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.7-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")] [assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer; namespace ADOFAIRenderer;
+134 -111
View File
@@ -191,6 +191,13 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
private bool _isOpened; private bool _isOpened;
private bool _disposed; private bool _disposed;
private bool _isDraining; private bool _isDraining;
/// <summary>Number of video packets skipped because the decoder rejected them as invalid data.</summary>
private int _skippedPacketCount;
/// <summary>Number of skipped packets for which a warning is logged.</summary>
private const int MaxLoggedSkippedPackets = 5;
private readonly object _lock = new(); private readonly object _lock = new();
// Fields to detect SwsContext updates // Fields to detect SwsContext updates
@@ -371,6 +378,16 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
if (_videoStreamIndex == -1) if (_videoStreamIndex == -1)
throw new Exception("Failed to find video stream."); throw new Exception("Failed to find video stream.");
// Only the video stream is used. The demuxer does not need to return the packets of the other streams
// (audio, subtitles, ...) once they are marked as discarded.
// Reference: AVStream.discard - "Selects which packets can be discarded at will and do not need to be demuxed."
// https://ffmpeg.org/doxygen/trunk/structAVStream.html
for (int i = 0; i < _pFormatContext->nb_streams; i++)
{
if (i != _videoStreamIndex)
_pFormatContext->streams[i]->discard = AVDiscard.AVDISCARD_ALL;
}
var stream = _pFormatContext->streams[_videoStreamIndex]; var stream = _pFormatContext->streams[_videoStreamIndex];
_timeBase = stream->time_base; _timeBase = stream->time_base;
@@ -441,9 +458,13 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
TotalFrames = 0; TotalFrames = 0;
} }
Duration = _pFormatContext->duration > 0 // Duration of the video stream (AVStream.duration, in the stream time base).
? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE // When the stream does not carry a duration, the container duration (AVFormatContext.duration,
: (stream->duration > 0 ? stream->duration * ffmpeg.av_q2d(stream->time_base) : 0); // in AV_TIME_BASE units, the longest of all streams) is used.
// Reference: https://ffmpeg.org/doxygen/trunk/structAVStream.html, https://ffmpeg.org/doxygen/trunk/structAVFormatContext.html
Duration = stream->duration > 0
? stream->duration * ffmpeg.av_q2d(stream->time_base)
: _pFormatContext->duration > 0 ? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE : 0;
// 9. Allocate frame and packet buffers // 9. Allocate frame and packet buffers
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html // Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
@@ -655,6 +676,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary> /// <summary>
/// Reads packets from the container until a packet belonging to the video stream is found, /// Reads packets from the container until a packet belonging to the video stream is found,
/// then sends it to the decoder via <c>avcodec_send_packet</c>. /// 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.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html /// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
/// </summary> /// </summary>
/// <returns>True if a video packet was sent; false on EOF.</returns> /// <returns>True if a video packet was sent; false on EOF.</returns>
@@ -670,6 +693,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
{ {
ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket); ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket);
ffmpeg.av_packet_unref(_pPacket); ffmpeg.av_packet_unref(_pPacket);
if (ret == ffmpeg.AVERROR_INVALIDDATA)
{
// The warning is logged for the first few packets only (a damaged file can have many).
_skippedPacketCount++;
if (_skippedPacketCount <= MaxLoggedSkippedPackets)
{
Melon<RenderMod>.Logger.Warning($"[FFmpegNativeDecoder] Skipped an invalid video packet ({_skippedPacketCount}): {GetFFmpegErrorString(ret)}");
}
continue;
}
if (ret < 0) if (ret < 0)
{ {
throw new Exception($"Error sending packet to decoder: {GetFFmpegErrorString(ret)}"); throw new Exception($"Error sending packet to decoder: {GetFFmpegErrorString(ret)}");
@@ -1212,107 +1247,24 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Decodes and discards a specified number of frames immediately without GPU-to-CPU transfer overhead. /// Maximum distance in frames over which <see cref="SeekToTime"/> moves forward by decoding.
/// Decoding forward needs no keyframe seek and no decoder flush, which is cheaper than seeking for short distances.
/// </summary> /// </summary>
/// <param name="count">Number of frames to discard.</param> private const int MaxForwardDecodeFrames = 60;
private void SkipFrames(int count)
{
lock (_lock)
{
if (!_isOpened || _disposed) return;
if (count <= 0) return;
int skipped = 0;
if (_hasCachedFrame)
{
ffmpeg.av_frame_unref(_pCachedFrame);
_hasCachedFrame = false;
skipped++;
}
while (skipped < count)
{
int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
if (ret == 0)
{
GetTimestampAndUpdateIndex(_pDecodedFrame);
ffmpeg.av_frame_unref(_pDecodedFrame);
skipped++;
continue;
}
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
if (_isDraining) break;
if (!ReadAndSendPacket())
{
_isDraining = true;
ffmpeg.avcodec_send_packet(_pCodecContext, null);
}
}
else if (ret == ffmpeg.AVERROR_EOF)
{
break;
}
else
{
throw new Exception($"Error skipping frames: {GetFFmpegErrorString(ret)}");
}
}
}
}
/// <summary> /// <summary>
/// Seeks to a specific playback time in seconds using backward keyframe seek followed by forward decode. /// Decodes forward from the current decoder position until the first frame whose presentation time is at least
/// Intermediate frames are discarded without GPU-to-host memory transfer to maximize seek speed; /// <paramref name="timeSeconds"/> minus half a frame, and keeps that frame in <see cref="_pCachedFrame"/>
/// only the target frame is transferred and preserved in <see cref="_pCachedFrame"/>. /// 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.
/// </summary> /// </summary>
/// <param name="timeSeconds">Target time in seconds to seek to.</param> /// <param name="timeSeconds">Target time in seconds.</param>
public void SeekToTime(double timeSeconds) private void DecodeToTarget(double timeSeconds)
{ {
lock (_lock)
{
if (!_isOpened || _disposed) return;
if (timeSeconds < 0) timeSeconds = 0;
double targetFrameIndex = timeSeconds * FPS;
// If already at target index or very close future, skip frames instead of seek to avoid I-frame seek latency
bool useSkip = targetFrameIndex >= _currentFrameIndex && targetFrameIndex - _currentFrameIndex < 60;
if (useSkip)
{
SkipFrames((int)(targetFrameIndex - _currentFrameIndex));
return;
}
if (_hasCachedFrame)
{
ffmpeg.av_frame_unref(_pCachedFrame);
_hasCachedFrame = false;
}
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)
{
// Fallback to beginning of file on seek failure
ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
{
throw new Exception($"Seek failed: {GetFFmpegErrorString(ret)}");
}
}
// Flush decoder internal buffers
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
ffmpeg.avcodec_flush_buffers(_pCodecContext);
_isDraining = false;
// Decode frames until reaching the target presentation timestamp
while (true) while (true)
{ {
ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame); int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
if (ret == 0) if (ret == 0)
{ {
double currentPts = GetFrameTimestamp(_pDecodedFrame); double currentPts = GetFrameTimestamp(_pDecodedFrame);
@@ -1343,28 +1295,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
} }
_hasCachedFrame = true; _hasCachedFrame = true;
break; return;
} }
// Discard non-matching intermediate frame without transferring to CPU // Discard non-matching intermediate frame without transferring to CPU
long ptsValue = _pDecodedFrame->best_effort_timestamp != ffmpeg.AV_NOPTS_VALUE GetTimestampAndUpdateIndex(_pDecodedFrame);
? _pDecodedFrame->best_effort_timestamp
: _pDecodedFrame->pts;
if (ptsValue == ffmpeg.AV_NOPTS_VALUE)
{
_currentFrameIndex++;
}
else
{
_currentFrameIndex = (long)Math.Round(currentPts * FPS);
}
ffmpeg.av_frame_unref(_pDecodedFrame); ffmpeg.av_frame_unref(_pDecodedFrame);
continue; continue;
} }
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN)) if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{ {
if (_isDraining) break; if (_isDraining) return;
if (!ReadAndSendPacket()) if (!ReadAndSendPacket())
{ {
@@ -1374,7 +1316,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
} }
else if (ret == ffmpeg.AVERROR_EOF) else if (ret == ffmpeg.AVERROR_EOF)
{ {
break; return;
} }
else else
{ {
@@ -1382,6 +1324,87 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
} }
} }
} }
/// <summary>
/// Moves the playback position to a time in seconds.
/// The next <c>TryRead*</c> call returns the first frame whose presentation time is at least
/// <paramref name="timeSeconds"/> minus half a frame (the frame nearest to the target time).
/// Both paths below return that same frame:
/// - Forward decode: the target is ahead of the current position by less than <see cref="MaxForwardDecodeFrames"/> frames.
/// Frames are decoded and discarded up to the target.
/// - 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.
/// </summary>
/// <param name="timeSeconds">Target time in seconds to seek to.</param>
public void SeekToTime(double timeSeconds)
{
lock (_lock)
{
if (!_isOpened || _disposed) return;
if (timeSeconds < 0) timeSeconds = 0;
// Earliest presentation time that satisfies the target (target minus half a frame).
double threshold = timeSeconds - (0.5 / FPS);
// Index of the first frame that satisfies the target. The epsilon keeps a frame that lies exactly on the
// margin from being excluded by floating point rounding.
double targetFrameIndex = Math.Ceiling(timeSeconds * FPS - 0.5 - 1e-6);
if (_hasCachedFrame)
{
// A frame is already positioned for the next read (e.g. by a previous SeekToTime call).
double cachedPts = GetFrameTimestamp(_pCachedFrame);
if (cachedPts >= threshold)
{
// The cached frame is the first frame that satisfies the target.
// When the target lies before it, the frames in between are no longer available and a keyframe seek is required.
if (cachedPts - threshold < 1.0 / FPS) return;
}
else if (targetFrameIndex - _currentFrameIndex < MaxForwardDecodeFrames)
{
// The cached frame lies before the target: drop it and continue decoding forward.
GetTimestampAndUpdateIndex(_pCachedFrame);
ffmpeg.av_frame_unref(_pCachedFrame);
_hasCachedFrame = false;
DecodeToTarget(timeSeconds);
return;
}
}
else if (targetFrameIndex > _currentFrameIndex && targetFrameIndex - _currentFrameIndex < MaxForwardDecodeFrames)
{
// The target frame has not been read yet and is close: decode forward instead of seeking.
DecodeToTarget(timeSeconds);
return;
}
if (_hasCachedFrame)
{
ffmpeg.av_frame_unref(_pCachedFrame);
_hasCachedFrame = false;
}
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)
{
// Fallback to beginning of file on seek failure
ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
{
throw new Exception($"Seek failed: {GetFFmpegErrorString(ret)}");
}
}
// Flush decoder internal buffers
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
ffmpeg.avcodec_flush_buffers(_pCodecContext);
_isDraining = false;
// Decode frames until reaching the target presentation timestamp
DecodeToTarget(timeSeconds);
}
} }
/// <summary> /// <summary>
+118 -16
View File
@@ -83,7 +83,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// <summary>Frames per second (e.g., 60.0, 29.97, etc.).</summary> /// <summary>Frames per second (e.g., 60.0, 29.97, etc.).</summary>
public double FPS; public double FPS;
/// <summary>Video codec type to use (H264, H265, AV1, VP9).</summary> /// <summary>Video codec type to use (H264, H265, AV1, VP9, VVC).</summary>
public Codec Codec; public Codec Codec;
/// <summary>Bitrate control mode (CQP, CBR, VBR).</summary> /// <summary>Bitrate control mode (CQP, CBR, VBR).</summary>
@@ -723,6 +723,16 @@ namespace ADOFAIRenderer.Util.FFmpeg
if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0) if (ffmpeg.avcodec_parameters_from_context(_pVideoStream->codecpar, _pCodecContext) < 0)
throw new Exception("Failed to copy codec parameters to stream."); throw new Exception("Failed to copy codec parameters to stream.");
// 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 ── // ── Step 4.5 (Optional): Initialize audio stream ──
// If EncoderSettings.Audio is set, add audio stream. // If EncoderSettings.Audio is set, add audio stream.
if (_settings.Audio.HasValue) if (_settings.Audio.HasValue)
@@ -825,7 +835,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// Reference - CQP options per encoder: /// Reference - CQP options per encoder:
/// NVENC: "qp" (single value applied to I/P/B frames) /// NVENC: "qp" (single value applied to I/P/B frames)
/// AMF: "qp_i" + "qp_p" (I-frame and P-frame configured separately) /// AMF: "qp_i" + "qp_p" (I-frame and P-frame configured separately)
/// QSV: "global_quality" (Intel unique option) /// QSV: AVCodecContext.global_quality (Intel ICQ mode)
/// Software: "crf" (Constant Rate Factor, smarter quality control) /// Software: "crf" (Constant Rate Factor, smarter quality control)
/// ///
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html /// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
@@ -847,11 +857,21 @@ namespace ADOFAIRenderer.Util.FFmpeg
_pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L; _pCodecContext->rc_max_rate = _pCodecContext->rc_min_rate = _settings.TargetBitrate * 1000L;
_pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000; _pCodecContext->rc_buffer_size = _settings.TargetBitrate * 1000;
// nal-hrd=cbr: x264/x265 dedicated option. Generates CBR bitstream matching // HRD (Hypothetical Reference Decoder) signaling for CBR output, used for streaming compatibility.
// NAL HRD (Hypothetical Reference Decoder) specification. May be required for streaming compatibility. // libx264: "nal-hrd=cbr" writes the NAL HRD information and pads filler data to keep the bitrate constant.
if (encoder == VideoEncoder.Libx264 || encoder == VideoEncoder.Libx265) // 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)
{ {
ffmpeg.av_opt_set(_pCodecContext->priv_data, "nal-hrd", "cbr", 0); TrySetOption("nal-hrd", "cbr");
}
else if (encoder == VideoEncoder.Libx265)
{
TrySetOption("x265-params", "hrd=1:strict-cbr=1");
} }
} }
break; break;
@@ -875,38 +895,77 @@ namespace ADOFAIRenderer.Util.FFmpeg
break; break;
case RateControlMode.CQP: 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. // Default value 18: Quality close to "visually lossless" in H.264.
// Provides similar level of quality in other codecs as well. // Provides similar level of quality in other codecs as well.
var qpValue = _settings.QualityValue is >= 0 and <= 51 ? _settings.QualityValue : 18; 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) if (encoder.Kind == EncoderKind.NVENC)
{ {
// NVENC: Applies same QP to I/P/B frames with single "qp" option // NVENC: Applies same QP to I/P/B frames with single "qp" option
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0); TrySetOption("qp", qpValue.ToString());
} }
else if (encoder.Kind == EncoderKind.AMF) else if (encoder.Kind == EncoderKind.AMF)
{ {
// AMF: QP for I-frame (qp_i) and P-frame (qp_p) must be set separately. // 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. // Since B-frames are disabled, qp_b is not set.
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_i", qpValue.ToString(), 0); TrySetOption("qp_i", qpValue.ToString());
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp_p", qpValue.ToString(), 0); TrySetOption("qp_p", qpValue.ToString());
} }
else if (encoder.Kind == EncoderKind.QSV) else if (encoder.Kind == EncoderKind.QSV)
{ {
// QSV: Uses ICQ (Intelligent Constant Quality) mode with "global_quality" option // QSV: ICQ (Intelligent Constant Quality) mode, configured with AVCodecContext.global_quality.
ffmpeg.av_opt_set(_pCodecContext->priv_data, "global_quality", qpValue.ToString(), 0); _pCodecContext->global_quality = qpValue;
} }
else if (encoder == VideoEncoder.LibVvenc) else if (encoder == VideoEncoder.LibVvenc)
{ {
// libvvenc: Specifies Constant QP mode with "qp" option // libvvenc: Specifies Constant QP mode with "qp" option
ffmpeg.av_opt_set(_pCodecContext->priv_data, "qp", qpValue.ToString(), 0); TrySetOption("qp", qpValue.ToString());
} }
else // Software encoder (libx264, libx265, libvpx-vp9, etc.) else
{ {
// CRF (Constant Rate Factor): Smarter quality control than QP. // CRF (Constant Rate Factor): Smarter quality control than QP.
// Automatically adjusts bitrate according to scene complexity to maintain consistent visual quality. // Automatically adjusts bitrate according to scene complexity to maintain consistent visual quality.
// Range: 0 (lossless) ~ 51 (lowest quality). Typically between 18 and 28. // Range: 0 (lossless) ~ 51 (lowest quality) for x264/x265, 0~63 for VP9/AV1. Typically between 18 and 28.
ffmpeg.av_opt_set(_pCodecContext->priv_data, "crf", qpValue.ToString(), 0); TrySetOption("crf", qpValue.ToString());
} }
break; break;
default: default:
@@ -914,6 +973,49 @@ namespace ADOFAIRenderer.Util.FFmpeg
} }
} }
/// <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>
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>
/// 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 // Write Frame
// ================================================================ // ================================================================