diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 7c9225141..a8feddd3e 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -306,4 +306,9 @@ if (BUILD_TESTS) target_compile_definitions(oakengine_node_test PRIVATE OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) + + make_oakengine_test(oakengine_keyframe_test) + target_compile_definitions(oakengine_keyframe_test PRIVATE + OAK_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + ) endif () diff --git a/engine/include/oakengine/node.h b/engine/include/oakengine/node.h index 381f7919e..bf583b41f 100644 --- a/engine/include/oakengine/node.h +++ b/engine/include/oakengine/node.h @@ -251,6 +251,102 @@ OAKENGINE_API int oakengine_node_connect(OakEngineNode *output_node, OAKENGINE_API int oakengine_node_disconnect(OakEngineNode *input_node, const char *input_id); +/* ---- Parameter animation (keyframes) -------------------------------------- + * + * Keyframes live on an input's keyframe tracks (olive::NodeKeyframe). All + * functions of this family operate on TRACK 0 only: for single-track types + * (FLOAT, INT, BOOL, RATIONAL, COMBO, STRING) that is the whole value; for + * split-track types (COLOR, VEC2/3/4) it is the FIRST component only (e.g. + * red / x) -- multi-component keyframe editing is a later milestone. + * + * Keyframe times cross the boundary as frame timestamps (int64) in the + * timebase of the project's first sequence's frame rate; projects without a + * sequence fall back to the engine's default frame rate (1001/30000 s per + * frame). The facade easing type is 0 = linear, 1 = bezier, 2 = hold (the + * engine's NodeKeyframe::Type in a different order). Bezier control points + * are the in-handle (x1, y1) and out-handle (x2, y2) in curve space; they + * are only meaningful for bezier keyframes. + * + * All mutating calls are undoable like the other editing primitives. + * Errors follow the family model (oakengine_node_last_error()). + */ + +/** + * @brief 1 if the input is keyframing-enabled (Node::is_input_keyframing()). + */ +OAKENGINE_API int oakengine_node_input_is_keyframed(const OakEngineNode *self, + const char *input_id); + +/** + * @brief Number of keyframes on track 0 of the input. + */ +OAKENGINE_API int oakengine_node_keyframe_count(const OakEngineNode *self, + const char *input_id); + +/** + * @brief Read the keyframe at `index` (time-ordered): its time as a frame + * timestamp (`time_ts`, may be NULL) and its value mapped like + * oakengine_node_get_input() (`value`, may be NULL; split-track types get + * their first component in f[0]/num). + */ +OAKENGINE_API int oakengine_node_keyframe_at(const OakEngineNode *self, + const char *input_id, int index, + int64_t *time_ts, + oak_node_value *value); + +/** + * @brief Read the easing of the keyframe at `index`: facade type into + * `type` (0 = linear, 1 = bezier, 2 = hold; may be NULL) and the bezier + * in/out control points into (x1, y1, x2, y2) -- zeroed for non-bezier + * keyframes. Any pointer may be NULL. + */ +OAKENGINE_API int oakengine_node_keyframe_get_easing( + const OakEngineNode *self, const char *input_id, int index, float *x1, + float *y1, float *x2, float *y2, int *type); + +/** + * @brief Add a keyframe at `time_ts` (undoable; enables keyframing on the + * input first when needed). + * + * `value` maps like oakengine_node_set_input() and must match the input's + * declared type (for split-track types, the first component is used). + * `type` is the facade easing type; the four control floats only apply to + * bezier (type 1). Fails with OAKENGINE_E_STATE when a keyframe already + * exists at that exact time. + */ +OAKENGINE_API int oakengine_node_keyframe_add(OakEngineNode *self, + const char *input_id, + int64_t time_ts, + const oak_node_value *value, + int type, float x1, float y1, + float x2, float y2); + +/** + * @brief Remove the keyframe at `time_ts` (undoable). + * OAKENGINE_E_NOT_FOUND when no keyframe exists at that exact time. + */ +OAKENGINE_API int oakengine_node_keyframe_remove(OakEngineNode *self, + const char *input_id, + int64_t time_ts); + +/** + * @brief Change the easing of the keyframe at `time_ts` (undoable; set type + * plus bezier control points, mirroring the application's keyframe view + * commands). OAKENGINE_E_NOT_FOUND when no keyframe exists at that time; + * OAKENGINE_E_INVALID for an unknown easing type. + */ +OAKENGINE_API int oakengine_node_keyframe_set_easing( + OakEngineNode *self, const char *input_id, int64_t time_ts, int type, + float x1, float y1, float x2, float y2); + +/** + * @brief Remove all keyframes from the input (undoable, + * olive::NodeImmediateRemoveAllKeyframesCommand). A no-op (OAKENGINE_OK) + * when the input has no keyframes. + */ +OAKENGINE_API int oakengine_node_keyframes_clear(OakEngineNode *self, + const char *input_id); + #ifdef __cplusplus } #endif diff --git a/engine/src/capi/node.cpp b/engine/src/capi/node.cpp index dbd2fca63..37b49e090 100644 --- a/engine/src/capi/node.cpp +++ b/engine/src/capi/node.cpp @@ -32,9 +32,11 @@ #include "coreengine.h" #include "node/factory.h" +#include "node/keyframe.h" #include "node/node.h" #include "node/nodeundo.h" #include "node/project.h" +#include "node/project/sequence/sequence.h" #include "node/value.h" #include "undo/undocommand.h" #include "undo/undostack.h" @@ -242,6 +244,197 @@ olive::NodeValue::Type checked_input(const olive::Node *self, return self->get_input_data_type(id); } +/* ---- Keyframe helpers ------------------------------------------------------ */ + +// facade easing type <-> engine NodeKeyframe::Type (different orders). +olive::NodeKeyframe::Type to_engine_easing(int type) +{ + switch (type) { + case 1: + return olive::NodeKeyframe::k_bezier; + case 2: + return olive::NodeKeyframe::k_hold; + default: + return olive::NodeKeyframe::k_linear; + } +} + +int from_engine_easing(olive::NodeKeyframe::Type type) +{ + switch (type) { + case olive::NodeKeyframe::k_bezier: + return 1; + case olive::NodeKeyframe::k_hold: + return 2; + default: + return 0; + } +} + +// Frame-timestamp timebase for keyframes: the frame rate of the project's +// first sequence, or the engine default (1001/30000 s per frame). +olive::Rational project_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); +} + +int64_t kf_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); +} + +// Map a track-0 keyframe component value into the POD. For split-track +// types the component lands in f[0] (COLOR/VEC) or num; single-track types +// map fully. Returns false for unmappable types. +bool kf_value_to_c(olive::NodeValue::Type type, const QVariant &component, + oak_node_value *out) +{ + memset(out, 0, sizeof(*out)); + switch (type) { + case olive::NodeValue::k_int: + case olive::NodeValue::k_combo: + out->type = OAK_NODE_VALUE_INT; + out->num = component.toLongLong(); + return true; + case olive::NodeValue::k_float: + out->type = OAK_NODE_VALUE_FLOAT; + out->f[0] = component.toDouble(); + return true; + case olive::NodeValue::k_boolean: + out->type = OAK_NODE_VALUE_BOOL; + out->num = component.toBool() ? 1 : 0; + return true; + case olive::NodeValue::k_rational: { + const olive::Rational r = component.value(); + out->type = OAK_NODE_VALUE_RATIONAL; + out->num = r.numerator(); + out->den = r.denominator(); + return true; + } + case olive::NodeValue::k_color: + out->type = OAK_NODE_VALUE_COLOR; + out->f[0] = component.toFloat(); + return true; + case olive::NodeValue::k_vec2: + out->type = OAK_NODE_VALUE_VEC2; + out->f[0] = component.toFloat(); + return true; + case olive::NodeValue::k_vec3: + out->type = OAK_NODE_VALUE_VEC3; + out->f[0] = component.toFloat(); + return true; + case olive::NodeValue::k_vec4: + out->type = OAK_NODE_VALUE_VEC4; + out->f[0] = component.toFloat(); + return true; + case olive::NodeValue::k_file: + out->type = OAK_NODE_VALUE_STRING; + return true; + default: + return false; + } +} + +// Undo commands for easing changes. The engine's undo stack has no +// keyframe type/bezier commands -- they live in the application layer +// (app/widget/keyframeviewundo.h), so the facade carries minimal +// equivalents with the same old/new semantics. +class KeyframeSetTypeCommand : public olive::UndoCommand { +public: + KeyframeSetTypeCommand(olive::NodeKeyframe *key, + olive::NodeKeyframe::Type type) + : key_(key) + , new_type_(type) + { + } + + virtual olive::Project *get_relevant_project() const override + { + return key_->parent() ? key_->parent()->project() : nullptr; + } + +protected: + virtual void redo() override + { + old_type_ = key_->type(); + key_->set_type(new_type_); + } + + virtual void undo() override + { + key_->set_type(old_type_); + } + +private: + olive::NodeKeyframe *key_; + olive::NodeKeyframe::Type old_type_ = olive::NodeKeyframe::k_linear; + olive::NodeKeyframe::Type new_type_; +}; + +class KeyframeSetBezierPointCommand : public olive::UndoCommand { +public: + KeyframeSetBezierPointCommand(olive::NodeKeyframe *key, + olive::NodeKeyframe::BezierType mode, + const QPointF &point) + : key_(key) + , mode_(mode) + , new_point_(point) + { + } + + virtual olive::Project *get_relevant_project() const override + { + return key_->parent() ? key_->parent()->project() : nullptr; + } + +protected: + virtual void redo() override + { + old_point_ = key_->bezier_control(mode_); + key_->set_bezier_control(mode_, new_point_); + } + + virtual void undo() override + { + key_->set_bezier_control(mode_, old_point_); + } + +private: + olive::NodeKeyframe *key_; + olive::NodeKeyframe::BezierType mode_; + QPointF old_point_; + QPointF new_point_; +}; + +// Validate a keyframing target: node + known input id + mappable type. +// Returns the declared type (k_none on failure; error reported). +olive::NodeValue::Type checked_keyframe_input(const olive::Node *self, + const char *input_id) +{ + const olive::NodeValue::Type type = checked_input(self, input_id); + if (!self || !input_id) { + set_error(QStringLiteral("invalid arguments")); + return olive::NodeValue::k_none; + } + if (type == olive::NodeValue::k_none) { + set_error(QStringLiteral("unknown input id \"%1\"") + .arg(QString::fromUtf8(input_id))); + return olive::NodeValue::k_none; + } + return type; +} + } // namespace extern "C" @@ -559,4 +752,247 @@ int oakengine_node_disconnect(OakEngineNode *input_node, const char *input_id) return OAKENGINE_OK; } +/* ---- Parameter animation (keyframes) -------------------------------------- */ + +int oakengine_node_input_is_keyframed(const OakEngineNode *self, + const char *input_id) +{ + if (!self || !input_id) { + return 0; + } + return impl(self)->is_input_keyframing(QString::fromUtf8(input_id)) ? 1 : + 0; +} + +int oakengine_node_keyframe_count(const OakEngineNode *self, + const char *input_id) +{ + if (!self || !input_id) { + return 0; + } + const QVector &tracks = + impl(self)->get_keyframe_tracks(QString::fromUtf8(input_id), -1); + return tracks.isEmpty() ? 0 : tracks.first().size(); +} + +int oakengine_node_keyframe_at(const OakEngineNode *self, + const char *input_id, int index, + int64_t *time_ts, oak_node_value *value) +{ + set_error(QString()); + const olive::NodeValue::Type type = checked_keyframe_input(impl(self), input_id); + if (type == olive::NodeValue::k_none) { + return self && input_id ? OAKENGINE_E_NOT_FOUND : OAKENGINE_E_INVALID; + } + const QVector &tracks = + impl(self)->get_keyframe_tracks(QString::fromUtf8(input_id), -1); + if (tracks.isEmpty() || index < 0 || index >= tracks.first().size()) { + set_error(QStringLiteral("no keyframe at index %1").arg(index)); + return OAKENGINE_E_NOT_FOUND; + } + const olive::NodeKeyframe *key = tracks.first().at(index); + if (time_ts) { + *time_ts = kf_time_to_ts(key->time(), project_time_base(impl(self))); + } + if (value && !kf_value_to_c(type, key->value(), value)) { + set_error(QStringLiteral( + "input \"%1\" has no POD keyframe representation") + .arg(QString::fromUtf8(input_id))); + return OAKENGINE_E_NOT_FOUND; + } + return OAKENGINE_OK; +} + +int oakengine_node_keyframe_get_easing(const OakEngineNode *self, + const char *input_id, int index, + float *x1, float *y1, float *x2, + float *y2, int *type) +{ + set_error(QString()); + if (checked_keyframe_input(impl(self), input_id) == olive::NodeValue::k_none) { + return self && input_id ? OAKENGINE_E_NOT_FOUND : OAKENGINE_E_INVALID; + } + const QVector &tracks = + impl(self)->get_keyframe_tracks(QString::fromUtf8(input_id), -1); + if (tracks.isEmpty() || index < 0 || index >= tracks.first().size()) { + set_error(QStringLiteral("no keyframe at index %1").arg(index)); + return OAKENGINE_E_NOT_FOUND; + } + const olive::NodeKeyframe *key = tracks.first().at(index); + if (type) { + *type = from_engine_easing(key->type()); + } + const bool bezier = key->type() == olive::NodeKeyframe::k_bezier; + const QPointF in = + bezier ? key->bezier_control_in() : QPointF(0, 0); + const QPointF out = + bezier ? key->bezier_control_out() : QPointF(0, 0); + if (x1) { + *x1 = float(in.x()); + } + if (y1) { + *y1 = float(in.y()); + } + if (x2) { + *x2 = float(out.x()); + } + if (y2) { + *y2 = float(out.y()); + } + return OAKENGINE_OK; +} + +int oakengine_node_keyframe_add(OakEngineNode *self, const char *input_id, + int64_t time_ts, const oak_node_value *value, + int type, float x1, float y1, float x2, + float y2) +{ + 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; + } + if (!value || type < 0 || type > 2) { + set_error(QStringLiteral("invalid arguments or easing type")); + return OAKENGINE_E_INVALID; + } + const QString id = QString::fromUtf8(input_id); + const olive::Rational tb = project_time_base(node); + const olive::Rational time = + olive::core::Timecode::timestamp_to_time(time_ts, tb); + + if (node->get_keyframe_at_time_on_track(id, time, 0)) { + set_error(QStringLiteral( + "a keyframe already exists at time %1 on \"%2\"") + .arg(time_ts) + .arg(id)); + return OAKENGINE_E_STATE; + } + + // Map the POD to an engine value, then split it like + // Node::set_standard_value() does; track 0 takes the first component. + QVariant normal; + if (!value_from_c(value, declared, &normal)) { + set_error(QStringLiteral("value type %1 does not match the declared " + "type of \"%2\"") + .arg(value->type) + .arg(id)); + return OAKENGINE_E_INVALID; + } + const olive::SplitValue split = + olive::NodeValue::split_normal_value_into_track_values(declared, + normal); + if (split.isEmpty()) { + set_error(QStringLiteral( + "input \"%1\" has no keyframable representation") + .arg(id)); + return OAKENGINE_E_INVALID; + } + + auto *key = new olive::NodeKeyframe(time, split.first(), + to_engine_easing(type), 0, -1, id); + if (type == 1) { + key->set_bezier_control_in(QPointF(x1, y1)); + key->set_bezier_control_out(QPointF(x2, y2)); + } + + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + if (!node->is_input_keyframing(id)) { + command->add_child(new olive::NodeParamSetKeyframingCommand( + olive::NodeInput(node, id), true)); + } + command->add_child(new olive::NodeParamInsertKeyframeCommand(node, key)); + push_or_run(command, QStringLiteral("Add Keyframe")); + return OAKENGINE_OK; +} + +int oakengine_node_keyframe_remove(OakEngineNode *self, const char *input_id, + int64_t time_ts) +{ + set_error(QString()); + olive::Node *node = impl(self); + if (checked_keyframe_input(node, input_id) == olive::NodeValue::k_none) { + return self && input_id ? OAKENGINE_E_NOT_FOUND : OAKENGINE_E_INVALID; + } + const QString id = QString::fromUtf8(input_id); + const olive::Rational time = olive::core::Timecode::timestamp_to_time( + time_ts, project_time_base(node)); + olive::NodeKeyframe *key = + node->get_keyframe_at_time_on_track(id, time, 0); + if (!key) { + set_error(QStringLiteral("no keyframe at time %1 on \"%2\"") + .arg(time_ts) + .arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + push_or_run(new olive::NodeParamRemoveKeyframeCommand(key), + QStringLiteral("Remove Keyframe")); + return OAKENGINE_OK; +} + +int oakengine_node_keyframe_set_easing(OakEngineNode *self, + const char *input_id, int64_t time_ts, + int type, float x1, float y1, + float x2, float y2) +{ + set_error(QString()); + olive::Node *node = impl(self); + if (checked_keyframe_input(node, input_id) == olive::NodeValue::k_none) { + return self && input_id ? OAKENGINE_E_NOT_FOUND : OAKENGINE_E_INVALID; + } + if (type < 0 || type > 2) { + set_error(QStringLiteral("unknown easing type %1").arg(type)); + return OAKENGINE_E_INVALID; + } + const QString id = QString::fromUtf8(input_id); + const olive::Rational time = olive::core::Timecode::timestamp_to_time( + time_ts, project_time_base(node)); + olive::NodeKeyframe *key = + node->get_keyframe_at_time_on_track(id, time, 0); + if (!key) { + set_error(QStringLiteral("no keyframe at time %1 on \"%2\"") + .arg(time_ts) + .arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + + olive::MultiUndoCommand *command = new olive::MultiUndoCommand(); + command->add_child( + new KeyframeSetTypeCommand(key, to_engine_easing(type))); + if (type == 1) { + command->add_child(new KeyframeSetBezierPointCommand( + key, olive::NodeKeyframe::k_in_handle, QPointF(x1, y1))); + command->add_child(new KeyframeSetBezierPointCommand( + key, olive::NodeKeyframe::k_out_handle, QPointF(x2, y2))); + } + push_or_run(command, QStringLiteral("Set Keyframe Easing")); + return OAKENGINE_OK; +} + +int oakengine_node_keyframes_clear(OakEngineNode *self, const char *input_id) +{ + set_error(QString()); + olive::Node *node = impl(self); + if (checked_keyframe_input(impl(self), input_id) == + olive::NodeValue::k_none) { + return self && input_id ? OAKENGINE_E_NOT_FOUND : OAKENGINE_E_INVALID; + } + const QString id = QString::fromUtf8(input_id); + olive::NodeInputImmediate *imm = node->get_immediate(id, -1); + if (!imm) { + set_error(QStringLiteral("unknown input id \"%1\"").arg(id)); + return OAKENGINE_E_NOT_FOUND; + } + // Nothing to clear: succeed without pushing an empty undo command. + if (node->get_keyframe_tracks(id, -1).isEmpty() || + node->get_keyframe_tracks(id, -1).first().isEmpty()) { + return OAKENGINE_OK; + } + push_or_run(new olive::NodeImmediateRemoveAllKeyframesCommand(imm), + QStringLiteral("Clear Keyframes")); + return OAKENGINE_OK; +} + } // extern "C" diff --git a/engine/tests/oakengine_keyframe_test.cpp b/engine/tests/oakengine_keyframe_test.cpp new file mode 100644 index 000000000..7e06aba1b --- /dev/null +++ b/engine/tests/oakengine_keyframe_test.cpp @@ -0,0 +1,304 @@ +/*** + + 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 keyframe (parameter animation) +// facade: is-keyframed, add/at/easing/remove/clear on track 0, across +// FLOAT, RATIONAL and COLOR (first-component) parameters, with the full +// undo/redo chain. 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_keyframe_test_%lu", + base, (unsigned long)GetCurrentProcessId()); + assert(_mkdir(g_tmpdir) == 0); +#else + strcpy(g_tmpdir, "/tmp/oakengine_keyframe_test_XXXXXX"); + assert(mkdtemp(g_tmpdir) != NULL); +#endif +} + +static oak_node_value float_value(double v) +{ + oak_node_value out; + memset(&out, 0, sizeof(out)); + out.type = OAK_NODE_VALUE_FLOAT; + out.f[0] = v; + return out; +} + +static void test_float_lifecycle(OakEngineProject *project, + OakEngineNode *opacity) +{ + char err[256]; + + assert(oakengine_node_input_is_keyframed(opacity, "opacity_in") == 0); + assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 0); + + // Missing input ids and missing keyframes report errors. + oak_node_value v = float_value(0.5); + assert(oakengine_node_keyframe_add(opacity, "no_such_input", 0, &v, 0, 0, + 0, 0, 0) == OAKENGINE_E_NOT_FOUND); + assert(oakengine_node_last_error(err, sizeof(err)) > 0); + assert(oakengine_node_keyframe_add(NULL, "opacity_in", 0, &v, 0, 0, 0, + 0, 0) == OAKENGINE_E_INVALID); + assert(oakengine_node_keyframe_add(opacity, "opacity_in", 0, NULL, 0, 0, + 0, 0, 0) == OAKENGINE_E_INVALID); + assert(oakengine_node_keyframe_add(opacity, "opacity_in", 0, &v, 9, 0, 0, + 0, 0) == OAKENGINE_E_INVALID); + + // Three linear keys at 0, 15, 30. + v = float_value(0.0); + assert(oakengine_node_keyframe_add(opacity, "opacity_in", 0, &v, 0, 0, 0, + 0, 0) == OAKENGINE_OK); + assert(oakengine_node_input_is_keyframed(opacity, "opacity_in") == 1); + v = float_value(0.5); + assert(oakengine_node_keyframe_add(opacity, "opacity_in", 15, &v, 0, 0, + 0, 0, 0) == OAKENGINE_OK); + v = float_value(1.0); + assert(oakengine_node_keyframe_add(opacity, "opacity_in", 30, &v, 0, 0, + 0, 0, 0) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 3); + + // A key at an occupied time is refused. + v = float_value(0.25); + assert(oakengine_node_keyframe_add(opacity, "opacity_in", 15, &v, 0, 0, + 0, 0, 0) == OAKENGINE_E_STATE); + + // Read them back: ordered, correct times and values, linear easing. + int64_t ts = -1; + oak_node_value out; + for (int i = 0; i < 3; i++) { + assert(oakengine_node_keyframe_at(opacity, "opacity_in", i, &ts, + &out) == OAKENGINE_OK); + assert(ts == i * 15); + assert(out.type == OAK_NODE_VALUE_FLOAT); + assert(fabs(out.f[0] - i * 0.5) < 1e-9); + int type = -1; + float x1 = -1, y1 = -1, x2 = -1, y2 = -1; + assert(oakengine_node_keyframe_get_easing(opacity, "opacity_in", i, + &x1, &y1, &x2, &y2, + &type) == OAKENGINE_OK); + assert(type == 0); // linear + assert(x1 == 0 && y1 == 0 && x2 == 0 && y2 == 0); + } + assert(oakengine_node_keyframe_at(opacity, "opacity_in", 3, &ts, &out) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_node_keyframe_get_easing(opacity, "opacity_in", 99, + NULL, NULL, NULL, NULL, + NULL) == OAKENGINE_E_NOT_FOUND); + + // Undo the adds one by one, then redo them all. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 2); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 1); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 0); + assert(oakengine_node_input_is_keyframed(opacity, "opacity_in") == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 3); + assert(oakengine_node_input_is_keyframed(opacity, "opacity_in") == 1); +} + +static void test_easing_and_remove(OakEngineProject *project, + OakEngineNode *opacity) +{ + // Bezier easing on the middle key, control points round-trip. + assert(oakengine_node_keyframe_set_easing(opacity, "opacity_in", 15, 1, + 0.1f, 0.2f, 0.3f, + 0.4f) == OAKENGINE_OK); + int type = -1; + float x1 = 0, y1 = 0, x2 = 0, y2 = 0; + assert(oakengine_node_keyframe_get_easing(opacity, "opacity_in", 1, &x1, + &y1, &x2, &y2, + &type) == OAKENGINE_OK); + assert(type == 1); // bezier + assert(fabsf(x1 - 0.1f) < 1e-6f && fabsf(y1 - 0.2f) < 1e-6f); + assert(fabsf(x2 - 0.3f) < 1e-6f && fabsf(y2 - 0.4f) < 1e-6f); + + // Undo restores linear, redo restores bezier. + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_get_easing(opacity, "opacity_in", 1, &x1, + &y1, &x2, &y2, + &type) == OAKENGINE_OK); + assert(type == 0); + assert(oakengine_project_redo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_get_easing(opacity, "opacity_in", 1, &x1, + &y1, &x2, &y2, + &type) == OAKENGINE_OK); + assert(type == 1); + + // Hold easing on the first key. + assert(oakengine_node_keyframe_set_easing(opacity, "opacity_in", 0, 2, 0, + 0, 0, 0) == OAKENGINE_OK); + assert(oakengine_node_keyframe_get_easing(opacity, "opacity_in", 0, NULL, + NULL, NULL, NULL, + &type) == OAKENGINE_OK); + assert(type == 2); // hold + assert(oakengine_node_keyframe_set_easing(opacity, "opacity_in", 15, 9, 0, + 0, 0, 0) == OAKENGINE_E_INVALID); + assert(oakengine_node_keyframe_set_easing(opacity, "opacity_in", 77, 1, 0, + 0, 0, + 0) == OAKENGINE_E_NOT_FOUND); + + // Remove the middle key; order compacts. + assert(oakengine_node_keyframe_remove(opacity, "opacity_in", 15) == + OAKENGINE_OK); + assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 2); + int64_t ts = -1; + oak_node_value out; + assert(oakengine_node_keyframe_at(opacity, "opacity_in", 1, &ts, &out) == + OAKENGINE_OK); + assert(ts == 30 && fabs(out.f[0] - 1.0) < 1e-9); + assert(oakengine_node_keyframe_remove(opacity, "opacity_in", 15) == + OAKENGINE_E_NOT_FOUND); + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 3); + assert(oakengine_node_keyframe_at(opacity, "opacity_in", 1, &ts, &out) == + OAKENGINE_OK); + assert(ts == 15); + + // Clear all, then undo. + assert(oakengine_node_keyframes_clear(opacity, "opacity_in") == + OAKENGINE_OK); + assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 0); + assert(oakengine_node_keyframes_clear(opacity, "opacity_in") == + OAKENGINE_OK); // no-op on empty + assert(oakengine_project_undo(project) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 3); +} + +static void test_rational_and_color(OakEngineProject *project, + OakEngineNode *timeremap, + OakEngineNode *solid) +{ + // RATIONAL value on the time remap node's rational input. + oak_node_value v; + memset(&v, 0, sizeof(v)); + v.type = OAK_NODE_VALUE_RATIONAL; + v.num = 30; + v.den = 1; + assert(oakengine_node_keyframe_add(timeremap, "time_in", 0, &v, 0, 0, 0, + 0, 0) == OAKENGINE_OK); + assert(oakengine_node_keyframe_count(timeremap, "time_in") == 1); + int64_t ts = -1; + oak_node_value out; + assert(oakengine_node_keyframe_at(timeremap, "time_in", 0, &ts, &out) == + OAKENGINE_OK); + assert(ts == 0); + assert(out.type == OAK_NODE_VALUE_RATIONAL); + assert(out.num == 30 && out.den == 1); + + // A type mismatch is rejected. + v.type = OAK_NODE_VALUE_FLOAT; + v.f[0] = 1.5; + assert(oakengine_node_keyframe_add(timeremap, "time_in", 5, &v, 0, 0, 0, + 0, 0) == OAKENGINE_E_INVALID); + + // COLOR on the Solid generator: track 0 is the red component. + memset(&v, 0, sizeof(v)); + v.type = OAK_NODE_VALUE_COLOR; + v.f[0] = 0.25; + v.f[1] = 0.5; + v.f[2] = 0.75; + v.f[3] = 1.0; + assert(oakengine_node_keyframe_add(solid, "color_in", 10, &v, 0, 0, 0, 0, + 0) == OAKENGINE_OK); + assert(oakengine_node_keyframe_at(solid, "color_in", 0, &ts, &out) == + OAKENGINE_OK); + assert(ts == 10); + assert(out.type == OAK_NODE_VALUE_COLOR); + assert(fabs(out.f[0] - 0.25) < 1e-6); // first component only + + assert(oakengine_node_keyframes_clear(timeremap, "time_in") == + OAKENGINE_OK); + assert(oakengine_node_keyframes_clear(solid, "color_in") == OAKENGINE_OK); +} + +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); + // A sequence provides the frame-rate timebase for keyframe timestamps. + OakEngineSequence *seq = oakengine_sequence_new(project, "Seq"); + assert(seq != NULL); + + OakEngineNode *opacity = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.opacity"); + assert(opacity != NULL); + OakEngineNode *timeremap = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.timeremap"); + assert(timeremap != NULL); + OakEngineNode *solid = oakengine_project_add_node( + project, "org.olivevideoeditor.Olive.solidgenerator"); + assert(solid != NULL); + + test_float_lifecycle(project, opacity); + test_easing_and_remove(project, opacity); + test_rational_and_color(project, timeremap, solid); + + oakengine_project_free(project); + assert(oakengine_shutdown() == OAKENGINE_OK); + + printf("oakengine_keyframe_test: all assertions passed\n"); + return 0; +}