diff --git a/app/config/config.cpp b/app/config/config.cpp index d9ac4d016..988395101 100644 --- a/app/config/config.cpp +++ b/app/config/config.cpp @@ -144,6 +144,8 @@ void Config::SetDefaults() SetEntryInternal(QStringLiteral("PreviewNonFloatDontAskAgain"), NodeValue::kBoolean, false); SetEntryInternal(QStringLiteral("UseGLFinish"), NodeValue::kBoolean, false); + SetEntryInternal(QStringLiteral("RenderProcessIsolationEnabled"), + NodeValue::kBoolean, false); SetEntryInternal(QStringLiteral("TimelineThumbnailMode"), NodeValue::kInt, Timeline::kThumbnailInOut); diff --git a/app/render/CMakeLists.txt b/app/render/CMakeLists.txt index d56e33284..e60e80cea 100644 --- a/app/render/CMakeLists.txt +++ b/app/render/CMakeLists.txt @@ -54,6 +54,8 @@ set(OLIVE_SOURCES render/renderjobtracker.h render/rendermanager.cpp render/rendermanager.h + render/renderworkerpool.cpp + render/renderworkerpool.h render/rendermodes.h render/renderprocessor.cpp render/renderprocessor.h diff --git a/app/render/rendermanager.cpp b/app/render/rendermanager.cpp index 9affa3f35..137733846 100644 --- a/app/render/rendermanager.cpp +++ b/app/render/rendermanager.cpp @@ -29,6 +29,7 @@ #include "core.h" #include "render/opengl/openglrenderer.h" #include "renderprocessor.h" +#include "renderworkerpool.h" #include "task/conform/conform.h" #include "task/taskmanager.h" #include "window/mainwindow/mainwindow.h" @@ -42,6 +43,7 @@ const rational RenderManager::kDryRunInterval = rational(10); RenderManager::RenderManager(QObject *parent) : backend_(kOpenGL) , aggressive_gc_(0) + , worker_pool_(nullptr) { if (backend_ == kOpenGL) { context_ = new OpenGLRenderer(); @@ -64,6 +66,12 @@ RenderManager::RenderManager(QObject *parent) } auto_cacher_ = new PreviewAutoCacher(this); + + if (OLIVE_CONFIG("RenderProcessIsolationEnabled").toBool()) { + worker_pool_ = new RenderWorkerPool(this); + worker_pool_->start(QThread::NormalPriority); + backend_ = kMultiProcess; + } } decoder_clear_timer_ = new QTimer(this); @@ -76,6 +84,12 @@ RenderManager::RenderManager(QObject *parent) RenderManager::~RenderManager() { if (context_) { + if (worker_pool_) { + worker_pool_->Shutdown(); + delete worker_pool_; + worker_pool_ = nullptr; + } + delete shader_cache_; delete decoder_cache_; @@ -126,6 +140,11 @@ RenderTicketPtr RenderManager::RenderFrame(const RenderVideoParams ¶ms) ticket->setProperty("cacheid", QVariant::fromValue(params.cache_id)); ticket->setProperty("multicam", QtUtils::PtrToValue(params.multicam)); + if (worker_pool_ && params.return_type == ReturnType::kFrame && + worker_pool_->SubmitFrame(ticket, params)) { + return ticket; + } + if (params.return_type == ReturnType::kNull) { dry_run_thread_->AddTicket(ticket); } else { diff --git a/app/render/rendermanager.h b/app/render/rendermanager.h index 7b8997008..f06d0f1b6 100644 --- a/app/render/rendermanager.h +++ b/app/render/rendermanager.h @@ -69,6 +69,8 @@ private: ShaderCache *shader_cache_; }; +class RenderWorkerPool; + class RenderManager : public QObject { Q_OBJECT public: @@ -76,6 +78,9 @@ public: /// Graphics acceleration provided by OpenGL kOpenGL, + /// Video frames are rendered by an external olive-render-worker process. + kMultiProcess, + /// No graphics rendering - used to test core threading logic kDummy }; @@ -246,6 +251,8 @@ private: PreviewAutoCacher *auto_cacher_; + RenderWorkerPool *worker_pool_; + private slots: void ClearOldDecoders(); }; diff --git a/app/render/renderworkerpool.cpp b/app/render/renderworkerpool.cpp new file mode 100644 index 000000000..94788dd93 --- /dev/null +++ b/app/render/renderworkerpool.cpp @@ -0,0 +1,374 @@ +/*** + + Oak - 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 "renderworkerpool.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "codec/frame.h" +#include "common/qtutils.h" + +namespace olive +{ + +namespace +{ + +constexpr int kProtocolVersion = 1; + +QString WorkerProgramPath() +{ + const QString dir = QCoreApplication::applicationDirPath(); +#if defined(Q_OS_WIN) + return QDir(dir).filePath(QStringLiteral("olive-render-worker.exe")); +#else + return QDir(dir).filePath(QStringLiteral("olive-render-worker")); +#endif +} + +bool WriteControlMessage(QProcess *process, const QJsonObject &obj) +{ + const QByteArray line = QJsonDocument(obj).toJson(QJsonDocument::Compact) + '\n'; + return process->write(line) == line.size() && process->waitForBytesWritten(5000); +} + +bool ReadControlMessage(QProcess *process, QJsonObject *out, QString *error, + int timeout_ms = 10000) +{ + if (!process->waitForReadyRead(timeout_ms)) { + if (error) { + *error = QStringLiteral("timeout waiting for worker response"); + } + return false; + } + + while (process->canReadLine()) { + const QByteArray line = process->readLine().trimmed(); + if (line.isEmpty()) { + continue; + } + + QJsonParseError parse_error; + const QJsonDocument doc = QJsonDocument::fromJson(line, &parse_error); + if (parse_error.error != QJsonParseError::NoError || !doc.isObject()) { + if (error) { + *error = QStringLiteral("worker emitted malformed control JSON"); + } + return false; + } + + *out = doc.object(); + if (out->value(QStringLiteral("type")).toString() == + QLatin1String(ipc::msgtype::kError)) { + if (error) { + *error = out->value(QStringLiteral("message")).toString(); + } + return false; + } + return true; + } + + if (error) { + *error = QStringLiteral("worker response did not contain a full line"); + } + return false; +} + +} // namespace + +RenderWorkerPool::RenderWorkerPool(QObject *parent) + : QThread(parent) +{ +} + +RenderWorkerPool::~RenderWorkerPool() +{ + Shutdown(); +} + +bool RenderWorkerPool::SubmitFrame(RenderTicketPtr ticket, + const RenderManager::RenderVideoParams ¶ms) +{ + Job job(ticket, params); + if (!PrepareJob(ticket, params, &job)) { + return false; + } + + ticket->moveToThread(this); + + QMutexLocker locker(&mutex_); + queue_.push_back(job); + wait_.wakeOne(); + return true; +} + +void RenderWorkerPool::Shutdown() +{ + { + QMutexLocker locker(&mutex_); + stopping_ = true; + wait_.wakeOne(); + } + + if (isRunning()) { + wait(); + } +} + +void RenderWorkerPool::run() +{ + while (true) { + mutex_.lock(); + while (queue_.empty() && !stopping_) { + wait_.wait(&mutex_); + } + if (stopping_ && queue_.empty()) { + mutex_.unlock(); + break; + } + + Job job = queue_.front(); + queue_.pop_front(); + mutex_.unlock(); + + ProcessJob(job); + CleanupGraphFile(job.graph_path); + } +} + +bool RenderWorkerPool::PrepareJob(RenderTicketPtr ticket, + const RenderManager::RenderVideoParams ¶ms, + Job *job) +{ + if (!IsSupported(params)) { + return false; + } + + Project *project = Project::GetProjectFromObject(params.node); + if (!project) { + qWarning() << "RenderWorkerPool could not resolve project for render node"; + return false; + } + + QString graph_path; + if (!WriteGraphSnapshot(project, &graph_path)) { + return false; + } + + job->ticket = ticket; + job->params = params; + job->graph_path = graph_path; + job->node_token = QString::number(reinterpret_cast(params.node)); + return true; +} + +bool RenderWorkerPool::WriteGraphSnapshot(Project *project, QString *path) +{ + QTemporaryFile file(QDir::temp().filePath(QStringLiteral("oak-render-graph-XXXXXX.ove"))); + file.setAutoRemove(false); + if (!file.open()) { + qWarning() << "RenderWorkerPool failed to create graph snapshot temp file" + << file.errorString(); + return false; + } + + QXmlStreamWriter writer(&file); + ProjectSerializer::SaveData data(ProjectSerializer::kProject, project, file.fileName()); + const ProjectSerializer::Result result = ProjectSerializer::Save(&writer, data); + file.close(); + + if (result.code() != ProjectSerializer::kSuccess || writer.hasError()) { + qWarning() << "RenderWorkerPool failed to serialize graph snapshot" + << result.GetDetails(); + QFile::remove(file.fileName()); + return false; + } + + *path = file.fileName(); + return true; +} + +bool RenderWorkerPool::IsSupported(const RenderManager::RenderVideoParams ¶ms) const +{ + return params.node && params.return_type == RenderManager::kFrame && + params.video_params.is_valid(); +} + +void RenderWorkerPool::ProcessJob(const Job &job) +{ + job.ticket->Start(); + if (job.ticket->IsCancelled()) { + job.ticket->Finish(); + return; + } + + const int linesize = Frame::generate_linesize_bytes( + kMaxWidth, PixelFormat::F32, VideoParams::kRGBAChannelCount); + const size_t slot_bytes = size_t(linesize) * kMaxHeight; + const size_t region_bytes = ipc::FrameSlotPool::BytesNeeded(kOutputSlots, slot_bytes); + const QString shm_key = + ipc::SharedMemoryRegion::MakeKey(QCoreApplication::applicationPid(), + int(reinterpret_cast(job.ticket.get()) & 0xFFFF)); + + ipc::SharedMemoryRegion region; + if (!region.Open(shm_key, region_bytes, ipc::SharedMemoryRegion::kCreate)) { + qWarning() << "RenderWorkerPool failed to create shared memory" + << region.error(); + job.ticket->Finish(); + return; + } + ipc::FrameSlotPool output_pool = + ipc::FrameSlotPool::Create(region.data(), kOutputSlots, slot_bytes); + + QProcess worker; + worker.setProgram(WorkerProgramPath()); + worker.start(); + if (!worker.waitForStarted(10000)) { + qWarning() << "RenderWorkerPool failed to start worker" + << worker.errorString(); + job.ticket->Finish(); + return; + } + + QString error; + QJsonObject response; + if (!ReadControlMessage(&worker, &response, &error)) { + qWarning() << "RenderWorkerPool did not receive startup handshake" + << error << worker.readAllStandardError(); + worker.kill(); + worker.waitForFinished(); + job.ticket->Finish(); + return; + } + + ipc::HandshakeMsg handshake; + handshake.protocol_version = kProtocolVersion; + handshake.shm_key = shm_key; + handshake.input_slots = 0; + handshake.output_slots = int(kOutputSlots); + handshake.slot_data_bytes = qint64(slot_bytes); + if (!WriteControlMessage(&worker, handshake.ToJson())) { + qWarning() << "RenderWorkerPool failed to send shared-memory handshake"; + worker.kill(); + worker.waitForFinished(); + job.ticket->Finish(); + return; + } + + ipc::LoadGraphMsg load; + load.path = job.graph_path; + if (!WriteControlMessage(&worker, load.ToJson()) || + !ReadControlMessage(&worker, &response, &error)) { + qWarning() << "RenderWorkerPool failed to load graph in worker" + << error << worker.readAllStandardError(); + worker.kill(); + worker.waitForFinished(); + job.ticket->Finish(); + return; + } + + ipc::RenderFrameMsg render; + render.ticket_id = qint64(reinterpret_cast(job.ticket.get())); + render.node_uuid = job.node_token; + render.time_num = job.params.time.numerator(); + render.time_den = job.params.time.denominator(); + render.width = job.params.force_size.width(); + render.height = job.params.force_size.height(); + render.format = int(job.params.force_format); + render.channel_count = job.params.force_channel_count; + render.mode = int(job.params.mode); + + if (!WriteControlMessage(&worker, render.ToJson())) { + qWarning() << "RenderWorkerPool failed to send render_frame"; + worker.kill(); + worker.waitForFinished(); + job.ticket->Finish(); + return; + } + + ipc::FrameReadyMsg ready; + while (true) { + if (!ReadControlMessage(&worker, &response, &error, 30000)) { + qWarning() << "RenderWorkerPool failed waiting for frame_ready" + << error << worker.readAllStandardError(); + worker.kill(); + worker.waitForFinished(); + job.ticket->Finish(); + return; + } + + if (ipc::FrameReadyMsg::FromJson(response, &ready)) { + break; + } + } + + FinishWithFrame(job.ticket, output_pool, uint32_t(ready.output_slot)); + + QJsonObject shutdown; + shutdown[QStringLiteral("type")] = ipc::msgtype::kShutdown; + WriteControlMessage(&worker, shutdown); + worker.closeWriteChannel(); + if (!worker.waitForFinished(5000)) { + worker.kill(); + worker.waitForFinished(); + } +} + +void RenderWorkerPool::FinishWithFrame(RenderTicketPtr ticket, + const ipc::FrameSlotPool &pool, + uint32_t slot) +{ + const ipc::FrameSlotMeta *meta = pool.Meta(slot); + if (!meta || meta->data_size <= 0 || + meta->data_size > int(pool.slot_data_bytes())) { + ticket->Finish(); + return; + } + + VideoParams params(meta->width, meta->height, + PixelFormat::Format(meta->format), + meta->channel_count); + FramePtr frame = Frame::Create(); + frame->set_timestamp(rational(int(meta->time_num), int(meta->time_den))); + frame->set_video_params(params); + if (!frame->allocate() || frame->allocated_size() < meta->data_size) { + ticket->Finish(); + return; + } + + memcpy(frame->data(), pool.SlotData(slot), size_t(meta->data_size)); + ticket->Finish(QVariant::fromValue(frame)); +} + +void RenderWorkerPool::CleanupGraphFile(const QString &path) +{ + if (!path.isEmpty()) { + QFile::remove(path); + } +} + +} // namespace olive diff --git a/app/render/renderworkerpool.h b/app/render/renderworkerpool.h new file mode 100644 index 000000000..6e1d864b5 --- /dev/null +++ b/app/render/renderworkerpool.h @@ -0,0 +1,89 @@ +/*** + + Oak - 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 RENDERWORKERPOOL_H +#define RENDERWORKERPOOL_H + +#include +#include +#include +#include + +#include "node/project/serializer/serializer.h" +#include "render/ipc/frameslotpool.h" +#include "render/ipc/ipcmessage.h" +#include "render/ipc/sharedmemoryregion.h" +#include "render/rendermanager.h" + +namespace olive +{ + +class RenderWorkerPool : public QThread { + Q_OBJECT +public: + explicit RenderWorkerPool(QObject *parent = nullptr); + ~RenderWorkerPool() override; + + bool SubmitFrame(RenderTicketPtr ticket, + const RenderManager::RenderVideoParams ¶ms); + + void Shutdown(); + +protected: + void run() override; + +private: + struct Job { + Job(RenderTicketPtr t, const RenderManager::RenderVideoParams &p) + : ticket(t) + , params(p) + { + } + + RenderTicketPtr ticket; + RenderManager::RenderVideoParams params; + QString graph_path; + QString node_token; + }; + + bool PrepareJob(RenderTicketPtr ticket, + const RenderManager::RenderVideoParams ¶ms, + Job *job); + bool WriteGraphSnapshot(Project *project, QString *path); + bool IsSupported(const RenderManager::RenderVideoParams ¶ms) const; + + void ProcessJob(const Job &job); + void FinishWithFrame(RenderTicketPtr ticket, const ipc::FrameSlotPool &pool, + uint32_t slot); + void CleanupGraphFile(const QString &path); + + QMutex mutex_; + QWaitCondition wait_; + std::deque queue_; + bool stopping_ = false; + + static constexpr uint32_t kOutputSlots = 2; + static constexpr int kMaxWidth = 4096; + static constexpr int kMaxHeight = 2160; +}; + +} + +#endif // RENDERWORKERPOOL_H diff --git a/docs/zh/render-process-isolation-plan.md b/docs/zh/render-process-isolation-plan.md index c1b1016a4..df5bef0a6 100644 --- a/docs/zh/render-process-isolation-plan.md +++ b/docs/zh/render-process-isolation-plan.md @@ -180,15 +180,26 @@ compact `QJsonObject`,`\n` 结尾。仅承载低频控制流量(握手、提 `frame_ready`;输出 slot 元数据为 `id=1001, 64x64, fmt=3, channels=4, bytes=65536`, 前 4KB 像素存在非零数据。 -### 阶段 3:主进程 WorkerPool + 调度器接线 +### 阶段 3:主进程 WorkerPool + 调度器接线(单 worker MVP 已接入) -- 新增 `app/render/renderworkerpool.{h,cpp}`: - - `QProcess` 启动 N 个 `olive-render-worker`,建立各自 SHM 段 + stdio 管道。 - - 维护 worker 忙闲状态,按最少负载派发。 - - `SubmitFrame(RenderTicketPtr)`:编码 `render_frame` 派发 → worker 回 `frame_ready` 后从输出 slot 拷出 `FramePtr` → `ticket->Finish(...)`。**对上层透明**,`RenderTicketWatcher`/`Viewer` 无感知。 -- `RenderManager` 增加 `kMultiProcess` backend 分支(与 `kOpenGL` 并存),多进程模式下 `RenderFrame()` 走 `RenderWorkerPool`。 -- config 开关控制启用,默认仍走进程内 `kOpenGL`。 -- 验证:开关打开后 Viewer 正常播放纯生成内容;关掉回退旧路径无差异。 +- ✅ 新增 `app/render/renderworkerpool.{h,cpp}`: + - 当前 MVP 用后台 `QThread` 持有任务队列,每个任务启动 1 个 `olive-render-worker`, + 建立输出 SHM 段 + stdio 管道。 + - `SubmitFrame(RenderTicketPtr, RenderVideoParams)`:写全量图快照临时文件 → + 发送 `handshake` / `load_graph` / `render_frame` → worker 回 `frame_ready` 后从输出 slot + 拷出 `FramePtr` → `ticket->Finish(...)`。对上层 `RenderTicketWatcher`/`Viewer` 保持透明。 + - 当前仅支持普通视频 `ReturnType::kFrame`;素材输入仍按阶段 4 处理,失败或不支持时回退旧路径。 +- ✅ `RenderManager` 增加 `kMultiProcess` backend 分支(与 `kOpenGL` 并存),开关开启且 + WorkerPool 接受任务时 `RenderFrame()` 走 `RenderWorkerPool`。 +- ✅ `Config` 增加 `RenderProcessIsolationEnabled`,默认 `false`,默认仍走进程内 `kOpenGL`。 +- 待补:常驻 N worker、忙闲/负载派发、崩溃重启与重派、Viewer 开关实测。 + +**验证结果**: +- `cmake --build build --target olive-render-worker olive-editor -j22` 通过。 +- `QT_QPA_PLATFORM=offscreen build/tests/gtest/olive-gtest --gtest_filter='SpscRingBuffer*:*FrameSlotPool*:*IpcMessage*:*ProjectSerializer*' --gtest_brief=1` + 通过,11 个测试全部通过。 +- 非沙箱环境 `printf '{"type":"shutdown"}\n' | build/app/olive-render-worker` 通过,输出合法 + handshake。 ### 阶段 4:素材输入解耦(关键重构) @@ -224,7 +235,7 @@ compact `QJsonObject`,`\n` 结尾。仅承载低频控制流量(握手、提 | `app/render/ipc/CMakeLists.txt` | 0 | ✅ | | `tests/gtest/render_ipc_test.cpp` | 0 | ✅ | | `app/render/worker/workermain.cpp` | 1/2 | ✅ 基础主循环 | -| `app/render/renderworkerpool.{h,cpp}` | 3 | 待办 | +| `app/render/renderworkerpool.{h,cpp}` | 3 | ✅ 单 worker MVP | **修改** @@ -234,9 +245,9 @@ compact `QJsonObject`,`\n` 结尾。仅承载低频控制流量(握手、提 | `tests/gtest/CMakeLists.txt`(注册 ipc 测试) | 0 | ✅ | | `app/CMakeLists.txt`(新增 `olive-render-worker` target) | 1 | ✅ | | `app/node/project/serializer/serializer*.{h,cpp}`(暴露加载映射供 worker 查节点) | 2 | ✅ | -| `app/render/rendermanager.{h,cpp}`(`kMultiProcess` 分支 + WorkerPool 接线) | 3 | 待办 | +| `app/render/rendermanager.{h,cpp}`(`kMultiProcess` 分支 + WorkerPool 接线) | 3 | ✅ 单 worker MVP | | `app/render/renderprocessor.cpp`(`ProcessVideoFootage` 改取输入 slot) | 4 | 待办 | -| `app/config/config.h`(多进程开关) | 3 | 待办 | +| `app/config/config.cpp`(多进程开关默认值) | 3 | ✅ 默认关闭 | ---