engine: add the keyframe family to the node facade

- keyframed state, count, read (time in sequence timebase + mapped
  values), add/remove, easing read/write (linear/bezier/hold with
  control points), and clear - all undoable with full undo/redo
  assertions
- easing set commands are minimal capi-local UndoCommands matching
  the app-layer semantics (engine has none of its own); same-time
  duplicates are rejected with E_STATE instead of hitting the engine's
  debug assert
This commit is contained in:
2026-07-20 08:31:24 +08:00
parent e82011b0ee
commit eaa301b755
4 changed files with 841 additions and 0 deletions
+5
View File
@@ -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 ()
+96
View File
@@ -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
+436
View File
@@ -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<olive::Sequence *>(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<olive::Rational>();
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<olive::NodeKeyframeTrack> &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<olive::NodeKeyframeTrack> &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<olive::NodeKeyframeTrack> &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"
+304
View File
@@ -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 <http://www.gnu.org/licenses/>.
***/
// 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 <assert.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(_WIN32)
#include <direct.h>
#include <windows.h>
#else
#include <unistd.h>
#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;
}