Use FrameProcessorChain#SurfaceProvider for encoder compat transform.

This change adds a SurfaceProvider interface which is necessary to
allow for texture processors whose output size becomes available
asynchronously in follow-ups.
VTSP's implementation of this interface wraps the encoder and provides
its input surface together with the output frame width, height, and
orientation as used for encoder configuration.
The FrameProcessorChain converts the output frames to the provided
orientation and resolution using a ScaleToFitTransformation and
Presentation replacing EncoderCompatibilityTransformation.

PiperOrigin-RevId: 455112598
This commit is contained in:
hschlueter 2022-06-15 13:14:56 +00:00 committed by Marc Baechinger
parent b475f1f2da
commit ea7f1ca1e3
13 changed files with 702 additions and 524 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -63,6 +63,8 @@ public final class FrameProcessorChainPixelTest {
"media/bitmap/sample_mp4_first_frame/translate_right.png"; "media/bitmap/sample_mp4_first_frame/translate_right.png";
public static final String ROTATE_THEN_TRANSLATE_PNG_ASSET_PATH = public static final String ROTATE_THEN_TRANSLATE_PNG_ASSET_PATH =
"media/bitmap/sample_mp4_first_frame/rotate_then_translate.png"; "media/bitmap/sample_mp4_first_frame/rotate_then_translate.png";
public static final String ROTATE_THEN_SCALE_PNG_ASSET_PATH =
"media/bitmap/sample_mp4_first_frame/rotate45_then_scale2w.png";
public static final String TRANSLATE_THEN_ROTATE_PNG_ASSET_PATH = public static final String TRANSLATE_THEN_ROTATE_PNG_ASSET_PATH =
"media/bitmap/sample_mp4_first_frame/translate_then_rotate.png"; "media/bitmap/sample_mp4_first_frame/translate_then_rotate.png";
public static final String REQUEST_OUTPUT_HEIGHT_PNG_ASSET_PATH = public static final String REQUEST_OUTPUT_HEIGHT_PNG_ASSET_PATH =
@ -88,7 +90,7 @@ public final class FrameProcessorChainPixelTest {
new AtomicReference<>(); new AtomicReference<>();
private @MonotonicNonNull FrameProcessorChain frameProcessorChain; private @MonotonicNonNull FrameProcessorChain frameProcessorChain;
private @MonotonicNonNull ImageReader outputImageReader; private volatile @MonotonicNonNull ImageReader outputImageReader;
private @MonotonicNonNull MediaFormat mediaFormat; private @MonotonicNonNull MediaFormat mediaFormat;
@After @After
@ -260,6 +262,30 @@ public final class FrameProcessorChainPixelTest {
assertThat(averagePixelAbsoluteDifference).isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE); assertThat(averagePixelAbsoluteDifference).isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE);
} }
@Test
public void processData_withTwoWrappedScaleToFitTransformations_producesExpectedOutput()
throws Exception {
String testId = "processData_withTwoWrappedScaleToFitTransformations";
setUpAndPrepareFirstFrame(
DEFAULT_PIXEL_WIDTH_HEIGHT_RATIO,
new GlEffectWrapper(new ScaleToFitTransformation.Builder().setRotationDegrees(45).build()),
new GlEffectWrapper(
new ScaleToFitTransformation.Builder()
.setScale(/* scaleX= */ 2, /* scaleY= */ 1)
.build()));
Bitmap expectedBitmap = BitmapTestUtil.readBitmap(ROTATE_THEN_SCALE_PNG_ASSET_PATH);
Bitmap actualBitmap = processFirstFrameAndEnd();
BitmapTestUtil.maybeSaveTestBitmapToCacheDirectory(
testId, /* bitmapLabel= */ "actual", actualBitmap);
// TODO(b/207848601): switch to using proper tooling for testing against golden data.
float averagePixelAbsoluteDifference =
BitmapTestUtil.getAveragePixelAbsoluteDifferenceArgb8888(
expectedBitmap, actualBitmap, testId);
assertThat(averagePixelAbsoluteDifference).isAtMost(MAXIMUM_AVERAGE_PIXEL_ABSOLUTE_DIFFERENCE);
}
@Test @Test
public void public void
processData_withManyComposedMatrixTransformations_producesSameOutputAsCombinedTransformation() processData_withManyComposedMatrixTransformations_producesSameOutputAsCombinedTransformation()
@ -325,6 +351,7 @@ public final class FrameProcessorChainPixelTest {
int inputWidth = checkNotNull(mediaFormat).getInteger(MediaFormat.KEY_WIDTH); int inputWidth = checkNotNull(mediaFormat).getInteger(MediaFormat.KEY_WIDTH);
int inputHeight = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT); int inputHeight = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT);
frameProcessorChain = frameProcessorChain =
checkNotNull(
FrameProcessorChain.create( FrameProcessorChain.create(
context, context,
/* listener= */ this.frameProcessingException::set, /* listener= */ this.frameProcessingException::set,
@ -333,19 +360,18 @@ public final class FrameProcessorChainPixelTest {
inputHeight, inputHeight,
/* streamOffsetUs= */ 0L, /* streamOffsetUs= */ 0L,
effects, effects,
/* enableExperimentalHdrEditing= */ false); /* outputSurfaceProvider= */ (requestedWidth, requestedHeight) -> {
Size outputSize = frameProcessorChain.getOutputSize();
outputImageReader = outputImageReader =
ImageReader.newInstance( ImageReader.newInstance(
outputSize.getWidth(), requestedWidth,
outputSize.getHeight(), requestedHeight,
PixelFormat.RGBA_8888, PixelFormat.RGBA_8888,
/* maxImages= */ 1); /* maxImages= */ 1);
frameProcessorChain.setOutputSurface( return new SurfaceInfo(
outputImageReader.getSurface(), outputImageReader.getSurface(), requestedWidth, requestedHeight);
outputSize.getWidth(), },
outputSize.getHeight(), Transformer.DebugViewProvider.NONE,
/* debugSurfaceView= */ null); /* enableExperimentalHdrEditing= */ false));
frameProcessorChain.registerInputFrame(); frameProcessorChain.registerInputFrame();
// Queue the first video frame from the extractor. // Queue the first video frame from the extractor.
@ -437,4 +463,27 @@ public final class FrameProcessorChainPixelTest {
return checkStateNotNull(adjustedTransformationMatrix); return checkStateNotNull(adjustedTransformationMatrix);
} }
} }
/**
* Wraps a {@link GlEffect} to prevent the {@link FrameProcessorChain} from detecting its class
* and optimizing it.
*
* <p>This ensures that {@link FrameProcessorChain} uses a separate {@link GlTextureProcessor} for
* the wrapped {@link GlEffect} rather than merging it with preceding or subsequent {@link
* GlEffect} instances and applying them in one combined {@link GlTextureProcessor}.
*/
private static final class GlEffectWrapper implements GlEffect {
private final GlEffect effect;
public GlEffectWrapper(GlEffect effect) {
this.effect = effect;
}
@Override
public SingleFrameGlTextureProcessor toGlTextureProcessor(Context context)
throws FrameProcessingException {
return effect.toGlTextureProcessor(context);
}
}
} }

View File

