From 8ae8e3a159e01ca755f3032f58447d916bf8ecdf Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 7 Jun 2026 16:16:15 +0800 Subject: [PATCH] Implement proxy media workflow --- app/codec/CMakeLists.txt | 2 + app/codec/proxymanager.cpp | 197 +++++++++ app/codec/proxymanager.h | 124 ++++++ app/core.cpp | 6 + app/node/project/footage/footage.cpp | 109 +++++ app/node/project/footage/footage.h | 55 +++ app/render/job/footagejob.h | 37 ++ app/render/renderprocessor.cpp | 20 +- app/task/CMakeLists.txt | 1 + app/task/proxy/CMakeLists.txt | 9 + app/task/proxy/proxy.cpp | 114 ++++++ app/task/proxy/proxy.h | 48 +++ app/widget/timelinewidget/timelinewidget.cpp | 156 ++++++++ app/widget/timelinewidget/timelinewidget.h | 8 + docs/zh/proxy-media-v04-plan.md | 153 +++++++ ...olor-audio-performance-manual-test-plan.md | 378 ++++++++++++++++++ tests/gtest/CMakeLists.txt | 1 + tests/gtest/proxy_manager_test.cpp | 258 ++++++++++++ 18 files changed, 1671 insertions(+), 5 deletions(-) create mode 100644 app/codec/proxymanager.cpp create mode 100644 app/codec/proxymanager.h create mode 100644 app/task/proxy/CMakeLists.txt create mode 100644 app/task/proxy/proxy.cpp create mode 100644 app/task/proxy/proxy.h create mode 100644 docs/zh/proxy-media-v04-plan.md create mode 100644 docs/zh/v04-color-audio-performance-manual-test-plan.md create mode 100644 tests/gtest/proxy_manager_test.cpp diff --git a/app/codec/CMakeLists.txt b/app/codec/CMakeLists.txt index a76089505..5c577e26c 100644 --- a/app/codec/CMakeLists.txt +++ b/app/codec/CMakeLists.txt @@ -33,6 +33,8 @@ set(OLIVE_SOURCES codec/frame.h codec/planarfiledevice.cpp codec/planarfiledevice.h + codec/proxymanager.cpp + codec/proxymanager.h codec/timecodemetadata.cpp codec/timecodemetadata.h PARENT_SCOPE diff --git a/app/codec/proxymanager.cpp b/app/codec/proxymanager.cpp new file mode 100644 index 000000000..722cf609c --- /dev/null +++ b/app/codec/proxymanager.cpp @@ -0,0 +1,197 @@ +/* + * Oak Video Editor - Non-Linear Video Editor + * Copyright (C) 2026 Oak Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "proxymanager.h" + +#include +#include +#include + +#include "common/filefunctions.h" +#include "task/proxy/proxy.h" +#include "task/taskmanager.h" + +namespace olive +{ + +ProxyManager *ProxyManager::instance_ = nullptr; + +bool ProxyParamsEqual(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; +} + +QString ProxyManager::GetProxyDirectory(const QString &cache_path) +{ + return QDir(cache_path).filePath(QStringLiteral("proxy")); +} + +QString ProxyManager::GetProxyFilename(const QString &cache_path, + const QString &source_filename, + int stream_index, + const ProxyParams ¶ms) +{ + const QString proxy_dir = GetProxyDirectory(cache_path); + const QString extension = + params.extension.isEmpty() ? QStringLiteral("mp4") : params.extension; + const QString filename = QStringLiteral("%1-%2.%3x%4.v%5.%6") + .arg(FileFunctions::GetUniqueFileIdentifier( + source_filename), + QString::number(stream_index), + QString::number(params.width), + QString::number(params.height), + QString::number(params.version), + extension); + + return QDir(proxy_dir).filePath(filename); +} + +QString ProxyManager::GetWorkingProxyFilename(const QString &proxy_filename) +{ + return QStringLiteral("%1.working").arg(proxy_filename); +} + +ProxyManager::ProxyState +ProxyManager::GetProxyState(const QString &proxy_filename) +{ + if (QFileInfo::exists(proxy_filename)) { + return kProxyReady; + } + + if (QFileInfo::exists(GetWorkingProxyFilename(proxy_filename))) { + return kProxyGenerating; + } + + return kProxyMissing; +} + +QString ProxyManager::ProxyStateToString(ProxyState state) +{ + switch (state) { + case kProxyMissing: + return QStringLiteral("missing"); + case kProxyGenerating: + return QStringLiteral("generating"); + case kProxyReady: + return QStringLiteral("ready"); + case kProxyFailed: + return QStringLiteral("failed"); + } + + return QStringLiteral("missing"); +} + +ProxyManager::ProxyState +ProxyManager::ProxyStateFromString(const QString &state) +{ + if (state == QStringLiteral("generating")) { + return kProxyGenerating; + } + + if (state == QStringLiteral("ready")) { + return kProxyReady; + } + + if (state == QStringLiteral("failed")) { + return kProxyFailed; + } + + return kProxyMissing; +} + +ProxyManager::Proxy ProxyManager::GetOrStartProxy( + const QString &cache_path, const QString &source_filename, + int stream_index, const ProxyParams ¶ms) +{ + QMutexLocker locker(&mutex_); + + const QString filename = + GetProxyFilename(cache_path, source_filename, stream_index, params); + const ProxyState file_state = GetProxyState(filename); + if (file_state == kProxyReady) { + return { kProxyReady, filename, nullptr }; + } + + for (const ProxyData &data : proxying_) { + if (data.source_filename == source_filename && + data.stream_index == stream_index && + ProxyParamsEqual(data.params, params)) { + return { kProxyGenerating, filename, data.task }; + } + } + + if (file_state == kProxyGenerating) { + QFile::remove(GetWorkingProxyFilename(filename)); + } + + const QString working_filename = GetWorkingProxyFilename(filename); + ProxyTask *task = new ProxyTask(source_filename, stream_index, params, + working_filename); + connect(task, &Task::Finished, this, + &ProxyManager::ProxyTaskFinished); + task->moveToThread(TaskManager::instance()->thread()); + QMetaObject::invokeMethod(TaskManager::instance(), "AddTask", + Qt::QueuedConnection, Q_ARG(Task *, task)); + + proxying_.append({ source_filename, stream_index, params, task, + working_filename, filename }); + + return { kProxyGenerating, filename, task }; +} + +void ProxyManager::ProxyTaskFinished(Task *task, bool succeeded) +{ + QMutexLocker locker(&mutex_); + + ProxyData data; + bool found = false; + for (int i = 0; i < proxying_.size(); i++) { + const ProxyData &candidate = proxying_.at(i); + if (candidate.task == task) { + data = candidate; + proxying_.removeAt(i); + found = true; + break; + } + } + + if (!found) { + return; + } + + if (succeeded) { + QFile::remove(data.finished_filename); + if (QFile::rename(data.working_filename, data.finished_filename)) { + locker.unlock(); + emit ProxyReady(data.source_filename, data.stream_index, + data.finished_filename); + emit ProxyFinished(data.source_filename, data.stream_index, + data.finished_filename, kProxyReady); + return; + } + } + + QFile::remove(data.working_filename); + locker.unlock(); + emit ProxyFinished(data.source_filename, data.stream_index, + data.finished_filename, kProxyFailed); +} + +} diff --git a/app/codec/proxymanager.h b/app/codec/proxymanager.h new file mode 100644 index 000000000..ee3c740d6 --- /dev/null +++ b/app/codec/proxymanager.h @@ -0,0 +1,124 @@ +/* + * Oak Video Editor - Non-Linear Video Editor + * Copyright (C) 2026 Oak Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef PROXYMANAGER_H +#define PROXYMANAGER_H + +#include +#include +#include +#include + +#include "task/task.h" + +namespace olive +{ + +class ProxyTask; + +class ProxyManager : public QObject { + Q_OBJECT +public: + static void CreateInstance() + { + if (!instance_) { + instance_ = new ProxyManager(); + } + } + + static void DestroyInstance() + { + delete instance_; + instance_ = nullptr; + } + + static ProxyManager *instance() + { + return instance_; + } + + enum ProxyState { + kProxyMissing, + kProxyGenerating, + kProxyReady, + kProxyFailed + }; + + struct ProxyParams { + int width = 1280; + int height = 720; + int version = 1; + QString extension = QStringLiteral("mp4"); + }; + + struct Proxy { + ProxyState state = kProxyMissing; + QString filename; + ProxyTask *task = nullptr; + }; + + static QString GetProxyDirectory(const QString &cache_path); + + static QString GetProxyFilename(const QString &cache_path, + const QString &source_filename, + int stream_index, + const ProxyParams ¶ms); + + static QString GetWorkingProxyFilename(const QString &proxy_filename); + + static ProxyState GetProxyState(const QString &proxy_filename); + + static QString ProxyStateToString(ProxyState state); + + static ProxyState ProxyStateFromString(const QString &state); + + Proxy GetOrStartProxy(const QString &cache_path, + const QString &source_filename, + int stream_index, + const ProxyParams ¶ms); + +signals: + void ProxyReady(const QString &source_filename, int stream_index, + const QString &proxy_filename); + void ProxyFinished(const QString &source_filename, int stream_index, + const QString &proxy_filename, ProxyState state); + +private: + ProxyManager() = default; + + static ProxyManager *instance_; + + struct ProxyData { + QString source_filename; + int stream_index = -1; + ProxyParams params; + ProxyTask *task = nullptr; + QString working_filename; + QString finished_filename; + }; + + QMutex mutex_; + QVector proxying_; + +private slots: + void ProxyTaskFinished(Task *task, bool succeeded); +}; + +} + +#endif // PROXYMANAGER_H diff --git a/app/core.cpp b/app/core.cpp index b48f33cd6..b7638f0ca 100644 --- a/app/core.cpp +++ b/app/core.cpp @@ -41,6 +41,7 @@ #include "audio/audiomanager.h" #include "cli/clitask/clitaskdialog.h" #include "codec/conformmanager.h" +#include "codec/proxymanager.h" #include "common/filefunctions.h" #include "common/xmlutils.h" #include "config/config.h" @@ -239,6 +240,9 @@ void Core::Start() // Initialize ConformManager ConformManager::CreateInstance(); + // Initialize ProxyManager + ProxyManager::CreateInstance(); + // Initialize RenderManager RenderManager::CreateInstance(); @@ -296,6 +300,8 @@ void Core::Stop() ConformManager::DestroyInstance(); + ProxyManager::DestroyInstance(); + FrameManager::DestroyInstance(); RenderManager::DestroyInstance(); diff --git a/app/node/project/footage/footage.cpp b/app/node/project/footage/footage.cpp index 4c508369c..636a3afde 100644 --- a/app/node/project/footage/footage.cpp +++ b/app/node/project/footage/footage.cpp @@ -45,6 +45,10 @@ Footage::Footage(const QString &filename) : ViewerOutput(false, false) , timestamp_(0) , has_source_start_time_(false) + , proxy_enabled_(false) + , proxy_state_(ProxyManager::kProxyMissing) + , proxy_video_stream_index_(-1) + , proxy_preset_version_(0) , valid_(false) , cancelled_(nullptr) , total_stream_count_(0) @@ -138,6 +142,7 @@ void Footage::Clear() has_source_start_time_ = false; source_start_time_ = rational(); source_start_time_source_.clear(); + ClearProxy(); // Clear total stream count total_stream_count_ = 0; @@ -224,6 +229,28 @@ void Footage::SetSourceStartTime(const rational &time, const QString &source) has_source_start_time_ = true; } +void Footage::SetProxy(const QString &path, + ProxyManager::ProxyState state, + int video_stream_index, + int preset_version, + bool enabled) +{ + proxy_path_ = path; + proxy_state_ = state; + proxy_video_stream_index_ = video_stream_index; + proxy_preset_version_ = preset_version; + proxy_enabled_ = enabled; +} + +void Footage::ClearProxy() +{ + proxy_enabled_ = false; + proxy_path_.clear(); + proxy_state_ = ProxyManager::kProxyMissing; + proxy_video_stream_index_ = -1; + proxy_preset_version_ = 0; +} + QString Footage::DescribeVideoStream(const VideoParams ¶ms) { if (params.video_type() == VideoParams::kVideoTypeStill) { @@ -275,6 +302,13 @@ void Footage::Value(const NodeValueRow &value, const NodeGlobals &globals, if (ref.type() == Track::kVideo) { VideoParams vp = GetVideoParams(ref.index()); + if (proxy_enabled_ && !proxy_path_.isEmpty() && + proxy_video_stream_index_ == vp.stream_index() && + ProxyManager::GetProxyState(proxy_path_) == + ProxyManager::kProxyReady) { + job.set_proxy(proxy_path_, QStringLiteral("ffmpeg"), 0); + } + // Ensure the colorspace is valid and not empty vp.set_colorspace(GetColorspaceToUse(vp)); @@ -473,6 +507,32 @@ bool Footage::LoadCustom(QXmlStreamReader *reader, SerializedData *data) while (XMLReadNextStartElement(reader)) { if (reader->name() == QStringLiteral("timestamp")) { this->set_timestamp(reader->readElementText().toLongLong()); + } else if (reader->name() == QStringLiteral("proxy")) { + bool enabled = false; + ProxyManager::ProxyState state = ProxyManager::kProxyMissing; + int stream = -1; + int preset_version = 0; + { + XMLAttributeLoop(reader, attr) + { + if (attr.name() == QStringLiteral("enabled")) { + enabled = (attr.value() == QStringLiteral("1") || + attr.value() == QStringLiteral("true")); + } else if (attr.name() == QStringLiteral("state")) { + state = ProxyManager::ProxyStateFromString( + attr.value().toString()); + } else if (attr.name() == QStringLiteral("stream")) { + stream = attr.value().toInt(); + } else if (attr.name() == QStringLiteral("preset")) { + preset_version = attr.value().toInt(); + } + } + } + + const QString path = reader->readElementText(); + if (!path.isEmpty()) { + SetProxy(path, state, stream, preset_version, enabled); + } } else if (reader->name() == QStringLiteral("sourcestarttime")) { QString source; { @@ -512,6 +572,22 @@ void Footage::SaveCustom(QXmlStreamWriter *writer) const writer->writeTextElement(QStringLiteral("timestamp"), QString::number(this->timestamp())); + if (!proxy_path_.isEmpty()) { + writer->writeStartElement(QStringLiteral("proxy")); + writer->writeAttribute(QStringLiteral("enabled"), + proxy_enabled_ ? QStringLiteral("1") + : QStringLiteral("0")); + writer->writeAttribute(QStringLiteral("state"), + ProxyManager::ProxyStateToString( + proxy_state_)); + writer->writeAttribute(QStringLiteral("stream"), + QString::number(proxy_video_stream_index_)); + writer->writeAttribute(QStringLiteral("preset"), + QString::number(proxy_preset_version_)); + writer->writeCharacters(proxy_path_); + writer->writeEndElement(); + } + if (has_source_start_time_) { writer->writeStartElement(QStringLiteral("sourcestarttime")); writer->writeAttribute(QStringLiteral("source"), @@ -533,12 +609,24 @@ void Footage::AddedToGraphEvent(Project *p) { connect(p->color_manager(), &ColorManager::DefaultInputChanged, this, &Footage::DefaultColorSpaceChanged); + if (ProxyManager::instance()) { + connect(ProxyManager::instance(), &ProxyManager::ProxyReady, this, + &Footage::ProxyReady); + connect(ProxyManager::instance(), &ProxyManager::ProxyFinished, this, + &Footage::ProxyFinished); + } } void Footage::RemovedFromGraphEvent(Project *p) { disconnect(p->color_manager(), &ColorManager::DefaultInputChanged, this, &Footage::DefaultColorSpaceChanged); + if (ProxyManager::instance()) { + disconnect(ProxyManager::instance(), &ProxyManager::ProxyReady, this, + &Footage::ProxyReady); + disconnect(ProxyManager::instance(), &ProxyManager::ProxyFinished, this, + &Footage::ProxyFinished); + } } void Footage::Reprobe() @@ -706,4 +794,25 @@ void Footage::DefaultColorSpaceChanged() } } +void Footage::ProxyReady(const QString &source_filename, int stream_index, + const QString &proxy_filename) +{ + ProxyFinished(source_filename, stream_index, proxy_filename, + ProxyManager::kProxyReady); +} + +void Footage::ProxyFinished(const QString &source_filename, int stream_index, + const QString &proxy_filename, + ProxyManager::ProxyState state) +{ + if (filename() != source_filename || + proxy_video_stream_index_ != stream_index || + proxy_path_ != proxy_filename) { + return; + } + + proxy_state_ = state; + InvalidateAll(kFilenameInput); +} + } diff --git a/app/node/project/footage/footage.h b/app/node/project/footage/footage.h index 13b97b308..8411bf5d4 100644 --- a/app/node/project/footage/footage.h +++ b/app/node/project/footage/footage.h @@ -27,6 +27,7 @@ #include #include "codec/decoder.h" +#include "codec/proxymanager.h" #include "footagedescription.h" #include "node/output/viewer/viewer.h" #include "render/cancelatom.h" @@ -172,6 +173,44 @@ public: void SetSourceStartTime(const rational &time, const QString &source); + bool proxy_enabled() const + { + return proxy_enabled_; + } + + void set_proxy_enabled(bool enabled) + { + proxy_enabled_ = enabled; + } + + const QString &proxy_path() const + { + return proxy_path_; + } + + int proxy_video_stream_index() const + { + return proxy_video_stream_index_; + } + + int proxy_preset_version() const + { + return proxy_preset_version_; + } + + ProxyManager::ProxyState proxy_state() const + { + return proxy_state_; + } + + void SetProxy(const QString &path, + ProxyManager::ProxyState state, + int video_stream_index, + int preset_version, + bool enabled); + + void ClearProxy(); + static QString DescribeVideoStream(const VideoParams ¶ms); static QString DescribeAudioStream(const AudioParams ¶ms); static QString DescribeSubtitleStream(const SubtitleParams ¶ms); @@ -236,6 +275,16 @@ private: bool has_source_start_time_; + bool proxy_enabled_; + + QString proxy_path_; + + ProxyManager::ProxyState proxy_state_; + + int proxy_video_stream_index_; + + int proxy_preset_version_; + bool valid_; CancelAtom *cancelled_; @@ -246,6 +295,12 @@ private slots: void CheckFootage(); void DefaultColorSpaceChanged(); + + void ProxyReady(const QString &source_filename, int stream_index, + const QString &proxy_filename); + void ProxyFinished(const QString &source_filename, int stream_index, + const QString &proxy_filename, + ProxyManager::ProxyState state); }; } diff --git a/app/render/job/footagejob.h b/app/render/job/footagejob.h index f41acfec8..e5e8e386c 100644 --- a/app/render/job/footagejob.h +++ b/app/render/job/footagejob.h @@ -56,6 +56,35 @@ public: return filename_; } + bool has_proxy() const + { + return has_proxy_; + } + + const QString &proxy_filename() const + { + return proxy_filename_; + } + + const QString &proxy_decoder() const + { + return proxy_decoder_; + } + + int proxy_stream_index() const + { + return proxy_stream_index_; + } + + void set_proxy(const QString &filename, const QString &decoder, + int stream_index) + { + proxy_filename_ = filename; + proxy_decoder_ = decoder; + proxy_stream_index_ = stream_index; + has_proxy_ = !filename.isEmpty(); + } + Track::Type type() const { return type_; @@ -122,6 +151,14 @@ private: QString filename_; + bool has_proxy_ = false; + + QString proxy_filename_; + + QString proxy_decoder_; + + int proxy_stream_index_ = -1; + Track::Type type_; VideoParams video_params_; diff --git a/app/render/renderprocessor.cpp b/app/render/renderprocessor.cpp index ea3d5ed03..560d53967 100644 --- a/app/render/renderprocessor.cpp +++ b/app/render/renderprocessor.cpp @@ -21,6 +21,7 @@ #include "renderprocessor.h" +#include #include #include #include @@ -501,10 +502,19 @@ void RenderProcessor::ProcessVideoFootage(TexturePtr destination, return; } - Decoder::CodecStream default_codec_stream( - stream->filename(), stream_data.stream_index(), GetCurrentBlock()); + 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_data.stream_index(); - QString decoder_id = stream->decoder(); + Decoder::CodecStream default_codec_stream( + decode_filename, stream_index, GetCurrentBlock()); DecoderPtr decoder = nullptr; @@ -523,11 +533,11 @@ void RenderProcessor::ProcessVideoFootage(TexturePtr destination, int64_t frame_number = stream_data.get_time_in_timebase_units(input_time); frame_filename = Decoder::TransformImageSequenceFileName( - stream->filename(), frame_number); + decode_filename, frame_number); // Decoder will close automatically since it's a stream_ptr decoder->Open(Decoder::CodecStream( - frame_filename, stream_data.stream_index(), GetCurrentBlock())); + frame_filename, stream_index, GetCurrentBlock())); } break; } diff --git a/app/task/CMakeLists.txt b/app/task/CMakeLists.txt index f56f080b6..b8a98e6dd 100644 --- a/app/task/CMakeLists.txt +++ b/app/task/CMakeLists.txt @@ -19,6 +19,7 @@ add_subdirectory(customcache) add_subdirectory(export) add_subdirectory(precache) add_subdirectory(project) +add_subdirectory(proxy) add_subdirectory(render) set(OLIVE_SOURCES diff --git a/app/task/proxy/CMakeLists.txt b/app/task/proxy/CMakeLists.txt new file mode 100644 index 000000000..4aa1ea49b --- /dev/null +++ b/app/task/proxy/CMakeLists.txt @@ -0,0 +1,9 @@ +# Oak Video Editor - Non-Linear Video Editor +# Copyright (C) 2026 Oak Team + +set(OLIVE_SOURCES + ${OLIVE_SOURCES} + task/proxy/proxy.h + task/proxy/proxy.cpp + PARENT_SCOPE +) diff --git a/app/task/proxy/proxy.cpp b/app/task/proxy/proxy.cpp new file mode 100644 index 000000000..42a1e0b4c --- /dev/null +++ b/app/task/proxy/proxy.cpp @@ -0,0 +1,114 @@ +/* + * Oak Video Editor - Non-Linear Video Editor + * Copyright (C) 2026 Oak Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "proxy.h" + +#include +#include +#include +#include +#include + +namespace olive +{ + +ProxyTask::ProxyTask(const QString &source_filename, + int stream_index, + const ProxyManager::ProxyParams ¶ms, + const QString &output_filename) + : source_filename_(source_filename) + , stream_index_(stream_index) + , params_(params) + , output_filename_(output_filename) +{ + SetTitle(tr("Generating Proxy %1:%2") + .arg(source_filename_, QString::number(stream_index_))); +} + +bool ProxyTask::Run() +{ + const QString ffmpeg = QStandardPaths::findExecutable(QStringLiteral("ffmpeg")); + if (ffmpeg.isEmpty()) { + SetError(tr("Failed to generate proxy: ffmpeg executable was not found")); + return false; + } + + QDir output_dir = QFileInfo(output_filename_).dir(); + if (!output_dir.exists() && !output_dir.mkpath(QStringLiteral("."))) { + SetError(tr("Failed to create proxy output directory")); + return false; + } + + 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)); + + 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") << QStringLiteral("veryfast") + << QStringLiteral("-crf") << QStringLiteral("23") + << QStringLiteral("-pix_fmt") << QStringLiteral("yuv420p") + << QStringLiteral("-movflags") << QStringLiteral("+faststart") + << output_filename_; + + QProcess process; + process.setProgram(ffmpeg); + process.setArguments(args); + process.setProcessChannelMode(QProcess::MergedChannels); + process.start(); + + if (!process.waitForStarted()) { + SetError(tr("Failed to start ffmpeg for proxy generation")); + return false; + } + + while (!process.waitForFinished(100)) { + if (IsCancelled()) { + process.kill(); + process.waitForFinished(); + QFile::remove(output_filename_); + SetError(tr("Proxy generation was cancelled")); + return false; + } + } + + if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) { + const QString output = QString::fromUtf8(process.readAll()).trimmed(); + QFile::remove(output_filename_); + SetError(tr("ffmpeg failed to generate proxy: %1").arg(output)); + return false; + } + + if (!QFileInfo::exists(output_filename_)) { + SetError(tr("ffmpeg finished but proxy file was not created")); + return false; + } + + emit ProgressChanged(1.0); + return true; +} + +} diff --git a/app/task/proxy/proxy.h b/app/task/proxy/proxy.h new file mode 100644 index 000000000..c763b4a80 --- /dev/null +++ b/app/task/proxy/proxy.h @@ -0,0 +1,48 @@ +/* + * Oak Video Editor - Non-Linear Video Editor + * Copyright (C) 2026 Oak Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef PROXYTASK_H +#define PROXYTASK_H + +#include "codec/proxymanager.h" +#include "task/task.h" + +namespace olive +{ + +class ProxyTask : public Task { + Q_OBJECT +public: + ProxyTask(const QString &source_filename, + int stream_index, + const ProxyManager::ProxyParams ¶ms, + const QString &output_filename); + +protected: + virtual bool Run() override; + +private: + QString source_filename_; + int stream_index_; + ProxyManager::ProxyParams params_; + QString output_filename_; +}; + +} + +#endif // PROXYTASK_H diff --git a/app/widget/timelinewidget/timelinewidget.cpp b/app/widget/timelinewidget/timelinewidget.cpp index f6ed3be99..09a13e6c1 100644 --- a/app/widget/timelinewidget/timelinewidget.cpp +++ b/app/widget/timelinewidget/timelinewidget.cpp @@ -24,12 +24,19 @@ #include #include #include +#include +#include +#include +#include +#include #include +#include #include #include #include "audio/audiosynchronizer.h" #include "audio/audiowaveformsync.h" +#include "codec/proxymanager.h" #include "core.h" #include "common/range.h" #include "dialog/sequence/sequence.h" @@ -155,6 +162,26 @@ QVector GetSelectedWaveformSyncClips( return clips; } +QVector GetSelectedProxyFootage(const QVector &blocks) +{ + QVector footage; + for (Block *block : blocks) { + ClipBlock *clip = dynamic_cast(block); + if (!clip) { + continue; + } + + Footage *candidate = dynamic_cast(clip->connected_viewer()); + if (!candidate || !candidate->GetFirstEnabledVideoStream().is_valid() || + footage.contains(candidate)) { + continue; + } + + footage.append(candidate); + } + return footage; +} + QVector ExtractWaveformCacheEnvelope(const WaveformSyncClip &clip, int sample_rate, size_t window_samples) @@ -1152,6 +1179,90 @@ void TimelineWidget::SynchronizeSelectedClipsByWaveform() command, tr("Synchronize Clips by Waveform")); } +void TimelineWidget::GenerateProxiesForSelectedClips() +{ + if (!ProxyManager::instance() || !sequence()) { + return; + } + + const QVector footage = GetSelectedProxyFootage(selected_blocks_); + for (Footage *item : footage) { + const VideoParams video = item->GetFirstEnabledVideoStream(); + if (!video.is_valid()) { + continue; + } + + ProxyManager::ProxyParams params; + const ProxyManager::Proxy proxy = + ProxyManager::instance()->GetOrStartProxy( + item->project()->cache_path(), item->filename(), + video.stream_index(), params); + item->SetProxy(proxy.filename, proxy.state, video.stream_index(), + params.version, true); + item->InvalidateAll(Footage::kFilenameInput); + } +} + +void TimelineWidget::SetSelectedClipsProxyEnabled(bool enabled) +{ + const QVector footage = GetSelectedProxyFootage(selected_blocks_); + for (Footage *item : footage) { + if (item->proxy_path().isEmpty()) { + continue; + } + + item->set_proxy_enabled(enabled); + item->InvalidateAll(Footage::kFilenameInput); + } +} + +void TimelineWidget::RevealProxyForSelectedClips() +{ + const QVector footage = GetSelectedProxyFootage(selected_blocks_); + for (Footage *item : footage) { + if (item->proxy_path().isEmpty()) { + continue; + } + +#if defined(Q_OS_WINDOWS) + QStringList args; + args << "/select," << QDir::toNativeSeparators(item->proxy_path()); + QProcess::startDetached(QStringLiteral("explorer"), args); +#elif defined(Q_OS_MAC) + QStringList args; + args << "-e"; + args << "tell application \"Finder\""; + args << "-e"; + args << "activate"; + args << "-e"; + args << "select POSIX file \"" + item->proxy_path() + "\""; + args << "-e"; + args << "end tell"; + QProcess::startDetached(QStringLiteral("osascript"), args); +#else + QDesktopServices::openUrl(QUrl::fromLocalFile( + QFileInfo(item->proxy_path()).dir().absolutePath())); +#endif + return; + } +} + +void TimelineWidget::DeleteProxiesForSelectedClips() +{ + const QVector footage = GetSelectedProxyFootage(selected_blocks_); + for (Footage *item : footage) { + if (item->proxy_path().isEmpty()) { + continue; + } + + QFile::remove(item->proxy_path()); + QFile::remove(ProxyManager::GetWorkingProxyFilename( + item->proxy_path())); + item->ClearProxy(); + item->InvalidateAll(Footage::kFilenameInput); + } +} + void TimelineWidget::RecordingCallback(const QString &filename, const TimeRange &time, const Track::Reference &track) @@ -1653,6 +1764,51 @@ void TimelineWidget::ShowContextMenu() &TimelineWidget::CacheDiscard); } + { + const QVector proxy_footage = + GetSelectedProxyFootage(selected); + Menu *proxy_menu = new Menu(tr("Proxy"), &menu); + menu.addMenu(proxy_menu); + + QAction *generate_proxy = + proxy_menu->addAction(tr("Generate Proxy")); + generate_proxy->setEnabled(!proxy_footage.isEmpty()); + connect(generate_proxy, &QAction::triggered, this, + &TimelineWidget::GenerateProxiesForSelectedClips); + + QAction *use_proxy = proxy_menu->addAction(tr("Use Proxy")); + use_proxy->setCheckable(true); + use_proxy->setEnabled(!proxy_footage.isEmpty()); + use_proxy->setChecked( + !proxy_footage.isEmpty() && + std::all_of(proxy_footage.cbegin(), proxy_footage.cend(), + [](const Footage *footage) { + return footage->proxy_enabled(); + })); + connect(use_proxy, &QAction::triggered, this, + &TimelineWidget::SetSelectedClipsProxyEnabled); + + QAction *reveal_proxy = + proxy_menu->addAction(tr("Reveal Proxy")); + reveal_proxy->setEnabled(std::any_of( + proxy_footage.cbegin(), proxy_footage.cend(), + [](const Footage *footage) { + return !footage->proxy_path().isEmpty(); + })); + connect(reveal_proxy, &QAction::triggered, this, + &TimelineWidget::RevealProxyForSelectedClips); + + QAction *delete_proxy = + proxy_menu->addAction(tr("Delete Proxy")); + delete_proxy->setEnabled(std::any_of( + proxy_footage.cbegin(), proxy_footage.cend(), + [](const Footage *footage) { + return !footage->proxy_path().isEmpty(); + })); + connect(delete_proxy, &QAction::triggered, this, + &TimelineWidget::DeleteProxiesForSelectedClips); + } + if (clip->connected_viewer()) { QAction *reveal_in_footage_viewer = menu.addAction(tr("Reveal in Footage Viewer")); diff --git a/app/widget/timelinewidget/timelinewidget.h b/app/widget/timelinewidget/timelinewidget.h index 366834834..d842955c1 100644 --- a/app/widget/timelinewidget/timelinewidget.h +++ b/app/widget/timelinewidget/timelinewidget.h @@ -109,6 +109,14 @@ public: void SynchronizeSelectedClipsByWaveform(); + void GenerateProxiesForSelectedClips(); + + void SetSelectedClipsProxyEnabled(bool enabled); + + void RevealProxyForSelectedClips(); + + void DeleteProxiesForSelectedClips(); + void RecordingCallback(const QString &filename, const TimeRange &time, const Track::Reference &track); diff --git a/docs/zh/proxy-media-v04-plan.md b/docs/zh/proxy-media-v04-plan.md new file mode 100644 index 000000000..943e8493c --- /dev/null +++ b/docs/zh/proxy-media-v04-plan.md @@ -0,0 +1,153 @@ +# 代理媒体 v0.4 实施计划 + +## 背景 + +v0.4 已合并“调色、音频与性能”范围,其中代理媒体工作流负责解决 4K/8K 素材在时间线预览、剪辑和调色时的可用性问题。当前代码里已有音频 conform:`ConformManager` 会把音频流转为 PCM cache,但它不适合作为视频代理的直接扩展,因为视频代理需要保留容器、视频编码参数、文件生命周期和解码路由。 + +## 当前状态 + +实施进度: + +- 阶段 1 已完成:已写计划,新增代理状态、稳定文件名函数、`Footage` 代理字段和 XML roundtrip 测试。 +- 阶段 2 已完成:已新增 `ProxyTask` 和 `ProxyManager`,使用 `.working` 临时文件、成功 rename、失败清理,并覆盖状态测试。 +- 阶段 3 已完成:`FootageJob` 携带代理解码信息,预览路径使用 ready 代理,导出/online 路径默认原片,代理缺失自动回退。 +- 阶段 4 已完成:时间线右键已有 `Generate Proxy`、`Use Proxy`、`Reveal Proxy`、`Delete Proxy`。 +- 阶段 5 自动验证已完成;仍需实际 4K/8K 素材做手工播放、重开项目和导出确认。 + +- `app/codec/conformmanager.{h,cpp}` 只处理音频 PCM conform,输出按声道拆分的 `.pcm` 文件。 +- `app/task/conform/conform.{h,cpp}` 只调用 `Decoder::ConformAudio()`。 +- `Decoder::CodecStream` 当前只包含原始 `filename + stream index + block`,解码时会检查该文件存在。 +- `RenderProcessor::ProcessVideoFootage()` 和 `ProcessAudioFootage()` 通过 `FootageJob` 的 filename/decoder/stream index 打开素材。 +- `Footage` 当前保存原始文件名、探测参数、source start time 等项目级元数据,但还没有代理文件状态。 +- timeline 已有 cache/thumbnail/waveform 机制,但这是渲染缓存,不是替代源媒体的代理媒体。 + +## 目标 + +第一阶段交付一个最小但完整的代理工作流: + +- 右键选中项目素材或时间线 clip 可生成代理。 +- 代理文件写入项目 cache/proxy 目录,使用稳定 hash 命名。 +- `Footage` 记录代理状态,项目保存/加载后仍能识别代理。 +- 播放/预览时可选择使用代理,导出默认使用原始素材。 +- 代理缺失、生成中、失败时能安全回退原始素材。 +- 生成任务进入现有 `TaskManager`,支持取消和失败清理。 + +## 非目标 + +- 不在第一版实现复杂代理 preset UI。 +- 不在第一版实现云端/跨机器代理 relink。 +- 不在第一版替代现有 sequence render cache。 +- 不改变音频 conform 的 PCM 路径。 +- 不让导出默认走代理,避免质量风险。 + +## 设计 + +### 1. 新增 ProxyManager + +新增 `app/codec/proxymanager.{h,cpp}`,职责类似但独立于 `ConformManager`: + +- 根据源文件、stream index、代理参数生成目标文件名。 +- 判断代理状态:missing、generating、ready、failed。 +- 避免同一素材重复生成任务。 +- 使用 `.working` 临时文件,成功后原子 rename。 +- 发出 `ProxyReady` 信号通知 UI/缓存失效。 + +### 2. 新增 ProxyTask + +新增 `app/task/proxy/proxy.{h,cpp}`: + +- 输入原始文件、decoder id、视频 stream、代理参数、输出路径。 +- 第一版优先使用 FFmpeg CLI 或内部 FFmpeg 编码路径生成 H.264/MP4 代理。 +- 目标默认参数:较短边不超过 720p,保持宽高比,8-bit 4:2:0,CRF 23 左右。 +- 失败时写清晰 error,删除 `.working`。 + +如果当前构建环境不适合直接调用外部 `ffmpeg`,则优先使用项目内部编码接口;否则在任务内检测 `ffmpeg` 可执行文件并给出失败信息。 + +### 3. 扩展 Footage 代理元数据 + +在 `Footage` 中增加: + +- `proxy_enabled` +- `proxy_path` +- `proxy_state` +- `proxy_video_stream_index` +- `proxy_generation_preset/version` + +项目 XML 写入 `path`。 + +`Clear()` 不能无条件清掉已保存代理路径,只有换源文件或重新探测时才重置不兼容代理。 + +### 4. 解码路由 + +提供统一方法选择实际解码源: + +- 在线预览/时间线播放:如果项目/素材启用代理且代理 ready,则使用代理文件。 +- 离线渲染/导出:默认使用原文件。 +- 用户以后可加“导出使用代理”选项,但默认关闭。 + +优先在 `FootageJob` 构造或 `RenderProcessor::ProcessVideoFootage()` 前完成选择,避免把代理逻辑散落到 decoder 内部。 + +### 5. UI 入口 + +第一版入口: + +- 时间线 clip 右键:`Generate Proxy`、`Use Proxy`、`Reveal Proxy`、`Delete Proxy`。 +- 项目素材右键若已有菜单结构可复用,也增加同样入口;如果项目素材菜单结构分散,先实现时间线入口。 +- 菜单启用规则:仅视频素材可生成代理;代理生成中禁用重复生成;代理缺失时 `Use Proxy` 可显示但禁用。 + +### 6. 缓存和失效 + +代理 ready 后: + +- 触发相关 footage/clip 的 video frame cache、thumbnail cache invalidation。 +- 不触碰 audio conform cache。 +- 不删除已有原始媒体 render cache,避免用户切换代理/原片时状态不可恢复。 + +## 实施阶段 + +### 阶段 1:计划和基础数据结构 + +- 写本计划。 +- 添加 proxy 状态枚举和 filename 生成函数。 +- 添加 `Footage` 代理字段和 XML 保存/加载测试。 + +### 阶段 2:代理生成任务 + +- 添加 `ProxyTask`。 +- 添加 `ProxyManager`。 +- 生成 `.working` 文件,成功 rename。 +- 增加单元测试覆盖文件名稳定性和状态转换。 + +### 阶段 3:解码路由 + +- 扩展 `FootageJob` 或其创建点,携带“实际解码文件”。 +- 在线模式优先代理,离线/导出默认原片。 +- 代理缺失自动回退原片。 + +### 阶段 4:时间线 UI + +- 时间线右键增加生成/启用/删除代理动作。 +- 动作进入 undo 或直接项目状态变更;代理生成任务本身不进 undo。 +- 代理 ready 后刷新 timeline/viewer。 + +### 阶段 5:验证 + +- `ninja -C cmake-build-debug olive-gtest olive-editor -j22` +- 代理字段 XML roundtrip 测试。 +- ProxyManager 状态测试。 +- 手工测试:导入 4K 素材、生成代理、启用代理、播放、关闭重开项目、删除代理、导出确认默认原片。 + +## 风险 + +- 外部 `ffmpeg` 依赖不可用会让代理生成失败;需要清晰错误并不影响原片播放。 +- 代理视频 stream index 可能不同于原片,需要解码路由在代理文件里使用正确 stream。 +- 代理分辨率改变会影响 thumbnails/cache,需要切换后明确 invalidation。 +- 项目保存相对/绝对代理路径策略要谨慎;第一版使用 cache 目录内路径并可重建。 + +## 完成标准 + +- 用户能从时间线对视频 clip 生成代理。 +- 代理生成结束后,启用代理的在线预览路径实际读取代理文件。 +- 项目重开后代理状态保留。 +- 代理缺失或生成失败时不影响原始素材播放。 +- 相关构建和 gtest 通过。 diff --git a/docs/zh/v04-color-audio-performance-manual-test-plan.md b/docs/zh/v04-color-audio-performance-manual-test-plan.md new file mode 100644 index 000000000..b567fe483 --- /dev/null +++ b/docs/zh/v04-color-audio-performance-manual-test-plan.md @@ -0,0 +1,378 @@ +# v0.4 调色、音频与性能手工测试计划 + +本文档覆盖路线图里已合并到 v0.4 的原 0.4-0.6 范围:调色/LUT/示波器、音频同步/音频表、代理媒体、硬件加速导出、批量渲染队列。目标是用真实项目验证“可用、可保存、可重开、可导出、失败可回退”。 + +## 测试目标 + +- 验证常见创作流程从导入到导出不会崩溃或丢失项目状态。 +- 验证调色、音频同步、代理媒体之间可以组合使用。 +- 验证导出默认保留原片质量路径,不被代理媒体意外降级。 +- 验证硬件依赖缺失时有安全回退或明确失败信息。 +- 验证项目关闭重开后,LUT、调色、同步、代理和导出队列状态符合预期。 + +## 测试环境矩阵 + +至少覆盖以下环境中的两个;发布前尽量覆盖全部: + +| 平台 | 必测项 | 备注 | +|:--|:--|:--| +| Linux + Mesa/AMD 或 Intel | 软件导出、代理、示波器、音频同步 | 当前开发主环境优先 | +| Linux + NVIDIA | NVENC、代理、4K/8K 预览 | 需要 NVIDIA 驱动和 ffmpeg 编码器支持 | +| macOS Apple Silicon | VideoToolbox、ColorSync/显示路径、代理 | 重点看硬件导出和 UI 响应 | +| Windows + NVIDIA/Intel | NVENC/QSV 可用性、路径编码、文件管理器 reveal | 重点看中文路径和空格路径 | + +## 测试素材准备 + +准备一个专用测试目录,路径同时覆盖英文和中文,例如:`Oak v04 测试/素材 A`。 + +必备素材: + +- `4k_camera_a.mov`:4K H.264/H.265,包含音频,时长 30 秒以上。 +- `8k_or_heavy_camera.mov`:8K 或高码率 4K,用于代理压力测试。 +- `dual_system_video.mov`:机内参考音频,画面有明显拍板或口型。 +- `dual_system_audio.wav`:外录 WAV/BWF,含相同拍板或口型音频。 +- `bwf_timecode.wav`:带 BWF 时间码元数据的 WAV。 +- `noisy_dialogue.wav`:对白或音乐,用于 LUFS/VU 观察。 +- `color_chart.mov`:含色卡、肤色、饱和色块和灰阶。 +- `lut_valid.cube`、`lut_valid.3dl`:可明显改变画面的 LUT。 +- `lut_invalid.cube`:故意损坏的 LUT,用于错误处理。 +- `mixed_media_project`:至少 10 个 clip、不同分辨率、不同帧率、不同采样率。 + +## 通用通过标准 + +每个用例都按以下标准判断: + +- 不崩溃,不出现 UI 死锁或无法取消的后台任务。 +- 操作结果可在 viewer/timeline 中观察到。 +- 保存项目、关闭应用、重新打开后,关键状态仍存在。 +- 导出文件可播放,音画不同步不超过 1 帧,除非用例预期制造偏移。 +- 错误输入不会污染项目状态;失败后原始媒体仍能播放。 + +## 0. 预检 + +1. 使用干净构建运行应用。 +2. 打开新项目,设置项目 cache 到专用测试目录。 +3. 导入所有测试素材。 +4. 保存项目为 `v04-manual-test.oak`。 +5. 关闭并重开项目,确认所有素材仍在线。 + +通过标准:项目重开后无素材丢失,无启动崩溃,cache 目录可写。 + +## 1. LUT 与调色测试 + +### 1.1 `.cube` LUT 导入和应用 + +1. 将 `color_chart.mov` 放入时间线。 +2. 添加或打开 LUT/调色相关效果入口。 +3. 加载 `lut_valid.cube`。 +4. 播放 5 秒并拖动时间线。 +5. 保存、关闭、重开项目。 + +通过标准:画面颜色明显变化;拖动时间线无明显卡死;重开后 LUT 仍生效。 + +### 1.2 `.3dl` LUT 导入和应用 + +1. 复制 1.1 流程,但加载 `lut_valid.3dl`。 +2. 对比启用/禁用效果的画面差异。 + +通过标准:`.3dl` 生效;启用/禁用状态即时反映;无崩溃。 + +### 1.3 损坏 LUT 处理 + +1. 在同一 clip 上尝试加载 `lut_invalid.cube`。 +2. 观察 UI 错误提示或效果状态。 +3. 继续播放原 clip。 +4. 保存并重开项目。 + +通过标准:损坏 LUT 不导致崩溃;原 clip 仍可播放;项目重开后不会卡在损坏状态。 + +### 1.4 三向色轮基础调整 + +1. 对 `color_chart.mov` 添加三向调色或打开三向色轮面板。 +2. 分别调整 Shadows、Midtones、Highlights。 +3. 调整强度到明显但不过曝。 +4. 播放、暂停、逐帧查看。 +5. 保存、重开项目。 + +通过标准:三个区域调整有可见差异;参数重开后保留;撤销/重做至少能回到前一状态。 + +### 1.5 调色与代理组合 + +1. 对重素材生成代理并启用代理。 +2. 在启用代理状态下应用 LUT 和三向调色。 +3. 关闭代理再观察同一帧。 +4. 导出 5 秒测试片段。 + +通过标准:代理和原片路径颜色处理一致;导出默认使用原片质量路径,不因代理分辨率降低。 + +## 2. 示波器测试 + +### 2.1 Waveform + +1. 打开 Scope 面板并选择 Waveform。 +2. 播放 `color_chart.mov`。 +3. 调整曝光/亮度相关参数。 +4. 观察示波器变化。 + +通过标准:Waveform 随当前 viewer 帧和调色变化更新;无明显延迟堆积。 + +### 2.2 Vectorscope + +1. 切换到 Vectorscope。 +2. 使用含肤色和饱和色块的帧。 +3. 调整饱和度或三向色轮。 + +通过标准:Vectorscope 分布随饱和度和色相变化;切换面板不崩溃。 + +### 2.3 Histogram + +1. 切换到 Histogram。 +2. 对灰阶或高反差画面调整亮度/对比度。 +3. 播放和暂停不同帧。 + +通过标准:Histogram 更新正确;不会显示上一帧的长期残留。 + +### 2.4 Scope 面板压力 + +1. 连续在 Waveform、Vectorscope、Histogram 间切换 20 次。 +2. 同时拖动时间线和调整调色参数。 + +通过标准:无 OpenGL/shader 崩溃;UI 不失去响应。 + +## 3. 波形自动同步测试 + +### 3.1 双系统音频自动同步 + +1. 将 `dual_system_video.mov` 和 `dual_system_audio.wav` 放入同一时间线。 +2. 故意将外录音频偏移 1-3 秒。 +3. 选择两个 clip,执行“按波形同步”。 +4. 播放拍板或口型片段。 + +通过标准:同步后拍板峰值对齐;口型和声音误差不超过 1 帧;无误删 clip。 + +### 3.2 多 clip 同步 + +1. 放入 3 个视频 clip 和 3 条外录音频。 +2. 每条音频设置不同初始偏移。 +3. 一次选择所有相关 clip 执行波形同步。 + +通过标准:每组素材被合理对齐;无法匹配的素材保持原位或给出明确失败,不影响其他素材。 + +### 3.3 低质量参考音频 + +1. 使用噪声较大或音量较低的参考音频。 +2. 执行波形同步。 +3. 比较同步结果。 + +通过标准:同步失败时不能产生明显错误对齐且无提示;若无法匹配,应安全保留原位置或提示失败。 + +### 3.4 同步结果持久化 + +1. 完成同步后保存项目。 +2. 关闭并重开。 +3. 播放同步点。 + +通过标准:clip 位置保持;同步结果不随重开漂移。 + +## 4. BWF 时间码同步测试 + +### 4.1 读取 BWF 时间码 + +1. 导入 `bwf_timecode.wav`。 +2. 查看素材属性或时间码相关入口。 +3. 将其放入时间线并执行按时间码同步。 + +通过标准:能识别 BWF 起始时间码;同步操作不会把音频放到错误日期/极端时间位置。 + +### 4.2 BWF 与视频时间码对齐 + +1. 导入带匹配时间码的视频和 BWF 音频。 +2. 执行按源时间码同步。 +3. 播放同步点。 + +通过标准:音画对齐;时间线位置符合时间码差值。 + +### 4.3 缺失时间码回退 + +1. 选择没有 BWF 时间码的普通 WAV。 +2. 执行按时间码同步。 + +通过标准:给出明确不可同步或安全跳过;不产生极端偏移。 + +## 5. 音频表测试 + +### 5.1 VU 表基础响应 + +1. 将 `noisy_dialogue.wav` 放入时间线。 +2. 打开音频表面板。 +3. 播放静音、对白、音乐段落。 + +通过标准:VU 表随音量实时变化;静音时回落;播放停止后状态合理。 + +### 5.2 LUFS 读数 + +1. 播放 30 秒对白或音乐。 +2. 观察 LUFS 短时/综合读数入口。 +3. 调整音量增益后重复播放。 + +通过标准:增益变化会反映到 LUFS;读数不会出现 NaN、无限大或明显跳变。 + +### 5.3 多声道音频 + +1. 导入立体声和多声道素材。 +2. 播放并观察左右声道/总线表。 +3. 静音或降低其中一个 clip 音量。 + +通过标准:声道显示符合素材声道;静音/增益调整即时反映。 + +## 6. 代理媒体测试 + +### 6.1 生成代理 + +1. 将 `8k_or_heavy_camera.mov` 放入时间线。 +2. 右键 clip,执行 `Proxy > Generate Proxy`。 +3. 观察任务列表和 cache/proxy 目录。 +4. 等待任务完成。 + +通过标准:生成 `.working` 文件后最终变成 `.mp4`;任务完成后 `.working` 被清理;UI 不阻塞。 + +### 6.2 启用/禁用代理 + +1. 生成代理后勾选 `Proxy > Use Proxy`。 +2. 播放时间线并观察流畅度。 +3. 取消 `Use Proxy` 再播放。 + +通过标准:启用代理后预览可用;禁用后回到原片;缺失代理时安全回退原片。 + +### 6.3 Reveal 和 Delete Proxy + +1. 执行 `Proxy > Reveal Proxy`。 +2. 确认打开代理所在目录。 +3. 执行 `Proxy > Delete Proxy`。 +4. 再次播放。 + +通过标准:Reveal 指向 cache/proxy;Delete 删除代理文件和 working 文件;播放回退原片。 + +### 6.4 项目重开 + +1. 生成并启用代理后保存项目。 +2. 关闭并重开。 +3. 检查 `Use Proxy` 状态并播放。 + +通过标准:代理路径和启用状态保留;代理文件存在时继续可用。 + +### 6.5 代理失败路径 + +1. 临时移除或隐藏系统 `ffmpeg`,或使用不可读源文件。 +2. 执行 Generate Proxy。 +3. 观察任务失败后的项目状态。 + +通过标准:失败信息明确;`.working` 被清理;原始素材仍能播放;不会反复占用同一任务。 + +### 6.6 导出默认原片 + +1. 对 4K/8K clip 生成 720p 代理并启用。 +2. 导出 4K 片段。 +3. 检查导出分辨率和画质。 + +通过标准:导出不使用 720p 代理作为源;分辨率和细节符合原片路径。 + +## 7. 硬件加速导出测试 + +### 7.1 NVENC + +1. 在 NVIDIA 环境打开导出设置。 +2. 选择 H.264/H.265 NVENC 编码器。 +3. 导出 30 秒 4K 片段。 +4. 使用播放器或 ffprobe 检查输出。 + +通过标准:导出成功;输出编码格式正确;硬件编码不可用时有清晰错误或自动回退选项。 + +### 7.2 VideoToolbox + +1. 在 macOS 打开导出设置。 +2. 选择 VideoToolbox H.264/H.265。 +3. 导出 30 秒 4K 片段。 + +通过标准:导出成功;系统负载符合硬件编码预期;输出可播放。 + +### 7.3 硬件导出失败回退 + +1. 选择当前机器不支持的硬件编码器。 +2. 尝试导出。 + +通过标准:失败可理解,不生成损坏的完成文件;用户能改用软件编码继续导出。 + +## 8. 批量渲染队列测试 + +### 8.1 多任务队列 + +1. 创建 3 个 sequence:短片、含 LUT 片段、含代理片段。 +2. 分别加入批量渲染队列。 +3. 开始队列。 + +通过标准:任务按队列执行;每个输出文件独立生成;一个任务失败不应让整个应用崩溃。 + +### 8.2 队列取消 + +1. 加入一个较长导出任务。 +2. 开始后立即取消。 +3. 再加入短任务并执行。 + +通过标准:取消不会留下锁死状态;后续任务可继续运行;半成品文件有明确状态。 + +### 8.3 队列与项目保存 + +1. 配置多个队列任务。 +2. 保存项目并重开。 +3. 检查队列是否按当前设计保存或清空。 + +通过标准:行为必须明确且一致;如果队列不持久化,重开后应为空而不是半损坏状态。 + +## 9. 组合回归测试 + +### 9.1 完整剪辑链路 + +1. 创建 60 秒 sequence。 +2. 混合 4K/8K、外录音频、LUT、三向调色、代理媒体。 +3. 对部分 clip 做波形同步。 +4. 打开 Scope 和音频表播放全片。 +5. 导出软件编码版本。 +6. 如果环境支持,再导出硬件编码版本。 + +通过标准:全流程无崩溃;导出文件音画同步;颜色和音量符合预览。 + +### 9.2 中文路径和空格路径 + +1. 将项目、素材、cache、导出目标放在包含中文和空格的路径。 +2. 重复代理生成、LUT 加载、导出。 + +通过标准:路径处理正常;Reveal Proxy 和导出文件路径可打开。 + +### 9.3 长时间稳定性 + +1. 打开 4K/8K 项目循环播放 20 分钟。 +2. 期间切换代理、Scope、音频表。 +3. 观察内存和 UI 响应。 + +通过标准:内存没有持续不可控增长;播放停止后应用仍可操作和保存。 + +## 缺陷记录模板 + +每个失败项记录以下信息: + +- 平台、GPU、驱动版本、FFmpeg 版本。 +- Oak commit hash。 +- 项目文件路径和素材类型。 +- 复现步骤,精确到菜单项和参数。 +- 预期结果和实际结果。 +- 是否可稳定复现。 +- 如果涉及导出,附 ffprobe 输出和导出设置截图。 + +## 发布前最低通过线 + +- 预检、LUT、三向色轮、三类 Scope、波形同步、BWF 时间码、音频表、代理生成/启用/删除、软件导出全部通过。 +- 至少一个硬件编码环境通过 NVENC 或 VideoToolbox。 +- 批量队列至少通过多任务执行和取消测试。 +- 组合回归测试中的完整剪辑链路通过。 +- 所有失败项有明确 issue 或文档化限制,不存在“无提示崩溃”级别问题。 diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 2f79a09ac..9648e6813 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -18,6 +18,7 @@ add_executable(olive-gtest render_pixelformat_test.cpp render_ipc_test.cpp project_serializer_test.cpp + proxy_manager_test.cpp timeline_marker_test.cpp undo_stack_test.cpp plugin_support_test.cpp diff --git a/tests/gtest/proxy_manager_test.cpp b/tests/gtest/proxy_manager_test.cpp new file mode 100644 index 000000000..29e30f43c --- /dev/null +++ b/tests/gtest/proxy_manager_test.cpp @@ -0,0 +1,258 @@ +#include + +#include +#include +#include +#include +#include + +#include "codec/proxymanager.h" +#include "node/project/footage/footage.h" +#include "render/job/footagejob.h" + +TEST(ProxyManager, BuildsStableProxyFilename) +{ + olive::ProxyManager::ProxyParams params; + params.width = 1280; + params.height = 720; + params.version = 1; + + const QString first = olive::ProxyManager::GetProxyFilename( + QStringLiteral("/tmp/oak-cache"), + QStringLiteral("/media/source.mov"), 0, params); + const QString second = olive::ProxyManager::GetProxyFilename( + QStringLiteral("/tmp/oak-cache"), + QStringLiteral("/media/source.mov"), 0, params); + const QString other_stream = olive::ProxyManager::GetProxyFilename( + QStringLiteral("/tmp/oak-cache"), + QStringLiteral("/media/source.mov"), 1, params); + + EXPECT_EQ(first, second); + EXPECT_NE(first, other_stream); + EXPECT_TRUE(first.contains(QStringLiteral("/proxy/"))); + EXPECT_TRUE(first.endsWith(QStringLiteral(".mp4"))); +} + +TEST(ProxyManager, ProxyFilenameIncludesPresetParameters) +{ + olive::ProxyManager::ProxyParams mp4_720p; + mp4_720p.width = 1280; + mp4_720p.height = 720; + mp4_720p.version = 1; + mp4_720p.extension = QStringLiteral("mp4"); + + olive::ProxyManager::ProxyParams mov_540p = mp4_720p; + mov_540p.width = 960; + mov_540p.height = 540; + mov_540p.version = 2; + mov_540p.extension = QStringLiteral("mov"); + + const QString first = olive::ProxyManager::GetProxyFilename( + QStringLiteral("/tmp/oak-cache"), + QStringLiteral("/media/source.mov"), 0, mp4_720p); + const QString second = olive::ProxyManager::GetProxyFilename( + QStringLiteral("/tmp/oak-cache"), + QStringLiteral("/media/source.mov"), 0, mov_540p); + + EXPECT_NE(first, second); + EXPECT_TRUE(first.contains(QStringLiteral(".1280x720.v1."))); + EXPECT_TRUE(second.contains(QStringLiteral(".960x540.v2."))); + EXPECT_TRUE(second.endsWith(QStringLiteral(".mov"))); +} + +TEST(ProxyManager, DetectsProxyState) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QString proxy = + QDir(dir.path()).filePath(QStringLiteral("proxy-file.mp4")); + EXPECT_EQ(olive::ProxyManager::GetProxyState(proxy), + olive::ProxyManager::kProxyMissing); + + QFile working(olive::ProxyManager::GetWorkingProxyFilename(proxy)); + ASSERT_TRUE(working.open(QFile::WriteOnly)); + working.close(); + EXPECT_EQ(olive::ProxyManager::GetProxyState(proxy), + olive::ProxyManager::kProxyGenerating); + + QFile ready(proxy); + ASSERT_TRUE(ready.open(QFile::WriteOnly)); + ready.close(); + EXPECT_EQ(olive::ProxyManager::GetProxyState(proxy), + olive::ProxyManager::kProxyReady); +} + +TEST(ProxyManager, ReadyStateTakesPrecedenceOverWorkingFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QString proxy = + QDir(dir.path()).filePath(QStringLiteral("proxy-file.mp4")); + QFile ready(proxy); + ASSERT_TRUE(ready.open(QFile::WriteOnly)); + ready.close(); + + QFile working(olive::ProxyManager::GetWorkingProxyFilename(proxy)); + ASSERT_TRUE(working.open(QFile::WriteOnly)); + working.close(); + + EXPECT_EQ(olive::ProxyManager::GetProxyState(proxy), + olive::ProxyManager::kProxyReady); +} + +TEST(ProxyManager, ConvertsProxyStateToAndFromStrings) +{ + EXPECT_EQ(olive::ProxyManager::ProxyStateToString( + olive::ProxyManager::kProxyMissing), + QStringLiteral("missing")); + EXPECT_EQ(olive::ProxyManager::ProxyStateToString( + olive::ProxyManager::kProxyGenerating), + QStringLiteral("generating")); + EXPECT_EQ(olive::ProxyManager::ProxyStateToString( + olive::ProxyManager::kProxyReady), + QStringLiteral("ready")); + EXPECT_EQ(olive::ProxyManager::ProxyStateToString( + olive::ProxyManager::kProxyFailed), + QStringLiteral("failed")); + + EXPECT_EQ(olive::ProxyManager::ProxyStateFromString( + QStringLiteral("missing")), + olive::ProxyManager::kProxyMissing); + EXPECT_EQ(olive::ProxyManager::ProxyStateFromString( + QStringLiteral("generating")), + olive::ProxyManager::kProxyGenerating); + EXPECT_EQ(olive::ProxyManager::ProxyStateFromString( + QStringLiteral("ready")), + olive::ProxyManager::kProxyReady); + EXPECT_EQ(olive::ProxyManager::ProxyStateFromString( + QStringLiteral("failed")), + olive::ProxyManager::kProxyFailed); + EXPECT_EQ(olive::ProxyManager::ProxyStateFromString( + QStringLiteral("unknown")), + olive::ProxyManager::kProxyMissing); +} + +TEST(ProxyManager, FootagePersistsProxyMetadata) +{ + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("custom")); + writer.writeTextElement(QStringLiteral("timestamp"), QStringLiteral("0")); + writer.writeStartElement(QStringLiteral("proxy")); + writer.writeAttribute(QStringLiteral("enabled"), QStringLiteral("1")); + writer.writeAttribute(QStringLiteral("state"), QStringLiteral("ready")); + writer.writeAttribute(QStringLiteral("stream"), QStringLiteral("0")); + writer.writeAttribute(QStringLiteral("preset"), QStringLiteral("1")); + writer.writeCharacters(QStringLiteral("/cache/proxy/example.mp4")); + writer.writeEndElement(); + writer.writeEndElement(); + writer.writeEndDocument(); + + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("custom")); + + olive::Footage footage; + ASSERT_TRUE(footage.LoadCustom(&reader, nullptr)); + EXPECT_TRUE(footage.proxy_enabled()); + EXPECT_EQ(footage.proxy_path(), QStringLiteral("/cache/proxy/example.mp4")); + EXPECT_EQ(footage.proxy_state(), olive::ProxyManager::kProxyReady); + EXPECT_EQ(footage.proxy_video_stream_index(), 0); + EXPECT_EQ(footage.proxy_preset_version(), 1); +} + +TEST(ProxyManager, FootageSavesProxyMetadata) +{ + olive::Footage footage; + footage.set_timestamp(42); + footage.SetProxy(QStringLiteral("/cache/proxy/example.mp4"), + olive::ProxyManager::kProxyReady, 2, 3, true); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("custom")); + footage.SaveCustom(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + EXPECT_TRUE(xml.contains(QStringLiteral("ProxyFinished( + QStringLiteral("/media/source.mov"), 0, + QStringLiteral("/cache/proxy/example.mp4"), + olive::ProxyManager::kProxyFailed); + + EXPECT_TRUE(received); + EXPECT_EQ(received_source, QStringLiteral("/media/source.mov")); + EXPECT_EQ(received_stream, 0); + EXPECT_EQ(received_proxy, QStringLiteral("/cache/proxy/example.mp4")); + EXPECT_EQ(received_state, olive::ProxyManager::kProxyFailed); + + olive::ProxyManager::DestroyInstance(); +} + +TEST(ProxyManager, FootageJobCarriesProxyMetadata) +{ + olive::FootageJob job(olive::TimeRange(), QStringLiteral("source-decoder"), + QStringLiteral("/media/source.mov"), + olive::Track::kVideo, olive::rational(10), + olive::LoopMode::kLoopModeOff); + EXPECT_FALSE(job.has_proxy()); + + job.set_proxy(QStringLiteral("/cache/proxy/source.mp4"), + QStringLiteral("ffmpeg"), 0); + + EXPECT_TRUE(job.has_proxy()); + EXPECT_EQ(job.filename(), QStringLiteral("/media/source.mov")); + EXPECT_EQ(job.proxy_filename(), QStringLiteral("/cache/proxy/source.mp4")); + EXPECT_EQ(job.proxy_decoder(), QStringLiteral("ffmpeg")); + EXPECT_EQ(job.proxy_stream_index(), 0); +}