diff --git a/Util/FFmpeg/FFmpegNativeDecoder.cs b/Util/FFmpeg/FFmpegNativeDecoder.cs index 95b0886..46737b8 100644 --- a/Util/FFmpeg/FFmpegNativeDecoder.cs +++ b/Util/FFmpeg/FFmpegNativeDecoder.cs @@ -1247,61 +1247,94 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable // ────────────────────────────────────────────────────────────── /// - /// Decodes and discards a specified number of frames immediately without GPU-to-CPU transfer overhead. + /// Maximum distance in frames over which moves forward by decoding. + /// Decoding forward needs no keyframe seek and no decoder flush, which is cheaper than seeking for short distances. /// - /// Number of frames to discard. - private void SkipFrames(int count) - { - lock (_lock) - { - if (!_isOpened || _disposed) return; - if (count <= 0) return; + private const int MaxForwardDecodeFrames = 60; - int skipped = 0; - if (_hasCachedFrame) + /// + /// Decodes forward from the current decoder position until the first frame whose presentation time is at least + /// minus half a frame, and keeps that frame in + /// for the next TryRead* 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. + /// + /// Target time in seconds. + private void DecodeToTarget(double timeSeconds) + { + while (true) + { + int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame); + if (ret == 0) { - ffmpeg.av_frame_unref(_pCachedFrame); - _hasCachedFrame = false; - skipped++; + double currentPts = GetFrameTimestamp(_pDecodedFrame); + + // Check with half-frame margin to accommodate floating point rounding + if (currentPts >= timeSeconds - (0.5 / FPS)) + { + // Cache the first matching frame in system RAM for immediate consumption + if (_isHwDecoding && _pDecodedFrame->format == (int)_hwPixFmt) + { + ffmpeg.av_frame_unref(_pCachedFrame); + int transferRet = ffmpeg.av_hwframe_transfer_data(_pCachedFrame, _pDecodedFrame, 0); + if (transferRet < 0) + { + ffmpeg.av_frame_unref(_pDecodedFrame); + throw new Exception($"Failed to transfer cached hardware frame during seek: {GetFFmpegErrorString(transferRet)}"); + } + + ffmpeg.av_frame_copy_props(_pCachedFrame, _pDecodedFrame); + ffmpeg.av_frame_unref(_pDecodedFrame); + + _downloadedPixFmt = (AVPixelFormat)_pCachedFrame->format; + } + else + { + ffmpeg.av_frame_unref(_pCachedFrame); + ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame); + } + + _hasCachedFrame = true; + return; + } + + // Discard non-matching intermediate frame without transferring to CPU + GetTimestampAndUpdateIndex(_pDecodedFrame); + ffmpeg.av_frame_unref(_pDecodedFrame); + continue; } - while (skipped < count) + if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN)) { - int ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame); - if (ret == 0) - { - GetTimestampAndUpdateIndex(_pDecodedFrame); - ffmpeg.av_frame_unref(_pDecodedFrame); - skipped++; - continue; - } + if (_isDraining) return; - if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN)) + if (!ReadAndSendPacket()) { - 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)}"); + _isDraining = true; + ffmpeg.avcodec_send_packet(_pCodecContext, null); } } + else if (ret == ffmpeg.AVERROR_EOF) + { + return; + } + else + { + throw new Exception($"Error during seek decoding: {GetFFmpegErrorString(ret)}"); + } } } /// - /// Seeks to a specific playback time in seconds using backward keyframe seek followed by forward decode. - /// Intermediate frames are discarded without GPU-to-host memory transfer to maximize seek speed; - /// only the target frame is transferred and preserved in . + /// Moves the playback position to a time in seconds. + /// The next TryRead* call returns the first frame whose presentation time is at least + /// 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 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 ; when it is already there, nothing is decoded. /// /// Target time in seconds to seek to. public void SeekToTime(double timeSeconds) @@ -1311,13 +1344,38 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable 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) + // 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) { - SkipFrames((int)(targetFrameIndex - _currentFrameIndex)); + // 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; } @@ -1345,77 +1403,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable _isDraining = false; // Decode frames until reaching the target presentation timestamp - while (true) - { - ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame); - if (ret == 0) - { - double currentPts = GetFrameTimestamp(_pDecodedFrame); - - // Check with half-frame margin to accommodate floating point rounding - if (currentPts >= timeSeconds - (0.5 / FPS)) - { - // Cache the first matching frame in system RAM for immediate consumption - if (_isHwDecoding && _pDecodedFrame->format == (int)_hwPixFmt) - { - ffmpeg.av_frame_unref(_pCachedFrame); - int transferRet = ffmpeg.av_hwframe_transfer_data(_pCachedFrame, _pDecodedFrame, 0); - if (transferRet < 0) - { - ffmpeg.av_frame_unref(_pDecodedFrame); - throw new Exception($"Failed to transfer cached hardware frame during seek: {GetFFmpegErrorString(transferRet)}"); - } - - ffmpeg.av_frame_copy_props(_pCachedFrame, _pDecodedFrame); - ffmpeg.av_frame_unref(_pDecodedFrame); - - _downloadedPixFmt = (AVPixelFormat)_pCachedFrame->format; - } - else - { - ffmpeg.av_frame_unref(_pCachedFrame); - ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame); - } - - _hasCachedFrame = true; - break; - } - - // Discard non-matching intermediate frame without transferring to CPU - long ptsValue = _pDecodedFrame->best_effort_timestamp != ffmpeg.AV_NOPTS_VALUE - ? _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); - 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 during seek decoding: {GetFFmpegErrorString(ret)}"); - } - } + DecodeToTarget(timeSeconds); } }