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
This commit is contained in:
2026-07-16 22:31:37 +08:00
parent 15525528c9
commit 6aaf37e2e5
11 changed files with 506 additions and 38 deletions
+73 -4
View File
@@ -18,11 +18,14 @@
#include "proxymanager.h"
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
#include <QMutexLocker>
#include <QStandardPaths>
#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<int>();
params.height = OLIVE_CONFIG("ProxyHeight").value<int>();
params.crf = OLIVE_CONFIG("ProxyCRF").value<int>();
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,
+22
View File
@@ -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 &params);
+4
View File
@@ -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);
+97
View File
@@ -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 &params)
{
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 &params)
{
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();
}
+34
View File
@@ -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 &params);
/**
* @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 &params);
static QString DescribeAudioStream(const AudioParams &params);
static QString DescribeSubtitleStream(const SubtitleParams &params);
@@ -283,6 +313,10 @@ private:
int proxy_preset_version_;
bool has_custom_proxy_params_;
ProxyManager::ProxyParams custom_proxy_params_;
bool valid_;
CancelAtom *cancelled_;
+16 -3
View File
@@ -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<RenderMode::Mode>(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();
+47 -21
View File
@@ -26,6 +26,8 @@
#include <QProcess>
#include <QStandardPaths>
#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 &params,
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);
+12
View File
@@ -32,6 +32,18 @@ public:
const ProxyManager::ProxyParams &params,
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 &params,
const QString &output_filename);
protected:
virtual bool Run() override;
@@ -611,11 +611,7 @@ void ProjectExplorer::GenerateProxiesForSelectedFootage()
continue;
}
ProxyManager::ProxyParams params;
params.width = OLIVE_CONFIG("ProxyWidth").value<int>();
params.height = OLIVE_CONFIG("ProxyHeight").value<int>();
params.crf = OLIVE_CONFIG("ProxyCRF").value<int>();
params.preset = OLIVE_CONFIG("ProxyPreset").toString();
ProxyManager::ProxyParams params = item->GetEffectiveProxyParams();
const ProxyManager::Proxy proxy =
ProxyManager::instance()->GetOrStartProxy(
item->project()->cache_path(), item->filename(),
+1 -5
View File
@@ -1154,11 +1154,7 @@ void TimelineWidget::GenerateProxiesForSelectedClips()
continue;
}
ProxyManager::ProxyParams params;
params.width = OLIVE_CONFIG("ProxyWidth").value<int>();
params.height = OLIVE_CONFIG("ProxyHeight").value<int>();
params.crf = OLIVE_CONFIG("ProxyCRF").value<int>();
params.preset = OLIVE_CONFIG("ProxyPreset").toString();
ProxyManager::ProxyParams params = item->GetEffectiveProxyParams();
const ProxyManager::Proxy proxy =
ProxyManager::instance()->GetOrStartProxy(
item->project()->cache_path(), item->filename(),