[graphics/krita/krita/6.0] /: [android] Recording exports via OS media encoder
Carsten Hartenfels <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 2d00ed6b9803e8e54e2e50a21bfe563267ca60cb by Carsten Hartenfels.
Committed on 27/07/2026 at 17:19.
Pushed by hartenfels into branch 'krita/6.0'.
[android] Recording exports via OS media encoder
This adds support for exporting video on Android via the MediaEncoder
API provided by the operating system. As far as the device supports
them, we provide MP4/H.264, WEBP/VP8 and MP4/AV1 export options.
The export dialog has been torn up quite a bit to make it work on
Android's paradigms. The profile options are now just format options,
since there's no command line to edit.
The wrapper interface has been kept somewhat generic to allow for
further extension, at minimum by a LibAV exporter that does GIF, but we
could possibly also add the Windows Media Foundation encoder in this
kind of structure in the future.
M +9 -1 libs/ui/CMakeLists.txt
A +940 -0 libs/ui/animation/KisAndroidMediaEncoderRunnable.cpp [License: GPL(v3.0+)]
A +80 -0 libs/ui/animation/KisAndroidMediaEncoderRunnable.h [License: GPL(v3.0+)]
A +43 -0 libs/ui/animation/KisMediaEncoderFormatPreferencesDialog.cpp [License: GPL(v3.0+)]
A +31 -0 libs/ui/animation/KisMediaEncoderFormatPreferencesDialog.h [License: GPL(v3.0+)]
A +242 -0 libs/ui/animation/KisMediaEncoderWrapper.cpp [License: GPL(v3.0+)]
A +155 -0 libs/ui/animation/KisMediaEncoderWrapper.h [License: GPL(v3.0+)]
A +548 -0 packaging/android/apk/src/org/krita/android/VideoEncoder.java
M +8 -3 plugins/dockers/recorder/CMakeLists.txt
M +216 -70 plugins/dockers/recorder/recorder_export.cpp
M +13 -5 plugins/dockers/recorder/recorder_export.h
M +45 -0 plugins/dockers/recorder/recorder_export_config.cpp
M +10 -0 plugins/dockers/recorder/recorder_export_config.h
M +14 -1 plugins/dockers/recorder/recorder_export_settings.h
M +2 -0 plugins/dockers/recorder/recorderdocker_dock.cpp
https://invent.kde.org/graphics/krita/-/commit/2d00ed6b9803e8e54e2e50a21bfe563267ca60cb
diff --git a/libs/ui/CMakeLists.txt b/libs/ui/CMakeLists.txt
index 830c193c3c2..f5cec959cf5 100644
--- a/libs/ui/CMakeLists.txt
+++ b/libs/ui/CMakeLists.txt
@@ -563,7 +563,12 @@ if(APPLE)
endif()
if(ANDROID)
- list(APPEND kritaui_LIB_SRCS KisAndroidDonations.cpp)
+ list(APPEND kritaui_LIB_SRCS
+ KisAndroidDonations.cpp
+ animation/KisAndroidMediaEncoderRunnable.cpp
+ animation/KisMediaEncoderFormatPreferencesDialog.cpp
+ animation/KisMediaEncoderWrapper.cpp
+ )
endif()
ki18n_wrap_ui(kritaui_LIB_SRCS
@@ -780,6 +785,9 @@ if (ANDROID)
target_link_libraries(kritaui PRIVATE GLESv3)
target_link_libraries(kritaui PUBLIC Qt${QT_MAJOR_VERSION}::Gui)
target_link_libraries(kritaui PRIVATE Qt${QT_MAJOR_VERSION}::AndroidExtras)
+ if(TARGET LibAV)
+ target_link_libraries(kritaui PRIVATE LibAV)
+ endif()
endif()
if (HAIKU)
diff --git a/libs/ui/animation/KisAndroidMediaEncoderRunnable.cpp b/libs/ui/animation/KisAndroidMediaEncoderRunnable.cpp
new file mode 100644
index 00000000000..b4390f684f9
--- /dev/null
+++ b/libs/ui/animation/KisAndroidMediaEncoderRunnable.cpp
@@ -0,0 +1,940 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "KisAndroidMediaEncoderRunnable.h"
+
+#include <QComboBox>
+#include <QDir>
+#include <QFile>
+#include <QFormLayout>
+#include <QImage>
+#include <QSpinBox>
+#include <QTemporaryFile>
+
+#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
+#include <QJniEnvironment>
+#include <QJniObject>
+#else
+#include <QAndroidJniEnvironment>
+#include <QAndroidJniObject>
+using QJniEnvironment = QAndroidJniEnvironment;
+using QJniObject = QAndroidJniObject;
+#endif
+
+#include <klocalizedstring.h>
+
+#include <kis_debug.h>
+
+extern "C" {
+#include <libavutil/imgutils.h>
+#include <libavutil/pixfmt.h>
+#include <libswscale/swscale.h>
+}
+
+struct KisAndroidMediaEncoderRunnable::EncoderImage {
+ uint8_t *bufferY;
+ uint8_t *bufferU;
+ uint8_t *bufferV;
+ int rowStrideY;
+ int rowStrideU;
+ int rowStrideV;
+ int pixelStrideU;
+ int pixelStrideV;
+};
+
+class KisAndroidMediaEncoderRunnable::Format : public KisMediaEncoderFormat
+{
+public:
+ struct Encoder {
+ QString name;
+ bool hardware;
+ };
+
+ Format(int formatId, const QVector<Encoder> &encoders)
+ : KisMediaEncoderFormat()
+ , m_formatId(formatId)
+ {
+ // Prefer software encoders, because hardware encoders are often busted.
+ m_encoders.reserve(encoders.size());
+ for (const Encoder &encoder : encoders) {
+ if (!encoder.hardware) {
+ m_encoders.append(encoder);
+ }
+ }
+ for (const Encoder &encoder : encoders) {
+ if (encoder.hardware) {
+ m_encoders.append(encoder);
+ }
+ }
+ }
+
+ int formatId() const
+ {
+ return m_formatId;
+ }
+
+ Type type() const override
+ {
+ return Type::AndroidMediaEncoder;
+ }
+
+ QString key() const override
+ {
+ return keyForFormatId(m_formatId);
+ }
+
+ QString title() const override
+ {
+ return i18n("Android: %1", titleForFormatId(m_formatId));
+ }
+
+ QString extension() const override
+ {
+ return extensionForFormatId(m_formatId);
+ }
+
+ QWidget *createPreferencesWidget(const QVariantMap &preferences) const override
+ {
+ KisAndroidMediaEncoderPreferencesWidget *pw = new KisAndroidMediaEncoderPreferencesWidget;
+
+ for (const Encoder &encoder : m_encoders) {
+ QString title;
+ if (encoder.hardware) {
+ title = i18n("%1 (hardware)", encoder.name);
+ } else {
+ title = i18n("%1 (software)", encoder.name);
+ }
+ pw->addEncoderOption(title, encoder.name);
+ }
+
+ applyPreferencesToWidget(pw, preferences);
+ return pw;
+ }
+
+ void resetPreferencesWidget(QWidget *widget) const override
+ {
+ KisAndroidMediaEncoderPreferencesWidget *pw = qobject_cast<KisAndroidMediaEncoderPreferencesWidget *>(widget);
+ KIS_SAFE_ASSERT_RECOVER_RETURN(pw);
+ applyPreferencesToWidget(pw, QVariantMap());
+ }
+
+ QVariantMap getPreferencesFromWidget(QWidget *widget) const override
+ {
+ QVariantMap preferences;
+ KisAndroidMediaEncoderPreferencesWidget *pw = qobject_cast<KisAndroidMediaEncoderPreferencesWidget *>(widget);
+ KIS_SAFE_ASSERT_RECOVER_RETURN_VALUE(pw, preferences);
+ preferences.insert(QStringLiteral("encoder"), pw->encoder());
+ preferences.insert(QStringLiteral("bitrate"), pw->bitrate());
+ return preferences;
+ }
+
+ QString getEncoderPreference(const QVariantMap &preferences) const
+ {
+ QString encoder = preferences.value(QStringLiteral("encoder")).toString();
+ if (encoder.isEmpty() && !m_encoders.isEmpty()) {
+ return m_encoders.constFirst().name;
+ } else {
+ return encoder;
+ }
+ }
+
+ int getBitratePreference(const QVariantMap &preferences) const
+ {
+ int bitrate = preferences.value(QStringLiteral("bitrate")).toInt();
+ if (bitrate <= 0) {
+ return defaultBitrateForFormatId(m_formatId);
+ } else {
+ return bitrate;
+ }
+ }
+
+private:
+ void applyPreferencesToWidget(KisAndroidMediaEncoderPreferencesWidget *pw, const QVariantMap &preferences) const
+ {
+ pw->setEncoder(getEncoderPreference(preferences));
+ pw->setBitrate(getBitratePreference(preferences));
+ }
+
+ static QString keyForFormatId(int formatId)
+ {
+ switch (formatId) {
+ case FORMAT_MP4_H264:
+ return QStringLiteral("android:mp4:h264");
+ case FORMAT_WEBM_VP8:
+ return QStringLiteral("android:webm:vp8");
+ case FORMAT_MP4_AV1:
+ return QStringLiteral("android:mp4:av1");
+ }
+ return QString();
+ }
+
+ static QString titleForFormatId(int formatId)
+ {
+ switch (formatId) {
+ case FORMAT_MP4_H264:
+ return QStringLiteral("MP4/H.264");
+ case FORMAT_WEBM_VP8:
+ return QStringLiteral("WEBM/VP8");
+ case FORMAT_MP4_AV1:
+ return QStringLiteral("MP4/AV1");
+ }
+ return QString();
+ }
+
+ static QString extensionForFormatId(int formatId)
+ {
+ switch (formatId) {
+ case FORMAT_MP4_H264:
+ case FORMAT_MP4_AV1:
+ return QStringLiteral("mp4");
+ case FORMAT_WEBM_VP8:
+ return QStringLiteral("webm");
+ }
+ return QString();
+ }
+
+ static int defaultBitrateForFormatId(int formatId)
+ {
+ switch (formatId) {
+ case FORMAT_MP4_H264:
+ return 6000000;
+ case FORMAT_WEBM_VP8:
+ return 7000000;
+ case FORMAT_MP4_AV1:
+ return 3200000;
+ }
+ return 6000000;
+ }
+
+ QVector<Encoder> m_encoders;
+ int m_formatId;
+};
+
+class KisAndroidMediaEncoderRunnable::Context
+{
+public:
+ explicit Context(QString *outErrorMessage = nullptr)
+ : m_outErrorMessage(outErrorMessage)
+ {
+ }
+
+ ~Context()
+ {
+ if (m_imageFormat != AV_PIX_FMT_NONE) {
+ av_freep(&m_imageBuffers[0]);
+ }
+ sws_freeContext(m_swsContext);
+ clearEncoder();
+ }
+
+ QJniEnvironment &env()
+ {
+ return m_env;
+ }
+
+ QJniObject &encoder()
+ {
+ return m_encoder;
+ }
+
+ void setEncoder(const QJniObject &encoder)
+ {
+ m_encoder = encoder;
+ }
+
+ void clearEncoder()
+ {
+ if (m_encoder.isValid()) {
+ m_encoder.callMethod<void>("cancel", "()V");
+ m_encoder = QJniObject();
+ if (m_env->ExceptionCheck()) {
+ warnFile << "JNI exception occurred cancelling encoder";
+ m_env->ExceptionDescribe();
+ m_env->ExceptionClear();
+ }
+ }
+ }
+
+ bool checkObject(const QString &title, QJniObject &obj)
+ {
+ if (checkException(title)) {
+ return true;
+ } else if (!obj.isValid()) {
+ setInternalErrorMessage(QStringLiteral("JNI object %1 invalid").arg(title));
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ bool checkResult(const QString &title, int result)
+ {
+ if (checkException(title)) {
+ return true;
+ } else if (result == STATUS_ERROR_START_ENCODER) {
+ warnFile << "Start encoder error" << result;
+ if (m_outErrorMessage) {
+ *m_outErrorMessage = i18n("Unsupported video parameters, try lowering the video FPS or size");
+ }
+ return true;
+ } else if (result == STATUS_ERROR_DRAIN_MUXER_ADD_TRACK) {
+ warnFile << "Muxer track error" << result;
+ if (m_outErrorMessage) {
+ *m_outErrorMessage = i18n("Unsupported format");
+ }
+ return true;
+ } else if (isErrorResult(result)) {
+ setInternalErrorMessage(QStringLiteral("%1 failed with code %2").arg(title).arg(result));
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ bool checkException(const QString &title)
+ {
+ if (m_env->ExceptionCheck()) {
+ setInternalErrorMessage(QStringLiteral("JNI exception in %1").arg(title));
+ m_env->ExceptionDescribe();
+ m_env->ExceptionClear();
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ SwsContext *getSwsContextFor(int inputWidth,
+ int inputHeight,
+ AVPixelFormat inputFormat,
+ int outputWidth,
+ int outputHeight,
+ AVPixelFormat outputFormat,
+ int flags)
+ {
+ return sws_getCachedContext(m_swsContext,
+ inputWidth,
+ inputHeight,
+ inputFormat,
+ outputWidth,
+ outputHeight,
+ outputFormat,
+ flags,
+ nullptr,
+ nullptr,
+ nullptr);
+ }
+
+ int imageFormat() const
+ {
+ return m_imageFormat;
+ }
+
+ uint8_t **imageBuffers()
+ {
+ return m_imageBuffers;
+ }
+
+ int *imageLinesizes()
+ {
+ return m_imageLinesizes;
+ }
+
+ bool allocateImage(int outputWidth, int outputHeight, AVPixelFormat outputFormat)
+ {
+ // The Android encoder really shouldn't be changing
+ // pixel formats along the way, but just in case.
+ if (m_imageFormat != AV_PIX_FMT_NONE) {
+ m_imageFormat = AV_PIX_FMT_NONE;
+ av_freep(&m_imageBuffers[0]);
+ }
+
+ int result = av_image_alloc(m_imageBuffers, m_imageLinesizes, outputWidth, outputHeight, outputFormat, 32);
+ if (result >= 0) {
+ m_imageFormat = outputFormat;
+ return true;
+ } else {
+ setInternalErrorMessage(QStringLiteral("av_image_alloc error %1").arg(result));
+ return false;
+ }
+ }
+
+ void setInternalErrorMessage(const QString &detail)
+ {
+ warnFile << "Media encoder error:" << detail;
+ if (m_outErrorMessage) {
+ // Internal encoder errors are only really useful for developers,
+ // so there's no point in translating them.
+ *m_outErrorMessage = i18n("Internal error (%1)", detail);
+ }
+ }
+
+private:
+ static bool isErrorResult(int result)
+ {
+ return result >= 100;
+ }
+
+ QJniEnvironment m_env;
+ QJniObject m_encoder;
+ QString *m_outErrorMessage;
+ SwsContext *m_swsContext = nullptr;
+ uint8_t *m_imageBuffers[4] = {nullptr, nullptr, nullptr, nullptr};
+ int m_imageLinesizes[4] = {0, 0, 0, 0};
+ AVPixelFormat m_imageFormat = AV_PIX_FMT_NONE;
+};
+
+KisAndroidMediaEncoderRunnable *KisAndroidMediaEncoderRunnable::create(const KisMediaEncoderWrapperSettings &settings,
+ QObject *parent)
+{
+ if (settings.format->type() == KisMediaEncoderFormat::Type::AndroidMediaEncoder) {
+ return new KisAndroidMediaEncoderRunnable(settings, parent);
+ } else {
+ return nullptr;
+ }
+}
+
+void KisAndroidMediaEncoderRunnable::getSupportedFormats(QVector<KisMediaEncoderFormat *> &outSupportedFormats)
+{
+ Context ctx;
+ int formatIds[] = {FORMAT_MP4_H264, FORMAT_WEBM_VP8, FORMAT_MP4_AV1};
+ for (int formatId : formatIds) {
+ checkFormatSupport(ctx, formatId, outSupportedFormats);
+ }
+}
+
+KisAndroidMediaEncoderRunnable::KisAndroidMediaEncoderRunnable(const KisMediaEncoderWrapperSettings &settings,
+ QObject *parent)
+ : KisMediaEncoderRunnable(settings, parent)
+{
+}
+
+KisMediaEncoderRunnable::EncodeResult KisAndroidMediaEncoderRunnable::encode(QString &outErrorMessage)
+{
+ Format *format = static_cast<Format *>(settings().format);
+
+ QTemporaryFile tempFile;
+ QString tempFilePath;
+ if (tempFile.open()) {
+ tempFilePath = tempFile.fileName();
+ tempFile.close();
+ } else {
+ warnFile << "Failed to open temporary file:" << tempFile.errorString();
+ // Keep going, we might not actually need a temporary file.
+ }
+
+ Context ctx(&outErrorMessage);
+ int outputWidth = settings().outputSize.width();
+ int outputHeight = settings().outputSize.height();
+
+ // Set up the encoder.
+ {
+ QJniObject outputPath = QJniObject::fromString(settings().outputFile);
+ if (ctx.checkObject(QStringLiteral("outputPath"), outputPath)) {
+ return EncodeResult::Failed;
+ }
+
+ QJniObject tempPath = QJniObject::fromString(tempFilePath);
+ if (ctx.checkObject(QStringLiteral("tempPath"), tempPath)) {
+ return EncodeResult::Failed;
+ }
+
+ QJniObject encoderName = QJniObject::fromString(format->getEncoderPreference(settings().formatPreferences));
+ ctx.checkException(QStringLiteral("encoderName"));
+
+ ctx.setEncoder(QJniObject("org/krita/android/VideoEncoder",
+ "(IIIFLjava/lang/String;Ljava/lang/String;Ljava/lang/String;I)V",
+ jint(format->formatId()),
+ jint(outputWidth),
+ jint(outputHeight),
+ jfloat(settings().outputFps),
+ outputPath.object<jstring>(),
+ tempPath.object<jstring>(),
+ encoderName.object<jstring>(),
+ jint(format->getBitratePreference(settings().formatPreferences))));
+ if (ctx.checkObject(QStringLiteral("encoder"), ctx.encoder())) {
+ return EncodeResult::Failed;
+ }
+ }
+
+ if (isCancelled()) {
+ return EncodeResult::Cancelled;
+ }
+
+ // Start the encoding.
+ {
+ QJniObject activity = QJniObject::callStaticObjectMethod("org/qtproject/qt5/android/QtNative",
+ "activity",
+ "()Landroid/app/Activity;");
+ if (ctx.checkObject(QStringLiteral("activity"), activity)) {
+ return EncodeResult::Failed;
+ }
+
+ int startResult =
+ int(ctx.encoder().callMethod<jint>("start", "(Landroid/content/Context;)I", activity.object<jobject>()));
+ if (ctx.checkResult(QStringLiteral("start"), startResult)) {
+ return EncodeResult::Failed;
+ }
+ }
+
+ // Encode the frames.
+ Frame frame;
+ while (nextFrame(frame)) {
+ if (isCancelled()) {
+ return EncodeResult::Cancelled;
+ }
+
+ // Grab the next frame from disk.
+ QImage inputImage;
+ AVPixelFormat inputPixelFormat;
+ if (frame.readImage(inputImage)) {
+ switch (inputImage.format()) {
+ case QImage::Format_RGB32:
+ inputPixelFormat = AV_PIX_FMT_BGR0;
+ break;
+ case QImage::Format_ARGB32:
+ inputPixelFormat = AV_PIX_FMT_BGRA;
+ break;
+ default:
+ // The above are the only formats I can get the the recorder to
+ // produce, so I'm not gonna get experimental with this.
+ inputPixelFormat = AV_PIX_FMT_BGRA;
+ inputImage = inputImage.convertToFormat(QImage::Format_ARGB32);
+ if (inputImage.isNull()) {
+ warnFile << "Frame conversion from" << inputImage.format() << "failed";
+ continue;
+ }
+ break;
+ }
+ } else {
+ continue; // Keep going, some frames may be corrupted.
+ }
+
+ int instances = frame.instances();
+ for (int i = 0; i < instances; ++i) {
+ // Grab a buffer from the encoder.
+ EncodeResult prepareResult = prepare(ctx);
+ if (prepareResult != EncodeResult::Completed) {
+ return prepareResult;
+ }
+
+ // Retrieve the buffer layout.
+ EncoderImage encoderImage;
+ if (!readEncoderImage(ctx, encoderImage)) {
+ return EncodeResult::Failed;
+ }
+
+ // Map the buffer layout to a libswscale-befitting arrangement. The
+ // layout may either have the YUV components in separate buffers or it
+ // may have the U and V components combined into a single buffer, where
+ // either U or V can come first. Which one we get depends on hardware.
+ uint8_t *dstBuffers[4] = {nullptr, nullptr, nullptr, nullptr};
+ int dstLinesizes[4] = {0, 0, 0, 0};
+ AVPixelFormat outputPixelFormat;
+ if (encoderImage.pixelStrideU == 1 && encoderImage.pixelStrideV == 1) {
+ // Separate Y, U and V buffers.
+ outputPixelFormat = AV_PIX_FMT_YUV420P;
+ dstBuffers[0] = encoderImage.bufferY;
+ dstBuffers[1] = encoderImage.bufferU;
+ dstBuffers[2] = encoderImage.bufferV;
+ dstLinesizes[0] = encoderImage.rowStrideY;
+ dstLinesizes[1] = encoderImage.rowStrideU;
+ dstLinesizes[2] = encoderImage.rowStrideV;
+
+ } else if (encoderImage.pixelStrideU == 2 && encoderImage.pixelStrideV == 2
+ && encoderImage.bufferU + 1 == encoderImage.bufferV) {
+ // One Y buffer and one combined UV buffer, U comes first.
+ outputPixelFormat = AV_PIX_FMT_NV12;
+ dstBuffers[0] = encoderImage.bufferY;
+ dstBuffers[1] = encoderImage.bufferU;
+ dstLinesizes[0] = encoderImage.rowStrideY;
+ dstLinesizes[1] = encoderImage.rowStrideU;
+
+ } else if (encoderImage.pixelStrideU == 2 && encoderImage.pixelStrideV == 2
+ && encoderImage.bufferV + 1 == encoderImage.bufferU) {
+ // One Y buffer and one combined UV buffer, V comes first.
+ outputPixelFormat = AV_PIX_FMT_NV21;
+ dstBuffers[0] = encoderImage.bufferY;
+ dstBuffers[1] = encoderImage.bufferV;
+ dstLinesizes[0] = encoderImage.rowStrideY;
+ dstLinesizes[1] = encoderImage.rowStrideV;
+
+ } else {
+ ctx.setInternalErrorMessage(QStringLiteral("unknown buffer format u%1/%2 v%3/%4")
+ .arg(quintptr(encoderImage.bufferU), 0, 16)
+ .arg(encoderImage.pixelStrideU)
+ .arg(quintptr(encoderImage.bufferV), 0, 16)
+ .arg(encoderImage.pixelStrideV));
+ return EncodeResult::Failed;
+ }
+
+ SwsContext *swsContext = ctx.getSwsContextFor(inputImage.width(),
+ inputImage.height(),
+ inputPixelFormat,
+ outputWidth,
+ outputHeight,
+ outputPixelFormat,
+ SWS_FAST_BILINEAR);
+ if (!swsContext) {
+ ctx.setInternalErrorMessage(QStringLiteral("sws_getCachedContext"));
+ return EncodeResult::Failed;
+ }
+
+ if (instances == 1) {
+ // Just a single frame, scale it into the native buffer.
+ const uint8_t *srcBuffers[] = {inputImage.bits(), nullptr, nullptr, nullptr};
+ const int srcLinesizes[] = {inputImage.bytesPerLine(), 0, 0, 0};
+ sws_scale(swsContext, srcBuffers, srcLinesizes, 0, inputImage.height(), dstBuffers, dstLinesizes);
+
+ } else {
+ // Repeated frame, scale it into an intermediate buffer, then
+ // copy it over to the native one for each instance.
+ if (i == 0 || ctx.imageFormat() != outputPixelFormat) {
+ if (ctx.imageFormat() != outputPixelFormat) {
+ if (!ctx.allocateImage(outputWidth, outputHeight, outputPixelFormat)) {
+ warnFile << "Encoder changed pixel format from" << int(ctx.imageFormat()) << "to"
+ << int(outputPixelFormat);
+ return EncodeResult::Failed;
+ }
+ }
+
+ const uint8_t *srcBuffers[] = {inputImage.bits(), nullptr, nullptr, nullptr};
+ const int srcLinesizes[] = {inputImage.bytesPerLine(), 0, 0, 0};
+ sws_scale(swsContext,
+ srcBuffers,
+ srcLinesizes,
+ 0,
+ inputImage.height(),
+ ctx.imageBuffers(),
+ ctx.imageLinesizes());
+ }
+
+ av_image_copy2(dstBuffers,
+ dstLinesizes,
+ ctx.imageBuffers(),
+ ctx.imageLinesizes(),
+ outputPixelFormat,
+ outputWidth,
+ outputHeight);
+ }
+
+ int commitResult = int(ctx.encoder().callMethod<jint>("commit", "()I"));
+ if (ctx.checkResult(QStringLiteral("commit"), commitResult)) {
+ return EncodeResult::Failed;
+ }
+ }
+ }
+
+ if (isCancelled()) {
+ return EncodeResult::Cancelled;
+ }
+
+ // Finish the encoder stream.
+ {
+ // Need a buffer from the encoder to tell it that it's done.
+ EncodeResult prepareResult = prepare(ctx);
+ if (prepareResult != EncodeResult::Completed) {
+ return prepareResult;
+ }
+ // Hand an empty buffer back with the end of stream flag set.
+ int finishResult = int(ctx.encoder().callMethod<jint>("finish", "()I"));
+ if (ctx.checkResult(QStringLiteral("finish"), finishResult)) {
+ return EncodeResult::Failed;
+ }
+ }
+
+ // Drain all remaining frames out of the encoder.
+ while (true) {
+ int drainResult = drain(ctx, 1000000LL);
+ if (drainResult == DRAIN_END_OF_STREAM) {
+ break;
+ } else if (drainResult == DRAIN_ERROR) {
+ return EncodeResult::Failed;
+ } else if (drainResult == DRAIN_CANCELLED) {
+ return EncodeResult::Cancelled;
+ } else {
+ KIS_SAFE_ASSERT_RECOVER_NOOP(drainResult >= 0);
+ }
+ }
+
+ // Close the encoder, copy the temporary file to the output file if needed.
+ {
+ int closeResult = int(ctx.encoder().callMethod<jint>("close", "()I"));
+ if (ctx.checkResult(QStringLiteral("close"), closeResult)) {
+ return EncodeResult::Failed;
+ }
+
+ if (isCancelled()) {
+ return EncodeResult::Cancelled;
+ }
+
+ if (closeResult == STATUS_NEEDS_COPY) {
+ if (!copyTemporaryToOutputFile(ctx, tempFilePath, settings().outputFile)) {
+ return EncodeResult::Failed;
+ }
+ }
+ }
+
+ return EncodeResult::Completed;
+}
+
+KisMediaEncoderRunnable::EncodeResult KisAndroidMediaEncoderRunnable::prepare(Context &ctx)
+{
+ while (true) {
+ if (isCancelled()) {
+ return EncodeResult::Cancelled;
+ }
+
+ int prepareResult = int(ctx.encoder().callMethod<jint>("prepare", "(J)I", jlong(100000LL)));
+ if (ctx.checkResult(QStringLiteral("prepare"), prepareResult)) {
+ return EncodeResult::Failed;
+
+ } else if (prepareResult == STATUS_TIMEOUT) {
+ int drainResult = drain(ctx, 0LL);
+ if (drainResult == DRAIN_END_OF_STREAM) {
+ ctx.setInternalErrorMessage(QStringLiteral("unexpected end of stream"));
+ return EncodeResult::Failed;
+ } else if (drainResult == DRAIN_ERROR) {
+ return EncodeResult::Failed;
+ } else if (drainResult == DRAIN_CANCELLED) {
+ return EncodeResult::Cancelled;
+ } else {
+ KIS_SAFE_ASSERT_RECOVER_NOOP(drainResult >= 0);
+ }
+
+ } else {
+ KIS_SAFE_ASSERT_RECOVER_NOOP(prepareResult == STATUS_OK);
+ break;
+ }
+ }
+ return EncodeResult::Completed;
+}
+
+int KisAndroidMediaEncoderRunnable::drain(Context &ctx, long long initialTimeout)
+{
+ int count = 0;
+ long long timeout = initialTimeout;
+ while (true) {
+ if (isCancelled()) {
+ return DRAIN_CANCELLED;
+ }
+
+ int drainResult = int(ctx.encoder().callMethod<jint>("drain", "(J)I", jlong(timeout)));
+
+ if (ctx.checkResult(QStringLiteral("drain"), drainResult)) {
+ return DRAIN_ERROR;
+
+ } else if (drainResult == STATUS_TIMEOUT) {
+ break;
+
+ } else if (drainResult == STATUS_END_OF_STREAM) {
+ return DRAIN_END_OF_STREAM;
+
+ } else {
+ KIS_SAFE_ASSERT_RECOVER_NOOP(drainResult == STATUS_OK);
+ ++count;
+ }
+ }
+ return count;
+}
+
+bool KisAndroidMediaEncoderRunnable::copyTemporaryToOutputFile(Context &ctx,
+ const QString &tempPath,
+ const QString &outputPath)
+{
+ QFile tempFile(tempPath);
+ if (!tempFile.open(QIODevice::ReadOnly)) {
+ ctx.setInternalErrorMessage(
+ QStringLiteral("failed to open temp file '%1': %2").arg(tempPath).arg(tempFile.errorString()));
+ return false;
+ }
+
+ QFile outputFile(outputPath);
+ if (!outputFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
+ ctx.setInternalErrorMessage(
+ QStringLiteral("failed to open output file '%1': %2").arg(outputPath).arg(outputFile.errorString()));
+ return false;
+ }
+
+ QByteArray buffer;
+ buffer.resize(BUFSIZ);
+ while (true) {
+ qint64 read = tempFile.read(buffer.data(), BUFSIZ);
+ if (read < 0) {
+ ctx.setInternalErrorMessage(
+ QStringLiteral("failed to read from temp file '%1': %2").arg(tempPath).arg(tempFile.errorString()));
+ return false;
+ } else if (read > 0) {
+ qint64 written = outputFile.write(buffer, read);
+ if (written < 0) {
+ ctx.setInternalErrorMessage(QStringLiteral("failed to write %1 byte(s) to output file '%2': %3")
+ .arg(read)
+ .arg(outputPath)
+ .arg(outputFile.errorString()));
+ return false;
+ } else if (written != read) {
+ ctx.setInternalErrorMessage(
+ QStringLiteral("tried to write %1 byte(s) to output file '%2', but only wrote %3")
+ .arg(read)
+ .arg(outputPath)
+ .arg(written));
+ return false;
+ }
+ } else {
+ if (outputFile.flush()) {
+ return true;
+ } else {
+ ctx.setInternalErrorMessage(QStringLiteral("failed to flush output file '%1': %2")
+ .arg(outputPath)
+ .arg(outputFile.errorString()));
+ return false;
+ }
+ }
+ }
+}
+
+bool KisAndroidMediaEncoderRunnable::readEncoderImage(Context &ctx, EncoderImage &outImage)
+{
+ return readPlaneBuffer(ctx, 0, outImage.bufferY) && readPlaneBuffer(ctx, 1, outImage.bufferU)
+ && readPlaneBuffer(ctx, 2, outImage.bufferV) && readPlaneRowStride(ctx, 0, outImage.rowStrideY)
+ && readPlaneRowStride(ctx, 1, outImage.rowStrideU) && readPlaneRowStride(ctx, 2, outImage.rowStrideV)
+ && readPlanePixelStride(ctx, 1, outImage.pixelStrideU) && readPlanePixelStride(ctx, 2, outImage.pixelStrideV);
+}
+
+bool KisAndroidMediaEncoderRunnable::readPlaneBuffer(Context &ctx, int index, uint8_t *&outBuffer)
+{
+ QJniObject plane =
+ ctx.encoder().callObjectMethod("getInputImagePlaneBuffer", "(I)Ljava/nio/ByteBuffer;", jint(index));
+ if (ctx.checkObject(QStringLiteral("plane"), plane)) {
+ return false;
+ }
+
+ uint8_t *buffer = static_cast<uint8_t *>(ctx.env()->GetDirectBufferAddress(plane.object<jobject>()));
+ if (!buffer) {
+ ctx.setInternalErrorMessage(QStringLiteral("null plane buffer %1").arg(index));
+ return false;
+ }
+
+ outBuffer = buffer;
+ return true;
+}
+
+bool KisAndroidMediaEncoderRunnable::readPlaneRowStride(Context &ctx, int index, int &outRowStride)
+{
+ jint rowStride = ctx.encoder().callMethod<jint>("getInputImagePlaneRowStride", "(I)I", jint(index));
+ if (ctx.checkException(QStringLiteral("rowStride"))) {
+ return false;
+ } else if (rowStride <= 0) {
+ ctx.setInternalErrorMessage(QStringLiteral("invalid row stride %1: %2").arg(index).arg(rowStride));
+ return false;
+ }
+
+ outRowStride = int(rowStride);
+ return true;
+}
+
+bool KisAndroidMediaEncoderRunnable::readPlanePixelStride(Context &ctx, int index, int &outPixelStride)
+{
+ jint pixelStride = ctx.encoder().callMethod<jint>("getInputImagePlanePixelStride", "(I)I", jint(index));
+ if (ctx.checkException(QStringLiteral("pixelStride"))) {
+ return false;
+ } else if (pixelStride <= 0) {
+ ctx.setInternalErrorMessage(QStringLiteral("invalid pixel stride %1: %2").arg(index).arg(pixelStride));
+ return false;
+ }
+
+ outPixelStride = int(pixelStride);
+ return true;
+}
+
+void KisAndroidMediaEncoderRunnable::checkFormatSupport(Context &ctx,
+ int formatId,
+ QVector<KisMediaEncoderFormat *> &outSupportedFormats)
+{
+ QJniObject supports = QJniObject::callStaticObjectMethod("org/krita/android/VideoEncoder",
+ "getSupportsForFormat",
+ "(I)Ljava/util/List;",
+ jint(formatId));
+ if (ctx.checkObject(QStringLiteral("supports"), supports)) {
+ return;
+ }
+
+ jint count = supports.callMethod<jint>("size", "()I");
+ if (ctx.checkException(QStringLiteral("size"))) {
+ return;
+ }
+
+ QVector<Format::Encoder> encoders;
+ for (jint i = 0; i < count; ++i) {
+ QJniObject entry = supports.callObjectMethod("get", "(I)Ljava/lang/Object;", i);
+ if (ctx.checkObject(QStringLiteral("entry"), entry)) {
+ continue;
+ }
+
+ QJniObject name = entry.getObjectField("name", "Ljava/lang/String;");
+ if (ctx.checkObject(QStringLiteral("name"), name)) {
+ continue;
+ }
+
+ QString nameString = name.toString();
+ if (ctx.checkException(QStringLiteral("nameString")) || nameString.isEmpty()) {
+ continue;
+ }
+
+ bool hardware = entry.getField<jboolean>("hardware");
+ if (ctx.checkException(QStringLiteral("hardware"))) {
+ continue;
+ }
+
+ encoders.append({nameString, hardware});
+ }
+
+ if (!encoders.isEmpty()) {
+ outSupportedFormats.append(new Format(formatId, encoders));
+ }
+}
+
+KisAndroidMediaEncoderPreferencesWidget::KisAndroidMediaEncoderPreferencesWidget(QWidget *parent)
+ : QWidget(parent)
+{
+ QFormLayout *form = new QFormLayout(this);
+
+ m_cmbEncoder = new QComboBox;
+ form->addRow(i18n("Encoder:"), m_cmbEncoder);
+
+ m_intBitrate = new QSpinBox;
+ m_intBitrate->setRange(1, 999999999);
+ form->addRow(i18n("Bitrate:"), m_intBitrate);
+}
+
+void KisAndroidMediaEncoderPreferencesWidget::addEncoderOption(const QString &title, const QString &key)
+{
+ m_cmbEncoder->addItem(title, QVariant(key));
+}
+
+QString KisAndroidMediaEncoderPreferencesWidget::encoder() const
+{
+ return m_cmbEncoder->currentData().toString();
+}
+
+void KisAndroidMediaEncoderPreferencesWidget::setEncoder(const QString &key)
+{
+ int index = 0;
+ int count = m_cmbEncoder->count();
+ for (int i = 0; i < count; ++i) {
+ if (m_cmbEncoder->itemData(i).toString() == key) {
+ index = i;
+ break;
+ }
+ }
+ m_cmbEncoder->setCurrentIndex(index);
+}
+
+int KisAndroidMediaEncoderPreferencesWidget::bitrate() const
+{
+ return m_intBitrate->value();
+}
+
+void KisAndroidMediaEncoderPreferencesWidget::setBitrate(int bitrate)
+{
+ m_intBitrate->setValue(bitrate);
+}
diff --git a/libs/ui/animation/KisAndroidMediaEncoderRunnable.h b/libs/ui/animation/KisAndroidMediaEncoderRunnable.h
new file mode 100644
index 00000000000..482225ffc48
--- /dev/null
+++ b/libs/ui/animation/KisAndroidMediaEncoderRunnable.h
@@ -0,0 +1,80 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef KISANDROIDMEDIAENCODERRUNNABLE
+#define KISANDROIDMEDIAENCODERRUNNABLE
+
+#include "KisMediaEncoderWrapper.h"
+#include <QWidget>
+
+class QComboBox;
+class QSpinBox;
+
+class KisAndroidMediaEncoderRunnable : public KisMediaEncoderRunnable
+{
+public:
+ static KisAndroidMediaEncoderRunnable *create(const KisMediaEncoderWrapperSettings &settings,
+ QObject *parent = nullptr);
+
+ static void getSupportedFormats(QVector<KisMediaEncoderFormat *> &outSupportedFormats);
+
+protected:
+ KisAndroidMediaEncoderRunnable(const KisMediaEncoderWrapperSettings &settings, QObject *parent);
+
+ EncodeResult encode(QString &outErrorMessage) override;
+
+private:
+ static constexpr int DRAIN_END_OF_STREAM = -1;
+ static constexpr int DRAIN_ERROR = -2;
+ static constexpr int DRAIN_CANCELLED = -3;
+ // Keep these formats in sync with VideoEncoder.java!
+ static constexpr int FORMAT_MP4_H264 = 0;
+ static constexpr int FORMAT_WEBM_VP8 = 1;
+ static constexpr int FORMAT_MP4_AV1 = 2;
+ // These statuses too!
+ static constexpr int STATUS_OK = 0;
+ static constexpr int STATUS_TIMEOUT = 1;
+ static constexpr int STATUS_END_OF_STREAM = 2;
+ static constexpr int STATUS_NEEDS_COPY = 3;
+ static constexpr int STATUS_ERROR_START_ENCODER = 104;
+ static constexpr int STATUS_ERROR_DRAIN_MUXER_ADD_TRACK = 404;
+
+ struct EncoderImage;
+ class Format;
+ class Context;
+
+ EncodeResult prepare(Context &ctx);
+
+ // Returns number of frames drained or one of the DRAIN_* values above.
+ int drain(Context &ctx, long long initialTimeout);
+
+ bool copyTemporaryToOutputFile(Context &ctx, const QString &tempPath, const QString &outputPath);
+
+ static bool readEncoderImage(Context &ctx, EncoderImage &outImage);
+ static bool readPlaneBuffer(Context &ctx, int index, uint8_t *&outBuffer);
+ static bool readPlaneRowStride(Context &ctx, int index, int &outRowStride);
+ static bool readPlanePixelStride(Context &ctx, int index, int &outPixelStride);
+
+ static void checkFormatSupport(Context &ctx, int formatId, QVector<KisMediaEncoderFormat *> &outSupportedFormats);
+};
+
+class KisAndroidMediaEncoderPreferencesWidget : public QWidget
+{
+ Q_OBJECT
+public:
+ explicit KisAndroidMediaEncoderPreferencesWidget(QWidget *parent = nullptr);
+
+ void addEncoderOption(const QString &title, const QString &key);
+
+ QString encoder() const;
+ void setEncoder(const QString &key);
+
+ int bitrate() const;
+ void setBitrate(int bitrate);
+
+private:
+ QComboBox *m_cmbEncoder;
+ QSpinBox *m_intBitrate;
+};
+
+#endif
diff --git a/libs/ui/animation/KisMediaEncoderFormatPreferencesDialog.cpp b/libs/ui/animation/KisMediaEncoderFormatPreferencesDialog.cpp
new file mode 100644
index 00000000000..ac52953ee55
--- /dev/null
+++ b/libs/ui/animation/KisMediaEncoderFormatPreferencesDialog.cpp
@@ -0,0 +1,43 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include "KisMediaEncoderFormatPreferencesDialog.h"
+
+#include <QDialogButtonBox>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+#include "KisMediaEncoderWrapper.h"
+
+KisMediaEncoderPreferencesDialog::KisMediaEncoderPreferencesDialog(KisMediaEncoderFormat *format,
+ const QVariantMap &preferences,
+ QWidget *parent)
+ : QDialog(parent)
+ , m_format(format)
+{
+ resize(400, 300);
+ QVBoxLayout *dlgLayout = new QVBoxLayout(this);
+
+ m_widget = format->createPreferencesWidget(preferences);
+ dlgLayout->addWidget(m_widget, 1);
+
+ QDialogButtonBox *buttons =
+ new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel | QDialogButtonBox::Reset);
+ dlgLayout->addWidget(buttons);
+ connect(buttons, &QDialogButtonBox::accepted, this, &KisMediaEncoderPreferencesDialog::accept);
+ connect(buttons, &QDialogButtonBox::rejected, this, &KisMediaEncoderPreferencesDialog::reject);
+ connect(buttons->button(QDialogButtonBox::Reset),
+ &QPushButton::clicked,
+ this,
+ &KisMediaEncoderPreferencesDialog::slotReset);
+}
+
+QVariant KisMediaEncoderPreferencesDialog::preferences() const
+{
+ return m_format->getPreferencesFromWidget(m_widget);
+}
+
+void KisMediaEncoderPreferencesDialog::slotReset()
+{
+ m_format->resetPreferencesWidget(m_widget);
+}
diff --git a/libs/ui/animation/KisMediaEncoderFormatPreferencesDialog.h b/libs/ui/animation/KisMediaEncoderFormatPreferencesDialog.h
new file mode 100644
index 00000000000..b218047de0e
--- /dev/null
+++ b/libs/ui/animation/KisMediaEncoderFormatPreferencesDialog.h
@@ -0,0 +1,31 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef KISMEDIAENCODERPREFERENCESDIALOG
+#define KISMEDIAENCODERPREFERENCESDIALOG
+
+#include <QDialog>
+#include <QVariantMap>
+
+#include <kritaui_export.h>
+
+class KisMediaEncoderFormat;
+
+class KRITAUI_EXPORT KisMediaEncoderPreferencesDialog : public QDialog
+{
+public:
+ explicit KisMediaEncoderPreferencesDialog(KisMediaEncoderFormat *format,
+ const QVariantMap &preferences,
+ QWidget *parent = nullptr);
+
+ QVariant preferences() const;
+
+private Q_SLOTS:
+ void slotReset();
+
+private:
+ KisMediaEncoderFormat *m_format;
+ QWidget *m_widget;
+};
+
+#endif
diff --git a/libs/ui/animation/KisMediaEncoderWrapper.cpp b/libs/ui/animation/KisMediaEncoderWrapper.cpp
new file mode 100644
index 00000000000..67c8c95a613
--- /dev/null
+++ b/libs/ui/animation/KisMediaEncoderWrapper.cpp
@@ -0,0 +1,242 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#include <QAtomicInt>
+#include <QImage>
+#include <QImageReader>
+#include <QRunnable>
+#include <QThreadPool>
+
+#include <functional>
+
+#include <klocalizedstring.h>
+
+#include "KisMediaEncoderWrapper.h"
+
+#include <kis_assert.h>
+#include <kis_debug.h>
+
+#ifdef Q_OS_ANDROID
+#include "KisAndroidMediaEncoderRunnable.h"
+#endif
+
+void KisMediaEncoderRunnable::slotHandleCancelRequested()
+{
+ m_cancel = true;
+}
+
+void KisMediaEncoderRunnable::run()
+{
+ Q_EMIT sigStarted();
+ QString errorMessage;
+ EncodeResult result = prepareAndEncode(errorMessage);
+ if (result == EncodeResult::Completed) {
+ Q_EMIT sigCompleted();
+ } else if (result == EncodeResult::Cancelled) {
+ Q_EMIT sigCancelled();
+ } else {
+ Q_EMIT sigFailed(errorMessage);
+ }
+}
+
+bool KisMediaEncoderRunnable::Frame::readImage(QImage &outImage) const
+{
+ QImageReader reader(m_path);
+ if (reader.read(&outImage)) {
+ // Should be non-null on success, but we'll guard against mayhem.
+ if (outImage.isNull() || outImage.size().isEmpty()) {
+ warnFile.nospace() << "Got null image reading frame from file '" << m_path << "'";
+ return false;
+ } else {
+ return true;
+ }
+ } else {
+ warnFile.nospace() << "Error " << reader.error() << " reading frame from file '" << m_path
+ << "': " << reader.errorString();
+ // Again, should be null anyway, but let's be extra sure.
+ outImage = QImage();
+ return false;
+ }
+}
+
+KisMediaEncoderRunnable::KisMediaEncoderRunnable(const KisMediaEncoderWrapperSettings &settings, QObject *parent)
+ : QObject(parent)
+ , m_settings(settings)
+{
+}
+
+bool KisMediaEncoderRunnable::nextFrame(Frame &outFrame)
+{
+ int inputFileCount = m_settings.inputFiles.size();
+ if (inputFileCount == 0) {
+ return false;
+ }
+
+ int lastIndex = inputFileCount - 1;
+ if (m_needsPreviewBefore) {
+ m_needsPreviewBefore = false;
+ int seconds = m_settings.firstFrameSec;
+ if (seconds > 0) {
+ outFrame = Frame(m_settings.inputFiles[lastIndex], seconds * m_settings.outputFps);
+ return true;
+ }
+ }
+
+ double inputFrameDuration = 1.0 / double(m_settings.inputFps);
+ double outputFrameDuration = 1.0 / double(m_settings.outputFps);
+ while (m_inputFileIndex < inputFileCount) {
+ int fileIndex = m_inputFileIndex;
+ ++m_inputFileIndex;
+
+ int instances = 0;
+ double nextInputTime = double(m_inputFileIndex) * inputFrameDuration;
+ while (m_inputTime < nextInputTime) {
+ ++instances;
+ m_inputTime += outputFrameDuration;
+ }
+
+ if (instances != 0) {
+ m_outputFrameNo += instances;
+ Q_EMIT sigProgressUpdated(m_outputFrameNo);
+
+ // If we're on the last frame, we can append the linger time.
+ if (fileIndex == lastIndex && m_needsLingerAfter) {
+ m_needsLingerAfter = false;
+ instances += qMax(0, m_settings.lastFrameSec) * m_settings.outputFps;
+ }
+
+ outFrame = Frame(m_settings.inputFiles[fileIndex], instances);
+ return true;
+ }
+ }
+
+ if (m_needsLingerAfter) {
+ m_needsLingerAfter = false;
+ int seconds = m_settings.lastFrameSec;
+ if (seconds > 0) {
+ outFrame = Frame(m_settings.inputFiles[lastIndex], seconds * m_settings.outputFps);
+ return true;
+ }
+ }
+
+ return false;
+}
+
+KisMediaEncoderRunnable::EncodeResult KisMediaEncoderRunnable::prepareAndEncode(QString &outErrorMessage)
+{
+ if (m_cancel) {
+ return EncodeResult::Cancelled;
+ }
+
+ if (m_settings.inputFiles.isEmpty()) {
+ outErrorMessage = i18n("No frame files found");
+ return EncodeResult::Failed;
+ }
+
+ m_inputFileIndex = 0;
+ return encode(outErrorMessage);
+}
+
+KisMediaEncoderWrapper::KisMediaEncoderWrapper(QObject *parent)
+ : QObject(parent)
+{
+}
+
+KisMediaEncoderWrapper::~KisMediaEncoderWrapper()
+{
+ reset();
+}
+
+void KisMediaEncoderWrapper::startNonBlocking(const KisMediaEncoderWrapperSettings &settings)
+{
+ reset();
+
+ KisMediaEncoderRunnable *runnable = makeSupportedRunnable(settings);
+ if (runnable) {
+ connect(runnable,
+ &KisMediaEncoderRunnable::sigStarted,
+ this,
+ &KisMediaEncoderWrapper::sigStarted,
+ Qt::QueuedConnection);
+ connect(runnable,
+ &KisMediaEncoderRunnable::sigCompleted,
+ this,
+ &KisMediaEncoderWrapper::sigFinished,
+ Qt::QueuedConnection);
+ connect(runnable,
+ &KisMediaEncoderRunnable::sigCancelled,
+ this,
+ &KisMediaEncoderWrapper::sigFinished,
+ Qt::QueuedConnection);
+ connect(runnable,
+ &KisMediaEncoderRunnable::sigFailed,
+ this,
+ &KisMediaEncoderWrapper::sigFinishedWithError,
+ Qt::QueuedConnection);
+ connect(runnable,
+ &KisMediaEncoderRunnable::sigProgressUpdated,
+ this,
+ &KisMediaEncoderWrapper::sigProgressUpdated,
+ Qt::QueuedConnection);
+ connect(this,
+ &KisMediaEncoderWrapper::sigCancelRequested,
+ runnable,
+ &KisMediaEncoderRunnable::slotHandleCancelRequested,
+ Qt::QueuedConnection);
+
+ m_runnable = runnable;
+ runnable->setAutoDelete(true);
+ QThreadPool::globalInstance()->start(runnable);
+
+ } else {
+ Q_EMIT sigFinishedWithError(i18n("No encoder for the selected format found"));
+ }
+}
+
+void KisMediaEncoderWrapper::reset()
+{
+ KisMediaEncoderRunnable *runnable = m_runnable.data();
+ if (runnable) {
+ m_runnable.clear();
+ Q_EMIT sigCancelRequested();
+ disconnect(runnable);
+ }
+}
+
+const QVector<KisMediaEncoderFormat *> &KisMediaEncoderWrapper::getSupportedFormats()
+{
+ static QVector<KisMediaEncoderFormat *> *supportedFormats;
+ if (!supportedFormats) {
+ supportedFormats = new QVector<KisMediaEncoderFormat *>();
+#ifdef Q_OS_ANDROID
+ KisAndroidMediaEncoderRunnable::getSupportedFormats(*supportedFormats);
+#endif
+ }
+ return *supportedFormats;
+}
+
+KisMediaEncoderFormat *KisMediaEncoderWrapper::getFormatByKey(const QString &key)
+{
+ for (KisMediaEncoderFormat *format : getSupportedFormats()) {
+ if (format->key() == key) {
+ return format;
+ }
+ }
+ return nullptr;
+}
+
+KisMediaEncoderRunnable *KisMediaEncoderWrapper::makeSupportedRunnable(const KisMediaEncoderWrapperSettings &settings)
+{
+ KIS_SAFE_ASSERT_RECOVER_RETURN_VALUE(settings.format, nullptr);
+
+#ifdef Q_OS_ANDROID
+ {
+ KisAndroidMediaEncoderRunnable *androidRunnable = KisAndroidMediaEncoderRunnable::create(settings);
+ if (androidRunnable) {
+ return androidRunnable;
+ }
+ }
+#endif
+
+ return nullptr;
+}
diff --git a/libs/ui/animation/KisMediaEncoderWrapper.h b/libs/ui/animation/KisMediaEncoderWrapper.h
new file mode 100644
index 00000000000..2e0a7126fc4
--- /dev/null
+++ b/libs/ui/animation/KisMediaEncoderWrapper.h
@@ -0,0 +1,155 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+#ifndef KISMEDIAENCODERWRAPPER
+#define KISMEDIAENCODERWRAPPER
+
+#include <QDir>
+#include <QObject>
+#include <QPointer>
+#include <QRunnable>
+#include <QSize>
+#include <QVariantMap>
+#include <QVector>
+
+#include <kritaui_export.h>
+
+class QImage;
+class QWidget;
+
+class KRITAUI_EXPORT KisMediaEncoderFormat
+{
+public:
+ enum class Type {
+ AndroidMediaEncoder,
+ };
+
+ virtual ~KisMediaEncoderFormat() = default;
+
+ virtual Type type() const = 0;
+ virtual QString key() const = 0;
+ virtual QString title() const = 0;
+ virtual QString extension() const = 0;
+
+ virtual QWidget *createPreferencesWidget(const QVariantMap &preferences) const = 0;
+ virtual void resetPreferencesWidget(QWidget *widget) const = 0;
+ virtual QVariantMap getPreferencesFromWidget(QWidget *widget) const = 0;
+
+protected:
+ KisMediaEncoderFormat() = default;
+};
+
+struct KRITAUI_EXPORT KisMediaEncoderWrapperSettings {
+ QString outputFile;
+ QStringList inputFiles;
+ KisMediaEncoderFormat *format;
+ QVariantMap formatPreferences;
+ QSize outputSize;
+ int inputFps;
+ int outputFps;
+ int firstFrameSec;
+ int lastFrameSec;
+};
+
+// Intentionally not exported, this is only needed internally.
+class KisMediaEncoderRunnable : public QObject, public QRunnable
+{
+ Q_OBJECT
+ Q_DISABLE_COPY_MOVE(KisMediaEncoderRunnable)
+public:
+ void run() override;
+
+public Q_SLOTS:
+ void slotHandleCancelRequested();
+
+Q_SIGNALS:
+ void sigStarted();
+ void sigCompleted();
+ void sigCancelled();
+ void sigFailed(const QString &errorMessage);
+ void sigProgressUpdated(int frameNo);
+
+protected:
+ enum class EncodeResult {
+ Completed,
+ Cancelled,
+ Failed,
+ };
+
+ class Frame
+ {
+ public:
+ explicit Frame(const QString &path = QString(), int instances = 0)
+ : m_path(path)
+ , m_instances(instances)
+ {
+ }
+
+ int instances() const
+ {
+ return m_instances;
+ }
+
+ bool readImage(QImage &outImage) const;
+
+ private:
+ QString m_path;
+ int m_instances;
+ };
+
+ KisMediaEncoderRunnable(const KisMediaEncoderWrapperSettings &settings, QObject *parent);
+
+ virtual EncodeResult encode(QString &outErrorMessage) = 0;
+
+ const KisMediaEncoderWrapperSettings settings()
+ {
+ return m_settings;
+ }
+
+ bool isCancelled() const
+ {
+ return m_cancel;
+ }
+
+ bool nextFrame(Frame &outFrame);
+
+private:
+ EncodeResult prepareAndEncode(QString &outErrorMessage);
+
+ KisMediaEncoderWrapperSettings m_settings;
+ double m_inputTime = 0.0;
+ int m_inputFileIndex = 0;
+ int m_outputFrameNo = 0;
+ bool m_needsPreviewBefore = true;
+ bool m_needsLingerAfter = true;
+ bool m_cancel = false;
+};
+
+class KRITAUI_EXPORT KisMediaEncoderWrapper : public QObject
+{
+ Q_OBJECT
+ Q_DISABLE_COPY_MOVE(KisMediaEncoderWrapper)
+public:
+ explicit KisMediaEncoderWrapper(QObject *parent = nullptr);
+ ~KisMediaEncoderWrapper() override;
+
+ void startNonBlocking(const KisMediaEncoderWrapperSettings &settings);
+ void reset();
+
+ static const QVector<KisMediaEncoderFormat *> &getSupportedFormats();
+ static KisMediaEncoderFormat *getFormatByKey(const QString &key);
+
+Q_SIGNALS:
+ void sigStarted();
+ void sigFinished();
+ void sigFinishedWithError(QString message);
+ void sigProgressUpdated(int frameNo);
+ void sigCancelRequested();
+
+private:
+ static KisMediaEncoderRunnable *makeSupportedRunnable(const KisMediaEncoderWrapperSettings &settings);
+
+ QPointer<KisMediaEncoderRunnable> m_runnable;
+};
+
+#endif
diff --git a/packaging/android/apk/src/org/krita/android/VideoEncoder.java b/packaging/android/apk/src/org/krita/android/VideoEncoder.java
new file mode 100644
index 00000000000..7abb81778f2
--- /dev/null
+++ b/packaging/android/apk/src/org/krita/android/VideoEncoder.java
@@ -0,0 +1,548 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.krita.android;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.media.Image;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaFormat;
+import android.media.MediaMuxer;
+import android.net.Uri;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class VideoEncoder {
+
+ public static class Support {
+ public final String name;
+ public final boolean hardware;
+
+ public Support(String name, boolean hardware) {
+ this.name = name;
+ this.hardware = hardware;
+ }
+ }
+
+ private static final String TAG = "krita.VideoEncoder";
+
+ // Keep these formats in sync with KisAndroidMediaEncoderRunnable!
+ private static final int FORMAT_MP4_H264 = 0;
+ private static final int FORMAT_WEBM_VP8 = 1;
+ private static final int FORMAT_MP4_AV1 = 2;
+ // These statuses too!
+ private static final int STATUS_OK = 0;
+ private static final int STATUS_TIMEOUT = 1;
+ private static final int STATUS_END_OF_STREAM = 2;
+ private static final int STATUS_NEEDS_TEMP_TO_OUTPUT_COPY = 3;
+ private static final int STATUS_ERROR_START_ALREADY_STARTED = 101;
+ private static final int STATUS_ERROR_START_UNKNOWN_FORMAT = 102;
+ private static final int STATUS_ERROR_START_FORMAT = 103;
+ private static final int STATUS_ERROR_START_ENCODER = 104;
+ private static final int STATUS_ERROR_START_MUXER = 105;
+ private static final int STATUS_ERROR_PREPARE_DEQUEUE = 201;
+ private static final int STATUS_ERROR_PREPARE_GET_IMAGE = 202;
+ private static final int STATUS_ERROR_PREPARE_NULL_IMAGE = 203;
+ private static final int STATUS_ERROR_COMMIT_QUEUE = 301;
+ private static final int STATUS_ERROR_DRAIN_DEQUEUE = 401;
+ private static final int STATUS_ERROR_DRAIN_MUXER_ALREADY_STARTED = 402;
+ private static final int STATUS_ERROR_DRAIN_ENCODER_GET_OUTPUT_FORMAT = 403;
+ private static final int STATUS_ERROR_DRAIN_MUXER_ADD_TRACK = 404;
+ private static final int STATUS_ERROR_DRAIN_MUXER_START = 405;
+ private static final int STATUS_ERROR_DRAIN_ENCODER_GET_OUTPUT_BUFFER = 406;
+ private static final int STATUS_ERROR_DRAIN_ENCODER_NULL_OUTPUT_BUFFER = 407;
+ private static final int STATUS_ERROR_DRAIN_MUXER_NOT_STARTED = 408;
+ private static final int STATUS_ERROR_DRAIN_ENCODER_RELEASE_OUTPUT_BUFFER = 409;
+ private static final int STATUS_ERROR_DRAIN_OUTPUT_BUFFER_POSITION = 410;
+ private static final int STATUS_ERROR_DRAIN_OUTPUT_BUFFER_LIMIT = 411;
+ private static final int STATUS_ERROR_DRAIN_MUXER_WRITE = 412;
+ private static final int STATUS_ERROR_DRAIN_MUXER_NEVER_STARTED = 413;
+ private static final int STATUS_ERROR_FINISH_QUEUE = 501;
+ private static final int STATUS_ERROR_CLOSE_TEAR_DOWN = 601;
+
+ private final int mFormat;
+ private final int mWidth;
+ private final int mHeight;
+ private final float mFramerate;
+ private final double mFrameDurationUs;
+ private final String mOutputPath;
+ private final String mTempPath;
+ private final String mEncoderName;
+ private final int mBitrate;
+ private final MediaCodec.BufferInfo mBufferInfo = new MediaCodec.BufferInfo();
+ private MediaCodec mEncoder = null;
+ private MediaMuxer mMuxer = null;
+ private boolean mUsesTempPath = false;
+ private boolean mMuxerStarted = false;
+ private int mFrameIndex = 0;
+ private int mVideoTrack = -1;
+ private int mInputBufferIndex = -1;
+ private Image mInputImage = null;
+
+ public VideoEncoder(int format, int width, int height, float framerate, String outputPath,
+ String tempPath, String encoderName, int bitrate) {
+ mFormat = format;
+ mWidth = width;
+ mHeight = height;
+ mFramerate = framerate;
+ mFrameDurationUs = 1000000.0 / ((double) framerate);
+ mOutputPath = outputPath;
+ mTempPath = tempPath;
+ mEncoderName = encoderName;
+ mBitrate = bitrate;
+ }
+
+ public int start(Context context) {
+ if (mEncoder != null || mMuxer != null) {
+ Log.e(TAG, "Attempting to start an already started video encoder");
+ return STATUS_ERROR_START_ALREADY_STARTED;
+ }
+
+ String videoMimeType = getFormatMimeType(mFormat);
+ if (videoMimeType == null) {
+ return STATUS_ERROR_START_UNKNOWN_FORMAT;
+ }
+
+ MediaFormat videoFormat;
+ try {
+ videoFormat = initializeVideoFormat(videoMimeType);
+ } catch (Exception e) {
+ Log.e(TAG, "Error initializing video format", e);
+ return STATUS_ERROR_START_FORMAT;
+ }
+
+ try {
+ mEncoder = initializeEncoder(videoMimeType, videoFormat);
+ } catch (Exception e) {
+ Log.e(TAG, "Error initializing encoder", e);
+ return STATUS_ERROR_START_ENCODER;
+ }
+
+ try {
+ mMuxer = initializeMuxer(context);
+ } catch (Exception e) {
+ Log.e(TAG, "Error initializing muxer", e);
+ return STATUS_ERROR_START_MUXER;
+ }
+
+ return STATUS_OK;
+ }
+
+ public int prepare(long timeoutUs) {
+ int inputBufferIndex;
+ try {
+ inputBufferIndex = mEncoder.dequeueInputBuffer(timeoutUs);
+ } catch (Exception e) {
+ Log.e(TAG, "Error dequeuing input buffer", e);
+ return STATUS_ERROR_PREPARE_DEQUEUE;
+ }
+
+ if (inputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
+ return STATUS_TIMEOUT;
+ }
+
+ Image inputImage;
+ try {
+ inputImage = mEncoder.getInputImage(inputBufferIndex);
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting input image " + inputBufferIndex, e);
+ return STATUS_ERROR_PREPARE_GET_IMAGE;
+ }
+ if (inputImage == null) {
+ Log.e(TAG, "No input image " + inputBufferIndex);
+ return STATUS_ERROR_PREPARE_NULL_IMAGE;
+ }
+
+ mInputBufferIndex = inputBufferIndex;
+ mInputImage = inputImage;
+ return STATUS_OK;
+ }
+
+ public ByteBuffer getInputImagePlaneBuffer(int index) {
+ try {
+ Image.Plane[] planes = mInputImage.getPlanes();
+ Image.Plane plane = planes[index];
+ return plane.getBuffer();
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting image plane buffer " + index, e);
+ return null;
+ }
+ }
+
+ public int getInputImagePlaneRowStride(int index) {
+ try {
+ Image.Plane[] planes = mInputImage.getPlanes();
+ Image.Plane plane = planes[index];
+ return plane.getRowStride();
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting image plane row stride " + index, e);
+ return -1;
+ }
+ }
+
+ public int getInputImagePlanePixelStride(int index) {
+ try {
+ Image.Plane[] planes = mInputImage.getPlanes();
+ Image.Plane plane = planes[index];
+ return plane.getPixelStride();
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting image plane pixel stride " + index, e);
+ return -1;
+ }
+ }
+
+ public int commit() {
+ try {
+ ByteBuffer inputBuffer = mEncoder.getInputBuffer(mInputBufferIndex);
+ mEncoder.queueInputBuffer(mInputBufferIndex, 0, inputBuffer == null ? 0 :
+ inputBuffer.capacity(), getPresentationTimeUs(), 0);
+ } catch (Exception e) {
+ Log.e(TAG, "Error committing frame " + mFrameIndex, e);
+ return STATUS_ERROR_COMMIT_QUEUE;
+ }
+ mInputBufferIndex = -1;
+ mInputImage = null;
+ ++mFrameIndex;
+ return STATUS_OK;
+ }
+
+ public int drain(long timeoutUs) {
+ int outputBufferIndex;
+ try {
+ outputBufferIndex = mEncoder.dequeueOutputBuffer(mBufferInfo, timeoutUs);
+ } catch (Exception e) {
+ Log.e(TAG, "Error dequeuing output buffer", e);
+ return STATUS_ERROR_DRAIN_DEQUEUE;
+ }
+
+ if (outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
+ return STATUS_TIMEOUT;
+
+ } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ if (mMuxerStarted) {
+ Log.e(TAG, "Format changed, but muxer already started");
+ return STATUS_ERROR_DRAIN_MUXER_ALREADY_STARTED;
+ }
+
+ MediaFormat outputFormat;
+ try {
+ outputFormat = mEncoder.getOutputFormat();
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting encoder output format", e);
+ return STATUS_ERROR_DRAIN_ENCODER_GET_OUTPUT_FORMAT;
+ }
+ try {
+ mVideoTrack = mMuxer.addTrack(outputFormat);
+ } catch (Exception e) {
+ Log.e(TAG, "Error adding muxer track", e);
+ return STATUS_ERROR_DRAIN_MUXER_ADD_TRACK;
+ }
+
+ try {
+ mMuxer.start();
+ } catch (Exception e) {
+ Log.e(TAG, "Error starting muxer", e);
+ return STATUS_ERROR_DRAIN_MUXER_START;
+ }
+ mMuxerStarted = true;
+
+ } else if (outputBufferIndex >= 0) {
+ ByteBuffer outputBuffer;
+ try {
+ outputBuffer = mEncoder.getOutputBuffer(outputBufferIndex);
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting encoder output buffer " + outputBufferIndex, e);
+ return STATUS_ERROR_DRAIN_ENCODER_GET_OUTPUT_BUFFER;
+ }
+ if (outputBuffer == null) {
+ Log.e(TAG, "Null encoder output buffer " + outputBufferIndex);
+ return STATUS_ERROR_DRAIN_ENCODER_NULL_OUTPUT_BUFFER;
+ }
+
+ if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0 && mBufferInfo.size != 0) {
+ if (!mMuxerStarted) {
+ Log.e(TAG,
+ "Got output buffer " + outputBufferIndex + " with size " + mBufferInfo.size + " while muxer is not started");
+ return STATUS_ERROR_DRAIN_MUXER_NOT_STARTED;
+ }
+
+ int position = mBufferInfo.offset;
+ try {
+ outputBuffer.position(position);
+ } catch (Exception e) {
+ Log.e(TAG,
+ "Error setting output buffer " + outputBufferIndex + " position " + position, e);
+ return STATUS_ERROR_DRAIN_OUTPUT_BUFFER_POSITION;
+ }
+
+ int limit = position + mBufferInfo.size;
+ try {
+ outputBuffer.limit(limit);
+ } catch (Exception e) {
+ Log.e(TAG,
+ "Error setting output buffer " + outputBufferIndex + " limit " + limit, e);
+ return STATUS_ERROR_DRAIN_OUTPUT_BUFFER_LIMIT;
+ }
+
+ try {
+ mMuxer.writeSampleData(mVideoTrack, outputBuffer, mBufferInfo);
+ } catch (Exception e) {
+ Log.e(TAG,
+ "Error writing sample data from output buffer " + outputBufferIndex + " to track " + mVideoTrack, e);
+ return STATUS_ERROR_DRAIN_MUXER_WRITE;
+ }
+ }
+
+ try {
+ mEncoder.releaseOutputBuffer(outputBufferIndex, false);
+ } catch (Exception e) {
+ Log.e(TAG, "Error releasing encoder output buffer " + outputBufferIndex, e);
+ return STATUS_ERROR_DRAIN_ENCODER_RELEASE_OUTPUT_BUFFER;
+ }
+
+ if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ if (mMuxerStarted) {
+ return STATUS_END_OF_STREAM;
+ } else {
+ Log.e(TAG, "Got end of stream without the muxer ever starting");
+ return STATUS_ERROR_DRAIN_MUXER_NEVER_STARTED;
+ }
+ }
+
+ } else {
+ Log.d(TAG, "Unhandled output buffer index value " + outputBufferIndex);
+ }
+
+ return STATUS_OK;
+ }
+
+ public int finish() {
+ try {
+ mEncoder.queueInputBuffer(mInputBufferIndex, 0, 0, getPresentationTimeUs(),
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+ } catch (Exception e) {
+ Log.e(TAG, "Error committing end of stream frame " + mFrameIndex, e);
+ return STATUS_ERROR_FINISH_QUEUE;
+ }
+ return STATUS_OK;
+ }
+
+ public int close() {
+ if (!tearDown()) {
+ return STATUS_ERROR_CLOSE_TEAR_DOWN;
+ }
+
+ if (mUsesTempPath) {
+ return STATUS_NEEDS_TEMP_TO_OUTPUT_COPY;
+ } else {
+ return STATUS_OK;
+ }
+ }
+
+ public void cancel() {
+ tearDown();
+ }
+
+ private boolean tearDown() {
+ boolean ok = true;
+
+ if (mEncoder != null) {
+ try {
+ mEncoder.stop();
+ } catch (Exception e) {
+ Log.e(TAG, "Error stopping encoder", e);
+ ok = false;
+ }
+ try {
+ mEncoder.release();
+ } catch (Exception e) {
+ Log.e(TAG, "Error releasing encoder", e);
+ ok = false;
+ }
+ mEncoder = null;
+ }
+
+ if (mMuxer != null) {
+ if (mMuxerStarted) {
+ mMuxerStarted = false;
+ try {
+ mMuxer.stop();
+ } catch (Exception e) {
+ Log.e(TAG, "Error stopping muxer", e);
+ ok = false;
+ }
+ }
+ try {
+ mMuxer.release();
+ } catch (Exception e) {
+ Log.e(TAG, "Error releasing muxer", e);
+ ok = false;
+ }
+ mMuxer = null;
+ }
+
+ return ok;
+ }
+
+ private MediaFormat initializeVideoFormat(String videoMimeType) throws Exception {
+ MediaFormat videoFormat = MediaFormat.createVideoFormat(videoMimeType, mWidth, mHeight);
+ videoFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible);
+ videoFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2);
+ videoFormat.setFloat(MediaFormat.KEY_FRAME_RATE, mFramerate);
+ videoFormat.setInteger(MediaFormat.KEY_BIT_RATE, mBitrate);
+ switch (mFormat) {
+ case FORMAT_MP4_H264:
+ case FORMAT_WEBM_VP8:
+ break;
+ case FORMAT_MP4_AV1:
+ videoFormat.setInteger(MediaFormat.KEY_BITRATE_MODE,
+ MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Should be the default, but let's set it if supported.
+ videoFormat.setInteger(MediaFormat.KEY_PROFILE,
+ MediaCodecInfo.CodecProfileLevel.AV1ProfileMain8);
+ }
+ break;
+ default:
+ throw new RuntimeException("Unhandled format " + mFormat);
+ }
+ return videoFormat;
+ }
+
+ private MediaCodec initializeEncoder(String videoMimeType, MediaFormat videoFormat) throws Exception {
+ MediaCodec encoder = createEncoder(videoMimeType);
+ encoder.configure(videoFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
+ encoder.start();
+ return encoder;
+ }
+
+ private MediaCodec createEncoder(String videoMimeType) throws Exception {
+ if (mEncoderName != null && !mEncoderName.isEmpty()) {
+ try {
+ return MediaCodec.createByCodecName(mEncoderName);
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to create encoder " + mEncoderName, e);
+ }
+ }
+ return MediaCodec.createEncoderByType(videoMimeType);
+ }
+
+ private MediaMuxer initializeMuxer(Context context) throws Exception {
+ int outputFormat;
+ switch (mFormat) {
+ case FORMAT_MP4_H264:
+ case FORMAT_MP4_AV1:
+ outputFormat = MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4;
+ break;
+ case FORMAT_WEBM_VP8:
+ outputFormat = MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM;
+ break;
+ default:
+ throw new RuntimeException("Muxer: unhandled format " + mFormat);
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && mOutputPath.contains("://")) {
+ try {
+ Uri uri = Uri.parse(mOutputPath);
+ ContentResolver contentResolver = context.getContentResolver();
+ try (ParcelFileDescriptor pfd = contentResolver.openFileDescriptor(uri, "rwt")) {
+ if (pfd == null) {
+ Log.w(TAG, "Could not open parcel file descriptor");
+ } else {
+ return new MediaMuxer(pfd.getFileDescriptor(), outputFormat);
+ }
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to parse output path", e);
+ }
+ }
+
+ mUsesTempPath = true;
+ return new MediaMuxer(mTempPath, outputFormat);
+ }
+
+ private long getPresentationTimeUs() {
+ return Math.round(((double) mFrameIndex) * mFrameDurationUs);
+ }
+
+ public static List<Support> getSupportsForFormat(int format) {
+ String mimeType = getFormatMimeType(format);
+ if (mimeType == null) {
+ return Collections.emptyList();
+ } else {
+ return getSupportsForMimeType(mimeType);
+ }
+ }
+
+ private static List<Support> getSupportsForMimeType(String mimeType) {
+ List<Support> supports = new ArrayList<>();
+
+ MediaCodecInfo[] codecInfos;
+ try {
+ MediaCodecList codecList = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
+ codecInfos = codecList.getCodecInfos();
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting available codecs for " + mimeType, e);
+ return supports;
+ }
+
+ if (codecInfos != null) {
+ for (MediaCodecInfo codecInfo : codecInfos) {
+ try {
+ if (codecInfo.isEncoder()) {
+ for (String type : codecInfo.getSupportedTypes()) {
+ if (mimeType.equalsIgnoreCase(type)) {
+ supports.add(new Support(codecInfo.getName(),
+ !looksLikeSoftwareEncoder(codecInfo)));
+ }
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Error checking codec for " + mimeType, e);
+ }
+ }
+ }
+
+ return supports;
+ }
+
+ private static boolean looksLikeSoftwareEncoder(MediaCodecInfo codecInfo) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ try {
+ if (codecInfo.isSoftwareOnly()) {
+ return true;
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to check software-only for codec " + codecInfo.getName());
+ }
+ }
+ // These are the built-in Android software encoders.
+ String lowerName = codecInfo.getName().toLowerCase();
+ return lowerName.startsWith("omx.google.") || lowerName.startsWith("c2.android.");
+ }
+
+ private static String getFormatMimeType(int format) {
+ switch (format) {
+ case FORMAT_MP4_H264:
+ return MediaFormat.MIMETYPE_VIDEO_AVC;
+ case FORMAT_WEBM_VP8:
+ return MediaFormat.MIMETYPE_VIDEO_VP8;
+ case FORMAT_MP4_AV1:
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return MediaFormat.MIMETYPE_VIDEO_AV1;
+ } else {
+ return null;
+ }
+ default:
+ return null;
+ }
+ }
+}
diff --git a/plugins/dockers/recorder/CMakeLists.txt b/plugins/dockers/recorder/CMakeLists.txt
index 72f20cca79f..ec8b62102aa 100644
--- a/plugins/dockers/recorder/CMakeLists.txt
+++ b/plugins/dockers/recorder/CMakeLists.txt
@@ -9,7 +9,6 @@ set(KRITA_RECORDERDOCKER_SOURCES
recorder_format.cpp
recorder_writer.cpp
recorder_export.cpp
- recorder_profile_settings.cpp
recorder_snapshots_manager.cpp
recorder_snapshots_scanner.cpp
recorder_directory_cleaner.cpp
@@ -17,13 +16,19 @@ set(KRITA_RECORDERDOCKER_SOURCES
recorderdocker_dock.cpp
)
-ki18n_wrap_ui(KRITA_RECORDERDOCKER_SOURCES
+set(KRITA_RECORDERDOCKER_UIS
recorderdocker.ui
recorder_export.ui
- recorder_profile_settings.ui
recorder_snapshots_manager.ui
)
+if(NOT ANDROID)
+ list(APPEND KRITA_RECORDERDOCKER_SOURCES recorder_profile_settings.cpp)
+ list(APPEND KRITA_RECORDERDOCKER_UIS recorder_profile_settings.ui)
+endif()
+
+ki18n_wrap_ui(KRITA_RECORDERDOCKER_SOURCES ${KRITA_RECORDERDOCKER_UIS})
+
install(FILES
recorder.action
diff --git a/plugins/dockers/recorder/recorder_export.cpp b/plugins/dockers/recorder/recorder_export.cpp
index 3e6bbd2237d..2873b7ca246 100644
--- a/plugins/dockers/recorder/recorder_export.cpp
+++ b/plugins/dockers/recorder/recorder_export.cpp
@@ -8,9 +8,7 @@
#include "ui_recorder_export.h"
#include "recorder_export_config.h"
#include "recorder_export_settings.h"
-#include "recorder_profile_settings.h"
#include "recorder_directory_cleaner.h"
-#include "animation/KisFFMpegWrapper.h"
#include <klocalizedstring.h>
#include <kis_icon_utils.h>
@@ -33,6 +31,15 @@
#include "kis_debug.h"
+#ifdef Q_OS_ANDROID
+#include "animation/KisMediaEncoderFormatPreferencesDialog.h"
+#include "animation/KisMediaEncoderWrapper.h"
+#include <KisAndroidUtils.h>
+#else
+#include "animation/KisFFMpegWrapper.h"
+#include "recorder_profile_settings.h"
+#endif
+
namespace
{
@@ -42,6 +49,12 @@ enum ExportPageIndex
PageProgress = 1,
PageDone = 2
};
+
+#ifdef Q_OS_ANDROID
+using Exporter = KisMediaEncoderWrapper;
+#else
+using Exporter = KisFFMpegWrapper;
+#endif
}
@@ -52,7 +65,7 @@ public:
QScopedPointer<Ui::RecorderExport> ui;
RecorderExportSettings *settings;
- QScopedPointer<KisFFMpegWrapper> ffmpeg;
+ QScopedPointer<Exporter> exporter;
RecorderDirectoryCleaner *cleaner = nullptr;
QElapsedTimer elapsedTimer;
@@ -67,7 +80,8 @@ public:
{
}
- void checkFfmpeg()
+#ifndef Q_OS_ANDROID
+ void checkExporter()
{
const QJsonObject ffmpegJson = KisFFMpegWrapper::findFFMpeg(settings->ffmpegPath);
const bool success = ffmpegJson["enabled"].toBool();
@@ -95,16 +109,42 @@ public:
}
ui->buttonBox->button(QDialogButtonBox::Save)->setEnabled(success);
}
+#endif
void fillComboProfiles()
{
- QSignalBlocker blocker(ui->comboProfile);
- ui->comboProfile->clear();
- for (const RecorderProfile &profile : settings->profiles) {
- ui->comboProfile->addItem(profile.name);
+ int indexToSelect;
+ {
+ QSignalBlocker blocker(ui->comboProfile);
+ ui->comboProfile->clear();
+#ifdef Q_OS_ANDROID
+ const QVector<KisMediaEncoderFormat *> formats = KisMediaEncoderWrapper::getSupportedFormats();
+ int count = formats.size();
+ if (count == 0) {
+ return;
+ }
+
+ indexToSelect = -1;
+ for (int i = 0, count = formats.size(); i < count; ++i) {
+ QString key = formats[i]->key();
+ ui->comboProfile->addItem(formats[i]->title(), QVariant(key));
+ if (key == settings->selectedFormat) {
+ indexToSelect = i;
+ }
+ }
+
+ if (indexToSelect == -1) {
+ indexToSelect = 0;
+ settings->selectedFormat = formats[0]->key();
+ }
+#else
+ for (const RecorderProfile &profile : settings->profiles) {
+ ui->comboProfile->addItem(profile.name);
+ }
+ indexToSelect = settings->profileIndex;
+#endif
}
- blocker.unblock();
- ui->comboProfile->setCurrentIndex(settings->profileIndex);
+ ui->comboProfile->setCurrentIndex(indexToSelect);
}
void updateFrameInfo()
@@ -119,8 +159,19 @@ public:
settings->imageSize.rwidth() &= ~1;
settings->imageSize.rheight() &= ~1;
}
+#ifdef Q_OS_ANDROID
+ // QDir::entryList is mind-bogglingly slow on Android, so we only load
+ // this once and cache the result. Not like these are supposed to change
+ // while this modal dialog is up anyway.
+ settings->inputFilePaths.clear();
+ settings->inputFilePaths.reserve(settings->framesCount);
+ for (const QString &frame : frames) {
+ settings->inputFilePaths.append(dir.filePath(frame));
+ }
+#endif
}
+#ifndef Q_OS_ANDROID
void updateVideoFilePath()
{
if (settings->videoDirectory.isEmpty())
@@ -134,6 +185,7 @@ public:
QSignalBlocker blocker(ui->editVideoFilePath);
ui->editVideoFilePath->setText(settings->videoFilePath);
}
+#endif
void updateRatio(bool widthToHeight)
{
@@ -171,18 +223,19 @@ public:
bool tryAbortExport()
{
- if (!ffmpeg)
+ if (!exporter)
return true;
if (QMessageBox::question(q, q->windowTitle(), i18n("Abort encoding the timelapse video?"))
== QMessageBox::Yes) {
- cleanupFFMpeg();
+ cleanupExporter();
return true;
}
return false;
}
+#ifndef Q_OS_ANDROID
QStringList splitCommand(const QString &command)
{
QStringList args;
@@ -222,43 +275,62 @@ public:
return args;
}
+#endif
void startExport()
{
- Q_ASSERT(ffmpeg == nullptr);
+ Q_ASSERT(exporter == nullptr);
+#ifndef Q_OS_ANDROID
+ // We don't do this again on Android, it's mind-bogglingly slow.
updateFrameInfo();
-
- const QString &arguments = applyVariables(settings->profiles[settings->profileIndex].arguments);
-
- ffmpeg.reset(new KisFFMpegWrapper(q));
- QObject::connect(ffmpeg.data(), SIGNAL(sigStarted()), q, SLOT(onFFMpegStarted()));
- QObject::connect(ffmpeg.data(), SIGNAL(sigFinished()), q, SLOT(onFFMpegFinished()));
- QObject::connect(ffmpeg.data(), SIGNAL(sigFinishedWithError(QString)), q, SLOT(onFFMpegFinishedWithError(QString)));
- QObject::connect(ffmpeg.data(), SIGNAL(sigProgressUpdated(int)), q, SLOT(onFFMpegProgressUpdated(int)));
-
- KisFFMpegWrapperSettings FFmpegSettings;
- KisConfig cfg(true);
- FFmpegSettings.processPath = settings->ffmpegPath;
- FFmpegSettings.args = splitCommand(arguments);
- FFmpegSettings.outputFile = settings->videoFilePath;
- FFmpegSettings.batchMode = true; //TODO: Consider renaming to 'silent' mode, meaning no window for extra window handling...
-
- ffmpeg->startNonBlocking(FFmpegSettings);
- ui->labelStatus->setText(i18nc("Status for the export of the video record", "Starting FFmpeg..."));
+#endif
+
+ exporter.reset(new Exporter(q));
+ QObject::connect(exporter.data(), SIGNAL(sigStarted()), q, SLOT(onExporterStarted()));
+ QObject::connect(exporter.data(), SIGNAL(sigFinished()), q, SLOT(onExporterFinished()));
+ QObject::connect(exporter.data(), SIGNAL(sigFinishedWithError(QString)), q, SLOT(onExporterFinishedWithError(QString)));
+ QObject::connect(exporter.data(), SIGNAL(sigProgressUpdated(int)), q, SLOT(onExporterProgressUpdated(int)));
+
+#ifdef Q_OS_ANDROID
+ KisMediaEncoderWrapperSettings exporterSettings = {
+ settings->videoFilePath,
+ settings->inputFilePaths,
+ KisMediaEncoderWrapper::getFormatByKey(settings->selectedFormat),
+ settings->formatPreferences.value(settings->selectedFormat).toMap(),
+ settings->resize ? settings->size : settings->imageSize,
+ settings->inputFps,
+ settings->fps,
+ settings->firstFrameSec,
+ settings->lastFrameSec,
+ };
+#else
+ const RecorderProfile &profile = settings->profiles[settings->profileIndex];
+ KisFFMpegWrapperSettings exporterSettings;
+ exporterSettings.processPath = settings->ffmpegPath;
+ exporterSettings.args = splitCommand(applyVariables(profile.arguments));
+ exporterSettings.outputFile = settings->videoFilePath;
+ exporterSettings.batchMode = true; //TODO: Consider renaming to 'silent' mode, meaning no window for extra window handling...
+#endif
+
+ ui->labelStatus->setText(i18nc("Status for the export of the video record", "Starting exporter..."));
ui->buttonCancelExport->setEnabled(false);
ui->progressExport->setValue(0);
elapsedTimer.start();
+
+ // Do this last, it may immediately emit a failure.
+ exporter->startNonBlocking(exporterSettings);
}
- void cleanupFFMpeg()
+ void cleanupExporter()
{
- if (ffmpeg) {
- ffmpeg->reset();
- ffmpeg.reset();
+ if (exporter) {
+ exporter->reset();
+ exporter.reset();
}
}
+#ifndef Q_OS_ANDROID
QString applyVariables(const QString &templateArguments)
{
const QSize &outSize = settings->resize ? settings->size : settings->imageSize;
@@ -278,6 +350,7 @@ public:
.replace("$LAST_FRAME_SEC", QString::number(resultLength))
.replace("$EXT", RecorderFormatInfo::fileExtension(settings->format));
}
+#endif
void updateVideoDuration()
{
@@ -317,6 +390,29 @@ public:
return result;
}
+
+ QString requestFile(const QString &extension, const QString &defaultDir = QString())
+ {
+ KoFileDialog dialog(q, KoFileDialog::SaveFile, "ExportTimelapse");
+ dialog.setCaption(i18n("Export Timelapse Video As"));
+ if (!defaultDir.isEmpty()) {
+ dialog.setDefaultDir(defaultDir);
+ }
+ dialog.setMimeTypeFilters(QStringList(KisMimeDatabase::mimeTypeForSuffix(extension)));
+ return dialog.filename();
+ }
+
+ static void desktopServicesOpenPath(const QString &path)
+ {
+#ifdef Q_OS_ANDROID
+ // QDesktopServices doesn't clear exceptions
+ KisAndroidUtils::clearJniException(QStringLiteral("before opening ") + path);
+ QDesktopServices::openUrl(QUrl(path));
+ KisAndroidUtils::clearJniException(QStringLiteral("after opening ") + path);
+#else
+ QDesktopServices::openUrl(QUrl::fromLocalFile(path));
+#endif
+ }
};
@@ -326,16 +422,28 @@ RecorderExport::RecorderExport(RecorderExportSettings *s, QWidget *parent)
, d(new Private(this))
{
d->ui->setupUi(this);
+
+#ifdef Q_OS_ANDROID
+ d->ui->labelFfmpegLocation->hide();
+ d->ui->editFfmpegPath->hide();
+ d->ui->buttonBrowseFfmpeg->hide();
+ d->ui->labelExportTo->hide();
+ d->ui->editVideoFilePath->hide();
+ d->ui->buttonBrowseExport->hide();
+ d->ui->buttonShowInFolder->hide();
+#else
+ d->ui->buttonBrowseFfmpeg->setIcon(KisIconUtils::loadIcon("folder"));
+ d->ui->buttonBrowseExport->setIcon(KisIconUtils::loadIcon("folder"));
+ d->ui->buttonShowInFolder->setIcon(KisIconUtils::loadIcon("folder"));
+#endif
+
d->spinInputFPSMaxValue = d->ui->spinInputFps->minimum();
d->spinInputFPSMaxValue = d->ui->spinInputFps->maximum();
d->ui->buttonBrowseDirectory->setIcon(KisIconUtils::loadIcon("view-preview"));
- d->ui->buttonBrowseFfmpeg->setIcon(KisIconUtils::loadIcon("folder"));
d->ui->buttonEditProfile->setIcon(KisIconUtils::loadIcon("document-edit"));
- d->ui->buttonBrowseExport->setIcon(KisIconUtils::loadIcon("folder"));
d->ui->buttonLockRatio->setIcon(settings->lockRatio ? KisIconUtils::loadIcon("locked") : KisIconUtils::loadIcon("unlocked"));
d->ui->buttonLockFps->setIcon(settings->lockFps ? KisIconUtils::loadIcon("locked") : KisIconUtils::loadIcon("unlocked"));
d->ui->buttonWatchIt->setIcon(KisIconUtils::loadIcon("media-playback-start"));
- d->ui->buttonShowInFolder->setIcon(KisIconUtils::loadIcon("folder"));
d->ui->buttonRemoveSnapshots->setIcon(KisIconUtils::loadIcon("edit-delete"));
d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageSettings);
d->ui->spinLastFrameSec->setEnabled(d->ui->extendResultCheckBox->isChecked());
@@ -353,25 +461,30 @@ RecorderExport::RecorderExport(RecorderExportSettings *s, QWidget *parent)
connect(d->ui->spinScaleHeight, SIGNAL(valueChanged(int)), SLOT(onSpinScaleHeightValueChanged(int)));
connect(d->ui->buttonLockRatio, SIGNAL(toggled(bool)), SLOT(onButtonLockRatioToggled(bool)));
connect(d->ui->buttonLockFps, SIGNAL(toggled(bool)), SLOT(onButtonLockFpsToggled(bool)));
- connect(d->ui->buttonBrowseFfmpeg, SIGNAL(clicked()), SLOT(onButtonBrowseFfmpegClicked()));
connect(d->ui->comboProfile, SIGNAL(currentIndexChanged(int)), SLOT(onComboProfileIndexChanged(int)));
connect(d->ui->buttonEditProfile, SIGNAL(clicked()), SLOT(onButtonEditProfileClicked()));
- connect(d->ui->editVideoFilePath, SIGNAL(textChanged(QString)), SLOT(onEditVideoPathChanged(QString)));
- connect(d->ui->buttonBrowseExport, SIGNAL(clicked()), SLOT(onButtonBrowseExportClicked()));
connect(d->ui->buttonBox->button(QDialogButtonBox::Save), SIGNAL(clicked()), this, SLOT(onButtonExportClicked()));
connect(d->ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(d->ui->buttonCancelExport, SIGNAL(clicked()), SLOT(onButtonCancelClicked()));
connect(d->ui->buttonWatchIt, SIGNAL(clicked()), SLOT(onButtonWatchItClicked()));
- connect(d->ui->buttonShowInFolder, SIGNAL(clicked()), SLOT(onButtonShowInFolderClicked()));
connect(d->ui->buttonRemoveSnapshots, SIGNAL(clicked()), SLOT(onButtonRemoveSnapshotsClicked()));
connect(d->ui->buttonRestart, SIGNAL(clicked()), SLOT(onButtonRestartClicked()));
connect(d->ui->resultPreviewCheckBox, SIGNAL(toggled(bool)), d->ui->spinFirstFrameSec, SLOT(setEnabled(bool)));
connect(d->ui->extendResultCheckBox, SIGNAL(toggled(bool)), d->ui->spinLastFrameSec, SLOT(setEnabled(bool)));
+#ifndef Q_OS_ANDROID
+ connect(d->ui->buttonBrowseFfmpeg, SIGNAL(clicked()), SLOT(onButtonBrowseFfmpegClicked()));
+ connect(d->ui->buttonBrowseExport, SIGNAL(clicked()), SLOT(onButtonBrowseExportClicked()));
+ connect(d->ui->editVideoFilePath, SIGNAL(textChanged(QString)), SLOT(onEditVideoPathChanged(QString)));
+ connect(d->ui->buttonShowInFolder, SIGNAL(clicked()), SLOT(onButtonShowInFolderClicked()));
+#endif
+
if (settings->realTimeCaptureMode)
d->ui->buttonBox->button(QDialogButtonBox::Close)->setText("OK");
d->ui->buttonBox->button(QDialogButtonBox::Save)->setText(i18n("Export"));
+#ifndef Q_OS_ANDROID
d->ui->editVideoFilePath->installEventFilter(this);
+#endif
}
RecorderExport::~RecorderExport()
@@ -418,8 +531,10 @@ void RecorderExport::setup()
d->ui->buttonLockFps->setChecked(settings->lockFps);
d->ui->buttonLockFps->setIcon(settings->lockFps ? KisIconUtils::loadIcon("locked") : KisIconUtils::loadIcon("unlocked"));
d->fillComboProfiles();
- d->checkFfmpeg();
+#ifndef Q_OS_ANDROID
+ d->checkExporter();
d->updateVideoFilePath();
+#endif
d->updateVideoDuration();
}
@@ -438,7 +553,7 @@ void RecorderExport::reject()
void RecorderExport::onButtonBrowseDirectoryClicked()
{
if (settings->framesCount != 0) {
- QDesktopServices::openUrl(QUrl::fromLocalFile(settings->inputDirectory));
+ Private::desktopServicesOpenPath(settings->inputDirectory);
} else {
QMessageBox::warning(this, windowTitle(), i18nc("Can't browse frames of recording because no frames have been recorded", "No frames to browse."));
return;
@@ -543,6 +658,7 @@ void RecorderExport::onButtonLockFpsToggled(bool checked)
}
+#ifndef Q_OS_ANDROID
void RecorderExport::onButtonBrowseFfmpegClicked()
{
KoFileDialog dialog(this, KoFileDialog::OpenFile, "SelectFFmpeg");
@@ -552,19 +668,37 @@ void RecorderExport::onButtonBrowseFfmpegClicked()
if (!file.isEmpty()) {
settings->ffmpegPath = file;
RecorderExportConfig(false).setFfmpegPath(file);
- d->checkFfmpeg();
+ d->checkExporter();
}
}
+#endif
void RecorderExport::onComboProfileIndexChanged(int index)
{
+#ifdef Q_OS_ANDROID
+ QString format = d->ui->comboProfile->itemData(index).toString();
+ d->settings->selectedFormat = format;
+ RecorderExportConfig(false).setSelectedFormat(format);
+#else
settings->profileIndex = index;
d->updateVideoFilePath();
RecorderExportConfig(false).setProfileIndex(index);
+#endif
}
void RecorderExport::onButtonEditProfileClicked()
{
+#ifdef Q_OS_ANDROID
+ QString key = settings->selectedFormat;
+ KisMediaEncoderFormat *format = KisMediaEncoderWrapper::getFormatByKey(key);
+ KIS_SAFE_ASSERT_RECOVER_RETURN(format);
+
+ KisMediaEncoderPreferencesDialog dlg(format, settings->formatPreferences.value(key).toMap(), this);
+ if (dlg.exec() == QDialog::Accepted) {
+ settings->formatPreferences.insert(key, dlg.preferences());
+ RecorderExportConfig(false).setFormatPreferences(settings->formatPreferences);
+ }
+#else
RecorderProfileSettings settingsDialog(this);
connect(&settingsDialog, &RecorderProfileSettings::requestPreview, [&](const QString & arguments) {
@@ -578,8 +712,10 @@ void RecorderExport::onButtonEditProfileClicked()
d->updateVideoFilePath();
RecorderExportConfig(false).setProfiles(settings->profiles);
}
+#endif
}
+#ifndef Q_OS_ANDROID
void RecorderExport::onEditVideoPathChanged(const QString &videoFilePath)
{
QFileInfo fileInfo(videoFilePath);
@@ -587,39 +723,45 @@ void RecorderExport::onEditVideoPathChanged(const QString &videoFilePath)
settings->videoDirectory = fileInfo.absolutePath();
settings->videoFileName = fileInfo.completeBaseName();
}
+#endif
+#ifndef Q_OS_ANDROID
void RecorderExport::onButtonBrowseExportClicked()
{
- KoFileDialog dialog(this, KoFileDialog::SaveFile, "ExportTimelapse");
- dialog.setCaption(i18n("Export Timelapse Video As"));
- dialog.setDefaultDir(settings->videoDirectory);
- const QString &extension = settings->profiles[settings->profileIndex].extension;
- dialog.setMimeTypeFilters(QStringList(KisMimeDatabase::mimeTypeForSuffix(extension)));
- QString videoFileName = dialog.filename();
+ QString videoFileName = d->requestFile(settings->profiles[settings->profileIndex].extension, settings->videoDirectory);
if (!videoFileName.isEmpty()) {
QFileInfo fileInfo(videoFileName);
settings->videoDirectory = fileInfo.absolutePath();
settings->videoFileName = fileInfo.completeBaseName();
- d->updateVideoFilePath();
RecorderExportConfig(false).setVideoDirectory(settings->videoDirectory);
+ d->updateVideoFilePath();
}
}
+#endif
void RecorderExport::onButtonExportClicked()
{
+ if (settings->framesCount == 0) {
+ QMessageBox::warning(this, windowTitle(), i18n("No frames to export."));
+ return;
+ }
+
+#ifdef Q_OS_ANDROID
+ KisMediaEncoderFormat *format = KisMediaEncoderWrapper::getFormatByKey(settings->selectedFormat);
+ KIS_SAFE_ASSERT_RECOVER_RETURN(format);
+ settings->videoFilePath = d->requestFile(format->extension());
+ if (settings->videoFilePath.isEmpty()) {
+ return;
+ }
+#else
if (QFile::exists(settings->videoFilePath)) {
- if (settings->framesCount != 0) {
- if (QMessageBox::question(this, windowTitle(),
- i18n("The video file already exists. Do you wish to overwrite it?"))
- != QMessageBox::Yes) {
- return;
- }
- } else {
- QMessageBox::warning(this, windowTitle(), i18n("No frames to export."));
+ if (QMessageBox::question(this, windowTitle(),
+ i18n("The video file already exists. Do you wish to overwrite it?"))
+ != QMessageBox::Yes) {
return;
}
}
-
+#endif
d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageProgress);
d->startExport();
@@ -639,42 +781,44 @@ void RecorderExport::onButtonCancelClicked()
}
-void RecorderExport::onFFMpegStarted()
+void RecorderExport::onExporterStarted()
{
d->ui->buttonCancelExport->setEnabled(true);
d->ui->labelStatus->setText(i18n("The timelapse video is being encoded..."));
}
-void RecorderExport::onFFMpegFinished()
+void RecorderExport::onExporterFinished()
{
quint64 elapsed = d->elapsedTimer.elapsed();
d->ui->labelRenderTime->setText(d->formatDuration(elapsed));
d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageDone);
d->ui->labelVideoPathDone->setText(settings->videoFilePath);
- d->cleanupFFMpeg();
+ d->cleanupExporter();
}
-void RecorderExport::onFFMpegFinishedWithError(QString error)
+void RecorderExport::onExporterFinishedWithError(QString error)
{
d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageSettings);
- QMessageBox::critical(this, windowTitle(), i18n("Export failed. FFmpeg message:") % "\n\n" % error);
- d->cleanupFFMpeg();
+ QMessageBox::critical(this, windowTitle(), i18n("Export failed. Error message:") % "\n\n" % error);
+ d->cleanupExporter();
}
-void RecorderExport::onFFMpegProgressUpdated(int frameNo)
+void RecorderExport::onExporterProgressUpdated(int frameNo)
{
d->ui->progressExport->setValue(frameNo * 100 / (settings->framesCount * settings->fps / static_cast<float>(settings->inputFps)));
}
void RecorderExport::onButtonWatchItClicked()
{
- QDesktopServices::openUrl(QUrl::fromLocalFile(settings->videoFilePath));
+ Private::desktopServicesOpenPath(settings->videoFilePath);
}
+#ifndef Q_OS_ANDROID
void RecorderExport::onButtonShowInFolderClicked()
{
- QDesktopServices::openUrl(QUrl::fromLocalFile(settings->videoDirectory));
+ Private::desktopServicesOpenPath(settings->videoDirectory);
}
+#endif
void RecorderExport::onButtonRemoveSnapshotsClicked()
{
@@ -709,6 +853,7 @@ void RecorderExport::onCleanUpFinished()
d->ui->buttonRemoveSnapshots->hide();
}
+#ifndef Q_OS_ANDROID
bool RecorderExport::eventFilter(QObject *obj, QEvent *event)
{
if (obj == d->ui->editVideoFilePath && event->type() == QEvent::FocusOut)
@@ -716,3 +861,4 @@ bool RecorderExport::eventFilter(QObject *obj, QEvent *event)
return QDialog::eventFilter(obj, event);
}
+#endif
diff --git a/plugins/dockers/recorder/recorder_export.h b/plugins/dockers/recorder/recorder_export.h
index c5ba908fded..3813fe58d06 100644
--- a/plugins/dockers/recorder/recorder_export.h
+++ b/plugins/dockers/recorder/recorder_export.h
@@ -42,28 +42,36 @@ private Q_SLOTS:
void onSpinScaleHeightValueChanged(int value);
void onButtonLockRatioToggled(bool checked);
void onButtonLockFpsToggled(bool checked);
+#ifndef Q_OS_ANDROID
void onButtonBrowseFfmpegClicked();
+#endif
void onComboProfileIndexChanged(int index);
void onButtonEditProfileClicked();
+#ifndef Q_OS_ANDROID
void onEditVideoPathChanged(const QString &videoFilePath);
void onButtonBrowseExportClicked();
+#endif
void onButtonExportClicked();
// second page
void onButtonCancelClicked();
- // ffmpeg
- void onFFMpegStarted();
- void onFFMpegFinished();
- void onFFMpegFinishedWithError(QString error);
- void onFFMpegProgressUpdated(int frameNo);
+ // exporter
+ void onExporterStarted();
+ void onExporterFinished();
+ void onExporterFinishedWithError(QString error);
+ void onExporterProgressUpdated(int frameNo);
// third page
void onButtonWatchItClicked();
+#ifndef Q_OS_ANDROID
void onButtonShowInFolderClicked();
+#endif
void onButtonRemoveSnapshotsClicked();
void onButtonRestartClicked();
void onCleanUpFinished();
+#ifndef Q_OS_ANDROID
private:
bool eventFilter(QObject *obj, QEvent *event) override;
+#endif
private:
Q_DISABLE_COPY(RecorderExport)
diff --git a/plugins/dockers/recorder/recorder_export_config.cpp b/plugins/dockers/recorder/recorder_export_config.cpp
index 5165f119830..6fd25662767 100644
--- a/plugins/dockers/recorder/recorder_export_config.cpp
+++ b/plugins/dockers/recorder/recorder_export_config.cpp
@@ -12,11 +12,17 @@
#include <QDir>
#include <QRegularExpression>
+#ifdef Q_OS_ANDROID
+#include <QJsonDocument>
+#endif
+
namespace
{
const QString keyAnimationExport = "ANIMATION_EXPORT";
+#ifndef Q_OS_ANDROID
const QString keyFfmpegPath = "ffmpeg_path";
const QString keyVideoDirectory = "recorder_export/videodirectory";
+#endif
const QString keyInputFps = "recorder_export/inputfps";
const QString keyFps = "recorder_export/fps";
const QString keyResultPreview="recorder_export/resultpreview";
@@ -27,6 +33,10 @@ const QString keyResize = "recorder_export/resize";
const QString keySize = "recorder_export/size";
const QString keyLockRatio = "recorder_export/lockratio";
const QString keyLockFps = "recorder_export/lockfps";
+#ifdef Q_OS_ANDROID
+const QString keySelectedFormat = "recorder_export/selectedformat";
+const QString keyFormatPreferences = "recorder_export/formatpreferences";
+#else
const QString keyProfileIndex = "recorder_export/profileIndex";
const QString keyProfiles = "recorder_export/profiles";
const QString keyEditedProfiles = "recorder_export/editedprofiles";
@@ -175,6 +185,7 @@ const QList<RecorderProfile> defaultProfiles = {
{ "Custom3", "editme", profilePrefix % "-filter_complex \"loop=$LAST_FRAME_SEC:size=1:start=$FRAMES,scale=$WIDTH:$HEIGHT\"\n-r $OUT_FPS" },
{ "Custom4", "editme", profilePrefix % "-filter_complex \"loop=$LAST_FRAME_SEC:size=1:start=$FRAMES,scale=$WIDTH:$HEIGHT\"\n-r $OUT_FPS" }
};
+#endif
}
RecorderExportConfig::RecorderExportConfig(bool readOnly)
@@ -198,11 +209,16 @@ void RecorderExportConfig::loadConfiguration(RecorderExportSettings *settings, b
settings->resize = resize();
settings->size = size();
settings->lockRatio = lockRatio();
+#ifdef Q_OS_ANDROID
+ settings->selectedFormat = selectedFormat();
+ settings->formatPreferences = formatPreferences();
+#else
settings->ffmpegPath = ffmpegPath();
settings->profiles = profiles();
settings->defaultProfiles = defaultProfiles();
settings->profileIndex = profileIndex();
settings->videoDirectory = videoDirectory();
+#endif
if (loadLockFps)
settings->lockFps = lockFps();
}
@@ -311,6 +327,34 @@ void RecorderExportConfig::setLockFps(bool value)
config->writeEntry(keyLockFps, value);
}
+#ifdef Q_OS_ANDROID
+QString RecorderExportConfig::selectedFormat() const
+{
+ return config->readEntry(keySelectedFormat, QString());
+}
+
+void RecorderExportConfig::setSelectedFormat(const QString &value)
+{
+ config->writeEntry(keySelectedFormat, value);
+}
+
+QVariantMap RecorderExportConfig::formatPreferences() const
+{
+ QByteArray bytes = config->readEntry(keyFormatPreferences, QByteArray());
+ if (!bytes.isEmpty()) {
+ QJsonDocument doc = QJsonDocument::fromJson(bytes);
+ if (doc.isObject()) {
+ return doc.toVariant().toMap();
+ }
+ }
+ return QVariantMap();
+}
+
+void RecorderExportConfig::setFormatPreferences(const QVariantMap &value)
+{
+ config->writeEntry(keyFormatPreferences, QJsonDocument::fromVariant(value).toJson(QJsonDocument::Compact));
+}
+#else
int RecorderExportConfig::profileIndex() const
{
return config->readEntry(keyProfileIndex, 0);
@@ -437,3 +481,4 @@ bool operator!=(const RecorderProfile &left, const RecorderProfile &right)
{
return !(left == right);
}
+#endif
diff --git a/plugins/dockers/recorder/recorder_export_config.h b/plugins/dockers/recorder/recorder_export_config.h
index 858ff2eb117..a4f34ffc83b 100644
--- a/plugins/dockers/recorder/recorder_export_config.h
+++ b/plugins/dockers/recorder/recorder_export_config.h
@@ -53,6 +53,13 @@ public:
bool lockFps() const;
void setLockFps(bool value);
+#ifdef Q_OS_ANDROID
+ QString selectedFormat() const;
+ void setSelectedFormat(const QString &value);
+
+ QVariantMap formatPreferences() const;
+ void setFormatPreferences(const QVariantMap &value);
+#else
int profileIndex() const;
void setProfileIndex(int value);
@@ -69,13 +76,16 @@ public:
QString videoDirectory() const;
void setVideoDirectory(const QString &value);
+#endif
private:
Q_DISABLE_COPY(RecorderExportConfig)
mutable KisConfig *config;
};
+#ifndef Q_OS_ANDROID
bool operator==(const RecorderProfile &left, const RecorderProfile &right);
bool operator!=(const RecorderProfile &left, const RecorderProfile &right);
+#endif
#endif // RECORDER_EXPORT_CONFIG_H
diff --git a/plugins/dockers/recorder/recorder_export_settings.h b/plugins/dockers/recorder/recorder_export_settings.h
index 813702409cf..9dd7bac9de6 100644
--- a/plugins/dockers/recorder/recorder_export_settings.h
+++ b/plugins/dockers/recorder/recorder_export_settings.h
@@ -14,12 +14,16 @@
#include <QSize>
#include "recorder_format.h"
+#ifdef Q_OS_ANDROID
+#include <QVariantMap>
+#else
struct RecorderProfile
{
QString name;
QString extension;
QString arguments;
};
+#endif
struct RecorderExportSettings {
@@ -31,15 +35,20 @@ struct RecorderExportSettings {
bool extendResult = true;
int inputFps = 30;
int fps = 30;
- int profileIndex = 0;
int firstFrameSec = 2;
int lastFrameSec = 5;
QSize size;
+#ifdef Q_OS_ANDROID
+ QString selectedFormat;
+ QVariantMap formatPreferences;
+#else
+ int profileIndex = 0;
QString ffmpegPath;
QString videoDirectory;
QString h264Encoder;
QList<RecorderProfile> profiles;
QList<RecorderProfile> defaultProfiles;
+#endif
// The following are additional settings, which will not be serialized in
@@ -48,7 +57,11 @@ struct RecorderExportSettings {
RecorderFormat format;
QSize imageSize;
+#ifdef Q_OS_ANDROID
+ QStringList inputFilePaths;
+#else
QString videoFileName;
+#endif
QString videoFilePath;
int framesCount = 0;
diff --git a/plugins/dockers/recorder/recorderdocker_dock.cpp b/plugins/dockers/recorder/recorderdocker_dock.cpp
index 87eb1fa50bd..2dfefa49f86 100644
--- a/plugins/dockers/recorder/recorderdocker_dock.cpp
+++ b/plugins/dockers/recorder/recorderdocker_dock.cpp
@@ -602,7 +602,9 @@ void RecorderDockerDock::onExportButtonClicked()
KisDocument *document = d->canvas->imageView()->document();
+#ifndef Q_OS_ANDROID
exportSettings->videoFileName = QFileInfo(document->caption().trimmed()).completeBaseName();
+#endif
exportSettings->inputDirectory = d->outputDirectory;
exportSettings->format = d->format;
exportSettings->realTimeCaptureMode = d->realTimeCaptureMode;