diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/Av1SampleDependencyParser.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/Av1SampleDependencyParser.java index b08cf9e3ed..09d873f1ff 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/Av1SampleDependencyParser.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/Av1SampleDependencyParser.java @@ -32,8 +32,9 @@ import java.util.List; /** An AV1 bitstream parser that identifies frames that are not depended on. */ /* package */ final class Av1SampleDependencyParser { /** - * When {@link #sampleLimitAfterSkippingNonReferenceFrame(ByteBuffer)} partially skips a temporal - * unit, the decoder input buffer is left with extra reference frames that need to be decoded. + * When {@link #sampleLimitAfterSkippingNonReferenceFrame(ByteBuffer, boolean)} partially skips a + * temporal unit, the decoder input buffer is left with extra reference frames that need to be + * decoded. * *

The AV1 spec defines {@code NUM_REF_FRAMES = 8} - delaying more than 8 reference frames will * overwrite the same output slots. @@ -50,20 +51,22 @@ import java.util.List; * that aren't shown are used as reference, but the shown frame may not be used as reference. * Frequently, the shown frame is the last frame in the temporal unit. * - *

If the last frame in the temporal unit is a non-reference {@link ObuParser#OBU_FRAME}, this - * method returns a new {@link ByteBuffer#limit()} value that would leave only the frames used as - * reference in the input {@code sample}. + *

If the last frame in the temporal unit is a non-reference {@link ObuParser#OBU_FRAME} or + * {@link ObuParser#OBU_FRAME_HEADER}, this method returns a new {@link ByteBuffer#limit()} value + * that would leave only the frames used as reference in the input {@code sample}. * *

See Ordering of OBUs. * * @param sample The sample data for one AV1 temporal unit. + * @param skipFrameHeaders Whether to skip {@link ObuParser#OBU_FRAME_HEADER}. */ - public int sampleLimitAfterSkippingNonReferenceFrame(ByteBuffer sample) { + public int sampleLimitAfterSkippingNonReferenceFrame( + ByteBuffer sample, boolean skipFrameHeaders) { List obuList = split(sample); updateSequenceHeaders(obuList); int skippedFramesCount = 0; int last = obuList.size() - 1; - while (last >= 0 && canSkipObu(obuList.get(last))) { + while (last >= 0 && canSkipObu(obuList.get(last), skipFrameHeaders)) { if (obuList.get(last).type == OBU_FRAME || obuList.get(last).type == OBU_FRAME_HEADER) { skippedFramesCount++; } @@ -88,10 +91,13 @@ import java.util.List; sequenceHeader = null; } - private boolean canSkipObu(ObuParser.Obu obu) { + private boolean canSkipObu(ObuParser.Obu obu, boolean skipFrameHeaders) { if (obu.type == OBU_TEMPORAL_DELIMITER || obu.type == OBU_PADDING) { return true; } + if (obu.type == OBU_FRAME_HEADER && !skipFrameHeaders) { + return false; + } if ((obu.type == OBU_FRAME || obu.type == OBU_FRAME_HEADER) && sequenceHeader != null) { FrameHeader frameHeader = FrameHeader.parse(sequenceHeader, obu); return frameHeader != null && !frameHeader.isDependedOn(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java index 398bb903ee..2396bd42c7 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java @@ -60,6 +60,7 @@ import androidx.media3.common.util.Size; import androidx.media3.common.util.TraceUtil; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; +import androidx.media3.container.ObuParser; import androidx.media3.decoder.DecoderInputBuffer; import androidx.media3.exoplayer.DecoderCounters; import androidx.media3.exoplayer.DecoderReuseEvaluation; @@ -148,6 +149,18 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer */ private static final long OFFSET_FROM_PERIOD_END_TO_TREAT_AS_LAST_US = 100_000L; + /** + * The maximum number of consecutive dropped input buffers that allow discarding frame headers. + * + *

Discarding input buffers of type {@link ObuParser#OBU_FRAME_HEADER} speeds up decoding by + * not showing already-decoded frames. This is less beneficial than discarding {@link + * ObuParser#OBU_FRAME} which reduces the total number of decoded frames. + * + *

Dropping too many consecutive input buffers reduces the update frequency of {@link + * #shouldDropDecoderInputBuffers}, and can harm user experience. + */ + private static final int MAX_CONSECUTIVE_DROPPED_INPUT_BUFFERS_COUNT_TO_DISCARD_HEADER = 0; + private static boolean evaluatedDeviceNeedsSetOutputSurfaceWorkaround; private static boolean deviceNeedsSetOutputSurfaceWorkaround; @@ -199,6 +212,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer private boolean pendingVideoSinkInputStreamChange; private boolean shouldDropDecoderInputBuffers; + private int consecutiveDroppedInputBufferCount; /** A builder to create {@link MediaCodecVideoRenderer} instances. */ public static final class Builder { @@ -1254,6 +1268,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer droppedDecoderInputBufferTimestamps.clear(); shouldDropDecoderInputBuffers = false; buffersInCodecCount = 0; + consecutiveDroppedInputBufferCount = 0; if (av1SampleDependencyParser != null) { av1SampleDependencyParser.reset(); } @@ -1436,6 +1451,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer && buffer.data != null) { av1SampleDependencyParser.queueInputBuffer(buffer.data); } + consecutiveDroppedInputBufferCount = 0; // In tunneling mode the device may do frame rate conversion, so in general we can't keep track // of the number of buffers in the codec. if (!tunneling) { @@ -1484,16 +1500,22 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer decoderCounters.skippedInputBufferCount += 1; } else if (shouldDropDecoderInputBuffers) { droppedDecoderInputBufferTimestamps.add(buffer.timeUs); + consecutiveDroppedInputBufferCount += 1; } return true; } if (av1SampleDependencyParser != null && checkNotNull(getCodecInfo()).mimeType.equals(MimeTypes.VIDEO_AV1) && buffer.data != null) { + boolean skipFrameHeaders = + shouldSkipDecoderInputBuffer + || consecutiveDroppedInputBufferCount + <= MAX_CONSECUTIVE_DROPPED_INPUT_BUFFERS_COUNT_TO_DISCARD_HEADER; ByteBuffer readOnlySample = buffer.data.asReadOnlyBuffer(); readOnlySample.flip(); int sampleLimitAfterSkippingNonReferenceFrames = - av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame(readOnlySample); + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + readOnlySample, skipFrameHeaders); boolean hasSpaceForNextFrame = sampleLimitAfterSkippingNonReferenceFrames + checkNotNull(codecMaxValues).inputSize < readOnlySample.capacity(); @@ -1504,6 +1526,7 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer decoderCounters.skippedInputBufferCount += 1; } else if (shouldDropDecoderInputBuffers) { droppedDecoderInputBufferTimestamps.add(buffer.timeUs); + consecutiveDroppedInputBufferCount += 1; } return true; } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ParseAv1SampleDependenciesPlaybackTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ParseAv1SampleDependenciesPlaybackTest.java index 202c49e4f6..7a2b56c240 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ParseAv1SampleDependenciesPlaybackTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ParseAv1SampleDependenciesPlaybackTest.java @@ -15,13 +15,27 @@ */ package androidx.media3.exoplayer.e2etest; +import static com.google.common.truth.Truth.assertThat; + import android.content.Context; import android.graphics.SurfaceTexture; +import android.os.Handler; import android.view.Surface; +import androidx.annotation.Nullable; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem.ClippingConfiguration; import androidx.media3.common.Player; +import androidx.media3.exoplayer.DecoderCounters; +import androidx.media3.exoplayer.DefaultRenderersFactory; import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.Renderer; +import androidx.media3.exoplayer.audio.AudioRendererEventListener; +import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter; +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import androidx.media3.exoplayer.metadata.MetadataOutput; +import androidx.media3.exoplayer.text.TextOutput; +import androidx.media3.exoplayer.video.MediaCodecVideoRenderer; +import androidx.media3.exoplayer.video.VideoRendererEventListener; import androidx.media3.test.utils.CapturingRenderersFactory; import androidx.media3.test.utils.DumpFileAsserts; import androidx.media3.test.utils.FakeClock; @@ -33,6 +47,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; /** End-to-end playback tests using AV1 sample skipping. */ @RunWith(AndroidJUnit4.class) @@ -74,4 +89,143 @@ public class ParseAv1SampleDependenciesPlaybackTest { playbackOutput, /* dumpFile= */ "playbackdumps/av1SampleDependencies/clippedMediaItem.dump"); } + + // TODO: b/390604981 - Run the test on older SDK levels to ensure it uses a MediaCodec shadow + // with more than one buffer slot. + @Config(minSdk = 30) + @Test + public void playback_withLateThresholdToDropDecoderInput_skipNonReferenceInputSamples() + throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + CapturingRenderersFactoryWithLateThresholdToDropDecoderInputUs renderersFactory = + new CapturingRenderersFactoryWithLateThresholdToDropDecoderInputUs(applicationContext); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + Surface surface = new Surface(new SurfaceTexture(/* texName= */ 1)); + player.setVideoSurface(surface); + player.setMediaItem(MediaItem.fromUri(TEST_MP4_URI)); + + player.prepare(); + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + surface.release(); + DecoderCounters decoderCounters = player.getVideoDecoderCounters(); + + // Do not assert on a full playback dump as it depends on the number of MediaCodec buffer + // slots, which may change in b/390604981. + // Half of the input buffers are non-reference OBU_FRAME which will be dropped. + // The other half are non-reference OBU_FRAME_HEADER - only the first one may be dropped. + // Which input buffer is dropped first depends on the number of MediaCodec buffer slots. + // This means the asserts cannot be isEqualTo. + assertThat(decoderCounters.maxConsecutiveDroppedBufferCount).isAtMost(2); + assertThat(decoderCounters.droppedInputBufferCount).isAtLeast(8); + } + + private static final class CapturingRenderersFactoryWithLateThresholdToDropDecoderInputUs + extends CapturingRenderersFactory { + + private final Context context; + + /** + * Creates an instance. + * + * @param context The {@link Context}. + */ + public CapturingRenderersFactoryWithLateThresholdToDropDecoderInputUs(Context context) { + super(context); + this.context = context; + } + + @Override + public Renderer[] createRenderers( + Handler eventHandler, + VideoRendererEventListener videoRendererEventListener, + AudioRendererEventListener audioRendererEventListener, + TextOutput textRendererOutput, + MetadataOutput metadataRendererOutput) { + return new Renderer[] { + new CapturingMediaCodecVideoRenderer( + context, + getMediaCodecAdapterFactory(), + MediaCodecSelector.DEFAULT, + DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS, + /* enableDecoderFallback= */ false, + eventHandler, + videoRendererEventListener, + DefaultRenderersFactory.MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY, + /* parseAv1SampleDependencies= */ true, + /* lateThresholdToDropDecoderInputUs= */ -100_000_000L) + }; + } + + /** + * A {@link MediaCodecVideoRenderer} that will not skip or drop buffers due to slow processing. + */ + private static class CapturingMediaCodecVideoRenderer extends MediaCodecVideoRenderer { + private CapturingMediaCodecVideoRenderer( + Context context, + MediaCodecAdapter.Factory codecAdapterFactory, + MediaCodecSelector mediaCodecSelector, + long allowedJoiningTimeMs, + boolean enableDecoderFallback, + @Nullable Handler eventHandler, + @Nullable VideoRendererEventListener eventListener, + int maxDroppedFramesToNotify, + boolean parseAv1SampleDependencies, + long lateThresholdToDropDecoderInputUs) { + super( + new Builder(context) + .setCodecAdapterFactory(codecAdapterFactory) + .setMediaCodecSelector(mediaCodecSelector) + .setAllowedJoiningTimeMs(allowedJoiningTimeMs) + .setEnableDecoderFallback(enableDecoderFallback) + .setEventHandler(eventHandler) + .setEventListener(eventListener) + .setMaxDroppedFramesToNotify(maxDroppedFramesToNotify) + .experimentalSetParseAv1SampleDependencies(parseAv1SampleDependencies) + .experimentalSetLateThresholdToDropDecoderInputUs( + lateThresholdToDropDecoderInputUs)); + } + + @Override + protected boolean shouldDropOutputBuffer( + long earlyUs, long elapsedRealtimeUs, boolean isLastBuffer) { + // Do not drop output buffers due to slow processing. + return false; + } + + @Override + protected boolean shouldDropBuffersToKeyframe( + long earlyUs, long elapsedRealtimeUs, boolean isLastBuffer) { + // Do not drop output buffers due to slow processing. + return false; + } + + @Override + protected boolean shouldSkipBuffersWithIdenticalReleaseTime() { + // Do not skip buffers with identical vsync times as we can't control this from tests. + return false; + } + + @Override + protected boolean shouldSkipLateBuffersWhileUsingPlaceholderSurface() { + // Do not skip buffers while using placeholder surface due to slow processing. + return false; + } + + @Override + protected boolean shouldForceRenderOutputBuffer(long earlyUs, long elapsedSinceLastRenderUs) { + // An auto-advancing FakeClock can make a lot of progress before + // AsynchronousMediaCodecAdapter produces an output buffer - causing all output buffers to + // be force rendered. + // Force rendering output buffers prevents evaluation of lateThresholdToDropDecoderInputUs. + // Do not allow force rendering of output buffers when testing + // lateThresholdToDropDecoderInputUs. + return false; + } + } + } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/video/Av1SampleDependencyParserTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/video/Av1SampleDependencyParserTest.java index c3a9bb65b9..167ae1d9f6 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/video/Av1SampleDependencyParserTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/video/Av1SampleDependencyParserTest.java @@ -43,6 +43,8 @@ public class Av1SampleDependencyParserTest { 0x32, 0x1A, 0x30, 0xC0, 0x00, 0x1D, 0x66, 0x68, 0x46, 0xC9, 0x38, 0x00, 0x60, 0x10, 0x20, 0x80, 0x20, 0x00, 0x00, 0x01, 0x8B, 0x7A, 0x87, 0xF9, 0xAA, 0x2D, 0x0F, 0x2C); + private static final byte[] frameHeader = createByteArray(0x1A, 0x01, 0xC8); + private static final byte[] temporalDelimiter = createByteArray(0x12, 0x00); private static final byte[] padding = createByteArray(0x7a, 0x02, 0xFF, 0xFF); @@ -56,7 +58,8 @@ public class Av1SampleDependencyParserTest { Av1SampleDependencyParser av1SampleDependencyParser = new Av1SampleDependencyParser(); int sampleLimitAfterSkippingNonReferenceFrames = - av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame(sample); + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + sample, /* skipFrameHeaders= */ true); assertThat(sampleLimitAfterSkippingNonReferenceFrames) .isEqualTo(sequenceHeader.length + dependedOnFrame.length); @@ -72,7 +75,8 @@ public class Av1SampleDependencyParserTest { Av1SampleDependencyParser av1SampleDependencyParser = new Av1SampleDependencyParser(); int sampleLimitAfterSkippingNonReferenceFrames = - av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame(sample); + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + sample, /* skipFrameHeaders= */ true); assertThat(sampleLimitAfterSkippingNonReferenceFrames).isEqualTo(sequenceHeader.length); } @@ -86,7 +90,8 @@ public class Av1SampleDependencyParserTest { av1SampleDependencyParser.queueInputBuffer(header); int sampleLimitAfterSkippingNonReferenceFrames = - av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame(frame); + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + frame, /* skipFrameHeaders= */ true); assertThat(sampleLimitAfterSkippingNonReferenceFrames).isEqualTo(0); } @@ -104,7 +109,8 @@ public class Av1SampleDependencyParserTest { av1SampleDependencyParser.queueInputBuffer(header); int sampleLimitAfterSkippingNonReferenceFrames = - av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame(sample); + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + sample, /* skipFrameHeaders= */ true); assertThat(sampleLimitAfterSkippingNonReferenceFrames).isEqualTo(0); } @@ -123,7 +129,8 @@ public class Av1SampleDependencyParserTest { av1SampleDependencyParser.queueInputBuffer(header); int sampleLimitAfterSkippingNonReferenceFrames = - av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame(sample); + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + sample, /* skipFrameHeaders= */ true); assertThat(sampleLimitAfterSkippingNonReferenceFrames) .isEqualTo(temporalDelimiter.length + padding.length + dependedOnFrame.length); @@ -135,7 +142,8 @@ public class Av1SampleDependencyParserTest { Av1SampleDependencyParser av1SampleDependencyParser = new Av1SampleDependencyParser(); int sampleLimitAfterSkippingNonReferenceFrames = - av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame(frame); + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + frame, /* skipFrameHeaders= */ true); assertThat(sampleLimitAfterSkippingNonReferenceFrames).isEqualTo(notDependedOnFrame.length); } @@ -152,7 +160,8 @@ public class Av1SampleDependencyParserTest { av1SampleDependencyParser.queueInputBuffer(header); int sampleLimitAfterSkippingNonReferenceFrames = - av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame(sample); + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + sample, /* skipFrameHeaders= */ true); assertThat(sampleLimitAfterSkippingNonReferenceFrames) .isEqualTo(notDependedOnFrame.length + notDependedOnFrame.length); @@ -174,7 +183,8 @@ public class Av1SampleDependencyParserTest { Av1SampleDependencyParser av1SampleDependencyParser = new Av1SampleDependencyParser(); int sampleLimitAfterSkippingNonReferenceFrames = - av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame(sample); + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + sample, /* skipFrameHeaders= */ true); assertThat(sampleLimitAfterSkippingNonReferenceFrames).isEqualTo(sample.limit()); } @@ -189,8 +199,39 @@ public class Av1SampleDependencyParserTest { av1SampleDependencyParser.queueInputBuffer(header); av1SampleDependencyParser.reset(); int sampleLimitAfterSkippingNonReferenceFrames = - av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame(frame); + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + frame, /* skipFrameHeaders= */ true); assertThat(sampleLimitAfterSkippingNonReferenceFrames).isEqualTo(notDependedOnFrame.length); } + + @Test + public void + sampleLimitAfterSkippingNonReferenceFrame_withSkipFrameHeadersTrue_returnsEmptySample() { + ByteBuffer header = ByteBuffer.wrap(sequenceHeader); + ByteBuffer sample = ByteBuffer.wrap(frameHeader); + Av1SampleDependencyParser av1SampleDependencyParser = new Av1SampleDependencyParser(); + + av1SampleDependencyParser.queueInputBuffer(header); + int sampleLimitAfterSkippingNonReferenceFrame = + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + sample, /* skipFrameHeaders= */ true); + + assertThat(sampleLimitAfterSkippingNonReferenceFrame).isEqualTo(0); + } + + @Test + public void + sampleLimitAfterSkippingNonReferenceFrame_withSkipFrameHeadersFalse_returnsFullSample() { + ByteBuffer header = ByteBuffer.wrap(sequenceHeader); + ByteBuffer sample = ByteBuffer.wrap(frameHeader); + Av1SampleDependencyParser av1SampleDependencyParser = new Av1SampleDependencyParser(); + + av1SampleDependencyParser.queueInputBuffer(header); + int sampleLimitAfterSkippingNonReferenceFrame = + av1SampleDependencyParser.sampleLimitAfterSkippingNonReferenceFrame( + sample, /* skipFrameHeaders= */ false); + + assertThat(sampleLimitAfterSkippingNonReferenceFrame).isEqualTo(frameHeader.length); + } } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/CapturingRenderersFactory.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/CapturingRenderersFactory.java index 37399aafcf..f533573392 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/CapturingRenderersFactory.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/CapturingRenderersFactory.java @@ -213,6 +213,13 @@ public class CapturingRenderersFactory implements RenderersFactory, Dumper.Dumpa /* parseAv1SampleDependencies= */ false); } + /** + * Returns the {@link CapturingMediaCodecAdapter.Factory} as a {@link MediaCodecAdapter.Factory}. + */ + protected MediaCodecAdapter.Factory getMediaCodecAdapterFactory() { + return mediaCodecAdapterFactory; + } + /** * A {@link MediaCodecVideoRenderer} that will not skip or drop buffers due to slow processing. */