diff --git a/libraries/datasource/src/androidTest/java/androidx/media3/datasource/DataSourceBitmapLoaderTest.java b/libraries/datasource/src/androidTest/java/androidx/media3/datasource/DataSourceBitmapLoaderTest.java index 521500f5e5..08e0232e56 100644 --- a/libraries/datasource/src/androidTest/java/androidx/media3/datasource/DataSourceBitmapLoaderTest.java +++ b/libraries/datasource/src/androidTest/java/androidx/media3/datasource/DataSourceBitmapLoaderTest.java @@ -31,6 +31,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import java.io.File; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.util.concurrent.ExecutionException; import okhttp3.mockwebserver.MockResponse; @@ -215,6 +216,28 @@ public class DataSourceBitmapLoaderTest { assertThat(bitmap.isMutable()).isTrue(); } + @Test + public void loadBitmap_withFileUriAndMaxOutputDimension_loadsDataWithSmallerSize() + throws Exception { + byte[] imageData = + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TEST_IMAGE_PATH); + File file = tempFolder.newFile(); + Files.write(Path.of(file.getAbsolutePath()), imageData); + Uri uri = Uri.fromFile(file); + int maximumOutputDimension = 2000; + DataSourceBitmapLoader bitmapLoader = + new DataSourceBitmapLoader( + MoreExecutors.newDirectExecutorService(), + dataSourceFactory, + /* options= */ null, + maximumOutputDimension); + + Bitmap bitmap = bitmapLoader.loadBitmap(uri).get(); + + assertThat(bitmap.getWidth()).isAtMost(maximumOutputDimension); + assertThat(bitmap.getHeight()).isAtMost(maximumOutputDimension); + } + @Test public void loadBitmap_fileUriWithFileNotExisting_throws() { DataSourceBitmapLoader bitmapLoader = diff --git a/libraries/datasource/src/main/java/androidx/media3/datasource/BitmapUtil.java b/libraries/datasource/src/main/java/androidx/media3/datasource/BitmapUtil.java index 6ca102094b..68f1f82efe 100644 --- a/libraries/datasource/src/main/java/androidx/media3/datasource/BitmapUtil.java +++ b/libraries/datasource/src/main/java/androidx/media3/datasource/BitmapUtil.java @@ -15,11 +15,14 @@ */ package androidx.media3.datasource; +import static java.lang.Math.max; + import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Matrix; import androidx.annotation.Nullable; import androidx.exifinterface.media.ExifInterface; +import androidx.media3.common.C; import androidx.media3.common.ParserException; import androidx.media3.common.util.UnstableApi; import java.io.ByteArrayInputStream; @@ -38,14 +41,37 @@ public final class BitmapUtil { * * @param data Byte array of compressed image data. * @param length The number of bytes to parse. - * @param options the {@link BitmapFactory.Options} to decode the {@code data} with. + * @param options The {@link BitmapFactory.Options} to decode the {@code data} with. + * @param maximumOutputDimension The largest output Bitmap dimension that can be returned by this + * method, or {@link C#LENGTH_UNSET} if no limits are enforced. * @throws ParserException if the {@code data} could not be decoded. */ // BitmapFactory's options parameter is null-ok. @SuppressWarnings("nullness:argument.type.incompatible") - public static Bitmap decode(byte[] data, int length, @Nullable BitmapFactory.Options options) + public static Bitmap decode( + byte[] data, int length, @Nullable BitmapFactory.Options options, int maximumOutputDimension) throws IOException { + if (maximumOutputDimension != C.LENGTH_UNSET) { + if (options == null) { + options = new BitmapFactory.Options(); + } + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(data, /* offset= */ 0, length, options); + int largerDimensions = max(options.outWidth, options.outHeight); + + options.inJustDecodeBounds = false; + options.inSampleSize = 1; + // Only scaling by 2x is supported. + while (largerDimensions > maximumOutputDimension) { + options.inSampleSize *= 2; + largerDimensions /= 2; + } + } + @Nullable Bitmap bitmap = BitmapFactory.decodeByteArray(data, /* offset= */ 0, length, options); + if (options != null) { + options.inSampleSize = 1; + } if (bitmap == null) { throw ParserException.createForMalformedContainer( "Could not decode image data", new IllegalStateException()); diff --git a/libraries/datasource/src/main/java/androidx/media3/datasource/DataSourceBitmapLoader.java b/libraries/datasource/src/main/java/androidx/media3/datasource/DataSourceBitmapLoader.java index 95efd0157b..3750510cee 100644 --- a/libraries/datasource/src/main/java/androidx/media3/datasource/DataSourceBitmapLoader.java +++ b/libraries/datasource/src/main/java/androidx/media3/datasource/DataSourceBitmapLoader.java @@ -23,6 +23,7 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; import androidx.annotation.Nullable; +import androidx.media3.common.C; import androidx.media3.common.util.BitmapLoader; import androidx.media3.common.util.UnstableApi; import com.google.common.base.Supplier; @@ -51,6 +52,7 @@ public final class DataSourceBitmapLoader implements BitmapLoader { private final ListeningExecutorService listeningExecutorService; private final DataSource.Factory dataSourceFactory; @Nullable private final BitmapFactory.Options options; + private final int maximumOutputDimension; /** * Creates an instance that uses a {@link DefaultHttpDataSource} for image loading and delegates @@ -84,9 +86,29 @@ public final class DataSourceBitmapLoader implements BitmapLoader { ListeningExecutorService listeningExecutorService, DataSource.Factory dataSourceFactory, @Nullable BitmapFactory.Options options) { + this(listeningExecutorService, dataSourceFactory, options, C.LENGTH_UNSET); + } + + /** + * Creates an instance that delegates loading tasks to the {@link ListeningExecutorService}. + * + *

Use {@code maximumOutputDimension} to limit memory usage when loading large Bitmaps. + * + * @param listeningExecutorService The {@link ListeningExecutorService}. + * @param dataSourceFactory The {@link DataSource.Factory} that creates the {@link DataSource} + * used to load the image. + * @param options The {@link BitmapFactory.Options} the image should be loaded with. + * @param maximumOutputDimension The maximum dimension of the output Bitmap. + */ + public DataSourceBitmapLoader( + ListeningExecutorService listeningExecutorService, + DataSource.Factory dataSourceFactory, + @Nullable BitmapFactory.Options options, + int maximumOutputDimension) { this.listeningExecutorService = listeningExecutorService; this.dataSourceFactory = dataSourceFactory; this.options = options; + this.maximumOutputDimension = maximumOutputDimension; } @Override @@ -96,22 +118,27 @@ public final class DataSourceBitmapLoader implements BitmapLoader { @Override public ListenableFuture decodeBitmap(byte[] data) { - return listeningExecutorService.submit(() -> BitmapUtil.decode(data, data.length, options)); + return listeningExecutorService.submit( + () -> BitmapUtil.decode(data, data.length, options, maximumOutputDimension)); } @Override public ListenableFuture loadBitmap(Uri uri) { return listeningExecutorService.submit( - () -> load(dataSourceFactory.createDataSource(), uri, options)); + () -> load(dataSourceFactory.createDataSource(), uri, options, maximumOutputDimension)); } private static Bitmap load( - DataSource dataSource, Uri uri, @Nullable BitmapFactory.Options options) throws IOException { + DataSource dataSource, + Uri uri, + @Nullable BitmapFactory.Options options, + int maximumOutputDimension) + throws IOException { try { DataSpec dataSpec = new DataSpec(uri); dataSource.open(dataSpec); byte[] readData = DataSourceUtil.readToEnd(dataSource); - return BitmapUtil.decode(readData, readData.length, options); + return BitmapUtil.decode(readData, readData.length, options, maximumOutputDimension); } finally { dataSource.close(); } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/BitmapFactoryImageDecoder.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/BitmapFactoryImageDecoder.java index 50e7eaa581..9cb02e77f4 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/BitmapFactoryImageDecoder.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/image/BitmapFactoryImageDecoder.java @@ -159,7 +159,8 @@ public final class BitmapFactoryImageDecoder */ private static Bitmap decode(byte[] data, int length) throws ImageDecoderException { try { - return BitmapUtil.decode(data, length, /* options= */ null); + return BitmapUtil.decode( + data, length, /* options= */ null, /* maximumOutputDimension= */ C.LENGTH_UNSET); } catch (ParserException e) { throw new ImageDecoderException( "Could not decode image data with BitmapFactory. (data.length = " diff --git a/libraries/test_data/src/test/assets/media/webp/black_large.webp b/libraries/test_data/src/test/assets/media/webp/black_large.webp new file mode 100644 index 0000000000..910bb2aa1a Binary files /dev/null and b/libraries/test_data/src/test/assets/media/webp/black_large.webp differ diff --git a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java index 795632a139..70bbf07abf 100644 --- a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java +++ b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java @@ -17,6 +17,7 @@ package androidx.media3.transformer; import static androidx.media3.common.MimeTypes.IMAGE_JPEG; import static androidx.media3.common.MimeTypes.IMAGE_PNG; +import static androidx.media3.common.MimeTypes.IMAGE_WEBP; import static androidx.media3.common.MimeTypes.VIDEO_AV1; import static androidx.media3.common.MimeTypes.VIDEO_DOLBY_VISION; import static androidx.media3.common.MimeTypes.VIDEO_H264; @@ -222,6 +223,16 @@ public final class AndroidTestUtil { .build()) .build(); + public static final AssetInfo WEBP_LARGE = + new AssetInfo.Builder("asset:///media/webp/black_large.webp") + .setVideoFormat( + new Format.Builder() + .setSampleMimeType(IMAGE_WEBP) + .setWidth(16000) + .setHeight(9000) + .build()) + .build(); + public static final AssetInfo MP4_TRIM_OPTIMIZATION = new AssetInfo.Builder("asset:///media/mp4/internal_emulator_transformer_output.mp4") .setVideoFormat( 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 195f830817..79d8be72e3 100644 --- a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java +++ b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java @@ -29,6 +29,7 @@ import static androidx.media3.transformer.AndroidTestUtil.MP4_TRIM_OPTIMIZATION; import static androidx.media3.transformer.AndroidTestUtil.MP4_TRIM_OPTIMIZATION_180; import static androidx.media3.transformer.AndroidTestUtil.MP4_TRIM_OPTIMIZATION_270; import static androidx.media3.transformer.AndroidTestUtil.PNG_ASSET; +import static androidx.media3.transformer.AndroidTestUtil.WEBP_LARGE; import static androidx.media3.transformer.AndroidTestUtil.assumeFormatsSupported; import static androidx.media3.transformer.AndroidTestUtil.createFrameCountingEffect; import static androidx.media3.transformer.AndroidTestUtil.createOpenGlObjects; @@ -288,6 +289,31 @@ public class TransformerEndToEndTest { assertThat(new File(result.filePath).length()).isGreaterThan(0); } + @Test + public void videoEditing_withLargeImageInput_completesWithCorrectFrameCountAndDuration() + throws Exception { + Transformer transformer = new Transformer.Builder(context).build(); + ImmutableList videoEffects = ImmutableList.of(Presentation.createForHeight(480)); + Effects effects = new Effects(/* audioProcessors= */ ImmutableList.of(), videoEffects); + int expectedFrameCount = 40; + EditedMediaItem editedMediaItem = + new EditedMediaItem.Builder(MediaItem.fromUri(WEBP_LARGE.uri)) + .setDurationUs(C.MICROS_PER_SECOND) + .setFrameRate(expectedFrameCount) + .setEffects(effects) + .build(); + ExportTestResult result = + new TransformerAndroidTestRunner.Builder(context, transformer) + .build() + .run(testId, editedMediaItem); + + assertThat(result.exportResult.videoFrameCount).isEqualTo(expectedFrameCount); + // Expected timestamp of the last frame. + assertThat(result.exportResult.durationMs) + .isEqualTo((C.MILLIS_PER_SECOND / expectedFrameCount) * (expectedFrameCount - 1)); + assertThat(new File(result.filePath).length()).isGreaterThan(0); + } + @Test public void videoEditing_withTextureInput_completesWithCorrectFrameCountAndDuration() throws Exception { diff --git a/libraries/transformer/src/main/java/androidx/media3/transformer/DefaultAssetLoaderFactory.java b/libraries/transformer/src/main/java/androidx/media3/transformer/DefaultAssetLoaderFactory.java index d8dc343271..db5b470ea7 100644 --- a/libraries/transformer/src/main/java/androidx/media3/transformer/DefaultAssetLoaderFactory.java +++ b/libraries/transformer/src/main/java/androidx/media3/transformer/DefaultAssetLoaderFactory.java @@ -39,6 +39,12 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @UnstableApi public final class DefaultAssetLoaderFactory implements AssetLoader.Factory { + // Limit decoded images to 4096x4096 - should be large enough for most image to video + // transcode operations, and smaller than GL_MAX_TEXTURE_SIZE for most devices. + // TODO: b/356072337 - consider reading this from GL_MAX_TEXTURE_SIZE. This requires an + // active OpenGL context. + private static final int MAXIMUM_BITMAP_OUTPUT_DIMENSION = 4096; + private final Context context; private final Codec.DecoderFactory decoderFactory; private final Clock clock; @@ -76,7 +82,8 @@ public final class DefaultAssetLoaderFactory implements AssetLoader.Factory { new DataSourceBitmapLoader( MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()), new DefaultDataSource.Factory(context), - options); + options, + MAXIMUM_BITMAP_OUTPUT_DIMENSION); } /**