Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract MP4 metadata-based chapters #1851

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,9 @@ public abstract class Mp4Box {
@SuppressWarnings("ConstantCaseForConstants")
public static final int TYPE_xyz = 0xa978797a;

@SuppressWarnings("ConstantCaseForConstants")
public static final int TYPE_chpl = 0x6368706c;

@SuppressWarnings("ConstantCaseForConstants")
public static final int TYPE_smta = 0x736d7461;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
import androidx.media3.extractor.HevcConfig;
import androidx.media3.extractor.OpusUtil;
import androidx.media3.extractor.VorbisUtil;
import androidx.media3.extractor.metadata.id3.ChapterFrame;
import androidx.media3.extractor.metadata.id3.Id3Frame;
import androidx.media3.extractor.metadata.id3.TextInformationFrame;
import com.google.common.base.Function;
import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Ints;
Expand Down Expand Up @@ -196,6 +199,9 @@ public static Metadata parseUdta(LeafBox udtaBox) {
SmtaAtomUtil.parseSmta(udtaData, atomPosition + atomSize));
} else if (atomType == Mp4Box.TYPE_xyz) {
metadata = metadata.copyWithAppendedEntriesFrom(parseXyz(udtaData));
} else if (atomType == Mp4Box.TYPE_chpl) {
udtaData.setPosition(atomPosition);
metadata = metadata.copyWithAppendedEntriesFrom(parseChpl(udtaData));
}
udtaData.setPosition(atomPosition + atomSize);
}
Expand Down Expand Up @@ -860,6 +866,36 @@ private static Metadata parseXyz(ParsableByteArray xyzBox) {
}
}

/** Parses the chapter metadata from the chpl atom. */
@Nullable
/* package */ static Metadata parseChpl(ParsableByteArray chpl) {
chpl.skipBytes(Mp4Box.HEADER_SIZE);
chpl.skipBytes(5); // version (1) + flags (3) + reservered byte (1)
ArrayList<Metadata.Entry> entries = new ArrayList<>();
int chapterCount = chpl.readInt();
for (int i = 0; i < chapterCount; i++) {
long startTimeMs = chpl.readLong() / 10_000; // Start time in 100-nanoseconds resolution
if (startTimeMs < 0 || startTimeMs > Integer.MAX_VALUE) {
startTimeMs = C.INDEX_UNSET;
}
int titleLength = chpl.readUnsignedByte();
String chapterName = chpl.readString(titleLength);
ChapterFrame chapterFrame =
new ChapterFrame(
/* chapterId= */ Integer.toString(i),
Copy link
Contributor Author

@MiSikora MiSikora Nov 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure if something else or perhaps C.INDEX_UNSET should be used as the ID. MP4 chapters do not have this concept.

(int) startTimeMs,
/* endTime= */ C.INDEX_UNSET,
/* startOffset= */ C.INDEX_UNSET,
/* endOffset= */ C.INDEX_UNSET,
/* subFrames= */ new Id3Frame[] {
new TextInformationFrame(
"TIT2", /* description= */ null, ImmutableList.of(chapterName))
});
entries.add(chapterFrame);
}
return entries.isEmpty() ? null : new Metadata(entries);
}

