/***
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 export facade. Codec probing and the
// argument/error paths require no GL; the actual export is GL-gated the same
// way as oakengine_renderer_test (dynamic backend probe + worker binary,
// SKIP with exit 0 when unavailable). The GL part builds a solid-color
// sequence through the engine C++ API (engine-internal test), exports one
// second of H.264 MP4 and validates it with ffprobe, and checks the
// progress callback.
#include
#include
#include
#include
#include
#if defined(_WIN32)
#include
#include
#else
#include
#endif
#include
#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/sequence/sequence.h"
#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND
#include "render/backend/dynamicrenderer.h"
#include "render/backend/renderbackend_c.h"
#endif
#include "oakengine/exporter.h"
#include "oakengine/footage.h"
#include "oakengine/init.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_export_test_%lu", base,
(unsigned long)GetCurrentProcessId());
assert(_mkdir(g_tmpdir) == 0);
#else
strcpy(g_tmpdir, "/tmp/oakengine_export_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
}
// ---- Progress callback state ----------------------------------------------
static int g_progress_calls = 0;
static double g_progress_last = -1.0;
static int g_progress_monotonic = 1;
static void progress_cb(double fraction, void *userdata)
{
(void)userdata;
g_progress_calls++;
if (fraction < g_progress_last - 1e-9) {
g_progress_monotonic = 0;
}
g_progress_last = fraction;
}
// ---- No-GL part -------------------------------------------------------------
static void test_codecs_and_validation(OakEngineSequence *seq)
{
// Codec probing needs no RENDER bit and no GL.
assert(oakengine_export_has_video_codec(OAKENGINE_EXPORT_VIDEO_H264) ==
1);
assert(oakengine_export_has_video_codec(OAKENGINE_EXPORT_VIDEO_H265) ==
1);
assert(oakengine_export_has_video_codec(
OAKENGINE_EXPORT_VIDEO_PNG_SEQUENCE) == 1);
assert(oakengine_export_has_video_codec(-2) == 0);
assert(oakengine_export_has_video_codec(99) == 0);
assert(oakengine_export_has_audio_codec(OAKENGINE_EXPORT_AUDIO_AAC) == 1);
assert(oakengine_export_has_audio_codec(OAKENGINE_EXPORT_AUDIO_PCM) == 1);
assert(oakengine_export_has_audio_codec(
OAKENGINE_EXPORT_AUDIO_NONE) == 0);
assert(oakengine_export_has_audio_codec(99) == 0);
// Argument validation (engine has no RENDER bit yet either).
char path[4096];
snprintf(path, sizeof(path), "%s/out.mp4", g_tmpdir);
assert(oakengine_export_render(NULL, path, 0, 30, 320, 180, NULL) ==
OAKENGINE_E_INVALID);
assert(oakengine_export_render(seq, NULL, 0, 30, 320, 180, NULL) ==
OAKENGINE_E_INVALID);
assert(oakengine_export_render(seq, path, -1, 30, 320, 180, NULL) ==
OAKENGINE_E_INVALID);
assert(oakengine_export_render(seq, path, 30, 30, 320, 180, NULL) ==
OAKENGINE_E_INVALID);
assert(oakengine_export_render(seq, path, 40, 30, 320, 180, NULL) ==
OAKENGINE_E_INVALID);
// Without OAKENGINE_INIT_RENDER the export is refused with E_STATE.
assert(oakengine_export_render(seq, path, 0, 30, 320, 180, NULL) ==
OAKENGINE_E_STATE);
char err[256];
assert(oakengine_export_last_error(err, sizeof(err)) > 0);
}
// 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, so a real
// tone is needed to exercise "audio content present" assertions.
static void make_tone(const char *path)
{
char cmd[4608];
snprintf(cmd, sizeof(cmd),
"ffmpeg -v error -y -f lavfi -i "
"\"sine=frequency=440:duration=2\" -ar 48000 -ac 2 \"%s\"",
path);
assert(system(cmd) == 0);
FILE *f = fopen(path, "rb");
assert(f != NULL);
fclose(f);
}
// Probe a media file with ffprobe/ffmpeg and assert the output contains a
// string (used for codec/dimension and loudness checks).
static void assert_probe_matches(const char *cmd, const char *needle)
{
FILE *probe = popen(cmd, "r");
assert(probe != NULL);
char out[2048] = { 0 };
const size_t len = fread(out, 1, sizeof(out) - 1, probe);
(void)len;
assert(pclose(probe) == 0);
assert(strstr(out, needle) != 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
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, "Export");
assert(seq != NULL);
test_codecs_and_validation(seq);
// ---- GL-gated part ------------------------------------------------------
if (!is_render_backend_available(QStringLiteral("opengl"))) {
printf("oakengine_export_test: SKIP: OpenGL render backend not "
"available, export assertions skipped\n");
oakengine_project_free(project);
oakengine_shutdown();
return 0;
}
if (!worker_binary_exists()) {
printf("oakengine_export_test: SKIP: oak-render-worker binary not "
"found, export assertions skipped\n");
oakengine_project_free(project);
oakengine_shutdown();
return 0;
}
olive::Config::current()[QStringLiteral("GraphicsBackend")] =
QStringLiteral("opengl");
assert(oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) ==
OAKENGINE_OK);
// Solid red generator -> texture input (engine C++ API, internal test).
auto *proj = reinterpret_cast(project);
auto *sequence = reinterpret_cast(seq);
auto *solid = new olive::SolidGenerator();
solid->setParent(proj);
olive::Node::connect_edge(
solid, olive::NodeInput(sequence, olive::ViewerOutput::k_texture_input));
// Export one second (30 frames at the default 30000/1001) of H.264.
char out[4096];
snprintf(out, sizeof(out), "%s/export.mp4", g_tmpdir);
oak_export_options opts;
memset(&opts, 0, sizeof(opts));
opts.video_codec = OAKENGINE_EXPORT_VIDEO_H264;
opts.audio_codec = OAKENGINE_EXPORT_AUDIO_NONE; // no audio content here
oakengine_export_set_progress_callback(progress_cb, NULL);
char err[512];
int rc = oakengine_export_render(seq, out, 0, 30, 320, 180, &opts);
if (rc != OAKENGINE_OK) {
fprintf(stderr, "export failed (%d): %s\n", rc,
oakengine_export_last_error(err, sizeof(err)) > 0 ?
err :
"(no error)");
}
assert(rc == OAKENGINE_OK);
oakengine_export_set_progress_callback(NULL, NULL);
// Progress was reported, monotonically, and completed.
assert(g_progress_calls > 0);
assert(g_progress_monotonic == 1);
assert(fabs(g_progress_last - 1.0) < 1e-6);
// Validate the MP4 with ffprobe: h264 video, 320x180, ~1 second.
assert(access(out, F_OK) == 0);
char cmd[4608];
snprintf(cmd, sizeof(cmd),
"ffprobe -v error -select_streams v:0 -show_entries "
"stream=codec_name,width,height,duration -of csv=p=0 \"%s\"",
out);
FILE *probe = popen(cmd, "r");
assert(probe != NULL);
char probe_out[512] = { 0 };
const size_t probe_len = fread(probe_out, 1, sizeof(probe_out) - 1, probe);
(void)probe_len;
assert(pclose(probe) == 0);
assert(strstr(probe_out, "h264") != NULL);
assert(strstr(probe_out, "320,180") != NULL);
// Duration is the last csv field; 30 frames at 30000/1001 ~= 1.001 s.
const char *comma = strrchr(probe_out, ',');
assert(comma != NULL);
const double duration = atof(comma + 1);
assert(fabs(duration - 1.001) < 0.15);
// ---- First-conform audio export ---------------------------------------
// The sandboxed cache guarantees no conform exists yet: this is the
// exact first-time-export path that used to come out silent. Lay a loud
// tone clip on an audio track and export with AAC audio; the exported
// audio must actually contain the tone.
{
char tone_path[4096];
snprintf(tone_path, sizeof(tone_path), "%s/tone.wav", g_tmpdir);
make_tone(tone_path);
// Import through the facade and place through the timeline editing
// primitives.
OakEngineFootage *tone =
oakengine_project_import_footage(project, tone_path);
assert(tone != NULL);
assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) ==
0);
assert(oakengine_sequence_add_footage_clip(
seq, tone, OAKENGINE_TRACK_TYPE_AUDIO, 0, 0, 30, 0) !=
NULL);
char out2[4096];
snprintf(out2, sizeof(out2), "%s/export_audio.mp4", g_tmpdir);
oak_export_options opts2;
memset(&opts2, 0, sizeof(opts2));
opts2.video_codec = OAKENGINE_EXPORT_VIDEO_H264;
opts2.audio_codec = OAKENGINE_EXPORT_AUDIO_AAC;
opts2.audio_sample_rate = 48000;
opts2.audio_channel_count = 2;
rc = oakengine_export_render(seq, out2, 0, 30, 320, 180, &opts2);
if (rc != OAKENGINE_OK) {
fprintf(stderr, "audio export failed (%d): %s\n", rc,
oakengine_export_last_error(err, sizeof(err)) > 0 ?
err :
"(no error)");
}
assert(rc == OAKENGINE_OK);
assert(access(out2, F_OK) == 0);
// The MP4 carries an AAC audio stream...
snprintf(cmd, sizeof(cmd),
"ffprobe -v error -select_streams a:0 -show_entries "
"stream=codec_name -of csv=p=0 \"%s\"",
out2);
assert_probe_matches(cmd, "aac");
// ...and the tone is really in there (sine is ~-24 dB; silence
// would read ~-91 dB).
snprintf(cmd, sizeof(cmd),
"ffmpeg -v info -i \"%s\" -map a:0 -af volumedetect -f "
"null - 2>&1 | grep mean_volume",
out2);
FILE *vol = popen(cmd, "r");
assert(vol != NULL);
char vol_out[512] = { 0 };
const size_t vol_len = fread(vol_out, 1, sizeof(vol_out) - 1, vol);
(void)vol_len;
assert(pclose(vol) == 0);
const char *db = strstr(vol_out, "mean_volume:");
assert(db != NULL);
const double mean_db = atof(db + strlen("mean_volume:"));
assert(mean_db > -60.0);
oakengine_footage_free(tone);
}
// ---- render_ex option validation (no GL needed) -------------------------
{
oak_export_options_ex bad;
memset(&bad, 0, sizeof(bad));
bad.format = 2; // mp4
bad.video_enabled = 1;
bad.video_codec = 1; // h264
bad.audio_enabled = 0;
char out_bad[4096];
snprintf(out_bad, sizeof(out_bad), "%s/bad.mp4", g_tmpdir);
bad.format = -1;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.format = 99;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.format = 2;
bad.video_codec = 99;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.video_codec = 1;
bad.range_mode = OAKENGINE_EXPORT_RANGE_CUSTOM;
bad.range_in_ts = 30;
bad.range_out_ts = 30;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.range_mode = OAKENGINE_EXPORT_RANGE_STILL;
bad.still_time_ts = -1;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.range_mode = OAKENGINE_EXPORT_RANGE_ENTIRE;
bad.color_transform = OAKENGINE_EXPORT_COLOR_CUSTOM;
bad.color_transform_name[0] = '\0';
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
bad.color_transform = OAKENGINE_EXPORT_COLOR_SRGB_OETF;
bad.video_pix_fmt = 99;
assert(oakengine_export_render_ex(seq, out_bad, &bad) ==
OAKENGINE_E_INVALID);
assert(oakengine_export_render_ex(NULL, out_bad, &bad) ==
OAKENGINE_E_INVALID);
assert(oakengine_export_render_ex(seq, NULL, &bad) ==
OAKENGINE_E_INVALID);
assert(oakengine_export_render_ex(seq, out_bad, NULL) ==
OAKENGINE_E_INVALID);
}
// ---- render_ex: real exports ---------------------------------------------
{
// H.264 + AAC over a custom 20-frame range of the same sequence.
char out3[4096];
snprintf(out3, sizeof(out3), "%s/ex_custom.mp4", g_tmpdir);
oak_export_options_ex o3;
memset(&o3, 0, sizeof(o3));
o3.range_mode = OAKENGINE_EXPORT_RANGE_CUSTOM;
o3.range_in_ts = 5;
o3.range_out_ts = 25;
o3.format = 2; // ExportFormat::k_format_mpe_g4_video
o3.video_enabled = 1;
o3.video_codec = 1; // ExportCodec::k_codec_h264
o3.audio_enabled = 1;
o3.audio_codec = 11; // ExportCodec::k_codec_aac
o3.audio_sample_rate = 48000;
o3.audio_channel_layout = 0x3;
o3.video_width = 320;
o3.video_height = 180;
rc = oakengine_export_render_ex(seq, out3, &o3);
if (rc != OAKENGINE_OK) {
fprintf(stderr, "ex custom export failed (%d): %s\n", rc,
oakengine_export_last_error(err, sizeof(err)) > 0 ?
err :
"(no error)");
}
assert(rc == OAKENGINE_OK);
snprintf(cmd, sizeof(cmd),
"ffprobe -v error -show_entries stream=codec_type,duration "
"-of csv=p=0 \"%s\"",
out3);
FILE *p3 = popen(cmd, "r");
assert(p3 != NULL);
char p3_out[512] = { 0 };
const size_t p3_len = fread(p3_out, 1, sizeof(p3_out) - 1, p3);
(void)p3_len;
assert(pclose(p3) == 0);
assert(strstr(p3_out, "video") != NULL);
assert(strstr(p3_out, "audio") != NULL);
// 20 frames at 30000/1001 ~= 0.667 s.
assert(strstr(p3_out, "0.6") != NULL);
// PNG sequence over 5 frames (image-sequence flag auto-fills the
// frame placeholder).
char out4[4096];
snprintf(out4, sizeof(out4), "%s/seqout.png", g_tmpdir);
oak_export_options_ex o4 = o3;
o4.range_in_ts = 0;
o4.range_out_ts = 5;
o4.format = 5; // ExportFormat::k_format_png
o4.video_codec = 5; // ExportCodec::k_codec_png
o4.audio_enabled = 0;
o4.is_image_sequence = 1;
rc = oakengine_export_render_ex(seq, out4, &o4);
if (rc != OAKENGINE_OK) {
fprintf(stderr, "ex png export failed (%d): %s\n", rc,
oakengine_export_last_error(err, sizeof(err)) > 0 ?
err :
"(no error)");
}
assert(rc == OAKENGINE_OK);
char png0[4096];
snprintf(png0, sizeof(png0), "%s/seqout_00000.png", g_tmpdir);
assert(access(png0, F_OK) == 0);
snprintf(cmd, sizeof(cmd), "file \"%s\"", png0);
assert_probe_matches(cmd, "PNG image data");
// Cancellation from another thread: either the export finishes
// first (idempotent) or reports E_CANCELLED; the exporter must stay
// usable afterwards.
oak_export_options_ex o5 = o3;
snprintf(out3, sizeof(out3), "%s/ex_cancel.mp4", g_tmpdir);
std::thread canceller([]() {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
oakengine_export_cancel();
});
const int cancel_rc = oakengine_export_render_ex(seq, out3, &o5);
canceller.join();
assert(cancel_rc == OAKENGINE_OK ||
cancel_rc == OAKENGINE_E_CANCELLED);
snprintf(out3, sizeof(out3), "%s/ex_after.mp4", g_tmpdir);
assert(oakengine_export_render_ex(seq, out3, &o5) == OAKENGINE_OK);
}
oakengine_project_free(project);
assert(oakengine_shutdown() == OAKENGINE_OK);
printf("oakengine_export_test: all assertions passed\n");
return 0;
}