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:
@@ -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 ()
|
||||
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user