diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt
index a8feddd3e..23b0c5041 100644
--- a/engine/CMakeLists.txt
+++ b/engine/CMakeLists.txt
@@ -311,4 +311,27 @@ if (BUILD_TESTS)
target_compile_definitions(oakengine_keyframe_test PRIVATE
OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
)
+
+ make_oakengine_test(oakengine_preview_test)
+ # The preview test needs audio rendering (RenderManager + workers).
+ target_include_directories(oakengine_preview_test PRIVATE
+ ${CMAKE_SOURCE_DIR}/third_party/openfx/include
+ ${CMAKE_SOURCE_DIR}/third_party/openfx/HostSupport/include
+ ${OLIVE_INCLUDE_DIRS}
+ )
+ target_compile_definitions(oakengine_preview_test PRIVATE
+ ${OLIVE_DEFINITIONS}
+ OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
+ )
+ target_compile_options(oakengine_preview_test PRIVATE
+ ${OLIVE_COMPILE_OPTIONS}
+ )
+ if (OAK_ENABLE_DYNAMIC_RENDER_BACKEND)
+ target_compile_definitions(oakengine_preview_test PRIVATE
+ OAK_ENABLE_DYNAMIC_RENDER_BACKEND)
+ add_dependencies(oakengine_preview_test oakgl)
+ endif ()
+ if (TARGET olive-render-worker)
+ add_dependencies(oakengine_preview_test olive-render-worker)
+ endif ()
endif ()
diff --git a/engine/include/oakengine/preview.h b/engine/include/oakengine/preview.h
new file mode 100644
index 000000000..7f4d269fc
--- /dev/null
+++ b/engine/include/oakengine/preview.h
@@ -0,0 +1,125 @@
+/***
+
+ 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_PREVIEW_H
+#define OAKENGINE_PREVIEW_H
+
+#include
+
+#include "export.h"
+#include "footage.h"
+#include "init.h"
+#include "timeline.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @file preview.h
+ * @brief C ABI for preview state and readouts (loop mode, audio levels,
+ * waveform summary)
+ *
+ * This family is deliberately NOT realtime playback: the application's
+ * realtime transport is deeply coupled to its viewer widgets (viewer
+ * playback timer, audio queue, PortAudio manager). What a headless
+ * consumer needs is renderable through the renderer family; this family
+ * adds the remaining preview state and readouts.
+ *
+ * Loop mode note: the engine has no sequence-level loop state. LoopMode
+ * (engine/render/loopmode.h: off / loop / clamp) is a property of a CLIP
+ * (ClipBlock::k_loop_mode_input), which is what the application edits.
+ * The loop functions here therefore operate on clip handles, persisted
+ * through the node-graph and undoable like other parameter writes.
+ *
+ * Audio readouts are computed by rendering the exact range through
+ * RenderManager::render_audio() and reducing the samples in-process --
+ * the exact semantics are documented per function. Rendering audio may
+ * conform sources on first use; the wait is driven with an event loop
+ * like the export family (up to 120 s). Everything here works without
+ * GL. Errors follow the family model (oakengine_preview_last_error()).
+ */
+
+/** @brief Loop modes, mirroring olive::LoopMode. */
+#define OAKENGINE_LOOP_MODE_OFF 0 /**< Play once (olive k_loop_mode_off). */
+#define OAKENGINE_LOOP_MODE_LOOP 1 /**< Repeat the clip (olive k_loop_mode_loop). */
+#define OAKENGINE_LOOP_MODE_CLAMP 2 /**< Hold first/last frame (olive k_loop_mode_clamp). */
+
+/**
+ * @brief Human-readable reason for the last failed preview call on this
+ * thread (buf/size convention).
+ */
+OAKENGINE_API int oakengine_preview_last_error(char *buf, int buf_size);
+
+/**
+ * @brief The clip's loop mode (OAKENGINE_LOOP_MODE_*;
+ * ClipBlock::loop_mode()). Returns the mode or OAKENGINE_E_INVALID.
+ */
+OAKENGINE_API int oakengine_clip_get_loop_mode(const OakEngineClip *self);
+
+/**
+ * @brief Set the clip's loop mode (undoable parameter write, same command
+ * path as the node family). OAKENGINE_E_INVALID for an unknown mode.
+ */
+OAKENGINE_API int oakengine_clip_set_loop_mode(OakEngineClip *self,
+ int mode);
+
+/**
+ * @brief Per-channel audio level of a sequence at `time_ts`.
+ *
+ * Exact semantics: the sequence's audio is rendered over ONE frame
+ * starting at `time_ts` (a frame timestamp in the sequence's frame-rate
+ * timebase, like the rest of the family) and the linear RMS
+ * (root-mean-square) of every channel is written to `values`, in the
+ * [0, 1] range for normalized audio. Ranges with no audio content yield
+ * exact zeros (the engine returns no allocated samples there).
+ * `channel_count` is the capacity of `values`; up to that many of the
+ * sequence's channels are written, the return value is the number of
+ * channels written, and a negative OAKENGINE_E_* code signals an error
+ * (e.g. no RENDER-less engine state issue -- audio renders without GL,
+ * but the engine must be initialized with OAKENGINE_INIT_RENDER because
+ * rendering goes through RenderManager).
+ */
+OAKENGINE_API int oakengine_preview_get_audio_levels(
+ OakEngineSequence *seq, int64_t time_ts, double *values,
+ int channel_count);
+
+/**
+ * @brief Waveform min/max summary of a footage's audio over a range.
+ *
+ * The footage's audio is rendered from `start_ts` to `end_ts` (frame
+ * timestamps in the timebase of the project's first sequence's frame
+ * rate, same convention as the keyframe family) and each of the `count`
+ * equal-sized buckets covering the range yields the minimum and maximum
+ * sample value of `channel` in `min_vals`/`max_vals` (linear, unclamped
+ * source samples; silent or content-free buckets are exact zeros). The
+ * footage handle must be a borrowed import handle (probe handles are
+ * rejected with OAKENGINE_E_INVALID); `channel` must be within the
+ * footage's channel count and `count` must be > 0.
+ */
+OAKENGINE_API int oakengine_preview_get_waveform_summary(
+ OakEngineFootage *footage, int channel, int64_t start_ts,
+ int64_t end_ts, double *min_vals, double *max_vals, int count);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* OAKENGINE_PREVIEW_H */
diff --git a/engine/src/capi/CMakeLists.txt b/engine/src/capi/CMakeLists.txt
index df4b05b21..dd37c165d 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/preview.h
src/capi/init.cpp
src/capi/project.cpp
src/capi/timeline.cpp
@@ -36,5 +37,6 @@ set(OLIVE_SOURCES
src/capi/footage.cpp
src/capi/export.cpp
src/capi/node.cpp
+ src/capi/preview.cpp
PARENT_SCOPE
)
diff --git a/engine/src/capi/preview.cpp b/engine/src/capi/preview.cpp
new file mode 100644
index 000000000..a2c802ec3
--- /dev/null
+++ b/engine/src/capi/preview.cpp
@@ -0,0 +1,345 @@
+/***
+
+ 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/preview.h"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "coreengine.h"
+#include "node/block/clip/clip.h"
+#include "node/nodeundo.h"
+#include "node/project.h"
+#include "node/project/footage/footage.h"
+#include "node/project/sequence/sequence.h"
+#include "node/value.h"
+#include "render/rendermanager.h"
+#include "render/renderticket.h"
+#include "undo/undocommand.h"
+#include "undo/undostack.h"
+
+// Internal cross-family accessor (defined in footage.cpp): borrowed
+// project node of an import handle, nullptr otherwise.
+extern "C" __attribute__((visibility("hidden"))) void *
+oakengine_capi_footage_node(OakEngineFootage *h);
+
+namespace
+{
+
+constexpr qint64 k_wait_timeout_ms = 120000;
+
+thread_local QString g_last_error;
+
+void set_error(const QString &error)
+{
+ g_last_error = error;
+}
+
+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 push_or_run(olive::UndoCommand *command, const QString &name)
+{
+ if (olive::EngineCore::instance()) {
+ olive::EngineCore::instance()->undo_stack()->push(command, name);
+ } else {
+ command->redo_now();
+ delete command;
+ }
+}
+
+// Frame-rate timebase of a sequence (frame duration), like the timeline
+// family's helper. Returns false when invalid.
+bool sequence_time_base(const olive::Sequence *s, olive::Rational *out)
+{
+ const olive::Rational fr = s->get_video_params().frame_rate();
+ if (fr.isNull() || fr.isNaN()) {
+ return false;
+ }
+ *out = fr.flipped();
+ return true;
+}
+
+// Frame-timestamp timebase of the project's first sequence (fallback to
+// the engine default), same convention as the keyframe family.
+olive::Rational project_time_base(const olive::Project *p)
+{
+ if (p) {
+ for (olive::Node *n : p->nodes()) {
+ if (const olive::Sequence *s = dynamic_cast(n)) {
+ olive::Rational tb;
+ if (sequence_time_base(s, &tb)) {
+ return tb;
+ }
+ }
+ }
+ }
+ return olive::Rational(1001, 30000);
+}
+
+// Render `range` of `node`'s audio synchronously, pumping the event loop
+// like the export family (audio conforms are delivered to the TaskManager
+// thread via queued calls and their completion is queued back here, so a
+// bare sleep-wait would deadlock). Returns OAKENGINE_OK on success;
+// otherwise a negative code with the reason in `error`.
+int render_audio_sync(olive::Node *node, const olive::TimeRange &range,
+ const olive::AudioParams &aparam,
+ olive::SampleBuffer *out, QString *error)
+{
+ if (!olive::RenderManager::instance()) {
+ *error = QStringLiteral("engine not initialized with "
+ "OAKENGINE_INIT_RENDER");
+ return OAKENGINE_E_STATE;
+ }
+
+ olive::RenderManager::RenderAudioParams params(node, range, aparam,
+ olive::RenderMode::k_offline);
+
+ // Resubmit while tickets come back "incomplete" (conform still
+ // generating), like the renderer family does.
+ for (int attempt = 0; attempt < 100; attempt++) {
+ olive::RenderTicketPtr ticket =
+ olive::RenderManager::instance()->render_audio(params);
+
+ std::atomic finished{ false };
+ 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_wait_timeout_ms)) {
+ QCoreApplication::processEvents(QEventLoop::AllEvents, 20);
+ QThread::msleep(5);
+ }
+ QObject::disconnect(conn);
+
+ if (!finished.load() || !ticket->has_result()) {
+ *error = QStringLiteral("audio render failed or timed out");
+ return OAKENGINE_E_FAILED;
+ }
+ if (!ticket->property("incomplete").toBool()) {
+ *out = ticket->get().value();
+ return OAKENGINE_OK;
+ }
+ // Conform still generating; pump a little and resubmit.
+ QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
+ QThread::msleep(50);
+ }
+
+ *error = QStringLiteral("audio conform did not finish in time");
+ return OAKENGINE_E_FAILED;
+}
+
+} // namespace
+
+extern "C"
+{
+
+int oakengine_preview_last_error(char *buf, int buf_size)
+{
+ return string_to_buf(g_last_error, buf, buf_size);
+}
+
+int oakengine_clip_get_loop_mode(const OakEngineClip *self)
+{
+ const olive::ClipBlock *clip =
+ reinterpret_cast(self);
+ if (!clip) {
+ return OAKENGINE_E_INVALID;
+ }
+ return int(clip->loop_mode());
+}
+
+int oakengine_clip_set_loop_mode(OakEngineClip *self, int mode)
+{
+ set_error(QString());
+ olive::ClipBlock *clip = reinterpret_cast(self);
+ if (!clip) {
+ set_error(QStringLiteral("invalid clip handle"));
+ return OAKENGINE_E_INVALID;
+ }
+ if (mode < OAKENGINE_LOOP_MODE_OFF || mode > OAKENGINE_LOOP_MODE_CLAMP) {
+ set_error(QStringLiteral("unknown loop mode %1").arg(mode));
+ return OAKENGINE_E_INVALID;
+ }
+ // Same undoable parameter write path as the node family.
+ push_or_run(new olive::NodeParamSetSplitStandardValueCommand(
+ olive::NodeInput(clip, olive::ClipBlock::k_loop_mode_input),
+ olive::NodeValue::split_normal_value_into_track_values(
+ olive::NodeValue::k_combo, QVariant::fromValue(mode))),
+ QStringLiteral("Set Loop Mode"));
+ return OAKENGINE_OK;
+}
+
+int oakengine_preview_get_audio_levels(OakEngineSequence *seq,
+ int64_t time_ts, double *values,
+ int channel_count)
+{
+ set_error(QString());
+ olive::Sequence *sequence = reinterpret_cast(seq);
+ if (!sequence || !values || channel_count <= 0 || time_ts < 0) {
+ set_error(QStringLiteral("invalid arguments"));
+ return OAKENGINE_E_INVALID;
+ }
+ olive::Rational tb;
+ if (!sequence_time_base(sequence, &tb)) {
+ set_error(QStringLiteral("sequence has no valid frame rate"));
+ return OAKENGINE_E_STATE;
+ }
+ olive::AudioParams aparam = sequence->get_audio_params();
+ if (aparam.sample_rate() <= 0) {
+ set_error(QStringLiteral("sequence has no valid audio parameters"));
+ return OAKENGINE_E_STATE;
+ }
+
+ // One frame of audio starting at time_ts.
+ const olive::Rational in =
+ olive::core::Timecode::timestamp_to_time(time_ts, tb);
+ const olive::TimeRange range(in, in + tb);
+
+ QString error;
+ olive::SampleBuffer samples;
+ const int render_rc =
+ render_audio_sync(sequence, range, aparam, &samples, &error);
+ if (render_rc != OAKENGINE_OK) {
+ set_error(error);
+ return render_rc;
+ }
+
+ // Linear RMS per channel; content-free ranges (unallocated buffer)
+ // report exact zeros.
+ const int channels =
+ qMin(channel_count, samples.is_allocated() ? samples.channel_count() :
+ 0);
+ for (int ch = 0; ch < channels; ch++) {
+ const float *data = samples.data(ch);
+ const size_t count = samples.sample_count();
+ double sum = 0.0;
+ for (size_t i = 0; i < count; i++) {
+ sum += double(data[i]) * double(data[i]);
+ }
+ values[ch] = count > 0 ? std::sqrt(sum / double(count)) : 0.0;
+ }
+ for (int ch = channels; ch < channel_count; ch++) {
+ values[ch] = 0.0;
+ }
+ return channels;
+}
+
+int oakengine_preview_get_waveform_summary(OakEngineFootage *footage,
+ int channel, int64_t start_ts,
+ int64_t end_ts, double *min_vals,
+ double *max_vals, int count)
+{
+ set_error(QString());
+ olive::Footage *node =
+ static_cast(oakengine_capi_footage_node(footage));
+ if (!footage) {
+ set_error(QStringLiteral("invalid footage handle"));
+ return OAKENGINE_E_INVALID;
+ }
+ if (!node) {
+ set_error(QStringLiteral(
+ "probe handles carry no project node; import the media first"));
+ return OAKENGINE_E_INVALID;
+ }
+ if (!min_vals || !max_vals || count <= 0 || start_ts < 0 ||
+ end_ts <= start_ts) {
+ set_error(QStringLiteral("invalid arguments"));
+ return OAKENGINE_E_INVALID;
+ }
+ olive::Project *project = node->project();
+ if (!project) {
+ set_error(QStringLiteral("footage is not part of a project"));
+ return OAKENGINE_E_INVALID;
+ }
+ if (node->get_audio_stream_count() < 1) {
+ set_error(QStringLiteral("footage has no audio stream"));
+ return OAKENGINE_E_INVALID;
+ }
+ olive::AudioParams aparam = node->get_audio_params(0);
+ // Render in the engine's internal float format regardless of the
+ // source sample format (the sequence path uses f32 too); rendering
+ // with the source format (e.g. s16) would reinterpret f32 conforms
+ // as garbage.
+ aparam = olive::AudioParams(aparam.sample_rate(),
+ aparam.channel_layout(),
+ olive::core::SampleFormat::f32_p);
+ if (channel < 0 || channel >= aparam.channel_count()) {
+ set_error(QStringLiteral("channel %1 out of range (%2 channels)")
+ .arg(channel)
+ .arg(aparam.channel_count()));
+ return OAKENGINE_E_INVALID;
+ }
+
+ const olive::Rational tb = project_time_base(project);
+ const olive::Rational in =
+ olive::core::Timecode::timestamp_to_time(start_ts, tb);
+ const olive::Rational out =
+ olive::core::Timecode::timestamp_to_time(end_ts, tb);
+
+ QString error;
+ olive::SampleBuffer samples;
+ const int render_rc = render_audio_sync(node, olive::TimeRange(in, out),
+ aparam, &samples, &error);
+ if (render_rc != OAKENGINE_OK) {
+ set_error(error);
+ return render_rc;
+ }
+
+ // Reduce the samples into `count` equal buckets.
+ for (int bucket = 0; bucket < count; bucket++) {
+ min_vals[bucket] = 0.0;
+ max_vals[bucket] = 0.0;
+ }
+ if (!samples.is_allocated() || channel >= samples.channel_count()) {
+ return OAKENGINE_OK; // no content: exact zeros
+ }
+ const float *data = samples.data(channel);
+ const size_t total = samples.sample_count();
+ for (size_t i = 0; i < total; i++) {
+ const size_t bucket = qMin(size_t(count - 1), i * size_t(count) / total);
+ const double v = double(data[i]);
+ if (i == 0 || v < min_vals[bucket]) {
+ min_vals[bucket] = v;
+ }
+ if (i == 0 || v > max_vals[bucket]) {
+ max_vals[bucket] = v;
+ }
+ }
+ return OAKENGINE_OK;
+}
+
+} // extern "C"
diff --git a/engine/src/capi/renderer.cpp b/engine/src/capi/renderer.cpp
index 854db0af9..ee5cb344e 100644
--- a/engine/src/capi/renderer.cpp
+++ b/engine/src/capi/renderer.cpp
@@ -25,6 +25,7 @@
#include
#include
+#include
#include
#include
#include
@@ -135,6 +136,10 @@ bool wait_for_ticket(OakEngineRendererState *state,
timer.start();
while (!finished.load() && !ticket->is_cancelled() &&
!timer.hasExpired(k_render_timeout_ms)) {
+ // Pump events, not just sleep: audio conforms signal their
+ // completion through this thread's event queue, and a bare sleep
+ // loop would starve them (the export family waits the same way).
+ QCoreApplication::processEvents(QEventLoop::AllEvents, 20);
QThread::msleep(5);
}
@@ -330,24 +335,43 @@ OakEngineAudioBuffer *oakengine_renderer_render_audio(
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;
+ // A ticket can finish "successfully" with an empty buffer when the
+ // audio conform was still generating (RenderProcessor marks it
+ // "incomplete" instead of blocking): resubmit until the conform is
+ // ready, up to the overall render timeout.
+ olive::SampleBuffer samples;
+ QElapsedTimer retry_timer;
+ retry_timer.start();
+ while (true) {
+ 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;
+ }
+
+ if (!ticket->property("incomplete").toBool()) {
+ samples = ticket->get().value();
+ break;
+ }
+ if (retry_timer.hasExpired(k_render_timeout_ms)) {
+ set_error(state, QStringLiteral(
+ "audio conform did not finish in time"));
+ return nullptr;
+ }
+ // Conform still generating; let it finish and try again.
+ QThread::msleep(50);
}
- 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;
diff --git a/engine/tests/oakengine_preview_test.cpp b/engine/tests/oakengine_preview_test.cpp
new file mode 100644
index 000000000..0deb2002c
--- /dev/null
+++ b/engine/tests/oakengine_preview_test.cpp
@@ -0,0 +1,275 @@
+/***
+
+ 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 preview facade: clip loop mode,
+// per-channel audio levels (one-frame RMS) and footage waveform summary.
+// Audio renders through RenderManager's audio thread and needs no GL
+// (only the RENDER init bit). Uses the real media file tests/demo.mp4.
+
+#include
+#include
+#include
+#include
+
+#if defined(_WIN32)
+#include
+#include
+#else
+#include
+#endif
+
+#include "oakengine/footage.h"
+#include "oakengine/init.h"
+#include "oakengine/preview.h"
+#include "oakengine/project.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_preview_test_%lu",
+ base, (unsigned long)GetCurrentProcessId());
+ assert(_mkdir(g_tmpdir) == 0);
+#else
+ strcpy(g_tmpdir, "/tmp/oakengine_preview_test_XXXXXX");
+ assert(mkdtemp(g_tmpdir) != NULL);
+#endif
+}
+
+static void demo_path(char *dst, size_t cap)
+{
+ const int n = snprintf(dst, cap, "%s/tests/demo.mp4", OAK_TEST_SOURCE_DIR);
+ assert(n > 0 && (size_t)n < cap);
+}
+
+static void test_loop_mode(OakEngineProject *project, OakEngineClip *clip)
+{
+ assert(oakengine_clip_get_loop_mode(clip) == OAKENGINE_LOOP_MODE_OFF);
+
+ assert(oakengine_clip_set_loop_mode(clip, OAKENGINE_LOOP_MODE_LOOP) ==
+ OAKENGINE_OK);
+ assert(oakengine_clip_get_loop_mode(clip) == OAKENGINE_LOOP_MODE_LOOP);
+ assert(oakengine_clip_set_loop_mode(clip, OAKENGINE_LOOP_MODE_CLAMP) ==
+ OAKENGINE_OK);
+ assert(oakengine_clip_get_loop_mode(clip) == OAKENGINE_LOOP_MODE_CLAMP);
+
+ // Unknown modes and NULL are rejected.
+ assert(oakengine_clip_set_loop_mode(clip, 3) == OAKENGINE_E_INVALID);
+ assert(oakengine_clip_set_loop_mode(clip, -1) == OAKENGINE_E_INVALID);
+ assert(oakengine_clip_set_loop_mode(NULL, 0) == OAKENGINE_E_INVALID);
+ assert(oakengine_clip_get_loop_mode(NULL) == OAKENGINE_E_INVALID);
+
+ // Undoable: undo restores LOOP then OFF, redo restores LOOP then CLAMP.
+ assert(oakengine_project_undo(project) == OAKENGINE_OK);
+ assert(oakengine_clip_get_loop_mode(clip) == OAKENGINE_LOOP_MODE_LOOP);
+ assert(oakengine_project_undo(project) == OAKENGINE_OK);
+ assert(oakengine_clip_get_loop_mode(clip) == OAKENGINE_LOOP_MODE_OFF);
+ assert(oakengine_project_redo(project) == OAKENGINE_OK);
+ assert(oakengine_project_redo(project) == OAKENGINE_OK);
+ assert(oakengine_clip_get_loop_mode(clip) == OAKENGINE_LOOP_MODE_CLAMP);
+}
+
+// Generate a loud test tone (440 Hz stereo sine, 2 s) with the ffmpeg
+// CLI -- the demo file's own audio track is essentially silent
+// (volumedetect: -91 dB), so a real tone is needed to exercise the
+// "content present" assertions. Requires ffmpeg in PATH, like the proxy
+// tests do.
+static void make_tone(char *dst, size_t cap)
+{
+ const int n = snprintf(dst, cap, "%s/tone.wav", g_tmpdir);
+ assert(n > 0 && (size_t)n < cap);
+ char cmd[4608];
+ snprintf(cmd, sizeof(cmd),
+ "ffmpeg -v error -y -f lavfi -i "
+ "\"sine=frequency=440:duration=2\" -ar 48000 -ac 2 \"%s\"",
+ dst);
+ assert(system(cmd) == 0);
+ FILE *f = fopen(dst, "rb");
+ assert(f != NULL);
+ fclose(f);
+}
+
+static void test_levels(OakEngineSequence *seq)
+{
+ char err[256];
+ double levels[4] = { -1.0, -1.0, -1.0, -1.0 };
+
+ // Inside the clip (30 frames at 30000/1001): a loud sine on both
+ // channels. RMS of a full-scale sine is ~0.707.
+ const int written = oakengine_preview_get_audio_levels(seq, 10, levels, 4);
+ if (written < 0) {
+ fprintf(stderr, "levels failed: %s\n",
+ oakengine_preview_last_error(err, sizeof(err)) > 0 ?
+ err :
+ "(no error)");
+ }
+ assert(written == 2);
+ assert(levels[2] == 0.0 && levels[3] == 0.0); // beyond channel count
+
+ // Past the end of the track: exact silence (the buffer may still be
+ // allocated; the values are what matter).
+ double silent[2] = { -1.0, -1.0 };
+ assert(oakengine_preview_get_audio_levels(seq, 35, silent, 2) >= 0);
+ assert(silent[0] == 0.0 && silent[1] == 0.0);
+
+ // Error paths.
+ assert(oakengine_preview_get_audio_levels(NULL, 10, levels, 2) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_preview_get_audio_levels(seq, -1, levels, 2) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_preview_get_audio_levels(seq, 10, NULL, 2) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_preview_get_audio_levels(seq, 10, levels, 0) ==
+ OAKENGINE_E_INVALID);
+}
+
+static void test_waveform(OakEngineFootage *tone, OakEngineFootage *demo,
+ OakEngineFootage *probed)
+{
+ double mins[16], maxs[16];
+
+ // The tone in 10 buckets over one second: every bucket swings.
+ assert(oakengine_preview_get_waveform_summary(tone, 0, 0, 30, mins,
+ maxs, 10) == OAKENGINE_OK);
+ for (int i = 0; i < 10; i++) {
+ assert(mins[i] <= maxs[i]);
+ assert(mins[i] < 0.0 && maxs[i] > 0.0);
+ }
+
+ // The demo file's audio is essentially silent: tiny magnitudes.
+ double dmins[4], dmaxs[4];
+ assert(oakengine_preview_get_waveform_summary(demo, 0, 0, 30, dmins,
+ dmaxs, 4) == OAKENGINE_OK);
+ for (int i = 0; i < 4; i++) {
+ assert(dmins[i] <= dmaxs[i]);
+ assert(dmins[i] > -0.01 && dmaxs[i] < 0.01);
+ }
+
+ // Far past the media: exact zeros.
+ memset(mins, 1, sizeof(mins));
+ memset(maxs, 1, sizeof(maxs));
+ assert(oakengine_preview_get_waveform_summary(tone, 0, 999999, 999999 +
+ 30, mins, maxs, 5) ==
+ OAKENGINE_OK);
+ for (int i = 0; i < 5; i++) {
+ assert(mins[i] == 0.0 && maxs[i] == 0.0);
+ }
+
+ // Error paths: probe handle, bad channel, bad count, NULL.
+ assert(oakengine_preview_get_waveform_summary(probed, 0, 0, 30, mins,
+ maxs, 10) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_preview_get_waveform_summary(tone, 99, 0, 30, mins,
+ maxs, 10) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_preview_get_waveform_summary(tone, 0, 0, 30, mins,
+ maxs, 0) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_preview_get_waveform_summary(tone, 0, 30, 30, mins,
+ maxs, 10) ==
+ OAKENGINE_E_INVALID);
+ assert(oakengine_preview_get_waveform_summary(NULL, 0, 0, 30, mins, maxs,
+ 10) == OAKENGINE_E_INVALID);
+ assert(oakengine_preview_get_waveform_summary(tone, 0, 0, 30, NULL,
+ maxs, 10) ==
+ OAKENGINE_E_INVALID);
+}
+
+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
+
+ 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, "Preview");
+ assert(seq != NULL);
+
+ char path[4096], tone_path[4096];
+ demo_path(path, sizeof(path));
+ make_tone(tone_path, sizeof(tone_path));
+ // Levels render the tone clip on the sequence's audio track; the demo
+ // file is used for the silent-content waveform case.
+ OakEngineFootage *tone =
+ oakengine_project_import_footage(project, tone_path);
+ assert(tone != NULL);
+ OakEngineFootage *demo =
+ oakengine_project_import_footage(project, path);
+ assert(demo != NULL);
+ OakEngineFootage *probed = oakengine_footage_probe(path);
+ assert(probed != NULL);
+
+ // No RENDER bit yet: readouts fail with E_STATE.
+ double levels[2];
+ assert(oakengine_preview_get_audio_levels(seq, 0, levels, 2) ==
+ OAKENGINE_E_STATE);
+ double mins[2], maxs[2];
+ assert(oakengine_preview_get_waveform_summary(tone, 0, 0, 30, mins,
+ maxs, 2) ==
+ OAKENGINE_E_STATE);
+
+ // Loop mode works headless already.
+ OakEngineClip *clip = NULL;
+ assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) ==
+ 0);
+ assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) ==
+ 0);
+ clip = oakengine_sequence_add_footage_clip(
+ seq, demo, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0);
+ assert(clip != NULL);
+ // The audio readouts need content on the audio track too.
+ OakEngineClip *aclip = oakengine_sequence_add_footage_clip(
+ seq, tone, OAKENGINE_TRACK_TYPE_AUDIO, 0, 0, 30, 0);
+ assert(aclip != NULL);
+ test_loop_mode(project, clip);
+
+ // Upgrade to RENDER for the audio readouts (audio needs no GL).
+ assert(oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) ==
+ OAKENGINE_OK);
+ test_levels(seq);
+ test_waveform(tone, demo, probed);
+
+ oakengine_footage_free(probed);
+ oakengine_footage_free(demo);
+ oakengine_footage_free(tone);
+ oakengine_project_free(project);
+ assert(oakengine_shutdown() == OAKENGINE_OK);
+
+ printf("oakengine_preview_test: all assertions passed\n");
+ return 0;
+}