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
+5
View File
@@ -264,4 +264,9 @@ if (BUILD_TESTS)
if (TARGET olive-render-worker)
add_dependencies(oakengine_renderer_test olive-render-worker)
endif ()
make_oakengine_test(oakengine_footage_test)
target_compile_definitions(oakengine_footage_test PRIVATE
OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
)
endif ()
+207
View File
@@ -0,0 +1,207 @@
/***
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/>.
***/
#ifndef OAKENGINE_FOOTAGE_H
#define OAKENGINE_FOOTAGE_H
#include <stdint.h>
#include "export.h"
#include "init.h"
#include "project.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @file footage.h
* @brief C ABI for media probing and project footage import
*
* Two models share the OakEngineFootage opaque handle:
*
* - Probing (oakengine_footage_probe()): inspect a media file without any
* project, directly through the engine's decoder probe path
* (Decoder::probe(), the same code the import task uses, minus the
* task/UI). The handle wraps an owned olive::FootageDescription and must
* be released with oakengine_footage_free().
*
* - Importing (oakengine_project_import_footage()): probe a file and add
* it as a Footage node to a project's root folder (the non-UI core of
* ProjectImportTask: probe via Footage::set_filename(), then an undoable
* NodeAddCommand + FolderAddChild). The returned handle is BORROWED --
* the underlying node is owned by the project (QObject parent chain) and
* the handle becomes invalid when the project is freed.
* oakengine_footage_free() on a borrowed handle only releases the handle
* wrapper, never the node.
*
* Image sequences: the application's import asks the user whether numbered
* stills form a sequence (EngineCore::confirm_image_sequence_handler). No
* such handler exists behind this facade, so imported stills are always
* treated as single frames and are never merged into an image sequence.
*
* Errors: oakengine_footage_probe() and oakengine_project_import_footage()
* return NULL on failure and record a human-readable reason retrievable
* with oakengine_footage_last_error() (thread-local). Information functions
* follow the usual conventions: booleans are int, 0 (OAKENGINE_OK) /
* negative OAKENGINE_E_* codes, buf/size string output, NULL handles yield
* no-ops / zero results / OAKENGINE_E_INVALID.
*
* Stream info uses the same timestamp/timebase convention as the timeline
* family: `duration_ts` counts units of the stream's time base
* (`time_base_num`/`time_base_den` seconds per unit), so
* seconds = duration_ts * time_base_num / time_base_den.
*/
/**
* @brief Opaque media handle (owned for probes, borrowed for imports; see
* the file comment above).
*/
typedef struct OakEngineFootage OakEngineFootage;
/**
* @brief POD description of one video stream (olive::VideoParams).
*
* color_primaries/color_trc carry the ISO/IEC 23001-8 code points the
* decoder reports (1 = BT.709), 0 when unknown. interlaced is 1 when the
* stream is interlaced (VideoParams::Interlacing != k_interlace_none).
*/
typedef struct oak_footage_video_info {
int stream_index;
int width;
int height;
int frame_rate_num;
int frame_rate_den;
int64_t duration_ts; /**< Duration in time-base units. */
int time_base_num; /**< Seconds per time-base unit (numerator). */
int time_base_den; /**< Seconds per time-base unit (denominator). */
int color_primaries;
int color_trc;
int interlaced;
} oak_footage_video_info;
/**
* @brief POD description of one audio stream (olive::AudioParams).
*
* channel_layout is the ffmpeg-style channel mask (e.g. 0x3 = stereo).
*/
typedef struct oak_footage_audio_info {
int stream_index;
int sample_rate;
uint64_t channel_layout;
int channel_count;
int64_t duration_ts; /**< Duration in time-base units. */
int time_base_num; /**< Seconds per time-base unit (numerator). */
int time_base_den; /**< Seconds per time-base unit (denominator). */
} oak_footage_audio_info;
/**
* @brief Probe a media file (decoder, streams, durations, color tags).
*
* Runs Decoder::create_from_id("ffmpeg")->probe() directly; requires the
* engine to be initialized (OAKENGINE_INIT_HEADLESS is sufficient, no GL
* needed). Returns an owned handle, or NULL on failure (see
* oakengine_footage_last_error()).
*/
OAKENGINE_API OakEngineFootage *oakengine_footage_probe(const char *path);
/**
* @brief Release a handle. For probe handles this frees the description;
* for borrowed import handles it only frees the wrapper (the node stays
* with its project). NULL-safe.
*/
OAKENGINE_API void oakengine_footage_free(OakEngineFootage *self);
/**
* @brief Human-readable reason for the last failed probe/import on this
* thread (buf/size convention). Empty when the last call succeeded.
*/
OAKENGINE_API int oakengine_footage_last_error(char *buf, int buf_size);
/**
* @brief ID of the decoder that owns the media (e.g. "ffmpeg"), buf/size
* convention.
*/
OAKENGINE_API int oakengine_footage_get_decoder_name(OakEngineFootage *self,
char *buf, int buf_size);
OAKENGINE_API int
oakengine_footage_get_video_stream_count(const OakEngineFootage *self);
OAKENGINE_API int
oakengine_footage_get_audio_stream_count(const OakEngineFootage *self);
OAKENGINE_API int
oakengine_footage_get_subtitle_stream_count(const OakEngineFootage *self);
/**
* @brief Fill `out` with the video stream at `index`. Returns OAKENGINE_OK
* or OAKENGINE_E_NOT_FOUND for an out-of-range index.
*/
OAKENGINE_API int oakengine_footage_get_video_stream_info(
OakEngineFootage *self, int index, oak_footage_video_info *out);
/**
* @brief Fill `out` with the audio stream at `index`. Returns OAKENGINE_OK
* or OAKENGINE_E_NOT_FOUND for an out-of-range index.
*/
OAKENGINE_API int oakengine_footage_get_audio_stream_info(
OakEngineFootage *self, int index, oak_footage_audio_info *out);
/**
* @brief Media duration in seconds: the longest stream duration across all
* video and audio streams.
*/
OAKENGINE_API int oakengine_footage_get_duration(OakEngineFootage *self,
double *seconds);
/**
* @brief 1 if the media file exists on disk, 0 otherwise.
*/
OAKENGINE_API int oakengine_footage_is_online(OakEngineFootage *self);
/**
* @brief Source start time as a rational (FootageDescription::
* source_start_time(), e.g. from a timecode track). Returns 1 when the
* media carries one, 0 when it does not, or a negative error code.
*/
OAKENGINE_API int oakengine_footage_get_source_start_time(
OakEngineFootage *self, int *num, int *den);
/**
* @brief Probe `path` and import it into `project`'s root folder.
*
* Mirrors the non-UI core of ProjectImportTask: the footage is probed on
* assignment (Footage::set_filename()), invalid/unreadable media is
* rejected, and the add is pushed onto the global undo stack as an undoable
* command (direct, non-undoable application when the engine is not
* initialized). Stills are imported as single frames (see the file comment
* about image sequences).
*
* Returns a BORROWED handle (owned by the project; do not free the node,
* oakengine_footage_free() only releases the wrapper), or NULL on failure
* (see oakengine_footage_last_error()).
*/
OAKENGINE_API OakEngineFootage *oakengine_project_import_footage(
OakEngineProject *project, const char *path);
#ifdef __cplusplus
}
#endif
#endif /* OAKENGINE_FOOTAGE_H */
+2
View File
@@ -26,9 +26,11 @@ set(OLIVE_SOURCES
include/oakengine/project.h
include/oakengine/timeline.h
include/oakengine/renderer.h
include/oakengine/footage.h
src/capi/init.cpp
src/capi/project.cpp
src/capi/timeline.cpp
src/capi/renderer.cpp
src/capi/footage.cpp
PARENT_SCOPE
)
+348
View File
@@ -0,0 +1,348 @@
/***
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/>.
***/
#include "oakengine/footage.h"
#include <cstdio>
#include <QByteArray>
#include <QFileInfo>
#include <QString>
#include "codec/decoder.h"
#include "coreengine.h"
#include "node/nodeundo.h"
#include "node/project.h"
#include "node/project/folder/folder.h"
#include "node/project/footage/footage.h"
#include "undo/undocommand.h"
#include "undo/undostack.h"
namespace
{
// Handle payload: a probe result (owned description) or a project footage
// node (borrowed). See oakengine/footage.h for the two models.
struct OakEngineFootageState {
// true: `node` is owned by a project and must not be deleted here.
bool borrowed = false;
olive::FootageDescription description;
olive::Footage *node = nullptr;
QString filename; // media path (probe model; the node has its own)
};
// Last probe/import error per thread (handles are NULL on failure, so the
// reason cannot hang off them like in the renderer family).
thread_local QString g_last_error;
void set_error(const QString &error)
{
g_last_error = error;
}
OakEngineFootageState *impl(OakEngineFootage *h)
{
return reinterpret_cast<OakEngineFootageState *>(h);
}
const OakEngineFootageState *impl(const OakEngineFootage *h)
{
return reinterpret_cast<const OakEngineFootageState *>(h);
}
OakEngineFootage *wrap(OakEngineFootageState *s)
{
return reinterpret_cast<OakEngineFootage *>(s);
}
// buf/size convention: returns the would-be length excluding the NUL.
int string_to_buf(const QString &s, char *buf, int buf_size)
{
const QByteArray utf = s.toUtf8();
if (buf && buf_size > 0) {
snprintf(buf, size_t(buf_size), "%s", utf.constData());
}
return int(utf.size());
}
// Unified stream access across the two handle models.
int video_stream_count(const OakEngineFootageState *s)
{
return s->node ? s->node->get_video_stream_count() :
s->description.get_video_streams().size();
}
int audio_stream_count(const OakEngineFootageState *s)
{
return s->node ? s->node->get_audio_stream_count() :
s->description.get_audio_streams().size();
}
int subtitle_stream_count(const OakEngineFootageState *s)
{
return s->node ? s->node->get_subtitle_stream_count() :
s->description.get_subtitle_streams().size();
}
olive::VideoParams video_stream_at(const OakEngineFootageState *s, int index)
{
if (index < 0 || index >= video_stream_count(s)) {
return olive::VideoParams();
}
return s->node ? s->node->get_video_params(index) :
s->description.get_video_streams().at(index);
}
olive::AudioParams audio_stream_at(const OakEngineFootageState *s, int index)
{
if (index < 0 || index >= audio_stream_count(s)) {
return olive::AudioParams();
}
return s->node ? s->node->get_audio_params(index) :
s->description.get_audio_streams().at(index);
}
QString filename_of(const OakEngineFootageState *s)
{
return s->node ? s->node->filename() : s->filename;
}
} // namespace
extern "C"
{
OakEngineFootage *oakengine_footage_probe(const char *path)
{
set_error(QString());
if (!path || !QFileInfo::exists(QString::fromUtf8(path))) {
set_error(QStringLiteral("file does not exist: %1")
.arg(path ? path : "(null)"));
return nullptr;
}
olive::DecoderPtr decoder =
olive::Decoder::create_from_id(QStringLiteral("ffmpeg"));
if (!decoder) {
set_error(QStringLiteral("ffmpeg decoder is not available"));
return nullptr;
}
olive::FootageDescription description =
decoder->probe(QString::fromUtf8(path), nullptr);
if (!description.is_valid()) {
set_error(QStringLiteral("failed to probe \"%1\": unsupported or "
"unreadable media file")
.arg(path));
return nullptr;
}
auto *state = new OakEngineFootageState();
state->description = description;
state->filename = QString::fromUtf8(path);
return wrap(state);
}
void oakengine_footage_free(OakEngineFootage *self)
{
// Only the wrapper is deleted; a borrowed node stays with its project.
delete impl(self);
}
int oakengine_footage_last_error(char *buf, int buf_size)
{
return string_to_buf(g_last_error, buf, buf_size);
}
int oakengine_footage_get_decoder_name(OakEngineFootage *self, char *buf,
int buf_size)
{
if (!self) {
return OAKENGINE_E_INVALID;
}
const OakEngineFootageState *s = impl(self);
return string_to_buf(s->node ? s->node->decoder() :
s->description.decoder(),
buf, buf_size);
}
int oakengine_footage_get_video_stream_count(const OakEngineFootage *self)
{
return self ? video_stream_count(impl(self)) : 0;
}
int oakengine_footage_get_audio_stream_count(const OakEngineFootage *self)
{
return self ? audio_stream_count(impl(self)) : 0;
}
int oakengine_footage_get_subtitle_stream_count(const OakEngineFootage *self)
{
return self ? subtitle_stream_count(impl(self)) : 0;
}
int oakengine_footage_get_video_stream_info(OakEngineFootage *self, int index,
oak_footage_video_info *out)
{
if (!self || !out) {
return OAKENGINE_E_INVALID;
}
const olive::VideoParams vp = video_stream_at(impl(self), index);
if (!vp.is_valid()) {
return OAKENGINE_E_NOT_FOUND;
}
const olive::Rational frame_rate = vp.frame_rate();
const olive::Rational time_base = vp.time_base();
out->stream_index = vp.stream_index();
out->width = vp.width();
out->height = vp.height();
out->frame_rate_num = frame_rate.numerator();
out->frame_rate_den = frame_rate.denominator();
out->duration_ts = vp.duration();
out->time_base_num = time_base.numerator();
out->time_base_den = time_base.denominator();
out->color_primaries = vp.color_primaries();
out->color_trc = vp.color_transfer();
out->interlaced =
vp.interlacing() != olive::VideoParams::k_interlace_none ? 1 : 0;
return OAKENGINE_OK;
}
int oakengine_footage_get_audio_stream_info(OakEngineFootage *self, int index,
oak_footage_audio_info *out)
{
if (!self || !out) {
return OAKENGINE_E_INVALID;
}
const olive::AudioParams ap = audio_stream_at(impl(self), index);
if (ap.sample_rate() <= 0) {
return OAKENGINE_E_NOT_FOUND;
}
const olive::Rational time_base = ap.time_base();
out->stream_index = ap.stream_index();
out->sample_rate = ap.sample_rate();
out->channel_layout = ap.channel_layout();
out->channel_count = ap.channel_count();
out->duration_ts = ap.duration();
out->time_base_num = time_base.numerator();
out->time_base_den = time_base.denominator();
return OAKENGINE_OK;
}
int oakengine_footage_get_duration(OakEngineFootage *self, double *seconds)
{
if (!self || !seconds) {
return OAKENGINE_E_INVALID;
}
const OakEngineFootageState *s = impl(self);
double longest = 0.0;
for (int i = 0; i < video_stream_count(s); i++) {
const olive::VideoParams vp = video_stream_at(s, i);
longest = qMax(longest, vp.duration() * vp.time_base().to_double());
}
for (int i = 0; i < audio_stream_count(s); i++) {
const olive::AudioParams ap = audio_stream_at(s, i);
longest = qMax(longest, ap.duration() * ap.time_base().to_double());
}
*seconds = longest;
return OAKENGINE_OK;
}
int oakengine_footage_is_online(OakEngineFootage *self)
{
if (!self) {
return OAKENGINE_E_INVALID;
}
return QFileInfo::exists(filename_of(impl(self))) ? 1 : 0;
}
int oakengine_footage_get_source_start_time(OakEngineFootage *self, int *num,
int *den)
{
if (!self) {
return OAKENGINE_E_INVALID;
}
const OakEngineFootageState *s = impl(self);
const bool has = s->node ? s->node->has_source_start_time() :
s->description.has_source_start_time();
if (!has) {
return 0;
}
const olive::Rational t = s->node ? s->node->source_start_time() :
s->description.source_start_time();
if (num) {
*num = t.numerator();
}
if (den) {
*den = t.denominator();
}
return 1;
}
OakEngineFootage *oakengine_project_import_footage(OakEngineProject *project,
const char *path)
{
set_error(QString());
olive::Project *p = reinterpret_cast<olive::Project *>(project);
if (!p || !p->root() || !path) {
set_error(QStringLiteral("invalid project or path"));
return nullptr;
}
const QFileInfo file_info(QString::fromUtf8(path));
if (!file_info.exists()) {
set_error(QStringLiteral("file does not exist: %1").arg(path));
return nullptr;
}
// Non-UI core of ProjectImportTask: assigning the filename probes the
// media (Footage::reprobe()); invalid media is rejected. Numbered stills
// are imported as single frames -- there is no image-sequence
// confirmation handler behind this facade.
auto *footage = new olive::Footage();
footage->set_filename(file_info.absoluteFilePath());
footage->set_label(file_info.fileName());
if (!footage->is_valid()) {
set_error(QStringLiteral("failed to probe \"%1\": unsupported or "
"unreadable media file")
.arg(path));
delete footage;
return nullptr;
}
olive::MultiUndoCommand *command = new olive::MultiUndoCommand();
command->add_child(new olive::NodeAddCommand(p, footage));
command->add_child(new olive::FolderAddChild(p->root(), footage));
if (olive::EngineCore::instance()) {
olive::EngineCore::instance()->undo_stack()->push(
command, QStringLiteral("Import Footage"));
} else {
command->redo_now();
delete command;
}
auto *state = new OakEngineFootageState();
state->borrowed = true;
state->node = footage;
return wrap(state);
}
} // extern "C"
+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;
}