@ -1,154 +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 androidx.media3.transformer;
import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
import static com.google.common.truth.Truth.assertThat;
import android.content.Context;
import android.util.Size;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Tests for creating and configuring a {@link FrameProcessorChain}.
*
* <p>See {@link FrameProcessorChainPixelTest} for data processing tests.
*/
@RunWith(AndroidJUnit4.class)
public final class FrameProcessorChainTest {
private final AtomicReference<FrameProcessingException> frameProcessingException =
new AtomicReference<>();
@Test
public void getOutputSize_noOperation_returnsInputSize() throws Exception {
Size inputSize = new Size(200, 100);
FrameProcessorChain frameProcessorChain =
createFrameProcessorChainWithFakeTextureProcessors(
/* pixelWidthHeightRatio= */ 1f,
inputSize,
/* textureProcessorOutputSizes= */ ImmutableList.of());
Size outputSize = frameProcessorChain.getOutputSize();
assertThat(outputSize).isEqualTo(inputSize);
assertThat(frameProcessingException.get()).isNull();
}
@Test
public void getOutputSize_withWidePixels_returnsWiderOutputSize() throws Exception {
Size inputSize = new Size(200, 100);
FrameProcessorChain frameProcessorChain =
createFrameProcessorChainWithFakeTextureProcessors(
/* pixelWidthHeightRatio= */ 2f,
inputSize,
/* textureProcessorOutputSizes= */ ImmutableList.of());
Size outputSize = frameProcessorChain.getOutputSize();
assertThat(outputSize).isEqualTo(new Size(400, 100));
assertThat(frameProcessingException.get()).isNull();
}
@Test
public void getOutputSize_withTallPixels_returnsTallerOutputSize() throws Exception {
Size inputSize = new Size(200, 100);
FrameProcessorChain frameProcessorChain =
createFrameProcessorChainWithFakeTextureProcessors(
/* pixelWidthHeightRatio= */ .5f,
inputSize,
/* textureProcessorOutputSizes= */ ImmutableList.of());
Size outputSize = frameProcessorChain.getOutputSize();
assertThat(outputSize).isEqualTo(new Size(200, 200));
assertThat(frameProcessingException.get()).isNull();
}
@Test
public void getOutputSize_withOneTextureProcessor_returnsItsOutputSize() throws Exception {
Size inputSize = new Size(200, 100);
Size textureProcessorOutputSize = new Size(300, 250);
FrameProcessorChain frameProcessorChain =
createFrameProcessorChainWithFakeTextureProcessors(
/* pixelWidthHeightRatio= */ 1f,
inputSize,
/* textureProcessorOutputSizes= */ ImmutableList.of(textureProcessorOutputSize));
Size frameProcessorChainOutputSize = frameProcessorChain.getOutputSize();
assertThat(frameProcessorChainOutputSize).isEqualTo(textureProcessorOutputSize);
assertThat(frameProcessingException.get()).isNull();
}
@Test
public void getOutputSize_withThreeTextureProcessors_returnsLastOutputSize() throws Exception {
Size inputSize = new Size(200, 100);
Size outputSize1 = new Size(300, 250);
Size outputSize2 = new Size(400, 244);
Size outputSize3 = new Size(150, 160);
FrameProcessorChain frameProcessorChain =
createFrameProcessorChainWithFakeTextureProcessors(
/* pixelWidthHeightRatio= */ 1f,
inputSize,
/* textureProcessorOutputSizes= */ ImmutableList.of(
outputSize1, outputSize2, outputSize3));
Size frameProcessorChainOutputSize = frameProcessorChain.getOutputSize();
assertThat(frameProcessorChainOutputSize).isEqualTo(outputSize3);
assertThat(frameProcessingException.get()).isNull();
}
private FrameProcessorChain createFrameProcessorChainWithFakeTextureProcessors(
float pixelWidthHeightRatio, Size inputSize, List<Size> textureProcessorOutputSizes)
throws FrameProcessingException {
ImmutableList.Builder<GlEffect> effects = new ImmutableList.Builder<>();
for (Size element : textureProcessorOutputSizes) {
effects.add((Context context) -> new FakeTextureProcessor(element));
}
return FrameProcessorChain.create(
getApplicationContext(),
/* listener= */ this.frameProcessingException::set,
pixelWidthHeightRatio,
inputSize.getWidth(),
inputSize.getHeight(),
/* streamOffsetUs= */ 0L,
effects.build(),
/* enableExperimentalHdrEditing= */ false);
}
private static class FakeTextureProcessor extends SingleFrameGlTextureProcessor {
private final Size outputSize;
private FakeTextureProcessor(Size outputSize) {
this.outputSize = outputSize;
}
@Override
public Size configure(int inputWidth, int inputHeight) {
return outputSize;
}
@Override
public void drawFrame(int inputTexId, long presentationTimeNs) {}
}
}

View File

