/* * Copyright (c) 2012-2025 Meltytech, LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "encodejob.h" #include "dialogs/listselectiondialog.h" #include "docks/timelinedock.h" #include "jobqueue.h" #include "jobs/videoqualityjob.h" #include "mainwindow.h" #include "qmltypes/qmlapplication.h" #include "qmltypes/qmlutilities.h" #include "settings.h" #include "spatialmedia/spatialmedia.h" #include "util.h" #include #include #include #include #include #include #include #include #include #include #include "Logger.h" EncodeJob::EncodeJob(const QString &name, const QString &xml, int frameRateNum, int frameRateDen, QThread::Priority priority) : MeltJob(name, xml, frameRateNum, frameRateDen, priority) { QAction *action = new QAction(tr("Open"), this); action->setData("Open"); action->setToolTip(tr("Open the output file in the Shotcut player")); connect(action, SIGNAL(triggered()), this, SLOT(onOpenTiggered())); m_successActions << action; action = new QAction(tr("Show In Files"), this); action->setToolTip(tr("Show In Files")); connect(action, SIGNAL(triggered()), this, SLOT(onShowInFilesTriggered())); m_successActions << action; action = new QAction(tr("Show In Folder"), this); action->setToolTip(tr("Show In Folder")); connect(action, SIGNAL(triggered()), this, SLOT(onShowFolderTriggered())); m_successActions << action; action = new QAction(tr("Measure Video Quality..."), this); connect(action, SIGNAL(triggered()), this, SLOT(onVideoQualityTriggered())); m_successActions << action; action = new QAction(tr("Set Equirectangular..."), this); connect(action, SIGNAL(triggered()), this, SLOT(onSpatialMediaTriggered())); m_successActions << action; action = new QAction(tr("Embed Markers as Chapters..."), this); connect(action, SIGNAL(triggered()), this, SLOT(onEmbedChapters())); m_successActions << action; } void EncodeJob::onVideoQualityTriggered() { // Get the location and file name for the report. QString directory = Settings.encodePath(); QString caption = tr("Video Quality Report"); QString nameFilter = tr("Text Documents (*.txt);;All Files (*)"); QString reportPath = QFileDialog::getSaveFileName(&MAIN, caption, directory, nameFilter, nullptr, Util::getFileDialogOptions()); if (!reportPath.isEmpty()) { QFileInfo fi(reportPath); if (fi.suffix().isEmpty()) reportPath += ".txt"; if (Util::warnIfNotWritable(reportPath, &MAIN, caption)) return; // Get temp file for the new XML. QScopedPointer tmp(Util::writableTemporaryFile(reportPath)); tmp->open(); // Generate the XML for the comparison. Mlt::Tractor tractor(MLT.profile()); Mlt::Producer original(MLT.profile(), xmlPath().toUtf8().constData()); Mlt::Producer encoded(MLT.profile(), objectName().toUtf8().constData()); Mlt::Transition vqm(MLT.profile(), "vqm"); if (original.is_valid() && encoded.is_valid() && vqm.is_valid()) { tractor.set_track(original, 0); tractor.set_track(encoded, 1); tractor.plant_transition(vqm); vqm.set("render", 0); MLT.saveXML(tmp->fileName(), &tractor, false /* without relative paths */, tmp.data()); tmp->close(); // Add consumer element to XML. QFile f1(tmp->fileName()); f1.open(QIODevice::ReadOnly); QDomDocument dom(tmp->fileName()); dom.setContent(&f1); f1.close(); QDomElement consumerNode = dom.createElement("consumer"); QDomNodeList profiles = dom.elementsByTagName("profile"); if (profiles.isEmpty()) dom.documentElement().insertAfter(consumerNode, dom.documentElement()); else dom.documentElement().insertAfter(consumerNode, profiles.at(profiles.length() - 1)); consumerNode.setAttribute("mlt_service", "null"); consumerNode.setAttribute("real_time", -1); consumerNode.setAttribute("terminate_on_pause", 1); // Create job and add it to the queue. JOBS.add(new VideoQualityJob(objectName(), dom.toString(2), reportPath, MLT.profile().frame_rate_num(), MLT.profile().frame_rate_den())); } } } void EncodeJob::onSpatialMediaTriggered() { // Get the location and file name for the output file. QString caption = tr("Set Equirectangular Projection"); QFileInfo info(objectName()); QString directory = QStringLiteral("%1/%2 - ERP.%3") .arg(Settings.encodePath()) .arg(info.completeBaseName()) .arg(info.suffix()); QString filePath = QFileDialog::getSaveFileName(&MAIN, caption, directory, QString(), nullptr, Util::getFileDialogOptions()); if (!filePath.isEmpty()) { if (SpatialMedia::injectSpherical(objectName().toStdString(), filePath.toStdString())) { MAIN.showStatusMessage(tr("Successfully wrote %1").arg(QFileInfo(filePath).fileName())); } else { MAIN.showStatusMessage(tr("An error occurred saving the projection.")); } } } void EncodeJob::onEmbedChapters() { // Options dialog auto uniqueColors = MAIN.timelineDock()->markersModel()->allColors(); if (uniqueColors.isEmpty()) { return; } std::sort(uniqueColors.begin(), uniqueColors.end(), [=](const QColor &a, const QColor &b) { if (a.hue() == b.hue()) { if (a.saturation() == b.saturation()) { return a.value() <= b.value(); } return a.saturation() <= b.saturation(); } return a.hue() <= b.hue(); }); QStringList colors; for (auto &color : uniqueColors) { colors << color.name(); } const auto rangesOption = tr("Include ranges (Duration > 1 frame)?"); QStringList initialOptions; for (auto &m : MAIN.timelineDock()->markersModel()->getMarkers()) { if (m.end != m.start) { initialOptions << rangesOption; break; } } ListSelectionDialog dialog(initialOptions, &MAIN); dialog.setWindowModality(QmlApplication::dialogModality()); dialog.setWindowTitle(tr("Choose Markers")); if (Settings.exportRangeMarkers()) { dialog.setSelection({rangesOption}); } dialog.setColors(colors); if (dialog.exec() != QDialog::Accepted) { return; } auto selection = dialog.selection(); Settings.setExportRangeMarkers(selection.contains(rangesOption)); // Get the location and file name for the output file. QString caption = tr("Embed Chapters"); QFileInfo info(objectName()); QString directory = QStringLiteral("%1/%2 - Chapters.%3") .arg(Settings.encodePath(), info.completeBaseName(), info.suffix()); QString filePath = QFileDialog::getSaveFileName(&MAIN, caption, directory, QString(), nullptr, Util::getFileDialogOptions()); if (!filePath.isEmpty()) { if (Util::warnIfNotWritable(filePath, &MAIN, caption)) return; // Locate the JavaScript file in the filesystem. QDir qmlDir = QmlUtilities::qmlDir(); qmlDir.cd("export-chapters"); auto jsFileName = qmlDir.absoluteFilePath("export-chapters.js"); QFile scriptFile(jsFileName); if (scriptFile.open(QIODevice::ReadOnly)) { // Read JavaScript into a string. QTextStream stream(&scriptFile); stream.setEncoding(QStringConverter::Utf8); stream.setAutoDetectUnicode(true); QString contents = stream.readAll(); scriptFile.close(); // Evaluate JavaScript. QJSEngine jsEngine; QJSValue result = jsEngine.evaluate(contents, jsFileName); if (!result.isError()) { // Call the JavaScript main function. QJSValue options = jsEngine.newObject(); options.setProperty("ffmetadata", true); if (selection.contains(rangesOption)) { options.setProperty("includeRanges", true); selection.removeOne(rangesOption); } QJSValue array = jsEngine.newArray(selection.size()); for (int i = 0; i < selection.size(); ++i) array.setProperty(i, selection[i].toUpper()); options.setProperty("colors", array); QJSValueList args; args << MLT.XML(0, true, true) << options; result = result.call(args); if (!result.isError()) { // Save the result with the export file name. auto tempFile = Util::writableTemporaryFile(filePath, "shotcut-XXXXXX.txt"); tempFile->write(result.toString().toUtf8()); tempFile->close(); QStringList args; args << "-i" << objectName() << "-i" << tempFile->fileName() << "-map_metadata" << "1" << "-c" << "copy" << "-y" << filePath; auto job = new FfmpegJob(filePath, args, false); job->setLabel(filePath); tempFile->setParent(job); JOBS.add(job); } } else { LOG_ERROR() << "Uncaught exception at line" << result.property("lineNumber").toInt() << ":" << result.toString(); MAIN.showStatusMessage(tr("A JavaScript error occurred during export.")); } } else { MAIN.showStatusMessage(tr("Failed to open export-chapters.js")); } } } void EncodeJob::onFinished(int exitCode, QProcess::ExitStatus exitStatus) { if (exitStatus != QProcess::NormalExit && exitCode != 0 && !stopped()) { LOG_INFO() << "job failed with" << exitCode; appendToLog(QStringLiteral("Failed with exit code %1\n").arg(exitCode)); bool isParallel = false; // Parse the XML. m_xml->open(); QDomDocument dom(xmlPath()); dom.setContent(m_xml.data()); m_xml->close(); // Locate the consumer element. QDomNodeList consumers = dom.elementsByTagName("consumer"); for (int i = 0; i < consumers.length(); i++) { QDomElement consumer = consumers.at(i).toElement(); // If real_time is set for parallel. if (consumer.attribute("real_time").toInt() < -1) { isParallel = true; consumer.setAttribute("real_time", "-1"); } } if (isParallel) { QString message(tr("Export job failed; trying again without Parallel processing.")); MAIN.showStatusMessage(message); appendToLog(message.append("\n")); m_xml->open(); QTextStream textStream(m_xml.data()); dom.save(textStream, 2); m_xml->close(); MeltJob::start(); return; } } MeltJob::onFinished(exitCode, exitStatus); }