mirror of
https://github.com/taglib/taglib.git
synced 2026-11-06 22:48:48 -05:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user