[frameworks/kfilemetadata] /: [FFmpegExtractor] Extract cover image from Matroska streams

Stefan Brüns <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 3543e7154cd0b46b00ccdc50a6ada9f61cfccaff by Stefan Brüns.
Committed on 10/08/2026 at 16:38.
Pushed by bruns into branch 'master'.

[FFmpegExtractor] Extract cover image from Matroska streams

FFmpeg exposes Matroska attachments in streams with an ATTACHED_PIC
disposition.

CCBUG: 500113

M  +1    -0    autotests/extractorcoveragetest.cpp
M  +49   -0    autotests/ffmpegextractortest.cpp
A  +-    --    autotests/samplefiles/test_cover.mkv
M  +46   -3    src/extractors/ffmpegextractor.cpp

https://invent.kde.org/frameworks/kfilemetadata/-/commit/3543e7154cd0b46b00ccdc50a6ada9f61cfccaff

diff --git a/autotests/extractorcoveragetest.cpp b/autotests/extractorcoveragetest.cpp
index a76c6aad..4420b592 100644
--- a/autotests/extractorcoveragetest.cpp
+++ b/autotests/extractorcoveragetest.cpp
@@ -76,6 +76,7 @@ private Q_SLOTS:
             { "test_missing_content.odt",      "application/vnd.oasis.opendocument.text"},
             { "test_missing_meta.odt",         "application/vnd.oasis.opendocument.text"},
             { "test.mkv",                      "video/x-matroska"},
+            { "test_cover.mkv",                "video/x-matroska"},
             { "test.mp3",                      "audio/mpeg"},
             { "test.id3v1.mp3",                "audio/mpeg"},
             { "test.aax",                      "audio/vnd.audible.aax"},
diff --git a/autotests/ffmpegextractortest.cpp b/autotests/ffmpegextractortest.cpp
index 0dc408d7..febc16a8 100644
--- a/autotests/ffmpegextractortest.cpp
+++ b/autotests/ffmpegextractortest.cpp
@@ -14,6 +14,7 @@
 #include <QTest>
 
 using namespace KFileMetaData;
+using namespace Qt::StringLiterals;
 
 namespace KFileMetaData {
 class ffmpegExtractorTest : public QObject
@@ -21,14 +22,18 @@ class ffmpegExtractorTest : public QObject
     Q_OBJECT
 
 private Q_SLOTS:
+    void initTestCase();
     void testNoExtraction();
     void testVideoProperties();
     void testVideoProperties_data();
     void testMetaData();
     void testMetaData_data();
+    void testCoverimage();
+    void testCoverimage_data();
 
 private:
     QMimeDatabase mimeDb;
+    QByteArray m_coverImage;
 };
 } // namespace KFileMetaData
 
@@ -42,6 +47,13 @@ QString testFilePath(const QString& baseName, const QString& extension)
 
 } // namespace
 
+void ffmpegExtractorTest::initTestCase()
+{
+    QFile imgFile(testFilePath(u"cover"_s, u"jpg"_s));
+    QVERIFY(imgFile.open(QIODevice::ReadOnly));
+    m_coverImage = imgFile.readAll();
+}
+
 void ffmpegExtractorTest::testNoExtraction()
 {
     QString fileName = testFilePath(QStringLiteral("test"), QStringLiteral("webm"));
@@ -166,6 +178,43 @@ void ffmpegExtractorTest::testMetaData()
     QCOMPARE(result.properties().value(Property::ReleaseYear).toInt(), 2019);
 }
 
+void ffmpegExtractorTest::testCoverimage_data()
+{
+    using IT = EmbeddedImageData::ImageType;
+    QTest::addColumn<QString>("baseName");
+    QTest::addColumn<QString>("fileType");
+    QTest::addColumn<QList<IT>>("coverTypes");
+
+    // clang-format off
+    QTest::addRow("Matroska Video")            << u"test"_s       << u"mkv"_s << QList<IT>();
+    QTest::addRow("Matroska Video with cover") << u"test_cover"_s << u"mkv"_s << QList<IT>{IT::FrontCover};
+    // clang-format on
+}
+
+void ffmpegExtractorTest::testCoverimage()
+{
+    QFETCH(QString, baseName);
+    QFETCH(QString, fileType);
+    QFETCH(QList<EmbeddedImageData::ImageType>, coverTypes);
+
+    const QString fileName = testFilePath(baseName, fileType);
+    const QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name();
+
+    FFmpegExtractor plugin{this};
+
+    SimpleExtractionResult result(fileName, mimeType, ExtractionResult::ExtractImageData);
+    plugin.extract(&result);
+
+    const auto imageMap = result.imageData();
+    QCOMPARE(imageMap.size(), coverTypes.size());
+    for (const auto type : coverTypes) {
+        QVERIFY(imageMap.contains(type));
+    }
+    for (const auto [type, imageData]: imageMap.asKeyValueRange()) {
+        QCOMPARE(imageData, m_coverImage);
+    }
+}
+
 QTEST_GUILESS_MAIN(ffmpegExtractorTest)
 
 #include "ffmpegextractortest.moc"
