From efc35e301d72a435c36b303f3294b2703f5d0939 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Tue, 2 Jun 2026 18:31:20 +0200 Subject: [PATCH 01/47] Add initial Lottie export support --- src/app/GUI/mainwindow.cpp | 17 ++ src/app/GUI/mainwindow.h | 3 + src/app/GUI/menu.cpp | 13 + src/core/CMakeLists.txt | 2 + src/core/lottie/lottieexporter.cpp | 87 +++++++ src/core/lottie/lottieexporter.h | 51 ++++ src/core/lottie/lottielayerbuilder.cpp | 327 +++++++++++++++++++++++++ src/core/lottie/lottielayerbuilder.h | 74 ++++++ src/ui/CMakeLists.txt | 2 + src/ui/dialogs/exportlottiedialog.cpp | 245 ++++++++++++++++++ src/ui/dialogs/exportlottiedialog.h | 49 ++++ 11 files changed, 870 insertions(+) create mode 100644 src/core/lottie/lottieexporter.cpp create mode 100644 src/core/lottie/lottieexporter.h create mode 100644 src/core/lottie/lottielayerbuilder.cpp create mode 100644 src/core/lottie/lottielayerbuilder.h create mode 100644 src/ui/dialogs/exportlottiedialog.cpp create mode 100644 src/ui/dialogs/exportlottiedialog.h diff --git a/src/app/GUI/mainwindow.cpp b/src/app/GUI/mainwindow.cpp index 47e6780a1..99a66e60f 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,7 @@ MainWindow::MainWindow(Document& document, , mSaveBackAct(nullptr) , mPreviewSVGAct(nullptr) , mExportSVGAct(nullptr) + , mExportLottieAct(nullptr) , mRenderVideoAct(nullptr) , mCloseProjectAct(nullptr) , mLinkedAct(nullptr) @@ -424,6 +426,7 @@ void MainWindow::updateSettingsForCurrentCanvas(Canvas* const scene) if (mPreviewSVGAct) { mPreviewSVGAct->setEnabled(scene); } if (mExportSVGAct) { mExportSVGAct->setEnabled(scene); } + if (mExportLottieAct) { mExportLottieAct->setEnabled(scene); } if (mSaveAct) { mSaveAct->setEnabled(scene); } if (mSaveAsAct) { mSaveAsAct->setEnabled(scene); } if (mSaveBackAct) { mSaveBackAct->setEnabled(scene); } @@ -1210,6 +1213,20 @@ 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 auto dialog = new ExportLottieDialog(this, checkBeforeExportLottie()); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->show(); +} + 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..6e83adb33 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(); void updateLastOpenDir(const QString &path); void updateLastSaveDir(const QString &path); const QString getLastOpenDir(); @@ -243,6 +245,7 @@ class MainWindow : public QMainWindow QAction *mSaveBackAct; QAction *mPreviewSVGAct; QAction *mExportSVGAct; + QAction *mExportLottieAct; QAction *mRenderVideoAct; QAction *mCloseProjectAct; QAction *mLinkedAct; diff --git a/src/app/GUI/menu.cpp b/src/app/GUI/menu.cpp index 59f5858bb..91cb06864 100644 --- a/src/app/GUI/menu.cpp +++ b/src/app/GUI/menu.cpp @@ -144,6 +144,18 @@ void MainWindow::setupMenuBar() mExportSVGAct->setObjectName("ExportSVGAct"); cmdAddAction(mExportSVGAct); + 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 +856,7 @@ void MainWindow::setupMenuBar() mToolbar->addAction(mPreviewSVGAct); mToolbar->addAction(mExportSVGAct); + mToolbar->addAction(mExportLottieAct); mToolbar->updateActions(); setMenuBar(mMenuBar); diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index ff9b50d30..da429c4ec 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -314,6 +314,8 @@ set( Animators/intanimator.cpp Animators/key.cpp Animators/boolanimator.cpp + lottie/lottieexporter.cpp + lottie/lottielayerbuilder.cpp svgexporter.cpp svgexporthelpers.cpp svgimporter.cpp diff --git a/src/core/lottie/lottieexporter.cpp b/src/core/lottie/lottieexporter.cpp new file mode 100644 index 000000000..6ed236c59 --- /dev/null +++ b/src/core/lottie/lottieexporter.cpp @@ -0,0 +1,87 @@ +/* +# +# 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 "appsupport.h" +#include "canvas.h" +#include "exceptions.h" +#include "lottie/lottielayerbuilder.h" + +#include +#include +#include + +LottieExporter::LottieExporter(const QString& path, + Canvas* const scene, + const FrameRange& frameRange, + const qreal fps, + const bool background) + : ComplexTask(INT_MAX, tr("Lottie Export")) + , mPath(path) + , mScene(scene) + , mFrameRange(frameRange) + , mFps(fps) + , mBackground(background) +{ + +} + +void LottieExporter::nextStep() +{ + finish(); +} + +void LottieExporter::finish() +{ + if (!mScene) { RuntimeThrow("No scene selected"); } + + 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); + root.insert(QStringLiteral("assets"), QJsonArray()); + root.insert(QStringLiteral("markers"), QJsonArray()); + root.insert(QStringLiteral("meta"), QJsonObject{ + {QStringLiteral("g"), + QStringLiteral("%1 - %2").arg(AppSupport::getAppDisplayName(), + AppSupport::getAppUrl())} + }); + + const LottieLayerBuilder builder(mScene, mFrameRange, mFps); + root.insert(QStringLiteral("layers"), builder.buildLayers(mBackground)); + + QFile file(mPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + RuntimeThrow("Could not open:\n\"" + mPath + "\""); + } + + const QJsonDocument doc(root); + file.write(doc.toJson(QJsonDocument::Indented)); + 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..194c5d0d3 --- /dev/null +++ b/src/core/lottie/lottieexporter.h @@ -0,0 +1,51 @@ +/* +# +# 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); + + void nextStep() override; + +private: + void finish(); + + const QString mPath; + Canvas* const mScene; + const FrameRange mFrameRange; + const qreal mFps; + const bool mBackground; +}; + +#endif // LOTTIEEXPORTER_H diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp new file mode 100644 index 000000000..1b5600382 --- /dev/null +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -0,0 +1,327 @@ +/* +# +# 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/paintsettingsanimator.h" +#include "Animators/qpointfanimator.h" +#include "Animators/qrealanimator.h" +#include "Animators/transformanimator.h" +#include "Boxes/boundingbox.h" +#include "Boxes/containerbox.h" +#include "Boxes/rectangle.h" +#include "canvas.h" +#include "paintsettings.h" + +#include +#include + +LottieLayerBuilder::LottieLayerBuilder(Canvas* const scene, + const FrameRange& frameRange, + const qreal fps) + : mScene(scene) + , mFrameRange(frameRange) + , mFps(fps) +{ + +} + +QJsonArray LottieLayerBuilder::buildLayers(const bool background) const +{ + QJsonArray layers; + if (!mScene) { return layers; } + + int nextId = background ? 2 : 1; + appendContainerLayers(mScene, layers, nextId); + if (background) { layers.append(buildBackgroundLayer()); } + return layers; +} + +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 +{ + const auto& boxes = container->getContainedBoxes(); + for (auto it = boxes.crbegin(); it != boxes.crend(); ++it) { + const auto box = *it; + if (!box) { continue; } + + const auto childContainer = dynamic_cast(box); + if (childContainer) { + appendContainerLayers(childContainer, layers, nextId); + continue; + } + + const auto rectangle = dynamic_cast(box); + if (rectangle) { + layers.append(buildRectangleLayer(rectangle, nextId)); + nextId++; + continue; + } + + layers.append(buildUnsupportedLayer(box, nextId)); + nextId++; + } +} + +QJsonObject LottieLayerBuilder::buildRectangleLayer(RectangleBox* const box, + const int id) const +{ + auto layer = baseLayer(box->prp_getName(), id, 4); + layer.insert(QStringLiteral("ks"), transformObject(box)); + + const QPointF topLeft = box->getTopLeftAnimator()->getEffectiveValue(mFrameRange.fMin); + const QPointF bottomRight = box->getBottomRightAnimator()->getEffectiveValue(mFrameRange.fMin); + const QPointF radius = box->getRadiusAnimator()->getEffectiveValue(mFrameRange.fMin); + + 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()); + + QJsonObject rect; + rect.insert(QStringLiteral("ty"), QStringLiteral("rc")); + rect.insert(QStringLiteral("d"), 1); + rect.insert(QStringLiteral("nm"), box->prp_getName()); + rect.insert(QStringLiteral("p"), staticProperty(QJsonArray{x + width*0.5, + y + height*0.5})); + rect.insert(QStringLiteral("s"), staticProperty(QJsonArray{width, height})); + rect.insert(QStringLiteral("r"), staticProperty(qMin(qAbs(radius.x()), + qAbs(radius.y())))); + + QColor fillColor(0, 0, 0, 0); + qreal fillOpacity = 0; + const auto fill = box->getFillSettings(); + if (fill && fill->getPaintType() == PaintType::FLATPAINT) { + fillColor = fill->getColor(mFrameRange.fMin); + fillOpacity = fillColor.alphaF()*100; + } + + QJsonObject fillObject; + fillObject.insert(QStringLiteral("ty"), QStringLiteral("fl")); + fillObject.insert(QStringLiteral("c"), staticProperty(colorArray(fillColor))); + fillObject.insert(QStringLiteral("o"), staticProperty(fillOpacity)); + fillObject.insert(QStringLiteral("r"), 1); + fillObject.insert(QStringLiteral("bm"), 0); + fillObject.insert(QStringLiteral("nm"), QStringLiteral("Fill")); + + 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)); + + layer.insert(QStringLiteral("shapes"), QJsonArray{rect, fillObject, shapeTransform}); + 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 +{ + 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"), 0); + Q_UNUSED(mFps) + return layer; +} + +QJsonObject LottieLayerBuilder::transformObject(const BoundingBox* const box) const +{ + if (box) { + const auto transform = box->getBoxTransformAnimator(); + if (transform) { + 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(), pos.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; + animated.insert(QStringLiteral("o"), animatedScalarProperty(opacities)); + animated.insert(QStringLiteral("r"), animatedScalarProperty(rotations)); + animated.insert(QStringLiteral("p"), animatedPointProperty(positions)); + animated.insert(QStringLiteral("a"), animatedPointProperty(anchors)); + animated.insert(QStringLiteral("s"), animatedPointProperty(scales)); + 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; +} + +QJsonObject LottieLayerBuilder::staticProperty(const QJsonValue& value) const +{ + return QJsonObject{ + {QStringLiteral("a"), 0}, + {QStringLiteral("k"), value} + }; +} + +QJsonObject LottieLayerBuilder::animatedScalarProperty(const QList& values) const +{ + if (values.isEmpty()) { return staticProperty(0); } + if (sameScalarValues(values)) { return staticProperty(values.first()); } + return QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), scalarKeyframes(values)} + }; +} + +QJsonObject LottieLayerBuilder::animatedPointProperty(const QList& values) const +{ + if (values.isEmpty()) { return staticProperty(QJsonArray()); } + if (samePointValues(values)) { return staticProperty(values.first()); } + return QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), pointKeyframes(values)} + }; +} + +QJsonArray LottieLayerBuilder::scalarKeyframes(const QList& values) const +{ + QJsonArray keyframes; + for (int i = 0; i < values.size(); i++) { + QJsonObject key; + key.insert(QStringLiteral("t"), mFrameRange.fMin + i); + key.insert(QStringLiteral("s"), QJsonArray{values.at(i)}); + if (i + 1 < values.size()) { + key.insert(QStringLiteral("e"), QJsonArray{values.at(i + 1)}); + key.insert(QStringLiteral("i"), keyframeEase()); + key.insert(QStringLiteral("o"), keyframeEase()); + } + keyframes.append(key); + } + return keyframes; +} + +QJsonArray LottieLayerBuilder::pointKeyframes(const QList& values) const +{ + QJsonArray keyframes; + for (int i = 0; i < values.size(); i++) { + QJsonObject key; + key.insert(QStringLiteral("t"), mFrameRange.fMin + i); + key.insert(QStringLiteral("s"), values.at(i)); + if (i + 1 < values.size()) { + key.insert(QStringLiteral("e"), values.at(i + 1)); + key.insert(QStringLiteral("i"), keyframeEase()); + key.insert(QStringLiteral("o"), keyframeEase()); + } + keyframes.append(key); + } + return keyframes; +} + +bool LottieLayerBuilder::sameScalarValues(const QList& values) const +{ + if (values.isEmpty()) { return true; } + const qreal first = values.first(); + for (const qreal value : values) { + if (!qFuzzyCompare(first + 1, value + 1)) { return false; } + } + return true; +} + +bool LottieLayerBuilder::samePointValues(const QList& values) const +{ + 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 (!qFuzzyCompare(first.at(i).toDouble() + 1, + value.at(i).toDouble() + 1)) { + return false; + } + } + } + return true; +} + +QJsonObject LottieLayerBuilder::keyframeEase() const +{ + return QJsonObject{ + {QStringLiteral("x"), QJsonArray{0.667}}, + {QStringLiteral("y"), QJsonArray{1}} + }; +} + +QJsonArray LottieLayerBuilder::colorArray(const QColor& color) const +{ + return QJsonArray{ + color.redF(), + color.greenF(), + color.blueF(), + color.alphaF() + }; +} diff --git a/src/core/lottie/lottielayerbuilder.h b/src/core/lottie/lottielayerbuilder.h new file mode 100644 index 000000000..f7a7773d6 --- /dev/null +++ b/src/core/lottie/lottielayerbuilder.h @@ -0,0 +1,74 @@ +/* +# +# 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 + +class BoundingBox; +class Canvas; +class QColor; +class ContainerBox; +class RectangleBox; + +class CORE_EXPORT LottieLayerBuilder +{ +public: + LottieLayerBuilder(Canvas* const scene, + const FrameRange& frameRange, + const qreal fps); + + QJsonArray buildLayers(const bool background) const; + +private: + QJsonObject buildBackgroundLayer() const; + void appendContainerLayers(const ContainerBox* const container, + QJsonArray& layers, + int& nextId) const; + QJsonObject buildRectangleLayer(RectangleBox* 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; + QJsonObject transformObject(const BoundingBox* const box = nullptr) const; + QJsonObject staticProperty(const QJsonValue& value) const; + QJsonObject animatedScalarProperty(const QList& values) const; + QJsonObject animatedPointProperty(const QList& values) const; + QJsonArray scalarKeyframes(const QList& values) const; + QJsonArray pointKeyframes(const QList& values) const; + bool sameScalarValues(const QList& values) const; + bool samePointValues(const QList& values) const; + QJsonObject keyframeEase() const; + QJsonArray colorArray(const QColor& color) const; + + Canvas* const mScene; + const FrameRange mFrameRange; + const qreal mFps; +}; + +#endif // LOTTIELAYERBUILDER_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..50d7c6cba --- /dev/null +++ b/src/ui/dialogs/exportlottiedialog.cpp @@ -0,0 +1,245 @@ +/* +# +# 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 + +ExportLottieDialog::ExportLottieDialog(QWidget* const parent, + const QString& warnings) + : Friction::Ui::Dialog(parent) +{ + 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); + + 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()); + 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()); + }); + + 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(mBackground, mNotify); + 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); + + connect(buttonExport, &QPushButton::clicked, this, [this]() { + const QString fileType = tr("Lottie Files %1", "ExportDialog_FileType"); + QString saveAs = eDialogs::saveFile(tr("Export Lottie"), + AppSupport::getSettings("files", + "recentExported", + QDir::homePath()).toString(), + fileType.arg("(*.json)")); + if (saveAs.isEmpty()) { return; } + if (!saveAs.endsWith(".json")) { saveAs.append(".json"); } + 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); + + if (!warnings.isEmpty()) { + const auto warnWidget = new QPlainTextEdit(this); + warnWidget->setSizePolicy(QSizePolicy::Expanding, + QSizePolicy::Expanding); + warnWidget->setMinimumHeight(100); + warnWidget->setReadOnly(true); + warnWidget->setPlainText(warnings); + settingsLayout->addWidget(warnWidget); + } else { + settingsLayout->addStretch(); + } + + buttonsLayout->addStretch(); + buttonsLayout->addWidget(buttonExport); + buttonsLayout->addWidget(buttonCancel); + settingsLayout->addWidget(buttons); + + buttonExport->setEnabled(scene); + connect(mScene, &SceneChooser::currentChanged, + this, [this, sceneButton, buttonExport](Canvas* const scene) { + buttonExport->setEnabled(scene); + sceneButton->setText(mScene->title()); + }); + + setLayout(settingsLayout); +} + +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()); + const auto taskSPtr = qsptr(task, &QObject::deleteLater); + task->nextStep(); + TaskScheduler::instance()->addComplexTask(taskSPtr); + return true; + } catch(const std::exception& e) { + gPrintExceptionCritical(e); + return false; + } +} + +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..070ed16a0 --- /dev/null +++ b/src/ui/dialogs/exportlottiedialog.h @@ -0,0 +1,49 @@ +/* +# +# 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 QSpinBox; +class SceneChooser; + +class UI_EXPORT ExportLottieDialog : public Friction::Ui::Dialog +{ +public: + ExportLottieDialog(QWidget* const parent = nullptr, + const QString& warnings = QString()); + +private: + bool exportTo(const QString& file); + void finishedDialog(const QString& fileName); + + SceneChooser* mScene; + QSpinBox* mFirstFrame; + QSpinBox* mLastFrame; + QCheckBox* mBackground; + QCheckBox* mNotify; +}; + +#endif // EXPORTLOTTIEDIALOG_H From 30a637a976ae704ed1bd8e744a7287e669f8c443 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Tue, 2 Jun 2026 18:43:01 +0200 Subject: [PATCH 02/47] Add Lottie preview action --- src/app/GUI/mainwindow.cpp | 13 +++- src/app/GUI/mainwindow.h | 3 +- src/app/GUI/menu.cpp | 13 ++++ src/ui/dialogs/exportlottiedialog.cpp | 86 +++++++++++++++++++++++++++ src/ui/dialogs/exportlottiedialog.h | 9 +++ 5 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/app/GUI/mainwindow.cpp b/src/app/GUI/mainwindow.cpp index 99a66e60f..441c0a6e5 100644 --- a/src/app/GUI/mainwindow.cpp +++ b/src/app/GUI/mainwindow.cpp @@ -108,6 +108,7 @@ MainWindow::MainWindow(Document& document, , mSaveBackAct(nullptr) , mPreviewSVGAct(nullptr) , mExportSVGAct(nullptr) + , mPreviewLottieAct(nullptr) , mExportLottieAct(nullptr) , mRenderVideoAct(nullptr) , mCloseProjectAct(nullptr) @@ -426,6 +427,7 @@ 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); } @@ -1220,11 +1222,16 @@ const QString MainWindow::checkBeforeExportLottie() "keeps scene layers isolated for future vector mapping."); } -void MainWindow::exportLottie() +void MainWindow::exportLottie(const bool &preview) { - const auto dialog = new ExportLottieDialog(this, checkBeforeExportLottie()); + const auto dialog = new ExportLottieDialog(this, + preview ? QString() : checkBeforeExportLottie()); dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->show(); + if (!preview) { + dialog->show(); + } else { + dialog->showPreview(true /* close when done */); + } } void MainWindow::updateLastOpenDir(const QString &path) diff --git a/src/app/GUI/mainwindow.h b/src/app/GUI/mainwindow.h index 6e83adb33..d475da86d 100644 --- a/src/app/GUI/mainwindow.h +++ b/src/app/GUI/mainwindow.h @@ -168,7 +168,7 @@ class MainWindow : public QMainWindow const QString checkBeforeExportSVG(); void exportSVG(const bool &preview = false); const QString checkBeforeExportLottie(); - void exportLottie(); + void exportLottie(const bool &preview = false); void updateLastOpenDir(const QString &path); void updateLastSaveDir(const QString &path); const QString getLastOpenDir(); @@ -245,6 +245,7 @@ class MainWindow : public QMainWindow QAction *mSaveBackAct; QAction *mPreviewSVGAct; QAction *mExportSVGAct; + QAction *mPreviewLottieAct; QAction *mExportLottieAct; QAction *mRenderVideoAct; QAction *mCloseProjectAct; diff --git a/src/app/GUI/menu.cpp b/src/app/GUI/menu.cpp index 91cb06864..55f6cfcfc 100644 --- a/src/app/GUI/menu.cpp +++ b/src/app/GUI/menu.cpp @@ -144,6 +144,18 @@ 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); + mExportLottieAct = mFileMenu->addAction(QIcon::fromTheme("output"), tr("Export Lottie", "MenuBar_File"), this, &MainWindow::exportLottie, @@ -856,6 +868,7 @@ void MainWindow::setupMenuBar() mToolbar->addAction(mPreviewSVGAct); mToolbar->addAction(mExportSVGAct); + mToolbar->addAction(mPreviewLottieAct); mToolbar->addAction(mExportLottieAct); mToolbar->updateActions(); diff --git a/src/ui/dialogs/exportlottiedialog.cpp b/src/ui/dialogs/exportlottiedialog.cpp index 50d7c6cba..2b547b723 100644 --- a/src/ui/dialogs/exportlottiedialog.cpp +++ b/src/ui/dialogs/exportlottiedialog.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,8 @@ #include #include #include +#include +#include #include #include @@ -146,6 +149,13 @@ ExportLottieDialog::ExportLottieDialog(QWidget* const parent, const auto buttonCancel = new QPushButton(QIcon::fromTheme("dialog-cancel"), tr("Close"), this); + mPreviewButton = new QPushButton(QIcon::fromTheme("seq_preview"), + tr("Preview"), + this); + mPreviewButton->setObjectName("LottiePreviewButton"); + + connect(mPreviewButton, &QPushButton::released, + this, [this] { showPreview(false); }); connect(buttonExport, &QPushButton::clicked, this, [this]() { const QString fileType = tr("Lottie Files %1", "ExportDialog_FileType"); @@ -181,21 +191,54 @@ ExportLottieDialog::ExportLottieDialog(QWidget* const parent, settingsLayout->addStretch(); } + 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) +{ + if (!mPreviewJsonFile) { + const QString templ = QString::fromUtf8("%1/%2_lottie_preview_XXXXXX.json").arg(AppSupport::getAppTempPath(), + AppSupport::getAppName()); + mPreviewJsonFile = QSharedPointer::create(templ); + mPreviewJsonFile->setAutoRemove(false); + mPreviewJsonFile->open(); + mPreviewJsonFile->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 jsonFile = mPreviewJsonFile->fileName(); + const QString htmlFile = mPreviewHtmlFile->fileName(); + if (!exportTo(jsonFile) || !writePreviewHtml(jsonFile, htmlFile)) { + if (closeWhenDone) { close(); } + return; + } + + QDesktopServices::openUrl(QUrl::fromLocalFile(htmlFile)); + if (closeWhenDone) { close(); } +} + bool ExportLottieDialog::exportTo(const QString& file) { try { @@ -218,6 +261,49 @@ bool ExportLottieDialog::exportTo(const QString& file) } } +bool ExportLottieDialog::writePreviewHtml(const QString& jsonFile, + const QString& htmlFile) +{ + QFile json(jsonFile); + if (!json.open(QIODevice::ReadOnly)) { return false; } + const QByteArray encodedJson = json.readAll().toBase64(); + json.close(); + + QFile html(htmlFile); + if (!html.open(QIODevice::WriteOnly | QIODevice::Truncate)) { return false; } + + QTextStream stream(&html); + stream << "\n"; + stream << "\n"; + stream << "\n"; + stream << "\n"; + stream << "" << tr("Lottie Preview") << "\n"; + stream << "\n"; + stream << "\n"; + stream << "\n"; + stream << "
\n"; + stream << "
\n"; + 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"); diff --git a/src/ui/dialogs/exportlottiedialog.h b/src/ui/dialogs/exportlottiedialog.h index 070ed16a0..ee6bf75ff 100644 --- a/src/ui/dialogs/exportlottiedialog.h +++ b/src/ui/dialogs/exportlottiedialog.h @@ -26,7 +26,9 @@ class Canvas; class QCheckBox; +class QPushButton; class QSpinBox; +class QTemporaryFile; class SceneChooser; class UI_EXPORT ExportLottieDialog : public Friction::Ui::Dialog @@ -34,11 +36,18 @@ class UI_EXPORT ExportLottieDialog : public Friction::Ui::Dialog public: ExportLottieDialog(QWidget* const parent = nullptr, const QString& warnings = QString()); + void showPreview(const bool& closeWhenDone = false); private: bool exportTo(const QString& file); + bool writePreviewHtml(const QString& jsonFile, + const QString& htmlFile); void finishedDialog(const QString& fileName); + QSharedPointer mPreviewJsonFile; + QSharedPointer mPreviewHtmlFile; + QPushButton* mPreviewButton; + SceneChooser* mScene; QSpinBox* mFirstFrame; QSpinBox* mLastFrame; From a260958016281b4df933ca1d54b0f9a11016d3de Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Tue, 2 Jun 2026 19:46:00 +0200 Subject: [PATCH 03/47] Add Lottie path shape support --- src/core/lottie/lottielayerbuilder.cpp | 293 ++++++++++++++++++++++--- src/core/lottie/lottielayerbuilder.h | 8 + 2 files changed, 276 insertions(+), 25 deletions(-) diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index 1b5600382..29da7b821 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -28,13 +28,164 @@ #include "Animators/transformanimator.h" #include "Boxes/boundingbox.h" #include "Boxes/containerbox.h" +#include "Boxes/pathbox.h" #include "Boxes/rectangle.h" #include "canvas.h" #include "paintsettings.h" +#include "simplemath.h" +#include "skia/skiaincludes.h" #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}; +} + +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 contourObject(const LottieContour& contour, + const QString& name, + const int index) +{ + QJsonObject path; + path.insert(QStringLiteral("i"), contour.inTangents); + path.insert(QStringLiteral("o"), contour.outTangents); + path.insert(QStringLiteral("v"), contour.vertices); + path.insert(QStringLiteral("c"), contour.closed); + + QJsonObject shape; + shape.insert(QStringLiteral("ty"), QStringLiteral("sh")); + shape.insert(QStringLiteral("ks"), lottieStaticProperty(path)); + shape.insert(QStringLiteral("nm"), QStringLiteral("%1 Path %2").arg(name).arg(index)); + shape.insert(QStringLiteral("ind"), index); + return shape; +} + +QJsonArray pathShapeObjects(const SkPath& path, const QString& name) +{ + QJsonArray shapes; + 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); } + for (int i = 0; i < contours.size(); i++) { + shapes.append(contourObject(contours.at(i), name, i + 1)); + } + return shapes; + } + } +} + +} + LottieLayerBuilder::LottieLayerBuilder(Canvas* const scene, const FrameRange& frameRange, const qreal fps) @@ -92,6 +243,13 @@ void LottieLayerBuilder::appendContainerLayers(const ContainerBox* const contain continue; } + const auto path = dynamic_cast(box); + if (path) { + layers.append(buildPathLayer(path, nextId)); + nextId++; + continue; + } + layers.append(buildUnsupportedLayer(box, nextId)); nextId++; } @@ -122,33 +280,26 @@ QJsonObject LottieLayerBuilder::buildRectangleLayer(RectangleBox* const box, rect.insert(QStringLiteral("r"), staticProperty(qMin(qAbs(radius.x()), qAbs(radius.y())))); - QColor fillColor(0, 0, 0, 0); - qreal fillOpacity = 0; - const auto fill = box->getFillSettings(); - if (fill && fill->getPaintType() == PaintType::FLATPAINT) { - fillColor = fill->getColor(mFrameRange.fMin); - fillOpacity = fillColor.alphaF()*100; - } + QJsonArray shapes{rect}; + appendPaintObjects(box, shapes); + shapes.append(shapeTransformObject()); - QJsonObject fillObject; - fillObject.insert(QStringLiteral("ty"), QStringLiteral("fl")); - fillObject.insert(QStringLiteral("c"), staticProperty(colorArray(fillColor))); - fillObject.insert(QStringLiteral("o"), staticProperty(fillOpacity)); - fillObject.insert(QStringLiteral("r"), 1); - fillObject.insert(QStringLiteral("bm"), 0); - fillObject.insert(QStringLiteral("nm"), QStringLiteral("Fill")); + layer.insert(QStringLiteral("shapes"), shapes); + return layer; +} - 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)); +QJsonObject LottieLayerBuilder::buildPathLayer(PathBox* const box, + const int id) const +{ + auto layer = baseLayer(box->prp_getName(), id, 4); + layer.insert(QStringLiteral("ks"), transformObject(box)); + + QJsonArray shapes = pathShapeObjects(box->getRelativePath(mFrameRange.fMin), + box->prp_getName()); + appendPaintObjects(box, shapes); + shapes.append(shapeTransformObject()); - layer.insert(QStringLiteral("shapes"), QJsonArray{rect, fillObject, shapeTransform}); + layer.insert(QStringLiteral("shapes"), shapes); return layer; } @@ -194,7 +345,9 @@ QJsonObject LottieLayerBuilder::transformObject(const BoundingBox* const box) co 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(), pos.y(), 0}; + 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); @@ -316,6 +469,96 @@ QJsonObject LottieLayerBuilder::keyframeEase() const }; } +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)); + } + + const auto stroke = box->getStrokeSettings(); + if (stroke && + stroke->getPaintType() == PaintType::FLATPAINT && + !isZero4Dec(stroke->getLineWidthAnimator()->getEffectiveValue(mFrameRange.fMin))) { + shapes.append(strokeObject(box)); + } +} + +QJsonObject LottieLayerBuilder::fillObject(const PathBox* const box) const +{ + QColor fillColor(0, 0, 0, 0); + qreal fillOpacity = 0; + const auto fill = box->getFillSettings(); + if (fill) { + fillColor = fill->getColor(mFrameRange.fMin); + fillOpacity = fillColor.alphaF()*100; + } + + QJsonObject object; + object.insert(QStringLiteral("ty"), QStringLiteral("fl")); + object.insert(QStringLiteral("c"), staticProperty(colorArray(fillColor))); + object.insert(QStringLiteral("o"), staticProperty(fillOpacity)); + object.insert(QStringLiteral("r"), 1); + object.insert(QStringLiteral("bm"), 0); + object.insert(QStringLiteral("nm"), QStringLiteral("Fill")); + return object; +} + +QJsonObject LottieLayerBuilder::strokeObject(const PathBox* const box) const +{ + QColor strokeColor(0, 0, 0, 0); + qreal strokeOpacity = 0; + qreal strokeWidth = 0; + int lineCap = 2; + int lineJoin = 2; + + const auto stroke = box->getStrokeSettings(); + if (stroke) { + strokeColor = stroke->getColor(mFrameRange.fMin); + strokeOpacity = strokeColor.alphaF()*100; + strokeWidth = stroke->getLineWidthAnimator()->getEffectiveValue(mFrameRange.fMin); + + 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; + } + } + + QJsonObject object; + object.insert(QStringLiteral("ty"), QStringLiteral("st")); + object.insert(QStringLiteral("c"), staticProperty(colorArray(strokeColor))); + object.insert(QStringLiteral("o"), staticProperty(strokeOpacity)); + object.insert(QStringLiteral("w"), staticProperty(strokeWidth)); + 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")); + return object; +} + +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{ diff --git a/src/core/lottie/lottielayerbuilder.h b/src/core/lottie/lottielayerbuilder.h index f7a7773d6..bbb0ca50d 100644 --- a/src/core/lottie/lottielayerbuilder.h +++ b/src/core/lottie/lottielayerbuilder.h @@ -31,6 +31,7 @@ class BoundingBox; class Canvas; class QColor; class ContainerBox; +class PathBox; class RectangleBox; class CORE_EXPORT LottieLayerBuilder @@ -49,6 +50,8 @@ class CORE_EXPORT LottieLayerBuilder int& nextId) const; QJsonObject buildRectangleLayer(RectangleBox* 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; @@ -64,6 +67,11 @@ class CORE_EXPORT LottieLayerBuilder bool sameScalarValues(const QList& values) const; bool samePointValues(const QList& values) const; QJsonObject keyframeEase() const; + void appendPaintObjects(const PathBox* const box, + QJsonArray& shapes) const; + QJsonObject fillObject(const PathBox* const box) const; + QJsonObject strokeObject(const PathBox* const box) const; + QJsonObject shapeTransformObject() const; QJsonArray colorArray(const QColor& color) const; Canvas* const mScene; From 289d941c6d7c449d78a591ba74128c4c99141bbc Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Tue, 2 Jun 2026 19:54:38 +0200 Subject: [PATCH 04/47] Add Lottie group parenting support --- src/core/lottie/lottielayerbuilder.cpp | 38 ++++++++++++++++++++++---- src/core/lottie/lottielayerbuilder.h | 7 ++++- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index 29da7b821..5e65606ef 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -223,7 +223,8 @@ QJsonObject LottieLayerBuilder::buildBackgroundLayer() const void LottieLayerBuilder::appendContainerLayers(const ContainerBox* const container, QJsonArray& layers, - int& nextId) const + int& nextId, + const int parentId) const { const auto& boxes = container->getContainedBoxes(); for (auto it = boxes.crbegin(); it != boxes.crend(); ++it) { @@ -232,29 +233,49 @@ void LottieLayerBuilder::appendContainerLayers(const ContainerBox* const contain const auto childContainer = dynamic_cast(box); if (childContainer) { - appendContainerLayers(childContainer, layers, nextId); + const int groupId = nextId++; + layers.append(buildContainerLayer(childContainer, groupId, parentId)); + appendContainerLayers(childContainer, layers, nextId, groupId); continue; } const auto rectangle = dynamic_cast(box); if (rectangle) { - layers.append(buildRectangleLayer(rectangle, nextId)); + auto layer = buildRectangleLayer(rectangle, nextId); + assignParent(layer, parentId); + layers.append(layer); nextId++; continue; } const auto path = dynamic_cast(box); if (path) { - layers.append(buildPathLayer(path, nextId)); + auto layer = buildPathLayer(path, nextId); + assignParent(layer, parentId); + layers.append(layer); nextId++; continue; } - layers.append(buildUnsupportedLayer(box, nextId)); + auto layer = buildUnsupportedLayer(box, nextId); + assignParent(layer, parentId); + layers.append(layer); nextId++; } } +QJsonObject LottieLayerBuilder::buildContainerLayer(const ContainerBox* const box, + const int id, + const int parentId) const +{ + auto layer = baseLayer(box ? box->prp_getName() : QStringLiteral("Group"), + id, + 3); + layer.insert(QStringLiteral("ks"), transformObject(box)); + assignParent(layer, parentId); + return layer; +} + QJsonObject LottieLayerBuilder::buildRectangleLayer(RectangleBox* const box, const int id) const { @@ -330,6 +351,13 @@ QJsonObject LottieLayerBuilder::baseLayer(const QString& name, 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) { diff --git a/src/core/lottie/lottielayerbuilder.h b/src/core/lottie/lottielayerbuilder.h index bbb0ca50d..12ef1ac7e 100644 --- a/src/core/lottie/lottielayerbuilder.h +++ b/src/core/lottie/lottielayerbuilder.h @@ -47,7 +47,11 @@ class CORE_EXPORT LottieLayerBuilder QJsonObject buildBackgroundLayer() const; void appendContainerLayers(const ContainerBox* const container, QJsonArray& layers, - int& nextId) const; + int& nextId, + const int parentId = 0) const; + QJsonObject buildContainerLayer(const ContainerBox* const box, + const int id, + const int parentId) const; QJsonObject buildRectangleLayer(RectangleBox* const box, const int id) const; QJsonObject buildPathLayer(PathBox* const box, @@ -58,6 +62,7 @@ class CORE_EXPORT LottieLayerBuilder QJsonObject baseLayer(const QString& name, const int id, const int type) const; + void assignParent(QJsonObject& layer, const int parentId) const; QJsonObject transformObject(const BoundingBox* const box = nullptr) const; QJsonObject staticProperty(const QJsonValue& value) const; QJsonObject animatedScalarProperty(const QList& values) const; From 1155803038d78091ddde7f30ab34ba228831ec5a Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Tue, 2 Jun 2026 20:23:05 +0200 Subject: [PATCH 05/47] Add Lottie sub-path trim support --- src/core/CMakeLists.txt | 1 + src/core/lottie/lottielayerbuilder.cpp | 3 + src/core/lottie/lottiepatheffects.cpp | 161 +++++++++++++++++++++++++ src/core/lottie/lottiepatheffects.h | 39 ++++++ 4 files changed, 204 insertions(+) create mode 100644 src/core/lottie/lottiepatheffects.cpp create mode 100644 src/core/lottie/lottiepatheffects.h diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index da429c4ec..fc8cc16a2 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -316,6 +316,7 @@ set( Animators/boolanimator.cpp lottie/lottieexporter.cpp lottie/lottielayerbuilder.cpp + lottie/lottiepatheffects.cpp svgexporter.cpp svgexporthelpers.cpp svgimporter.cpp diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index 5e65606ef..a30e3a3aa 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -31,6 +31,7 @@ #include "Boxes/pathbox.h" #include "Boxes/rectangle.h" #include "canvas.h" +#include "lottie/lottiepatheffects.h" #include "paintsettings.h" #include "simplemath.h" #include "skia/skiaincludes.h" @@ -302,6 +303,7 @@ QJsonObject LottieLayerBuilder::buildRectangleLayer(RectangleBox* const box, qAbs(radius.y())))); QJsonArray shapes{rect}; + LottiePathEffects::appendBasePathEffects(box, mFrameRange, shapes); appendPaintObjects(box, shapes); shapes.append(shapeTransformObject()); @@ -317,6 +319,7 @@ QJsonObject LottieLayerBuilder::buildPathLayer(PathBox* const box, QJsonArray shapes = pathShapeObjects(box->getRelativePath(mFrameRange.fMin), box->prp_getName()); + LottiePathEffects::appendBasePathEffects(box, mFrameRange, shapes); appendPaintObjects(box, shapes); shapes.append(shapeTransformObject()); diff --git a/src/core/lottie/lottiepatheffects.cpp b/src/core/lottie/lottiepatheffects.cpp new file mode 100644 index 000000000..f87f4eecd --- /dev/null +++ b/src/core/lottie/lottiepatheffects.cpp @@ -0,0 +1,161 @@ +/* +# +# 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 "PathEffects/patheffect.h" +#include "PathEffects/patheffectcollection.h" +#include "Properties/boolproperty.h" + +#include +#include + +namespace { + +QJsonObject staticProperty(const QJsonValue& value) +{ + return QJsonObject{ + {QStringLiteral("a"), 0}, + {QStringLiteral("k"), value} + }; +} + +QJsonObject keyframeEase() +{ + return QJsonObject{ + {QStringLiteral("x"), QJsonArray{0.667}}, + {QStringLiteral("y"), QJsonArray{1}} + }; +} + +bool sameScalarValues(const QList& values) +{ + if (values.isEmpty()) { return true; } + const qreal first = values.first(); + for (const qreal value : values) { + if (!qFuzzyCompare(first + 1, value + 1)) { return false; } + } + return true; +} + +QJsonArray scalarKeyframes(const QList& values, + const FrameRange& frameRange) +{ + QJsonArray keyframes; + for (int i = 0; i < values.size(); i++) { + QJsonObject key; + key.insert(QStringLiteral("t"), frameRange.fMin + i); + key.insert(QStringLiteral("s"), QJsonArray{values.at(i)}); + if (i + 1 < values.size()) { + key.insert(QStringLiteral("e"), QJsonArray{values.at(i + 1)}); + key.insert(QStringLiteral("i"), keyframeEase()); + key.insert(QStringLiteral("o"), keyframeEase()); + } + keyframes.append(key); + } + return keyframes; +} + +QJsonObject animatedScalarProperty(const QList& values, + const FrameRange& frameRange) +{ + if (values.isEmpty()) { return staticProperty(0); } + if (sameScalarValues(values)) { return staticProperty(values.first()); } + return QJsonObject{ + {QStringLiteral("a"), 1}, + {QStringLiteral("k"), scalarKeyframes(values, frameRange)} + }; +} + +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"), animatedScalarProperty(starts, frameRange)); + object.insert(QStringLiteral("e"), animatedScalarProperty(ends, frameRange)); + object.insert(QStringLiteral("o"), animatedScalarProperty(offsets, frameRange)); + object.insert(QStringLiteral("m"), pathWise && pathWise->getValue() ? 2 : 1); + object.insert(QStringLiteral("nm"), QStringLiteral("Sub-Path")); + object.insert(QStringLiteral("hd"), false); + return object; +} + +} + +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)); + } +} diff --git a/src/core/lottie/lottiepatheffects.h b/src/core/lottie/lottiepatheffects.h new file mode 100644 index 000000000..ff7330daf --- /dev/null +++ b/src/core/lottie/lottiepatheffects.h @@ -0,0 +1,39 @@ +/* +# +# 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 + +class PathBox; + +namespace LottiePathEffects { + +void appendBasePathEffects(const PathBox* const box, + const FrameRange& frameRange, + QJsonArray& shapes); + +} + +#endif // LOTTIEPATHEFFECTS_H From f48a88e6dc422e944c61829ac9eae024d0b7356d Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Tue, 2 Jun 2026 21:47:49 +0200 Subject: [PATCH 06/47] Add native Lottie text layers --- src/core/lottie/lottieexporter.cpp | 1 + src/core/lottie/lottielayerbuilder.cpp | 147 +++++++++++++++++++++++++ src/core/lottie/lottielayerbuilder.h | 12 ++ 3 files changed, 160 insertions(+) diff --git a/src/core/lottie/lottieexporter.cpp b/src/core/lottie/lottieexporter.cpp index 6ed236c59..05c456e5b 100644 --- a/src/core/lottie/lottieexporter.cpp +++ b/src/core/lottie/lottieexporter.cpp @@ -72,6 +72,7 @@ void LottieExporter::finish() }); const LottieLayerBuilder builder(mScene, mFrameRange, mFps); + root.insert(QStringLiteral("fonts"), builder.buildFonts()); root.insert(QStringLiteral("layers"), builder.buildLayers(mBackground)); QFile file(mPath); diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index a30e3a3aa..0aab276b8 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -30,6 +30,7 @@ #include "Boxes/containerbox.h" #include "Boxes/pathbox.h" #include "Boxes/rectangle.h" +#include "Boxes/textbox.h" #include "canvas.h" #include "lottie/lottiepatheffects.h" #include "paintsettings.h" @@ -38,6 +39,7 @@ #include #include +#include namespace { @@ -208,6 +210,14 @@ QJsonArray LottieLayerBuilder::buildLayers(const bool background) const return layers; } +QJsonObject LottieLayerBuilder::buildFonts() const +{ + QJsonArray fonts; + QSet names; + if (mScene) { appendFonts(mScene, fonts, names); } + return QJsonObject{{QStringLiteral("list"), fonts}}; +} + QJsonObject LottieLayerBuilder::buildBackgroundLayer() const { auto layer = baseLayer(QStringLiteral("Background"), 1, 1); @@ -249,6 +259,15 @@ void LottieLayerBuilder::appendContainerLayers(const ContainerBox* const contain continue; } + const auto text = dynamic_cast(box); + if (text && canBuildNativeTextLayer(text)) { + auto layer = buildTextLayer(text, nextId); + assignParent(layer, parentId); + layers.append(layer); + nextId++; + continue; + } + const auto path = dynamic_cast(box); if (path) { auto layer = buildPathLayer(path, nextId); @@ -311,6 +330,64 @@ QJsonObject LottieLayerBuilder::buildRectangleLayer(RectangleBox* const box, return layer; } +QJsonObject LottieLayerBuilder::buildTextLayer(TextBox* const box, + const int id) const +{ + auto layer = baseLayer(box->prp_getName(), id, 5); + layer.insert(QStringLiteral("ks"), transformObject(box)); + + QColor fillColor(0, 0, 0); + const auto fill = box->getFillSettings(); + if (fill && fill->getPaintType() == PaintType::FLATPAINT) { + fillColor = fill->getColor(mFrameRange.fMin); + } + + QJsonObject document; + document.insert(QStringLiteral("s"), box->getFontSize()); + document.insert(QStringLiteral("f"), fontName(box)); + document.insert(QStringLiteral("t"), box->getCurrentValue()); + document.insert(QStringLiteral("j"), 0); + document.insert(QStringLiteral("tr"), 0); + document.insert(QStringLiteral("lh"), box->getFontSize()*1.2); + document.insert(QStringLiteral("ls"), 0); + document.insert(QStringLiteral("sz"), QJsonArray{mScene ? mScene->getCanvasWidth() : 0, + mScene ? mScene->getCanvasHeight() : 0}); + document.insert(QStringLiteral("ps"), QJsonArray{0, -box->getFontSize()*0.75}); + 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(mFrameRange.fMin))) { + const QColor strokeColor = stroke->getColor(mFrameRange.fMin); + document.insert(QStringLiteral("sc"), QJsonArray{strokeColor.redF(), + strokeColor.greenF(), + strokeColor.blueF()}); + document.insert(QStringLiteral("sw"), + stroke->getLineWidthAnimator()->getEffectiveValue(mFrameRange.fMin)); + } + + QJsonObject documentKey; + documentKey.insert(QStringLiteral("s"), document); + documentKey.insert(QStringLiteral("t"), mFrameRange.fMin); + + QJsonObject textData; + textData.insert(QStringLiteral("d"), QJsonObject{ + {QStringLiteral("k"), QJsonArray{documentKey}} + }); + 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::buildPathLayer(PathBox* const box, const int id) const { @@ -599,3 +676,73 @@ QJsonArray LottieLayerBuilder::colorArray(const QColor& color) const 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) { 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); + } + } +} + +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 +{ + return box && + !box->hasTextEffects() && + !box->hasBasePathEffects() && + !box->hasFillEffects() && + !box->hasOutlineBaseEffects() && + !box->hasOutlineEffects(); +} diff --git a/src/core/lottie/lottielayerbuilder.h b/src/core/lottie/lottielayerbuilder.h index 12ef1ac7e..575c16b73 100644 --- a/src/core/lottie/lottielayerbuilder.h +++ b/src/core/lottie/lottielayerbuilder.h @@ -26,6 +26,7 @@ #include #include +#include class BoundingBox; class Canvas; @@ -33,6 +34,7 @@ class QColor; class ContainerBox; class PathBox; class RectangleBox; +class TextBox; class CORE_EXPORT LottieLayerBuilder { @@ -42,6 +44,7 @@ class CORE_EXPORT LottieLayerBuilder const qreal fps); QJsonArray buildLayers(const bool background) const; + QJsonObject buildFonts() const; private: QJsonObject buildBackgroundLayer() const; @@ -54,6 +57,8 @@ class CORE_EXPORT LottieLayerBuilder const int parentId) const; QJsonObject buildRectangleLayer(RectangleBox* const box, const int id) const; + QJsonObject buildTextLayer(TextBox* const box, + const int id) const; QJsonObject buildPathLayer(PathBox* const box, const int id) const; QJsonObject buildUnsupportedLayer(const BoundingBox* const box, @@ -78,6 +83,13 @@ class CORE_EXPORT LottieLayerBuilder QJsonObject strokeObject(const PathBox* const box) const; QJsonObject shapeTransformObject() const; QJsonArray colorArray(const QColor& color) const; + void appendFonts(const ContainerBox* const container, + QJsonArray& fonts, + QSet& names) 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; Canvas* const mScene; const FrameRange mFrameRange; From d8291d6d68e46139923ffae67b9da999fc7e122b Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Tue, 2 Jun 2026 21:49:03 +0200 Subject: [PATCH 07/47] Preserve Friction layer order in Lottie --- src/core/lottie/lottielayerbuilder.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index 0aab276b8..4192d8015 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -238,8 +238,7 @@ void LottieLayerBuilder::appendContainerLayers(const ContainerBox* const contain const int parentId) const { const auto& boxes = container->getContainedBoxes(); - for (auto it = boxes.crbegin(); it != boxes.crend(); ++it) { - const auto box = *it; + for (const auto box : boxes) { if (!box) { continue; } const auto childContainer = dynamic_cast(box); From 77fd4d45457abb6df4b27708df5141fc55ff5102 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 09:42:00 +0200 Subject: [PATCH 08/47] Add Lottie dash path effect support --- src/core/lottie/lottielayerbuilder.cpp | 1 + src/core/lottie/lottiepatheffects.cpp | 46 ++++++++++++++++++++++++++ src/core/lottie/lottiepatheffects.h | 4 +++ 3 files changed, 51 insertions(+) diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index 4192d8015..9446f5f5d 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -649,6 +649,7 @@ QJsonObject LottieLayerBuilder::strokeObject(const PathBox* const box) const object.insert(QStringLiteral("ml"), 4); object.insert(QStringLiteral("bm"), 0); object.insert(QStringLiteral("nm"), QStringLiteral("Stroke")); + LottiePathEffects::appendStrokeDash(box, mFrameRange, object); return object; } diff --git a/src/core/lottie/lottiepatheffects.cpp b/src/core/lottie/lottiepatheffects.cpp index f87f4eecd..f8b11fe13 100644 --- a/src/core/lottie/lottiepatheffects.cpp +++ b/src/core/lottie/lottiepatheffects.cpp @@ -141,6 +141,34 @@ QJsonObject trimPathObject(PathEffect* const effect, 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 = animatedScalarProperty(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, @@ -159,3 +187,21 @@ void LottiePathEffects::appendBasePathEffects(const PathBox* const box, 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 index ff7330daf..265cf1e66 100644 --- a/src/core/lottie/lottiepatheffects.h +++ b/src/core/lottie/lottiepatheffects.h @@ -25,6 +25,7 @@ #include "framerange.h" #include +#include class PathBox; @@ -33,6 +34,9 @@ namespace LottiePathEffects { void appendBasePathEffects(const PathBox* const box, const FrameRange& frameRange, QJsonArray& shapes); +void appendStrokeDash(const PathBox* const box, + const FrameRange& frameRange, + QJsonObject& stroke); } From e4201a74e0c3ff999ec8796a0762478918ce011a Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 09:42:53 +0200 Subject: [PATCH 09/47] Animate Lottie fill and stroke paint --- src/core/lottie/lottielayerbuilder.cpp | 44 ++++++++++++++++---------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index 9446f5f5d..c6b66c5bc 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -594,18 +594,20 @@ void LottieLayerBuilder::appendPaintObjects(const PathBox* const box, QJsonObject LottieLayerBuilder::fillObject(const PathBox* const box) const { - QColor fillColor(0, 0, 0, 0); - qreal fillOpacity = 0; + QList fillColors; + QList fillOpacities; const auto fill = box->getFillSettings(); - if (fill) { - fillColor = fill->getColor(mFrameRange.fMin); - fillOpacity = fillColor.alphaF()*100; + 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")); - object.insert(QStringLiteral("c"), staticProperty(colorArray(fillColor))); - object.insert(QStringLiteral("o"), staticProperty(fillOpacity)); + object.insert(QStringLiteral("c"), animatedPointProperty(fillColors)); + object.insert(QStringLiteral("o"), animatedScalarProperty(fillOpacities)); object.insert(QStringLiteral("r"), 1); object.insert(QStringLiteral("bm"), 0); object.insert(QStringLiteral("nm"), QStringLiteral("Fill")); @@ -614,18 +616,14 @@ QJsonObject LottieLayerBuilder::fillObject(const PathBox* const box) const QJsonObject LottieLayerBuilder::strokeObject(const PathBox* const box) const { - QColor strokeColor(0, 0, 0, 0); - qreal strokeOpacity = 0; - qreal strokeWidth = 0; + QList strokeColors; + QList strokeOpacities; + QList strokeWidths; int lineCap = 2; int lineJoin = 2; const auto stroke = box->getStrokeSettings(); if (stroke) { - strokeColor = stroke->getColor(mFrameRange.fMin); - strokeOpacity = strokeColor.alphaF()*100; - strokeWidth = stroke->getLineWidthAnimator()->getEffectiveValue(mFrameRange.fMin); - switch(stroke->getCapStyle()) { case SkPaint::kButt_Cap: lineCap = 1; break; case SkPaint::kSquare_Cap: lineCap = 3; break; @@ -639,11 +637,23 @@ QJsonObject LottieLayerBuilder::strokeObject(const PathBox* const box) const } } + 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")); - object.insert(QStringLiteral("c"), staticProperty(colorArray(strokeColor))); - object.insert(QStringLiteral("o"), staticProperty(strokeOpacity)); - object.insert(QStringLiteral("w"), staticProperty(strokeWidth)); + object.insert(QStringLiteral("c"), animatedPointProperty(strokeColors)); + object.insert(QStringLiteral("o"), animatedScalarProperty(strokeOpacities)); + object.insert(QStringLiteral("w"), animatedScalarProperty(strokeWidths)); object.insert(QStringLiteral("lc"), lineCap); object.insert(QStringLiteral("lj"), lineJoin); object.insert(QStringLiteral("ml"), 4); From cf50d73dd6076012f67403b40c784ba8f77f1d77 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 09:57:28 +0200 Subject: [PATCH 10/47] Add Lottie gradient paint support --- src/core/lottie/lottielayerbuilder.cpp | 134 ++++++++++++++++++++++++- src/core/lottie/lottielayerbuilder.h | 10 ++ 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index c6b66c5bc..ddca434eb 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -22,6 +22,8 @@ #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" @@ -582,13 +584,18 @@ void LottieLayerBuilder::appendPaintObjects(const PathBox* const box, 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 && - stroke->getPaintType() == PaintType::FLATPAINT && !isZero4Dec(stroke->getLineWidthAnimator()->getEffectiveValue(mFrameRange.fMin))) { - shapes.append(strokeObject(box)); + if (stroke->getPaintType() == PaintType::FLATPAINT) { + shapes.append(strokeObject(box)); + } else if (stroke->getPaintType() == PaintType::GRADIENTPAINT) { + shapes.append(gradientStrokeObject(box)); + } } } @@ -614,6 +621,13 @@ QJsonObject LottieLayerBuilder::fillObject(const PathBox* const box) const 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; @@ -663,6 +677,122 @@ QJsonObject LottieLayerBuilder::strokeObject(const PathBox* const box) const 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); + object.insert(QStringLiteral("g"), QJsonObject{ + {QStringLiteral("p"), stopCount}, + {QStringLiteral("k"), animatedPointProperty(colors)} + }); + object.insert(QStringLiteral("s"), animatedPointProperty(starts)); + object.insert(QStringLiteral("e"), animatedPointProperty(ends)); + object.insert(QStringLiteral("t"), + settings && + settings->getGradientType() == GradientType::RADIAL ? 2 : 1); + object.insert(QStringLiteral("nm"), name); + + if (stroke) { + object.insert(QStringLiteral("w"), animatedScalarProperty(widths)); + 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; diff --git a/src/core/lottie/lottielayerbuilder.h b/src/core/lottie/lottielayerbuilder.h index 575c16b73..8252471f3 100644 --- a/src/core/lottie/lottielayerbuilder.h +++ b/src/core/lottie/lottielayerbuilder.h @@ -32,6 +32,7 @@ class BoundingBox; class Canvas; class QColor; class ContainerBox; +class PaintSettingsAnimator; class PathBox; class RectangleBox; class TextBox; @@ -80,7 +81,16 @@ class CORE_EXPORT LottieLayerBuilder 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, From f80b209d30167f7792e409b8d466bf009d3bd854 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 13:11:17 +0200 Subject: [PATCH 11/47] Compact sampled Lottie keyframes --- src/core/CMakeLists.txt | 1 + src/core/lottie/lottieanimatedproperty.cpp | 269 +++++++++++++++++++++ src/core/lottie/lottieanimatedproperty.h | 45 ++++ src/core/lottie/lottielayerbuilder.cpp | 88 +------ src/core/lottie/lottielayerbuilder.h | 5 - src/core/lottie/lottiepatheffects.cpp | 49 +--- 6 files changed, 322 insertions(+), 135 deletions(-) create mode 100644 src/core/lottie/lottieanimatedproperty.cpp create mode 100644 src/core/lottie/lottieanimatedproperty.h diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index fc8cc16a2..ee574fb4b 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -315,6 +315,7 @@ set( Animators/key.cpp Animators/boolanimator.cpp lottie/lottieexporter.cpp + lottie/lottieanimatedproperty.cpp lottie/lottielayerbuilder.cpp lottie/lottiepatheffects.cpp svgexporter.cpp diff --git a/src/core/lottie/lottieanimatedproperty.cpp b/src/core/lottie/lottieanimatedproperty.cpp new file mode 100644 index 000000000..dd1925c05 --- /dev/null +++ b/src/core/lottie/lottieanimatedproperty.cpp @@ -0,0 +1,269 @@ +/* +# +# 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; +} + +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"), LottieAnimatedProperty::keyframeEase()); + key.insert(QStringLiteral("o"), LottieAnimatedProperty::keyframeEase()); + } + 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"), LottieAnimatedProperty::keyframeEase()); + key.insert(QStringLiteral("o"), LottieAnimatedProperty::keyframeEase()); + } + 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)} + }; +} + +QJsonObject LottieAnimatedProperty::keyframeEase() +{ + return QJsonObject{ + {QStringLiteral("x"), QJsonArray{0.667}}, + {QStringLiteral("y"), QJsonArray{1}} + }; +} diff --git a/src/core/lottie/lottieanimatedproperty.h b/src/core/lottie/lottieanimatedproperty.h new file mode 100644 index 000000000..f9d9e7eae --- /dev/null +++ b/src/core/lottie/lottieanimatedproperty.h @@ -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 . +# +*/ + +#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); +QJsonObject keyframeEase(); + +} + +#endif // LOTTIEANIMATEDPROPERTY_H diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index ddca434eb..0c1ea36b3 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -34,6 +34,7 @@ #include "Boxes/rectangle.h" #include "Boxes/textbox.h" #include "canvas.h" +#include "lottie/lottieanimatedproperty.h" #include "lottie/lottiepatheffects.h" #include "paintsettings.h" #include "simplemath.h" @@ -484,98 +485,17 @@ QJsonObject LottieLayerBuilder::transformObject(const BoundingBox* const box) co QJsonObject LottieLayerBuilder::staticProperty(const QJsonValue& value) const { - return QJsonObject{ - {QStringLiteral("a"), 0}, - {QStringLiteral("k"), value} - }; + return LottieAnimatedProperty::staticProperty(value); } QJsonObject LottieLayerBuilder::animatedScalarProperty(const QList& values) const { - if (values.isEmpty()) { return staticProperty(0); } - if (sameScalarValues(values)) { return staticProperty(values.first()); } - return QJsonObject{ - {QStringLiteral("a"), 1}, - {QStringLiteral("k"), scalarKeyframes(values)} - }; + return LottieAnimatedProperty::scalar(values, mFrameRange); } QJsonObject LottieLayerBuilder::animatedPointProperty(const QList& values) const { - if (values.isEmpty()) { return staticProperty(QJsonArray()); } - if (samePointValues(values)) { return staticProperty(values.first()); } - return QJsonObject{ - {QStringLiteral("a"), 1}, - {QStringLiteral("k"), pointKeyframes(values)} - }; -} - -QJsonArray LottieLayerBuilder::scalarKeyframes(const QList& values) const -{ - QJsonArray keyframes; - for (int i = 0; i < values.size(); i++) { - QJsonObject key; - key.insert(QStringLiteral("t"), mFrameRange.fMin + i); - key.insert(QStringLiteral("s"), QJsonArray{values.at(i)}); - if (i + 1 < values.size()) { - key.insert(QStringLiteral("e"), QJsonArray{values.at(i + 1)}); - key.insert(QStringLiteral("i"), keyframeEase()); - key.insert(QStringLiteral("o"), keyframeEase()); - } - keyframes.append(key); - } - return keyframes; -} - -QJsonArray LottieLayerBuilder::pointKeyframes(const QList& values) const -{ - QJsonArray keyframes; - for (int i = 0; i < values.size(); i++) { - QJsonObject key; - key.insert(QStringLiteral("t"), mFrameRange.fMin + i); - key.insert(QStringLiteral("s"), values.at(i)); - if (i + 1 < values.size()) { - key.insert(QStringLiteral("e"), values.at(i + 1)); - key.insert(QStringLiteral("i"), keyframeEase()); - key.insert(QStringLiteral("o"), keyframeEase()); - } - keyframes.append(key); - } - return keyframes; -} - -bool LottieLayerBuilder::sameScalarValues(const QList& values) const -{ - if (values.isEmpty()) { return true; } - const qreal first = values.first(); - for (const qreal value : values) { - if (!qFuzzyCompare(first + 1, value + 1)) { return false; } - } - return true; -} - -bool LottieLayerBuilder::samePointValues(const QList& values) const -{ - 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 (!qFuzzyCompare(first.at(i).toDouble() + 1, - value.at(i).toDouble() + 1)) { - return false; - } - } - } - return true; -} - -QJsonObject LottieLayerBuilder::keyframeEase() const -{ - return QJsonObject{ - {QStringLiteral("x"), QJsonArray{0.667}}, - {QStringLiteral("y"), QJsonArray{1}} - }; + return LottieAnimatedProperty::point(values, mFrameRange); } void LottieLayerBuilder::appendPaintObjects(const PathBox* const box, diff --git a/src/core/lottie/lottielayerbuilder.h b/src/core/lottie/lottielayerbuilder.h index 8252471f3..b36535e20 100644 --- a/src/core/lottie/lottielayerbuilder.h +++ b/src/core/lottie/lottielayerbuilder.h @@ -73,11 +73,6 @@ class CORE_EXPORT LottieLayerBuilder QJsonObject staticProperty(const QJsonValue& value) const; QJsonObject animatedScalarProperty(const QList& values) const; QJsonObject animatedPointProperty(const QList& values) const; - QJsonArray scalarKeyframes(const QList& values) const; - QJsonArray pointKeyframes(const QList& values) const; - bool sameScalarValues(const QList& values) const; - bool samePointValues(const QList& values) const; - QJsonObject keyframeEase() const; void appendPaintObjects(const PathBox* const box, QJsonArray& shapes) const; QJsonObject fillObject(const PathBox* const box) const; diff --git a/src/core/lottie/lottiepatheffects.cpp b/src/core/lottie/lottiepatheffects.cpp index f8b11fe13..34f14f348 100644 --- a/src/core/lottie/lottiepatheffects.cpp +++ b/src/core/lottie/lottiepatheffects.cpp @@ -23,6 +23,7 @@ #include "Animators/qrealanimator.h" #include "Boxes/pathbox.h" +#include "lottie/lottieanimatedproperty.h" #include "PathEffects/patheffect.h" #include "PathEffects/patheffectcollection.h" #include "Properties/boolproperty.h" @@ -34,57 +35,13 @@ namespace { QJsonObject staticProperty(const QJsonValue& value) { - return QJsonObject{ - {QStringLiteral("a"), 0}, - {QStringLiteral("k"), value} - }; -} - -QJsonObject keyframeEase() -{ - return QJsonObject{ - {QStringLiteral("x"), QJsonArray{0.667}}, - {QStringLiteral("y"), QJsonArray{1}} - }; -} - -bool sameScalarValues(const QList& values) -{ - if (values.isEmpty()) { return true; } - const qreal first = values.first(); - for (const qreal value : values) { - if (!qFuzzyCompare(first + 1, value + 1)) { return false; } - } - return true; -} - -QJsonArray scalarKeyframes(const QList& values, - const FrameRange& frameRange) -{ - QJsonArray keyframes; - for (int i = 0; i < values.size(); i++) { - QJsonObject key; - key.insert(QStringLiteral("t"), frameRange.fMin + i); - key.insert(QStringLiteral("s"), QJsonArray{values.at(i)}); - if (i + 1 < values.size()) { - key.insert(QStringLiteral("e"), QJsonArray{values.at(i + 1)}); - key.insert(QStringLiteral("i"), keyframeEase()); - key.insert(QStringLiteral("o"), keyframeEase()); - } - keyframes.append(key); - } - return keyframes; + return LottieAnimatedProperty::staticProperty(value); } QJsonObject animatedScalarProperty(const QList& values, const FrameRange& frameRange) { - if (values.isEmpty()) { return staticProperty(0); } - if (sameScalarValues(values)) { return staticProperty(values.first()); } - return QJsonObject{ - {QStringLiteral("a"), 1}, - {QStringLiteral("k"), scalarKeyframes(values, frameRange)} - }; + return LottieAnimatedProperty::scalar(values, frameRange); } QrealAnimator* qrealChild(PathEffect* const effect, From 775f86f073c9c7d773ad74b7edb40cec81c82b48 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 13:27:16 +0200 Subject: [PATCH 12/47] Minify Lottie output and linearize compact keyframes --- src/core/lottie/lottieanimatedproperty.cpp | 32 ++++++++++++++-------- src/core/lottie/lottieanimatedproperty.h | 1 - src/core/lottie/lottieexporter.cpp | 2 +- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/core/lottie/lottieanimatedproperty.cpp b/src/core/lottie/lottieanimatedproperty.cpp index dd1925c05..1809d38c6 100644 --- a/src/core/lottie/lottieanimatedproperty.cpp +++ b/src/core/lottie/lottieanimatedproperty.cpp @@ -180,6 +180,22 @@ QList simplifiedPointIndices(const QList& values, 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) @@ -193,8 +209,8 @@ QJsonArray scalarKeyframes(const QList& values, if (i + 1 < indices.size()) { const int nextIndex = indices.at(i + 1); key.insert(QStringLiteral("e"), QJsonArray{values.at(nextIndex)}); - key.insert(QStringLiteral("i"), LottieAnimatedProperty::keyframeEase()); - key.insert(QStringLiteral("o"), LottieAnimatedProperty::keyframeEase()); + key.insert(QStringLiteral("i"), linearInEase()); + key.insert(QStringLiteral("o"), linearOutEase()); } keyframes.append(key); } @@ -214,8 +230,8 @@ QJsonArray pointKeyframes(const QList& values, if (i + 1 < indices.size()) { const int nextIndex = indices.at(i + 1); key.insert(QStringLiteral("e"), values.at(nextIndex)); - key.insert(QStringLiteral("i"), LottieAnimatedProperty::keyframeEase()); - key.insert(QStringLiteral("o"), LottieAnimatedProperty::keyframeEase()); + key.insert(QStringLiteral("i"), linearInEase()); + key.insert(QStringLiteral("o"), linearOutEase()); } keyframes.append(key); } @@ -259,11 +275,3 @@ QJsonObject LottieAnimatedProperty::point(const QList& values, {QStringLiteral("k"), pointKeyframes(values, indices, frameRange)} }; } - -QJsonObject LottieAnimatedProperty::keyframeEase() -{ - return QJsonObject{ - {QStringLiteral("x"), QJsonArray{0.667}}, - {QStringLiteral("y"), QJsonArray{1}} - }; -} diff --git a/src/core/lottie/lottieanimatedproperty.h b/src/core/lottie/lottieanimatedproperty.h index f9d9e7eae..1194ee1d4 100644 --- a/src/core/lottie/lottieanimatedproperty.h +++ b/src/core/lottie/lottieanimatedproperty.h @@ -38,7 +38,6 @@ QJsonObject scalar(const QList& values, QJsonObject point(const QList& values, const FrameRange& frameRange, const qreal tolerance = 0.001); -QJsonObject keyframeEase(); } diff --git a/src/core/lottie/lottieexporter.cpp b/src/core/lottie/lottieexporter.cpp index 05c456e5b..f182a9f03 100644 --- a/src/core/lottie/lottieexporter.cpp +++ b/src/core/lottie/lottieexporter.cpp @@ -81,7 +81,7 @@ void LottieExporter::finish() } const QJsonDocument doc(root); - file.write(doc.toJson(QJsonDocument::Indented)); + file.write(doc.toJson(QJsonDocument::Compact)); file.write("\n"); file.close(); setValue(INT_MAX); From e2221629c10a4954ce32a3f19c9114a5d88e0299 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 14:05:13 +0200 Subject: [PATCH 13/47] Optimize Lottie JSON output --- src/core/CMakeLists.txt | 1 + src/core/lottie/lottieexporter.cpp | 3 +- src/core/lottie/lottiejsonoptimizer.cpp | 88 +++++++++++++++++++++++++ src/core/lottie/lottiejsonoptimizer.h | 33 ++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/core/lottie/lottiejsonoptimizer.cpp create mode 100644 src/core/lottie/lottiejsonoptimizer.h diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index ee574fb4b..041a43ea6 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -316,6 +316,7 @@ set( Animators/boolanimator.cpp lottie/lottieexporter.cpp lottie/lottieanimatedproperty.cpp + lottie/lottiejsonoptimizer.cpp lottie/lottielayerbuilder.cpp lottie/lottiepatheffects.cpp svgexporter.cpp diff --git a/src/core/lottie/lottieexporter.cpp b/src/core/lottie/lottieexporter.cpp index f182a9f03..db248a9f1 100644 --- a/src/core/lottie/lottieexporter.cpp +++ b/src/core/lottie/lottieexporter.cpp @@ -24,6 +24,7 @@ #include "appsupport.h" #include "canvas.h" #include "exceptions.h" +#include "lottie/lottiejsonoptimizer.h" #include "lottie/lottielayerbuilder.h" #include @@ -80,7 +81,7 @@ void LottieExporter::finish() RuntimeThrow("Could not open:\n\"" + mPath + "\""); } - const QJsonDocument doc(root); + const QJsonDocument doc(LottieJsonOptimizer::optimize(root)); file.write(doc.toJson(QJsonDocument::Compact)); file.write("\n"); file.close(); 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 From 533cedcc3dd9e3cb1cece2fcd7d3070e252e418f Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 14:10:36 +0200 Subject: [PATCH 14/47] Omit empty Lottie root metadata --- src/core/lottie/lottieexporter.cpp | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/core/lottie/lottieexporter.cpp b/src/core/lottie/lottieexporter.cpp index db248a9f1..b41491a23 100644 --- a/src/core/lottie/lottieexporter.cpp +++ b/src/core/lottie/lottieexporter.cpp @@ -21,7 +21,6 @@ #include "lottie/lottieexporter.h" -#include "appsupport.h" #include "canvas.h" #include "exceptions.h" #include "lottie/lottiejsonoptimizer.h" @@ -64,16 +63,12 @@ void LottieExporter::finish() root.insert(QStringLiteral("h"), mScene->getCanvasHeight()); root.insert(QStringLiteral("nm"), mScene->prp_getName()); root.insert(QStringLiteral("ddd"), 0); - root.insert(QStringLiteral("assets"), QJsonArray()); - root.insert(QStringLiteral("markers"), QJsonArray()); - root.insert(QStringLiteral("meta"), QJsonObject{ - {QStringLiteral("g"), - QStringLiteral("%1 - %2").arg(AppSupport::getAppDisplayName(), - AppSupport::getAppUrl())} - }); const LottieLayerBuilder builder(mScene, mFrameRange, mFps); - root.insert(QStringLiteral("fonts"), builder.buildFonts()); + const auto fonts = builder.buildFonts(); + if (!fonts.value(QStringLiteral("list")).toArray().isEmpty()) { + root.insert(QStringLiteral("fonts"), fonts); + } root.insert(QStringLiteral("layers"), builder.buildLayers(mBackground)); QFile file(mPath); From 3b157c3da761a8ae13a76f5f9f526b79b3c35500 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 16:59:52 +0200 Subject: [PATCH 15/47] Export Lottie transform keyframes from Friction keys --- src/core/CMakeLists.txt | 1 + src/core/lottie/lottielayerbuilder.cpp | 54 +++++- src/core/lottie/lottierealkeyframes.cpp | 245 ++++++++++++++++++++++++ src/core/lottie/lottierealkeyframes.h | 50 +++++ 4 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 src/core/lottie/lottierealkeyframes.cpp create mode 100644 src/core/lottie/lottierealkeyframes.h diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 041a43ea6..be3bb0b40 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -317,6 +317,7 @@ set( lottie/lottieexporter.cpp lottie/lottieanimatedproperty.cpp lottie/lottiejsonoptimizer.cpp + lottie/lottierealkeyframes.cpp lottie/lottielayerbuilder.cpp lottie/lottiepatheffects.cpp svgexporter.cpp diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index 0c1ea36b3..39f148580 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -36,6 +36,7 @@ #include "canvas.h" #include "lottie/lottieanimatedproperty.h" #include "lottie/lottiepatheffects.h" +#include "lottie/lottierealkeyframes.h" #include "paintsettings.h" #include "simplemath.h" #include "skia/skiaincludes.h" @@ -445,6 +446,16 @@ QJsonObject LottieLayerBuilder::transformObject(const BoundingBox* const box) co if (box) { 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; @@ -465,11 +476,44 @@ QJsonObject LottieLayerBuilder::transformObject(const BoundingBox* const box) co } QJsonObject animated; - animated.insert(QStringLiteral("o"), animatedScalarProperty(opacities)); - animated.insert(QStringLiteral("r"), animatedScalarProperty(rotations)); - animated.insert(QStringLiteral("p"), animatedPointProperty(positions)); - animated.insert(QStringLiteral("a"), animatedPointProperty(anchors)); - animated.insert(QStringLiteral("s"), animatedPointProperty(scales)); + 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; } } diff --git a/src/core/lottie/lottierealkeyframes.cpp b/src/core/lottie/lottierealkeyframes.cpp new file mode 100644 index 000000000..b66c06f05 --- /dev/null +++ b/src/core/lottie/lottierealkeyframes.cpp @@ -0,0 +1,245 @@ +/* +# +# 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/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; +} + +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 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::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..8eb5588a7 --- /dev/null +++ b/src/core/lottie/lottierealkeyframes.h @@ -0,0 +1,50 @@ +/* +# +# 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; + +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); + +} + +#endif // LOTTIEREALKEYFRAMES_H From 9c930826183896d7ed7ec1e85f503cd94d32e2ef Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 17:10:32 +0200 Subject: [PATCH 16/47] Export Lottie paint keyframes from Friction keys --- src/core/lottie/lottielayerbuilder.cpp | 39 +++++++-- src/core/lottie/lottierealkeyframes.cpp | 100 ++++++++++++++++++++++++ src/core/lottie/lottierealkeyframes.h | 4 + 3 files changed, 138 insertions(+), 5 deletions(-) diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index 39f148580..2c1b145cb 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -577,8 +577,20 @@ QJsonObject LottieLayerBuilder::fillObject(const PathBox* const box) const QJsonObject object; object.insert(QStringLiteral("ty"), QStringLiteral("fl")); - object.insert(QStringLiteral("c"), animatedPointProperty(fillColors)); - object.insert(QStringLiteral("o"), animatedScalarProperty(fillOpacities)); + 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")); @@ -629,9 +641,26 @@ QJsonObject LottieLayerBuilder::strokeObject(const PathBox* const box) const QJsonObject object; object.insert(QStringLiteral("ty"), QStringLiteral("st")); - object.insert(QStringLiteral("c"), animatedPointProperty(strokeColors)); - object.insert(QStringLiteral("o"), animatedScalarProperty(strokeOpacities)); - object.insert(QStringLiteral("w"), animatedScalarProperty(strokeWidths)); + 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); diff --git a/src/core/lottie/lottierealkeyframes.cpp b/src/core/lottie/lottierealkeyframes.cpp index b66c06f05..a93196f65 100644 --- a/src/core/lottie/lottierealkeyframes.cpp +++ b/src/core/lottie/lottierealkeyframes.cpp @@ -21,6 +21,7 @@ #include "lottie/lottierealkeyframes.h" +#include "Animators/coloranimator.h" #include "Animators/qpointfanimator.h" #include "Animators/qrealanimator.h" #include "Animators/qrealkey.h" @@ -130,6 +131,28 @@ bool compatiblePointKeys(const ScalarKeys& x, 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, @@ -176,6 +199,48 @@ QJsonObject pointEaseOut(QrealKey* const xPrev, 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; +} + } QJsonObject LottieRealKeyframes::scalar(QrealAnimator* const animator, @@ -206,6 +271,41 @@ QJsonObject LottieRealKeyframes::scalar(QrealAnimator* const animator, }; } +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::point(QPointFAnimator* const animator, const FrameRange& frameRange, const PointValue& value) diff --git a/src/core/lottie/lottierealkeyframes.h b/src/core/lottie/lottierealkeyframes.h index 8eb5588a7..4090d4f4a 100644 --- a/src/core/lottie/lottierealkeyframes.h +++ b/src/core/lottie/lottierealkeyframes.h @@ -32,6 +32,7 @@ class QPointFAnimator; class QrealAnimator; +class ColorAnimator; namespace LottieRealKeyframes { @@ -44,6 +45,9 @@ QJsonObject scalar(QrealAnimator* const animator, QJsonObject point(QPointFAnimator* const animator, const FrameRange& frameRange, const PointValue& value); +QJsonObject color(ColorAnimator* const animator, + const FrameRange& frameRange, + const bool alpha); } From 39699b5164158b602d565fc8d6898f3c5913c7ab Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 17:20:52 +0200 Subject: [PATCH 17/47] Export Lottie gradient keyframes from Friction keys --- src/core/lottie/lottielayerbuilder.cpp | 40 ++++++- src/core/lottie/lottierealkeyframes.cpp | 148 ++++++++++++++++++++++++ src/core/lottie/lottierealkeyframes.h | 4 + 3 files changed, 188 insertions(+), 4 deletions(-) diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index 2c1b145cb..ef49c6244 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -727,19 +727,51 @@ QJsonObject LottieLayerBuilder::gradientObject(PaintSettingsAnimator* const sett 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"), animatedPointProperty(colors)} + {QStringLiteral("k"), + gradientReal.isEmpty() ? + animatedPointProperty(colors) : + gradientReal} }); - object.insert(QStringLiteral("s"), animatedPointProperty(starts)); - object.insert(QStringLiteral("e"), animatedPointProperty(ends)); + 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) { - object.insert(QStringLiteral("w"), animatedScalarProperty(widths)); + 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); diff --git a/src/core/lottie/lottierealkeyframes.cpp b/src/core/lottie/lottierealkeyframes.cpp index a93196f65..ab77c9292 100644 --- a/src/core/lottie/lottierealkeyframes.cpp +++ b/src/core/lottie/lottierealkeyframes.cpp @@ -22,6 +22,7 @@ #include "lottie/lottierealkeyframes.h" #include "Animators/coloranimator.h" +#include "Animators/gradient.h" #include "Animators/qpointfanimator.h" #include "Animators/qrealanimator.h" #include "Animators/qrealkey.h" @@ -241,6 +242,108 @@ QJsonArray colorArray(const QList& channels, 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, @@ -306,6 +409,51 @@ QJsonObject LottieRealKeyframes::color(ColorAnimator* const animator, }; } +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) diff --git a/src/core/lottie/lottierealkeyframes.h b/src/core/lottie/lottierealkeyframes.h index 4090d4f4a..476352177 100644 --- a/src/core/lottie/lottierealkeyframes.h +++ b/src/core/lottie/lottierealkeyframes.h @@ -33,6 +33,7 @@ class QPointFAnimator; class QrealAnimator; class ColorAnimator; +class Gradient; namespace LottieRealKeyframes { @@ -48,6 +49,9 @@ QJsonObject point(QPointFAnimator* const animator, QJsonObject color(ColorAnimator* const animator, const FrameRange& frameRange, const bool alpha); +QJsonObject gradientColors(Gradient* const gradient, + const int stopCount, + const FrameRange& frameRange); } From 6802a8be015376600386f6d9898884bc1ac9b7fa Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 17:25:56 +0200 Subject: [PATCH 18/47] Export Lottie path effect keyframes from Friction keys --- src/core/lottie/lottiepatheffects.cpp | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/core/lottie/lottiepatheffects.cpp b/src/core/lottie/lottiepatheffects.cpp index 34f14f348..044fcaba2 100644 --- a/src/core/lottie/lottiepatheffects.cpp +++ b/src/core/lottie/lottiepatheffects.cpp @@ -24,6 +24,7 @@ #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" @@ -44,6 +45,15 @@ QJsonObject animatedScalarProperty(const QList& values, 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) { @@ -89,9 +99,13 @@ QJsonObject trimPathObject(PathEffect* const effect, QJsonObject object; object.insert(QStringLiteral("ty"), QStringLiteral("tm")); - object.insert(QStringLiteral("s"), animatedScalarProperty(starts, frameRange)); - object.insert(QStringLiteral("e"), animatedScalarProperty(ends, frameRange)); - object.insert(QStringLiteral("o"), animatedScalarProperty(offsets, frameRange)); + 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); @@ -118,7 +132,7 @@ QJsonArray dashValues(PathEffect* const effect, values << (size ? size->getEffectiveValue(frame) : 0); } - const auto property = animatedScalarProperty(values, frameRange); + const auto property = scalarProperty(size, values, frameRange); return QJsonArray{ strokeDashEntry(QStringLiteral("d"), QStringLiteral("dash"), property), strokeDashEntry(QStringLiteral("g"), QStringLiteral("gap"), property), From c00dc76f8a1d67a8572e54189f8685a53ae8ec2f Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 17:38:46 +0200 Subject: [PATCH 19/47] Animate Lottie rectangle geometry --- src/core/lottie/lottielayerbuilder.cpp | 33 ++++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index ef49c6244..d679da0a0 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -305,24 +305,31 @@ QJsonObject LottieLayerBuilder::buildRectangleLayer(RectangleBox* const box, auto layer = baseLayer(box->prp_getName(), id, 4); layer.insert(QStringLiteral("ks"), transformObject(box)); - const QPointF topLeft = box->getTopLeftAnimator()->getEffectiveValue(mFrameRange.fMin); - const QPointF bottomRight = box->getBottomRightAnimator()->getEffectiveValue(mFrameRange.fMin); - const QPointF radius = box->getRadiusAnimator()->getEffectiveValue(mFrameRange.fMin); - - 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()); + 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"), staticProperty(QJsonArray{x + width*0.5, - y + height*0.5})); - rect.insert(QStringLiteral("s"), staticProperty(QJsonArray{width, height})); - rect.insert(QStringLiteral("r"), staticProperty(qMin(qAbs(radius.x()), - qAbs(radius.y())))); + 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); From e220de65cf167e4d7acf3cfbc43a2620f809ff14 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 18:14:48 +0200 Subject: [PATCH 20/47] Add controls to Lottie preview --- src/ui/dialogs/exportlottiedialog.cpp | 55 +++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/ui/dialogs/exportlottiedialog.cpp b/src/ui/dialogs/exportlottiedialog.cpp index 2b547b723..f67acca79 100644 --- a/src/ui/dialogs/exportlottiedialog.cpp +++ b/src/ui/dialogs/exportlottiedialog.cpp @@ -281,20 +281,69 @@ bool ExportLottieDialog::writePreviewHtml(const QString& jsonFile, stream << "\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"; stream << "
\n"; stream << "\n"; stream << "\n"; stream << "\n"; From fb5ae21d7b3850f651bba6e1463731e16289b13a Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 18:54:28 +0200 Subject: [PATCH 21/47] Add Lottie image asset export --- src/core/Boxes/imagebox.cpp | 15 +++ src/core/Boxes/imagebox.h | 3 + src/core/lottie/lottieexporter.cpp | 10 +- src/core/lottie/lottieexporter.h | 4 +- src/core/lottie/lottielayerbuilder.cpp | 164 ++++++++++++++++++++++++- src/core/lottie/lottielayerbuilder.h | 18 ++- src/ui/dialogs/exportlottiedialog.cpp | 16 ++- src/ui/dialogs/exportlottiedialog.h | 1 + 8 files changed, 224 insertions(+), 7 deletions(-) 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/lottie/lottieexporter.cpp b/src/core/lottie/lottieexporter.cpp index b41491a23..86c8761ce 100644 --- a/src/core/lottie/lottieexporter.cpp +++ b/src/core/lottie/lottieexporter.cpp @@ -34,13 +34,15 @@ LottieExporter::LottieExporter(const QString& path, Canvas* const scene, const FrameRange& frameRange, const qreal fps, - const bool background) + const bool background, + const bool embedImages) : ComplexTask(INT_MAX, tr("Lottie Export")) , mPath(path) , mScene(scene) , mFrameRange(frameRange) , mFps(fps) , mBackground(background) + , mEmbedImages(embedImages) { } @@ -64,11 +66,15 @@ void LottieExporter::finish() root.insert(QStringLiteral("nm"), mScene->prp_getName()); root.insert(QStringLiteral("ddd"), 0); - const LottieLayerBuilder builder(mScene, mFrameRange, mFps); + const LottieLayerBuilder builder(mScene, mFrameRange, mFps, mPath, mEmbedImages); const auto fonts = builder.buildFonts(); if (!fonts.value(QStringLiteral("list")).toArray().isEmpty()) { root.insert(QStringLiteral("fonts"), fonts); } + const auto assets = builder.buildAssets(); + if (!assets.isEmpty()) { + root.insert(QStringLiteral("assets"), assets); + } root.insert(QStringLiteral("layers"), builder.buildLayers(mBackground)); QFile file(mPath); diff --git a/src/core/lottie/lottieexporter.h b/src/core/lottie/lottieexporter.h index 194c5d0d3..1da49f8c4 100644 --- a/src/core/lottie/lottieexporter.h +++ b/src/core/lottie/lottieexporter.h @@ -34,7 +34,8 @@ class CORE_EXPORT LottieExporter : public ComplexTask Canvas* const scene, const FrameRange& frameRange, const qreal fps, - const bool background); + const bool background, + const bool embedImages = true); void nextStep() override; @@ -46,6 +47,7 @@ class CORE_EXPORT LottieExporter : public ComplexTask const FrameRange mFrameRange; const qreal mFps; const bool mBackground; + const bool mEmbedImages; }; #endif // LOTTIEEXPORTER_H diff --git a/src/core/lottie/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index d679da0a0..d39c10fd1 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -30,6 +30,7 @@ #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/textbox.h" @@ -42,6 +43,10 @@ #include "skia/skiaincludes.h" #include +#include +#include +#include +#include #include #include @@ -70,6 +75,34 @@ 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; @@ -195,10 +228,14 @@ QJsonArray pathShapeObjects(const SkPath& path, const QString& name) LottieLayerBuilder::LottieLayerBuilder(Canvas* const scene, const FrameRange& frameRange, - const qreal fps) + const qreal fps, + const QString& path, + const bool embedImages) : mScene(scene) , mFrameRange(frameRange) , mFps(fps) + , mPath(path) + , mEmbedImages(embedImages) { } @@ -214,6 +251,14 @@ QJsonArray LottieLayerBuilder::buildLayers(const bool background) const return layers; } +QJsonArray LottieLayerBuilder::buildAssets() const +{ + QJsonArray assets; + QSet ids; + if (mScene) { appendImageAssets(mScene, assets, ids); } + return assets; +} + QJsonObject LottieLayerBuilder::buildFonts() const { QJsonArray fonts; @@ -271,6 +316,15 @@ void LottieLayerBuilder::appendContainerLayers(const ContainerBox* const contain continue; } + const auto image = dynamic_cast(box); + if (image && imageForBox(image)) { + auto layer = buildImageLayer(image, nextId); + assignParent(layer, parentId); + layers.append(layer); + nextId++; + continue; + } + const auto path = dynamic_cast(box); if (path) { auto layer = buildPathLayer(path, nextId); @@ -398,6 +452,21 @@ QJsonObject LottieLayerBuilder::buildTextLayer(TextBox* const box, return layer; } +QJsonObject LottieLayerBuilder::buildImageLayer(ImageBox* const box, + const int id) const +{ + auto layer = baseLayer(box->prp_getName(), id, 2); + 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 { @@ -875,6 +944,99 @@ void LottieLayerBuilder::appendFonts(const ContainerBox* const container, } } +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) { 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{ diff --git a/src/core/lottie/lottielayerbuilder.h b/src/core/lottie/lottielayerbuilder.h index b36535e20..9ed9ebdf0 100644 --- a/src/core/lottie/lottielayerbuilder.h +++ b/src/core/lottie/lottielayerbuilder.h @@ -32,6 +32,7 @@ class BoundingBox; class Canvas; class QColor; class ContainerBox; +class ImageBox; class PaintSettingsAnimator; class PathBox; class RectangleBox; @@ -42,9 +43,12 @@ class CORE_EXPORT LottieLayerBuilder public: LottieLayerBuilder(Canvas* const scene, const FrameRange& frameRange, - const qreal fps); + const qreal fps, + const QString& path = QString(), + const bool embedImages = true); QJsonArray buildLayers(const bool background) const; + QJsonArray buildAssets() const; QJsonObject buildFonts() const; private: @@ -60,6 +64,8 @@ class CORE_EXPORT LottieLayerBuilder const int id) const; QJsonObject buildTextLayer(TextBox* const box, const int id) 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, @@ -91,6 +97,14 @@ class CORE_EXPORT LottieLayerBuilder 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; @@ -99,6 +113,8 @@ class CORE_EXPORT LottieLayerBuilder Canvas* const mScene; const FrameRange mFrameRange; const qreal mFps; + const QString mPath; + const bool mEmbedImages; }; #endif // LOTTIELAYERBUILDER_H diff --git a/src/ui/dialogs/exportlottiedialog.cpp b/src/ui/dialogs/exportlottiedialog.cpp index f67acca79..73b36e0e7 100644 --- a/src/ui/dialogs/exportlottiedialog.cpp +++ b/src/ui/dialogs/exportlottiedialog.cpp @@ -88,6 +88,10 @@ ExportLottieDialog::ExportLottieDialog(QWidget* const parent, mBackground->setChecked(AppSupport::getSettings("exportLottie", "background", true).toBool()); + mEmbedImages = new QCheckBox(tr("Embed images"), this); + mEmbedImages->setChecked(AppSupport::getSettings("exportLottie", + "embedImages", + true).toBool()); mNotify = new QCheckBox(tr("Notify when done"), this); mNotify->setChecked(AppSupport::getSettings("exportLottie", "notify", @@ -105,6 +109,12 @@ ExportLottieDialog::ExportLottieDialog(QWidget* const parent, "notify", mNotify->isChecked()); }); + connect(mEmbedImages, &QCheckBox::stateChanged, + this, [this] { + AppSupport::setSettings("exportLottie", + "embedImages", + mEmbedImages->isChecked()); + }); twoColLayout->addPair(new QLabel(tr("Scene")), sceneButton); twoColLayout->addPair(new QLabel(tr("First Frame")), mFirstFrame); @@ -117,7 +127,8 @@ ExportLottieDialog::ExportLottieDialog(QWidget* const parent, optsWidget->setObjectName("BlueBox"); const auto optsTwoCol = new TwoColumnLayout(); - optsTwoCol->addPair(mBackground, mNotify); + optsTwoCol->addPair(mBackground, mEmbedImages); + optsTwoCol->addPair(mNotify, new QWidget(this)); optsTwoCol->addSpacing(4); sceneWidget->setLayout(twoColLayout); @@ -250,7 +261,8 @@ bool ExportLottieDialog::exportTo(const QString& file) scene, frameRange, scene->getFps(), - mBackground->isChecked()); + mBackground->isChecked(), + mEmbedImages->isChecked()); const auto taskSPtr = qsptr(task, &QObject::deleteLater); task->nextStep(); TaskScheduler::instance()->addComplexTask(taskSPtr); diff --git a/src/ui/dialogs/exportlottiedialog.h b/src/ui/dialogs/exportlottiedialog.h index ee6bf75ff..a1e06e356 100644 --- a/src/ui/dialogs/exportlottiedialog.h +++ b/src/ui/dialogs/exportlottiedialog.h @@ -52,6 +52,7 @@ class UI_EXPORT ExportLottieDialog : public Friction::Ui::Dialog QSpinBox* mFirstFrame; QSpinBox* mLastFrame; QCheckBox* mBackground; + QCheckBox* mEmbedImages; QCheckBox* mNotify; }; From a902b855828dfbd25d5f7dd3e1f863aaf5713bae Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 23:03:13 +0200 Subject: [PATCH 22/47] Add Lottie blend mode export --- src/core/CMakeLists.txt | 1 + src/core/lottie/lottieblendmode.cpp | 45 ++++++++++++++++++++++++++ src/core/lottie/lottieblendmode.h | 33 +++++++++++++++++++ src/core/lottie/lottielayerbuilder.cpp | 18 +++++++---- src/core/lottie/lottielayerbuilder.h | 3 +- 5 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 src/core/lottie/lottieblendmode.cpp create mode 100644 src/core/lottie/lottieblendmode.h diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index be3bb0b40..cdd50a1b4 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -316,6 +316,7 @@ set( Animators/boolanimator.cpp lottie/lottieexporter.cpp lottie/lottieanimatedproperty.cpp + lottie/lottieblendmode.cpp lottie/lottiejsonoptimizer.cpp lottie/lottierealkeyframes.cpp lottie/lottielayerbuilder.cpp 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/lottielayerbuilder.cpp b/src/core/lottie/lottielayerbuilder.cpp index d39c10fd1..0090b6733 100644 --- a/src/core/lottie/lottielayerbuilder.cpp +++ b/src/core/lottie/lottielayerbuilder.cpp @@ -36,6 +36,7 @@ #include "Boxes/textbox.h" #include "canvas.h" #include "lottie/lottieanimatedproperty.h" +#include "lottie/lottieblendmode.h" #include "lottie/lottiepatheffects.h" #include "lottie/lottierealkeyframes.h" #include "paintsettings.h" @@ -347,7 +348,8 @@ QJsonObject LottieLayerBuilder::buildContainerLayer(const ContainerBox* const bo { auto layer = baseLayer(box ? box->prp_getName() : QStringLiteral("Group"), id, - 3); + 3, + box); layer.insert(QStringLiteral("ks"), transformObject(box)); assignParent(layer, parentId); return layer; @@ -356,7 +358,7 @@ QJsonObject LottieLayerBuilder::buildContainerLayer(const ContainerBox* const bo QJsonObject LottieLayerBuilder::buildRectangleLayer(RectangleBox* const box, const int id) const { - auto layer = baseLayer(box->prp_getName(), id, 4); + auto layer = baseLayer(box->prp_getName(), id, 4, box); layer.insert(QStringLiteral("ks"), transformObject(box)); QList centers; @@ -397,7 +399,7 @@ QJsonObject LottieLayerBuilder::buildRectangleLayer(RectangleBox* const box, QJsonObject LottieLayerBuilder::buildTextLayer(TextBox* const box, const int id) const { - auto layer = baseLayer(box->prp_getName(), id, 5); + auto layer = baseLayer(box->prp_getName(), id, 5, box); layer.insert(QStringLiteral("ks"), transformObject(box)); QColor fillColor(0, 0, 0); @@ -455,7 +457,7 @@ QJsonObject LottieLayerBuilder::buildTextLayer(TextBox* const box, QJsonObject LottieLayerBuilder::buildImageLayer(ImageBox* const box, const int id) const { - auto layer = baseLayer(box->prp_getName(), id, 2); + auto layer = baseLayer(box->prp_getName(), id, 2, box); layer.insert(QStringLiteral("ks"), transformObject(box)); layer.insert(QStringLiteral("refId"), imageAssetId(box)); @@ -470,7 +472,7 @@ QJsonObject LottieLayerBuilder::buildImageLayer(ImageBox* const box, QJsonObject LottieLayerBuilder::buildPathLayer(PathBox* const box, const int id) const { - auto layer = baseLayer(box->prp_getName(), id, 4); + auto layer = baseLayer(box->prp_getName(), id, 4, box); layer.insert(QStringLiteral("ks"), transformObject(box)); QJsonArray shapes = pathShapeObjects(box->getRelativePath(mFrameRange.fMin), @@ -493,7 +495,8 @@ QJsonObject LottieLayerBuilder::buildUnsupportedLayer(const BoundingBox* const b QJsonObject LottieLayerBuilder::baseLayer(const QString& name, const int id, - const int type) const + const int type, + const BoundingBox* const box) const { QJsonObject layer; layer.insert(QStringLiteral("ddd"), 0); @@ -505,7 +508,8 @@ QJsonObject LottieLayerBuilder::baseLayer(const QString& name, layer.insert(QStringLiteral("ip"), mFrameRange.fMin); layer.insert(QStringLiteral("op"), mFrameRange.fMax + 1); layer.insert(QStringLiteral("st"), 0); - layer.insert(QStringLiteral("bm"), 0); + layer.insert(QStringLiteral("bm"), + box ? LottieBlendMode::value(box->getBlendMode()) : 0); Q_UNUSED(mFps) return layer; } diff --git a/src/core/lottie/lottielayerbuilder.h b/src/core/lottie/lottielayerbuilder.h index 9ed9ebdf0..7bdcbdbb4 100644 --- a/src/core/lottie/lottielayerbuilder.h +++ b/src/core/lottie/lottielayerbuilder.h @@ -73,7 +73,8 @@ class CORE_EXPORT LottieLayerBuilder QJsonObject baseLayer(const QString& name, const int id, - const int type) const; + 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; QJsonObject staticProperty(const QJsonValue& value) const; From f3f29f0c42d02c53623434b0b520de1cee6f5885 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 3 Jun 2026 23:03:19 +0200 Subject: [PATCH 23/47] Add Lottie preview renderer selector --- src/ui/dialogs/exportlottiedialog.cpp | 29 +++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/ui/dialogs/exportlottiedialog.cpp b/src/ui/dialogs/exportlottiedialog.cpp index 73b36e0e7..ced478ee8 100644 --- a/src/ui/dialogs/exportlottiedialog.cpp +++ b/src/ui/dialogs/exportlottiedialog.cpp @@ -317,6 +317,10 @@ bool ExportLottieDialog::writePreviewHtml(const QString& jsonFile, stream << "\n"; stream << "\n"; stream << "\n"; + stream << "\n"; stream << "