/***
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 viewer facade (oakengine/viewer.h)
// and the viewer events (oakengine/events.h ids 100-110). Exercises every
// function of the family on a Sequence (a ViewerOutput subclass): handle
// validation, input ids, playhead/length, stream params, enabled streams,
// workarea, parameter setup, waveform and the change notifications. No GL
// required (headless init, CPU only). Uses tests/demo.mp4 to give the
// sequence real content length.
#include
#include
#include
#include
#if defined(_WIN32)
#include
#include
#else
#include
#endif
#include "oakengine/events.h"
#include "oakengine/footage.h"
#include "oakengine/init.h"
#include "oakengine/node.h"
#include "oakengine/project.h"
#include "oakengine/timeline.h"
#include "oakengine/viewer.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_viewer_test_%lu", base,
(unsigned long)GetCurrentProcessId());
assert(_mkdir(g_tmpdir) == 0);
#else
strcpy(g_tmpdir, "/tmp/oakengine_viewer_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);
}
// A sequence handle is the same engine object pointer as its node handle
// (all facade handles are reinterpreted engine pointers; see the wrap()
// helpers in src/capi/timeline.cpp).
static OakEngineNode *as_node(OakEngineSequence *seq)
{
return (OakEngineNode *)seq;
}
// ---- Handle validation / constants ----------------------------------------
static void test_from_node(OakEngineProject *project, OakEngineSequence *seq)
{
OakEngineNode *seq_node = as_node(seq);
OakEngineNode *solid = oakengine_project_add_node(
project, "org.olivevideoeditor.Olive.solidgenerator");
assert(solid != NULL);
assert(oakengine_viewer_from_node(NULL) == NULL);
assert(oakengine_viewer_from_node(solid) == NULL);
assert(oakengine_viewer_from_node(seq_node) == seq_node);
assert(oakengine_viewer_from_const_node(NULL) == NULL);
assert(oakengine_viewer_from_const_node((const OakEngineNode *)solid) ==
NULL);
assert(oakengine_viewer_from_const_node((const OakEngineNode *)seq_node) ==
(const OakEngineNode *)seq_node);
// The input id constants are static, non-empty strings.
assert(oakengine_viewer_video_params_input_id() != NULL);
assert(oakengine_viewer_video_params_input_id()[0] != '\0');
assert(oakengine_viewer_audio_params_input_id()[0] != '\0');
assert(oakengine_viewer_subtitle_params_input_id()[0] != '\0');
assert(oakengine_viewer_texture_input_id()[0] != '\0');
assert(oakengine_viewer_samples_input_id()[0] != '\0');
assert(oakengine_viewer_default_sample_format() >= 0);
}
// ---- Playhead / length ------------------------------------------------------
static void test_playhead(OakEngineSequence *seq)
{
int64_t num = -1, den = -1;
assert(oakengine_viewer_get_playhead(NULL, &num, &den) ==
OAKENGINE_E_INVALID);
assert(oakengine_viewer_get_playhead(as_node(seq), &num, &den) ==
OAKENGINE_OK);
assert(num == 0);
assert(oakengine_viewer_set_playhead(NULL, 1, 1) == OAKENGINE_E_INVALID);
assert(oakengine_viewer_set_playhead(as_node(seq), 2, 1) == OAKENGINE_OK);
num = den = -1;
assert(oakengine_viewer_get_playhead(as_node(seq), &num, &den) ==
OAKENGINE_OK);
assert(num == 2 && den == 1);
}
static void test_lengths(OakEngineSequence *seq)
{
int64_t num = -1, den = -1;
assert(oakengine_viewer_get_length(NULL, &num, &den) ==
OAKENGINE_E_INVALID);
assert(oakengine_viewer_get_length(as_node(seq), &num, &den) ==
OAKENGINE_OK);
assert(num == 0);
assert(oakengine_viewer_get_video_length(as_node(seq), &num, &den) ==
OAKENGINE_OK);
assert(num == 0);
assert(oakengine_viewer_get_audio_length(as_node(seq), &num, &den) ==
OAKENGINE_OK);
assert(num == 0);
}
// ---- Stream parameters --------------------------------------------------------
static void test_stream_params(OakEngineSequence *seq)
{
const OakEngineNode *node = (const OakEngineNode *)as_node(seq);
oak_video_params vp;
int sr = -1, format = -1;
uint64_t layout = 1;
assert(oakengine_viewer_get_video_params(NULL, 0, &vp) ==
OAKENGINE_E_INVALID);
assert(oakengine_viewer_get_video_params(node, 0, NULL) ==
OAKENGINE_E_INVALID);
// A fresh sequence has one video and one audio stream, no subtitles.
assert(oakengine_viewer_get_video_stream_count(NULL) == 0);
assert(oakengine_viewer_get_video_stream_count(node) == 1);
assert(oakengine_viewer_get_audio_stream_count(node) == 1);
assert(oakengine_viewer_get_subtitle_stream_count(node) == 0);
// In-range video params come from the sequence defaults.
assert(oakengine_viewer_get_video_params(node, 0, &vp) == OAKENGINE_OK);
assert(vp.width > 0 && vp.height > 0);
assert(vp.time_base_num > 0 && vp.time_base_den > 0);
// Out-of-range yields a zeroed struct (documented in viewer.h).
memset(&vp, 0xFF, sizeof(vp));
assert(oakengine_viewer_get_video_params(node, 99, &vp) == OAKENGINE_OK);
assert(vp.width == 0 && vp.height == 0);
// Audio params; out-of-range yields 0/0/0.
assert(oakengine_viewer_get_audio_params(NULL, 0, &sr, &layout,
&format) == OAKENGINE_E_INVALID);
assert(oakengine_viewer_get_audio_params(node, 0, &sr, &layout,
&format) == OAKENGINE_OK);
assert(sr > 0 && layout != 0);
sr = -1;
layout = 1;
format = -1;
assert(oakengine_viewer_get_audio_params(node, 99, &sr, &layout,
&format) == OAKENGINE_OK);
assert(sr == 0 && layout == 0 && format == 0);
// Per-stream enabled flags: video/audio stream 0 are enabled by
// default; subtitle has no stream 0.
assert(oakengine_viewer_get_stream_enabled(NULL, OAKENGINE_TRACK_TYPE_VIDEO,
0) == OAKENGINE_E_INVALID);
assert(oakengine_viewer_get_stream_enabled(node, OAKENGINE_TRACK_TYPE_VIDEO,
0) == 1);
assert(oakengine_viewer_get_stream_enabled(node, OAKENGINE_TRACK_TYPE_AUDIO,
0) == 1);
assert(oakengine_viewer_get_stream_enabled(
node, OAKENGINE_TRACK_TYPE_SUBTITLE, 0) == 0);
assert(oakengine_viewer_get_stream_enabled(node, 99, 0) ==
OAKENGINE_E_INVALID);
// Subtitle access: no subtitle streams on a fresh sequence.
assert(oakengine_viewer_get_subtitle_count(NULL, 0) == OAKENGINE_E_INVALID);
assert(oakengine_viewer_get_subtitle_count(node, 0) == OAKENGINE_E_INVALID);
assert(oakengine_viewer_get_subtitle_at(NULL, 0, 0) == NULL);
assert(oakengine_viewer_get_subtitle_at(node, 0, 0) == NULL);
}
static void test_enabled_streams(OakEngineSequence *seq)
{
const OakEngineNode *node = (const OakEngineNode *)as_node(seq);
oak_video_params vp;
assert(oakengine_viewer_has_enabled_streams(NULL,
OAKENGINE_TRACK_TYPE_VIDEO) ==
0);
assert(oakengine_viewer_has_enabled_streams(node,
OAKENGINE_TRACK_TYPE_VIDEO) ==
1);
assert(oakengine_viewer_has_enabled_streams(node,
OAKENGINE_TRACK_TYPE_AUDIO) ==
1);
assert(oakengine_viewer_has_enabled_streams(
node, OAKENGINE_TRACK_TYPE_SUBTITLE) == 0);
assert(oakengine_viewer_has_enabled_streams(node, 99) == 0);
assert(oakengine_viewer_get_first_enabled_video_stream(NULL, &vp) ==
OAKENGINE_E_INVALID);
assert(oakengine_viewer_get_first_enabled_video_stream(node, &vp) ==
OAKENGINE_OK);
assert(vp.width > 0 && vp.height > 0);
// Enabled stream references: video:0 and audio:0.
assert(oakengine_viewer_get_enabled_stream_count(NULL) == 0);
const int count = oakengine_viewer_get_enabled_stream_count(node);
assert(count == 2);
// Query form (max = 0, NULL arrays) returns the total count.
assert(oakengine_viewer_get_enabled_streams(node, NULL, NULL, 0) == count);
int types[8];
int indices[8];
memset(types, -1, sizeof(types));
memset(indices, -1, sizeof(indices));
assert(oakengine_viewer_get_enabled_streams(node, types, indices, 8) ==
count);
int saw_video = 0, saw_audio = 0;
for (int i = 0; i < count; i++) {
assert(indices[i] == 0);
if (types[i] == OAKENGINE_TRACK_TYPE_VIDEO) {
saw_video = 1;
} else if (types[i] == OAKENGINE_TRACK_TYPE_AUDIO) {
saw_audio = 1;
} else {
assert(0); // unexpected stream type
}
}
assert(saw_video && saw_audio);
// A smaller max truncates the write but still returns the total.
types[0] = types[1] = -1;
indices[0] = indices[1] = -1;
assert(oakengine_viewer_get_enabled_streams(node, types, indices, 1) ==
count);
assert(types[0] != -1 && types[1] == -1);
}
// ---- Workarea ------------------------------------------------------------------
static void test_workarea(OakEngineSequence *seq)
{
OakEngineNode *node = as_node(seq);
oakengine_viewer_workarea wa;
assert(oakengine_viewer_get_workarea(NULL, &wa) == OAKENGINE_E_INVALID);
assert(oakengine_viewer_get_workarea(node, NULL) == OAKENGINE_E_INVALID);
memset(&wa, 0xFF, sizeof(wa));
assert(oakengine_viewer_get_workarea(node, &wa) == OAKENGINE_OK);
assert(wa.enabled == 0);
assert(oakengine_viewer_set_workarea_range(NULL, 0, 1, 1, 1) ==
OAKENGINE_E_INVALID);
assert(oakengine_viewer_set_workarea_range(node, 1, 1, 5, 1) ==
OAKENGINE_OK);
assert(oakengine_viewer_set_workarea_enabled(NULL, 1) ==
OAKENGINE_E_INVALID);
assert(oakengine_viewer_set_workarea_enabled(node, 1) == OAKENGINE_OK);
memset(&wa, 0, sizeof(wa));
assert(oakengine_viewer_get_workarea(node, &wa) == OAKENGINE_OK);
assert(wa.in_num == 1 && wa.in_den == 1);
assert(wa.out_num == 5 && wa.out_den == 1);
assert(wa.enabled == 1);
assert(oakengine_viewer_set_workarea_enabled(node, 0) == OAKENGINE_OK);
assert(oakengine_viewer_get_workarea(node, &wa) == OAKENGINE_OK);
assert(wa.enabled == 0);
}
// ---- Parameter setup / waveform -------------------------------------------------
static void test_parameter_setup(OakEngineProject *project,
OakEngineSequence *seq)
{
OakEngineNode *node = as_node(seq);
assert(oakengine_viewer_set_default_parameters(NULL) ==
OAKENGINE_E_INVALID);
assert(oakengine_viewer_set_default_parameters(node) == OAKENGINE_OK);
// set_parameters_from_footage accepts any viewer handles; a second
// sequence stands in for the footage array here.
OakEngineSequence *other = oakengine_sequence_new(project, "Other");
assert(other != NULL);
OakEngineNode *other_node = as_node(other);
assert(oakengine_viewer_set_parameters_from_footage(NULL, &other_node,
1) == OAKENGINE_E_INVALID);
assert(oakengine_viewer_set_parameters_from_footage(node, NULL, 1) ==
OAKENGINE_E_INVALID);
// An empty array is a valid no-op.
assert(oakengine_viewer_set_parameters_from_footage(node, NULL, 0) ==
OAKENGINE_OK);
// One invalid element rejects the whole call.
OakEngineNode *solid = oakengine_project_add_node(
project, "org.olivevideoeditor.Olive.solidgenerator");
assert(solid != NULL);
OakEngineNode *mixed[2] = { other_node, solid };
assert(oakengine_viewer_set_parameters_from_footage(node, mixed, 2) ==
OAKENGINE_E_INVALID);
// All viewers: OK, and the params are adopted.
OakEngineNode *viewers[1] = { other_node };
assert(oakengine_viewer_set_parameters_from_footage(node, viewers, 1) ==
OAKENGINE_OK);
// Waveform toggle; nothing is connected to the samples input, so the
// connected waveform is NULL.
assert(oakengine_viewer_set_waveform_enabled(NULL, 1) ==
OAKENGINE_E_INVALID);
assert(oakengine_viewer_set_waveform_enabled(node, 1) == OAKENGINE_OK);
assert(oakengine_viewer_set_waveform_enabled(node, 0) == OAKENGINE_OK);
assert(oakengine_viewer_get_connected_waveform(NULL) == NULL);
assert(oakengine_viewer_get_connected_waveform(
(const OakEngineNode *)node) == NULL);
}
// ---- Events ---------------------------------------------------------------------
struct EventLog {
int playhead_events;
int64_t playhead_num;
int64_t playhead_den;
int length_events;
int64_t length_num;
int64_t length_den;
int size_events;
int64_t size_w;
int64_t size_h;
int video_params_events;
int audio_params_events;
int sample_rate_events;
int64_t sample_rate;
int texture_events;
int frame_rate_events;
int pixel_aspect_events;
int interlacing_events;
int64_t interlacing_mode;
int waveform_events;
};
static void record_event(const oakengine_event *event, void *userdata)
{
struct EventLog *log = (struct EventLog *)userdata;
assert(event != NULL);
switch (event->id) {
case OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED:
log->playhead_events++;
log->playhead_num = event->a;
log->playhead_den = event->b;
break;
case OAKENGINE_EVENT_VIEWER_LENGTH_CHANGED:
log->length_events++;
log->length_num = event->a;
log->length_den = event->b;
break;
case OAKENGINE_EVENT_VIEWER_SIZE_CHANGED:
log->size_events++;
log->size_w = event->a;
log->size_h = event->b;
break;
case OAKENGINE_EVENT_VIEWER_VIDEO_PARAMS_CHANGED:
log->video_params_events++;
break;
case OAKENGINE_EVENT_VIEWER_AUDIO_PARAMS_CHANGED:
log->audio_params_events++;
break;
case OAKENGINE_EVENT_VIEWER_SAMPLE_RATE_CHANGED:
log->sample_rate_events++;
log->sample_rate = event->a;
break;
case OAKENGINE_EVENT_VIEWER_TEXTURE_INPUT_CHANGED:
log->texture_events++;
break;
case OAKENGINE_EVENT_VIEWER_FRAME_RATE_CHANGED:
log->frame_rate_events++;
break;
case OAKENGINE_EVENT_VIEWER_PIXEL_ASPECT_CHANGED:
log->pixel_aspect_events++;
break;
case OAKENGINE_EVENT_VIEWER_INTERLACING_CHANGED:
log->interlacing_events++;
log->interlacing_mode = event->a;
break;
case OAKENGINE_EVENT_VIEWER_CONNECTED_WAVEFORM_CHANGED:
log->waveform_events++;
break;
default:
assert(0); // unexpected event id on this subscription
}
}
static int64_t subscribe_checked(OakEngineNode *node, int32_t id,
struct EventLog *log)
{
const int64_t sub = oakengine_event_subscribe(node, id, record_event, log);
assert(sub > 0);
return sub;
}
static void test_events(OakEngineProject *project, OakEngineSequence *seq,
const char *media_path)
{
struct EventLog log;
memset(&log, 0, sizeof(log));
OakEngineNode *node = as_node(seq);
// Family mismatch: a viewer event on a non-viewer node must fail.
OakEngineNode *solid = oakengine_project_add_node(
project, "org.olivevideoeditor.Olive.solidgenerator");
assert(solid != NULL);
assert(oakengine_event_subscribe(solid,
OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED,
record_event, &log) == 0);
int64_t subs[16];
int n = 0;
subs[n++] = subscribe_checked(node, OAKENGINE_EVENT_VIEWER_LENGTH_CHANGED,
&log);
subs[n++] =
subscribe_checked(node, OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED, &log);
subs[n++] = subscribe_checked(
node, OAKENGINE_EVENT_VIEWER_FRAME_RATE_CHANGED, &log);
subs[n++] =
subscribe_checked(node, OAKENGINE_EVENT_VIEWER_SIZE_CHANGED, &log);
subs[n++] = subscribe_checked(
node, OAKENGINE_EVENT_VIEWER_PIXEL_ASPECT_CHANGED, &log);
subs[n++] = subscribe_checked(
node, OAKENGINE_EVENT_VIEWER_INTERLACING_CHANGED, &log);
subs[n++] = subscribe_checked(
node, OAKENGINE_EVENT_VIEWER_VIDEO_PARAMS_CHANGED, &log);
subs[n++] = subscribe_checked(
node, OAKENGINE_EVENT_VIEWER_AUDIO_PARAMS_CHANGED, &log);
subs[n++] = subscribe_checked(
node, OAKENGINE_EVENT_VIEWER_TEXTURE_INPUT_CHANGED, &log);
subs[n++] = subscribe_checked(
node, OAKENGINE_EVENT_VIEWER_SAMPLE_RATE_CHANGED, &log);
subs[n++] = subscribe_checked(
node, OAKENGINE_EVENT_VIEWER_CONNECTED_WAVEFORM_CHANGED, &log);
// Playhead.
assert(oakengine_viewer_set_playhead(node, 3, 1) == OAKENGINE_OK);
assert(log.playhead_events == 1);
assert(log.playhead_num == 3 && log.playhead_den == 1);
// Length: placing a real clip makes verify_length() emit
// length_changed with the new content length.
OakEngineFootage *footage =
oakengine_project_import_footage(project, media_path);
assert(footage != NULL);
assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == 0);
OakEngineClip *clip = oakengine_sequence_add_footage_clip(
seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0);
assert(clip != NULL);
assert(log.length_events >= 1);
assert(log.length_num > 0 && log.length_den > 0);
int64_t len_num = -1, len_den = -1;
assert(oakengine_viewer_get_length(node, &len_num, &len_den) ==
OAKENGINE_OK);
assert(len_num == log.length_num && len_den == log.length_den);
// Video params: changing the size emits size_changed (with the new
// dimensions as a/b) and video_params_changed.
assert(oakengine_sequence_set_video_params(seq, 1280, 720, -1, -1, -1, -1,
-1, -1, 0) == OAKENGINE_OK);
assert(log.size_events == 1);
assert(log.size_w == 1280 && log.size_h == 720);
assert(log.video_params_events == 1);
assert(log.frame_rate_events == 0);
assert(log.pixel_aspect_events == 0);
assert(log.interlacing_events == 0);
// Pixel aspect and interlacing changes fire their own events.
assert(oakengine_sequence_set_video_params(seq, -1, -1, -1, -1, 4, 3, -1,
-1, 0) == OAKENGINE_OK);
assert(log.pixel_aspect_events == 1);
assert(oakengine_sequence_set_video_params(seq, -1, -1, -1, -1, -1, -1, 1,
-1, 0) == OAKENGINE_OK);
assert(log.interlacing_events == 1);
assert(log.interlacing_mode == 1);
// Audio params: a new sample rate emits sample_rate_changed (a = rate)
// and audio_params_changed.
assert(oakengine_sequence_set_audio_params(seq, 44100, 0, 0) ==
OAKENGINE_OK);
assert(log.sample_rate_events == 1);
assert(log.sample_rate == 44100);
assert(log.audio_params_events == 1);
// Texture input: the placed clip's track auto-connected the viewer's
// texture input, so disconnect it first, then connect a node and check
// that texture_input_changed fired.
assert(oakengine_node_disconnect(node,
oakengine_viewer_texture_input_id()) ==
OAKENGINE_OK);
assert(oakengine_node_connect(solid, node,
oakengine_viewer_texture_input_id()) ==
OAKENGINE_OK);
assert(log.texture_events >= 1);
assert(oakengine_node_disconnect(node,
oakengine_viewer_texture_input_id()) ==
OAKENGINE_OK);
// connected_waveform_changed requires a connected sample output with a
// waveform cache; there is no audio-producing node chain in this test,
// so only the subscription itself is exercised above.
while (n > 0) {
assert(oakengine_event_unsubscribe(subs[--n]) == OAKENGINE_OK);
}
}
int main(void)
{
make_tmpdir();
// Sandbox the config/cache/data locations.
#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, "ViewerSeq");
assert(seq != NULL);
test_from_node(project, seq);
test_playhead(seq);
test_lengths(seq);
test_stream_params(seq);
test_enabled_streams(seq);
test_workarea(seq);
test_parameter_setup(project, seq);
char media[4096];
demo_path(media, sizeof(media));
// test_parameter_setup() called set_default_parameters(), which reads
// the (empty, sandboxed) user config and may leave invalid params
// behind (same hazard oakengine_sequence_new() backfills against).
// Restore known-good params so the clip/timebase paths work.
assert(oakengine_sequence_set_video_params(seq, 1920, 1080, 30000, 1001,
1, 1, 0, -1, 0) == OAKENGINE_OK);
assert(oakengine_sequence_set_audio_params(seq, 48000, 3, 0) ==
OAKENGINE_OK);
test_events(project, seq, media);
oakengine_project_free(project);
assert(oakengine_shutdown() == OAKENGINE_OK);
printf("oakengine_viewer_test: all assertions passed\n");
return 0;
}