video: drain libavcodec's reorder buffer at end-of-stream

Closes H1 from the pre-ship review (the known-limit doc note added in
c0d55ba). The previous workaround was "first play-through truncates
the last ~16 frames; replay is fine because flush_buffers clears
libavcodec." That trade-off was OK for shipping but the proper fix
is to drain the reorder buffer before propagating EOS to ExoPlayer.

Media3's SimpleDecoder short-circuits the end-of-stream input buffer
and never invokes the subclass's decode(), so there's no hook to send
avcodec_send_packet(NULL). Every override worth overriding (decode
loop, queue methods, flush) is final on SimpleDecoder. So we
vendor a copy as FfmpegSimpleDecoder (Apache 2.0 attribution at the
top of the file) with one structural change: an EOS-drain state. On
EOS input, signalEndOfInput() flushes libavcodec's reorder queue,
then drainAtEndOfStream() is called on successive output buffers
until it reports DRAIN_DONE — at which point the loop attaches
BUFFER_FLAG_END_OF_STREAM and resumes normal teardown.

Everything else mirrors SimpleDecoder verbatim so upstream
improvements are cheap to pull forward.

  - FfmpegSimpleDecoder.java: vendored base class.
  - ffmpegVideoSignalEos JNI: sends avcodec_send_packet(NULL).
  - FfmpegVideoDecoder: extends the new base; signalEndOfInput
    forwards to the JNI; drainAtEndOfStream re-uses the existing
    ffmpegVideoReceiveFrame so per-frame PTS recovery and the
    pending_frame path from c0d55ba continue to work during drain.
This commit is contained in:
agra
2026-05-29 07:45:48 +03:00
parent c0d55babf3
commit dc47fc0159
3 changed files with 422 additions and 11 deletions

View File

