diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index e2373e837..62b4c78cf 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -44,6 +44,7 @@ add_subdirectory(timeline) add_subdirectory(tool) add_subdirectory(ui) add_subdirectory(undo) +add_subdirectory(src/capi) add_library(oakengine SHARED ${OLIVE_SOURCES} @@ -225,4 +226,11 @@ if (BUILD_TESTS) endfunction() make_oakengine_test(oakengine_ipc_test) + + make_oakengine_test(oakengine_init_test) + # Resolves the real test assets (tests/demo.mp4, the footage fixture + # project) relative to the repository root, like tests/gtest does. + target_compile_definitions(oakengine_init_test PRIVATE + OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + ) endif () diff --git a/engine/include/oakengine/init.h b/engine/include/oakengine/init.h new file mode 100644 index 000000000..6d771c7af --- /dev/null +++ b/engine/include/oakengine/init.h @@ -0,0 +1,125 @@ +/*** + + 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_INIT_H +#define OAKENGINE_INIT_H + +#include "export.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file init.h + * @brief C ABI for engine process initialization and shutdown + * + * oakengine_init() brings up the UI-independent engine services a consumer of + * liboakengine needs before touching projects or timelines. It never creates + * any UI: no QApplication, no main window, no display connection. When no + * application object exists yet, an offscreen QGuiApplication is created (Qt + * requires exactly one application object; it is leaked intentionally because + * Qt cannot safely re-create one). A QGuiApplication -- not a plain + * QCoreApplication -- is required because EngineCore's undo stack creates + * QActions in its constructor, and Qt6 QActions need QGuiApplication state; + * with the offscreen QPA plugin (the default, overridable through + * QT_QPA_PLATFORM) nothing graphical ever happens. + * + * The flag mask selects the service set: + * + * - OAKENGINE_INIT_HEADLESS: Config, NodeFactory, ColorManager, TaskManager, + * ConformManager, ProxyManager, FrameManager, DiskManager, + * ProjectSerializer and a process-wide EngineCore shell (holds the global + * undo stack and the EngineCore::instance() pointer engine code + * dereferences). DiskManager is not part of EngineCore::start() but is + * required because loading a project touches PlaybackCache state which + * dereferences DiskManager::instance(). Everything here runs headless; + * no GL is required. + * + * - OAKENGINE_INIT_RENDER: additionally creates the RenderManager. Only + * with this bit may a consumer end up needing a GL context (the actual + * render backends are dynamic engine plugins loaded on demand). + * + * Initialization is modelled on the render worker's headless bootstrap + * (worker/workermain.cpp) rather than EngineCore::start(), because start() + * unconditionally creates the RenderManager, starts the autorecovery timer + * and reads the recent-projects list -- application behavior that does not + * belong behind a library boundary. + * + * Conventions: + * - Return codes: 0 (OAKENGINE_OK) on success, a negative OAKENGINE_E_* + * error code on failure. + * - oakengine_init() is idempotent: calling it again is a no-op for flag + * bits already initialized and only brings up the missing bits (e.g. + * upgrading HEADLESS to HEADLESS|RENDER). + * - oakengine_shutdown() pairs with oakengine_init() and tears down the + * initialized services in reverse order. The QCoreApplication and the + * EngineCore shell are kept alive (see above), so oakengine_init() may be + * called again afterwards. + */ + +/** + * @brief Status and error codes shared by the init/project/timeline families. + */ +#define OAKENGINE_OK 0 /**< Success. */ +#define OAKENGINE_E_INVALID (-1) /**< NULL handle or invalid argument. */ +#define OAKENGINE_E_STATE (-2) /**< Call not valid in the current state. */ +#define OAKENGINE_E_FAILED (-3) /**< The engine reported a failure. */ +#define OAKENGINE_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */ + +/** @brief Base headless engine services (no GL required). */ +#define OAKENGINE_INIT_HEADLESS 0x01 +/** @brief Render services on top of HEADLESS (may require GL). */ +#define OAKENGINE_INIT_RENDER 0x02 + +/** + * @brief Initialize the engine services selected by `flags`. + * + * `flags` is a bitmask of OAKENGINE_INIT_HEADLESS and/or + * OAKENGINE_INIT_RENDER; 0 is invalid. Repeated calls are idempotent and may + * add the RENDER bit to a running HEADLESS instance. + * + * @return OAKENGINE_OK on success, OAKENGINE_E_INVALID for an empty mask. + */ +OAKENGINE_API int oakengine_init(int flags); + +/** + * @brief Tear down the services brought up by oakengine_init(). + * + * Safe to call when not initialized (a no-op then). The QCoreApplication and + * the EngineCore shell survive shutdown intentionally. + * + * @return OAKENGINE_OK. + */ +OAKENGINE_API int oakengine_shutdown(void); + +/** + * @brief Current initialization state as a flag bitmask (0 = not initialized). + * + * Only services that are actually up are reported, e.g. after upgrading a + * HEADLESS instance with the RENDER bit the result includes both bits. + */ +OAKENGINE_API int oakengine_init_flags(void); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_INIT_H */ diff --git a/engine/include/oakengine/project.h b/engine/include/oakengine/project.h new file mode 100644 index 000000000..758e2d7ef --- /dev/null +++ b/engine/include/oakengine/project.h @@ -0,0 +1,202 @@ +/*** + + 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_PROJECT_H +#define OAKENGINE_PROJECT_H + +#include "export.h" +#include "init.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file project.h + * @brief C ABI for Oak project files (.ove) + * + * An OakEngineProject wraps the engine's olive::Project node graph. Projects + * are created empty with oakengine_project_create() and must then be given + * content exactly once: either oakengine_project_new() for a blank project or + * oakengine_project_load() to read a project file (the engine's project + * serializers require a fresh, uninitialized project, so loading into a + * project that already has content is rejected with OAKENGINE_E_STATE). + * + * Sequences are owned by their project through Qt's QObject parent chain; + * OakEngineSequence handles are borrowed views whose lifetime follows the + * project. See timeline.h for the sequence function family. + * + * Conventions (matching oakengine/ipc.h): + * - Booleans are int (1/0). + * - Return codes: 0 (OAKENGINE_OK) on success, negative OAKENGINE_E_* on + * failure. + * - String output uses the buf/size convention: the return value is the + * number of characters that would have been written excluding the NUL, + * so buf == NULL or a short buffer queries the required size. The output + * is NUL-terminated whenever buf_size > 0. A negative return value is an + * OAKENGINE_E_* error code. + * - NULL handles are accepted everywhere and yield a no-op / zero result / + * OAKENGINE_E_INVALID. + * + * Undo/redo note: the engine's undo stack is a process-wide singleton held by + * EngineCore (EngineCore::undo_stack()), not a per-project object. The + * project_undo/project_redo family therefore operates on that global stack + * and is not scoped to `self`; oakengine_project_new() and + * oakengine_project_load() clear it, matching the application's + * new/open-project behavior. + */ + +/** + * @brief Opaque project handle. Owned by the caller; release with + * oakengine_project_free(). + */ +typedef struct OakEngineProject OakEngineProject; + +/** + * @brief Opaque sequence (timeline) handle. + * + * Typedef'd here so project.h and timeline.h can be included in any order. + * Handles are borrowed from their owning OakEngineProject (QObject parent + * chain) and must NOT be freed; they become invalid when the project is + * freed. The sequence function family lives in oakengine/timeline.h. + */ +typedef struct OakEngineSequence OakEngineSequence; + +/** + * @brief Allocate an empty project shell. Owned by the caller. + * + * The project has no content until exactly one of oakengine_project_new() or + * oakengine_project_load() succeeds on it. + */ +OAKENGINE_API OakEngineProject *oakengine_project_create(void); + +/** + * @brief Destroy a project and everything it owns (including sequences). + * NULL-safe. + */ +OAKENGINE_API void oakengine_project_free(OakEngineProject *self); + +/** + * @brief Initialize `self` as a new, blank project (untitled, not modified). + * + * Clears the global undo stack. Returns OAKENGINE_E_STATE if the project + * already has content (new/load may only be applied once). + */ +OAKENGINE_API int oakengine_project_new(OakEngineProject *self); + +/** + * @brief Load project content from a .ove file. + * + * On success the project's filename is set to `path` and the modified flag is + * cleared; the global undo stack is cleared. Footage is validated like the + * engine does (EngineCore::validate_footage_in_loaded_project): files that + * moved together with the project are resolved relatively; missing footage is + * tolerated (no relink UI exists at this layer, so the project is accepted + * as-is). On failure a human-readable reason is written to `err` using the + * buf/size convention (err may be NULL) and a negative code is returned. + */ +OAKENGINE_API int oakengine_project_load(OakEngineProject *self, + const char *path, char *err, + int err_size); + +/** + * @brief Save the project to `path`. + * + * `path` == NULL saves under the project's current filename + * (Project::filename()); saving an untitled project without a path fails with + * OAKENGINE_E_INVALID. Files are gzip-compressed unless the target name ends + * in ".ovexml" (mirrors the application behavior). On success the project's + * filename is set to the target and the modified flag is cleared + * (EngineCore::on_project_saved() semantics, minus the UI recent-list). + */ +OAKENGINE_API int oakengine_project_save(OakEngineProject *self, + const char *path); + +OAKENGINE_API int oakengine_project_is_modified(const OakEngineProject *self); + +/** + * @brief Set the project modified flag (Project::set_modified). + */ +OAKENGINE_API int oakengine_project_set_modified(OakEngineProject *self, + int modified); + +/** + * @brief Project display name (Project::name(): the filename's base name, or + * "(untitled)"). Uses the buf/size convention. + */ +OAKENGINE_API int oakengine_project_name(const OakEngineProject *self, + char *buf, int buf_size); + +/** + * @brief Full path the project was loaded from / saved to, or "" if untitled + * (Project::filename()). Uses the buf/size convention. + */ +OAKENGINE_API int oakengine_project_filename(const OakEngineProject *self, + char *buf, int buf_size); + +/** + * @brief Number of footage items in the project. + */ +OAKENGINE_API int oakengine_project_footage_count(const OakEngineProject *self); + +/** + * @brief Stored filename of the footage item at `index` (Footage::filename(), + * as recorded in the project file; may be relative). Uses the buf/size + * convention; returns OAKENGINE_E_NOT_FOUND for an out-of-range index. + */ +OAKENGINE_API int oakengine_project_footage_filename( + const OakEngineProject *self, int index, char *buf, int buf_size); + +/** + * @brief 1 if the footage file at `index` exists on disk, 0 if not. + * + * Mirrors the engine's validation rule: the stored path is checked as-is + * first, then resolved relative to the project file's directory (footage + * that moved together with the project stays online). Returns + * OAKENGINE_E_NOT_FOUND for an out-of-range index. + */ +OAKENGINE_API int +oakengine_project_footage_is_online(const OakEngineProject *self, int index); + +/* ---- Undo (global singleton stack, see the file comment above) ---------- */ + +OAKENGINE_API int oakengine_project_can_undo(const OakEngineProject *self); +OAKENGINE_API int oakengine_project_can_redo(const OakEngineProject *self); +OAKENGINE_API int oakengine_project_undo(OakEngineProject *self); +OAKENGINE_API int oakengine_project_redo(OakEngineProject *self); + +/** + * @brief Number of sequences (timelines) in the project. + */ +OAKENGINE_API int +oakengine_project_sequence_count(const OakEngineProject *self); + +/** + * @brief Borrowed handle of the sequence at `index`, or NULL when out of + * range. Do NOT free; the sequence is owned by the project. + */ +OAKENGINE_API OakEngineSequence * +oakengine_project_sequence_at(const OakEngineProject *self, int index); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_PROJECT_H */ diff --git a/engine/include/oakengine/timeline.h b/engine/include/oakengine/timeline.h new file mode 100644 index 000000000..91284b814 --- /dev/null +++ b/engine/include/oakengine/timeline.h @@ -0,0 +1,185 @@ +/*** + + 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_TIMELINE_H +#define OAKENGINE_TIMELINE_H + +#include + +#include "export.h" +#include "init.h" +#include "project.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file timeline.h + * @brief C ABI for sequences (Oak timelines) + * + * An OakEngineSequence wraps the engine's olive::Sequence node + * (engine/node/project/sequence/sequence.h, a ViewerOutput). This family is + * intentionally read-mostly this round: playhead and workarea are simple + * writes, everything else is inspection. Clip/track editing is a later + * milestone. + * + * Handles are borrowed from their owning OakEngineProject (Qt QObject parent + * chain; sequences are added to the project graph which becomes their + * parent). There is deliberately no oakengine_sequence_free(): a sequence + * handle becomes invalid when its project is freed. Sequences created with + * oakengine_sequence_new() are additionally undoable -- undoing the creation + * removes the sequence from the project (but keeps it alive under the undo + * command), redoing brings it back. + * + * Time is exposed in the two representations used across the engine + * (olive::core::Timecode terminology, core/util/timecodefunctions.h): + * + * - seconds: a plain double (`*_seconds` accessors), or a rational seconds + * value as a numerator/denominator int pair (`*_rational`); + * + * - timestamp: an int64 count of timebase units, where the timebase is the + * sequence's frame duration (the frame rate flipped, e.g. 1001/30000 for + * a 30000/1001 sequence) -- i.e. a frame number. This matches how the + * engine converts between Rational times and frame timestamps + * (Timecode::time_to_timestamp / timestamp_to_time). + * + * Conventions match oakengine/project.h: booleans are int, 0 + * (OAKENGINE_OK)/negative OAKENGINE_E_* return codes, buf/size string output, + * NULL handles yield no-ops or OAKENGINE_E_INVALID. + */ + +/** + * @brief Create a new sequence named `name` in `project` and return its + * borrowed handle (NULL on failure; `project` NULL -> NULL). + * + * The sequence gets the application's default parameters + * (ViewerOutput::set_default_parameters(): width/height/pixel aspect/ + * interlacing/audio layout from Config, frame rate from the + * DefaultSequenceFrameRate config entry, 30000/1001 by default) and starts + * with zero tracks. The creation is pushed onto the global undo stack like + * the application's "Create New Sequence" action (minus opening a viewer). + */ +OAKENGINE_API OakEngineSequence * +oakengine_sequence_new(OakEngineProject *project, const char *name); + +/** + * @brief Sequence name (Node::get_label()). Uses the buf/size convention. + */ +OAKENGINE_API int oakengine_sequence_name(const OakEngineSequence *self, + char *buf, int buf_size); + +/** + * @brief Length of the sequence content in seconds + * (ViewerOutput::get_length()). 0 for an empty sequence. + */ +OAKENGINE_API int oakengine_sequence_get_length(const OakEngineSequence *self, + double *seconds); + +/** + * @brief Length of the sequence content as rational seconds + * (ViewerOutput::get_length().numerator()/denominator()). + */ +OAKENGINE_API int +oakengine_sequence_get_length_rational(const OakEngineSequence *self, int *num, + int *den); + +/** + * @brief Sequence frame rate as a num/den pair, e.g. 30000/1001 + * (ViewerOutput::get_video_params().frame_rate()). + */ +OAKENGINE_API int +oakengine_sequence_get_frame_rate(const OakEngineSequence *self, int *num, + int *den); + +/** + * @brief Number of tracks per track type (Sequence::track_list(type)-> + * get_track_count()). Any of `video`/`audio`/`subtitle` may be NULL. + */ +OAKENGINE_API int oakengine_sequence_track_count(const OakEngineSequence *self, + int *video, int *audio, + int *subtitle); + +/** + * @brief Playhead position as a timestamp in timebase units (frame number; + * ViewerOutput::get_playhead() rescaled to the frame-rate timebase). + */ +OAKENGINE_API int +oakengine_sequence_get_playhead(const OakEngineSequence *self, + int64_t *timestamp); + +/** + * @brief Move the playhead to `timestamp` (frame number; + * ViewerOutput::set_playhead()). + */ +OAKENGINE_API int oakengine_sequence_set_playhead(OakEngineSequence *self, + int64_t timestamp); + +/** + * @brief Playhead position in seconds. + */ +OAKENGINE_API int +oakengine_sequence_get_playhead_seconds(const OakEngineSequence *self, + double *seconds); + +/** + * @brief 1 if the workarea (in/out range) is enabled + * (TimelineWorkArea::enabled()). + */ +OAKENGINE_API int +oakengine_sequence_workarea_is_enabled(const OakEngineSequence *self); + +/** + * @brief Workarea in/out points as timestamps in timebase units + * (TimelineWorkArea::in()/out()). Either pointer may be NULL. + */ +OAKENGINE_API int +oakengine_sequence_get_workarea(const OakEngineSequence *self, int64_t *in, + int64_t *out); + +/** + * @brief Set the workarea: enable flag plus in/out timestamps in timebase + * units (TimelineWorkArea::set_enabled()/set_range()). + */ +OAKENGINE_API int oakengine_sequence_set_workarea(OakEngineSequence *self, + int enabled, int64_t in, + int64_t out); + +/** + * @brief Number of timeline markers (TimelineMarkerList::size()). + */ +OAKENGINE_API int +oakengine_sequence_marker_count(const OakEngineSequence *self); + +/** + * @brief Marker at `index`: `time` receives its in-point as a timestamp in + * timebase units (may be NULL), `name` its label using the buf/size + * truncation convention (may be NULL to only fetch the time). Returns + * OAKENGINE_OK on success, OAKENGINE_E_NOT_FOUND for an out-of-range index. + */ +OAKENGINE_API int oakengine_sequence_marker_at(const OakEngineSequence *self, + int index, int64_t *time, + char *name, int name_size); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_TIMELINE_H */ diff --git a/engine/src/capi/CMakeLists.txt b/engine/src/capi/CMakeLists.txt new file mode 100644 index 000000000..7d7e95914 --- /dev/null +++ b/engine/src/capi/CMakeLists.txt @@ -0,0 +1,32 @@ +# 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 . + +# C ABI facade of liboakengine (mirrors the pattern documented in +# render/ipc/CMakeLists.txt): +# - include/oakengine/*.h public C API +# - src/capi/*.cpp C ABI implementations +# The init/project/timeline families wrap the engine core, the project +# serializer and the sequence/viewer timeline objects. +set(OLIVE_SOURCES + ${OLIVE_SOURCES} + include/oakengine/init.h + include/oakengine/project.h + include/oakengine/timeline.h + src/capi/init.cpp + src/capi/project.cpp + src/capi/timeline.cpp + PARENT_SCOPE +) diff --git a/engine/src/capi/init.cpp b/engine/src/capi/init.cpp new file mode 100644 index 000000000..2bf115c18 --- /dev/null +++ b/engine/src/capi/init.cpp @@ -0,0 +1,163 @@ +/*** + + 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/init.h" + +#include +#include + +#include "codec/conformmanager.h" +#include "codec/proxymanager.h" +#include "config/config.h" +#include "coreengine.h" +#include "node/color/colormanager/colormanager.h" +#include "node/factory.h" +#include "node/project/serializer/serializer.h" +#include "render/diskmanager.h" +#include "render/framemanager.h" +#include "render/rendermanager.h" +#include "task/taskmanager.h" + +namespace +{ + +// Currently initialized OAKENGINE_INIT_* bits. +int g_flags = 0; + +// Qt requires exactly one application object for the process and cannot +// safely destroy and re-create one, so when the library has to create it the +// object (and its argv storage) is leaked intentionally. +// +// This is a QGuiApplication, not a plain QCoreApplication: EngineCore's +// UndoStack member creates QActions in its constructor, and Qt6 QActions +// dereference QGuiApplication private state (they crash without one). A +// QGuiApplication is still a QCoreApplication, and with the offscreen QPA +// plugin (defaulted below, overridable by the caller) no display +// connection, window or other UI is ever created. +void ensure_qcoreapplication() +{ + if (QCoreApplication::instance()) { + return; + } + + // Same default as the gtest harness (tests/gtest/main.cpp). + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + + static int argc = 1; + static char app_name[] = "oakengine"; + static char *argv[] = { app_name, nullptr }; + new QGuiApplication(argc, argv); + + // Same identity as the editor (app/main.cpp) so Config and friends land + // in the same locations. + QCoreApplication::setOrganizationName(QStringLiteral("oakvideoeditor.org")); + QCoreApplication::setApplicationName(QStringLiteral("Oak Video Editor")); +} + +} // namespace + +extern "C" +{ + +int oakengine_init(int flags) +{ + if (flags == 0 || (flags & ~(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER)) != 0) { + return OAKENGINE_E_INVALID; + } + + if ((flags & OAKENGINE_INIT_HEADLESS) != 0 && + (g_flags & OAKENGINE_INIT_HEADLESS) == 0) { + ensure_qcoreapplication(); + + // EngineCore shell: provides EngineCore::instance() and the global + // undo stack. Never deleted -- EngineCore does not reset instance_ in + // its destructor, so deleting would leave a dangling singleton. This + // mirrors the render worker's headless bootstrap. + if (!olive::EngineCore::instance()) { + new olive::EngineCore(olive::EngineCore::CoreParams()); + } + + olive::Config::load(); + olive::NodeFactory::initialize(); + olive::ColorManager::set_up_default_config(); + olive::TaskManager::create_instance(); + olive::ConformManager::create_instance(); + olive::ProxyManager::create_instance(); + olive::FrameManager::create_instance(); + // Not in EngineCore::start(), but required headless: loading a project + // touches PlaybackCache::load_state() which dereferences + // DiskManager::instance() (the render worker creates it for the same + // reason). + olive::DiskManager::create_instance(); + olive::ProjectSerializer::initialize(); + + g_flags |= OAKENGINE_INIT_HEADLESS; + } + + if ((flags & OAKENGINE_INIT_RENDER) != 0 && + (g_flags & OAKENGINE_INIT_RENDER) == 0) { + olive::RenderManager::create_instance(); + + g_flags |= OAKENGINE_INIT_RENDER; + } + + return OAKENGINE_OK; +} + +int oakengine_shutdown(void) +{ + // Reverse of oakengine_init(), mirroring EngineCore::stop(). The + // QCoreApplication and the EngineCore shell intentionally survive (see + // oakengine_init()). + if ((g_flags & OAKENGINE_INIT_RENDER) != 0) { + olive::RenderManager::destroy_instance(); + } + + if ((g_flags & OAKENGINE_INIT_HEADLESS) != 0) { + olive::Config::save(); + + olive::ProjectSerializer::destroy(); + + olive::DiskManager::destroy_instance(); + + olive::ConformManager::destroy_instance(); + + olive::ProxyManager::destroy_instance(); + + olive::FrameManager::destroy_instance(); + + olive::TaskManager::destroy_instance(); + + olive::NodeFactory::destroy(); + } + + g_flags = 0; + + return OAKENGINE_OK; +} + +int oakengine_init_flags(void) +{ + return g_flags; +} + +} // extern "C" diff --git a/engine/src/capi/project.cpp b/engine/src/capi/project.cpp new file mode 100644 index 000000000..e818bd305 --- /dev/null +++ b/engine/src/capi/project.cpp @@ -0,0 +1,391 @@ +/*** + + 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/project.h" + +#include +#include + +#include +#include +#include + +#include "coreengine.h" +#include "node/project.h" +#include "node/project/footage/footage.h" +#include "node/project/sequence/sequence.h" +#include "node/project/serializer/serializer.h" +#include "undo/undostack.h" + +namespace +{ + +olive::Project *impl(OakEngineProject *h) +{ + return reinterpret_cast(h); +} + +const olive::Project *impl(const OakEngineProject *h) +{ + return reinterpret_cast(h); +} + +OakEngineProject *wrap(olive::Project *p) +{ + return reinterpret_cast(p); +} + +OakEngineSequence *wrap_seq(olive::Sequence *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()); +} + +// The footage node at `index` in iteration order over the graph, or nullptr. +olive::Footage *footage_at(const olive::Project *p, int index) +{ + if (index < 0) { + return nullptr; + } + int i = 0; + for (olive::Node *n : p->nodes()) { + if (olive::Footage *f = dynamic_cast(n)) { + if (i == index) { + return f; + } + i++; + } + } + return nullptr; +} + +// The sequence node at `index` in iteration order over the graph, or nullptr. +olive::Sequence *sequence_at(const olive::Project *p, int index) +{ + if (index < 0) { + return nullptr; + } + int i = 0; + for (olive::Node *n : p->nodes()) { + if (olive::Sequence *s = dynamic_cast(n)) { + if (i == index) { + return s; + } + i++; + } + } + return nullptr; +} + +int node_count_of_type(const olive::Project *p, bool sequences) +{ + int count = 0; + for (olive::Node *n : p->nodes()) { + const bool match = sequences ? + (dynamic_cast(n) != nullptr) : + (dynamic_cast(n) != nullptr); + if (match) { + count++; + } + } + return count; +} + +// Human-readable text for a failed project load, mirroring the messages in +// ProjectLoadTask::run() (task/project/load/load.cpp). +QString load_error_string(olive::ProjectSerializer::ResultCode code, + const QString &details, const QString &filename) +{ + switch (code) { + case olive::ProjectSerializer::k_project_too_old: + return QStringLiteral( + "This project is from a version of Oak Video Editor that is no " + "longer supported in this version."); + case olive::ProjectSerializer::k_project_too_new: + return QStringLiteral( + "This project is from a newer version of Oak Video Editor and " + "cannot be opened in this version."); + case olive::ProjectSerializer::k_unknown_version: + return QStringLiteral("Failed to determine project version."); + case olive::ProjectSerializer::k_file_error: + return QStringLiteral("Failed to read file \"%1\" for reading.") + .arg(filename); + case olive::ProjectSerializer::k_xml_error: + return QStringLiteral( + "Failed to read XML document. File may be corrupt. Error was: %1") + .arg(details); + case olive::ProjectSerializer::k_no_data: + return QStringLiteral("Failed to find any data to parse."); + case olive::ProjectSerializer::k_success: + case olive::ProjectSerializer::k_overwrite_error: + break; + } + return QStringLiteral("Unknown error."); +} + +} // namespace + +extern "C" +{ + +OakEngineProject *oakengine_project_create(void) +{ + // Not initialized on purpose: the project serializers require a fresh + // project (root folder unset), so oakengine_project_new() and + // oakengine_project_load() perform the one-time content setup. + return wrap(new olive::Project()); +} + +void oakengine_project_free(OakEngineProject *self) +{ + delete impl(self); +} + +int oakengine_project_new(OakEngineProject *self) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + if (impl(self)->root() != nullptr) { + return OAKENGINE_E_STATE; + } + impl(self)->initialize(); + if (olive::EngineCore::instance()) { + olive::EngineCore::instance()->undo_stack()->clear(); + } + return OAKENGINE_OK; +} + +int oakengine_project_load(OakEngineProject *self, const char *path, + char *err, int err_size) +{ + if (!self || !path) { + return OAKENGINE_E_INVALID; + } + olive::Project *project = impl(self); + if (project->root() != nullptr) { + return OAKENGINE_E_STATE; + } + + const QString filename = QString::fromUtf8(path); + project->set_filename(filename); + + olive::ProjectSerializer::Result result = olive::ProjectSerializer::load( + project, filename, olive::ProjectSerializer::k_project); + if (result != olive::ProjectSerializer::k_success) { + // The project may be partially loaded; the handle should be freed + // (loading again is rejected above because root is set by then). + string_to_buf(load_error_string(result.code(), result.get_details(), + filename), + err, err_size); + return OAKENGINE_E_FAILED; + } + + // Validate footage like the application does: resolve files that moved + // together with the project. Without a relink handler (none exists at + // this layer) the project is accepted as-is. + if (olive::EngineCore::instance()) { + olive::EngineCore::instance()->validate_footage_in_loaded_project( + project, project->get_saved_url()); + olive::EngineCore::instance()->undo_stack()->clear(); + } + + project->set_modified(false); + if (err && err_size > 0) { + err[0] = '\0'; + } + return OAKENGINE_OK; +} + +int oakengine_project_save(OakEngineProject *self, const char *path) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + olive::Project *project = impl(self); + + QString filename = path ? QString::fromUtf8(path) : project->filename(); + if (filename.isEmpty()) { + return OAKENGINE_E_INVALID; + } + + olive::ProjectSerializer::SaveData data(olive::ProjectSerializer::k_project, + project, filename); + const bool compress = !filename.endsWith(QStringLiteral(".ovexml"), + Qt::CaseInsensitive); + olive::ProjectSerializer::Result result = + olive::ProjectSerializer::save(data, compress); + + switch (result.code()) { + case olive::ProjectSerializer::k_success: + project->set_filename(filename); + project->set_modified(false); + return OAKENGINE_OK; + case olive::ProjectSerializer::k_overwrite_error: + // The file could not be replaced and the project was written to a + // temporary name instead; the engine counts this as a success. + project->set_filename(result.get_details()); + project->set_modified(false); + return OAKENGINE_OK; + default: + return OAKENGINE_E_FAILED; + } +} + +int oakengine_project_is_modified(const OakEngineProject *self) +{ + return self && impl(self)->is_modified() ? 1 : 0; +} + +int oakengine_project_set_modified(OakEngineProject *self, int modified) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + impl(self)->set_modified(modified != 0); + return OAKENGINE_OK; +} + +int oakengine_project_name(const OakEngineProject *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->name(), buf, buf_size); +} + +int oakengine_project_filename(const OakEngineProject *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->filename(), buf, buf_size); +} + +int oakengine_project_footage_count(const OakEngineProject *self) +{ + return self ? node_count_of_type(impl(self), false) : 0; +} + +int oakengine_project_footage_filename(const OakEngineProject *self, int index, + char *buf, int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + olive::Footage *f = footage_at(impl(self), index); + if (!f) { + return OAKENGINE_E_NOT_FOUND; + } + return string_to_buf(f->filename(), buf, buf_size); +} + +int oakengine_project_footage_is_online(const OakEngineProject *self, + int index) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::Project *project = impl(self); + olive::Footage *f = footage_at(project, index); + if (!f) { + return OAKENGINE_E_NOT_FOUND; + } + const QString filename = f->filename(); + if (QFileInfo::exists(filename)) { + return 1; + } + // Footage that moved together with the project file: resolve relative + // paths against the project's directory (same rule as + // EngineCore::validate_footage_in_loaded_project()). + if (QFileInfo(filename).isRelative() && !project->filename().isEmpty()) { + const QString resolved = + QFileInfo(project->filename()).dir().filePath(filename); + if (QFileInfo::exists(resolved)) { + return 1; + } + } + return 0; +} + +int oakengine_project_can_undo(const OakEngineProject *self) +{ + if (!self || !olive::EngineCore::instance()) { + return 0; + } + return olive::EngineCore::instance()->undo_stack()->can_undo() ? 1 : 0; +} + +int oakengine_project_can_redo(const OakEngineProject *self) +{ + if (!self || !olive::EngineCore::instance()) { + return 0; + } + return olive::EngineCore::instance()->undo_stack()->can_redo() ? 1 : 0; +} + +int oakengine_project_undo(OakEngineProject *self) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + if (olive::EngineCore::instance()) { + olive::EngineCore::instance()->undo_stack()->undo(); + } + return OAKENGINE_OK; +} + +int oakengine_project_redo(OakEngineProject *self) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + if (olive::EngineCore::instance()) { + olive::EngineCore::instance()->undo_stack()->redo(); + } + return OAKENGINE_OK; +} + +int oakengine_project_sequence_count(const OakEngineProject *self) +{ + return self ? node_count_of_type(impl(self), true) : 0; +} + +OakEngineSequence *oakengine_project_sequence_at(const OakEngineProject *self, + int index) +{ + if (!self) { + return nullptr; + } + return wrap_seq(sequence_at(impl(self), index)); +} + +} // extern "C" diff --git a/engine/src/capi/timeline.cpp b/engine/src/capi/timeline.cpp new file mode 100644 index 000000000..7717bfd61 --- /dev/null +++ b/engine/src/capi/timeline.cpp @@ -0,0 +1,326 @@ +/*** + + 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/timeline.h" + +#include +#include + +#include +#include + +#include "coreengine.h" +#include "node/nodeundo.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "node/project/sequence/sequence.h" +#include "timeline/timelinemarker.h" +#include "timeline/timelineworkarea.h" +#include "undo/undocommand.h" +#include "undo/undostack.h" + +namespace +{ + +olive::Sequence *impl(OakEngineSequence *h) +{ + return reinterpret_cast(h); +} + +const olive::Sequence *impl(const OakEngineSequence *h) +{ + return reinterpret_cast(h); +} + +OakEngineSequence *wrap(olive::Sequence *s) +{ + return reinterpret_cast(s); +} + +// ViewerOutput::get_playhead() is not a const method in the engine; the +// facade keeps const-correct handles and casts locally. +olive::Sequence *mutable_impl(const OakEngineSequence *h) +{ + return const_cast( + reinterpret_cast(h)); +} + +// 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()); +} + +// Copy a QString into a fixed-capacity C buffer, always NUL-terminating and +// truncating what does not fit. +void copy_to_buf(const QString &s, char *dst, size_t cap) +{ + const QByteArray utf = s.toUtf8(); + const size_t n = qMin(size_t(utf.size()), cap - 1); + memcpy(dst, utf.constData(), n); + dst[n] = '\0'; +} + +// The sequence's frame duration as a Rational timebase (frame rate flipped). +// Returns false when the sequence has no valid frame rate (no video params). +bool time_base_of(const olive::Sequence *s, olive::Rational *out) +{ + const olive::Rational frame_rate = s->get_video_params().frame_rate(); + if (frame_rate.isNull() || frame_rate.isNaN()) { + return false; + } + *out = frame_rate.flipped(); + return true; +} + +// Rational seconds -> timestamp in timebase units, like +// Timecode::time_to_timestamp with k_round rounding. +int64_t time_to_ts(const olive::Rational &time, const olive::Rational &tb) +{ + return olive::core::Timecode::time_to_timestamp( + time, tb, olive::core::Timecode::k_round); +} + +} // namespace + +extern "C" +{ + +OakEngineSequence *oakengine_sequence_new(OakEngineProject *project, + const char *name) +{ + olive::Project *p = reinterpret_cast(project); + if (!p || !p->root()) { + return nullptr; + } + + olive::Sequence *sequence = new olive::Sequence(); + sequence->set_default_parameters(); + sequence->set_label(QString::fromUtf8(name ? name : "")); + + // Same undoable creation as the application's "Create New Sequence" + // action (app/core.cpp), minus opening a viewer. Without an EngineCore + // (library not initialized) the command is executed non-undoably. + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + command->add_child(new olive::NodeAddCommand(p, sequence)); + command->add_child(new olive::FolderAddChild(p->root(), sequence)); + + if (olive::EngineCore::instance()) { + olive::EngineCore::instance()->undo_stack()->push( + command, QStringLiteral("Create Sequence")); + } else { + command->redo_now(); + delete command; + } + + return wrap(sequence); +} + +int oakengine_sequence_name(const OakEngineSequence *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->get_label(), buf, buf_size); +} + +int oakengine_sequence_get_length(const OakEngineSequence *self, + double *seconds) +{ + if (!self || !seconds) { + return OAKENGINE_E_INVALID; + } + *seconds = impl(self)->get_length().to_double(); + return OAKENGINE_OK; +} + +int oakengine_sequence_get_length_rational(const OakEngineSequence *self, + int *num, int *den) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::Rational length = impl(self)->get_length(); + if (num) { + *num = length.numerator(); + } + if (den) { + *den = length.denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_sequence_get_frame_rate(const OakEngineSequence *self, int *num, + int *den) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::Rational frame_rate = impl(self)->get_video_params().frame_rate(); + if (frame_rate.isNull() || frame_rate.isNaN()) { + return OAKENGINE_E_STATE; + } + if (num) { + *num = frame_rate.numerator(); + } + if (den) { + *den = frame_rate.denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_sequence_track_count(const OakEngineSequence *self, int *video, + int *audio, int *subtitle) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::Sequence *s = impl(self); + if (video) { + *video = s->track_list(olive::Track::k_video)->get_track_count(); + } + if (audio) { + *audio = s->track_list(olive::Track::k_audio)->get_track_count(); + } + if (subtitle) { + *subtitle = s->track_list(olive::Track::k_subtitle)->get_track_count(); + } + return OAKENGINE_OK; +} + +int oakengine_sequence_get_playhead(const OakEngineSequence *self, + int64_t *timestamp) +{ + if (!self || !timestamp) { + return OAKENGINE_E_INVALID; + } + olive::Rational tb; + if (!time_base_of(impl(self), &tb)) { + return OAKENGINE_E_STATE; + } + *timestamp = time_to_ts(mutable_impl(self)->get_playhead(), tb); + return OAKENGINE_OK; +} + +int oakengine_sequence_set_playhead(OakEngineSequence *self, int64_t timestamp) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + olive::Rational tb; + if (!time_base_of(impl(self), &tb)) { + return OAKENGINE_E_STATE; + } + impl(self)->set_playhead( + olive::core::Timecode::timestamp_to_time(timestamp, tb)); + return OAKENGINE_OK; +} + +int oakengine_sequence_get_playhead_seconds(const OakEngineSequence *self, + double *seconds) +{ + if (!self || !seconds) { + return OAKENGINE_E_INVALID; + } + *seconds = mutable_impl(self)->get_playhead().to_double(); + return OAKENGINE_OK; +} + +int oakengine_sequence_workarea_is_enabled(const OakEngineSequence *self) +{ + return self && impl(self)->get_work_area()->enabled() ? 1 : 0; +} + +int oakengine_sequence_get_workarea(const OakEngineSequence *self, int64_t *in, + int64_t *out) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + olive::Rational tb; + if (!time_base_of(impl(self), &tb)) { + return OAKENGINE_E_STATE; + } + const olive::TimelineWorkArea *workarea = impl(self)->get_work_area(); + if (in) { + *in = time_to_ts(workarea->in(), tb); + } + if (out) { + *out = time_to_ts(workarea->out(), tb); + } + return OAKENGINE_OK; +} + +int oakengine_sequence_set_workarea(OakEngineSequence *self, int enabled, + int64_t in, int64_t out) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + olive::Rational tb; + if (!time_base_of(impl(self), &tb)) { + return OAKENGINE_E_STATE; + } + olive::TimelineWorkArea *workarea = impl(self)->get_work_area(); + workarea->set_enabled(enabled != 0); + workarea->set_range( + olive::TimeRange(olive::core::Timecode::timestamp_to_time(in, tb), + olive::core::Timecode::timestamp_to_time(out, tb))); + return OAKENGINE_OK; +} + +int oakengine_sequence_marker_count(const OakEngineSequence *self) +{ + if (!self) { + return 0; + } + return int(impl(self)->get_markers()->size()); +} + +int oakengine_sequence_marker_at(const OakEngineSequence *self, int index, + int64_t *time, char *name, int name_size) +{ + if (!self || index < 0) { + return OAKENGINE_E_INVALID; + } + const olive::TimelineMarkerList *markers = impl(self)->get_markers(); + if (size_t(index) >= markers->size()) { + return OAKENGINE_E_NOT_FOUND; + } + const olive::TimelineMarker *marker = *(markers->cbegin() + index); + if (time) { + olive::Rational tb; + if (!time_base_of(impl(self), &tb)) { + return OAKENGINE_E_STATE; + } + *time = time_to_ts(marker->time().in(), tb); + } + if (name && name_size > 0) { + copy_to_buf(marker->name(), name, size_t(name_size)); + } + return OAKENGINE_OK; +} + +} // extern "C" diff --git a/engine/tests/oakengine_init_test.cpp b/engine/tests/oakengine_init_test.cpp new file mode 100644 index 000000000..fa705d49a --- /dev/null +++ b/engine/tests/oakengine_init_test.cpp @@ -0,0 +1,420 @@ +/*** + + 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 init/project/timeline facade. +// Exercises engine init/shutdown, project create/new/save/load round-trips, +// the global undo stack, sequence inspection (length, frame rate, tracks, +// playhead, workarea, markers) and a fixture project with real footage +// (tests/demo.mp4). No Qt, no GL: oakengine_init() creates the +// QCoreApplication itself when needed. + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#endif + +#include "oakengine/init.h" +#include "oakengine/project.h" +#include "oakengine/timeline.h" + +#ifndef OAK_TEST_SOURCE_DIR +#define OAK_TEST_SOURCE_DIR "." +#endif + +static char g_tmpdir[4096]; + +static void make_tmpdir(void) +{ +#if defined(_WIN32) + char base[MAX_PATH]; + const DWORD len = GetTempPathA(MAX_PATH, base); + assert(len > 0 && len < MAX_PATH); + snprintf(g_tmpdir, sizeof(g_tmpdir), "%soakengine_init_test_%lu", base, + (unsigned long)GetCurrentProcessId()); + assert(_mkdir(g_tmpdir) == 0); +#else + strcpy(g_tmpdir, "/tmp/oakengine_init_test_XXXXXX"); + assert(mkdtemp(g_tmpdir) != NULL); +#endif +} + +static void make_path(char *dst, size_t cap, const char *filename) +{ + const int n = snprintf(dst, cap, "%s/%s", g_tmpdir, filename); + assert(n > 0 && (size_t)n < cap); +} + +static int file_exists(const char *path) +{ + FILE *f = fopen(path, "rb"); + if (!f) { + return 0; + } + fclose(f); + return 1; +} + +static void test_init(void) +{ + assert(oakengine_init(0) == OAKENGINE_E_INVALID); + assert(oakengine_init(4) == OAKENGINE_E_INVALID); // unknown bit + + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + assert(oakengine_init_flags() == OAKENGINE_INIT_HEADLESS); + + // Repeated init is idempotent. + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + assert(oakengine_init_flags() == OAKENGINE_INIT_HEADLESS); +} + +static void test_project_basics(void) +{ + OakEngineProject *p = oakengine_project_create(); + assert(p != NULL); + + // Fresh shell: untitled, unmodified, empty. + assert(oakengine_project_is_modified(p) == 0); + assert(oakengine_project_footage_count(p) == 0); + assert(oakengine_project_sequence_count(p) == 0); + assert(oakengine_project_can_undo(p) == 0); + assert(oakengine_project_can_redo(p) == 0); + + assert(oakengine_project_new(p) == OAKENGINE_OK); + // Content setup may only happen once. + assert(oakengine_project_new(p) == OAKENGINE_E_STATE); + + // An untitled project reports the engine's "(untitled)" name and an + // empty filename. + char name[64]; + const int name_len = oakengine_project_name(p, NULL, 0); + assert(name_len > 0); + assert(oakengine_project_name(p, name, sizeof(name)) == name_len); + assert((int)strlen(name) == name_len); + assert(strcmp(name, "(untitled)") == 0); + // buf/size truncation: reports the full size, writes what fits. + char tiny[4]; + assert(oakengine_project_name(p, tiny, sizeof(tiny)) == name_len); + assert(strlen(tiny) == sizeof(tiny) - 1); + + char filename[16]; + assert(oakengine_project_filename(p, filename, sizeof(filename)) == 0); + assert(filename[0] == '\0'); + + // Modified flag round-trips. + assert(oakengine_project_set_modified(p, 1) == OAKENGINE_OK); + assert(oakengine_project_is_modified(p) == 1); + assert(oakengine_project_set_modified(p, 0) == OAKENGINE_OK); + assert(oakengine_project_is_modified(p) == 0); + + // Saving an untitled project without a path fails. + assert(oakengine_project_save(p, NULL) == OAKENGINE_E_INVALID); + + oakengine_project_free(p); +} + +static void test_sequence_and_save_load(void) +{ + char path[4096]; + make_path(path, sizeof(path), "roundtrip.ove"); + + OakEngineProject *p = oakengine_project_create(); + assert(p != NULL); + assert(oakengine_project_new(p) == OAKENGINE_OK); + + // Create a sequence through the facade; it is owned by the project. + OakEngineSequence *seq = oakengine_sequence_new(p, "Main"); + assert(seq != NULL); + assert(oakengine_project_sequence_count(p) == 1); + assert(oakengine_project_sequence_at(p, 0) == seq); + assert(oakengine_project_sequence_at(p, 1) == NULL); + assert(oakengine_project_sequence_at(p, -1) == NULL); + + // The creation went onto the global undo stack and marked the project. + assert(oakengine_project_can_undo(p) == 1); + assert(oakengine_project_is_modified(p) == 1); + + // Sequence name round-trips. + char name[64]; + const int name_len = oakengine_sequence_name(seq, NULL, 0); + assert(name_len == 4); + assert(oakengine_sequence_name(seq, name, sizeof(name)) == 4); + assert(strcmp(name, "Main") == 0); + + // Empty sequence: zero length. + double seconds = -1.0; + assert(oakengine_sequence_get_length(seq, &seconds) == OAKENGINE_OK); + assert(seconds == 0.0); + int num = -1, den = -1; + assert(oakengine_sequence_get_length_rational(seq, &num, &den) == + OAKENGINE_OK); + assert(num == 0); + + // Default frame rate from Config's DefaultSequenceFrameRate entry + // (time base 1001/30000 -> frame rate 30000/1001). + num = den = -1; + assert(oakengine_sequence_get_frame_rate(seq, &num, &den) == OAKENGINE_OK); + assert(num == 30000 && den == 1001); + + // Fresh sequences have no tracks. + int video = -1, audio = -1, subtitle = -1; + assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) == + OAKENGINE_OK); + assert(video == 0 && audio == 0 && subtitle == 0); + + // Playhead starts at zero and accepts frame timestamps. + int64_t ts = -1; + assert(oakengine_sequence_get_playhead(seq, &ts) == OAKENGINE_OK); + assert(ts == 0); + assert(oakengine_sequence_set_playhead(seq, 30) == OAKENGINE_OK); + ts = -1; + assert(oakengine_sequence_get_playhead(seq, &ts) == OAKENGINE_OK); + assert(ts == 30); + // 30 frames at 1001/30000 per frame = 1.001 seconds. + seconds = -1.0; + assert(oakengine_sequence_get_playhead_seconds(seq, &seconds) == + OAKENGINE_OK); + assert(fabs(seconds - 1.001) < 1e-9); + + // Workarea: disabled by default, then set + read back. + assert(oakengine_sequence_workarea_is_enabled(seq) == 0); + assert(oakengine_sequence_set_workarea(seq, 1, 10, 40) == OAKENGINE_OK); + assert(oakengine_sequence_workarea_is_enabled(seq) == 1); + int64_t in = -1, out = -1; + assert(oakengine_sequence_get_workarea(seq, &in, &out) == OAKENGINE_OK); + assert(in == 10 && out == 40); + + // No markers on a fresh sequence. + assert(oakengine_sequence_marker_count(seq) == 0); + assert(oakengine_sequence_marker_at(seq, 0, NULL, NULL, 0) == + OAKENGINE_E_NOT_FOUND); + + // Save; the modified flag clears and the filename is adopted. + assert(oakengine_project_save(p, path) == OAKENGINE_OK); + assert(oakengine_project_is_modified(p) == 0); + assert(file_exists(path)); + char filename[4096]; + assert(oakengine_project_filename(p, filename, sizeof(filename)) == + (int)strlen(path)); + assert(strcmp(filename, path) == 0); + + // Undo removes the sequence, redo brings the same object back. + assert(oakengine_project_undo(p) == OAKENGINE_OK); + assert(oakengine_project_sequence_count(p) == 0); + assert(oakengine_project_can_redo(p) == 1); + assert(oakengine_project_redo(p) == OAKENGINE_OK); + assert(oakengine_project_sequence_count(p) == 1); + assert(oakengine_project_sequence_at(p, 0) == seq); + oakengine_project_free(p); + + // Load the saved file into a fresh project. + OakEngineProject *q = oakengine_project_create(); + assert(q != NULL); + char err[512]; + assert(oakengine_project_load(q, path, err, sizeof(err)) == OAKENGINE_OK); + assert(oakengine_project_is_modified(q) == 0); + assert(oakengine_project_can_undo(q) == 0); // load clears the undo stack + assert(oakengine_project_can_redo(q) == 0); + + // Project name derives from the file's base name. + const int qname_len = oakengine_project_name(q, NULL, 0); + assert(qname_len == (int)strlen("roundtrip")); + assert(oakengine_project_name(q, name, sizeof(name)) == qname_len); + assert(strcmp(name, "roundtrip") == 0); + assert(oakengine_project_filename(q, filename, sizeof(filename)) == + (int)strlen(path)); + assert(strcmp(filename, path) == 0); + + // The sequence survived the round trip, workarea included (the workarea + // is serialized by ViewerOutput::save_custom()). + assert(oakengine_project_sequence_count(q) == 1); + OakEngineSequence *loaded = oakengine_project_sequence_at(q, 0); + assert(loaded != NULL); + assert(oakengine_sequence_name(loaded, name, sizeof(name)) == 4); + assert(strcmp(name, "Main") == 0); + num = den = -1; + assert(oakengine_sequence_get_frame_rate(loaded, &num, &den) == + OAKENGINE_OK); + assert(num == 30000 && den == 1001); + assert(oakengine_sequence_track_count(loaded, &video, &audio, + &subtitle) == OAKENGINE_OK); + assert(video == 0 && audio == 0 && subtitle == 0); + assert(oakengine_sequence_workarea_is_enabled(loaded) == 1); + in = out = -1; + assert(oakengine_sequence_get_workarea(loaded, &in, &out) == OAKENGINE_OK); + assert(in == 10 && out == 40); + assert(oakengine_project_footage_count(q) == 0); + + // Saving with a NULL path reuses the project's current filename. + assert(oakengine_project_save(q, NULL) == OAKENGINE_OK); + assert(file_exists(path)); + oakengine_project_free(q); + + // Loading a missing file fails and reports a human-readable reason. + OakEngineProject *r = oakengine_project_create(); + assert(r != NULL); + char missing[4096]; + make_path(missing, sizeof(missing), "does-not-exist.ove"); + assert(oakengine_project_load(r, missing, err, sizeof(err)) == + OAKENGINE_E_FAILED); + assert(strlen(err) > 0); + // Loading into a project that already has content is rejected. + assert(oakengine_project_new(r) == OAKENGINE_OK); + assert(oakengine_project_load(r, missing, err, sizeof(err)) == + OAKENGINE_E_STATE); + oakengine_project_free(r); +} + +// tests/project_with_footage.ove is a small uncompressed project holding one +// footage item whose stored filename is the relative path "demo.mp4" (the +// real media file sits next to the fixture in tests/). +static void test_footage_fixture(void) +{ + char fixture[4096]; + const int n = snprintf(fixture, sizeof(fixture), + "%s/tests/project_with_footage.ove", + OAK_TEST_SOURCE_DIR); + assert(n > 0 && (size_t)n < sizeof(fixture)); + assert(file_exists(fixture)); + + OakEngineProject *p = oakengine_project_create(); + assert(p != NULL); + char err[512]; + assert(oakengine_project_load(p, fixture, err, sizeof(err)) == + OAKENGINE_OK); + + assert(oakengine_project_footage_count(p) == 1); + char filename[256]; + assert(oakengine_project_footage_filename(p, 0, filename, + sizeof(filename)) > 0); + assert(strcmp(filename, "demo.mp4") == 0); + // The stored relative path resolves against the project file's + // directory (tests/), where demo.mp4 exists. + assert(oakengine_project_footage_is_online(p, 0) == 1); + // Out-of-range footage indexes report OAKENGINE_E_NOT_FOUND. + assert(oakengine_project_footage_filename(p, 1, filename, + sizeof(filename)) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_project_footage_is_online(p, -1) == + OAKENGINE_E_NOT_FOUND); + + assert(oakengine_project_sequence_count(p) == 1); + OakEngineSequence *seq = oakengine_project_sequence_at(p, 0); + assert(seq != NULL); + char name[64]; + assert(oakengine_sequence_name(seq, name, sizeof(name)) > 0); + assert(strcmp(name, "Fixture Sequence") == 0); + + oakengine_project_free(p); +} + +static void test_null_safety(void) +{ + oakengine_project_free(NULL); + assert(oakengine_project_new(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_project_load(NULL, "/tmp/x.ove", NULL, 0) == + OAKENGINE_E_INVALID); + assert(oakengine_project_save(NULL, "/tmp/x.ove") == OAKENGINE_E_INVALID); + assert(oakengine_project_is_modified(NULL) == 0); + assert(oakengine_project_set_modified(NULL, 1) == OAKENGINE_E_INVALID); + assert(oakengine_project_name(NULL, NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_project_filename(NULL, NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_project_footage_count(NULL) == 0); + assert(oakengine_project_footage_filename(NULL, 0, NULL, 0) == + OAKENGINE_E_INVALID); + assert(oakengine_project_footage_is_online(NULL, 0) == + OAKENGINE_E_INVALID); + assert(oakengine_project_can_undo(NULL) == 0); + assert(oakengine_project_can_redo(NULL) == 0); + assert(oakengine_project_undo(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_project_redo(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_project_sequence_count(NULL) == 0); + assert(oakengine_project_sequence_at(NULL, 0) == NULL); + + assert(oakengine_sequence_new(NULL, "x") == NULL); + assert(oakengine_sequence_name(NULL, NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_sequence_get_length(NULL, NULL) == OAKENGINE_E_INVALID); + assert(oakengine_sequence_get_length_rational(NULL, NULL, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_get_frame_rate(NULL, NULL, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_track_count(NULL, NULL, NULL, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_get_playhead(NULL, NULL) == OAKENGINE_E_INVALID); + assert(oakengine_sequence_set_playhead(NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_sequence_get_playhead_seconds(NULL, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_workarea_is_enabled(NULL) == 0); + assert(oakengine_sequence_get_workarea(NULL, NULL, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_set_workarea(NULL, 0, 0, 0) == + OAKENGINE_E_INVALID); + assert(oakengine_sequence_marker_count(NULL) == 0); + assert(oakengine_sequence_marker_at(NULL, 0, NULL, NULL, 0) == + OAKENGINE_E_INVALID); +} + +static void test_shutdown_pairing(void) +{ + assert(oakengine_shutdown() == OAKENGINE_OK); + assert(oakengine_init_flags() == 0); + + // Shutdown is idempotent. + assert(oakengine_shutdown() == OAKENGINE_OK); + + // init may run again after a paired shutdown. + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + assert(oakengine_init_flags() == OAKENGINE_INIT_HEADLESS); + assert(oakengine_shutdown() == OAKENGINE_OK); + assert(oakengine_init_flags() == 0); +} + +int main(void) +{ + make_tmpdir(); + + // Sandbox the config/cache/data locations so the engine never reads or + // writes the real user configuration. The engine's config path comes + // from QStandardPaths::AppDataLocation + // (FileFunctions::get_configuration_location()), which follows + // XDG_DATA_HOME on Linux; the other two are sandboxed for good measure. +#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 + + test_init(); + test_project_basics(); + test_sequence_and_save_load(); + test_footage_fixture(); + test_null_safety(); + test_shutdown_pairing(); + + printf("oakengine_init_test: all assertions passed\n"); + return 0; +} diff --git a/tests/project_with_footage.ove b/tests/project_with_footage.ove new file mode 100644 index 000000000..70f50796a --- /dev/null +++ b/tests/project_with_footage.ove @@ -0,0 +1,311 @@ + + + + + {b9147285-72ca-4b6b-b980-ce1dfbec408f} + + + + + + 0 + + true + + + + + + + + + + + + + + + + + + + + + + + + + 94695150298656 + + + 94695150363552 + + + + + + {3f699391-032f-4b27-9ab9-297faa2781ba} + {a11aa028-d950-460f-a7a6-d71df445ce68} + + + + + + + + + demo.mp4 + + + + + + 0 + + true + + + + + + + + 0 + 0 + 0 + 0/1 + -1 + 0 + 1/1 + 0 + 1 + 1 + 0 + 0 + 0 + 0 + 0/1 + 0 + 0 + 0 + + 0 + 0 + 0 + + + + + + + + + 0 + 0 + + 1 + 0 + 0 + 0/1 + + + + + + + + + + + + + + + {d7346615-f439-4910-9c0b-c9b94df27883} + {7d154ff3-1794-451c-9495-528c5d5c4576} + + + 0 + + + 0 + 0/1 + 0/1 + + + + + + + + + + 0 + + true + + + + + + + + 0 + 0 + 0 + 0/1 + -1 + 0 + 1/1 + 0 + 1 + 1 + 0 + 0 + 0 + 0 + 0/1 + 0 + 0 + 0 + + 0 + 0 + 0 + + + + + + + + 1920 + 1080 + 1 + 1/10 + 3 + 4 + 1/1 + 0 + 1 + 1 + 0 + 0 + 0 + 0 + 10/1 + 0 + 0 + 0 + + 0 + 0 + 0 + + + + + + + + + + 0 + 0 + + 1 + 0 + 0 + 0/1 + + + + + + + + 48000 + 3 + f32p + 1 + 0 + 0 + 1/48000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {edd6c747-f4a8-4ecd-869c-e2d522ea648f} + {654584d4-b0c7-40d7-9c12-95971f981e69} + + + + 0 + 0/1 + 0/1 + + + + + + + scene_linear + Rec.709 OETF + 94695150158256 + + + + + + + + + + +