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
+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"