diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt index fcbe53947..4db1d0634 100644 --- a/cli/CMakeLists.txt +++ b/cli/CMakeLists.txt @@ -81,6 +81,15 @@ if (BUILD_TESTS) set_tests_properties(oak_cli_render PROPERTIES SKIP_RETURN_CODE 2 ) + + # probe is headless and always runs; the output must mention the demo + # file's 1920x1080 video and 48000 Hz audio. + add_test(NAME oak_cli_probe + COMMAND oak-cli probe ${CMAKE_SOURCE_DIR}/tests/demo.mp4 + ) + set_tests_properties(oak_cli_probe PROPERTIES + PASS_REGULAR_EXPRESSION "1920.*48000" + ) # Rendering needs the render worker and the dynamic backend plugins. if (TARGET olive-render-worker) add_dependencies(oak-cli olive-render-worker) diff --git a/cli/main.cpp b/cli/main.cpp index ac4c8a6c3..1451d68cb 100644 --- a/cli/main.cpp +++ b/cli/main.cpp @@ -48,6 +48,7 @@ #include #include +#include "oakengine/footage.h" #include "oakengine/init.h" #include "oakengine/project.h" #include "oakengine/renderer.h" @@ -76,12 +77,15 @@ void print_usage(FILE *out) " Render the first sequence to PPM frames (P6, 8-bit RGB) and the\n" " audio range to a PCM s16 WAV file in .\n" "\n" + " oak-cli probe \n" + " Probe a media file: decoder, duration, video and audio streams.\n" + "\n" " oak-cli --help\n" " Show this text.\n" "\n" "Exit codes:\n" " 0 success\n" - " 1 general error (bad project file, no sequence, I/O failure)\n" + " 1 general error (bad project/media file, no sequence, I/O failure)\n" " 2 rendering unavailable or failed (e.g. no GL render backend)\n" " 64 usage error\n"); } @@ -460,6 +464,79 @@ int cmd_render(const char *path, const char *start_str, const char *end_str, return rc; } +int cmd_probe(const char *path) +{ + if (oakengine_init(OAKENGINE_INIT_HEADLESS) != OAKENGINE_OK) { + fprintf(stderr, "error: failed to initialize the engine\n"); + return k_exit_error; + } + + int rc = k_exit_ok; + OakEngineFootage *f = oakengine_footage_probe(path); + if (!f) { + char err[1024]; + fprintf(stderr, "error: %s\n", + oakengine_footage_last_error(err, sizeof(err)) > 0 ? + err : + "probe failed"); + rc = k_exit_error; + } else { + char decoder[64]; + oakengine_footage_get_decoder_name(f, decoder, sizeof(decoder)); + double duration = 0.0; + oakengine_footage_get_duration(f, &duration); + printf("Decoder: %s\n", decoder); + printf("Duration: %.6f s\n", duration); + + const int videos = oakengine_footage_get_video_stream_count(f); + printf("Video streams: %d\n", videos); + for (int i = 0; i < videos; i++) { + oak_footage_video_info vi; + if (oakengine_footage_get_video_stream_info(f, i, &vi) != + OAKENGINE_OK) { + continue; + } + const double secs = vi.time_base_den ? + double(vi.duration_ts) * vi.time_base_num / vi.time_base_den : + 0.0; + const double fps = vi.frame_rate_den ? + double(vi.frame_rate_num) / vi.frame_rate_den : + 0.0; + printf(" [%d] stream %d: %dx%d, %d/%d fps (%.3f), duration " + "%lld/%d (%f s), primaries=%d trc=%d, %s\n", + i, vi.stream_index, vi.width, vi.height, + vi.frame_rate_num, vi.frame_rate_den, fps, + (long long)vi.duration_ts, vi.time_base_den, secs, + vi.color_primaries, vi.color_trc, + vi.interlaced ? "interlaced" : "progressive"); + } + + const int audios = oakengine_footage_get_audio_stream_count(f); + printf("Audio streams: %d\n", audios); + for (int i = 0; i < audios; i++) { + oak_footage_audio_info ai; + if (oakengine_footage_get_audio_stream_info(f, i, &ai) != + OAKENGINE_OK) { + continue; + } + const double secs = ai.time_base_den ? + double(ai.duration_ts) * ai.time_base_num / ai.time_base_den : + 0.0; + printf(" [%d] stream %d: %d Hz, %d channels, duration %lld/%d " + "(%f s)\n", + i, ai.stream_index, ai.sample_rate, ai.channel_count, + (long long)ai.duration_ts, ai.time_base_den, secs); + } + + printf("Subtitle streams: %d\n", + oakengine_footage_get_subtitle_stream_count(f)); + oakengine_footage_free(f); + } + + oakengine_shutdown(); + return rc; +} + } // namespace int main(int argc, char *argv[]) @@ -488,6 +565,13 @@ int main(int argc, char *argv[]) } return cmd_render(argv[2], argv[3], argv[4], argv[5]); } + if (command == "probe") { + if (argc != 3) { + print_usage(stderr); + return k_exit_usage; + } + return cmd_probe(argv[2]); + } fprintf(stderr, "error: unknown command \"%s\"\n", argv[1]); print_usage(stderr); diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index a93d810d8..0b8b73d6e 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -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 () diff --git a/engine/include/oakengine/footage.h b/engine/include/oakengine/footage.h new file mode 100644 index 000000000..978c6f100 --- /dev/null +++ b/engine/include/oakengine/footage.h @@ -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 . + +***/ + +#ifndef OAKENGINE_FOOTAGE_H +#define OAKENGINE_FOOTAGE_H + +#include + +#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 */ diff --git a/engine/src/capi/CMakeLists.txt b/engine/src/capi/CMakeLists.txt index aeb6b9eaf..6db761d91 100644 --- a/engine/src/capi/CMakeLists.txt +++ b/engine/src/capi/CMakeLists.txt @@ -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 ) diff --git a/engine/src/capi/footage.cpp b/engine/src/capi/footage.cpp new file mode 100644 index 000000000..1ef46e197 --- /dev/null +++ b/engine/src/capi/footage.cpp @@ -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 . + +***/ + +#include "oakengine/footage.h" + +#include + +#include +#include +#include + +#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(h); +} + +const OakEngineFootageState *impl(const OakEngineFootage *h) +{ + return reinterpret_cast(h); +} + +OakEngineFootage *wrap(OakEngineFootageState *s) +{ + return reinterpret_cast(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(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" diff --git a/engine/tests/oakengine_footage_test.cpp b/engine/tests/oakengine_footage_test.cpp new file mode 100644 index 000000000..4f18535be --- /dev/null +++ b/engine/tests/oakengine_footage_test.cpp @@ -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 . + +***/ + +// 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 +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#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; +}