diff --git a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java index 216582b94f..0bebc19658 100644 --- a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java +++ b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java @@ -59,6 +59,8 @@ import android.util.Pair; import androidx.media3.common.C; import androidx.media3.common.Effect; import androidx.media3.common.Format; +import androidx.media3.common.GlObjectsProvider; +import androidx.media3.common.GlTextureInfo; import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; import androidx.media3.common.OnInputFrameProcessedListener; @@ -77,6 +79,8 @@ import androidx.media3.effect.DefaultGlObjectsProvider; import androidx.media3.effect.DefaultVideoFrameProcessor; import androidx.media3.effect.FrameCache; import androidx.media3.effect.GlEffect; +import androidx.media3.effect.GlShaderProgram; +import androidx.media3.effect.PassthroughShaderProgram; import androidx.media3.effect.Presentation; import androidx.media3.effect.RgbFilter; import androidx.media3.effect.ScaleAndRotateTransformation; @@ -96,6 +100,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import java.io.File; import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicInteger; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.junit.Before; import org.junit.Rule; @@ -1335,6 +1340,105 @@ public class TransformerEndToEndTest { assertThat(new File(result.filePath).length()).isGreaterThan(0); } + @Test + public void analyzeAudio_completesSuccessfully() throws Exception { + assumeFormatsSupported( + context, + testId, + /* inputFormat= */ MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_FORMAT, + /* outputFormat= */ null); + Transformer transformer = ExperimentalAnalyzerModeFactory.buildAnalyzer(context); + AtomicInteger audioBytesSeen = new AtomicInteger(/* initialValue= */ 0); + EditedMediaItem editedMediaItem = + new EditedMediaItem.Builder( + MediaItem.fromUri( + Uri.parse(MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_URI_STRING))) + .setRemoveVideo(true) + .setEffects( + new Effects( + ImmutableList.of(createByteCountingAudioProcessor(audioBytesSeen)), + /* videoEffects= */ ImmutableList.of())) + .build(); + + ExportTestResult result = + new TransformerAndroidTestRunner.Builder(context, transformer) + .build() + .run(testId, editedMediaItem); + + assertThat(audioBytesSeen.get()).isEqualTo(2_985_984); + // Confirm no data was written to file. + assertThat(result.exportResult.averageAudioBitrate).isEqualTo(C.RATE_UNSET_INT); + assertThat(result.exportResult.fileSizeBytes).isEqualTo(C.LENGTH_UNSET); + } + + @Test + public void analyzeVideo_completesSuccessfully() throws Exception { + assumeFormatsSupported( + context, + testId, + /* inputFormat= */ MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_FORMAT, + /* outputFormat= */ null); + Transformer transformer = ExperimentalAnalyzerModeFactory.buildAnalyzer(context); + AtomicInteger videoFramesSeen = new AtomicInteger(/* initialValue= */ 0); + // Analysis must be added to item effects because composition effects are not applied to single + // input video. + EditedMediaItem editedMediaItem = + new EditedMediaItem.Builder( + MediaItem.fromUri( + Uri.parse(MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_URI_STRING))) + .setRemoveAudio(true) + .setEffects( + new Effects( + /* audioProcessors= */ ImmutableList.of(), + ImmutableList.of(createFrameCountingEffect(videoFramesSeen)))) + .build(); + + ExportTestResult result = + new TransformerAndroidTestRunner.Builder(context, transformer) + .build() + .run(testId, editedMediaItem); + + assertThat(videoFramesSeen.get()).isEqualTo(932); + // Confirm no data was written to file. + assertThat(result.exportResult.videoFrameCount).isEqualTo(0); + assertThat(result.exportResult.fileSizeBytes).isEqualTo(C.LENGTH_UNSET); + } + + @Test + public void analyzeAudioAndVideo_completesSuccessfully() throws Exception { + assumeFormatsSupported( + context, + testId, + /* inputFormat= */ MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_FORMAT, + /* outputFormat= */ null); + Transformer transformer = ExperimentalAnalyzerModeFactory.buildAnalyzer(context); + AtomicInteger audioBytesSeen = new AtomicInteger(/* initialValue= */ 0); + AtomicInteger videoFramesSeen = new AtomicInteger(/* initialValue= */ 0); + // Analysis must be added to item effects because composition effects are not applied to single + // input video. + EditedMediaItem editedMediaItem = + new EditedMediaItem.Builder( + MediaItem.fromUri( + Uri.parse(MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_URI_STRING))) + .setEffects( + new Effects( + ImmutableList.of(createByteCountingAudioProcessor(audioBytesSeen)), + ImmutableList.of(createFrameCountingEffect(videoFramesSeen)))) + .build(); + + ExportTestResult result = + new TransformerAndroidTestRunner.Builder(context, transformer) + .build() + .run(testId, editedMediaItem); + + assertThat(audioBytesSeen.get()).isEqualTo(2_985_984); + assertThat(videoFramesSeen.get()).isEqualTo(932); + // Confirm no data was written to file. + assertThat(result.exportResult.averageAudioBitrate).isEqualTo(C.RATE_UNSET_INT); + assertThat(result.exportResult.videoFrameCount).isEqualTo(0); + assertThat(result.exportResult.fileSizeBytes).isEqualTo(C.LENGTH_UNSET); + } + @Test public void transcode_withOutputVideoMimeTypeAv1_completesSuccessfully() throws Exception { assumeFormatsSupported( @@ -1628,6 +1732,37 @@ public class TransformerEndToEndTest { return sonic; } + private static AudioProcessor createByteCountingAudioProcessor(AtomicInteger byteCount) { + return new TeeAudioProcessor( + new TeeAudioProcessor.AudioBufferSink() { + @Override + public void flush(int sampleRateHz, int channelCount, @C.PcmEncoding int encoding) {} + + @Override + public void handleBuffer(ByteBuffer buffer) { + byteCount.addAndGet(buffer.remaining()); + } + }); + } + + private static GlEffect createFrameCountingEffect(AtomicInteger frameCount) { + return new GlEffect() { + @Override + public GlShaderProgram toGlShaderProgram(Context context, boolean useHdr) { + return new PassthroughShaderProgram() { + @Override + public void queueInputFrame( + GlObjectsProvider glObjectsProvider, + GlTextureInfo inputTexture, + long presentationTimeUs) { + super.queueInputFrame(glObjectsProvider, inputTexture, presentationTimeUs); + frameCount.incrementAndGet(); + } + }; + } + }; + } + private final class TestTextureAssetLoaderFactory implements AssetLoader.Factory { private final int width; diff --git a/libraries/transformer/src/main/java/androidx/media3/transformer/ExperimentalAnalyzerModeFactory.java b/libraries/transformer/src/main/java/androidx/media3/transformer/ExperimentalAnalyzerModeFactory.java new file mode 100644 index 0000000000..2936ba5c09 --- /dev/null +++ b/libraries/transformer/src/main/java/androidx/media3/transformer/ExperimentalAnalyzerModeFactory.java @@ -0,0 +1,249 @@ +/* + * Copyright 2024 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 androidx.media3.transformer; + +import static androidx.media3.common.util.Assertions.checkState; + +import android.content.Context; +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.view.Surface; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.Metadata; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.decoder.DecoderInputBuffer; +import androidx.media3.exoplayer.video.PlaceholderSurface; +import androidx.media3.muxer.Muxer; +import com.google.common.collect.ImmutableList; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; + +/** + * Factory for creating instances of {@link Transformer} that can be used to analyze media. + * + *
When using {@link Transformer} to analyze decoded data, users should provide their analysis + * effects through the {@link EditedMediaItem#effects}. + * + *
This class is experimental and will be renamed or removed in a future release. + */ +@UnstableApi +public final class ExperimentalAnalyzerModeFactory { + + private ExperimentalAnalyzerModeFactory() {} + + /** + * Builds a {@link Transformer} that runs as an analyzer. + * + *
No encoding or muxing is performed, therefore no data is written to any output files. + * + * @param context The {@link Context}. + * @return The analyzer {@link Transformer}. + */ + public static Transformer buildAnalyzer(Context context) { + return buildAnalyzer(context, new Transformer.Builder(context).build()); + } + + /** + * Builds a {@link Transformer} that runs as an analyzer. + * + *
No encoding or muxing is performed, therefore no data is written to any output files.
+ *
+ * @param context The {@link Context}.
+ * @param transformer The {@link Transformer} to be built upon.
+ * @return The analyzer {@link Transformer}.
+ */
+ public static Transformer buildAnalyzer(Context context, Transformer transformer) {
+ return transformer
+ .buildUpon()
+ .experimentalSetTrimOptimizationEnabled(false)
+ .setEncoderFactory(new DroppingEncoder.Factory(context))
+ .setMaxDelayBetweenMuxerSamplesMs(C.TIME_UNSET)
+ .setMuxerFactory(
+ new NoWriteMuxer.Factory(
+ /* audioMimeTypes= */ ImmutableList.of(MimeTypes.AUDIO_AAC),
+ /* videoMimeTypes= */ ImmutableList.of(MimeTypes.VIDEO_H264)))
+ .setAudioMimeType(MimeTypes.AUDIO_AAC)
+ .setVideoMimeType(MimeTypes.VIDEO_H264)
+ .build();
+ }
+
+ /** A {@linkplain Codec encoder} implementation that drops input and produces no output. */
+ private static final class DroppingEncoder implements Codec {
+ public static final class Factory implements Codec.EncoderFactory {
+ private final Context context;
+
+ public Factory(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public Codec createForAudioEncoding(Format format) {
+ return new DroppingEncoder(context, format);
+ }
+
+ @Override
+ public Codec createForVideoEncoding(Format format) {
+ return new DroppingEncoder(context, format);
+ }
+ }
+
+ private static final String TAG = "DroppingEncoder";
+ private static final int INTERNAL_BUFFER_SIZE = 8196;
+
+ private final Context context;
+ private final Format configurationFormat;
+ private final ByteBuffer buffer;
+
+ private boolean inputStreamEnded;
+
+ public DroppingEncoder(Context context, Format format) {
+ this.context = context;
+ this.configurationFormat = format;
+ buffer = ByteBuffer.allocateDirect(INTERNAL_BUFFER_SIZE).order(ByteOrder.nativeOrder());
+ }
+
+ @Override
+ public String getName() {
+ return TAG;
+ }
+
+ @Override
+ public Format getConfigurationFormat() {
+ return configurationFormat;
+ }
+
+ @Override
+ public Surface getInputSurface() {
+ return PlaceholderSurface.newInstance(context, /* secure= */ false);
+ }
+
+ @Override
+ @EnsuresNonNullIf(expression = "#1.data", result = true)
+ public boolean maybeDequeueInputBuffer(DecoderInputBuffer inputBuffer) {
+ if (inputStreamEnded) {
+ return false;
+ }
+ inputBuffer.data = buffer;
+ return true;
+ }
+
+ @Override
+ public void queueInputBuffer(DecoderInputBuffer inputBuffer) {
+ checkState(
+ !inputStreamEnded, "Input buffer can not be queued after the input stream has ended.");
+ if (inputBuffer.isEndOfStream()) {
+ inputStreamEnded = true;
+ }
+ inputBuffer.clear();
+ inputBuffer.data = null;
+ }
+
+ @Override
+ public void signalEndOfInputStream() {
+ inputStreamEnded = true;
+ }
+
+ @Override
+ @Nullable
+ public Format getOutputFormat() {
+ return configurationFormat;
+ }
+
+ @Override
+ @Nullable
+ public ByteBuffer getOutputBuffer() {
+ return null;
+ }
+
+ @Override
+ @Nullable
+ public BufferInfo getOutputBufferInfo() {
+ return null;
+ }
+
+ @Override
+ public boolean isEnded() {
+ return inputStreamEnded;
+ }
+
+ @Override
+ public void releaseOutputBuffer(boolean render) {}
+
+ @Override
+ public void releaseOutputBuffer(long renderPresentationTimeUs) {}
+
+ @Override
+ public void release() {}
+ }
+
+ /** A {@link Muxer} implementation that does nothing. */
+ private static final class NoWriteMuxer implements Muxer {
+ public static final class Factory implements Muxer.Factory {
+
+ private final ImmutableList