@@ -444,6 +444,26 @@ VIDEO_DECODER_FUNC(jint, ffmpegVideoReceiveFrame, jlong handle,
return VIDEO_DECODER_SUCCESS;
}
// Signals libavcodec to flush its reorder buffer so the Java side can
// pull the remaining frames out via successive ffmpegVideoReceiveFrame
// calls. Used during end-of-stream drain: SimpleDecoder's EOS short-
// circuit hides the EOS input from our decode() override, so without
// this hook libavcodec's buffered tail (~16 frames for iOS H.264
// High@3.1) would be lost on first play-through.
VIDEO_DECODER_FUNC(jint, ffmpegVideoSignalEos, jlong handle) {
if (!handle) {
LOGE("ffmpegVideoSignalEos: null handle");
return VIDEO_DECODER_ERROR_OTHER;
}
UxFfmpegVideoContext* ctx = (UxFfmpegVideoContext*)handle;
int result = avcodec_send_packet(ctx->codec_ctx, nullptr);
if (result < 0 && result != AVERROR_EOF) {
logError("avcodec_send_packet(NULL)", result);
return transformError(result);
}
return VIDEO_DECODER_SUCCESS;
}
VIDEO_DECODER_FUNC(void, ffmpegVideoFlush, jlong handle) {
if (!handle) return;
UxFfmpegVideoContext* ctx = (UxFfmpegVideoContext*)handle;

View File

@@ -0,0 +1,376 @@
/*
* Adapted from Media3's SimpleDecoder (Apache 2.0, Copyright 2016 The
* Android Open Source Project) under the LICENSE-2.0:
* https://www.apache.org/licenses/LICENSE-2.0
*
* Adds an end-of-stream drain hook. Media3's SimpleDecoder short-
* circuits the EOS input and never invokes the subclass decode(), so
* libavcodec's reorder buffer (up to ~16 frames for iOS H.264) is
* never drained — the last frames of a clip are lost on first
* play-through. This subclass extends the decode loop with a
* draining state: when EOS hits, signalEndOfInput() tells libavcodec
* to flush its reorder queue, then drainAtEndOfStream() is called on
* successive output buffers until it reports DRAIN_DONE, at which
* point the EOS flag is finally attached.
*
* Everything else mirrors SimpleDecoder verbatim. Keeping the
* mirror tight makes it cheap to adopt upstream improvements.
*/
package io.swipelab.ux.video.ffmpeg;
import androidx.annotation.CallSuper;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.decoder.Decoder;
import androidx.media3.decoder.DecoderException;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.decoder.DecoderOutputBuffer;
import java.util.ArrayDeque;
@UnstableApi
abstract class FfmpegSimpleDecoder<
I extends DecoderInputBuffer, O extends DecoderOutputBuffer, E extends DecoderException>
implements Decoder<I, O, E> {
/** Subclass produced a frame into the supplied output buffer; keep calling. */
protected static final int DRAIN_PRODUCED_FRAME = 0;
/** Subclass has no more buffered frames; output buffer untouched, attach EOS. */
protected static final int DRAIN_DONE = 1;
/** Fatal drain error; output buffer untouched, abort the decoder. */
protected static final int DRAIN_ERROR = 2;
private final Thread decodeThread;
private final Object lock;
private final ArrayDeque<I> queuedInputBuffers;
private final ArrayDeque<O> queuedOutputBuffers;
private final I[] availableInputBuffers;
private final O[] availableOutputBuffers;
private int availableInputBufferCount;
private int availableOutputBufferCount;
@Nullable private I dequeuedInputBuffer;
@Nullable private E exception;
private boolean flushed;
private boolean released;
private int skippedOutputBufferCount;
private long outputStartTimeUs;
// True between receiving an EOS input and the drain hook returning
// DRAIN_DONE. During this window the decode loop fires whenever an
// output buffer is available, regardless of the input queue.
private boolean draining;
protected FfmpegSimpleDecoder(I[] inputBuffers, O[] outputBuffers) {
lock = new Object();
outputStartTimeUs = C.TIME_UNSET;
queuedInputBuffers = new ArrayDeque<>();
queuedOutputBuffers = new ArrayDeque<>();
availableInputBuffers = inputBuffers;
availableInputBufferCount = inputBuffers.length;
for (int i = 0; i < availableInputBufferCount; i++) {
availableInputBuffers[i] = createInputBuffer();
}
availableOutputBuffers = outputBuffers;
availableOutputBufferCount = outputBuffers.length;
for (int i = 0; i < availableOutputBufferCount; i++) {
availableOutputBuffers[i] = createOutputBuffer();
}
decodeThread =
new Thread("UxFfmpegSimpleDecoder") {
@Override
public void run() {
FfmpegSimpleDecoder.this.runLoop();
}
};
decodeThread.start();
}
protected final void setInitialInputBufferSize(int size) {
for (I inputBuffer : availableInputBuffers) {
inputBuffer.ensureSpaceForWrite(size);
}
}
protected final boolean isAtLeastOutputStartTimeUs(long timeUs) {
synchronized (lock) {
return outputStartTimeUs == C.TIME_UNSET || timeUs >= outputStartTimeUs;
}
}
@Override
public final void setOutputStartTimeUs(long outputStartTimeUs) {
synchronized (lock) {
this.outputStartTimeUs = outputStartTimeUs;
}
}
@Override
@Nullable
public final I dequeueInputBuffer() throws E {
synchronized (lock) {
maybeThrowException();
dequeuedInputBuffer =
availableInputBufferCount == 0
? null
: availableInputBuffers[--availableInputBufferCount];
return dequeuedInputBuffer;
}
}
@Override
public final void queueInputBuffer(I inputBuffer) throws E {
synchronized (lock) {
maybeThrowException();
queuedInputBuffers.addLast(inputBuffer);
maybeNotifyDecodeLoop();
dequeuedInputBuffer = null;
}
}
@Override
@Nullable
public final O dequeueOutputBuffer() throws E {
synchronized (lock) {
maybeThrowException();
if (queuedOutputBuffers.isEmpty()) {
return null;
}
return queuedOutputBuffers.removeFirst();
}
}
@CallSuper
protected void releaseOutputBuffer(O outputBuffer) {
synchronized (lock) {
outputBuffer.clear();
availableOutputBuffers[availableOutputBufferCount++] = outputBuffer;
maybeNotifyDecodeLoop();
}
}
@Override
public final void flush() {
synchronized (lock) {
flushed = true;
draining = false;
skippedOutputBufferCount = 0;
if (dequeuedInputBuffer != null) {
releaseInputBufferInternal(dequeuedInputBuffer);
dequeuedInputBuffer = null;
}
while (!queuedInputBuffers.isEmpty()) {
releaseInputBufferInternal(queuedInputBuffers.removeFirst());
}
while (!queuedOutputBuffers.isEmpty()) {
queuedOutputBuffers.removeFirst().release();
}
}
}
@CallSuper
@Override
public void release() {
synchronized (lock) {
released = true;
lock.notify();
}
try {
decodeThread.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void maybeThrowException() throws E {
@Nullable E e = this.exception;
if (e != null) {
throw e;
}
}
private void maybeNotifyDecodeLoop() {
if (canDecodeBuffer()) {
lock.notify();
}
}
private void runLoop() {
try {
while (decodeOne()) {
// tight loop
}
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
private boolean decodeOne() throws InterruptedException {
@Nullable I inputBuffer;
O outputBuffer;
boolean resetDecoder;
boolean drainingLocal;
synchronized (lock) {
while (!released && !canDecodeBuffer()) {
lock.wait();
}
if (released) {
return false;
}
outputBuffer = availableOutputBuffers[--availableOutputBufferCount];
resetDecoder = flushed;
flushed = false;
drainingLocal = draining;
if (drainingLocal) {
inputBuffer = null;
} else {
inputBuffer = queuedInputBuffers.removeFirst();
}
}
@Nullable E iterationException = null;
boolean attachEosFlag = false;
if (drainingLocal) {
// Continuation of an EOS drain that started earlier.
int drainResult;
try {
drainResult = drainAtEndOfStream(outputBuffer);
} catch (RuntimeException e) {
drainResult = DRAIN_ERROR;
iterationException = createUnexpectedDecodeException(e);
} catch (OutOfMemoryError e) {
drainResult = DRAIN_ERROR;
iterationException = createUnexpectedDecodeException(e);
}
if (drainResult == DRAIN_DONE) {
attachEosFlag = true;
synchronized (lock) {
draining = false;
}
} else if (drainResult == DRAIN_ERROR && iterationException == null) {
iterationException = createUnexpectedDecodeException(
new IllegalStateException("EOS drain reported DRAIN_ERROR"));
}
} else if (inputBuffer.isEndOfStream()) {
// First time we see EOS — tell libavcodec to flush its reorder
// buffer, then drain one frame immediately into this output
// buffer. If no buffered frames remain we attach EOS now;
// otherwise the next loop iteration runs the draining branch.
try {
signalEndOfInput();
} catch (RuntimeException e) {
iterationException = createUnexpectedDecodeException(e);
}
int drainResult = DRAIN_DONE;
if (iterationException == null) {
try {
drainResult = drainAtEndOfStream(outputBuffer);
} catch (RuntimeException e) {
drainResult = DRAIN_ERROR;
iterationException = createUnexpectedDecodeException(e);
}
}
if (drainResult == DRAIN_PRODUCED_FRAME) {
synchronized (lock) {
draining = true;
}
} else if (drainResult == DRAIN_DONE) {
attachEosFlag = true;
} else if (iterationException == null) {
iterationException = createUnexpectedDecodeException(
new IllegalStateException("EOS drain reported DRAIN_ERROR"));
}
} else {
// Normal decode path — same shape as SimpleDecoder.
outputBuffer.timeUs = inputBuffer.timeUs;
if (inputBuffer.isFirstSample()) {
outputBuffer.addFlag(C.BUFFER_FLAG_FIRST_SAMPLE);
}
if (!isAtLeastOutputStartTimeUs(inputBuffer.timeUs)) {
outputBuffer.shouldBeSkipped = true;
}
try {
iterationException = decode(inputBuffer, outputBuffer, resetDecoder);
} catch (RuntimeException e) {
iterationException = createUnexpectedDecodeException(e);
} catch (OutOfMemoryError e) {
iterationException = createUnexpectedDecodeException(e);
}
}
if (iterationException != null) {
synchronized (lock) {
this.exception = iterationException;
}
return false;
}
if (attachEosFlag) {
outputBuffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM);
}
synchronized (lock) {
if (flushed) {
outputBuffer.release();
} else if (outputBuffer.shouldBeSkipped) {
skippedOutputBufferCount++;
outputBuffer.release();
} else {
outputBuffer.skippedOutputBufferCount = skippedOutputBufferCount;
skippedOutputBufferCount = 0;
queuedOutputBuffers.addLast(outputBuffer);
}
if (inputBuffer != null) {
releaseInputBufferInternal(inputBuffer);
}
}
return true;
}
private boolean canDecodeBuffer() {
if (draining) {
return availableOutputBufferCount > 0;
}
return !queuedInputBuffers.isEmpty() && availableOutputBufferCount > 0;
}
private void releaseInputBufferInternal(I inputBuffer) {
inputBuffer.clear();
availableInputBuffers[availableInputBufferCount++] = inputBuffer;
}
protected abstract I createInputBuffer();
protected abstract O createOutputBuffer();
protected abstract E createUnexpectedDecodeException(Throwable error);
/**
* Decodes the {@code inputBuffer} and stores any decoded output in
* {@code outputBuffer}. Same contract as Media3's
* {@code SimpleDecoder.decode}.
*/
@Nullable
protected abstract E decode(I inputBuffer, O outputBuffer, boolean reset);
/**
* Called once when an end-of-stream input buffer arrives, before
* the first call to {@link #drainAtEndOfStream}. Subclass should
* tell its backend decoder to flush its internal queue —
* {@code avcodec_send_packet(ctx, NULL)} for libavcodec.
*/
protected abstract void signalEndOfInput();
/**
* Pulls one buffered frame from the backend decoder into the
* supplied output buffer. Called once per available output buffer
* after EOS until it reports {@link #DRAIN_DONE}; the decode loop
* then attaches {@code BUFFER_FLAG_END_OF_STREAM} to that same
* output buffer before queueing it.
*
* @return one of {@link #DRAIN_PRODUCED_FRAME}, {@link #DRAIN_DONE},
* {@link #DRAIN_ERROR}.
*/
protected abstract int drainAtEndOfStream(O outputBuffer);
}

View File

@@ -15,7 +15,6 @@ import androidx.media3.common.Format;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.decoder.SimpleDecoder;
import androidx.media3.decoder.VideoDecoderOutputBuffer;
import java.nio.ByteBuffer;
import java.util.List;
@@ -29,15 +28,6 @@ import java.util.List;
*
* <h3>Known limits</h3>
* <ul>
* <li><b>EOS trailing frames.</b> Media3's {@code SimpleDecoder}
* base class special-cases the end-of-stream input buffer and
* never invokes our {@code decode()} for it, so libavcodec's
* reorder buffer (~16 frames for iOS H.264 High@3.1) is never
* drained with {@code avcodec_send_packet(NULL)}. The last ~500 ms
* of a clip can be truncated on first play-through. Replay via
* {@code REPEAT_MODE_ONE} or {@code seekTo(0)} hits
* {@code avcodec_flush_buffers} which clears the queue, so the
* second play and onwards are full-length.</li>
* <li><b>Colorspace heuristic.</b> When the bitstream's
* {@code colorspace}/{@code primaries}/{@code transfer} are all
* unspecified we fall back to a size-based guess (BT.709 for >=
@@ -50,7 +40,7 @@ import java.util.List;
*/
@UnstableApi
public final class FfmpegVideoDecoder
extends SimpleDecoder<
extends FfmpegSimpleDecoder<
DecoderInputBuffer, VideoDecoderOutputBuffer, FfmpegDecoderException> {
// Mirrored from ffmpeg_jni.cc.
@@ -165,6 +155,29 @@ public final class FfmpegVideoDecoder
ffmpegVideoRelease(nativeContext);
}
@Override
protected void signalEndOfInput() {
int result = ffmpegVideoSignalEos(nativeContext);
if (result != VIDEO_DECODER_SUCCESS) {
throw new RuntimeException("ffmpegVideoSignalEos failed: " + result);
}
}
@Override
protected int drainAtEndOfStream(VideoDecoderOutputBuffer outputBuffer) {
outputBuffer.init(0, outputMode, /* supplementalData= */ null);
int result = ffmpegVideoReceiveFrame(nativeContext, outputBuffer);
if (result == VIDEO_DECODER_SUCCESS) {
return DRAIN_PRODUCED_FRAME;
}
if (result == VIDEO_DECODER_READ_AGAIN) {
// libavcodec returned EAGAIN or EOF after the NULL packet —
// reorder buffer is empty, drain is complete.
return DRAIN_DONE;
}
return DRAIN_ERROR;
}
public void setOutputMode(@C.VideoOutputMode int outputMode) {
this.outputMode = outputMode;
}
@@ -203,6 +216,8 @@ public final class FfmpegVideoDecoder
private native int ffmpegVideoReceiveFrame(long context, VideoDecoderOutputBuffer outputBuffer);
private native int ffmpegVideoSignalEos(long context);
private native void ffmpegVideoFlush(long context);
private native void ffmpegVideoRelease(long context);