From 3793a06bdd26283277458e9fd560ba3228fd16c8 Mon Sep 17 00:00:00 2001 From: sheenachhabra Date: Tue, 2 Jul 2024 09:39:30 -0700 Subject: [PATCH] Add support for file format for depth/editing in Mp4Muxer PiperOrigin-RevId: 648747038 --- api.txt | 3 +- .../main/java/androidx/media3/common/C.java | 54 +++- .../java/androidx/media3/common/Format.java | 42 +++ .../androidx/media3/common/MimeTypes.java | 4 + .../androidx/media3/common/util/Util.java | 3 + .../media3/container/MdtaMetadataEntry.java | 39 +++ .../media3/muxer/MetadataCollector.java | 5 + .../java/androidx/media3/muxer/Mp4Muxer.java | 303 ++++++++++++++++-- .../java/androidx/media3/muxer/Mp4Writer.java | 126 ++++---- .../media3/muxer/Mp4MuxerEndToEndTest.java | 243 ++++++++++++++ .../mp4_with_editable_video_tracks.mp4.dump | 87 +++++ ...ith_editable_video_tracks_in_edvd.box.dump | 111 +++++++ ...ditable_video_tracks_without_edvd.box.dump | 87 +++++ .../mp4_with_primary_tracks.mp4.dump | 48 +++ .../media3/test/utils/DumpableMp4Box.java | 2 +- .../androidx/media3/test/utils/TestUtil.java | 13 +- 16 files changed, 1083 insertions(+), 87 deletions(-) create mode 100644 libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks.mp4.dump create mode 100644 libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks_in_edvd.box.dump create mode 100644 libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks_without_edvd.box.dump create mode 100644 libraries/test_data/src/test/assets/muxerdumps/mp4_with_primary_tracks.mp4.dump diff --git a/api.txt b/api.txt index e138ae401a..e13090db32 100644 --- a/api.txt +++ b/api.txt @@ -79,6 +79,7 @@ package androidx.media3.common { field public static final java.util.UUID PLAYREADY_UUID; field public static final float RATE_UNSET = -3.4028235E38f; field public static final int ROLE_FLAG_ALTERNATE = 2; // 0x2 + field public static final int ROLE_FLAG_AUXILIARY = 32768; // 0x8000 field public static final int ROLE_FLAG_CAPTION = 64; // 0x40 field public static final int ROLE_FLAG_COMMENTARY = 8; // 0x8 field public static final int ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND = 1024; // 0x400 @@ -156,7 +157,7 @@ package androidx.media3.common { @IntDef(open=true, value={androidx.media3.common.C.CRYPTO_TYPE_UNSUPPORTED, androidx.media3.common.C.CRYPTO_TYPE_NONE, androidx.media3.common.C.CRYPTO_TYPE_FRAMEWORK}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target(java.lang.annotation.ElementType.TYPE_USE) public static @interface C.CryptoType { } - @IntDef(flag=true, value={androidx.media3.common.C.ROLE_FLAG_MAIN, androidx.media3.common.C.ROLE_FLAG_ALTERNATE, androidx.media3.common.C.ROLE_FLAG_SUPPLEMENTARY, androidx.media3.common.C.ROLE_FLAG_COMMENTARY, androidx.media3.common.C.ROLE_FLAG_DUB, androidx.media3.common.C.ROLE_FLAG_EMERGENCY, androidx.media3.common.C.ROLE_FLAG_CAPTION, androidx.media3.common.C.ROLE_FLAG_SUBTITLE, androidx.media3.common.C.ROLE_FLAG_SIGN, androidx.media3.common.C.ROLE_FLAG_DESCRIBES_VIDEO, androidx.media3.common.C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND, androidx.media3.common.C.ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY, androidx.media3.common.C.ROLE_FLAG_TRANSCRIBES_DIALOG, androidx.media3.common.C.ROLE_FLAG_EASY_TO_READ, androidx.media3.common.C.ROLE_FLAG_TRICK_PLAY}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface C.RoleFlags { + @IntDef(flag=true, value={androidx.media3.common.C.ROLE_FLAG_MAIN, androidx.media3.common.C.ROLE_FLAG_ALTERNATE, androidx.media3.common.C.ROLE_FLAG_SUPPLEMENTARY, androidx.media3.common.C.ROLE_FLAG_COMMENTARY, androidx.media3.common.C.ROLE_FLAG_DUB, androidx.media3.common.C.ROLE_FLAG_EMERGENCY, androidx.media3.common.C.ROLE_FLAG_CAPTION, androidx.media3.common.C.ROLE_FLAG_SUBTITLE, androidx.media3.common.C.ROLE_FLAG_SIGN, androidx.media3.common.C.ROLE_FLAG_DESCRIBES_VIDEO, androidx.media3.common.C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND, androidx.media3.common.C.ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY, androidx.media3.common.C.ROLE_FLAG_TRANSCRIBES_DIALOG, androidx.media3.common.C.ROLE_FLAG_EASY_TO_READ, androidx.media3.common.C.ROLE_FLAG_TRICK_PLAY, androidx.media3.common.C.ROLE_FLAG_AUXILIARY}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface C.RoleFlags { } @IntDef(flag=true, value={androidx.media3.common.C.SELECTION_FLAG_DEFAULT, androidx.media3.common.C.SELECTION_FLAG_FORCED, androidx.media3.common.C.SELECTION_FLAG_AUTOSELECT}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface C.SelectionFlags { diff --git a/libraries/common/src/main/java/androidx/media3/common/C.java b/libraries/common/src/main/java/androidx/media3/common/C.java index 1367f1d024..286ddf6942 100644 --- a/libraries/common/src/main/java/androidx/media3/common/C.java +++ b/libraries/common/src/main/java/androidx/media3/common/C.java @@ -1428,7 +1428,8 @@ public final class C { ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY, ROLE_FLAG_TRANSCRIBES_DIALOG, ROLE_FLAG_EASY_TO_READ, - ROLE_FLAG_TRICK_PLAY + ROLE_FLAG_TRICK_PLAY, + ROLE_FLAG_AUXILIARY }) public @interface RoleFlags {} @@ -1493,6 +1494,57 @@ public final class C { /** Indicates the track is intended for trick play. */ public static final int ROLE_FLAG_TRICK_PLAY = 1 << 14; + /** + * Indicates an auxiliary track. An auxiliary track provides additional information about other + * tracks and is generally not meant for stand-alone playback, but rather for further processing + * in conjunction with other tracks (for example, a track with depth information). + */ + public static final int ROLE_FLAG_AUXILIARY = 1 << 15; + + /** + * {@linkplain #ROLE_FLAG_AUXILIARY Auxiliary track types}. One of {@link + * #AUXILIARY_TRACK_TYPE_UNDEFINED}, {@link #AUXILIARY_TRACK_TYPE_ORIGINAL}, {@link + * #AUXILIARY_TRACK_TYPE_DEPTH_LINEAR}, {@link #AUXILIARY_TRACK_TYPE_DEPTH_INVERSE}, {@link + * #AUXILIARY_TRACK_TYPE_DEPTH_METADATA}. + */ + @UnstableApi + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target({FIELD, METHOD, PARAMETER, LOCAL_VARIABLE, TYPE_USE}) + @IntDef({ + AUXILIARY_TRACK_TYPE_UNDEFINED, + AUXILIARY_TRACK_TYPE_ORIGINAL, + AUXILIARY_TRACK_TYPE_DEPTH_LINEAR, + AUXILIARY_TRACK_TYPE_DEPTH_INVERSE, + AUXILIARY_TRACK_TYPE_DEPTH_METADATA + }) + public @interface AuxiliaryTrackType {} + + /** Not an auxiliary track or an auxiliary track with an undefined type. */ + @UnstableApi public static final int AUXILIARY_TRACK_TYPE_UNDEFINED = 0; + + /** The original video track without any depth based effects applied. */ + @UnstableApi public static final int AUXILIARY_TRACK_TYPE_ORIGINAL = 1; + + /** + * A linear encoded depth video track. + * + *

See https://developer.android.com/static/media/camera/camera2/Dynamic-depth-v1.0.pdf for + * linear depth encoding. + */ + @UnstableApi public static final int AUXILIARY_TRACK_TYPE_DEPTH_LINEAR = 2; + + /** + * An inverse encoded depth video track. + * + *

See https://developer.android.com/static/media/camera/camera2/Dynamic-depth-v1.0.pdf for + * inverse depth encoding. + */ + @UnstableApi public static final int AUXILIARY_TRACK_TYPE_DEPTH_INVERSE = 3; + + /** A timed metadata of depth video track. */ + @UnstableApi public static final int AUXILIARY_TRACK_TYPE_DEPTH_METADATA = 4; + /** * Level of support for a format. One of {@link #FORMAT_HANDLED}, {@link * #FORMAT_EXCEEDS_CAPABILITIES}, {@link #FORMAT_UNSUPPORTED_DRM}, {@link diff --git a/libraries/common/src/main/java/androidx/media3/common/Format.java b/libraries/common/src/main/java/androidx/media3/common/Format.java index 7c2b4193f4..b48dd78895 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Format.java +++ b/libraries/common/src/main/java/androidx/media3/common/Format.java @@ -146,6 +146,7 @@ public final class Format { @Nullable private String language; private @C.SelectionFlags int selectionFlags; private @C.RoleFlags int roleFlags; + private @C.AuxiliaryTrackType int auxiliaryTrackType; private int averageBitrate; private int peakBitrate; @Nullable private String codecs; @@ -225,6 +226,7 @@ public final class Format { tileCountVertical = NO_VALUE; // Provided by the source. cryptoType = C.CRYPTO_TYPE_NONE; + auxiliaryTrackType = C.AUXILIARY_TRACK_TYPE_UNDEFINED; } /** @@ -360,6 +362,9 @@ public final class Format { /** * Sets {@link Format#roleFlags}. The default value is 0. * + *

When {@code roleFlags} includes {@link C#ROLE_FLAG_AUXILIARY}, then the specific {@link + * C.AuxiliaryTrackType} can also be {@linkplain #setAuxiliaryTrackType(int) set}. + * * @param roleFlags The {@link Format#roleFlags}. * @return The builder. */ @@ -369,6 +374,22 @@ public final class Format { return this; } + /** + * Sets {@link Format#auxiliaryTrackType}. The default value is {@link + * C#AUXILIARY_TRACK_TYPE_UNDEFINED}. + * + *

This must be set to a value other than {@link C#AUXILIARY_TRACK_TYPE_UNDEFINED} only when + * {@linkplain #setRoleFlags(int) role flags} contains {@link C#ROLE_FLAG_AUXILIARY}. + * + * @param auxiliaryTrackType The {@link Format#auxiliaryTrackType}. + * @return The builder. + */ + @CanIgnoreReturnValue + public Builder setAuxiliaryTrackType(@C.AuxiliaryTrackType int auxiliaryTrackType) { + this.auxiliaryTrackType = auxiliaryTrackType; + return this; + } + /** * Sets {@link Format#averageBitrate}. The default value is {@link #NO_VALUE}. * @@ -824,6 +845,9 @@ public final class Format { /** Track role flags. */ public final @C.RoleFlags int roleFlags; + /** The auxiliary track type. */ + @UnstableApi public final @C.AuxiliaryTrackType int auxiliaryTrackType; + /** * The average bitrate in bits per second, or {@link #NO_VALUE} if unknown or not applicable. The * way in which this field is populated depends on the type of media to which the format @@ -1043,7 +1067,14 @@ public final class Format { label = builder.label; } selectionFlags = builder.selectionFlags; + + checkState( + builder.auxiliaryTrackType == C.AUXILIARY_TRACK_TYPE_UNDEFINED + || (builder.roleFlags & C.ROLE_FLAG_AUXILIARY) != 0, + "Auxiliary track type must only be set to a value other than AUXILIARY_TRACK_TYPE_UNDEFINED" + + " only when ROLE_FLAG_AUXILIARY is set"); roleFlags = builder.roleFlags; + auxiliaryTrackType = builder.auxiliaryTrackType; averageBitrate = builder.averageBitrate; peakBitrate = builder.peakBitrate; bitrate = peakBitrate != NO_VALUE ? peakBitrate : averageBitrate; @@ -1229,6 +1260,7 @@ public final class Format { result = 31 * result + (language == null ? 0 : language.hashCode()); result = 31 * result + selectionFlags; result = 31 * result + roleFlags; + result = 31 * result + auxiliaryTrackType; result = 31 * result + averageBitrate; result = 31 * result + peakBitrate; result = 31 * result + (codecs == null ? 0 : codecs.hashCode()); @@ -1284,6 +1316,7 @@ public final class Format { // Field equality checks ordered by type, with the cheapest checks first. return selectionFlags == other.selectionFlags && roleFlags == other.roleFlags + && auxiliaryTrackType == other.auxiliaryTrackType && averageBitrate == other.averageBitrate && peakBitrate == other.peakBitrate && maxInputSize == other.maxInputSize @@ -1416,6 +1449,9 @@ public final class Format { if (format.customData != null) { builder.append(", customData=").append(format.customData); } + if ((format.roleFlags & C.ROLE_FLAG_AUXILIARY) != 0) { + builder.append(", auxiliaryTrackType=").append(format.auxiliaryTrackType); + } return builder.toString(); } @@ -1452,6 +1488,7 @@ public final class Format { private static final String FIELD_TILE_COUNT_HORIZONTAL = Util.intToStringMaxRadix(30); private static final String FIELD_TILE_COUNT_VERTICAL = Util.intToStringMaxRadix(31); private static final String FIELD_LABELS = Util.intToStringMaxRadix(32); + private static final String FIELD_AUXILIARY_TRACK_TYPE = Util.intToStringMaxRadix(33); /** * @deprecated Use {@link #toBundle(boolean)} instead. @@ -1476,6 +1513,9 @@ public final class Format { bundle.putString(FIELD_LANGUAGE, language); bundle.putInt(FIELD_SELECTION_FLAGS, selectionFlags); bundle.putInt(FIELD_ROLE_FLAGS, roleFlags); + if (auxiliaryTrackType != DEFAULT.auxiliaryTrackType) { + bundle.putInt(FIELD_AUXILIARY_TRACK_TYPE, auxiliaryTrackType); + } bundle.putInt(FIELD_AVERAGE_BITRATE, averageBitrate); bundle.putInt(FIELD_PEAK_BITRATE, peakBitrate); bundle.putString(FIELD_CODECS, codecs); @@ -1540,6 +1580,8 @@ public final class Format { .setLanguage(defaultIfNull(bundle.getString(FIELD_LANGUAGE), DEFAULT.language)) .setSelectionFlags(bundle.getInt(FIELD_SELECTION_FLAGS, DEFAULT.selectionFlags)) .setRoleFlags(bundle.getInt(FIELD_ROLE_FLAGS, DEFAULT.roleFlags)) + .setAuxiliaryTrackType( + bundle.getInt(FIELD_AUXILIARY_TRACK_TYPE, DEFAULT.auxiliaryTrackType)) .setAverageBitrate(bundle.getInt(FIELD_AVERAGE_BITRATE, DEFAULT.averageBitrate)) .setPeakBitrate(bundle.getInt(FIELD_PEAK_BITRATE, DEFAULT.peakBitrate)) .setCodecs(defaultIfNull(bundle.getString(FIELD_CODECS), DEFAULT.codecs)) diff --git a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java index 91bebc4960..f50235714c 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java +++ b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java @@ -144,6 +144,10 @@ public final class MimeTypes { @UnstableApi public static final String APPLICATION_CAMERA_MOTION = BASE_TYPE_APPLICATION + "/x-camera-motion"; + @UnstableApi + public static final String APPLICATION_DEPTH_METADATA = + BASE_TYPE_APPLICATION + "/x-depth-metadata"; + @UnstableApi public static final String APPLICATION_EMSG = BASE_TYPE_APPLICATION + "/x-emsg"; public static final String APPLICATION_DVBSUBS = BASE_TYPE_APPLICATION + "/dvbsubs"; @UnstableApi public static final String APPLICATION_EXIF = BASE_TYPE_APPLICATION + "/x-exif"; diff --git a/libraries/common/src/main/java/androidx/media3/common/util/Util.java b/libraries/common/src/main/java/androidx/media3/common/util/Util.java index 04cf81c16e..7582a7c072 100644 --- a/libraries/common/src/main/java/androidx/media3/common/util/Util.java +++ b/libraries/common/src/main/java/androidx/media3/common/util/Util.java @@ -3294,6 +3294,9 @@ public final class Util { if ((roleFlags & C.ROLE_FLAG_TRICK_PLAY) != 0) { result.add("trick-play"); } + if ((roleFlags & C.ROLE_FLAG_AUXILIARY) != 0) { + result.add("auxiliary"); + } return result; } diff --git a/libraries/container/src/main/java/androidx/media3/container/MdtaMetadataEntry.java b/libraries/container/src/main/java/androidx/media3/container/MdtaMetadataEntry.java index 40c223624b..2db3f817c5 100644 --- a/libraries/container/src/main/java/androidx/media3/container/MdtaMetadataEntry.java +++ b/libraries/container/src/main/java/androidx/media3/container/MdtaMetadataEntry.java @@ -19,6 +19,7 @@ import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.Nullable; import androidx.media3.common.Metadata; +import androidx.media3.common.util.ParsableByteArray; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import com.google.common.primitives.Ints; @@ -34,9 +35,21 @@ public final class MdtaMetadataEntry implements Metadata.Entry { /** Key for the capture frame rate (in frames per second). */ public static final String KEY_ANDROID_CAPTURE_FPS = "com.android.capture.fps"; + /** Key for editable tracks box (edvd) offset. */ + public static final String KEY_EDITABLE_TRACKS_OFFSET = "editable.tracks.offset"; + + /** Key for editable tracks box (edvd) length. */ + public static final String KEY_EDITABLE_TRACKS_LENGTH = "editable.tracks.length"; + + /** Key for editable tracks map. */ + public static final String KEY_EDITABLE_TRACKS_MAP = "editable.tracks.map"; + /** The default locale indicator which implies all speakers in all countries. */ public static final int DEFAULT_LOCALE_INDICATOR = 0; + /** The type indicator to use when no type needs to be indicated. */ + public static final int TYPE_INDICATOR_RESERVED = 0; + /** The type indicator for UTF-8 string. */ public static final int TYPE_INDICATOR_STRING = 1; @@ -46,6 +59,9 @@ public final class MdtaMetadataEntry implements Metadata.Entry { /** The type indicator for 32-bit signed integer. */ public static final int TYPE_INDICATOR_INT32 = 67; + /** The type indicator for 64-bit unsigned integer. */ + public static final int TYPE_INDICATOR_UNSIGNED_INT64 = 78; + /** The metadata key name. */ public final String key; @@ -119,6 +135,15 @@ public final class MdtaMetadataEntry implements Metadata.Entry { case TYPE_INDICATOR_INT32: formattedValue = String.valueOf(Ints.fromByteArray(value)); break; + case TYPE_INDICATOR_UNSIGNED_INT64: + formattedValue = String.valueOf(new ParsableByteArray(value).readUnsignedLongToLong()); + break; + case TYPE_INDICATOR_RESERVED: + if (key.equals(KEY_EDITABLE_TRACKS_MAP)) { + formattedValue = getFormattedValueForEditableTracksMap(value); + break; + } + // fall through default: formattedValue = Util.toHexString(value); } @@ -154,4 +179,18 @@ public final class MdtaMetadataEntry implements Metadata.Entry { return new MdtaMetadataEntry[size]; } }; + + private static String getFormattedValueForEditableTracksMap(byte[] value) { + // Value has 1 byte version, 1 byte track count, n bytes track types. + int numberOfTracks = value[1]; + StringBuilder sb = new StringBuilder(); + sb.append("track types = "); + for (int i = 0; i < numberOfTracks; i++) { + sb.append(value[i + 2]); + if (i < numberOfTracks - 1) { + sb.append(", "); + } + } + return sb.toString(); + } } diff --git a/libraries/muxer/src/main/java/androidx/media3/muxer/MetadataCollector.java b/libraries/muxer/src/main/java/androidx/media3/muxer/MetadataCollector.java index cb90e1394f..15b620f789 100644 --- a/libraries/muxer/src/main/java/androidx/media3/muxer/MetadataCollector.java +++ b/libraries/muxer/src/main/java/androidx/media3/muxer/MetadataCollector.java @@ -62,4 +62,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; throw new IllegalArgumentException("Unsupported metadata"); } } + + /** Removes a previously added {@link MdtaMetadataEntry}. */ + public void removeMdtaMetadataEntry(MdtaMetadataEntry mdtaMetadataEntry) { + metadataEntries.remove(mdtaMetadataEntry); + } } diff --git a/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Muxer.java b/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Muxer.java index a5ea37817b..5a619172fd 100644 --- a/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Muxer.java +++ b/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Muxer.java @@ -17,21 +17,28 @@ package androidx.media3.muxer; import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; import static java.lang.annotation.ElementType.TYPE_USE; import android.media.MediaCodec.BufferInfo; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.Metadata; import androidx.media3.common.util.Log; import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; import androidx.media3.container.MdtaMetadataEntry; import androidx.media3.container.Mp4LocationData; import androidx.media3.container.Mp4OrientationData; import androidx.media3.container.Mp4TimestampData; import androidx.media3.container.XmpData; +import com.google.common.io.ByteStreams; +import com.google.common.primitives.Longs; import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.lang.annotation.Documented; @@ -39,6 +46,10 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.util.ArrayList; +import java.util.List; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; /** * A muxer for creating an MP4 container file. @@ -69,6 +80,19 @@ import java.nio.ByteBuffer; */ @UnstableApi public final class Mp4Muxer implements Muxer { + /** Provides temporary cache files to be used by the muxer. */ + public interface CacheFileProvider { + + /** + * Returns a cache file path. + * + *

Every call to this method should return a new cache file. + * + *

The app is responsible for deleting the cache file after {@linkplain Mp4Muxer#close() + * closing} the muxer. + */ + String getCacheFilePath(); + } /** Behavior for the last sample duration. */ @Documented @@ -89,25 +113,48 @@ public final class Mp4Muxer implements Muxer { */ public static final int LAST_FRAME_DURATION_BEHAVIOR_DUPLICATE_PREV_DURATION = 1; + /** The specific MP4 file format. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef({FILE_FORMAT_DEFAULT, FILE_FORMAT_EDITABLE_VIDEO}) + public @interface FileFormat {} + + /** The default MP4 format. */ + public static final int FILE_FORMAT_DEFAULT = 0; + + // TODO: b/345219017 - Add spec details. + /** + * The editable video file format. In this file format all the tracks with {@linkplain + * Format#auxiliaryTrackType} set to {@link C#AUXILIARY_TRACK_TYPE_ORIGINAL}, {@link + * C#AUXILIARY_TRACK_TYPE_DEPTH_LINEAR}, {@link C#AUXILIARY_TRACK_TYPE_DEPTH_INVERSE}, or {@link + * C#AUXILIARY_TRACK_TYPE_DEPTH_METADATA} are written in the MP4 edit data (edvd box). The rest of + * the tracks are written as usual. + */ + public static final int FILE_FORMAT_EDITABLE_VIDEO = 1; + /** A builder for {@link Mp4Muxer} instances. */ public static final class Builder { - private final FileOutputStream fileOutputStream; + private final FileOutputStream outputStream; private @LastFrameDurationBehavior int lastFrameDurationBehavior; @Nullable private AnnexBToAvccConverter annexBToAvccConverter; private boolean sampleCopyEnabled; private boolean attemptStreamableOutputEnabled; + private @FileFormat int outputFileFormat; + @Nullable private CacheFileProvider cacheFileProvider; /** * Creates a {@link Builder} instance with default values. * - * @param fileOutputStream The {@link FileOutputStream} to write the media data to. + * @param outputStream The {@link FileOutputStream} to write the media data to. */ - public Builder(FileOutputStream fileOutputStream) { - this.fileOutputStream = checkNotNull(fileOutputStream); + public Builder(FileOutputStream outputStream) { + this.outputStream = outputStream; lastFrameDurationBehavior = LAST_FRAME_DURATION_BEHAVIOR_INSERT_SHORT_FRAME; sampleCopyEnabled = true; attemptStreamableOutputEnabled = true; + outputFileFormat = FILE_FORMAT_DEFAULT; } /** @@ -166,40 +213,99 @@ public final class Mp4Muxer implements Muxer { return this; } + /** + * Sets the specific MP4 file format. + * + *

The default value is {@link #FILE_FORMAT_DEFAULT}. + * + *

For {@link #FILE_FORMAT_EDITABLE_VIDEO}, a {@link CacheFileProvider} must also be + * {@linkplain #setCacheFileProvider(CacheFileProvider) set}. + */ + @CanIgnoreReturnValue + public Mp4Muxer.Builder setOutputFileFormat(@FileFormat int fileFormat) { + this.outputFileFormat = fileFormat; + return this; + } + + /** Sets the {@link CacheFileProvider}. */ + @CanIgnoreReturnValue + public Mp4Muxer.Builder setCacheFileProvider(CacheFileProvider cacheFileProvider) { + this.cacheFileProvider = cacheFileProvider; + return this; + } + /** Builds an {@link Mp4Muxer} instance. */ public Mp4Muxer build() { + checkArgument( + outputFileFormat != FILE_FORMAT_EDITABLE_VIDEO || cacheFileProvider != null, + "A CacheFileProvider must be set for FILE_FORMAT_EDITABLE_VIDEO"); return new Mp4Muxer( - fileOutputStream, + outputStream, lastFrameDurationBehavior, annexBToAvccConverter == null ? AnnexBToAvccConverter.DEFAULT : annexBToAvccConverter, sampleCopyEnabled, - attemptStreamableOutputEnabled); + attemptStreamableOutputEnabled, + outputFileFormat, + cacheFileProvider); } } private static final String TAG = "Mp4Muxer"; - private final FileOutputStream fileOutputStream; + // TODO: b/295339654 - Move these constants to container module to share with the extractor. + private static final int EDITABLE_TRACK_TYPE_SHARP = 0; + private static final int EDITABLE_TRACK_TYPE_DEPTH_LINEAR = 1; + private static final int EDITABLE_TRACK_TYPE_DEPTH_INVERSE = 2; + private static final int EDITABLE_TRACK_TYPE_DEPTH_METADATA = 3; + + // 4 bytes (indicating a 64-bit length field) + 4 byte (box type) + 8 bytes (actual length) + private static final int EDVD_BOX_HEADER_SIZE_BYTE = 16; + + private final FileOutputStream outputStream; + private final FileChannel outputChannel; + private final @LastFrameDurationBehavior int lastFrameDurationBehavior; + private final AnnexBToAvccConverter annexBToAvccConverter; + private final boolean sampleCopyEnabled; + private final boolean attemptStreamableOutputEnabled; + private final @FileFormat int outputFileFormat; + @Nullable private final CacheFileProvider cacheFileProvider; private final MetadataCollector metadataCollector; private final Mp4Writer mp4Writer; + private final List editableVideoTracks; + + @Nullable private String cacheFilePath; + @Nullable private FileOutputStream cacheFileOutputStream; + @Nullable private MetadataCollector editableVideoMetadataCollector; + @Nullable private Mp4Writer editableVideoMp4Writer; + @Nullable private Mp4TimestampData timestampData; private Mp4Muxer( - FileOutputStream fileOutputStream, + FileOutputStream outputStream, @LastFrameDurationBehavior int lastFrameDurationBehavior, AnnexBToAvccConverter annexBToAvccConverter, boolean sampleCopyEnabled, - boolean attemptStreamableOutputEnabled) { - this.fileOutputStream = fileOutputStream; + boolean attemptStreamableOutputEnabled, + @FileFormat int outputFileFormat, + @Nullable CacheFileProvider cacheFileProvider) { + this.outputStream = outputStream; + outputChannel = outputStream.getChannel(); + this.lastFrameDurationBehavior = lastFrameDurationBehavior; + this.annexBToAvccConverter = annexBToAvccConverter; + this.sampleCopyEnabled = sampleCopyEnabled; + this.attemptStreamableOutputEnabled = attemptStreamableOutputEnabled; + this.outputFileFormat = outputFileFormat; + this.cacheFileProvider = cacheFileProvider; metadataCollector = new MetadataCollector(); Mp4MoovStructure moovStructure = new Mp4MoovStructure(metadataCollector, lastFrameDurationBehavior); mp4Writer = new Mp4Writer( - fileOutputStream.getChannel(), + outputChannel, moovStructure, annexBToAvccConverter, sampleCopyEnabled, attemptStreamableOutputEnabled); + editableVideoTracks = new ArrayList<>(); } /** @@ -212,9 +318,10 @@ public final class Mp4Muxer implements Muxer { * * @param format The {@link Format} for the track. * @return A unique {@link TrackToken}. It should be used in {@link #writeSampleData}. + * @throws MuxerException If an error occurs while adding track. */ @Override - public TrackToken addTrack(Format format) { + public TrackToken addTrack(Format format) throws MuxerException { return addTrack(/* sortKey= */ 1, format); } @@ -231,15 +338,26 @@ public final class Mp4Muxer implements Muxer { * @param sortKey The key used for sorting the track list. * @param format The {@link Format} for the track. * @return A unique {@link TrackToken}. It should be used in {@link #writeSampleData}. + * @throws MuxerException If an error occurs while adding track. */ - public TrackToken addTrack(int sortKey, Format format) { + public TrackToken addTrack(int sortKey, Format format) throws MuxerException { + if (outputFileFormat == FILE_FORMAT_EDITABLE_VIDEO && isEditableVideoTrack(format)) { + try { + ensureSetupForEditableVideoTracks(); + } catch (FileNotFoundException e) { + throw new MuxerException("Cache file not found", e); + } + TrackToken trackToken = editableVideoMp4Writer.addTrack(sortKey, format); + editableVideoTracks.add(trackToken); + return trackToken; + } return mp4Writer.addTrack(sortKey, format); } /** * {@inheritDoc} * - *

Samples are written to the disk in batches. If {@link Builder#setSampleCopyEnabled(boolean) + *

Samples are written to the file in batches. If {@link Builder#setSampleCopyEnabled(boolean) * sample copying} is disabled, the {@code byteBuffer} and the {@code bufferInfo} must not be * modified after calling this method. Otherwise, they are copied and it is safe to modify them * after this method returns. @@ -249,13 +367,17 @@ public final class Mp4Muxer implements Muxer { * Builder#setSampleCopyEnabled(boolean) sample copying} is disabled. Otherwise, the position * of the buffer is updated but the caller retains ownership. * @param bufferInfo The {@link BufferInfo} related to this sample. - * @throws MuxerException If there is any error while writing data to the disk. + * @throws MuxerException If an error occurs while writing data to the output file. */ @Override public void writeSampleData(TrackToken trackToken, ByteBuffer byteBuffer, BufferInfo bufferInfo) throws MuxerException { try { - mp4Writer.writeSampleData(trackToken, byteBuffer, bufferInfo); + if (editableVideoTracks.contains(trackToken)) { + checkNotNull(editableVideoMp4Writer).writeSampleData(trackToken, byteBuffer, bufferInfo); + } else { + mp4Writer.writeSampleData(trackToken, byteBuffer, bufferInfo); + } } catch (IOException e) { throw new MuxerException( "Failed to write sample for presentationTimeUs=" @@ -288,6 +410,9 @@ public final class Mp4Muxer implements Muxer { @Override public void addMetadataEntry(Metadata.Entry metadataEntry) { checkArgument(Mp4Utils.isMetadataSupported(metadataEntry), "Unsupported metadata"); + if (metadataEntry instanceof Mp4TimestampData) { + timestampData = (Mp4TimestampData) metadataEntry; + } metadataCollector.addMetadata(metadataEntry); } @@ -295,12 +420,14 @@ public final class Mp4Muxer implements Muxer { public void close() throws MuxerException { @Nullable MuxerException exception = null; try { - mp4Writer.finishWritingSamplesAndFinalizeMoovBox(); + finishWritingEditableVideoTracks(); + finishWritingPrimaryVideoTracks(); + appendEditableVideoTracksDataToTheOutputFile(); } catch (IOException e) { - exception = new MuxerException("Failed to finish writing samples", e); + exception = new MuxerException("Failed to finish writing data", e); } try { - fileOutputStream.close(); + outputStream.close(); } catch (IOException e) { if (exception == null) { exception = new MuxerException("Failed to close output stream", e); @@ -308,8 +435,146 @@ public final class Mp4Muxer implements Muxer { Log.e(TAG, "Failed to close output stream", e); } } + if (cacheFileOutputStream != null) { + try { + cacheFileOutputStream.close(); + } catch (IOException e) { + if (exception == null) { + exception = new MuxerException("Failed to close the cache file output stream", e); + } else { + Log.e(TAG, "Failed to close cache file output stream", e); + } + } + } if (exception != null) { throw exception; } } + + private static boolean isEditableVideoTrack(Format format) { + return (format.roleFlags & C.ROLE_FLAG_AUXILIARY) > 0 + && (format.auxiliaryTrackType == C.AUXILIARY_TRACK_TYPE_ORIGINAL + || format.auxiliaryTrackType == C.AUXILIARY_TRACK_TYPE_DEPTH_LINEAR + || format.auxiliaryTrackType == C.AUXILIARY_TRACK_TYPE_DEPTH_INVERSE + || format.auxiliaryTrackType == C.AUXILIARY_TRACK_TYPE_DEPTH_METADATA); + } + + @EnsuresNonNull({"editableVideoMp4Writer"}) + private void ensureSetupForEditableVideoTracks() throws FileNotFoundException { + if (editableVideoMp4Writer == null) { + cacheFilePath = checkNotNull(cacheFileProvider).getCacheFilePath(); + cacheFileOutputStream = new FileOutputStream(cacheFilePath); + editableVideoMetadataCollector = new MetadataCollector(); + Mp4MoovStructure mp4MoovStructure = + new Mp4MoovStructure(editableVideoMetadataCollector, lastFrameDurationBehavior); + editableVideoMp4Writer = + new Mp4Writer( + cacheFileOutputStream.getChannel(), + mp4MoovStructure, + annexBToAvccConverter, + sampleCopyEnabled, + attemptStreamableOutputEnabled); + } + } + + private void finishWritingEditableVideoTracks() throws IOException { + if (editableVideoMp4Writer == null) { + // Editable video tracks were not added. + return; + } + + // Write editable tracks map. + // 1 byte version + 1 byte track count (n) + n bytes track types. + int totalTracks = editableVideoTracks.size(); + int dataSize = 2 + totalTracks; + byte[] data = new byte[dataSize]; + data[0] = 1; // version + data[1] = (byte) totalTracks; // track count + for (int i = 0; i < totalTracks; i++) { + checkState(editableVideoTracks.get(i) instanceof Track); + Track track = (Track) editableVideoTracks.get(i); + int trackType; + switch (track.format.auxiliaryTrackType) { + case C.AUXILIARY_TRACK_TYPE_ORIGINAL: + trackType = EDITABLE_TRACK_TYPE_SHARP; + break; + case C.AUXILIARY_TRACK_TYPE_DEPTH_LINEAR: + trackType = EDITABLE_TRACK_TYPE_DEPTH_LINEAR; + break; + case C.AUXILIARY_TRACK_TYPE_DEPTH_INVERSE: + trackType = EDITABLE_TRACK_TYPE_DEPTH_INVERSE; + break; + case C.AUXILIARY_TRACK_TYPE_DEPTH_METADATA: + trackType = EDITABLE_TRACK_TYPE_DEPTH_METADATA; + break; + default: + throw new IllegalArgumentException( + "Unsupported editable track type " + track.format.auxiliaryTrackType); + } + data[i + 2] = (byte) trackType; + } + + checkNotNull(editableVideoMetadataCollector); + editableVideoMetadataCollector.addMetadata( + new MdtaMetadataEntry( + MdtaMetadataEntry.KEY_EDITABLE_TRACKS_MAP, + data, + MdtaMetadataEntry.TYPE_INDICATOR_RESERVED)); + if (timestampData != null) { + editableVideoMetadataCollector.addMetadata(timestampData); + } + checkNotNull(editableVideoMp4Writer).finishWritingSamplesAndFinalizeMoovBox(); + } + + private void finishWritingPrimaryVideoTracks() throws IOException { + // The exact offset is known after writing all the data in mp4Writer. + @Nullable + MdtaMetadataEntry placeholderEditableTrackOffset = + new MdtaMetadataEntry( + MdtaMetadataEntry.KEY_EDITABLE_TRACKS_OFFSET, + new byte[8], + MdtaMetadataEntry.TYPE_INDICATOR_UNSIGNED_INT64); + if (editableVideoMp4Writer != null) { + long editableVideoDataSize = checkNotNull(cacheFileOutputStream).getChannel().size(); + long edvdBoxSize = EDVD_BOX_HEADER_SIZE_BYTE + editableVideoDataSize; + metadataCollector.addMetadata( + new MdtaMetadataEntry( + MdtaMetadataEntry.KEY_EDITABLE_TRACKS_LENGTH, + Longs.toByteArray(edvdBoxSize), + MdtaMetadataEntry.TYPE_INDICATOR_UNSIGNED_INT64)); + metadataCollector.addMetadata(placeholderEditableTrackOffset); + } + mp4Writer.finishWritingSamplesAndFinalizeMoovBox(); + if (editableVideoMp4Writer != null) { + long primaryVideoDataSize = outputChannel.size(); + metadataCollector.removeMdtaMetadataEntry(placeholderEditableTrackOffset); + metadataCollector.addMetadata( + new MdtaMetadataEntry( + MdtaMetadataEntry.KEY_EDITABLE_TRACKS_OFFSET, + Longs.toByteArray(primaryVideoDataSize), + MdtaMetadataEntry.TYPE_INDICATOR_UNSIGNED_INT64)); + mp4Writer.finalizeMoovBox(); + checkState( + outputChannel.size() == primaryVideoDataSize, + "The editable tracks offset should remain the same"); + } + } + + private void appendEditableVideoTracksDataToTheOutputFile() throws IOException { + if (editableVideoMp4Writer == null) { + // Editable video tracks were not added. + return; + } + outputChannel.position(outputChannel.size()); + FileInputStream inputStream = new FileInputStream(checkNotNull(cacheFilePath)); + ByteBuffer edvdBoxHeader = ByteBuffer.allocate(EDVD_BOX_HEADER_SIZE_BYTE); + edvdBoxHeader.putInt(1); // indicating a 64-bit length field + edvdBoxHeader.put(Util.getUtf8Bytes("edvd")); + edvdBoxHeader.putLong( + EDVD_BOX_HEADER_SIZE_BYTE + inputStream.getChannel().size()); // the actual length + edvdBoxHeader.flip(); + outputChannel.write(edvdBoxHeader); + ByteStreams.copy(inputStream, outputStream); + inputStream.close(); + } } diff --git a/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Writer.java b/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Writer.java index c0b2745863..03f7f57232 100644 --- a/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Writer.java +++ b/libraries/muxer/src/main/java/androidx/media3/muxer/Mp4Writer.java @@ -136,6 +136,69 @@ import java.util.concurrent.atomic.AtomicBoolean; } } + /** + * Writes the updated moov box to the output {@link FileChannel}. + * + *

It also trims any extra spaces from the file. + * + * @throws IOException If there is any error while writing data to the output {@link FileChannel}. + */ + public void finalizeMoovBox() throws IOException { + if (canWriteMoovAtStart) { + maybeWriteMoovAtStart(); + return; + } + + // The current state is: + // | ftyp | mdat .. .. .. (00 00 00) | moov | + + // To keep the trimming safe, first write the final moov box into the gap at the end of the mdat + // box, and only then trim the extra space. + ByteBuffer currentMoovData = assembleCurrentMoovData(); + + int moovBytesNeeded = currentMoovData.remaining(); + + // Write a temporary free box wrapping the new moov box. + int moovAndFreeBytesNeeded = moovBytesNeeded + 8; + + if (mdatEnd - mdatDataEnd < moovAndFreeBytesNeeded) { + // If the gap is not big enough for the moov box, then extend the mdat box once again. This + // involves writing moov box farther away one more time. + safelyReplaceMoovAtEnd( + lastMoovWritten.upperEndpoint() + moovAndFreeBytesNeeded, currentMoovData); + checkState(mdatEnd - mdatDataEnd >= moovAndFreeBytesNeeded); + } + + // Write out the new moov box into the gap. + long newMoovLocation = mdatDataEnd; + outputFileChannel.position(mdatDataEnd); + outputFileChannel.write(currentMoovData); + + // Add a free box to account for the actual remaining length of the file. + long remainingLength = lastMoovWritten.upperEndpoint() - (newMoovLocation + moovBytesNeeded); + + // Moov boxes shouldn't be too long; they can fit into a free box with a 32-bit length field. + checkState(remainingLength < Integer.MAX_VALUE); + + ByteBuffer freeHeader = ByteBuffer.allocate(4 + 4); + freeHeader.putInt((int) remainingLength); + freeHeader.put(Util.getUtf8Bytes(FREE_BOX_TYPE)); + freeHeader.flip(); + outputFileChannel.write(freeHeader); + + // The moov box is actually written inside mdat box so the current state is: + // | ftyp | mdat .. .. .. (new moov) (free header ) (00 00 00) | old moov | + + // Now change this to: + // | ftyp | mdat .. .. .. | new moov | free (00 00 00) (old moov) | + mdatEnd = newMoovLocation; + updateMdatSize(mdatEnd - mdatStart); + lastMoovWritten = Range.closed(newMoovLocation, newMoovLocation + currentMoovData.limit()); + + // Remove the free box. + outputFileChannel.truncate(newMoovLocation + moovBytesNeeded); + } + private void writeHeader() throws IOException { outputFileChannel.position(0L); outputFileChannel.write(Boxes.ftyp()); @@ -249,69 +312,6 @@ import java.util.concurrent.atomic.AtomicBoolean; updateMdatSize(mdatDataEnd - mdatStart); } - /** - * Writes the updated moov box to the output {@link FileChannel}. - * - *

It also trims any extra spaces from the file. - * - * @throws IOException If there is any error while writing data to the output {@link FileChannel}. - */ - private void finalizeMoovBox() throws IOException { - if (canWriteMoovAtStart) { - maybeWriteMoovAtStart(); - return; - } - - // The current state is: - // | ftyp | mdat .. .. .. (00 00 00) | moov | - - // To keep the trimming safe, first write the final moov box into the gap at the end of the mdat - // box, and only then trim the extra space. - ByteBuffer currentMoovData = assembleCurrentMoovData(); - - int moovBytesNeeded = currentMoovData.remaining(); - - // Write a temporary free box wrapping the new moov box. - int moovAndFreeBytesNeeded = moovBytesNeeded + 8; - - if (mdatEnd - mdatDataEnd < moovAndFreeBytesNeeded) { - // If the gap is not big enough for the moov box, then extend the mdat box once again. This - // involves writing moov box farther away one more time. - safelyReplaceMoovAtEnd( - lastMoovWritten.upperEndpoint() + moovAndFreeBytesNeeded, currentMoovData); - checkState(mdatEnd - mdatDataEnd >= moovAndFreeBytesNeeded); - } - - // Write out the new moov box into the gap. - long newMoovLocation = mdatDataEnd; - outputFileChannel.position(mdatDataEnd); - outputFileChannel.write(currentMoovData); - - // Add a free box to account for the actual remaining length of the file. - long remainingLength = lastMoovWritten.upperEndpoint() - (newMoovLocation + moovBytesNeeded); - - // Moov boxes shouldn't be too long; they can fit into a free box with a 32-bit length field. - checkState(remainingLength < Integer.MAX_VALUE); - - ByteBuffer freeHeader = ByteBuffer.allocate(4 + 4); - freeHeader.putInt((int) remainingLength); - freeHeader.put(Util.getUtf8Bytes(FREE_BOX_TYPE)); - freeHeader.flip(); - outputFileChannel.write(freeHeader); - - // The moov box is actually written inside mdat box so the current state is: - // | ftyp | mdat .. .. .. (new moov) (free header ) (00 00 00) | old moov | - - // Now change this to: - // | ftyp | mdat .. .. .. | new moov | free (00 00 00) (old moov) | - mdatEnd = newMoovLocation; - updateMdatSize(mdatEnd - mdatStart); - lastMoovWritten = Range.closed(newMoovLocation, newMoovLocation + currentMoovData.limit()); - - // Remove the free box. - outputFileChannel.truncate(newMoovLocation + moovBytesNeeded); - } - /** * Rewrites the moov box after accommodating extra bytes needed for the mdat box. * diff --git a/libraries/muxer/src/test/java/androidx/media3/muxer/Mp4MuxerEndToEndTest.java b/libraries/muxer/src/test/java/androidx/media3/muxer/Mp4MuxerEndToEndTest.java index 6b49cdd839..dbfb694c10 100644 --- a/libraries/muxer/src/test/java/androidx/media3/muxer/Mp4MuxerEndToEndTest.java +++ b/libraries/muxer/src/test/java/androidx/media3/muxer/Mp4MuxerEndToEndTest.java @@ -19,10 +19,14 @@ import static androidx.media3.muxer.MuxerTestUtil.FAKE_VIDEO_FORMAT; import static androidx.media3.muxer.MuxerTestUtil.XMP_SAMPLE_DATA; import static androidx.media3.muxer.MuxerTestUtil.getFakeSampleAndSampleInfo; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; import android.content.Context; import android.media.MediaCodec.BufferInfo; import android.util.Pair; +import androidx.media3.common.C; +import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.ParsableByteArray; import androidx.media3.common.util.Util; import androidx.media3.container.MdtaMetadataEntry; import androidx.media3.container.Mp4LocationData; @@ -39,6 +43,7 @@ import androidx.media3.test.utils.TestUtil; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.io.FileOutputStream; +import java.io.IOException; import java.nio.ByteBuffer; import org.junit.Rule; import org.junit.Test; @@ -360,4 +365,242 @@ public class Mp4MuxerEndToEndTest { dumpableBox, MuxerTestUtil.getExpectedDumpFilePath("mp4_with_moov_at_the_end_and_no_free_box.mp4")); } + + @Test + public void createMp4Muxer_withFileFormatEditableVideoButWithoutCacheFileProvider_throws() + throws Exception { + String outputFilePath = temporaryFolder.newFile().getPath(); + + assertThrows( + IllegalArgumentException.class, + () -> + new Mp4Muxer.Builder(new FileOutputStream(outputFilePath)) + .setOutputFileFormat(Mp4Muxer.FILE_FORMAT_EDITABLE_VIDEO) + .build()); + } + + @Test + public void writeMp4File_withFileFormatEditableVideoAndEditableVideoTracks_writesEdvdBox() + throws Exception { + String outputFilePath = temporaryFolder.newFile().getPath(); + String cacheFilePath = temporaryFolder.newFile().getPath(); + Mp4Muxer muxer = + new Mp4Muxer.Builder(new FileOutputStream(outputFilePath)) + .setOutputFileFormat(Mp4Muxer.FILE_FORMAT_EDITABLE_VIDEO) + .setCacheFileProvider(() -> cacheFilePath) + .build(); + + try { + muxer.addMetadataEntry( + new Mp4TimestampData( + /* creationTimestampSeconds= */ 1_000_000L, + /* modificationTimestampSeconds= */ 5_000_000L)); + TrackToken primaryVideoTrackToken = muxer.addTrack(FAKE_VIDEO_FORMAT); + TrackToken sharpVideoTrackToken = + muxer.addTrack( + FAKE_VIDEO_FORMAT + .buildUpon() + .setRoleFlags(C.ROLE_FLAG_AUXILIARY) + .setAuxiliaryTrackType(C.AUXILIARY_TRACK_TYPE_ORIGINAL) + .build()); + TrackToken depthLinearVideoTrackToken = + muxer.addTrack( + FAKE_VIDEO_FORMAT + .buildUpon() + .setRoleFlags(C.ROLE_FLAG_AUXILIARY) + .setAuxiliaryTrackType(C.AUXILIARY_TRACK_TYPE_DEPTH_LINEAR) + .build()); + writeFakeSamples(muxer, primaryVideoTrackToken, /* sampleCount= */ 5); + writeFakeSamples(muxer, sharpVideoTrackToken, /* sampleCount= */ 5); + writeFakeSamples(muxer, depthLinearVideoTrackToken, /* sampleCount= */ 5); + } finally { + muxer.close(); + } + + DumpableMp4Box outputFileDumpableBox = + new DumpableMp4Box(ByteBuffer.wrap(TestUtil.getByteArrayFromFilePath(outputFilePath))); + // 1 track is written in the outer moov box and 2 tracks are writtin in the edvd.moov box. + DumpFileAsserts.assertOutput( + context, + outputFileDumpableBox, + MuxerTestUtil.getExpectedDumpFilePath("mp4_with_editable_video_tracks_in_edvd.box")); + } + + @Test + public void writeMp4File_withFileFormatDefaultAndEditableVideoTracks_doesNotWriteEdvdBox() + throws Exception { + String outputFilePath = temporaryFolder.newFile().getPath(); + String cacheFilePath = temporaryFolder.newFile().getPath(); + Mp4Muxer muxer = + new Mp4Muxer.Builder(new FileOutputStream(outputFilePath)) + .setCacheFileProvider(() -> cacheFilePath) + .build(); + + try { + muxer.addMetadataEntry( + new Mp4TimestampData( + /* creationTimestampSeconds= */ 1_000_000L, + /* modificationTimestampSeconds= */ 5_000_000L)); + TrackToken primaryVideoTrackToken = muxer.addTrack(FAKE_VIDEO_FORMAT); + TrackToken sharpVideoTrackToken = + muxer.addTrack( + FAKE_VIDEO_FORMAT + .buildUpon() + .setRoleFlags(C.ROLE_FLAG_AUXILIARY) + .setAuxiliaryTrackType(C.AUXILIARY_TRACK_TYPE_ORIGINAL) + .build()); + TrackToken depthLinearVideoTrackToken = + muxer.addTrack( + FAKE_VIDEO_FORMAT + .buildUpon() + .setRoleFlags(C.ROLE_FLAG_AUXILIARY) + .setAuxiliaryTrackType(C.AUXILIARY_TRACK_TYPE_DEPTH_LINEAR) + .build()); + writeFakeSamples(muxer, primaryVideoTrackToken, /* sampleCount= */ 5); + writeFakeSamples(muxer, sharpVideoTrackToken, /* sampleCount= */ 5); + writeFakeSamples(muxer, depthLinearVideoTrackToken, /* sampleCount= */ 5); + } finally { + muxer.close(); + } + + DumpableMp4Box outputFileDumpableBox = + new DumpableMp4Box(ByteBuffer.wrap(TestUtil.getByteArrayFromFilePath(outputFilePath))); + // All 3 tracks are written in the outer moov box and no edvd box. + DumpFileAsserts.assertOutput( + context, + outputFileDumpableBox, + MuxerTestUtil.getExpectedDumpFilePath("mp4_with_editable_video_tracks_without_edvd.box")); + } + + @Test + public void + writeMp4File_withFileFormatEditableVideoAndEditableVideoTracks_primaryVideoTracksMatchesExpected() + throws Exception { + String outputFilePath = temporaryFolder.newFile().getPath(); + String cacheFilePath = temporaryFolder.newFile().getPath(); + Mp4Muxer muxer = + new Mp4Muxer.Builder(new FileOutputStream(outputFilePath)) + .setOutputFileFormat(Mp4Muxer.FILE_FORMAT_EDITABLE_VIDEO) + .setCacheFileProvider(() -> cacheFilePath) + .build(); + + try { + muxer.addMetadataEntry( + new Mp4TimestampData( + /* creationTimestampSeconds= */ 1_000_000L, + /* modificationTimestampSeconds= */ 5_000_000L)); + TrackToken primaryVideoTrackToken = muxer.addTrack(FAKE_VIDEO_FORMAT); + TrackToken sharpVideoTrackToken = + muxer.addTrack( + FAKE_VIDEO_FORMAT + .buildUpon() + .setRoleFlags(C.ROLE_FLAG_AUXILIARY) + .setAuxiliaryTrackType(C.AUXILIARY_TRACK_TYPE_ORIGINAL) + .build()); + TrackToken depthLinearVideoTrackToken = + muxer.addTrack( + FAKE_VIDEO_FORMAT + .buildUpon() + .setRoleFlags(C.ROLE_FLAG_AUXILIARY) + .setAuxiliaryTrackType(C.AUXILIARY_TRACK_TYPE_DEPTH_LINEAR) + .build()); + writeFakeSamples(muxer, primaryVideoTrackToken, /* sampleCount= */ 5); + writeFakeSamples(muxer, sharpVideoTrackToken, /* sampleCount= */ 5); + writeFakeSamples(muxer, depthLinearVideoTrackToken, /* sampleCount= */ 5); + } finally { + muxer.close(); + } + + FakeExtractorOutput primaryTracksOutput = + TestUtil.extractAllSamplesFromFilePath( + new Mp4Extractor(new DefaultSubtitleParserFactory()), outputFilePath); + // The Mp4Extractor can not read edvd box and can only parse primary tracks. + DumpFileAsserts.assertOutput( + context, + primaryTracksOutput, + MuxerTestUtil.getExpectedDumpFilePath("mp4_with_primary_tracks.mp4")); + } + + @Test + public void + writeMp4File_withFileFormatEditableVideoAndEditableVideoTracks_editableVideoTracksMatchesExpected() + throws Exception { + String outputFilePath = temporaryFolder.newFile().getPath(); + String cacheFilePath = temporaryFolder.newFile().getPath(); + Mp4Muxer muxer = + new Mp4Muxer.Builder(new FileOutputStream(outputFilePath)) + .setOutputFileFormat(Mp4Muxer.FILE_FORMAT_EDITABLE_VIDEO) + .setCacheFileProvider(() -> cacheFilePath) + .build(); + + try { + muxer.addMetadataEntry( + new Mp4TimestampData( + /* creationTimestampSeconds= */ 1_000_000L, + /* modificationTimestampSeconds= */ 5_000_000L)); + TrackToken primaryVideoTrackToken = muxer.addTrack(FAKE_VIDEO_FORMAT); + TrackToken sharpVideoTrackToken = + muxer.addTrack( + FAKE_VIDEO_FORMAT + .buildUpon() + .setRoleFlags(C.ROLE_FLAG_AUXILIARY) + .setAuxiliaryTrackType(C.AUXILIARY_TRACK_TYPE_ORIGINAL) + .build()); + TrackToken depthLinearVideoTrackToken = + muxer.addTrack( + FAKE_VIDEO_FORMAT + .buildUpon() + .setRoleFlags(C.ROLE_FLAG_AUXILIARY) + .setAuxiliaryTrackType(C.AUXILIARY_TRACK_TYPE_DEPTH_LINEAR) + .build()); + writeFakeSamples(muxer, primaryVideoTrackToken, /* sampleCount= */ 5); + writeFakeSamples(muxer, sharpVideoTrackToken, /* sampleCount= */ 5); + writeFakeSamples(muxer, depthLinearVideoTrackToken, /* sampleCount= */ 5); + } finally { + muxer.close(); + } + + byte[] edvdBoxPayload = getEdvdBoxPayload(outputFilePath); + FakeExtractorOutput editableTracksOutput = + TestUtil.extractAllSamplesFromByteArray( + new Mp4Extractor(new DefaultSubtitleParserFactory()), edvdBoxPayload); + // The Mp4Extractor can parse the MP4 embedded in the edvd box. + DumpFileAsserts.assertOutput( + context, + editableTracksOutput, + MuxerTestUtil.getExpectedDumpFilePath("mp4_with_editable_video_tracks.mp4")); + } + + private static void writeFakeSamples(Mp4Muxer muxer, TrackToken trackToken, int sampleCount) + throws Muxer.MuxerException { + for (int i = 0; i < sampleCount; i++) { + Pair sampleAndSampleInfo = + getFakeSampleAndSampleInfo(/* presentationTimeUs= */ i); + muxer.writeSampleData(trackToken, sampleAndSampleInfo.first, sampleAndSampleInfo.second); + } + } + + private static byte[] getEdvdBoxPayload(String filePath) throws IOException { + ParsableByteArray data = new ParsableByteArray(TestUtil.getByteArrayFromFilePath(filePath)); + while (data.bytesLeft() > 0) { + long size = data.readInt(); + String name = data.readString(/* length= */ 4); + long payloadSize = size - 8; + if (size == 1) { + size = data.readUnsignedLongToLong(); + // Parsing is not supported for box having size > Integer.MAX_VALUE. + Assertions.checkState(size <= Integer.MAX_VALUE); + // Subtract 4 bytes (32-bit box size) + 4 bytes (box name) + 8 bytes (64-bit box size). + payloadSize = size - 16; + } + if (name.equals("edvd")) { + byte[] payloadData = new byte[(int) payloadSize]; + data.readBytes(payloadData, /* offset= */ 0, (int) payloadSize); + return payloadData; + } else { + data.skipBytes((int) payloadSize); + } + } + return new byte[0]; + } } diff --git a/libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks.mp4.dump b/libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks.mp4.dump new file mode 100644 index 0000000000..4f402ff103 --- /dev/null +++ b/libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks.mp4.dump @@ -0,0 +1,87 @@ +seekMap: + isSeekable = true + duration = 0 + getPosition(0) = [[timeUs=0, position=400052]] + getPosition(1) = [[timeUs=0, position=400276]] + getPosition(0) = [[timeUs=0, position=400052]] + getPosition(0) = [[timeUs=0, position=400052]] +numberOfTracks = 2 +track 0: + total output bytes = 280 + sample count = 5 + format 0: + id = 1 + sampleMimeType = video/avc + codecs = avc1.F4000A + maxInputSize = 86 + maxNumReorderSamples = 2 + width = 12 + height = 10 + colorInfo: + colorRange = 1 + lumaBitdepth = 8 + chromaBitdepth = 8 + metadata = entries=[mdta: key=editable.tracks.map, value=track types = 0, 1, Mp4Timestamp: creation time=1000000, modification time=5000000, timescale=10000] + initializationData: + data = length 28, hash 410B510 + data = length 9, hash FBADD682 + sample 0: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 1: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 2: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 3: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 4: + time = 0 + flags = 536870913 + data = length 56, hash C4551A2E +track 1: + total output bytes = 280 + sample count = 5 + format 0: + id = 2 + sampleMimeType = video/avc + codecs = avc1.F4000A + maxInputSize = 86 + maxNumReorderSamples = 2 + width = 12 + height = 10 + colorInfo: + colorRange = 1 + lumaBitdepth = 8 + chromaBitdepth = 8 + metadata = entries=[mdta: key=editable.tracks.map, value=track types = 0, 1, Mp4Timestamp: creation time=1000000, modification time=5000000, timescale=10000] + initializationData: + data = length 28, hash 410B510 + data = length 9, hash FBADD682 + sample 0: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 1: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 2: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 3: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 4: + time = 0 + flags = 536870913 + data = length 56, hash C4551A2E +tracksEnded = true diff --git a/libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks_in_edvd.box.dump b/libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks_in_edvd.box.dump new file mode 100644 index 0000000000..142fa15025 --- /dev/null +++ b/libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks_in_edvd.box.dump @@ -0,0 +1,111 @@ +ftyp (28 bytes): + Data = length 20, hash EF896440 +moov (879 bytes): + mvhd (108 bytes): + Data = length 100, hash 2613A5C + meta (189 bytes): + hdlr (33 bytes): + Data = length 25, hash C39D0F5B + keys (76 bytes): + Data = length 68, hash AEF33438 + ilst (72 bytes): + Data = length 64, hash 545348D9 + trak (574 bytes): + tkhd (92 bytes): + Data = length 84, hash 3D79758F + mdia (474 bytes): + mdhd (32 bytes): + Data = length 24, hash 41542D81 + hdlr (44 bytes): + Data = length 36, hash A0852FF2 + minf (390 bytes): + vmhd (20 bytes): + Data = length 12, hash EE830681 + dinf (36 bytes): + Data = length 28, hash D535436B + stbl (326 bytes): + stsd (166 bytes): + Data = length 158, hash 11532063 + stts (24 bytes): + Data = length 16, hash E534C287 + stsz (40 bytes): + Data = length 32, hash B3F09E + stsc (28 bytes): + Data = length 20, hash 8FA6E089 + co64 (24 bytes): + Data = length 16, hash E4EE6662 + stss (36 bytes): + Data = length 28, hash 53024615 +free (399129 bytes): + Data = length 399121, hash 4626C21F +mdat (296 bytes): + Data = length 280, hash 8DCFD2E3 +edvd (400628 bytes): + ftyp (28 bytes): + Data = length 20, hash EF896440 + moov (1384 bytes): + mvhd (108 bytes): + Data = length 100, hash 2613A5D + meta (120 bytes): + hdlr (33 bytes): + Data = length 25, hash C39D0F5B + keys (43 bytes): + Data = length 35, hash 1CD3D3F + ilst (36 bytes): + Data = length 28, hash D9B9DABE + trak (574 bytes): + tkhd (92 bytes): + Data = length 84, hash 3D79758F + mdia (474 bytes): + mdhd (32 bytes): + Data = length 24, hash 41542D81 + hdlr (44 bytes): + Data = length 36, hash A0852FF2 + minf (390 bytes): + vmhd (20 bytes): + Data = length 12, hash EE830681 + dinf (36 bytes): + Data = length 28, hash D535436B + stbl (326 bytes): + stsd (166 bytes): + Data = length 158, hash 11532063 + stts (24 bytes): + Data = length 16, hash E534C287 + stsz (40 bytes): + Data = length 32, hash B3F09E + stsc (28 bytes): + Data = length 20, hash 8FA6E089 + co64 (24 bytes): + Data = length 16, hash E4EE6662 + stss (36 bytes): + Data = length 28, hash 53024615 + trak (574 bytes): + tkhd (92 bytes): + Data = length 84, hash 2ECB0510 + mdia (474 bytes): + mdhd (32 bytes): + Data = length 24, hash 41542D81 + hdlr (44 bytes): + Data = length 36, hash A0852FF2 + minf (390 bytes): + vmhd (20 bytes): + Data = length 12, hash EE830681 + dinf (36 bytes): + Data = length 28, hash D535436B + stbl (326 bytes): + stsd (166 bytes): + Data = length 158, hash 11532063 + stts (24 bytes): + Data = length 16, hash E534C287 + stsz (40 bytes): + Data = length 32, hash B3F09E + stsc (28 bytes): + Data = length 20, hash 8FA6E089 + co64 (24 bytes): + Data = length 16, hash E4EE6699 + stss (36 bytes): + Data = length 28, hash 53024615 + free (398624 bytes): + Data = length 398616, hash 25A3AD01 + mdat (576 bytes): + Data = length 560, hash 9E0D5FC5 diff --git a/libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks_without_edvd.box.dump b/libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks_without_edvd.box.dump new file mode 100644 index 0000000000..08ae765e4d --- /dev/null +++ b/libraries/test_data/src/test/assets/muxerdumps/mp4_with_editable_video_tracks_without_edvd.box.dump @@ -0,0 +1,87 @@ +ftyp (28 bytes): + Data = length 20, hash EF896440 +moov (1838 bytes): + mvhd (108 bytes): + Data = length 100, hash 2613A5E + trak (574 bytes): + tkhd (92 bytes): + Data = length 84, hash 3D79758F + mdia (474 bytes): + mdhd (32 bytes): + Data = length 24, hash 41542D81 + hdlr (44 bytes): + Data = length 36, hash A0852FF2 + minf (390 bytes): + vmhd (20 bytes): + Data = length 12, hash EE830681 + dinf (36 bytes): + Data = length 28, hash D535436B + stbl (326 bytes): + stsd (166 bytes): + Data = length 158, hash 11532063 + stts (24 bytes): + Data = length 16, hash E534C287 + stsz (40 bytes): + Data = length 32, hash B3F09E + stsc (28 bytes): + Data = length 20, hash 8FA6E089 + co64 (24 bytes): + Data = length 16, hash E4EE6662 + stss (36 bytes): + Data = length 28, hash 53024615 + trak (574 bytes): + tkhd (92 bytes): + Data = length 84, hash 2ECB0510 + mdia (474 bytes): + mdhd (32 bytes): + Data = length 24, hash 41542D81 + hdlr (44 bytes): + Data = length 36, hash A0852FF2 + minf (390 bytes): + vmhd (20 bytes): + Data = length 12, hash EE830681 + dinf (36 bytes): + Data = length 28, hash D535436B + stbl (326 bytes): + stsd (166 bytes): + Data = length 158, hash 11532063 + stts (24 bytes): + Data = length 16, hash E534C287 + stsz (40 bytes): + Data = length 32, hash B3F09E + stsc (28 bytes): + Data = length 20, hash 8FA6E089 + co64 (24 bytes): + Data = length 16, hash E4EE6699 + stss (36 bytes): + Data = length 28, hash 53024615 + trak (574 bytes): + tkhd (92 bytes): + Data = length 84, hash 201C9491 + mdia (474 bytes): + mdhd (32 bytes): + Data = length 24, hash 41542D81 + hdlr (44 bytes): + Data = length 36, hash A0852FF2 + minf (390 bytes): + vmhd (20 bytes): + Data = length 12, hash EE830681 + dinf (36 bytes): + Data = length 28, hash D535436B + stbl (326 bytes): + stsd (166 bytes): + Data = length 158, hash 11532063 + stts (24 bytes): + Data = length 16, hash E534C287 + stsz (40 bytes): + Data = length 32, hash B3F09E + stsc (28 bytes): + Data = length 20, hash 8FA6E089 + co64 (24 bytes): + Data = length 16, hash E4EE66D0 + stss (36 bytes): + Data = length 28, hash 53024615 +free (398170 bytes): + Data = length 398162, hash E06B79C1 +mdat (856 bytes): + Data = length 840, hash A66AA6A7 diff --git a/libraries/test_data/src/test/assets/muxerdumps/mp4_with_primary_tracks.mp4.dump b/libraries/test_data/src/test/assets/muxerdumps/mp4_with_primary_tracks.mp4.dump new file mode 100644 index 0000000000..ad74f2fda6 --- /dev/null +++ b/libraries/test_data/src/test/assets/muxerdumps/mp4_with_primary_tracks.mp4.dump @@ -0,0 +1,48 @@ +seekMap: + isSeekable = true + duration = 0 + getPosition(0) = [[timeUs=0, position=400052]] + getPosition(1) = [[timeUs=0, position=400276]] + getPosition(0) = [[timeUs=0, position=400052]] + getPosition(0) = [[timeUs=0, position=400052]] +numberOfTracks = 1 +track 0: + total output bytes = 280 + sample count = 5 + format 0: + id = 1 + sampleMimeType = video/avc + codecs = avc1.F4000A + maxInputSize = 86 + maxNumReorderSamples = 2 + width = 12 + height = 10 + colorInfo: + colorRange = 1 + lumaBitdepth = 8 + chromaBitdepth = 8 + metadata = entries=[mdta: key=editable.tracks.offset, value=400332, mdta: key=editable.tracks.length, value=400628, Mp4Timestamp: creation time=1000000, modification time=5000000, timescale=10000] + initializationData: + data = length 28, hash 410B510 + data = length 9, hash FBADD682 + sample 0: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 1: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 2: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 3: + time = 0 + flags = 1 + data = length 56, hash C4551A2E + sample 4: + time = 0 + flags = 536870913 + data = length 56, hash C4551A2E +tracksEnded = true diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/DumpableMp4Box.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/DumpableMp4Box.java index 6a63ffd839..f708dc043f 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/DumpableMp4Box.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/DumpableMp4Box.java @@ -26,7 +26,7 @@ import java.nio.ByteBuffer; public final class DumpableMp4Box implements Dumper.Dumpable { private static final ImmutableSet CONTAINER_BOXES = ImmutableSet.of( - "moov", "trak", "mdia", "minf", "stbl", "edts", "meta", "mvex", "moof", "traf"); + "moov", "trak", "mdia", "minf", "stbl", "edts", "meta", "mvex", "moof", "traf", "edvd"); private final ParsableByteArray box; /*** diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java index 14937a5271..6552b2d411 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java @@ -475,8 +475,17 @@ public class TestUtil { return extractAllSamplesFromByteArray(extractor, data); } - private static FakeExtractorOutput extractAllSamplesFromByteArray( - Extractor extractor, byte[] data) throws IOException { + /** + * Extracts all samples from the given byte array into a {@link FakeTrackOutput}. + * + * @param extractor The {@link Extractor} to be used. + * @param data The byte array data. + * @return The {@link FakeTrackOutput} containing the extracted samples. + * @throws IOException If an error occurred reading from the input, or if the extractor finishes + * reading from input without extracting any {@link SeekMap}. + */ + public static FakeExtractorOutput extractAllSamplesFromByteArray(Extractor extractor, byte[] data) + throws IOException { FakeExtractorOutput expectedOutput = new FakeExtractorOutput(); extractor.init(expectedOutput); FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build();