SHA256
Fix Decoder Forward Seek Returning One Frame Ahead
This commit is contained in:
1 file changed
+96
-108
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user