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);
}
}