Fix Decoder Forward Seek Returning One Frame Ahead

This commit is contained in:
copecone committed 2026-10-11 20:24:11 +09:00
1 parent 36552511f5
commit 413778e165
1 file changed
+96 -108
+96 -108
View File
@@ -1247,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);
@@ -1378,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())
{ {
@@ -1409,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
{ {
@@ -1417,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>