diff --git a/taglib/CMakeLists.txt b/taglib/CMakeLists.txt index 9e45b261..4c8e5332 100644 --- a/taglib/CMakeLists.txt +++ b/taglib/CMakeLists.txt @@ -196,6 +196,10 @@ if(WITH_MP4) mp4/mp4coverart.h mp4/mp4stem.h mp4/mp4itemfactory.h + mp4/mp4chapter.h + mp4/mp4chapterholder.h + mp4/mp4nerochapterlist.h + mp4/mp4qtchapterlist.h ) endif() if(WITH_MOD) @@ -372,6 +376,9 @@ if(WITH_MP4) mp4/mp4coverart.cpp mp4/mp4stem.cpp mp4/mp4itemfactory.cpp + mp4/mp4chapter.cpp + mp4/mp4nerochapterlist.cpp + mp4/mp4qtchapterlist.cpp ) endif() diff --git a/taglib/mp4/mp4chapter.cpp b/taglib/mp4/mp4chapter.cpp new file mode 100644 index 00000000..2222748f --- /dev/null +++ b/taglib/mp4/mp4chapter.cpp @@ -0,0 +1,89 @@ +/************************************************************************** + 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 "mp4chapter.h" +#include "tstring.h" + +using namespace TagLib; + +class MP4::Chapter::ChapterPrivate +{ +public: + ChapterPrivate() = default; + ~ChapterPrivate() = default; + String title; + long long startTime {0}; +}; + +MP4::Chapter::Chapter(const String &title, long long startTime) : + d(std::make_unique()) +{ + d->title = title; + d->startTime = startTime; +} + +MP4::Chapter::Chapter(const Chapter &other) : + d(std::make_unique(*other.d)) +{ +} + +MP4::Chapter::Chapter(Chapter &&other) noexcept = default; + +MP4::Chapter::Chapter::~Chapter() = default; + +MP4::Chapter &MP4::Chapter::Chapter::operator=(const Chapter &other) +{ + Chapter(other).swap(*this); + return *this; +} + +MP4::Chapter &MP4::Chapter::Chapter::operator=( + Chapter &&other) noexcept = default; + +bool MP4::Chapter::operator==(const Chapter &other) const +{ + return title() == other.title() && startTime() == other.startTime(); +} + +bool MP4::Chapter::operator!=(const Chapter &other) const +{ + return !(*this == other); +} + +void MP4::Chapter::swap(Chapter &other) noexcept +{ + using std::swap; + + swap(d, other.d); +} + +const String &MP4::Chapter::title() const +{ + return d->title; +} + +long long MP4::Chapter::startTime() const +{ + return d->startTime; +} diff --git a/taglib/mp4/mp4chapter.h b/taglib/mp4/mp4chapter.h new file mode 100644 index 00000000..839f75d8 --- /dev/null +++ b/taglib/mp4/mp4chapter.h @@ -0,0 +1,108 @@ +/************************************************************************** + 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_MP4CHAPTER_H +#define TAGLIB_MP4CHAPTER_H + +#include +#include "taglib_export.h" +#include "tlist.h" + +namespace TagLib { + class String; + namespace MP4 { + + /*! + * A single Nero-style chapter marker. + */ + class TAGLIB_EXPORT Chapter { + public: + /*! + * Construct a chapter. + */ + Chapter(const String &title, long long startTime); + + /*! + * Construct a chapter as a copy of \a other. + */ + Chapter(const Chapter &other); + + /*! + * Construct a chapter moving from \a other. + */ + Chapter(Chapter &&other) noexcept; + + /*! + * Destroys this chapter. + */ + ~Chapter(); + + /*! + * Copies the contents of \a other into this object. + */ + Chapter &operator=(const Chapter &other); + + /*! + * Moves the contents of \a other into this object. + */ + Chapter &operator=(Chapter &&other) noexcept; + + /*! + * Returns \c true if the chapter and \a other contain the same data. + */ + bool operator==(const Chapter &other) const; + + /*! + * Returns \c true if the chapter and \a other differ in data. + */ + bool operator!=(const Chapter &other) const; + + /*! + * Exchanges the content of the object with the content of \a other. + */ + void swap(Chapter &other) noexcept; + + /*! + * Returns the title representing the chapter. + */ + const String &title() const; + + /*! + * Returns the start time in milliseconds. + */ + long long startTime() const; + + private: + class ChapterPrivate; + TAGLIB_MSVC_SUPPRESS_WARNING_NEEDS_TO_HAVE_DLL_INTERFACE + std::unique_ptr d; + }; + + //! List of chapters. + using ChapterList = List; + + } // namespace MP4 +} // namespace TagLib + +#endif diff --git a/taglib/mp4/mp4chapterholder.h b/taglib/mp4/mp4chapterholder.h new file mode 100644 index 00000000..72752ba4 --- /dev/null +++ b/taglib/mp4/mp4chapterholder.h @@ -0,0 +1,126 @@ +/************************************************************************** + copyright : (C) 2006 by Urs Fleisch + email : ufleisch@users.sourceforge.net + **************************************************************************/ + +/*************************************************************************** + * 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_MP4CHAPTERHOLDER_H +#define TAGLIB_MP4CHAPTERHOLDER_H + +#include "mp4chapter.h" + +namespace TagLib { + class File; + namespace MP4 { + /*! + * Base class to hold chapters and store modified state. + */ + class ChapterHolder { + public: + /*! + * Get list of chapters. + */ + ChapterList chapters() const { return chapterList; } + + /*! + * Set list of chapters. + */ + void setChapters(const ChapterList &chapters) { chapterList = chapters; } + + /*! + * Returns \c true if the list of chapters has been modified. + */ + bool isModified() const { return modified; } + + /*! + * Set if the contained chapters are modified. + */ + void setModified(bool chaptersModified) { modified = chaptersModified; } + + protected: + ChapterList chapterList; + bool modified = false; + }; + + /*! + * Lazily fetch list of chapters. + * @tparam T class derived from ChapterHolder and implementing read(File *) + * @param holder unique pointer to holder, initially null + * @param file file with chapters + * @return list of chapters, empty if no chapters found. + */ + template + ChapterList getChaptersLazy(std::unique_ptr &holder, TagLib::File *file) + { + if (!holder) { + holder = std::make_unique(); + holder->read(file); + } + return holder->chapters(); + } + + /*! + * Lazily set a list of chapters. + * @tparam T class derived from ChapterHolder + * @param holder unique pointer to holder, initially null + * @param chapters list of chapters to set + */ + template + void setChaptersLazy(std::unique_ptr &holder, const ChapterList& chapters) + { + if (!holder) { + holder = std::make_unique(); + // The chapters have not been read before, so we do not know their + // current state and mark them as modified. Otherwise, the check below + // would not set the chapters if they are empty. + holder->setModified(true); + } + if(holder->isModified() || holder->chapters() != chapters) { + holder->setChapters(chapters); + holder->setModified(true); + } + } + + /*! + * Save a list of chapters if it has been modified. + * @tparam T class derived from ChapterHolder and implementing write(File *) + * @param holder unique pointer to holder, initially null + * @param file file with chapters + * @return true if write successful or not modified. + */ + template + bool saveChaptersIfModified(std::unique_ptr &holder, TagLib::File *file) + { + if(holder && holder->isModified()) { + if(holder->write(file)) { + holder->setModified(false); + return true; + } + return false; + } + return true; + } + + } // namespace MP4 +} // namespace TagLib + +#endif diff --git a/taglib/mp4/mp4file.cpp b/taglib/mp4/mp4file.cpp index cf760be2..5f8e5bec 100644 --- a/taglib/mp4/mp4file.cpp +++ b/taglib/mp4/mp4file.cpp @@ -30,6 +30,8 @@ #include "tagutils.h" #include "mp4itemfactory.h" +#include "mp4nerochapterlist.h" +#include "mp4qtchapterlist.h" using namespace TagLib; @@ -48,6 +50,8 @@ public: std::unique_ptr tag; std::unique_ptr atoms; std::unique_ptr properties; + std::unique_ptr neroChapterList; + std::unique_ptr qtChapterList; }; //////////////////////////////////////////////////////////////////////////////// @@ -111,6 +115,26 @@ MP4::Properties *MP4::File::audioProperties() const return d->properties.get(); } +MP4::ChapterList MP4::File::neroChapters() +{ + return getChaptersLazy(d->neroChapterList, this); +} + +void MP4::File::setNeroChapters(const ChapterList& chapters) +{ + setChaptersLazy(d->neroChapterList, chapters); +} + +MP4::ChapterList MP4::File::qtChapters() +{ + return getChaptersLazy(d->qtChapterList, this); +} + +void MP4::File::setQtChapters(const ChapterList& chapters) +{ + setChaptersLazy(d->qtChapterList, chapters); +} + void MP4::File::read(bool readProperties) { @@ -148,7 +172,9 @@ MP4::File::save() return false; } - return d->tag->save(); + return d->tag->save() && + saveChaptersIfModified(d->neroChapterList, this) && + saveChaptersIfModified(d->qtChapterList, this); } bool diff --git a/taglib/mp4/mp4file.h b/taglib/mp4/mp4file.h index faf215e4..ba5ce608 100644 --- a/taglib/mp4/mp4file.h +++ b/taglib/mp4/mp4file.h @@ -31,6 +31,7 @@ #include "mp4tag.h" #include "tag.h" #include "mp4properties.h" +#include "mp4chapter.h" namespace TagLib { //! An implementation of MP4 (AAC, ALAC, ...) metadata @@ -130,6 +131,26 @@ namespace TagLib { */ Properties *audioProperties() const override; + /*! + * Returns the Nero style chapters for this file. + */ + ChapterList neroChapters(); + + /*! + * Sets the Nero style chapters for this file. + */ + void setNeroChapters(const ChapterList &chapters); + + /*! + * Returns the QuickTime chapters for this file. + */ + ChapterList qtChapters(); + + /*! + * Sets the QuickTime style chapters for this file. + */ + void setQtChapters(const ChapterList &chapters); + /*! * Save the file. * diff --git a/taglib/mp4/mp4nerochapterlist.cpp b/taglib/mp4/mp4nerochapterlist.cpp new file mode 100644 index 00000000..644e96b1 --- /dev/null +++ b/taglib/mp4/mp4nerochapterlist.cpp @@ -0,0 +1,320 @@ +/************************************************************************** + 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 "mp4nerochapterlist.h" + +#include + +#include "tdebug.h" +#include "mp4file.h" +#include "mp4atom.h" + +using namespace TagLib; + +namespace +{ + ByteVector renderAtom(const ByteVector &name, const ByteVector &data) + { + return ByteVector::fromUInt(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()); + if(const long size = file->readBlock(4).toUInt(); size == 1) { + // 64-bit size + file->seek(4, TagLib::File::Current); + const 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, const MP4::Atoms *atoms, + offset_t delta, offset_t offset) + { + if(const 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; + const unsigned int maxPos = data.size() - 4; + while(count-- && pos <= maxPos) { + 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; + const unsigned int maxPos = data.size() - 8; + while(count-- && pos <= maxPos) { + long long o = data.toLongLong(pos); + if(o > offset) + o += delta; + file->writeBlock(ByteVector::fromLongLong(o)); + pos += 8; + } + } + } + + if(const 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) + { + const unsigned int count = std::min(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, on-disk format is 100-nanosecond units + data.append(ByteVector::fromLongLong(ch.startTime() * 10000LL)); + + // Title: 1-byte length + UTF-8 bytes (max 255 bytes) + ByteVector titleBytes = ch.title().data(String::UTF8); + const unsigned int titleLen = std::min(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; + + // Minimum: version(1) + flags(3) + count(1) = 5 bytes (version 0 layout) + if(data.size() < 5) + return chapters; + + unsigned int pos = 0; + const auto 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; + + const unsigned int count = static_cast(data[pos++]); + + for(unsigned int i = 0; i < count && pos + 9 <= data.size(); ++i) { + const long long startTime100ns = data.toLongLong(pos); + pos += 8; + + const 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; + } + + chapters.append(MP4::Chapter(title, startTime100ns / 10000LL)); + } + + return chapters; + } + +} // namespace + +//////////////////////////////////////////////////////////////////////////////// +// public members +//////////////////////////////////////////////////////////////////////////////// + +bool MP4::NeroChapterList::read(TagLib::File *file) +{ + const Atoms atoms(file); + + const Atom *chpl = atoms.find("moov", "udta", "chpl"); + modified = false; + chapterList.clear(); + if(chpl) { + // Read the atom content (skip 8-byte atom header) + file->seek(chpl->offset() + 8); + const ByteVector data = file->readBlock(chpl->length() - 8); + + chapterList = parseChplData(data); + return true; + } + return false; +} + +bool MP4::NeroChapterList::write(TagLib::File *file) +{ + // Writing an empty list is equivalent to removing the chapters. + if(chapterList.isEmpty()) + return remove(file); + + const Atoms atoms(file); + + if(!atoms.find("moov")) { + debug("MP4ChapterList::write() -- No moov atom found"); + return false; + } + + const ByteVector chplPayload = renderChplData(chapterList); + const ByteVector chplAtom = renderAtom("chpl", chplPayload); + + if(const Atom *existingChpl = atoms.find("moov", "udta", "chpl")) { + // Replace existing chpl atom + const offset_t offset = existingChpl->offset(); + const offset_t oldLength = existingChpl->length(); + const offset_t delta = static_cast(chplAtom.size()) - oldLength; + + file->insert(chplAtom, offset, oldLength); + + if(delta != 0) { + // Update parent sizes: moov and udta + const 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 + + if(AtomList udtaPath = atoms.path("moov", "udta"); udtaPath.size() == 2) { + // udta exists -- insert chpl at the beginning of udta's content + const 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 + const ByteVector udtaAtom = renderAtom("udta", chplAtom); + + AtomList moovPath = atoms.path("moov"); + if(moovPath.isEmpty()) { + debug("MP4ChapterList::write() -- No moov atom in path"); + return false; + } + + const offset_t insertOffset = moovPath.back()->offset() + 8; + file->insert(udtaAtom, insertOffset, 0); + + updateParentSizes(file, moovPath, udtaAtom.size()); + updateChunkOffsets(file, &atoms, udtaAtom.size(), insertOffset); + } + } + + modified = false; + return true; +} + +bool MP4::NeroChapterList::remove(TagLib::File *file) +{ + const Atoms atoms(file); + chapterList.clear(); + modified = false; + + const Atom *chpl = atoms.find("moov", "udta", "chpl"); + if(!chpl) { + // No chpl atom -- nothing to remove + return true; + } + + const offset_t offset = chpl->offset(); + const offset_t length = chpl->length(); + + file->removeBlock(offset, length); + + // Update parent sizes with negative delta + const 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/mp4nerochapterlist.h b/taglib/mp4/mp4nerochapterlist.h new file mode 100644 index 00000000..3d2ac4c2 --- /dev/null +++ b/taglib/mp4/mp4nerochapterlist.h @@ -0,0 +1,66 @@ +/************************************************************************** + 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 "mp4chapterholder.h" + +namespace TagLib { + class File; + namespace MP4 { + + /*! + * 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 NeroChapterList : public ChapterHolder + { + public: + /*! + * Reads chapter markers from the already-opened \a file. + * Returns \c false if the file has no chpl atom. + */ + bool read(TagLib::File *file); + + /*! + * Writes chapter markers to the already-opened \a file, + * replacing any existing chpl atom. + * The chapter count is capped at 255 (Nero format limit). + * Returns \c true on success. + */ + bool write(TagLib::File *file); + + /*! + * Removes the chpl atom from the already-opened \a file. + * Returns \c true on success, or if no chpl atom exists. + */ + bool remove(TagLib::File *file); + }; + + } // namespace MP4 +} // namespace TagLib + +#endif diff --git a/taglib/mp4/mp4qtchapterlist.cpp b/taglib/mp4/mp4qtchapterlist.cpp new file mode 100644 index 00000000..93d2abd3 --- /dev/null +++ b/taglib/mp4/mp4qtchapterlist.cpp @@ -0,0 +1,1307 @@ +/************************************************************************** + 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 "mp4qtchapterlist.h" + +#include +#include + +#include "tdebug.h" +#include "mp4file.h" +#include "mp4atom.h" + +using namespace TagLib; + +namespace +{ + + // -- Atom building helpers ------------------------------------------------ + + ByteVector renderAtom(const ByteVector &name, const ByteVector &data) + { + return ByteVector::fromUInt(data.size() + 8) + name + data; + } + + //! Build a full-box (version + flags) atom. + ByteVector renderFullBox(const ByteVector &name, unsigned char version, + unsigned int flags, const ByteVector &data) + { + ByteVector vf; + vf.append(static_cast(version)); + vf.append(ByteVector::fromUInt(flags).mid(1, 3)); // 3 bytes of flags + vf.append(data); + return renderAtom(name, vf); + } + + // -- Parent / offset fixup (mirrors mp4chapterlist.cpp) ------------------- + + 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()); + if(const long size = file->readBlock(4).toUInt(); size == 1) { + file->seek(4, TagLib::File::Current); + const long long longSize = file->readBlock(8).toLongLong(); + file->seek((*it)->offset() + 8); + file->writeBlock(ByteVector::fromLongLong(longSize + delta)); + } + else { + file->seek((*it)->offset()); + file->writeBlock(ByteVector::fromUInt(static_cast(size + delta))); + } + } + } + + void updateChunkOffsets(TagLib::File *file, const MP4::Atoms *atoms, + offset_t delta, offset_t offset) + { + if(const 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; + const unsigned int maxPos = data.size() - 4; + while(count-- && pos <= maxPos) { + 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; + const unsigned int maxPos = data.size() - 8; + while(count-- && pos <= maxPos) { + long long o = data.toLongLong(pos); + if(o > offset) + o += delta; + file->writeBlock(ByteVector::fromLongLong(o)); + pos += 8; + } + } + } + + if(const 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)); + } + } + } + } + + // -- Duration reading ----------------------------------------------------- + + //! Movie-level header info from mvhd. + struct MovieInfo { + unsigned int timescale = 0; + unsigned int duration = 0; // in mvhd timescale units + long long durationMs = 0; // converted to milliseconds + }; + + //! Reads movie-level info from mvhd. + MovieInfo readMovieInfo(TagLib::File *file, const MP4::Atoms *atoms) + { + MovieInfo info; + MP4::Atom *moov = atoms->find("moov"); + if(!moov) + return info; + + const MP4::Atom *mvhd = moov->find("mvhd"); + if(!mvhd) + return info; + + file->seek(mvhd->offset()); + ByteVector data = file->readBlock(mvhd->length()); + if(data.size() < 8 + 4) + return info; + + const auto version = static_cast(data[8]); + long long timescale, duration; + if(version == 1 && data.size() >= 8 + 28) { + timescale = data.toUInt(28U); + duration = data.toLongLong(32U); + } + else if(data.size() >= 8 + 16 + 4) { + timescale = data.toUInt(20U); + duration = data.toUInt(24U); + } + else { + return info; + } + + if(timescale > 0 && duration > 0) { + info.timescale = static_cast(timescale); + info.duration = static_cast(duration); + info.durationMs = static_cast( + static_cast(duration) * 1000.0 / static_cast(timescale) + 0.5); + } + return info; + } + + // -- Audio track helpers -------------------------------------------------- + + struct TrackInfo { + MP4::Atom *trak = nullptr; + unsigned int trackId = 0; + }; + + //! Finds the first audio track (hdlr handler_type == "soun"). + TrackInfo findAudioTrack(TagLib::File *file, const MP4::Atoms *atoms) + { + TrackInfo info; + const MP4::Atom *moov = atoms->find("moov"); + if(!moov) + return info; + + const MP4::AtomList trakList = moov->findall("trak"); + for(const auto &trak : trakList) { + const MP4::Atom *hdlr = trak->find("mdia", "hdlr"); + if(!hdlr) + continue; + file->seek(hdlr->offset()); + // handler_type is at offset 16 from atom start (8 header + 4 version/flags + 4 pre_defined) + if(ByteVector data = file->readBlock(hdlr->length()); + data.containsAt("soun", 16)) { + info.trak = trak; + // Read track_id from tkhd + if(const MP4::Atom *tkhd = trak->find("tkhd")) { + file->seek(tkhd->offset()); + ByteVector tkhdData = file->readBlock(tkhd->length()); + if(const auto version = static_cast(tkhdData[8]); + version == 1 && tkhdData.size() >= 8 + 20 + 4) { + info.trackId = tkhdData.toUInt(28U); + } + else if(tkhdData.size() >= 8 + 12 + 4) { + info.trackId = tkhdData.toUInt(20U); + } + } + return info; + } + } + return info; + } + + //! Reads the next_track_ID from mvhd. + unsigned int getNextTrackId(TagLib::File *file, const MP4::Atoms *atoms) + { + MP4::Atom *moov = atoms->find("moov"); + if(!moov) return 0; + + const MP4::Atom *mvhd = moov->find("mvhd"); + if(!mvhd) return 0; + + file->seek(mvhd->offset()); + ByteVector data = file->readBlock(mvhd->length()); + const auto version = static_cast(data[8]); + + // next_track_ID is the last 4 bytes of mvhd + // version 0: header(8) + version/flags(4) + creation(4) + modification(4) + // + timescale(4) + duration(4) + ... total fixed = 108 bytes + // version 1: header(8) + version/flags(4) + creation(8) + modification(8) + // + timescale(4) + duration(8) + ... total fixed = 120 bytes + if(const unsigned int nextTrackIdOffset = version == 1 ? 120 - 4 : 108 - 4; + data.size() >= nextTrackIdOffset + 4) + return data.toUInt(nextTrackIdOffset); + + return 0; + } + + //! Writes next_track_ID in mvhd. + void setNextTrackId(TagLib::File *file, const MP4::Atoms *atoms, unsigned int newId) + { + MP4::Atom *moov = atoms->find("moov"); + if(!moov) return; + + const MP4::Atom *mvhd = moov->find("mvhd"); + if(!mvhd) return; + + file->seek(mvhd->offset()); + ByteVector data = file->readBlock(mvhd->length()); + const auto version = static_cast(data[8]); + + if(const unsigned int nextTrackIdOffset = version == 1 ? 120 - 4 : 108 - 4; + data.size() >= nextTrackIdOffset + 4) { + file->seek(mvhd->offset() + nextTrackIdOffset); + file->writeBlock(ByteVector::fromUInt(newId)); + } + } + + // -- Chapter track finder ------------------------------------------------- + + //! Finds an existing chapter track by scanning for tref/chap in the audio track. + //! tref is NOT in TagLib's container list, so we read it manually. + MP4::Atom *findChapterTrak(TagLib::File *file, const MP4::Atoms *atoms, + const MP4::Atom *audioTrak) + { + if(!audioTrak) + return nullptr; + + const MP4::Atom *moov = atoms->find("moov"); + if(!moov) + return nullptr; + + for(const auto &child : audioTrak->children()) { + if(child->name() == "tref") { + file->seek(child->offset() + 8); + const offset_t trefEnd = child->offset() + child->length(); + + while(file->tell() + 8 <= trefEnd) { + const offset_t boxStart = file->tell(); + ByteVector header = file->readBlock(8); + if(header.size() < 8) + break; + + const unsigned int boxSize = header.toUInt(); + if(boxSize < 8) + break; + + if(ByteVector boxName = header.mid(4, 4); + boxName == "chap" && boxSize >= 12) { + ByteVector refData = file->readBlock(boxSize - 8); + const unsigned int refTrackId = refData.toUInt(); + + const MP4::AtomList allTraks = moov->findall("trak"); + + for(const auto &t : allTraks) { + const MP4::Atom *tkhd = t->find("tkhd"); + if(!tkhd) + continue; + + file->seek(tkhd->offset()); + ByteVector tkhdData = file->readBlock(tkhd->length()); + if(tkhdData.size() < 24) + continue; + + const auto version = static_cast(tkhdData[8]); + unsigned int tid; + if(version == 1 && tkhdData.size() >= 32) { + tid = tkhdData.toUInt(28U); + } + else { + tid = tkhdData.toUInt(20U); + } + + if(tid == refTrackId) + return t; + } + } + + file->seek(boxStart + boxSize); + } + } + } + + return nullptr; + } + + // -- Text sample building ------------------------------------------------- + + //! Size of the 'encd' (encoding) atom appended to each text sample. + //! encd declares UTF-8 encoding: size(4) + "encd"(4) + 0x0100(4) = 12 bytes. + constexpr unsigned int encdAtomSize = 12; + + //! Builds a single text sample: 2-byte big-endian length + UTF-8 text + encd atom. + ByteVector buildTextSample(const String &title) + { + const ByteVector utf8 = title.data(String::UTF8); + const unsigned int textLen = utf8.size(); + + ByteVector sample; + sample.append(ByteVector::fromShort(static_cast(textLen))); + if(textLen > 0) + sample.append(utf8); + + // Append encd atom (encoding declaration: UTF-8) immediately after text. + // No padding -- AVFoundation expects encd to follow directly after the text. + ByteVector encdData; + encdData.append(ByteVector::fromShort(0)); // padding + encdData.append(ByteVector::fromShort(0x0100)); // UTF-8 encoding + sample.append(renderAtom("encd", encdData)); + + return sample; + } + + //! Calculate the actual size of each chapter text sample. + //! Each sample is: 2-byte length prefix + UTF-8 text + 12-byte encd atom. + //! Returns a vector of per-sample sizes (no padding, sizes may differ). + std::vector calculateSampleSizes(const MP4::ChapterList &chapters) + { + std::vector sizes; + for(const auto &ch : chapters) { + const unsigned int textLen = ch.title().data(String::UTF8).size(); + sizes.push_back(2 + textLen + encdAtomSize); + } + return sizes; + } + + // -- stbl atom builders --------------------------------------------------- + + //! stsd: text sample description (required for QT text tracks) + ByteVector buildStsd() + { + // QT text sample entry matching ffmpeg's chapter track output byte-for-byte. + // Entry body (after 8-byte size+"text" header) is 51 bytes: + // reserved(6) + dref_index(2) + display_flags(4) + justification(4) + // + bgColor(6) + textBox(8) + fontID(2) + style_flags(1) + font_size(1) + // + text_color_RGBA(4) + ftab_atom(13) + // + // Hardcoded from ffmpeg's known-good output to avoid subtle field-size + // mismatches with the under-documented QT text sample entry format. + const unsigned char entryBody[] = { + // reserved (6 bytes) + data reference index (2 bytes) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + // display flags (4) = 1 + 0x00, 0x00, 0x00, 0x01, + // text justification (4) = 0 + 0x00, 0x00, 0x00, 0x00, + // background color RGB (6 bytes, QT text format: 2 bytes per channel) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // default text box: top(2), left(2), bottom(2), right(2) = all zero + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // font ID (2) = 1 + 0x00, 0x01, + // font style flags(1) + font size(1) + text color RGBA(4) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // ftab atom: size(4)=13 + "ftab"(4) + entry_count(2)=1 + fontID(2)=1 + name_len(1)=0 + 0x00, 0x00, 0x00, 0x0d, 0x66, 0x74, 0x61, 0x62, + 0x00, 0x01, 0x00, 0x01, 0x00 + }; + + ByteVector sampleEntry; + constexpr unsigned int entrySize = 8 + sizeof(entryBody); + sampleEntry.append(ByteVector::fromUInt(entrySize)); + sampleEntry.append(ByteVector("text", 4)); + sampleEntry.append(ByteVector(reinterpret_cast(entryBody), + sizeof(entryBody))); + + ByteVector stsdPayload; + stsdPayload.append(ByteVector::fromUInt(1)); // entry count + stsdPayload.append(sampleEntry); + + return renderFullBox("stsd", 0, 0, stsdPayload); + } + + //! stts: time-to-sample table. + //! For N chapters, entry i has duration = chapter[i+1].start - chapter[i].start. + //! The last chapter runs to the end of the file. + ByteVector buildStts(const MP4::ChapterList &chapters, unsigned int timescale, + long long durationMs) + { + const unsigned int count = chapters.size(); + if(count == 0) + return ByteVector(); + + // Convert 100-ns units to timescale units + auto toTimescale = [timescale](long long timeMs) -> unsigned int { + return static_cast( + static_cast(timeMs) * static_cast(timescale) / 1000.0 + 0.5); + }; + + const auto totalDuration = static_cast( + static_cast(durationMs) * static_cast(timescale) / 1000.0 + 0.5); + + // Build per-sample durations + std::vector durations; + auto it = chapters.begin(); + for(unsigned int i = 0; i < count; ++i, ++it) { + auto next = it; + ++next; + const unsigned int startTs = toTimescale(it->startTime()); + unsigned int dur; + if(next != chapters.end()) { + const unsigned int nextTs = toTimescale(next->startTime()); + dur = nextTs - startTs; + } + else { + // Last chapter runs to end of file + dur = totalDuration > startTs ? totalDuration - startTs : 0; + } + durations.push_back(dur); + } + + // One stts entry per sample (sampleCount=1 each), matching ffmpeg's output. + // AVFoundation requires this layout rather than run-length encoding. + ByteVector payload; + payload.append(ByteVector::fromUInt(count)); + for(const auto d : durations) { + payload.append(ByteVector::fromUInt(1)); // sample count + payload.append(ByteVector::fromUInt(d)); // sample delta + } + + return renderFullBox("stts", 0, 0, payload); + } + + //! stsz: sample size table with per-sample entries (matching ffmpeg output). + ByteVector buildStsz(const std::vector &sampleSizes) + { + ByteVector payload; + payload.append(ByteVector::fromUInt(0)); // default_sample_size = 0 (per-sample) + payload.append(ByteVector::fromUInt(static_cast(sampleSizes.size()))); + for(const auto sz : sampleSizes) + payload.append(ByteVector::fromUInt(sz)); + return renderFullBox("stsz", 0, 0, payload); + } + + //! stsc: sample-to-chunk table. All samples in one chunk. + ByteVector buildStsc(unsigned int sampleCount) + { + // All samples in a single chunk: one entry saying + // "starting at chunk 1, N samples per chunk, description index 1" + ByteVector payload; + payload.append(ByteVector::fromUInt(1)); // entry count + payload.append(ByteVector::fromUInt(1)); // first chunk + payload.append(ByteVector::fromUInt(sampleCount)); // samples per chunk + payload.append(ByteVector::fromUInt(1)); // sample description index + + return renderFullBox("stsc", 0, 0, payload); + } + + //! stco: chunk offset table. Single chunk offset pointing to the + //! start of the contiguous text sample data. + ByteVector buildStco(unsigned int offset) + { + ByteVector payload; + payload.append(ByteVector::fromUInt(1)); // entry count = 1 + payload.append(ByteVector::fromUInt(offset)); // chunk offset + return renderFullBox("stco", 0, 0, payload); + } + + // -- Full trak builder ---------------------------------------------------- + + //! Builds a complete chapter text trak atom. + //! \a textDataOffset is where the text samples will start in the file. + //! \a sampleSizes contains per-sample sizes for the stsz table. + //! \a movieDuration is the movie-level duration in mvhd timescale units (for edts/elst). + ByteVector buildChapterTrak(unsigned int trackId, unsigned int timescale, + long long durationMs, + const MP4::ChapterList &chapters, + const std::vector &sampleSizes, + offset_t textDataOffset, + unsigned int movieDuration) + { + unsigned int count = chapters.size(); + auto totalDuration = static_cast( + static_cast(durationMs) * static_cast(timescale) / 1000.0 + 0.5); + + // Single chunk offset -- all samples are contiguous starting at textDataOffset + auto chunkOffset = static_cast(textDataOffset); + + // -- tkhd (track header) -- + // version 0: 8 header + 4 ver/flags + 4 creation + 4 modification + // + 4 track_id + 4 reserved + 4 duration + 8 reserved + // + 2 layer + 2 alternate_group + 2 volume + 2 reserved + // + 36 matrix + 4 width + 4 height = 92 bytes total + ByteVector tkhdData; + tkhdData.append(ByteVector(4, '\0')); // creation time + tkhdData.append(ByteVector(4, '\0')); // modification time + tkhdData.append(ByteVector::fromUInt(trackId)); + tkhdData.append(ByteVector(4, '\0')); // reserved + // Duration in mvhd timescale. + tkhdData.append(ByteVector::fromUInt(totalDuration)); + tkhdData.append(ByteVector(8, '\0')); // reserved + tkhdData.append(ByteVector::fromShort(0)); // layer + tkhdData.append(ByteVector::fromShort(0)); // alternate_group + tkhdData.append(ByteVector::fromShort(0)); // volume (0 for text) + tkhdData.append(ByteVector::fromShort(0)); // reserved + // Identity matrix (3x3 fixed point) + tkhdData.append(ByteVector::fromUInt(0x00010000)); // a = 1.0 + tkhdData.append(ByteVector(4, '\0')); // b + tkhdData.append(ByteVector(4, '\0')); // u + tkhdData.append(ByteVector(4, '\0')); // c + tkhdData.append(ByteVector::fromUInt(0x00010000)); // d = 1.0 + tkhdData.append(ByteVector(4, '\0')); // v + tkhdData.append(ByteVector(4, '\0')); // x + tkhdData.append(ByteVector(4, '\0')); // y + tkhdData.append(ByteVector::fromUInt(0x40000000)); // w = 1.0 + tkhdData.append(ByteVector::fromUInt(0)); // width + tkhdData.append(ByteVector::fromUInt(0)); // height + + // flags = 0x02: track_in_movie only (matches ffmpeg's chapter track output). + // Chapter tracks are NOT track_enabled(1) -- they are disabled but present in movie. + ByteVector tkhd = renderFullBox("tkhd", 0, 0x02, tkhdData); + + // -- mdhd (media header) -- + ByteVector mdhdData; + mdhdData.append(ByteVector(4, '\0')); // creation time + mdhdData.append(ByteVector(4, '\0')); // modification time + mdhdData.append(ByteVector::fromUInt(timescale)); + mdhdData.append(ByteVector::fromUInt(totalDuration)); + // language: 0x0000 (matches ffmpeg chapter track output) + mdhdData.append(ByteVector::fromShort(0)); + mdhdData.append(ByteVector::fromShort(0)); // pre_defined + + ByteVector mdhd = renderFullBox("mdhd", 0, 0, mdhdData); + + // -- hdlr (handler reference) -- + ByteVector hdlrData; + hdlrData.append(ByteVector(4, '\0')); // pre_defined + hdlrData.append(ByteVector("text", 4)); // handler_type + hdlrData.append(ByteVector(12, '\0')); // reserved + // name: null-terminated "Chapter" string + hdlrData.append(ByteVector("Chapter", 7)); + hdlrData.append(static_cast(0)); + + ByteVector hdlr = renderFullBox("hdlr", 0, 0, hdlrData); + + // -- gmhd (base media information header) -- + // QT text/chapter tracks use gmhd with gmin + text children. + + // gmin: graphicsMode(2) + opcolor(6) + balance(2) + reserved(2) + ByteVector gminData; + gminData.append(ByteVector::fromShort(0x0040)); // graphicsMode = ditherCopy (0x40) + gminData.append(ByteVector("\x80\x00\x80\x00\x80\x00", 6)); // opcolor (gray) + gminData.append(ByteVector::fromShort(0)); // balance + gminData.append(ByteVector::fromShort(0)); // reserved + ByteVector gmin = renderFullBox("gmin", 0, 0, gminData); + + // text media information atom: matrix(36) + ... = 36 bytes of data + ByteVector textInfoData; + textInfoData.append(ByteVector::fromShort(1)); // 0x0001 + textInfoData.append(ByteVector(14, '\0')); // reserved + textInfoData.append(ByteVector::fromShort(1)); // 0x0001 + textInfoData.append(ByteVector(14, '\0')); // reserved + textInfoData.append(ByteVector::fromUInt(0x40000000)); // 1.0 fixed point + ByteVector textInfo = renderAtom("text", textInfoData); + + ByteVector gmhdContent; + gmhdContent.append(gmin); + gmhdContent.append(textInfo); + ByteVector gmhd = renderAtom("gmhd", gmhdContent); + + // -- dinf / dref (data reference) -- + ByteVector drefEntry; + // "url " self-reference entry (flag 1 = data is in this file) + drefEntry = renderFullBox("url ", 0, 1, ByteVector()); + + ByteVector drefData; + drefData.append(ByteVector::fromUInt(1)); // entry count + drefData.append(drefEntry); + ByteVector dref = renderFullBox("dref", 0, 0, drefData); + ByteVector dinf = renderAtom("dinf", dref); + + // -- stbl (sample table) -- + ByteVector stsd = buildStsd(); + ByteVector stts = buildStts(chapters, timescale, durationMs); + ByteVector stsz = buildStsz(sampleSizes); + ByteVector stsc = buildStsc(count); + ByteVector stco = buildStco(chunkOffset); + + ByteVector stblContent; + stblContent.append(stsd); + stblContent.append(stts); + stblContent.append(stsz); + stblContent.append(stsc); + stblContent.append(stco); + ByteVector stbl = renderAtom("stbl", stblContent); + + // -- minf (media information) -- + ByteVector minfContent; + minfContent.append(gmhd); + minfContent.append(dinf); + minfContent.append(stbl); + ByteVector minf = renderAtom("minf", minfContent); + + // -- mdia (media) -- + ByteVector mdiaContent; + mdiaContent.append(mdhd); + mdiaContent.append(hdlr); + mdiaContent.append(minf); + ByteVector mdia = renderAtom("mdia", mdiaContent); + + // -- edts / elst (edit list) -- + // AVFoundation requires an edit list for the chapter track. + // Single entry: play the whole media from time 0, at normal rate. + ByteVector elstData; + elstData.append(ByteVector::fromUInt(1)); // entry count + elstData.append(ByteVector::fromUInt(movieDuration)); // segment duration (mvhd timescale) + elstData.append(ByteVector::fromUInt(0)); // media time = 0 + elstData.append(ByteVector::fromUInt(0x00010000)); // media rate = 1.0 (fixed point) + ByteVector elst = renderFullBox("elst", 0, 0, elstData); + ByteVector edts = renderAtom("edts", elst); + + // -- trak -- + ByteVector trakContent; + trakContent.append(tkhd); + trakContent.append(edts); + trakContent.append(mdia); + ByteVector trak = renderAtom("trak", trakContent); + + return trak; + } + + // -- tref / chap builder -------------------------------------------------- + + //! Builds a tref atom containing a chap reference to the given track ID. + ByteVector buildTref(unsigned int chapterTrackId) + { + ByteVector chapData; + chapData.append(ByteVector::fromUInt(chapterTrackId)); + const ByteVector chap = renderAtom("chap", chapData); + return renderAtom("tref", chap); + } + + // -- Reading helpers ------------------------------------------------------ + + //! Reads chapter track duration info from the chapter trak's mdhd. + struct ChapterTrackInfo { + unsigned int timescale = 0; + unsigned int totalDuration = 0; + }; + + ChapterTrackInfo readChapterTrackInfo(TagLib::File *file, MP4::Atom *chapterTrak) + { + ChapterTrackInfo info; + + const MP4::Atom *mdhd = chapterTrak->find("mdia", "mdhd"); + if(!mdhd) + return info; + + file->seek(mdhd->offset()); + ByteVector data = file->readBlock(mdhd->length()); + if(data.size() < 8 + 4) + return info; + + if(const auto version = static_cast(data[8]); + version == 1 && data.size() >= 40) { + // v1 mdhd: header(8) + ver/flags(4) + creation(8) + modification(8) + // + timescale(4)@28 + duration(8)@32 + lang(2) + pre(2) = 44 + info.timescale = data.toUInt(28U); + info.totalDuration = static_cast(data.toLongLong(32U)); + } + else if(version == 0 && data.size() >= 28) { + // v0 mdhd: header(8) + ver/flags(4) + creation(4) + modification(4) + // + timescale(4)@20 + duration(4)@24 + lang(2) + pre(2) = 32 + info.timescale = data.toUInt(20U); + info.totalDuration = data.toUInt(24U); + } + return info; + } + + //! Reads stts entries from the chapter track. + struct SttsEntry { + unsigned int sampleCount; + unsigned int sampleDelta; + }; + + std::vector readStts(TagLib::File *file, MP4::Atom *chapterTrak) + { + std::vector entries; + const MP4::Atom *stts = chapterTrak->find("mdia", "minf", "stbl", "stts"); + if(!stts) + return entries; + + file->seek(stts->offset() + 12); // skip header(8) + version/flags(4) + const ByteVector data = file->readBlock(stts->length() - 12); + if(data.size() < 4) + return entries; + + const unsigned int count = data.toUInt(); + unsigned int pos = 4; + for(unsigned int i = 0; i < count && pos + 8 <= data.size(); ++i) { + SttsEntry e; + e.sampleCount = data.toUInt(pos); + e.sampleDelta = data.toUInt(pos + 4); + entries.push_back(e); + pos += 8; + } + return entries; + } + + //! Reads chunk offsets from stco. + std::vector readStco(TagLib::File *file, MP4::Atom *chapterTrak) + { + std::vector offsets; + const MP4::Atom *stco = chapterTrak->find("mdia", "minf", "stbl", "stco"); + if(!stco) + return offsets; + + file->seek(stco->offset() + 12); + const ByteVector data = file->readBlock(stco->length() - 12); + if(data.size() < 4) + return offsets; + + const unsigned int count = data.toUInt(); + unsigned int pos = 4; + for(unsigned int i = 0; i < count && pos + 4 <= data.size(); ++i) { + offsets.push_back(data.toUInt(pos)); + pos += 4; + } + return offsets; + } + + //! Reads sample sizes. Returns (defaultSize, perSampleSizes). + //! If defaultSize > 0, perSampleSizes is empty. + struct SampleSizeInfo { + unsigned int defaultSize = 0; + unsigned int sampleCount = 0; + std::vector perSampleSizes; + }; + + SampleSizeInfo readStsz(TagLib::File *file, MP4::Atom *chapterTrak) + { + SampleSizeInfo info; + const MP4::Atom *stsz = chapterTrak->find("mdia", "minf", "stbl", "stsz"); + if(!stsz) + return info; + + file->seek(stsz->offset() + 12); + const ByteVector data = file->readBlock(stsz->length() - 12); + if(data.size() < 8) + return info; + + info.defaultSize = data.toUInt(); + info.sampleCount = data.toUInt(4U); + + if(info.defaultSize == 0) { + unsigned int pos = 8; + for(unsigned int i = 0; i < info.sampleCount && pos + 4 <= data.size(); ++i) { + info.perSampleSizes.push_back(data.toUInt(pos)); + pos += 4; + } + } + return info; + } + + //! Resolves chunk-level offsets (stco) into per-sample file offsets + //! using the stsc (sample-to-chunk) table and sample sizes from stsz. + //! This handles both single-chunk and multi-chunk layouts. + std::vector resolveSampleOffsets(TagLib::File *file, + MP4::Atom *chapterTrak, + const SampleSizeInfo &sizeInfo) + { + const std::vector chunkOffsets = readStco(file, chapterTrak); + if(chunkOffsets.empty()) + return {}; + + // Read stsc entries + struct StscEntry { + unsigned int firstChunk; + unsigned int samplesPerChunk; + unsigned int descIndex; + }; + std::vector stscEntries; + + if(const MP4::Atom *stsc = chapterTrak->find("mdia", "minf", "stbl", "stsc")) { + file->seek(stsc->offset() + 12); + if(const ByteVector data = file->readBlock(stsc->length() - 12); + data.size() >= 4) { + const unsigned int entryCount = data.toUInt(); + unsigned int pos = 4; + for(unsigned int i = 0; i < entryCount && pos + 12 <= data.size(); ++i) { + StscEntry e; + e.firstChunk = data.toUInt(pos); + e.samplesPerChunk = data.toUInt(pos + 4); + e.descIndex = data.toUInt(pos + 8); + stscEntries.push_back(e); + pos += 12; + } + } + } + + // Default: 1 sample per chunk if no stsc + if(stscEntries.empty()) { + stscEntries.push_back({1, 1, 1}); + } + + // Resolve per-sample offsets by walking chunks + std::vector sampleOffsets; + const auto totalChunks = static_cast(chunkOffsets.size()); + unsigned int sampleIndex = 0; + + for(unsigned int chunkIdx = 0; chunkIdx < totalChunks; ++chunkIdx) { + // Find which stsc entry applies to this chunk (1-based) + const unsigned int chunkNum = chunkIdx + 1; + unsigned int samplesInChunk = stscEntries[0].samplesPerChunk; + for(const auto & stscEntry : stscEntries) { + if(stscEntry.firstChunk <= chunkNum) { + samplesInChunk = stscEntry.samplesPerChunk; + } + else { + break; + } + } + + unsigned int offsetInChunk = 0; + for(unsigned int s = 0; s < samplesInChunk; ++s) { + sampleOffsets.push_back(chunkOffsets[chunkIdx] + offsetInChunk); + + // Advance by this sample's size + unsigned int sz = sizeInfo.defaultSize; + if(sz == 0 && sampleIndex < sizeInfo.perSampleSizes.size()) + sz = sizeInfo.perSampleSizes[sampleIndex]; + offsetInChunk += sz; + sampleIndex++; + } + } + + return sampleOffsets; + } + + //! Read a text sample at a given file offset. + String readTextSample(TagLib::File *file, unsigned int offset, unsigned int maxSize) + { + file->seek(offset); + const ByteVector data = file->readBlock(maxSize); + if(data.size() < 2) + return String(); + + const unsigned int textLen = data.toUShort(); + if(textLen == 0 || textLen + 2 > data.size()) + return String(); + + return String(data.mid(2, textLen), String::UTF8); + } + + // -- Remove helpers ------------------------------------------------------- + + //! Removes the tref atom from the audio track. + //! Updates trak size, parent sizes, and chunk offsets. + //! audioTrak's in-memory children list is NOT modified (caller re-parses if needed). + void removeAudioTref(TagLib::File *file, const MP4::Atoms *atoms, const MP4::Atom *audioTrak) + { + for(const auto &child : audioTrak->children()) { + if(child->name() != "tref") + continue; + + const offset_t trefOff = child->offset(); + const offset_t trefLen = child->length(); + + file->removeBlock(trefOff, trefLen); + + // Fix audio trak size on disk + file->seek(audioTrak->offset()); + const unsigned int trakSize = file->readBlock(4).toUInt(); + file->seek(audioTrak->offset()); + file->writeBlock(ByteVector::fromUInt( + static_cast(trakSize - trefLen))); + + const MP4::AtomList moovPath = atoms->path("moov"); + updateParentSizes(file, moovPath, -trefLen); + updateChunkOffsets(file, atoms, -trefLen, trefOff); + return; + } + } + + //! Finds the top-level mdat atom that covers the given file offset. + const MP4::Atom *findMdatContaining(const MP4::Atoms *atoms, offset_t fileOffset) + { + for(const auto *atom : atoms->atoms()) { + if(atom->name() != "mdat") + continue; + const offset_t dataStart = atom->offset() + 8; + const offset_t end = atom->offset() + atom->length(); + if(fileOffset >= dataStart && fileOffset < end) + return atom; + } + return nullptr; + } + + //! True if any stco/co64 entry in the atom tree points inside the given mdat range. + //! Used to detect mdats that are shared with other tracks (audio data + chapter + //! text co-located in a single mdat) so we never delete live track data. + bool mdatIsUsedByAnyTrack(TagLib::File *file, const MP4::Atoms *atoms, + offset_t mdatStart, offset_t mdatSize) + { + const offset_t dataStart = mdatStart + 8; + const offset_t dataEnd = mdatStart + mdatSize; + + const MP4::Atom *moov = atoms->find("moov"); + if(!moov) + return false; + + for(const auto &stco : moov->findall("stco", true)) { + file->seek(stco->offset() + 12); + ByteVector data = file->readBlock(stco->length() - 12); + if(data.size() < 4) + continue; + unsigned int count = data.toUInt(); + unsigned int pos = 4; + const unsigned int maxPos = data.size() - 4; + while(count-- && pos <= maxPos) { + const auto o = static_cast(data.toUInt(pos)); + if(o >= dataStart && o < dataEnd) + return true; + pos += 4; + } + } + + for(const auto &co64 : moov->findall("co64", true)) { + file->seek(co64->offset() + 12); + ByteVector data = file->readBlock(co64->length() - 12); + if(data.size() < 4) + continue; + unsigned int count = data.toUInt(); + unsigned int pos = 4; + const unsigned int maxPos = data.size() - 8; + while(count-- && pos <= maxPos) { + const offset_t o = data.toLongLong(pos); + if(o >= dataStart && o < dataEnd) + return true; + pos += 8; + } + } + + return false; + } + + // Removes the QT chapter trak and the tref from the audio track, plus the + // chapter text mdat if (and only if) no other track points into it. + // + // The chapter mdat identity is resolved by looking up which top-level mdat + // contains the chapter track's first chunk offset. After removing the trak + // and tref, we re-parse and verify the mdat is not referenced by any other + // track before deleting it. Audiobook-style files commonly co-locate chapter + // text inside the main audio mdat; in that case the mdat is shared and MUST + // NOT be deleted -- the text bytes are left as dead space and the chapter + // track alone is removed. + void removeQTChapterTrack(TagLib::File *file, const MP4::Atoms *atoms, + MP4::Atom *moov, MP4::Atom *chapterTrak, + const MP4::Atom *audioTrak) + { + // Identify the chapter text mdat BEFORE removal (while stco is still valid). + offset_t chapterMdatOffset = -1; + offset_t chapterMdatSize = 0; + { + const std::vector stco = readStco(file, chapterTrak); + if(!stco.empty()) { + if(const MP4::Atom *mdat = findMdatContaining(atoms, + static_cast(stco[0]))) { + chapterMdatOffset = mdat->offset(); + chapterMdatSize = mdat->length(); + } + } + } + + // Capture tref/chapter trak locations for mdat offset fix-up below. + offset_t trefOff = -1; + offset_t trefLen = 0; + for(const auto &child : audioTrak->children()) { + if(child->name() == "tref") { + trefOff = child->offset(); + trefLen = child->length(); + break; + } + } + + // Remove chapter trak FIRST (higher offset in file). + const offset_t chapterOff = chapterTrak->offset(); + const offset_t chapterLen = chapterTrak->length(); + + // Remove from in-memory tree so updateChunkOffsets skips its stco. + moov->removeChild(chapterTrak); + delete chapterTrak; + + file->removeBlock(chapterOff, chapterLen); + + const MP4::AtomList moovPath = atoms->path("moov"); + updateParentSizes(file, moovPath, -chapterLen); + updateChunkOffsets(file, atoms, -chapterLen, chapterOff); + + // Remove tref from audio trak (lower offset, still valid after chapter trak removal). + removeAudioTref(file, atoms, audioTrak); + + // Decide whether the chapter mdat is safe to delete. + if(chapterMdatOffset < 0) + return; + + // Shift the original mdat offset by however much of the removed bytes + // preceded it in the file. + offset_t adjustedOffset = chapterMdatOffset; + if(chapterMdatOffset > chapterOff) + adjustedOffset -= chapterLen; + if(trefOff >= 0 && chapterMdatOffset > trefOff) + adjustedOffset -= trefLen; + + // Re-parse to verify the post-removal on-disk state matches expectations + // and that no surviving track references this mdat's data range. + const MP4::Atoms cleanAtoms(file); + if(mdatIsUsedByAnyTrack(file, &cleanAtoms, adjustedOffset, chapterMdatSize)) + return; + + file->seek(adjustedOffset); + const ByteVector header = file->readBlock(8); + if(header.size() != 8 || header.mid(4, 4) != "mdat") + return; + if(static_cast(header.toUInt()) != chapterMdatSize) + return; + + file->removeBlock(adjustedOffset, chapterMdatSize); + } + +} // namespace + +//////////////////////////////////////////////////////////////////////////////// +// public members +//////////////////////////////////////////////////////////////////////////////// + +bool MP4::QtChapterList::read(TagLib::File *file) +{ + const Atoms atoms(file); + modified = false; + chapterList.clear(); + + TrackInfo audio = findAudioTrack(file, &atoms); + if(!audio.trak) + return false; + + Atom *chapterTrak = findChapterTrak(file, &atoms, audio.trak); + if(!chapterTrak) + return false; + + ChapterTrackInfo trackInfo = readChapterTrackInfo(file, chapterTrak); + if(trackInfo.timescale == 0) + return false; + + const std::vector sttsEntries = readStts(file, chapterTrak); + const SampleSizeInfo sizeInfo = readStsz(file, chapterTrak); + const std::vector offsets = resolveSampleOffsets(file, chapterTrak, sizeInfo); + + if(offsets.empty()) + return false; + + unsigned int sampleIndex = 0; + long long currentTime = 0; + + for(const auto &entry : sttsEntries) { + for(unsigned int s = 0; s < entry.sampleCount; ++s) { + if(sampleIndex >= offsets.size()) + break; + + unsigned int sampleSize = sizeInfo.defaultSize; + if(sampleSize == 0 && sampleIndex < sizeInfo.perSampleSizes.size()) + sampleSize = sizeInfo.perSampleSizes[sampleIndex]; + + String title = readTextSample(file, offsets[sampleIndex], sampleSize); + + const auto startTimeMs = static_cast( + static_cast(currentTime) * 1000.0 / + static_cast(trackInfo.timescale) + 0.5); + + chapterList.append(Chapter(title, startTimeMs)); + + currentTime += entry.sampleDelta; + sampleIndex++; + } + } + + // Strip a leading dummy chapter (empty title at time 0) that was inserted + // during write to preserve non-zero first-chapter start times. + if(chapterList.size() > 1) { + if(const Chapter &first = chapterList.front(); + first.startTime() == 0 && first.title().isEmpty()) { + chapterList.erase(chapterList.begin()); + } + } + + return true; +} + +bool MP4::QtChapterList::write(TagLib::File *file) +{ + // Writing an empty list is equivalent to removing the chapter track. + if(chapterList.isEmpty()) + return remove(file); + + // ---- Phase 1: Parse and gather info ---- + + Atoms atoms(file); + Atom *moov = atoms.find("moov"); + if(!moov) { + debug("MP4QTChapterList::write() -- No moov atom found"); + return false; + } + + const MovieInfo movieInfo = readMovieInfo(file, &atoms); + if(movieInfo.durationMs <= 0) { + debug("MP4QTChapterList::write() -- Could not determine file duration"); + return false; + } + const long long durationMs = movieInfo.durationMs; + + TrackInfo audio = findAudioTrack(file, &atoms); + if(!audio.trak) { + debug("MP4QTChapterList::write() -- No audio track found"); + return false; + } + + // ---- Phase 2: Remove existing chapter data (if any) ---- + + // Pointer to the Atoms object we'll use for the insert phase. + // Points to `atoms` for fresh writes, or `cleanAtoms` after cleanup. + const Atoms *activeAtoms = &atoms; + // Optional second parse -- only constructed when replacing existing chapters. + std::unique_ptr cleanAtoms; + + if(Atom *existingChapter = findChapterTrak(file, &atoms, audio.trak)) { + removeQTChapterTrack(file, &atoms, moov, existingChapter, audio.trak); + + // Re-parse to get clean state after removals. + cleanAtoms = std::make_unique(file); + activeAtoms = cleanAtoms.get(); + + moov = activeAtoms->find("moov"); + if(!moov) { + debug("MP4QTChapterList::write() -- moov disappeared after cleanup"); + return false; + } + audio = findAudioTrack(file, activeAtoms); + if(!audio.trak) { + debug("MP4QTChapterList::write() -- No audio track after cleanup"); + return false; + } + } + + // ---- Phase 3: Build and insert new chapter data ---- + + // QT chapter tracks always start at media time 0. If the first chapter has a + // non-zero start time, prepend a dummy chapter at time 0 with an empty title + // so the absolute positions are preserved as stts durations. + ChapterList workingChapters(chapterList); + if(!workingChapters.isEmpty() && workingChapters.front().startTime() > 0) { + workingChapters.prepend(Chapter(String(), 0)); + } + + const unsigned int nextId = getNextTrackId(file, activeAtoms); + const unsigned int chapterTrackId = nextId > 0 ? nextId : audio.trackId + 1; + constexpr unsigned int timescale = 1000; + const std::vector sampleSizes = calculateSampleSizes(workingChapters); + + // Build tref/chap atom for audio track + const ByteVector trefAtom = buildTref(chapterTrackId); + + // Two-pass build for chapter trak: first to measure size, then with correct stco offsets. + const ByteVector trakMeasure = buildChapterTrak( + chapterTrackId, timescale, durationMs, workingChapters, sampleSizes, 0, + movieInfo.duration); + const auto totalInsert = static_cast(trefAtom.size() + trakMeasure.size()); + // Text samples go inside an mdat atom at EOF. stco offsets point past the 8-byte mdat header. + const offset_t textDataOffset = file->length() + totalInsert + 8; + + // Build final trak with correct stco offsets pointing to where text data will land. + const ByteVector trakAtom = buildChapterTrak( + chapterTrackId, timescale, durationMs, workingChapters, sampleSizes, textDataOffset, + movieInfo.duration); + + // Combined payload: tref (goes inside audio trak) + chapter trak (moov sibling) + ByteVector combinedPayload = trefAtom; + combinedPayload.append(trakAtom); + + // Insert at the end of the audio trak boundary. + // tref is logically inside audio trak; chapter trak is logically after it. + const offset_t insertOffset = audio.trak->offset() + audio.trak->length(); + + file->insert(combinedPayload, insertOffset, 0); + + // Fix audio trak size on disk -- only tref goes inside + file->seek(audio.trak->offset()); + const unsigned int audioTrakSize = file->readBlock(4).toUInt(); + const unsigned int newAudioTrakSize = audioTrakSize + trefAtom.size(); + file->seek(audio.trak->offset()); + file->writeBlock(ByteVector::fromUInt(newAudioTrakSize)); + + // Fix moov size -- both tref and chapter trak are inside moov + const AtomList moovPath = activeAtoms->path("moov"); + updateParentSizes(file, moovPath, combinedPayload.size()); + + // Fix existing chunk offsets -- only the ORIGINAL atom tree is iterated, + // so the new chapter trak's stco (which already has correct offsets) is untouched. + updateChunkOffsets(file, activeAtoms, combinedPayload.size(), insertOffset); + + // ---- Phase 4: Append text samples in mdat at EOF ---- + + ByteVector textSamples; + for(const auto &ch : workingChapters) { + textSamples.append(buildTextSample(ch.title())); + } + // Wrap text samples in an mdat atom so players can find them. + const ByteVector mdatAtom = renderAtom("mdat", textSamples); + + file->seek(0, TagLib::File::End); + file->writeBlock(mdatAtom); + + // ---- Phase 5: Update mvhd next_track_ID ---- + // mvhd is before insertOffset, so its offset is unchanged. + + if(const unsigned int currentNextId = getNextTrackId(file, activeAtoms); + chapterTrackId >= currentNextId) { + setNextTrackId(file, activeAtoms, chapterTrackId + 1); + } + + modified = false; + return true; +} + +bool MP4::QtChapterList::remove(TagLib::File *file) +{ + Atoms atoms(file); + chapterList.clear(); + modified = false; + + TrackInfo audio = findAudioTrack(file, &atoms); + if(!audio.trak) + return true; // No audio track -- nothing to do + + Atom *chapterTrak = findChapterTrak(file, &atoms, audio.trak); + if(!chapterTrak) + return true; // No chapter track -- nothing to do + + Atom *moov = atoms.find("moov"); + if(!moov) + return false; + + removeQTChapterTrack(file, &atoms, moov, chapterTrak, audio.trak); + return true; +} diff --git a/taglib/mp4/mp4qtchapterlist.h b/taglib/mp4/mp4qtchapterlist.h new file mode 100644 index 00000000..c2900156 --- /dev/null +++ b/taglib/mp4/mp4qtchapterlist.h @@ -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 "mp4chapterholder.h" + +namespace TagLib { + class File; + 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 QtChapterList : public ChapterHolder + { + public: + /*! + * Reads chapter markers from the QuickTime chapter track in the + * already-opened \a file. + * Returns \c false if the file has no chapter track. + */ + bool read(TagLib::File *file); + + /*! + * Writes chapter markers as a QuickTime chapter track to the + * already-opened \a file, replacing any existing chapter track. + * Returns \c true on success. + */ + bool write(TagLib::File *file); + + /*! + * Removes the QuickTime chapter track and its \c tref/chap + * reference from the already-opened \a file. + * Returns \c true on success, or if no chapter track exists. + */ + bool remove(TagLib::File *file); + }; + + } // namespace MP4 +} // namespace TagLib + +#endif diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index 03f96ae5..d037a14f 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 "mp4chapterholder.h" #include "plainfile.h" #include #include "utils.h" @@ -69,6 +70,56 @@ namespace }; CustomItemFactory CustomItemFactory::factory; + + class MockChapterList : public MP4::ChapterHolder { + public: + static const MP4::ChapterList mockChapters; + + bool read(TagLib::File *) + { + chapterList = mockChapters; + ++readCount; + return true; + } + + bool write(TagLib::File *) + { + ++writeCount; + return true; + } + + int readCount = 0; + int writeCount = 0; + }; + + const MP4::ChapterList MockChapterList::mockChapters = { + MP4::Chapter("Mock", 123) + }; + + class MockChapterFile : public PlainFile { + public: + explicit MockChapterFile(FileName name) : PlainFile(name) + { + } + + MP4::ChapterList chapters() + { + return getChaptersLazy(chapterList, this); + } + + void setChapters(const MP4::ChapterList& chapters) + { + setChaptersLazy(chapterList, chapters); + } + + bool save() override + { + return MP4::saveChaptersIfModified(chapterList, this); + } + + std::unique_ptr chapterList; + }; + } // namespace class TestMP4 : public CppUnit::TestFixture @@ -102,6 +153,26 @@ 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(testQTChapterListWrite); + CPPUNIT_TEST(testQTChapterListRemove); + CPPUNIT_TEST(testQTChapterListWithExistingTags); + CPPUNIT_TEST(testQTChapterListReadEmpty); + CPPUNIT_TEST(testQTChapterListOverwrite); + CPPUNIT_TEST(testQTChapterListTimestampPrecision); + CPPUNIT_TEST(testQTChapterListNonZeroFirstChapter); + CPPUNIT_TEST(testQTChapterListNoOrphanedMdat); + CPPUNIT_TEST(testQTChapterListSharedMdatPreservesAudio); + CPPUNIT_TEST(testQTChapterListUnicodeTitles); + CPPUNIT_TEST(testChapterListUnicodeTitles); + CPPUNIT_TEST(testQTChapterListEmptyTitleStripped); + CPPUNIT_TEST(testQTChapterListSingleEmptyTitleNotStripped); + CPPUNIT_TEST(testNeroAndQTChaptersAreIndependent); + CPPUNIT_TEST(testNeroChaptersAloneWhenNoQT); + CPPUNIT_TEST(testLazyReadingAndWritingChapters); CPPUNIT_TEST_SUITE_END(); public: @@ -873,6 +944,847 @@ 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::File f(filename.c_str()); + MP4::ChapterList chapters = f.neroChapters(); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + + // Write chapters + { + MP4::File f(filename.c_str()); + f.setNeroChapters(MP4::ChapterList{ + MP4::Chapter("Introduction", 0), + MP4::Chapter("Main Content", 30000LL), + MP4::Chapter("Conclusion", 60000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Read back and verify + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.neroChapters(); + CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime()); + CPPUNIT_ASSERT_EQUAL(String("Introduction"), chapters[0].title()); + CPPUNIT_ASSERT_EQUAL(30000LL, chapters[1].startTime()); + CPPUNIT_ASSERT_EQUAL(String("Main Content"), chapters[1].title()); + CPPUNIT_ASSERT_EQUAL(60000LL, chapters[2].startTime()); + CPPUNIT_ASSERT_EQUAL(String("Conclusion"), chapters[2].title()); + + // Overwrite with different chapters + f.setNeroChapters(MP4::ChapterList{ + MP4::Chapter("Part One", 0) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Verify overwrite + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.neroChapters(); + 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::File f(filename.c_str()); + f.setNeroChapters(MP4::ChapterList{ + MP4::Chapter("Chapter 1", 0) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Verify written + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.neroChapters(); + CPPUNIT_ASSERT_EQUAL(1U, chapters.size()); + + // Remove chapters + f.setNeroChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } + + // Verify removed + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.neroChapters(); + CPPUNIT_ASSERT(chapters.isEmpty()); + + // Remove from file with no chapters should also succeed + f.setNeroChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } + } + + 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 + f.setNeroChapters(MP4::ChapterList{ + MP4::Chapter("Intro", 0), + MP4::Chapter("Verse", 10000LL)}); + CPPUNIT_ASSERT(f.save()); + } + + // Verify chapters are written AND existing tags are preserved + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + MP4::ChapterList chapters = f.neroChapters(); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title()); + CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title()); + CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist()); + + // Remove chapters and verify tags still survive + f.setNeroChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } + + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist()); + CPPUNIT_ASSERT(f.neroChapters().isEmpty()); + } + } + + void testChapterListReadEmpty() + { + // Reading from a file with no chpl atom should return empty list + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.neroChapters().isEmpty()); + } + } + + void testQTChapterListWrite() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // File should have no QT chapters initially + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + + // Write chapters (times in 100-nanosecond units) + { + MP4::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("Intro", 0), + MP4::Chapter("Verse", 15000LL), + MP4::Chapter("Outro", 30000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Read back and verify + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime()); + CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title()); + CPPUNIT_ASSERT_EQUAL(15000LL, chapters[1].startTime()); + CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title()); + CPPUNIT_ASSERT_EQUAL(30000LL, 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::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("Chapter 1", 0), + MP4::Chapter("Chapter 2", 10000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Verify written + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + + // Remove chapters + f.setQtChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } + + // Verify removed + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + CPPUNIT_ASSERT(chapters.isEmpty()); + + // Remove from file with no chapters should also succeed + f.setQtChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } + } + + 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 + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("Intro", 0), + MP4::Chapter("Verse", 10000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Verify chapters are written AND existing tags are preserved + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title()); + CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title()); + + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist()); + + // Remove chapters and verify tags still survive + f.setQtChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } + + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist()); + CPPUNIT_ASSERT(f.qtChapters().isEmpty()); + } + } + + void testQTChapterListReadEmpty() + { + // Reading from a file with no chapter track should return empty list + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.qtChapters().isEmpty()); + } + } + + void testQTChapterListOverwrite() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // Write initial chapters + { + MP4::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("Old1", 0), + MP4::Chapter("Old2", 5000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Verify initial + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + } + + // Overwrite with different chapters + { + MP4::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("New1", 0), + MP4::Chapter("New2", 10000LL), + MP4::Chapter("New3", 20000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Verify overwrite + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + 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::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("Start", 0), + MP4::Chapter("Precise", 1500LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Read back and verify timestamps + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime()); + CPPUNIT_ASSERT_EQUAL(1500LL, 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::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("One", 10000LL), + MP4::Chapter("Two", 20000LL), + MP4::Chapter("Three", 30000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Read back -- dummy chapter at time 0 should be stripped + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(10000LL, chapters[0].startTime()); + CPPUNIT_ASSERT_EQUAL(20000LL, chapters[1].startTime()); + CPPUNIT_ASSERT_EQUAL(30000LL, 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()); + } + } + + // 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 + // cycles produced originalCount + 3 mdat atoms. + void testQTChapterListNoOrphanedMdat() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // Count top-level mdat atoms using TagLib's own atom parser. + auto countMdatTagLib = [&]() -> int { + PlainFile pf(filename.c_str()); + MP4::Atoms atoms(&pf); + int count = 0; + for(const auto *atom : atoms.atoms()) + if(atom->name() == "mdat") + ++count; + return count; + }; + + const int baseMdatTagLib = countMdatTagLib(); + + // Three add/remove cycles (the scenario ufleisch demonstrated). + for(int cycle = 0; cycle < 3; ++cycle) { + { + MP4::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("Chapter 1", 0), + MP4::Chapter("Chapter 2", 10000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + { + MP4::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } + } + + // No orphaned mdat atoms should remain. + CPPUNIT_ASSERT_EQUAL(baseMdatTagLib, countMdatTagLib()); + } + + // Regression test for the data-loss bug reported in PR #1343 by ufleisch. + // Audiobook-style files co-locate chapter text samples inside the main + // audio mdat. In that case the chapter track's stco[0] does NOT mark a + // dedicated chapter mdat -- it points into the shared audio mdat, and + // naively deleting "the mdat at stco[0] - 8" destroys the audio payload. + // + // Simulate that layout by writing a chapter track, then rewriting its + // stco[0] to point at the start of the primary audio mdat. Removing the + // chapter track must leave the audio mdat fully intact. + void testQTChapterListSharedMdatPreservesAudio() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + struct MdatInfo { offset_t offset; offset_t length; }; + auto findFirstMdat = [&]() -> MdatInfo { + PlainFile pf(filename.c_str()); + MP4::Atoms atoms(&pf); + for(const auto *atom : atoms.atoms()) + if(atom->name() == "mdat") + return {atom->offset(), atom->length()}; + return {-1, 0}; + }; + + const MdatInfo audioMdat = findFirstMdat(); + CPPUNIT_ASSERT(audioMdat.offset >= 0); + CPPUNIT_ASSERT(audioMdat.length > 16); + + // Capture the audio mdat bytes so we can confirm byte-for-byte preservation. + ByteVector originalAudioMdat; + { + PlainFile pf(filename.c_str()); + pf.seek(audioMdat.offset); + originalAudioMdat = pf.readBlock(audioMdat.length); + } + + // Add a chapter track. write() appends its own mdat for the chapter text + // at EOF; we'll relocate stco[0] below to simulate the shared-mdat case. + { + MP4::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("Chapter 1", 0), + MP4::Chapter("Chapter 2", 1000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Rewrite the chapter track's stco[0] to point inside the audio mdat's + // data, so findMdatContaining() will identify the audio mdat as the + // candidate target. Choosing audioMdat.offset + 8 (the data start) is + // the worst case: without the shared-mdat guard, the old code would + // treat the audio mdat header as the chapter mdat header and wipe it. + { + PlainFile pf(filename.c_str()); + MP4::Atoms atoms(&pf); + const MP4::Atom *moov = atoms.find("moov"); + CPPUNIT_ASSERT(moov); + const MP4::AtomList traks = moov->findall("trak"); + CPPUNIT_ASSERT(traks.size() >= 2); + // The chapter trak is the most recently added -- find the one whose + // hdlr handler_type is "text". + MP4::Atom *chapterTrak = nullptr; + for(auto *t : traks) { + MP4::Atom *hdlr = t->find("mdia", "hdlr"); + if(!hdlr) continue; + pf.seek(hdlr->offset()); + if(ByteVector d = pf.readBlock(hdlr->length()); d.containsAt("text", 16)) { + chapterTrak = t; + break; + } + } + CPPUNIT_ASSERT(chapterTrak); + MP4::Atom *stco = chapterTrak->find("mdia", "minf", "stbl", "stco"); + CPPUNIT_ASSERT(stco); + // stco payload: full-box header(4) + entry_count(4) + offsets[] + pf.seek(stco->offset() + 16); + pf.writeBlock(ByteVector::fromUInt( + static_cast(audioMdat.offset + 8))); + } + + // Trigger the chapter-removal path with the crafted stco[0]. + { + MP4::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } + + // The audio mdat must survive with its contents byte-identical. + const MdatInfo afterMdat = findFirstMdat(); + CPPUNIT_ASSERT(afterMdat.offset >= 0); + CPPUNIT_ASSERT_EQUAL(audioMdat.length, afterMdat.length); + { + PlainFile pf(filename.c_str()); + pf.seek(afterMdat.offset); + const ByteVector afterBytes = pf.readBlock(afterMdat.length); + CPPUNIT_ASSERT(afterBytes == originalAudioMdat); + } + } + + // Unicode titles (CJK, Latin with diacritics, Cyrillic) survive the + // write -> save -> open -> read round-trip through the QT chapter track. + // This exercises the text-sample serialisation in mp4qtchapterlist.cpp. + void testQTChapterListUnicodeTitles() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // UTF-8: 日本語, Über, Привет + const String japanese("\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e", String::UTF8); + const String german("\xc3\x9c" "ber", String::UTF8); + const String russian("\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82", String::UTF8); + + { + MP4::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter(japanese, 0), + MP4::Chapter(german, 15000LL), + MP4::Chapter(russian, 30000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(japanese, chapters[0].title()); + CPPUNIT_ASSERT_EQUAL(german, chapters[1].title()); + CPPUNIT_ASSERT_EQUAL(russian, chapters[2].title()); + } + } + + // Unicode titles survive the write -> save -> open -> read round-trip + // through the Nero chpl atom, which uses a different serialisation path + // (length-prefixed UTF-8 inside udta/chpl). + void testChapterListUnicodeTitles() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // UTF-8: 日本語, Über, Привет + const String japanese("\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e", String::UTF8); + const String german("\xc3\x9c" "ber", String::UTF8); + const String russian("\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82", String::UTF8); + + { + MP4::File f(filename.c_str()); + f.setNeroChapters(MP4::ChapterList{ + MP4::Chapter(japanese, 0), + MP4::Chapter(german, 15000LL), + MP4::Chapter(russian, 30000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.neroChapters(); + CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(japanese, chapters[0].title()); + CPPUNIT_ASSERT_EQUAL(german, chapters[1].title()); + CPPUNIT_ASSERT_EQUAL(russian, chapters[2].title()); + } + } + + // When a multi-chapter list begins with an empty-titled chapter at time 0, + // that entry matches the QT dummy-marker pattern and must be stripped on + // read-back. This test documents the stripping behaviour so a regression + // is immediately detectable. + void testQTChapterListEmptyTitleStripped() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + { + MP4::File f(filename.c_str()); + // First entry has an empty title at t=0. write() sees the list already + // starts at t=0 so no dummy is prepended; the empty entry is written + // as-is. read() must strip it because size > 1 && startTime()==0 && + // title().isEmpty(). + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("", 0), + MP4::Chapter("Chapter 1", 5000LL), + MP4::Chapter("Chapter 2", 10000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + // The empty t=0 entry is stripped; only the two real chapters remain. + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(5000LL, chapters[0].startTime()); + CPPUNIT_ASSERT_EQUAL(String("Chapter 1"), chapters[0].title()); + CPPUNIT_ASSERT_EQUAL(10000LL, chapters[1].startTime()); + CPPUNIT_ASSERT_EQUAL(String("Chapter 2"), chapters[1].title()); + } + } + + // A single chapter with an empty title at time 0 must NOT be stripped. + // The stripping rule applies only when size > 1 -- a file with exactly one + // chapter is valid and its t=0 marker is not a dummy. + void testQTChapterListSingleEmptyTitleNotStripped() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + { + MP4::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("", 0) + }); + CPPUNIT_ASSERT(f.save()); + } + + { + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); + CPPUNIT_ASSERT_EQUAL(1U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime()); + CPPUNIT_ASSERT_EQUAL(String(""), chapters[0].title()); + } + } + + // Both Nero (chpl) and QT chapter tracks can coexist in the same file. + // Writing one format must not disturb the other, and removing one must + // leave the other intact -- this validates the saveChaptersIfModified lazy + // save contract in mp4file.cpp. + void testNeroAndQTChaptersAreIndependent() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // Write both formats in a single save. + { + MP4::File f(filename.c_str()); + f.setNeroChapters(MP4::ChapterList{ + MP4::Chapter("Nero 1", 0), + MP4::Chapter("Nero 2", 10000LL) + }); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("QT 1", 0), + MP4::Chapter("QT 2", 20000LL) + }); + CPPUNIT_ASSERT(f.save()); + } + + // Verify both are present and distinct. + { + MP4::File f(filename.c_str()); + const MP4::ChapterList nero = f.neroChapters(); + const MP4::ChapterList qt = f.qtChapters(); + + CPPUNIT_ASSERT_EQUAL(2U, nero.size()); + CPPUNIT_ASSERT_EQUAL(String("Nero 1"), nero[0].title()); + CPPUNIT_ASSERT_EQUAL(String("Nero 2"), nero[1].title()); + + CPPUNIT_ASSERT_EQUAL(2U, qt.size()); + CPPUNIT_ASSERT_EQUAL(String("QT 1"), qt[0].title()); + CPPUNIT_ASSERT_EQUAL(String("QT 2"), qt[1].title()); + + // Remove only the QT track. + f.setQtChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } + + // QT removed; Nero chapters must be fully intact. + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.qtChapters().isEmpty()); + + const MP4::ChapterList nero = f.neroChapters(); + CPPUNIT_ASSERT_EQUAL(2U, nero.size()); + CPPUNIT_ASSERT_EQUAL(String("Nero 1"), nero[0].title()); + CPPUNIT_ASSERT_EQUAL(String("Nero 2"), nero[1].title()); + } + } + + // Writing only Nero chapters must not accidentally create a QT chapter track, + // and writing only QT chapters must not accidentally create a Nero chpl atom. + void testNeroChaptersAloneWhenNoQT() + { + // Nero only -- QT track must remain absent. + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + { + MP4::File f(filename.c_str()); + f.setNeroChapters(MP4::ChapterList{ + MP4::Chapter("Nero Only", 0) + }); + CPPUNIT_ASSERT(f.save()); + } + + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(1U, f.neroChapters().size()); + CPPUNIT_ASSERT(f.qtChapters().isEmpty()); + } + } + + // QT only -- Nero chpl atom must remain absent. + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + { + MP4::File f(filename.c_str()); + f.setQtChapters(MP4::ChapterList{ + MP4::Chapter("QT Only", 0) + }); + CPPUNIT_ASSERT(f.save()); + } + + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(1U, f.qtChapters().size()); + CPPUNIT_ASSERT(f.neroChapters().isEmpty()); + } + } + } + + void testLazyReadingAndWritingChapters() + { + // No reads or writes if chapters are not used + { + MockChapterFile f(TEST_FILE_PATH_C("no-tags.m4a")); + f.save(); + CPPUNIT_ASSERT(!f.chapterList); + } + // Do not read if already read, do not write if not modified + { + MockChapterFile f(TEST_FILE_PATH_C("no-tags.m4a")); + auto chapters = f.chapters(); + CPPUNIT_ASSERT(chapters == MockChapterList::mockChapters); + CPPUNIT_ASSERT(f.chapterList); + CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount); + chapters = f.chapters(); + CPPUNIT_ASSERT(chapters == MockChapterList::mockChapters); + CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount); + f.save(); + CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount); + CPPUNIT_ASSERT_EQUAL(0, f.chapterList->writeCount); + } + // Do not write if not modified + { + MockChapterFile f(TEST_FILE_PATH_C("no-tags.m4a")); + auto chapters = f.chapters(); + CPPUNIT_ASSERT(chapters == MockChapterList::mockChapters); + CPPUNIT_ASSERT(f.chapterList); + CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount); + f.setChapters(MockChapterList::mockChapters); + f.save(); + CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount); + CPPUNIT_ASSERT_EQUAL(0, f.chapterList->writeCount); + } + // Write if set without being read before + { + MockChapterFile f(TEST_FILE_PATH_C("no-tags.m4a")); + f.setChapters(MP4::ChapterList()); + f.save(); + CPPUNIT_ASSERT(f.chapterList); + CPPUNIT_ASSERT_EQUAL(0, f.chapterList->readCount); + CPPUNIT_ASSERT_EQUAL(1, f.chapterList->writeCount); + } + // Do write if modified + { + MockChapterFile f(TEST_FILE_PATH_C("no-tags.m4a")); + CPPUNIT_ASSERT(f.chapters() == MockChapterList::mockChapters); + CPPUNIT_ASSERT(f.chapterList); + CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount); + const auto chapters1 = MP4::ChapterList({ + MP4::Chapter("Chapter 1", 0), + }); + f.setChapters(chapters1); + CPPUNIT_ASSERT(f.chapters() == chapters1); + f.save(); + CPPUNIT_ASSERT(f.chapters() == chapters1); + CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount); + CPPUNIT_ASSERT_EQUAL(1, f.chapterList->writeCount); + f.setChapters(chapters1); + f.save(); + CPPUNIT_ASSERT_EQUAL(1, f.chapterList->writeCount); + auto chapters2 = MP4::ChapterList({ + MP4::Chapter("Chapter 1", 0), + MP4::Chapter("Chapter 2", 1), + }); + f.setChapters(chapters2); + CPPUNIT_ASSERT(f.chapters() == chapters2); + f.save(); + CPPUNIT_ASSERT(f.chapters() == chapters2); + CPPUNIT_ASSERT_EQUAL(2, f.chapterList->writeCount); + chapters2 = MP4::ChapterList({ + MP4::Chapter("Chapter 1", 0), + MP4::Chapter("Chapter 2", 2), + }); + f.setChapters(chapters2); + f.save(); + CPPUNIT_ASSERT_EQUAL(3, f.chapterList->writeCount); + f.setChapters(chapters2); + CPPUNIT_ASSERT(f.chapters() == chapters2); + f.save(); + CPPUNIT_ASSERT(f.chapters() == chapters2); + CPPUNIT_ASSERT_EQUAL(3, f.chapterList->writeCount); + const auto chapters3 = MP4::ChapterList({ + MP4::Chapter("Chapter 1", 0), + MP4::Chapter("Chapter 3", 2), + }); + f.setChapters(chapters3); + CPPUNIT_ASSERT(f.chapters() == chapters3); + f.save(); + CPPUNIT_ASSERT(f.chapters() == chapters3); + CPPUNIT_ASSERT_EQUAL(4, f.chapterList->writeCount); + f.setChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.chapters().isEmpty()); + f.save(); + CPPUNIT_ASSERT(f.chapters().isEmpty()); + CPPUNIT_ASSERT_EQUAL(5, f.chapterList->writeCount); + } + } + }; CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4);