/*** 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 #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); EXPECT_TRUE(len > 0 && len < MAX_PATH); snprintf(g_tmpdir, sizeof(g_tmpdir), "%soakengine_export_test_%lu", base, (unsigned long)GetCurrentProcessId()); EXPECT_TRUE(_mkdir(g_tmpdir) == 0); #else strcpy(g_tmpdir, "/tmp/oakengine_export_test_XXXXXX"); EXPECT_TRUE(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. EXPECT_TRUE(oakengine_export_has_video_codec(OAKENGINE_EXPORT_VIDEO_H264) == 1); EXPECT_TRUE(oakengine_export_has_video_codec(OAKENGINE_EXPORT_VIDEO_H265) == 1); EXPECT_TRUE(oakengine_export_has_video_codec( OAKENGINE_EXPORT_VIDEO_PNG_SEQUENCE) == 1); EXPECT_TRUE(oakengine_export_has_video_codec(-2) == 0); EXPECT_TRUE(oakengine_export_has_video_codec(99) == 0); EXPECT_TRUE(oakengine_export_has_audio_codec(OAKENGINE_EXPORT_AUDIO_AAC) == 1); EXPECT_TRUE(oakengine_export_has_audio_codec(OAKENGINE_EXPORT_AUDIO_PCM) == 1); EXPECT_TRUE(oakengine_export_has_audio_codec( OAKENGINE_EXPORT_AUDIO_NONE) == 0); EXPECT_TRUE(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); EXPECT_TRUE(oakengine_export_render(NULL, path, 0, 30, 320, 180, NULL) == OAKENGINE_E_INVALID); EXPECT_TRUE(oakengine_export_render(seq, NULL, 0, 30, 320, 180, NULL) == OAKENGINE_E_INVALID); EXPECT_TRUE(oakengine_export_render(seq, path, -1, 30, 320, 180, NULL) == OAKENGINE_E_INVALID); EXPECT_TRUE(oakengine_export_render(seq, path, 30, 30, 320, 180, NULL) == OAKENGINE_E_INVALID); EXPECT_TRUE(oakengine_export_render(seq, path, 40, 30, 320, 180, NULL) == OAKENGINE_E_INVALID); // Without OAKENGINE_INIT_RENDER the export is refused with E_STATE. EXPECT_TRUE(oakengine_export_render(seq, path, 0, 30, 320, 180, NULL) == OAKENGINE_E_STATE); char err[256]; EXPECT_TRUE(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); EXPECT_TRUE(system(cmd) == 0); FILE *f = fopen(path, "rb"); EXPECT_TRUE(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"); EXPECT_TRUE(probe != NULL); char out[2048] = { 0 }; const size_t len = fread(out, 1, sizeof(out) - 1, probe); (void)len; EXPECT_TRUE(pclose(probe) == 0); EXPECT_TRUE(strstr(out, needle) != NULL); } TEST(OakEngineExport, Main) { make_tmpdir(); // Sandbox the config/cache/data locations (see oakengine_init_test). #if !defined(_WIN32) EXPECT_TRUE(setenv("XDG_CONFIG_HOME", g_tmpdir, 1) == 0); EXPECT_TRUE(setenv("XDG_CACHE_HOME", g_tmpdir, 1) == 0); EXPECT_TRUE(setenv("XDG_DATA_HOME", g_tmpdir, 1) == 0); EXPECT_TRUE(setenv("OAK_CONFIG_DIR", g_tmpdir, 1) == 0); #endif EXPECT_TRUE(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); OakEngineProject *project = oakengine_project_create(); EXPECT_TRUE(project != NULL); EXPECT_TRUE(oakengine_project_new(project) == OAKENGINE_OK); OakEngineSequence *seq = oakengine_sequence_new(project, "Export"); EXPECT_TRUE(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; } 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; } olive::Config::current()[QStringLiteral("GraphicsBackend")] = QStringLiteral("opengl"); EXPECT_TRUE(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)"); } EXPECT_TRUE(rc == OAKENGINE_OK); oakengine_export_set_progress_callback(NULL, NULL); // Progress was reported, monotonically, and completed. EXPECT_TRUE(g_progress_calls > 0); EXPECT_TRUE(g_progress_monotonic == 1); EXPECT_TRUE(fabs(g_progress_last - 1.0) < 1e-6); // Validate the MP4 with ffprobe: h264 video, 320x180, ~1 second. EXPECT_TRUE(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"); EXPECT_TRUE(probe != NULL); char probe_out[512] = { 0 }; const size_t probe_len = fread(probe_out, 1, sizeof(probe_out) - 1, probe); (void)probe_len; EXPECT_TRUE(pclose(probe) == 0); EXPECT_TRUE(strstr(probe_out, "h264") != NULL); EXPECT_TRUE(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, ','); EXPECT_TRUE(comma != NULL); const double duration = atof(comma + 1); EXPECT_TRUE(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); EXPECT_TRUE(tone != NULL); EXPECT_TRUE(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == 0); EXPECT_TRUE(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)"); } EXPECT_TRUE(rc == OAKENGINE_OK); EXPECT_TRUE(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"); EXPECT_TRUE(vol != NULL); char vol_out[512] = { 0 }; const size_t vol_len = fread(vol_out, 1, sizeof(vol_out) - 1, vol); (void)vol_len; EXPECT_TRUE(pclose(vol) == 0); const char *db = strstr(vol_out, "mean_volume:"); EXPECT_TRUE(db != NULL); const double mean_db = atof(db + strlen("mean_volume:")); EXPECT_TRUE(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; EXPECT_TRUE(oakengine_export_render_ex(seq, out_bad, &bad) == OAKENGINE_E_INVALID); bad.format = 99; EXPECT_TRUE(oakengine_export_render_ex(seq, out_bad, &bad) == OAKENGINE_E_INVALID); bad.format = 2; bad.video_codec = 99; EXPECT_TRUE(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; EXPECT_TRUE(oakengine_export_render_ex(seq, out_bad, &bad) == OAKENGINE_E_INVALID); bad.range_mode = OAKENGINE_EXPORT_RANGE_STILL; bad.still_time_ts = -1; EXPECT_TRUE(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'; EXPECT_TRUE(oakengine_export_render_ex(seq, out_bad, &bad) == OAKENGINE_E_INVALID); bad.color_transform = OAKENGINE_EXPORT_COLOR_SRGB_OETF; bad.video_pix_fmt = 99; EXPECT_TRUE(oakengine_export_render_ex(seq, out_bad, &bad) == OAKENGINE_E_INVALID); EXPECT_TRUE(oakengine_export_render_ex(NULL, out_bad, &bad) == OAKENGINE_E_INVALID); EXPECT_TRUE(oakengine_export_render_ex(seq, NULL, &bad) == OAKENGINE_E_INVALID); EXPECT_TRUE(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. // Exercise the encoder-specific video option pass-through // (crf=18) for this render, then clear it so later exports and // the cancellation re-run are unaffected. oakengine_export_set_video_option("crf", "18"); 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)"); } EXPECT_TRUE(rc == OAKENGINE_OK); oakengine_export_set_video_option(NULL, NULL); snprintf(cmd, sizeof(cmd), "ffprobe -v error -show_entries stream=codec_type,duration " "-of csv=p=0 \"%s\"", out3); FILE *p3 = popen(cmd, "r"); EXPECT_TRUE(p3 != NULL); char p3_out[512] = { 0 }; const size_t p3_len = fread(p3_out, 1, sizeof(p3_out) - 1, p3); (void)p3_len; EXPECT_TRUE(pclose(p3) == 0); EXPECT_TRUE(strstr(p3_out, "video") != NULL); EXPECT_TRUE(strstr(p3_out, "audio") != NULL); // 20 frames at 30000/1001 ~= 0.667 s. EXPECT_TRUE(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)"); } EXPECT_TRUE(rc == OAKENGINE_OK); char png0[4096]; snprintf(png0, sizeof(png0), "%s/seqout_00000.png", g_tmpdir); EXPECT_TRUE(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(); EXPECT_TRUE(cancel_rc == OAKENGINE_OK || cancel_rc == OAKENGINE_E_CANCELLED); snprintf(out3, sizeof(out3), "%s/ex_after.mp4", g_tmpdir); EXPECT_TRUE(oakengine_export_render_ex(seq, out3, &o5) == OAKENGINE_OK); } oakengine_project_free(project); EXPECT_TRUE(oakengine_shutdown() == OAKENGINE_OK); }