/*** 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 timeline editing primitives: // add_track, add_footage_clip and the clip accessors, including their // undo/redo behavior and failure paths. Uses the real media file // tests/demo.mp4. No GL required. #include #include #include #include #if defined(_WIN32) #include #include #else #include #endif #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_tl_edit_test_%lu", base, (unsigned long)GetCurrentProcessId()); assert(_mkdir(g_tmpdir) == 0); #else strcpy(g_tmpdir, "/tmp/oakengine_tl_edit_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_add_track(OakEngineProject *project, OakEngineSequence *seq) { int video = -1, audio = -1, subtitle = -1; assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) == OAKENGINE_OK); assert(video == 0 && audio == 0 && subtitle == 0); assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == 0); assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == 0); assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_SUBTITLE) == 0); assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == 1); assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) == OAKENGINE_OK); assert(video == 2 && audio == 1 && subtitle == 1); // Invalid track types are rejected. assert(oakengine_sequence_add_track(seq, -1) == OAKENGINE_E_INVALID); assert(oakengine_sequence_add_track(seq, 3) == OAKENGINE_E_INVALID); assert(oakengine_sequence_add_track(NULL, OAKENGINE_TRACK_TYPE_VIDEO) == OAKENGINE_E_INVALID); // Track adds are undoable: undo the second video track, then redo it. assert(oakengine_project_undo(project) == OAKENGINE_OK); video = audio = subtitle = -1; assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) == OAKENGINE_OK); assert(video == 1 && audio == 1 && subtitle == 1); assert(oakengine_project_redo(project) == OAKENGINE_OK); video = audio = subtitle = -1; assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) == OAKENGINE_OK); assert(video == 2 && audio == 1 && subtitle == 1); } static void test_add_clip(OakEngineProject *project, OakEngineSequence *seq, const char *media_path) { char err[512]; // Probe handles carry no project node and cannot be placed. OakEngineFootage *probed = oakengine_footage_probe(media_path); assert(probed != NULL); assert(oakengine_sequence_add_footage_clip(seq, probed, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0) == NULL); assert(oakengine_sequence_last_error(err, sizeof(err)) > 0); oakengine_footage_free(probed); // Import the media into the project. OakEngineFootage *footage = oakengine_project_import_footage(project, media_path); assert(footage != NULL); // No subtitle clips. assert(oakengine_sequence_add_footage_clip(seq, footage, OAKENGINE_TRACK_TYPE_SUBTITLE, 0, 0, 30, 0) == NULL); // Out-of-range track indexes are rejected without creating tracks. assert(oakengine_sequence_add_footage_clip(seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 5, 0, 30, 0) == NULL); assert(oakengine_sequence_last_error(err, sizeof(err)) > 0); assert(oakengine_sequence_add_footage_clip(seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, -1, 0, 30, 0) == NULL); // Bad ranges: out <= in, negative in / media_in. assert(oakengine_sequence_add_footage_clip(seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 30, 30, 0) == NULL); assert(oakengine_sequence_add_footage_clip(seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 30, 10, 0) == NULL); assert(oakengine_sequence_add_footage_clip(seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, -1, 30, 0) == NULL); assert(oakengine_sequence_add_footage_clip(seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, -1) == NULL); // NULL safety. assert(oakengine_sequence_add_footage_clip(NULL, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0) == NULL); assert(oakengine_sequence_add_footage_clip(seq, NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0) == NULL); // clip_count reports OAKENGINE_E_NOT_FOUND for a missing track. assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, 99) == OAKENGINE_E_NOT_FOUND); assert(oakengine_sequence_clip_count(NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0) == OAKENGINE_E_INVALID); // Place a video clip on track 0: frames 10..40, media in-point 5. OakEngineClip *clip = oakengine_sequence_add_footage_clip( seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 10, 40, 5); assert(clip != NULL); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 1); OakEngineClip *at = oakengine_sequence_clip_at( seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); assert(at == clip); assert(oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1) == NULL); assert(oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 9, 0) == NULL); int64_t in = -1, out = -1, media_in = -1; assert(oakengine_clip_get_range(clip, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 10 && out == 40 && media_in == 5); assert(oakengine_clip_get_range(NULL, &in, &out, &media_in) == OAKENGINE_E_INVALID); // And an audio clip on audio track 0 (same footage, whole range). OakEngineClip *aclip = oakengine_sequence_add_footage_clip( seq, footage, OAKENGINE_TRACK_TYPE_AUDIO, 0, 0, 30, 0); assert(aclip != NULL); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_AUDIO, 0) == 1); // Undo/redo both clip adds (the audio clip is on top of the undo stack, // the video clip right below it). assert(oakengine_project_undo(project) == OAKENGINE_OK); assert(oakengine_project_undo(project) == OAKENGINE_OK); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_AUDIO, 0) == 0); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 0); assert(oakengine_project_redo(project) == OAKENGINE_OK); assert(oakengine_project_redo(project) == OAKENGINE_OK); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 1); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_AUDIO, 0) == 1); oakengine_footage_free(footage); // wrapper only; node stays assert(oakengine_project_footage_count(project) == 1); } // Footage imported into one project must not be placed into another. static void test_cross_project_rejected(const char *media_path) { OakEngineProject *a = oakengine_project_create(); OakEngineProject *b = oakengine_project_create(); assert(a != NULL && b != NULL); assert(oakengine_project_new(a) == OAKENGINE_OK); assert(oakengine_project_new(b) == OAKENGINE_OK); OakEngineFootage *footage = oakengine_project_import_footage(a, media_path); assert(footage != NULL); OakEngineSequence *seq_b = oakengine_sequence_new(b, "B"); assert(seq_b != NULL); assert(oakengine_sequence_add_track(seq_b, OAKENGINE_TRACK_TYPE_VIDEO) == 0); char err[512]; assert(oakengine_sequence_add_footage_clip(seq_b, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0) == NULL); assert(oakengine_sequence_last_error(err, sizeof(err)) > 0); assert(oakengine_sequence_clip_count(seq_b, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 0); oakengine_footage_free(footage); oakengine_project_free(a); oakengine_project_free(b); } // Round-2 primitives: split, trim, move, ripple delete (plus undo/redo), // over a fresh project with two clips on one video track. static void test_edit_round2(const char *media_path) { OakEngineProject *project = oakengine_project_create(); assert(project != NULL); assert(oakengine_project_new(project) == OAKENGINE_OK); OakEngineSequence *seq = oakengine_sequence_new(project, "Round2"); assert(seq != NULL); assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == 0); OakEngineFootage *footage = oakengine_project_import_footage(project, media_path); assert(footage != NULL); // Two clips back to back: A [0,30] and B [30,60], both from media 0. OakEngineClip *clip_a = oakengine_sequence_add_footage_clip( seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0); assert(clip_a != NULL); OakEngineClip *clip_b = oakengine_sequence_add_footage_clip( seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 30, 60, 0); assert(clip_b != NULL); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 2); int64_t in = -1, out = -1, media_in = -1; char err[512]; // ---- split ------------------------------------------------------------- // Bad split points: on the edges, outside, missing clip. assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 0) == OAKENGINE_E_INVALID); assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30) == OAKENGINE_E_INVALID); assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, -5) == OAKENGINE_E_INVALID); assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 99) == OAKENGINE_E_INVALID); assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 99, 15) == OAKENGINE_E_NOT_FOUND); assert(oakengine_sequence_split_clip(NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 15) == OAKENGINE_E_INVALID); // Split A [0,30] at 15 -> [0,15] + [15,30] with aligned media in-points. assert(oakengine_sequence_split_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 15) == OAKENGINE_OK); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 3); OakEngineClip *left = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); OakEngineClip *right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); assert(left == clip_a); assert(oakengine_clip_get_range(left, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 0 && out == 15 && media_in == 0); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 15 && out == 30 && media_in == 15); // Undo merges them back, redo splits again. assert(oakengine_project_undo(project) == OAKENGINE_OK); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 2); assert(oakengine_clip_get_range(clip_a, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 0 && out == 30 && media_in == 0); assert(oakengine_project_redo(project) == OAKENGINE_OK); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 3); right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 15 && out == 30 && media_in == 15); // ---- trim -------------------------------------------------------------- // Bad trim ranges. assert(oakengine_clip_trim(NULL, 0, 10) == OAKENGINE_E_INVALID); assert(oakengine_clip_trim(right, 30, 20) == OAKENGINE_E_INVALID); assert(oakengine_clip_trim(right, -1, 20) == OAKENGINE_E_INVALID); // Trim the right part's in-point 15 -> 20: media_in follows 15 -> 20. assert(oakengine_clip_trim(right, 20, 30) == OAKENGINE_OK); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 20 && out == 30 && media_in == 20); // The leading clip is untouched (a gap absorbs the difference). assert(oakengine_clip_get_range(clip_a, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 0 && out == 15 && media_in == 0); assert(oakengine_project_undo(project) == OAKENGINE_OK); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 15 && out == 30 && media_in == 15); assert(oakengine_project_redo(project) == OAKENGINE_OK); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 20 && out == 30 && media_in == 20); // Trim the out-point 30 -> 25 (media in-point stays). assert(oakengine_clip_trim(right, 20, 25) == OAKENGINE_OK); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 20 && out == 25 && media_in == 20); assert(oakengine_project_undo(project) == OAKENGINE_OK); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 20 && out == 30 && media_in == 20); // No-op trim is accepted and does nothing. assert(oakengine_clip_trim(right, 20, 30) == OAKENGINE_OK); // Both ends in one command: [20,30] -> [22,28], media_in follows. assert(oakengine_clip_trim(right, 22, 28) == OAKENGINE_OK); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 22 && out == 28 && media_in == 22); assert(oakengine_project_undo(project) == OAKENGINE_OK); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 20 && out == 30 && media_in == 20); // ---- move --------------------------------------------------------------- assert(oakengine_sequence_move_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1, -1) == OAKENGINE_E_INVALID); assert(oakengine_sequence_move_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 99, 50) == OAKENGINE_E_NOT_FOUND); // Move the second clip [20,30] to 70 (past everything): position shifts, // length and media in-point are preserved, the other clips stay put. assert(oakengine_sequence_move_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1, 70) == OAKENGINE_OK); // After the move the moved clip is the LAST one on the track. right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 2); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 70 && out == 80 && media_in == 20); assert(oakengine_clip_get_range(clip_a, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 0 && out == 15 && media_in == 0); assert(oakengine_clip_get_range(clip_b, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 30 && out == 60 && media_in == 0); assert(oakengine_project_undo(project) == OAKENGINE_OK); right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 20 && out == 30 && media_in == 20); assert(oakengine_project_redo(project) == OAKENGINE_OK); right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 2); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 70 && out == 80 && media_in == 20); // ---- ripple delete ------------------------------------------------------- assert(oakengine_sequence_ripple_delete_clip( seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 99) == OAKENGINE_E_NOT_FOUND); assert(oakengine_sequence_ripple_delete_clip(NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0) == OAKENGINE_E_INVALID); // Delete the first clip [0,15]: both following clips shift left by 15. assert(oakengine_sequence_ripple_delete_clip( seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0) == OAKENGINE_OK); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 2); right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 15 && out == 45 && media_in == 0); // clip_b was [30,60] right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 55 && out == 65 && media_in == 20); // was [70,80] assert(oakengine_project_undo(project) == OAKENGINE_OK); assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0) == 3); right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 0 && out == 15 && media_in == 0); right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 30 && out == 60 && media_in == 0); right = oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 2); assert(oakengine_clip_get_range(right, &in, &out, &media_in) == OAKENGINE_OK); assert(in == 70 && out == 80 && media_in == 20); oakengine_footage_free(footage); // wrapper only; node stays assert(oakengine_project_footage_count(project) == 1); } // Footage imported into one project must not be placed into another. 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, "Edit"); assert(seq != NULL); char path[4096]; demo_path(path, sizeof(path)); test_add_track(project, seq); test_add_clip(project, seq, path); test_cross_project_rejected(path); test_edit_round2(path); oakengine_project_free(project); assert(oakengine_shutdown() == OAKENGINE_OK); printf("oakengine_timeline_edit_test: all assertions passed\n"); return 0; }