Krita Source Code Documentation
Loading...
Searching...
No Matches
recorder_export.cpp
Go to the documentation of this file.
1/*
2 * SPDX-FileCopyrightText: 2020 Dmitrii Utkin <loentar@gmail.com>
3 *
4 * SPDX-License-Identifier: LGPL-2.1-only
5 */
6
7#include "recorder_export.h"
8#include "ui_recorder_export.h"
12
13#include <klocalizedstring.h>
14#include <kis_icon_utils.h>
15#include "kis_config.h"
16#include "KoFileDialog.h"
17#include "KisMimeDatabase.h"
18
19#include <QAction>
20#include <QDesktopServices>
21#include <QDir>
22#include <QDirIterator>
23#include <QUrl>
24#include <QDebug>
25#include <QCloseEvent>
26#include <QMessageBox>
27#include <QJsonObject>
28#include <QJsonArray>
29#include <QImageReader>
30#include <QElapsedTimer>
31
32#include "kis_debug.h"
33
34#ifdef Q_OS_ANDROID
37#include <KisAndroidUtils.h>
38#else
41#endif
42
43
44namespace
45{
46enum ExportPageIndex
47{
48 PageSettings = 0,
49 PageProgress = 1,
50 PageDone = 2
51};
52
53#ifdef Q_OS_ANDROID
54using Exporter = KisMediaEncoderWrapper;
55#else
56using Exporter = KisFFMpegWrapper;
57#endif
58}
59
60
62{
63public:
64 static constexpr int DIMENSION_LIMIT = 1920;
65
67 QScopedPointer<Ui::RecorderExport> ui;
69
70 QScopedPointer<Exporter> exporter;
72
73 QElapsedTimer elapsedTimer;
74
77
79 : q(q_ptr)
80 , ui(new Ui::RecorderExport)
81 , settings(q_ptr->settings)
82 {
83 }
84
85#ifndef Q_OS_ANDROID
87 {
88 const QJsonObject ffmpegJson = KisFFMpegWrapper::findFFMpeg(settings->ffmpegPath);
89 const bool success = ffmpegJson["enabled"].toBool();
90 const QIcon &icon = KisIconUtils::loadIcon(success ? "dialog-ok" : "window-close");
91 const QList<QAction *> &actions = ui->editFfmpegPath->actions();
92 QAction *action;
93
94 if (!actions.isEmpty()) {
95 action = actions.first();
96 action->setIcon(icon);
97 } else {
98 action = ui->editFfmpegPath->addAction(icon, QLineEdit::TrailingPosition);
99 }
100 if (success) {
101 const QJsonArray h264Encoders = ffmpegJson["codecs"].toObject()["h264"].toObject()["encoders"].toArray();
102 settings->ffmpegPath = ffmpegJson["path"].toString();
103 settings->h264Encoder = h264Encoders.contains("libopenh264") ? "libopenh264" : "libx264";
104 ui->editFfmpegPath->setText(settings->ffmpegPath);
105 action->setToolTip("Version: "+ffmpegJson["version"].toString()
106 +(ffmpegJson["codecs"].toObject()["h264"].toObject()["encoding"].toBool() ? "":" (MP4/MKV UNSUPPORTED)")
107 );
108 } else {
109 ui->editFfmpegPath->setText(i18nc("This text is displayed instead of path to external tool in case of external tool is not found", "[NOT FOUND]"));
110 action->setToolTip(i18n("FFmpeg executable location couldn't be detected, please install it or select its location manually"));
111 }
112 ui->buttonBox->button(QDialogButtonBox::Save)->setEnabled(success);
113 }
114#endif
115
117 {
118 int indexToSelect;
119 {
120 QSignalBlocker blocker(ui->comboProfile);
121 ui->comboProfile->clear();
122#ifdef Q_OS_ANDROID
124 int count = formats.size();
125 if (count == 0) {
126 return;
127 }
128
129 indexToSelect = -1;
130 for (int i = 0, count = formats.size(); i < count; ++i) {
131 QString key = formats[i]->key();
132 ui->comboProfile->addItem(formats[i]->title(), QVariant(key));
133 if (key == settings->selectedFormat) {
134 indexToSelect = i;
135 }
136 }
137
138 if (indexToSelect == -1) {
139 indexToSelect = 0;
140 settings->selectedFormat = formats[0]->key();
141 }
142#else
143 for (const RecorderProfile &profile : settings->profiles) {
144 ui->comboProfile->addItem(profile.name);
145 }
146 indexToSelect = settings->profileIndex;
147#endif
148 }
149 ui->comboProfile->setCurrentIndex(indexToSelect);
150 }
151
153 {
155 QDir::Name, QDir::Files | QDir::NoDotAndDotDot);
156 QStringList frames = dir.entryList(); // dir.count() calls entryList().count() internally
157 settings->framesCount = frames.count();
158 if (settings->framesCount != 0) {
159 const QString &fileName = settings->inputDirectory % QDir::separator() % frames.last();
160 settings->imageSize = QImageReader(fileName).size();
161 settings->imageSize.rwidth() &= ~1;
162 settings->imageSize.rheight() &= ~1;
163 }
164#ifdef Q_OS_ANDROID
165 // QDir::entryList is mind-bogglingly slow on Android, so we only load
166 // this once and cache the result. Not like these are supposed to change
167 // while this modal dialog is up anyway.
168 settings->inputFilePaths.clear();
169 settings->inputFilePaths.reserve(settings->framesCount);
170 for (const QString &frame : frames) {
171 settings->inputFilePaths.append(dir.filePath(frame));
172 }
173#endif
174 }
175
176#ifndef Q_OS_ANDROID
178 {
179 if (settings->videoDirectory.isEmpty())
181
183 % QDir::separator()
185 % "."
186 % settings->profiles[settings->profileIndex].extension;
187 QSignalBlocker blocker(ui->editVideoFilePath);
188 ui->editVideoFilePath->setText(settings->videoFilePath);
189 }
190#endif
191
192 void updateRatio(bool widthToHeight)
193 {
194 const float ratio = static_cast<float>(settings->imageSize.width()) / static_cast<float>(settings->imageSize.height());
195 if (widthToHeight) {
196 settings->size.setHeight(static_cast<int>(settings->size.width() / ratio));
197 } else {
198 settings->size.setWidth(static_cast<int>(settings->size.height() * ratio));
199 }
200 // make width and height even
201 settings->size.rwidth() &= ~1;
202 settings->size.rheight() &= ~1;
203 QSignalBlocker blockerWidth(ui->spinScaleHeight);
204 QSignalBlocker blockerHeight(ui->spinScaleWidth);
205 ui->spinScaleHeight->setValue(settings->size.height());
206 ui->spinScaleWidth->setValue(settings->size.width());
207 }
208
209 void updateFps(RecorderExportConfig &config, bool takeFromInputFps = false)
210 {
211 if (!settings->lockFps)
212 return;
213
214 if (takeFromInputFps) {
216 config.setFps(settings->fps);
217 ui->spinFps->setValue(settings->fps);
218 } else {
221 ui->spinInputFps->setValue(settings->inputFps);
222 }
224 }
225
227 {
228 if (!exporter)
229 return true;
230
231 if (QMessageBox::question(q, q->windowTitle(), i18n("Abort encoding the timelapse video?"))
232 == QMessageBox::Yes) {
234 return true;
235 }
236
237 return false;
238 }
239
240#ifndef Q_OS_ANDROID
241 QStringList splitCommand(const QString &command)
242 {
243 QStringList args;
244 QString tmp;
245 int quoteCount = 0;
246 bool inQuote = false;
247
248 // handle quoting. tokens can be surrounded by double quotes
249 // "hello world". three consecutive double quotes represent
250 // the quote character itself.
251 for (int i = 0; i < command.size(); ++i) {
252 if (command.at(i) == QLatin1Char('"')) {
253 ++quoteCount;
254 if (quoteCount == 3) {
255 // third consecutive quote
256 quoteCount = 0;
257 tmp += command.at(i);
258 }
259 continue;
260 }
261 if (quoteCount) {
262 if (quoteCount == 1)
263 inQuote = !inQuote;
264 quoteCount = 0;
265 }
266 if (!inQuote && command.at(i).isSpace()) {
267 if (!tmp.isEmpty()) {
268 args += tmp;
269 tmp.clear();
270 }
271 } else {
272 tmp += command.at(i);
273 }
274 }
275 if (!tmp.isEmpty())
276 args += tmp;
277
278 return args;
279 }
280#endif
281
283 {
284 Q_ASSERT(exporter == nullptr);
285
286#ifndef Q_OS_ANDROID
287 // We don't do this again on Android, it's mind-bogglingly slow.
289#endif
290
291 exporter.reset(new Exporter(q));
292 QObject::connect(exporter.data(), SIGNAL(sigStarted()), q, SLOT(onExporterStarted()));
293 QObject::connect(exporter.data(), SIGNAL(sigFinished()), q, SLOT(onExporterFinished()));
294 QObject::connect(exporter.data(), SIGNAL(sigFinishedWithError(QString)), q, SLOT(onExporterFinishedWithError(QString)));
295 QObject::connect(exporter.data(), SIGNAL(sigProgressUpdated(int)), q, SLOT(onExporterProgressUpdated(int)));
296
297#ifdef Q_OS_ANDROID
298 KisMediaEncoderWrapperSettings exporterSettings = {
300 settings->inputFilePaths,
302 settings->formatPreferences.value(settings->selectedFormat).toMap(),
305 settings->fps,
308 };
309#else
311 KisFFMpegWrapperSettings exporterSettings;
312 exporterSettings.processPath = settings->ffmpegPath;
313 exporterSettings.args = splitCommand(applyVariables(profile.arguments));
314 exporterSettings.outputFile = settings->videoFilePath;
315 exporterSettings.batchMode = true; //TODO: Consider renaming to 'silent' mode, meaning no window for extra window handling...
316#endif
317
318 ui->labelStatus->setText(i18nc("Status for the export of the video record", "Starting exporter..."));
319 ui->buttonCancelExport->setEnabled(false);
320 ui->progressExport->setValue(0);
321 elapsedTimer.start();
322
323 // Do this last, it may immediately emit a failure.
324 exporter->startNonBlocking(exporterSettings);
325 }
326
328 {
329 if (exporter) {
330 exporter->reset();
331 exporter.reset();
332 }
333 }
334
335#ifndef Q_OS_ANDROID
336 QString applyVariables(const QString &templateArguments)
337 {
338 const QSize &outSize = settings->resize ? settings->size : settings->imageSize;
339 const int previewLength = settings->resultPreview ? settings->firstFrameSec : 0;
340 const int resultLength = settings->extendResult ? settings->lastFrameSec : 0;
341 const float transitionLength = settings->resultPreview ? 0.7 : 0;
342 return QString(templateArguments)
343 .replace("$IN_FPS", QString::number(settings->inputFps))
344 .replace("$OUT_FPS", QString::number(settings->fps))
345 .replace("$WIDTH", QString::number(outSize.width()))
346 .replace("$HEIGHT", QString::number(outSize.height()))
347 .replace("$FRAMES", QString::number(settings->framesCount))
348 .replace("$INPUT_DIR", settings->inputDirectory)
349 .replace("$FIRST_FRAME_SEC", QString::number(previewLength))
350 .replace("$TRANSITION_LENGTH", QString::number(transitionLength))
351 .replace("$H264_ENCODER", settings->h264Encoder)
352 .replace("$LAST_FRAME_SEC", QString::number(resultLength))
354 }
355#endif
356
358 {
359 long ms = (settings->framesCount * 1000L / (settings->inputFps ? settings->inputFps : 30));
360
361 if (settings->resultPreview) {
362 ms += (settings->firstFrameSec * 1000L);
363 }
364
365 if (settings->extendResult) {
366 ms += (settings->lastFrameSec * 1000L);
367 }
368
369 ui->labelVideoDuration->setText(formatDuration(ms));
370 }
371
372 QString formatDuration(long durationMs)
373 {
374 QString result;
375 const long ms = (durationMs % 1000) / 10;
376
377 result += QString(".%1").arg(ms, 2, 10, QLatin1Char('0'));
378
379 long duration = durationMs / 1000;
380 const long seconds = duration % 60;
381 result = QString("%1%2").arg(seconds, 2, 10, QLatin1Char('0')).arg(result);
382
383 duration = duration / 60;
384 const long minutes = duration % 60;
385 if (minutes != 0) {
386 result = QString("%1:%2").arg(minutes, 2, 10, QLatin1Char('0')).arg(result);
387
388 duration = duration / 60;
389 if (duration != 0)
390 result = QString("%1:%2").arg(duration, 2, 10, QLatin1Char('0')).arg(result);
391 }
392
393 return result;
394 }
395
397 {
398 // Video with dimensions above 1920 pixels isn't widely supported.
399 // They often fail to encode with mysterious errors, can't be played
400 // back properly and/or are rejected by websites where users attempt to
401 // upload the videos. This caps the dimensions to avoid that trap.
402 settings->resize = true;
403 settings->lockRatio = true;
404 if (settings->imageSize.isEmpty()) {
405 settings->size = QSize(1024, 1024);
406 } else {
407 int iw = settings->imageSize.width();
408 int ih = settings->imageSize.height();
409 if (iw > DIMENSION_LIMIT || ih > DIMENSION_LIMIT) {
410 int ow, oh;
411 if (iw >= ih) {
412 ow = DIMENSION_LIMIT;
413 oh = qRound(qreal(DIMENSION_LIMIT) / qreal(iw) * qreal(ih));
414 } else {
415 ow = qRound(qreal(DIMENSION_LIMIT) / qreal(ih) * qreal(iw));
416 oh = DIMENSION_LIMIT;
417 }
418 ow &= ~1;
419 oh &= ~1;
420 settings->size = QSize(ow, oh);
421 } else {
423 }
424 }
425 }
426
428 {
429 ui->wdgWarnFps->setVisible(settings->fps > 30);
430 QSize size = settings->resize ? settings->size : settings->imageSize;
431 ui->wdgWarnSize->setVisible(size.width() > DIMENSION_LIMIT || size.height() > DIMENSION_LIMIT);
432 }
433
434 QString requestFile(const QString &extension, const QString &defaultDir = QString())
435 {
436 KoFileDialog dialog(q, KoFileDialog::SaveFile, "ExportTimelapse");
437 dialog.setCaption(i18n("Export Timelapse Video As"));
438 if (!defaultDir.isEmpty()) {
439 dialog.setDefaultDir(defaultDir);
440 }
441 dialog.setMimeTypeFilters(QStringList(KisMimeDatabase::mimeTypeForSuffix(extension)));
442 return dialog.filename();
443 }
444
445 static void desktopServicesOpenPath(const QString &path)
446 {
447#ifdef Q_OS_ANDROID
448 // QDesktopServices doesn't clear exceptions
449 KisAndroidUtils::clearJniException(QStringLiteral("before opening ") + path);
450 QDesktopServices::openUrl(QUrl(path));
451 KisAndroidUtils::clearJniException(QStringLiteral("after opening ") + path);
452#else
453 QDesktopServices::openUrl(QUrl::fromLocalFile(path));
454#endif
455 }
456};
457
458
460 : QDialog(parent)
461 , settings(s)
462 , d(new Private(this))
463{
464 d->ui->setupUi(this);
465
466#ifdef Q_OS_ANDROID
467 d->ui->labelFfmpegLocation->hide();
468 d->ui->editFfmpegPath->hide();
469 d->ui->buttonBrowseFfmpeg->hide();
470 d->ui->labelExportTo->hide();
471 d->ui->editVideoFilePath->hide();
472 d->ui->buttonBrowseExport->hide();
473 d->ui->buttonShowInFolder->hide();
474#else
475 d->ui->buttonBrowseFfmpeg->setIcon(KisIconUtils::loadIcon("folder"));
476 d->ui->buttonBrowseExport->setIcon(KisIconUtils::loadIcon("folder"));
477 d->ui->buttonShowInFolder->setIcon(KisIconUtils::loadIcon("folder"));
478#endif
479
480 d->spinInputFPSMaxValue = d->ui->spinInputFps->minimum();
481 d->spinInputFPSMaxValue = d->ui->spinInputFps->maximum();
482 d->ui->buttonBrowseDirectory->setIcon(KisIconUtils::loadIcon("view-preview"));
483 d->ui->buttonEditProfile->setIcon(KisIconUtils::loadIcon("document-edit"));
484 d->ui->buttonLockRatio->setIcon(settings->lockRatio ? KisIconUtils::loadIcon("locked") : KisIconUtils::loadIcon("unlocked"));
485 d->ui->buttonLockFps->setIcon(settings->lockFps ? KisIconUtils::loadIcon("locked") : KisIconUtils::loadIcon("unlocked"));
486 d->ui->buttonWatchIt->setIcon(KisIconUtils::loadIcon("media-playback-start"));
487 d->ui->buttonRemoveSnapshots->setIcon(KisIconUtils::loadIcon("edit-delete"));
488 d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageSettings);
489 d->ui->spinLastFrameSec->setEnabled(d->ui->extendResultCheckBox->isChecked());
490 d->ui->spinFirstFrameSec->setEnabled(d->ui->resultPreviewCheckBox->isChecked());
491 d->ui->lblWarnFpsIcon->setPixmap(KisIconUtils::loadIcon("dialog-warning").pixmap(48, 48));
492 d->ui->lblWarnSizeIcon->setPixmap(KisIconUtils::loadIcon("dialog-warning").pixmap(48, 48));
493 d->ui->wdgWarnFps->hide();
494 d->ui->wdgWarnSize->hide();
495
496 connect(d->ui->buttonBrowseDirectory, SIGNAL(clicked()), SLOT(onButtonBrowseDirectoryClicked()));
497 connect(d->ui->spinInputFps, SIGNAL(valueChanged(int)), SLOT(onSpinInputFpsValueChanged(int)));
498 connect(d->ui->spinFps, SIGNAL(valueChanged(int)), SLOT(onSpinFpsValueChanged(int)));
499 connect(d->ui->resultPreviewCheckBox, SIGNAL(toggled(bool)), SLOT(onCheckResultPreviewToggled(bool)));
500 connect(d->ui->spinFirstFrameSec, SIGNAL(valueChanged(int)), SLOT(onFirstFrameSecValueChanged(int)));
501 connect(d->ui->extendResultCheckBox, SIGNAL(toggled(bool)), SLOT(onCheckExtendResultToggled(bool)));
502 connect(d->ui->spinLastFrameSec, SIGNAL(valueChanged(int)), SLOT(onLastFrameSecValueChanged(int)));
503 connect(d->ui->checkResize, SIGNAL(toggled(bool)), SLOT(onCheckResizeToggled(bool)));
504 connect(d->ui->spinScaleWidth, SIGNAL(valueChanged(int)), SLOT(onSpinScaleWidthValueChanged(int)));
505 connect(d->ui->spinScaleHeight, SIGNAL(valueChanged(int)), SLOT(onSpinScaleHeightValueChanged(int)));
506 connect(d->ui->buttonLockRatio, SIGNAL(toggled(bool)), SLOT(onButtonLockRatioToggled(bool)));
507 connect(d->ui->buttonLockFps, SIGNAL(toggled(bool)), SLOT(onButtonLockFpsToggled(bool)));
508 connect(d->ui->comboProfile, SIGNAL(currentIndexChanged(int)), SLOT(onComboProfileIndexChanged(int)));
509 connect(d->ui->buttonEditProfile, SIGNAL(clicked()), SLOT(onButtonEditProfileClicked()));
510 connect(d->ui->buttonBox->button(QDialogButtonBox::Save), SIGNAL(clicked()), this, SLOT(onButtonExportClicked()));
511 connect(d->ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
512 connect(d->ui->buttonCancelExport, SIGNAL(clicked()), SLOT(onButtonCancelClicked()));
513 connect(d->ui->buttonWatchIt, SIGNAL(clicked()), SLOT(onButtonWatchItClicked()));
514 connect(d->ui->buttonRemoveSnapshots, SIGNAL(clicked()), SLOT(onButtonRemoveSnapshotsClicked()));
515 connect(d->ui->buttonRestart, SIGNAL(clicked()), SLOT(onButtonRestartClicked()));
516 connect(d->ui->resultPreviewCheckBox, SIGNAL(toggled(bool)), d->ui->spinFirstFrameSec, SLOT(setEnabled(bool)));
517 connect(d->ui->extendResultCheckBox, SIGNAL(toggled(bool)), d->ui->spinLastFrameSec, SLOT(setEnabled(bool)));
518
519#ifndef Q_OS_ANDROID
520 connect(d->ui->buttonBrowseFfmpeg, SIGNAL(clicked()), SLOT(onButtonBrowseFfmpegClicked()));
521 connect(d->ui->buttonBrowseExport, SIGNAL(clicked()), SLOT(onButtonBrowseExportClicked()));
522 connect(d->ui->editVideoFilePath, SIGNAL(textChanged(QString)), SLOT(onEditVideoPathChanged(QString)));
523 connect(d->ui->buttonShowInFolder, SIGNAL(clicked()), SLOT(onButtonShowInFolderClicked()));
524#endif
525
527 d->ui->buttonBox->button(QDialogButtonBox::Close)->setText("OK");
528 d->ui->buttonBox->button(QDialogButtonBox::Save)->setText(i18n("Export"));
529#ifndef Q_OS_ANDROID
530 d->ui->editVideoFilePath->installEventFilter(this);
531#endif
532}
533
537
539{
540 RecorderExportConfig config(true);
541 d->updateFps(config);
542 d->updateFrameInfo();
543
544 if (settings->framesCount == 0) {
545 d->ui->labelRecordInfo->setText(i18nc("Can't export recording because nothing to export", "No frames to export"));
546 d->ui->buttonBox->button(QDialogButtonBox::Save)->setEnabled(false);
547 } else {
548 d->ui->labelRecordInfo->setText(QString("%1: %2x%3 %4, %5 %6")
549 .arg(i18nc("General information about recording", "Recording info"))
550 .arg(settings->imageSize.width())
551 .arg(settings->imageSize.height())
552 .arg(i18nc("Pixel dimension suffix", "px"))
553 .arg(settings->framesCount)
554 .arg(i18nc("The suffix after number of frames", "frame(s)"))
555 );
556 }
557
558
559 // Don't load lockFps flag from config, if liveCaptureMode was just set by the user
562
563 // Video dimensions are much more restrictive than image dimensions.
564 // Clobber them with sensible defaults instead of letting the user run into
565 // the trap of trying to export video well beyond what most encoders,
566 // devices and websites support.
567 d->initDimensions();
568
569 d->ui->spinInputFps->setValue(settings->inputFps);
570 d->ui->spinFps->setValue(settings->fps);
571 d->ui->resultPreviewCheckBox->setChecked(settings->resultPreview);
572 d->ui->spinFirstFrameSec->setValue(settings->firstFrameSec);
573 d->ui->extendResultCheckBox->setChecked(settings->extendResult);
574 d->ui->spinLastFrameSec->setValue(settings->lastFrameSec);
575 d->ui->checkResize->setChecked(settings->resize);
576 {
577 // Need to block signals or else a locked ratio will mess these up.
578 QSignalBlocker spinScaleWidthBlocker(d->ui->spinScaleWidth);
579 QSignalBlocker spinScaleHeightBlocker(d->ui->spinScaleHeight);
580 QSignalBlocker buttonLockRatioBlocker(d->ui->buttonLockRatio);
581 d->ui->spinScaleWidth->setValue(settings->size.width());
582 d->ui->spinScaleHeight->setValue(settings->size.height());
583 d->ui->buttonLockRatio->setChecked(settings->lockRatio);
584 }
585 d->ui->buttonLockRatio->setIcon(settings->lockRatio ? KisIconUtils::loadIcon("locked") : KisIconUtils::loadIcon("unlocked"));
586 d->ui->labelRealTimeCaptureNotion->setVisible(settings->realTimeCaptureMode);
587 d->ui->buttonLockFps->setChecked(settings->lockFps);
588 d->ui->buttonLockFps->setIcon(settings->lockFps ? KisIconUtils::loadIcon("locked") : KisIconUtils::loadIcon("unlocked"));
589 d->fillComboProfiles();
590#ifndef Q_OS_ANDROID
591 d->checkExporter();
592 d->updateVideoFilePath();
593#endif
594 d->updateVideoDuration();
595}
596
597void RecorderExport::closeEvent(QCloseEvent *event)
598{
599 if (!d->tryAbortExport())
600 event->ignore();
601}
602
604{
605 if (d->tryAbortExport())
606 QDialog::reject();
607}
608
610{
611 if (settings->framesCount != 0) {
613 } else {
614 QMessageBox::warning(this, windowTitle(), i18nc("Can't browse frames of recording because no frames have been recorded", "No frames to browse."));
615 return;
616 }
617}
618
620{
622 RecorderExportConfig config(false);
623 config.setInputFps(value);
624 d->updateFps(config, true);
625 d->updateVideoDuration();
626}
627
629{
630 settings->fps = value;
631 RecorderExportConfig config(false);
632 config.setFps(value);
633 d->updateFps(config, false);
634 d->updateVideoDuration();
635 d->updateWarningVisibility();
636}
637
639{
640 settings->resultPreview = checked;
642 d->updateVideoDuration();
643}
644
651
653{
654 settings->extendResult = checked;
656 d->updateVideoDuration();
657}
658
660{
663 d->updateVideoDuration();
664}
665
667{
668 settings->resize = checked;
669 RecorderExportConfig(false).setResize(checked);
670 d->updateWarningVisibility();
671}
672
674{
675 settings->size.setWidth(value);
676 if (settings->lockRatio)
677 d->updateRatio(true);
679 d->updateWarningVisibility();
680}
681
683{
684 settings->size.setHeight(value);
685 if (settings->lockRatio)
686 d->updateRatio(false);
688 d->updateWarningVisibility();
689}
690
692{
693 settings->lockRatio = checked;
694 RecorderExportConfig config(false);
695 config.setLockRatio(checked);
696 if (settings->lockRatio) {
697 d->updateRatio(true);
698 config.setSize(settings->size);
699 }
700 d->ui->buttonLockRatio->setIcon(settings->lockRatio ? KisIconUtils::loadIcon("locked") : KisIconUtils::loadIcon("unlocked"));
701 d->updateWarningVisibility();
702}
703
705{
706 settings->lockFps = checked;
707 RecorderExportConfig config(false);
708 config.setLockFps(checked);
709 d->updateFps(config);
710 if (settings->lockFps) {
711 d->ui->buttonLockFps->setIcon(KisIconUtils::loadIcon("locked"));
712 d->ui->spinInputFps->setMinimum(d->ui->spinFps->minimum());
713 d->ui->spinInputFps->setMaximum(d->ui->spinFps->maximum());
714 } else {
715 d->ui->buttonLockFps->setIcon(KisIconUtils::loadIcon("unlocked"));
716 d->ui->spinInputFps->setMinimum(d->spinInputFPSMinValue);
717 d->ui->spinInputFps->setMaximum(d->spinInputFPSMaxValue);
718 }
719 d->updateWarningVisibility();
720}
721
722#ifndef Q_OS_ANDROID
724{
725 KoFileDialog dialog(this, KoFileDialog::OpenFile, "SelectFFmpeg");
726 dialog.setCaption(i18n("Select FFmpeg Executable File"));
727 dialog.setDefaultDir(settings->ffmpegPath);
728 QString file = dialog.filename();
729 if (!file.isEmpty()) {
730 settings->ffmpegPath = file;
732 d->checkExporter();
733 }
734}
735#endif
736
738{
739#ifdef Q_OS_ANDROID
740 QString format = d->ui->comboProfile->itemData(index).toString();
741 d->settings->selectedFormat = format;
742 RecorderExportConfig(false).setSelectedFormat(format);
743#else
744 settings->profileIndex = index;
745 d->updateVideoFilePath();
747#endif
748}
749
751{
752#ifdef Q_OS_ANDROID
753 QString key = settings->selectedFormat;
756
757 KisMediaEncoderPreferencesDialog dlg(format, settings->formatPreferences.value(key).toMap(), this);
758 if (dlg.exec() == QDialog::Accepted) {
759 settings->formatPreferences.insert(key, dlg.preferences());
760 RecorderExportConfig(false).setFormatPreferences(settings->formatPreferences);
761 }
762#else
763 RecorderProfileSettings settingsDialog(this);
764
765 connect(&settingsDialog, &RecorderProfileSettings::requestPreview, [&](const QString & arguments) {
766 settingsDialog.setPreview(settings->ffmpegPath % " -y " % d->applyVariables(arguments).replace("\n", " ")
767 % " \"" % settings->videoFilePath % "\"");
768 });
769
770 if (settingsDialog.editProfile(
772 d->fillComboProfiles();
773 d->updateVideoFilePath();
775 }
776#endif
777}
778
779#ifndef Q_OS_ANDROID
780void RecorderExport::onEditVideoPathChanged(const QString &videoFilePath)
781{
782 QFileInfo fileInfo(videoFilePath);
783 if (!fileInfo.isRelative())
784 settings->videoDirectory = fileInfo.absolutePath();
785 settings->videoFileName = fileInfo.completeBaseName();
786}
787#endif
788
789#ifndef Q_OS_ANDROID
791{
792 QString videoFileName = d->requestFile(settings->profiles[settings->profileIndex].extension, settings->videoDirectory);
793 if (!videoFileName.isEmpty()) {
794 QFileInfo fileInfo(videoFileName);
795 settings->videoDirectory = fileInfo.absolutePath();
796 settings->videoFileName = fileInfo.completeBaseName();
798 d->updateVideoFilePath();
799 }
800}
801#endif
802
804{
805 if (settings->framesCount == 0) {
806 QMessageBox::warning(this, windowTitle(), i18n("No frames to export."));
807 return;
808 }
809
810#ifdef Q_OS_ANDROID
813 settings->videoFilePath = d->requestFile(format->extension());
814 if (settings->videoFilePath.isEmpty()) {
815 return;
816 }
817#else
818 if (QFile::exists(settings->videoFilePath)) {
819 if (QMessageBox::question(this, windowTitle(),
820 i18n("The video file already exists. Do you wish to overwrite it?"))
821 != QMessageBox::Yes) {
822 return;
823 }
824 }
825#endif
826
827 d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageProgress);
828 d->startExport();
829}
830
832{
833 if (d->cleaner) {
834 d->cleaner->stop();
835 d->cleaner->deleteLater();
836 d->cleaner = nullptr;
837 return;
838 }
839
840 if (d->tryAbortExport())
841 d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageSettings);
842}
843
844
846{
847 d->ui->buttonCancelExport->setEnabled(true);
848 d->ui->labelStatus->setText(i18n("The timelapse video is being encoded..."));
849}
850
852{
853 quint64 elapsed = d->elapsedTimer.elapsed();
854 d->ui->labelRenderTime->setText(d->formatDuration(elapsed));
855 d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageDone);
856 d->ui->labelVideoPathDone->setText(settings->videoFilePath);
857 d->cleanupExporter();
858}
859
861{
862 d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageSettings);
863 QMessageBox::critical(this, windowTitle(), i18n("Export failed. Error message:") % "\n\n" % error);
864 d->cleanupExporter();
865}
866
868{
869 d->ui->progressExport->setValue(frameNo * 100 / (settings->framesCount * settings->fps / static_cast<float>(settings->inputFps)));
870}
871
876
877#ifndef Q_OS_ANDROID
882#endif
883
885{
886 const QString confirmation(i18n("The recordings for this document will be deleted"
887 " and you will not be able to export a timelapse for it again"
888 ". Note that already exported timelapses will still be preserved."
889 "\n\nDo you wish to continue?"));
890 if (QMessageBox::question(this, windowTitle(), confirmation) != QMessageBox::Yes)
891 return;
892
893 d->ui->labelStatus->setText(i18nc("Label title, Snapshot directory deleting is in progress", "Cleaning up..."));
894 d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageProgress);
895
896 Q_ASSERT(d->cleaner == nullptr);
897 d->cleaner = new RecorderDirectoryCleaner({d->settings->inputDirectory});
898 connect(d->cleaner, SIGNAL(finished()), this, SLOT(onCleanUpFinished()));
899 d->cleaner->start();
900}
901
903{
904 d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageSettings);
905}
906
908{
909 d->cleaner->deleteLater();
910 d->cleaner = nullptr;
911
912 d->ui->stackedWidget->setCurrentIndex(ExportPageIndex::PageDone);
913 d->ui->buttonRestart->hide();
914 d->ui->buttonRemoveSnapshots->hide();
915}
916
917#ifndef Q_OS_ANDROID
918bool RecorderExport::eventFilter(QObject *obj, QEvent *event)
919{
920 if (obj == d->ui->editVideoFilePath && event->type() == QEvent::FocusOut)
921 d->updateVideoFilePath();
922
923 return QDialog::eventFilter(obj, event);
924}
925#endif
float value(const T *src, size_t ch)
QList< QString > QStringList
static QJsonObject findFFMpeg(const QString &customLocation)
virtual QString extension() const =0
static KisMediaEncoderFormat * getFormatByKey(const QString &key)
static const QVector< KisMediaEncoderFormat * > & getSupportedFormats()
static QString mimeTypeForSuffix(const QString &suffix)
Find the mimetype for a given extension. The extension may have the form "*.xxx" or "xxx".
void setProfiles(const QList< RecorderProfile > &value)
void setVideoDirectory(const QString &value)
void loadConfiguration(RecorderExportSettings *settings, bool loadLockFps=true) const
void setFfmpegPath(const QString &value)
void setSize(const QSize &value)
QString requestFile(const QString &extension, const QString &defaultDir=QString())
void updateFps(RecorderExportConfig &config, bool takeFromInputFps=false)
RecorderDirectoryCleaner * cleaner
QString applyVariables(const QString &templateArguments)
QScopedPointer< Ui::RecorderExport > ui
RecorderExportSettings * settings
QString formatDuration(long durationMs)
static void desktopServicesOpenPath(const QString &path)
Private(RecorderExport *q_ptr)
QScopedPointer< Exporter > exporter
static constexpr int DIMENSION_LIMIT
QStringList splitCommand(const QString &command)
void updateRatio(bool widthToHeight)
void onButtonBrowseFfmpegClicked()
void onCheckResultPreviewToggled(bool checked)
void onSpinInputFpsValueChanged(int value)
void onLastFrameSecValueChanged(int value)
void onButtonShowInFolderClicked()
void closeEvent(QCloseEvent *event) override
void onEditVideoPathChanged(const QString &videoFilePath)
RecorderExportSettings * settings
RecorderExport(RecorderExportSettings *s, QWidget *parent=nullptr)
void onSpinScaleHeightValueChanged(int value)
void onButtonEditProfileClicked()
void onExporterFinishedWithError(QString error)
void onFirstFrameSecValueChanged(int value)
void onButtonRemoveSnapshotsClicked()
QScopedPointer< Private > d
void reject() override
void onCheckExtendResultToggled(bool checked)
bool eventFilter(QObject *obj, QEvent *event) override
void onCheckResizeToggled(bool checked)
void onButtonBrowseDirectoryClicked()
void onButtonLockFpsToggled(bool checked)
void onComboProfileIndexChanged(int index)
void onSpinFpsValueChanged(int value)
void onButtonLockRatioToggled(bool checked)
void onExporterProgressUpdated(int frameNo)
void onSpinScaleWidthValueChanged(int value)
void onButtonBrowseExportClicked()
bool editProfile(RecorderProfile *profile, const RecorderProfile &defaultProfile)
void setPreview(const QString &preview)
void requestPreview(QString arguments)
#define KIS_SAFE_ASSERT_RECOVER_RETURN(cond)
Definition kis_assert.h:128
void clearJniException(const QString &location)
QIcon loadIcon(const QString &name)
QLatin1String fileExtension(RecorderFormat format)
QList< RecorderProfile > profiles
QList< RecorderProfile > defaultProfiles