From 9c56f191e5e3b3daa5eb9e381cc27845e2a731dc Mon Sep 17 00:00:00 2001 From: Ryan Francesconi Date: Sat, 4 Apr 2026 07:30:47 -0700 Subject: [PATCH] MP4: Add Nero-style chapter marker support Implement read/write/remove of Nero-style chapter markers (chpl atom) in MP4 files. The chpl atom lives at moov/udta/chpl, storing up to 255 chapter entries with 100-nanosecond timestamps and UTF-8 titles. Includes CppUnit tests covering round-trip read/write, remove, tag preservation, and reading from files with no chapters. --- taglib/CMakeLists.txt | 2 + taglib/mp4/mp4chapterlist.cpp | 338 ++++++++++++++++++++++++++++++++++ taglib/mp4/mp4chapterlist.h | 77 ++++++++ tests/test_mp4.cpp | 161 ++++++++++++++++ 4 files changed, 578 insertions(+) create mode 100644 taglib/mp4/mp4chapterlist.cpp create mode 100644 taglib/mp4/mp4chapterlist.h diff --git a/taglib/CMakeLists.txt b/taglib/CMakeLists.txt index 9e45b261..84da534e 100644 --- a/taglib/CMakeLists.txt +++ b/taglib/CMakeLists.txt @@ -196,6 +196,7 @@ if(WITH_MP4) mp4/mp4coverart.h mp4/mp4stem.h mp4/mp4itemfactory.h + mp4/mp4chapterlist.h ) endif() if(WITH_MOD) @@ -372,6 +373,7 @@ if(WITH_MP4) mp4/mp4coverart.cpp mp4/mp4stem.cpp mp4/mp4itemfactory.cpp + mp4/mp4chapterlist.cpp ) endif() diff --git a/taglib/mp4/mp4chapterlist.cpp b/taglib/mp4/mp4chapterlist.cpp new file mode 100644 index 00000000..22184435 --- /dev/null +++ b/taglib/mp4/mp4chapterlist.cpp @@ -0,0 +1,338 @@ +/************************************************************************** + 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/ * + ***************************************************************************/ + +#include "mp4chapterlist.h" + +#include + +#include "tdebug.h" +#include "mp4file.h" +#include "mp4atom.h" + +using namespace TagLib; + +namespace +{ + // Nero chpl version 1 header: version(1) + flags(3) + reserved(4) + count(1) = 9 bytes + constexpr int chplHeaderSize = 9; + + ByteVector renderAtom(const ByteVector &name, const ByteVector &data) + { + return ByteVector::fromUInt(static_cast(data.size() + 8)) + name + data; + } + + // Update parent atom sizes along a path when child size changes by delta. + // Mirrors MP4::Tag::updateParents(). + void updateParentSizes(TagLib::File *file, const MP4::AtomList &path, + offset_t delta, int ignore = 0) + { + if(static_cast(path.size()) <= ignore) + return; + + auto itEnd = path.end(); + std::advance(itEnd, 0 - ignore); + + for(auto it = path.begin(); it != itEnd; ++it) { + file->seek((*it)->offset()); + long size = file->readBlock(4).toUInt(); + if(size == 1) { + // 64-bit size + file->seek(4, TagLib::File::Current); + long long longSize = file->readBlock(8).toLongLong(); + file->seek((*it)->offset() + 8); + file->writeBlock(ByteVector::fromLongLong(longSize + delta)); + } + else { + // 32-bit size + file->seek((*it)->offset()); + file->writeBlock(ByteVector::fromUInt(static_cast(size + delta))); + } + } + } + + // Update stco/co64/tfhd chunk offsets when file content shifts. + // Mirrors MP4::Tag::updateOffsets(). + void updateChunkOffsets(TagLib::File *file, MP4::Atoms *atoms, + offset_t delta, offset_t offset) + { + if(MP4::Atom *moov = atoms->find("moov")) { + const MP4::AtomList stco = moov->findall("stco", true); + for(const auto &atom : stco) { + if(atom->offset() > offset) + atom->addToOffset(delta); + file->seek(atom->offset() + 12); + ByteVector data = file->readBlock(atom->length() - 12); + unsigned int count = data.toUInt(); + file->seek(atom->offset() + 16); + unsigned int pos = 4; + while(count--) { + auto o = static_cast(data.toUInt(pos)); + if(o > offset) + o += delta; + file->writeBlock(ByteVector::fromUInt(static_cast(o))); + pos += 4; + } + } + + const MP4::AtomList co64 = moov->findall("co64", true); + for(const auto &atom : co64) { + if(atom->offset() > offset) + atom->addToOffset(delta); + file->seek(atom->offset() + 12); + ByteVector data = file->readBlock(atom->length() - 12); + unsigned int count = data.toUInt(); + file->seek(atom->offset() + 16); + unsigned int pos = 4; + while(count--) { + long long o = data.toLongLong(pos); + if(o > offset) + o += delta; + file->writeBlock(ByteVector::fromLongLong(o)); + pos += 8; + } + } + } + + if(MP4::Atom *moof = atoms->find("moof")) { + const MP4::AtomList tfhd = moof->findall("tfhd", true); + for(const auto &atom : tfhd) { + if(atom->offset() > offset) + atom->addToOffset(delta); + file->seek(atom->offset() + 9); + ByteVector data = file->readBlock(atom->length() - 9); + if(const unsigned int flags = data.toUInt(0, 3, true); + flags & 1) { + long long o = data.toLongLong(7U); + if(o > offset) + o += delta; + file->seek(atom->offset() + 16); + file->writeBlock(ByteVector::fromLongLong(o)); + } + } + } + } + + // Build the binary payload for a chpl atom (version 1). + ByteVector renderChplData(const MP4::ChapterList &chapters) + { + unsigned int count = std::min(static_cast(chapters.size()), 255U); + + ByteVector data; + // Version (1 byte) + flags (3 bytes) + reserved (4 bytes) + data.append(static_cast(0x01)); // version 1 + data.append(ByteVector(3, '\0')); // flags + data.append(ByteVector(4, '\0')); // reserved + + // Chapter count + data.append(static_cast(count & 0xFF)); + + unsigned int i = 0; + for(const auto &ch : chapters) { + if(i++ >= count) + break; + + // Start time: 8 bytes big-endian + data.append(ByteVector::fromLongLong(ch.startTime)); + + // Title: 1-byte length + UTF-8 bytes (max 255 bytes) + ByteVector titleBytes = ch.title.data(String::UTF8); + unsigned int titleLen = std::min(static_cast(titleBytes.size()), 255U); + data.append(static_cast(titleLen & 0xFF)); + if(titleLen > 0) + data.append(titleBytes.mid(0, titleLen)); + } + + return data; + } + + // Parse the binary content of a chpl atom into a ChapterList. + MP4::ChapterList parseChplData(const ByteVector &data) + { + MP4::ChapterList chapters; + + if(data.size() < static_cast(chplHeaderSize)) + return chapters; + + unsigned int pos = 0; + unsigned char version = static_cast(data[pos++]); + + // Skip flags (3 bytes) + pos += 3; + + // Version 1 has 4 reserved bytes + if(version >= 1) + pos += 4; + + if(pos >= data.size()) + return chapters; + + unsigned int count = static_cast(data[pos++]); + + for(unsigned int i = 0; i < count && pos + 9 <= data.size(); ++i) { + long long startTime = data.toLongLong(pos); + pos += 8; + + unsigned int titleLen = static_cast(data[pos++]); + + String title; + if(titleLen > 0 && pos + titleLen <= data.size()) { + title = String(data.mid(pos, titleLen), String::UTF8); + pos += titleLen; + } + + MP4::Chapter ch; + ch.startTime = startTime; + ch.title = title; + chapters.append(ch); + } + + return chapters; + } + +} // namespace + +//////////////////////////////////////////////////////////////////////////////// +// public members +//////////////////////////////////////////////////////////////////////////////// + +MP4::ChapterList +MP4::MP4ChapterList::read(const char *path) +{ + MP4::File file(path, false); + if(!file.isOpen() || !file.isValid()) { + debug("MP4ChapterList::read() -- Could not open file"); + return ChapterList(); + } + + Atoms atoms(&file); + + Atom *chpl = atoms.find("moov", "udta", "chpl"); + if(!chpl) + return ChapterList(); + + // Read the atom content (skip 8-byte atom header) + file.seek(chpl->offset() + 8); + ByteVector data = file.readBlock(chpl->length() - 8); + + return parseChplData(data); +} + +bool +MP4::MP4ChapterList::write(const char *path, const ChapterList &chapters) +{ + MP4::File file(path, false); + if(!file.isOpen() || !file.isValid() || file.readOnly()) { + debug("MP4ChapterList::write() -- Could not open file for writing"); + return false; + } + + Atoms atoms(&file); + + if(!atoms.find("moov")) { + debug("MP4ChapterList::write() -- No moov atom found"); + return false; + } + + ByteVector chplPayload = renderChplData(chapters); + ByteVector chplAtom = renderAtom("chpl", chplPayload); + + Atom *existingChpl = atoms.find("moov", "udta", "chpl"); + + if(existingChpl) { + // Replace existing chpl atom + offset_t offset = existingChpl->offset(); + offset_t oldLength = existingChpl->length(); + offset_t delta = static_cast(chplAtom.size()) - oldLength; + + file.insert(chplAtom, offset, oldLength); + + if(delta != 0) { + // Update parent sizes: moov and udta + AtomList parentPath = atoms.path("moov", "udta", "chpl"); + updateParentSizes(&file, parentPath, delta, 1); // ignore chpl itself + updateChunkOffsets(&file, &atoms, delta, offset); + } + } + else { + // Need to insert a new chpl atom + AtomList udtaPath = atoms.path("moov", "udta"); + + if(udtaPath.size() == 2) { + // udta exists -- insert chpl at the beginning of udta's content + offset_t insertOffset = udtaPath.back()->offset() + 8; + file.insert(chplAtom, insertOffset, 0); + + updateParentSizes(&file, udtaPath, chplAtom.size()); + updateChunkOffsets(&file, &atoms, chplAtom.size(), insertOffset); + } + else { + // No udta -- insert udta + chpl at the beginning of moov's content + ByteVector udtaAtom = renderAtom("udta", chplAtom); + + AtomList moovPath = atoms.path("moov"); + if(moovPath.isEmpty()) { + debug("MP4ChapterList::write() -- No moov atom in path"); + return false; + } + + offset_t insertOffset = moovPath.back()->offset() + 8; + file.insert(udtaAtom, insertOffset, 0); + + updateParentSizes(&file, moovPath, udtaAtom.size()); + updateChunkOffsets(&file, &atoms, udtaAtom.size(), insertOffset); + } + } + + return true; +} + +bool +MP4::MP4ChapterList::remove(const char *path) +{ + MP4::File file(path, false); + if(!file.isOpen() || !file.isValid() || file.readOnly()) { + debug("MP4ChapterList::remove() -- Could not open file for writing"); + return false; + } + + Atoms atoms(&file); + + Atom *chpl = atoms.find("moov", "udta", "chpl"); + if(!chpl) { + // No chpl atom -- nothing to remove + return true; + } + + offset_t offset = chpl->offset(); + offset_t length = chpl->length(); + + file.removeBlock(offset, length); + + // Update parent sizes with negative delta + AtomList parentPath = atoms.path("moov", "udta", "chpl"); + updateParentSizes(&file, parentPath, -length, 1); // ignore chpl itself + updateChunkOffsets(&file, &atoms, -length, offset); + + return true; +} diff --git a/taglib/mp4/mp4chapterlist.h b/taglib/mp4/mp4chapterlist.h new file mode 100644 index 00000000..37cb723f --- /dev/null +++ b/taglib/mp4/mp4chapterlist.h @@ -0,0 +1,77 @@ +/************************************************************************** + 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_MP4CHAPTERLIST_H +#define TAGLIB_MP4CHAPTERLIST_H + +#include "tlist.h" +#include "tstring.h" +#include "taglib_export.h" + +namespace TagLib { + namespace MP4 { + + /*! + * A single Nero-style chapter marker. + */ + struct TAGLIB_EXPORT Chapter { + long long startTime; //!< Start time in 100-nanosecond units + String title; + }; + + using ChapterList = List; + + /*! + * Reads, writes, and removes Nero-style chapter markers (chpl atom) + * from MP4 files. Operates independently of MP4::Tag -- the chpl atom + * lives at moov/udta/chpl, a sibling of the metadata ilst path. + */ + class TAGLIB_EXPORT MP4ChapterList + { + public: + /*! + * Reads chapter markers from the MP4 file at \a path. + * Returns an empty list if the file has no chpl atom. + */ + static ChapterList read(const char *path); + + /*! + * Writes chapter markers to the MP4 file at \a path, + * replacing any existing chpl atom. The chapter count is + * capped at 255 (Nero format limit). + * Returns \c true on success. + */ + static bool write(const char *path, const ChapterList &chapters); + + /*! + * Removes the chpl atom from the MP4 file at \a path. + * Returns \c true on success, or if no chpl atom exists. + */ + static bool remove(const char *path); + }; + + } // namespace MP4 +} // namespace TagLib + +#endif diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index 03f96ae5..4afea33f 100644 --- a/tests/test_mp4.cpp +++ b/tests/test_mp4.cpp @@ -34,6 +34,7 @@ #include "mp4atom.h" #include "mp4file.h" #include "mp4itemfactory.h" +#include "mp4chapterlist.h" #include "plainfile.h" #include #include "utils.h" @@ -102,6 +103,10 @@ class TestMP4 : public CppUnit::TestFixture CPPUNIT_TEST(testNonFullMetaAtom); CPPUNIT_TEST(testItemFactory); CPPUNIT_TEST(testNonPrintableAtom); + CPPUNIT_TEST(testChapterListWrite); + CPPUNIT_TEST(testChapterListRemove); + CPPUNIT_TEST(testChapterListWithExistingTags); + CPPUNIT_TEST(testChapterListReadEmpty); CPPUNIT_TEST_SUITE_END(); public: @@ -873,6 +878,162 @@ public: CPPUNIT_ASSERT_EQUAL(String("TITLE"), f.tag()->title()); } } + void testChapterListWrite() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // File should have no chapters initially + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + + // Write chapters + { + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Introduction"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 300000000LL; // 30 seconds in 100ns units + ch2.title = "Main Content"; + chapters.append(ch2); + + MP4::Chapter ch3; + ch3.startTime = 600000000LL; // 60 seconds + ch3.title = "Conclusion"; + chapters.append(ch3); + + CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters)); + } + + // Read back and verify + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); + CPPUNIT_ASSERT_EQUAL(String("Introduction"), chapters[0].title); + CPPUNIT_ASSERT_EQUAL(300000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(String("Main Content"), chapters[1].title); + CPPUNIT_ASSERT_EQUAL(600000000LL, chapters[2].startTime); + CPPUNIT_ASSERT_EQUAL(String("Conclusion"), chapters[2].title); + } + + // Overwrite with different chapters + { + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Part One"; + chapters.append(ch1); + + CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters)); + } + + // Verify overwrite + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(1U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(String("Part One"), chapters[0].title); + } + } + + void testChapterListRemove() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // Write chapters + { + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Chapter 1"; + chapters.append(ch1); + + CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters)); + } + + // Verify written + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(1U, chapters.size()); + } + + // Remove chapters + CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str())); + + // Verify removed + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + + // Remove from file with no chapters should also succeed + CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str())); + } + + void testChapterListWithExistingTags() + { + 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::MP4ChapterList::write(filename.c_str(), chapters)); + } + + // Verify chapters are written AND existing tags are preserved + { + MP4::ChapterList chapters = MP4::MP4ChapterList::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::MP4ChapterList::remove(filename.c_str())); + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist()); + } + } + + void testChapterListReadEmpty() + { + // Reading from a file with no chpl atom should return empty list + MP4::ChapterList chapters = MP4::MP4ChapterList::read( + TEST_FILE_PATH_C("no-tags.m4a")); + CPPUNIT_ASSERT(chapters.isEmpty()); + } }; CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4);