From f64c28f2a67d5ebc10ba3a0b05e8ad4c705c423e Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 19 Nov 2021 14:30:31 +0000 Subject: [PATCH 01/56] Set LogSessionId on MediaDrm session. PiperOrigin-RevId: 411047184 --- .../exoplayer2/drm/DefaultDrmSession.java | 7 +++++- .../drm/DefaultDrmSessionManager.java | 5 +++- .../android/exoplayer2/drm/ExoMediaDrm.java | 9 +++++++ .../exoplayer2/drm/FrameworkMediaDrm.java | 24 +++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java index fff8ab9e7b..43c91d0f30 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSession.java @@ -31,6 +31,7 @@ import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.analytics.PlayerId; import com.google.android.exoplayer2.decoder.CryptoConfig; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; import com.google.android.exoplayer2.drm.ExoMediaDrm.KeyRequest; @@ -133,6 +134,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final HashMap keyRequestParameters; private final CopyOnWriteMultiset eventDispatchers; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final PlayerId playerId; /* package */ final MediaDrmCallback callback; /* package */ final UUID uuid; @@ -182,7 +184,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; HashMap keyRequestParameters, MediaDrmCallback callback, Looper playbackLooper, - LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + PlayerId playerId) { if (mode == DefaultDrmSessionManager.MODE_QUERY || mode == DefaultDrmSessionManager.MODE_RELEASE) { Assertions.checkNotNull(offlineLicenseKeySetId); @@ -204,6 +207,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; this.callback = callback; this.eventDispatchers = new CopyOnWriteMultiset<>(); this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.playerId = playerId; state = STATE_OPENING; responseHandler = new ResponseHandler(playbackLooper); } @@ -370,6 +374,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; try { sessionId = mediaDrm.openSession(); + mediaDrm.setPlayerIdForSession(sessionId, playerId); cryptoConfig = mediaDrm.createCryptoConfig(sessionId); state = STATE_OPENED; // Capture state into a local so a consistent value is seen by the lambda. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java index 652eb8b4a2..3fe2636d09 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java @@ -304,6 +304,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { private @MonotonicNonNull Handler playbackHandler; private int mode; @Nullable private byte[] offlineLicenseKeySetId; + private @MonotonicNonNull PlayerId playerId; /* package */ volatile @Nullable MediaDrmHandler mediaDrmHandler; @@ -492,6 +493,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override public void setPlayer(Looper playbackLooper, PlayerId playerId) { initPlaybackLooper(playbackLooper); + this.playerId = playerId; } @Override @@ -777,7 +779,8 @@ public class DefaultDrmSessionManager implements DrmSessionManager { keyRequestParameters, callback, checkNotNull(playbackLooper), - loadErrorHandlingPolicy); + loadErrorHandlingPolicy, + checkNotNull(playerId)); // Acquire the session once on behalf of the caller to DrmSessionManager - this is the // reference 'assigned' to the caller which they're responsible for releasing. Do this first, // to ensure that eventDispatcher receives all events related to the initial diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java index 6d6a0a821a..722a010150 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ExoMediaDrm.java @@ -26,6 +26,7 @@ import android.os.PersistableBundle; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.analytics.PlayerId; import com.google.android.exoplayer2.decoder.CryptoConfig; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; import java.lang.annotation.Documented; @@ -395,6 +396,14 @@ public interface ExoMediaDrm { */ void closeSession(byte[] sessionId); + /** + * Sets the {@link PlayerId} of the player using a session. + * + * @param sessionId The ID of the session. + * @param playerId The {@link PlayerId} of the player using the session. + */ + default void setPlayerIdForSession(byte[] sessionId, PlayerId playerId) {} + /** * Generates a key request. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java index 9cc9910443..2d02921c98 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.drm; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + import android.annotation.SuppressLint; import android.media.DeniedByServerException; import android.media.MediaCrypto; @@ -23,12 +25,14 @@ import android.media.MediaDrm; import android.media.MediaDrmException; import android.media.NotProvisionedException; import android.media.UnsupportedSchemeException; +import android.media.metrics.LogSessionId; import android.os.PersistableBundle; import android.text.TextUtils; import androidx.annotation.DoNotInline; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.analytics.PlayerId; import com.google.android.exoplayer2.drm.DrmInitData.SchemeData; import com.google.android.exoplayer2.extractor.mp4.PsshAtomUtil; import com.google.android.exoplayer2.util.Assertions; @@ -182,6 +186,13 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { mediaDrm.closeSession(sessionId); } + @Override + public void setPlayerIdForSession(byte[] sessionId, PlayerId playerId) { + if (Util.SDK_INT >= 31) { + Api31.setLogSessionIdOnMediaDrmSession(mediaDrm, sessionId, playerId); + } + } + // Return values of MediaDrm.KeyRequest.getRequestType are equal to KeyRequest.RequestType. @SuppressLint("WrongConstant") @Override @@ -504,9 +515,22 @@ public final class FrameworkMediaDrm implements ExoMediaDrm { @RequiresApi(31) private static class Api31 { + private Api31() {} + @DoNotInline public static boolean requiresSecureDecoder(MediaDrm mediaDrm, String mimeType) { return mediaDrm.requiresSecureDecoder(mimeType); } + + @DoNotInline + public static void setLogSessionIdOnMediaDrmSession( + MediaDrm mediaDrm, byte[] drmSessionId, PlayerId playerId) { + LogSessionId logSessionId = playerId.getLogSessionId(); + if (!logSessionId.equals(LogSessionId.LOG_SESSION_ID_NONE)) { + MediaDrm.PlaybackComponent playbackComponent = + checkNotNull(mediaDrm.getPlaybackComponent(drmSessionId)); + playbackComponent.setLogSessionId(logSessionId); + } + } } } From f138ec98c2bafa6c6671726c8b01b4b07bb7fa8b Mon Sep 17 00:00:00 2001 From: christosts Date: Fri, 19 Nov 2021 14:34:47 +0000 Subject: [PATCH 02/56] Make SynchronousMediaCodecAdapter final PiperOrigin-RevId: 411047838 --- .../exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java index 5995e122c0..00000fea99 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/SynchronousMediaCodecAdapter.java @@ -37,7 +37,7 @@ import java.nio.ByteBuffer; /** * A {@link MediaCodecAdapter} that operates the underlying {@link MediaCodec} in synchronous mode. */ -public class SynchronousMediaCodecAdapter implements MediaCodecAdapter { +public final class SynchronousMediaCodecAdapter implements MediaCodecAdapter { /** A factory for {@link SynchronousMediaCodecAdapter} instances. */ public static class Factory implements MediaCodecAdapter.Factory { From 88d7b14b497656b79fd1aad681bbf72df3435444 Mon Sep 17 00:00:00 2001 From: hschlueter Date: Fri, 19 Nov 2021 15:53:52 +0000 Subject: [PATCH 03/56] Do not queue empty input buffers. Follow-up to a comment on https://github.com/google/ExoPlayer/commit/6f0f7dd1be40c81cb056d82215ceabdd5a8a2a1a: Buffers that are useful to pass to the sample/passthrough pipeline should either contain data or the end of input flag. Otherwise, passing these buffers along is unnecessary and may even cause the decoder to allocate a new input buffer which is wasteful. PiperOrigin-RevId: 411060709 --- .../exoplayer2/transformer/MuxerWrapper.java | 9 +-- .../transformer/SampleTransformer.java | 31 ---------- ...ormer.java => SefSlowMotionFlattener.java} | 29 +++++---- .../transformer/TransformerAudioRenderer.java | 9 ++- .../transformer/TransformerVideoRenderer.java | 31 ++++++---- ...t.java => SefSlowMotionFlattenerTest.java} | 59 ++++++++----------- .../mp4/sample_sef_slow_motion.mp4.dump | 6 ++ 7 files changed, 78 insertions(+), 96 deletions(-) delete mode 100644 library/transformer/src/main/java/com/google/android/exoplayer2/transformer/SampleTransformer.java rename library/transformer/src/main/java/com/google/android/exoplayer2/transformer/{SefSlowMotionVideoSampleTransformer.java => SefSlowMotionFlattener.java} (95%) rename library/transformer/src/test/java/com/google/android/exoplayer2/transformer/{SefSlowMotionVideoSampleTransformerTest.java => SefSlowMotionFlattenerTest.java} (81%) diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/MuxerWrapper.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/MuxerWrapper.java index 76e74efa95..eb56f71904 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/MuxerWrapper.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/MuxerWrapper.java @@ -122,7 +122,7 @@ import java.nio.ByteBuffer; * Attempts to write a sample to the muxer. * * @param trackType The {@link C.TrackType track type} of the sample. - * @param data The sample to write, or {@code null} if the sample is empty. + * @param data The sample to write. * @param isKeyFrame Whether the sample is a key frame. * @param presentationTimeUs The presentation time of the sample in microseconds. * @return Whether the sample was successfully written. This is {@code false} if the muxer hasn't @@ -133,10 +133,7 @@ import java.nio.ByteBuffer; * track of the given track type. */ public boolean writeSample( - @C.TrackType int trackType, - @Nullable ByteBuffer data, - boolean isKeyFrame, - long presentationTimeUs) { + @C.TrackType int trackType, ByteBuffer data, boolean isKeyFrame, long presentationTimeUs) { int trackIndex = trackTypeToIndex.get(trackType, /* valueIfKeyNotFound= */ C.INDEX_UNSET); checkState( trackIndex != C.INDEX_UNSET, @@ -144,8 +141,6 @@ import java.nio.ByteBuffer; if (!canWriteSampleOfType(trackType)) { return false; - } else if (data == null) { - return true; } muxer.writeSampleData(trackIndex, data, isKeyFrame, presentationTimeUs); diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/SampleTransformer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/SampleTransformer.java deleted file mode 100644 index 266034c905..0000000000 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/SampleTransformer.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.transformer; - -import com.google.android.exoplayer2.decoder.DecoderInputBuffer; - -/** A sample transformer for a given track. */ -/* package */ interface SampleTransformer { - - /** - * Transforms the data and metadata of the sample contained in {@code buffer}. - * - * @param buffer The sample to transform. If the sample {@link DecoderInputBuffer#data data} is - * {@code null} after the execution of this method, the sample must be discarded. - */ - void transformSample(DecoderInputBuffer buffer); -} diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/SefSlowMotionVideoSampleTransformer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/SefSlowMotionFlattener.java similarity index 95% rename from library/transformer/src/main/java/com/google/android/exoplayer2/transformer/SefSlowMotionVideoSampleTransformer.java rename to library/transformer/src/main/java/com/google/android/exoplayer2/transformer/SefSlowMotionFlattener.java index 27626ca764..1fcb4cc0a5 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/SefSlowMotionVideoSampleTransformer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/SefSlowMotionFlattener.java @@ -19,7 +19,6 @@ package com.google.android.exoplayer2.transformer; import static com.google.android.exoplayer2.util.Assertions.checkArgument; import static com.google.android.exoplayer2.util.Assertions.checkState; import static com.google.android.exoplayer2.util.NalUnitUtil.NAL_START_CODE; -import static com.google.android.exoplayer2.util.Util.castNonNull; import static java.lang.Math.min; import androidx.annotation.Nullable; @@ -40,7 +39,7 @@ import java.util.List; import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** - * {@link SampleTransformer} that flattens SEF slow motion video samples. + * Sample transformer that flattens SEF slow motion video samples. * *

Such samples follow the ITU-T Recommendation H.264 with temporal SVC. * @@ -50,7 +49,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; *

The mathematical formulas used in this class are explained in [Internal ref: * http://go/exoplayer-sef-slomo-video-flattening]. */ -/* package */ final class SefSlowMotionVideoSampleTransformer implements SampleTransformer { +/* package */ final class SefSlowMotionFlattener { /** * The frame rate of SEF slow motion videos, in fps. @@ -109,7 +108,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; */ private long frameTimeDeltaUs; - public SefSlowMotionVideoSampleTransformer(Format format) { + public SefSlowMotionFlattener(Format format) { scratch = new byte[NAL_START_CODE_LENGTH]; MetadataInfo metadataInfo = getMetadataInfo(format.metadata); slowMotionData = metadataInfo.slowMotionData; @@ -130,14 +129,20 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } } - @Override - public void transformSample(DecoderInputBuffer buffer) { + /** + * Applies slow motion flattening by either indicating that the buffer's data should be dropped or + * transforming it in place. + * + * @return Whether the buffer should be dropped. + */ + @RequiresNonNull("#1.data") + public boolean dropOrTransformSample(DecoderInputBuffer buffer) { if (slowMotionData == null) { // The input is not an SEF slow motion video. - return; + return false; } - ByteBuffer data = castNonNull(buffer.data); + ByteBuffer data = buffer.data; int originalPosition = data.position(); data.position(originalPosition + NAL_START_CODE_LENGTH); data.get(scratch, 0, 4); // Read nal_unit_header_svc_extension. @@ -148,14 +153,14 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; "Missing SVC extension prefix NAL unit."); int layer = (scratch[3] & 0xFF) >> 5; boolean shouldKeepFrame = processCurrentFrame(layer, buffer.timeUs); - // Update buffer timestamp regardless of whether the frame is dropped because the buffer might - // still be passed to a decoder if it contains an end of stream flag. + // Update the timestamp regardless of whether the buffer is dropped as the timestamp may be + // reused for the empty end-of-stream buffer. buffer.timeUs = getCurrentFrameOutputTimeUs(/* inputTimeUs= */ buffer.timeUs); if (shouldKeepFrame) { skipToNextNalUnit(data); // Skip over prefix_nal_unit_svc. - } else { - buffer.data = null; + return false; } + return true; } /** diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java index 5a43bc7d59..6126596070 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.transformer; import static com.google.android.exoplayer2.source.SampleStream.FLAG_REQUIRE_FORMAT; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -127,7 +128,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } if (!muxerWrapper.writeSample( getTrackType(), - samplePipelineOutputBuffer.data, + checkStateNotNull(samplePipelineOutputBuffer.data), /* isKeyFrame= */ true, samplePipelineOutputBuffer.timeUs)) { return false; @@ -152,11 +153,15 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int result = readSource(getFormatHolder(), samplePipelineInputBuffer, /* readFlags= */ 0); switch (result) { case C.RESULT_BUFFER_READ: + if (samplePipelineInputBuffer.isEndOfStream()) { + samplePipeline.queueInputBuffer(); + return false; + } mediaClock.updateTimeForTrackType(getTrackType(), samplePipelineInputBuffer.timeUs); samplePipelineInputBuffer.timeUs -= streamOffsetUs; samplePipelineInputBuffer.flip(); samplePipeline.queueInputBuffer(); - return !samplePipelineInputBuffer.isEndOfStream(); + return true; case C.RESULT_FORMAT_READ: throw new IllegalStateException("Format changes are not supported."); case C.RESULT_NOTHING_READ: diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java index ab942501b0..2d61a35184 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java @@ -18,6 +18,7 @@ package com.google.android.exoplayer2.transformer; import static com.google.android.exoplayer2.source.SampleStream.FLAG_REQUIRE_FORMAT; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; import android.content.Context; import androidx.annotation.Nullable; @@ -28,6 +29,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; +import java.nio.ByteBuffer; import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; @@ -40,7 +42,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final Context context; private final DecoderInputBuffer decoderInputBuffer; - private @MonotonicNonNull SampleTransformer slowMotionSampleTransformer; + private @MonotonicNonNull SefSlowMotionFlattener sefSlowMotionFlattener; private @MonotonicNonNull SamplePipeline samplePipeline; private boolean muxerWrapperTrackAdded; private boolean muxerWrapperTrackEnded; @@ -107,7 +109,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; samplePipeline = new PassthroughSamplePipeline(decoderInputFormat); } if (transformation.flattenForSlowMotion) { - slowMotionSampleTransformer = new SefSlowMotionVideoSampleTransformer(decoderInputFormat); + sefSlowMotionFlattener = new SefSlowMotionFlattener(decoderInputFormat); } return true; } @@ -141,7 +143,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (!muxerWrapper.writeSample( getTrackType(), - samplePipelineOutputBuffer.data, + checkStateNotNull(samplePipelineOutputBuffer.data), samplePipelineOutputBuffer.isKeyFrame(), samplePipelineOutputBuffer.timeUs)) { return false; @@ -172,17 +174,24 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int result = readSource(getFormatHolder(), samplePipelineInputBuffer, /* readFlags= */ 0); switch (result) { case C.RESULT_BUFFER_READ: - if (samplePipelineInputBuffer.data != null - && samplePipelineInputBuffer.data.position() > 0) { - mediaClock.updateTimeForTrackType(getTrackType(), samplePipelineInputBuffer.timeUs); - samplePipelineInputBuffer.timeUs -= streamOffsetUs; - samplePipelineInputBuffer.flip(); - if (slowMotionSampleTransformer != null) { - slowMotionSampleTransformer.transformSample(samplePipelineInputBuffer); + if (samplePipelineInputBuffer.isEndOfStream()) { + samplePipeline.queueInputBuffer(); + return false; + } + mediaClock.updateTimeForTrackType(getTrackType(), samplePipelineInputBuffer.timeUs); + samplePipelineInputBuffer.timeUs -= streamOffsetUs; + samplePipelineInputBuffer.flip(); + if (sefSlowMotionFlattener != null) { + ByteBuffer data = checkStateNotNull(samplePipelineInputBuffer.data); + boolean shouldDropSample = + sefSlowMotionFlattener.dropOrTransformSample(samplePipelineInputBuffer); + if (shouldDropSample) { + data.clear(); + return true; } } samplePipeline.queueInputBuffer(); - return !samplePipelineInputBuffer.isEndOfStream(); + return true; case C.RESULT_FORMAT_READ: throw new IllegalStateException("Format changes are not supported."); case C.RESULT_NOTHING_READ: diff --git a/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/SefSlowMotionVideoSampleTransformerTest.java b/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/SefSlowMotionFlattenerTest.java similarity index 81% rename from library/transformer/src/test/java/com/google/android/exoplayer2/transformer/SefSlowMotionVideoSampleTransformerTest.java rename to library/transformer/src/test/java/com/google/android/exoplayer2/transformer/SefSlowMotionFlattenerTest.java index c29289e4c8..3a5d38bc83 100644 --- a/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/SefSlowMotionVideoSampleTransformerTest.java +++ b/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/SefSlowMotionFlattenerTest.java @@ -16,7 +16,7 @@ package com.google.android.exoplayer2.transformer; -import static com.google.android.exoplayer2.transformer.SefSlowMotionVideoSampleTransformer.INPUT_FRAME_RATE; +import static com.google.android.exoplayer2.transformer.SefSlowMotionFlattener.INPUT_FRAME_RATE; import static com.google.common.truth.Truth.assertThat; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -32,9 +32,9 @@ import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; -/** Unit tests for {@link SefSlowMotionVideoSampleTransformer}. */ +/** Unit tests for {@link SefSlowMotionFlattener}. */ @RunWith(AndroidJUnit4.class) -public class SefSlowMotionVideoSampleTransformerTest { +public class SefSlowMotionFlattenerTest { /** * Sequence of temporal SVC layers in an SEF slow motion video track with a maximum layer of 3. @@ -56,10 +56,9 @@ public class SefSlowMotionVideoSampleTransformerTest { createSefSlowMotionFormat( captureFrameRate, inputMaxLayer, Arrays.asList(segment1, segment2)); - SefSlowMotionVideoSampleTransformer sampleTransformer = - new SefSlowMotionVideoSampleTransformer(format); + SefSlowMotionFlattener sefSlowMotionFlattener = new SefSlowMotionFlattener(format); List outputLayers = - getKeptOutputLayers(sampleTransformer, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); + getKeptOutputLayers(sefSlowMotionFlattener, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); List expectedLayers = Arrays.asList(0, 0, 1, 0, 0, 1, 2, 3, 0, 3, 2, 3, 1, 3, 0); assertThat(outputLayers).isEqualTo(expectedLayers); @@ -78,10 +77,9 @@ public class SefSlowMotionVideoSampleTransformerTest { createSefSlowMotionFormat( captureFrameRate, inputMaxLayer, Arrays.asList(segment1, segment2)); - SefSlowMotionVideoSampleTransformer sampleTransformer = - new SefSlowMotionVideoSampleTransformer(format); + SefSlowMotionFlattener sefSlowMotionFlattener = new SefSlowMotionFlattener(format); List outputLayers = - getKeptOutputLayers(sampleTransformer, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); + getKeptOutputLayers(sefSlowMotionFlattener, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); List expectedLayers = Arrays.asList(0, 1, 0, 3, 2, 3, 1, 3, 2, 3, 0, 1, 0, 1, 2, 3, 0, 3, 2, 3, 1, 3, 0, 1); @@ -101,10 +99,9 @@ public class SefSlowMotionVideoSampleTransformerTest { createSefSlowMotionFormat( captureFrameRate, inputMaxLayer, Arrays.asList(segment1, segment2)); - SefSlowMotionVideoSampleTransformer sampleTransformer = - new SefSlowMotionVideoSampleTransformer(format); + SefSlowMotionFlattener sefSlowMotionFlattener = new SefSlowMotionFlattener(format); List outputLayers = - getKeptOutputLayers(sampleTransformer, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); + getKeptOutputLayers(sefSlowMotionFlattener, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); List expectedLayers = Arrays.asList(0, 0, 1, 0, 2, 3, 1, 3, 0); assertThat(outputLayers).isEqualTo(expectedLayers); @@ -129,10 +126,9 @@ public class SefSlowMotionVideoSampleTransformerTest { inputMaxLayer, Arrays.asList(segmentWithNoFrame1, segmentWithNoFrame2, segmentWithFrame)); - SefSlowMotionVideoSampleTransformer sampleTransformer = - new SefSlowMotionVideoSampleTransformer(format); + SefSlowMotionFlattener sefSlowMotionFlattener = new SefSlowMotionFlattener(format); List outputLayers = - getKeptOutputLayers(sampleTransformer, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); + getKeptOutputLayers(sefSlowMotionFlattener, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); List expectedLayers = Arrays.asList(0, 0, 1); assertThat(outputLayers).isEqualTo(expectedLayers); @@ -153,10 +149,9 @@ public class SefSlowMotionVideoSampleTransformerTest { createSefSlowMotionFormat( captureFrameRate, inputMaxLayer, Arrays.asList(segment1, segment2)); - SefSlowMotionVideoSampleTransformer sampleTransformer = - new SefSlowMotionVideoSampleTransformer(format); + SefSlowMotionFlattener sefSlowMotionFlattener = new SefSlowMotionFlattener(format); List outputTimesUs = - getOutputTimesUs(sampleTransformer, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); + getOutputTimesUs(sefSlowMotionFlattener, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); // Test frame inside segment. assertThat(outputTimesUs.get(9)) @@ -181,10 +176,9 @@ public class SefSlowMotionVideoSampleTransformerTest { createSefSlowMotionFormat( captureFrameRate, inputMaxLayer, Arrays.asList(segment1, segment2)); - SefSlowMotionVideoSampleTransformer sampleTransformer = - new SefSlowMotionVideoSampleTransformer(format); + SefSlowMotionFlattener sefSlowMotionFlattener = new SefSlowMotionFlattener(format); List outputTimesUs = - getOutputTimesUs(sampleTransformer, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); + getOutputTimesUs(sefSlowMotionFlattener, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); // Test frame inside segment. assertThat(outputTimesUs.get(9)) @@ -209,10 +203,9 @@ public class SefSlowMotionVideoSampleTransformerTest { createSefSlowMotionFormat( captureFrameRate, inputMaxLayer, Arrays.asList(segment1, segment2)); - SefSlowMotionVideoSampleTransformer sampleTransformer = - new SefSlowMotionVideoSampleTransformer(format); + SefSlowMotionFlattener sefSlowMotionFlattener = new SefSlowMotionFlattener(format); List outputTimesUs = - getOutputTimesUs(sampleTransformer, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); + getOutputTimesUs(sefSlowMotionFlattener, LAYER_SEQUENCE_MAX_LAYER_THREE, frameCount); // Test frame inside second segment. assertThat(outputTimesUs.get(9)).isEqualTo(136_250); @@ -249,20 +242,20 @@ public class SefSlowMotionVideoSampleTransformerTest { /** * Returns a list containing the temporal SVC layers of the frames that should be kept according - * to {@link SefSlowMotionVideoSampleTransformer#processCurrentFrame(int, long)}. + * to {@link SefSlowMotionFlattener#processCurrentFrame(int, long)}. * - * @param sampleTransformer The {@link SefSlowMotionVideoSampleTransformer}. + * @param sefSlowMotionFlattener The {@link SefSlowMotionFlattener}. * @param layerSequence The sequence of layer values in the input. * @param frameCount The number of video frames in the input. * @return The output layers. */ private static List getKeptOutputLayers( - SefSlowMotionVideoSampleTransformer sampleTransformer, int[] layerSequence, int frameCount) { + SefSlowMotionFlattener sefSlowMotionFlattener, int[] layerSequence, int frameCount) { List outputLayers = new ArrayList<>(); for (int i = 0; i < frameCount; i++) { int layer = layerSequence[i % layerSequence.length]; long timeUs = i * C.MICROS_PER_SECOND / INPUT_FRAME_RATE; - if (sampleTransformer.processCurrentFrame(layer, timeUs)) { + if (sefSlowMotionFlattener.processCurrentFrame(layer, timeUs)) { outputLayers.add(layer); } } @@ -271,24 +264,24 @@ public class SefSlowMotionVideoSampleTransformerTest { /** * Returns a list containing the frame output times obtained using {@link - * SefSlowMotionVideoSampleTransformer#getCurrentFrameOutputTimeUs(long)}. + * SefSlowMotionFlattener#getCurrentFrameOutputTimeUs(long)}. * *

The output contains the output times for all the input frames, regardless of whether they * should be kept or not. * - * @param sampleTransformer The {@link SefSlowMotionVideoSampleTransformer}. + * @param sefSlowMotionFlattener The {@link SefSlowMotionFlattener}. * @param layerSequence The sequence of layer values in the input. * @param frameCount The number of video frames in the input. * @return The frame output times, in microseconds. */ private static List getOutputTimesUs( - SefSlowMotionVideoSampleTransformer sampleTransformer, int[] layerSequence, int frameCount) { + SefSlowMotionFlattener sefSlowMotionFlattener, int[] layerSequence, int frameCount) { List outputTimesUs = new ArrayList<>(); for (int i = 0; i < frameCount; i++) { int layer = layerSequence[i % layerSequence.length]; long inputTimeUs = i * C.MICROS_PER_SECOND / INPUT_FRAME_RATE; - sampleTransformer.processCurrentFrame(layer, inputTimeUs); - outputTimesUs.add(sampleTransformer.getCurrentFrameOutputTimeUs(inputTimeUs)); + sefSlowMotionFlattener.processCurrentFrame(layer, inputTimeUs); + outputTimesUs.add(sefSlowMotionFlattener.getCurrentFrameOutputTimeUs(inputTimeUs)); } return outputTimesUs; } diff --git a/testdata/src/test/assets/transformerdumps/mp4/sample_sef_slow_motion.mp4.dump b/testdata/src/test/assets/transformerdumps/mp4/sample_sef_slow_motion.mp4.dump index 816e26e384..d5db3513de 100644 --- a/testdata/src/test/assets/transformerdumps/mp4/sample_sef_slow_motion.mp4.dump +++ b/testdata/src/test/assets/transformerdumps/mp4/sample_sef_slow_motion.mp4.dump @@ -298,6 +298,12 @@ sample: size = 1193 isKeyFrame = false presentationTimeUs = 734083 +sample: + trackIndex = 0 + dataHashCode = 820561200 + size = 1252 + isKeyFrame = true + presentationTimeUs = 201521 sample: trackIndex = 1 dataHashCode = -1554795381 From 2ae9f54c23a4b2a15814c68818c7eedaee23861e Mon Sep 17 00:00:00 2001 From: hschlueter Date: Fri, 19 Nov 2021 16:55:33 +0000 Subject: [PATCH 04/56] Merge Transformer and TranscodingTransformer. The features supported by `TranscodingTransformer` are a superset of those supported by `Transformer` after merging the video renderers in . This change removes `TranscodingTransformer` and adds its features to `Transformer`. PiperOrigin-RevId: 411072392 --- .../transformer/AndroidTestUtil.java | 51 -- .../RepeatedTranscodeTransformationTest.java | 7 +- .../transformer/TranscodingTransformer.java | 786 ------------------ .../exoplayer2/transformer/Transformer.java | 118 ++- 4 files changed, 117 insertions(+), 845 deletions(-) delete mode 100644 library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TranscodingTransformer.java diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java index fb45925a95..3a6b148b88 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java @@ -92,57 +92,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } } - // TODO(internal b/202131097): Deduplicate with the other overload when TranscodingTransformer is - // merged into Transformer. - /** Transforms the {@code uriString} with the {@link TranscodingTransformer}. */ - public static TransformationResult runTransformer( - Context context, TranscodingTransformer transformer, String uriString) throws Exception { - AtomicReference<@NullableType Exception> exceptionReference = new AtomicReference<>(); - CountDownLatch countDownLatch = new CountDownLatch(1); - - TranscodingTransformer testTransformer = - transformer - .buildUpon() - .setListener( - new TranscodingTransformer.Listener() { - @Override - public void onTransformationCompleted(MediaItem inputMediaItem) { - countDownLatch.countDown(); - } - - @Override - public void onTransformationError(MediaItem inputMediaItem, Exception exception) { - exceptionReference.set(exception); - countDownLatch.countDown(); - } - }) - .build(); - - Uri uri = Uri.parse(uriString); - File externalCacheFile = createExternalCacheFile(uri, context); - try { - InstrumentationRegistry.getInstrumentation() - .runOnMainSync( - () -> { - try { - testTransformer.startTransformation( - MediaItem.fromUri(uri), externalCacheFile.getAbsolutePath()); - } catch (IOException e) { - exceptionReference.set(e); - } - }); - countDownLatch.await(); - @Nullable Exception exception = exceptionReference.get(); - if (exception != null) { - throw exception; - } - long outputSizeBytes = externalCacheFile.length(); - return new TransformationResult(outputSizeBytes); - } finally { - externalCacheFile.delete(); - } - } - private static File createExternalCacheFile(Uri uri, Context context) throws IOException { File file = new File(context.getExternalCacheDir(), "transformer-" + uri.hashCode()); Assertions.checkState( diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java index d54890e809..1cee83a14e 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java @@ -37,8 +37,8 @@ public final class RepeatedTranscodeTransformationTest { @Test public void repeatedTranscode_givesConsistentLengthOutput() throws Exception { Context context = ApplicationProvider.getApplicationContext(); - TranscodingTransformer transcodingTransformer = - new TranscodingTransformer.Builder() + Transformer transformer = + new Transformer.Builder() .setVideoMimeType(MimeTypes.VIDEO_H265) .setContext(context) .build(); @@ -47,8 +47,7 @@ public final class RepeatedTranscodeTransformationTest { for (int i = 0; i < TRANSCODE_COUNT; i++) { // Use a long video in case an error occurs a while after the start of the video. long outputSizeBytes = - runTransformer( - context, transcodingTransformer, AndroidTestUtil.REMOTE_MP4_10_SECONDS_URI_STRING) + runTransformer(context, transformer, AndroidTestUtil.REMOTE_MP4_10_SECONDS_URI_STRING) .outputSizeBytes; if (previousOutputSizeBytes != C.LENGTH_UNSET) { assertWithMessage("Unexpected output size on transcode " + i + " out of " + TRANSCODE_COUNT) diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TranscodingTransformer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TranscodingTransformer.java deleted file mode 100644 index ccff860f1d..0000000000 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TranscodingTransformer.java +++ /dev/null @@ -1,786 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.exoplayer2.transformer; - -import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; -import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS; -import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_MAX_BUFFER_MS; -import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_MIN_BUFFER_MS; -import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.util.Assertions.checkState; -import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; -import static java.lang.Math.min; - -import android.content.Context; -import android.media.MediaFormat; -import android.media.MediaMuxer; -import android.os.Handler; -import android.os.Looper; -import android.os.ParcelFileDescriptor; -import androidx.annotation.IntDef; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.annotation.VisibleForTesting; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.DefaultLoadControl; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.Renderer; -import com.google.android.exoplayer2.RenderersFactory; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.TracksInfo; -import com.google.android.exoplayer2.audio.AudioRendererEventListener; -import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; -import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor; -import com.google.android.exoplayer2.metadata.MetadataOutput; -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.MediaSourceFactory; -import com.google.android.exoplayer2.text.TextOutput; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.util.Clock; -import com.google.android.exoplayer2.util.MimeTypes; -import com.google.android.exoplayer2.util.Util; -import com.google.android.exoplayer2.video.VideoRendererEventListener; -import java.io.IOException; -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; - -/** - * A transcoding transformer to transform media inputs. - * - *

Temporary copy of the {@link Transformer} class, which transforms by transcoding rather than - * by muxing. This class is intended to replace the Transformer class. - * - *

The same TranscodingTransformer instance can be used to transform multiple inputs - * (sequentially, not concurrently). - * - *

TranscodingTransformer instances must be accessed from a single application thread. For the - * vast majority of cases this should be the application's main thread. The thread on which a - * TranscodingTransformer instance must be accessed can be explicitly specified by passing a {@link - * Looper} when creating the transcoding transformer. If no Looper is specified, then the Looper of - * the thread that the {@link TranscodingTransformer.Builder} is created on is used, or if that - * thread does not have a Looper, the Looper of the application's main thread is used. In all cases - * the Looper of the thread from which the transcoding transformer must be accessed can be queried - * using {@link #getApplicationLooper()}. - */ -@RequiresApi(18) -public final class TranscodingTransformer { - // TODO(http://b/202131097): Replace the Transformer class with TranscodingTransformer, and - // rename this class to Transformer. - - /** A builder for {@link TranscodingTransformer} instances. */ - public static final class Builder { - - // Mandatory field. - private @MonotonicNonNull Context context; - - // Optional fields. - private @MonotonicNonNull MediaSourceFactory mediaSourceFactory; - private Muxer.Factory muxerFactory; - private boolean removeAudio; - private boolean removeVideo; - private boolean flattenForSlowMotion; - private int outputHeight; - private String containerMimeType; - @Nullable private String audioMimeType; - @Nullable private String videoMimeType; - private TranscodingTransformer.Listener listener; - private Looper looper; - private Clock clock; - - /** Creates a builder with default values. */ - public Builder() { - muxerFactory = new FrameworkMuxer.Factory(); - outputHeight = Transformation.NO_VALUE; - containerMimeType = MimeTypes.VIDEO_MP4; - listener = new Listener() {}; - looper = Util.getCurrentOrMainLooper(); - clock = Clock.DEFAULT; - } - - /** Creates a builder with the values of the provided {@link TranscodingTransformer}. */ - private Builder(TranscodingTransformer transcodingTransformer) { - this.context = transcodingTransformer.context; - this.mediaSourceFactory = transcodingTransformer.mediaSourceFactory; - this.muxerFactory = transcodingTransformer.muxerFactory; - this.removeAudio = transcodingTransformer.transformation.removeAudio; - this.removeVideo = transcodingTransformer.transformation.removeVideo; - this.flattenForSlowMotion = transcodingTransformer.transformation.flattenForSlowMotion; - this.outputHeight = transcodingTransformer.transformation.outputHeight; - this.containerMimeType = transcodingTransformer.transformation.containerMimeType; - this.audioMimeType = transcodingTransformer.transformation.audioMimeType; - this.videoMimeType = transcodingTransformer.transformation.videoMimeType; - this.listener = transcodingTransformer.listener; - this.looper = transcodingTransformer.looper; - this.clock = transcodingTransformer.clock; - } - - /** - * Sets the {@link Context}. - * - *

This parameter is mandatory. - * - * @param context The {@link Context}. - * @return This builder. - */ - public Builder setContext(Context context) { - this.context = context.getApplicationContext(); - return this; - } - - /** - * Sets the {@link MediaSourceFactory} to be used to retrieve the inputs to transform. The - * default value is a {@link DefaultMediaSourceFactory} built with the context provided in - * {@link #setContext(Context)}. - * - * @param mediaSourceFactory A {@link MediaSourceFactory}. - * @return This builder. - */ - public Builder setMediaSourceFactory(MediaSourceFactory mediaSourceFactory) { - this.mediaSourceFactory = mediaSourceFactory; - return this; - } - - /** - * Sets whether to remove the audio from the output. The default value is {@code false}. - * - *

The audio and video cannot both be removed because the output would not contain any - * samples. - * - * @param removeAudio Whether to remove the audio. - * @return This builder. - */ - public Builder setRemoveAudio(boolean removeAudio) { - this.removeAudio = removeAudio; - return this; - } - - /** - * Sets whether to remove the video from the output. The default value is {@code false}. - * - *

The audio and video cannot both be removed because the output would not contain any - * samples. - * - * @param removeVideo Whether to remove the video. - * @return This builder. - */ - public Builder setRemoveVideo(boolean removeVideo) { - this.removeVideo = removeVideo; - return this; - } - - /** - * Sets whether the input should be flattened for media containing slow motion markers. The - * transformed output is obtained by removing the slow motion metadata and by actually slowing - * down the parts of the video and audio streams defined in this metadata. The default value for - * {@code flattenForSlowMotion} is {@code false}. - * - *

Only Samsung Extension Format (SEF) slow motion metadata type is supported. The - * transformation has no effect if the input does not contain this metadata type. - * - *

For SEF slow motion media, the following assumptions are made on the input: - * - *

    - *
  • The input container format is (unfragmented) MP4. - *
  • The input contains an AVC video elementary stream with temporal SVC. - *
  • The recording frame rate of the video is 120 or 240 fps. - *
- * - *

If specifying a {@link MediaSourceFactory} using {@link - * #setMediaSourceFactory(MediaSourceFactory)}, make sure that {@link - * Mp4Extractor#FLAG_READ_SEF_DATA} is set on the {@link Mp4Extractor} used. Otherwise, the slow - * motion metadata will be ignored and the input won't be flattened. - * - * @param flattenForSlowMotion Whether to flatten for slow motion. - * @return This builder. - */ - public Builder setFlattenForSlowMotion(boolean flattenForSlowMotion) { - this.flattenForSlowMotion = flattenForSlowMotion; - return this; - } - - /** - * Sets the output resolution using the output height. The default value is {@link - * Transformation#NO_VALUE}, which will use the same height as the input. Output width will - * scale to preserve the input video's aspect ratio. - * - *

For now, only "popular" heights like 240, 360, 480, 720, 1080, 1440, or 2160 are - * supported, to ensure compatibility on different devices. - * - *

For example, a 1920x1440 video can be scaled to 640x480 by calling setResolution(480). - * - * @param outputHeight The output height in pixels. - * @return This builder. - */ - public Builder setResolution(int outputHeight) { - // TODO(Internal b/201293185): Restructure to input a Presentation class. - // TODO(Internal b/201293185): Check encoder codec capabilities in order to allow arbitrary - // resolutions and reasonable fallbacks. - if (outputHeight != 240 - && outputHeight != 360 - && outputHeight != 480 - && outputHeight != 720 - && outputHeight != 1080 - && outputHeight != 1440 - && outputHeight != 2160) { - throw new IllegalArgumentException( - "Please use a height of 240, 360, 480, 720, 1080, 1440, or 2160."); - } - this.outputHeight = outputHeight; - return this; - } - - /** - * @deprecated This feature will be removed in a following release and the MIME type of the - * output will always be MP4. - */ - @Deprecated - public Builder setOutputMimeType(String outputMimeType) { - this.containerMimeType = outputMimeType; - return this; - } - - /** - * Sets the video MIME type of the output. The default value is to use the same MIME type as the - * input. Supported values are: - * - *

    - *
  • when the container MIME type is {@link MimeTypes#VIDEO_MP4}: - *
      - *
    • {@link MimeTypes#VIDEO_H263} - *
    • {@link MimeTypes#VIDEO_H264} - *
    • {@link MimeTypes#VIDEO_H265} from API level 24 - *
    • {@link MimeTypes#VIDEO_MP4V} - *
    - *
  • when the container MIME type is {@link MimeTypes#VIDEO_WEBM}: - *
      - *
    • {@link MimeTypes#VIDEO_VP8} - *
    • {@link MimeTypes#VIDEO_VP9} from API level 24 - *
    - *
- * - * @param videoMimeType The MIME type of the video samples in the output. - * @return This builder. - */ - public Builder setVideoMimeType(String videoMimeType) { - this.videoMimeType = videoMimeType; - return this; - } - - /** - * Sets the audio MIME type of the output. The default value is to use the same MIME type as the - * input. Supported values are: - * - *
    - *
  • when the container MIME type is {@link MimeTypes#VIDEO_MP4}: - *
      - *
    • {@link MimeTypes#AUDIO_AAC} - *
    • {@link MimeTypes#AUDIO_AMR_NB} - *
    • {@link MimeTypes#AUDIO_AMR_WB} - *
    - *
  • when the container MIME type is {@link MimeTypes#VIDEO_WEBM}: - *
      - *
    • {@link MimeTypes#AUDIO_VORBIS} - *
    - *
- * - * @param audioMimeType The MIME type of the audio samples in the output. - * @return This builder. - */ - public Builder setAudioMimeType(String audioMimeType) { - this.audioMimeType = audioMimeType; - return this; - } - - /** - * Sets the {@link TranscodingTransformer.Listener} to listen to the transformation events. - * - *

This is equivalent to {@link TranscodingTransformer#setListener(Listener)}. - * - * @param listener A {@link TranscodingTransformer.Listener}. - * @return This builder. - */ - public Builder setListener(TranscodingTransformer.Listener listener) { - this.listener = listener; - return this; - } - - /** - * Sets the {@link Looper} that must be used for all calls to the transcoding transformer and - * that is used to call listeners on. The default value is the Looper of the thread that this - * builder was created on, or if that thread does not have a Looper, the Looper of the - * application's main thread. - * - * @param looper A {@link Looper}. - * @return This builder. - */ - public Builder setLooper(Looper looper) { - this.looper = looper; - return this; - } - - /** - * Sets the {@link Clock} that will be used by the transcoding transformer. The default value is - * {@link Clock#DEFAULT}. - * - * @param clock The {@link Clock} instance. - * @return This builder. - */ - @VisibleForTesting - /* package */ Builder setClock(Clock clock) { - this.clock = clock; - return this; - } - - /** - * Sets the factory for muxers that write the media container. The default value is a {@link - * FrameworkMuxer.Factory}. - * - * @param muxerFactory A {@link Muxer.Factory}. - * @return This builder. - */ - @VisibleForTesting - /* package */ Builder setMuxerFactory(Muxer.Factory muxerFactory) { - this.muxerFactory = muxerFactory; - return this; - } - - /** - * Builds a {@link TranscodingTransformer} instance. - * - * @throws IllegalStateException If the {@link Context} has not been provided. - * @throws IllegalStateException If both audio and video have been removed (otherwise the output - * would not contain any samples). - * @throws IllegalStateException If the muxer doesn't support the requested container MIME type. - * @throws IllegalStateException If the muxer doesn't support the requested audio MIME type. - */ - public TranscodingTransformer build() { - checkStateNotNull(context); - if (mediaSourceFactory == null) { - DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); - if (flattenForSlowMotion) { - defaultExtractorsFactory.setMp4ExtractorFlags(Mp4Extractor.FLAG_READ_SEF_DATA); - } - mediaSourceFactory = new DefaultMediaSourceFactory(context, defaultExtractorsFactory); - } - checkState( - muxerFactory.supportsOutputMimeType(containerMimeType), - "Unsupported container MIME type: " + containerMimeType); - if (audioMimeType != null) { - checkSampleMimeType(audioMimeType); - } - if (videoMimeType != null) { - checkSampleMimeType(videoMimeType); - } - Transformation transformation = - new Transformation( - removeAudio, - removeVideo, - flattenForSlowMotion, - outputHeight, - containerMimeType, - audioMimeType, - videoMimeType); - return new TranscodingTransformer( - context, mediaSourceFactory, muxerFactory, transformation, listener, looper, clock); - } - - private void checkSampleMimeType(String sampleMimeType) { - checkState( - muxerFactory.supportsSampleMimeType(sampleMimeType, containerMimeType), - "Unsupported sample MIME type " - + sampleMimeType - + " for container MIME type " - + containerMimeType); - } - } - - /** A listener for the transformation events. */ - public interface Listener { - - /** - * Called when the transformation is completed. - * - * @param inputMediaItem The {@link MediaItem} for which the transformation is completed. - */ - default void onTransformationCompleted(MediaItem inputMediaItem) {} - - /** - * Called if an error occurs during the transformation. - * - * @param inputMediaItem The {@link MediaItem} for which the error occurs. - * @param exception The exception describing the error. - */ - default void onTransformationError(MediaItem inputMediaItem, Exception exception) {} - } - - /** - * Progress state. One of {@link #PROGRESS_STATE_WAITING_FOR_AVAILABILITY}, {@link - * #PROGRESS_STATE_AVAILABLE}, {@link #PROGRESS_STATE_UNAVAILABLE}, {@link - * #PROGRESS_STATE_NO_TRANSFORMATION} - */ - @Documented - @Retention(RetentionPolicy.SOURCE) - @IntDef({ - PROGRESS_STATE_WAITING_FOR_AVAILABILITY, - PROGRESS_STATE_AVAILABLE, - PROGRESS_STATE_UNAVAILABLE, - PROGRESS_STATE_NO_TRANSFORMATION - }) - public @interface ProgressState {} - - /** - * Indicates that the progress is unavailable for the current transformation, but might become - * available. - */ - public static final int PROGRESS_STATE_WAITING_FOR_AVAILABILITY = 0; - /** Indicates that the progress is available. */ - public static final int PROGRESS_STATE_AVAILABLE = 1; - /** Indicates that the progress is permanently unavailable for the current transformation. */ - public static final int PROGRESS_STATE_UNAVAILABLE = 2; - /** Indicates that there is no current transformation. */ - public static final int PROGRESS_STATE_NO_TRANSFORMATION = 4; - - private final Context context; - private final MediaSourceFactory mediaSourceFactory; - private final Muxer.Factory muxerFactory; - private final Transformation transformation; - private final Looper looper; - private final Clock clock; - - private TranscodingTransformer.Listener listener; - @Nullable private MuxerWrapper muxerWrapper; - @Nullable private ExoPlayer player; - @ProgressState private int progressState; - - private TranscodingTransformer( - Context context, - MediaSourceFactory mediaSourceFactory, - Muxer.Factory muxerFactory, - Transformation transformation, - TranscodingTransformer.Listener listener, - Looper looper, - Clock clock) { - checkState( - !transformation.removeAudio || !transformation.removeVideo, - "Audio and video cannot both be removed."); - this.context = context; - this.mediaSourceFactory = mediaSourceFactory; - this.muxerFactory = muxerFactory; - this.transformation = transformation; - this.listener = listener; - this.looper = looper; - this.clock = clock; - progressState = PROGRESS_STATE_NO_TRANSFORMATION; - } - - /** - * Returns a {@link TranscodingTransformer.Builder} initialized with the values of this instance. - */ - public Builder buildUpon() { - return new Builder(this); - } - - /** - * Sets the {@link TranscodingTransformer.Listener} to listen to the transformation events. - * - * @param listener A {@link TranscodingTransformer.Listener}. - * @throws IllegalStateException If this method is called from the wrong thread. - */ - public void setListener(TranscodingTransformer.Listener listener) { - verifyApplicationThread(); - this.listener = listener; - } - - /** - * Starts an asynchronous operation to transform the given {@link MediaItem}. - * - *

The transformation state is notified through the {@link Builder#setListener(Listener) - * listener}. - * - *

Concurrent transformations on the same TranscodingTransformer object are not allowed. - * - *

The output is an MP4 file. It can contain at most one video track and one audio track. Other - * track types are ignored. For adaptive bitrate {@link MediaSource media sources}, the highest - * bitrate video and audio streams are selected. - * - * @param mediaItem The {@link MediaItem} to transform. The supported sample formats depend on the - * {@link Muxer} and on the output container format. For the {@link FrameworkMuxer}, they are - * described in {@link MediaMuxer#addTrack(MediaFormat)}. - * @param path The path to the output file. - * @throws IllegalArgumentException If the path is invalid. - * @throws IllegalStateException If this method is called from the wrong thread. - * @throws IllegalStateException If a transformation is already in progress. - * @throws IOException If an error occurs opening the output file for writing. - */ - public void startTransformation(MediaItem mediaItem, String path) throws IOException { - startTransformation(mediaItem, muxerFactory.create(path, transformation.containerMimeType)); - } - - /** - * Starts an asynchronous operation to transform the given {@link MediaItem}. - * - *

The transformation state is notified through the {@link Builder#setListener(Listener) - * listener}. - * - *

Concurrent transformations on the same TranscodingTransformer object are not allowed. - * - *

The output is an MP4 file. It can contain at most one video track and one audio track. Other - * track types are ignored. For adaptive bitrate {@link MediaSource media sources}, the highest - * bitrate video and audio streams are selected. - * - * @param mediaItem The {@link MediaItem} to transform. The supported sample formats depend on the - * {@link Muxer} and on the output container format. For the {@link FrameworkMuxer}, they are - * described in {@link MediaMuxer#addTrack(MediaFormat)}. - * @param parcelFileDescriptor A readable and writable {@link ParcelFileDescriptor} of the output. - * The file referenced by this ParcelFileDescriptor should not be used before the - * transformation is completed. It is the responsibility of the caller to close the - * ParcelFileDescriptor. This can be done after this method returns. - * @throws IllegalArgumentException If the file descriptor is invalid. - * @throws IllegalStateException If this method is called from the wrong thread. - * @throws IllegalStateException If a transformation is already in progress. - * @throws IOException If an error occurs opening the output file for writing. - */ - @RequiresApi(26) - public void startTransformation(MediaItem mediaItem, ParcelFileDescriptor parcelFileDescriptor) - throws IOException { - startTransformation( - mediaItem, muxerFactory.create(parcelFileDescriptor, transformation.containerMimeType)); - } - - private void startTransformation(MediaItem mediaItem, Muxer muxer) { - verifyApplicationThread(); - if (player != null) { - throw new IllegalStateException("There is already a transformation in progress."); - } - - MuxerWrapper muxerWrapper = - new MuxerWrapper(muxer, muxerFactory, transformation.containerMimeType); - this.muxerWrapper = muxerWrapper; - DefaultTrackSelector trackSelector = new DefaultTrackSelector(context); - trackSelector.setParameters( - new DefaultTrackSelector.ParametersBuilder(context) - .setForceHighestSupportedBitrate(true) - .build()); - // Arbitrarily decrease buffers for playback so that samples start being sent earlier to the - // muxer (rebuffers are less problematic for the transformation use case). - DefaultLoadControl loadControl = - new DefaultLoadControl.Builder() - .setBufferDurationsMs( - DEFAULT_MIN_BUFFER_MS, - DEFAULT_MAX_BUFFER_MS, - DEFAULT_BUFFER_FOR_PLAYBACK_MS / 10, - DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS / 10) - .build(); - player = - new ExoPlayer.Builder( - context, - new TranscodingTransformerRenderersFactory(context, muxerWrapper, transformation)) - .setMediaSourceFactory(mediaSourceFactory) - .setTrackSelector(trackSelector) - .setLoadControl(loadControl) - .setLooper(looper) - .setClock(clock) - .build(); - player.setMediaItem(mediaItem); - player.addListener(new TranscodingTransformerPlayerListener(mediaItem, muxerWrapper)); - player.prepare(); - - progressState = PROGRESS_STATE_WAITING_FOR_AVAILABILITY; - } - - /** - * Returns the {@link Looper} associated with the application thread that's used to access the - * transcoding transformer and on which transcoding transformer events are received. - */ - public Looper getApplicationLooper() { - return looper; - } - - /** - * Returns the current {@link ProgressState} and updates {@code progressHolder} with the current - * progress if it is {@link #PROGRESS_STATE_AVAILABLE available}. - * - *

After a transformation {@link Listener#onTransformationCompleted(MediaItem) completes}, this - * method returns {@link #PROGRESS_STATE_NO_TRANSFORMATION}. - * - * @param progressHolder A {@link ProgressHolder}, updated to hold the percentage progress if - * {@link #PROGRESS_STATE_AVAILABLE available}. - * @return The {@link ProgressState}. - * @throws IllegalStateException If this method is called from the wrong thread. - */ - @ProgressState - public int getProgress(ProgressHolder progressHolder) { - verifyApplicationThread(); - if (progressState == PROGRESS_STATE_AVAILABLE) { - Player player = checkNotNull(this.player); - long durationMs = player.getDuration(); - long positionMs = player.getCurrentPosition(); - progressHolder.progress = min((int) (positionMs * 100 / durationMs), 99); - } - return progressState; - } - - /** - * Cancels the transformation that is currently in progress, if any. - * - * @throws IllegalStateException If this method is called from the wrong thread. - */ - public void cancel() { - releaseResources(/* forCancellation= */ true); - } - - /** - * Releases the resources. - * - * @param forCancellation Whether the reason for releasing the resources is the transformation - * cancellation. - * @throws IllegalStateException If this method is called from the wrong thread. - * @throws IllegalStateException If the muxer is in the wrong state and {@code forCancellation} is - * false. - */ - private void releaseResources(boolean forCancellation) { - verifyApplicationThread(); - if (player != null) { - player.release(); - player = null; - } - if (muxerWrapper != null) { - muxerWrapper.release(forCancellation); - muxerWrapper = null; - } - progressState = PROGRESS_STATE_NO_TRANSFORMATION; - } - - private void verifyApplicationThread() { - if (Looper.myLooper() != looper) { - throw new IllegalStateException("Transcoding Transformer is accessed on the wrong thread."); - } - } - - private static final class TranscodingTransformerRenderersFactory implements RenderersFactory { - - private final Context context; - private final MuxerWrapper muxerWrapper; - private final TransformerMediaClock mediaClock; - private final Transformation transformation; - - public TranscodingTransformerRenderersFactory( - Context context, MuxerWrapper muxerWrapper, Transformation transformation) { - this.context = context; - this.muxerWrapper = muxerWrapper; - this.transformation = transformation; - mediaClock = new TransformerMediaClock(); - } - - @Override - public Renderer[] createRenderers( - Handler eventHandler, - VideoRendererEventListener videoRendererEventListener, - AudioRendererEventListener audioRendererEventListener, - TextOutput textRendererOutput, - MetadataOutput metadataRendererOutput) { - int rendererCount = transformation.removeAudio || transformation.removeVideo ? 1 : 2; - Renderer[] renderers = new Renderer[rendererCount]; - int index = 0; - if (!transformation.removeAudio) { - renderers[index] = new TransformerAudioRenderer(muxerWrapper, mediaClock, transformation); - index++; - } - if (!transformation.removeVideo) { - renderers[index] = - new TransformerVideoRenderer(context, muxerWrapper, mediaClock, transformation); - index++; - } - return renderers; - } - } - - private final class TranscodingTransformerPlayerListener implements Player.Listener { - - private final MediaItem mediaItem; - private final MuxerWrapper muxerWrapper; - - public TranscodingTransformerPlayerListener(MediaItem mediaItem, MuxerWrapper muxerWrapper) { - this.mediaItem = mediaItem; - this.muxerWrapper = muxerWrapper; - } - - @Override - public void onPlaybackStateChanged(int state) { - if (state == Player.STATE_ENDED) { - handleTransformationEnded(/* exception= */ null); - } - } - - @Override - public void onTimelineChanged(Timeline timeline, int reason) { - if (progressState != PROGRESS_STATE_WAITING_FOR_AVAILABILITY) { - return; - } - Timeline.Window window = new Timeline.Window(); - timeline.getWindow(/* windowIndex= */ 0, window); - if (!window.isPlaceholder) { - long durationUs = window.durationUs; - // Make progress permanently unavailable if the duration is unknown, so that it doesn't jump - // to a high value at the end of the transformation if the duration is set once the media is - // entirely loaded. - progressState = - durationUs <= 0 || durationUs == C.TIME_UNSET - ? PROGRESS_STATE_UNAVAILABLE - : PROGRESS_STATE_AVAILABLE; - checkNotNull(player).play(); - } - } - - @Override - public void onTracksInfoChanged(TracksInfo tracksInfo) { - if (muxerWrapper.getTrackCount() == 0) { - handleTransformationEnded( - new IllegalStateException( - "The output does not contain any tracks. Check that at least one of the input" - + " sample formats is supported.")); - } - } - - @Override - public void onPlayerError(PlaybackException error) { - handleTransformationEnded(error); - } - - private void handleTransformationEnded(@Nullable Exception exception) { - try { - releaseResources(/* forCancellation= */ false); - } catch (IllegalStateException e) { - if (exception == null) { - exception = e; - } - } - - if (exception == null) { - listener.onTransformationCompleted(mediaItem); - } else { - listener.onTransformationError(mediaItem, exception); - } - } - } -} diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java index 356772a9ed..5ed855a56e 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -89,13 +89,19 @@ public final class Transformer { /** A builder for {@link Transformer} instances. */ public static final class Builder { + // Mandatory field. private @MonotonicNonNull Context context; + + // Optional fields. private @MonotonicNonNull MediaSourceFactory mediaSourceFactory; private Muxer.Factory muxerFactory; private boolean removeAudio; private boolean removeVideo; private boolean flattenForSlowMotion; + private int outputHeight; private String containerMimeType; + @Nullable private String audioMimeType; + @Nullable private String videoMimeType; private Transformer.Listener listener; private Looper looper; private Clock clock; @@ -103,6 +109,7 @@ public final class Transformer { /** Creates a builder with default values. */ public Builder() { muxerFactory = new FrameworkMuxer.Factory(); + outputHeight = Transformation.NO_VALUE; containerMimeType = MimeTypes.VIDEO_MP4; listener = new Listener() {}; looper = Util.getCurrentOrMainLooper(); @@ -117,7 +124,10 @@ public final class Transformer { this.removeAudio = transformer.transformation.removeAudio; this.removeVideo = transformer.transformation.removeVideo; this.flattenForSlowMotion = transformer.transformation.flattenForSlowMotion; + this.outputHeight = transformer.transformation.outputHeight; this.containerMimeType = transformer.transformation.containerMimeType; + this.audioMimeType = transformer.transformation.audioMimeType; + this.videoMimeType = transformer.transformation.videoMimeType; this.listener = transformer.listener; this.looper = transformer.looper; this.clock = transformer.clock; @@ -207,6 +217,37 @@ public final class Transformer { return this; } + /** + * Sets the output resolution using the output height. The default value is {@link + * Transformation#NO_VALUE}, which will use the same height as the input. Output width will + * scale to preserve the input video's aspect ratio. + * + *

For now, only "popular" heights like 240, 360, 480, 720, 1080, 1440, or 2160 are + * supported, to ensure compatibility on different devices. + * + *

For example, a 1920x1440 video can be scaled to 640x480 by calling setResolution(480). + * + * @param outputHeight The output height in pixels. + * @return This builder. + */ + public Builder setResolution(int outputHeight) { + // TODO(Internal b/201293185): Restructure to input a Presentation class. + // TODO(Internal b/201293185): Check encoder codec capabilities in order to allow arbitrary + // resolutions and reasonable fallbacks. + if (outputHeight != 240 + && outputHeight != 360 + && outputHeight != 480 + && outputHeight != 720 + && outputHeight != 1080 + && outputHeight != 1440 + && outputHeight != 2160) { + throw new IllegalArgumentException( + "Please use a height of 240, 360, 480, 720, 1080, 1440, or 2160."); + } + this.outputHeight = outputHeight; + return this; + } + /** * @deprecated This feature will be removed in a following release and the MIME type of the * output will always be MP4. @@ -217,6 +258,58 @@ public final class Transformer { return this; } + /** + * Sets the video MIME type of the output. The default value is to use the same MIME type as the + * input. Supported values are: + * + *

    + *
  • when the container MIME type is {@link MimeTypes#VIDEO_MP4}: + *
      + *
    • {@link MimeTypes#VIDEO_H263} + *
    • {@link MimeTypes#VIDEO_H264} + *
    • {@link MimeTypes#VIDEO_H265} from API level 24 + *
    • {@link MimeTypes#VIDEO_MP4V} + *
    + *
  • when the container MIME type is {@link MimeTypes#VIDEO_WEBM}: + *
      + *
    • {@link MimeTypes#VIDEO_VP8} + *
    • {@link MimeTypes#VIDEO_VP9} from API level 24 + *
    + *
+ * + * @param videoMimeType The MIME type of the video samples in the output. + * @return This builder. + */ + public Builder setVideoMimeType(String videoMimeType) { + this.videoMimeType = videoMimeType; + return this; + } + + /** + * Sets the audio MIME type of the output. The default value is to use the same MIME type as the + * input. Supported values are: + * + *
    + *
  • when the container MIME type is {@link MimeTypes#VIDEO_MP4}: + *
      + *
    • {@link MimeTypes#AUDIO_AAC} + *
    • {@link MimeTypes#AUDIO_AMR_NB} + *
    • {@link MimeTypes#AUDIO_AMR_WB} + *
    + *
  • when the container MIME type is {@link MimeTypes#VIDEO_WEBM}: + *
      + *
    • {@link MimeTypes#AUDIO_VORBIS} + *
    + *
+ * + * @param audioMimeType The MIME type of the audio samples in the output. + * @return This builder. + */ + public Builder setAudioMimeType(String audioMimeType) { + this.audioMimeType = audioMimeType; + return this; + } + /** * Sets the {@link Transformer.Listener} to listen to the transformation events. * @@ -277,6 +370,8 @@ public final class Transformer { * @throws IllegalStateException If both audio and video have been removed (otherwise the output * would not contain any samples). * @throws IllegalStateException If the muxer doesn't support the requested container MIME type. + * @throws IllegalStateException If the muxer doesn't support the requested audio MIME type. + * @throws IllegalStateException If the muxer doesn't support the requested video MIME type. */ public Transformer build() { checkStateNotNull(context); @@ -290,18 +385,33 @@ public final class Transformer { checkState( muxerFactory.supportsOutputMimeType(containerMimeType), "Unsupported container MIME type: " + containerMimeType); + if (audioMimeType != null) { + checkSampleMimeType(audioMimeType); + } + if (videoMimeType != null) { + checkSampleMimeType(videoMimeType); + } Transformation transformation = new Transformation( removeAudio, removeVideo, flattenForSlowMotion, - /* outputHeight= */ Transformation.NO_VALUE, + outputHeight, containerMimeType, - /* audioMimeType= */ null, - /* videoMimeType= */ null); + audioMimeType, + videoMimeType); return new Transformer( context, mediaSourceFactory, muxerFactory, transformation, listener, looper, clock); } + + private void checkSampleMimeType(String sampleMimeType) { + checkState( + muxerFactory.supportsSampleMimeType(sampleMimeType, containerMimeType), + "Unsupported sample MIME type " + + sampleMimeType + + " for container MIME type " + + containerMimeType); + } } /** A listener for the transformation events. */ From b0cfe910a90442d8d41436c31053fd8286f05a72 Mon Sep 17 00:00:00 2001 From: ibaker Date: Fri, 19 Nov 2021 16:59:33 +0000 Subject: [PATCH 05/56] Fix javadoc reference to StyledPlayerView in cast demo app This should have been done in https://github.com/google/ExoPlayer/commit/098c3a010edb2461b65496b1229bd08b14b0f0d6 PiperOrigin-RevId: 411073049 --- .../com/google/android/exoplayer2/castdemo/PlayerManager.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java index 884a3a5cda..32fb65c2fa 100644 --- a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java @@ -28,7 +28,6 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.TracksInfo; import com.google.android.exoplayer2.ext.cast.CastPlayer; import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener; -import com.google.android.exoplayer2.ui.PlayerView; import com.google.android.exoplayer2.ui.StyledPlayerControlView; import com.google.android.exoplayer2.ui.StyledPlayerView; import com.google.android.gms.cast.framework.CastContext; @@ -67,7 +66,7 @@ import java.util.ArrayList; * * @param context A {@link Context}. * @param listener A {@link Listener} for queue position changes. - * @param playerView The {@link PlayerView} for playback. + * @param playerView The {@link StyledPlayerView} for playback. * @param castContext The {@link CastContext}. */ public PlayerManager( From cd6ba0680fd705ffeea700c05fbd27d6ff2d95dd Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Mon, 22 Nov 2021 11:44:29 +0000 Subject: [PATCH 06/56] Encapsulate attributes and uniforms within Program Document that apps should retain `GlUtil.Program` while the program is in use, and keep a reference to attributes/uniforms within the program to make sure they don't get GC'd causing any allocated buffers passed to GL to become invalid. Tested manually by running gldemo and transformer. PiperOrigin-RevId: 411516894 --- .../gldemo/BitmapOverlayVideoProcessor.java | 88 ++- .../gldemo/VideoProcessingGLSurfaceView.java | 3 + .../android/exoplayer2/util/GlUtil.java | 515 ++++++++++-------- .../video/spherical/ProjectionRenderer.java | 6 +- .../transformer/OpenGlFrameEditor.java | 142 ++--- 5 files changed, 360 insertions(+), 394 deletions(-) diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java index 5bd5441b5e..1d927fff57 100644 --- a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java +++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/BitmapOverlayVideoProcessor.java @@ -26,7 +26,6 @@ import android.graphics.Paint; import android.graphics.drawable.BitmapDrawable; import android.opengl.GLES20; import android.opengl.GLUtils; -import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.GlUtil; import java.io.IOException; @@ -52,8 +51,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private final Canvas overlayCanvas; private GlUtil.@MonotonicNonNull Program program; - @Nullable private GlUtil.Attribute[] attributes; - @Nullable private GlUtil.Uniform[] uniforms; private float bitmapScaleX; private float bitmapScaleY; @@ -88,31 +85,24 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } catch (IOException e) { throw new IllegalStateException(e); } - program.use(); - GlUtil.Attribute[] attributes = program.getAttributes(); - for (GlUtil.Attribute attribute : attributes) { - if (attribute.name.equals("a_position")) { - attribute.setBuffer( - new float[] { - -1, -1, 0, 1, - 1, -1, 0, 1, - -1, 1, 0, 1, - 1, 1, 0, 1 - }, - 4); - } else if (attribute.name.equals("a_texcoord")) { - attribute.setBuffer( - new float[] { - 0, 0, 0, 1, - 1, 0, 0, 1, - 0, 1, 0, 1, - 1, 1, 0, 1 - }, - 4); - } - } - this.attributes = attributes; - this.uniforms = program.getUniforms(); + program.setBufferAttribute( + "a_position", + new float[] { + -1, -1, 0, 1, + 1, -1, 0, 1, + -1, 1, 0, 1, + 1, 1, 0, 1 + }, + 4); + program.setBufferAttribute( + "a_texcoord", + new float[] { + 0, 0, 0, 1, + 1, 0, 0, 1, + 0, 1, 0, 1, + 1, 1, 0, 1 + }, + 4); GLES20.glGenTextures(1, textures, 0); GLES20.glBindTexture(GL10.GL_TEXTURE_2D, textures[0]); GLES20.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_NEAREST); @@ -141,36 +131,22 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; GlUtil.checkGlError(); // Run the shader program. - GlUtil.Uniform[] uniforms = checkNotNull(this.uniforms); - GlUtil.Attribute[] attributes = checkNotNull(this.attributes); - for (GlUtil.Uniform uniform : uniforms) { - switch (uniform.name) { - case "tex_sampler_0": - uniform.setSamplerTexId(frameTexture, /* unit= */ 0); - break; - case "tex_sampler_1": - uniform.setSamplerTexId(textures[0], /* unit= */ 1); - break; - case "scaleX": - uniform.setFloat(bitmapScaleX); - break; - case "scaleY": - uniform.setFloat(bitmapScaleY); - break; - case "tex_transform": - uniform.setFloats(transformMatrix); - break; - default: // fall out - } - } - for (GlUtil.Attribute copyExternalAttribute : attributes) { - copyExternalAttribute.bind(); - } - for (GlUtil.Uniform copyExternalUniform : uniforms) { - copyExternalUniform.bind(); - } + GlUtil.Program program = checkNotNull(this.program); + program.setSamplerTexIdUniform("tex_sampler_0", frameTexture, /* unit= */ 0); + program.setSamplerTexIdUniform("tex_sampler_1", textures[0], /* unit= */ 1); + program.setFloatUniform("scaleX", bitmapScaleX); + program.setFloatUniform("scaleY", bitmapScaleY); + program.setFloatsUniform("tex_transform", transformMatrix); + program.bindAttributesAndUniforms(); GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, /* first= */ 0, /* count= */ 4); GlUtil.checkGlError(); } + + @Override + public void release() { + if (program != null) { + program.delete(); + } + } } diff --git a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/VideoProcessingGLSurfaceView.java b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/VideoProcessingGLSurfaceView.java index e2796b0370..2bdd087251 100644 --- a/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/VideoProcessingGLSurfaceView.java +++ b/demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/VideoProcessingGLSurfaceView.java @@ -64,6 +64,9 @@ public final class VideoProcessingGLSurfaceView extends GLSurfaceView { * @param transformMatrix The 4 * 4 transform matrix to be applied to the texture. */ void draw(int frameTexture, long frameTimestampUs, float[] transformMatrix); + + /** Releases any resources associated with this {@link VideoProcessor}. */ + void release(); } private static final int EGL_PROTECTED_CONTENT_EXT = 0x32C0; diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/GlUtil.java b/library/common/src/main/java/com/google/android/exoplayer2/util/GlUtil.java index 84794d6550..e2696818aa 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/GlUtil.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/GlUtil.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.util; import static android.opengl.GLU.gluErrorString; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import android.content.Context; import android.content.pm.PackageManager; @@ -37,6 +38,8 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.FloatBuffer; import java.nio.IntBuffer; +import java.util.HashMap; +import java.util.Map; import javax.microedition.khronos.egl.EGL10; /** GL utilities. */ @@ -53,25 +56,20 @@ public final class GlUtil { /** Thrown when the required EGL version is not supported by the device. */ public static final class UnsupportedEglVersionException extends Exception {} - /** GL program. */ + /** + * Represents a GLSL shader program. + * + *

After constructing a program, keep a reference for its lifetime and call {@link #delete()} + * (or release the current GL context) when it's no longer needed. + */ public static final class Program { /** The identifier of a compiled and linked GLSL shader program. */ private final int programId; - /** - * Compiles a GL shader program from vertex and fragment shader GLSL GLES20 code. - * - * @param vertexShaderGlsl The vertex shader program. - * @param fragmentShaderGlsl The fragment shader program. - */ - public Program(String vertexShaderGlsl, String fragmentShaderGlsl) { - programId = GLES20.glCreateProgram(); - checkGlError(); - - // Add the vertex and fragment shaders. - addShader(GLES20.GL_VERTEX_SHADER, vertexShaderGlsl); - addShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderGlsl); - } + private final Attribute[] attributes; + private final Uniform[] uniforms; + private final Map attributeByName; + private final Map uniformByName; /** * Compiles a GL shader program from vertex and fragment shader GLSL GLES20 code. @@ -86,9 +84,21 @@ public final class GlUtil { this(loadAsset(context, vertexShaderFilePath), loadAsset(context, fragmentShaderFilePath)); } - /** Uses the program. */ - public void use() { - // Link and check for errors. + /** + * Compiles a GL shader program from vertex and fragment shader GLSL GLES20 code. + * + * @param vertexShaderGlsl The vertex shader program. + * @param fragmentShaderGlsl The fragment shader program. + */ + public Program(String vertexShaderGlsl, String fragmentShaderGlsl) { + programId = GLES20.glCreateProgram(); + checkGlError(); + + // Add the vertex and fragment shaders. + addShader(programId, GLES20.GL_VERTEX_SHADER, vertexShaderGlsl); + addShader(programId, GLES20.GL_FRAGMENT_SHADER, fragmentShaderGlsl); + + // Link and use the program, and enumerate attributes/uniforms. GLES20.glLinkProgram(programId); int[] linkStatus = new int[] {GLES20.GL_FALSE}; GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0); @@ -96,14 +106,38 @@ public final class GlUtil { throwGlException( "Unable to link shader program: \n" + GLES20.glGetProgramInfoLog(programId)); } - checkGlError(); - GLES20.glUseProgram(programId); + attributeByName = new HashMap<>(); + int[] attributeCount = new int[1]; + GLES20.glGetProgramiv(programId, GLES20.GL_ACTIVE_ATTRIBUTES, attributeCount, 0); + attributes = new Attribute[attributeCount[0]]; + for (int i = 0; i < attributeCount[0]; i++) { + Attribute attribute = Attribute.create(programId, i); + attributes[i] = attribute; + attributeByName.put(attribute.name, attribute); + } + uniformByName = new HashMap<>(); + int[] uniformCount = new int[1]; + GLES20.glGetProgramiv(programId, GLES20.GL_ACTIVE_UNIFORMS, uniformCount, 0); + uniforms = new Uniform[uniformCount[0]]; + for (int i = 0; i < uniformCount[0]; i++) { + Uniform uniform = Uniform.create(programId, i); + uniforms[i] = uniform; + uniformByName.put(uniform.name, uniform); + } + checkGlError(); + } + + /** Uses the program. */ + public void use() { + GLES20.glUseProgram(programId); + checkGlError(); } /** Deletes the program. Deleted programs cannot be used again. */ public void delete() { GLES20.glDeleteProgram(programId); + checkGlError(); } /** @@ -119,234 +153,45 @@ public final class GlUtil { /** Returns the location of an {@link Attribute}. */ private int getAttributeLocation(String attributeName) { - return GLES20.glGetAttribLocation(programId, attributeName); + return GlUtil.getAttributeLocation(programId, attributeName); } /** Returns the location of a {@link Uniform}. */ public int getUniformLocation(String uniformName) { - return GLES20.glGetUniformLocation(programId, uniformName); + return GlUtil.getUniformLocation(programId, uniformName); } - /** Returns the program's {@link Attribute}s. */ - public Attribute[] getAttributes() { - int[] attributeCount = new int[1]; - GLES20.glGetProgramiv(programId, GLES20.GL_ACTIVE_ATTRIBUTES, attributeCount, 0); - if (attributeCount[0] != 2) { - throw new IllegalStateException("Expected two attributes but found " + attributeCount[0]); + /** Sets a float buffer type attribute. */ + public void setBufferAttribute(String name, float[] values, int size) { + checkNotNull(attributeByName.get(name)).setBuffer(values, size); + } + + /** Sets a texture sampler type uniform. */ + public void setSamplerTexIdUniform(String name, int texId, int unit) { + checkNotNull(uniformByName.get(name)).setSamplerTexId(texId, unit); + } + + /** Sets a float type uniform. */ + public void setFloatUniform(String name, float value) { + checkNotNull(uniformByName.get(name)).setFloat(value); + } + + /** Sets a float array type uniform. */ + public void setFloatsUniform(String name, float[] value) { + checkNotNull(uniformByName.get(name)).setFloats(value); + } + + /** Binds all attributes and uniforms in the program. */ + public void bindAttributesAndUniforms() { + for (Attribute attribute : attributes) { + attribute.bind(); } - - Attribute[] attributes = new Attribute[attributeCount[0]]; - for (int i = 0; i < attributeCount[0]; i++) { - attributes[i] = createAttribute(i); + for (Uniform uniform : uniforms) { + uniform.bind(); } - return attributes; - } - - /** Returns the program's {@link Uniform}s. */ - public Uniform[] getUniforms() { - int[] uniformCount = new int[1]; - GLES20.glGetProgramiv(programId, GLES20.GL_ACTIVE_UNIFORMS, uniformCount, 0); - - Uniform[] uniforms = new Uniform[uniformCount[0]]; - for (int i = 0; i < uniformCount[0]; i++) { - uniforms[i] = createUniform(i); - } - - return uniforms; - } - - private Attribute createAttribute(int index) { - int[] length = new int[1]; - GLES20.glGetProgramiv(programId, GLES20.GL_ACTIVE_ATTRIBUTE_MAX_LENGTH, length, 0); - - int[] type = new int[1]; - int[] size = new int[1]; - byte[] nameBytes = new byte[length[0]]; - int[] ignore = new int[1]; - - GLES20.glGetActiveAttrib( - programId, index, length[0], ignore, 0, size, 0, type, 0, nameBytes, 0); - String name = new String(nameBytes, 0, strlen(nameBytes)); - int location = getAttributeLocation(name); - - return new Attribute(name, index, location); - } - - private Uniform createUniform(int index) { - int[] length = new int[1]; - GLES20.glGetProgramiv(programId, GLES20.GL_ACTIVE_UNIFORM_MAX_LENGTH, length, 0); - - int[] type = new int[1]; - int[] size = new int[1]; - byte[] nameBytes = new byte[length[0]]; - int[] ignore = new int[1]; - - GLES20.glGetActiveUniform( - programId, index, length[0], ignore, 0, size, 0, type, 0, nameBytes, 0); - String name = new String(nameBytes, 0, strlen(nameBytes)); - int location = getUniformLocation(name); - - return new Uniform(name, location, type[0]); - } - - private void addShader(int type, String glsl) { - int shader = GLES20.glCreateShader(type); - GLES20.glShaderSource(shader, glsl); - GLES20.glCompileShader(shader); - - int[] result = new int[] {GLES20.GL_FALSE}; - GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, result, 0); - if (result[0] != GLES20.GL_TRUE) { - throwGlException(GLES20.glGetShaderInfoLog(shader) + ", source: " + glsl); - } - - GLES20.glAttachShader(programId, shader); - GLES20.glDeleteShader(shader); - checkGlError(); } } - /** - * GL attribute, which can be attached to a buffer with {@link Attribute#setBuffer(float[], int)}. - */ - public static final class Attribute { - - /** The name of the attribute in the GLSL sources. */ - public final String name; - - private final int index; - private final int location; - - @Nullable private Buffer buffer; - private int size; - - /* Creates a new Attribute. */ - public Attribute(String name, int index, int location) { - this.name = name; - this.index = index; - this.location = location; - } - - /** - * Configures {@link #bind()} to attach vertices in {@code buffer} (each of size {@code size} - * elements) to this {@link Attribute}. - * - * @param buffer Buffer to bind to this attribute. - * @param size Number of elements per vertex. - */ - public void setBuffer(float[] buffer, int size) { - this.buffer = createBuffer(buffer); - this.size = size; - } - - /** - * Sets the vertex attribute to whatever was attached via {@link #setBuffer(float[], int)}. - * - *

Should be called before each drawing call. - */ - public void bind() { - Buffer buffer = Assertions.checkNotNull(this.buffer, "call setBuffer before bind"); - GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); - GLES20.glVertexAttribPointer( - location, - size, // count - GLES20.GL_FLOAT, // type - false, // normalize - 0, // stride - buffer); - GLES20.glEnableVertexAttribArray(index); - checkGlError(); - } - } - - /** - * GL uniform, which can be attached to a sampler using {@link Uniform#setSamplerTexId(int, int)}. - */ - public static final class Uniform { - - /** The name of the uniform in the GLSL sources. */ - public final String name; - - private final int location; - private final int type; - private final float[] value; - - private int texId; - private int unit; - - /** Creates a new uniform. */ - public Uniform(String name, int location, int type) { - this.name = name; - this.location = location; - this.type = type; - this.value = new float[16]; - } - - /** - * Configures {@link #bind()} to use the specified {@code texId} for this sampler uniform. - * - * @param texId The GL texture identifier from which to sample. - * @param unit The GL texture unit index. - */ - public void setSamplerTexId(int texId, int unit) { - this.texId = texId; - this.unit = unit; - } - - /** Configures {@link #bind()} to use the specified float {@code value} for this uniform. */ - public void setFloat(float value) { - this.value[0] = value; - } - - /** Configures {@link #bind()} to use the specified float[] {@code value} for this uniform. */ - public void setFloats(float[] value) { - System.arraycopy(value, 0, this.value, 0, value.length); - } - - /** - * Sets the uniform to whatever value was passed via {@link #setSamplerTexId(int, int)}, {@link - * #setFloat(float)} or {@link #setFloats(float[])}. - * - *

Should be called before each drawing call. - */ - public void bind() { - if (type == GLES20.GL_FLOAT) { - GLES20.glUniform1fv(location, 1, value, 0); - checkGlError(); - return; - } - - if (type == GLES20.GL_FLOAT_MAT4) { - GLES20.glUniformMatrix4fv(location, 1, false, value, 0); - checkGlError(); - return; - } - - if (texId == 0) { - throw new IllegalStateException("Call setSamplerTexId before bind."); - } - GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + unit); - if (type == GLES11Ext.GL_SAMPLER_EXTERNAL_OES) { - GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId); - } else if (type == GLES20.GL_SAMPLER_2D) { - GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId); - } else { - throw new IllegalStateException("Unexpected uniform type: " + type); - } - GLES20.glUniform1i(location, unit); - GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); - GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); - GLES20.glTexParameteri( - GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); - GLES20.glTexParameteri( - GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); - checkGlError(); - } - } - - /** Represents an unset texture ID. */ - public static final int TEXTURE_ID_UNSET = -1; - /** Whether to throw a {@link GlException} in case of an OpenGL error. */ public static boolean glAssertionsEnabled = false; @@ -531,6 +376,30 @@ public final class GlUtil { return texId[0]; } + private static void addShader(int programId, int type, String glsl) { + int shader = GLES20.glCreateShader(type); + GLES20.glShaderSource(shader, glsl); + GLES20.glCompileShader(shader); + + int[] result = new int[] {GLES20.GL_FALSE}; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, result, 0); + if (result[0] != GLES20.GL_TRUE) { + throwGlException(GLES20.glGetShaderInfoLog(shader) + ", source: " + glsl); + } + + GLES20.glAttachShader(programId, shader); + GLES20.glDeleteShader(shader); + checkGlError(); + } + + private static int getAttributeLocation(int programId, String attributeName) { + return GLES20.glGetAttribLocation(programId, attributeName); + } + + private static int getUniformLocation(int programId, String uniformName) { + return GLES20.glGetUniformLocation(programId, uniformName); + } + private static void throwGlException(String errorMsg) { Log.e(TAG, errorMsg); if (glAssertionsEnabled) { @@ -554,6 +423,178 @@ public final class GlUtil { return strVal.length; } + /** + * GL attribute, which can be attached to a buffer with {@link Attribute#setBuffer(float[], int)}. + */ + private static final class Attribute { + + /* Returns the attribute at the given index in the program. */ + public static Attribute create(int programId, int index) { + int[] length = new int[1]; + GLES20.glGetProgramiv(programId, GLES20.GL_ACTIVE_ATTRIBUTE_MAX_LENGTH, length, 0); + + int[] type = new int[1]; + int[] size = new int[1]; + byte[] nameBytes = new byte[length[0]]; + int[] ignore = new int[1]; + + GLES20.glGetActiveAttrib( + programId, index, length[0], ignore, 0, size, 0, type, 0, nameBytes, 0); + String name = new String(nameBytes, 0, strlen(nameBytes)); + int location = getAttributeLocation(programId, name); + + return new Attribute(name, index, location); + } + + /** The name of the attribute in the GLSL sources. */ + public final String name; + + private final int index; + private final int location; + + @Nullable private Buffer buffer; + private int size; + + private Attribute(String name, int index, int location) { + this.name = name; + this.index = index; + this.location = location; + } + + /** + * Configures {@link #bind()} to attach vertices in {@code buffer} (each of size {@code size} + * elements) to this {@link Attribute}. + * + * @param buffer Buffer to bind to this attribute. + * @param size Number of elements per vertex. + */ + public void setBuffer(float[] buffer, int size) { + this.buffer = createBuffer(buffer); + this.size = size; + } + + /** + * Sets the vertex attribute to whatever was attached via {@link #setBuffer(float[], int)}. + * + *

Should be called before each drawing call. + */ + public void bind() { + Buffer buffer = checkNotNull(this.buffer, "call setBuffer before bind"); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + GLES20.glVertexAttribPointer( + location, + size, // count + GLES20.GL_FLOAT, // type + false, // normalize + 0, // stride + buffer); + GLES20.glEnableVertexAttribArray(index); + checkGlError(); + } + } + + /** + * GL uniform, which can be attached to a sampler using {@link Uniform#setSamplerTexId(int, int)}. + */ + private static final class Uniform { + + /** Returns the uniform at the given index in the program. */ + public static Uniform create(int programId, int index) { + int[] length = new int[1]; + GLES20.glGetProgramiv(programId, GLES20.GL_ACTIVE_UNIFORM_MAX_LENGTH, length, 0); + + int[] type = new int[1]; + int[] size = new int[1]; + byte[] nameBytes = new byte[length[0]]; + int[] ignore = new int[1]; + + GLES20.glGetActiveUniform( + programId, index, length[0], ignore, 0, size, 0, type, 0, nameBytes, 0); + String name = new String(nameBytes, 0, strlen(nameBytes)); + int location = getUniformLocation(programId, name); + + return new Uniform(name, location, type[0]); + } + + /** The name of the uniform in the GLSL sources. */ + public final String name; + + private final int location; + private final int type; + private final float[] value; + + private int texId; + private int unit; + + private Uniform(String name, int location, int type) { + this.name = name; + this.location = location; + this.type = type; + this.value = new float[16]; + } + + /** + * Configures {@link #bind()} to use the specified {@code texId} for this sampler uniform. + * + * @param texId The GL texture identifier from which to sample. + * @param unit The GL texture unit index. + */ + public void setSamplerTexId(int texId, int unit) { + this.texId = texId; + this.unit = unit; + } + + /** Configures {@link #bind()} to use the specified float {@code value} for this uniform. */ + public void setFloat(float value) { + this.value[0] = value; + } + + /** Configures {@link #bind()} to use the specified float[] {@code value} for this uniform. */ + public void setFloats(float[] value) { + System.arraycopy(value, 0, this.value, 0, value.length); + } + + /** + * Sets the uniform to whatever value was passed via {@link #setSamplerTexId(int, int)}, {@link + * #setFloat(float)} or {@link #setFloats(float[])}. + * + *

Should be called before each drawing call. + */ + public void bind() { + if (type == GLES20.GL_FLOAT) { + GLES20.glUniform1fv(location, 1, value, 0); + checkGlError(); + return; + } + + if (type == GLES20.GL_FLOAT_MAT4) { + GLES20.glUniformMatrix4fv(location, 1, false, value, 0); + checkGlError(); + return; + } + + if (texId == 0) { + throw new IllegalStateException("Call setSamplerTexId before bind."); + } + GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + unit); + if (type == GLES11Ext.GL_SAMPLER_EXTERNAL_OES) { + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId); + } else if (type == GLES20.GL_SAMPLER_2D) { + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId); + } else { + throw new IllegalStateException("Unexpected uniform type: " + type); + } + GLES20.glUniform1i(location, unit); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + checkGlError(); + } + } + @RequiresApi(17) private static final class Api17 { private Api17() {} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java index 5f88b05488..3e9f577e20 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java @@ -114,7 +114,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } /** Initializes of the GL components. */ - /* package */ void init() { + public void init() { program = new GlUtil.Program(VERTEX_SHADER, FRAGMENT_SHADER); mvpMatrixHandle = program.getUniformLocation("uMvpMatrix"); uTexMatrixHandle = program.getUniformLocation("uTexMatrix"); @@ -132,7 +132,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * @param rightEye Whether the right eye view should be drawn. If {@code false}, the left eye view * is drawn. */ - /* package */ void draw(int textureId, float[] mvpMatrix, boolean rightEye) { + public void draw(int textureId, float[] mvpMatrix, boolean rightEye) { MeshData meshData = rightEye ? rightMeshData : leftMeshData; if (meshData == null) { return; @@ -188,7 +188,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } /** Cleans up GL resources. */ - /* package */ void shutdown() { + public void shutdown() { if (program != null) { program.delete(); } diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/OpenGlFrameEditor.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/OpenGlFrameEditor.java index cbf19da7fa..00d42411e4 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/OpenGlFrameEditor.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/OpenGlFrameEditor.java @@ -13,12 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.android.exoplayer2.transformer; -import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.util.Assertions.checkState; - import android.content.Context; import android.graphics.SurfaceTexture; import android.opengl.EGL14; @@ -31,7 +27,6 @@ import android.view.Surface; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.util.GlUtil; import java.io.IOException; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * OpenGlFrameEditor applies changes to individual video frames using OpenGL. Changes include just @@ -67,73 +62,38 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; int textureId = GlUtil.createExternalTexture(); GlUtil.Program copyProgram; try { + // TODO(internal b/205002913): check the loaded program is consistent with the attributes + // and uniforms expected in the code. copyProgram = new GlUtil.Program(context, VERTEX_SHADER_FILE_PATH, FRAGMENT_SHADER_FILE_PATH); } catch (IOException e) { throw new IllegalStateException(e); } - - copyProgram.use(); - GlUtil.Attribute[] copyAttributes = copyProgram.getAttributes(); - checkState( - copyAttributes.length == EXPECTED_NUMBER_OF_ATTRIBUTES, - "Expected program to have " + EXPECTED_NUMBER_OF_ATTRIBUTES + " vertex attributes."); - for (GlUtil.Attribute copyAttribute : copyAttributes) { - if (copyAttribute.name.equals("a_position")) { - copyAttribute.setBuffer( - new float[] { - -1.0f, -1.0f, 0.0f, 1.0f, - 1.0f, -1.0f, 0.0f, 1.0f, - -1.0f, 1.0f, 0.0f, 1.0f, - 1.0f, 1.0f, 0.0f, 1.0f, - }, - /* size= */ 4); - } else if (copyAttribute.name.equals("a_texcoord")) { - copyAttribute.setBuffer( - new float[] { - 0.0f, 0.0f, 0.0f, 1.0f, - 1.0f, 0.0f, 0.0f, 1.0f, - 0.0f, 1.0f, 0.0f, 1.0f, - 1.0f, 1.0f, 0.0f, 1.0f, - }, - /* size= */ 4); - } else { - throw new IllegalStateException("Unexpected attribute name."); - } - copyAttribute.bind(); - } - GlUtil.Uniform[] copyUniforms = copyProgram.getUniforms(); - checkState( - copyUniforms.length == EXPECTED_NUMBER_OF_UNIFORMS, - "Expected program to have " + EXPECTED_NUMBER_OF_UNIFORMS + " uniforms."); - GlUtil.@MonotonicNonNull Uniform textureTransformUniform = null; - for (GlUtil.Uniform copyUniform : copyUniforms) { - if (copyUniform.name.equals("tex_sampler")) { - copyUniform.setSamplerTexId(textureId, 0); - copyUniform.bind(); - } else if (copyUniform.name.equals("tex_transform")) { - textureTransformUniform = copyUniform; - } else { - throw new IllegalStateException("Unexpected uniform name."); - } - } - - return new OpenGlFrameEditor( - eglDisplay, - eglContext, - eglSurface, - textureId, - checkNotNull(textureTransformUniform), - copyProgram, - copyAttributes, - copyUniforms); + copyProgram.setBufferAttribute( + "a_position", + new float[] { + -1.0f, -1.0f, 0.0f, 1.0f, + 1.0f, -1.0f, 0.0f, 1.0f, + -1.0f, 1.0f, 0.0f, 1.0f, + 1.0f, 1.0f, 0.0f, 1.0f, + }, + /* size= */ 4); + copyProgram.setBufferAttribute( + "a_texcoord", + new float[] { + 0.0f, 0.0f, 0.0f, 1.0f, + 1.0f, 0.0f, 0.0f, 1.0f, + 0.0f, 1.0f, 0.0f, 1.0f, + 1.0f, 1.0f, 0.0f, 1.0f, + }, + /* size= */ 4); + copyProgram.setSamplerTexIdUniform("tex_sampler", textureId, /* unit= */ 0); + return new OpenGlFrameEditor(eglDisplay, eglContext, eglSurface, textureId, copyProgram); } // Predefined shader values. private static final String VERTEX_SHADER_FILE_PATH = "shaders/blit_vertex_shader.glsl"; private static final String FRAGMENT_SHADER_FILE_PATH = "shaders/copy_external_fragment_shader.glsl"; - private static final int EXPECTED_NUMBER_OF_ATTRIBUTES = 2; - private static final int EXPECTED_NUMBER_OF_UNIFORMS = 2; private final float[] textureTransformMatrix; private final EGLDisplay eglDisplay; @@ -142,19 +102,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private final int textureId; private final SurfaceTexture inputSurfaceTexture; private final Surface inputSurface; - private final GlUtil.Uniform textureTransformUniform; - // TODO(internal: b/206631334): These fields ensure buffers passed to GL are not GC'ed. Implement - // a better way of doing this so they aren't just unused fields. - @SuppressWarnings("unused") private final GlUtil.Program copyProgram; - @SuppressWarnings("unused") - private final GlUtil.Attribute[] copyAttributes; - - @SuppressWarnings("unused") - private final GlUtil.Uniform[] copyUniforms; - private volatile boolean hasInputData; private OpenGlFrameEditor( @@ -162,38 +112,37 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; EGLContext eglContext, EGLSurface eglSurface, int textureId, - GlUtil.Uniform textureTransformUniform, - GlUtil.Program copyProgram, - GlUtil.Attribute[] copyAttributes, - GlUtil.Uniform[] copyUniforms) { + GlUtil.Program copyProgram) { this.eglDisplay = eglDisplay; this.eglContext = eglContext; this.eglSurface = eglSurface; this.textureId = textureId; - this.textureTransformUniform = textureTransformUniform; this.copyProgram = copyProgram; - this.copyAttributes = copyAttributes; - this.copyUniforms = copyUniforms; textureTransformMatrix = new float[16]; inputSurfaceTexture = new SurfaceTexture(textureId); inputSurfaceTexture.setOnFrameAvailableListener(surfaceTexture -> hasInputData = true); inputSurface = new Surface(inputSurfaceTexture); } - /** Releases all resources. */ - public void release() { - GlUtil.destroyEglContext(eglDisplay, eglContext); - GlUtil.deleteTexture(textureId); - inputSurfaceTexture.release(); - inputSurface.release(); + /** Returns the input {@link Surface}. */ + public Surface getInputSurface() { + return inputSurface; } - /** Informs the editor that there is new input data available for it to process asynchronously. */ + /** + * Returns whether there is pending input data that can be processed by calling {@link + * #processData()}. + */ + public boolean hasInputData() { + return hasInputData; + } + + /** Processes pending input data. */ public void processData() { inputSurfaceTexture.updateTexImage(); inputSurfaceTexture.getTransformMatrix(textureTransformMatrix); - textureTransformUniform.setFloats(textureTransformMatrix); - textureTransformUniform.bind(); + copyProgram.setFloatsUniform("tex_transform", textureTransformMatrix); + copyProgram.bindAttributesAndUniforms(); GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); long surfaceTextureTimestampNs = inputSurfaceTexture.getTimestamp(); EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, surfaceTextureTimestampNs); @@ -201,15 +150,12 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; hasInputData = false; } - /** - * Returns the input {@link Surface} after configuring the editor if it has not previously been - * configured. - */ - public Surface getInputSurface() { - return inputSurface; - } - - public boolean hasInputData() { - return hasInputData; + /** Releases all resources. */ + public void release() { + copyProgram.delete(); + GlUtil.deleteTexture(textureId); + GlUtil.destroyEglContext(eglDisplay, eglContext); + inputSurfaceTexture.release(); + inputSurface.release(); } } From 6b8a1a365c1ff54588305ea1bff02153baf97e81 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 22 Nov 2021 11:47:26 +0000 Subject: [PATCH 07/56] Add MediaMetricsListener class. PiperOrigin-RevId: 411517319 --- .../analytics/MediaMetricsListener.java | 845 ++++++++++++++++++ 1 file changed, 845 insertions(+) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/analytics/MediaMetricsListener.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/MediaMetricsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/MediaMetricsListener.java new file mode 100644 index 0000000000..f085a6bae1 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/MediaMetricsListener.java @@ -0,0 +1,845 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.analytics; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.media.DeniedByServerException; +import android.media.MediaCodec; +import android.media.MediaDrm; +import android.media.MediaDrmResetException; +import android.media.NotProvisionedException; +import android.media.metrics.LogSessionId; +import android.media.metrics.MediaMetricsManager; +import android.media.metrics.NetworkEvent; +import android.media.metrics.PlaybackErrorEvent; +import android.media.metrics.PlaybackMetrics; +import android.media.metrics.PlaybackSession; +import android.media.metrics.PlaybackStateEvent; +import android.media.metrics.TrackChangeEvent; +import android.os.SystemClock; +import android.system.ErrnoException; +import android.system.OsConstants; +import android.util.Pair; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.TracksInfo; +import com.google.android.exoplayer2.TracksInfo.TrackGroupInfo; +import com.google.android.exoplayer2.audio.AudioSink; +import com.google.android.exoplayer2.decoder.DecoderCounters; +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; +import com.google.android.exoplayer2.drm.DrmInitData; +import com.google.android.exoplayer2.drm.DrmSession; +import com.google.android.exoplayer2.drm.UnsupportedDrmException; +import com.google.android.exoplayer2.mediacodec.MediaCodecDecoderException; +import com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; +import com.google.android.exoplayer2.source.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaLoadData; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.upstream.FileDataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.UdpDataSource; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.NetworkTypeObserver; +import com.google.android.exoplayer2.util.Util; +import com.google.android.exoplayer2.video.VideoSize; +import com.google.common.collect.ImmutableList; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.UUID; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * An {@link AnalyticsListener} that interacts with the Android {@link MediaMetricsManager}. + * + *

It listens to playback events and forwards them to a {@link PlaybackSession}. The {@link + * LogSessionId} of the playback session can be obtained with {@link #getLogSessionId()}. + */ +@RequiresApi(31) +public final class MediaMetricsListener + implements AnalyticsListener, PlaybackSessionManager.Listener { + + private final Context context; + private final PlaybackSessionManager sessionManager; + private final PlaybackSession playbackSession; + private final long startTimeMs; + private final Timeline.Window window; + private final Timeline.Period period; + + @Nullable private PlaybackMetrics.Builder metricsBuilder; + @Player.DiscontinuityReason private int discontinuityReason; + private int currentPlaybackState; + private int currentNetworkType; + @Nullable private PlaybackException pendingPlayerError; + @Nullable private PendingFormatUpdate pendingVideoFormat; + @Nullable private PendingFormatUpdate pendingAudioFormat; + @Nullable private PendingFormatUpdate pendingTextFormat; + @Nullable private Format currentVideoFormat; + @Nullable private Format currentAudioFormat; + @Nullable private Format currentTextFormat; + private boolean isSeeking; + private int ioErrorType; + private boolean hasFatalError; + private int droppedFrames; + private int playedFrames; + private long bandwidthTimeMs; + private long bandwidthBytes; + private int audioUnderruns; + + /** + * Creates the listener. + * + * @param context A {@link Context}. + */ + public MediaMetricsListener(Context context) { + context = context.getApplicationContext(); + this.context = context; + window = new Timeline.Window(); + period = new Timeline.Period(); + MediaMetricsManager mediaMetricsManager = + checkStateNotNull( + (MediaMetricsManager) context.getSystemService(Context.MEDIA_METRICS_SERVICE)); + playbackSession = mediaMetricsManager.createPlaybackSession(); + startTimeMs = SystemClock.elapsedRealtime(); + currentPlaybackState = PlaybackStateEvent.STATE_NOT_STARTED; + currentNetworkType = NetworkEvent.NETWORK_TYPE_UNKNOWN; + sessionManager = new DefaultPlaybackSessionManager(); + sessionManager.setListener(this); + } + + /** Returns the {@link LogSessionId} used by this listener. */ + public LogSessionId getLogSessionId() { + return playbackSession.getSessionId(); + } + + // PlaybackSessionManager.Listener implementation. + + @Override + public void onSessionCreated(EventTime eventTime, String sessionId) {} + + @Override + public void onSessionActive(EventTime eventTime, String sessionId) { + if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) { + // Ignore ad sessions. + return; + } + finishCurrentSession(); + metricsBuilder = + new PlaybackMetrics.Builder() + .setPlayerName(ExoPlayerLibraryInfo.TAG) + .setPlayerVersion(ExoPlayerLibraryInfo.VERSION); + maybeUpdateTimelineMetadata(eventTime.timeline, eventTime.mediaPeriodId); + } + + @Override + public void onAdPlaybackStarted( + EventTime eventTime, String contentSessionId, String adSessionId) {} + + @Override + public void onSessionFinished( + EventTime eventTime, String sessionId, boolean automaticTransitionToNextPlayback) { + if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) { + // Ignore ad sessions. + return; + } + finishCurrentSession(); + } + + // AnalyticsListener implementation. + + @Override + public void onPositionDiscontinuity( + EventTime eventTime, + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + isSeeking = true; + } + discontinuityReason = reason; + } + + @Override + public void onVideoDisabled(EventTime eventTime, DecoderCounters decoderCounters) { + // TODO(b/181122234): DecoderCounters are not re-reported at period boundaries. + droppedFrames += decoderCounters.droppedBufferCount; + playedFrames += decoderCounters.renderedOutputBufferCount; + } + + @Override + public void onBandwidthEstimate( + EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { + bandwidthTimeMs += totalLoadTimeMs; + bandwidthBytes += totalBytesLoaded; + } + + @Override + public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { + PendingFormatUpdate update = + new PendingFormatUpdate( + checkNotNull(mediaLoadData.trackFormat), + mediaLoadData.trackSelectionReason, + sessionManager.getSessionForMediaPeriodId( + eventTime.timeline, checkNotNull(eventTime.mediaPeriodId))); + switch (mediaLoadData.trackType) { + case C.TRACK_TYPE_VIDEO: + case C.TRACK_TYPE_DEFAULT: + pendingVideoFormat = update; + break; + case C.TRACK_TYPE_AUDIO: + pendingAudioFormat = update; + break; + case C.TRACK_TYPE_TEXT: + pendingTextFormat = update; + break; + default: + // Other track type. Ignore. + } + } + + @Override + public void onVideoSizeChanged(EventTime eventTime, VideoSize videoSize) { + @Nullable PendingFormatUpdate pendingVideoFormat = this.pendingVideoFormat; + if (pendingVideoFormat != null && pendingVideoFormat.format.height == Format.NO_VALUE) { + Format formatWithHeightAndWidth = + pendingVideoFormat + .format + .buildUpon() + .setWidth(videoSize.width) + .setHeight(videoSize.height) + .build(); + this.pendingVideoFormat = + new PendingFormatUpdate( + formatWithHeightAndWidth, + pendingVideoFormat.selectionReason, + pendingVideoFormat.sessionId); + } + } + + @Override + public void onLoadError( + EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + ioErrorType = mediaLoadData.dataType; + } + + @Override + public void onPlayerError(EventTime eventTime, PlaybackException error) { + pendingPlayerError = error; + } + + @Override + public void onEvents(Player player, Events events) { + if (events.size() == 0) { + return; + } + maybeAddSessions(events); + + long realtimeMs = SystemClock.elapsedRealtime(); + maybeUpdateMetricsBuilderValues(player, events); + maybeReportPlaybackError(realtimeMs); + maybeReportTrackChanges(player, events, realtimeMs); + maybeReportNetworkChange(realtimeMs); + maybeReportPlaybackStateChange(player, events, realtimeMs); + + if (events.contains(AnalyticsListener.EVENT_PLAYER_RELEASED)) { + sessionManager.finishAllSessions(events.getEventTime(EVENT_PLAYER_RELEASED)); + } + } + + private void maybeAddSessions(Events events) { + for (int i = 0; i < events.size(); i++) { + @EventFlags int event = events.get(i); + EventTime eventTime = events.getEventTime(event); + if (event == EVENT_TIMELINE_CHANGED) { + sessionManager.updateSessionsWithTimelineChange(eventTime); + } else if (event == EVENT_POSITION_DISCONTINUITY) { + sessionManager.updateSessionsWithDiscontinuity(eventTime, discontinuityReason); + } else { + sessionManager.updateSessions(eventTime); + } + } + } + + private void maybeUpdateMetricsBuilderValues(Player player, Events events) { + if (events.contains(EVENT_TIMELINE_CHANGED)) { + EventTime eventTime = events.getEventTime(EVENT_TIMELINE_CHANGED); + if (metricsBuilder != null) { + maybeUpdateTimelineMetadata(eventTime.timeline, eventTime.mediaPeriodId); + } + } + if (events.contains(EVENT_TRACKS_CHANGED) && metricsBuilder != null) { + @Nullable + DrmInitData drmInitData = getDrmInitData(player.getCurrentTracksInfo().getTrackGroupInfos()); + if (drmInitData != null) { + castNonNull(metricsBuilder).setDrmType(getDrmType(drmInitData)); + } + } + if (events.contains(EVENT_AUDIO_UNDERRUN)) { + audioUnderruns++; + } + } + + private void maybeReportPlaybackError(long realtimeMs) { + @Nullable PlaybackException error = pendingPlayerError; + if (error == null) { + return; + } + ErrorInfo errorInfo = + getErrorInfo( + error, context, /* lastIoErrorForManifest= */ ioErrorType == C.DATA_TYPE_MANIFEST); + playbackSession.reportPlaybackErrorEvent( + new PlaybackErrorEvent.Builder() + .setTimeSinceCreatedMillis(realtimeMs - startTimeMs) + .setErrorCode(errorInfo.errorCode) + .setSubErrorCode(errorInfo.subErrorCode) + .setException(error) + .build()); + pendingPlayerError = null; + } + + private void maybeReportTrackChanges(Player player, Events events, long realtimeMs) { + if (events.contains(EVENT_TRACKS_CHANGED)) { + TracksInfo tracksInfo = player.getCurrentTracksInfo(); + boolean isVideoSelected = tracksInfo.isTypeSelected(C.TRACK_TYPE_VIDEO); + boolean isAudioSelected = tracksInfo.isTypeSelected(C.TRACK_TYPE_AUDIO); + boolean isTextSelected = tracksInfo.isTypeSelected(C.TRACK_TYPE_TEXT); + if (isVideoSelected || isAudioSelected || isTextSelected) { + // Ignore updates with insufficient information where no tracks are selected. + if (!isVideoSelected) { + maybeUpdateVideoFormat(realtimeMs, /* videoFormat= */ null, C.SELECTION_REASON_UNKNOWN); + } + if (!isAudioSelected) { + maybeUpdateAudioFormat(realtimeMs, /* audioFormat= */ null, C.SELECTION_REASON_UNKNOWN); + } + if (!isTextSelected) { + maybeUpdateTextFormat(realtimeMs, /* textFormat= */ null, C.SELECTION_REASON_UNKNOWN); + } + } + } + if (canReportPendingFormatUpdate(pendingVideoFormat) + && pendingVideoFormat.format.height != Format.NO_VALUE) { + maybeUpdateVideoFormat( + realtimeMs, pendingVideoFormat.format, pendingVideoFormat.selectionReason); + pendingVideoFormat = null; + } + if (canReportPendingFormatUpdate(pendingAudioFormat)) { + maybeUpdateAudioFormat( + realtimeMs, pendingAudioFormat.format, pendingAudioFormat.selectionReason); + pendingAudioFormat = null; + } + if (canReportPendingFormatUpdate(pendingTextFormat)) { + maybeUpdateTextFormat( + realtimeMs, pendingTextFormat.format, pendingTextFormat.selectionReason); + pendingTextFormat = null; + } + } + + @EnsuresNonNullIf(result = true, expression = "#1") + private boolean canReportPendingFormatUpdate(@Nullable PendingFormatUpdate pendingFormatUpdate) { + return pendingFormatUpdate != null + && pendingFormatUpdate.sessionId.equals(sessionManager.getActiveSessionId()); + } + + private void maybeReportNetworkChange(long realtimeMs) { + int networkType = getNetworkType(context); + if (networkType != currentNetworkType) { + currentNetworkType = networkType; + playbackSession.reportNetworkEvent( + new NetworkEvent.Builder() + .setNetworkType(networkType) + .setTimeSinceCreatedMillis(realtimeMs - startTimeMs) + .build()); + } + } + + private void maybeReportPlaybackStateChange(Player player, Events events, long realtimeMs) { + if (player.getPlaybackState() != Player.STATE_BUFFERING) { + isSeeking = false; + } + if (player.getPlayerError() == null) { + hasFatalError = false; + } else if (events.contains(EVENT_PLAYER_ERROR)) { + hasFatalError = true; + } + int newPlaybackState = resolveNewPlaybackState(player); + if (currentPlaybackState != newPlaybackState) { + currentPlaybackState = newPlaybackState; + playbackSession.reportPlaybackStateEvent( + new PlaybackStateEvent.Builder() + .setState(currentPlaybackState) + .setTimeSinceCreatedMillis(realtimeMs - startTimeMs) + .build()); + } + } + + private int resolveNewPlaybackState(Player player) { + @Player.State int playerPlaybackState = player.getPlaybackState(); + if (isSeeking) { + // Seeking takes precedence over errors such that we report a seek while in error state. + return PlaybackStateEvent.STATE_SEEKING; + } else if (hasFatalError) { + return PlaybackStateEvent.STATE_FAILED; + } else if (playerPlaybackState == Player.STATE_ENDED) { + return PlaybackStateEvent.STATE_ENDED; + } else if (playerPlaybackState == Player.STATE_BUFFERING) { + if (currentPlaybackState == PlaybackStateEvent.STATE_NOT_STARTED + || currentPlaybackState == PlaybackStateEvent.STATE_JOINING_FOREGROUND) { + return PlaybackStateEvent.STATE_JOINING_FOREGROUND; + } + if (!player.getPlayWhenReady()) { + return PlaybackStateEvent.STATE_PAUSED_BUFFERING; + } + return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE + ? PlaybackStateEvent.STATE_SUPPRESSED_BUFFERING + : PlaybackStateEvent.STATE_BUFFERING; + } else if (playerPlaybackState == Player.STATE_READY) { + if (!player.getPlayWhenReady()) { + return PlaybackStateEvent.STATE_PAUSED; + } + return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE + ? PlaybackStateEvent.STATE_SUPPRESSED + : PlaybackStateEvent.STATE_PLAYING; + } else if (playerPlaybackState == Player.STATE_IDLE + && currentPlaybackState != PlaybackStateEvent.STATE_NOT_STARTED) { + // This case only applies for calls to player.stop(). All other IDLE cases are handled by + // !isForeground, hasFatalError or isSuspended. NOT_STARTED is deliberately ignored. + return PlaybackStateEvent.STATE_STOPPED; + } + return currentPlaybackState; + } + + private void maybeUpdateVideoFormat( + long realtimeMs, @Nullable Format videoFormat, @C.SelectionReason int trackSelectionReason) { + if (Util.areEqual(currentVideoFormat, videoFormat)) { + return; + } + if (currentVideoFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) { + trackSelectionReason = C.SELECTION_REASON_INITIAL; + } + currentVideoFormat = videoFormat; + reportTrackChangeEvent( + TrackChangeEvent.TRACK_TYPE_VIDEO, realtimeMs, videoFormat, trackSelectionReason); + } + + private void maybeUpdateAudioFormat( + long realtimeMs, @Nullable Format audioFormat, @C.SelectionReason int trackSelectionReason) { + if (Util.areEqual(currentAudioFormat, audioFormat)) { + return; + } + if (currentAudioFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) { + trackSelectionReason = C.SELECTION_REASON_INITIAL; + } + currentAudioFormat = audioFormat; + reportTrackChangeEvent( + TrackChangeEvent.TRACK_TYPE_AUDIO, realtimeMs, audioFormat, trackSelectionReason); + } + + private void maybeUpdateTextFormat( + long realtimeMs, @Nullable Format textFormat, @C.SelectionReason int trackSelectionReason) { + if (Util.areEqual(currentTextFormat, textFormat)) { + return; + } + if (currentTextFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) { + trackSelectionReason = C.SELECTION_REASON_INITIAL; + } + currentTextFormat = textFormat; + reportTrackChangeEvent( + TrackChangeEvent.TRACK_TYPE_TEXT, realtimeMs, textFormat, trackSelectionReason); + } + + private void reportTrackChangeEvent( + int type, + long realtimeMs, + @Nullable Format format, + @C.SelectionReason int trackSelectionReason) { + TrackChangeEvent.Builder builder = + new TrackChangeEvent.Builder(type).setTimeSinceCreatedMillis(realtimeMs - startTimeMs); + if (format != null) { + builder.setTrackState(TrackChangeEvent.TRACK_STATE_ON); + builder.setTrackChangeReason(getTrackChangeReason(trackSelectionReason)); + if (format.containerMimeType != null) { + // TODO(b/181121074): Progressive container mime type is not filled in by MediaSource. + builder.setContainerMimeType(format.containerMimeType); + } + if (format.sampleMimeType != null) { + builder.setSampleMimeType(format.sampleMimeType); + } + if (format.codecs != null) { + builder.setCodecName(format.codecs); + } + if (format.bitrate != Format.NO_VALUE) { + builder.setBitrate(format.bitrate); + } + if (format.width != Format.NO_VALUE) { + builder.setWidth(format.width); + } + if (format.height != Format.NO_VALUE) { + builder.setHeight(format.height); + } + if (format.channelCount != Format.NO_VALUE) { + builder.setChannelCount(format.channelCount); + } + if (format.sampleRate != Format.NO_VALUE) { + builder.setAudioSampleRate(format.sampleRate); + } + if (format.language != null) { + Pair languageAndRegion = + getLanguageAndRegion(format.language); + builder.setLanguage(languageAndRegion.first); + if (languageAndRegion.second != null) { + builder.setLanguageRegion(languageAndRegion.second); + } + } + if (format.frameRate != Format.NO_VALUE) { + builder.setVideoFrameRate(format.frameRate); + } + } else { + builder.setTrackState(TrackChangeEvent.TRACK_STATE_OFF); + } + playbackSession.reportTrackChangeEvent(builder.build()); + } + + @RequiresNonNull("metricsBuilder") + private void maybeUpdateTimelineMetadata( + Timeline timeline, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + PlaybackMetrics.Builder metricsBuilder = this.metricsBuilder; + if (mediaPeriodId == null) { + return; + } + int periodIndex = timeline.getIndexOfPeriod(mediaPeriodId.periodUid); + if (periodIndex == C.INDEX_UNSET) { + return; + } + timeline.getPeriod(periodIndex, period); + timeline.getWindow(period.windowIndex, window); + metricsBuilder.setStreamType(getStreamType(window.mediaItem)); + if (window.durationUs != C.TIME_UNSET + && !window.isPlaceholder + && !window.isDynamic + && !window.isLive()) { + metricsBuilder.setMediaDurationMillis(window.getDurationMs()); + } + metricsBuilder.setPlaybackType( + window.isLive() ? PlaybackMetrics.PLAYBACK_TYPE_LIVE : PlaybackMetrics.PLAYBACK_TYPE_VOD); + } + + private void finishCurrentSession() { + if (metricsBuilder == null) { + return; + } + metricsBuilder.setAudioUnderrunCount(audioUnderruns); + metricsBuilder.setVideoFramesDropped(droppedFrames); + metricsBuilder.setVideoFramesPlayed(playedFrames); + metricsBuilder.setNetworkTransferDurationMillis(bandwidthTimeMs); + // TODO(b/181121847): Report localBytesRead. This requires additional callbacks or plumbing. + metricsBuilder.setNetworkBytesRead(bandwidthBytes); + // TODO(b/181121847): Detect stream sources mixed and local depending on localBytesRead. + metricsBuilder.setStreamSource( + bandwidthBytes > 0 + ? PlaybackMetrics.STREAM_SOURCE_NETWORK + : PlaybackMetrics.STREAM_SOURCE_UNKNOWN); + playbackSession.reportPlaybackMetrics(metricsBuilder.build()); + metricsBuilder = null; + } + + private static int getTrackChangeReason(@C.SelectionReason int trackSelectionReason) { + switch (trackSelectionReason) { + case C.SELECTION_REASON_INITIAL: + return TrackChangeEvent.TRACK_CHANGE_REASON_INITIAL; + case C.SELECTION_REASON_ADAPTIVE: + return TrackChangeEvent.TRACK_CHANGE_REASON_ADAPTIVE; + case C.SELECTION_REASON_MANUAL: + return TrackChangeEvent.TRACK_CHANGE_REASON_MANUAL; + case C.SELECTION_REASON_TRICK_PLAY: + case C.SELECTION_REASON_UNKNOWN: + default: + return TrackChangeEvent.TRACK_CHANGE_REASON_OTHER; + } + } + + private static Pair getLanguageAndRegion(String languageCode) { + String[] parts = Util.split(languageCode, "-"); + return Pair.create(parts[0], parts.length >= 2 ? parts[1] : null); + } + + private static int getNetworkType(Context context) { + switch (NetworkTypeObserver.getInstance(context).getNetworkType()) { + case C.NETWORK_TYPE_WIFI: + return NetworkEvent.NETWORK_TYPE_WIFI; + case C.NETWORK_TYPE_2G: + return NetworkEvent.NETWORK_TYPE_2G; + case C.NETWORK_TYPE_3G: + return NetworkEvent.NETWORK_TYPE_3G; + case C.NETWORK_TYPE_4G: + return NetworkEvent.NETWORK_TYPE_4G; + case C.NETWORK_TYPE_5G_SA: + return NetworkEvent.NETWORK_TYPE_5G_SA; + case C.NETWORK_TYPE_5G_NSA: + return NetworkEvent.NETWORK_TYPE_5G_NSA; + case C.NETWORK_TYPE_ETHERNET: + return NetworkEvent.NETWORK_TYPE_ETHERNET; + case C.NETWORK_TYPE_OFFLINE: + return NetworkEvent.NETWORK_TYPE_OFFLINE; + case C.NETWORK_TYPE_UNKNOWN: + return NetworkEvent.NETWORK_TYPE_UNKNOWN; + default: + return NetworkEvent.NETWORK_TYPE_OTHER; + } + } + + private static int getStreamType(MediaItem mediaItem) { + if (mediaItem.localConfiguration == null || mediaItem.localConfiguration.mimeType == null) { + return PlaybackMetrics.STREAM_TYPE_UNKNOWN; + } + String mimeType = mediaItem.localConfiguration.mimeType; + switch (mimeType) { + case MimeTypes.APPLICATION_M3U8: + return PlaybackMetrics.STREAM_TYPE_HLS; + case MimeTypes.APPLICATION_MPD: + return PlaybackMetrics.STREAM_TYPE_DASH; + case MimeTypes.APPLICATION_SS: + return PlaybackMetrics.STREAM_TYPE_SS; + default: + return PlaybackMetrics.STREAM_TYPE_PROGRESSIVE; + } + } + + private static ErrorInfo getErrorInfo( + PlaybackException error, Context context, boolean lastIoErrorForManifest) { + if (error.errorCode == PlaybackException.ERROR_CODE_REMOTE_ERROR) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_PLAYER_REMOTE, /* subErrorCode= */ 0); + } + // Unpack the PlaybackException. + // TODO(b/190203080): Use error codes instead of the Exception's cause where possible. + boolean isRendererExoPlaybackException = false; + int rendererFormatSupport = C.FORMAT_UNSUPPORTED_TYPE; + if (error instanceof ExoPlaybackException) { + ExoPlaybackException exoPlaybackException = (ExoPlaybackException) error; + isRendererExoPlaybackException = + exoPlaybackException.type == ExoPlaybackException.TYPE_RENDERER; + rendererFormatSupport = exoPlaybackException.rendererFormatSupport; + } + Throwable cause = checkNotNull(error.getCause()); + if (cause instanceof IOException) { + if (cause instanceof HttpDataSource.InvalidResponseCodeException) { + int responseCode = ((HttpDataSource.InvalidResponseCodeException) cause).responseCode; + return new ErrorInfo( + PlaybackErrorEvent.ERROR_IO_BAD_HTTP_STATUS, /* subErrorCode= */ responseCode); + } else if (cause instanceof HttpDataSource.InvalidContentTypeException + || cause instanceof ParserException) { + return new ErrorInfo( + lastIoErrorForManifest + ? PlaybackErrorEvent.ERROR_PARSING_MANIFEST_MALFORMED + : PlaybackErrorEvent.ERROR_PARSING_CONTAINER_MALFORMED, + /* subErrorCode= */ 0); + } else if (cause instanceof HttpDataSource.HttpDataSourceException + || cause instanceof UdpDataSource.UdpDataSourceException) { + if (NetworkTypeObserver.getInstance(context).getNetworkType() == C.NETWORK_TYPE_OFFLINE) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_IO_NETWORK_UNAVAILABLE, /* subErrorCode= */ 0); + } else { + @Nullable Throwable detailedCause = cause.getCause(); + if (detailedCause instanceof UnknownHostException) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_DNS_FAILED, /* subErrorCode= */ 0); + } else if (detailedCause instanceof SocketTimeoutException) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_IO_CONNECTION_TIMEOUT, /* subErrorCode= */ 0); + } else if (cause instanceof HttpDataSource.HttpDataSourceException + && ((HttpDataSource.HttpDataSourceException) cause).type + == HttpDataSource.HttpDataSourceException.TYPE_OPEN) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_IO_NETWORK_CONNECTION_FAILED, /* subErrorCode= */ 0); + } else { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_IO_CONNECTION_CLOSED, /* subErrorCode= */ 0); + } + } + } else if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_PLAYER_BEHIND_LIVE_WINDOW, /* subErrorCode= */ 0); + } else if (cause instanceof DrmSession.DrmSessionException) { + // Unpack DrmSessionException. + cause = checkNotNull(cause.getCause()); + if (Util.SDK_INT >= 21 && cause instanceof MediaDrm.MediaDrmStateException) { + String diagnosticsInfo = ((MediaDrm.MediaDrmStateException) cause).getDiagnosticInfo(); + int subErrorCode = Util.getErrorCodeFromPlatformDiagnosticsInfo(diagnosticsInfo); + int errorCode = getDrmErrorCode(subErrorCode); + return new ErrorInfo(errorCode, subErrorCode); + } else if (Util.SDK_INT >= 23 && cause instanceof MediaDrmResetException) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_SYSTEM_ERROR, /* subErrorCode= */ 0); + } else if (Util.SDK_INT >= 18 && cause instanceof NotProvisionedException) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_DRM_PROVISIONING_FAILED, /* subErrorCode= */ 0); + } else if (Util.SDK_INT >= 18 && cause instanceof DeniedByServerException) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_DEVICE_REVOKED, /* subErrorCode= */ 0); + } else if (cause instanceof UnsupportedDrmException) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_DRM_SCHEME_UNSUPPORTED, /* subErrorCode= */ 0); + } else if (cause instanceof DefaultDrmSessionManager.MissingSchemeDataException) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_CONTENT_ERROR, /* subErrorCode= */ 0); + } else { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_OTHER, /* subErrorCode= */ 0); + } + } else if (cause instanceof FileDataSource.FileDataSourceException + && cause.getCause() instanceof FileNotFoundException) { + @Nullable Throwable notFoundCause = checkNotNull(cause.getCause()).getCause(); + if (Util.SDK_INT >= 21 + && notFoundCause instanceof ErrnoException + && ((ErrnoException) notFoundCause).errno == OsConstants.EACCES) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_NO_PERMISSION, /* subErrorCode= */ 0); + } else { + return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_FILE_NOT_FOUND, /* subErrorCode= */ 0); + } + } else { + return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_OTHER, /* subErrorCode= */ 0); + } + } else if (isRendererExoPlaybackException + && (rendererFormatSupport == C.FORMAT_UNSUPPORTED_TYPE + || rendererFormatSupport == C.FORMAT_UNSUPPORTED_SUBTYPE)) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_DECODING_FORMAT_UNSUPPORTED, /* subErrorCode= */ 0); + } else if (isRendererExoPlaybackException + && rendererFormatSupport == C.FORMAT_EXCEEDS_CAPABILITIES) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_DECODING_FORMAT_EXCEEDS_CAPABILITIES, /* subErrorCode= */ 0); + } else if (isRendererExoPlaybackException + && rendererFormatSupport == C.FORMAT_UNSUPPORTED_DRM) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_SCHEME_UNSUPPORTED, /* subErrorCode= */ 0); + } else if (cause instanceof MediaCodecRenderer.DecoderInitializationException) { + @Nullable + String diagnosticsInfo = + ((MediaCodecRenderer.DecoderInitializationException) cause).diagnosticInfo; + int subErrorCode = Util.getErrorCodeFromPlatformDiagnosticsInfo(diagnosticsInfo); + return new ErrorInfo(PlaybackErrorEvent.ERROR_DECODER_INIT_FAILED, subErrorCode); + } else if (cause instanceof MediaCodecDecoderException) { + @Nullable String diagnosticsInfo = ((MediaCodecDecoderException) cause).diagnosticInfo; + int subErrorCode = Util.getErrorCodeFromPlatformDiagnosticsInfo(diagnosticsInfo); + return new ErrorInfo(PlaybackErrorEvent.ERROR_DECODING_FAILED, subErrorCode); + } else if (cause instanceof OutOfMemoryError) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DECODING_FAILED, /* subErrorCode= */ 0); + } else if (cause instanceof AudioSink.InitializationException) { + int subErrorCode = ((AudioSink.InitializationException) cause).audioTrackState; + return new ErrorInfo(PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, subErrorCode); + } else if (cause instanceof AudioSink.WriteException) { + int subErrorCode = ((AudioSink.WriteException) cause).errorCode; + return new ErrorInfo(PlaybackErrorEvent.ERROR_AUDIO_TRACK_WRITE_FAILED, subErrorCode); + } else if (Util.SDK_INT >= 16 && cause instanceof MediaCodec.CryptoException) { + int subErrorCode = ((MediaCodec.CryptoException) cause).getErrorCode(); + int errorCode = getDrmErrorCode(subErrorCode); + return new ErrorInfo(errorCode, subErrorCode); + } else { + return new ErrorInfo(PlaybackErrorEvent.ERROR_PLAYER_OTHER, /* subErrorCode= */ 0); + } + } + + @Nullable + private static DrmInitData getDrmInitData(ImmutableList trackGroupInfos) { + for (TrackGroupInfo trackGroupInfo : trackGroupInfos) { + TrackGroup trackGroup = trackGroupInfo.getTrackGroup(); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (trackGroupInfo.isTrackSelected(trackIndex)) { + @Nullable DrmInitData drmInitData = trackGroup.getFormat(trackIndex).drmInitData; + if (drmInitData != null) { + return drmInitData; + } + } + } + } + return null; + } + + private static int getDrmType(DrmInitData drmInitData) { + for (int i = 0; i < drmInitData.schemeDataCount; i++) { + UUID uuid = drmInitData.get(i).uuid; + if (uuid.equals(C.WIDEVINE_UUID)) { + // TODO(b/77625596): Forward MediaDrm metrics to distinguish between L1 and L3 and to set + // the drm session id. + return PlaybackMetrics.DRM_TYPE_WIDEVINE_L1; + } + if (uuid.equals(C.PLAYREADY_UUID)) { + return PlaybackMetrics.DRM_TYPE_PLAY_READY; + } + if (uuid.equals(C.CLEARKEY_UUID)) { + return PlaybackMetrics.DRM_TYPE_CLEARKEY; + } + } + return PlaybackMetrics.DRM_TYPE_OTHER; + } + + @SuppressLint("SwitchIntDef") // Only DRM error codes are relevant here. + private static int getDrmErrorCode(int mediaDrmErrorCode) { + switch (Util.getErrorCodeForMediaDrmErrorCode(mediaDrmErrorCode)) { + case PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED: + return PlaybackErrorEvent.ERROR_DRM_PROVISIONING_FAILED; + case PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED: + return PlaybackErrorEvent.ERROR_DRM_LICENSE_ACQUISITION_FAILED; + case PlaybackException.ERROR_CODE_DRM_DISALLOWED_OPERATION: + return PlaybackErrorEvent.ERROR_DRM_DISALLOWED_OPERATION; + case PlaybackException.ERROR_CODE_DRM_CONTENT_ERROR: + return PlaybackErrorEvent.ERROR_DRM_CONTENT_ERROR; + case PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR: + default: + return PlaybackErrorEvent.ERROR_DRM_SYSTEM_ERROR; + } + } + + private static final class ErrorInfo { + + public final int errorCode; + public final int subErrorCode; + + public ErrorInfo(int errorCode, int subErrorCode) { + this.errorCode = errorCode; + this.subErrorCode = subErrorCode; + } + } + + private static final class PendingFormatUpdate { + + public final Format format; + @C.SelectionReason public final int selectionReason; + public final String sessionId; + + public PendingFormatUpdate( + Format format, @C.SelectionReason int selectionReason, String sessionId) { + this.format = format; + this.selectionReason = selectionReason; + this.sessionId = sessionId; + } + } +} From ff9585f153ce48297e9a9fb85743c511574aa903 Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 22 Nov 2021 11:51:56 +0000 Subject: [PATCH 08/56] Provide an opt-out from clearing media items on stop After removing the deprecated call to player.stop(/* reset= */ true) and instead using two calls to the player, overridding stop in a ForwardingPlayer does not help to avoid clearing the player. To remove the deprecation we need an option so that users who want not to clear the player have a way to do so. PiperOrigin-RevId: 411518090 --- RELEASENOTES.md | 5 +++++ .../ext/mediasession/MediaSessionConnector.java | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 704d6f54bf..3b34c286c7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -14,6 +14,11 @@ * HLS: * Support key-frame accurate seeking in HLS ([#2882](https://github.com/google/ExoPlayer/issues/2882)). + * Correctly populate `Format.label` for audio only HLS streams + ([#9608](https://github.com/google/ExoPlayer/issues/9608)). +* MediaSession extension + * Remove deprecated call to `onStop(/* reset= */ true)` and provide an + opt-out flag for apps that don't want to clear the playlist on stop. ### 2.16.1 (2021-11-18) diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java index 751a850fbe..85b141eba8 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java @@ -466,6 +466,7 @@ public final class MediaSessionConnector { private long enabledPlaybackActions; private boolean metadataDeduplicationEnabled; private boolean dispatchUnsupportedActionsEnabled; + private boolean clearMediaItemsOnStop; /** * Creates an instance. @@ -486,6 +487,7 @@ public final class MediaSessionConnector { enabledPlaybackActions = DEFAULT_PLAYBACK_ACTIONS; mediaSession.setFlags(BASE_MEDIA_SESSION_FLAGS); mediaSession.setCallback(componentListener, new Handler(looper)); + clearMediaItemsOnStop = true; } /** @@ -699,6 +701,14 @@ public final class MediaSessionConnector { this.dispatchUnsupportedActionsEnabled = dispatchUnsupportedActionsEnabled; } + /** + * Sets whether media items are cleared from the playlist when a client sends a {@link + * MediaControllerCompat.TransportControls#stop()} command. + */ + public void setClearMediaItemsOnStop(boolean clearMediaItemsOnStop) { + this.clearMediaItemsOnStop = clearMediaItemsOnStop; + } + /** * Sets whether {@link MediaMetadataProvider#sameAs(MediaMetadataCompat, MediaMetadataCompat)} * should be consulted before calling {@link MediaSessionCompat#setMetadata(MediaMetadataCompat)}. @@ -1208,7 +1218,10 @@ public final class MediaSessionConnector { @Override public void onStop() { if (canDispatchPlaybackAction(PlaybackStateCompat.ACTION_STOP)) { - player.stop(/* reset= */ true); + player.stop(); + if (clearMediaItemsOnStop) { + player.clearMediaItems(); + } } } From 90cb02c942c91283a9019757971205bd0bc8e335 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 22 Nov 2021 12:43:54 +0000 Subject: [PATCH 09/56] Add a 120s timeout to transformer running within instrumentation tests. PiperOrigin-RevId: 411526089 --- .../transformer/AndroidTestUtil.java | 26 +++++++++++++++---- .../RemoveAudioTransformationTest.java | 4 +-- .../RemoveVideoTransformationTest.java | 4 +-- .../RepeatedTranscodeTransformationTest.java | 6 ++++- .../transformer/SefTransformationTest.java | 4 +-- .../transformer/TransformationTest.java | 4 +-- 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java index 3a6b148b88..18a933bcf6 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.transformer; +import static com.google.common.truth.Truth.assertWithMessage; +import static java.util.concurrent.TimeUnit.SECONDS; + import android.content.Context; import android.net.Uri; import androidx.annotation.Nullable; @@ -29,8 +32,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** Utilities for instrumentation tests. */ /* package */ final class AndroidTestUtil { - public static final String MP4_ASSET_URI = "asset:///media/mp4/sample.mp4"; - public static final String SEF_ASSET_URI = "asset:///media/mp4/sample_sef_slow_motion.mp4"; + public static final String MP4_ASSET_URI_STRING = "asset:///media/mp4/sample.mp4"; + public static final String SEF_ASSET_URI_STRING = "asset:///media/mp4/sample_sef_slow_motion.mp4"; public static final String REMOTE_MP4_10_SECONDS_URI_STRING = "https://storage.googleapis.com/exoplayer-test-media-1/mp4/android-screens-10s.mp4"; @@ -43,9 +46,19 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } } - /** Transforms the {@code uriString} with the {@link Transformer}. */ + /** + * Transforms the {@code uriString} with the {@link Transformer}. + * + * @param context The {@link Context}. + * @param transformer The {@link Transformer} that performs the transformation. + * @param uriString The uri (as a {@link String}) that will be transformed. + * @param timeoutSeconds The transformer timeout. An assertion confirms this is not exceeded. + * @return The {@link TransformationResult}. + * @throws Exception The cause of the transformation not completing. + */ public static TransformationResult runTransformer( - Context context, Transformer transformer, String uriString) throws Exception { + Context context, Transformer transformer, String uriString, int timeoutSeconds) + throws Exception { AtomicReference<@NullableType Exception> exceptionReference = new AtomicReference<>(); CountDownLatch countDownLatch = new CountDownLatch(1); @@ -80,7 +93,10 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; exceptionReference.set(e); } }); - countDownLatch.await(); + + assertWithMessage("Transformer timed out after " + timeoutSeconds + " seconds.") + .that(countDownLatch.await(timeoutSeconds, SECONDS)) + .isTrue(); @Nullable Exception exception = exceptionReference.get(); if (exception != null) { throw exception; diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java index b8c7bd923b..a1f92decb0 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.transformer; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.MP4_ASSET_URI; +import static com.google.android.exoplayer2.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING; import static com.google.android.exoplayer2.transformer.AndroidTestUtil.runTransformer; import android.content.Context; @@ -32,6 +32,6 @@ public class RemoveAudioTransformationTest { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = new Transformer.Builder().setContext(context).setRemoveAudio(true).build(); - runTransformer(context, transformer, MP4_ASSET_URI); + runTransformer(context, transformer, MP4_ASSET_URI_STRING, /* timeoutSeconds= */ 120); } } diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java index 7a27887c93..98bc01a299 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.transformer; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.MP4_ASSET_URI; +import static com.google.android.exoplayer2.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING; import static com.google.android.exoplayer2.transformer.AndroidTestUtil.runTransformer; import android.content.Context; @@ -32,6 +32,6 @@ public class RemoveVideoTransformationTest { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = new Transformer.Builder().setContext(context).setRemoveVideo(true).build(); - runTransformer(context, transformer, MP4_ASSET_URI); + runTransformer(context, transformer, MP4_ASSET_URI_STRING, /* timeoutSeconds= */ 120); } } diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java index 1cee83a14e..aa662fd035 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java @@ -47,7 +47,11 @@ public final class RepeatedTranscodeTransformationTest { for (int i = 0; i < TRANSCODE_COUNT; i++) { // Use a long video in case an error occurs a while after the start of the video. long outputSizeBytes = - runTransformer(context, transformer, AndroidTestUtil.REMOTE_MP4_10_SECONDS_URI_STRING) + runTransformer( + context, + transformer, + AndroidTestUtil.REMOTE_MP4_10_SECONDS_URI_STRING, + /* timeoutSeconds= */ 120) .outputSizeBytes; if (previousOutputSizeBytes != C.LENGTH_UNSET) { assertWithMessage("Unexpected output size on transcode " + i + " out of " + TRANSCODE_COUNT) diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java index 5b311443b4..33a6e282d8 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.transformer; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.SEF_ASSET_URI; +import static com.google.android.exoplayer2.transformer.AndroidTestUtil.SEF_ASSET_URI_STRING; import static com.google.android.exoplayer2.transformer.AndroidTestUtil.runTransformer; import android.content.Context; @@ -32,6 +32,6 @@ public class SefTransformationTest { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = new Transformer.Builder().setContext(context).setFlattenForSlowMotion(true).build(); - runTransformer(context, transformer, SEF_ASSET_URI); + runTransformer(context, transformer, SEF_ASSET_URI_STRING, /* timeoutSeconds= */ 120); } } diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java index 20fe021ff2..246b7a4b74 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java @@ -15,7 +15,7 @@ */ package com.google.android.exoplayer2.transformer; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.MP4_ASSET_URI; +import static com.google.android.exoplayer2.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING; import static com.google.android.exoplayer2.transformer.AndroidTestUtil.runTransformer; import android.content.Context; @@ -31,6 +31,6 @@ public class TransformationTest { public void transform() throws Exception { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = new Transformer.Builder().setContext(context).build(); - runTransformer(context, transformer, MP4_ASSET_URI); + runTransformer(context, transformer, MP4_ASSET_URI_STRING, /* timeoutSeconds= */ 120); } } From 7f8067aa6e576c664cf0bdd6f1ea516901b093cf Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 22 Nov 2021 13:02:14 +0000 Subject: [PATCH 10/56] Change RepeatedTranscode test to attempt no audio or no video. Test failure message now also reports the number of different sizes. PiperOrigin-RevId: 411529648 --- .../RepeatedTranscodeTransformationTest.java | 79 ++++++++++++++++--- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java index aa662fd035..1270ebba67 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java @@ -21,8 +21,9 @@ import static com.google.common.truth.Truth.assertWithMessage; import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.util.MimeTypes; +import java.util.HashSet; +import java.util.Set; import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; @@ -39,26 +40,82 @@ public final class RepeatedTranscodeTransformationTest { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = new Transformer.Builder() - .setVideoMimeType(MimeTypes.VIDEO_H265) .setContext(context) + .setVideoMimeType(MimeTypes.VIDEO_H265) + .setAudioMimeType(MimeTypes.AUDIO_AMR_NB) .build(); - long previousOutputSizeBytes = C.LENGTH_UNSET; + Set differentOutputSizesBytes = new HashSet<>(); for (int i = 0; i < TRANSCODE_COUNT; i++) { // Use a long video in case an error occurs a while after the start of the video. - long outputSizeBytes = + differentOutputSizesBytes.add( runTransformer( context, transformer, AndroidTestUtil.REMOTE_MP4_10_SECONDS_URI_STRING, /* timeoutSeconds= */ 120) - .outputSizeBytes; - if (previousOutputSizeBytes != C.LENGTH_UNSET) { - assertWithMessage("Unexpected output size on transcode " + i + " out of " + TRANSCODE_COUNT) - .that(outputSizeBytes) - .isEqualTo(previousOutputSizeBytes); - } - previousOutputSizeBytes = outputSizeBytes; + .outputSizeBytes); } + + assertWithMessage( + "Different transcoding output sizes detected. Sizes: " + differentOutputSizesBytes) + .that(differentOutputSizesBytes.size()) + .isEqualTo(1); + } + + @Test + public void repeatedTranscodeNoAudio_givesConsistentLengthOutput() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + Transformer transformer = + new Transformer.Builder() + .setContext(context) + .setVideoMimeType(MimeTypes.VIDEO_H265) + .setRemoveAudio(true) + .build(); + + Set differentOutputSizesBytes = new HashSet<>(); + for (int i = 0; i < TRANSCODE_COUNT; i++) { + // Use a long video in case an error occurs a while after the start of the video. + differentOutputSizesBytes.add( + runTransformer( + context, + transformer, + AndroidTestUtil.REMOTE_MP4_10_SECONDS_URI_STRING, + /* timeoutSeconds= */ 120) + .outputSizeBytes); + } + + assertWithMessage( + "Different transcoding output sizes detected. Sizes: " + differentOutputSizesBytes) + .that(differentOutputSizesBytes.size()) + .isEqualTo(1); + } + + @Test + public void repeatedTranscodeNoVideo_givesConsistentLengthOutput() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + Transformer transcodingTransformer = + new Transformer.Builder() + .setContext(context) + .setAudioMimeType(MimeTypes.AUDIO_AMR_NB) + .setRemoveVideo(true) + .build(); + + Set differentOutputSizesBytes = new HashSet<>(); + for (int i = 0; i < TRANSCODE_COUNT; i++) { + // Use a long video in case an error occurs a while after the start of the video. + differentOutputSizesBytes.add( + runTransformer( + context, + transcodingTransformer, + AndroidTestUtil.REMOTE_MP4_10_SECONDS_URI_STRING, + /* timeoutSeconds= */ 120) + .outputSizeBytes); + } + + assertWithMessage( + "Different transcoding output sizes detected. Sizes: " + differentOutputSizesBytes) + .that(differentOutputSizesBytes.size()) + .isEqualTo(1); } } From a4368beb7b269d821b89fe71d2a235444b3b37e2 Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 22 Nov 2021 16:38:38 +0000 Subject: [PATCH 11/56] Add aquaman to devices needing setOutputSurface workaround. Issue: google/ExoPlayer#9710 PiperOrigin-RevId: 411568601 --- .../android/exoplayer2/video/MediaCodecVideoRenderer.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java index 55b6359fef..be506ae398 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -1694,7 +1694,9 @@ public class MediaCodecVideoRenderer extends MediaCodecRenderer { // https://github.com/google/ExoPlayer/issues/6899. // https://github.com/google/ExoPlayer/issues/8014. // https://github.com/google/ExoPlayer/issues/8329. + // https://github.com/google/ExoPlayer/issues/9710. switch (Util.DEVICE) { + case "aquaman": case "dangal": case "dangalUHD": case "dangalFHD": From 92c971ecd0ad4196e3ad48d1fd7f016d7e19eb3f Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 22 Nov 2021 19:28:40 +0000 Subject: [PATCH 12/56] Don't drop updates of the playing period for skipped SSI ads Before this change ExpPlayerImplInternal dropped a change of the playing period when a change in the timeline occurred that actually changed the playing period but we don't want to update the period queue. This logic also dropped the update of a skipped server side inserted preroll ad for which we want the periodQueue to 'seek' to the stream position after the preroll ad and trigger a SKIP discontinuity. This change now introduces an exception so that a skipped SSI ad is still causing an update in the period queue which leads to a 'seek' and a discontinuity of type SKIP. PiperOrigin-RevId: 411607299 --- .../google/android/exoplayer2/Timeline.java | 16 ++++++++ .../exoplayer2/ExoPlayerImplInternal.java | 40 +++++++++++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java index 8b6e734e68..cf77941844 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2; +import static com.google.android.exoplayer2.source.ads.AdPlaybackState.AD_STATE_UNAVAILABLE; import static com.google.android.exoplayer2.util.Assertions.checkArgument; import static com.google.android.exoplayer2.util.Assertions.checkState; import static java.lang.Math.max; @@ -815,6 +816,21 @@ public abstract class Timeline implements Bundleable { return adGroup.count != C.LENGTH_UNSET ? adGroup.durationsUs[adIndexInAdGroup] : C.TIME_UNSET; } + /** + * Returns the state of the ad at index {@code adIndexInAdGroup} in the ad group at {@code + * adGroupIndex}, or {@link AdPlaybackState#AD_STATE_UNAVAILABLE} if not yet known. + * + * @param adGroupIndex The ad group index. + * @return The state of the ad, or {@link AdPlaybackState#AD_STATE_UNAVAILABLE} if not yet + * known. + */ + public int getAdState(int adGroupIndex, int adIndexInAdGroup) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + return adGroup.count != C.LENGTH_UNSET + ? adGroup.states[adIndexInAdGroup] + : AD_STATE_UNAVAILABLE; + } + /** * Returns the position offset in the first unplayed ad at which to begin playback, in * microseconds. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index ca1ae359f5..ae8063516a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -44,6 +44,7 @@ import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.text.TextRenderer; import com.google.android.exoplayer2.trackselection.ExoTrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelector; @@ -2648,15 +2649,14 @@ import java.util.concurrent.atomic.AtomicBoolean; && earliestCuePointIsUnchangedOrLater; // Drop update if the change is from/to server-side inserted ads at the same content position to // avoid any unintentional renderer reset. - timeline.getPeriodByUid(newPeriodUid, period); boolean isInStreamAdChange = - sameOldAndNewPeriodUid - && !isUsingPlaceholderPeriod - && oldContentPositionUs == newContentPositionUs - && ((periodIdWithAds.isAd() - && period.isServerSideInsertedAdGroup(periodIdWithAds.adGroupIndex)) - || (oldPeriodId.isAd() - && period.isServerSideInsertedAdGroup(oldPeriodId.adGroupIndex))); + isIgnorableServerSideAdInsertionPeriodChange( + isUsingPlaceholderPeriod, + oldPeriodId, + oldContentPositionUs, + periodIdWithAds, + timeline.getPeriodByUid(newPeriodUid, period), + newContentPositionUs); MediaPeriodId newPeriodId = onlyNextAdGroupIndexIncreased || isInStreamAdChange ? oldPeriodId : periodIdWithAds; @@ -2682,6 +2682,30 @@ import java.util.concurrent.atomic.AtomicBoolean; setTargetLiveOffset); } + private static boolean isIgnorableServerSideAdInsertionPeriodChange( + boolean isUsingPlaceholderPeriod, + MediaPeriodId oldPeriodId, + long oldContentPositionUs, + MediaPeriodId newPeriodId, + Timeline.Period newPeriod, + long newContentPositionUs) { + if (isUsingPlaceholderPeriod + || oldContentPositionUs != newContentPositionUs + || !oldPeriodId.periodUid.equals(newPeriodId.periodUid)) { + // The period position changed. + return false; + } + if (oldPeriodId.isAd() && newPeriod.isServerSideInsertedAdGroup(oldPeriodId.adGroupIndex)) { + // Whether the old period was a server side ad that doesn't need skipping to the content. + return newPeriod.getAdState(oldPeriodId.adGroupIndex, oldPeriodId.adIndexInAdGroup) + != AdPlaybackState.AD_STATE_ERROR + && newPeriod.getAdState(oldPeriodId.adGroupIndex, oldPeriodId.adIndexInAdGroup) + != AdPlaybackState.AD_STATE_SKIPPED; + } + // If the new period is a server side inserted ad, we can just continue playing. + return newPeriodId.isAd() && newPeriod.isServerSideInsertedAdGroup(newPeriodId.adGroupIndex); + } + private static boolean isUsingPlaceholderPeriod( PlaybackInfo playbackInfo, Timeline.Period period) { MediaPeriodId periodId = playbackInfo.periodId; From cbceb2a275c29ddc044d42b9896b8d0d49ba305f Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 22 Nov 2021 23:06:37 +0000 Subject: [PATCH 13/56] Rename ServerSideInsertedAdMediaSource et al PiperOrigin-RevId: 411657479 --- ... => ServerSideAdInsertionMediaSource.java} | 22 ++++++++-------- ...il.java => ServerSideAdInsertionUtil.java} | 4 +-- ...ServerSideAdInsertionMediaSourceTest.java} | 26 +++++++++---------- ...ava => ServerSideAdInsertionUtilTest.java} | 16 ++++++------ 4 files changed, 34 insertions(+), 34 deletions(-) rename library/core/src/main/java/com/google/android/exoplayer2/source/ads/{ServerSideInsertedAdsMediaSource.java => ServerSideAdInsertionMediaSource.java} (98%) rename library/core/src/main/java/com/google/android/exoplayer2/source/ads/{ServerSideInsertedAdsUtil.java => ServerSideAdInsertionUtil.java} (99%) rename library/core/src/test/java/com/google/android/exoplayer2/source/ads/{ServerSideInsertedAdMediaSourceTest.java => ServerSideAdInsertionMediaSourceTest.java} (96%) rename library/core/src/test/java/com/google/android/exoplayer2/source/ads/{ServerSideInsertedAdsUtilTest.java => ServerSideAdInsertionUtilTest.java} (98%) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionMediaSource.java similarity index 98% rename from library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsMediaSource.java rename to library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionMediaSource.java index 3a14510b8b..b040966714 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionMediaSource.java @@ -15,11 +15,11 @@ */ package com.google.android.exoplayer2.source.ads; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getAdCountInGroup; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getMediaPeriodPositionUs; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getMediaPeriodPositionUsForAd; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getMediaPeriodPositionUsForContent; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getStreamPositionUs; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.getAdCountInGroup; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.getMediaPeriodPositionUs; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.getMediaPeriodPositionUsForAd; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.getMediaPeriodPositionUsForContent; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.getStreamPositionUs; import static com.google.android.exoplayer2.util.Assertions.checkArgument; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Util.castNonNull; @@ -76,7 +76,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; *

The ad breaks need to be specified using {@link #setAdPlaybackState} and can be updated during * playback. */ -public final class ServerSideInsertedAdsMediaSource extends BaseMediaSource +public final class ServerSideAdInsertionMediaSource extends BaseMediaSource implements MediaSource.MediaSourceCaller, MediaSourceEventListener, DrmSessionEventListener { private final MediaSource mediaSource; @@ -99,7 +99,7 @@ public final class ServerSideInsertedAdsMediaSource extends BaseMediaSource */ // Calling BaseMediaSource.createEventDispatcher from the constructor. @SuppressWarnings("nullness:method.invocation") - public ServerSideInsertedAdsMediaSource(MediaSource mediaSource) { + public ServerSideAdInsertionMediaSource(MediaSource mediaSource) { this.mediaSource = mediaSource; mediaPeriods = ArrayListMultimap.create(); adPlaybackState = AdPlaybackState.NONE; @@ -148,7 +148,7 @@ public final class ServerSideInsertedAdsMediaSource extends BaseMediaSource this.adPlaybackState = adPlaybackState; if (contentTimeline != null) { refreshSourceInfo( - new ServerSideInsertedAdsTimeline(contentTimeline, adPlaybackState)); + new ServerSideAdInsertionTimeline(contentTimeline, adPlaybackState)); } }); } @@ -193,7 +193,7 @@ public final class ServerSideInsertedAdsMediaSource extends BaseMediaSource if (AdPlaybackState.NONE.equals(adPlaybackState)) { return; } - refreshSourceInfo(new ServerSideInsertedAdsTimeline(timeline, adPlaybackState)); + refreshSourceInfo(new ServerSideAdInsertionTimeline(timeline, adPlaybackState)); } @Override @@ -899,11 +899,11 @@ public final class ServerSideInsertedAdsMediaSource extends BaseMediaSource } } - private static final class ServerSideInsertedAdsTimeline extends ForwardingTimeline { + private static final class ServerSideAdInsertionTimeline extends ForwardingTimeline { private final AdPlaybackState adPlaybackState; - public ServerSideInsertedAdsTimeline( + public ServerSideAdInsertionTimeline( Timeline contentTimeline, AdPlaybackState adPlaybackState) { super(contentTimeline); Assertions.checkState(contentTimeline.getPeriodCount() == 1); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionUtil.java similarity index 99% rename from library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsUtil.java rename to library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionUtil.java index 48ba8da7aa..9b4d4710f9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionUtil.java @@ -26,9 +26,9 @@ import com.google.android.exoplayer2.source.MediaPeriodId; import com.google.android.exoplayer2.util.Util; /** A static utility class with methods to work with server-side inserted ads. */ -public final class ServerSideInsertedAdsUtil { +public final class ServerSideAdInsertionUtil { - private ServerSideInsertedAdsUtil() {} + private ServerSideAdInsertionUtil() {} /** * Adds a new server-side inserted ad group to an {@link AdPlaybackState}. diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdMediaSourceTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionMediaSourceTest.java similarity index 96% rename from library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdMediaSourceTest.java rename to library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionMediaSourceTest.java index 3c0dba79b6..fcb8d6d08c 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdMediaSourceTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionMediaSourceTest.java @@ -19,7 +19,7 @@ import static com.google.android.exoplayer2.robolectric.RobolectricUtil.runMainL import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.playUntilPosition; import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled; import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilPlaybackState; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.addAdGroupToAdPlaybackState; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -54,9 +54,9 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link ServerSideInsertedAdsMediaSource}. */ +/** Unit test for {@link ServerSideAdInsertionMediaSource}. */ @RunWith(AndroidJUnit4.class) -public final class ServerSideInsertedAdMediaSourceTest { +public final class ServerSideAdInsertionMediaSourceTest { @Rule public ShadowMediaCodecConfig mediaCodecConfig = @@ -80,8 +80,8 @@ public final class ServerSideInsertedAdMediaSourceTest { /* defaultPositionUs= */ 3_000_000, /* windowOffsetInFirstPeriodUs= */ 42_000_000L, AdPlaybackState.NONE)); - ServerSideInsertedAdsMediaSource mediaSource = - new ServerSideInsertedAdsMediaSource(new FakeMediaSource(wrappedTimeline)); + ServerSideAdInsertionMediaSource mediaSource = + new ServerSideAdInsertionMediaSource(new FakeMediaSource(wrappedTimeline)); // Test with one ad group before the window, and the window starting within the second ad group. AdPlaybackState adPlaybackState = new AdPlaybackState( @@ -152,8 +152,8 @@ public final class ServerSideInsertedAdMediaSourceTest { player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory); - ServerSideInsertedAdsMediaSource mediaSource = - new ServerSideInsertedAdsMediaSource( + ServerSideAdInsertionMediaSource mediaSource = + new ServerSideAdInsertionMediaSource( new DefaultMediaSourceFactory(context) .createMediaSource(MediaItem.fromUri(TEST_ASSET))); AdPlaybackState adPlaybackState = new AdPlaybackState(/* adsId= */ new Object()); @@ -211,8 +211,8 @@ public final class ServerSideInsertedAdMediaSourceTest { player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory); - ServerSideInsertedAdsMediaSource mediaSource = - new ServerSideInsertedAdsMediaSource( + ServerSideAdInsertionMediaSource mediaSource = + new ServerSideAdInsertionMediaSource( new DefaultMediaSourceFactory(context) .createMediaSource(MediaItem.fromUri(TEST_ASSET))); AdPlaybackState adPlaybackState = new AdPlaybackState(/* adsId= */ new Object()); @@ -271,8 +271,8 @@ public final class ServerSideInsertedAdMediaSourceTest { player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory); - ServerSideInsertedAdsMediaSource mediaSource = - new ServerSideInsertedAdsMediaSource( + ServerSideAdInsertionMediaSource mediaSource = + new ServerSideAdInsertionMediaSource( new DefaultMediaSourceFactory(context) .createMediaSource(MediaItem.fromUri(TEST_ASSET))); AdPlaybackState adPlaybackState = new AdPlaybackState(/* adsId= */ new Object()); @@ -325,8 +325,8 @@ public final class ServerSideInsertedAdMediaSourceTest { new ExoPlayer.Builder(context).setClock(new FakeClock(/* isAutoAdvancing= */ true)).build(); player.setVideoSurface(new Surface(new SurfaceTexture(/* texName= */ 1))); - ServerSideInsertedAdsMediaSource mediaSource = - new ServerSideInsertedAdsMediaSource( + ServerSideAdInsertionMediaSource mediaSource = + new ServerSideAdInsertionMediaSource( new DefaultMediaSourceFactory(context) .createMediaSource(MediaItem.fromUri(TEST_ASSET))); AdPlaybackState adPlaybackState = new AdPlaybackState(/* adsId= */ new Object()); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionUtilTest.java similarity index 98% rename from library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsUtilTest.java rename to library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionUtilTest.java index 25379a816b..b9bfaae7b9 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideInsertedAdsUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ads/ServerSideAdInsertionUtilTest.java @@ -15,12 +15,12 @@ */ package com.google.android.exoplayer2.source.ads; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.addAdGroupToAdPlaybackState; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getAdCountInGroup; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getMediaPeriodPositionUsForAd; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getMediaPeriodPositionUsForContent; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getStreamPositionUsForAd; -import static com.google.android.exoplayer2.source.ads.ServerSideInsertedAdsUtil.getStreamPositionUsForContent; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.getAdCountInGroup; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.getMediaPeriodPositionUsForAd; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.getMediaPeriodPositionUsForContent; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.getStreamPositionUsForAd; +import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.getStreamPositionUsForContent; import static com.google.common.truth.Truth.assertThat; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -28,9 +28,9 @@ import com.google.android.exoplayer2.C; import org.junit.Test; import org.junit.runner.RunWith; -/** Unit tests for {@link ServerSideInsertedAdsUtil}. */ +/** Unit tests for {@link ServerSideAdInsertionUtil}. */ @RunWith(AndroidJUnit4.class) -public final class ServerSideInsertedAdsUtilTest { +public final class ServerSideAdInsertionUtilTest { private static final Object ADS_ID = new Object(); From 8618e4b05cf8d7c4a68d61e2b00695ccd70c9d57 Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 23 Nov 2021 00:35:16 +0000 Subject: [PATCH 14/56] Document that channelNameResourceId needs to be set This is documented on the setter already, but it seems to make sense to do this in the constructor as well for clarity. Issue: google/ExoPlayer#9550 PiperOrigin-RevId: 411675700 --- .../android/exoplayer2/ui/PlayerNotificationManager.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java index 682483cd4e..462da93a6c 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java @@ -343,7 +343,9 @@ public class PlayerNotificationManager { * * @param context The {@link Context}. * @param notificationId The id of the notification to be posted. Must be greater than 0. - * @param channelId The id of the notification channel. + * @param channelId The id of the notification channel of an existing notification channel or of + * the channel that should be automatically created. In the latter case, {@link + * #setChannelNameResourceId(int)} needs to be called as well. */ public Builder(Context context, @IntRange(from = 1) int notificationId, String channelId) { checkArgument(notificationId > 0); From da80b17a15e5ef79916db8e37a7ebf39e9280130 Mon Sep 17 00:00:00 2001 From: kimvde Date: Tue, 23 Nov 2021 09:54:35 +0000 Subject: [PATCH 15/56] Transformer: rename OpenGlFrameEditor to FrameEditor PiperOrigin-RevId: 411751425 --- .../{OpenGlFrameEditor.java => FrameEditor.java} | 16 ++++++++-------- .../transformer/VideoSamplePipeline.java | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) rename library/transformer/src/main/java/com/google/android/exoplayer2/transformer/{OpenGlFrameEditor.java => FrameEditor.java} (90%) diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/OpenGlFrameEditor.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameEditor.java similarity index 90% rename from library/transformer/src/main/java/com/google/android/exoplayer2/transformer/OpenGlFrameEditor.java rename to library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameEditor.java index 00d42411e4..d7dc67da23 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/OpenGlFrameEditor.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameEditor.java @@ -29,26 +29,26 @@ import com.google.android.exoplayer2.util.GlUtil; import java.io.IOException; /** - * OpenGlFrameEditor applies changes to individual video frames using OpenGL. Changes include just - * resolution for now, but may later include brightness, cropping, rotation, etc. + * FrameEditor applies changes to individual video frames. Changes include just resolution for now, + * but may later include brightness, cropping, rotation, etc. */ @RequiresApi(18) -/* package */ final class OpenGlFrameEditor { +/* package */ final class FrameEditor { static { GlUtil.glAssertionsEnabled = true; } /** - * Returns a new OpenGlFrameEditor for applying changes to individual frames. + * Returns a new {@code FrameEditor} for applying changes to individual frames. * * @param context A {@link Context}. * @param outputWidth The output width in pixels. * @param outputHeight The output height in pixels. * @param outputSurface The {@link Surface}. - * @return A configured OpenGlFrameEditor. + * @return A configured {@code FrameEditor}. */ - public static OpenGlFrameEditor create( + public static FrameEditor create( Context context, int outputWidth, int outputHeight, Surface outputSurface) { EGLDisplay eglDisplay = GlUtil.createEglDisplay(); EGLContext eglContext; @@ -87,7 +87,7 @@ import java.io.IOException; }, /* size= */ 4); copyProgram.setSamplerTexIdUniform("tex_sampler", textureId, /* unit= */ 0); - return new OpenGlFrameEditor(eglDisplay, eglContext, eglSurface, textureId, copyProgram); + return new FrameEditor(eglDisplay, eglContext, eglSurface, textureId, copyProgram); } // Predefined shader values. @@ -107,7 +107,7 @@ import java.io.IOException; private volatile boolean hasInputData; - private OpenGlFrameEditor( + private FrameEditor( EGLDisplay eglDisplay, EGLContext eglContext, EGLSurface eglSurface, diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java index 6cb6e58441..9cb698efcd 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java @@ -44,7 +44,7 @@ import java.io.IOException; private final DecoderInputBuffer decoderInputBuffer; private final MediaCodecAdapterWrapper decoder; - private final OpenGlFrameEditor openGlFrameEditor; + private final FrameEditor frameEditor; private boolean waitingForPopulatedDecoderSurface; @@ -84,8 +84,8 @@ import java.io.IOException; throw createRendererException( e, rendererIndex, decoderInputFormat, PlaybackException.ERROR_CODE_UNSPECIFIED); } - openGlFrameEditor = - OpenGlFrameEditor.create( + frameEditor = + FrameEditor.create( context, outputWidth, outputHeight, @@ -93,7 +93,7 @@ import java.io.IOException; try { decoder = MediaCodecAdapterWrapper.createForVideoDecoding( - decoderInputFormat, openGlFrameEditor.getInputSurface()); + decoderInputFormat, frameEditor.getInputSurface()); } catch (IOException e) { throw createRendererException( e, rendererIndex, decoderInputFormat, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED); @@ -106,7 +106,7 @@ import java.io.IOException; return false; } - if (!openGlFrameEditor.hasInputData()) { + if (!frameEditor.hasInputData()) { if (!waitingForPopulatedDecoderSurface) { if (decoder.getOutputBufferInfo() != null) { decoder.releaseOutputBuffer(/* render= */ true); @@ -120,7 +120,7 @@ import java.io.IOException; } waitingForPopulatedDecoderSurface = false; - openGlFrameEditor.processData(); + frameEditor.processData(); return true; } @@ -166,7 +166,7 @@ import java.io.IOException; @Override public void release() { - openGlFrameEditor.release(); + frameEditor.release(); decoder.release(); encoder.release(); } From 92507e02f7616dd7717bb0b4f1773a95ee879c9d Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 23 Nov 2021 10:25:22 +0000 Subject: [PATCH 16/56] Remove ExoPlayerImpl inheritance from BasePlayer. This inheritance is really confusing because ExoPlayerImpl is not a full Player interface implementation. It also claims to be an ExoPlayer implementation in the Javadoc which isn't true in its current state. Removing the inheritance also allows to clean up some unused methods. PiperOrigin-RevId: 411756963 --- .../exoplayer2/ext/cast/CastPlayer.java | 2 +- .../google/android/exoplayer2/BasePlayer.java | 31 -- .../google/android/exoplayer2/util/Util.java | 48 +++ .../android/exoplayer2/ExoPlayerImpl.java | 280 +++++------------- .../android/exoplayer2/SimpleExoPlayer.java | 1 - 5 files changed, 128 insertions(+), 234 deletions(-) diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index 0054378727..0092846045 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -1092,7 +1092,7 @@ public final class CastPlayer extends BasePlayer { private void updateAvailableCommandsAndNotifyIfChanged() { Commands previousAvailableCommands = availableCommands; - availableCommands = getAvailableCommands(PERMANENT_AVAILABLE_COMMANDS); + availableCommands = Util.getAvailableCommands(/* player= */ this, PERMANENT_AVAILABLE_COMMANDS); if (!availableCommands.equals(previousAvailableCommands)) { listeners.queueEvent( Player.EVENT_AVAILABLE_COMMANDS_CHANGED, diff --git a/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java b/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java index 2209803f3e..4135a5d7c4 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java @@ -382,37 +382,6 @@ public abstract class BasePlayer implements Player { : timeline.getWindow(getCurrentMediaItemIndex(), window).getDurationMs(); } - /** - * Returns the {@link Commands} available in the player. - * - * @param permanentAvailableCommands The commands permanently available in the player. - * @return The available {@link Commands}. - */ - protected Commands getAvailableCommands(Commands permanentAvailableCommands) { - return new Commands.Builder() - .addAll(permanentAvailableCommands) - .addIf(COMMAND_SEEK_TO_DEFAULT_POSITION, !isPlayingAd()) - .addIf(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, isCurrentMediaItemSeekable() && !isPlayingAd()) - .addIf(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, hasPreviousMediaItem() && !isPlayingAd()) - .addIf( - COMMAND_SEEK_TO_PREVIOUS, - !getCurrentTimeline().isEmpty() - && (hasPreviousMediaItem() - || !isCurrentMediaItemLive() - || isCurrentMediaItemSeekable()) - && !isPlayingAd()) - .addIf(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, hasNextMediaItem() && !isPlayingAd()) - .addIf( - COMMAND_SEEK_TO_NEXT, - !getCurrentTimeline().isEmpty() - && (hasNextMediaItem() || (isCurrentMediaItemLive() && isCurrentMediaItemDynamic())) - && !isPlayingAd()) - .addIf(COMMAND_SEEK_TO_MEDIA_ITEM, !isPlayingAd()) - .addIf(COMMAND_SEEK_BACK, isCurrentMediaItemSeekable() && !isPlayingAd()) - .addIf(COMMAND_SEEK_FORWARD, isCurrentMediaItemSeekable() && !isPlayingAd()) - .build(); - } - @RepeatMode private int getRepeatModeForNavigation() { @RepeatMode int repeatMode = getRepeatMode(); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 9cf935d56f..b7678d35e0 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -16,6 +16,15 @@ package com.google.android.exoplayer2.util; import static android.content.Context.UI_MODE_SERVICE; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_BACK; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_FORWARD; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_DEFAULT_POSITION; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_MEDIA_ITEM; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_NEXT; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_PREVIOUS; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static java.lang.Math.abs; import static java.lang.Math.max; @@ -63,6 +72,8 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Player.Commands; import com.google.common.base.Ascii; import com.google.common.base.Charsets; import java.io.ByteArrayOutputStream; @@ -2477,6 +2488,43 @@ public final class Util { } } + /** + * Returns the {@link Commands} available in the {@link Player}. + * + * @param player The {@link Player}. + * @param permanentAvailableCommands The commands permanently available in the player. + * @return The available {@link Commands}. + */ + public static Commands getAvailableCommands(Player player, Commands permanentAvailableCommands) { + boolean isPlayingAd = player.isPlayingAd(); + boolean isCurrentMediaItemSeekable = player.isCurrentMediaItemSeekable(); + boolean hasPreviousMediaItem = player.hasPreviousMediaItem(); + boolean hasNextMediaItem = player.hasNextMediaItem(); + boolean isCurrentMediaItemLive = player.isCurrentMediaItemLive(); + boolean isCurrentMediaItemDynamic = player.isCurrentMediaItemDynamic(); + boolean isTimelineEmpty = player.getCurrentTimeline().isEmpty(); + return new Commands.Builder() + .addAll(permanentAvailableCommands) + .addIf(COMMAND_SEEK_TO_DEFAULT_POSITION, !isPlayingAd) + .addIf(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, isCurrentMediaItemSeekable && !isPlayingAd) + .addIf(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, hasPreviousMediaItem && !isPlayingAd) + .addIf( + COMMAND_SEEK_TO_PREVIOUS, + !isTimelineEmpty + && (hasPreviousMediaItem || !isCurrentMediaItemLive || isCurrentMediaItemSeekable) + && !isPlayingAd) + .addIf(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, hasNextMediaItem && !isPlayingAd) + .addIf( + COMMAND_SEEK_TO_NEXT, + !isTimelineEmpty + && (hasNextMediaItem || (isCurrentMediaItemLive && isCurrentMediaItemDynamic)) + && !isPlayingAd) + .addIf(COMMAND_SEEK_TO_MEDIA_ITEM, !isPlayingAd) + .addIf(COMMAND_SEEK_BACK, isCurrentMediaItemSeekable && !isPlayingAd) + .addIf(COMMAND_SEEK_FORWARD, isCurrentMediaItemSeekable && !isPlayingAd) + .build(); + } + @Nullable private static String getSystemProperty(String name) { try { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 0e9b8e8bc8..41e31180f0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -15,6 +15,39 @@ */ package com.google.android.exoplayer2; +import static com.google.android.exoplayer2.Player.COMMAND_CHANGE_MEDIA_ITEMS; +import static com.google.android.exoplayer2.Player.COMMAND_GET_CURRENT_MEDIA_ITEM; +import static com.google.android.exoplayer2.Player.COMMAND_GET_MEDIA_ITEMS_METADATA; +import static com.google.android.exoplayer2.Player.COMMAND_GET_TIMELINE; +import static com.google.android.exoplayer2.Player.COMMAND_GET_TRACK_INFOS; +import static com.google.android.exoplayer2.Player.COMMAND_PLAY_PAUSE; +import static com.google.android.exoplayer2.Player.COMMAND_PREPARE; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_DEFAULT_POSITION; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_MEDIA_ITEM; +import static com.google.android.exoplayer2.Player.COMMAND_SET_MEDIA_ITEMS_METADATA; +import static com.google.android.exoplayer2.Player.COMMAND_SET_REPEAT_MODE; +import static com.google.android.exoplayer2.Player.COMMAND_SET_SHUFFLE_MODE; +import static com.google.android.exoplayer2.Player.COMMAND_SET_SPEED_AND_PITCH; +import static com.google.android.exoplayer2.Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS; +import static com.google.android.exoplayer2.Player.COMMAND_STOP; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_REMOVE; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK; +import static com.google.android.exoplayer2.Player.EVENT_MEDIA_METADATA_CHANGED; +import static com.google.android.exoplayer2.Player.EVENT_PLAYLIST_METADATA_CHANGED; +import static com.google.android.exoplayer2.Player.EVENT_TRACK_SELECTION_PARAMETERS_CHANGED; +import static com.google.android.exoplayer2.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO; +import static com.google.android.exoplayer2.Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED; +import static com.google.android.exoplayer2.Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT; +import static com.google.android.exoplayer2.Player.MEDIA_ITEM_TRANSITION_REASON_SEEK; +import static com.google.android.exoplayer2.Player.PLAYBACK_SUPPRESSION_REASON_NONE; +import static com.google.android.exoplayer2.Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST; +import static com.google.android.exoplayer2.Player.STATE_BUFFERING; +import static com.google.android.exoplayer2.Player.STATE_ENDED; +import static com.google.android.exoplayer2.Player.STATE_IDLE; +import static com.google.android.exoplayer2.Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED; +import static com.google.android.exoplayer2.Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Assertions.checkState; import static com.google.android.exoplayer2.util.Util.castNonNull; @@ -26,18 +59,24 @@ import android.media.metrics.LogSessionId; import android.os.Handler; import android.os.Looper; import android.util.Pair; -import android.view.Surface; -import android.view.SurfaceHolder; -import android.view.SurfaceView; -import android.view.TextureView; import androidx.annotation.DoNotInline; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.ExoPlayer.AudioOffloadListener; +import com.google.android.exoplayer2.Player.Commands; +import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Player.EventListener; +import com.google.android.exoplayer2.Player.Events; +import com.google.android.exoplayer2.Player.Listener; +import com.google.android.exoplayer2.Player.PlayWhenReadyChangeReason; +import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; +import com.google.android.exoplayer2.Player.PositionInfo; +import com.google.android.exoplayer2.Player.RepeatMode; +import com.google.android.exoplayer2.Player.State; +import com.google.android.exoplayer2.Player.TimelineChangeReason; import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.analytics.AnalyticsCollector; import com.google.android.exoplayer2.analytics.PlayerId; -import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; @@ -45,7 +84,6 @@ import com.google.android.exoplayer2.source.MediaSourceFactory; import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.trackselection.ExoTrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.trackselection.TrackSelectionParameters; @@ -58,15 +96,14 @@ import com.google.android.exoplayer2.util.HandlerWrapper; import com.google.android.exoplayer2.util.ListenerSet; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; -import com.google.android.exoplayer2.video.VideoSize; import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArraySet; -/** An {@link ExoPlayer} implementation. */ -/* package */ final class ExoPlayerImpl extends BasePlayer { +/** A helper class for the {@link SimpleExoPlayer} implementation of {@link ExoPlayer}. */ +/* package */ final class ExoPlayerImpl { static { ExoPlayerLibraryInfo.registerModule("goog.exo.exoplayer"); @@ -84,6 +121,7 @@ import java.util.concurrent.CopyOnWriteArraySet; /* package */ final TrackSelectorResult emptyTrackSelectorResult; /* package */ final Commands permanentAvailableCommands; + private final Player wrappingPlayer; private final Renderer[] renderers; private final TrackSelector trackSelector; private final HandlerWrapper playbackInfoUpdateHandler; @@ -92,10 +130,11 @@ import java.util.concurrent.CopyOnWriteArraySet; private final ListenerSet listeners; private final CopyOnWriteArraySet audioOffloadListeners; private final Timeline.Period period; + private final Timeline.Window window; private final List mediaSourceHolderSnapshots; private final boolean useLazyPreparation; private final MediaSourceFactory mediaSourceFactory; - @Nullable private final AnalyticsCollector analyticsCollector; + private final AnalyticsCollector analyticsCollector; private final Looper applicationLooper; private final BandwidthMeter bandwidthMeter; private final long seekBackIncrementMs; @@ -141,16 +180,15 @@ import java.util.concurrent.CopyOnWriteArraySet; * loads and other initial preparation steps happen immediately. If true, these initial * preparations are triggered only when the player starts buffering the media. * @param seekParameters The {@link SeekParameters}. - * @param seekBackIncrementMs The {@link #seekBack()} increment in milliseconds. - * @param seekForwardIncrementMs The {@link #seekForward()} increment in milliseconds. + * @param seekBackIncrementMs The seek back increment in milliseconds. + * @param seekForwardIncrementMs The seek forward increment in milliseconds. * @param livePlaybackSpeedControl The {@link LivePlaybackSpeedControl}. * @param releaseTimeoutMs The timeout for calls to {@link #release()} in milliseconds. * @param pauseAtEndOfMediaItems Whether to pause playback at the end of each media item. * @param clock The {@link Clock}. * @param applicationLooper The {@link Looper} that must be used for all calls to the player and * which is used to call listeners on. - * @param wrappingPlayer The {@link Player} wrapping this one if applicable. This player instance - * should be used for all externally visible callbacks. + * @param wrappingPlayer The {@link Player} using this class. * @param additionalPermanentAvailableCommands The {@link Commands} that are permanently available * in the wrapping player but that are not in this player. */ @@ -161,7 +199,7 @@ import java.util.concurrent.CopyOnWriteArraySet; MediaSourceFactory mediaSourceFactory, LoadControl loadControl, BandwidthMeter bandwidthMeter, - @Nullable AnalyticsCollector analyticsCollector, + AnalyticsCollector analyticsCollector, boolean useLazyPreparation, SeekParameters seekParameters, long seekBackIncrementMs, @@ -171,7 +209,7 @@ import java.util.concurrent.CopyOnWriteArraySet; boolean pauseAtEndOfMediaItems, Clock clock, Looper applicationLooper, - @Nullable Player wrappingPlayer, + Player wrappingPlayer, Commands additionalPermanentAvailableCommands) { Log.i( TAG, @@ -195,13 +233,13 @@ import java.util.concurrent.CopyOnWriteArraySet; this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems; this.applicationLooper = applicationLooper; this.clock = clock; + this.wrappingPlayer = wrappingPlayer; repeatMode = Player.REPEAT_MODE_OFF; - Player playerForListeners = wrappingPlayer != null ? wrappingPlayer : this; listeners = new ListenerSet<>( applicationLooper, clock, - (listener, flags) -> listener.onEvents(playerForListeners, new Events(flags))); + (listener, flags) -> listener.onEvents(wrappingPlayer, new Events(flags))); audioOffloadListeners = new CopyOnWriteArraySet<>(); mediaSourceHolderSnapshots = new ArrayList<>(); shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 0); @@ -212,6 +250,7 @@ import java.util.concurrent.CopyOnWriteArraySet; TracksInfo.EMPTY, /* info= */ null); period = new Timeline.Period(); + window = new Timeline.Window(); permanentAvailableCommands = new Commands.Builder() .addAll( @@ -245,11 +284,9 @@ import java.util.concurrent.CopyOnWriteArraySet; playbackInfoUpdate -> playbackInfoUpdateHandler.post(() -> handlePlaybackInfo(playbackInfoUpdate)); playbackInfo = PlaybackInfo.createDummy(emptyTrackSelectorResult); - if (analyticsCollector != null) { - analyticsCollector.setPlayer(playerForListeners, applicationLooper); - addListener(analyticsCollector); - bandwidthMeter.addEventListener(new Handler(applicationLooper), analyticsCollector); - } + analyticsCollector.setPlayer(wrappingPlayer, applicationLooper); + addListener(analyticsCollector); + bandwidthMeter.addEventListener(new Handler(applicationLooper), analyticsCollector); PlayerId playerId = Util.SDK_INT < 31 ? new PlayerId() : Api31.createPlayerId(); internalPlayer = new ExoPlayerImplInternal( @@ -297,7 +334,6 @@ import java.util.concurrent.CopyOnWriteArraySet; return internalPlayer.getPlaybackLooper(); } - @Override public Looper getApplicationLooper() { return applicationLooper; } @@ -306,16 +342,10 @@ import java.util.concurrent.CopyOnWriteArraySet; return clock; } - @Override public void addListener(Listener listener) { addEventListener(listener); } - @Override - public void removeListener(Listener listener) { - removeEventListener(listener); - } - @SuppressWarnings("deprecation") // Register deprecated EventListener. public void addEventListener(Player.EventListener eventListener) { listeners.add(eventListener); @@ -334,24 +364,20 @@ import java.util.concurrent.CopyOnWriteArraySet; audioOffloadListeners.remove(listener); } - @Override public Commands getAvailableCommands() { return availableCommands; } - @Override @State public int getPlaybackState() { return playbackInfo.playbackState; } - @Override @PlaybackSuppressionReason public int getPlaybackSuppressionReason() { return playbackInfo.playbackSuppressionReason; } - @Override @Nullable public ExoPlaybackException getPlayerError() { return playbackInfo.playbackError; @@ -363,7 +389,6 @@ import java.util.concurrent.CopyOnWriteArraySet; prepare(); } - @Override public void prepare() { if (playbackInfo.playbackState != Player.STATE_IDLE) { return; @@ -371,7 +396,7 @@ import java.util.concurrent.CopyOnWriteArraySet; PlaybackInfo playbackInfo = this.playbackInfo.copyWithPlaybackError(null); playbackInfo = playbackInfo.copyWithPlaybackState( - playbackInfo.timeline.isEmpty() ? Player.STATE_ENDED : Player.STATE_BUFFERING); + playbackInfo.timeline.isEmpty() ? STATE_ENDED : STATE_BUFFERING); // Trigger internal prepare first before updating the playback info and notifying external // listeners to ensure that new operations issued in the listener notifications reach the // player after this prepare. The internal player can't change the playback info immediately @@ -408,12 +433,10 @@ import java.util.concurrent.CopyOnWriteArraySet; prepare(); } - @Override public void setMediaItems(List mediaItems, boolean resetPosition) { setMediaSources(createMediaSources(mediaItems), resetPosition); } - @Override public void setMediaItems(List mediaItems, int startIndex, long startPositionMs) { setMediaSources(createMediaSources(mediaItems), startIndex, startPositionMs); } @@ -449,7 +472,6 @@ import java.util.concurrent.CopyOnWriteArraySet; mediaSources, startWindowIndex, startPositionMs, /* resetToDefaultPosition= */ false); } - @Override public void addMediaItems(int index, List mediaItems) { index = min(index, mediaSourceHolderSnapshots.size()); addMediaSources(index, createMediaSources(mediaItems)); @@ -490,7 +512,6 @@ import java.util.concurrent.CopyOnWriteArraySet; /* ignored */ C.INDEX_UNSET); } - @Override public void removeMediaItems(int fromIndex, int toIndex) { toIndex = min(toIndex, mediaSourceHolderSnapshots.size()); PlaybackInfo newPlaybackInfo = removeMediaItemsInternal(fromIndex, toIndex); @@ -502,12 +523,11 @@ import java.util.concurrent.CopyOnWriteArraySet; /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, /* seekProcessed= */ false, positionDiscontinuity, - Player.DISCONTINUITY_REASON_REMOVE, + DISCONTINUITY_REASON_REMOVE, /* discontinuityWindowStartPositionUs= */ getCurrentPositionUsInternal(newPlaybackInfo), /* ignored */ C.INDEX_UNSET); } - @Override public void moveMediaItems(int fromIndex, int toIndex, int newFromIndex) { Assertions.checkArgument( fromIndex >= 0 @@ -558,14 +578,6 @@ import java.util.concurrent.CopyOnWriteArraySet; /* ignored */ C.INDEX_UNSET); } - @Override - public void setPlayWhenReady(boolean playWhenReady) { - setPlayWhenReady( - playWhenReady, - PLAYBACK_SUPPRESSION_REASON_NONE, - PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST); - } - public void setPauseAtEndOfMediaItems(boolean pauseAtEndOfMediaItems) { if (this.pauseAtEndOfMediaItems == pauseAtEndOfMediaItems) { return; @@ -601,12 +613,10 @@ import java.util.concurrent.CopyOnWriteArraySet; /* ignored */ C.INDEX_UNSET); } - @Override public boolean getPlayWhenReady() { return playbackInfo.playWhenReady; } - @Override public void setRepeatMode(@RepeatMode int repeatMode) { if (this.repeatMode != repeatMode) { this.repeatMode = repeatMode; @@ -618,12 +628,11 @@ import java.util.concurrent.CopyOnWriteArraySet; } } - @Override - public @RepeatMode int getRepeatMode() { + @RepeatMode + public int getRepeatMode() { return repeatMode; } - @Override public void setShuffleModeEnabled(boolean shuffleModeEnabled) { if (this.shuffleModeEnabled != shuffleModeEnabled) { this.shuffleModeEnabled = shuffleModeEnabled; @@ -636,17 +645,14 @@ import java.util.concurrent.CopyOnWriteArraySet; } } - @Override public boolean getShuffleModeEnabled() { return shuffleModeEnabled; } - @Override public boolean isLoading() { return playbackInfo.isLoading; } - @Override public void seekTo(int mediaItemIndex, long positionMs) { Timeline timeline = playbackInfo.timeline; if (mediaItemIndex < 0 @@ -667,7 +673,7 @@ import java.util.concurrent.CopyOnWriteArraySet; } @Player.State int newPlaybackState = - getPlaybackState() == Player.STATE_IDLE ? Player.STATE_IDLE : Player.STATE_BUFFERING; + getPlaybackState() == Player.STATE_IDLE ? Player.STATE_IDLE : STATE_BUFFERING; int oldMaskingMediaItemIndex = getCurrentMediaItemIndex(); PlaybackInfo newPlaybackInfo = playbackInfo.copyWithPlaybackState(newPlaybackState); newPlaybackInfo = @@ -687,22 +693,18 @@ import java.util.concurrent.CopyOnWriteArraySet; oldMaskingMediaItemIndex); } - @Override public long getSeekBackIncrement() { return seekBackIncrementMs; } - @Override public long getSeekForwardIncrement() { return seekForwardIncrementMs; } - @Override public long getMaxSeekToPreviousPosition() { return C.DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS; } - @Override public void setPlaybackParameters(PlaybackParameters playbackParameters) { if (playbackParameters == null) { playbackParameters = PlaybackParameters.DEFAULT; @@ -724,7 +726,6 @@ import java.util.concurrent.CopyOnWriteArraySet; /* ignored */ C.INDEX_UNSET); } - @Override public PlaybackParameters getPlaybackParameters() { return playbackInfo.playbackParameters; } @@ -757,13 +758,6 @@ import java.util.concurrent.CopyOnWriteArraySet; } } - @Override - public void stop() { - stop(/* reset= */ false); - } - - @Deprecated - @Override public void stop(boolean reset) { stop(reset, /* error= */ null); } @@ -806,7 +800,6 @@ import java.util.concurrent.CopyOnWriteArraySet; /* ignored */ C.INDEX_UNSET); } - @Override public void release() { Log.i( TAG, @@ -831,9 +824,7 @@ import java.util.concurrent.CopyOnWriteArraySet; } listeners.release(); playbackInfoUpdateHandler.removeCallbacksAndMessages(null); - if (analyticsCollector != null) { - bandwidthMeter.removeEventListener(analyticsCollector); - } + bandwidthMeter.removeEventListener(analyticsCollector); playbackInfo = playbackInfo.copyWithPlaybackState(Player.STATE_IDLE); playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(playbackInfo.periodId); playbackInfo.bufferedPositionUs = playbackInfo.positionUs; @@ -850,7 +841,6 @@ import java.util.concurrent.CopyOnWriteArraySet; internalPlayer.getPlaybackLooper()); } - @Override public int getCurrentPeriodIndex() { if (playbackInfo.timeline.isEmpty()) { return maskingPeriodIndex; @@ -859,13 +849,11 @@ import java.util.concurrent.CopyOnWriteArraySet; } } - @Override public int getCurrentMediaItemIndex() { int currentWindowIndex = getCurrentWindowIndexInternal(); return currentWindowIndex == C.INDEX_UNSET ? 0 : currentWindowIndex; } - @Override public long getDuration() { if (isPlayingAd()) { MediaPeriodId periodId = playbackInfo.periodId; @@ -876,12 +864,17 @@ import java.util.concurrent.CopyOnWriteArraySet; return getContentDuration(); } - @Override + private long getContentDuration() { + Timeline timeline = getCurrentTimeline(); + return timeline.isEmpty() + ? C.TIME_UNSET + : timeline.getWindow(getCurrentMediaItemIndex(), window).getDurationMs(); + } + public long getCurrentPosition() { return Util.usToMs(getCurrentPositionUsInternal(playbackInfo)); } - @Override public long getBufferedPosition() { if (isPlayingAd()) { return playbackInfo.loadingMediaPeriodId.equals(playbackInfo.periodId) @@ -891,27 +884,22 @@ import java.util.concurrent.CopyOnWriteArraySet; return getContentBufferedPosition(); } - @Override public long getTotalBufferedDuration() { return Util.usToMs(playbackInfo.totalBufferedDurationUs); } - @Override public boolean isPlayingAd() { return playbackInfo.periodId.isAd(); } - @Override public int getCurrentAdGroupIndex() { return isPlayingAd() ? playbackInfo.periodId.adGroupIndex : C.INDEX_UNSET; } - @Override public int getCurrentAdIndexInAdGroup() { return isPlayingAd() ? playbackInfo.periodId.adIndexInAdGroup : C.INDEX_UNSET; } - @Override public long getContentPosition() { if (isPlayingAd()) { playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period); @@ -926,7 +914,6 @@ import java.util.concurrent.CopyOnWriteArraySet; } } - @Override public long getContentBufferedPosition() { if (playbackInfo.timeline.isEmpty()) { return maskingWindowPositionMs; @@ -958,32 +945,26 @@ import java.util.concurrent.CopyOnWriteArraySet; return renderers[index].getTrackType(); } - @Nullable public TrackSelector getTrackSelector() { return trackSelector; } - @Override public TrackGroupArray getCurrentTrackGroups() { return playbackInfo.trackGroups; } - @Override public TrackSelectionArray getCurrentTrackSelections() { return new TrackSelectionArray(playbackInfo.trackSelectorResult.selections); } - @Override public TracksInfo getCurrentTracksInfo() { return playbackInfo.trackSelectorResult.tracksInfo; } - @Override public TrackSelectionParameters getTrackSelectionParameters() { return trackSelector.getParameters(); } - @Override public void setTrackSelectionParameters(TrackSelectionParameters parameters) { if (!trackSelector.isSetParametersSupported() || parameters.equals(trackSelector.getParameters())) { @@ -995,7 +976,6 @@ import java.util.concurrent.CopyOnWriteArraySet; listener -> listener.onTrackSelectionParametersChanged(parameters)); } - @Override public MediaMetadata getMediaMetadata() { return mediaMetadata; } @@ -1014,12 +994,10 @@ import java.util.concurrent.CopyOnWriteArraySet; EVENT_MEDIA_METADATA_CHANGED, listener -> listener.onMediaMetadataChanged(mediaMetadata)); } - @Override public MediaMetadata getPlaylistMetadata() { return playlistMetadata; } - @Override public void setPlaylistMetadata(MediaMetadata playlistMetadata) { checkNotNull(playlistMetadata); if (playlistMetadata.equals(this.playlistMetadata)) { @@ -1031,109 +1009,10 @@ import java.util.concurrent.CopyOnWriteArraySet; listener -> listener.onPlaylistMetadataChanged(this.playlistMetadata)); } - @Override public Timeline getCurrentTimeline() { return playbackInfo.timeline; } - /** This method is not supported and returns {@link AudioAttributes#DEFAULT}. */ - @Override - public AudioAttributes getAudioAttributes() { - return AudioAttributes.DEFAULT; - } - - /** This method is not supported and does nothing. */ - @Override - public void setVolume(float volume) {} - - /** This method is not supported and returns 1. */ - @Override - public float getVolume() { - return 1; - } - - /** This method is not supported and does nothing. */ - @Override - public void clearVideoSurface() {} - - /** This method is not supported and does nothing. */ - @Override - public void clearVideoSurface(@Nullable Surface surface) {} - - /** This method is not supported and does nothing. */ - @Override - public void setVideoSurface(@Nullable Surface surface) {} - - /** This method is not supported and does nothing. */ - @Override - public void setVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) {} - - /** This method is not supported and does nothing. */ - @Override - public void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) {} - - /** This method is not supported and does nothing. */ - @Override - public void setVideoSurfaceView(@Nullable SurfaceView surfaceView) {} - - /** This method is not supported and does nothing. */ - @Override - public void clearVideoSurfaceView(@Nullable SurfaceView surfaceView) {} - - /** This method is not supported and does nothing. */ - @Override - public void setVideoTextureView(@Nullable TextureView textureView) {} - - /** This method is not supported and does nothing. */ - @Override - public void clearVideoTextureView(@Nullable TextureView textureView) {} - - /** This method is not supported and returns {@link VideoSize#UNKNOWN}. */ - @Override - public VideoSize getVideoSize() { - return VideoSize.UNKNOWN; - } - - /** This method is not supported and returns an empty list. */ - @Override - public ImmutableList getCurrentCues() { - return ImmutableList.of(); - } - - /** This method is not supported and always returns {@link DeviceInfo#UNKNOWN}. */ - @Override - public DeviceInfo getDeviceInfo() { - return DeviceInfo.UNKNOWN; - } - - /** This method is not supported and always returns {@code 0}. */ - @Override - public int getDeviceVolume() { - return 0; - } - - /** This method is not supported and always returns {@link false}. */ - @Override - public boolean isDeviceMuted() { - return false; - } - - /** This method is not supported and does nothing. */ - @Override - public void setDeviceVolume(int volume) {} - - /** This method is not supported and does nothing. */ - @Override - public void increaseDeviceVolume() {} - - /** This method is not supported and does nothing. */ - @Override - public void decreaseDeviceVolume() {} - - /** This method is not supported and does nothing. */ - @Override - public void setDeviceMuted(boolean muted) {} - private int getCurrentWindowIndexInternal() { if (playbackInfo.timeline.isEmpty()) { return maskingWindowIndex; @@ -1316,7 +1195,7 @@ import java.util.concurrent.CopyOnWriteArraySet; if (metadataChanged) { final MediaMetadata finalMediaMetadata = mediaMetadata; listeners.queueEvent( - Player.EVENT_MEDIA_METADATA_CHANGED, + EVENT_MEDIA_METADATA_CHANGED, listener -> listener.onMediaMetadataChanged(finalMediaMetadata)); } if (previousPlaybackInfo.isLoading != newPlaybackInfo.isLoading) { @@ -1524,7 +1403,7 @@ import java.util.concurrent.CopyOnWriteArraySet; private void updateAvailableCommands() { Commands previousAvailableCommands = availableCommands; - availableCommands = getAvailableCommands(permanentAvailableCommands); + availableCommands = Util.getAvailableCommands(wrappingPlayer, permanentAvailableCommands); if (!availableCommands.equals(previousAvailableCommands)) { listeners.queueEvent( Player.EVENT_AVAILABLE_COMMANDS_CHANGED, @@ -1586,7 +1465,7 @@ import java.util.concurrent.CopyOnWriteArraySet; /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, /* seekProcessed= */ false, /* positionDiscontinuity= */ positionDiscontinuity, - Player.DISCONTINUITY_REASON_REMOVE, + DISCONTINUITY_REASON_REMOVE, /* discontinuityWindowStartPositionUs= */ getCurrentPositionUsInternal(newPlaybackInfo), /* ignored */ C.INDEX_UNSET); } @@ -1825,12 +1704,11 @@ import java.util.concurrent.CopyOnWriteArraySet; * #onMetadata(Metadata)}) sources. */ private MediaMetadata buildUpdatedMediaMetadata() { - @Nullable MediaItem mediaItem = getCurrentMediaItem(); - - if (mediaItem == null) { + Timeline timeline = getCurrentTimeline(); + if (timeline.isEmpty()) { return staticAndDynamicMediaMetadata; } - + MediaItem mediaItem = timeline.getWindow(getCurrentMediaItemIndex(), window).mediaItem; // MediaItem metadata is prioritized over metadata within the media. return staticAndDynamicMediaMetadata.buildUpon().populate(mediaItem.mediaMetadata).build(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index fdc819078b..6c4b6cc45c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -1351,7 +1351,6 @@ public class SimpleExoPlayer extends BasePlayer } @Override - @Nullable public TrackSelector getTrackSelector() { verifyApplicationThread(); return player.getTrackSelector(); From 8af7089cd2ad25dd9821867aef0b7de0e6503355 Mon Sep 17 00:00:00 2001 From: hschlueter Date: Tue, 23 Nov 2021 10:38:52 +0000 Subject: [PATCH 17/56] Deduplicate transformer audio and video renderer implementations. This change moves methods that are the same in `TransformerAudioRenderer` and `TransformerVideoRenderer` to `TransformerBaseRenderer`. PiperOrigin-RevId: 411758928 --- .../transformer/AudioSamplePipeline.java | 1 + .../transformer/TransformerAudioRenderer.java | 105 +-------------- .../transformer/TransformerBaseRenderer.java | 127 +++++++++++++++++- .../transformer/TransformerVideoRenderer.java | 126 ++--------------- 4 files changed, 138 insertions(+), 221 deletions(-) diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/AudioSamplePipeline.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/AudioSamplePipeline.java index 7eb6630172..b53da51eb3 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/AudioSamplePipeline.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/AudioSamplePipeline.java @@ -150,6 +150,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; encoderOutputBuffer.data = encoder.getOutputBuffer(); if (encoderOutputBuffer.data != null) { encoderOutputBuffer.timeUs = checkNotNull(encoder.getOutputBufferInfo()).presentationTimeUs; + encoderOutputBuffer.setFlags(C.BUFFER_FLAG_KEY_FRAME); return encoderOutputBuffer; } } diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java index 6126596070..e57150c996 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java @@ -18,9 +18,7 @@ package com.google.android.exoplayer2.transformer; import static com.google.android.exoplayer2.source.SampleStream.FLAG_REQUIRE_FORMAT; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; -import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -28,9 +26,6 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; -import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; -import org.checkerframework.checker.nullness.qual.MonotonicNonNull; -import org.checkerframework.checker.nullness.qual.RequiresNonNull; @RequiresApi(18) /* package */ final class TransformerAudioRenderer extends TransformerBaseRenderer { @@ -39,10 +34,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final DecoderInputBuffer decoderInputBuffer; - private @MonotonicNonNull SamplePipeline samplePipeline; - private boolean muxerWrapperTrackAdded; - private boolean muxerWrapperTrackEnded; - public TransformerAudioRenderer( MuxerWrapper muxerWrapper, TransformerMediaClock mediaClock, Transformation transformation) { super(C.TRACK_TYPE_AUDIO, muxerWrapper, mediaClock, transformation); @@ -55,32 +46,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return TAG; } + /** Attempts to read the input format and to initialize the sample or passthrough pipeline. */ @Override - public boolean isEnded() { - return muxerWrapperTrackEnded; - } - - @Override - protected void onReset() { - if (samplePipeline != null) { - samplePipeline.release(); - } - muxerWrapperTrackAdded = false; - muxerWrapperTrackEnded = false; - } - - @Override - public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { - if (!isRendererStarted || isEnded() || !ensureRendererConfigured()) { - return; - } - - while (feedMuxerFromPipeline() || samplePipeline.processData() || feedPipelineFromInput()) {} - } - - /** Attempts to read the input format and to initialize the sample pipeline. */ - @EnsuresNonNullIf(expression = "samplePipeline", result = true) - private boolean ensureRendererConfigured() throws ExoPlaybackException { + protected boolean ensureConfigured() throws ExoPlaybackException { if (samplePipeline != null) { return true; } @@ -100,73 +68,4 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } return true; } - - /** - * Attempts to write sample pipeline output data to the muxer. - * - * @return Whether it may be possible to write more data immediately by calling this method again. - */ - @RequiresNonNull("samplePipeline") - private boolean feedMuxerFromPipeline() { - if (!muxerWrapperTrackAdded) { - @Nullable Format samplePipelineOutputFormat = samplePipeline.getOutputFormat(); - if (samplePipelineOutputFormat == null) { - return false; - } - muxerWrapperTrackAdded = true; - muxerWrapper.addTrackFormat(samplePipelineOutputFormat); - } - - if (samplePipeline.isEnded()) { - muxerWrapper.endTrack(getTrackType()); - muxerWrapperTrackEnded = true; - return false; - } - @Nullable DecoderInputBuffer samplePipelineOutputBuffer = samplePipeline.getOutputBuffer(); - if (samplePipelineOutputBuffer == null) { - return false; - } - if (!muxerWrapper.writeSample( - getTrackType(), - checkStateNotNull(samplePipelineOutputBuffer.data), - /* isKeyFrame= */ true, - samplePipelineOutputBuffer.timeUs)) { - return false; - } - samplePipeline.releaseOutputBuffer(); - return true; - } - - /** - * Attempts to pass input data to the sample pipeline. - * - * @return Whether it may be possible to pass more data immediately by calling this method again. - */ - @RequiresNonNull("samplePipeline") - private boolean feedPipelineFromInput() { - @Nullable DecoderInputBuffer samplePipelineInputBuffer = samplePipeline.dequeueInputBuffer(); - if (samplePipelineInputBuffer == null) { - return false; - } - - @ReadDataResult - int result = readSource(getFormatHolder(), samplePipelineInputBuffer, /* readFlags= */ 0); - switch (result) { - case C.RESULT_BUFFER_READ: - if (samplePipelineInputBuffer.isEndOfStream()) { - samplePipeline.queueInputBuffer(); - return false; - } - mediaClock.updateTimeForTrackType(getTrackType(), samplePipelineInputBuffer.timeUs); - samplePipelineInputBuffer.timeUs -= streamOffsetUs; - samplePipelineInputBuffer.flip(); - samplePipeline.queueInputBuffer(); - return true; - case C.RESULT_FORMAT_READ: - throw new IllegalStateException("Format changes are not supported."); - case C.RESULT_NOTHING_READ: - default: - return false; - } - } } diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java index d3fe72d65b..d0f8e1253d 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java @@ -16,6 +16,8 @@ package com.google.android.exoplayer2.transformer; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; + import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.BaseRenderer; @@ -23,8 +25,14 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; import com.google.android.exoplayer2.util.MediaClock; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.errorprone.annotations.ForOverride; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; @RequiresApi(18) /* package */ abstract class TransformerBaseRenderer extends BaseRenderer { @@ -34,7 +42,10 @@ import com.google.android.exoplayer2.util.MimeTypes; protected final Transformation transformation; protected boolean isRendererStarted; + protected boolean muxerWrapperTrackAdded; + protected boolean muxerWrapperTrackEnded; protected long streamOffsetUs; + protected @MonotonicNonNull SamplePipeline samplePipeline; public TransformerBaseRenderer( int trackType, @@ -47,11 +58,6 @@ import com.google.android.exoplayer2.util.MimeTypes; this.transformation = transformation; } - @Override - protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { - this.streamOffsetUs = offsetUs; - } - @Override @C.FormatSupport public final int supportsFormat(Format format) { @@ -84,6 +90,34 @@ import com.google.android.exoplayer2.util.MimeTypes; return mediaClock; } + @Override + public final boolean isEnded() { + return muxerWrapperTrackEnded; + } + + @Override + public final void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + if (!isRendererStarted || isEnded() || !ensureConfigured()) { + return; + } + + while (feedMuxerFromPipeline() || samplePipeline.processData() || feedPipelineFromInput()) {} + } + + @Override + protected final void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { + this.streamOffsetUs = offsetUs; + } + + @Override + protected final void onReset() { + if (samplePipeline != null) { + samplePipeline.release(); + } + muxerWrapperTrackAdded = false; + muxerWrapperTrackEnded = false; + } + @Override protected final void onEnabled(boolean joining, boolean mayRenderStartOfStream) { muxerWrapper.registerTrack(); @@ -91,7 +125,7 @@ import com.google.android.exoplayer2.util.MimeTypes; } @Override - protected void onStarted() throws ExoPlaybackException { + protected final void onStarted() { isRendererStarted = true; } @@ -99,4 +133,85 @@ import com.google.android.exoplayer2.util.MimeTypes; protected final void onStopped() { isRendererStarted = false; } + + @ForOverride + @EnsuresNonNullIf(expression = "samplePipeline", result = true) + protected abstract boolean ensureConfigured() throws ExoPlaybackException; + + @RequiresNonNull({"samplePipeline", "#1.data"}) + protected void maybeQueueSampleToPipeline(DecoderInputBuffer inputBuffer) { + samplePipeline.queueInputBuffer(); + } + + /** + * Attempts to write sample pipeline output data to the muxer. + * + * @return Whether it may be possible to write more data immediately by calling this method again. + */ + @RequiresNonNull("samplePipeline") + private boolean feedMuxerFromPipeline() { + if (!muxerWrapperTrackAdded) { + @Nullable Format samplePipelineOutputFormat = samplePipeline.getOutputFormat(); + if (samplePipelineOutputFormat == null) { + return false; + } + muxerWrapperTrackAdded = true; + muxerWrapper.addTrackFormat(samplePipelineOutputFormat); + } + + if (samplePipeline.isEnded()) { + muxerWrapper.endTrack(getTrackType()); + muxerWrapperTrackEnded = true; + return false; + } + + @Nullable DecoderInputBuffer samplePipelineOutputBuffer = samplePipeline.getOutputBuffer(); + if (samplePipelineOutputBuffer == null) { + return false; + } + + if (!muxerWrapper.writeSample( + getTrackType(), + checkStateNotNull(samplePipelineOutputBuffer.data), + samplePipelineOutputBuffer.isKeyFrame(), + samplePipelineOutputBuffer.timeUs)) { + return false; + } + samplePipeline.releaseOutputBuffer(); + return true; + } + + /** + * Attempts to read input data and pass the input data to the sample pipeline. + * + * @return Whether it may be possible to read more data immediately by calling this method again. + */ + @RequiresNonNull("samplePipeline") + private boolean feedPipelineFromInput() { + @Nullable DecoderInputBuffer samplePipelineInputBuffer = samplePipeline.dequeueInputBuffer(); + if (samplePipelineInputBuffer == null) { + return false; + } + + @ReadDataResult + int result = readSource(getFormatHolder(), samplePipelineInputBuffer, /* readFlags= */ 0); + switch (result) { + case C.RESULT_BUFFER_READ: + if (samplePipelineInputBuffer.isEndOfStream()) { + samplePipeline.queueInputBuffer(); + return false; + } + mediaClock.updateTimeForTrackType(getTrackType(), samplePipelineInputBuffer.timeUs); + samplePipelineInputBuffer.timeUs -= streamOffsetUs; + samplePipelineInputBuffer.flip(); + checkStateNotNull(samplePipelineInputBuffer.data); + maybeQueueSampleToPipeline(samplePipelineInputBuffer); + return true; + case C.RESULT_FORMAT_READ: + throw new IllegalStateException("Format changes are not supported."); + case C.RESULT_NOTHING_READ: + default: + return false; + } + } } diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java index 2d61a35184..1541dc7fe6 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java @@ -18,10 +18,8 @@ package com.google.android.exoplayer2.transformer; import static com.google.android.exoplayer2.source.SampleStream.FLAG_REQUIRE_FORMAT; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; import android.content.Context; -import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; @@ -30,7 +28,6 @@ import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; import java.nio.ByteBuffer; -import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; @@ -43,9 +40,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final DecoderInputBuffer decoderInputBuffer; private @MonotonicNonNull SefSlowMotionFlattener sefSlowMotionFlattener; - private @MonotonicNonNull SamplePipeline samplePipeline; - private boolean muxerWrapperTrackAdded; - private boolean muxerWrapperTrackEnded; public TransformerVideoRenderer( Context context, @@ -63,32 +57,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return TAG; } - @Override - public boolean isEnded() { - return muxerWrapperTrackEnded; - } - - @Override - protected void onReset() { - if (samplePipeline != null) { - samplePipeline.release(); - } - muxerWrapperTrackAdded = false; - muxerWrapperTrackEnded = false; - } - - @Override - public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { - if (!isRendererStarted || isEnded() || !ensureRendererConfigured()) { - return; - } - - while (feedMuxerFromPipeline() || samplePipeline.processData() || feedPipelineFromInput()) {} - } - /** Attempts to read the input format and to initialize the sample or passthrough pipeline. */ - @EnsuresNonNullIf(expression = "samplePipeline", result = true) - private boolean ensureRendererConfigured() throws ExoPlaybackException { + @Override + protected boolean ensureConfigured() throws ExoPlaybackException { if (samplePipeline != null) { return true; } @@ -115,88 +86,19 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } /** - * Attempts to write sample pipeline output data to the muxer. - * - * @return Whether it may be possible to write more data immediately by calling this method again. + * Queues the input buffer to the sample pipeline unless it should be dropped because of slow + * motion flattening. */ - @RequiresNonNull("samplePipeline") - private boolean feedMuxerFromPipeline() { - if (!muxerWrapperTrackAdded) { - @Nullable Format samplePipelineOutputFormat = samplePipeline.getOutputFormat(); - if (samplePipelineOutputFormat == null) { - return false; - } - muxerWrapperTrackAdded = true; - muxerWrapper.addTrackFormat(samplePipelineOutputFormat); - } - - if (samplePipeline.isEnded()) { - muxerWrapper.endTrack(getTrackType()); - muxerWrapperTrackEnded = true; - return false; - } - - @Nullable DecoderInputBuffer samplePipelineOutputBuffer = samplePipeline.getOutputBuffer(); - if (samplePipelineOutputBuffer == null) { - return false; - } - - if (!muxerWrapper.writeSample( - getTrackType(), - checkStateNotNull(samplePipelineOutputBuffer.data), - samplePipelineOutputBuffer.isKeyFrame(), - samplePipelineOutputBuffer.timeUs)) { - return false; - } - samplePipeline.releaseOutputBuffer(); - return true; - } - - /** - * Attempts to: - * - *

    - *
  1. read input data, - *
  2. optionally, apply slow motion flattening, and - *
  3. pass input data to the sample pipeline. - *
- * - * @return Whether it may be possible to read more data immediately by calling this method again. - */ - @RequiresNonNull("samplePipeline") - private boolean feedPipelineFromInput() { - @Nullable DecoderInputBuffer samplePipelineInputBuffer = samplePipeline.dequeueInputBuffer(); - if (samplePipelineInputBuffer == null) { - return false; - } - - @ReadDataResult - int result = readSource(getFormatHolder(), samplePipelineInputBuffer, /* readFlags= */ 0); - switch (result) { - case C.RESULT_BUFFER_READ: - if (samplePipelineInputBuffer.isEndOfStream()) { - samplePipeline.queueInputBuffer(); - return false; - } - mediaClock.updateTimeForTrackType(getTrackType(), samplePipelineInputBuffer.timeUs); - samplePipelineInputBuffer.timeUs -= streamOffsetUs; - samplePipelineInputBuffer.flip(); - if (sefSlowMotionFlattener != null) { - ByteBuffer data = checkStateNotNull(samplePipelineInputBuffer.data); - boolean shouldDropSample = - sefSlowMotionFlattener.dropOrTransformSample(samplePipelineInputBuffer); - if (shouldDropSample) { - data.clear(); - return true; - } - } - samplePipeline.queueInputBuffer(); - return true; - case C.RESULT_FORMAT_READ: - throw new IllegalStateException("Format changes are not supported."); - case C.RESULT_NOTHING_READ: - default: - return false; + @Override + @RequiresNonNull({"samplePipeline", "#1.data"}) + protected void maybeQueueSampleToPipeline(DecoderInputBuffer inputBuffer) { + ByteBuffer data = inputBuffer.data; + boolean shouldDropSample = + sefSlowMotionFlattener != null && sefSlowMotionFlattener.dropOrTransformSample(inputBuffer); + if (shouldDropSample) { + data.clear(); + } else { + samplePipeline.queueInputBuffer(); } } } From f790d105b76cb92ac31cf0ab6c9557a41b4bc15b Mon Sep 17 00:00:00 2001 From: samrobinson Date: Wed, 24 Nov 2021 10:31:09 +0000 Subject: [PATCH 18/56] Remove usage of @ForOverride. Fixes the gradle compilation failures. Gradle dependencies need revising if we want to be using this, as checkerframework is ahead of their latest version, such that we can't compile. PiperOrigin-RevId: 412004021 --- .../android/exoplayer2/transformer/TransformerBaseRenderer.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java index d0f8e1253d..63246a5dbe 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java @@ -29,7 +29,6 @@ import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; import com.google.android.exoplayer2.util.MediaClock; import com.google.android.exoplayer2.util.MimeTypes; -import com.google.errorprone.annotations.ForOverride; import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; @@ -134,7 +133,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; isRendererStarted = false; } - @ForOverride @EnsuresNonNullIf(expression = "samplePipeline", result = true) protected abstract boolean ensureConfigured() throws ExoPlaybackException; From 0fbd4959fd1c232f99857ca1253b4382bcdbe364 Mon Sep 17 00:00:00 2001 From: huangdarwin Date: Wed, 24 Nov 2021 15:30:07 +0000 Subject: [PATCH 19/56] Transformer: Move required Builder context to be a constructor arg. Deprecates setContext() and moves the required Context arg into the constructor. This way, the parameter can later be final and non-null, per the comment at: http://go/https://github.com/google/ExoPlayer/commit/ecb47ba5647a7622fc73c09ac37f4e8b3b450cec/depot/google3/third_party/java_src/android_libs/media/libraries/transformer/src/main/java/androidx/media3/transformer/TranscodingTransformer.java?left=s19&right=r12#97L Also, fixes setOutputMimeType_unsupportedMimeType_throws by providing a context in the builder, and updating the FrameworkMuxer#supportsOutputMimeType to catch IllegalArgumentExceptions thrown by FrameworkMuxer#mimeTypeToMuxerOutputFormat. PiperOrigin-RevId: 412053564 --- docs/transforming-media.md | 6 +-- .../RemoveAudioTransformationTest.java | 3 +- .../RemoveVideoTransformationTest.java | 3 +- .../RepeatedTranscodeTransformationTest.java | 9 ++-- .../transformer/SefTransformationTest.java | 2 +- .../transformer/TransformationTest.java | 2 +- .../transformer/FrameworkMuxer.java | 2 +- .../exoplayer2/transformer/Transformer.java | 38 +++++++++++--- .../transformer/TransformerBuilderTest.java | 13 ++--- .../transformer/TransformerTest.java | 52 ++++++++----------- 10 files changed, 68 insertions(+), 62 deletions(-) diff --git a/docs/transforming-media.md b/docs/transforming-media.md index ec75d6070f..94f2a17037 100644 --- a/docs/transforming-media.md +++ b/docs/transforming-media.md @@ -32,8 +32,7 @@ transformation that removes the audio track from the input: ~~~ // Configure and create a Transformer instance. Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setRemoveAudio(true) .setListener(transformerListener) .build(); @@ -120,8 +119,7 @@ method. ~~~ Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setFlattenForSlowMotion(true) .setListener(transformerListener) .build(); diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java index a1f92decb0..55c1c06336 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java @@ -30,8 +30,7 @@ public class RemoveAudioTransformationTest { @Test public void removeAudioTransform() throws Exception { Context context = ApplicationProvider.getApplicationContext(); - Transformer transformer = - new Transformer.Builder().setContext(context).setRemoveAudio(true).build(); + Transformer transformer = new Transformer.Builder(context).setRemoveAudio(true).build(); runTransformer(context, transformer, MP4_ASSET_URI_STRING, /* timeoutSeconds= */ 120); } } diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java index 98bc01a299..8ded085aa5 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java @@ -30,8 +30,7 @@ public class RemoveVideoTransformationTest { @Test public void removeVideoTransform() throws Exception { Context context = ApplicationProvider.getApplicationContext(); - Transformer transformer = - new Transformer.Builder().setContext(context).setRemoveVideo(true).build(); + Transformer transformer = new Transformer.Builder(context).setRemoveVideo(true).build(); runTransformer(context, transformer, MP4_ASSET_URI_STRING, /* timeoutSeconds= */ 120); } } diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java index 1270ebba67..194483d740 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java @@ -39,8 +39,7 @@ public final class RepeatedTranscodeTransformationTest { public void repeatedTranscode_givesConsistentLengthOutput() throws Exception { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setVideoMimeType(MimeTypes.VIDEO_H265) .setAudioMimeType(MimeTypes.AUDIO_AMR_NB) .build(); @@ -67,8 +66,7 @@ public final class RepeatedTranscodeTransformationTest { public void repeatedTranscodeNoAudio_givesConsistentLengthOutput() throws Exception { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setVideoMimeType(MimeTypes.VIDEO_H265) .setRemoveAudio(true) .build(); @@ -95,8 +93,7 @@ public final class RepeatedTranscodeTransformationTest { public void repeatedTranscodeNoVideo_givesConsistentLengthOutput() throws Exception { Context context = ApplicationProvider.getApplicationContext(); Transformer transcodingTransformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setAudioMimeType(MimeTypes.AUDIO_AMR_NB) .setRemoveVideo(true) .build(); diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java index 33a6e282d8..c06cfb67fa 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java @@ -31,7 +31,7 @@ public class SefTransformationTest { public void sefTransform() throws Exception { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = - new Transformer.Builder().setContext(context).setFlattenForSlowMotion(true).build(); + new Transformer.Builder(context).setFlattenForSlowMotion(true).build(); runTransformer(context, transformer, SEF_ASSET_URI_STRING, /* timeoutSeconds= */ 120); } } diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java index 246b7a4b74..fb84c6f666 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java @@ -30,7 +30,7 @@ public class TransformationTest { @Test public void transform() throws Exception { Context context = ApplicationProvider.getApplicationContext(); - Transformer transformer = new Transformer.Builder().setContext(context).build(); + Transformer transformer = new Transformer.Builder(context).build(); runTransformer(context, transformer, MP4_ASSET_URI_STRING, /* timeoutSeconds= */ 120); } } diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameworkMuxer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameworkMuxer.java index 182f34e114..b4e3912a7a 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameworkMuxer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameworkMuxer.java @@ -61,7 +61,7 @@ import java.nio.ByteBuffer; public boolean supportsOutputMimeType(String mimeType) { try { mimeTypeToMuxerOutputFormat(mimeType); - } catch (IllegalStateException e) { + } catch (IllegalArgumentException e) { return false; } return true; diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java index 5ed855a56e..ea08c415c7 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java @@ -22,7 +22,6 @@ import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_MAX_BUFFE import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_MIN_BUFFER_MS; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Assertions.checkState; -import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; import static java.lang.Math.min; import android.content.Context; @@ -90,6 +89,8 @@ public final class Transformer { public static final class Builder { // Mandatory field. + // TODO(huangdarwin): Update @MonotonicNonNull to final after deprecated {@link + // #setContext(Context)} is removed. private @MonotonicNonNull Context context; // Optional fields. @@ -106,7 +107,12 @@ public final class Transformer { private Looper looper; private Clock clock; - /** Creates a builder with default values. */ + /** + * Creates a builder with default values. + * + * @deprecated Use {@link #Builder(Context)} instead. + */ + @Deprecated public Builder() { muxerFactory = new FrameworkMuxer.Factory(); outputHeight = Transformation.NO_VALUE; @@ -116,6 +122,22 @@ public final class Transformer { clock = Clock.DEFAULT; } + /** + * Creates a builder with default values. + * + * @param context The {@link Context}. + * @throws NullPointerException If the {@link Context} has not been provided. + */ + public Builder(Context context) { + this.context = context.getApplicationContext(); + muxerFactory = new FrameworkMuxer.Factory(); + outputHeight = Transformation.NO_VALUE; + containerMimeType = MimeTypes.VIDEO_MP4; + listener = new Listener() {}; + looper = Util.getCurrentOrMainLooper(); + clock = Clock.DEFAULT; + } + /** Creates a builder with the values of the provided {@link Transformer}. */ private Builder(Transformer transformer) { this.context = transformer.context; @@ -140,7 +162,9 @@ public final class Transformer { * * @param context The {@link Context}. * @return This builder. + * @deprecated Use {@link #Builder(Context)} instead. */ + @Deprecated public Builder setContext(Context context) { this.context = context.getApplicationContext(); return this; @@ -148,8 +172,8 @@ public final class Transformer { /** * Sets the {@link MediaSourceFactory} to be used to retrieve the inputs to transform. The - * default value is a {@link DefaultMediaSourceFactory} built with the context provided in - * {@link #setContext(Context)}. + * default value is a {@link DefaultMediaSourceFactory} built with the context provided in the + * constructor. * * @param mediaSourceFactory A {@link MediaSourceFactory}. * @return This builder. @@ -366,7 +390,7 @@ public final class Transformer { /** * Builds a {@link Transformer} instance. * - * @throws IllegalStateException If the {@link Context} has not been provided. + * @throws NullPointerException If the {@link Context} has not been provided. * @throws IllegalStateException If both audio and video have been removed (otherwise the output * would not contain any samples). * @throws IllegalStateException If the muxer doesn't support the requested container MIME type. @@ -374,7 +398,9 @@ public final class Transformer { * @throws IllegalStateException If the muxer doesn't support the requested video MIME type. */ public Transformer build() { - checkStateNotNull(context); + // TODO(huangdarwin): Remove this checkNotNull after deprecated {@link #setContext(Context)} + // is removed. + checkNotNull(context); if (mediaSourceFactory == null) { DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory(); if (flattenForSlowMotion) { diff --git a/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/TransformerBuilderTest.java b/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/TransformerBuilderTest.java index 8cfba3156d..8796678596 100644 --- a/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/TransformerBuilderTest.java +++ b/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/TransformerBuilderTest.java @@ -31,14 +31,16 @@ public class TransformerBuilderTest { @Test public void setOutputMimeType_unsupportedMimeType_throws() { + Context context = ApplicationProvider.getApplicationContext(); + assertThrows( IllegalStateException.class, - () -> new Transformer.Builder().setOutputMimeType(MimeTypes.VIDEO_FLV).build()); + () -> new Transformer.Builder(context).setOutputMimeType(MimeTypes.VIDEO_UNKNOWN).build()); } @Test public void build_withoutContext_throws() { - assertThrows(IllegalStateException.class, () -> new Transformer.Builder().build()); + assertThrows(NullPointerException.class, () -> new Transformer.Builder().build()); } @Test @@ -47,11 +49,6 @@ public class TransformerBuilderTest { assertThrows( IllegalStateException.class, - () -> - new Transformer.Builder() - .setContext(context) - .setRemoveAudio(true) - .setRemoveVideo(true) - .build()); + () -> new Transformer.Builder(context).setRemoveAudio(true).setRemoveVideo(true).build()); } } diff --git a/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/TransformerTest.java b/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/TransformerTest.java index 690b6fe2bb..4fea0e999d 100644 --- a/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/TransformerTest.java +++ b/library/transformer/src/test/java/com/google/android/exoplayer2/transformer/TransformerTest.java @@ -92,8 +92,7 @@ public final class TransformerTest { @Test public void startTransformation_videoOnly_completesSuccessfully() throws Exception { Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setClock(clock) .setMuxerFactory(new TestMuxerFactory()) .build(); @@ -108,8 +107,7 @@ public final class TransformerTest { @Test public void startTransformation_audioOnly_completesSuccessfully() throws Exception { Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setClock(clock) .setMuxerFactory(new TestMuxerFactory()) .build(); @@ -124,8 +122,7 @@ public final class TransformerTest { @Test public void startTransformation_audioAndVideo_completesSuccessfully() throws Exception { Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setClock(clock) .setMuxerFactory(new TestMuxerFactory()) .build(); @@ -140,8 +137,7 @@ public final class TransformerTest { @Test public void startTransformation_withSubtitles_completesSuccessfully() throws Exception { Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setClock(clock) .setMuxerFactory(new TestMuxerFactory()) .build(); @@ -157,8 +153,7 @@ public final class TransformerTest { public void startTransformation_successiveTransformations_completesSuccessfully() throws Exception { Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setClock(clock) .setMuxerFactory(new TestMuxerFactory()) .build(); @@ -178,7 +173,7 @@ public final class TransformerTest { @Test public void startTransformation_concurrentTransformations_throwsError() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); MediaItem mediaItem = MediaItem.fromUri(URI_PREFIX + FILE_VIDEO_ONLY); transformer.startTransformation(mediaItem, outputPath); @@ -190,8 +185,7 @@ public final class TransformerTest { @Test public void startTransformation_removeAudio_completesSuccessfully() throws Exception { Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setRemoveAudio(true) .setClock(clock) .setMuxerFactory(new TestMuxerFactory()) @@ -208,8 +202,7 @@ public final class TransformerTest { @Test public void startTransformation_removeVideo_completesSuccessfully() throws Exception { Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setRemoveVideo(true) .setClock(clock) .setMuxerFactory(new TestMuxerFactory()) @@ -226,8 +219,7 @@ public final class TransformerTest { @Test public void startTransformation_flattenForSlowMotion_completesSuccessfully() throws Exception { Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setFlattenForSlowMotion(true) .setClock(clock) .setMuxerFactory(new TestMuxerFactory()) @@ -242,7 +234,7 @@ public final class TransformerTest { @Test public void startTransformation_withPlayerError_completesWithError() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); MediaItem mediaItem = MediaItem.fromUri("asset:///non-existing-path.mp4"); transformer.startTransformation(mediaItem, outputPath); @@ -255,7 +247,7 @@ public final class TransformerTest { @Test public void startTransformation_withAllSampleFormatsUnsupported_completesWithError() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); MediaItem mediaItem = MediaItem.fromUri(URI_PREFIX + FILE_WITH_ALL_SAMPLE_FORMATS_UNSUPPORTED); transformer.startTransformation(mediaItem, outputPath); @@ -267,8 +259,7 @@ public final class TransformerTest { @Test public void startTransformation_afterCancellation_completesSuccessfully() throws Exception { Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setClock(clock) .setMuxerFactory(new TestMuxerFactory()) .build(); @@ -291,8 +282,7 @@ public final class TransformerTest { anotherThread.start(); Looper looper = anotherThread.getLooper(); Transformer transformer = - new Transformer.Builder() - .setContext(context) + new Transformer.Builder(context) .setLooper(looper) .setClock(clock) .setMuxerFactory(new TestMuxerFactory()) @@ -321,7 +311,7 @@ public final class TransformerTest { @Test public void startTransformation_fromWrongThread_throwsError() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); MediaItem mediaItem = MediaItem.fromUri(URI_PREFIX + FILE_AUDIO_ONLY); HandlerThread anotherThread = new HandlerThread("AnotherThread"); AtomicReference illegalStateException = new AtomicReference<>(); @@ -348,7 +338,7 @@ public final class TransformerTest { @Test public void getProgress_knownDuration_returnsConsistentStates() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); MediaItem mediaItem = MediaItem.fromUri(URI_PREFIX + FILE_VIDEO_ONLY); AtomicInteger previousProgressState = new AtomicInteger(PROGRESS_STATE_WAITING_FOR_AVAILABILITY); @@ -394,7 +384,7 @@ public final class TransformerTest { @Test public void getProgress_knownDuration_givesIncreasingPercentages() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); MediaItem mediaItem = MediaItem.fromUri(URI_PREFIX + FILE_VIDEO_ONLY); List progresses = new ArrayList<>(); Handler progressHandler = @@ -429,7 +419,7 @@ public final class TransformerTest { @Test public void getProgress_noCurrentTransformation_returnsNoTransformation() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); MediaItem mediaItem = MediaItem.fromUri(URI_PREFIX + FILE_VIDEO_ONLY); @Transformer.ProgressState int stateBeforeTransform = transformer.getProgress(progressHolder); @@ -443,7 +433,7 @@ public final class TransformerTest { @Test public void getProgress_unknownDuration_returnsConsistentStates() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); MediaItem mediaItem = MediaItem.fromUri(URI_PREFIX + FILE_UNKNOWN_DURATION); AtomicInteger previousProgressState = new AtomicInteger(PROGRESS_STATE_WAITING_FOR_AVAILABILITY); @@ -486,7 +476,7 @@ public final class TransformerTest { @Test public void getProgress_fromWrongThread_throwsError() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); HandlerThread anotherThread = new HandlerThread("AnotherThread"); AtomicReference illegalStateException = new AtomicReference<>(); CountDownLatch countDownLatch = new CountDownLatch(1); @@ -510,7 +500,7 @@ public final class TransformerTest { @Test public void cancel_afterCompletion_doesNotThrow() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); MediaItem mediaItem = MediaItem.fromUri(URI_PREFIX + FILE_VIDEO_ONLY); transformer.startTransformation(mediaItem, outputPath); @@ -520,7 +510,7 @@ public final class TransformerTest { @Test public void cancel_fromWrongThread_throwsError() throws Exception { - Transformer transformer = new Transformer.Builder().setContext(context).setClock(clock).build(); + Transformer transformer = new Transformer.Builder(context).setClock(clock).build(); HandlerThread anotherThread = new HandlerThread("AnotherThread"); AtomicReference illegalStateException = new AtomicReference<>(); CountDownLatch countDownLatch = new CountDownLatch(1); From 8d5b368991805e215e965ce7e18f7d978ebe4c3b Mon Sep 17 00:00:00 2001 From: samrobinson Date: Wed, 24 Nov 2021 18:48:02 +0000 Subject: [PATCH 20/56] Derive test output video name from the TAG. We need the filename of the output videos to be predictable, because MobileHarness requires the exact filename to pull the file. PiperOrigin-RevId: 412092347 --- .../exoplayer2/transformer/AndroidTestUtil.java | 14 ++++++++------ .../transformer/RemoveAudioTransformationTest.java | 7 ++++++- .../transformer/RemoveVideoTransformationTest.java | 7 ++++++- .../RepeatedTranscodeTransformationTest.java | 4 +++- .../transformer/SefTransformationTest.java | 7 ++++++- .../exoplayer2/transformer/TransformationTest.java | 7 ++++++- 6 files changed, 35 insertions(+), 11 deletions(-) diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java index 18a933bcf6..a49f807b9d 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java @@ -50,6 +50,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * Transforms the {@code uriString} with the {@link Transformer}. * * @param context The {@link Context}. + * @param testId An identifier for the test. * @param transformer The {@link Transformer} that performs the transformation. * @param uriString The uri (as a {@link String}) that will be transformed. * @param timeoutSeconds The transformer timeout. An assertion confirms this is not exceeded. @@ -57,7 +58,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * @throws Exception The cause of the transformation not completing. */ public static TransformationResult runTransformer( - Context context, Transformer transformer, String uriString, int timeoutSeconds) + Context context, String testId, Transformer transformer, String uriString, int timeoutSeconds) throws Exception { AtomicReference<@NullableType Exception> exceptionReference = new AtomicReference<>(); CountDownLatch countDownLatch = new CountDownLatch(1); @@ -81,7 +82,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; .build(); Uri uri = Uri.parse(uriString); - File externalCacheFile = createExternalCacheFile(uri, context); + File externalCacheFile = createExternalCacheFile(context, /* filePrefix= */ testId); try { InstrumentationRegistry.getInstrumentation() .runOnMainSync( @@ -108,11 +109,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } } - private static File createExternalCacheFile(Uri uri, Context context) throws IOException { - File file = new File(context.getExternalCacheDir(), "transformer-" + uri.hashCode()); + private static File createExternalCacheFile(Context context, String filePrefix) + throws IOException { + File file = new File(context.getExternalCacheDir(), filePrefix + "-output.mp4"); Assertions.checkState( - !file.exists() || file.delete(), "Could not delete the previous transformer output file"); - Assertions.checkState(file.createNewFile(), "Could not create the transformer output file"); + !file.exists() || file.delete(), "Could not delete the previous transformer output file."); + Assertions.checkState(file.createNewFile(), "Could not create the transformer output file."); return file; } diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java index 55c1c06336..0d775c24ab 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java @@ -31,6 +31,11 @@ public class RemoveAudioTransformationTest { public void removeAudioTransform() throws Exception { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = new Transformer.Builder(context).setRemoveAudio(true).build(); - runTransformer(context, transformer, MP4_ASSET_URI_STRING, /* timeoutSeconds= */ 120); + runTransformer( + context, + /* testId= */ "removeAudioTransform", + transformer, + MP4_ASSET_URI_STRING, + /* timeoutSeconds= */ 120); } } diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java index 8ded085aa5..cf8a86f6fc 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java @@ -31,6 +31,11 @@ public class RemoveVideoTransformationTest { public void removeVideoTransform() throws Exception { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = new Transformer.Builder(context).setRemoveVideo(true).build(); - runTransformer(context, transformer, MP4_ASSET_URI_STRING, /* timeoutSeconds= */ 120); + runTransformer( + context, + /* testId= */ "removeVideoTransform", + transformer, + MP4_ASSET_URI_STRING, + /* timeoutSeconds= */ 120); } } diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java index 194483d740..d1ca15f47f 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java @@ -32,7 +32,6 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) @Ignore("Internal - b/206917996") public final class RepeatedTranscodeTransformationTest { - private static final int TRANSCODE_COUNT = 10; @Test @@ -50,6 +49,7 @@ public final class RepeatedTranscodeTransformationTest { differentOutputSizesBytes.add( runTransformer( context, + /* testId= */ "repeatedTranscode_givesConsistentLengthOutput", transformer, AndroidTestUtil.REMOTE_MP4_10_SECONDS_URI_STRING, /* timeoutSeconds= */ 120) @@ -77,6 +77,7 @@ public final class RepeatedTranscodeTransformationTest { differentOutputSizesBytes.add( runTransformer( context, + /* testId= */ "repeatedTranscodeNoAudio_givesConsistentLengthOutput", transformer, AndroidTestUtil.REMOTE_MP4_10_SECONDS_URI_STRING, /* timeoutSeconds= */ 120) @@ -104,6 +105,7 @@ public final class RepeatedTranscodeTransformationTest { differentOutputSizesBytes.add( runTransformer( context, + /* testId= */ "repeatedTranscodeNoVideo_givesConsistentLengthOutput", transcodingTransformer, AndroidTestUtil.REMOTE_MP4_10_SECONDS_URI_STRING, /* timeoutSeconds= */ 120) diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java index c06cfb67fa..886726916b 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java @@ -32,6 +32,11 @@ public class SefTransformationTest { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = new Transformer.Builder(context).setFlattenForSlowMotion(true).build(); - runTransformer(context, transformer, SEF_ASSET_URI_STRING, /* timeoutSeconds= */ 120); + runTransformer( + context, + /* testId = */ "sefTransform", + transformer, + SEF_ASSET_URI_STRING, + /* timeoutSeconds= */ 120); } } diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java index fb84c6f666..1725358855 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java @@ -31,6 +31,11 @@ public class TransformationTest { public void transform() throws Exception { Context context = ApplicationProvider.getApplicationContext(); Transformer transformer = new Transformer.Builder(context).build(); - runTransformer(context, transformer, MP4_ASSET_URI_STRING, /* timeoutSeconds= */ 120); + runTransformer( + context, + /* testId= */ "transform", + transformer, + MP4_ASSET_URI_STRING, + /* timeoutSeconds= */ 120); } } From 94ef005b179557b85219d19234e18b1259d42c81 Mon Sep 17 00:00:00 2001 From: kimvde Date: Wed, 24 Nov 2021 18:52:55 +0000 Subject: [PATCH 21/56] Rename decoderInputFormat in transformer renderers - This format is passed to the PassthroughPipeline, which doesn't use any decoder. - In most other cases where it is used, it is not relevant that this format will be or has been passed to the decoder. What's relevant is that it is the format of the input. PiperOrigin-RevId: 412093371 --- .../transformer/AudioSamplePipeline.java | 23 +++++++------- .../transformer/TransformerAudioRenderer.java | 8 ++--- .../transformer/TransformerVideoRenderer.java | 13 ++++---- .../transformer/VideoSamplePipeline.java | 31 +++++++++---------- 4 files changed, 35 insertions(+), 40 deletions(-) diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/AudioSamplePipeline.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/AudioSamplePipeline.java index b53da51eb3..042cb8e990 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/AudioSamplePipeline.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/AudioSamplePipeline.java @@ -47,8 +47,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private static final String TAG = "AudioSamplePipeline"; private static final int DEFAULT_ENCODER_BITRATE = 128 * 1024; + private final Format inputFormat; + private final Transformation transformation; + private final int rendererIndex; + private final MediaCodecAdapterWrapper decoder; - private final Format decoderInputFormat; private final DecoderInputBuffer decoderInputBuffer; private final SonicAudioProcessor sonicAudioProcessor; @@ -57,9 +60,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final DecoderInputBuffer encoderInputBuffer; private final DecoderInputBuffer encoderOutputBuffer; - private final Transformation transformation; - private final int rendererIndex; - private @MonotonicNonNull AudioFormat encoderInputAudioFormat; private @MonotonicNonNull MediaCodecAdapterWrapper encoder; private long nextEncoderInputBufferTimeUs; @@ -69,10 +69,9 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private boolean drainingSonicForSpeedChange; private float currentSpeed; - public AudioSamplePipeline( - Format decoderInputFormat, Transformation transformation, int rendererIndex) + public AudioSamplePipeline(Format inputFormat, Transformation transformation, int rendererIndex) throws ExoPlaybackException { - this.decoderInputFormat = decoderInputFormat; + this.inputFormat = inputFormat; this.transformation = transformation; this.rendererIndex = rendererIndex; decoderInputBuffer = @@ -83,17 +82,17 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED); sonicAudioProcessor = new SonicAudioProcessor(); sonicOutputBuffer = AudioProcessor.EMPTY_BUFFER; - speedProvider = new SegmentSpeedProvider(decoderInputFormat); + speedProvider = new SegmentSpeedProvider(inputFormat); currentSpeed = speedProvider.getSpeed(0); try { - this.decoder = MediaCodecAdapterWrapper.createForAudioDecoding(decoderInputFormat); + this.decoder = MediaCodecAdapterWrapper.createForAudioDecoding(inputFormat); } catch (IOException e) { // TODO(internal b/192864511): Assign a specific error code. throw ExoPlaybackException.createForRenderer( e, TAG, rendererIndex, - decoderInputFormat, + inputFormat, /* rendererFormatSupport= */ C.FORMAT_HANDLED, /* isRecoverable= */ false, PlaybackException.ERROR_CODE_UNSPECIFIED); @@ -319,7 +318,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } String audioMimeType = transformation.audioMimeType == null - ? decoderInputFormat.sampleMimeType + ? inputFormat.sampleMimeType : transformation.audioMimeType; try { encoder = @@ -359,7 +358,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; cause, TAG, rendererIndex, - decoderInputFormat, + inputFormat, /* rendererFormatSupport= */ C.FORMAT_HANDLED, /* isRecoverable= */ false, errorCode); diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java index e57150c996..8f71a54ba3 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java @@ -58,13 +58,13 @@ import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; if (result != C.RESULT_FORMAT_READ) { return false; } - Format decoderInputFormat = checkNotNull(formatHolder.format); + Format inputFormat = checkNotNull(formatHolder.format); if ((transformation.audioMimeType != null - && !transformation.audioMimeType.equals(decoderInputFormat.sampleMimeType)) + && !transformation.audioMimeType.equals(inputFormat.sampleMimeType)) || transformation.flattenForSlowMotion) { - samplePipeline = new AudioSamplePipeline(decoderInputFormat, transformation, getIndex()); + samplePipeline = new AudioSamplePipeline(inputFormat, transformation, getIndex()); } else { - samplePipeline = new PassthroughSamplePipeline(decoderInputFormat); + samplePipeline = new PassthroughSamplePipeline(inputFormat); } return true; } diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java index 1541dc7fe6..f4ccefc26f 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java @@ -69,18 +69,17 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; if (result != C.RESULT_FORMAT_READ) { return false; } - Format decoderInputFormat = checkNotNull(formatHolder.format); + Format inputFormat = checkNotNull(formatHolder.format); if ((transformation.videoMimeType != null - && !transformation.videoMimeType.equals(decoderInputFormat.sampleMimeType)) + && !transformation.videoMimeType.equals(inputFormat.sampleMimeType)) || (transformation.outputHeight != Transformation.NO_VALUE - && transformation.outputHeight != decoderInputFormat.height)) { - samplePipeline = - new VideoSamplePipeline(context, decoderInputFormat, transformation, getIndex()); + && transformation.outputHeight != inputFormat.height)) { + samplePipeline = new VideoSamplePipeline(context, inputFormat, transformation, getIndex()); } else { - samplePipeline = new PassthroughSamplePipeline(decoderInputFormat); + samplePipeline = new PassthroughSamplePipeline(inputFormat); } if (transformation.flattenForSlowMotion) { - sefSlowMotionFlattener = new SefSlowMotionFlattener(decoderInputFormat); + sefSlowMotionFlattener = new SefSlowMotionFlattener(inputFormat); } return true; } diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java index 9cb698efcd..46c0ef23d1 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java @@ -38,32 +38,29 @@ import java.io.IOException; private static final String TAG = "VideoSamplePipeline"; - private final MediaCodecAdapterWrapper encoder; - private final DecoderInputBuffer encoderOutputBuffer; - private final DecoderInputBuffer decoderInputBuffer; private final MediaCodecAdapterWrapper decoder; private final FrameEditor frameEditor; + private final MediaCodecAdapterWrapper encoder; + private final DecoderInputBuffer encoderOutputBuffer; + private boolean waitingForPopulatedDecoderSurface; public VideoSamplePipeline( - Context context, Format decoderInputFormat, Transformation transformation, int rendererIndex) + Context context, Format inputFormat, Transformation transformation, int rendererIndex) throws ExoPlaybackException { - decoderInputBuffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED); - encoderOutputBuffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED); - int outputWidth = decoderInputFormat.width; - int outputHeight = decoderInputFormat.height; + int outputWidth = inputFormat.width; + int outputHeight = inputFormat.height; if (transformation.outputHeight != Transformation.NO_VALUE - && transformation.outputHeight != decoderInputFormat.height) { - outputWidth = - decoderInputFormat.width * transformation.outputHeight / decoderInputFormat.height; + && transformation.outputHeight != inputFormat.height) { + outputWidth = inputFormat.width * transformation.outputHeight / inputFormat.height; outputHeight = transformation.outputHeight; } @@ -76,13 +73,13 @@ import java.io.IOException; .setSampleMimeType( transformation.videoMimeType != null ? transformation.videoMimeType - : decoderInputFormat.sampleMimeType) + : inputFormat.sampleMimeType) .build(), ImmutableMap.of()); } catch (IOException e) { // TODO(internal b/192864511): Assign a specific error code. throw createRendererException( - e, rendererIndex, decoderInputFormat, PlaybackException.ERROR_CODE_UNSPECIFIED); + e, rendererIndex, inputFormat, PlaybackException.ERROR_CODE_UNSPECIFIED); } frameEditor = FrameEditor.create( @@ -93,10 +90,10 @@ import java.io.IOException; try { decoder = MediaCodecAdapterWrapper.createForVideoDecoding( - decoderInputFormat, frameEditor.getInputSurface()); + inputFormat, frameEditor.getInputSurface()); } catch (IOException e) { throw createRendererException( - e, rendererIndex, decoderInputFormat, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED); + e, rendererIndex, inputFormat, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED); } } @@ -172,12 +169,12 @@ import java.io.IOException; } private static ExoPlaybackException createRendererException( - Throwable cause, int rendererIndex, Format decoderInputFormat, int errorCode) { + Throwable cause, int rendererIndex, Format inputFormat, int errorCode) { return ExoPlaybackException.createForRenderer( cause, TAG, rendererIndex, - decoderInputFormat, + inputFormat, /* rendererFormatSupport= */ C.FORMAT_HANDLED, /* isRecoverable= */ false, errorCode); From be0b2b8c8c496d7e8349d174e8a6c1dfe8d23df9 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 25 Nov 2021 10:01:47 +0000 Subject: [PATCH 22/56] Move MediaMetricsListener creation to static constructor method. This allows to check if the media metrics service is available outside the actual constructor and to fail gracefully if it is missing. PiperOrigin-RevId: 412232425 --- .../analytics/MediaMetricsListener.java | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/MediaMetricsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/MediaMetricsListener.java index f085a6bae1..15340b4e30 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/MediaMetricsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/MediaMetricsListener.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer2.analytics; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; import static com.google.android.exoplayer2.util.Util.castNonNull; import android.annotation.SuppressLint; @@ -90,6 +89,23 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; public final class MediaMetricsListener implements AnalyticsListener, PlaybackSessionManager.Listener { + /** + * Creates a media metrics listener. + * + * @param context A context. + * @return The {@link MediaMetricsListener}, or null if the {@link Context#MEDIA_METRICS_SERVICE + * media metrics service} isn't available. + */ + @Nullable + public static MediaMetricsListener create(Context context) { + @Nullable + MediaMetricsManager mediaMetricsManager = + (MediaMetricsManager) context.getSystemService(Context.MEDIA_METRICS_SERVICE); + return mediaMetricsManager == null + ? null + : new MediaMetricsListener(context, mediaMetricsManager.createPlaybackSession()); + } + private final Context context; private final PlaybackSessionManager sessionManager; private final PlaybackSession playbackSession; @@ -122,15 +138,12 @@ public final class MediaMetricsListener * * @param context A {@link Context}. */ - public MediaMetricsListener(Context context) { + private MediaMetricsListener(Context context, PlaybackSession playbackSession) { context = context.getApplicationContext(); this.context = context; + this.playbackSession = playbackSession; window = new Timeline.Window(); period = new Timeline.Period(); - MediaMetricsManager mediaMetricsManager = - checkStateNotNull( - (MediaMetricsManager) context.getSystemService(Context.MEDIA_METRICS_SERVICE)); - playbackSession = mediaMetricsManager.createPlaybackSession(); startTimeMs = SystemClock.elapsedRealtime(); currentPlaybackState = PlaybackStateEvent.STATE_NOT_STARTED; currentNetworkType = NetworkEvent.NETWORK_TYPE_UNKNOWN; From 3cc64ae2df0f576112995d95c37e8dedc22e5585 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Thu, 25 Nov 2021 11:58:12 +0000 Subject: [PATCH 23/56] Pull files from the device cache after a MH test concludes. PiperOrigin-RevId: 412251020 --- .../transformer/AndroidTestUtil.java | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java index a49f807b9d..5b93b7ba38 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java @@ -83,30 +83,26 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; Uri uri = Uri.parse(uriString); File externalCacheFile = createExternalCacheFile(context, /* filePrefix= */ testId); - try { - InstrumentationRegistry.getInstrumentation() - .runOnMainSync( - () -> { - try { - testTransformer.startTransformation( - MediaItem.fromUri(uri), externalCacheFile.getAbsolutePath()); - } catch (IOException e) { - exceptionReference.set(e); - } - }); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + try { + testTransformer.startTransformation( + MediaItem.fromUri(uri), externalCacheFile.getAbsolutePath()); + } catch (IOException e) { + exceptionReference.set(e); + } + }); - assertWithMessage("Transformer timed out after " + timeoutSeconds + " seconds.") - .that(countDownLatch.await(timeoutSeconds, SECONDS)) - .isTrue(); - @Nullable Exception exception = exceptionReference.get(); - if (exception != null) { - throw exception; - } - long outputSizeBytes = externalCacheFile.length(); - return new TransformationResult(outputSizeBytes); - } finally { - externalCacheFile.delete(); + assertWithMessage("Transformer timed out after " + timeoutSeconds + " seconds.") + .that(countDownLatch.await(timeoutSeconds, SECONDS)) + .isTrue(); + @Nullable Exception exception = exceptionReference.get(); + if (exception != null) { + throw exception; } + long outputSizeBytes = externalCacheFile.length(); + return new TransformationResult(outputSizeBytes); } private static File createExternalCacheFile(Context context, String filePrefix) From 276f103c896cfb55e4f1f4fdd80a9c0407338672 Mon Sep 17 00:00:00 2001 From: ibaker Date: Thu, 25 Nov 2021 13:48:15 +0000 Subject: [PATCH 24/56] Parse DASH forced-subtitle role value Issue: google/ExoPlayer#9727 #minor-release PiperOrigin-RevId: 412266397 --- RELEASENOTES.md | 3 +++ .../source/dash/manifest/DashManifestParser.java | 4 ++++ .../source/dash/manifest/DashManifestParserTest.java | 9 ++++++++- testdata/src/test/assets/media/mpd/sample_mpd_text | 8 +++++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3b34c286c7..9921db756e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -11,6 +11,9 @@ * Add a method to `AdPlaybackState` to allow resetting an ad group so that it can be played again ([#9615](https://github.com/google/ExoPlayer/issues/9615)). +* DASH: + * Support the `forced-subtitle` track role + ([#9727](https://github.com/google/ExoPlayer/issues/9727)). * HLS: * Support key-frame accurate seeking in HLS ([#2882](https://github.com/google/ExoPlayer/issues/2882)). diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java index ebafbee31d..4ade8ce361 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParser.java @@ -1475,6 +1475,8 @@ public class DashManifestParser extends DefaultHandler case "main": return C.SELECTION_FLAG_DEFAULT; case "forced_subtitle": + // Support both hyphen and underscore (https://github.com/google/ExoPlayer/issues/9727). + case "forced-subtitle": return C.SELECTION_FLAG_FORCED; default: return 0; @@ -1545,6 +1547,8 @@ public class DashManifestParser extends DefaultHandler case "caption": return C.ROLE_FLAG_CAPTION; case "forced_subtitle": + // Support both hyphen and underscore (https://github.com/google/ExoPlayer/issues/9727). + case "forced-subtitle": case "subtitle": return C.ROLE_FLAG_SUBTITLE; case "sign": diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java index 6926a05859..cb2d216c32 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/manifest/DashManifestParserTest.java @@ -254,7 +254,13 @@ public class DashManifestParserTest { assertThat(format.selectionFlags).isEqualTo(C.SELECTION_FLAG_FORCED); assertThat(adaptationSets.get(1).type).isEqualTo(C.TRACK_TYPE_TEXT); + // Ensure that forced-subtitle and forced_subtitle are both parsed as a 'forced' text track. + // https://github.com/google/ExoPlayer/issues/9727 format = adaptationSets.get(2).representations.get(0).format; + assertThat(format.roleFlags).isEqualTo(C.ROLE_FLAG_SUBTITLE); + assertThat(format.selectionFlags).isEqualTo(C.SELECTION_FLAG_FORCED); + + format = adaptationSets.get(3).representations.get(0).format; assertThat(format.containerMimeType).isEqualTo(MimeTypes.APPLICATION_TTML); assertThat(format.sampleMimeType).isEqualTo(MimeTypes.APPLICATION_TTML); assertThat(format.codecs).isNull(); @@ -586,10 +592,11 @@ public class DashManifestParserTest { assertThat(manifest.getPeriodCount()).isEqualTo(1); List adaptationSets = manifest.getPeriod(0).adaptationSets; - assertThat(adaptationSets).hasSize(3); + assertThat(adaptationSets).hasSize(4); assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(0))).isEqualTo(C.TIME_UNSET); assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(1))).isEqualTo(C.TIME_UNSET); assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(2))).isEqualTo(C.TIME_UNSET); + assertThat(getAvailabilityTimeOffsetUs(adaptationSets.get(3))).isEqualTo(C.TIME_UNSET); } @Test diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_text b/testdata/src/test/assets/media/mpd/sample_mpd_text index f940527037..4220f5b2f2 100644 --- a/testdata/src/test/assets/media/mpd/sample_mpd_text +++ b/testdata/src/test/assets/media/mpd/sample_mpd_text @@ -13,11 +13,17 @@ - + https://test.com/0 + + + + https://test.com/0 + + https://test.com/0 From e846e9f06c7851b24def0548d8c1f4bc3f7997af Mon Sep 17 00:00:00 2001 From: kimvde Date: Thu, 25 Nov 2021 16:11:58 +0000 Subject: [PATCH 25/56] Miscellaneous small fixes in Transformer PiperOrigin-RevId: 412286692 --- .../transformer/Transformation.java | 3 -- .../exoplayer2/transformer/Transformer.java | 31 ++++++------------- .../transformer/TransformerAudioRenderer.java | 2 +- .../transformer/TransformerBaseRenderer.java | 26 ++++++++-------- .../transformer/TransformerVideoRenderer.java | 4 +-- .../transformer/VideoSamplePipeline.java | 2 +- 6 files changed, 26 insertions(+), 42 deletions(-) diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformation.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformation.java index b1baee9001..5a087183d4 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformation.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformation.java @@ -21,9 +21,6 @@ import androidx.annotation.Nullable; /** A media transformation configuration. */ /* package */ final class Transformation { - /** A value for various fields to indicate that the field's value is unknown or not set. */ - public static final int NO_VALUE = -1; - public final boolean removeAudio; public final boolean removeVideo; public final boolean flattenForSlowMotion; diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java index ea08c415c7..74c5733ee4 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/Transformer.java @@ -38,6 +38,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.DefaultLoadControl; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.Player; @@ -107,15 +108,11 @@ public final class Transformer { private Looper looper; private Clock clock; - /** - * Creates a builder with default values. - * - * @deprecated Use {@link #Builder(Context)} instead. - */ + /** @deprecated Use {@link #Builder(Context)} instead. */ @Deprecated public Builder() { muxerFactory = new FrameworkMuxer.Factory(); - outputHeight = Transformation.NO_VALUE; + outputHeight = Format.NO_VALUE; containerMimeType = MimeTypes.VIDEO_MP4; listener = new Listener() {}; looper = Util.getCurrentOrMainLooper(); @@ -126,12 +123,11 @@ public final class Transformer { * Creates a builder with default values. * * @param context The {@link Context}. - * @throws NullPointerException If the {@link Context} has not been provided. */ public Builder(Context context) { this.context = context.getApplicationContext(); muxerFactory = new FrameworkMuxer.Factory(); - outputHeight = Transformation.NO_VALUE; + outputHeight = Format.NO_VALUE; containerMimeType = MimeTypes.VIDEO_MP4; listener = new Listener() {}; looper = Util.getCurrentOrMainLooper(); @@ -155,15 +151,7 @@ public final class Transformer { this.clock = transformer.clock; } - /** - * Sets the {@link Context}. - * - *

This parameter is mandatory. - * - * @param context The {@link Context}. - * @return This builder. - * @deprecated Use {@link #Builder(Context)} instead. - */ + /** @deprecated Use {@link #Builder(Context)} instead. */ @Deprecated public Builder setContext(Context context) { this.context = context.getApplicationContext(); @@ -172,8 +160,8 @@ public final class Transformer { /** * Sets the {@link MediaSourceFactory} to be used to retrieve the inputs to transform. The - * default value is a {@link DefaultMediaSourceFactory} built with the context provided in the - * constructor. + * default value is a {@link DefaultMediaSourceFactory} built with the context provided in + * {@link #Builder(Context) the constructor}. * * @param mediaSourceFactory A {@link MediaSourceFactory}. * @return This builder. @@ -242,9 +230,8 @@ public final class Transformer { } /** - * Sets the output resolution using the output height. The default value is {@link - * Transformation#NO_VALUE}, which will use the same height as the input. Output width will - * scale to preserve the input video's aspect ratio. + * Sets the output resolution using the output height. The default value is the same height as + * the input. Output width will scale to preserve the input video's aspect ratio. * *

For now, only "popular" heights like 240, 360, 480, 720, 1080, 1440, or 2160 are * supported, to ensure compatibility on different devices. diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java index 8f71a54ba3..e1a2d35f0d 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java @@ -46,7 +46,7 @@ import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; return TAG; } - /** Attempts to read the input format and to initialize the sample or passthrough pipeline. */ + /** Attempts to read the input format and to initialize the {@link SamplePipeline}. */ @Override protected boolean ensureConfigured() throws ExoPlaybackException { if (samplePipeline != null) { diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java index 63246a5dbe..bf064520b2 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java @@ -80,13 +80,13 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } @Override - public final boolean isReady() { - return isSourceReady(); + public final MediaClock getMediaClock() { + return mediaClock; } @Override - public final MediaClock getMediaClock() { - return mediaClock; + public final boolean isReady() { + return isSourceReady(); } @Override @@ -108,15 +108,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; this.streamOffsetUs = offsetUs; } - @Override - protected final void onReset() { - if (samplePipeline != null) { - samplePipeline.release(); - } - muxerWrapperTrackAdded = false; - muxerWrapperTrackEnded = false; - } - @Override protected final void onEnabled(boolean joining, boolean mayRenderStartOfStream) { muxerWrapper.registerTrack(); @@ -133,6 +124,15 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; isRendererStarted = false; } + @Override + protected final void onReset() { + if (samplePipeline != null) { + samplePipeline.release(); + } + muxerWrapperTrackAdded = false; + muxerWrapperTrackEnded = false; + } + @EnsuresNonNullIf(expression = "samplePipeline", result = true) protected abstract boolean ensureConfigured() throws ExoPlaybackException; diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java index f4ccefc26f..b577ef86e7 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerVideoRenderer.java @@ -57,7 +57,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return TAG; } - /** Attempts to read the input format and to initialize the sample or passthrough pipeline. */ + /** Attempts to read the input format and to initialize the {@link SamplePipeline}. */ @Override protected boolean ensureConfigured() throws ExoPlaybackException { if (samplePipeline != null) { @@ -72,7 +72,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; Format inputFormat = checkNotNull(formatHolder.format); if ((transformation.videoMimeType != null && !transformation.videoMimeType.equals(inputFormat.sampleMimeType)) - || (transformation.outputHeight != Transformation.NO_VALUE + || (transformation.outputHeight != Format.NO_VALUE && transformation.outputHeight != inputFormat.height)) { samplePipeline = new VideoSamplePipeline(context, inputFormat, transformation, getIndex()); } else { diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java index 46c0ef23d1..53c924dbb0 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/VideoSamplePipeline.java @@ -58,7 +58,7 @@ import java.io.IOException; int outputWidth = inputFormat.width; int outputHeight = inputFormat.height; - if (transformation.outputHeight != Transformation.NO_VALUE + if (transformation.outputHeight != Format.NO_VALUE && transformation.outputHeight != inputFormat.height) { outputWidth = inputFormat.width * transformation.outputHeight / inputFormat.height; outputHeight = transformation.outputHeight; From 339d99b8601923678f27f3c71d85e842594bb81e Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 25 Nov 2021 17:01:15 +0000 Subject: [PATCH 26/56] Add preferredVideoRoleFlags to TrackSelectionParameters. And also tweak existing role flag logic to strictly prefer perfect matches over partial matches. Caveat: Video role flags only supported for fixed track selections (same issue as Issue: google/ExoPlayer#9519). Issue: google/ExoPlayer#9402 PiperOrigin-RevId: 412292835 --- RELEASENOTES.md | 104 +++++++++--------- .../TrackSelectionParameters.java | 29 +++++ .../trackselection/DefaultTrackSelector.java | 30 ++++- .../DefaultTrackSelectorTest.java | 85 +++++++++++++- 4 files changed, 192 insertions(+), 56 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9921db756e..bb68a80e80 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,9 @@ ### dev-v2 (not yet released) +* Core library: + * Support preferred video role flags in track selection + ((#9402)[https://github.com/google/ExoPlayer/issues/9402]). * DRM: * Remove `playbackLooper` from `DrmSessionManager.(pre)acquireSession`. When a `DrmSessionManager` is used by an app in a custom `MediaSource`, @@ -545,8 +548,8 @@ * The most used methods of `Player`'s audio, video, text and metadata components have been added directly to `Player`. * Add `Player.getAvailableCommands`, `Player.isCommandAvailable` and - `Listener.onAvailableCommandsChanged` to query which commands - that can be executed on the player. + `Listener.onAvailableCommandsChanged` to query which commands that can + be executed on the player. * Add a `Player.Listener` interface to receive all player events. Component listeners and `EventListener` have been deprecated. * Add `Player.getMediaMetadata`, which returns a combined and structured @@ -555,8 +558,8 @@ * `Player.setPlaybackParameters` no longer accepts null, use `PlaybackParameters.DEFAULT` instead. * Report information about the old and the new playback positions to - `Listener.onPositionDiscontinuity`. Add `DISCONTINUITY_REASON_SKIP` - and `DISCONTINUITY_REASON_REMOVE` as discontinuity reasons, and rename + `Listener.onPositionDiscontinuity`. Add `DISCONTINUITY_REASON_SKIP` and + `DISCONTINUITY_REASON_REMOVE` as discontinuity reasons, and rename `DISCONTINUITY_REASON_PERIOD_TRANSITION` to `DISCONTINUITY_REASON_AUTO_TRANSITION`. Remove `DISCONTINUITY_REASON_AD_INSERTION`, for which @@ -611,8 +614,8 @@ dispatched for each track in each period. * Include the session state in DRM session-acquired listener methods. * UI: - * Add `PlayerNotificationManager.Builder`, with the ability to - specify which group the notification should belong to. + * Add `PlayerNotificationManager.Builder`, with the ability to specify + which group the notification should belong to. * Remove `setUseSensorRotation` from `PlayerView` and `StyledPlayerView`. Instead, cast the view returned by `getVideoSurfaceView` to `SphericalGLSurfaceView`, and then call `setUseSensorRotation` on the @@ -684,7 +687,8 @@ ### 2.13.3 (2021-04-14) -* Published via the Google Maven repository (i.e., google()) rather than JCenter. +* Published via the Google Maven repository (i.e., google()) rather than + JCenter. * Core: * Reset playback speed when live playback speed control becomes unused ([#8664](https://github.com/google/ExoPlayer/issues/8664)). @@ -839,8 +843,8 @@ * Remove `Player.setVideoDecoderOutputBufferRenderer` from Player API. Use `setVideoSurfaceView` and `clearVideoSurfaceView` instead. * Default `SingleSampleMediaSource.treatLoadErrorsAsEndOfStream` to `true` - so that errors loading external subtitle files do not cause playback - to fail ([#8430](https://github.com/google/ExoPlayer/issues/8430)). A + so that errors loading external subtitle files do not cause playback to + fail ([#8430](https://github.com/google/ExoPlayer/issues/8430)). A warning will be logged by `SingleSampleMediaPeriod` whenever a load error is treated as though the end of the stream has been reached. * Time out on release to prevent ANRs if an underlying platform call is @@ -921,9 +925,8 @@ ([#7847](https://github.com/google/ExoPlayer/issues/7847)). * Drop key and provision responses if `DefaultDrmSession` is released while waiting for the response. This prevents harmless log messages of - the form: - `IllegalStateException: sending message to a Handler on a dead thread` - ([#8328](https://github.com/google/ExoPlayer/issues/8328)). + the form: `IllegalStateException: sending message to a Handler on a dead + thread` ([#8328](https://github.com/google/ExoPlayer/issues/8328)). * Allow apps to fully customize DRM behaviour for each `MediaItem` by passing a `DrmSessionManagerProvider` to `MediaSourceFactory` ([#8466](https://github.com/google/ExoPlayer/issues/8466)). @@ -938,8 +941,8 @@ existing decoder instance for the new format, and if not then the reasons why. * Video: - * Fall back to AVC/HEVC decoders for Dolby Vision streams with level 10 - to 13 ([#8530](https://github.com/google/ExoPlayer/issues/8530)). + * Fall back to AVC/HEVC decoders for Dolby Vision streams with level 10 to + 13 ([#8530](https://github.com/google/ExoPlayer/issues/8530)). * Fix VP9 format capability checks on API level 23 and earlier. The platform does not correctly report the VP9 level supported by the decoder in this case, so we estimate it based on the decoder's maximum @@ -1021,8 +1024,8 @@ * `ExtractorsMediaSource.Factory.setMinLoadableRetryCount(int)`. Use `ExtractorsMediaSource.Factory.setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)` instead. - * `FixedTrackSelection.Factory`. If you need to disable adaptive - selection in `DefaultTrackSelector`, enable the + * `FixedTrackSelection.Factory`. If you need to disable adaptive selection + in `DefaultTrackSelector`, enable the `DefaultTrackSelector.Parameters.forceHighestSupportedBitrate` flag. * `HlsMediaSource.Factory.setMinLoadableRetryCount(int)`. Use `HlsMediaSource.Factory.setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)` @@ -1035,8 +1038,8 @@ `MappedTrackInfo.getUnmappedTrackGroups()` instead. * `MappedTrackInfo.length`. Use `MappedTrackInfo.getRendererCount()` instead. - * `Player.DefaultEventListener.onTimelineChanged(Timeline, Object)`. - Use `Player.EventListener.onTimelineChanged(Timeline, int)` instead. + * `Player.DefaultEventListener.onTimelineChanged(Timeline, Object)`. Use + `Player.EventListener.onTimelineChanged(Timeline, int)` instead. * `Player.setAudioAttributes(AudioAttributes)`. Use `Player.AudioComponent.setAudioAttributes(AudioAttributes, boolean)` instead. @@ -1052,8 +1055,8 @@ `SimpleExoPlayer.removeVideoListener(VideoListener)` instead. * `SimpleExoPlayer.getAudioStreamType()`. Use `SimpleExoPlayer.getAudioAttributes()` instead. - * `SimpleExoPlayer.setAudioDebugListener(AudioRendererEventListener)`. - Use `SimpleExoPlayer.addAnalyticsListener(AnalyticsListener)` instead. + * `SimpleExoPlayer.setAudioDebugListener(AudioRendererEventListener)`. Use + `SimpleExoPlayer.addAnalyticsListener(AnalyticsListener)` instead. * `SimpleExoPlayer.setAudioStreamType(int)`. Use `SimpleExoPlayer.setAudioAttributes(AudioAttributes)` instead. * `SimpleExoPlayer.setMetadataOutput(MetadataOutput)`. Use @@ -1064,12 +1067,11 @@ * `SimpleExoPlayer.setPlaybackParams(PlaybackParams)`. Use `SimpleExoPlayer.setPlaybackParameters(PlaybackParameters)` instead. * `SimpleExoPlayer.setTextOutput(TextOutput)`. Use - `SimpleExoPlayer.addTextOutput(TextOutput)` instead. If your - application is calling `SimpleExoPlayer.setTextOutput(null)`, make sure - to replace this call with a call to - `SimpleExoPlayer.removeTextOutput(TextOutput)`. - * `SimpleExoPlayer.setVideoDebugListener(VideoRendererEventListener)`. - Use `SimpleExoPlayer.addAnalyticsListener(AnalyticsListener)` instead. + `SimpleExoPlayer.addTextOutput(TextOutput)` instead. If your application + is calling `SimpleExoPlayer.setTextOutput(null)`, make sure to replace + this call with a call to `SimpleExoPlayer.removeTextOutput(TextOutput)`. + * `SimpleExoPlayer.setVideoDebugListener(VideoRendererEventListener)`. Use + `SimpleExoPlayer.addAnalyticsListener(AnalyticsListener)` instead. * `SimpleExoPlayer.setVideoListener(VideoListener)`. Use `SimpleExoPlayer.addVideoListener(VideoListener)` instead. If your application is calling `SimpleExoPlayer.setVideoListener(null)`, make @@ -1093,7 +1095,7 @@ `SsMediaSource.Factory.setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)` instead. -### 2.12.3 (2021-01-13) ### +### 2.12.3 (2021-01-13) * Core library: * Fix `MediaCodecRenderer` issue where empty streams would fail to play in @@ -1130,7 +1132,7 @@ fix a deadlock while creating PlaybackStateCompat internally. ([#8011](https://github.com/google/ExoPlayer/issues/8011)). -### 2.12.2 (2020-12-01) ### +### 2.12.2 (2020-12-01) * Core library: * Suppress exceptions from registering and unregistering the stream volume @@ -1191,7 +1193,7 @@ * Allow to remove all playlist items that makes the player reset ([#8047](https://github.com/google/ExoPlayer/issues/8047)). -### 2.12.1 (2020-10-23) ### +### 2.12.1 (2020-10-23) * Core library: * Fix issue where `Player.setMediaItems` would ignore its `resetPosition` @@ -1230,7 +1232,7 @@ ([#8058](https://github.com/google/ExoPlayer/issues/8058)). * Extractors: * MP4: - * Add support for `_mp2` boxes + * Add support for `_mp2` boxes ([#7967](https://github.com/google/ExoPlayer/issues/7967)). * Fix playback of files containing `pcm_alaw` or `pcm_mulaw` audio tracks, by enabling sample rechunking for such tracks. @@ -1266,11 +1268,11 @@ ([#7961](https://github.com/google/ExoPlayer/issues/7961)). * Fix incorrect truncation of large cue point positions ([#8067](https://github.com/google/ExoPlayer/issues/8067)). - * Upgrade IMA SDK dependency to 3.20.1. This brings in a fix for - companion ads rendering when targeting API 29 + * Upgrade IMA SDK dependency to 3.20.1. This brings in a fix for companion + ads rendering when targeting API 29 ([#6432](https://github.com/google/ExoPlayer/issues/6432)). -### 2.12.0 (2020-09-11) ### +### 2.12.0 (2020-09-11) To learn more about what's new in 2.12, read the corresponding [blog post](https://medium.com/google-exoplayer/exoplayer-2-12-whats-new-e43ef8ff72e7). @@ -1301,8 +1303,7 @@ To learn more about what's new in 2.12, read the corresponding * Remove `PlaybackParameters.skipSilence`, and replace it with `AudioComponent.setSkipSilenceEnabled`. This method is also available on `SimpleExoPlayer`. An - `AudioListener.onSkipSilenceEnabledChanged` callback is also - added. + `AudioListener.onSkipSilenceEnabledChanged` callback is also added. * Add `TextComponent.getCurrentCues` to get the current cues. This method is also available on `SimpleExoPlayer`. The current cues are no longer automatically forwarded to a `TextOutput` when it's added @@ -1630,20 +1631,19 @@ To learn more about what's new in 2.12, read the corresponding * Add support for downloading DRM-protected content using offline Widevine licenses. -### 2.11.8 (2020-08-25) ### +### 2.11.8 (2020-08-25) -* Fix distorted playback of floating point audio when samples exceed the - `[-1, 1]` nominal range. +* Fix distorted playback of floating point audio when samples exceed the `[-1, + 1]` nominal range. * MP4: * Add support for `piff` and `isml` brands ([#7584](https://github.com/google/ExoPlayer/issues/7584)). * Fix playback of very short MP4 files. * FMP4: - * Fix `saiz` and `senc` sample count checks, resolving a "length - mismatch" `ParserException` when playing certain protected FMP4 streams + * Fix `saiz` and `senc` sample count checks, resolving a "length mismatch" + `ParserException` when playing certain protected FMP4 streams ([#7592](https://github.com/google/ExoPlayer/issues/7592)). - * Fix handling of `traf` boxes containing multiple `sbgp` or `sgpd` - boxes. + * Fix handling of `traf` boxes containing multiple `sbgp` or `sgpd` boxes. * FLV: Ignore `SCRIPTDATA` segments with invalid name types, rather than failing playback ([#7675](https://github.com/google/ExoPlayer/issues/7675)). * Better infer the content type of `.ism` and `.isml` streaming URLs. @@ -1656,12 +1656,12 @@ To learn more about what's new in 2.12, read the corresponding * Demo app: Fix playback of ClearKey protected content on API level 26 and earlier ([#7735](https://github.com/google/ExoPlayer/issues/7735)). -### 2.11.7 (2020-06-29) ### +### 2.11.7 (2020-06-29) * IMA extension: Fix the way postroll "content complete" notifications are handled to avoid repeatedly refreshing the timeline after playback ends. -### 2.11.6 (2020-06-19) ### +### 2.11.6 (2020-06-19) * UI: Prevent `PlayerView` from temporarily hiding the video surface when seeking to an unprepared period within the current window. For example when @@ -1676,14 +1676,14 @@ To learn more about what's new in 2.12, read the corresponding ([#7508](https://github.com/google/ExoPlayer/issues/7508)). * Fix a bug where the number of ads in an ad group couldn't change ([#7477](https://github.com/google/ExoPlayer/issues/7477)). - * Work around unexpected `pauseAd`/`stopAd` for ads that have preloaded - on seeking to another position + * Work around unexpected `pauseAd`/`stopAd` for ads that have preloaded on + seeking to another position ([#7492](https://github.com/google/ExoPlayer/issues/7492)). * Fix incorrect rounding of ad cue points. * Fix handling of postrolls preloading ([#7518](https://github.com/google/ExoPlayer/issues/7518)). -### 2.11.5 (2020-06-05) ### +### 2.11.5 (2020-06-05) * Improve the smoothness of video playback immediately after starting, seeking or resuming a playback @@ -1691,8 +1691,8 @@ To learn more about what's new in 2.12, read the corresponding * Add `SilenceMediaSource.Factory` to support tags. * Enable the configuration of `SilenceSkippingAudioProcessor` ([#6705](https://github.com/google/ExoPlayer/issues/6705)). -* Fix bug where `PlayerMessages` throw an exception after `MediaSources` - are removed from the playlist +* Fix bug where `PlayerMessages` throw an exception after `MediaSources` are + removed from the playlist ([#7278](https://github.com/google/ExoPlayer/issues/7278)). * Fix "Not allowed to start service" `IllegalStateException` in `DownloadService` @@ -1724,13 +1724,11 @@ To learn more about what's new in 2.12, read the corresponding ([#7303](https://github.com/google/ExoPlayer/issues/7303)). * Add `showScrubber` and `hideScrubber` methods to `DefaultTimeBar`. * Text: - * Use anti-aliasing and bitmap filtering when displaying bitmap - subtitles. + * Use anti-aliasing and bitmap filtering when displaying bitmap subtitles. * Fix `SubtitlePainter` to render `EDGE_TYPE_OUTLINE` using the correct color. * IMA extension: - * Upgrade to IMA SDK version 3.19.0, and migrate to new - preloading APIs + * Upgrade to IMA SDK version 3.19.0, and migrate to new preloading APIs ([#6429](https://github.com/google/ExoPlayer/issues/6429)). This fixes several issues involving preloading and handling of ad loading error cases: ([#4140](https://github.com/google/ExoPlayer/issues/4140), diff --git a/library/common/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java b/library/common/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java index 8080451246..f114c81500 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java @@ -80,6 +80,7 @@ public class TrackSelectionParameters implements Bundleable { private int viewportHeight; private boolean viewportOrientationMayChange; private ImmutableList preferredVideoMimeTypes; + private @C.RoleFlags int preferredVideoRoleFlags; // Audio private ImmutableList preferredAudioLanguages; private @C.RoleFlags int preferredAudioRoleFlags; @@ -111,6 +112,7 @@ public class TrackSelectionParameters implements Bundleable { viewportHeight = Integer.MAX_VALUE; viewportOrientationMayChange = true; preferredVideoMimeTypes = ImmutableList.of(); + preferredVideoRoleFlags = 0; // Audio preferredAudioLanguages = ImmutableList.of(); preferredAudioRoleFlags = 0; @@ -183,6 +185,10 @@ public class TrackSelectionParameters implements Bundleable { firstNonNull( bundle.getStringArray(keyForField(FIELD_PREFERRED_VIDEO_MIMETYPES)), new String[0])); + preferredVideoRoleFlags = + bundle.getInt( + keyForField(FIELD_PREFERRED_VIDEO_ROLE_FLAGS), + DEFAULT_WITHOUT_CONTEXT.preferredVideoRoleFlags); // Audio String[] preferredAudioLanguages1 = firstNonNull( @@ -261,6 +267,7 @@ public class TrackSelectionParameters implements Bundleable { viewportHeight = parameters.viewportHeight; viewportOrientationMayChange = parameters.viewportOrientationMayChange; preferredVideoMimeTypes = parameters.preferredVideoMimeTypes; + preferredVideoRoleFlags = parameters.preferredVideoRoleFlags; // Audio preferredAudioLanguages = parameters.preferredAudioLanguages; preferredAudioRoleFlags = parameters.preferredAudioRoleFlags; @@ -441,6 +448,17 @@ public class TrackSelectionParameters implements Bundleable { return this; } + /** + * Sets the preferred {@link C.RoleFlags} for video tracks. + * + * @param preferredVideoRoleFlags Preferred video role flags. + * @return This builder. + */ + public Builder setPreferredVideoRoleFlags(@C.RoleFlags int preferredVideoRoleFlags) { + this.preferredVideoRoleFlags = preferredVideoRoleFlags; + return this; + } + // Audio /** @@ -770,6 +788,11 @@ public class TrackSelectionParameters implements Bundleable { * no preference. The default is an empty list. */ public final ImmutableList preferredVideoMimeTypes; + /** + * The preferred {@link C.RoleFlags} for video tracks. {@code 0} selects the default track if + * there is one, or the first track if there's no default. The default value is {@code 0}. + */ + public final @C.RoleFlags int preferredVideoRoleFlags; // Audio /** * The preferred languages for audio and forced text tracks as IETF BCP 47 conformant tags in @@ -853,6 +876,7 @@ public class TrackSelectionParameters implements Bundleable { this.viewportHeight = builder.viewportHeight; this.viewportOrientationMayChange = builder.viewportOrientationMayChange; this.preferredVideoMimeTypes = builder.preferredVideoMimeTypes; + this.preferredVideoRoleFlags = builder.preferredVideoRoleFlags; // Audio this.preferredAudioLanguages = builder.preferredAudioLanguages; this.preferredAudioRoleFlags = builder.preferredAudioRoleFlags; @@ -898,6 +922,7 @@ public class TrackSelectionParameters implements Bundleable { && viewportWidth == other.viewportWidth && viewportHeight == other.viewportHeight && preferredVideoMimeTypes.equals(other.preferredVideoMimeTypes) + && preferredVideoRoleFlags == other.preferredVideoRoleFlags // Audio && preferredAudioLanguages.equals(other.preferredAudioLanguages) && preferredAudioRoleFlags == other.preferredAudioRoleFlags @@ -930,6 +955,7 @@ public class TrackSelectionParameters implements Bundleable { result = 31 * result + viewportWidth; result = 31 * result + viewportHeight; result = 31 * result + preferredVideoMimeTypes.hashCode(); + result = 31 * result + preferredVideoRoleFlags; // Audio result = 31 * result + preferredAudioLanguages.hashCode(); result = 31 * result + preferredAudioRoleFlags; @@ -978,6 +1004,7 @@ public class TrackSelectionParameters implements Bundleable { FIELD_SELECTION_OVERRIDE_KEYS, FIELD_SELECTION_OVERRIDE_VALUES, FIELD_DISABLED_TRACK_TYPE, + FIELD_PREFERRED_VIDEO_ROLE_FLAGS }) private @interface FieldNumber {} @@ -1006,6 +1033,7 @@ public class TrackSelectionParameters implements Bundleable { private static final int FIELD_SELECTION_OVERRIDE_KEYS = 23; private static final int FIELD_SELECTION_OVERRIDE_VALUES = 24; private static final int FIELD_DISABLED_TRACK_TYPE = 25; + private static final int FIELD_PREFERRED_VIDEO_ROLE_FLAGS = 26; @Override public Bundle toBundle() { @@ -1027,6 +1055,7 @@ public class TrackSelectionParameters implements Bundleable { bundle.putStringArray( keyForField(FIELD_PREFERRED_VIDEO_MIMETYPES), preferredVideoMimeTypes.toArray(new String[0])); + bundle.putInt(keyForField(FIELD_PREFERRED_VIDEO_ROLE_FLAGS), preferredVideoRoleFlags); // Audio bundle.putStringArray( keyForField(FIELD_PREFERRED_AUDIO_LANGUAGES), diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java index d82ac79add..24ff480b5e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java @@ -27,6 +27,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.Bundleable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C.FormatSupport; +import com.google.android.exoplayer2.C.RoleFlags; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Renderer; @@ -368,6 +369,13 @@ public class DefaultTrackSelector extends MappingTrackSelector { return this; } + @Override + public DefaultTrackSelector.ParametersBuilder setPreferredVideoRoleFlags( + @RoleFlags int preferredVideoRoleFlags) { + super.setPreferredVideoRoleFlags(preferredVideoRoleFlags); + return this; + } + // Audio @Override @@ -2468,6 +2476,14 @@ public class DefaultTrackSelector extends MappingTrackSelector { } } + private static int getRoleFlagMatchScore(int trackRoleFlags, int preferredRoleFlags) { + if (trackRoleFlags != 0 && trackRoleFlags == preferredRoleFlags) { + // Prefer perfect match over partial matches. + return Integer.MAX_VALUE; + } + return Integer.bitCount(trackRoleFlags & preferredRoleFlags); + } + /** Represents how well a video track matches the selection {@link Parameters}. */ protected static final class VideoTrackScore implements Comparable { @@ -2483,6 +2499,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { private final int bitrate; private final int pixelCount; private final int preferredMimeTypeMatchIndex; + private final int preferredRoleFlagsScore; + private final boolean hasMainOrNoRoleFlag; public VideoTrackScore( Format format, @@ -2510,6 +2528,9 @@ public class DefaultTrackSelector extends MappingTrackSelector { isSupported(formatSupport, /* allowExceedsCapabilities= */ false); bitrate = format.bitrate; pixelCount = format.getPixelCount(); + preferredRoleFlagsScore = + getRoleFlagMatchScore(format.roleFlags, parameters.preferredVideoRoleFlags); + hasMainOrNoRoleFlag = format.roleFlags == 0 || (format.roleFlags & C.ROLE_FLAG_MAIN) != 0; int bestMimeTypeMatchIndex = Integer.MAX_VALUE; for (int i = 0; i < parameters.preferredVideoMimeTypes.size(); i++) { if (format.sampleMimeType != null @@ -2537,6 +2558,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { : FORMAT_VALUE_ORDERING.reverse(); return ComparisonChain.start() .compareFalseFirst(this.isWithinRendererCapabilities, other.isWithinRendererCapabilities) + .compare(this.preferredRoleFlagsScore, other.preferredRoleFlagsScore) + .compareFalseFirst(this.hasMainOrNoRoleFlag, other.hasMainOrNoRoleFlag) .compareFalseFirst(this.isWithinMaxConstraints, other.isWithinMaxConstraints) .compareFalseFirst(this.isWithinMinConstraints, other.isWithinMinConstraints) .compare( @@ -2568,6 +2591,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { private final int preferredLanguageScore; private final int preferredLanguageIndex; private final int preferredRoleFlagsScore; + private final boolean hasMainOrNoRoleFlag; private final int localeLanguageMatchIndex; private final int localeLanguageScore; private final boolean isDefaultSelectionFlag; @@ -2598,7 +2622,8 @@ public class DefaultTrackSelector extends MappingTrackSelector { preferredLanguageIndex = bestLanguageIndex; preferredLanguageScore = bestLanguageScore; preferredRoleFlagsScore = - Integer.bitCount(format.roleFlags & parameters.preferredAudioRoleFlags); + getRoleFlagMatchScore(format.roleFlags, parameters.preferredAudioRoleFlags); + hasMainOrNoRoleFlag = format.roleFlags == 0 || (format.roleFlags & C.ROLE_FLAG_MAIN) != 0; isDefaultSelectionFlag = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; channelCount = format.channelCount; sampleRate = format.sampleRate; @@ -2656,6 +2681,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { Ordering.natural().reverse()) .compare(this.preferredLanguageScore, other.preferredLanguageScore) .compare(this.preferredRoleFlagsScore, other.preferredRoleFlagsScore) + .compareFalseFirst(this.hasMainOrNoRoleFlag, other.hasMainOrNoRoleFlag) .compareFalseFirst(this.isWithinConstraints, other.isWithinConstraints) .compare( this.preferredMimeTypeMatchIndex, @@ -2732,7 +2758,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { preferredLanguageIndex = bestLanguageIndex; preferredLanguageScore = bestLanguageScore; preferredRoleFlagsScore = - Integer.bitCount(format.roleFlags & parameters.preferredTextRoleFlags); + getRoleFlagMatchScore(format.roleFlags, parameters.preferredTextRoleFlags); hasCaptionRoleFlags = (format.roleFlags & (C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND)) != 0; boolean selectedAudioLanguageUndetermined = diff --git a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java index 5209adb8dc..e7bf61f9ba 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelectorTest.java @@ -594,7 +594,7 @@ public final class DefaultTrackSelectorTest { } /** - * Tests that track selector will select audio track with the highest number of matching role + * Tests that track selector will select the audio track with the highest number of matching role * flags given by {@link Parameters}. */ @Test @@ -619,6 +619,17 @@ public final class DefaultTrackSelectorTest { periodId, TIMELINE); assertFixedSelection(result.selections[0], trackGroups, moreRoleFlags); + + // Also verify that exact match between parameters and tracks is preferred. + trackSelector.setParameters( + defaultParameters.buildUpon().setPreferredAudioRoleFlags(C.ROLE_FLAG_CAPTION)); + result = + trackSelector.selectTracks( + new RendererCapabilities[] {ALL_AUDIO_FORMAT_SUPPORTED_RENDERER_CAPABILITIES}, + trackGroups, + periodId, + TIMELINE); + assertFixedSelection(result.selections[0], trackGroups, lessRoleFlags); } /** @@ -1279,6 +1290,45 @@ public final class DefaultTrackSelectorTest { assertFixedSelection(result.selections[1], trackGroups, german); } + /** + * Tests that track selector will select the text track with the highest number of matching role + * flags given by {@link Parameters}. + */ + @Test + public void selectTracks_withPreferredTextRoleFlags_selectPreferredTrack() throws Exception { + Format.Builder formatBuilder = TEXT_FORMAT.buildUpon(); + Format noRoleFlags = formatBuilder.build(); + Format lessRoleFlags = formatBuilder.setRoleFlags(C.ROLE_FLAG_CAPTION).build(); + Format moreRoleFlags = + formatBuilder + .setRoleFlags(C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_COMMENTARY | C.ROLE_FLAG_DUB) + .build(); + TrackGroupArray trackGroups = wrapFormats(noRoleFlags, moreRoleFlags, lessRoleFlags); + + trackSelector.setParameters( + defaultParameters + .buildUpon() + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_COMMENTARY)); + TrackSelectorResult result = + trackSelector.selectTracks( + new RendererCapabilities[] {ALL_TEXT_FORMAT_SUPPORTED_RENDERER_CAPABILITIES}, + trackGroups, + periodId, + TIMELINE); + assertFixedSelection(result.selections[0], trackGroups, moreRoleFlags); + + // Also verify that exact match between parameters and tracks is preferred. + trackSelector.setParameters( + defaultParameters.buildUpon().setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION)); + result = + trackSelector.selectTracks( + new RendererCapabilities[] {ALL_TEXT_FORMAT_SUPPORTED_RENDERER_CAPABILITIES}, + trackGroups, + periodId, + TIMELINE); + assertFixedSelection(result.selections[0], trackGroups, lessRoleFlags); + } + /** * Tests that track selector will select the lowest bitrate supported audio track when {@link * Parameters#forceLowestBitrate} is set. @@ -1809,6 +1859,39 @@ public final class DefaultTrackSelectorTest { assertFixedSelection(result.selections[0], trackGroups, formatAv1); } + /** + * Tests that track selector will select the video track with the highest number of matching role + * flags given by {@link Parameters}. + */ + @Test + public void selectTracks_withPreferredVideoRoleFlags_selectPreferredTrack() throws Exception { + Format.Builder formatBuilder = VIDEO_FORMAT.buildUpon(); + Format noRoleFlags = formatBuilder.build(); + Format lessRoleFlags = formatBuilder.setRoleFlags(C.ROLE_FLAG_CAPTION).build(); + Format moreRoleFlags = + formatBuilder + .setRoleFlags(C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_COMMENTARY | C.ROLE_FLAG_DUB) + .build(); + TrackGroupArray trackGroups = wrapFormats(noRoleFlags, moreRoleFlags, lessRoleFlags); + + trackSelector.setParameters( + defaultParameters + .buildUpon() + .setPreferredVideoRoleFlags(C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_COMMENTARY)); + TrackSelectorResult result = + trackSelector.selectTracks( + new RendererCapabilities[] {VIDEO_CAPABILITIES}, trackGroups, periodId, TIMELINE); + assertFixedSelection(result.selections[0], trackGroups, moreRoleFlags); + + // Also verify that exact match between parameters and tracks is preferred. + trackSelector.setParameters( + defaultParameters.buildUpon().setPreferredVideoRoleFlags(C.ROLE_FLAG_CAPTION)); + result = + trackSelector.selectTracks( + new RendererCapabilities[] {VIDEO_CAPABILITIES}, trackGroups, periodId, TIMELINE); + assertFixedSelection(result.selections[0], trackGroups, lessRoleFlags); + } + @Test public void selectTracks_withPreferredAudioMimeTypes_selectsTrackWithPreferredMimeType() throws Exception { From 0e65925bb27f00bcb0dcefbfb8c0273f4e6ade76 Mon Sep 17 00:00:00 2001 From: huangdarwin Date: Thu, 25 Nov 2021 19:11:18 +0000 Subject: [PATCH 27/56] GL: Remove redundant use() call. This is already called in GlUtil.Program(). Tested by confirming that the demo-gl target still runs as expected. Refactoring change only. No intended functional changes. PiperOrigin-RevId: 412308564 --- .../google/android/exoplayer2/util/GlUtil.java | 15 ++++++++++++--- .../video/VideoDecoderGLSurfaceView.java | 1 - .../video/spherical/ProjectionRenderer.java | 4 ---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/GlUtil.java b/library/common/src/main/java/com/google/android/exoplayer2/util/GlUtil.java index e2696818aa..6f9676becb 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/GlUtil.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/GlUtil.java @@ -42,7 +42,7 @@ import java.util.HashMap; import java.util.Map; import javax.microedition.khronos.egl.EGL10; -/** GL utilities. */ +/** OpenGL ES 2.0 utilities. */ public final class GlUtil { /** Thrown when an OpenGL error occurs and {@link #glAssertionsEnabled} is {@code true}. */ @@ -85,7 +85,10 @@ public final class GlUtil { } /** - * Compiles a GL shader program from vertex and fragment shader GLSL GLES20 code. + * Creates a GL shader program from vertex and fragment shader GLSL GLES20 code. + * + *

This involves slow steps, like compiling, linking, and switching the GL program, so do not + * call this in fast rendering loops. * * @param vertexShaderGlsl The vertex shader program. * @param fragmentShaderGlsl The fragment shader program. @@ -128,8 +131,14 @@ public final class GlUtil { checkGlError(); } - /** Uses the program. */ + /** + * Uses the program. + * + *

Call this in the rendering loop to switch between different programs. + */ public void use() { + // TODO(http://b/205002913): When multiple GL programs are supported by Transformer, make sure + // to call use() to switch between programs. GLES20.glUseProgram(programId); checkGlError(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java index 8f61630e19..524132f807 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java @@ -166,7 +166,6 @@ public final class VideoDecoderGLSurfaceView extends GLSurfaceView @Override public void onSurfaceCreated(GL10 unused, EGLConfig config) { program = new GlUtil.Program(VERTEX_SHADER, FRAGMENT_SHADER); - program.use(); int posLocation = program.getAttributeArrayLocationAndEnable("in_pos"); GLES20.glVertexAttribPointer( posLocation, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java index 3e9f577e20..f5f9966b4e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.video.spherical; -import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.GlUtil.checkGlError; import android.opengl.GLES11Ext; @@ -139,9 +138,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } // Configure shader. - checkNotNull(program).use(); - checkGlError(); - float[] texMatrix; if (stereoMode == C.STEREO_MODE_TOP_BOTTOM) { texMatrix = rightEye ? TEX_MATRIX_BOTTOM : TEX_MATRIX_TOP; From dbec03b5435f446bfb4ddd9fa9c5771d21b31748 Mon Sep 17 00:00:00 2001 From: kimvde Date: Fri, 26 Nov 2021 12:23:38 +0000 Subject: [PATCH 28/56] Fix inconsistency with spec in H.265 SPS nal units parsing Issue: google/ExoPlayer#9719 #minor-release PiperOrigin-RevId: 412424558 --- RELEASENOTES.md | 3 +++ .../java/com/google/android/exoplayer2/util/NalUnitUtil.java | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bb68a80e80..b317217030 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,6 +5,9 @@ * Core library: * Support preferred video role flags in track selection ((#9402)[https://github.com/google/ExoPlayer/issues/9402]). +* Extractors: + * Fix inconsistency with spec in H.265 SPS nal units parsing + ((#9719)[https://github.com/google/ExoPlayer/issues/9719]). * DRM: * Remove `playbackLooper` from `DrmSessionManager.(pre)acquireSession`. When a `DrmSessionManager` is used by an app in a custom `MediaSource`, diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java b/library/extractor/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java index f53cee7254..44014bda8e 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/util/NalUnitUtil.java @@ -779,7 +779,7 @@ public final class NalUnitUtil { bitArray.skipBit(); // delta_rps_sign bitArray.readUnsignedExpGolombCodedInt(); // abs_delta_rps_minus1 for (int j = 0; j <= previousNumDeltaPocs; j++) { - if (bitArray.readBit()) { // used_by_curr_pic_flag[j] + if (!bitArray.readBit()) { // used_by_curr_pic_flag[j] bitArray.skipBit(); // use_delta_flag[j] } } From 0ac262c472c847f16532a9cf6bd7107cc41a949f Mon Sep 17 00:00:00 2001 From: huangdarwin Date: Fri, 26 Nov 2021 12:54:50 +0000 Subject: [PATCH 29/56] Misc refactoring. Use @VisibleForTesting and add some comments for GL code. Refactoring change only. No functional changes intended PiperOrigin-RevId: 412428196 --- .../android/exoplayer2/mediacodec/MediaCodecUtil.java | 8 +++----- .../exoplayer2/video/spherical/ProjectionRenderer.java | 2 +- .../android/exoplayer2/transformer/FrameEditor.java | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java index 40e75c06bb..adebf6e666 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java @@ -27,6 +27,7 @@ import androidx.annotation.CheckResult; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.util.Log; @@ -105,11 +106,8 @@ public final class MediaCodecUtil { } } - /** - * Clears the codec cache. - * - *

This method should only be called in tests. - */ + /* Clears the codec cache.*/ + @VisibleForTesting public static synchronized void clearDecoderInfoCache() { decoderInfosCache.clear(); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java index f5f9966b4e..3ac9bdeb70 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/video/spherical/ProjectionRenderer.java @@ -176,7 +176,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; checkGlError(); // Render. - GLES20.glDrawArrays(meshData.drawMode, 0, meshData.vertexCount); + GLES20.glDrawArrays(meshData.drawMode, /* first= */ 0, meshData.vertexCount); checkGlError(); GLES20.glDisableVertexAttribArray(positionHandle); diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameEditor.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameEditor.java index d7dc67da23..82f67af680 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameEditor.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/FrameEditor.java @@ -143,7 +143,7 @@ import java.io.IOException; inputSurfaceTexture.getTransformMatrix(textureTransformMatrix); copyProgram.setFloatsUniform("tex_transform", textureTransformMatrix); copyProgram.bindAttributesAndUniforms(); - GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, /* first= */ 0, /* count= */ 4); long surfaceTextureTimestampNs = inputSurfaceTexture.getTimestamp(); EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, surfaceTextureTimestampNs); EGL14.eglSwapBuffers(eglDisplay, eglSurface); From 51762a47959ad3160241f586fb8a14c430aa3ca0 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Fri, 26 Nov 2021 14:09:35 +0000 Subject: [PATCH 30/56] Move MobileHarness based tests into a `mh` package. PiperOrigin-RevId: 412438389 --- .../transformer/{ => mh}/AndroidTestUtil.java | 3 ++- .../RemoveAudioTransformationTest.java | 7 ++++--- .../RemoveVideoTransformationTest.java | 7 ++++--- .../RepeatedTranscodeTransformationTest.java | 5 +++-- .../{ => mh}/SefTransformationTest.java | 7 ++++--- .../{ => mh}/TransformationTest.java | 7 ++++--- .../transformer/mh/package-info.java | 19 +++++++++++++++++++ 7 files changed, 40 insertions(+), 15 deletions(-) rename library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/{ => mh}/AndroidTestUtil.java (97%) rename library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/{ => mh}/RemoveAudioTransformationTest.java (81%) rename library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/{ => mh}/RemoveVideoTransformationTest.java (81%) rename library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/{ => mh}/RepeatedTranscodeTransformationTest.java (95%) rename library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/{ => mh}/SefTransformationTest.java (81%) rename library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/{ => mh}/TransformationTest.java (80%) create mode 100644 library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/package-info.java diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java similarity index 97% rename from library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java rename to library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java index 5b93b7ba38..2f471a9dcb 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.transformer; +package com.google.android.exoplayer2.transformer.mh; import static com.google.common.truth.Truth.assertWithMessage; import static java.util.concurrent.TimeUnit.SECONDS; @@ -23,6 +23,7 @@ import android.net.Uri; import androidx.annotation.Nullable; import androidx.test.platform.app.InstrumentationRegistry; import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.transformer.Transformer; import com.google.android.exoplayer2.util.Assertions; import java.io.File; import java.io.IOException; diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/RemoveAudioTransformationTest.java similarity index 81% rename from library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java rename to library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/RemoveAudioTransformationTest.java index 0d775c24ab..4b5a147f1c 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveAudioTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/RemoveAudioTransformationTest.java @@ -13,14 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.transformer; +package com.google.android.exoplayer2.transformer.mh; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.runTransformer; +import static com.google.android.exoplayer2.transformer.mh.AndroidTestUtil.MP4_ASSET_URI_STRING; +import static com.google.android.exoplayer2.transformer.mh.AndroidTestUtil.runTransformer; import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.transformer.Transformer; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/RemoveVideoTransformationTest.java similarity index 81% rename from library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java rename to library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/RemoveVideoTransformationTest.java index cf8a86f6fc..e530217258 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RemoveVideoTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/RemoveVideoTransformationTest.java @@ -13,14 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.transformer; +package com.google.android.exoplayer2.transformer.mh; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.runTransformer; +import static com.google.android.exoplayer2.transformer.mh.AndroidTestUtil.MP4_ASSET_URI_STRING; +import static com.google.android.exoplayer2.transformer.mh.AndroidTestUtil.runTransformer; import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.transformer.Transformer; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/RepeatedTranscodeTransformationTest.java similarity index 95% rename from library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java rename to library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/RepeatedTranscodeTransformationTest.java index d1ca15f47f..25f43d4828 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/RepeatedTranscodeTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/RepeatedTranscodeTransformationTest.java @@ -13,14 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.transformer; +package com.google.android.exoplayer2.transformer.mh; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.runTransformer; +import static com.google.android.exoplayer2.transformer.mh.AndroidTestUtil.runTransformer; import static com.google.common.truth.Truth.assertWithMessage; import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.transformer.Transformer; import com.google.android.exoplayer2.util.MimeTypes; import java.util.HashSet; import java.util.Set; diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/SefTransformationTest.java similarity index 81% rename from library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java rename to library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/SefTransformationTest.java index 886726916b..129e230694 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/SefTransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/SefTransformationTest.java @@ -13,14 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.transformer; +package com.google.android.exoplayer2.transformer.mh; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.SEF_ASSET_URI_STRING; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.runTransformer; +import static com.google.android.exoplayer2.transformer.mh.AndroidTestUtil.SEF_ASSET_URI_STRING; +import static com.google.android.exoplayer2.transformer.mh.AndroidTestUtil.runTransformer; import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.transformer.Transformer; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/TransformationTest.java similarity index 80% rename from library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java rename to library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/TransformationTest.java index 1725358855..a440d4815a 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/TransformationTest.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/TransformationTest.java @@ -13,14 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.transformer; +package com.google.android.exoplayer2.transformer.mh; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING; -import static com.google.android.exoplayer2.transformer.AndroidTestUtil.runTransformer; +import static com.google.android.exoplayer2.transformer.mh.AndroidTestUtil.MP4_ASSET_URI_STRING; +import static com.google.android.exoplayer2.transformer.mh.AndroidTestUtil.runTransformer; import android.content.Context; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.transformer.Transformer; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/package-info.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/package-info.java new file mode 100644 index 0000000000..68d677203e --- /dev/null +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.google.android.exoplayer2.transformer.mh; + +import com.google.android.exoplayer2.util.NonNullApi; From 041c3e9971b6a1c9446b485afd40f31196865b94 Mon Sep 17 00:00:00 2001 From: hschlueter Date: Fri, 26 Nov 2021 14:47:27 +0000 Subject: [PATCH 31/56] Use audio passthrough if flattening is requested but not needed. When the input is not a slow motion video, then flattening should do nothing, so there is no need to re-encode audio. PiperOrigin-RevId: 412443097 --- .../transformer/TransformerAudioRenderer.java | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java index e1a2d35f0d..07bd1f0db8 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerAudioRenderer.java @@ -19,12 +19,15 @@ package com.google.android.exoplayer2.transformer; import static com.google.android.exoplayer2.source.SampleStream.FLAG_REQUIRE_FORMAT; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.mp4.SlowMotionData; import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; @RequiresApi(18) @@ -59,13 +62,29 @@ import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; return false; } Format inputFormat = checkNotNull(formatHolder.format); - if ((transformation.audioMimeType != null - && !transformation.audioMimeType.equals(inputFormat.sampleMimeType)) - || transformation.flattenForSlowMotion) { + boolean shouldChangeMimeType = + transformation.audioMimeType != null + && !transformation.audioMimeType.equals(inputFormat.sampleMimeType); + boolean shouldFlattenForSlowMotion = + transformation.flattenForSlowMotion && isSlowMotion(inputFormat); + if (shouldChangeMimeType || shouldFlattenForSlowMotion) { samplePipeline = new AudioSamplePipeline(inputFormat, transformation, getIndex()); } else { samplePipeline = new PassthroughSamplePipeline(inputFormat); } return true; } + + private static boolean isSlowMotion(Format format) { + @Nullable Metadata metadata = format.metadata; + if (metadata == null) { + return false; + } + for (int i = 0; i < metadata.length(); i++) { + if (metadata.get(i) instanceof SlowMotionData) { + return true; + } + } + return false; + } } From 99eb35179dbf3e6b39bdc752bf9464dfd2c2190f Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 29 Nov 2021 10:57:51 +0000 Subject: [PATCH 32/56] Update track selection to prefer content over technical preferences. Currently we prefer technical preferences set in the Parameters over content preferences implied by the media. It proably makes more sense in the opposite order to avoid the situation where a non-default track (e.g. commentary) is selected just because it better matches some technical criteria. Also add comments explaining the track selection logic stages. PiperOrigin-RevId: 412840962 --- RELEASENOTES.md | 4 ++++ .../trackselection/DefaultTrackSelector.java | 22 ++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b317217030..2dcd8eebf5 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,6 +5,10 @@ * Core library: * Support preferred video role flags in track selection ((#9402)[https://github.com/google/ExoPlayer/issues/9402]). + * Prefer audio content preferences (for example, "default" audio track or + track matching system Locale language) over technical track selection + constraints (for example, preferred MIME type, or maximum channel + count). * Extractors: * Fix inconsistency with spec in H.265 SPS nal units parsing ((#9719)[https://github.com/google/ExoPlayer/issues/9719]). diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java index 24ff480b5e..21e05f33bb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java @@ -2558,14 +2558,18 @@ public class DefaultTrackSelector extends MappingTrackSelector { : FORMAT_VALUE_ORDERING.reverse(); return ComparisonChain.start() .compareFalseFirst(this.isWithinRendererCapabilities, other.isWithinRendererCapabilities) + // 1. Compare match with specific content preferences set by the parameters. .compare(this.preferredRoleFlagsScore, other.preferredRoleFlagsScore) + // 2. Compare match with implicit content preferences set by the media. .compareFalseFirst(this.hasMainOrNoRoleFlag, other.hasMainOrNoRoleFlag) + // 3. Compare match with technical preferences set by the parameters. .compareFalseFirst(this.isWithinMaxConstraints, other.isWithinMaxConstraints) .compareFalseFirst(this.isWithinMinConstraints, other.isWithinMinConstraints) .compare( this.preferredMimeTypeMatchIndex, other.preferredMimeTypeMatchIndex, Ordering.natural().reverse()) + // 4. Compare technical quality. .compare( this.bitrate, other.bitrate, @@ -2675,13 +2679,22 @@ public class DefaultTrackSelector extends MappingTrackSelector { : FORMAT_VALUE_ORDERING.reverse(); return ComparisonChain.start() .compareFalseFirst(this.isWithinRendererCapabilities, other.isWithinRendererCapabilities) + // 1. Compare match with specific content preferences set by the parameters. .compare( this.preferredLanguageIndex, other.preferredLanguageIndex, Ordering.natural().reverse()) .compare(this.preferredLanguageScore, other.preferredLanguageScore) .compare(this.preferredRoleFlagsScore, other.preferredRoleFlagsScore) + // 2. Compare match with implicit content preferences set by the media or the system. + .compareFalseFirst(this.isDefaultSelectionFlag, other.isDefaultSelectionFlag) .compareFalseFirst(this.hasMainOrNoRoleFlag, other.hasMainOrNoRoleFlag) + .compare( + this.localeLanguageMatchIndex, + other.localeLanguageMatchIndex, + Ordering.natural().reverse()) + .compare(this.localeLanguageScore, other.localeLanguageScore) + // 3. Compare match with technical preferences set by the parameters. .compareFalseFirst(this.isWithinConstraints, other.isWithinConstraints) .compare( this.preferredMimeTypeMatchIndex, @@ -2691,12 +2704,7 @@ public class DefaultTrackSelector extends MappingTrackSelector { this.bitrate, other.bitrate, parameters.forceLowestBitrate ? FORMAT_VALUE_ORDERING.reverse() : NO_ORDER) - .compareFalseFirst(this.isDefaultSelectionFlag, other.isDefaultSelectionFlag) - .compare( - this.localeLanguageMatchIndex, - other.localeLanguageMatchIndex, - Ordering.natural().reverse()) - .compare(this.localeLanguageScore, other.localeLanguageScore) + // 4. Compare technical quality. .compare(this.channelCount, other.channelCount, qualityOrdering) .compare(this.sampleRate, other.sampleRate, qualityOrdering) .compare( @@ -2785,12 +2793,14 @@ public class DefaultTrackSelector extends MappingTrackSelector { ComparisonChain.start() .compareFalseFirst( this.isWithinRendererCapabilities, other.isWithinRendererCapabilities) + // 1. Compare match with specific content preferences set by the parameters. .compare( this.preferredLanguageIndex, other.preferredLanguageIndex, Ordering.natural().reverse()) .compare(this.preferredLanguageScore, other.preferredLanguageScore) .compare(this.preferredRoleFlagsScore, other.preferredRoleFlagsScore) + // 2. Compare match with implicit content preferences set by the media. .compareFalseFirst(this.isDefault, other.isDefault) .compare( this.isForced, From 83408d065e2a93c4bf436a19086b3f04d334fd8b Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 29 Nov 2021 12:36:40 +0000 Subject: [PATCH 33/56] Create and write the TransformationResult to on-device text file. PiperOrigin-RevId: 412856100 --- .../transformer/mh/AndroidTestUtil.java | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java index 2f471a9dcb..824edf685f 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java @@ -20,12 +20,14 @@ import static java.util.concurrent.TimeUnit.SECONDS; import android.content.Context; import android.net.Uri; +import android.os.Build; import androidx.annotation.Nullable; import androidx.test.platform.app.InstrumentationRegistry; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.transformer.Transformer; import com.google.android.exoplayer2.util.Assertions; import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; @@ -40,9 +42,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** Information about the result of successfully running a transformer. */ public static final class TransformationResult { - public long outputSizeBytes; + public final String testId; + public final long outputSizeBytes; - private TransformationResult(long outputSizeBytes) { + private TransformationResult(String testId, long outputSizeBytes) { + this.testId = testId; this.outputSizeBytes = outputSizeBytes; } } @@ -83,13 +87,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; .build(); Uri uri = Uri.parse(uriString); - File externalCacheFile = createExternalCacheFile(context, /* filePrefix= */ testId); + File outputVideoFile = createExternalCacheFile(context, /* fileName= */ testId + "-output.mp4"); InstrumentationRegistry.getInstrumentation() .runOnMainSync( () -> { try { testTransformer.startTransformation( - MediaItem.fromUri(uri), externalCacheFile.getAbsolutePath()); + MediaItem.fromUri(uri), outputVideoFile.getAbsolutePath()); } catch (IOException e) { exceptionReference.set(e); } @@ -102,16 +106,41 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; if (exception != null) { throw exception; } - long outputSizeBytes = externalCacheFile.length(); - return new TransformationResult(outputSizeBytes); + long outputSizeBytes = outputVideoFile.length(); + + TransformationResult result = new TransformationResult(testId, outputSizeBytes); + writeTransformationResultToFile(context, result); + return result; } - private static File createExternalCacheFile(Context context, String filePrefix) + private static void writeTransformationResultToFile(Context context, TransformationResult result) throws IOException { - File file = new File(context.getExternalCacheDir(), filePrefix + "-output.mp4"); + File analysisFile = + createExternalCacheFile(context, /* fileName= */ result.testId + "-result.txt"); + FileWriter fileWriter = new FileWriter(analysisFile); + String fileContents = + "test=" + + result.testId + + ", deviceBrand=" + + Build.MANUFACTURER + + ", deviceModel=" + + Build.MODEL + + ", sdkVersion=" + + Build.VERSION.SDK_INT + + ", outputSizeBytes=" + + result.outputSizeBytes; + try { + fileWriter.write(fileContents); + } finally { + fileWriter.close(); + } + } + + private static File createExternalCacheFile(Context context, String fileName) throws IOException { + File file = new File(context.getExternalCacheDir(), fileName); Assertions.checkState( - !file.exists() || file.delete(), "Could not delete the previous transformer output file."); - Assertions.checkState(file.createNewFile(), "Could not create the transformer output file."); + !file.exists() || file.delete(), "Could not delete file: " + file.getAbsolutePath()); + Assertions.checkState(file.createNewFile(), "Could not create file: " + file.getAbsolutePath()); return file; } From f5789b74a0230bb962cdcf6f206164b6856d8f76 Mon Sep 17 00:00:00 2001 From: samrobinson Date: Mon, 29 Nov 2021 16:58:35 +0000 Subject: [PATCH 34/56] Make use of try with-resources to auto close file. PiperOrigin-RevId: 412901581 --- .../transformer/mh/AndroidTestUtil.java | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java index 824edf685f..6e9512a020 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/AndroidTestUtil.java @@ -117,22 +117,19 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; throws IOException { File analysisFile = createExternalCacheFile(context, /* fileName= */ result.testId + "-result.txt"); - FileWriter fileWriter = new FileWriter(analysisFile); - String fileContents = - "test=" - + result.testId - + ", deviceBrand=" - + Build.MANUFACTURER - + ", deviceModel=" - + Build.MODEL - + ", sdkVersion=" - + Build.VERSION.SDK_INT - + ", outputSizeBytes=" - + result.outputSizeBytes; - try { + try (FileWriter fileWriter = new FileWriter(analysisFile)) { + String fileContents = + "test=" + + result.testId + + ", deviceBrand=" + + Build.MANUFACTURER + + ", deviceModel=" + + Build.MODEL + + ", sdkVersion=" + + Build.VERSION.SDK_INT + + ", outputSizeBytes=" + + result.outputSizeBytes; fileWriter.write(fileContents); - } finally { - fileWriter.close(); } } From ce80fe5361dd6ead0a9f122809ce8fde74b353d1 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 29 Nov 2021 16:59:53 +0000 Subject: [PATCH 35/56] Rollback of https://github.com/google/ExoPlayer/commit/f790d105b76cb92ac31cf0ab6c9557a41b4bc15b *** Original commit *** Remove usage of @ForOverride. Fixes the gradle compilation failures. Gradle dependencies need revising if we want to be using this, as checkerframework is ahead of their latest version, such that we can't compile. *** PiperOrigin-RevId: 412901827 --- library/transformer/build.gradle | 1 + .../android/exoplayer2/transformer/TransformerBaseRenderer.java | 2 ++ 2 files changed, 3 insertions(+) diff --git a/library/transformer/build.gradle b/library/transformer/build.gradle index a06a70a320..a89617a0b4 100644 --- a/library/transformer/build.gradle +++ b/library/transformer/build.gradle @@ -38,6 +38,7 @@ android { dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation project(modulePrefix + 'library-core') + compileOnly 'com.google.errorprone:error_prone_annotations:' + errorProneVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java index bf064520b2..4b78c64b8b 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/TransformerBaseRenderer.java @@ -29,6 +29,7 @@ import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; import com.google.android.exoplayer2.util.MediaClock; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.errorprone.annotations.ForOverride; import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.RequiresNonNull; @@ -133,6 +134,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; muxerWrapperTrackEnded = false; } + @ForOverride @EnsuresNonNullIf(expression = "samplePipeline", result = true) protected abstract boolean ensureConfigured() throws ExoPlaybackException; From 5694a07acef1de814f7e00fb427f6d03d8e55e6b Mon Sep 17 00:00:00 2001 From: andrewlewis Date: Tue, 30 Nov 2021 14:18:34 +0000 Subject: [PATCH 36/56] Add unit test for `FrameEditor` The test extracts and decodes the first video frame in the test media, renders it to the frame editor's input surface and then processes data. It then reads back the output from the frame editor, converts it to a bitmap and then compares that with a 'golden' bitmap (which is just the same as the test media's first video frame). PiperOrigin-RevId: 413131811 --- .../transformer/FrameEditorTest.java | 226 ++++++++++++++++++ .../media/bitmap/sample_mp4_first_frame.png | Bin 0 -> 537612 bytes 2 files changed, 226 insertions(+) create mode 100644 library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/FrameEditorTest.java create mode 100644 testdata/src/test/assets/media/bitmap/sample_mp4_first_frame.png diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/FrameEditorTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/FrameEditorTest.java new file mode 100644 index 0000000000..7ebdbf2d78 --- /dev/null +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/FrameEditorTest.java @@ -0,0 +1,226 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.transformer; + +import static androidx.test.core.app.ApplicationProvider.getApplicationContext; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.common.truth.Truth.assertThat; +import static java.lang.Math.abs; +import static java.lang.Math.max; + +import android.content.res.AssetFileDescriptor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.PixelFormat; +import android.media.Image; +import android.media.ImageReader; +import android.media.MediaCodec; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.util.MimeTypes; +import java.io.InputStream; +import java.nio.ByteBuffer; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Test for frame processing via {@link FrameEditor}. */ +@RunWith(AndroidJUnit4.class) +public final class FrameEditorTest { + + private static final String INPUT_MP4_ASSET_STRING = "media/mp4/sample.mp4"; + private static final String NO_EDITS_EXPECTED_OUTPUT_PNG_ASSET_STRING = + "media/bitmap/sample_mp4_first_frame.png"; + /** + * Maximum allowed average pixel difference between the expected and actual edited images for the + * test to pass. The value is chosen so that differences in decoder behavior across emulator + * versions shouldn't affect whether the test passes, but substantial distortions introduced by + * changes in the behavior of the frame editor will cause the test to fail. + */ + private static final float MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE = 0.1f; + /** Timeout for dequeueing buffers from the codec, in microseconds. */ + private static final int DEQUEUE_TIMEOUT_US = 500_000; + /** Time to wait for the frame editor's input to be populated by the decoder, in milliseconds. */ + private static final int SURFACE_WAIT_MS = 1000; + + private @MonotonicNonNull FrameEditor frameEditor; + private @MonotonicNonNull ImageReader frameEditorOutputImageReader; + private @MonotonicNonNull MediaFormat mediaFormat; + + @Before + public void setUp() throws Exception { + // Set up the extractor to read the first video frame and get its format. + MediaExtractor mediaExtractor = new MediaExtractor(); + @Nullable MediaCodec mediaCodec = null; + try (AssetFileDescriptor afd = + getApplicationContext().getAssets().openFd(INPUT_MP4_ASSET_STRING)) { + mediaExtractor.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); + for (int i = 0; i < mediaExtractor.getTrackCount(); i++) { + if (MimeTypes.isVideo(mediaExtractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME))) { + mediaFormat = mediaExtractor.getTrackFormat(i); + mediaExtractor.selectTrack(i); + break; + } + } + + int width = checkNotNull(mediaFormat).getInteger(MediaFormat.KEY_WIDTH); + int height = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT); + frameEditorOutputImageReader = + ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, /* maxImages= */ 1); + frameEditor = + FrameEditor.create( + getApplicationContext(), width, height, frameEditorOutputImageReader.getSurface()); + + // Queue the first video frame from the extractor. + String mimeType = checkNotNull(mediaFormat.getString(MediaFormat.KEY_MIME)); + mediaCodec = MediaCodec.createDecoderByType(mimeType); + mediaCodec.configure( + mediaFormat, frameEditor.getInputSurface(), /* crypto= */ null, /* flags= */ 0); + mediaCodec.start(); + int inputBufferIndex = mediaCodec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US); + assertThat(inputBufferIndex).isNotEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + ByteBuffer inputBuffer = checkNotNull(mediaCodec.getInputBuffers()[inputBufferIndex]); + int sampleSize = mediaExtractor.readSampleData(inputBuffer, /* offset= */ 0); + mediaCodec.queueInputBuffer( + inputBufferIndex, + /* offset= */ 0, + sampleSize, + mediaExtractor.getSampleTime(), + mediaExtractor.getSampleFlags()); + + // Queue an end-of-stream buffer to force the codec to produce output. + inputBufferIndex = mediaCodec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US); + assertThat(inputBufferIndex).isNotEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + mediaCodec.queueInputBuffer( + inputBufferIndex, + /* offset= */ 0, + /* size= */ 0, + /* presentationTimeUs= */ 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM); + + // Dequeue and render the output video frame. + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + int outputBufferIndex; + do { + outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, DEQUEUE_TIMEOUT_US); + assertThat(outputBufferIndex).isNotEqualTo(MediaCodec.INFO_TRY_AGAIN_LATER); + } while (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED + || outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED); + mediaCodec.releaseOutputBuffer(outputBufferIndex, /* render= */ true); + + // Sleep to give time for the surface texture to be populated. + Thread.sleep(SURFACE_WAIT_MS); + assertThat(frameEditor.hasInputData()).isTrue(); + } finally { + mediaExtractor.release(); + if (mediaCodec != null) { + mediaCodec.release(); + } + } + } + + @After + public void tearDown() { + if (frameEditor != null) { + frameEditor.release(); + } + } + + @Test + public void processData_noEdits_producesExpectedOutput() throws Exception { + Bitmap expectedBitmap; + try (InputStream inputStream = + getApplicationContext().getAssets().open(NO_EDITS_EXPECTED_OUTPUT_PNG_ASSET_STRING)) { + expectedBitmap = BitmapFactory.decodeStream(inputStream); + } + + checkNotNull(frameEditor).processData(); + Image editedImage = checkNotNull(frameEditorOutputImageReader).acquireLatestImage(); + Bitmap editedBitmap = getArgb8888BitmapForRgba8888Image(editedImage); + + // TODO(internal b/207848601): switch to using proper tooling for testing against golden data. + float averagePixelAbsoluteDifference = + getAveragePixelAbsoluteDifferenceArgb8888(expectedBitmap, editedBitmap); + assertThat(averagePixelAbsoluteDifference).isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE); + } + + /** + * Returns a bitmap with the same information as the provided alpha/red/green/blue 8-bits per + * component image. + */ + private static Bitmap getArgb8888BitmapForRgba8888Image(Image image) { + int width = image.getWidth(); + int height = image.getHeight(); + assertThat(image.getPlanes()).hasLength(1); + assertThat(image.getFormat()).isEqualTo(PixelFormat.RGBA_8888); + Image.Plane plane = image.getPlanes()[0]; + ByteBuffer buffer = plane.getBuffer(); + int[] colors = new int[width * height]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int offset = y * plane.getRowStride() + x * plane.getPixelStride(); + int r = buffer.get(offset) & 0xFF; + int g = buffer.get(offset + 1) & 0xFF; + int b = buffer.get(offset + 2) & 0xFF; + int a = buffer.get(offset + 3) & 0xFF; + colors[y * width + x] = (a << 24) + (r << 16) + (g << 8) + b; + } + } + return Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888); + } + + /** + * Returns the sum of the absolute differences between the expected and actual bitmaps, calculated + * using the maximum difference across all color channels for each pixel, then divided by the + * total number of pixels in the image. The bitmap resolutions must match and they must use + * configuration {@link Bitmap.Config#ARGB_8888}. + */ + private static float getAveragePixelAbsoluteDifferenceArgb8888(Bitmap expected, Bitmap actual) { + int width = actual.getWidth(); + int height = actual.getHeight(); + assertThat(width).isEqualTo(expected.getWidth()); + assertThat(height).isEqualTo(expected.getHeight()); + assertThat(actual.getConfig()).isEqualTo(Bitmap.Config.ARGB_8888); + long sumMaximumAbsoluteDifferences = 0; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int color = actual.getPixel(x, y); + int expectedColor = expected.getPixel(x, y); + int maximumAbsoluteDifference = 0; + maximumAbsoluteDifference = + max( + maximumAbsoluteDifference, + abs(((color >> 24) & 0xFF) - ((expectedColor >> 24) & 0xFF))); + maximumAbsoluteDifference = + max( + maximumAbsoluteDifference, + abs(((color >> 16) & 0xFF) - ((expectedColor >> 16) & 0xFF))); + maximumAbsoluteDifference = + max( + maximumAbsoluteDifference, + abs(((color >> 8) & 0xFF) - ((expectedColor >> 8) & 0xFF))); + maximumAbsoluteDifference = + max(maximumAbsoluteDifference, abs((color & 0xFF) - (expectedColor & 0xFF))); + sumMaximumAbsoluteDifferences += maximumAbsoluteDifference; + } + } + return (float) sumMaximumAbsoluteDifferences / (width * height); + } +} diff --git a/testdata/src/test/assets/media/bitmap/sample_mp4_first_frame.png b/testdata/src/test/assets/media/bitmap/sample_mp4_first_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..fc51b716a06cbc7781a836a6b80db87c2d1ed47c GIT binary patch literal 537612 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%0AhR)2d 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