From 0c02ff0d775ab8eca7b56a57f1e402f15c4769ac Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 19 Jul 2026 21:43:58 +0800 Subject: [PATCH] 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 --- app/codec/proxymanager.cpp | 23 ++++-- app/codec/proxymanager.h | 5 ++ app/config/config.cpp | 3 + app/core.cpp | 14 ++++ app/core.h | 9 +++ app/dialog/proxy/proxydialog.cpp | 39 +++++++++ app/dialog/proxy/proxydialog.h | 6 ++ app/node/project/footage/footage.cpp | 66 ++++++++++++++- app/node/project/footage/footage.h | 3 + app/render/job/footagejob.h | 17 ++++ app/render/renderprocessor.cpp | 12 +-- app/render/renderworkerpool.cpp | 13 +-- app/task/proxy/proxy.cpp | 107 +++++++++++++++++++++++-- app/task/proxy/proxy.h | 10 +++ app/window/mainwindow/mainmenu.cpp | 10 +++ app/window/mainwindow/mainmenu.h | 1 + docs/project-file-reference.md | 2 + tests/gtest/CMakeLists.txt | 7 ++ tests/gtest/footage_job_proxy_test.cpp | 54 +++++++++++++ tests/gtest/footage_test.cpp | 52 ++++++++++++ tests/gtest/proxy_manager_test.cpp | 66 +++++++++++++++ 21 files changed, 494 insertions(+), 25 deletions(-) create mode 100644 tests/gtest/footage_job_proxy_test.cpp diff --git a/app/codec/proxymanager.cpp b/app/codec/proxymanager.cpp index eb815ffa1..855d384ac 100644 --- a/app/codec/proxymanager.cpp +++ b/app/codec/proxymanager.cpp @@ -38,8 +38,8 @@ bool proxy_params_equal(const ProxyManager::ProxyParams &a, const ProxyManager::ProxyParams &b) { 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.divider == b.divider && a.version == b.version && + a.extension == b.extension && a.crf == b.crf && a.preset == b.preset && 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 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 = - 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), - QString::number(stream_index), QString::number(params.width), - QString::number(params.height), QString::number(params.version), + QString::number(stream_index), size_tag, + QString::number(params.version), params.include_audio ? QStringLiteral("1") : QStringLiteral("0"), extension); @@ -133,6 +145,7 @@ ProxyManager::ProxyParams ProxyManager::proxy_params_from_config() ProxyParams params; params.width = OAK_CONFIG("ProxyWidth").value(); params.height = OAK_CONFIG("ProxyHeight").value(); + params.divider = OAK_CONFIG("ProxyDivider").value(); params.crf = OAK_CONFIG("ProxyCRF").value(); params.preset = OAK_CONFIG("ProxyPreset").toString(); params.include_audio = OAK_CONFIG("ProxyIncludeAudio").toBool(); diff --git a/app/codec/proxymanager.h b/app/codec/proxymanager.h index 33a9c13b2..a70c29be2 100644 --- a/app/codec/proxymanager.h +++ b/app/codec/proxymanager.h @@ -62,6 +62,11 @@ public: struct ProxyParams { int width = 1280; 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; QString extension = QStringLiteral("mp4"); int crf = 23; diff --git a/app/config/config.cpp b/app/config/config.cpp index 6acee8ff7..4f763afc3 100644 --- a/app/config/config.cpp +++ b/app/config/config.cpp @@ -232,11 +232,14 @@ void Config::set_defaults() set_entry_internal(QStringLiteral("ProxyWidth"), NodeValue::k_int, 1280); 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("ProxyPreset"), NodeValue::k_text, QStringLiteral("veryfast")); set_entry_internal(QStringLiteral("ProxyIncludeAudio"), NodeValue::k_boolean, true); + set_entry_internal(QStringLiteral("UseProxyMedia"), NodeValue::k_boolean, + true); set_entry_internal(QStringLiteral("FFmpegPath"), NodeValue::k_text, QString()); diff --git a/app/core.cpp b/app/core.cpp index 461a23219..4c60669ce 100644 --- a/app/core.cpp +++ b/app/core.cpp @@ -448,6 +448,20 @@ void Core::set_snapping(const bool &b) 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(n)) { + footage->invalidate_all(Footage::k_filename_input); + } + } + } +} + void Core::dialog_about_show() { AboutDialog a(false, main_window_); diff --git a/app/core.h b/app/core.h index 807a33b83..d28f0cc17 100644 --- a/app/core.h +++ b/app/core.h @@ -337,6 +337,15 @@ public slots: */ 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 */ diff --git a/app/dialog/proxy/proxydialog.cpp b/app/dialog/proxy/proxydialog.cpp index 296770eee..3403d1499 100644 --- a/app/dialog/proxy/proxydialog.cpp +++ b/app/dialog/proxy/proxydialog.cpp @@ -77,6 +77,20 @@ ProxyDialog::ProxyDialog(QWidget *parent, const QVector &footage) 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); width_slider_ = new IntegerSlider(); width_slider_->set_minimum(160); @@ -91,6 +105,16 @@ ProxyDialog::ProxyDialog(QWidget *parent, const QVector &footage) height_slider_->set_value(params.height); 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++; settings_layout->addWidget(new QLabel(tr("Proxy CRF:")), row, 0); @@ -182,6 +206,11 @@ int ProxyDialog::proxy_height() const return static_cast(height_slider_->get_value()); } +int ProxyDialog::proxy_divider() const +{ + return resolution_combo_->currentData().toInt(); +} + int ProxyDialog::proxy_crf() const { return static_cast(crf_slider_->get_value()); @@ -212,6 +241,14 @@ void ProxyDialog::set_proxy_height(int 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) { crf_slider_->set_value(crf); @@ -237,6 +274,7 @@ ProxyManager::ProxyParams ProxyDialog::current_params() const ProxyManager::ProxyParams params = ProxyManager::proxy_params_from_config(); params.width = static_cast(width_slider_->get_value()); params.height = static_cast(height_slider_->get_value()); + params.divider = resolution_combo_->currentData().toInt(); params.crf = static_cast(crf_slider_->get_value()); params.preset = preset_combo_->currentText(); params.include_audio = include_audio_checkbox_->isChecked(); @@ -247,6 +285,7 @@ void ProxyDialog::save_global_settings() { OAK_CONFIG("ProxyWidth") = static_cast(width_slider_->get_value()); OAK_CONFIG("ProxyHeight") = static_cast(height_slider_->get_value()); + OAK_CONFIG("ProxyDivider") = resolution_combo_->currentData().toInt(); OAK_CONFIG("ProxyCRF") = static_cast(crf_slider_->get_value()); OAK_CONFIG("ProxyPreset") = preset_combo_->currentText(); OAK_CONFIG("ProxyIncludeAudio") = include_audio_checkbox_->isChecked(); diff --git a/app/dialog/proxy/proxydialog.h b/app/dialog/proxy/proxydialog.h index dc4d335fc..0da9a600a 100644 --- a/app/dialog/proxy/proxydialog.h +++ b/app/dialog/proxy/proxydialog.h @@ -43,6 +43,8 @@ public: int proxy_height() const; + int proxy_divider() const; + int proxy_crf() const; QString proxy_preset() const; @@ -55,6 +57,8 @@ public: void set_proxy_height(int height); + void set_proxy_divider(int divider); + void set_proxy_crf(int crf); void set_proxy_preset(const QString &preset); @@ -80,6 +84,8 @@ private: IntegerSlider *height_slider_; + QComboBox *resolution_combo_; + IntegerSlider *crf_slider_; QComboBox *preset_combo_; diff --git a/app/node/project/footage/footage.cpp b/app/node/project/footage/footage.cpp index fa4462065..0dd0c04ce 100644 --- a/app/node/project/footage/footage.cpp +++ b/app/node/project/footage/footage.cpp @@ -24,6 +24,8 @@ #include #include #include +#include +#include #include #include "codec/decoder.h" @@ -353,6 +355,11 @@ void Footage::value(const NodeValueRow &value, const NodeGlobals &globals, // Pop filename from table 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 (QFileInfo::exists(file)) { // Push length @@ -368,7 +375,8 @@ void Footage::value(const NodeValueRow &value, const NodeGlobals &globals, if (ref.type() == Track::k_video) { 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() && ProxyManager::get_proxy_state(proxy_path_) == 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 // 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::k_proxy_ready && ProxyManager::proxy_filename_has_audio(proxy_path_)) { @@ -425,9 +434,62 @@ void Footage::value(const NodeValueRow &value, const NodeGlobals &globals, 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(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) { switch (type) { diff --git a/app/node/project/footage/footage.h b/app/node/project/footage/footage.h index 4d3eb02a3..230ff3e20 100644 --- a/app/node/project/footage/footage.h +++ b/app/node/project/footage/footage.h @@ -247,6 +247,9 @@ public: virtual void value(const NodeValueRow &value, const NodeGlobals &globals, NodeValueTable *table) const override; + virtual void generate_frame(FramePtr frame, + const GenerateJob &job) const override; + static QString get_stream_type_name(Track::Type type); virtual Node *get_connected_texture_output() override; diff --git a/app/render/job/footagejob.h b/app/render/job/footagejob.h index ecb6851fa..b95b58dba 100644 --- a/app/render/job/footagejob.h +++ b/app/render/job/footagejob.h @@ -22,7 +22,10 @@ #ifndef OAK_FOOTAGEJOB_H #define OAK_FOOTAGEJOB_H +#include + #include "node/project/footage/footage.h" +#include "render/rendermodes.h" namespace olive { @@ -85,6 +88,20 @@ public: 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 { return type_; diff --git a/app/render/renderprocessor.cpp b/app/render/renderprocessor.cpp index 4a6451d72..5a7664765 100644 --- a/app/render/renderprocessor.cpp +++ b/app/render/renderprocessor.cpp @@ -530,10 +530,8 @@ void RenderProcessor::process_video_footage(TexturePtr destination, return; } - const bool use_proxy = - static_cast(ticket_->property("mode").toInt()) == - RenderMode::k_offline && - stream->has_proxy() && QFileInfo::exists(stream->proxy_filename()); + const bool use_proxy = stream->should_use_proxy( + static_cast(ticket_->property("mode").toInt())); const QString decode_filename = use_proxy ? stream->proxy_filename() : stream->filename(); 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 // audio) for offline renders only, never for export - const bool use_proxy = - static_cast(ticket_->property("mode").toInt()) == - RenderMode::k_offline && - stream->has_proxy() && QFileInfo::exists(stream->proxy_filename()); + const bool use_proxy = stream->should_use_proxy( + static_cast(ticket_->property("mode").toInt())); const QString decode_filename = use_proxy ? stream->proxy_filename() : stream->filename(); const QString decoder_id = use_proxy ? stream->proxy_decoder() : diff --git a/app/render/renderworkerpool.cpp b/app/render/renderworkerpool.cpp index 00b39b26a..43f7dcc03 100644 --- a/app/render/renderworkerpool.cpp +++ b/app/render/renderworkerpool.cpp @@ -137,16 +137,18 @@ DecoderPtr resolve_decoder_from_cache(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(); QString filename = input.job.filename(); QString decoder_id = input.job.decoder(); int stream_index = stream_data.stream_index(); - // Use generated proxy if one is attached to the job. The Footage node only - // attaches a proxy when it is enabled, ready, and matches this stream. - if (input.job.has_proxy()) { + // Use the generated proxy when this render is allowed to (preview only, + // never export). The Footage node only attaches a proxy when it is + // enabled, ready, and matches this stream. + if (input.job.should_use_proxy(mode)) { filename = input.job.proxy_filename(); decoder_id = input.job.proxy_decoder(); stream_index = input.job.proxy_stream_index(); @@ -219,7 +221,8 @@ bool decode_input_frames(DecoderCache *decoder_cache, 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()) { frames->clear(); return false; diff --git a/app/task/proxy/proxy.cpp b/app/task/proxy/proxy.cpp index 6b3eeb669..1f70c03ab 100644 --- a/app/task/proxy/proxy.cpp +++ b/app/task/proxy/proxy.cpp @@ -48,16 +48,28 @@ QStringList ProxyTask::build_arguments(const QString &source_filename, 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)); + QString scale_filter; + if (params.divider > 1) { + // Fraction of the source resolution, rounded down to even dimensions + // 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 = params.extension.isEmpty() ? QStringLiteral("mp4") : params.extension; 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 << QStringLiteral("-map") << QStringLiteral("0:%1").arg(stream_index); @@ -82,6 +94,68 @@ QStringList ProxyTask::build_arguments(const QString &source_filename, 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() { const QString ffmpeg = ProxyManager::find_f_fmpeg_executable( @@ -115,6 +189,10 @@ bool ProxyTask::run() process.setProgram(ffmpeg); process.setArguments(args); process.setProcessChannelMode(QProcess::MergedChannels); + + const double duration_seconds = + probe_source_duration_seconds(ffmpeg, source_filename_); + process.start(); if (!process.waitForStarted()) { @@ -124,7 +202,25 @@ bool ProxyTask::run() 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)) { + drain_progress(); if (is_cancelled()) { process.kill(); process.waitForFinished(); @@ -133,6 +229,7 @@ bool ProxyTask::run() return false; } } + drain_progress(); if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) { diff --git a/app/task/proxy/proxy.h b/app/task/proxy/proxy.h index a48b83d4a..4e051a80b 100644 --- a/app/task/proxy/proxy.h +++ b/app/task/proxy/proxy.h @@ -44,6 +44,16 @@ public: const ProxyManager::ProxyParams ¶ms, 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: virtual bool run() override; diff --git a/app/window/mainwindow/mainmenu.cpp b/app/window/mainwindow/mainmenu.cpp index 51dcf10ff..9521bf0bc 100644 --- a/app/window/mainwindow/mainmenu.cpp +++ b/app/window/mainwindow/mainmenu.cpp @@ -361,6 +361,15 @@ MainMenu::MainMenu(MainWindow *parent) 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); Menu::conform_item(tools_proxy_settings_item_, "proxysettings"); connect(tools_proxy_settings_item_, &QAction::triggered, this, [this]() { @@ -892,6 +901,7 @@ void MainMenu::retranslate() tools_add_item_->setText(tr("Add Tool")); tools_record_item_->setText(tr("Record Tool")); 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_preferences_item_->setText(tr("Preferences")); tools_add_item_menu_->setTitle(tr("Add Tool Item")); diff --git a/app/window/mainwindow/mainmenu.h b/app/window/mainwindow/mainmenu.h index a33e03270..4aedccde9 100644 --- a/app/window/mainwindow/mainmenu.h +++ b/app/window/mainwindow/mainmenu.h @@ -281,6 +281,7 @@ private: QAction *tools_add_item_; QAction *tools_record_item_; QAction *tools_snapping_item_; + QAction *tools_use_proxy_item_; QAction *tools_proxy_settings_item_; QAction *tools_preferences_item_; Menu *tools_add_item_menu_; diff --git a/docs/project-file-reference.md b/docs/project-file-reference.md index cd0f68397..b4c858eb9 100644 --- a/docs/project-file-reference.md +++ b/docs/project-file-reference.md @@ -375,6 +375,8 @@ Default `Node::SaveCustom()` writes nothing. Specific node subclasses may overri - `custom`(可选):为 `1` 表示该素材使用独立的自定义代理参数,而不是全局设置。 - `pwidth`, `pheight` (optional, requires `custom="1"`): custom proxy dimensions. - `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`(可选):自定义 x264 CRF 值。 - `ppreset` (optional): custom x264 preset name. diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 920627a8b..80a9b7b72 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -46,6 +46,7 @@ add_executable(olive-gtest project_serializer_test.cpp proxy_manager_test.cpp proxy_dialog_test.cpp + footage_job_proxy_test.cpp lut_file_field_test.cpp node_math_test.cpp node_undo_test.cpp @@ -184,6 +185,12 @@ if (OAK_ENABLE_DYNAMIC_RENDER_BACKEND) 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) add_test("Olive.gtest" olive-gtest) else() diff --git a/tests/gtest/footage_job_proxy_test.cpp b/tests/gtest/footage_job_proxy_test.cpp new file mode 100644 index 000000000..0ba6f6db4 --- /dev/null +++ b/tests/gtest/footage_job_proxy_test.cpp @@ -0,0 +1,54 @@ +#include + +#include + +#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)); +} diff --git a/tests/gtest/footage_test.cpp b/tests/gtest/footage_test.cpp index 3b11e1702..49cdfcd81 100644 --- a/tests/gtest/footage_test.cpp +++ b/tests/gtest/footage_test.cpp @@ -12,6 +12,7 @@ #include "common/filefunctions.h" #include "core.h" +#include "codec/frame.h" #include "node/color/colormanager/colormanager.h" #include "node/globals.h" #include "node/project.h" @@ -19,6 +20,7 @@ #include "render/diskmanager.h" #include "node/project/footage/footagedescription.h" #include "render/job/footagejob.h" +#include "render/job/generatejob.h" #include "render/loopmode.h" #include "render/texture.h" #include "ui/icons/icons.h" @@ -589,6 +591,56 @@ TEST_F(FootageTest, ProxyChangesMarkProjectModifiedAndEmitSignal) 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(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) { QTemporaryDir dir; diff --git a/tests/gtest/proxy_manager_test.cpp b/tests/gtest/proxy_manager_test.cpp index 35b742c2a..12db01940 100644 --- a/tests/gtest/proxy_manager_test.cpp +++ b/tests/gtest/proxy_manager_test.cpp @@ -437,12 +437,76 @@ TEST(ProxyTask, BuildArgumentsDisablesAudioWhenDisabled) 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) { olive::Footage footage; olive::ProxyManager::ProxyParams params; params.width = 640; params.height = 360; + params.divider = 4; params.crf = 30; params.preset = QStringLiteral("faster"); params.extension = QStringLiteral("mov"); @@ -462,6 +526,7 @@ TEST(ProxyManager, FootagePersistsCustomProxyParams) 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("pdivider=\"4\""))); EXPECT_TRUE(xml.contains(QStringLiteral("pcrf=\"30\""))); EXPECT_TRUE(xml.contains(QStringLiteral("ppreset=\"faster\""))); EXPECT_TRUE(xml.contains(QStringLiteral("pext=\"mov\""))); @@ -476,6 +541,7 @@ TEST(ProxyManager, FootagePersistsCustomProxyParams) 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().divider, 4); 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"));