MP4: Add QuickTime-style chapter track support

QuickTime-style chapter tracks are the native chapter format for
Apple's ecosystem. They use a disabled text track (hdlr type "text")
referenced by a chap track-reference in the audio track's tref box.
This format is recognized by QuickTime, iTunes/Music, Final Cut Pro,
Logic Pro, DaVinci Resolve, VLC, and most other MP4/M4A players. It
is also the format that AVFoundation reads natively via
AVAssetChapterMetadataGroup.

The implementation produces output that matches ffmpeg's chapter track
structure byte-for-byte: per-sample stts entries (required by
AVFoundation), encd atoms for UTF-8 text encoding, edts/elst edit
lists, gmhd with gmin+text media information, and disabled tkhd flags
(track_in_movie only).

Key behaviors:
- write() inserts tref + chapter trak as a single contiguous block,
  then appends text samples in an mdat atom at EOF
- Handles non-zero first chapter times by prepending a dummy chapter
  at time 0 (stripped on read)
- Overwrite support: removes existing chapter track before writing
- Preserves existing metadata tags and audio data integrity
- Uses timescale=1000 (milliseconds) for chapter track timing

7 new tests covering write/read round-trip, remove, overwrite, tag
preservation, empty file read, timestamp precision, and non-zero
first chapter handling.
This commit is contained in:
Ryan Francesconi
2026-04-04 07:31:03 -07:00
parent 9c56f191e5
commit 4a73d73b20
4 changed files with 1572 additions and 0 deletions

View File

@@ -197,6 +197,7 @@ if(WITH_MP4)
mp4/mp4stem.h
mp4/mp4itemfactory.h
mp4/mp4chapterlist.h
mp4/mp4qtchapterlist.h
)
endif()
if(WITH_MOD)
@@ -374,6 +375,7 @@ if(WITH_MP4)
mp4/mp4stem.cpp
mp4/mp4itemfactory.cpp
mp4/mp4chapterlist.cpp
mp4/mp4qtchapterlist.cpp
)
endif()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
/**************************************************************************
copyright : (C) 2026 by Ryan Francesconi
**************************************************************************/
/***************************************************************************
* This library is free software; you can redistribute it and/or modify *
* it under the terms of the GNU Lesser General Public License version *
* 2.1 as published by the Free Software Foundation. *
* *
* This library is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with this library; if not, write to the Free Software *
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
* 02110-1301 USA *
* *
* Alternatively, this file is available under the Mozilla Public *
* License Version 1.1. You may obtain a copy of the License at *
* http://www.mozilla.org/MPL/ *
***************************************************************************/
#ifndef TAGLIB_MP4QTCHAPTERLIST_H
#define TAGLIB_MP4QTCHAPTERLIST_H
#include "mp4chapterlist.h"
namespace TagLib {
namespace MP4 {
/*!
* Reads, writes, and removes QuickTime-style chapter tracks from MP4
* files. A QT chapter track is a disabled text track (\c hdlr type
* \c "text") referenced by a \c chap track-reference in the audio
* track's \c tref box. This format is understood by QuickTime,
* iTunes, Final Cut, Logic, DaVinci Resolve, Twisted Wave, and most
* other Apple/macOS software.
*
* The existing \c MP4ChapterList class handles Nero-style \c chpl
* atoms, which are a different (and less widely supported) chapter
* format.
*
* Chapter times use the same 100-nanosecond unit convention as
* \c MP4ChapterList so that existing \c Chapter / \c ChapterList
* types can be shared.
*/
class TAGLIB_EXPORT MP4QTChapterList
{
public:
/*!
* Reads chapter markers from the QuickTime chapter track in the
* MP4 file at \a path. Returns an empty list if the file has no
* chapter track (i.e. no \c tref/chap reference to a text track).
*/
static ChapterList read(const char *path);
/*!
* Writes chapter markers as a QuickTime chapter track to the MP4
* file at \a path, replacing any existing chapter track. The
* file's duration is read internally from the movie header.
* Returns \c true on success.
*/
static bool write(const char *path, const ChapterList &chapters);
/*!
* Removes the QuickTime chapter track and its \c tref/chap
* reference from the MP4 file at \a path.
* Returns \c true on success, or if no chapter track exists.
*/
static bool remove(const char *path);
};
} // namespace MP4
} // namespace TagLib
#endif

