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