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 | ✅ 默认关闭 |
---