engine: add the footage family to the C ABI facade; oak-cli probe

- oakengine_footage_probe inspects media without a project (decoder
  probe): stream counts, per-stream video info (dimensions, rate,
  duration, color tags, interlacing), audio info, duration, decoder
  name, source start time; thread_local last_error for the NULL-handle
  failure paths
- oakengine_project_import_footage adds probed footage to a project
  through the same undoable command path as the app
- oak-cli probe prints decoder/duration/per-stream details and runs as
  a ctest everywhere (no GL)
- dual ownership documented: probe handles are owned, imported footage
  is borrowed from its project
This commit is contained in:
2026-07-20 06:03:16 +08:00
parent 6118260e01
commit 1a7029fd8f
7 changed files with 919 additions and 1 deletions
+263
View File
@@ -0,0 +1,263 @@
/***
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 <http://www.gnu.org/licenses/>.
***/
// Pure C ABI test for the liboakengine footage facade. Probes the real
// media file tests/demo.mp4 (decoder, streams, durations, color tags),
// imports media into a project through the facade (including undo/redo),
// and covers the failure paths. No GL required.
#include <assert.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(_WIN32)
#include <direct.h>
#include <windows.h>
#else
#include <unistd.h>
#endif
#include "oakengine/footage.h"
#include "oakengine/init.h"
#include "oakengine/project.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_footage_test_%lu", base,
(unsigned long)GetCurrentProcessId());
assert(_mkdir(g_tmpdir) == 0);
#else
strcpy(g_tmpdir, "/tmp/oakengine_footage_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);
}
// tests/demo.mp4: 1920x1080 25 fps video (~17 s) + 48 kHz stereo audio,
// tagged BT.709 primaries and transfer (see codec_decoder_test.cpp).
static void test_probe(void)
{
char path[4096];
demo_path(path, sizeof(path));
OakEngineFootage *f = oakengine_footage_probe(path);
assert(f != NULL);
char name[64];
assert(oakengine_footage_get_decoder_name(f, name, sizeof(name)) > 0);
assert(strcmp(name, "ffmpeg") == 0);
double duration = 0.0;
assert(oakengine_footage_get_duration(f, &duration) == OAKENGINE_OK);
assert(fabs(duration - 17.0) < 0.5);
assert(oakengine_footage_get_video_stream_count(f) == 1);
assert(oakengine_footage_get_audio_stream_count(f) == 1);
assert(oakengine_footage_get_subtitle_stream_count(f) == 0);
oak_footage_video_info vi;
memset(&vi, 0, sizeof(vi));
assert(oakengine_footage_get_video_stream_info(f, 0, &vi) ==
OAKENGINE_OK);
assert(vi.stream_index == 0);
assert(vi.width == 1920 && vi.height == 1080);
assert(vi.frame_rate_num == 25 && vi.frame_rate_den == 1);
// Duration units make sense against the time base (17 s +/- 0.5).
assert(vi.time_base_den > 0);
const double vsecs =
double(vi.duration_ts) * vi.time_base_num / vi.time_base_den;
assert(fabs(vsecs - 17.0) < 0.5);
assert(vi.color_primaries == 1); // BT.709
assert(vi.color_trc == 1);
assert(vi.interlaced == 0);
oak_footage_audio_info ai;
memset(&ai, 0, sizeof(ai));
assert(oakengine_footage_get_audio_stream_info(f, 0, &ai) ==
OAKENGINE_OK);
assert(ai.stream_index == 1);
assert(ai.sample_rate == 48000);
assert(ai.channel_count == 2);
assert(ai.time_base_den > 0);
const double asecs =
double(ai.duration_ts) * ai.time_base_num / ai.time_base_den;
assert(fabs(asecs - 17.0) < 0.5);
assert(oakengine_footage_is_online(f) == 1);
// demo.mp4 carries a timecode track reading 01:00:00:00 at 25 fps,
// i.e. a source start time of 3600 seconds.
int num = -1, den = -1;
assert(oakengine_footage_get_source_start_time(f, &num, &den) == 1);
assert(num == 3600 && den == 1);
// Out-of-range stream indexes report OAKENGINE_E_NOT_FOUND.
assert(oakengine_footage_get_video_stream_info(f, 1, &vi) ==
OAKENGINE_E_NOT_FOUND);
assert(oakengine_footage_get_audio_stream_info(f, 1, &ai) ==
OAKENGINE_E_NOT_FOUND);
oakengine_footage_free(f);
}
static void test_import(void)
{
assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK);
OakEngineProject *project = oakengine_project_create();
assert(project != NULL);
assert(oakengine_project_new(project) == OAKENGINE_OK);
assert(oakengine_project_footage_count(project) == 0);
char path[4096];
demo_path(path, sizeof(path));
OakEngineFootage *f = oakengine_project_import_footage(project, path);
assert(f != NULL);
// The import went into the project as an undoable command.
assert(oakengine_project_footage_count(project) == 1);
assert(oakengine_project_can_undo(project) == 1);
assert(oakengine_project_is_modified(project) == 1);
// The project's view of the footage matches the imported file.
char filename[4096];
assert(oakengine_project_footage_filename(project, 0, filename,
sizeof(filename)) > 0);
assert(strcmp(filename, path) == 0);
assert(oakengine_project_footage_is_online(project, 0) == 1);
// The borrowed handle exposes the same probe information.
assert(oakengine_footage_get_video_stream_count(f) == 1);
assert(oakengine_footage_get_audio_stream_count(f) == 1);
oak_footage_video_info vi;
assert(oakengine_footage_get_video_stream_info(f, 0, &vi) ==
OAKENGINE_OK);
assert(vi.width == 1920 && vi.height == 1080);
char name[64];
assert(oakengine_footage_get_decoder_name(f, name, sizeof(name)) > 0);
assert(strcmp(name, "ffmpeg") == 0);
assert(oakengine_footage_is_online(f) == 1);
// Undo removes the footage from the project, redo brings it back.
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_project_footage_count(project) == 0);
assert(oakengine_project_can_redo(project) == 1);
assert(oakengine_project_redo(project) == OAKENGINE_OK);
assert(oakengine_project_footage_count(project) == 1);
// Releasing the borrowed handle frees only the wrapper.
oakengine_footage_free(f);
assert(oakengine_project_footage_count(project) == 1);
oakengine_project_free(project);
}
static void test_failures(void)
{
char path[4096];
snprintf(path, sizeof(path), "%s/tests/definitely-not-there.mp4",
OAK_TEST_SOURCE_DIR);
char err[512];
assert(oakengine_footage_probe(path) == NULL);
assert(oakengine_footage_last_error(err, sizeof(err)) > 0);
// A non-media file is rejected by the probe.
snprintf(path, sizeof(path), "%s/not-media.txt", g_tmpdir);
FILE *txt = fopen(path, "w");
assert(txt != NULL);
fputs("this is not a media file\n", txt);
fclose(txt);
assert(oakengine_footage_probe(path) == NULL);
assert(oakengine_footage_last_error(err, sizeof(err)) > 0);
// Importing a missing file fails with a reason.
OakEngineProject *project = oakengine_project_create();
assert(project != NULL);
assert(oakengine_project_new(project) == OAKENGINE_OK);
snprintf(path, sizeof(path), "%s/tests/definitely-not-there.mp4",
OAK_TEST_SOURCE_DIR);
assert(oakengine_project_import_footage(project, path) == NULL);
assert(oakengine_footage_last_error(err, sizeof(err)) > 0);
assert(oakengine_project_footage_count(project) == 0);
assert(oakengine_project_import_footage(NULL, path) == NULL);
oakengine_project_free(project);
// NULL safety.
oakengine_footage_free(NULL);
assert(oakengine_footage_get_decoder_name(NULL, err, sizeof(err)) ==
OAKENGINE_E_INVALID);
assert(oakengine_footage_get_video_stream_count(NULL) == 0);
assert(oakengine_footage_get_audio_stream_count(NULL) == 0);
assert(oakengine_footage_get_subtitle_stream_count(NULL) == 0);
oak_footage_video_info vi;
assert(oakengine_footage_get_video_stream_info(NULL, 0, &vi) ==
OAKENGINE_E_INVALID);
oak_footage_audio_info ai;
assert(oakengine_footage_get_audio_stream_info(NULL, 0, &ai) ==
OAKENGINE_E_INVALID);
double duration = 0.0;
assert(oakengine_footage_get_duration(NULL, &duration) ==
OAKENGINE_E_INVALID);
assert(oakengine_footage_is_online(NULL) == OAKENGINE_E_INVALID);
assert(oakengine_footage_get_source_start_time(NULL, NULL, NULL) ==
OAKENGINE_E_INVALID);
}
int main(void)
{
make_tmpdir();
// Sandbox the config/cache/data locations (see oakengine_init_test);
// the probe metadata cache then lives in the temp dir.
#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);
test_probe();
test_import();
test_failures();
assert(oakengine_shutdown() == OAKENGINE_OK);
printf("oakengine_footage_test: all assertions passed\n");
return 0;
}