From 6aaf37e2e5084c1c0ca80cf81b4fbeced2a9914f Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 16 Jul 2026 22:31:37 +0800 Subject: [PATCH] proxy: per-footage presets, audio in proxies, configurable ffmpeg path - Footage can store custom proxy parameters (width/height/crf/preset/ extension/audio) that override the global settings; they are serialized with the project and used by every generation entry point via Footage::GetEffectiveProxyParams() - Proxies now include the source audio streams (AAC) unless disabled; the proxy filename records the audio flag and offline audio rendering decodes from the proxy when present - ProxyTask resolves ffmpeg from the new FFmpegPath config key first, then PATH, then common install locations (e.g. Homebrew on macOS), instead of relying on PATH only; the error message points at the preferences when no executable is found - ProxyTask::BuildArguments() is extracted for testability - Add ProxyIncludeAudio and FFmpegPath config defaults - Add regression tests for the filename audio marker, config-backed params, ffmpeg resolution, argument building, and custom-param persistence --- app/codec/proxymanager.cpp | 77 ++++++- app/codec/proxymanager.h | 22 ++ app/config/config.cpp | 4 + app/node/project/footage/footage.cpp | 97 +++++++++ app/node/project/footage/footage.h | 34 +++ app/render/renderprocessor.cpp | 19 +- app/task/proxy/proxy.cpp | 68 ++++-- app/task/proxy/proxy.h | 12 ++ .../projectexplorer/projectexplorer.cpp | 6 +- app/widget/timelinewidget/timelinewidget.cpp | 6 +- tests/gtest/proxy_manager_test.cpp | 199 ++++++++++++++++++ 11 files changed, 506 insertions(+), 38 deletions(-) diff --git a/app/codec/proxymanager.cpp b/app/codec/proxymanager.cpp index 84c09a731..2d0d41fde 100644 --- a/app/codec/proxymanager.cpp +++ b/app/codec/proxymanager.cpp @@ -18,11 +18,14 @@ #include "proxymanager.h" +#include #include #include #include +#include #include "common/filefunctions.h" +#include "config/config.h" #include "task/proxy/proxy.h" #include "task/taskmanager.h" @@ -36,7 +39,8 @@ bool ProxyParamsEqual(const ProxyManager::ProxyParams &a, { return a.width == b.width && a.height == b.height && a.version == b.version && a.extension == b.extension && - a.crf == b.crf && a.preset == b.preset; + a.crf == b.crf && a.preset == b.preset && + a.include_audio == b.include_audio; } QString ProxyManager::GetProxyDirectory(const QString &cache_path) @@ -53,11 +57,12 @@ QString ProxyManager::GetProxyFilename(const QString &cache_path, const QString extension = params.extension.isEmpty() ? QStringLiteral("mp4") : params.extension; const QString filename = - QStringLiteral("%1-%2.%3x%4.v%5.%6") + QStringLiteral("%1-%2.%3x%4.v%5.a%6.%7") .arg(FileFunctions::GetUniqueFileIdentifier(source_filename), QString::number(stream_index), QString::number(params.width), - QString::number(params.height), - QString::number(params.version), extension); + QString::number(params.height), QString::number(params.version), + params.include_audio ? QStringLiteral("1") : QStringLiteral("0"), + extension); return QDir(proxy_dir).filePath(filename); } @@ -117,6 +122,70 @@ ProxyManager::ProxyStateFromString(const QString &state) return kProxyMissing; } +bool ProxyManager::ProxyFilenameHasAudio(const QString &proxy_filename) +{ + return QFileInfo(proxy_filename).fileName().contains( + QStringLiteral(".a1.")); +} + +ProxyManager::ProxyParams ProxyManager::ProxyParamsFromConfig() +{ + ProxyParams params; + params.width = OLIVE_CONFIG("ProxyWidth").value(); + params.height = OLIVE_CONFIG("ProxyHeight").value(); + params.crf = OLIVE_CONFIG("ProxyCRF").value(); + params.preset = OLIVE_CONFIG("ProxyPreset").toString(); + params.include_audio = OLIVE_CONFIG("ProxyIncludeAudio").toBool(); + return params; +} + +QString ProxyManager::FindFFmpegExecutable(const QString &configured_path) +{ + // An explicitly configured path takes precedence if it is usable + if (!configured_path.isEmpty()) { + const QFileInfo configured_info(configured_path); + if (configured_info.exists() && configured_info.isFile() && + configured_info.isExecutable()) { + return configured_info.absoluteFilePath(); + } + + qWarning() << "Configured ffmpeg path is not a valid executable:" + << configured_path; + } + + // Fall back to searching the system PATH + const QString from_path = + QStandardPaths::findExecutable(QStringLiteral("ffmpeg")); + if (!from_path.isEmpty()) { + return from_path; + } + + // Finally, try common install locations (PATH on GUI-launched apps, + // particularly on macOS, often lacks these) + QStringList candidates; + candidates.append(QCoreApplication::applicationDirPath() + + QStringLiteral("/ffmpeg")); +#ifdef Q_OS_MAC + candidates.append(QStringLiteral("/opt/homebrew/bin/ffmpeg")); + candidates.append(QStringLiteral("/usr/local/bin/ffmpeg")); +#endif +#ifdef Q_OS_WINDOWS + candidates.append(QCoreApplication::applicationDirPath() + + QStringLiteral("/ffmpeg.exe")); +#endif + candidates.append(QStringLiteral("/usr/bin/ffmpeg")); + candidates.append(QStringLiteral("/usr/local/bin/ffmpeg")); + + for (const QString &candidate : candidates) { + const QFileInfo info(candidate); + if (info.exists() && info.isFile() && info.isExecutable()) { + return info.absoluteFilePath(); + } + } + + return QString(); +} + ProxyManager::Proxy ProxyManager::GetOrStartProxy(const QString &cache_path, const QString &source_filename, int stream_index, diff --git a/app/codec/proxymanager.h b/app/codec/proxymanager.h index 0b59925ec..a39d5d18a 100644 --- a/app/codec/proxymanager.h +++ b/app/codec/proxymanager.h @@ -66,6 +66,7 @@ public: QString extension = QStringLiteral("mp4"); int crf = 23; QString preset = QStringLiteral("veryfast"); + bool include_audio = true; }; struct Proxy { @@ -89,6 +90,27 @@ public: static ProxyState ProxyStateFromString(const QString &state); + /** + * @brief Returns true if a proxy filename generated by GetProxyFilename() + * indicates the proxy contains audio streams + */ + static bool ProxyFilenameHasAudio(const QString &proxy_filename); + + /** + * @brief Builds proxy parameters from the global application config + */ + static ProxyParams ProxyParamsFromConfig(); + + /** + * @brief Locates an ffmpeg executable for proxy generation + * + * Resolution order: the explicitly configured path (if non-empty and an + * existing executable file), then the system PATH, then common + * platform-specific install locations. Returns an empty string if no + * executable could be found. + */ + static QString FindFFmpegExecutable(const QString &configured_path); + Proxy GetOrStartProxy(const QString &cache_path, const QString &source_filename, int stream_index, const ProxyParams ¶ms); diff --git a/app/config/config.cpp b/app/config/config.cpp index 796676e69..1b437f52d 100644 --- a/app/config/config.cpp +++ b/app/config/config.cpp @@ -235,6 +235,10 @@ void Config::SetDefaults() SetEntryInternal(QStringLiteral("ProxyCRF"), NodeValue::kInt, 23); SetEntryInternal(QStringLiteral("ProxyPreset"), NodeValue::kText, QStringLiteral("veryfast")); + SetEntryInternal(QStringLiteral("ProxyIncludeAudio"), NodeValue::kBoolean, + true); + SetEntryInternal(QStringLiteral("FFmpegPath"), NodeValue::kText, + QString()); SetEntryInternal(QStringLiteral("DefaultSequenceWidth"), NodeValue::kInt, 1920); diff --git a/app/node/project/footage/footage.cpp b/app/node/project/footage/footage.cpp index 12ec4d3f9..ec5dc435c 100644 --- a/app/node/project/footage/footage.cpp +++ b/app/node/project/footage/footage.cpp @@ -50,6 +50,7 @@ Footage::Footage(const QString &filename) , proxy_state_(ProxyManager::kProxyMissing) , proxy_video_stream_index_(-1) , proxy_preset_version_(0) + , has_custom_proxy_params_(false) , valid_(false) , cancelled_(nullptr) , total_stream_count_(0) @@ -269,6 +270,37 @@ void Footage::ClearProxy() emit ProxySettingsChanged(); } +void Footage::SetCustomProxyParams(const ProxyManager::ProxyParams ¶ms) +{ + custom_proxy_params_ = params; + has_custom_proxy_params_ = true; + if (Project *p = project()) { + p->set_modified(true); + } + emit ProxySettingsChanged(); +} + +void Footage::ClearCustomProxyParams() +{ + if (has_custom_proxy_params_) { + has_custom_proxy_params_ = false; + custom_proxy_params_ = ProxyManager::ProxyParams(); + if (Project *p = project()) { + p->set_modified(true); + } + emit ProxySettingsChanged(); + } +} + +ProxyManager::ProxyParams Footage::GetEffectiveProxyParams() const +{ + if (has_custom_proxy_params_) { + return custom_proxy_params_; + } + + return ProxyManager::ProxyParamsFromConfig(); +} + QString Footage::DescribeVideoStream(const VideoParams ¶ms) { if (params.video_type() == VideoParams::kVideoTypeStill) { @@ -353,6 +385,26 @@ void Footage::Value(const NodeValueRow &value, const NodeGlobals &globals, job.set_audio_params(ap); job.set_cache_path(project()->cache_path()); + // Proxies generated with audio contain the video stream at + // index 0 followed by all source audio streams in source order + if (proxy_enabled_ && !proxy_path_.isEmpty() && + ProxyManager::GetProxyState(proxy_path_) == + ProxyManager::kProxyReady && + ProxyManager::ProxyFilenameHasAudio(proxy_path_)) { + int audio_rank = 0; + for (int i = 0; i < GetTotalStreamCount(); i++) { + const Track::Reference other = + GetReferenceFromRealIndex(i); + if (other.type() == Track::kAudio && + GetAudioParams(other.index()).stream_index() < + ap.stream_index()) { + audio_rank++; + } + } + job.set_proxy(proxy_path_, QStringLiteral("ffmpeg"), + audio_rank + 1); + } + table->Push(NodeValue::kSamples, QVariant::fromValue(job), this, ref.ToString()); } @@ -530,6 +582,8 @@ bool Footage::LoadCustom(QXmlStreamReader *reader, SerializedData *data) ProxyManager::ProxyState state = ProxyManager::kProxyMissing; int stream = -1; int preset_version = 0; + bool has_custom_params = false; + ProxyManager::ProxyParams custom_params; { XMLAttributeLoop(reader, attr) { @@ -543,10 +597,32 @@ bool Footage::LoadCustom(QXmlStreamReader *reader, SerializedData *data) stream = attr.value().toInt(); } else if (attr.name() == QStringLiteral("preset")) { preset_version = attr.value().toInt(); + } else if (attr.name() == QStringLiteral("custom")) { + has_custom_params = + (attr.value() == QStringLiteral("1") || + attr.value() == QStringLiteral("true")); + } else if (attr.name() == QStringLiteral("pwidth")) { + custom_params.width = attr.value().toInt(); + } else if (attr.name() == QStringLiteral("pheight")) { + custom_params.height = attr.value().toInt(); + } else if (attr.name() == QStringLiteral("pcrf")) { + custom_params.crf = attr.value().toInt(); + } else if (attr.name() == QStringLiteral("ppreset")) { + custom_params.preset = attr.value().toString(); + } else if (attr.name() == QStringLiteral("pext")) { + custom_params.extension = attr.value().toString(); + } else if (attr.name() == QStringLiteral("paudio")) { + custom_params.include_audio = + (attr.value() == QStringLiteral("1") || + attr.value() == QStringLiteral("true")); } } } + if (has_custom_params) { + SetCustomProxyParams(custom_params); + } + const QString path = reader->readElementText(); if (!path.isEmpty()) { SetProxy(path, state, stream, preset_version, enabled); @@ -608,6 +684,27 @@ void Footage::SaveCustom(QXmlStreamWriter *writer) const QString::number(proxy_video_stream_index_)); writer->writeAttribute(QStringLiteral("preset"), QString::number(proxy_preset_version_)); + if (has_custom_proxy_params_) { + writer->writeAttribute(QStringLiteral("custom"), + QStringLiteral("1")); + writer->writeAttribute( + QStringLiteral("pwidth"), + QString::number(custom_proxy_params_.width)); + writer->writeAttribute( + QStringLiteral("pheight"), + QString::number(custom_proxy_params_.height)); + writer->writeAttribute( + QStringLiteral("pcrf"), + QString::number(custom_proxy_params_.crf)); + writer->writeAttribute(QStringLiteral("ppreset"), + custom_proxy_params_.preset); + writer->writeAttribute(QStringLiteral("pext"), + custom_proxy_params_.extension); + writer->writeAttribute( + QStringLiteral("paudio"), + custom_proxy_params_.include_audio ? QStringLiteral("1") : + QStringLiteral("0")); + } writer->writeCharacters(proxy_path_); writer->writeEndElement(); } diff --git a/app/node/project/footage/footage.h b/app/node/project/footage/footage.h index 416fe8fc5..effc6e50a 100644 --- a/app/node/project/footage/footage.h +++ b/app/node/project/footage/footage.h @@ -205,6 +205,36 @@ public: void ClearProxy(); + /** + * @brief Returns true if this footage uses its own proxy parameters + * instead of the global proxy settings + */ + bool has_custom_proxy_params() const + { + return has_custom_proxy_params_; + } + + const ProxyManager::ProxyParams &custom_proxy_params() const + { + return custom_proxy_params_; + } + + /** + * @brief Sets per-footage proxy parameters, overriding the global settings + */ + void SetCustomProxyParams(const ProxyManager::ProxyParams ¶ms); + + /** + * @brief Reverts this footage to using the global proxy settings + */ + void ClearCustomProxyParams(); + + /** + * @brief Returns the custom proxy parameters if set, otherwise the + * parameters from the global application config + */ + ProxyManager::ProxyParams GetEffectiveProxyParams() const; + static QString DescribeVideoStream(const VideoParams ¶ms); static QString DescribeAudioStream(const AudioParams ¶ms); static QString DescribeSubtitleStream(const SubtitleParams ¶ms); @@ -283,6 +313,10 @@ private: int proxy_preset_version_; + bool has_custom_proxy_params_; + + ProxyManager::ProxyParams custom_proxy_params_; + bool valid_; CancelAtom *cancelled_; diff --git a/app/render/renderprocessor.cpp b/app/render/renderprocessor.cpp index 0ac87ba24..c6b840670 100644 --- a/app/render/renderprocessor.cpp +++ b/app/render/renderprocessor.cpp @@ -611,10 +611,23 @@ void RenderProcessor::ProcessAudioFootage(SampleBuffer &destination, return; } + // Mirror the video path: use the proxy (when enabled, ready, and containing + // audio) for offline renders only, never for export + const bool use_proxy = + static_cast(ticket_->property("mode").toInt()) == + RenderMode::kOffline && + stream->has_proxy() && QFileInfo::exists(stream->proxy_filename()); + const QString decode_filename = use_proxy ? stream->proxy_filename() : + stream->filename(); + const QString decoder_id = use_proxy ? stream->proxy_decoder() : + stream->decoder(); + const int stream_index = use_proxy ? + stream->proxy_stream_index() : + stream->audio_params().stream_index(); + DecoderPtr decoder = ResolveDecoderFromInput( - stream->decoder(), - Decoder::CodecStream(stream->filename(), - stream->audio_params().stream_index(), nullptr)); + decoder_id, + Decoder::CodecStream(decode_filename, stream_index, nullptr)); if (decoder) { const AudioParams &audio_params = GetCacheAudioParams(); diff --git a/app/task/proxy/proxy.cpp b/app/task/proxy/proxy.cpp index 524149802..d0c88f368 100644 --- a/app/task/proxy/proxy.cpp +++ b/app/task/proxy/proxy.cpp @@ -26,6 +26,8 @@ #include #include +#include "config/config.h" + namespace olive { @@ -41,13 +43,53 @@ ProxyTask::ProxyTask(const QString &source_filename, int stream_index, .arg(source_filename_, QString::number(stream_index_))); } +QStringList ProxyTask::BuildArguments(const QString &source_filename, + int stream_index, + const ProxyManager::ProxyParams ¶ms, + const QString &output_filename) +{ + const QString scale_filter = + QStringLiteral("scale=w=%1:h=%2:force_original_aspect_ratio=decrease") + .arg(QString::number(params.width), + QString::number(params.height)); + + const QString container_format = + params.extension.isEmpty() ? QStringLiteral("mp4") : params.extension; + + QStringList args; + args << QStringLiteral("-y") << QStringLiteral("-i") << source_filename + // Map the requested video stream first so it is stream 0 in the proxy + << QStringLiteral("-map") << QStringLiteral("0:%1").arg(stream_index); + + if (params.include_audio) { + // Keep the source audio (if any) so the proxy can also be used for + // audio preview. Audio streams follow the video stream in source order. + args << QStringLiteral("-map") << QStringLiteral("0:a?") + << QStringLiteral("-c:a") << QStringLiteral("aac") + << QStringLiteral("-b:a") << QStringLiteral("128k"); + } else { + args << QStringLiteral("-an"); + } + + args << QStringLiteral("-vf") << scale_filter << QStringLiteral("-c:v") + << QStringLiteral("libx264") << QStringLiteral("-preset") + << params.preset << QStringLiteral("-crf") + << QString::number(params.crf) << QStringLiteral("-pix_fmt") + << QStringLiteral("yuv420p") << QStringLiteral("-movflags") + << QStringLiteral("+faststart") << QStringLiteral("-f") + << container_format << output_filename; + + return args; +} + bool ProxyTask::Run() { - const QString ffmpeg = - QStandardPaths::findExecutable(QStringLiteral("ffmpeg")); + const QString ffmpeg = ProxyManager::FindFFmpegExecutable( + OLIVE_CONFIG("FFmpegPath").toString()); if (ffmpeg.isEmpty()) { SetError( - tr("Failed to generate proxy: ffmpeg executable was not found")); + tr("Failed to generate proxy: ffmpeg executable was not found. Set " + "the ffmpeg path in Preferences > Disk > Proxy Settings.")); qWarning() << "ProxyTask: ffmpeg executable not found"; return false; } @@ -66,24 +108,8 @@ bool ProxyTask::Run() QFile::remove(output_filename_); - const QString scale_filter = - QStringLiteral("scale=w=%1:h=%2:force_original_aspect_ratio=decrease") - .arg(QString::number(params_.width), - QString::number(params_.height)); - - const QString container_format = - params_.extension.isEmpty() ? QStringLiteral("mp4") : params_.extension; - - QStringList args; - args << QStringLiteral("-y") << QStringLiteral("-i") << source_filename_ - << QStringLiteral("-map") << QStringLiteral("0:%1").arg(stream_index_) - << QStringLiteral("-an") << QStringLiteral("-vf") << scale_filter - << QStringLiteral("-c:v") << QStringLiteral("libx264") - << QStringLiteral("-preset") << params_.preset - << QStringLiteral("-crf") << QString::number(params_.crf) - << QStringLiteral("-pix_fmt") << QStringLiteral("yuv420p") - << QStringLiteral("-movflags") << QStringLiteral("+faststart") - << QStringLiteral("-f") << container_format << output_filename_; + const QStringList args = BuildArguments(source_filename_, stream_index_, + params_, output_filename_); QProcess process; process.setProgram(ffmpeg); diff --git a/app/task/proxy/proxy.h b/app/task/proxy/proxy.h index e96bb2cb9..7d5632639 100644 --- a/app/task/proxy/proxy.h +++ b/app/task/proxy/proxy.h @@ -32,6 +32,18 @@ public: const ProxyManager::ProxyParams ¶ms, const QString &output_filename); + /** + * @brief Builds the ffmpeg command line for a proxy generation run + * + * Extracted for testability. The video stream is always mapped first so + * that it is stream 0 in the proxy file; audio streams (when enabled) + * follow in source order. + */ + static QStringList BuildArguments(const QString &source_filename, + int stream_index, + const ProxyManager::ProxyParams ¶ms, + const QString &output_filename); + protected: virtual bool Run() override; diff --git a/app/widget/projectexplorer/projectexplorer.cpp b/app/widget/projectexplorer/projectexplorer.cpp index 83a83f3d5..21421f165 100644 --- a/app/widget/projectexplorer/projectexplorer.cpp +++ b/app/widget/projectexplorer/projectexplorer.cpp @@ -611,11 +611,7 @@ void ProjectExplorer::GenerateProxiesForSelectedFootage() continue; } - ProxyManager::ProxyParams params; - params.width = OLIVE_CONFIG("ProxyWidth").value(); - params.height = OLIVE_CONFIG("ProxyHeight").value(); - params.crf = OLIVE_CONFIG("ProxyCRF").value(); - params.preset = OLIVE_CONFIG("ProxyPreset").toString(); + ProxyManager::ProxyParams params = item->GetEffectiveProxyParams(); const ProxyManager::Proxy proxy = ProxyManager::instance()->GetOrStartProxy( item->project()->cache_path(), item->filename(), diff --git a/app/widget/timelinewidget/timelinewidget.cpp b/app/widget/timelinewidget/timelinewidget.cpp index 0226267f0..f7636ee0d 100644 --- a/app/widget/timelinewidget/timelinewidget.cpp +++ b/app/widget/timelinewidget/timelinewidget.cpp @@ -1154,11 +1154,7 @@ void TimelineWidget::GenerateProxiesForSelectedClips() continue; } - ProxyManager::ProxyParams params; - params.width = OLIVE_CONFIG("ProxyWidth").value(); - params.height = OLIVE_CONFIG("ProxyHeight").value(); - params.crf = OLIVE_CONFIG("ProxyCRF").value(); - params.preset = OLIVE_CONFIG("ProxyPreset").toString(); + ProxyManager::ProxyParams params = item->GetEffectiveProxyParams(); const ProxyManager::Proxy proxy = ProxyManager::instance()->GetOrStartProxy( item->project()->cache_path(), item->filename(), diff --git a/tests/gtest/proxy_manager_test.cpp b/tests/gtest/proxy_manager_test.cpp index a23742bd1..a147c9bc1 100644 --- a/tests/gtest/proxy_manager_test.cpp +++ b/tests/gtest/proxy_manager_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -7,8 +8,20 @@ #include #include "codec/proxymanager.h" +#include "config/config.h" #include "node/project/footage/footage.h" #include "render/job/footagejob.h" +#include "task/proxy/proxy.h" + +namespace +{ + +QVariant ProxyConfigValue(const char *key) +{ + return olive::Config::Current()[QString::fromUtf8(key)]; +} + +} // namespace TEST(ProxyManager, BuildsStableProxyFilename) { @@ -275,3 +288,189 @@ TEST(ProxyManager, FootageJobWithoutProxyHasEmptyProxyFields) EXPECT_TRUE(job.proxy_decoder().isEmpty()); EXPECT_EQ(job.proxy_stream_index(), -1); } + +TEST(ProxyManager, ProxyFilenameIncludesAudioFlag) +{ + olive::ProxyManager::ProxyParams params; + params.include_audio = true; + + const QString with_audio = olive::ProxyManager::GetProxyFilename( + QStringLiteral("/tmp/oak-cache"), QStringLiteral("/media/source.mov"), + 0, params); + EXPECT_TRUE(with_audio.contains(QStringLiteral(".a1."))); + EXPECT_TRUE(olive::ProxyManager::ProxyFilenameHasAudio(with_audio)); + + params.include_audio = false; + const QString without_audio = olive::ProxyManager::GetProxyFilename( + QStringLiteral("/tmp/oak-cache"), QStringLiteral("/media/source.mov"), + 0, params); + EXPECT_TRUE(without_audio.contains(QStringLiteral(".a0."))); + EXPECT_FALSE(olive::ProxyManager::ProxyFilenameHasAudio(without_audio)); + + // Legacy proxy filenames (no audio marker) must be treated as video-only + EXPECT_FALSE(olive::ProxyManager::ProxyFilenameHasAudio( + QStringLiteral("/tmp/oak-cache/proxy/abc-0.1280x720.v1.mp4"))); + + // The audio flag distinguishes otherwise identical proxy filenames + EXPECT_NE(with_audio, without_audio); +} + +TEST(ProxyManager, ProxyParamsFromConfigReadsDefaults) +{ + const olive::ProxyManager::ProxyParams params = + olive::ProxyManager::ProxyParamsFromConfig(); + + EXPECT_EQ(params.width, ProxyConfigValue("ProxyWidth").value()); + EXPECT_EQ(params.height, ProxyConfigValue("ProxyHeight").value()); + EXPECT_EQ(params.crf, ProxyConfigValue("ProxyCRF").value()); + EXPECT_EQ(params.preset, ProxyConfigValue("ProxyPreset").toString()); + EXPECT_EQ(params.include_audio, + ProxyConfigValue("ProxyIncludeAudio").toBool()); +} + +TEST(ProxyManager, FindFFmpegExecutablePrefersConfiguredPath) +{ + // The test executable itself is guaranteed to be an existing executable + // file, making it a safe stand-in for an ffmpeg binary + const QString self = QCoreApplication::applicationFilePath(); + ASSERT_FALSE(self.isEmpty()); + + EXPECT_EQ(olive::ProxyManager::FindFFmpegExecutable(self), self); +} + +TEST(ProxyManager, FindFFmpegExecutableRejectsInvalidConfiguredPath) +{ + const QString bogus = QStringLiteral("/nonexistent/ffmpeg-binary"); + const QString result = olive::ProxyManager::FindFFmpegExecutable(bogus); + + // Must not return the invalid configured path; any fallback is acceptable + EXPECT_NE(result, bogus); +} + +TEST(ProxyTask, BuildArgumentsIncludesAudioWhenEnabled) +{ + olive::ProxyManager::ProxyParams params; + params.include_audio = true; + + const QStringList args = olive::ProxyTask::BuildArguments( + QStringLiteral("/media/source.mov"), 1, params, + QStringLiteral("/cache/proxy/out.mp4")); + + EXPECT_FALSE(args.contains(QStringLiteral("-an"))); + const int audio_map = args.indexOf(QStringLiteral("0:a?")); + EXPECT_GE(audio_map, 0); + EXPECT_GT(audio_map, args.indexOf(QStringLiteral("-map"))); + EXPECT_TRUE(args.contains(QStringLiteral("-c:a"))); + EXPECT_TRUE(args.contains(QStringLiteral("aac"))); + // The requested video stream must be mapped before the audio streams + EXPECT_LT(args.indexOf(QStringLiteral("0:1")), audio_map); +} + +TEST(ProxyTask, BuildArgumentsDisablesAudioWhenDisabled) +{ + olive::ProxyManager::ProxyParams params; + params.include_audio = false; + + const QStringList args = olive::ProxyTask::BuildArguments( + QStringLiteral("/media/source.mov"), 1, params, + QStringLiteral("/cache/proxy/out.mp4")); + + EXPECT_TRUE(args.contains(QStringLiteral("-an"))); + EXPECT_FALSE(args.contains(QStringLiteral("0:a?"))); +} + +TEST(ProxyManager, FootagePersistsCustomProxyParams) +{ + olive::Footage footage; + olive::ProxyManager::ProxyParams params; + params.width = 640; + params.height = 360; + params.crf = 30; + params.preset = QStringLiteral("faster"); + params.extension = QStringLiteral("mov"); + params.include_audio = false; + footage.SetCustomProxyParams(params); + footage.SetProxy(QStringLiteral("/cache/proxy/example.mp4"), + olive::ProxyManager::kProxyReady, 0, 1, true); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("custom")); + footage.SaveCustom(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + EXPECT_TRUE(xml.contains(QStringLiteral("custom=\"1\""))); + EXPECT_TRUE(xml.contains(QStringLiteral("pwidth=\"640\""))); + EXPECT_TRUE(xml.contains(QStringLiteral("pheight=\"360\""))); + EXPECT_TRUE(xml.contains(QStringLiteral("pcrf=\"30\""))); + EXPECT_TRUE(xml.contains(QStringLiteral("ppreset=\"faster\""))); + EXPECT_TRUE(xml.contains(QStringLiteral("pext=\"mov\""))); + EXPECT_TRUE(xml.contains(QStringLiteral("paudio=\"0\""))); + + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("custom")); + + olive::Footage loaded; + ASSERT_TRUE(loaded.LoadCustom(&reader, nullptr)); + ASSERT_TRUE(loaded.has_custom_proxy_params()); + EXPECT_EQ(loaded.custom_proxy_params().width, 640); + EXPECT_EQ(loaded.custom_proxy_params().height, 360); + EXPECT_EQ(loaded.custom_proxy_params().crf, 30); + EXPECT_EQ(loaded.custom_proxy_params().preset, QStringLiteral("faster")); + EXPECT_EQ(loaded.custom_proxy_params().extension, QStringLiteral("mov")); + EXPECT_FALSE(loaded.custom_proxy_params().include_audio); +} + +TEST(ProxyManager, FootageWithoutCustomParamsOmitsThemFromXml) +{ + olive::Footage footage; + footage.SetProxy(QStringLiteral("/cache/proxy/example.mp4"), + olive::ProxyManager::kProxyReady, 0, 1, true); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("custom")); + footage.SaveCustom(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + EXPECT_FALSE(xml.contains(QStringLiteral("custom="))); + + // Loading old project files without custom params must not enable them + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + olive::Footage loaded; + ASSERT_TRUE(loaded.LoadCustom(&reader, nullptr)); + EXPECT_FALSE(loaded.has_custom_proxy_params()); +} + +TEST(ProxyManager, FootageEffectiveProxyParams) +{ + olive::Footage footage; + + // Without custom params, the global config values apply + const olive::ProxyManager::ProxyParams global_params = + footage.GetEffectiveProxyParams(); + EXPECT_EQ(global_params.width, ProxyConfigValue("ProxyWidth").value()); + EXPECT_EQ(global_params.include_audio, + ProxyConfigValue("ProxyIncludeAudio").toBool()); + + // Custom params take precedence + olive::ProxyManager::ProxyParams custom; + custom.width = 320; + custom.height = 180; + footage.SetCustomProxyParams(custom); + EXPECT_TRUE(footage.has_custom_proxy_params()); + EXPECT_EQ(footage.GetEffectiveProxyParams().width, 320); + EXPECT_EQ(footage.GetEffectiveProxyParams().height, 180); + + // Clearing reverts to the global config values + footage.ClearCustomProxyParams(); + EXPECT_FALSE(footage.has_custom_proxy_params()); + EXPECT_EQ(footage.GetEffectiveProxyParams().width, + ProxyConfigValue("ProxyWidth").value()); +}