From b26ffb7d64cafbe631f85d9a7fc5ea23874d5143 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Mon, 20 Jul 2026 05:26:42 +0800 Subject: [PATCH] engine: add the renderer family to the C ABI facade - oakengine_renderer_create/set_mode/last_error, render_frame (sync, 60s timeout, CPU frames via the existing worker pool), render_audio (planar float), cancel; frames and audio buffers are owned handles with borrowed data pointers - output colorspace names map to OCIO display transforms, with graceful fallback to reference-space output - oakengine_renderer_test: parameter validation and error paths need no GL and always run; render assertions gate on DynamicRenderer backend availability and SKIP cleanly otherwise - fix a facade bug found by its own test: wait_for_ticket leaked a connected lambda capturing a stack reference, which a subsequent cancelled ticket could fire into reused stack memory --- engine/CMakeLists.txt | 31 ++ engine/include/oakengine/renderer.h | 211 +++++++++++ engine/src/capi/CMakeLists.txt | 2 + engine/src/capi/renderer.cpp | 458 +++++++++++++++++++++++ engine/tests/oakengine_renderer_test.cpp | 357 ++++++++++++++++++ 5 files changed, 1059 insertions(+) create mode 100644 engine/include/oakengine/renderer.h create mode 100644 engine/src/capi/renderer.cpp create mode 100644 engine/tests/oakengine_renderer_test.cpp 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; +}