diff --git a/NEWS.md b/NEWS.md index a2ea2c0a5..ab6c4c2e7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,8 @@ * translations: added a Belarusian translation of the programs & the man pages by prydespar (see `AUTHORS`). +* mkvmerge, MKVToolNix GUI's chapter editor: added support for reading + chapters from ffmpeg metadata files. Implements #3676. # Version 82.0 "I'm The President" 2024-01-02 diff --git a/src/common/chapters/chapters.cpp b/src/common/chapters/chapters.cpp index 1a9d462ca..2b9ac0d87 100644 --- a/src/common/chapters/chapters.cpp +++ b/src/common/chapters/chapters.cpp @@ -16,6 +16,7 @@ #include #include +#include #include @@ -29,6 +30,7 @@ #include "common/error.h" #include "common/iso3166.h" #include "common/locale.h" +#include "common/math_fwd.h" #include "common/mm_io_x.h" #include "common/mm_file_io.h" #include "common/mm_proxy_io.h" @@ -350,6 +352,146 @@ parse_simple(mm_text_io_c *in, return 0 == num ? kax_cptr{} : chaps; } +bool +probe_ffmpeg_meta(mm_text_io_c *in) { + std::string line; + + in->setFilePointer(0); + + if (!in->getline2(line)) + return false; + + mtx::string::strip(line); + + return line == ";FFMETADATA1"; +} + +kax_cptr +parse_ffmpeg_meta(mm_text_io_c *in, + int64_t min_ts, + int64_t max_ts, + int64_t offset, + mtx::bcp47::language_c const &language, + std::string const &charset) { + in->setFilePointer(0); + + std::string line, title; + std::optional start_scaled, end_scaled; + mtx_mp_rational_t time_base; + kax_cptr chapters; + libmatroska::KaxEditionEntry *edition{}; + charset_converter_cptr cc_utf8; + bool in_chapter = false; + bool do_convert = in->get_byte_order_mark() == byte_order_mark_e::none; + + if (do_convert) + cc_utf8 = charset_converter_c::init(charset); + + auto use_language = language.is_valid() ? language + : g_default_language.is_valid() ? g_default_language + : mtx::bcp47::language_c::parse("eng"s); + + QRegularExpression + start_line_re{ Q("^start *=([0-9]+)"), QRegularExpression::CaseInsensitiveOption}, + end_line_re{ Q("^end *=([0-9]+)"), QRegularExpression::CaseInsensitiveOption}, + title_line_re{ Q("^title *=(.*)"), QRegularExpression::CaseInsensitiveOption}, + time_base_line_re{ Q("^timebase *=([0-9]+)/([0-9]+)"), QRegularExpression::CaseInsensitiveOption}; + QRegularExpressionMatch matches; + + auto reset_values = [&]() { + start_scaled.reset(); + end_scaled.reset(); + title.clear(); + + time_base = mtx::rational(1, 1); + }; + + auto add_chapter_atom = [&]() { + if (!start_scaled || !in_chapter) { + reset_values(); + return; + } + + auto start = mtx::to_int_rounded(*start_scaled * time_base); + + if ((start < min_ts) || ((max_ts != -1) && (start > max_ts))) { + reset_values(); + return; + } + + if (!chapters) { + chapters = std::make_shared(); + edition = &get_child(*chapters); + } + + auto &atom = add_empty_child(*edition); + get_child(atom).SetValue(create_unique_number(UNIQUE_CHAPTER_IDS)); + get_child(atom).SetValue(start - offset); + + if (end_scaled) { + auto end = mtx::to_int_rounded(*end_scaled * time_base); + get_child(atom).SetValue(end - offset); + } + + auto &display = get_child(atom); + get_child(display).SetValueUTF8(title); + + if (use_language.is_valid()) { + get_child(display).SetValue(use_language.get_closest_iso639_2_alpha_3_code()); + if (!mtx::bcp47::language_c::is_disabled()) + get_child(display).SetValue(use_language.format()); + else + delete_children(display); + } + + if (!g_default_country.empty()) + get_child(display).SetValue(g_default_country); + + reset_values(); + }; + + while (in->getline2(line)) { + if (do_convert) + line = cc_utf8->utf8(line); + + mtx::string::strip(line); + if (line.empty() || (line[0] == ';') || (line[0] == '#')) + continue; + + if (mtx::string::to_lower_ascii(line) == "[chapter]") { + add_chapter_atom(); + in_chapter = true; + + } else if (line[0] == '[') { + add_chapter_atom(); + in_chapter = false; + + } else if (!in_chapter) + continue; + + else if ((matches = title_line_re.match(Q(line))).hasMatch()) + title = to_utf8(matches.captured(1)); + + else if ((matches = start_line_re.match(Q(line))).hasMatch()) + start_scaled = matches.captured(1).toLongLong(); + + else if ((matches = end_line_re.match(Q(line))).hasMatch()) + end_scaled = matches.captured(1).toLongLong(); + + else if ((matches = time_base_line_re.match(Q(line))).hasMatch()) { + auto numerator = matches.captured(1).toLongLong(); + auto deniminator = matches.captured(2).toLongLong(); + + if ((numerator != 0) && (deniminator != 0)) + time_base = mtx::rational(numerator, deniminator) * 1'000'000'000; + } + } + + add_chapter_atom(); + + return chapters; +} + /** \brief Probe a file for different chapter formats and parse the file. The file \a file_name is opened and checked for supported chapter formats. @@ -483,6 +625,11 @@ parse(mm_text_io_c *in, *format = format_e::cue; return parse_cue(in, min_ts, max_ts, offset, language, charset, tags); + } else if (probe_ffmpeg_meta(in)) { + if (format) + *format = format_e::ffmpeg_meta; + return parse_ffmpeg_meta(in, min_ts, max_ts, offset, language, charset); + } else if (format) *format = format_e::xml; diff --git a/src/common/chapters/chapters.h b/src/common/chapters/chapters.h index 6641ee4f7..a46f7e903 100644 --- a/src/common/chapters/chapters.h +++ b/src/common/chapters/chapters.h @@ -54,6 +54,7 @@ enum class format_e { xml, ogg, cue, + ffmpeg_meta, }; mtx::chapters::kax_cptr diff --git a/src/mkvtoolnix-gui/chapter_editor/tool.cpp b/src/mkvtoolnix-gui/chapter_editor/tool.cpp index 4c028412f..616f59d71 100644 --- a/src/mkvtoolnix-gui/chapter_editor/tool.cpp +++ b/src/mkvtoolnix-gui/chapter_editor/tool.cpp @@ -193,7 +193,7 @@ Tool::selectFileToOpen(bool append) { #endif // HAVE_DVDREAD auto fileNames = Util::getOpenFileNames(this, append ? QY("Append files in chapter editor") : QY("Open files in chapter editor"), Util::Settings::get().lastOpenDirPath(), - QY("Supported file types") + Q(" (*.cue%1 *.mpls *.mkv *.mka *.mks *.mk3d *.txt *.webm *.xml);;").arg(ifo) + + QY("Supported file types") + Q(" (*.cue%1 *.meta *.mpls *.mkv *.mka *.mks *.mk3d *.txt *.webm *.xml);;").arg(ifo) + QY("Matroska files") + Q(" (*.mkv *.mka *.mks *.mk3d);;") + QY("WebM files") + Q(" (*.webm);;") + QY("Blu-ray playlist files") + Q(" (*.mpls);;") + @@ -201,6 +201,7 @@ Tool::selectFileToOpen(bool append) { QY("XML chapter files") + Q(" (*.xml);;") + QY("Simple OGM-style chapter files") + Q(" (*.txt);;") + QY("Cue sheet files") + Q(" (*.cue);;") + + QY("ffmpeg metadata files") + Q(" (*.meta);;") + QY("All files") + Q(" (*)")); if (fileNames.isEmpty()) return; diff --git a/src/mkvtoolnix-gui/merge/file_identification_thread.cpp b/src/mkvtoolnix-gui/merge/file_identification_thread.cpp index 5ae3318ef..46d5ef16c 100644 --- a/src/mkvtoolnix-gui/merge/file_identification_thread.cpp +++ b/src/mkvtoolnix-gui/merge/file_identification_thread.cpp @@ -26,7 +26,7 @@ class FileIdentificationWorkerPrivate { QVector m_toIdentify; QMutex m_mutex; QAtomicInteger m_abortPlaylistScan; - QRegularExpression m_simpleChaptersRE, m_xmlChaptersRE, m_xmlSegmentInfoRE, m_xmlTagsRE; + QRegularExpression m_simpleChaptersRE, m_ffmpegMetaChaptersRE, m_xmlChaptersRE, m_xmlSegmentInfoRE, m_xmlTagsRE; explicit FileIdentificationWorkerPrivate() { @@ -39,11 +39,12 @@ FileIdentificationWorker::FileIdentificationWorker(QObject *parent) : QObject{parent} , p_ptr{new FileIdentificationWorkerPrivate{}} { - auto p = p_func(); - p->m_simpleChaptersRE = QRegularExpression{R"(^CHAPTER\d{2}=[\s\S]*CHAPTER\d{2}NAME=)"}; - p->m_xmlChaptersRE = QRegularExpression{R"(^(\s*)*<\?xml[^>]+version[\s\S]*?\?>[\s\S]*?)"}; - p->m_xmlSegmentInfoRE = QRegularExpression{R"(^(\s*)*<\?xml[^>]+version[\s\S]*?\?>[\s\S]*?)"}; - p->m_xmlTagsRE = QRegularExpression{R"(^(\s*)*<\?xml[^>]+version[\s\S]*?\?>[\s\S]*?)"}; + auto p = p_func(); + p->m_simpleChaptersRE = QRegularExpression{R"(^CHAPTER\d{2}=[\s\S]*CHAPTER\d{2}NAME=)"}; + p->m_xmlChaptersRE = QRegularExpression{R"(^(\s*)*<\?xml[^>]+version[\s\S]*?\?>[\s\S]*?)"}; + p->m_xmlSegmentInfoRE = QRegularExpression{R"(^(\s*)*<\?xml[^>]+version[\s\S]*?\?>[\s\S]*?)"}; + p->m_xmlTagsRE = QRegularExpression{R"(^(\s*)*<\?xml[^>]+version[\s\S]*?\?>[\s\S]*?)"}; + p->m_ffmpegMetaChaptersRE = QRegularExpression{R"(;FFMETADATA1)"}; } FileIdentificationWorker::~FileIdentificationWorker() { @@ -179,7 +180,7 @@ FileIdentificationWorker::determineIfFileThatShouldBeSelectedElsewhere(QString c auto content = QString::fromUtf8(bytes); - if (content.contains(p->m_simpleChaptersRE) || content.contains(p->m_xmlChaptersRE)) + if (content.contains(p->m_simpleChaptersRE) || content.contains(p->m_ffmpegMetaChaptersRE) || content.contains(p->m_xmlChaptersRE)) return IdentificationPack::FileType::Chapters; else if (content.contains(p->m_xmlSegmentInfoRE)) diff --git a/tests/results.txt b/tests/results.txt index 89bbad2f4..bd7337c5d 100644 --- a/tests/results.txt +++ b/tests/results.txt @@ -609,3 +609,4 @@ T_0761hevc_dolby_vision_dual_layer_single_track_annex_b:4a40b17c0d60a1c82c109975 T_0762dovi_combining_bl_and_el:880014a766cf6b5929cf180bea71d046-2eeed24f6b3e3f2d6da0e8d25896089b-88193925316d397c23c6a3a6cb2ab740-ef854e7bae7c85ad46b1a0d71d09d3fc:passed:20231129-202129:0.387596229 T_0763vp9_alpha_channel_data:ac329c9d810d9b19fb3ffcde6f12af2a-OK:passed:20231203-190856:0.059109676 T_0764ui_locale_be_BY:a44c54eadfb4c8fbdc104b75aa1de1c1-72b98d331b58a0f95e10159fca191b52:passed:20240120-191944:0.043782405 +T_0765ffmpeg_metadata_chapters:f16630c4019413c98b75b959a5697391-6b2b843310e80367b5fe5aaa8a5d51c4:passed:20240310-145016:0.047790171 diff --git a/tests/test-0765ffmpeg_metadata_chapters.rb b/tests/test-0765ffmpeg_metadata_chapters.rb new file mode 100755 index 000000000..b6b965471 --- /dev/null +++ b/tests/test-0765ffmpeg_metadata_chapters.rb @@ -0,0 +1,8 @@ +#!/usr/bin/ruby -w + +# T_765ffmpeg_metadata_chapters +describe "mkvmerge / reading chapters from ffmpeg metadata files" + +Dir.glob("data/chapters/ffmpeg-metadata/*.meta").sort.each do |file_name| + test_merge "data/subtitles/srt/ven.srt", :args => "--chapters #{file_name}" +end