diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt
index 62b4c78cf..a93d810d8 100644
--- a/engine/CMakeLists.txt
+++ b/engine/CMakeLists.txt
@@ -233,4 +233,35 @@ if (BUILD_TESTS)
target_compile_definitions(oakengine_init_test PRIVATE
OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
)
+
+ make_oakengine_test(oakengine_renderer_test)
+ # The renderer test builds sequence content through the engine C++ API
+ # (allowed for engine-internal tests) and probes the dynamic render
+ # backend the same way the gtest harness does, so it needs the internal
+ # include paths and backend definitions (mirrors tests/gtest).
+ target_include_directories(oakengine_renderer_test PRIVATE
+ ${CMAKE_SOURCE_DIR}/third_party/openfx/include
+ ${CMAKE_SOURCE_DIR}/third_party/openfx/HostSupport/include
+ ${OLIVE_INCLUDE_DIRS}
+ )
+ target_compile_definitions(oakengine_renderer_test PRIVATE
+ ${OLIVE_DEFINITIONS}
+ OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
+ )
+ target_compile_options(oakengine_renderer_test PRIVATE
+ ${OLIVE_COMPILE_OPTIONS}
+ )
+ if (OAK_ENABLE_DYNAMIC_RENDER_BACKEND)
+ target_compile_definitions(oakengine_renderer_test PRIVATE
+ OAK_ENABLE_DYNAMIC_RENDER_BACKEND)
+ add_dependencies(oakengine_renderer_test oakgl)
+ if (TARGET oakvulkan)
+ add_dependencies(oakengine_renderer_test oakvulkan)
+ endif ()
+ endif ()
+ # render_frame goes through the render worker pool; make sure the worker
+ # binary in the build tree is up to date.
+ if (TARGET olive-render-worker)
+ add_dependencies(oakengine_renderer_test olive-render-worker)
+ endif ()
endif ()
diff --git a/engine/include/oakengine/renderer.h b/engine/include/oakengine/renderer.h
new file mode 100644
index 000000000..7aaca40e9
--- /dev/null
+++ b/engine/include/oakengine/renderer.h
@@ -0,0 +1,211 @@
+/***
+
+ 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 OAKENGINE_RENDERER_H
+#define OAKENGINE_RENDERER_H
+
+#include
+
+#include "export.h"
+#include "init.h"
+#include "timeline.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @file renderer.h
+ * @brief C ABI for synchronous frame/audio rendering of a sequence
+ *
+ * An OakEngineRenderer pulls finished CPU frames and audio buffers out of an
+ * OakEngineSequence. It is a thin synchronous facade over the engine's
+ * asynchronous render pipeline (RenderManager::render_frame()/
+ * render_audio() returning RenderTicket objects, engine/render/
+ * rendermanager.h): each render call submits a ticket and blocks until it
+ * finishes, a timeout elapses, or it is cancelled.
+ *
+ * Rendering requires the engine to be initialized with OAKENGINE_INIT_RENDER
+ * (oakengine/init.h); oakengine_renderer_create() itself only validates its
+ * arguments, but oakengine_renderer_render_frame()/_render_audio() fail with
+ * NULL and set the per-renderer error string (query with
+ * oakengine_renderer_last_error()) when the render services are not up.
+ *
+ * Video frames are produced by the engine's render worker pool
+ * (oak-render-worker child processes, which is where a GL context may be
+ * needed); audio is rendered in-process on the audio render thread.
+ *
+ * Conventions (matching the other facade families):
+ * - Returned handles are owned by the caller and must be released with the
+ * matching _free(). NULL is accepted by every function and yields a
+ * no-op / zero result.
+ * - Data pointers (oakengine_frame_data(), oakengine_audio_data()) are
+ * borrowed: they stay valid until the owning frame/buffer is freed.
+ * - The `pixel_format` argument and oakengine_frame_format() carry
+ * olive::core::PixelFormat::Format values: u8 = 0, u10 = 1, u16 = 2,
+ * f16 = 3, f32 = 4.
+ * - Timestamps are frame numbers in the timebase of the frame rate passed
+ * to oakengine_renderer_create() (e.g. timestamp 30 at 30000/1001 means
+ * frame 30, 1001/1000 seconds). This is the same timestamp/timebase
+ * convention as the timeline family (oakengine/timeline.h).
+ * - `mode` follows olive::RenderMode: 0 = offline (preview quality),
+ * 1 = online (export/master quality).
+ */
+
+/**
+ * @brief Opaque renderer handle, bound to one sequence and one output
+ * geometry. Owned by the caller; release with oakengine_renderer_free().
+ */
+typedef struct OakEngineRenderer OakEngineRenderer;
+
+/**
+ * @brief Opaque CPU video frame. Owned by the caller; release with
+ * oakengine_frame_free().
+ */
+typedef struct OakEngineFrame OakEngineFrame;
+
+/**
+ * @brief Opaque planar float audio buffer. Owned by the caller; release with
+ * oakengine_audio_free().
+ */
+typedef struct OakEngineAudioBuffer OakEngineAudioBuffer;
+
+/**
+ * @brief Create a renderer for `seq` producing `width`x`height` frames of
+ * `pixel_format` at the given frame rate.
+ *
+ * `frame_rate_num`/`frame_rate_den` is the frame rate as a rational (e.g.
+ * 30000/1001); it defines both the video time base and the meaning of all
+ * timestamps passed to this renderer. `output_colorspace` names the OCIO
+ * color space the frames are converted into after rendering in the
+ * project's reference space (a ColorTransform to that space, applied as
+ * RenderVideoParams::force_color_output); NULL renders straight into the
+ * reference space without an output transform. If the named color space
+ * cannot be resolved, the renderer falls back to no transform and records
+ * the reason in the error string.
+ *
+ * Returns NULL on invalid arguments (NULL `seq`, non-positive size, frame
+ * rate or pixel format).
+ */
+OAKENGINE_API OakEngineRenderer *oakengine_renderer_create(
+ OakEngineSequence *seq, int width, int height, int pixel_format,
+ int frame_rate_num, int frame_rate_den, const char *output_colorspace);
+
+OAKENGINE_API void oakengine_renderer_free(OakEngineRenderer *self);
+
+/**
+ * @brief Set the render mode: 0 = offline/preview, 1 = online/export
+ * (olive::RenderMode). Defaults to 0. Returns OAKENGINE_E_INVALID for other
+ * values.
+ */
+OAKENGINE_API int oakengine_renderer_set_mode(OakEngineRenderer *self,
+ int mode);
+
+/**
+ * @brief Human-readable reason for the last failed render call on this
+ * renderer (buf/size convention). Empty when the last call succeeded.
+ */
+OAKENGINE_API int oakengine_renderer_last_error(
+ const OakEngineRenderer *self, char *buf, int buf_size);
+
+/**
+ * @brief Synchronously render the frame at `timestamp` (a frame number in
+ * the renderer's timebase).
+ *
+ * Submits a RenderVideoParams ticket (RenderManager::render_frame(), return
+ * type k_frame = CPU frame) and blocks up to 60 seconds for it to finish.
+ * Returns NULL on failure, timeout or cancellation; the reason is available
+ * through oakengine_renderer_last_error().
+ */
+OAKENGINE_API OakEngineFrame *
+oakengine_renderer_render_frame(OakEngineRenderer *self, int64_t timestamp);
+
+/**
+ * @brief Synchronously render audio starting at `start_timestamp` (frame
+ * number) and spanning `length_timestamp` timebase units, in the
+ * sequence's audio parameters (RenderManager::render_audio()).
+ *
+ * Same blocking/timeout/error semantics as
+ * oakengine_renderer_render_frame().
+ */
+OAKENGINE_API OakEngineAudioBuffer *oakengine_renderer_render_audio(
+ OakEngineRenderer *self, int64_t start_timestamp,
+ int64_t length_timestamp);
+
+/**
+ * @brief Cancel the in-flight render call, if any (RenderTicket::cancel()
+ * plus RenderManager::remove_ticket()).
+ *
+ * Intended to be called from another thread while a render call blocks;
+ * safe to call anytime, a no-op when nothing is in flight.
+ */
+OAKENGINE_API void oakengine_renderer_cancel(OakEngineRenderer *self);
+
+/* ---- OakEngineFrame ----------------------------------------------------- */
+
+OAKENGINE_API int oakengine_frame_width(const OakEngineFrame *self);
+OAKENGINE_API int oakengine_frame_height(const OakEngineFrame *self);
+
+/**
+ * @brief Pixel format as an olive::core::PixelFormat::Format value.
+ */
+OAKENGINE_API int oakengine_frame_format(const OakEngineFrame *self);
+OAKENGINE_API int oakengine_frame_channel_count(const OakEngineFrame *self);
+
+/**
+ * @brief Bytes per scanline (stride).
+ */
+OAKENGINE_API int oakengine_frame_linesize_bytes(const OakEngineFrame *self);
+
+/**
+ * @brief Borrowed pointer to the pixel data (linesize_bytes * height bytes).
+ * Valid until the frame is freed.
+ */
+OAKENGINE_API const void *oakengine_frame_data(const OakEngineFrame *self);
+
+OAKENGINE_API void oakengine_frame_free(OakEngineFrame *self);
+
+/* ---- OakEngineAudioBuffer ------------------------------------------------ */
+
+OAKENGINE_API int oakengine_audio_sample_rate(const OakEngineAudioBuffer *self);
+OAKENGINE_API int
+oakengine_audio_channel_count(const OakEngineAudioBuffer *self);
+
+/**
+ * @brief Samples per channel.
+ */
+OAKENGINE_API int64_t
+oakengine_audio_sample_count(const OakEngineAudioBuffer *self);
+
+/**
+ * @brief Borrowed pointer to one channel's planar float samples
+ * (sample_count floats). Valid until the buffer is freed. Returns NULL for
+ * an out-of-range channel.
+ */
+OAKENGINE_API const float *
+oakengine_audio_data(const OakEngineAudioBuffer *self, int channel);
+
+OAKENGINE_API void oakengine_audio_free(OakEngineAudioBuffer *self);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* OAKENGINE_RENDERER_H */
diff --git a/engine/src/capi/CMakeLists.txt b/engine/src/capi/CMakeLists.txt
index 7d7e95914..aeb6b9eaf 100644
--- a/engine/src/capi/CMakeLists.txt
+++ b/engine/src/capi/CMakeLists.txt
@@ -25,8 +25,10 @@ set(OLIVE_SOURCES
include/oakengine/init.h
include/oakengine/project.h
include/oakengine/timeline.h
+ include/oakengine/renderer.h
src/capi/init.cpp
src/capi/project.cpp
src/capi/timeline.cpp
+ src/capi/renderer.cpp
PARENT_SCOPE
)
diff --git a/engine/src/capi/renderer.cpp b/engine/src/capi/renderer.cpp
new file mode 100644
index 000000000..854db0af9
--- /dev/null
+++ b/engine/src/capi/renderer.cpp
@@ -0,0 +1,458 @@
+/***
+
+ 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 "oakengine/renderer.h"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "node/project.h"
+#include "node/project/sequence/sequence.h"
+#include "render/colorprocessor.h"
+#include "render/rendermanager.h"
+#include "render/renderticket.h"
+
+namespace
+{
+
+// Synchronous-render timeout for one ticket.
+constexpr qint64 k_render_timeout_ms = 60000;
+
+struct OakEngineRendererState {
+ olive::Sequence *sequence = nullptr; // borrowed, owned by the project
+ olive::VideoParams video_params;
+ olive::AudioParams audio_params;
+ olive::ColorProcessorPtr color_output; // null = reference space, no transform
+ olive::RenderMode::Mode mode = olive::RenderMode::k_offline;
+ olive::Rational time_base; // frame duration
+ QString last_error;
+ QMutex ticket_mutex;
+ olive::RenderTicketPtr in_flight;
+};
+
+struct OakEngineFrameState {
+ olive::FramePtr frame;
+};
+
+struct OakEngineAudioState {
+ olive::SampleBuffer samples;
+};
+
+OakEngineRendererState *impl(OakEngineRenderer *h)
+{
+ return reinterpret_cast(h);
+}
+
+const OakEngineRendererState *impl(const OakEngineRenderer *h)
+{
+ return reinterpret_cast(h);
+}
+
+OakEngineFrameState *impl(OakEngineFrame *h)
+{
+ return reinterpret_cast(h);
+}
+
+const OakEngineFrameState *impl(const OakEngineFrame *h)
+{
+ return reinterpret_cast(h);
+}
+
+OakEngineAudioState *impl(OakEngineAudioBuffer *h)
+{
+ return reinterpret_cast(h);
+}
+
+const OakEngineAudioState *impl(const OakEngineAudioBuffer *h)
+{
+ return reinterpret_cast(h);
+}
+
+// buf/size convention: returns the would-be length excluding the NUL.
+int string_to_buf(const QString &s, char *buf, int buf_size)
+{
+ const QByteArray utf = s.toUtf8();
+ if (buf && buf_size > 0) {
+ snprintf(buf, size_t(buf_size), "%s", utf.constData());
+ }
+ return int(utf.size());
+}
+
+void set_error(OakEngineRendererState *state, const QString &error)
+{
+ if (state) {
+ state->last_error = error;
+ }
+}
+
+// The color manager of the project the sequence belongs to.
+olive::ColorManager *color_manager_of(olive::Sequence *seq)
+{
+ if (olive::Project *p = olive::Project::get_project_from_object(seq)) {
+ return p->color_manager();
+ }
+ return nullptr;
+}
+
+// Block until the ticket finishes, the timeout elapses or the ticket is
+// cancelled. Returns true when the ticket finished (inspect has_result()
+// for whether it carries a value).
+bool wait_for_ticket(OakEngineRendererState *state,
+ const olive::RenderTicketPtr &ticket)
+{
+ std::atomic finished{ false };
+ // Functor connection without a context object is a direct connection:
+ // the flag is set on whichever thread finishes the ticket.
+ const QMetaObject::Connection conn =
+ QObject::connect(ticket.get(), &olive::RenderTicket::finished,
+ [&finished]() { finished.store(true); });
+
+ QElapsedTimer timer;
+ timer.start();
+ while (!finished.load() && !ticket->is_cancelled() &&
+ !timer.hasExpired(k_render_timeout_ms)) {
+ QThread::msleep(5);
+ }
+
+ // The connection must not outlive this stack frame: the lambda captures
+ // a local by reference, and the engine may finish the ticket again (e.g.
+ // the worker pool finishing a cancelled ticket) after we stopped waiting.
+ QObject::disconnect(conn);
+
+ {
+ QMutexLocker locker(&state->ticket_mutex);
+ if (state->in_flight == ticket) {
+ state->in_flight.reset();
+ }
+ }
+
+ return finished.load();
+}
+
+} // namespace
+
+extern "C"
+{
+
+OakEngineRenderer *oakengine_renderer_create(
+ OakEngineSequence *seq, int width, int height, int pixel_format,
+ int frame_rate_num, int frame_rate_den, const char *output_colorspace)
+{
+ olive::Sequence *sequence = reinterpret_cast(seq);
+ if (!sequence || width <= 0 || height <= 0 || frame_rate_num <= 0 ||
+ frame_rate_den <= 0 || pixel_format < 0 ||
+ pixel_format >= olive::PixelFormat::count) {
+ return nullptr;
+ }
+
+ auto *state = new OakEngineRendererState();
+ state->sequence = sequence;
+ state->time_base = olive::Rational(frame_rate_den, frame_rate_num);
+ state->video_params =
+ olive::VideoParams(width, height, state->time_base,
+ static_cast(pixel_format),
+ olive::VideoParams::k_internal_channel_count);
+ state->audio_params = sequence->get_audio_params();
+ if (state->audio_params.sample_rate() <= 0) {
+ // Sequences created outside the facade may lack audio parameters;
+ // fall back to the engine defaults (48 kHz stereo float).
+ state->audio_params = olive::AudioParams(
+ 48000, olive::core::k_channel_layout_stereo,
+ olive::core::SampleFormat::f32_p);
+ }
+
+ if (output_colorspace && output_colorspace[0] != '\0') {
+ if (olive::ColorManager *colorman = color_manager_of(sequence)) {
+ try {
+ state->color_output = olive::ColorProcessor::create(
+ colorman, colorman->get_reference_color_space(),
+ olive::ColorTransform(
+ QString::fromUtf8(output_colorspace)));
+ } catch (const std::exception &e) {
+ state->color_output = nullptr;
+ state->last_error = QStringLiteral(
+ "failed to create output color transform '%1': %2; "
+ "rendering without an output transform")
+ .arg(output_colorspace, e.what());
+ }
+ } else {
+ state->last_error = QStringLiteral(
+ "sequence is not part of a project; rendering without an "
+ "output transform");
+ }
+ }
+
+ return reinterpret_cast(state);
+}
+
+void oakengine_renderer_free(OakEngineRenderer *self)
+{
+ delete impl(self);
+}
+
+int oakengine_renderer_set_mode(OakEngineRenderer *self, int mode)
+{
+ if (!self) {
+ return OAKENGINE_E_INVALID;
+ }
+ switch (mode) {
+ case 0:
+ impl(self)->mode = olive::RenderMode::k_offline;
+ return OAKENGINE_OK;
+ case 1:
+ impl(self)->mode = olive::RenderMode::k_online;
+ return OAKENGINE_OK;
+ default:
+ return OAKENGINE_E_INVALID;
+ }
+}
+
+int oakengine_renderer_last_error(const OakEngineRenderer *self, char *buf,
+ int buf_size)
+{
+ if (!self) {
+ return OAKENGINE_E_INVALID;
+ }
+ return string_to_buf(impl(self)->last_error, buf, buf_size);
+}
+
+OakEngineFrame *oakengine_renderer_render_frame(OakEngineRenderer *self,
+ int64_t timestamp)
+{
+ if (!self) {
+ return nullptr;
+ }
+ OakEngineRendererState *state = impl(self);
+ set_error(state, QString());
+
+ if (!olive::RenderManager::instance()) {
+ set_error(state,
+ QStringLiteral("engine not initialized with "
+ "OAKENGINE_INIT_RENDER"));
+ return nullptr;
+ }
+
+ try {
+ const olive::Rational time =
+ olive::core::Timecode::timestamp_to_time(timestamp,
+ state->time_base);
+
+ olive::RenderManager::RenderVideoParams params(
+ state->sequence, state->video_params, state->audio_params, time,
+ color_manager_of(state->sequence), state->mode);
+ params.force_size = QSize(state->video_params.width(),
+ state->video_params.height());
+ params.force_format = state->video_params.format();
+ params.force_channel_count = state->video_params.channel_count();
+ params.force_color_output = state->color_output;
+ params.return_type = olive::RenderManager::k_frame;
+
+ olive::RenderTicketPtr ticket =
+ olive::RenderManager::instance()->render_frame(params);
+ {
+ QMutexLocker locker(&state->ticket_mutex);
+ state->in_flight = ticket;
+ }
+
+ if (!wait_for_ticket(state, ticket) || !ticket->has_result()) {
+ set_error(state,
+ ticket->is_cancelled() ?
+ QStringLiteral("render cancelled") :
+ QStringLiteral(
+ "render produced no frame (timeout or failure)"));
+ return nullptr;
+ }
+
+ olive::FramePtr frame = ticket->get().value();
+ if (!frame || !frame->is_allocated()) {
+ set_error(state, QStringLiteral("render result was empty"));
+ return nullptr;
+ }
+
+ return reinterpret_cast(
+ new OakEngineFrameState{ frame });
+ } catch (const std::exception &e) {
+ set_error(state,
+ QStringLiteral("render failed: %1").arg(e.what()));
+ return nullptr;
+ }
+}
+
+OakEngineAudioBuffer *oakengine_renderer_render_audio(
+ OakEngineRenderer *self, int64_t start_timestamp, int64_t length_timestamp)
+{
+ if (!self || length_timestamp < 0) {
+ return nullptr;
+ }
+ OakEngineRendererState *state = impl(self);
+ set_error(state, QString());
+
+ if (!olive::RenderManager::instance()) {
+ set_error(state,
+ QStringLiteral("engine not initialized with "
+ "OAKENGINE_INIT_RENDER"));
+ return nullptr;
+ }
+
+ try {
+ const olive::Rational in_time =
+ olive::core::Timecode::timestamp_to_time(start_timestamp,
+ state->time_base);
+ const olive::Rational length_time =
+ olive::core::Timecode::timestamp_to_time(length_timestamp,
+ state->time_base);
+
+ olive::RenderManager::RenderAudioParams params(
+ state->sequence, olive::TimeRange(in_time, in_time + length_time),
+ state->audio_params, state->mode);
+
+ olive::RenderTicketPtr ticket =
+ olive::RenderManager::instance()->render_audio(params);
+ {
+ QMutexLocker locker(&state->ticket_mutex);
+ state->in_flight = ticket;
+ }
+
+ if (!wait_for_ticket(state, ticket) || !ticket->has_result()) {
+ set_error(state,
+ ticket->is_cancelled() ?
+ QStringLiteral("render cancelled") :
+ QStringLiteral(
+ "audio render produced nothing (timeout or failure)"));
+ return nullptr;
+ }
+
+ olive::SampleBuffer samples =
+ ticket->get().value();
+ if (!samples.is_allocated()) {
+ set_error(state, QStringLiteral("audio render result was empty"));
+ return nullptr;
+ }
+
+ return reinterpret_cast(
+ new OakEngineAudioState{ samples });
+ } catch (const std::exception &e) {
+ set_error(state,
+ QStringLiteral("audio render failed: %1").arg(e.what()));
+ return nullptr;
+ }
+}
+
+void oakengine_renderer_cancel(OakEngineRenderer *self)
+{
+ if (!self) {
+ return;
+ }
+ OakEngineRendererState *state = impl(self);
+ olive::RenderTicketPtr ticket;
+ {
+ QMutexLocker locker(&state->ticket_mutex);
+ ticket = state->in_flight;
+ }
+ if (!ticket) {
+ return;
+ }
+ ticket->cancel();
+ if (olive::RenderManager::instance()) {
+ olive::RenderManager::instance()->remove_ticket(ticket);
+ }
+}
+
+/* ---- OakEngineFrame ----------------------------------------------------- */
+
+int oakengine_frame_width(const OakEngineFrame *self)
+{
+ return self && impl(self)->frame ? impl(self)->frame->width() : 0;
+}
+
+int oakengine_frame_height(const OakEngineFrame *self)
+{
+ return self && impl(self)->frame ? impl(self)->frame->height() : 0;
+}
+
+int oakengine_frame_format(const OakEngineFrame *self)
+{
+ return self && impl(self)->frame ?
+ static_cast(impl(self)->frame->format()) :
+ -1;
+}
+
+int oakengine_frame_channel_count(const OakEngineFrame *self)
+{
+ return self && impl(self)->frame ? impl(self)->frame->channel_count() : 0;
+}
+
+int oakengine_frame_linesize_bytes(const OakEngineFrame *self)
+{
+ return self && impl(self)->frame ? impl(self)->frame->linesize_bytes() : 0;
+}
+
+const void *oakengine_frame_data(const OakEngineFrame *self)
+{
+ return self && impl(self)->frame && impl(self)->frame->is_allocated() ?
+ impl(self)->frame->const_data() :
+ nullptr;
+}
+
+void oakengine_frame_free(OakEngineFrame *self)
+{
+ delete impl(self);
+}
+
+/* ---- OakEngineAudioBuffer ------------------------------------------------ */
+
+int oakengine_audio_sample_rate(const OakEngineAudioBuffer *self)
+{
+ return self ? impl(self)->samples.audio_params().sample_rate() : 0;
+}
+
+int oakengine_audio_channel_count(const OakEngineAudioBuffer *self)
+{
+ return self ? impl(self)->samples.channel_count() : 0;
+}
+
+int64_t oakengine_audio_sample_count(const OakEngineAudioBuffer *self)
+{
+ return self ? int64_t(impl(self)->samples.sample_count()) : 0;
+}
+
+const float *oakengine_audio_data(const OakEngineAudioBuffer *self,
+ int channel)
+{
+ if (!self || channel < 0 ||
+ channel >= impl(self)->samples.channel_count()) {
+ return nullptr;
+ }
+ return impl(self)->samples.data(channel);
+}
+
+void oakengine_audio_free(OakEngineAudioBuffer *self)
+{
+ delete impl(self);
+}
+
+} // extern "C"
diff --git a/engine/tests/oakengine_renderer_test.cpp b/engine/tests/oakengine_renderer_test.cpp
new file mode 100644
index 000000000..c6d015a73
--- /dev/null
+++ b/engine/tests/oakengine_renderer_test.cpp
@@ -0,0 +1,357 @@
+/***
+
+ 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 .
+
+***/
+
+// Pure C ABI test for the liboakengine renderer facade. The validation part
+// (argument checking, not-initialized errors, NULL safety, cancel
+// idempotency) requires no GL and must always pass. The render part needs a
+// working render backend (the engine renders video in oak-render-worker
+// child processes); when no backend is available it prints a SKIP notice
+// and exits 0, mirroring the is_render_backend_available()/GTEST_SKIP logic
+// of tests/gtest/render_worker_footage_test.cpp.
+//
+// Being an engine-internal test, the GL-gated part builds sequence content
+// through the engine C++ API (the facade has no node-graph editing API yet):
+// a solid red generator feeds the sequence's texture input, and the real
+// footage tests/demo.mp4 feeds its samples input.
+
+#include
+#include
+#include
+#include
+#include
+
+#if defined(_WIN32)
+#include
+#include
+#else
+#include
+#endif
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include "node/generator/solid/solid.h"
+#include "node/node.h"
+#include "node/output/viewer/viewer.h"
+#include "node/project.h"
+#include "node/project/footage/footage.h"
+#include "node/project/sequence/sequence.h"
+
+#include "config/config.h"
+
+#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND
+#include "render/backend/dynamicrenderer.h"
+#include "render/backend/renderbackend_c.h"
+#endif
+
+#include "oakengine/init.h"
+#include "oakengine/project.h"
+#include "oakengine/renderer.h"
+#include "oakengine/timeline.h"
+
+#ifndef OAK_TEST_SOURCE_DIR
+#define OAK_TEST_SOURCE_DIR "."
+#endif
+
+static char g_tmpdir[4096];
+
+static void make_tmpdir(void)
+{
+#if defined(_WIN32)
+ char base[MAX_PATH];
+ const DWORD len = GetTempPathA(MAX_PATH, base);
+ assert(len > 0 && len < MAX_PATH);
+ snprintf(g_tmpdir, sizeof(g_tmpdir), "%soakengine_renderer_test_%lu",
+ base, (unsigned long)GetCurrentProcessId());
+ assert(_mkdir(g_tmpdir) == 0);
+#else
+ strcpy(g_tmpdir, "/tmp/oakengine_renderer_test_XXXXXX");
+ assert(mkdtemp(g_tmpdir) != NULL);
+#endif
+}
+
+// Same probe as tests/gtest/render_worker_footage_test.cpp.
+static bool is_render_backend_available(const QString &backend)
+{
+#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND
+ olive::DynamicRenderer renderer(backend);
+ if (!renderer.load()) {
+ return false;
+ }
+
+ OakRenderBackendInfo info = {};
+ if (!renderer.get_backend_info(&info)) {
+ return false;
+ }
+
+ if (backend == QStringLiteral("vulkan") &&
+ info.kind != oak_render_backend_vulkan) {
+ return false;
+ }
+
+ if (backend == QStringLiteral("opengl") &&
+ info.kind != oak_render_backend_opengl) {
+ return false;
+ }
+
+ return renderer.init();
+#else
+ Q_UNUSED(backend)
+ return false;
+#endif
+}
+
+static bool worker_binary_exists()
+{
+ QDir dir(QCoreApplication::applicationDirPath());
+ dir.cd(QStringLiteral("../worker"));
+#if defined(_WIN32)
+ return QFileInfo::exists(dir.filePath(QStringLiteral("oak-render-worker.exe")));
+#else
+ return QFileInfo::exists(dir.filePath(QStringLiteral("oak-render-worker")));
+#endif
+}
+
+// Samples up to 4096 positions of the frame and counts non-zero bytes.
+static int frame_nonzero_bytes(const OakEngineFrame *frame)
+{
+ const unsigned char *data =
+ (const unsigned char *)oakengine_frame_data(frame);
+ if (!data) {
+ return -1;
+ }
+ const int size = oakengine_frame_linesize_bytes(frame) *
+ oakengine_frame_height(frame);
+ const int step = size / 4096 > 1 ? size / 4096 : 1;
+ int nonzero = 0;
+ for (int i = 0; i < size; i += step) {
+ if (data[i] != 0) {
+ nonzero++;
+ }
+ }
+ return nonzero;
+}
+
+// Argument validation and behavior without OAKENGINE_INIT_RENDER. Requires
+// no GL at all.
+static void test_validation(OakEngineSequence *seq)
+{
+ // create() argument validation.
+ assert(oakengine_renderer_create(NULL, 320, 180, 4, 30000, 1001, NULL) ==
+ NULL);
+ assert(oakengine_renderer_create(seq, 0, 180, 4, 30000, 1001, NULL) ==
+ NULL);
+ assert(oakengine_renderer_create(seq, 320, -1, 4, 30000, 1001, NULL) ==
+ NULL);
+ assert(oakengine_renderer_create(seq, 320, 180, 4, 0, 1001, NULL) ==
+ NULL);
+ assert(oakengine_renderer_create(seq, 320, 180, 4, 30000, -1001, NULL) ==
+ NULL);
+ assert(oakengine_renderer_create(seq, 320, 180, -1, 30000, 1001, NULL) ==
+ NULL);
+ assert(oakengine_renderer_create(seq, 320, 180, 5, 30000, 1001, NULL) ==
+ NULL);
+
+ OakEngineRenderer *r =
+ oakengine_renderer_create(seq, 320, 180, 4, 30000, 1001, NULL);
+ assert(r != NULL);
+
+ // Mode follows olive::RenderMode: 0/1 accepted, everything else rejected.
+ assert(oakengine_renderer_set_mode(r, 0) == OAKENGINE_OK);
+ assert(oakengine_renderer_set_mode(r, 1) == OAKENGINE_OK);
+ assert(oakengine_renderer_set_mode(r, -1) == OAKENGINE_E_INVALID);
+ assert(oakengine_renderer_set_mode(r, 2) == OAKENGINE_E_INVALID);
+ assert(oakengine_renderer_set_mode(NULL, 0) == OAKENGINE_E_INVALID);
+
+ // Renders fail while the engine lacks the RENDER bit, with a
+ // human-readable reason.
+ assert(oakengine_renderer_render_frame(r, 0) == NULL);
+ char err[256];
+ assert(oakengine_renderer_last_error(r, err, sizeof(err)) > 0);
+ assert(strstr(err, "OAKENGINE_INIT_RENDER") != NULL);
+ assert(oakengine_renderer_render_audio(r, 0, 30) == NULL);
+ assert(oakengine_renderer_last_error(r, err, sizeof(err)) > 0);
+
+ // cancel is a no-op when nothing is in flight and is idempotent.
+ oakengine_renderer_cancel(r);
+ oakengine_renderer_cancel(r);
+
+ oakengine_renderer_free(r);
+
+ // NULL safety across all three handle families.
+ assert(oakengine_renderer_last_error(NULL, err, sizeof(err)) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_renderer_render_frame(NULL, 0) == NULL);
+ assert(oakengine_renderer_render_audio(NULL, 0, 30) == NULL);
+ oakengine_renderer_cancel(NULL);
+ oakengine_renderer_free(NULL);
+ assert(oakengine_frame_width(NULL) == 0);
+ assert(oakengine_frame_height(NULL) == 0);
+ assert(oakengine_frame_format(NULL) == -1);
+ assert(oakengine_frame_channel_count(NULL) == 0);
+ assert(oakengine_frame_linesize_bytes(NULL) == 0);
+ assert(oakengine_frame_data(NULL) == NULL);
+ oakengine_frame_free(NULL);
+ assert(oakengine_audio_sample_rate(NULL) == 0);
+ assert(oakengine_audio_channel_count(NULL) == 0);
+ assert(oakengine_audio_sample_count(NULL) == 0);
+ assert(oakengine_audio_data(NULL, 0) == NULL);
+ oakengine_audio_free(NULL);
+}
+
+int main(void)
+{
+ make_tmpdir();
+
+ // Sandbox the config/cache/data locations (see oakengine_init_test).
+#if !defined(_WIN32)
+ assert(setenv("XDG_CONFIG_HOME", g_tmpdir, 1) == 0);
+ assert(setenv("XDG_CACHE_HOME", g_tmpdir, 1) == 0);
+ assert(setenv("XDG_DATA_HOME", g_tmpdir, 1) == 0);
+#endif
+
+ // HEADLESS is enough for the validation part and creates the offscreen
+ // application object the backend probe below depends on.
+ assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK);
+
+ OakEngineProject *project = oakengine_project_create();
+ assert(project != NULL);
+ assert(oakengine_project_new(project) == OAKENGINE_OK);
+ OakEngineSequence *seq = oakengine_sequence_new(project, "Render");
+ assert(seq != NULL);
+
+ test_validation(seq);
+
+ // ---- GL-gated part ---------------------------------------------------
+ if (!is_render_backend_available(QStringLiteral("opengl"))) {
+ printf("oakengine_renderer_test: SKIP: OpenGL render backend not "
+ "available, render assertions skipped\n");
+ oakengine_project_free(project);
+ oakengine_shutdown();
+ return 0;
+ }
+ if (!worker_binary_exists()) {
+ printf("oakengine_renderer_test: SKIP: oak-render-worker binary not "
+ "found, render assertions skipped\n");
+ oakengine_project_free(project);
+ oakengine_shutdown();
+ return 0;
+ }
+
+ // The engine renders through the backend requested in the config.
+ olive::Config::current()[QStringLiteral("GraphicsBackend")] =
+ QStringLiteral("opengl");
+
+ // Upgrade HEADLESS -> HEADLESS|RENDER (idempotent flag add).
+ assert(oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) ==
+ OAKENGINE_OK);
+ assert(oakengine_init_flags() ==
+ (OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER));
+
+ // Build content through the engine C++ API (engine-internal test; the
+ // facade has no node editing API yet). The handles are the engine
+ // pointers by design.
+ auto *proj = reinterpret_cast(project);
+ auto *sequence = reinterpret_cast(seq);
+
+ // Solid red generator -> texture input: every frame is solid red.
+ auto *solid = new olive::SolidGenerator();
+ solid->setParent(proj);
+ olive::Node::connect_edge(
+ solid, olive::NodeInput(sequence, olive::ViewerOutput::k_texture_input));
+
+ // Real footage -> samples input: real audio for render_audio.
+ const QString demo_path =
+ QDir(QStringLiteral(OAK_TEST_SOURCE_DIR))
+ .filePath(QStringLiteral("tests/demo.mp4"));
+ assert(QFileInfo::exists(demo_path));
+ auto *footage = new olive::Footage(demo_path);
+ footage->setParent(proj);
+ assert(footage->is_valid());
+ olive::Node::connect_edge(
+ footage,
+ olive::NodeInput(sequence, olive::ViewerOutput::k_samples_input));
+
+ // render_frame: geometry/format and non-black pixels.
+ OakEngineRenderer *renderer =
+ oakengine_renderer_create(seq, 320, 180, 4, 30000, 1001, NULL);
+ assert(renderer != NULL);
+ OakEngineFrame *frame = oakengine_renderer_render_frame(renderer, 0);
+ char err[256];
+ if (!frame) {
+ fprintf(stderr, "render_frame failed: %s\n",
+ oakengine_renderer_last_error(renderer, err, sizeof(err)) > 0 ?
+ err :
+ "(no error)");
+ }
+ assert(frame != NULL);
+ assert(oakengine_frame_width(frame) == 320);
+ assert(oakengine_frame_height(frame) == 180);
+ assert(oakengine_frame_format(frame) == 4); // f32
+ assert(oakengine_frame_channel_count(frame) == 4);
+ assert(oakengine_frame_linesize_bytes(frame) >= 320 * 4 * 4);
+ assert(oakengine_frame_data(frame) != NULL);
+ assert(frame_nonzero_bytes(frame) > 0); // solid red, not black
+ oakengine_frame_free(frame);
+
+ // render_audio: 30 frames at 1001/30000 = 1.001s at 48 kHz stereo.
+ OakEngineAudioBuffer *audio =
+ oakengine_renderer_render_audio(renderer, 0, 30);
+ if (!audio) {
+ fprintf(stderr, "render_audio failed: %s\n",
+ oakengine_renderer_last_error(renderer, err, sizeof(err)) > 0 ?
+ err :
+ "(no error)");
+ }
+ assert(audio != NULL);
+ assert(oakengine_audio_sample_rate(audio) == 48000);
+ assert(oakengine_audio_channel_count(audio) == 2);
+ const int64_t samples = oakengine_audio_sample_count(audio);
+ assert(samples > 48048 - 4800 && samples < 48048 + 4800);
+ assert(oakengine_audio_data(audio, 0) != NULL);
+ assert(oakengine_audio_data(audio, 1) != NULL);
+ assert(oakengine_audio_data(audio, 2) == NULL); // out of range
+ oakengine_audio_free(audio);
+
+ // Cancelling mid-render from another thread must not crash, and the
+ // renderer must stay usable afterwards.
+ std::thread canceller([renderer]() {
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ oakengine_renderer_cancel(renderer);
+ });
+ OakEngineFrame *maybe = oakengine_renderer_render_frame(renderer, 60);
+ canceller.join();
+ oakengine_frame_free(maybe); // may be NULL (cancelled) or a frame
+ OakEngineFrame *after = oakengine_renderer_render_frame(renderer, 0);
+ assert(after != NULL);
+ oakengine_frame_free(after);
+ oakengine_renderer_cancel(renderer); // idempotent no-op
+
+ oakengine_renderer_free(renderer);
+ oakengine_project_free(project);
+ assert(oakengine_shutdown() == OAKENGINE_OK);
+
+ printf("oakengine_renderer_test: all assertions passed\n");
+ return 0;
+}