Implement proxy media workflow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "proxymanager.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMutexLocker>
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#ifndef PROXYMANAGER_H
|
||||
#define PROXYMANAGER_H
|
||||
|
||||
#include <QMutex>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
#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<ProxyData> proxying_;
|
||||
|
||||
private slots:
|
||||
void ProxyTaskFinished(Task *task, bool succeeded);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
#endif // PROXYMANAGER_H
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <QDateTime>
|
||||
|
||||
#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);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include "renderprocessor.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QOpenGLContext>
|
||||
#include <QVector2D>
|
||||
#include <QVector3D>
|
||||
@@ -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<RenderMode::Mode>(ticket_->property("mode").toInt()) ==
|
||||
RenderMode::kOffline &&
|
||||
stream->has_proxy() && QFileInfo::exists(stream->proxy_filename());
|
||||
const QString decode_filename =
|
||||
use_proxy ? stream->proxy_filename() : stream->filename();
|
||||
const QString decoder_id =
|
||||
use_proxy ? stream->proxy_decoder() : stream->decoder();
|
||||
const int stream_index =
|
||||
use_proxy ? stream->proxy_stream_index() : stream_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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "proxy.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QProcess>
|
||||
#include <QStandardPaths>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#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
|
||||
@@ -24,12 +24,19 @@
|
||||
#include <algorithm>
|
||||
#include <cfloat>
|
||||
#include <cmath>
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QProcess>
|
||||
#include <QSplitter>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtMath>
|
||||
|
||||
#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<WaveformSyncClip> GetSelectedWaveformSyncClips(
|
||||
return clips;
|
||||
}
|
||||
|
||||
QVector<Footage *> GetSelectedProxyFootage(const QVector<Block *> &blocks)
|
||||
{
|
||||
QVector<Footage *> footage;
|
||||
for (Block *block : blocks) {
|
||||
ClipBlock *clip = dynamic_cast<ClipBlock *>(block);
|
||||
if (!clip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Footage *candidate = dynamic_cast<Footage *>(clip->connected_viewer());
|
||||
if (!candidate || !candidate->GetFirstEnabledVideoStream().is_valid() ||
|
||||
footage.contains(candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
footage.append(candidate);
|
||||
}
|
||||
return footage;
|
||||
}
|
||||
|
||||
QVector<double> 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 *> 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 *> 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 *> 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 *> 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<Footage *> 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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 写入 `<proxy enabled="..." state="..." stream="..." preset="...">path</proxy>`。
|
||||
|
||||
`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 通过。
|
||||
@@ -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 或文档化限制,不存在“无提示崩溃”级别问题。
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
#include <QXmlStreamReader>
|
||||
#include <QXmlStreamWriter>
|
||||
|
||||
#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("<proxy")));
|
||||
EXPECT_TRUE(xml.contains(QStringLiteral("enabled=\"1\"")));
|
||||
EXPECT_TRUE(xml.contains(QStringLiteral("state=\"ready\"")));
|
||||
EXPECT_TRUE(xml.contains(QStringLiteral("stream=\"2\"")));
|
||||
EXPECT_TRUE(xml.contains(QStringLiteral("preset=\"3\"")));
|
||||
EXPECT_TRUE(xml.contains(QStringLiteral("/cache/proxy/example.mp4")));
|
||||
}
|
||||
|
||||
TEST(ProxyManager, FootageClearRemovesProxyMetadata)
|
||||
{
|
||||
olive::Footage footage;
|
||||
footage.SetProxy(QStringLiteral("/cache/proxy/example.mp4"),
|
||||
olive::ProxyManager::kProxyReady, 0, 1, true);
|
||||
|
||||
footage.Clear();
|
||||
|
||||
EXPECT_FALSE(footage.proxy_enabled());
|
||||
EXPECT_TRUE(footage.proxy_path().isEmpty());
|
||||
EXPECT_EQ(footage.proxy_state(), olive::ProxyManager::kProxyMissing);
|
||||
EXPECT_EQ(footage.proxy_video_stream_index(), -1);
|
||||
EXPECT_EQ(footage.proxy_preset_version(), 0);
|
||||
}
|
||||
|
||||
TEST(ProxyManager, EmitsProxyFinishedState)
|
||||
{
|
||||
olive::ProxyManager::CreateInstance();
|
||||
|
||||
bool received = false;
|
||||
QString received_source;
|
||||
int received_stream = -1;
|
||||
QString received_proxy;
|
||||
olive::ProxyManager::ProxyState received_state =
|
||||
olive::ProxyManager::kProxyMissing;
|
||||
QObject::connect(
|
||||
olive::ProxyManager::instance(), &olive::ProxyManager::ProxyFinished,
|
||||
[&received, &received_source, &received_stream, &received_proxy,
|
||||
&received_state](const QString &source_filename, int stream_index,
|
||||
const QString &proxy_filename,
|
||||
olive::ProxyManager::ProxyState state) {
|
||||
received = true;
|
||||
received_source = source_filename;
|
||||
received_stream = stream_index;
|
||||
received_proxy = proxy_filename;
|
||||
received_state = state;
|
||||
});
|
||||
|
||||
emit olive::ProxyManager::instance()->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);
|
||||
}
|
||||
Reference in New Issue
Block a user