@ -18,12 +18,15 @@ package androidx.media3.transformer;
import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING; import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_URI_STRING;
import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_URI_STRING; import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET_WITH_INCREASING_TIMESTAMPS_320W_240H_15S_URI_STRING;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import android.content.Context; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.util.List;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
@ -34,9 +37,10 @@ import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public class TransformerEndToEndTest { public class TransformerEndToEndTest {
private final Context context = ApplicationProvider.getApplicationContext();
@Test @Test
public void videoEditing_completesWithConsistentFrameCount() throws Exception { public void videoEditing_completesWithConsistentFrameCount() throws Exception {
Context context = ApplicationProvider.getApplicationContext();
Transformer transformer = Transformer transformer =
new Transformer.Builder(context) new Transformer.Builder(context)
.setTransformationRequest( .setTransformationRequest(
@ -61,7 +65,6 @@ public class TransformerEndToEndTest {
@Test @Test
public void videoOnly_completesWithConsistentDuration() throws Exception { public void videoOnly_completesWithConsistentDuration() throws Exception {
Context context = ApplicationProvider.getApplicationContext();
Transformer transformer = Transformer transformer =
new Transformer.Builder(context) new Transformer.Builder(context)
.setRemoveAudio(true) .setRemoveAudio(true)
@ -85,7 +88,6 @@ public class TransformerEndToEndTest {
@Test @Test
public void clippedMedia_completesWithClippedDuration() throws Exception { public void clippedMedia_completesWithClippedDuration() throws Exception {
Context context = ApplicationProvider.getApplicationContext();
Transformer transformer = new Transformer.Builder(context).build(); Transformer transformer = new Transformer.Builder(context).build();
long clippingStartMs = 10_000; long clippingStartMs = 10_000;
long clippingEndMs = 11_000; long clippingEndMs = 11_000;
@ -106,4 +108,65 @@ public class TransformerEndToEndTest {
assertThat(result.transformationResult.durationMs).isAtMost(clippingEndMs - clippingStartMs); assertThat(result.transformationResult.durationMs).isAtMost(clippingEndMs - clippingStartMs);
} }
@Test
public void videoEncoderFormatUnsupported_completesWithError() {
Transformer transformer =
new Transformer.Builder(context)
.setEncoderFactory(new VideoUnsupportedEncoderFactory(context))
.setRemoveAudio(true)
.build();
TransformationException exception =
assertThrows(
TransformationException.class,
() ->
new TransformerAndroidTestRunner.Builder(context, transformer)
.build()
.run(
/* testId= */ "videoEncoderFormatUnsupported_completesWithError",
MediaItem.fromUri(Uri.parse(MP4_ASSET_URI_STRING))));
assertThat(exception).hasCauseThat().isInstanceOf(IllegalArgumentException.class);
assertThat(exception.errorCode)
.isEqualTo(TransformationException.ERROR_CODE_ENCODER_INIT_FAILED);
assertThat(exception).hasMessageThat().contains("video");
}
private static final class VideoUnsupportedEncoderFactory implements Codec.EncoderFactory {
private final Codec.EncoderFactory encoderFactory;
public VideoUnsupportedEncoderFactory(Context context) {
encoderFactory = new DefaultEncoderFactory(context);
}
@Override
public Codec createForAudioEncoding(Format format, List<String> allowedMimeTypes)
throws TransformationException {
return encoderFactory.createForAudioEncoding(format, allowedMimeTypes);
}
@Override
public Codec createForVideoEncoding(Format format, List<String> allowedMimeTypes)
throws TransformationException {
throw TransformationException.createForCodec(
new IllegalArgumentException(),
/* isVideo= */ true,
/* isDecoder= */ false,
format,
/* mediaCodecName= */ null,
TransformationException.ERROR_CODE_ENCODER_INIT_FAILED);
}
@Override
public boolean audioNeedsEncoding() {
return false;
}
@Override
public boolean videoNeedsEncoding() {
return true;
}
}
} }

View File

@ -1,82 +0,0 @@
/*
* Copyright 2022 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.checkArgument;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import android.graphics.Matrix;
import android.util.Size;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/**
* Specifies a {@link Format#rotationDegrees} to apply to each frame for encoder compatibility, if
* needed.
*
* <p>Encoders commonly support higher maximum widths than maximum heights. This may rotate the
* decoded frame before encoding, so the encoded frame's width >= height, and set {@link
* Format#rotationDegrees} to ensure the frame is displayed in the correct orientation.
*/
/* package */ class EncoderCompatibilityTransformation implements MatrixTransformation {
// TODO(b/218488308): Allow reconfiguration of the output size, as encoders may not support the
// requested output resolution.
private int outputRotationDegrees;
private @MonotonicNonNull Matrix transformationMatrix;
/** Creates a new instance. */
public EncoderCompatibilityTransformation() {
outputRotationDegrees = C.LENGTH_UNSET;
}
@Override
public Size configure(int inputWidth, int inputHeight) {
checkArgument(inputWidth > 0, "inputWidth must be positive");
checkArgument(inputHeight > 0, "inputHeight must be positive");
transformationMatrix = new Matrix();
if (inputHeight > inputWidth) {
outputRotationDegrees = 90;
transformationMatrix.postRotate(outputRotationDegrees);
return new Size(inputHeight, inputWidth);
} else {
outputRotationDegrees = 0;
return new Size(inputWidth, inputHeight);
}
}
@Override
public Matrix getMatrix(long presentationTimeUs) {
return checkStateNotNull(transformationMatrix, "configure must be called first");
}
/**
* Returns {@link Format#rotationDegrees} for the output frame.
*
* <p>Return values may be {@code 0} or {@code 90} degrees.
*
* <p>Should only be called after {@linkplain #configure(int, int) configuration}.
*/
public int getOutputRotationDegrees() {
checkState(
outputRotationDegrees != C.LENGTH_UNSET,
"configure must be called before getOutputRotationDegrees");
return outputRotationDegrees;
}
}

View File

@ -18,7 +18,6 @@ package androidx.media3.transformer;
import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static com.google.common.collect.Iterables.getLast; import static com.google.common.collect.Iterables.getLast;
import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.MILLISECONDS;
@ -40,6 +39,7 @@ import androidx.media3.common.C;
import androidx.media3.common.util.GlUtil; import androidx.media3.common.util.GlUtil;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import com.google.common.base.Optional;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import java.util.List; import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
@ -58,8 +58,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
* and is processed on a background thread as it becomes available. All input frames should be * and is processed on a background thread as it becomes available. All input frames should be
* {@linkplain #registerInputFrame() registered} before they are rendered to the input surface. * {@linkplain #registerInputFrame() registered} before they are rendered to the input surface.
* {@link #getPendingFrameCount()} can be used to check whether there are frames that have not been * {@link #getPendingFrameCount()} can be used to check whether there are frames that have not been
* fully processed yet. Output is written to its {@linkplain #setOutputSurface(Surface, int, int, * fully processed yet. Output is written to the provided {@linkplain #create(Context, Listener,
* SurfaceView) output surface}. * float, int, int, long, List, SurfaceInfo.Provider, Transformer.DebugViewProvider, boolean) output
* surface}.
*/ */
// TODO(b/227625423): Factor out FrameProcessor interface and rename this class to GlFrameProcessor. // TODO(b/227625423): Factor out FrameProcessor interface and rename this class to GlFrameProcessor.
/* package */ final class FrameProcessorChain { /* package */ final class FrameProcessorChain {
@ -84,11 +85,18 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
* @param inputWidth The input frame width, in pixels. * @param inputWidth The input frame width, in pixels.
* @param inputHeight The input frame height, in pixels. * @param inputHeight The input frame height, in pixels.
* @param effects The {@link GlEffect GlEffects} to apply to each frame. * @param effects The {@link GlEffect GlEffects} to apply to each frame.
* @param outputSurfaceProvider A {@link SurfaceInfo.Provider} managing the output {@link
* Surface}.
* @param debugViewProvider A {@link Transformer.DebugViewProvider}.
* @param enableExperimentalHdrEditing Whether to attempt to process the input as an HDR signal. * @param enableExperimentalHdrEditing Whether to attempt to process the input as an HDR signal.
* @return A new instance. * @return A new instance or {@code null}, if no output surface was provided.
* @throws FrameProcessingException If reading shader files fails, or an OpenGL error occurs while * @throws FrameProcessingException If reading shader files fails, or an OpenGL error occurs while
* creating and configuring the OpenGL components. * creating and configuring the OpenGL components.
*/ */
// TODO(b/227625423): Remove @Nullable here and allow the output surface to be @Nullable until
// the output surface is requested when the output size becomes available asynchronously
// via the final GlTextureProcessor.
@Nullable
public static FrameProcessorChain create( public static FrameProcessorChain create(
Context context, Context context,
Listener listener, Listener listener,
@ -97,6 +105,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
int inputHeight, int inputHeight,
long streamOffsetUs, long streamOffsetUs,
List<GlEffect> effects, List<GlEffect> effects,
SurfaceInfo.Provider outputSurfaceProvider,
Transformer.DebugViewProvider debugViewProvider,
boolean enableExperimentalHdrEditing) boolean enableExperimentalHdrEditing)
throws FrameProcessingException { throws FrameProcessingException {
checkArgument(inputWidth > 0, "inputWidth must be positive"); checkArgument(inputWidth > 0, "inputWidth must be positive");
@ -104,10 +114,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
ExecutorService singleThreadExecutorService = Util.newSingleThreadExecutor(THREAD_NAME); ExecutorService singleThreadExecutorService = Util.newSingleThreadExecutor(THREAD_NAME);
try { Future<Optional<FrameProcessorChain>> frameProcessorChainFuture =
return singleThreadExecutorService singleThreadExecutorService.submit(
.submit(
() -> () ->
Optional.fromNullable(
createOpenGlObjectsAndFrameProcessorChain( createOpenGlObjectsAndFrameProcessorChain(
context, context,
listener, listener,
@ -116,9 +126,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
inputHeight, inputHeight,
streamOffsetUs, streamOffsetUs,
effects, effects,
outputSurfaceProvider,
debugViewProvider,
enableExperimentalHdrEditing, enableExperimentalHdrEditing,
singleThreadExecutorService)) singleThreadExecutorService)));
.get();
try {
return frameProcessorChainFuture.get().orNull();
} catch (ExecutionException e) { } catch (ExecutionException e) {
throw new FrameProcessingException(e); throw new FrameProcessingException(e);
} catch (InterruptedException e) { } catch (InterruptedException e) {
@ -135,6 +149,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
* <p>This method must be executed using the {@code singleThreadExecutorService}. * <p>This method must be executed using the {@code singleThreadExecutorService}.
*/ */
@WorkerThread @WorkerThread
@Nullable
private static FrameProcessorChain createOpenGlObjectsAndFrameProcessorChain( private static FrameProcessorChain createOpenGlObjectsAndFrameProcessorChain(
Context context, Context context,
Listener listener, Listener listener,
@ -143,6 +158,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
int inputHeight, int inputHeight,
long streamOffsetUs, long streamOffsetUs,
List<GlEffect> effects, List<GlEffect> effects,
SurfaceInfo.Provider outputSurfaceProvider,
Transformer.DebugViewProvider debugViewProvider,
boolean enableExperimentalHdrEditing, boolean enableExperimentalHdrEditing,
ExecutorService singleThreadExecutorService) ExecutorService singleThreadExecutorService)
throws GlUtil.GlException, FrameProcessingException { throws GlUtil.GlException, FrameProcessingException {
@ -164,45 +181,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
GlUtil.focusPlaceholderEglSurface(eglContext, eglDisplay); GlUtil.focusPlaceholderEglSurface(eglContext, eglDisplay);
} }
ExternalTextureProcessor externalTextureProcessor =
new ExternalTextureProcessor(context, enableExperimentalHdrEditing);
ImmutableList<SingleFrameGlTextureProcessor> textureProcessors =
getTextureProcessors(context, externalTextureProcessor, pixelWidthHeightRatio, effects);
// Initialize texture processors.
int inputExternalTexId = GlUtil.createExternalTexture();
Size outputSize = externalTextureProcessor.configure(inputWidth, inputHeight);
ImmutableList.Builder<TextureInfo> intermediateTextures = new ImmutableList.Builder<>();
for (int i = 1; i < textureProcessors.size(); i++) {
int texId = GlUtil.createTexture(outputSize.getWidth(), outputSize.getHeight());
int fboId = GlUtil.createFboForTexture(texId);
intermediateTextures.add(
new TextureInfo(texId, fboId, outputSize.getWidth(), outputSize.getHeight()));
SingleFrameGlTextureProcessor textureProcessor = textureProcessors.get(i);
outputSize = textureProcessor.configure(outputSize.getWidth(), outputSize.getHeight());
}
return new FrameProcessorChain(
eglDisplay,
eglContext,
singleThreadExecutorService,
inputExternalTexId,
streamOffsetUs,
intermediateTextures.build(),
textureProcessors,
outputSize,
listener,
enableExperimentalHdrEditing);
}
private static ImmutableList<SingleFrameGlTextureProcessor> getTextureProcessors(
Context context,
ExternalTextureProcessor externalTextureProcessor,
float pixelWidthHeightRatio,
List<GlEffect> effects)
throws FrameProcessingException {
ImmutableList.Builder<SingleFrameGlTextureProcessor> textureProcessors =
new ImmutableList.Builder<SingleFrameGlTextureProcessor>().add(externalTextureProcessor);
ImmutableList.Builder<GlMatrixTransformation> matrixTransformationListBuilder = ImmutableList.Builder<GlMatrixTransformation> matrixTransformationListBuilder =
new ImmutableList.Builder<>(); new ImmutableList.Builder<>();
// Scale to expand the frame to apply the pixelWidthHeightRatio. // Scale to expand the frame to apply the pixelWidthHeightRatio.
@ -218,6 +196,14 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
.build()); .build());
} }
ExternalTextureProcessor externalTextureProcessor =
new ExternalTextureProcessor(context, enableExperimentalHdrEditing);
int inputExternalTexId = GlUtil.createExternalTexture();
Size outputSize = externalTextureProcessor.configure(inputWidth, inputHeight);
ImmutableList.Builder<TextureInfo> intermediateTextures = new ImmutableList.Builder<>();
ImmutableList.Builder<SingleFrameGlTextureProcessor> textureProcessors =
new ImmutableList.Builder<SingleFrameGlTextureProcessor>().add(externalTextureProcessor);
// Combine consecutive GlMatrixTransformations into a single SingleFrameGlTextureProcessor and // Combine consecutive GlMatrixTransformations into a single SingleFrameGlTextureProcessor and
// convert all other GlEffects to SingleFrameGlTextureProcessors. // convert all other GlEffects to SingleFrameGlTextureProcessors.
for (int i = 0; i < effects.size(); i++) { for (int i = 0; i < effects.size(); i++) {
@ -226,21 +212,100 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
matrixTransformationListBuilder.add((GlMatrixTransformation) effect); matrixTransformationListBuilder.add((GlMatrixTransformation) effect);
continue; continue;
} }
ImmutableList<GlMatrixTransformation> matrixTransformations = ImmutableList<GlMatrixTransformation> matrixTransformations =
matrixTransformationListBuilder.build(); matrixTransformationListBuilder.build();
if (!matrixTransformations.isEmpty()) { if (!matrixTransformations.isEmpty()) {
textureProcessors.add(new MatrixTransformationProcessor(context, matrixTransformations)); MatrixTransformationProcessor matrixTransformationProcessor =
new MatrixTransformationProcessor(context, matrixTransformations);
intermediateTextures.add(createTexture(outputSize.getWidth(), outputSize.getHeight()));
outputSize =
matrixTransformationProcessor.configure(outputSize.getWidth(), outputSize.getHeight());
textureProcessors.add(matrixTransformationProcessor);
matrixTransformationListBuilder = new ImmutableList.Builder<>(); matrixTransformationListBuilder = new ImmutableList.Builder<>();
} }
textureProcessors.add(effect.toGlTextureProcessor(context)); intermediateTextures.add(createTexture(outputSize.getWidth(), outputSize.getHeight()));
SingleFrameGlTextureProcessor textureProcessor = effect.toGlTextureProcessor(context);
outputSize = textureProcessor.configure(outputSize.getWidth(), outputSize.getHeight());
textureProcessors.add(textureProcessor);
} }
// TODO(b/227625423): Request the output surface during processing when the output size becomes
// available asynchronously via the final GlTextureProcessor instead of requesting it here.
// This will also avoid needing to return null here when no surface is provided.
Size requestedOutputSize =
MatrixUtils.configureAndGetOutputSize(
outputSize.getWidth(), outputSize.getHeight(), matrixTransformationListBuilder.build());
@Nullable
SurfaceInfo outputSurfaceInfo =
outputSurfaceProvider.getSurfaceInfo(
requestedOutputSize.getWidth(), requestedOutputSize.getHeight());
if (outputSurfaceInfo == null) {
Log.d(TAG, "No output surface provided.");
return null;
}
if (outputSurfaceInfo.orientationDegrees != 0) {
matrixTransformationListBuilder.add(
new ScaleToFitTransformation.Builder()
.setRotationDegrees(outputSurfaceInfo.orientationDegrees)
.build());
}
if (outputSurfaceInfo.width != outputSize.getWidth()
|| outputSurfaceInfo.height != outputSize.getHeight()) {
matrixTransformationListBuilder.add(
new Presentation.Builder()
.setAspectRatio(
outputSurfaceInfo.width / (float) outputSurfaceInfo.height,
Presentation.LAYOUT_SCALE_TO_FIT)
.setResolution(outputSurfaceInfo.height)
.build());
}
// Convert final list of matrix transformations (including additional transformations for the
// output surface) to a SingleFrameGlTextureProcessors.
ImmutableList<GlMatrixTransformation> matrixTransformations = ImmutableList<GlMatrixTransformation> matrixTransformations =
matrixTransformationListBuilder.build(); matrixTransformationListBuilder.build();
if (!matrixTransformations.isEmpty()) { if (!matrixTransformations.isEmpty()) {
textureProcessors.add(new MatrixTransformationProcessor(context, matrixTransformations)); intermediateTextures.add(createTexture(outputSize.getWidth(), outputSize.getHeight()));
MatrixTransformationProcessor matrixTransformationProcessor =
new MatrixTransformationProcessor(context, matrixTransformations);
outputSize =
matrixTransformationProcessor.configure(outputSize.getWidth(), outputSize.getHeight());
checkState(outputSize.getWidth() == outputSurfaceInfo.width);
checkState(outputSize.getHeight() == outputSurfaceInfo.height);
textureProcessors.add(matrixTransformationProcessor);
} }
return textureProcessors.build(); EGLSurface outputEglSurface;
if (enableExperimentalHdrEditing) {
// TODO(b/227624622): Don't assume BT.2020 PQ input/output.
outputEglSurface = GlUtil.getEglSurfaceBt2020Pq(eglDisplay, outputSurfaceInfo.surface);
} else {
outputEglSurface = GlUtil.getEglSurface(eglDisplay, outputSurfaceInfo.surface);
}
return new FrameProcessorChain(
eglDisplay,
eglContext,
singleThreadExecutorService,
inputExternalTexId,
streamOffsetUs,
intermediateTextures.build(),
textureProcessors.build(),
outputSurfaceInfo.width,
outputSurfaceInfo.height,
outputEglSurface,
listener,
debugViewProvider.getDebugPreviewSurfaceView(
outputSurfaceInfo.width, outputSurfaceInfo.height),
enableExperimentalHdrEditing);
}
private static TextureInfo createTexture(int outputWidth, int outputHeight)
throws GlUtil.GlException {
int texId = GlUtil.createTexture(outputWidth, outputHeight);
int fboId = GlUtil.createFboForTexture(texId);
return new TextureInfo(texId, fboId, outputWidth, outputHeight);
} }
private static final String TAG = "FrameProcessorChain"; private static final String TAG = "FrameProcessorChain";
@ -282,8 +347,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
* SingleFrameGlTextureProcessor}. * SingleFrameGlTextureProcessor}.
*/ */
private final ImmutableList<TextureInfo> intermediateTextures; private final ImmutableList<TextureInfo> intermediateTextures;
/** The last texture processor's output {@link Size}. */
private final Size recommendedOutputSize;
private final Listener listener; private final Listener listener;
@ -293,15 +356,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
*/ */
private final AtomicBoolean stopProcessing; private final AtomicBoolean stopProcessing;
private int outputWidth; private final int outputWidth;
private int outputHeight; private final int outputHeight;
private @MonotonicNonNull Surface outputSurface;
/** /**
* Wraps the output {@link Surface} that is populated with the output of the final {@link * Wraps the output {@link Surface} that is populated with the output of the final {@link
* SingleFrameGlTextureProcessor} for each frame. * SingleFrameGlTextureProcessor} for each frame.
*/ */
private @MonotonicNonNull EGLSurface outputEglSurface; private final EGLSurface outputEglSurface;
/** /**
* Wraps a debug {@link SurfaceView} that is populated with the output of the final {@link * Wraps a debug {@link SurfaceView} that is populated with the output of the final {@link
* SingleFrameGlTextureProcessor} for each frame. * SingleFrameGlTextureProcessor} for each frame.
@ -320,8 +381,11 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
long streamOffsetUs, long streamOffsetUs,
ImmutableList<TextureInfo> intermediateTextures, ImmutableList<TextureInfo> intermediateTextures,
ImmutableList<SingleFrameGlTextureProcessor> textureProcessors, ImmutableList<SingleFrameGlTextureProcessor> textureProcessors,
Size recommendedOutputSize, int outputWidth,
int outputHeight,
EGLSurface outputEglSurface,
Listener listener, Listener listener,
@Nullable SurfaceView debugSurfaceView,
boolean enableExperimentalHdrEditing) { boolean enableExperimentalHdrEditing) {
checkState(!textureProcessors.isEmpty()); checkState(!textureProcessors.isEmpty());
@ -332,7 +396,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
this.streamOffsetUs = streamOffsetUs; this.streamOffsetUs = streamOffsetUs;
this.intermediateTextures = intermediateTextures; this.intermediateTextures = intermediateTextures;
this.textureProcessors = textureProcessors; this.textureProcessors = textureProcessors;
this.recommendedOutputSize = recommendedOutputSize; this.outputWidth = outputWidth;
this.outputHeight = outputHeight;
this.outputEglSurface = outputEglSurface;
this.listener = listener; this.listener = listener;
this.stopProcessing = new AtomicBoolean(); this.stopProcessing = new AtomicBoolean();
this.enableExperimentalHdrEditing = enableExperimentalHdrEditing; this.enableExperimentalHdrEditing = enableExperimentalHdrEditing;
@ -342,47 +408,14 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
inputSurfaceTexture = new SurfaceTexture(inputExternalTexId); inputSurfaceTexture = new SurfaceTexture(inputExternalTexId);
inputSurface = new Surface(inputSurfaceTexture); inputSurface = new Surface(inputSurfaceTexture);
textureTransformMatrix = new float[16]; textureTransformMatrix = new float[16];
outputWidth = C.LENGTH_UNSET;
outputHeight = C.LENGTH_UNSET;
}
/**
* Returns the recommended output size.
*
* <p>This is the recommended size to use for the {@linkplain #setOutputSurface(Surface, int, int,
* SurfaceView) output surface}.
*/
public Size getOutputSize() {
return recommendedOutputSize;
}
/**
* Sets the output {@link Surface}.
*
* <p>The recommended output size is given by {@link #getOutputSize()}. Setting a different output
* size may cause poor quality or distortion.
*
* @param outputSurface The output {@link Surface}.
* @param outputWidth The output width, in pixels.
* @param outputHeight The output height, in pixels.
* @param debugSurfaceView Optional debug {@link SurfaceView} to show output.
*/
public void setOutputSurface(
Surface outputSurface,
int outputWidth,
int outputHeight,
@Nullable SurfaceView debugSurfaceView) {
// TODO(b/218488308): Don't override output size for encoder fallback. Instead allow the final
// SingleFrameGlTextureProcessor to be re-configured or append another
// SingleFrameGlTextureProcessor.
this.outputSurface = outputSurface;
this.outputWidth = outputWidth;
this.outputHeight = outputHeight;
if (debugSurfaceView != null) { if (debugSurfaceView != null) {
debugSurfaceViewWrapper = new SurfaceViewWrapper(debugSurfaceView); debugSurfaceViewWrapper = new SurfaceViewWrapper(debugSurfaceView);
} }
}
/** Returns the input {@link Surface}. */
public Surface getInputSurface() {
// TODO(b/227625423): Allow input surface to be recreated for input size change.
inputSurfaceTexture.setOnFrameAvailableListener( inputSurfaceTexture.setOnFrameAvailableListener(
surfaceTexture -> { surfaceTexture -> {
if (stopProcessing.get()) { if (stopProcessing.get()) {
@ -398,10 +431,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
} }
} }
}); });
}
/** Returns the input {@link Surface}. */
public Surface getInputSurface() {
return inputSurface; return inputSurface;
} }
@ -479,16 +508,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
try { try {
checkState(Thread.currentThread().getName().equals(THREAD_NAME)); checkState(Thread.currentThread().getName().equals(THREAD_NAME));
if (outputEglSurface == null) {
checkStateNotNull(outputSurface);
if (enableExperimentalHdrEditing) {
// TODO(b/227624622): Don't assume BT.2020 PQ input/output.
outputEglSurface = GlUtil.getEglSurfaceBt2020Pq(eglDisplay, outputSurface);
} else {
outputEglSurface = GlUtil.getEglSurface(eglDisplay, outputSurface);
}
}
inputSurfaceTexture.updateTexImage(); inputSurfaceTexture.updateTexImage();
long inputFrameTimeNs = inputSurfaceTexture.getTimestamp(); long inputFrameTimeNs = inputSurfaceTexture.getTimestamp();
// Correct for the stream offset so processors see original media presentation timestamps. // Correct for the stream offset so processors see original media presentation timestamps.

View File

@ -15,7 +15,6 @@
*/ */
package androidx.media3.transformer; package androidx.media3.transformer;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Assertions.checkState;
import android.content.Context; import android.content.Context;
@ -133,16 +132,7 @@ import java.util.Arrays;
@Override @Override
public Size configure(int inputWidth, int inputHeight) { public Size configure(int inputWidth, int inputHeight) {
checkArgument(inputWidth > 0, "inputWidth must be positive"); return MatrixUtils.configureAndGetOutputSize(inputWidth, inputHeight, matrixTransformations);
checkArgument(inputHeight > 0, "inputHeight must be positive");
Size outputSize = new Size(inputWidth, inputHeight);
for (int i = 0; i < matrixTransformations.size(); i++) {
outputSize =
matrixTransformations.get(i).configure(outputSize.getWidth(), outputSize.getHeight());
}
return outputSize;
} }
@Override @Override

View File

@ -18,6 +18,7 @@ package androidx.media3.transformer;
import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkArgument;
import android.opengl.Matrix; import android.opengl.Matrix;
import android.util.Size;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import java.util.Arrays; import java.util.Arrays;
@ -217,6 +218,26 @@ import java.util.Arrays;
return transformedPoints.build(); return transformedPoints.build();
} }
/**
* Returns the output frame {@link Size} after applying the given list of {@link
* GlMatrixTransformation GlMatrixTransformations} to an input frame with the given size.
*/
public static Size configureAndGetOutputSize(
int inputWidth,
int inputHeight,
ImmutableList<GlMatrixTransformation> matrixTransformations) {
checkArgument(inputWidth > 0, "inputWidth must be positive");
checkArgument(inputHeight > 0, "inputHeight must be positive");
Size outputSize = new Size(inputWidth, inputHeight);
for (int i = 0; i < matrixTransformations.size(); i++) {
outputSize =
matrixTransformations.get(i).configure(outputSize.getWidth(), outputSize.getHeight());
}
return outputSize;
}
/** Class only contains static methods. */ /** Class only contains static methods. */
private MatrixUtils() {} private MatrixUtils() {}
} }

