MP4: Cover the leading QuickTime placeholder chapter with an empty edit (#1473)

QuickTime chapter samples run back to back from media time 0, so when the
first chapter starts after 0, QtChapterList::write() prepends an untitled
placeholder sample to keep the absolute start times.  TagLib's own reader
strips it again, but the chapter track's edit list played the whole media
from time 0, so AVFoundation and ffmpeg listed the placeholder as an
untitled first chapter.

The edit list now begins with an empty edit spanning the placeholder,
followed by the media from the first written chapter.  The placeholder
sample is kept, so readers which ignore edit lists see the same chapters
as before.  A list starting at 0 still gets a single edit.

testQTChapterListNonZeroFirstChapterEditList writes chapters at 1 s and
2 s and checks the chapter track's elst: an empty edit (media time -1) of
one second in the mvhd timescale, then the remaining duration from media
time one second.  It also checks that a list starting at 0 gets a single
edit from media time 0, and that qtChapters() reads back exactly the
written chapters in both cases.
This commit is contained in:
Ryan Francesconi
2026-10-28 17:37:46 +01:00
committed by GitHub
parent 53a80ba166
commit bdfeb82cbe
2 changed files with 167 additions and 9 deletions
+133
View File
@@ -169,6 +169,7 @@ class TestMP4 : public CppUnit::TestFixture
CPPUNIT_TEST(testQTChapterListOverwrite);
CPPUNIT_TEST(testQTChapterListTimestampPrecision);
CPPUNIT_TEST(testQTChapterListNonZeroFirstChapter);
CPPUNIT_TEST(testQTChapterListNonZeroFirstChapterEditList);
CPPUNIT_TEST(testQTChapterListNoOrphanedMdat);
CPPUNIT_TEST(testQTChapterListSharedTref);
CPPUNIT_TEST(testQTChapterListSharedMdatPreservesAudio);
@@ -1405,6 +1406,138 @@ public:
}
}
// A chapter track's samples run back to back from media time 0, so a list
// whose first chapter starts later is written with an untitled placeholder
// sample before it. The edit list covers that placeholder with an empty edit,
// so that players which honor edit lists start at the first written chapter.
void testQTChapterListNonZeroFirstChapterEditList()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
struct EditListEntry {
unsigned int segmentDuration;
int mediaTime;
};
struct ChapterTrackTiming {
unsigned int movieTimescale = 0;
unsigned int movieDuration = 0;
unsigned int mediaTimescale = 0;
std::vector<EditListEntry> edits;
};
// The mvhd timing, and the chapter (text) track's mdhd timescale and
// version 0 edit list entries.
auto chapterTrackTiming = [&]() {
ChapterTrackTiming timing;
PlainFile pf(filename.c_str());
MP4::Atoms atoms(&pf);
MP4::Atom *moov = atoms.find("moov");
if(!moov)
return timing;
if(const MP4::Atom *mvhd = moov->find("mvhd")) {
pf.seek(mvhd->offset());
const ByteVector data = pf.readBlock(mvhd->length());
if(data.size() >= 28 && data[8] == 0) {
timing.movieTimescale = data.toUInt(20U);
timing.movieDuration = data.toUInt(24U);
}
}
for(auto *trak : moov->findall("trak")) {
const MP4::Atom *hdlr = trak->find("mdia", "hdlr");
if(!hdlr)
continue;
pf.seek(hdlr->offset());
if(!pf.readBlock(hdlr->length()).containsAt("text", 16))
continue;
if(const MP4::Atom *mdhd = trak->find("mdia", "mdhd")) {
pf.seek(mdhd->offset());
const ByteVector data = pf.readBlock(mdhd->length());
if(data.size() >= 24 && data[8] == 0)
timing.mediaTimescale = data.toUInt(20U);
}
// edts is not parsed as a container, so its elst is read from its data.
if(const MP4::Atom *edts = trak->find("edts")) {
pf.seek(edts->offset());
const ByteVector data = pf.readBlock(edts->length());
if(data.size() >= 24 && data.containsAt("elst", 12) && data[16] == 0) {
const unsigned int count = data.toUInt(20U);
for(unsigned int i = 0; i < count && 24 + (i + 1) * 12 <= data.size(); ++i) {
const unsigned int pos = 24 + i * 12;
timing.edits.push_back({
data.toUInt(pos),
static_cast<int>(data.toUInt(pos + 4))
});
}
}
}
break;
}
return timing;
};
// First chapter after 0: an empty edit spans the gap, then the media plays
// from the first written chapter.
{
MP4::File f(filename.c_str());
f.setQtChapters(MP4::ChapterList{
MP4::Chapter("One", 1000LL),
MP4::Chapter("Two", 2000LL)
});
CPPUNIT_ASSERT(f.save());
}
{
const ChapterTrackTiming timing = chapterTrackTiming();
CPPUNIT_ASSERT(timing.movieTimescale > 0);
CPPUNIT_ASSERT(timing.mediaTimescale > 0);
CPPUNIT_ASSERT_EQUAL(static_cast<size_t>(2), timing.edits.size());
const unsigned int lead = timing.movieTimescale;
CPPUNIT_ASSERT_EQUAL(lead, timing.edits[0].segmentDuration);
CPPUNIT_ASSERT_EQUAL(-1, timing.edits[0].mediaTime);
CPPUNIT_ASSERT_EQUAL(timing.movieDuration - lead, timing.edits[1].segmentDuration);
CPPUNIT_ASSERT_EQUAL(static_cast<int>(timing.mediaTimescale), timing.edits[1].mediaTime);
}
{
MP4::File f(filename.c_str());
const MP4::ChapterList chapters = f.qtChapters();
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
CPPUNIT_ASSERT_EQUAL(String("One"), chapters[0].title());
CPPUNIT_ASSERT_EQUAL(1000LL, chapters[0].startTime());
CPPUNIT_ASSERT_EQUAL(String("Two"), chapters[1].title());
CPPUNIT_ASSERT_EQUAL(2000LL, chapters[1].startTime());
}
// First chapter at 0: a single edit plays the whole media.
{
MP4::File f(filename.c_str());
f.setQtChapters(MP4::ChapterList{
MP4::Chapter("One", 0),
MP4::Chapter("Two", 2000LL)
});
CPPUNIT_ASSERT(f.save());
}
{
const ChapterTrackTiming timing = chapterTrackTiming();
CPPUNIT_ASSERT_EQUAL(static_cast<size_t>(1), timing.edits.size());
CPPUNIT_ASSERT_EQUAL(timing.movieDuration, timing.edits[0].segmentDuration);
CPPUNIT_ASSERT_EQUAL(0, timing.edits[0].mediaTime);
}
{
MP4::File f(filename.c_str());
const MP4::ChapterList chapters = f.qtChapters();
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
CPPUNIT_ASSERT_EQUAL(String("One"), chapters[0].title());
CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime());
CPPUNIT_ASSERT_EQUAL(String("Two"), chapters[1].title());
CPPUNIT_ASSERT_EQUAL(2000LL, chapters[1].startTime());
}
}
// Regression test for the orphaned-mdat bug reported in PR #1325 by ufleisch.
// Each add/remove cycle must leave the file's mdat count unchanged. Before
// the fix, the chapter mdat appended by write() was never removed, so three