From e82011b0ee172bb2d2ce4590964fb9a4d7ef925c Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Mon, 20 Jul 2026 08:16:43 +0800 Subject: [PATCH] engine: add the node graph family to the C ABI facade - project node enumeration, type/name/label metadata, input introspection (id, mapped value type, connection state) - typed parameter read/write for the eight common NodeValue kinds (int/float/bool/rational/color/vec2-4/combo/string), undoable via NodeParamSetSplitStandardValueCommand - the split-track value path, chosen after proving the standard-value command stores whole variants into a single component track - graph operations (add/remove/connect/disconnect) reusing the engine's undo commands, all covered by undo/redo assertions --- engine/CMakeLists.txt | 5 + engine/include/oakengine/node.h | 258 ++++++++++++ engine/src/capi/CMakeLists.txt | 2 + engine/src/capi/node.cpp | 562 +++++++++++++++++++++++++++ engine/tests/oakengine_node_test.cpp | 374 ++++++++++++++++++ 5 files changed, 1201 insertions(+) create mode 100644 engine/include/oakengine/node.h create mode 100644 engine/src/capi/node.cpp create mode 100644 engine/tests/oakengine_node_test.cpp diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 17ec9dcde..7c9225141 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -301,4 +301,9 @@ if (BUILD_TESTS) if (TARGET olive-render-worker) add_dependencies(oakengine_export_test olive-render-worker) endif () + + make_oakengine_test(oakengine_node_test) + target_compile_definitions(oakengine_node_test PRIVATE + OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + ) endif () diff --git a/engine/include/oakengine/node.h b/engine/include/oakengine/node.h new file mode 100644 index 000000000..381f7919e --- /dev/null +++ b/engine/include/oakengine/node.h @@ -0,0 +1,258 @@ +/*** + + 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_NODE_H +#define OAKENGINE_NODE_H + +#include + +#include "export.h" +#include "init.h" +#include "project.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @file node.h + * @brief C ABI for the node graph: enumeration, metadata, input + * introspection, parameter access and edge editing + * + * An OakEngineNode wraps the engine's olive::Node (engine/node/node.h). + * Handles are borrowed views of nodes owned by their project (QObject + * parent chain); they become invalid when the project is freed or the node + * is removed (e.g. by undoing oakengine_project_add_node()). + * + * Parameter values cross the boundary as the small POD oak_node_value; + * which of its fields are meaningful depends on the oak_node_value_type + * (see the enum). String-typed inputs (NodeValue::k_file) do not fit the + * POD and use the dedicated oakengine_node_get/set_input_string() pair + * (buf/size convention). + * + * Every mutating call is undoable through the global undo stack, like the + * timeline editing primitives (direct non-undoable application when the + * engine is not initialized). Errors follow the family model: negative + * OAKENGINE_E_* codes, NULL handles as no-ops, and a thread-local + * human-readable reason via oakengine_node_last_error(). + */ + +/** + * @brief Value type of an oak_node_value / a node input. + * + * Mirrors the data-carrying subset of olive::NodeValue::Type + * (engine/node/value.h): combo maps to k_combo, STRING maps to k_file + * (string-typed inputs handled by the dedicated string functions). Input + * types without a POD representation (texture, samples, params, bezier, + * binary, ...) report as OAK_NODE_VALUE_NONE. + */ +typedef enum oak_node_value_type { + OAK_NODE_VALUE_NONE = 0, + OAK_NODE_VALUE_INT, /**< num (olive k_int) */ + OAK_NODE_VALUE_FLOAT, /**< f[0] (olive k_float) */ + OAK_NODE_VALUE_BOOL, /**< num 0/1 (olive k_boolean) */ + OAK_NODE_VALUE_RATIONAL, /**< num/den (olive k_rational) */ + OAK_NODE_VALUE_COLOR, /**< f[0..3] = r,g,b,a (olive k_color) */ + OAK_NODE_VALUE_VEC2, /**< f[0..1] (olive k_vec2) */ + 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_type; + +/** + * @brief POD parameter value. Only the fields documented for the value's + * `type` are meaningful. + */ +typedef struct oak_node_value { + int type; /**< oak_node_value_type. */ + int64_t num; /**< INT/COMBO value, BOOL 0/1, RATIONAL numerator. */ + int64_t den; /**< RATIONAL denominator. */ + double f[4]; /**< FLOAT f[0]; VEC2/3/4 f[0..n-1]; COLOR r,g,b,a. */ +} oak_node_value; + +/** + * @brief Opaque node handle (borrowed from the owning project). + */ +typedef struct OakEngineNode OakEngineNode; + +/** + * @brief Human-readable reason for the last failed node call on this + * thread (buf/size convention). + */ +OAKENGINE_API int oakengine_node_last_error(char *buf, int buf_size); + +/* ---- Enumeration --------------------------------------------------------- */ + +/** + * @brief Number of nodes in the project's graph (Project::nodes()). + */ +OAKENGINE_API int oakengine_project_node_count(const OakEngineProject *self); + +/** + * @brief Borrowed handle of the node at `index`, or NULL when out of range. + */ +OAKENGINE_API OakEngineNode * +oakengine_project_node_at(const OakEngineProject *self, int index); + +/* ---- Metadata -------------------------------------------------------------- */ + +/** + * @brief The node's type id (Node::id(), e.g. + * "org.olivevideoeditor.Olive.solidgenerator"). buf/size convention. + */ +OAKENGINE_API int oakengine_node_get_type_id(const OakEngineNode *self, + char *buf, int buf_size); + +/** + * @brief The node's display name (Node::name(), translated). + * buf/size convention. + */ +OAKENGINE_API int oakengine_node_get_name(const OakEngineNode *self, + char *buf, int buf_size); + +/** + * @brief The node's user label (Node::get_label()). buf/size convention. + */ +OAKENGINE_API int oakengine_node_get_label(const OakEngineNode *self, + char *buf, int buf_size); + +/** + * @brief Set the node's user label (undoable, olive::NodeRenameCommand). + */ +OAKENGINE_API int oakengine_node_set_label(OakEngineNode *self, + const char *label); + +/* ---- Input introspection ---------------------------------------------------- */ + +/** + * @brief Number of declared inputs (Node::inputs(); array elements are not + * counted separately). + */ +OAKENGINE_API int oakengine_node_input_count(const OakEngineNode *self); + +/** + * @brief The input id at `index` (Node::inputs()). buf/size convention; + * returns OAKENGINE_E_NOT_FOUND for an out-of-range index. + */ +OAKENGINE_API int oakengine_node_input_id(const OakEngineNode *self, + int index, char *buf, int buf_size); + +/** + * @brief The input's value type as oak_node_value_type. + * + * Unknown ids and inputs whose NodeValue::Type has no POD representation + * (texture, samples, params, ...) report OAK_NODE_VALUE_NONE; k_file + * reports OAK_NODE_VALUE_STRING. + */ +OAKENGINE_API int oakengine_node_input_get_type(const OakEngineNode *self, + const char *input_id); + +/** + * @brief 1 if the input currently has a connected edge + * (Node::is_input_connected()). + */ +OAKENGINE_API int oakengine_node_input_is_connected( + const OakEngineNode *self, const char *input_id); + +/* ---- Parameter access -------------------------------------------------------- */ + +/** + * @brief Read an input's standard value (Node::get_standard_value()) + * mapped into `out`. + * + * String (k_file) inputs fail with OAKENGINE_E_INVALID -- use + * oakengine_node_get_input_string(). Types without a POD representation + * fail with OAKENGINE_E_NOT_FOUND; a missing input id fails with + * OAKENGINE_E_NOT_FOUND as well. + */ +OAKENGINE_API int oakengine_node_get_input(const OakEngineNode *self, + const char *input_id, + oak_node_value *out); + +/** + * @brief Write an input's standard value (undoable, + * olive::NodeParamSetStandardValueCommand). + * + * `v->type` must equal the input's declared type (STRING is rejected -- + * use oakengine_node_set_input_string()); a type mismatch or an unknown + * input id returns OAKENGINE_E_INVALID / OAKENGINE_E_NOT_FOUND. + */ +OAKENGINE_API int oakengine_node_set_input(OakEngineNode *self, + const char *input_id, + const oak_node_value *v); + +/** + * @brief Read a string-typed (k_file) input's value (buf/size convention). + */ +OAKENGINE_API int oakengine_node_get_input_string(const OakEngineNode *self, + const char *input_id, + char *buf, int buf_size); + +/** + * @brief Write a string-typed (k_file) input's value (undoable). + */ +OAKENGINE_API int oakengine_node_set_input_string(OakEngineNode *self, + const char *input_id, + const char *s); + +/* ---- Graph editing ------------------------------------------------------------- */ + +/** + * @brief Create a node of `type_id` in the project (undoable: + * NodeFactory::create_from_id() + olive::NodeAddCommand). + * + * Returns the borrowed node handle, or NULL when `type_id` is not a + * registered node id (see oakengine_node_last_error()). + */ +OAKENGINE_API OakEngineNode * +oakengine_project_add_node(OakEngineProject *project, const char *type_id); + +/** + * @brief Remove a node from the project, disconnecting its edges + * (undoable, olive::NodeRemoveAndDisconnectCommand). + */ +OAKENGINE_API int oakengine_project_remove_node(OakEngineProject *project, + OakEngineNode *node); + +/** + * @brief Connect `output_node`'s output into `input_node`'s `input_id` + * (undoable, olive::NodeEdgeAddCommand). + * + * Fails with OAKENGINE_E_INVALID when the input is not connectable or the + * id is unknown, and with OAKENGINE_E_STATE when the input is already + * connected (disconnect first). + */ +OAKENGINE_API int oakengine_node_connect(OakEngineNode *output_node, + OakEngineNode *input_node, + const char *input_id); + +/** + * @brief Remove the edge feeding `input_node`'s `input_id` (undoable, + * olive::NodeEdgeRemoveCommand). OAKENGINE_E_NOT_FOUND when not connected. + */ +OAKENGINE_API int oakengine_node_disconnect(OakEngineNode *input_node, + const char *input_id); + +#ifdef __cplusplus +} +#endif + +#endif /* OAKENGINE_NODE_H */ diff --git a/engine/src/capi/CMakeLists.txt b/engine/src/capi/CMakeLists.txt index d802d0a26..df4b05b21 100644 --- a/engine/src/capi/CMakeLists.txt +++ b/engine/src/capi/CMakeLists.txt @@ -28,11 +28,13 @@ set(OLIVE_SOURCES include/oakengine/renderer.h include/oakengine/footage.h include/oakengine/exporter.h + include/oakengine/node.h src/capi/init.cpp src/capi/project.cpp src/capi/timeline.cpp src/capi/renderer.cpp src/capi/footage.cpp src/capi/export.cpp + src/capi/node.cpp PARENT_SCOPE ) diff --git a/engine/src/capi/node.cpp b/engine/src/capi/node.cpp new file mode 100644 index 000000000..dbd2fca63 --- /dev/null +++ b/engine/src/capi/node.cpp @@ -0,0 +1,562 @@ +/*** + + 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/node.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "coreengine.h" +#include "node/factory.h" +#include "node/node.h" +#include "node/nodeundo.h" +#include "node/project.h" +#include "node/value.h" +#include "undo/undocommand.h" +#include "undo/undostack.h" + +namespace +{ + +// Last node error per thread. +thread_local QString g_last_error; + +void set_error(const QString &error) +{ + g_last_error = error; +} + +olive::Node *impl(OakEngineNode *h) +{ + return reinterpret_cast(h); +} + +const olive::Node *impl(const OakEngineNode *h) +{ + return reinterpret_cast(h); +} + +OakEngineNode *wrap(olive::Node *n) +{ + return reinterpret_cast(n); +} + +olive::Project *impl(OakEngineProject *h) +{ + return reinterpret_cast(h); +} + +const olive::Project *impl(const OakEngineProject *h) +{ + return reinterpret_cast(h); +} + +// buf/size convention: returns the would-be length excluding the NUL. +int string_to_buf(const QString &s, char *buf, int buf_size) +{ + const QByteArray utf = s.toUtf8(); + if (buf && buf_size > 0) { + snprintf(buf, size_t(buf_size), "%s", utf.constData()); + } + return int(utf.size()); +} + +// 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) +{ + if (olive::EngineCore::instance()) { + olive::EngineCore::instance()->undo_stack()->push(command, name); + } else { + command->redo_now(); + delete command; + } +} + +// NodeValue::Type -> facade value type; types without a POD representation +// map to OAK_NODE_VALUE_NONE. +oak_node_value_type 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; + default: + return OAK_NODE_VALUE_NONE; + } +} + +// 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) +{ + memset(out, 0, sizeof(*out)); + out->type = to_c_type(v.type()); + switch (v.type()) { + case olive::NodeValue::k_int: + case olive::NodeValue::k_combo: + out->num = v.to_int(); + return true; + case olive::NodeValue::k_float: + out->f[0] = v.to_double(); + return true; + case olive::NodeValue::k_boolean: + out->num = v.to_bool() ? 1 : 0; + return true; + case olive::NodeValue::k_rational: { + const olive::Rational r = v.to_rational(); + out->num = r.numerator(); + out->den = r.denominator(); + return true; + } + case olive::NodeValue::k_color: { + const olive::Color c = v.to_color(); + 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: { + const QVector2D c = v.to_vec2(); + out->f[0] = c.x(); + out->f[1] = c.y(); + return true; + } + case olive::NodeValue::k_vec3: { + const QVector3D c = v.to_vec3(); + out->f[0] = c.x(); + out->f[1] = c.y(); + out->f[2] = c.z(); + return true; + } + case olive::NodeValue::k_vec4: { + const QVector4D c = v.to_vec4(); + out->f[0] = c.x(); + out->f[1] = c.y(); + out->f[2] = c.z(); + out->f[3] = c.w(); + return true; + } + default: + return false; + } +} + +// Map a POD back into an engine QVariant, checking the type against the +// input's declared type. Returns false on a type mismatch. +bool value_from_c(const oak_node_value *v, olive::NodeValue::Type declared, + QVariant *out) +{ + if (int(v->type) != int(to_c_type(declared))) { + return false; + } + switch (declared) { + case olive::NodeValue::k_int: + case olive::NodeValue::k_combo: + *out = QVariant::fromValue(v->num); + return true; + case olive::NodeValue::k_float: + *out = QVariant::fromValue(v->f[0]); + return true; + case olive::NodeValue::k_boolean: + *out = QVariant::fromValue(v->num != 0); + return true; + case olive::NodeValue::k_rational: + *out = QVariant::fromValue( + olive::Rational(int(v->num), int(v->den))); + return true; + case olive::NodeValue::k_color: + *out = QVariant::fromValue(olive::Color( + float(v->f[0]), float(v->f[1]), float(v->f[2]), float(v->f[3]))); + return true; + case olive::NodeValue::k_vec2: + *out = QVariant::fromValue( + QVector2D(float(v->f[0]), float(v->f[1]))); + return true; + case olive::NodeValue::k_vec3: + *out = QVariant::fromValue( + QVector3D(float(v->f[0]), float(v->f[1]), float(v->f[2]))); + return true; + case olive::NodeValue::k_vec4: + *out = QVariant::fromValue(QVector4D( + float(v->f[0]), float(v->f[1]), float(v->f[2]), float(v->f[3]))); + return true; + default: + return false; + } +} + +// Validate self + input id and return the declared type; reports the error. +olive::NodeValue::Type checked_input(const olive::Node *self, + const char *input_id) +{ + if (!self || !input_id) { + return olive::NodeValue::k_none; + } + const QString id = QString::fromUtf8(input_id); + if (!self->inputs().contains(id)) { + return olive::NodeValue::k_none; + } + return self->get_input_data_type(id); +} + +} // namespace + +extern "C" +{ + +int oakengine_node_last_error(char *buf, int buf_size) +{ + return string_to_buf(g_last_error, buf, buf_size); +} + +int oakengine_project_node_count(const OakEngineProject *self) +{ + return self ? impl(self)->nodes().size() : 0; +} + +OakEngineNode *oakengine_project_node_at(const OakEngineProject *self, + int index) +{ + if (!self || index < 0 || index >= impl(self)->nodes().size()) { + return nullptr; + } + return wrap(impl(self)->nodes().at(index)); +} + +int oakengine_node_get_type_id(const OakEngineNode *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->id(), buf, buf_size); +} + +int oakengine_node_get_name(const OakEngineNode *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->name(), buf, buf_size); +} + +int oakengine_node_get_label(const OakEngineNode *self, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + return string_to_buf(impl(self)->get_label(), buf, buf_size); +} + +int oakengine_node_set_label(OakEngineNode *self, const char *label) +{ + set_error(QString()); + if (!self) { + set_error(QStringLiteral("invalid node")); + return OAKENGINE_E_INVALID; + } + push_or_run(new olive::NodeRenameCommand( + impl(self), QString::fromUtf8(label ? label : "")), + QStringLiteral("Rename Node")); + return OAKENGINE_OK; +} + +int oakengine_node_input_count(const OakEngineNode *self) +{ + return self ? impl(self)->inputs().size() : 0; +} + +int oakengine_node_input_id(const OakEngineNode *self, int index, char *buf, + int buf_size) +{ + if (!self) { + return OAKENGINE_E_INVALID; + } + const QVector &ids = impl(self)->inputs(); + if (index < 0 || index >= ids.size()) { + return OAKENGINE_E_NOT_FOUND; + } + return string_to_buf(ids.at(index), buf, buf_size); +} + +int oakengine_node_input_get_type(const OakEngineNode *self, + const char *input_id) +{ + if (!self || !input_id) { + return OAK_NODE_VALUE_NONE; + } + return to_c_type(checked_input(impl(self), input_id)); +} + +int oakengine_node_input_is_connected(const OakEngineNode *self, + const char *input_id) +{ + if (!self || !input_id) { + return 0; + } + return impl(self)->is_input_connected(QString::fromUtf8(input_id)) ? 1 : + 0; +} + +int oakengine_node_get_input(const OakEngineNode *self, const char *input_id, + oak_node_value *out) +{ + set_error(QString()); + 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) { + set_error(QStringLiteral( + "\"%1\" is a string input; use oakengine_node_get_input_string()") + .arg(id)); + return OAKENGINE_E_INVALID; + } + const olive::NodeValue v(type, node->get_standard_value(id)); + if (!value_to_c(v, out)) { + set_error(QStringLiteral( + "input \"%1\" has no POD value representation") + .arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + return OAKENGINE_OK; +} + +int oakengine_node_set_input(OakEngineNode *self, const char *input_id, + const oak_node_value *v) +{ + set_error(QString()); + if (!self || !input_id || !v) { + 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; + } + const olive::NodeValue::Type type = node->get_input_data_type(id); + if (type == olive::NodeValue::k_file) { + set_error(QStringLiteral( + "\"%1\" is a string input; use oakengine_node_set_input_string()") + .arg(id)); + return OAKENGINE_E_INVALID; + } + QVariant value; + if (!value_from_c(v, type, &value)) { + set_error(QStringLiteral("value type %1 does not match the declared " + "type of \"%2\"") + .arg(v->type) + .arg(id)); + return OAKENGINE_E_INVALID; + } + // The undo stack's set-value commands work on split (per-component + // track) values; split_normal_value_into_track_values() is the same + // conversion Node::set_standard_value() performs. + push_or_run(new olive::NodeParamSetSplitStandardValueCommand( + olive::NodeInput(node, id), + olive::NodeValue::split_normal_value_into_track_values( + type, value)), + QStringLiteral("Set Node Value")); + return OAKENGINE_OK; +} + +int oakengine_node_get_input_string(const OakEngineNode *self, + const char *input_id, 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; + } + if (node->get_input_data_type(id) != olive::NodeValue::k_file) { + set_error(QStringLiteral("\"%1\" is not a string input").arg(id)); + return OAKENGINE_E_INVALID; + } + return string_to_buf(node->get_standard_value(id).toString(), buf, + buf_size); +} + +int oakengine_node_set_input_string(OakEngineNode *self, + const char *input_id, const char *s) +{ + 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; + } + if (node->get_input_data_type(id) != olive::NodeValue::k_file) { + set_error(QStringLiteral("\"%1\" is not a string input").arg(id)); + return OAKENGINE_E_INVALID; + } + const QVariant value = QVariant::fromValue(QString::fromUtf8(s ? s : "")); + push_or_run(new olive::NodeParamSetSplitStandardValueCommand( + olive::NodeInput(node, id), + olive::NodeValue::split_normal_value_into_track_values( + olive::NodeValue::k_file, value)), + QStringLiteral("Set Node Value")); + return OAKENGINE_OK; +} + +OakEngineNode *oakengine_project_add_node(OakEngineProject *project, + const char *type_id) +{ + set_error(QString()); + olive::Project *p = reinterpret_cast(project); + if (!p || !type_id) { + set_error(QStringLiteral("invalid project or type id")); + return nullptr; + } + const QString id = QString::fromUtf8(type_id); + olive::Node *node = olive::NodeFactory::create_from_id(id); + if (!node) { + set_error(QStringLiteral("unknown node type id \"%1\"").arg(id)); + return nullptr; + } + push_or_run(new olive::NodeAddCommand(p, node), + QStringLiteral("Add Node")); + return wrap(node); +} + +int oakengine_project_remove_node(OakEngineProject *project, + OakEngineNode *node) +{ + set_error(QString()); + olive::Project *p = reinterpret_cast(project); + olive::Node *n = impl(node); + if (!p || !n) { + set_error(QStringLiteral("invalid project or node")); + return OAKENGINE_E_INVALID; + } + if (olive::Project::get_project_from_object(n) != p) { + set_error(QStringLiteral("node does not belong to this project")); + return OAKENGINE_E_INVALID; + } + push_or_run(new olive::NodeRemoveAndDisconnectCommand(n), + QStringLiteral("Remove Node")); + return OAKENGINE_OK; +} + +int oakengine_node_connect(OakEngineNode *output_node, + OakEngineNode *input_node, const char *input_id) +{ + set_error(QString()); + olive::Node *out_node = impl(output_node); + olive::Node *in_node = impl(input_node); + if (!out_node || !in_node || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const QString id = QString::fromUtf8(input_id); + if (!in_node->inputs().contains(id)) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + if (!in_node->is_input_connectable(id)) { + set_error(QStringLiteral("input \"%1\" is not connectable").arg(id)); + return OAKENGINE_E_INVALID; + } + if (in_node->is_input_connected(id)) { + set_error(QStringLiteral( + "input \"%1\" is already connected; disconnect first") + .arg(id)); + return OAKENGINE_E_STATE; + } + push_or_run(new olive::NodeEdgeAddCommand( + out_node, olive::NodeInput(in_node, id)), + QStringLiteral("Connect Nodes")); + return OAKENGINE_OK; +} + +int oakengine_node_disconnect(OakEngineNode *input_node, const char *input_id) +{ + set_error(QString()); + olive::Node *in_node = impl(input_node); + if (!in_node || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return OAKENGINE_E_INVALID; + } + const QString id = QString::fromUtf8(input_id); + if (!in_node->inputs().contains(id)) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + olive::Node *connected = + in_node->get_connected_output(olive::NodeInput(in_node, id)); + if (!connected) { + set_error(QStringLiteral("input \"%1\" is not connected").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + push_or_run(new olive::NodeEdgeRemoveCommand( + connected, olive::NodeInput(in_node, id)), + QStringLiteral("Disconnect Nodes")); + return OAKENGINE_OK; +} + +} // extern "C" diff --git a/engine/tests/oakengine_node_test.cpp b/engine/tests/oakengine_node_test.cpp new file mode 100644 index 000000000..e67d2aa3e --- /dev/null +++ b/engine/tests/oakengine_node_test.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 . + +***/ + +// Pure C ABI test for the liboakengine node-graph facade: enumeration, +// metadata, input introspection, parameter get/set (POD + string), edge +// connect/disconnect and node add/remove -- all through their undo/redo +// behavior too. Uses the Solid generator and the OCIO LUT nodes as +// fixtures. No GL required. + +#include +#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/timeline.h" + +#ifndef OAK_TEST_SOURCE_DIR +#define OAK_TEST_SOURCE_DIR "." +#endif + +static char g_tmpdir[4096]; + +static void make_tmpdir(void) +{ +#if defined(_WIN32) + char base[MAX_PATH]; + const DWORD len = GetTempPathA(MAX_PATH, base); + assert(len > 0 && len < MAX_PATH); + snprintf(g_tmpdir, sizeof(g_tmpdir), "%soakengine_node_test_%lu", base, + (unsigned long)GetCurrentProcessId()); + assert(_mkdir(g_tmpdir) == 0); +#else + strcpy(g_tmpdir, "/tmp/oakengine_node_test_XXXXXX"); + assert(mkdtemp(g_tmpdir) != NULL); +#endif +} + +static void test_enumeration(OakEngineProject *project) +{ + // A fresh project holds at least its root folder node. + const int count = oakengine_project_node_count(project); + assert(count >= 1); + assert(oakengine_project_node_at(project, 0) != NULL); + assert(oakengine_project_node_at(project, count - 1) != NULL); + assert(oakengine_project_node_at(project, count) == NULL); + assert(oakengine_project_node_at(project, -1) == NULL); + + // NULL safety. + assert(oakengine_project_node_count(NULL) == 0); + assert(oakengine_project_node_at(NULL, 0) == NULL); + assert(oakengine_node_get_type_id(NULL, NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_node_get_name(NULL, NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_node_get_label(NULL, NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_node_set_label(NULL, "x") == OAKENGINE_E_INVALID); + assert(oakengine_node_input_count(NULL) == 0); + assert(oakengine_node_input_id(NULL, 0, NULL, 0) == OAKENGINE_E_INVALID); + assert(oakengine_node_input_get_type(NULL, "x") == OAK_NODE_VALUE_NONE); + assert(oakengine_node_input_is_connected(NULL, "x") == 0); + oak_node_value v; + memset(&v, 0, sizeof(v)); + assert(oakengine_node_get_input(NULL, "x", &v) == OAKENGINE_E_INVALID); + assert(oakengine_node_set_input(NULL, "x", &v) == OAKENGINE_E_INVALID); + assert(oakengine_node_get_input_string(NULL, "x", NULL, 0) == + OAKENGINE_E_INVALID); + assert(oakengine_node_set_input_string(NULL, "x", "y") == + OAKENGINE_E_INVALID); + assert(oakengine_project_add_node(NULL, "x") == NULL); + assert(oakengine_project_remove_node(NULL, NULL) == OAKENGINE_E_INVALID); + assert(oakengine_node_connect(NULL, NULL, "x") == OAKENGINE_E_INVALID); + assert(oakengine_node_disconnect(NULL, "x") == OAKENGINE_E_INVALID); +} + +static void test_add_and_metadata(OakEngineProject *project, + OakEngineNode **solid_out, + OakEngineNode **lut_out) +{ + const int before = oakengine_project_node_count(project); + char buf[256]; + + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + assert(oakengine_project_node_count(project) == before + 1); + + assert(oakengine_node_get_type_id(solid, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "org.olivevideoeditor.Olive.solidgenerator") == 0); + assert(oakengine_node_get_name(solid, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "Solid") == 0); + + // Unknown type id fails with a reason. + assert(oakengine_project_add_node(project, "org.example.nonexistent") == + NULL); + char err[256]; + assert(oakengine_node_last_error(err, sizeof(err)) > 0); + + // Label round-trip with undo/redo. + assert(oakengine_node_get_label(solid, buf, sizeof(buf)) >= 0); + assert(oakengine_node_set_label(solid, "MySolid") == OAKENGINE_OK); + assert(oakengine_node_get_label(solid, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "MySolid") == 0); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_get_label(solid, buf, sizeof(buf)) >= 0); + assert(strcmp(buf, "MySolid") != 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_node_get_label(solid, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "MySolid") == 0); + + OakEngineNode *lut = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.ociolut"); + assert(lut != NULL); + + *solid_out = solid; + *lut_out = lut; +} + +static void test_inputs_and_params(OakEngineProject *project, + OakEngineNode *solid, OakEngineNode *lut) +{ + char buf[256]; + + // Introspection on the Solid generator: the Node base class provides + // "enabled_in" (BOOL), the generator itself adds "color_in" (COLOR). + const int inputs = oakengine_node_input_count(solid); + assert(inputs >= 2); + assert(oakengine_node_input_id(solid, 0, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "enabled_in") == 0); + assert(oakengine_node_input_id(solid, 1, buf, sizeof(buf)) > 0); + assert(strcmp(buf, "color_in") == 0); + assert(oakengine_node_input_id(solid, inputs, buf, sizeof(buf)) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_node_input_get_type(solid, "enabled_in") == + OAK_NODE_VALUE_BOOL); + assert(oakengine_node_input_get_type(solid, "color_in") == + OAK_NODE_VALUE_COLOR); + assert(oakengine_node_input_get_type(solid, "no_such_input") == + OAK_NODE_VALUE_NONE); + assert(oakengine_node_input_is_connected(solid, "color_in") == 0); + + // BOOL get/set on the base-class input (defaults to true). + oak_node_value v; + memset(&v, 0, sizeof(v)); + assert(oakengine_node_get_input(solid, "enabled_in", &v) == + OAKENGINE_OK); + assert(v.type == OAK_NODE_VALUE_BOOL && v.num == 1); + v.num = 0; + assert(oakengine_node_set_input(solid, "enabled_in", &v) == + OAKENGINE_OK); + memset(&v, 0, sizeof(v)); + assert(oakengine_node_get_input(solid, "enabled_in", &v) == + OAKENGINE_OK); + assert(v.num == 0); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_get_input(solid, "enabled_in", &v) == + OAKENGINE_OK); + assert(v.num == 1); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + + // COLOR get/set round-trip (Solid defaults to opaque red). + memset(&v, 0, sizeof(v)); + assert(oakengine_node_get_input(solid, "color_in", &v) == OAKENGINE_OK); + assert(v.type == OAK_NODE_VALUE_COLOR); + assert(fabs(v.f[0] - 1.0) < 1e-6 && fabs(v.f[3] - 1.0) < 1e-6); + + v.f[0] = 0.2; + v.f[1] = 0.4; + v.f[2] = 0.6; + v.f[3] = 1.0; + assert(oakengine_node_set_input(solid, "color_in", &v) == OAKENGINE_OK); + memset(&v, 0, sizeof(v)); + assert(oakengine_node_get_input(solid, "color_in", &v) == OAKENGINE_OK); + assert(fabs(v.f[0] - 0.2) < 1e-6 && fabs(v.f[1] - 0.4) < 1e-6 && + fabs(v.f[2] - 0.6) < 1e-6); + + // Undo restores the default, redo applies it again. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_get_input(solid, "color_in", &v) == OAKENGINE_OK); + assert(fabs(v.f[0] - 1.0) < 1e-6); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_node_get_input(solid, "color_in", &v) == OAKENGINE_OK); + assert(fabs(v.f[0] - 0.2) < 1e-6); + + // Type errors: INT into a COLOR input, POD into a string input, POD and + // string APIs on an unknown id. + memset(&v, 0, sizeof(v)); + v.type = OAK_NODE_VALUE_INT; + v.num = 3; + assert(oakengine_node_set_input(solid, "color_in", &v) == + OAKENGINE_E_INVALID); + v.type = OAK_NODE_VALUE_COLOR; + assert(oakengine_node_set_input(solid, "no_such_input", &v) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_node_get_input(solid, "no_such_input", &v) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_node_set_input_string(solid, "color_in", "x") == + OAKENGINE_E_INVALID); + assert(oakengine_node_get_input_string(solid, "color_in", buf, + sizeof(buf)) == + OAKENGINE_E_INVALID); + + // STRING (k_file) on the LUT node: empty by default, round-trip + undo. + assert(oakengine_node_input_get_type(lut, "lut_file_in") == + OAK_NODE_VALUE_STRING); + assert(oakengine_node_get_input_string(lut, "lut_file_in", buf, + sizeof(buf)) == 0); + assert(oakengine_node_set_input_string(lut, "lut_file_in", + "/tmp/x.cube") == OAKENGINE_OK); + assert(oakengine_node_get_input_string(lut, "lut_file_in", buf, + sizeof(buf)) > 0); + assert(strcmp(buf, "/tmp/x.cube") == 0); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_get_input_string(lut, "lut_file_in", buf, + sizeof(buf)) == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_node_get_input_string(lut, "lut_file_in", buf, + sizeof(buf)) > 0); + assert(strcmp(buf, "/tmp/x.cube") == 0); + + // String typed inputs are rejected by the POD pair. + memset(&v, 0, sizeof(v)); + assert(oakengine_node_get_input(lut, "lut_file_in", &v) == + OAKENGINE_E_INVALID); + v.type = OAK_NODE_VALUE_STRING; + assert(oakengine_node_set_input(lut, "lut_file_in", &v) == + OAKENGINE_E_INVALID); + + // COMBO on the LUT direction input: 0 by default, set 1, undo. + assert(oakengine_node_input_get_type(lut, "lut_dir_in") == + OAK_NODE_VALUE_COMBO); + memset(&v, 0, sizeof(v)); + assert(oakengine_node_get_input(lut, "lut_dir_in", &v) == OAKENGINE_OK); + assert(v.type == OAK_NODE_VALUE_COMBO && v.num == 0); + v.num = 1; + assert(oakengine_node_set_input(lut, "lut_dir_in", &v) == OAKENGINE_OK); + memset(&v, 0, sizeof(v)); + assert(oakengine_node_get_input(lut, "lut_dir_in", &v) == OAKENGINE_OK); + assert(v.num == 1); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_get_input(lut, "lut_dir_in", &v) == OAKENGINE_OK); + assert(v.num == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); +} + +static void test_edges(OakEngineProject *project, OakEngineNode *solid, + OakEngineNode *lut) +{ + char err[256]; + + // Solid's texture output -> LUT's "tex_in". + assert(oakengine_node_input_is_connected(lut, "tex_in") == 0); + assert(oakengine_node_connect(solid, lut, "tex_in") == OAKENGINE_OK); + assert(oakengine_node_input_is_connected(lut, "tex_in") == 1); + + // Already-connected input is refused; unknown ids and unconnectable + // inputs fail. + assert(oakengine_node_connect(solid, lut, "tex_in") == + OAKENGINE_E_STATE); + assert(oakengine_node_connect(solid, lut, "no_such_input") == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_node_connect(solid, lut, "lut_file_in") == + OAKENGINE_E_INVALID); + assert(oakengine_node_last_error(err, sizeof(err)) > 0); + + // Disconnect restores the unconnected state; a second disconnect fails. + assert(oakengine_node_disconnect(lut, "tex_in") == OAKENGINE_OK); + assert(oakengine_node_input_is_connected(lut, "tex_in") == 0); + assert(oakengine_node_disconnect(lut, "tex_in") == + OAKENGINE_E_NOT_FOUND); + + // 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. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_input_is_connected(lut, "tex_in") == 1); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_input_is_connected(lut, "tex_in") == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_node_input_is_connected(lut, "tex_in") == 1); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_node_input_is_connected(lut, "tex_in") == 0); +} + +static void test_remove(OakEngineProject *project, OakEngineNode *solid, + OakEngineNode *lut) +{ + const int before = oakengine_project_node_count(project); + + // A node from another project is refused. + OakEngineProject *other = oakengine_project_create(); + assert(other != NULL); + assert(oakengine_project_new(other) == OAKENGINE_OK); + assert(oakengine_project_remove_node(other, solid) == + OAKENGINE_E_INVALID); + oakengine_project_free(other); + + assert(oakengine_project_remove_node(project, lut) == OAKENGINE_OK); + assert(oakengine_project_node_count(project) == before - 1); + + // Undo brings the node back, redo removes it again. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_project_node_count(project) == before); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_project_node_count(project) == before - 1); + + (void)solid; +} + +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); + + test_enumeration(project); + + OakEngineNode *solid = NULL, *lut = NULL; + test_add_and_metadata(project, &solid, &lut); + test_inputs_and_params(project, solid, lut); + test_edges(project, solid, lut); + test_remove(project, solid, lut); + + // Graph nodes are not timeline clips: a sequence's track list stays + // empty no matter what the project graph holds. + OakEngineSequence *seq = oakengine_sequence_new(project, "Seq"); + assert(seq != NULL); + int video = -1, audio = -1, subtitle = -1; + assert(oakengine_sequence_track_count(seq, &video, &audio, &subtitle) == + OAKENGINE_OK); + assert(video == 0 && audio == 0 && subtitle == 0); + + oakengine_project_free(project); + assert(oakengine_shutdown() == OAKENGINE_OK); + + printf("oakengine_node_test: all assertions passed\n"); + return 0; +}