View File

@ -0,0 +1,70 @@
/*
* Copyright 2022 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.checkArgument;
import android.view.Surface;
import androidx.annotation.Nullable;
/** Immutable value class for a {@link Surface} and supporting information. */
/* package */ final class SurfaceInfo {
/** The {@link Surface}. */
public final Surface surface;
/** The width of frames rendered to the {@link #surface}, in pixels. */
public final int width;
/** The height of frames rendered to the {@link #surface}, in pixels. */
public final int height;
/**
* A counter-clockwise rotation to apply to frames before rendering them to the {@link #surface}.
*
* <p>Must be 0, 90, 180, or 270 degrees. Default is 0.
*/
public final int orientationDegrees;
/** Creates a new instance. */
public SurfaceInfo(Surface surface, int width, int height) {
this(surface, width, height, /* orientationDegrees= */ 0);
}
/** Creates a new instance. */
public SurfaceInfo(Surface surface, int width, int height, int orientationDegrees) {
checkArgument(
orientationDegrees == 0
|| orientationDegrees == 90
|| orientationDegrees == 180
|| orientationDegrees == 270,
"orientationDegrees must be 0, 90, 180, or 270");
this.surface = surface;
this.width = width;
this.height = height;
this.orientationDegrees = orientationDegrees;
}
/** A provider for a {@link SurfaceInfo} instance. */
public interface Provider {
/**
* Provides a {@linkplain SurfaceInfo surface} for the requested dimensions.
*
* <p>The dimensions given in the provided {@link SurfaceInfo} may differ from the requested
* dimensions. It is up to the caller to transform frames from the requested dimensions to the
* provided dimensions before rendering them to the {@link SurfaceInfo#surface}.
*/
@Nullable
SurfaceInfo getSurfaceInfo(int requestedWidth, int requestedHeight);
}
}

