Downscale bitmaps during decoding in Transformer

Limit input image size in Transformer to be less than 4096x4096.
For very large images, this can reduce memory usage substantially,
and stays away from `GL_MAX_TEXTURE_SIZE` - often 4096

PiperOrigin-RevId: 670555939
This commit is contained in:
dancho 2024-09-03 07:50:37 -07:00 committed by Copybara-Service
parent 9562c976a9
commit 1c61fbadf7
8 changed files with 129 additions and 8 deletions

View File

@ -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 =

View File

@ -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());

View File

@ -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}.
*
* <p>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<Bitmap> 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<Bitmap> 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();
}

View File

@ -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 = "

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

View File

@ -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(

View File

@ -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<Effect> 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 {

View File

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