/**
* Parses a tkhd atom (defined in ISO/IEC 14496-12).
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,17 @@
import static com.google.common.truth.Truth.assertThat;

import androidx.media3.common.C;
import androidx.media3.common.Metadata;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.Util;
import androidx.media3.container.Mp4Box;
import androidx.media3.extractor.metadata.id3.ChapterFrame;
import androidx.media3.extractor.metadata.id3.Id3Frame;
import androidx.media3.extractor.metadata.id3.TextInformationFrame;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.nio.ByteBuffer;
import org.junit.Test;
import org.junit.runner.RunWith;

Expand Down Expand Up @@ -288,6 +294,94 @@ public void vexuParsings() throws ParserException {
assertThat(vexuData.hasBothEyeViews()).isTrue();
}

@Test
public void chplParsing() {
byte[] data =
ByteBuffer.allocate(50)
.putInt(50) // box size
.putInt(Mp4Box.TYPE_chpl) // box type
.put(new byte[5]) // version (1), flags (3), reserved byte (1)
.putInt(2) // chapter count
// first chapter
.putLong(0) // start time in 100-nanoseconds resolution
.put((byte) 9) // Title length
.put("Chapter A".getBytes())
// second chapter
.putLong(12_340_000) // start time in 100-nanoseconds resolution
.put((byte) 6) // Title length
.put("Chap B".getBytes())
.array();

Metadata metadata = BoxParser.parseChpl(new ParsableByteArray(data));
assertThat(metadata.length()).isEqualTo(2);
assertThat(metadata.get(0))
.isEqualTo(
new ChapterFrame(
/* chapterId= */ "0",
/* startTimeMs= */ 0,
/* endTimeMs= */ -1,
/* startOffset= */ -1,
/* endOffset= */ -1,
new Id3Frame[] {
new TextInformationFrame(
"TIT2", /* description= */ null, ImmutableList.of("Chapter A"))
}));
assertThat(metadata.get(1))
.isEqualTo(
new ChapterFrame(
/* chapterId= */ "1",
/* startTimeMs= */ 1234,
/* endTimeMs= */ -1,
/* startOffset= */ -1,
/* endOffset= */ -1,
new Id3Frame[] {
new TextInformationFrame(
"TIT2", /* description= */ null, ImmutableList.of("Chap B"))
}));
}

@Test
public void chplParsingInvalidStartTime() {
byte[] data =
ByteBuffer.allocate(50)
.putInt(50) // box size
.putInt(Mp4Box.TYPE_chpl) // box type
.put(new byte[5]) // version (1), flags (3), reserved byte (1)
.putInt(2) // chapter count
// first chapter
.putLong(-10_000) // start time, negative
.put((byte) 0) // Title length
// second chapter
.putLong(Long.valueOf(Integer.MAX_VALUE) * 10_000 + 10_000) // start time, overflow
.put((byte) 0) // Title length
.array();

Metadata metadata = BoxParser.parseChpl(new ParsableByteArray(data));
assertThat(metadata.length()).isEqualTo(2);
assertThat(metadata.get(0))
.isEqualTo(
new ChapterFrame(
/* chapterId= */ "0",
/* startTimeMs= */ -1,
/* endTimeMs= */ -1,
/* startOffset= */ -1,
/* endOffset= */ -1,
new Id3Frame[] {
new TextInformationFrame("TIT2", /* description= */ null, ImmutableList.of(""))
}));
assertThat(metadata.get(1))
.isEqualTo(
new ChapterFrame(
/* chapterId= */ "1",
/* startTimeMs= */ -1,
/* endTimeMs= */ -1,
/* startOffset= */ -1,
/* endOffset= */ -1,
new Id3Frame[] {
new TextInformationFrame("TIT2", /* description= */ null, ImmutableList.of(""))
}));
}

private static void verifyStz2Parsing(Mp4Box.LeafBox stz2Atom) {
BoxParser.Stz2SampleSizeBox box = new BoxParser.Stz2SampleSizeBox(stz2Atom);
assertThat(box.getSampleCount()).isEqualTo(4);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ public void mp4SampleWithNumericGenre() throws Exception {
assertExtractorBehavior("media/mp4/sample_with_numeric_genre.mp4");
}

@Test
public void mp4SampleWithChapters() throws Exception {
assertExtractorBehavior("media/mp4/sample_with_chapters.mp4");
}

/**
* Test case for https://github.com/google/ExoPlayer/issues/6774. The sample file contains an mdat
* atom whose size indicates that it extends 8 bytes beyond the end of the file.
Expand Down
Loading