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);
}
/**