Skip to content

Commit

Permalink
feat: allow to convert ugoira files using FFmpeg (fix #3093)
Browse files Browse the repository at this point in the history
  • Loading branch information
Bionus committed Jan 31, 2024
1 parent 03af210 commit 8497191
Show file tree
Hide file tree
Showing 15 changed files with 371 additions and 26 deletions.
6 changes: 6 additions & 0 deletions src/gui/src/settings/options-window.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ OptionsWindow::OptionsWindow(Profile *profile, ThemeLoader *themeLoader, QWidget
});
ffmpegVersionWatcher->setFuture(ffmpegVersion);
ui->checkConversionFFmpegRemuxWebmToMp4->setChecked(settings->value("Save/FFmpegRemuxWebmToMp4", false).toBool());
ui->checkConversionUgoiraEnabled->setChecked(settings->value("Save/ConvertUgoira", false).toBool());
ui->comboConversionUgoiraTargetExtension->setCurrentText(settings->value("Save/ConvertUgoiraFormat", "gif").toString().toUpper());
ui->checkConversionUgoiraDelete->setChecked(settings->value("Save/ConvertUgoiraDeleteOriginal", false).toBool());

// Metadata using Windows Property System
#ifndef WIN_FILE_PROPS
Expand Down Expand Up @@ -1274,6 +1277,9 @@ void OptionsWindow::save()
}

settings->setValue("FFmpegRemuxWebmToMp4", ui->checkConversionFFmpegRemuxWebmToMp4->isChecked());
settings->setValue("ConvertUgoira", ui->checkConversionUgoiraEnabled->isChecked());
settings->setValue("ConvertUgoiraFormat", ui->comboConversionUgoiraTargetExtension->currentText().toLower());
settings->setValue("ConvertUgoiraDeleteOriginal", ui->checkConversionUgoiraDelete->isChecked());

