engine: node parameter panel migrates to the facade

- new facade API: set_input_at_time (element addressing, track=-1 for
  all components at once), set_input_string_at_time, frame_time_base,
  array_insert_at/remove_at, disconnect_ex (element-aware), and
  keyframes_set_type_many (first cross-track keyframe op, addressed by
  (time,track) pairs)
- the widget bridge's commit funnel, color path, array ops, label
  disconnect, and keyframe set-type actions in keyframeview/curvewidget
  now go through the facade; keyframeviewundo.h loses two consumers
- deliberate leftovers with rationale: keyframecontrol's multi-track
  composite ops (documented track-0-only limitation of the keyframe
  family), keyframeproperties dialog (needs a set_time primitive),
  curveview's drag UX, and NodeInputDragger (already engine-side)
This commit is contained in:
2026-07-20 14:42:00 +08:00
parent 0fe37dba3c
commit 8311128f4c
9 changed files with 747 additions and 57 deletions
+87
View File
@@ -224,6 +224,66 @@ OAKENGINE_API int oakengine_node_set_input_string(OakEngineNode *self,
const char *input_id,
const char *s);
/**
* @brief The frame timebase used for keyframe/parameter frame timestamps
* (seconds per frame: the frame rate of the project's first sequence
* flipped, or the engine default 1001/30000). Any pointer may be NULL.
* Use it to convert rational seconds to the timestamps this family
* takes, exactly like the facade does internally.
*/
OAKENGINE_API int oakengine_node_frame_time_base(
const OakEngineNode *self, int *num, int *den);
/**
* @brief Write an input's value at a time (undoable, ONE command;
* olive::Node::set_value_at_time() -- the application's parameter
* panel commit path).
*
* When the input is keyframed this inserts or updates the keyframe at
* `time_ts` (the engine's set_value_at_time semantics); otherwise it
* sets the standard value on `track`. `element` is the array element
* (-1 for non-array inputs). `track` is the keyframe track/component;
* pass -1 to write ALL components of a split-track type (COLOR/VEC2/3/4)
* from `v->f[]` in the same command. `v->type` must match the input's
* declared type; a k_bezier input takes an OAK_NODE_VALUE_FLOAT
* component per track. String-family inputs (k_file/k_text/k_font/
* k_str_combo) are rejected -- use oakengine_node_set_input_string_at_
* time(). `insert_on_all_tracks` mirrors set_value_at_time's
* insert_on_all_tracks_if_no_key (ignored for `track` -1).
*/
OAKENGINE_API int oakengine_node_set_input_at_time(
OakEngineNode *self, const char *input_id, int element, int64_t time_ts,
int track, const oak_node_value *v, int insert_on_all_tracks);
/**
* @brief Write a string-family input's value at a time (undoable, ONE
* command; k_file/k_text/k_font/k_str_combo on track 0, with
* insert_on_all_tracks_if_no_key semantics like the panel).
*/
OAKENGINE_API int oakengine_node_set_input_string_at_time(
OakEngineNode *self, const char *input_id, int element, int64_t time_ts,
const char *value);
/* ---- Array inputs --------------------------------------------------------- */
/**
* @brief Insert an element into an array input at `index` (undoable,
* olive::NodeArrayInsertCommand; the panel's array append/insert
* buttons). `index` must be >= 0.
*/
OAKENGINE_API int oakengine_node_array_insert_at(OakEngineNode *self,
const char *input_id,
int index);
/**
* @brief Remove the array element at `index` (undoable,
* olive::NodeArrayRemoveCommand). OAKENGINE_E_NOT_FOUND for an
* out-of-range index.
*/
OAKENGINE_API int oakengine_node_array_remove_at(OakEngineNode *self,
const char *input_id,
int index);
/* ---- Graph editing ------------------------------------------------------------- */
/**
@@ -262,6 +322,16 @@ OAKENGINE_API int oakengine_node_connect(OakEngineNode *output_node,
OAKENGINE_API int oakengine_node_disconnect(OakEngineNode *input_node,
const char *input_id);
/**
* @brief Remove the edge feeding `input_node`'s `input_id` at `element`
* (undoable). Same as oakengine_node_disconnect() (which passes element
* -1) but addresses an array element, like the panel's connected-label
* disconnect for array inputs.
*/
OAKENGINE_API int oakengine_node_disconnect_ex(OakEngineNode *input_node,
const char *input_id,
int element);
/* ---- Parameter animation (keyframes) --------------------------------------
*
* Keyframes live on an input's keyframe tracks (olive::NodeKeyframe). All
@@ -350,6 +420,23 @@ 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 Change only the easing TYPE of several keyframes of one input
* (undoable, ONE command; the application's keyframe view
* KeyframeSetTypeCommand, batched like its context-menu action).
*
* Unlike the rest of this family (track 0 only), keyframes are addressed
* individually by (`times_ts`[i], `tracks`[i]) because the view's
* selection may span tracks; `element` addresses the input's array
* element (-1 for non-array). Bezier control points are left untouched.
* Every address must name an existing keyframe or the whole call fails
* with OAKENGINE_E_NOT_FOUND and nothing is pushed. Returns the number
* of affected keyframes (>= 0) or a negative code.
*/
OAKENGINE_API int oakengine_node_keyframes_set_type_many(
OakEngineNode *self, const char *input_id, int element,
const int64_t *times_ts, const int *tracks, int count, int type);
/**
* @brief Remove all keyframes from the input (undoable,
* olive::NodeImmediateRemoveAllKeyframesCommand). A no-op (OAKENGINE_OK)
+270 -4
View File
@@ -668,6 +668,221 @@ int oakengine_node_set_input_string(OakEngineNode *self,
return OAKENGINE_OK;
}
int oakengine_node_frame_time_base(const OakEngineNode *self, int *num,
int *den)
{
if (!self) {
return OAKENGINE_E_INVALID;
}
const olive::Rational tb = project_time_base(impl(self));
if (num) {
*num = tb.numerator();
}
if (den) {
*den = tb.denominator();
}
return OAKENGINE_OK;
}
// Component QVariant of a per-track POD for set_value_at_time: the
// panel's sliders carry one scalar per track (int64/double/Rational/
// bool). Returns false on a type that has no scalar component here.
static bool component_from_c(const oak_node_value *v,
olive::NodeValue::Type declared, int component,
QVariant *out)
{
switch (declared) {
case olive::NodeValue::k_int:
case olive::NodeValue::k_combo:
if (v->type != OAK_NODE_VALUE_INT &&
v->type != OAK_NODE_VALUE_COMBO) {
return false;
}
*out = QVariant::fromValue<qlonglong>(v->num);
return true;
case olive::NodeValue::k_float:
case olive::NodeValue::k_bezier:
if (v->type != OAK_NODE_VALUE_FLOAT) {
return false;
}
*out = QVariant::fromValue(v->f[0]);
return true;
case olive::NodeValue::k_boolean:
if (v->type != OAK_NODE_VALUE_BOOL) {
return false;
}
*out = QVariant::fromValue(v->num != 0);
return true;
case olive::NodeValue::k_rational:
if (v->type != OAK_NODE_VALUE_RATIONAL) {
return false;
}
*out = QVariant::fromValue(
olive::Rational(int(v->num), int(v->den)));
return true;
case olive::NodeValue::k_color:
case olive::NodeValue::k_vec2:
case olive::NodeValue::k_vec3:
case olive::NodeValue::k_vec4:
if (int(v->type) != int(to_c_type(declared))) {
return false;
}
*out = QVariant::fromValue(v->f[component]);
return true;
default:
return false;
}
}
int oakengine_node_set_input_at_time(OakEngineNode *self,
const char *input_id, int element,
int64_t time_ts, int track,
const oak_node_value *v,
int insert_on_all_tracks)
{
set_error(QString());
olive::Node *node = impl(self);
const olive::NodeValue::Type declared = checked_input(node, input_id);
if (declared == olive::NodeValue::k_none) {
set_error(self && input_id ?
QStringLiteral("unknown input id \"%1\"")
.arg(QString::fromUtf8(input_id)) :
QStringLiteral("invalid arguments"));
return self && input_id ? OAKENGINE_E_NOT_FOUND : OAKENGINE_E_INVALID;
}
if (declared == olive::NodeValue::k_file ||
declared == olive::NodeValue::k_text ||
declared == olive::NodeValue::k_font ||
declared == olive::NodeValue::k_str_combo) {
set_error(QStringLiteral(
"string inputs use oakengine_node_set_input_string_at_time"));
return OAKENGINE_E_INVALID;
}
const int nb_tracks =
olive::NodeValue::get_number_of_keyframe_tracks(declared);
if (!v || track < -1 || track >= nb_tracks || nb_tracks == 0) {
set_error(QStringLiteral("invalid arguments"));
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));
const olive::NodeInput input(node, id, element);
// The panel's commit path (Node::set_value_at_time), one undoable
// command: keyframed inputs insert/update the keyframe at the time,
// others set the standard value on the track.
olive::MultiUndoCommand *command = new olive::MultiUndoCommand();
if (track == -1) {
for (int i = 0; i < nb_tracks; i++) {
QVariant component;
if (!component_from_c(v, declared, i, &component)) {
set_error(QStringLiteral(
"value type does not match the declared input type"));
delete command;
return OAKENGINE_E_INVALID;
}
olive::Node::set_value_at_time(input, time, component, i,
command, false);
}
} else {
QVariant component;
if (!component_from_c(v, declared, 0, &component)) {
set_error(QStringLiteral(
"value type does not match the declared input type"));
delete command;
return OAKENGINE_E_INVALID;
}
olive::Node::set_value_at_time(input, time, component, track,
command, insert_on_all_tracks != 0);
}
push_or_run(command, QStringLiteral("Set Input Value"));
return OAKENGINE_OK;
}
int oakengine_node_set_input_string_at_time(OakEngineNode *self,
const char *input_id, int element,
int64_t time_ts, const char *value)
{
set_error(QString());
olive::Node *node = impl(self);
const olive::NodeValue::Type declared = checked_input(node, input_id);
if (declared == olive::NodeValue::k_none) {
set_error(self && input_id ?
QStringLiteral("unknown input id \"%1\"")
.arg(QString::fromUtf8(input_id)) :
QStringLiteral("invalid arguments"));
return self && input_id ? OAKENGINE_E_NOT_FOUND : OAKENGINE_E_INVALID;
}
if (declared != olive::NodeValue::k_file &&
declared != olive::NodeValue::k_text &&
declared != olive::NodeValue::k_font &&
declared != olive::NodeValue::k_str_combo) {
set_error(QStringLiteral("\"%1\" is not a string input")
.arg(QString::fromUtf8(input_id)));
return OAKENGINE_E_INVALID;
}
const olive::Rational time = olive::core::Timecode::timestamp_to_time(
time_ts, project_time_base(node));
const QVariant v =
QVariant::fromValue(QString::fromUtf8(value ? value : ""));
olive::MultiUndoCommand *command = new olive::MultiUndoCommand();
olive::Node::set_value_at_time(
olive::NodeInput(node, QString::fromUtf8(input_id), element), time, v,
0, command, true);
push_or_run(command, QStringLiteral("Set Input Value"));
return OAKENGINE_OK;
}
int oakengine_node_array_insert_at(OakEngineNode *self, const char *input_id,
int index)
{
set_error(QString());
olive::Node *node = impl(self);
if (checked_input(node, input_id) == olive::NodeValue::k_none ||
index < 0) {
set_error(self && input_id && index >= 0 ?
QStringLiteral("unknown input id \"%1\"")
.arg(QString::fromUtf8(input_id)) :
QStringLiteral("invalid arguments"));
return self && input_id && index >= 0 ? OAKENGINE_E_NOT_FOUND :
OAKENGINE_E_INVALID;
}
push_or_run(new olive::NodeArrayInsertCommand(
node, QString::fromUtf8(input_id), index),
QStringLiteral("Insert Array Element"));
return OAKENGINE_OK;
}
int oakengine_node_array_remove_at(OakEngineNode *self, const char *input_id,
int index)
{
set_error(QString());
olive::Node *node = impl(self);
if (checked_input(node, input_id) == olive::NodeValue::k_none ||
index < 0) {
set_error(self && input_id && index >= 0 ?
QStringLiteral("unknown input id \"%1\"")
.arg(QString::fromUtf8(input_id)) :
QStringLiteral("invalid arguments"));
return self && input_id && index >= 0 ? OAKENGINE_E_NOT_FOUND :
OAKENGINE_E_INVALID;
}
const QString id = QString::fromUtf8(input_id);
const int size = olive::NodeInput(node, id).get_array_size();
if (index >= size) {
set_error(QStringLiteral("array index %1 out of range (size %2)")
.arg(index)
.arg(size));
return OAKENGINE_E_NOT_FOUND;
}
push_or_run(new olive::NodeArrayRemoveCommand(node, id, index),
QStringLiteral("Remove Array Element"));
return OAKENGINE_OK;
}
OakEngineNode *oakengine_project_add_node(OakEngineProject *project,
const char *type_id)
{
@@ -739,6 +954,12 @@ int oakengine_node_connect(OakEngineNode *output_node,
}
int oakengine_node_disconnect(OakEngineNode *input_node, const char *input_id)
{
return oakengine_node_disconnect_ex(input_node, input_id, -1);
}
int oakengine_node_disconnect_ex(OakEngineNode *input_node,
const char *input_id, int element)
{
set_error(QString());
olive::Node *in_node = impl(input_node);
@@ -751,14 +972,13 @@ int oakengine_node_disconnect(OakEngineNode *input_node, const char *input_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));
const olive::NodeInput input(in_node, id, element);
olive::Node *connected = in_node->get_connected_output(input);
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)),
push_or_run(new olive::NodeEdgeRemoveCommand(connected, input),
QStringLiteral("Disconnect Nodes"));
return OAKENGINE_OK;
}
@@ -982,6 +1202,52 @@ int oakengine_node_keyframe_set_easing(OakEngineNode *self,
return OAKENGINE_OK;
}
int oakengine_node_keyframes_set_type_many(OakEngineNode *self,
const char *input_id, int element,
const int64_t *times_ts,
const int *tracks, int count,
int type)
{
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 || count < 0 || (count > 0 && (!times_ts || !tracks))) {
set_error(QStringLiteral("invalid arguments"));
return OAKENGINE_E_INVALID;
}
if (count == 0) {
return 0;
}
const QString id = QString::fromUtf8(input_id);
const olive::Rational tb = project_time_base(node);
const olive::NodeInput input(node, id, element);
// Resolve every keyframe first so a bad address fails without side
// effects (same batch semantics as the view's context-menu action).
olive::MultiUndoCommand *command = new olive::MultiUndoCommand();
for (int i = 0; i < count; i++) {
const olive::Rational time =
olive::core::Timecode::timestamp_to_time(times_ts[i], tb);
olive::NodeKeyframe *key =
node->get_keyframe_at_time_on_track(input, time, tracks[i]);
if (!key) {
set_error(QStringLiteral("no keyframe at time %1 track %2 on "
"\"%3\"")
.arg(times_ts[i])
.arg(tracks[i])
.arg(id));
delete command;
return OAKENGINE_E_NOT_FOUND;
}
command->add_child(
new KeyframeSetTypeCommand(key, to_engine_easing(type)));
}
push_or_run(command, QStringLiteral("Set Keyframe Type"));
return count;
}
int oakengine_node_keyframes_clear(OakEngineNode *self, const char *input_id)
{
set_error(QString());
+157
View File
@@ -262,6 +262,162 @@ static void test_rational_and_color(OakEngineProject *project,
assert(oakengine_node_keyframes_clear(solid, "color_in") == OAKENGINE_OK);
}
static void test_panel_paths(OakEngineProject *project,
OakEngineNode *opacity, OakEngineNode *solid)
{
oak_node_value out;
// Frame time base (1001/30000 with the default sequence).
int tbn = 0, tbd = 0;
assert(oakengine_node_frame_time_base(opacity, &tbn, &tbd) ==
OAKENGINE_OK);
assert(tbn > 0 && tbd > 0);
assert(oakengine_node_frame_time_base(NULL, &tbn, &tbd) ==
OAKENGINE_E_INVALID);
// set_input_at_time on a NON-keyframed input (fresh node) sets the
// standard value; undo restores it.
OakEngineNode *op2 = oakengine_project_add_node(
project, "org.olivevideoeditor.Olive.opacity");
assert(op2 != NULL);
oak_node_value v = float_value(0.75);
assert(oakengine_node_set_input_at_time(op2, "opacity_in", -1, 10, 0, &v,
1) == OAKENGINE_OK);
assert(oakengine_node_get_input(op2, "opacity_in", &out) == OAKENGINE_OK);
assert(fabs(out.f[0] - 0.75) < 1e-9);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_node_get_input(op2, "opacity_in", &out) == OAKENGINE_OK);
assert(fabs(out.f[0] - 0.75) > 1e-9);
// Component type must match the declared type; bad track/id rejected.
oak_node_value wrong;
memset(&wrong, 0, sizeof(wrong));
wrong.type = OAK_NODE_VALUE_INT;
assert(oakengine_node_set_input_at_time(op2, "opacity_in", -1, 0, 0,
&wrong,
1) == OAKENGINE_E_INVALID);
assert(oakengine_node_set_input_at_time(op2, "opacity_in", -1, 0, 1, &v,
1) == OAKENGINE_E_INVALID);
assert(oakengine_node_set_input_at_time(op2, "nope", -1, 0, 0, &v,
1) == OAKENGINE_E_NOT_FOUND);
assert(oakengine_node_set_input_at_time(NULL, "opacity_in", -1, 0, 0, &v,
1) == OAKENGINE_E_INVALID);
// `opacity` is keyframed at this point (keys at 0/15/30 left by the
// earlier tests): the call writes a KEYFRAME at the time
// (set_value_at_time semantics) -- insert first, then update in place.
v = float_value(0.9);
assert(oakengine_node_set_input_at_time(opacity, "opacity_in", -1, 20, 0,
&v, 1) == OAKENGINE_OK);
assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 4);
int64_t ts = -1;
assert(oakengine_node_keyframe_at(opacity, "opacity_in", 2, &ts, &out) ==
OAKENGINE_OK);
assert(ts == 20 && fabs(out.f[0] - 0.9) < 1e-9);
v = float_value(0.8);
assert(oakengine_node_set_input_at_time(opacity, "opacity_in", -1, 20, 0,
&v, 1) == OAKENGINE_OK);
assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 4);
assert(oakengine_node_keyframe_at(opacity, "opacity_in", 2, &ts, &out) ==
OAKENGINE_OK);
assert(fabs(out.f[0] - 0.8) < 1e-9);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 3);
// track -1 writes all color components in one command (fresh node, so
// the standard value itself is written and readable back).
OakEngineNode *solid2 = oakengine_project_add_node(
project, "org.olivevideoeditor.Olive.solidgenerator");
assert(solid2 != NULL);
oak_node_value c;
memset(&c, 0, sizeof(c));
c.type = OAK_NODE_VALUE_COLOR;
c.f[0] = 0.1;
c.f[1] = 0.2;
c.f[2] = 0.3;
c.f[3] = 0.4;
assert(oakengine_node_set_input_at_time(solid2, "color_in", -1, 5, -1, &c,
0) == OAKENGINE_OK);
assert(oakengine_node_get_input(solid2, "color_in", &out) ==
OAKENGINE_OK);
assert(fabs(out.f[0] - 0.1) < 1e-6 && fabs(out.f[1] - 0.2) < 1e-6 &&
fabs(out.f[2] - 0.3) < 1e-6 && fabs(out.f[3] - 0.4) < 1e-6);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
// String-at-time on the text node; the POD path rejects strings and
// vice versa.
OakEngineNode *text = oakengine_project_add_node(
project, "org.olivevideoeditor.Olive.text3");
assert(text != NULL);
assert(oakengine_node_set_input_string_at_time(text, "text_in", -1, 0,
"hello") == OAKENGINE_OK);
assert(oakengine_node_set_input_string_at_time(text, "nope", -1, 0,
"x") == OAKENGINE_E_NOT_FOUND);
assert(oakengine_node_set_input_string_at_time(op2, "opacity_in", -1, 0,
"x") == OAKENGINE_E_INVALID);
assert(oakengine_node_set_input_at_time(text, "text_in", -1, 0, 0, &v,
1) == OAKENGINE_E_INVALID);
// Array insert/remove on the text node's args_in array.
assert(oakengine_node_array_insert_at(text, "args_in", 0) ==
OAKENGINE_OK);
assert(oakengine_node_array_insert_at(text, "args_in", 1) ==
OAKENGINE_OK);
assert(oakengine_node_array_remove_at(text, "args_in", 1) ==
OAKENGINE_OK);
assert(oakengine_node_array_remove_at(text, "args_in", 5) ==
OAKENGINE_E_NOT_FOUND);
assert(oakengine_node_array_remove_at(text, "args_in", 0) ==
OAKENGINE_OK);
assert(oakengine_node_array_insert_at(text, "args_in", -1) ==
OAKENGINE_E_INVALID);
assert(oakengine_node_array_remove_at(NULL, "args_in", 0) ==
OAKENGINE_E_INVALID);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_project_undo(project) == OAKENGINE_OK);
// keyframes_set_type_many: keys at 0 and 15 to hold in one command.
const int64_t times[2] = { 0, 15 };
const int tracks[2] = { 0, 0 };
assert(oakengine_node_keyframes_set_type_many(opacity, "opacity_in", -1,
times, tracks, 2, 2) == 2);
int type = -1;
assert(oakengine_node_keyframe_get_easing(opacity, "opacity_in", 0, NULL,
NULL, NULL, NULL,
&type) == OAKENGINE_OK);
assert(type == 2);
assert(oakengine_node_keyframe_get_easing(opacity, "opacity_in", 1, NULL,
NULL, NULL, NULL,
&type) == OAKENGINE_OK);
assert(type == 2);
// One undo restores each key's own previous easing (hold at 0, bezier
// at 15 -- left over from the earlier tests).
assert(oakengine_project_undo(project) == OAKENGINE_OK);
assert(oakengine_node_keyframe_get_easing(opacity, "opacity_in", 0, NULL,
NULL, NULL, NULL,
&type) == OAKENGINE_OK);
assert(type == 2);
assert(oakengine_node_keyframe_get_easing(opacity, "opacity_in", 1, NULL,
NULL, NULL, NULL,
&type) == OAKENGINE_OK);
assert(type == 1);
// A bad address fails without side effects; zero count is a no-op.
const int64_t bad[1] = { 99 };
assert(oakengine_node_keyframes_set_type_many(opacity, "opacity_in", -1,
bad, tracks, 1,
1) == OAKENGINE_E_NOT_FOUND);
assert(oakengine_node_keyframes_set_type_many(opacity, "opacity_in", -1,
times, tracks, 0, 1) == 0);
assert(oakengine_node_keyframes_set_type_many(NULL, "opacity_in", -1,
times, tracks, 1,
1) == OAKENGINE_E_INVALID);
assert(oakengine_node_keyframe_count(opacity, "opacity_in") == 3);
}
int main(void)
{
make_tmpdir();
@@ -295,6 +451,7 @@ int main(void)
test_float_lifecycle(project, opacity);
test_easing_and_remove(project, opacity);
test_rational_and_color(project, timeremap, solid);
test_panel_paths(project, opacity, solid);
oakengine_project_free(project);
assert(oakengine_shutdown() == OAKENGINE_OK);