diff --git a/src/app/GUI/mainwindow.cpp b/src/app/GUI/mainwindow.cpp index 47e6780a1..e1fb04033 100644 --- a/src/app/GUI/mainwindow.cpp +++ b/src/app/GUI/mainwindow.cpp @@ -64,6 +64,7 @@ #include "GUI/edialogs.h" #include "eimporters.h" #include "dialogs/exportsvgdialog.h" +#include "dialogs/exportlottiedialog.h" #include "widgets/alignwidget.h" #include "widgets/welcomedialog.h" #include "Boxes/textbox.h" @@ -107,6 +108,8 @@ MainWindow::MainWindow(Document& document, , mSaveBackAct(nullptr) , mPreviewSVGAct(nullptr) , mExportSVGAct(nullptr) + , mPreviewLottieAct(nullptr) + , mExportLottieAct(nullptr) , mRenderVideoAct(nullptr) , mCloseProjectAct(nullptr) , mLinkedAct(nullptr) @@ -424,6 +427,8 @@ void MainWindow::updateSettingsForCurrentCanvas(Canvas* const scene) if (mPreviewSVGAct) { mPreviewSVGAct->setEnabled(scene); } if (mExportSVGAct) { mExportSVGAct->setEnabled(scene); } + if (mPreviewLottieAct) { mPreviewLottieAct->setEnabled(scene); } + if (mExportLottieAct) { mExportLottieAct->setEnabled(scene); } if (mSaveAct) { mSaveAct->setEnabled(scene); } if (mSaveAsAct) { mSaveAsAct->setEnabled(scene); } if (mSaveBackAct) { mSaveBackAct->setEnabled(scene); } @@ -1210,6 +1215,27 @@ void MainWindow::exportSVG(const bool &preview) } } +const QString MainWindow::checkBeforeExportLottie() +{ + return tr("Lottie export is currently an initial native exporter. " + "It writes composition timing, canvas size, background, and " + "keeps scene layers isolated for future vector mapping."); +} + +void MainWindow::exportLottie(const bool &preview) +{ + const auto dialog = new ExportLottieDialog(this, + preview ? QString() : checkBeforeExportLottie()); + dialog->setAttribute(Qt::WA_DeleteOnClose); + connect(dialog, &ExportLottieDialog::formatChanged, + this, &MainWindow::updatePreviewLottieAction); + if (!preview) { + dialog->show(); + } else { + dialog->showPreview(true /* close when done */); + } +} + void MainWindow::updateLastOpenDir(const QString &path) { if (path.isEmpty()) { return; } diff --git a/src/app/GUI/mainwindow.h b/src/app/GUI/mainwindow.h index 4c8b4f549..40989f2f9 100644 --- a/src/app/GUI/mainwindow.h +++ b/src/app/GUI/mainwindow.h @@ -167,6 +167,8 @@ class MainWindow : public QMainWindow void saveBackup(); const QString checkBeforeExportSVG(); void exportSVG(const bool &preview = false); + const QString checkBeforeExportLottie(); + void exportLottie(const bool &preview = false); void updateLastOpenDir(const QString &path); void updateLastSaveDir(const QString &path); const QString getLastOpenDir(); @@ -219,6 +221,7 @@ class MainWindow : public QMainWindow static MainWindow *sInstance; void updateRecentMenu(); + void updatePreviewLottieAction(const QString& format = QString()); void addRecentFile(const QString &recent); void readRecentFiles(); @@ -243,6 +246,8 @@ class MainWindow : public QMainWindow QAction *mSaveBackAct; QAction *mPreviewSVGAct; QAction *mExportSVGAct; + QAction *mPreviewLottieAct; + QAction *mExportLottieAct; QAction *mRenderVideoAct; QAction *mCloseProjectAct; QAction *mLinkedAct; diff --git a/src/app/GUI/menu.cpp b/src/app/GUI/menu.cpp index 59f5858bb..48daf428b 100644 --- a/src/app/GUI/menu.cpp +++ b/src/app/GUI/menu.cpp @@ -36,6 +36,25 @@ using namespace Friction; +void MainWindow::updatePreviewLottieAction(const QString& format) +{ + if (!mPreviewLottieAct) { return; } + + const QString currentFormat = format.isEmpty() ? + AppSupport::getSettings("exportLottie", "format", "json").toString() : + format; + const bool dotLottie = currentFormat == QStringLiteral("lottie"); + const QString text = dotLottie ? + tr("Preview dotLottie", "MenuBar_File") : + tr("Preview Lottie", "MenuBar_File"); + const QString toolTip = dotLottie ? + tr("Preview dotLottie Animation in Web Browser") : + tr("Preview Lottie Animation in Web Browser"); + mPreviewLottieAct->setText(text); + mPreviewLottieAct->setToolTip(toolTip); + mPreviewLottieAct->setData(toolTip); +} + void MainWindow::setupMenuBar() { mMenuBar = new QMenuBar(nullptr); @@ -144,6 +163,31 @@ void MainWindow::setupMenuBar() mExportSVGAct->setObjectName("ExportSVGAct"); cmdAddAction(mExportSVGAct); + mPreviewLottieAct = mFileMenu->addAction(QIcon::fromTheme("seq_preview"), + tr("Preview Lottie", "MenuBar_File"), + this, [this]{ exportLottie(true); }, + QKeySequence(AppSupport::getSettings("shortcuts", + "previewLottie", + "Alt+Ctrl+F12").toString())); + mPreviewLottieAct->setEnabled(false); + mPreviewLottieAct->setToolTip(tr("Preview Lottie Animation in Web Browser")); + mPreviewLottieAct->setData(mPreviewLottieAct->toolTip()); + mPreviewLottieAct->setObjectName("PreviewLottieAct"); + cmdAddAction(mPreviewLottieAct); + updatePreviewLottieAction(); + + mExportLottieAct = mFileMenu->addAction(QIcon::fromTheme("output"), + tr("Export Lottie", "MenuBar_File"), + this, &MainWindow::exportLottie, + QKeySequence(AppSupport::getSettings("shortcuts", + "exportLottie", + "Alt+Shift+F12").toString())); + mExportLottieAct->setEnabled(false); + mExportLottieAct->setToolTip(tr("Export Lottie Animation")); + mExportLottieAct->setData(mExportLottieAct->toolTip()); + mExportLottieAct->setObjectName("ExportLottieAct"); + cmdAddAction(mExportLottieAct); + mFileMenu->addSeparator(); mCloseProjectAct = mFileMenu->addAction(QIcon::fromTheme("dialog-cancel"), tr("Close", "MenuBar_File"), @@ -844,6 +888,8 @@ void MainWindow::setupMenuBar() mToolbar->addAction(mPreviewSVGAct); mToolbar->addAction(mExportSVGAct); + mToolbar->addAction(mPreviewLottieAct); + mToolbar->addAction(mExportLottieAct); mToolbar->updateActions(); setMenuBar(mMenuBar); diff --git a/src/core/Boxes/imagebox.cpp b/src/core/Boxes/imagebox.cpp index 5ebf5d39c..d86a7003c 100644 --- a/src/core/Boxes/imagebox.cpp +++ b/src/core/Boxes/imagebox.cpp @@ -103,6 +103,21 @@ void ImageBox::setFilePath(const QString &path) { rename(QFileInfo(path).completeBaseName()); } +QString ImageBox::filePath() const +{ + return mFileHandler.path(); +} + +bool ImageBox::hasImage() const +{ + return mFileHandler && mFileHandler->hasImage(); +} + +sk_sp ImageBox::image() const +{ + return mFileHandler ? mFileHandler->getImage() : nullptr; +} + void ImageBox::reload() { if(mFileHandler) mFileHandler->reloadAction(); } diff --git a/src/core/Boxes/imagebox.h b/src/core/Boxes/imagebox.h index 270c12a15..ecb46a340 100644 --- a/src/core/Boxes/imagebox.h +++ b/src/core/Boxes/imagebox.h @@ -65,6 +65,9 @@ class CORE_EXPORT ImageBox : public BoundingBox { void changeSourceFile(); void setFilePath(const QString &path); + QString filePath() const; + bool hasImage() const; + sk_sp image() const; void reload(); private: diff --git a/src/core/Boxes/textbox.cpp b/src/core/Boxes/textbox.cpp index d06a22e45..2ff981609 100644 --- a/src/core/Boxes/textbox.cpp +++ b/src/core/Boxes/textbox.cpp @@ -254,6 +254,10 @@ SkScalar TextBox::getFontSize() const { return mFont.getSize(); } +SkScalar TextBox::getFontSpacing() const { + return mFont.getSpacing(); +} + const QString& TextBox::getFontFamily() const { return mFamily; } @@ -262,6 +266,58 @@ const QString& TextBox::getCurrentValue() const { return mText->getCurrentValue(); } +QString TextBox::getValueAtRelFrame(const qreal relFrame) const { + return mText->getValueAtRelFrame(relFrame); +} + +Qt::Alignment TextBox::getTextHAlignment() const { + return mHAlignment; +} + +Qt::Alignment TextBox::getTextVAlignment() const { + return mVAlignment; +} + +qreal TextBox::getLetterSpacing(const qreal relFrame) const { + return mLetterSpacing->getEffectiveValue(relFrame); +} + +qreal TextBox::getWordSpacing(const qreal relFrame) const { + return mWordSpacing->getEffectiveValue(relFrame); +} + +qreal TextBox::getLineSpacing(const qreal relFrame) const { + return mLineSpacing->getEffectiveValue(relFrame); +} + +qreal TextBox::getMaxLineWidth(const qreal relFrame) const { + const QString textAtFrame = mText->getValueAtRelFrame(relFrame); + const qreal letterSpacing = mLetterSpacing->getEffectiveValue(relFrame); + const qreal wordSpacing = mWordSpacing->getEffectiveValue(relFrame); + + qreal maxWidth = 0; + const QStringList lines = textAtFrame.split(QRegExp("\n|\r\n|\r")); + for(const auto& line : lines) { + maxWidth = qMax(maxWidth, + horizontalAdvance(mFont, line, letterSpacing, wordSpacing)); + } + return maxWidth; +} + +qreal TextBox::getTextWidth(const QString& text, + const qreal letterSpacing, + const qreal wordSpacing) const { + return horizontalAdvance(mFont, text, letterSpacing, wordSpacing); +} + +SkPath TextBox::getTextPath(const QString& text, + const qreal x, + const qreal y) const { + SkPath path; + textToPath(x, y, text, path); + return path; +} + void TextBox::setupCanvasMenu(PropertyMenu * const menu) { if (menu->hasActionsForType()) { return; } diff --git a/src/core/Boxes/textbox.h b/src/core/Boxes/textbox.h index ebc3b8851..555c44700 100644 --- a/src/core/Boxes/textbox.h +++ b/src/core/Boxes/textbox.h @@ -65,9 +65,23 @@ class CORE_EXPORT TextBox : public PathBox { Canvas * const scene); SkScalar getFontSize() const; + SkScalar getFontSpacing() const; const QString& getFontFamily() const; const SkFontStyle& getFontStyle() const; const QString& getCurrentValue() const; + QString getValueAtRelFrame(const qreal relFrame) const; + Qt::Alignment getTextHAlignment() const; + Qt::Alignment getTextVAlignment() const; + qreal getLetterSpacing(const qreal relFrame) const; + qreal getWordSpacing(const qreal relFrame) const; + qreal getLineSpacing(const qreal relFrame) const; + qreal getMaxLineWidth(const qreal relFrame) const; + qreal getTextWidth(const QString& text, + const qreal letterSpacing = 0, + const qreal wordSpacing = 1) const; + SkPath getTextPath(const QString& text, + const qreal x, + const qreal y) const; void openTextEditor(QWidget* dialogParent); diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index ff9b50d30..ba07310e2 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -314,6 +314,15 @@ set( Animators/intanimator.cpp Animators/key.cpp Animators/boolanimator.cpp + lottie/dotlottiewriter.cpp + lottie/lottieexporter.cpp + lottie/lottieanimatedproperty.cpp + lottie/lottieblendmode.cpp + lottie/lottiejsonoptimizer.cpp + lottie/lottierealkeyframes.cpp + lottie/lottielayerbuilder.cpp + lottie/lottieparenting.cpp + lottie/lottiepatheffects.cpp svgexporter.cpp svgexporthelpers.cpp svgimporter.cpp @@ -717,6 +726,8 @@ set( pointtypemenu.h wrappedint.h windowsincludes.h + lottie/dotlottiewriter.h + lottie/lottieparenting.h zipfileloader.h zipfilesaver.h outputsettings.h diff --git a/src/core/lottie/dotlottiewriter.cpp b/src/core/lottie/dotlottiewriter.cpp new file mode 100644 index 000000000..e669b0f76 --- /dev/null +++ b/src/core/lottie/dotlottiewriter.cpp @@ -0,0 +1,181 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +*/ + +#include "lottie/dotlottiewriter.h" + +#include "exceptions.h" +#include "lottie/lottiejsonoptimizer.h" + +#include +#include +#include + +namespace { + +struct ZipEntry { + ZipEntry(const QByteArray& entryName, const QByteArray& entryData) + : name(entryName), data(entryData) {} + + QByteArray name; + QByteArray data; + QByteArray compressed; + quint32 crc = 0; + quint32 offset = 0; +}; + +void append16(QByteArray& data, const quint16 value) +{ + data.append(static_cast(value & 0xff)); + data.append(static_cast((value >> 8) & 0xff)); +} + +void append32(QByteArray& data, const quint32 value) +{ + append16(data, static_cast(value & 0xffff)); + append16(data, static_cast((value >> 16) & 0xffff)); +} + +quint32 crc32(const QByteArray& data) +{ + quint32 crc = 0xffffffff; + for (const auto byte : data) { + crc ^= static_cast(byte); + for (int bit = 0; bit < 8; bit++) { + crc = (crc >> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return crc ^ 0xffffffff; +} + +QByteArray rawDeflate(const QByteArray& data) +{ + const QByteArray compressed = qCompress(data, 9); + if (compressed.size() < 10) { RuntimeThrow("Could not compress dotLottie entry"); } + // qCompress adds a four-byte size prefix and a zlib wrapper. ZIP entries + // require the raw Deflate payload inside that wrapper. + return compressed.mid(6, compressed.size() - 10); +} + +void writeZip(const QString& path, QList entries) +{ + QByteArray archive; + for (auto& entry : entries) { + entry.crc = crc32(entry.data); + entry.compressed = rawDeflate(entry.data); + entry.offset = archive.size(); + + append32(archive, 0x04034b50); + append16(archive, 20); + append16(archive, 0x0800); + append16(archive, 8); + append16(archive, 0); + append16(archive, 0); + append32(archive, entry.crc); + append32(archive, entry.compressed.size()); + append32(archive, entry.data.size()); + append16(archive, entry.name.size()); + append16(archive, 0); + archive.append(entry.name); + archive.append(entry.compressed); + } + + const quint32 directoryOffset = archive.size(); + for (const auto& entry : entries) { + append32(archive, 0x02014b50); + append16(archive, 20); + append16(archive, 20); + append16(archive, 0x0800); + append16(archive, 8); + append16(archive, 0); + append16(archive, 0); + append32(archive, entry.crc); + append32(archive, entry.compressed.size()); + append32(archive, entry.data.size()); + append16(archive, entry.name.size()); + append16(archive, 0); + append16(archive, 0); + append16(archive, 0); + append16(archive, 0); + append32(archive, 0); + append32(archive, entry.offset); + archive.append(entry.name); + } + + const quint32 directorySize = archive.size() - directoryOffset; + append32(archive, 0x06054b50); + append16(archive, 0); + append16(archive, 0); + append16(archive, entries.size()); + append16(archive, entries.size()); + append32(archive, directorySize); + append32(archive, directoryOffset); + append16(archive, 0); + + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || + file.write(archive) != archive.size()) { + RuntimeThrow(QStringLiteral("Could not write dotLottie file:\n\"%1\"") + .arg(path).toStdString()); + } +} + +} + +void DotLottieWriter::write(const QString& path, + const QString& animationId, + const QString& animationName, + QJsonObject animation, + const QString& assetsPath) +{ + QList entries; + auto assets = animation.value(QStringLiteral("assets")).toArray(); + for (int i = 0; i < assets.size(); i++) { + auto asset = assets.at(i).toObject(); + // Precomposition assets also live in this array, but only image + // assets have a path and need to be copied into the archive. + if (!asset.contains(QStringLiteral("p")) || + asset.value(QStringLiteral("p")).toString().isEmpty()) { continue; } + if (asset.value(QStringLiteral("e")).toInt() != 0) { continue; } + + const QString fileName = asset.value(QStringLiteral("p")).toString(); + QFile image(assetsPath + QStringLiteral("/") + fileName); + if (!image.open(QIODevice::ReadOnly)) { + RuntimeThrow(QStringLiteral("Could not read dotLottie image:\n\"%1\"") + .arg(image.fileName()).toStdString()); + } + entries.append({QStringLiteral("images/").append(fileName).toUtf8(), + image.readAll()}); + asset.insert(QStringLiteral("u"), QString()); + asset.insert(QStringLiteral("p"), QStringLiteral("images/") + fileName); + assets.replace(i, asset); + } + if (!assets.isEmpty()) { animation.insert(QStringLiteral("assets"), assets); } + + const QJsonDocument animationDoc(LottieJsonOptimizer::optimize(animation)); + entries.prepend({QStringLiteral("animations/%1.json").arg(animationId).toUtf8(), + animationDoc.toJson(QJsonDocument::Compact) + '\n'}); + + const QJsonObject manifest{ + {QStringLiteral("version"), QStringLiteral("1")}, + {QStringLiteral("generator"), QStringLiteral("Friction")}, + {QStringLiteral("activeAnimationId"), animationId}, + {QStringLiteral("animations"), QJsonArray{ + QJsonObject{ + {QStringLiteral("id"), animationId}, + {QStringLiteral("name"), animationName} + } + }} + }; + entries.prepend({QByteArrayLiteral("manifest.json"), + QJsonDocument(manifest).toJson(QJsonDocument::Compact) + '\n'}); + writeZip(path, entries); +} diff --git a/src/core/lottie/dotlottiewriter.h b/src/core/lottie/dotlottiewriter.h new file mode 100644 index 000000000..3d0254ef7 --- /dev/null +++ b/src/core/lottie/dotlottiewriter.h @@ -0,0 +1,30 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +*/ + +#ifndef DOTLOTTIEWRITER_H +#define DOTLOTTIEWRITER_H + +#include "core_global.h" + +#include + +class CORE_EXPORT DotLottieWriter +{ +public: + static void write(const QString& path, + const QString& animationId, + const QString& animationName, + QJsonObject animation, + const QString& assetsPath); +}; + +#endif // DOTLOTTIEWRITER_H diff --git a/src/core/lottie/lottieanimatedproperty.cpp b/src/core/lottie/lottieanimatedproperty.cpp new file mode 100644 index 000000000..1809d38c6 --- /dev/null +++ b/src/core/lottie/lottieanimatedproperty.cpp @@ -0,0 +1,277 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#include "lottie/lottieanimatedproperty.h" + +#include +#include + +#include +#include + +namespace { + +bool sameScalarValues(const QList& values, + const qreal tolerance) +{ + if (values.isEmpty()) { return true; } + const qreal first = values.first(); + for (const qreal value : values) { + if (qAbs(first - value) > tolerance) { return false; } + } + return true; +} + +bool samePointValues(const QList& values, + const qreal tolerance) +{ + if (values.isEmpty()) { return true; } + const auto first = values.first(); + for (const auto& value : values) { + if (value.size() != first.size()) { return false; } + for (int i = 0; i < value.size(); i++) { + if (qAbs(first.at(i).toDouble() - value.at(i).toDouble()) > tolerance) { + return false; + } + } + } + return true; +} + +qreal interpolated(const qreal start, + const qreal end, + const qreal progress) +{ + return start + (end - start)*progress; +} + +qreal scalarError(const QList& values, + const int start, + const int end, + int& worstIndex) +{ + qreal worstError = 0; + worstIndex = -1; + const int span = end - start; + if (span <= 1) { return worstError; } + + for (int i = start + 1; i < end; i++) { + const qreal progress = qreal(i - start)/span; + const qreal expected = interpolated(values.at(start), + values.at(end), + progress); + const qreal error = qAbs(values.at(i) - expected); + if (error > worstError) { + worstError = error; + worstIndex = i; + } + } + return worstError; +} + +qreal pointError(const QList& values, + const int start, + const int end, + int& worstIndex) +{ + qreal worstError = 0; + worstIndex = -1; + const int span = end - start; + if (span <= 1) { return worstError; } + + const auto startValue = values.at(start); + const auto endValue = values.at(end); + if (startValue.size() != endValue.size()) { + worstIndex = start + 1; + return std::numeric_limits::max(); + } + + for (int i = start + 1; i < end; i++) { + const auto value = values.at(i); + if (value.size() != startValue.size()) { + worstIndex = i; + return std::numeric_limits::max(); + } + + const qreal progress = qreal(i - start)/span; + qreal error = 0; + for (int c = 0; c < value.size(); c++) { + const qreal expected = interpolated(startValue.at(c).toDouble(), + endValue.at(c).toDouble(), + progress); + error = qMax(error, qAbs(value.at(c).toDouble() - expected)); + } + + if (error > worstError) { + worstError = error; + worstIndex = i; + } + } + return worstError; +} + +template +void simplifyRange(const int start, + const int end, + const qreal tolerance, + const ErrorFunc& errorFunc, + QSet& indices) +{ + int worstIndex = -1; + const qreal error = errorFunc(start, end, worstIndex); + if (worstIndex < 0 || error <= tolerance) { return; } + + indices.insert(worstIndex); + simplifyRange(start, worstIndex, tolerance, errorFunc, indices); + simplifyRange(worstIndex, end, tolerance, errorFunc, indices); +} + +QList simplifiedScalarIndices(const QList& values, + const qreal tolerance) +{ + QList result; + if (values.isEmpty()) { return result; } + if (values.size() == 1) { return QList{0}; } + + QSet indices{0, values.size() - 1}; + simplifyRange(0, values.size() - 1, tolerance, + [&values](const int start, const int end, int& worstIndex) { + return scalarError(values, start, end, worstIndex); + }, + indices); + result = indices.values(); + std::sort(result.begin(), result.end()); + return result; +} + +QList simplifiedPointIndices(const QList& values, + const qreal tolerance) +{ + QList result; + if (values.isEmpty()) { return result; } + if (values.size() == 1) { return QList{0}; } + + QSet indices{0, values.size() - 1}; + simplifyRange(0, values.size() - 1, tolerance, + [&values](const int start, const int end, int& worstIndex) { + return pointError(values, start, end, worstIndex); + }, + indices); + result = indices.values(); + std::sort(result.begin(), result.end()); + return result; +} + +QJsonObject linearInEase() +{ + return QJsonObject{ + {QStringLiteral("x"), QJsonArray{0.833}}, + {QStringLiteral("y"), QJsonArray{0.833}} + }; +} + +QJsonObject linearOutEase() +{ + return QJsonObject{ + {QStringLiteral("x"), QJsonArray{0.167}}, + {QStringLiteral("y"), QJsonArray{0.167}} + }; +} + +QJsonArray scalarKeyframes(const QList& values, + const QList& indices, + const FrameRange& frameRange) +{ + QJsonArray keyframes; + for (int i = 0; i < indices.size(); i++) { + const int sampleIndex = indices.at(i); + QJsonObject key; + key.insert(QStringLiteral("t"), frameRange.fMin + sampleIndex); + key.insert(QStringLiteral("s"), QJsonArray{values.at(sampleIndex)}); + if (i + 1 < indices.size()) { + const int nextIndex = indices.at(i + 1); + key.insert(QStringLiteral("e"), QJsonArray{values.at(nextIndex)}); + key.insert(QStringLiteral("i"), linearInEase()); + key.insert(QStringLiteral("o"), linearOutEase()); + } + keyframes.append(key); + } + return keyframes; +} + +QJsonArray pointKeyframes(const QList& values, + const QList& indices, + const FrameRange& frameRange) +{ + QJsonArray keyframes; + for (int i = 0; i < indices.size(); i++) { + const int sampleIndex = indices.at(i); + QJsonObject key; + key.insert(QStringLiteral("t"), frameRange.fMin + sampleIndex); + key.insert(QStringLiteral("s"), values.at(sampleIndex)); + if (i + 1 < indices.size()) { + const int nextIndex = indices.at(i + 1); + key.insert(QStringLiteral("e"), values.at(nextIndex)); + key.insert(QStringLiteral("i"), linearInEase()); + key.insert(QStringLiteral("o"), linearOutEase()); + } + keyframes.append(key); + } + return keyframes; +} + +} + +QJsonObject LottieAnimatedProperty::staticProperty(const QJsonValue& value) +{ + return QJsonObject{ + {QStringLiteral("a"), 0}, + {QStringLiteral("k"), value} + }; +} + +QJsonObject LottieAnimatedProperty::scalar(const QList& values, + const FrameRange& frameRange, + const qreal tolerance) +{ + if (values.isEmpty()) { return staticProperty(0); } + if (sameScalarValues(values, tolerance)) { return staticProperty(values.first()); } + + const auto indices = simplifiedScalarIndices(values, tolerance); + return QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), scalarKeyframes(values, indices, frameRange)} + }; +} + +QJsonObject LottieAnimatedProperty::point(const QList& values, + const FrameRange& frameRange, + const qreal tolerance) +{ + if (values.isEmpty()) { return staticProperty(QJsonArray()); } + if (samePointValues(values, tolerance)) { return staticProperty(values.first()); } + + const auto indices = simplifiedPointIndices(values, tolerance); + return QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), pointKeyframes(values, indices, frameRange)} + }; +} diff --git a/src/core/lottie/lottieanimatedproperty.h b/src/core/lottie/lottieanimatedproperty.h new file mode 100644 index 000000000..1194ee1d4 --- /dev/null +++ b/src/core/lottie/lottieanimatedproperty.h @@ -0,0 +1,44 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#ifndef LOTTIEANIMATEDPROPERTY_H +#define LOTTIEANIMATEDPROPERTY_H + +#include "framerange.h" + +#include +#include +#include +#include + +namespace LottieAnimatedProperty { + +QJsonObject staticProperty(const QJsonValue& value); +QJsonObject scalar(const QList& values, + const FrameRange& frameRange, + const qreal tolerance = 0.001); +QJsonObject point(const QList& values, + const FrameRange& frameRange, + const qreal tolerance = 0.001); + +} + +#endif // LOTTIEANIMATEDPROPERTY_H diff --git a/src/core/lottie/lottieblendmode.cpp b/src/core/lottie/lottieblendmode.cpp new file mode 100644 index 000000000..5e2d87f5a --- /dev/null +++ b/src/core/lottie/lottieblendmode.cpp @@ -0,0 +1,45 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#include "lottie/lottieblendmode.h" + +int LottieBlendMode::value(const SkBlendMode mode) +{ + switch (mode) { + case SkBlendMode::kMultiply: return 1; + case SkBlendMode::kScreen: return 2; + case SkBlendMode::kOverlay: return 3; + case SkBlendMode::kDarken: return 4; + case SkBlendMode::kLighten: return 5; + case SkBlendMode::kColorDodge: return 6; + case SkBlendMode::kColorBurn: return 7; + case SkBlendMode::kHardLight: return 8; + case SkBlendMode::kSoftLight: return 9; + case SkBlendMode::kDifference: return 10; + case SkBlendMode::kExclusion: return 11; + case SkBlendMode::kHue: return 12; + case SkBlendMode::kSaturation: return 13; + case SkBlendMode::kColor: return 14; + case SkBlendMode::kLuminosity: return 15; + case SkBlendMode::kPlus: return 16; + default: return 0; + } +} diff --git a/src/core/lottie/lottieblendmode.h b/src/core/lottie/lottieblendmode.h new file mode 100644 index 000000000..08bb080c0 --- /dev/null +++ b/src/core/lottie/lottieblendmode.h @@ -0,0 +1,33 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#ifndef LOTTIEBLENDMODE_H +#define LOTTIEBLENDMODE_H + +#include "skia/skiaincludes.h" + +namespace LottieBlendMode { + +int value(const SkBlendMode mode); + +} + +#endif // LOTTIEBLENDMODE_H diff --git a/src/core/lottie/lottieexporter.cpp b/src/core/lottie/lottieexporter.cpp new file mode 100644 index 000000000..a8b8c2182 --- /dev/null +++ b/src/core/lottie/lottieexporter.cpp @@ -0,0 +1,142 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#include "lottie/lottieexporter.h" + +#include "canvas.h" +#include "exceptions.h" +#include "lottie/dotlottiewriter.h" +#include "lottie/lottiejsonoptimizer.h" +#include "lottie/lottielayerbuilder.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +QString safeAnimationId(QString name) +{ + name = name.toLower(); + name.replace(QRegularExpression(QStringLiteral("[^a-z0-9_-]+")), + QStringLiteral("_")); + name.remove(QRegularExpression(QStringLiteral("^_+|_+$"))); + return name.isEmpty() ? QStringLiteral("animation") : name; +} + +} + +LottieExporter::LottieExporter(const QString& path, + Canvas* const scene, + const FrameRange& frameRange, + const qreal fps, + const bool background, + const bool embedImages, + const bool svgRendererFix, + const bool nativeText) + : ComplexTask(INT_MAX, tr("Lottie Export")) + , mPath(path) + , mScene(scene) + , mFrameRange(frameRange) + , mFps(fps) + , mBackground(background) + , mEmbedImages(embedImages) + , mSvgRendererFix(svgRendererFix) + , mNativeText(nativeText) +{ + +} + +void LottieExporter::nextStep() +{ + finish(); +} + +void LottieExporter::finish() +{ + if (!mScene) { RuntimeThrow("No scene selected"); } + const bool dotLottie = mPath.endsWith(QStringLiteral(".lottie"), + Qt::CaseInsensitive); + std::unique_ptr dotLottieDir; + if (dotLottie) { + dotLottieDir = std::make_unique(); + if (!dotLottieDir->isValid()) { + RuntimeThrow("Could not create temporary dotLottie directory"); + } + } + const QString builderPath = dotLottie ? + QDir(dotLottieDir->path()).filePath(QStringLiteral("animation.json")) : + mPath; + + QJsonObject root; + root.insert(QStringLiteral("v"), QStringLiteral("5.7.11")); + root.insert(QStringLiteral("fr"), mFps); + root.insert(QStringLiteral("ip"), mFrameRange.fMin); + root.insert(QStringLiteral("op"), mFrameRange.fMax + 1); + root.insert(QStringLiteral("w"), mScene->getCanvasWidth()); + root.insert(QStringLiteral("h"), mScene->getCanvasHeight()); + root.insert(QStringLiteral("nm"), mScene->prp_getName()); + root.insert(QStringLiteral("ddd"), 0); + + const LottieLayerBuilder builder(mScene, + mFrameRange, + mFps, + builderPath, + dotLottie ? false : mEmbedImages, + mSvgRendererFix, + mNativeText); + const auto fonts = builder.buildFonts(); + if (!fonts.value(QStringLiteral("list")).toArray().isEmpty()) { + root.insert(QStringLiteral("fonts"), fonts); + } + // Group layers discover their precomposition assets while being built. + root.insert(QStringLiteral("layers"), builder.buildLayers(mBackground)); + const auto assets = builder.buildAssets(); + if (!assets.isEmpty()) { + root.insert(QStringLiteral("assets"), assets); + } + + if (dotLottie) { + const QString animationName = mScene->prp_getName(); + DotLottieWriter::write(mPath, + safeAnimationId(animationName), + animationName, + root, + QDir(dotLottieDir->path()).filePath( + QStringLiteral("animation_assets"))); + } else { + QFile file(mPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + RuntimeThrow("Could not open:\n\"" + mPath + "\""); + } + + const QJsonDocument doc(LottieJsonOptimizer::optimize(root)); + file.write(doc.toJson(QJsonDocument::Compact)); + file.write("\n"); + file.close(); + } + setValue(INT_MAX); +} diff --git a/src/core/lottie/lottieexporter.h b/src/core/lottie/lottieexporter.h new file mode 100644 index 000000000..0eee8a3aa --- /dev/null +++ b/src/core/lottie/lottieexporter.h @@ -0,0 +1,57 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#ifndef LOTTIEEXPORTER_H +#define LOTTIEEXPORTER_H + +#include "Private/Tasks/complextask.h" +#include "framerange.h" + +class Canvas; + +class CORE_EXPORT LottieExporter : public ComplexTask +{ +public: + LottieExporter(const QString& path, + Canvas* const scene, + const FrameRange& frameRange, + const qreal fps, + const bool background, + const bool embedImages = true, + const bool svgRendererFix = false, + const bool nativeText = false); + + void nextStep() override; + +private: + void finish(); + + const QString mPath; + Canvas* const mScene; + const FrameRange mFrameRange; + const qreal mFps; + const bool mBackground; + const bool mEmbedImages; + const bool mSvgRendererFix; + const bool mNativeText; +}; + +#endif // LOTTIEEXPORTER_H diff --git a/src/core/lottie/lottiejsonoptimizer.cpp b/src/core/lottie/lottiejsonoptimizer.cpp new file mode 100644 index 000000000..39338d2d7 --- /dev/null +++ b/src/core/lottie/lottiejsonoptimizer.cpp @@ -0,0 +1,88 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#include "lottie/lottiejsonoptimizer.h" + +#include +#include +#include + +namespace { + +constexpr qreal kNumberScale = 1000; + +qreal roundedNumber(const qreal value) +{ + const qreal rounded = qRound64(value*kNumberScale)/kNumberScale; + return qFuzzyIsNull(rounded) ? 0 : rounded; +} + +bool isKeyframeObject(const QJsonObject& object) +{ + return object.contains(QStringLiteral("t")) && + object.contains(QStringLiteral("s")); +} + +QJsonValue optimizeValue(const QJsonValue& value); + +QJsonObject optimizeObject(const QJsonObject& object) +{ + QJsonObject optimized; + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + optimized.insert(it.key(), optimizeValue(it.value())); + } + return optimized; +} + +QJsonArray optimizeArray(const QJsonArray& array) +{ + QJsonArray optimized; + for (int i = 0; i < array.size(); i++) { + auto value = optimizeValue(array.at(i)); + if (i + 1 < array.size() && value.isObject()) { + auto object = value.toObject(); + const auto next = array.at(i + 1); + if (isKeyframeObject(object) && + next.isObject() && + isKeyframeObject(next.toObject())) { + object.remove(QStringLiteral("e")); + value = object; + } + } + optimized.append(value); + } + return optimized; +} + +QJsonValue optimizeValue(const QJsonValue& value) +{ + if (value.isDouble()) { return roundedNumber(value.toDouble()); } + if (value.isArray()) { return optimizeArray(value.toArray()); } + if (value.isObject()) { return optimizeObject(value.toObject()); } + return value; +} + +} + +QJsonObject LottieJsonOptimizer::optimize(const QJsonObject& root) +{ + return optimizeObject(root); +} diff --git a/src/core/lottie/lottiejsonoptimizer.h b/src/core/lottie/lottiejsonoptimizer.h new file mode 100644 index 000000000..ccfea4a5f --- /dev/null +++ b/src/core/lottie/lottiejsonoptimizer.h @@ -0,0 +1,33 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#ifndef LOTTIEJSONOPTIMIZER_H +#define LOTTIEJSONOPTIMIZER_H + +#include + +namespace LottieJsonOptimizer { + +QJsonObject optimize(const QJsonObject& root); + +} + +#endif // LOTTIEJSONOPTIMIZER_H diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp new file mode 100644 index 000000000..26e039e3b --- /dev/null +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -0,0 +1,1587 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#include "lottie/lottielayerbuilder.h" + +#include "Animators/coloranimator.h" +#include "Animators/gradient.h" +#include "Animators/gradientpoints.h" +#include "Animators/paintsettingsanimator.h" +#include "Animators/qpointfanimator.h" +#include "Animators/qrealanimator.h" +#include "Animators/transformanimator.h" +#include "Boxes/boundingbox.h" +#include "Boxes/containerbox.h" +#include "Boxes/imagebox.h" +#include "Boxes/pathbox.h" +#include "Boxes/rectangle.h" +#include "Boxes/smartvectorpath.h" +#include "Boxes/textbox.h" +#include "canvas.h" +#include "lottie/lottieanimatedproperty.h" +#include "lottie/lottieblendmode.h" +#include "lottie/lottiepatheffects.h" +#include "lottie/lottieparenting.h" +#include "lottie/lottierealkeyframes.h" +#include "paintsettings.h" +#include "simplemath.h" +#include "skia/skiaincludes.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QJsonObject lottieStaticProperty(const QJsonValue& value) +{ + return QJsonObject{ + {QStringLiteral("a"), 0}, + {QStringLiteral("k"), value} + }; +} + +QJsonArray pointArray(const SkPoint& point) +{ + return QJsonArray{point.fX, point.fY}; +} + +QJsonArray tangentArray(const SkPoint& from, const SkPoint& to) +{ + return QJsonArray{to.fX - from.fX, to.fY - from.fY}; +} + +QJsonArray zeroTangent() +{ + return QJsonArray{0, 0}; +} + +sk_sp imageForBox(const ImageBox* const box) +{ + if (!box) { return nullptr; } + if (box->hasImage()) { return box->image(); } + + const QString path = box->filePath(); + if (path.isEmpty() || !QFileInfo::exists(path)) { return nullptr; } + + const sk_sp data = SkData::MakeFromFileName(path.toUtf8().constData()); + return data ? SkImage::MakeFromEncoded(data) : nullptr; +} + +sk_sp pngData(const sk_sp& image) +{ + if (!image) { return nullptr; } + return image->encodeToData(SkEncodedImageFormat::kPNG, 100); +} + +QString pngDataUri(const sk_sp& image) +{ + const sk_sp png = pngData(image); + if (!png) { return QString(); } + + const QByteArray bytes(static_cast(png->data()), png->size()); + return QStringLiteral("data:image/png;base64,%1") + .arg(QString::fromLatin1(bytes.toBase64())); +} + +struct LottieContour { + QJsonArray vertices; + QJsonArray inTangents; + QJsonArray outTangents; + bool closed = false; +}; + +void addMove(LottieContour& contour, const SkPoint& point) +{ + contour.vertices.append(pointArray(point)); + contour.inTangents.append(zeroTangent()); + contour.outTangents.append(zeroTangent()); +} + +void addLine(LottieContour& contour, const SkPoint& point) +{ + contour.vertices.append(pointArray(point)); + contour.inTangents.append(zeroTangent()); + contour.outTangents.append(zeroTangent()); +} + +void addCubic(LottieContour& contour, + const SkPoint& cp1, + const SkPoint& cp2, + const SkPoint& end) +{ + if (contour.vertices.isEmpty()) { addMove(contour, end); return; } + + const int lastIndex = contour.vertices.size() - 1; + const auto lastVertex = contour.vertices.at(lastIndex).toArray(); + const SkPoint start = SkPoint::Make(SkScalar(lastVertex.at(0).toDouble()), + SkScalar(lastVertex.at(1).toDouble())); + contour.outTangents.replace(lastIndex, tangentArray(start, cp1)); + contour.vertices.append(pointArray(end)); + contour.inTangents.append(tangentArray(end, cp2)); + contour.outTangents.append(zeroTangent()); +} + +void addQuad(LottieContour& contour, + const SkPoint& control, + const SkPoint& end) +{ + if (contour.vertices.isEmpty()) { addMove(contour, end); return; } + + const int lastIndex = contour.vertices.size() - 1; + const auto lastVertex = contour.vertices.at(lastIndex).toArray(); + const SkPoint start = SkPoint::Make(SkScalar(lastVertex.at(0).toDouble()), + SkScalar(lastVertex.at(1).toDouble())); + const SkPoint cp1 = SkPoint::Make(start.fX + (control.fX - start.fX)*2/3, + start.fY + (control.fY - start.fY)*2/3); + const SkPoint cp2 = SkPoint::Make(end.fX + (control.fX - end.fX)*2/3, + end.fY + (control.fY - end.fY)*2/3); + addCubic(contour, cp1, cp2, end); +} + +QJsonObject contourPathObject(const LottieContour& contour) +{ + return QJsonObject{ + {QStringLiteral("i"), contour.inTangents}, + {QStringLiteral("o"), contour.outTangents}, + {QStringLiteral("v"), contour.vertices}, + {QStringLiteral("c"), contour.closed} + }; +} + +QList pathContours(const SkPath& path) +{ + QList contours; + LottieContour contour; + SkPath::Iter iter(path, false); + SkPoint pts[4]; + + for (;;) { + switch(iter.next(pts)) { + case SkPath::kMove_Verb: + if (!contour.vertices.isEmpty()) { + contours.append(contour); + contour = LottieContour(); + } + addMove(contour, pts[0]); + break; + case SkPath::kLine_Verb: + addLine(contour, pts[1]); + break; + case SkPath::kQuad_Verb: + addQuad(contour, pts[1], pts[2]); + break; + case SkPath::kConic_Verb: { + const SkScalar tol = SK_Scalar1 / 1024; + SkAutoConicToQuads quadder; + const SkPoint* quadPts = quadder.computeQuads(pts, iter.conicWeight(), tol); + for (int i = 0; i < quadder.countQuads(); i++) { + addQuad(contour, quadPts[i*2 + 1], quadPts[i*2 + 2]); + } + break; + } + case SkPath::kCubic_Verb: + addCubic(contour, pts[1], pts[2], pts[3]); + break; + case SkPath::kClose_Verb: + contour.closed = true; + break; + case SkPath::kDone_Verb: + if (!contour.vertices.isEmpty()) { contours.append(contour); } + return contours; + } + } +} + +bool compatibleContours(const QList& a, + const QList& b) +{ + if (a.size() != b.size()) { return false; } + for (int i = 0; i < a.size(); i++) { + if (a.at(i).closed != b.at(i).closed) { return false; } + if (a.at(i).vertices.size() != b.at(i).vertices.size()) { return false; } + if (a.at(i).inTangents.size() != b.at(i).inTangents.size()) { return false; } + if (a.at(i).outTangents.size() != b.at(i).outTangents.size()) { return false; } + } + return true; +} + +qreal lottiePointValue(const QJsonArray& point, + const int component) +{ + return point.at(component).toDouble(); +} + +qreal interpolated(const qreal start, + const qreal end, + const qreal progress) +{ + return start + (end - start)*progress; +} + +qreal contourPointArrayError(const QJsonArray& start, + const QJsonArray& end, + const QJsonArray& value, + const qreal progress) +{ + qreal error = 0; + for (int pointIndex = 0; pointIndex < value.size(); pointIndex++) { + const auto startPoint = start.at(pointIndex).toArray(); + const auto endPoint = end.at(pointIndex).toArray(); + const auto valuePoint = value.at(pointIndex).toArray(); + for (int component = 0; component < valuePoint.size(); component++) { + const qreal expected = interpolated(lottiePointValue(startPoint, component), + lottiePointValue(endPoint, component), + progress); + error = qMax(error, + qAbs(lottiePointValue(valuePoint, component) - expected)); + } + } + return error; +} + +bool sameContourFrames(const QList>& frames, + const int contourIndex, + const qreal tolerance) +{ + if (frames.isEmpty()) { return true; } + + const auto& first = frames.first().at(contourIndex); + for (int frameIndex = 1; frameIndex < frames.size(); frameIndex++) { + const auto& contour = frames.at(frameIndex).at(contourIndex); + qreal error = contourPointArrayError(first.vertices, + first.vertices, + contour.vertices, + 0); + error = qMax(error, contourPointArrayError(first.inTangents, + first.inTangents, + contour.inTangents, + 0)); + error = qMax(error, contourPointArrayError(first.outTangents, + first.outTangents, + contour.outTangents, + 0)); + if (error > tolerance) { return false; } + } + return true; +} + +qreal contourError(const QList>& frames, + const int contourIndex, + const int start, + const int end, + int& worstIndex) +{ + qreal worstError = 0; + worstIndex = -1; + const int span = end - start; + if (span <= 1) { return worstError; } + + const auto& startContour = frames.at(start).at(contourIndex); + const auto& endContour = frames.at(end).at(contourIndex); + for (int frameIndex = start + 1; frameIndex < end; frameIndex++) { + const qreal progress = qreal(frameIndex - start)/span; + const auto& contour = frames.at(frameIndex).at(contourIndex); + qreal error = contourPointArrayError(startContour.vertices, + endContour.vertices, + contour.vertices, + progress); + error = qMax(error, contourPointArrayError(startContour.inTangents, + endContour.inTangents, + contour.inTangents, + progress)); + error = qMax(error, contourPointArrayError(startContour.outTangents, + endContour.outTangents, + contour.outTangents, + progress)); + if (error > worstError) { + worstError = error; + worstIndex = frameIndex; + } + } + return worstError; +} + +void simplifyContourRange(const QList>& frames, + const int contourIndex, + const int start, + const int end, + const qreal tolerance, + QSet& indices) +{ + int worstIndex = -1; + const qreal error = contourError(frames, contourIndex, start, end, worstIndex); + if (worstIndex < 0 || error <= tolerance) { return; } + + indices.insert(worstIndex); + simplifyContourRange(frames, contourIndex, start, worstIndex, tolerance, indices); + simplifyContourRange(frames, contourIndex, worstIndex, end, tolerance, indices); +} + +QList simplifiedContourIndices(const QList>& frames, + const int contourIndex) +{ + QList result; + if (frames.isEmpty()) { return result; } + if (frames.size() == 1) { return QList{0}; } + + constexpr qreal tolerance = 0.25; + if (sameContourFrames(frames, contourIndex, tolerance)) { + return QList{0}; + } + + QSet indices{0, frames.size() - 1}; + simplifyContourRange(frames, + contourIndex, + 0, + frames.size() - 1, + tolerance, + indices); + result = indices.values(); + std::sort(result.begin(), result.end()); + return result; +} + +QJsonObject shapeInEase() +{ + return QJsonObject{ + {QStringLiteral("x"), QJsonArray{0.833}}, + {QStringLiteral("y"), QJsonArray{0.833}} + }; +} + +QJsonObject shapeOutEase() +{ + return QJsonObject{ + {QStringLiteral("x"), QJsonArray{0.167}}, + {QStringLiteral("y"), QJsonArray{0.167}} + }; +} + +QJsonArray pathShapeObjects(const QList>& frames, + const QString& name, + const FrameRange& frameRange) +{ + QJsonArray shapes; + if (frames.isEmpty()) { return shapes; } + + const auto& firstContours = frames.first(); + const bool animated = frames.size() > 1; + for (int contourIndex = 0; contourIndex < firstContours.size(); contourIndex++) { + QJsonObject shape; + shape.insert(QStringLiteral("ty"), QStringLiteral("sh")); + shape.insert(QStringLiteral("nm"), + QStringLiteral("%1 Path %2").arg(name).arg(contourIndex + 1)); + shape.insert(QStringLiteral("ind"), contourIndex + 1); + + if (animated) { + QJsonArray keys; + const QList keyFrameIndices = simplifiedContourIndices(frames, contourIndex); + + for (int keyIndex = 0; keyIndex < keyFrameIndices.size(); keyIndex++) { + const int frameIndex = keyFrameIndices.at(keyIndex); + const QJsonObject path = contourPathObject(frames.at(frameIndex).at(contourIndex)); + + QJsonObject key; + key.insert(QStringLiteral("t"), frameRange.fMin + frameIndex); + key.insert(QStringLiteral("s"), QJsonArray{path}); + if (keyIndex + 1 < keyFrameIndices.size()) { + const int nextFrameIndex = keyFrameIndices.at(keyIndex + 1); + const QJsonObject nextPath = + contourPathObject(frames.at(nextFrameIndex).at(contourIndex)); + key.insert(QStringLiteral("e"), QJsonArray{nextPath}); + key.insert(QStringLiteral("i"), shapeInEase()); + key.insert(QStringLiteral("o"), shapeOutEase()); + } + keys.append(key); + } + + if (keys.size() > 1) { + shape.insert(QStringLiteral("ks"), QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), keys} + }); + } else { + const int frameIndex = keyFrameIndices.isEmpty() ? 0 : keyFrameIndices.first(); + shape.insert(QStringLiteral("ks"), + lottieStaticProperty( + contourPathObject(frames.at(frameIndex).at(contourIndex)))); + } + } else { + shape.insert(QStringLiteral("ks"), + lottieStaticProperty(contourPathObject(firstContours.at(contourIndex)))); + } + shapes.append(shape); + } + return shapes; +} + +QJsonArray pathShapeObjects(const SkPath& path, const QString& name) +{ + return pathShapeObjects(QList>{pathContours(path)}, + name, + FrameRange{0, 0}); +} + +} + +LottieLayerBuilder::LottieLayerBuilder(Canvas* const scene, + const FrameRange& frameRange, + const qreal fps, + const QString& path, + const bool embedImages, + const bool svgRendererFix, + const bool nativeText) + : mScene(scene) + , mFrameRange(frameRange) + , mFps(fps) + , mPath(path) + , mEmbedImages(embedImages) + , mSvgRendererFix(svgRendererFix) + , mNativeText(nativeText) +{ + +} + +QJsonArray LottieLayerBuilder::buildLayers(const bool background) const +{ + QJsonArray layers; + if (!mScene) { return layers; } + + mPrecompAssets = QJsonArray(); + mNextPrecompId = 1; + int nextId = background ? 2 : 1; + appendContainerLayers(mScene, layers, nextId); + if (background) { layers.append(buildBackgroundLayer()); } + if (mSvgRendererFix) { layers.append(buildSvgRendererFixLayer(nextId)); } + return layers; +} + +QJsonArray LottieLayerBuilder::buildAssets() const +{ + QJsonArray assets = mPrecompAssets; + QSet ids; + if (mScene) { appendImageAssets(mScene, assets, ids); } + return assets; +} + +QJsonObject LottieLayerBuilder::buildFonts() const +{ + QJsonArray fonts; + QSet names; + if (mScene) { appendFonts(mScene, fonts, names); } + return QJsonObject{{QStringLiteral("list"), fonts}}; +} + +QJsonObject LottieLayerBuilder::buildSvgRendererFixLayer(const int id) const +{ + auto layer = baseLayer(QStringLiteral("SVG Renderer Fix"), id, 3); + + QJsonObject transform; + transform.insert(QStringLiteral("o"), staticProperty(0)); + transform.insert(QStringLiteral("r"), staticProperty(0)); + transform.insert(QStringLiteral("a"), staticProperty(QJsonArray{0, 0, 0})); + transform.insert(QStringLiteral("s"), staticProperty(QJsonArray{100, 100, 100})); + transform.insert(QStringLiteral("p"), QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), QJsonArray{ + QJsonObject{ + {QStringLiteral("t"), mFrameRange.fMin}, + {QStringLiteral("s"), QJsonArray{0, 0, 0}}, + {QStringLiteral("e"), QJsonArray{0.001, 0, 0}}, + {QStringLiteral("i"), QJsonObject{ + {QStringLiteral("x"), QJsonArray{0.833}}, + {QStringLiteral("y"), QJsonArray{0.833}} + }}, + {QStringLiteral("o"), QJsonObject{ + {QStringLiteral("x"), QJsonArray{0.167}}, + {QStringLiteral("y"), QJsonArray{0.167}} + }} + }, + QJsonObject{ + {QStringLiteral("t"), mFrameRange.fMax}, + {QStringLiteral("s"), QJsonArray{0.001, 0, 0}} + } + }} + }); + layer.insert(QStringLiteral("ks"), transform); + return layer; +} + +QJsonObject LottieLayerBuilder::buildBackgroundLayer() const +{ + auto layer = baseLayer(QStringLiteral("Background"), 1, 1); + layer.insert(QStringLiteral("sw"), mScene->getCanvasWidth()); + layer.insert(QStringLiteral("sh"), mScene->getCanvasHeight()); + + QColor color(0, 0, 0); + if (mScene->getBgColorAnimator()) { + color = mScene->getBgColorAnimator()->getColor(mFrameRange.fMin); + } + layer.insert(QStringLiteral("sc"), color.name(QColor::HexRgb)); + return layer; +} + +void LottieLayerBuilder::appendContainerLayers(const ContainerBox* const container, + QJsonArray& layers, + int& nextId, + const int parentId) const +{ + QHash layerIds; + QHash layerIndices; + const auto appendLayer = [&layers, &layerIds, &layerIndices]( + const BoundingBox* const box, const QJsonObject& layer) { + layerIds.insert(box, layer.value(QStringLiteral("ind")).toInt()); + layerIndices.insert(box, layers.size()); + layers.append(layer); + }; + + const auto& boxes = container->getContainedBoxes(); + for (int i = 0; i < boxes.size(); i++) { + const auto box = boxes.at(i); + if (!box || !box->isVisible()) { continue; } + + if (isAlphaMatteLayer(box) && i + 1 < boxes.size()) { + const auto target = boxes.at(i + 1); + if (!target || !target->isVisible()) { + i++; + continue; + } + if (const auto targetContainer = dynamic_cast(target)) { + if (canBuildMatteLayer(box)) { + auto matteLayer = buildMatteLayer(box, nextId); + assignParent(matteLayer, parentId); + appendLayer(box, matteLayer); + nextId++; + + auto targetLayer = buildContainerLayer(targetContainer, + nextId, + parentId); + targetLayer.insert(QStringLiteral("tt"), alphaMatteType(box)); + appendLayer(target, targetLayer); + nextId++; + i++; + continue; + } + } else if (canBuildMatteLayer(box) && canBuildBoxLayer(target)) { + auto matteLayer = buildMatteLayer(box, nextId); + assignParent(matteLayer, parentId); + appendLayer(box, matteLayer); + nextId++; + + auto targetLayer = buildBoxLayer(target, nextId); + targetLayer.insert(QStringLiteral("tt"), alphaMatteType(box)); + assignParent(targetLayer, parentId); + appendLayer(target, targetLayer); + nextId++; + i++; + continue; + } + } + + const auto childContainer = dynamic_cast(box); + if (childContainer) { + // Lottie parent layers do not pass opacity to their children. + // A precomposition is required for Friction group compositing. + appendLayer(box, buildContainerLayer(childContainer, nextId, parentId)); + nextId++; + continue; + } + + if (canBuildBoxLayer(box)) { + auto layer = buildBoxLayer(box, nextId); + assignParent(layer, parentId); + appendLayer(box, layer); + nextId++; + continue; + } + + auto layer = buildUnsupportedLayer(box, nextId); + assignParent(layer, parentId); + appendLayer(box, layer); + nextId++; + } + + for (auto it = layerIndices.constBegin(); it != layerIndices.constEnd(); ++it) { + const auto target = nativeParentTarget(it.key()); + if (!target || !layerIds.contains(target)) { continue; } + + auto layer = layers.at(it.value()).toObject(); + layer.insert(QStringLiteral("parent"), layerIds.value(target)); + layers.replace(it.value(), layer); + } +} + +QJsonObject LottieLayerBuilder::buildMatteLayer(BoundingBox* const box, + const int id) const +{ + auto layer = buildBoxLayer(box, id); + layer.insert(QStringLiteral("td"), 1); + layer.insert(QStringLiteral("bm"), 0); + return layer; +} + +QJsonObject LottieLayerBuilder::buildBoxLayer(BoundingBox* const box, + const int id) const +{ + if (const auto rectangle = dynamic_cast(box)) { + return buildRectangleLayer(rectangle, id); + } + + if (const auto text = dynamic_cast(box)) { + if (canBuildNativeTextLayer(text)) { return buildTextLayer(text, id); } + } + + if (const auto image = dynamic_cast(box)) { + if (imageForBox(image)) { return buildImageLayer(image, id); } + } + + if (const auto path = dynamic_cast(box)) { + return buildPathLayer(path, id); + } + + return QJsonObject(); +} + +bool LottieLayerBuilder::canBuildBoxLayer(const BoundingBox* const box) const +{ + if (!box) { return false; } + if (dynamic_cast(box)) { return true; } + if (dynamic_cast(box)) { return true; } + if (const auto image = dynamic_cast(box)) { + return imageForBox(image).get(); + } + return dynamic_cast(box); +} + +bool LottieLayerBuilder::canBuildMatteLayer(const BoundingBox* const box) const +{ + return canBuildBoxLayer(box) && !dynamic_cast(box); +} + +bool LottieLayerBuilder::isAlphaMatteLayer(const BoundingBox* const box) const +{ + if (!box) { return false; } + const auto mode = box->getBlendMode(); + return mode == SkBlendMode::kDstIn || mode == SkBlendMode::kDstOut; +} + +int LottieLayerBuilder::alphaMatteType(const BoundingBox* const box) const +{ + if (!box) { return 1; } + return box->getBlendMode() == SkBlendMode::kDstOut ? 2 : 1; +} + +QJsonObject LottieLayerBuilder::buildContainerLayer(const ContainerBox* const box, + const int id, + const int parentId) const +{ + auto layer = baseLayer(box ? box->prp_getName() : QStringLiteral("Group"), + id, + 0, + box); + layer.insert(QStringLiteral("ks"), transformObject(box)); + layer.insert(QStringLiteral("refId"), appendPrecompAsset(box)); + layer.insert(QStringLiteral("w"), mScene->getCanvasWidth()); + layer.insert(QStringLiteral("h"), mScene->getCanvasHeight()); + assignParent(layer, parentId); + return layer; +} + +QString LottieLayerBuilder::appendPrecompAsset(const ContainerBox* const box) const +{ + const QString id = QStringLiteral("precomp_%1").arg(mNextPrecompId++); + QJsonArray layers; + int nextId = 1; + appendContainerLayers(box, layers, nextId); + + mPrecompAssets.append(QJsonObject{ + {QStringLiteral("id"), id}, + {QStringLiteral("nm"), box ? box->prp_getName() : QStringLiteral("Group")}, + {QStringLiteral("w"), mScene->getCanvasWidth()}, + {QStringLiteral("h"), mScene->getCanvasHeight()}, + {QStringLiteral("layers"), layers} + }); + return id; +} + +QJsonObject LottieLayerBuilder::buildRectangleLayer(RectangleBox* const box, + const int id) const +{ + auto layer = baseLayer(box->prp_getName(), id, 4, box); + layer.insert(QStringLiteral("ks"), transformObject(box)); + + QList centers; + QList sizes; + QList radii; + for (int frame = mFrameRange.fMin; frame <= mFrameRange.fMax; frame++) { + const QPointF topLeft = box->getTopLeftAnimator()->getEffectiveValue(frame); + const QPointF bottomRight = box->getBottomRightAnimator()->getEffectiveValue(frame); + const QPointF radius = box->getRadiusAnimator()->getEffectiveValue(frame); + + const qreal x = qMin(topLeft.x(), bottomRight.x()); + const qreal y = qMin(topLeft.y(), bottomRight.y()); + const qreal width = qAbs(topLeft.x() - bottomRight.x()); + const qreal height = qAbs(topLeft.y() - bottomRight.y()); + + centers << QJsonArray{x + width*0.5, y + height*0.5}; + sizes << QJsonArray{width, height}; + radii << qMin(qAbs(radius.x()), qAbs(radius.y())); + } + + QJsonObject rect; + rect.insert(QStringLiteral("ty"), QStringLiteral("rc")); + rect.insert(QStringLiteral("d"), 1); + rect.insert(QStringLiteral("nm"), box->prp_getName()); + rect.insert(QStringLiteral("p"), animatedPointProperty(centers)); + rect.insert(QStringLiteral("s"), animatedPointProperty(sizes)); + rect.insert(QStringLiteral("r"), animatedScalarProperty(radii)); + + QJsonArray shapes{rect}; + LottiePathEffects::appendBasePathEffects(box, mFrameRange, shapes); + appendPaintObjects(box, shapes); + shapes.append(shapeTransformObject()); + + layer.insert(QStringLiteral("shapes"), shapes); + return layer; +} + +QJsonObject LottieLayerBuilder::buildTextLayer(TextBox* const box, + const int id) const +{ + auto layer = baseLayer(box->prp_getName(), id, 5, box); + layer.insert(QStringLiteral("ks"), transformObject(box)); + + QJsonObject textData; + textData.insert(QStringLiteral("d"), QJsonObject{ + {QStringLiteral("k"), textDocumentKeyframes(box)} + }); + textData.insert(QStringLiteral("p"), QJsonObject()); + textData.insert(QStringLiteral("m"), QJsonObject{ + {QStringLiteral("g"), 1}, + {QStringLiteral("a"), staticProperty(QJsonArray{0, 0})} + }); + textData.insert(QStringLiteral("a"), QJsonArray()); + + layer.insert(QStringLiteral("t"), textData); + return layer; +} + +QJsonObject LottieLayerBuilder::textDocumentObject(TextBox* const box, + const int frame) const +{ + QColor fillColor(0, 0, 0); + const auto fill = box->getFillSettings(); + if (fill && fill->getPaintType() == PaintType::FLATPAINT) { + fillColor = fill->getColor(frame); + } + + const qreal fontSize = box->getFontSize(); + const qreal lineHeight = box->getFontSpacing()*box->getLineSpacing(frame); + const qreal letterSpacing = box->getLetterSpacing(frame); + const qreal effectiveLineHeight = isZero4Dec(lineHeight) ? fontSize*1.2 : lineHeight; + QString normalizedText = box->getValueAtRelFrame(frame); + normalizedText.replace(QStringLiteral("\r\n"), QStringLiteral("\n")); + normalizedText.replace(QLatin1Char('\r'), QLatin1Char('\n')); + const int lineCount = qMax(1, normalizedText.count(QLatin1Char('\n')) + 1); + const qreal textHeight = (lineCount - 1)*effectiveLineHeight + fontSize; + const qreal maxWidth = qMax(1, box->getMaxLineWidth(frame)); + qreal horizontalOffset = 0; + const auto horizontalAlignment = box->getTextHAlignment(); + if (horizontalAlignment & Qt::AlignRight) { + horizontalOffset = -maxWidth; + } else if (horizontalAlignment & Qt::AlignHCenter) { + horizontalOffset = -0.5*maxWidth; + } + + qreal verticalOffset = 0; + const auto verticalAlignment = box->getTextVAlignment(); + if (verticalAlignment & Qt::AlignBottom) { + verticalOffset = -textHeight; + } else if (verticalAlignment & Qt::AlignVCenter) { + verticalOffset = -0.5*textHeight; + } + + QJsonObject document; + document.insert(QStringLiteral("s"), fontSize); + document.insert(QStringLiteral("f"), fontName(box)); + document.insert(QStringLiteral("t"), normalizedText); + document.insert(QStringLiteral("j"), textJustification(box->getTextHAlignment())); + document.insert(QStringLiteral("tr"), qRound(letterSpacing*1000)); + document.insert(QStringLiteral("lh"), effectiveLineHeight); + document.insert(QStringLiteral("ls"), 0); + document.insert(QStringLiteral("sz"), QJsonArray{maxWidth, textHeight}); + document.insert(QStringLiteral("ps"), + QJsonArray{horizontalOffset, -fontSize*0.75 + verticalOffset}); + document.insert(QStringLiteral("fc"), QJsonArray{fillColor.redF(), + fillColor.greenF(), + fillColor.blueF()}); + + const auto stroke = box->getStrokeSettings(); + if (stroke && + stroke->getPaintType() == PaintType::FLATPAINT && + !isZero4Dec(stroke->getLineWidthAnimator()->getEffectiveValue(frame))) { + const QColor strokeColor = stroke->getColor(frame); + document.insert(QStringLiteral("sc"), QJsonArray{strokeColor.redF(), + strokeColor.greenF(), + strokeColor.blueF()}); + document.insert(QStringLiteral("sw"), + stroke->getLineWidthAnimator()->getEffectiveValue(frame)); + } + + return document; +} + +QJsonArray LottieLayerBuilder::textDocumentKeyframes(TextBox* const box) const +{ + QJsonArray keys; + QJsonObject previousDocument; + for (int frame = mFrameRange.fMin; frame <= mFrameRange.fMax; frame++) { + const QJsonObject document = textDocumentObject(box, frame); + if (frame != mFrameRange.fMin && document == previousDocument) { continue; } + + QJsonObject key; + key.insert(QStringLiteral("s"), document); + key.insert(QStringLiteral("t"), frame); + keys.append(key); + previousDocument = document; + } + return keys; +} + +int LottieLayerBuilder::textJustification(const Qt::Alignment alignment) const +{ + if (alignment & Qt::AlignRight) { return 1; } + if (alignment & Qt::AlignHCenter) { return 2; } + return 0; +} + +QJsonObject LottieLayerBuilder::buildImageLayer(ImageBox* const box, + const int id) const +{ + auto layer = baseLayer(box->prp_getName(), id, 2, box); + layer.insert(QStringLiteral("ks"), transformObject(box)); + layer.insert(QStringLiteral("refId"), imageAssetId(box)); + + const auto image = imageForBox(box); + if (image) { + layer.insert(QStringLiteral("w"), image->width()); + layer.insert(QStringLiteral("h"), image->height()); + } + return layer; +} + +QJsonObject LottieLayerBuilder::buildPathLayer(PathBox* const box, + const int id) const +{ + auto layer = baseLayer(box->prp_getName(), id, 4, box); + layer.insert(QStringLiteral("ks"), transformObject(box)); + + const auto smartPath = dynamic_cast(box); + if (smartPath && !smartPath->getPathAnimator()->anim_hasKeys()) { + QJsonArray shapes = pathShapeObjects(box->getRelativePath(mFrameRange.fMin), + box->prp_getName()); + LottiePathEffects::appendBasePathEffects(box, mFrameRange, shapes); + appendPaintObjects(box, shapes); + shapes.append(shapeTransformObject()); + layer.insert(QStringLiteral("shapes"), shapes); + return layer; + } + + QList> pathFrames; + bool compatible = true; + for (int frame = mFrameRange.fMin; frame <= mFrameRange.fMax; frame++) { + const auto contours = pathContours(box->getRelativePath(frame)); + if (!pathFrames.isEmpty() && !compatibleContours(pathFrames.first(), contours)) { + compatible = false; + break; + } + pathFrames.append(contours); + } + + QJsonArray shapes = compatible ? + pathShapeObjects(pathFrames, box->prp_getName(), mFrameRange) : + pathShapeObjects(box->getRelativePath(mFrameRange.fMin), + box->prp_getName()); + LottiePathEffects::appendBasePathEffects(box, mFrameRange, shapes); + appendPaintObjects(box, shapes); + shapes.append(shapeTransformObject()); + + layer.insert(QStringLiteral("shapes"), shapes); + return layer; +} + +QJsonObject LottieLayerBuilder::buildUnsupportedLayer(const BoundingBox* const box, + const int id) const +{ + return baseLayer(box ? box->prp_getName() : QStringLiteral("Layer"), + id, + 3); +} + +QJsonObject LottieLayerBuilder::baseLayer(const QString& name, + const int id, + const int type, + const BoundingBox* const box) const +{ + QJsonObject layer; + layer.insert(QStringLiteral("ddd"), 0); + layer.insert(QStringLiteral("ind"), id); + layer.insert(QStringLiteral("ty"), type); + layer.insert(QStringLiteral("nm"), name); + layer.insert(QStringLiteral("sr"), 1); + layer.insert(QStringLiteral("ks"), transformObject()); + layer.insert(QStringLiteral("ip"), mFrameRange.fMin); + layer.insert(QStringLiteral("op"), mFrameRange.fMax + 1); + layer.insert(QStringLiteral("st"), 0); + layer.insert(QStringLiteral("bm"), + box ? LottieBlendMode::value(box->getBlendMode()) : 0); + Q_UNUSED(mFps) + return layer; +} + +void LottieLayerBuilder::assignParent(QJsonObject& layer, const int parentId) const +{ + if (parentId > 0) { + layer.insert(QStringLiteral("parent"), parentId); + } +} + +QJsonObject LottieLayerBuilder::transformObject(const BoundingBox* const box) const +{ + if (box) { + if (LottieParenting::target(box)) { + // Lottie parenting only works inside one composition. Otherwise, + // export Friction's already-resolved transform in local group space. + return LottieParenting::transform(box, + nativeParentTarget(box), + mFrameRange); + } + + const auto transform = box->getBoxTransformAnimator(); + if (transform) { + const auto pivot = transform->getPivotAnimator(); + const auto pivotStatic = pivot && + !pivot->getXAnimator()->anim_hasKeys() && + !pivot->getXAnimator()->hasExpression() && + !pivot->getYAnimator()->anim_hasKeys() && + !pivot->getYAnimator()->hasExpression(); + const QPointF staticPivot = pivotStatic ? + pivot->getEffectiveValue(mFrameRange.fMin) : + QPointF(0, 0); + + QList positions; + QList scales; + QList anchors; + QList rotations; + QList opacities; + + for (int frame = mFrameRange.fMin; frame <= mFrameRange.fMax; frame++) { + const QPointF pos = transform->getPosAnimator()->getEffectiveValue(frame); + const QPointF scale = transform->getScaleAnimator()->getEffectiveValue(frame); + const QPointF pivot = transform->getPivotAnimator()->getEffectiveValue(frame); + positions << QJsonArray{pos.x() + pivot.x(), + pos.y() + pivot.y(), + 0}; + scales << QJsonArray{scale.x()*100, scale.y()*100, 100}; + anchors << QJsonArray{pivot.x(), pivot.y(), 0}; + rotations << transform->getRotAnimator()->getEffectiveValue(frame); + opacities << transform->getOpacityAnimator()->getEffectiveValue(frame); + } + + QJsonObject animated; + const auto opacityReal = LottieRealKeyframes::scalar( + transform->getOpacityAnimator(), mFrameRange); + animated.insert(QStringLiteral("o"), + opacityReal.isEmpty() ? animatedScalarProperty(opacities) : opacityReal); + + const auto rotationReal = LottieRealKeyframes::scalar( + transform->getRotAnimator(), mFrameRange); + animated.insert(QStringLiteral("r"), + rotationReal.isEmpty() ? animatedScalarProperty(rotations) : rotationReal); + + QJsonObject positionReal; + if (pivotStatic) { + positionReal = LottieRealKeyframes::point( + transform->getPosAnimator(), mFrameRange, + [staticPivot](const QPointF& point, const qreal) { + return QJsonArray{point.x() + staticPivot.x(), + point.y() + staticPivot.y(), + 0}; + }); + } + animated.insert(QStringLiteral("p"), + positionReal.isEmpty() ? animatedPointProperty(positions) : positionReal); + + const auto anchorReal = LottieRealKeyframes::point( + transform->getPivotAnimator(), mFrameRange, + [](const QPointF& point, const qreal) { + return QJsonArray{point.x(), point.y(), 0}; + }); + animated.insert(QStringLiteral("a"), + anchorReal.isEmpty() ? animatedPointProperty(anchors) : anchorReal); + + const auto scaleReal = LottieRealKeyframes::point( + transform->getScaleAnimator(), mFrameRange, + [](const QPointF& point, const qreal) { + return QJsonArray{point.x()*100, point.y()*100, 100}; + }); + animated.insert(QStringLiteral("s"), + scaleReal.isEmpty() ? animatedPointProperty(scales) : scaleReal); + return animated; + } + } + + QJsonObject transform; + transform.insert(QStringLiteral("o"), staticProperty(100)); + transform.insert(QStringLiteral("r"), staticProperty(0)); + transform.insert(QStringLiteral("p"), staticProperty(QJsonArray{0, 0, 0})); + transform.insert(QStringLiteral("a"), staticProperty(QJsonArray{0, 0, 0})); + transform.insert(QStringLiteral("s"), staticProperty(QJsonArray{100, 100, 100})); + return transform; +} + +const BoundingBox* LottieLayerBuilder::nativeParentTarget( + const BoundingBox* const box) const +{ + if (!box) { return nullptr; } + const auto target = LottieParenting::target(box); + if (!target || + !target->isVisible() || + (!canBuildBoxLayer(target) && !dynamic_cast(target)) || + target->getParentGroup() != box->getParentGroup()) { + return nullptr; + } + + QSet visited{box}; + for (auto ancestor = target; ancestor; ancestor = LottieParenting::target(ancestor)) { + if (visited.contains(ancestor)) { return nullptr; } + visited.insert(ancestor); + if (ancestor->getParentGroup() != box->getParentGroup()) { break; } + } + + return target; +} + +QJsonObject LottieLayerBuilder::staticProperty(const QJsonValue& value) const +{ + return LottieAnimatedProperty::staticProperty(value); +} + +QJsonObject LottieLayerBuilder::animatedScalarProperty(const QList& values) const +{ + return LottieAnimatedProperty::scalar(values, mFrameRange); +} + +QJsonObject LottieLayerBuilder::animatedPointProperty(const QList& values) const +{ + return LottieAnimatedProperty::point(values, mFrameRange); +} + +void LottieLayerBuilder::appendPaintObjects(const PathBox* const box, + QJsonArray& shapes) const +{ + const auto fill = box->getFillSettings(); + if (fill && fill->getPaintType() == PaintType::FLATPAINT) { + shapes.append(fillObject(box)); + } else if (fill && fill->getPaintType() == PaintType::GRADIENTPAINT) { + shapes.append(gradientFillObject(box)); + } + + const auto stroke = box->getStrokeSettings(); + if (stroke && + !isZero4Dec(stroke->getLineWidthAnimator()->getEffectiveValue(mFrameRange.fMin))) { + if (stroke->getPaintType() == PaintType::FLATPAINT) { + shapes.append(strokeObject(box)); + } else if (stroke->getPaintType() == PaintType::GRADIENTPAINT) { + shapes.append(gradientStrokeObject(box)); + } + } +} + +QJsonObject LottieLayerBuilder::fillObject(const PathBox* const box) const +{ + QList fillColors; + QList fillOpacities; + const auto fill = box->getFillSettings(); + for (int frame = mFrameRange.fMin; frame <= mFrameRange.fMax; frame++) { + QColor fillColor(0, 0, 0, 0); + if (fill) { fillColor = fill->getColor(frame); } + fillColors << colorArray(fillColor); + fillOpacities << fillColor.alphaF()*100; + } + + QJsonObject object; + object.insert(QStringLiteral("ty"), QStringLiteral("fl")); + const auto colorReal = fill ? + LottieRealKeyframes::color(fill->getColorAnimator(), mFrameRange, true) : + QJsonObject(); + object.insert(QStringLiteral("c"), + colorReal.isEmpty() ? animatedPointProperty(fillColors) : colorReal); + + const auto opacityReal = fill && fill->getColorAnimator() ? + LottieRealKeyframes::scalar( + fill->getColorAnimator()->getAlphaAnimator(), + mFrameRange, + [](const qreal value) { return value*100; }) : + QJsonObject(); + object.insert(QStringLiteral("o"), + opacityReal.isEmpty() ? animatedScalarProperty(fillOpacities) : opacityReal); + object.insert(QStringLiteral("r"), 1); + object.insert(QStringLiteral("bm"), 0); + object.insert(QStringLiteral("nm"), QStringLiteral("Fill")); + return object; +} + +QJsonObject LottieLayerBuilder::gradientFillObject(const PathBox* const box) const +{ + return gradientObject(box ? box->getFillSettings() : nullptr, + QStringLiteral("Gradient Fill"), + false); +} + +QJsonObject LottieLayerBuilder::strokeObject(const PathBox* const box) const +{ + QList strokeColors; + QList strokeOpacities; + QList strokeWidths; + int lineCap = 2; + int lineJoin = 2; + + const auto stroke = box->getStrokeSettings(); + if (stroke) { + switch(stroke->getCapStyle()) { + case SkPaint::kButt_Cap: lineCap = 1; break; + case SkPaint::kSquare_Cap: lineCap = 3; break; + default: lineCap = 2; break; + } + + switch(stroke->getJoinStyle()) { + case SkPaint::kMiter_Join: lineJoin = 1; break; + case SkPaint::kBevel_Join: lineJoin = 3; break; + default: lineJoin = 2; break; + } + } + + for (int frame = mFrameRange.fMin; frame <= mFrameRange.fMax; frame++) { + QColor strokeColor(0, 0, 0, 0); + qreal strokeWidth = 0; + if (stroke) { + strokeColor = stroke->getColor(frame); + strokeWidth = stroke->getLineWidthAnimator()->getEffectiveValue(frame); + } + strokeColors << colorArray(strokeColor); + strokeOpacities << strokeColor.alphaF()*100; + strokeWidths << strokeWidth; + } + + QJsonObject object; + object.insert(QStringLiteral("ty"), QStringLiteral("st")); + const auto colorReal = stroke ? + LottieRealKeyframes::color(stroke->getColorAnimator(), mFrameRange, true) : + QJsonObject(); + object.insert(QStringLiteral("c"), + colorReal.isEmpty() ? animatedPointProperty(strokeColors) : colorReal); + + const auto opacityReal = stroke && stroke->getColorAnimator() ? + LottieRealKeyframes::scalar( + stroke->getColorAnimator()->getAlphaAnimator(), + mFrameRange, + [](const qreal value) { return value*100; }) : + QJsonObject(); + object.insert(QStringLiteral("o"), + opacityReal.isEmpty() ? animatedScalarProperty(strokeOpacities) : opacityReal); + + const auto widthReal = stroke ? + LottieRealKeyframes::scalar(stroke->getLineWidthAnimator(), mFrameRange) : + QJsonObject(); + object.insert(QStringLiteral("w"), + widthReal.isEmpty() ? animatedScalarProperty(strokeWidths) : widthReal); + object.insert(QStringLiteral("lc"), lineCap); + object.insert(QStringLiteral("lj"), lineJoin); + object.insert(QStringLiteral("ml"), 4); + object.insert(QStringLiteral("bm"), 0); + object.insert(QStringLiteral("nm"), QStringLiteral("Stroke")); + LottiePathEffects::appendStrokeDash(box, mFrameRange, object); + return object; +} + +QJsonObject LottieLayerBuilder::gradientStrokeObject(const PathBox* const box) const +{ + auto object = gradientObject(box ? box->getStrokeSettings() : nullptr, + QStringLiteral("Gradient Stroke"), + true); + LottiePathEffects::appendStrokeDash(box, mFrameRange, object); + return object; +} + +QJsonObject LottieLayerBuilder::gradientObject(PaintSettingsAnimator* const settings, + const QString& name, + const bool stroke) const +{ + const int stopCount = gradientStopCount(settings); + const auto outline = dynamic_cast(settings); + QList colors; + QList starts; + QList ends; + QList opacities; + QList widths; + int lineCap = 2; + int lineJoin = 2; + + if (outline) { + switch(outline->getCapStyle()) { + case SkPaint::kButt_Cap: lineCap = 1; break; + case SkPaint::kSquare_Cap: lineCap = 3; break; + default: lineCap = 2; break; + } + + switch(outline->getJoinStyle()) { + case SkPaint::kMiter_Join: lineJoin = 1; break; + case SkPaint::kBevel_Join: lineJoin = 3; break; + default: lineJoin = 2; break; + } + } + + for (int frame = mFrameRange.fMin; frame <= mFrameRange.fMax; frame++) { + colors << gradientColorArray(settings, frame, stopCount); + + QPointF startPoint(0, 0); + QPointF endPoint(0, 0); + if (settings && settings->getGradientPoints()) { + startPoint = settings->getGradientPoints()->getStartPoint(frame); + endPoint = settings->getGradientPoints()->getEndPoint(frame); + } + starts << QJsonArray{startPoint.x(), startPoint.y()}; + ends << QJsonArray{endPoint.x(), endPoint.y()}; + opacities << 100; + widths << (outline ? outline->getLineWidthAnimator()->getEffectiveValue(frame) : 0); + } + + QJsonObject object; + object.insert(QStringLiteral("ty"), stroke ? QStringLiteral("gs") : QStringLiteral("gf")); + object.insert(QStringLiteral("o"), animatedScalarProperty(opacities)); + object.insert(QStringLiteral("r"), 1); + object.insert(QStringLiteral("bm"), 0); + const auto gradientReal = settings ? + LottieRealKeyframes::gradientColors( + settings->getGradient(), + stopCount, + mFrameRange) : + QJsonObject(); + object.insert(QStringLiteral("g"), QJsonObject{ + {QStringLiteral("p"), stopCount}, + {QStringLiteral("k"), + gradientReal.isEmpty() ? + animatedPointProperty(colors) : + gradientReal} + }); + const auto startReal = settings && settings->getGradientPoints() ? + LottieRealKeyframes::point( + settings->getGradientPoints()->startAnimator(), + mFrameRange, + [](const QPointF& point, const qreal) { + return QJsonArray{point.x(), point.y()}; + }) : + QJsonObject(); + object.insert(QStringLiteral("s"), + startReal.isEmpty() ? animatedPointProperty(starts) : startReal); + + const auto endReal = settings && settings->getGradientPoints() ? + LottieRealKeyframes::point( + settings->getGradientPoints()->endAnimator(), + mFrameRange, + [](const QPointF& point, const qreal) { + return QJsonArray{point.x(), point.y()}; + }) : + QJsonObject(); + object.insert(QStringLiteral("e"), + endReal.isEmpty() ? animatedPointProperty(ends) : endReal); + object.insert(QStringLiteral("t"), + settings && + settings->getGradientType() == GradientType::RADIAL ? 2 : 1); + object.insert(QStringLiteral("nm"), name); + + if (stroke) { + const auto widthReal = outline ? + LottieRealKeyframes::scalar(outline->getLineWidthAnimator(), mFrameRange) : + QJsonObject(); + object.insert(QStringLiteral("w"), + widthReal.isEmpty() ? animatedScalarProperty(widths) : widthReal); + object.insert(QStringLiteral("lc"), lineCap); + object.insert(QStringLiteral("lj"), lineJoin); + object.insert(QStringLiteral("ml"), 4); + } + + return object; +} + +QJsonArray LottieLayerBuilder::gradientColorArray(PaintSettingsAnimator* const settings, + const int frame, + const int stopCount) const +{ + QJsonArray values; + const auto gradient = settings ? settings->getGradient() : nullptr; + QGradientStops stops = gradient ? + gradient->getQGradientStops(settings->prp_relFrameToAbsFrameF(frame)) : + QGradientStops{{0, QColor(0, 0, 0)}}; + + if (stops.isEmpty()) { stops << QGradientStop(0, QColor(0, 0, 0)); } + while (stops.size() < stopCount) { + const qreal position = stopCount <= 1 ? 0 : qreal(stops.size())/(stopCount - 1); + stops << QGradientStop(position, stops.last().second); + } + while (stops.size() > stopCount) { stops.removeLast(); } + + for (const auto& stop : stops) { + values.append(stop.first); + values.append(stop.second.redF()); + values.append(stop.second.greenF()); + values.append(stop.second.blueF()); + } + + for (const auto& stop : stops) { + values.append(stop.first); + values.append(stop.second.alphaF()); + } + return values; +} + +int LottieLayerBuilder::gradientStopCount(PaintSettingsAnimator* const settings) const +{ + const auto gradient = settings ? settings->getGradient() : nullptr; + const int stops = gradient ? gradient->getQGradientStops().size() : 0; + return qMax(2, stops); +} + +QJsonObject LottieLayerBuilder::shapeTransformObject() const +{ + QJsonObject shapeTransform; + shapeTransform.insert(QStringLiteral("ty"), QStringLiteral("tr")); + shapeTransform.insert(QStringLiteral("p"), staticProperty(QJsonArray{0, 0})); + shapeTransform.insert(QStringLiteral("a"), staticProperty(QJsonArray{0, 0})); + shapeTransform.insert(QStringLiteral("s"), staticProperty(QJsonArray{100, 100})); + shapeTransform.insert(QStringLiteral("r"), staticProperty(0)); + shapeTransform.insert(QStringLiteral("o"), staticProperty(100)); + shapeTransform.insert(QStringLiteral("sk"), staticProperty(0)); + shapeTransform.insert(QStringLiteral("sa"), staticProperty(0)); + return shapeTransform; +} + +QJsonArray LottieLayerBuilder::colorArray(const QColor& color) const +{ + return QJsonArray{ + color.redF(), + color.greenF(), + color.blueF(), + color.alphaF() + }; +} + +void LottieLayerBuilder::appendFonts(const ContainerBox* const container, + QJsonArray& fonts, + QSet& names) const +{ + if (!container) { return; } + + const auto& boxes = container->getContainedBoxes(); + for (const auto box : boxes) { + if (!box || !box->isVisible()) { continue; } + + const auto text = dynamic_cast(box); + if (text && canBuildNativeTextLayer(text)) { + const QString name = fontName(text); + if (!names.contains(name)) { + names.insert(name); + fonts.append(fontObject(text)); + } + } + + const auto childContainer = dynamic_cast(box); + if (childContainer) { + appendFonts(childContainer, fonts, names); + } + } +} + +void LottieLayerBuilder::appendImageAssets(const ContainerBox* const container, + QJsonArray& assets, + QSet& ids) const +{ + if (!container) { return; } + + const auto& boxes = container->getContainedBoxes(); + for (const auto box : boxes) { + if (!box || !box->isVisible()) { continue; } + + const auto image = dynamic_cast(box); + if (image) { + const QString id = imageAssetId(image); + if (!ids.contains(id)) { + const auto asset = imageAsset(image); + if (!asset.isEmpty()) { + ids.insert(id); + assets.append(asset); + } + } + } + + const auto childContainer = dynamic_cast(box); + if (childContainer) { + appendImageAssets(childContainer, assets, ids); + } + } +} + +QJsonObject LottieLayerBuilder::imageAsset(const ImageBox* const box) const +{ + const auto image = imageForBox(box); + if (!image) { return QJsonObject(); } + + QString path; + QString dir; + bool embedded = true; + if (mEmbedImages) { + path = pngDataUri(image); + if (path.isEmpty()) { return QJsonObject(); } + } else { + const sk_sp data = pngData(image); + if (!data) { return QJsonObject(); } + + const QString dirPath = imageAssetsDirPath(); + if (dirPath.isEmpty()) { return QJsonObject(); } + QDir().mkpath(dirPath); + + QFile file(QDir(dirPath).filePath(imageAssetFileName(box))); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return QJsonObject(); + } + file.write(static_cast(data->data()), data->size()); + file.close(); + + path = imageAssetFileName(box); + dir = imageAssetsDirName() + QStringLiteral("/"); + embedded = false; + } + + return QJsonObject{ + {QStringLiteral("id"), imageAssetId(box)}, + {QStringLiteral("w"), image->width()}, + {QStringLiteral("h"), image->height()}, + {QStringLiteral("u"), dir}, + {QStringLiteral("p"), path}, + {QStringLiteral("e"), embedded ? 1 : 0} + }; +} + +QString LottieLayerBuilder::imageAssetId(const ImageBox* const box) const +{ + return QStringLiteral("image_%1").arg(reinterpret_cast(box), 0, 16); +} + +QString LottieLayerBuilder::imageAssetFileName(const ImageBox* const box) const +{ + return imageAssetId(box) + QStringLiteral(".png"); +} + +QString LottieLayerBuilder::imageAssetsDirName() const +{ + const QFileInfo info(mPath); + return info.completeBaseName() + QStringLiteral("_assets"); +} + +QString LottieLayerBuilder::imageAssetsDirPath() const +{ + const QFileInfo info(mPath); + if (info.absolutePath().isEmpty()) { return QString(); } + return QDir(info.absolutePath()).filePath(imageAssetsDirName()); +} + +QJsonObject LottieLayerBuilder::fontObject(const TextBox* const box) const +{ + return QJsonObject{ + {QStringLiteral("fName"), fontName(box)}, + {QStringLiteral("fFamily"), box ? box->getFontFamily() : QStringLiteral("Sans")}, + {QStringLiteral("fStyle"), fontStyleName(box)}, + {QStringLiteral("ascent"), 75} + }; +} + +QString LottieLayerBuilder::fontName(const TextBox* const box) const +{ + const QString family = box ? box->getFontFamily() : QStringLiteral("Sans"); + const QString style = fontStyleName(box); + QString name = style == QStringLiteral("Regular") ? + family : + QStringLiteral("%1-%2").arg(family, style); + return name.replace(QLatin1Char(' '), QLatin1Char('_')); +} + +QString LottieLayerBuilder::fontStyleName(const TextBox* const box) const +{ + if (!box) { return QStringLiteral("Regular"); } + + const auto& style = box->getFontStyle(); + const bool bold = style.weight() >= SkFontStyle::kSemiBold_Weight; + const bool italic = style.slant() != SkFontStyle::kUpright_Slant; + + if (bold && italic) { return QStringLiteral("BoldItalic"); } + if (bold) { return QStringLiteral("Bold"); } + if (italic) { return QStringLiteral("Italic"); } + return QStringLiteral("Regular"); +} + +bool LottieLayerBuilder::canBuildNativeTextLayer(const TextBox* const box) const +{ + if (!mNativeText) { return false; } + if (!box || + box->hasTextEffects() || + box->hasBasePathEffects() || + box->hasFillEffects() || + box->hasOutlineBaseEffects() || + box->hasOutlineEffects()) { + return false; + } + + for (int frame = mFrameRange.fMin; frame <= mFrameRange.fMax; frame++) { + if (!isOne4Dec(box->getWordSpacing(frame))) { return false; } + if (!canBuildNativeTextValue(box->getValueAtRelFrame(frame))) { return false; } + } + return true; +} + +bool LottieLayerBuilder::canBuildNativeTextValue(const QString& value) const +{ + for (const auto character : value) { + if (character == QLatin1Char('\n') || character == QLatin1Char('\r')) { + continue; + } + if (character.unicode() > 0x7f) { return false; } + } + return true; +} diff --git a/src/core/lottie/lottielayerbuilder.h b/src/core/lottie/lottielayerbuilder.h new file mode 100644 index 000000000..4e6d9f5ac --- /dev/null +++ b/src/core/lottie/lottielayerbuilder.h @@ -0,0 +1,144 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#ifndef LOTTIELAYERBUILDER_H +#define LOTTIELAYERBUILDER_H + +#include "framerange.h" + +#include +#include +#include +#include + +class BoundingBox; +class Canvas; +class QColor; +class ContainerBox; +class ImageBox; +class PaintSettingsAnimator; +class PathBox; +class RectangleBox; +class TextBox; + +class CORE_EXPORT LottieLayerBuilder +{ +public: + LottieLayerBuilder(Canvas* const scene, + const FrameRange& frameRange, + const qreal fps, + const QString& path = QString(), + const bool embedImages = true, + const bool svgRendererFix = false, + const bool nativeText = false); + + QJsonArray buildLayers(const bool background) const; + QJsonArray buildAssets() const; + QJsonObject buildFonts() const; + +private: + QJsonObject buildSvgRendererFixLayer(const int id) const; + QJsonObject buildBackgroundLayer() const; + void appendContainerLayers(const ContainerBox* const container, + QJsonArray& layers, + int& nextId, + const int parentId = 0) const; + QJsonObject buildMatteLayer(BoundingBox* const box, + const int id) const; + QJsonObject buildBoxLayer(BoundingBox* const box, + const int id) const; + bool canBuildBoxLayer(const BoundingBox* const box) const; + bool canBuildMatteLayer(const BoundingBox* const box) const; + bool isAlphaMatteLayer(const BoundingBox* const box) const; + int alphaMatteType(const BoundingBox* const box) const; + QJsonObject buildContainerLayer(const ContainerBox* const box, + const int id, + const int parentId) const; + QString appendPrecompAsset(const ContainerBox* const box) const; + QJsonObject buildRectangleLayer(RectangleBox* const box, + const int id) const; + QJsonObject buildTextLayer(TextBox* const box, + const int id) const; + QJsonObject textDocumentObject(TextBox* const box, + const int frame) const; + QJsonArray textDocumentKeyframes(TextBox* const box) const; + int textJustification(const Qt::Alignment alignment) const; + QJsonObject buildImageLayer(ImageBox* const box, + const int id) const; + QJsonObject buildPathLayer(PathBox* const box, + const int id) const; + QJsonObject buildUnsupportedLayer(const BoundingBox* const box, + const int id) const; + + QJsonObject baseLayer(const QString& name, + const int id, + const int type, + const BoundingBox* const box = nullptr) const; + void assignParent(QJsonObject& layer, const int parentId) const; + QJsonObject transformObject(const BoundingBox* const box = nullptr) const; + const BoundingBox* nativeParentTarget(const BoundingBox* const box) const; + QJsonObject staticProperty(const QJsonValue& value) const; + QJsonObject animatedScalarProperty(const QList& values) const; + QJsonObject animatedPointProperty(const QList& values) const; + void appendPaintObjects(const PathBox* const box, + QJsonArray& shapes) const; + QJsonObject fillObject(const PathBox* const box) const; + QJsonObject gradientFillObject(const PathBox* const box) const; + QJsonObject strokeObject(const PathBox* const box) const; + QJsonObject gradientStrokeObject(const PathBox* const box) const; + QJsonObject gradientObject(PaintSettingsAnimator* const settings, + const QString& name, + const bool stroke) const; + QJsonArray gradientColorArray(PaintSettingsAnimator* const settings, + const int frame, + const int stopCount) const; + int gradientStopCount(PaintSettingsAnimator* const settings) const; + QJsonObject shapeTransformObject() const; + QJsonArray colorArray(const QColor& color) const; + void appendFonts(const ContainerBox* const container, + QJsonArray& fonts, + QSet& names) const; + void appendImageAssets(const ContainerBox* const container, + QJsonArray& assets, + QSet& ids) const; + QJsonObject imageAsset(const ImageBox* const box) const; + QString imageAssetId(const ImageBox* const box) const; + QString imageAssetFileName(const ImageBox* const box) const; + QString imageAssetsDirName() const; + QString imageAssetsDirPath() const; + QJsonObject fontObject(const TextBox* const box) const; + QString fontName(const TextBox* const box) const; + QString fontStyleName(const TextBox* const box) const; + bool canBuildNativeTextLayer(const TextBox* const box) const; + bool canBuildNativeTextValue(const QString& value) const; + + Canvas* const mScene; + const FrameRange mFrameRange; + const qreal mFps; + const QString mPath; + const bool mEmbedImages; + const bool mSvgRendererFix; + const bool mNativeText; + mutable QJsonArray mPrecompAssets; + mutable int mNextPrecompId = 1; +}; + +#endif // LOTTIELAYERBUILDER_H diff --git a/src/core/lottie/lottieparenting.cpp b/src/core/lottie/lottieparenting.cpp new file mode 100644 index 000000000..a2e9f0532 --- /dev/null +++ b/src/core/lottie/lottieparenting.cpp @@ -0,0 +1,125 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +*/ + +#include "lottie/lottieparenting.h" + +#include "Animators/qrealanimator.h" +#include "Animators/transformanimator.h" +#include "Boxes/boundingbox.h" +#include "Properties/boxtargetproperty.h" +#include "TransformEffects/parenteffect.h" +#include "TransformEffects/transformeffectcollection.h" +#include "lottie/lottieanimatedproperty.h" +#include "matrixdecomposition.h" +#include "simplemath.h" + +#include + +BoundingBox* LottieParenting::target(const BoundingBox* const box) +{ + if (!box) { return nullptr; } + const auto transform = box->getBoxTransformAnimator(); + if (!transform) { return nullptr; } + + // Keep Parent Effect discovery local to the exporter instead of exposing + // Lottie-specific accessors on BoundingBox or ParentEffect. + BoundingBox* result = nullptr; + for (int i = 0; i < transform->ca_getNumberOfChildren(); i++) { + const auto child = transform->ca_getChildAt(i); + const auto effects = dynamic_cast(child); + if (!effects) { continue; } + + for (int j = 0; j < effects->ca_getNumberOfChildren(); j++) { + const auto effect = dynamic_cast(effects->getChild(j)); + if (!effect || !effect->isVisible()) { continue; } + + for (int k = 0; k < effect->ca_getNumberOfChildren(); k++) { + const auto effectChild = effect->ca_getChildAt(k); + const auto property = dynamic_cast(effectChild); + if (property && property->getTarget()) { + result = property->getTarget(); + break; + } + } + } + } + return result; +} + +QJsonObject LottieParenting::transform(const BoundingBox* const box, + const BoundingBox* const parent, + const FrameRange& frameRange) +{ + QList positions; + QList scales; + QList rotations; + QList opacities; + QList skews; + QList skewAxes; + qreal previousRotation = 0; + bool firstRotation = true; + + for (int frame = frameRange.fMin; frame <= frameRange.fMax; frame++) { + QMatrix matrix = box->getRelativeTransformAtFrame(frame); + if (parent) { + const qreal absFrame = box->prp_relFrameToAbsFrameF(frame); + const qreal parentFrame = parent->prp_absFrameToRelFrameF(absFrame); + bool invertible = false; + const QMatrix parentInverse = + parent->getRelativeTransformAtFrame(parentFrame).inverted(&invertible); + if (invertible) { matrix *= parentInverse; } + } + + const TransformValues values = MatrixDecomposition::decompose(matrix); + qreal rotation = values.fRotation; + if (!firstRotation) { + while (rotation - previousRotation > 180) { rotation -= 360; } + while (rotation - previousRotation < -180) { rotation += 360; } + } + firstRotation = false; + previousRotation = rotation; + + positions << QJsonArray{values.fMoveX, values.fMoveY, 0}; + scales << QJsonArray{values.fScaleX*100, values.fScaleY*100, 100}; + rotations << rotation; + if (!isZero6Dec(values.fShearX)) { + skews << qRadiansToDegrees(std::atan(values.fShearX)); + skewAxes << 0; + } else { + skews << qRadiansToDegrees(std::atan(values.fShearY)); + skewAxes << 90; + } + + const auto boxTransform = box->getBoxTransformAnimator(); + opacities << (boxTransform ? + boxTransform->getOpacityAnimator()->getEffectiveValue(frame) : + 100); + } + + QJsonObject transform; + transform.insert(QStringLiteral("o"), + LottieAnimatedProperty::scalar(opacities, frameRange)); + transform.insert(QStringLiteral("r"), + LottieAnimatedProperty::scalar(rotations, frameRange)); + transform.insert(QStringLiteral("p"), + LottieAnimatedProperty::point(positions, frameRange)); + transform.insert(QStringLiteral("a"), + LottieAnimatedProperty::staticProperty(QJsonArray{0, 0, 0})); + transform.insert(QStringLiteral("s"), + LottieAnimatedProperty::point(scales, frameRange)); + transform.insert(QStringLiteral("sk"), + LottieAnimatedProperty::scalar(skews, frameRange)); + transform.insert(QStringLiteral("sa"), + LottieAnimatedProperty::scalar(skewAxes, frameRange)); + return transform; +} diff --git a/src/core/lottie/lottieparenting.h b/src/core/lottie/lottieparenting.h new file mode 100644 index 000000000..3541762cb --- /dev/null +++ b/src/core/lottie/lottieparenting.h @@ -0,0 +1,32 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +*/ + +#ifndef LOTTIEPARENTING_H +#define LOTTIEPARENTING_H + +#include "framerange.h" + +#include + +class BoundingBox; + +namespace LottieParenting { + +BoundingBox* target(const BoundingBox* const box); +QJsonObject transform(const BoundingBox* const box, + const BoundingBox* const parent, + const FrameRange& frameRange); + +} + +#endif // LOTTIEPARENTING_H diff --git a/src/core/lottie/lottiepatheffects.cpp b/src/core/lottie/lottiepatheffects.cpp new file mode 100644 index 000000000..044fcaba2 --- /dev/null +++ b/src/core/lottie/lottiepatheffects.cpp @@ -0,0 +1,178 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#include "lottie/lottiepatheffects.h" + +#include "Animators/qrealanimator.h" +#include "Boxes/pathbox.h" +#include "lottie/lottieanimatedproperty.h" +#include "lottie/lottierealkeyframes.h" +#include "PathEffects/patheffect.h" +#include "PathEffects/patheffectcollection.h" +#include "Properties/boolproperty.h" + +#include +#include + +namespace { + +QJsonObject staticProperty(const QJsonValue& value) +{ + return LottieAnimatedProperty::staticProperty(value); +} + +QJsonObject animatedScalarProperty(const QList& values, + const FrameRange& frameRange) +{ + return LottieAnimatedProperty::scalar(values, frameRange); +} + +QJsonObject scalarProperty(QrealAnimator* const animator, + const QList& values, + const FrameRange& frameRange, + const LottieRealKeyframes::ScalarValue& value = nullptr) +{ + const auto real = LottieRealKeyframes::scalar(animator, frameRange, value); + return real.isEmpty() ? animatedScalarProperty(values, frameRange) : real; +} + +QrealAnimator* qrealChild(PathEffect* const effect, + const QString& name) +{ + if (!effect) { return nullptr; } + for (int i = 0; i < effect->ca_getNumberOfChildren(); i++) { + const auto child = effect->ca_getChildAt(i); + if (child && child->prp_getName() == name) { + return enve_cast(child); + } + } + return nullptr; +} + +BoolProperty* boolChild(PathEffect* const effect, + const QString& name) +{ + if (!effect) { return nullptr; } + for (int i = 0; i < effect->ca_getNumberOfChildren(); i++) { + const auto child = effect->ca_getChildAt(i); + if (child && child->prp_getName() == name) { + return enve_cast(child); + } + } + return nullptr; +} + +QJsonObject trimPathObject(PathEffect* const effect, + const FrameRange& frameRange) +{ + const auto pathWise = boolChild(effect, QStringLiteral("path-wise")); + const auto minLength = qrealChild(effect, QStringLiteral("min length")); + const auto maxLength = qrealChild(effect, QStringLiteral("max length")); + const auto offset = qrealChild(effect, QStringLiteral("offset")); + + QList starts; + QList ends; + QList offsets; + for (int frame = frameRange.fMin; frame <= frameRange.fMax; frame++) { + starts << (minLength ? minLength->getEffectiveValue(frame) : 0); + ends << (maxLength ? maxLength->getEffectiveValue(frame) : 100); + offsets << (offset ? offset->getEffectiveValue(frame)*3.6 : 0); + } + + QJsonObject object; + object.insert(QStringLiteral("ty"), QStringLiteral("tm")); + object.insert(QStringLiteral("s"), scalarProperty(minLength, starts, frameRange)); + object.insert(QStringLiteral("e"), scalarProperty(maxLength, ends, frameRange)); + object.insert(QStringLiteral("o"), + scalarProperty(offset, + offsets, + frameRange, + [](const qreal value) { return value*3.6; })); + object.insert(QStringLiteral("m"), pathWise && pathWise->getValue() ? 2 : 1); + object.insert(QStringLiteral("nm"), QStringLiteral("Sub-Path")); + object.insert(QStringLiteral("hd"), false); + return object; +} + +QJsonObject strokeDashEntry(const QString& name, + const QString& displayName, + const QJsonObject& value) +{ + return QJsonObject{ + {QStringLiteral("n"), name}, + {QStringLiteral("nm"), displayName}, + {QStringLiteral("v"), value} + }; +} + +QJsonArray dashValues(PathEffect* const effect, + const FrameRange& frameRange) +{ + QList values; + const auto size = qrealChild(effect, QStringLiteral("size")); + for (int frame = frameRange.fMin; frame <= frameRange.fMax; frame++) { + values << (size ? size->getEffectiveValue(frame) : 0); + } + + const auto property = scalarProperty(size, values, frameRange); + return QJsonArray{ + strokeDashEntry(QStringLiteral("d"), QStringLiteral("dash"), property), + strokeDashEntry(QStringLiteral("g"), QStringLiteral("gap"), property), + strokeDashEntry(QStringLiteral("o"), QStringLiteral("offset"), staticProperty(0)) + }; +} + +} + +void LottiePathEffects::appendBasePathEffects(const PathBox* const box, + const FrameRange& frameRange, + QJsonArray& shapes) +{ + if (!box) { return; } + + const auto effects = const_cast(box)->getPathEffectsAnimators(); + if (!effects || !effects->hasEffects()) { return; } + + for (int i = 0; i < effects->ca_getNumberOfChildren(); i++) { + const auto effect = effects->getChild(i); + if (!effect || !effect->isVisible()) { continue; } + if (effect->getEffectType() != PathEffectType::SUB) { continue; } + shapes.append(trimPathObject(effect, frameRange)); + } +} + +void LottiePathEffects::appendStrokeDash(const PathBox* const box, + const FrameRange& frameRange, + QJsonObject& stroke) +{ + if (!box) { return; } + + const auto effects = const_cast(box)->getPathEffectsAnimators(); + if (!effects || !effects->hasEffects()) { return; } + + for (int i = 0; i < effects->ca_getNumberOfChildren(); i++) { + const auto effect = effects->getChild(i); + if (!effect || !effect->isVisible()) { continue; } + if (effect->getEffectType() != PathEffectType::DASH) { continue; } + stroke.insert(QStringLiteral("d"), dashValues(effect, frameRange)); + return; + } +} diff --git a/src/core/lottie/lottiepatheffects.h b/src/core/lottie/lottiepatheffects.h new file mode 100644 index 000000000..265cf1e66 --- /dev/null +++ b/src/core/lottie/lottiepatheffects.h @@ -0,0 +1,43 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#ifndef LOTTIEPATHEFFECTS_H +#define LOTTIEPATHEFFECTS_H + +#include "framerange.h" + +#include +#include + +class PathBox; + +namespace LottiePathEffects { + +void appendBasePathEffects(const PathBox* const box, + const FrameRange& frameRange, + QJsonArray& shapes); +void appendStrokeDash(const PathBox* const box, + const FrameRange& frameRange, + QJsonObject& stroke); + +} + +#endif // LOTTIEPATHEFFECTS_H diff --git a/src/core/lottie/lottierealkeyframes.cpp b/src/core/lottie/lottierealkeyframes.cpp new file mode 100644 index 000000000..ab77c9292 --- /dev/null +++ b/src/core/lottie/lottierealkeyframes.cpp @@ -0,0 +1,493 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#include "lottie/lottierealkeyframes.h" + +#include "Animators/coloranimator.h" +#include "Animators/gradient.h" +#include "Animators/qpointfanimator.h" +#include "Animators/qrealanimator.h" +#include "Animators/qrealkey.h" +#include "lottie/lottieanimatedproperty.h" +#include "simplemath.h" + +#include +#include + +namespace { + +QJsonArray scalarArray(const qreal value) +{ + return QJsonArray{value}; +} + +QJsonObject easeObject(const QList& x, + const QList& y) +{ + QJsonArray xArray; + QJsonArray yArray; + for (const auto value : x) { xArray.append(qBound(qreal(0), value, qreal(1))); } + for (const auto value : y) { yArray.append(value); } + return QJsonObject{ + {QStringLiteral("x"), xArray}, + {QStringLiteral("y"), yArray} + }; +} + +qreal normalizedValue(const qreal value, + const qreal start, + const qreal end, + const qreal fallback) +{ + const qreal delta = end - start; + if (isZero4Dec(delta)) { return fallback; } + return (value - start)/delta; +} + +struct ScalarKeys { + QList keys; + bool valid = false; +}; + +ScalarKeys scalarKeys(QrealAnimator* const animator, + const FrameRange& frameRange) +{ + ScalarKeys result; + if (!animator || animator->hasExpression()) { return result; } + + const auto& keys = animator->anim_getKeys(); + if (keys.count() < 2) { return result; } + + for (const auto& key : keys) { + const auto realKey = static_cast(key); + const int frame = realKey->getAbsFrame(); + if (frame < frameRange.fMin || frame > frameRange.fMax) { continue; } + result.keys << realKey; + } + + if (result.keys.size() < 2) { return result; } + if (result.keys.first()->getAbsFrame() != frameRange.fMin) { return result; } + if (result.keys.last()->getAbsFrame() != frameRange.fMax) { return result; } + + result.valid = true; + return result; +} + +QJsonObject scalarEaseIn(QrealKey* const prev, + QrealKey* const next) +{ + const qreal startFrame = prev->getAbsFrame(); + const qreal endFrame = next->getAbsFrame(); + const qreal span = qMax(qreal(1), endFrame - startFrame); + const qreal x = (next->getC0AbsFrame() - startFrame)/span; + const qreal y = normalizedValue(next->getC0Value(), + prev->getValue(), + next->getValue(), + 1); + return easeObject({x}, {y}); +} + +QJsonObject scalarEaseOut(QrealKey* const prev, + QrealKey* const next) +{ + const qreal startFrame = prev->getAbsFrame(); + const qreal endFrame = next->getAbsFrame(); + const qreal span = qMax(qreal(1), endFrame - startFrame); + const qreal x = (prev->getC1AbsFrame() - startFrame)/span; + const qreal y = normalizedValue(prev->getC1Value(), + prev->getValue(), + next->getValue(), + 0); + return easeObject({x}, {y}); +} + +bool compatiblePointKeys(const ScalarKeys& x, + const ScalarKeys& y) +{ + if (!x.valid || !y.valid) { return false; } + if (x.keys.size() != y.keys.size()) { return false; } + for (int i = 0; i < x.keys.size(); i++) { + if (x.keys.at(i)->getAbsFrame() != y.keys.at(i)->getAbsFrame()) { + return false; + } + } + return true; +} + +bool compatibleColorKeys(const QList& channels) +{ + if (channels.isEmpty()) { return false; } + + const ScalarKeys* reference = nullptr; + for (const auto& channel : channels) { + if (!channel.valid) { return false; } + if (!reference) { + reference = &channel; + continue; + } + if (reference->keys.size() != channel.keys.size()) { return false; } + for (int i = 0; i < reference->keys.size(); i++) { + if (reference->keys.at(i)->getAbsFrame() != channel.keys.at(i)->getAbsFrame()) { + return false; + } + } + } + + return reference && reference->keys.size() >= 2; +} + +QJsonObject pointEaseIn(QrealKey* const xPrev, + QrealKey* const xNext, + QrealKey* const yPrev, + QrealKey* const yNext, + const int dimensions) +{ + const qreal xStartFrame = xPrev->getAbsFrame(); + const qreal xSpan = qMax(qreal(1), qreal(xNext->getAbsFrame() - xPrev->getAbsFrame())); + const qreal yStartFrame = yPrev->getAbsFrame(); + const qreal ySpan = qMax(qreal(1), qreal(yNext->getAbsFrame() - yPrev->getAbsFrame())); + QList xs{ + (xNext->getC0AbsFrame() - xStartFrame)/xSpan, + (yNext->getC0AbsFrame() - yStartFrame)/ySpan + }; + QList ys{ + normalizedValue(xNext->getC0Value(), xPrev->getValue(), xNext->getValue(), 1), + normalizedValue(yNext->getC0Value(), yPrev->getValue(), yNext->getValue(), 1) + }; + while (xs.size() < dimensions) { xs << 1; } + while (ys.size() < dimensions) { ys << 1; } + return easeObject(xs, ys); +} + +QJsonObject pointEaseOut(QrealKey* const xPrev, + QrealKey* const xNext, + QrealKey* const yPrev, + QrealKey* const yNext, + const int dimensions) +{ + const qreal xStartFrame = xPrev->getAbsFrame(); + const qreal xSpan = qMax(qreal(1), qreal(xNext->getAbsFrame() - xPrev->getAbsFrame())); + const qreal yStartFrame = yPrev->getAbsFrame(); + const qreal ySpan = qMax(qreal(1), qreal(yNext->getAbsFrame() - yPrev->getAbsFrame())); + QList xs{ + (xPrev->getC1AbsFrame() - xStartFrame)/xSpan, + (yPrev->getC1AbsFrame() - yStartFrame)/ySpan + }; + QList ys{ + normalizedValue(xPrev->getC1Value(), xPrev->getValue(), xNext->getValue(), 0), + normalizedValue(yPrev->getC1Value(), yPrev->getValue(), yNext->getValue(), 0) + }; + while (xs.size() < dimensions) { xs << 0; } + while (ys.size() < dimensions) { ys << 0; } + return easeObject(xs, ys); +} + +QJsonObject colorEaseIn(const QList& channels, + const int index) +{ + QList xs; + QList ys; + for (const auto& channel : channels) { + const auto prev = channel.keys.at(index); + const auto next = channel.keys.at(index + 1); + const qreal startFrame = prev->getAbsFrame(); + const qreal span = qMax(qreal(1), qreal(next->getAbsFrame() - prev->getAbsFrame())); + xs << (next->getC0AbsFrame() - startFrame)/span; + ys << normalizedValue(next->getC0Value(), prev->getValue(), next->getValue(), 1); + } + return easeObject(xs, ys); +} + +QJsonObject colorEaseOut(const QList& channels, + const int index) +{ + QList xs; + QList ys; + for (const auto& channel : channels) { + const auto prev = channel.keys.at(index); + const auto next = channel.keys.at(index + 1); + const qreal startFrame = prev->getAbsFrame(); + const qreal span = qMax(qreal(1), qreal(next->getAbsFrame() - prev->getAbsFrame())); + xs << (prev->getC1AbsFrame() - startFrame)/span; + ys << normalizedValue(prev->getC1Value(), prev->getValue(), next->getValue(), 0); + } + return easeObject(xs, ys); +} + +QJsonArray colorArray(const QList& channels, + const int index) +{ + QJsonArray result; + for (const auto& channel : channels) { + result.append(channel.keys.at(index)->getValue()); + } + return result; +} + +qreal gradientStopPosition(const int index, + const int stopCount) +{ + return stopCount <= 1 ? 0 : qreal(index)/(stopCount - 1); +} + +void appendStaticEaseIn(QList& xs, + QList& ys) +{ + xs << 1; + ys << 1; +} + +void appendStaticEaseOut(QList& xs, + QList& ys) +{ + xs << 0; + ys << 0; +} + +void appendChannelEaseIn(QList& xs, + QList& ys, + const ScalarKeys& channel, + const int index) +{ + const auto prev = channel.keys.at(index); + const auto next = channel.keys.at(index + 1); + const qreal startFrame = prev->getAbsFrame(); + const qreal span = qMax(qreal(1), qreal(next->getAbsFrame() - prev->getAbsFrame())); + xs << (next->getC0AbsFrame() - startFrame)/span; + ys << normalizedValue(next->getC0Value(), prev->getValue(), next->getValue(), 1); +} + +void appendChannelEaseOut(QList& xs, + QList& ys, + const ScalarKeys& channel, + const int index) +{ + const auto prev = channel.keys.at(index); + const auto next = channel.keys.at(index + 1); + const qreal startFrame = prev->getAbsFrame(); + const qreal span = qMax(qreal(1), qreal(next->getAbsFrame() - prev->getAbsFrame())); + xs << (prev->getC1AbsFrame() - startFrame)/span; + ys << normalizedValue(prev->getC1Value(), prev->getValue(), next->getValue(), 0); +} + +QJsonObject gradientEaseIn(const QList>& stops, + const int index) +{ + QList xs; + QList ys; + for (const auto& stop : stops) { + appendStaticEaseIn(xs, ys); + appendChannelEaseIn(xs, ys, stop.at(0), index); + appendChannelEaseIn(xs, ys, stop.at(1), index); + appendChannelEaseIn(xs, ys, stop.at(2), index); + } + for (const auto& stop : stops) { + appendStaticEaseIn(xs, ys); + appendChannelEaseIn(xs, ys, stop.at(3), index); + } + return easeObject(xs, ys); +} + +QJsonObject gradientEaseOut(const QList>& stops, + const int index) +{ + QList xs; + QList ys; + for (const auto& stop : stops) { + appendStaticEaseOut(xs, ys); + appendChannelEaseOut(xs, ys, stop.at(0), index); + appendChannelEaseOut(xs, ys, stop.at(1), index); + appendChannelEaseOut(xs, ys, stop.at(2), index); + } + for (const auto& stop : stops) { + appendStaticEaseOut(xs, ys); + appendChannelEaseOut(xs, ys, stop.at(3), index); + } + return easeObject(xs, ys); +} + +QJsonArray gradientColorArray(const QList>& stops, + const int index) +{ + QJsonArray result; + const int stopCount = stops.size(); + for (int i = 0; i < stopCount; i++) { + const auto& stop = stops.at(i); + result.append(gradientStopPosition(i, stopCount)); + result.append(stop.at(0).keys.at(index)->getValue()); + result.append(stop.at(1).keys.at(index)->getValue()); + result.append(stop.at(2).keys.at(index)->getValue()); + } + for (int i = 0; i < stopCount; i++) { + const auto& stop = stops.at(i); + result.append(gradientStopPosition(i, stopCount)); + result.append(stop.at(3).keys.at(index)->getValue()); + } + return result; +} + +} + +QJsonObject LottieRealKeyframes::scalar(QrealAnimator* const animator, + const FrameRange& frameRange, + const ScalarValue& value) +{ + const auto keys = scalarKeys(animator, frameRange); + if (!keys.valid) { return QJsonObject(); } + + QJsonArray keyframes; + for (int i = 0; i < keys.keys.size(); i++) { + const auto key = keys.keys.at(i); + const qreal rawValue = key->getValue(); + QJsonObject keyframe; + keyframe.insert(QStringLiteral("t"), key->getAbsFrame()); + keyframe.insert(QStringLiteral("s"), scalarArray(value ? value(rawValue) : rawValue)); + if (i + 1 < keys.keys.size()) { + const auto next = keys.keys.at(i + 1); + keyframe.insert(QStringLiteral("i"), scalarEaseIn(key, next)); + keyframe.insert(QStringLiteral("o"), scalarEaseOut(key, next)); + } + keyframes.append(keyframe); + } + + return QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), keyframes} + }; +} + +QJsonObject LottieRealKeyframes::color(ColorAnimator* const animator, + const FrameRange& frameRange, + const bool alpha) +{ + if (!animator || animator->getColorMode() != ColorMode::rgb) { + return QJsonObject(); + } + + QList channels{ + scalarKeys(animator->getVal1Animator(), frameRange), + scalarKeys(animator->getVal2Animator(), frameRange), + scalarKeys(animator->getVal3Animator(), frameRange) + }; + if (alpha) { channels << scalarKeys(animator->getAlphaAnimator(), frameRange); } + if (!compatibleColorKeys(channels)) { return QJsonObject(); } + + QJsonArray keyframes; + const int keyCount = channels.first().keys.size(); + for (int i = 0; i < keyCount; i++) { + QJsonObject keyframe; + keyframe.insert(QStringLiteral("t"), channels.first().keys.at(i)->getAbsFrame()); + keyframe.insert(QStringLiteral("s"), colorArray(channels, i)); + if (i + 1 < keyCount) { + keyframe.insert(QStringLiteral("i"), colorEaseIn(channels, i)); + keyframe.insert(QStringLiteral("o"), colorEaseOut(channels, i)); + } + keyframes.append(keyframe); + } + + return QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), keyframes} + }; +} + +QJsonObject LottieRealKeyframes::gradientColors(Gradient* const gradient, + const int stopCount, + const FrameRange& frameRange) +{ + if (!gradient || stopCount < 2 || + gradient->ca_getNumberOfChildren() != stopCount) { + return QJsonObject(); + } + + QList> stops; + QList allChannels; + for (int i = 0; i < stopCount; i++) { + const auto color = gradient->getChild(i); + if (!color || color->getColorMode() != ColorMode::rgb) { return QJsonObject(); } + + QList channels{ + scalarKeys(color->getVal1Animator(), frameRange), + scalarKeys(color->getVal2Animator(), frameRange), + scalarKeys(color->getVal3Animator(), frameRange), + scalarKeys(color->getAlphaAnimator(), frameRange) + }; + for (const auto& channel : channels) { allChannels << channel; } + stops << channels; + } + if (!compatibleColorKeys(allChannels)) { return QJsonObject(); } + + QJsonArray keyframes; + const int keyCount = allChannels.first().keys.size(); + for (int i = 0; i < keyCount; i++) { + QJsonObject keyframe; + keyframe.insert(QStringLiteral("t"), allChannels.first().keys.at(i)->getAbsFrame()); + keyframe.insert(QStringLiteral("s"), gradientColorArray(stops, i)); + if (i + 1 < keyCount) { + keyframe.insert(QStringLiteral("i"), gradientEaseIn(stops, i)); + keyframe.insert(QStringLiteral("o"), gradientEaseOut(stops, i)); + } + keyframes.append(keyframe); + } + + return QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), keyframes} + }; +} + +QJsonObject LottieRealKeyframes::point(QPointFAnimator* const animator, + const FrameRange& frameRange, + const PointValue& value) +{ + if (!animator || !value) { return QJsonObject(); } + + const auto xKeys = scalarKeys(animator->getXAnimator(), frameRange); + const auto yKeys = scalarKeys(animator->getYAnimator(), frameRange); + if (!compatiblePointKeys(xKeys, yKeys)) { return QJsonObject(); } + + QJsonArray keyframes; + for (int i = 0; i < xKeys.keys.size(); i++) { + const auto xKey = xKeys.keys.at(i); + const auto yKey = yKeys.keys.at(i); + const QPointF point(xKey->getValue(), yKey->getValue()); + const auto lottieValue = value(point, xKey->getAbsFrame()); + QJsonObject keyframe; + keyframe.insert(QStringLiteral("t"), xKey->getAbsFrame()); + keyframe.insert(QStringLiteral("s"), lottieValue); + if (i + 1 < xKeys.keys.size()) { + keyframe.insert(QStringLiteral("i"), + pointEaseIn(xKey, xKeys.keys.at(i + 1), + yKey, yKeys.keys.at(i + 1), + lottieValue.size())); + keyframe.insert(QStringLiteral("o"), + pointEaseOut(xKey, xKeys.keys.at(i + 1), + yKey, yKeys.keys.at(i + 1), + lottieValue.size())); + } + keyframes.append(keyframe); + } + + return QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), keyframes} + }; +} diff --git a/src/core/lottie/lottierealkeyframes.h b/src/core/lottie/lottierealkeyframes.h new file mode 100644 index 000000000..476352177 --- /dev/null +++ b/src/core/lottie/lottierealkeyframes.h @@ -0,0 +1,58 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#ifndef LOTTIEREALKEYFRAMES_H +#define LOTTIEREALKEYFRAMES_H + +#include "framerange.h" + +#include +#include +#include + +#include + +class QPointFAnimator; +class QrealAnimator; +class ColorAnimator; +class Gradient; + +namespace LottieRealKeyframes { + +using ScalarValue = std::function; +using PointValue = std::function; + +QJsonObject scalar(QrealAnimator* const animator, + const FrameRange& frameRange, + const ScalarValue& value = nullptr); +QJsonObject point(QPointFAnimator* const animator, + const FrameRange& frameRange, + const PointValue& value); +QJsonObject color(ColorAnimator* const animator, + const FrameRange& frameRange, + const bool alpha); +QJsonObject gradientColors(Gradient* const gradient, + const int stopCount, + const FrameRange& frameRange); + +} + +#endif // LOTTIEREALKEYFRAMES_H diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index bbf4461c1..ce70cc1b4 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -47,6 +47,7 @@ set( dialogs/commandpalette.cpp dialogs/dialog.cpp dialogs/durationrectsettingsdialog.cpp + dialogs/exportlottiedialog.cpp dialogs/exportsvgdialog.cpp dialogs/markereditordialog.cpp dialogs/qrealpointvaluedialog.cpp @@ -128,6 +129,7 @@ set( dialogs/commandpalette.h dialogs/dialog.h dialogs/durationrectsettingsdialog.h + dialogs/exportlottiedialog.h dialogs/exportsvgdialog.h dialogs/markereditordialog.h dialogs/qrealpointvaluedialog.h diff --git a/src/ui/dialogs/exportlottiedialog.cpp b/src/ui/dialogs/exportlottiedialog.cpp new file mode 100644 index 000000000..3eb331d72 --- /dev/null +++ b/src/ui/dialogs/exportlottiedialog.cpp @@ -0,0 +1,562 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#include "exportlottiedialog.h" + +#include "Private/Tasks/taskscheduler.h" +#include "Private/document.h" +#include "GUI/edialogs.h" +#include "appsupport.h" +#include "canvas.h" +#include "lottie/lottieexporter.h" +#include "widgets/scenechooser.h" +#include "widgets/twocolumnlayout.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +ExportLottieDialog::ExportLottieDialog(QWidget* const parent, + const QString& warnings) + : Friction::Ui::Dialog(parent) +{ + Q_UNUSED(warnings) + + setWindowTitle(tr("Export Lottie")); + + const auto settingsLayout = new QVBoxLayout(); + const auto twoColLayout = new TwoColumnLayout(); + + const auto document = Document::sInstance; + mScene = new SceneChooser(*document, false, this); + auto scene = *document->fActiveScene; + if (!scene) { + const auto& visScenes = document->fVisibleScenes; + if (!visScenes.empty()) { scene = visScenes.begin()->first; } + else { + const auto& scenes = document->fScenes; + if (!scenes.isEmpty()) { scene = scenes.first().get(); } + } + } + mScene->setCurrentScene(scene); + + const auto sceneButton = new QPushButton(mScene->title(), this); + sceneButton->setMenu(mScene); + + mFirstFrame = new QSpinBox(this); + mLastFrame = new QSpinBox(this); + mFormat = new QComboBox(this); + mFormat->addItem(tr("Lottie JSON"), QStringLiteral("json")); + mFormat->addItem(tr("dotLottie"), QStringLiteral("lottie")); + const QString format = AppSupport::getSettings("exportLottie", + "format", + "json").toString(); + mFormat->setCurrentIndex(qMax(0, mFormat->findData(format))); + + const int minFrame = scene ? scene->getMinFrame() : 0; + const int maxFrame = scene ? scene->getMaxFrame() : 0; + + mFirstFrame->setRange(-INT_MAX, maxFrame); + mFirstFrame->setValue(minFrame); + mLastFrame->setRange(minFrame, INT_MAX); + mLastFrame->setValue(maxFrame); + + mBackground = new QCheckBox(tr("Background"), this); + mBackground->setChecked(AppSupport::getSettings("exportLottie", + "background", + true).toBool()); + mEmbedImages = new QCheckBox(tr("Embed images"), this); + mEmbedImages->setChecked(AppSupport::getSettings("exportLottie", + "embedImages", + true).toBool()); + mEmbedImages->setEnabled(mFormat->currentData().toString() == QStringLiteral("json")); + mPreviewBackground = new QComboBox(this); + mPreviewBackground->addItem(tr("White"), QStringLiteral("white")); + mPreviewBackground->addItem(tr("Black"), QStringLiteral("black")); + mPreviewBackground->addItem(tr("Gray"), QStringLiteral("gray")); + mPreviewBackground->addItem(tr("Transparent"), QStringLiteral("transparent")); + const QString previewBackground = AppSupport::getSettings("exportLottie", + "previewBackground", + "white").toString(); + const int previewBackgroundIndex = mPreviewBackground->findData(previewBackground); + mPreviewBackground->setCurrentIndex(qMax(0, previewBackgroundIndex)); + mNativeText = new QCheckBox(tr("Native text (experimental)"), this); + mNativeText->setToolTip(tr("Disabled: exports text as vector outlines for best renderer compatibility. " + "\nEnabled: keeps simple text as native Lottie text, but it may not render " + "correctly in the canvas renderer.")); + mNativeText->setChecked(AppSupport::getSettings("exportLottie", + "nativeText", + false).toBool()); + mNotify = new QCheckBox(tr("Notify when done"), this); + mNotify->setChecked(AppSupport::getSettings("exportLottie", + "notify", + true).toBool()); + + connect(mBackground, &QCheckBox::stateChanged, + this, [this] { + AppSupport::setSettings("exportLottie", + "background", + mBackground->isChecked()); + }); + connect(mNotify, &QCheckBox::stateChanged, + this, [this] { + AppSupport::setSettings("exportLottie", + "notify", + mNotify->isChecked()); + }); + connect(mEmbedImages, &QCheckBox::stateChanged, + this, [this] { + AppSupport::setSettings("exportLottie", + "embedImages", + mEmbedImages->isChecked()); + }); + connect(mFormat, + QOverload::of(&QComboBox::currentIndexChanged), + this, [this] { + const QString format = mFormat->currentData().toString(); + AppSupport::setSettings("exportLottie", "format", format); + mEmbedImages->setEnabled(format == QStringLiteral("json")); + emit formatChanged(format); + }); + connect(mPreviewBackground, + QOverload::of(&QComboBox::currentIndexChanged), + this, [this] { + AppSupport::setSettings("exportLottie", + "previewBackground", + mPreviewBackground->currentData()); + }); + connect(mNativeText, &QCheckBox::stateChanged, + this, [this] { + AppSupport::setSettings("exportLottie", + "nativeText", + mNativeText->isChecked()); + }); + + twoColLayout->addPair(new QLabel(tr("Scene")), sceneButton); + twoColLayout->addPair(new QLabel(tr("First Frame")), mFirstFrame); + twoColLayout->addPair(new QLabel(tr("Last Frame")), mLastFrame); + + const auto sceneWidget = new QGroupBox(tr("Scene"), this); + const auto optsWidget = new QGroupBox(tr("Options"), this); + + sceneWidget->setObjectName("BlueBox"); + optsWidget->setObjectName("BlueBox"); + + const auto optsTwoCol = new TwoColumnLayout(); + optsTwoCol->addPair(new QLabel(tr("Format")), mFormat); + optsTwoCol->addPair(new QLabel(tr("Preview background")), mPreviewBackground); + optsTwoCol->addPair(mBackground, mEmbedImages); + optsTwoCol->addPair(mNativeText, new QWidget(this)); + optsTwoCol->addPair(mNotify, new QWidget(this)); + optsTwoCol->addSpacing(4); + + sceneWidget->setLayout(twoColLayout); + optsWidget->setLayout(optsTwoCol); + + const auto container = new QWidget(this); + const auto wrapper = new QHBoxLayout(container); + + container->setContentsMargins(0, 0, 0, 0); + wrapper->setContentsMargins(0, 0, 0, 0); + wrapper->addWidget(sceneWidget); + wrapper->addWidget(optsWidget); + + settingsLayout->addWidget(container); + + connect(mFirstFrame, qOverload(&QSpinBox::valueChanged), + mLastFrame, &QSpinBox::setMinimum); + connect(mLastFrame, qOverload(&QSpinBox::valueChanged), + mFirstFrame, &QSpinBox::setMaximum); + + const auto buttons = new QWidget(this); + buttons->setContentsMargins(0, 0, 0, 0); + const auto buttonsLayout = new QHBoxLayout(buttons); + buttonsLayout->setContentsMargins(0, 0, 0, 0); + + const auto buttonExport = new QPushButton(QIcon::fromTheme("dialog-ok"), + tr("Export"), + this); + const auto buttonCancel = new QPushButton(QIcon::fromTheme("dialog-cancel"), + tr("Close"), + this); + mPreviewButton = new QPushButton(QIcon::fromTheme("seq_preview"), + mFormat->currentData().toString() == QStringLiteral("lottie") ? + tr("Preview dotLottie") : + tr("Preview Lottie"), + this); + mPreviewButton->setObjectName("LottiePreviewButton"); + + connect(mPreviewButton, &QPushButton::released, + this, [this] { showPreview(false); }); + connect(mFormat, + QOverload::of(&QComboBox::currentIndexChanged), + this, [this] { + mPreviewButton->setText( + mFormat->currentData().toString() == QStringLiteral("lottie") ? + tr("Preview dotLottie") : + tr("Preview Lottie")); + }); + + connect(buttonExport, &QPushButton::clicked, this, [this]() { + const QString fileType = tr("Lottie Files %1", "ExportDialog_FileType"); + const QString extension = mFormat->currentData().toString(); + QString saveAs = eDialogs::saveFile(tr("Export Lottie"), + AppSupport::getSettings("files", + "recentExported", + QDir::homePath()).toString(), + fileType.arg(QStringLiteral("(*.%1)") + .arg(extension))); + if (saveAs.isEmpty()) { return; } + const QString suffix = QStringLiteral(".") + extension; + if (!saveAs.endsWith(suffix, Qt::CaseInsensitive)) { + saveAs.append(suffix); + } + QFileInfo saveInfo(saveAs); + AppSupport::setSettings("files", + "recentExported", + saveInfo.absoluteDir().absolutePath()); + const bool success = exportTo(saveAs); + if (success) { + if (mNotify->isChecked()) { finishedDialog(saveAs); } + accept(); + } + }); + + connect(buttonCancel, &QPushButton::clicked, this, &QDialog::reject); + + buttonsLayout->addWidget(mPreviewButton); + buttonsLayout->addStretch(); + buttonsLayout->addWidget(buttonExport); + buttonsLayout->addWidget(buttonCancel); + settingsLayout->addWidget(buttons); + + mPreviewButton->setEnabled(scene); + buttonExport->setEnabled(scene); + connect(mScene, &SceneChooser::currentChanged, + this, [this, sceneButton, buttonExport](Canvas* const scene) { + buttonExport->setEnabled(scene); + mPreviewButton->setEnabled(scene); + sceneButton->setText(mScene->title()); + }); + + setLayout(settingsLayout); +} + +void ExportLottieDialog::showPreview(const bool& closeWhenDone) +{ + const QString extension = mFormat->currentData().toString(); + if (mPreviewAnimationFile && + QFileInfo(mPreviewAnimationFile->fileName()).suffix() != extension) { + mPreviewAnimationFile.reset(); + } + if (!mPreviewAnimationFile) { + const QString templ = QString::fromUtf8("%1/%2_lottie_preview_XXXXXX.%3") + .arg(AppSupport::getAppTempPath(), + AppSupport::getAppName(), + extension); + mPreviewAnimationFile = QSharedPointer::create(templ); + mPreviewAnimationFile->setAutoRemove(false); + mPreviewAnimationFile->open(); + mPreviewAnimationFile->close(); + } + if (!mPreviewHtmlFile) { + const QString templ = QString::fromUtf8("%1/%2_lottie_preview_XXXXXX.html").arg(AppSupport::getAppTempPath(), + AppSupport::getAppName()); + mPreviewHtmlFile = QSharedPointer::create(templ); + mPreviewHtmlFile->setAutoRemove(false); + mPreviewHtmlFile->open(); + mPreviewHtmlFile->close(); + } + + const QString animationFile = mPreviewAnimationFile->fileName(); + const QString htmlFile = mPreviewHtmlFile->fileName(); + if (!exportTo(animationFile) || !writePreviewHtml(animationFile, htmlFile)) { + if (closeWhenDone) { close(); } + return; + } + + QDesktopServices::openUrl(QUrl::fromLocalFile(htmlFile)); + if (closeWhenDone) { close(); } +} + +bool ExportLottieDialog::exportTo(const QString& file) +{ + try { + const auto scene = mScene->getCurrentScene(); + if (!scene) { RuntimeThrow(tr("No scene selected")); } + + const FrameRange frameRange{mFirstFrame->value(), mLastFrame->value()}; + const auto task = new LottieExporter(file, + scene, + frameRange, + scene->getFps(), + mBackground->isChecked(), + mEmbedImages->isChecked(), + false, + mNativeText->isChecked()); + const auto taskSPtr = qsptr(task, &QObject::deleteLater); + task->nextStep(); + TaskScheduler::instance()->addComplexTask(taskSPtr); + return true; + } catch(const std::exception& e) { + gPrintExceptionCritical(e); + return false; + } +} + +bool ExportLottieDialog::writePreviewHtml(const QString& animationFile, + const QString& htmlFile) +{ + QFile animation(animationFile); + if (!animation.open(QIODevice::ReadOnly)) { return false; } + const QByteArray animationData = animation.readAll(); + animation.close(); + const QByteArray encodedAnimation = animationData.toBase64(); + const bool dotLottie = animationFile.endsWith(QStringLiteral(".lottie"), + Qt::CaseInsensitive); + const QString assetsBase = QUrl::fromLocalFile( + QFileInfo(animationFile).absolutePath() + QDir::separator()).toString(); + const QString previewBackground = mPreviewBackground->currentData().toString(); + QString projectName = QFileInfo(Document::sInstance->fEvFile).baseName(); + if (projectName.isEmpty()) { projectName = tr("Untitled"); } + const QString previewName = dotLottie ? + tr("Preview dotLottie - %1").arg(projectName) : + tr("Preview Lottie - %1").arg(projectName); + const qreal fileSizeKb = animationData.size() / 1024.0; + const QString fileSize = fileSizeKb < 1024.0 ? + tr("%1 KB").arg(QString::number(fileSizeKb, 'f', 1)) : + tr("%1 MB").arg(QString::number(fileSizeKb / 1024.0, 'f', 2)); + + QFile html(htmlFile); + if (!html.open(QIODevice::WriteOnly | QIODevice::Truncate)) { return false; } + + QTextStream stream(&html); + stream << "\n"; + stream << "\n"; + stream << "\n"; + stream << "\n"; + stream << "" << previewName.toHtmlEscaped() << "\n"; + stream << "\n"; + stream << "\n"; + stream << "\n"; + stream << "
\n"; + stream << "
\n"; + stream << "
\n"; + stream << "\n"; + stream << "\n"; + stream << "\n"; + stream << "
\n"; + stream << "
" + << previewName.toHtmlEscaped() + << "" + << fileSize.toHtmlEscaped() + << "
\n"; + stream << "
\n"; + stream << "\n"; + stream << "\n"; + stream << "
\n"; + stream << "
\n"; + stream << "
\n"; + stream << "\n"; + stream << "0 / 0\n"; + stream << "
\n"; + stream << "
\n"; + stream << "\n"; + if (dotLottie) { + stream << "\n"; + } else { + stream << "\n"; + stream << "\n"; + } + stream << "\n"; + stream << "\n"; + stream.flush(); + html.close(); + return true; +} + +void ExportLottieDialog::finishedDialog(const QString& fileName) +{ + const QString askOpenFile = tr("Open File"); + const QString askOpenFolder = tr("Open Folder"); + const QString askClose = tr("Close"); + const int ask = QMessageBox::information(this, + tr("Lottie export finished"), + tr("Project exported to %1.").arg(fileName), + askOpenFile, + askOpenFolder, + askClose, + 2, + 2); + QUrl url; + switch (ask) { + case 0: + url = QUrl::fromLocalFile(fileName); + break; + case 1: + url = QUrl::fromLocalFile(QFileInfo(fileName).absolutePath()); + break; + default:; + } + if (!url.isEmpty()) { QDesktopServices::openUrl(url); } +} diff --git a/src/ui/dialogs/exportlottiedialog.h b/src/ui/dialogs/exportlottiedialog.h new file mode 100644 index 000000000..6d89b473b --- /dev/null +++ b/src/ui/dialogs/exportlottiedialog.h @@ -0,0 +1,68 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +*/ + +#ifndef EXPORTLOTTIEDIALOG_H +#define EXPORTLOTTIEDIALOG_H + +#include "dialogs/dialog.h" + +class Canvas; +class QCheckBox; +class QComboBox; +class QPushButton; +class QSpinBox; +class QTemporaryFile; +class SceneChooser; + +class UI_EXPORT ExportLottieDialog : public Friction::Ui::Dialog +{ + Q_OBJECT + +public: + ExportLottieDialog(QWidget* const parent = nullptr, + const QString& warnings = QString()); + void showPreview(const bool& closeWhenDone = false); + +signals: + void formatChanged(const QString& format); + +private: + bool exportTo(const QString& file); + bool writePreviewHtml(const QString& animationFile, + const QString& htmlFile); + void finishedDialog(const QString& fileName); + + QSharedPointer mPreviewAnimationFile; + QSharedPointer mPreviewHtmlFile; + QPushButton* mPreviewButton; + + SceneChooser* mScene; + QSpinBox* mFirstFrame; + QSpinBox* mLastFrame; + QComboBox* mFormat; + QCheckBox* mBackground; + QCheckBox* mEmbedImages; + QComboBox* mPreviewBackground; + QCheckBox* mNativeText; + QCheckBox* mNotify; +}; + +#endif // EXPORTLOTTIEDIALOG_H