/*
* Copyright (c) 2023-2024 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 "transcoder.h"
#include "jobqueue.h"
#include "jobs/ffmpegjob.h"
#include "mainwindow.h"
#include "settings.h"
#include "shotcut_mlt_properties.h"
#include "util.h"
#include
#include
static const auto kHandleSeconds = 15.0;
void Transcoder::setProducers(QList &producers)
{
m_producers = producers;
}
void Transcoder::addProducer(Mlt::Producer &producer)
{
m_producers.append(producer);
}
void Transcoder::addProducer(Mlt::Producer *producer)
{
m_producers.append(*producer);
}
void Transcoder::convert(TranscodeDialog &dialog)
{
int result = dialog.exec();
if (dialog.isCheckBoxChecked()) {
Settings.setShowConvertClipDialog(false);
}
if (result != QDialog::Accepted) {
return;
}
QString path = Settings.savePath();
QString suffix = dialog.isSubClip() ? tr("Sub-clip") + ' ' : tr("Converted");
QString filename;
QString nameFormat;
QString nameFilter;
switch (dialog.format()) {
case 0:
nameFormat = "/%1 - %2.mp4";
nameFilter = tr("MP4 (*.mp4);;All Files (*)");
break;
case 1:
nameFormat = "/%1 - %2.mov";
nameFilter = tr("MOV (*.mov);;All Files (*)");
break;
case 2:
nameFormat = "/%1 - %2.mkv";
nameFilter = tr("MKV (*.mkv);;All Files (*)");
break;
}
if (m_producers.length() == 1) {
QString resource = Util::GetFilenameFromProducer(&m_producers[0]);
QFileInfo fi(resource);
filename = path + nameFormat.arg(fi.completeBaseName(), suffix);
if (dialog.isSubClip()) {
filename = Util::getNextFile(path);
}
filename = QFileDialog::getSaveFileName(MAIN.centralWidget(),
dialog.windowTitle(),
filename,
nameFilter,
nullptr,
Util::getFileDialogOptions());
if (!filename.isEmpty()) {
if (filename == QDir::toNativeSeparators(resource)) {
QMessageBox::warning(MAIN.centralWidget(),
dialog.windowTitle(),
QObject::tr("Unable to write file %1\n"
"Perhaps you do not have permission.\n"
"Try again with a different folder.")
.arg(fi.fileName()));
return;
}
if (JOBS.targetIsInProgress(filename)) {
MAIN.showStatusMessage(tr("A job already exists for %1").arg(filename));
return;
}
if (Util::warnIfNotWritable(filename, MAIN.centralWidget(), dialog.windowTitle()))
return;
if (Util::warnIfLowDiskSpace(filename)) {
MAIN.showStatusMessage(tr("Convert canceled"));
return;
}
}
convertProducer(&m_producers[0], dialog, filename);
} else if (m_producers.length() > 1) {
path = QFileDialog::getExistingDirectory(MAIN.centralWidget(),
dialog.windowTitle(),
path,
Util::getFileDialogOptions());
if (path.isEmpty()) {
MAIN.showStatusMessage(tr("Convert canceled"));
return;
}
if (Util::warnIfNotWritable(path, MAIN.centralWidget(), dialog.windowTitle())) {
return;
}
if (Util::warnIfLowDiskSpace(path)) {
MAIN.showStatusMessage(tr("Convert canceled"));
return;
}
for (auto &producer : m_producers) {
QString resource = Util::GetFilenameFromProducer(&producer);
QFileInfo fi(resource);
filename = path + nameFormat.arg(fi.completeBaseName(), suffix);
filename = Util::getNextFile(filename);
if (JOBS.targetIsInProgress(filename)) {
if (JOBS.targetIsInProgress(filename)) {
MAIN.showStatusMessage(tr("A job already exists for %1").arg(filename));
return;
}
}
convertProducer(&producer, dialog, filename);
}
}
Settings.setSavePath(QFileInfo(filename).path());
}
void Transcoder::convertProducer(Mlt::Producer *producer, TranscodeDialog &dialog, QString filename)
{
QString resource = Util::GetFilenameFromProducer(producer);
QStringList args;
int in = -1;
args << "-loglevel"
<< "verbose";
args << "-i" << resource;
args << "-max_muxing_queue_size"
<< "9999";
if (dialog.isSubClip()) {
if (Settings.proxyEnabled()) {
producer->Mlt::Properties::clear(kOriginalResourceProperty);
}
// set trim options
if (producer->get(kFilterInProperty)) {
in = producer->get_int(kFilterInProperty);
int ss = qMax(0, in - qRound(producer->get_fps() * kHandleSeconds));
auto s = QString::fromLatin1(producer->frames_to_time(ss, mlt_time_clock));
args << "-ss" << s.replace(',', '.');
in -= ss;
} else {
args << "-ss"
<< QString::fromLatin1(producer->get_time("in", mlt_time_clock))
.replace(',', '.')
.replace(',', '.');
}
if (producer->get(kFilterOutProperty)) {
int out = producer->get_int(kFilterOutProperty);
int to = qMin(producer->get_playtime() - 1,
out + qRound(producer->get_fps() * kHandleSeconds));
auto s = QString::fromLatin1(producer->frames_to_time(to, mlt_time_clock));
args << "-to" << s.replace(',', '.');
} else {
args << "-to"
<< QString::fromLatin1(producer->get_time("out", mlt_time_clock)).replace(',', '.');
}
}
// transcode all streams except data, subtitles, and attachments
auto audioIndex = producer->property_exists(kDefaultAudioIndexProperty)
? producer->get_int(kDefaultAudioIndexProperty)
: producer->get_int("audio_index");
if (producer->get_int("video_index") < audioIndex) {
if (Util::hasiPhoneAmbisonic(producer))
args << "-map"
<< "0:V?"
<< "-map"
<< "0:a:0";
else
args << "-map"
<< "0:V?"
<< "-map"
<< "0:a?";
} else {
args << "-map"
<< "0:a?"
<< "-map"
<< "0:V?";
}
args << "-map_metadata"
<< "0"
<< "-ignore_unknown";
// Set Sample rate if different than source
if (!dialog.sampleRate().isEmpty()) {
args << "-ar" << dialog.sampleRate();
}
// Set video filters
args << "-vf";
QString filterString;
if (dialog.deinterlace()) {
QString deinterlaceFilter = QStringLiteral("bwdif,");
filterString = filterString + deinterlaceFilter;
}
QString color_range;
if (producer->get("color_range")) {
if (producer->get_int("color_range") == 2) {
color_range = "full";
} else {
color_range = "mpeg";
}
} else if (producer->get("force_full_range")) {
if (producer->get_int("force_full_range")) {
color_range = "full";
} else {
color_range = "mpeg";
}
} else {
color_range = producer->get("meta.media.color_range");
}
if (color_range != "full" && color_range != "mpeg") {
color_range = "mpeg";
}
if (dialog.get709Convert()) {
QString convertFilter = QStringLiteral(
"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,"
"zscale=t=bt709:m=bt709:r=tv,format=yuv422p,");
filterString = filterString + convertFilter;
}
filterString
= filterString
+ QStringLiteral(
"scale=flags=accurate_rnd+full_chroma_inp+full_chroma_int:in_range=%1:out_range=%2")
.arg(color_range)
.arg(color_range);
auto fps = dialog.fpsOverride() ? dialog.fps() : Util::getSuggestedFrameRate(producer);
auto fpsStr = QStringLiteral("%1").arg(fps, 0, 'f', 6);
int numerator, denominator;
Util::normalizeFrameRate(fps, numerator, denominator);
if (denominator == 1001) {
fpsStr = QStringLiteral("%1/%2").arg(numerator).arg(denominator);
}
QString minterpFilter
= QStringLiteral(",minterpolate='mi_mode=%1:mc_mode=aobmc:me_mode=bidir:vsbmc=1:fps=%2'")
.arg(dialog.frc(), fpsStr);
filterString = filterString + minterpFilter;
args << filterString;
// Specify color range
if (color_range == "full") {
args << "-color_range"
<< "2";
} else {
args << "-color_range"
<< "1";
}
int progressive = producer->get_int("meta.media.progressive")
|| producer->get_int("force_progressive");
if (!dialog.deinterlace() && !progressive) {
int tff = producer->get_int("meta.media.top_field_first") || producer->get_int("force_tff");
args << "-flags"
<< "+ildct+ilme"
<< "-top" << QString::number(tff);
}
switch (dialog.format()) {
case 0:
args << "-f"
<< "mp4"
<< "-codec:a"
<< "ac3"
<< "-b:a"
<< "512k"
<< "-codec:v"
<< "libx264";
args << "-preset"
<< "medium"
<< "-g"
<< "1"
<< "-crf"
<< "15";
break;
case 1:
args << "-f"
<< "mov"
<< "-codec:a"
<< "pcm_f32le";
if (dialog.deinterlace() || progressive) {
args << "-codec:v"
<< "dnxhd"
<< "-profile:v"
<< "dnxhr_hq"
<< "-pix_fmt"
<< "yuv422p";
} else { // interlaced
args << "-codec:v"
<< "prores_ks"
<< "-profile:v"
<< "standard";
}
break;
case 2:
args << "-f"
<< "matroska"
<< "-codec:a"
<< "pcm_f32le"
<< "-codec:v"
<< "utvideo";
args << "-pix_fmt"
<< "yuv422p";
break;
}
if (dialog.get709Convert()) {
args << "-colorspace"
<< "bt709"
<< "-color_primaries"
<< "bt709"
<< "-color_trc"
<< "bt709";
} else if (dialog.format() == 2 && producer->get_int("meta.media.colorspace") == 709) {
// Work around a limitation that FFMpeg does not pass colorspace for utvideo
args << "-colorspace"
<< "bt709";
}
args << "-y" << filename;
producer->Mlt::Properties::clear(kOriginalResourceProperty);
FfmpegJob *job = new FfmpegJob(filename, args, false);
job->setLabel(tr("Convert %1").arg(Util::baseName(filename)));
job->setTarget(filename);
if (dialog.isSubClip()) {
if (producer->get(kMultitrackItemProperty)) {
QString s = QString::fromLatin1(producer->get(kMultitrackItemProperty));
auto parts = s.split(':');
if (parts.length() == 2) {
int clipIndex = parts[0].toInt();
int trackIndex = parts[1].toInt();
QUuid uuid = MAIN.timelineClipUuid(trackIndex, clipIndex);
if (!uuid.isNull()) {
job->setPostJobAction(
new ReplaceOnePostJobAction(resource, filename, QString(), uuid, in));
JOBS.add(job);
}
}
} else {
job->setPostJobAction(new OpenPostJobAction(resource, filename, QString()));
JOBS.add(job);
}
return;
}
job->setPostJobAction(new ReplaceAllPostJobAction(resource, filename, Util::getHash(*producer)));
JOBS.add(job);
}