View File

@ -106,10 +106,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
encoderFactory, encoderFactory,
muxerWrapper.getSupportedSampleMimeTypes(getTrackType()), muxerWrapper.getSupportedSampleMimeTypes(getTrackType()),
fallbackListener, fallbackListener,
/* frameProcessorChainListener= */ exception -> asyncErrorListener,
asyncErrorListener.onTransformationException(
TransformationException.createForFrameProcessorChain(
exception, TransformationException.ERROR_CODE_GL_PROCESSING_FAILED)),
debugViewProvider); debugViewProvider);
} }
if (transformationRequest.flattenForSlowMotion) { if (transformationRequest.flattenForSlowMotion) {

View File

@ -17,25 +17,29 @@
package androidx.media3.transformer; package androidx.media3.transformer;
import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import android.content.Context; import android.content.Context;
import android.media.MediaCodec; import android.media.MediaCodec;
import android.util.Size; import android.view.Surface;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.Format; import androidx.media3.common.Format;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.decoder.DecoderInputBuffer; import androidx.media3.decoder.DecoderInputBuffer;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import java.nio.ByteBuffer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.dataflow.qual.Pure; import org.checkerframework.dataflow.qual.Pure;
/** /**
* Pipeline to decode video samples, apply transformations on the raw samples, and re-encode them. * Pipeline to decode video samples, apply transformations on the raw samples, and re-encode them.
*/ */
/* package */ final class VideoTranscodingSamplePipeline implements SamplePipeline { /* package */ final class VideoTranscodingSamplePipeline implements SamplePipeline {
private final int outputRotationDegrees;
private final int maxPendingFrameCount; private final int maxPendingFrameCount;
private final DecoderInputBuffer decoderInputBuffer; private final DecoderInputBuffer decoderInputBuffer;
@ -44,7 +48,7 @@ import org.checkerframework.dataflow.qual.Pure;
private final FrameProcessorChain frameProcessorChain; private final FrameProcessorChain frameProcessorChain;
private final Codec encoder; private final EncoderWrapper encoderWrapper;
private final DecoderInputBuffer encoderOutputBuffer; private final DecoderInputBuffer encoderOutputBuffer;
private boolean signaledEndOfStreamToEncoder; private boolean signaledEndOfStreamToEncoder;
@ -59,7 +63,7 @@ import org.checkerframework.dataflow.qual.Pure;
Codec.EncoderFactory encoderFactory, Codec.EncoderFactory encoderFactory,
List<String> allowedOutputMimeTypes, List<String> allowedOutputMimeTypes,
FallbackListener fallbackListener, FallbackListener fallbackListener,
FrameProcessorChain.Listener frameProcessorChainListener, Transformer.AsyncErrorListener asyncErrorListener,
Transformer.DebugViewProvider debugViewProvider) Transformer.DebugViewProvider debugViewProvider)
throws TransformationException { throws TransformationException {
decoderInputBuffer = decoderInputBuffer =
@ -89,54 +93,45 @@ import org.checkerframework.dataflow.qual.Pure;
effectsListBuilder.add( effectsListBuilder.add(
new Presentation.Builder().setResolution(transformationRequest.outputHeight).build()); new Presentation.Builder().setResolution(transformationRequest.outputHeight).build());
} }
EncoderCompatibilityTransformation encoderCompatibilityTransformation =
new EncoderCompatibilityTransformation(); AtomicReference<TransformationException> encoderInitializationException =
effectsListBuilder.add(encoderCompatibilityTransformation); new AtomicReference<>();
encoderWrapper =
new EncoderWrapper(
encoderFactory,
inputFormat,
allowedOutputMimeTypes,
transformationRequest,
fallbackListener,
encoderInitializationException);
@Nullable FrameProcessorChain frameProcessorChain;
try { try {
frameProcessorChain = frameProcessorChain =
FrameProcessorChain.create( FrameProcessorChain.create(
context, context,
frameProcessorChainListener, /* listener= */ exception ->
asyncErrorListener.onTransformationException(
TransformationException.createForFrameProcessorChain(
exception, TransformationException.ERROR_CODE_GL_PROCESSING_FAILED)),
inputFormat.pixelWidthHeightRatio, inputFormat.pixelWidthHeightRatio,
/* inputWidth= */ decodedWidth, /* inputWidth= */ decodedWidth,
/* inputHeight= */ decodedHeight, /* inputHeight= */ decodedHeight,
streamOffsetUs, streamOffsetUs,
effectsListBuilder.build(), effectsListBuilder.build(),
/* outputSurfaceProvider= */ encoderWrapper,
debugViewProvider,
transformationRequest.enableHdrEditing); transformationRequest.enableHdrEditing);
} catch (FrameProcessingException e) { } catch (FrameProcessingException e) {
throw TransformationException.createForFrameProcessorChain( throw TransformationException.createForFrameProcessorChain(
e, TransformationException.ERROR_CODE_GL_INIT_FAILED); e, TransformationException.ERROR_CODE_GL_INIT_FAILED);
} }
Size requestedEncoderSize = frameProcessorChain.getOutputSize();
outputRotationDegrees = encoderCompatibilityTransformation.getOutputRotationDegrees();
Format requestedEncoderFormat = if (frameProcessorChain == null) {
new Format.Builder() // Failed to create FrameProcessorChain because the encoder could not provide a surface.
.setWidth(requestedEncoderSize.getWidth()) throw checkStateNotNull(encoderInitializationException.get());
.setHeight(requestedEncoderSize.getHeight()) }
.setRotationDegrees(0) this.frameProcessorChain = frameProcessorChain;
.setFrameRate(inputFormat.frameRate)
.setSampleMimeType(
transformationRequest.videoMimeType != null
? transformationRequest.videoMimeType
: inputFormat.sampleMimeType)
.build();
encoder = encoderFactory.createForVideoEncoding(requestedEncoderFormat, allowedOutputMimeTypes);
Format encoderSupportedFormat = encoder.getConfigurationFormat();
fallbackListener.onTransformationRequestFinalized(
createFallbackTransformationRequest(
transformationRequest,
/* hasOutputFormatRotation= */ outputRotationDegrees == 0,
requestedEncoderFormat,
encoderSupportedFormat));
frameProcessorChain.setOutputSurface(
/* outputSurface= */ encoder.getInputSurface(),
/* outputWidth= */ encoderSupportedFormat.width,
/* outputHeight= */ encoderSupportedFormat.height,
debugViewProvider.getDebugPreviewSurfaceView(
encoderSupportedFormat.width, encoderSupportedFormat.height));
decoder = decoder =
decoderFactory.createForVideoDecoding( decoderFactory.createForVideoDecoding(
@ -164,7 +159,7 @@ import org.checkerframework.dataflow.qual.Pure;
public boolean processData() throws TransformationException { public boolean processData() throws TransformationException {
if (frameProcessorChain.isEnded()) { if (frameProcessorChain.isEnded()) {
if (!signaledEndOfStreamToEncoder) { if (!signaledEndOfStreamToEncoder) {
encoder.signalEndOfInputStream(); encoderWrapper.signalEndOfInputStream();
signaledEndOfStreamToEncoder = true; signaledEndOfStreamToEncoder = true;
} }
return false; return false;
@ -187,20 +182,17 @@ import org.checkerframework.dataflow.qual.Pure;
@Override @Override
@Nullable @Nullable
public Format getOutputFormat() throws TransformationException { public Format getOutputFormat() throws TransformationException {
@Nullable Format format = encoder.getOutputFormat(); return encoderWrapper.getOutputFormat();
return format == null
? null
: format.buildUpon().setRotationDegrees(outputRotationDegrees).build();
} }
@Override @Override
@Nullable @Nullable
public DecoderInputBuffer getOutputBuffer() throws TransformationException { public DecoderInputBuffer getOutputBuffer() throws TransformationException {
encoderOutputBuffer.data = encoder.getOutputBuffer(); encoderOutputBuffer.data = encoderWrapper.getOutputBuffer();
if (encoderOutputBuffer.data == null) { if (encoderOutputBuffer.data == null) {
return null; return null;
} }
MediaCodec.BufferInfo bufferInfo = checkNotNull(encoder.getOutputBufferInfo()); MediaCodec.BufferInfo bufferInfo = checkNotNull(encoderWrapper.getOutputBufferInfo());
encoderOutputBuffer.timeUs = bufferInfo.presentationTimeUs; encoderOutputBuffer.timeUs = bufferInfo.presentationTimeUs;
encoderOutputBuffer.setFlags(bufferInfo.flags); encoderOutputBuffer.setFlags(bufferInfo.flags);
return encoderOutputBuffer; return encoderOutputBuffer;
@ -208,19 +200,19 @@ import org.checkerframework.dataflow.qual.Pure;
@Override @Override
public void releaseOutputBuffer() throws TransformationException { public void releaseOutputBuffer() throws TransformationException {
encoder.releaseOutputBuffer(/* render= */ false); encoderWrapper.releaseOutputBuffer(/* render= */ false);
} }
@Override @Override
public boolean isEnded() { public boolean isEnded() {
return encoder.isEnded(); return encoderWrapper.isEnded();
} }
@Override @Override
public void release() { public void release() {
frameProcessorChain.release(); frameProcessorChain.release();
decoder.release(); decoder.release();
encoder.release(); encoderWrapper.release();
} }
/** /**
@ -292,4 +284,151 @@ import org.checkerframework.dataflow.qual.Pure;
} }
return false; return false;
} }
/**
* Wraps an {@linkplain Codec encoder} and provides its input {@link Surface}.
*
* <p>The encoder is created once the {@link Surface} is {@linkplain #getSurfaceInfo(int, int)
* requested}. If it is {@linkplain #getSurfaceInfo(int, int) requested} again with different
* dimensions, the same encoder is used and the provided dimensions stay fixed.
*/
@VisibleForTesting
/* package */ static final class EncoderWrapper implements SurfaceInfo.Provider {
private final Codec.EncoderFactory encoderFactory;
private final Format inputFormat;
private final List<String> allowedOutputMimeTypes;
private final TransformationRequest transformationRequest;
private final FallbackListener fallbackListener;
private final AtomicReference<TransformationException> encoderInitializationException;
private @MonotonicNonNull SurfaceInfo encoderSurfaceInfo;
private volatile @MonotonicNonNull Codec encoder;
private volatile int outputRotationDegrees;
private volatile boolean releaseEncoder;
public EncoderWrapper(
Codec.EncoderFactory encoderFactory,
Format inputFormat,
List<String> allowedOutputMimeTypes,
TransformationRequest transformationRequest,
FallbackListener fallbackListener,
AtomicReference<TransformationException> encoderInitializationException) {
this.encoderFactory = encoderFactory;
this.inputFormat = inputFormat;
this.allowedOutputMimeTypes = allowedOutputMimeTypes;
this.transformationRequest = transformationRequest;
this.fallbackListener = fallbackListener;
this.encoderInitializationException = encoderInitializationException;
}
@Override
@Nullable
public SurfaceInfo getSurfaceInfo(int requestedWidth, int requestedHeight) {
if (releaseEncoder) {
return null;
}
if (encoderSurfaceInfo != null) {
return encoderSurfaceInfo;
}
// Encoders commonly support higher maximum widths than maximum heights. This may rotate the
// frame before encoding, so the encoded frame's width >= height, and sets
// rotationDegrees in the output Format to ensure the frame is displayed in the correct
// orientation.
boolean flipOrientation = requestedWidth < requestedHeight;
if (flipOrientation) {
int temp = requestedWidth;
requestedWidth = requestedHeight;
requestedHeight = temp;
outputRotationDegrees = 90;
}
Format requestedEncoderFormat =
new Format.Builder()
.setWidth(requestedWidth)
.setHeight(requestedHeight)
.setRotationDegrees(0)
.setFrameRate(inputFormat.frameRate)
.setSampleMimeType(
transformationRequest.videoMimeType != null
? transformationRequest.videoMimeType
: inputFormat.sampleMimeType)
.build();
try {
encoder =
encoderFactory.createForVideoEncoding(requestedEncoderFormat, allowedOutputMimeTypes);
} catch (TransformationException e) {
encoderInitializationException.set(e);
return null;
}
Format encoderSupportedFormat = encoder.getConfigurationFormat();
fallbackListener.onTransformationRequestFinalized(
createFallbackTransformationRequest(
transformationRequest,
/* hasOutputFormatRotation= */ flipOrientation,
requestedEncoderFormat,
encoderSupportedFormat));
encoderSurfaceInfo =
new SurfaceInfo(
encoder.getInputSurface(),
encoderSupportedFormat.width,
encoderSupportedFormat.height,
outputRotationDegrees);
if (releaseEncoder) {
encoder.release();
}
return encoderSurfaceInfo;
}
public void signalEndOfInputStream() throws TransformationException {
if (encoder != null) {
encoder.signalEndOfInputStream();
}
}
@Nullable
public Format getOutputFormat() throws TransformationException {
if (encoder == null) {
return null;
}
@Nullable Format outputFormat = encoder.getOutputFormat();
if (outputFormat != null && outputRotationDegrees != 0) {
outputFormat = outputFormat.buildUpon().setRotationDegrees(outputRotationDegrees).build();
}
return outputFormat;
}
@Nullable
public ByteBuffer getOutputBuffer() throws TransformationException {
return encoder != null ? encoder.getOutputBuffer() : null;
}
@Nullable
public MediaCodec.BufferInfo getOutputBufferInfo() throws TransformationException {
return encoder != null ? encoder.getOutputBufferInfo() : null;
}
public void releaseOutputBuffer(boolean render) throws TransformationException {
if (encoder != null) {
encoder.releaseOutputBuffer(render);
}
}
public boolean isEnded() {
return encoder != null && encoder.isEnded();
}
public void release() {
if (encoder != null) {
encoder.release();
}
releaseEncoder = true;
}
}
} }

View File

@ -1,80 +0,0 @@
/*
* Copyright 2022 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 com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import android.util.Size;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit tests for {@link EncoderCompatibilityTransformation}. */
@RunWith(AndroidJUnit4.class)
public final class EncoderCompatibilityTransformationTest {
@Test
public void configure_noEditsLandscape_leavesOrientationUnchanged() {
int inputWidth = 200;
int inputHeight = 150;
EncoderCompatibilityTransformation encoderCompatibilityTransformation =
new EncoderCompatibilityTransformation();
Size outputSize = encoderCompatibilityTransformation.configure(inputWidth, inputHeight);
assertThat(encoderCompatibilityTransformation.getOutputRotationDegrees()).isEqualTo(0);
assertThat(outputSize.getWidth()).isEqualTo(inputWidth);
assertThat(outputSize.getHeight()).isEqualTo(inputHeight);
}
@Test
public void configure_noEditsSquare_leavesOrientationUnchanged() {
int inputWidth = 150;
int inputHeight = 150;
EncoderCompatibilityTransformation encoderCompatibilityTransformation =
new EncoderCompatibilityTransformation();
Size outputSize = encoderCompatibilityTransformation.configure(inputWidth, inputHeight);
assertThat(encoderCompatibilityTransformation.getOutputRotationDegrees()).isEqualTo(0);
assertThat(outputSize.getWidth()).isEqualTo(inputWidth);
assertThat(outputSize.getHeight()).isEqualTo(inputHeight);
}
@Test
public void configure_noEditsPortrait_flipsOrientation() {
int inputWidth = 150;
int inputHeight = 200;
EncoderCompatibilityTransformation encoderCompatibilityTransformation =
new EncoderCompatibilityTransformation();
Size outputSize = encoderCompatibilityTransformation.configure(inputWidth, inputHeight);
assertThat(encoderCompatibilityTransformation.getOutputRotationDegrees()).isEqualTo(90);
assertThat(outputSize.getWidth()).isEqualTo(inputHeight);
assertThat(outputSize.getHeight()).isEqualTo(inputWidth);
}
@Test
public void getOutputRotationDegreesBeforeConfigure_throwsIllegalStateException() {
EncoderCompatibilityTransformation encoderCompatibilityTransformation =
new EncoderCompatibilityTransformation();
// configure not called before getOutputRotationDegrees.
assertThrows(
IllegalStateException.class, encoderCompatibilityTransformation::getOutputRotationDegrees);
}
}

View File

@ -0,0 +1,146 @@
/*
* Copyright 2022 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 com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import android.net.Uri;
import android.os.Looper;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem;
import androidx.media3.common.util.Clock;
import androidx.media3.common.util.ListenerSet;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit tests for {@link VideoTranscodingSamplePipeline.EncoderWrapper}. */
@RunWith(AndroidJUnit4.class)
public final class VideoEncoderWrapperTest {
private final TransformationRequest emptyTransformationRequest =
new TransformationRequest.Builder().build();
private final FakeVideoEncoderFactory fakeEncoderFactory = new FakeVideoEncoderFactory();
private final FallbackListener fallbackListener =
new FallbackListener(
MediaItem.fromUri(Uri.EMPTY),
new ListenerSet<>(Looper.myLooper(), Clock.DEFAULT, (listener, flags) -> {}),
emptyTransformationRequest);
private final VideoTranscodingSamplePipeline.EncoderWrapper encoderWrapper =
new VideoTranscodingSamplePipeline.EncoderWrapper(
fakeEncoderFactory,
/* inputFormat= */ new Format.Builder().build(),
/* allowedOutputMimeTypes= */ ImmutableList.of(),
emptyTransformationRequest,
fallbackListener,
new AtomicReference<>());
@Before
public void registerTrack() {
fallbackListener.registerTrack();
}
@Test
public void getSurfaceInfo_landscape_leavesOrientationUnchanged() {
int inputWidth = 200;
int inputHeight = 150;
SurfaceInfo surfaceInfo = encoderWrapper.getSurfaceInfo(inputWidth, inputHeight);
assertThat(surfaceInfo.orientationDegrees).isEqualTo(0);
assertThat(surfaceInfo.width).isEqualTo(inputWidth);
assertThat(surfaceInfo.height).isEqualTo(inputHeight);
}
@Test
public void getSurfaceInfo_square_leavesOrientationUnchanged() {
int inputWidth = 150;
int inputHeight = 150;
SurfaceInfo surfaceInfo = encoderWrapper.getSurfaceInfo(inputWidth, inputHeight);
assertThat(surfaceInfo.orientationDegrees).isEqualTo(0);
assertThat(surfaceInfo.width).isEqualTo(inputWidth);
assertThat(surfaceInfo.height).isEqualTo(inputHeight);
}
@Test
public void getSurfaceInfo_portrait_flipsOrientation() {
int inputWidth = 150;
int inputHeight = 200;
SurfaceInfo surfaceInfo = encoderWrapper.getSurfaceInfo(inputWidth, inputHeight);
assertThat(surfaceInfo.orientationDegrees).isEqualTo(90);
assertThat(surfaceInfo.width).isEqualTo(inputHeight);
assertThat(surfaceInfo.height).isEqualTo(inputWidth);
}
@Test
public void getSurfaceInfo_withEncoderFallback_usesFallbackResolution() {
int inputWidth = 200;
int inputHeight = 150;
int fallbackWidth = 100;
int fallbackHeight = 75;
fakeEncoderFactory.setFallbackResolution(fallbackWidth, fallbackHeight);
SurfaceInfo surfaceInfo = encoderWrapper.getSurfaceInfo(inputWidth, inputHeight);
assertThat(surfaceInfo.orientationDegrees).isEqualTo(0);
assertThat(surfaceInfo.width).isEqualTo(fallbackWidth);
assertThat(surfaceInfo.height).isEqualTo(fallbackHeight);
}
private static class FakeVideoEncoderFactory implements Codec.EncoderFactory {
private int fallbackWidth;
private int fallbackHeight;
public FakeVideoEncoderFactory() {
fallbackWidth = C.LENGTH_UNSET;
fallbackHeight = C.LENGTH_UNSET;
}
public void setFallbackResolution(int fallbackWidth, int fallbackHeight) {
this.fallbackWidth = fallbackWidth;
this.fallbackHeight = fallbackHeight;
}
@Override
public Codec createForAudioEncoding(Format format, List<String> allowedMimeTypes) {
throw new UnsupportedOperationException();
}
@Override
public Codec createForVideoEncoding(Format format, List<String> allowedMimeTypes) {
Codec mockEncoder = mock(Codec.class);
if (fallbackWidth != C.LENGTH_UNSET) {
format = format.buildUpon().setWidth(fallbackWidth).build();
}
if (fallbackHeight != C.LENGTH_UNSET) {
format = format.buildUpon().setHeight(fallbackHeight).build();
}
when(mockEncoder.getConfigurationFormat()).thenReturn(format);
return mockEncoder;
}
}
}