From bb17c59c0f169d58fba9076c35dbe3cfdc8cc081 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Mon, 20 Jul 2026 09:28:47 +0800 Subject: [PATCH] engine: add the preview family to the C ABI facade - clip-level loop mode (off/loop/clamp), undoable - per-channel audio levels as one-frame RMS at a timestamp - waveform min/max buckets over a footage range, rendered on demand - two real fixes uncovered by this family: conform completion signals were starved by msleep-only waits (audio renders always came back empty), and incomplete tickets from conform-pending renders are now retried until the conform is ready --- engine/CMakeLists.txt | 23 ++ engine/include/oakengine/preview.h | 125 +++++++++ engine/src/capi/CMakeLists.txt | 2 + engine/src/capi/preview.cpp | 345 ++++++++++++++++++++++++ engine/src/capi/renderer.cpp | 56 ++-- engine/tests/oakengine_preview_test.cpp | 275 +++++++++++++++++++ 6 files changed, 810 insertions(+), 16 deletions(-) create mode 100644 engine/include/oakengine/preview.h create mode 100644 engine/src/capi/preview.cpp create mode 100644 engine/tests/oakengine_preview_test.cpp 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; +}