From bec8b44ba55b922f5b650f65b99992b8697e32aa Mon Sep 17 00:00:00 2001 From: Dustin Date: Sun, 30 Jan 2022 13:29:07 -0700 Subject: [PATCH] BitmapFactoryVideoRenderer Tests --- library/core/build.gradle | 6 +- .../video/BitmapFactoryVideoRenderer.java | 200 +++++++++------ .../video/BitmapFactoryVideoRendererTest.java | 237 ++++++++++++++++++ .../exoplayer2/video/FakeEventListener.java | 64 +++++ .../video/ShadowSurfaceExtended.java | 43 ++++ .../exoplayer2/ui/CanvasSubtitleOutput.java | 3 +- .../test/assets/media/jpeg/image-320-240.jpg | Bin 0 -> 36039 bytes 7 files changed, 474 insertions(+), 79 deletions(-) create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/video/BitmapFactoryVideoRendererTest.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/video/FakeEventListener.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/video/ShadowSurfaceExtended.java create mode 100644 testdata/src/test/assets/media/jpeg/image-320-240.jpg diff --git a/library/core/build.gradle b/library/core/build.gradle index 5bf70ad151..3196735378 100644 --- a/library/core/build.gradle +++ b/library/core/build.gradle @@ -21,7 +21,11 @@ android { testInstrumentationRunnerArguments clearPackageData: 'true' multiDexEnabled true } - + testOptions{ + unitTests.all { + jvmArgs '-noverify' + } + } buildTypes { debug { testCoverageEnabled = true diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/BitmapFactoryVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/BitmapFactoryVideoRenderer.java index 08c2e51e49..31dba8ef3e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/BitmapFactoryVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/BitmapFactoryVideoRenderer.java @@ -3,13 +3,15 @@ package com.google.android.exoplayer2.video; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; -import android.graphics.Point; import android.graphics.Rect; import android.os.Handler; import android.os.SystemClock; import android.view.Surface; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; +import androidx.arch.core.util.Function; import com.google.android.exoplayer2.BaseRenderer; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -23,11 +25,16 @@ import com.google.android.exoplayer2.util.MimeTypes; import java.nio.ByteBuffer; public class BitmapFactoryVideoRenderer extends BaseRenderer { - private static final String TAG = "BitmapFactoryRenderer"; + static final String TAG = "BitmapFactoryRenderer"; + + //Sleep Reasons + static final String STREAM_END = "Stream End"; + static final String STREAM_EMPTY = "Stream Empty"; + static final String RENDER_WAIT = "Render Wait"; + private static int threadId; private final Rect rect = new Rect(); - private final Point lastSurface = new Point(); private final RenderRunnable renderRunnable = new RenderRunnable(); final VideoRendererEventListener.EventDispatcher eventDispatcher; @@ -60,7 +67,16 @@ public class BitmapFactoryVideoRenderer extends BaseRenderer { throws ExoPlaybackException { decoderCounters = new DecoderCounters(); eventDispatcher.enabled(decoderCounters); - thread.start(); + if (mayRenderStartOfStream) { + thread.start(); + } + } + + @Override + protected void onStarted() throws ExoPlaybackException { + if (thread.getState() == Thread.State.NEW) { + thread.start(); + } } @Override @@ -74,20 +90,12 @@ public class BitmapFactoryVideoRenderer extends BaseRenderer { } } - private void onFormatChanged(@NonNull FormatHolder formatHolder) { - @Nullable final Format format = formatHolder.format; - if (format != null) { - frameUs = (long)(1_000_000L / format.frameRate); - eventDispatcher.inputFormatChanged(format, null); - } - } - @Override public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { //Log.d(TAG, "Render: us=" + positionUs); - synchronized (eventDispatcher) { + synchronized (renderRunnable) { currentTimeUs = positionUs; - eventDispatcher.notify(); + renderRunnable.notify(); } } @@ -127,7 +135,17 @@ public class BitmapFactoryVideoRenderer extends BaseRenderer { return RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE); } - void renderBitmap(final Bitmap bitmap) { + @WorkerThread + private void onFormatChanged(@NonNull FormatHolder formatHolder) { + @Nullable final Format format = formatHolder.format; + if (format != null) { + frameUs = (long)(1_000_000L / format.frameRate); + eventDispatcher.inputFormatChanged(format, null); + } + } + + @WorkerThread + void renderBitmap(@NonNull final Bitmap bitmap) { @Nullable final Surface surface = this.surface; if (surface == null) { @@ -136,30 +154,7 @@ public class BitmapFactoryVideoRenderer extends BaseRenderer { //Log.d(TAG, "Drawing: " + bitmap.getWidth() + "x" + bitmap.getHeight()); final Canvas canvas = surface.lockCanvas(null); - final Rect clipBounds = canvas.getClipBounds(); - final VideoSize videoSize = new VideoSize(bitmap.getWidth(), bitmap.getHeight()); - final boolean videoSizeChanged; - if (videoSize.equals(lastVideoSize)) { - videoSizeChanged = false; - } else { - lastVideoSize = videoSize; - eventDispatcher.videoSizeChanged(videoSize); - videoSizeChanged = true; - } - if (lastSurface.x != clipBounds.width() || lastSurface.y != clipBounds.height() || - videoSizeChanged) { - lastSurface.x = clipBounds.width(); - lastSurface.y = clipBounds.height(); - final float scaleX = lastSurface.x / (float)videoSize.width; - final float scaleY = lastSurface.y / (float)videoSize.height; - final float scale = Math.min(scaleX, scaleY); - final float width = videoSize.width * scale; - final float height = videoSize.height * scale; - final int x = (int)(lastSurface.x - width) / 2; - final int y = (int)(lastSurface.y - height) / 2; - rect.set(x, y, x + (int)width, y + (int) height); - } - canvas.drawBitmap(bitmap, null, rect, null); + renderBitmap(bitmap, canvas); surface.unlockCanvasAndPost(canvas); @Nullable @@ -173,12 +168,27 @@ public class BitmapFactoryVideoRenderer extends BaseRenderer { } } - class RenderRunnable implements Runnable { + @WorkerThread + @VisibleForTesting + void renderBitmap(Bitmap bitmap, Canvas canvas) { + final VideoSize videoSize = new VideoSize(bitmap.getWidth(), bitmap.getHeight()); + if (!videoSize.equals(lastVideoSize)) { + lastVideoSize = videoSize; + eventDispatcher.videoSizeChanged(videoSize); + } + rect.set(0,0,canvas.getWidth(), canvas.getHeight()); + canvas.drawBitmap(bitmap, null, rect, null); + } + + class RenderRunnable implements Runnable, Function { final DecoderInputBuffer decoderInputBuffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); private volatile boolean running = true; + @VisibleForTesting + Function sleepFunction = this; + void stop() { running = false; thread.interrupt(); @@ -197,7 +207,7 @@ public class BitmapFactoryVideoRenderer extends BaseRenderer { bitmap = BitmapFactory.decodeByteArray(byteBuffer.array(), byteBuffer.arrayOffset(), byteBuffer.arrayOffset() + byteBuffer.position()); if (bitmap == null) { - eventDispatcher.videoCodecError(new NullPointerException("Decode bytes failed")); + throw new NullPointerException("Decode bytes failed"); } else { return bitmap; } @@ -212,18 +222,21 @@ public class BitmapFactoryVideoRenderer extends BaseRenderer { * * @return true if interrupted */ - private boolean sleep() { - synchronized (eventDispatcher) { - try { - eventDispatcher.wait(); - return false; - } catch (InterruptedException e) { - //If we are interrupted, treat as a cancel - return true; - } + public synchronized Boolean apply(String why) { + try { + wait(); + return false; + } catch (InterruptedException e) { + //If we are interrupted, treat as a cancel + return true; } } + private boolean sleep(String why) { + return sleepFunction.apply(why); + } + + @WorkerThread public void run() { final FormatHolder formatHolder = getFormatHolder(); long start = SystemClock.uptimeMillis(); @@ -232,40 +245,73 @@ public class BitmapFactoryVideoRenderer extends BaseRenderer { decoderInputBuffer.clear(); final int result = readSource(formatHolder, decoderInputBuffer, formatHolder.format == null ? SampleStream.FLAG_REQUIRE_FORMAT : 0); - if (result == C.RESULT_BUFFER_READ) { - if (decoderInputBuffer.isEndOfStream()) { - //Wait for shutdown or stream to be changed - sleep(); - continue; - } - final long leadUs = decoderInputBuffer.timeUs - currentTimeUs; - //If we are more than 1/2 a frame behind, skip the next frame - if (leadUs < -frameUs / 2) { - eventDispatcher.droppedFrames(1, SystemClock.uptimeMillis() - start); + switch (result) { + case C.RESULT_BUFFER_READ: { + if (decoderInputBuffer.isEndOfStream()) { + //Wait for shutdown or stream to be changed + sleep(STREAM_END); + continue; + } + final long leadUs = decoderInputBuffer.timeUs - currentTimeUs; + //If we are more than 1/2 a frame behind, skip the next frame + if (leadUs < -frameUs / 2) { + eventDispatcher.droppedFrames(1, SystemClock.uptimeMillis() - start); + start = SystemClock.uptimeMillis(); + continue; + } start = SystemClock.uptimeMillis(); - continue; - } - start = SystemClock.uptimeMillis(); - @Nullable - final Bitmap bitmap = decodeInputBuffer(decoderInputBuffer); - if (bitmap == null) { - continue; - } - while (currentTimeUs < decoderInputBuffer.timeUs) { - //Log.d(TAG, "Sleep: us=" + currentTimeUs); - if (sleep()) { - //Sleep was interrupted, discard Bitmap - continue main; + @Nullable + final Bitmap bitmap = decodeInputBuffer(decoderInputBuffer); + if (bitmap == null) { + continue; + } + while (currentTimeUs < decoderInputBuffer.timeUs) { + //Log.d(TAG, "Sleep: us=" + currentTimeUs); + if (sleep(RENDER_WAIT)) { + //Sleep was interrupted, discard Bitmap + continue main; + } + } + if (running) { + renderBitmap(bitmap); } } - if (running) { - renderBitmap(bitmap); - } - } else if (result == C.RESULT_FORMAT_READ) { + break; + case C.RESULT_FORMAT_READ: onFormatChanged(formatHolder); + break; + case C.RESULT_NOTHING_READ: + sleep(STREAM_EMPTY); + break; } } } } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + Rect getRect() { + return rect; + } + + @Nullable + @VisibleForTesting + DecoderCounters getDecoderCounters() { + return decoderCounters; + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + Thread getThread() { + return thread; + } + + @Nullable + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + Surface getSurface() { + return surface; + } + + RenderRunnable getRenderRunnable() { + return renderRunnable; + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/BitmapFactoryVideoRendererTest.java b/library/core/src/test/java/com/google/android/exoplayer2/video/BitmapFactoryVideoRendererTest.java new file mode 100644 index 0000000000..914f59786b --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/BitmapFactoryVideoRendererTest.java @@ -0,0 +1,237 @@ +package com.google.android.exoplayer2.video; + +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; +import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.sample; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.os.Handler; +import android.os.Looper; +import android.view.Surface; +import androidx.arch.core.util.Function; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.RendererConfiguration; +import com.google.android.exoplayer2.drm.DrmSessionEventListener; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.testutil.FakeSampleStream; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DefaultAllocator; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowBitmapFactory; +import org.robolectric.shadows.ShadowLooper; + +@RunWith(AndroidJUnit4.class) +@Config(shadows = {ShadowSurfaceExtended.class}) +public class BitmapFactoryVideoRendererTest { + private final static Format FORMAT_MJPEG = new Format.Builder(). + setSampleMimeType(MimeTypes.VIDEO_MJPEG). + setWidth(320).setHeight(240). + setFrameRate(15f).build(); + + FakeEventListener fakeEventListener = new FakeEventListener(); + BitmapFactoryVideoRenderer bitmapFactoryVideoRenderer; + + @Before + public void before() { + fakeEventListener = new FakeEventListener(); + final Handler handler = new Handler(Looper.getMainLooper()); + bitmapFactoryVideoRenderer = new BitmapFactoryVideoRenderer(handler, fakeEventListener); + } + + @After + public void after() { + //Kill the Thread + bitmapFactoryVideoRenderer.onDisabled(); + } + + @Test + public void getName() { + Assert.assertEquals(BitmapFactoryVideoRenderer.TAG, bitmapFactoryVideoRenderer.getName()); + } + + @Test + public void onEnabled_givenMayRenderStartOfStream() throws PlaybackException { + bitmapFactoryVideoRenderer.onEnabled(false, true); + ShadowLooper.idleMainLooper(); + Assert.assertNotNull(bitmapFactoryVideoRenderer.getDecoderCounters()); + Assert.assertEquals(Thread.State.RUNNABLE, bitmapFactoryVideoRenderer.getThread().getState()); + Assert.assertTrue(fakeEventListener.isVideoEnabled()); + } + + @Test + public void onStarted_givenThreadNotStarted() throws PlaybackException { + bitmapFactoryVideoRenderer.onStarted(); + ShadowLooper.idleMainLooper(); + Assert.assertEquals(Thread.State.RUNNABLE, bitmapFactoryVideoRenderer.getThread().getState()); + } + + @Test + public void onDisabled_givenOnEnabled() throws PlaybackException, InterruptedException { + onEnabled_givenMayRenderStartOfStream(); + bitmapFactoryVideoRenderer.onDisabled(); + ShadowLooper.idleMainLooper(); + Assert.assertFalse(fakeEventListener.isVideoEnabled()); + //Ensure Thread is shutdown + bitmapFactoryVideoRenderer.getThread().join(500L); + Assert.assertTrue(bitmapFactoryVideoRenderer.isEnded()); + } + + private FakeSampleStream getSampleStream() throws IOException { + final Context context = ApplicationProvider.getApplicationContext(); + final byte[] bytes = TestUtil.getByteArray(context, "media/jpeg/image-320-240.jpg"); + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + new DefaultAllocator(/* trimOnReset= */ true, /* individualAllocationSize= */ 1024), + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DRM_UNSUPPORTED, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ FORMAT_MJPEG, + ImmutableList.of( + sample(0L, C.BUFFER_FLAG_KEY_FRAME, bytes), + END_OF_STREAM_ITEM)); + return fakeSampleStream; + } + + private Surface setSurface() throws ExoPlaybackException { + final Surface surface = ShadowSurfaceExtended.newInstance(); + final ShadowSurfaceExtended shadowSurfaceExtended = Shadow.extract(surface); + shadowSurfaceExtended.setSize(1080, 1920); + bitmapFactoryVideoRenderer.handleMessage(Renderer.MSG_SET_VIDEO_OUTPUT, surface); + return surface; + } + + @Test + public void handleMessage_givenSurface() throws ExoPlaybackException { + final Surface surface = setSurface(); + Assert.assertSame(surface, bitmapFactoryVideoRenderer.getSurface()); + bitmapFactoryVideoRenderer.handleMessage(Renderer.MSG_SET_VIDEO_OUTPUT, null); + Assert.assertNull(bitmapFactoryVideoRenderer.getSurface()); + } + + @Test + public void isReady_givenSurface() throws ExoPlaybackException { + Assert.assertFalse(bitmapFactoryVideoRenderer.isReady()); + setSurface(); + Assert.assertTrue(bitmapFactoryVideoRenderer.isReady()); + } + + @Test + public void render_givenJpegAndSurface() throws IOException, ExoPlaybackException { + final Surface surface = setSurface(); + final ShadowSurfaceExtended shadowSurfaceExtended = Shadow.extract(surface); + + FakeSampleStream fakeSampleStream = getSampleStream(); + fakeSampleStream.writeData(0L); + bitmapFactoryVideoRenderer.enable(RendererConfiguration.DEFAULT, new Format[]{FORMAT_MJPEG}, + fakeSampleStream, 0L, false, true, 0L, 0L); + bitmapFactoryVideoRenderer.render(0L, 0L); + // This test actually decodes the JPEG (very cool!), + // May need to bump up timers for slow machines + Assert.assertTrue(shadowSurfaceExtended.waitForPost(500L)); + } + + @Test + public void supportsFormat_givenMjpegFormat() throws ExoPlaybackException{ + Assert.assertEquals(C.FORMAT_HANDLED, + bitmapFactoryVideoRenderer.supportsFormat(FORMAT_MJPEG) & C.FORMAT_HANDLED); + } + + @Test + public void supportsFormat_givenMp4vFormat() throws ExoPlaybackException{ + final Format format = new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_MP4V).build(); + Assert.assertEquals(0, + bitmapFactoryVideoRenderer.supportsFormat(format) & C.FORMAT_HANDLED); + } + + @Test + public void renderBitmap_given4by3BitmapAnd16by9Canvas() { + final Bitmap bitmap = Bitmap.createBitmap(FORMAT_MJPEG.width, FORMAT_MJPEG.height, Bitmap.Config.ARGB_8888); + final Bitmap canvasBitmap = Bitmap.createBitmap(1080, 1920, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(canvasBitmap); + bitmapFactoryVideoRenderer.renderBitmap(bitmap, canvas); + ShadowLooper.idleMainLooper(); + + final Rect rect = bitmapFactoryVideoRenderer.getRect(); + Assert.assertEquals(canvas.getWidth(), rect.width()); + Assert.assertEquals(canvas.getHeight(), rect.height()); + final VideoSize videoSize = fakeEventListener.videoSize; + Assert.assertEquals(bitmap.getWidth(), videoSize.width); + + bitmapFactoryVideoRenderer.renderBitmap(bitmap, canvas); + ShadowLooper.idleMainLooper(); + Assert.assertSame(videoSize, fakeEventListener.videoSize); + } + + @Test + public void RenderRunnable_run_givenLateFrame() throws IOException, ExoPlaybackException { + final Function sleep = why -> {throw new RuntimeException(why);}; + + FakeSampleStream fakeSampleStream = getSampleStream(); + fakeSampleStream.writeData(0L); + //Don't enable so the Thread is not running + bitmapFactoryVideoRenderer.replaceStream(new Format[]{FORMAT_MJPEG}, fakeSampleStream, 0L, 0L); + BitmapFactoryVideoRenderer.RenderRunnable renderRunnable = + bitmapFactoryVideoRenderer.getRenderRunnable(); + renderRunnable.sleepFunction = sleep; + bitmapFactoryVideoRenderer.render(1_000_000L, 0L); + try { + renderRunnable.run(); + } catch (RuntimeException e) { + Assert.assertEquals(BitmapFactoryVideoRenderer.STREAM_EMPTY, e.getMessage()); + } + ShadowLooper.idleMainLooper(); + Assert.assertEquals(1, fakeEventListener.getDroppedFrames()); + } + + @Test + public void RenderRunnable_run_givenBadJpeg() throws IOException, ExoPlaybackException { + final Function sleep = why -> {throw new RuntimeException(why);}; + FakeSampleStream fakeSampleStream = + new FakeSampleStream( + new DefaultAllocator(/* trimOnReset= */ true, /* individualAllocationSize= */ 1024), + /* mediaSourceEventDispatcher= */ null, + DrmSessionManager.DRM_UNSUPPORTED, + new DrmSessionEventListener.EventDispatcher(), + /* initialFormat= */ FORMAT_MJPEG, + ImmutableList.of( + oneByteSample(0L, C.BUFFER_FLAG_KEY_FRAME), + END_OF_STREAM_ITEM)); + fakeSampleStream.writeData(0L); + + //Don't enable so the Thread is not running + bitmapFactoryVideoRenderer.replaceStream(new Format[]{FORMAT_MJPEG}, fakeSampleStream, 0L, 0L); + BitmapFactoryVideoRenderer.RenderRunnable renderRunnable = + bitmapFactoryVideoRenderer.getRenderRunnable(); + renderRunnable.sleepFunction = sleep; + bitmapFactoryVideoRenderer.render(0L, 0L); + // There is a bug in Robolectric where it doesn't handle null images, + // so we won't get our Exception + ShadowBitmapFactory.setAllowInvalidImageData(false); + try { + renderRunnable.run(); + } catch (RuntimeException e) { + Assert.assertEquals(BitmapFactoryVideoRenderer.STREAM_EMPTY, e.getMessage()); + } + ShadowLooper.idleMainLooper(); + Assert.assertTrue(fakeEventListener.getVideoCodecError() instanceof NullPointerException); + + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/FakeEventListener.java b/library/core/src/test/java/com/google/android/exoplayer2/video/FakeEventListener.java new file mode 100644 index 0000000000..ebc70e3f94 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/FakeEventListener.java @@ -0,0 +1,64 @@ +package com.google.android.exoplayer2.video; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.decoder.DecoderCounters; + +public class FakeEventListener implements VideoRendererEventListener { + @Nullable + VideoSize videoSize; + @Nullable + DecoderCounters decoderCounters; + + private long firstFrameRenderMs = Long.MIN_VALUE; + + private int droppedFrames; + + private Exception videoCodecError; + + @Override + public void onVideoSizeChanged(VideoSize videoSize) { + this.videoSize = videoSize; + } + + public boolean isVideoEnabled() { + return decoderCounters != null; + } + + @Override + public void onVideoEnabled(DecoderCounters counters) { + decoderCounters = counters; + } + + @Override + public void onVideoDisabled(DecoderCounters counters) { + decoderCounters = null; + } + + public long getFirstFrameRenderMs() { + return firstFrameRenderMs; + } + + @Override + public void onRenderedFirstFrame(Object output, long renderTimeMs) { + firstFrameRenderMs = renderTimeMs; + } + + public int getDroppedFrames() { + return droppedFrames; + } + + @Override + public void onDroppedFrames(int count, long elapsedMs) { + droppedFrames+=count; + } + + public Exception getVideoCodecError() { + return videoCodecError; + } + + @Override + public void onVideoCodecError(Exception videoCodecError) { + this.videoCodecError = videoCodecError; + } + +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/video/ShadowSurfaceExtended.java b/library/core/src/test/java/com/google/android/exoplayer2/video/ShadowSurfaceExtended.java new file mode 100644 index 0000000000..c107832310 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/video/ShadowSurfaceExtended.java @@ -0,0 +1,43 @@ +package com.google.android.exoplayer2.video; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.view.Surface; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import org.robolectric.annotation.Implements; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowSurface; + +@Implements(Surface.class) +public class ShadowSurfaceExtended extends ShadowSurface { + private final Semaphore postSemaphore = new Semaphore(0); + private int width; + private int height; + + public static Surface newInstance() { + return Shadow.newInstanceOf(Surface.class); + } + + public void setSize(final int width, final int height) { + this.width = width; + this.height = height; + } + + public Canvas lockCanvas(Rect canvas) { + return new Canvas(Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)); + } + + public void unlockCanvasAndPost(Canvas canvas) { + postSemaphore.release(); + } + + public boolean waitForPost(long millis) { + try { + return postSemaphore.tryAcquire(millis, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + return false; + } + } +} diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/CanvasSubtitleOutput.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/CanvasSubtitleOutput.java index f0826ce35b..3d92566d3c 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/CanvasSubtitleOutput.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/CanvasSubtitleOutput.java @@ -32,7 +32,8 @@ import java.util.List; * A {@link SubtitleView.Output} that uses Android's native layout framework via {@link * SubtitlePainter}. */ -/* package */ final class CanvasSubtitleOutput extends View implements SubtitleView.Output { +/* package */ final class +CanvasSubtitleOutput extends View implements SubtitleView.Output { private final List painters; diff --git a/testdata/src/test/assets/media/jpeg/image-320-240.jpg b/testdata/src/test/assets/media/jpeg/image-320-240.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d1796c66fdb1869e074b43d4b9964d8257709452 GIT binary patch literal 36039 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�oMa*=h2irbTvr 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