From 48bcda69a7835999ece808db278994e52df67a62 Mon Sep 17 00:00:00 2001 From: Bionus Date: Mon, 29 Jan 2024 23:49:58 +0100 Subject: [PATCH] feat: allow to remux WEBM to MP4 using FFmpeg (fix #3092) --- src/gui/src/settings/options-window.cpp | 18 +++++ src/gui/src/settings/options-window.ui | 60 ++++++++++++++- src/lib/src/downloader/image-downloader.cpp | 6 +- src/lib/src/ffmpeg.cpp | 84 +++++++++++++++++++++ src/lib/src/ffmpeg.h | 30 ++++++++ src/lib/src/loader/downloadable.h | 2 +- src/lib/src/models/image.cpp | 24 ++++-- src/lib/src/models/image.h | 4 +- 8 files changed, 211 insertions(+), 17 deletions(-) create mode 100644 src/lib/src/ffmpeg.cpp create mode 100644 src/lib/src/ffmpeg.h diff --git a/src/gui/src/settings/options-window.cpp b/src/gui/src/settings/options-window.cpp index 2d2bb7dbe..89fe1cddf 100644 --- a/src/gui/src/settings/options-window.cpp +++ b/src/gui/src/settings/options-window.cpp @@ -22,6 +22,7 @@ #include "backup.h" #include "custom-buttons.h" #include "exiftool.h" +#include "ffmpeg.h" #include "functions.h" #include "filename/conditional-filename.h" #include "helpers.h" @@ -138,6 +139,21 @@ OptionsWindow::OptionsWindow(Profile *profile, ThemeLoader *themeLoader, QWidget ui->keyAcceptDialogue->setKeySequence(getKeySequence(settings, "keyAcceptDialog", Qt::CTRL | Qt::Key_Y)); ui->keyDeclineDialogue->setKeySequence(getKeySequence(settings, "keyDeclineDialog", Qt::CTRL | Qt::Key_N)); + // FFmpeg format conversion + QFuture ffmpegVersion = QtConcurrent::run([=]() { + return FFmpeg::version(); + }); + auto *ffmpegVersionWatcher = new QFutureWatcher(this); + connect(ffmpegVersionWatcher, &QFutureWatcher::finished, [=]() { + const QString &version = ffmpegVersion.result(); + ui->labelConversionFFmpegVersion->setText(version.isEmpty() ? tr("ffmpeg not found") : version); + if (version.isEmpty()) { + ui->labelConversionFFmpegVersion->setStyleSheet("color: red"); + } + }); + ffmpegVersionWatcher->setFuture(ffmpegVersion); + ui->checkConversionFFmpegRemuxWebmToMp4->setChecked(settings->value("Save/FFmpegRemuxWebmToMp4", false).toBool()); + // Metadata using Windows Property System #ifndef WIN_FILE_PROPS ui->groupMetadataPropsys->setEnabled(false); @@ -1257,6 +1273,8 @@ void OptionsWindow::save() tokenSettings->save(); } + settings->setValue("FFmpegRemuxWebmToMp4", ui->checkConversionFFmpegRemuxWebmToMp4->isChecked()); + settings->setValue("MetadataPropsysExtensions", ui->lineMetadataPropsysExtensions->text()); settings->beginWriteArray("MetadataPropsys"); for (int i = 0, j = 0; i < m_metadataPropsys.count(); ++i) { diff --git a/src/gui/src/settings/options-window.ui b/src/gui/src/settings/options-window.ui index 07e01e35d..2c4e03558 100644 --- a/src/gui/src/settings/options-window.ui +++ b/src/gui/src/settings/options-window.ui @@ -6,8 +6,8 @@ 0 0 - 743 - 619 + 747 + 628 @@ -98,6 +98,11 @@ Image size + + + Format conversion + + @@ -1176,8 +1181,8 @@ 0 0 - 473 - 466 + 476 + 475 @@ -1429,6 +1434,53 @@ + + + + + + FFmpeg + + + + + + Version + + + + + + + Loading... + + + + + + + Remux WEBM files to MP4 + + + + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + diff --git a/src/lib/src/downloader/image-downloader.cpp b/src/lib/src/downloader/image-downloader.cpp index ab2a030c1..7168f4229 100644 --- a/src/lib/src/downloader/image-downloader.cpp +++ b/src/lib/src/downloader/image-downloader.cpp @@ -429,7 +429,7 @@ QList ImageDownloader::afterTemporarySave(Image::SaveResult sav QList result; for (const QString &file : qAsConst(m_paths)) { - const QString path = file + suffix; + QString path = file + suffix; // Don't overwrite already existing files if (QFile::exists(file) || (!suffix.isEmpty() && QFile::exists(path))) { @@ -475,10 +475,10 @@ QList ImageDownloader::afterTemporarySave(Image::SaveResult sav } } - result.append({ path, size, saveResult }); if (m_postSave) { - m_image->postSave(path, size, saveResult, m_addMd5, m_startCommands, m_count); + path = m_image->postSave(path, size, saveResult, m_addMd5, m_startCommands, m_count); } + result.append({ path, size, saveResult }); } if (!moved) { diff --git a/src/lib/src/ffmpeg.cpp b/src/lib/src/ffmpeg.cpp new file mode 100644 index 000000000..ee7d0efbb --- /dev/null +++ b/src/lib/src/ffmpeg.cpp @@ -0,0 +1,84 @@ +#include "ffmpeg.h" +#include +#include +#include +#include "logger.h" + + +QString FFmpeg::version(int msecs) +{ + QProcess process; + process.start("ffmpeg", { "-version" }); + + if (!process.waitForStarted(msecs)) { + return ""; + } + if (!process.waitForFinished(msecs)) { + process.kill(); + return ""; + } + if (process.exitCode() != 0) { + return ""; + } + + const QString output = QString::fromLocal8Bit(process.readAllStandardOutput()); + QString line = output.split("\n").first().trimmed(); + + if (line.startsWith("ffmpeg version ")) { + line = line.mid(15); + } + + const qsizetype index = line.indexOf("Copyright"); + if (index != -1) { + return line.left(index).trimmed(); + } + + return line.trimmed(); +} + + +QString FFmpeg::remux(const QString &file, const QString &extension, bool deleteOriginal, int msecs) +{ + // Since the method takes an extension, build an absolute path to the input file with that extension + const QFileInfo info(file); + const QString destination = info.path() + QDir::separator() + info.completeBaseName() + "." + extension; + + // Ensure the operation is safe to do + if (!QFile::exists(file)) { + log(QStringLiteral("Cannot remux file that does not exist: `%1`").arg(file), Logger::Error); + return file; + } + if (QFile::exists(destination)) { + log(QStringLiteral("Remuxing the file `%1` would overwrite another file: `%2`").arg(file, destination), Logger::Error); + return file; + } + + QProcess process; + process.start("ffmpeg", { "-n", "-loglevel", "error", "-i", file, "-c", "copy", destination }); + + // Ensure the process started successfully + if (!process.waitForStarted(msecs)) { + log(QStringLiteral("Could not start FFmpeg")); + return file; + } + + // Wait for FFmpeg to finish + bool ok = process.waitForFinished(msecs); + + // Print stdout and stderr to the log + const QString standardOutput = QString::fromLocal8Bit(process.readAllStandardOutput()).trimmed(); + if (!standardOutput.isEmpty()) { + log(QString("[Exiftool] %1").arg(standardOutput), Logger::Debug); + } + const QString standardError = QString::fromLocal8Bit(process.readAllStandardError()).trimmed(); + if (!standardError.isEmpty()) { + log(QString("[Exiftool] %1").arg(standardError), Logger::Error); + } + + // On success, delete the original file if requested + if (ok && deleteOriginal) { + QFile::remove(file); + } + + return destination; +} diff --git a/src/lib/src/ffmpeg.h b/src/lib/src/ffmpeg.h new file mode 100644 index 000000000..2f923ad6c --- /dev/null +++ b/src/lib/src/ffmpeg.h @@ -0,0 +1,30 @@ +#ifndef FFMPEG_H +#define FFMPEG_H + +#include + + +class FFmpeg +{ + public: + /** + * Get the version of FFmpeg. + * + * @param msecs The duration to wait in milliseconds for the version command to run. + * @return The version number found, with basic parsing done (ex: "4.4.3"). + */ + static QString version(int msecs = 30000); + + /** + * Remux a file to a different format, copying the streams. + * + * @param file The file to remux. + * @param extension The target extension (ex: "mp4"). + * @param deleteOriginal Whether to delete the original file on success. + * @param msecs The duration to wait in milliseconds for the command to run. + * @return The destination file path on success, the original file path on error. + */ + static QString remux(const QString &file, const QString &extension, bool deleteOriginal = true, int msecs = 30000); +}; + +#endif // FFMPEG_H diff --git a/src/lib/src/loader/downloadable.h b/src/lib/src/loader/downloadable.h index caa3631d8..7d5e83bf5 100644 --- a/src/lib/src/loader/downloadable.h +++ b/src/lib/src/loader/downloadable.h @@ -51,7 +51,7 @@ class Downloadable virtual QStringList paths(const Filename &filename, const QString &folder, int count) const = 0; const QMap &tokens(Profile *profile) const; virtual SaveResult preSave(const QString &path, Size size) = 0; - virtual void postSave(const QString &path, Size size, SaveResult result, bool addMd5, bool startCommands, int count, bool basic = false) = 0; + virtual QString postSave(const QString &path, Size size, SaveResult result, bool addMd5, bool startCommands, int count, bool basic = false) = 0; virtual QColor color() const = 0; virtual QString tooltip() const = 0; diff --git a/src/lib/src/models/image.cpp b/src/lib/src/models/image.cpp index 65b614878..1bfe8df31 100644 --- a/src/lib/src/models/image.cpp +++ b/src/lib/src/models/image.cpp @@ -13,6 +13,7 @@ #include "downloader/extension-rotator.h" #include "exiftool.h" #include "favorite.h" +#include "ffmpeg.h" #include "filename/filename.h" #include "filtering/tag-filter-list.h" #include "functions.h" @@ -701,11 +702,9 @@ QString &pathTokens(QString &filename, const QString &path) .replace("%dir:nobackslash%", QString(dir).replace("\\", "/")) .replace("%dir%", dir); } -void Image::postSaving(const QString &path, Size size, bool addMd5, bool startCommands, int count, bool basic) +QString Image::postSaving(const QString &originalPath, Size size, bool addMd5, bool startCommands, int count, bool basic) { - if (addMd5) { - m_profile->addMd5(md5(), path); - } + QString path = originalPath; // Save info to a text file if (!basic) { @@ -767,8 +766,14 @@ void Image::postSaving(const QString &path, Size size, bool addMd5, bool startCo commands.after(); } - // Metadata const QString &ext = extension(); + + // FFmpeg + if (ext == QStringLiteral("webm") && m_settings->value("Save/FFmpegRemuxWebmToMp4", false).toBool()) { + path = FFmpeg::remux(path, "mp4"); + } + + // Metadata #ifdef WIN_FILE_PROPS const QStringList exts = m_settings->value("Save/MetadataPropsysExtensions", "jpg jpeg mp4").toString().split(' ', Qt::SkipEmptyParts); if (exts.isEmpty() || exts.contains(ext)) { @@ -799,7 +804,12 @@ void Image::postSaving(const QString &path, Size size, bool addMd5, bool startCo } } + if (addMd5) { + m_profile->addMd5(md5(), path); + } + setSavePath(path, size); + return path; } @@ -1218,10 +1228,10 @@ QMap Image::generateTokens(Profile *profile) const return tokens; } -void Image::postSave(const QString &path, Size size, SaveResult res, bool addMd5, bool startCommands, int count, bool basic) +QString Image::postSave(const QString &path, Size size, SaveResult res, bool addMd5, bool startCommands, int count, bool basic) { static const QList md5Results { SaveResult::Moved, SaveResult::Copied, SaveResult::Shortcut, SaveResult::Linked, SaveResult::Saved }; - postSaving(path, size, addMd5 && md5Results.contains(res), startCommands, count, basic); + return postSaving(path, size, addMd5 && md5Results.contains(res), startCommands, count, basic); } bool Image::isValid() const diff --git a/src/lib/src/models/image.h b/src/lib/src/models/image.h index 5270613fc..2a178072c 100644 --- a/src/lib/src/models/image.h +++ b/src/lib/src/models/image.h @@ -109,7 +109,7 @@ class Image : public QObject, public Downloadable QStringList paths(const Filename &filename, const QString &folder, int count) const override; QMap generateTokens(Profile *profile) const override; SaveResult preSave(const QString &path, Size size) override; - void postSave(const QString &path, Size size, SaveResult result, bool addMd5, bool startCommands, int count, bool basic = false) override; + QString postSave(const QString &path, Size size, SaveResult result, bool addMd5, bool startCommands, int count, bool basic = false) override; // Tokens template @@ -125,7 +125,7 @@ class Image : public QObject, public Downloadable protected: void init(); QString md5forced() const; - void postSaving(const QString &path, Size size, bool addMd5 = true, bool startCommands = false, int count = 1, bool basic = false); + QString postSaving(const QString &path, Size size, bool addMd5 = true, bool startCommands = false, int count = 1, bool basic = false); public slots: void loadDetails(bool rateLimit = false);