Files
oak-editor/app/render/renderworkerpool.cpp
T
Mike-Solar ea78558a31 render: reference-count graph snapshots to prevent premature deletion
Proxy generation modifies the project, causing graph snapshots to be
rewritten. The old implementation deleted the previous snapshot
immediately, even though queued render jobs still held its path. The
worker then failed to load the deleted file.

Add per-path reference counting:
- Increment when a job is created with a snapshot path.
- Decrement when the job is processed, cancelled or removed.
- Only delete a snapshot when its reference count reaches zero and it
  is no longer the cached snapshot for the project.

Also keep snapshots in the system temp directory as before, since the
location itself was not the bug.
2026-07-13 13:43:20 +08:00

1300 lines
36 KiB
C++

/***
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 <http://www.gnu.org/licenses/>.
***/
#include "renderworkerpool.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QTemporaryFile>
#include <QXmlStreamWriter>
#include <algorithm>
#include <memory>
#include <optional>
#include <thread>
#include <utility>
#include <vector>
#if defined(Q_OS_WIN)
#include <windows.h>
#else
#include <signal.h>
#endif
#include "codec/frame.h"
#include "common/qtutils.h"
#include "node/project/footage/footage.h"
#include "node/traverser.h"
namespace olive
{
namespace
{
constexpr int kProtocolVersion = 1;
struct FootageInput {
FootageJob job;
rational time;
};
class FootageInputCollector : public NodeTraverser {
public:
QVector<FootageInput> Collect(const RenderManager::RenderVideoParams &params,
CancelAtom *cancel)
{
SetCancelPointer(cancel);
VideoParams cache_params = params.video_params;
cache_params.set_format(PixelFormat::F32);
SetCacheVideoParams(cache_params);
SetCacheAudioParams(params.audio_params);
rational frame_length = cache_params.frame_rate_as_time_base();
if (cache_params.interlacing() != VideoParams::kInterlaceNone) {
frame_length /= 2;
}
NodeValueTable table = GenerateTable(params.node,
TimeRange(params.time,
params.time + frame_length));
NodeValue texture = table.Get(NodeValue::kTexture);
ResolveJobs(texture);
if (cache_params.interlacing() != VideoParams::kInterlaceNone) {
NodeValueTable second_table =
GenerateTable(params.node,
TimeRange(params.time + frame_length,
params.time + frame_length * 2));
NodeValue second_texture = second_table.Get(NodeValue::kTexture);
ResolveJobs(second_texture);
}
return inputs_;
}
protected:
void ProcessVideoFootage(TexturePtr destination,
const FootageJob *stream,
const rational &input_time) override
{
Q_UNUSED(destination)
if (stream) {
inputs_.append({*stream, input_time});
}
}
private:
QVector<FootageInput> inputs_;
};
DecoderPtr ResolveDecoderFromCache(DecoderCache *decoder_cache,
const QString &decoder_id,
const Decoder::CodecStream &stream)
{
if (!decoder_cache || !stream.IsValid()) {
return nullptr;
}
QMutexLocker locker(decoder_cache->mutex());
DecoderPair decoder = decoder_cache->value(stream);
const qint64 file_last_modified =
QFileInfo(stream.filename()).lastModified().toMSecsSinceEpoch();
if (decoder.decoder && decoder.last_modified == file_last_modified) {
return decoder.decoder;
}
decoder.decoder = Decoder::CreateFromID(decoder_id);
decoder.last_modified = file_last_modified;
decoder_cache->insert(stream, decoder);
locker.unlock();
if (!decoder.decoder || !decoder.decoder->Open(stream)) {
qWarning() << "RenderWorkerPool failed to open decoder for"
<< stream.filename() << "::" << stream.stream();
return nullptr;
}
return decoder.decoder;
}
FramePtr DecodeInputFrame(DecoderCache *decoder_cache,
const FootageInput &input,
CancelAtom *cancel)
{
VideoParams stream_data = input.job.video_params();
QString filename = input.job.filename();
QString decoder_id = input.job.decoder();
int stream_index = stream_data.stream_index();
// Use generated proxy if one is attached to the job. The Footage node only
// attaches a proxy when it is enabled, ready, and matches this stream.
if (input.job.has_proxy()) {
filename = input.job.proxy_filename();
decoder_id = input.job.proxy_decoder();
stream_index = input.job.proxy_stream_index();
}
DecoderPtr decoder;
switch (stream_data.video_type()) {
case VideoParams::kVideoTypeVideo:
case VideoParams::kVideoTypeStill:
decoder = ResolveDecoderFromCache(
decoder_cache,
decoder_id,
Decoder::CodecStream(filename, stream_index, nullptr));
break;
case VideoParams::kVideoTypeImageSequence: {
const int64_t frame_number =
stream_data.get_time_in_timebase_units(input.time);
filename = Decoder::TransformImageSequenceFileName(filename, frame_number);
decoder = Decoder::CreateFromID(decoder_id);
if (decoder &&
!decoder->Open(Decoder::CodecStream(filename,
stream_index,
nullptr))) {
decoder = nullptr;
}
break;
}
}
if (!decoder) {
return nullptr;
}
Decoder::RetrieveVideoParams retrieve;
retrieve.divider = stream_data.divider();
retrieve.maximum_format = PixelFormat::U16;
retrieve.time = stream_data.video_type() == VideoParams::kVideoTypeVideo
? input.time
: Decoder::kAnyTimecode;
retrieve.cancelled = cancel;
retrieve.force_range = stream_data.color_range();
retrieve.src_interlacing = stream_data.interlacing();
FramePtr frame = decoder->RetrieveVideoFrame(retrieve);
if (frame) {
frame->set_timestamp(input.time);
// Ensure the frame carries the colorspace the color manager expects.
// Decoders do not always set this on the returned frame, but the worker
// needs it to build the correct OCIO transform.
VideoParams frame_params = frame->video_params();
if (frame_params.colorspace().isEmpty() &&
!stream_data.colorspace().isEmpty()) {
frame_params.set_colorspace(stream_data.colorspace());
frame->set_video_params(frame_params);
}
}
return frame;
}
bool DecodeInputFrames(DecoderCache *decoder_cache,
const RenderManager::RenderVideoParams &params,
CancelAtom *cancel,
QVector<FramePtr> *frames)
{
frames->clear();
FootageInputCollector collector;
const QVector<FootageInput> inputs = collector.Collect(params, cancel);
frames->reserve(inputs.size());
for (const FootageInput &input : inputs) {
if (cancel && cancel->IsCancelled()) {
return false;
}
FramePtr frame = DecodeInputFrame(decoder_cache, input, cancel);
if (!frame || !frame->is_allocated()) {
frames->clear();
return false;
}
frames->append(frame);
}
return true;
}
QString WorkerProgramPath()
{
#if defined(Q_OS_WIN)
const QString file = QStringLiteral("olive-render-worker.exe");
#else
const QString file = QStringLiteral("olive-render-worker");
#endif
const QString app_dir = QCoreApplication::applicationDirPath();
const QStringList candidates = {
QDir(app_dir).filePath(file),
QDir(app_dir).filePath(QStringLiteral("../app/") + file),
};
for (const QString &path : candidates) {
if (QFileInfo::exists(path)) {
return path;
}
}
if (qEnvironmentVariableIsSet("OAK_RENDER_WORKER")) {
return QString::fromUtf8(qgetenv("OAK_RENDER_WORKER"));
}
return candidates.first();
}
bool WriteControlMessage(QProcess *process, const QJsonObject &obj)
{
if (!process || process->state() != QProcess::Running) {
return false;
}
const QByteArray line = QJsonDocument(obj).toJson(QJsonDocument::Compact) + '\n';
const qint64 written = process->write(line);
if (written != line.size()) {
return false;
}
return process->waitForBytesWritten(5000);
}
void TryWriteControlMessage(QProcess *process, const QJsonObject &obj)
{
if (!process || process->state() != QProcess::Running) {
return;
}
const QByteArray line = QJsonDocument(obj).toJson(QJsonDocument::Compact) + '\n';
process->write(line);
}
bool KillProcessById(qint64 process_id)
{
if (process_id <= 0) {
return false;
}
#if defined(Q_OS_WIN)
HANDLE handle = OpenProcess(PROCESS_TERMINATE, FALSE, DWORD(process_id));
if (!handle) {
return false;
}
const bool ok = TerminateProcess(handle, 1) != 0;
CloseHandle(handle);
return ok;
#else
return ::kill(pid_t(process_id), SIGKILL) == 0;
#endif
}
bool IsProcessAlive(qint64 process_id)
{
if (process_id <= 0) {
return false;
}
#if defined(Q_OS_WIN)
HANDLE handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, DWORD(process_id));
if (!handle) {
return false;
}
DWORD exit_code = 0;
const bool alive = GetExitCodeProcess(handle, &exit_code) && exit_code == STILL_ACTIVE;
CloseHandle(handle);
return alive;
#else
return ::kill(pid_t(process_id), 0) == 0;
#endif
}
QString WorkerProcessDetails(const QProcess *process)
{
if (!process) {
return QStringLiteral("worker process unavailable");
}
const QString exit_status =
process->exitStatus() == QProcess::CrashExit
? QStringLiteral("crash")
: QStringLiteral("normal");
return QStringLiteral("state=%1 exit_status=%2 exit_code=%3 process_error=%4 error=\"%5\"")
.arg(int(process->state()))
.arg(exit_status)
.arg(process->exitCode())
.arg(int(process->error()))
.arg(process->errorString());
}
bool ReadControlMessage(QProcess *process, QJsonObject *out, QString *error,
int timeout_ms = 10000)
{
if (!process->waitForReadyRead(timeout_ms)) {
if (error) {
if (process->state() == QProcess::NotRunning) {
*error = QStringLiteral("worker exited before response: %1")
.arg(WorkerProcessDetails(process));
} else {
*error = QStringLiteral("timeout waiting for worker response: %1")
.arg(WorkerProcessDetails(process));
}
}
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(DecoderCache *decoder_cache,
const QString &gpu_backend,
QObject *parent)
: QThread(parent)
, decoder_cache_(decoder_cache)
, gpu_backend_(gpu_backend)
{
}
RenderWorkerPool::~RenderWorkerPool()
{
Shutdown();
}
bool RenderWorkerPool::SubmitFrame(RenderTicketPtr ticket,
const RenderManager::RenderVideoParams &params)
{
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;
}
bool RenderWorkerPool::RemoveTicket(RenderTicketPtr ticket)
{
if (!ticket) {
return false;
}
QString queued_graph_path;
bool matched_active = false;
{
QMutexLocker locker(&mutex_);
auto it = std::find_if(queue_.begin(), queue_.end(),
[&ticket](const Job &job) {
return job.ticket == ticket;
});
if (it != queue_.end()) {
queued_graph_path = it->graph_path;
queue_.erase(it);
} else {
for (const ActiveJob &active : active_jobs_) {
if (active.ticket == ticket) {
ticket->Cancel();
CancelActiveProcess(active.process_id);
matched_active = true;
break;
}
}
if (!matched_active) {
return false;
}
}
}
if (!queued_graph_path.isEmpty()) {
ReleaseGraphPathRef(queued_graph_path);
return true;
}
return true;
}
void RenderWorkerPool::Shutdown()
{
{
QMutexLocker locker(&mutex_);
stopping_ = true;
for (Job &job : queue_) {
if (job.ticket) {
job.ticket->Cancel();
}
ReleaseGraphPathRefLocked(job.graph_path);
}
queue_.clear();
for (ActiveJob &active : active_jobs_) {
if (active.ticket) {
active.ticket->Cancel();
CancelActiveProcess(active.process_id);
}
}
for (auto it = graph_cache_.begin(); it != graph_cache_.end(); ++it) {
SetGraphPathCachedLocked(it->path, false);
}
graph_cache_.clear();
wait_.wakeAll();
}
if (isRunning()) {
wait();
}
}
void RenderWorkerPool::run()
{
const int worker_count = WorkerCount();
{
QMutexLocker locker(&mutex_);
active_jobs_.resize(worker_count);
}
std::vector<std::vector<std::unique_ptr<PooledWorker>>> local_pools(worker_count);
std::vector<std::thread> workers;
workers.reserve(size_t(worker_count));
for (int i = 0; i < worker_count; i++) {
workers.emplace_back([this, i, &local_pools]() {
WorkerLoop(i, &local_pools[i]);
});
}
for (std::thread &worker : workers) {
worker.join();
}
for (auto &local_pool : local_pools) {
ShutdownLocalPool(&local_pool);
}
ClearGraphCache();
QMutexLocker locker(&mutex_);
active_jobs_.clear();
}
void RenderWorkerPool::WorkerLoop(
int worker_index,
std::vector<std::unique_ptr<PooledWorker>> *local_pool)
{
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, worker_index, local_pool);
ReleaseGraphPathRef(job.graph_path);
}
}
bool RenderWorkerPool::PrepareJob(RenderTicketPtr ticket,
const RenderManager::RenderVideoParams &params,
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;
}
QVector<FramePtr> input_frames;
if (!DecodeInputFrames(decoder_cache_, params, ticket->GetCancelAtom(),
&input_frames)) {
qWarning() << "RenderWorkerPool could not predecode footage inputs;"
<< "falling back to in-process render";
return false;
}
QString graph_path;
bool wrote_new_snapshot = false;
{
const QUuid project_uuid = project->GetUuid();
QMutexLocker locker(&mutex_);
auto it = graph_cache_.find(project_uuid);
if (it != graph_cache_.end() && !project->is_modified()) {
graph_path = it->path;
AddGraphPathRefLocked(graph_path);
qDebug() << "RenderWorkerPool::PrepareJob: using cached graph snapshot"
<< graph_path;
} else {
if (it != graph_cache_.end()) {
qDebug() << "RenderWorkerPool::PrepareJob: graph stale, rewriting"
<< project->is_modified();
SetGraphPathCachedLocked(it->path, false);
graph_cache_.erase(it);
}
locker.unlock();
if (!WriteGraphSnapshot(project, &graph_path)) {
return false;
}
wrote_new_snapshot = true;
// For internal render-proxy copies, the snapshot now represents the
// serialized state, so reset the modified flag so the snapshot can be
// reused until the copy changes again.
if (project->property("_oak_render_proxy").toBool()) {
project->set_modified(false);
}
locker.relock();
graph_cache_.insert(project_uuid, {graph_path});
SetGraphPathCachedLocked(graph_path, true);
AddGraphPathRefLocked(graph_path);
}
}
job->ticket = ticket;
job->params = params;
job->graph_path = graph_path;
job->node_token = QString::number(reinterpret_cast<quintptr>(params.node));
job->input_frames = input_frames;
Q_UNUSED(wrote_new_snapshot)
return true;
}
bool RenderWorkerPool::WriteGraphSnapshot(Project *project, QString *path)
{
// Keep snapshots in the system temp directory. The previous bug was not the
// temp location itself, but stale snapshots being deleted while queued jobs
// still referenced them.
const QString graph_dir = QDir::tempPath();
QTemporaryFile file(QDir(graph_dir).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;
}
qDebug() << "RenderWorkerPool wrote graph snapshot" << file.fileName()
<< "size" << QFileInfo(file.fileName()).size();
*path = file.fileName();
return true;
}
bool RenderWorkerPool::IsSupported(const RenderManager::RenderVideoParams &params) const
{
return params.node && params.return_type == RenderManager::kFrame &&
params.video_params.is_valid();
}
void RenderWorkerPool::ProcessJob(
const Job &job, int worker_index,
std::vector<std::unique_ptr<PooledWorker>> *local_pool)
{
const qint64 ticket_id = qint64(reinterpret_cast<quintptr>(job.ticket.get()));
SetActiveWorker(worker_index, job.ticket, nullptr, ticket_id);
job.ticket->Start();
if (job.ticket->IsCancelled()) {
job.ticket->Finish();
ClearActiveWorker(worker_index, 0);
return;
}
std::unique_ptr<PooledWorker> worker = AcquireWorker(local_pool, job.graph_path);
if (!worker) {
qWarning() << "RenderWorkerPool failed to acquire worker for ticket"
<< ticket_id;
job.ticket->Finish();
ClearActiveWorker(worker_index, 0);
return;
}
for (int attempt = 0; attempt < kMaxAttempts; attempt++) {
if (attempt > 0) {
worker = AcquireWorker(local_pool, job.graph_path);
if (!worker) {
qWarning() << "RenderWorkerPool failed to acquire worker for retry"
<< ticket_id;
break;
}
}
const JobResult result = ProcessJobAttempt(job, worker_index, attempt,
worker.get());
const qint64 worker_pid = worker && worker->process
? worker->process->processId()
: 0;
const bool process_state_running = worker && worker->process &&
worker->process->state() == QProcess::Running;
const bool os_alive = worker_pid > 0 && IsProcessAlive(worker_pid);
const bool worker_healthy = process_state_running || os_alive;
const bool keep_alive = (result == JobResult::kFinished) && worker_healthy;
ReturnWorker(local_pool, std::move(worker), keep_alive);
worker.reset();
if (result == JobResult::kFinished) {
ClearActiveWorker(worker_index, 0);
return;
}
if (result == JobResult::kCancelled) {
job.ticket->Finish();
ClearActiveWorker(worker_index, 0);
return;
}
if (result == JobResult::kFatalFailure) {
break;
}
if (attempt + 1 < kMaxAttempts && !job.ticket->IsCancelled()) {
qWarning() << "RenderWorkerPool retrying render worker for ticket"
<< ticket_id << "after worker failure";
}
}
if (job.ticket->IsCancelled()) {
job.ticket->Finish();
} else {
qWarning() << "RenderWorkerPool exhausted worker retries for ticket"
<< ticket_id;
job.ticket->Finish();
}
ClearActiveWorker(worker_index, 0);
}
RenderWorkerPool::JobResult RenderWorkerPool::ProcessJobAttempt(
const Job &job, int worker_index, int attempt_index,
PooledWorker *worker)
{
const qint64 ticket_id = qint64(reinterpret_cast<quintptr>(job.ticket.get()));
if (job.ticket->IsCancelled()) {
return JobResult::kCancelled;
}
if (!worker || !worker->process) {
return JobResult::kRetryableFailure;
}
const qint64 worker_process_id = worker->process->processId();
const int output_width = job.params.force_size.width() > 0
? job.params.force_size.width()
: job.params.video_params.effective_width();
const int output_height = job.params.force_size.height() > 0
? job.params.force_size.height()
: job.params.video_params.effective_height();
const PixelFormat::Format output_format =
job.params.force_format != PixelFormat::INVALID
? PixelFormat::Format(job.params.force_format)
: PixelFormat::F32;
const int output_channels = job.params.force_channel_count > 0
? job.params.force_channel_count
: VideoParams::kRGBAChannelCount;
const int output_linesize =
Frame::generate_linesize_bytes(output_width, output_format,
output_channels);
const size_t estimated_output_slot_bytes =
size_t(output_linesize) * size_t(output_height);
const int f32_rgba_linesize =
Frame::generate_linesize_bytes(output_width, PixelFormat::F32,
VideoParams::kRGBAChannelCount);
const size_t f32_rgba_slot_bytes =
size_t(f32_rgba_linesize) * size_t(output_height);
const size_t output_slot_bytes =
std::max(estimated_output_slot_bytes, f32_rgba_slot_bytes);
size_t input_slot_bytes = 0;
for (const FramePtr &frame : job.input_frames) {
if (frame && frame->is_allocated()) {
input_slot_bytes =
std::max(input_slot_bytes, size_t(frame->allocated_size()));
}
}
const size_t output_region_bytes =
ipc::FrameSlotPool::BytesNeeded(kOutputSlots, output_slot_bytes);
if (!worker->output_region.IsValid() ||
worker->output_slot_bytes < output_slot_bytes) {
if (worker->output_region.IsValid()) {
worker->output_region.Close();
worker->output_pool = ipc::FrameSlotPool();
}
if (worker->output_shm_key.isEmpty()) {
worker->output_shm_key =
ipc::SharedMemoryRegion::MakeKey(worker_process_id, 0) +
QStringLiteral("-out");
}
if (!worker->output_region.Open(worker->output_shm_key,
output_region_bytes,
ipc::SharedMemoryRegion::kCreate)) {
qWarning() << "RenderWorkerPool failed to create output shared memory"
<< worker->output_region.error();
return JobResult::kFatalFailure;
}
worker->output_pool = ipc::FrameSlotPool::Create(
worker->output_region.data(), kOutputSlots, output_slot_bytes);
worker->output_slot_bytes = output_slot_bytes;
}
const QString shm_key = worker->output_shm_key;
ipc::FrameSlotPool &output_pool = worker->output_pool;
const uint32_t input_slot_count =
job.input_frames.isEmpty() ? 0 : uint32_t(job.input_frames.size());
if (input_slot_count > 0) {
if (!worker->input_region.IsValid() ||
worker->input_slot_bytes < input_slot_bytes ||
worker->input_pool.slot_count() < input_slot_count) {
if (worker->input_region.IsValid()) {
worker->input_region.Close();
worker->input_pool = ipc::FrameSlotPool();
}
if (worker->input_shm_key.isEmpty()) {
worker->input_shm_key =
ipc::SharedMemoryRegion::MakeKey(worker_process_id, 1) +
QStringLiteral("-in");
}
const size_t input_region_bytes =
ipc::FrameSlotPool::BytesNeeded(input_slot_count, input_slot_bytes);
if (!worker->input_region.Open(worker->input_shm_key,
input_region_bytes,
ipc::SharedMemoryRegion::kCreate)) {
qWarning() << "RenderWorkerPool failed to create input shared memory"
<< worker->input_region.error();
return JobResult::kFatalFailure;
}
worker->input_pool = ipc::FrameSlotPool::Create(
worker->input_region.data(), input_slot_count, input_slot_bytes);
worker->input_slot_bytes = input_slot_bytes;
}
}
const QString input_shm_key = worker->input_shm_key;
ipc::FrameSlotPool &input_pool = worker->input_pool;
QVector<int> input_slots;
if (input_slot_count > 0) {
for (const FramePtr &frame : job.input_frames) {
if (frame->allocated_size() > int(worker->input_slot_bytes)) {
qWarning() << "RenderWorkerPool decoded input frame exceeds slot size";
return JobResult::kFatalFailure;
}
uint32_t slot = 0;
if (!input_pool.Acquire(&slot)) {
qWarning() << "RenderWorkerPool input pool had no free slot";
return JobResult::kFatalFailure;
}
memcpy(input_pool.SlotData(slot), frame->const_data(),
size_t(frame->allocated_size()));
ipc::FrameSlotMeta *meta = input_pool.Meta(slot);
meta->id = qint64(input_slots.size());
meta->time_num = frame->timestamp().numerator();
meta->time_den = frame->timestamp().denominator();
meta->width = frame->width();
meta->height = frame->height();
meta->format = int32_t(frame->format());
meta->channel_count = frame->channel_count();
meta->linesize = frame->linesize_bytes();
meta->data_size = frame->allocated_size();
memset(meta->colorspace, 0, sizeof(meta->colorspace));
const QString cs = frame->video_params().colorspace();
if (!cs.isEmpty()) {
const QByteArray cs_utf8 = cs.toUtf8();
const size_t copy_len = qMin(
static_cast<size_t>(cs_utf8.size()),
sizeof(meta->colorspace) - 1);
memcpy(meta->colorspace, cs_utf8.constData(), copy_len);
meta->colorspace[copy_len] = '\0';
}
if (!input_pool.Publish(slot)) {
qWarning() << "RenderWorkerPool failed to publish input slot";
return JobResult::kFatalFailure;
}
input_slots.append(int(slot));
}
if (input_slots.size() != job.input_frames.size()) {
qWarning() << "RenderWorkerPool failed to publish all input frames;"
<< "aborting worker render";
return JobResult::kFatalFailure;
}
}
SetActiveWorker(worker_index, job.ticket, worker->process, ticket_id);
if (job.ticket->IsCancelled()) {
ipc::CancelMsg cancel;
cancel.ticket_id = ticket_id;
TryWriteControlMessage(worker->process, cancel.ToJson());
ClearActiveWorker(worker_index, worker_process_id);
return JobResult::kCancelled;
}
ipc::HandshakeMsg handshake;
handshake.protocol_version = kProtocolVersion;
handshake.shm_key = shm_key;
handshake.input_shm_key = input_slots.isEmpty() ? QString() : input_shm_key;
handshake.input_slots = input_slots.size();
handshake.output_slots = int(kOutputSlots);
handshake.slot_data_bytes = qint64(output_slot_bytes);
handshake.input_slot_data_bytes = input_slots.isEmpty()
? 0
: qint64(input_slot_bytes);
if (!WriteControlMessage(worker->process, handshake.ToJson())) {
if (!job.ticket->IsCancelled()) {
qWarning() << "RenderWorkerPool failed to send shared-memory handshake";
}
ClearActiveWorker(worker_index, worker_process_id);
return job.ticket->IsCancelled() ? JobResult::kCancelled
: JobResult::kRetryableFailure;
}
if (worker->loaded_graph_path != job.graph_path) {
ipc::LoadGraphMsg load;
load.path = job.graph_path;
QString error;
QJsonObject response;
if (!WriteControlMessage(worker->process, load.ToJson()) ||
!ReadControlMessage(worker->process, &response, &error)) {
if (!job.ticket->IsCancelled()) {
qWarning() << "RenderWorkerPool failed to load graph in worker"
<< error << worker->process->readAllStandardError();
}
ClearActiveWorker(worker_index, worker_process_id);
return job.ticket->IsCancelled() ? JobResult::kCancelled
: JobResult::kRetryableFailure;
}
worker->loaded_graph_path = job.graph_path;
}
ipc::RenderFrameMsg render;
render.ticket_id = ticket_id;
render.node_uuid = job.node_token;
render.time_num = job.params.time.numerator();
render.time_den = job.params.time.denominator();
render.width = output_width;
render.height = output_height;
render.format = int(output_format);
render.channel_count = output_channels;
render.mode = int(job.params.mode);
render.input_slot = input_slots.isEmpty() ? -1 : input_slots.front();
render.input_slots = input_slots;
const ColorTransform &ct = job.params.force_color_transform;
render.has_color_transform = !ct.output().isEmpty();
render.color_is_display = ct.is_display();
render.color_output = ct.output();
render.color_view = ct.view();
render.color_look = ct.look();
if (!WriteControlMessage(worker->process, render.ToJson())) {
if (!job.ticket->IsCancelled()) {
qWarning() << "RenderWorkerPool failed to send render_frame";
}
ClearActiveWorker(worker_index, worker_process_id);
return job.ticket->IsCancelled() ? JobResult::kCancelled
: JobResult::kRetryableFailure;
}
QString error;
QJsonObject response;
ipc::FrameReadyMsg ready;
while (true) {
if (!ReadControlMessage(worker->process, &response, &error, 30000)) {
if (!job.ticket->IsCancelled()) {
qWarning() << "RenderWorkerPool failed waiting for frame_ready"
<< error << worker->process->readAllStandardError();
}
ClearActiveWorker(worker_index, worker_process_id);
return job.ticket->IsCancelled() ? JobResult::kCancelled
: JobResult::kRetryableFailure;
}
if (ipc::FrameReadyMsg::FromJson(response, &ready)) {
break;
}
}
if (job.ticket->IsCancelled()) {
ClearActiveWorker(worker_index, worker_process_id);
return JobResult::kCancelled;
}
uint32_t consumed_slot = 0;
if (!output_pool.Consume(&consumed_slot)) {
qWarning() << "RenderWorkerPool failed to consume output slot";
ClearActiveWorker(worker_index, worker_process_id);
return JobResult::kRetryableFailure;
}
if (int(consumed_slot) != ready.output_slot) {
qWarning() << "RenderWorkerPool output slot mismatch: consumed"
<< consumed_slot << "expected" << ready.output_slot;
}
FinishWithFrame(job.ticket, output_pool, consumed_slot);
output_pool.Release(consumed_slot);
ClearActiveWorker(worker_index, worker_process_id);
return JobResult::kFinished;
}
void RenderWorkerPool::CancelActiveProcess(qint64 process_id)
{
KillProcessById(process_id);
}
void RenderWorkerPool::SetActiveWorker(int worker_index, RenderTicketPtr ticket,
QProcess *worker, qint64 ticket_id)
{
QMutexLocker locker(&mutex_);
if (worker_index < 0 || worker_index >= active_jobs_.size()) {
return;
}
ActiveJob &active = active_jobs_[worker_index];
active.ticket = ticket;
active.process_id = worker ? worker->processId() : 0;
active.ticket_id = ticket_id;
}
void RenderWorkerPool::ClearActiveWorker(int worker_index, qint64 process_id)
{
QMutexLocker locker(&mutex_);
if (worker_index < 0 || worker_index >= active_jobs_.size()) {
return;
}
ActiveJob &active = active_jobs_[worker_index];
if (process_id > 0) {
if (active.process_id == process_id) {
active.process_id = 0;
}
} else {
active = ActiveJob();
}
}
int RenderWorkerPool::WorkerCount() const
{
// GPU rendering is the bottleneck for video frames; too many workers just
// multiply first-frame warmup (shader/OCIO cache creation) and compete for
// the same GPU. Cap at a small number while still leaving cores free.
const int ideal = QThread::idealThreadCount();
return std::max(1, std::min(ideal - 2, 4));
}
std::unique_ptr<RenderWorkerPool::PooledWorker> RenderWorkerPool::AcquireWorker(
std::vector<std::unique_ptr<PooledWorker>> *local_pool,
const QString &graph_path)
{
if (!local_pool) {
return nullptr;
}
const qint64 now = QDateTime::currentMSecsSinceEpoch();
// Prefer an idle worker that already has the requested graph loaded.
int best_index = -1;
for (size_t i = 0; i < local_pool->size();) {
PooledWorker *candidate = (*local_pool)[i].get();
if (!candidate || !candidate->process) {
local_pool->erase(local_pool->begin() + i);
continue;
}
const bool candidate_state_running =
candidate->process->state() == QProcess::Running;
const bool candidate_os_alive =
IsProcessAlive(candidate->process->processId());
if (!candidate_state_running && !candidate_os_alive) {
ShutdownWorker(candidate);
local_pool->erase(local_pool->begin() + i);
continue;
}
if (now - candidate->last_used_ms > kWorkerIdleTimeoutMs) {
ShutdownWorker(candidate);
local_pool->erase(local_pool->begin() + i);
continue;
}
if (best_index < 0 ||
(!candidate->loaded_graph_path.isEmpty() &&
candidate->loaded_graph_path == graph_path &&
((*local_pool)[size_t(best_index)]->loaded_graph_path != graph_path))) {
best_index = int(i);
}
++i;
}
if (best_index >= 0) {
std::unique_ptr<PooledWorker> worker =
std::move((*local_pool)[size_t(best_index)]);
local_pool->erase(local_pool->begin() + best_index);
worker->last_used_ms = now;
++worker->use_count;
return worker;
}
// No idle worker available: start a new one.
auto *process = new QProcess();
process->setProgram(WorkerProgramPath());
process->setArguments({QStringLiteral("--backend"), gpu_backend_});
const QString worker_stderr_path = QDir(QDir::tempPath()).filePath(
QStringLiteral("oak-render-worker-%1-%2.stderr.log")
.arg(QCoreApplication::applicationPid())
.arg(QDateTime::currentMSecsSinceEpoch()));
process->setStandardErrorFile(worker_stderr_path);
process->start();
if (!process->waitForStarted(10000)) {
qWarning() << "RenderWorkerPool failed to start worker"
<< process->errorString();
delete process;
return nullptr;
}
QString error;
QJsonObject response;
if (!ReadControlMessage(process, &response, &error)) {
qWarning() << "RenderWorkerPool did not receive startup handshake"
<< error << process->readAllStandardError();
process->kill();
process->waitForFinished();
delete process;
return nullptr;
}
auto worker = std::make_unique<PooledWorker>();
worker->process = process;
worker->last_used_ms = now;
worker->use_count = 1;
return worker;
}
void RenderWorkerPool::ReturnWorker(
std::vector<std::unique_ptr<PooledWorker>> *local_pool,
std::unique_ptr<PooledWorker> worker,
bool keep_alive)
{
if (!worker || !worker->process) {
return;
}
const bool pool_full = worker->use_count >= kWorkerMaxUses;
if (!keep_alive || stopping_ || pool_full) {
ShutdownWorker(worker.get());
return;
}
worker->last_used_ms = QDateTime::currentMSecsSinceEpoch();
local_pool->push_back(std::move(worker));
}
void RenderWorkerPool::ShutdownWorker(PooledWorker *worker)
{
if (!worker || !worker->process) {
return;
}
QProcess *process = worker->process;
worker->process = nullptr;
worker->loaded_graph_path.clear();
worker->use_count = 0;
if (process->state() == QProcess::Running) {
QJsonObject shutdown;
shutdown[QStringLiteral("type")] = ipc::msgtype::kShutdown;
TryWriteControlMessage(process, shutdown);
process->closeWriteChannel();
if (!process->waitForFinished(5000)) {
process->kill();
process->waitForFinished();
}
}
delete process;
}
void RenderWorkerPool::ShutdownLocalPool(
std::vector<std::unique_ptr<PooledWorker>> *local_pool)
{
if (!local_pool) {
return;
}
for (std::unique_ptr<PooledWorker> &worker : *local_pool) {
ShutdownWorker(worker.get());
}
local_pool->clear();
}
void RenderWorkerPool::ClearGraphCache()
{
QMutexLocker locker(&mutex_);
for (auto it = graph_cache_.begin(); it != graph_cache_.end(); ++it) {
SetGraphPathCachedLocked(it->path, false);
}
graph_cache_.clear();
graph_path_ref_count_.clear();
cached_graph_paths_.clear();
}
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()) {
qDebug() << "RenderWorkerPool cleaning up graph file" << path;
QFile::remove(path);
}
}
void RenderWorkerPool::AddGraphPathRef(const QString &path)
{
QMutexLocker locker(&mutex_);
AddGraphPathRefLocked(path);
}
void RenderWorkerPool::AddGraphPathRefLocked(const QString &path)
{
if (path.isEmpty()) {
return;
}
++graph_path_ref_count_[path];
}
void RenderWorkerPool::ReleaseGraphPathRef(const QString &path)
{
QMutexLocker locker(&mutex_);
ReleaseGraphPathRefLocked(path);
}
void RenderWorkerPool::ReleaseGraphPathRefLocked(const QString &path)
{
if (path.isEmpty()) {
return;
}
auto it = graph_path_ref_count_.find(path);
if (it == graph_path_ref_count_.end()) {
return;
}
if (--(*it) <= 0) {
graph_path_ref_count_.erase(it);
if (!cached_graph_paths_.contains(path)) {
CleanupGraphFile(path);
}
}
}
void RenderWorkerPool::SetGraphPathCached(const QString &path, bool cached)
{
QMutexLocker locker(&mutex_);
SetGraphPathCachedLocked(path, cached);
}
void RenderWorkerPool::SetGraphPathCachedLocked(const QString &path, bool cached)
{
if (path.isEmpty()) {
return;
}
if (cached) {
cached_graph_paths_.insert(path);
} else {
cached_graph_paths_.remove(path);
if (!graph_path_ref_count_.contains(path)) {
CleanupGraphFile(path);
}
}
}
} // namespace olive