proxy: isolate proxies from export renders and improve proxy workflow

- FootageJob::should_use_proxy() centralizes the proxy decision; worker
  pre-decode now honors the render mode so exports always decode the
  original media (previously every frame was pre-decoded from proxies)
- global Tools > Use Proxy Media toggle with footage invalidation
- ffmpeg -progress parsing for real percentage feedback while generating
- divider mode (1/2, 1/4, 1/8 of source resolution) with UI, proxy
  filename tags and per-footage persistence (pdivider)
- Media Offline warning slat rendered for missing footage
- regression tests: export isolation, relink invalidation, offline slat,
  progress parsing, divider arguments
This commit is contained in:
2026-07-19 21:43:58 +08:00
parent bb40b4923e
commit 0c02ff0d77
21 changed files with 494 additions and 25 deletions
+18 -5
View File
@@ -38,8 +38,8 @@ bool proxy_params_equal(const ProxyManager::ProxyParams &a,
const ProxyManager::ProxyParams &b) const ProxyManager::ProxyParams &b)
{ {
return a.width == b.width && a.height == b.height && return a.width == b.width && a.height == b.height &&
a.version == b.version && a.extension == b.extension && a.divider == b.divider && a.version == b.version &&
a.crf == b.crf && a.preset == b.preset && a.extension == b.extension && a.crf == b.crf && a.preset == b.preset &&
a.include_audio == b.include_audio; a.include_audio == b.include_audio;
} }
@@ -56,11 +56,23 @@ QString ProxyManager::get_proxy_filename(const QString &cache_path,
const QString proxy_dir = get_proxy_directory(cache_path); const QString proxy_dir = get_proxy_directory(cache_path);
const QString extension = const QString extension =
params.extension.isEmpty() ? QStringLiteral("mp4") : params.extension; params.extension.isEmpty() ? QStringLiteral("mp4") : params.extension;
// Divider mode scales relative to the source, so the tag names the
// divider rather than an absolute target size
QString size_tag;
if (params.divider > 1) {
size_tag = QStringLiteral("div%1").arg(QString::number(params.divider));
} else {
size_tag = QStringLiteral("%1x%2")
.arg(QString::number(params.width),
QString::number(params.height));
}
const QString filename = const QString filename =
QStringLiteral("%1-%2.%3x%4.v%5.a%6.%7") QStringLiteral("%1-%2.%3.v%4.a%5.%6")
.arg(FileFunctions::get_unique_file_identifier(source_filename), .arg(FileFunctions::get_unique_file_identifier(source_filename),
QString::number(stream_index), QString::number(params.width), QString::number(stream_index), size_tag,
QString::number(params.height), QString::number(params.version), QString::number(params.version),
params.include_audio ? QStringLiteral("1") : QStringLiteral("0"), params.include_audio ? QStringLiteral("1") : QStringLiteral("0"),
extension); extension);
@@ -133,6 +145,7 @@ ProxyManager::ProxyParams ProxyManager::proxy_params_from_config()
ProxyParams params; ProxyParams params;
params.width = OAK_CONFIG("ProxyWidth").value<int>(); params.width = OAK_CONFIG("ProxyWidth").value<int>();
params.height = OAK_CONFIG("ProxyHeight").value<int>(); params.height = OAK_CONFIG("ProxyHeight").value<int>();
params.divider = OAK_CONFIG("ProxyDivider").value<int>();
params.crf = OAK_CONFIG("ProxyCRF").value<int>(); params.crf = OAK_CONFIG("ProxyCRF").value<int>();
params.preset = OAK_CONFIG("ProxyPreset").toString(); params.preset = OAK_CONFIG("ProxyPreset").toString();
params.include_audio = OAK_CONFIG("ProxyIncludeAudio").toBool(); params.include_audio = OAK_CONFIG("ProxyIncludeAudio").toBool();
+5
View File
@@ -62,6 +62,11 @@ public:
struct ProxyParams { struct ProxyParams {
int width = 1280; int width = 1280;
int height = 720; int height = 720;
/**
* @brief Source resolution divider (1 = use absolute width/height,
* 2/4/8 = fraction of the source resolution)
*/
int divider = 1;
int version = 1; int version = 1;
QString extension = QStringLiteral("mp4"); QString extension = QStringLiteral("mp4");
int crf = 23; int crf = 23;
+3
View File
@@ -232,11 +232,14 @@ void Config::set_defaults()
set_entry_internal(QStringLiteral("ProxyWidth"), NodeValue::k_int, 1280); set_entry_internal(QStringLiteral("ProxyWidth"), NodeValue::k_int, 1280);
set_entry_internal(QStringLiteral("ProxyHeight"), NodeValue::k_int, 720); set_entry_internal(QStringLiteral("ProxyHeight"), NodeValue::k_int, 720);
set_entry_internal(QStringLiteral("ProxyDivider"), NodeValue::k_int, 1);
set_entry_internal(QStringLiteral("ProxyCRF"), NodeValue::k_int, 23); set_entry_internal(QStringLiteral("ProxyCRF"), NodeValue::k_int, 23);
set_entry_internal(QStringLiteral("ProxyPreset"), NodeValue::k_text, set_entry_internal(QStringLiteral("ProxyPreset"), NodeValue::k_text,
QStringLiteral("veryfast")); QStringLiteral("veryfast"));
set_entry_internal(QStringLiteral("ProxyIncludeAudio"), NodeValue::k_boolean, set_entry_internal(QStringLiteral("ProxyIncludeAudio"), NodeValue::k_boolean,
true); true);
set_entry_internal(QStringLiteral("UseProxyMedia"), NodeValue::k_boolean,
true);
set_entry_internal(QStringLiteral("FFmpegPath"), NodeValue::k_text, set_entry_internal(QStringLiteral("FFmpegPath"), NodeValue::k_text,
QString()); QString());
+14
View File
@@ -448,6 +448,20 @@ void Core::set_snapping(const bool &b)
emit snapping_changed(snapping_); emit snapping_changed(snapping_);
} }
void Core::set_use_proxy_media(bool enabled)
{
Config::current()[QStringLiteral("UseProxyMedia")] = enabled;
// Invalidate all footage so viewers re-evaluate with the new proxy state
if (open_project_) {
for (Node *n : open_project_->nodes()) {
if (Footage *footage = dynamic_cast<Footage *>(n)) {
footage->invalidate_all(Footage::k_filename_input);
}
}
}
}
void Core::dialog_about_show() void Core::dialog_about_show()
{ {
AboutDialog a(false, main_window_); AboutDialog a(false, main_window_);
+9
View File
@@ -337,6 +337,15 @@ public slots:
*/ */
void set_snapping(const bool &b); void set_snapping(const bool &b);
/**
* @brief Globally enable or disable decoding from proxy media
*
* When disabled, all footage decodes from its original media regardless of
* each footage's individual proxy setting. The per-footage settings are
* preserved and take effect again when this is re-enabled.
*/
void set_use_proxy_media(bool enabled);
/** /**
* @brief Show an About dialog * @brief Show an About dialog
*/ */
+39
View File
@@ -77,6 +77,20 @@ ProxyDialog::ProxyDialog(QWidget *parent, const QVector<Footage *> &footage)
int row = 0; int row = 0;
settings_layout->addWidget(new QLabel(tr("Proxy Resolution:")), row, 0);
resolution_combo_ = new QComboBox();
resolution_combo_->addItem(tr("Custom size"), 1);
resolution_combo_->addItem(tr("1/2 of source"), 2);
resolution_combo_->addItem(tr("1/4 of source"), 4);
resolution_combo_->addItem(tr("1/8 of source"), 8);
const int divider_index = resolution_combo_->findData(params.divider);
if (divider_index >= 0) {
resolution_combo_->setCurrentIndex(divider_index);
}
settings_layout->addWidget(resolution_combo_, row, 1, 1, 3);
row++;
settings_layout->addWidget(new QLabel(tr("Proxy Width:")), row, 0); settings_layout->addWidget(new QLabel(tr("Proxy Width:")), row, 0);
width_slider_ = new IntegerSlider(); width_slider_ = new IntegerSlider();
width_slider_->set_minimum(160); width_slider_->set_minimum(160);
@@ -91,6 +105,16 @@ ProxyDialog::ProxyDialog(QWidget *parent, const QVector<Footage *> &footage)
height_slider_->set_value(params.height); height_slider_->set_value(params.height);
settings_layout->addWidget(height_slider_, row, 3); settings_layout->addWidget(height_slider_, row, 3);
// Absolute size only applies in "Custom size" mode
const auto update_size_sliders_enabled = [this]() {
const bool custom_size = resolution_combo_->currentData().toInt() == 1;
width_slider_->setEnabled(custom_size);
height_slider_->setEnabled(custom_size);
};
connect(resolution_combo_, &QComboBox::currentIndexChanged, this,
update_size_sliders_enabled);
update_size_sliders_enabled();
row++; row++;
settings_layout->addWidget(new QLabel(tr("Proxy CRF:")), row, 0); settings_layout->addWidget(new QLabel(tr("Proxy CRF:")), row, 0);
@@ -182,6 +206,11 @@ int ProxyDialog::proxy_height() const
return static_cast<int>(height_slider_->get_value()); return static_cast<int>(height_slider_->get_value());
} }
int ProxyDialog::proxy_divider() const
{
return resolution_combo_->currentData().toInt();
}
int ProxyDialog::proxy_crf() const int ProxyDialog::proxy_crf() const
{ {
return static_cast<int>(crf_slider_->get_value()); return static_cast<int>(crf_slider_->get_value());
@@ -212,6 +241,14 @@ void ProxyDialog::set_proxy_height(int height)
height_slider_->set_value(height); height_slider_->set_value(height);
} }
void ProxyDialog::set_proxy_divider(int divider)
{
const int index = resolution_combo_->findData(divider);
if (index >= 0) {
resolution_combo_->setCurrentIndex(index);
}
}
void ProxyDialog::set_proxy_crf(int crf) void ProxyDialog::set_proxy_crf(int crf)
{ {
crf_slider_->set_value(crf); crf_slider_->set_value(crf);
@@ -237,6 +274,7 @@ ProxyManager::ProxyParams ProxyDialog::current_params() const
ProxyManager::ProxyParams params = ProxyManager::proxy_params_from_config(); ProxyManager::ProxyParams params = ProxyManager::proxy_params_from_config();
params.width = static_cast<int>(width_slider_->get_value()); params.width = static_cast<int>(width_slider_->get_value());
params.height = static_cast<int>(height_slider_->get_value()); params.height = static_cast<int>(height_slider_->get_value());
params.divider = resolution_combo_->currentData().toInt();
params.crf = static_cast<int>(crf_slider_->get_value()); params.crf = static_cast<int>(crf_slider_->get_value());
params.preset = preset_combo_->currentText(); params.preset = preset_combo_->currentText();
params.include_audio = include_audio_checkbox_->isChecked(); params.include_audio = include_audio_checkbox_->isChecked();
@@ -247,6 +285,7 @@ void ProxyDialog::save_global_settings()
{ {
OAK_CONFIG("ProxyWidth") = static_cast<int>(width_slider_->get_value()); OAK_CONFIG("ProxyWidth") = static_cast<int>(width_slider_->get_value());
OAK_CONFIG("ProxyHeight") = static_cast<int>(height_slider_->get_value()); OAK_CONFIG("ProxyHeight") = static_cast<int>(height_slider_->get_value());
OAK_CONFIG("ProxyDivider") = resolution_combo_->currentData().toInt();
OAK_CONFIG("ProxyCRF") = static_cast<int>(crf_slider_->get_value()); OAK_CONFIG("ProxyCRF") = static_cast<int>(crf_slider_->get_value());
OAK_CONFIG("ProxyPreset") = preset_combo_->currentText(); OAK_CONFIG("ProxyPreset") = preset_combo_->currentText();
OAK_CONFIG("ProxyIncludeAudio") = include_audio_checkbox_->isChecked(); OAK_CONFIG("ProxyIncludeAudio") = include_audio_checkbox_->isChecked();
+6
View File
@@ -43,6 +43,8 @@ public:
int proxy_height() const; int proxy_height() const;
int proxy_divider() const;
int proxy_crf() const; int proxy_crf() const;
QString proxy_preset() const; QString proxy_preset() const;
@@ -55,6 +57,8 @@ public:
void set_proxy_height(int height); void set_proxy_height(int height);
void set_proxy_divider(int divider);
void set_proxy_crf(int crf); void set_proxy_crf(int crf);
void set_proxy_preset(const QString &preset); void set_proxy_preset(const QString &preset);
@@ -80,6 +84,8 @@ private:
IntegerSlider *height_slider_; IntegerSlider *height_slider_;
QComboBox *resolution_combo_;
IntegerSlider *crf_slider_; IntegerSlider *crf_slider_;
QComboBox *preset_combo_; QComboBox *preset_combo_;
+64 -2
View File
@@ -24,6 +24,8 @@
#include <QApplication> #include <QApplication>
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
#include <QImage>
#include <QPainter>
#include <QStandardPaths> #include <QStandardPaths>
#include "codec/decoder.h" #include "codec/decoder.h"
@@ -353,6 +355,11 @@ void Footage::value(const NodeValueRow &value, const NodeGlobals &globals,
// Pop filename from table // Pop filename from table
QString file = value[k_filename_input].to_string(); QString file = value[k_filename_input].to_string();
// Proxies can be globally disabled (Tools > Use Proxy Media) without
// losing each footage's individual proxy setting
const bool proxies_allowed =
Config::current()[QStringLiteral("UseProxyMedia")].toBool();
// If the file exists and the reference is valid, push a footage job to the renderer // If the file exists and the reference is valid, push a footage job to the renderer
if (QFileInfo::exists(file)) { if (QFileInfo::exists(file)) {
// Push length // Push length
@@ -368,7 +375,8 @@ void Footage::value(const NodeValueRow &value, const NodeGlobals &globals,
if (ref.type() == Track::k_video) { if (ref.type() == Track::k_video) {
VideoParams vp = get_video_params(ref.index()); VideoParams vp = get_video_params(ref.index());
if (proxy_enabled_ && !proxy_path_.isEmpty() && if (proxies_allowed && proxy_enabled_ &&
!proxy_path_.isEmpty() &&
proxy_video_stream_index_ == vp.stream_index() && proxy_video_stream_index_ == vp.stream_index() &&
ProxyManager::get_proxy_state(proxy_path_) == ProxyManager::get_proxy_state(proxy_path_) ==
ProxyManager::k_proxy_ready) { ProxyManager::k_proxy_ready) {
@@ -403,7 +411,8 @@ void Footage::value(const NodeValueRow &value, const NodeGlobals &globals,
// Proxies generated with audio contain the video stream at // Proxies generated with audio contain the video stream at
// index 0 followed by all source audio streams in source order // index 0 followed by all source audio streams in source order
if (proxy_enabled_ && !proxy_path_.isEmpty() && if (proxies_allowed && proxy_enabled_ &&
!proxy_path_.isEmpty() &&
ProxyManager::get_proxy_state(proxy_path_) == ProxyManager::get_proxy_state(proxy_path_) ==
ProxyManager::k_proxy_ready && ProxyManager::k_proxy_ready &&
ProxyManager::proxy_filename_has_audio(proxy_path_)) { ProxyManager::proxy_filename_has_audio(proxy_path_)) {
@@ -425,9 +434,62 @@ void Footage::value(const NodeValueRow &value, const NodeGlobals &globals,
ref.to_string()); ref.to_string());
} }
} }
} else if (!file.isEmpty()) {
// Media is offline: push a generated warning frame for each video
// stream so missing media is clearly visible in the timeline instead
// of a transparent/black hole. generate_frame() draws the slat.
for (int i = 0; i < get_total_stream_count(); i++) {
Track::Reference ref = get_reference_from_real_index(i);
if (ref.type() != Track::k_video) {
continue;
}
VideoParams vp = get_video_params(ref.index());
if (!vp.is_valid()) {
vp = globals.vparams();
}
vp.set_format(PixelFormat::u8);
vp.set_colorspace(
project()->color_manager()->get_default_input_color_space());
GenerateJob job(value);
table->push(NodeValue::k_texture, Texture::job(vp, job), this,
ref.to_string());
}
} }
} }
void Footage::generate_frame(FramePtr frame, const GenerateJob &job) const
{
Q_UNUSED(job)
QImage img(reinterpret_cast<uchar *>(frame->data()), frame->width(),
frame->height(), frame->linesize_bytes(),
QImage::Format_RGBA8888_Premultiplied);
// Dark red slat with diagonal stripes, matching the offline media
// warnings of other NLEs
img.fill(QColor(60, 0, 0));
QPainter p(&img);
p.setRenderHint(QPainter::Antialiasing);
p.setPen(QPen(QColor(120, 20, 20), qMax(2, frame->height() / 90)));
const int stripe_step = qMax(16, frame->height() / 6);
for (int x = -frame->height(); x < frame->width() + frame->height();
x += stripe_step) {
p.drawLine(x, 0, x + frame->height(), frame->height());
}
QFont font = p.font();
font.setPixelSize(qMax(10, frame->height() / 10));
font.setBold(true);
p.setFont(font);
p.setPen(Qt::white);
p.drawText(img.rect(), Qt::AlignCenter | Qt::TextWordWrap,
tr("Media Offline\n%1").arg(QFileInfo(filename()).fileName()));
}
QString Footage::get_stream_type_name(Track::Type type) QString Footage::get_stream_type_name(Track::Type type)
{ {
switch (type) { switch (type) {
+3
View File
@@ -247,6 +247,9 @@ public:
virtual void value(const NodeValueRow &value, const NodeGlobals &globals, virtual void value(const NodeValueRow &value, const NodeGlobals &globals,
NodeValueTable *table) const override; NodeValueTable *table) const override;
virtual void generate_frame(FramePtr frame,
const GenerateJob &job) const override;
static QString get_stream_type_name(Track::Type type); static QString get_stream_type_name(Track::Type type);
virtual Node *get_connected_texture_output() override; virtual Node *get_connected_texture_output() override;
+17
View File
@@ -22,7 +22,10 @@
#ifndef OAK_FOOTAGEJOB_H #ifndef OAK_FOOTAGEJOB_H
#define OAK_FOOTAGEJOB_H #define OAK_FOOTAGEJOB_H
#include <QFileInfo>
#include "node/project/footage/footage.h" #include "node/project/footage/footage.h"
#include "render/rendermodes.h"
namespace olive namespace olive
{ {
@@ -85,6 +88,20 @@ public:
has_proxy_ = !filename.isEmpty(); has_proxy_ = !filename.isEmpty();
} }
/**
* @brief Whether decoding for the given render mode should use the proxy
*
* Proxies are a preview accelerator only: offline (realtime preview)
* renders may decode from them, online (export/master) renders must
* always decode the original media. The proxy file must also still
* exist on disk, otherwise decoding falls back to the original.
*/
bool should_use_proxy(RenderMode::Mode mode) const
{
return mode == RenderMode::k_offline && has_proxy() &&
QFileInfo::exists(proxy_filename_);
}
Track::Type type() const Track::Type type() const
{ {
return type_; return type_;
+4 -8
View File
@@ -530,10 +530,8 @@ void RenderProcessor::process_video_footage(TexturePtr destination,
return; return;
} }
const bool use_proxy = const bool use_proxy = stream->should_use_proxy(
static_cast<RenderMode::Mode>(ticket_->property("mode").toInt()) == static_cast<RenderMode::Mode>(ticket_->property("mode").toInt()));
RenderMode::k_offline &&
stream->has_proxy() && QFileInfo::exists(stream->proxy_filename());
const QString decode_filename = use_proxy ? stream->proxy_filename() : const QString decode_filename = use_proxy ? stream->proxy_filename() :
stream->filename(); stream->filename();
const QString decoder_id = use_proxy ? stream->proxy_decoder() : const QString decoder_id = use_proxy ? stream->proxy_decoder() :
@@ -613,10 +611,8 @@ void RenderProcessor::process_audio_footage(SampleBuffer &destination,
// Mirror the video path: use the proxy (when enabled, ready, and containing // Mirror the video path: use the proxy (when enabled, ready, and containing
// audio) for offline renders only, never for export // audio) for offline renders only, never for export
const bool use_proxy = const bool use_proxy = stream->should_use_proxy(
static_cast<RenderMode::Mode>(ticket_->property("mode").toInt()) == static_cast<RenderMode::Mode>(ticket_->property("mode").toInt()));
RenderMode::k_offline &&
stream->has_proxy() && QFileInfo::exists(stream->proxy_filename());
const QString decode_filename = use_proxy ? stream->proxy_filename() : const QString decode_filename = use_proxy ? stream->proxy_filename() :
stream->filename(); stream->filename();
const QString decoder_id = use_proxy ? stream->proxy_decoder() : const QString decoder_id = use_proxy ? stream->proxy_decoder() :
+8 -5
View File
@@ -137,16 +137,18 @@ DecoderPtr resolve_decoder_from_cache(DecoderCache *decoder_cache,
} }
FramePtr decode_input_frame(DecoderCache *decoder_cache, FramePtr decode_input_frame(DecoderCache *decoder_cache,
const FootageInput &input, CancelAtom *cancel) const FootageInput &input, CancelAtom *cancel,
RenderMode::Mode mode)
{ {
VideoParams stream_data = input.job.video_params(); VideoParams stream_data = input.job.video_params();
QString filename = input.job.filename(); QString filename = input.job.filename();
QString decoder_id = input.job.decoder(); QString decoder_id = input.job.decoder();
int stream_index = stream_data.stream_index(); int stream_index = stream_data.stream_index();
// Use generated proxy if one is attached to the job. The Footage node only // Use the generated proxy when this render is allowed to (preview only,
// attaches a proxy when it is enabled, ready, and matches this stream. // never export). The Footage node only attaches a proxy when it is
if (input.job.has_proxy()) { // enabled, ready, and matches this stream.
if (input.job.should_use_proxy(mode)) {
filename = input.job.proxy_filename(); filename = input.job.proxy_filename();
decoder_id = input.job.proxy_decoder(); decoder_id = input.job.proxy_decoder();
stream_index = input.job.proxy_stream_index(); stream_index = input.job.proxy_stream_index();
@@ -219,7 +221,8 @@ bool decode_input_frames(DecoderCache *decoder_cache,
return false; return false;
} }
FramePtr frame = decode_input_frame(decoder_cache, input, cancel); FramePtr frame =
decode_input_frame(decoder_cache, input, cancel, params.mode);
if (!frame || !frame->is_allocated()) { if (!frame || !frame->is_allocated()) {
frames->clear(); frames->clear();
return false; return false;
+102 -5
View File
@@ -48,16 +48,28 @@ QStringList ProxyTask::build_arguments(const QString &source_filename,
const ProxyManager::ProxyParams &params, const ProxyManager::ProxyParams &params,
const QString &output_filename) const QString &output_filename)
{ {
const QString scale_filter = QString scale_filter;
QStringLiteral("scale=w=%1:h=%2:force_original_aspect_ratio=decrease") if (params.divider > 1) {
.arg(QString::number(params.width), // Fraction of the source resolution, rounded down to even dimensions
QString::number(params.height)); // as required by yuv420p
scale_filter =
QStringLiteral("scale=w=trunc(iw/%1/2)*2:h=trunc(ih/%1/2)*2")
.arg(QString::number(params.divider));
} else {
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 = const QString container_format =
params.extension.isEmpty() ? QStringLiteral("mp4") : params.extension; params.extension.isEmpty() ? QStringLiteral("mp4") : params.extension;
QStringList args; QStringList args;
args << QStringLiteral("-y") << QStringLiteral("-i") << source_filename args << QStringLiteral("-y")
// Report machine-readable progress on stdout for the task dialog
<< QStringLiteral("-nostats") << QStringLiteral("-progress")
<< QStringLiteral("pipe:1") << QStringLiteral("-i") << source_filename
// Map the requested video stream first so it is stream 0 in the proxy // Map the requested video stream first so it is stream 0 in the proxy
<< QStringLiteral("-map") << QStringLiteral("0:%1").arg(stream_index); << QStringLiteral("-map") << QStringLiteral("0:%1").arg(stream_index);
@@ -82,6 +94,68 @@ QStringList ProxyTask::build_arguments(const QString &source_filename,
return args; return args;
} }
double ProxyTask::parse_progress(const QString &line, double duration_seconds)
{
if (duration_seconds <= 0.0) {
return -1.0;
}
qint64 out_time_us = -1;
if (line.startsWith(QStringLiteral("out_time_us="))) {
out_time_us = line.mid(12).toLongLong();
} else if (line.startsWith(QStringLiteral("out_time_ms="))) {
// Despite the name, ffmpeg reports this value in microseconds
out_time_us = line.mid(12).toLongLong();
}
if (out_time_us < 0) {
return -1.0;
}
return qBound(0.0, out_time_us / 1000000.0 / duration_seconds, 1.0);
}
namespace
{
/**
* @brief Probes the source duration with the ffprobe next to ffmpeg
*
* Returns 0 when ffprobe is unavailable or the duration cannot be
* determined, in which case the task simply reports no intermediate
* progress.
*/
double probe_source_duration_seconds(const QString &ffmpeg_path,
const QString &source_filename)
{
QString ffprobe =
QFileInfo(ffmpeg_path).dir().filePath(QStringLiteral("ffprobe"));
#if defined(Q_OS_WIN)
ffprobe += QStringLiteral(".exe");
#endif
if (!QFileInfo::exists(ffprobe)) {
return 0.0;
}
QProcess probe;
probe.start(ffprobe,
{ QStringLiteral("-v"), QStringLiteral("error"),
QStringLiteral("-show_entries"), QStringLiteral("format=duration"),
QStringLiteral("-of"),
QStringLiteral("default=noprint_wrappers=1:nokey=1"),
source_filename });
if (!probe.waitForFinished(10000) || probe.exitCode() != 0) {
return 0.0;
}
bool ok = false;
const double duration =
QString::fromUtf8(probe.readAllStandardOutput()).trimmed().toDouble(&ok);
return ok ? duration : 0.0;
}
} // namespace
bool ProxyTask::run() bool ProxyTask::run()
{ {
const QString ffmpeg = ProxyManager::find_f_fmpeg_executable( const QString ffmpeg = ProxyManager::find_f_fmpeg_executable(
@@ -115,6 +189,10 @@ bool ProxyTask::run()
process.setProgram(ffmpeg); process.setProgram(ffmpeg);
process.setArguments(args); process.setArguments(args);
process.setProcessChannelMode(QProcess::MergedChannels); process.setProcessChannelMode(QProcess::MergedChannels);
const double duration_seconds =
probe_source_duration_seconds(ffmpeg, source_filename_);
process.start(); process.start();
if (!process.waitForStarted()) { if (!process.waitForStarted()) {
@@ -124,7 +202,25 @@ bool ProxyTask::run()
return false; return false;
} }
QString progress_buffer;
double last_progress = 0.0;
const auto drain_progress = [&]() {
progress_buffer += QString::fromUtf8(process.readAll());
int newline = -1;
while ((newline = progress_buffer.indexOf(QLatin1Char('\n'))) >= 0) {
const QString line = progress_buffer.left(newline).trimmed();
progress_buffer.remove(0, newline + 1);
const double progress = parse_progress(line, duration_seconds);
if (progress >= 0.0 && progress - last_progress > 0.001) {
last_progress = progress;
emit progress_changed(progress);
}
}
};
while (!process.waitForFinished(100)) { while (!process.waitForFinished(100)) {
drain_progress();
if (is_cancelled()) { if (is_cancelled()) {
process.kill(); process.kill();
process.waitForFinished(); process.waitForFinished();
@@ -133,6 +229,7 @@ bool ProxyTask::run()
return false; return false;
} }
} }
drain_progress();
if (process.exitStatus() != QProcess::NormalExit || if (process.exitStatus() != QProcess::NormalExit ||
process.exitCode() != 0) { process.exitCode() != 0) {
+10
View File
@@ -44,6 +44,16 @@ public:
const ProxyManager::ProxyParams &params, const ProxyManager::ProxyParams &params,
const QString &output_filename); const QString &output_filename);
/**
* @brief Parses one line of ffmpeg "-progress" output
*
* Extracted for testability. If the line carries an output timestamp
* ("out_time_us=" or "out_time_ms="), returns the progress fraction in
* the range [0, 1] against duration_seconds. Returns a negative value
* when the line carries no timestamp or duration_seconds is unknown.
*/
static double parse_progress(const QString &line, double duration_seconds);
protected: protected:
virtual bool run() override; virtual bool run() override;
+10
View File
@@ -361,6 +361,15 @@ MainMenu::MainMenu(MainWindow *parent)
tools_menu_->addSeparator(); tools_menu_->addSeparator();
tools_use_proxy_item_ = new QAction(this);
Menu::conform_item(tools_use_proxy_item_, "useproxymedia");
tools_use_proxy_item_->setCheckable(true);
tools_use_proxy_item_->setChecked(
Config::current()[QStringLiteral("UseProxyMedia")].toBool());
connect(tools_use_proxy_item_, &QAction::triggered, Core::instance(),
&Core::set_use_proxy_media);
tools_menu_->addAction(tools_use_proxy_item_);
tools_proxy_settings_item_ = new QAction(this); tools_proxy_settings_item_ = new QAction(this);
Menu::conform_item(tools_proxy_settings_item_, "proxysettings"); Menu::conform_item(tools_proxy_settings_item_, "proxysettings");
connect(tools_proxy_settings_item_, &QAction::triggered, this, [this]() { connect(tools_proxy_settings_item_, &QAction::triggered, this, [this]() {
@@ -892,6 +901,7 @@ void MainMenu::retranslate()
tools_add_item_->setText(tr("Add Tool")); tools_add_item_->setText(tr("Add Tool"));
tools_record_item_->setText(tr("Record Tool")); tools_record_item_->setText(tr("Record Tool"));
tools_snapping_item_->setText(tr("Enable Snapping")); tools_snapping_item_->setText(tr("Enable Snapping"));
tools_use_proxy_item_->setText(tr("Use Proxy Media"));
tools_proxy_settings_item_->setText(tr("Proxy Settings...")); tools_proxy_settings_item_->setText(tr("Proxy Settings..."));
tools_preferences_item_->setText(tr("Preferences")); tools_preferences_item_->setText(tr("Preferences"));
tools_add_item_menu_->setTitle(tr("Add Tool Item")); tools_add_item_menu_->setTitle(tr("Add Tool Item"));
+1
View File
@@ -281,6 +281,7 @@ private:
QAction *tools_add_item_; QAction *tools_add_item_;
QAction *tools_record_item_; QAction *tools_record_item_;
QAction *tools_snapping_item_; QAction *tools_snapping_item_;
QAction *tools_use_proxy_item_;
QAction *tools_proxy_settings_item_; QAction *tools_proxy_settings_item_;
QAction *tools_preferences_item_; QAction *tools_preferences_item_;
Menu *tools_add_item_menu_; Menu *tools_add_item_menu_;
+2
View File
@@ -375,6 +375,8 @@ Default `Node::SaveCustom()` writes nothing. Specific node subclasses may overri
- `custom`(可选):为 `1` 表示该素材使用独立的自定义代理参数,而不是全局设置。 - `custom`(可选):为 `1` 表示该素材使用独立的自定义代理参数,而不是全局设置。
- `pwidth`, `pheight` (optional, requires `custom="1"`): custom proxy dimensions. - `pwidth`, `pheight` (optional, requires `custom="1"`): custom proxy dimensions.
- `pwidth``pheight`(可选,需 `custom="1"`):自定义代理分辨率。 - `pwidth``pheight`(可选,需 `custom="1"`):自定义代理分辨率。
- `pdivider` (optional): source resolution divider (`1` = use `pwidth`/`pheight`; `2`/`4`/`8` = fraction of the source resolution). Defaults to `1` when absent.
- `pdivider`(可选):源分辨率分频(`1` = 使用 `pwidth`/`pheight``2`/`4`/`8` = 源分辨率的几分之一)。缺省时为 `1`
- `pcrf` (optional): custom x264 CRF value. - `pcrf` (optional): custom x264 CRF value.
- `pcrf`(可选):自定义 x264 CRF 值。 - `pcrf`(可选):自定义 x264 CRF 值。
- `ppreset` (optional): custom x264 preset name. - `ppreset` (optional): custom x264 preset name.
+7
View File
@@ -46,6 +46,7 @@ add_executable(olive-gtest
project_serializer_test.cpp project_serializer_test.cpp
proxy_manager_test.cpp proxy_manager_test.cpp
proxy_dialog_test.cpp proxy_dialog_test.cpp
footage_job_proxy_test.cpp
lut_file_field_test.cpp lut_file_field_test.cpp
node_math_test.cpp node_math_test.cpp
node_undo_test.cpp node_undo_test.cpp
@@ -184,6 +185,12 @@ if (OAK_ENABLE_DYNAMIC_RENDER_BACKEND)
endif() endif()
endif() endif()
# Several render tests spawn the render worker binary from the build tree; it
# must be up to date with the code under test
if (TARGET olive-render-worker)
add_dependencies(olive-gtest olive-render-worker)
endif()
if (MSVC) if (MSVC)
add_test("Olive.gtest" olive-gtest) add_test("Olive.gtest" olive-gtest)
else() else()
+54
View File
@@ -0,0 +1,54 @@
#include <gtest/gtest.h>
#include <QTemporaryFile>
#include "render/job/footagejob.h"
TEST(FootageJobProxy, NoProxyNeverUsed)
{
olive::FootageJob job;
EXPECT_FALSE(job.has_proxy());
EXPECT_FALSE(job.should_use_proxy(olive::RenderMode::k_offline));
EXPECT_FALSE(job.should_use_proxy(olive::RenderMode::k_online));
}
TEST(FootageJobProxy, OfflineUsesProxyOnlineDoesNot)
{
QTemporaryFile proxy_file;
ASSERT_TRUE(proxy_file.open());
olive::FootageJob job;
job.set_proxy(proxy_file.fileName(), QStringLiteral("ffmpeg"), 0);
ASSERT_TRUE(job.has_proxy());
// Preview renders may decode from the proxy
EXPECT_TRUE(job.should_use_proxy(olive::RenderMode::k_offline));
// Export/master renders must always decode the original media
EXPECT_FALSE(job.should_use_proxy(olive::RenderMode::k_online));
}
TEST(FootageJobProxy, MissingProxyFileFallsBackToOriginal)
{
olive::FootageJob job;
job.set_proxy(QStringLiteral("/nonexistent/path/proxy.mp4"),
QStringLiteral("ffmpeg"), 0);
ASSERT_TRUE(job.has_proxy());
// A proxy that no longer exists on disk must not be used, even for preview
EXPECT_FALSE(job.should_use_proxy(olive::RenderMode::k_offline));
EXPECT_FALSE(job.should_use_proxy(olive::RenderMode::k_online));
}
TEST(FootageJobProxy, EmptyProxyFilenameDisablesProxy)
{
olive::FootageJob job;
job.set_proxy(QString(), QStringLiteral("ffmpeg"), 0);
EXPECT_FALSE(job.has_proxy());
EXPECT_FALSE(job.should_use_proxy(olive::RenderMode::k_offline));
EXPECT_FALSE(job.should_use_proxy(olive::RenderMode::k_online));
}
+52
View File
@@ -12,6 +12,7 @@
#include "common/filefunctions.h" #include "common/filefunctions.h"
#include "core.h" #include "core.h"
#include "codec/frame.h"
#include "node/color/colormanager/colormanager.h" #include "node/color/colormanager/colormanager.h"
#include "node/globals.h" #include "node/globals.h"
#include "node/project.h" #include "node/project.h"
@@ -19,6 +20,7 @@
#include "render/diskmanager.h" #include "render/diskmanager.h"
#include "node/project/footage/footagedescription.h" #include "node/project/footage/footagedescription.h"
#include "render/job/footagejob.h" #include "render/job/footagejob.h"
#include "render/job/generatejob.h"
#include "render/loopmode.h" #include "render/loopmode.h"
#include "render/texture.h" #include "render/texture.h"
#include "ui/icons/icons.h" #include "ui/icons/icons.h"
@@ -589,6 +591,56 @@ TEST_F(FootageTest, ProxyChangesMarkProjectModifiedAndEmitSignal)
EXPECT_FALSE(footage->proxy_enabled()); EXPECT_FALSE(footage->proxy_enabled());
} }
TEST_F(FootageTest, OfflineMediaGeneratesWarningFrame)
{
olive::Footage *footage = add_footage();
footage->set_filename(QStringLiteral("/nonexistent/media.mp4"));
olive::FramePtr frame = olive::Frame::create();
frame->set_video_params(olive::VideoParams(320, 180, olive::PixelFormat::u8,
olive::VideoParams::k_rgba_channel_count));
frame->allocate();
footage->generate_frame(frame, olive::GenerateJob());
// The offline slat must be visible: dark red background/stripes plus
// white warning text
bool found_red_pixel = false;
bool found_bright_pixel = false;
const auto *data = reinterpret_cast<const uchar *>(frame->data());
for (int y = 0; y < frame->height(); y++) {
const uchar *row = data + y * frame->linesize_bytes();
for (int x = 0; x < frame->width(); x++) {
const uchar *px = row + x * 4;
if (px[0] > 40 && px[1] < px[0] / 2 && px[2] < px[0] / 2) {
found_red_pixel = true;
}
if (px[0] > 200 && px[1] > 200 && px[2] > 200) {
found_bright_pixel = true;
}
}
}
EXPECT_TRUE(found_red_pixel);
EXPECT_TRUE(found_bright_pixel);
}
TEST_F(FootageTest, RelinkClearsStaleProxy)
{
olive::Footage *footage = add_footage();
footage->set_filename(QStringLiteral("/media/original.mov"));
footage->set_proxy(QStringLiteral("/cache/proxy/example.mp4"),
olive::ProxyManager::k_proxy_ready, 0, 1, true);
ASSERT_FALSE(footage->proxy_path().isEmpty());
// Relinking to a different file must invalidate the proxy that was
// generated from the old source (Footage::clear() drops it when the
// filename input changes)
footage->set_filename(QStringLiteral("/media/relinked.mov"));
EXPECT_TRUE(footage->proxy_path().isEmpty());
EXPECT_FALSE(footage->proxy_enabled());
EXPECT_EQ(footage->proxy_state(), olive::ProxyManager::k_proxy_missing);
}
TEST_F(FootageTest, ReprobeRestoresStreamsFromMetadataCache) TEST_F(FootageTest, ReprobeRestoresStreamsFromMetadataCache)
{ {
QTemporaryDir dir; QTemporaryDir dir;
+66
View File
@@ -437,12 +437,76 @@ TEST(ProxyTask, BuildArgumentsDisablesAudioWhenDisabled)
EXPECT_FALSE(args.contains(QStringLiteral("0:a?"))); EXPECT_FALSE(args.contains(QStringLiteral("0:a?")));
} }
TEST(ProxyTask, ParseProgressReadsOutTime)
{
EXPECT_DOUBLE_EQ(olive::ProxyTask::parse_progress(
QStringLiteral("out_time_us=5000000"), 10.0),
0.5);
// Despite the name, out_time_ms is also reported in microseconds
EXPECT_DOUBLE_EQ(olive::ProxyTask::parse_progress(
QStringLiteral("out_time_ms=2500000"), 10.0),
0.25);
}
TEST(ProxyTask, ParseProgressClampsToOne)
{
EXPECT_DOUBLE_EQ(olive::ProxyTask::parse_progress(
QStringLiteral("out_time_us=20000000"), 10.0),
1.0);
}
TEST(ProxyTask, ParseProgressIgnoresUnknownDuration)
{
EXPECT_LT(olive::ProxyTask::parse_progress(
QStringLiteral("out_time_us=5000000"), 0.0),
0.0);
}
TEST(ProxyTask, ParseProgressIgnoresUnrelatedLines)
{
EXPECT_LT(olive::ProxyTask::parse_progress(QStringLiteral("frame= 250"),
10.0),
0.0);
EXPECT_LT(olive::ProxyTask::parse_progress(QStringLiteral("progress=end"),
10.0),
0.0);
}
TEST(ProxyTask, BuildArgumentsDividerScalesFromSource)
{
olive::ProxyManager::ProxyParams params;
params.divider = 4;
const QStringList args = olive::ProxyTask::build_arguments(
QStringLiteral("/media/source.mov"), 0, params,
QStringLiteral("/cache/proxy/out.mp4"));
const int vf = args.indexOf(QStringLiteral("-vf"));
ASSERT_GE(vf, 0);
EXPECT_EQ(args.at(vf + 1),
QStringLiteral("scale=w=trunc(iw/4/2)*2:h=trunc(ih/4/2)*2"));
}
TEST(ProxyManager, DividerProxyFilenameUsesDividerTag)
{
olive::ProxyManager::ProxyParams params;
params.divider = 2;
const QString filename = olive::ProxyManager::get_proxy_filename(
QStringLiteral("/tmp/oak-cache"), QStringLiteral("/media/source.mov"),
0, params);
EXPECT_TRUE(QFileInfo(filename).fileName().contains(
QStringLiteral(".div2.")));
}
TEST(ProxyManager, FootagePersistsCustomProxyParams) TEST(ProxyManager, FootagePersistsCustomProxyParams)
{ {
olive::Footage footage; olive::Footage footage;
olive::ProxyManager::ProxyParams params; olive::ProxyManager::ProxyParams params;
params.width = 640; params.width = 640;
params.height = 360; params.height = 360;
params.divider = 4;
params.crf = 30; params.crf = 30;
params.preset = QStringLiteral("faster"); params.preset = QStringLiteral("faster");
params.extension = QStringLiteral("mov"); params.extension = QStringLiteral("mov");
@@ -462,6 +526,7 @@ TEST(ProxyManager, FootagePersistsCustomProxyParams)
EXPECT_TRUE(xml.contains(QStringLiteral("custom=\"1\""))); EXPECT_TRUE(xml.contains(QStringLiteral("custom=\"1\"")));
EXPECT_TRUE(xml.contains(QStringLiteral("pwidth=\"640\""))); EXPECT_TRUE(xml.contains(QStringLiteral("pwidth=\"640\"")));
EXPECT_TRUE(xml.contains(QStringLiteral("pheight=\"360\""))); EXPECT_TRUE(xml.contains(QStringLiteral("pheight=\"360\"")));
EXPECT_TRUE(xml.contains(QStringLiteral("pdivider=\"4\"")));
EXPECT_TRUE(xml.contains(QStringLiteral("pcrf=\"30\""))); EXPECT_TRUE(xml.contains(QStringLiteral("pcrf=\"30\"")));
EXPECT_TRUE(xml.contains(QStringLiteral("ppreset=\"faster\""))); EXPECT_TRUE(xml.contains(QStringLiteral("ppreset=\"faster\"")));
EXPECT_TRUE(xml.contains(QStringLiteral("pext=\"mov\""))); EXPECT_TRUE(xml.contains(QStringLiteral("pext=\"mov\"")));
@@ -476,6 +541,7 @@ TEST(ProxyManager, FootagePersistsCustomProxyParams)
ASSERT_TRUE(loaded.has_custom_proxy_params()); ASSERT_TRUE(loaded.has_custom_proxy_params());
EXPECT_EQ(loaded.custom_proxy_params().width, 640); EXPECT_EQ(loaded.custom_proxy_params().width, 640);
EXPECT_EQ(loaded.custom_proxy_params().height, 360); EXPECT_EQ(loaded.custom_proxy_params().height, 360);
EXPECT_EQ(loaded.custom_proxy_params().divider, 4);
EXPECT_EQ(loaded.custom_proxy_params().crf, 30); EXPECT_EQ(loaded.custom_proxy_params().crf, 30);
EXPECT_EQ(loaded.custom_proxy_params().preset, QStringLiteral("faster")); EXPECT_EQ(loaded.custom_proxy_params().preset, QStringLiteral("faster"));
EXPECT_EQ(loaded.custom_proxy_params().extension, QStringLiteral("mov")); EXPECT_EQ(loaded.custom_proxy_params().extension, QStringLiteral("mov"));