diff --git a/autotests/samplefiles/test_cover.mkv b/autotests/samplefiles/test_cover.mkv
new file mode 100644
index 00000000..71577d76
Binary files /dev/null and b/autotests/samplefiles/test_cover.mkv differ
diff --git a/src/extractors/ffmpegextractor.cpp b/src/extractors/ffmpegextractor.cpp
index 712d6724..d80d15d4 100644
--- a/src/extractors/ffmpegextractor.cpp
+++ b/src/extractors/ffmpegextractor.cpp
@@ -10,6 +10,7 @@
 
 
 #include "ffmpegextractor.h"
+#include "embeddedimagedata.h"
 #include "kfilemetadata_debug.h"
 
 #ifdef __cplusplus
@@ -30,6 +31,36 @@ extern "C" {
 
 using namespace KFileMetaData;
 
+namespace {
+QMap<EmbeddedImageData::ImageType, QByteArray>
+extractCover(const AVStream* stream)
+{
+    if (const auto e = av_dict_get(stream->metadata, "filename", nullptr, 0)) {
+        const std::string_view value{e->value};
+        if (!(value.starts_with("cover") || value.starts_with("small_cover"))) {
+            qCDebug(KFILEMETADATA_LOG) << "Ignore attached" << value;
+            return {};
+        }
+        if (!(value.ends_with(".png") || value.ends_with(".jpg") || value.ends_with(".jpeg"))) {
+            qCDebug(KFILEMETADATA_LOG) << "Ignore attached" << value;
+            return {};
+        }
+    }
+    if (const auto e = av_dict_get(stream->metadata, "mimetype", nullptr, 0)) {
+        const std::string_view value{e->value};
+        if (!value.starts_with("image/")) {
+            qCDebug(KFILEMETADATA_LOG) << "Ignore attached file with type" << value;
+            return {};
+        }
+    }
+    const auto &ap = stream->attached_pic;
+    if (!(ap.data && ap.size)) {
+        return {};
+    }
+    return {std::pair{EmbeddedImageData::FrontCover, QByteArray{QByteArrayView(ap.data, ap.size)}}};
+}
+} // namespace <anonymous>
+
 FFmpegExtractor::FFmpegExtractor(QObject* parent)
     : ExtractorPlugin(parent)
 {
@@ -88,7 +119,7 @@ void FFmpegExtractor::extract(ExtractionResult* result)
 
     result->addType(Type::Video);
 
-    if (result->inputFlags() & ExtractionResult::ExtractMetaData) {
+    if (result->inputFlags() & (ExtractionResult::ExtractMetaData | ExtractionResult::ExtractImageData)) {
         int totalSecs = fmt_ctx->duration / AV_TIME_BASE;
         int bitrate = fmt_ctx->bit_rate;
 
@@ -96,12 +127,24 @@ void FFmpegExtractor::extract(ExtractionResult* result)
         result->add(Property::BitRate, bitrate);
 
         const int index_stream = av_find_default_stream_index(fmt_ctx);
-        if (index_stream >= 0) {
-            AVStream* stream = fmt_ctx->streams[index_stream];
+
+        for (unsigned int index = 0; index < fmt_ctx->nb_streams; index++) {
+            AVStream* stream = fmt_ctx->streams[index];
 
             const AVCodecParameters* codec = stream->codecpar;
 
             if (codec->codec_type == AVMEDIA_TYPE_VIDEO) {
+                if (stream->disposition & AV_DISPOSITION_ATTACHED_PIC) {
+                    if (auto images = extractCover(stream); !images.isEmpty()) {
+                        result->addImageData(std::move(images));
+                    }
+                    continue;
+                } else if (index_stream < 0) {
+                    continue;
+                } else if (unsigned int t = index_stream; index != t) {
+                    continue;
+                }
+
                 result->add(Property::Width, codec->width);
                 result->add(Property::Height, codec->height);
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.