[multimedia/kid3] /: Mp4v2Metadata: Support chapters in MP4 files
Urs Fleisch
null at kde.org
Sat Jun 4 05:53:10 BST 2022
Git commit cdcbae4321d1ff1c19c0023821046600b04316cc by Urs Fleisch.
Committed on 04/06/2022 at 04:51.
Pushed by ufleisch into branch 'master'.
Mp4v2Metadata: Support chapters in MP4 files
BUG: 451787
M +17 -0 doc/en/index.docbook
M +6 -3 src/core/model/kid3application.cpp
M +1 -0 src/core/tags/frame.cpp
M +2 -1 src/gui/dialogs/editframefieldsdialog.cpp
M +214 -41 src/plugins/mp4v2metadata/m4afile.cpp
M +1 -1 src/plugins/mp4v2metadata/m4afile.h
https://invent.kde.org/multimedia/kid3/commit/cdcbae4321d1ff1c19c0023821046600b04316cc
diff --git a/doc/en/index.docbook b/doc/en/index.docbook
index 046e5527..b969a814 100644
--- a/doc/en/index.docbook
+++ b/doc/en/index.docbook
@@ -1016,6 +1016,23 @@ position</guimenuitem> from the context menu.
</para>
</sect2>
+<sect2 id="mp4-chapters">
+<title>Chapters in MP4 Files</title>
+
+<para>
+MP4 audiobooks typically have a <filename class="extension">.m4b</filename>
+extension and are rather large because they contain all chapters in a single
+file. To navigate in such files, they can contain chapter marks, which can be
+edited in &kid3; in a pseudo "Chapters" frame using the same editor which is
+used for <link linkend="synchronized-lyrics">synchronized lyrics</link>. Note,
+however, that this feature is only available with the
+<guilabel>Mp4v2Metadata</guilabel> plugin, so make sure that it is activated
+and above the <guilabel>TaglibMetadata</guilabel> plugin in the
+<guilabel>Plugins</guilabel> tab of the settings if you have to edit MP4
+chapters.
+</para>
+</sect2>
+
</sect1>
<sect1 id="file-menu">
diff --git a/src/core/model/kid3application.cpp b/src/core/model/kid3application.cpp
index e863caf4..4c980c7e 100644
--- a/src/core/model/kid3application.cpp
+++ b/src/core/model/kid3application.cpp
@@ -3487,7 +3487,8 @@ QString Kid3Application::getFrame(Frame::TagVersion tagMask,
if (it->getType() == Frame::FT_Picture ||
frmName.startsWith(QLatin1String("GEOB"))) {
PictureFrame::writeDataToFile(*it, dataFileName);
- } else if ((isSylt = frmName.startsWith(QLatin1String("SYLT"))) ||
+ } else if ((isSylt = frmName.startsWith(QLatin1String("SYLT")) ||
+ frmName == QLatin1String("Chapters")) ||
frmName.startsWith(QLatin1String("ETCO"))) {
QFile file(dataFileName);
if (file.open(QIODevice::WriteOnly)) {
@@ -3639,7 +3640,8 @@ bool Kid3Application::setFrame(Frame::TagVersion tagMask,
Frame::setField(frame, Frame::ID_Description, value);
PictureFrame::setDataFromFile(frame, dataFileName);
addFrame(tagNr, &frame);
- } else if ((isSylt = frmName.startsWith(QLatin1String("SYLT"))) ||
+ } else if ((isSylt = frmName.startsWith(QLatin1String("SYLT")) ||
+ frmName == QLatin1String("Chapters")) ||
frmName.startsWith(QLatin1String("ETCO"))) {
QFile file(dataFileName);
if (file.open(QIODevice::ReadOnly)) {
@@ -3719,7 +3721,8 @@ bool Kid3Application::setFrame(Frame::TagVersion tagMask,
PictureFrame::getMimeTypeForFile(dataFileName),
QFileInfo(dataFileName).fileName(), value);
PictureFrame::setDataFromFile(frame, dataFileName);
- } else if ((isSylt = frmName.startsWith(QLatin1String("SYLT"))) ||
+ } else if ((isSylt = frmName.startsWith(QLatin1String("SYLT")) ||
+ frmName == QLatin1String("Chapters")) ||
frmName.startsWith(QLatin1String("ETCO"))) {
QFile file(dataFileName);
if (file.open(QIODevice::ReadOnly)) {
diff --git a/src/core/tags/frame.cpp b/src/core/tags/frame.cpp
index a3c6178f..0dbd2a3d 100644
--- a/src/core/tags/frame.cpp
+++ b/src/core/tags/frame.cpp
@@ -323,6 +323,7 @@ QMap<QByteArray, QByteArray> getDisplayNamesOfIds()
{ "egid", QT_TRANSLATE_NOOP("@default", "Podcast GUID") },
{ "cmID", QT_TRANSLATE_NOOP("@default", "Composer ID") },
{ "xid ", QT_TRANSLATE_NOOP("@default", "XID") },
+ { "Chapters", QT_TRANSLATE_NOOP("@default", "Chapters") },
{ "IARL", QT_TRANSLATE_NOOP("@default", "Archival Location") },
{ "ICMS", QT_TRANSLATE_NOOP("@default", "Commissioned") },
{ "ICRP", QT_TRANSLATE_NOOP("@default", "Cropped") },
diff --git a/src/gui/dialogs/editframefieldsdialog.cpp b/src/gui/dialogs/editframefieldsdialog.cpp
index 23fa77a2..32de10e5 100644
--- a/src/gui/dialogs/editframefieldsdialog.cpp
+++ b/src/gui/dialogs/editframefieldsdialog.cpp
@@ -1450,7 +1450,8 @@ void EditFrameFieldsDialog::setFrame(const Frame& frame,
#endif
{
QString frameName = frame.getName();
- if (frameName.startsWith(QLatin1String("SYLT"))) {
+ if (frameName.startsWith(QLatin1String("SYLT")) ||
+ frameName == QLatin1String("Chapters")) {
auto timeEventCtl = new TimeEventFieldControl(
m_platformTools, m_app, fld, m_fields, taggedFile, tagNr,
TimeEventModel::SynchronizedLyrics);
diff --git a/src/plugins/mp4v2metadata/m4afile.cpp b/src/plugins/mp4v2metadata/m4afile.cpp
index fccfb86b..67cb5ef7 100644
--- a/src/plugins/mp4v2metadata/m4afile.cpp
+++ b/src/plugins/mp4v2metadata/m4afile.cpp
@@ -380,6 +380,123 @@ QByteArray getValueByteArray(const char* name,
return str;
}
+/**
+ * Set a SYLT frame with data from MP4 chapters.
+ * @param frame frame to set
+ * @param data list with time stamps and chapter titles
+ */
+void setMp4ChaptersFields(Frame& frame,
+ const QVariantList& data = QVariantList())
+{
+ frame.setExtendedType(Frame::ExtendedType(Frame::FT_Other,
+ QLatin1String("Chapters")));
+ frame.setValue(QString());
+
+ Frame::Field field;
+ Frame::FieldList& fields = frame.fieldList();
+ fields.clear();
+
+ field.m_id = Frame::ID_TimestampFormat;
+ field.m_value = 2; // milliseconds
+ fields.append(field);
+
+ field.m_id = Frame::ID_ContentType;
+ field.m_value = 0; // other
+ fields.append(field);
+
+ field.m_id = Frame::ID_Description;
+ field.m_value = QString();
+ fields.append(field);
+
+ field.m_id = Frame::ID_Data;
+ field.m_value = data;
+ fields.append(field);
+}
+
+/**
+ * Set a SYLT frame from MP4 chapters.
+ * @param chapterList MP4 chapters
+ * @param chapterCount number of elements in chapterList
+ * @param frame the SYLT frame is returned here
+ */
+void mp4ChaptersToFrame(const MP4Chapter_t* chapterList, uint32_t chapterCount,
+ Frame& frame)
+{
+ QVariantList data;
+ quint32 time = 0;
+ for (uint32_t i = 0; i < chapterCount; ++i) {
+ MP4Chapter_t chapter = chapterList[i];
+ data.append(time);
+ data.append(QString::fromUtf8(chapter.title));
+ time += chapter.duration;
+ }
+ data.append(time);
+ data.append(QString());
+ setMp4ChaptersFields(frame, data);
+}
+
+/**
+ * Set MP4 chapters from a SYLT frame.
+ * @param frame SYLT frame
+ * @param chapterList the chapters are returned here and must be freed using
+ * delete[] afterwards
+ * @param chapterCount the number of elements in @a chapterList is returned here
+ */
+void frameToMp4Chapters(const Frame& frame,
+ MP4Chapter_t*& chapterList, uint32_t& chapterCount)
+{
+ QVariantList data = Frame::getField(frame, Frame::ID_Data).toList();
+ int dataLen = data.size();
+ if (dataLen >= 2) {
+ quint32 lastTime = data.at(dataLen - 2).toUInt();
+ QString lastTitle = data.at(dataLen - 1).toString();
+ if (!lastTitle.trimmed().isEmpty()) {
+ data.append(lastTime);
+ data.append(QString());
+ dataLen += 2;
+ }
+ }
+ if (dataLen > 2 && (dataLen & 1) == 0) {
+ chapterCount = (dataLen - 2) / 2;
+ chapterList = new MP4Chapter_t[chapterCount];
+ quint32 lastTime = 0;
+ uint32_t i = 0;
+ QListIterator<QVariant> it(data);
+ while (it.hasNext()) {
+ quint32 time = it.next().toUInt();
+ if (!it.hasNext())
+ break;
+
+ QByteArray chapterTitle = it.next().toString().trimmed().toUtf8();
+ if (i < chapterCount) {
+ MP4Chapter_t* mp4Chapter = &chapterList[i];
+ qstrncpy(mp4Chapter->title, chapterTitle.constData(),
+ sizeof(mp4Chapter->title) - 1);
+ mp4Chapter->title[sizeof(mp4Chapter->title) - 1] = '\0';
+ }
+ if (i > 0 && i <= chapterCount) {
+ chapterList[i - 1].duration = time - lastTime;
+ }
+ lastTime = time;
+ ++i;
+ }
+ } else {
+ chapterCount = 0;
+ chapterList = nullptr;
+ }
+}
+
+/**
+ * Check if two chapters frames are equal.
+ * @param f1 first chapters frame
+ * @param f2 second chapters frame
+ * @return true if equal.
+ */
+bool areMp4ChaptersFieldsEqual(const Frame& f1, const Frame& f2)
+{
+ return Frame::getField(f1, Frame::ID_Data) == Frame::getField(f2, Frame::ID_Data);
+}
+
}
/**
@@ -411,7 +528,7 @@ void M4aFile::readTags(bool force)
bool priorIsTagInformationRead = isTagInformationRead();
if (force || !m_fileRead) {
m_metadata.clear();
- m_pictures.clear();
+ m_extraFrames.clear();
markTagUnchanged(Frame::Tag_2);
m_fileRead = true;
QByteArray fnIn =
@@ -472,7 +589,7 @@ void M4aFile::readTags(bool force)
frame.setIndex(Frame::toNegativeIndex(i));
frame.setExtendedType(Frame::ExtendedType(Frame::FT_Picture,
QLatin1String(key)));
- m_pictures.append(frame);
+ m_extraFrames.append(frame);
}
}
} else {
@@ -489,6 +606,17 @@ void M4aFile::readTags(bool force)
}
MP4ItmfItemListFree(list);
}
+
+ MP4Chapter_t* chapterList = nullptr;
+ uint32_t chapterCount = 0;
+ MP4GetChapters(handle, &chapterList, &chapterCount, MP4ChapterTypeQt);
+ if (chapterList) {
+ Frame frame;
+ mp4ChaptersToFrame(chapterList, chapterCount, frame);
+ frame.setIndex(Frame::toNegativeIndex(m_extraFrames.size()));
+ m_extraFrames.append(frame);
+ MP4Free(chapterList);
+ }
#elif defined HAVE_MP4V2_MP4GETMETADATABYINDEX_CHARPP_ARG
static char notFreeFormStr[] = "NOFF";
static char freeFormStr[] = "----";
@@ -876,32 +1004,46 @@ bool M4aFile::writeTags(bool force, bool* renamed, bool preserve)
}
}
- const auto frames = m_pictures;
+ bool hasChapters = false;
+ const auto frames = m_extraFrames;
for (const Frame& frame : frames) {
- QByteArray ba;
- if (PictureFrame::getData(frame, ba)) {
+ if (frame.getType() == Frame::FT_Other &&
+ frame.getName() == QLatin1String("Chapters")) {
+ uint32_t chapterCount = 0;
+ MP4Chapter_t* chapterList = nullptr;
+ frameToMp4Chapters(frame, chapterList, chapterCount);
+ MP4SetChapters(handle, chapterList, chapterCount, MP4ChapterTypeQt);
+ hasChapters = true;
+ delete [] chapterList;
+ } else {
+ QByteArray ba;
+ if (PictureFrame::getData(frame, ba)) {
#if MPEG4IP_MAJOR_MINOR_VERSION >= 0x0109
- MP4TagArtwork artwork;
- artwork.data = ba.data();
- artwork.size = static_cast<uint32_t>(ba.size());
- artwork.type = MP4_ART_JPEG;
- QString mimeType;
- if (PictureFrame::getMimeType(frame, mimeType)) {
- if (mimeType == QLatin1String("image/png")) {
- artwork.type = MP4_ART_PNG;
- } else if (mimeType == QLatin1String("image/bmp")) {
- artwork.type = MP4_ART_BMP;
- } else if (mimeType == QLatin1String("image/gif")) {
- artwork.type = MP4_ART_GIF;
+ MP4TagArtwork artwork;
+ artwork.data = ba.data();
+ artwork.size = static_cast<uint32_t>(ba.size());
+ artwork.type = MP4_ART_JPEG;
+ QString mimeType;
+ if (PictureFrame::getMimeType(frame, mimeType)) {
+ if (mimeType == QLatin1String("image/png")) {
+ artwork.type = MP4_ART_PNG;
+ } else if (mimeType == QLatin1String("image/bmp")) {
+ artwork.type = MP4_ART_BMP;
+ } else if (mimeType == QLatin1String("image/gif")) {
+ artwork.type = MP4_ART_GIF;
+ }
}
- }
- MP4TagsAddArtwork(tags, &artwork);
+ MP4TagsAddArtwork(tags, &artwork);
#else
- MP4SetMetadataCoverArt(handle, reinterpret_cast<uint8_t*>(ba.data()),
- ba.size());
+ MP4SetMetadataCoverArt(handle, reinterpret_cast<uint8_t*>(ba.data()),
+ ba.size());
#endif
+ }
}
}
+ if (!hasChapters) {
+ MP4DeleteChapters(handle);
+ }
#if MPEG4IP_MAJOR_MINOR_VERSION >= 0x0109
MP4TagsStore(tags, handle);
@@ -953,7 +1095,7 @@ void M4aFile::clearTags(bool force)
bool priorIsTagInformationRead = isTagInformationRead();
m_metadata.clear();
- m_pictures.clear();
+ m_extraFrames.clear();
markTagUnchanged(Frame::Tag_2);
m_fileRead = false;
notifyModelDataChanged(priorIsTagInformationRead);
@@ -972,7 +1114,7 @@ void M4aFile::deleteFrames(Frame::TagNumber tagNr, const FrameFilter& flt)
if (flt.areAllEnabled()) {
m_metadata.clear();
- m_pictures.clear();
+ m_extraFrames.clear();
markTagChanged(Frame::Tag_2, Frame::FT_UnknownFrame);
} else {
bool changed = false;
@@ -986,9 +1128,21 @@ void M4aFile::deleteFrames(Frame::TagNumber tagNr, const FrameFilter& flt)
++it;
}
}
- if (flt.isEnabled(Frame::FT_Picture) && !m_pictures.isEmpty()) {
- m_pictures.clear();
- changed = true;
+ bool pictureEnabled = flt.isEnabled(Frame::FT_Picture);
+ bool chaptersEnabled = flt.isEnabled(Frame::FT_Other,
+ QLatin1String("Chapters"));
+ if ((pictureEnabled || chaptersEnabled) && !m_extraFrames.isEmpty()) {
+ for (auto it = m_extraFrames.begin(); it != m_extraFrames.end();) {
+ Frame::Type type = it->getType();
+ if ((pictureEnabled && type == Frame::FT_Picture) ||
+ (chaptersEnabled && type == Frame::FT_Other &&
+ it->getName() == QLatin1String("Chapters"))) {
+ it = m_extraFrames.erase(it);
+ changed = true;
+ } else {
+ ++it;
+ }
+ }
}
if (changed) {
markTagChanged(Frame::Tag_2, Frame::FT_UnknownFrame);
@@ -1161,15 +1315,21 @@ bool M4aFile::getFrame(Frame::TagNumber tagNr, Frame::Type type, Frame& frame) c
bool M4aFile::setFrame(Frame::TagNumber tagNr, const Frame& frame)
{
if (tagNr == Frame::Tag_2) {
- if (frame.getType() == Frame::FT_Picture) {
+ if ((frame.getType() == Frame::FT_Picture) ||
+ (frame.getType() == Frame::FT_Other &&
+ frame.getName() == QLatin1String("Chapters"))) {
int idx = Frame::fromNegativeIndex(frame.getIndex());
- if (idx >= 0 && idx < m_pictures.size()) {
+ if (idx >= 0 && idx < m_extraFrames.size()) {
Frame newFrame(frame);
- if (PictureFrame::areFieldsEqual(m_pictures[idx], newFrame)) {
- m_pictures[idx].setValueChanged(false);
+ if ((frame.getType() == Frame::FT_Picture &&
+ PictureFrame::areFieldsEqual(m_extraFrames[idx], newFrame)) ||
+ (frame.getType() == Frame::FT_Other &&
+ frame.getName() == QLatin1String("Chapters") &&
+ areMp4ChaptersFieldsEqual(m_extraFrames[idx], newFrame))) {
+ m_extraFrames[idx].setValueChanged(false);
} else {
- m_pictures[idx] = newFrame;
- markTagChanged(tagNr, Frame::FT_Picture);
+ m_extraFrames[idx] = newFrame;
+ markTagChanged(tagNr, frame.getType());
}
return true;
} else {
@@ -1258,11 +1418,21 @@ bool M4aFile::addFrame(Frame::TagNumber tagNr, Frame& frame)
if (frame.getFieldList().empty()) {
PictureFrame::setFields(frame);
}
- frame.setIndex(Frame::toNegativeIndex(m_pictures.size()));
- m_pictures.append(frame);
+ frame.setIndex(Frame::toNegativeIndex(m_extraFrames.size()));
+ m_extraFrames.append(frame);
markTagChanged(tagNr, Frame::FT_Picture);
return true;
}
+ if (type == Frame::FT_Other &&
+ frame.getName() == QLatin1String("Chapters")) {
+ if (frame.getFieldList().empty()) {
+ setMp4ChaptersFields(frame);
+ }
+ frame.setIndex(Frame::toNegativeIndex(m_extraFrames.size()));
+ m_extraFrames.append(frame);
+ markTagChanged(Frame::Tag_2, type);
+ return true;;
+ }
QString name;
if (type != Frame::FT_Other) {
name = getNameForType(type);
@@ -1289,15 +1459,17 @@ bool M4aFile::addFrame(Frame::TagNumber tagNr, Frame& frame)
bool M4aFile::deleteFrame(Frame::TagNumber tagNr, const Frame& frame)
{
if (tagNr == Frame::Tag_2) {
- if (frame.getType() == Frame::FT_Picture) {
+ if ((frame.getType() == Frame::FT_Picture) ||
+ (frame.getType() == Frame::FT_Other &&
+ frame.getName() == QLatin1String("Chapters"))) {
int idx = Frame::fromNegativeIndex(frame.getIndex());
- if (idx >= 0 && idx < m_pictures.size()) {
- m_pictures.removeAt(idx);
- while (idx < m_pictures.size()) {
- m_pictures[idx].setIndex(Frame::toNegativeIndex(idx));
+ if (idx >= 0 && idx < m_extraFrames.size()) {
+ m_extraFrames.removeAt(idx);
+ while (idx < m_extraFrames.size()) {
+ m_extraFrames[idx].setIndex(Frame::toNegativeIndex(idx));
++idx;
}
- markTagChanged(tagNr, Frame::FT_Picture);
+ markTagChanged(tagNr, frame.getType());
return true;
}
}
@@ -1333,7 +1505,7 @@ void M4aFile::getAllFrames(Frame::TagNumber tagNr, FrameCollection& frames)
value = QString::fromUtf8((*it).data(), (*it).size());
frames.insert(Frame(type, value, name, i++));
}
- for (auto it = m_pictures.constBegin(); it != m_pictures.constEnd(); ++it) {
+ for (auto it = m_extraFrames.constBegin(); it != m_extraFrames.constEnd(); ++it) {
frames.insert(*it);
}
frames.addMissingStandardFrames();
@@ -1409,6 +1581,7 @@ QStringList M4aFile::getFrameIds(Frame::TagNumber tagNr) const
QLatin1String("tves") << QLatin1String("tvnn") << QLatin1String("tvsh") << QLatin1String("tvsn") <<
QLatin1String("purl") << QLatin1String("egid") << QLatin1String("cmID") << QLatin1String("xid ");
#endif
+ lst << QLatin1String("Chapters");
return lst;
}
diff --git a/src/plugins/mp4v2metadata/m4afile.h b/src/plugins/mp4v2metadata/m4afile.h
index 27b820b3..29a3e1ee 100644
--- a/src/plugins/mp4v2metadata/m4afile.h
+++ b/src/plugins/mp4v2metadata/m4afile.h
@@ -248,5 +248,5 @@ private:
/** Metadata. */
MetadataMap m_metadata;
- QList<Frame> m_pictures;
+ QList<Frame> m_extraFrames;
};
More information about the kde-doc-english
mailing list