diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt
index 23b0c5041..a15abe5cf 100644
--- a/engine/CMakeLists.txt
+++ b/engine/CMakeLists.txt
@@ -334,4 +334,32 @@ if (BUILD_TESTS)
if (TARGET olive-render-worker)
add_dependencies(oakengine_preview_test olive-render-worker)
endif ()
+
+ make_oakengine_test(oakengine_playback_test)
+ # The playback test builds sequence content through the engine C++ API
+ # and pulls frames through the render worker pool (same needs as
+ # oakengine_renderer_test).
+ target_include_directories(oakengine_playback_test PRIVATE
+ ${CMAKE_SOURCE_DIR}/third_party/openfx/include
+ ${CMAKE_SOURCE_DIR}/third_party/openfx/HostSupport/include
+ ${OLIVE_INCLUDE_DIRS}
+ )
+ target_compile_definitions(oakengine_playback_test PRIVATE
+ ${OLIVE_DEFINITIONS}
+ OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
+ )
+ target_compile_options(oakengine_playback_test PRIVATE
+ ${OLIVE_COMPILE_OPTIONS}
+ )
+ if (OAK_ENABLE_DYNAMIC_RENDER_BACKEND)
+ target_compile_definitions(oakengine_playback_test PRIVATE
+ OAK_ENABLE_DYNAMIC_RENDER_BACKEND)
+ add_dependencies(oakengine_playback_test oakgl)
+ if (TARGET oakvulkan)
+ add_dependencies(oakengine_playback_test oakvulkan)
+ endif ()
+ endif ()
+ if (TARGET olive-render-worker)
+ add_dependencies(oakengine_playback_test olive-render-worker)
+ endif ()
endif ()
diff --git a/engine/include/oakengine/playback.h b/engine/include/oakengine/playback.h
new file mode 100644
index 000000000..c08bbd864
--- /dev/null
+++ b/engine/include/oakengine/playback.h
@@ -0,0 +1,218 @@
+/***
+
+ 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_PLAYBACK_H
+#define OAKENGINE_PLAYBACK_H
+
+#include
+
+#include "export.h"
+#include "timeline.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @file playback.h
+ * @brief C ABI for asynchronous headless playback of a sequence
+ *
+ * An OakEnginePlayback drives a pull thread that renders upcoming frames
+ * and 1/4-second audio blocks through the renderer family and delivers
+ * them through user callbacks -- the execution engine a viewer can be
+ * built on. The MVP deliberately covers forward playback at constant or
+ * sped-up rates only: no negative speed, no variable shuttle, no
+ * tape-style scrub audio.
+ *
+ * Threading: every callback fires ON THE PULL THREAD (one background
+ * thread per instance). Consumers that need the main thread (widgets,
+ * most GUI work) must marshal the data themselves. The payload pointers
+ * inside oak_playback_frame/oak_playback_audio are valid only until the
+ * callback returns (the engine owns and recycles the buffers); copy what
+ * you keep.
+ *
+ * Audio output: when an olive::AudioManager instance exists (the
+ * application creates it; bare facade processes usually have none), each
+ * audio block is also pushed to it (packed float32) and the playback
+ * position is read from its output clock (AudioManager::seconds(),
+ * latency-compensated). Without an instance, only the callbacks fire and
+ * the position falls back to the wall clock. Sequence position advances
+ * by consumed-output seconds * speed from the start timestamp either
+ * way.
+ *
+ * Event loop requirement: the process must pump a Qt event loop on its
+ * main thread while playing (the engine's conform/decode completions
+ * are posted there); GUI consumers get this for free, console consumers
+ * should drive QCoreApplication::processEvents() periodically (the same
+ * rule the synchronous facade waits follow).
+ *
+ * Frame pacing at speed != 1 mirrors the application's viewer: the
+ * timestamp step between delivered frames is llround(speed) (minimum
+ * 1), so > 1x skips frames while pacing stays clock-driven; audio is
+ * rendered per wall 1/4 second covering interval*speed of sequence time
+ * (no tempo/pitch correction at speed, an MVP limitation).
+ *
+ * Conventions match the other families: 0 (OAKENGINE_OK) / negative
+ * OAKENGINE_E_* codes, NULL handles as no-ops or OAKENGINE_E_INVALID,
+ * per-handle human-readable reason via oakengine_playback_last_error().
+ * All timestamps are frame numbers in the sequence's frame-rate
+ * timebase, like the rest of the timeline family.
+ */
+
+/**
+ * @brief Opaque playback engine handle. Free with
+ * oakengine_playback_free(); the sequence is borrowed (owned by its
+ * project).
+ */
+typedef struct OakEnginePlayback OakEnginePlayback;
+
+/**
+ * @brief POD video frame delivered to the frame callback.
+ *
+ * `timestamp` is the frame number in the sequence's timebase; `format`
+ * is an olive::core::PixelFormat::Format value; `linesize` is the
+ * stride in bytes. `data` is owned by the engine and valid only until
+ * the callback returns.
+ */
+typedef struct oak_playback_frame {
+ int64_t timestamp;
+ int width;
+ int height;
+ int format;
+ int linesize;
+ const void *data;
+} oak_playback_frame;
+
+/**
+ * @brief POD audio block delivered to the audio callback.
+ *
+ * `start_ts` is the block's start in sequence timebase units,
+ * `sample_count` the frames per channel, `channel_data` planar float
+ * pointers (engine-owned, valid only until the callback returns).
+ */
+typedef struct oak_playback_audio {
+ int64_t start_ts;
+ int channels;
+ int sample_rate;
+ int64_t sample_count;
+ const float *const *channel_data;
+} oak_playback_audio;
+
+/**
+ * @brief Create a playback engine for `seq` producing `width`x`height`
+ * frames at `fps_num`/`fps_den`.
+ *
+ * Rendering goes through the renderer family, so actual playback
+ * requires the engine initialized with OAKENGINE_INIT_RENDER (starting
+ * without it fails with OAKENGINE_E_STATE). Returns NULL on invalid
+ * arguments (NULL sequence, non-positive size or frame rate).
+ */
+OAKENGINE_API OakEnginePlayback *oakengine_playback_create(
+ OakEngineSequence *seq, int width, int height, int fps_num,
+ int fps_den);
+
+/**
+ * @brief Stop playback, join the pull thread and free the instance.
+ * NULL-safe. Must NOT be called from inside a frame/audio callback (the
+ * pull thread cannot join itself; use oakengine_playback_stop() there
+ * and free from another thread afterwards).
+ */
+OAKENGINE_API void oakengine_playback_free(OakEnginePlayback *self);
+
+/**
+ * @brief Install the frame callback (NULL to clear). Fires on the pull
+ * thread; the payload is valid only during the call.
+ */
+OAKENGINE_API int oakengine_playback_set_frame_callback(
+ OakEnginePlayback *self,
+ void (*on_frame)(const oak_playback_frame *frame, void *userdata),
+ void *userdata);
+
+/**
+ * @brief Install the audio callback (NULL to clear). Fires on the pull
+ * thread; the payload is valid only during the call.
+ */
+OAKENGINE_API int oakengine_playback_set_audio_callback(
+ OakEnginePlayback *self,
+ void (*on_audio)(const oak_playback_audio *audio, void *userdata),
+ void *userdata);
+
+/**
+ * @brief Start (or re-base) playback at `start_ts` with `speed`.
+ *
+ * `speed` must be > 0 (OAKENGINE_E_INVALID otherwise; negative speed is
+ * outside the MVP). Starting while already playing re-anchors at
+ * `start_ts`. Requires OAKENGINE_INIT_RENDER (OAKENGINE_E_STATE).
+ */
+OAKENGINE_API int oakengine_playback_start(OakEnginePlayback *self,
+ int64_t start_ts, double speed);
+
+/**
+ * @brief Pause playback (idempotent). The position freezes; resume by
+ * calling oakengine_playback_start() at the frozen (or any) timestamp.
+ */
+OAKENGINE_API int oakengine_playback_pause(OakEnginePlayback *self);
+
+/**
+ * @brief Stop playback (idempotent): the pull thread exits and the
+ * position resets to the last start timestamp (0 before the first
+ * start). May be called from inside a callback (the pull thread then
+ * detaches and exits on its own; oakengine_playback_free() waits it
+ * out).
+ */
+OAKENGINE_API int oakengine_playback_stop(OakEnginePlayback *self);
+
+/**
+ * @brief Current playback position as a frame timestamp.
+ *
+ * Read from the audio output clock when an AudioManager instance is
+ * pushing audio (the master clock), otherwise from the wall clock; a
+ * frozen value while paused, the last start timestamp while stopped.
+ */
+OAKENGINE_API int oakengine_playback_get_position(
+ const OakEnginePlayback *self, int64_t *ts);
+
+/**
+ * @brief Change the speed mid-playback (`speed` > 0). Re-anchors at the
+ * current position so delivery stays monotonic. OAKENGINE_E_INVALID
+ * for speed <= 0.
+ */
+OAKENGINE_API int oakengine_playback_set_speed(OakEnginePlayback *self,
+ double speed);
+
+/**
+ * @brief 1 while playing (0 when paused, stopped, or after the
+ * end-of-stream auto-stop). 0 on a NULL handle.
+ */
+OAKENGINE_API int oakengine_playback_is_playing(
+ const OakEnginePlayback *self);
+
+/**
+ * @brief Human-readable reason of the last failed call on this handle
+ * (buf/size convention). Empty when the last call succeeded.
+ */
+OAKENGINE_API int oakengine_playback_last_error(
+ const OakEnginePlayback *self, char *buf, int buf_size);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* OAKENGINE_PLAYBACK_H */
diff --git a/engine/src/capi/CMakeLists.txt b/engine/src/capi/CMakeLists.txt
index dd37c165d..29175dba8 100644
--- a/engine/src/capi/CMakeLists.txt
+++ b/engine/src/capi/CMakeLists.txt
@@ -29,6 +29,7 @@ set(OLIVE_SOURCES
include/oakengine/footage.h
include/oakengine/exporter.h
include/oakengine/node.h
+ include/oakengine/playback.h
include/oakengine/preview.h
src/capi/init.cpp
src/capi/project.cpp
@@ -37,6 +38,7 @@ set(OLIVE_SOURCES
src/capi/footage.cpp
src/capi/export.cpp
src/capi/node.cpp
+ src/capi/playback.cpp
src/capi/preview.cpp
PARENT_SCOPE
)
diff --git a/engine/src/capi/playback.cpp b/engine/src/capi/playback.cpp
new file mode 100644
index 000000000..398f32784
--- /dev/null
+++ b/engine/src/capi/playback.cpp
@@ -0,0 +1,527 @@
+/***
+
+ 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/playback.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "audio/audiomanager.h"
+#include "node/project/sequence/sequence.h"
+#include "oakengine/renderer.h"
+#include "render/rendermanager.h"
+
+namespace
+{
+
+// Audio block length rendered per wall interval (the viewer's
+// k_audio_playback_interval), and how many blocks are kept queued.
+constexpr double k_audio_interval_s = 0.25;
+constexpr double k_audio_ahead_s = 2 * k_audio_interval_s;
+
+constexpr int k_state_stopped = 0;
+constexpr int k_state_playing = 1;
+constexpr int k_state_paused = 2;
+
+struct OakEnginePlaybackState {
+ olive::Sequence *sequence = nullptr; // borrowed, owned by the project
+ OakEngineRenderer *renderer = nullptr;
+ olive::Rational time_base; // frame duration in seconds
+ double fps = 0.0; // frames per second
+
+ void (*frame_cb)(const oak_playback_frame *, void *) = nullptr;
+ void *frame_cb_data = nullptr;
+ void (*audio_cb)(const oak_playback_audio *, void *) = nullptr;
+ void *audio_cb_data = nullptr;
+
+ std::thread pull_thread;
+ // Set by the pull thread right before it exits; lets free() wait out a
+ // detached thread (stop called from inside a callback detaches, see
+ // stop_and_join) before the state is deleted.
+ std::atomic thread_done{ true };
+ std::atomic state{ k_state_stopped };
+ std::atomic speed{ 1.0 };
+ // Anchors for the clock-driven position (see get_position).
+ std::atomic anchor_ts{ 0 }; // sequence position at anchor
+ std::atomic anchor_clock{ 0.0 }; // master clock at anchor
+ std::atomic next_frame_ts{ 0 };
+ std::atomic next_audio_time{ 0.0 }; // seconds
+ std::atomic paused_ts{ 0 };
+ std::atomic last_start_ts{ 0 };
+ QElapsedTimer wall_timer;
+ QString last_error;
+};
+
+OakEnginePlaybackState *impl(OakEnginePlayback *h)
+{
+ return reinterpret_cast(h);
+}
+
+const OakEnginePlaybackState *impl(const OakEnginePlayback *h)
+{
+ return reinterpret_cast(h);
+}
+
+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(OakEnginePlaybackState *state, const QString &error)
+{
+ if (state) {
+ state->last_error = error;
+ }
+}
+
+// The master clock in seconds: the audio output clock when an
+// AudioManager instance is consuming (latency-compensated), otherwise
+// the wall clock of this instance.
+double master_clock_seconds(const OakEnginePlaybackState *state)
+{
+ // Trust the audio output clock only while this instance is actually
+ // feeding it: on a silent sequence nothing is consumed, so the output
+ // clock never advances and would freeze the position at the anchor.
+ if (state->sequence->get_audio_params().channel_count() > 0 &&
+ olive::AudioManager::instance()) {
+ const double s = olive::AudioManager::instance()->seconds();
+ if (s >= 0.0) {
+ return s;
+ }
+ }
+ return double(state->wall_timer.elapsed()) / 1000.0;
+}
+
+int64_t position_ts_of(const OakEnginePlaybackState *state, double clock_s)
+{
+ const double advanced =
+ (clock_s - state->anchor_clock.load()) * state->speed.load();
+ return state->anchor_ts.load() + int64_t(advanced * state->fps);
+}
+
+// Deliver one rendered frame through the callback; the payload stays
+// valid for the duration of the call only.
+void deliver_frame(OakEnginePlaybackState *state, OakEngineFrame *frame,
+ int64_t ts)
+{
+ if (!state->frame_cb) {
+ oakengine_frame_free(frame);
+ return;
+ }
+ oak_playback_frame payload;
+ payload.timestamp = ts;
+ payload.width = oakengine_frame_width(frame);
+ payload.height = oakengine_frame_height(frame);
+ payload.format = oakengine_frame_format(frame);
+ payload.linesize = oakengine_frame_linesize_bytes(frame);
+ payload.data = oakengine_frame_data(frame);
+ state->frame_cb(&payload, state->frame_cb_data);
+ oakengine_frame_free(frame);
+}
+
+// Deliver one rendered audio block through the AudioManager (when an
+// instance exists) and the callback; the payload stays valid for the
+// duration of the call only.
+void deliver_audio(OakEnginePlaybackState *state, OakEngineAudioBuffer *buf,
+ int64_t start_ts)
+{
+ const int channels = oakengine_audio_channel_count(buf);
+ const int64_t count = oakengine_audio_sample_count(buf);
+ const int rate = oakengine_audio_sample_rate(buf);
+
+ std::vector channel_ptrs(size_t(channels), nullptr);
+ for (int i = 0; i < channels; i++) {
+ channel_ptrs[size_t(i)] = oakengine_audio_data(buf, i);
+ }
+
+ if (olive::AudioManager::instance() && count > 0) {
+ // Pack planar float into interleaved float32 for the output.
+ QByteArray pack;
+ pack.resize(int(count * channels * int64_t(sizeof(float))));
+ auto *dst = reinterpret_cast(pack.data());
+ for (int64_t i = 0; i < count; i++) {
+ for (int ch = 0; ch < channels; ch++) {
+ *dst++ = channel_ptrs[size_t(ch)][i];
+ }
+ }
+ const olive::AudioParams params(
+ rate, state->sequence->get_audio_params().channel_layout(),
+ olive::core::SampleFormat::f32);
+ olive::AudioManager::instance()->push_to_output(params, pack);
+ }
+
+ if (state->audio_cb) {
+ oak_playback_audio payload;
+ payload.start_ts = start_ts;
+ payload.channels = channels;
+ payload.sample_rate = rate;
+ payload.sample_count = count;
+ payload.channel_data = channel_ptrs.data();
+ state->audio_cb(&payload, state->audio_cb_data);
+ }
+ oakengine_audio_free(buf);
+}
+
+void pull_loop(OakEnginePlaybackState *state)
+{
+ const olive::Rational video_length = state->sequence->get_length();
+ // A sequence without timeline clips (node graph only) has no defined
+ // end: treat it as unbounded instead of stopping instantly.
+ const bool bounded = video_length > 0;
+ const int64_t end_ts =
+ bounded ? olive::core::Timecode::time_to_timestamp(
+ video_length, state->time_base, olive::core::Timecode::k_round) :
+ INT64_MAX;
+ const double end_time = bounded ? video_length.to_double() : DBL_MAX;
+
+ while (state->state.load() != k_state_stopped) {
+ if (state->state.load() == k_state_paused) {
+ QThread::msleep(5);
+ continue;
+ }
+
+ const double clock_s = master_clock_seconds(state);
+ const double speed = state->speed.load();
+ const int64_t step = std::max(1, llround(speed));
+
+ // Video: deliver every due frame (rendering is synchronous, so
+ // the pull thread is the pacer; when rendering is slower than
+ // realtime the loop naturally delivers late).
+ const int64_t due_ts = position_ts_of(state, clock_s);
+ while (state->state.load() == k_state_playing &&
+ state->next_frame_ts.load() <= due_ts &&
+ state->next_frame_ts.load() < end_ts) {
+ const int64_t ts = state->next_frame_ts.load();
+ OakEngineFrame *frame =
+ oakengine_renderer_render_frame(state->renderer, ts);
+ if (!frame) {
+ // Skip a failed frame instead of stalling the loop; the
+ // renderer's error is mirrored into our handle.
+ char err[256];
+ err[0] = '\0';
+ oakengine_renderer_last_error(state->renderer, err,
+ sizeof(err));
+ set_error(state, QString::fromUtf8(err));
+ state->next_frame_ts.store(ts + step);
+ continue;
+ }
+ deliver_frame(state, frame, ts);
+ state->next_frame_ts.store(ts + step);
+ }
+
+ // Audio: keep k_audio_ahead_s of sequence time queued.
+ if (state->sequence->get_audio_params().channel_count() > 0) {
+ const double audio_due =
+ state->anchor_ts.load() * state->time_base.to_double() +
+ (clock_s - state->anchor_clock.load()) * speed +
+ k_audio_ahead_s;
+ while (state->state.load() == k_state_playing &&
+ state->next_audio_time.load() <
+ std::min(audio_due, end_time)) {
+ const double t = state->next_audio_time.load();
+ const int64_t start_ts =
+ olive::core::Timecode::time_to_timestamp(
+ olive::Rational::from_double(t), state->time_base,
+ olive::core::Timecode::k_round);
+ const int64_t len_ts =
+ std::max(1, llround(k_audio_interval_s * speed *
+ state->fps));
+ OakEngineAudioBuffer *buf =
+ oakengine_renderer_render_audio(state->renderer, start_ts,
+ len_ts);
+ if (!buf) {
+ char err[256];
+ err[0] = '\0';
+ oakengine_renderer_last_error(state->renderer, err,
+ sizeof(err));
+ set_error(state, QString::fromUtf8(err));
+ } else {
+ deliver_audio(state, buf, start_ts);
+ }
+ state->next_audio_time.store(t + k_audio_interval_s * speed);
+ }
+ }
+
+ // End of stream: both queues exhausted.
+ const bool video_done = state->next_frame_ts.load() >= end_ts;
+ const bool audio_done =
+ state->sequence->get_audio_params().channel_count() == 0 ||
+ state->next_audio_time.load() >= end_time;
+ if (video_done && audio_done) {
+ state->paused_ts.store(end_ts);
+ state->last_start_ts.store(end_ts);
+ state->state.store(k_state_stopped);
+ break;
+ }
+
+ QThread::msleep(4);
+ }
+
+ state->thread_done.store(true);
+}
+
+void stop_and_join(OakEnginePlaybackState *state)
+{
+ state->state.store(k_state_stopped);
+ if (state->renderer) {
+ oakengine_renderer_cancel(state->renderer);
+ }
+ if (state->pull_thread.joinable()) {
+ if (state->pull_thread.get_id() == std::this_thread::get_id()) {
+ // Called from inside a callback on the pull thread: joining
+ // ourselves would deadlock. Detach instead; the loop exits on
+ // the stopped state and free() waits on thread_done.
+ state->pull_thread.detach();
+ } else {
+ state->pull_thread.join();
+ }
+ }
+}
+
+} // namespace
+
+extern "C"
+{
+
+OakEnginePlayback *oakengine_playback_create(OakEngineSequence *seq, int width,
+ int height, int fps_num,
+ int fps_den)
+{
+ olive::Sequence *sequence = reinterpret_cast(seq);
+ if (!sequence || width <= 0 || height <= 0 || fps_num <= 0 ||
+ fps_den <= 0) {
+ return nullptr;
+ }
+
+ auto *state = new OakEnginePlaybackState();
+ state->sequence = sequence;
+ state->time_base = olive::Rational(fps_den, fps_num);
+ state->fps = double(fps_num) / double(fps_den);
+ // Frames: RGBA float16 like the renderer family's default CPU frames;
+ // the colorspace is the sequence's reference space (no transform).
+ state->renderer = oakengine_renderer_create(seq, width, height, 3 /* f16 */,
+ fps_num, fps_den, nullptr);
+ if (!state->renderer) {
+ delete state;
+ return nullptr;
+ }
+ state->wall_timer.start();
+ return reinterpret_cast(state);
+}
+
+void oakengine_playback_free(OakEnginePlayback *self)
+{
+ if (!self) {
+ return;
+ }
+ OakEnginePlaybackState *state = impl(self);
+ stop_and_join(state);
+ // A stop from inside a callback detaches the pull thread; wait for it
+ // to actually exit before the state goes away. (Calling free() from a
+ // callback itself is forbidden, see playback.h.)
+ while (!state->thread_done.load()) {
+ QThread::msleep(1);
+ }
+ if (state->renderer) {
+ oakengine_renderer_free(state->renderer);
+ }
+ delete state;
+}
+
+int oakengine_playback_set_frame_callback(
+ OakEnginePlayback *self,
+ void (*on_frame)(const oak_playback_frame *frame, void *userdata),
+ void *userdata)
+{
+ if (!self) {
+ return OAKENGINE_E_INVALID;
+ }
+ impl(self)->frame_cb = on_frame;
+ impl(self)->frame_cb_data = userdata;
+ return OAKENGINE_OK;
+}
+
+int oakengine_playback_set_audio_callback(
+ OakEnginePlayback *self,
+ void (*on_audio)(const oak_playback_audio *audio, void *userdata),
+ void *userdata)
+{
+ if (!self) {
+ return OAKENGINE_E_INVALID;
+ }
+ impl(self)->audio_cb = on_audio;
+ impl(self)->audio_cb_data = userdata;
+ return OAKENGINE_OK;
+}
+
+int oakengine_playback_start(OakEnginePlayback *self, int64_t start_ts,
+ double speed)
+{
+ if (!self) {
+ return OAKENGINE_E_INVALID;
+ }
+ OakEnginePlaybackState *state = impl(self);
+ set_error(state, QString());
+ if (start_ts < 0 || !(speed > 0.0)) {
+ set_error(state, QStringLiteral("invalid start time or speed"));
+ return OAKENGINE_E_INVALID;
+ }
+ if (!olive::RenderManager::instance()) {
+ set_error(state, QStringLiteral("engine not initialized with "
+ "OAKENGINE_INIT_RENDER"));
+ return OAKENGINE_E_STATE;
+ }
+
+ // A stopped thread is (re)spawned; a paused/playing one is re-anchored.
+ if (state->state.load() == k_state_stopped) {
+ if (state->pull_thread.joinable()) {
+ state->pull_thread.join();
+ }
+ // A thread detached by a stop-from-callback may still be exiting;
+ // wait it out so its final thread_done store cannot clobber the
+ // new run. (Only in the stopped case: a paused thread is alive by
+ // design and never sets thread_done.)
+ while (!state->thread_done.load()) {
+ QThread::msleep(1);
+ }
+ }
+
+ state->speed.store(speed);
+ state->anchor_ts.store(start_ts);
+ state->last_start_ts.store(start_ts);
+ state->next_frame_ts.store(start_ts);
+ state->next_audio_time.store(start_ts * state->time_base.to_double());
+ state->wall_timer.restart();
+ if (olive::AudioManager::instance()) {
+ // Restart the master clock at zero for this run (the viewer does
+ // the same in finish_play_preprocess()).
+ olive::AudioManager::instance()->reset_output_clock();
+ }
+ state->anchor_clock.store(master_clock_seconds(state));
+
+ if (state->state.load() == k_state_stopped) {
+ state->state.store(k_state_playing);
+ state->thread_done.store(false);
+ state->pull_thread = std::thread(pull_loop, state);
+ } else {
+ state->state.store(k_state_playing);
+ }
+ return OAKENGINE_OK;
+}
+
+int oakengine_playback_pause(OakEnginePlayback *self)
+{
+ if (!self) {
+ return OAKENGINE_E_INVALID;
+ }
+ OakEnginePlaybackState *state = impl(self);
+ if (state->state.load() == k_state_playing) {
+ state->paused_ts.store(position_ts_of(state,
+ master_clock_seconds(state)));
+ state->state.store(k_state_paused);
+ if (state->renderer) {
+ oakengine_renderer_cancel(state->renderer);
+ }
+ }
+ return OAKENGINE_OK;
+}
+
+int oakengine_playback_stop(OakEnginePlayback *self)
+{
+ if (!self) {
+ return OAKENGINE_E_INVALID;
+ }
+ stop_and_join(impl(self));
+ return OAKENGINE_OK;
+}
+
+int oakengine_playback_get_position(const OakEnginePlayback *self, int64_t *ts)
+{
+ if (!self || !ts) {
+ return OAKENGINE_E_INVALID;
+ }
+ const OakEnginePlaybackState *state = impl(self);
+ switch (state->state.load()) {
+ case k_state_playing:
+ *ts = position_ts_of(state, master_clock_seconds(state));
+ break;
+ case k_state_paused:
+ *ts = state->paused_ts.load();
+ break;
+ default:
+ *ts = state->last_start_ts.load();
+ break;
+ }
+ return OAKENGINE_OK;
+}
+
+int oakengine_playback_set_speed(OakEnginePlayback *self, double speed)
+{
+ if (!self) {
+ return OAKENGINE_E_INVALID;
+ }
+ OakEnginePlaybackState *state = impl(self);
+ set_error(state, QString());
+ if (!(speed > 0.0)) {
+ set_error(state, QStringLiteral("invalid speed %1").arg(speed));
+ return OAKENGINE_E_INVALID;
+ }
+ if (state->state.load() == k_state_playing) {
+ // Re-anchor at the current position so delivery stays monotonic.
+ const double clock_s = master_clock_seconds(state);
+ state->anchor_ts.store(position_ts_of(state, clock_s));
+ state->anchor_clock.store(clock_s);
+ }
+ state->speed.store(speed);
+ return OAKENGINE_OK;
+}
+
+int oakengine_playback_is_playing(const OakEnginePlayback *self)
+{
+ if (!self) {
+ return 0;
+ }
+ return impl(self)->state.load() == k_state_playing ? 1 : 0;
+}
+
+int oakengine_playback_last_error(const OakEnginePlayback *self, char *buf,
+ int buf_size)
+{
+ if (!self) {
+ return OAKENGINE_E_INVALID;
+ }
+ return string_to_buf(impl(self)->last_error, buf, buf_size);
+}
+
+} // extern "C"
diff --git a/engine/tests/oakengine_playback_test.cpp b/engine/tests/oakengine_playback_test.cpp
new file mode 100644
index 000000000..e13f70cbe
--- /dev/null
+++ b/engine/tests/oakengine_playback_test.cpp
@@ -0,0 +1,515 @@
+/***
+
+ 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 playback facade. The validation
+// part (argument checking, not-initialized errors, idempotent
+// pause/stop, NULL safety) requires no GL and must always pass. The
+// playback part needs a working render backend (frames are pulled
+// through the renderer family in oak-render-worker child processes);
+// when no backend is available it prints a SKIP notice and exits 0,
+// mirroring oakengine_renderer_test.
+//
+// Being an engine-internal test, the GL-gated part builds sequence
+// content through the engine C++ API: a solid red generator feeds the
+// sequence's texture input and tests/demo.mp4 feeds its samples input.
+// Callbacks fire on the engine's pull thread, so the test records them
+// under a mutex (the documented marshalling duty of the consumer).
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#if defined(_WIN32)
+#include
+#include
+#else
+#include
+#endif
+
+#include
+#include
+#include
+#include
+#include
+
+#include "config/config.h"
+#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 "oakengine/init.h"
+#include "oakengine/playback.h"
+#include "oakengine/project.h"
+#include "oakengine/renderer.h"
+#include "oakengine/timeline.h"
+#include "olive/core/util/timecodefunctions.h"
+#include "render/backend/dynamicrenderer.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_playback_test_%lu", base,
+ (unsigned long)GetCurrentProcessId());
+ assert(_mkdir(g_tmpdir) == 0);
+#else
+ strcpy(g_tmpdir, "/tmp/oakengine_playback_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("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
+}
+
+// Callback recorders (fired on the engine pull thread).
+struct FrameLog {
+ std::mutex mutex;
+ std::vector frames;
+
+ void add(const oak_playback_frame &f)
+ {
+ std::lock_guard lock(mutex);
+ frames.push_back(f);
+ }
+
+ size_t size()
+ {
+ std::lock_guard lock(mutex);
+ return frames.size();
+ }
+};
+
+struct AudioLog {
+ std::mutex mutex;
+ std::vector blocks;
+
+ void add(const oak_playback_audio &a)
+ {
+ std::lock_guard lock(mutex);
+ blocks.push_back(a);
+ }
+
+ size_t size()
+ {
+ std::lock_guard lock(mutex);
+ return blocks.size();
+ }
+};
+
+static void on_frame(const oak_playback_frame *frame, void *userdata)
+{
+ static_cast(userdata)->add(*frame);
+}
+
+static void on_audio(const oak_playback_audio *audio, void *userdata)
+{
+ static_cast(userdata)->add(*audio);
+}
+
+// Stops playback from inside the callback (i.e. on the pull thread); the
+// playback.h contract allows this for stop (but not for free).
+static void on_frame_stop(const oak_playback_frame *frame, void *userdata)
+{
+ Q_UNUSED(frame)
+ oakengine_playback_stop(static_cast(userdata));
+}
+
+// Poll `pred` until it holds or the timeout elapses, PUMPING THE MAIN
+// THREAD's event queue: conform/decode completions are posted here, and
+// starving it stalls audio rendering (the playback facade requires a
+// pumped Qt event loop on the consumer's main thread).
+static bool wait_until(bool (*pred)(void *), void *userdata, int timeout_ms)
+{
+ const int64_t deadline =
+ QDateTime::currentMSecsSinceEpoch() + timeout_ms;
+ while (QDateTime::currentMSecsSinceEpoch() < deadline) {
+ if (pred(userdata)) {
+ return true;
+ }
+ QCoreApplication::processEvents(QEventLoop::AllEvents, 10);
+ QThread::msleep(5);
+ }
+ return pred(userdata);
+}
+
+// Sleep `ms` while pumping the main thread (see wait_until).
+static void pump_ms(int ms)
+{
+ const int64_t deadline = QDateTime::currentMSecsSinceEpoch() + ms;
+ while (QDateTime::currentMSecsSinceEpoch() < deadline) {
+ QCoreApplication::processEvents(QEventLoop::AllEvents, 10);
+ QThread::msleep(5);
+ }
+}
+
+static bool six_frames(void *userdata)
+{
+ return static_cast(userdata)->size() >= 6;
+}
+
+static bool good_audio_block(void *userdata)
+{
+ AudioLog *log = static_cast(userdata);
+ std::lock_guard lock(log->mutex);
+ for (const oak_playback_audio &a : log->blocks) {
+ if (a.channel_data && a.channel_data[0] && a.channel_data[1]) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool not_playing(void *userdata)
+{
+ return oakengine_playback_is_playing(
+ static_cast(userdata)) == 0;
+}
+
+// Argument validation, idempotent pause/stop, initial position. No GL.
+static void test_validation(OakEngineSequence *seq)
+{
+ assert(oakengine_playback_create(NULL, 320, 180, 24, 1) == NULL);
+ assert(oakengine_playback_create(seq, 0, 180, 24, 1) == NULL);
+ assert(oakengine_playback_create(seq, 320, -1, 24, 1) == NULL);
+ assert(oakengine_playback_create(seq, 320, 180, 0, 1) == NULL);
+ assert(oakengine_playback_create(seq, 320, 180, 24, 0) == NULL);
+
+ OakEnginePlayback *p = oakengine_playback_create(seq, 320, 180, 24, 1);
+ assert(p != NULL);
+
+ // Callback setters: NULL-safe, idempotent.
+ assert(oakengine_playback_set_frame_callback(NULL, on_frame, NULL) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_playback_set_frame_callback(p, NULL, NULL) ==
+ OAKENGINE_OK);
+ assert(oakengine_playback_set_audio_callback(NULL, on_audio, NULL) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_playback_set_audio_callback(p, NULL, NULL) ==
+ OAKENGINE_OK);
+
+ // Position is 0 before the first start; pause/stop are idempotent.
+ int64_t pos = -1;
+ assert(oakengine_playback_get_position(p, &pos) == OAKENGINE_OK);
+ assert(pos == 0);
+ assert(oakengine_playback_get_position(NULL, &pos) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_playback_get_position(p, NULL) == OAKENGINE_E_INVALID);
+ assert(oakengine_playback_is_playing(p) == 0);
+ assert(oakengine_playback_is_playing(NULL) == 0);
+ assert(oakengine_playback_pause(p) == OAKENGINE_OK);
+ assert(oakengine_playback_stop(p) == OAKENGINE_OK);
+ assert(oakengine_playback_is_playing(p) == 0);
+
+ // Bad speeds are rejected with a readable reason; start without the
+ // RENDER bit reports OAKENGINE_E_STATE.
+ assert(oakengine_playback_set_speed(p, 0.0) == OAKENGINE_E_INVALID);
+ assert(oakengine_playback_set_speed(p, -1.0) == OAKENGINE_E_INVALID);
+ assert(oakengine_playback_set_speed(NULL, 1.0) == OAKENGINE_E_INVALID);
+ char err[256];
+ assert(oakengine_playback_last_error(p, err, sizeof(err)) > 0);
+ assert(oakengine_playback_start(p, 0, 0.0) == OAKENGINE_E_INVALID);
+ assert(oakengine_playback_start(p, 0, -2.0) == OAKENGINE_E_INVALID);
+ assert(oakengine_playback_start(p, -1, 1.0) == OAKENGINE_E_INVALID);
+ assert(oakengine_playback_start(p, 0, 1.0) == OAKENGINE_E_STATE);
+ assert(strstr(err, "OAKENGINE_INIT_RENDER") != NULL ||
+ oakengine_playback_last_error(p, err, sizeof(err)) > 0);
+ assert(oakengine_playback_start(NULL, 0, 1.0) == OAKENGINE_E_INVALID);
+ assert(oakengine_playback_pause(NULL) == OAKENGINE_E_INVALID);
+ assert(oakengine_playback_stop(NULL) == OAKENGINE_E_INVALID);
+
+ oakengine_playback_free(p);
+ oakengine_playback_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
+ // 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, "Playback");
+ assert(seq != NULL);
+
+ test_validation(seq);
+
+ // ---- GL-gated part ---------------------------------------------------
+ if (!is_render_backend_available(QStringLiteral("opengl"))) {
+ printf("oakengine_playback_test: SKIP: OpenGL render backend not "
+ "available, playback assertions skipped\n");
+ oakengine_project_free(project);
+ oakengine_shutdown();
+ return 0;
+ }
+ if (!worker_binary_exists()) {
+ printf("oakengine_playback_test: SKIP: oak-render-worker binary not "
+ "found, playback 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");
+ assert(oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) ==
+ OAKENGINE_OK);
+
+ // Build content through the facade (a real clip gives the sequence a
+ // bounded length for the end-of-stream test): one video track with
+ // tests/demo.mp4 placed from 0.
+ const QString demo_path = QDir(QStringLiteral(OAK_TEST_SOURCE_DIR))
+ .filePath(QStringLiteral("tests/demo.mp4"));
+ assert(QFileInfo::exists(demo_path));
+ assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) ==
+ 0);
+ OakEngineFootage *footage =
+ oakengine_project_import_footage(project, demo_path.toUtf8().constData());
+ assert(footage != NULL);
+ OakEngineClip *clip = oakengine_sequence_add_footage_clip(
+ seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 480, 0);
+ assert(clip != NULL);
+ // Audio comes from an audio track: without one the sequence's samples
+ // output is empty (the video track alone carries no samples).
+ assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) ==
+ 0);
+ OakEngineClip *aclip = oakengine_sequence_add_footage_clip(
+ seq, footage, OAKENGINE_TRACK_TYPE_AUDIO, 0, 0, 480, 0);
+ assert(aclip != NULL);
+ oakengine_footage_free(footage);
+
+ FrameLog frames;
+ AudioLog audio;
+ OakEnginePlayback *p =
+ oakengine_playback_create(seq, 320, 180, 30000, 1001);
+ assert(p != NULL);
+ assert(oakengine_playback_set_frame_callback(p, on_frame, &frames) ==
+ OAKENGINE_OK);
+ assert(oakengine_playback_set_audio_callback(p, on_audio, &audio) ==
+ OAKENGINE_OK);
+
+ // Start at 1x: frames arrive monotonically with the right geometry,
+ // audio blocks with the right layout.
+ assert(oakengine_playback_start(p, 0, 1.0) == OAKENGINE_OK);
+ assert(oakengine_playback_is_playing(p) == 1);
+ assert(wait_until(six_frames, &frames, 10000));
+ // The first block(s) can land while the decoder is still warming up
+ // (empty channels); require at least one well-formed block and a
+ // consistent layout across all of them (generous for CI stability).
+ assert(wait_until(good_audio_block, &audio, 10000));
+ {
+ frames.mutex.lock();
+ const size_t n = frames.frames.size();
+ for (size_t i = 0; i < n; i++) {
+ const oak_playback_frame &f = frames.frames.at(i);
+ assert(f.width == 320 && f.height == 180);
+ assert(f.format == 3); // f16
+ assert(f.linesize >= 320 * 4 * 2);
+ assert(f.data != NULL);
+ if (i > 0) {
+ assert(f.timestamp > frames.frames.at(i - 1).timestamp);
+ }
+ }
+ frames.mutex.unlock();
+ }
+ {
+ audio.mutex.lock();
+ bool found_good = false;
+ for (const oak_playback_audio &a : audio.blocks) {
+ assert(a.channels == 2);
+ assert(a.sample_rate == 48000);
+ assert(a.sample_count >= 0);
+ if (a.channel_data && a.channel_data[0] && a.channel_data[1] &&
+ a.sample_count > 0) {
+ found_good = true;
+ }
+ }
+ assert(found_good);
+ audio.mutex.unlock();
+ }
+
+ // Position advances from 0 (wall clock master here: no AudioManager
+ // instance exists in this process).
+ pump_ms(400);
+ int64_t pos_a = -1;
+ assert(oakengine_playback_get_position(p, &pos_a) == OAKENGINE_OK);
+ assert(pos_a > 0);
+
+ // Pause freezes delivery and the position.
+ assert(oakengine_playback_pause(p) == OAKENGINE_OK);
+ assert(oakengine_playback_is_playing(p) == 0);
+ const size_t frozen_frames = frames.size();
+ int64_t frozen_pos = -1;
+ assert(oakengine_playback_get_position(p, &frozen_pos) == OAKENGINE_OK);
+ pump_ms(250);
+ assert(frames.size() <= frozen_frames + 1); // one in-flight may land
+ int64_t still_frozen = -1;
+ assert(oakengine_playback_get_position(p, &still_frozen) == OAKENGINE_OK);
+ assert(still_frozen == frozen_pos);
+
+ // Resume at the frozen position: delivery continues.
+ assert(oakengine_playback_start(p, frozen_pos, 1.0) == OAKENGINE_OK);
+ assert(oakengine_playback_is_playing(p) == 1);
+ pump_ms(300);
+ assert(frames.size() > frozen_frames);
+
+ // 2x advances the position (much) faster than 1x over the same wall
+ // window (generous margins to stay CI-stable).
+ assert(oakengine_playback_get_position(p, &pos_a) == OAKENGINE_OK);
+ pump_ms(600);
+ int64_t pos_b = -1;
+ assert(oakengine_playback_get_position(p, &pos_b) == OAKENGINE_OK);
+ const int64_t advance_1x = pos_b - pos_a;
+ assert(oakengine_playback_set_speed(p, 2.0) == OAKENGINE_OK);
+ pump_ms(600);
+ int64_t pos_c = -1;
+ assert(oakengine_playback_get_position(p, &pos_c) == OAKENGINE_OK);
+ const int64_t advance_2x = pos_c - pos_b;
+ assert(advance_1x > 0);
+ assert(advance_2x > advance_1x + advance_1x / 2);
+
+ // Stop resets to the last start timestamp; restarting from 0 replays.
+ assert(oakengine_playback_stop(p) == OAKENGINE_OK);
+ assert(oakengine_playback_is_playing(p) == 0);
+ int64_t stopped_pos = -1;
+ assert(oakengine_playback_get_position(p, &stopped_pos) ==
+ OAKENGINE_OK);
+ assert(stopped_pos == frozen_pos);
+ {
+ frames.mutex.lock();
+ frames.frames.clear();
+ frames.mutex.unlock();
+ }
+ assert(oakengine_playback_start(p, 0, 1.0) == OAKENGINE_OK);
+ assert(wait_until(six_frames, &frames, 10000));
+ {
+ frames.mutex.lock();
+ assert(frames.frames.front().timestamp < 30);
+ frames.mutex.unlock();
+ }
+ assert(oakengine_playback_stop(p) == OAKENGINE_OK);
+
+ // End of stream: start near the end at 4x, the engine auto-stops at
+ // the sequence length and reports that as the position.
+ int len_num = 0, len_den = 1;
+ assert(oakengine_sequence_get_length_rational(seq, &len_num, &len_den) ==
+ OAKENGINE_OK);
+ const int64_t end_ts = int64_t(
+ olive::core::Timecode::time_to_timestamp(
+ olive::Rational(len_num, len_den),
+ olive::Rational(1001, 30000), olive::core::Timecode::k_round));
+ const int64_t near_end = end_ts > 48 ? end_ts - 48 : 0;
+ assert(oakengine_playback_start(p, near_end, 4.0) == OAKENGINE_OK);
+ assert(wait_until(not_playing, p, 10000));
+ int64_t end_pos = -1;
+ assert(oakengine_playback_get_position(p, &end_pos) == OAKENGINE_OK);
+ assert(end_pos == end_ts);
+
+ // Freeing during playback must not crash.
+ OakEnginePlayback *p2 =
+ oakengine_playback_create(seq, 320, 180, 30000, 1001);
+ assert(p2 != NULL);
+ assert(oakengine_playback_set_frame_callback(p2, on_frame,
+ &frames) == OAKENGINE_OK);
+ assert(oakengine_playback_start(p2, 0, 1.0) == OAKENGINE_OK);
+ pump_ms(50);
+ oakengine_playback_free(p2);
+
+ // Stopping from inside a callback must not deadlock: the pull thread
+ // detaches, and free() from this thread waits its exit out.
+ OakEnginePlayback *p3 =
+ oakengine_playback_create(seq, 320, 180, 30000, 1001);
+ assert(p3 != NULL);
+ assert(oakengine_playback_set_frame_callback(p3, on_frame_stop, p3) ==
+ OAKENGINE_OK);
+ assert(oakengine_playback_start(p3, 0, 1.0) == OAKENGINE_OK);
+ assert(wait_until(not_playing, p3, 10000));
+ oakengine_playback_free(p3);
+
+ oakengine_playback_free(p);
+ oakengine_project_free(project);
+ assert(oakengine_shutdown() == OAKENGINE_OK);
+
+ printf("oakengine_playback_test: all assertions passed\n");
+ return 0;
+}