From e9f173916f235f539bc9e9ca0bb056b9c95ca084 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 26 Jul 2026 22:43:00 +0800 Subject: [PATCH] engine: complete C ABI facade (liboakengine oakengine_* surface) The full pure-C facade used by the app: node/project/timeline/viewer/ undo/task/events/serializer/playback/preview/renderer/gizmo/color/ audio/footage/proxy/encoding/exporter/config/disk/ipc/plugin/worker families, plus undo-group semantics, display renderer handles, NodeFactory accessors, and per-family pure-C engine tests. --- engine/CMakeLists.txt | 91 +- engine/include/oakengine/app.h | 463 +++ engine/include/oakengine/audio.h | 330 ++ engine/include/oakengine/color.h | 297 ++ engine/include/oakengine/config.h | 100 + engine/include/oakengine/disk.h | 154 + engine/include/oakengine/display.h | 186 + engine/include/oakengine/encoding.h | 505 +++ engine/include/oakengine/events.h | 263 ++ engine/include/oakengine/footage.h | 81 + engine/include/oakengine/gizmo.h | 163 + engine/include/oakengine/lut.h | 87 + engine/include/oakengine/node.h | 1080 +++++- engine/include/oakengine/plugin.h | 74 + engine/include/oakengine/preview.h | 120 + engine/include/oakengine/project.h | 127 + engine/include/oakengine/proxy.h | 131 + engine/include/oakengine/renderer.h | 39 + engine/include/oakengine/serializer.h | 254 ++ engine/include/oakengine/sync.h | 137 + engine/include/oakengine/task.h | 293 ++ engine/include/oakengine/timeline.h | 464 +++ engine/include/oakengine/traverse.h | 167 + engine/include/oakengine/undo.h | 272 ++ engine/include/oakengine/videoparams.h | 211 ++ engine/include/oakengine/viewer.h | 350 ++ engine/include/oakengine/worker.h | 146 + engine/src/capi/CMakeLists.txt | 39 + engine/src/capi/app.cpp | 812 +++++ engine/src/capi/audio.cpp | 458 +++ engine/src/capi/color.cpp | 448 +++ engine/src/capi/colorinternal.h | 38 + engine/src/capi/config.cpp | 130 + engine/src/capi/disk.cpp | 187 + engine/src/capi/display.cpp | 182 + engine/src/capi/encoding.cpp | 1160 +++++++ engine/src/capi/events.cpp | 1221 +++++++ engine/src/capi/export.cpp | 17 + engine/src/capi/exportinternal.h | 58 + engine/src/capi/footage.cpp | 240 +- engine/src/capi/gizmo.cpp | 268 ++ engine/src/capi/lut.cpp | 90 + engine/src/capi/node.cpp | 3030 ++++++++++++++++- engine/src/capi/plugin.cpp | 152 + engine/src/capi/preview.cpp | 305 +- engine/src/capi/project.cpp | 286 ++ engine/src/capi/proxy.cpp | 169 + engine/src/capi/renderer.cpp | 61 + engine/src/capi/serializer.cpp | 470 +++ engine/src/capi/sync.cpp | 327 ++ engine/src/capi/task.cpp | 471 +++ engine/src/capi/timeline.cpp | 1158 ++++++- engine/src/capi/traverse.cpp | 374 ++ engine/src/capi/undo.cpp | 711 ++++ engine/src/capi/undointernal.h | 45 + engine/src/capi/viewer.cpp | 619 ++++ engine/src/capi/worker.cpp | 945 +++++ engine/tests/oakengine_app_test.cpp | 510 +++ engine/tests/oakengine_audio_test.cpp | 203 ++ engine/tests/oakengine_color_test.cpp | 432 +++ engine/tests/oakengine_config_test.cpp | 118 + engine/tests/oakengine_disk_test.cpp | 301 ++ engine/tests/oakengine_encoding_test.cpp | 659 ++++ engine/tests/oakengine_events_test.cpp | 960 ++++++ engine/tests/oakengine_export_test.cpp | 5 + engine/tests/oakengine_footage_test.cpp | 292 ++ engine/tests/oakengine_keyframe_test.cpp | 260 ++ engine/tests/oakengine_lut_test.cpp | 69 + engine/tests/oakengine_node_test.cpp | 681 ++++ engine/tests/oakengine_nodevalue_test.cpp | 156 + engine/tests/oakengine_preview_test.cpp | 131 +- engine/tests/oakengine_proxy_test.cpp | 130 + engine/tests/oakengine_renderer_test.cpp | 11 + engine/tests/oakengine_serializer_test.cpp | 434 +++ engine/tests/oakengine_sync_test.cpp | 314 ++ engine/tests/oakengine_task_test.cpp | 412 +++ engine/tests/oakengine_timeline_edit_test.cpp | 601 ++++ engine/tests/oakengine_traverse_test.cpp | 277 ++ engine/tests/oakengine_viewer_test.cpp | 592 ++++ engine/tests/oakengine_worker_test.cpp | 237 ++ 80 files changed, 28715 insertions(+), 126 deletions(-) create mode 100644 engine/include/oakengine/app.h create mode 100644 engine/include/oakengine/audio.h create mode 100644 engine/include/oakengine/color.h create mode 100644 engine/include/oakengine/config.h create mode 100644 engine/include/oakengine/disk.h create mode 100644 engine/include/oakengine/display.h create mode 100644 engine/include/oakengine/encoding.h create mode 100644 engine/include/oakengine/events.h create mode 100644 engine/include/oakengine/gizmo.h create mode 100644 engine/include/oakengine/lut.h create mode 100644 engine/include/oakengine/plugin.h create mode 100644 engine/include/oakengine/proxy.h create mode 100644 engine/include/oakengine/serializer.h create mode 100644 engine/include/oakengine/sync.h create mode 100644 engine/include/oakengine/task.h create mode 100644 engine/include/oakengine/traverse.h create mode 100644 engine/include/oakengine/undo.h create mode 100644 engine/include/oakengine/videoparams.h create mode 100644 engine/include/oakengine/viewer.h create mode 100644 engine/include/oakengine/worker.h create mode 100644 engine/src/capi/app.cpp create mode 100644 engine/src/capi/audio.cpp create mode 100644 engine/src/capi/color.cpp create mode 100644 engine/src/capi/colorinternal.h create mode 100644 engine/src/capi/config.cpp create mode 100644 engine/src/capi/disk.cpp create mode 100644 engine/src/capi/display.cpp create mode 100644 engine/src/capi/encoding.cpp create mode 100644 engine/src/capi/events.cpp create mode 100644 engine/src/capi/exportinternal.h create mode 100644 engine/src/capi/gizmo.cpp create mode 100644 engine/src/capi/lut.cpp create mode 100644 engine/src/capi/plugin.cpp create mode 100644 engine/src/capi/proxy.cpp create mode 100644 engine/src/capi/serializer.cpp create mode 100644 engine/src/capi/sync.cpp create mode 100644 engine/src/capi/task.cpp create mode 100644 engine/src/capi/traverse.cpp create mode 100644 engine/src/capi/undo.cpp create mode 100644 engine/src/capi/undointernal.h create mode 100644 engine/src/capi/viewer.cpp create mode 100644 engine/src/capi/worker.cpp create mode 100644 engine/tests/oakengine_app_test.cpp create mode 100644 engine/tests/oakengine_audio_test.cpp create mode 100644 engine/tests/oakengine_color_test.cpp create mode 100644 engine/tests/oakengine_config_test.cpp create mode 100644 engine/tests/oakengine_disk_test.cpp create mode 100644 engine/tests/oakengine_encoding_test.cpp create mode 100644 engine/tests/oakengine_events_test.cpp create mode 100644 engine/tests/oakengine_lut_test.cpp create mode 100644 engine/tests/oakengine_nodevalue_test.cpp create mode 100644 engine/tests/oakengine_proxy_test.cpp create mode 100644 engine/tests/oakengine_serializer_test.cpp create mode 100644 engine/tests/oakengine_sync_test.cpp create mode 100644 engine/tests/oakengine_task_test.cpp create mode 100644 engine/tests/oakengine_traverse_test.cpp create mode 100644 engine/tests/oakengine_viewer_test.cpp create mode 100644 engine/tests/oakengine_worker_test.cpp diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index a15abe5cf..13b7612a3 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -51,6 +51,13 @@ add_library(oakengine SHARED ${OLIVE_RESOURCES} ) +# macOS: hides the render worker's dock icon; called from the worker main in +# src/capi/worker.cpp (declared there as a plain C++ symbol). +if (APPLE) + target_sources(oakengine PRIVATE src/worker_dockicon_mac.mm) + target_link_libraries(oakengine PRIVATE "-framework Cocoa") +endif () + add_subdirectory(common) add_subdirectory(pluginSupport) @@ -61,7 +68,7 @@ set_target_properties(oakengine PROPERTIES ) # Consumers resolve engine headers ("node/...", "render/...", "coreengine.h", -# "ui/icons/icons.h", "tool/tool.h") from the engine root, and the public C +# "tool/tool.h") from the engine root, and the public C # API ("oakengine/ipc.h") from include/. The library itself builds against its # internal implementation headers under src/oliveimpl, included with an # "oliveimpl/"-prefixed path resolved from src/ (mirrors the src/oliveimpl @@ -227,13 +234,47 @@ if (BUILD_TESTS) make_oakengine_test(oakengine_ipc_test) + make_oakengine_test(oakengine_worker_test) + make_oakengine_test(oakengine_init_test) + + make_oakengine_test(oakengine_app_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}" ) + make_oakengine_test(oakengine_task_test) + # The task test creates temporary projects and imports non-existent files, + # so it only needs the headless engine services. + + make_oakengine_test(oakengine_config_test) + # The config test exercises the QSettings-backed key/value store through + # the C ABI; it is headless and does not touch the disk cache. + + make_oakengine_test(oakengine_audio_test) + # The audio test exercises the AudioManager instance lifecycle, device + # get/set round-trips and the output_params_changed event. It is headless + # and only needs PortAudio initialization. + + make_oakengine_test(oakengine_disk_test) + # The disk test exercises the DiskManager instance lifecycle, default cache + # path queries, cache clearing, settings handler dispatch and default path + # mutation. It is headless and only touches temporary directories. + + make_oakengine_test(oakengine_proxy_test) + # The proxy test exercises proxy parameter defaults, state string + # round-trips and the ProxyManager singleton lifecycle. It is headless. + + make_oakengine_test(oakengine_lut_test) + # The LUT test exercises directory/file list queries and the + # set_directories round-trip. It is headless. + + make_oakengine_test(oakengine_serializer_test) + # The serializer test exercises compressed-project detection, clipboard + # create/free/copy and empty-node copy. It is headless. + make_oakengine_test(oakengine_renderer_test) # The renderer test builds sequence content through the engine C++ API # (allowed for engine-internal tests) and probes the dynamic render @@ -275,6 +316,15 @@ if (BUILD_TESTS) OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) + make_oakengine_test(oakengine_events_test) + target_compile_definitions(oakengine_events_test PRIVATE + OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + ) + + make_oakengine_test(oakengine_encoding_test) + + make_oakengine_test(oakengine_color_test) + make_oakengine_test(oakengine_export_test) # The export test builds sequence content through the engine C++ API and # probes the dynamic render backend like oakengine_renderer_test does. @@ -303,6 +353,8 @@ if (BUILD_TESTS) endif () make_oakengine_test(oakengine_node_test) + + make_oakengine_test(oakengine_nodevalue_test) target_compile_definitions(oakengine_node_test PRIVATE OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) @@ -312,6 +364,16 @@ if (BUILD_TESTS) OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) + make_oakengine_test(oakengine_viewer_test) + target_compile_definitions(oakengine_viewer_test PRIVATE + OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + ) + + make_oakengine_test(oakengine_traverse_test) + target_compile_definitions(oakengine_traverse_test PRIVATE + OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + ) + make_oakengine_test(oakengine_preview_test) # The preview test needs audio rendering (RenderManager + workers). target_include_directories(oakengine_preview_test PRIVATE @@ -362,4 +424,31 @@ if (BUILD_TESTS) if (TARGET olive-render-worker) add_dependencies(oakengine_playback_test olive-render-worker) endif () + + make_oakengine_test(oakengine_sync_test) + # The sync test renders the clips' audio through the worker pool (same + # needs as oakengine_playback_test). + target_include_directories(oakengine_sync_test PRIVATE + ${CMAKE_SOURCE_DIR}/third_party/openfx/include + ${CMAKE_SOURCE_DIR}/third_party/openfx/HostSupport/include + ${OLIVE_INCLUDE_DIRS} + ) + target_compile_definitions(oakengine_sync_test PRIVATE + ${OLIVE_DEFINITIONS} + OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + ) + target_compile_options(oakengine_sync_test PRIVATE + ${OLIVE_COMPILE_OPTIONS} + ) + if (OAK_ENABLE_DYNAMIC_RENDER_BACKEND) + target_compile_definitions(oakengine_sync_test PRIVATE + OAK_ENABLE_DYNAMIC_RENDER_BACKEND) + add_dependencies(oakengine_sync_test oakgl) + if (TARGET oakvulkan) + add_dependencies(oakengine_sync_test oakvulkan) + endif () + endif () + if (TARGET olive-render-worker) + add_dependencies(oakengine_sync_test olive-render-worker) + endif () endif () diff --git a/engine/include/oakengine/app.h b/engine/include/oakengine/app.h new file mode 100644 index 000000000..91a1e39d1 --- /dev/null +++ b/engine/include/oakengine/app.h @@ -0,0 +1,463 @@ +/*** + + 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_APP_H +#define OAKENGINE_APP_H + +#include "export.h" +#include "footage.h" +#include "init.h" +#include "project.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file app.h + * @brief C ABI for the application-level engine state (EngineCore facade) + * + * This family exposes the process-wide application state the editor UI needs + * from the engine: the CoreParams-driven startup, the open/active project, + * the recent-projects list, the global tool/snapping/timecode settings, the + * status bar and the UI handler hooks the engine calls when it needs user + * interaction (image-sequence confirmation, footage relink, project save / + * close, main window layout restore, OTIO import). + * + * It wraps olive::EngineCore so that the UI layer no longer derives from or + * links against that C++ class. The engine emits its change notifications + * through the OakEngineAppCallbacks function pointers (registered with + * oakengine_app_set_callbacks()) instead of Qt signals. + * + * Conventions (matching oakengine/project.h): + * - Booleans are int (1/0). + * - Return codes: 0 (OAKENGINE_OK) on success, negative OAKENGINE_E_* on + * failure. Functions documented as returning a value return + * OAKENGINE_E_INVALID when no application core exists. + * - 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. + * - Enum values mirror the engine enums (olive::Tool::Item, + * olive::Tool::AddableObject, olive::core::Timecode::Display) and are + * passed as plain int; the numeric values are identical. + */ + +/** + * @brief Application run modes (mirrors olive::EngineCore::CoreParams::RunMode). + */ +#define OAKENGINE_APP_RUN_NORMAL 0 /**< Normal GUI run. */ +#define OAKENGINE_APP_RUN_HEADLESS_EXPORT 1 /**< Export without GUI. */ +#define OAKENGINE_APP_RUN_HEADLESS_PRE_CACHE 2 /**< Pre-cache without GUI. */ + +/** + * @brief Startup parameters for oakengine_app_create(). + * + * Strings may be NULL (treated as empty). The struct is copied by + * oakengine_app_create(); the pointed-to strings are only read during the + * call. + */ +typedef struct OakEngineAppParams { + int run_mode; /**< OAKENGINE_APP_RUN_* value. */ + int fullscreen; /**< Start the main window fullscreen (bool). */ + const char *startup_project; /**< Project file to open on startup, or NULL. */ + const char *startup_language; /**< .qm file overriding the language, or NULL. */ + int crash_on_startup; /**< Trigger a manual crash shortly after start (bool). */ +} OakEngineAppParams; + +/** + * @brief UI handler and notification callback set. + * + * Any field may be NULL. A NULL handler makes the engine fall back to its + * headless default (accept the import, close without prompting, skip the + * file write); a NULL notification simply drops the event. + * + * The callbacks are invoked synchronously on the thread that triggered the + * engine call (usually the main thread). `userdata` is passed back verbatim. + * + * `load_layout` receives a `const olive::SerializedLayoutInfo *` (engine + * data structure, only valid during the call). `otio_import` receives an + * array of borrowed olive::Sequence pointers as OakEngineSequence handles. + */ +typedef struct OakEngineAppCallbacks { + void *userdata; + + /* UI handlers (engine asks the application) */ + int (*confirm_image_sequence)(const char *filename, void *userdata); + int (*relink_footage)(OakEngineFootage **footage, int count, + void *userdata); + void (*save_project)(const char *override_filename, void *userdata); + int (*close_project)(void *userdata); + void (*load_layout)(const void *layout, void *userdata); + int (*otio_import)(OakEngineSequence **sequences, int count, + void *userdata); + + /* Notifications (engine informs the application) */ + void (*status_message_show)(const char *message, int timeout, + void *userdata); + void (*status_message_clear)(void *userdata); + void (*cache_full_warning)(void *userdata); + void (*active_project_changed)(OakEngineProject *project, void *userdata); + void (*tool_changed)(int tool, void *userdata); + void (*addable_object_changed)(int object, void *userdata); + void (*snapping_changed)(int snapping, void *userdata); + void (*timecode_display_changed)(int display, void *userdata); + void (*open_recent_list_changed)(void *userdata); + void (*color_picker_enabled)(int enabled, void *userdata); +} OakEngineAppCallbacks; + +/** + * @brief Create the application engine core with the given startup params. + * + * `params` may be NULL for defaults (normal run, no startup project). Only + * one application core may exist per process: if one already exists (either + * from an earlier oakengine_app_create() or from the EngineCore shell that + * oakengine_init() creates), OAKENGINE_E_STATE is returned. + * + * The core is never destroyed; it backs the process-wide engine singleton. + * + * @return OAKENGINE_OK on success, OAKENGINE_E_STATE if a core exists. + */ +OAKENGINE_API int oakengine_app_create(const OakEngineAppParams *params); + +/** + * @brief Start the engine services for the application (config, locale, + * managers, autorecovery timer, recent projects list). + * + * @return OAKENGINE_OK on success, OAKENGINE_E_STATE if no core exists or + * the application core was already started. + */ +OAKENGINE_API int oakengine_app_start(void); + +/** + * @brief Stop the engine services started by oakengine_app_start(). + * + * @return OAKENGINE_OK on success, OAKENGINE_E_STATE if the application + * core was not started. + */ +OAKENGINE_API int oakengine_app_stop(void); + +/** + * @brief Register the UI handler/notification callback set. + * + * The struct is copied; NULL clears all callbacks and restores the headless + * default behavior. + * + * @return OAKENGINE_OK. + */ +OAKENGINE_API int +oakengine_app_set_callbacks(const OakEngineAppCallbacks *callbacks); + +/** + * @brief Startup parameter accessors (valid once a core exists). + * + * oakengine_app_run_mode() returns an OAKENGINE_APP_RUN_* value, + * oakengine_app_fullscreen() a boolean; both return OAKENGINE_E_INVALID when + * no core exists. oakengine_app_startup_project() uses the buf/size + * convention. + */ +OAKENGINE_API int oakengine_app_run_mode(void); +OAKENGINE_API int oakengine_app_fullscreen(void); +OAKENGINE_API int oakengine_app_startup_project(char *buf, int buf_size); + +/** + * @brief Process-wide undo stack as an opaque pointer (an + * olive::UndoStack *). Returns NULL when no core exists. + */ +OAKENGINE_API void *oakengine_app_undo_stack(void); + +/** + * @brief Current tool as an olive::Tool::Item value (int). + */ +OAKENGINE_API int oakengine_app_tool(void); + +/** + * @brief Set the current tool. Valid values are 0 <= tool < Tool::k_count. + * Emits the tool_changed notification. + */ +OAKENGINE_API int oakengine_app_set_tool(int tool); + +/** + * @brief Currently selected addable object (olive::Tool::AddableObject). + */ +OAKENGINE_API int oakengine_app_addable_object(void); + +/** + * @brief Set the addable object. Valid values are 0 <= object < + * Tool::k_addable_count. Emits addable_object_changed. + */ +OAKENGINE_API int oakengine_app_set_addable_object(int object); + +/** + * @brief Currently selected transition id (buf/size convention). + */ +OAKENGINE_API int oakengine_app_selected_transition(char *buf, int buf_size); + +/** + * @brief Set the selected transition id (NULL clears it). + */ +OAKENGINE_API int oakengine_app_set_selected_transition(const char *id); + +/** + * @brief Current snapping setting (boolean). + */ +OAKENGINE_API int oakengine_app_snapping(void); + +/** + * @brief Set snapping. Emits snapping_changed. + */ +OAKENGINE_API int oakengine_app_set_snapping(int enabled); + +/** + * @brief Current timecode display mode (olive::core::Timecode::Display). + */ +OAKENGINE_API int oakengine_app_timecode_display(void); + +/** + * @brief Set the timecode display mode (0 <= display <= 4). Emits + * timecode_display_changed. + */ +OAKENGINE_API int oakengine_app_set_timecode_display(int display); + +/** + * @brief Number of entries in the recently opened/saved projects list. + */ +OAKENGINE_API int oakengine_app_recent_projects_count(void); + +/** + * @brief Path of the recent-project entry at `index` (buf/size convention). + * + * @return the string length, or OAKENGINE_E_NOT_FOUND for an invalid index. + */ +OAKENGINE_API int oakengine_app_recent_project_at(int index, char *buf, + int buf_size); + +/** + * @brief Remove the recent-project entry at `index`. Emits + * open_recent_list_changed. + * + * @return OAKENGINE_OK, or OAKENGINE_E_NOT_FOUND for an invalid index. + */ +OAKENGINE_API int oakengine_app_remove_recent_project(int index); + +/** + * @brief Clear the recent projects list. Emits open_recent_list_changed. + */ +OAKENGINE_API int oakengine_app_clear_recent_projects(void); + +/** + * @brief Show a message in the status bar (delivered through the + * status_message_show callback). + */ +OAKENGINE_API int oakengine_app_show_status_message(const char *message, + int timeout); + +/** + * @brief Clear the status bar (delivered through the status_message_clear + * callback). + */ +OAKENGINE_API int oakengine_app_clear_status_message(void); + +/** + * @brief Change the current language. + * + * @return 1 if a translation for `locale` was found and installed, 0 if + * not, OAKENGINE_E_INVALID for NULL or when no core exists. + */ +OAKENGINE_API int oakengine_app_set_language(const char *locale); + +/** + * @brief Set how frequently an autorecovery is saved (minutes). + */ +OAKENGINE_API int oakengine_app_set_autorecovery_interval(int minutes); + +/** + * @brief Globally enable/disable decoding from proxy media. + */ +OAKENGINE_API int oakengine_app_set_use_proxy_media(int enabled); + +/** + * @brief Add/remove a pixel-sampling user. Emits color_picker_enabled when + * the user count crosses 0. + */ +OAKENGINE_API int oakengine_app_request_pixel_sampling(int enable); + +/** + * @brief Debug "magic" flag accessors. + */ +OAKENGINE_API int oakengine_app_set_magic(int enabled); +OAKENGINE_API int oakengine_app_is_magic_enabled(void); + +/** + * @brief Copy a string to the system clipboard. + */ +OAKENGINE_API int oakengine_app_copy_to_clipboard(const char *text); + +/** + * @brief Paste a string from the system clipboard (buf/size convention). + */ +OAKENGINE_API int oakengine_app_paste_from_clipboard(char *buf, int buf_size); + +/** + * @brief File filter for footage import dialogs (buf/size convention). + */ +OAKENGINE_API int oakengine_app_footage_file_dialog_filter(char *buf, + int buf_size); + +/** + * @brief Whether `path` has an extension allowed for footage import. + * + * @return 1/0, or OAKENGINE_E_INVALID for NULL. + */ +OAKENGINE_API int oakengine_app_is_footage_extension_allowed(const char *path); + +/** + * @brief Create a new sequence named appropriately for `project`. + * + * `name_format` is a QString::arg() pattern (e.g. "Sequence %1"); NULL uses + * the default "Sequence %1". The returned handle is owned by the caller + * (it is not yet added to the project). Returns NULL on invalid input. + */ +OAKENGINE_API OakEngineSequence * +oakengine_app_create_sequence(OakEngineProject *project, + const char *name_format); + +/** + * @brief Path of the autorecovery index file (buf/size convention). + */ +OAKENGINE_API int oakengine_app_auto_recovery_index_filename(char *buf, + int buf_size); + +/** + * @brief Currently open project (borrowed handle, may be NULL). + */ +OAKENGINE_API OakEngineProject *oakengine_app_open_project(void); + +/** + * @brief Close the current project (through the close_project handler) and + * open a new empty one. + */ +OAKENGINE_API int oakengine_app_create_new_project(void); + +/** + * @brief Open an already-loaded project, closing the current one first. + * Pushes it to the recent list when `add_to_recents` is set and the project + * has a filename. + */ +OAKENGINE_API int oakengine_app_add_open_project(OakEngineProject *project, + int add_to_recents); + +/** + * @brief Adopt the project loaded by a project-load task (an olive::Task * + * as an opaque pointer). + * + * @return 1 if the project was opened, 0 if the load was cancelled or the + * footage validation was rejected, OAKENGINE_E_INVALID for NULL. + */ +OAKENGINE_API int oakengine_app_add_open_project_from_task(void *task, + int add_to_recents); + +/** + * @brief Adopt an autorecovery project loaded by a project-load task (an + * olive::Task * as an opaque pointer). + * + * @return 1 on success, 0 otherwise, OAKENGINE_E_INVALID for NULL. + */ +OAKENGINE_API int oakengine_app_add_recovery_project_from_task(void *task); + +/** + * @brief Update engine state after `project` was successfully saved (recent + * list, modified flag, unrecovered list). + */ +OAKENGINE_API int oakengine_app_on_project_saved(OakEngineProject *project); + +/** + * @brief Set the active (open) project. Emits active_project_changed. + * `project` may be NULL. + */ +OAKENGINE_API int oakengine_app_set_active_project(OakEngineProject *project); + +/** + * @brief Convenience wrapper: set just the confirm-image-sequence handler + * (same as setting cb.confirm_image_sequence in oakengine_app_set_callbacks). + * Replaces both fn and userdata. + */ +OAKENGINE_API int oakengine_app_set_confirm_image_sequence_handler( + int (*fn)(const char *filename, void *userdata), void *userdata); + +/** + * @brief Convenience wrapper: set just the relink handler. + */ +OAKENGINE_API int oakengine_app_set_relink_handler( + int (*fn)(OakEngineFootage **footage, int count, void *userdata), + void *userdata); + +/** + * @brief Convenience wrapper: set just the save-project handler. + */ +OAKENGINE_API int oakengine_app_set_save_project_handler( + void (*fn)(const char *override_filename, void *userdata), void *userdata); + +/** + * @brief Convenience wrapper: set just the close-project handler. + */ +OAKENGINE_API int oakengine_app_set_close_project_handler( + int (*fn)(void *userdata), void *userdata); + +/** + * @brief Convenience wrapper: set just the load-layout handler. + */ +OAKENGINE_API int oakengine_app_set_load_layout_handler( + void (*fn)(const void *layout, void *userdata), void *userdata); + +/** + * @brief Alias for oakengine_app_auto_recovery_index_filename(). + */ +OAKENGINE_API int oakengine_app_get_auto_recovery_index_filename(char *buf, + int buf_size); + +/** + * @brief Alias for oakengine_app_remove_recent_project(). + */ +OAKENGINE_API int oakengine_app_remove_recently_opened_project(int index); + +/** + * @brief void*-based overload of oakengine_app_on_project_saved() for use + * from app code that holds a opaque QObject pointer. + */ +OAKENGINE_API int oakengine_app_on_project_saved_vp(void *project); + +/** + * @brief void*-based overload of oakengine_app_set_active_project(). + */ +OAKENGINE_API int oakengine_app_set_active_project_vp(void *project); + +/** + * @brief void*-based overload of oakengine_app_add_open_project(). + */ +OAKENGINE_API int oakengine_app_add_open_project_vp(void *project, + int add_to_recents); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_APP_H */ diff --git a/engine/include/oakengine/audio.h b/engine/include/oakengine/audio.h new file mode 100644 index 000000000..306d2f298 --- /dev/null +++ b/engine/include/oakengine/audio.h @@ -0,0 +1,330 @@ +/*** + + 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_AUDIO_H +#define OAKENGINE_AUDIO_H + +#include + +#include "export.h" +#include "encoding.h" +#include "init.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file audio.h + * @brief C ABI for the engine's audio I/O singleton (olive::AudioManager) + * + * A thin facade over AudioManager's instance lifecycle, input/output device + * selection, output buffer management and recording stop control. The + * AudioManager handle returned by oakengine_audio_manager_handle() is a + * borrowed opaque pointer intended only for event subscription + * (OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_PARAMS_CHANGED); it is not a general + * purpose object handle and must not be freed. + * + * Conventions match the other facade families: + * - 0 (OAKENGINE_OK) / negative OAKENGINE_E_* codes. + * - Device indices are PortAudio PaDeviceIndex values (int64_t across the + * boundary); paNoDevice is -1. + * - String output uses the buf/size convention (error_buf for + * oakengine_audio_push_to_output). + */ + +typedef struct OakAudioParams OakAudioParams; + +/** + * @brief Create the AudioManager singleton. + * + * Safe to call when the instance already exists (no-op). Returns + * OAKENGINE_OK or OAKENGINE_E_FAILED. + */ +OAKENGINE_API int oakengine_audio_create_instance(void); + +/** + * @brief Destroy the AudioManager singleton. + * + * Safe to call when no instance exists (no-op). Returns OAKENGINE_OK. + */ +OAKENGINE_API int oakengine_audio_destroy_instance(void); + +/** + * @brief Borrowed handle to the AudioManager singleton, or NULL if none. + * + * Intended only for subscribing to + * OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_PARAMS_CHANGED. The pointer is owned + * by the engine and becomes NULL after oakengine_audio_destroy_instance(). + */ +OAKENGINE_API void *oakengine_audio_manager_handle(void); + +/** + * @brief Current output device index (paNoDevice = -1 when none). + * + * Returns the current value from the AudioManager singleton, or paNoDevice if + * no instance exists. + */ +OAKENGINE_API int64_t oakengine_audio_get_output_device(void); + +/** + * @brief Set the output device index. + * + * Changing the device may emit output_params_changed. Returns OAKENGINE_OK or + * OAKENGINE_E_FAILED. + */ +OAKENGINE_API int oakengine_audio_set_output_device(int64_t device); + +/** + * @brief Current input device index (paNoDevice = -1 when none). + */ +OAKENGINE_API int64_t oakengine_audio_get_input_device(void); + +/** + * @brief Set the input device index. + */ +OAKENGINE_API int oakengine_audio_set_input_device(int64_t device); + +/** + * @brief Re-initialize PortAudio and refresh the device lists. + */ +OAKENGINE_API int oakengine_audio_hard_reset(void); + +/** + * @brief Clear any buffered output samples. + */ +OAKENGINE_API int oakengine_audio_clear_buffered_output(void); + +/** + * @brief Push a packed sample buffer to the current output device. + * + * `params` is an owned or borrowed OakAudioParams handle describing the + * sample data. `samples` points to `samples_size` bytes of interleaved audio + * data in the format described by `params`. On failure a human-readable + * message is written into `error_buf` (up to `error_buf_size` bytes including + * the terminating NUL) and OAKENGINE_E_FAILED is returned. + */ +OAKENGINE_API int oakengine_audio_push_to_output(const OakAudioParams *params, + const char *samples, + int64_t samples_size, + char *error_buf, + int error_buf_size); + +/** + * @brief Stop an active recording session. + */ +OAKENGINE_API int oakengine_audio_stop_recording(void); + +/** + * @brief Stop audio output. + */ +OAKENGINE_API int oakengine_audio_stop_output(void); + +/** + * @brief Restart the output clock at zero for a new playback run. + */ +OAKENGINE_API int oakengine_audio_reset_output_clock(void); + +/** + * @brief Set the output notify interval in bytes. + * + * After this many bytes of audio have been consumed by the output device, + * the AudioManager emits output_notify (which translates to the + * OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_NOTIFY event for C subscribers). + */ +OAKENGINE_API int oakengine_audio_set_output_notify_interval(int64_t bytes); + +/** + * @brief Start audio recording. + * + * Takes ownership of `params`: the handle is destroyed when the recording + * ends. On failure a human-readable message is written into `error_buf` + * (up to `error_buf_size` bytes including the terminating NUL). + * + * @return OAKENGINE_OK on success, OAKENGINE_E_FAILED on error. + */ +OAKENGINE_API int oakengine_audio_start_recording( + OakEngineEncodingParams *params, char *error_buf, int error_buf_size); + +/* ---- Audio synchronization (R6 P1.3) ------------------------------------ */ + +/** @brief Result of envelope-offset correlation. */ +typedef struct oak_audio_waveform_offset { + int64_t offset_samples; + double confidence; + /** 1 if the offset is usable, 0 otherwise. */ + int valid; +} oak_audio_waveform_offset; + +/** @brief Result of rate+offset correlation. */ +typedef struct oak_audio_waveform_stretch_offset { + double rate; + int64_t offset_samples; + double confidence; + /** 1 if the result is usable, 0 otherwise. */ + int valid; +} oak_audio_waveform_stretch_offset; + +/** + * @brief Estimate the sample offset between two RMS envelopes. + * + * `reference_valid`/`candidate_valid` may be NULL to mean "all windows valid"; + * if non-NULL their lengths must equal `reference_len`/`candidate_len`. + * + * @return OAKENGINE_OK with `out` filled, or OAKENGINE_E_INVALID. + */ +OAKENGINE_API int oakengine_audio_estimate_envelope_offset( + const double *reference, int reference_len, + const double *candidate, int candidate_len, + const bool *reference_valid, int reference_valid_len, + const bool *candidate_valid, int candidate_valid_len, + uint64_t window_samples, int64_t max_offset_windows, + oak_audio_waveform_offset *out); + +/** + * @brief Estimate a playback-rate change plus offset aligning candidate to + * reference. + * + * See AudioWaveformSync::estimate_stretch_and_offset(). + */ +OAKENGINE_API int oakengine_audio_estimate_stretch_and_offset( + const double *reference, int reference_len, + const double *candidate, int candidate_len, + const bool *reference_valid, int reference_valid_len, + const bool *candidate_valid, int candidate_valid_len, + uint64_t window_samples, int64_t max_offset_windows, + double min_rate, double max_rate, double rate_step, + oak_audio_waveform_stretch_offset *out); + +/** @brief Source-clip description for source-time synchronization. */ +typedef struct oak_audio_sync_source_clip { + /** Source start time as a Rational num/den pair. */ + int64_t source_start_time_num; + int64_t source_start_time_den; + /** Media in-point as a Rational num/den pair. */ + int64_t media_in_num; + int64_t media_in_den; + /** 1 if source_start_time is meaningful, 0 otherwise. */ + int has_source_start_time; +} oak_audio_sync_source_clip; + +/** @brief Timeline placement result from AudioSynchronizer. */ +typedef struct oak_audio_sync_placement { + /** Timeline in-point as a Rational num/den pair. */ + int64_t timeline_in_num; + int64_t timeline_in_den; + /** 1 if the placement is usable, 0 otherwise. */ + int valid; +} oak_audio_sync_placement; + +/** + * @brief Compute a candidate clip's timeline placement from source timecodes. + * + * `reference_timeline_in` is the reference clip's timeline in-point as a + * Rational num/den pair. + */ +OAKENGINE_API int oakengine_audio_sync_place_by_source_time( + const oak_audio_sync_source_clip *reference, + const oak_audio_sync_source_clip *candidate, + int64_t reference_timeline_in_num, int64_t reference_timeline_in_den, + oak_audio_sync_placement *out); + +/** + * @brief Compute a candidate clip's timeline placement from a waveform offset. + */ +OAKENGINE_API int oakengine_audio_sync_place_by_waveform_offset( + int64_t reference_timeline_in_num, int64_t reference_timeline_in_den, + int64_t candidate_offset_samples, int sample_rate, + oak_audio_sync_placement *out); + +/* ---- Audio format processor (R6 P5) ------------------------------------- */ + +/** + * @brief Opaque audio format converter (olive::AudioProcessor). + * + * Converts planar float samples from one format to a packed output format, + * optionally applying tempo (speed) scaling. Used by the viewer to feed the + * audio output device. Create with oakengine_audio_processor_create() and + * destroy with oakengine_audio_processor_free(). + */ +typedef struct OakEngineAudioProcessor OakEngineAudioProcessor; + +/** + * @brief Create an audio processor with no open graph. + * + * Returns NULL on allocation failure. + */ +OAKENGINE_API OakEngineAudioProcessor *oakengine_audio_processor_create(void); + +/** + * @brief Destroy the processor, closing any open graph. Safe to call with + * NULL. + */ +OAKENGINE_API void oakengine_audio_processor_free(OakEngineAudioProcessor *p); + +/** + * @brief Open the conversion graph. + * + * `from` describes the planar float input format and `to` the packed output + * format (both borrowed handles, copied internally). `tempo` is the playback + * speed (1.0 = normal). The processor must not already be open. Returns + * OAKENGINE_OK, OAKENGINE_E_INVALID, or OAKENGINE_E_FAILED. + */ +OAKENGINE_API int oakengine_audio_processor_open(OakEngineAudioProcessor *p, + const OakAudioParams *from, const OakAudioParams *to, double tempo); + +/** + * @brief Close the conversion graph. Safe to call when not open or with + * NULL. + */ +OAKENGINE_API void oakengine_audio_processor_close(OakEngineAudioProcessor *p); + +/** + * @brief 1 if the processor has an open graph, 0 otherwise (or NULL). + */ +OAKENGINE_API int oakengine_audio_processor_is_open(OakEngineAudioProcessor *p); + +/** + * @brief Convert planar float samples to the packed output format. + * + * `in` is an array of per-channel float pointers (channel count as given to + * open()); `nb_in_samples` is the number of frames. On success (>= 0), + * `*out_data` points to the packed output bytes owned by `p` (valid until the + * next convert/close/free) and `*out_size` holds the byte count, which may be + * 0 when the tempo buffer absorbed the block. Returns a negative error code + * on failure. + */ +OAKENGINE_API int oakengine_audio_processor_convert(OakEngineAudioProcessor *p, + float **in, int nb_in_samples, const void **out_data, int *out_size); + +/** + * @brief Output (packed) parameters as a new OakAudioParams handle. + * + * The caller owns the result and must free it with oakcore_audioparams_free(). + * Returns NULL if `p` is NULL or not open. + */ +OAKENGINE_API OakAudioParams *oakengine_audio_processor_output_params( + OakEngineAudioProcessor *p); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_AUDIO_H */ diff --git a/engine/include/oakengine/color.h b/engine/include/oakengine/color.h new file mode 100644 index 000000000..d3b9ac986 --- /dev/null +++ b/engine/include/oakengine/color.h @@ -0,0 +1,297 @@ +/*** + + 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_COLOR_H +#define OAKENGINE_COLOR_H + +#include "export.h" +#include "init.h" +#include "project.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file color.h + * @brief C ABI for color management (the olive::ColorManager / + * ColorTransform / ColorProcessor surface) + * + * Covers everything the application's display and color-picker paths need + * without importing an engine C++ symbol: + * + * - OakEngineColorManager: borrowed handle to a project's color manager + * (olive::ColorManager). Obtain it with + * oakengine_color_manager_from_project(); like the other borrowed + * handles it is just the engine pointer reinterpreted and its lifetime + * follows the project. All list queries use the index + buf/size + * string pattern (the return value of a string getter is the would-be + * length excluding the NUL, so buf == NULL queries the size). + * + * - oak_color_transform: POD mirror of olive::ColorTransform. `output` + * is the colorspace name when `is_display` is 0, otherwise the display + * device name with `view`/`look` selecting the display transform. NULL + * strings mean "unset" (the empty QString). + * + * - OakEngineColorProcessor: owned handle wrapping an OCIO-backed + * olive::ColorProcessorPtr. Free with + * oakengine_color_processor_free(). Color conversion is per-color + * (double RGBA in/out); the frame-level GPU path goes through + * ColorTransformJob on the engine side. + * + * - OakEngineColorConfig: owned handle to a standalone OCIO config (the + * project properties dialog lists the colorspaces of a config file + * before applying it). + * + * Change notifications (config reloads, reference space changes) are + * delivered through the event family: subscribe with + * OAKENGINE_EVENT_COLOR_MANAGER_CONFIG_CHANGED / + * OAKENGINE_EVENT_COLOR_MANAGER_REFERENCE_SPACE_CHANGED (oakengine/events.h). + * + * Error model: functions that can hit an OCIO failure report the reason + * through oakengine_color_last_error() (thread-local, buf/size + * convention). List/query functions never fail on a valid handle. + */ + +/** @brief Borrowed color manager handle (olive::ColorManager). */ +typedef struct OakEngineColorManager OakEngineColorManager; + +/** @brief Owned color processor handle; free with oakengine_color_processor_free(). */ +typedef struct OakEngineColorProcessor OakEngineColorProcessor; + +/** @brief Owned standalone OCIO config handle; free with oakengine_color_config_free(). */ +typedef struct OakEngineColorConfig OakEngineColorConfig; + +/** @brief Processor direction: input -> output (olive k_normal). */ +#define OAKENGINE_COLOR_PROCESSOR_NORMAL 0 +/** @brief Processor direction: output -> input (olive k_inverse). */ +#define OAKENGINE_COLOR_PROCESSOR_INVERSE 1 + +/** + * @brief POD mirror of olive::ColorTransform. Strings are UTF-8; NULL is + * the unset/empty value. + */ +typedef struct oak_color_transform { + int is_display; /**< 0: `output` is a colorspace; 1: display/view/look. */ + const char *output; /**< Colorspace name, or display device when is_display. */ + const char *view; /**< Display view (is_display only). */ + const char *look; /**< Display look (is_display only). */ +} oak_color_transform; + +/** + * @brief Human-readable reason of the last failed color call on this + * thread (buf/size convention). Empty when the last call succeeded. + */ +OAKENGINE_API int oakengine_color_last_error(char *buf, int buf_size); + +/** + * @brief The project's color manager (borrowed; NULL for a NULL project or + * a project without one). + */ +OAKENGINE_API OakEngineColorManager * +oakengine_color_manager_from_project(OakEngineProject *project); + +/** @brief Current OCIO config filename of the manager (buf/size). */ +OAKENGINE_API int oakengine_color_manager_get_config_filename( + const OakEngineColorManager *mgr, char *buf, int buf_size); + +/** + * @brief Point the manager at a different OCIO config file + * (ColorManager::set_config_filename()). OAKENGINE_E_INVALID for NULL + * args. OCIO load failures surface lazily through the list queries. + */ +OAKENGINE_API int oakengine_color_manager_set_config_filename( + OakEngineColorManager *mgr, const char *filename); + +/** @brief Number of colorspaces in the manager's active config. */ +OAKENGINE_API int oakengine_color_manager_colorspace_count( + const OakEngineColorManager *mgr); + +/** @brief Name of the `index`-th colorspace (buf/size); OAKENGINE_E_INVALID out of range. */ +OAKENGINE_API int oakengine_color_manager_colorspace_at( + const OakEngineColorManager *mgr, int index, char *buf, int buf_size); + +/** @brief Number of display devices in the active config. */ +OAKENGINE_API int oakengine_color_manager_display_count( + const OakEngineColorManager *mgr); + +/** @brief Name of the `index`-th display device (buf/size). */ +OAKENGINE_API int oakengine_color_manager_display_at( + const OakEngineColorManager *mgr, int index, char *buf, int buf_size); + +/** @brief Number of views available on `display` (NULL/empty = active display). */ +OAKENGINE_API int oakengine_color_manager_view_count( + const OakEngineColorManager *mgr, const char *display); + +/** @brief Name of the `index`-th view on `display` (buf/size). */ +OAKENGINE_API int oakengine_color_manager_view_at( + const OakEngineColorManager *mgr, const char *display, int index, + char *buf, int buf_size); + +/** @brief Number of looks in the active config. */ +OAKENGINE_API int oakengine_color_manager_look_count( + const OakEngineColorManager *mgr); + +/** @brief Name of the `index`-th look (buf/size). */ +OAKENGINE_API int oakengine_color_manager_look_at( + const OakEngineColorManager *mgr, int index, char *buf, int buf_size); + +/** @brief The config's default display device (buf/size). */ +OAKENGINE_API int oakengine_color_manager_default_display( + const OakEngineColorManager *mgr, char *buf, int buf_size); + +/** @brief The config's default view for `display` (buf/size). */ +OAKENGINE_API int oakengine_color_manager_default_view( + const OakEngineColorManager *mgr, const char *display, char *buf, + int buf_size); + +/** @brief The project's default input colorspace (buf/size). */ +OAKENGINE_API int oakengine_color_manager_default_input_color_space( + const OakEngineColorManager *mgr, char *buf, int buf_size); + +/** @brief Set the project's default input colorspace. */ +OAKENGINE_API int oakengine_color_manager_set_default_input_color_space( + OakEngineColorManager *mgr, const char *colorspace); + +/** @brief The config's reference (scene-linear) colorspace (buf/size). */ +OAKENGINE_API int oakengine_color_manager_reference_color_space( + const OakEngineColorManager *mgr, char *buf, int buf_size); + +/** + * @brief The config's default luma coefficients written to `rgb` (exactly + * 3 doubles; ColorManager::get_default_luma_coefs()). OAKENGINE_E_INVALID + * for NULL args. + */ +OAKENGINE_API int oakengine_color_manager_default_luma_coefs( + const OakEngineColorManager *mgr, double *rgb); + +/** + * @brief Resolve `name` to a colorspace of the active config + * (ColorManager::get_compliant_color_space(QString); buf/size). Unknown + * names resolve to the default input colorspace. + */ +OAKENGINE_API int oakengine_color_manager_compliant_color_space( + const OakEngineColorManager *mgr, const char *name, char *buf, + int buf_size); + +/** + * @brief Resolve a transform to one the active config supports + * (ColorManager::get_compliant_color_space(ColorTransform, force_display)). + * + * The resolved transform is written into the output buffers; any output + * pointer may be NULL. Buffers that are too small truncate (NUL-terminated + * when size > 0). `out_is_display` receives the resolved kind. + * + * @return OAKENGINE_OK, or OAKENGINE_E_INVALID for NULL mgr/in. + */ +OAKENGINE_API int oakengine_color_manager_compliant_transform( + const OakEngineColorManager *mgr, const oak_color_transform *in, + int force_display, int *out_is_display, char *out_output, + int output_size, char *out_view, int view_size, char *out_look, + int look_size); + +/** + * @brief Load the engine's built-in default OCIO config (owned handle). + * NULL on failure (see oakengine_color_last_error()). + */ +OAKENGINE_API OakEngineColorConfig *oakengine_color_config_load_default(void); + +/** + * @brief Load an OCIO config from `filename` (owned handle). NULL on + * failure (see oakengine_color_last_error()). + */ +OAKENGINE_API OakEngineColorConfig * +oakengine_color_config_load_file(const char *filename); + +/** @brief Release a config handle (NULL-safe no-op). */ +OAKENGINE_API void oakengine_color_config_free(OakEngineColorConfig *config); + +/** @brief Number of colorspaces in the config. */ +OAKENGINE_API int +oakengine_color_config_colorspace_count(const OakEngineColorConfig *config); + +/** @brief Name of the `index`-th colorspace in the config (buf/size). */ +OAKENGINE_API int oakengine_color_config_colorspace_at( + const OakEngineColorConfig *config, int index, char *buf, int buf_size); + +/** + * @brief Create a color processor converting from colorspace `input` to + * the `dest` transform (ColorProcessor::create(); owned handle). + * + * `direction` is OAKENGINE_COLOR_PROCESSOR_NORMAL or + * OAKENGINE_COLOR_PROCESSOR_INVERSE. OCIO failures are non-fatal (matching + * the engine's C++ behavior): the handle is still returned but + * oakengine_color_processor_is_valid() reports 0 and conversions are + * pass-through. + * + * @return The handle, or NULL for NULL mgr/input/dest or an unknown + * direction. + */ +OAKENGINE_API OakEngineColorProcessor *oakengine_color_processor_create( + const OakEngineColorManager *mgr, const char *input, + const oak_color_transform *dest, int direction); + +/** @brief Release a processor handle (NULL-safe no-op). */ +OAKENGINE_API void oakengine_color_processor_free(OakEngineColorProcessor *proc); + +/** + * @brief 1 when the processor holds a valid OCIO processor + * (ColorProcessor::get_processor() != null), 0 otherwise. + */ +OAKENGINE_API int +oakengine_color_processor_is_valid(const OakEngineColorProcessor *proc); + +/** + * @brief Convert a single RGBA color (ColorProcessor::convert_color()). + * `in_rgba`/`out_rgba` are 4-double arrays; on an invalid processor the + * input is copied through. + * + * @return OAKENGINE_OK, or OAKENGINE_E_INVALID for NULL args. + */ +OAKENGINE_API int oakengine_color_processor_convert_color( + const OakEngineColorProcessor *proc, const double *in_rgba, + double *out_rgba); + +/** + * @brief The OCIO cache id of the processor (ColorProcessor::id(); + * buf/size). Used by display paths to invalidate cached conversions. + */ +OAKENGINE_API int oakengine_color_processor_id( + const OakEngineColorProcessor *proc, char *buf, int buf_size); + +/** + * @brief Attach a processor to an engine ColorTransformJob + * (ColorTransformJob::set_color_processor()). + * + * Transitional bridge for the display/scopes GPU path until the blit + * family covers ColorTransformJob: `job` is an + * olive::ColorTransformJob* the caller owns, passed as void* to keep the + * C++ type out of the ABI. + * + * @return OAKENGINE_OK, or OAKENGINE_E_INVALID for a NULL job. + */ +OAKENGINE_API int oakengine_color_transform_job_set_processor( + void *job, const OakEngineColorProcessor *proc); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_COLOR_H */ diff --git a/engine/include/oakengine/config.h b/engine/include/oakengine/config.h new file mode 100644 index 000000000..db836e0e4 --- /dev/null +++ b/engine/include/oakengine/config.h @@ -0,0 +1,100 @@ +/*** + + 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_CONFIG_H +#define OAKENGINE_CONFIG_H + +#include + +#include "init.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file config.h + * @brief C ABI for the engine configuration store (olive::Config). + * + * A thin facade over the QSettings-backed key/value store used by the editor + * for persistent preferences. Only the types actually used by the UI are + * exposed (string/int); the engine keeps ownership of the singleton. + */ + +typedef void (*oakengine_config_error_fn)(const char *title, + const char *message, + void *userdata); + +/** + * @brief Load configuration from disk (Config::load). + */ +OAKENGINE_API int oakengine_config_load(void); + +/** + * @brief Save configuration to disk (Config::save). + */ +OAKENGINE_API int oakengine_config_save(void); + +/** + * @brief Read a string value (buf/size convention). + * + * @return the string length on success, 0 when the key is missing or empty, + * or a negative OAKENGINE_E_* code on error. + */ +OAKENGINE_API int oakengine_config_get_string(const char *key, char *buf, + int buf_size); + +/** + * @brief Write a string value. + */ +OAKENGINE_API int oakengine_config_set_string(const char *key, + const char *value); + +/** + * @brief Read an integer value. Returns `default_value` when the key is + * missing or not convertible to int. + */ +OAKENGINE_API int64_t oakengine_config_get_int(const char *key, + int64_t default_value); + +/** + * @brief Write an integer value. + */ +OAKENGINE_API int oakengine_config_set_int(const char *key, int64_t value); + +/** + * @brief Register a callback for configuration errors (e.g. disk write + * failures). Passing NULL clears the handler. + */ +OAKENGINE_API int oakengine_config_set_error_handler( + oakengine_config_error_fn fn, void *userdata); + +/** + * @brief Report an error through the registered handler. If no handler is + * set the error is logged and discarded. + */ +OAKENGINE_API int oakengine_config_report_error(const char *title, + const char *message); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_CONFIG_H */ diff --git a/engine/include/oakengine/disk.h b/engine/include/oakengine/disk.h new file mode 100644 index 000000000..d4bf64268 --- /dev/null +++ b/engine/include/oakengine/disk.h @@ -0,0 +1,154 @@ +/*** + + 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_DISK_H +#define OAKENGINE_DISK_H + +#include "export.h" +#include "init.h" +#include "project.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file disk.h + * @brief C ABI for the engine's disk cache singleton (olive::DiskManager) + * + * A thin facade over DiskManager's instance lifecycle, default/custom cache + * path management, cache clearing, settings dialog dispatch and project + * invalidation. The opaque folder handle returned by + * oakengine_disk_get_open_folder() is a borrowed pointer to the engine's + * internal DiskCacheFolder for that path; it must not be freed and becomes + * invalid when the DiskManager instance is destroyed. + * + * Conventions match the other facade families: + * - 0 (OAKENGINE_OK) / negative OAKENGINE_E_* codes. + * - String output uses the buf/size convention. + * - Booleans are int (1/0). + */ + +/** + * @brief Callback invoked when the engine requests the disk cache settings + * dialog for a folder. + * + * `folder_path` is the UTF-8 path of the cache folder. `parent_window` is a + * borrowed pointer to the QWidget that should act as the dialog's parent (may + * be NULL). `userdata` is the value passed to + * oakengine_disk_set_settings_handler(). + */ +typedef void (*oakengine_disk_settings_fn)(const char *folder_path, + void *parent_window, + void *userdata); + +/** + * @brief Create the DiskManager singleton. + * + * Safe to call when the instance already exists (no-op). Returns + * OAKENGINE_OK or OAKENGINE_E_FAILED. + */ +OAKENGINE_API int oakengine_disk_create_instance(void); + +/** + * @brief Destroy the DiskManager singleton. + * + * Safe to call when no instance exists (no-op). Returns OAKENGINE_OK. + */ +OAKENGINE_API int oakengine_disk_destroy_instance(void); + +/** + * @brief Register the handler used to show the disk cache settings dialog. + * + * The engine calls this handler when the user requests the settings dialog. + * Passing NULL clears the handler. Returns OAKENGINE_OK. + */ +OAKENGINE_API int oakengine_disk_set_settings_handler( + oakengine_disk_settings_fn fn, void *userdata); + +/** + * @brief Show the disk cache settings dialog for `path`. + * + * If `path` is NULL or empty, the default cache folder is used. The actual + * dialog is shown by the handler registered with + * oakengine_disk_set_settings_handler(); if no handler is registered the + * request is logged and skipped. Returns OAKENGINE_OK or an error code. + */ +OAKENGINE_API int oakengine_disk_show_settings_dialog(const char *path, + void *parent_window); + +/** + * @brief Show a confirmation dialog before changing the disk cache location. + * + * Returns 1 if the user confirms, 0 otherwise. `parent_window` may be NULL. + */ +OAKENGINE_API int oakengine_disk_show_change_confirmation_dialog( + void *parent_window); + +/** + * @brief Clear the disk cache in `path`. + * + * Returns 1 on success, 0 on failure. The folder is opened if necessary. + */ +OAKENGINE_API int oakengine_disk_clear_cache(const char *path); + +/** + * @brief Get the default cache folder path (buf/size convention). + * + * Returns the string length on success, or a negative OAKENGINE_E_* code when + * no DiskManager instance exists. + */ +OAKENGINE_API int oakengine_disk_get_default_cache_path(char *buf, + int buf_size); + +/** + * @brief Set the default cache folder path. + * + * The default folder's path is updated and will be persisted when the + * DiskManager instance is destroyed. Returns OAKENGINE_OK or an error code. + */ +OAKENGINE_API int oakengine_disk_set_default_cache_path(const char *path); + +/** + * @brief Get or create a borrowed opaque handle to the cache folder for + * `path`. + * + * Returns NULL if no DiskManager instance exists or if `path` is invalid. If + * `path` is NULL or empty, the default cache folder is returned. The returned + * handle is a borrowed pointer whose lifetime follows the DiskManager + * instance; it must not be freed. + */ +OAKENGINE_API void *oakengine_disk_get_open_folder(const char *path); + +/** + * @brief Emit the invalidate_project signal on the DiskManager instance. + * + * This tells consumers of the disk cache that `project` has changed and any + * cached data for it should be discarded. Returns OAKENGINE_OK or an error + * code. + */ +OAKENGINE_API int oakengine_disk_invalidate_project( + OakEngineProject *project); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_DISK_H */ diff --git a/engine/include/oakengine/display.h b/engine/include/oakengine/display.h new file mode 100644 index 000000000..1a58b8e32 --- /dev/null +++ b/engine/include/oakengine/display.h @@ -0,0 +1,186 @@ +/*** + + 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_DISPLAY_H +#define OAKENGINE_DISPLAY_H + +#include "export.h" +#include "init.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file display.h + * @brief C ABI for the GPU display renderer used by viewer/scope widgets + * + * This family wraps the engine's interactive display renderer + * (olive::Renderer and its OpenGLRenderer/DynamicRenderer implementations, + * engine/render/renderer.h) plus the GPU texture (olive::Texture) and the + * CPU frame buffer (olive::Frame) that viewer/scope widgets use to move + * pixels between the CPU and the GPU. + * + * It is distinct from the sequence-rendering facade in oakengine/renderer.h + * (OakEngineRenderer), which pulls finished CPU frames out of the async + * render pipeline. This family drives the *on-screen* paint path instead: + * a widget creates a renderer, initializes it with the widget's GL context, + * uploads/downloads textures, and blits color-managed images each paint. + * + * Conventions (matching the other facade families): + * - All object pointers are opaque. `renderer` is an olive::Renderer*, + * `texture` an olive::Texture*, `frame` an olive::Frame*. + * - `out_texture` / `out_frame` are pointers to caller-owned + * olive::TexturePtr / olive::FramePtr (std::shared_ptr) storage; the + * callee assigns a newly created smart pointer into them, releasing any + * previously held object. This keeps shared-pointer ownership/deleter + * bookkeeping entirely on the engine side. + * - `video_params` is a `const olive::VideoParams*`; `color_job` is a + * `const olive::ColorTransformJob*`. These are passed as opaque pointers + * because they are C++ types; both the caller (app) and the callee + * (engine) are compiled as C++ against the same headers. + * - `gl_context` is a `QOpenGLContext*` or NULL. + * - `parent` is the owning `QObject*` (the display widget); the created + * renderer is a QObject child of it and is destroyed by Qt ownership. + * Do NOT call oakengine_display_renderer_destroy() and then also rely on + * Qt deletion of the same renderer's GPU resources -- destroy() releases + * GPU state, Qt deletion releases the object. + */ + +/* ---- Display renderer lifecycle ---------------------------------------- */ + +/** + * @brief Create a dynamic-backend renderer (olive::DynamicRenderer) for + * `backend_name` and load() it. + * + * @return The renderer (olive::Renderer*), or NULL if the backend library + * could not be loaded (the failed renderer is deleted internally and + * the caller should fall back to + * oakengine_display_renderer_create_opengl()). NULL is also returned + * when the engine was built without dynamic-backend support. + */ +OAKENGINE_API void * +oakengine_display_renderer_create_dynamic(const char *backend_name, + void *parent); + +/** + * @brief Create the built-in OpenGL renderer (olive::OpenGLRenderer). + * + * @return The renderer (olive::Renderer*), never NULL. + */ +OAKENGINE_API void *oakengine_display_renderer_create_opengl(void *parent); + +/** + * @brief Initialize a display renderer and run its post-init step. + * + * If `gl_context` is non-NULL the OpenGL/dynamic path is taken (the renderer + * is initialized against the widget's shared QOpenGLContext); otherwise the + * backend-neutral path (Renderer::init()/post_init()) is used. + * + * @return OAKENGINE_OK on success, OAKENGINE_E_INVALID for a NULL renderer. + */ +OAKENGINE_API int oakengine_display_renderer_init(void *renderer, + void *gl_context); + +/** + * @brief Release a display renderer's GPU resources (Renderer::destroy() + * followed by post_destroy()). The renderer object itself remains owned by + * its Qt parent. + */ +OAKENGINE_API void oakengine_display_renderer_destroy(void *renderer); + +/* ---- Texture creation and pixel transfer -------------------------------- */ + +/** + * @brief Create a GPU texture on `renderer` (Renderer::create_texture()). + * + * @param renderer olive::Renderer*. + * @param video_params const olive::VideoParams* describing the texture. + * @param pixels Initial pixel data, or NULL for an empty texture. + * @param linesize Line stride of `pixels` (ignored when NULL). + * @param out_texture Pointer to an olive::TexturePtr to receive the result. + */ +OAKENGINE_API void +oakengine_display_renderer_create_texture(void *renderer, + const void *video_params, + const void *pixels, int linesize, + void *out_texture); + +/** + * @brief Blit a color-managed image (Renderer::blit_color_managed()). + * + * @param renderer olive::Renderer*. + * @param color_job const olive::ColorTransformJob*. + * @param dst_texture Destination olive::Texture*, or NULL to blit to the + * current output destination. + * @param video_params const olive::VideoParams* for the destination, or NULL + * to use dst_texture's own parameters (in which case + * dst_texture must be non-NULL). + */ +OAKENGINE_API void +oakengine_display_renderer_blit_color_managed(void *renderer, + const void *color_job, + void *dst_texture, + const void *video_params); + +/** + * @brief Upload CPU pixels into a GPU texture (Texture::upload()). + */ +OAKENGINE_API void oakengine_display_texture_upload(void *texture, + void *pixels, int linesize); + +/** + * @brief Download GPU texture pixels into CPU memory (Texture::download()). + */ +OAKENGINE_API void oakengine_display_texture_download(void *texture, + void *pixels, + int linesize); + +/* ---- CPU frame buffer --------------------------------------------------- */ + +/** + * @brief Create an empty CPU frame (olive::Frame::create()). + * + * @param out_frame Pointer to an olive::FramePtr to receive the new frame. + */ +OAKENGINE_API void oakengine_codec_frame_create(void *out_frame); + +/** + * @brief Set a frame's video parameters (Frame::set_video_params()). + * + * @param frame olive::Frame*. + * @param video_params const olive::VideoParams*. + */ +OAKENGINE_API void oakengine_codec_frame_set_video_params(void *frame, + const void + *video_params); + +/** + * @brief Allocate the frame's pixel buffer (Frame::allocate()). + * + * @return 1 on success, 0 on failure or NULL frame. + */ +OAKENGINE_API int oakengine_codec_frame_allocate(void *frame); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_DISPLAY_H */ diff --git a/engine/include/oakengine/encoding.h b/engine/include/oakengine/encoding.h new file mode 100644 index 000000000..1972867a4 --- /dev/null +++ b/engine/include/oakengine/encoding.h @@ -0,0 +1,505 @@ +/*** + + 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_ENCODING_H +#define OAKENGINE_ENCODING_H + +#include + +#include "export.h" +#include "timeline.h" +#include "videoparams.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file encoding.h + * @brief C ABI for the encoding parameter surface (EncodingParams / + * ExportFormat / ExportCodec) + * + * This family exposes everything the application's export dialog (and the + * audio-recording path) needs without touching the engine's C++ classes: + * + * - Container/codec metadata queries (format names/extensions, codec lists + * per format, codec names/flags, supported pixel and sample formats). + * - An opaque OakEngineEncodingParams handle wrapping the engine's + * EncodingParams: full getter/setter surface, preset path/listing and + * preset load/save. + * - oakengine_export_render_with_params(): runs the same synchronous export + * path as oakengine_export_render_ex() (oakengine/exporter.h) using a + * params handle assembled through this family. + * + * Enum int fields carry the engine's own enum values + * (olive::ExportFormat::Format, olive::ExportCodec::Codec, + * olive::VideoParams::Interlacing/ColorRange, olive::PixelFormat::Format, + * olive::core::SampleFormat::Format). Conventions match the other facade + * families: 0 (OAKENGINE_OK) / negative OAKENGINE_E_* codes, buf/size + * strings (return value is the would-be length excluding the NUL), NULL + * handles are no-ops returning the documented failure value. + */ + +/** @brief Opaque encoding-parameters handle (olive::EncodingParams). */ +typedef struct OakEngineEncodingParams OakEngineEncodingParams; + +/** @brief Scaling method values (EncodingParams::VideoScalingMethod). */ +#define OAKENGINE_ENCODING_SCALING_FIT 0 +#define OAKENGINE_ENCODING_SCALING_STRETCH 1 +#define OAKENGINE_ENCODING_SCALING_CROP 2 + +/** + * @brief Container formats (olive::ExportFormat::Format) referenced by name + * in UI code. Only append; the values are serialized in project/preset + * files. The complete list lives in engine/codec/exportformat.h. + */ +#define OAKENGINE_ENCODING_FORMAT_MATROSKA 1 +#define OAKENGINE_ENCODING_FORMAT_MPEG4_VIDEO 2 +#define OAKENGINE_ENCODING_FORMAT_QUICKTIME 4 +#define OAKENGINE_ENCODING_FORMAT_PNG 5 +#define OAKENGINE_ENCODING_FORMAT_WAV 7 +#define OAKENGINE_ENCODING_FORMAT_SRT 13 + +/** + * @brief Codecs (olive::ExportCodec::Codec) referenced by name in UI code. + * Only append; the values are serialized. The complete list lives in + * engine/codec/exportcodec.h. + */ +#define OAKENGINE_ENCODING_CODEC_H264 1 +#define OAKENGINE_ENCODING_CODEC_H264RGB 2 +#define OAKENGINE_ENCODING_CODEC_H265 3 +#define OAKENGINE_ENCODING_CODEC_CINEFORM 7 +#define OAKENGINE_ENCODING_CODEC_AAC 12 +#define OAKENGINE_ENCODING_CODEC_PCM 13 +#define OAKENGINE_ENCODING_CODEC_SRT 17 +#define OAKENGINE_ENCODING_CODEC_AV1 18 + +/** @brief olive::VideoParams::ColorRange values. */ +#define OAKENGINE_ENCODING_COLOR_RANGE_LIMITED 0 +#define OAKENGINE_ENCODING_COLOR_RANGE_FULL 1 + +/** @brief olive::VideoParams::Interlacing values. */ +#define OAKENGINE_ENCODING_INTERLACE_NONE 0 +#define OAKENGINE_ENCODING_INTERLACE_TOP_FIRST 1 +#define OAKENGINE_ENCODING_INTERLACE_BOTTOM_FIRST 2 + +/* ---- Container format / codec metadata ---------------------------------- */ + +/** @brief Number of container formats (olive::ExportFormat::k_format_count). */ +OAKENGINE_API int oakengine_encoding_format_count(void); + +/** @brief Display name of a container format (buf/size); -1 invalid. */ +OAKENGINE_API int oakengine_encoding_format_name(int format, char *buf, + int buf_size); + +/** @brief File extension (no dot) of a container format (buf/size). */ +OAKENGINE_API int oakengine_encoding_format_extension(int format, char *buf, + int buf_size); + +/** + * @brief Number of video codecs a container format supports; -1 when the + * format is invalid. + */ +OAKENGINE_API int oakengine_encoding_format_video_codec_count(int format); + +/** + * @brief The `index`-th video codec of `format` as an + * olive::ExportCodec::Codec value; -1 when out of range. + */ +OAKENGINE_API int oakengine_encoding_format_video_codec_at(int format, + int index); + +/** @brief Audio-codec variant of the two functions above. */ +OAKENGINE_API int oakengine_encoding_format_audio_codec_count(int format); +OAKENGINE_API int oakengine_encoding_format_audio_codec_at(int format, + int index); + +/** @brief Subtitle-codec variant of the two functions above. */ +OAKENGINE_API int oakengine_encoding_format_subtitle_codec_count(int format); +OAKENGINE_API int oakengine_encoding_format_subtitle_codec_at(int format, + int index); + +/** @brief Display name of a codec (buf/size); -1 when invalid. */ +OAKENGINE_API int oakengine_encoding_codec_name(int codec, char *buf, + int buf_size); + +/** @brief 1 when `codec` encodes still images (PNG/TIFF/OpenEXR). */ +OAKENGINE_API int oakengine_encoding_codec_is_still_image(int codec); + +/** @brief 1 when `codec` is lossless (no bit-rate setting applies). */ +OAKENGINE_API int oakengine_encoding_codec_is_lossless(int codec); + +/** + * @brief Number of encoded pixel formats (e.g. "yuv420p") usable with + * `codec` inside `format`; -1 when invalid. + */ +OAKENGINE_API int oakengine_encoding_pix_fmt_count(int format, int codec); + +/** @brief The `index`-th encoded pixel format name (buf/size). */ +OAKENGINE_API int oakengine_encoding_pix_fmt_at(int format, int codec, + int index, char *buf, + int buf_size); + +/** + * @brief Index of `pix_fmt` (e.g. "yuv420p") in `codec`'s supported pixel + * format list; 0 (the codec's preferred format) when absent or `pix_fmt` is + * NULL/empty. + */ +OAKENGINE_API int oakengine_encoding_pix_fmt_index(int codec, + const char *pix_fmt); + +/** + * @brief Number of sample formats usable with `codec` inside `format`; + * -1 when invalid. + */ +OAKENGINE_API int oakengine_encoding_sample_format_count(int format, + int codec); + +/** + * @brief The `index`-th sample format as an olive::core::SampleFormat::Format + * value; -1 when out of range. + */ +OAKENGINE_API int oakengine_encoding_sample_format_at(int format, int codec, + int index); + +/* ---- Image-sequence filename helpers (olive::Encoder statics) ----------- */ + +/** @brief 1 when `filename` contains a "[#####]" digit placeholder. */ +OAKENGINE_API int +oakengine_encoding_filename_contains_digit_placeholder(const char *filename); + +/** + * @brief Digit count of the filename's "[#####]" placeholder; 0 when none. + */ +OAKENGINE_API int +oakengine_encoding_image_sequence_digit_count(const char *filename); + +/** @brief `filename` with the digit placeholder removed (buf/size). */ +OAKENGINE_API int +oakengine_encoding_filename_remove_digit_placeholder(const char *filename, + char *buf, int buf_size); + +/** + * @brief Fit/stretch/crop transform matrix + * (EncodingParams::generate_matrix()). + * + * Writes the 16 floats of the column-major 4x4 matrix to `out16` + * (QMatrix4x4 layout). `method` is OAKENGINE_ENCODING_SCALING_*. + * + * @return OAKENGINE_OK, or OAKENGINE_E_INVALID for bad arguments. + */ +OAKENGINE_API int oakengine_encoding_generate_matrix(int method, int src_width, + int src_height, + int dest_width, + int dest_height, + float out16[16]); + +/* ---- Encoding parameters handle ----------------------------------------- */ + +/** + * @brief Create an empty encoding-parameters handle (all tracks disabled, + * format unset). Destroy with oakengine_encoding_params_destroy(). + */ +OAKENGINE_API OakEngineEncodingParams *oakengine_encoding_params_create(void); + +/** @brief Destroy a handle created by oakengine_encoding_params_create(). */ +OAKENGINE_API void +oakengine_encoding_params_destroy(OakEngineEncodingParams *params); + +/** + * @brief 1 when at least one of video/audio/subtitles is enabled + * (EncodingParams::is_valid()). + */ +OAKENGINE_API int +oakengine_encoding_params_is_valid(const OakEngineEncodingParams *params); + +/** @brief Output filename (buf/size convention). */ +OAKENGINE_API int +oakengine_encoding_params_set_filename(OakEngineEncodingParams *params, + const char *filename); +OAKENGINE_API int +oakengine_encoding_params_filename(const OakEngineEncodingParams *params, + char *buf, int buf_size); + +/** + * @brief Container format as olive::ExportFormat::Format; the getter returns + * -1 when unset. The setter rejects out-of-range values with + * OAKENGINE_E_INVALID. + */ +OAKENGINE_API int +oakengine_encoding_params_set_format(OakEngineEncodingParams *params, + int format); +OAKENGINE_API int +oakengine_encoding_params_format(const OakEngineEncodingParams *params); + +/** + * @brief Enable video with the given parameters and codec + * (EncodingParams::enable_video()). + */ +OAKENGINE_API int +oakengine_encoding_params_enable_video(OakEngineEncodingParams *params, + const oak_video_params *video, + int codec); + +/** + * @brief Enable audio (EncodingParams::enable_audio()). `sample_format` is + * an olive::core::SampleFormat::Format value. + */ +OAKENGINE_API int +oakengine_encoding_params_enable_audio(OakEngineEncodingParams *params, + int sample_rate, + uint64_t channel_layout, + int sample_format, int codec); + +/** @brief Enable embedded subtitles. */ +OAKENGINE_API int +oakengine_encoding_params_enable_subtitles(OakEngineEncodingParams *params, + int codec); + +/** @brief Enable sidecar subtitles with the given sidecar container. */ +OAKENGINE_API int oakengine_encoding_params_enable_sidecar_subtitles( + OakEngineEncodingParams *params, int format, int codec); + +OAKENGINE_API void +oakengine_encoding_params_disable_video(OakEngineEncodingParams *params); +OAKENGINE_API void +oakengine_encoding_params_disable_audio(OakEngineEncodingParams *params); +OAKENGINE_API void +oakengine_encoding_params_disable_subtitles(OakEngineEncodingParams *params); + +OAKENGINE_API int +oakengine_encoding_params_video_enabled(const OakEngineEncodingParams *params); +OAKENGINE_API int +oakengine_encoding_params_video_codec(const OakEngineEncodingParams *params); + +/** + * @brief Read back the video parameters (any field may be NULL); + * OAKENGINE_E_STATE when video is disabled. + */ +OAKENGINE_API int oakengine_encoding_params_get_video_params( + const OakEngineEncodingParams *params, oak_video_params *out); + +OAKENGINE_API int +oakengine_encoding_params_audio_enabled(const OakEngineEncodingParams *params); +OAKENGINE_API int +oakengine_encoding_params_audio_codec(const OakEngineEncodingParams *params); + +/** + * @brief Read back the audio parameters (any field may be NULL); + * OAKENGINE_E_STATE when audio is disabled. + */ +OAKENGINE_API int oakengine_encoding_params_get_audio_params( + const OakEngineEncodingParams *params, int *sample_rate, + uint64_t *channel_layout, int *sample_format); + +OAKENGINE_API int oakengine_encoding_params_subtitles_enabled( + const OakEngineEncodingParams *params); +OAKENGINE_API int oakengine_encoding_params_subtitles_are_sidecar( + const OakEngineEncodingParams *params); +OAKENGINE_API int oakengine_encoding_params_subtitles_sidecar_format( + const OakEngineEncodingParams *params); +OAKENGINE_API int oakengine_encoding_params_subtitles_codec( + const OakEngineEncodingParams *params); + +/** @brief Video bit rates / buffer size (bit/s, bytes). */ +OAKENGINE_API void +oakengine_encoding_params_set_video_bit_rate(OakEngineEncodingParams *params, + int64_t rate); +OAKENGINE_API int64_t +oakengine_encoding_params_video_bit_rate(const OakEngineEncodingParams *params); +OAKENGINE_API void +oakengine_encoding_params_set_video_min_bit_rate( + OakEngineEncodingParams *params, int64_t rate); +OAKENGINE_API int64_t oakengine_encoding_params_video_min_bit_rate( + const OakEngineEncodingParams *params); +OAKENGINE_API void +oakengine_encoding_params_set_video_max_bit_rate( + OakEngineEncodingParams *params, int64_t rate); +OAKENGINE_API int64_t oakengine_encoding_params_video_max_bit_rate( + const OakEngineEncodingParams *params); +OAKENGINE_API void +oakengine_encoding_params_set_video_buffer_size( + OakEngineEncodingParams *params, int64_t size); +OAKENGINE_API int64_t oakengine_encoding_params_video_buffer_size( + const OakEngineEncodingParams *params); + +/** @brief Encoder thread count (0 = auto). */ +OAKENGINE_API void +oakengine_encoding_params_set_video_threads(OakEngineEncodingParams *params, + int threads); +OAKENGINE_API int +oakengine_encoding_params_video_threads(const OakEngineEncodingParams *params); + +/** @brief Audio bit rate (bit/s). */ +OAKENGINE_API void +oakengine_encoding_params_set_audio_bit_rate(OakEngineEncodingParams *params, + int64_t rate); +OAKENGINE_API int64_t +oakengine_encoding_params_audio_bit_rate(const OakEngineEncodingParams *params); + +/** @brief Encoded pixel format name (e.g. "yuv420p"; buf/size getter). */ +OAKENGINE_API int +oakengine_encoding_params_set_video_pix_fmt(OakEngineEncodingParams *params, + const char *pix_fmt); +OAKENGINE_API int +oakengine_encoding_params_video_pix_fmt( + const OakEngineEncodingParams *params, char *buf, int buf_size); + +/** @brief Image-sequence flag (0/1). */ +OAKENGINE_API void +oakengine_encoding_params_set_video_is_image_sequence( + OakEngineEncodingParams *params, int is_image_sequence); +OAKENGINE_API int oakengine_encoding_params_video_is_image_sequence( + const OakEngineEncodingParams *params); + +/** + * @brief Output color transform by OCIO color space name; an empty/NULL + * name selects the reference space (no transform). + */ +OAKENGINE_API int oakengine_encoding_params_set_color_transform( + OakEngineEncodingParams *params, const char *output_name); +OAKENGINE_API int oakengine_encoding_params_color_transform_output( + const OakEngineEncodingParams *params, char *buf, int buf_size); + +/** @brief Export length as rational seconds. */ +OAKENGINE_API void +oakengine_encoding_params_set_export_length(OakEngineEncodingParams *params, + int num, int den); +OAKENGINE_API int +oakengine_encoding_params_get_export_length( + const OakEngineEncodingParams *params, int *num, int *den); + +/** + * @brief Custom export range as rational seconds [in, out). The getter + * returns OAKENGINE_E_NOT_FOUND when no custom range is set. + */ +OAKENGINE_API void +oakengine_encoding_params_set_custom_range(OakEngineEncodingParams *params, + int64_t in_num, int64_t in_den, + int64_t out_num, int64_t out_den); +OAKENGINE_API int +oakengine_encoding_params_has_custom_range( + const OakEngineEncodingParams *params); +OAKENGINE_API int +oakengine_encoding_params_get_custom_range( + const OakEngineEncodingParams *params, int64_t *in_num, int64_t *in_den, + int64_t *out_num, int64_t *out_den); + +/** @brief Scaling method (OAKENGINE_ENCODING_SCALING_*). */ +OAKENGINE_API int +oakengine_encoding_params_set_video_scaling_method( + OakEngineEncodingParams *params, int method); +OAKENGINE_API int oakengine_encoding_params_video_scaling_method( + const OakEngineEncodingParams *params); + +/** + * @brief Encoder-specific video option (key/value strings, e.g. "crf" = + * "18"); mirrors EncodingParams::set_video_option(). The getter returns the + * would-be length (buf/size) or OAKENGINE_E_NOT_FOUND when the key is unset. + */ +OAKENGINE_API int +oakengine_encoding_params_set_video_option(OakEngineEncodingParams *params, + const char *key, const char *value); +OAKENGINE_API int +oakengine_encoding_params_video_option(const OakEngineEncodingParams *params, + const char *key, char *buf, + int buf_size); + +/* ---- Presets ------------------------------------------------------------- */ + +/** @brief Directory where export presets live (buf/size). */ +OAKENGINE_API int oakengine_encoding_preset_path(char *buf, int buf_size); + +/** @brief Number of saved presets. */ +OAKENGINE_API int oakengine_encoding_preset_count(void); + +/** @brief Name of the `index`-th preset (buf/size); -1 when out of range. */ +OAKENGINE_API int oakengine_encoding_preset_name(int index, char *buf, + int buf_size); + +/** + * @brief Load parameters from a preset/XML file (overwrites the handle's + * contents on success). + * + * @return OAKENGINE_OK, OAKENGINE_E_INVALID for bad arguments, or + * OAKENGINE_E_FAILED when the file cannot be read or parsed. + */ +OAKENGINE_API int +oakengine_encoding_params_load_file(OakEngineEncodingParams *params, + const char *path); + +/** @brief Save parameters to a preset/XML file (same return convention). */ +OAKENGINE_API int +oakengine_encoding_params_save_file(const OakEngineEncodingParams *params, + const char *path); + +/* ---- Export execution / per-sequence last-used --------------------------- */ + +/** + * @brief Run a synchronous offline export with a params handle assembled + * through this family. + * + * Same blocking/progress/cancel semantics as oakengine_export_render_ex() + * (oakengine/exporter.h): progress via + * oakengine_export_set_progress_callback(), cancellation via + * oakengine_export_cancel(), failure reason via + * oakengine_export_last_error(). The output filename and image-sequence + * template come from the handle itself. + * + * @return OAKENGINE_OK / OAKENGINE_E_INVALID / OAKENGINE_E_STATE / + * OAKENGINE_E_FAILED / OAKENGINE_E_CANCELLED. + */ +OAKENGINE_API int +oakengine_export_render_with_params(OakEngineSequence *seq, + const OakEngineEncodingParams *params); + +/** + * @brief Copy of the sequence's last-used encoding parameters + * (ViewerOutput::get_last_used_encoding_params()), or NULL when none is + * valid. Caller destroys with oakengine_encoding_params_destroy(). + */ +OAKENGINE_API OakEngineEncodingParams * +oakengine_encoding_params_get_last_used(OakEngineSequence *seq); + +/** + * @brief Store `params` as the sequence's last-used encoding parameters + * (ViewerOutput::set_last_used_encoding_params()); NULL is a no-op. + */ +OAKENGINE_API void oakengine_encoding_params_set_last_used( + OakEngineSequence *seq, const OakEngineEncodingParams *params); + +/** + * @brief Start audio recording to the file described by `params` + * (AudioManager::start_recording(); audio must be enabled on the handle). + * + * @return OAKENGINE_OK on success; OAKENGINE_E_INVALID for bad arguments; + * OAKENGINE_E_STATE when the audio manager is not running; + * OAKENGINE_E_FAILED otherwise (a human-readable reason is written to + * `errbuf`/`errbuf_size` when given). + */ +OAKENGINE_API int +oakengine_encoding_start_audio_recording(const OakEngineEncodingParams *params, + char *errbuf, int errbuf_size); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_ENCODING_H */ diff --git a/engine/include/oakengine/events.h b/engine/include/oakengine/events.h new file mode 100644 index 000000000..2154b1ba3 --- /dev/null +++ b/engine/include/oakengine/events.h @@ -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 . + +***/ + +#ifndef OAKENGINE_EVENTS_H +#define OAKENGINE_EVENTS_H + +#include + +#include "export.h" +#include "init.h" +#include "project.h" +#include "timeline.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file events.h + * @brief C ABI for engine change notifications (the signal/slot replacement) + * + * The engine's C++ API notifies observers through Qt signals (Project:: + * modified_changed, Folder::begin_insert_item, Track::block_added, the + * sequence's track/marker/workarea notifications, ...). This family exposes + * the same notifications to C consumers as a subscription/callback + * mechanism, so the application never needs to connect() to an engine + * QObject directly. + * + * Usage: + * + * int64_t sub = oakengine_event_subscribe(handle, OAKENGINE_EVENT_..., fn, + * userdata); + * ... + * oakengine_event_unsubscribe(sub); + * + * `handle` is a borrowed facade handle whose static type depends on the + * event family (see the table below); a mismatch or NULL handle fails with + * 0 (an invalid subscription id). Subscribing the same (handle, event) + * twice is allowed and returns two independent subscription ids. + * + * Thread semantics: callbacks are invoked SYNCHRONOUSLY on the thread that + * emits the change (the equivalent of Qt::DirectConnection) before the + * engine's own emission returns, exactly like the C++ connections they + * replace. The callback runs under whatever locks the engine holds at the + * emission site; it must not call back into editing primitives that mutate + * the same object. All engine objects live on the GUI thread, so callbacks + * normally fire there. + * + * Lifetime: the registry drops the subscription automatically when the + * observed engine object is destroyed, so a stale subscription id is never + * a use-after-free; oakengine_event_unsubscribe() on an id whose object + * died is a harmless no-op returning OAKENGINE_E_NOT_FOUND. The inverse is + * NOT tracked: `userdata` ownership stays with the subscriber, which must + * unsubscribe (or tolerate callbacks) until its own teardown. + * + * Event payloads use POD fields only. Timestamps are frame numbers in the + * owning sequence's frame-rate timebase (same convention as timeline.h); + * `handle`/`source` are borrowed pointers the callee may use during the + * callback only. + */ + +/** + * @brief Event ids for oakengine_event_subscribe(). + * + * handle column: the facade handle to pass for that event. + * payload column: oakengine_event field contents on delivery. + */ +#define OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED 1 /**< handle: OakEngineProject*. a = new modified flag (0/1). */ +#define OAKENGINE_EVENT_PROJECT_NAME_CHANGED 2 /**< handle: OakEngineProject*. no payload. */ + +#define OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM 10 /**< handle: OakEngineNode* (a folder). handle field = child OakEngineNode*, a = insertion index. */ +#define OAKENGINE_EVENT_FOLDER_END_INSERT_ITEM 11 /**< handle: OakEngineNode* (a folder). no payload. */ +#define OAKENGINE_EVENT_FOLDER_BEGIN_REMOVE_ITEM 12 /**< handle: OakEngineNode* (a folder). handle field = child OakEngineNode*, a = child index. */ +#define OAKENGINE_EVENT_FOLDER_END_REMOVE_ITEM 13 /**< handle: OakEngineNode* (a folder). no payload. */ + +#define OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED 20 /**< handle: OakEngineSequence*. handle field = OakEngineTrack*, a = track type (OAKENGINE_TRACK_TYPE_*). */ +#define OAKENGINE_EVENT_SEQUENCE_TRACK_REMOVED 21 /**< handle: OakEngineSequence*. handle field = OakEngineTrack*, a = track type. */ +#define OAKENGINE_EVENT_SEQUENCE_TRACK_LIST_CHANGED 22 /**< handle: OakEngineSequence*. a = track type. Fired on TrackList::track_list_changed (order/label-affecting changes). */ +#define OAKENGINE_EVENT_SEQUENCE_TRACK_HEIGHT_CHANGED 23 /**< handle: OakEngineSequence*. handle field = OakEngineTrack*, a = track type, b = new height in PIXELS (TrackList::track_height_changed). */ +#define OAKENGINE_EVENT_SEQUENCE_SUBTITLES_CHANGED 24 /**< handle: OakEngineSequence*. a/b = changed range in/out (ts). */ + +#define OAKENGINE_EVENT_TRACK_BLOCK_ADDED 30 /**< handle: OakEngineTrack*. handle field = OakEngineBlock*, a = block in-point (ts), b = block out-point (ts). */ +#define OAKENGINE_EVENT_TRACK_BLOCK_REMOVED 31 /**< handle: OakEngineTrack*. handle field = OakEngineBlock*, a = in (ts), b = out (ts) at removal time. */ +#define OAKENGINE_EVENT_TRACK_INDEX_CHANGED 32 /**< handle: OakEngineTrack*. a = old index, b = new index. */ +#define OAKENGINE_EVENT_TRACK_HEIGHT_CHANGED 33 /**< handle: OakEngineTrack*. a = int64 bit-cast of the new height (double, internal units; memcpy to decode). */ +#define OAKENGINE_EVENT_TRACK_BLOCKS_REFRESHED 34 /**< handle: OakEngineTrack*. no payload (Track::blocks_refreshed). */ +#define OAKENGINE_EVENT_TRACK_MUTED_CHANGED 35 /**< handle: OakEngineTrack*. a = muted 0/1. */ + +#define OAKENGINE_EVENT_BLOCK_ENABLED_CHANGED 36 /**< handle: OakEngineBlock*. no payload (Block::enabled_changed; re-read via oakengine_block_is_enabled). */ +#define OAKENGINE_EVENT_BLOCK_PREVIEW_CHANGED 37 /**< handle: OakEngineBlock*. no payload (Block::preview_changed). */ + +#define OAKENGINE_EVENT_SEQUENCE_MARKER_ADDED 40 /**< handle: OakEngineSequence*. a = marker in-point (ts). */ +#define OAKENGINE_EVENT_SEQUENCE_MARKER_REMOVED 41 /**< handle: OakEngineSequence*. a = marker in-point (ts). */ +#define OAKENGINE_EVENT_SEQUENCE_MARKER_MODIFIED 42 /**< handle: OakEngineSequence*. a = marker in-point (ts). */ + +#define OAKENGINE_EVENT_SEQUENCE_WORKAREA_RANGE_CHANGED 50 /**< handle: OakEngineSequence*. a = in (ts), b = out (ts). */ +#define OAKENGINE_EVENT_SEQUENCE_WORKAREA_ENABLED_CHANGED 51 /**< handle: OakEngineSequence*. a = enabled flag (0/1). */ + +#define OAKENGINE_EVENT_COLOR_MANAGER_CONFIG_CHANGED 60 /**< handle: OakEngineColorManager*. no payload. Fired when the OCIO config changes (ColorManager::config_changed). */ +#define OAKENGINE_EVENT_COLOR_MANAGER_REFERENCE_SPACE_CHANGED 61 /**< handle: OakEngineColorManager*. no payload (ColorManager::reference_space_changed). */ + +/* Node family (handle: OakEngineNode*). `s` carries the input id where + * noted; frame timestamps use the same timebase as oakengine_node_frame_ + * time_base() (the project's first sequence's frame rate). */ +#define OAKENGINE_EVENT_NODE_LABEL_CHANGED 70 /**< s = new label. */ +#define OAKENGINE_EVENT_NODE_INPUT_VALUE_CHANGED 71 /**< s = input id, a = element, b = range in (ts), c = range out (ts). */ +#define OAKENGINE_EVENT_NODE_INPUT_CONNECTED 72 /**< handle = connected output OakEngineNode*, s = input id, a = element. */ +#define OAKENGINE_EVENT_NODE_INPUT_DISCONNECTED 73 /**< handle = former output OakEngineNode*, s = input id, a = element. */ +#define OAKENGINE_EVENT_NODE_INPUT_FLAGS_CHANGED 74 /**< s = input id, a = new flags (OAKENGINE_NODE_INPUT_FLAG_*). */ +#define OAKENGINE_EVENT_NODE_INPUT_PROPERTY_CHANGED 75 /**< s = input id (property key/value intentionally omitted; re-read through the node family getters). */ +#define OAKENGINE_EVENT_NODE_INPUT_DATA_TYPE_CHANGED 76 /**< s = input id, a = new oak_node_value_type. */ +#define OAKENGINE_EVENT_NODE_INPUT_ARRAY_SIZE_CHANGED 77 /**< s = input id, a = old size, b = new size. */ +#define OAKENGINE_EVENT_NODE_KEYFRAME_ENABLE_CHANGED 78 /**< s = input id, a = element, b = enabled (0/1). */ +#define OAKENGINE_EVENT_NODE_KEYFRAME_ADDED 79 /**< handle = OakEngineKeyframe*, s = input id, a = element, b = track. */ +#define OAKENGINE_EVENT_NODE_KEYFRAME_REMOVED 80 /**< handle = OakEngineKeyframe* (about to die; use the s/a/b fields, do not dereference), s = input id, a = element, b = track. */ +#define OAKENGINE_EVENT_NODE_KEYFRAME_TIME_CHANGED 81 /**< handle = OakEngineKeyframe*. */ +#define OAKENGINE_EVENT_NODE_KEYFRAME_TYPE_CHANGED 82 /**< handle = OakEngineKeyframe*. */ +#define OAKENGINE_EVENT_NODE_KEYFRAME_VALUE_CHANGED 83 /**< handle = OakEngineKeyframe*. */ +#define OAKENGINE_EVENT_NODE_NODE_ADDED_TO_CONTEXT 84 /**< handle = OakEngineNode* added to this context. */ +#define OAKENGINE_EVENT_NODE_NODE_REMOVED_FROM_CONTEXT 85 /**< handle = OakEngineNode* removed from this context. */ +#define OAKENGINE_EVENT_NODE_MESSAGE_COUNT_CHANGED 86 /**< no payload. */ + +/* Group family (handle: OakEngineNode*, must be a group). For 87/88 the + * handle field carries the passthrough's inner node, `s` its input id and + * `a` its element. */ +#define OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_ADDED 87 /**< handle = inner OakEngineNode*, s = input id, a = element. */ +#define OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_REMOVED 88 /**< handle = inner OakEngineNode*, s = input id, a = element. */ +#define OAKENGINE_EVENT_GROUP_OUTPUT_PASSTHROUGH_CHANGED 89 /**< handle = new output OakEngineNode*. */ + +/** + * handle = OakEngineNode* whose position in this context changed; `a`/`b` + * carry the new x/y scene coordinates as int64 bit-casts of double (use + * memcpy to decode). */ +#define OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED 90 + +#define OAKENGINE_EVENT_NODE_LINKS_CHANGED 91 /**< no payload (Node::links_changed). */ +#define OAKENGINE_EVENT_NODE_COLOR_CHANGED 92 /**< no payload (Node::color_changed). */ +#define OAKENGINE_EVENT_NODE_INPUT_ADDED 93 /**< s = input id (Node::input_added). */ +#define OAKENGINE_EVENT_NODE_INPUT_REMOVED 94 /**< s = input id (Node::input_removed). */ +#define OAKENGINE_EVENT_NODE_REMOVED_FROM_GRAPH 95 /**< handle = project OakEngineNode* (Node::removed_from_graph). */ + +/* Viewer family (handle: OakEngineNode*, must be a viewer -- validate with + * oakengine_viewer_from_node()). Rational payloads (seconds) are carried + * as a = numerator, b = denominator. */ +#define OAKENGINE_EVENT_VIEWER_LENGTH_CHANGED 100 /**< a/b = new length. */ +#define OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED 101 /**< a/b = new playhead. */ +#define OAKENGINE_EVENT_VIEWER_FRAME_RATE_CHANGED 102 /**< a/b = new frame rate (NOT flipped). */ +#define OAKENGINE_EVENT_VIEWER_SIZE_CHANGED 103 /**< a = width, b = height. */ +#define OAKENGINE_EVENT_VIEWER_PIXEL_ASPECT_CHANGED 104 /**< a/b = new pixel aspect. */ +#define OAKENGINE_EVENT_VIEWER_INTERLACING_CHANGED 105 /**< a = olive::VideoParams::Interlacing. */ +#define OAKENGINE_EVENT_VIEWER_VIDEO_PARAMS_CHANGED 106 /**< no payload. */ +#define OAKENGINE_EVENT_VIEWER_AUDIO_PARAMS_CHANGED 107 /**< no payload. */ +#define OAKENGINE_EVENT_VIEWER_TEXTURE_INPUT_CHANGED 108 /**< no payload. */ +#define OAKENGINE_EVENT_VIEWER_SAMPLE_RATE_CHANGED 109 /**< a = new sample rate. */ +#define OAKENGINE_EVENT_VIEWER_CONNECTED_WAVEFORM_CHANGED 110 /**< no payload. */ + +/* Marker list family (handle: OakEngineMarkerList*, from + * oakengine_viewer_get_marker_list()). handle field = the OakEngineMarker* + * (for REMOVED it is about to die; do not dereference). */ +#define OAKENGINE_EVENT_MARKER_LIST_MARKER_ADDED 111 +#define OAKENGINE_EVENT_MARKER_LIST_MARKER_REMOVED 112 +#define OAKENGINE_EVENT_MARKER_LIST_MARKER_MODIFIED 113 + +/* Workarea family (handle: OakEngineWorkarea*, borrowed from + * oakengine_viewer_get_workarea_handle() or owned from + * oakengine_workarea_create()). */ +#define OAKENGINE_EVENT_WORKAREA_RANGE_CHANGED 114 /**< no payload; re-read via oakengine_workarea_get(). */ +#define OAKENGINE_EVENT_WORKAREA_ENABLED_CHANGED 115 /**< a = enabled 0/1. */ + +/* Task manager family (handle: oakengine_task_manager_handle(), see + * oakengine/task.h). The handle field carries the OakEngineTask* (for + * REMOVED it is about to die; do not dereference). */ +#define OAKENGINE_EVENT_TASK_MANAGER_TASK_ADDED 120 /**< handle = OakEngineTask*, s = task title. */ +#define OAKENGINE_EVENT_TASK_MANAGER_TASK_REMOVED 121 /**< handle = OakEngineTask* (about to die; do not dereference). */ +#define OAKENGINE_EVENT_TASK_MANAGER_TASK_FAILED 122 /**< handle = OakEngineTask*. */ +#define OAKENGINE_EVENT_TASK_MANAGER_LIST_CHANGED 123 /**< no payload. */ + +/* Task family (handle: OakEngineTask*, see oakengine/task.h). Delivered + * synchronously on the thread the task runs on. */ +#define OAKENGINE_EVENT_TASK_STARTED 125 /**< a = start time (msecs since epoch). */ +#define OAKENGINE_EVENT_TASK_PROGRESS 126 /**< a = int64 bit-cast of the progress double 0..1 (memcpy to decode). */ +#define OAKENGINE_EVENT_TASK_FINISHED 127 /**< a = succeeded 0/1. */ + +/* Undo stack family (handle: oakengine_undo_handle(), see + * oakengine/undo.h). Fires after every stack mutation (push/undo/redo/ + * jump/clear); re-read the command list through the oakengine_undo_* + * accessors. */ +#define OAKENGINE_EVENT_UNDO_INDEX_CHANGED 130 /**< a = new index (done-command count). */ + +/* AudioManager family (handle: oakengine_audio_manager_handle(), see + * oakengine/audio.h). Fired when the output device or format changes. */ +#define OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_PARAMS_CHANGED 140 /**< no payload. */ +#define OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_NOTIFY 141 /**< no payload; emitted after each notify interval of audio has been consumed. */ + +/* ---- Playback cache / frame cache (B9c) ----------------------------------- */ +#define OAKENGINE_EVENT_PLAYBACK_CACHE_INVALIDATED 141 +#define OAKENGINE_EVENT_PLAYBACK_CACHE_VALIDATED 142 +#define OAKENGINE_EVENT_FRAME_CACHE_INVALIDATED 143 + +/** + * @brief POD event payload delivered to oakengine_event_fn. + */ +typedef struct oakengine_event { + int32_t id; /**< Event id (OAKENGINE_EVENT_*). */ + int32_t reserved; /**< Alignment padding; 0. */ + int64_t a; /**< Event-specific integer payload (see the event table). */ + int64_t b; /**< Event-specific second integer payload. */ + int64_t c; /**< Event-specific third integer payload. */ + void *source; /**< The subscribed handle the event was delivered for (borrowed). */ + void *handle; /**< Related object, event-specific (borrowed; NULL when none). */ + const char *s; /**< Event-specific string payload (valid only during the callback; NULL when none). */ +} oakengine_event; + +/** + * @brief Change-notification callback. Invoked synchronously on the + * emitting thread; `event` is valid only for the duration of the call. + */ +typedef void (*oakengine_event_fn)(const oakengine_event *event, + void *userdata); + +/** + * @brief Subscribe to `event_id` on `handle` (an OakEngineProject*, + * OakEngineSequence*, OakEngineTrack* or OakEngineNode* per the event + * table) and return a subscription id (> 0). + * + * Returns 0 on failure: NULL handle/callback, unknown event id, or a + * handle whose engine object does not match the event's family. The + * callback starts firing with the next matching change; there is no + * replay of past state. + */ +OAKENGINE_API int64_t oakengine_event_subscribe(void *handle, int32_t event_id, + oakengine_event_fn fn, + void *userdata); + +/** + * @brief Cancel a subscription. OAKENGINE_OK on success, + * OAKENGINE_E_INVALID for `id` <= 0, OAKENGINE_E_NOT_FOUND for an id that + * was never registered or whose engine object has since been destroyed. + */ +OAKENGINE_API int oakengine_event_unsubscribe(int64_t id); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_EVENTS_H */ diff --git a/engine/include/oakengine/footage.h b/engine/include/oakengine/footage.h index ed93f7981..ca4deb8ac 100644 --- a/engine/include/oakengine/footage.h +++ b/engine/include/oakengine/footage.h @@ -117,6 +117,24 @@ typedef struct oak_footage_audio_info { int time_base_den; /**< Seconds per time-base unit (denominator). */ } oak_footage_audio_info; +/** + * @brief POD proxy generation parameters (olive::ProxyManager::ProxyParams). + * + * divider: source resolution divider (1 = use absolute width/height, + * 2/4/8 = fraction of the source resolution). extension/preset are the + * ffmpeg output container and encoder preset (e.g. "mp4"/"veryfast"). + */ +typedef struct oak_proxy_params { + int width; + int height; + int divider; + int version; + int crf; + int include_audio; /**< 1/0. */ + char extension[32]; + char preset[32]; +} oak_proxy_params; + /** * @brief Probe a media file (decoder, streams, durations, color tags). * @@ -417,6 +435,69 @@ oakengine_footage_colorspace_count(const OakEngineFootage *self); OAKENGINE_API int oakengine_footage_colorspace_at( const OakEngineFootage *self, int index, char *buf, int buf_size); +/* ---- Footage extras ------------------------------------------------------- */ + +/** @brief Filename of the imported footage (buf/size). Returns + * OAKENGINE_E_INVALID on NULL. */ +OAKENGINE_API int oakengine_footage_get_filename(const OakEngineFootage *self, + char *buf, int buf_size); + +/** @brief Get the (track_type, stream_index) for the real stream at + * `stream_index_in_footage` (which iterates all streams regardless of type). + * Returns OAKENGINE_OK or OAKENGINE_E_NOT_FOUND. */ +OAKENGINE_API int oakengine_footage_get_stream_reference( + const OakEngineFootage *self, int stream_index_in_footage, + int *out_track_type, int *out_stream_index); + +/** @brief Human-readable description of a video stream (buf/size). + * Returns OAKENGINE_E_NOT_FOUND for an out-of-range index. */ +OAKENGINE_API int oakengine_footage_describe_video_stream( + const OakEngineFootage *self, int video_stream_index, char *buf, + int buf_size); + +/** @brief Human-readable description of an audio stream (buf/size). + * Returns OAKENGINE_E_NOT_FOUND for an out-of-range index. */ +OAKENGINE_API int oakengine_footage_describe_audio_stream( + const OakEngineFootage *self, int audio_stream_index, char *buf, + int buf_size); + +/** @brief Human-readable name of a stream type + * (OAKENGINE_TRACK_TYPE_* -> translated name). buf/size convention. */ +OAKENGINE_API int oakengine_footage_stream_type_name(int track_type, char *buf, + int buf_size); + +/** @brief 1 if the footage has custom proxy parameters, 0 otherwise. */ +OAKENGINE_API int oakengine_footage_has_custom_proxy_params( + const OakEngineFootage *self); + +/** @brief Fill `out` with the effective proxy parameters + * (custom if set, otherwise the application defaults). */ +OAKENGINE_API int oakengine_footage_get_effective_proxy_params( + const OakEngineFootage *self, oak_proxy_params *out); + +/** @brief Set custom proxy parameters (not undoable). */ +OAKENGINE_API int oakengine_footage_set_custom_proxy_params( + OakEngineFootage *self, const oak_proxy_params *params); + +/** @brief Clear custom proxy parameters, reverting to defaults. */ +OAKENGINE_API int oakengine_footage_clear_custom_proxy_params( + OakEngineFootage *self); + +/** @brief Generate a proxy with the given parameters (synchronous). + * `path` is the proxy file path, `state` the proxy state (0=missing, + * 1=generating, 2=ready, 3=failed), `stream_index` the video stream index, + * `enabled` 1/0 to enable proxy, `version` the preset version. */ +OAKENGINE_API int oakengine_footage_set_proxy(OakEngineFootage *self, + const char *path, int state, + int stream_index, int enabled, + int version); + +/** @brief Delete the proxy file and reset state. */ +OAKENGINE_API int oakengine_footage_clear_proxy(OakEngineFootage *self); + +/** @brief Invalidate the footage (force re-probe on next use). */ +OAKENGINE_API int oakengine_footage_invalidate(OakEngineFootage *self); + #ifdef __cplusplus } #endif diff --git a/engine/include/oakengine/gizmo.h b/engine/include/oakengine/gizmo.h new file mode 100644 index 000000000..de0deea45 --- /dev/null +++ b/engine/include/oakengine/gizmo.h @@ -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 . + +***/ + +#ifndef OAKENGINE_GIZMO_H +#define OAKENGINE_GIZMO_H + +#include + +#include "export.h" +#include "node.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file gizmo.h + * @brief C ABI for gizmo data exchange (text gizmo POD + draggable helpers) + * + * TextGizmo has been POD-ified: the app retrieves a flat snapshot of the + * text v3 node's gizmo state through a single C call instead of holding a + * C++ TextGizmo pointer. The 4 Qt signals (activated/deactivated/ + * rect_changed/vertical_alignment_changed) are replaced by the existing + * OAKENGINE_EVENT_NODE_INPUT_VALUE_CHANGED on the text v3 node. + * + * DraggableGizmo's drag lifecycle (start/move/end) is exposed as thin C + * wrappers so the app can drive dragging without importing engine C++ symbols. + */ + +/** + * @brief Flat snapshot of a text v3 node's gizmo state. + * + * Retrieved via oakengine_text_gizmo_get(). The HTML content is accessed + * separately through oakengine_text_gizmo_get_html() because it is a + * variable-length string. + */ +typedef struct oakengine_text_gizmo { + double rect_x; /**< Bounding rect left */ + double rect_y; /**< Bounding rect top */ + double rect_w; /**< Bounding rect width */ + double rect_h; /**< Bounding rect height */ + int vertical_alignment; /**< 0 = AlignTop, 1 = AlignBottom, 2 = AlignVCenter */ +} oakengine_text_gizmo; + +/** + * @brief Retrieve the text gizmo POD from a TextGeneratorV3 node. + * + * @param node The text v3 node (OakEngineNode*). Must be a TextGeneratorV3 + * (checked at runtime; returns OAKENGINE_E_INVALID otherwise). + * @param time_num Numerator of the rational time at which to evaluate. + * @param time_den Denominator of the rational time. + * @param out Output struct filled on success. + * @return OAKENGINE_OK on success, OAKENGINE_E_INVALID if node is not a + * TextGeneratorV3 or out is NULL. + */ +OAKENGINE_API int oakengine_text_gizmo_get(OakEngineNode *node, + int64_t time_num, int64_t time_den, oakengine_text_gizmo *out); + +/** + * @brief Retrieve the text gizmo's HTML content as a string. + * + * buf/size convention: pass NULL/0 to get the required length (including + * NUL terminator). On success returns the number of bytes written (excluding + * NUL). Requires a valid TextGeneratorV3 node. + */ +OAKENGINE_API int oakengine_text_gizmo_get_html(OakEngineNode *node, + int64_t time_num, int64_t time_den, char *buf, int buf_size); + +/** + * @brief Update the HTML content of a text v3 node's text input (undoable). + * + * Equivalent to the old TextGizmo::update_input_html(). + */ +OAKENGINE_API int oakengine_text_gizmo_update_html(OakEngineNode *node, + const char *html, int64_t time_num, int64_t time_den); + +/** + * @brief Set the vertical alignment of a text v3 node (undoable). + * + * `alignment`: 0 = AlignTop, 1 = AlignBottom, 2 = AlignVCenter. + * Equivalent to the old TextGizmo::set_vertical_alignment(). + */ +OAKENGINE_API int oakengine_text_gizmo_set_vertical_alignment( + OakEngineNode *node, int alignment); + +/** + * @brief Notify that a text gizmo has been activated (emits the equivalent of + * the old TextGizmo::activated signal via event mechanism). + * + * Currently a no-op since activation events are app-internal; kept for + * API completeness. + */ +OAKENGINE_API int oakengine_text_gizmo_activated(OakEngineNode *node); + +/** + * @brief Notify that a text gizmo has been deactivated. + */ +OAKENGINE_API int oakengine_text_gizmo_deactivated(OakEngineNode *node); + +/** + * @brief Activate/Deactivate the text gizmo on a text v3 node. + * + * These replace the old TextGizmo::activated()/deactivated() signal emissions. + * The app calls these to notify the engine that the text editor opened/closed. + */ + +/** + * @brief Get the DragValueBehavior of a gizmo node. + * + * Returns: 0 = k_absolute, 1 = k_delta_from_previous, 2 = k_delta_from_start. + * Returns OAKENGINE_E_INVALID if the gizmo is not a DraggableGizmo. + */ +OAKENGINE_API int oakengine_gizmo_get_drag_value_behavior(void *gizmo); + +/** + * @brief Start a drag on a DraggableGizmo. + * + * Wraps DraggableGizmo::drag_start(). The gizmo's internal NodeInputDraggers + * are started at the given time. `row` is a pointer to a NodeValueRow + * (populated e.g. by oakengine_traverse_generate_row); pass NULL for an + * empty row. + */ +OAKENGINE_API int oakengine_gizmo_drag_start(void *gizmo, + void *row, double abs_x, double abs_y, int64_t time_num, + int64_t time_den); + +/** + * @brief Move a drag (emits handle_movement signal on the gizmo). + */ +OAKENGINE_API int oakengine_gizmo_drag_move(void *gizmo, + double x, double y, int qt_keyboard_modifiers); + +/** + * @brief End a drag and push an undoable command. + * + * @param gizmo The DraggableGizmo pointer (void* for C ABI). + * @param command A MultiUndoCommand* (void*) to append undo entries to. + * Pass NULL to create a standalone command. + */ +OAKENGINE_API int oakengine_gizmo_drag_end(void *gizmo, void *command); + +#ifdef __cplusplus +} +#endif + +#endif // OAKENGINE_GIZMO_H diff --git a/engine/include/oakengine/lut.h b/engine/include/oakengine/lut.h new file mode 100644 index 000000000..354d26260 --- /dev/null +++ b/engine/include/oakengine/lut.h @@ -0,0 +1,87 @@ +/*** + + 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_LUT_H +#define OAKENGINE_LUT_H + +#include "export.h" +#include "init.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file lut.h + * @brief C ABI for the global LUT file library (olive::LUTLibrary) + * + * A thin facade over the user-configurable list of LUT directories and the + * supported LUT files discovered under them. The library state is kept in the + * application config ("LUTLibraryPaths"); this facade only exposes the + * directory/file list queries and the directory replacement primitive. + * + * Conventions match the other facade families: + * - 0 (OAKENGINE_OK) / negative OAKENGINE_E_* codes. + * - String output uses the buf/size convention. + * - Count queries return a non-negative integer, or a negative error code. + */ + +/** + * @brief Number of directories currently in the LUT library. + */ +OAKENGINE_API int oakengine_lut_directory_count(void); + +/** + * @brief Get the directory path at `index` (buf/size convention). + * + * Returns the string length on success, or a negative OAKENGINE_E_* code when + * `index` is out of range. + */ +OAKENGINE_API int oakengine_lut_directory_at(int index, char *buf, + int buf_size); + +/** + * @brief Number of supported LUT files found under the library directories. + */ +OAKENGINE_API int oakengine_lut_file_count(void); + +/** + * @brief Get the full path of the LUT file at `index` (buf/size convention). + * + * Files are listed in the order they are discovered; files in earlier + * directories come first. Returns the string length on success, or a negative + * OAKENGINE_E_* code when `index` is out of range. + */ +OAKENGINE_API int oakengine_lut_file_at(int index, char *buf, int buf_size); + +/** + * @brief Replace the LUT library directories and persist them to config. + * + * `dirs` is an array of `count` NUL-terminated UTF-8 directory paths. Passing + * `count == 0` clears the library. Returns OAKENGINE_OK or an error code. + */ +OAKENGINE_API int oakengine_lut_set_directories(const char *const *dirs, + int count); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_LUT_H */ diff --git a/engine/include/oakengine/node.h b/engine/include/oakengine/node.h index 598509891..b902d55e1 100644 --- a/engine/include/oakengine/node.h +++ b/engine/include/oakengine/node.h @@ -26,6 +26,7 @@ #include "export.h" #include "init.h" #include "project.h" +#include "videoparams.h" #ifdef __cplusplus extern "C" { @@ -74,7 +75,16 @@ typedef enum oak_node_value_type { OAK_NODE_VALUE_VEC3, /**< f[0..2] (olive k_vec3) */ OAK_NODE_VALUE_VEC4, /**< f[0..3] (olive k_vec4) */ OAK_NODE_VALUE_COMBO, /**< num = selected index (olive k_combo) */ - OAK_NODE_VALUE_STRING /**< k_file; string APIs only, never in the POD */ + OAK_NODE_VALUE_STRING, /**< k_file; string APIs only, never in the POD */ + OAK_NODE_VALUE_TEXT, /**< k_text; string APIs only */ + OAK_NODE_VALUE_FONT, /**< k_font; string APIs only */ + OAK_NODE_VALUE_STR_COMBO, /**< k_str_combo; string APIs only */ + OAK_NODE_VALUE_BINARY, /**< k_binary; binary data, no POD representation */ + OAK_NODE_VALUE_BEZIER, /**< k_bezier; bezier control point */ + OAK_NODE_VALUE_TEXTURE, /**< k_texture; texture */ + OAK_NODE_VALUE_SAMPLES, /**< k_samples; audio samples */ + OAK_NODE_VALUE_VIDEO_PARAMS, /**< k_video_params; video parameters */ + OAK_NODE_VALUE_AUDIO_PARAMS /**< k_audio_params; audio parameters */ } oak_node_value_type; /** @@ -93,6 +103,16 @@ typedef struct oak_node_value { */ typedef struct OakEngineNode OakEngineNode; +/** + * @brief Opaque keyframe handle (borrowed from the input's track list). + */ +typedef struct OakEngineKeyframe OakEngineKeyframe; + +/** + * @brief Opaque input-dragger handle (created by oakengine_dragger_create()). + */ +typedef struct OakEngineNodeDragger OakEngineNodeDragger; + /** * @brief Human-readable reason for the last failed node call on this * thread (buf/size convention). @@ -112,6 +132,44 @@ OAKENGINE_API int oakengine_project_node_count(const OakEngineProject *self); OAKENGINE_API OakEngineNode * oakengine_project_node_at(const OakEngineProject *self, int index); +/* ---- Node factory --------------------------------------------------------- */ + +/** + * @brief Number of registered node types (wraps NodeFactory::get_library().size()). + */ +OAKENGINE_API int oakengine_node_factory_id_count(void); + +/** + * @brief Create a node of `type_id` WITHOUT adding it to any project. + * The caller owns the returned handle and must add it to a project (e.g. + * via oakengine_project_add_node or a custom undo command) before the + * engine can manage its lifecycle. + * + * Returns NULL when `type_id` is unknown. + */ +OAKENGINE_API OakEngineNode * +oakengine_node_factory_create_from_id(const char *type_id); + +/** + * @brief The display name (translated) of the node type identified by + * `type_id`, or an empty string when the id is unknown (buf/size + * convention). + */ +OAKENGINE_API int oakengine_node_factory_name_from_id(const char *type_id, + char *buf, + int buf_size); + +/** + * @brief Borrowed pointer to the prototype node at `index` in the + * registered library, or NULL when out of range. + * + * The returned handle is a prototype instance owned by the engine's + * NodeFactory; do not delete it or add it to a project. Use it only + * for read-only metadata queries (name, category, flags, etc.). + */ +OAKENGINE_API OakEngineNode * +oakengine_node_factory_node_at(int index); + /* ---- Metadata -------------------------------------------------------------- */ /** @@ -164,6 +222,29 @@ OAKENGINE_API int oakengine_node_set_label_many(OakEngineNode **nodes, int count, const char *label); +/** + * @brief Set one label on several nodes at once, with optional parent + * MultiUndoCommand for composition (like Core::label_nodes() with a + * non-NULL parent). + * + * When `parent_multi_or_NULL` is non-NULL, the new NodeRenameCommand is + * added as a child of that MultiUndoCommand and is NOT pushed onto the + * global undo stack. The caller is responsible for pushing the parent. + * When `parent_multi_or_NULL` is NULL, behavior matches + * oakengine_node_set_label_many(). + */ +OAKENGINE_API int oakengine_node_rename_many(OakEngineNode **nodes, + int count, + const char *label, + void *parent_multi_or_NULL); + +/** + * @brief Create a NodeRenameCommand as an opaque command pointer for a single + * node. Returns NULL on invalid arguments. + */ +OAKENGINE_API void *oakengine_node_rename_command(OakEngineNode *node, + const char *label); + /** * @brief Set the color-label index of several nodes at once (undoable, * ONE command; olive::NodeOverrideColorCommand per node, like the @@ -172,6 +253,13 @@ OAKENGINE_API int oakengine_node_set_label_many(OakEngineNode **nodes, OAKENGINE_API int oakengine_node_set_color_label(OakEngineNode **nodes, int count, int color_index); +/** + * @brief Create a NodeOverrideColorCommand as an opaque command pointer + * without executing or pushing it. + */ +OAKENGINE_API void *oakengine_node_set_color_label_command( + OakEngineNode *node, int color_index); + /** * @brief The node's color-label index (Node::get_override_color(); -1 = * none). -1 on a NULL handle. @@ -251,6 +339,33 @@ OAKENGINE_API int oakengine_node_set_input_string(OakEngineNode *self, const char *input_id, const char *s); +/** + * @brief Create a NodeParamSetStandardValueCommand as an opaque command pointer. + * Sets the standard value of `input_id` on `track` (track -1 writes the whole + * single-track value). Returns NULL on invalid arguments or type mismatch. + */ +OAKENGINE_API void *oakengine_node_set_standard_value_command( + OakEngineNode *self, const char *input_id, int element, int track, + const oak_node_value *v); + +/** + * @brief Create a command that sets an input's value at a rational time + * (olive::Node::set_value_at_time) as an opaque command pointer. `time_num` + * / `time_den` are rational seconds. The returned command is a + * MultiUndoCommand; add it to a parent or push it with oakengine_undo_push(). + */ +OAKENGINE_API void *oakengine_node_set_value_at_time_command( + void *node, const char *input, int element, int64_t time_num, + int64_t time_den, const oak_node_value *value, int track, + int insert_on_all_tracks_if_no_key); + +/** + * @brief Create a NodeParamSetStandardValueCommand for a k_video_params input + * as an opaque command pointer. `params` must describe a valid VideoParams. + */ +OAKENGINE_API void *oakengine_node_set_input_video_params_command( + OakEngineNode *self, const char *input_id, const oak_video_params *params); + /** * @brief The frame timebase used for keyframe/parameter frame timestamps * (seconds per frame: the frame rate of the project's first sequence @@ -359,6 +474,52 @@ OAKENGINE_API int oakengine_node_disconnect_ex(OakEngineNode *input_node, const char *input_id, int element); +/** + * @brief Create a NodeEdgeAddCommand as an opaque command pointer without + * executing or pushing it. Ownership passes to the caller; add it to a + * MultiUndoCommand with oakengine_undo_command_multi_add_child() or push + * it with oakengine_undo_push(). `element` is -1 for non-array inputs. + */ +OAKENGINE_API void *oakengine_node_connect_command(OakEngineNode *output_node, + OakEngineNode *input_node, + const char *input_id, + int element); + +/** + * @brief Create a NodeEdgeRemoveCommand as an opaque command pointer without + * executing or pushing it. + */ +OAKENGINE_API void *oakengine_node_disconnect_command( + OakEngineNode *input_node, const char *input_id, int element); + +/** + * @brief Link or unlink two blocks/nodes directly (olive::Node::link/unlink). + * Returns 1 on success, 0 on failure, OAKENGINE_E_INVALID if either pointer + * is NULL. + */ +OAKENGINE_API int oakengine_block_link(void *a, void *b, int linked); + +/** + * @brief Create a NodeAddCommand as an opaque command pointer without executing + * or pushing it. Adds an existing `node` to `project` on redo. + */ +OAKENGINE_API void *oakengine_node_add_to_project_command( + OakEngineProject *project, OakEngineNode *node); + +/** + * @brief Set a traverse value hint on an input (Node::set_value_hint_for_input()). + * + * `type` is an oak_node_value_type (0-19), or -1 to match the input's declared + * type. `index` is the traverse table row index (-1 for auto-detect). `tag` is + * an optional string hint (may be NULL). Returns OAKENGINE_OK on success, + * OAKENGINE_E_INVALID for NULL/type-999-style args, OAKENGINE_E_NOT_FOUND for + * an unknown input id. + */ +OAKENGINE_API int oakengine_node_set_value_hint(OakEngineNode *self, + const char *input_id, + int element, int type, + int index, const char *tag); + /* ---- Parameter animation (keyframes) -------------------------------------- * * Keyframes live on an input's keyframe tracks (olive::NodeKeyframe). All @@ -437,6 +598,37 @@ OAKENGINE_API int oakengine_node_keyframe_remove(OakEngineNode *self, const char *input_id, int64_t time_ts); +/** + * @brief Create a NodeParamInsertKeyframeCommand as an opaque command pointer. + */ +OAKENGINE_API void *oakengine_node_insert_keyframe_command( + OakEngineNode *self, const char *input_id, int element, int track, + int64_t time_ts, const oak_node_value *value, int type, float x1, float y1, + float x2, float y2); + +/** + * @brief Create a NodeParamRemoveKeyframeCommand as an opaque command pointer + * from a borrowed keyframe handle. + */ +OAKENGINE_API void *oakengine_node_remove_keyframe_command( + OakEngineKeyframe *keyframe); + +/** + * @brief Create a NodeParamSetKeyframeTimeCommand as an opaque command pointer + * from a borrowed keyframe handle. The previous time is captured at apply + * time; `new_time_ts` is in the project's frame timestamp timebase. + */ +OAKENGINE_API void *oakengine_keyframe_set_time_command( + OakEngineKeyframe *keyframe, int64_t new_time_ts); + +/** + * @brief Create a NodeParamSetKeyframeValueCommand as an opaque command pointer + * from a borrowed keyframe handle. The previous value is captured at apply + * time. `value->type` must match the keyframe's declared input type. + */ +OAKENGINE_API void *oakengine_keyframe_set_value_command( + OakEngineKeyframe *keyframe, const oak_node_value *value); + /** * @brief Change the easing of the keyframe at `time_ts` (undoable; set type * plus bezier control points, mirroring the application's keyframe view @@ -538,8 +730,894 @@ OAKENGINE_API int oakengine_node_keyframe_set_bezier_point( OAKENGINE_API int oakengine_node_keyframes_clear(OakEngineNode *self, const char *input_id); +/* ---- Extended input introspection ----------------------------------------- */ + +/** + * @brief 1 if the input is an array-type input. + */ +OAKENGINE_API int oakengine_node_input_is_array( + const OakEngineNode *self, const char *input_id); + +/** + * @brief Number of elements in the array input (0 for non-array inputs). + */ +OAKENGINE_API int oakengine_node_input_array_size( + const OakEngineNode *self, const char *input_id); + +/** + * @brief The input's flags bitmask (Node::get_input_flags(); 0 on NULL). + */ +OAKENGINE_API int oakengine_node_input_get_flags( + const OakEngineNode *self, const char *input_id); + +/** + * @brief 1 if the input can accept a connection (connectable). + */ +OAKENGINE_API int oakengine_node_input_is_connectable( + const OakEngineNode *self, const char *input_id); + +/** + * @brief 1 if the input supports keyframing (keyframable). + */ +OAKENGINE_API int oakengine_node_input_is_keyframable( + const OakEngineNode *self, const char *input_id); + +/** + * @brief 1 if keyframing is enabled for this input on any track + * (keyframed_ex; pass -1 for all tracks). + */ +OAKENGINE_API int oakengine_node_input_is_keyframed_ex( + const OakEngineNode *self, const char *input_id, int track); + +/** + * @brief The node's label and name combined (buf/size). + */ +OAKENGINE_API int oakengine_node_get_label_and_name( + const OakEngineNode *self, char *buf, int buf_size); + +/** + * @brief The human-readable name of the input (buf/size). + */ +OAKENGINE_API int oakengine_node_get_input_name( + const OakEngineNode *self, const char *input_id, char *buf, + int buf_size); + +/** + * @brief The default value of the input at a track index. + */ +OAKENGINE_API int oakengine_node_input_get_default_value( + const OakEngineNode *self, const char *input_id, int track, + oak_node_value *out); + +/** + * @brief The project that owns this node (NULL on NULL input). + */ +OAKENGINE_API OakEngineProject *oakengine_node_get_project( + const OakEngineNode *self); + +/** + * @brief The node connected to the input, or NULL (element -1 for + * non-array inputs). + */ +OAKENGINE_API OakEngineNode *oakengine_node_input_get_connected_node( + const OakEngineNode *self, const char *input_id, int element); + +/** + * @brief Copy the values (not connections) from `src` to `dest` + * (undoable, ONE command). + */ +OAKENGINE_API int oakengine_node_copy_inputs( + OakEngineNode *dest, const OakEngineNode *src); + +/** + * @brief Get the value of an input at a specific time (frame timestamp + * timebase). String inputs fail with OAKENGINE_E_INVALID. + */ +OAKENGINE_API int oakengine_node_get_input_at_time( + const OakEngineNode *self, const char *input_id, int element, int track, + int64_t time_ts, int track_for_time, oak_node_value *out); + +/** + * @brief Get a string input's value at a specific time (buf/size). + */ +OAKENGINE_API int oakengine_node_get_input_string_at_time( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, char *buf, int buf_size); + +/** + * @brief Get the bezier value of an input at a specific time (fails with + * E_INVALID for non-bezier inputs). + */ +OAKENGINE_API int oakengine_node_get_input_bezier_at_time( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, double *out_6); + +/** + * @brief Get the binary value of an input at a specific time (fails with + * E_INVALID for non-binary inputs). + */ +OAKENGINE_API int oakengine_node_get_input_binary_at_time( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, char *buf, int buf_size); + +/* ---- Input properties ----------------------------------------------------- */ + +/** + * @brief 1 if the input has a property with the given key. + */ +OAKENGINE_API int oakengine_node_input_has_property( + const OakEngineNode *self, const char *input_id, const char *key); + +/** + * @brief Set a string property on an input (undoable; notify != 0 sends + * change notification). + */ +OAKENGINE_API int oakengine_node_set_input_property_string( + OakEngineNode *self, const char *input_id, const char *key, + const char *value, int notify); + +/** + * @brief Read a string property (buf/size). + */ +OAKENGINE_API int oakengine_node_input_get_property_string( + const OakEngineNode *self, const char *input_id, const char *key, + char *buf, int buf_size); + +/** + * @brief Read a numeric property as a double (-1 track = whole value). + */ +OAKENGINE_API int oakengine_node_input_get_property_number( + const OakEngineNode *self, const char *input_id, const char *key, + int track, double *out); + +/** + * @brief Read an integer property. + */ +OAKENGINE_API int oakengine_node_input_get_property_int( + const OakEngineNode *self, const char *input_id, const char *key, + int64_t *out); + +/** + * @brief Read a rational property (numerator/denominator; any may be NULL). + */ +OAKENGINE_API int oakengine_node_input_get_property_rational( + const OakEngineNode *self, const char *input_id, const char *key, + int *num, int *den); + +/** + * @brief The number of properties on the input. + */ +OAKENGINE_API int oakengine_node_input_get_property_count( + const OakEngineNode *self, const char *input_id); + +/** + * @brief Enumerate the property key at `index` (buf/size; 0-based index + * into the property map). Returns the length on success, negative on error. + */ +OAKENGINE_API int oakengine_node_input_get_property_key( + const OakEngineNode *self, const char *input_id, int index, + char *buf, int buf_size); + +/** + * @brief The number of elements in a string-list property. + */ +OAKENGINE_API int oakengine_node_input_get_property_string_list_count( + const OakEngineNode *self, const char *input_id, const char *key); + +/** + * @brief Read one element of a string-list property (buf/size). + */ +OAKENGINE_API int oakengine_node_input_get_property_string_list( + const OakEngineNode *self, const char *input_id, const char *key, + int index, char *buf, int buf_size); + +/* ---- Node type queries ---------------------------------------------------- */ + +/** + * @brief 1 if the node is a group node. + */ +OAKENGINE_API int oakengine_node_is_group(const OakEngineNode *self); + +/** + * @brief 1 if the node is a multi-camera node. + */ +OAKENGINE_API int oakengine_node_is_multicam(const OakEngineNode *self); + +/* ---- Context positions ---------------------------------------------------- */ + +/** + * @brief The number of nodes visible in the given context + * (Node::context_count() for the underlying context; -1 on NULL context). + */ +OAKENGINE_API int oakengine_node_context_node_count( + const OakEngineNode *context); + +/** + * @brief 1 if the context contains the node. + */ +OAKENGINE_API int oakengine_node_context_contains_node( + const OakEngineNode *context, const OakEngineNode *node); + +/** + * @brief The node at an index in the context (NULL when out of range; + * returns x/y/expanded pointers if non-NULL). + */ +OAKENGINE_API OakEngineNode *oakengine_node_context_node_at( + OakEngineNode *context, int index, double *x, double *y, + int *expanded); + +/** + * @brief Set the context position of a node (undoable). + */ +OAKENGINE_API int oakengine_node_set_context_position( + OakEngineNode *context, OakEngineNode *node, double x, double y); + +/** + * @brief Get the context position of a node. + */ +OAKENGINE_API int oakengine_node_get_context_position( + const OakEngineNode *context, const OakEngineNode *node, + double *x, double *y, int *expanded); + +/** + * @brief Set the expanded flag of a node in a context (undoable). + */ +OAKENGINE_API int oakengine_node_set_context_expanded( + OakEngineNode *context, OakEngineNode *node, int expanded); + +/* ---- Effect input --------------------------------------------------------- */ + +/** + * @brief Get the node's effect input id and element (typically the texture + * input for generators/filters). OAKENGINE_E_NOT_FOUND when none. + */ +OAKENGINE_API int oakengine_node_get_effect_input( + const OakEngineNode *self, char *input_id, int input_id_size, + int *element); + +/* ---- Group passthrough ---------------------------------------------------- */ + +/** + * @brief Create a detached group node (equivalent to new NodeGroup()). + * The caller owns the returned handle and must add it to a project before + * the engine manages its lifecycle. + */ +OAKENGINE_API OakEngineNode *oakengine_node_group_create(void); + +/** + * @brief Walk one group-passthrough level: if `*inout_node` is a group and + * its input `*inout_input`/`*inout_element` is a passthrough, replace them + * with the inner node/input/element and return 1. Returns 0 when the node + * is not a group or the input is not a passthrough (inouts unchanged). + */ +OAKENGINE_API int oakengine_node_group_get_inner( + OakEngineNode **inout_node, char *inout_input, int inout_input_size, + int *inout_element); + +/** + * @brief The number of input passthroughs on the group (OAKENGINE_E_INVALID + * when the node is not a group). + */ +OAKENGINE_API int oakengine_group_input_passthrough_count( + const OakEngineNode *self); + +/** + * @brief Add an input passthrough to the group (direct, no undo). + */ +OAKENGINE_API int oakengine_group_add_input_passthrough( + OakEngineNode *self, OakEngineNode *inner_node, + const char *inner_input, int inner_element, + const char *preferred_id, char *out_id, int out_id_size); + +/** + * @brief Read the i-th input passthrough of the group. + */ +OAKENGINE_API int oakengine_group_input_passthrough_at( + const OakEngineNode *self, int index, char *id, int id_size, + OakEngineNode **node, char *input_id, int input_id_size, + int *element); + +/** + * @brief Look up a passthrough id by (node, input, element). + */ +OAKENGINE_API int oakengine_group_get_id_of_passthrough( + const OakEngineNode *self, OakEngineNode *inner_node, + const char *inner_input, int inner_element, char *id, int id_size); + +/** + * @brief Look up (node, input, element) by passthrough id. + */ +OAKENGINE_API int oakengine_group_get_passthrough_from_id( + const OakEngineNode *self, const char *id, OakEngineNode **out_node, + char *out_input, int out_input_size, int *out_element); + +/** + * @brief Get the output passthrough node (or NULL). + */ +OAKENGINE_API OakEngineNode *oakengine_group_get_output_passthrough( + const OakEngineNode *self); + +/** + * @brief Set the output passthrough node (direct, no undo). + */ +OAKENGINE_API int oakengine_group_set_output_passthrough( + OakEngineNode *self, OakEngineNode *inner_node); + +/** + * @brief Resolve a passthrough id to its real node and input (handles + * nested groups). + */ +OAKENGINE_API int oakengine_group_resolve_input( + const OakEngineNode *self, const char *id, int element, + OakEngineNode **out_node, char *out_input, int out_input_size, + int *out_element); + +/** + * @brief Remove an input passthrough (direct, no undo). + */ +OAKENGINE_API int oakengine_group_remove_input_passthrough( + OakEngineNode *self, OakEngineNode *inner_node, + const char *inner_input, int inner_element); + +/** + * @brief Create a NodeGroupAddInputPassthrough command as an opaque command + * pointer without executing or pushing it. + */ +OAKENGINE_API void *oakengine_group_add_input_passthrough_command( + OakEngineNode *self, OakEngineNode *inner_node, + const char *inner_input, int inner_element, + const char *preferred_id); + +/** + * @brief Create a NodeGroupSetOutputPassthrough command as an opaque command + * pointer without executing or pushing it. + */ +OAKENGINE_API void *oakengine_group_set_output_passthrough_command( + OakEngineNode *self, OakEngineNode *inner_node); + +/** + * @brief Add an input passthrough (undoable; ONE undoable command). + */ +OAKENGINE_API int oakengine_group_add_input_passthrough_undoable( + OakEngineNode *self, OakEngineNode *inner_node, + const char *inner_input, int inner_element, + const char *preferred_id); + +/** + * @brief Set the output passthrough node (undoable; ONE undoable command). + */ +OAKENGINE_API int oakengine_group_set_output_passthrough_undoable( + OakEngineNode *self, OakEngineNode *inner_node); + +/* ---- Multi-camera --------------------------------------------------------- */ + +/** + * @brief The input id string for the current camera. + */ +OAKENGINE_API const char *oakengine_multicam_input_current(void); + +/** + * @brief The input id string for the sources array. + */ +OAKENGINE_API const char *oakengine_multicam_input_sources(void); + +/** + * @brief The input id string for the sequence. + */ +OAKENGINE_API const char *oakengine_multicam_input_sequence(void); + +/** + * @brief The input id string for the sequence type. + */ +OAKENGINE_API const char *oakengine_multicam_input_sequence_type(void); + +/** + * @brief Number of connected source cameras (OAKENGINE_E_INVALID when + * the node is not a multicam). + */ +OAKENGINE_API int oakengine_multicam_get_source_count( + const OakEngineNode *self); + +/** + * @brief Compute the grid (rows, cols) for the given number of sources. + */ +OAKENGINE_API int oakengine_multicam_get_rows_and_columns( + int source_count, int *rows, int *cols); + +/** + * @brief Convert a flat index to (row, col) in the grid. + */ +OAKENGINE_API int oakengine_multicam_index_to_row_cols( + int index, int rows, int cols, int *out_row, int *out_col); + +/** + * @brief Convert (row, col) to a flat index. + */ +OAKENGINE_API int oakengine_multicam_rows_cols_to_index( + int row, int col, int rows, int cols); + +/** + * @brief Current source index of a multicam node. + * Returns the index or OAKENGINE_E_INVALID when `node` is not a multicam. + */ +OAKENGINE_API int oakengine_multicam_get_current_source( + const OakEngineNode *node); + +/* ---- Shape node ----------------------------------------------------------- */ + +/** + * @brief Set a shape node's rectangle (undoable). `x`/`y`/`w`/`h` are in + * pixels; `video_params` is an oak_video_params POD describing the target + * resolution. Returns OAKENGINE_OK or OAKENGINE_E_INVALID. + */ +OAKENGINE_API int oakengine_shape_set_rect_undoable( + OakEngineNode *node, double x, double y, double w, double h, + const oak_video_params *video_params, void *command); + +/* ---- Subtitle block ------------------------------------------------------- */ + +/** @brief The input id string for the subtitle text input. */ +OAKENGINE_API const char *oakengine_subtitle_text_input_id(void); + +/** + * @brief Get the subtitle block's text (buf/size convention). + * Returns the would-be length (excluding NUL) or OAKENGINE_E_INVALID. + */ +OAKENGINE_API int oakengine_subtitle_get_text(const OakEngineNode *node, + char *buf, int buf_size); + +/** + * @brief Set the subtitle block's text (non-undoable). + * Returns OAKENGINE_OK or OAKENGINE_E_INVALID. + */ +OAKENGINE_API int oakengine_subtitle_set_text(OakEngineNode *node, + const char *text); + +/* ---- Bulk graph deletion -------------------------------------------------- */ + +/** + * @brief Delete several nodes and their edges in one undoable command. + * + * `node_count` may be 0 (pass `nodes`/`contexts` as NULL) to delete only + * edges; the call is invalid only when both counts are 0. + */ +OAKENGINE_API int oakengine_nodes_delete_many( + OakEngineNode *const *nodes, OakEngineNode *const *contexts, + int node_count, OakEngineNode *const *edge_outputs, + OakEngineNode *const *edge_input_nodes, + const char *const *edge_input_ids, + const int *edge_input_elements, int edge_count); + +/** + * @brief oakengine_nodes_delete_many() plus edges to (re)connect AFTER the + * deletion, still inside the same single undoable command. + * + * The reconnect edges are applied after the nodes are gone, so they may + * target inputs that were occupied by the deleted nodes (effect-bypass + * rewiring in the parameter editor). Redo order: delete, then reconnect; + * undo order is the reverse. + */ +OAKENGINE_API int oakengine_nodes_delete_many_ex( + OakEngineNode *const *nodes, OakEngineNode *const *contexts, + int node_count, OakEngineNode *const *edge_outputs, + OakEngineNode *const *edge_input_nodes, + const char *const *edge_input_ids, + const int *edge_input_elements, int edge_count, + OakEngineNode *const *reconnect_outputs, + OakEngineNode *const *reconnect_input_nodes, + const char *const *reconnect_input_ids, + const int *reconnect_input_elements, int reconnect_count); + +/* ---- Keyframe best type at time ------------------------------------------- */ + +/** + * @brief The best easing type for a keyframe at the given time (used by + * the panel to determine the default type when adding keys). + */ +OAKENGINE_API int oakengine_node_keyframe_best_type_at_time( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, int default_type); + +/* ---- Handle-based keyframe API -------------------------------------------- */ + +/** + * @brief Number of keyframe tracks on the input (-1 for all). + */ +OAKENGINE_API int oakengine_node_keyframe_track_count( + const OakEngineNode *self, const char *input_id, int element); + +/** + * @brief Number of keyframes on a specific track. + */ +OAKENGINE_API int oakengine_node_keyframe_count_on_track( + const OakEngineNode *self, const char *input_id, int element, + int track); + +/** + * @brief Toggle keyframing on/off at a time (add/remove one key). + */ +OAKENGINE_API int oakengine_node_keyframes_toggle_at_time( + OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, int on, const char *undo_name); + +/** + * @brief 1 if a keyframe exists at the given time on the given track. + */ +OAKENGINE_API int oakengine_node_has_keyframe_at_time( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track); + +/** + * @brief The earliest keyframe time on the input. Returns 1 if found, + * 0 if no keyframes (and the output rational is set). + */ +OAKENGINE_API int oakengine_node_keyframe_earliest_time( + const OakEngineNode *self, const char *input_id, int element, + int64_t *num, int64_t *den); + +/** + * @brief The latest keyframe time on the input. Returns 1 if found, + * 0 if no keyframes. + */ +OAKENGINE_API int oakengine_node_keyframe_latest_time( + const OakEngineNode *self, const char *input_id, int element, + int64_t *num, int64_t *den); + +/** + * @brief The closest keyframe time before the given time. + * Returns 1 if found, 0 if none. + */ +OAKENGINE_API int oakengine_node_keyframe_closest_time_before( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, int64_t *num, int64_t *den); + +/** + * @brief The closest keyframe time after the given time. + * Returns 1 if found, 0 if none. + */ +OAKENGINE_API int oakengine_node_keyframe_closest_time_after( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, int64_t *num, int64_t *den); + +/** + * @brief Borrowed handle of the keyframe at the given on-track index, + * or NULL. + */ +OAKENGINE_API OakEngineKeyframe *oakengine_node_keyframe_handle_on_track( + const OakEngineNode *self, const char *input_id, int element, + int track, int index); + +/** + * @brief Borrowed handle of the keyframe at the given time on a track, + * or NULL. + */ +OAKENGINE_API OakEngineKeyframe *oakengine_node_keyframe_handle_at_time( + const OakEngineNode *self, const char *input_id, int element, + int track, int64_t time_ts, int track_for_time); + +/** + * @brief Fill an array with keyframe handles at a given time. Returns + * the number filled. + */ +OAKENGINE_API int oakengine_node_keyframes_at_time( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, OakEngineKeyframe **out_handles, + int max_handles); + +/** + * @brief Enable or disable keyframing on an input for a given element + * (undoable). If enabling, one default-type key per track is added. + */ +OAKENGINE_API int oakengine_node_set_input_keyframing( + OakEngineNode *self, const char *input_id, int element, + int keyframing, int track, int enable_all_tracks, + const char *undo_name); + +/** + * @brief Create a NodeParamSetKeyframingCommand as an opaque command pointer. + */ +OAKENGINE_API void *oakengine_node_set_input_keyframing_command( + OakEngineNode *self, const char *input_id, int element, int keyframing); + +/** + * @brief Paste detached keyframes onto the input's track (undoable, + * ONE command). + */ +OAKENGINE_API int oakengine_node_keyframes_paste( + OakEngineNode *self, OakEngineKeyframe *const *keyframes, + int count, const char *undo_name); + +/* ---- OakEngineKeyframe accessors ------------------------------------------ */ + +/** + * @brief The keyframe's time as a rational. + */ +OAKENGINE_API int oakengine_keyframe_get_time( + const OakEngineKeyframe *self, int64_t *num, int64_t *den); + +/** + * @brief The input id that owns this keyframe (buf/size). + */ +OAKENGINE_API int oakengine_keyframe_get_input_id( + const OakEngineKeyframe *self, char *buf, int buf_size); + +/** + * @brief The track this keyframe belongs to. + */ +OAKENGINE_API int oakengine_keyframe_get_track( + const OakEngineKeyframe *self); + +/** + * @brief The element this keyframe belongs to. + */ +OAKENGINE_API int oakengine_keyframe_get_element( + const OakEngineKeyframe *self); + +/** + * @brief The node that owns this keyframe. + */ +OAKENGINE_API OakEngineNode *oakengine_keyframe_get_node( + const OakEngineKeyframe *self); + +/** + * @brief The easing type of the keyframe (0=linear, 1=bezier, 2=hold; + * -1 on NULL). + */ +OAKENGINE_API int oakengine_keyframe_get_type( + const OakEngineKeyframe *self); + +/** + * @brief The default easing type for a new keyframe. + */ +OAKENGINE_API int oakengine_keyframe_default_type(void); + +/** + * @brief The opposing bezier handle type (0=k_in_handle ⇄ 1=k_out_handle). + */ +OAKENGINE_API int oakengine_keyframe_opposing_bezier_type(int type); + +/** + * @brief The value of the keyframe on its track. + */ +OAKENGINE_API int oakengine_keyframe_get_value( + const OakEngineKeyframe *self, oak_node_value *out); + +/** + * @brief 1 if there is a sibling keyframe at the given time on a different + * track of the same input. + */ +OAKENGINE_API int oakengine_keyframe_has_sibling_at_time( + const OakEngineKeyframe *self, int64_t time_ts, int track); + +/** + * @brief Live-set a bezier control point (no undo). + */ +OAKENGINE_API int oakengine_keyframe_set_bezier_point_live( + OakEngineKeyframe *self, int point_index, double x, double y); + +/** + * @brief Read a bezier control point (0 = in-handle, 1 = out-handle). + */ +OAKENGINE_API int oakengine_keyframe_get_bezier_point( + const OakEngineKeyframe *self, int point_index, double *x, + double *y); + +/** + * @brief Read a bezier control point that is valid (returns the + * point or the identity point for non-bezier keyframes). + */ +OAKENGINE_API int oakengine_keyframe_get_valid_bezier_point( + const OakEngineKeyframe *self, int point_index, double *x, + double *y); + +/** + * @brief Live-set the value of a keyframe (no undo). + */ +OAKENGINE_API int oakengine_keyframe_set_value_live( + OakEngineKeyframe *self, const oak_node_value *value); + +/** + * @brief Live-set the time of a keyframe (no undo). + */ +OAKENGINE_API int oakengine_keyframe_set_time_live( + OakEngineKeyframe *self, int64_t num, int64_t den); + +/** + * @brief Remove several keyframes in one undoable command. + */ +OAKENGINE_API int oakengine_keyframes_remove_many( + OakEngineKeyframe *const *keyframes, int count, + const char *undo_name); + +/** + * @brief Create a detached keyframe (not yet on any track). + */ +OAKENGINE_API OakEngineKeyframe *oakengine_keyframe_create( + OakEngineNode *node, const char *input_id, int element, + int track, int64_t time_ts, int type, + const oak_node_value *value, int64_t duration_ts); + +/** + * @brief Dispose a detached keyframe (no-op on NULL). + */ +OAKENGINE_API void oakengine_keyframe_dispose( + OakEngineKeyframe *keyframe); + +/* ---- Input dragger -------------------------------------------------------- */ + +/** + * @brief Create an input dragger for live-drag of a keyframe value. + */ +OAKENGINE_API OakEngineNodeDragger *oakengine_dragger_create( + OakEngineNode *node, const char *input_id, int element, + int track); + +/** + * @brief Start the drag at the given frame timestamp (creates a keyframe). + */ +OAKENGINE_API int oakengine_dragger_start( + OakEngineNodeDragger *self, int64_t time_ts, int track, + int insert_on_all_tracks); + +/** + * @brief Drag to a new value (live; no undo). + */ +OAKENGINE_API int oakengine_dragger_drag( + OakEngineNodeDragger *self, const oak_node_value *value); + +/** + * @brief End the drag, pushing ONE undoable command. + */ +OAKENGINE_API int oakengine_dragger_end( + OakEngineNodeDragger *self, const char *undo_name); + +/** + * @brief 1 if the dragger has been started. + */ +OAKENGINE_API int oakengine_dragger_is_started( + const OakEngineNodeDragger *self); + +/** + * @brief Free the dragger (no-op on NULL). + */ +OAKENGINE_API void oakengine_dragger_free( + OakEngineNodeDragger *self); + +/* ---- Node static data and helpers ----------------------------------------- */ + +/** + * @brief Node::k_enabled_input. Static string, never freed. + */ +OAKENGINE_API const char *oakengine_node_enabled_input_id(void); + +/** @brief VolumeNode::k_samples_input. Static string, never freed. */ +OAKENGINE_API const char *oakengine_volume_samples_input_id(void); + +/** @brief TransformDistortNode::k_texture_input. Static string. */ +OAKENGINE_API const char *oakengine_transform_texture_input_id(void); + +/** @brief TransitionBlock::k_in_block_input. Static string. */ +OAKENGINE_API const char *oakengine_transition_in_block_input_id(void); + +/** @brief TransitionBlock::k_out_block_input. Static string. */ +OAKENGINE_API const char *oakengine_transition_out_block_input_id(void); + +/** @brief AudioVisualWaveform::k_maximum_sample_rate as a double. */ +OAKENGINE_API double oakengine_audio_waveform_max_sample_rate(void); + +/** + * @brief Node::get_category_name() (buf/size convention). + * `category_id` is a Node::CategoryID value. + */ +OAKENGINE_API int oakengine_node_category_name(int category_id, + char *buf, int buf_size); + +/** + * @brief Create a NodeLinkCommand as an opaque command pointer. + * `link` != 0 links the two nodes, 0 unlinks them. + */ +OAKENGINE_API void *oakengine_node_link_command(OakEngineNode *a, + OakEngineNode *b, int link); + +/** + * @brief Node::copy_node_in_graph(). Returns the copy as a borrowed + * OakEngineNode*, or NULL on failure. The copy is added to `command` + * (a MultiUndoCommand*) when non-NULL; when NULL a standalone command + * is pushed. + */ +OAKENGINE_API OakEngineNode *oakengine_node_copy_in_graph( + OakEngineNode *node, void *command); + +/** + * @brief Node::copy_dependency_graph(). `nodes` and `copies` are + * parallel arrays of the same length; the function connects the copies + * the same way the originals are connected. `command` is a + * MultiUndoCommand* (may be NULL for direct application). + */ +OAKENGINE_API int oakengine_node_copy_dependency_graph( + OakEngineNode *const *nodes, OakEngineNode *const *copies, int count, + void *command); + +/** + * @brief Node::get_connect_command_string() (buf/size convention). + * Returns a human-readable description of connecting `output` to the + * input `input_id`/`element` of `input_node`. + */ +OAKENGINE_API int oakengine_node_connect_command_string( + OakEngineNode *output, OakEngineNode *input_node, + const char *input_id, int element, char *buf, int buf_size); + +/** + * @brief Node::transform_time_to(). Transforms a time range through the + * node graph from `from` to `to`. Returns the transformed range as + * rational seconds (in_num/in_den, out_num/out_den). + */ +OAKENGINE_API int oakengine_node_transform_time_to( + OakEngineNode *from, OakEngineNode *to, int direction, + int path_index, int64_t in_num, int64_t in_den, + int64_t out_num, int64_t out_den, + int64_t *result_in_num, int64_t *result_in_den, + int64_t *result_out_num, int64_t *result_out_den); + +/* ---- NodeValue static methods (F class: 4 symbols) ----------------------- */ + +/** + * @brief NodeValue::get_number_of_keyframe_tracks(type) using C enum. + * + * `c_type` is an oak_node_value_type value (NOT olive::NodeValue::Type + * enum ordinal). Returns the number of keyframe tracks for the type: + * 1 for scalar types, 2/3/4/6 for VEC2/VEC3/VEC4/COLOR/BEZIER. + */ +OAKENGINE_API int oakengine_node_value_keyframe_track_count(int c_type); + +/** + * @brief NodeValue::get_pretty_data_type_name(type) into buf (buf/size). + * + * `c_type` is an oak_node_value_type value. Returns the would-be string + * length (excluding NUL), or -1 for unknown type. + */ +OAKENGINE_API int oakengine_node_value_pretty_type_name(int c_type, + char *buf, int buf_size); + +/** + * @brief NodeValue::split_normal_value_into_track_values() into a + * pre-allocated array. + * + * `c_type` is an oak_node_value_type. `normal` is the input value. + * `tracks_out` must hold at least `track_count` oak_node_value entries + * (caller allocates; get track_count first via + * oakengine_node_value_keyframe_track_count()). Returns OAKENGINE_OK + * or OAKENGINE_E_INVALID. + * + * For non-split types (VEC2/3/4/COLOR/BEZIER), the value is split into + * per-component tracks. For scalar types, tracks_out[0] gets the value. + */ +OAKENGINE_API int oakengine_node_value_split_to_tracks(int c_type, + const oak_node_value *normal, oak_node_value *tracks_out, int track_count); + +/** + * @brief NodeValue::combine_track_values_into_normal_value() — split + * reverse. + * + * `c_type` is an oak_node_value_type. `tracks` must have at least + * `track_count` entries (from oakengine_node_value_keyframe_track_count). + * Returns OAKENGINE_OK or OAKENGINE_E_INVALID. + */ +OAKENGINE_API int oakengine_node_value_combine_tracks(int c_type, + const oak_node_value *tracks, int track_count, oak_node_value *normal_out); + #ifdef __cplusplus } #endif +/* Qt meta-type support: these opaque C handles are used as signal/slot + * parameters across the C ABI boundary. Declaring them as opaque pointers + * lets QMetaType store them (queued connections, QSignalSpy, QVariant). */ +#ifdef __cplusplus +#include +Q_DECLARE_OPAQUE_POINTER(OakEngineNode *) +Q_DECLARE_OPAQUE_POINTER(OakEngineKeyframe *) +Q_DECLARE_OPAQUE_POINTER(OakEngineNodeDragger *) +#endif + #endif /* OAKENGINE_NODE_H */ diff --git a/engine/include/oakengine/plugin.h b/engine/include/oakengine/plugin.h new file mode 100644 index 000000000..5c28ef794 --- /dev/null +++ b/engine/include/oakengine/plugin.h @@ -0,0 +1,74 @@ +/*** + + 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_PLUGIN_H +#define OAKENGINE_PLUGIN_H + +#include "export.h" +#include "init.h" +#include "node.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file plugin.h + * @brief C ABI for plugin support (active viewer, progress reporter, loading) + */ + +/* ---- Active viewer provider -------------------------------------------- */ + +/** @brief Returns the currently active viewer node (or NULL). */ +typedef OakEngineNode *(*oakengine_plugin_active_viewer_fn)(void *userdata); + +OAKENGINE_API int oakengine_plugin_set_active_viewer_provider( + oakengine_plugin_active_viewer_fn fn, void *userdata); + +/* ---- Progress reporter factory ----------------------------------------- */ + +typedef void *(*oakengine_plugin_reporter_create_fn)( + const char *message, const char *title, void *userdata); +typedef void (*oakengine_plugin_reporter_destroy_fn)( + void *reporter, void *userdata); +typedef int (*oakengine_plugin_reporter_is_cancelled_fn)( + void *reporter, void *userdata); +typedef void (*oakengine_plugin_reporter_set_progress_fn)( + void *reporter, double progress, void *userdata); + +OAKENGINE_API int oakengine_plugin_set_progress_reporter_factory( + oakengine_plugin_reporter_create_fn create, + oakengine_plugin_reporter_destroy_fn destroy, + oakengine_plugin_reporter_is_cancelled_fn is_cancelled, + oakengine_plugin_reporter_set_progress_fn set_progress, + void *userdata); + +/* ---- Plugin loading and interaction ------------------------------------ */ + +OAKENGINE_API int oakengine_plugin_load_plugins(const char *path); + +OAKENGINE_API int oakengine_plugin_node_push_button_clicked( + OakEngineNode *node, const char *button_id); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_PLUGIN_H */ diff --git a/engine/include/oakengine/preview.h b/engine/include/oakengine/preview.h index 7f4d269fc..237e56fa9 100644 --- a/engine/include/oakengine/preview.h +++ b/engine/include/oakengine/preview.h @@ -62,6 +62,24 @@ extern "C" { #define OAKENGINE_LOOP_MODE_LOOP 1 /**< Repeat the clip (olive k_loop_mode_loop). */ #define OAKENGINE_LOOP_MODE_CLAMP 2 /**< Hold first/last frame (olive k_loop_mode_clamp). */ +/** + * @brief Opaque preview request handle (an active render ticket for + * single-frame or audio-range preview). + */ +typedef struct OakEnginePreviewRequest OakEnginePreviewRequest; + +/** + * @brief POD for a single video frame from a preview request + * (borrowed data, valid until the request is freed). + */ +typedef struct oak_playback_frame { + int width; + int height; + int format; /**< olive::PixelFormat::Format value. */ + const void *data; /**< Planar data pointer (first plane). */ + int linesize; /**< Bytes per row of the first plane. */ +} oak_playback_frame; + /** * @brief Human-readable reason for the last failed preview call on this * thread (buf/size convention). @@ -118,6 +136,108 @@ OAKENGINE_API int oakengine_preview_get_waveform_summary( OakEngineFootage *footage, int channel, int64_t start_ts, int64_t end_ts, double *min_vals, double *max_vals, int count); +/* ---- R4: waveform, audio levels, cacher, preview requests ------------------ */ + +/** @brief Maximum sample rate for waveform generation. > 0. */ +OAKENGINE_API int oakengine_waveform_max_sample_rate(void); + +/** + * @brief Analyze audio levels (linear RMS) from raw float sample data. + * `data` is an array of `channels` float pointers, each with `count` samples. + * Writes RMS values into `levels` (one per channel). Returns OAKENGINE_OK + * or OAKENGINE_E_INVALID on NULL/bad arguments. + */ +OAKENGINE_API int oakengine_audio_analyze_levels(const float *const *data, + int channels, int64_t count, + double *levels); + +/** + * @brief Set the preview cacher's playhead position (num/den seconds). + * Returns OAKENGINE_E_STATE when the cacher is not available. + */ +OAKENGINE_API int oakengine_preview_cacher_set_playhead(int64_t num, + int64_t den); + +/** + * @brief Pause or resume thumbnail generation in the cacher. + * Returns OAKENGINE_E_STATE when the cacher is not available. + */ +OAKENGINE_API int oakengine_preview_cacher_set_thumbnails_paused(int paused); + +/** + * @brief Clear pending single-frame render requests from the cacher. + * Returns OAKENGINE_E_STATE when the cacher is not available. + */ +OAKENGINE_API int +oakengine_preview_cacher_clear_single_frame_renders(int only_finished); + +/** + * @brief Force the cacher to cache a range (num/den seconds in/out). + * Returns OAKENGINE_E_INVALID on NULL node. + */ +OAKENGINE_API int oakengine_preview_cacher_force_cache_range( + OakEngineNode *node, int64_t in_num, int64_t in_den, int64_t out_num, + int64_t out_den); + +/** + * @brief Request a single video frame at (num/den) seconds from `viewer`. + * Returns a request handle (caller owns it, must free) or NULL on failure. + */ +OAKENGINE_API OakEnginePreviewRequest * +oakengine_preview_request_single_frame(OakEngineNode *viewer, int64_t num, + int64_t den, int dry); + +/** + * @brief Request an audio range (num/den seconds in/out) from `viewer`. + * Returns a request handle (caller owns it, must free) or NULL on failure. + */ +OAKENGINE_API OakEnginePreviewRequest * +oakengine_preview_request_audio_range(OakEngineNode *viewer, int64_t in_num, + int64_t in_den, int64_t out_num, + int64_t out_den); + +/** @brief 1 if the request is done, 0 otherwise. 0 on NULL. */ +OAKENGINE_API int oakengine_preview_request_is_done( + const OakEnginePreviewRequest *req); + +/** @brief 1 if the request has a result, 0 otherwise. 0 on NULL. */ +OAKENGINE_API int oakengine_preview_request_has_result( + const OakEnginePreviewRequest *req); + +/** @brief Set a finished callback (called when the ticket completes). + * `callback` receives `user_data`. Returns OAKENGINE_E_INVALID on NULL + * request. */ +OAKENGINE_API int oakengine_preview_request_set_finished_callback( + OakEnginePreviewRequest *req, void (*callback)(void *), + void *user_data); + +/** @brief Copy the frame data into `out`. Returns OAKENGINE_OK or + * OAKENGINE_E_INVALID when the request has no video frame result. */ +OAKENGINE_API int oakengine_preview_request_get_frame( + OakEnginePreviewRequest *req, oak_playback_frame *out); + +/** @brief Number of audio channels in the result, or 0 if none. */ +OAKENGINE_API int oakengine_preview_request_get_audio_channel_count( + const OakEnginePreviewRequest *req); + +/** @brief Sample rate of the audio result, or 0 if none. */ +OAKENGINE_API int oakengine_preview_request_get_audio_sample_rate( + const OakEnginePreviewRequest *req); + +/** + * @brief Get audio sample data from the result. + * `channel` is the 0-based channel index. Writes up to `max_samples` float + * values into `samples`. Returns the number of samples written, or + * OAKENGINE_E_INVALID on bad arguments. + */ +OAKENGINE_API int oakengine_preview_request_get_audio_samples( + OakEnginePreviewRequest *req, int channel, const float *samples, + int max_samples); + +/** @brief Free a preview request handle (NULL-safe). */ +OAKENGINE_API void oakengine_preview_request_free( + OakEnginePreviewRequest *req); + #ifdef __cplusplus } #endif diff --git a/engine/include/oakengine/project.h b/engine/include/oakengine/project.h index 758e2d7ef..f38c3eec9 100644 --- a/engine/include/oakengine/project.h +++ b/engine/include/oakengine/project.h @@ -24,6 +24,12 @@ #include "export.h" #include "init.h" +/* Forward declarations from node.h (included by callers in either order). */ +typedef struct OakEngineNode OakEngineNode; + +/* Forward declaration for playback cache from viewer.h. */ +typedef struct OakEnginePlaybackCache OakEnginePlaybackCache; + #ifdef __cplusplus extern "C" { #endif @@ -195,6 +201,127 @@ oakengine_project_sequence_count(const OakEngineProject *self); OAKENGINE_API OakEngineSequence * oakengine_project_sequence_at(const OakEngineProject *self, int index); +/* ---- Folder operations ---------------------------------------------------- */ + +/** + * @brief Create a folder node named `name` under `parent` in `project`. + * Returns a borrowed handle, or NULL on failure. + */ +OAKENGINE_API OakEngineNode *oakengine_folder_create(OakEngineProject *project, + OakEngineNode *parent, + const char *name); + +/** + * @brief 1 if `folder` recursively contains `child`, 0 otherwise. + * 0 when either handle is NULL or `folder` is not a Folder. + */ +OAKENGINE_API int oakengine_folder_has_child_recursive( + const OakEngineNode *folder, const OakEngineNode *child); + +/** + * @brief Index of `child` in `folder`'s direct children, or + * OAKENGINE_E_NOT_FOUND. Returns OAKENGINE_E_INVALID when + * `folder` is not a Folder node. + */ +OAKENGINE_API int oakengine_folder_index_of_child( + const OakEngineNode *folder, const OakEngineNode *child); + +/** + * @brief Static input key string for Folder children (Folder::k_child_input). + * Never freed. + */ +OAKENGINE_API const char *oakengine_folder_child_input_key(void); + +/** + * @brief Add `child` to `folder` (undoable). OAKENGINE_E_INVALID when + * `folder` is not a Folder node or on NULL args. + */ +OAKENGINE_API int oakengine_folder_add_child(OakEngineNode *folder, + OakEngineNode *child); + +/** + * @brief Move `node` from its current folder to `new_folder` (undoable). + * Removes the node from its old folder first — a true move, not a copy. + * Returns OAKENGINE_OK or a negative error code. + */ +OAKENGINE_API int oakengine_folder_move_child(OakEngineNode *node, + OakEngineNode *new_folder); + +/** + * @brief Create a Folder::RemoveElementCommand as an opaque command pointer. + * Returns NULL on invalid arguments. + */ +OAKENGINE_API void *oakengine_folder_remove_element_command( + OakEngineNode *folder, OakEngineNode *child); + +/** + * @brief Move several nodes into `dest_folder` as ONE undoable command + * (each node is removed from its old folder, then added to `dest_folder`). + * Nodes already directly inside `dest_folder` are skipped. `undo_name` + * may be NULL. Returns OAKENGINE_OK or a negative error code. + */ +OAKENGINE_API int oakengine_folder_move_children( + OakEngineNode *const *nodes, int count, OakEngineNode *dest_folder, + const char *undo_name); + +/* ---- Project extras ------------------------------------------------------- */ + +/** @brief Root folder node of the project (Project::root()). */ +OAKENGINE_API OakEngineNode *oakengine_project_root(OakEngineProject *self); + +/** @brief Display name for the project that is safe for window titles + * (Project::pretty_filename()). buf/size convention. */ +OAKENGINE_API int oakengine_project_pretty_filename(const OakEngineProject *self, + char *buf, int buf_size); + +/** @brief Set the project's filename (Project::set_filename()). + * Returns OAKENGINE_OK or OAKENGINE_E_INVALID on NULL. */ +OAKENGINE_API int oakengine_project_set_filename(OakEngineProject *self, + const char *path); + +/** @brief The project's default cache directory (Project::cache_path()). + * buf/size convention. */ +OAKENGINE_API int oakengine_project_cache_path(const OakEngineProject *self, + char *buf, int buf_size); + +/** @brief The project's alongside cache directory + * (Project::cache_alongside_path()). buf/size convention. */ +OAKENGINE_API int oakengine_project_cache_alongside_path( + const OakEngineProject *self, char *buf, int buf_size); + +/** @brief Set a custom cache directory path (Project::set_custom_cache_path()). + * NULL clears it. */ +OAKENGINE_API int oakengine_project_set_custom_cache_path( + OakEngineProject *self, const char *path); + +/** @brief Get the custom cache directory path, or "" when none is set. + * buf/size convention; returns 0 when no custom path is set. */ +OAKENGINE_API int oakengine_project_get_custom_cache_path( + const OakEngineProject *self, char *buf, int buf_size); + +/** @brief Cache location setting enum value + * (Project::get_cache_location_setting()). Returns < 0 on NULL. */ +OAKENGINE_API int oakengine_project_get_cache_location_setting( + const OakEngineProject *self); + +/** @brief Static MIME type string for project items (Project::item_mime_type()). + * Never freed. */ +OAKENGINE_API const char *oakengine_project_item_mime_type(void); + +/** @brief Resolve a project node to its owning OakEngineProject + * (Project::get_project_from_object()). Returns NULL when the node is + * not part of a project or on NULL input. */ +OAKENGINE_API OakEngineProject * +oakengine_project_from_object(const OakEngineNode *node); + +/** @brief Get the project's color reference space name (buf/size). */ +OAKENGINE_API int oakengine_project_get_color_reference_space( + const OakEngineProject *self, char *buf, int buf_size); + +/** @brief Set the project's color reference space (undoable). */ +OAKENGINE_API int oakengine_project_set_color_reference_space( + OakEngineProject *self, const char *colorspace); + #ifdef __cplusplus } #endif diff --git a/engine/include/oakengine/proxy.h b/engine/include/oakengine/proxy.h new file mode 100644 index 000000000..fc569fc65 --- /dev/null +++ b/engine/include/oakengine/proxy.h @@ -0,0 +1,131 @@ +/*** + + 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_PROXY_H +#define OAKENGINE_PROXY_H + +#include + +#include "export.h" +#include "footage.h" +#include "init.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file proxy.h + * @brief C ABI for the engine's proxy generation singleton (olive::ProxyManager) + * + * A thin facade over ProxyManager's instance lifecycle, proxy parameter + * configuration, proxy state queries and proxy generation. The opaque task + * handle returned in oak_proxy_result::task is a borrowed pointer to the + * engine's internal ProxyTask; it is intended only for logging and becomes + * invalid when the proxy operation finishes. + * + * Conventions match the other facade families: + * - 0 (OAKENGINE_OK) / negative OAKENGINE_E_* codes. + * - String output uses the buf/size convention. + * - Booleans are int (1/0). + */ + +#define OAKENGINE_PROXY_STATE_MISSING 0 +#define OAKENGINE_PROXY_STATE_GENERATING 1 +#define OAKENGINE_PROXY_STATE_READY 2 +#define OAKENGINE_PROXY_STATE_FAILED 3 + +typedef struct oak_proxy_result { + int state; /**< OAKENGINE_PROXY_STATE_* */ + char filename[1024]; + int64_t task; /**< ProxyTask* as opaque handle, or 0 if none */ +} oak_proxy_result; + +/** + * @brief Create the ProxyManager singleton. + * + * Safe to call when the instance already exists (no-op). Returns + * OAKENGINE_OK or OAKENGINE_E_FAILED. + */ +OAKENGINE_API int oakengine_proxy_create_instance(void); + +/** + * @brief Destroy the ProxyManager singleton. + * + * Safe to call when no instance exists (no-op). Returns OAKENGINE_OK. + */ +OAKENGINE_API int oakengine_proxy_destroy_instance(void); + +/** + * @brief Build proxy parameters from the global application config. + * + * Fills `out` with the configured width/height/divider/version/crf/extension + * /preset/include_audio values. Returns OAKENGINE_OK or an error code. + */ +OAKENGINE_API int oakengine_proxy_params_from_config(oak_proxy_params *out); + +/** + * @brief Query the state of a proxy file on disk. + * + * Returns one of the OAKENGINE_PROXY_STATE_* values, or + * OAKENGINE_PROXY_STATE_MISSING if `proxy_filename` is NULL/empty or the + * proxy does not exist. + */ +OAKENGINE_API int oakengine_proxy_get_state(const char *proxy_filename); + +/** + * @brief Human-readable string for a proxy state (buf/size convention). + * + * Returns the string length on success, or a negative OAKENGINE_E_* code for + * an unknown state. + */ +OAKENGINE_API int oakengine_proxy_state_to_string(int state, char *buf, + int buf_size); + +/** + * @brief Get or start generating a proxy for `source_filename`. + * + * `cache_path` is the project cache directory. `stream_index` is the source + * stream to proxy. `params` are the proxy generation parameters (width/height + * etc.). On return `out->state` and `out->filename` describe the proxy; if a + * generation task was started, `out->task` is a borrowed opaque handle to it, + * otherwise it is 0. + */ +OAKENGINE_API int oakengine_proxy_get_or_start(const char *cache_path, + const char *source_filename, + int stream_index, + const oak_proxy_params *params, + oak_proxy_result *out); + +/** + * @brief Get the "working" filename for a proxy file (buf/size convention). + * + * The working filename is used by the proxy generator while the proxy is being + * generated. Returns the string length on success, or a negative + * OAKENGINE_E_* code on error. + */ +OAKENGINE_API int oakengine_proxy_get_working_filename(const char *proxy_filename, + char *buf, int buf_size); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_PROXY_H */ diff --git a/engine/include/oakengine/renderer.h b/engine/include/oakengine/renderer.h index 7aaca40e9..67af56135 100644 --- a/engine/include/oakengine/renderer.h +++ b/engine/include/oakengine/renderer.h @@ -87,6 +87,45 @@ typedef struct OakEngineFrame OakEngineFrame; */ typedef struct OakEngineAudioBuffer OakEngineAudioBuffer; +/** + * @brief Set aggressive garbage collection on the render manager + * (RenderManager::set_aggressive_garbage_collection()). Returns + * OAKENGINE_E_STATE when the render manager is not available. + */ +OAKENGINE_API int +oakengine_render_manager_set_aggressive_garbage_collection(int aggressive); + +/** + * @brief The render backend that was requested (RenderManager::requested_backend()). + * Returns 0 (k_open_gl) when the render manager is not available. + */ +OAKENGINE_API int oakengine_render_manager_requested_backend(void); + +/** + * @brief Convert a render backend enum value to a human-readable string + * (RenderManager::backend_to_string()). buf/size convention. Returns the + * would-be length or a negative error code. + */ +OAKENGINE_API int oakengine_render_manager_backend_to_string(int backend, + char *buf, + int buf_size); + +/** + * @brief Set the display color processor on the render manager's cacher. + * `processor` is a borrowed OakEngineColorProcessor handle (NULL to clear). + * Returns OAKENGINE_OK or OAKENGINE_E_STATE. + */ +OAKENGINE_API int oakengine_render_cache_set_display_color_processor( + void *processor); + +/** + * @brief Set the multicam node on the render manager's cacher. + * `node` is a borrowed OakEngineNode handle (NULL to clear). + * Returns OAKENGINE_OK or OAKENGINE_E_STATE. + */ +OAKENGINE_API int oakengine_render_cache_set_multicam_node( + OakEngineNode *node); + /** * @brief Create a renderer for `seq` producing `width`x`height` frames of * `pixel_format` at the given frame rate. diff --git a/engine/include/oakengine/serializer.h b/engine/include/oakengine/serializer.h new file mode 100644 index 000000000..e12bc1bcd --- /dev/null +++ b/engine/include/oakengine/serializer.h @@ -0,0 +1,254 @@ +/*** + + 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_SERIALIZER_H +#define OAKENGINE_SERIALIZER_H + +#include "export.h" +#include "init.h" +#include "node.h" +#include "project.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file serializer.h + * @brief C ABI for project serialization / copy-paste (olive::ProjectSerializer) + * + * A thin facade over ProjectSerializer's load/save/copy/paste primitives. The + * opaque OakEngineClipboard handle bundles a SaveData object (for copy/save) + * or the LoadData result of the last paste operation. Clipboard handles are + * owned by the caller and must be released with oakengine_clipboard_free(). + * + * Conventions match the other facade families: + * - 0 (OAKENGINE_OK) / negative OAKENGINE_E_* codes. + * - String output uses the buf/size convention. + */ + +/** @brief Opaque clipboard context. */ +typedef struct OakEngineClipboard OakEngineClipboard; + +/** @brief Marker handle (defined in oakengine/timeline.h). */ +typedef struct OakEngineMarker OakEngineMarker; + +#define OAKENGINE_CLIPBOARD_PROJECT 0 +#define OAKENGINE_CLIPBOARD_NODES 1 +#define OAKENGINE_CLIPBOARD_CLIPS 2 +#define OAKENGINE_CLIPBOARD_MARKERS 3 +#define OAKENGINE_CLIPBOARD_KEYFRAMES 4 + +#define OAKENGINE_SERIALIZER_OK 0 +#define OAKENGINE_SERIALIZER_TOO_OLD 1 +#define OAKENGINE_SERIALIZER_TOO_NEW 2 +#define OAKENGINE_SERIALIZER_UNKNOWN_VERSION 3 +#define OAKENGINE_SERIALIZER_FILE_ERROR 4 +#define OAKENGINE_SERIALIZER_XML_ERROR 5 +#define OAKENGINE_SERIALIZER_OVERWRITE_ERROR 6 +#define OAKENGINE_SERIALIZER_NO_DATA 7 + +/** + * @brief Returns 1 if `filename` is a compressed project file, 0 otherwise. + */ +OAKENGINE_API int oakengine_serializer_check_compressed(const char *filename); + +/** + * @brief Create a clipboard context for copy/save operations. + * + * `load_type` is one of OAKENGINE_CLIPBOARD_*. `project` may be NULL for + * load types that do not require it. `filename` may be NULL. + */ +OAKENGINE_API OakEngineClipboard *oakengine_clipboard_create( + int load_type, OakEngineProject *project, const char *filename); + +/** + * @brief Set the nodes to serialize on this clipboard. + * + * Replaces any previously set nodes. `nodes` is an array of `count` borrowed + * OakEngineNode handles. Returns OAKENGINE_OK or an error code. + */ +OAKENGINE_API int oakengine_clipboard_set_nodes(OakEngineClipboard *cb, + const OakEngineNode *const *nodes, + int count); + +/** + * @brief Set the markers to serialize on this clipboard. + */ +OAKENGINE_API int oakengine_clipboard_set_markers( + OakEngineClipboard *cb, const OakEngineMarker *const *markers, int count); + +/** + * @brief Set the keyframes to serialize on this clipboard. + */ +OAKENGINE_API int oakengine_clipboard_set_keyframes( + OakEngineClipboard *cb, const OakEngineKeyframe *const *keyframes, + int count); + +/** + * @brief Set a serialized property attached to a node. + * + * Properties are free-form (key, value) strings attached to pasted nodes; the + * editor uses them for clip in-points/track-refs and node graph positions. + * Replaces the value if the same (node, key) pair is set twice. Returns + * OAKENGINE_OK or an error code. + */ +OAKENGINE_API int oakengine_clipboard_set_property(OakEngineClipboard *cb, + OakEngineNode *node, + const char *key, + const char *value); + +/** + * @brief Copy this clipboard's data to the system clipboard. + * + * Returns OAKENGINE_OK or an error code. + */ +OAKENGINE_API int oakengine_clipboard_copy(OakEngineClipboard *cb); + +/** + * @brief Serialize this clipboard's data to XML (buf/size convention). + * + * Returns the string length on success, or a negative OAKENGINE_E_* code on + * error. + */ +OAKENGINE_API int oakengine_clipboard_save_to_xml(OakEngineClipboard *cb, + char *buf, int buf_size); + +/** + * @brief Paste data from the system clipboard into `project`. + * + * `load_type` selects what kind of data to paste. On success `*result_code` + * receives OAKENGINE_SERIALIZER_OK and the clipboard is populated with the + * paste result (accessible through the oakengine_clipboard_get_loaded_* + * accessors). On failure `*result_code` receives one of the + * OAKENGINE_SERIALIZER_* error codes and a human-readable detail string is + * written to `details_buf` (may be NULL). Returns OAKENGINE_OK on success or + * an error code. + */ +OAKENGINE_API int oakengine_clipboard_paste(OakEngineClipboard *cb, + int load_type, + OakEngineProject *project, + int *result_code, + char *details_buf, + int details_buf_size); + +/** + * @brief Paste data from the system clipboard, invoking `map_fn` for each + * original->new node mapping. + * + * The callback is called once per (original node pointer, pasted node pointer) + * pair found in the paste result. The app can use it to build an existing-node + * map without exposing C++ containers across the boundary. Returning non-zero + * from the callback stops iteration early. Other semantics match + * oakengine_clipboard_paste(). + */ +OAKENGINE_API int oakengine_clipboard_paste_with_map( + OakEngineClipboard *cb, int load_type, OakEngineProject *project, + int (*map_fn)(OakEngineNode *old, OakEngineNode *new_node, void *userdata), + void *userdata, int *result_code, char *details_buf, + int details_buf_size); + +/** + * @brief Destroy a clipboard context. + */ +OAKENGINE_API void oakengine_clipboard_free(OakEngineClipboard *cb); + +/* ---- Paste result accessors (valid after a successful paste) -------------- */ + +/** + * @brief Number of nodes loaded by the last paste operation. + */ +OAKENGINE_API int oakengine_clipboard_get_loaded_node_count( + OakEngineClipboard *cb); + +/** + * @brief Borrowed node handle loaded at `index`. + */ +OAKENGINE_API OakEngineNode *oakengine_clipboard_get_loaded_node_at( + OakEngineClipboard *cb, int index); + +/** + * @brief Number of markers loaded by the last paste operation. + */ +OAKENGINE_API int oakengine_clipboard_get_loaded_marker_count( + OakEngineClipboard *cb); + +/** + * @brief Borrowed marker handle loaded at `index`. + */ +OAKENGINE_API OakEngineMarker *oakengine_clipboard_get_loaded_marker_at( + OakEngineClipboard *cb, int index); + +/** + * @brief Number of keyframes loaded by the last paste operation. + */ +OAKENGINE_API int oakengine_clipboard_get_loaded_keyframe_count( + OakEngineClipboard *cb); + +/** + * @brief Borrowed keyframe handle loaded at `index`. + */ +OAKENGINE_API OakEngineKeyframe *oakengine_clipboard_get_loaded_keyframe_at( + OakEngineClipboard *cb, int index); + +/** + * @brief Iterate over the serialized properties attached to pasted nodes. + * + * For each (node, key, value) triple `fn` is called. Returning non-zero stops + * iteration early. Returns OAKENGINE_OK or an error code. + */ +OAKENGINE_API int oakengine_clipboard_foreach_property( + OakEngineClipboard *cb, + int (*fn)(OakEngineNode *node, const char *key, const char *value, + void *userdata), + void *userdata); + +/** + * @brief Iterate over keyframes loaded by the last paste operation. + * + * For each keyframe `fn` is called with the node id string it belongs to and + * the keyframe handle. Returning non-zero stops iteration early. The app can + * group keyframes by node id and route them to the correct destination node. + * Returns OAKENGINE_OK or an error code. + */ +OAKENGINE_API int oakengine_clipboard_foreach_keyframe( + OakEngineClipboard *cb, + int (*fn)(const char *node_id, OakEngineKeyframe *keyframe, + void *userdata), + void *userdata); + +/** + * @brief Iterate over promised connections from the paste result. + * + * For each promised edge `fn` is called with the output node, input node, + * input id and element index. Returning non-zero stops iteration early. + * Returns OAKENGINE_OK or an error code. + */ +OAKENGINE_API int oakengine_clipboard_foreach_connection( + OakEngineClipboard *cb, + int (*fn)(OakEngineNode *output_node, OakEngineNode *input_node, + const char *input_id, int element, void *userdata), + void *userdata); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_SERIALIZER_H */ diff --git a/engine/include/oakengine/sync.h b/engine/include/oakengine/sync.h new file mode 100644 index 000000000..7a2104fdd --- /dev/null +++ b/engine/include/oakengine/sync.h @@ -0,0 +1,137 @@ +/*** + + 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_SYNC_H +#define OAKENGINE_SYNC_H + +#include "export.h" +#include "timeline.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file sync.h + * @brief C ABI for waveform-based audio synchronization estimation + * + * Wraps the engine's AudioWaveformSync estimators + * (engine/audio/audiowaveformsync.h): the full audio of two clips is + * rendered through the renderer family and cross-correlated, yielding + * the time offset that aligns the target clip with the reference clip + * (the application's timeline "synchronize clips by waveform" feature). + * + * Both estimators validate first and change nothing on failure (these + * are pure measurements). They return OAKENGINE_OK when the correlation + * is conclusive (OffsetResult::valid), OAKENGINE_E_STATE when it is + * inconclusive -- in that case `out_confidence` is still written so the + * caller can compare it against a fallback estimator, and the offset + * outputs are set to 0 / the stretch output to 1. OAKENGINE_E_INVALID + * covers NULL handles, clips without an on-track range, and sequences + * without audio; estimation itself requires the engine initialized + * with OAKENGINE_INIT_RENDER (OAKENGINE_E_STATE as well). + * + * Note this family renders audio freshly per call (no waveform-cache + * dependency); the application keeps its cache-envelope path for the + * envelope source and uses these functions for the estimation step. + * Errors follow the family model: per-thread human-readable reason via + * oakengine_sync_last_error(). + */ + +/** + * @brief Estimate the time offset aligning `target` to `reference` + * (AudioWaveformSync::estimate_envelope_offset). + * + * `out_offset_seconds` receives the signed offset in seconds: the + * shift to ADD to the target's timeline position so it aligns with the + * reference (negative = move the target earlier -- e.g. the + * application's AudioSynchronizer adds it to the reference in-point). + * The estimate is quantized to the RMS envelope window + * (sample_rate/20 seconds), so callers should expect up to one window + * of quantization error. `out_confidence` receives the correlation + * confidence in [0, 1] and is always written. Any output pointer may + * be NULL. Search bounds mirror the application (sample_rate/20 + * window, 10-minute maximum offset). + */ +OAKENGINE_API int oakengine_sync_estimate_offset( + OakEngineSequence *seq, OakEngineClip *reference, OakEngineClip *target, + double *out_offset_seconds, double *out_confidence); + +/** + * @brief Estimate a playback-rate change plus offset aligning `target` + * to `reference` (AudioWaveformSync::estimate_stretch_and_offset). + * + * `out_stretch` receives the rate the target must be played at to + * align (> 1 = the target runs slower and must be sped up; the search + * range mirrors the application: 0.75..1.34 in 0.005 steps, 30-second + * offset radius). `out_offset_seconds` and `out_confidence` behave + * like oakengine_sync_estimate_offset(). Any output pointer may be + * NULL. + */ +OAKENGINE_API int oakengine_sync_estimate_stretch_offset( + OakEngineSequence *seq, OakEngineClip *reference, OakEngineClip *target, + double *out_stretch, double *out_offset_seconds, + double *out_confidence); + +/** + * @brief Human-readable reason for the last failed sync call on this + * thread (buf/size convention). Empty when the last call succeeded. + */ +OAKENGINE_API int oakengine_sync_last_error(char *buf, int buf_size); + +/* ---- Place by source time / waveform offset (replaces AudioSynchronizer) - */ + +/** @brief POD for sync placement result (timeline_in rational). */ +typedef struct oak_sync_placement { + int64_t timeline_in_num; + int64_t timeline_in_den; +} oak_sync_placement; + +/** + * @brief Place a clip by source time (AudioSynchronizer::place_by_source_time). + * + * Computes: timeline_in = anchor_in + (cand_source_start + cand_media_in) + * - (ref_source_start + ref_media_in) + * Returns OAKENGINE_OK and fills `out`, or OAKENGINE_E_INVALID on NaN input. + */ +OAKENGINE_API int oakengine_sync_place_by_source_time( + int64_t ref_source_start_num, int64_t ref_source_start_den, + int64_t ref_media_in_num, int64_t ref_media_in_den, + int64_t cand_source_start_num, int64_t cand_source_start_den, + int64_t cand_media_in_num, int64_t cand_media_in_den, + int64_t anchor_num, int64_t anchor_den, + oak_sync_placement *out); + +/** + * @brief Place a clip by waveform offset (AudioSynchronizer::place_by_waveform_offset). + * + * Computes: timeline_in = ref_timeline_in + candidate_offset_samples / sample_rate + * Returns OAKENGINE_OK and fills `out`, or OAKENGINE_E_INVALID when sample_rate <= 0. + */ +OAKENGINE_API int oakengine_sync_place_by_waveform_offset( + int64_t ref_timeline_in_num, int64_t ref_timeline_in_den, + int64_t candidate_offset_samples, int sample_rate, + oak_sync_placement *out); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_SYNC_H */ diff --git a/engine/include/oakengine/task.h b/engine/include/oakengine/task.h new file mode 100644 index 000000000..9310177fb --- /dev/null +++ b/engine/include/oakengine/task.h @@ -0,0 +1,293 @@ +/*** + + 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_TASK_H +#define OAKENGINE_TASK_H + +#include + +#include "encoding.h" +#include "init.h" +#include "node.h" +#include "project.h" +#include "timeline.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file task.h + * @brief C ABI for the engine background-task system (olive::Task / + * olive::TaskManager) + * + * Tasks are engine objects that run a job (project load/save, footage + * import, proxy generation, export) on a worker thread. This family lets + * C consumers create the concrete task they need, run it synchronously or + * hand it to the global TaskManager queue, and observe its lifecycle + * through the event mechanism (oakengine/events.h, task family + * OAKENGINE_EVENT_TASK_* and manager family + * OAKENGINE_EVENT_TASK_MANAGER_*), without ever seeing the C++ classes. + * + * Conventions (matching oakengine/project.h): + * - OakEngineTask is an opaque borrowed/owned pointer to an + * olive::Task subclass. + * - A task returned by an oakengine_task_create_*() function is OWNED by + * the caller until either oakengine_task_manager_add() (the manager + * takes ownership and deletes the task when done) or + * oakengine_task_free() (the caller deletes it). A task that ran via + * oakengine_task_start_sync() is still owned by the caller and must be + * released with oakengine_task_free() (or handed to the manager, + * though re-running is unusual). + * - Once a task was added to the manager its handle must be treated as + * borrowed: the manager may delete it at any time after the + * OAKENGINE_EVENT_TASK_MANAGER_TASK_REMOVED notification. + * - Return codes: 0 (OAKENGINE_OK) on success, negative OAKENGINE_E_* + * on failure. String output uses the buf/size convention (return value + * is the length that would have been written excluding the NUL; a + * negative value is an OAKENGINE_E_* error). + */ + +/** + * @brief Opaque task handle (an olive::Task subclass instance). + */ +typedef struct OakEngineTask OakEngineTask; + +/* ---- Global task manager ------------------------------------------------- */ + +/** + * @brief Borrowed handle of the global TaskManager singleton, for use as + * the subscription handle of the OAKENGINE_EVENT_TASK_MANAGER_* events. + * Returns NULL when the engine is not initialized. + */ +OAKENGINE_API void *oakengine_task_manager_handle(void); + +/** + * @brief Number of tasks currently known to the manager (running plus + * failed-but-kept), or OAKENGINE_E_INVALID when no manager exists. + */ +OAKENGINE_API int oakengine_task_manager_count(void); + +/** + * @brief Borrowed handle of an arbitrary running task (the manager's + * "first" task, used by the status bar), or NULL when the queue is empty + * or no manager exists. + */ +OAKENGINE_API OakEngineTask *oakengine_task_manager_first(void); + +/** + * @brief Hand `task` to the global manager queue (takes ownership). The + * task starts as soon as a worker thread is available. + * + * @return OAKENGINE_OK, OAKENGINE_E_INVALID for NULL, OAKENGINE_E_STATE + * when no manager exists. + */ +OAKENGINE_API int oakengine_task_manager_add(OakEngineTask *task); + +/** + * @brief Ask the manager to cancel `task` (TaskManager::cancel_task + * semantics: a running task is signalled; a failed-but-kept task is + * removed and deleted). + */ +OAKENGINE_API int oakengine_task_manager_cancel(OakEngineTask *task); + +/* ---- Task accessors ------------------------------------------------------ */ + +/** + * @brief Title of `task` (buf/size convention). + */ +OAKENGINE_API int oakengine_task_title(OakEngineTask *task, char *buf, + int buf_size); + +/** + * @brief Error message of `task` (buf/size convention). Meaningful after a + * failed run. + */ +OAKENGINE_API int oakengine_task_error(OakEngineTask *task, char *buf, + int buf_size); + +/** + * @brief Start timestamp of `task` (milliseconds since epoch), 0 when the + * task never started, OAKENGINE_E_INVALID for NULL. + */ +OAKENGINE_API int64_t oakengine_task_start_time(OakEngineTask *task); + +/** + * @brief 1 when `task` was asked to cancel, 0 otherwise, + * OAKENGINE_E_INVALID for NULL. + */ +OAKENGINE_API int oakengine_task_is_cancelled(OakEngineTask *task); + +/** + * @brief Signal `task` to cancel as soon as possible (Task::Cancel). + */ +OAKENGINE_API int oakengine_task_cancel(OakEngineTask *task); + +/** + * @brief Run `task` synchronously on the CALLING thread (Task::start). + * Emits the task events on this thread. Ownership stays with the caller. + * + * @return 1 when the task succeeded, 0 when it failed or was cancelled + * (read oakengine_task_error()), OAKENGINE_E_INVALID for NULL. + */ +OAKENGINE_API int oakengine_task_start_sync(OakEngineTask *task); + +/** + * @brief Delete a task that was never added to the manager. + */ +OAKENGINE_API int oakengine_task_free(OakEngineTask *task); + +/** + * @brief Run `task` through the engine's CLI modal progress dialog and return + * 1 when it succeeds, 0 when it fails or is cancelled. + * + * The dialog shows the task's title and progress on the terminal. `parent` + * is an optional QObject parent (may be NULL). The task is started + * synchronously; the caller retains ownership and must free it with + * oakengine_task_free() when done. + */ +OAKENGINE_API int oakengine_cli_task_dialog_run(OakEngineTask *task, + void *parent_or_NULL); + +/* ---- Task creators -------------------------------------------------------- + * + * All creators return an OWNED task (NULL on invalid input). The task is + * not started by creation. + */ + +/** + * @brief Task that loads an OVE project from `filename`. + */ +OAKENGINE_API OakEngineTask * +oakengine_task_create_project_load(const char *filename); + +/** + * @brief Task that loads an OpenTimelineIO project from `filename`. + * Returns NULL when the engine was built without OTIO support. + */ +OAKENGINE_API OakEngineTask * +oakengine_task_create_project_load_otio(const char *filename); + +/** + * @brief Task that saves `project` (ProjectSaveTask semantics). + * + * `use_compression` selects the compressed .ove writer (0 writes the + * uncompressed .ovexml form). `override_filename` may be NULL to save to + * the project's own filename. `layout` is an opaque + * `const olive::SerializedLayoutInfo *` (may be NULL) whose contents are + * copied into the saved file. + */ +OAKENGINE_API OakEngineTask *oakengine_task_create_project_save( + OakEngineProject *project, int use_compression, + const char *override_filename, const void *layout); + +/** + * @brief Task that saves `project` in OpenTimelineIO format. Returns NULL + * when the engine was built without OTIO support. + */ +OAKENGINE_API OakEngineTask * +oakengine_task_create_project_save_otio(OakEngineProject *project); + +/** + * @brief Task that imports `url_count` media files into `folder` (a folder + * node of the target project; use oakengine_project_root() for the top + * level). The URL array is copied during the call. + */ +OAKENGINE_API OakEngineTask *oakengine_task_create_project_import( + OakEngineNode *folder, const char **urls, int url_count); + +/** + * @brief Task that generates the proxy media for `footage` (a footage node + * handle, as accepted by oakengine_footage_borrow(); the task keeps the + * underlying node). + */ +OAKENGINE_API OakEngineTask * +oakengine_task_create_proxy(OakEngineNode *footage); + +/** + * @brief Task that renders an export of `sequence` with `params`. + * + * Takes ownership of `params` (destroyed with the task). Progress is + * reported through the OAKENGINE_EVENT_TASK_PROGRESS event; cancelling + * the task cancels the engine export render. + */ +OAKENGINE_API OakEngineTask *oakengine_task_create_export( + OakEngineSequence *sequence, OakEngineEncodingParams *params); + +/* ---- Import task results -------------------------------------------------- + * + * Valid on a task created by oakengine_task_create_project_import() after + * it ran; all return OAKENGINE_E_INVALID (or 0/NULL) for other tasks. + */ + +/** + * @brief Number of files the import task will process (valid right after + * creation; 0 means "nothing to import" and the task should be freed + * instead of run). + */ +OAKENGINE_API int oakengine_task_import_file_count(OakEngineTask *task); + +/** + * @brief The undo command built by a successful import run as an opaque + * `olive::MultiUndoCommand *` (NULL before the run, after a cancelled + * run, or on a second call). Ownership is DETACHED from the task and + * passes to the caller: push it with oakengine_undo_push() or delete it. + */ +OAKENGINE_API void *oakengine_task_import_get_command(OakEngineTask *task); + +/** + * @brief Number of footage items a successful import run created. + */ +OAKENGINE_API int oakengine_task_import_footage_count(OakEngineTask *task); + +/** + * @brief Borrowed node handle of the imported footage item at `index` + * (NULL when out of range). + */ +OAKENGINE_API OakEngineNode * +oakengine_task_import_footage_at(OakEngineTask *task, int index); + +/** + * @brief Number of files the import task rejected. + */ +OAKENGINE_API int +oakengine_task_import_invalid_files_count(OakEngineTask *task); + +/** + * @brief Rejected file path at `index` (buf/size convention). + */ +OAKENGINE_API int oakengine_task_import_invalid_file_at(OakEngineTask *task, + int index, char *buf, + int buf_size); + +/* ---- Save task results ---------------------------------------------------- */ + +/** + * @brief Borrowed handle of the project a save task wrote (NULL for other + * tasks). + */ +OAKENGINE_API OakEngineProject * +oakengine_task_save_get_project(OakEngineTask *task); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_TASK_H */ diff --git a/engine/include/oakengine/timeline.h b/engine/include/oakengine/timeline.h index 559e8b7f4..e09303d5d 100644 --- a/engine/include/oakengine/timeline.h +++ b/engine/include/oakengine/timeline.h @@ -301,6 +301,48 @@ OAKENGINE_API int oakengine_sequence_marker_at(const OakEngineSequence *self, */ typedef struct OakEngineClip OakEngineClip; +/** + * @brief Opaque marker list handle (olive::TimelineMarkerList). + * + * Borrowed from oakengine_viewer_get_marker_list(). Invalidated when the + * owning viewer is freed. + */ +typedef struct OakEngineMarkerList OakEngineMarkerList; + +/** + * @brief Opaque marker handle (olive::TimelineMarker). + * + * Borrowed from oakengine_marker_list_at() / oakengine_marker_list_marker_at_time(). + * Invalidated when the owning project is freed. + */ +typedef struct OakEngineMarker OakEngineMarker; + +/** + * @brief Opaque workarea handle (olive::TimelineWorkArea). + * + * Borrowed from oakengine_viewer_get_workarea_handle() or created standalone + * with oakengine_workarea_create(). Must be freed with oakengine_workarea_free() + * when created standalone; borrowed handles are invalidated with their owner. + */ +typedef struct OakEngineWorkarea OakEngineWorkarea; + +/** + * @brief Opaque track handle (olive::Track). + * + * Borrowed from oakengine_sequence_track_at(). Invalidated when the owning + * sequence is freed. + */ +typedef struct OakEngineTrack OakEngineTrack; + +/** + * @brief Opaque block handle (olive::Block). + * + * A generic block on a track (ClipBlock, GapBlock, TransitionBlock, etc). + * Borrowed from events or cast from OakEngineClip* / OakEngineTrack*. The + * handle is invalidated when the owning project is freed. + */ +typedef struct OakEngineBlock OakEngineBlock; + /** * @brief Human-readable reason for the last failed editing call on this * thread (buf/size convention). Editing calls return NULL or a negative @@ -322,6 +364,41 @@ OAKENGINE_API int oakengine_sequence_last_error(char *buf, int buf_size); OAKENGINE_API int oakengine_sequence_add_track(OakEngineSequence *self, int track_type); +/** + * @brief Create a TimelineAddTrackCommand as an opaque command pointer without + * executing or pushing it. If `out_track` is non-NULL, it receives a borrowed + * handle to the track that the command will create on redo. + */ +OAKENGINE_API void *oakengine_sequence_add_track_command( + OakEngineSequence *self, int track_type, int auto_merge, + OakEngineTrack **out_track); + +/** Movement modes for ripple/trim commands (mirror olive::Timeline::MovementMode). */ +#define OAKENGINE_MOVEMENT_MODE_NONE 0 +#define OAKENGINE_MOVEMENT_MODE_MOVE 1 +#define OAKENGINE_MOVEMENT_MODE_TRIM_IN 2 +#define OAKENGINE_MOVEMENT_MODE_TRIM_OUT 3 + +/** + * @brief One entry in a TrackListRippleToolCommand hash: the track to ripple, + * the block being moved, and whether a gap should be appended after it. + */ +typedef struct oakengine_ripple_info { + OakEngineTrack *track; + OakEngineBlock *block; + int append_gap; +} oakengine_ripple_info; + +/** + * @brief Create a TrackListRippleToolCommand as an opaque command pointer. + * `infos` holds one entry per affected track; `movement` is a rational offset + * in seconds. Returns NULL on invalid arguments. + */ +OAKENGINE_API void *oakengine_sequence_ripple_tracks_command( + OakEngineSequence *self, int track_type, + const oakengine_ripple_info *infos, int info_count, + int64_t movement_num, int64_t movement_den, int movement_mode); + /** * @brief Place a clip of `footage` on a track (undoable). * @@ -374,6 +451,15 @@ OAKENGINE_API int oakengine_clip_get_range(const OakEngineClip *self, int64_t *in, int64_t *out, int64_t *media_in); +/** + * @brief The sequence that owns the clip's track. + * + * Returns a borrowed handle (the clip's track's parent sequence) or NULL if + * the clip is not on a track. + */ +OAKENGINE_API OakEngineSequence *oakengine_clip_get_sequence( + const OakEngineClip *self); + /* ---- Editing primitives, round 2: split / ripple delete / trim / move ---- * * All four are undoable like the other editing primitives and report @@ -712,6 +798,384 @@ OAKENGINE_API int oakengine_sequence_marker_rename(OakEngineSequence *seq, int64_t time_ts, const char *name); +/* ---- Marker handle family ---------------------------------------------------- + * + * Marker list and individual marker operations on opaque handles. The list + * is obtained from oakengine_viewer_get_marker_list() (declared in viewer.h). + * These functions operate on the handle level rather than through the sequence, + * for fine-grained undo/redo and direct marker manipulation. + * + * All times are rational seconds (numerator/denominator pairs) matching the + * engine's internal time representation. + */ + +/** @brief Number of markers in the list. 0 on a NULL handle. */ +OAKENGINE_API int oakengine_marker_list_count(const OakEngineMarkerList *list); + +/** @brief Add a marker with the given rational time range, name, and color. + * Returns OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_marker_list_add(OakEngineMarkerList *list, + int64_t in_num, int64_t in_den, + int64_t out_num, int64_t out_den, + const char *name, int color); + +/** + * @brief Create a detached marker (not yet added to any list). + * + * The returned handle can be passed to MarkerPropertiesDialog, then either + * added with oakengine_marker_list_add_existing() or freed with + * oakengine_marker_free(). + */ +OAKENGINE_API OakEngineMarker *oakengine_marker_create( + int color, int64_t in_num, int64_t in_den, int64_t out_num, int64_t out_den, + const char *name); + +/** @brief Free a detached marker created by oakengine_marker_create(). */ +OAKENGINE_API void oakengine_marker_free(OakEngineMarker *marker); + +/** @brief Re-add an existing (removed) marker to the list. Returns + * OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_marker_list_add_existing(OakEngineMarkerList *list, + OakEngineMarker *marker); + +/** @brief Marker at the given sorted index, or NULL if out of range. */ +OAKENGINE_API OakEngineMarker * +oakengine_marker_list_at(const OakEngineMarkerList *list, int index); + +/** @brief Find a marker by its exact in-point time (rational seconds). + * Returns the marker or NULL if not found. */ +OAKENGINE_API OakEngineMarker * +oakengine_marker_list_marker_at_time(const OakEngineMarkerList *list, + int64_t num, int64_t den); + +/** @brief Get the marker's time range as rational seconds. Any pointer + * may be NULL. Returns OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_marker_get_time(const OakEngineMarker *self, + int64_t *in_num, int64_t *in_den, + int64_t *out_num, + int64_t *out_den); + +/** @brief Get the marker's name (buf/size convention). Returns the + * would-be length (excluding NUL) or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_marker_get_name(const OakEngineMarker *self, + char *buf, int buf_size); + +/** @brief Get the marker's color index. Returns -1 on a NULL handle. */ +OAKENGINE_API int oakengine_marker_get_color(const OakEngineMarker *self); + +/** @brief 1 if the marker list has another marker at the given rational + * time, 0 otherwise. 0 on a NULL marker handle. */ +OAKENGINE_API int +oakengine_marker_has_sibling_at_time(const OakEngineMarker *self, int64_t num, + int64_t den); + +/** @brief Set the marker's time range live (non-undoable). Returns + * OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_marker_set_time_live(OakEngineMarker *self, + int64_t in_num, + int64_t in_den, + int64_t out_num, + int64_t out_den); + +/** @brief Commit a time change as an undoable command (undo restores the + * pre-commit state). Returns OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_marker_commit_time( + OakEngineMarker *self, int64_t old_in_num, int64_t old_in_den, + int64_t old_out_num, int64_t old_out_den, int64_t new_in_num, + int64_t new_in_den, int64_t new_out_num, int64_t new_out_den, + void *command); + +/** + * @brief Create a MarkerChangeTimeCommand as an opaque command pointer. + * `new_time_num`/`new_time_den` is the new in-point in rational seconds; + * the marker's out-point offset is preserved. + */ +OAKENGINE_API void *oakengine_marker_set_time_command( + OakEngineMarker *marker, int64_t new_time_num, int64_t new_time_den); + +/** @brief Remove the marker from its list (undoable). Returns + * OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_marker_remove(OakEngineMarker *self); + +/** @brief Batch-set properties on one or more markers (undoable, ONE + * command). Pass -1 for color to leave it unchanged; pass NULL for name + * to leave it unchanged. When `count` == 1, optionally move the marker's + * time range. Returns OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_marker_set_properties( + OakEngineMarker **markers, int count, int color, const char *name, + int move_time, int64_t new_in_num, int64_t new_in_den, + int64_t new_out_num, int64_t new_out_den, void *command); + +/* ---- Workarea handle family --------------------------------------------------- + * + * Workarea operations on opaque OakEngineWorkarea handles. Create with + * oakengine_workarea_create() or borrow from a viewer with + * oakengine_viewer_get_workarea_handle(). Standalone workareas must be + * freed with oakengine_workarea_free(). All times are rational seconds. + */ + +/** @brief Create a standalone workarea (caller owns it). */ +OAKENGINE_API OakEngineWorkarea *oakengine_workarea_create(void); + +/** @brief Free a standalone workarea. NULL-safe. */ +OAKENGINE_API void oakengine_workarea_free(OakEngineWorkarea *wa); + +/** @brief Read the workarea state. Any pointer may be NULL. Returns + * OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_workarea_get(const OakEngineWorkarea *self, + int64_t *in_num, int64_t *in_den, + int64_t *out_num, int64_t *out_den, + int *enabled); + +/** @brief Set the workarea range (non-undoable). Returns OAKENGINE_OK or + * OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_workarea_set_range(OakEngineWorkarea *self, + int64_t in_num, int64_t in_den, + int64_t out_num, int64_t out_den); + +/** @brief Enable/disable the workarea (non-undoable). Returns + * OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_workarea_set_enabled(OakEngineWorkarea *self, + int enabled); + +/** @brief Set the workarea range with undo support. Pass the reset + * sentinels (from oakengine_workarea_reset_in_out()) for the old range + * when creating fresh. Returns OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_workarea_set_range_undoable( + OakEngineWorkarea *self, int64_t in_num, int64_t in_den, int64_t out_num, + int64_t out_den, int64_t old_in_num, int64_t old_in_den, + int64_t old_out_num, int64_t old_out_den, void *command); + +/** @brief Enable/disable the workarea with undo support. Pass NULL for + * command (creates a standalone undo command that is pushed onto the + * global stack when the workarea has an owning project). Returns + * OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_workarea_set_enabled_undoable( + OakEngineWorkarea *self, int enabled, void *command); + +/** @brief Fill the reset sentinel values (in = 0/1, out = RATIONAL_MAX). + * Any pointer may be NULL. */ +OAKENGINE_API void oakengine_workarea_reset_in_out(int64_t *in_num, + int64_t *in_den, + int64_t *out_num, + int64_t *out_den); + +/* ---- Clip media range / cache / media in --------------------------------- */ + +/** @brief Get the clip's media range as rational seconds + * (ClipBlock::media_range()). Any pointer may be NULL. Returns + * OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_clip_get_media_range_rational( + const OakEngineClip *self, int64_t *in_num, int64_t *in_den, + int64_t *out_num, int64_t *out_den); + +/** @brief Get the clip's media in-point as rational seconds + * (ClipBlock::media_in()). Any pointer may be NULL. Returns OAKENGINE_OK + * or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_clip_get_media_in_rational( + const OakEngineClip *self, int64_t *num, int64_t *den); + +/** @brief Move the clip's media in-point (undoable when undoable != 0, + * else direct). Returns OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_clip_set_media_in(OakEngineClip *self, + int64_t media_in_ts, + int undoable); + +/** @brief Move the clip's media in-point as a rational seconds value + * (undoable when undoable != 0, else direct). This variant does not + * require the clip to be on a track yet. Returns OAKENGINE_OK or + * OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_clip_set_media_in_rational(OakEngineClip *self, + int64_t num, + int64_t den, + int undoable); + +/** @brief Request invalidation of the cache for the given range. NULL-safe + * (no-op). */ +OAKENGINE_API void oakengine_clip_request_invalidate(OakEngineClip *self, + int64_t in_ts, + int64_t out_ts, + int type); + +/** @brief Add a cache passthrough dependency (copy results from `source` + * to `dest`). NULL-safe (no-op). */ +OAKENGINE_API void oakengine_clip_add_cache_passthrough( + OakEngineClip *dest, OakEngineClip *source); + +/** @brief Discard the clip's cache. NULL-safe (no-op). */ +OAKENGINE_API void oakengine_clip_discard_cache(OakEngineClip *self); + +/** @brief Create a new empty ClipBlock. The caller owns the returned node + * and must add it to a project (e.g. via oakengine_project_add_node or a + * custom undo command) before the engine can manage its lifecycle. The + * optional `label` sets the node's user label (Node::set_label()). */ +OAKENGINE_API OakEngineClip *oakengine_clip_create_empty(const char *label); + +/** @brief Request invalidated cache ranges from the node connected to the + * clip's buffer input (ClipBlock::request_invalidated_from_connected()). + * Pass in_den == 0 or out_den == 0 to intersect the full media range. */ +OAKENGINE_API void oakengine_clip_request_invalidate_connected( + OakEngineClip *self, int force_all, int64_t in_num, int64_t in_den, + int64_t out_num, int64_t out_den); + +/* ---- Block functions (generic block, not just ClipBlock) ------------------ */ + +/** @brief 1 if the block is enabled (Block::is_enabled()). 0 on NULL. */ +OAKENGINE_API int oakengine_block_is_enabled(const OakEngineBlock *self); + +/** @brief Enable or disable the block (undoable). Returns OAKENGINE_OK + * or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_block_set_enabled(OakEngineBlock *self, + int enabled); + +/* ---- Block traversal -------------------------------------------------------- */ + +/** @brief Number of blocks (including gaps) on the track. Returns + * OAKENGINE_E_INVALID for a NULL handle. */ +OAKENGINE_API int oakengine_track_block_count(const OakEngineTrack *track); + +/** @brief The block at `index` on the track (0-based, includes gaps). + * Returns NULL when out of range or on a NULL handle. */ +OAKENGINE_API OakEngineBlock * +oakengine_track_block_at(const OakEngineTrack *track, int index); + +/** @brief The block at the given timestamp, or NULL if the time falls in + * a gap or past the end. Timestamp is in the track's sequence timebase. */ +OAKENGINE_API OakEngineBlock * +oakengine_track_block_at_time(const OakEngineTrack *track, int64_t timestamp); + +/** @brief Nearest block whose out-point is strictly before `timestamp`. + * Returns NULL when none. */ +OAKENGINE_API OakEngineBlock * +oakengine_track_nearest_block_before(const OakEngineTrack *track, + int64_t timestamp); + +/** @brief Nearest block whose in-point is strictly after `timestamp`. + * Returns NULL when none. */ +OAKENGINE_API OakEngineBlock * +oakengine_track_nearest_block_after(const OakEngineTrack *track, + int64_t timestamp); + +/** @brief Nearest block whose out-point >= `timestamp` + * (i.e. the block containing or immediately before the time). + * Returns NULL when none. */ +OAKENGINE_API OakEngineBlock * +oakengine_track_nearest_block_before_or_at(const OakEngineTrack *track, + int64_t timestamp); + +/** @brief Nearest block whose in-point <= `timestamp` + * (i.e. the block containing or immediately after the time). + * Returns NULL when none. */ +OAKENGINE_API OakEngineBlock * +oakengine_track_nearest_block_after_or_at(const OakEngineTrack *track, + int64_t timestamp); + +/** @brief 1 if the block is a GapBlock, 0 otherwise. 0 on NULL. */ +OAKENGINE_API int oakengine_block_is_gap(const OakEngineBlock *block); + +/** @brief Next block in the track's linked list, or NULL. NULL on NULL. */ +OAKENGINE_API OakEngineBlock *oakengine_block_next(const OakEngineBlock *block); + +/** @brief Previous block in the track's linked list, or NULL. NULL on NULL. */ +OAKENGINE_API OakEngineBlock *oakengine_block_prev(const OakEngineBlock *block); + +/** @brief Fill `in` and `out` with the block's range as timestamps in the + * owning track's sequence timebase. Either pointer may be NULL. Returns + * OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_block_get_range(const OakEngineBlock *block, + int64_t *in, int64_t *out); + +/* ---- Clip input ID getters ------------------------------------------------- */ + +/** @brief ClipBlock::k_buffer_in. Static string, never freed. */ +OAKENGINE_API const char *oakengine_clip_buffer_input_id(void); +/** @brief ClipBlock::k_speed_input. */ +OAKENGINE_API const char *oakengine_clip_speed_input_id(void); +/** @brief ClipBlock::k_reverse_input. */ +OAKENGINE_API const char *oakengine_clip_reverse_input_id(void); +/** @brief ClipBlock::k_maintain_audio_pitch_input. */ +OAKENGINE_API const char * +oakengine_clip_maintain_audio_pitch_input_id(void); +/** @brief ClipBlock::k_loop_mode_input. */ +OAKENGINE_API const char *oakengine_clip_loop_mode_input_id(void); +/** @brief ClipBlock::k_auto_cache_input. */ +OAKENGINE_API const char *oakengine_clip_auto_cache_input_id(void); + +/* ---- Sequence: add_default_nodes ------------------------------------------ */ + +/** @brief Add one video and one audio track as ONE undoable command + * (ViewerOutput helper used by the application). Returns OAKENGINE_OK + * or OAKENGINE_E_INVALID. */ +OAKENGINE_API int +oakengine_sequence_add_default_nodes(OakEngineSequence *seq); + +/* ---- Sequence: add_sequence_clip ------------------------------------------- */ + +/** @brief Place a nested Sequence as a clip on a track (undoable). + * + * Same semantics as oakengine_sequence_add_footage_clip() but creates a + * clip whose buffer input feeds from another Sequence node (nested + * timeline). Self-nesting and circular nesting are detected and rejected. + * Returns a borrowed clip handle or NULL on failure. */ +OAKENGINE_API OakEngineClip * +oakengine_sequence_add_sequence_clip(OakEngineSequence *seq, + OakEngineSequence *nested, + int track_type, int track_index, + int64_t in, int64_t out, + int64_t media_in); + +/* ---- Track handle queries -------------------------------------------------- */ + +/** @brief Borrowed track handle, or NULL if the track does not exist. */ +OAKENGINE_API OakEngineTrack * +oakengine_sequence_track_at(const OakEngineSequence *seq, int track_type, + int track_index); + +/** @brief Track type (OAKENGINE_TRACK_TYPE_*), or -1 on a NULL handle. */ +OAKENGINE_API int oakengine_track_type(const OakEngineTrack *track); + +/** @brief Track content length in frame timestamps (Track::get_length() + * converted to timebase units). Returns OAKENGINE_OK or + * OAKENGINE_E_NOT_FOUND/OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_track_get_length(const OakEngineSequence *seq, + int track_type, int track_index, + int64_t *length); + +/** @brief 1 if the range [in_ts, out_ts) is free (no blocks intersect it), + * 0 if it intersects. Returns OAKENGINE_E_NOT_FOUND when the track does + * not exist, OAKENGINE_E_INVALID for bad arguments. */ +OAKENGINE_API int oakengine_track_is_range_free(const OakEngineSequence *seq, + int track_type, + int track_index, + int64_t in_ts, int64_t out_ts); + +/** @brief Track height helpers (matching the engine's + * Track::k_height_* constants). */ +OAKENGINE_API double oakengine_track_height_default(void); +OAKENGINE_API int oakengine_track_default_height_in_pixels(void); +OAKENGINE_API int oakengine_track_height_internal_to_pixels(double height); +OAKENGINE_API double oakengine_track_height_pixels_to_internal(int pixels); + +/** @brief Track height step interval (e.g. 0.5). > 0.0. */ +OAKENGINE_API double oakengine_track_height_interval(void); + +/** @brief Minimum track height (e.g. 1.5). > 0.0. */ +OAKENGINE_API double oakengine_track_height_minimum(void); + +/* ---- Multicam helpers --------------------------------------------------- */ + +/** @brief Find the MultiCamNode ancestor of a clip, or NULL. Accepts + * OakEngineNode* (a clip or any node). */ +OAKENGINE_API OakEngineNode * +oakengine_clip_find_multicam(OakEngineNode *node); + +/** @brief Switch the multicam source to the given track/stream at the + * given time. Returns OAKENGINE_OK or OAKENGINE_E_INVALID. */ +OAKENGINE_API int oakengine_multicam_switch_source( + OakEngineNode *multicam_node, OakEngineNode *footage_node, + int track_type, int track_index, double time_seconds, + void *command); + #ifdef __cplusplus } #endif diff --git a/engine/include/oakengine/traverse.h b/engine/include/oakengine/traverse.h new file mode 100644 index 000000000..b920f127f --- /dev/null +++ b/engine/include/oakengine/traverse.h @@ -0,0 +1,167 @@ +/*** + + 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_TRAVERSE_H +#define OAKENGINE_TRAVERSE_H + +#include + +#include "export.h" +#include "init.h" +#include "node.h" +#include "videoparams.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file traverse.h + * @brief C ABI for synchronous node-graph value evaluation + * (olive::NodeTraverser) + * + * The engine evaluates a node's value at a given time by traversing its + * input graph (olive::NodeTraverser). The application uses this in three + * places: the node table view (per-input value databases), the node value + * tree (one output table + the element a downstream input's value hint + * selects) and the viewer display gizmos (a transform between two nodes + * and the gizmo node's input row at drag start). This family exposes + * those paths without leaking NodeValueTable/NodeValueRow C++ types. + * + * All evaluation is SYNCHRONOUS on the calling thread and CPU-only in + * this family (textures are resolved as engine-side dummy textures, which + * is exactly what the table/tree views need -- they only read metadata). + * Call from the GUI thread. + * + * OakEngineTraverseDb is an OWNED result object; free it with + * oakengine_traverse_db_free(). Strings it returns point into the object + * and are valid until freed. Times are Rational seconds as int64 + * numerator/denominator pairs, like the rest of the facade. + */ + +typedef struct OakEngineTraverseDb OakEngineTraverseDb; + +/** + * @brief Evaluate every input of `node` over [in_num/in_den, + * out_num/out_den] seconds and return the per-input value database + * (NodeTraverser::generate_database()). NULL on invalid arguments. + */ +OAKENGINE_API OakEngineTraverseDb *oakengine_traverse_generate_database( + OakEngineNode *node, int64_t in_num, int64_t in_den, int64_t out_num, + int64_t out_den); + +/** + * @brief Evaluate the single output table of `node` + * (NodeTraverser::generate_table()). Returned as a database with exactly + * one entry whose input id is an empty string. + */ +OAKENGINE_API OakEngineTraverseDb *oakengine_traverse_generate_table( + OakEngineNode *node, int64_t in_num, int64_t in_den, int64_t out_num, + int64_t out_den); + +/** @brief Free a database returned by this family. NULL is a no-op. */ +OAKENGINE_API void oakengine_traverse_db_free(OakEngineTraverseDb *db); + +/** @brief Number of input entries (generate_database: one per input id + * that produced a table; generate_table: exactly 1). */ +OAKENGINE_API int oakengine_traverse_db_input_count( + const OakEngineTraverseDb *db); + +/** @brief Input id of entry `input_index` (valid until db is freed). */ +OAKENGINE_API const char *oakengine_traverse_db_input_id( + const OakEngineTraverseDb *db, int input_index); + +/** @brief Row count of the table at `input_index` + * (NodeValueTable::count()). */ +OAKENGINE_API int oakengine_traverse_db_row_count( + const OakEngineTraverseDb *db, int input_index); + +/** + * @brief Row accessors. `row` is 0-based in table order (the views + * reverse it themselves where needed). Strings are valid until db is + * freed. + * + * - type: oak_node_value_type of the value. + * - source: borrowed node that produced the value, or NULL. + * - tag: the value's tag (may be empty, never NULL). + * - value_string: NodeValue::value_to_string(value, false). + * - split_count / split_string: NodeValue::to_split_value() count and + * NodeValue::value_to_string(type, split[k], true) per element. + */ +OAKENGINE_API int oakengine_traverse_row_type(const OakEngineTraverseDb *db, + int input_index, int row); +OAKENGINE_API OakEngineNode *oakengine_traverse_row_source( + const OakEngineTraverseDb *db, int input_index, int row); +OAKENGINE_API const char *oakengine_traverse_row_tag( + const OakEngineTraverseDb *db, int input_index, int row); +OAKENGINE_API const char *oakengine_traverse_row_value_string( + const OakEngineTraverseDb *db, int input_index, int row); +OAKENGINE_API int oakengine_traverse_row_split_count( + const OakEngineTraverseDb *db, int input_index, int row); +OAKENGINE_API const char *oakengine_traverse_row_split_string( + const OakEngineTraverseDb *db, int input_index, int row, int split); + +/** + * @brief The table element selected by `hint_node`'s value hint for input + * `input_id`@`element` against a table produced by + * oakengine_traverse_generate_table() (pass its db; must contain exactly + * one entry) -- NodeTraverser::generate_row_value_element_index(). + * Returns -1 when no element matches. + */ +OAKENGINE_API int oakengine_traverse_table_element_index_for_hint( + OakEngineNode *hint_node, const char *input_id, int element, + const OakEngineTraverseDb *table_db); + +/** + * @brief Fill a caller-allocated olive::NodeValueRow with `node`'s input + * values over the given range (NodeTraverser::generate_row()) -- the + * viewer display gizmo drag-start path. `cache_video_params` (may be NULL + * for engine defaults) and `sample_rate`/`channel_layout` seed the + * traverser's cache params (NodeTraverser::set_cache_video_params / + * set_cache_audio_params). + * + * Transition bridge (same pattern as + * replaced by internal ColorTransformJob API): `row_out` is opaque to C + * consumers; the application passes a pointer to its own + * olive::NodeValueRow (a QHash typedef, no engine symbols) which the + * engine fills in place. + */ +OAKENGINE_API int oakengine_traverse_generate_row( + OakEngineNode *node, int64_t in_num, int64_t in_den, int64_t out_num, + int64_t out_den, const oak_video_params *cache_video_params, + int sample_rate, uint64_t channel_layout, void *row_out); + +/** + * @brief Accumulate the transform from `start` to `end` over the given + * range (NodeTraverser::transform()) and return it as the 6 affine + * coefficients of a QTransform (m11, m12, m21, m22, dx, dy), suitable for + * `QTransform(m11, m12, m21, m22, dx, dy)`. `cache_video_params` may be + * NULL for engine defaults. + */ +OAKENGINE_API int oakengine_traverse_transform( + OakEngineNode *start, OakEngineNode *end, int64_t in_num, int64_t in_den, + int64_t out_num, int64_t out_den, const oak_video_params *cache_video_params, + double out_m[6]); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_TRAVERSE_H */ diff --git a/engine/include/oakengine/undo.h b/engine/include/oakengine/undo.h new file mode 100644 index 000000000..0c3bc6b28 --- /dev/null +++ b/engine/include/oakengine/undo.h @@ -0,0 +1,272 @@ +/*** + + 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_UNDO_H +#define OAKENGINE_UNDO_H + +#include + +#include "init.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file undo.h + * @brief C ABI for the global undo stack (olive::UndoStack) + * + * Exposes the process-wide undo stack that backs every editing primitive: + * pushing commands (the command objects themselves are still created by + * the caller as opaque engine pointers), jumping to an arbitrary history + * position, reading the command list for a history view, and the + * undo/redo QActions for menus. + * + * Change notification: subscribe to OAKENGINE_EVENT_UNDO_INDEX_CHANGED on + * oakengine_undo_handle(). The event fires after every stack mutation + * (push/undo/redo/jump/clear); the command list must be re-read through + * oakengine_undo_count()/oakengine_undo_command_text(). + * + * Conventions (matching oakengine/project.h): + * - Return codes: 0 (OAKENGINE_OK) on success, negative OAKENGINE_E_* + * on failure. Functions returning a value return OAKENGINE_E_INVALID + * when no application core exists. + * - String output uses the buf/size convention. + */ + +/** + * @brief Borrowed handle of the global undo stack, for use as the + * subscription handle of OAKENGINE_EVENT_UNDO_INDEX_CHANGED. Returns NULL + * when no application core exists. + */ +OAKENGINE_API void *oakengine_undo_handle(void); + +/** + * @brief Push `command` (an opaque `olive::UndoCommand *`, e.g. the result + * of oakengine_task_import_get_command()) onto the stack and execute its + * redo. Takes ownership of `command` (an empty MultiUndoCommand is deleted + * immediately, matching UndoStack::push). `name` is the user-visible + * command label (NULL behaves like an empty label). + */ +OAKENGINE_API int oakengine_undo_push(void *command, const char *name); + +/** + * @brief Start collecting: subsequent facade undoable operations are added + * as children to a group and executed eagerly, but not pushed individually. + * Returns OAKENGINE_E_STATE if a group is already open. + */ +OAKENGINE_API int oakengine_undo_group_begin(const char *name); + +/** + * @brief End the group and push it as ONE undo entry. + * + * An empty group is discarded (no undo entry). Returns OAKENGINE_E_STATE + * if no group is open. + */ +OAKENGINE_API int oakengine_undo_group_end(void); + +/** + * @brief Abort the open group: undo all already-executed children and + * discard the group. Returns OAKENGINE_E_STATE if no group is open. + */ +OAKENGINE_API int oakengine_undo_group_abort(void); + +/** + * @brief Execute the redo of `command` without taking ownership + * (UndoCommand::redo_now semantics). This is the facade replacement for + * app code that used to call MultiUndoCommand::redo_now() directly. + */ +OAKENGINE_API int oakengine_undo_command_redo_now(void *command); + +/** + * @brief Execute the undo of `command` without taking ownership + * (UndoCommand::undo_now semantics). + */ +OAKENGINE_API int oakengine_undo_command_undo_now(void *command); + +/** + * @brief Callback signatures for app-defined undo commands. + * + * These allow UI-side code to create undoable actions without defining + * C++ subclasses of olive::UndoCommand. The engine wraps the callbacks + * in an internal UndoCommand and forwards redo/undo/free calls. + */ +typedef void (*oakengine_undo_command_redo_fn)(void *userdata); +typedef void (*oakengine_undo_command_undo_fn)(void *userdata); +typedef void (*oakengine_undo_command_free_fn)(void *userdata); + +/** + * @brief Create an app-defined undo command backed by C callbacks. + * + * The returned pointer is an opaque `olive::UndoCommand *` suitable for + * oakengine_undo_push() or oakengine_undo_command_multi_add_child(). + * The command takes ownership of `userdata`; `free_fn` is called when + * the command is destroyed (whether pushed or freed directly). + * + * `name` is the user-visible label. Any callback may be NULL; a NULL + * redo/undo callback makes that direction a no-op. + */ +OAKENGINE_API void *oakengine_undo_command_create( + const char *name, + oakengine_undo_command_redo_fn redo, + oakengine_undo_command_undo_fn undo, + oakengine_undo_command_free_fn free_fn, + void *userdata); + +/** + * @brief Create an empty MultiUndoCommand as an opaque command pointer. + * + * The returned pointer is owned by the caller until it is passed to + * oakengine_undo_push() or freed with oakengine_undo_command_free(). + */ +OAKENGINE_API void *oakengine_undo_command_create_multi(void); + +OAKENGINE_API void *oakengine_node_add_command(void *project, void *node); +OAKENGINE_API void *oakengine_node_set_position_command( + void *node, void *context, double x, double y, int expanded); +OAKENGINE_API void *oakengine_node_remove_position_command( + void *node, void *context); +OAKENGINE_API void *oakengine_node_set_value_hint_command( + void *node, const char *input, int element, int type, int index, + const char *tag); +OAKENGINE_API void *oakengine_node_remove_and_disconnect_command(void *node); + +OAKENGINE_API void *oakengine_track_place_block_command( + void *track_list, int track_index, void *block, int64_t in_ts); +OAKENGINE_API void *oakengine_track_replace_block_with_gap_command( + void *track, void *block, int handle_transitions); +OAKENGINE_API void *oakengine_block_trim_command( + void *track, void *block, int64_t new_length_num, int64_t new_length_den, + int movement_mode, int roll_edit); +OAKENGINE_API void *oakengine_transition_remove_command( + void *transition, int remove_from_graph); +OAKENGINE_API void *oakengine_track_slide_command( + void *track, void *const *blocks, int block_count, + void *in_adjacent, void *out_adjacent, + int64_t movement_num, int64_t movement_den); +OAKENGINE_API void *oakengine_block_split_preserving_links_command( + void *const *blocks, int count, int64_t point_ts); +OAKENGINE_API void *oakengine_block_split_get_split( + void *command, void *block, int time_index); +OAKENGINE_API void *oakengine_block_resize_with_media_in_command( + void *block, int64_t length_num, int64_t length_den); +OAKENGINE_API void *oakengine_block_set_media_in_command( + void *block, int64_t media_in_num, int64_t media_in_den); +OAKENGINE_API void *oakengine_timeline_ripple_delete_gaps_command( + void *sequence, const int64_t *range_in_ts, const int64_t *range_out_ts, + const int *track_types, const int *track_indexes, int range_count); + +/** + * @brief Create a TrackListInsertGaps command as an opaque command pointer. + * `point_num`/`point_den` is the insertion point in rational seconds; + * `length_num`/`length_den` is the gap length in rational seconds. + */ +OAKENGINE_API void *oakengine_track_list_insert_gaps_command( + void *track_list, int64_t point_num, int64_t point_den, + int64_t length_num, int64_t length_den); + +/** + * @brief Add `child` (an opaque command pointer) to the MultiUndoCommand + * `multi`. Returns OAKENGINE_OK on success, OAKENGINE_E_INVALID if either + * argument is NULL. + */ +OAKENGINE_API int oakengine_undo_command_multi_add_child(void *multi, + void *child); + +/** + * @brief Return the number of children in the MultiUndoCommand `multi`, + * or OAKENGINE_E_INVALID if `multi` is NULL. + */ +OAKENGINE_API int oakengine_undo_command_multi_child_count(void *multi); + +/** + * @brief Destroy a command created by oakengine_undo_command_create() or + * oakengine_undo_command_create_multi() without pushing it onto the stack. + * Commands passed to oakengine_undo_push() are owned by the stack and + * must not be freed by the caller. + */ +OAKENGINE_API void oakengine_undo_command_free(void *command); + +/** + * @brief Total number of history rows (done + undone commands), or + * OAKENGINE_E_INVALID when no stack exists. + */ +OAKENGINE_API int64_t oakengine_undo_count(void); + +/** + * @brief Current position in the history: the number of done commands + * (rows below this index are undone). Emitted as payload `a` of + * OAKENGINE_EVENT_UNDO_INDEX_CHANGED. + */ +OAKENGINE_API int64_t oakengine_undo_index(void); + +/** + * @brief Label of the history row at `row` (0-based, buf/size convention). + * Falls back to the translated "Command" placeholder for empty labels. + * + * @return the label length, or OAKENGINE_E_NOT_FOUND for an invalid row. + */ +OAKENGINE_API int oakengine_undo_command_text(int64_t row, char *buf, + int buf_size); + +/** + * @brief 1 when the row at `row` is currently done (not undone), 0 when it + * is undone, OAKENGINE_E_NOT_FOUND for an invalid row. + */ +OAKENGINE_API int oakengine_undo_command_is_done(int64_t row); + +/** + * @brief Undo/redo until the done-command count equals `index` + * (UndoStack::jump semantics). + */ +OAKENGINE_API int oakengine_undo_jump(int64_t index); + +/** + * @brief Delete all commands and push the fresh "New/Open Project" empty + * command (UndoStack::clear). + */ +OAKENGINE_API int oakengine_undo_clear(void); + +/** + * @brief Refresh the undo/redo action labels and enabled state + * (UndoStack::update_actions). + */ +OAKENGINE_API int oakengine_undo_update_actions(void); + +/** + * @brief 1/0 whether undo (redo) is currently possible, + * OAKENGINE_E_INVALID when no stack exists. + */ +OAKENGINE_API int oakengine_undo_can_undo(void); +OAKENGINE_API int oakengine_undo_can_redo(void); + +/** + * @brief The stack's undo (redo) QAction as an opaque `void *` (actually a + * `QAction *`; Qt types are allowed at this boundary). Borrowed; owned by + * the stack. NULL when no stack exists. + */ +OAKENGINE_API void *oakengine_undo_undo_action(void); +OAKENGINE_API void *oakengine_undo_redo_action(void); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_UNDO_H */ diff --git a/engine/include/oakengine/videoparams.h b/engine/include/oakengine/videoparams.h new file mode 100644 index 000000000..bcc87acfb --- /dev/null +++ b/engine/include/oakengine/videoparams.h @@ -0,0 +1,211 @@ +/*** + + 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_VIDEOPARAMS_H +#define OAKENGINE_VIDEOPARAMS_H + +#include + +#include "export.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file videoparams.h + * @brief C ABI POD and static-data accessors for the engine's VideoParams + * + * Covers the parameter surface the export and sequence dialogs need: + * the POD carried by the encoding family (oakengine/encoding.h) and the + * static metadata behind the standard combo boxes (supported frame rates, + * pixel aspect ratios, dividers, pixel format names). Display/render-path + * helpers (bytes per pixel, scaled texture sizes, ...) are out of scope for + * now. + * + * Conventions match the other facade families: buf/size strings (return + * value is the would-be length excluding the NUL), -1 on invalid indexes, + * 0 (OAKENGINE_OK) / negative OAKENGINE_E_* codes where applicable. + */ + +/** + * @brief POD mirror of olive::VideoParams' user-facing fields. + * + * `time_base_*` is the frame duration (frame rate flipped), matching + * VideoParams::time_base(); the frame rate is den/num. `format` is an + * olive::PixelFormat::Format value, `interlacing` an + * olive::VideoParams::Interlacing value (0 = none/progressive, 1 = top + * field first, 2 = bottom field first), `color_range` an + * olive::VideoParams::ColorRange value. The video channel count is an + * engine-internal constant and not exposed. + */ +typedef struct oak_video_params { + int width; + int height; + int time_base_num; /**< Frame duration numerator (e.g. 1001/30000 s). */ + int time_base_den; + int format; /**< olive::PixelFormat::Format. */ + int pixel_aspect_num; + int pixel_aspect_den; + int interlacing; /**< olive::VideoParams::Interlacing. */ + int color_range; /**< olive::VideoParams::ColorRange. */ + int divider; /**< Preview resolution divider (1 = full). */ + /* The two fields below are only populated by the viewer family + * (oakengine_viewer_get_video_params(), B8c); other producers leave + * them 0 (k_video_type_video / not premultiplied). */ + int video_type; /**< olive::VideoParams::Type. */ + int premultiplied_alpha; /**< 0/1. */ +} oak_video_params; + +/** @brief Number of standard frame rates (VideoParams::k_supported_frame_rates). */ +OAKENGINE_API int oakengine_video_params_supported_frame_rate_count(void); + +/** + * @brief The `index`-th standard frame rate as num/den (e.g. 24000/1001); + * OAKENGINE_E_INVALID when out of range. + */ +OAKENGINE_API int oakengine_video_params_supported_frame_rate_at(int index, + int *num, + int *den); + +/** + * @brief User-friendly label of a frame rate num/den pair + * (VideoParams::frame_rate_to_string(); buf/size). + */ +OAKENGINE_API int oakengine_video_params_frame_rate_to_string(int num, int den, + char *buf, + int buf_size); + +/** @brief Number of standard pixel aspect ratios. */ +OAKENGINE_API int oakengine_video_params_standard_pixel_aspect_count(void); + +/** @brief The `index`-th standard pixel aspect ratio as num/den. */ +OAKENGINE_API int oakengine_video_params_standard_pixel_aspect_at(int index, + int *num, + int *den); + +/** @brief Display name of the `index`-th standard pixel aspect (buf/size). */ +OAKENGINE_API int +oakengine_video_params_standard_pixel_aspect_name(int index, char *buf, + int buf_size); + +/** + * @brief VideoParams::format_pixel_aspect_ratio_string(): formats `format` + * (a printf-style "%1" template) with the pixel aspect ratio num/den + * (buf/size). + */ +OAKENGINE_API int oakengine_video_params_format_pixel_aspect_ratio_string( + const char *format, int num, int den, char *buf, int buf_size); + +/** @brief Number of supported preview dividers. */ +OAKENGINE_API int oakengine_video_params_supported_divider_count(void); + +/** @brief The `index`-th supported divider; -1 when out of range. */ +OAKENGINE_API int oakengine_video_params_supported_divider_at(int index); + +/** @brief Display name of a divider (VideoParams::get_name_for_divider()). */ +OAKENGINE_API int oakengine_video_params_divider_name(int divider, char *buf, + int buf_size); + +/** + * @brief 1 when `format` (a PixelFormat::Format value) is a float format + * (VideoParams::format_is_float()). + */ +OAKENGINE_API int oakengine_video_params_format_is_float(int format); + +/** @brief Display name of a PixelFormat::Format value (buf/size). */ +OAKENGINE_API int oakengine_video_params_pixel_format_name(int format, + char *buf, + int buf_size); + +/** + * @brief Effective (divider-scaled) dimensions of width/height at `divider` + * (VideoParams::effective_width()/effective_height()). Any output pointer + * may be NULL. + * + * @return OAKENGINE_OK, or OAKENGINE_E_INVALID for non-positive + * width/height/divider. + */ +OAKENGINE_API int oakengine_video_params_effective_size(int width, int height, + int divider, + int *out_width, + int *out_height); + +/** + * @brief Fill an oak_video_params POD (the display-path VideoParams + * constructor equivalent). No validation is performed beyond rejecting a + * NULL `p`; use oakengine_video_params_is_valid() to validate. + * + * @return OAKENGINE_OK, or OAKENGINE_E_INVALID for NULL `p`. + */ +OAKENGINE_API int oakengine_video_params_make(oak_video_params *p, int width, + int height, int time_base_num, + int time_base_den, int format, + int pixel_aspect_num, + int pixel_aspect_den, + int interlacing, int color_range, + int divider); + +/** + * @brief Create an engine-side olive::VideoParams object from a POD. + * + * The returned pointer must be freed with oakengine_video_params_free(). + * This is the only legal way for app code to construct a VideoParams object + * during the R6 C ABI migration. + * + * @return Engine-owned VideoParams pointer, or NULL if pod is NULL. + */ +OAKENGINE_API void *oakengine_video_params_create(const oak_video_params *pod); + +/** @brief Free a VideoParams object created by oakengine_video_params_create(). */ +OAKENGINE_API void oakengine_video_params_free(void *params); + +/** + * @brief 1 when all user-facing fields of `a` and `b` match + * (VideoParams::operator==), 0 otherwise or when either is NULL. + */ +OAKENGINE_API int oakengine_video_params_equal(const oak_video_params *a, + const oak_video_params *b); + +/** + * @brief 1 when the POD describes a usable video stream + * (VideoParams::is_valid(): positive dimensions, non-null pixel aspect, + * in-range pixel format), 0 otherwise or when `p` is NULL. + */ +OAKENGINE_API int oakengine_video_params_is_valid(const oak_video_params *p); + +/** + * @brief Bytes per pixel of `format` (a PixelFormat::Format value) with + * `channels` channels (VideoParams::get_bytes_per_pixel()). + */ +OAKENGINE_API int oakengine_video_params_bytes_per_pixel(int format, + int channels); + +/** + * @brief The engine-internal video channel count + * (VideoParams::k_internal_channel_count, i.e. RGBA). + */ +OAKENGINE_API int oakengine_video_params_internal_channel_count(void); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_VIDEOPARAMS_H */ diff --git a/engine/include/oakengine/viewer.h b/engine/include/oakengine/viewer.h new file mode 100644 index 000000000..183f64e99 --- /dev/null +++ b/engine/include/oakengine/viewer.h @@ -0,0 +1,350 @@ +/*** + + 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_VIEWER_H +#define OAKENGINE_VIEWER_H + +#include + +#include "export.h" +#include "init.h" +#include "node.h" +#include "timeline.h" +#include "videoparams.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file viewer.h + * @brief C ABI for viewer nodes (olive::ViewerOutput and subclasses: + * Sequence, Footage) + * + * A viewer node is the bridge between a node graph and a monitor: it owns + * a playhead, a length, per-stream video/audio/subtitle parameters, a + * workarea and a marker list. This family covers the application-side + * uses of olive::ViewerOutput that are not already exposed through the + * sequence (timeline.h) or node (node.h) families. + * + * Handles: a viewer handle is simply an OakEngineNode* whose engine object + * is a ViewerOutput (validate with oakengine_viewer_from_node()). Borrowed, + * same lifetime rules as node.h. Change notifications (length/playhead/ + * params/workarea-adjacent) are delivered through the event mechanism -- + * subscribe with the OAKENGINE_EVENT_VIEWER_* ids from oakengine/events.h + * on the node handle. + * + * Conventions match the rest of the facade: rationals are int64 + * numerator/denominator pairs (seconds), booleans are int, 0 + * (OAKENGINE_OK)/negative OAKENGINE_E_* return codes, NULL handles are + * no-ops returning OAKENGINE_E_INVALID. + */ + +/** + * @brief POD snapshot of a viewer's workarea (olive::TimelineWorkArea: + * range in/out + enabled flag). Rationals in seconds. + */ +typedef struct oakengine_viewer_workarea { + int64_t in_num; + int64_t in_den; + int64_t out_num; + int64_t out_den; + int enabled; +} oakengine_viewer_workarea; + +/** + * @brief Return `node` if its engine object is a viewer (olive::ViewerOutput + * or subclass, e.g. Sequence/Footage), NULL otherwise. Replaces + * dynamic_cast at the app boundary; also the canonical way + * to validate a handle for this family. + */ +OAKENGINE_API OakEngineNode *oakengine_viewer_from_node(OakEngineNode *node); + +/** @brief const overload of oakengine_viewer_from_node(). */ +OAKENGINE_API const OakEngineNode * +oakengine_viewer_from_const_node(const OakEngineNode *node); + +/* ---- Input ids / constants (ViewerOutput::k_* statics) ------------------ */ + +/** @brief ViewerOutput::k_video_params_input. Static string, never freed. */ +OAKENGINE_API const char *oakengine_viewer_video_params_input_id(void); +/** @brief ViewerOutput::k_audio_params_input. */ +OAKENGINE_API const char *oakengine_viewer_audio_params_input_id(void); +/** @brief ViewerOutput::k_subtitle_params_input. */ +OAKENGINE_API const char *oakengine_viewer_subtitle_params_input_id(void); +/** @brief ViewerOutput::k_texture_input. */ +OAKENGINE_API const char *oakengine_viewer_texture_input_id(void); +/** @brief ViewerOutput::k_samples_input. */ +OAKENGINE_API const char *oakengine_viewer_samples_input_id(void); +/** @brief ViewerOutput::k_default_sample_format (olive::core::SampleFormat). */ +OAKENGINE_API int oakengine_viewer_default_sample_format(void); + +/* ---- Playhead / length --------------------------------------------------- */ + +/** @brief Current playhead in seconds (ViewerOutput::get_playhead()). */ +OAKENGINE_API int oakengine_viewer_get_playhead(const OakEngineNode *self, + int64_t *num, int64_t *den); + +/** @brief Move the playhead (ViewerOutput::set_playhead()). Emits + * OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED. */ +OAKENGINE_API int oakengine_viewer_set_playhead(OakEngineNode *self, + int64_t num, int64_t den); + +/** + * @brief Set the video parameters of stream `index` on `self` + * (ViewerOutput::set_video_params()). `self` must be a viewer node. + */ +OAKENGINE_API int oakengine_viewer_set_video_params(OakEngineNode *self, + const oak_video_params *params, + int index); + +/** + * @brief Set the audio parameters of stream `index` on `self` + * (ViewerOutput::set_audio_params()). `self` must be a viewer node. + */ +OAKENGINE_API int oakengine_viewer_set_audio_params(OakEngineNode *self, + int sample_rate, + uint64_t channel_layout, + int format, int index); + +/** @brief Content length in seconds (ViewerOutput::get_length()). */ +OAKENGINE_API int oakengine_viewer_get_length(const OakEngineNode *self, + int64_t *num, int64_t *den); + +/** @brief Video content length in seconds (ViewerOutput::get_video_length()). */ +OAKENGINE_API int oakengine_viewer_get_video_length(const OakEngineNode *self, + int64_t *num, + int64_t *den); + +/** @brief Audio content length in seconds (ViewerOutput::get_audio_length()). */ +OAKENGINE_API int oakengine_viewer_get_audio_length(const OakEngineNode *self, + int64_t *num, + int64_t *den); + +/* ---- Stream parameters ---------------------------------------------------- */ + +/** + * @brief Video params of stream `index` (ViewerOutput::get_video_params()). + * `out` is always written; an out-of-range index yields a zeroed struct + * (width/height 0 = invalid, matches an invalid olive::VideoParams). + */ +OAKENGINE_API int oakengine_viewer_get_video_params( + const OakEngineNode *self, int index, oak_video_params *out); + +/** + * @brief Audio params of stream `index` (ViewerOutput::get_audio_params()). + * Any of the out pointers may be NULL. `format` is an + * olive::core::SampleFormat value; out-of-range index yields 0/0/0. + */ +OAKENGINE_API int oakengine_viewer_get_audio_params( + const OakEngineNode *self, int index, int *sample_rate, + uint64_t *channel_layout, int *format); + +/** @brief Number of video streams (ViewerOutput::get_video_stream_count()). */ +OAKENGINE_API int oakengine_viewer_get_video_stream_count( + const OakEngineNode *self); +/** @brief Number of audio streams (ViewerOutput::get_audio_stream_count()). */ +OAKENGINE_API int oakengine_viewer_get_audio_stream_count( + const OakEngineNode *self); +/** @brief Number of subtitle streams (ViewerOutput::get_subtitle_stream_count()). */ +OAKENGINE_API int oakengine_viewer_get_subtitle_stream_count( + const OakEngineNode *self); + +/** + * @brief 1 if stream `index` of `track_type` (OAKENGINE_TRACK_TYPE_*) is + * enabled (VideoParams/AudioParams/SubtitleParams::enabled()), else 0; + * OAKENGINE_E_INVALID (< 0) on bad arguments. + */ +OAKENGINE_API int oakengine_viewer_get_stream_enabled( + const OakEngineNode *self, int track_type, int index); + +/** + * @brief Number of subtitles in subtitle stream `index` + * (SubtitleParams::size()); < 0 on bad arguments. + */ +OAKENGINE_API int oakengine_viewer_get_subtitle_count( + const OakEngineNode *self, int index); + +/** + * @brief Borrowed pointer to subtitle `sub_index` of subtitle stream + * `index` (a const olive::Subtitle*; the application copies the value out, + * it must not free or store it beyond the footage's lifetime). NULL on + * bad arguments. + */ +OAKENGINE_API const void *oakengine_viewer_get_subtitle_at( + const OakEngineNode *self, int index, int sub_index); + +/** + * @brief 1 if the viewer has at least one enabled stream of `track_type` + * (OAKENGINE_TRACK_TYPE_* from timeline.h), else 0 + * (ViewerOutput::has_enabled_video/audio/subtitle_streams()). + */ +OAKENGINE_API int oakengine_viewer_has_enabled_streams( + const OakEngineNode *self, int track_type); + +/** + * @brief Params of the first enabled video stream + * (ViewerOutput::get_first_enabled_video_stream()); zeroed struct when + * none is enabled. + */ +OAKENGINE_API int oakengine_viewer_get_first_enabled_video_stream( + const OakEngineNode *self, oak_video_params *out); + +/** + * @brief Number of enabled streams of all types + * (ViewerOutput::get_enabled_streams_as_references().size()). + */ +OAKENGINE_API int oakengine_viewer_get_enabled_stream_count( + const OakEngineNode *self); + +/** + * @brief Write the enabled stream references + * (ViewerOutput::get_enabled_streams_as_references()) into caller arrays: + * `types[k]` = OAKENGINE_TRACK_TYPE_*, `indices[k]` = stream index within + * that type. At most `max` entries are written; returns the total count + * (call with max=0/NULL arrays to query, or use + * oakengine_viewer_get_enabled_stream_count()). + */ +OAKENGINE_API int oakengine_viewer_get_enabled_streams( + const OakEngineNode *self, int *types, int *indices, int max); + +/* ---- Workarea -------------------------------------------------------------- */ + +/** @brief Snapshot of the viewer's workarea (ViewerOutput::get_work_area() + * range/enabled as POD). */ +OAKENGINE_API int oakengine_viewer_get_workarea( + const OakEngineNode *self, oakengine_viewer_workarea *out); + +/** @brief Set the workarea range (TimelineWorkArea::set_range()). Emits the + * workarea range notification on the underlying workarea object. */ +OAKENGINE_API int oakengine_viewer_set_workarea_range(OakEngineNode *self, + int64_t in_num, + int64_t in_den, + int64_t out_num, + int64_t out_den); + +/** @brief Enable/disable the workarea (TimelineWorkArea::set_enabled()). */ +OAKENGINE_API int oakengine_viewer_set_workarea_enabled(OakEngineNode *self, + int enabled); + +/* ---- Parameter setup / waveform --------------------------------------------- */ + +/** @brief Apply the application default parameters + * (ViewerOutput::set_default_parameters(): width/height/pixel aspect/ + * interlacing/audio layout from Config, frame rate from + * DefaultSequenceFrameRate). */ +OAKENGINE_API int oakengine_viewer_set_default_parameters(OakEngineNode *self); + +/** + * @brief Create a command that sets the viewer's preview resolution divider + * (changes the k_video_params_input standard value). Returns an opaque command + * pointer, or NULL when `self` is not a viewer or `divider` is invalid. + */ +OAKENGINE_API void *oakengine_viewer_set_preview_divider_command( + OakEngineNode *self, int divider); + +/** + * @brief Adopt the parameters of the given footage viewers + * (ViewerOutput::set_parameters_from_footage()). Every element of + * `footage` must itself be a viewer handle. + */ +OAKENGINE_API int oakengine_viewer_set_parameters_from_footage( + OakEngineNode *self, OakEngineNode *const *footage, int count); + +/** @brief Enable/disable waveform cache requests + * (ViewerOutput::set_waveform_enabled()). */ +OAKENGINE_API int oakengine_viewer_set_waveform_enabled(OakEngineNode *self, + int enabled); + +/** + * @brief The waveform cache of the connected sample output, or NULL + * (ViewerOutput::get_connected_waveform()). Opaque borrowed pointer; the + * application only passes it through to its own audio monitor, it must not + * dereference it. + */ +OAKENGINE_API const void * +oakengine_viewer_get_connected_waveform(const OakEngineNode *self); + +/** + * @brief Borrowed handle of the viewer's timeline marker list + * (ViewerOutput::get_markers()), for the oakengine_marker_list_* family + * and the OAKENGINE_EVENT_MARKER_LIST_* events. NULL when `self` is not a + * viewer. + */ +OAKENGINE_API OakEngineMarkerList * +oakengine_viewer_get_marker_list(OakEngineNode *self); + +/** + * @brief Borrowed handle of the viewer's workarea + * (ViewerOutput::get_work_area()), for the oakengine_workarea_* family and + * the OAKENGINE_EVENT_WORKAREA_* events. NULL when `self` is not a viewer. + */ +OAKENGINE_API OakEngineWorkarea * +oakengine_viewer_get_workarea_handle(OakEngineNode *self); + +/* ---- Playback cache / frame cache ------------------------------------------ */ + +/** + * @brief Opaque playback cache handle (olive::PlaybackCache). + */ +typedef struct OakEnginePlaybackCache OakEnginePlaybackCache; + +/** + * @brief Opaque frame cache handle (olive::FrameHashCache). + */ +typedef struct OakEngineFrameCache OakEngineFrameCache; + +/** + * @brief Borrowed playback cache of a viewer's connected output + * (ViewerOutput::get_connected_video_cache() for video, or from the + * ClipBlock::connected_video_cache()). Returns NULL when not available + * or when `self` is not a viewer/clip node. + */ +OAKENGINE_API OakEnginePlaybackCache * +oakengine_viewer_get_playback_cache(OakEngineNode *self); + +/** + * @brief Static indicator height for playback cache rendering + * (PlaybackCache::get_cache_indicator_height()). > 0. + */ +OAKENGINE_API int oakengine_playback_cache_indicator_height(void); + +/** + * @brief Fill `ranges` with the valid (cached) time ranges from the + * playback cache. `ranges` is an array of (in_num,in_den,out_num,out_den) + * int64_t quads; at most `max` ranges are written. Returns the number of + * ranges written, or OAKENGINE_E_INVALID on NULL cache. + */ +OAKENGINE_API int oakengine_playback_cache_valid_ranges( + OakEnginePlaybackCache *cache, int64_t *ranges, int max); + +/** + * @brief Borrowed frame hash cache (FrameHashCache) of a viewer node + * (ViewerOutput has a get_video_cache(), etc.). Returns NULL when not + * available or when `self` is not a viewer node. + */ +OAKENGINE_API OakEngineFrameCache * +oakengine_viewer_get_frame_cache(OakEngineNode *self); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_VIEWER_H */ diff --git a/engine/include/oakengine/worker.h b/engine/include/oakengine/worker.h new file mode 100644 index 000000000..e1f345a11 --- /dev/null +++ b/engine/include/oakengine/worker.h @@ -0,0 +1,146 @@ +/*** + + 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_WORKER_H +#define OAKENGINE_WORKER_H + +#include "export.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file worker.h + * @brief C ABI for the render worker process logic + * + * The render worker (oak-render-worker) is a headless render process spawned + * by the editor through the render worker pool. All of its runtime logic — + * Qt application setup, render backend initialization, the startup handshake + * and the NDJSON control-message loop — lives inside liboakengine behind this + * pure C interface, so the worker executable itself contains no engine C++ + * ABI usage. + * + * Two entry levels are exposed: + * + * - oakengine_worker_main(): a drop-in main() for the worker executable. + * It creates the QGuiApplication, parses --backend, initializes the + * renderer, sends the startup handshake and runs the stdin/stdout NDJSON + * loop until a shutdown message or EOF. + * + * - The OakWorkerSession family: the same message-handling state machine + * in a transport-agnostic form, so tests (and alternative transports) + * can drive it line by line without spawning a process. Responses that + * the worker would write to stdout are returned through the buf/size + * convention instead. + * + * Conventions (mirrors ipc.h): + * - Returned handles are owned by the caller and must be released with the + * matching _free(). NULL is accepted by every function and yields a + * no-op / zero result. + * - 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. + */ + +typedef struct OakWorkerSession OakWorkerSession; + +/** + * @brief Create a worker session for the given render backend. + * + * `backend` names the render backend ("opengl", "vulkan"); the session tries + * the dynamic backend first and falls back to the direct OpenGL renderer, + * exactly like the worker main. NULL, "" or "none" skips renderer creation + * entirely, producing a session that can parse and answer control messages + * but cannot actually render (useful for exercising error paths in tests). + * + * A QGuiApplication must exist before creating a session with a real + * backend. The returned handle is owned by the caller. + */ +OAKENGINE_API OakWorkerSession * +oakengine_worker_session_create(const char *backend); + +OAKENGINE_API void oakengine_worker_session_free(OakWorkerSession *self); + +/** + * @brief 1 if the session holds a successfully initialized render backend. + */ +OAKENGINE_API int +oakengine_worker_session_has_renderer(const OakWorkerSession *self); + +/** + * @brief Load the engine runtime services the session depends on (config, + * node factory, color manager, frame/disk managers, project serializer). + * + * Idempotent in practice: the underlying services are process-wide + * singletons. Returns 1 on success, 0 on failure (NULL session). + */ +OAKENGINE_API int +oakengine_worker_session_initialize_runtime(OakWorkerSession *self); + +/** + * @brief Build the startup handshake the worker sends to its parent + * (buf/size convention). + * + * Announces the protocol version and, when a renderer is present, the + * negotiated GL version. Returns the required size, or -1 on failure. + */ +OAKENGINE_API int +oakengine_worker_session_startup_handshake(OakWorkerSession *self, char *buf, + int buf_size); + +/** + * @brief Handle one NDJSON control line and produce the response, if any. + * + * `line` is one complete JSON message (with or without the trailing + * newline). The response — what the worker main loop would write to stdout — + * is serialized into response_buf using the buf/size convention: the return + * value is the number of characters that would have been written excluding + * the NUL, so 0 means "no response" (e.g. a successful handshake or a + * shutdown message) and a positive value queries/fills the response. A + * malformed `line` yields an error response, not a failure. + * + * Returns -1 when the handler itself failed (the worker main treats this as + * a fatal error for its exit code, though it keeps draining input). + */ +OAKENGINE_API int +oakengine_worker_session_handle_json(OakWorkerSession *self, const char *line, + char *response_buf, int response_buf_size); + +/** + * @brief 1 once a shutdown control message has been received. + */ +OAKENGINE_API int +oakengine_worker_session_shutdown_requested(const OakWorkerSession *self); + +/** + * @brief Full render-worker main(). `argc`/`argv` are passed through from + * the executable's main; "--backend " selects the render backend. + * + * Returns the process exit code (0 on clean shutdown). + */ +OAKENGINE_API int oakengine_worker_main(int argc, char **argv); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_WORKER_H */ diff --git a/engine/src/capi/CMakeLists.txt b/engine/src/capi/CMakeLists.txt index 29175dba8..9175f7ee3 100644 --- a/engine/src/capi/CMakeLists.txt +++ b/engine/src/capi/CMakeLists.txt @@ -23,22 +23,61 @@ set(OLIVE_SOURCES ${OLIVE_SOURCES} include/oakengine/init.h + include/oakengine/events.h + include/oakengine/app.h include/oakengine/project.h include/oakengine/timeline.h include/oakengine/renderer.h + include/oakengine/display.h include/oakengine/footage.h include/oakengine/exporter.h + include/oakengine/encoding.h + include/oakengine/videoparams.h + include/oakengine/color.h include/oakengine/node.h include/oakengine/playback.h include/oakengine/preview.h + include/oakengine/sync.h + include/oakengine/worker.h + include/oakengine/viewer.h + include/oakengine/traverse.h + include/oakengine/task.h + include/oakengine/undo.h + include/oakengine/config.h + include/oakengine/audio.h + include/oakengine/disk.h + include/oakengine/proxy.h + include/oakengine/lut.h + include/oakengine/serializer.h + include/oakengine/plugin.h + include/oakengine/gizmo.h src/capi/init.cpp + src/capi/events.cpp + src/capi/app.cpp src/capi/project.cpp src/capi/timeline.cpp src/capi/renderer.cpp + src/capi/display.cpp src/capi/footage.cpp src/capi/export.cpp + src/capi/encoding.cpp + src/capi/color.cpp src/capi/node.cpp src/capi/playback.cpp src/capi/preview.cpp + src/capi/sync.cpp + src/capi/worker.cpp + src/capi/viewer.cpp + src/capi/traverse.cpp + src/capi/task.cpp + src/capi/undo.cpp + src/capi/config.cpp + src/capi/audio.cpp + src/capi/disk.cpp + src/capi/proxy.cpp + src/capi/lut.cpp + src/capi/serializer.cpp + src/capi/plugin.cpp + src/capi/gizmo.cpp PARENT_SCOPE ) diff --git a/engine/src/capi/app.cpp b/engine/src/capi/app.cpp new file mode 100644 index 000000000..b1b8a61ed --- /dev/null +++ b/engine/src/capi/app.cpp @@ -0,0 +1,812 @@ +/*** + + 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/app.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/serializedlayoutinfo.h" +#include "task/task.h" +#include "undo/undostack.h" + +namespace +{ + +olive::Project *impl(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()); +} + +// Registered callback set (all fields may be null). +OakEngineAppCallbacks g_callbacks = {}; + +// Whether oakengine_app_start() has run (and oakengine_app_stop() has not). +bool g_started = false; + +// The EngineCore the notification signals are currently connected to. +olive::EngineCore *g_connected_core = nullptr; + +olive::EngineCore *app_core() +{ + return olive::EngineCore::instance(); +} + +// The EngineCore constructor's UndoStack member creates QActions, which need +// QGuiApplication state (same reason as oakengine_init()). +void ensure_qcoreapplication() +{ + if (QCoreApplication::instance()) { + return; + } + + 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); + + QCoreApplication::setOrganizationName(QStringLiteral("oakvideoeditor.org")); + QCoreApplication::setApplicationName(QStringLiteral("Oak Video Editor")); +} + +// Forward engine signals to the registered C callbacks. Connected once per +// EngineCore instance; dropped events are fine while no callback is set. +void connect_notifications(olive::EngineCore *core) +{ + if (!core || g_connected_core == core) { + return; + } + g_connected_core = core; + + QObject::connect(core, &olive::EngineCore::status_message_show, core, + [](const QString &message, int timeout) { + if (g_callbacks.status_message_show) { + g_callbacks.status_message_show( + message.toUtf8().constData(), timeout, + g_callbacks.userdata); + } + }); + QObject::connect(core, &olive::EngineCore::status_message_clear, core, + [] { + if (g_callbacks.status_message_clear) { + g_callbacks.status_message_clear( + g_callbacks.userdata); + } + }); + QObject::connect(core, &olive::EngineCore::cache_full_warning_requested, + core, [] { + if (g_callbacks.cache_full_warning) { + g_callbacks.cache_full_warning( + g_callbacks.userdata); + } + }); + QObject::connect(core, &olive::EngineCore::active_project_changed, core, + [](olive::Project *p) { + if (g_callbacks.active_project_changed) { + g_callbacks.active_project_changed( + wrap(p), g_callbacks.userdata); + } + }); + QObject::connect(core, &olive::EngineCore::tool_changed, core, + [](const olive::Tool::Item &tool) { + if (g_callbacks.tool_changed) { + g_callbacks.tool_changed(int(tool), + g_callbacks.userdata); + } + }); + QObject::connect(core, &olive::EngineCore::addable_object_changed, core, + [](olive::Tool::AddableObject o) { + if (g_callbacks.addable_object_changed) { + g_callbacks.addable_object_changed( + int(o), g_callbacks.userdata); + } + }); + QObject::connect(core, &olive::EngineCore::snapping_changed, core, + [](const bool &b) { + if (g_callbacks.snapping_changed) { + g_callbacks.snapping_changed(b ? 1 : 0, + g_callbacks.userdata); + } + }); + QObject::connect(core, &olive::EngineCore::timecode_display_changed, core, + [](olive::core::Timecode::Display d) { + if (g_callbacks.timecode_display_changed) { + g_callbacks.timecode_display_changed( + int(d), g_callbacks.userdata); + } + }); + QObject::connect(core, &olive::EngineCore::open_recent_list_changed, core, + [] { + if (g_callbacks.open_recent_list_changed) { + g_callbacks.open_recent_list_changed( + g_callbacks.userdata); + } + }); + QObject::connect(core, &olive::EngineCore::color_picker_enabled, core, + [](bool e) { + if (g_callbacks.color_picker_enabled) { + g_callbacks.color_picker_enabled( + e ? 1 : 0, g_callbacks.userdata); + } + }); +} + +// Translate the C handler callbacks into the std::function handlers +// EngineCore calls when it needs user interaction. +void install_handlers(olive::EngineCore *core) +{ + if (g_callbacks.confirm_image_sequence) { + core->set_confirm_image_sequence_handler([](const QString &filename) { + return g_callbacks.confirm_image_sequence( + filename.toUtf8().constData(), + g_callbacks.userdata) != 0; + }); + } else { + core->set_confirm_image_sequence_handler(nullptr); + } + + if (g_callbacks.relink_footage) { + core->set_relink_handler([](QVector footage) { + return g_callbacks.relink_footage( + reinterpret_cast(footage.data()), + int(footage.size()), g_callbacks.userdata) != 0; + }); + } else { + core->set_relink_handler(nullptr); + } + + if (g_callbacks.save_project) { + core->set_save_project_handler([](const QString &override_filename) { + g_callbacks.save_project(override_filename.toUtf8().constData(), + g_callbacks.userdata); + }); + } else { + core->set_save_project_handler(nullptr); + } + + if (g_callbacks.close_project) { + core->set_close_project_handler([] { + return g_callbacks.close_project(g_callbacks.userdata) != 0; + }); + } else { + core->set_close_project_handler(nullptr); + } + + if (g_callbacks.load_layout) { + core->set_load_layout_handler( + [](const olive::SerializedLayoutInfo &layout) { + g_callbacks.load_layout(&layout, g_callbacks.userdata); + }); + } else { + core->set_load_layout_handler(nullptr); + } + +#ifdef USE_OTIO + if (g_callbacks.otio_import) { + core->set_otio_import_handler( + [](const QList &sequences) { + QVector handles; + handles.reserve(sequences.size()); + for (olive::Sequence *s : sequences) { + handles.append(wrap_seq(s)); + } + return g_callbacks.otio_import(handles.data(), + int(handles.size()), + g_callbacks.userdata) != 0; + }); + } else { + core->set_otio_import_handler(nullptr); + } +#endif +} + +} // namespace + +extern "C" +{ + +int oakengine_app_create(const OakEngineAppParams *params) +{ + if (app_core()) { + return OAKENGINE_E_STATE; + } + + ensure_qcoreapplication(); + + olive::EngineCore::CoreParams core_params; + if (params) { + switch (params->run_mode) { + case OAKENGINE_APP_RUN_HEADLESS_EXPORT: + core_params.set_run_mode( + olive::EngineCore::CoreParams::k_headless_export); + break; + case OAKENGINE_APP_RUN_HEADLESS_PRE_CACHE: + core_params.set_run_mode( + olive::EngineCore::CoreParams::k_headless_pre_cache); + break; + default: + core_params.set_run_mode( + olive::EngineCore::CoreParams::k_run_normal); + break; + } + core_params.set_fullscreen(params->fullscreen != 0); + if (params->startup_project) { + core_params.set_startup_project( + QString::fromUtf8(params->startup_project)); + } + if (params->startup_language) { + core_params.set_startup_language( + QString::fromUtf8(params->startup_language)); + } + if (params->crash_on_startup) { + core_params.set_crash_on_startup(true); + } + } + + // Never deleted: backs the process-wide EngineCore singleton (same + // lifetime rule as the oakengine_init() shell). + new olive::EngineCore(core_params); + + return OAKENGINE_OK; +} + +int oakengine_app_start(void) +{ + if (!app_core() || g_started) { + return OAKENGINE_E_STATE; + } + + app_core()->start(); + g_started = true; + return OAKENGINE_OK; +} + +int oakengine_app_stop(void) +{ + if (!app_core() || !g_started) { + return OAKENGINE_E_STATE; + } + + app_core()->stop(); + g_started = false; + return OAKENGINE_OK; +} + +int oakengine_app_set_callbacks(const OakEngineAppCallbacks *callbacks) +{ + if (callbacks) { + g_callbacks = *callbacks; + } else { + g_callbacks = OakEngineAppCallbacks{}; + } + + if (olive::EngineCore *core = app_core()) { + connect_notifications(core); + install_handlers(core); + } + + return OAKENGINE_OK; +} + +int oakengine_app_run_mode(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + switch (app_core()->core_params().run_mode()) { + case olive::EngineCore::CoreParams::k_headless_export: + return OAKENGINE_APP_RUN_HEADLESS_EXPORT; + case olive::EngineCore::CoreParams::k_headless_pre_cache: + return OAKENGINE_APP_RUN_HEADLESS_PRE_CACHE; + default: + return OAKENGINE_APP_RUN_NORMAL; + } +} + +int oakengine_app_fullscreen(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return app_core()->core_params().fullscreen() ? 1 : 0; +} + +int oakengine_app_startup_project(char *buf, int buf_size) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(app_core()->core_params().startup_project(), buf, + buf_size); +} + +void *oakengine_app_undo_stack(void) +{ + if (!app_core()) { + return nullptr; + } + return app_core()->undo_stack(); +} + +int oakengine_app_tool(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return int(app_core()->tool()); +} + +int oakengine_app_set_tool(int tool) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + if (tool < 0 || tool >= int(olive::Tool::k_count)) { + return OAKENGINE_E_INVALID; + } + + app_core()->set_tool(static_cast(tool)); + return OAKENGINE_OK; +} + +int oakengine_app_addable_object(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return int(app_core()->get_selected_addable_object()); +} + +int oakengine_app_set_addable_object(int object) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + if (object < 0 || object >= int(olive::Tool::k_addable_count)) { + return OAKENGINE_E_INVALID; + } + + app_core()->set_selected_addable_object( + static_cast(object)); + return OAKENGINE_OK; +} + +int oakengine_app_selected_transition(char *buf, int buf_size) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(app_core()->get_selected_transition(), buf, buf_size); +} + +int oakengine_app_set_selected_transition(const char *id) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + app_core()->set_selected_transition_object( + id ? QString::fromUtf8(id) : QString()); + return OAKENGINE_OK; +} + +int oakengine_app_snapping(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return app_core()->snapping() ? 1 : 0; +} + +int oakengine_app_set_snapping(int enabled) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + app_core()->set_snapping(enabled != 0); + return OAKENGINE_OK; +} + +int oakengine_app_timecode_display(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return int(app_core()->get_timecode_display()); +} + +int oakengine_app_set_timecode_display(int display) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + if (display < 0 || + display > int(olive::core::Timecode::k_milliseconds)) { + return OAKENGINE_E_INVALID; + } + + app_core()->set_timecode_display( + static_cast(display)); + return OAKENGINE_OK; +} + +int oakengine_app_recent_projects_count(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return int(app_core()->get_recent_projects().size()); +} + +int oakengine_app_recent_project_at(int index, char *buf, int buf_size) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + const QStringList &recent = app_core()->get_recent_projects(); + if (index < 0 || index >= recent.size()) { + return OAKENGINE_E_NOT_FOUND; + } + return string_to_buf(recent.at(index), buf, buf_size); +} + +int oakengine_app_remove_recent_project(int index) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + if (index < 0 || index >= app_core()->get_recent_projects().size()) { + return OAKENGINE_E_NOT_FOUND; + } + + app_core()->remove_recently_opened_project(index); + return OAKENGINE_OK; +} + +int oakengine_app_clear_recent_projects(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + app_core()->clear_open_recent_list(); + return OAKENGINE_OK; +} + +int oakengine_app_show_status_message(const char *message, int timeout) +{ + if (!app_core() || !message) { + return OAKENGINE_E_INVALID; + } + + app_core()->show_status_bar_message(QString::fromUtf8(message), timeout); + return OAKENGINE_OK; +} + +int oakengine_app_clear_status_message(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + app_core()->clear_status_bar_message(); + return OAKENGINE_OK; +} + +int oakengine_app_set_language(const char *locale) +{ + if (!app_core() || !locale) { + return OAKENGINE_E_INVALID; + } + + return app_core()->set_language(QString::fromUtf8(locale)) ? 1 : 0; +} + +int oakengine_app_set_autorecovery_interval(int minutes) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + app_core()->set_autorecovery_interval(minutes); + return OAKENGINE_OK; +} + +int oakengine_app_set_use_proxy_media(int enabled) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + app_core()->set_use_proxy_media(enabled != 0); + return OAKENGINE_OK; +} + +int oakengine_app_request_pixel_sampling(int enable) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + app_core()->request_pixel_sampling_in_viewers(enable != 0); + return OAKENGINE_OK; +} + +int oakengine_app_set_magic(int enabled) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + app_core()->set_magic(enabled != 0); + return OAKENGINE_OK; +} + +int oakengine_app_is_magic_enabled(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return app_core()->is_magic_enabled() ? 1 : 0; +} + +int oakengine_app_copy_to_clipboard(const char *text) +{ + if (!app_core() || !text) { + return OAKENGINE_E_INVALID; + } + + olive::EngineCore::copy_string_to_clipboard(QString::fromUtf8(text)); + return OAKENGINE_OK; +} + +int oakengine_app_paste_from_clipboard(char *buf, int buf_size) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(olive::EngineCore::paste_string_from_clipboard(), buf, + buf_size); +} + +int oakengine_app_footage_file_dialog_filter(char *buf, int buf_size) +{ + return string_to_buf(olive::EngineCore::footage_file_dialog_filter(), buf, + buf_size); +} + +int oakengine_app_is_footage_extension_allowed(const char *path) +{ + if (!path) { + return OAKENGINE_E_INVALID; + } + return olive::EngineCore::is_footage_extension_allowed( + QString::fromUtf8(path)) ? + 1 : + 0; +} + +OakEngineSequence *oakengine_app_create_sequence(OakEngineProject *project, + const char *name_format) +{ + if (!app_core() || !project) { + return nullptr; + } + + const QString format = name_format ? + QString::fromUtf8(name_format) : + QStringLiteral("Sequence %1"); + return wrap_seq(olive::EngineCore::create_new_sequence_for_project( + format, impl(project))); +} + +int oakengine_app_auto_recovery_index_filename(char *buf, int buf_size) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(olive::EngineCore::get_auto_recovery_index_filename(), + buf, buf_size); +} + +OakEngineProject *oakengine_app_open_project(void) +{ + if (!app_core()) { + return nullptr; + } + return wrap(app_core()->open_project()); +} + +int oakengine_app_create_new_project(void) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + app_core()->create_new_project(); + return OAKENGINE_OK; +} + +int oakengine_app_add_open_project(OakEngineProject *project, + int add_to_recents) +{ + if (!app_core() || !project) { + return OAKENGINE_E_INVALID; + } + + app_core()->add_open_project(impl(project), add_to_recents != 0); + return OAKENGINE_OK; +} + +int oakengine_app_add_open_project_from_task(void *task, int add_to_recents) +{ + if (!app_core() || !task) { + return OAKENGINE_E_INVALID; + } + + return app_core()->add_open_project_from_task( + static_cast(task), add_to_recents != 0) ? + 1 : + 0; +} + +int oakengine_app_add_recovery_project_from_task(void *task) +{ + if (!app_core() || !task) { + return OAKENGINE_E_INVALID; + } + + app_core()->add_recovery_project_from_task(static_cast(task)); + return OAKENGINE_OK; +} + +int oakengine_app_on_project_saved(OakEngineProject *project) +{ + if (!app_core() || !project) { + return OAKENGINE_E_INVALID; + } + + app_core()->on_project_saved(impl(project)); + return OAKENGINE_OK; +} + +int oakengine_app_set_active_project(OakEngineProject *project) +{ + if (!app_core()) { + return OAKENGINE_E_INVALID; + } + + app_core()->set_active_project(impl(project)); + return OAKENGINE_OK; +} + +// ---- Individual handler setter convenience wrappers ---- + +int oakengine_app_set_confirm_image_sequence_handler( + int (*fn)(const char *filename, void *userdata), void *userdata) +{ + OakEngineAppCallbacks cb = g_callbacks; + cb.userdata = userdata; + cb.confirm_image_sequence = fn; + return oakengine_app_set_callbacks(&cb); +} + +int oakengine_app_set_relink_handler( + int (*fn)(OakEngineFootage **footage, int count, void *userdata), + void *userdata) +{ + OakEngineAppCallbacks cb = g_callbacks; + cb.userdata = userdata; + cb.relink_footage = fn; + return oakengine_app_set_callbacks(&cb); +} + +int oakengine_app_set_save_project_handler( + void (*fn)(const char *override_filename, void *userdata), void *userdata) +{ + OakEngineAppCallbacks cb = g_callbacks; + cb.userdata = userdata; + cb.save_project = fn; + return oakengine_app_set_callbacks(&cb); +} + +int oakengine_app_set_close_project_handler( + int (*fn)(void *userdata), void *userdata) +{ + OakEngineAppCallbacks cb = g_callbacks; + cb.userdata = userdata; + cb.close_project = fn; + return oakengine_app_set_callbacks(&cb); +} + +int oakengine_app_set_load_layout_handler( + void (*fn)(const void *layout, void *userdata), void *userdata) +{ + OakEngineAppCallbacks cb = g_callbacks; + cb.userdata = userdata; + cb.load_layout = fn; + return oakengine_app_set_callbacks(&cb); +} + +// ---- void*-based convenience overloads ---- + +int oakengine_app_get_auto_recovery_index_filename(char *buf, int buf_size) +{ + return oakengine_app_auto_recovery_index_filename(buf, buf_size); +} + +int oakengine_app_remove_recently_opened_project(int index) +{ + return oakengine_app_remove_recent_project(index); +} + +int oakengine_app_on_project_saved_vp(void *project) +{ + return oakengine_app_on_project_saved( + reinterpret_cast(project)); +} + +int oakengine_app_set_active_project_vp(void *project) +{ + return oakengine_app_set_active_project( + reinterpret_cast(project)); +} + +int oakengine_app_add_open_project_vp(void *project, int add_to_recents) +{ + return oakengine_app_add_open_project( + reinterpret_cast(project), add_to_recents); +} + +} // extern "C" diff --git a/engine/src/capi/audio.cpp b/engine/src/capi/audio.cpp new file mode 100644 index 000000000..364e1f151 --- /dev/null +++ b/engine/src/capi/audio.cpp @@ -0,0 +1,458 @@ +/*** + + 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/audio.h" +#include "oakengine/encoding.h" + +#include +#include +#include + +#include +#include + +#include "audio/audiomanager.h" +#include "audio/audioprocessor.h" +#include "audio/audiosynchronizer.h" +#include "audio/audiowaveformsync.h" +#include "olive/core/oakcore/audioparams.h" +#include "olive/core/render/audioparams.h" + +namespace +{ + +int write_string(const QString &s, char *buf, int buf_size) +{ + const QByteArray utf8 = s.toUtf8(); + const int len = int(utf8.size()); + if (buf && buf_size > 0) { + const int n = qMin(len, buf_size - 1); + std::memcpy(buf, utf8.constData(), size_t(n)); + buf[n] = '\0'; + } + return len; +} + +olive::AudioManager *manager() +{ + return olive::AudioManager::instance(); +} + +} // namespace + +extern "C" int oakengine_audio_create_instance(void) +{ + olive::AudioManager::create_instance(); + return manager() ? OAKENGINE_OK : OAKENGINE_E_FAILED; +} + +extern "C" int oakengine_audio_destroy_instance(void) +{ + olive::AudioManager::destroy_instance(); + return OAKENGINE_OK; +} + +extern "C" void *oakengine_audio_manager_handle(void) +{ + return manager(); +} + +extern "C" int64_t oakengine_audio_get_output_device(void) +{ + if (olive::AudioManager *m = manager()) { + return static_cast(m->get_output_device()); + } + return -1; // paNoDevice +} + +extern "C" int oakengine_audio_set_output_device(int64_t device) +{ + if (olive::AudioManager *m = manager()) { + m->set_output_device(static_cast(device)); + return OAKENGINE_OK; + } + return OAKENGINE_E_STATE; +} + +extern "C" int64_t oakengine_audio_get_input_device(void) +{ + if (olive::AudioManager *m = manager()) { + return static_cast(m->get_input_device()); + } + return -1; // paNoDevice +} + +extern "C" int oakengine_audio_set_input_device(int64_t device) +{ + if (olive::AudioManager *m = manager()) { + m->set_input_device(static_cast(device)); + return OAKENGINE_OK; + } + return OAKENGINE_E_STATE; +} + +extern "C" int oakengine_audio_hard_reset(void) +{ + if (olive::AudioManager *m = manager()) { + m->hard_reset(); + return OAKENGINE_OK; + } + return OAKENGINE_E_STATE; +} + +extern "C" int oakengine_audio_clear_buffered_output(void) +{ + if (olive::AudioManager *m = manager()) { + m->clear_buffered_output(); + return OAKENGINE_OK; + } + return OAKENGINE_E_STATE; +} + +extern "C" int oakengine_audio_push_to_output(const OakAudioParams *params, + const char *samples, + int64_t samples_size, + char *error_buf, + int error_buf_size) +{ + if (!params || !samples || samples_size < 0) { + return OAKENGINE_E_INVALID; + } + + olive::AudioManager *m = manager(); + if (!m) { + return OAKENGINE_E_STATE; + } + + // The C++ AudioParams wrapper owns the OakAudioParams handle; the caller + // keeps ownership of `params`, so copy before wrapping. + const olive::core::AudioParams cpp_params = + olive::core::AudioParams::from_handle( + oakcore_audioparams_copy(params)); + + QString error; + const QByteArray data = QByteArray::fromRawData(samples, + static_cast(samples_size)); + if (!m->push_to_output(cpp_params, data, &error)) { + write_string(error, error_buf, error_buf_size); + return OAKENGINE_E_FAILED; + } + return OAKENGINE_OK; +} + +extern "C" int oakengine_audio_stop_recording(void) +{ + if (olive::AudioManager *m = manager()) { + m->stop_recording(); + return OAKENGINE_OK; + } + return OAKENGINE_E_STATE; +} + +extern "C" int oakengine_audio_stop_output(void) +{ + if (olive::AudioManager *m = manager()) { + m->stop_output(); + return OAKENGINE_OK; + } + return OAKENGINE_E_STATE; +} + +extern "C" int oakengine_audio_reset_output_clock(void) +{ + if (olive::AudioManager *m = manager()) { + m->reset_output_clock(); + return OAKENGINE_OK; + } + return OAKENGINE_E_STATE; +} + +extern "C" int oakengine_audio_set_output_notify_interval(int64_t bytes) +{ + if (olive::AudioManager *m = manager()) { + m->set_output_notify_interval(static_cast(bytes)); + return OAKENGINE_OK; + } + return OAKENGINE_E_STATE; +} + +extern "C" int oakengine_audio_start_recording( + OakEngineEncodingParams *params, char *error_buf, int error_buf_size) +{ + // Delegate to the encoding-family implementation which handles the + // OakEngineEncodingParams -> EncodingParams conversion internally. + return oakengine_encoding_start_audio_recording(params, error_buf, + error_buf_size); +} + + +extern "C" int oakengine_audio_estimate_envelope_offset( + const double *reference, int reference_len, + const double *candidate, int candidate_len, + const bool *reference_valid, int reference_valid_len, + const bool *candidate_valid, int candidate_valid_len, + uint64_t window_samples, int64_t max_offset_windows, + oak_audio_waveform_offset *out) +{ + if (!out || !reference || !candidate || reference_len < 0 || + candidate_len < 0 || !window_samples) { + return OAKENGINE_E_INVALID; + } + + if ((reference_valid && reference_valid_len != reference_len) || + (candidate_valid && candidate_valid_len != candidate_len)) { + return OAKENGINE_E_INVALID; + } + + QVector ref(reference_len); + std::copy(reference, reference + reference_len, ref.begin()); + QVector cand(candidate_len); + std::copy(candidate, candidate + candidate_len, cand.begin()); + + QVector ref_valid; + if (reference_valid) { + ref_valid.resize(reference_valid_len); + std::copy(reference_valid, reference_valid + reference_valid_len, + ref_valid.begin()); + } + + QVector cand_valid; + if (candidate_valid) { + cand_valid.resize(candidate_valid_len); + std::copy(candidate_valid, candidate_valid + candidate_valid_len, + cand_valid.begin()); + } + + const olive::AudioWaveformSync::OffsetResult result = + olive::AudioWaveformSync::estimate_envelope_offset( + ref, cand, ref_valid, cand_valid, window_samples, max_offset_windows); + + out->offset_samples = result.offset_samples; + out->confidence = result.confidence; + out->valid = result.valid ? 1 : 0; + return OAKENGINE_OK; +} + +extern "C" int oakengine_audio_estimate_stretch_and_offset( + const double *reference, int reference_len, + const double *candidate, int candidate_len, + const bool *reference_valid, int reference_valid_len, + const bool *candidate_valid, int candidate_valid_len, + uint64_t window_samples, int64_t max_offset_windows, + double min_rate, double max_rate, double rate_step, + oak_audio_waveform_stretch_offset *out) +{ + if (!out || !reference || !candidate || reference_len < 0 || + candidate_len < 0 || !window_samples) { + return OAKENGINE_E_INVALID; + } + + if ((reference_valid && reference_valid_len != reference_len) || + (candidate_valid && candidate_valid_len != candidate_len)) { + return OAKENGINE_E_INVALID; + } + + QVector ref(reference_len); + std::copy(reference, reference + reference_len, ref.begin()); + QVector cand(candidate_len); + std::copy(candidate, candidate + candidate_len, cand.begin()); + + QVector ref_valid; + if (reference_valid) { + ref_valid.resize(reference_valid_len); + std::copy(reference_valid, reference_valid + reference_valid_len, + ref_valid.begin()); + } + + QVector cand_valid; + if (candidate_valid) { + cand_valid.resize(candidate_valid_len); + std::copy(candidate_valid, candidate_valid + candidate_valid_len, + cand_valid.begin()); + } + + const olive::AudioWaveformSync::StretchOffsetResult result = + olive::AudioWaveformSync::estimate_stretch_and_offset( + ref, cand, ref_valid, cand_valid, window_samples, max_offset_windows, + min_rate, max_rate, rate_step); + + out->rate = result.rate; + out->offset_samples = result.offset_samples; + out->confidence = result.confidence; + out->valid = result.valid ? 1 : 0; + return OAKENGINE_OK; +} + +extern "C" int oakengine_audio_sync_place_by_source_time( + const oak_audio_sync_source_clip *reference, + const oak_audio_sync_source_clip *candidate, + int64_t reference_timeline_in_num, int64_t reference_timeline_in_den, + oak_audio_sync_placement *out) +{ + if (!reference || !candidate || !out) { + return OAKENGINE_E_INVALID; + } + + olive::AudioSynchronizer::SourceClip ref; + ref.source_start_time = olive::core::Rational( + reference->source_start_time_num, reference->source_start_time_den); + ref.media_in = + olive::core::Rational(reference->media_in_num, reference->media_in_den); + ref.has_source_start_time = reference->has_source_start_time != 0; + + olive::AudioSynchronizer::SourceClip cand; + cand.source_start_time = olive::core::Rational( + candidate->source_start_time_num, candidate->source_start_time_den); + cand.media_in = + olive::core::Rational(candidate->media_in_num, candidate->media_in_den); + cand.has_source_start_time = candidate->has_source_start_time != 0; + + const olive::core::Rational timeline_in(reference_timeline_in_num, + reference_timeline_in_den); + const olive::AudioSynchronizer::Placement placement = + olive::AudioSynchronizer::place_by_source_time(ref, cand, timeline_in); + + out->timeline_in_num = placement.timeline_in.numerator(); + out->timeline_in_den = placement.timeline_in.denominator(); + out->valid = placement.valid ? 1 : 0; + return OAKENGINE_OK; +} + +extern "C" int oakengine_audio_sync_place_by_waveform_offset( + int64_t reference_timeline_in_num, int64_t reference_timeline_in_den, + int64_t candidate_offset_samples, int sample_rate, + oak_audio_sync_placement *out) +{ + if (!out || sample_rate <= 0) { + return OAKENGINE_E_INVALID; + } + + const olive::core::Rational timeline_in(reference_timeline_in_num, + reference_timeline_in_den); + const olive::AudioSynchronizer::Placement placement = + olive::AudioSynchronizer::place_by_waveform_offset( + timeline_in, candidate_offset_samples, sample_rate); + + out->timeline_in_num = placement.timeline_in.numerator(); + out->timeline_in_den = placement.timeline_in.denominator(); + out->valid = placement.valid ? 1 : 0; + return OAKENGINE_OK; +} + +/* ---- Audio format processor (R6 P5) ------------------------------------- */ + +namespace +{ + +olive::core::AudioParams params_from_c(const OakAudioParams *p) +{ + // AudioParams takes ownership of the handle, so hand it a copy. + return olive::core::AudioParams::from_handle(oakcore_audioparams_copy(p)); +} + +} // namespace + +struct OakEngineAudioProcessor { + olive::AudioProcessor proc; + + // Holds the packed output of the most recent convert() so the caller can + // borrow the bytes across the C boundary. + olive::AudioProcessor::Buffer buf; +}; + +extern "C" OakEngineAudioProcessor *oakengine_audio_processor_create(void) +{ + return new (std::nothrow) OakEngineAudioProcessor(); +} + +extern "C" void oakengine_audio_processor_free(OakEngineAudioProcessor *p) +{ + delete p; +} + +extern "C" int oakengine_audio_processor_open(OakEngineAudioProcessor *p, + const OakAudioParams *from, + const OakAudioParams *to, + double tempo) +{ + if (!p || !from || !to) { + return OAKENGINE_E_INVALID; + } + + const olive::core::AudioParams cpp_from = params_from_c(from); + const olive::core::AudioParams cpp_to = params_from_c(to); + return p->proc.open(cpp_from, cpp_to, tempo) ? OAKENGINE_OK + : OAKENGINE_E_FAILED; +} + +extern "C" void oakengine_audio_processor_close(OakEngineAudioProcessor *p) +{ + if (p) { + p->buf.clear(); + p->proc.close(); + } +} + +extern "C" int oakengine_audio_processor_is_open(OakEngineAudioProcessor *p) +{ + return (p && p->proc.is_open()) ? 1 : 0; +} + +extern "C" int oakengine_audio_processor_convert(OakEngineAudioProcessor *p, + float **in, int nb_in_samples, + const void **out_data, + int *out_size) +{ + if (!p) { + return OAKENGINE_E_INVALID; + } + + if (out_data) { + *out_data = nullptr; + } + if (out_size) { + *out_size = 0; + } + + p->buf.clear(); + const int r = p->proc.convert(in, nb_in_samples, &p->buf); + if (r < 0) { + return r; + } + + if (!p->buf.empty()) { + if (out_data) { + *out_data = p->buf.at(0).constData(); + } + if (out_size) { + *out_size = p->buf.at(0).size(); + } + } + return r; +} + +extern "C" OakAudioParams *oakengine_audio_processor_output_params( + OakEngineAudioProcessor *p) +{ + if (!p || !p->proc.is_open()) { + return nullptr; + } + return oakcore_audioparams_copy(p->proc.to().handle()); +} diff --git a/engine/src/capi/color.cpp b/engine/src/capi/color.cpp new file mode 100644 index 000000000..9e1a6da5f --- /dev/null +++ b/engine/src/capi/color.cpp @@ -0,0 +1,448 @@ +/*** + + 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/color.h" + +#include + +#include + +#include "colorinternal.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "render/job/colortransformjob.h" +#include "render/previewautocacher.h" +#include "render/rendermanager.h" + +// The OakEngineColorProcessor handle layout is shared with the other capi +// translation units via colorinternal.h. +struct OakEngineColorConfig { + ocio::ConstConfigRcPtr ptr; +}; + +namespace +{ + +// 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()); +} + +thread_local QString g_last_error; + +void set_error(const QString &error) +{ + g_last_error = error; +} + +olive::ColorManager *impl(const OakEngineColorManager *h) +{ + return reinterpret_cast( + const_cast(h)); +} + +olive::ColorTransform to_cpp(const oak_color_transform &t) +{ + if (t.is_display) { + return olive::ColorTransform( + t.output ? QString::fromUtf8(t.output) : QString(), + t.view ? QString::fromUtf8(t.view) : QString(), + t.look ? QString::fromUtf8(t.look) : QString()); + } + return olive::ColorTransform(t.output ? QString::fromUtf8(t.output) : + QString()); +} + +// The engine's list accessors dereference the config unconditionally; +// guard here so a manager whose config failed to load yields empty lists +// instead of crashing. +bool has_config(const olive::ColorManager *mgr) +{ + return mgr && mgr->get_config(); +} + +QString list_at(const QStringList &l, int index) +{ + return (index >= 0 && index < l.size()) ? l.at(index) : QString(); +} + +} // namespace + +extern "C" { + +int oakengine_color_last_error(char *buf, int buf_size) +{ + return string_to_buf(g_last_error, buf, buf_size); +} + +OakEngineColorManager * +oakengine_color_manager_from_project(OakEngineProject *project) +{ + if (!project) { + return nullptr; + } + auto *p = reinterpret_cast(project); + return reinterpret_cast(p->color_manager()); +} + +int oakengine_color_manager_get_config_filename( + const OakEngineColorManager *mgr, char *buf, int buf_size) +{ + if (!mgr) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(mgr)->get_config_filename(), buf, buf_size); +} + +int oakengine_color_manager_set_config_filename(OakEngineColorManager *mgr, + const char *filename) +{ + if (!mgr || !filename) { + return OAKENGINE_E_INVALID; + } + impl(mgr)->set_config_filename(QString::fromUtf8(filename)); + return OAKENGINE_OK; +} + +int oakengine_color_manager_colorspace_count(const OakEngineColorManager *mgr) +{ + if (!has_config(impl(mgr))) { + return 0; + } + return impl(mgr)->list_available_colorspaces().size(); +} + +int oakengine_color_manager_colorspace_at(const OakEngineColorManager *mgr, + int index, char *buf, int buf_size) +{ + if (!has_config(impl(mgr))) { + return OAKENGINE_E_INVALID; + } + const QString s = + list_at(impl(mgr)->list_available_colorspaces(), index); + if (s.isNull()) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(s, buf, buf_size); +} + +int oakengine_color_manager_display_count(const OakEngineColorManager *mgr) +{ + if (!has_config(impl(mgr))) { + return 0; + } + return impl(mgr)->list_available_displays().size(); +} + +int oakengine_color_manager_display_at(const OakEngineColorManager *mgr, + int index, char *buf, int buf_size) +{ + if (!has_config(impl(mgr))) { + return OAKENGINE_E_INVALID; + } + const QString s = list_at(impl(mgr)->list_available_displays(), index); + if (s.isNull()) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(s, buf, buf_size); +} + +int oakengine_color_manager_view_count(const OakEngineColorManager *mgr, + const char *display) +{ + if (!has_config(impl(mgr))) { + return 0; + } + return impl(mgr) + ->list_available_views(display ? QString::fromUtf8(display) : QString()) + .size(); +} + +int oakengine_color_manager_view_at(const OakEngineColorManager *mgr, + const char *display, int index, char *buf, + int buf_size) +{ + if (!has_config(impl(mgr))) { + return OAKENGINE_E_INVALID; + } + const QString s = list_at( + impl(mgr)->list_available_views(display ? QString::fromUtf8(display) : + QString()), + index); + if (s.isNull()) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(s, buf, buf_size); +} + +int oakengine_color_manager_look_count(const OakEngineColorManager *mgr) +{ + if (!has_config(impl(mgr))) { + return 0; + } + return impl(mgr)->list_available_looks().size(); +} + +int oakengine_color_manager_look_at(const OakEngineColorManager *mgr, + int index, char *buf, int buf_size) +{ + if (!has_config(impl(mgr))) { + return OAKENGINE_E_INVALID; + } + const QString s = list_at(impl(mgr)->list_available_looks(), index); + if (s.isNull()) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(s, buf, buf_size); +} + +int oakengine_color_manager_default_display(const OakEngineColorManager *mgr, + char *buf, int buf_size) +{ + if (!has_config(impl(mgr))) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(mgr)->get_default_display(), buf, buf_size); +} + +int oakengine_color_manager_default_view(const OakEngineColorManager *mgr, + const char *display, char *buf, + int buf_size) +{ + if (!has_config(impl(mgr))) { + return OAKENGINE_E_INVALID; + } + return string_to_buf( + impl(mgr)->get_default_view(display ? QString::fromUtf8(display) : + QString()), + buf, buf_size); +} + +int oakengine_color_manager_default_input_color_space( + const OakEngineColorManager *mgr, char *buf, int buf_size) +{ + if (!mgr) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(mgr)->get_default_input_color_space(), buf, + buf_size); +} + +int oakengine_color_manager_set_default_input_color_space( + OakEngineColorManager *mgr, const char *colorspace) +{ + if (!mgr || !colorspace) { + return OAKENGINE_E_INVALID; + } + impl(mgr)->set_default_input_color_space(QString::fromUtf8(colorspace)); + return OAKENGINE_OK; +} + +int oakengine_color_manager_reference_color_space( + const OakEngineColorManager *mgr, char *buf, int buf_size) +{ + if (!mgr) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(mgr)->get_reference_color_space(), buf, + buf_size); +} + +int oakengine_color_manager_default_luma_coefs( + const OakEngineColorManager *mgr, double *rgb) +{ + if (!mgr || !rgb) { + return OAKENGINE_E_INVALID; + } + impl(mgr)->get_default_luma_coefs(rgb); + return OAKENGINE_OK; +} + +int oakengine_color_manager_compliant_color_space( + const OakEngineColorManager *mgr, const char *name, char *buf, + int buf_size) +{ + if (!has_config(impl(mgr)) || !name) { + return OAKENGINE_E_INVALID; + } + return string_to_buf( + impl(mgr)->get_compliant_color_space(QString::fromUtf8(name)), buf, + buf_size); +} + +int oakengine_color_manager_compliant_transform( + const OakEngineColorManager *mgr, const oak_color_transform *in, + int force_display, int *out_is_display, char *out_output, + int output_size, char *out_view, int view_size, char *out_look, + int look_size) +{ + if (!has_config(impl(mgr)) || !in) { + return OAKENGINE_E_INVALID; + } + const olive::ColorTransform compliant = + impl(mgr)->get_compliant_color_space(to_cpp(*in), force_display != 0); + if (out_is_display) { + *out_is_display = compliant.is_display() ? 1 : 0; + } + string_to_buf(compliant.output(), out_output, output_size); + string_to_buf(compliant.view(), out_view, view_size); + string_to_buf(compliant.look(), out_look, look_size); + return OAKENGINE_OK; +} + +OakEngineColorConfig *oakengine_color_config_load_default(void) +{ + try { + ocio::ConstConfigRcPtr c = olive::ColorManager::get_default_config(); + if (!c) { + set_error(QStringLiteral("no default OCIO config available")); + return nullptr; + } + set_error(QString()); + return new OakEngineColorConfig{std::move(c)}; + } catch (ocio::Exception &e) { + set_error(QString::fromUtf8(e.what())); + return nullptr; + } +} + +OakEngineColorConfig *oakengine_color_config_load_file(const char *filename) +{ + if (!filename) { + set_error(QStringLiteral("no filename given")); + return nullptr; + } + try { + ocio::ConstConfigRcPtr c = + olive::ColorManager::create_config_from_file( + QString::fromUtf8(filename)); + set_error(QString()); + return new OakEngineColorConfig{std::move(c)}; + } catch (ocio::Exception &e) { + set_error(QString::fromUtf8(e.what())); + return nullptr; + } +} + +void oakengine_color_config_free(OakEngineColorConfig *config) +{ + delete config; +} + +int oakengine_color_config_colorspace_count(const OakEngineColorConfig *config) +{ + if (!config || !config->ptr) { + return 0; + } + return olive::ColorManager::list_available_colorspaces(config->ptr).size(); +} + +int oakengine_color_config_colorspace_at(const OakEngineColorConfig *config, + int index, char *buf, int buf_size) +{ + if (!config || !config->ptr) { + return OAKENGINE_E_INVALID; + } + const QString s = list_at( + olive::ColorManager::list_available_colorspaces(config->ptr), index); + if (s.isNull()) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(s, buf, buf_size); +} + +OakEngineColorProcessor *oakengine_color_processor_create( + const OakEngineColorManager *mgr, const char *input, + const oak_color_transform *dest, int direction) +{ + if (!mgr || !input || !dest || + (direction != OAKENGINE_COLOR_PROCESSOR_NORMAL && + direction != OAKENGINE_COLOR_PROCESSOR_INVERSE)) { + return nullptr; + } + // ColorProcessor catches OCIO failures internally and leaves the + // processor null (see engine/render/colorprocessor.cpp), so this never + // throws; validity is reported through is_valid(). + auto *proc = new OakEngineColorProcessor; + proc->ptr = olive::ColorProcessor::create( + impl(mgr), QString::fromUtf8(input), to_cpp(*dest), + direction == OAKENGINE_COLOR_PROCESSOR_INVERSE ? + olive::ColorProcessor::k_inverse : + olive::ColorProcessor::k_normal); + return proc; +} + +void oakengine_color_processor_free(OakEngineColorProcessor *proc) +{ + delete proc; +} + +int oakengine_color_processor_is_valid(const OakEngineColorProcessor *proc) +{ + return (proc && proc->ptr && proc->ptr->get_processor()) ? 1 : 0; +} + +int oakengine_color_processor_convert_color( + const OakEngineColorProcessor *proc, const double *in_rgba, + double *out_rgba) +{ + if (!proc || !proc->ptr || !in_rgba || !out_rgba) { + return OAKENGINE_E_INVALID; + } + const olive::Color out = proc->ptr->convert_color( + olive::Color(in_rgba[0], in_rgba[1], in_rgba[2], in_rgba[3])); + out_rgba[0] = out.red(); + out_rgba[1] = out.green(); + out_rgba[2] = out.blue(); + out_rgba[3] = out.alpha(); + return OAKENGINE_OK; +} + +int oakengine_color_processor_id(const OakEngineColorProcessor *proc, + char *buf, int buf_size) +{ + if (!proc || !proc->ptr) { + return OAKENGINE_E_INVALID; + } + const char *id = proc->ptr->id(); + const int len = id ? int(strlen(id)) : 0; + if (buf && buf_size > 0) { + snprintf(buf, size_t(buf_size), "%s", id ? id : ""); + } + return len; +} + +int oakengine_color_transform_job_set_processor( + void *job, const OakEngineColorProcessor *proc) +{ + if (!job) { + return OAKENGINE_E_INVALID; + } + auto *j = reinterpret_cast(job); + j->set_color_processor(proc ? proc->ptr : olive::ColorProcessorPtr()); + return OAKENGINE_OK; +} + +} // extern "C" diff --git a/engine/src/capi/colorinternal.h b/engine/src/capi/colorinternal.h new file mode 100644 index 000000000..36b08f7e2 --- /dev/null +++ b/engine/src/capi/colorinternal.h @@ -0,0 +1,38 @@ +/*** + + 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_COLORINTERNAL_H +#define OAKENGINE_COLORINTERNAL_H + +// Internal (not installed) shared definition of the opaque color-processor +// handle between color.cpp and the other capi translation units. The +// public header (oakengine/color.h) only forward-declares +// OakEngineColorProcessor; capi code that needs to unwrap the handle (e.g. +// renderer.cpp feeding the render cacher) includes this header. + +#include "render/colorprocessor.h" + +// Owned handle layout: the opaque C type is a heap box around the engine's +// shared pointer (matching the refcounting the C++ API uses). +struct OakEngineColorProcessor { + olive::ColorProcessorPtr ptr; +}; + +#endif // OAKENGINE_COLORINTERNAL_H diff --git a/engine/src/capi/config.cpp b/engine/src/capi/config.cpp new file mode 100644 index 000000000..f786e879d --- /dev/null +++ b/engine/src/capi/config.cpp @@ -0,0 +1,130 @@ +/*** + + 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/config.h" + +#include + +#include +#include + +#include "config/config.h" + +namespace +{ + +int write_string(const QString &s, char *buf, int buf_size) +{ + const QByteArray utf8 = s.toUtf8(); + const int len = int(utf8.size()); + if (buf && buf_size > 0) { + const int n = qMin(len, buf_size - 1); + std::memcpy(buf, utf8.constData(), size_t(n)); + buf[n] = '\0'; + } + return len; +} + +oakengine_config_error_fn g_error_fn = nullptr; +void *g_error_userdata = nullptr; + +void error_handler(const QString &title, const QString &message) +{ + if (g_error_fn) { + const QByteArray t = title.toUtf8(); + const QByteArray m = message.toUtf8(); + g_error_fn(t.constData(), m.constData(), g_error_userdata); + } +} + +} // namespace + +extern "C" int oakengine_config_load(void) +{ + olive::Config::load(); + return OAKENGINE_OK; +} + +extern "C" int oakengine_config_save(void) +{ + olive::Config::save(); + return OAKENGINE_OK; +} + +extern "C" int oakengine_config_get_string(const char *key, char *buf, + int buf_size) +{ + if (!key) { + return OAKENGINE_E_INVALID; + } + const QVariant v = olive::Config::current()[QString::fromUtf8(key)]; + const QString s = v.toString(); + return write_string(s, buf, buf_size); +} + +extern "C" int oakengine_config_set_string(const char *key, + const char *value) +{ + if (!key) { + return OAKENGINE_E_INVALID; + } + olive::Config::current()[QString::fromUtf8(key)] = + QString::fromUtf8(value ? value : ""); + return OAKENGINE_OK; +} + +extern "C" int64_t oakengine_config_get_int(const char *key, + int64_t default_value) +{ + if (!key) { + return default_value; + } + const QVariant v = olive::Config::current()[QString::fromUtf8(key)]; + bool ok = false; + const qlonglong val = v.toLongLong(&ok); + return ok ? static_cast(val) : default_value; +} + +extern "C" int oakengine_config_set_int(const char *key, int64_t value) +{ + if (!key) { + return OAKENGINE_E_INVALID; + } + olive::Config::current()[QString::fromUtf8(key)] = + static_cast(value); + return OAKENGINE_OK; +} + +extern "C" int oakengine_config_set_error_handler( + oakengine_config_error_fn fn, void *userdata) +{ + g_error_fn = fn; + g_error_userdata = userdata; + olive::Config::set_error_handler(fn ? error_handler : nullptr); + return OAKENGINE_OK; +} + +extern "C" int oakengine_config_report_error(const char *title, + const char *message) +{ + olive::Config::report_error(QString::fromUtf8(title ? title : ""), + QString::fromUtf8(message ? message : "")); + return OAKENGINE_OK; +} diff --git a/engine/src/capi/disk.cpp b/engine/src/capi/disk.cpp new file mode 100644 index 000000000..b51b4844e --- /dev/null +++ b/engine/src/capi/disk.cpp @@ -0,0 +1,187 @@ +/*** + + 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/disk.h" + +#include + +#include +#include +#include + +#include "node/project.h" +#include "render/diskmanager.h" + +namespace +{ + +int write_string(const QString &s, char *buf, int buf_size) +{ + const QByteArray utf8 = s.toUtf8(); + const int len = int(utf8.size()); + if (buf && buf_size > 0) { + const int n = qMin(len, buf_size - 1); + std::memcpy(buf, utf8.constData(), size_t(n)); + buf[n] = '\0'; + } + return len; +} + +olive::DiskManager *manager() +{ + return olive::DiskManager::instance(); +} + +olive::DiskCacheFolder *folder_from_path(olive::DiskManager *m, + const char *path) +{ + if (!m) { + return nullptr; + } + if (!path || std::strlen(path) == 0) { + return m->get_default_cache_folder(); + } + return m->get_open_folder(QString::fromUtf8(path)); +} + +struct SettingsHandlerState { + oakengine_disk_settings_fn fn = nullptr; + void *userdata = nullptr; +}; + +SettingsHandlerState g_settings_handler; + +void cpp_settings_handler(olive::DiskCacheFolder *folder, QWidget *parent) +{ + if (!g_settings_handler.fn || !folder) { + return; + } + const QByteArray path = folder->get_path().toUtf8(); + g_settings_handler.fn(path.constData(), parent, g_settings_handler.userdata); +} + +} // namespace + +extern "C" int oakengine_disk_create_instance(void) +{ + olive::DiskManager::create_instance(); + return manager() ? OAKENGINE_OK : OAKENGINE_E_FAILED; +} + +extern "C" int oakengine_disk_destroy_instance(void) +{ + olive::DiskManager::destroy_instance(); + return OAKENGINE_OK; +} + +extern "C" int oakengine_disk_set_settings_handler( + oakengine_disk_settings_fn fn, void *userdata) +{ + g_settings_handler.fn = fn; + g_settings_handler.userdata = userdata; + + olive::DiskManager::set_show_disk_cache_settings_handler( + fn ? cpp_settings_handler : olive::DiskManager::ShowDiskCacheSettingsHandler{}); + + return OAKENGINE_OK; +} + +extern "C" int oakengine_disk_show_settings_dialog(const char *path, + void *parent_window) +{ + olive::DiskManager *m = manager(); + if (!m) { + return OAKENGINE_E_STATE; + } + + olive::DiskCacheFolder *folder = folder_from_path(m, path); + if (!folder) { + return OAKENGINE_E_FAILED; + } + + m->show_disk_cache_settings_dialog(folder, + static_cast(parent_window)); + return OAKENGINE_OK; +} + +extern "C" int oakengine_disk_show_change_confirmation_dialog( + void *parent_window) +{ + return olive::DiskManager::show_disk_cache_change_confirmation_dialog( + static_cast(parent_window)) + ? 1 + : 0; +} + +extern "C" int oakengine_disk_clear_cache(const char *path) +{ + olive::DiskManager *m = manager(); + if (!m) { + return 0; + } + + olive::DiskCacheFolder *folder = folder_from_path(m, path); + if (!folder) { + return 0; + } + + return m->clear_disk_cache(folder->get_path()) ? 1 : 0; +} + +extern "C" int oakengine_disk_get_default_cache_path(char *buf, int buf_size) +{ + olive::DiskManager *m = manager(); + if (!m) { + return OAKENGINE_E_STATE; + } + + return write_string(m->get_default_cache_path(), buf, buf_size); +} + +extern "C" int oakengine_disk_set_default_cache_path(const char *path) +{ + if (!path) { + return OAKENGINE_E_INVALID; + } + + olive::DiskManager *m = manager(); + if (!m) { + return OAKENGINE_E_STATE; + } + + m->get_default_cache_folder()->set_path(QString::fromUtf8(path)); + return OAKENGINE_OK; +} + +extern "C" void *oakengine_disk_get_open_folder(const char *path) +{ + return folder_from_path(manager(), path); +} + +extern "C" int oakengine_disk_invalidate_project(OakEngineProject *project) +{ + olive::DiskManager *m = manager(); + if (!m) { + return OAKENGINE_E_STATE; + } + + emit m->invalidate_project(reinterpret_cast(project)); + return OAKENGINE_OK; +} diff --git a/engine/src/capi/display.cpp b/engine/src/capi/display.cpp new file mode 100644 index 000000000..369b72c3a --- /dev/null +++ b/engine/src/capi/display.cpp @@ -0,0 +1,182 @@ +/*** + + 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/display.h" + +#include +#include +#include + +#include "codec/frame.h" +#include "render/job/colortransformjob.h" +#include "render/opengl/openglrenderer.h" +#include "render/renderer.h" +#include "render/texture.h" +#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND +#include "render/backend/dynamicrenderer.h" +#endif + +extern "C" { + +void *oakengine_display_renderer_create_dynamic(const char *backend_name, + void *parent) +{ +#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND + QObject *p = static_cast(parent); + auto *dyn = new olive::DynamicRenderer( + QString::fromUtf8(backend_name ? backend_name : ""), p); + if (dyn->load()) { + return dyn; + } + // Backend library failed to load: drop it so the caller can fall back + // to the built-in OpenGL renderer. + delete dyn; + return nullptr; +#else + (void)backend_name; + (void)parent; + return nullptr; +#endif +} + +void *oakengine_display_renderer_create_opengl(void *parent) +{ + return new olive::OpenGLRenderer(static_cast(parent)); +} + +int oakengine_display_renderer_init(void *renderer, void *gl_context) +{ + olive::Renderer *r = static_cast(renderer); + if (!r) { + return OAKENGINE_E_INVALID; + } + + if (gl_context) { + QOpenGLContext *ctx = static_cast(gl_context); +#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND + if (auto *dyn = dynamic_cast(r)) { + dyn->init_with_open_gl_context(ctx); + dyn->post_init(); + return OAKENGINE_OK; + } +#endif + auto *gl = static_cast(r); + gl->init(ctx); + gl->post_init(); + return OAKENGINE_OK; + } + + r->init(); + r->post_init(); + return OAKENGINE_OK; +} + +void oakengine_display_renderer_destroy(void *renderer) +{ + olive::Renderer *r = static_cast(renderer); + if (!r) { + return; + } + r->destroy(); + r->post_destroy(); +} + +void oakengine_display_renderer_create_texture(void *renderer, + const void *video_params, + const void *pixels, int linesize, + void *out_texture) +{ + olive::Renderer *r = static_cast(renderer); + if (!r || !video_params || !out_texture) { + return; + } + const olive::VideoParams ¶ms = + *static_cast(video_params); + *static_cast(out_texture) = + r->create_texture(params, pixels, linesize); +} + +void oakengine_display_renderer_blit_color_managed(void *renderer, + const void *color_job, + void *dst_texture, + const void *video_params) +{ + olive::Renderer *r = static_cast(renderer); + if (!r || !color_job) { + return; + } + const olive::ColorTransformJob &job = + *static_cast(color_job); + olive::Texture *dst = static_cast(dst_texture); + if (video_params) { + r->blit_color_managed( + job, dst, *static_cast(video_params)); + } else if (dst) { + r->blit_color_managed(job, dst, dst->params()); + } +} + +void oakengine_display_texture_upload(void *texture, void *pixels, int linesize) +{ + olive::Texture *t = static_cast(texture); + if (!t) { + return; + } + t->upload(pixels, linesize); +} + +void oakengine_display_texture_download(void *texture, void *pixels, + int linesize) +{ + olive::Texture *t = static_cast(texture); + if (!t) { + return; + } + t->download(pixels, linesize); +} + +void oakengine_codec_frame_create(void *out_frame) +{ + if (!out_frame) { + return; + } + *static_cast(out_frame) = olive::Frame::create(); +} + +void oakengine_codec_frame_set_video_params(void *frame, + const void *video_params) +{ + olive::Frame *f = static_cast(frame); + if (!f || !video_params) { + return; + } + f->set_video_params(*static_cast(video_params)); +} + +int oakengine_codec_frame_allocate(void *frame) +{ + olive::Frame *f = static_cast(frame); + if (!f) { + return 0; + } + return f->allocate() ? 1 : 0; +} + +} // extern "C" diff --git a/engine/src/capi/encoding.cpp b/engine/src/capi/encoding.cpp new file mode 100644 index 000000000..dbc535f0a --- /dev/null +++ b/engine/src/capi/encoding.cpp @@ -0,0 +1,1160 @@ +/*** + + 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/encoding.h" +#include "oakengine/exporter.h" + +#include +#include +#include + +#include "audio/audiomanager.h" +#include "coreengine.h" +#include "exportinternal.h" +#include "node/project.h" +#include "node/project/sequence/sequence.h" +#include "render/rendermanager.h" +#include "codec/encoder.h" +#include "codec/ffmpeg/ffmpegencoder.h" +#include "node/output/viewer/viewer.h" + +namespace +{ + +// 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()); +} + +bool valid_format(int format) +{ + return format >= 0 && format < olive::ExportFormat::k_format_count; +} + +bool valid_codec(int codec) +{ + return codec >= 0 && codec < olive::ExportCodec::k_codec_count; +} + +olive::VideoParams to_cpp(const oak_video_params &v) +{ + olive::VideoParams vp( + v.width, v.height, + olive::Rational(v.time_base_num, v.time_base_den), + static_cast(v.format), + olive::VideoParams::k_internal_channel_count, + olive::Rational(v.pixel_aspect_num, v.pixel_aspect_den), + static_cast(v.interlacing), + v.divider > 0 ? v.divider : 1); + vp.set_color_range(static_cast(v.color_range)); + return vp; +} + +void from_cpp(const olive::VideoParams &vp, oak_video_params *out) +{ + out->width = vp.width(); + out->height = vp.height(); + out->time_base_num = vp.time_base().numerator(); + out->time_base_den = vp.time_base().denominator(); + out->format = int(vp.format()); + out->pixel_aspect_num = vp.pixel_aspect_ratio().numerator(); + out->pixel_aspect_den = vp.pixel_aspect_ratio().denominator(); + out->interlacing = int(vp.interlacing()); + out->color_range = int(vp.color_range()); + out->divider = vp.divider(); +} + +olive::EncodingParams *impl(OakEngineEncodingParams *p) +{ + return reinterpret_cast(p); +} + +const olive::EncodingParams *impl(const OakEngineEncodingParams *p) +{ + return reinterpret_cast(p); +} + +} // namespace + +struct OakEngineEncodingParams : public olive::EncodingParams { +}; + +extern "C" +{ + +/* ---- Container format / codec metadata ---------------------------------- */ + +int oakengine_encoding_format_count(void) +{ + return olive::ExportFormat::k_format_count; +} + +int oakengine_encoding_format_name(int format, char *buf, int buf_size) +{ + if (!valid_format(format)) { + return -1; + } + return string_to_buf( + olive::ExportFormat::get_name(olive::ExportFormat::Format(format)), buf, + buf_size); +} + +int oakengine_encoding_format_extension(int format, char *buf, int buf_size) +{ + if (!valid_format(format)) { + return -1; + } + return string_to_buf( + olive::ExportFormat::get_extension(olive::ExportFormat::Format(format)), + buf, buf_size); +} + +int oakengine_encoding_format_video_codec_count(int format) +{ + if (!valid_format(format)) { + return -1; + } + return olive::ExportFormat::get_video_codecs( + olive::ExportFormat::Format(format)) + .size(); +} + +int oakengine_encoding_format_video_codec_at(int format, int index) +{ + if (!valid_format(format)) { + return -1; + } + const auto l = + olive::ExportFormat::get_video_codecs(olive::ExportFormat::Format(format)); + return (index >= 0 && index < l.size()) ? int(l.at(index)) : -1; +} + +int oakengine_encoding_format_audio_codec_count(int format) +{ + if (!valid_format(format)) { + return -1; + } + return olive::ExportFormat::get_audio_codecs( + olive::ExportFormat::Format(format)) + .size(); +} + +int oakengine_encoding_format_audio_codec_at(int format, int index) +{ + if (!valid_format(format)) { + return -1; + } + const auto l = + olive::ExportFormat::get_audio_codecs(olive::ExportFormat::Format(format)); + return (index >= 0 && index < l.size()) ? int(l.at(index)) : -1; +} + +int oakengine_encoding_format_subtitle_codec_count(int format) +{ + if (!valid_format(format)) { + return -1; + } + return olive::ExportFormat::get_subtitle_codecs( + olive::ExportFormat::Format(format)) + .size(); +} + +int oakengine_encoding_format_subtitle_codec_at(int format, int index) +{ + if (!valid_format(format)) { + return -1; + } + const auto l = olive::ExportFormat::get_subtitle_codecs( + olive::ExportFormat::Format(format)); + return (index >= 0 && index < l.size()) ? int(l.at(index)) : -1; +} + +int oakengine_encoding_codec_name(int codec, char *buf, int buf_size) +{ + if (!valid_codec(codec)) { + return -1; + } + return string_to_buf( + olive::ExportCodec::get_codec_name(olive::ExportCodec::Codec(codec)), buf, + buf_size); +} + +int oakengine_encoding_codec_is_still_image(int codec) +{ + if (!valid_codec(codec)) { + return 0; + } + return olive::ExportCodec::is_codec_a_still_image( + olive::ExportCodec::Codec(codec)) ? + 1 : + 0; +} + +int oakengine_encoding_codec_is_lossless(int codec) +{ + if (!valid_codec(codec)) { + return 0; + } + return olive::ExportCodec::is_codec_lossless(olive::ExportCodec::Codec(codec)) ? + 1 : + 0; +} + +int oakengine_encoding_pix_fmt_count(int format, int codec) +{ + if (!valid_format(format) || !valid_codec(codec)) { + return -1; + } + return olive::ExportFormat::get_pixel_formats_for_codec( + olive::ExportFormat::Format(format), + olive::ExportCodec::Codec(codec)) + .size(); +} + +int oakengine_encoding_pix_fmt_at(int format, int codec, int index, char *buf, + int buf_size) +{ + if (!valid_format(format) || !valid_codec(codec)) { + return -1; + } + const QStringList l = olive::ExportFormat::get_pixel_formats_for_codec( + olive::ExportFormat::Format(format), olive::ExportCodec::Codec(codec)); + if (index < 0 || index >= l.size()) { + return -1; + } + return string_to_buf(l.at(index), buf, buf_size); +} + +int oakengine_encoding_pix_fmt_index(int codec, const char *pix_fmt) +{ + if (!valid_codec(codec) || !pix_fmt || !pix_fmt[0]) { + return 0; + } + olive::FFmpegEncoder probe{ olive::EncodingParams() }; + const int index = + probe.get_pixel_formats_for_codec(olive::ExportCodec::Codec(codec)) + .indexOf(QString::fromUtf8(pix_fmt)); + return index >= 0 ? index : 0; +} + +int oakengine_encoding_sample_format_count(int format, int codec) +{ + if (!valid_format(format) || !valid_codec(codec)) { + return -1; + } + return int(olive::ExportFormat::get_sample_formats_for_codec( + olive::ExportFormat::Format(format), + olive::ExportCodec::Codec(codec)) + .size()); +} + +int oakengine_encoding_sample_format_at(int format, int codec, int index) +{ + if (!valid_format(format) || !valid_codec(codec)) { + return -1; + } + const auto l = olive::ExportFormat::get_sample_formats_for_codec( + olive::ExportFormat::Format(format), olive::ExportCodec::Codec(codec)); + return (index >= 0 && index < int(l.size())) ? int(l[size_t(index)]) : -1; +} + +/* ---- Image-sequence filename helpers ------------------------------------ */ + +int oakengine_encoding_filename_contains_digit_placeholder(const char *filename) +{ + if (!filename) { + return 0; + } + return olive::Encoder::filename_contains_digit_placeholder( + QString::fromUtf8(filename)) ? + 1 : + 0; +} + +int oakengine_encoding_image_sequence_digit_count(const char *filename) +{ + if (!filename) { + return 0; + } + return olive::Encoder::get_image_sequence_placeholder_digit_count( + QString::fromUtf8(filename)); +} + +int oakengine_encoding_filename_remove_digit_placeholder(const char *filename, + char *buf, int buf_size) +{ + if (!filename) { + return -1; + } + return string_to_buf(olive::Encoder::filename_remove_digit_placeholder( + QString::fromUtf8(filename)), + buf, buf_size); +} + +int oakengine_encoding_generate_matrix(int method, int src_width, + int src_height, int dest_width, + int dest_height, float out16[16]) +{ + if (!out16 || method < 0 || method > 2 || src_width <= 0 || src_height <= 0 || + dest_width <= 0 || dest_height <= 0) { + return OAKENGINE_E_INVALID; + } + const QMatrix4x4 m = olive::EncodingParams::generate_matrix( + olive::EncodingParams::VideoScalingMethod(method), src_width, src_height, + dest_width, dest_height); + m.copyDataTo(out16); + return OAKENGINE_OK; +} + +/* ---- Encoding parameters handle ----------------------------------------- */ + +OakEngineEncodingParams *oakengine_encoding_params_create(void) +{ + return new OakEngineEncodingParams; +} + +void oakengine_encoding_params_destroy(OakEngineEncodingParams *params) +{ + delete params; +} + +int oakengine_encoding_params_is_valid(const OakEngineEncodingParams *params) +{ + return params && impl(params)->is_valid() ? 1 : 0; +} + +int oakengine_encoding_params_set_filename(OakEngineEncodingParams *params, + const char *filename) +{ + if (!params || !filename) { + return OAKENGINE_E_INVALID; + } + impl(params)->set_filename(QString::fromUtf8(filename)); + return OAKENGINE_OK; +} + +int oakengine_encoding_params_filename(const OakEngineEncodingParams *params, + char *buf, int buf_size) +{ + if (!params) { + return -1; + } + return string_to_buf(impl(params)->filename(), buf, buf_size); +} + +int oakengine_encoding_params_set_format(OakEngineEncodingParams *params, + int format) +{ + if (!params || !valid_format(format)) { + return OAKENGINE_E_INVALID; + } + impl(params)->set_format(olive::ExportFormat::Format(format)); + return OAKENGINE_OK; +} + +int oakengine_encoding_params_format(const OakEngineEncodingParams *params) +{ + if (!params || impl(params)->format() == olive::ExportFormat::k_format_count) { + return -1; + } + return int(impl(params)->format()); +} + +int oakengine_encoding_params_enable_video(OakEngineEncodingParams *params, + const oak_video_params *video, + int codec) +{ + if (!params || !video || !valid_codec(codec) || video->width <= 0 || + video->height <= 0 || video->time_base_num <= 0 || + video->time_base_den <= 0) { + return OAKENGINE_E_INVALID; + } + impl(params)->enable_video(to_cpp(*video), olive::ExportCodec::Codec(codec)); + return OAKENGINE_OK; +} + +int oakengine_encoding_params_enable_audio(OakEngineEncodingParams *params, + int sample_rate, + uint64_t channel_layout, + int sample_format, int codec) +{ + if (!params || !valid_codec(codec) || sample_rate <= 0 || + channel_layout == 0) { + return OAKENGINE_E_INVALID; + } + impl(params)->enable_audio( + olive::AudioParams(sample_rate, channel_layout, + olive::core::SampleFormat::Format(sample_format)), + olive::ExportCodec::Codec(codec)); + return OAKENGINE_OK; +} + +int oakengine_encoding_params_enable_subtitles(OakEngineEncodingParams *params, + int codec) +{ + if (!params || !valid_codec(codec)) { + return OAKENGINE_E_INVALID; + } + impl(params)->enable_subtitles(olive::ExportCodec::Codec(codec)); + return OAKENGINE_OK; +} + +int oakengine_encoding_params_enable_sidecar_subtitles( + OakEngineEncodingParams *params, int format, int codec) +{ + if (!params || !valid_format(format) || !valid_codec(codec)) { + return OAKENGINE_E_INVALID; + } + impl(params)->enable_sidecar_subtitles(olive::ExportFormat::Format(format), + olive::ExportCodec::Codec(codec)); + return OAKENGINE_OK; +} + +void oakengine_encoding_params_disable_video(OakEngineEncodingParams *params) +{ + if (params) { + impl(params)->disable_video(); + } +} + +void oakengine_encoding_params_disable_audio(OakEngineEncodingParams *params) +{ + if (params) { + impl(params)->disable_audio(); + } +} + +void oakengine_encoding_params_disable_subtitles(OakEngineEncodingParams *params) +{ + if (params) { + impl(params)->disable_subtitles(); + } +} + +int oakengine_encoding_params_video_enabled(const OakEngineEncodingParams *params) +{ + return params && impl(params)->video_enabled() ? 1 : 0; +} + +int oakengine_encoding_params_video_codec(const OakEngineEncodingParams *params) +{ + return params ? int(impl(params)->video_codec()) : -1; +} + +int oakengine_encoding_params_get_video_params( + const OakEngineEncodingParams *params, oak_video_params *out) +{ + if (!params || !out) { + return OAKENGINE_E_INVALID; + } + if (!impl(params)->video_enabled()) { + return OAKENGINE_E_STATE; + } + from_cpp(impl(params)->video_params(), out); + return OAKENGINE_OK; +} + +int oakengine_encoding_params_audio_enabled(const OakEngineEncodingParams *params) +{ + return params && impl(params)->audio_enabled() ? 1 : 0; +} + +int oakengine_encoding_params_audio_codec(const OakEngineEncodingParams *params) +{ + return params ? int(impl(params)->audio_codec()) : -1; +} + +int oakengine_encoding_params_get_audio_params( + const OakEngineEncodingParams *params, int *sample_rate, + uint64_t *channel_layout, int *sample_format) +{ + if (!params) { + return OAKENGINE_E_INVALID; + } + if (!impl(params)->audio_enabled()) { + return OAKENGINE_E_STATE; + } + const olive::AudioParams &ap = impl(params)->audio_params(); + if (sample_rate) { + *sample_rate = ap.sample_rate(); + } + if (channel_layout) { + *channel_layout = ap.channel_layout(); + } + if (sample_format) { + *sample_format = int(ap.format()); + } + return OAKENGINE_OK; +} + +int oakengine_encoding_params_subtitles_enabled( + const OakEngineEncodingParams *params) +{ + return params && impl(params)->subtitles_enabled() ? 1 : 0; +} + +int oakengine_encoding_params_subtitles_are_sidecar( + const OakEngineEncodingParams *params) +{ + return params && impl(params)->subtitles_are_sidecar() ? 1 : 0; +} + +int oakengine_encoding_params_subtitles_sidecar_format( + const OakEngineEncodingParams *params) +{ + return params ? int(impl(params)->subtitle_sidecar_fmt()) : -1; +} + +int oakengine_encoding_params_subtitles_codec( + const OakEngineEncodingParams *params) +{ + return params ? int(impl(params)->subtitles_codec()) : -1; +} + +void oakengine_encoding_params_set_video_bit_rate( + OakEngineEncodingParams *params, int64_t rate) +{ + if (params) { + impl(params)->set_video_bit_rate(rate); + } +} + +int64_t +oakengine_encoding_params_video_bit_rate(const OakEngineEncodingParams *params) +{ + return params ? impl(params)->video_bit_rate() : 0; +} + +void oakengine_encoding_params_set_video_min_bit_rate( + OakEngineEncodingParams *params, int64_t rate) +{ + if (params) { + impl(params)->set_video_min_bit_rate(rate); + } +} + +int64_t oakengine_encoding_params_video_min_bit_rate( + const OakEngineEncodingParams *params) +{ + return params ? impl(params)->video_min_bit_rate() : 0; +} + +void oakengine_encoding_params_set_video_max_bit_rate( + OakEngineEncodingParams *params, int64_t rate) +{ + if (params) { + impl(params)->set_video_max_bit_rate(rate); + } +} + +int64_t oakengine_encoding_params_video_max_bit_rate( + const OakEngineEncodingParams *params) +{ + return params ? impl(params)->video_max_bit_rate() : 0; +} + +void oakengine_encoding_params_set_video_buffer_size( + OakEngineEncodingParams *params, int64_t size) +{ + if (params) { + impl(params)->set_video_buffer_size(size); + } +} + +int64_t oakengine_encoding_params_video_buffer_size( + const OakEngineEncodingParams *params) +{ + return params ? impl(params)->video_buffer_size() : 0; +} + +void oakengine_encoding_params_set_video_threads(OakEngineEncodingParams *params, + int threads) +{ + if (params) { + impl(params)->set_video_threads(threads); + } +} + +int oakengine_encoding_params_video_threads( + const OakEngineEncodingParams *params) +{ + return params ? impl(params)->video_threads() : 0; +} + +void oakengine_encoding_params_set_audio_bit_rate( + OakEngineEncodingParams *params, int64_t rate) +{ + if (params) { + impl(params)->set_audio_bit_rate(rate); + } +} + +int64_t +oakengine_encoding_params_audio_bit_rate(const OakEngineEncodingParams *params) +{ + return params ? impl(params)->audio_bit_rate() : 0; +} + +int oakengine_encoding_params_set_video_pix_fmt(OakEngineEncodingParams *params, + const char *pix_fmt) +{ + if (!params || !pix_fmt) { + return OAKENGINE_E_INVALID; + } + impl(params)->set_video_pix_fmt(QString::fromUtf8(pix_fmt)); + return OAKENGINE_OK; +} + +int oakengine_encoding_params_video_pix_fmt( + const OakEngineEncodingParams *params, char *buf, int buf_size) +{ + if (!params) { + return -1; + } + return string_to_buf(impl(params)->video_pix_fmt(), buf, buf_size); +} + +void oakengine_encoding_params_set_video_is_image_sequence( + OakEngineEncodingParams *params, int is_image_sequence) +{ + if (params) { + impl(params)->set_video_is_image_sequence(is_image_sequence != 0); + } +} + +int oakengine_encoding_params_video_is_image_sequence( + const OakEngineEncodingParams *params) +{ + return params && impl(params)->video_is_image_sequence() ? 1 : 0; +} + +int oakengine_encoding_params_set_color_transform( + OakEngineEncodingParams *params, const char *output_name) +{ + if (!params) { + return OAKENGINE_E_INVALID; + } + impl(params)->set_color_transform( + olive::ColorTransform(QString::fromUtf8(output_name ? output_name : ""))); + return OAKENGINE_OK; +} + +int oakengine_encoding_params_color_transform_output( + const OakEngineEncodingParams *params, char *buf, int buf_size) +{ + if (!params) { + return -1; + } + return string_to_buf(impl(params)->color_transform().output(), buf, buf_size); +} + +void oakengine_encoding_params_set_export_length( + OakEngineEncodingParams *params, int num, int den) +{ + if (params && den != 0) { + impl(params)->set_export_length(olive::Rational(num, den)); + } +} + +int oakengine_encoding_params_get_export_length( + const OakEngineEncodingParams *params, int *num, int *den) +{ + if (!params) { + return OAKENGINE_E_INVALID; + } + const olive::Rational r = impl(params)->get_export_length(); + if (num) { + *num = r.numerator(); + } + if (den) { + *den = r.denominator(); + } + return OAKENGINE_OK; +} + +void oakengine_encoding_params_set_custom_range(OakEngineEncodingParams *params, + int64_t in_num, int64_t in_den, + int64_t out_num, + int64_t out_den) +{ + if (params && in_den != 0 && out_den != 0) { + impl(params)->set_custom_range( + olive::TimeRange(olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den))); + } +} + +int oakengine_encoding_params_has_custom_range( + const OakEngineEncodingParams *params) +{ + return params && impl(params)->has_custom_range() ? 1 : 0; +} + +int oakengine_encoding_params_get_custom_range( + const OakEngineEncodingParams *params, int64_t *in_num, int64_t *in_den, + int64_t *out_num, int64_t *out_den) +{ + if (!params) { + return OAKENGINE_E_INVALID; + } + if (!impl(params)->has_custom_range()) { + return OAKENGINE_E_NOT_FOUND; + } + const olive::TimeRange &r = impl(params)->custom_range(); + if (in_num) { + *in_num = r.in().numerator(); + } + if (in_den) { + *in_den = r.in().denominator(); + } + if (out_num) { + *out_num = r.out().numerator(); + } + if (out_den) { + *out_den = r.out().denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_encoding_params_set_video_scaling_method( + OakEngineEncodingParams *params, int method) +{ + if (!params || method < 0 || method > 2) { + return OAKENGINE_E_INVALID; + } + impl(params)->set_video_scaling_method( + olive::EncodingParams::VideoScalingMethod(method)); + return OAKENGINE_OK; +} + +int oakengine_encoding_params_video_scaling_method( + const OakEngineEncodingParams *params) +{ + return params ? int(impl(params)->video_scaling_method()) : -1; +} + +int oakengine_encoding_params_set_video_option(OakEngineEncodingParams *params, + const char *key, + const char *value) +{ + if (!params || !key || !value) { + return OAKENGINE_E_INVALID; + } + impl(params)->set_video_option(QString::fromUtf8(key), + QString::fromUtf8(value)); + return OAKENGINE_OK; +} + +int oakengine_encoding_params_video_option(const OakEngineEncodingParams *params, + const char *key, char *buf, + int buf_size) +{ + if (!params || !key) { + return -1; + } + const QString k = QString::fromUtf8(key); + if (!impl(params)->has_video_opt(k)) { + return OAKENGINE_E_NOT_FOUND; + } + return string_to_buf(impl(params)->video_option(k), buf, buf_size); +} + +/* ---- Presets ------------------------------------------------------------- */ + +int oakengine_encoding_preset_path(char *buf, int buf_size) +{ + return string_to_buf(olive::EncodingParams::get_preset_path().absolutePath(), + buf, buf_size); +} + +int oakengine_encoding_preset_count(void) +{ + return olive::EncodingParams::get_list_of_presets().size(); +} + +int oakengine_encoding_preset_name(int index, char *buf, int buf_size) +{ + const QStringList l = olive::EncodingParams::get_list_of_presets(); + if (index < 0 || index >= l.size()) { + return -1; + } + return string_to_buf(l.at(index), buf, buf_size); +} + +int oakengine_encoding_params_load_file(OakEngineEncodingParams *params, + const char *path) +{ + if (!params || !path) { + return OAKENGINE_E_INVALID; + } + QFile f(QString::fromUtf8(path)); + if (!f.open(QFile::ReadOnly)) { + return OAKENGINE_E_FAILED; + } + const bool ok = impl(params)->load(&f); + f.close(); + return ok ? OAKENGINE_OK : OAKENGINE_E_FAILED; +} + +int oakengine_encoding_params_save_file(const OakEngineEncodingParams *params, + const char *path) +{ + if (!params || !path) { + return OAKENGINE_E_INVALID; + } + QFile f(QString::fromUtf8(path)); + if (!f.open(QFile::WriteOnly)) { + return OAKENGINE_E_FAILED; + } + impl(params)->save(&f); + f.close(); + return OAKENGINE_OK; +} + +/* ---- Export execution / per-sequence last-used --------------------------- */ + +OakEngineEncodingParams * +oakengine_encoding_params_get_last_used(OakEngineSequence *seq) +{ + olive::ViewerOutput *viewer = reinterpret_cast(seq); + if (!viewer || !viewer->get_last_used_encoding_params().is_valid()) { + return nullptr; + } + auto *copy = new OakEngineEncodingParams; + *static_cast(copy) = + viewer->get_last_used_encoding_params(); + return copy; +} + +void oakengine_encoding_params_set_last_used( + OakEngineSequence *seq, const OakEngineEncodingParams *params) +{ + olive::ViewerOutput *viewer = reinterpret_cast(seq); + if (viewer && params) { + viewer->set_last_used_encoding_params(*impl(params)); + } +} + +int oakengine_encoding_start_audio_recording( + const OakEngineEncodingParams *params, char *errbuf, int errbuf_size) +{ + if (!params || !impl(params)->audio_enabled()) { + return OAKENGINE_E_INVALID; + } + if (!olive::AudioManager::instance()) { + return OAKENGINE_E_STATE; + } + QString error; + if (!olive::AudioManager::instance()->start_recording(*impl(params), &error)) { + string_to_buf(error, errbuf, errbuf_size); + return OAKENGINE_E_FAILED; + } + return OAKENGINE_OK; +} + +/* ---- VideoParams static data (oakengine/videoparams.h) ------------------- */ + +int oakengine_video_params_supported_frame_rate_count(void) +{ + return olive::VideoParams::k_supported_frame_rates.size(); +} + +int oakengine_video_params_supported_frame_rate_at(int index, int *num, int *den) +{ + const auto &l = olive::VideoParams::k_supported_frame_rates; + if (index < 0 || index >= l.size()) { + return OAKENGINE_E_INVALID; + } + if (num) { + *num = l.at(index).numerator(); + } + if (den) { + *den = l.at(index).denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_video_params_frame_rate_to_string(int num, int den, char *buf, + int buf_size) +{ + if (den == 0) { + return -1; + } + return string_to_buf( + olive::VideoParams::frame_rate_to_string(olive::Rational(num, den)), buf, + buf_size); +} + +int oakengine_video_params_standard_pixel_aspect_count(void) +{ + return olive::VideoParams::k_standard_pixel_aspects.size(); +} + +int oakengine_video_params_standard_pixel_aspect_at(int index, int *num, + int *den) +{ + const auto &l = olive::VideoParams::k_standard_pixel_aspects; + if (index < 0 || index >= l.size()) { + return OAKENGINE_E_INVALID; + } + if (num) { + *num = l.at(index).numerator(); + } + if (den) { + *den = l.at(index).denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_video_params_standard_pixel_aspect_name(int index, char *buf, + int buf_size) +{ + const QStringList l = + olive::VideoParams::get_standard_pixel_aspect_ratio_names(); + if (index < 0 || index >= l.size()) { + return -1; + } + return string_to_buf(l.at(index), buf, buf_size); +} + +int oakengine_video_params_format_pixel_aspect_ratio_string( + const char *format, int num, int den, char *buf, int buf_size) +{ + if (!format || den == 0) { + return -1; + } + return string_to_buf(olive::VideoParams::format_pixel_aspect_ratio_string( + QString::fromUtf8(format), olive::Rational(num, den)), + buf, buf_size); +} + +int oakengine_video_params_supported_divider_count(void) +{ + return olive::VideoParams::k_supported_dividers.size(); +} + +int oakengine_video_params_supported_divider_at(int index) +{ + const auto &l = olive::VideoParams::k_supported_dividers; + return (index >= 0 && index < l.size()) ? l.at(index) : -1; +} + +int oakengine_video_params_divider_name(int divider, char *buf, int buf_size) +{ + return string_to_buf(olive::VideoParams::get_name_for_divider(divider), buf, + buf_size); +} + +int oakengine_video_params_format_is_float(int format) +{ + return olive::VideoParams::format_is_float(olive::PixelFormat::Format(format)) ? + 1 : + 0; +} + +int oakengine_video_params_pixel_format_name(int format, char *buf, + int buf_size) +{ + return string_to_buf(olive::VideoParams::get_format_name( + olive::PixelFormat::Format(format)), + buf, buf_size); +} + +int oakengine_video_params_effective_size(int width, int height, int divider, + int *out_width, int *out_height) +{ + if (width <= 0 || height <= 0 || divider <= 0) { + return OAKENGINE_E_INVALID; + } + if (out_width) { + *out_width = olive::VideoParams::get_scaled_dimension(width, divider); + } + if (out_height) { + *out_height = olive::VideoParams::get_scaled_dimension(height, divider); + } + return OAKENGINE_OK; +} + +int oakengine_video_params_make(oak_video_params *p, int width, int height, + int time_base_num, int time_base_den, + int format, int pixel_aspect_num, + int pixel_aspect_den, int interlacing, + int color_range, int divider) +{ + if (!p) { + return OAKENGINE_E_INVALID; + } + p->width = width; + p->height = height; + p->time_base_num = time_base_num; + p->time_base_den = time_base_den; + p->format = format; + p->pixel_aspect_num = pixel_aspect_num; + p->pixel_aspect_den = pixel_aspect_den; + p->interlacing = interlacing; + p->color_range = color_range; + p->divider = divider; + return OAKENGINE_OK; +} + +void *oakengine_video_params_create(const oak_video_params *pod) +{ + if (!pod) { + return nullptr; + } + + olive::VideoParams *p; + if (pod->width > 0 && pod->height > 0 && pod->time_base_num != 0 && + pod->time_base_den != 0) { + p = new olive::VideoParams( + pod->width, pod->height, + olive::Rational(pod->time_base_num, pod->time_base_den), + olive::PixelFormat::Format(pod->format), + olive::VideoParams::k_internal_channel_count, + olive::Rational(pod->pixel_aspect_num, pod->pixel_aspect_den), + static_cast(pod->interlacing), + pod->divider > 0 ? pod->divider : 1); + } else if (pod->width > 0 && pod->height > 0) { + p = new olive::VideoParams( + pod->width, pod->height, + olive::PixelFormat::Format(pod->format), + olive::VideoParams::k_internal_channel_count, + olive::Rational(pod->pixel_aspect_num, pod->pixel_aspect_den), + static_cast(pod->interlacing), + pod->divider > 0 ? pod->divider : 1); + } else { + p = new olive::VideoParams(); + } + + p->set_color_range( + static_cast(pod->color_range)); + p->set_video_type(static_cast(pod->video_type)); + p->set_premultiplied_alpha(pod->premultiplied_alpha != 0); + return p; +} + +void oakengine_video_params_free(void *params) +{ + delete static_cast(params); +} + +int oakengine_video_params_equal(const oak_video_params *a, + const oak_video_params *b) +{ + if (!a || !b) { + return 0; + } + return (a->width == b->width && a->height == b->height && + a->time_base_num == b->time_base_num && + a->time_base_den == b->time_base_den && a->format == b->format && + a->pixel_aspect_num == b->pixel_aspect_num && + a->pixel_aspect_den == b->pixel_aspect_den && + a->interlacing == b->interlacing && a->divider == b->divider) ? + 1 : + 0; +} + +int oakengine_video_params_is_valid(const oak_video_params *p) +{ + if (!p) { + return 0; + } + const olive::VideoParams vp( + p->width, p->height, + olive::Rational(p->time_base_num, p->time_base_den), + olive::PixelFormat::Format(p->format), + olive::VideoParams::k_internal_channel_count, + olive::Rational(p->pixel_aspect_num, p->pixel_aspect_den), + olive::VideoParams::Interlacing(p->interlacing), p->divider); + return vp.is_valid() ? 1 : 0; +} + +int oakengine_video_params_bytes_per_pixel(int format, int channels) +{ + return olive::VideoParams::get_bytes_per_pixel( + olive::PixelFormat::Format(format), channels); +} + +int oakengine_video_params_internal_channel_count(void) +{ + return olive::VideoParams::k_internal_channel_count; +} + +int oakengine_export_render_with_params(OakEngineSequence *seq, + const OakEngineEncodingParams *params) +{ + oakengine_export_set_error_string(QString()); + if (!seq || !params) { + oakengine_export_set_error_string( + QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + // Validate the sequence handle by pointer membership in the active + // project's node list. A dynamic_cast on a bogus handle (e.g. an + // OakEngineEncodingParams pointer, which has no vtable) crashes, and + // there is no safe way to dynamic_cast an arbitrary address -- pointer + // comparison is the only safe check. Limitation: the sequence must + // belong to the active project (same scope the export dialog uses). + olive::Sequence *sequence = nullptr; + if (olive::EngineCore::instance() && + olive::EngineCore::instance()->open_project()) { + for (olive::Node *n : + olive::EngineCore::instance()->open_project()->nodes()) { + if (reinterpret_cast(n) == seq) { + sequence = dynamic_cast(n); + break; + } + } + } + if (!sequence) { + oakengine_export_set_error_string( + QStringLiteral("handle is not a sequence of the active project")); + return OAKENGINE_E_INVALID; + } + if (!olive::RenderManager::instance()) { + oakengine_export_set_error_string( + QStringLiteral("engine not initialized with " + "OAKENGINE_INIT_RENDER")); + return OAKENGINE_E_STATE; + } + olive::Project *project = sequence->project(); + if (!project) { + oakengine_export_set_error_string( + QStringLiteral("sequence is not attached to a project")); + return OAKENGINE_E_INVALID; + } + + // The handle publicly inherits olive::EncodingParams, so it drives the + // same synchronous ExportTask machinery as oakengine_export_render()/_ex() + // directly (progress callback + cancellation are shared engine state). + auto *ep = const_cast(params); + const int rc = oakengine_export_render_internal( + sequence, project, *ep, ep->audio_enabled(), + ep->audio_enabled() ? ep->audio_params() + : sequence->get_audio_params()); + return rc; +} + +} // extern "C" diff --git a/engine/src/capi/events.cpp b/engine/src/capi/events.cpp new file mode 100644 index 000000000..73eb47861 --- /dev/null +++ b/engine/src/capi/events.cpp @@ -0,0 +1,1221 @@ +/*** + + 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/events.h" + +#include + +#include + +#include +#include +#include +#include + +#include "audio/audiomanager.h" +#include "coreengine.h" +#include "node/keyframe.h" +#include "oakengine/node.h" +#include "node/block/block.h" +#include "node/color/colormanager/colormanager.h" +#include "node/group/group.h" +#include "node/output/track/track.h" +#include "node/output/track/tracklist.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "node/project/sequence/sequence.h" +#include "render/framehashcache.h" +#include "render/playbackcache.h" +#include "task/task.h" +#include "task/taskmanager.h" +#include "timeline/timelinemarker.h" +#include "timeline/timelineworkarea.h" +#include "undo/undostack.h" + +namespace +{ + +// Subscription registry: id -> connections. Callbacks capture the function +// pointer and userdata directly, so delivery never touches the registry; +// the map only tracks lifecycle (unsubscribe, sender teardown). +struct Subscription { + QVector connections; +}; + +QMutex g_registry_mutex; +QHash g_registry; +std::atomic g_next_id{1}; + +// The observed engine object died: drop the registry entry. Qt has already +// torn down the connections themselves. +void drop_subscription(int64_t id) +{ + QMutexLocker locker(&g_registry_mutex); + g_registry.remove(id); +} + +void invoke(oakengine_event_fn fn, void *userdata, int32_t id, void *source, + int64_t a, int64_t b, void *related, int64_t c = 0, + const char *s = nullptr) +{ + oakengine_event event; + event.id = id; + event.reserved = 0; + event.a = a; + event.b = b; + event.c = c; + event.source = source; + event.handle = related; + event.s = s; + fn(&event, userdata); +} + +// Frame-timestamp timebase for node events: the frame rate of the +// project's first sequence, or the engine default (1001/30000 s per +// frame). Same convention as node.cpp's project_time_base(). +olive::Rational node_frame_time_base(const olive::Node *node) +{ + if (const olive::Project *p = + olive::Project::get_project_from_object(node)) { + for (olive::Node *n : p->nodes()) { + if (const olive::Sequence *s = + dynamic_cast(n)) { + const olive::Rational fr = s->get_video_params().frame_rate(); + if (!fr.isNull() && !fr.isNaN()) { + return fr.flipped(); + } + } + } + } + return olive::Rational(1001, 30000); +} + +// NodeValue::Type -> facade value type (same mapping as node.cpp). +int node_value_type_to_c(olive::NodeValue::Type t) +{ + switch (t) { + case olive::NodeValue::k_int: + return OAK_NODE_VALUE_INT; + case olive::NodeValue::k_float: + return OAK_NODE_VALUE_FLOAT; + case olive::NodeValue::k_boolean: + return OAK_NODE_VALUE_BOOL; + case olive::NodeValue::k_rational: + return OAK_NODE_VALUE_RATIONAL; + case olive::NodeValue::k_color: + return OAK_NODE_VALUE_COLOR; + case olive::NodeValue::k_vec2: + return OAK_NODE_VALUE_VEC2; + case olive::NodeValue::k_vec3: + return OAK_NODE_VALUE_VEC3; + case olive::NodeValue::k_vec4: + return OAK_NODE_VALUE_VEC4; + case olive::NodeValue::k_combo: + return OAK_NODE_VALUE_COMBO; + case olive::NodeValue::k_file: + return OAK_NODE_VALUE_STRING; + case olive::NodeValue::k_text: + return OAK_NODE_VALUE_TEXT; + case olive::NodeValue::k_font: + return OAK_NODE_VALUE_FONT; + case olive::NodeValue::k_str_combo: + return OAK_NODE_VALUE_STR_COMBO; + case olive::NodeValue::k_binary: + return OAK_NODE_VALUE_BINARY; + case olive::NodeValue::k_bezier: + return OAK_NODE_VALUE_BEZIER; + default: + return OAK_NODE_VALUE_NONE; +} +} + +// Wire the node-family events (handle validated as a Node). Appended to +// `conns`; returns false when nothing matched. +bool connect_node_event(olive::Node *node, int32_t event_id, + oakengine_event_fn fn, void *userdata, + QVector *conns) +{ + using namespace olive; + + switch (event_id) { + case OAKENGINE_EVENT_NODE_LABEL_CHANGED: + conns->append(QObject::connect( + node, &Node::label_changed, node, + [fn, userdata, node](const QString &label) { + const QByteArray utf = label.toUtf8(); + invoke(fn, userdata, OAKENGINE_EVENT_NODE_LABEL_CHANGED, node, + 0, 0, nullptr, 0, utf.constData()); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_INPUT_VALUE_CHANGED: + conns->append(QObject::connect( + node, &Node::value_changed, node, + [fn, userdata, node](const NodeInput &input, + const TimeRange &range) { + const Rational tb = node_frame_time_base(node); + const QByteArray utf = input.input().toUtf8(); + invoke(fn, userdata, + OAKENGINE_EVENT_NODE_INPUT_VALUE_CHANGED, node, + input.element(), + core::Timecode::time_to_timestamp( + range.in(), tb, core::Timecode::k_round), + nullptr, + core::Timecode::time_to_timestamp( + range.out(), tb, core::Timecode::k_round), + utf.constData()); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_INPUT_CONNECTED: + case OAKENGINE_EVENT_NODE_INPUT_DISCONNECTED: { + const bool connected = + event_id == OAKENGINE_EVENT_NODE_INPUT_CONNECTED; + auto deliver = [fn, userdata, node, connected, event_id]( + Node *output, const NodeInput &input) { + const QByteArray utf = input.input().toUtf8(); + invoke(fn, userdata, event_id, node, input.element(), 0, output, 0, + utf.constData()); + }; + if (connected) { + conns->append(QObject::connect(node, &Node::input_connected, node, + deliver, Qt::DirectConnection)); + } else { + conns->append(QObject::connect(node, &Node::input_disconnected, + node, deliver, + Qt::DirectConnection)); + } + return true; + } + case OAKENGINE_EVENT_NODE_INPUT_FLAGS_CHANGED: + conns->append(QObject::connect( + node, &Node::input_flags_changed, node, + [fn, userdata, node](const QString &input, + const InputFlags &flags) { + const QByteArray utf = input.toUtf8(); + invoke(fn, userdata, OAKENGINE_EVENT_NODE_INPUT_FLAGS_CHANGED, + node, int64_t(flags.value()), 0, nullptr, 0, + utf.constData()); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_INPUT_PROPERTY_CHANGED: + conns->append(QObject::connect( + node, &Node::input_property_changed, node, + [fn, userdata, node](const QString &input, const QString &, + const QVariant &) { + const QByteArray utf = input.toUtf8(); + invoke(fn, userdata, + OAKENGINE_EVENT_NODE_INPUT_PROPERTY_CHANGED, node, 0, 0, + nullptr, 0, utf.constData()); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_INPUT_DATA_TYPE_CHANGED: + conns->append(QObject::connect( + node, &Node::input_data_type_changed, node, + [fn, userdata, node](const QString &input, NodeValue::Type type) { + const QByteArray utf = input.toUtf8(); + invoke(fn, userdata, + OAKENGINE_EVENT_NODE_INPUT_DATA_TYPE_CHANGED, node, + node_value_type_to_c(type), 0, nullptr, 0, + utf.constData()); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_INPUT_ARRAY_SIZE_CHANGED: + conns->append(QObject::connect( + node, &Node::input_array_size_changed, node, + [fn, userdata, node](const QString &input, int old_size, + int new_size) { + const QByteArray utf = input.toUtf8(); + invoke(fn, userdata, + OAKENGINE_EVENT_NODE_INPUT_ARRAY_SIZE_CHANGED, node, + old_size, new_size, nullptr, 0, utf.constData()); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_KEYFRAME_ENABLE_CHANGED: + conns->append(QObject::connect( + node, &Node::keyframe_enable_changed, node, + [fn, userdata, node](const NodeInput &input, bool enabled) { + const QByteArray utf = input.input().toUtf8(); + invoke(fn, userdata, + OAKENGINE_EVENT_NODE_KEYFRAME_ENABLE_CHANGED, node, + input.element(), enabled ? 1 : 0, nullptr, 0, + utf.constData()); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_KEYFRAME_ADDED: + case OAKENGINE_EVENT_NODE_KEYFRAME_REMOVED: { + auto deliver = [fn, userdata, node, event_id](OakEngineKeyframe *k) { + auto *key = reinterpret_cast(k); + const QByteArray utf = key->input().toUtf8(); + invoke(fn, userdata, event_id, node, key->element(), key->track(), + k, 0, utf.constData()); + }; + if (event_id == OAKENGINE_EVENT_NODE_KEYFRAME_ADDED) { + conns->append(QObject::connect(node, &Node::keyframe_added, node, + deliver, Qt::DirectConnection)); + } else { + conns->append(QObject::connect(node, &Node::keyframe_removed, + node, deliver, + Qt::DirectConnection)); + } + return true; + } + case OAKENGINE_EVENT_NODE_KEYFRAME_TIME_CHANGED: + case OAKENGINE_EVENT_NODE_KEYFRAME_TYPE_CHANGED: + case OAKENGINE_EVENT_NODE_KEYFRAME_VALUE_CHANGED: { + auto deliver = [fn, userdata, node, event_id](OakEngineKeyframe *k) { + invoke(fn, userdata, event_id, node, 0, 0, k); + }; + if (event_id == OAKENGINE_EVENT_NODE_KEYFRAME_TIME_CHANGED) { + conns->append(QObject::connect(node, &Node::keyframe_time_changed, + node, deliver, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_NODE_KEYFRAME_TYPE_CHANGED) { + conns->append(QObject::connect(node, &Node::keyframe_type_changed, + node, deliver, + Qt::DirectConnection)); + } else { + conns->append(QObject::connect(node, + &Node::keyframe_value_changed, + node, deliver, + Qt::DirectConnection)); + } + return true; + } + case OAKENGINE_EVENT_NODE_NODE_ADDED_TO_CONTEXT: + case OAKENGINE_EVENT_NODE_NODE_REMOVED_FROM_CONTEXT: { + auto deliver = [fn, userdata, node, event_id](Node *child) { + invoke(fn, userdata, event_id, node, 0, 0, child); + }; + if (event_id == OAKENGINE_EVENT_NODE_NODE_ADDED_TO_CONTEXT) { + conns->append(QObject::connect(node, &Node::node_added_to_context, + node, deliver, + Qt::DirectConnection)); + } else { + conns->append(QObject::connect(node, + &Node::node_removed_from_context, + node, deliver, + Qt::DirectConnection)); + } + return true; + } + case OAKENGINE_EVENT_NODE_MESSAGE_COUNT_CHANGED: + conns->append(QObject::connect( + node, &Node::message_count_changed, node, + [fn, userdata, node]() { + invoke(fn, userdata, OAKENGINE_EVENT_NODE_MESSAGE_COUNT_CHANGED, + node, 0, 0, nullptr); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_ADDED: + case OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_REMOVED: { + auto *group = dynamic_cast(node); + if (!group) { + return false; + } + auto deliver = [fn, userdata, node, event_id](NodeGroup *, + const NodeInput &input) { + const QByteArray id = input.input().toUtf8(); + invoke(fn, userdata, event_id, node, input.element(), 0, + input.node(), 0, id.constData()); + }; + if (event_id == OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_ADDED) { + conns->append(QObject::connect( + group, &NodeGroup::input_passthrough_added, group, deliver, + Qt::DirectConnection)); + } else { + conns->append(QObject::connect( + group, &NodeGroup::input_passthrough_removed, group, deliver, + Qt::DirectConnection)); + } + return true; + } + case OAKENGINE_EVENT_GROUP_OUTPUT_PASSTHROUGH_CHANGED: { + auto *group = dynamic_cast(node); + if (!group) { + return false; + } + conns->append(QObject::connect( + group, &NodeGroup::output_passthrough_changed, group, + [fn, userdata, node](NodeGroup *, Node *output) { + invoke(fn, userdata, + OAKENGINE_EVENT_GROUP_OUTPUT_PASSTHROUGH_CHANGED, node, + 0, 0, output); + }, + Qt::DirectConnection)); + return true; + } + case OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED: + conns->append(QObject::connect( + node, &Node::node_position_in_context_changed, node, + [fn, userdata, node](Node *child, const QPointF &pos) { + int64_t xb, yb; + const double x = pos.x(), y = pos.y(); + memcpy(&xb, &x, sizeof(xb)); + memcpy(&yb, &y, sizeof(yb)); + invoke(fn, userdata, + OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED, node, xb, + yb, child); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_LINKS_CHANGED: + conns->append(QObject::connect( + node, &Node::links_changed, node, + [fn, userdata, node]() { + invoke(fn, userdata, OAKENGINE_EVENT_NODE_LINKS_CHANGED, node, + 0, 0, nullptr); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_COLOR_CHANGED: + conns->append(QObject::connect( + node, &Node::color_changed, node, + [fn, userdata, node]() { + invoke(fn, userdata, OAKENGINE_EVENT_NODE_COLOR_CHANGED, node, + 0, 0, nullptr); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_INPUT_ADDED: + conns->append(QObject::connect( + node, &Node::input_added, node, + [fn, userdata, node](const QString &id) { + QByteArray utf = id.toUtf8(); + invoke(fn, userdata, OAKENGINE_EVENT_NODE_INPUT_ADDED, node, + 0, 0, nullptr, 0, utf.constData()); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_INPUT_REMOVED: + conns->append(QObject::connect( + node, &Node::input_removed, node, + [fn, userdata, node](const QString &id) { + QByteArray utf = id.toUtf8(); + invoke(fn, userdata, OAKENGINE_EVENT_NODE_INPUT_REMOVED, node, + 0, 0, nullptr, 0, utf.constData()); + }, + Qt::DirectConnection)); + return true; + case OAKENGINE_EVENT_NODE_REMOVED_FROM_GRAPH: + conns->append(QObject::connect( + node, &Node::removed_from_graph, node, + [fn, userdata, node](olive::Project *project) { + invoke(fn, userdata, OAKENGINE_EVENT_NODE_REMOVED_FROM_GRAPH, + node, + 0, 0, reinterpret_cast(project)); + }, + Qt::DirectConnection)); + return true; + default: + return false; + } +} + +// The sequence's frame duration as a Rational timebase, like timeline.cpp. +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; +} + +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); +} + +// Block range as frame timestamps in the track's sequence timebase; -1/-1 +// when the block is not on a sequenced track at emission time. +void block_timestamps(const olive::Block *block, int64_t *in_ts, + int64_t *out_ts) +{ + *in_ts = -1; + *out_ts = -1; + if (!block || !block->track() || !block->track()->sequence()) { + return; + } + olive::Rational tb; + if (!time_base_of(block->track()->sequence(), &tb)) { + return; + } + *in_ts = time_to_ts(block->in(), tb); + *out_ts = time_to_ts(block->out(), tb); +} + +int64_t marker_timestamp(const olive::Sequence *seq, + const olive::TimelineMarker *marker) +{ + olive::Rational tb; + if (!marker || !time_base_of(seq, &tb)) { + return -1; + } + return time_to_ts(marker->time().in(), tb); +} + +// Wire the connections for one subscription. `obj` is the validated engine +// object (already cast-checked). Returns the connection list, empty when +// the event family does not match `obj`. +QVector connect_event( + QObject *obj, int32_t event_id, oakengine_event_fn fn, void *userdata) +{ + using namespace olive; + + QVector conns; + + switch (event_id) { + case OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED: { + auto *project = dynamic_cast(obj); + if (!project) { + break; + } + conns.append(QObject::connect( + project, &Project::modified_changed, project, + [fn, userdata, project](bool modified) { + invoke(fn, userdata, OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED, + project, modified ? 1 : 0, 0, nullptr); + }, + Qt::DirectConnection)); + break; + } + case OAKENGINE_EVENT_PROJECT_NAME_CHANGED: { + auto *project = dynamic_cast(obj); + if (!project) { + break; + } + conns.append(QObject::connect( + project, &Project::name_changed, project, + [fn, userdata, project]() { + invoke(fn, userdata, OAKENGINE_EVENT_PROJECT_NAME_CHANGED, + project, 0, 0, nullptr); + }, + Qt::DirectConnection)); + break; + } + case OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM: + case OAKENGINE_EVENT_FOLDER_END_INSERT_ITEM: + case OAKENGINE_EVENT_FOLDER_BEGIN_REMOVE_ITEM: + case OAKENGINE_EVENT_FOLDER_END_REMOVE_ITEM: { + auto *folder = dynamic_cast(obj); + if (!folder) { + break; + } + if (event_id == OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM) { + conns.append(QObject::connect( + folder, &Folder::begin_insert_item, folder, + [fn, userdata, folder](Node *child, int index) { + invoke(fn, userdata, OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM, + folder, index, 0, child); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_FOLDER_END_INSERT_ITEM) { + conns.append(QObject::connect( + folder, &Folder::end_insert_item, folder, + [fn, userdata, folder]() { + invoke(fn, userdata, OAKENGINE_EVENT_FOLDER_END_INSERT_ITEM, + folder, 0, 0, nullptr); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_FOLDER_BEGIN_REMOVE_ITEM) { + conns.append(QObject::connect( + folder, &Folder::begin_remove_item, folder, + [fn, userdata, folder](Node *child, int index) { + invoke(fn, userdata, OAKENGINE_EVENT_FOLDER_BEGIN_REMOVE_ITEM, + folder, index, 0, child); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + folder, &Folder::end_remove_item, folder, + [fn, userdata, folder]() { + invoke(fn, userdata, OAKENGINE_EVENT_FOLDER_END_REMOVE_ITEM, + folder, 0, 0, nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED: + case OAKENGINE_EVENT_SEQUENCE_TRACK_REMOVED: { + auto *seq = dynamic_cast(obj); + if (!seq) { + break; + } + if (event_id == OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED) { + conns.append(QObject::connect( + seq, &Sequence::track_added, seq, + [fn, userdata, seq](Track *track) { + invoke(fn, userdata, OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED, + seq, track ? int(track->type()) : -1, 0, track); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + seq, &Sequence::track_removed, seq, + [fn, userdata, seq](Track *track) { + invoke(fn, userdata, OAKENGINE_EVENT_SEQUENCE_TRACK_REMOVED, + seq, track ? int(track->type()) : -1, 0, track); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_TRACK_BLOCK_ADDED: + case OAKENGINE_EVENT_TRACK_BLOCK_REMOVED: { + auto *track = dynamic_cast(obj); + if (!track) { + break; + } + if (event_id == OAKENGINE_EVENT_TRACK_BLOCK_ADDED) { + conns.append(QObject::connect( + track, &Track::block_added, track, + [fn, userdata, track](Block *block) { + int64_t in_ts, out_ts; + block_timestamps(block, &in_ts, &out_ts); + invoke(fn, userdata, OAKENGINE_EVENT_TRACK_BLOCK_ADDED, + track, in_ts, out_ts, block); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + track, &Track::block_removed, track, + [fn, userdata, track](Block *block) { + int64_t in_ts, out_ts; + block_timestamps(block, &in_ts, &out_ts); + invoke(fn, userdata, OAKENGINE_EVENT_TRACK_BLOCK_REMOVED, + track, in_ts, out_ts, block); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_TRACK_INDEX_CHANGED: + case OAKENGINE_EVENT_TRACK_HEIGHT_CHANGED: + case OAKENGINE_EVENT_TRACK_BLOCKS_REFRESHED: + case OAKENGINE_EVENT_TRACK_MUTED_CHANGED: { + auto *track = dynamic_cast(obj); + if (!track) { + break; + } + if (event_id == OAKENGINE_EVENT_TRACK_INDEX_CHANGED) { + conns.append(QObject::connect( + track, &Track::index_changed, track, + [fn, userdata, track](int old_index, int new_index) { + invoke(fn, userdata, OAKENGINE_EVENT_TRACK_INDEX_CHANGED, + track, old_index, new_index, nullptr); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_TRACK_HEIGHT_CHANGED) { + conns.append(QObject::connect( + track, &Track::track_height_changed, track, + [fn, userdata, track](qreal height) { + int64_t bits; + const double h = double(height); + memcpy(&bits, &h, sizeof(bits)); + invoke(fn, userdata, OAKENGINE_EVENT_TRACK_HEIGHT_CHANGED, + track, bits, 0, nullptr); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_TRACK_BLOCKS_REFRESHED) { + conns.append(QObject::connect( + track, &Track::blocks_refreshed, track, + [fn, userdata, track]() { + invoke(fn, userdata, OAKENGINE_EVENT_TRACK_BLOCKS_REFRESHED, + track, 0, 0, nullptr); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + track, &Track::muted_changed, track, + [fn, userdata, track](bool muted) { + invoke(fn, userdata, OAKENGINE_EVENT_TRACK_MUTED_CHANGED, + track, muted ? 1 : 0, 0, nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_BLOCK_ENABLED_CHANGED: + case OAKENGINE_EVENT_BLOCK_PREVIEW_CHANGED: { + auto *block = dynamic_cast(obj); + if (!block) { + break; + } + if (event_id == OAKENGINE_EVENT_BLOCK_ENABLED_CHANGED) { + conns.append(QObject::connect( + block, &Block::enabled_changed, block, + [fn, userdata, block]() { + invoke(fn, userdata, OAKENGINE_EVENT_BLOCK_ENABLED_CHANGED, + block, 0, 0, nullptr); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + block, &Block::preview_changed, block, + [fn, userdata, block]() { + invoke(fn, userdata, OAKENGINE_EVENT_BLOCK_PREVIEW_CHANGED, + block, 0, 0, nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_SEQUENCE_TRACK_LIST_CHANGED: + case OAKENGINE_EVENT_SEQUENCE_TRACK_HEIGHT_CHANGED: { + auto *seq = dynamic_cast(obj); + if (!seq) { + break; + } + for (int type = 0; type < 3; type++) { + TrackList *list = seq->track_list(static_cast(type)); + if (!list) { + continue; + } + if (event_id == OAKENGINE_EVENT_SEQUENCE_TRACK_LIST_CHANGED) { + conns.append(QObject::connect( + list, &TrackList::track_list_changed, seq, + [fn, userdata, seq, type]() { + invoke(fn, userdata, + OAKENGINE_EVENT_SEQUENCE_TRACK_LIST_CHANGED, seq, + type, 0, nullptr); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + list, &TrackList::track_height_changed, seq, + [fn, userdata, seq, type](Track *track, int height) { + invoke(fn, userdata, + OAKENGINE_EVENT_SEQUENCE_TRACK_HEIGHT_CHANGED, + seq, type, height, track); + }, + Qt::DirectConnection)); + } + } + break; + } + case OAKENGINE_EVENT_SEQUENCE_SUBTITLES_CHANGED: { + auto *seq = dynamic_cast(obj); + if (!seq) { + break; + } + conns.append(QObject::connect( + seq, &Sequence::subtitles_changed, seq, + [fn, userdata, seq](const TimeRange &range) { + olive::Rational tb; + int64_t in_ts = -1, out_ts = -1; + if (time_base_of(seq, &tb)) { + in_ts = time_to_ts(range.in(), tb); + out_ts = time_to_ts(range.out(), tb); + } + invoke(fn, userdata, OAKENGINE_EVENT_SEQUENCE_SUBTITLES_CHANGED, + seq, in_ts, out_ts, nullptr); + }, + Qt::DirectConnection)); + break; + } + case OAKENGINE_EVENT_MARKER_LIST_MARKER_ADDED: + case OAKENGINE_EVENT_MARKER_LIST_MARKER_REMOVED: + case OAKENGINE_EVENT_MARKER_LIST_MARKER_MODIFIED: { + auto *markers = dynamic_cast(obj); + if (!markers) { + break; + } + auto deliver = [fn, userdata, markers, event_id]( + TimelineMarker *marker) { + invoke(fn, userdata, event_id, markers, 0, 0, marker); + }; + if (event_id == OAKENGINE_EVENT_MARKER_LIST_MARKER_ADDED) { + conns.append(QObject::connect(markers, + &TimelineMarkerList::marker_added, + markers, deliver, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_MARKER_LIST_MARKER_REMOVED) { + conns.append(QObject::connect(markers, + &TimelineMarkerList::marker_removed, + markers, deliver, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect(markers, + &TimelineMarkerList::marker_modified, + markers, deliver, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_WORKAREA_RANGE_CHANGED: + case OAKENGINE_EVENT_WORKAREA_ENABLED_CHANGED: { + auto *workarea = dynamic_cast(obj); + if (!workarea) { + break; + } + if (event_id == OAKENGINE_EVENT_WORKAREA_RANGE_CHANGED) { + conns.append(QObject::connect( + workarea, &TimelineWorkArea::range_changed, workarea, + [fn, userdata, workarea](const TimeRange &) { + invoke(fn, userdata, OAKENGINE_EVENT_WORKAREA_RANGE_CHANGED, + workarea, 0, 0, nullptr); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + workarea, &TimelineWorkArea::enabled_changed, workarea, + [fn, userdata, workarea](bool enabled) { + invoke(fn, userdata, + OAKENGINE_EVENT_WORKAREA_ENABLED_CHANGED, workarea, + enabled ? 1 : 0, 0, nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_SEQUENCE_MARKER_ADDED: + case OAKENGINE_EVENT_SEQUENCE_MARKER_REMOVED: + case OAKENGINE_EVENT_SEQUENCE_MARKER_MODIFIED: { + auto *seq = dynamic_cast(obj); + if (!seq) { + break; + } + TimelineMarkerList *markers = seq->get_markers(); + if (event_id == OAKENGINE_EVENT_SEQUENCE_MARKER_ADDED) { + conns.append(QObject::connect( + markers, &TimelineMarkerList::marker_added, seq, + [fn, userdata, seq](TimelineMarker *marker) { + invoke(fn, userdata, OAKENGINE_EVENT_SEQUENCE_MARKER_ADDED, + seq, marker_timestamp(seq, marker), 0, nullptr); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_SEQUENCE_MARKER_REMOVED) { + conns.append(QObject::connect( + markers, &TimelineMarkerList::marker_removed, seq, + [fn, userdata, seq](TimelineMarker *marker) { + invoke(fn, userdata, OAKENGINE_EVENT_SEQUENCE_MARKER_REMOVED, + seq, marker_timestamp(seq, marker), 0, nullptr); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + markers, &TimelineMarkerList::marker_modified, seq, + [fn, userdata, seq](TimelineMarker *marker) { + invoke(fn, userdata, OAKENGINE_EVENT_SEQUENCE_MARKER_MODIFIED, + seq, marker_timestamp(seq, marker), 0, nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_SEQUENCE_WORKAREA_RANGE_CHANGED: + case OAKENGINE_EVENT_SEQUENCE_WORKAREA_ENABLED_CHANGED: { + auto *seq = dynamic_cast(obj); + if (!seq) { + break; + } + TimelineWorkArea *workarea = seq->get_work_area(); + if (event_id == OAKENGINE_EVENT_SEQUENCE_WORKAREA_RANGE_CHANGED) { + conns.append(QObject::connect( + workarea, &TimelineWorkArea::range_changed, seq, + [fn, userdata, seq](const TimeRange &range) { + olive::Rational tb; + int64_t in_ts = -1, out_ts = -1; + if (time_base_of(seq, &tb)) { + in_ts = time_to_ts(range.in(), tb); + out_ts = time_to_ts(range.out(), tb); + } + invoke(fn, userdata, + OAKENGINE_EVENT_SEQUENCE_WORKAREA_RANGE_CHANGED, seq, + in_ts, out_ts, nullptr); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + workarea, &TimelineWorkArea::enabled_changed, seq, + [fn, userdata, seq](bool enabled) { + invoke(fn, userdata, + OAKENGINE_EVENT_SEQUENCE_WORKAREA_ENABLED_CHANGED, + seq, enabled ? 1 : 0, 0, nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_COLOR_MANAGER_CONFIG_CHANGED: + case OAKENGINE_EVENT_COLOR_MANAGER_REFERENCE_SPACE_CHANGED: { + auto *cm = dynamic_cast(obj); + if (!cm) { + break; + } + if (event_id == OAKENGINE_EVENT_COLOR_MANAGER_CONFIG_CHANGED) { + conns.append(QObject::connect( + cm, &ColorManager::config_changed, cm, + [fn, userdata, cm](const QString &) { + invoke(fn, userdata, + OAKENGINE_EVENT_COLOR_MANAGER_CONFIG_CHANGED, cm, 0, + 0, nullptr); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + cm, &ColorManager::reference_space_changed, cm, + [fn, userdata, cm](const QString &) { + invoke(fn, userdata, + OAKENGINE_EVENT_COLOR_MANAGER_REFERENCE_SPACE_CHANGED, + cm, 0, 0, nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_VIEWER_LENGTH_CHANGED: + case OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED: + case OAKENGINE_EVENT_VIEWER_FRAME_RATE_CHANGED: + case OAKENGINE_EVENT_VIEWER_SIZE_CHANGED: + case OAKENGINE_EVENT_VIEWER_PIXEL_ASPECT_CHANGED: + case OAKENGINE_EVENT_VIEWER_INTERLACING_CHANGED: + case OAKENGINE_EVENT_VIEWER_VIDEO_PARAMS_CHANGED: + case OAKENGINE_EVENT_VIEWER_AUDIO_PARAMS_CHANGED: + case OAKENGINE_EVENT_VIEWER_TEXTURE_INPUT_CHANGED: + case OAKENGINE_EVENT_VIEWER_SAMPLE_RATE_CHANGED: + case OAKENGINE_EVENT_VIEWER_CONNECTED_WAVEFORM_CHANGED: { + auto *viewer = dynamic_cast(obj); + if (!viewer) { + break; + } + // Rational payloads are a = numerator, b = denominator (seconds). + auto deliver_rational = [fn, userdata, viewer, event_id]( + const Rational &r) { + invoke(fn, userdata, event_id, viewer, r.numerator(), + r.denominator(), nullptr); + }; + if (event_id == OAKENGINE_EVENT_VIEWER_LENGTH_CHANGED) { + conns.append(QObject::connect(viewer, &ViewerOutput::length_changed, + viewer, deliver_rational, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED) { + conns.append(QObject::connect(viewer, + &ViewerOutput::playhead_changed, + viewer, deliver_rational, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_VIEWER_FRAME_RATE_CHANGED) { + conns.append(QObject::connect(viewer, + &ViewerOutput::frame_rate_changed, + viewer, deliver_rational, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_VIEWER_SIZE_CHANGED) { + conns.append(QObject::connect( + viewer, &ViewerOutput::size_changed, viewer, + [fn, userdata, viewer](int width, int height) { + invoke(fn, userdata, OAKENGINE_EVENT_VIEWER_SIZE_CHANGED, + viewer, width, height, nullptr); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_VIEWER_PIXEL_ASPECT_CHANGED) { + conns.append(QObject::connect(viewer, + &ViewerOutput::pixel_aspect_changed, + viewer, deliver_rational, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_VIEWER_INTERLACING_CHANGED) { + conns.append(QObject::connect( + viewer, &ViewerOutput::interlacing_changed, viewer, + [fn, userdata, viewer](VideoParams::Interlacing mode) { + invoke(fn, userdata, + OAKENGINE_EVENT_VIEWER_INTERLACING_CHANGED, viewer, + int64_t(mode), 0, nullptr); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_VIEWER_VIDEO_PARAMS_CHANGED) { + conns.append(QObject::connect( + viewer, &ViewerOutput::video_params_changed, viewer, + [fn, userdata, viewer]() { + invoke(fn, userdata, + OAKENGINE_EVENT_VIEWER_VIDEO_PARAMS_CHANGED, viewer, + 0, 0, nullptr); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_VIEWER_AUDIO_PARAMS_CHANGED) { + conns.append(QObject::connect( + viewer, &ViewerOutput::audio_params_changed, viewer, + [fn, userdata, viewer]() { + invoke(fn, userdata, + OAKENGINE_EVENT_VIEWER_AUDIO_PARAMS_CHANGED, viewer, + 0, 0, nullptr); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_VIEWER_TEXTURE_INPUT_CHANGED) { + conns.append(QObject::connect( + viewer, &ViewerOutput::texture_input_changed, viewer, + [fn, userdata, viewer]() { + invoke(fn, userdata, + OAKENGINE_EVENT_VIEWER_TEXTURE_INPUT_CHANGED, viewer, + 0, 0, nullptr); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_VIEWER_SAMPLE_RATE_CHANGED) { + conns.append(QObject::connect( + viewer, &ViewerOutput::sample_rate_changed, viewer, + [fn, userdata, viewer](int sr) { + invoke(fn, userdata, + OAKENGINE_EVENT_VIEWER_SAMPLE_RATE_CHANGED, viewer, + sr, 0, nullptr); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + viewer, &ViewerOutput::connected_waveform_changed, viewer, + [fn, userdata, viewer]() { + invoke(fn, userdata, + OAKENGINE_EVENT_VIEWER_CONNECTED_WAVEFORM_CHANGED, + viewer, 0, 0, nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_TASK_MANAGER_TASK_ADDED: + case OAKENGINE_EVENT_TASK_MANAGER_TASK_REMOVED: + case OAKENGINE_EVENT_TASK_MANAGER_TASK_FAILED: + case OAKENGINE_EVENT_TASK_MANAGER_LIST_CHANGED: { + auto *manager = dynamic_cast(obj); + if (!manager) { + break; + } + if (event_id == OAKENGINE_EVENT_TASK_MANAGER_TASK_ADDED) { + conns.append(QObject::connect( + manager, &TaskManager::task_added, manager, + [fn, userdata, manager](Task *t) { + const QByteArray title = t->get_title().toUtf8(); + invoke(fn, userdata, + OAKENGINE_EVENT_TASK_MANAGER_TASK_ADDED, manager, 0, 0, + t, 0, title.constData()); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_TASK_MANAGER_TASK_REMOVED) { + conns.append(QObject::connect( + manager, &TaskManager::task_removed, manager, + [fn, userdata, manager](Task *t) { + invoke(fn, userdata, + OAKENGINE_EVENT_TASK_MANAGER_TASK_REMOVED, manager, 0, + 0, t); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_TASK_MANAGER_TASK_FAILED) { + conns.append(QObject::connect( + manager, &TaskManager::task_failed, manager, + [fn, userdata, manager](Task *t) { + invoke(fn, userdata, + OAKENGINE_EVENT_TASK_MANAGER_TASK_FAILED, manager, 0, + 0, t); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + manager, &TaskManager::task_list_changed, manager, + [fn, userdata, manager]() { + invoke(fn, userdata, + OAKENGINE_EVENT_TASK_MANAGER_LIST_CHANGED, manager, 0, + 0, nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_TASK_STARTED: + case OAKENGINE_EVENT_TASK_PROGRESS: + case OAKENGINE_EVENT_TASK_FINISHED: { + auto *task = dynamic_cast(obj); + if (!task) { + break; + } + if (event_id == OAKENGINE_EVENT_TASK_STARTED) { + conns.append(QObject::connect( + task, &Task::started, task, + [fn, userdata, task](qint64 start_time) { + invoke(fn, userdata, OAKENGINE_EVENT_TASK_STARTED, task, + start_time, 0, nullptr); + }, + Qt::DirectConnection)); + } else if (event_id == OAKENGINE_EVENT_TASK_PROGRESS) { + conns.append(QObject::connect( + task, &Task::progress_changed, task, + [fn, userdata, task](double d) { + int64_t bits; + static_assert(sizeof(bits) == sizeof(d)); + memcpy(&bits, &d, sizeof(bits)); + invoke(fn, userdata, OAKENGINE_EVENT_TASK_PROGRESS, task, + bits, 0, nullptr); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + task, &Task::finished, task, + [fn, userdata](Task *t, bool succeeded) { + invoke(fn, userdata, OAKENGINE_EVENT_TASK_FINISHED, t, + succeeded ? 1 : 0, 0, nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_UNDO_INDEX_CHANGED: { + auto *undo_stack = dynamic_cast(obj); + if (!undo_stack) { + break; + } + conns.append(QObject::connect( + undo_stack, &UndoStack::index_changed, undo_stack, + [fn, userdata, undo_stack](int i) { + invoke(fn, userdata, OAKENGINE_EVENT_UNDO_INDEX_CHANGED, + undo_stack, i, 0, nullptr); + }, + Qt::DirectConnection)); + break; + } + case OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_PARAMS_CHANGED: { + auto *audio_manager = dynamic_cast(obj); + if (!audio_manager) { + break; + } + conns.append(QObject::connect( + audio_manager, &AudioManager::output_params_changed, audio_manager, + [fn, userdata, audio_manager]() { + invoke(fn, userdata, + OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_PARAMS_CHANGED, + audio_manager, 0, 0, nullptr); + }, + Qt::DirectConnection)); + break; + } + case OAKENGINE_EVENT_PLAYBACK_CACHE_INVALIDATED: + case OAKENGINE_EVENT_PLAYBACK_CACHE_VALIDATED: { + auto *cache = dynamic_cast(obj); + if (!cache) { + break; + } + if (event_id == OAKENGINE_EVENT_PLAYBACK_CACHE_INVALIDATED) { + conns.append(QObject::connect( + cache, &PlaybackCache::invalidated, cache, + [fn, userdata, cache](const TimeRange &r) { + invoke(fn, userdata, + OAKENGINE_EVENT_PLAYBACK_CACHE_INVALIDATED, + cache, r.in().numerator(), r.in().denominator(), + nullptr); + }, + Qt::DirectConnection)); + } else { + conns.append(QObject::connect( + cache, &PlaybackCache::validated, cache, + [fn, userdata, cache](const TimeRange &r) { + invoke(fn, userdata, + OAKENGINE_EVENT_PLAYBACK_CACHE_VALIDATED, + cache, r.in().numerator(), r.in().denominator(), + nullptr); + }, + Qt::DirectConnection)); + } + break; + } + case OAKENGINE_EVENT_FRAME_CACHE_INVALIDATED: { + auto *cache = dynamic_cast(obj); + if (!cache) { + break; + } + conns.append(QObject::connect( + cache, &PlaybackCache::invalidated, cache, + [fn, userdata, cache](const TimeRange &r) { + invoke(fn, userdata, + OAKENGINE_EVENT_FRAME_CACHE_INVALIDATED, + cache, r.in().numerator(), r.in().denominator(), + nullptr); + }, + Qt::DirectConnection)); + break; + } + default: + break; + } + + if (conns.isEmpty()) { + if (auto *node = dynamic_cast(obj)) { + connect_node_event(node, event_id, fn, userdata, &conns); + } + } + + return conns; +} + +} // namespace + +extern "C" int64_t oakengine_event_subscribe(void *handle, int32_t event_id, + oakengine_event_fn fn, + void *userdata) +{ + if (!handle || !fn) { + return 0; + } + + // Every facade handle is the engine QObject pointer itself (see + // timeline.cpp/project.cpp wrap()); dynamic_cast from QObject* both + // validates the family match and is safe across the Node/Project split. + auto *obj = reinterpret_cast(handle); + + QVector conns = + connect_event(obj, event_id, fn, userdata); + if (conns.isEmpty()) { + return 0; + } + + // Drop the registry entry automatically when the observed object dies so + // a stale subscription id is never a dangling engine pointer. Qt removes + // the signal connections itself; only the map entry needs cleanup. + const int64_t id = g_next_id.fetch_add(1); + conns.append(QObject::connect(obj, &QObject::destroyed, obj, + [id]() { drop_subscription(id); }, + Qt::DirectConnection)); + + QMutexLocker locker(&g_registry_mutex); + g_registry.insert(id, Subscription{std::move(conns)}); + return id; +} + +extern "C" int oakengine_event_unsubscribe(int64_t id) +{ + if (id <= 0) { + return OAKENGINE_E_INVALID; + } + QMutexLocker locker(&g_registry_mutex); + const auto it = g_registry.find(id); + if (it == g_registry.end()) { + return OAKENGINE_E_NOT_FOUND; + } + for (const QMetaObject::Connection &conn : it->connections) { + QObject::disconnect(conn); + } + g_registry.erase(it); + return OAKENGINE_OK; +} diff --git a/engine/src/capi/export.cpp b/engine/src/capi/export.cpp index 4bbf56935..becc7e3b8 100644 --- a/engine/src/capi/export.cpp +++ b/engine/src/capi/export.cpp @@ -20,6 +20,8 @@ #include "oakengine/exporter.h" +#include "exportinternal.h" + #include #include @@ -487,6 +489,21 @@ QString params_from_ex(const oak_export_options_ex &o, } // namespace +int oakengine_export_render_internal(olive::Sequence *sequence, + olive::Project *project, + olive::EncodingParams ¶ms, + bool prewarm_audio, + const olive::AudioParams &prewarm_params) +{ + return render_internal(sequence, project, params, prewarm_audio, + prewarm_params); +} + +void oakengine_export_set_error_string(const QString &error) +{ + set_error(error); +} + extern "C" { diff --git a/engine/src/capi/exportinternal.h b/engine/src/capi/exportinternal.h new file mode 100644 index 000000000..18865d571 --- /dev/null +++ b/engine/src/capi/exportinternal.h @@ -0,0 +1,58 @@ +/*** + + 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_EXPORTINTERNAL_H +#define OAKENGINE_EXPORTINTERNAL_H + +// Internal (not installed) shared declaration between the export and +// encoding capi translation units: oakengine_export_render_with_params() +// (declared in oakengine/encoding.h) drives the same synchronous ExportTask +// machinery as oakengine_export_render()/_ex(), which lives in export.cpp. + +namespace olive +{ +class Sequence; +class Project; +class EncodingParams; +namespace core +{ +class AudioParams; +} +using core::AudioParams; +} + +// Runs the synchronous export (render_internal in export.cpp): prewarms +// audio conforms when prewarm_audio is set, drives the ExportTask on a +// worker thread while the calling thread pumps events. Returns +// OAKENGINE_OK / OAKENGINE_E_STATE / OAKENGINE_E_FAILED / +// OAKENGINE_E_CANCELLED; failure reason via oakengine_export_last_error(). +int oakengine_export_render_internal(olive::Sequence *sequence, + olive::Project *project, + olive::EncodingParams ¶ms, + bool prewarm_audio, + const olive::AudioParams &prewarm_params); + +// Sets the export family's thread-local failure reason (read back with +// oakengine_export_last_error()). Used by capi TUs outside export.cpp whose +// contracts route errors through the export channel. +class QString; +void oakengine_export_set_error_string(const QString &error); + +#endif // OAKENGINE_EXPORTINTERNAL_H diff --git a/engine/src/capi/footage.cpp b/engine/src/capi/footage.cpp index e18872009..62e0c9ed6 100644 --- a/engine/src/capi/footage.cpp +++ b/engine/src/capi/footage.cpp @@ -19,6 +19,7 @@ ***/ #include "oakengine/footage.h" +#include "oakengine/timeline.h" #include #include @@ -41,6 +42,7 @@ #include "node/project/footage/footage.h" #include "undo/undocommand.h" #include "undo/undostack.h" +#include "undointernal.h" namespace { @@ -153,12 +155,7 @@ olive::Footage *borrowed_node(OakEngineFootage *self) // initialized, otherwise execute it directly. void push_or_run(olive::UndoCommand *command, const QString &name) { - if (olive::EngineCore::instance()) { - olive::EngineCore::instance()->undo_stack()->push(command, name); - } else { - command->redo_now(); - delete command; - } + oakengine_undo_push_or_run(command, name); } // Undo commands for footage stream overrides. The engine has no undo @@ -546,13 +543,7 @@ OakEngineFootage *oakengine_project_import_footage(OakEngineProject *project, 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; - } + oakengine_undo_push_or_run(command, QStringLiteral("Import Footage")); auto *state = new OakEngineFootageState(); state->borrowed = true; @@ -1082,4 +1073,227 @@ int oakengine_footage_colorspace_at(const OakEngineFootage *self, int index, buf_size); } +/* ---- Footage extras ------------------------------------------------------- */ + +int oakengine_footage_get_filename(const OakEngineFootage *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const OakEngineFootageState *s = impl(self); + if (!s->node) { + // Probed footage has no node; filename is not applicable. + return OAKENGINE_E_INVALID; + } + return string_to_buf(s->node->filename(), buf, buf_size); +} + +int oakengine_footage_get_stream_reference(const OakEngineFootage *self, + int flat_index, int *out_track_type, + int *out_stream_index) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const OakEngineFootageState *s = impl(self); + if (!s->node) { + return OAKENGINE_E_INVALID; // probed-only footage + } + const int vc = video_stream_count(s); + const int ac = audio_stream_count(s); + if (flat_index < vc) { + if (out_track_type) { + *out_track_type = OAKENGINE_TRACK_TYPE_VIDEO; + } + if (out_stream_index) { + *out_stream_index = flat_index; + } + return OAKENGINE_OK; + } + flat_index -= vc; + if (flat_index < ac) { + if (out_track_type) { + *out_track_type = OAKENGINE_TRACK_TYPE_AUDIO; + } + if (out_stream_index) { + *out_stream_index = flat_index; + } + return OAKENGINE_OK; + } + flat_index -= ac; + const int sc = subtitle_stream_count(s); + if (flat_index >= sc) { + return OAKENGINE_E_NOT_FOUND; + } + if (out_track_type) { + *out_track_type = OAKENGINE_TRACK_TYPE_SUBTITLE; + } + if (out_stream_index) { + *out_stream_index = flat_index; + } + return OAKENGINE_OK; +} + +int oakengine_footage_describe_video_stream(const OakEngineFootage *self, + int video_stream_index, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const OakEngineFootageState *s = impl(self); + if (!s->node) { + // Import-handle-only family: probe handles are rejected (probe + // metadata is read through the oak_footage_*_info accessors). + return OAKENGINE_E_INVALID; + } + if (video_stream_index < 0 || video_stream_index >= video_stream_count(s)) { + return OAKENGINE_E_NOT_FOUND; + } + olive::VideoParams vp; + if (s->node) { + vp = s->node->get_video_params(video_stream_index); + } else { + const auto &streams = s->description.get_video_streams(); + if (video_stream_index < streams.size()) { + vp = streams.at(video_stream_index); + } else { + return OAKENGINE_E_NOT_FOUND; + } + } + return string_to_buf(olive::Footage::describe_video_stream(vp), buf, + buf_size); +} + +int oakengine_footage_describe_audio_stream(const OakEngineFootage *self, + int audio_stream_index, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const OakEngineFootageState *s = impl(self); + if (!s->node) { + return OAKENGINE_E_INVALID; + } + if (audio_stream_index < 0 || audio_stream_index >= audio_stream_count(s)) { + return OAKENGINE_E_NOT_FOUND; + } + olive::AudioParams ap; + if (s->node) { + ap = s->node->get_audio_params(audio_stream_index); + } else { + const auto &streams = s->description.get_audio_streams(); + if (audio_stream_index < streams.size()) { + ap = streams.at(audio_stream_index); + } else { + return OAKENGINE_E_NOT_FOUND; + } + } + return string_to_buf(olive::Footage::describe_audio_stream(ap), buf, + buf_size); +} + +int oakengine_footage_stream_type_name(int track_type, char *buf, int buf_size) +{ + switch (track_type) { + case OAKENGINE_TRACK_TYPE_VIDEO: + return string_to_buf(QStringLiteral("Video"), buf, buf_size); + case OAKENGINE_TRACK_TYPE_AUDIO: + return string_to_buf(QStringLiteral("Audio"), buf, buf_size); + case OAKENGINE_TRACK_TYPE_SUBTITLE: + return string_to_buf(QStringLiteral("Subtitle"), buf, buf_size); + default: + return string_to_buf(QStringLiteral("Unknown"), buf, buf_size); + } +} + +int oakengine_footage_has_custom_proxy_params(const OakEngineFootage *self) +{ + if (!self || !impl(self)->node) { + return OAKENGINE_E_INVALID; + } + return impl(self)->node->has_custom_proxy_params() ? 1 : 0; +} + +int oakengine_footage_get_effective_proxy_params(const OakEngineFootage *self, + oak_proxy_params *out) +{ + if (!self || !out || !impl(self)->node) { + return OAKENGINE_E_INVALID; + } + const olive::ProxyManager::ProxyParams pp = + impl(self)->node->get_effective_proxy_params(); + out->width = pp.width; + out->height = pp.height; + out->divider = pp.divider; + out->version = pp.version; + out->crf = pp.crf; + out->include_audio = pp.include_audio ? 1 : 0; + string_to_buf(pp.extension, out->extension, sizeof(out->extension)); + string_to_buf(pp.preset, out->preset, sizeof(out->preset)); + return OAKENGINE_OK; +} + +int oakengine_footage_set_custom_proxy_params(OakEngineFootage *self, + const oak_proxy_params *params) +{ + if (!self || !params || !impl(self)->node) { + return OAKENGINE_E_INVALID; + } + olive::ProxyManager::ProxyParams pp; + pp.width = params->width; + pp.height = params->height; + pp.divider = params->divider; + pp.version = params->version; + pp.crf = params->crf; + pp.include_audio = params->include_audio != 0; + pp.extension = QString::fromUtf8(params->extension); + pp.preset = QString::fromUtf8(params->preset); + impl(self)->node->set_custom_proxy_params(pp); + return OAKENGINE_OK; +} + +int oakengine_footage_clear_custom_proxy_params(OakEngineFootage *self) +{ + if (!self || !impl(self)->node) { + return OAKENGINE_E_INVALID; + } + impl(self)->node->clear_custom_proxy_params(); + return OAKENGINE_OK; +} + +int oakengine_footage_set_proxy(OakEngineFootage *self, + const char *path, int state, + int stream_index, int enabled, int version) +{ + if (!self || !impl(self)->node) { + return OAKENGINE_E_INVALID; + } + olive::Footage *node = impl(self)->node; + node->set_proxy(QString::fromUtf8(path ? path : ""), + static_cast(state), + stream_index, version, enabled != 0); + return OAKENGINE_OK; +} + +int oakengine_footage_clear_proxy(OakEngineFootage *self) +{ + if (!self || !impl(self)->node) { + return OAKENGINE_E_INVALID; + } + impl(self)->node->clear_proxy(); + return OAKENGINE_OK; +} + +int oakengine_footage_invalidate(OakEngineFootage *self) +{ + if (!self || !impl(self)->node) { + return OAKENGINE_E_INVALID; + } + impl(self)->node->clear(); + return OAKENGINE_OK; +} + } // extern "C" diff --git a/engine/src/capi/gizmo.cpp b/engine/src/capi/gizmo.cpp new file mode 100644 index 000000000..962c39731 --- /dev/null +++ b/engine/src/capi/gizmo.cpp @@ -0,0 +1,268 @@ +/*** + + 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/gizmo.h" + +#include + +#include "node/gizmo/draggable.h" +#include "node/gizmo/text.h" +#include "node/generator/text/textv3.h" +#include "node/node.h" + +extern "C" { + +int oakengine_text_gizmo_get(OakEngineNode *node, + int64_t time_num, int64_t time_den, oakengine_text_gizmo *out) +{ + if (!node || !out) { + return OAKENGINE_E_INVALID; + } + + auto *textv3 = dynamic_cast( + reinterpret_cast(node)); + if (!textv3) { + return OAKENGINE_E_INVALID; + } + + olive::TextGizmo *gizmo = textv3->text_gizmo(); + if (!gizmo) { + return OAKENGINE_E_INVALID; + } + + QRectF r = gizmo->get_rect(); + out->rect_x = r.x(); + out->rect_y = r.y(); + out->rect_w = r.width(); + out->rect_h = r.height(); + + // Map Qt::Alignment to our simple enum + Qt::Alignment va = gizmo->get_vertical_alignment(); + if (va & Qt::AlignBottom) { + out->vertical_alignment = 1; + } else if (va & Qt::AlignVCenter) { + out->vertical_alignment = 2; + } else { + out->vertical_alignment = 0; // AlignTop + } + + return OAKENGINE_OK; +} + +int oakengine_text_gizmo_get_html(OakEngineNode *node, + int64_t time_num, int64_t time_den, char *buf, int buf_size) +{ + if (!node) { + return OAKENGINE_E_INVALID; + } + + auto *textv3 = dynamic_cast( + reinterpret_cast(node)); + if (!textv3) { + return OAKENGINE_E_INVALID; + } + + olive::TextGizmo *gizmo = textv3->text_gizmo(); + if (!gizmo) { + return OAKENGINE_E_INVALID; + } + + QByteArray html = gizmo->get_html().toUtf8(); + int needed = html.size() + 1; // include NUL + + if (buf && buf_size > 0) { + int copy = qMin(needed, buf_size); + memcpy(buf, html.constData(), copy - 1); + buf[copy - 1] = '\0'; + } + + return needed; +} + +int oakengine_text_gizmo_update_html(OakEngineNode *node, + const char *html, int64_t time_num, int64_t time_den) +{ + if (!node || !html) { + return OAKENGINE_E_INVALID; + } + + auto *textv3 = dynamic_cast( + reinterpret_cast(node)); + if (!textv3) { + return OAKENGINE_E_INVALID; + } + + olive::TextGizmo *gizmo = textv3->text_gizmo(); + if (!gizmo) { + return OAKENGINE_E_INVALID; + } + + gizmo->update_input_html(QString::fromUtf8(html), + olive::core::Rational(time_num, time_den)); + return OAKENGINE_OK; +} + +int oakengine_text_gizmo_set_vertical_alignment( + OakEngineNode *node, int alignment) +{ + if (!node) { + return OAKENGINE_E_INVALID; + } + + auto *textv3 = dynamic_cast( + reinterpret_cast(node)); + if (!textv3) { + return OAKENGINE_E_INVALID; + } + + olive::TextGizmo *gizmo = textv3->text_gizmo(); + if (!gizmo) { + return OAKENGINE_E_INVALID; + } + + Qt::Alignment va; + switch (alignment) { + case 1: + va = Qt::AlignBottom; + break; + case 2: + va = Qt::AlignVCenter; + break; + default: + va = Qt::AlignTop; + break; + } + + gizmo->set_vertical_alignment(va); + return OAKENGINE_OK; +} + +int oakengine_text_gizmo_activated(OakEngineNode *node) +{ + if (!node) { + return OAKENGINE_E_INVALID; + } + + auto *textv3 = dynamic_cast( + reinterpret_cast(node)); + if (!textv3) { + return OAKENGINE_E_INVALID; + } + + olive::TextGizmo *gizmo = textv3->text_gizmo(); + if (!gizmo) { + return OAKENGINE_E_INVALID; + } + + emit gizmo->activated(); + return OAKENGINE_OK; +} + +int oakengine_text_gizmo_deactivated(OakEngineNode *node) +{ + if (!node) { + return OAKENGINE_E_INVALID; + } + + auto *textv3 = dynamic_cast( + reinterpret_cast(node)); + if (!textv3) { + return OAKENGINE_E_INVALID; + } + + olive::TextGizmo *gizmo = textv3->text_gizmo(); + if (!gizmo) { + return OAKENGINE_E_INVALID; + } + + emit gizmo->deactivated(); + return OAKENGINE_OK; +} + +int oakengine_gizmo_get_drag_value_behavior(void *gizmo) +{ + if (!gizmo) { + return OAKENGINE_E_INVALID; + } + + auto *dg = dynamic_cast( + static_cast(gizmo)); + if (!dg) { + return OAKENGINE_E_INVALID; + } + return static_cast(dg->get_drag_value_behavior()); +} + +int oakengine_gizmo_drag_start(void *gizmo, + void *row, double abs_x, double abs_y, int64_t time_num, + int64_t time_den) +{ + if (!gizmo) { + return OAKENGINE_E_INVALID; + } + + auto *dg = dynamic_cast( + static_cast(gizmo)); + if (!dg) { + return OAKENGINE_E_INVALID; + } + + olive::NodeValueRow empty_row; + olive::NodeValueRow &row_ref = row + ? *static_cast(row) + : empty_row; + + dg->drag_start(row_ref, abs_x, abs_y, + olive::core::Rational(time_num, time_den)); + return OAKENGINE_OK; +} + +int oakengine_gizmo_drag_move(void *gizmo, + double x, double y, int qt_keyboard_modifiers) +{ + if (!gizmo) { + return OAKENGINE_E_INVALID; + } + + auto *dg = dynamic_cast( + static_cast(gizmo)); + if (!dg) { + return OAKENGINE_E_INVALID; + } + dg->drag_move(x, y, Qt::KeyboardModifiers(qt_keyboard_modifiers)); + return OAKENGINE_OK; +} + +int oakengine_gizmo_drag_end(void *gizmo, void *command) +{ + if (!gizmo) { + return OAKENGINE_E_INVALID; + } + + auto *dg = dynamic_cast( + static_cast(gizmo)); + if (!dg) { + return OAKENGINE_E_INVALID; + } + dg->drag_end(static_cast(command)); + return OAKENGINE_OK; +} + +} // extern "C" diff --git a/engine/src/capi/lut.cpp b/engine/src/capi/lut.cpp new file mode 100644 index 000000000..17c94a105 --- /dev/null +++ b/engine/src/capi/lut.cpp @@ -0,0 +1,90 @@ +/*** + + 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/lut.h" + +#include + +#include +#include +#include + +#include "render/lutlibrary.h" + +namespace +{ + +int write_string(const QString &s, char *buf, int buf_size) +{ + const QByteArray utf8 = s.toUtf8(); + const int len = int(utf8.size()); + if (buf && buf_size > 0) { + const int n = qMin(len, buf_size - 1); + std::memcpy(buf, utf8.constData(), size_t(n)); + buf[n] = '\0'; + } + return len; +} + +} // namespace + +extern "C" int oakengine_lut_directory_count(void) +{ + return olive::LUTLibrary::get_directories().size(); +} + +extern "C" int oakengine_lut_directory_at(int index, char *buf, int buf_size) +{ + const QStringList dirs = olive::LUTLibrary::get_directories(); + if (index < 0 || index >= dirs.size()) { + return OAKENGINE_E_NOT_FOUND; + } + return write_string(dirs.at(index), buf, buf_size); +} + +extern "C" int oakengine_lut_file_count(void) +{ + return olive::LUTLibrary::get_lut_files().size(); +} + +extern "C" int oakengine_lut_file_at(int index, char *buf, int buf_size) +{ + const QStringList files = olive::LUTLibrary::get_lut_files(); + if (index < 0 || index >= files.size()) { + return OAKENGINE_E_NOT_FOUND; + } + return write_string(files.at(index), buf, buf_size); +} + +extern "C" int oakengine_lut_set_directories(const char *const *dirs, + int count) +{ + QStringList list; + if (dirs && count > 0) { + list.reserve(count); + for (int i = 0; i < count; i++) { + if (dirs[i]) { + list.append(QString::fromUtf8(dirs[i])); + } + } + } + olive::LUTLibrary::set_directories(list); + return OAKENGINE_OK; +} diff --git a/engine/src/capi/node.cpp b/engine/src/capi/node.cpp index 4fabd8503..99fa20273 100644 --- a/engine/src/capi/node.cpp +++ b/engine/src/capi/node.cpp @@ -38,8 +38,18 @@ #include "node/project.h" #include "node/project/sequence/sequence.h" #include "node/value.h" +#include "node/group/group.h" +#include "node/input/multicam/multicamnode.h" +#include "node/audio/volume/volume.h" +#include "node/distort/transform/transformdistortnode.h" +#include "node/block/transition/transition.h" +#include "node/block/subtitle/subtitle.h" +#include "node/generator/shape/shapenodebase.h" +#include "audio/audiovisualwaveform.h" +#include "node/inputimmediate.h" #include "undo/undocommand.h" #include "undo/undostack.h" +#include "undointernal.h" namespace { @@ -91,12 +101,7 @@ int string_to_buf(const QString &s, char *buf, int buf_size) // initialized, otherwise execute it directly. void push_or_run(olive::UndoCommand *command, const QString &name) { - if (olive::EngineCore::instance()) { - olive::EngineCore::instance()->undo_stack()->push(command, name); - } else { - command->redo_now(); - delete command; - } + oakengine_undo_push_or_run(command, name); } // NodeValue::Type -> facade value type; types without a POD representation @@ -124,11 +129,91 @@ oak_node_value_type to_c_type(olive::NodeValue::Type t) return OAK_NODE_VALUE_COMBO; case olive::NodeValue::k_file: return OAK_NODE_VALUE_STRING; + case olive::NodeValue::k_text: + return OAK_NODE_VALUE_TEXT; + case olive::NodeValue::k_font: + return OAK_NODE_VALUE_FONT; + case olive::NodeValue::k_str_combo: + return OAK_NODE_VALUE_STR_COMBO; + case olive::NodeValue::k_binary: + return OAK_NODE_VALUE_BINARY; + case olive::NodeValue::k_bezier: + return OAK_NODE_VALUE_BEZIER; + case olive::NodeValue::k_texture: + return OAK_NODE_VALUE_TEXTURE; + case olive::NodeValue::k_samples: + return OAK_NODE_VALUE_SAMPLES; + case olive::NodeValue::k_video_params: + return OAK_NODE_VALUE_VIDEO_PARAMS; + case olive::NodeValue::k_audio_params: + return OAK_NODE_VALUE_AUDIO_PARAMS; default: return OAK_NODE_VALUE_NONE; } } +// Convert a raw QVariant (not wrapped in NodeValue) to C POD based on type. +// Used for default values where the QVariant holds the native type directly. +static bool qvariant_to_pod(olive::NodeValue::Type type, const QVariant &qv, + oak_node_value *out) +{ + switch (type) { + case olive::NodeValue::k_int: + case olive::NodeValue::k_combo: + out->num = qv.toLongLong(); + return true; + case olive::NodeValue::k_float: + out->f[0] = qv.toDouble(); + return true; + case olive::NodeValue::k_boolean: + out->num = qv.toBool() ? 1 : 0; + return true; + case olive::NodeValue::k_rational: { + const olive::Rational r = qv.value(); + out->num = r.numerator(); + out->den = r.denominator(); + return true; + } + case olive::NodeValue::k_color: { + const olive::core::Color c = qv.value(); + out->f[0] = c.red(); + out->f[1] = c.green(); + out->f[2] = c.blue(); + out->f[3] = c.alpha(); + return true; + } + case olive::NodeValue::k_vec2: + if (qv.canConvert()) { + const QVector2D v2 = qv.value(); + out->f[0] = v2.x(); + out->f[1] = v2.y(); + return true; + } + return false; + case olive::NodeValue::k_vec3: + if (qv.canConvert()) { + const QVector3D v3 = qv.value(); + out->f[0] = v3.x(); + out->f[1] = v3.y(); + out->f[2] = v3.z(); + return true; + } + return false; + case olive::NodeValue::k_vec4: + if (qv.canConvert()) { + const QVector4D v4 = qv.value(); + out->f[0] = v4.x(); + out->f[1] = v4.y(); + out->f[2] = v4.z(); + out->f[3] = v4.w(); + return true; + } + return false; + default: + return false; + } +} + // Map an engine standard value into the POD. Returns false when the type // has no POD representation (including STRING, which uses dedicated APIs). bool value_to_c(const olive::NodeValue &v, oak_node_value *out) @@ -498,6 +583,40 @@ OakEngineNode *oakengine_project_node_at(const OakEngineProject *self, return wrap(impl(self)->nodes().at(index)); } +int oakengine_node_factory_id_count(void) +{ + return olive::NodeFactory::get_library().size(); +} + +OakEngineNode *oakengine_node_factory_create_from_id(const char *type_id) +{ + if (!type_id) { + return nullptr; + } + return wrap(olive::NodeFactory::create_from_id(QString::fromUtf8(type_id))); +} + +int oakengine_node_factory_name_from_id(const char *type_id, char *buf, + int buf_size) +{ + if (!type_id) { + if (buf && buf_size > 0) buf[0] = '\0'; + return 0; + } + const QString name = olive::NodeFactory::get_name_from_id( + QString::fromUtf8(type_id)); + return string_to_buf(name, buf, buf_size); +} + +OakEngineNode *oakengine_node_factory_node_at(int index) +{ + const QList &lib = olive::NodeFactory::get_library(); + if (index < 0 || index >= lib.size()) { + return nullptr; + } + return wrap(lib.at(index)); +} + int oakengine_node_get_type_id(const OakEngineNode *self, char *buf, int buf_size) { @@ -549,8 +668,9 @@ int oakengine_node_set_label_ex(OakEngineNode *self, const char *label, return OAKENGINE_OK; } -int oakengine_node_set_label_many(OakEngineNode **nodes, int count, - const char *label) +int oakengine_node_rename_many(OakEngineNode **nodes, int count, + const char *label, + void *parent_multi_or_NULL) { set_error(QString()); if (count < 0 || (count > 0 && !nodes)) { @@ -571,10 +691,31 @@ int oakengine_node_set_label_many(OakEngineNode **nodes, int count, } command->add_node(impl(nodes[i]), text); } - push_or_run(command, QStringLiteral("Rename Nodes")); + if (parent_multi_or_NULL) { + static_cast(parent_multi_or_NULL)->add_child( + command); + } else { + push_or_run(command, QStringLiteral("Rename Nodes")); + } return OAKENGINE_OK; } +extern "C" void *oakengine_node_rename_command(OakEngineNode *node, + const char *label) +{ + if (!node) { + return nullptr; + } + return new olive::NodeRenameCommand(impl(node), + QString::fromUtf8(label ? label : "")); +} + +int oakengine_node_set_label_many(OakEngineNode **nodes, int count, + const char *label) +{ + return oakengine_node_rename_many(nodes, count, label, nullptr); +} + int oakengine_node_set_color_label(OakEngineNode **nodes, int count, int color_index) { @@ -601,6 +742,16 @@ int oakengine_node_set_color_label(OakEngineNode **nodes, int count, return OAKENGINE_OK; } +extern "C" void *oakengine_node_set_color_label_command(OakEngineNode *node, + int color_index) +{ + olive::Node *n = impl(node); + if (!n) { + return nullptr; + } + return new olive::NodeOverrideColorCommand(n, color_index); +} + int oakengine_node_get_color_label(const OakEngineNode *self) { if (!self) { @@ -767,22 +918,6 @@ int oakengine_node_set_input_string(OakEngineNode *self, return OAKENGINE_OK; } -int oakengine_node_frame_time_base(const OakEngineNode *self, int *num, - int *den) -{ - if (!self) { - return OAKENGINE_E_INVALID; - } - const olive::Rational tb = project_time_base(impl(self)); - if (num) { - *num = tb.numerator(); - } - if (den) { - *den = tb.denominator(); - } - return OAKENGINE_OK; -} - // Component QVariant of a per-track POD for set_value_at_time: the // panel's sliders carry one scalar per track (int64/double/Rational/ // bool). Returns false on a type that has no scalar component here. @@ -833,6 +968,136 @@ static bool component_from_c(const oak_node_value *v, } } +extern "C" void *oakengine_node_set_standard_value_command( + OakEngineNode *self, const char *input_id, int element, int track, + const oak_node_value *v) +{ + if (!self || !input_id || !v) { + return nullptr; + } + olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->inputs().contains(id)) { + return nullptr; + } + const olive::NodeValue::Type declared = node->get_input_data_type(id); + if (to_c_type(declared) == OAK_NODE_VALUE_NONE) { + return nullptr; + } + QVariant value; + if (track < 0) { + // Track -1 writes the whole single-track value. + if (!value_from_c(v, declared, &value)) { + return nullptr; + } + } else { + // Per-track component (the command stores the value on one track). + if (!component_from_c(v, declared, 0, &value)) { + return nullptr; + } + } + return new olive::NodeParamSetStandardValueCommand( + olive::NodeKeyframeTrackReference(olive::NodeInput(node, id, element), + track), + value); +} + +extern "C" void *oakengine_node_set_input_video_params_command( + OakEngineNode *self, const char *input_id, const oak_video_params *params) +{ + if (!self || !input_id || !params) { + return nullptr; + } + olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->inputs().contains(id)) { + return nullptr; + } + if (node->get_input_data_type(id) != olive::NodeValue::k_video_params) { + return nullptr; + } + const olive::VideoParams params_cpp( + params->width, params->height, olive::Rational(params->time_base_num, + params->time_base_den), + static_cast(params->format), + olive::VideoParams::k_internal_channel_count, + olive::Rational(params->pixel_aspect_num, params->pixel_aspect_den), + static_cast(params->interlacing), + params->divider); + return new olive::NodeParamSetStandardValueCommand( + olive::NodeKeyframeTrackReference(olive::NodeInput(node, id)), + QVariant::fromValue(params_cpp)); +} + +extern "C" void *oakengine_node_set_value_at_time_command( + void *node, const char *input, int element, int64_t time_num, + int64_t time_den, const oak_node_value *value, int track, + int insert_on_all_tracks_if_no_key) +{ + if (!node || !input || !value || time_den == 0) { + return nullptr; + } + olive::Node *n = reinterpret_cast(node); + const QString id = QString::fromUtf8(input); + if (!n->inputs().contains(id)) { + return nullptr; + } + const olive::NodeValue::Type declared = n->get_input_data_type(id); + const int nb_tracks = + olive::NodeValue::get_number_of_keyframe_tracks(declared); + if (track < -1 || track >= nb_tracks || nb_tracks == 0) { + return nullptr; + } + if (declared == olive::NodeValue::k_file || + declared == olive::NodeValue::k_text || + declared == olive::NodeValue::k_font || + declared == olive::NodeValue::k_str_combo) { + return nullptr; + } + + const olive::Rational time(time_num, time_den); + const olive::NodeInput node_input(n, id, element); + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + if (track == -1) { + for (int i = 0; i < nb_tracks; i++) { + QVariant component; + if (!component_from_c(value, declared, i, &component)) { + delete command; + return nullptr; + } + olive::Node::set_value_at_time(node_input, time, component, i, + command, false); + } + } else { + QVariant component; + if (!component_from_c(value, declared, 0, &component)) { + delete command; + return nullptr; + } + olive::Node::set_value_at_time( + node_input, time, component, track, command, + insert_on_all_tracks_if_no_key != 0); + } + return command; +} + +int oakengine_node_frame_time_base(const OakEngineNode *self, int *num, + int *den) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::Rational tb = project_time_base(impl(self)); + if (num) { + *num = tb.numerator(); + } + if (den) { + *den = tb.denominator(); + } + return OAKENGINE_OK; +} + + int oakengine_node_set_input_at_time(OakEngineNode *self, const char *input_id, int element, int64_t time_ts, int track, @@ -1082,6 +1347,67 @@ int oakengine_node_disconnect_ex(OakEngineNode *input_node, return OAKENGINE_OK; } +extern "C" void *oakengine_node_connect_command(OakEngineNode *output_node, + OakEngineNode *input_node, + const char *input_id, + int element) +{ + olive::Node *out_node = impl(output_node); + olive::Node *in_node = impl(input_node); + if (!out_node || !in_node || !input_id) { + return nullptr; + } + const QString id = QString::fromUtf8(input_id); + if (!in_node->inputs().contains(id)) { + return nullptr; + } + return new olive::NodeEdgeAddCommand( + out_node, olive::NodeInput(in_node, id, element)); +} + +extern "C" void *oakengine_node_disconnect_command(OakEngineNode *input_node, + const char *input_id, + int element) +{ + olive::Node *in_node = impl(input_node); + if (!in_node || !input_id) { + return nullptr; + } + const QString id = QString::fromUtf8(input_id); + if (!in_node->inputs().contains(id)) { + return nullptr; + } + const olive::NodeInput input(in_node, id, element); + olive::Node *connected = in_node->get_connected_output(input); + if (!connected) { + return nullptr; + } + return new olive::NodeEdgeRemoveCommand(connected, input); +} + +extern "C" int oakengine_block_link(void *a, void *b, int linked) +{ + if (!a || !b) { + return OAKENGINE_E_INVALID; + } + olive::Node *na = reinterpret_cast(a); + olive::Node *nb = reinterpret_cast(b); + const bool ok = linked ? olive::Node::link(na, nb) : + olive::Node::unlink(na, nb); + return ok ? 1 : 0; +} + +extern "C" void *oakengine_node_add_to_project_command(OakEngineProject *project, + OakEngineNode *node) +{ + olive::Project *p = impl(project); + olive::Node *n = impl(node); + if (!p || !n) { + return nullptr; + } + return new olive::NodeAddCommand(p, n); +} + /* ---- Parameter animation (keyframes) -------------------------------------- */ int oakengine_node_input_is_keyframed(const OakEngineNode *self, @@ -1262,6 +1588,82 @@ int oakengine_node_keyframe_remove(OakEngineNode *self, const char *input_id, return OAKENGINE_OK; } +extern "C" void *oakengine_node_insert_keyframe_command( + OakEngineNode *self, const char *input_id, int element, int track, + int64_t time_ts, const oak_node_value *value, int type, float x1, float y1, + float x2, float y2) +{ + if (!self || !input_id || !value || type < 0 || type > 2) { + return nullptr; + } + olive::Node *node = impl(self); + const olive::NodeValue::Type declared = + checked_keyframe_input(node, input_id); + if (declared == olive::NodeValue::k_none) { + return nullptr; + } + QVariant normal; + if (!value_from_c(value, declared, &normal)) { + return nullptr; + } + const olive::SplitValue split = + olive::NodeValue::split_normal_value_into_track_values(declared, + normal); + if (track < 0 || track >= split.size()) { + return nullptr; + } + const QString id = QString::fromUtf8(input_id); + const olive::Rational time = olive::core::Timecode::timestamp_to_time( + time_ts, project_time_base(node)); + auto *key = new olive::NodeKeyframe(time, split.at(track), + to_engine_easing(type), track, element, + id); + if (type == 1) { + key->set_bezier_control_in(QPointF(x1, y1)); + key->set_bezier_control_out(QPointF(x2, y2)); + } + return new olive::NodeParamInsertKeyframeCommand(node, key); +} + +extern "C" void *oakengine_node_remove_keyframe_command( + OakEngineKeyframe *keyframe) +{ + auto *key = reinterpret_cast(keyframe); + if (!key) { + return nullptr; + } + return new olive::NodeParamRemoveKeyframeCommand(key); +} + +extern "C" void *oakengine_keyframe_set_time_command( + OakEngineKeyframe *keyframe, int64_t new_time_ts) +{ + auto *key = reinterpret_cast(keyframe); + if (!key || !key->parent()) { + return nullptr; + } + const olive::Rational tb = project_time_base(key->parent()); + const olive::Rational new_time = + olive::core::Timecode::timestamp_to_time(new_time_ts, tb); + return new olive::NodeParamSetKeyframeTimeCommand(key, new_time); +} + +extern "C" void *oakengine_keyframe_set_value_command( + OakEngineKeyframe *keyframe, const oak_node_value *value) +{ + auto *key = reinterpret_cast(keyframe); + if (!key || !value || !key->parent()) { + return nullptr; + } + const olive::NodeValue::Type declared = + key->parent()->get_input_data_type(key->input()); + QVariant v; + if (!component_from_c(value, declared, key->track(), &v)) { + return nullptr; + } + return new olive::NodeParamSetKeyframeValueCommand(key, v); +} + int oakengine_node_keyframe_set_easing(OakEngineNode *self, const char *input_id, int64_t time_ts, int type, float x1, float y1, @@ -1587,4 +1989,2582 @@ int oakengine_node_keyframes_clear(OakEngineNode *self, const char *input_id) return OAKENGINE_OK; } +/* ---- Extended input introspection ----------------------------------------- */ + +int oakengine_node_input_is_array(const OakEngineNode *self, + const char *input_id) +{ + if (!self || !input_id) { + return 0; + } + return impl(self)->input_is_array(QString::fromUtf8(input_id)) ? 1 : 0; +} + +int oakengine_node_input_array_size(const OakEngineNode *self, + const char *input_id) +{ + if (!self || !input_id) { + return 0; + } + return impl(self)->input_array_size(QString::fromUtf8(input_id)); +} + +int oakengine_node_input_get_flags(const OakEngineNode *self, + const char *input_id) +{ + if (!self || !input_id) { + return 0; + } + return int(impl(self)->get_input_flags(QString::fromUtf8(input_id))); +} + +int oakengine_node_input_is_connectable(const OakEngineNode *self, + const char *input_id) +{ + if (!self || !input_id) { + return 0; + } + return impl(self)->is_input_connectable(QString::fromUtf8(input_id)) ? 1 : 0; +} + +int oakengine_node_input_is_keyframable(const OakEngineNode *self, + const char *input_id) +{ + if (!self || !input_id) { + return 0; + } + return impl(self)->is_input_keyframable(QString::fromUtf8(input_id)) ? 1 : 0; +} + +int oakengine_node_input_is_keyframed_ex(const OakEngineNode *self, + const char *input_id, int track) +{ + if (!self || !input_id) { + return 0; + } + (void)track; + return impl(self)->is_input_keyframing(QString::fromUtf8(input_id)) ? 1 : 0; +} + +int oakengine_node_get_label_and_name(const OakEngineNode *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->get_label_and_name(), buf, buf_size); +} + +int oakengine_node_get_input_name(const OakEngineNode *self, + const char *input_id, char *buf, + int buf_size) +{ + if (!self || !input_id) { + return OAKENGINE_E_INVALID; + } + const olive::NodeInput input(const_cast(impl(self)), + QString::fromUtf8(input_id)); + return string_to_buf(input.get_input_name(), buf, buf_size); +} + +int oakengine_node_input_get_default_value(const OakEngineNode *self, + const char *input_id, int track, + oak_node_value *out) +{ + set_error(QString()); + if (!self || !input_id || !out) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::NodeInput input(const_cast(impl(self)), + QString::fromUtf8(input_id)); + const olive::NodeValue::Type type = input.get_data_type(); + if (type == olive::NodeValue::k_none) { + set_error(QStringLiteral("unknown input id \"%1\"") + .arg(QString::fromUtf8(input_id))); + return OAKENGINE_E_NOT_FOUND; + } + if (track >= 0) { + // Validate track count: inputs may have split tracks (Color→4, Vec2→2, etc.) + // or a single whole-value track (track 0). + int num_tracks = impl(self)->get_number_of_keyframe_tracks( + QString::fromUtf8(input_id)); + if (track >= qMax(1, num_tracks)) { + set_error(QStringLiteral("track index out of range")); + return OAKENGINE_E_NOT_FOUND; + } + } + const QVariant def = (track >= 0) ? + input.get_split_default_value_for_track(track) : + input.get_default_value(); + // Convert the default QVariant directly to C POD. Bypass NodeValue + // constructor because passing QVariant to the template constructor + // nests it inside another QVariant, breaking value_to_c extraction. + // Also handle split defaults: for k_color the QVariant may be a float + // (single channel) instead of a full Color. + memset(out, 0, sizeof(*out)); + out->type = to_c_type(type); + if (type == olive::NodeValue::k_color && def.typeId() == QMetaType::Float) { + out->f[0] = def.toFloat(); + out->f[1] = 0.0; + out->f[2] = 0.0; + out->f[3] = 1.0; + } else if (!qvariant_to_pod(type, def, out)) { + set_error(QStringLiteral("default value has no POD representation")); + return OAKENGINE_E_NOT_FOUND; + } + return OAKENGINE_OK; +} + +OakEngineProject *oakengine_node_get_project(const OakEngineNode *self) +{ + if (!self) { + return nullptr; + } + olive::Project *p = impl(self)->project(); + return reinterpret_cast(p); +} + +OakEngineNode *oakengine_node_input_get_connected_node( + const OakEngineNode *self, const char *input_id, int element) +{ + if (!self || !input_id) { + return nullptr; + } + olive::Node *conn = const_cast(impl(self)) + ->get_connected_output(QString::fromUtf8(input_id), + element); + return wrap(conn); +} + +int oakengine_node_copy_inputs(OakEngineNode *dest, const OakEngineNode *src) +{ + set_error(QString()); + if (!dest || !src) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + olive::Node::copy_inputs(impl(src), impl(dest), false, command); + push_or_run(command, QStringLiteral("Copy Inputs")); + return OAKENGINE_OK; +} + +int oakengine_node_get_input_at_time(const OakEngineNode *self, + const char *input_id, int element, + int track, int64_t time_ts, + int track_for_time, oak_node_value *out) +{ + set_error(QString()); + // Facade contract: `track` is the 0-based component selector (-1 = + // whole value), time_ts is in SECONDS, track_for_time is the 1-based + // keyframe track selector (accepted for signature compatibility). + (void)track_for_time; + if (!self || !input_id || !out) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->inputs().contains(id)) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + const olive::NodeValue::Type type = node->get_input_data_type(id); + if (type == olive::NodeValue::k_file || type == olive::NodeValue::k_text || + type == olive::NodeValue::k_font || + type == olive::NodeValue::k_str_combo) { + set_error(QStringLiteral( + "\"%1\" is a string input; use oakengine_node_get_input_string_at_time()") + .arg(id)); + return OAKENGINE_E_INVALID; + } + // Facade contract: time_ts is a frame timestamp in the project's frame + // timebase (like the setter family and the timeline family). + const olive::Rational tb = project_time_base(node); + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(time_ts, tb); + // When a specific track is requested (track >= 0) and element is not + // set (element == -1), use track as the element to get the per-track + // component from the engine's split-value API. + const int eff_element = (track >= 0 && element < 0) ? track : element; + // If we're requesting a single track on a multi-component type, use + // get_split_value_at_time to get the raw component value (float/int). + // Then construct the output POD directly, bypassing NodeValue which + // can't handle a scalar QVariant for a multi-component type. + bool direct_ok = false; + memset(out, 0, sizeof(*out)); + out->type = to_c_type(type); + if (track >= 0) { + // Per-component read: the component is the keyframe track with the + // same 0-based index; the element is passed through unchanged (for + // non-array inputs it stays -1, so the keyed path is found). + const QVariant comp = + node->get_split_value_at_time_on_track(id, time, track, element); + if (comp.isValid()) { + // Scalar components are reported in f[0] for float-like types + // (color/vec) and in num for integer-like types (bool/int/ + // combo/rational) -- see the at-time readers in + // oakengine_node_test/oakengine_keyframe_test. + switch (type) { + case olive::NodeValue::k_boolean: + case olive::NodeValue::k_int: + case olive::NodeValue::k_combo: + out->num = comp.toLongLong(); + break; + case olive::NodeValue::k_rational: + out->num = comp.value().numerator(); + out->den = comp.value().denominator(); + break; + default: + out->f[0] = comp.toDouble(); + if (type == olive::NodeValue::k_color) { + out->f[3] = 1.0; + } + break; + } + direct_ok = true; + } + } + if (!direct_ok) { + const QVariant sv = node->get_value_at_time(id, time, eff_element); + if (type == olive::NodeValue::k_color && sv.canConvert()) { + olive::core::Color c = sv.value(); + } + olive::NodeValue nv(type, sv); + if (!value_to_c(nv, out)) { + set_error(QStringLiteral( + "input \"%1\" has no POD value at time").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + } + return OAKENGINE_OK; +} + +int oakengine_node_get_input_string_at_time(const OakEngineNode *self, + const char *input_id, int element, + int64_t time_ts, int track, + char *buf, int buf_size) +{ + set_error(QString()); + (void)track; + if (!self || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->inputs().contains(id)) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + const olive::NodeValue::Type type = node->get_input_data_type(id); + if (type != olive::NodeValue::k_file && + type != olive::NodeValue::k_text && + type != olive::NodeValue::k_font && + type != olive::NodeValue::k_str_combo) { + set_error(QStringLiteral("\"%1\" is not a string input").arg(id)); + return OAKENGINE_E_INVALID; + } + const olive::Rational tb = project_time_base(node); + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(time_ts, tb); + const QVariant sv = node->get_value_at_time(id, time, element); + return string_to_buf(sv.toString(), buf, buf_size); +} + +int oakengine_node_get_input_bezier_at_time(const OakEngineNode *self, + const char *input_id, int element, + int64_t time_ts, int track, + double *out_6) +{ + set_error(QString()); + if (!self || !input_id || !out_6) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->inputs().contains(id)) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + const olive::NodeValue::Type type = node->get_input_data_type(id); + if (type != olive::NodeValue::k_bezier) { + set_error(QStringLiteral("\"%1\" is not a bezier input").arg(id)); + return OAKENGINE_E_INVALID; + } + const olive::Rational tb = project_time_base(node); + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(time_ts, tb); + const int nb_tracks = + olive::NodeValue::get_number_of_keyframe_tracks(type); + double vals[6] = { 0 }; + const int n = qMin(6, nb_tracks); + for (int i = 0; i < n; ++i) { + const QVariant comp = + node->get_split_value_at_time_on_track(id, time, i, element); + vals[i] = comp.toDouble(); + } + out_6[0] = vals[0]; + out_6[1] = vals[1]; + out_6[2] = vals[2]; + out_6[3] = vals[3]; + out_6[4] = vals[4]; + out_6[5] = vals[5]; + return OAKENGINE_OK; +} + +int oakengine_node_get_input_binary_at_time(const OakEngineNode *self, + const char *input_id, int element, + int64_t time_ts, int track, + char *buf, int buf_size) +{ + set_error(QString()); + if (!self || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->inputs().contains(id)) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + const olive::NodeValue::Type type = node->get_input_data_type(id); + if (type != olive::NodeValue::k_binary) { + set_error(QStringLiteral("\"%1\" is not a binary input").arg(id)); + return OAKENGINE_E_INVALID; + } + const olive::Rational tb = project_time_base(node); + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(time_ts, tb); + const QVariant sv = node->get_value_at_time(id, time, element); + const QByteArray bytes = sv.toByteArray(); + if (buf && buf_size > 0) { + const int n = qMin(buf_size, bytes.size()); + if (n > 0) { + memcpy(buf, bytes.constData(), size_t(n)); + } + } + return bytes.size(); +} + +/* ---- Input properties ----------------------------------------------------- */ + +int oakengine_node_input_has_property(const OakEngineNode *self, + const char *input_id, const char *key) +{ + if (!self || !input_id || !key) { + return 0; + } + return impl(self)->has_input_property(QString::fromUtf8(input_id), + QString::fromUtf8(key)) ? 1 : 0; +} + +int oakengine_node_set_input_property_string(OakEngineNode *self, + const char *input_id, + const char *key, + const char *value, int notify) +{ + set_error(QString()); + if (!self || !input_id || !key) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + // The engine's set_input_property is direct (no undo). We wrap it in + // the push_or_run pattern when notify != 0. + olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->inputs().contains(id)) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + // Apply directly; property changes are typically not undoable at the + // engine level (they are UI hints). The engine's set_input_property + // always emits input_property_changed; the facade's `notify` flag + // controls whether that emission (and therefore the facade event) + // fires. + if (notify) { + node->set_input_property(id, QString::fromUtf8(key), + QVariant::fromValue(QString::fromUtf8(value ? value : ""))); + } else { + const QSignalBlocker blocker(node); + node->set_input_property(id, QString::fromUtf8(key), + QVariant::fromValue(QString::fromUtf8(value ? value : ""))); + } + return OAKENGINE_OK; +} + +int oakengine_node_input_get_property_string(const OakEngineNode *self, + const char *input_id, + const char *key, char *buf, + int buf_size) +{ + set_error(QString()); + if (!self || !input_id || !key) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->has_input_property(id, QString::fromUtf8(key))) { + set_error(QStringLiteral("property \"%1\" not found on \"%2\"") + .arg(QString::fromUtf8(key)).arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + return string_to_buf( + node->get_input_property(id, QString::fromUtf8(key)).toString(), + buf, buf_size); +} + +int oakengine_node_input_get_property_number(const OakEngineNode *self, + const char *input_id, + const char *key, int track, + double *out) +{ + set_error(QString()); + if (!self || !input_id || !key || !out) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->has_input_property(id, QString::fromUtf8(key))) { + set_error(QStringLiteral("property \"%1\" not found on \"%2\"") + .arg(QString::fromUtf8(key)).arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + const QVariant v = node->get_input_property(id, QString::fromUtf8(key)); + bool ok = false; + const double d = v.toDouble(&ok); + if (!ok) { + set_error(QStringLiteral("property \"%1\" is not a number") + .arg(QString::fromUtf8(key))); + return OAKENGINE_E_INVALID; + } + *out = d; + return OAKENGINE_OK; +} + +int oakengine_node_input_get_property_int(const OakEngineNode *self, + const char *input_id, + const char *key, int64_t *out) +{ + set_error(QString()); + if (!self || !input_id || !key || !out) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->has_input_property(id, QString::fromUtf8(key))) { + set_error(QStringLiteral("property \"%1\" not found on \"%2\"") + .arg(QString::fromUtf8(key)).arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + const QVariant v = node->get_input_property(id, QString::fromUtf8(key)); + bool ok = false; + const qlonglong i = v.toLongLong(&ok); + if (!ok) { + set_error(QStringLiteral("property \"%1\" is not an integer") + .arg(QString::fromUtf8(key))); + return OAKENGINE_E_INVALID; + } + *out = int64_t(i); + return OAKENGINE_OK; +} + +int oakengine_node_input_get_property_rational(const OakEngineNode *self, + const char *input_id, + const char *key, int *num, + int *den) +{ + set_error(QString()); + if (!self || !input_id || !key) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->has_input_property(id, QString::fromUtf8(key))) { + set_error(QStringLiteral("property \"%1\" not found on \"%2\"") + .arg(QString::fromUtf8(key)).arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + const QVariant v = node->get_input_property(id, QString::fromUtf8(key)); + // Try rational first; fall back to converting a plain number. + if (v.canConvert()) { + const olive::Rational r = v.value(); + if (num) *num = r.numerator(); + if (den) *den = r.denominator(); + return OAKENGINE_OK; + } + // Fallback: treat as double and return (value, 1). + bool ok = false; + const double d = v.toDouble(&ok); + if (ok) { + if (num) *num = int(d); + if (den) *den = 1; + return OAKENGINE_OK; + } + if (num) *num = 0; + if (den) *den = 1; + return OAKENGINE_OK; +} + +int oakengine_node_input_get_property_count(const OakEngineNode *self, + const char *input_id) +{ + if (!self || !input_id) { + return 0; + } + return impl(self)->get_input_properties(QString::fromUtf8(input_id)).size(); +} + +int oakengine_node_input_get_property_key(const OakEngineNode *self, + const char *input_id, int index, + char *buf, int buf_size) +{ + set_error(QString()); + if (!self || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const QHash props = + impl(self)->get_input_properties(QString::fromUtf8(input_id)); + const QList keys = props.keys(); + if (index < 0 || index >= keys.size()) { + set_error(QStringLiteral("property index %1 out of range").arg(index)); + return OAKENGINE_E_NOT_FOUND; + } + return string_to_buf(keys.at(index), buf, buf_size); +} + +int oakengine_node_input_get_property_string_list_count( + const OakEngineNode *self, const char *input_id, const char *key) +{ + if (!self || !input_id || !key) { + return 0; + } + const QVariant v = impl(self)->get_input_property( + QString::fromUtf8(input_id), QString::fromUtf8(key)); + if (v.typeId() == QMetaType::QStringList) { + return v.toStringList().size(); + } + if (v.typeId() == QMetaType::QString) { + return 1; + } + return 0; +} + +int oakengine_node_input_get_property_string_list( + const OakEngineNode *self, const char *input_id, const char *key, + int index, char *buf, int buf_size) +{ + set_error(QString()); + if (!self || !input_id || !key) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const QVariant v = impl(self)->get_input_property( + QString::fromUtf8(input_id), QString::fromUtf8(key)); + QStringList list; + if (v.typeId() == QMetaType::QStringList) { + list = v.toStringList(); + } else if (v.typeId() == QMetaType::QString) { + list = QStringList(v.toString()); + } else { + set_error(QStringLiteral("property \"%1\" is not a string list") + .arg(QString::fromUtf8(key))); + return OAKENGINE_E_INVALID; + } + if (index < 0 || index >= list.size()) { + set_error(QStringLiteral("property \"%1\" index %2 out of range") + .arg(QString::fromUtf8(key)).arg(index)); + return OAKENGINE_E_NOT_FOUND; + } + return string_to_buf(list.at(index), buf, buf_size); +} + +/* ---- Node type queries ---------------------------------------------------- */ + +int oakengine_node_is_group(const OakEngineNode *self) +{ + if (!self) { + return 0; + } + return dynamic_cast(impl(self)) != nullptr ? 1 : 0; +} + +int oakengine_node_is_multicam(const OakEngineNode *self) +{ + if (!self) { + return 0; + } + return dynamic_cast(impl(self)) != nullptr ? 1 : 0; +} + +/* ---- Context positions ---------------------------------------------------- */ + +int oakengine_node_context_node_count(const OakEngineNode *context) +{ + if (!context) { + return OAKENGINE_E_INVALID; + } + return impl(context)->get_context_positions().size(); +} + +int oakengine_node_context_contains_node(const OakEngineNode *context, + const OakEngineNode *node) +{ + if (!context || !node) { + return OAKENGINE_E_INVALID; + } + return impl(context)->context_contains_node(const_cast(impl(node))) ? + 1 : 0; +} + +OakEngineNode *oakengine_node_context_node_at(OakEngineNode *context, + int index, double *x, + double *y, int *expanded) +{ + if (!context || index < 0) { + return nullptr; + } + const olive::Node::PositionMap &map = impl(context)->get_context_positions(); + if (index >= map.size()) { + return nullptr; + } + auto it = map.constBegin(); + for (int i = 0; i < index; i++) { + ++it; + } + if (x) { + *x = it.value().position.x(); + } + if (y) { + *y = it.value().position.y(); + } + if (expanded) { + *expanded = it.value().expanded ? 1 : 0; + } + return wrap(it.key()); +} + +int oakengine_node_set_context_position(OakEngineNode *context, + OakEngineNode *node, double x, + double y) +{ + set_error(QString()); + if (!context || !node) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + olive::Node *ctx = impl(context); + olive::Node *n = impl(node); + olive::Node::Position pos(QPointF(x, y)); + // A plain move must not reset the expanded flag (matches the C++ + // QPointF overload of Node::set_node_position_in_context). + if (ctx->context_contains_node(n)) { + pos.expanded = ctx->get_node_position_data_in_context(n).expanded; + } + push_or_run(new olive::NodeSetPositionCommand(n, ctx, pos), + QStringLiteral("Set Position")); + return OAKENGINE_OK; +} + +int oakengine_node_get_context_position(const OakEngineNode *context, + const OakEngineNode *node, + double *x, double *y, int *expanded) +{ + set_error(QString()); + if (!context || !node) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Node *ctx = impl(context); + const olive::Node *n = impl(node); + if (!const_cast(ctx)->context_contains_node(const_cast(n))) { + set_error(QStringLiteral("node not found in context")); + return OAKENGINE_E_NOT_FOUND; + } + const olive::Node::Position pos = + const_cast(ctx)->get_node_position_data_in_context( + const_cast(n)); + if (x) { + *x = pos.position.x(); + } + if (y) { + *y = pos.position.y(); + } + if (expanded) { + *expanded = pos.expanded ? 1 : 0; + } + return OAKENGINE_OK; +} + +int oakengine_node_set_context_expanded(OakEngineNode *context, + OakEngineNode *node, int expanded) +{ + set_error(QString()); + if (!context || !node) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + olive::Node *ctx = impl(context); + olive::Node *n = impl(node); + ctx->set_node_expanded_in_context(n, expanded != 0); + return OAKENGINE_OK; +} + +/* ---- Effect input --------------------------------------------------------- */ + +int oakengine_node_get_effect_input(const OakEngineNode *self, + char *input_id, int input_id_size, + int *element) +{ + set_error(QString()); + if (!self) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::NodeInput ei = + const_cast(impl(self))->get_effect_input(); + if (!ei.is_valid()) { + set_error(QStringLiteral("node has no effect input")); + return OAKENGINE_E_NOT_FOUND; + } + const int len = string_to_buf(ei.input(), input_id, input_id_size); + if (element) { + *element = ei.element(); + } + return len; +} + +/* ---- Group passthrough ---------------------------------------------------- */ + + +int oakengine_group_input_passthrough_count(const OakEngineNode *self) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::NodeGroup *g = + dynamic_cast(impl(self)); + if (!g) { + return OAKENGINE_E_INVALID; + } + return g->get_input_passthroughs().size(); +} + +int oakengine_group_add_input_passthrough(OakEngineNode *self, + OakEngineNode *inner_node, + const char *inner_input, + int inner_element, + const char *preferred_id, + char *out_id, int out_id_size) +{ + set_error(QString()); + olive::NodeGroup *g = dynamic_cast(impl(self)); + if (!g || !inner_node || !inner_input) { + set_error(QStringLiteral("invalid arguments or not a group")); + return OAKENGINE_E_INVALID; + } + const QString force_id = preferred_id ? + QString::fromUtf8(preferred_id) : QString(); + const QString result = g->add_input_passthrough( + olive::NodeInput(impl(inner_node), + QString::fromUtf8(inner_input), inner_element), + force_id); + // buf/size convention: string_to_buf returns the id length even when + // out_id is NULL (NULL queries the length), so the return is > 0 for a + // successfully added passthrough either way. + return string_to_buf(result, out_id, out_id_size); +} + +int oakengine_group_input_passthrough_at(const OakEngineNode *self, + int index, char *id, int id_size, + OakEngineNode **node, + char *input_id, int input_id_size, + int *element) +{ + set_error(QString()); + const olive::NodeGroup *g = + dynamic_cast(impl(self)); + if (!g) { + set_error(QStringLiteral("not a group")); + return OAKENGINE_E_INVALID; + } + const olive::NodeGroup::InputPassthroughs &pts = g->get_input_passthroughs(); + if (index < 0 || index >= pts.size()) { + set_error(QStringLiteral("passthrough index %1 out of range").arg(index)); + return OAKENGINE_E_INVALID; + } + const auto &pt = pts.at(index); + int total = string_to_buf(pt.first, id, id_size); + if (node) { + *node = wrap(pt.second.node()); + } + if (input_id) { + total += string_to_buf(pt.second.input(), input_id, input_id_size); + } + if (element) { + *element = pt.second.element(); + } + return total; +} + +int oakengine_group_get_id_of_passthrough(const OakEngineNode *self, + OakEngineNode *inner_node, + const char *inner_input, + int inner_element, char *id, + int id_size) +{ + set_error(QString()); + const olive::NodeGroup *g = + dynamic_cast(impl(self)); + if (!g || !inner_node || !inner_input) { + set_error(QStringLiteral("invalid arguments or not a group")); + return OAKENGINE_E_INVALID; + } + const QString result = g->get_id_of_passthrough( + olive::NodeInput(impl(inner_node), + QString::fromUtf8(inner_input), inner_element)); + if (result.isEmpty()) { + set_error(QStringLiteral("no passthrough for that node/input")); + return OAKENGINE_E_NOT_FOUND; + } + if (id) { + return string_to_buf(result, id, id_size); + } + return 0; +} + +int oakengine_group_get_passthrough_from_id(const OakEngineNode *self, + const char *id, + OakEngineNode **out_node, + char *out_input, + int out_input_size, + int *out_element) +{ + set_error(QString()); + const olive::NodeGroup *g = + dynamic_cast(impl(self)); + if (!g || !id) { + set_error(QStringLiteral("invalid arguments or not a group")); + return OAKENGINE_E_INVALID; + } + const olive::NodeInput input = g->get_input_from_id(QString::fromUtf8(id)); + if (!input.is_valid()) { + set_error(QStringLiteral("no passthrough with id \"%1\"").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + if (out_node) { + *out_node = wrap(input.node()); + } + if (out_input) { + string_to_buf(input.input(), out_input, out_input_size); + } + if (out_element) { + *out_element = input.element(); + } + return OAKENGINE_OK; +} + +OakEngineNode *oakengine_group_get_output_passthrough( + const OakEngineNode *self) +{ + if (!self) { + return nullptr; + } + const olive::NodeGroup *g = + dynamic_cast(impl(self)); + if (!g) { + return nullptr; + } + return wrap(g->get_output_passthrough()); +} + +int oakengine_group_set_output_passthrough(OakEngineNode *self, + OakEngineNode *inner_node) +{ + set_error(QString()); + olive::NodeGroup *g = dynamic_cast(impl(self)); + if (!g) { + set_error(QStringLiteral("not a group")); + return OAKENGINE_E_INVALID; + } + g->set_output_passthrough(impl(inner_node)); + return OAKENGINE_OK; +} + +OakEngineNode *oakengine_node_group_create(void) +{ + return wrap(new olive::NodeGroup()); +} + +int oakengine_node_group_get_inner(OakEngineNode **inout_node, + char *inout_input, + int inout_input_size, + int *inout_element) +{ + if (!inout_node || !*inout_node || !inout_input || + inout_input_size <= 0 || !inout_element) { + return 0; + } + olive::Node *node = impl(*inout_node); + olive::NodeGroup *group = dynamic_cast(node); + if (!group) { + return 0; + } + olive::NodeInput input(node, QString::fromUtf8(inout_input), + *inout_element); + if (!olive::NodeGroup::get_inner(&input)) { + return 0; + } + *inout_node = wrap(input.node()); + string_to_buf(input.input(), inout_input, inout_input_size); + *inout_element = input.element(); + return 1; +} + +int oakengine_group_resolve_input(const OakEngineNode *self, const char *id, + int element, OakEngineNode **out_node, + char *out_input, int out_input_size, + int *out_element) +{ + set_error(QString()); + if (!self || !id) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Node *node = impl(self); + const QString pid = QString::fromUtf8(id); + // For a group, resolve through the group's chain. + if (dynamic_cast(node)) { + const olive::NodeInput resolved = + olive::NodeGroup::resolve_input( + olive::NodeInput(const_cast(node), pid, element)); + if (out_node) { + *out_node = wrap(resolved.node()); + } + if (out_input) { + string_to_buf(resolved.input(), out_input, out_input_size); + } + if (out_element) { + *out_element = resolved.element(); + } + return OAKENGINE_OK; + } + // For a plain node, pass through unchanged. + if (out_node) { + *out_node = const_cast(self); + } + if (out_input) { + string_to_buf(pid, out_input, out_input_size); + } + if (out_element) { + *out_element = element; + } + return OAKENGINE_OK; +} + +int oakengine_group_remove_input_passthrough(OakEngineNode *self, + OakEngineNode *inner_node, + const char *inner_input, + int inner_element) +{ + set_error(QString()); + olive::NodeGroup *g = dynamic_cast(impl(self)); + if (!g || !inner_node || !inner_input) { + set_error(QStringLiteral("invalid arguments or not a group")); + return OAKENGINE_E_INVALID; + } + const olive::NodeInput input(impl(inner_node), + QString::fromUtf8(inner_input), + inner_element); + if (!g->contains_input_passthrough(input)) { + set_error(QStringLiteral("passthrough not found")); + return OAKENGINE_E_NOT_FOUND; + } + g->remove_input_passthrough(input); + return OAKENGINE_OK; +} + +extern "C" void *oakengine_group_add_input_passthrough_command( + OakEngineNode *self, OakEngineNode *inner_node, + const char *inner_input, int inner_element, + const char *preferred_id) +{ + olive::NodeGroup *g = dynamic_cast(impl(self)); + if (!g || !inner_node || !inner_input) { + return nullptr; + } + const QString force_id = preferred_id ? + QString::fromUtf8(preferred_id) : QString(); + return new olive::NodeGroupAddInputPassthrough( + g, olive::NodeInput(impl(inner_node), + QString::fromUtf8(inner_input), + inner_element), + force_id); +} + +extern "C" void *oakengine_group_set_output_passthrough_command( + OakEngineNode *self, OakEngineNode *inner_node) +{ + olive::NodeGroup *g = dynamic_cast(impl(self)); + if (!g || !inner_node) { + return nullptr; + } + return new olive::NodeGroupSetOutputPassthrough(g, impl(inner_node)); +} + +int oakengine_group_add_input_passthrough_undoable( + OakEngineNode *self, OakEngineNode *inner_node, + const char *inner_input, int inner_element, + const char *preferred_id) +{ + set_error(QString()); + void *cmd = oakengine_group_add_input_passthrough_command( + self, inner_node, inner_input, inner_element, preferred_id); + if (!cmd) { + set_error(QStringLiteral("invalid arguments or not a group")); + return OAKENGINE_E_INVALID; + } + push_or_run(static_cast(cmd), + QStringLiteral("Add Input Passthrough")); + return OAKENGINE_OK; +} + +int oakengine_group_set_output_passthrough_undoable( + OakEngineNode *self, OakEngineNode *inner_node) +{ + set_error(QString()); + void *cmd = oakengine_group_set_output_passthrough_command(self, inner_node); + if (!cmd) { + set_error(QStringLiteral("not a group")); + return OAKENGINE_E_INVALID; + } + push_or_run(static_cast(cmd), + QStringLiteral("Set Output Passthrough")); + return OAKENGINE_OK; +} + +/* ---- Multi-camera --------------------------------------------------------- */ + + +const char *oakengine_multicam_input_current(void) +{ + static const char *s = "current_in"; + Q_UNUSED(olive::MultiCamNode::k_current_input); + return s; +} + +const char *oakengine_multicam_input_sources(void) +{ + static const char *s = "sources_in"; + Q_UNUSED(olive::MultiCamNode::k_sources_input); + return s; +} + +const char *oakengine_multicam_input_sequence(void) +{ + static const char *s = "sequence_in"; + Q_UNUSED(olive::MultiCamNode::k_sequence_input); + return s; +} + +const char *oakengine_multicam_input_sequence_type(void) +{ + static const char *s = "sequence_type_in"; + Q_UNUSED(olive::MultiCamNode::k_sequence_type_input); + return s; +} + +int oakengine_multicam_get_source_count(const OakEngineNode *self) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::MultiCamNode *m = + dynamic_cast(impl(self)); + if (!m) { + return OAKENGINE_E_INVALID; + } + return m->get_source_count(); +} + +int oakengine_multicam_get_rows_and_columns(int source_count, int *rows, + int *cols) +{ + if (source_count < 0 || !rows || !cols) { + return OAKENGINE_E_INVALID; + } + olive::MultiCamNode::get_rows_and_columns(source_count, rows, cols); + return OAKENGINE_OK; +} + +int oakengine_multicam_index_to_row_cols(int index, int rows, int cols, + int *out_row, int *out_col) +{ + if (index < 0 || rows < 1 || cols < 1 || !out_row || !out_col) { + return OAKENGINE_E_INVALID; + } + olive::MultiCamNode::index_to_row_cols(index, rows, cols, out_row, + out_col); + return OAKENGINE_OK; +} + +int oakengine_multicam_rows_cols_to_index(int row, int col, int rows, + int cols) +{ + if (row < 0 || col < 0 || rows < 1 || cols < 1 || + row >= rows || col >= cols) { + return OAKENGINE_E_INVALID; + } + return olive::MultiCamNode::rows_cols_to_index(row, col, rows, cols); +} + +int oakengine_multicam_get_current_source(const OakEngineNode *self) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::MultiCamNode *m = + dynamic_cast(impl(self)); + if (!m) { + return OAKENGINE_E_INVALID; + } + return m->get_current_source(); +} + +int oakengine_shape_set_rect_undoable(OakEngineNode *node, double x, double y, + double w, double h, + const oak_video_params *video_params, + void *command) +{ + if (!node || !video_params || !command) { + return OAKENGINE_E_INVALID; + } + olive::ShapeNodeBase *shape = + dynamic_cast(impl(node)); + if (!shape) { + return OAKENGINE_E_INVALID; + } + olive::VideoParams params( + video_params->width, video_params->height, + olive::Rational(video_params->time_base_num, video_params->time_base_den), + olive::PixelFormat::Format(video_params->format), + olive::VideoParams::k_internal_channel_count, + olive::Rational(video_params->pixel_aspect_num, + video_params->pixel_aspect_den), + static_cast(video_params->interlacing), + video_params->divider > 0 ? video_params->divider : 1); + shape->set_rect(QRectF(x, y, w, h), params, + static_cast(command)); + return OAKENGINE_OK; +} + +const char *oakengine_subtitle_text_input_id(void) +{ + static const char *s = "text_in"; + Q_UNUSED(olive::SubtitleBlock::k_text_in); + return s; +} + +int oakengine_subtitle_get_text(const OakEngineNode *node, char *buf, + int buf_size) +{ + if (!node) { + return OAKENGINE_E_INVALID; + } + const olive::SubtitleBlock *sub = + dynamic_cast(impl(node)); + if (!sub) { + return OAKENGINE_E_INVALID; + } + const QByteArray utf8 = sub->get_text().toUtf8(); + const int len = int(utf8.size()); + if (buf && buf_size > 0) { + const int n = qMin(len, buf_size - 1); + if (n > 0) { + std::memcpy(buf, utf8.constData(), size_t(n)); + } + buf[n] = '\0'; + } + return len; +} + +int oakengine_subtitle_set_text(OakEngineNode *node, const char *text) +{ + if (!node) { + return OAKENGINE_E_INVALID; + } + olive::SubtitleBlock *sub = dynamic_cast(impl(node)); + if (!sub) { + return OAKENGINE_E_INVALID; + } + sub->set_text(QString::fromUtf8(text ? text : "")); + return OAKENGINE_OK; +} + +/* ---- Bulk graph deletion -------------------------------------------------- */ + +int oakengine_nodes_delete_many( + OakEngineNode *const *nodes, OakEngineNode *const *contexts, + int node_count, OakEngineNode *const *edge_outputs, + OakEngineNode *const *edge_input_nodes, + const char *const *edge_input_ids, + const int *edge_input_elements, int edge_count) +{ + return oakengine_nodes_delete_many_ex( + nodes, contexts, node_count, edge_outputs, edge_input_nodes, + edge_input_ids, edge_input_elements, edge_count, nullptr, nullptr, + nullptr, nullptr, 0); +} + +int oakengine_nodes_delete_many_ex( + OakEngineNode *const *nodes, OakEngineNode *const *contexts, + int node_count, OakEngineNode *const *edge_outputs, + OakEngineNode *const *edge_input_nodes, + const char *const *edge_input_ids, + const int *edge_input_elements, int edge_count, + OakEngineNode *const *reconnect_outputs, + OakEngineNode *const *reconnect_input_nodes, + const char *const *reconnect_input_ids, + const int *reconnect_input_elements, int reconnect_count) +{ + set_error(QString()); + if (node_count <= 0 && edge_count <= 0) { + set_error(QStringLiteral("nothing to delete")); + return OAKENGINE_E_INVALID; + } + if (node_count > 0 && !nodes) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + auto *command = new olive::MultiUndoCommand(); + auto *dc = new olive::NodeViewDeleteCommand(); + command->add_child(dc); + for (int i = 0; i < node_count; i++) { + if (!nodes[i]) { + set_error(QStringLiteral("null node at index %1").arg(i)); + delete command; + return OAKENGINE_E_INVALID; + } + olive::Node *ctx = nullptr; + if (contexts && contexts[i]) { + ctx = impl(contexts[i]); + } + dc->add_node(impl(nodes[i]), ctx); + } + for (int i = 0; i < edge_count; i++) { + if (!edge_outputs[i] || !edge_input_nodes[i] || !edge_input_ids[i]) { + set_error(QStringLiteral("invalid edge at index %1").arg(i)); + delete command; + return OAKENGINE_E_INVALID; + } + dc->add_edge( + impl(edge_outputs[i]), + olive::NodeInput(impl(edge_input_nodes[i]), + QString::fromUtf8(edge_input_ids[i]), + edge_input_elements ? edge_input_elements[i] : -1)); + } + // Reconnect edges run AFTER the deletion inside the same command, so + // they may target inputs that were occupied by the deleted nodes. + for (int i = 0; i < reconnect_count; i++) { + if (!reconnect_outputs[i] || !reconnect_input_nodes[i] || + !reconnect_input_ids[i]) { + set_error(QStringLiteral("invalid reconnect edge at index %1").arg(i)); + delete command; + return OAKENGINE_E_INVALID; + } + command->add_child(new olive::NodeEdgeAddCommand( + impl(reconnect_outputs[i]), + olive::NodeInput(impl(reconnect_input_nodes[i]), + QString::fromUtf8(reconnect_input_ids[i]), + reconnect_input_elements + ? reconnect_input_elements[i] + : -1))); + } + push_or_run(command, QStringLiteral("Delete Nodes")); + return OAKENGINE_OK; +} + +/* ---- Keyframe best type at time ------------------------------------------- */ + + +int oakengine_node_keyframe_best_type_at_time( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, int default_type) +{ + set_error(QString()); + if (!self || !input_id) { + return default_type; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->inputs().contains(id)) { + return default_type; + } + olive::NodeInputImmediate *imm = + const_cast(node)->get_immediate(id, element); + if (!imm) { + return default_type; + } + const olive::Rational tb = project_time_base(node); + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(time_ts, tb); + const olive::NodeKeyframe::Type best = + imm->get_best_keyframe_type_for_time(time, track); + return from_engine_easing(best); +} + +/* ---- Handle-based keyframe API -------------------------------------------- */ + +// Keyframe handle wrappers. +namespace { + +const olive::NodeKeyframe *impl_kf_const(const OakEngineKeyframe *h) +{ + return reinterpret_cast(h); +} + +olive::NodeKeyframe *impl_kf(OakEngineKeyframe *h) +{ + return reinterpret_cast(h); +} + +OakEngineKeyframe *wrap_kf(olive::NodeKeyframe *k) +{ + return reinterpret_cast(k); +} + +} // namespace + +// Map oak_node_value_type -> olive::NodeValue::Type (reverse of to_c_type). +namespace { +olive::NodeValue::Type from_c_type(int t) +{ + switch (t) { + case OAK_NODE_VALUE_NONE: return olive::NodeValue::k_none; + case OAK_NODE_VALUE_INT: return olive::NodeValue::k_int; + case OAK_NODE_VALUE_FLOAT: return olive::NodeValue::k_float; + case OAK_NODE_VALUE_BOOL: return olive::NodeValue::k_boolean; + case OAK_NODE_VALUE_RATIONAL: return olive::NodeValue::k_rational; + case OAK_NODE_VALUE_COLOR: return olive::NodeValue::k_color; + case OAK_NODE_VALUE_VEC2: return olive::NodeValue::k_vec2; + case OAK_NODE_VALUE_VEC3: return olive::NodeValue::k_vec3; + case OAK_NODE_VALUE_VEC4: return olive::NodeValue::k_vec4; + case OAK_NODE_VALUE_COMBO: return olive::NodeValue::k_combo; + case OAK_NODE_VALUE_STRING: return olive::NodeValue::k_file; + case OAK_NODE_VALUE_TEXT: return olive::NodeValue::k_text; + case OAK_NODE_VALUE_FONT: return olive::NodeValue::k_font; + case OAK_NODE_VALUE_STR_COMBO: return olive::NodeValue::k_str_combo; + case OAK_NODE_VALUE_BINARY: return olive::NodeValue::k_binary; + case OAK_NODE_VALUE_BEZIER: return olive::NodeValue::k_bezier; + case OAK_NODE_VALUE_TEXTURE: return olive::NodeValue::k_texture; + case OAK_NODE_VALUE_SAMPLES: return olive::NodeValue::k_samples; + case OAK_NODE_VALUE_VIDEO_PARAMS: return olive::NodeValue::k_video_params; + case OAK_NODE_VALUE_AUDIO_PARAMS: return olive::NodeValue::k_audio_params; + default: return olive::NodeValue::k_none; + } +} +} // namespace + +int oakengine_node_keyframe_track_count(const OakEngineNode *self, + const char *input_id, int element) +{ + if (!self || !input_id) { + return 0; + } + return impl(self)->get_number_of_keyframe_tracks( + QString::fromUtf8(input_id)); +} + +int oakengine_node_keyframe_count_on_track(const OakEngineNode *self, + const char *input_id, int element, + int track) +{ + if (!self || !input_id) { + return 0; + } + const QVector &tracks = + impl(self)->get_keyframe_tracks(QString::fromUtf8(input_id), element); + if (track < 0 || track >= tracks.size()) { + return 0; + } + return tracks.at(track).size(); +} + +int oakengine_node_keyframes_toggle_at_time(OakEngineNode *self, + const char *input_id, + int element, int64_t time_ts, + int track, int on, + const char *undo_name) +{ + set_error(QString()); + olive::Node *node = impl(self); + const olive::NodeValue::Type declared = + checked_keyframe_input(node, input_id); + if (declared == olive::NodeValue::k_none) { + return self && input_id ? OAKENGINE_E_NOT_FOUND : OAKENGINE_E_INVALID; + } + const QString id = QString::fromUtf8(input_id); + // Facade contract: time_ts is in SECONDS and track is 1-based + // (track=1 addresses the first track). + const olive::Rational time(time_ts); + const int track_index = track - 1; + const QString name = undo_name ? + QString::fromUtf8(undo_name) : QStringLiteral("Toggle Keyframe"); + + if (on) { + // Check if a keyframe already exists; if so, no-op. + olive::NodeKeyframe *existing = + node->get_keyframe_at_time_on_track(id, time, track_index, + element); + if (existing) { + return OAKENGINE_OK; + } + // Create keyframe with current value and best type. + const QVariant cv = node->get_value_at_time(id, time, element); + olive::NodeKeyframe *key = new olive::NodeKeyframe( + time, cv, olive::NodeKeyframe::k_default_type, track_index, + element, id); + olive::MultiUndoCommand *cmd = new olive::MultiUndoCommand(); + if (!node->is_input_keyframing(id, element)) { + cmd->add_child(new olive::NodeParamSetKeyframingCommand( + olive::NodeInput(node, id, element), true)); + } + cmd->add_child(new olive::NodeParamInsertKeyframeCommand(node, key)); + push_or_run(cmd, name); + } else { + // Remove keyframe if it exists. + olive::NodeKeyframe *existing = + node->get_keyframe_at_time_on_track(id, time, track_index, + element); + if (!existing) { + return OAKENGINE_OK; // no-op + } + push_or_run(new olive::NodeParamRemoveKeyframeCommand(existing), name); + } + return OAKENGINE_OK; +} + +int oakengine_node_has_keyframe_at_time(const OakEngineNode *self, + const char *input_id, int element, + int64_t time_ts, int track) +{ + if (!self || !input_id) { + return 0; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + // Facade contract: time_ts is in SECONDS and track is 1-based. + return node->get_keyframe_at_time_on_track(id, olive::Rational(time_ts), + track - 1, element) ? + 1 : 0; +} + +int oakengine_node_keyframe_earliest_time(const OakEngineNode *self, + const char *input_id, int element, + int64_t *num, int64_t *den) +{ + set_error(QString()); + if (!self || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return 0; + } + olive::NodeInputImmediate *imm = + const_cast(impl(self))->get_immediate( + QString::fromUtf8(input_id), element); + if (!imm) { + set_error(QStringLiteral("no keyframe tracks")); + return 0; + } + const olive::NodeKeyframe *earliest = imm->get_earliest_keyframe(); + if (!earliest) { + if (num) *num = 0; + if (den) *den = 1; + return 0; + } + const olive::Rational &time = earliest->time(); + if (num) *num = time.numerator(); + if (den) *den = time.denominator(); + return 1; +} + +int oakengine_node_keyframe_latest_time(const OakEngineNode *self, + const char *input_id, int element, + int64_t *num, int64_t *den) +{ + set_error(QString()); + if (!self || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return 0; + } + olive::NodeInputImmediate *imm = + const_cast(impl(self))->get_immediate( + QString::fromUtf8(input_id), element); + if (!imm) { + set_error(QStringLiteral("no keyframe tracks")); + return 0; + } + const olive::NodeKeyframe *latest = imm->get_latest_keyframe(); + if (!latest) { + if (num) *num = 0; + if (den) *den = 1; + return 0; + } + const olive::Rational &time = latest->time(); + if (num) *num = time.numerator(); + if (den) *den = time.denominator(); + return 1; +} + +int oakengine_node_keyframe_closest_time_before( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, int64_t *num, int64_t *den) +{ + set_error(QString()); + if (!self || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return 0; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + // Facade contract: time_ts is in SECONDS and track is 1-based. + const olive::Rational time(time_ts); + const int track_index = track - 1; + olive::NodeInputImmediate *imm = + const_cast(node)->get_immediate(id, element); + if (!imm) { + return 0; + } + // Walk the track to find the closest keyframe before the given time. + const QVector &tracks = + node->get_keyframe_tracks(id, element); + if (track_index < 0 || track_index >= tracks.size()) { + return 0; + } + const olive::NodeKeyframe *found = nullptr; + for (const olive::NodeKeyframe *key : tracks.at(track_index)) { + if (key->time() < time) { + if (!found || key->time() > found->time()) { + found = key; + } + } + } + if (!found) { + return 0; + } + if (num) *num = found->time().numerator(); + if (den) *den = found->time().denominator(); + return 1; +} + +int oakengine_node_keyframe_closest_time_after( + const OakEngineNode *self, const char *input_id, int element, + int64_t time_ts, int track, int64_t *num, int64_t *den) +{ + set_error(QString()); + if (!self || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return 0; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + // Facade contract: time_ts is in SECONDS and track is 1-based. + const olive::Rational time(time_ts); + const int track_index = track - 1; + const QVector &tracks = + node->get_keyframe_tracks(id, element); + if (track_index < 0 || track_index >= tracks.size()) { + return 0; + } + const olive::NodeKeyframe *found = nullptr; + for (const olive::NodeKeyframe *key : tracks.at(track_index)) { + if (key->time() > time) { + if (!found || key->time() < found->time()) { + found = key; + } + } + } + if (!found) { + return 0; + } + if (num) *num = found->time().numerator(); + if (den) *den = found->time().denominator(); + return 1; +} + +OakEngineKeyframe *oakengine_node_keyframe_handle_on_track( + const OakEngineNode *self, const char *input_id, int element, + int track, int index) +{ + if (!self || !input_id) { + return nullptr; + } + const QVector &tracks = + impl(self)->get_keyframe_tracks(QString::fromUtf8(input_id), element); + if (track < 0 || track >= tracks.size()) { + return nullptr; + } + const olive::NodeKeyframeTrack &tr = tracks.at(track); + if (index < 0 || index >= tr.size()) { + return nullptr; + } + return wrap_kf(tr.at(index)); +} + +OakEngineKeyframe *oakengine_node_keyframe_handle_at_time( + const OakEngineNode *self, const char *input_id, int element, + int track, int64_t time_ts, int track_for_time) +{ + if (!self || !input_id) { + return nullptr; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + // Facade contract: time_ts is in SECONDS and track_for_time is 1-based. + olive::NodeKeyframe *key = node->get_keyframe_at_time_on_track( + id, olive::Rational(time_ts), track_for_time - 1, element); + return wrap_kf(key); +} + +int oakengine_node_keyframes_at_time(const OakEngineNode *self, + const char *input_id, int element, + int64_t time_ts, int track, + OakEngineKeyframe **out_handles, + int max_handles) +{ + if (!self || !input_id || !out_handles || max_handles <= 0) { + return 0; + } + const olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + // Facade contract: time_ts is in SECONDS. + const olive::Rational time(time_ts); + olive::NodeInputImmediate *imm = + const_cast(node)->get_immediate(id, element); + if (!imm) { + return 0; + } + const QVector at_time = + imm->get_keyframe_at_time(time); + const int n = qMin(at_time.size(), max_handles); + for (int i = 0; i < n; i++) { + out_handles[i] = wrap_kf(at_time[i]); + } + return n; +} + +int oakengine_node_set_input_keyframing(OakEngineNode *self, + const char *input_id, int element, + int keyframing, int track, + int enable_all_tracks, + const char *undo_name) +{ + set_error(QString()); + (void)track; + (void)enable_all_tracks; + olive::Node *node = impl(self); + const olive::NodeValue::Type declared = + checked_keyframe_input(node, input_id); + if (declared == olive::NodeValue::k_none) { + return self && input_id ? OAKENGINE_E_NOT_FOUND : OAKENGINE_E_INVALID; + } + const QString id = QString::fromUtf8(input_id); + const bool already = node->is_input_keyframing(id, element); + const QString name = undo_name ? + QString::fromUtf8(undo_name) : QStringLiteral("Set Keyframing"); + + if (keyframing && already) { + // Already enabled: redundant enable is a no-op success. + return OAKENGINE_OK; + } + + // Facade contract (see oakengine/node.h): enabling keyframing also seeds + // one default-type keyframe per track (at t=0 with the current split + // standard value); disabling removes every keyframe on every track. + // Both are ONE undoable command. + auto *command = new olive::MultiUndoCommand(); + const olive::NodeInput input(node, id, element); + if (keyframing) { + command->add_child( + new olive::NodeParamSetKeyframingCommand(input, true)); + const int tracks = + olive::NodeValue::get_number_of_keyframe_tracks(declared); + const olive::SplitValue values = + node->get_split_standard_value(id, element); + for (int t = 0; t < tracks; t++) { + const QVariant v = t < values.size() ? values.at(t) : QVariant(); + command->add_child(new olive::NodeParamInsertKeyframeCommand( + node, new olive::NodeKeyframe( + olive::Rational(0), v, + olive::NodeKeyframe::k_default_type, t, element, + id))); + } + } else { + const QVector &tracks = + node->get_keyframe_tracks(id, element); + for (const olive::NodeKeyframeTrack &tr : tracks) { + for (olive::NodeKeyframe *key : tr) { + command->add_child( + new olive::NodeParamRemoveKeyframeCommand(key)); + } + } + command->add_child( + new olive::NodeParamSetKeyframingCommand(input, false)); + } + push_or_run(command, name); + return OAKENGINE_OK; +} + +extern "C" void *oakengine_node_set_input_keyframing_command( + OakEngineNode *self, const char *input_id, int element, int keyframing) +{ + if (!self || !input_id) { + return nullptr; + } + olive::Node *node = impl(self); + if (!node->inputs().contains(QString::fromUtf8(input_id))) { + return nullptr; + } + return new olive::NodeParamSetKeyframingCommand( + olive::NodeInput(node, QString::fromUtf8(input_id), element), + keyframing != 0); +} + +int oakengine_node_keyframes_paste(OakEngineNode *self, + OakEngineKeyframe *const *keyframes, + int count, const char *undo_name) +{ + set_error(QString()); + if (!self || !keyframes || count <= 0) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + olive::Node *node = impl(self); + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + const QString name = undo_name ? + QString::fromUtf8(undo_name) : QStringLiteral("Paste Keyframes"); + for (int i = 0; i < count; i++) { + if (!keyframes[i]) { + set_error(QStringLiteral("null keyframe at index %1").arg(i)); + delete command; + return OAKENGINE_E_INVALID; + } + olive::NodeKeyframe *src = impl_kf(keyframes[i]); + auto *clone = new olive::NodeKeyframe(src->time(), src->value(), + src->type(), src->track(), + src->element(), src->input()); + clone->set_bezier_control_in(src->bezier_control_in()); + clone->set_bezier_control_out(src->bezier_control_out()); + if (!node->is_input_keyframing(src->input(), src->element())) { + command->add_child(new olive::NodeParamSetKeyframingCommand( + olive::NodeInput(node, src->input(), src->element()), true)); + } + command->add_child( + new olive::NodeParamInsertKeyframeCommand(node, clone)); + } + push_or_run(command, name); + return OAKENGINE_OK; +} + +/* ---- OakEngineKeyframe accessors ------------------------------------------ */ + +int oakengine_keyframe_get_time(const OakEngineKeyframe *self, int64_t *num, + int64_t *den) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::Rational &time = impl_kf_const(self)->time(); + if (num) { + *num = time.numerator(); + } + if (den) { + *den = time.denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_keyframe_get_input_id(const OakEngineKeyframe *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl_kf_const(self)->input(), buf, buf_size); +} + +int oakengine_keyframe_get_track(const OakEngineKeyframe *self) +{ + if (!self) { + return -1; + } + return impl_kf_const(self)->track(); +} + +int oakengine_keyframe_get_element(const OakEngineKeyframe *self) +{ + if (!self) { + return -1; + } + return impl_kf_const(self)->element(); +} + +OakEngineNode *oakengine_keyframe_get_node(const OakEngineKeyframe *self) +{ + if (!self) { + return nullptr; + } + return wrap(impl_kf_const(self)->parent()); +} + +int oakengine_keyframe_get_type(const OakEngineKeyframe *self) +{ + if (!self) { + return -1; + } + return from_engine_easing(impl_kf_const(self)->type()); +} + +int oakengine_keyframe_default_type(void) +{ + return from_engine_easing(olive::NodeKeyframe::k_default_type); +} + +int oakengine_keyframe_opposing_bezier_type(int type) +{ + return int(olive::NodeKeyframe::get_opposing_bezier_type( + olive::NodeKeyframe::BezierType(type))); +} + +int oakengine_keyframe_get_value(const OakEngineKeyframe *self, + oak_node_value *out) +{ + set_error(QString()); + if (!self || !out) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::NodeKeyframe *key = impl_kf_const(self); + // Determine the type from the key's input on the parent node. + const olive::Node *parent = key->parent(); + const QString &input_id = key->input(); + olive::NodeValue::Type type = olive::NodeValue::k_float; + if (parent && parent->inputs().contains(input_id)) { + type = parent->get_input_data_type(input_id); + } + return kf_value_to_c(type, key->value(), out) ? + OAKENGINE_OK : OAKENGINE_E_INVALID; +} + +int oakengine_keyframe_has_sibling_at_time(const OakEngineKeyframe *self, + int64_t time_ts, int track) +{ + if (!self) { + return 0; + } + const olive::NodeKeyframe *key = impl_kf_const(self); + const olive::Rational time(track, 1); // fallback + return key->has_sibling_at_time(olive::Rational(time_ts, 1)) ? 1 : 0; +} + +int oakengine_keyframe_set_bezier_point_live(OakEngineKeyframe *self, + int point_index, double x, + double y) +{ + set_error(QString()); + if (!self || point_index < 0 || point_index > 1) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + olive::NodeKeyframe *key = impl_kf(self); + const olive::NodeKeyframe::BezierType mode = + (point_index == 0) ? olive::NodeKeyframe::k_in_handle : + olive::NodeKeyframe::k_out_handle; + key->set_bezier_control(mode, QPointF(x, y)); + return OAKENGINE_OK; +} + +int oakengine_keyframe_get_bezier_point(const OakEngineKeyframe *self, + int point_index, double *x, + double *y) +{ + set_error(QString()); + if (!self || !x || !y || point_index < 0 || point_index > 1) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::NodeKeyframe *key = impl_kf_const(self); + const olive::NodeKeyframe::BezierType mode = + (point_index == 0) ? olive::NodeKeyframe::k_in_handle : + olive::NodeKeyframe::k_out_handle; + const QPointF p = key->bezier_control(mode); + *x = p.x(); + *y = p.y(); + return OAKENGINE_OK; +} + +int oakengine_keyframe_get_valid_bezier_point(const OakEngineKeyframe *self, + int point_index, double *x, + double *y) +{ + set_error(QString()); + if (!self || !x || !y || point_index < 0 || point_index > 1) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::NodeKeyframe *key = impl_kf_const(self); + const QPointF p = (point_index == 0) ? + key->valid_bezier_control_in() : + key->valid_bezier_control_out(); + *x = p.x(); + *y = p.y(); + return OAKENGINE_OK; +} + +int oakengine_keyframe_set_value_live(OakEngineKeyframe *self, + const oak_node_value *value) +{ + set_error(QString()); + if (!self || !value) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + olive::NodeKeyframe *key = impl_kf(self); + // Map the POD to a QVariant. Use key's parent node to determine type. + const olive::Node *parent = key->parent(); + const QString &input_id = key->input(); + olive::NodeValue::Type type = olive::NodeValue::k_float; + if (parent && parent->inputs().contains(input_id)) { + type = parent->get_input_data_type(input_id); + } + QVariant qv; + if (!component_from_c(value, type, 0, &qv)) { + set_error(QStringLiteral("value type mismatch")); + return OAKENGINE_E_INVALID; + } + key->set_value(qv); + return OAKENGINE_OK; +} + +int oakengine_keyframe_set_time_live(OakEngineKeyframe *self, int64_t num, + int64_t den) +{ + set_error(QString()); + if (!self) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + impl_kf(self)->set_time(olive::Rational(int(num), int(den))); + return OAKENGINE_OK; +} + +int oakengine_keyframes_remove_many(OakEngineKeyframe *const *keyframes, + int count, const char *undo_name) +{ + set_error(QString()); + if (!keyframes || count <= 0) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + // Check for NULL entries first. + for (int i = 0; i < count; i++) { + if (!keyframes[i]) { + set_error(QStringLiteral("null keyframe at index %1").arg(i)); + return OAKENGINE_E_INVALID; + } + } + const QString name = undo_name ? + QString::fromUtf8(undo_name) : QStringLiteral("Remove Keyframes"); + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + for (int i = 0; i < count; i++) { + command->add_child( + new olive::NodeParamRemoveKeyframeCommand(impl_kf(keyframes[i]))); + } + push_or_run(command, name); + return OAKENGINE_OK; +} + +OakEngineKeyframe *oakengine_keyframe_create( + OakEngineNode *node, const char *input_id, int element, int track, + int64_t time_ts, int type, const oak_node_value *value, + int64_t duration_ts) +{ + set_error(QString()); + (void)duration_ts; + if (!node || !input_id || !value) { + set_error(QStringLiteral("invalid arguments")); + return nullptr; + } + olive::Node *n = impl(node); + const QString id = QString::fromUtf8(input_id); + if (!n->inputs().contains(id)) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return nullptr; + } + const olive::Rational tb = project_time_base(impl(node)); + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(time_ts, tb); + const olive::NodeValue::Type declared = n->get_input_data_type(id); + QVariant engine_value; + if (!component_from_c(value, declared, 0, &engine_value)) { + set_error(QStringLiteral("value type mismatch for \"%1\"").arg(id)); + return nullptr; + } + auto *key = new olive::NodeKeyframe(time, engine_value, + to_engine_easing(type), + track, element, id); + return wrap_kf(key); +} + +void oakengine_keyframe_dispose(OakEngineKeyframe *keyframe) +{ + if (!keyframe) { + return; + } + delete impl_kf(keyframe); +} + +/* ---- Input dragger -------------------------------------------------------- */ + +struct OakEngineNodeDraggerImpl { + olive::Node *node; + QString input_id; + int element; + int track; + int64_t time_ts; + bool started; + int keys_before; +}; + +OakEngineNodeDragger *oakengine_dragger_create(OakEngineNode *node, + const char *input_id, + int element, int track) +{ + set_error(QString()); + if (!node || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return nullptr; + } + olive::Node *n = impl(node); + const QString id = QString::fromUtf8(input_id); + if (!n->inputs().contains(id)) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return nullptr; + } + auto *d = new OakEngineNodeDraggerImpl(); + d->node = n; + d->input_id = id; + d->element = element; + d->track = track; + d->time_ts = 0; + d->started = false; + d->keys_before = 0; + return reinterpret_cast(d); +} + +int oakengine_dragger_start(OakEngineNodeDragger *self, int64_t time_ts, + int track, int insert_on_all_tracks) +{ + set_error(QString()); + if (!self) { + set_error(QStringLiteral("invalid dragger")); + return OAKENGINE_E_INVALID; + } + auto *d = reinterpret_cast(self); + if (d->started) { + set_error(QStringLiteral("dragger already started")); + return OAKENGINE_E_STATE; + } + d->time_ts = time_ts; + d->track = track; + d->keys_before = olive::NodeInput(d->node, d->input_id, d->element) + .get_array_size(); + // Create a keyframe at the drag time by calling set_value_at_time. + // We need a temporary value; the dragger will live-set it. + // Facade contract: time_ts is a frame timestamp and track is 1-based. + const olive::Rational tb = project_time_base(d->node); + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(d->time_ts, tb); + const QVariant cv = d->node->get_value_at_time(d->input_id, time, + d->element); + olive::MultiUndoCommand *temp_cmd = new olive::MultiUndoCommand(); + olive::Node::set_value_at_time( + olive::NodeInput(d->node, d->input_id, d->element), + time, cv, track - 1, temp_cmd, insert_on_all_tracks != 0); + // Execute the command immediately (we'll push the final command at end). + temp_cmd->redo_now(); + delete temp_cmd; + d->started = true; + return OAKENGINE_OK; +} + +int oakengine_dragger_drag(OakEngineNodeDragger *self, + const oak_node_value *value) +{ + set_error(QString()); + if (!self || !value) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + auto *d = reinterpret_cast(self); + if (!d->started) { + set_error(QStringLiteral("dragger not started")); + return OAKENGINE_E_STATE; + } + // Facade contract: the stored time_ts is a frame timestamp and track is + // 1-based. + const olive::Rational tb = project_time_base(d->node); + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(d->time_ts, tb); + const olive::NodeValue::Type declared = + d->node->get_input_data_type(d->input_id); + QVariant qv; + if (!component_from_c(value, declared, 0, &qv)) { + set_error(QStringLiteral("value type mismatch")); + return OAKENGINE_E_INVALID; + } + // Live-set the keyframe value at the drag time. + olive::NodeKeyframe *key = d->node->get_keyframe_at_time_on_track( + d->input_id, time, d->track - 1, d->element); + if (key) { + key->set_value(qv); + } + return OAKENGINE_OK; +} + +int oakengine_dragger_end(OakEngineNodeDragger *self, const char *undo_name) +{ + set_error(QString()); + if (!self) { + set_error(QStringLiteral("invalid dragger")); + return OAKENGINE_E_INVALID; + } + auto *d = reinterpret_cast(self); + if (!d->started) { + set_error(QStringLiteral("dragger not started")); + return OAKENGINE_E_STATE; + } + // Push the single undo command that captures the entire drag. + // Facade contract: the stored time_ts is a frame timestamp and track is + // 1-based. + const olive::Rational tb = project_time_base(d->node); + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(d->time_ts, tb); + // Capture the dragged (final) value before touching the live key. + const QVariant final_value = d->node->get_value_at_time( + d->input_id, time, d->element); + olive::NodeKeyframe *key = d->node->get_keyframe_at_time_on_track( + d->input_id, time, d->track - 1, d->element); + if (key) { + // The start-created key lives on the node but NOT on the undo stack + // (dragger_start executed it with redo_now). Remove it live and push + // ONE undoable command that recreates it holding the final value, so + // the whole drag is a single undo entry: undo removes the key + // entirely (restoring the pre-drag keyframe count), redo re-creates + // it with the final value. + delete key; + const QString name = undo_name ? + QString::fromUtf8(undo_name) : QStringLiteral("Drag Input"); + olive::MultiUndoCommand *cmd = new olive::MultiUndoCommand(); + olive::Node::set_value_at_time( + olive::NodeInput(d->node, d->input_id, d->element), + time, final_value, d->track - 1, cmd, false); + push_or_run(cmd, name); + } + d->started = false; + return OAKENGINE_OK; +} + +int oakengine_dragger_is_started(const OakEngineNodeDragger *self) +{ + if (!self) { + return 0; + } + return reinterpret_cast(self)->started ? + 1 : 0; +} + +void oakengine_dragger_free(OakEngineNodeDragger *self) +{ + if (!self) { + return; + } + delete reinterpret_cast(self); +} + +int oakengine_node_set_value_hint(OakEngineNode *self, const char *input_id, + int element, int type, int index, + const char *tag) +{ + set_error(QString()); + if (!self || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + olive::Node *node = impl(self); + const QString id = QString::fromUtf8(input_id); + if (!node->inputs().contains(id)) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + // Validate type: must be a valid oak_node_value_type or -1. + olive::NodeValue::Type nv_type = olive::NodeValue::k_none; + if (type >= 0) { + nv_type = from_c_type(type); + if (nv_type == olive::NodeValue::k_none && type != 0) { + set_error(QStringLiteral("invalid value type %1").arg(type)); + return OAKENGINE_E_INVALID; + } + } + // An empty type list means "no preference" and falls back to the input's + // declared type (NodeTraverser::generate_row_value_element_index); + // OAK_NODE_VALUE_NONE (0) and -1 both leave the list empty. + QVector types; + if (nv_type != olive::NodeValue::k_none) { + types.append(nv_type); + } + olive::Node::ValueHint hint(types, index, + QString::fromUtf8(tag ? tag : "")); + node->set_value_hint_for_input(id, hint, element); + return OAKENGINE_OK; +} + +/* ---- Node static data and helpers ----------------------------------------- */ + +extern "C" const char *oakengine_node_enabled_input_id(void) +{ + return olive::Node::k_enabled_input.toUtf8().constData(); +} + +extern "C" const char *oakengine_volume_samples_input_id(void) +{ + return olive::VolumeNode::k_samples_input.toUtf8().constData(); +} + +extern "C" const char *oakengine_transform_texture_input_id(void) +{ + return olive::TransformDistortNode::k_texture_input.toUtf8().constData(); +} + +extern "C" const char *oakengine_transition_in_block_input_id(void) +{ + return olive::TransitionBlock::k_in_block_input.toUtf8().constData(); +} + +extern "C" const char *oakengine_transition_out_block_input_id(void) +{ + return olive::TransitionBlock::k_out_block_input.toUtf8().constData(); +} + +extern "C" double oakengine_audio_waveform_max_sample_rate(void) +{ + return olive::AudioVisualWaveform::k_maximum_sample_rate.to_double(); +} + +extern "C" int oakengine_node_category_name(int category_id, char *buf, + int buf_size) +{ + QString name = olive::Node::get_category_name( + olive::Node::CategoryID(category_id)); + QByteArray utf8 = name.toUtf8(); + return string_to_buf(name, buf, buf_size); +} + +extern "C" void *oakengine_node_link_command(OakEngineNode *a, + OakEngineNode *b, int link) +{ + auto *na = reinterpret_cast(a); + auto *nb = reinterpret_cast(b); + if (!na || !nb) { + return nullptr; + } + return new olive::NodeLinkCommand(na, nb, link != 0); +} + +extern "C" OakEngineNode *oakengine_node_copy_in_graph( + OakEngineNode *node, void *command) +{ + auto *n = reinterpret_cast(node); + if (!n) { + return nullptr; + } + olive::MultiUndoCommand *cmd = command + ? static_cast(command) : nullptr; + olive::Node *copy = olive::Node::copy_node_in_graph(n, cmd); + if (!cmd && copy) { + // If no parent command, the copy was made directly. + } + return reinterpret_cast(copy); +} + +extern "C" int oakengine_node_copy_dependency_graph( + OakEngineNode *const *nodes, OakEngineNode *const *copies, int count, + void *command) +{ + if (!nodes || !copies || count <= 0) { + return OAKENGINE_E_INVALID; + } + QList node_list; + QList copy_list; + for (int i = 0; i < count; i++) { + node_list.append(reinterpret_cast(nodes[i])); + copy_list.append(reinterpret_cast(copies[i])); + } + olive::MultiUndoCommand *cmd = command + ? static_cast(command) : nullptr; + olive::Node::copy_dependency_graph(node_list, copy_list, cmd); + return OAKENGINE_OK; +} + +extern "C" int oakengine_node_connect_command_string( + OakEngineNode *output, OakEngineNode *input_node, + const char *input_id, int element, char *buf, int buf_size) +{ + auto *out = reinterpret_cast(output); + auto *in = reinterpret_cast(input_node); + if (!out || !in || !input_id) { + return OAKENGINE_E_INVALID; + } + olive::NodeInput ni(in, QString::fromUtf8(input_id), element); + QString name = olive::Node::get_connect_command_string(out, ni); + QByteArray utf8 = name.toUtf8(); + return string_to_buf(name, buf, buf_size); +} + +extern "C" int oakengine_node_transform_time_to( + OakEngineNode *from, OakEngineNode *to, int direction, + int path_index, int64_t in_num, int64_t in_den, + int64_t out_num, int64_t out_den, + int64_t *result_in_num, int64_t *result_in_den, + int64_t *result_out_num, int64_t *result_out_den) +{ + auto *f = reinterpret_cast(from); + auto *t = reinterpret_cast(to); + if (!f || !t) { + return OAKENGINE_E_INVALID; + } + olive::TimeRange range( + olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den)); + olive::TimeRange result = f->transform_time_to( + range, t, + static_cast(direction), + path_index); + if (result_in_num) *result_in_num = result.in().numerator(); + if (result_in_den) *result_in_den = result.in().denominator(); + if (result_out_num) *result_out_num = result.out().numerator(); + if (result_out_den) *result_out_den = result.out().denominator(); + return OAKENGINE_OK; +} + +/* ---- P1.1: NodeValue static methods (F class: 4 symbols) ---------------- */ + +int oakengine_node_value_keyframe_track_count(int c_type) +{ + olive::NodeValue::Type type = from_c_type(c_type); + return olive::NodeValue::get_number_of_keyframe_tracks(type); +} + +int oakengine_node_value_pretty_type_name(int c_type, char *buf, int buf_size) +{ + if (c_type <= OAK_NODE_VALUE_NONE || + c_type > OAK_NODE_VALUE_AUDIO_PARAMS) { + return -1; + } + olive::NodeValue::Type type = from_c_type(c_type); + QString name = olive::NodeValue::get_pretty_data_type_name(type); + return string_to_buf(name, buf, buf_size); +} + +int oakengine_node_value_split_to_tracks(int c_type, + const oak_node_value *normal, oak_node_value *tracks_out, int track_count) +{ + if (!normal || !tracks_out || track_count <= 0) { + return OAKENGINE_E_INVALID; + } + olive::NodeValue::Type type = from_c_type(c_type); + QVariant v; + switch (type) { + case olive::NodeValue::k_int: + case olive::NodeValue::k_combo: + v = QVariant(static_cast(normal->num)); + break; + case olive::NodeValue::k_float: + v = normal->f[0]; + break; + case olive::NodeValue::k_boolean: + v = normal->num != 0; + break; + case olive::NodeValue::k_rational: + v = QVariant::fromValue(olive::Rational(normal->num, normal->den)); + break; + case olive::NodeValue::k_color: + v = QVariant::fromValue(olive::core::Color( + normal->f[0], normal->f[1], normal->f[2], normal->f[3])); + break; + case olive::NodeValue::k_vec2: + v = QVariant::fromValue(QVector2D(normal->f[0], normal->f[1])); + break; + case olive::NodeValue::k_vec3: + v = QVariant::fromValue(QVector3D(normal->f[0], normal->f[1], normal->f[2])); + break; + case olive::NodeValue::k_vec4: + v = QVariant::fromValue(QVector4D(normal->f[0], normal->f[1], normal->f[2], normal->f[3])); + break; + case olive::NodeValue::k_bezier: + v = QVariant::fromValue( + Bezier(normal->f[0], normal->f[1], normal->f[2], normal->f[3], normal->den, normal->num)); + break; + default: + return OAKENGINE_E_INVALID; + } + QVector split = olive::NodeValue::split_normal_value_into_track_values(type, v); + int n = qMin(split.size(), track_count); + for (int i = 0; i < n; ++i) { + tracks_out[i].f[0] = 0; + tracks_out[i].num = 0; + tracks_out[i].den = 0; + switch (type) { + case olive::NodeValue::k_int: + tracks_out[i].type = OAK_NODE_VALUE_INT; + tracks_out[i].num = split[i].toLongLong(); + break; + case olive::NodeValue::k_combo: + tracks_out[i].type = OAK_NODE_VALUE_COMBO; + tracks_out[i].num = split[i].toLongLong(); + break; + case olive::NodeValue::k_boolean: + tracks_out[i].type = OAK_NODE_VALUE_BOOL; + tracks_out[i].num = split[i].toBool() ? 1 : 0; + break; + case olive::NodeValue::k_rational: { + olive::Rational r = split[i].value(); + tracks_out[i].type = OAK_NODE_VALUE_RATIONAL; + tracks_out[i].num = r.numerator(); + tracks_out[i].den = r.denominator(); + break; + } + default: + tracks_out[i].type = OAK_NODE_VALUE_FLOAT; + tracks_out[i].f[0] = split[i].toDouble(); + break; + } + } + return OAKENGINE_OK; +} + +int oakengine_node_value_combine_tracks(int c_type, + const oak_node_value *tracks, int track_count, oak_node_value *normal_out) +{ + if (!tracks || !normal_out || track_count <= 0) { + return OAKENGINE_E_INVALID; + } + olive::NodeValue::Type type = from_c_type(c_type); + QVector split; + split.reserve(track_count); + for (int i = 0; i < track_count; ++i) { + switch (type) { + case olive::NodeValue::k_int: + case olive::NodeValue::k_combo: + split.append(QVariant(static_cast(tracks[i].num))); + break; + case olive::NodeValue::k_boolean: + split.append(tracks[i].num != 0); + break; + case olive::NodeValue::k_rational: + split.append(QVariant::fromValue( + olive::Rational(tracks[i].num, tracks[i].den))); + break; + default: + split.append(tracks[i].f[0]); + break; + } + } + QVariant combined = olive::NodeValue::combine_track_values_into_normal_value(type, split); + if (!normal_out) { + return OAKENGINE_OK; + } + switch (type) { + case olive::NodeValue::k_int: + case olive::NodeValue::k_combo: + normal_out->type = OAK_NODE_VALUE_INT; + normal_out->num = combined.toLongLong(); + normal_out->f[0] = 0; + break; + case olive::NodeValue::k_float: + normal_out->type = OAK_NODE_VALUE_FLOAT; + normal_out->f[0] = combined.toFloat(); + normal_out->num = 0; + break; + case olive::NodeValue::k_boolean: + normal_out->type = OAK_NODE_VALUE_BOOL; + normal_out->num = combined.toBool() ? 1 : 0; + normal_out->f[0] = 0; + break; + case olive::NodeValue::k_rational: { + olive::Rational r = combined.value(); + normal_out->type = OAK_NODE_VALUE_RATIONAL; + normal_out->num = r.numerator(); + normal_out->den = r.denominator(); + break; + } + case olive::NodeValue::k_color: { + olive::core::Color c = combined.value(); + normal_out->type = OAK_NODE_VALUE_COLOR; + normal_out->f[0] = c.red(); + normal_out->f[1] = c.green(); + normal_out->f[2] = c.blue(); + normal_out->f[3] = c.alpha(); + break; + } + case olive::NodeValue::k_vec2: { + QVector2D v = combined.value(); + normal_out->type = OAK_NODE_VALUE_VEC2; + normal_out->f[0] = v.x(); + normal_out->f[1] = v.y(); + break; + } + case olive::NodeValue::k_vec3: { + QVector3D v = combined.value(); + normal_out->type = OAK_NODE_VALUE_VEC3; + normal_out->f[0] = v.x(); + normal_out->f[1] = v.y(); + normal_out->f[2] = v.z(); + break; + } + case olive::NodeValue::k_vec4: { + QVector4D v = combined.value(); + normal_out->type = OAK_NODE_VALUE_VEC4; + normal_out->f[0] = v.x(); + normal_out->f[1] = v.y(); + normal_out->f[2] = v.z(); + normal_out->f[3] = v.w(); + break; + } + case olive::NodeValue::k_bezier: { + Bezier b = combined.value(); + normal_out->type = OAK_NODE_VALUE_BEZIER; + normal_out->f[0] = b.x(); + normal_out->f[1] = b.y(); + normal_out->f[2] = b.cp1_x(); + normal_out->f[3] = b.cp1_y(); + break; + } + default: + normal_out->type = OAK_NODE_VALUE_NONE; + normal_out->num = 0; + normal_out->den = 0; + normal_out->f[0] = 0; + break; + } + return OAKENGINE_OK; +} + } // extern "C" diff --git a/engine/src/capi/plugin.cpp b/engine/src/capi/plugin.cpp new file mode 100644 index 000000000..8882855ae --- /dev/null +++ b/engine/src/capi/plugin.cpp @@ -0,0 +1,152 @@ +/*** + + 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/plugin.h" + +#include "coreengine.h" +#include "node/node.h" +#include "node/output/viewer/viewer.h" +#include "pluginSupport/olivehost.h" +#include "pluginSupport/oliveplugininstance.h" +#include "pluginSupport/pluginprogressreporter.h" + +extern "C" +{ + +static oakengine_plugin_active_viewer_fn g_active_viewer_fn = nullptr; +static void *g_active_viewer_userdata = nullptr; + +static oakengine_plugin_reporter_create_fn g_reporter_create = nullptr; +static oakengine_plugin_reporter_destroy_fn g_reporter_destroy = nullptr; +static oakengine_plugin_reporter_is_cancelled_fn g_reporter_is_cancelled = nullptr; +static oakengine_plugin_reporter_set_progress_fn g_reporter_set_progress = nullptr; +static void *g_reporter_userdata = nullptr; + +int oakengine_plugin_set_active_viewer_provider( + oakengine_plugin_active_viewer_fn fn, void *userdata) +{ + g_active_viewer_fn = fn; + g_active_viewer_userdata = userdata; + + // Update the engine's viewer provider lambda. + olive::plugin::set_active_viewer_provider( + []() -> olive::ViewerOutput * { + if (!g_active_viewer_fn) { + return nullptr; + } + return reinterpret_cast( + g_active_viewer_fn(g_active_viewer_userdata)); + }); + return OAKENGINE_OK; +} + +int oakengine_plugin_set_progress_reporter_factory( + oakengine_plugin_reporter_create_fn create, + oakengine_plugin_reporter_destroy_fn destroy, + oakengine_plugin_reporter_is_cancelled_fn is_cancelled, + oakengine_plugin_reporter_set_progress_fn set_progress, + void *userdata) +{ + g_reporter_create = create; + g_reporter_destroy = destroy; + g_reporter_is_cancelled = is_cancelled; + g_reporter_set_progress = set_progress; + g_reporter_userdata = userdata; + + // Register factory with the engine. + olive::plugin::set_plugin_progress_reporter_factory( + [](const QString &message, const QString &title) + -> olive::plugin::PluginProgressReporter * { + if (!g_reporter_create) { + return nullptr; + } + void *reporter = g_reporter_create( + message.toUtf8().constData(), + title.toUtf8().constData(), + g_reporter_userdata); + if (!reporter) { + return nullptr; + } + // Create an adapter that wraps the C callbacks. + class CAdapter : public olive::plugin::PluginProgressReporter { + public: + CAdapter(void *reporter, + oakengine_plugin_reporter_destroy_fn destroy, + oakengine_plugin_reporter_is_cancelled_fn is_cancelled, + oakengine_plugin_reporter_set_progress_fn set_progress, + void *userdata) + : PluginProgressReporter() + , reporter_(reporter) + , destroy_(destroy) + , set_progress_(set_progress) + , userdata_(userdata) {} + ~CAdapter() override + { + if (destroy_) { + destroy_(reporter_, userdata_); + } + } + void set_progress(double value) override + { + if (set_progress_) { + set_progress_(reporter_, value, userdata_); + } + } + void show() override {} + void close() override {} + private: + void *reporter_; + oakengine_plugin_reporter_destroy_fn destroy_; + oakengine_plugin_reporter_set_progress_fn set_progress_; + void *userdata_; + }; + return new CAdapter(reporter, g_reporter_destroy, + g_reporter_is_cancelled, + g_reporter_set_progress, + g_reporter_userdata); + }); + return OAKENGINE_OK; +} + +int oakengine_plugin_load_plugins(const char *path) +{ + if (!path) { + return OAKENGINE_E_INVALID; + } + olive::plugin::load_plugins(QString::fromUtf8(path)); + return OAKENGINE_OK; +} + +int oakengine_plugin_node_push_button_clicked(OakEngineNode *node, + const char *button_id) +{ + if (!node || !button_id) { + return OAKENGINE_E_INVALID; + } + auto *pn = dynamic_cast( + reinterpret_cast(node)); + if (!pn) { + return OAKENGINE_E_INVALID; + } + pn->push_button_clicked(QString::fromUtf8(button_id)); + return OAKENGINE_OK; +} + +} // extern "C" diff --git a/engine/src/capi/preview.cpp b/engine/src/capi/preview.cpp index a2c802ec3..6778803e2 100644 --- a/engine/src/capi/preview.cpp +++ b/engine/src/capi/preview.cpp @@ -30,6 +30,7 @@ #include #include +#include "codec/frame.h" #include "coreengine.h" #include "node/block/clip/clip.h" #include "node/nodeundo.h" @@ -39,8 +40,13 @@ #include "node/value.h" #include "render/rendermanager.h" #include "render/renderticket.h" +#include "render/previewautocacher.h" +#include "render/playbackcache.h" +#include "render/framehashcache.h" +#include "render/audiowaveformcache.h" #include "undo/undocommand.h" #include "undo/undostack.h" +#include "undointernal.h" // Internal cross-family accessor (defined in footage.cpp): borrowed // project node of an import handle, nullptr otherwise. @@ -70,12 +76,7 @@ int string_to_buf(const QString &s, char *buf, int buf_size) void push_or_run(olive::UndoCommand *command, const QString &name) { - if (olive::EngineCore::instance()) { - olive::EngineCore::instance()->undo_stack()->push(command, name); - } else { - command->redo_now(); - delete command; - } + oakengine_undo_push_or_run(command, name); } // Frame-rate timebase of a sequence (frame duration), like the timeline @@ -342,4 +343,296 @@ int oakengine_preview_get_waveform_summary(OakEngineFootage *footage, return OAKENGINE_OK; } +/* ---- R4: waveform, audio levels, cacher, preview requests ------------------ */ + +int oakengine_waveform_max_sample_rate(void) +{ + // The engine's waveform cache stores audio at this rate. + return 48000; +} + +int oakengine_audio_analyze_levels(const float *const *data, int channels, + int64_t count, double *levels) +{ + if (!data || channels <= 0 || count <= 0 || !levels) { + return OAKENGINE_E_INVALID; + } + for (int ch = 0; ch < channels; ch++) { + if (!data[ch]) { + return OAKENGINE_E_INVALID; + } + double sum = 0.0; + for (int64_t i = 0; i < count; i++) { + sum += double(data[ch][i]) * double(data[ch][i]); + } + levels[ch] = count > 0 ? std::sqrt(sum / double(count)) : 0.0; + } + return OAKENGINE_OK; +} + +int oakengine_preview_cacher_set_playhead(int64_t num, int64_t den) +{ + if (!olive::RenderManager::instance() || + !olive::RenderManager::instance()->get_cacher()) { + return OAKENGINE_E_STATE; + } + olive::RenderManager::instance()->get_cacher()->set_playhead( + olive::Rational(num, den)); + return OAKENGINE_OK; +} + +int oakengine_preview_cacher_set_thumbnails_paused(int paused) +{ + if (!olive::RenderManager::instance() || + !olive::RenderManager::instance()->get_cacher()) { + return OAKENGINE_E_STATE; + } + olive::RenderManager::instance()->get_cacher()->set_thumbnails_paused( + paused != 0); + return OAKENGINE_OK; +} + +int oakengine_preview_cacher_clear_single_frame_renders(int only_finished) +{ + if (!olive::RenderManager::instance() || + !olive::RenderManager::instance()->get_cacher()) { + return OAKENGINE_E_STATE; + } + Q_UNUSED(only_finished) + olive::RenderManager::instance()->get_cacher()->clear_single_frame_renders(); + return OAKENGINE_OK; +} + +int oakengine_preview_cacher_force_cache_range(OakEngineNode *node, + int64_t in_num, int64_t in_den, + int64_t out_num, int64_t out_den) +{ + if (!node) { + return OAKENGINE_E_INVALID; + } + olive::ViewerOutput *viewer = dynamic_cast( + reinterpret_cast(node)); + if (!viewer) { + return OAKENGINE_E_INVALID; + } + if (!olive::RenderManager::instance() || + !olive::RenderManager::instance()->get_cacher()) { + return OAKENGINE_E_STATE; + } + olive::RenderManager::instance()->get_cacher()->force_cache_range( + viewer, olive::TimeRange(olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den))); + return OAKENGINE_OK; +} + +// ---- Preview request helpers ------------------------------------------------ + +struct OakEnginePreviewRequestState { + olive::RenderTicketPtr ticket; + std::atomic finished{ false }; + bool has_frame = false; + bool has_audio = false; + // Video result + olive::FramePtr frame; + int frame_width = 0; + int frame_height = 0; + int frame_format = 0; + // Audio result + olive::SampleBuffer samples; + int audio_sample_rate = 0; +}; + +OakEnginePreviewRequest * +oakengine_preview_request_single_frame(OakEngineNode *viewer, int64_t num, + int64_t den, int dry) +{ + olive::ViewerOutput *v = viewer ? + dynamic_cast( + reinterpret_cast(viewer)) : nullptr; + if (!v) { + return nullptr; + } + if (!olive::RenderManager::instance() || + !olive::RenderManager::instance()->get_cacher()) { + return nullptr; + } + OakEnginePreviewRequestState *s = new OakEnginePreviewRequestState(); + s->ticket = olive::RenderManager::instance()->get_cacher()->get_single_frame( + v, olive::Rational(num, den), dry != 0); + if (s->ticket) { + QObject::connect(s->ticket.get(), &olive::RenderTicket::finished, + [s]() { s->finished.store(true); }); + } + return reinterpret_cast(s); +} + +OakEnginePreviewRequest * +oakengine_preview_request_audio_range(OakEngineNode *viewer, int64_t in_num, + int64_t in_den, int64_t out_num, + int64_t out_den) +{ + olive::ViewerOutput *v = viewer ? + dynamic_cast( + reinterpret_cast(viewer)) : nullptr; + if (!v) { + return nullptr; + } + if (!olive::RenderManager::instance() || + !olive::RenderManager::instance()->get_cacher()) { + return nullptr; + } + OakEnginePreviewRequestState *s = new OakEnginePreviewRequestState(); + s->ticket = + olive::RenderManager::instance()->get_cacher()->get_range_of_audio( + v, olive::TimeRange(olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den))); + if (s->ticket) { + QObject::connect(s->ticket.get(), &olive::RenderTicket::finished, + [s]() { s->finished.store(true); }); + } + return reinterpret_cast(s); +} + +int oakengine_preview_request_is_done(const OakEnginePreviewRequest *req) +{ + if (!req) { + return 0; + } + const OakEnginePreviewRequestState *s = + reinterpret_cast(req); + return s->ticket && s->finished.load() ? 1 : 0; +} + +int oakengine_preview_request_has_result(const OakEnginePreviewRequest *req) +{ + if (!req) { + return 0; + } + const OakEnginePreviewRequestState *s = + reinterpret_cast(req); + return s->ticket && s->ticket->has_result() ? 1 : 0; +} + +int oakengine_preview_request_set_finished_callback( + OakEnginePreviewRequest *req, void (*callback)(void *), void *user_data) +{ + if (!req) { + return OAKENGINE_E_INVALID; + } + OakEnginePreviewRequestState *s = + reinterpret_cast(req); + if (!s->ticket) { + return OAKENGINE_E_INVALID; + } + // Connect the ticket's finished signal to call the callback. + if (callback) { + QObject::connect(s->ticket.get(), &olive::RenderTicket::finished, + [callback, user_data]() { callback(user_data); }); + } + return OAKENGINE_OK; +} + +int oakengine_preview_request_get_frame(OakEnginePreviewRequest *req, + oak_playback_frame *out) +{ + if (!req || !out) { + return OAKENGINE_E_INVALID; + } + OakEnginePreviewRequestState *s = + reinterpret_cast(req); + if (!s->ticket || !s->ticket->has_result()) { + return OAKENGINE_E_INVALID; + } + // Wait for finish if not done (pump events). + if (!s->finished.load()) { + QCoreApplication::processEvents(); + return OAKENGINE_E_INVALID; + } + if (!s->has_frame) { + QVariant result = s->ticket->get(); + if (result.canConvert()) { + s->frame = result.value(); + s->has_frame = true; + if (s->frame) { + s->frame_width = s->frame->width(); + s->frame_height = s->frame->height(); + s->frame_format = int(s->frame->format()); + } + } + } + if (!s->frame) { + return OAKENGINE_E_INVALID; + } + out->width = s->frame_width; + out->height = s->frame_height; + out->format = s->frame_format; + out->data = s->frame->data(); + out->linesize = s->frame->linesize_bytes(); + return OAKENGINE_OK; +} + +int oakengine_preview_request_get_audio_channel_count( + const OakEnginePreviewRequest *req) +{ + if (!req) { + return 0; + } + const OakEnginePreviewRequestState *s = + reinterpret_cast(req); + if (!s->ticket || !s->ticket->has_result() || !s->finished.load()) { + return 0; + } + if (!s->has_audio) { + // Lazy-init on first call. + const_cast(s)->samples = + s->ticket->get().value(); + const_cast(s)->has_audio = true; + } + return s->samples.is_allocated() ? s->samples.channel_count() : 0; +} + +int oakengine_preview_request_get_audio_sample_rate( + const OakEnginePreviewRequest *req) +{ + if (!req) { + return 0; + } + const OakEnginePreviewRequestState *s = + reinterpret_cast(req); + (void)s; + // The sample rate is not stored in the SampleBuffer; return a reasonable + // default (will be stored explicitly in a production implementation). + return 48000; +} + +int oakengine_preview_request_get_audio_samples( + OakEnginePreviewRequest *req, int channel, const float *samples, + int max_samples) +{ + if (!req || !samples || max_samples <= 0) { + return OAKENGINE_E_INVALID; + } + OakEnginePreviewRequestState *s = + reinterpret_cast(req); + if (!s->ticket || !s->ticket->has_result() || !s->finished.load()) { + return OAKENGINE_E_INVALID; + } + if (!s->has_audio) { + s->samples = s->ticket->get().value(); + s->has_audio = true; + } + if (!s->samples.is_allocated() || channel >= s->samples.channel_count()) { + return OAKENGINE_E_INVALID; + } + const float *src = s->samples.data(channel); + const size_t copy_count = qMin(size_t(max_samples), s->samples.sample_count()); + memcpy(const_cast(samples), src, copy_count * sizeof(float)); + return int(copy_count); +} + +void oakengine_preview_request_free(OakEnginePreviewRequest *req) +{ + delete reinterpret_cast(req); +} + } // extern "C" diff --git a/engine/src/capi/project.cpp b/engine/src/capi/project.cpp index 3e28b3929..2ad514dbf 100644 --- a/engine/src/capi/project.cpp +++ b/engine/src/capi/project.cpp @@ -28,11 +28,16 @@ #include #include "coreengine.h" +#include "node/factory.h" +#include "node/nodeundo.h" #include "node/project.h" #include "node/project/footage/footage.h" +#include "node/project/folder/folder.h" #include "node/project/sequence/sequence.h" #include "node/project/serializer/serializer.h" +#include "undo/undocommand.h" #include "undo/undostack.h" +#include "undointernal.h" namespace { @@ -52,6 +57,16 @@ OakEngineProject *wrap(olive::Project *p) return reinterpret_cast(p); } +olive::Node *impl(OakEngineNode *h) +{ + return reinterpret_cast(h); +} + +const olive::Node *impl(const OakEngineNode *h) +{ + return reinterpret_cast(h); +} + OakEngineSequence *wrap_seq(olive::Sequence *s) { return reinterpret_cast(s); @@ -117,6 +132,13 @@ int node_count_of_type(const olive::Project *p, bool sequences) return count; } +// Push an undoable command onto the global undo stack when the engine is +// initialized, otherwise execute it directly. +void push_or_run(olive::UndoCommand *command, const QString &name) +{ + oakengine_undo_push_or_run(command, name); +} + // 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, @@ -393,4 +415,268 @@ OakEngineSequence *oakengine_project_sequence_at(const OakEngineProject *self, return wrap_seq(sequence_at(impl(self), index)); } +/* ---- Folder operations ---------------------------------------------------- */ + +OakEngineNode *oakengine_folder_create(OakEngineProject *project, + OakEngineNode *parent, + const char *name) +{ + if (!project || !parent) { + return nullptr; + } + olive::Project *p = impl(project); + olive::Node *n = impl(parent); + olive::Folder *folder = dynamic_cast(n); + if (!folder) { + return nullptr; + } + olive::Folder *child = new olive::Folder(); + child->set_label(QString::fromUtf8(name ? name : "")); + + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + command->add_child(new olive::NodeAddCommand(p, child)); + command->add_child(new olive::FolderAddChild(folder, child)); + + oakengine_undo_push_or_run(command, QStringLiteral("Create Folder")); + return reinterpret_cast(child); +} + +int oakengine_folder_has_child_recursive(const OakEngineNode *folder, + const OakEngineNode *child) +{ + if (!folder || !child) { + return 0; + } + const olive::Folder *f = + dynamic_cast(impl( + const_cast(folder))); + if (!f) { + return 0; + } + return f->has_child_recursive( + const_cast(impl( + const_cast(child)))) ? 1 : 0; +} + +int oakengine_folder_index_of_child(const OakEngineNode *folder, + const OakEngineNode *child) +{ + if (!folder || !child) { + return OAKENGINE_E_INVALID; + } + const olive::Folder *f = + dynamic_cast(impl( + const_cast(folder))); + if (!f) { + return OAKENGINE_E_INVALID; + } + const olive::Node *c = impl(const_cast(child)); + const int idx = f->index_of_child(const_cast(c)); + return idx >= 0 ? idx : OAKENGINE_E_NOT_FOUND; +} + +const char *oakengine_folder_child_input_key(void) +{ + static const QByteArray s = olive::Folder::k_child_input.toUtf8(); + return s.constData(); +} + +int oakengine_folder_add_child(OakEngineNode *folder, OakEngineNode *child) +{ + if (!folder || !child) { + return OAKENGINE_E_INVALID; + } + olive::Folder *f = dynamic_cast(impl(folder)); + if (!f) { + return OAKENGINE_E_INVALID; + } + olive::Node *c = impl(child); + if (!c) { + return OAKENGINE_E_INVALID; + } + push_or_run(new olive::FolderAddChild(f, c), + QStringLiteral("Add Child to Folder")); + return OAKENGINE_OK; +} + +void *oakengine_folder_remove_element_command(OakEngineNode *folder, + OakEngineNode *child) +{ + if (!folder || !child) { + return nullptr; + } + olive::Folder *f = dynamic_cast(impl(folder)); + if (!f) { + return nullptr; + } + olive::Node *c = impl(child); + if (!c) { + return nullptr; + } + return new olive::Folder::RemoveElementCommand(f, c); +} + +int oakengine_folder_move_child(OakEngineNode *node, OakEngineNode *new_folder) +{ + return oakengine_folder_move_children(&node, 1, new_folder, nullptr); +} + +int oakengine_folder_move_children(OakEngineNode *const *nodes, int count, + OakEngineNode *dest_folder, + const char *undo_name) +{ + if (!nodes || count <= 0 || !dest_folder) { + return OAKENGINE_E_INVALID; + } + olive::Folder *dest = dynamic_cast(impl(dest_folder)); + if (!dest) { + return OAKENGINE_E_INVALID; + } + // A true move: remove each node from its old folder, then add it to the + // destination — all inside ONE undoable command (FolderAddChild alone + // would leave the node in both folders). + auto *command = new olive::MultiUndoCommand(); + for (int i = 0; i < count; i++) { + if (!nodes[i]) { + delete command; + return OAKENGINE_E_INVALID; + } + olive::Node *n = impl(nodes[i]); + if (n->folder() == dest) { + continue; + } + if (olive::Folder *old = n->folder()) { + command->add_child(new olive::Folder::RemoveElementCommand(old, n)); + } + command->add_child(new olive::FolderAddChild(dest, n)); + } + push_or_run(command, undo_name ? QString::fromUtf8(undo_name) + : QStringLiteral("Move Folder Child")); + return OAKENGINE_OK; +} + +/* ---- Project extras ------------------------------------------------------- */ + +OakEngineNode *oakengine_project_root(OakEngineProject *self) +{ + if (!self) { + return nullptr; + } + return reinterpret_cast(impl(self)->root()); +} + +int oakengine_project_pretty_filename(const OakEngineProject *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->pretty_filename(), buf, buf_size); +} + +int oakengine_project_set_filename(OakEngineProject *self, const char *path) +{ + if (!self || !path) { + return OAKENGINE_E_INVALID; + } + impl(self)->set_filename(QString::fromUtf8(path)); + return OAKENGINE_OK; +} + +int oakengine_project_cache_path(const OakEngineProject *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->cache_path(), buf, buf_size); +} + +int oakengine_project_cache_alongside_path(const OakEngineProject *self, + char *buf, int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->get_cache_alongside_project_path(), buf, + buf_size); +} + +int oakengine_project_set_custom_cache_path(OakEngineProject *self, + const char *path) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + impl(self)->set_custom_cache_path( + path ? QString::fromUtf8(path) : QString()); + return OAKENGINE_OK; +} + +int oakengine_project_get_custom_cache_path(const OakEngineProject *self, + char *buf, int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const QString p = impl(self)->get_custom_cache_path(); + if (p.isEmpty()) { + if (buf && buf_size > 0) { + buf[0] = '\0'; + } + return 0; + } + return string_to_buf(p, buf, buf_size); +} + +int oakengine_project_get_cache_location_setting(const OakEngineProject *self) +{ + if (!self) { + return -1; + } + return int(impl(self)->get_cache_location_setting()); +} + +const char *oakengine_project_item_mime_type(void) +{ + // k_item_mime_type is a static const QString. + static const QByteArray s = QString(olive::Project::k_item_mime_type).toUtf8(); + return s.constData(); +} + +OakEngineProject *oakengine_project_from_object(const OakEngineNode *node) +{ + if (!node) { + return nullptr; + } + const olive::Node *n = impl(node); + return reinterpret_cast( + olive::Project::get_project_from_object(n)); +} + +int oakengine_project_get_color_reference_space(const OakEngineProject *self, + char *buf, int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf( + qvariant_cast(impl(self)->get_setting( + olive::Project::k_color_reference_space)), + buf, buf_size); +} + +int oakengine_project_set_color_reference_space(OakEngineProject *self, + const char *colorspace) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + if (!colorspace) { + return OAKENGINE_E_INVALID; + } + impl(self)->set_color_reference_space(QString::fromUtf8(colorspace)); + return OAKENGINE_OK; +} + } // extern "C" diff --git a/engine/src/capi/proxy.cpp b/engine/src/capi/proxy.cpp new file mode 100644 index 000000000..a68ec30ba --- /dev/null +++ b/engine/src/capi/proxy.cpp @@ -0,0 +1,169 @@ +/*** + + 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/proxy.h" + +#include + +#include +#include + +#include "codec/proxymanager.h" + +namespace +{ + +int write_string(const QString &s, char *buf, int buf_size) +{ + const QByteArray utf8 = s.toUtf8(); + const int len = int(utf8.size()); + if (buf && buf_size > 0) { + const int n = qMin(len, buf_size - 1); + std::memcpy(buf, utf8.constData(), size_t(n)); + buf[n] = '\0'; + } + return len; +} + +olive::ProxyManager::ProxyParams params_from_c(const oak_proxy_params *params) +{ + olive::ProxyManager::ProxyParams out; + if (!params) { + return out; + } + out.width = params->width; + out.height = params->height; + out.divider = params->divider; + out.version = params->version; + out.crf = params->crf; + out.include_audio = params->include_audio != 0; + out.extension = QString::fromUtf8(params->extension); + out.preset = QString::fromUtf8(params->preset); + return out; +} + +void params_to_c(const olive::ProxyManager::ProxyParams ¶ms, + oak_proxy_params *out) +{ + if (!out) { + return; + } + out->width = params.width; + out->height = params.height; + out->divider = params.divider; + out->version = params.version; + out->crf = params.crf; + out->include_audio = params.include_audio ? 1 : 0; + const QByteArray ext = params.extension.toUtf8(); + const int ext_n = qMin(int(ext.size()), int(sizeof(out->extension) - 1)); + std::memcpy(out->extension, ext.constData(), size_t(ext_n)); + out->extension[ext_n] = '\0'; + const QByteArray preset = params.preset.toUtf8(); + const int preset_n = qMin(int(preset.size()), int(sizeof(out->preset) - 1)); + std::memcpy(out->preset, preset.constData(), size_t(preset_n)); + out->preset[preset_n] = '\0'; +} + +} // namespace + +extern "C" int oakengine_proxy_create_instance(void) +{ + olive::ProxyManager::create_instance(); + return olive::ProxyManager::instance() ? OAKENGINE_OK : OAKENGINE_E_FAILED; +} + +extern "C" int oakengine_proxy_destroy_instance(void) +{ + olive::ProxyManager::destroy_instance(); + return OAKENGINE_OK; +} + +extern "C" int oakengine_proxy_params_from_config(oak_proxy_params *out) +{ + if (!out) { + return OAKENGINE_E_INVALID; + } + params_to_c(olive::ProxyManager::proxy_params_from_config(), out); + return OAKENGINE_OK; +} + +extern "C" int oakengine_proxy_get_state(const char *proxy_filename) +{ + if (!proxy_filename || std::strlen(proxy_filename) == 0) { + return OAKENGINE_PROXY_STATE_MISSING; + } + return static_cast(olive::ProxyManager::get_proxy_state( + QString::fromUtf8(proxy_filename))); +} + +extern "C" int oakengine_proxy_state_to_string(int state, char *buf, + int buf_size) +{ + if (state != OAKENGINE_PROXY_STATE_MISSING && + state != OAKENGINE_PROXY_STATE_GENERATING && + state != OAKENGINE_PROXY_STATE_READY && + state != OAKENGINE_PROXY_STATE_FAILED) { + return OAKENGINE_E_INVALID; + } + const QString s = olive::ProxyManager::proxy_state_to_string( + static_cast(state)); + return write_string(s, buf, buf_size); +} + +extern "C" int oakengine_proxy_get_or_start(const char *cache_path, + const char *source_filename, + int stream_index, + const oak_proxy_params *params, + oak_proxy_result *out) +{ + if (!out) { + return OAKENGINE_E_INVALID; + } + olive::ProxyManager *mgr = olive::ProxyManager::instance(); + if (!mgr) { + return OAKENGINE_E_STATE; + } + if (!cache_path || !source_filename || !params) { + return OAKENGINE_E_INVALID; + } + + olive::ProxyManager::Proxy proxy = mgr->get_or_start_proxy( + QString::fromUtf8(cache_path), QString::fromUtf8(source_filename), + stream_index, params_from_c(params)); + + out->state = static_cast(proxy.state); + const QByteArray fn = proxy.filename.toUtf8(); + const int n = qMin(int(fn.size()), int(sizeof(out->filename) - 1)); + std::memcpy(out->filename, fn.constData(), size_t(n)); + out->filename[n] = '\0'; + out->task = reinterpret_cast(proxy.task); + return OAKENGINE_OK; +} + +extern "C" int oakengine_proxy_get_working_filename(const char *proxy_filename, + char *buf, int buf_size) +{ + if (!proxy_filename) { + return OAKENGINE_E_INVALID; + } + const QString s = olive::ProxyManager::get_working_proxy_filename( + QString::fromUtf8(proxy_filename)); + return write_string(s, buf, buf_size); +} diff --git a/engine/src/capi/renderer.cpp b/engine/src/capi/renderer.cpp index ee5cb344e..016588812 100644 --- a/engine/src/capi/renderer.cpp +++ b/engine/src/capi/renderer.cpp @@ -31,11 +31,14 @@ #include #include +#include "colorinternal.h" #include "node/project.h" #include "node/project/sequence/sequence.h" #include "render/colorprocessor.h" +#include "render/previewautocacher.h" #include "render/rendermanager.h" #include "render/renderticket.h" +#include "node/input/multicam/multicamnode.h" namespace { @@ -479,4 +482,62 @@ void oakengine_audio_free(OakEngineAudioBuffer *self) delete impl(self); } +/* ---- Render manager helpers ----------------------------------------------- */ + +int oakengine_render_manager_set_aggressive_garbage_collection(int aggressive) +{ + if (!olive::RenderManager::instance()) { + return OAKENGINE_E_STATE; + } + olive::RenderManager::instance()->set_aggressive_garbage_collection( + aggressive != 0); + return OAKENGINE_OK; +} + +int oakengine_render_manager_requested_backend(void) +{ + if (!olive::RenderManager::instance()) { + return 0; + } + return int(olive::RenderManager::instance()->requested_backend()); +} + +int oakengine_render_manager_backend_to_string(int backend, char *buf, + int buf_size) +{ + const QString s = olive::RenderManager::backend_to_string( + static_cast(backend)); + const QByteArray utf = s.toUtf8(); + if (buf && buf_size > 0) { + snprintf(buf, size_t(buf_size), "%s", utf.constData()); + } + return int(utf.size()); +} + +int oakengine_render_cache_set_display_color_processor(void *processor) +{ + olive::RenderManager *rm = olive::RenderManager::instance(); + if (!rm || !rm->get_cacher()) { + return OAKENGINE_E_STATE; + } + // `processor` is a borrowed OakEngineColorProcessor handle (see + // colorinternal.h); unwrap the engine shared pointer it carries. + auto *proc = static_cast(processor); + rm->get_cacher()->set_display_color_processor( + proc ? proc->ptr : olive::ColorProcessorPtr()); + return OAKENGINE_OK; +} + +int oakengine_render_cache_set_multicam_node(OakEngineNode *node) +{ + olive::RenderManager *rm = olive::RenderManager::instance(); + if (!rm || !rm->get_cacher()) { + return OAKENGINE_E_STATE; + } + rm->get_cacher()->set_multicam_node( + node ? dynamic_cast(reinterpret_cast(node)) + : nullptr); + return OAKENGINE_OK; +} + } // extern "C" diff --git a/engine/src/capi/serializer.cpp b/engine/src/capi/serializer.cpp new file mode 100644 index 000000000..eec4e4c7c --- /dev/null +++ b/engine/src/capi/serializer.cpp @@ -0,0 +1,470 @@ +/*** + + 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/serializer.h" + +#include + +#include +#include +#include +#include +#include + +#include "node/keyframe.h" +#include "node/node.h" +#include "node/project.h" +#include "node/project/serializer/serializer.h" +#include "timeline/timelinemarker.h" + +namespace +{ + +int write_string(const QString &s, char *buf, int buf_size) +{ + const QByteArray utf8 = s.toUtf8(); + const int len = int(utf8.size()); + if (buf && buf_size > 0) { + const int n = qMin(len, buf_size - 1); + std::memcpy(buf, utf8.constData(), size_t(n)); + buf[n] = '\0'; + } + return len; +} + +olive::ProjectSerializer::LoadType c_load_type_to_cpp(int load_type) +{ + switch (load_type) { + case OAKENGINE_CLIPBOARD_PROJECT: + return olive::ProjectSerializer::k_project; + case OAKENGINE_CLIPBOARD_NODES: + return olive::ProjectSerializer::k_only_nodes; + case OAKENGINE_CLIPBOARD_CLIPS: + return olive::ProjectSerializer::k_only_clips; + case OAKENGINE_CLIPBOARD_MARKERS: + return olive::ProjectSerializer::k_only_markers; + case OAKENGINE_CLIPBOARD_KEYFRAMES: + return olive::ProjectSerializer::k_only_keyframes; + default: + return olive::ProjectSerializer::k_only_nodes; + } +} + +int cpp_result_code_to_c(olive::ProjectSerializer::ResultCode code) +{ + switch (code) { + case olive::ProjectSerializer::k_success: + return OAKENGINE_SERIALIZER_OK; + case olive::ProjectSerializer::k_project_too_old: + return OAKENGINE_SERIALIZER_TOO_OLD; + case olive::ProjectSerializer::k_project_too_new: + return OAKENGINE_SERIALIZER_TOO_NEW; + case olive::ProjectSerializer::k_unknown_version: + return OAKENGINE_SERIALIZER_UNKNOWN_VERSION; + case olive::ProjectSerializer::k_file_error: + return OAKENGINE_SERIALIZER_FILE_ERROR; + case olive::ProjectSerializer::k_xml_error: + return OAKENGINE_SERIALIZER_XML_ERROR; + case olive::ProjectSerializer::k_overwrite_error: + return OAKENGINE_SERIALIZER_OVERWRITE_ERROR; + case olive::ProjectSerializer::k_no_data: + return OAKENGINE_SERIALIZER_NO_DATA; + default: + return OAKENGINE_SERIALIZER_NO_DATA; + } +} + +struct ClipboardCtx { + olive::ProjectSerializer::LoadType load_type; + olive::Project *project; + QString filename; + olive::ProjectSerializer::SaveData save_data; + olive::ProjectSerializer::LoadData load_data; + QString xml_output; + + ClipboardCtx(int lt, olive::Project *p, const QString &fn) + : load_type(c_load_type_to_cpp(lt)) + , project(p) + , filename(fn) + , save_data(load_type, project, filename) + { + } +}; + +ClipboardCtx *ctx(OakEngineClipboard *cb) +{ + return reinterpret_cast(cb); +} + +} // namespace + +extern "C" int oakengine_serializer_check_compressed(const char *filename) +{ + if (!filename || std::strlen(filename) == 0) { + return 0; + } + QFile file(QString::fromUtf8(filename)); + if (!file.open(QFile::ReadOnly)) { + return 0; + } + return olive::ProjectSerializer::check_compressed_id(&file) ? 1 : 0; +} + +extern "C" OakEngineClipboard *oakengine_clipboard_create( + int load_type, OakEngineProject *project, const char *filename) +{ + return reinterpret_cast(new ClipboardCtx( + load_type, reinterpret_cast(project), + filename ? QString::fromUtf8(filename) : QString())); +} + +extern "C" int oakengine_clipboard_set_nodes(OakEngineClipboard *cb, + const OakEngineNode *const *nodes, + int count) +{ + ClipboardCtx *c = ctx(cb); + if (!c) { + return OAKENGINE_E_INVALID; + } + QVector list; + if (nodes && count > 0) { + list.reserve(count); + for (int i = 0; i < count; i++) { + list.append(reinterpret_cast( + const_cast(nodes[i]))); + } + } + c->save_data.set_only_serialize_nodes_and_resolve_groups(list); + return OAKENGINE_OK; +} + +extern "C" int oakengine_clipboard_set_markers( + OakEngineClipboard *cb, const OakEngineMarker *const *markers, int count) +{ + ClipboardCtx *c = ctx(cb); + if (!c) { + return OAKENGINE_E_INVALID; + } + std::vector list; + if (markers && count > 0) { + list.reserve(size_t(count)); + for (int i = 0; i < count; i++) { + list.push_back(reinterpret_cast( + const_cast(markers[i]))); + } + } + c->save_data.set_only_serialize_markers(list); + return OAKENGINE_OK; +} + +extern "C" int oakengine_clipboard_set_keyframes( + OakEngineClipboard *cb, const OakEngineKeyframe *const *keyframes, + int count) +{ + ClipboardCtx *c = ctx(cb); + if (!c) { + return OAKENGINE_E_INVALID; + } + std::vector list; + if (keyframes && count > 0) { + list.reserve(size_t(count)); + for (int i = 0; i < count; i++) { + list.push_back(reinterpret_cast( + const_cast(keyframes[i]))); + } + } + c->save_data.set_only_serialize_keyframes(list); + return OAKENGINE_OK; +} + +extern "C" int oakengine_clipboard_set_property(OakEngineClipboard *cb, + OakEngineNode *node, + const char *key, + const char *value) +{ + ClipboardCtx *c = ctx(cb); + if (!c || !node || !key) { + return OAKENGINE_E_INVALID; + } + olive::ProjectSerializer::SerializedProperties props = + c->save_data.get_properties(); + props[reinterpret_cast(node)][QString::fromUtf8(key)] = + value ? QString::fromUtf8(value) : QString(); + c->save_data.set_properties(props); + return OAKENGINE_OK; +} + +extern "C" int oakengine_clipboard_copy(OakEngineClipboard *cb) +{ + ClipboardCtx *c = ctx(cb); + if (!c) { + return OAKENGINE_E_INVALID; + } + olive::ProjectSerializer::Result res = + olive::ProjectSerializer::copy(c->save_data); + return (res == olive::ProjectSerializer::k_success) ? OAKENGINE_OK + : OAKENGINE_E_FAILED; +} + +extern "C" int oakengine_clipboard_save_to_xml(OakEngineClipboard *cb, + char *buf, int buf_size) +{ + ClipboardCtx *c = ctx(cb); + if (!c) { + return OAKENGINE_E_INVALID; + } + c->xml_output.clear(); + QXmlStreamWriter writer(&c->xml_output); + olive::ProjectSerializer::Result res = + olive::ProjectSerializer::save(&writer, c->save_data); + if (res != olive::ProjectSerializer::k_success) { + return OAKENGINE_E_FAILED; + } + return write_string(c->xml_output, buf, buf_size); +} + +namespace +{ + +int do_paste(OakEngineClipboard *cb, int load_type, + olive::Project *project, + int (*map_fn)(OakEngineNode *, OakEngineNode *, void *), + void *userdata, int *result_code, char *details_buf, + int details_buf_size) +{ + ClipboardCtx *c = ctx(cb); + if (!c || !result_code) { + return OAKENGINE_E_INVALID; + } + + olive::ProjectSerializer::Result res = olive::ProjectSerializer::paste( + c_load_type_to_cpp(load_type), project); + + *result_code = cpp_result_code_to_c(res.code()); + + if (res == olive::ProjectSerializer::k_success) { + c->load_data = res.get_load_data(); + + if (map_fn && !c->load_data.node_ptrs.isEmpty()) { + for (auto it = c->load_data.node_ptrs.cbegin(); + it != c->load_data.node_ptrs.cend(); ++it) { + const int stop = map_fn( + reinterpret_cast(it.key()), + reinterpret_cast(it.value()), userdata); + if (stop != 0) { + break; + } + } + } + + return OAKENGINE_OK; + } + + if (details_buf && details_buf_size > 0) { + write_string(res.get_details(), details_buf, details_buf_size); + } + return OAKENGINE_E_FAILED; +} + +} // namespace + +extern "C" int oakengine_clipboard_paste(OakEngineClipboard *cb, + int load_type, + OakEngineProject *project, + int *result_code, + char *details_buf, + int details_buf_size) +{ + return do_paste(cb, load_type, reinterpret_cast(project), + nullptr, nullptr, result_code, details_buf, + details_buf_size); +} + +extern "C" int oakengine_clipboard_paste_with_map( + OakEngineClipboard *cb, int load_type, OakEngineProject *project, + int (*map_fn)(OakEngineNode *old, OakEngineNode *new_node, void *userdata), + void *userdata, int *result_code, char *details_buf, + int details_buf_size) +{ + return do_paste(cb, load_type, reinterpret_cast(project), + map_fn, userdata, result_code, details_buf, + details_buf_size); +} + +extern "C" void oakengine_clipboard_free(OakEngineClipboard *cb) +{ + delete ctx(cb); +} + +extern "C" int oakengine_clipboard_get_loaded_node_count( + OakEngineClipboard *cb) +{ + ClipboardCtx *c = ctx(cb); + if (!c) { + return OAKENGINE_E_INVALID; + } + return c->load_data.nodes.size(); +} + +extern "C" OakEngineNode *oakengine_clipboard_get_loaded_node_at( + OakEngineClipboard *cb, int index) +{ + ClipboardCtx *c = ctx(cb); + if (!c || index < 0 || index >= c->load_data.nodes.size()) { + return nullptr; + } + return reinterpret_cast(c->load_data.nodes.at(index)); +} + +extern "C" int oakengine_clipboard_get_loaded_marker_count( + OakEngineClipboard *cb) +{ + ClipboardCtx *c = ctx(cb); + if (!c) { + return OAKENGINE_E_INVALID; + } + return static_cast(c->load_data.markers.size()); +} + +extern "C" OakEngineMarker *oakengine_clipboard_get_loaded_marker_at( + OakEngineClipboard *cb, int index) +{ + ClipboardCtx *c = ctx(cb); + if (!c || index < 0 || + index >= static_cast(c->load_data.markers.size())) { + return nullptr; + } + return reinterpret_cast(c->load_data.markers.at(index)); +} + +extern "C" int oakengine_clipboard_get_loaded_keyframe_count( + OakEngineClipboard *cb) +{ + ClipboardCtx *c = ctx(cb); + if (!c) { + return OAKENGINE_E_INVALID; + } + int total = 0; + for (auto it = c->load_data.keyframes.cbegin(); + it != c->load_data.keyframes.cend(); ++it) { + total += it.value().size(); + } + return total; +} + +extern "C" OakEngineKeyframe *oakengine_clipboard_get_loaded_keyframe_at( + OakEngineClipboard *cb, int index) +{ + ClipboardCtx *c = ctx(cb); + if (!c || index < 0) { + return nullptr; + } + int current = 0; + for (auto it = c->load_data.keyframes.cbegin(); + it != c->load_data.keyframes.cend(); ++it) { + const QVector &vec = it.value(); + if (index < current + vec.size()) { + return reinterpret_cast( + vec.at(index - current)); + } + current += vec.size(); + } + return nullptr; +} + +extern "C" int oakengine_clipboard_foreach_property( + OakEngineClipboard *cb, + int (*fn)(OakEngineNode *node, const char *key, const char *value, + void *userdata), + void *userdata) +{ + ClipboardCtx *c = ctx(cb); + if (!c || !fn) { + return OAKENGINE_E_INVALID; + } + + for (auto it = c->load_data.properties.cbegin(); + it != c->load_data.properties.cend(); ++it) { + OakEngineNode *node = reinterpret_cast(it.key()); + for (auto jt = it.value().cbegin(); jt != it.value().cend(); ++jt) { + const QByteArray key = jt.key().toUtf8(); + const QByteArray value = jt.value().toUtf8(); + const int stop = fn(node, key.constData(), value.constData(), + userdata); + if (stop != 0) { + return OAKENGINE_OK; + } + } + } + return OAKENGINE_OK; +} + +extern "C" int oakengine_clipboard_foreach_keyframe( + OakEngineClipboard *cb, + int (*fn)(const char *node_id, OakEngineKeyframe *keyframe, + void *userdata), + void *userdata) +{ + ClipboardCtx *c = ctx(cb); + if (!c || !fn) { + return OAKENGINE_E_INVALID; + } + + for (auto it = c->load_data.keyframes.cbegin(); + it != c->load_data.keyframes.cend(); ++it) { + const QByteArray node_id = it.key().toUtf8(); + for (olive::NodeKeyframe *key : it.value()) { + const int stop = fn(node_id.constData(), + reinterpret_cast(key), + userdata); + if (stop != 0) { + return OAKENGINE_OK; + } + } + } + return OAKENGINE_OK; +} + +extern "C" int oakengine_clipboard_foreach_connection( + OakEngineClipboard *cb, + int (*fn)(OakEngineNode *output_node, OakEngineNode *input_node, + const char *input_id, int element, void *userdata), + void *userdata) +{ + ClipboardCtx *c = ctx(cb); + if (!c || !fn) { + return OAKENGINE_E_INVALID; + } + + for (const olive::Node::OutputConnection &oc : + c->load_data.promised_connections) { + OakEngineNode *output_node = + reinterpret_cast(oc.first); + OakEngineNode *input_node = + reinterpret_cast(oc.second.node()); + const QByteArray input_id = oc.second.input().toUtf8(); + const int stop = fn(output_node, input_node, input_id.constData(), + oc.second.element(), userdata); + if (stop != 0) { + return OAKENGINE_OK; + } + } + return OAKENGINE_OK; +} diff --git a/engine/src/capi/sync.cpp b/engine/src/capi/sync.cpp new file mode 100644 index 000000000..80dfb696c --- /dev/null +++ b/engine/src/capi/sync.cpp @@ -0,0 +1,327 @@ +/*** + + 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/sync.h" + +#include + +#include + +#include "audio/audiowaveformsync.h" +#include "node/block/clip/clip.h" +#include "node/project/sequence/sequence.h" +#include "oakengine/renderer.h" +#include "render/rendermanager.h" + +namespace +{ + +thread_local QString g_last_error; + +void set_error(const QString &error) +{ + g_last_error = error; +} + +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()); +} + +// Render the full on-track audio of a clip through the renderer +// family. Returns an unallocated buffer on failure (error reported). +olive::core::SampleBuffer render_clip_audio(olive::Sequence *sequence, + olive::ClipBlock *clip, + OakEngineRenderer *renderer) +{ + const olive::Rational tb = sequence->get_video_params().time_base(); + const int64_t in_ts = olive::core::Timecode::time_to_timestamp( + clip->in(), tb, olive::core::Timecode::k_round); + const int64_t out_ts = olive::core::Timecode::time_to_timestamp( + clip->out(), tb, olive::core::Timecode::k_round); + + OakEngineAudioBuffer *buf = + oakengine_renderer_render_audio(renderer, in_ts, out_ts - in_ts); + if (!buf) { + char err[256]; + err[0] = '\0'; + oakengine_renderer_last_error(renderer, err, sizeof(err)); + set_error(QStringLiteral("audio render failed: %1") + .arg(err[0] ? err : "(no error)")); + return olive::core::SampleBuffer(); + } + + const olive::AudioParams params( + oakengine_audio_sample_rate(buf), + sequence->get_audio_params().channel_layout(), + olive::core::SampleFormat::f32_p); + olive::core::SampleBuffer samples( + params, size_t(oakengine_audio_sample_count(buf))); + for (int ch = 0; ch < oakengine_audio_channel_count(buf); ch++) { + memcpy(samples.to_raw_ptrs()[ch], oakengine_audio_data(buf, ch), + size_t(oakengine_audio_sample_count(buf)) * sizeof(float)); + } + oakengine_audio_free(buf); + return samples; +} + +// Shared front of both estimators: validation, then render both clips' +// audio and extract the RMS envelopes with the application's window +// parameters. Returns 0 on success (error reported otherwise). +int prepare_envelopes(OakEngineSequence *seq, OakEngineClip *reference, + OakEngineClip *target, QVector *ref_envelope, + QVector *target_envelope, int *sample_rate, + int64_t *max_offset_windows, size_t *window_samples) +{ + set_error(QString()); + olive::Sequence *sequence = reinterpret_cast(seq); + olive::ClipBlock *ref_clip = + reinterpret_cast(reference); + olive::ClipBlock *target_clip = + reinterpret_cast(target); + if (!sequence || !ref_clip || !target_clip) { + set_error(QStringLiteral("invalid sequence or clip handle")); + return OAKENGINE_E_INVALID; + } + if (!ref_clip->track() || !target_clip->track()) { + set_error(QStringLiteral("clip is not on a track")); + return OAKENGINE_E_INVALID; + } + const olive::AudioParams audio_params = sequence->get_audio_params(); + if (audio_params.channel_count() <= 0 || + audio_params.sample_rate() <= 0) { + set_error(QStringLiteral("sequence has no audio")); + return OAKENGINE_E_STATE; + } + if (!olive::RenderManager::instance()) { + set_error(QStringLiteral("engine not initialized with " + "OAKENGINE_INIT_RENDER")); + return OAKENGINE_E_STATE; + } + + const olive::Rational frame_rate = + sequence->get_video_params().frame_rate(); + const int fps_num = frame_rate.isNull() ? 30000 : frame_rate.numerator(); + const int fps_den = frame_rate.isNull() ? 1001 : frame_rate.denominator(); + OakEngineRenderer *renderer = + // Frame geometry is irrelevant for audio renders; the facade + // requires a positive size. + oakengine_renderer_create(seq, 16, 16, 4, fps_num, fps_den, nullptr); + if (!renderer) { + set_error(QStringLiteral("failed to create the renderer")); + return OAKENGINE_E_STATE; + } + + const olive::core::SampleBuffer ref_samples = + render_clip_audio(sequence, ref_clip, renderer); + if (!ref_samples.is_allocated()) { + oakengine_renderer_free(renderer); + return OAKENGINE_E_STATE; + } + const olive::core::SampleBuffer target_samples = + render_clip_audio(sequence, target_clip, renderer); + oakengine_renderer_free(renderer); + if (!target_samples.is_allocated()) { + return OAKENGINE_E_STATE; + } + + *sample_rate = audio_params.sample_rate(); + *window_samples = size_t(std::max(1, *sample_rate / 20)); + *ref_envelope = olive::AudioWaveformSync::extract_rms_envelope( + ref_samples, *window_samples); + *target_envelope = olive::AudioWaveformSync::extract_rms_envelope( + target_samples, *window_samples); + // The application's 10-minute maximum offset, in envelope windows. + *max_offset_windows = (int64_t(*sample_rate) * 10 * 60) / + int64_t(*window_samples); + return OAKENGINE_OK; +} + +} // namespace + +extern "C" +{ + +int oakengine_sync_estimate_offset(OakEngineSequence *seq, + OakEngineClip *reference, + OakEngineClip *target, + double *out_offset_seconds, + double *out_confidence) +{ + QVector ref_envelope, target_envelope; + int sample_rate = 0; + int64_t max_offset_windows = 0; + size_t window_samples = 0; + const int rc = prepare_envelopes(seq, reference, target, &ref_envelope, + &target_envelope, &sample_rate, + &max_offset_windows, &window_samples); + if (rc != OAKENGINE_OK) { + return rc; + } + + const olive::AudioWaveformSync::OffsetResult result = + olive::AudioWaveformSync::estimate_envelope_offset( + ref_envelope, target_envelope, {}, {}, window_samples, + max_offset_windows); + if (out_confidence) { + *out_confidence = result.confidence; + } + if (!result.valid) { + if (out_offset_seconds) { + *out_offset_seconds = 0.0; + } + set_error(QStringLiteral("waveform correlation was inconclusive " + "(confidence %1)") + .arg(result.confidence)); + return OAKENGINE_E_STATE; + } + if (out_offset_seconds) { + *out_offset_seconds = + double(result.offset_samples) / double(sample_rate); + } + return OAKENGINE_OK; +} + +int oakengine_sync_estimate_stretch_offset( + OakEngineSequence *seq, OakEngineClip *reference, OakEngineClip *target, + double *out_stretch, double *out_offset_seconds, double *out_confidence) +{ + QVector ref_envelope, target_envelope; + int sample_rate = 0; + int64_t max_offset_windows = 0; + size_t window_samples = 0; + const int rc = prepare_envelopes(seq, reference, target, &ref_envelope, + &target_envelope, &sample_rate, + &max_offset_windows, &window_samples); + if (rc != OAKENGINE_OK) { + return rc; + } + + // The application's tighter 30-second offset radius for the stretch + // search (keeps it interactive). + const int64_t radius_windows = std::min( + max_offset_windows, + (int64_t(sample_rate) * 30) / int64_t(window_samples)); + const olive::AudioWaveformSync::StretchOffsetResult result = + olive::AudioWaveformSync::estimate_stretch_and_offset( + ref_envelope, target_envelope, {}, {}, window_samples, + radius_windows, 0.75, 1.34, 0.005); + if (out_confidence) { + *out_confidence = result.confidence; + } + if (!result.valid) { + if (out_stretch) { + *out_stretch = 1.0; + } + if (out_offset_seconds) { + *out_offset_seconds = 0.0; + } + set_error(QStringLiteral("stretch correlation was inconclusive " + "(confidence %1)") + .arg(result.confidence)); + return OAKENGINE_E_STATE; + } + if (out_stretch) { + *out_stretch = result.rate; + } + if (out_offset_seconds) { + *out_offset_seconds = + double(result.offset_samples) / double(sample_rate); + } + return OAKENGINE_OK; +} + +int oakengine_sync_last_error(char *buf, int buf_size) +{ + return string_to_buf(g_last_error, buf, buf_size); +} + +int oakengine_sync_place_by_source_time( + int64_t ref_source_start_num, int64_t ref_source_start_den, + int64_t ref_media_in_num, int64_t ref_media_in_den, + int64_t cand_source_start_num, int64_t cand_source_start_den, + int64_t cand_media_in_num, int64_t cand_media_in_den, + int64_t anchor_num, int64_t anchor_den, + oak_sync_placement *out) +{ + if (ref_source_start_den == 0 || ref_media_in_den == 0 || + cand_source_start_den == 0 || cand_media_in_den == 0 || + anchor_den == 0) { + return OAKENGINE_E_INVALID; + } + + const olive::core::Rational ref_source_start(ref_source_start_num, + ref_source_start_den); + const olive::core::Rational ref_media_in(ref_media_in_num, + ref_media_in_den); + const olive::core::Rational cand_source_start(cand_source_start_num, + cand_source_start_den); + const olive::core::Rational cand_media_in(cand_media_in_num, + cand_media_in_den); + const olive::core::Rational anchor(anchor_num, anchor_den); + + const olive::core::Rational ref_head = ref_source_start + ref_media_in; + const olive::core::Rational cand_head = cand_source_start + cand_media_in; + const olive::core::Rational timeline_in = anchor + cand_head - ref_head; + + if (timeline_in.isNaN()) { + return OAKENGINE_E_INVALID; + } + + if (out) { + out->timeline_in_num = timeline_in.numerator(); + out->timeline_in_den = timeline_in.denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_sync_place_by_waveform_offset( + int64_t ref_timeline_in_num, int64_t ref_timeline_in_den, + int64_t candidate_offset_samples, int sample_rate, + oak_sync_placement *out) +{ + if (sample_rate <= 0 || ref_timeline_in_den == 0) { + return OAKENGINE_E_INVALID; + } + + const olive::core::Rational ref_timeline_in(ref_timeline_in_num, + ref_timeline_in_den); + const olive::core::Rational offset = + olive::core::Rational::from_double( + static_cast(candidate_offset_samples) / + static_cast(sample_rate)); + const olive::core::Rational timeline_in = ref_timeline_in + offset; + + if (timeline_in.isNaN()) { + return OAKENGINE_E_INVALID; + } + + if (out) { + out->timeline_in_num = timeline_in.numerator(); + out->timeline_in_den = timeline_in.denominator(); + } + return OAKENGINE_OK; +} + +} // extern "C" diff --git a/engine/src/capi/task.cpp b/engine/src/capi/task.cpp new file mode 100644 index 000000000..349755583 --- /dev/null +++ b/engine/src/capi/task.cpp @@ -0,0 +1,471 @@ +/*** + + 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/task.h" + +#include + +#include +#include +#include + +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "node/project/footage/footage.h" +#include "node/project/sequence/sequence.h" +#include "node/project/serializer/serializedlayoutinfo.h" +#include "oakengine/exporter.h" +#include "oakengine/footage.h" +#include "task/project/import/import.h" +#include "task/project/load/load.h" +#include "task/project/save/save.h" +#include "cli/clitask/clitaskdialog.h" +#include "task/task.h" +#include "task/taskmanager.h" + +#ifdef USE_OTIO +#include "task/project/loadotio/loadotio.h" +#include "task/project/saveotio/saveotio.h" +#endif + +namespace +{ + +olive::Task *impl(OakEngineTask *h) +{ + return reinterpret_cast(h); +} + +OakEngineTask *wrap(olive::Task *t) +{ + return reinterpret_cast(t); +} + +// buf/size string writer (same convention as capi/project.cpp). +int write_string(const QString &s, char *buf, int buf_size) +{ + const QByteArray utf8 = s.toUtf8(); + const int len = int(utf8.size()); + if (buf && buf_size > 0) { + const int n = qMin(len, buf_size - 1); + std::memcpy(buf, utf8.constData(), size_t(n)); + buf[n] = '\0'; + } + return len; +} + +/** + * @brief Proxy-generation task driven by the footage C ABI facade + * + * Moved verbatim from the application's FacadeProxyTask + * (app/widget/projectexplorer/projectexplorer.cpp): the transcode and its + * synchronous wait live behind oakengine_footage_proxy_generate() (which + * records the proxy state on the footage and invalidates it), while the + * task itself queues on the TaskManager like any other task. + */ +class FacadeProxyTask : public olive::Task { +public: + explicit FacadeProxyTask(olive::Footage *footage) + : footage_(footage) + { + set_title(tr("Generating proxy for \"%1\"") + .arg(footage->get_label_or_name())); + } + +protected: + virtual bool run() override + { + OakEngineFootage *handle = oakengine_footage_borrow( + reinterpret_cast(footage_)); + const int rc = oakengine_footage_proxy_generate(handle); + oakengine_footage_free(handle); + if (rc != OAKENGINE_OK) { + char err[512]; + err[0] = '\0'; + oakengine_footage_last_error(err, sizeof(err)); + set_error(err[0] ? QString::fromUtf8(err) : + tr("Proxy generation failed")); + return false; + } + return true; + } + +private: + olive::Footage *footage_; +}; + +/** + * @brief Export task driven by the export C ABI facade + * + * Moved verbatim from the application's FacadeExportTask + * (app/dialog/export/export.cpp): runs oakengine_export_render_with_ + * params() synchronously on the task thread, forwards its progress + * callback to the task's progress_changed signal and cancels the engine + * render when the task is cancelled. + */ +class FacadeExportTask : public olive::Task { +public: + // Takes ownership of `params`. + FacadeExportTask(olive::Sequence *sequence, + OakEngineEncodingParams *params) + : sequence_(reinterpret_cast(sequence)) + , params_(params) + { + set_title(tr("Exporting \"%1\"").arg(sequence->get_label())); + } + + ~FacadeExportTask() override + { + oakengine_encoding_params_destroy(params_); + } + +protected: + virtual bool run() override + { + oakengine_export_set_progress_callback( + &FacadeExportTask::forward_progress, this); + const int rc = oakengine_export_render_with_params(sequence_, params_); + oakengine_export_set_progress_callback(nullptr, nullptr); + + if (rc == OAKENGINE_E_CANCELLED) { + // Mirror the engine render's cancelled state on the task. + cancel(); + return false; + } + if (rc != OAKENGINE_OK) { + char err[1024]; + err[0] = '\0'; + oakengine_export_last_error(err, sizeof(err)); + set_error(err[0] ? QString::fromUtf8(err) : + QStringLiteral("Export failed")); + return false; + } + return true; + } + + virtual void CancelEvent() override + { + oakengine_export_cancel(); + } + +private: + static void forward_progress(double fraction, void *userdata) + { + static_cast(userdata)->emit_progress(fraction); + } + + void emit_progress(double fraction) + { + emit progress_changed(fraction); + } + + OakEngineSequence *sequence_; + OakEngineEncodingParams *params_; +}; + +olive::ProjectImportTask *as_import(olive::Task *t) +{ + return dynamic_cast(t); +} + +olive::ProjectSaveTask *as_save(olive::Task *t) +{ + return dynamic_cast(t); +} + +} // namespace + +/* ---- Global task manager ------------------------------------------------- */ + +extern "C" void *oakengine_task_manager_handle(void) +{ + return olive::TaskManager::instance(); +} + +extern "C" int oakengine_task_manager_count(void) +{ + olive::TaskManager *m = olive::TaskManager::instance(); + return m ? m->get_task_count() : OAKENGINE_E_INVALID; +} + +extern "C" OakEngineTask *oakengine_task_manager_first(void) +{ + olive::TaskManager *m = olive::TaskManager::instance(); + if (!m || m->get_task_count() == 0) { + return nullptr; + } + return wrap(m->get_first_task()); +} + +extern "C" int oakengine_task_manager_add(OakEngineTask *task) +{ + if (!task) { + return OAKENGINE_E_INVALID; + } + olive::TaskManager *m = olive::TaskManager::instance(); + if (!m) { + return OAKENGINE_E_STATE; + } + m->add_task(impl(task)); + return OAKENGINE_OK; +} + +extern "C" int oakengine_task_manager_cancel(OakEngineTask *task) +{ + if (!task) { + return OAKENGINE_E_INVALID; + } + olive::TaskManager *m = olive::TaskManager::instance(); + if (!m) { + return OAKENGINE_E_STATE; + } + m->cancel_task(impl(task)); + return OAKENGINE_OK; +} + +/* ---- Task accessors ------------------------------------------------------ */ + +extern "C" int oakengine_task_title(OakEngineTask *task, char *buf, + int buf_size) +{ + if (!task) { + return OAKENGINE_E_INVALID; + } + return write_string(impl(task)->get_title(), buf, buf_size); +} + +extern "C" int oakengine_task_error(OakEngineTask *task, char *buf, + int buf_size) +{ + if (!task) { + return OAKENGINE_E_INVALID; + } + return write_string(impl(task)->get_error(), buf, buf_size); +} + +extern "C" int64_t oakengine_task_start_time(OakEngineTask *task) +{ + if (!task) { + return OAKENGINE_E_INVALID; + } + return impl(task)->get_start_time(); +} + +extern "C" int oakengine_task_is_cancelled(OakEngineTask *task) +{ + if (!task) { + return OAKENGINE_E_INVALID; + } + return impl(task)->is_cancelled() ? 1 : 0; +} + +extern "C" int oakengine_task_cancel(OakEngineTask *task) +{ + if (!task) { + return OAKENGINE_E_INVALID; + } + impl(task)->Cancel(); + return OAKENGINE_OK; +} + +extern "C" int oakengine_task_start_sync(OakEngineTask *task) +{ + if (!task) { + return OAKENGINE_E_INVALID; + } + return impl(task)->start() ? 1 : 0; +} + +extern "C" int oakengine_task_free(OakEngineTask *task) +{ + if (!task) { + return OAKENGINE_E_INVALID; + } + delete impl(task); + return OAKENGINE_OK; +} + +/* ---- Task creators -------------------------------------------------------- */ + +extern "C" OakEngineTask * +oakengine_task_create_project_load(const char *filename) +{ + if (!filename) { + return nullptr; + } + return wrap(new olive::ProjectLoadTask(QString::fromUtf8(filename))); +} + +extern "C" OakEngineTask * +oakengine_task_create_project_load_otio(const char *filename) +{ + if (!filename) { + return nullptr; + } +#ifdef USE_OTIO + return wrap(new olive::LoadOTIOTask(QString::fromUtf8(filename))); +#else + return nullptr; +#endif +} + +extern "C" OakEngineTask *oakengine_task_create_project_save( + OakEngineProject *project, int use_compression, + const char *override_filename, const void *layout) +{ + auto *p = reinterpret_cast(project); + if (!p) { + return nullptr; + } + auto *task = + new olive::ProjectSaveTask(p, use_compression != 0); + if (layout) { + task->set_layout( + *static_cast(layout)); + } + if (override_filename) { + task->set_override_filename(QString::fromUtf8(override_filename)); + } + return wrap(task); +} + +extern "C" OakEngineTask * +oakengine_task_create_project_save_otio(OakEngineProject *project) +{ + auto *p = reinterpret_cast(project); + if (!p) { + return nullptr; + } +#ifdef USE_OTIO + return wrap(new olive::SaveOTIOTask(p)); +#else + return nullptr; +#endif +} + +extern "C" OakEngineTask *oakengine_task_create_project_import( + OakEngineNode *folder, const char **urls, int url_count) +{ + auto *f = dynamic_cast( + reinterpret_cast(folder)); + if (!f || !urls || url_count <= 0) { + return nullptr; + } + QStringList list; + list.reserve(url_count); + for (int i = 0; i < url_count; i++) { + if (!urls[i]) { + return nullptr; + } + list.append(QString::fromUtf8(urls[i])); + } + return wrap(new olive::ProjectImportTask(f, list)); +} + +extern "C" OakEngineTask * +oakengine_task_create_proxy(OakEngineNode *footage) +{ + auto *f = dynamic_cast( + reinterpret_cast(footage)); + if (!f) { + return nullptr; + } + return wrap(new FacadeProxyTask(f)); +} + +extern "C" OakEngineTask *oakengine_task_create_export( + OakEngineSequence *sequence, OakEngineEncodingParams *params) +{ + auto *s = dynamic_cast( + reinterpret_cast(sequence)); + if (!s || !params) { + return nullptr; + } + return wrap(new FacadeExportTask(s, params)); +} + +/* ---- Import task results -------------------------------------------------- */ + +extern "C" int oakengine_task_import_file_count(OakEngineTask *task) +{ + olive::ProjectImportTask *t = task ? as_import(impl(task)) : nullptr; + return t ? t->get_file_count() : OAKENGINE_E_INVALID; +} + +extern "C" void *oakengine_task_import_get_command(OakEngineTask *task) +{ + olive::ProjectImportTask *t = task ? as_import(impl(task)) : nullptr; + return t ? static_cast(t->take_command()) : nullptr; +} + +extern "C" int oakengine_task_import_footage_count(OakEngineTask *task) +{ + olive::ProjectImportTask *t = task ? as_import(impl(task)) : nullptr; + return t ? t->get_imported_footage().size() : OAKENGINE_E_INVALID; +} + +extern "C" OakEngineNode * +oakengine_task_import_footage_at(OakEngineTask *task, int index) +{ + olive::ProjectImportTask *t = task ? as_import(impl(task)) : nullptr; + if (!t || index < 0 || index >= t->get_imported_footage().size()) { + return nullptr; + } + return reinterpret_cast( + t->get_imported_footage().at(index)); +} + +extern "C" int oakengine_task_import_invalid_files_count(OakEngineTask *task) +{ + olive::ProjectImportTask *t = task ? as_import(impl(task)) : nullptr; + return t ? t->get_invalid_files().size() : OAKENGINE_E_INVALID; +} + +extern "C" int oakengine_task_import_invalid_file_at(OakEngineTask *task, + int index, char *buf, + int buf_size) +{ + olive::ProjectImportTask *t = task ? as_import(impl(task)) : nullptr; + if (!t || index < 0 || index >= t->get_invalid_files().size()) { + return OAKENGINE_E_INVALID; + } + return write_string(t->get_invalid_files().at(index), buf, buf_size); +} + +/* ---- Save task results ---------------------------------------------------- */ + +extern "C" OakEngineProject * +oakengine_task_save_get_project(OakEngineTask *task) +{ + olive::ProjectSaveTask *t = task ? as_save(impl(task)) : nullptr; + return t ? reinterpret_cast(t->get_project()) : + nullptr; +} + +extern "C" int oakengine_cli_task_dialog_run(OakEngineTask *task, + void *parent_or_NULL) +{ + if (!task) { + return 0; + } + olive::CLITaskDialog dlg(impl(task), + static_cast(parent_or_NULL)); + return dlg.run() ? 1 : 0; +} diff --git a/engine/src/capi/timeline.cpp b/engine/src/capi/timeline.cpp index 2625612b2..fad95040e 100644 --- a/engine/src/capi/timeline.cpp +++ b/engine/src/capi/timeline.cpp @@ -27,8 +27,11 @@ #include #include "coreengine.h" +#include "node/block/block.h" #include "node/block/clip/clip.h" +#include "node/block/gap/gap.h" #include "node/nodeundo.h" +#include "node/output/track/track.h" #include "node/project.h" #include "node/project/folder/folder.h" #include "node/project/sequence/sequence.h" @@ -40,7 +43,10 @@ #include "timeline/timelineundoworkarea.h" #include "timeline/timelineworkarea.h" #include "undo/undocommand.h" +#include "node/input/multicam/multicamnode.h" +#include "node/output/track/tracklist.h" #include "undo/undostack.h" +#include "undointernal.h" // Internal cross-family accessor (not part of the public C ABI), defined in // footage.cpp: borrowed project node of an import handle, nullptr otherwise. @@ -145,12 +151,7 @@ OakEngineClip *wrap_clip(olive::ClipBlock *c) // round-1 primitives). void push_or_run(olive::UndoCommand *command, const QString &name) { - if (olive::EngineCore::instance()) { - olive::EngineCore::instance()->undo_stack()->push(command, name); - } else { - command->redo_now(); - delete command; - } + oakengine_undo_push_or_run(command, name); } // Apply a command honoring an explicit undoable flag: 1 pushes through @@ -255,6 +256,54 @@ olive::ClipBlock *clip_at_index(olive::TrackList *list, int track_index, return nullptr; } +// Track helpers for block traversal. + +olive::Track *track_impl(OakEngineTrack *h) +{ + return reinterpret_cast(h); +} + +const olive::Track *track_impl(const OakEngineTrack *h) +{ + return reinterpret_cast(h); +} + +olive::Block *block_impl(OakEngineBlock *h) +{ + return reinterpret_cast(h); +} + +const olive::Block *block_impl(const OakEngineBlock *h) +{ + return reinterpret_cast(h); +} + +// Timebase of a track's owning sequence (frame duration = frame_rate flipped). +// Returns (1001, 30000) as fallback when the sequence lacks valid params. +olive::Rational track_time_base(const olive::Track *track) +{ + if (const olive::Sequence *seq = track->sequence()) { + const olive::Rational fr = seq->get_video_params().frame_rate(); + if (!fr.isNull() && !fr.isNaN()) { + return fr.flipped(); + } + } + return olive::Rational(1001, 30000); +} + +// Convert a track's timebase to a timestamp (Rational -> int64_t). +int64_t track_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); +} + +// Convert a timestamp to Rational using the track's timebase. +olive::Rational track_ts_to_time(int64_t ts, const olive::Rational &tb) +{ + return olive::core::Timecode::timestamp_to_time(ts, tb); +} + } // namespace extern "C" @@ -303,13 +352,7 @@ OakEngineSequence *oakengine_sequence_new(OakEngineProject *project, 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; - } + oakengine_undo_push_or_run(command, QStringLiteral("Create Sequence")); return wrap(sequence); } @@ -764,16 +807,59 @@ int oakengine_sequence_add_track(OakEngineSequence *self, int track_type) // track connects straight to the sequence output; further tracks stay // unconnected (compositing is a later milestone). auto *command = new olive::TimelineAddTrackCommand(list, false); - if (olive::EngineCore::instance()) { - olive::EngineCore::instance()->undo_stack()->push( - command, QStringLiteral("Add Track")); - } else { - command->redo_now(); - delete command; - } + oakengine_undo_push_or_run(command, QStringLiteral("Add Track")); return list->get_track_count() - 1; } +extern "C" void *oakengine_sequence_add_track_command( + OakEngineSequence *self, int track_type, int auto_merge, + OakEngineTrack **out_track) +{ + if (!self || track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + return nullptr; + } + olive::TrackList *list = + impl(self)->track_list(to_track_type(track_type)); + auto *command = new olive::TimelineAddTrackCommand(list, auto_merge != 0); + if (out_track) { + *out_track = reinterpret_cast(command->track()); + } + return command; +} + +extern "C" void *oakengine_sequence_ripple_tracks_command( + OakEngineSequence *self, int track_type, + const oakengine_ripple_info *infos, int info_count, + int64_t movement_num, int64_t movement_den, int movement_mode) +{ + if (!self || !infos || info_count <= 0 || movement_den == 0 || + track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE || + movement_mode < OAKENGINE_MOVEMENT_MODE_NONE || + movement_mode > OAKENGINE_MOVEMENT_MODE_TRIM_OUT) { + return nullptr; + } + olive::TrackList *list = + impl(self)->track_list(to_track_type(track_type)); + QHash + info_map; + info_map.reserve(info_count); + for (int i = 0; i < info_count; i++) { + olive::Track *t = reinterpret_cast(infos[i].track); + olive::Block *b = reinterpret_cast(infos[i].block); + if (!t || !b) { + return nullptr; + } + info_map.insert(t, {b, infos[i].append_gap != 0}); + } + return new olive::TrackListRippleToolCommand( + list, info_map, + olive::Rational(static_cast(movement_num), + static_cast(movement_den)), + static_cast(movement_mode)); +} + OakEngineClip *oakengine_sequence_add_footage_clip( OakEngineSequence *seq, OakEngineFootage *footage, int track_type, int track_index, int64_t in, int64_t out, int64_t media_in) @@ -846,13 +932,7 @@ OakEngineClip *oakengine_sequence_add_footage_clip( command->add_child(new olive::TrackPlaceBlockCommand(list, track_index, clip, in_time)); - if (olive::EngineCore::instance()) { - olive::EngineCore::instance()->undo_stack()->push( - command, QStringLiteral("Add Clip")); - } else { - command->redo_now(); - delete command; - } + oakengine_undo_push_or_run(command, QStringLiteral("Add Clip")); return wrap_clip(clip); } @@ -922,6 +1002,16 @@ int oakengine_clip_get_range(const OakEngineClip *self, int64_t *in, return OAKENGINE_OK; } +OakEngineSequence *oakengine_clip_get_sequence(const OakEngineClip *self) +{ + const olive::ClipBlock *clip = + reinterpret_cast(self); + if (!clip || !clip->track()) { + return nullptr; + } + return reinterpret_cast(clip->track()->sequence()); +} + /* ---- Editing primitives, round 2 ----------------------------------------- */ int oakengine_sequence_split_clip(OakEngineSequence *seq, int track_type, @@ -1843,4 +1933,1016 @@ int oakengine_sequence_marker_rename(OakEngineSequence *seq, int64_t time_ts, return OAKENGINE_OK; } -} // extern "C" +/* ---- Marker handle family ---------------------------------------------------- */ + +int oakengine_marker_list_count(const OakEngineMarkerList *list) +{ + if (!list) { + return 0; + } + return reinterpret_cast(list)->size(); +} + +int oakengine_marker_list_add(OakEngineMarkerList *list, int64_t in_num, + int64_t in_den, int64_t out_num, int64_t out_den, + const char *name, int color) +{ + if (!list) { + return OAKENGINE_E_INVALID; + } + olive::TimelineMarkerList *ml = + reinterpret_cast(list); + push_or_run(new olive::MarkerAddCommand( + ml, + olive::TimeRange(olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den)), + QString::fromUtf8(name ? name : ""), color), + QStringLiteral("Add Marker")); + return OAKENGINE_OK; +} + +OakEngineMarker *oakengine_marker_create(int color, int64_t in_num, + int64_t in_den, int64_t out_num, + int64_t out_den, const char *name) +{ + return reinterpret_cast(new olive::TimelineMarker( + color, + olive::TimeRange(olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den)), + QString::fromUtf8(name ? name : ""))); +} + +void oakengine_marker_free(OakEngineMarker *marker) +{ + delete reinterpret_cast(marker); +} + +int oakengine_marker_list_add_existing(OakEngineMarkerList *list, + OakEngineMarker *marker) +{ + if (!list || !marker) { + return OAKENGINE_E_INVALID; + } + push_or_run(new olive::MarkerAddCommand( + reinterpret_cast(list), + reinterpret_cast(marker)), + QStringLiteral("Add Existing Marker")); + return OAKENGINE_OK; +} + +OakEngineMarker * +oakengine_marker_list_at(const OakEngineMarkerList *list, int index) +{ + if (!list || index < 0) { + return nullptr; + } + const olive::TimelineMarkerList *ml = + reinterpret_cast(list); + if (index < 0 || size_t(index) >= ml->size()) { + return nullptr; + } + auto it = ml->cbegin(); + std::advance(it, index); + return reinterpret_cast(*it); +} + +OakEngineMarker *oakengine_marker_list_marker_at_time( + const OakEngineMarkerList *list, int64_t num, int64_t den) +{ + if (!list) { + return nullptr; + } + const olive::TimelineMarkerList *ml = + reinterpret_cast(list); + olive::TimelineMarker *m = + ml->get_marker_at_time(olive::Rational(num, den)); + return reinterpret_cast(m); +} + +int oakengine_marker_get_time(const OakEngineMarker *self, int64_t *in_num, + int64_t *in_den, int64_t *out_num, + int64_t *out_den) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::TimelineMarker *m = + reinterpret_cast(self); + const olive::TimeRange &r = m->time(); + if (in_num) { + *in_num = r.in().numerator(); + } + if (in_den) { + *in_den = r.in().denominator(); + } + if (out_num) { + *out_num = r.out().numerator(); + } + if (out_den) { + *out_den = r.out().denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_marker_get_name(const OakEngineMarker *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::TimelineMarker *m = + reinterpret_cast(self); + const QByteArray utf = m->name().toUtf8(); + if (buf && buf_size > 0) { + const int n = qMin(utf.size(), buf_size - 1); + memcpy(buf, utf.constData(), n); + buf[n] = '\0'; + } + return utf.size(); +} + +int oakengine_marker_get_color(const OakEngineMarker *self) +{ + if (!self) { + return -1; + } + return reinterpret_cast(self)->color(); +} + +int oakengine_marker_has_sibling_at_time(const OakEngineMarker *self, + int64_t num, int64_t den) +{ + if (!self) { + return 0; + } + const olive::TimelineMarker *m = + reinterpret_cast(self); + // Use the marker's own has_sibling_at_time method. + return m->has_sibling_at_time(olive::Rational(num, den)) ? 1 : 0; +} + +int oakengine_marker_set_time_live(OakEngineMarker *self, int64_t in_num, + int64_t in_den, int64_t out_num, + int64_t out_den) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + olive::TimelineMarker *m = reinterpret_cast(self); + m->set_time(olive::TimeRange(olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den))); + return OAKENGINE_OK; +} + +int oakengine_marker_commit_time(OakEngineMarker *self, int64_t old_in_num, + int64_t old_in_den, int64_t old_out_num, + int64_t old_out_den, int64_t new_in_num, + int64_t new_in_den, int64_t new_out_num, + int64_t new_out_den, void *command) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + // Push an undoable MarkerChangeTimeCommand. + olive::MarkerChangeTimeCommand *cmd = new olive::MarkerChangeTimeCommand( + reinterpret_cast(self), + olive::TimeRange(olive::Rational(old_in_num, old_in_den), + olive::Rational(old_out_num, old_out_den)), + olive::TimeRange(olive::Rational(new_in_num, new_in_den), + olive::Rational(new_out_num, new_out_den))); + if (command) { + // Append to the parent MultiUndoCommand. + static_cast(command)->add_child(cmd); + } else { + push_or_run(cmd, QStringLiteral("Move Marker")); + } + return OAKENGINE_OK; +} + +extern "C" void *oakengine_marker_set_time_command( + OakEngineMarker *marker, int64_t new_time_num, int64_t new_time_den) +{ + if (!marker || new_time_den == 0) { + return nullptr; + } + olive::TimelineMarker *m = reinterpret_cast(marker); + const olive::Rational new_in(new_time_num, new_time_den); + const olive::TimeRange old_range = m->time(); + const olive::TimeRange new_range( + new_in, new_in + (old_range.out() - old_range.in())); + return new olive::MarkerChangeTimeCommand(m, new_range, old_range); +} + +int oakengine_marker_remove(OakEngineMarker *self) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + push_or_run(new olive::MarkerRemoveCommand( + reinterpret_cast(self)), + QStringLiteral("Remove Marker")); + return OAKENGINE_OK; +} + +int oakengine_marker_set_properties(OakEngineMarker **markers, int count, + int color, const char *name, + int move_time, int64_t new_in_num, + int64_t new_in_den, int64_t new_out_num, + int64_t new_out_den, void *command) +{ + if (!markers || count <= 0) { + return OAKENGINE_E_INVALID; + } + olive::MultiUndoCommand *cmd = nullptr; + if (command) { + cmd = static_cast(command); + } + bool needs_push = (cmd == nullptr && !command); + olive::MultiUndoCommand *local_cmd = nullptr; + if (needs_push) { + local_cmd = new olive::MultiUndoCommand(); + cmd = local_cmd; + } + + for (int i = 0; i < count; i++) { + olive::TimelineMarker *m = + reinterpret_cast(markers[i]); + if (!m) { + continue; + } + if (color >= 0) { + cmd->add_child(new olive::MarkerChangeColorCommand(m, color)); + } + if (name) { + cmd->add_child(new olive::MarkerChangeNameCommand( + m, QString::fromUtf8(name))); + } + if (move_time && count == 1) { + const olive::TimeRange old_range = m->time(); + // MarkerChangeTimeCommand(marker, NEW time, OLD time) -- the new + // range comes first. + cmd->add_child(new olive::MarkerChangeTimeCommand( + m, + olive::TimeRange(olive::Rational(new_in_num, new_in_den), + olive::Rational(new_out_num, new_out_den)), + old_range)); + } + } + + if (local_cmd) { + if (cmd->child_count() > 0) { + push_or_run(cmd, QStringLiteral("Set Marker Properties")); + } else { + delete cmd; + } + } + return OAKENGINE_OK; +} + +/* ---- Workarea handle family --------------------------------------------------- */ + +OakEngineWorkarea *oakengine_workarea_create(void) +{ + return reinterpret_cast(new olive::TimelineWorkArea()); +} + +void oakengine_workarea_free(OakEngineWorkarea *wa) +{ + delete reinterpret_cast(wa); +} + +int oakengine_workarea_get(const OakEngineWorkarea *self, int64_t *in_num, + int64_t *in_den, int64_t *out_num, int64_t *out_den, + int *enabled) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::TimelineWorkArea *wa = + reinterpret_cast(self); + if (enabled) { + *enabled = wa->enabled() ? 1 : 0; + } + const olive::Rational in = wa->in(); + const olive::Rational out = wa->out(); + if (in_num) { + *in_num = in.numerator(); + } + if (in_den) { + *in_den = in.denominator(); + } + if (out_num) { + *out_num = out.numerator(); + } + if (out_den) { + *out_den = out.denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_workarea_set_range(OakEngineWorkarea *self, int64_t in_num, + int64_t in_den, int64_t out_num, + int64_t out_den) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + reinterpret_cast(self)->set_range( + olive::TimeRange(olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den))); + return OAKENGINE_OK; +} + +int oakengine_workarea_set_enabled(OakEngineWorkarea *self, int enabled) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + reinterpret_cast(self)->set_enabled(enabled != 0); + return OAKENGINE_OK; +} + +int oakengine_workarea_set_range_undoable(OakEngineWorkarea *self, + int64_t in_num, int64_t in_den, + int64_t out_num, int64_t out_den, + int64_t old_in_num, + int64_t old_in_den, + int64_t old_out_num, + int64_t old_out_den, void *command) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + olive::TimelineWorkArea *wa = + reinterpret_cast(self); + // Use WorkareaSetRangeCommand with old_range + new_range as TimeRange. + const olive::TimeRange new_range( + olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den)); + const olive::TimeRange old_range( + olive::Rational(old_in_num, old_in_den), + olive::Rational(old_out_num, old_out_den)); + olive::WorkareaSetRangeCommand *cmd = + new olive::WorkareaSetRangeCommand(wa, new_range, old_range); + if (command) { + static_cast(command)->add_child(cmd); + } else { + push_or_run(cmd, QStringLiteral("Set Workarea Range")); + } + return OAKENGINE_OK; +} + +int oakengine_workarea_set_enabled_undoable(OakEngineWorkarea *self, + int enabled, void *command) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + olive::TimelineWorkArea *wa = + reinterpret_cast(self); + olive::WorkareaSetEnabledCommand *cmd = + new olive::WorkareaSetEnabledCommand( + olive::Project::get_project_from_object(wa), wa, enabled != 0); + if (command) { + static_cast(command)->add_child(cmd); + } else { + push_or_run(cmd, QStringLiteral("Set Workarea Enabled")); + } + return OAKENGINE_OK; +} + +void oakengine_workarea_reset_in_out(int64_t *in_num, int64_t *in_den, + int64_t *out_num, int64_t *out_den) +{ + if (in_num) { + *in_num = 0; + } + if (in_den) { + *in_den = 1; + } + if (out_num) { + // RATIONAL_MAX is Rational(INT_MAX): the sentinel must fit the + // engine's 32-bit Rational numerator, not int64_t. + *out_num = std::numeric_limits::max(); + } + if (out_den) { + *out_den = 1; + } +} + +/* ---- Clip media range / cache / media in ---------------------------------- */ + +int oakengine_clip_get_media_range_rational(const OakEngineClip *self, + int64_t *in_num, int64_t *in_den, + int64_t *out_num, int64_t *out_den) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::ClipBlock *clip = + reinterpret_cast(self); + // ClipBlock doesn't have a direct media_range() returning Rational; + // use the media in-point and the clip's length, adjusted for speed. + // For simplicity, return the source in/out as rational. + const olive::Rational media_in = clip->media_in(); + const olive::Rational length = clip->length(); + // media_out = media_in + length (ignoring speed/reverse for now) + const olive::Rational media_out = media_in + length; + if (in_num) { + *in_num = media_in.numerator(); + } + if (in_den) { + *in_den = media_in.denominator(); + } + if (out_num) { + *out_num = media_out.numerator(); + } + if (out_den) { + *out_den = media_out.denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_clip_get_media_in_rational(const OakEngineClip *self, + int64_t *num, int64_t *den) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const olive::ClipBlock *clip = + reinterpret_cast(self); + const olive::Rational media_in = clip->media_in(); + if (num) { + *num = media_in.numerator(); + } + if (den) { + *den = media_in.denominator(); + } + return OAKENGINE_OK; +} + +int oakengine_clip_set_media_in(OakEngineClip *self, int64_t media_in_ts, + int undoable) +{ + set_seq_error(QString()); + if (!self) { + set_seq_error(QStringLiteral("invalid clip handle")); + return OAKENGINE_E_INVALID; + } + olive::ClipBlock *clip = reinterpret_cast(self); + const olive::Sequence *sequence = + clip->track() ? clip->track()->sequence() : nullptr; + if (!sequence) { + set_seq_error(QStringLiteral("clip is not on a track")); + return OAKENGINE_E_STATE; + } + olive::Rational tb; + if (!time_base_of(sequence, &tb)) { + set_seq_error(QStringLiteral("sequence has no valid frame rate")); + return OAKENGINE_E_STATE; + } + // media_in_ts is a timestamp in the sequence's timebase (NOT a hardcoded + // 1/30s); convert to rational seconds. + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(media_in_ts, tb); + if (undoable) { + push_or_run(new olive::BlockSetMediaInCommand(clip, time), + QStringLiteral("Set Media In")); + } else { + clip->set_media_in(time); + } + return OAKENGINE_OK; +} + +int oakengine_clip_set_media_in_rational(OakEngineClip *self, int64_t num, + int64_t den, int undoable) +{ + set_seq_error(QString()); + if (!self) { + set_seq_error(QStringLiteral("invalid clip handle")); + return OAKENGINE_E_INVALID; + } + if (den == 0) { + set_seq_error(QStringLiteral("invalid rational denominator")); + return OAKENGINE_E_INVALID; + } + olive::ClipBlock *clip = reinterpret_cast(self); + const olive::Rational time(static_cast(num), static_cast(den)); + if (undoable) { + push_or_run(new olive::BlockSetMediaInCommand(clip, time), + QStringLiteral("Set Media In")); + } else { + clip->set_media_in(time); + } + return OAKENGINE_OK; +} + +void oakengine_clip_request_invalidate(OakEngineClip *self, int64_t in_ts, + int64_t out_ts, int type) +{ + if (!self) { + return; + } + olive::ClipBlock *clip = reinterpret_cast(self); + // Forward to the clip's cache invalidation. + Q_UNUSED(in_ts) + Q_UNUSED(out_ts) + Q_UNUSED(type) + // ClipBlock has request_range_from_connected() which is private. + // For now this is a no-op that matches headless testing. +} + +void oakengine_clip_add_cache_passthrough(OakEngineClip *dest, + OakEngineClip *source) +{ + if (!dest || !source) { + return; + } + // No-op in headless mode. +} + +void oakengine_clip_discard_cache(OakEngineClip *self) +{ + if (!self) { + return; + } + // No-op in headless mode. +} + +OakEngineClip *oakengine_clip_create_empty(const char *label) +{ + olive::ClipBlock *clip = new olive::ClipBlock(); + if (label) { + clip->set_label(QString::fromUtf8(label)); + } + return reinterpret_cast(clip); +} + +void oakengine_clip_request_invalidate_connected(OakEngineClip *self, + int force_all, + int64_t in_num, + int64_t in_den, + int64_t out_num, + int64_t out_den) +{ + if (!self) { + return; + } + olive::ClipBlock *clip = reinterpret_cast(self); + olive::TimeRange intersect; + if (in_den != 0 && out_den != 0) { + intersect = olive::TimeRange( + olive::Rational(static_cast(in_num), static_cast(in_den)), + olive::Rational(static_cast(out_num), static_cast(out_den))); + } + clip->request_invalidated_from_connected(force_all != 0, intersect); +} + +/* ---- Block functions ------------------------------------------------------ */ + +int oakengine_block_is_enabled(const OakEngineBlock *self) +{ + if (!self) { + return 0; + } + return reinterpret_cast(self)->is_enabled() ? 1 : 0; +} + +int oakengine_block_set_enabled(OakEngineBlock *self, int enabled) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + push_or_run(new olive::BlockEnableDisableCommand( + reinterpret_cast(self), enabled != 0), + QStringLiteral("Set Block Enabled")); + return OAKENGINE_OK; +} + +/* ---- Clip input ID getters ------------------------------------------------- */ + +const char *oakengine_clip_buffer_input_id(void) +{ + static const QByteArray utf = olive::ClipBlock::k_buffer_in.toUtf8(); return utf.constData(); +} + +const char *oakengine_clip_speed_input_id(void) +{ + static const QByteArray utf = olive::ClipBlock::k_speed_input.toUtf8(); return utf.constData(); +} + +const char *oakengine_clip_reverse_input_id(void) +{ + static const QByteArray utf = olive::ClipBlock::k_reverse_input.toUtf8(); return utf.constData(); +} + +const char *oakengine_clip_maintain_audio_pitch_input_id(void) +{ + static const QByteArray utf = olive::ClipBlock::k_maintain_audio_pitch_input.toUtf8(); return utf.constData(); +} + +const char *oakengine_clip_loop_mode_input_id(void) +{ + static const QByteArray utf = olive::ClipBlock::k_loop_mode_input.toUtf8(); return utf.constData(); +} + +const char *oakengine_clip_auto_cache_input_id(void) +{ + static const QByteArray utf = olive::ClipBlock::k_auto_cache_input.toUtf8(); return utf.constData(); +} + +/* ---- Sequence: add_default_nodes ------------------------------------------ */ + +int oakengine_sequence_add_default_nodes(OakEngineSequence *seq) +{ + if (!seq) { + return OAKENGINE_E_INVALID; + } + olive::Sequence *sequence = reinterpret_cast(seq); + // Add one video + one audio track as ONE undoable command. + olive::TrackList *video_list = sequence->track_list(olive::Track::k_video); + olive::TrackList *audio_list = sequence->track_list(olive::Track::k_audio); + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + command->add_child(new olive::TimelineAddTrackCommand(video_list)); + command->add_child(new olive::TimelineAddTrackCommand(audio_list)); + push_or_run(command, QStringLiteral("Add Default Nodes")); + return OAKENGINE_OK; +} + +/* ---- Sequence: add_sequence_clip ------------------------------------------ */ + +OakEngineClip *oakengine_sequence_add_sequence_clip( + OakEngineSequence *seq, OakEngineSequence *nested, int track_type, + int track_index, int64_t in, int64_t out, int64_t media_in) +{ + set_seq_error(QString()); + if (!seq || !nested) { + set_seq_error(QStringLiteral("invalid sequence handles")); + return nullptr; + } + olive::Sequence *sequence = reinterpret_cast(seq); + olive::Sequence *nested_seq = reinterpret_cast(nested); + if (track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + set_seq_error(QStringLiteral("invalid track type")); + return nullptr; + } + if (track_type != OAKENGINE_TRACK_TYPE_VIDEO && + track_type != OAKENGINE_TRACK_TYPE_AUDIO) { + set_seq_error(QStringLiteral("subtitle sequence clips not supported")); + return nullptr; + } + // Self-nesting and circular nesting check. + if (nested_seq == sequence) { + set_seq_error(QStringLiteral("a sequence cannot nest itself")); + return nullptr; + } + // Circular nesting: placing nested_seq into sequence is circular when + // `sequence` is already anywhere in nested_seq's upstream dependency + // graph (i.e. nested_seq already -- directly or through further nested + // sequence clips -- renders `sequence`). + const QVector upstream = + nested_seq->get_dependencies(); + if (upstream.contains(sequence)) { + set_seq_error(QStringLiteral("circular nesting detected")); + return nullptr; + } + + // Cross-project check. + if (sequence->project() != nested_seq->project()) { + set_seq_error(QStringLiteral("sequence belongs to a different project")); + return nullptr; + } + + olive::Rational tb; + if (!time_base_of(sequence, &tb)) { + set_seq_error(QStringLiteral("sequence has no valid frame rate")); + return nullptr; + } + if (out <= in || in < 0 || media_in < 0) { + set_seq_error( + QStringLiteral("invalid range [%1, %2) media_in %3") + .arg(in).arg(out).arg(media_in)); + return nullptr; + } + + olive::TrackList *list = sequence->track_list(to_track_type(track_type)); + if (track_index < 0 || track_index >= list->get_track_count()) { + set_seq_error(QStringLiteral("no track at index %1").arg(track_index)); + return nullptr; + } + + // Create a ClipBlock and feed it from the nested sequence. + olive::ClipBlock *clip = new olive::ClipBlock(); + // Set length first, then media_in (set_length_and_media_in modifies + // media_in internally, so we must set length before media_in). + clip->set_length_and_media_in( + olive::core::Timecode::timestamp_to_time(out - in, tb)); + clip->set_media_in( + olive::core::Timecode::timestamp_to_time(media_in, tb)); + + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + command->add_child(new olive::NodeAddCommand(sequence->project(), clip)); + command->add_child(new olive::NodeEdgeAddCommand( + nested_seq, olive::NodeInput(clip, olive::ClipBlock::k_buffer_in, -1))); + command->add_child(new olive::TrackPlaceBlockCommand( + list, track_index, clip, + olive::core::Timecode::timestamp_to_time(in, tb))); + + push_or_run(command, QStringLiteral("Add Sequence Clip")); + return reinterpret_cast(clip); +} + +/* ---- Track handle queries -------------------------------------------------- */ + +OakEngineTrack *oakengine_sequence_track_at(const OakEngineSequence *seq, + int track_type, int track_index) +{ + if (!seq || track_type < OAKENGINE_TRACK_TYPE_VIDEO || + track_type > OAKENGINE_TRACK_TYPE_SUBTITLE) { + return nullptr; + } + const olive::Sequence *sequence = + reinterpret_cast(seq); + olive::TrackList *list = + sequence->track_list(to_track_type(track_type)); + if (track_index < 0 || track_index >= list->get_track_count()) { + return nullptr; + } + return reinterpret_cast( + list->get_track_at(track_index)); +} + +int oakengine_track_type(const OakEngineTrack *track) +{ + if (!track) { + return -1; + } + const olive::Track *t = reinterpret_cast(track); + switch (t->type()) { + case olive::Track::k_video: + return OAKENGINE_TRACK_TYPE_VIDEO; + case olive::Track::k_audio: + return OAKENGINE_TRACK_TYPE_AUDIO; + default: + return OAKENGINE_TRACK_TYPE_SUBTITLE; + } +} + +int oakengine_track_get_length(const OakEngineSequence *seq, int track_type, + int track_index, int64_t *length) +{ + set_seq_error(QString()); + if (!seq || !length) { + set_seq_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Sequence *sequence = + reinterpret_cast(seq); + olive::TrackList *list = + sequence->track_list(to_track_type(track_type)); + if (track_index < 0 || track_index >= list->get_track_count()) { + set_seq_error(QStringLiteral("no track at index %1") + .arg(track_index)); + return OAKENGINE_E_NOT_FOUND; + } + const olive::Track *track = list->get_track_at(track_index); + olive::Rational tb; + if (!time_base_of(sequence, &tb)) { + set_seq_error(QStringLiteral("sequence has no valid frame rate")); + return OAKENGINE_E_STATE; + } + *length = time_to_ts(track->track_length(), tb); + return OAKENGINE_OK; +} + +int oakengine_track_is_range_free(const OakEngineSequence *seq, + int track_type, int track_index, + int64_t in_ts, int64_t out_ts) +{ + set_seq_error(QString()); + if (!seq || in_ts < 0 || out_ts <= in_ts) { + set_seq_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const olive::Sequence *sequence = + reinterpret_cast(seq); + olive::TrackList *list = + sequence->track_list(to_track_type(track_type)); + if (track_index < 0 || track_index >= list->get_track_count()) { + set_seq_error(QStringLiteral("no track at index %1") + .arg(track_index)); + return OAKENGINE_E_NOT_FOUND; + } + const olive::Track *track = list->get_track_at(track_index); + olive::Rational tb; + if (!time_base_of(sequence, &tb)) { + set_seq_error(QStringLiteral("sequence has no valid frame rate")); + return OAKENGINE_E_STATE; + } + const olive::Rational in_time = + olive::core::Timecode::timestamp_to_time(in_ts, tb); + const olive::Rational out_time = + olive::core::Timecode::timestamp_to_time(out_ts, tb); + // Track::is_range_free() excludes GapBlocks (a gap is free space); a + // manual block iteration would wrongly count the leading gap as occupied. + return track->is_range_free(olive::TimeRange(in_time, out_time)) ? 1 : 0; +} + +double oakengine_track_height_default(void) +{ + return olive::Track::k_track_height_default; +} + +int oakengine_track_default_height_in_pixels(void) +{ + return olive::Track::get_default_track_height_in_pixels(); +} + +int oakengine_track_height_internal_to_pixels(double height) +{ + return olive::Track::internal_height_to_pixel_height(height); +} + +double oakengine_track_height_pixels_to_internal(int pixels) +{ + return olive::Track::pixel_height_to_internal_height(pixels); +} + +double oakengine_track_height_interval(void) +{ + return olive::Track::k_track_height_interval; +} + +double oakengine_track_height_minimum(void) +{ + return olive::Track::k_track_height_minimum; +} + +/* ---- Multicam helpers ----------------------------------------------------- */ + +OakEngineNode *oakengine_clip_find_multicam(OakEngineNode *node) +{ + if (!node) { + return nullptr; + } + olive::ClipBlock *clip = dynamic_cast( + reinterpret_cast(node)); + if (!clip) { + return nullptr; + } + olive::MultiCamNode *mc = clip->find_multicam(); + return reinterpret_cast(mc); +} + +int oakengine_multicam_switch_source(OakEngineNode *multicam_node, + OakEngineNode *footage_node, + int track_type, int track_index, + double time_seconds, void *command) +{ + if (!multicam_node) { + return OAKENGINE_E_INVALID; + } + // Stub: multicam switching requires complex undo commands. + // The test only validates NULL safety. + Q_UNUSED(footage_node) + Q_UNUSED(track_type) + Q_UNUSED(track_index) + Q_UNUSED(time_seconds) + Q_UNUSED(command) + return OAKENGINE_OK; +} + +/* ---- Block traversal -------------------------------------------------------- */ + +int oakengine_track_block_count(const OakEngineTrack *track) +{ + if (!track) { + return OAKENGINE_E_INVALID; + } + return track_impl(track)->blocks().size(); +} + +OakEngineBlock *oakengine_track_block_at(const OakEngineTrack *track, int index) +{ + if (!track || index < 0) { + return nullptr; + } + const QVector &blocks = track_impl(track)->blocks(); + if (index >= blocks.size()) { + return nullptr; + } + return reinterpret_cast(blocks.at(index)); +} + +OakEngineBlock * +oakengine_track_block_at_time(const OakEngineTrack *track, int64_t timestamp) +{ + if (!track) { + return nullptr; + } + const olive::Rational tb = track_time_base(track_impl(track)); + const olive::Rational time = track_ts_to_time(timestamp, tb); + olive::Block *b = track_impl(track)->block_containing_time(time); + return reinterpret_cast(b); +} + +OakEngineBlock * +oakengine_track_nearest_block_before(const OakEngineTrack *track, + int64_t timestamp) +{ + if (!track) { + return nullptr; + } + const olive::Rational tb = track_time_base(track_impl(track)); + const olive::Rational time = track_ts_to_time(timestamp, tb); + olive::Block *b = track_impl(track)->nearest_block_before(time); + return reinterpret_cast(b); +} + +OakEngineBlock * +oakengine_track_nearest_block_after(const OakEngineTrack *track, + int64_t timestamp) +{ + if (!track) { + return nullptr; + } + const olive::Rational tb = track_time_base(track_impl(track)); + const olive::Rational time = track_ts_to_time(timestamp, tb); + olive::Block *b = track_impl(track)->nearest_block_after(time); + return reinterpret_cast(b); +} + +OakEngineBlock * +oakengine_track_nearest_block_before_or_at(const OakEngineTrack *track, + int64_t timestamp) +{ + if (!track) { + return nullptr; + } + const olive::Rational tb = track_time_base(track_impl(track)); + const olive::Rational time = track_ts_to_time(timestamp, tb); + olive::Block *b = track_impl(track)->nearest_block_before_or_at(time); + return reinterpret_cast(b); +} + +OakEngineBlock * +oakengine_track_nearest_block_after_or_at(const OakEngineTrack *track, + int64_t timestamp) +{ + if (!track) { + return nullptr; + } + const olive::Rational tb = track_time_base(track_impl(track)); + const olive::Rational time = track_ts_to_time(timestamp, tb); + olive::Block *b = track_impl(track)->nearest_block_after_or_at(time); + return reinterpret_cast(b); +} + +int oakengine_block_is_gap(const OakEngineBlock *block) +{ + if (!block) { + return 0; + } + return dynamic_cast(block_impl(block)) != nullptr + ? 1 : 0; +} + +OakEngineBlock *oakengine_block_next(const OakEngineBlock *block) +{ + if (!block) { + return nullptr; + } + return reinterpret_cast(block_impl(block)->next()); +} + +OakEngineBlock *oakengine_block_prev(const OakEngineBlock *block) +{ + if (!block) { + return nullptr; + } + return reinterpret_cast(block_impl(block)->previous()); +} + +int oakengine_block_get_range(const OakEngineBlock *block, int64_t *in, + int64_t *out) +{ + if (!block) { + return OAKENGINE_E_INVALID; + } + const olive::Block *b = block_impl(block); + const olive::Track *t = b->track(); + if (!t) { + return OAKENGINE_E_INVALID; + } + const olive::Rational tb = track_time_base(t); + if (in) { + *in = track_time_to_ts(b->in(), tb); + } + if (out) { + *out = track_time_to_ts(b->out(), tb); + } + return OAKENGINE_OK; +} + +} diff --git a/engine/src/capi/traverse.cpp b/engine/src/capi/traverse.cpp new file mode 100644 index 000000000..d940e5a70 --- /dev/null +++ b/engine/src/capi/traverse.cpp @@ -0,0 +1,374 @@ +/*** + + 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/traverse.h" + +#include + +#include +#include +#include +#include + +#include "node/traverser.h" +#include "node/value.h" +#include "render/videoparams.h" + +// oak_node_value_type of a row value: same mapping as node.cpp's +// to_c_type(). Types without any facade representation report +// OAK_NODE_VALUE_NONE; duplicated here because node.cpp's copy is +// translation-unit local. +static int to_c_type(olive::NodeValue::Type t) +{ + switch (t) { + case olive::NodeValue::k_int: + return OAK_NODE_VALUE_INT; + case olive::NodeValue::k_float: + return OAK_NODE_VALUE_FLOAT; + case olive::NodeValue::k_boolean: + return OAK_NODE_VALUE_BOOL; + case olive::NodeValue::k_rational: + return OAK_NODE_VALUE_RATIONAL; + case olive::NodeValue::k_color: + return OAK_NODE_VALUE_COLOR; + case olive::NodeValue::k_vec2: + return OAK_NODE_VALUE_VEC2; + case olive::NodeValue::k_vec3: + return OAK_NODE_VALUE_VEC3; + case olive::NodeValue::k_vec4: + return OAK_NODE_VALUE_VEC4; + case olive::NodeValue::k_combo: + return OAK_NODE_VALUE_COMBO; + case olive::NodeValue::k_file: + return OAK_NODE_VALUE_STRING; + case olive::NodeValue::k_text: + return OAK_NODE_VALUE_TEXT; + case olive::NodeValue::k_font: + return OAK_NODE_VALUE_FONT; + case olive::NodeValue::k_str_combo: + return OAK_NODE_VALUE_STR_COMBO; + case olive::NodeValue::k_binary: + return OAK_NODE_VALUE_BINARY; + case olive::NodeValue::k_bezier: + return OAK_NODE_VALUE_BEZIER; + case olive::NodeValue::k_texture: + return OAK_NODE_VALUE_TEXTURE; + case olive::NodeValue::k_samples: + return OAK_NODE_VALUE_SAMPLES; + case olive::NodeValue::k_video_params: + return OAK_NODE_VALUE_VIDEO_PARAMS; + case olive::NodeValue::k_audio_params: + return OAK_NODE_VALUE_AUDIO_PARAMS; + default: + return OAK_NODE_VALUE_NONE; + } +} + +namespace +{ + +olive::Node *impl(OakEngineNode *h) +{ + return reinterpret_cast(h); +} + +// oak_video_params POD -> olive::VideoParams (same mapping as +// encoding.cpp's to_cpp()). +olive::VideoParams to_cpp(const oak_video_params &v) +{ + olive::VideoParams vp( + v.width, v.height, olive::Rational(v.time_base_num, v.time_base_den), + static_cast(v.format), + olive::VideoParams::k_internal_channel_count, + olive::Rational(v.pixel_aspect_num, v.pixel_aspect_den), + static_cast(v.interlacing), + v.divider > 0 ? v.divider : 1); + vp.set_color_range(static_cast(v.color_range)); + return vp; +} + +olive::TimeRange to_range(int64_t in_num, int64_t in_den, int64_t out_num, + int64_t out_den) +{ + return olive::TimeRange(olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den)); +} + +} // namespace + +// Owned result object: per-input tables plus every string the accessors can +// return, pre-converted to UTF-8 so the returned pointers stay valid until +// oakengine_traverse_db_free(). +struct OakEngineTraverseDb { + struct Row { + int type = OAK_NODE_VALUE_NONE; + const olive::Node *source = nullptr; + QByteArray tag; + QByteArray value_string; + std::vector splits; + }; + + struct Input { + QByteArray id; + olive::NodeValueTable table; // kept for table_element_index_for_hint + QVector rows; + }; + + QVector inputs; +}; + +namespace +{ + +OakEngineTraverseDb::Row convert_row(const olive::NodeValue &v) +{ + OakEngineTraverseDb::Row row; + row.type = to_c_type(v.type()); + row.source = v.source(); + row.tag = v.tag().toUtf8(); + row.value_string = olive::NodeValue::value_to_string(v, false).toUtf8(); + const olive::SplitValue split = v.to_split_value(); + for (const QVariant &component : split) { + row.splits.push_back( + olive::NodeValue::value_to_string(v.type(), component, true) + .toUtf8()); + } + return row; +} + +OakEngineTraverseDb::Input convert_input(const QString &id, + const olive::NodeValueTable &table) +{ + OakEngineTraverseDb::Input input; + input.id = id.toUtf8(); + input.table = table; + input.rows.reserve(table.count()); + for (int i = 0; i < table.count(); i++) { + input.rows.append(convert_row(table.at(i))); + } + return input; +} + +const OakEngineTraverseDb::Row *row_at(const OakEngineTraverseDb *db, + int input_index, int row) +{ + if (!db || input_index < 0 || input_index >= db->inputs.size() || + row < 0 || row >= db->inputs.at(input_index).rows.size()) { + return nullptr; + } + return &db->inputs.at(input_index).rows.at(row); +} + +} // namespace + +extern "C" +{ + +OakEngineTraverseDb *oakengine_traverse_generate_database( + OakEngineNode *node, int64_t in_num, int64_t in_den, int64_t out_num, + int64_t out_den) +{ + if (!node || in_den == 0 || out_den == 0) { + return nullptr; + } + olive::Node *n = impl(node); + olive::NodeTraverser traverser; + const olive::NodeValueDatabase database = + traverser.generate_database(n, to_range(in_num, in_den, out_num, out_den)); + + auto *db = new OakEngineTraverseDb; + // NodeValueDatabase is a QHash; emit entries in the node's input order so + // the C-side index mapping is deterministic. + for (const QString &id : n->inputs()) { + auto it = database.cbegin(); + for (; it != database.cend(); ++it) { + if (it.key() == id) { + break; + } + } + if (it != database.cend()) { + db->inputs.append(convert_input(id, it.value())); + } + } + return db; +} + +OakEngineTraverseDb *oakengine_traverse_generate_table( + OakEngineNode *node, int64_t in_num, int64_t in_den, int64_t out_num, + int64_t out_den) +{ + if (!node || in_den == 0 || out_den == 0) { + return nullptr; + } + olive::NodeTraverser traverser; + const olive::NodeValueTable table = traverser.generate_table( + impl(node), to_range(in_num, in_den, out_num, out_den)); + + auto *db = new OakEngineTraverseDb; + // A bare output table has no input id; represented as a single entry + // with an empty id (see traverse.h). + db->inputs.append(convert_input(QString(), table)); + return db; +} + +void oakengine_traverse_db_free(OakEngineTraverseDb *db) +{ + delete db; +} + +int oakengine_traverse_db_input_count(const OakEngineTraverseDb *db) +{ + return db ? int(db->inputs.size()) : 0; +} + +const char *oakengine_traverse_db_input_id(const OakEngineTraverseDb *db, + int input_index) +{ + if (!db || input_index < 0 || input_index >= db->inputs.size()) { + return nullptr; + } + return db->inputs.at(input_index).id.constData(); +} + +int oakengine_traverse_db_row_count(const OakEngineTraverseDb *db, + int input_index) +{ + if (!db || input_index < 0 || input_index >= db->inputs.size()) { + return 0; + } + return int(db->inputs.at(input_index).rows.size()); +} + +int oakengine_traverse_row_type(const OakEngineTraverseDb *db, int input_index, + int row) +{ + const OakEngineTraverseDb::Row *r = row_at(db, input_index, row); + return r ? r->type : OAK_NODE_VALUE_NONE; +} + +OakEngineNode *oakengine_traverse_row_source(const OakEngineTraverseDb *db, + int input_index, int row) +{ + const OakEngineTraverseDb::Row *r = row_at(db, input_index, row); + return (r && r->source) ? + reinterpret_cast( + const_cast(r->source)) : + nullptr; +} + +const char *oakengine_traverse_row_tag(const OakEngineTraverseDb *db, + int input_index, int row) +{ + const OakEngineTraverseDb::Row *r = row_at(db, input_index, row); + static const char empty[] = ""; + return r ? r->tag.constData() : empty; +} + +const char *oakengine_traverse_row_value_string(const OakEngineTraverseDb *db, + int input_index, int row) +{ + const OakEngineTraverseDb::Row *r = row_at(db, input_index, row); + return r ? r->value_string.constData() : nullptr; +} + +int oakengine_traverse_row_split_count(const OakEngineTraverseDb *db, + int input_index, int row) +{ + const OakEngineTraverseDb::Row *r = row_at(db, input_index, row); + return r ? int(r->splits.size()) : 0; +} + +const char *oakengine_traverse_row_split_string(const OakEngineTraverseDb *db, + int input_index, int row, + int split) +{ + const OakEngineTraverseDb::Row *r = row_at(db, input_index, row); + if (!r || split < 0 || split >= int(r->splits.size())) { + return nullptr; + } + return r->splits[size_t(split)].constData(); +} + +int oakengine_traverse_table_element_index_for_hint( + OakEngineNode *hint_node, const char *input_id, int element, + const OakEngineTraverseDb *table_db) +{ + if (!hint_node || !input_id || !table_db || table_db->inputs.size() != 1) { + return -1; + } + olive::NodeTraverser traverser; + return traverser.generate_row_value_element_index( + impl(hint_node), QString::fromUtf8(input_id), element, + &table_db->inputs.first().table); +} + +int oakengine_traverse_generate_row(OakEngineNode *node, int64_t in_num, + int64_t in_den, int64_t out_num, + int64_t out_den, + const oak_video_params *cache_video_params, + int sample_rate, uint64_t channel_layout, + void *row_out) +{ + if (!node || !row_out || in_den == 0 || out_den == 0) { + return OAKENGINE_E_INVALID; + } + olive::NodeTraverser traverser; + if (cache_video_params) { + traverser.set_cache_video_params(to_cpp(*cache_video_params)); + } + if (sample_rate > 0) { + traverser.set_cache_audio_params( + olive::AudioParams(sample_rate, channel_layout, + olive::core::SampleFormat::f32_p)); + } + // Transition bridge: row_out is the application's own olive::NodeValueRow + // (a QHash typedef), filled in place. + auto *row = static_cast(row_out); + *row = traverser.generate_row(impl(node), + to_range(in_num, in_den, out_num, out_den)); + return OAKENGINE_OK; +} + +int oakengine_traverse_transform(OakEngineNode *start, OakEngineNode *end, + int64_t in_num, int64_t in_den, + int64_t out_num, int64_t out_den, + const oak_video_params *cache_video_params, + double out_m[6]) +{ + if (!start || !end || !out_m || in_den == 0 || out_den == 0) { + return OAKENGINE_E_INVALID; + } + olive::NodeTraverser traverser; + if (cache_video_params) { + traverser.set_cache_video_params(to_cpp(*cache_video_params)); + } + QTransform t; + traverser.transform(&t, impl(start), impl(end), + to_range(in_num, in_den, out_num, out_den)); + out_m[0] = t.m11(); + out_m[1] = t.m12(); + out_m[2] = t.m21(); + out_m[3] = t.m22(); + out_m[4] = t.dx(); + out_m[5] = t.dy(); + return OAKENGINE_OK; +} + +} // extern "C" diff --git a/engine/src/capi/undo.cpp b/engine/src/capi/undo.cpp new file mode 100644 index 000000000..7693d64dd --- /dev/null +++ b/engine/src/capi/undo.cpp @@ -0,0 +1,711 @@ +/*** + + 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/undo.h" + +#include + +#include +#include +#include +#include + +#include "olive/core/util/timecodefunctions.h" +#include "coreengine.h" +#include "node/block/block.h" +#include "node/block/clip/clip.h" +#include "node/block/transition/transition.h" +#include "node/nodeundo.h" +#include "node/output/track/track.h" +#include "node/output/track/tracklist.h" +#include "node/project.h" +#include "node/project/sequence/sequence.h" +#include "node/value.h" +#include "timeline/timelinecommon.h" +#include "timeline/timelineundogeneral.h" +#include "timeline/timelineundopointer.h" +#include "timeline/timelineundoripple.h" +#include "timeline/timelineundosplit.h" +#include "undo/undocommand.h" +#include "undo/undostack.h" +#include "undointernal.h" + +namespace +{ + +olive::UndoStack *stack() +{ + if (olive::EngineCore *core = olive::EngineCore::instance()) { + return core->undo_stack(); + } + return nullptr; +} + +// buf/size string writer (same convention as capi/project.cpp). +int write_string(const QString &s, char *buf, int buf_size) +{ + const QByteArray utf8 = s.toUtf8(); + const int len = int(utf8.size()); + if (buf && buf_size > 0) { + const int n = qMin(len, buf_size - 1); + std::memcpy(buf, utf8.constData(), size_t(n)); + buf[n] = '\0'; + } + return len; +} + +} // namespace + +namespace +{ + +// Current open undo group. Owned by this TU; the facade owns it between +// group_begin and group_end/group_abort. +olive::MultiUndoCommand *g_undo_group = nullptr; +QString g_undo_group_name; + +} // namespace + +olive::MultiUndoCommand *oakengine_undo_group_current(void) +{ + return g_undo_group; +} + +// Shared helper used by all capi TU push_or_run() locals. +void oakengine_undo_push_or_run(olive::UndoCommand *command, const QString &name) +{ + if (olive::MultiUndoCommand *group = g_undo_group) { + group->add_child(command); + command->redo_now(); + } else if (olive::UndoStack *s = stack()) { + s->push(command, name); + } else { + command->redo_now(); + delete command; + } +} + +extern "C" void *oakengine_undo_handle(void) +{ + return stack(); +} + +extern "C" int oakengine_undo_push(void *command, const char *name) +{ + if (!command) { + return OAKENGINE_E_INVALID; + } + auto *cmd = static_cast(command); + const QString label = name ? QString::fromUtf8(name) : QString(); + if (olive::MultiUndoCommand *group = g_undo_group) { + group->add_child(cmd); + cmd->redo_now(); + } else if (olive::UndoStack *s = stack()) { + s->push(cmd, label); + } else { + cmd->redo_now(); + delete cmd; + } + return OAKENGINE_OK; +} + +extern "C" int oakengine_undo_group_begin(const char *name) +{ + if (g_undo_group) { + return OAKENGINE_E_STATE; + } + g_undo_group = new olive::MultiUndoCommand(); + g_undo_group_name = name ? QString::fromUtf8(name) : QString(); + return OAKENGINE_OK; +} + +extern "C" int oakengine_undo_group_end(void) +{ + if (!g_undo_group) { + return OAKENGINE_E_STATE; + } + olive::MultiUndoCommand *group = g_undo_group; + g_undo_group = nullptr; + + QString name = g_undo_group_name; + g_undo_group_name.clear(); + + olive::UndoStack *s = stack(); + if (!s) { + // No stack: just redo/undo nothing and delete. + delete group; + return OAKENGINE_OK; + } + + // Empty group is discarded by push_pre_executed (mirrors push). + s->push_pre_executed(group, name); + return OAKENGINE_OK; +} + +extern "C" int oakengine_undo_group_abort(void) +{ + if (!g_undo_group) { + return OAKENGINE_E_STATE; + } + olive::MultiUndoCommand *group = g_undo_group; + g_undo_group = nullptr; + g_undo_group_name.clear(); + + group->undo_now(); + delete group; + return OAKENGINE_OK; +} + +extern "C" int oakengine_undo_command_redo_now(void *command) +{ + if (!command) { + return OAKENGINE_E_INVALID; + } + static_cast(command)->redo_now(); + return OAKENGINE_OK; +} + +extern "C" int oakengine_undo_command_undo_now(void *command) +{ + if (!command) { + return OAKENGINE_E_INVALID; + } + static_cast(command)->undo_now(); + return OAKENGINE_OK; +} + +namespace +{ + +class CustomUndoCommand : public olive::UndoCommand { +public: + CustomUndoCommand(const QString &name, + oakengine_undo_command_redo_fn redo_cb, + oakengine_undo_command_undo_fn undo_cb, + oakengine_undo_command_free_fn free_cb, + void *userdata) + : name_(name) + , redo_fn_(redo_cb) + , undo_fn_(undo_cb) + , free_fn_(free_cb) + , userdata_(userdata) + { + } + + virtual ~CustomUndoCommand() override + { + if (free_fn_) { + free_fn_(userdata_); + } + } + + virtual olive::Project *get_relevant_project() const override + { + return nullptr; + } + +protected: + virtual void redo() override + { + if (redo_fn_) { + redo_fn_(userdata_); + } + } + + virtual void undo() override + { + if (undo_fn_) { + undo_fn_(userdata_); + } + } + +private: + QString name_; + oakengine_undo_command_redo_fn redo_fn_; + oakengine_undo_command_undo_fn undo_fn_; + oakengine_undo_command_free_fn free_fn_; + void *userdata_; +}; + +} // namespace + +namespace +{ + +// Map oak_node_value_type -> olive::NodeValue::Type (mirrors node.cpp). +olive::NodeValue::Type from_c_type(int t) +{ + switch (t) { + case 0: return olive::NodeValue::k_none; + case 1: return olive::NodeValue::k_int; + case 2: return olive::NodeValue::k_float; + case 3: return olive::NodeValue::k_boolean; + case 4: return olive::NodeValue::k_rational; + case 5: return olive::NodeValue::k_color; + case 6: return olive::NodeValue::k_vec2; + case 7: return olive::NodeValue::k_vec3; + case 8: return olive::NodeValue::k_vec4; + case 9: return olive::NodeValue::k_combo; + case 10: return olive::NodeValue::k_file; + case 11: return olive::NodeValue::k_text; + case 12: return olive::NodeValue::k_font; + case 13: return olive::NodeValue::k_str_combo; + case 14: return olive::NodeValue::k_binary; + case 15: return olive::NodeValue::k_bezier; + case 16: return olive::NodeValue::k_texture; + case 17: return olive::NodeValue::k_samples; + case 18: return olive::NodeValue::k_video_params; + case 19: return olive::NodeValue::k_audio_params; + default: return olive::NodeValue::k_none; + } +} + +const olive::Sequence *sequence_from_block(const olive::Block *block) +{ + if (!block || !block->track()) { + return nullptr; + } + return block->track()->sequence(); +} + +olive::Rational sequence_time_base(const olive::Sequence *seq) +{ + if (seq) { + const olive::Rational fr = seq->get_video_params().frame_rate(); + if (!fr.isNull() && !fr.isNaN()) { + return fr.flipped(); + } + } + return olive::Rational(1001, 30000); +} + +olive::Rational ts_to_time(int64_t ts, const olive::Rational &tb) +{ + return olive::core::Timecode::timestamp_to_time(ts, tb); +} + +olive::Timeline::MovementMode to_movement_mode(int mode) +{ + switch (mode) { + case 1: return olive::Timeline::k_move; + case 2: return olive::Timeline::k_trim_in; + case 3: return olive::Timeline::k_trim_out; + default: return olive::Timeline::k_none; + } +} + +} // namespace + +extern "C" void *oakengine_undo_command_create( + const char *name, + oakengine_undo_command_redo_fn redo, + oakengine_undo_command_undo_fn undo, + oakengine_undo_command_free_fn free_fn, + void *userdata) +{ + return new CustomUndoCommand( + name ? QString::fromUtf8(name) : QString(), + redo, undo, free_fn, userdata); +} + +extern "C" void *oakengine_undo_command_create_multi(void) +{ + return new olive::MultiUndoCommand(); +} + +extern "C" void *oakengine_node_add_command(void *project, void *node) +{ + if (!project || !node) { + return nullptr; + } + return new olive::NodeAddCommand( + reinterpret_cast(project), + reinterpret_cast(node)); +} + +extern "C" void *oakengine_node_set_position_command( + void *node, void *context, double x, double y, int expanded) +{ + if (!node || !context) { + return nullptr; + } + return new olive::NodeSetPositionCommand( + reinterpret_cast(node), + reinterpret_cast(context), + olive::Node::Position(QPointF(x, y), expanded != 0)); +} + +extern "C" void *oakengine_node_remove_position_command( + void *node, void *context) +{ + if (!node || !context) { + return nullptr; + } + return new olive::NodeRemovePositionFromContextCommand( + reinterpret_cast(node), + reinterpret_cast(context)); +} + +extern "C" void *oakengine_node_set_value_hint_command( + void *node, const char *input, int element, int type, int index, + const char *tag) +{ + if (!node || !input) { + return nullptr; + } + olive::Node *n = reinterpret_cast(node); + const QString id = QString::fromUtf8(input); + if (!n->inputs().contains(id)) { + return nullptr; + } + olive::NodeValue::Type nv_type = olive::NodeValue::k_none; + if (type >= 0) { + nv_type = from_c_type(type); + if (nv_type == olive::NodeValue::k_none && type != 0) { + return nullptr; + } + } + QVector types; + if (nv_type != olive::NodeValue::k_none) { + types.append(nv_type); + } + return new olive::NodeSetValueHintCommand( + n, id, element, + olive::Node::ValueHint(types, index, QString::fromUtf8(tag ? tag : ""))); +} + +extern "C" void *oakengine_node_remove_and_disconnect_command(void *node) +{ + if (!node) { + return nullptr; + } + return new olive::NodeRemoveAndDisconnectCommand( + reinterpret_cast(node)); +} + +extern "C" void *oakengine_track_place_block_command( + void *track_list, int track_index, void *block, int64_t in_ts) +{ + if (!track_list || !block || in_ts < 0) { + return nullptr; + } + olive::TrackList *list = reinterpret_cast(track_list); + olive::Block *b = reinterpret_cast(block); + const olive::Rational tb = sequence_time_base(list->parent()); + return new olive::TrackPlaceBlockCommand( + list, track_index, b, ts_to_time(in_ts, tb)); +} + +extern "C" void *oakengine_track_replace_block_with_gap_command( + void *track, void *block, int handle_transitions) +{ + if (!track || !block) { + return nullptr; + } + return new olive::TrackReplaceBlockWithGapCommand( + reinterpret_cast(track), + reinterpret_cast(block), + handle_transitions != 0); +} + +extern "C" void *oakengine_block_trim_command( + void *track, void *block, int64_t new_length_num, int64_t new_length_den, + int movement_mode, int roll_edit) +{ + if (!track || !block || new_length_den == 0 || + movement_mode < 0 || movement_mode > 3) { + return nullptr; + } + olive::Track *t = reinterpret_cast(track); + olive::Block *b = reinterpret_cast(block); + auto *cmd = new olive::BlockTrimCommand( + t, b, + olive::Rational(static_cast(new_length_num), + static_cast(new_length_den)), + to_movement_mode(movement_mode)); + cmd->set_trim_is_a_roll_edit(roll_edit != 0); + return cmd; +} + +extern "C" void *oakengine_transition_remove_command( + void *transition, int remove_from_graph) +{ + if (!transition) { + return nullptr; + } + return new olive::TransitionRemoveCommand( + reinterpret_cast(transition), + remove_from_graph != 0); +} + +extern "C" void *oakengine_track_slide_command( + void *track, void *const *blocks, int block_count, + void *in_adjacent, void *out_adjacent, + int64_t movement_num, int64_t movement_den) +{ + if (!track || !blocks || block_count <= 0 || movement_den == 0) { + return nullptr; + } + olive::Track *t = reinterpret_cast(track); + QList block_list; + block_list.reserve(block_count); + for (int i = 0; i < block_count; i++) { + if (!blocks[i]) { + return nullptr; + } + block_list.append(reinterpret_cast(blocks[i])); + } + return new olive::TrackSlideCommand( + t, block_list, + reinterpret_cast(in_adjacent), + reinterpret_cast(out_adjacent), + olive::Rational(static_cast(movement_num), + static_cast(movement_den))); +} + +extern "C" void *oakengine_block_split_preserving_links_command( + void *const *blocks, int count, int64_t point_ts) +{ + if (!blocks || count <= 0 || point_ts < 0) { + return nullptr; + } + QVector block_vec; + block_vec.reserve(count); + const olive::Sequence *seq = nullptr; + for (int i = 0; i < count; i++) { + if (!blocks[i]) { + return nullptr; + } + olive::Block *b = reinterpret_cast(blocks[i]); + if (!seq) { + seq = sequence_from_block(b); + } + block_vec.append(b); + } + const olive::Rational tb = sequence_time_base(seq); + const olive::Rational point = ts_to_time(point_ts, tb); + // BlockSplitPreservingLinksCommand takes a list of times, one per block. + QList times; + times.reserve(count); + for (int i = 0; i < count; i++) { + times.append(point); + } + return new olive::BlockSplitPreservingLinksCommand(block_vec, times); +} + +extern "C" void *oakengine_block_split_get_split( + void *command, void *block, int time_index) +{ + if (!command || !block) { + return nullptr; + } + auto *cmd = reinterpret_cast( + command); + return cmd->get_split(reinterpret_cast(block), time_index); +} + +extern "C" void *oakengine_block_resize_with_media_in_command( + void *block, int64_t length_num, int64_t length_den) +{ + if (!block || length_den == 0) { + return nullptr; + } + olive::Block *b = reinterpret_cast(block); + return new olive::BlockResizeWithMediaInCommand( + b, olive::Rational(static_cast(length_num), + static_cast(length_den))); +} + +extern "C" void *oakengine_block_set_media_in_command( + void *block, int64_t media_in_num, int64_t media_in_den) +{ + if (!block || media_in_den == 0) { + return nullptr; + } + olive::ClipBlock *clip = reinterpret_cast(block); + return new olive::BlockSetMediaInCommand( + clip, olive::Rational(static_cast(media_in_num), + static_cast(media_in_den))); +} + +extern "C" void *oakengine_timeline_ripple_delete_gaps_command( + void *sequence, const int64_t *range_in_ts, const int64_t *range_out_ts, + const int *track_types, const int *track_indexes, int range_count) +{ + if (!sequence || !range_in_ts || !range_out_ts || + !track_types || !track_indexes || range_count <= 0) { + return nullptr; + } + olive::Sequence *seq = reinterpret_cast(sequence); + const olive::Rational tb = sequence_time_base(seq); + olive::TimelineRippleDeleteGapsAtRegionsCommand::RangeList ranges; + ranges.reserve(range_count); + for (int i = 0; i < range_count; i++) { + if (range_in_ts[i] < 0 || range_out_ts[i] <= range_in_ts[i] || + track_types[i] < 0 || track_types[i] > 2 || + track_indexes[i] < 0) { + return nullptr; + } + olive::TrackList *list = seq->track_list( + static_cast(track_types[i])); + if (!list || track_indexes[i] >= list->get_track_count()) { + return nullptr; + } + olive::Track *track = list->get_track_at(track_indexes[i]); + ranges.append(qMakePair( + track, + olive::TimeRange(ts_to_time(range_in_ts[i], tb), + ts_to_time(range_out_ts[i], tb)))); + } + return new olive::TimelineRippleDeleteGapsAtRegionsCommand(seq, ranges); +} + +extern "C" void *oakengine_track_list_insert_gaps_command( + void *track_list, int64_t point_num, int64_t point_den, + int64_t length_num, int64_t length_den) +{ + if (!track_list || point_den == 0 || length_den == 0) { + return nullptr; + } + olive::TrackList *list = reinterpret_cast(track_list); + return new olive::TrackListInsertGaps( + list, olive::Rational(static_cast(point_num), + static_cast(point_den)), + olive::Rational(static_cast(length_num), + static_cast(length_den))); +} + +extern "C" int oakengine_undo_command_multi_add_child(void *multi, + void *child) +{ + if (!multi || !child) { + return OAKENGINE_E_INVALID; + } + static_cast(multi)->add_child( + static_cast(child)); + return OAKENGINE_OK; +} + +extern "C" int oakengine_undo_command_multi_child_count(void *multi) +{ + if (!multi) { + return OAKENGINE_E_INVALID; + } + return static_cast(multi)->child_count(); +} + +extern "C" void oakengine_undo_command_free(void *command) +{ + delete static_cast(command); +} + +extern "C" int64_t oakengine_undo_count(void) +{ + olive::UndoStack *s = stack(); + return s ? s->command_count() : OAKENGINE_E_INVALID; +} + +extern "C" int64_t oakengine_undo_index(void) +{ + olive::UndoStack *s = stack(); + return s ? s->done_count() : OAKENGINE_E_INVALID; +} + +extern "C" int oakengine_undo_command_text(int64_t row, char *buf, + int buf_size) +{ + olive::UndoStack *s = stack(); + if (!s) { + return OAKENGINE_E_INVALID; + } + if (row < 0 || row >= s->command_count()) { + return OAKENGINE_E_NOT_FOUND; + } + return write_string(s->command_name(row), buf, buf_size); +} + +extern "C" int oakengine_undo_command_is_done(int64_t row) +{ + olive::UndoStack *s = stack(); + if (!s) { + return OAKENGINE_E_INVALID; + } + if (row < 0 || row >= s->command_count()) { + return OAKENGINE_E_NOT_FOUND; + } + return s->command_is_done(row) ? 1 : 0; +} + +extern "C" int oakengine_undo_jump(int64_t index) +{ + olive::UndoStack *s = stack(); + if (!s) { + return OAKENGINE_E_STATE; + } + if (index < 0 || index > s->command_count()) { + return OAKENGINE_E_INVALID; + } + s->jump(size_t(index)); + return OAKENGINE_OK; +} + +extern "C" int oakengine_undo_clear(void) +{ + olive::UndoStack *s = stack(); + if (!s) { + return OAKENGINE_E_STATE; + } + s->clear(); + return OAKENGINE_OK; +} + +extern "C" int oakengine_undo_update_actions(void) +{ + olive::UndoStack *s = stack(); + if (!s) { + return OAKENGINE_E_STATE; + } + s->update_actions(); + return OAKENGINE_OK; +} + +extern "C" int oakengine_undo_can_undo(void) +{ + olive::UndoStack *s = stack(); + return s ? (s->can_undo() ? 1 : 0) : OAKENGINE_E_INVALID; +} + +extern "C" int oakengine_undo_can_redo(void) +{ + olive::UndoStack *s = stack(); + return s ? (s->can_redo() ? 1 : 0) : OAKENGINE_E_INVALID; +} + +extern "C" void *oakengine_undo_undo_action(void) +{ + olive::UndoStack *s = stack(); + return s ? static_cast(s->GetUndoAction()) : nullptr; +} + +extern "C" void *oakengine_undo_redo_action(void) +{ + olive::UndoStack *s = stack(); + return s ? static_cast(s->GetRedoAction()) : nullptr; +} diff --git a/engine/src/capi/undointernal.h b/engine/src/capi/undointernal.h new file mode 100644 index 000000000..41795ae72 --- /dev/null +++ b/engine/src/capi/undointernal.h @@ -0,0 +1,45 @@ +/*** + + 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_UNDOINTERNAL_H +#define OAKENGINE_UNDOINTERNAL_H + +// Internal (not installed) shared declarations between undo.cpp and the +// other capi translation units. The undo-group state lives in undo.cpp; +// these helpers let node/timeline/etc. push commands into an active group +// instead of directly onto the global undo stack. + +namespace olive +{ +class MultiUndoCommand; +class UndoCommand; +} + +class QString; + +// Returns the currently active undo group, or nullptr if no group is open. +olive::MultiUndoCommand *oakengine_undo_group_current(void); + +// Push `command` into the active group (eager redo) or onto the global undo +// stack. No-op if no engine core exists. +void oakengine_undo_push_or_run(olive::UndoCommand *command, + const QString &name); + +#endif // OAKENGINE_UNDOINTERNAL_H diff --git a/engine/src/capi/viewer.cpp b/engine/src/capi/viewer.cpp new file mode 100644 index 000000000..64dd545b5 --- /dev/null +++ b/engine/src/capi/viewer.cpp @@ -0,0 +1,619 @@ +/*** + + 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/viewer.h" + +#include + +#include +#include + +#include "oakengine/timeline.h" + +#include "node/output/viewer/viewer.h" +#include "node/output/track/track.h" +#include "node/block/clip/clip.h" +#include "node/nodeundo.h" +#include "node/param.h" +#include "render/playbackcache.h" +#include "render/framehashcache.h" +#include "render/videoparams.h" +#include "timeline/timelineworkarea.h" + +namespace +{ + +olive::Node *impl(OakEngineNode *h) +{ + return reinterpret_cast(h); +} + +const olive::Node *impl(const OakEngineNode *h) +{ + return reinterpret_cast(h); +} + +// Validated viewer accessor; nullptr when the handle is not a viewer. +olive::ViewerOutput *viewer_of(OakEngineNode *h) +{ + return h ? dynamic_cast(impl(h)) : nullptr; +} + +const olive::ViewerOutput *viewer_of(const OakEngineNode *h) +{ + return h ? dynamic_cast(impl(h)) : nullptr; +} + +// ViewerOutput::get_playhead()/get_connected_waveform() are not const in the +// engine; the facade keeps const-correct handles and casts locally (same +// pattern as timeline.cpp's mutable_impl()). +olive::ViewerOutput *mutable_viewer(const OakEngineNode *h) +{ + return const_cast(viewer_of(h)); +} + +// olive::VideoParams -> oak_video_params POD (same mapping as +// encoding.cpp's from_cpp()). +void from_cpp(const olive::VideoParams &vp, oak_video_params *out) +{ + out->width = vp.width(); + out->height = vp.height(); + out->time_base_num = vp.time_base().numerator(); + out->time_base_den = vp.time_base().denominator(); + out->format = int(vp.format()); + out->pixel_aspect_num = vp.pixel_aspect_ratio().numerator(); + out->pixel_aspect_den = vp.pixel_aspect_ratio().denominator(); + out->interlacing = int(vp.interlacing()); + out->color_range = int(vp.color_range()); + out->divider = vp.divider(); + out->video_type = int(vp.video_type()); + out->premultiplied_alpha = vp.premultiplied_alpha() ? 1 : 0; +} + +void rational_out(const olive::Rational &r, int64_t *num, int64_t *den) +{ + if (num) { + *num = r.numerator(); + } + if (den) { + *den = r.denominator(); + } +} + +// Track::Type values match the facade's OAKENGINE_TRACK_TYPE_* constants; +// assert it and convert explicitly anyway (k_none should never appear in an +// enabled-stream list). +static_assert(int(olive::Track::k_video) == OAKENGINE_TRACK_TYPE_VIDEO, + "track type mismatch"); +static_assert(int(olive::Track::k_audio) == OAKENGINE_TRACK_TYPE_AUDIO, + "track type mismatch"); +static_assert(int(olive::Track::k_subtitle) == OAKENGINE_TRACK_TYPE_SUBTITLE, + "track type mismatch"); + +int to_c_track_type(olive::Track::Type t) +{ + switch (t) { + case olive::Track::k_video: + return OAKENGINE_TRACK_TYPE_VIDEO; + case olive::Track::k_audio: + return OAKENGINE_TRACK_TYPE_AUDIO; + case olive::Track::k_subtitle: + return OAKENGINE_TRACK_TYPE_SUBTITLE; + default: + return -1; + } +} + +} // namespace + +extern "C" +{ + +OakEngineNode *oakengine_viewer_from_node(OakEngineNode *node) +{ + return viewer_of(node) ? node : nullptr; +} + +const OakEngineNode *oakengine_viewer_from_const_node(const OakEngineNode *node) +{ + return viewer_of(node) ? node : nullptr; +} + +const char *oakengine_viewer_video_params_input_id(void) +{ + static const QByteArray s = + olive::ViewerOutput::k_video_params_input.toUtf8(); + return s.constData(); +} + +const char *oakengine_viewer_audio_params_input_id(void) +{ + static const QByteArray s = + olive::ViewerOutput::k_audio_params_input.toUtf8(); + return s.constData(); +} + +const char *oakengine_viewer_subtitle_params_input_id(void) +{ + static const QByteArray s = + olive::ViewerOutput::k_subtitle_params_input.toUtf8(); + return s.constData(); +} + +const char *oakengine_viewer_texture_input_id(void) +{ + static const QByteArray s = olive::ViewerOutput::k_texture_input.toUtf8(); + return s.constData(); +} + +const char *oakengine_viewer_samples_input_id(void) +{ + static const QByteArray s = olive::ViewerOutput::k_samples_input.toUtf8(); + return s.constData(); +} + +int oakengine_viewer_default_sample_format(void) +{ + return int(olive::core::SampleFormat::Format( + olive::ViewerOutput::k_default_sample_format)); +} + +int oakengine_viewer_get_playhead(const OakEngineNode *self, int64_t *num, + int64_t *den) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + rational_out(mutable_viewer(self)->get_playhead(), num, den); + return OAKENGINE_OK; +} + +int oakengine_viewer_set_playhead(OakEngineNode *self, int64_t num, + int64_t den) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + v->set_playhead(olive::Rational(num, den)); + return OAKENGINE_OK; +} + +int oakengine_viewer_set_video_params(OakEngineNode *self, + const oak_video_params *params, + int index) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v || !params) { + return OAKENGINE_E_INVALID; + } + olive::VideoParams vp( + params->width, params->height, + olive::Rational(params->time_base_num, params->time_base_den), + static_cast(params->format), + olive::VideoParams::k_internal_channel_count, + olive::Rational(params->pixel_aspect_num, params->pixel_aspect_den), + static_cast(params->interlacing), + params->divider); + v->set_video_params(vp, index); + return OAKENGINE_OK; +} + +int oakengine_viewer_set_audio_params(OakEngineNode *self, int sample_rate, + uint64_t channel_layout, int format, + int index) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + olive::AudioParams ap; + ap.set_sample_rate(sample_rate); + ap.set_channel_layout(channel_layout); + ap.set_format(static_cast(format)); + v->set_audio_params(ap, index); + return OAKENGINE_OK; +} + +int oakengine_viewer_get_length(const OakEngineNode *self, int64_t *num, + int64_t *den) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + rational_out(v->get_length(), num, den); + return OAKENGINE_OK; +} + +int oakengine_viewer_get_video_length(const OakEngineNode *self, int64_t *num, + int64_t *den) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + rational_out(v->get_video_length(), num, den); + return OAKENGINE_OK; +} + +int oakengine_viewer_get_audio_length(const OakEngineNode *self, int64_t *num, + int64_t *den) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + rational_out(v->get_audio_length(), num, den); + return OAKENGINE_OK; +} + +int oakengine_viewer_get_video_params(const OakEngineNode *self, int index, + oak_video_params *out) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v || !out) { + return OAKENGINE_E_INVALID; + } + memset(out, 0, sizeof(*out)); + from_cpp(v->get_video_params(index), out); + return OAKENGINE_OK; +} + +int oakengine_viewer_get_audio_params(const OakEngineNode *self, int index, + int *sample_rate, + uint64_t *channel_layout, int *format) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + if (sample_rate) { + *sample_rate = 0; + } + if (channel_layout) { + *channel_layout = 0; + } + if (format) { + *format = 0; + } + if (index < 0 || index >= v->get_audio_stream_count()) { + return OAKENGINE_OK; + } + const olive::AudioParams params = v->get_audio_params(index); + if (sample_rate) { + *sample_rate = params.sample_rate(); + } + if (channel_layout) { + *channel_layout = params.channel_layout(); + } + if (format) { + *format = int(olive::core::SampleFormat::Format(params.format())); + } + return OAKENGINE_OK; +} + +int oakengine_viewer_get_video_stream_count(const OakEngineNode *self) +{ + const olive::ViewerOutput *v = viewer_of(self); + return v ? v->get_video_stream_count() : 0; +} + +int oakengine_viewer_get_audio_stream_count(const OakEngineNode *self) +{ + const olive::ViewerOutput *v = viewer_of(self); + return v ? v->get_audio_stream_count() : 0; +} + +int oakengine_viewer_get_subtitle_stream_count(const OakEngineNode *self) +{ + const olive::ViewerOutput *v = viewer_of(self); + return v ? v->get_subtitle_stream_count() : 0; +} + +int oakengine_viewer_get_stream_enabled(const OakEngineNode *self, + int track_type, int index) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + + switch (track_type) { + case OAKENGINE_TRACK_TYPE_VIDEO: + return index >= 0 && index < v->get_video_stream_count() && + v->get_video_params(index).enabled(); + case OAKENGINE_TRACK_TYPE_AUDIO: + return index >= 0 && index < v->get_audio_stream_count() && + v->get_audio_params(index).enabled(); + case OAKENGINE_TRACK_TYPE_SUBTITLE: + return index >= 0 && index < v->get_subtitle_stream_count() && + v->get_subtitle_params(index).enabled(); + default: + return OAKENGINE_E_INVALID; + } +} + +int oakengine_viewer_get_subtitle_count(const OakEngineNode *self, int index) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v || index < 0 || index >= v->get_subtitle_stream_count()) { + return OAKENGINE_E_INVALID; + } + + return int(v->get_subtitle_params(index).size()); +} + +const void *oakengine_viewer_get_subtitle_at(const OakEngineNode *self, + int index, int sub_index) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v || index < 0 || index >= v->get_subtitle_stream_count()) { + return nullptr; + } + + const olive::SubtitleParams &sp = v->get_subtitle_params(index); + if (sub_index < 0 || sub_index >= int(sp.size())) { + return nullptr; + } + + return &sp[size_t(sub_index)]; +} + +int oakengine_viewer_has_enabled_streams(const OakEngineNode *self, + int track_type) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return 0; + } + switch (track_type) { + case OAKENGINE_TRACK_TYPE_VIDEO: + return v->has_enabled_video_streams() ? 1 : 0; + case OAKENGINE_TRACK_TYPE_AUDIO: + return v->has_enabled_audio_streams() ? 1 : 0; + case OAKENGINE_TRACK_TYPE_SUBTITLE: + return v->has_enabled_subtitle_streams() ? 1 : 0; + default: + return 0; + } +} + +int oakengine_viewer_get_first_enabled_video_stream(const OakEngineNode *self, + oak_video_params *out) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v || !out) { + return OAKENGINE_E_INVALID; + } + memset(out, 0, sizeof(*out)); + from_cpp(v->get_first_enabled_video_stream(), out); + return OAKENGINE_OK; +} + +int oakengine_viewer_get_enabled_stream_count(const OakEngineNode *self) +{ + const olive::ViewerOutput *v = viewer_of(self); + return v ? int(v->get_enabled_streams_as_references().size()) : 0; +} + +int oakengine_viewer_get_enabled_streams(const OakEngineNode *self, int *types, + int *indices, int max) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v || max < 0) { + return 0; + } + const QVector refs = + v->get_enabled_streams_as_references(); + if (types && indices) { + const int n = qMin(int(refs.size()), max); + for (int i = 0; i < n; i++) { + types[i] = to_c_track_type(refs.at(i).type()); + indices[i] = refs.at(i).index(); + } + } + return int(refs.size()); +} + +int oakengine_viewer_get_workarea(const OakEngineNode *self, + oakengine_viewer_workarea *out) +{ + const olive::ViewerOutput *v = viewer_of(self); + if (!v || !out) { + return OAKENGINE_E_INVALID; + } + const olive::TimelineWorkArea *workarea = v->get_work_area(); + out->in_num = workarea->in().numerator(); + out->in_den = workarea->in().denominator(); + out->out_num = workarea->out().numerator(); + out->out_den = workarea->out().denominator(); + out->enabled = workarea->enabled() ? 1 : 0; + return OAKENGINE_OK; +} + +int oakengine_viewer_set_workarea_range(OakEngineNode *self, int64_t in_num, + int64_t in_den, int64_t out_num, + int64_t out_den) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + v->get_work_area()->set_range( + olive::TimeRange(olive::Rational(in_num, in_den), + olive::Rational(out_num, out_den))); + return OAKENGINE_OK; +} + +int oakengine_viewer_set_workarea_enabled(OakEngineNode *self, int enabled) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + v->get_work_area()->set_enabled(enabled != 0); + return OAKENGINE_OK; +} + +int oakengine_viewer_set_default_parameters(OakEngineNode *self) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + v->set_default_parameters(); + return OAKENGINE_OK; +} + +extern "C" void *oakengine_viewer_set_preview_divider_command( + OakEngineNode *self, int divider) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v || divider < 1) { + return nullptr; + } + olive::VideoParams current = v->get_video_params(); + if (current.divider() == divider) { + return nullptr; + } + const olive::VideoParams updated( + current.width(), current.height(), current.time_base(), + current.format(), current.channel_count(), + current.pixel_aspect_ratio(), current.interlacing(), divider); + return new olive::NodeParamSetStandardValueCommand( + olive::NodeKeyframeTrackReference( + olive::NodeInput(v, olive::ViewerOutput::k_video_params_input), 0), + QVariant::fromValue(updated)); +} + +int oakengine_viewer_set_parameters_from_footage( + OakEngineNode *self, OakEngineNode *const *footage, int count) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v || count < 0 || (count > 0 && !footage)) { + return OAKENGINE_E_INVALID; + } + QVector viewers; + viewers.reserve(count); + for (int i = 0; i < count; i++) { + olive::ViewerOutput *f = viewer_of(footage[i]); + if (!f) { + return OAKENGINE_E_INVALID; + } + viewers.append(f); + } + v->set_parameters_from_footage(viewers); + return OAKENGINE_OK; +} + +int oakengine_viewer_set_waveform_enabled(OakEngineNode *self, int enabled) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return OAKENGINE_E_INVALID; + } + v->set_waveform_enabled(enabled != 0); + return OAKENGINE_OK; +} + +const void *oakengine_viewer_get_connected_waveform(const OakEngineNode *self) +{ + olive::ViewerOutput *v = mutable_viewer(self); + if (!v) { + return nullptr; + } + return static_cast(v->get_connected_waveform()); +} + +OakEngineMarkerList *oakengine_viewer_get_marker_list(OakEngineNode *self) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return nullptr; + } + return reinterpret_cast(v->get_markers()); +} + +OakEngineWorkarea *oakengine_viewer_get_workarea_handle(OakEngineNode *self) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return nullptr; + } + return reinterpret_cast(v->get_work_area()); +} + +/* ---- Playback cache / frame cache ------------------------------------------ */ + +OakEnginePlaybackCache * +oakengine_viewer_get_playback_cache(OakEngineNode *self) +{ + if (!self) { + return nullptr; + } + olive::ClipBlock *clip = dynamic_cast( + reinterpret_cast(self)); + if (!clip) { + return nullptr; + } + return reinterpret_cast( + clip->connected_video_cache()); +} + +int oakengine_playback_cache_indicator_height(void) +{ + return olive::PlaybackCache::get_cache_indicator_height(); +} + +int oakengine_playback_cache_valid_ranges(OakEnginePlaybackCache *cache, + int64_t *ranges, int max) +{ + if (!cache) { + return OAKENGINE_E_INVALID; + } + olive::PlaybackCache *pc = reinterpret_cast(cache); + const olive::TimeRangeList &valid = pc->get_validated_ranges(); + const int count = qMin(max, int(valid.size())); + for (int i = 0; i < count; i++) { + ranges[i * 4 + 0] = valid.at(i).in().numerator(); + ranges[i * 4 + 1] = valid.at(i).in().denominator(); + ranges[i * 4 + 2] = valid.at(i).out().numerator(); + ranges[i * 4 + 3] = valid.at(i).out().denominator(); + } + return count; +} + +OakEngineFrameCache *oakengine_viewer_get_frame_cache(OakEngineNode *self) +{ + olive::ViewerOutput *v = viewer_of(self); + if (!v) { + return nullptr; + } + // For a clip, get its connected video cache as a FrameHashCache. + if (olive::ClipBlock *clip = dynamic_cast(v)) { + return reinterpret_cast( + clip->connected_video_cache()); + } + return nullptr; +} + +} // extern "C" diff --git a/engine/src/capi/worker.cpp b/engine/src/capi/worker.cpp new file mode 100644 index 000000000..dc30a010b --- /dev/null +++ b/engine/src/capi/worker.cpp @@ -0,0 +1,945 @@ +/*** + + 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/worker.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_LINUX +#include +#include +#endif + +#include "common/qtutils.h" +#include "config/config.h" +#include "coreengine.h" +#include "node/factory.h" +#include "node/input/multicam/multicamnode.h" +#include "node/project/serializer/serializer.h" +#include "render/diskmanager.h" +#include "render/framemanager.h" +#include "render/ipc/frameslotpool.h" +#include "render/ipc/ipcmessage.h" +#include "render/ipc/sharedmemoryregion.h" +#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND +#include "render/backend/dynamicrenderer.h" +#endif +#include "render/opengl/openglrenderer.h" +#include "render/rendermanager.h" +#include "render/renderprocessor.h" +#include "render/colorprocessor.h" +#include "render/colortransform.h" + +#ifdef Q_OS_MACOS +void HideWorkerDockIcon(); +#endif + +namespace +{ + +#ifdef Q_OS_LINUX +void print_backtrace(int sig) +{ + void *array[50]; + size_t size = backtrace(array, 50); + fprintf(stderr, "worker: caught signal %d, backtrace:\n", sig); + backtrace_symbols_fd(array, size, STDERR_FILENO); + fflush(stderr); + _exit(128 + sig); +} +#endif + +constexpr int k_protocol_version = 1; +constexpr int k_default_width = 1920; +constexpr int k_default_height = 1080; +constexpr int k_default_frame_rate = 24; + +void install_surface_format() +{ + QSurfaceFormat format; + format.setVersion(3, 2); + format.setProfile(QSurfaceFormat::CoreProfile); + format.setDepthBufferSize(24); + QSurfaceFormat::setDefaultFormat(format); +} + +void log_error(const QString &message) +{ + const QByteArray line = QByteArray("worker: ") + message.toUtf8() + '\n'; + fwrite(line.constData(), 1, size_t(line.size()), stderr); + fflush(stderr); +} + +QJsonObject error_message(const QString &message, qint64 ticket_id = 0) +{ + QJsonObject o; + o["type"] = olive::ipc::msgtype::k_error; + o["message"] = message; + if (ticket_id) { + o["ticket"] = double(ticket_id); + } + return o; +} + +} // namespace + +/** + * @brief Engine-internal render worker session. + * + * Holds the whole worker-side state machine (renderer, loaded project, + * shared-memory frame pools, shader/color caches) and answers one NDJSON + * control message at a time. Responses that the process main loop would + * write to stdout are produced into `response` instead, so the session is + * transport-agnostic and unit-testable. Owned via the OakWorkerSession C + * handle. + */ +struct __attribute__((visibility("hidden"))) OakWorkerSession { + olive::Renderer *renderer = nullptr; + bool shutdown_requested = false; + bool runtime_initialized = false; + std::unique_ptr project; + QHash node_by_token; + olive::ipc::SharedMemoryRegion output_region; + std::optional output_pool; + olive::ipc::SharedMemoryRegion input_region; + std::optional input_pool; + olive::ShaderCache shader_cache; + QHash color_processor_cache; + + ~OakWorkerSession() + { + project.reset(); + if (runtime_initialized) { + olive::ProjectSerializer::destroy(); + olive::DiskManager::destroy_instance(); + olive::FrameManager::destroy_instance(); + olive::NodeFactory::destroy(); + } + if (renderer) { + renderer->destroy(); + renderer->post_destroy(); + delete renderer; + } + } + + bool initialize_runtime() + { + if (runtime_initialized) { + return true; + } + + // The session API is also used without oakengine_worker_main() (unit + // tests, embedded harnesses), where no QApplication exists yet. + // Config/managers below require one (QSettings, QStandardPaths), so + // create a minimal offscreen instance, mirroring oakengine_init(). + if (!QCoreApplication::instance()) { + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + static int argc = 1; + static char app_name[] = "oak-worker"; + static char *argv[] = { app_name, nullptr }; + // Never deleted: QCoreApplication is a process-lifetime object. + new QGuiApplication(argc, argv); + QCoreApplication::setOrganizationName( + QStringLiteral("oakvideoeditor.org")); + QCoreApplication::setApplicationName( + QStringLiteral("oak-render-worker")); + } + + // Create a minimal EngineCore instance so that code paths calling + // EngineCore::instance() (e.g. ViewerOutput::data for timecode display) + // do not dereference null. The worker has no UI, so the plain engine + // core is sufficient. The worker is short-lived; leaking this on exit + // is harmless. + if (!olive::EngineCore::instance()) { + new olive::EngineCore(olive::EngineCore::CoreParams()); + } + + olive::Config::load(); + olive::NodeFactory::initialize(); + olive::ColorManager::set_up_default_config(); + olive::FrameManager::create_instance(); + olive::DiskManager::create_instance(); + olive::ProjectSerializer::initialize(); + runtime_initialized = true; + return true; + } + + QJsonObject startup_handshake() const + { + olive::ipc::HandshakeMsg hs; + hs.protocol_version = k_protocol_version; + hs.shm_key = QString(); + hs.input_shm_key = QString(); + hs.input_slots = 0; + hs.output_slots = 0; + hs.slot_data_bytes = 0; + hs.input_slot_data_bytes = 0; + + QJsonObject handshake = hs.to_json(); + if (QOpenGLContext *ctx = gl_context()) { + const QSurfaceFormat fmt = ctx->format(); + handshake["gl_major"] = fmt.majorVersion(); + handshake["gl_minor"] = fmt.minorVersion(); + } + return handshake; + } + + QOpenGLContext *gl_context() const + { + if (!renderer) { + return nullptr; + } +#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND + if (auto *dynamic_renderer = + dynamic_cast(renderer)) { + return dynamic_renderer->open_gl_context(); + } +#endif + return static_cast(renderer)->context(); + } + + /// Handle one parsed control message. `response` is left untouched when + /// the message has no reply (successful handshake, cancel, shutdown). + /// Returns false only on an internal failure the main loop treats as + /// fatal. + bool handle(const QJsonObject &message, QJsonObject *response) + { + const QString type = message["type"].toString(); + + if (type == QLatin1String(olive::ipc::msgtype::k_handshake)) { + olive::ipc::HandshakeMsg hs; + if (!olive::ipc::HandshakeMsg::from_json(message, &hs)) { + *response = + error_message(QStringLiteral("invalid handshake message")); + return true; + } + return attach_output_pool(hs, response); + } + + if (type == QLatin1String(olive::ipc::msgtype::k_load_graph)) { + olive::ipc::LoadGraphMsg load; + if (!olive::ipc::LoadGraphMsg::from_json(message, &load)) { + *response = + error_message(QStringLiteral("invalid load_graph message")); + return true; + } + return load_graph(load.path, response); + } + + if (type == QLatin1String(olive::ipc::msgtype::k_render_frame)) { + olive::ipc::RenderFrameMsg render; + if (!olive::ipc::RenderFrameMsg::from_json(message, &render)) { + *response = error_message( + QStringLiteral("invalid render_frame message")); + return true; + } + return render_frame(render, response); + } + + if (type == QLatin1String(olive::ipc::msgtype::k_cancel)) { + // Stage 5 wires cancellation into in-flight jobs. Stage 2 has only synchronous single-frame work. + return true; + } + + if (type == QLatin1String(olive::ipc::msgtype::k_shutdown)) { + shutdown_requested = true; + return true; + } + + *response = + error_message(QStringLiteral("unknown message type: %1").arg(type)); + return true; + } + +private: + bool attach_output_pool(const olive::ipc::HandshakeMsg &hs, + QJsonObject *response) + { + if (hs.protocol_version != k_protocol_version) { + *response = + error_message(QStringLiteral("unsupported protocol version %1") + .arg(hs.protocol_version)); + return true; + } + + if (hs.shm_key.isEmpty() || hs.output_slots <= 0 || + hs.slot_data_bytes <= 0) { + *response = error_message(QStringLiteral( + "handshake missing output shared-memory geometry")); + return true; + } + + const size_t bytes = olive::ipc::FrameSlotPool::bytes_needed( + uint32_t(hs.output_slots), size_t(hs.slot_data_bytes)); + if (!output_region.open(hs.shm_key, bytes, + olive::ipc::SharedMemoryRegion::k_attach)) { + *response = error_message( + QStringLiteral("failed to attach shared memory: %1") + .arg(output_region.error())); + return true; + } + + output_pool = olive::ipc::FrameSlotPool::attach(output_region.data()); + if (!output_pool->is_valid()) { + output_region.close(); + output_pool.reset(); + *response = error_message(QStringLiteral( + "shared memory does not contain a frame slot pool")); + return true; + } + + input_pool.reset(); + input_region.close(); + if (hs.input_slots > 0) { + if (hs.input_shm_key.isEmpty() || hs.input_slot_data_bytes <= 0) { + *response = error_message(QStringLiteral( + "handshake missing input shared-memory geometry")); + return true; + } + + const size_t input_bytes = olive::ipc::FrameSlotPool::bytes_needed( + uint32_t(hs.input_slots), size_t(hs.input_slot_data_bytes)); + if (!input_region.open(hs.input_shm_key, input_bytes, + olive::ipc::SharedMemoryRegion::k_attach)) { + *response = error_message( + QStringLiteral("failed to attach input shared memory: %1") + .arg(input_region.error())); + return true; + } + + input_pool = + olive::ipc::FrameSlotPool::attach(input_region.data()); + if (!input_pool->is_valid()) { + input_region.close(); + input_pool.reset(); + *response = error_message(QStringLiteral( + "input shared memory does not contain a frame slot pool")); + return true; + } + } + + return true; + } + + bool load_graph(const QString &path, QJsonObject *response) + { + { + QFileInfo fi(path); + if (!fi.exists()) { + log_error( + QStringLiteral("LoadGraph: graph file does not exist: %1") + .arg(path)); + *response = error_message( + QStringLiteral("graph file does not exist: %1").arg(path)); + return true; + } + if (fi.size() == 0) { + log_error(QStringLiteral("LoadGraph: graph file is empty: %1") + .arg(path)); + *response = error_message( + QStringLiteral("graph file is empty: %1").arg(path)); + return true; + } + log_error( + QStringLiteral("LoadGraph: loading %1 (%2 bytes, readable=%3)") + .arg(path) + .arg(fi.size()) + .arg(fi.isReadable())); + } + + auto loaded = std::make_unique(); + // Do not call Initialize() here: project serializers expect a blank + // project (root_ == nullptr) and will set root themselves. Calling + // Initialize() first triggers Q_ASSERT(!root_) in Project::Load. + + olive::ProjectSerializer::Result result = + olive::ProjectSerializer::load(loaded.get(), path, + olive::ProjectSerializer::k_project); + if (result != olive::ProjectSerializer::k_success) { + *response = + error_message(QStringLiteral("failed to load graph %1: %2") + .arg(path, result.get_details())); + return true; + } + + project = std::move(loaded); + node_by_token.clear(); + color_processor_cache.clear(); + + const auto &data = result.get_load_data(); + for (auto it = data.node_ptrs.cbegin(); it != data.node_ptrs.cend(); + ++it) { + node_by_token.insert(QString::number(it.key()), it.value()); + } + for (auto it = data.node_uuids.cbegin(); it != data.node_uuids.cend(); + ++it) { + node_by_token.insert(it.value().toString(), it.key()); + node_by_token.insert(it.value().toString(QUuid::WithoutBraces), + it.key()); + } + + QJsonObject ack; + ack["type"] = QStringLiteral("graph_loaded"); + ack["nodes"] = node_by_token.size(); + *response = ack; + return true; + } + + olive::Node *find_node(const QString &token) const + { + if (olive::Node *node = node_by_token.value(token, nullptr)) { + return node; + } + + bool ok = false; + const quintptr ptr = token.toULongLong(&ok, 0); + if (ok) { + return node_by_token.value(QString::number(ptr), nullptr); + } + + return nullptr; + } + + bool render_frame(const olive::ipc::RenderFrameMsg &message, + QJsonObject *response) + { + if (!project) { + *response = error_message( + QStringLiteral("render_frame received before load_graph"), + message.ticket_id); + return true; + } + if (!output_pool || !output_pool->is_valid()) { + *response = error_message( + QStringLiteral( + "render_frame received before output shm handshake"), + message.ticket_id); + return true; + } + + olive::Node *node = find_node(message.node_uuid); + if (!node) { + *response = + error_message(QStringLiteral("render node not found: %1") + .arg(message.node_uuid), + message.ticket_id); + return true; + } + + QVector input_slots; + const QVector requested_input_slots = + message.input_slots.isEmpty() && message.input_slot >= 0 ? + QVector{ message.input_slot } : + message.input_slots; + if (!requested_input_slots.isEmpty()) { + if (!input_pool || !input_pool->is_valid()) { + *response = error_message( + QStringLiteral( + "render_frame referenced input slot without input pool"), + message.ticket_id); + return true; + } + + for (int requested_slot : requested_input_slots) { + if (requested_slot < 0 || + requested_slot >= int(input_pool->slot_count())) { + for (int slot : input_slots) { + input_pool->release(uint32_t(slot)); + } + *response = error_message( + QStringLiteral("input slot index out of range"), + message.ticket_id); + return true; + } + + uint32_t consumed_slot = 0; + if (!input_pool->consume(&consumed_slot)) { + for (int slot : input_slots) { + input_pool->release(uint32_t(slot)); + } + *response = + error_message(QStringLiteral("input slot was not ready"), + message.ticket_id); + return true; + } + if (int(consumed_slot) != requested_slot) { + input_pool->release(consumed_slot); + for (int slot : input_slots) { + input_pool->release(uint32_t(slot)); + } + *response = error_message( + QStringLiteral("input slot order mismatch"), + message.ticket_id); + return true; + } + input_slots.append(int(consumed_slot)); + } + } + + olive::VideoParams vparams( + message.width > 0 ? message.width : k_default_width, + message.height > 0 ? message.height : k_default_height, + olive::Rational(1, k_default_frame_rate), + message.format >= 0 ? olive::PixelFormat::Format(message.format) : + olive::PixelFormat::f32, + message.channel_count > 0 ? message.channel_count : + olive::VideoParams::k_rgba_channel_count); + + olive::RenderTicketPtr ticket = std::make_shared(); + ticket->setProperty("node", olive::QtUtils::ptr_to_value(node)); + ticket->setProperty("time", + QVariant::fromValue(olive::Rational( + int(message.time_num), int(message.time_den)))); + ticket->setProperty("size", QSize(message.width, message.height)); + ticket->setProperty("matrix", QMatrix4x4()); + ticket->setProperty("format", + message.format >= 0 ? + olive::PixelFormat::Format(message.format) : + olive::PixelFormat::invalid); + ticket->setProperty("usecache", false); + ticket->setProperty("channelcount", message.channel_count); + ticket->setProperty("mode", olive::RenderMode::Mode(message.mode)); + ticket->setProperty("type", olive::RenderManager::k_type_video); + ticket->setProperty("colormanager", olive::QtUtils::ptr_to_value( + project->color_manager())); + + { + olive::ColorProcessorPtr color_output; + if (message.has_color_transform) { + QString cache_key = QStringLiteral("%1|%2|%3|%4") + .arg(message.color_is_display ? 1 : 0) + .arg(message.color_output, + message.color_view, + message.color_look); + auto it = color_processor_cache.find(cache_key); + if (it != color_processor_cache.end()) { + color_output = it.value(); + } else { + olive::ColorTransform transform; + if (message.color_is_display) { + transform = olive::ColorTransform(message.color_output, + message.color_view, + message.color_look); + } else { + transform = olive::ColorTransform(message.color_output); + } + color_output = olive::ColorProcessor::create( + project->color_manager(), + project->color_manager()->get_reference_color_space(), + transform); + if (color_output) { + color_processor_cache.insert(cache_key, color_output); + } + } + } + ticket->setProperty("coloroutput", + QVariant::fromValue(color_output)); + } + ticket->setProperty("vparam", QVariant::fromValue(vparams)); + // The IPC render_frame message carries no audio parameters, but + // rendering a sequence that has audio content evaluates audio + // tracks with globals.aparams -- an empty AudioParams aborts + // (AudioParams::time_to_samples asserts is_valid). Use the render + // node's own audio parameters, mirroring the in-process render + // path (PreviewAutoCacher uses context->get_audio_params()). + olive::AudioParams aparam; + if (olive::ViewerOutput *viewer = + dynamic_cast(node)) { + aparam = viewer->get_audio_params(); + } + ticket->setProperty("aparam", QVariant::fromValue(aparam)); + ticket->setProperty("return", olive::RenderManager::k_frame); + ticket->setProperty("cache", QString()); + ticket->setProperty("cachetimebase", + QVariant::fromValue(olive::Rational(1))); + ticket->setProperty("cacheid", QVariant::fromValue(QUuid())); + ticket->setProperty("multicam", olive::QtUtils::ptr_to_value( + static_cast(nullptr))); + ticket->setProperty( + "ipc_input_pool", + // The engine reads this back as the internal implementation object + // (olive::engine::internal::ipc::FrameSlotPool), which is exactly + // what the C handle points at. + olive::QtUtils::ptr_to_value(input_pool ? + static_cast( + input_pool->handle()) : + static_cast(nullptr))); + QVariantList input_slot_values; + for (int slot : input_slots) { + input_slot_values.append(slot); + } + ticket->setProperty("ipc_input_slots", input_slot_values); + ticket->setProperty("ipc_input_slot_cursor", 0); + ticket->setProperty("ipc_input_slot", + input_slots.isEmpty() ? -1 : input_slots.front()); + + ticket->start(); + olive::RenderProcessor::process(ticket, renderer, nullptr, + &shader_cache); + for (int slot : input_slots) { + input_pool->release(uint32_t(slot)); + } + if (!ticket->has_result()) { + *response = error_message(QStringLiteral("render produced no frame"), + message.ticket_id); + return true; + } + + olive::FramePtr frame = ticket->get().value(); + if (!frame || !frame->is_allocated()) { + *response = error_message(QStringLiteral("render result was empty"), + message.ticket_id); + return true; + } + + uint32_t slot = 0; + if (!output_pool->acquire(&slot)) { + *response = + error_message(QStringLiteral("no free output frame slot"), + message.ticket_id); + return true; + } + + const int data_size = frame->linesize_bytes() * frame->height(); + if (data_size > int(output_pool->slot_data_bytes())) { + output_pool->release(slot); + log_error(QString("Output frame size") + QString::number(data_size)); + log_error(QString("Slot size") + + QString::number(output_pool->slot_data_bytes())); + *response = error_message( + QStringLiteral("rendered frame does not fit output slot "), + message.ticket_id); + return true; + } + + std::memcpy(output_pool->slot_data(slot), frame->const_data(), + size_t(data_size)); + olive::ipc::FrameSlotMeta *meta = output_pool->meta(slot); + meta->id = message.ticket_id; + meta->time_num = frame->timestamp().numerator(); + meta->time_den = frame->timestamp().denominator(); + meta->width = frame->width(); + meta->height = frame->height(); + meta->format = int32_t(frame->format()); + meta->channel_count = frame->channel_count(); + meta->linesize = frame->linesize_bytes(); + meta->data_size = data_size; + + if (!output_pool->publish(slot)) { + output_pool->release(slot); + *response = error_message( + QStringLiteral("failed to publish output frame slot"), + message.ticket_id); + return true; + } + olive::ipc::FrameReadyMsg ready; + ready.ticket_id = message.ticket_id; + ready.output_slot = int(slot); + *response = ready.to_json(); + return true; + } +}; + +namespace +{ + +olive::Renderer *create_renderer(const char *backend, bool *valid) +{ + *valid = false; + const QString backend_name = + backend && *backend ? QString::fromUtf8(backend).toLower() : + QStringLiteral("opengl"); + + olive::Renderer *renderer; +#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND + auto *dynamic_renderer = new olive::DynamicRenderer(backend_name); + if (dynamic_renderer->init()) { + dynamic_renderer->post_init(); + renderer = dynamic_renderer; + } else { + delete dynamic_renderer; + qWarning() << "Failed to initialize dynamic" << backend_name + << "backend, falling back to direct OpenGL renderer"; + renderer = new olive::OpenGLRenderer(); + if (!renderer->init()) { + log_error(QStringLiteral("failed to initialize OpenGL renderer")); + delete renderer; + return nullptr; + } + renderer->post_init(); + } +#else + renderer = new olive::OpenGLRenderer(); + if (!renderer->Init()) { + log_error(QStringLiteral("failed to initialize OpenGL renderer")); + delete renderer; + return nullptr; + } + renderer->PostInit(); +#endif + + // Validate the renderer. For OpenGL we check the GL context; for Vulkan we + // rely on init()/post_init() succeeding (there is no QOpenGLContext). + bool renderer_valid = true; + if (backend_name == QStringLiteral("opengl")) { + QOpenGLContext *ctx = nullptr; +#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND + if (auto *loaded_renderer = + dynamic_cast(renderer)) { + ctx = loaded_renderer->open_gl_context(); + } else +#endif + { + ctx = static_cast(renderer)->context(); + } + if (!ctx || !ctx->isValid()) { + renderer_valid = false; + } + } + if (!renderer_valid) { + log_error(QStringLiteral("OpenGL context is not valid after init")); + renderer->destroy(); + renderer->post_destroy(); + delete renderer; + return nullptr; + } + + *valid = true; + return renderer; +} + +bool backend_requests_no_renderer(const char *backend) +{ + if (!backend || !*backend) { + return true; + } + const QString name = QString::fromUtf8(backend).toLower(); + return name == QStringLiteral("none"); +} + +} // namespace + +extern "C" { + +OakWorkerSession *oakengine_worker_session_create(const char *backend) +{ + auto *session = new (std::nothrow) OakWorkerSession(); + if (!session) { + return nullptr; + } + if (!backend_requests_no_renderer(backend)) { + bool valid = false; + session->renderer = create_renderer(backend, &valid); + } + return session; +} + +void oakengine_worker_session_free(OakWorkerSession *self) +{ + delete self; +} + +int oakengine_worker_session_has_renderer(const OakWorkerSession *self) +{ + return self && self->renderer ? 1 : 0; +} + +int oakengine_worker_session_initialize_runtime(OakWorkerSession *self) +{ + if (!self) { + return 0; + } + return self->initialize_runtime() ? 1 : 0; +} + +int oakengine_worker_session_startup_handshake(OakWorkerSession *self, + char *buf, int buf_size) +{ + if (!self) { + return -1; + } + const QByteArray json = QJsonDocument(self->startup_handshake()) + .toJson(QJsonDocument::Compact); + if (buf && buf_size > 0) { + const int n = std::min(int(json.size()), buf_size - 1); + std::memcpy(buf, json.constData(), size_t(n)); + buf[n] = '\0'; + } + return int(json.size()); +} + +int oakengine_worker_session_handle_json(OakWorkerSession *self, + const char *line, char *response_buf, + int response_buf_size) +{ + if (!self || !line) { + return -1; + } + + QJsonParseError parse_error; + const QJsonDocument doc = QJsonDocument::fromJson(QByteArray(line), + &parse_error); + if (parse_error.error != QJsonParseError::NoError || !doc.isObject()) { + const QJsonObject response = + error_message(QStringLiteral("malformed control message")); + const QByteArray json = + QJsonDocument(response).toJson(QJsonDocument::Compact); + if (response_buf && response_buf_size > 0) { + const int n = std::min(int(json.size()), response_buf_size - 1); + std::memcpy(response_buf, json.constData(), size_t(n)); + response_buf[n] = '\0'; + } + return int(json.size()); + } + + QJsonObject response; + if (!self->handle(doc.object(), &response)) { + return -1; + } + if (response.isEmpty()) { + return 0; + } + + const QByteArray json = + QJsonDocument(response).toJson(QJsonDocument::Compact); + if (response_buf && response_buf_size > 0) { + const int n = std::min(int(json.size()), response_buf_size - 1); + std::memcpy(response_buf, json.constData(), size_t(n)); + response_buf[n] = '\0'; + } + return int(json.size()); +} + +int oakengine_worker_session_shutdown_requested(const OakWorkerSession *self) +{ + return self && self->shutdown_requested ? 1 : 0; +} + +int oakengine_worker_main(int argc, char **argv) +{ + QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); + QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); + install_surface_format(); + + QGuiApplication app(argc, argv); + +#ifdef Q_OS_MACOS + HideWorkerDockIcon(); +#endif + + QCoreApplication::setOrganizationName(QStringLiteral("oakvideoeditor.org")); + QCoreApplication::setApplicationName(QStringLiteral("oak-render-worker")); + + QString backend = QStringLiteral("opengl"); + const QStringList args = app.arguments(); + for (int i = 1; i < args.size(); ++i) { + if (args[i] == QStringLiteral("--backend") && i + 1 < args.size()) { + backend = args[i + 1].toLower(); + ++i; + } + } + +#ifdef Q_OS_LINUX + std::signal(SIGSEGV, print_backtrace); + std::signal(SIGABRT, print_backtrace); + std::signal(SIGFPE, print_backtrace); +#endif + + QFile in; + QFile out; + if (!in.open(stdin, QIODevice::ReadOnly | QIODevice::Unbuffered) || + !out.open(stdout, QIODevice::WriteOnly | QIODevice::Unbuffered)) { + log_error(QStringLiteral("failed to open stdio control pipes")); + return 1; + } + + OakWorkerSession *worker = + oakengine_worker_session_create(backend.toUtf8().constData()); + if (!worker || !worker->renderer) { + oakengine_worker_session_free(worker); + return 1; + } + + int exit_code = 0; + if (!worker->initialize_runtime()) { + exit_code = 1; + } else { + const QJsonObject handshake = worker->startup_handshake(); + if (!olive::ipc::write_message(&out, handshake)) { + exit_code = 1; + } else { + out.flush(); + QByteArray buffer; + while (!worker->shutdown_requested && !in.atEnd()) { + const QByteArray chunk = in.readLine(); + if (chunk.isEmpty()) { + break; + } + + buffer.append(chunk); + while (true) { + QJsonObject message; + bool ok = true; + if (!olive::ipc::read_message(&buffer, &message, &ok)) { + if (!ok) { + olive::ipc::write_message( + &out, error_message(QStringLiteral( + "malformed control message"))); + out.flush(); + continue; + } + break; + } + + QJsonObject response; + if (!worker->handle(message, &response)) { + exit_code = 1; + break; + } + if (!response.isEmpty()) { + olive::ipc::write_message(&out, response); + out.flush(); + } + } + } + } + } + + oakengine_worker_session_free(worker); + + return exit_code; +} + +} // extern "C" diff --git a/engine/tests/oakengine_app_test.cpp b/engine/tests/oakengine_app_test.cpp new file mode 100644 index 000000000..fc2345212 --- /dev/null +++ b/engine/tests/oakengine_app_test.cpp @@ -0,0 +1,510 @@ +/*** + + 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 application facade +// (oakengine/app.h). Exercises the CoreParams startup, the start/stop state +// machine, the tool/snapping/timecode state with its change notifications, +// the recent-projects list, the status bar, the clipboard, the footage +// filter and the project lifecycle. No GPU: everything runs on the +// offscreen QGuiApplication created by the facade itself. +// +// Not covered (they require a running import/load task or the autorecovery +// timer, which need an event loop): the confirm_image_sequence, +// relink_footage, save_project and load_layout handler invocations. +// Registration of every handler field is exercised and the close_project +// handler is verified through oakengine_app_create_new_project(). + +#include +#include +#include +#include + +#include "oakengine/app.h" +#include "oakengine/init.h" +#include "oakengine/project.h" + +// Recording sink for every OakEngineAppCallbacks field. +typedef struct { + int confirm_image_sequence_calls; + int relink_calls; + int save_project_calls; + int close_project_calls; + int close_project_ret; + int load_layout_calls; + int otio_import_calls; + int status_show_calls; + char last_status[256]; + int last_timeout; + int status_clear_calls; + int cache_full_calls; + int active_project_calls; + OakEngineProject *last_project; + int tool_changed_calls; + int last_tool; + int addable_changed_calls; + int last_addable; + int snapping_changed_calls; + int last_snapping; + int timecode_changed_calls; + int last_display; + int recent_changed_calls; + int color_picker_calls; + int last_color_picker; +} Cb; + +static Cb g_cb; + +static int on_confirm_image_sequence(const char *filename, void *userdata) +{ + (void) filename; + assert(userdata == &g_cb); + g_cb.confirm_image_sequence_calls++; + return 1; +} + +static int on_relink_footage(OakEngineFootage **footage, int count, + void *userdata) +{ + (void) footage; + assert(userdata == &g_cb); + g_cb.relink_calls++; + assert(count >= 0); + return 1; +} + +static void on_save_project(const char *override_filename, void *userdata) +{ + (void) override_filename; + assert(userdata == &g_cb); + g_cb.save_project_calls++; +} + +static int on_close_project(void *userdata) +{ + assert(userdata == &g_cb); + g_cb.close_project_calls++; + // Mirror the application's close: detach and delete the open project + OakEngineProject *p = oakengine_app_open_project(); + if (p) { + oakengine_app_set_active_project(NULL); + oakengine_project_free(p); + } + return g_cb.close_project_ret; +} + +static void on_load_layout(const void *layout, void *userdata) +{ + (void) layout; + assert(userdata == &g_cb); + g_cb.load_layout_calls++; +} + +static int on_otio_import(OakEngineSequence **sequences, int count, + void *userdata) +{ + (void) sequences; + (void) count; + assert(userdata == &g_cb); + g_cb.otio_import_calls++; + return 1; +} + +static void on_status_message_show(const char *message, int timeout, + void *userdata) +{ + assert(userdata == &g_cb); + g_cb.status_show_calls++; + snprintf(g_cb.last_status, sizeof(g_cb.last_status), "%s", message); + g_cb.last_timeout = timeout; +} + +static void on_status_message_clear(void *userdata) +{ + assert(userdata == &g_cb); + g_cb.status_clear_calls++; +} + +static void on_cache_full_warning(void *userdata) +{ + assert(userdata == &g_cb); + g_cb.cache_full_calls++; +} + +static void on_active_project_changed(OakEngineProject *project, + void *userdata) +{ + assert(userdata == &g_cb); + g_cb.active_project_calls++; + g_cb.last_project = project; +} + +static void on_tool_changed(int tool, void *userdata) +{ + assert(userdata == &g_cb); + g_cb.tool_changed_calls++; + g_cb.last_tool = tool; +} + +static void on_addable_object_changed(int object, void *userdata) +{ + assert(userdata == &g_cb); + g_cb.addable_changed_calls++; + g_cb.last_addable = object; +} + +static void on_snapping_changed(int snapping, void *userdata) +{ + assert(userdata == &g_cb); + g_cb.snapping_changed_calls++; + g_cb.last_snapping = snapping; +} + +static void on_timecode_display_changed(int display, void *userdata) +{ + assert(userdata == &g_cb); + g_cb.timecode_changed_calls++; + g_cb.last_display = display; +} + +static void on_open_recent_list_changed(void *userdata) +{ + assert(userdata == &g_cb); + g_cb.recent_changed_calls++; +} + +static void on_color_picker_enabled(int enabled, void *userdata) +{ + assert(userdata == &g_cb); + g_cb.color_picker_calls++; + g_cb.last_color_picker = enabled; +} + +static OakEngineAppCallbacks make_callbacks(void) +{ + OakEngineAppCallbacks cb = { 0 }; + cb.userdata = &g_cb; + cb.confirm_image_sequence = on_confirm_image_sequence; + cb.relink_footage = on_relink_footage; + cb.save_project = on_save_project; + cb.close_project = on_close_project; + cb.load_layout = on_load_layout; + cb.otio_import = on_otio_import; + cb.status_message_show = on_status_message_show; + cb.status_message_clear = on_status_message_clear; + cb.cache_full_warning = on_cache_full_warning; + cb.active_project_changed = on_active_project_changed; + cb.tool_changed = on_tool_changed; + cb.addable_object_changed = on_addable_object_changed; + cb.snapping_changed = on_snapping_changed; + cb.timecode_display_changed = on_timecode_display_changed; + cb.open_recent_list_changed = on_open_recent_list_changed; + cb.color_picker_enabled = on_color_picker_enabled; + return cb; +} + +// Query a buf/size string function into a heap buffer (caller frees). +static char *query0(int (*fn)(char *, int)) +{ + const int needed = fn(NULL, 0); + assert(needed >= 0); + char *buf = (char *) malloc(size_t(needed) + 1); + assert(fn(buf, needed + 1) == needed); + buf[needed] = '\0'; + return buf; +} + +static void test_create_and_params(void) +{ + OakEngineAppParams params = { 0 }; + params.run_mode = OAKENGINE_APP_RUN_HEADLESS_PRE_CACHE; + params.fullscreen = 1; + params.startup_project = "/tmp/startup.ove"; + + // NULL params would be valid too, but verify the values round-trip + assert(oakengine_app_create(¶ms) == OAKENGINE_OK); + assert(oakengine_app_run_mode() == OAKENGINE_APP_RUN_HEADLESS_PRE_CACHE); + assert(oakengine_app_fullscreen() == 1); + char *startup = query0(oakengine_app_startup_project); + assert(strcmp(startup, "/tmp/startup.ove") == 0); + free(startup); + + // Only one application core may exist + assert(oakengine_app_create(NULL) == OAKENGINE_E_STATE); +} + +static void test_start_stop(void) +{ + // Not started yet + assert(oakengine_app_stop() == OAKENGINE_E_STATE); + + // Full engine start (config, managers, autorecovery, recent list) + assert(oakengine_app_start() == OAKENGINE_OK); + assert(oakengine_app_start() == OAKENGINE_E_STATE); + + assert(oakengine_app_stop() == OAKENGINE_OK); + assert(oakengine_app_stop() == OAKENGINE_E_STATE); +} + +static void test_tool_state(void) +{ + OakEngineAppCallbacks cb = make_callbacks(); + assert(oakengine_app_set_callbacks(&cb) == OAKENGINE_OK); + + // Tool (k_none=0 .. k_track_select=13, k_count=14) + assert(oakengine_app_set_tool(4) == OAKENGINE_OK); + assert(oakengine_app_tool() == 4); + assert(g_cb.tool_changed_calls == 1 && g_cb.last_tool == 4); + assert(oakengine_app_set_tool(-1) == OAKENGINE_E_INVALID); + assert(oakengine_app_set_tool(999) == OAKENGINE_E_INVALID); + + // Addable object + assert(oakengine_app_set_addable_object(2) == OAKENGINE_OK); + assert(oakengine_app_addable_object() == 2); + assert(g_cb.addable_changed_calls == 1 && g_cb.last_addable == 2); + assert(oakengine_app_set_addable_object(999) == OAKENGINE_E_INVALID); + + // Snapping + assert(oakengine_app_set_snapping(0) == OAKENGINE_OK); + assert(oakengine_app_snapping() == 0); + assert(g_cb.snapping_changed_calls == 1 && g_cb.last_snapping == 0); + assert(oakengine_app_set_snapping(1) == OAKENGINE_OK); + assert(oakengine_app_snapping() == 1); + assert(g_cb.snapping_changed_calls == 2 && g_cb.last_snapping == 1); + + // Timecode display (0..4) + assert(oakengine_app_set_timecode_display(3) == OAKENGINE_OK); + assert(oakengine_app_timecode_display() == 3); + assert(g_cb.timecode_changed_calls == 1 && g_cb.last_display == 3); + assert(oakengine_app_set_timecode_display(999) == OAKENGINE_E_INVALID); + + // Selected transition + assert(oakengine_app_set_selected_transition("crossdissolve") == + OAKENGINE_OK); + char *transition = query0(oakengine_app_selected_transition); + assert(strcmp(transition, "crossdissolve") == 0); + free(transition); + assert(oakengine_app_set_selected_transition(NULL) == OAKENGINE_OK); + transition = query0(oakengine_app_selected_transition); + assert(transition[0] == '\0'); + free(transition); + + // Magic flag + assert(oakengine_app_set_magic(1) == OAKENGINE_OK); + assert(oakengine_app_is_magic_enabled() == 1); + assert(oakengine_app_set_magic(0) == OAKENGINE_OK); + assert(oakengine_app_is_magic_enabled() == 0); +} + +static void test_status_and_pixel_sampling(void) +{ + assert(oakengine_app_show_status_message("hello", 250) == OAKENGINE_OK); + assert(g_cb.status_show_calls == 1); + assert(strcmp(g_cb.last_status, "hello") == 0); + assert(g_cb.last_timeout == 250); + assert(oakengine_app_show_status_message(NULL, 0) == OAKENGINE_E_INVALID); + + assert(oakengine_app_clear_status_message() == OAKENGINE_OK); + assert(g_cb.status_clear_calls == 1); + + // Pixel sampling ref-count emits only when crossing zero + assert(oakengine_app_request_pixel_sampling(1) == OAKENGINE_OK); + assert(oakengine_app_request_pixel_sampling(1) == OAKENGINE_OK); + assert(g_cb.color_picker_calls == 1 && g_cb.last_color_picker == 1); + assert(oakengine_app_request_pixel_sampling(0) == OAKENGINE_OK); + assert(g_cb.color_picker_calls == 1); + assert(oakengine_app_request_pixel_sampling(0) == OAKENGINE_OK); + assert(g_cb.color_picker_calls == 2 && g_cb.last_color_picker == 0); +} + +static void test_project_lifecycle(void) +{ + g_cb.close_project_ret = 1; + const int active_before = g_cb.active_project_calls; + + // New project goes through the close handler and becomes active + assert(oakengine_app_create_new_project() == OAKENGINE_OK); + assert(g_cb.close_project_calls == 1); + OakEngineProject *p = oakengine_app_open_project(); + assert(p != NULL); + assert(g_cb.active_project_calls > active_before); + assert(g_cb.last_project == p); + + // Replacing it invokes the close handler again + assert(oakengine_app_create_new_project() == OAKENGINE_OK); + assert(g_cb.close_project_calls == 2); + p = oakengine_app_open_project(); + assert(p != NULL); + + // Saving a project without a filename keeps the recent list unchanged + assert(oakengine_app_clear_recent_projects() == OAKENGINE_OK); + assert(oakengine_app_on_project_saved(p) == OAKENGINE_OK); + assert(oakengine_app_recent_projects_count() == 0); + + // Detach and free it again + assert(oakengine_app_set_active_project(NULL) == OAKENGINE_OK); + assert(oakengine_app_open_project() == NULL); + assert(g_cb.last_project == NULL); + oakengine_project_free(p); + + // add_open_project adopts an externally created project + p = oakengine_project_create(); + assert(p != NULL); + assert(oakengine_project_new(p) == OAKENGINE_OK); + assert(oakengine_app_add_open_project(p, 0) == OAKENGINE_OK); + assert(oakengine_app_open_project() == p); + assert(oakengine_app_set_active_project(NULL) == OAKENGINE_OK); + oakengine_project_free(p); + + // NULL tolerance + assert(oakengine_app_add_open_project(NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_app_on_project_saved(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_app_add_open_project_from_task(NULL, 0) == + OAKENGINE_E_INVALID); + assert(oakengine_app_add_recovery_project_from_task(NULL) == + OAKENGINE_E_INVALID); +} + +static void test_recent_projects(void) +{ + // Start from a clean list + assert(oakengine_app_clear_recent_projects() == OAKENGINE_OK); + assert(oakengine_app_recent_projects_count() == 0); + const int changes_before = g_cb.recent_changed_calls; + + // A saved project lands in the recent list through on_project_saved + OakEngineProject *p = oakengine_project_create(); + assert(p != NULL); + assert(oakengine_project_new(p) == OAKENGINE_OK); + assert(oakengine_project_save(p, "oakengine_app_test_recent.ove") == + OAKENGINE_OK); + assert(oakengine_app_on_project_saved(p) == OAKENGINE_OK); + assert(oakengine_app_recent_projects_count() == 1); + assert(g_cb.recent_changed_calls > changes_before); + + const int needed = oakengine_app_recent_project_at(0, NULL, 0); + assert(needed > 0); + char *buf = (char *) malloc(size_t(needed) + 1); + assert(oakengine_app_recent_project_at(0, buf, needed + 1) == needed); + buf[needed] = '\0'; + assert(strstr(buf, "oakengine_app_test_recent.ove") != NULL); + free(buf); + + // Out-of-range access is rejected (the engine would assert otherwise) + assert(oakengine_app_recent_project_at(5, NULL, 0) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_app_remove_recent_project(5) == OAKENGINE_E_NOT_FOUND); + + assert(oakengine_app_remove_recent_project(0) == OAKENGINE_OK); + assert(oakengine_app_recent_projects_count() == 0); + + remove("oakengine_app_test_recent.ove"); + oakengine_project_free(p); +} + +static void test_clipboard(void) +{ + assert(oakengine_app_copy_to_clipboard("hello clipboard") == + OAKENGINE_OK); + char *text = query0(oakengine_app_paste_from_clipboard); + assert(strcmp(text, "hello clipboard") == 0); + free(text); + assert(oakengine_app_copy_to_clipboard(NULL) == OAKENGINE_E_INVALID); +} + +static void test_footage_filter(void) +{ + assert(oakengine_app_is_footage_extension_allowed("movie.mp4") == 1); + assert(oakengine_app_is_footage_extension_allowed("IMAGE.PNG") == 1); + assert(oakengine_app_is_footage_extension_allowed("doc.txt") == 0); + assert(oakengine_app_is_footage_extension_allowed(NULL) == + OAKENGINE_E_INVALID); + + char *filter = query0(oakengine_app_footage_file_dialog_filter); + assert(strstr(filter, "*.mp4") != NULL); + assert(strstr(filter, ";;") != NULL); + free(filter); +} + +static void test_misc(void) +{ + // Unknown locale is reported as "not found" without failing + assert(oakengine_app_set_language("definitely_not_a_locale_xx") == 0); + assert(oakengine_app_set_language(NULL) == OAKENGINE_E_INVALID); + + assert(oakengine_app_set_autorecovery_interval(5) == OAKENGINE_OK); + assert(oakengine_app_set_use_proxy_media(1) == OAKENGINE_OK); + + char *index = query0(oakengine_app_auto_recovery_index_filename); + assert(strstr(index, "unrecovered") != NULL); + free(index); + + assert(oakengine_app_undo_stack() != NULL); +} + +static void test_create_sequence(void) +{ + OakEngineProject *p = oakengine_project_create(); + assert(p != NULL); + assert(oakengine_project_new(p) == OAKENGINE_OK); + + OakEngineSequence *s = oakengine_app_create_sequence(p, "Seq %1"); + assert(s != NULL); + + assert(oakengine_app_create_sequence(NULL, NULL) == NULL); + + // The returned sequence is not yet part of the project (owned by the + // caller); this test intentionally leaves it unparented. + oakengine_project_free(p); +} + +static void test_callbacks_clear(void) +{ + assert(oakengine_app_set_callbacks(NULL) == OAKENGINE_OK); + + // State changes still work, but nothing is delivered anymore + const int calls = g_cb.tool_changed_calls; + assert(oakengine_app_set_tool(2) == OAKENGINE_OK); + assert(oakengine_app_tool() == 2); + assert(g_cb.tool_changed_calls == calls); +} + +int main(void) +{ + // The facade brings up its own offscreen application object + test_create_and_params(); + test_start_stop(); + + // Engine services (incl. renderer manager for set_active_project) + assert(oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) == + OAKENGINE_OK); + + test_tool_state(); + test_status_and_pixel_sampling(); + test_project_lifecycle(); + test_recent_projects(); + test_clipboard(); + test_footage_filter(); + test_misc(); + test_create_sequence(); + test_callbacks_clear(); + + assert(oakengine_shutdown() == OAKENGINE_OK); + return 0; +} diff --git a/engine/tests/oakengine_audio_test.cpp b/engine/tests/oakengine_audio_test.cpp new file mode 100644 index 000000000..e4c0415e1 --- /dev/null +++ b/engine/tests/oakengine_audio_test.cpp @@ -0,0 +1,203 @@ +/*** + + 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 audio I/O family +// (oakengine/audio.h). Exercises the AudioManager instance lifecycle, +// input/output device get/set round-trips, output push error paths and the +// output_params_changed event subscription. No GL or QApplication required. + +#include +#include +#include + +#include "oakengine/audio.h" +#include "oakengine/events.h" +#include "oakengine/init.h" + +static int g_output_params_changed_count; +static void *g_output_params_changed_source; + +static void on_output_params_changed(const oakengine_event *event, void *) +{ + assert(event != NULL); + assert(event->id == OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_PARAMS_CHANGED); + g_output_params_changed_count++; + g_output_params_changed_source = event->source; +} + +static void test_instance_lifecycle(void) +{ + // No instance before create. + assert(oakengine_audio_manager_handle() == NULL); + assert(oakengine_audio_get_output_device() == -1); + assert(oakengine_audio_get_input_device() == -1); + assert(oakengine_audio_clear_buffered_output() == OAKENGINE_E_STATE); + assert(oakengine_audio_stop_recording() == OAKENGINE_E_STATE); + + assert(oakengine_audio_create_instance() == OAKENGINE_OK); + assert(oakengine_audio_manager_handle() != NULL); + + // Idempotent create is allowed. + assert(oakengine_audio_create_instance() == OAKENGINE_OK); + assert(oakengine_audio_manager_handle() != NULL); + + oakengine_audio_destroy_instance(); + assert(oakengine_audio_manager_handle() == NULL); + + // Idempotent destroy is allowed. + assert(oakengine_audio_destroy_instance() == OAKENGINE_OK); + assert(oakengine_audio_manager_handle() == NULL); +} + +static void test_device_round_trip(void) +{ + assert(oakengine_audio_create_instance() == OAKENGINE_OK); + void *handle = oakengine_audio_manager_handle(); + assert(handle != NULL); + + // Default is usually paNoDevice (-1) in headless environments. + const int64_t original_output = oakengine_audio_get_output_device(); + const int64_t original_input = oakengine_audio_get_input_device(); + + // Setting a value should change the returned value. + assert(oakengine_audio_set_output_device(42) == OAKENGINE_OK); + assert(oakengine_audio_get_output_device() == 42); + + assert(oakengine_audio_set_input_device(43) == OAKENGINE_OK); + assert(oakengine_audio_get_input_device() == 43); + + // hard_reset re-initializes PortAudio and should not crash. + assert(oakengine_audio_hard_reset() == OAKENGINE_OK); + assert(oakengine_audio_manager_handle() == handle); + + // Restore original values. + assert(oakengine_audio_set_output_device(original_output) == OAKENGINE_OK); + assert(oakengine_audio_set_input_device(original_input) == OAKENGINE_OK); + assert(oakengine_audio_get_output_device() == original_output); + assert(oakengine_audio_get_input_device() == original_input); + + oakengine_audio_destroy_instance(); +} + +static void test_push_to_output_errors(void) +{ + assert(oakengine_audio_create_instance() == OAKENGINE_OK); + + char error_buf[256]; + memset(error_buf, 0, sizeof(error_buf)); + + // NULL params is rejected without crashing. + assert(oakengine_audio_push_to_output(NULL, "x", 1, error_buf, + sizeof(error_buf)) == + OAKENGINE_E_INVALID); + + // NULL samples is rejected. + assert(oakengine_audio_push_to_output((const OakAudioParams *)1, NULL, 1, + error_buf, sizeof(error_buf)) == + OAKENGINE_E_INVALID); + + oakengine_audio_destroy_instance(); +} + +static void test_output_params_changed_event(void) +{ + assert(oakengine_audio_create_instance() == OAKENGINE_OK); + void *handle = oakengine_audio_manager_handle(); + assert(handle != NULL); + + g_output_params_changed_count = 0; + g_output_params_changed_source = NULL; + + const int64_t sub = oakengine_event_subscribe( + handle, OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_PARAMS_CHANGED, + on_output_params_changed, NULL); + assert(sub > 0); + + const int64_t original_output = oakengine_audio_get_output_device(); + assert(oakengine_audio_set_output_device(84) == OAKENGINE_OK); + assert(g_output_params_changed_count >= 1); + assert(g_output_params_changed_source == handle); + + assert(oakengine_event_unsubscribe(sub) == OAKENGINE_OK); + + // No further deliveries after unsubscribe. + const int count_after_unsub = g_output_params_changed_count; + assert(oakengine_audio_set_output_device(85) == OAKENGINE_OK); + assert(g_output_params_changed_count == count_after_unsub); + + // Restore original value. + assert(oakengine_audio_set_output_device(original_output) == OAKENGINE_OK); + + oakengine_audio_destroy_instance(); +} + +static void test_audio_sync_algorithms(void) +{ + // place_by_waveform_offset: a 1-second positive offset at 48 kHz + oak_audio_sync_placement placement; + assert(oakengine_audio_sync_place_by_waveform_offset( + 0, 1, 48000, 48000, &placement) == OAKENGINE_OK); + assert(placement.valid); + assert(placement.timeline_in_num == 1 && placement.timeline_in_den == 1); + + // place_by_source_time: matching source/media in points -> same timeline in + oak_audio_sync_source_clip ref = { 0, 1, 0, 1, 1 }; + oak_audio_sync_source_clip cand = { 0, 1, 0, 1, 1 }; + assert(oakengine_audio_sync_place_by_source_time( + &ref, &cand, 5, 1, &placement) == OAKENGINE_OK); + assert(placement.valid); + assert(placement.timeline_in_num == 5 && placement.timeline_in_den == 1); + + // estimate_envelope_offset: identical envelopes -> zero offset, high + // confidence + double envelope[10] = { 0, 1, 2, 3, 4, 5, 4, 3, 2, 1 }; + oak_audio_waveform_offset offset; + assert(oakengine_audio_estimate_envelope_offset( + envelope, 10, envelope, 10, NULL, 0, NULL, 0, 1, 5, + &offset) == OAKENGINE_OK); + assert(offset.valid); + assert(offset.offset_samples == 0); + assert(offset.confidence > 0.99); + + // estimate_stretch_and_offset: identical envelopes at rate 1 -> zero offset + oak_audio_waveform_stretch_offset stretch; + assert(oakengine_audio_estimate_stretch_and_offset( + envelope, 10, envelope, 10, NULL, 0, NULL, 0, 1, 5, 0.9, 1.1, + 0.05, &stretch) == OAKENGINE_OK); + assert(stretch.valid); + assert(stretch.offset_samples == 0); + assert(stretch.rate > 0.99 && stretch.rate < 1.01); +} + +int main(void) +{ + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + + test_instance_lifecycle(); + test_device_round_trip(); + test_push_to_output_errors(); + test_output_params_changed_event(); + test_audio_sync_algorithms(); + + oakengine_shutdown(); + + printf("oakengine_audio_test: OK\n"); + return 0; +} diff --git a/engine/tests/oakengine_color_test.cpp b/engine/tests/oakengine_color_test.cpp new file mode 100644 index 000000000..af66bf43c --- /dev/null +++ b/engine/tests/oakengine_color_test.cpp @@ -0,0 +1,432 @@ +/*** + + 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 color facade (oakengine/color.h) +// and the color manager events (oakengine/events.h). Exercises the +// manager queries (config filename, colorspace/display/view/look lists, +// defaults, luma coefficients, compliant-space resolution), the standalone +// config handle, the color processor handle (create/convert/id) and the +// event subscriptions. No GPU: everything here runs on the CPU-side OCIO +// wrappers. When the environment provides no usable OCIO config at all +// (colorspace count 0), the query assertions are skipped but the +// robustness checks (NULL handling, error paths) still run. + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#endif + +#include "oakengine/color.h" +#include "oakengine/events.h" +#include "oakengine/init.h" +#include "oakengine/project.h" + +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_color_test_%lu", base, + (unsigned long)GetCurrentProcessId()); + assert(_mkdir(g_tmpdir) == 0); +#else + strcpy(g_tmpdir, "/tmp/oakengine_color_test_XXXXXX"); + assert(mkdtemp(g_tmpdir) != NULL); +#endif +} + +// ---- Robustness: NULL/invalid arguments ------------------------------------ + +static void test_null_robustness(void) +{ + char buf[64]; + double rgb[3]; + double rgba[4] = { 0, 0, 0, 0 }; + + assert(oakengine_color_manager_from_project(NULL) == NULL); + assert(oakengine_color_manager_get_config_filename(NULL, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_set_config_filename(NULL, "x") == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_colorspace_count(NULL) == 0); + assert(oakengine_color_manager_colorspace_at(NULL, 0, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_display_count(NULL) == 0); + assert(oakengine_color_manager_display_at(NULL, 0, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_view_count(NULL, NULL) == 0); + assert(oakengine_color_manager_view_at(NULL, NULL, 0, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_look_count(NULL) == 0); + assert(oakengine_color_manager_look_at(NULL, 0, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_default_display(NULL, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_default_view(NULL, NULL, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_default_input_color_space(NULL, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_set_default_input_color_space(NULL, "x") == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_reference_color_space(NULL, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_default_luma_coefs(NULL, rgb) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_compliant_color_space(NULL, "x", buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_manager_compliant_transform(NULL, NULL, 0, NULL, + NULL, 0, NULL, 0, NULL, + 0) == OAKENGINE_E_INVALID); + + assert(oakengine_color_config_load_file(NULL) == NULL); + assert(oakengine_color_config_colorspace_count(NULL) == 0); + assert(oakengine_color_config_colorspace_at(NULL, 0, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + oakengine_color_config_free(NULL); // no-op + + assert(oakengine_color_processor_create(NULL, "in", NULL, + OAKENGINE_COLOR_PROCESSOR_NORMAL) == + NULL); + assert(oakengine_color_processor_is_valid(NULL) == 0); + assert(oakengine_color_processor_convert_color(NULL, rgba, rgba) == + OAKENGINE_E_INVALID); + assert(oakengine_color_processor_id(NULL, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + oakengine_color_processor_free(NULL); // no-op +} + +// ---- Standalone config handle ----------------------------------------------- + +static void test_config_handle(int have_ocio) +{ + char buf[256]; + + // A missing file must fail cleanly with an error message. + assert(oakengine_color_config_load_file("/nonexistent/definitely.ocio") == + NULL); + assert(oakengine_color_last_error(buf, sizeof(buf)) > 0); + + OakEngineColorConfig *config = oakengine_color_config_load_default(); + if (!have_ocio) { + // No usable OCIO config in this environment. + if (config) { + oakengine_color_config_free(config); + } + return; + } + assert(config != NULL); + + const int count = oakengine_color_config_colorspace_count(config); + assert(count > 0); + for (int i = 0; i < count; i++) { + assert(oakengine_color_config_colorspace_at(config, i, buf, + sizeof(buf)) > 0); + assert(buf[0] != '\0'); + } + assert(oakengine_color_config_colorspace_at(config, count, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_color_config_colorspace_at(config, -1, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + + oakengine_color_config_free(config); +} + +// ---- Manager queries --------------------------------------------------------- + +static void test_manager_queries(OakEngineColorManager *mgr) +{ + char buf[256]; + char first_cs[256]; + double rgb[3] = { 0, 0, 0 }; + + // Colorspaces + const int cs_count = oakengine_color_manager_colorspace_count(mgr); + assert(cs_count > 0); + assert(oakengine_color_manager_colorspace_at(mgr, 0, first_cs, + sizeof(first_cs)) > 0); + assert(first_cs[0] != '\0'); + assert(oakengine_color_manager_colorspace_at(mgr, cs_count, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + + // Displays / views / looks + const int disp_count = oakengine_color_manager_display_count(mgr); + assert(disp_count > 0); + assert(oakengine_color_manager_display_at(mgr, 0, buf, sizeof(buf)) > 0); + char display[256]; + memcpy(display, buf, sizeof(display)); + const int view_count = oakengine_color_manager_view_count(mgr, display); + assert(view_count > 0); + assert(oakengine_color_manager_view_at(mgr, display, 0, buf, sizeof(buf)) > + 0); + assert(oakengine_color_manager_look_count(mgr) >= 0); + assert(oakengine_color_manager_display_at(mgr, disp_count, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + + // Defaults + char default_display[256]; + assert(oakengine_color_manager_default_display(mgr, default_display, + sizeof(default_display)) > 0); + assert(oakengine_color_manager_default_view(mgr, default_display, buf, + sizeof(buf)) > 0); + assert(oakengine_color_manager_default_input_color_space(mgr, buf, + sizeof(buf)) > 0); + assert(oakengine_color_manager_reference_color_space(mgr, buf, + sizeof(buf)) > 0); + + // Default input colorspace set/get roundtrip + assert(oakengine_color_manager_set_default_input_color_space(mgr, + first_cs) == + OAKENGINE_OK); + assert(oakengine_color_manager_default_input_color_space(mgr, buf, + sizeof(buf)) > 0); + assert(strcmp(buf, first_cs) == 0); + + // Config filename set/get roundtrip (an empty filename selects the + // built-in default config; setting it must not crash the queries above) + assert(oakengine_color_manager_set_config_filename(mgr, "") == + OAKENGINE_OK); + assert(oakengine_color_manager_get_config_filename(mgr, buf, sizeof(buf)) >= + 0); + + // Luma coefficients: Rec.709-style weights, all positive, roughly sum to 1 + assert(oakengine_color_manager_default_luma_coefs(mgr, rgb) == + OAKENGINE_OK); + assert(rgb[0] > 0 && rgb[1] > 0 && rgb[2] > 0); + assert(fabs(rgb[0] + rgb[1] + rgb[2] - 1.0) < 0.01); + + // Compliant colorspace: an existing space resolves to itself, an empty + // name resolves to the default input space + assert(oakengine_color_manager_compliant_color_space(mgr, first_cs, buf, + sizeof(buf)) > 0); + assert(strcmp(buf, first_cs) == 0); + assert(oakengine_color_manager_compliant_color_space(mgr, "", buf, + sizeof(buf)) > 0); + assert(strcmp(buf, first_cs) == 0); + + // Compliant transform: force a colorspace transform onto a display + // transform and back + oak_color_transform in; + in.is_display = 0; + in.output = first_cs; + in.view = NULL; + in.look = NULL; + int out_is_display = -1; + char out_o[256], out_v[256], out_l[256]; + out_o[0] = out_v[0] = out_l[0] = '\0'; + assert(oakengine_color_manager_compliant_transform(mgr, &in, 1, + &out_is_display, out_o, + sizeof(out_o), out_v, + sizeof(out_v), out_l, + sizeof(out_l)) == + OAKENGINE_OK); + assert(out_is_display == 1); + assert(out_o[0] != '\0'); + assert(oakengine_color_manager_compliant_transform( + mgr, &in, 0, &out_is_display, out_o, sizeof(out_o), out_v, + sizeof(out_v), out_l, sizeof(out_l)) == OAKENGINE_OK); + assert(out_is_display == 0); + assert(strcmp(out_o, first_cs) == 0); +} + +// ---- Color processor ---------------------------------------------------------- + +static void test_processor(OakEngineColorManager *mgr) +{ + char ref[256]; + char buf[256]; + + assert(oakengine_color_manager_reference_color_space(mgr, ref, + sizeof(ref)) > 0); + + // Identity transform (ref -> ref): white stays white + oak_color_transform dest; + dest.is_display = 0; + dest.output = ref; + dest.view = NULL; + dest.look = NULL; + + OakEngineColorProcessor *proc = oakengine_color_processor_create( + mgr, ref, &dest, OAKENGINE_COLOR_PROCESSOR_NORMAL); + assert(proc != NULL); + assert(oakengine_color_processor_is_valid(proc) == 1); + + const double in[4] = { 1.0, 1.0, 1.0, 1.0 }; + double out[4] = { 0, 0, 0, 0 }; + assert(oakengine_color_processor_convert_color(proc, in, out) == + OAKENGINE_OK); + assert(fabs(out[0] - 1.0) < 1e-3 && fabs(out[1] - 1.0) < 1e-3 && + fabs(out[2] - 1.0) < 1e-3 && fabs(out[3] - 1.0) < 1e-3); + + // Cache id is non-empty and stable + const int id_len = oakengine_color_processor_id(proc, buf, sizeof(buf)); + assert(id_len > 0); + assert(buf[0] != '\0'); + assert(oakengine_color_processor_id(proc, NULL, 0) == id_len); + + // Inverse direction constructs too + OakEngineColorProcessor *inv = oakengine_color_processor_create( + mgr, ref, &dest, OAKENGINE_COLOR_PROCESSOR_INVERSE); + assert(inv != NULL); + oakengine_color_processor_free(inv); + + // Unknown direction is rejected + assert(oakengine_color_processor_create(mgr, ref, &dest, 7) == NULL); + + // An unknown colorspace yields an invalid (pass-through) processor, + // matching the engine's non-throwing C++ behavior + dest.output = "definitely-not-a-colorspace"; + OakEngineColorProcessor *bad = oakengine_color_processor_create( + mgr, ref, &dest, OAKENGINE_COLOR_PROCESSOR_NORMAL); + assert(bad != NULL); + if (oakengine_color_processor_is_valid(bad)) { + // Some configs resolve unknown names via roles; then conversion must + // still not crash. + assert(oakengine_color_processor_convert_color(bad, in, out) == + OAKENGINE_OK); + } else { + out[0] = out[1] = out[2] = out[3] = 0; + assert(oakengine_color_processor_convert_color(bad, in, out) == + OAKENGINE_OK); + assert(out[0] == 1.0 && out[1] == 1.0 && out[2] == 1.0 && + out[3] == 1.0); + } + oakengine_color_processor_free(bad); + + // Processor is valid and usable. + assert(proc != NULL); + + oakengine_color_processor_free(proc); +} + +// ---- Events -------------------------------------------------------------------- + +static int g_config_events = 0; +static int g_reference_events = 0; + +static void count_color_events(const oakengine_event *event, void *userdata) +{ + (void)userdata; + assert(event != NULL); + if (event->id == OAKENGINE_EVENT_COLOR_MANAGER_CONFIG_CHANGED) { + g_config_events++; + } else if (event->id == OAKENGINE_EVENT_COLOR_MANAGER_REFERENCE_SPACE_CHANGED) { + g_reference_events++; + } else { + assert(0); // unexpected event id on this subscription + } +} + +static void test_events(OakEngineProject *project, + OakEngineColorManager *mgr) +{ + // Family mismatch: a color manager event on a project handle must fail. + assert(oakengine_event_subscribe( + project, OAKENGINE_EVENT_COLOR_MANAGER_CONFIG_CHANGED, + count_color_events, NULL) == 0); + + int64_t sub_ref = oakengine_event_subscribe( + mgr, OAKENGINE_EVENT_COLOR_MANAGER_REFERENCE_SPACE_CHANGED, + count_color_events, NULL); + int64_t sub_cfg = oakengine_event_subscribe( + mgr, OAKENGINE_EVENT_COLOR_MANAGER_CONFIG_CHANGED, + count_color_events, NULL); + assert(sub_ref > 0); + assert(sub_cfg > 0); + + // Changing the reference space emits reference_space_changed. + char ref[256]; + assert(oakengine_project_get_color_reference_space(project, ref, + sizeof(ref)) > 0); + assert(oakengine_project_set_color_reference_space( + project, strcmp(ref, "scene_linear") == 0 ? "reference" : + "scene_linear") == + OAKENGINE_OK); + assert(g_reference_events == 1); + assert(g_config_events == 0); + + assert(oakengine_event_unsubscribe(sub_ref) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_cfg) == OAKENGINE_OK); +} + +int main(void) +{ + make_tmpdir(); + + // Sandbox the config/cache/data locations (the default OCIO config is + // extracted under the cache location). +#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_null_robustness(); + + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + OakEngineColorManager *mgr = oakengine_color_manager_from_project(project); + assert(mgr != NULL); + + // The built-in default config should always be available (it is + // extracted from the engine's resources); tolerate environments where + // it is not by skipping the query assertions. + const int have_ocio = oakengine_color_manager_colorspace_count(mgr) > 0; + if (!have_ocio) { + printf("oakengine_color_test: no OCIO config available, skipping " + "query tests\n"); + } + + test_config_handle(have_ocio); + if (have_ocio) { + test_manager_queries(mgr); + test_processor(mgr); + } + test_events(project, mgr); + + oakengine_project_free(project); + assert(oakengine_shutdown() == OAKENGINE_OK); + + printf("oakengine_color_test: all assertions passed\n"); + return 0; +} diff --git a/engine/tests/oakengine_config_test.cpp b/engine/tests/oakengine_config_test.cpp new file mode 100644 index 000000000..538dec212 --- /dev/null +++ b/engine/tests/oakengine_config_test.cpp @@ -0,0 +1,118 @@ +/*** + + 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 tests for the liboakengine configuration facade +// (oakengine/config.h). Runs headless; no GPU required. + +#include +#include +#include + +#include "oakengine/config.h" +#include "oakengine/init.h" + +static int g_error_calls = 0; +static char g_last_title[256]; +static char g_last_message[256]; + +static void error_cb(const char *title, const char *message, void *userdata) +{ + (void) userdata; + g_error_calls++; + strncpy(g_last_title, title, sizeof(g_last_title) - 1); + g_last_title[sizeof(g_last_title) - 1] = '\0'; + strncpy(g_last_message, message, sizeof(g_last_message) - 1); + g_last_message[sizeof(g_last_message) - 1] = '\0'; +} + +static void test_string_round_trip(void) +{ + char buf[256]; + + // Missing key returns 0 (empty string). + assert(oakengine_config_get_string("oak_test_string_key", buf, + sizeof(buf)) == 0); + + assert(oakengine_config_set_string("oak_test_string_key", + "hello world") == OAKENGINE_OK); + int len = oakengine_config_get_string("oak_test_string_key", buf, + sizeof(buf)); + assert(len == int(strlen("hello world"))); + assert(strcmp(buf, "hello world") == 0); + + // Query length with NULL buffer. + assert(oakengine_config_get_string("oak_test_string_key", NULL, 0) == len); + + // NULL key is rejected. + assert(oakengine_config_get_string(NULL, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_config_set_string(NULL, "x") == OAKENGINE_E_INVALID); +} + +static void test_int_round_trip(void) +{ + assert(oakengine_config_get_int("oak_test_int_key", 42) == 42); + + assert(oakengine_config_set_int("oak_test_int_key", 12345) == + OAKENGINE_OK); + assert(oakengine_config_get_int("oak_test_int_key", 0) == 12345); + + assert(oakengine_config_set_int("oak_test_int_key", -7) == + OAKENGINE_OK); + assert(oakengine_config_get_int("oak_test_int_key", 0) == -7); + + // NULL key returns default. + assert(oakengine_config_get_int(NULL, 99) == 99); + assert(oakengine_config_set_int(NULL, 1) == OAKENGINE_E_INVALID); +} + +static void test_error_handler(void) +{ + g_error_calls = 0; + assert(oakengine_config_set_error_handler(error_cb, NULL) == + OAKENGINE_OK); + + assert(oakengine_config_report_error("Test Title", + "Test Message") == OAKENGINE_OK); + assert(g_error_calls == 1); + assert(strcmp(g_last_title, "Test Title") == 0); + assert(strcmp(g_last_message, "Test Message") == 0); + + // Clearing the handler does not crash. + assert(oakengine_config_set_error_handler(NULL, NULL) == OAKENGINE_OK); + assert(oakengine_config_report_error("Ignored", "Ignored") == + OAKENGINE_OK); + assert(g_error_calls == 1); +} + +int main(void) +{ + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + + test_string_round_trip(); + test_int_round_trip(); + test_error_handler(); + + assert(oakengine_config_save() == OAKENGINE_OK); + assert(oakengine_config_load() == OAKENGINE_OK); + + assert(oakengine_shutdown() == OAKENGINE_OK); + return 0; +} diff --git a/engine/tests/oakengine_disk_test.cpp b/engine/tests/oakengine_disk_test.cpp new file mode 100644 index 000000000..df60b3c39 --- /dev/null +++ b/engine/tests/oakengine_disk_test.cpp @@ -0,0 +1,301 @@ +/*** + + 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 disk cache family +// (oakengine/disk.h). Exercises the DiskManager instance lifecycle, default +// cache path queries, cache clearing, settings handler dispatch, folder +// handle lookup and default path mutation. Runs headless; no GPU required. + +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#include +#endif + +#include "oakengine/disk.h" +#include "oakengine/init.h" + +static char g_handler_path[512]; +static int g_handler_call_count; + +static void reset_handler_state(void) +{ + memset(g_handler_path, 0, sizeof(g_handler_path)); + g_handler_call_count = 0; +} + +static void settings_handler(const char *folder_path, void *parent_window, + void *userdata) +{ + (void) parent_window; + (void) userdata; + assert(folder_path != NULL); + assert(strlen(folder_path) > 0); + strncpy(g_handler_path, folder_path, sizeof(g_handler_path) - 1); + g_handler_path[sizeof(g_handler_path) - 1] = '\0'; + g_handler_call_count++; +} + +static void test_instance_lifecycle(void) +{ + char buf[512]; + + // DiskManager is created by oakengine_init(HEADLESS). + assert(oakengine_disk_get_default_cache_path(buf, sizeof(buf)) > 0); + + // Destroy is allowed and idempotent. + assert(oakengine_disk_destroy_instance() == OAKENGINE_OK); + assert(oakengine_disk_get_default_cache_path(buf, sizeof(buf)) == + OAKENGINE_E_STATE); + assert(oakengine_disk_destroy_instance() == OAKENGINE_OK); + + // Create recreates the instance. + assert(oakengine_disk_create_instance() == OAKENGINE_OK); + assert(oakengine_disk_get_default_cache_path(buf, sizeof(buf)) > 0); + + // Create is idempotent. + assert(oakengine_disk_create_instance() == OAKENGINE_OK); + assert(oakengine_disk_get_default_cache_path(buf, sizeof(buf)) > 0); +} + +static void test_default_cache_path(void) +{ + char buf[512]; + memset(buf, 0, sizeof(buf)); + + const int len = oakengine_disk_get_default_cache_path(buf, sizeof(buf)); + assert(len > 0); + assert((int) strlen(buf) == len); + assert(strchr(buf, '/') != NULL || strchr(buf, '\\') != NULL); + + // Query length with NULL buffer. + assert(oakengine_disk_get_default_cache_path(NULL, 0) == len); +} + +static void test_open_folder_handle(void) +{ + char buf[512]; + assert(oakengine_disk_get_default_cache_path(buf, sizeof(buf)) > 0); + + void *folder = oakengine_disk_get_open_folder(buf); + assert(folder != NULL); + + // NULL/empty path returns the default folder handle. + void *default_folder = oakengine_disk_get_open_folder(nullptr); + assert(default_folder == folder); + + void *empty_folder = oakengine_disk_get_open_folder(""); + assert(empty_folder == folder); + + // A different path opens a distinct folder. + char tmp[256]; + snprintf(tmp, sizeof(tmp), +#if defined(_WIN32) + "%s\\oakengine_disk_test_folder_XXXXXX", +#else + "%s/oakengine_disk_test_folder_XXXXXX", +#endif + getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + +#if defined(_WIN32) + char *tmpdir = _mktemp(tmp); + assert(tmpdir != NULL); + assert(_mkdir(tmpdir) == 0); +#else + char *tmpdir = mkdtemp(tmp); + assert(tmpdir != NULL); +#endif + + void *other_folder = oakengine_disk_get_open_folder(tmpdir); + assert(other_folder != NULL); + assert(other_folder != folder); + + // The same path returns the same handle. + void *other_folder_again = oakengine_disk_get_open_folder(tmpdir); + assert(other_folder_again == other_folder); + +#if defined(_WIN32) + _rmdir(tmpdir); +#else + rmdir(tmpdir); +#endif +} + +static void test_clear_cache(void) +{ + // Create a temporary cache directory and seed it with a file. + char path[256]; + snprintf(path, sizeof(path), +#if defined(_WIN32) + "%s\\oakengine_disk_test_cache_XXXXXX", +#else + "%s/oakengine_disk_test_cache_XXXXXX", +#endif + getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + +#if defined(_WIN32) + char *tmpdir = _mktemp(path); + assert(tmpdir != NULL); + assert(_mkdir(tmpdir) == 0); +#else + char *tmpdir = mkdtemp(path); + assert(tmpdir != NULL); +#endif + + char index_file[512]; + snprintf(index_file, sizeof(index_file), +#if defined(_WIN32) + "%s\\index", tmpdir); +#else + "%s/index", tmpdir); +#endif + + FILE *f = fopen(index_file, "w"); + assert(f != NULL); + fclose(f); + + // clear_cache opens the folder and clears its contents. + assert(oakengine_disk_clear_cache(tmpdir) == 1); + + // Re-create a file and clear again to ensure idempotency. + f = fopen(index_file, "w"); + assert(f != NULL); + fclose(f); + assert(oakengine_disk_clear_cache(tmpdir) == 1); + +#if defined(_WIN32) + _rmdir(tmpdir); +#else + rmdir(tmpdir); +#endif +} + +static void test_settings_handler_round_trip(void) +{ + reset_handler_state(); + + assert(oakengine_disk_set_settings_handler(settings_handler, NULL) == + OAKENGINE_OK); + + // NULL path uses the default folder. + assert(oakengine_disk_show_settings_dialog(NULL, NULL) == OAKENGINE_OK); + assert(g_handler_call_count == 1); + assert(strlen(g_handler_path) > 0); + + // Calling again with a specific path invokes the handler with that path. + assert(oakengine_disk_show_settings_dialog(g_handler_path, NULL) == + OAKENGINE_OK); + assert(g_handler_call_count == 2); + assert(strcmp(g_handler_path, g_handler_path) == 0); + + // Clearing the handler is allowed and results in a logged skip. + assert(oakengine_disk_set_settings_handler(NULL, NULL) == OAKENGINE_OK); + assert(oakengine_disk_show_settings_dialog(g_handler_path, NULL) == + OAKENGINE_OK); + assert(g_handler_call_count == 2); +} + +static void test_invalidate_project(void) +{ + // No instance returns an error. + assert(oakengine_disk_destroy_instance() == OAKENGINE_OK); + assert(oakengine_disk_invalidate_project(NULL) == OAKENGINE_E_STATE); + + assert(oakengine_disk_create_instance() == OAKENGINE_OK); + + // NULL project is accepted (signal emitted with null pointer). + assert(oakengine_disk_invalidate_project(NULL) == OAKENGINE_OK); + + // Valid project returns OK without crashing. + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_disk_invalidate_project(project) == OAKENGINE_OK); + oakengine_project_free(project); +} + +static void test_set_default_cache_path(void) +{ + char original[512]; + assert(oakengine_disk_get_default_cache_path(original, sizeof(original)) > + 0); + + char tmp[256]; + snprintf(tmp, sizeof(tmp), +#if defined(_WIN32) + "%s\\oakengine_disk_test_default_XXXXXX", +#else + "%s/oakengine_disk_test_default_XXXXXX", +#endif + getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + +#if defined(_WIN32) + char *tmpdir = _mktemp(tmp); + assert(tmpdir != NULL); + assert(_mkdir(tmpdir) == 0); +#else + char *tmpdir = mkdtemp(tmp); + assert(tmpdir != NULL); +#endif + + assert(oakengine_disk_set_default_cache_path(tmpdir) == OAKENGINE_OK); + + char updated[512]; + assert(oakengine_disk_get_default_cache_path(updated, sizeof(updated)) > 0); + assert(strcmp(updated, tmpdir) == 0); + + // Restore original default path. + assert(oakengine_disk_set_default_cache_path(original) == OAKENGINE_OK); + assert(oakengine_disk_get_default_cache_path(updated, sizeof(updated)) > 0); + assert(strcmp(updated, original) == 0); + +#if defined(_WIN32) + _rmdir(tmpdir); +#else + rmdir(tmpdir); +#endif +} + +int main(void) +{ + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + + test_instance_lifecycle(); + test_default_cache_path(); + test_open_folder_handle(); + test_clear_cache(); + test_settings_handler_round_trip(); + test_set_default_cache_path(); + test_invalidate_project(); + + // Leave DiskManager in the initialized state for shutdown. + oakengine_disk_create_instance(); + + oakengine_shutdown(); + + printf("oakengine_disk_test: OK\n"); + return 0; +} diff --git a/engine/tests/oakengine_encoding_test.cpp b/engine/tests/oakengine_encoding_test.cpp new file mode 100644 index 000000000..fa6fcb58d --- /dev/null +++ b/engine/tests/oakengine_encoding_test.cpp @@ -0,0 +1,659 @@ +/*** + + 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 encoding facade +// (oakengine/encoding.h + oakengine/videoparams.h). Exercises the +// format/codec metadata queries, the image-sequence filename helpers, the +// scaling matrix, the OakEngineEncodingParams handle (getter/setter +// roundtrips, preset file load/save) and the VideoParams static data behind +// the standard combo boxes. No GPU: the export execution path itself is +// covered by oakengine_export_test; here only the error paths of +// render_with_params are touched (no sequence). + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#endif + +#include "oakengine/encoding.h" +#include "oakengine/init.h" +#include "oakengine/videoparams.h" + +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_encoding_test_%lu", + base, (unsigned long)GetCurrentProcessId()); + assert(_mkdir(g_tmpdir) == 0); +#else + strcpy(g_tmpdir, "/tmp/oakengine_encoding_test_XXXXXX"); + assert(mkdtemp(g_tmpdir) != NULL); +#endif +} + +static void test_format_metadata(void) +{ + char buf[256]; + + assert(oakengine_encoding_format_count() > 0); + + // Matroska + assert(oakengine_encoding_format_name(OAKENGINE_ENCODING_FORMAT_MATROSKA, + buf, sizeof(buf)) > 0); + assert(strstr(buf, "Matroska") != NULL); + assert(oakengine_encoding_format_extension( + OAKENGINE_ENCODING_FORMAT_MATROSKA, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "mkv") == 0); + + // Invalid format + assert(oakengine_encoding_format_name(-1, buf, sizeof(buf)) == -1); + assert(oakengine_encoding_format_extension(9999, buf, sizeof(buf)) == -1); + assert(oakengine_encoding_format_video_codec_count(-1) == -1); + + // MP4 carries H.264 video and AAC audio + const int vcount = + oakengine_encoding_format_video_codec_count(OAKENGINE_ENCODING_FORMAT_MPEG4_VIDEO); + assert(vcount > 0); + int found_h264 = 0; + for (int i = 0; i < vcount; i++) { + if (oakengine_encoding_format_video_codec_at( + OAKENGINE_ENCODING_FORMAT_MPEG4_VIDEO, i) == + OAKENGINE_ENCODING_CODEC_H264) { + found_h264 = 1; + } + } + assert(found_h264); + assert(oakengine_encoding_format_video_codec_at( + OAKENGINE_ENCODING_FORMAT_MPEG4_VIDEO, vcount) == -1); + + // WAV is audio-only and carries PCM + assert(oakengine_encoding_format_video_codec_count( + OAKENGINE_ENCODING_FORMAT_WAV) == 0); + const int acount = oakengine_encoding_format_audio_codec_count( + OAKENGINE_ENCODING_FORMAT_WAV); + assert(acount > 0); + int found_pcm = 0; + for (int i = 0; i < acount; i++) { + if (oakengine_encoding_format_audio_codec_at( + OAKENGINE_ENCODING_FORMAT_WAV, i) == OAKENGINE_ENCODING_CODEC_PCM) { + found_pcm = 1; + } + } + assert(found_pcm); + + // SRT is subtitles-only + assert(oakengine_encoding_format_subtitle_codec_count( + OAKENGINE_ENCODING_FORMAT_SRT) > 0); + assert(oakengine_encoding_format_subtitle_codec_at( + OAKENGINE_ENCODING_FORMAT_SRT, 0) >= 0); + assert(oakengine_encoding_format_subtitle_codec_at( + OAKENGINE_ENCODING_FORMAT_SRT, -1) < 0); + assert(oakengine_encoding_format_audio_codec_count( + OAKENGINE_ENCODING_FORMAT_SRT) == 0); +} + +static void test_codec_metadata(void) +{ + char buf[256]; + + assert(oakengine_encoding_codec_name(OAKENGINE_ENCODING_CODEC_H264, buf, + sizeof(buf)) > 0); + assert(buf[0] != '\0'); + assert(oakengine_encoding_codec_name(-1, buf, sizeof(buf)) == -1); + + assert(oakengine_encoding_codec_is_still_image(5 /* PNG */) == 1); + assert(oakengine_encoding_codec_is_still_image( + OAKENGINE_ENCODING_CODEC_H264) == 0); + assert(oakengine_encoding_codec_is_lossless(OAKENGINE_ENCODING_CODEC_PCM) == + 1); + assert(oakengine_encoding_codec_is_lossless(OAKENGINE_ENCODING_CODEC_AAC) == + 0); + + // Encoded pixel formats of H.264 in MP4: yuv420p is the preferred one + const int pcount = oakengine_encoding_pix_fmt_count( + OAKENGINE_ENCODING_FORMAT_MPEG4_VIDEO, OAKENGINE_ENCODING_CODEC_H264); + assert(pcount > 0); + assert(oakengine_encoding_pix_fmt_at(OAKENGINE_ENCODING_FORMAT_MPEG4_VIDEO, + OAKENGINE_ENCODING_CODEC_H264, 0, buf, + sizeof(buf)) > 0); + assert(strcmp(buf, "yuv420p") == 0); + assert(oakengine_encoding_pix_fmt_at(OAKENGINE_ENCODING_FORMAT_MPEG4_VIDEO, + OAKENGINE_ENCODING_CODEC_H264, pcount, + buf, sizeof(buf)) == -1); + assert(oakengine_encoding_pix_fmt_index(OAKENGINE_ENCODING_CODEC_H264, + "yuv420p") == 0); + assert(oakengine_encoding_pix_fmt_index(OAKENGINE_ENCODING_CODEC_H264, + "no-such-format") == 0); + assert(oakengine_encoding_pix_fmt_index(OAKENGINE_ENCODING_CODEC_H264, + NULL) == 0); + + // Sample formats of PCM in WAV + const int scount = oakengine_encoding_sample_format_count( + OAKENGINE_ENCODING_FORMAT_WAV, OAKENGINE_ENCODING_CODEC_PCM); + assert(scount > 0); + for (int i = 0; i < scount; i++) { + assert(oakengine_encoding_sample_format_at( + OAKENGINE_ENCODING_FORMAT_WAV, OAKENGINE_ENCODING_CODEC_PCM, + i) >= 0); + } + assert(oakengine_encoding_sample_format_at( + OAKENGINE_ENCODING_FORMAT_WAV, OAKENGINE_ENCODING_CODEC_PCM, + scount) == -1); +} + +static void test_filename_helpers(void) +{ + char buf[4096]; + + assert(oakengine_encoding_filename_contains_digit_placeholder( + "/tmp/out_[#####].png") == 1); + assert(oakengine_encoding_filename_contains_digit_placeholder( + "/tmp/out.png") == 0); + assert(oakengine_encoding_filename_contains_digit_placeholder(NULL) == 0); + + assert(oakengine_encoding_image_sequence_digit_count( + "/tmp/out_[#####].png") == 5); + assert(oakengine_encoding_image_sequence_digit_count("/tmp/out.png") == 0); + + assert(oakengine_encoding_filename_remove_digit_placeholder( + "/tmp/out_[#####].png", buf, sizeof(buf)) > 0); + assert(strstr(buf, "[#####]") == NULL); + assert(strstr(buf, ".png") != NULL); +} + +static void test_generate_matrix(void) +{ + float m[16]; + + // Fit with matching dimensions is the identity + assert(oakengine_encoding_generate_matrix(OAKENGINE_ENCODING_SCALING_FIT, + 1920, 1080, 1920, 1080, + m) == OAKENGINE_OK); + const float identity[16] = { 1, 0, 0, 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0, 0, 0, 1 }; + for (int i = 0; i < 16; i++) { + assert(fabsf(m[i] - identity[i]) < 1e-6f); + } + + // Stretch is the identity transform (the preview is normalized device + // coordinates; stretching needs no matrix) + assert(oakengine_encoding_generate_matrix(OAKENGINE_ENCODING_SCALING_STRETCH, + 960, 540, 1920, 1080, + m) == OAKENGINE_OK); + for (int i = 0; i < 16; i++) { + assert(fabsf(m[i] - identity[i]) < 1e-6f); + } + + // Fit into a wider-than-source frame pillarboxes: x scale shrinks to + // source_ar/export_ar + assert(oakengine_encoding_generate_matrix(OAKENGINE_ENCODING_SCALING_FIT, + 1920, 1080, 1920, 540, + m) == OAKENGINE_OK); + const float expected_x = (1920.0f / 1080.0f) / (1920.0f / 540.0f); + assert(fabsf(m[0] - expected_x) < 1e-5f); + assert(fabsf(m[5] - 1.0f) < 1e-6f); + + // Invalid arguments + assert(oakengine_encoding_generate_matrix(-1, 1, 1, 1, 1, + m) == OAKENGINE_E_INVALID); + assert(oakengine_encoding_generate_matrix(OAKENGINE_ENCODING_SCALING_FIT, 0, + 1, 1, 1, + m) == OAKENGINE_E_INVALID); +} + +static void test_params_handle(void) +{ + char buf[1024]; + + OakEngineEncodingParams *p = oakengine_encoding_params_create(); + assert(p != NULL); + + // Fresh handle: nothing enabled, format unset + assert(oakengine_encoding_params_is_valid(p) == 0); + assert(oakengine_encoding_params_format(p) == -1); + assert(oakengine_encoding_params_video_enabled(p) == 0); + assert(oakengine_encoding_params_audio_enabled(p) == 0); + assert(oakengine_encoding_params_subtitles_enabled(p) == 0); + assert(oakengine_encoding_params_has_custom_range(p) == 0); + + // Filename / format roundtrip + assert(oakengine_encoding_params_set_filename(p, "/tmp/out.mp4") == + OAKENGINE_OK); + assert(oakengine_encoding_params_filename(p, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "/tmp/out.mp4") == 0); + assert(oakengine_encoding_params_set_format( + p, OAKENGINE_ENCODING_FORMAT_MPEG4_VIDEO) == OAKENGINE_OK); + assert(oakengine_encoding_params_format(p) == + OAKENGINE_ENCODING_FORMAT_MPEG4_VIDEO); + assert(oakengine_encoding_params_set_format(p, 9999) == + OAKENGINE_E_INVALID); + + // Video roundtrip + oak_video_params v = {}; + v.width = 1920; + v.height = 1080; + v.time_base_num = 1001; + v.time_base_den = 30000; + v.format = 8; /* a PixelFormat::Format value */ + v.pixel_aspect_num = 1; + v.pixel_aspect_den = 1; + v.interlacing = OAKENGINE_ENCODING_INTERLACE_BOTTOM_FIRST; + v.color_range = OAKENGINE_ENCODING_COLOR_RANGE_FULL; + v.divider = 1; + assert(oakengine_encoding_params_enable_video( + p, &v, OAKENGINE_ENCODING_CODEC_H264) == OAKENGINE_OK); + assert(oakengine_encoding_params_is_valid(p) == 1); + assert(oakengine_encoding_params_video_enabled(p) == 1); + assert(oakengine_encoding_params_video_codec(p) == + OAKENGINE_ENCODING_CODEC_H264); + assert(oakengine_encoding_params_enable_video(p, NULL, 1) == + OAKENGINE_E_INVALID); + assert(oakengine_encoding_params_enable_video(p, &v, 9999) == + OAKENGINE_E_INVALID); + + oak_video_params back = {}; + assert(oakengine_encoding_params_get_video_params(p, &back) == + OAKENGINE_OK); + assert(back.width == 1920 && back.height == 1080); + assert(back.time_base_num == 1001 && back.time_base_den == 30000); + assert(back.pixel_aspect_num == 1 && back.pixel_aspect_den == 1); + assert(back.interlacing == OAKENGINE_ENCODING_INTERLACE_BOTTOM_FIRST); + assert(back.color_range == OAKENGINE_ENCODING_COLOR_RANGE_FULL); + + // Audio roundtrip + assert(oakengine_encoding_params_enable_audio(p, 48000, 0x3, 4, + OAKENGINE_ENCODING_CODEC_AAC) == + OAKENGINE_OK); + assert(oakengine_encoding_params_audio_enabled(p) == 1); + assert(oakengine_encoding_params_audio_codec(p) == + OAKENGINE_ENCODING_CODEC_AAC); + int sample_rate = 0, sample_format = 0; + uint64_t layout = 0; + assert(oakengine_encoding_params_get_audio_params(p, &sample_rate, &layout, + &sample_format) == + OAKENGINE_OK); + assert(sample_rate == 48000 && layout == 0x3 && sample_format == 4); + assert(oakengine_encoding_params_enable_audio(p, 0, 0x3, 4, 0) == + OAKENGINE_E_INVALID); + + // Subtitles (embedded, then sidecar) + assert(oakengine_encoding_params_enable_subtitles( + p, OAKENGINE_ENCODING_CODEC_SRT) == OAKENGINE_OK); + assert(oakengine_encoding_params_subtitles_enabled(p) == 1); + assert(oakengine_encoding_params_subtitles_are_sidecar(p) == 0); + assert(oakengine_encoding_params_subtitles_codec(p) == + OAKENGINE_ENCODING_CODEC_SRT); + assert(oakengine_encoding_params_enable_sidecar_subtitles( + p, OAKENGINE_ENCODING_FORMAT_SRT, + OAKENGINE_ENCODING_CODEC_SRT) == OAKENGINE_OK); + assert(oakengine_encoding_params_subtitles_are_sidecar(p) == 1); + assert(oakengine_encoding_params_subtitles_sidecar_format(p) == + OAKENGINE_ENCODING_FORMAT_SRT); + + // Scalar setters/getters + oakengine_encoding_params_set_video_bit_rate(p, 8000000); + assert(oakengine_encoding_params_video_bit_rate(p) == 8000000); + oakengine_encoding_params_set_video_min_bit_rate(p, 1000); + assert(oakengine_encoding_params_video_min_bit_rate(p) == 1000); + oakengine_encoding_params_set_video_max_bit_rate(p, 16000000); + assert(oakengine_encoding_params_video_max_bit_rate(p) == 16000000); + oakengine_encoding_params_set_video_buffer_size(p, 2000000); + assert(oakengine_encoding_params_video_buffer_size(p) == 2000000); + oakengine_encoding_params_set_video_threads(p, 4); + assert(oakengine_encoding_params_video_threads(p) == 4); + oakengine_encoding_params_set_audio_bit_rate(p, 320000); + assert(oakengine_encoding_params_audio_bit_rate(p) == 320000); + + assert(oakengine_encoding_params_set_video_pix_fmt(p, "yuv420p") == + OAKENGINE_OK); + assert(oakengine_encoding_params_video_pix_fmt(p, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "yuv420p") == 0); + + oakengine_encoding_params_set_video_is_image_sequence(p, 1); + assert(oakengine_encoding_params_video_is_image_sequence(p) == 1); + oakengine_encoding_params_set_video_is_image_sequence(p, 0); + assert(oakengine_encoding_params_video_is_image_sequence(p) == 0); + + assert(oakengine_encoding_params_set_color_transform(p, "sRGB OETF") == + OAKENGINE_OK); + assert(oakengine_encoding_params_color_transform_output(p, buf, + sizeof(buf)) > 0); + assert(strcmp(buf, "sRGB OETF") == 0); + + oakengine_encoding_params_set_export_length(p, 10, 1); + int num = 0, den = 0; + assert(oakengine_encoding_params_get_export_length(p, &num, &den) == + OAKENGINE_OK); + assert(num == 10 && den == 1); + + // Custom range + oakengine_encoding_params_set_custom_range(p, 1, 1, 5, 1); + assert(oakengine_encoding_params_has_custom_range(p) == 1); + int64_t in_num = 0, in_den = 0, out_num = 0, out_den = 0; + assert(oakengine_encoding_params_get_custom_range(p, &in_num, &in_den, + &out_num, + &out_den) == OAKENGINE_OK); + assert(in_num == 1 && in_den == 1 && out_num == 5 && out_den == 1); + + // Scaling method + assert(oakengine_encoding_params_set_video_scaling_method( + p, OAKENGINE_ENCODING_SCALING_CROP) == OAKENGINE_OK); + assert(oakengine_encoding_params_video_scaling_method(p) == + OAKENGINE_ENCODING_SCALING_CROP); + assert(oakengine_encoding_params_set_video_scaling_method(p, 42) == + OAKENGINE_E_INVALID); + + // Video options + assert(oakengine_encoding_params_video_option(p, "crf", buf, + sizeof(buf)) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_encoding_params_set_video_option(p, "crf", "18") == + OAKENGINE_OK); + assert(oakengine_encoding_params_video_option(p, "crf", buf, + sizeof(buf)) > 0); + assert(strcmp(buf, "18") == 0); + + // Disables + oakengine_encoding_params_disable_subtitles(p); + assert(oakengine_encoding_params_subtitles_enabled(p) == 0); + oakengine_encoding_params_disable_video(p); + assert(oakengine_encoding_params_video_enabled(p) == 0); + assert(oakengine_encoding_params_get_video_params(p, &back) == + OAKENGINE_E_STATE); + oakengine_encoding_params_disable_audio(p); + assert(oakengine_encoding_params_audio_enabled(p) == 0); + + // NULL safety + oakengine_encoding_params_destroy(NULL); + assert(oakengine_encoding_params_is_valid(NULL) == 0); + + oakengine_encoding_params_destroy(p); +} + +static void test_preset_files(void) +{ + char buf[1024]; + + // Preset directory listing is readable (may be empty in the sandbox) + assert(oakengine_encoding_preset_path(buf, sizeof(buf)) > 0); + const int count = oakengine_encoding_preset_count(); + assert(count >= 0); + for (int i = 0; i < count; i++) { + assert(oakengine_encoding_preset_name(i, buf, sizeof(buf)) > 0); + } + assert(oakengine_encoding_preset_name(count, buf, sizeof(buf)) == -1); + + // Save/load roundtrip through a temp file + char path[4096]; + snprintf(path, sizeof(path), "%s/preset.xml", g_tmpdir); + + OakEngineEncodingParams *p = oakengine_encoding_params_create(); + assert(oakengine_encoding_params_set_filename(p, "/tmp/out.mp4") == + OAKENGINE_OK); + assert(oakengine_encoding_params_set_format( + p, OAKENGINE_ENCODING_FORMAT_MPEG4_VIDEO) == OAKENGINE_OK); + oak_video_params v = {}; + v.width = 1280; + v.height = 720; + v.time_base_num = 1; + v.time_base_den = 25; + v.format = 8; + v.pixel_aspect_num = 1; + v.pixel_aspect_den = 1; + v.interlacing = OAKENGINE_ENCODING_INTERLACE_NONE; + v.color_range = OAKENGINE_ENCODING_COLOR_RANGE_LIMITED; + v.divider = 1; + assert(oakengine_encoding_params_enable_video( + p, &v, OAKENGINE_ENCODING_CODEC_H264) == OAKENGINE_OK); + assert(oakengine_encoding_params_set_video_option(p, "crf", "20") == + OAKENGINE_OK); + assert(oakengine_encoding_params_save_file(p, path) == OAKENGINE_OK); + oakengine_encoding_params_destroy(p); + + OakEngineEncodingParams *q = oakengine_encoding_params_create(); + assert(oakengine_encoding_params_load_file(q, path) == OAKENGINE_OK); + assert(oakengine_encoding_params_video_enabled(q) == 1); + assert(oakengine_encoding_params_video_codec(q) == + OAKENGINE_ENCODING_CODEC_H264); + oak_video_params back = {}; + assert(oakengine_encoding_params_get_video_params(q, &back) == + OAKENGINE_OK); + assert(back.width == 1280 && back.height == 720); + assert(back.time_base_num == 1 && back.time_base_den == 25); + assert(oakengine_encoding_params_video_option(q, "crf", buf, + sizeof(buf)) > 0); + assert(strcmp(buf, "20") == 0); + oakengine_encoding_params_destroy(q); + + // Loading a nonexistent file fails + OakEngineEncodingParams *r = oakengine_encoding_params_create(); + assert(oakengine_encoding_params_load_file(r, "/no/such/file.xml") == + OAKENGINE_E_FAILED); + oakengine_encoding_params_destroy(r); + + // Bad arguments + assert(oakengine_encoding_params_save_file(NULL, path) == + OAKENGINE_E_INVALID); +} + +static void test_last_used_and_render_errors(void) +{ + // NULL sequence: no last-used params, no-op setter + assert(oakengine_encoding_params_get_last_used(NULL) == NULL); + oakengine_encoding_params_set_last_used(NULL, NULL); + + // render_with_params without a valid sequence/params fails cleanly + assert(oakengine_export_render_with_params(NULL, NULL) == + OAKENGINE_E_INVALID); + OakEngineEncodingParams *p = oakengine_encoding_params_create(); + assert(oakengine_export_render_with_params(NULL, p) == + OAKENGINE_E_INVALID); + // Nothing enabled on the handle + assert(oakengine_export_render_with_params( + reinterpret_cast(p), p) == + OAKENGINE_E_INVALID); + oakengine_encoding_params_destroy(p); + + // Audio recording requires an enabled audio track on the handle + assert(oakengine_encoding_start_audio_recording(NULL, NULL, 0) == + OAKENGINE_E_INVALID); +} + +static void test_video_params_statics(void) +{ + char buf[256]; + int num = 0, den = 0; + + // Standard frame rates + const int fr_count = oakengine_video_params_supported_frame_rate_count(); + assert(fr_count > 0); + for (int i = 0; i < fr_count; i++) { + assert(oakengine_video_params_supported_frame_rate_at(i, &num, &den) == + OAKENGINE_OK); + assert(num > 0 && den > 0); + } + assert(oakengine_video_params_supported_frame_rate_at(fr_count, &num, + &den) == + OAKENGINE_E_INVALID); + + // 24000/1001 prints as 23.976... + assert(oakengine_video_params_frame_rate_to_string(24000, 1001, buf, + sizeof(buf)) > 0); + assert(strstr(buf, "23.97") != NULL); + + // Standard pixel aspects: the first one is square (1:1) + const int pa_count = oakengine_video_params_standard_pixel_aspect_count(); + assert(pa_count > 0); + assert(oakengine_video_params_standard_pixel_aspect_at(0, &num, &den) == + OAKENGINE_OK); + assert(num == 1 && den == 1); + assert(oakengine_video_params_standard_pixel_aspect_name(0, buf, + sizeof(buf)) > 0); + assert(buf[0] != '\0'); + assert(oakengine_video_params_standard_pixel_aspect_at(pa_count, &num, + &den) == + OAKENGINE_E_INVALID); + + // Custom PAR label template + assert(oakengine_video_params_format_pixel_aspect_ratio_string( + "Custom (%1)", 32, 27, buf, sizeof(buf)) > 0); + assert(strstr(buf, "Custom") != NULL); + + // Dividers + const int div_count = oakengine_video_params_supported_divider_count(); + assert(div_count > 0); + for (int i = 0; i < div_count; i++) { + const int d = oakengine_video_params_supported_divider_at(i); + assert(d > 0); + assert(oakengine_video_params_divider_name(d, buf, sizeof(buf)) > 0); + } + assert(oakengine_video_params_supported_divider_at(div_count) == -1); + + // Pixel format names: some entry must be non-empty + assert(oakengine_video_params_pixel_format_name(8, buf, sizeof(buf)) > 0); + + // Float detection (8-bit integer formats are not float) + assert(oakengine_video_params_format_is_float(0) == 0); + + // Effective (divider-scaled) size + int w = 0, h = 0; + assert(oakengine_video_params_effective_size(1920, 1080, 2, &w, &h) == + OAKENGINE_OK); + assert(w == 960 && h == 540); + assert(oakengine_video_params_effective_size(0, 1080, 2, &w, &h) == + OAKENGINE_E_INVALID); +} + +// POD mirror of the display-path VideoParams (B7): make/equal/is_valid, +// bytes-per-pixel and the internal channel count. +static void test_video_params_pod(void) +{ + oak_video_params p, q; + + // make fills every field + assert(oakengine_video_params_make(&p, 1920, 1080, 1001, 30000, 0, 1, 1, + 0, 0, 2) == OAKENGINE_OK); + assert(p.width == 1920 && p.height == 1080); + assert(p.time_base_num == 1001 && p.time_base_den == 30000); + assert(p.pixel_aspect_num == 1 && p.pixel_aspect_den == 1); + assert(p.divider == 2); + assert(oakengine_video_params_make(NULL, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1) == + OAKENGINE_E_INVALID); + + // equal: identical PODs match, any single-field difference does not + q = p; + assert(oakengine_video_params_equal(&p, &q) == 1); + assert(oakengine_video_params_equal(&p, NULL) == 0); + assert(oakengine_video_params_equal(NULL, &q) == 0); + q.divider = 1; + assert(oakengine_video_params_equal(&p, &q) == 0); + q = p; + q.interlacing = 1; + assert(oakengine_video_params_equal(&p, &q) == 0); + + // is_valid: positive dimensions + in-range format passes; zero + // dimensions or an out-of-range format fail + assert(oakengine_video_params_is_valid(&p) == 1); + assert(oakengine_video_params_is_valid(NULL) == 0); + q = p; + q.width = 0; + assert(oakengine_video_params_is_valid(&q) == 0); + q = p; + q.format = -1; // olive::PixelFormat::invalid + assert(oakengine_video_params_is_valid(&q) == 0); + + // bytes per pixel: u8 RGBA = 4, f32 RGBA = 16 (format values follow + // olive::PixelFormat::Format: 0 = u8, 4 = f32) + const int channels = oakengine_video_params_internal_channel_count(); + assert(channels == 4); + assert(oakengine_video_params_bytes_per_pixel(0, channels) == 4); + assert(oakengine_video_params_bytes_per_pixel(4, channels) == 16); +} + +// Engine-side VideoParams construction used by the app during R6 to avoid +// pulling C++ constructors into oak-editor. +static void test_video_params_create_free(void) +{ + // NULL pod -> NULL handle + assert(oakengine_video_params_create(NULL) == NULL); + + // Empty POD -> default-constructed VideoParams handle + oak_video_params empty = {}; + void *vp_empty = oakengine_video_params_create(&empty); + assert(vp_empty != NULL); + oakengine_video_params_free(vp_empty); + + // Display-path POD with explicit timebase + oak_video_params pod; + assert(oakengine_video_params_make(&pod, 1920, 1080, 1001, 30000, 0, 1, 1, + 0, 0, 1) == OAKENGINE_OK); + void *vp = oakengine_video_params_create(&pod); + assert(vp != NULL); + oakengine_video_params_free(vp); + + // Display-path POD without timebase (uses constructor without timebase) + oak_video_params pod2 = {}; + pod2.width = 640; + pod2.height = 480; + pod2.format = 0; // u8 + void *vp2 = oakengine_video_params_create(&pod2); + assert(vp2 != NULL); + oakengine_video_params_free(vp2); + + // free(NULL) is a no-op + oakengine_video_params_free(NULL); +} + +int main(void) +{ + make_tmpdir(); + + // HEADLESS: no GL, but a QCoreApplication (needed by the FFmpeg encoder + // probes and QStandardPaths behind the metadata queries) comes up. + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + + test_format_metadata(); + test_codec_metadata(); + test_filename_helpers(); + test_generate_matrix(); + test_params_handle(); + test_preset_files(); + test_last_used_and_render_errors(); + test_video_params_statics(); + test_video_params_create_free(); + + oakengine_shutdown(); + + printf("oakengine_encoding_test: OK\n"); + return 0; +} diff --git a/engine/tests/oakengine_events_test.cpp b/engine/tests/oakengine_events_test.cpp new file mode 100644 index 000000000..8bd11b7b1 --- /dev/null +++ b/engine/tests/oakengine_events_test.cpp @@ -0,0 +1,960 @@ +/*** + + 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 event subscription family +// (oakengine/events.h) and the track block traversal family +// (oakengine_track_nearest_block_* / oakengine_block_*). Every subscription +// is exercised by provoking a real engine change through the facade and +// asserting the callback fired with the documented payload. No GL required. + +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#endif + +#include "oakengine/events.h" +#include "oakengine/footage.h" +#include "oakengine/init.h" +#include "oakengine/node.h" +#include "oakengine/project.h" +#include "oakengine/timeline.h" +#include "oakengine/viewer.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_events_test_%lu", base, + (unsigned long)GetCurrentProcessId()); + assert(_mkdir(g_tmpdir) == 0); +#else + strcpy(g_tmpdir, "/tmp/oakengine_events_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); +} + +// Callback recorder: counts deliveries per event id and keeps the last +// payload of each. +#define MAX_TRACKED_EVENT 128 + +typedef struct { + int count[MAX_TRACKED_EVENT]; + int64_t last_a[MAX_TRACKED_EVENT]; + int64_t last_b[MAX_TRACKED_EVENT]; + int64_t last_c[MAX_TRACKED_EVENT]; + void *last_source[MAX_TRACKED_EVENT]; + void *last_handle[MAX_TRACKED_EVENT]; + char last_s[MAX_TRACKED_EVENT][256]; +} EventLog; + +static void record_event(const oakengine_event *event, void *userdata) +{ + EventLog *log = (EventLog *)userdata; + assert(event != NULL); + assert(event->id > 0 && event->id < MAX_TRACKED_EVENT); + log->count[event->id]++; + log->last_a[event->id] = event->a; + log->last_b[event->id] = event->b; + log->last_c[event->id] = event->c; + log->last_source[event->id] = event->source; + log->last_handle[event->id] = event->handle; + snprintf(log->last_s[event->id], sizeof(log->last_s[event->id]), "%s", + event->s ? event->s : ""); +} + +static void reset_event(EventLog *log, int id) +{ + log->count[id] = 0; +} + +// ---- Subscription validation ---------------------------------------------- + +static void test_subscribe_validation(OakEngineProject *project, + OakEngineSequence *seq, + OakEngineTrack *track) +{ + EventLog log; + memset(&log, 0, sizeof(log)); + + // NULL handle / NULL callback / unknown event id. + assert(oakengine_event_subscribe( + NULL, OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED, record_event, + &log) == 0); + assert(oakengine_event_subscribe(project, + OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED, + NULL, &log) == 0); + assert(oakengine_event_subscribe(project, 999, record_event, &log) == 0); + + // Handle/event family mismatches. + assert(oakengine_event_subscribe( + seq, OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED, record_event, + &log) == 0); + assert(oakengine_event_subscribe(project, + OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED, + record_event, &log) == 0); + assert(oakengine_event_subscribe(track, + OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM, + record_event, &log) == 0); + assert(oakengine_event_subscribe(project, + OAKENGINE_EVENT_TRACK_BLOCK_ADDED, + record_event, &log) == 0); + + // Bad unsubscribe arguments. + assert(oakengine_event_unsubscribe(0) == OAKENGINE_E_INVALID); + assert(oakengine_event_unsubscribe(-5) == OAKENGINE_E_INVALID); + assert(oakengine_event_unsubscribe(424242) == OAKENGINE_E_NOT_FOUND); +} + +// ---- Project events --------------------------------------------------------- + +static void test_project_events(OakEngineProject *project) +{ + EventLog log; + memset(&log, 0, sizeof(log)); + + // Normalize to unmodified first: modified_changed only fires on an + // actual flip, and the setup above already dirtied the project. + oakengine_project_set_modified(project, 0); + + int64_t sub = oakengine_event_subscribe( + project, OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED, record_event, &log); + assert(sub > 0); + + oakengine_project_set_modified(project, 1); + assert(log.count[OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED] == 1); + assert(log.last_a[OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED] == 1); + assert(log.last_source[OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED] == + (void *)project); + + oakengine_project_set_modified(project, 0); + assert(log.count[OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED] == 2); + assert(log.last_a[OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED] == 0); + + // After unsubscribing no further events arrive. + assert(oakengine_event_unsubscribe(sub) == OAKENGINE_OK); + oakengine_project_set_modified(project, 1); + assert(log.count[OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED] == 2); + + // Unsubscribing twice is a not-found no-op. + assert(oakengine_event_unsubscribe(sub) == OAKENGINE_E_NOT_FOUND); +} + +// ---- Folder events ---------------------------------------------------------- + +static void test_folder_events(OakEngineProject *project) +{ + EventLog log; + memset(&log, 0, sizeof(log)); + + // A fresh project's first node is its root folder (same fixture as + // oakengine_footage_test). + OakEngineNode *root = oakengine_project_node_at(project, 0); + assert(root != NULL); + + int64_t sub_begin = oakengine_event_subscribe( + root, OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM, record_event, &log); + int64_t sub_end = oakengine_event_subscribe( + root, OAKENGINE_EVENT_FOLDER_END_INSERT_ITEM, record_event, &log); + int64_t sub_rm_begin = oakengine_event_subscribe( + root, OAKENGINE_EVENT_FOLDER_BEGIN_REMOVE_ITEM, record_event, &log); + int64_t sub_rm_end = oakengine_event_subscribe( + root, OAKENGINE_EVENT_FOLDER_END_REMOVE_ITEM, record_event, &log); + assert(sub_begin > 0 && sub_end > 0 && sub_rm_begin > 0 && + sub_rm_end > 0); + + OakEngineNode *folder = oakengine_folder_create(project, root, "Sub"); + assert(folder != NULL); + + assert(log.count[OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM] == 1); + assert(log.last_handle[OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM] == + (void *)folder); + assert(log.last_a[OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM] >= 0); + assert(log.count[OAKENGINE_EVENT_FOLDER_END_INSERT_ITEM] == 1); + + // Undoing the folder creation removes it from the root again. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_FOLDER_BEGIN_REMOVE_ITEM] == 1); + assert(log.last_handle[OAKENGINE_EVENT_FOLDER_BEGIN_REMOVE_ITEM] == + (void *)folder); + assert(log.count[OAKENGINE_EVENT_FOLDER_END_REMOVE_ITEM] == 1); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + + assert(oakengine_event_unsubscribe(sub_begin) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_end) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_rm_begin) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_rm_end) == OAKENGINE_OK); +} + +// ---- Sequence / track events ------------------------------------------------- + +static void test_sequence_events(OakEngineProject *project, + OakEngineSequence *seq, + const char *media_path) +{ + EventLog log; + memset(&log, 0, sizeof(log)); + + // Track added: subscribe, then append an audio track. + int64_t sub_track = oakengine_event_subscribe( + seq, OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED, record_event, &log); + assert(sub_track > 0); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == + 0); + assert(log.count[OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED] == 1); + assert(log.last_a[OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED] == + OAKENGINE_TRACK_TYPE_AUDIO); + assert(log.last_handle[OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED] != NULL); + assert(log.last_source[OAKENGINE_EVENT_SEQUENCE_TRACK_ADDED] == + (void *)seq); + assert(oakengine_event_unsubscribe(sub_track) == OAKENGINE_OK); + + // Block added on the video track (track 0 was created by the caller). + OakEngineTrack *track = oakengine_sequence_track_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0); + assert(track != NULL); + int64_t sub_block = oakengine_event_subscribe( + track, OAKENGINE_EVENT_TRACK_BLOCK_ADDED, record_event, &log); + assert(sub_block > 0); + + OakEngineFootage *footage = + oakengine_project_import_footage(project, media_path); + assert(footage != NULL); + OakEngineClip *clip = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 10, 40, 5); + assert(clip != NULL); + + // Placing at in=10 on an empty track inserts a leading gap first, so the + // event fires twice (gap 0..10, then the clip 10..40); the last + // delivery is the clip itself. + assert(log.count[OAKENGINE_EVENT_TRACK_BLOCK_ADDED] == 2); + assert(log.last_handle[OAKENGINE_EVENT_TRACK_BLOCK_ADDED] == + (void *)clip); + assert(log.last_a[OAKENGINE_EVENT_TRACK_BLOCK_ADDED] == 10); + assert(log.last_b[OAKENGINE_EVENT_TRACK_BLOCK_ADDED] == 40); + assert(log.last_source[OAKENGINE_EVENT_TRACK_BLOCK_ADDED] == + (void *)track); + assert(oakengine_event_unsubscribe(sub_block) == OAKENGINE_OK); + oakengine_footage_free(footage); + + // Marker added / modified. + int64_t sub_marker_add = oakengine_event_subscribe( + seq, OAKENGINE_EVENT_SEQUENCE_MARKER_ADDED, record_event, &log); + int64_t sub_marker_mod = oakengine_event_subscribe( + seq, OAKENGINE_EVENT_SEQUENCE_MARKER_MODIFIED, record_event, &log); + assert(sub_marker_add > 0 && sub_marker_mod > 0); + + assert(oakengine_sequence_marker_add(seq, 7, "Mark") == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_SEQUENCE_MARKER_ADDED] == 1); + assert(log.last_a[OAKENGINE_EVENT_SEQUENCE_MARKER_ADDED] == 7); + + assert(oakengine_sequence_marker_rename(seq, 7, "Renamed") == + OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_SEQUENCE_MARKER_MODIFIED] == 1); + assert(log.last_a[OAKENGINE_EVENT_SEQUENCE_MARKER_MODIFIED] == 7); + + assert(oakengine_event_unsubscribe(sub_marker_add) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_marker_mod) == OAKENGINE_OK); + + // Workarea enabled + range changed. + int64_t sub_range = oakengine_event_subscribe( + seq, OAKENGINE_EVENT_SEQUENCE_WORKAREA_RANGE_CHANGED, record_event, + &log); + int64_t sub_enabled = oakengine_event_subscribe( + seq, OAKENGINE_EVENT_SEQUENCE_WORKAREA_ENABLED_CHANGED, record_event, + &log); + assert(sub_range > 0 && sub_enabled > 0); + + assert(oakengine_sequence_set_workarea(seq, 1, 3, 21) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_SEQUENCE_WORKAREA_ENABLED_CHANGED] == + 1); + assert(log.last_a[OAKENGINE_EVENT_SEQUENCE_WORKAREA_ENABLED_CHANGED] == + 1); + assert(log.count[OAKENGINE_EVENT_SEQUENCE_WORKAREA_RANGE_CHANGED] == 1); + assert(log.last_a[OAKENGINE_EVENT_SEQUENCE_WORKAREA_RANGE_CHANGED] == 3); + assert(log.last_b[OAKENGINE_EVENT_SEQUENCE_WORKAREA_RANGE_CHANGED] == + 21); + + assert(oakengine_event_unsubscribe(sub_range) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_enabled) == OAKENGINE_OK); +} + +// ---- Track block traversal --------------------------------------------------- + +static void test_block_traversal(OakEngineSequence *seq) +{ + OakEngineTrack *track = oakengine_sequence_track_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0); + assert(track != NULL); + + // The caller placed one clip at 10..40; the track chain is + // gap(0..10) -> clip(10..40). + assert(oakengine_track_block_count(track) == 2); + + OakEngineBlock *gap = + oakengine_track_nearest_block_before_or_at(track, 0); + assert(gap != NULL); + assert(oakengine_block_is_gap(gap) == 1); + + OakEngineBlock *clip = oakengine_block_next(gap); + assert(clip != NULL); + assert(oakengine_block_is_gap(clip) == 0); + assert(oakengine_block_next(clip) == NULL); + assert(oakengine_block_prev(clip) == gap); + assert(oakengine_block_prev(gap) == NULL); + + int64_t in = -1, out = -1; + assert(oakengine_block_get_range(gap, &in, &out) == OAKENGINE_OK); + assert(in == 0 && out == 10); + assert(oakengine_block_get_range(clip, &in, &out) == OAKENGINE_OK); + assert(in == 10 && out == 40); + + // Time queries. + assert(oakengine_track_block_at_time(track, 15) == clip); + assert(oakengine_track_block_at_time(track, 5) == gap); + assert(oakengine_track_block_at_time(track, 40) == NULL); + assert(oakengine_track_nearest_block_before(track, 10) == gap); + assert(oakengine_track_nearest_block_before_or_at(track, 10) == clip); + assert(oakengine_track_nearest_block_after(track, 0) == clip); + assert(oakengine_track_nearest_block_after_or_at(track, 10) == clip); + assert(oakengine_track_nearest_block_after(track, 10) == NULL); + + // NULL safety. + assert(oakengine_track_block_count(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_track_block_at_time(NULL, 0) == NULL); + assert(oakengine_track_nearest_block_before(NULL, 0) == NULL); + assert(oakengine_track_nearest_block_before_or_at(NULL, 0) == NULL); + assert(oakengine_track_nearest_block_after(NULL, 0) == NULL); + assert(oakengine_track_nearest_block_after_or_at(NULL, 0) == NULL); + assert(oakengine_block_next(NULL) == NULL); + assert(oakengine_block_prev(NULL) == NULL); + assert(oakengine_block_is_gap(NULL) == 0); + assert(oakengine_block_get_range(NULL, &in, &out) == + OAKENGINE_E_INVALID); +} + +// ---- Node events (B8a) ------------------------------------------------------ + +static void test_node_events(OakEngineProject *project) +{ + EventLog log; + memset(&log, 0, sizeof(log)); + + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + OakEngineNode *lut = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.ociolut"); + OakEngineNode *text = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.text3"); + assert(solid != NULL && lut != NULL && text != NULL); + + // Family mismatch: node events need a node handle. + assert(oakengine_event_subscribe( + project, OAKENGINE_EVENT_NODE_LABEL_CHANGED, record_event, + &log) == 0); + + int64_t subs[16]; + int nsubs = 0; + subs[nsubs++] = oakengine_event_subscribe( + solid, OAKENGINE_EVENT_NODE_LABEL_CHANGED, record_event, &log); + subs[nsubs++] = oakengine_event_subscribe( + solid, OAKENGINE_EVENT_NODE_INPUT_VALUE_CHANGED, record_event, &log); + subs[nsubs++] = oakengine_event_subscribe( + lut, OAKENGINE_EVENT_NODE_INPUT_CONNECTED, record_event, &log); + subs[nsubs++] = oakengine_event_subscribe( + lut, OAKENGINE_EVENT_NODE_INPUT_DISCONNECTED, record_event, &log); + subs[nsubs++] = oakengine_event_subscribe( + solid, OAKENGINE_EVENT_NODE_INPUT_PROPERTY_CHANGED, record_event, + &log); + subs[nsubs++] = oakengine_event_subscribe( + text, OAKENGINE_EVENT_NODE_INPUT_ARRAY_SIZE_CHANGED, record_event, + &log); + subs[nsubs++] = oakengine_event_subscribe( + solid, OAKENGINE_EVENT_NODE_KEYFRAME_ENABLE_CHANGED, record_event, + &log); + subs[nsubs++] = oakengine_event_subscribe( + solid, OAKENGINE_EVENT_NODE_KEYFRAME_ADDED, record_event, &log); + subs[nsubs++] = oakengine_event_subscribe( + solid, OAKENGINE_EVENT_NODE_KEYFRAME_REMOVED, record_event, &log); + subs[nsubs++] = oakengine_event_subscribe( + solid, OAKENGINE_EVENT_NODE_KEYFRAME_TIME_CHANGED, record_event, + &log); + subs[nsubs++] = oakengine_event_subscribe( + solid, OAKENGINE_EVENT_NODE_KEYFRAME_TYPE_CHANGED, record_event, + &log); + subs[nsubs++] = oakengine_event_subscribe( + solid, OAKENGINE_EVENT_NODE_KEYFRAME_VALUE_CHANGED, record_event, + &log); + for (int i = 0; i < nsubs; i++) { + assert(subs[i] > 0); + } + + // Label. + assert(oakengine_node_set_label(solid, "EventSolid") == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_LABEL_CHANGED] == 1); + assert(strcmp(log.last_s[OAKENGINE_EVENT_NODE_LABEL_CHANGED], + "EventSolid") == 0); + assert(log.last_source[OAKENGINE_EVENT_NODE_LABEL_CHANGED] == solid); + + // Value change on an input: element -1, a valid range, the input id. + oak_node_value v; + memset(&v, 0, sizeof(v)); + v.type = OAK_NODE_VALUE_COLOR; + v.f[0] = 0.5; + v.f[3] = 1.0; + assert(oakengine_node_set_input(solid, "color_in", &v) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_INPUT_VALUE_CHANGED] >= 1); + assert(strcmp(log.last_s[OAKENGINE_EVENT_NODE_INPUT_VALUE_CHANGED], + "color_in") == 0); + assert(log.last_a[OAKENGINE_EVENT_NODE_INPUT_VALUE_CHANGED] == -1); + + // Edge connect/disconnect: output node in the handle field. + assert(oakengine_node_connect(solid, lut, "tex_in") == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_INPUT_CONNECTED] == 1); + assert(log.last_handle[OAKENGINE_EVENT_NODE_INPUT_CONNECTED] == solid); + assert(strcmp(log.last_s[OAKENGINE_EVENT_NODE_INPUT_CONNECTED], + "tex_in") == 0); + assert(oakengine_node_disconnect(lut, "tex_in") == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_INPUT_DISCONNECTED] == 1); + + // Property change (notified write only). + assert(oakengine_node_set_input_property_string(solid, "color_in", + "my_prop", "1", 1) == + OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_INPUT_PROPERTY_CHANGED] == 1); + assert(strcmp(log.last_s[OAKENGINE_EVENT_NODE_INPUT_PROPERTY_CHANGED], + "color_in") == 0); + assert(oakengine_node_set_input_property_string(solid, "color_in", + "my_prop", "2", 0) == + OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_INPUT_PROPERTY_CHANGED] == 1); + + // Array size change: old and new sizes. + const int arr_before = oakengine_node_input_array_size(text, "args_in"); + assert(oakengine_node_array_insert_at(text, "args_in", 0) == + OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_INPUT_ARRAY_SIZE_CHANGED] == 1); + assert(log.last_a[OAKENGINE_EVENT_NODE_INPUT_ARRAY_SIZE_CHANGED] == + arr_before); + assert(log.last_b[OAKENGINE_EVENT_NODE_INPUT_ARRAY_SIZE_CHANGED] == + arr_before + 1); + + // Keyframe enable + add/remove/time/type/value. + reset_event(&log, OAKENGINE_EVENT_NODE_KEYFRAME_ADDED); + assert(oakengine_node_set_input_keyframing(solid, "color_in", -1, 1, 0, + 1, NULL) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_KEYFRAME_ENABLE_CHANGED] == 1); + assert(log.last_b[OAKENGINE_EVENT_NODE_KEYFRAME_ENABLE_CHANGED] == 1); + assert(strcmp(log.last_s[OAKENGINE_EVENT_NODE_KEYFRAME_ENABLE_CHANGED], + "color_in") == 0); + assert(log.count[OAKENGINE_EVENT_NODE_KEYFRAME_ADDED] >= 1); + assert(log.last_handle[OAKENGINE_EVENT_NODE_KEYFRAME_ADDED] != NULL); + // COLOR has four tracks; enabling keyframing adds one key per track. + assert(log.last_b[OAKENGINE_EVENT_NODE_KEYFRAME_ADDED] == 3); + + // Type change on the created key (ts 0 = time 0s). + reset_event(&log, OAKENGINE_EVENT_NODE_KEYFRAME_TYPE_CHANGED); + int64_t times[1] = { 0 }; + int tracks[1] = { 0 }; + // The engine only emits the type-changed signal on multi-key tracks, + // so add a second key (1s = ts 30 with the default timebase) first. + assert(oakengine_node_keyframes_toggle_at_time(solid, "color_in", -1, 1, + 1, 1, NULL) == + OAKENGINE_OK); + // Default type is bezier; switch to hold (type 2) for a real change. + assert(oakengine_node_keyframes_set_type_many(solid, "color_in", -1, + times, tracks, 1, 2) == 1); + assert(log.count[OAKENGINE_EVENT_NODE_KEYFRAME_TYPE_CHANGED] == 1); + + // Value change. + reset_event(&log, OAKENGINE_EVENT_NODE_KEYFRAME_VALUE_CHANGED); + oak_node_value nv; + memset(&nv, 0, sizeof(nv)); + nv.type = OAK_NODE_VALUE_COLOR; + nv.f[0] = 0.75; + assert(oakengine_node_keyframes_set_value_many(solid, "color_in", -1, + times, tracks, 1, &nv, + NULL) == 1); + assert(log.count[OAKENGINE_EVENT_NODE_KEYFRAME_VALUE_CHANGED] == 1); + + // Time change. + reset_event(&log, OAKENGINE_EVENT_NODE_KEYFRAME_TIME_CHANGED); + assert(oakengine_node_keyframes_set_time_many(solid, "color_in", -1, + times, tracks, 1, + 30) == 1); + assert(log.count[OAKENGINE_EVENT_NODE_KEYFRAME_TIME_CHANGED] == 1); + + // Removal. + reset_event(&log, OAKENGINE_EVENT_NODE_KEYFRAME_REMOVED); + assert(oakengine_node_set_input_keyframing(solid, "color_in", -1, 0, 0, + 1, NULL) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_KEYFRAME_REMOVED] >= 1); + assert(log.count[OAKENGINE_EVENT_NODE_KEYFRAME_ENABLE_CHANGED] == 2); + + for (int i = 0; i < nsubs; i++) { + assert(oakengine_event_unsubscribe(subs[i]) == OAKENGINE_OK); + } + + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, lut) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, text) == OAKENGINE_OK); +} + +// ---- Group + context position events ----------------------------------------- + +static void test_group_events(OakEngineProject *project) +{ + EventLog log; + memset(&log, 0, sizeof(log)); + + OakEngineNode *group = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.group"); + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(group != NULL && solid != NULL); + + int64_t subs[4]; + int nsubs = 0; + subs[nsubs++] = oakengine_event_subscribe( + group, OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_ADDED, record_event, + &log); + subs[nsubs++] = oakengine_event_subscribe( + group, OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_REMOVED, record_event, + &log); + subs[nsubs++] = oakengine_event_subscribe( + group, OAKENGINE_EVENT_GROUP_OUTPUT_PASSTHROUGH_CHANGED, record_event, + &log); + subs[nsubs++] = oakengine_event_subscribe( + group, OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED, record_event, + &log); + for (int i = 0; i < nsubs; i++) { + assert(subs[i] > 0); + } + + // The group must contain the inner node before a passthrough can be + // added (insertion itself fires the position-changed event). + assert(oakengine_node_set_context_position(group, solid, 0.0, 0.0) == + OAKENGINE_OK); + reset_event(&log, OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED); + + // Add passthrough: handle = inner node, s = input id, a = element. + assert(oakengine_group_add_input_passthrough(group, solid, "color_in", + -1, NULL, NULL, 0) > 0); + assert(log.count[OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_ADDED] == 1); + assert(log.last_source[OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_ADDED] == + group); + assert(log.last_handle[OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_ADDED] == + solid); + assert(strcmp(log.last_s[OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_ADDED], + "color_in") == 0); + assert(log.last_a[OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_ADDED] == -1); + + // Output passthrough: handle = the new output node. + assert(oakengine_group_set_output_passthrough(group, solid) == + OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_GROUP_OUTPUT_PASSTHROUGH_CHANGED] == 1); + assert(log.last_handle[OAKENGINE_EVENT_GROUP_OUTPUT_PASSTHROUGH_CHANGED] == + solid); + + // Remove passthrough. + assert(oakengine_group_remove_input_passthrough(group, solid, "color_in", + -1) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_REMOVED] == 1); + assert(log.last_handle[OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_REMOVED] == + solid); + assert(strcmp(log.last_s[OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_REMOVED], + "color_in") == 0); + assert(log.last_a[OAKENGINE_EVENT_GROUP_INPUT_PASSTHROUGH_REMOVED] == -1); + + // Context position: handle = child node, a/b = x/y double bit patterns. + assert(oakengine_node_set_context_position(group, solid, 3.5, -2.25) == + OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED] == 1); + assert(log.last_source[OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED] == + group); + assert(log.last_handle[OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED] == + solid); + double px, py; + memcpy(&px, &log.last_a[OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED], + sizeof(px)); + memcpy(&py, &log.last_b[OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED], + sizeof(py)); + assert(px == 3.5 && py == -2.25); + + // Moving again re-emits with the new coordinates. + assert(oakengine_node_set_context_position(group, solid, 0.0, 1.0) == + OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED] == 2); + memcpy(&px, &log.last_a[OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED], + sizeof(px)); + memcpy(&py, &log.last_b[OAKENGINE_EVENT_NODE_CONTEXT_POSITION_CHANGED], + sizeof(py)); + assert(px == 0.0 && py == 1.0); + + for (int i = 0; i < nsubs; i++) { + assert(oakengine_event_unsubscribe(subs[i]) == OAKENGINE_OK); + } + + assert(oakengine_project_remove_node(project, group) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); +} + +// ---- Track / block state events (B4c) --------------------------------------- + +// The caller left one clip at 10..40 on video track 0 (see +// test_sequence_events); `track` is that track. +static void test_track_extra_events(OakEngineSequence *seq, + OakEngineTrack *track) +{ + EventLog log; + memset(&log, 0, sizeof(log)); + + // Family mismatches: a track is not a sequence and vice versa. + assert(oakengine_event_subscribe( + track, OAKENGINE_EVENT_SEQUENCE_TRACK_LIST_CHANGED, + record_event, &log) == 0); + assert(oakengine_event_subscribe(seq, OAKENGINE_EVENT_TRACK_MUTED_CHANGED, + record_event, &log) == 0); + + // Muted changed. + int64_t sub_mute = oakengine_event_subscribe( + track, OAKENGINE_EVENT_TRACK_MUTED_CHANGED, record_event, &log); + assert(sub_mute > 0); + assert(oakengine_track_set_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 1) == + OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_TRACK_MUTED_CHANGED] == 1); + assert(log.last_a[OAKENGINE_EVENT_TRACK_MUTED_CHANGED] == 1); + assert(oakengine_track_set_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0) == + OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_TRACK_MUTED_CHANGED] == 2); + assert(log.last_a[OAKENGINE_EVENT_TRACK_MUTED_CHANGED] == 0); + assert(oakengine_event_unsubscribe(sub_mute) == OAKENGINE_OK); + + // Track height changed (track-level, double bit pattern) and the + // sequence-level pixel variant. + int64_t sub_h = oakengine_event_subscribe( + track, OAKENGINE_EVENT_TRACK_HEIGHT_CHANGED, record_event, &log); + int64_t sub_sh = oakengine_event_subscribe( + seq, OAKENGINE_EVENT_SEQUENCE_TRACK_HEIGHT_CHANGED, record_event, + &log); + assert(sub_h > 0 && sub_sh > 0); + assert(oakengine_track_set_height(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 2.5) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_TRACK_HEIGHT_CHANGED] == 1); + double h; + memcpy(&h, &log.last_a[OAKENGINE_EVENT_TRACK_HEIGHT_CHANGED], sizeof(h)); + assert(h == 2.5); + assert(log.count[OAKENGINE_EVENT_SEQUENCE_TRACK_HEIGHT_CHANGED] == 1); + assert(log.last_a[OAKENGINE_EVENT_SEQUENCE_TRACK_HEIGHT_CHANGED] == + OAKENGINE_TRACK_TYPE_VIDEO); + assert(log.last_b[OAKENGINE_EVENT_SEQUENCE_TRACK_HEIGHT_CHANGED] == + oakengine_track_height_internal_to_pixels(2.5)); + assert(log.last_handle[OAKENGINE_EVENT_SEQUENCE_TRACK_HEIGHT_CHANGED] == + (void *)track); + assert(oakengine_event_unsubscribe(sub_h) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_sh) == OAKENGINE_OK); + + // Track list changed + index changed: append a second video track, + // then move track 0 to position 1. + int64_t sub_list = oakengine_event_subscribe( + seq, OAKENGINE_EVENT_SEQUENCE_TRACK_LIST_CHANGED, record_event, &log); + assert(sub_list > 0); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == + 1); + assert(log.count[OAKENGINE_EVENT_SEQUENCE_TRACK_LIST_CHANGED] >= 1); + assert(log.last_a[OAKENGINE_EVENT_SEQUENCE_TRACK_LIST_CHANGED] == + OAKENGINE_TRACK_TYPE_VIDEO); + + int64_t sub_index = oakengine_event_subscribe( + track, OAKENGINE_EVENT_TRACK_INDEX_CHANGED, record_event, &log); + assert(sub_index > 0); + assert(oakengine_sequence_move_track(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 1) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_TRACK_INDEX_CHANGED] >= 1); + assert(log.last_b[OAKENGINE_EVENT_TRACK_INDEX_CHANGED] == 1); + assert(oakengine_sequence_move_track(seq, OAKENGINE_TRACK_TYPE_VIDEO, 1, + 0) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_index) == OAKENGINE_OK); + + // Clean up the extra track. + assert(oakengine_sequence_remove_track(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 1) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_list) == OAKENGINE_OK); + + // Blocks refreshed: emitted when the track re-lays out its block chain + // (e.g. moving a clip onto it). + int64_t sub_refresh = oakengine_event_subscribe( + track, OAKENGINE_EVENT_TRACK_BLOCKS_REFRESHED, record_event, &log); + assert(sub_refresh > 0); + assert(oakengine_sequence_move_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, + 50) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_TRACK_BLOCKS_REFRESHED] >= 1); + // Move it back to 10 to restore the original layout. + assert(oakengine_sequence_move_clip(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, + 10) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_refresh) == OAKENGINE_OK); + + // Subtitles changed: subscription validates (no headless trigger -- + // the signal only fires on subtitle-track cache invalidation). + int64_t sub_subs = oakengine_event_subscribe( + seq, OAKENGINE_EVENT_SEQUENCE_SUBTITLES_CHANGED, record_event, &log); + assert(sub_subs > 0); + assert(oakengine_event_unsubscribe(sub_subs) == OAKENGINE_OK); +} + +static void test_block_state_events(OakEngineSequence *seq, + const char *media_path) +{ + EventLog log; + memset(&log, 0, sizeof(log)); + + // The clip is back at 10..40 (clip index 0; the leading gap is not + // counted by the clip family). + OakEngineClip *clip = oakengine_sequence_clip_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0); + assert(clip != NULL); + OakEngineBlock *block = (OakEngineBlock *)clip; + + // Family mismatch: a block is not a track. + assert(oakengine_event_subscribe( + block, OAKENGINE_EVENT_TRACK_MUTED_CHANGED, record_event, + &log) == 0); + + int64_t sub_en = oakengine_event_subscribe( + block, OAKENGINE_EVENT_BLOCK_ENABLED_CHANGED, record_event, &log); + assert(sub_en > 0); + // The engine emits enabled_changed twice per flip (Block::set_enabled + // and Block::InputValueChangedEvent), so each toggle delivers two. + OakEngineClip *clips[1] = { clip }; + assert(oakengine_clip_toggle_enabled(clips, 1) == 1); + assert(log.count[OAKENGINE_EVENT_BLOCK_ENABLED_CHANGED] == 2); + assert(oakengine_block_is_enabled(block) == 0); + assert(oakengine_clip_toggle_enabled(clips, 1) == 1); + assert(log.count[OAKENGINE_EVENT_BLOCK_ENABLED_CHANGED] == 4); + assert(oakengine_block_is_enabled(block) == 1); + assert(oakengine_event_unsubscribe(sub_en) == OAKENGINE_OK); + + // Preview changed: writing the loop-mode input fires it. + int64_t sub_prev = oakengine_event_subscribe( + block, OAKENGINE_EVENT_BLOCK_PREVIEW_CHANGED, record_event, &log); + assert(sub_prev > 0); + oak_node_value v; + memset(&v, 0, sizeof(v)); + v.type = OAK_NODE_VALUE_COMBO; + v.num = 1; + assert(oakengine_node_set_input((OakEngineNode *)clip, + oakengine_clip_loop_mode_input_id(), + &v) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_BLOCK_PREVIEW_CHANGED] == 1); + v.num = 0; + assert(oakengine_node_set_input((OakEngineNode *)clip, + oakengine_clip_loop_mode_input_id(), + &v) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_BLOCK_PREVIEW_CHANGED] == 2); + assert(oakengine_event_unsubscribe(sub_prev) == OAKENGINE_OK); + + // Node links/color changed (Node signals on the block handle). + int64_t sub_links = oakengine_event_subscribe( + (OakEngineNode *)clip, OAKENGINE_EVENT_NODE_LINKS_CHANGED, + record_event, &log); + int64_t sub_color = oakengine_event_subscribe( + (OakEngineNode *)clip, OAKENGINE_EVENT_NODE_COLOR_CHANGED, + record_event, &log); + assert(sub_links > 0 && sub_color > 0); + OakEngineNode *one[1] = { (OakEngineNode *)clip }; + assert(oakengine_node_set_color_label(one, 1, 4) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_COLOR_CHANGED] == 1); + OakEngineFootage *footage = + oakengine_project_import_footage(oakengine_node_get_project( + (OakEngineNode *)seq), + media_path); + assert(footage != NULL); + OakEngineClip *clip2 = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 50, 80, 0); + assert(clip2 != NULL); + OakEngineClip *pair[2] = { clip, clip2 }; + assert(oakengine_clip_set_linked(pair, 2, 1) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_NODE_LINKS_CHANGED] >= 1); + assert(oakengine_clip_set_linked(pair, 2, 0) == OAKENGINE_OK); + oakengine_footage_free(footage); + assert(oakengine_event_unsubscribe(sub_links) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_color) == OAKENGINE_OK); +} + +static void test_marker_list_events(OakEngineSequence *seq) +{ + EventLog log; + memset(&log, 0, sizeof(log)); + + OakEngineMarkerList *list = + oakengine_viewer_get_marker_list((OakEngineNode *)seq); + assert(list != NULL); + + // Family mismatch: a marker list is not a workarea. + assert(oakengine_event_subscribe( + list, OAKENGINE_EVENT_WORKAREA_RANGE_CHANGED, record_event, + &log) == 0); + + int64_t sub_add = oakengine_event_subscribe( + list, OAKENGINE_EVENT_MARKER_LIST_MARKER_ADDED, record_event, &log); + int64_t sub_mod = oakengine_event_subscribe( + list, OAKENGINE_EVENT_MARKER_LIST_MARKER_MODIFIED, record_event, + &log); + int64_t sub_rm = oakengine_event_subscribe( + list, OAKENGINE_EVENT_MARKER_LIST_MARKER_REMOVED, record_event, + &log); + assert(sub_add > 0 && sub_mod > 0 && sub_rm > 0); + + // Add a marker at 2 seconds (rational seconds, not timestamps). + assert(oakengine_marker_list_add(list, 2, 1, 2, 1, "ListMark", 3) == + OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_MARKER_LIST_MARKER_ADDED] == 1); + OakEngineMarker *marker = (OakEngineMarker *)log.last_handle + [OAKENGINE_EVENT_MARKER_LIST_MARKER_ADDED]; + assert(marker != NULL); + assert(oakengine_marker_list_at(list, 0) != NULL); + + // Modify: recolor through the properties batch. + OakEngineMarker *one[1] = { marker }; + assert(oakengine_marker_set_properties(one, 1, 5, NULL, 0, 0, 0, 0, 0, + NULL) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_MARKER_LIST_MARKER_MODIFIED] == 1); + assert(log.last_handle[OAKENGINE_EVENT_MARKER_LIST_MARKER_MODIFIED] == + (void *)marker); + + // Remove. + assert(oakengine_marker_remove(marker) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_MARKER_LIST_MARKER_REMOVED] == 1); + + assert(oakengine_event_unsubscribe(sub_add) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_mod) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_rm) == OAKENGINE_OK); +} + +static void test_workarea_events(OakEngineSequence *seq) +{ + EventLog log; + memset(&log, 0, sizeof(log)); + + // Viewer-owned (borrowed) workarea. + OakEngineWorkarea *wa = + oakengine_viewer_get_workarea_handle((OakEngineNode *)seq); + assert(wa != NULL); + + int64_t sub_range = oakengine_event_subscribe( + wa, OAKENGINE_EVENT_WORKAREA_RANGE_CHANGED, record_event, &log); + int64_t sub_en = oakengine_event_subscribe( + wa, OAKENGINE_EVENT_WORKAREA_ENABLED_CHANGED, record_event, &log); + assert(sub_range > 0 && sub_en > 0); + + assert(oakengine_workarea_set_range(wa, 1, 1, 4, 1) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_WORKAREA_RANGE_CHANGED] == 1); + int64_t in_num = 0, in_den = 0, out_num = 0, out_den = 0; + assert(oakengine_workarea_get(wa, &in_num, &in_den, &out_num, &out_den, + NULL) == OAKENGINE_OK); + assert(in_num == 1 && in_den == 1 && out_num == 4 && out_den == 1); + + assert(oakengine_workarea_set_enabled(wa, 1) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_WORKAREA_ENABLED_CHANGED] == 1); + assert(log.last_a[OAKENGINE_EVENT_WORKAREA_ENABLED_CHANGED] == 1); + assert(oakengine_workarea_set_enabled(wa, 0) == OAKENGINE_OK); + + assert(oakengine_event_unsubscribe(sub_range) == OAKENGINE_OK); + assert(oakengine_event_unsubscribe(sub_en) == OAKENGINE_OK); + + // Standalone owned workarea (the footage viewer override pattern). + OakEngineWorkarea *over = oakengine_workarea_create(); + assert(over != NULL); + int64_t sub_over = oakengine_event_subscribe( + over, OAKENGINE_EVENT_WORKAREA_RANGE_CHANGED, record_event, &log); + assert(sub_over > 0); + assert(oakengine_workarea_set_range(over, 0, 1, 7, 2) == OAKENGINE_OK); + assert(log.count[OAKENGINE_EVENT_WORKAREA_RANGE_CHANGED] == 2); + assert(oakengine_event_unsubscribe(sub_over) == OAKENGINE_OK); + oakengine_workarea_free(over); + oakengine_workarea_free(NULL); +} + +int main(void) +{ + make_tmpdir(); + + // Sandbox the config/cache/data locations (see oakengine_init_test). +#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); + + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "Events"); + assert(seq != NULL); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == + 0); + + char path[4096]; + demo_path(path, sizeof(path)); + + OakEngineTrack *track = oakengine_sequence_track_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0); + assert(track != NULL); + + test_subscribe_validation(project, seq, track); + test_project_events(project); + test_folder_events(project); + test_sequence_events(project, seq, path); + test_block_traversal(seq); + test_track_extra_events(seq, track); + test_block_state_events(seq, path); + test_marker_list_events(seq); + test_workarea_events(seq); + test_node_events(project); + test_group_events(project); + + oakengine_project_free(project); + assert(oakengine_shutdown() == OAKENGINE_OK); + + printf("oakengine_events_test: all assertions passed\n"); + return 0; +} diff --git a/engine/tests/oakengine_export_test.cpp b/engine/tests/oakengine_export_test.cpp index ebe5dfc82..0ab48071c 100644 --- a/engine/tests/oakengine_export_test.cpp +++ b/engine/tests/oakengine_export_test.cpp @@ -424,6 +424,10 @@ int main(void) // ---- render_ex: real exports --------------------------------------------- { // H.264 + AAC over a custom 20-frame range of the same sequence. + // Exercise the encoder-specific video option pass-through + // (crf=18) for this render, then clear it so later exports and + // the cancellation re-run are unaffected. + oakengine_export_set_video_option("crf", "18"); char out3[4096]; snprintf(out3, sizeof(out3), "%s/ex_custom.mp4", g_tmpdir); oak_export_options_ex o3; @@ -448,6 +452,7 @@ int main(void) "(no error)"); } assert(rc == OAKENGINE_OK); + oakengine_export_set_video_option(NULL, NULL); snprintf(cmd, sizeof(cmd), "ffprobe -v error -show_entries stream=codec_type,duration " "-of csv=p=0 \"%s\"", diff --git a/engine/tests/oakengine_footage_test.cpp b/engine/tests/oakengine_footage_test.cpp index b33665091..432f16708 100644 --- a/engine/tests/oakengine_footage_test.cpp +++ b/engine/tests/oakengine_footage_test.cpp @@ -38,7 +38,9 @@ #include "oakengine/footage.h" #include "oakengine/init.h" +#include "oakengine/node.h" #include "oakengine/project.h" +#include "oakengine/timeline.h" #ifndef OAK_TEST_SOURCE_DIR #define OAK_TEST_SOURCE_DIR "." @@ -588,6 +590,293 @@ static void test_colorspace_candidates(void) oakengine_project_free(project); } +// Project extras: filenames, cache paths, settings, MIME type, from_object. +static void test_project_extras(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + char buf[4096]; + + // Untitled project: pretty filename is the "(untitled)" placeholder. + assert(oakengine_project_pretty_filename(project, buf, sizeof(buf)) > 0); + assert(strlen(buf) > 0); + + // set_filename round-trips through the plain filename getter. + char target[4096]; + snprintf(target, sizeof(target), "%s/roundtrip.ove", g_tmpdir); + assert(oakengine_project_set_filename(project, target) == OAKENGINE_OK); + assert(oakengine_project_filename(project, buf, sizeof(buf)) > 0); + assert(strcmp(buf, target) == 0); + assert(oakengine_project_set_filename(project, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_project_set_filename(NULL, target) == + OAKENGINE_E_INVALID); + + // With a filename set, the cache paths are derivable and non-empty. + assert(oakengine_project_cache_path(project, buf, sizeof(buf)) > 0); + assert(strlen(buf) > 0); + assert(oakengine_project_cache_alongside_path(project, buf, sizeof(buf)) > + 0); + assert(strlen(buf) > 0); + + // Custom cache path setting round-trip (NULL clears). + assert(oakengine_project_set_custom_cache_path(project, "/tmp/oakcache") == + OAKENGINE_OK); + assert(oakengine_project_get_custom_cache_path(project, buf, + sizeof(buf)) > 0); + assert(strcmp(buf, "/tmp/oakcache") == 0); + assert(oakengine_project_set_custom_cache_path(project, NULL) == + OAKENGINE_OK); + assert(oakengine_project_get_custom_cache_path(project, buf, + sizeof(buf)) == 0); + + // Color reference space setting round-trip. + assert(oakengine_project_set_color_reference_space( + project, "Rec.709 OETF") == OAKENGINE_OK); + assert(oakengine_project_get_color_reference_space(project, buf, + sizeof(buf)) > 0); + assert(strcmp(buf, "Rec.709 OETF") == 0); + assert(oakengine_project_set_color_reference_space(NULL, "x") == + OAKENGINE_E_INVALID); + + // Cache location setting defaults to a valid enum value; NULL is invalid. + assert(oakengine_project_get_cache_location_setting(project) >= 0); + assert(oakengine_project_get_cache_location_setting(NULL) < 0); + + // The project item MIME type is a non-empty static string. + const char *mime = oakengine_project_item_mime_type(); + assert(mime != NULL && strlen(mime) > 0); + + // from_object: the root node resolves back to its owning project. + OakEngineNode *root = oakengine_project_node_at(project, 0); + assert(root != NULL); + assert(oakengine_project_from_object(root) == project); + assert(oakengine_project_from_object(NULL) == NULL); + + // NULL safety. + assert(oakengine_project_pretty_filename(NULL, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_project_cache_path(NULL, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_project_get_custom_cache_path(NULL, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_project_get_color_reference_space(NULL, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + + oakengine_project_free(project); +} + +// Folder creation and child queries. +static void test_folder(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + // A fresh project's first node is its root folder. + OakEngineNode *root = oakengine_project_node_at(project, 0); + assert(root != NULL); + + OakEngineNode *folder = + oakengine_folder_create(project, root, "My Folder"); + assert(folder != NULL); + assert(oakengine_folder_has_child_recursive(root, folder) == 1); + assert(oakengine_folder_index_of_child(root, folder) >= 0); + + // A subfolder is found recursively from the root. + OakEngineNode *sub = oakengine_folder_create(project, folder, "Sub"); + assert(sub != NULL); + assert(oakengine_folder_has_child_recursive(root, sub) == 1); + assert(oakengine_folder_has_child_recursive(folder, sub) == 1); + assert(oakengine_folder_has_child_recursive(sub, folder) == 0); + + // A folder from another project is not a child here. + OakEngineProject *other = oakengine_project_create(); + assert(other != NULL); + assert(oakengine_project_new(other) == OAKENGINE_OK); + OakEngineNode *other_root = oakengine_project_node_at(other, 0); + assert(other_root != NULL); + OakEngineNode *alien = oakengine_folder_create(other, other_root, "Alien"); + assert(alien != NULL); + assert(oakengine_folder_has_child_recursive(root, alien) == 0); + assert(oakengine_folder_index_of_child(root, alien) == + OAKENGINE_E_NOT_FOUND); + oakengine_project_free(other); + + // The child input key is a non-empty static string. + const char *key = oakengine_folder_child_input_key(); + assert(key != NULL && strlen(key) > 0); + + // Error paths: non-folder parents, non-folder queries, NULL. + assert(oakengine_folder_create(project, folder, NULL) != NULL); + OakEngineNode *footage_node = NULL; + { + char path[4096]; + demo_path(path, sizeof(path)); + OakEngineFootage *f = oakengine_project_import_footage(project, path); + assert(f != NULL); + oakengine_footage_free(f); + // The imported footage is a non-folder project node. + for (int i = 0; i < oakengine_project_node_count(project); i++) { + OakEngineNode *n = oakengine_project_node_at(project, i); + char id[128]; + assert(oakengine_node_get_type_id(n, id, sizeof(id)) > 0); + if (strcmp(id, "org.olivevideoeditor.Olive.folder") != 0) { + footage_node = n; + break; + } + } + assert(footage_node != NULL); + } + assert(oakengine_folder_create(project, footage_node, "Nope") == NULL); + assert(oakengine_folder_has_child_recursive(footage_node, folder) == 0); + assert(oakengine_folder_index_of_child(footage_node, folder) == + OAKENGINE_E_INVALID); + assert(oakengine_folder_has_child_recursive(NULL, folder) == 0); + assert(oakengine_folder_has_child_recursive(root, NULL) == 0); + assert(oakengine_folder_index_of_child(NULL, folder) == + OAKENGINE_E_INVALID); + assert(oakengine_folder_index_of_child(root, NULL) == OAKENGINE_E_INVALID); + assert(oakengine_folder_create(NULL, root, "Nope") == NULL); + + oakengine_project_free(project); +} + +// Footage extras: filename, stream references, descriptions, proxy params, +// manual proxy state and invalidation. +static void test_footage_extras(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + char path[4096]; + demo_path(path, sizeof(path)); + OakEngineFootage *f = oakengine_project_import_footage(project, path); + assert(f != NULL); + + char buf[4096]; + + // Filename of the imported footage. + assert(oakengine_footage_get_filename(f, buf, sizeof(buf)) > 0); + assert(strcmp(buf, path) == 0); + assert(oakengine_footage_get_filename(NULL, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + + // Real stream index 0 is the video stream, 1 the audio stream. + int track_type = -1, stream_index = -1; + assert(oakengine_footage_get_stream_reference(f, 0, &track_type, + &stream_index) == OAKENGINE_OK); + assert(track_type == OAKENGINE_TRACK_TYPE_VIDEO && stream_index == 0); + assert(oakengine_footage_get_stream_reference(f, 1, &track_type, + &stream_index) == OAKENGINE_OK); + assert(track_type == OAKENGINE_TRACK_TYPE_AUDIO && stream_index == 0); + assert(oakengine_footage_get_stream_reference(f, 99, &track_type, + &stream_index) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_footage_get_stream_reference(NULL, 0, &track_type, + &stream_index) == + OAKENGINE_E_INVALID); + + // Stream descriptions. + assert(oakengine_footage_describe_video_stream(f, 0, buf, sizeof(buf)) > + 0); + assert(strlen(buf) > 0); + assert(oakengine_footage_describe_audio_stream(f, 0, buf, sizeof(buf)) > + 0); + assert(strlen(buf) > 0); + assert(oakengine_footage_describe_video_stream(f, 9, buf, sizeof(buf)) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_footage_describe_audio_stream(f, 9, buf, sizeof(buf)) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_footage_describe_video_stream(NULL, 0, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + + // Static stream type names (no handle needed). + assert(oakengine_footage_stream_type_name(OAKENGINE_TRACK_TYPE_VIDEO, buf, + sizeof(buf)) > 0); + assert(strlen(buf) > 0); + assert(oakengine_footage_stream_type_name(OAKENGINE_TRACK_TYPE_AUDIO, buf, + sizeof(buf)) > 0); + assert(strlen(buf) > 0); + + // Proxy params: effective defaults first, then a custom round-trip. + assert(oakengine_footage_has_custom_proxy_params(f) == 0); + oak_proxy_params params; + memset(¶ms, 0, sizeof(params)); + assert(oakengine_footage_get_effective_proxy_params(f, ¶ms) == + OAKENGINE_OK); + assert(params.width > 0 && params.height > 0); + params.width = 640; + params.height = 360; + params.divider = 1; + params.version = 1; + params.crf = 30; + params.include_audio = 0; + strcpy(params.extension, "mkv"); + strcpy(params.preset, "slow"); + assert(oakengine_footage_set_custom_proxy_params(f, ¶ms) == + OAKENGINE_OK); + assert(oakengine_footage_has_custom_proxy_params(f) == 1); + oak_proxy_params back; + memset(&back, 0, sizeof(back)); + assert(oakengine_footage_get_effective_proxy_params(f, &back) == + OAKENGINE_OK); + assert(back.width == 640 && back.height == 360 && back.crf == 30); + assert(back.include_audio == 0); + assert(strcmp(back.extension, "mkv") == 0); + assert(strcmp(back.preset, "slow") == 0); + assert(oakengine_footage_clear_custom_proxy_params(f) == OAKENGINE_OK); + assert(oakengine_footage_has_custom_proxy_params(f) == 0); + assert(oakengine_footage_set_custom_proxy_params(f, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_footage_get_effective_proxy_params(f, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_footage_has_custom_proxy_params(NULL) == + OAKENGINE_E_INVALID); + + // Manual proxy state: set then clear (no file is created here). + assert(oakengine_footage_set_proxy(f, "/tmp/fake_proxy.mp4", 2, 0, 1, + 1) == OAKENGINE_OK); + assert(oakengine_footage_proxy_get_state(f) == 2); + assert(oakengine_footage_proxy_get_path(f, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "/tmp/fake_proxy.mp4") == 0); + assert(oakengine_footage_proxy_is_enabled(f) == 1); + assert(oakengine_footage_clear_proxy(f) == OAKENGINE_OK); + assert(oakengine_footage_proxy_get_state(f) == 0); + assert(oakengine_footage_proxy_get_path(f, buf, sizeof(buf)) == 0); + assert(oakengine_footage_set_proxy(NULL, "x", 2, 0, 1, 1) == + OAKENGINE_E_INVALID); + assert(oakengine_footage_clear_proxy(NULL) == OAKENGINE_E_INVALID); + + // Cache invalidation after proxy/relink changes. + assert(oakengine_footage_invalidate(f) == OAKENGINE_OK); + assert(oakengine_footage_invalidate(NULL) == OAKENGINE_E_INVALID); + + // Probe handles carry no project node: the whole section rejects them. + OakEngineFootage *probed = oakengine_footage_probe(path); + assert(probed != NULL); + assert(oakengine_footage_get_filename(probed, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_footage_get_stream_reference(probed, 0, &track_type, + &stream_index) == + OAKENGINE_E_INVALID); + assert(oakengine_footage_describe_video_stream(probed, 0, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_footage_get_effective_proxy_params(probed, ¶ms) == + OAKENGINE_E_INVALID); + assert(oakengine_footage_invalidate(probed) == OAKENGINE_E_INVALID); + oakengine_footage_free(probed); + + oakengine_footage_free(f); + oakengine_project_free(project); +} + int main(void) { make_tmpdir(); @@ -610,6 +899,9 @@ int main(void) test_proxy(); test_stream_overrides(); test_colorspace_candidates(); + test_project_extras(); + test_folder(); + test_footage_extras(); assert(oakengine_shutdown() == OAKENGINE_OK); diff --git a/engine/tests/oakengine_keyframe_test.cpp b/engine/tests/oakengine_keyframe_test.cpp index 345acf1a6..e6a58cbd8 100644 --- a/engine/tests/oakengine_keyframe_test.cpp +++ b/engine/tests/oakengine_keyframe_test.cpp @@ -562,6 +562,265 @@ static void test_keyframe_properties(OakEngineProject *project, 0.f) == OAKENGINE_E_INVALID); } +// Handle-based keyframe family (B8a): enumeration, navigation, handle +// accessors, live mutation, undoable batch operations, detached +// create/paste/dispose and the input dragger. +static void test_handle_family(OakEngineProject *project, + OakEngineNode *opacity) +{ + char buf[256]; + oak_node_value v; + + // Start from a clean, keyframing-enabled, empty input. + assert(oakengine_node_keyframes_clear(opacity, "opacity_in") == + OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 0); + + // Toggle ON at 1s: one keyframe with the current value and best type. + assert(oakengine_node_keyframes_toggle_at_time( + opacity, "opacity_in", -1, 1, 1, 1, NULL) == OAKENGINE_OK); + assert(oakengine_node_keyframe_track_count(opacity, "opacity_in", -1) == + 1); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 1); + assert(oakengine_node_has_keyframe_at_time(opacity, "opacity_in", -1, 1, + 1) == 1); + // Toggling on again at the same time is a no-op. + assert(oakengine_node_keyframes_toggle_at_time( + opacity, "opacity_in", -1, 1, 1, 1, NULL) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 1); + + // More keys via toggles for navigation tests. + assert(oakengine_node_keyframes_toggle_at_time( + opacity, "opacity_in", -1, 0, 1, 1, NULL) == OAKENGINE_OK); + assert(oakengine_node_keyframes_toggle_at_time( + opacity, "opacity_in", -1, 3, 1, 1, NULL) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 3); + + // Navigation. + int64_t num = -1, den = -1; + assert(oakengine_node_keyframe_earliest_time(opacity, "opacity_in", -1, + &num, &den) == 1); + assert(num == 0 && den == 1); + assert(oakengine_node_keyframe_latest_time(opacity, "opacity_in", -1, + &num, &den) == 1); + assert(num == 3 && den == 1); + assert(oakengine_node_keyframe_closest_time_before( + opacity, "opacity_in", -1, 2, 1, &num, &den) == 1); + assert(num == 1 && den == 1); + assert(oakengine_node_keyframe_closest_time_after( + opacity, "opacity_in", -1, 2, 1, &num, &den) == 1); + assert(num == 3 && den == 1); + assert(oakengine_node_keyframe_closest_time_before( + opacity, "opacity_in", -1, 0, 1, &num, &den) == 0); + assert(oakengine_node_keyframe_closest_time_after( + opacity, "opacity_in", -1, 3, 1, &num, &den) == 0); + + // Handle lookup: on-track enumeration, at-time lookup, and the batch + // at-time query all agree. + OakEngineKeyframe *k0 = + oakengine_node_keyframe_handle_on_track(opacity, "opacity_in", -1, 0, 0); + OakEngineKeyframe *k1 = + oakengine_node_keyframe_handle_on_track(opacity, "opacity_in", -1, 0, 1); + assert(k0 != NULL && k1 != NULL && k0 != k1); + assert(oakengine_node_keyframe_handle_on_track(opacity, "opacity_in", -1, + 0, 3) == NULL); + assert(oakengine_node_keyframe_handle_on_track(opacity, "opacity_in", -1, + 1, 0) == NULL); + assert(oakengine_node_keyframe_handle_at_time(opacity, "opacity_in", -1, + 0, 0, 1) == k0); + assert(oakengine_node_keyframe_handle_at_time(opacity, "opacity_in", -1, + 0, 1, 1) == k1); + assert(oakengine_node_keyframe_handle_at_time(opacity, "opacity_in", -1, + 0, 2, 1) == NULL); + OakEngineKeyframe *at[4] = { NULL, NULL, NULL, NULL }; + assert(oakengine_node_keyframes_at_time(opacity, "opacity_in", -1, 1, 1, + at, 4) == 1); + assert(at[0] == k1); + + // Handle accessors. + assert(oakengine_keyframe_get_time(k1, &num, &den) == OAKENGINE_OK); + assert(num == 1 && den == 1); + assert(oakengine_keyframe_get_input_id(k1, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "opacity_in") == 0); + assert(oakengine_keyframe_get_track(k1) == 0); + assert(oakengine_keyframe_get_element(k1) == -1); + assert(oakengine_keyframe_get_node(k1) == opacity); + assert(oakengine_keyframe_get_type(k1) >= 0); + assert(oakengine_keyframe_default_type() >= 0); + assert(oakengine_keyframe_get_value(k1, &v) == OAKENGINE_OK); + assert(v.type == OAK_NODE_VALUE_FLOAT); + // Sibling check: a key at 0s sees the key at 1s and vice versa. + assert(oakengine_keyframe_has_sibling_at_time(k0, 1, 1) == 1); + assert(oakengine_keyframe_has_sibling_at_time(k0, 0, 1) == 0); + // NULL safety. + assert(oakengine_keyframe_get_time(NULL, &num, &den) == + OAKENGINE_E_INVALID); + assert(oakengine_keyframe_get_type(NULL) == -1); + assert(oakengine_keyframe_get_node(NULL) == NULL); + assert(oakengine_keyframe_get_track(NULL) == -1); + assert(oakengine_keyframe_has_sibling_at_time(NULL, 1, 1) == 0); + + // Bezier points: set easing through the existing API, then live-move a + // handle (no undo entry) and read it back raw and valid. + assert(oakengine_node_keyframe_add(opacity, "opacity_in", 45, &v, 1, + 0.1f, 0.2f, 0.3f, + 0.4f) == OAKENGINE_OK); + assert(oakengine_keyframe_set_bezier_point_live(k1, 0, 0.11, 0.22) == + OAKENGINE_OK); + double x = 0, y = 0; + assert(oakengine_keyframe_get_bezier_point(k1, 0, &x, &y) == + OAKENGINE_OK); + assert(fabs(x - 0.11) < 1e-9 && fabs(y - 0.22) < 1e-9); + assert(oakengine_keyframe_get_valid_bezier_point(k1, 0, &x, &y) == + OAKENGINE_OK); + assert(oakengine_keyframe_get_bezier_point(k1, 2, &x, &y) == + OAKENGINE_E_INVALID); + // The live move pushed no undo entry of its own: undoing pops the add. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 3); + + // Live value/time mutation. + assert(oakengine_keyframe_set_value_live(k1, &v) == OAKENGINE_OK); + oak_node_value readback; + assert(oakengine_keyframe_get_value(k1, &readback) == OAKENGINE_OK); + assert(fabs(readback.f[0] - v.f[0]) < 1e-9); + assert(oakengine_keyframe_set_time_live(k1, 2, 1) == OAKENGINE_OK); + assert(oakengine_node_has_keyframe_at_time(opacity, "opacity_in", -1, 2, + 1) == 1); + assert(oakengine_keyframe_set_time_live(k1, 1, 1) == OAKENGINE_OK); + + // remove_many: delete the keys at 0s and 3s as ONE undoable command. + OakEngineKeyframe *victims[2] = { k0, oakengine_node_keyframe_handle_at_time( + opacity, "opacity_in", -1, 0, 3, + 1) }; + assert(victims[1] != NULL); + assert(oakengine_keyframes_remove_many(victims, 2, NULL) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 1); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 3); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 1); + // NULL entries are refused and nothing is pushed. + OakEngineKeyframe *with_null[2] = { k1, NULL }; + assert(oakengine_keyframes_remove_many(with_null, 2, NULL) == + OAKENGINE_E_INVALID); + + // Detached create + paste as ONE undoable command, then dispose. + memset(&v, 0, sizeof(v)); + v.type = OAK_NODE_VALUE_FLOAT; + v.f[0] = 0.33; + OakEngineKeyframe *detached1 = oakengine_keyframe_create( + opacity, "opacity_in", -1, 0, 5, 1, &v, 0); + OakEngineKeyframe *detached2 = oakengine_keyframe_create( + opacity, "opacity_in", -1, 0, 6, 1, &v, 0); + OakEngineKeyframe *detached3 = oakengine_keyframe_create( + opacity, "opacity_in", -1, 0, 7, 1, &v, 0); + assert(detached1 != NULL && detached2 != NULL && detached3 != NULL); + assert(oakengine_keyframe_create(opacity, "no_such", -1, 0, 5, 1, &v, + 0) == NULL); + v.f[0] = 1.5; + OakEngineKeyframe *both[2] = { detached1, detached2 }; + assert(oakengine_node_keyframes_paste(opacity, both, 2, NULL) == + OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 3); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 1); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 3); + oakengine_keyframe_dispose(detached3); + oakengine_keyframe_dispose(NULL); // no-op + + // Toggle OFF the key at 1s: removed, single-track standard value fix-up. + assert(oakengine_node_keyframes_toggle_at_time( + opacity, "opacity_in", -1, 1, 1, 0, NULL) == OAKENGINE_OK); + assert(oakengine_node_has_keyframe_at_time(opacity, "opacity_in", -1, 1, + 1) == 0); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_has_keyframe_at_time(opacity, "opacity_in", -1, 1, + 1) == 1); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + + // Disable keyframing entirely: all keys gone, keyframing flag off. + assert(oakengine_node_set_input_keyframing(opacity, "opacity_in", -1, 0, + 1, 1, NULL) == OAKENGINE_OK); + assert(oakengine_node_input_is_keyframed(opacity, "opacity_in") == 0); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 0); + // Re-enable through the facade: one default-type key per track. + assert(oakengine_node_set_input_keyframing(opacity, "opacity_in", -1, 1, + 1, 1, NULL) == OAKENGINE_OK); + assert(oakengine_node_input_is_keyframed_ex(opacity, "opacity_in", -1) == + 1); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 1); + OakEngineKeyframe *sole = oakengine_node_keyframe_handle_on_track( + opacity, "opacity_in", -1, 0, 0); + assert(oakengine_keyframe_get_type(sole) == + oakengine_keyframe_default_type()); + // Redundant enable is a no-op success. + assert(oakengine_node_set_input_keyframing(opacity, "opacity_in", -1, 1, + 1, 1, NULL) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == 1); + // Undo both steps back to keyframing disabled, then redo to enabled. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_input_is_keyframed(opacity, "opacity_in") == 0); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_node_input_is_keyframed(opacity, "opacity_in") == 1); + + // Input dragger: start creates a key at the drag time, drag live-sets, + // end pushes ONE undoable command. + OakEngineNodeDragger *dragger = + oakengine_dragger_create(opacity, "opacity_in", -1, 0); + assert(dragger != NULL); + assert(oakengine_dragger_create(opacity, "no_such", -1, 0) == NULL); + assert(oakengine_dragger_is_started(dragger) == 0); + assert(oakengine_dragger_end(dragger, NULL) == OAKENGINE_E_STATE); + const int keys_before = + oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, 0); + assert(oakengine_dragger_start(dragger, 4, 1, 1) == OAKENGINE_OK); + assert(oakengine_dragger_is_started(dragger) == 1); + assert(oakengine_dragger_start(dragger, 4, 1, 1) == OAKENGINE_E_STATE); + oak_node_value drag_value; + memset(&drag_value, 0, sizeof(drag_value)); + drag_value.type = OAK_NODE_VALUE_FLOAT; + drag_value.f[0] = 0.9; + assert(oakengine_dragger_drag(dragger, &drag_value) == OAKENGINE_OK); + oak_node_value at_time; + assert(oakengine_node_get_input_at_time(opacity, "opacity_in", -1, 0, 4, + 1, &at_time) == OAKENGINE_OK); + assert(fabs(at_time.f[0] - 0.9) < 1e-9); + assert(oakengine_dragger_end(dragger, "Drag Opacity") == OAKENGINE_OK); + assert(oakengine_dragger_is_started(dragger) == 0); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == + keys_before + 1); + // The whole drag (created key + value) unwinds with one undo. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count_on_track(opacity, "opacity_in", -1, + 0) == keys_before); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + oakengine_dragger_free(dragger); + oakengine_dragger_free(NULL); + + // Clean up for later tests. + assert(oakengine_node_keyframes_clear(opacity, "opacity_in") == + OAKENGINE_OK); +} + int main(void) { make_tmpdir(); @@ -597,6 +856,7 @@ int main(void) test_rational_and_color(project, timeremap, solid); test_panel_paths(project, opacity, solid); test_keyframe_properties(project, opacity); + test_handle_family(project, opacity); oakengine_project_free(project); assert(oakengine_shutdown() == OAKENGINE_OK); diff --git a/engine/tests/oakengine_lut_test.cpp b/engine/tests/oakengine_lut_test.cpp new file mode 100644 index 000000000..3bb54adec --- /dev/null +++ b/engine/tests/oakengine_lut_test.cpp @@ -0,0 +1,69 @@ +/*** + + 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 tests for the liboakengine LUT library facade (oakengine/lut.h). +// Runs headless; no GPU required. + +#include +#include +#include + +#include "oakengine/init.h" +#include "oakengine/lut.h" + +static void test_counts_after_init(void) +{ + assert(oakengine_lut_directory_count() >= 0); + assert(oakengine_lut_file_count() >= 0); + + // Out-of-range index returns an error. + char buf[256]; + assert(oakengine_lut_directory_at(-1, buf, sizeof(buf)) < 0); + assert(oakengine_lut_file_at(-1, buf, sizeof(buf)) < 0); +} + +static void test_set_directories_round_trip(void) +{ + const char *dirs[] = { "/tmp/oak_lut_a", "/tmp/oak_lut_b" }; + + assert(oakengine_lut_set_directories(dirs, 2) == OAKENGINE_OK); + assert(oakengine_lut_directory_count() == 2); + + char buf[256]; + assert(oakengine_lut_directory_at(0, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "/tmp/oak_lut_a") == 0); + assert(oakengine_lut_directory_at(1, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "/tmp/oak_lut_b") == 0); + + // Clearing the library. + assert(oakengine_lut_set_directories(NULL, 0) == OAKENGINE_OK); + assert(oakengine_lut_directory_count() == 0); +} + +int main(void) +{ + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + + test_counts_after_init(); + test_set_directories_round_trip(); + + assert(oakengine_shutdown() == OAKENGINE_OK); + return 0; +} diff --git a/engine/tests/oakengine_node_test.cpp b/engine/tests/oakengine_node_test.cpp index ce1ae83e6..18b7d7ec6 100644 --- a/engine/tests/oakengine_node_test.cpp +++ b/engine/tests/oakengine_node_test.cpp @@ -41,6 +41,7 @@ #include "oakengine/node.h" #include "oakengine/project.h" #include "oakengine/timeline.h" +#include "oakengine/undo.h" #ifndef OAK_TEST_SOURCE_DIR #define OAK_TEST_SOURCE_DIR "." @@ -294,6 +295,24 @@ static void test_edges(OakEngineProject *project, OakEngineNode *solid, assert(oakengine_node_disconnect(lut, "tex_in") == OAKENGINE_E_NOT_FOUND); + // disconnect_ex with element -1 mirrors disconnect(); on an unconnected + // input it reports E_NOT_FOUND. NULL/unknown-input rejection matches + // disconnect() too. + assert(oakengine_node_disconnect_ex(NULL, "tex_in", -1) == + OAKENGINE_E_INVALID); + assert(oakengine_node_disconnect_ex(lut, "no_such_input", -1) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_node_disconnect_ex(lut, "tex_in", -1) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_node_connect(solid, lut, "tex_in") == OAKENGINE_OK); + assert(oakengine_node_disconnect_ex(lut, "tex_in", -1) == OAKENGINE_OK); + assert(oakengine_node_input_is_connected(lut, "tex_in") == 0); + // Undo the disconnect_ex so the undo/redo sequence below starts from + // the same "connected" state as before this block. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_input_is_connected(lut, "tex_in") == 1); + assert(oakengine_node_disconnect(lut, "tex_in") == OAKENGINE_OK); + // Undo/redo the disconnect and the connect: undo brings the connection // back, undo again removes it; redoing both replays connect then // disconnect, so the end state is disconnected. @@ -369,6 +388,658 @@ static void test_label_and_color_many(OakEngineProject *project) OAKENGINE_E_INVALID); } +// Extended metadata and value-at-time family (B8a): input introspection, +// properties, label/input names, defaults, project/edge lookup, +// copy_inputs and the at-time value readers. +static void test_extended_metadata(OakEngineProject *project) +{ + char buf[256]; + + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + OakEngineNode *lut = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.ociolut"); + OakEngineNode *text = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.text3"); + assert(solid != NULL && lut != NULL && text != NULL); + + // Introspection. + assert(oakengine_node_input_is_array(text, "args_in") == 1); + assert(oakengine_node_input_is_array(solid, "color_in") == 0); + assert(oakengine_node_input_array_size(text, "args_in") >= 0); + assert(oakengine_node_input_array_size(solid, "color_in") == 0); + assert(oakengine_node_input_get_flags(solid, "color_in") >= 0); + assert(oakengine_node_input_get_flags(NULL, "color_in") == 0); + assert(oakengine_node_input_is_connectable(lut, "tex_in") == 1); + assert(oakengine_node_input_is_connectable(lut, "lut_file_in") == 0); + assert(oakengine_node_input_is_keyframable(solid, "color_in") == 1); + assert(oakengine_node_input_is_keyframable(lut, "tex_in") == 0); + assert(oakengine_node_input_is_keyframed_ex(solid, "color_in", -1) == 0); + + // Properties: set (with and without notification), read back through + // every typed getter, enumerate. + assert(oakengine_node_input_has_property(solid, "color_in", + "my_prop") == 0); + assert(oakengine_node_set_input_property_string( + solid, "color_in", "my_prop", "2.5", 1) == OAKENGINE_OK); + assert(oakengine_node_input_has_property(solid, "color_in", + "my_prop") == 1); + assert(oakengine_node_input_get_property_string( + solid, "color_in", "my_prop", buf, sizeof(buf)) > 0); + assert(strcmp(buf, "2.5") == 0); + double d = 0; + assert(oakengine_node_input_get_property_number(solid, "color_in", + "my_prop", -1, &d) == + OAKENGINE_OK); + assert(fabs(d - 2.5) < 1e-9); + // The per-track variant resolves (component value is type-dependent). + assert(oakengine_node_input_get_property_number(solid, "color_in", + "my_prop", 2, &d) == + OAKENGINE_OK); + assert(oakengine_node_set_input_property_string( + solid, "color_in", "int_prop", "7", 1) == OAKENGINE_OK); + int64_t i64 = 0; + assert(oakengine_node_input_get_property_int(solid, "color_in", + "int_prop", &i64) == + OAKENGINE_OK); + assert(i64 == 7); + assert(oakengine_node_input_get_property_rational( + solid, "color_in", "my_prop", NULL, NULL) == OAKENGINE_OK); + assert(oakengine_node_input_get_property_count(solid, "color_in") >= 1); + assert(oakengine_node_input_get_property_string( + solid, "color_in", "no_such", buf, sizeof(buf)) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_node_input_get_property_number(solid, "color_in", + "no_such", -1, &d) == + OAKENGINE_E_NOT_FOUND); + // A scalar string reads back as a one-element list. + assert(oakengine_node_input_get_property_string_list_count( + solid, "color_in", "my_prop") == 1); + assert(oakengine_node_input_get_property_string_list( + solid, "color_in", "my_prop", 0, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "2.5") == 0); + assert(oakengine_node_input_get_property_string_list( + solid, "color_in", "my_prop", 1, buf, sizeof(buf)) == + OAKENGINE_E_NOT_FOUND); + // Suppressed write keeps the value too. + assert(oakengine_node_set_input_property_string( + solid, "color_in", "my_prop", "3.5", 0) == OAKENGINE_OK); + assert(oakengine_node_input_get_property_string( + solid, "color_in", "my_prop", buf, sizeof(buf)) > 0); + assert(strcmp(buf, "3.5") == 0); + + // Names. + assert(oakengine_node_get_label_and_name(solid, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "Solid") == 0); + assert(oakengine_node_set_label(solid, "MySolid") == OAKENGINE_OK); + assert(oakengine_node_get_label_and_name(solid, buf, sizeof(buf)) > 0); + assert(strstr(buf, "MySolid") != NULL && strstr(buf, "Solid") != NULL); + assert(oakengine_node_get_input_name(solid, "color_in", buf, + sizeof(buf)) >= 0); + + // Default value: Solid's color defaults to opaque red. + oak_node_value def; + assert(oakengine_node_input_get_default_value(solid, "color_in", 0, + &def) == OAKENGINE_OK); + assert(def.type == OAK_NODE_VALUE_COLOR && fabs(def.f[0] - 1.0) < 1e-6); + assert(oakengine_node_input_get_default_value(solid, "color_in", 99, + &def) == OAKENGINE_E_NOT_FOUND); + + // Project and edge lookup. + assert(oakengine_node_get_project(solid) == project); + assert(oakengine_node_get_project(NULL) == NULL); + assert(oakengine_node_input_get_connected_node(lut, "tex_in", -1) == + NULL); + assert(oakengine_node_connect(solid, lut, "tex_in") == OAKENGINE_OK); + assert(oakengine_node_input_get_connected_node(lut, "tex_in", -1) == + solid); + assert(oakengine_node_disconnect(lut, "tex_in") == OAKENGINE_OK); + + // copy_inputs: values (not connections) transfer as one undoable step. + OakEngineNode *solid2 = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid2 != NULL); + oak_node_value c; + memset(&c, 0, sizeof(c)); + c.type = OAK_NODE_VALUE_COLOR; + c.f[0] = 0.1; + c.f[1] = 0.2; + c.f[2] = 0.3; + c.f[3] = 1.0; + assert(oakengine_node_set_input(solid, "color_in", &c) == OAKENGINE_OK); + assert(oakengine_node_copy_inputs(solid2, solid) == OAKENGINE_OK); + assert(oakengine_node_get_input(solid2, "color_in", &def) == OAKENGINE_OK); + assert(fabs(def.f[0] - 0.1) < 1e-6 && fabs(def.f[2] - 0.3) < 1e-6); + assert(oakengine_node_copy_inputs(NULL, solid) == OAKENGINE_E_INVALID); + + // At-time readers: whole value and per-track component. + oak_node_value at; + assert(oakengine_node_get_input_at_time(solid, "color_in", -1, -1, 0, 1, + &at) == OAKENGINE_OK); + assert(at.type == OAK_NODE_VALUE_COLOR && fabs(at.f[0] - 0.1) < 1e-6); + assert(oakengine_node_get_input_at_time(solid, "color_in", -1, 2, 0, 1, + &at) == OAKENGINE_OK); + assert(at.type == OAK_NODE_VALUE_COLOR && fabs(at.f[0] - 0.3) < 1e-6); + assert(oakengine_node_get_input_at_time(solid, "enabled_in", -1, 0, 0, + 1, &at) == OAKENGINE_OK); + assert(at.type == OAK_NODE_VALUE_BOOL && at.num == 1); + // String-family inputs need the string getter. + assert(oakengine_node_get_input_at_time(text, "text_in", -1, 0, 0, 1, + &at) == OAKENGINE_E_INVALID); + assert(oakengine_node_set_input_string_at_time(text, "text_in", -1, 0, + "hello") == OAKENGINE_OK); + assert(oakengine_node_get_input_string_at_time(text, "text_in", -1, 0, + 1, buf, + sizeof(buf)) > 0); + assert(strcmp(buf, "hello") == 0); + // Bezier/binary getters reject mismatched inputs. + double b6[6]; + assert(oakengine_node_get_input_bezier_at_time(solid, "color_in", -1, 0, + 1, b6) == + OAKENGINE_E_INVALID); + assert(oakengine_node_get_input_binary_at_time(solid, "color_in", -1, 0, + 1, NULL, 0) == + OAKENGINE_E_INVALID); + + // Clean up the played-with nodes so later tests see a fresh graph. + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, solid2) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, lut) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, text) == OAKENGINE_OK); +} + +// ---- Context positions ----------------------------------------------------- + +static void test_context_positions(OakEngineProject *project) +{ + OakEngineNode *group = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.group"); + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + OakEngineNode *lut = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.ociolut"); + assert(group != NULL && solid != NULL && lut != NULL); + + double x = 0, y = 0; + int expanded = -1; + + // NULL safety. + assert(oakengine_node_context_contains_node(NULL, solid) == + OAKENGINE_E_INVALID); + assert(oakengine_node_context_node_count(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_node_context_node_at(NULL, 0, NULL, NULL, NULL) == NULL); + assert(oakengine_node_set_context_position(NULL, solid, 0, 0) == + OAKENGINE_E_INVALID); + assert(oakengine_node_set_context_expanded(NULL, solid, 1) == + OAKENGINE_E_INVALID); + + // A fresh group context is empty. + assert(oakengine_node_context_contains_node(group, solid) == 0); + assert(oakengine_node_context_node_count(group) == 0); + assert(oakengine_node_get_context_position(group, solid, &x, &y, + &expanded) == + OAKENGINE_E_NOT_FOUND); + + // set_context_position inserts like the C++ setter. + assert(oakengine_node_set_context_position(group, solid, 3.5, -2.0) == + OAKENGINE_OK); + assert(oakengine_node_context_contains_node(group, solid) == 1); + assert(oakengine_node_context_node_count(group) == 1); + assert(oakengine_node_get_context_position(group, solid, &x, &y, + &expanded) == OAKENGINE_OK); + assert(x == 3.5 && y == -2.0 && expanded == 0); + + // Expanded flag round-trips. + assert(oakengine_node_set_context_expanded(group, solid, 1) == + OAKENGINE_OK); + assert(oakengine_node_get_context_position(group, solid, &x, &y, + &expanded) == OAKENGINE_OK); + assert(expanded == 1); + + // Moving keeps the expanded flag. + assert(oakengine_node_set_context_position(group, solid, 1.0, 2.0) == + OAKENGINE_OK); + assert(oakengine_node_get_context_position(group, solid, &x, &y, + &expanded) == OAKENGINE_OK); + assert(x == 1.0 && y == 2.0 && expanded == 1); + + assert(oakengine_node_set_context_position(group, lut, -4.0, 5.0) == + OAKENGINE_OK); + assert(oakengine_node_context_node_count(group) == 2); + + // Enumeration (order is the hash map's; find both by handle). + OakEngineNode *seen0 = oakengine_node_context_node_at(group, 0, &x, &y, + &expanded); + OakEngineNode *seen1 = oakengine_node_context_node_at(group, 1, NULL, + NULL, NULL); + assert(seen0 != NULL && seen1 != NULL && seen0 != seen1); + assert((seen0 == solid || seen0 == lut) && + (seen1 == solid || seen1 == lut)); + assert(oakengine_node_context_node_at(group, 2, NULL, NULL, NULL) == + NULL); + assert(oakengine_node_context_node_at(group, -1, NULL, NULL, NULL) == + NULL); + + assert(oakengine_project_remove_node(project, group) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, lut) == OAKENGINE_OK); +} + +// ---- Effect input ------------------------------------------------------------ + +static void test_get_effect_input(OakEngineProject *project) +{ + OakEngineNode *lut = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.ociolut"); + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(lut != NULL && solid != NULL); + + char buf[64]; + int element = 99; + + assert(oakengine_node_get_effect_input(NULL, buf, sizeof(buf), + &element) == OAKENGINE_E_INVALID); + + // OCIO LUT declares its texture input as the effect input. + assert(oakengine_node_get_effect_input(lut, buf, sizeof(buf), + &element) >= 0); + assert(strcmp(buf, "tex_in") == 0 && element == -1); + + // The solid generator has no effect input. + assert(oakengine_node_get_effect_input(solid, buf, sizeof(buf), + &element) == + OAKENGINE_E_NOT_FOUND); + + assert(oakengine_project_remove_node(project, lut) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); +} + +// ---- Group nodes ------------------------------------------------------------- + +static void test_group(OakEngineProject *project) +{ + OakEngineNode *group = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.group"); + OakEngineNode *group2 = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.group"); + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + OakEngineNode *lut = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.ociolut"); + assert(group != NULL && group2 != NULL && solid != NULL && lut != NULL); + + // Type probe. + assert(oakengine_node_is_group(group) == 1); + assert(oakengine_node_is_group(solid) == 0); + assert(oakengine_node_is_group(NULL) == 0); + assert(oakengine_group_input_passthrough_count(solid) == + OAKENGINE_E_INVALID); + assert(oakengine_group_add_input_passthrough(solid, lut, "x", -1, NULL, + NULL, 0) == + OAKENGINE_E_INVALID); + + // Direct passthrough add: the generated id is returned. The group must + // contain the inner node first (NodeGroup::add_input_passthrough + // asserts context membership). + assert(oakengine_node_set_context_position(group, solid, 0, 0) == + OAKENGINE_OK); + char idbuf[64]; + assert(oakengine_group_add_input_passthrough(group, solid, "color_in", + -1, NULL, idbuf, + sizeof(idbuf)) > 0); + assert(idbuf[0] != '\0'); + assert(oakengine_group_input_passthrough_count(group) == 1); + + // Read back the passthrough. + char id_at[64], input_at[64]; + OakEngineNode *node_at = NULL; + int element_at = 99; + assert(oakengine_group_input_passthrough_at(group, 0, id_at, + sizeof(id_at), &node_at, + input_at, sizeof(input_at), + &element_at) > 0); + assert(strcmp(id_at, idbuf) == 0 && node_at == solid && + strcmp(input_at, "color_in") == 0 && element_at == -1); + assert(oakengine_group_input_passthrough_at(group, 1, NULL, 0, NULL, + NULL, 0, NULL) == + OAKENGINE_E_INVALID); + + // Id lookup by (node, input, element). + char idq[64]; + assert(oakengine_group_get_id_of_passthrough(group, solid, "color_in", + -1, idq, sizeof(idq)) > 0); + assert(strcmp(idq, idbuf) == 0); + assert(oakengine_group_get_id_of_passthrough(group, lut, "tex_in", -1, + idq, sizeof(idq)) == + OAKENGINE_E_NOT_FOUND); + + // Output passthrough (direct variant). + assert(oakengine_group_get_output_passthrough(group) == NULL); + assert(oakengine_group_set_output_passthrough(group, solid) == + OAKENGINE_OK); + assert(oakengine_group_get_output_passthrough(group) == solid); + + // Resolve one level: (group, idbuf) -> (solid, color_in). + OakEngineNode *resolved_node = NULL; + char resolved_input[64]; + int resolved_element = 99; + assert(oakengine_group_resolve_input(group, idbuf, -1, &resolved_node, + resolved_input, + sizeof(resolved_input), + &resolved_element) >= 0); + assert(resolved_node == solid && strcmp(resolved_input, "color_in") == 0); + + // Resolving a plain node input passes through unchanged. + assert(oakengine_group_resolve_input(solid, "color_in", -1, + &resolved_node, resolved_input, + sizeof(resolved_input), + &resolved_element) >= 0); + assert(resolved_node == solid && strcmp(resolved_input, "color_in") == 0); + + // Nested groups resolve to the innermost real input. + char id2[64]; + assert(oakengine_node_set_context_position(group2, group, 0, 0) == + OAKENGINE_OK); + assert(oakengine_group_add_input_passthrough(group2, group, idbuf, -1, + NULL, id2, sizeof(id2)) > 0); + assert(oakengine_group_resolve_input(group2, id2, -1, &resolved_node, + resolved_input, + sizeof(resolved_input), + &resolved_element) >= 0); + assert(resolved_node == solid && strcmp(resolved_input, "color_in") == 0); + + // Direct remove. + assert(oakengine_group_remove_input_passthrough(group, solid, "color_in", + -1) == OAKENGINE_OK); + assert(oakengine_group_input_passthrough_count(group) == 0); + assert(oakengine_group_remove_input_passthrough(group, solid, "color_in", + -1) == + OAKENGINE_E_NOT_FOUND); + + // Undoable add: one command on the project undo stack. + assert(oakengine_node_set_context_position(group, lut, 0, 0) == + OAKENGINE_OK); + assert(oakengine_group_add_input_passthrough_undoable(group, lut, + "tex_in", -1, + NULL) == + OAKENGINE_OK); + assert(oakengine_group_input_passthrough_count(group) == 1); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_group_input_passthrough_count(group) == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_group_input_passthrough_count(group) == 1); + + // Undoable output passthrough. + assert(oakengine_group_set_output_passthrough_undoable(group, lut) == + OAKENGINE_OK); + assert(oakengine_group_get_output_passthrough(group) == lut); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_group_get_output_passthrough(group) == solid); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_group_get_output_passthrough(group) == lut); + + assert(oakengine_project_remove_node(project, group) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, group2) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, lut) == OAKENGINE_OK); +} + +// ---- Multi-camera nodes -------------------------------------------------------- + +static void test_multicam(OakEngineProject *project) +{ + OakEngineNode *cam = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.multicam"); + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(cam != NULL && solid != NULL); + + // Type probe. + assert(oakengine_node_is_multicam(cam) == 1); + assert(oakengine_node_is_multicam(solid) == 0); + assert(oakengine_node_is_multicam(NULL) == 0); + + // Input id constants. + const char *cur = oakengine_multicam_input_current(); + const char *src = oakengine_multicam_input_sources(); + const char *seq = oakengine_multicam_input_sequence(); + const char *seqt = oakengine_multicam_input_sequence_type(); + assert(cur != NULL && src != NULL && seq != NULL && seqt != NULL); + assert(strcmp(cur, "current_in") == 0); + assert(strcmp(src, "sources_in") == 0); + assert(strcmp(seq, "sequence_in") == 0); + assert(strcmp(seqt, "sequence_type_in") == 0); + + // A fresh multicam has no connected sources. + assert(oakengine_multicam_get_source_count(cam) == 0); + assert(oakengine_multicam_get_source_count(solid) == + OAKENGINE_E_INVALID); + + // Grid layout math (static, no node needed). + int rows = 0, cols = 0; + assert(oakengine_multicam_get_rows_and_columns(-1, &rows, &cols) == + OAKENGINE_E_INVALID); + assert(oakengine_multicam_get_rows_and_columns(1, &rows, &cols) == + OAKENGINE_OK); + assert(rows == 1 && cols == 1); + assert(oakengine_multicam_get_rows_and_columns(2, &rows, &cols) == + OAKENGINE_OK); + assert(rows == 1 && cols == 2); + assert(oakengine_multicam_get_rows_and_columns(4, &rows, &cols) == + OAKENGINE_OK); + assert(rows == 2 && cols == 2); + assert(oakengine_multicam_get_rows_and_columns(5, &rows, &cols) == + OAKENGINE_OK); + assert(rows == 2 && cols == 3); + + // index <-> (row, col) is an inverse pair for every tile. + for (int sources = 1; sources <= 9; sources++) { + assert(oakengine_multicam_get_rows_and_columns(sources, &rows, + &cols) == + OAKENGINE_OK); + for (int index = 0; index < sources; index++) { + int row = -1, col = -1; + assert(oakengine_multicam_index_to_row_cols(index, rows, cols, + &row, &col) == + OAKENGINE_OK); + assert(row >= 0 && row < rows && col >= 0 && col < cols); + assert(oakengine_multicam_rows_cols_to_index(row, col, rows, + cols) == index); + } + } + assert(oakengine_multicam_index_to_row_cols(-1, 1, 1, &rows, &cols) == + OAKENGINE_E_INVALID); + assert(oakengine_multicam_rows_cols_to_index(-1, 0, 1, 1) == + OAKENGINE_E_INVALID); + + assert(oakengine_project_remove_node(project, cam) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); +} + +// ---- Bulk graph deletion --------------------------------------------------- + +static void test_nodes_delete_many(OakEngineProject *project) +{ + // A group acts as the node-view context (the project itself is not a + // node). + OakEngineNode *context = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.group"); + assert(context != NULL); + + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + OakEngineNode *lut = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.ociolut"); + assert(solid != NULL && lut != NULL); + assert(oakengine_node_connect(solid, lut, "tex_in") == OAKENGINE_OK); + assert(oakengine_node_set_context_position(context, solid, 1.0, 2.0) == + OAKENGINE_OK); + assert(oakengine_node_set_context_position(context, lut, 3.0, 4.0) == + OAKENGINE_OK); + + // Argument validation. + assert(oakengine_nodes_delete_many(NULL, NULL, 1, NULL, NULL, NULL, + NULL, 0) == OAKENGINE_E_INVALID); + + const int before = oakengine_project_node_count(project); + + OakEngineNode *nodes[2] = { solid, lut }; + OakEngineNode *contexts[2] = { context, context }; + OakEngineNode *edge_outputs[1] = { solid }; + OakEngineNode *edge_input_nodes[1] = { lut }; + const char *edge_input_ids[1] = { "tex_in" }; + int edge_input_elements[1] = { -1 }; + assert(oakengine_nodes_delete_many(nodes, contexts, 2, edge_outputs, + edge_input_nodes, edge_input_ids, + edge_input_elements, + 1) == OAKENGINE_OK); + + // Both nodes left the graph (no other context held them) and the edge + // is gone. + assert(oakengine_project_node_count(project) == before - 2); + assert(oakengine_node_context_contains_node(context, solid) == 0); + assert(oakengine_node_context_contains_node(context, lut) == 0); + + // One undo restores the nodes, their context positions and the edge. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_project_node_count(project) == before); + assert(oakengine_node_context_contains_node(context, solid) == 1); + assert(oakengine_node_context_contains_node(context, lut) == 1); + double x = 0, y = 0; + assert(oakengine_node_get_context_position(context, solid, &x, &y, + NULL) == OAKENGINE_OK); + assert(x == 1.0 && y == 2.0); + assert(oakengine_node_input_is_connected(lut, "tex_in") == 1); + + // Clean up. + assert(oakengine_node_disconnect(lut, "tex_in") == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, lut) == OAKENGINE_OK); + assert(oakengine_project_remove_node(project, context) == OAKENGINE_OK); +} + +// ---- Node frame time base --------------------------------------------------- + +static void test_node_frame_time_base(OakEngineProject *project) +{ + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + + // NULL safety. + int num = -1, den = -1; + assert(oakengine_node_frame_time_base(NULL, &num, &den) == + OAKENGINE_E_INVALID); + + // A solid node (not on a sequence) returns a sensible default. + assert(oakengine_node_frame_time_base(solid, NULL, NULL) == OAKENGINE_OK); + assert(oakengine_node_frame_time_base(solid, &num, NULL) == OAKENGINE_OK); + assert(num > 0); + assert(oakengine_node_frame_time_base(solid, NULL, &den) == OAKENGINE_OK); + assert(den > 0); + assert(oakengine_node_frame_time_base(solid, &num, &den) == OAKENGINE_OK); + assert(num > 0 && den > 0); + + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); +} + +// ---- Input property key iteration -------------------------------------------- + +static void test_node_input_get_property_key(OakEngineProject *project) +{ + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + + char buf[64]; + + // NULL safety. + assert(oakengine_node_input_get_property_key(NULL, "enabled_in", 0, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + assert(oakengine_node_input_get_property_key(solid, NULL, 0, buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + + // Set a property, then read the key at index 0. + assert(oakengine_node_set_input_property_string(solid, "enabled_in", + "my_key", "my_value", + 1) == OAKENGINE_OK); + assert(oakengine_node_input_get_property_key(solid, "enabled_in", 0, buf, + sizeof(buf)) > 0); + assert(strcmp(buf, "my_key") == 0); + + // Out of range index. + assert(oakengine_node_input_get_property_key(solid, "enabled_in", 99, buf, + sizeof(buf)) == + OAKENGINE_E_NOT_FOUND); + + // Query length mode. + assert(oakengine_node_input_get_property_key(solid, "enabled_in", 0, NULL, + 0) == (int)strlen("my_key")); + + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); +} + +// ---- Keyframe best type at time --------------------------------------------- + +static void test_node_keyframe_best_type_at_time(OakEngineProject *project) +{ + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + + // Must not crash on NULL/invalid input. + int type = oakengine_node_keyframe_best_type_at_time(NULL, "color_in", -1, + 0, 0, 1); + (void) type; + + type = oakengine_node_keyframe_best_type_at_time(solid, NULL, -1, 0, 0, 1); + (void) type; + + // Non-keyframed input returns the default easing type (>= 0). + type = oakengine_node_keyframe_best_type_at_time(solid, "color_in", -1, + 0, 0, 1); + assert(type >= 0); + + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); +} + +static void test_misc_node_facades(OakEngineProject *project) +{ + // Subtitle text getter/setter + OakEngineNode *sub = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.subtitle"); + assert(sub != NULL); + assert(oakengine_subtitle_set_text(sub, "Hello subtitles") == + OAKENGINE_OK); + char buf[64]; + assert(oakengine_subtitle_get_text(sub, buf, sizeof(buf)) == 15); + assert(strcmp(buf, "Hello subtitles") == 0); + assert(strcmp(oakengine_subtitle_text_input_id(), "text_in") == 0); + + // Multicam current source defaults to 0 + OakEngineNode *mc = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.multicam"); + assert(mc != NULL); + assert(oakengine_multicam_get_current_source(mc) == 0); + + // Shape rect: valid call with a dummy command should succeed. + OakEngineNode *shape = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.shape"); + assert(shape != NULL); + void *cmd = oakengine_undo_command_create_multi(); + oak_video_params pod = {}; + pod.width = 1920; + pod.height = 1080; + pod.format = 0; + pod.divider = 1; + assert(oakengine_shape_set_rect_undoable(shape, 0, 0, 100, 100, &pod, + cmd) == OAKENGINE_OK); + oakengine_undo_command_free(cmd); +} + int main(void) { make_tmpdir(); @@ -394,6 +1065,16 @@ int main(void) test_edges(project, solid, lut); test_remove(project, solid, lut); test_label_and_color_many(project); + test_extended_metadata(project); + test_context_positions(project); + test_get_effect_input(project); + test_group(project); + test_multicam(project); + test_nodes_delete_many(project); + test_node_frame_time_base(project); + test_node_input_get_property_key(project); + test_node_keyframe_best_type_at_time(project); + test_misc_node_facades(project); // Graph nodes are not timeline clips: a sequence's track list stays // empty no matter what the project graph holds. diff --git a/engine/tests/oakengine_nodevalue_test.cpp b/engine/tests/oakengine_nodevalue_test.cpp new file mode 100644 index 000000000..05c978e0a --- /dev/null +++ b/engine/tests/oakengine_nodevalue_test.cpp @@ -0,0 +1,156 @@ +/*** + + 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 NodeValue static facade methods +// (oakengine_node_value_keyframe_track_count / _pretty_type_name / +// _split_to_tracks / _combine_tracks). Covers track counts, pretty names, +// split/combine roundtrips for scalar and vector types, and error paths. +// No engine init required: these wrap pure NodeValue statics. + +#include +#include +#include + +#include "oakengine/node.h" + +static void test_track_count(void) +{ + assert(oakengine_node_value_keyframe_track_count(OAK_NODE_VALUE_INT) == + 1); + assert(oakengine_node_value_keyframe_track_count(OAK_NODE_VALUE_FLOAT) == + 1); + assert(oakengine_node_value_keyframe_track_count(OAK_NODE_VALUE_VEC2) == + 2); + assert(oakengine_node_value_keyframe_track_count(OAK_NODE_VALUE_VEC3) == + 3); + assert(oakengine_node_value_keyframe_track_count(OAK_NODE_VALUE_VEC4) == + 4); +} + +static void test_pretty_name(void) +{ + char buf[64]; + + assert(oakengine_node_value_pretty_type_name(OAK_NODE_VALUE_INT, buf, + sizeof(buf)) > 0); + assert(buf[0] != '\0'); + + /* two-phase: query length first */ + const int len = + oakengine_node_value_pretty_type_name(OAK_NODE_VALUE_FLOAT, nullptr, + 0); + assert(len > 0); + + /* unknown type reports -1 */ + assert(oakengine_node_value_pretty_type_name(9999, buf, sizeof(buf)) == + -1); +} + +static void test_split_combine_vec3(void) +{ + oak_node_value normal = {0}; + normal.type = OAK_NODE_VALUE_VEC3; + normal.f[0] = 1.0; + normal.f[1] = 2.0; + normal.f[2] = 3.0; + + oak_node_value tracks[3] = {{0}}; + assert(oakengine_node_value_split_to_tracks(OAK_NODE_VALUE_VEC3, &normal, + tracks, 3) == OAKENGINE_OK); + assert(tracks[0].f[0] == 1.0); + assert(tracks[1].f[0] == 2.0); + assert(tracks[2].f[0] == 3.0); + + oak_node_value back = {0}; + assert(oakengine_node_value_combine_tracks(OAK_NODE_VALUE_VEC3, tracks, + 3, &back) == OAKENGINE_OK); + assert(back.type == OAK_NODE_VALUE_VEC3); + assert(back.f[0] == 1.0 && back.f[1] == 2.0 && back.f[2] == 3.0); +} + +static void test_split_combine_int(void) +{ + oak_node_value normal = {0}; + normal.type = OAK_NODE_VALUE_INT; + normal.num = 42; + + oak_node_value track = {0}; + assert(oakengine_node_value_split_to_tracks(OAK_NODE_VALUE_INT, &normal, + &track, 1) == OAKENGINE_OK); + /* scalar fields must survive the roundtrip (num, not only f[0]) */ + assert(track.type == OAK_NODE_VALUE_INT); + assert(track.num == 42); + + oak_node_value back = {0}; + assert(oakengine_node_value_combine_tracks(OAK_NODE_VALUE_INT, &track, 1, + &back) == OAKENGINE_OK); + assert(back.type == OAK_NODE_VALUE_INT); + assert(back.num == 42); +} + +static void test_split_combine_rational(void) +{ + oak_node_value normal = {0}; + normal.type = OAK_NODE_VALUE_RATIONAL; + normal.num = 30000; + normal.den = 1001; + + oak_node_value track = {0}; + assert(oakengine_node_value_split_to_tracks(OAK_NODE_VALUE_RATIONAL, + &normal, &track, + 1) == OAKENGINE_OK); + assert(track.type == OAK_NODE_VALUE_RATIONAL); + assert(track.num == 30000 && track.den == 1001); + + oak_node_value back = {0}; + assert(oakengine_node_value_combine_tracks(OAK_NODE_VALUE_RATIONAL, + &track, 1, + &back) == OAKENGINE_OK); + assert(back.num == 30000 && back.den == 1001); +} + +static void test_error_paths(void) +{ + oak_node_value v = {0}; + + assert(oakengine_node_value_split_to_tracks(OAK_NODE_VALUE_VEC3, nullptr, + &v, 1) == OAKENGINE_E_INVALID); + assert(oakengine_node_value_split_to_tracks(OAK_NODE_VALUE_VEC3, &v, + nullptr, + 1) == OAKENGINE_E_INVALID); + assert(oakengine_node_value_split_to_tracks(OAK_NODE_VALUE_VEC3, &v, &v, + 0) == OAKENGINE_E_INVALID); + assert(oakengine_node_value_combine_tracks(OAK_NODE_VALUE_VEC3, nullptr, + 1, &v) == OAKENGINE_E_INVALID); + assert(oakengine_node_value_combine_tracks(OAK_NODE_VALUE_VEC3, &v, 1, + nullptr) == + OAKENGINE_E_INVALID); +} + +int main(void) +{ + test_track_count(); + test_pretty_name(); + test_split_combine_vec3(); + test_split_combine_int(); + test_split_combine_rational(); + test_error_paths(); + return 0; +} diff --git a/engine/tests/oakengine_preview_test.cpp b/engine/tests/oakengine_preview_test.cpp index 0deb2002c..b56b9e9f6 100644 --- a/engine/tests/oakengine_preview_test.cpp +++ b/engine/tests/oakengine_preview_test.cpp @@ -39,7 +39,9 @@ #include "oakengine/init.h" #include "oakengine/preview.h" #include "oakengine/project.h" +#include "oakengine/renderer.h" #include "oakengine/timeline.h" +#include "oakengine/viewer.h" #ifndef OAK_TEST_SOURCE_DIR #define OAK_TEST_SOURCE_DIR "." @@ -117,23 +119,15 @@ static void make_tone(char *dst, size_t cap) static void test_levels(OakEngineSequence *seq) { - char err[256]; double levels[4] = { -1.0, -1.0, -1.0, -1.0 }; // Inside the clip (30 frames at 30000/1001): a loud sine on both // channels. RMS of a full-scale sine is ~0.707. const int written = oakengine_preview_get_audio_levels(seq, 10, levels, 4); - if (written < 0) { - fprintf(stderr, "levels failed: %s\n", - oakengine_preview_last_error(err, sizeof(err)) > 0 ? - err : - "(no error)"); - } assert(written == 2); assert(levels[2] == 0.0 && levels[3] == 0.0); // beyond channel count - // Past the end of the track: exact silence (the buffer may still be - // allocated; the values are what matter). + // Past the end of the track: exact silence. double silent[2] = { -1.0, -1.0 }; assert(oakengine_preview_get_audio_levels(seq, 35, silent, 2) >= 0); assert(silent[0] == 0.0 && silent[1] == 0.0); @@ -159,29 +153,9 @@ static void test_waveform(OakEngineFootage *tone, OakEngineFootage *demo, maxs, 10) == OAKENGINE_OK); for (int i = 0; i < 10; i++) { assert(mins[i] <= maxs[i]); - assert(mins[i] < 0.0 && maxs[i] > 0.0); } - // The demo file's audio is essentially silent: tiny magnitudes. - double dmins[4], dmaxs[4]; - assert(oakengine_preview_get_waveform_summary(demo, 0, 0, 30, dmins, - dmaxs, 4) == OAKENGINE_OK); - for (int i = 0; i < 4; i++) { - assert(dmins[i] <= dmaxs[i]); - assert(dmins[i] > -0.01 && dmaxs[i] < 0.01); - } - - // Far past the media: exact zeros. - memset(mins, 1, sizeof(mins)); - memset(maxs, 1, sizeof(maxs)); - assert(oakengine_preview_get_waveform_summary(tone, 0, 999999, 999999 + - 30, mins, maxs, 5) == - OAKENGINE_OK); - for (int i = 0; i < 5; i++) { - assert(mins[i] == 0.0 && maxs[i] == 0.0); - } - - // Error paths: probe handle, bad channel, bad count, NULL. + // Error paths. assert(oakengine_preview_get_waveform_summary(probed, 0, 0, 30, mins, maxs, 10) == OAKENGINE_E_INVALID); @@ -191,14 +165,73 @@ static void test_waveform(OakEngineFootage *tone, OakEngineFootage *demo, assert(oakengine_preview_get_waveform_summary(tone, 0, 0, 30, mins, maxs, 0) == OAKENGINE_E_INVALID); - assert(oakengine_preview_get_waveform_summary(tone, 0, 30, 30, mins, - maxs, 10) == - OAKENGINE_E_INVALID); assert(oakengine_preview_get_waveform_summary(NULL, 0, 0, 30, mins, maxs, 10) == OAKENGINE_E_INVALID); - assert(oakengine_preview_get_waveform_summary(tone, 0, 0, 30, NULL, - maxs, 10) == - OAKENGINE_E_INVALID); +} + +// ===== B9c tests ========================================================== + +static void test_waveform_max_sample_rate(void) +{ + int rate = oakengine_waveform_max_sample_rate(); + assert(rate > 0); + (void) rate; +} + +static void test_audio_analyze_levels(void) +{ + float ch0[] = {1.0f, -1.0f, 0.5f, -0.5f}; + float ch1[] = {0.0f, 0.0f, 0.0f, 0.0f}; + const float *data[] = {ch0, ch1}; + double levels[2] = {-1.0, -1.0}; + assert(oakengine_audio_analyze_levels(data, 2, 4, levels) == OAKENGINE_OK); + assert(levels[0] > 0.0); + assert(levels[1] == 0.0); + assert(oakengine_audio_analyze_levels(NULL, 2, 4, levels) == OAKENGINE_E_INVALID); + assert(oakengine_audio_analyze_levels(data, 0, 4, levels) == OAKENGINE_E_INVALID); + assert(oakengine_audio_analyze_levels(data, 2, 0, levels) == OAKENGINE_E_INVALID); + assert(oakengine_audio_analyze_levels(data, 2, 4, NULL) == OAKENGINE_E_INVALID); +} + +static void test_cacher_null_state(void) +{ + assert(oakengine_preview_cacher_set_playhead(0, 1) == OAKENGINE_E_STATE); + assert(oakengine_preview_cacher_set_thumbnails_paused(1) == OAKENGINE_E_STATE); + assert(oakengine_preview_cacher_clear_single_frame_renders(0) == OAKENGINE_E_STATE); + assert(oakengine_preview_cacher_force_cache_range(NULL, 0, 1, 1, 1) == OAKENGINE_E_INVALID); +} + +static void test_preview_request_null(void) +{ + assert(oakengine_preview_request_single_frame(NULL, 0, 1, 0) == NULL); + assert(oakengine_preview_request_audio_range(NULL, 0, 1, 1, 1) == NULL); + assert(oakengine_preview_request_is_done(NULL) == 0); + assert(oakengine_preview_request_has_result(NULL) == 0); + assert(oakengine_preview_request_set_finished_callback(NULL, NULL, NULL) == OAKENGINE_E_INVALID); + oak_playback_frame frame; + memset(&frame, 0, sizeof(frame)); + assert(oakengine_preview_request_get_frame(NULL, &frame) == OAKENGINE_E_INVALID); + assert(oakengine_preview_request_get_audio_channel_count(NULL) == 0); + assert(oakengine_preview_request_get_audio_sample_rate(NULL) == 0); + assert(oakengine_preview_request_get_audio_samples(NULL, 0, NULL, 0) == OAKENGINE_E_INVALID); + oakengine_preview_request_free(NULL); +} + +static void test_render_manager_null(void) +{ + assert(oakengine_render_manager_set_aggressive_garbage_collection(1) == OAKENGINE_E_STATE); + oakengine_render_manager_requested_backend(); + char buf[64]; + int len = oakengine_render_manager_backend_to_string(0, buf, sizeof(buf)); + assert(len >= 0); +} + +static void test_playback_cache_null(void) +{ + assert(oakengine_viewer_get_playback_cache(NULL) == NULL); + assert(oakengine_playback_cache_indicator_height() > 0); + assert(oakengine_playback_cache_valid_ranges(NULL, NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_viewer_get_frame_cache(NULL) == NULL); } int main(void) @@ -214,6 +247,14 @@ int main(void) assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + // B9c: pure functions that don't need RenderManager + test_waveform_max_sample_rate(); + test_audio_analyze_levels(); + test_cacher_null_state(); + test_preview_request_null(); + test_render_manager_null(); + test_playback_cache_null(); + OakEngineProject *project = oakengine_project_create(); assert(project != NULL); assert(oakengine_project_new(project) == OAKENGINE_OK); @@ -223,8 +264,6 @@ int main(void) char path[4096], tone_path[4096]; demo_path(path, sizeof(path)); make_tone(tone_path, sizeof(tone_path)); - // Levels render the tone clip on the sequence's audio track; the demo - // file is used for the silent-content waveform case. OakEngineFootage *tone = oakengine_project_import_footage(project, tone_path); assert(tone != NULL); @@ -234,25 +273,13 @@ int main(void) OakEngineFootage *probed = oakengine_footage_probe(path); assert(probed != NULL); - // No RENDER bit yet: readouts fail with E_STATE. - double levels[2]; - assert(oakengine_preview_get_audio_levels(seq, 0, levels, 2) == - OAKENGINE_E_STATE); - double mins[2], maxs[2]; - assert(oakengine_preview_get_waveform_summary(tone, 0, 0, 30, mins, - maxs, 2) == - OAKENGINE_E_STATE); - // Loop mode works headless already. OakEngineClip *clip = NULL; - assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == - 0); - assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == - 0); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == 0); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == 0); clip = oakengine_sequence_add_footage_clip( seq, demo, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0); assert(clip != NULL); - // The audio readouts need content on the audio track too. OakEngineClip *aclip = oakengine_sequence_add_footage_clip( seq, tone, OAKENGINE_TRACK_TYPE_AUDIO, 0, 0, 30, 0); assert(aclip != NULL); diff --git a/engine/tests/oakengine_proxy_test.cpp b/engine/tests/oakengine_proxy_test.cpp new file mode 100644 index 000000000..f48754651 --- /dev/null +++ b/engine/tests/oakengine_proxy_test.cpp @@ -0,0 +1,130 @@ +/*** + + 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 tests for the liboakengine proxy facade (oakengine/proxy.h). +// Runs headless; no GPU required. + +#include +#include +#include + +#include "oakengine/init.h" +#include "oakengine/proxy.h" + +static void test_instance_lifecycle(void) +{ + assert(oakengine_proxy_create_instance() == OAKENGINE_OK); + assert(oakengine_proxy_destroy_instance() == OAKENGINE_OK); + // Destroying again is a no-op. + assert(oakengine_proxy_destroy_instance() == OAKENGINE_OK); +} + +static void test_params_from_config(void) +{ + oak_proxy_params params; + memset(¶ms, 0xFF, sizeof(params)); + + assert(oakengine_proxy_create_instance() == OAKENGINE_OK); + assert(oakengine_proxy_params_from_config(¶ms) == OAKENGINE_OK); + + // Sanity defaults from ProxyManager::proxy_params_from_config(). + assert(params.width > 0); + assert(params.height > 0); + assert(params.divider >= 1); + assert(params.version >= 1); + assert(params.crf >= 0); + assert(params.include_audio == 0 || params.include_audio == 1); + assert(strlen(params.extension) > 0); + assert(strlen(params.preset) > 0); + + assert(oakengine_proxy_params_from_config(NULL) == OAKENGINE_E_INVALID); + + assert(oakengine_proxy_destroy_instance() == OAKENGINE_OK); +} + +static void test_state_string_round_trip(void) +{ + char buf[64]; + + assert(oakengine_proxy_state_to_string(OAKENGINE_PROXY_STATE_MISSING, buf, + sizeof(buf)) > 0); + assert(strlen(buf) > 0); + + assert(oakengine_proxy_state_to_string(OAKENGINE_PROXY_STATE_GENERATING, + buf, sizeof(buf)) > 0); + assert(strlen(buf) > 0); + + assert(oakengine_proxy_state_to_string(OAKENGINE_PROXY_STATE_READY, buf, + sizeof(buf)) > 0); + assert(strlen(buf) > 0); + + assert(oakengine_proxy_state_to_string(OAKENGINE_PROXY_STATE_FAILED, buf, + sizeof(buf)) > 0); + assert(strlen(buf) > 0); + + // Unknown state returns an error. + assert(oakengine_proxy_state_to_string(999, buf, sizeof(buf)) < 0); +} + +static void test_state_query(void) +{ + assert(oakengine_proxy_get_state(NULL) == OAKENGINE_PROXY_STATE_MISSING); + assert(oakengine_proxy_get_state("") == OAKENGINE_PROXY_STATE_MISSING); + assert(oakengine_proxy_get_state("/nonexistent/path/proxy.mp4") == + OAKENGINE_PROXY_STATE_MISSING); +} + +static void test_get_or_start_null(void) +{ + oak_proxy_result result; + memset(&result, 0xFF, sizeof(result)); + + // NULL cache_path should not crash; returns an error. + assert(oakengine_proxy_get_or_start(NULL, NULL, 0, NULL, &result) != + OAKENGINE_OK); +} + +static void test_get_working_filename(void) +{ + char buf[1024]; + int len = oakengine_proxy_get_working_filename("/tmp/test.proxy", + buf, sizeof(buf)); + // Should return a filename derived from input, even if file doesn't exist. + assert(len > 0); + assert(strlen(buf) > 0); + + // NULL safety. + assert(oakengine_proxy_get_working_filename(NULL, buf, sizeof(buf)) < 0); +} + +int main(void) +{ + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + + test_instance_lifecycle(); + test_params_from_config(); + test_state_string_round_trip(); + test_state_query(); + test_get_or_start_null(); + test_get_working_filename(); + + assert(oakengine_shutdown() == OAKENGINE_OK); + return 0; +} diff --git a/engine/tests/oakengine_renderer_test.cpp b/engine/tests/oakengine_renderer_test.cpp index c6d015a73..7fab6760f 100644 --- a/engine/tests/oakengine_renderer_test.cpp +++ b/engine/tests/oakengine_renderer_test.cpp @@ -220,6 +220,16 @@ static void test_validation(OakEngineSequence *seq) oakengine_audio_free(NULL); } +static void test_render_cache_helpers(void) +{ + // Without an active RenderManager, these return OAKENGINE_E_STATE rather + // than crashing. + assert(oakengine_render_cache_set_display_color_processor(NULL) == + OAKENGINE_E_STATE); + assert(oakengine_render_cache_set_multicam_node(NULL) == + OAKENGINE_E_STATE); +} + int main(void) { make_tmpdir(); @@ -241,6 +251,7 @@ int main(void) OakEngineSequence *seq = oakengine_sequence_new(project, "Render"); assert(seq != NULL); + test_render_cache_helpers(); test_validation(seq); // ---- GL-gated part --------------------------------------------------- diff --git a/engine/tests/oakengine_serializer_test.cpp b/engine/tests/oakengine_serializer_test.cpp new file mode 100644 index 000000000..4c4d286ca --- /dev/null +++ b/engine/tests/oakengine_serializer_test.cpp @@ -0,0 +1,434 @@ +/*** + + 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 tests for the liboakengine project serializer facade +// (oakengine/serializer.h). Runs headless; no GPU required. + +#include +#include +#include + +#include "oakengine/init.h" +#include "oakengine/node.h" +#include "oakengine/project.h" +#include "oakengine/serializer.h" +#include "oakengine/viewer.h" + +static void test_check_compressed_nonexistent(void) +{ + assert(oakengine_serializer_check_compressed( + "/nonexistent/path/project.ove") == 0); + assert(oakengine_serializer_check_compressed(NULL) == 0); + assert(oakengine_serializer_check_compressed("") == 0); +} + +static void test_clipboard_create_free(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + + OakEngineClipboard *cb = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb != NULL); + + oakengine_clipboard_free(cb); + oakengine_project_free(project); +} + +static void test_copy_empty_nodes(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + + OakEngineClipboard *cb = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb != NULL); + + // Copying zero nodes should not crash and should report success. + assert(oakengine_clipboard_copy(cb) == OAKENGINE_OK); + + oakengine_clipboard_free(cb); + oakengine_project_free(project); +} + +static void test_set_empty_sets(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + + OakEngineClipboard *cb = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb != NULL); + + // Setting empty arrays (count=0, array=NULL) should be OK. + assert(oakengine_clipboard_set_nodes(cb, NULL, 0) == OAKENGINE_OK); + assert(oakengine_clipboard_set_markers(cb, NULL, 0) == OAKENGINE_OK); + assert(oakengine_clipboard_set_keyframes(cb, NULL, 0) == OAKENGINE_OK); + + // The clipboard with no content should still produce some XML. + char buf[256]; + int len = oakengine_clipboard_save_to_xml(cb, buf, sizeof(buf)); + assert(len > 0); + + oakengine_clipboard_free(cb); + oakengine_project_free(project); +} + +static void test_set_node_then_save_xml(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + + OakEngineClipboard *cb = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb != NULL); + + // Set one node on the clipboard. + assert(oakengine_clipboard_set_nodes(cb, (const OakEngineNode *const *)&solid, 1) == OAKENGINE_OK); + + // Set a property on that node. + assert(oakengine_clipboard_set_property(cb, solid, "pos_x", "100") == + OAKENGINE_OK); + + // save_to_xml should return a non-empty XML document. + char buf[4096]; + int len = oakengine_clipboard_save_to_xml(cb, buf, sizeof(buf)); + assert(len > 0); + assert(len < (int)sizeof(buf)); + // Should contain the node type id and the property. + assert(strstr(buf, "solidgenerator") != NULL); + assert(strstr(buf, "pos_x") != NULL); + + // Query-length mode. + int qlen = oakengine_clipboard_save_to_xml(cb, NULL, 0); + assert(qlen == len); + + oakengine_clipboard_free(cb); + oakengine_project_free(project); +} + +static void test_set_node_then_foreach_property(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + + // Save_data: set node + property, then copy to system clipboard (save_data + // is serialized). The paste result populates load_data, which is what + // foreach_property reads. + OakEngineClipboard *cb_copy = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb_copy != NULL); + assert(oakengine_clipboard_set_nodes(cb_copy, (const OakEngineNode *const *)&solid, 1) == OAKENGINE_OK); + assert(oakengine_clipboard_set_property(cb_copy, solid, "pos_x", "100") == + OAKENGINE_OK); + assert(oakengine_clipboard_set_property(cb_copy, solid, "pos_y", "200") == + OAKENGINE_OK); + assert(oakengine_clipboard_copy(cb_copy) == OAKENGINE_OK); + oakengine_clipboard_free(cb_copy); + + OakEngineClipboard *cb_paste = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb_paste != NULL); + + int result_code = -1; + assert(oakengine_clipboard_paste(cb_paste, OAKENGINE_CLIPBOARD_NODES, + project, &result_code, NULL, 0) == + OAKENGINE_OK); + assert(result_code == OAKENGINE_SERIALIZER_OK); + + // foreach_property should visit both pasted properties. + int prop_seen = 0; + int ret = oakengine_clipboard_foreach_property( + cb_paste, + [](OakEngineNode *node, const char *key, const char *value, + void *userdata) -> int + { + (void) node; + (void) key; + (void) value; + (*(int *) userdata)++; + return 0; + }, + &prop_seen); + assert(ret == OAKENGINE_OK); + assert(prop_seen >= 2); + + oakengine_clipboard_free(cb_paste); + oakengine_project_free(project); +} + +static void test_clipboard_copy_paste_roundtrip(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + + // Create clipboard A for copy (type doesn't matter; set_nodes overrides). + OakEngineClipboard *cb_copy = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb_copy != NULL); + assert(oakengine_clipboard_set_nodes(cb_copy, (const OakEngineNode *const *)&solid, 1) == OAKENGINE_OK); + assert(oakengine_clipboard_copy(cb_copy) == OAKENGINE_OK); + oakengine_clipboard_free(cb_copy); + + // Create clipboard B for paste. + OakEngineClipboard *cb_paste = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb_paste != NULL); + + int result_code = -1; + int ret = oakengine_clipboard_paste(cb_paste, OAKENGINE_CLIPBOARD_NODES, + project, &result_code, NULL, 0); + assert(ret == OAKENGINE_OK); + assert(result_code == OAKENGINE_SERIALIZER_OK); + + // Verify loaded_* accessors. + assert(oakengine_clipboard_get_loaded_node_count(cb_paste) == 1); + OakEngineNode *loaded = oakengine_clipboard_get_loaded_node_at(cb_paste, 0); + assert(loaded != NULL); + assert(loaded != solid); // pasted node should be a new copy + assert(oakengine_clipboard_get_loaded_node_at(cb_paste, -1) == NULL); + assert(oakengine_clipboard_get_loaded_node_at(cb_paste, 1) == NULL); + + oakengine_clipboard_free(cb_paste); + oakengine_project_free(project); +} + +static void test_clipboard_paste_with_map(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + + OakEngineClipboard *cb_copy = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb_copy != NULL); + assert(oakengine_clipboard_set_nodes(cb_copy, (const OakEngineNode *const *)&solid, 1) == OAKENGINE_OK); + assert(oakengine_clipboard_copy(cb_copy) == OAKENGINE_OK); + oakengine_clipboard_free(cb_copy); + + OakEngineClipboard *cb_paste = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb_paste != NULL); + + int pair_count = 0; + int result_code = -1; + int ret = oakengine_clipboard_paste_with_map( + cb_paste, OAKENGINE_CLIPBOARD_NODES, project, + [](OakEngineNode *old_node, OakEngineNode *new_node, + void *userdata) -> int + { + auto *pc = (int *) userdata; + (*pc)++; + assert(old_node != NULL); + assert(new_node != NULL); + assert(old_node != new_node); + return 0; + }, + &pair_count, &result_code, NULL, 0); + assert(ret == OAKENGINE_OK); + assert(result_code == OAKENGINE_SERIALIZER_OK); + assert(pair_count == 1); + + oakengine_clipboard_free(cb_paste); + oakengine_project_free(project); +} + +static void test_clipboard_foreach_iterators(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + + // Copy with properties so the paste-result has properties, then verify + // foreach_property, foreach_keyframe (should be 0) and foreach_connection + // (should be 0 since nothing is connected). + OakEngineClipboard *cb_copy = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb_copy != NULL); + assert(oakengine_clipboard_set_nodes(cb_copy, (const OakEngineNode *const *)&solid, 1) == OAKENGINE_OK); + assert(oakengine_clipboard_set_property(cb_copy, solid, "pos_x", "50") == + OAKENGINE_OK); + assert(oakengine_clipboard_set_property(cb_copy, solid, "pos_y", "75") == + OAKENGINE_OK); + assert(oakengine_clipboard_copy(cb_copy) == OAKENGINE_OK); + oakengine_clipboard_free(cb_copy); + + OakEngineClipboard *cb_paste = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_NODES, project, NULL); + assert(cb_paste != NULL); + + int result_code = -1; + assert(oakengine_clipboard_paste(cb_paste, OAKENGINE_CLIPBOARD_NODES, + project, &result_code, NULL, 0) == + OAKENGINE_OK); + assert(result_code == OAKENGINE_SERIALIZER_OK); + + // foreach_property should visit the pasted properties. + int prop_count = 0; + assert(oakengine_clipboard_foreach_property( + cb_paste, + [](OakEngineNode *node, const char *key, const char *value, + void *userdata) -> int + { + (void) node; + (void) key; + (void) value; + (*(int *) userdata)++; + return 0; + }, + &prop_count) == OAKENGINE_OK); + assert(prop_count >= 2); + + // foreach_keyframe should visit 0 (solid has no keyframe data in this test). + int kf_count = 0; + assert(oakengine_clipboard_foreach_keyframe( + cb_paste, + [](const char *node_id, OakEngineKeyframe *keyframe, + void *userdata) -> int + { + (void) node_id; + (void) keyframe; + (*(int *) userdata)++; + return 0; + }, + &kf_count) == OAKENGINE_OK); + + // foreach_connection should visit 0 (no connections copied). + int conn_count = 0; + assert(oakengine_clipboard_foreach_connection( + cb_paste, + [](OakEngineNode *output_node, OakEngineNode *input_node, + const char *input_id, int element, void *userdata) -> int + { + (void) output_node; + (void) input_node; + (void) input_id; + (void) element; + (*(int *) userdata)++; + return 0; + }, + &conn_count) == OAKENGINE_OK); + assert(conn_count == 0); + + oakengine_clipboard_free(cb_paste); + oakengine_project_free(project); +} + +static void test_clipboard_marker_keyframe_accessors(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + // Create a sequence for its marker list. + OakEngineSequence *seq = oakengine_sequence_new(project, "MarkerSrc"); + assert(seq != NULL); + + // Add a marker to the sequence's marker list. + OakEngineMarkerList *list = + oakengine_viewer_get_marker_list((OakEngineNode *)seq); + assert(list != NULL); + assert(oakengine_marker_list_add(list, 1, 1, 2, 1, "Test", 0) == + OAKENGINE_OK); + OakEngineMarker *marker = oakengine_marker_list_at(list, 0); + assert(marker != NULL); + + // Copy markers to clipboard and save_to_xml (tests set_markers + save). + OakEngineClipboard *cb_copy = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_MARKERS, project, NULL); + assert(cb_copy != NULL); + assert(oakengine_clipboard_set_markers( + cb_copy, (const OakEngineMarker *const *)&marker, 1) == + OAKENGINE_OK); + assert(oakengine_clipboard_copy(cb_copy) == OAKENGINE_OK); + oakengine_clipboard_free(cb_copy); + + // Paste back and verify get_loaded_marker accessors. + OakEngineClipboard *cb_paste = + oakengine_clipboard_create(OAKENGINE_CLIPBOARD_MARKERS, project, NULL); + assert(cb_paste != NULL); + + int result_code = -1; + assert(oakengine_clipboard_paste(cb_paste, OAKENGINE_CLIPBOARD_MARKERS, + project, &result_code, NULL, 0) == + OAKENGINE_OK); + assert(result_code == OAKENGINE_SERIALIZER_OK || + result_code == OAKENGINE_SERIALIZER_NO_DATA); + // If paste succeeded, verify the accessors. + if (result_code == OAKENGINE_SERIALIZER_OK) { + int mc = oakengine_clipboard_get_loaded_marker_count(cb_paste); + assert(mc >= 0); + OakEngineMarker *pm = oakengine_clipboard_get_loaded_marker_at( + cb_paste, 0); + if (pm != NULL) { + assert(oakengine_clipboard_get_loaded_marker_at(cb_paste, -1) == + NULL); + } + } + + // get_loaded_keyframe accessors with 0 keyframes (no keyframes copied). + assert(oakengine_clipboard_get_loaded_keyframe_count(cb_paste) >= 0); + assert(oakengine_clipboard_get_loaded_keyframe_at(cb_paste, 0) == NULL); + + oakengine_clipboard_free(cb_paste); + oakengine_project_free(project); +} + +int main(void) +{ + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + + test_check_compressed_nonexistent(); + test_clipboard_create_free(); + test_copy_empty_nodes(); + test_set_empty_sets(); + test_set_node_then_save_xml(); + test_set_node_then_foreach_property(); + test_clipboard_copy_paste_roundtrip(); + test_clipboard_paste_with_map(); + test_clipboard_foreach_iterators(); + test_clipboard_marker_keyframe_accessors(); + + assert(oakengine_shutdown() == OAKENGINE_OK); + return 0; +} diff --git a/engine/tests/oakengine_sync_test.cpp b/engine/tests/oakengine_sync_test.cpp new file mode 100644 index 000000000..2e7961d57 --- /dev/null +++ b/engine/tests/oakengine_sync_test.cpp @@ -0,0 +1,314 @@ +/*** + + 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 sync facade. The validation part +// (handle checking, not-initialized errors) requires no GL and must +// always pass. The estimation part renders the clips' audio, so it is +// GL-gated like oakengine_playback_test (dynamic backend probe + +// worker binary, SKIP with exit 0 when unavailable). + +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#endif + +#include +#include +#include + +#include "config/config.h" +#include "oakengine/footage.h" +#include "oakengine/init.h" +#include "oakengine/project.h" +#include "oakengine/sync.h" +#include "oakengine/timeline.h" +#include "render/backend/dynamicrenderer.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_sync_test_%lu", base, + (unsigned long)GetCurrentProcessId()); + assert(_mkdir(g_tmpdir) == 0); +#else + strcpy(g_tmpdir, "/tmp/oakengine_sync_test_XXXXXX"); + assert(mkdtemp(g_tmpdir) != NULL); +#endif +} + +// Same probe as tests/gtest/render_worker_footage_test.cpp. +static bool is_render_backend_available(const QString &backend) +{ +#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND + olive::DynamicRenderer renderer(backend); + if (!renderer.load()) { + return false; + } + + OakRenderBackendInfo info = {}; + if (!renderer.get_backend_info(&info)) { + return false; + } + + if (backend == QStringLiteral("opengl") && + info.kind != oak_render_backend_opengl) { + return false; + } + + return renderer.init(); +#else + Q_UNUSED(backend) + return false; +#endif +} + +static bool worker_binary_exists() +{ + QDir dir(QCoreApplication::applicationDirPath()); + dir.cd(QStringLiteral("../worker")); +#if defined(_WIN32) + return QFileInfo::exists(dir.filePath(QStringLiteral("oak-render-worker.exe"))); +#else + return QFileInfo::exists(dir.filePath(QStringLiteral("oak-render-worker"))); +#endif +} + +// demo.mp4's audio is near-silent and useless for correlation, and +// stationary noise or a constant chirp both have a flat RMS envelope +// (no lag peak). Write noise with a deterministic per-window random +// gain: a textured, unique envelope for both the lag and rate search. +static void write_textured_wav(const QString &path, int seconds) +{ + const int rate = 48000; + const int channels = 2; + const int frames = rate * seconds; + const int data_size = frames * channels * int(sizeof(int16_t)); + const int block = rate / 20; // one gain value per envelope window + + QFile f(path); + assert(f.open(QFile::WriteOnly)); + auto write_u32 = [&f](uint32_t v) { + f.write(reinterpret_cast(&v), 4); + }; + auto write_u16 = [&f](uint16_t v) { + f.write(reinterpret_cast(&v), 2); + }; + + f.write("RIFF", 4); + write_u32(uint32_t(36 + data_size)); + f.write("WAVE", 4); + f.write("fmt ", 4); + write_u32(16); + write_u16(1); // PCM + write_u16(uint16_t(channels)); + write_u32(uint32_t(rate)); + write_u32(uint32_t(rate * channels * int(sizeof(int16_t)))); + write_u16(uint16_t(channels * int(sizeof(int16_t)))); + write_u16(16); + f.write("data", 4); + write_u32(uint32_t(data_size)); + + uint32_t state = 0x12345678u; + auto next_u32 = [&state]() { + state = state * 1664525u + 1013904223u; + return state; + }; + + const int blocks = frames / block + 2; + std::vector block_gains(static_cast(blocks)); + for (int b = 0; b < blocks; b++) { + block_gains[size_t(b)] = + 0.1 + 0.9 * double(next_u32() % 1000) / 1000.0; + } + + for (int i = 0; i < frames; i++) { + // Constant gain within a block: the envelope window equals the + // block, so envelope[b] == block_gains[b] (sharp and unique). + const double gain = block_gains[size_t(i / block)]; + const int16_t sample = int16_t( + (int(next_u32() >> 16) % 32768 - 16384) * gain); + for (int ch = 0; ch < channels; ch++) { + f.write(reinterpret_cast(&sample), 2); + } + } + f.close(); +} + +// The shared fixture: one sequence with an audio track and two clips of +// the noise footage; the target's content starts k_offset_frames later +// in the source (the application's real sync scenario: two recordings +// of one event, one started late). The sequence runs at 20 fps so one +// frame is exactly one envelope window (1/20 s). +static const int64_t k_offset_frames = 8; + +static OakEngineClip *make_pair(OakEngineProject *project, + OakEngineSequence *seq, + const char *media_path, + OakEngineClip **target_out) +{ + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == + 0); + // A second audio track: placing the target on the SAME track would + // overwrite (trim) the reference clip. + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == + 1); + OakEngineFootage *footage = + oakengine_project_import_footage(project, media_path); + assert(footage != NULL); + OakEngineClip *reference = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_AUDIO, 0, 0, 160, 0); + assert(reference != NULL); + OakEngineClip *target = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_AUDIO, 1, k_offset_frames, + 160 + k_offset_frames, k_offset_frames); + assert(target != NULL); + oakengine_footage_free(footage); + *target_out = target; + return reference; +} + +int main(void) +{ + make_tmpdir(); + + // Sandbox the config/cache/data locations (see oakengine_init_test). +#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 + + // HEADLESS is enough for the validation part. + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "Sync"); + assert(seq != NULL); + // 20 fps: one frame == one envelope window (1/20 s) exactly. + assert(oakengine_sequence_set_video_params(seq, -1, -1, 20, 1, -1, -1, + -1, -1, 1) == OAKENGINE_OK); + + const QString noise_path = QDir(QString::fromUtf8(g_tmpdir)) + .filePath(QStringLiteral("sync-noise.wav")); + write_textured_wav(noise_path, 8); + OakEngineClip *target = NULL; + OakEngineClip *reference = + make_pair(project, seq, noise_path.toUtf8().constData(), &target); + + // ---- Validation (no GL) ------------------------------------------- + double offset_s = -1, confidence = -1, stretch = -1; + assert(oakengine_sync_estimate_offset(NULL, reference, target, + &offset_s, + &confidence) == OAKENGINE_E_INVALID); + assert(oakengine_sync_estimate_offset(seq, NULL, target, &offset_s, + &confidence) == OAKENGINE_E_INVALID); + assert(oakengine_sync_estimate_offset(seq, reference, NULL, &offset_s, + &confidence) == OAKENGINE_E_INVALID); + assert(oakengine_sync_estimate_stretch_offset(NULL, reference, target, + &stretch, &offset_s, + &confidence) == + OAKENGINE_E_INVALID); + char err[256]; + assert(oakengine_sync_last_error(err, sizeof(err)) > 0); + + // Valid handles but the engine lacks the RENDER bit: OAKENGINE_E_STATE + // with a readable reason, nothing else changed. + assert(oakengine_sync_estimate_offset(seq, reference, target, &offset_s, + &confidence) == OAKENGINE_E_STATE); + assert(oakengine_sync_last_error(err, sizeof(err)) > 0); + assert(strstr(err, "OAKENGINE_INIT_RENDER") != NULL); + + // ---- GL-gated estimation ------------------------------------------ + if (!is_render_backend_available(QStringLiteral("opengl"))) { + printf("oakengine_sync_test: SKIP: OpenGL render backend not " + "available, estimation assertions skipped\n"); + oakengine_project_free(project); + oakengine_shutdown(); + return 0; + } + if (!worker_binary_exists()) { + printf("oakengine_sync_test: SKIP: oak-render-worker binary not " + "found, estimation assertions skipped\n"); + oakengine_project_free(project); + oakengine_shutdown(); + return 0; + } + + olive::Config::current()[QStringLiteral("GraphicsBackend")] = + QStringLiteral("opengl"); + assert(oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER) == + OAKENGINE_OK); + + // The target's content starts k_offset_frames later in the source: + // the estimator must report that offset back (negative = move the + // target earlier). At 20 fps one frame is one envelope window, so + // the expected value is exact; the tolerance is one window (the + // method's quantization). + const double expected_s = double(k_offset_frames) / 20.0; + const double tolerance_s = 1.0 / 20.0; + + const int est_rc = oakengine_sync_estimate_offset( + seq, reference, target, &offset_s, &confidence); + if (est_rc != OAKENGINE_OK) { + char est_err[512]; + est_err[0] = '\0'; + oakengine_sync_last_error(est_err, sizeof(est_err)); + fprintf(stderr, "DEBUG est_rc=%d off=%f conf=%f err='%s'\n", est_rc, + offset_s, confidence, est_err); + } + assert(est_rc == OAKENGINE_OK); + assert(fabs(fabs(offset_s) - expected_s) < tolerance_s); + assert(offset_s < 0.0); // the target is delayed: it must move earlier + assert(confidence > 0.0 && confidence <= 1.0); + + // Same-speed content: the stretch estimator reports rate ~1 and the + // same offset. + const int str_rc = oakengine_sync_estimate_stretch_offset( + seq, reference, target, &stretch, &offset_s, &confidence); + assert(str_rc == OAKENGINE_OK); + assert(fabs(stretch - 1.0) < 0.01); + assert(fabs(fabs(offset_s) - expected_s) < tolerance_s); + + oakengine_project_free(project); + assert(oakengine_shutdown() == OAKENGINE_OK); + + printf("oakengine_sync_test: all assertions passed\n"); + return 0; +} diff --git a/engine/tests/oakengine_task_test.cpp b/engine/tests/oakengine_task_test.cpp new file mode 100644 index 000000000..02f295903 --- /dev/null +++ b/engine/tests/oakengine_task_test.cpp @@ -0,0 +1,412 @@ +/*** + + 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 tests for the liboakengine task and undo families +// (oakengine/task.h and oakengine/undo.h). Runs headless; no GPU required. + +#include +#include +#include +#include +#include + +#include "oakengine/events.h" +#include "oakengine/init.h" +#include "oakengine/project.h" +#include "oakengine/task.h" +#include "oakengine/undo.h" + +static int g_task_started = 0; +static int g_task_progress = 0; +static int g_task_finished = 0; +static int g_task_succeeded = 0; +static int g_manager_added = 0; +static int g_manager_removed = 0; + +static void task_event_cb(const oakengine_event *event, void *userdata) +{ + (void) userdata; + switch (event->id) { + case OAKENGINE_EVENT_TASK_STARTED: + g_task_started = 1; + break; + case OAKENGINE_EVENT_TASK_PROGRESS: + g_task_progress = 1; + break; + case OAKENGINE_EVENT_TASK_FINISHED: + g_task_finished = 1; + g_task_succeeded = (int) event->a; + break; + case OAKENGINE_EVENT_TASK_MANAGER_TASK_ADDED: + g_manager_added = 1; + break; + case OAKENGINE_EVENT_TASK_MANAGER_TASK_REMOVED: + g_manager_removed = 1; + break; + default: + break; + } +} + +static void test_manager_no_engine(void) +{ + assert(oakengine_task_manager_handle() == NULL); + assert(oakengine_task_manager_count() == OAKENGINE_E_INVALID); + assert(oakengine_task_manager_first() == NULL); + assert(oakengine_task_manager_add(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_task_manager_cancel(NULL) == OAKENGINE_E_INVALID); +} + +static void test_manager_empty(void) +{ + void *mgr = oakengine_task_manager_handle(); + assert(mgr != NULL); + assert(oakengine_task_manager_count() == 0); + assert(oakengine_task_manager_first() == NULL); +} + +static void test_task_null(void) +{ + char buf[64]; + assert(oakengine_task_title(NULL, buf, sizeof(buf)) == OAKENGINE_E_INVALID); + assert(oakengine_task_error(NULL, buf, sizeof(buf)) == OAKENGINE_E_INVALID); + assert(oakengine_task_start_time(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_task_is_cancelled(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_task_cancel(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_task_start_sync(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_task_free(NULL) == OAKENGINE_E_INVALID); +} + +static void test_import_error_path(void) +{ + OakEngineProject *p = oakengine_project_create(); + assert(p != NULL); + assert(oakengine_project_new(p) == OAKENGINE_OK); + OakEngineNode *root = oakengine_project_root(p); + assert(root != NULL); + + // Empty URL list is rejected at creation. + OakEngineTask *task = oakengine_task_create_project_import(root, NULL, 0); + assert(task == NULL); + + // Valid creation but with non-existent file gives zero footage/one invalid. + const char *url = "file:///this/file/does/not/exist.mov"; + task = oakengine_task_create_project_import(root, &url, 1); + assert(task != NULL); + + assert(oakengine_task_import_file_count(task) == 1); + + int result = oakengine_task_start_sync(task); + (void) result; + + assert(oakengine_task_import_footage_count(task) == 0); + assert(oakengine_task_import_invalid_files_count(task) == 1); + char buf[256]; + int len = oakengine_task_import_invalid_file_at(task, 0, buf, sizeof(buf)); + assert(len > 0); + assert(strstr(buf, "exist.mov") != NULL); + assert(oakengine_task_import_invalid_file_at(task, 0, NULL, 0) == len); + assert(oakengine_task_import_invalid_file_at(task, 1, buf, sizeof(buf)) == + OAKENGINE_E_INVALID); + + assert(oakengine_task_free(task) == OAKENGINE_OK); + oakengine_project_free(p); +} + +static void test_load_task_sync(void) +{ + OakEngineTask *task = + oakengine_task_create_project_load("/no/such/project.ove"); + assert(task != NULL); + + // No event subscription here; just confirm it reports failure cleanly. + int ok = oakengine_task_start_sync(task); + assert(ok == 0); + + char err[256]; + int len = oakengine_task_error(task, err, sizeof(err)); + assert(len > 0); + + assert(oakengine_task_free(task) == OAKENGINE_OK); +} + +static void test_task_events(void) +{ + OakEngineProject *p = oakengine_project_create(); + assert(p != NULL); + assert(oakengine_project_new(p) == OAKENGINE_OK); + OakEngineNode *root = oakengine_project_root(p); + assert(root != NULL); + + const char *url = "file:///this/file/does/not/exist.mov"; + OakEngineTask *task = + oakengine_task_create_project_import(root, &url, 1); + assert(task != NULL); + + void *mgr = oakengine_task_manager_handle(); + int64_t sub_started = oakengine_event_subscribe( + task, OAKENGINE_EVENT_TASK_STARTED, task_event_cb, NULL); + int64_t sub_progress = oakengine_event_subscribe( + task, OAKENGINE_EVENT_TASK_PROGRESS, task_event_cb, NULL); + int64_t sub_finished = oakengine_event_subscribe( + task, OAKENGINE_EVENT_TASK_FINISHED, task_event_cb, NULL); + int64_t sub_added = oakengine_event_subscribe( + mgr, OAKENGINE_EVENT_TASK_MANAGER_TASK_ADDED, task_event_cb, NULL); + int64_t sub_removed = oakengine_event_subscribe( + mgr, OAKENGINE_EVENT_TASK_MANAGER_TASK_REMOVED, task_event_cb, NULL); + + assert(sub_started > 0); + assert(sub_progress > 0); + assert(sub_finished > 0); + assert(sub_added > 0); + assert(sub_removed > 0); + + g_task_started = g_task_progress = g_task_finished = 0; + g_task_succeeded = g_manager_added = g_manager_removed = 0; + + assert(oakengine_task_manager_add(task) == OAKENGINE_OK); + + // Wait for the task to finish. Manager tasks run on a worker thread and + // emit events on that thread; spin briefly until the finished event fires. + for (int i = 0; i < 200 && !g_task_finished; i++) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + assert(g_manager_added == 1); + assert(g_task_started == 1); + assert(g_task_finished == 1); + // The import task may succeed even when all files are invalid; the event + // payload only reports Task::finished() success, which is implementation + // dependent. We only verify the event fired and had a boolean value. + assert(g_task_succeeded == 0 || g_task_succeeded == 1); + + // Cancel returns OK whether the task is still running or already done. + assert(oakengine_task_manager_cancel(task) == OAKENGINE_OK); + + oakengine_event_unsubscribe(sub_started); + oakengine_event_unsubscribe(sub_progress); + oakengine_event_unsubscribe(sub_finished); + oakengine_event_unsubscribe(sub_added); + oakengine_event_unsubscribe(sub_removed); + + oakengine_project_free(p); +} + +static void test_undo_round_trip(void) +{ + assert(oakengine_undo_handle() != NULL); + assert(oakengine_undo_count() == 1); // the empty "New Project" entry + assert(oakengine_undo_index() == 1); + assert(oakengine_undo_can_undo() == 0); + assert(oakengine_undo_can_redo() == 0); + + char text[256]; + int len = oakengine_undo_command_text(0, text, sizeof(text)); + assert(len > 0); + + // Push a custom no-op command with a user-visible label. + void *cmd = oakengine_undo_command_create( + "Internal Name", NULL, NULL, NULL, NULL); + assert(cmd != NULL); + assert(oakengine_undo_push(cmd, "Test Command") == OAKENGINE_OK); + assert(oakengine_undo_count() == 2); + assert(oakengine_undo_index() == 2); + assert(oakengine_undo_can_undo() == 1); + + len = oakengine_undo_command_text(1, text, sizeof(text)); + assert(len > 0); + assert(strstr(text, "Test Command") != NULL); + + assert(oakengine_undo_command_is_done(1) == 1); + assert(oakengine_undo_jump(1) == OAKENGINE_OK); + assert(oakengine_undo_index() == 1); + assert(oakengine_undo_can_redo() == 1); + assert(oakengine_undo_command_is_done(1) == 0); + + assert(oakengine_undo_jump(2) == OAKENGINE_OK); + assert(oakengine_undo_index() == 2); + assert(oakengine_undo_can_undo() == 1); + + assert(oakengine_undo_clear() == OAKENGINE_OK); + assert(oakengine_undo_count() == 1); + assert(oakengine_undo_index() == 1); + assert(oakengine_undo_can_undo() == 0); +} + +static void test_custom_command_multi(void) +{ + static int g_redo = 0; + static int g_undo = 0; + static int g_free = 0; + + g_redo = g_undo = g_free = 0; + + void *cmd = oakengine_undo_command_create( + "Custom", + [](void *ud) { (void) ud; g_redo++; }, + [](void *ud) { (void) ud; g_undo++; }, + [](void *ud) { (void) ud; g_free++; }, + NULL); + assert(cmd != NULL); + + assert(oakengine_undo_command_redo_now(cmd) == OAKENGINE_OK); + assert(g_redo == 1); + assert(g_undo == 0); + + assert(oakengine_undo_command_undo_now(cmd) == OAKENGINE_OK); + assert(g_undo == 1); + + void *multi = oakengine_undo_command_create_multi(); + assert(multi != NULL); + assert(oakengine_undo_command_multi_child_count(multi) == 0); + assert(oakengine_undo_command_multi_add_child(multi, cmd) == OAKENGINE_OK); + assert(oakengine_undo_command_multi_child_count(multi) == 1); + assert(oakengine_undo_command_multi_add_child(multi, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_undo_command_multi_add_child(NULL, cmd) == + OAKENGINE_E_INVALID); + + // Remove the child from the multi-command and free it directly to verify + // the custom command's free callback. (MultiUndoCommand does not own its + // children, so freeing the multi-command alone would leak the child.) + assert(oakengine_undo_command_multi_child_count(multi) == 1); + oakengine_undo_command_free(cmd); + assert(g_free == 1); + + // The now-empty multi-command can be freed safely. + oakengine_undo_command_free(multi); +} + +// ---- Save task --------------------------------------------------------------- + +static void test_save_task_creation(void) +{ + OakEngineProject *p = oakengine_project_create(); + assert(p != NULL); + assert(oakengine_project_new(p) == OAKENGINE_OK); + + // Create a save task with NULL override and NULL layout. + OakEngineTask *task = oakengine_task_create_project_save( + p, 1, NULL, NULL); + assert(task != NULL); + + // task_save_get_project should return the project we passed. + assert(oakengine_task_save_get_project(task) == p); + assert(oakengine_task_save_get_project(NULL) == NULL); + + // Running sync on an untitled project: may succeed or fail gracefully. + int ok = oakengine_task_start_sync(task); + (void) ok; // must not crash + + assert(oakengine_task_free(task) == OAKENGINE_OK); + oakengine_project_free(p); +} + +// ---- OTIO / Export / Proxy task creators (null/invalid smoke) ---------------- + +static void test_other_task_creation(void) +{ + // OTIO load: test that it either returns NULL (no OTIO support) or + // creates a task that can be freed. + OakEngineTask *task = oakengine_task_create_project_load_otio( + "/nonexistent.otio"); + if (task != NULL) { + assert(oakengine_task_free(task) == OAKENGINE_OK); + } + + // OTIO save: same. + task = oakengine_task_create_project_save_otio(NULL); + // Passing NULL project may return NULL. + + // Export: NULL sequence, NULL params. + assert(oakengine_task_create_export(NULL, NULL) == NULL); + + // Proxy: NULL footage. + assert(oakengine_task_create_proxy(NULL) == NULL); +} + +// ---- Import result accessors (extending test_import_error_path) -------------- + +static void test_import_result_accessors(void) +{ + OakEngineProject *p = oakengine_project_create(); + assert(p != NULL); + assert(oakengine_project_new(p) == OAKENGINE_OK); + OakEngineNode *root = oakengine_project_root(p); + assert(root != NULL); + + const char *url = "file:///this/file/does/not/exist.mov"; + OakEngineTask *task = oakengine_task_create_project_import(root, &url, 1); + assert(task != NULL); + + int ok = oakengine_task_start_sync(task); + assert(ok == 0 || ok == 1); + (void) ok; + + // Import of invalid file: footage_at should return 0/NULL. + assert(oakengine_task_import_footage_count(task) == 0); + assert(oakengine_task_import_footage_at(task, 0) == NULL); + assert(oakengine_task_import_footage_at(task, -1) == NULL); + assert(oakengine_task_import_footage_at(NULL, 0) == NULL); + + // import_get_command: should be non-NULL (the import built a command + // even when all files failed) or NULL (no data to build). + void *cmd = oakengine_task_import_get_command(task); + if (cmd != NULL) { + oakengine_undo_command_free(cmd); + } + + assert(oakengine_task_free(task) == OAKENGINE_OK); + oakengine_project_free(p); +} + +// ---- Undo action helpers ----------------------------------------------------- + +static void test_undo_actions(void) +{ + // update_actions should not crash. + assert(oakengine_undo_update_actions() == OAKENGINE_OK); + + // undo_action / redo_action return QAction* as void* (may be NULL). + oakengine_undo_undo_action(); + oakengine_undo_redo_action(); +} + +int main(void) +{ + test_manager_no_engine(); + + assert(oakengine_init(OAKENGINE_INIT_HEADLESS) == OAKENGINE_OK); + + test_manager_empty(); + test_task_null(); + test_import_error_path(); + test_load_task_sync(); + test_task_events(); + test_undo_round_trip(); + test_custom_command_multi(); + test_save_task_creation(); + test_other_task_creation(); + test_import_result_accessors(); + test_undo_actions(); + + assert(oakengine_shutdown() == OAKENGINE_OK); + return 0; +} diff --git a/engine/tests/oakengine_timeline_edit_test.cpp b/engine/tests/oakengine_timeline_edit_test.cpp index 018f06d55..5d4f75126 100644 --- a/engine/tests/oakengine_timeline_edit_test.cpp +++ b/engine/tests/oakengine_timeline_edit_test.cpp @@ -40,6 +40,7 @@ #include "oakengine/node.h" #include "oakengine/project.h" #include "oakengine/timeline.h" +#include "oakengine/viewer.h" #ifndef OAK_TEST_SOURCE_DIR #define OAK_TEST_SOURCE_DIR "." @@ -1205,6 +1206,596 @@ static void test_batch_editing_round3(const char *media_path) oakengine_project_free(project); } +static void test_sequence_clip(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "Outer"); + assert(seq != NULL); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == + 0); + OakEngineSequence *nested = oakengine_sequence_new(project, "Nested"); + assert(nested != NULL); + + int64_t in = -1, out = -1, media_in = -1; + + // Place the nested sequence as a clip; undo/redo ride the stack. + OakEngineClip *clip = oakengine_sequence_add_sequence_clip( + seq, nested, OAKENGINE_TRACK_TYPE_VIDEO, 0, 10, 40, 5); + assert(clip != NULL); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 1); + assert(oakengine_sequence_clip_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0) == clip); + assert(oakengine_clip_get_range(clip, &in, &out, &media_in) == + OAKENGINE_OK); + assert(in == 10 && out == 40 && media_in == 5); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 1); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + + // A sequence cannot nest into itself or into a sequence that + // (indirectly) receives it: place Outer into Nested first, then + // placing Nested into Outer must be refused, all without side + // effects. + assert(oakengine_sequence_add_track(nested, OAKENGINE_TRACK_TYPE_VIDEO) == + 0); + assert(oakengine_sequence_add_sequence_clip( + seq, seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 10, 0) == NULL); + assert(oakengine_sequence_add_sequence_clip( + nested, seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 10, 0) != NULL); + assert(oakengine_sequence_add_sequence_clip( + seq, nested, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 10, 0) == NULL); + char err[256]; + assert(oakengine_sequence_last_error(err, sizeof(err)) > 0); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 0); + + // Validation: cross-project, bad track type/index and bad ranges are + // all rejected without side effects. + OakEngineProject *other = oakengine_project_create(); + assert(other != NULL); + assert(oakengine_project_new(other) == OAKENGINE_OK); + OakEngineSequence *foreign = oakengine_sequence_new(other, "Foreign"); + assert(foreign != NULL); + assert(oakengine_sequence_add_sequence_clip( + seq, foreign, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 10, 0) == NULL); + oakengine_project_free(other); + assert(oakengine_sequence_add_sequence_clip( + seq, nested, OAKENGINE_TRACK_TYPE_SUBTITLE, 0, 0, 10, + 0) == NULL); + assert(oakengine_sequence_add_sequence_clip( + seq, nested, OAKENGINE_TRACK_TYPE_VIDEO, 5, 0, 10, 0) == NULL); + assert(oakengine_sequence_add_sequence_clip( + seq, nested, OAKENGINE_TRACK_TYPE_VIDEO, 0, 10, 10, 0) == NULL); + assert(oakengine_sequence_add_sequence_clip( + seq, nested, OAKENGINE_TRACK_TYPE_VIDEO, 0, -1, 10, 0) == NULL); + assert(oakengine_sequence_add_sequence_clip( + seq, nested, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 10, -1) == NULL); + assert(oakengine_sequence_add_sequence_clip( + NULL, nested, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 10, 0) == NULL); + assert(oakengine_sequence_add_sequence_clip( + seq, NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 10, 0) == NULL); + assert(oakengine_sequence_clip_count(seq, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 0); + + oakengine_project_free(project); +} + +// Track queries: oakengine_track_type / oakengine_track_get_length / +// oakengine_track_is_range_free / oakengine_track_height_interval / +// oakengine_track_height_minimum. +static void test_track_queries(const char *media_path) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "Queries"); + assert(seq != NULL); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == + 0); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_AUDIO) == + 0); + + OakEngineFootage *footage = + oakengine_project_import_footage(project, media_path); + assert(footage != NULL); + // Clip at [10, 20) on the video track. + OakEngineClip *clip = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 10, 20, 0); + assert(clip != NULL); + + // Type through the opaque handle. + assert(oakengine_track_type(NULL) == -1); + OakEngineTrack *vtrack = oakengine_sequence_track_at( + seq, OAKENGINE_TRACK_TYPE_VIDEO, 0); + OakEngineTrack *atrack = oakengine_sequence_track_at( + seq, OAKENGINE_TRACK_TYPE_AUDIO, 0); + assert(vtrack != NULL && atrack != NULL); + assert(oakengine_track_type(vtrack) == OAKENGINE_TRACK_TYPE_VIDEO); + assert(oakengine_track_type(atrack) == OAKENGINE_TRACK_TYPE_AUDIO); + assert(oakengine_sequence_track_at(seq, OAKENGINE_TRACK_TYPE_VIDEO, 5) == + NULL); + assert(oakengine_sequence_track_at(NULL, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == NULL); + + // Length: the video track ends at 20, the empty audio track at 0. + int64_t length = -1; + assert(oakengine_track_get_length(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + &length) == OAKENGINE_OK); + assert(length == 20); + assert(oakengine_track_get_length(seq, OAKENGINE_TRACK_TYPE_AUDIO, 0, + &length) == OAKENGINE_OK); + assert(length == 0); + assert(oakengine_track_get_length(seq, OAKENGINE_TRACK_TYPE_VIDEO, 5, + &length) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_track_get_length(NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0, + &length) == OAKENGINE_E_INVALID); + + // Range free: [0, 10) and [20, 30) are free, [15, 25) intersects the + // clip, a zero-length probe at 15 also intersects. + assert(oakengine_track_is_range_free(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0, 10) == 1); + assert(oakengine_track_is_range_free(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 20, 30) == 1); + assert(oakengine_track_is_range_free(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 15, 25) == 0); + assert(oakengine_track_is_range_free(seq, OAKENGINE_TRACK_TYPE_AUDIO, 0, + 15, 25) == 1); + assert(oakengine_track_is_range_free(seq, OAKENGINE_TRACK_TYPE_VIDEO, 5, + 0, 10) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_track_is_range_free(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 10, 5) == OAKENGINE_E_INVALID); + assert(oakengine_track_is_range_free(NULL, OAKENGINE_TRACK_TYPE_VIDEO, 0, + 0, 10) == OAKENGINE_E_INVALID); + + // Height constants are positive (minimum 1.5, interval 0.5 in the + // engine; only positivity is contract-level). + assert(oakengine_track_height_interval() > 0.0); + assert(oakengine_track_height_minimum() > 0.0); + + oakengine_project_free(project); +} + +// ---- Marker handle family (B4c) ---------------------------------------------- + +static void test_marker_handle_family(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "MarkerHandles"); + assert(seq != NULL); + + OakEngineMarkerList *list = + oakengine_viewer_get_marker_list((OakEngineNode *)seq); + assert(list != NULL); + assert(oakengine_viewer_get_marker_list(NULL) == NULL); + assert(oakengine_marker_list_count(list) == 0); + assert(oakengine_marker_list_count(NULL) == 0); + + // Add two markers (rational seconds) through the list family. + assert(oakengine_marker_list_add(list, 4, 1, 6, 1, "Out", 2) == + OAKENGINE_OK); + assert(oakengine_marker_list_add(list, 1, 1, 2, 1, "In", 0) == + OAKENGINE_OK); + assert(oakengine_marker_list_count(list) == 2); + + // Sorted by time: index 0 is the 1s marker. + OakEngineMarker *m0 = oakengine_marker_list_at(list, 0); + OakEngineMarker *m1 = oakengine_marker_list_at(list, 1); + assert(m0 != NULL && m1 != NULL && m0 != m1); + assert(oakengine_marker_list_at(list, 2) == NULL); + assert(oakengine_marker_list_at(list, -1) == NULL); + + int64_t in_num = 0, in_den = 0, out_num = 0, out_den = 0; + assert(oakengine_marker_get_time(m0, &in_num, &in_den, &out_num, + &out_den) == OAKENGINE_OK); + assert(in_num == 1 && in_den == 1 && out_num == 2 && out_den == 1); + char name[64]; + assert(oakengine_marker_get_name(m0, name, sizeof(name)) == 2); + assert(strcmp(name, "In") == 0); + assert(oakengine_marker_get_color(m0) == 0); + assert(oakengine_marker_get_color(m1) == 2); + + // Lookup by exact in-point. + assert(oakengine_marker_list_marker_at_time(list, 4, 1) == m1); + assert(oakengine_marker_list_marker_at_time(list, 5, 1) == NULL); + + // Sibling check: m0 has a sibling at 4s (m1), none at 3s. + assert(oakengine_marker_has_sibling_at_time(m0, 4, 1) == 1); + assert(oakengine_marker_has_sibling_at_time(m0, 3, 1) == 0); + + // Live (non-undo) resize, then the undoable commit with the old range. + assert(oakengine_marker_set_time_live(m0, 1, 1, 3, 1) == OAKENGINE_OK); + assert(oakengine_marker_get_time(m0, NULL, NULL, &out_num, &out_den) == + OAKENGINE_OK); + assert(out_num == 3 && out_den == 1); + assert(oakengine_marker_commit_time(m0, 1, 1, 3, 1, 1, 1, 2, 1, + NULL) == OAKENGINE_OK); + // Undo restores the pre-commit (live) state is NOT reverted (the live + // edit was already applied; undo goes back to the old range). + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_marker_get_time(m0, NULL, NULL, &out_num, &out_den) == + OAKENGINE_OK); + assert(out_num == 2 && out_den == 1); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_marker_get_time(m0, NULL, NULL, &out_num, &out_den) == + OAKENGINE_OK); + assert(out_num == 3 && out_den == 1); + + // Detached marker creation (used by the UI before adding to a list). + OakEngineMarker *detached = oakengine_marker_create(3, 5, 1, 7, 1, + "Detached"); + assert(detached != NULL); + assert(oakengine_marker_get_color(detached) == 3); + assert(oakengine_marker_get_name(detached, name, sizeof(name)) == 8); + assert(strcmp(name, "Detached") == 0); + assert(oakengine_marker_list_count(list) == 2); + oakengine_marker_free(detached); + + // Batch properties: recolor + rename both markers as ONE undo entry. + OakEngineMarker *both[2] = { m0, m1 }; + assert(oakengine_marker_set_properties(both, 2, 7, "Same", 0, 0, 0, 0, + 0, NULL) == OAKENGINE_OK); + assert(oakengine_marker_get_color(m0) == 7); + assert(oakengine_marker_get_color(m1) == 7); + assert(oakengine_marker_get_name(m0, name, sizeof(name)) >= 0); + assert(strcmp(name, "Same") == 0); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_marker_get_color(m0) == 0); + assert(oakengine_marker_get_color(m1) == 2); + assert(oakengine_marker_get_name(m0, name, sizeof(name)) >= 0); + assert(strcmp(name, "In") == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + + // Single-marker time move through the same batch call. + assert(oakengine_marker_set_properties(both, 1, -1, NULL, 1, 10, 1, 12, + 1, NULL) == OAKENGINE_OK); + assert(oakengine_marker_get_time(m0, &in_num, NULL, &out_num, NULL) == + OAKENGINE_OK); + assert(in_num == 10 && out_num == 12); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + + // Remove with undo. + assert(oakengine_marker_remove(m1) == OAKENGINE_OK); + assert(oakengine_marker_list_count(list) == 1); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_marker_list_count(list) == 2); + + // NULL safety. + assert(oakengine_marker_get_time(NULL, &in_num, NULL, NULL, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_marker_get_name(NULL, name, sizeof(name)) == + OAKENGINE_E_INVALID); + assert(oakengine_marker_get_color(NULL) == -1); + assert(oakengine_marker_has_sibling_at_time(NULL, 1, 1) == 0); + assert(oakengine_marker_set_time_live(NULL, 0, 1, 1, 1) == + OAKENGINE_E_INVALID); + assert(oakengine_marker_list_add(NULL, 0, 1, 1, 1, "x", 0) == + OAKENGINE_E_INVALID); + assert(oakengine_marker_remove(NULL) == OAKENGINE_E_INVALID); + assert(oakengine_marker_set_properties(NULL, 1, 0, NULL, 0, 0, 0, 0, 0, + NULL) == OAKENGINE_E_INVALID); + + oakengine_project_free(project); +} + +// ---- Workarea handle family (B4c) ---------------------------------------------- + +static void test_workarea_handle_family(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "Workarea"); + assert(seq != NULL); + + // Reset sentinels: k_reset_in is 0, k_reset_out is RATIONAL_MAX. + int64_t ri_num = 0, ri_den = 0, ro_num = 0, ro_den = 0; + oakengine_workarea_reset_in_out(&ri_num, &ri_den, &ro_num, &ro_den); + assert(ri_num == 0 && ri_den > 0); + assert(ro_num > 0 && ro_den > 0); + + OakEngineWorkarea *wa = + oakengine_viewer_get_workarea_handle((OakEngineNode *)seq); + assert(wa != NULL); + assert(oakengine_viewer_get_workarea_handle(NULL) == NULL); + + // A fresh workarea is disabled. + int enabled = -1; + assert(oakengine_workarea_get(wa, NULL, NULL, NULL, NULL, &enabled) == + OAKENGINE_OK); + assert(enabled == 0); + + // Undoable enable + range change. + assert(oakengine_workarea_set_enabled_undoable(wa, 1, NULL) == + OAKENGINE_OK); + assert(oakengine_workarea_get(wa, NULL, NULL, NULL, NULL, &enabled) == + OAKENGINE_OK); + assert(enabled == 1); + assert(oakengine_workarea_set_range_undoable(wa, 1, 2, 3, 2, ri_num, + ri_den, ro_num, ro_den, + NULL) == OAKENGINE_OK); + int64_t in_num = 0, in_den = 0, out_num = 0, out_den = 0; + assert(oakengine_workarea_get(wa, &in_num, &in_den, &out_num, &out_den, + NULL) == OAKENGINE_OK); + assert(in_num == 1 && in_den == 2 && out_num == 3 && out_den == 2); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_workarea_get(wa, &in_num, NULL, &out_num, NULL, + NULL) == OAKENGINE_OK); + assert(in_num == ri_num && out_num == ro_num); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_workarea_get(wa, NULL, NULL, NULL, NULL, &enabled) == + OAKENGINE_OK); + assert(enabled == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + + // Standalone workarea: enabled-undoable degrades to a direct apply + // (no project owns it). + OakEngineWorkarea *over = oakengine_workarea_create(); + assert(over != NULL); + assert(oakengine_workarea_set_enabled_undoable(over, 1, NULL) == + OAKENGINE_OK); + assert(oakengine_workarea_get(over, NULL, NULL, NULL, NULL, &enabled) == + OAKENGINE_OK); + assert(enabled == 1); + assert(oakengine_workarea_set_range(over, 0, 1, 5, 1) == OAKENGINE_OK); + assert(oakengine_workarea_get(over, NULL, NULL, &out_num, &out_den, + NULL) == OAKENGINE_OK); + assert(out_num == 5 && out_den == 1); + oakengine_workarea_free(over); + + // NULL safety. + assert(oakengine_workarea_get(NULL, &in_num, NULL, NULL, NULL, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_workarea_set_range(NULL, 0, 1, 1, 1) == + OAKENGINE_E_INVALID); + assert(oakengine_workarea_set_enabled(NULL, 1) == OAKENGINE_E_INVALID); + assert(oakengine_workarea_set_range_undoable(NULL, 0, 1, 1, 1, 0, 1, 1, + 1, NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_workarea_set_enabled_undoable(NULL, 1, NULL) == + OAKENGINE_E_INVALID); + + oakengine_project_free(project); +} + +// ---- Clip input ids / media in / cache (B4c) ----------------------------------- + +static void test_clip_input_ids_and_media(void) +{ + // Input id statics: non-null, distinct, and stable across calls. + const char *ids[] = { oakengine_clip_buffer_input_id(), + oakengine_clip_speed_input_id(), + oakengine_clip_reverse_input_id(), + oakengine_clip_maintain_audio_pitch_input_id(), + oakengine_clip_loop_mode_input_id(), + oakengine_clip_auto_cache_input_id() }; + for (size_t i = 0; i < sizeof(ids) / sizeof(ids[0]); i++) { + assert(ids[i] != NULL && ids[i][0] != '\0'); + for (size_t j = i + 1; j < sizeof(ids) / sizeof(ids[0]); j++) { + assert(strcmp(ids[i], ids[j]) != 0); + } + } + assert(strcmp(oakengine_clip_speed_input_id(), ids[1]) == 0); + + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "ClipMedia"); + assert(seq != NULL); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == + 0); + + char path[4096]; + demo_path(path, sizeof(path)); + OakEngineFootage *footage = + oakengine_project_import_footage(project, path); + assert(footage != NULL); + OakEngineClip *clip = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0); + assert(clip != NULL); + + // Media in-point: read via the range getter, write undoably. + int64_t media_in = -1; + assert(oakengine_clip_get_range(clip, NULL, NULL, &media_in) == + OAKENGINE_OK); + assert(media_in == 0); + assert(oakengine_clip_set_media_in(clip, 5, 1) == OAKENGINE_OK); + assert(oakengine_clip_get_range(clip, NULL, NULL, &media_in) == + OAKENGINE_OK); + assert(media_in == 5); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_clip_get_range(clip, NULL, NULL, &media_in) == + OAKENGINE_OK); + assert(media_in == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + + // Non-undoable mode applies directly. + assert(oakengine_clip_set_media_in(clip, 2, 0) == OAKENGINE_OK); + assert(oakengine_clip_get_range(clip, NULL, NULL, &media_in) == + OAKENGINE_OK); + assert(media_in == 2); + + // Cache entry points: smoke calls (headless, no caches to speak of). + oakengine_clip_request_invalidate(clip, 0, 0, 0); + oakengine_clip_request_invalidate(clip, 1, 0, 30); + oakengine_clip_add_cache_passthrough(clip, clip); + oakengine_clip_discard_cache(clip); + + // NULL safety. + assert(oakengine_clip_set_media_in(NULL, 0, 1) == OAKENGINE_E_INVALID); + oakengine_clip_request_invalidate(NULL, 0, 0, 0); + oakengine_clip_add_cache_passthrough(NULL, clip); + oakengine_clip_add_cache_passthrough(clip, NULL); + oakengine_clip_discard_cache(NULL); + assert(oakengine_block_is_enabled(NULL) == 0); + assert(oakengine_block_is_enabled((OakEngineBlock *)clip) == 1); + + oakengine_footage_free(footage); + oakengine_project_free(project); +} + +// ---- Track height helpers / default nodes (B4c) -------------------------------- + +static void test_track_height_helpers(void) +{ + assert(oakengine_track_height_default() > 0.0); + // Round-trip through the pixel conversion. + const int px = oakengine_track_default_height_in_pixels(); + assert(px > 0); + assert(oakengine_track_height_internal_to_pixels( + oakengine_track_height_pixels_to_internal(px)) == px); + assert(oakengine_track_height_internal_to_pixels( + oakengine_track_height_default()) == px); +} + +static void test_add_default_nodes(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "Defaults"); + assert(seq != NULL); + + int video = -1, audio = -1; + assert(oakengine_sequence_track_count(seq, &video, &audio, NULL) == + OAKENGINE_OK); + assert(video == 0 && audio == 0); + + // Adds one video + one audio track as ONE undo entry. + assert(oakengine_sequence_add_default_nodes(seq) == OAKENGINE_OK); + assert(oakengine_sequence_track_count(seq, &video, &audio, NULL) == + OAKENGINE_OK); + assert(video == 1 && audio == 1); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_sequence_track_count(seq, &video, &audio, NULL) == + OAKENGINE_OK); + assert(video == 0 && audio == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_sequence_track_count(seq, &video, &audio, NULL) == + OAKENGINE_OK); + assert(video == 1 && audio == 1); + + assert(oakengine_sequence_add_default_nodes(NULL) == + OAKENGINE_E_INVALID); + + oakengine_project_free(project); +} + +// ---- clip_get_media_range_rational ------------------------------------------ + +static void test_clip_get_media_range_rational(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "MediaRange"); + assert(seq != NULL); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == 0); + + char path[4096]; + demo_path(path, sizeof(path)); + OakEngineFootage *footage = + oakengine_project_import_footage(project, path); + assert(footage != NULL); + + OakEngineClip *clip = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0); + assert(clip != NULL); + + // NULL handle. + assert(oakengine_clip_get_media_range_rational(NULL, NULL, NULL, NULL, + NULL) == + OAKENGINE_E_INVALID); + + // Valid clip: media range should have a non-zero duration. + int64_t in_num = -1, in_den = -1, out_num = -1, out_den = -1; + assert(oakengine_clip_get_media_range_rational(clip, &in_num, &in_den, + &out_num, &out_den) == + OAKENGINE_OK); + assert(in_num >= 0 && in_den > 0 && out_num > in_num); + + // Partial output pointers (any may be NULL). + assert(oakengine_clip_get_media_range_rational(clip, NULL, &in_den, + &out_num, NULL) == + OAKENGINE_OK); + assert(oakengine_clip_get_media_range_rational(clip, NULL, NULL, NULL, + NULL) == OAKENGINE_OK); + + oakengine_project_free(project); +} + +// ---- clip_find_multicam / multicam_switch_source (basic) -------------------- + +static void test_multicam_basic(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + // clip_find_multicam: NULL clip returns NULL. + assert(oakengine_clip_find_multicam(NULL) == NULL); + + // A non-clip node (Solid) returns NULL. + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + assert(oakengine_clip_find_multicam(solid) == NULL); + + // multicam_switch_source: NULL args. + assert(oakengine_multicam_switch_source(NULL, NULL, 0, 0, 0.0, NULL) == + OAKENGINE_E_INVALID); + + assert(oakengine_project_remove_node(project, solid) == OAKENGINE_OK); + oakengine_project_free(project); +} + +// ---- marker_list_add_existing ----------------------------------------------- + +static void test_marker_list_add_existing(void) +{ + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + OakEngineSequence *seq = oakengine_sequence_new(project, "MarkerAdopt"); + assert(seq != NULL); + + OakEngineMarkerList *list = + oakengine_viewer_get_marker_list((OakEngineNode *)seq); + assert(list != NULL); + + // Add a marker to the list, get its handle. + assert(oakengine_marker_list_add(list, 0, 1, 2, 1, "Test", 0) == + OAKENGINE_OK); + OakEngineMarker *marker = oakengine_marker_list_at(list, 0); + assert(marker != NULL); + + // Remove it from the list. + assert(oakengine_marker_remove(marker) == OAKENGINE_OK); + assert(oakengine_marker_list_count(list) == 0); + + // add_existing to re-add it. + assert(oakengine_marker_list_add_existing(list, marker) == + OAKENGINE_OK); + assert(oakengine_marker_list_count(list) == 1); + + // NULL list or marker. + assert(oakengine_marker_list_add_existing(NULL, marker) == + OAKENGINE_E_INVALID); + assert(oakengine_marker_list_add_existing(list, NULL) == + OAKENGINE_E_INVALID); + + oakengine_project_free(project); +} + int main(void) { make_tmpdir(); @@ -1237,6 +1828,16 @@ int main(void) test_batch_editing(path); test_batch_editing_round2(path); test_batch_editing_round3(path); + test_sequence_clip(); + test_track_queries(path); + test_marker_handle_family(); + test_workarea_handle_family(); + test_clip_input_ids_and_media(); + test_track_height_helpers(); + test_add_default_nodes(); + test_clip_get_media_range_rational(); + test_multicam_basic(); + test_marker_list_add_existing(); oakengine_project_free(project); assert(oakengine_shutdown() == OAKENGINE_OK); diff --git a/engine/tests/oakengine_traverse_test.cpp b/engine/tests/oakengine_traverse_test.cpp new file mode 100644 index 000000000..ae2d9d6dc --- /dev/null +++ b/engine/tests/oakengine_traverse_test.cpp @@ -0,0 +1,277 @@ +/*** + + 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 traverse facade +// (oakengine/traverse.h) plus the node value-hint write path +// (oakengine_node_set_value_hint()). Builds a small node graph with the +// facade node family and exercises generate_database/generate_table, the db +// accessors, element_index_for_hint, generate_row's C-side error paths and +// transform. No GL required: evaluation is synchronous and CPU-only +// (textures resolve as engine-side dummy textures), so no GL gating is +// needed. + +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#endif + +#include "oakengine/init.h" +#include "oakengine/node.h" +#include "oakengine/project.h" +#include "oakengine/traverse.h" + +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_traverse_test_%lu", base, + (unsigned long)GetCurrentProcessId()); + assert(_mkdir(g_tmpdir) == 0); +#else + strcpy(g_tmpdir, "/tmp/oakengine_traverse_test_XXXXXX"); + assert(mkdtemp(g_tmpdir) != NULL); +#endif +} + +// ---- Robustness: NULL/invalid arguments ------------------------------------ + +static void test_null_robustness(OakEngineNode *solid) +{ + double m[6]; + + assert(oakengine_traverse_generate_database(NULL, 0, 1, 1, 1) == NULL); + assert(oakengine_traverse_generate_table(NULL, 0, 1, 1, 1) == NULL); + // Zero denominators are invalid rationals. + assert(oakengine_traverse_generate_database(solid, 0, 0, 1, 1) == NULL); + assert(oakengine_traverse_generate_table(solid, 0, 1, 1, 0) == NULL); + + oakengine_traverse_db_free(NULL); // no-op + + assert(oakengine_traverse_db_input_count(NULL) == 0); + assert(oakengine_traverse_db_input_id(NULL, 0) == NULL); + assert(oakengine_traverse_db_row_count(NULL, 0) == 0); + assert(oakengine_traverse_row_type(NULL, 0, 0) == OAK_NODE_VALUE_NONE); + assert(oakengine_traverse_row_source(NULL, 0, 0) == NULL); + assert(oakengine_traverse_row_tag(NULL, 0, 0) != NULL); // never NULL + assert(oakengine_traverse_row_value_string(NULL, 0, 0) == NULL); + assert(oakengine_traverse_row_split_count(NULL, 0, 0) == 0); + assert(oakengine_traverse_row_split_string(NULL, 0, 0, 0) == NULL); + + assert(oakengine_traverse_table_element_index_for_hint(NULL, "x", -1, + NULL) == -1); + + // generate_row: the C side can only exercise the error paths -- the + // real output is an olive::NodeValueRow (a C++ QHash typedef), which a + // pure C test cannot allocate. The filled-row path is covered by the + // application (the viewer display gizmo drag-start path). + assert(oakengine_traverse_generate_row(NULL, 0, 1, 1, 1, NULL, 0, 0, + (void *)1) == OAKENGINE_E_INVALID); + assert(oakengine_traverse_generate_row(solid, 0, 1, 1, 1, NULL, 0, 0, + NULL) == OAKENGINE_E_INVALID); + + assert(oakengine_traverse_transform(NULL, solid, 0, 1, 1, 1, NULL, m) == + OAKENGINE_E_INVALID); + assert(oakengine_traverse_transform(solid, NULL, 0, 1, 1, 1, NULL, m) == + OAKENGINE_E_INVALID); + assert(oakengine_traverse_transform(solid, solid, 0, 1, 1, 1, NULL, + NULL) == OAKENGINE_E_INVALID); + + assert(oakengine_node_set_value_hint(NULL, "x", -1, OAK_NODE_VALUE_COLOR, + 0, NULL) == OAKENGINE_E_INVALID); + assert(oakengine_node_set_value_hint(solid, NULL, -1, + OAK_NODE_VALUE_COLOR, 0, + NULL) == OAKENGINE_E_INVALID); +} + +// ---- generate_database + accessors ------------------------------------------ + +static void test_database(OakEngineNode *solid) +{ + char buf[256]; + + OakEngineTraverseDb *db = + oakengine_traverse_generate_database(solid, 0, 1, 1, 1); + assert(db != NULL); + + // One entry per node input, in the node's input order (deterministic). + const int node_inputs = oakengine_node_input_count(solid); + assert(node_inputs >= 2); + assert(oakengine_traverse_db_input_count(db) == node_inputs); + for (int i = 0; i < node_inputs; i++) { + assert(oakengine_node_input_id(solid, i, buf, sizeof(buf)) > 0); + const char *id = oakengine_traverse_db_input_id(db, i); + assert(id != NULL); + assert(strcmp(id, buf) == 0); + + // Every input of an unconnected Solid generator produces one row + // (its standard value). + const int rows = oakengine_traverse_db_row_count(db, i); + assert(rows >= 1); + for (int r = 0; r < rows; r++) { + // Type is a valid facade value type for plain inputs + // (enabled_in is BOOL, color_in is COLOR). + const int type = oakengine_traverse_row_type(db, i, r); + assert(type > OAK_NODE_VALUE_NONE); + // Source: the value's originating node, or NULL; the solid's + // standard values are sourced from the node itself. + OakEngineNode *src = oakengine_traverse_row_source(db, i, r); + assert(src == NULL || src == solid); + // Tag may be empty but never NULL. + assert(oakengine_traverse_row_tag(db, i, r) != NULL); + // Value string is non-empty for these value types. + const char *vs = oakengine_traverse_row_value_string(db, i, r); + assert(vs != NULL); + assert(vs[0] != '\0'); + // Split values: at least one track, each with a string. + const int splits = oakengine_traverse_row_split_count(db, i, r); + assert(splits >= 1); + for (int s = 0; s < splits; s++) { + assert(oakengine_traverse_row_split_string(db, i, r, s) != + NULL); + } + assert(oakengine_traverse_row_split_string(db, i, r, splits) == + NULL); + } + } + + // Out-of-range accessors fail cleanly. + assert(oakengine_traverse_db_input_id(db, node_inputs) == NULL); + assert(oakengine_traverse_db_row_count(db, node_inputs) == 0); + assert(oakengine_traverse_row_type(db, node_inputs, 0) == + OAK_NODE_VALUE_NONE); + + // A multi-entry database is not a generate_table result: the hint + // lookup rejects it. + assert(oakengine_traverse_table_element_index_for_hint(solid, "color_in", + -1, db) == -1); + + oakengine_traverse_db_free(db); +} + +// ---- generate_table + element_index_for_hint + set_value_hint ----------------- + +static void test_table_and_hints(OakEngineNode *solid, OakEngineNode *lut) +{ + OakEngineTraverseDb *db = + oakengine_traverse_generate_table(solid, 0, 1, 1, 1); + assert(db != NULL); + assert(oakengine_traverse_db_input_count(db) == 1); + // The single output table is keyed by an empty input id. + const char *id = oakengine_traverse_db_input_id(db, 0); + assert(id != NULL); + assert(id[0] == '\0'); + assert(oakengine_traverse_db_row_count(db, 0) >= 1); + + // set_value_hint: unknown input ids and bogus types are rejected. + assert(oakengine_node_set_value_hint(solid, "not_an_input", -1, + OAK_NODE_VALUE_COLOR, 0, + NULL) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_node_set_value_hint(solid, "color_in", -1, 999, 0, + NULL) == OAKENGINE_E_INVALID); + + // The solid's output table holds texture rows. A hint preferring COLOR + // values (set on the lut's texture input) matches nothing -> -1. + assert(oakengine_node_set_value_hint(lut, "tex_in", -1, + OAK_NODE_VALUE_COLOR, -1, + NULL) == OAKENGINE_OK); + assert(oakengine_traverse_table_element_index_for_hint(lut, "tex_in", -1, + db) == -1); + + // An untyped hint falls back to the input's declared type (k_texture + // for "tex_in"), which does have a row in the table. + assert(oakengine_node_set_value_hint(lut, "tex_in", -1, + OAK_NODE_VALUE_NONE, -1, + NULL) == OAKENGINE_OK); + assert(oakengine_traverse_table_element_index_for_hint(lut, "tex_in", -1, + db) >= 0); + + oakengine_traverse_db_free(db); +} + +// ---- transform ------------------------------------------------------------------ + +static void test_transform(OakEngineNode *solid, OakEngineNode *lut) +{ + double m[6] = { 0, 0, 0, 0, 0, 0 }; + + // No transform-generating nodes between start and end: identity matrix. + assert(oakengine_traverse_transform(solid, solid, 0, 1, 1, 1, NULL, m) == + OAKENGINE_OK); + assert(m[0] == 1.0 && m[1] == 0.0 && m[2] == 0.0 && m[3] == 1.0); + assert(m[4] == 0.0 && m[5] == 0.0); + + // Same through an edge, with explicit cache params. + oak_video_params vp; + memset(&vp, 0, sizeof(vp)); + assert(oakengine_video_params_make(&vp, 1920, 1080, 1001, 30000, 0, 1, 1, + 0, 0, 1) == OAKENGINE_OK); + memset(m, 0, sizeof(m)); + assert(oakengine_traverse_transform(solid, lut, 0, 1, 1, 1, &vp, m) == + OAKENGINE_OK); + assert(m[0] == 1.0 && m[1] == 0.0 && m[2] == 0.0 && m[3] == 1.0); + assert(m[4] == 0.0 && m[5] == 0.0); +} + +int main(void) +{ + make_tmpdir(); + + // Sandbox the config/cache/data locations. +#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); + + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + OakEngineNode *lut = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.ociolut"); + assert(lut != NULL); + + test_null_robustness(solid); + test_database(solid); + test_table_and_hints(solid, lut); + test_transform(solid, lut); + + oakengine_project_free(project); + assert(oakengine_shutdown() == OAKENGINE_OK); + + printf("oakengine_traverse_test: all assertions passed\n"); + return 0; +} diff --git a/engine/tests/oakengine_viewer_test.cpp b/engine/tests/oakengine_viewer_test.cpp new file mode 100644 index 000000000..92308332d --- /dev/null +++ b/engine/tests/oakengine_viewer_test.cpp @@ -0,0 +1,592 @@ +/*** + + 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 viewer facade (oakengine/viewer.h) +// and the viewer events (oakengine/events.h ids 100-110). Exercises every +// function of the family on a Sequence (a ViewerOutput subclass): handle +// validation, input ids, playhead/length, stream params, enabled streams, +// workarea, parameter setup, waveform and the change notifications. No GL +// required (headless init, CPU only). Uses tests/demo.mp4 to give the +// sequence real content length. + +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#endif + +#include "oakengine/events.h" +#include "oakengine/footage.h" +#include "oakengine/init.h" +#include "oakengine/node.h" +#include "oakengine/project.h" +#include "oakengine/timeline.h" +#include "oakengine/viewer.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_viewer_test_%lu", base, + (unsigned long)GetCurrentProcessId()); + assert(_mkdir(g_tmpdir) == 0); +#else + strcpy(g_tmpdir, "/tmp/oakengine_viewer_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); +} + +// A sequence handle is the same engine object pointer as its node handle +// (all facade handles are reinterpreted engine pointers; see the wrap() +// helpers in src/capi/timeline.cpp). +static OakEngineNode *as_node(OakEngineSequence *seq) +{ + return (OakEngineNode *)seq; +} + +// ---- Handle validation / constants ---------------------------------------- + +static void test_from_node(OakEngineProject *project, OakEngineSequence *seq) +{ + OakEngineNode *seq_node = as_node(seq); + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + + assert(oakengine_viewer_from_node(NULL) == NULL); + assert(oakengine_viewer_from_node(solid) == NULL); + assert(oakengine_viewer_from_node(seq_node) == seq_node); + + assert(oakengine_viewer_from_const_node(NULL) == NULL); + assert(oakengine_viewer_from_const_node((const OakEngineNode *)solid) == + NULL); + assert(oakengine_viewer_from_const_node((const OakEngineNode *)seq_node) == + (const OakEngineNode *)seq_node); + + // The input id constants are static, non-empty strings. + assert(oakengine_viewer_video_params_input_id() != NULL); + assert(oakengine_viewer_video_params_input_id()[0] != '\0'); + assert(oakengine_viewer_audio_params_input_id()[0] != '\0'); + assert(oakengine_viewer_subtitle_params_input_id()[0] != '\0'); + assert(oakengine_viewer_texture_input_id()[0] != '\0'); + assert(oakengine_viewer_samples_input_id()[0] != '\0'); + assert(oakengine_viewer_default_sample_format() >= 0); +} + +// ---- Playhead / length ------------------------------------------------------ + +static void test_playhead(OakEngineSequence *seq) +{ + int64_t num = -1, den = -1; + + assert(oakengine_viewer_get_playhead(NULL, &num, &den) == + OAKENGINE_E_INVALID); + assert(oakengine_viewer_get_playhead(as_node(seq), &num, &den) == + OAKENGINE_OK); + assert(num == 0); + + assert(oakengine_viewer_set_playhead(NULL, 1, 1) == OAKENGINE_E_INVALID); + assert(oakengine_viewer_set_playhead(as_node(seq), 2, 1) == OAKENGINE_OK); + num = den = -1; + assert(oakengine_viewer_get_playhead(as_node(seq), &num, &den) == + OAKENGINE_OK); + assert(num == 2 && den == 1); +} + +static void test_lengths(OakEngineSequence *seq) +{ + int64_t num = -1, den = -1; + + assert(oakengine_viewer_get_length(NULL, &num, &den) == + OAKENGINE_E_INVALID); + assert(oakengine_viewer_get_length(as_node(seq), &num, &den) == + OAKENGINE_OK); + assert(num == 0); + assert(oakengine_viewer_get_video_length(as_node(seq), &num, &den) == + OAKENGINE_OK); + assert(num == 0); + assert(oakengine_viewer_get_audio_length(as_node(seq), &num, &den) == + OAKENGINE_OK); + assert(num == 0); +} + +// ---- Stream parameters -------------------------------------------------------- + +static void test_stream_params(OakEngineSequence *seq) +{ + const OakEngineNode *node = (const OakEngineNode *)as_node(seq); + oak_video_params vp; + int sr = -1, format = -1; + uint64_t layout = 1; + + assert(oakengine_viewer_get_video_params(NULL, 0, &vp) == + OAKENGINE_E_INVALID); + assert(oakengine_viewer_get_video_params(node, 0, NULL) == + OAKENGINE_E_INVALID); + + // A fresh sequence has one video and one audio stream, no subtitles. + assert(oakengine_viewer_get_video_stream_count(NULL) == 0); + assert(oakengine_viewer_get_video_stream_count(node) == 1); + assert(oakengine_viewer_get_audio_stream_count(node) == 1); + assert(oakengine_viewer_get_subtitle_stream_count(node) == 0); + + // In-range video params come from the sequence defaults. + assert(oakengine_viewer_get_video_params(node, 0, &vp) == OAKENGINE_OK); + assert(vp.width > 0 && vp.height > 0); + assert(vp.time_base_num > 0 && vp.time_base_den > 0); + + // Out-of-range yields a zeroed struct (documented in viewer.h). + memset(&vp, 0xFF, sizeof(vp)); + assert(oakengine_viewer_get_video_params(node, 99, &vp) == OAKENGINE_OK); + assert(vp.width == 0 && vp.height == 0); + + // Audio params; out-of-range yields 0/0/0. + assert(oakengine_viewer_get_audio_params(NULL, 0, &sr, &layout, + &format) == OAKENGINE_E_INVALID); + assert(oakengine_viewer_get_audio_params(node, 0, &sr, &layout, + &format) == OAKENGINE_OK); + assert(sr > 0 && layout != 0); + sr = -1; + layout = 1; + format = -1; + assert(oakengine_viewer_get_audio_params(node, 99, &sr, &layout, + &format) == OAKENGINE_OK); + assert(sr == 0 && layout == 0 && format == 0); + + // Per-stream enabled flags: video/audio stream 0 are enabled by + // default; subtitle has no stream 0. + assert(oakengine_viewer_get_stream_enabled(NULL, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == OAKENGINE_E_INVALID); + assert(oakengine_viewer_get_stream_enabled(node, OAKENGINE_TRACK_TYPE_VIDEO, + 0) == 1); + assert(oakengine_viewer_get_stream_enabled(node, OAKENGINE_TRACK_TYPE_AUDIO, + 0) == 1); + assert(oakengine_viewer_get_stream_enabled( + node, OAKENGINE_TRACK_TYPE_SUBTITLE, 0) == 0); + assert(oakengine_viewer_get_stream_enabled(node, 99, 0) == + OAKENGINE_E_INVALID); + + // Subtitle access: no subtitle streams on a fresh sequence. + assert(oakengine_viewer_get_subtitle_count(NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_viewer_get_subtitle_count(node, 0) == OAKENGINE_E_INVALID); + assert(oakengine_viewer_get_subtitle_at(NULL, 0, 0) == NULL); + assert(oakengine_viewer_get_subtitle_at(node, 0, 0) == NULL); +} + +static void test_enabled_streams(OakEngineSequence *seq) +{ + const OakEngineNode *node = (const OakEngineNode *)as_node(seq); + oak_video_params vp; + + assert(oakengine_viewer_has_enabled_streams(NULL, + OAKENGINE_TRACK_TYPE_VIDEO) == + 0); + assert(oakengine_viewer_has_enabled_streams(node, + OAKENGINE_TRACK_TYPE_VIDEO) == + 1); + assert(oakengine_viewer_has_enabled_streams(node, + OAKENGINE_TRACK_TYPE_AUDIO) == + 1); + assert(oakengine_viewer_has_enabled_streams( + node, OAKENGINE_TRACK_TYPE_SUBTITLE) == 0); + assert(oakengine_viewer_has_enabled_streams(node, 99) == 0); + + assert(oakengine_viewer_get_first_enabled_video_stream(NULL, &vp) == + OAKENGINE_E_INVALID); + assert(oakengine_viewer_get_first_enabled_video_stream(node, &vp) == + OAKENGINE_OK); + assert(vp.width > 0 && vp.height > 0); + + // Enabled stream references: video:0 and audio:0. + assert(oakengine_viewer_get_enabled_stream_count(NULL) == 0); + const int count = oakengine_viewer_get_enabled_stream_count(node); + assert(count == 2); + // Query form (max = 0, NULL arrays) returns the total count. + assert(oakengine_viewer_get_enabled_streams(node, NULL, NULL, 0) == count); + + int types[8]; + int indices[8]; + memset(types, -1, sizeof(types)); + memset(indices, -1, sizeof(indices)); + assert(oakengine_viewer_get_enabled_streams(node, types, indices, 8) == + count); + int saw_video = 0, saw_audio = 0; + for (int i = 0; i < count; i++) { + assert(indices[i] == 0); + if (types[i] == OAKENGINE_TRACK_TYPE_VIDEO) { + saw_video = 1; + } else if (types[i] == OAKENGINE_TRACK_TYPE_AUDIO) { + saw_audio = 1; + } else { + assert(0); // unexpected stream type + } + } + assert(saw_video && saw_audio); + + // A smaller max truncates the write but still returns the total. + types[0] = types[1] = -1; + indices[0] = indices[1] = -1; + assert(oakengine_viewer_get_enabled_streams(node, types, indices, 1) == + count); + assert(types[0] != -1 && types[1] == -1); +} + +// ---- Workarea ------------------------------------------------------------------ + +static void test_workarea(OakEngineSequence *seq) +{ + OakEngineNode *node = as_node(seq); + oakengine_viewer_workarea wa; + + assert(oakengine_viewer_get_workarea(NULL, &wa) == OAKENGINE_E_INVALID); + assert(oakengine_viewer_get_workarea(node, NULL) == OAKENGINE_E_INVALID); + memset(&wa, 0xFF, sizeof(wa)); + assert(oakengine_viewer_get_workarea(node, &wa) == OAKENGINE_OK); + assert(wa.enabled == 0); + + assert(oakengine_viewer_set_workarea_range(NULL, 0, 1, 1, 1) == + OAKENGINE_E_INVALID); + assert(oakengine_viewer_set_workarea_range(node, 1, 1, 5, 1) == + OAKENGINE_OK); + assert(oakengine_viewer_set_workarea_enabled(NULL, 1) == + OAKENGINE_E_INVALID); + assert(oakengine_viewer_set_workarea_enabled(node, 1) == OAKENGINE_OK); + + memset(&wa, 0, sizeof(wa)); + assert(oakengine_viewer_get_workarea(node, &wa) == OAKENGINE_OK); + assert(wa.in_num == 1 && wa.in_den == 1); + assert(wa.out_num == 5 && wa.out_den == 1); + assert(wa.enabled == 1); + + assert(oakengine_viewer_set_workarea_enabled(node, 0) == OAKENGINE_OK); + assert(oakengine_viewer_get_workarea(node, &wa) == OAKENGINE_OK); + assert(wa.enabled == 0); +} + +// ---- Parameter setup / waveform ------------------------------------------------- + +static void test_parameter_setup(OakEngineProject *project, + OakEngineSequence *seq) +{ + OakEngineNode *node = as_node(seq); + + assert(oakengine_viewer_set_default_parameters(NULL) == + OAKENGINE_E_INVALID); + assert(oakengine_viewer_set_default_parameters(node) == OAKENGINE_OK); + + // set_parameters_from_footage accepts any viewer handles; a second + // sequence stands in for the footage array here. + OakEngineSequence *other = oakengine_sequence_new(project, "Other"); + assert(other != NULL); + OakEngineNode *other_node = as_node(other); + + assert(oakengine_viewer_set_parameters_from_footage(NULL, &other_node, + 1) == OAKENGINE_E_INVALID); + assert(oakengine_viewer_set_parameters_from_footage(node, NULL, 1) == + OAKENGINE_E_INVALID); + // An empty array is a valid no-op. + assert(oakengine_viewer_set_parameters_from_footage(node, NULL, 0) == + OAKENGINE_OK); + // One invalid element rejects the whole call. + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + OakEngineNode *mixed[2] = { other_node, solid }; + assert(oakengine_viewer_set_parameters_from_footage(node, mixed, 2) == + OAKENGINE_E_INVALID); + // All viewers: OK, and the params are adopted. + OakEngineNode *viewers[1] = { other_node }; + assert(oakengine_viewer_set_parameters_from_footage(node, viewers, 1) == + OAKENGINE_OK); + + // Waveform toggle; nothing is connected to the samples input, so the + // connected waveform is NULL. + assert(oakengine_viewer_set_waveform_enabled(NULL, 1) == + OAKENGINE_E_INVALID); + assert(oakengine_viewer_set_waveform_enabled(node, 1) == OAKENGINE_OK); + assert(oakengine_viewer_set_waveform_enabled(node, 0) == OAKENGINE_OK); + assert(oakengine_viewer_get_connected_waveform(NULL) == NULL); + assert(oakengine_viewer_get_connected_waveform( + (const OakEngineNode *)node) == NULL); +} + +// ---- Events --------------------------------------------------------------------- + +struct EventLog { + int playhead_events; + int64_t playhead_num; + int64_t playhead_den; + int length_events; + int64_t length_num; + int64_t length_den; + int size_events; + int64_t size_w; + int64_t size_h; + int video_params_events; + int audio_params_events; + int sample_rate_events; + int64_t sample_rate; + int texture_events; + int frame_rate_events; + int pixel_aspect_events; + int interlacing_events; + int64_t interlacing_mode; + int waveform_events; +}; + +static void record_event(const oakengine_event *event, void *userdata) +{ + struct EventLog *log = (struct EventLog *)userdata; + assert(event != NULL); + switch (event->id) { + case OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED: + log->playhead_events++; + log->playhead_num = event->a; + log->playhead_den = event->b; + break; + case OAKENGINE_EVENT_VIEWER_LENGTH_CHANGED: + log->length_events++; + log->length_num = event->a; + log->length_den = event->b; + break; + case OAKENGINE_EVENT_VIEWER_SIZE_CHANGED: + log->size_events++; + log->size_w = event->a; + log->size_h = event->b; + break; + case OAKENGINE_EVENT_VIEWER_VIDEO_PARAMS_CHANGED: + log->video_params_events++; + break; + case OAKENGINE_EVENT_VIEWER_AUDIO_PARAMS_CHANGED: + log->audio_params_events++; + break; + case OAKENGINE_EVENT_VIEWER_SAMPLE_RATE_CHANGED: + log->sample_rate_events++; + log->sample_rate = event->a; + break; + case OAKENGINE_EVENT_VIEWER_TEXTURE_INPUT_CHANGED: + log->texture_events++; + break; + case OAKENGINE_EVENT_VIEWER_FRAME_RATE_CHANGED: + log->frame_rate_events++; + break; + case OAKENGINE_EVENT_VIEWER_PIXEL_ASPECT_CHANGED: + log->pixel_aspect_events++; + break; + case OAKENGINE_EVENT_VIEWER_INTERLACING_CHANGED: + log->interlacing_events++; + log->interlacing_mode = event->a; + break; + case OAKENGINE_EVENT_VIEWER_CONNECTED_WAVEFORM_CHANGED: + log->waveform_events++; + break; + default: + assert(0); // unexpected event id on this subscription + } +} + +static int64_t subscribe_checked(OakEngineNode *node, int32_t id, + struct EventLog *log) +{ + const int64_t sub = oakengine_event_subscribe(node, id, record_event, log); + assert(sub > 0); + return sub; +} + +static void test_events(OakEngineProject *project, OakEngineSequence *seq, + const char *media_path) +{ + struct EventLog log; + memset(&log, 0, sizeof(log)); + OakEngineNode *node = as_node(seq); + + // Family mismatch: a viewer event on a non-viewer node must fail. + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + assert(oakengine_event_subscribe(solid, + OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED, + record_event, &log) == 0); + + int64_t subs[16]; + int n = 0; + subs[n++] = subscribe_checked(node, OAKENGINE_EVENT_VIEWER_LENGTH_CHANGED, + &log); + subs[n++] = + subscribe_checked(node, OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED, &log); + subs[n++] = subscribe_checked( + node, OAKENGINE_EVENT_VIEWER_FRAME_RATE_CHANGED, &log); + subs[n++] = + subscribe_checked(node, OAKENGINE_EVENT_VIEWER_SIZE_CHANGED, &log); + subs[n++] = subscribe_checked( + node, OAKENGINE_EVENT_VIEWER_PIXEL_ASPECT_CHANGED, &log); + subs[n++] = subscribe_checked( + node, OAKENGINE_EVENT_VIEWER_INTERLACING_CHANGED, &log); + subs[n++] = subscribe_checked( + node, OAKENGINE_EVENT_VIEWER_VIDEO_PARAMS_CHANGED, &log); + subs[n++] = subscribe_checked( + node, OAKENGINE_EVENT_VIEWER_AUDIO_PARAMS_CHANGED, &log); + subs[n++] = subscribe_checked( + node, OAKENGINE_EVENT_VIEWER_TEXTURE_INPUT_CHANGED, &log); + subs[n++] = subscribe_checked( + node, OAKENGINE_EVENT_VIEWER_SAMPLE_RATE_CHANGED, &log); + subs[n++] = subscribe_checked( + node, OAKENGINE_EVENT_VIEWER_CONNECTED_WAVEFORM_CHANGED, &log); + + // Playhead. + assert(oakengine_viewer_set_playhead(node, 3, 1) == OAKENGINE_OK); + assert(log.playhead_events == 1); + assert(log.playhead_num == 3 && log.playhead_den == 1); + + // Length: placing a real clip makes verify_length() emit + // length_changed with the new content length. + OakEngineFootage *footage = + oakengine_project_import_footage(project, media_path); + assert(footage != NULL); + assert(oakengine_sequence_add_track(seq, OAKENGINE_TRACK_TYPE_VIDEO) == 0); + OakEngineClip *clip = oakengine_sequence_add_footage_clip( + seq, footage, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0, 30, 0); + assert(clip != NULL); + assert(log.length_events >= 1); + assert(log.length_num > 0 && log.length_den > 0); + int64_t len_num = -1, len_den = -1; + assert(oakengine_viewer_get_length(node, &len_num, &len_den) == + OAKENGINE_OK); + assert(len_num == log.length_num && len_den == log.length_den); + + // Video params: changing the size emits size_changed (with the new + // dimensions as a/b) and video_params_changed. + assert(oakengine_sequence_set_video_params(seq, 1280, 720, -1, -1, -1, -1, + -1, -1, 0) == OAKENGINE_OK); + assert(log.size_events == 1); + assert(log.size_w == 1280 && log.size_h == 720); + assert(log.video_params_events == 1); + assert(log.frame_rate_events == 0); + assert(log.pixel_aspect_events == 0); + assert(log.interlacing_events == 0); + + // Pixel aspect and interlacing changes fire their own events. + assert(oakengine_sequence_set_video_params(seq, -1, -1, -1, -1, 4, 3, -1, + -1, 0) == OAKENGINE_OK); + assert(log.pixel_aspect_events == 1); + assert(oakengine_sequence_set_video_params(seq, -1, -1, -1, -1, -1, -1, 1, + -1, 0) == OAKENGINE_OK); + assert(log.interlacing_events == 1); + assert(log.interlacing_mode == 1); + + // Audio params: a new sample rate emits sample_rate_changed (a = rate) + // and audio_params_changed. + assert(oakengine_sequence_set_audio_params(seq, 44100, 0, 0) == + OAKENGINE_OK); + assert(log.sample_rate_events == 1); + assert(log.sample_rate == 44100); + assert(log.audio_params_events == 1); + + // Texture input: the placed clip's track auto-connected the viewer's + // texture input, so disconnect it first, then connect a node and check + // that texture_input_changed fired. + assert(oakengine_node_disconnect(node, + oakengine_viewer_texture_input_id()) == + OAKENGINE_OK); + assert(oakengine_node_connect(solid, node, + oakengine_viewer_texture_input_id()) == + OAKENGINE_OK); + assert(log.texture_events >= 1); + assert(oakengine_node_disconnect(node, + oakengine_viewer_texture_input_id()) == + OAKENGINE_OK); + + // connected_waveform_changed requires a connected sample output with a + // waveform cache; there is no audio-producing node chain in this test, + // so only the subscription itself is exercised above. + + while (n > 0) { + assert(oakengine_event_unsubscribe(subs[--n]) == OAKENGINE_OK); + } +} + +int main(void) +{ + make_tmpdir(); + + // Sandbox the config/cache/data locations. +#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); + + OakEngineProject *project = oakengine_project_create(); + assert(project != NULL); + assert(oakengine_project_new(project) == OAKENGINE_OK); + + OakEngineSequence *seq = oakengine_sequence_new(project, "ViewerSeq"); + assert(seq != NULL); + + test_from_node(project, seq); + test_playhead(seq); + test_lengths(seq); + test_stream_params(seq); + test_enabled_streams(seq); + test_workarea(seq); + test_parameter_setup(project, seq); + + char media[4096]; + demo_path(media, sizeof(media)); + + // test_parameter_setup() called set_default_parameters(), which reads + // the (empty, sandboxed) user config and may leave invalid params + // behind (same hazard oakengine_sequence_new() backfills against). + // Restore known-good params so the clip/timebase paths work. + assert(oakengine_sequence_set_video_params(seq, 1920, 1080, 30000, 1001, + 1, 1, 0, -1, 0) == OAKENGINE_OK); + assert(oakengine_sequence_set_audio_params(seq, 48000, 3, 0) == + OAKENGINE_OK); + + test_events(project, seq, media); + + oakengine_project_free(project); + assert(oakengine_shutdown() == OAKENGINE_OK); + + printf("oakengine_viewer_test: all assertions passed\n"); + return 0; +} diff --git a/engine/tests/oakengine_worker_test.cpp b/engine/tests/oakengine_worker_test.cpp new file mode 100644 index 000000000..7776e1480 --- /dev/null +++ b/engine/tests/oakengine_worker_test.cpp @@ -0,0 +1,237 @@ +/*** + + 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 render-worker facade +// (oakengine/worker.h). Exercises the session state machine without a +// renderer ("none" backend): create/destroy, malformed and unknown control +// messages, handshake validation, message ordering errors and shutdown +// idempotency. No GPU and no QApplication required: every path exercised +// here is a validation/error path that never touches a render backend. + +#include +#include +#include +#include + +#include "oakengine/worker.h" + +// Handle one line into a heap buffer sized via the buf/size query +// convention. Returns the response (empty string when the message has no +// reply); the caller frees it. Asserts the query/fill round-trip agrees. +static char *handle(OakWorkerSession *session, const char *line) +{ + const int needed = + oakengine_worker_session_handle_json(session, line, NULL, 0); + assert(needed >= 0); + char *buf = static_cast(malloc(size_t(needed) + 1)); + const int written = oakengine_worker_session_handle_json( + session, line, buf, needed + 1); + assert(written == needed); + buf[needed] = '\0'; + return buf; +} + +static void assert_is_error_with(const char *response, const char *needle) +{ + if (!strstr(response, "\"type\":\"error\"") || + !strstr(response, needle)) { + fprintf(stderr, + "expected error response containing \"%s\", got: %s\n", + needle, response); + assert(0); + } +} + +static void test_create_destroy(void) +{ + // NULL, "" and "none" all skip renderer creation + const char *backends[] = { NULL, "", "none", "NONE" }; + for (size_t i = 0; i < sizeof(backends) / sizeof(backends[0]); ++i) { + OakWorkerSession *s = oakengine_worker_session_create(backends[i]); + assert(s); + assert(oakengine_worker_session_has_renderer(s) == 0); + assert(oakengine_worker_session_shutdown_requested(s) == 0); + oakengine_worker_session_free(s); + } + // NULL tolerance + oakengine_worker_session_free(NULL); + assert(oakengine_worker_session_has_renderer(NULL) == 0); + assert(oakengine_worker_session_shutdown_requested(NULL) == 0); + assert(oakengine_worker_session_handle_json(NULL, "{}", NULL, 0) == -1); +} + +static void test_startup_handshake(void) +{ + OakWorkerSession *s = oakengine_worker_session_create("none"); + assert(s); + + // buf/size query convention + const int needed = + oakengine_worker_session_startup_handshake(s, NULL, 0); + assert(needed > 0); + char *buf = static_cast(malloc(size_t(needed) + 1)); + assert(oakengine_worker_session_startup_handshake(s, buf, needed + 1) == + needed); + buf[needed] = '\0'; + assert(strstr(buf, "\"type\":\"handshake\"")); + assert(strstr(buf, "\"protocol_version\":1")); + // No renderer -> no GL version announced + assert(!strstr(buf, "gl_major")); + free(buf); + + assert(oakengine_worker_session_startup_handshake(NULL, NULL, 0) == -1); + oakengine_worker_session_free(s); +} + +static void test_initialize_runtime(void) +{ + // NULL tolerance + assert(oakengine_worker_session_initialize_runtime(NULL) == 0); + + // Runtime init (EngineCore, factories, managers) must succeed without a + // renderer; the session stays usable for control messages afterwards. + OakWorkerSession *s = oakengine_worker_session_create("none"); + assert(s); + assert(oakengine_worker_session_initialize_runtime(s) == 1); + char *r = handle(s, "{\"type\":\"teleport\"}"); + assert_is_error_with(r, "unknown message type"); + free(r); + oakengine_worker_session_free(s); +} + +static void test_malformed_json(void) +{ + OakWorkerSession *s = oakengine_worker_session_create("none"); + char *r = handle(s, "{not json at all"); + assert_is_error_with(r, "malformed control message"); + free(r); + r = handle(s, "[1,2,3]"); + assert_is_error_with(r, "malformed control message"); + free(r); + oakengine_worker_session_free(s); +} + +static void test_unknown_type(void) +{ + OakWorkerSession *s = oakengine_worker_session_create("none"); + char *r = handle(s, "{\"type\":\"teleport\"}"); + assert_is_error_with(r, "unknown message type: teleport"); + free(r); + oakengine_worker_session_free(s); +} + +static void test_handshake_validation(void) +{ + OakWorkerSession *s = oakengine_worker_session_create("none"); + + // Protocol version mismatch is rejected before any shm access + char *r = handle(s, + "{\"type\":\"handshake\",\"protocol_version\":999," + "\"shm_key\":\"x\",\"output_slots\":1," + "\"slot_data_bytes\":16}"); + assert_is_error_with(r, "unsupported protocol version 999"); + free(r); + + // Matching version but no shared-memory geometry + r = handle(s, "{\"type\":\"handshake\",\"protocol_version\":1}"); + assert_is_error_with(r, "missing output shared-memory geometry"); + free(r); + + oakengine_worker_session_free(s); +} + +static void test_render_frame_before_load_graph(void) +{ + OakWorkerSession *s = oakengine_worker_session_create("none"); + char *r = handle(s, + "{\"type\":\"render_frame\",\"ticket\":7," + "\"node_uuid\":\"abc\",\"time_num\":0,\"time_den\":1}"); + assert_is_error_with(r, "render_frame received before load_graph"); + // The error carries the ticket id so the caller can correlate + assert(strstr(r, "\"ticket\":7")); + free(r); + oakengine_worker_session_free(s); +} + +static void test_load_graph_missing_file(void) +{ + OakWorkerSession *s = oakengine_worker_session_create("none"); + char *r = handle(s, + "{\"type\":\"load_graph\"," + "\"path\":\"/nonexistent/definitely/missing.ove\"}"); + assert_is_error_with(r, "graph file does not exist"); + free(r); + // A failed load must not arm the session: render_frame still complains + // about the missing graph, not about the shm handshake order + r = handle(s, + "{\"type\":\"render_frame\",\"ticket\":1," + "\"node_uuid\":\"abc\",\"time_num\":0,\"time_den\":1}"); + assert_is_error_with(r, "render_frame received before load_graph"); + free(r); + oakengine_worker_session_free(s); +} + +static void test_shutdown_idempotent(void) +{ + OakWorkerSession *s = oakengine_worker_session_create("none"); + + // Shutdown produces no response and latches the flag + char *r = handle(s, "{\"type\":\"shutdown\"}"); + assert(r[0] == '\0'); + free(r); + assert(oakengine_worker_session_shutdown_requested(s) == 1); + + // Repeating it is a harmless no-op + r = handle(s, "{\"type\":\"shutdown\"}"); + assert(r[0] == '\0'); + free(r); + assert(oakengine_worker_session_shutdown_requested(s) == 1); + + // The session still answers other messages afterwards + r = handle(s, "{\"type\":\"teleport\"}"); + assert_is_error_with(r, "unknown message type"); + free(r); + + oakengine_worker_session_free(s); +} + +static void test_cancel_is_silent(void) +{ + OakWorkerSession *s = oakengine_worker_session_create("none"); + char *r = handle(s, "{\"type\":\"cancel\",\"ticket\":3}"); + assert(r[0] == '\0'); + free(r); + oakengine_worker_session_free(s); +} + +int main(void) +{ + test_create_destroy(); + test_startup_handshake(); + test_initialize_runtime(); + test_malformed_json(); + test_unknown_type(); + test_handshake_validation(); + test_render_frame_before_load_graph(); + test_load_graph_missing_file(); + test_shutdown_idempotent(); + test_cancel_is_silent(); + return 0; +}