settings->setValue("MetadataPropsysExtensions", ui->lineMetadataPropsysExtensions->text());
settings->beginWriteArray("MetadataPropsys");
Expand Down
112 changes: 97 additions & 15 deletions src/gui/src/settings/options-window.ui
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>747</width>
<height>628</height>
<width>743</width>
<height>619</height>
</rect>
</property>
<property name="windowTitle">
Expand Down Expand Up @@ -1181,8 +1181,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>476</width>
<height>475</height>
<width>473</width>
<height>466</height>
</rect>
</property>
<layout class="QVBoxLayout" name="scrollAreaWidgetLayout">
Expand Down Expand Up @@ -1463,6 +1463,56 @@
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="labelConversionUgoiraTargetExtension">
<property name="text">
<string>Target format</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QComboBox" name="comboConversionUgoiraTargetExtension">
<item>
<property name="text">
<string>GIF</string>
</property>
</item>
<item>
<property name="text">
<string>WEBM</string>
</property>
</item>
<item>
<property name="text">
<string>APNG</string>
</property>
</item>
<item>
<property name="text">
<string>WEBP</string>
</property>
</item>
<item>
<property name="text">
<string>MKV</string>
</property>
</item>
</widget>
</item>
<item row="4" column="0" colspan="2">
<widget class="QCheckBox" name="checkConversionUgoiraDelete">
<property name="text">
<string>Delete original ugoira ZIP files</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QCheckBox" name="checkConversionUgoiraEnabled">
<property name="text">
<string>Convert ugoira ZIP files</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
Expand Down Expand Up @@ -5996,8 +6046,8 @@
<slot>addLogFile()</slot>
<hints>
<hint type="sourcelabel">
<x>336</x>
<y>40</y>
<x>584</x>
<y>97</y>
</hint>
<hint type="destinationlabel">
<x>659</x>
Expand Down Expand Up @@ -6044,8 +6094,8 @@
<slot>addSourceRegistry()</slot>
<hints>
<hint type="sourcelabel">
<x>336</x>
<y>40</y>
<x>584</x>
<y>81</y>
</hint>
<hint type="destinationlabel">
<x>738</x>
Expand Down Expand Up @@ -6080,7 +6130,7 @@
<y>40</y>
</hint>
<hint type="destinationlabel">
<x>749</x>
<x>742</x>
<y>79</y>
</hint>
</hints>
Expand All @@ -6096,8 +6146,8 @@
<y>263</y>
</hint>
<hint type="destinationlabel">
<x>718</x>
<y>325</y>
<x>720</x>
<y>308</y>
</hint>
</hints>
</connection>
Expand All @@ -6108,12 +6158,44 @@
<slot>setDisabled(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>523</x>
<y>507</y>
<x>336</x>
<y>40</y>
</hint>
<hint type="destinationlabel">
<x>336</x>
<y>40</y>
</hint>
</hints>
</connection>
<connection>
<sender>checkConversionUgoiraEnabled</sender>
<signal>toggled(bool)</signal>
<receiver>comboConversionUgoiraTargetExtension</receiver>
<slot>setEnabled(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>371</x>
<y>113</y>
</hint>
<hint type="destinationlabel">
<x>458</x>
<y>138</y>
</hint>
</hints>
</connection>
<connection>
<sender>checkConversionUgoiraEnabled</sender>
<signal>toggled(bool)</signal>
<receiver>checkConversionUgoiraDelete</receiver>
<slot>setEnabled(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>322</x>
<y>114</y>
</hint>
<hint type="destinationlabel">
<x>515</x>
<y>522</y>
<x>324</x>
<y>171</y>
</hint>
</hints>
</connection>
Expand Down
3 changes: 2 additions & 1 deletion src/lib/src/downloader/image-downloader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,15 @@ void ImageDownloader::save()
// Always load details if the API doesn't provide the file URL in the listing page
const QStringList forcedTokens = m_image->parentSite()->getApis().first()->forcedTokens();
const bool needFileUrl = forcedTokens.contains("*") || forcedTokens.contains("file_url");
const bool needUgoiraData = m_image->extension() == QStringLiteral("zip") && m_profile->getSettings()->value("Save/ConvertUgoira", false).toBool();

// If we use direct saving or don't want to load tags, we directly save the image
const int globalNeedTags = needExactTags(m_profile->getSettings());
const int localNeedTags = m_filename.needExactTags(m_image->parentSite(), m_profile->getSettings());
const int needTags = qMax(globalNeedTags, localNeedTags);
const bool filenameNeedTags = needTags == 2 || (needTags == 1 && m_image->hasUnknownTag());
const bool blacklistNeedTags = m_blacklist != nullptr && !m_blacklist->isEmpty() && m_image->tags().isEmpty();
if (!blacklistNeedTags && !needFileUrl && (!m_loadTags || !m_paths.isEmpty() || !filenameNeedTags)) {
if (!blacklistNeedTags && !needFileUrl && !needUgoiraData && (!m_loadTags || !m_paths.isEmpty() || !filenameNeedTags)) {
loadedSave(Image::LoadTagsResult::Ok);
return;
}
Expand Down
117 changes: 108 additions & 9 deletions src/lib/src/ffmpeg.cpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#include "ffmpeg.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QProcess>
#include <QTemporaryDir>
#include "functions.h"
#include "logger.h"
#include "utils/zip.h"


QString FFmpeg::version(int msecs)
Expand Down Expand Up @@ -41,7 +45,7 @@ QString FFmpeg::remux(const QString &file, const QString &extension, bool delete
{
// 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;
QString destination = info.path() + QDir::separator() + info.completeBaseName() + "." + extension;

// Ensure the operation is safe to do
if (!QFile::exists(file)) {
Expand All @@ -53,13 +57,113 @@ QString FFmpeg::remux(const QString &file, const QString &extension, bool delete
return file;
}

// Execute the conversion command
const QStringList params = { "-n", "-loglevel", "error", "-i", file, "-c", "copy", destination };
if (!execute(params, msecs)) {
return file;
}

// Copy file creation information
setFileCreationDate(destination, info.lastModified());

// On success, delete the original file if requested
if (deleteOriginal) {
QFile::remove(file);
}

return destination;
}

QString FFmpeg::convertUgoira(const QString &file, const QList<QPair<QString, int>> &frameInformation, 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);
QString destination = info.path() + QDir::separator() + info.completeBaseName() + "." + extension;

// Ensure the operation is safe to do
if (info.suffix() != QStringLiteral("zip")) {
log(QStringLiteral("Cannot convert ugoira file that is not a ZIP: `%1`").arg(file), Logger::Error);
return file;
}
if (!QFile::exists(file)) {
log(QStringLiteral("Cannot convert ugoira file that does not exist: `%1`").arg(file), Logger::Error);
return file;
}
if (QFile::exists(destination)) {
log(QStringLiteral("Converting the ugoira file `%1` would overwrite another file: `%2`").arg(file, destination), Logger::Error);
return file;
}

// Extract the ugoira ZIP file
QTemporaryDir tmpDir;
const QString tmpDirPath = tmpDir.path();
if (!tmpDir.isValid() || !unzipFile(file, tmpDir.path())) {
log(QStringLiteral("Could not extract ugoira ZIP file `%1` into directory: `%2`").arg(file, destination), Logger::Error);
return file;
}

// List all frame files from the ZIP
QStringList frameFiles = QDir(tmpDir.path()).entryList(QDir::Files | QDir::NoDotAndDotDot);
if (frameInformation.count() != frameFiles.count()) {
log(QStringLiteral("Could not extract ugoira ZIP file `%1` into directory: `%2`").arg(file, destination), Logger::Error);
return file;
}

// Build the ffmpeg concatenation string
QFile ffconcatFile(tmpDir.filePath("ffconcat.txt"));
if (!ffconcatFile.open(QFile::WriteOnly)) {
log(QStringLiteral("Could not create temporary ffconcat file: `%1`").arg(ffconcatFile.fileName()), Logger::Error);
return file;
}
QString ffconcat = "ffconcat version 1.0\n";
for (const auto &frame : frameInformation) {
ffconcat += "file " + (frame.first.isEmpty() ? frameFiles.takeFirst() : frame.first) + '\n';
ffconcat += "duration " + QString::number(float(frame.second) / 1000) + '\n';
}
ffconcatFile.write(ffconcat.toUtf8());
ffconcatFile.close();

// Build the params
QStringList params = { "-n", "-loglevel", "error", "-i", ffconcatFile.fileName() };
if (extension == QStringLiteral("gif")) {
params.append({ "-filter_complex", "[0:v]split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", "-vsync", "0" });
} else if (extension == QStringLiteral("apng")) {
params.append({ "-c:v", "apng", "-plays", "0", "-vsync", "0" });
} else if (extension == QStringLiteral("webp")) {
params.append({ "-c:v", "libwebp", "-lossless", "0", "-compression_level", "5", "-quality", "100", "-loop", "0", "-vsync", "0" });
} else if (extension == QStringLiteral("webm")) {
params.append({ "-c:v", "libvpx-vp9", "-lossless", "0", "-crf", "15", "-b", "0", "-vsync", "0" });
} else {
params.append({ "-c:v", "copy" });
}
params.append(destination);

// Execute the conversion command
if (!execute(params, msecs)) {
return file;
}

// Copy file creation information
setFileCreationDate(destination, info.lastModified());

// On success, delete the original file if requested
if (deleteOriginal) {
QFile::remove(file);
}

return destination;
}


bool FFmpeg::execute(const QStringList &params, int msecs)
{
QProcess process;
process.start("ffmpeg", { "-n", "-loglevel", "error", "-i", file, "-c", "copy", destination });
process.start("ffmpeg", params);

// Ensure the process started successfully
if (!process.waitForStarted(msecs)) {
log(QStringLiteral("Could not start FFmpeg"));
return file;
return false;
}

// Wait for FFmpeg to finish
Expand All @@ -75,10 +179,5 @@ QString FFmpeg::remux(const QString &file, const QString &extension, bool delete
log(QString("[Exiftool] %1").arg(standardError), Logger::Error);
}

// On success, delete the original file if requested
if (ok && deleteOriginal) {
QFile::remove(file);
}

return destination;
return ok;
}
15 changes: 15 additions & 0 deletions src/lib/src/ffmpeg.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ class FFmpeg
* @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);

/**
* Convert a ugoira ZIP file to a different format.
*
* @param file The file to remux.
* @param frameInformation A list of (frameFile, delay) tuples representing each frame in this ugoira ZIP file.
* @param extension The target extension (ex: "gif").
* @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 if the original was deleted, the original file path otherwise.
*/
static QString convertUgoira(const QString &file, const QList<QPair<QString, int>> &frameInformation, const QString &extension, bool deleteOriginal = true, int msecs = 30000);

protected:
static bool execute(const QStringList &params, int msecs = 30000);
};

#endif // FFMPEG_H
1 change: 1 addition & 0 deletions src/lib/src/models/api/api-endpoint.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class ApiEndpoint

virtual PageUrl url(const QMap<QString, QVariant> &query, int page, int limit, const PageInformation &lastPage, Site *site) const = 0;
virtual ParsedPage parse(Page *parentPage, const QString &source, int statusCode, int first) const = 0;
virtual QVariant parseAny(const QString &source, int statusCode) const = 0;
};

#endif // API_ENDPOINT_H
7 changes: 7 additions & 0 deletions src/lib/src/models/api/javascript-api-endpoint.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,10 @@ ParsedPage JavascriptApiEndpoint::parse(Page *parentPage, const QString &source,

return ret;
}

QVariant JavascriptApiEndpoint::parseAny(const QString &source, int statusCode) const
{
const QJSValue parseFunction = m_endpoint.property("parse");
const QJSValue &result = parseFunction.call(QList<QJSValue> { source, statusCode });
return result.toVariant();
}
Loading

0 comments on commit 8497191

Please # to comment.