View File

@@ -35,6 +35,7 @@
#include "mp4file.h"
#include "mp4itemfactory.h"
#include "mp4chapterlist.h"
#include "mp4qtchapterlist.h"
#include "plainfile.h"
#include <cppunit/extensions/HelperMacros.h>
#include "utils.h"
@@ -107,6 +108,13 @@ class TestMP4 : public CppUnit::TestFixture
CPPUNIT_TEST(testChapterListRemove);
CPPUNIT_TEST(testChapterListWithExistingTags);
CPPUNIT_TEST(testChapterListReadEmpty);
CPPUNIT_TEST(testQTChapterListWrite);
CPPUNIT_TEST(testQTChapterListRemove);
CPPUNIT_TEST(testQTChapterListWithExistingTags);
CPPUNIT_TEST(testQTChapterListReadEmpty);
CPPUNIT_TEST(testQTChapterListOverwrite);
CPPUNIT_TEST(testQTChapterListTimestampPrecision);
CPPUNIT_TEST(testQTChapterListNonZeroFirstChapter);
CPPUNIT_TEST_SUITE_END();
public:
@@ -1034,6 +1042,277 @@ public:
TEST_FILE_PATH_C("no-tags.m4a"));
CPPUNIT_ASSERT(chapters.isEmpty());
}
void testQTChapterListWrite()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// File should have no QT chapters initially
{
MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str());
CPPUNIT_ASSERT(chapters.isEmpty());
}
// Write chapters (times in 100-nanosecond units)
{
MP4::ChapterList chapters;
MP4::Chapter ch1;
ch1.startTime = 0;
ch1.title = "Intro";
chapters.append(ch1);
MP4::Chapter ch2;
ch2.startTime = 150000000LL; // 15 seconds
ch2.title = "Verse";
chapters.append(ch2);
MP4::Chapter ch3;
ch3.startTime = 300000000LL; // 30 seconds
ch3.title = "Outro";
chapters.append(ch3);
CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters));
}
// Read back and verify
{
MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str());
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime);
CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title);
CPPUNIT_ASSERT_EQUAL(150000000LL, chapters[1].startTime);
CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title);
CPPUNIT_ASSERT_EQUAL(300000000LL, chapters[2].startTime);
CPPUNIT_ASSERT_EQUAL(String("Outro"), chapters[2].title);
}
}
void testQTChapterListRemove()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// Write chapters first
{
MP4::ChapterList chapters;
MP4::Chapter ch1;
ch1.startTime = 0;
ch1.title = "Chapter 1";
chapters.append(ch1);
MP4::Chapter ch2;
ch2.startTime = 100000000LL; // 10 seconds
ch2.title = "Chapter 2";
chapters.append(ch2);
CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters));
}
// Verify written
{
MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str());
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
}
// Remove chapters
CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(filename.c_str()));
// Verify removed
{
MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str());
CPPUNIT_ASSERT(chapters.isEmpty());
}
// Remove from file with no chapters should also succeed
CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(filename.c_str()));
}
void testQTChapterListWithExistingTags()
{
ScopedFileCopy copy("has-tags", ".m4a");
string filename = copy.fileName();
// File has existing tags -- verify they survive chapter operations
String originalArtist;
{
MP4::File f(filename.c_str());
CPPUNIT_ASSERT(f.isValid());
originalArtist = f.tag()->artist();
CPPUNIT_ASSERT(!originalArtist.isEmpty());
}
// Write chapters
{
MP4::ChapterList chapters;
MP4::Chapter ch1;
ch1.startTime = 0;
ch1.title = "Intro";
chapters.append(ch1);
MP4::Chapter ch2;
ch2.startTime = 100000000LL; // 10 seconds
ch2.title = "Verse";
chapters.append(ch2);
CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters));
}
// Verify chapters are written AND existing tags are preserved
{
MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str());
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title);
CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title);
MP4::File f(filename.c_str());
CPPUNIT_ASSERT(f.isValid());
CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist());
}
// Remove chapters and verify tags still survive
CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(filename.c_str()));
{
MP4::File f(filename.c_str());
CPPUNIT_ASSERT(f.isValid());
CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist());
}
}
void testQTChapterListReadEmpty()
{
// Reading from a file with no chapter track should return empty list
MP4::ChapterList chapters = MP4::MP4QTChapterList::read(
TEST_FILE_PATH_C("no-tags.m4a"));
CPPUNIT_ASSERT(chapters.isEmpty());
}
void testQTChapterListOverwrite()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// Write initial chapters
{
MP4::ChapterList chapters;
MP4::Chapter ch1;
ch1.startTime = 0;
ch1.title = "Old1";
chapters.append(ch1);
MP4::Chapter ch2;
ch2.startTime = 50000000LL; // 5 seconds
ch2.title = "Old2";
chapters.append(ch2);
CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters));
}
// Verify initial
{
MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str());
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
}
// Overwrite with different chapters
{
MP4::ChapterList chapters;
MP4::Chapter ch1;
ch1.startTime = 0;
ch1.title = "New1";
chapters.append(ch1);
MP4::Chapter ch2;
ch2.startTime = 100000000LL; // 10 seconds
ch2.title = "New2";
chapters.append(ch2);
MP4::Chapter ch3;
ch3.startTime = 200000000LL; // 20 seconds
ch3.title = "New3";
chapters.append(ch3);
CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters));
}
// Verify overwrite
{
MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str());
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
CPPUNIT_ASSERT_EQUAL(String("New1"), chapters[0].title);
CPPUNIT_ASSERT_EQUAL(String("New2"), chapters[1].title);
CPPUNIT_ASSERT_EQUAL(String("New3"), chapters[2].title);
}
}
void testQTChapterListTimestampPrecision()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// Write chapters at precise times
{
MP4::ChapterList chapters;
MP4::Chapter ch1;
ch1.startTime = 0;
ch1.title = "Start";
chapters.append(ch1);
MP4::Chapter ch2;
ch2.startTime = 15000000LL; // 1.5 seconds in 100ns units
ch2.title = "Precise";
chapters.append(ch2);
CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters));
}
// Read back and verify timestamps
{
MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str());
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime);
CPPUNIT_ASSERT_EQUAL(15000000LL, chapters[1].startTime);
}
}
void testQTChapterListNonZeroFirstChapter()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// Write chapters where first chapter is NOT at time 0
{
MP4::ChapterList chapters;
MP4::Chapter ch1;
ch1.startTime = 100000000LL; // 10 seconds
ch1.title = "One";
chapters.append(ch1);
MP4::Chapter ch2;
ch2.startTime = 200000000LL; // 20 seconds
ch2.title = "Two";
chapters.append(ch2);
MP4::Chapter ch3;
ch3.startTime = 300000000LL; // 30 seconds
ch3.title = "Three";
chapters.append(ch3);
CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters));
}
// Read back -- dummy chapter at time 0 should be stripped
{
MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str());
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
CPPUNIT_ASSERT_EQUAL(100000000LL, chapters[0].startTime);
CPPUNIT_ASSERT_EQUAL(200000000LL, chapters[1].startTime);
CPPUNIT_ASSERT_EQUAL(300000000LL, chapters[2].startTime);
CPPUNIT_ASSERT_EQUAL(String("One"), chapters[0].title);
CPPUNIT_ASSERT_EQUAL(String("Two"), chapters[1].title);
CPPUNIT_ASSERT_EQUAL(String("Three"), chapters[2].title);
}
}
};
CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4);