From d63131cb124d9eb933579c01dae40d4bf70e1263 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Mon, 20 Jul 2026 17:46:49 +0800 Subject: [PATCH] engine: add the playback family to the facade --- engine/CMakeLists.txt | 28 ++ engine/include/oakengine/playback.h | 218 ++++++++++ engine/src/capi/CMakeLists.txt | 2 + engine/src/capi/playback.cpp | 527 +++++++++++++++++++++++ engine/tests/oakengine_playback_test.cpp | 515 ++++++++++++++++++++++ 5 files changed, 1290 insertions(+) create mode 100644 engine/include/oakengine/playback.h create mode 100644 engine/src/capi/playback.cpp create mode 100644 engine/tests/oakengine_playback_test.cpp 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; +}