feat(engine,app): effect chain facade + app effect stack wiring

facade (oakengine/oaknode, API only extended):
- oaknode_node_get_effect_input / oaknode_node_get_flags module exports
- oakengine_clip_as_node, oakengine_node_effect_count/at/insert/remove/
  move/set_enabled, oakengine_node_identity/is_enabled/get_type_id,
  oakengine_node_factory_id_at — chain edits wrap disconnect/reconnect
  into single undo groups; insert/move/remove validated against the
  factory and the host's effect input
- it_node covers enumeration/insert/remove/move/toggle/undo-redo and
  the NULL/illegal-input matrix

app:
- RealEngine binds the selected clip's effect chain to the inspector's
  effect stack (cards, enable, reorder, remove, expansion state), all
  edits undoable through the facade
- AppEngine gains set_selected_clips / addable_effects / add_effect
  (default no-ops keep the mock untouched); the timeline selection
  drives the stack target; the inspector gets a small add-effect menu
- known module gap (documented): ClipBlockBehavior sets no effect_input
  yet, so timeline clips cannot host effects until oaknode grows tex_in
This commit is contained in:
2026-08-11 20:04:44 +08:00
parent f86a895c8a
commit cdda643d64
10 changed files with 1443 additions and 28 deletions
+12
View File
@@ -269,6 +269,18 @@ pub fn oaknode_node_set_enabled_undoable(node: CHandle, enabled: c_int, out_comm
unsafe { oaknode::ffi::node::oaknode_node_set_enabled_undoable(node, enabled, out_command) }
}
/// Direct call into the `oaknode` crate (single-lib unification; the
/// `#[no_mangle]` export stays for the external C ABI).
pub fn oaknode_node_get_effect_input(node: CHandle, buf: *mut c_char, buf_size: c_int) -> c_int {
unsafe { oaknode::ffi::node::oaknode_node_get_effect_input(node, buf, buf_size) }
}
/// Direct call into the `oaknode` crate (single-lib unification; the
/// `#[no_mangle]` export stays for the external C ABI).
pub fn oaknode_node_get_flags(node: CHandle) -> u64 {
unsafe { oaknode::ffi::node::oaknode_node_get_flags(node) }
}
/// Direct call into the `oaknode` crate (single-lib unification; the
/// `#[no_mangle]` export stays for the external C ABI).
pub fn oaknode_node_input_count(node: CHandle, out_count: *mut c_int) -> c_int {
+629 -10
View File
@@ -1322,6 +1322,23 @@ pub unsafe extern "C" fn oakengine_node_factory_node_at(index: c_int) -> *mut Oa
})
}
/// `oakengine_node_factory_id_at` — type id at `index` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_factory_id_at(
index: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
guard_int(|| unsafe {
let rc = n::oaknode_factory_id_at(index, buf, buf_size);
if rc < 0 {
Err(Error::Module(rc))
} else {
Ok(string_result(rc))
}
})
}
/// `oakengine_node_category_count`.
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_category_count(self_: *const OakEngineNode) -> c_int {
@@ -1356,9 +1373,9 @@ pub unsafe extern "C" fn oakengine_node_category_at(
/// `oakengine_node_get_flags`.
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_get_flags(self_: *const OakEngineNode) -> u64 {
// Stub: the oaknode module has no per-node flags export. NULL and
// empty (null-ctx) handle boxes both report 0 — the `guard_i64`
// error sentinel would otherwise surface as u64::MAX to C callers.
// NULL and empty (null-ctx) handle boxes both report 0 — the
// `guard_i64` error sentinel would otherwise surface as u64::MAX to C
// callers.
crate::handle::guard_i64(|| unsafe {
if self_.is_null() {
return Ok(0);
@@ -1366,7 +1383,7 @@ pub unsafe extern "C" fn oakengine_node_get_flags(self_: *const OakEngineNode) -
if (*self_).handle.is_null() {
return Ok(0);
}
Ok(0)
Ok(n::oaknode_node_get_flags(unbox(self_)?) as i64)
}) as u64
}
@@ -3723,10 +3740,63 @@ pub unsafe extern "C" fn oakengine_node_set_context_expanded(
}
// ---------------------------------------------------------------------------
// node.h — effect input
// node.h — effect input / effect chain
// ---------------------------------------------------------------------------
/// `oakengine_node_get_effect_input`.
/// Owns a set of borrowed module node handles, releasing each shell on
/// drop (error paths never leak). Extracted handles are replaced with
/// NULL (a no-op release).
struct HandleGuard(Vec<CHandle>);
impl HandleGuard {
/// Wrap a handle vector.
fn new(v: Vec<CHandle>) -> Self {
Self(v)
}
/// The number of held handles.
fn len(&self) -> usize {
self.0.len()
}
/// A borrowed copy of the `i`-th handle.
fn get(&self, i: usize) -> CHandle {
self.0[i]
}
/// Take the `i`-th handle out of the guard (the caller owns it now).
fn take(&mut self, i: usize) -> CHandle {
let h = self.0[i];
self.0[i] = CHandle::null();
h
}
/// Release ownership of every held handle (into the caller's hands).
fn into_inner(mut self) -> Vec<CHandle> {
let out = std::mem::take(&mut self.0);
std::mem::forget(self);
out
}
}
impl Drop for HandleGuard {
fn drop(&mut self) {
for h in &self.0 {
release_handle(*h);
}
}
}
/// Release a module handle shell (NULL and empty handles are no-ops).
fn release_handle(h: CHandle) {
if let Some(release) = h.release {
unsafe { release(h.ctx) };
}
}
/// `oakengine_node_get_effect_input` — the id of the input the effect
/// chain attaches to (C++ `Node::GetEffectInputID`). Empty when the node
/// cannot host effects; the facade reports that as `E_NOT_FOUND`.
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_get_effect_input(
self_: *const OakEngineNode,
@@ -3734,14 +3804,563 @@ pub unsafe extern "C" fn oakengine_node_get_effect_input(
input_id_size: c_int,
element: *mut c_int,
) -> c_int {
// Stub: the oaknode module has no effect-input export.
guard_int(|| unsafe {
if self_.is_null() {
return Err(Error::Invalid);
}
let _ = unbox(self_)?;
let _ = (input_id, input_id_size, element);
Err(Error::NotFound)
let h = unbox(self_)?;
if !element.is_null() {
*element = -1;
}
let id = effect_input_of(h)?;
if id.is_empty() {
return Err(Error::NotFound);
}
Ok(write_string(&id, input_id, input_id_size))
})
}
/// The effect-input id of `node`, or `""` when the node cannot host
/// effects (C++ `GetEffectInputID`).
fn effect_input_of(node: CHandle) -> Result<String> {
unsafe { module_string(|buf, size| n::oaknode_node_get_effect_input(node, buf, size)) }
}
/// The node feeding `node`'s input `input_id` (borrowed handle; caller
/// releases), or `None` when the input is unconnected.
fn connected_node(node: CHandle, input_id: &str) -> Result<Option<CHandle>> {
unsafe {
let cid = std::ffi::CString::new(input_id)
.map_err(|_| Error::Failed("invalid input id".into()))?;
let mut out = CHandle::null();
let rc = n::oaknode_node_input_get_connected_node(node, cid.as_ptr(), &mut out);
if rc != 0 {
// An input that does not exist on the node is a chain-structure
// error; an unconnected input returns OK with a NULL handle.
return Err(Error::Module(rc));
}
Ok(if out.is_null() { None } else { Some(out) })
}
}
/// The effect chain of `node`, **closest-to-source first** (signal order:
/// the first element feeds the media side, the last feeds `node`'s effect
/// input). Every returned handle is a borrowed shell the caller must
/// release. The walk follows each node's effect input upstream until an
/// unconnected input or a node without an effect input; a `seen` guard
/// protects against malformed cycles.
fn effect_chain(node: CHandle) -> Result<Vec<CHandle>> {
let mut chain: HandleGuard = HandleGuard::new(Vec::new());
let mut cur = node;
let mut seen: Vec<usize> = Vec::new();
loop {
let id = unsafe { n::oaknode_node_identity(cur) };
if seen.contains(&id) {
break;
}
seen.push(id);
let input = effect_input_of(cur)?;
if input.is_empty() {
break;
}
let Some(up) = connected_node(cur, &input)? else {
break;
};
chain.0.push(up);
cur = up;
}
// The walk collects host-upstream (last effect first); reverse to get
// signal order.
chain.0.reverse();
Ok(chain.into_inner())
}
/// `oakengine_node_effect_count` — the length of `self_`'s effect chain
/// (0 when the node cannot host effects or nothing is attached).
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_effect_count(self_: *const OakEngineNode) -> c_int {
guard_int(|| unsafe {
if self_.is_null() {
return Ok(0);
}
let h = unbox(self_)?;
Ok(effect_chain(h)?.len() as c_int)
})
}
/// `oakengine_node_effect_at` — the `index`-th effect of `self_`'s chain
/// (borrowed handle; index 0 = closest to the source). NULL when out of
/// range or the node hosts no effects.
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_effect_at(
self_: *const OakEngineNode,
index: c_int,
) -> *mut OakEngineNode {
guard_ptr(|| unsafe {
if self_.is_null() || index < 0 {
return Ok(std::ptr::null_mut());
}
let h = unbox(self_)?;
let mut chain = HandleGuard::new(effect_chain(h)?);
if (index as usize) >= chain.len() {
return Ok(std::ptr::null_mut());
}
Ok(box_handle::<OakEngineNode>(chain.take(index as usize)))
})
}
/// `oakengine_node_is_enabled` — 1/0 (the node's `enabled_in` flag; the
/// effect stack's enable toggle).
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_is_enabled(self_: *const OakEngineNode) -> c_int {
guard_int(|| unsafe {
if self_.is_null() {
return Ok(0);
}
let mut value: c_int = 0;
Error::from_module(n::oaknode_node_is_enabled(unbox(self_)?, &mut value))?;
Ok(value)
})
}
/// `oakengine_node_identity` — the node's stable identity (the effect
/// stack uses it as the card id across frames). 0 for NULL/invalid.
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_identity(self_: *const OakEngineNode) -> u64 {
crate::handle::guard_i64(|| unsafe {
if self_.is_null() {
return Ok(0);
}
let h = unbox(self_)?;
Ok(n::oaknode_node_identity(h) as i64)
}) as u64
}
/// `oakengine_node_effect_set_enabled` — undoable enable toggle of an
/// effect node (the stack's enable switch; `enabled_in` flag).
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_effect_set_enabled(
self_: *mut OakEngineNode,
enabled: c_int,
) -> c_int {
guard(|| unsafe {
if self_.is_null() {
set_node_error("invalid arguments");
return Err(Error::Invalid);
}
let h = unbox(self_)?;
let mut cmd: CHandle = CHandle::null();
let rc = n::oaknode_node_set_enabled_undoable(h, enabled, &mut cmd);
if rc != 0 {
return Err(Error::Module(rc));
}
push_command(cmd, "Toggle Effect")
})
}
/// `oakengine_node_effect_insert` — undoable insertion of a new effect of
/// `type_id` at chain position `index` (0 = closest to the source, `len`
/// = closest to the host; out-of-range indices clamp to the ends). The
/// node is created from the factory, added to the host's project, and
/// wired into the chain — one undo row for the whole edit.
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_effect_insert(
self_: *mut OakEngineNode,
index: c_int,
type_id: *const c_char,
) -> c_int {
guard(|| unsafe {
if self_.is_null() || type_id.is_null() {
set_node_error("invalid arguments");
return Err(Error::Invalid);
}
let host = unbox(self_)?;
node_effect_insert_impl(host, index, type_id)
})
}
/// The insert implementation (see `oakengine_node_effect_insert`).
///
/// # Safety
/// `host` must be a live module node handle; `type_id` a NUL-terminated
/// C string.
unsafe fn node_effect_insert_impl(host: CHandle, index: c_int, type_id: *const c_char) -> Result<()> {
unsafe {
// The host must be able to host effects.
let host_input = effect_input_of(host)?;
if host_input.is_empty() {
set_node_error("node cannot host effects (no effect input)");
return Err(Error::NotFound);
}
// The new effect node (owned scratch handle; freed below).
let mut new_node = n::oaknode_factory_create_from_id(type_id);
if new_node.ctx.is_null() {
set_node_error(&format!("unknown node type id \"{}\"", read_cstr(type_id)));
return Err(Error::Failed("unknown node type".into()));
}
let new_input = match effect_input_of(new_node) {
Ok(id) if !id.is_empty() => id,
_ => {
// A node without an effect input cannot sit in the chain.
set_node_error("node type has no effect input; cannot be chained");
n::oaknode_node_free(&mut new_node);
return Err(Error::Invalid);
}
};
// The chain (closest-to-source first) and the neighbors of the
// insertion point.
let chain = HandleGuard::new(effect_chain(host)?);
let len = chain.len();
let pos = (index.max(0) as usize).min(len);
let (upstream, downstream) = if pos == 0 {
if len == 0 {
(None, host)
} else {
let d = chain.get(0);
(connected_node(d, &effect_input_of(d)?)?, d)
}
} else if pos == len {
(Some(chain.get(len - 1)), host)
} else {
(Some(chain.get(pos - 1)), chain.get(pos))
};
let downstream_input = effect_input_of(downstream)?;
let upstream_input = upstream
.map(|u| effect_input_of(u))
.transpose()?
.unwrap_or_default();
let project = project_of(host)?;
// The identities present before the add; the moved node is the one
// whose identity is new afterwards (its id may be reallocated on a
// slot collision — see the module's `Graph::add_entry`).
let existing: Vec<usize> = (0..n::oaknode_project_node_count(project))
.filter_map(|i| {
let other = n::oaknode_project_node_at(project, i);
if other.is_null() {
None
} else {
let id = n::oaknode_node_identity(other);
if id == 0 {
None
} else {
Some(id)
}
}
})
.collect();
// One undo row for the whole edit: open a group, push the add-node
// command and the rewiring commands into it, then close it. On any
// failure the group is aborted (executed children are undone).
let name = std::ffi::CString::new("Add Effect")
.map_err(|_| Error::Failed("invalid undo name".into()))?;
let begin = crate::undo::oakengine_undo_group_begin(name.as_ptr());
if begin != 0 {
set_node_error("failed to open an undo group");
n::oaknode_node_free(&mut new_node);
return Err(Error::Module(begin));
}
macro_rules! step {
($e:expr) => {
match $e {
Ok(()) => {}
Err(e) => {
crate::undo::oakengine_undo_group_abort();
n::oaknode_node_free(&mut new_node);
return Err(e);
}
}
};
}
// 1. Add the node to the project (the group child executes eagerly).
let add_cmd = n::oaknode_command_create_add_node(project, new_node);
if add_cmd.ctx.is_null() {
set_node_error("add-node command failed");
step!(Err(Error::Failed("add-node command failed".into())));
}
step!(push_command(add_cmd, "Add Effect"));
// 2. A fresh project-borrowed view of the moved node (the one whose
// identity was not present before the add).
let total = n::oaknode_project_node_count(project);
let mut fresh = CHandle::null();
for i in 0..total {
let other = n::oaknode_project_node_at(project, i);
if other.is_null() {
continue;
}
let id = n::oaknode_node_identity(other);
if id != 0 && !existing.contains(&id) {
fresh = other;
break;
}
}
if fresh.ctx.is_null() {
set_node_error("could not resolve the added node");
step!(Err(Error::Failed("added node resolution failed".into())));
}
// 3. Unhook the downstream input from its current upstream (when
// there is one).
if upstream.is_some() {
let mut cmd = CHandle::null();
let cid = std::ffi::CString::new(downstream_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
let rc = n::oaknode_node_disconnect_undoable(downstream, cid.as_ptr(), &mut cmd);
step!(Error::from_module(rc).and_then(|()| push_command(cmd, "Add Effect")));
}
// 4. Wire the new effect between upstream and downstream.
let dcid = std::ffi::CString::new(downstream_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
let mut cmd = CHandle::null();
let rc = n::oaknode_node_connect_undoable(fresh, downstream, dcid.as_ptr(), &mut cmd);
step!(Error::from_module(rc).and_then(|()| push_command(cmd, "Add Effect")));
if let Some(upstream) = upstream {
let ucid = std::ffi::CString::new(upstream_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
let mut cmd = CHandle::null();
let rc = n::oaknode_node_connect_undoable(upstream, fresh, ucid.as_ptr(), &mut cmd);
step!(Error::from_module(rc).and_then(|()| push_command(cmd, "Add Effect")));
}
let end = crate::undo::oakengine_undo_group_end();
// Cleanup: the factory shell is a stale view (the node now lives in
// the project graph); the fresh view is a borrowed shell. The chain
// guard releases the rest.
n::oaknode_node_free(&mut new_node);
release_handle(fresh);
if end != 0 {
return Err(Error::Module(end));
}
Ok(())
}
}
/// `oakengine_node_effect_remove` — undoable removal of `effect` (a node
/// in `self_`'s chain): unhook both edges and bridge the gap, one undo
/// row. The node itself is left orphaned in the project graph: the module
/// node-transfer commands are one-way (undo discards the entry), so a
/// reversible detach does not exist there yet — documented limitation
/// (a future module command can take the node out of the project while
/// keeping its entry restorable).
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_effect_remove(
self_: *mut OakEngineNode,
effect: *mut OakEngineNode,
) -> c_int {
guard(|| unsafe {
if self_.is_null() || effect.is_null() {
set_node_error("invalid arguments");
return Err(Error::Invalid);
}
let host = unbox(self_)?;
let eff = unbox(effect)?;
let eff_identity = n::oaknode_node_identity(eff);
let chain = HandleGuard::new(effect_chain(host)?);
let len = chain.len();
let Some(pos) = chain
.0
.iter()
.position(|c| n::oaknode_node_identity(*c) == eff_identity)
else {
return Err(Error::NotFound);
};
let upstream = if pos == 0 {
connected_node(chain.get(0), &effect_input_of(chain.get(0))?)?
} else {
Some(chain.get(pos - 1))
};
let downstream = if pos + 1 == len { host } else { chain.get(pos + 1) };
let downstream_input = effect_input_of(downstream)?;
// All nodes are already in the project: a plain multi command.
let mut children: Vec<CHandle> = Vec::new();
// 1. Unhook the effect from its upstream.
let eff_input = effect_input_of(eff)?;
if upstream.is_some() {
let mut cmd = CHandle::null();
let cid = std::ffi::CString::new(eff_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
Error::from_module(n::oaknode_node_disconnect_undoable(
eff,
cid.as_ptr(),
&mut cmd,
))?;
children.push(cmd);
}
// 2. Unhook the downstream from the effect, then bridge it back to
// the upstream.
let mut cmd = CHandle::null();
let cid = std::ffi::CString::new(downstream_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
Error::from_module(n::oaknode_node_disconnect_undoable(
downstream,
cid.as_ptr(),
&mut cmd,
))?;
children.push(cmd);
if let Some(upstream) = upstream {
let mut cmd = CHandle::null();
let cid = std::ffi::CString::new(downstream_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
Error::from_module(n::oaknode_node_connect_undoable(
upstream,
downstream,
cid.as_ptr(),
&mut cmd,
))?;
children.push(cmd);
}
// The guard releases every command handle on error paths; on success
// `push_multi_commands` consumes them all.
let children = HandleGuard::new(children);
push_multi_commands(&children.into_inner(), std::ptr::null_mut(), "Remove Effect")
})
}
/// `oakengine_node_effect_move` — undoable reorder of `effect` to chain
/// position `new_index` (an insertion index **after** removal, matching
/// the effect stack's `ReorderRequested`; `0..=len-1` where `len` is the
/// post-removal chain length). One undo row.
#[no_mangle]
pub unsafe extern "C" fn oakengine_node_effect_move(
self_: *mut OakEngineNode,
effect: *mut OakEngineNode,
new_index: c_int,
) -> c_int {
guard(|| unsafe {
if self_.is_null() || effect.is_null() {
set_node_error("invalid arguments");
return Err(Error::Invalid);
}
let host = unbox(self_)?;
let eff = unbox(effect)?;
let eff_identity = n::oaknode_node_identity(eff);
let chain = HandleGuard::new(effect_chain(host)?);
let len = chain.len();
let Some(from) = chain
.0
.iter()
.position(|c| n::oaknode_node_identity(*c) == eff_identity)
else {
return Err(Error::NotFound);
};
// The post-removal insertion index (0..=len-1; clamp for safety).
let to = if new_index < 0 {
0
} else {
(new_index as usize).min(len - 1)
};
// The guard releases every command handle on error paths; on success
// `push_multi_commands` consumes them all.
let mut children: HandleGuard = HandleGuard::new(Vec::new());
// ---- remove the effect from position `from` ---------------------
let upstream = if from == 0 {
connected_node(chain.get(0), &effect_input_of(chain.get(0))?)?
} else {
Some(chain.get(from - 1))
};
let downstream = if from + 1 == len { host } else { chain.get(from + 1) };
let downstream_input = effect_input_of(downstream)?;
let eff_input = effect_input_of(eff)?;
if upstream.is_some() {
let mut cmd = CHandle::null();
let cid = std::ffi::CString::new(eff_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
Error::from_module(n::oaknode_node_disconnect_undoable(
eff,
cid.as_ptr(),
&mut cmd,
))?;
children.0.push(cmd);
}
let mut cmd = CHandle::null();
let cid = std::ffi::CString::new(downstream_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
Error::from_module(n::oaknode_node_disconnect_undoable(
downstream,
cid.as_ptr(),
&mut cmd,
))?;
children.0.push(cmd);
if let Some(upstream) = upstream {
let mut cmd = CHandle::null();
let cid = std::ffi::CString::new(downstream_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
Error::from_module(n::oaknode_node_connect_undoable(
upstream,
downstream,
cid.as_ptr(),
&mut cmd,
))?;
children.0.push(cmd);
}
// ---- reinsert at position `to` (the post-removal chain) ---------
// After removal the chain has `len - 1` effects; position `to`
// (0..=len-1) sits between index `to - 1` and `to` of the remaining
// list, where index `-1` is the chain source and index `len-1` is
// the host.
let remaining: Vec<CHandle> = chain
.0
.iter()
.enumerate()
.filter(|(i, _)| *i != from)
.map(|(_, c)| *c)
.collect();
let (up2, down2) = if to == 0 {
if remaining.is_empty() {
(None, host)
} else {
let d = remaining[0];
(connected_node(d, &effect_input_of(d)?)?, d)
}
} else if to == len - 1 {
(Some(remaining[len - 2]), host)
} else {
(Some(remaining[to - 1]), remaining[to])
};
let down2_input = effect_input_of(down2)?;
let up2_input = up2
.map(|u| effect_input_of(u))
.transpose()?
.unwrap_or_default();
if up2.is_some() {
let mut cmd = CHandle::null();
let cid = std::ffi::CString::new(down2_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
Error::from_module(n::oaknode_node_disconnect_undoable(
down2,
cid.as_ptr(),
&mut cmd,
))?;
children.0.push(cmd);
}
let mut cmd = CHandle::null();
let cid = std::ffi::CString::new(down2_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
Error::from_module(n::oaknode_node_connect_undoable(
eff,
down2,
cid.as_ptr(),
&mut cmd,
))?;
children.0.push(cmd);
if let Some(up2) = up2 {
let mut cmd = CHandle::null();
let cid = std::ffi::CString::new(up2_input.as_str())
.map_err(|_| Error::Failed("invalid input id".into()))?;
Error::from_module(n::oaknode_node_connect_undoable(
up2,
eff,
cid.as_ptr(),
&mut cmd,
))?;
children.0.push(cmd);
}
push_multi_commands(&children.into_inner(), std::ptr::null_mut(), "Reorder Effect")
})
}
+19
View File
@@ -2041,6 +2041,25 @@ pub unsafe extern "C" fn oakengine_clip_get_sequence(
})
}
/// `oakengine_clip_as_node` — the clip's node view (borrowed; freed with
/// `oakengine_node_free`). The effect-stack surface (chain enumeration and
/// edits) is node-based, so the app converts its clip handle before
/// walking the chain.
#[no_mangle]
pub unsafe extern "C" fn oakengine_clip_as_node(self_: *const OakEngineClip) -> *mut OakEngineNode {
guard_ptr(|| unsafe {
if self_.is_null() {
return Ok(std::ptr::null_mut());
}
let h = unbox(self_)?;
let node = n::oaknode_block_as_node(h);
if node.is_null() {
return Ok(std::ptr::null_mut());
}
Ok(box_handle::<OakEngineNode>(node))
})
}
/* ---- Editing primitives, round 2: split / ripple delete / trim / move ---- */
/// `oakengine_sequence_split_clip` — split the addressed clip at `time`.
+171
View File
@@ -54,6 +54,7 @@ use oakengine::handle::{
};
use oakengine::node::*;
use oakengine::node::value_type as vt;
use oakengine::timeline::oakengine_clip_as_node;
use oakengine::undo::oakengine_undo_command_free;
use oaknode::ffi::dragger::oaknode_dragger_free;
use oaknode::ffi::factory::oaknode_factory_create_from_id;
@@ -66,6 +67,7 @@ use oaknode::ffi::project::oaknode_project_free;
/// Facade error codes (src/error.rs).
const E_INVALID: c_int = -1;
const E_STATE: c_int = -2;
const E_FAILED: c_int = -3;
const E_NOT_FOUND: c_int = -4;
/// Module error codes (include/node/error.h) — passed through untranslated.
const NODE_E_INVALID: c_int = -30001;
@@ -81,6 +83,8 @@ const TYPE_TEXT: &std::ffi::CStr = c"org.olivevideoeditor.Olive.textgenerator";
const TYPE_GROUP: &std::ffi::CStr = c"org.olivevideoeditor.Olive.group";
const TYPE_MULTICAM: &std::ffi::CStr = c"org.olivevideoeditor.Olive.multicam";
const TYPE_FOOTAGE: &std::ffi::CStr = c"org.olivevideoeditor.Olive.footage";
const TYPE_BLUR: &std::ffi::CStr = c"org.olivevideoeditor.Olive.blur";
const TYPE_OPACITY: &std::ffi::CStr = c"org.olivevideoeditor.Olive.opacity";
// ---------------------------------------------------------------------------
// Fixtures
@@ -1469,6 +1473,173 @@ fn node_family_legal_paths() {
});
}
// ---------------------------------------------------------------------------
// Effect chain surface (node.h effect-input family)
// ---------------------------------------------------------------------------
/// Effect chain enumeration and edits over the facade: insert at index,
/// remove, reorder, enable toggle — each a single undoable row — plus the
/// NULL and illegal-input matrix of the new exports. The chain semantics
/// mirror the C++ original (`Node::GetEffectInputID` / `GetConnectedOutput`
/// walk): effects attach to the host's effect input, ordered closest-to-
/// source first.
#[test]
fn node_effect_chain_edits() {
with_owned(|| {
common::force_link();
let _ = force_oakundo_command_link();
let base = alive();
let mut buf = [0 as c_char; 512];
let mut element: c_int = 0;
// ---- fixtures: a transform host + a plain value node ---------------
let project = oakengine_project_create();
assert!(!project.is_null());
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let host = unsafe { oakengine_project_add_node(project, TYPE_TRANSFORM.as_ptr()) };
assert!(!host.is_null());
let value = unsafe { oakengine_project_add_node(project, TYPE_VALUE.as_ptr()) };
assert!(!value.is_null());
// The transform's effect input is "tex_in".
let len = unsafe { oakengine_node_get_effect_input(host, buf.as_mut_ptr(), 512, &mut element) };
assert_eq!(len, "tex_in".len() as c_int);
assert_eq!(unsafe { read_buf(&mut buf) }, "tex_in");
assert_eq!(element, -1, "non-array input element");
// A node without an effect input cannot host effects.
assert_eq!(
unsafe { oakengine_node_get_effect_input(value, buf.as_mut_ptr(), 512, &mut element) },
E_NOT_FOUND
);
// ---- enumeration on an empty chain ---------------------------------
assert_eq!(unsafe { oakengine_node_effect_count(host) }, 0);
assert!(unsafe { oakengine_node_effect_at(host, 0) }.is_null());
// ---- insert three effects: opacity, blur, transform -----------------
// Inserting at 0 twice stacks each new effect closest to the source;
// inserting at len appends next to the host.
assert_eq!(unsafe { oakengine_node_effect_insert(host, 0, TYPE_BLUR.as_ptr()) }, 0);
eprintln!("[dbg] count after blur insert: {}", unsafe { oakengine_node_effect_count(host) });
assert_eq!(unsafe { oakengine_node_effect_insert(host, 0, TYPE_OPACITY.as_ptr()) }, 0);
eprintln!("[dbg] count after opacity insert: {}", unsafe { oakengine_node_effect_count(host) });
assert_eq!(unsafe { oakengine_node_effect_insert(host, 2, TYPE_TRANSFORM.as_ptr()) }, 0);
eprintln!("[dbg] count after transform insert: {}", unsafe { oakengine_node_effect_count(host) });
assert_eq!(unsafe { oakengine_node_effect_count(host) }, 3);
let eff0 = unsafe { oakengine_node_effect_at(host, 0) };
let eff1 = unsafe { oakengine_node_effect_at(host, 1) };
let eff2 = unsafe { oakengine_node_effect_at(host, 2) };
assert!(!eff0.is_null() && !eff1.is_null() && !eff2.is_null());
assert!(unsafe { oakengine_node_effect_at(host, -1) }.is_null());
assert!(unsafe { oakengine_node_effect_at(host, 99) }.is_null());
// Signal order: opacity (source side) → blur → transform (host side).
assert_eq!(unsafe { oakengine_node_get_type_id(eff0, buf.as_mut_ptr(), 512) }, TYPE_OPACITY.to_bytes().len() as c_int);
assert_eq!(unsafe { read_buf(&mut buf) }, TYPE_OPACITY.to_str().unwrap());
assert_eq!(unsafe { oakengine_node_get_type_id(eff1, buf.as_mut_ptr(), 512) }, TYPE_BLUR.to_bytes().len() as c_int);
assert_eq!(unsafe { read_buf(&mut buf) }, TYPE_BLUR.to_str().unwrap());
assert_eq!(unsafe { oakengine_node_get_type_id(eff2, buf.as_mut_ptr(), 512) }, TYPE_TRANSFORM.to_bytes().len() as c_int);
assert_eq!(unsafe { read_buf(&mut buf) }, TYPE_TRANSFORM.to_str().unwrap());
// Effect nodes carry the video-effect flag; plain nodes do not.
assert_ne!(unsafe { oakengine_node_get_flags(eff0) } & oakengine_node_flag_video_effect(), 0);
assert_ne!(unsafe { oakengine_node_get_flags(eff1) } & oakengine_node_flag_video_effect(), 0);
assert_eq!(unsafe { oakengine_node_get_flags(value) } & oakengine_node_flag_video_effect(), 0);
// Stable identities (the stack's card ids) are non-zero.
let eff1_identity = unsafe { oakengine_node_identity(eff1) };
assert!(eff1_identity > 0);
// ---- enable toggle --------------------------------------------------
assert_eq!(unsafe { oakengine_node_is_enabled(eff1) }, 1);
assert_eq!(unsafe { oakengine_node_effect_set_enabled(eff1, 0) }, 0);
assert_eq!(unsafe { oakengine_node_is_enabled(eff1) }, 0);
assert_eq!(unsafe { oakengine_node_effect_set_enabled(eff1, 1) }, 0);
assert_eq!(unsafe { oakengine_node_is_enabled(eff1) }, 1);
// ---- reorder: move blur (index 1) to the end -----------------------
assert_eq!(unsafe { oakengine_node_effect_move(host, eff1, 2) }, 0);
assert_eq!(unsafe { oakengine_node_get_type_id(unsafe { oakengine_node_effect_at(host, 0) }, buf.as_mut_ptr(), 512) }, TYPE_OPACITY.to_bytes().len() as c_int);
assert_eq!(unsafe { oakengine_node_get_type_id(unsafe { oakengine_node_effect_at(host, 1) }, buf.as_mut_ptr(), 512) }, TYPE_TRANSFORM.to_bytes().len() as c_int);
assert_eq!(unsafe { oakengine_node_get_type_id(unsafe { oakengine_node_effect_at(host, 2) }, buf.as_mut_ptr(), 512) }, TYPE_BLUR.to_bytes().len() as c_int);
// ---- remove transform (now at index 1) ------------------------------
let t_at_1 = unsafe { oakengine_node_effect_at(host, 1) };
assert!(!t_at_1.is_null());
assert_eq!(unsafe { oakengine_node_effect_remove(host, t_at_1) }, 0);
assert_eq!(unsafe { oakengine_node_effect_count(host) }, 2);
assert_eq!(unsafe { oakengine_node_get_type_id(unsafe { oakengine_node_effect_at(host, 0) }, buf.as_mut_ptr(), 512) }, TYPE_OPACITY.to_bytes().len() as c_int);
assert_eq!(unsafe { oakengine_node_get_type_id(unsafe { oakengine_node_effect_at(host, 1) }, buf.as_mut_ptr(), 512) }, TYPE_BLUR.to_bytes().len() as c_int);
// ---- the edit is one undo row: undo/redo restores the chain --------
assert_eq!(unsafe { oakengine_project_undo(project) }, 0);
assert_eq!(unsafe { oakengine_node_effect_count(host) }, 3);
assert_eq!(unsafe { oakengine_node_get_type_id(unsafe { oakengine_node_effect_at(host, 0) }, buf.as_mut_ptr(), 512) }, TYPE_OPACITY.to_bytes().len() as c_int);
assert_eq!(unsafe { oakengine_node_get_type_id(unsafe { oakengine_node_effect_at(host, 1) }, buf.as_mut_ptr(), 512) }, TYPE_TRANSFORM.to_bytes().len() as c_int);
assert_eq!(unsafe { oakengine_node_get_type_id(unsafe { oakengine_node_effect_at(host, 2) }, buf.as_mut_ptr(), 512) }, TYPE_BLUR.to_bytes().len() as c_int);
assert_eq!(unsafe { oakengine_project_redo(project) }, 0);
assert_eq!(unsafe { oakengine_node_effect_count(host) }, 2);
// ---- out-of-range insert clamps to the host side -------------------
assert_eq!(unsafe { oakengine_node_effect_insert(host, 99, TYPE_BLUR.as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_node_effect_count(host) }, 3);
assert_eq!(unsafe { oakengine_node_get_type_id(unsafe { oakengine_node_effect_at(host, 2) }, buf.as_mut_ptr(), 512) }, TYPE_BLUR.to_bytes().len() as c_int);
// ---- illegal inputs -------------------------------------------------
assert_eq!(unsafe { oakengine_node_effect_insert(std::ptr::null_mut(), 0, TYPE_BLUR.as_ptr()) }, E_INVALID);
assert_eq!(unsafe { oakengine_node_effect_insert(host, 0, std::ptr::null()) }, E_INVALID);
assert_eq!(unsafe { oakengine_node_effect_insert(host, 0, c"org.oak.no.such.node".as_ptr()) }, E_FAILED, "unknown factory id");
assert_eq!(unsafe { oakengine_node_effect_insert(host, 0, TYPE_VALUE.as_ptr()) }, E_INVALID, "a node without an effect input cannot be chained");
assert_eq!(unsafe { oakengine_node_effect_insert(value, 0, TYPE_BLUR.as_ptr()) }, E_NOT_FOUND, "a host without an effect input hosts no chain");
assert_eq!(unsafe { oakengine_node_effect_remove(std::ptr::null_mut(), eff1) }, E_INVALID);
assert_eq!(unsafe { oakengine_node_effect_remove(host, std::ptr::null_mut()) }, E_INVALID);
assert_eq!(unsafe { oakengine_node_effect_remove(host, value) }, E_NOT_FOUND, "not a member of the chain");
assert_eq!(unsafe { oakengine_node_effect_move(std::ptr::null_mut(), eff1, 0) }, E_INVALID);
assert_eq!(unsafe { oakengine_node_effect_move(host, std::ptr::null_mut(), 0) }, E_INVALID);
assert_eq!(unsafe { oakengine_node_effect_move(host, value, 0) }, E_NOT_FOUND, "not a member of the chain");
assert_eq!(unsafe { oakengine_node_effect_set_enabled(std::ptr::null_mut(), 0) }, E_INVALID);
assert_eq!(unsafe { oakengine_node_is_enabled(std::ptr::null_mut()) }, 0);
assert_eq!(unsafe { oakengine_node_identity(std::ptr::null_mut()) }, 0);
assert_eq!(unsafe { oakengine_node_effect_count(std::ptr::null_mut()) }, 0);
assert!(unsafe { oakengine_node_effect_at(std::ptr::null_mut(), 0) }.is_null());
assert_eq!(unsafe { oakengine_node_get_effect_input(std::ptr::null_mut(), buf.as_mut_ptr(), 512, &mut element) }, E_INVALID);
assert_eq!(unsafe { oakengine_node_get_flags(std::ptr::null_mut()) }, 0);
// ---- factory id enumeration + clip → node conversion ---------------
let factory_count = oakengine_node_factory_id_count();
assert!(factory_count > 0);
let len = unsafe { oakengine_node_factory_id_at(0, buf.as_mut_ptr(), 512) };
assert!(len > 0);
let first_id = unsafe { read_buf(&mut buf) };
let name_len = unsafe { oakengine_node_factory_name_from_id(first_id.as_ptr() as *const c_char, buf.as_mut_ptr(), 512) };
assert!(name_len > 0, "factory name resolves for an enumerated id");
assert_eq!(unsafe { oakengine_node_factory_id_at(-1, buf.as_mut_ptr(), 512) }, NODE_E_NOT_FOUND);
assert_eq!(unsafe { oakengine_node_factory_id_at(factory_count, buf.as_mut_ptr(), 512) }, NODE_E_NOT_FOUND);
// `oakengine_clip_as_node` converts a clip box to its node view (the
// engine opaque types share one CHandle layout; a plain node box
// exercises the same conversion path).
let clip_view = value as *mut oakengine::handle::OakEngineClip;
let as_node = unsafe { oakengine_clip_as_node(clip_view) };
assert!(!as_node.is_null());
assert_eq!(unsafe { oakengine_node_get_type_id(as_node, buf.as_mut_ptr(), 512) }, TYPE_VALUE.to_bytes().len() as c_int);
assert!(unsafe { oakengine_clip_as_node(std::ptr::null_mut()) }.is_null());
// ---- cleanup --------------------------------------------------------
unsafe { oakengine_node_free(eff0) };
unsafe { oakengine_node_free(eff1) };
unsafe { oakengine_node_free(eff2) };
unsafe { oakengine_node_free(t_at_1) };
unsafe { oakengine_node_free(as_node) };
unsafe { oakengine_node_free(host) };
unsafe { oakengine_node_free(value) };
unsafe { oakengine_project_free(project) };
// No probe project is involved in this test (unlike the footage
// family above): the effect edits keep every node inside the project
// graph, so freeing the project returns the alive counter to base.
assert_eq!(alive(), base, "effect-chain edits must not leak boxes");
});
}
// ---------------------------------------------------------------------------
// Illegal inputs: NULL / empty handles / bad sizes / garbage (parallel-safe)
// ---------------------------------------------------------------------------
+34
View File
@@ -1016,6 +1016,40 @@ pub mod node {
})
}
/// `oaknode_node_get_effect_input` (two-stage; `Node::GetEffectInputID()`).
///
/// The id of the input the effect chain attaches to (empty when the node
/// cannot host effects — the C++ contract: "If this is empty, effects
/// cannot attach to this node").
#[no_mangle]
pub unsafe extern "C" fn oaknode_node_get_effect_input(
node: CHandle,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
let n = match unsafe { node_ref(&node) } {
Ok(n) => n,
Err(_) => return crate::error::OAKNODE_E_INVALID,
};
let effect_input = with_graph_read(&n.project, |g| {
g.get(n.id).map(|e| e.core.effect_input.clone())
});
match effect_input {
Some(id) => copy_string_out(&id, buf, buf_size),
None => crate::error::OAKNODE_E_NOT_FOUND,
}
}
/// `oaknode_node_get_flags` — the node's flags bitmask (`Node::flags_`).
#[no_mangle]
pub unsafe extern "C" fn oaknode_node_get_flags(node: CHandle) -> u64 {
let n = match unsafe { node_ref(&node) } {
Ok(n) => n,
Err(_) => return 0,
};
with_graph_read(&n.project, |g| g.get(n.id).map(|e| e.core.flags)).unwrap_or(0)
}
// ---- Input introspection ------------------------------------------
/// `oaknode_node_input_count`.
+12 -2
View File
@@ -41,7 +41,7 @@ use std::time::Duration;
use gpui::dock::{
DockArea, DockLayout, DropTarget, DropZone, NodePath, PanelHandle, PanelRegistry,
};
use gpui::timeline::{Frame, TimelineEvent, TimelineView};
use gpui::timeline::{ClipId, Frame, TimelineEvent, TimelineView};
use gpui::{
div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, Entity, Render, Window,
WindowBounds, WindowOptions,
@@ -397,9 +397,19 @@ impl<E: AppEngine> OakApp<E> {
// Every timeline widget request (playhead seek, trim, move, track
// height) is applied by the engine through its backend's edit
// commands; the playhead is routed to the program monitor.
// `SelectionChanged` carries no payload — the selection is read from
// the view and forwarded to the engine so the inspector's effect
// stack can target the selected clip.
cx.subscribe(
&timeline,
|this, _timeline, event: &TimelineEvent, cx| {
|this, timeline, event: &TimelineEvent, cx| {
if matches!(event, TimelineEvent::SelectionChanged) {
let clips: Vec<ClipId> =
timeline.read(cx).selection().iter().copied().collect();
this.engine.update(cx, |engine, cx| {
engine.set_selected_clips(clips, cx)
});
}
this.engine
.update(cx, |engine, cx| engine.apply_timeline_event(event, cx));
},
+24
View File
@@ -207,6 +207,30 @@ pub trait AppEngine:
/// Applies an effect-stack edit request to the engine's model.
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>);
/// Updates the timeline clip selection (drives the effect stack's
/// target). The app shell forwards `TimelineEvent::SelectionChanged`
/// with the view's selection set. Default: no-op (engines without a
/// selection-driven stack keep their existing behavior).
fn set_selected_clips(&mut self, _clips: Vec<ClipId>, _cx: &mut Context<Self>) {}
/// The effect types the user can add to the selected clip's chain, as
/// (type id, display name) pairs — the facade factory entries flagged
/// `video_effect` and not hidden from the create menu. The inspector
/// panel lists them in its "add effect" menu. Default: empty.
fn addable_effects(&self) -> Vec<(String, String)> {
Vec::new()
}
/// Inserts the effect `type_id` at `index` into the selected clip's
/// chain (undoable). `index` is an insertion index into
/// [`EffectStackDataSource::effects`] (0 = closest to the source);
/// the panel passes the position carried by the `AddRequested` event.
/// Returns a user-facing error message on failure. Default: unsupported.
fn add_effect(&mut self, index: usize, type_id: &str, cx: &mut Context<Self>) -> Result<(), String> {
let _ = (index, type_id, cx);
Err("add effect not supported".into())
}
/// Applies a node-editor edit request to the engine's model.
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>);
+95
View File
@@ -103,6 +103,7 @@ engine_handle! {
OakEngineClip,
OakEngineEncodingParams,
OakEngineFrame,
OakEngineNode,
OakEngineProject,
OakEngineRenderer,
OakEngineSequence,
@@ -480,6 +481,100 @@ unsafe extern "C" {
/// `oakengine_track_height_pixels_to_internal`.
pub fn oakengine_track_height_pixels_to_internal(pixels: c_int) -> f64;
// -- oakengine::node (effect chain) --
//
// The effect-stack surface over a selected clip: convert the clip to its
// node view, enumerate its effect chain (index 0 = closest to the
// source), and edit the chain (insert / remove / reorder / enable
// toggle), each edit packaged as an undoable facade command. Factory
// enumeration feeds the "add effect" menu. Node boxes are freed with
// `oakengine_node_free`; the clip box returned by
// `oakengine_sequence_clip_at` is freed with [`free_box`].
/// `oakengine_clip_as_node` — the clip's node view (borrowed box;
/// freed with `oakengine_node_free`).
pub fn oakengine_clip_as_node(self_: *const OakEngineClip) -> *mut OakEngineNode;
/// `oakengine_node_effect_count` — the chain length (0 without effects).
pub fn oakengine_node_effect_count(self_: *const OakEngineNode) -> c_int;
/// `oakengine_node_effect_at` — the `index`-th effect (borrowed box;
/// NULL out of range / no chain).
pub fn oakengine_node_effect_at(
self_: *const OakEngineNode,
index: c_int,
) -> *mut OakEngineNode;
/// `oakengine_node_identity` — the node's stable identity (the stack's
/// card id). 0 for NULL/invalid.
pub fn oakengine_node_identity(self_: *const OakEngineNode) -> u64;
/// `oakengine_node_is_enabled` — 1/0 (the stack's enable switch).
pub fn oakengine_node_is_enabled(self_: *const OakEngineNode) -> c_int;
/// `oakengine_node_effect_set_enabled` — undoable enable toggle.
pub fn oakengine_node_effect_set_enabled(
self_: *mut OakEngineNode,
enabled: c_int,
) -> c_int;
/// `oakengine_node_effect_insert` — undoable insert at `index`
/// (0 = closest to the source; clamped to the ends).
pub fn oakengine_node_effect_insert(
self_: *mut OakEngineNode,
index: c_int,
type_id: *const c_char,
) -> c_int;
/// `oakengine_node_effect_remove` — undoable removal of `effect` from
/// `self_`'s chain (the node is left orphaned in the project graph).
pub fn oakengine_node_effect_remove(
self_: *mut OakEngineNode,
effect: *mut OakEngineNode,
) -> c_int;
/// `oakengine_node_effect_move` — undoable reorder of `effect` to
/// `new_index` (post-removal insertion index, matching the effect
/// stack's `ReorderRequested`).
pub fn oakengine_node_effect_move(
self_: *mut OakEngineNode,
effect: *mut OakEngineNode,
new_index: c_int,
) -> c_int;
/// `oakengine_node_get_effect_input` — the id of the input the effect
/// chain attaches to (negative error when the node cannot host
/// effects; `element` receives -1 for non-array inputs).
pub fn oakengine_node_get_effect_input(
self_: *const OakEngineNode,
input_id: *mut c_char,
input_id_size: c_int,
element: *mut c_int,
) -> c_int;
/// `oakengine_node_get_type_id` — the node's factory type id (buf/size).
pub fn oakengine_node_get_type_id(
self_: *const OakEngineNode,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_node_get_flags` — the node's flags bitmask (0 for
/// NULL/invalid).
pub fn oakengine_node_get_flags(self_: *const OakEngineNode) -> u64;
/// `oakengine_node_free` — free a node box (NULL no-op).
pub fn oakengine_node_free(node: *mut OakEngineNode);
/// `oakengine_node_factory_id_count` — factory entry count.
pub fn oakengine_node_factory_id_count() -> c_int;
/// `oakengine_node_factory_id_at` — type id at `index` (buf/size;
/// negative error out of range).
pub fn oakengine_node_factory_id_at(
index: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_node_factory_name_from_id` — display name (buf/size).
pub fn oakengine_node_factory_name_from_id(
type_id: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_node_factory_create_from_id` — owned node, not added.
pub fn oakengine_node_factory_create_from_id(type_id: *const c_char) -> *mut OakEngineNode;
/// `oakengine_node_flag_video_effect`.
pub fn oakengine_node_flag_video_effect() -> u64;
/// `oakengine_node_flag_dont_show_in_create_menu`.
pub fn oakengine_node_flag_dont_show_in_create_menu() -> u64;
// -- oakengine::render (CPU frame renderer) --
//
// The renderer binds a sequence handle to an output geometry; each
+343 -12
View File
@@ -48,9 +48,12 @@
//! facade CPU renderer ([`RealEngine::render_program_frame`]) at a proxy
//! resolution; the full-resolution async render worker (the facade's
//! worker module) is a separate process surface not bound yet.
//! * Effect stack, node graph and audio meter feed empty/silent data: the
//! facade surfaces for them (effect chains, graph nodes, audio levels)
//! are not bound in this increment.
//! * Effect stack — the selected clip's effect chain is bound: the stack
//! reads the chain through the facade (see
//! [`EffectStackDataSource`](EffectStackDataSource) for `RealEngine`)
//! and edits go through the facade's undoable effect commands. Node
//! graph and audio meter still feed empty/silent data (their facade
//! surfaces are not bound in this increment).
//! * `oakengine_sequence_move_clip` is a documented facade stub (module gap),
//! so clip moves report the facade error instead of applying.
//!
@@ -62,14 +65,16 @@
//! `oakengine_task_cancel` mirrors the C++ capi contract (cancel atom set
//! from the UI thread while the task runs on its own thread).
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use std::ffi::{c_char, c_int, c_void, CString};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use gpui::effect_stack::{EffectData, EffectStackDataSource, EffectStackEvent};
use gpui::effect_stack::{
EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent,
};
use gpui::node_graph::{
EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData, PortId,
PortDataType, PortKind,
@@ -399,6 +404,46 @@ impl ClipData for RealClip {
}
}
/// One card of the real effect stack: the facade chain node's identity,
/// its factory display name, its enabled flag, and the app-owned
/// expansion state ([`RealEngine::expanded_effects`]). No source/output
/// cards: the host clip node is the implicit output, the chain's unlinked
/// upstream input is the implicit source (the effect stack shows only the
/// editable middle).
#[derive(Debug, Clone)]
struct RealEffect {
/// The node's stable identity (also the card's `EffectId`).
id: EffectId,
/// The factory display name of the node's type.
title: SharedString,
/// The node's `enabled_in` flag.
enabled: bool,
/// The app-owned expansion state (not undoable).
expanded: bool,
}
impl EffectData for RealEffect {
fn id(&self) -> EffectId {
self.id
}
fn kind(&self) -> EffectCardKind {
EffectCardKind::Effect
}
fn title(&self) -> SharedString {
self.title.clone()
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn is_expanded(&self) -> bool {
self.expanded
}
}
/// A track on the real timeline (snapshot handed to the timeline widget).
#[derive(Debug, Clone)]
pub struct RealTrack {
@@ -587,6 +632,14 @@ pub struct RealEngine {
bin_children: Vec<ProjectEntry>,
/// The selected material-bin entry (demo state).
selected_item: Option<u64>,
/// The single selected timeline clip — the effect stack's target
/// (`None` for an empty or multi-clip selection, or before any
/// selection event).
selected_clip: Option<ClipId>,
/// Node identities whose effect cards are expanded (view state; kept
/// here because `EffectData` is a pure read and expansion is not
/// undoable).
expanded_effects: BTreeSet<u64>,
/// Whether the program monitor is playing (mirrors the clock; kept here
/// because the audio-meter data source has no `App` to read the clock).
program_playing: bool,
@@ -628,6 +681,8 @@ impl RealEngine {
bin_roots: Vec::new(),
bin_children: Vec::new(),
selected_item: None,
selected_clip: None,
expanded_effects: BTreeSet::new(),
program_playing: false,
meter_phase: 0,
cpu_frame_cache: Mutex::new(HashMap::new()),
@@ -1016,6 +1071,140 @@ impl RealEngine {
None
}
/// The selected clip's facade node view (a boxed node handle the
/// caller frees with `oakengine_node_free`), or `None` when no single
/// clip is selected or the clip box resolves to no node.
fn selected_clip_node(&self) -> Option<*mut OakEngineNode> {
let clip_id = self.selected_clip?;
let (kind, track_index, clip_index) = self.clip_coords(clip_id)?;
let seq = self.seq_ptr()?;
let clip = unsafe {
oakengine_sequence_clip_at(
seq,
Self::track_type_of(kind),
track_index as c_int,
clip_index as c_int,
)
};
if clip.is_null() {
return None;
}
let node = unsafe { oakengine_clip_as_node(clip) };
// SAFETY: the clip box is a plain facade box (see `free_box`); the
// node box is independent (the facade boxed its own handle copy).
unsafe { free_box(clip) };
// SAFETY: `node` is a fresh box the caller frees, or NULL.
if node.is_null() {
None
} else {
Some(node)
}
}
/// Whether `node` can host effects (its effect-input id is non-empty;
/// the facade reports `E_NOT_FOUND` for nodes without one).
///
/// # Safety
/// `node` must be a live node box (NULL reports false).
unsafe fn node_hosts_effects(node: *mut OakEngineNode) -> bool {
let mut buf = [0 as c_char; 64];
let mut element: c_int = 0;
unsafe {
oakengine_node_get_effect_input(node, buf.as_mut_ptr(), buf.len() as c_int, &mut element)
>= 0
}
}
/// The effect in `host`'s chain whose identity is `identity` (a boxed
/// node handle the caller frees), or `None` when not a member.
///
/// # Safety
/// `host` must be a live node box.
unsafe fn chain_effect_by_identity(
host: *mut OakEngineNode,
identity: u64,
) -> Option<*mut OakEngineNode> {
let count = unsafe { oakengine_node_effect_count(host) };
for i in 0..count.max(0) {
let effect = unsafe { oakengine_node_effect_at(host, i) };
if effect.is_null() {
continue;
}
if unsafe { oakengine_node_identity(effect) } == identity {
return Some(effect);
}
// SAFETY: `effect` is a box from `oakengine_node_effect_at`.
unsafe { oakengine_node_free(effect) };
}
None
}
/// The display label of the selected clip (its timeline snapshot
/// label), if any.
fn selected_clip_label(&self) -> Option<SharedString> {
let clip_id = self.selected_clip?;
for track in &self.tracks {
if let Some(clip) = track.clips.iter().find(|c| c.id() == clip_id) {
return Some(clip.label());
}
}
None
}
/// The card list of the selected clip's effect chain. Each card wraps
/// one chain node (index 0 = closest to the source); a clip that
/// cannot host effects yields an empty list (its `target_label` is
/// `None`, so the stack shows the empty state).
fn selected_effect_cards(&self) -> Vec<Arc<dyn EffectData>> {
let Some(node) = self.selected_clip_node() else {
return Vec::new();
};
let mut out: Vec<Arc<dyn EffectData>> = Vec::new();
// SAFETY: `node` is a live box; freed on every return path.
unsafe {
if !Self::node_hosts_effects(node) {
oakengine_node_free(node);
return out;
}
let count = oakengine_node_effect_count(node);
for i in 0..count.max(0) {
let effect = oakengine_node_effect_at(node, i);
if effect.is_null() {
continue;
}
let identity = oakengine_node_identity(effect);
let type_id = read_string(|buf, size| {
// SAFETY: `effect` is a live box; buf/size follow the
// facade two-stage convention (the enclosing `unsafe`
// block covers this closure body).
oakengine_node_get_type_id(effect, buf, size)
});
let title = CString::new(type_id.clone())
.ok()
.map(|c| {
read_string(|buf, size| {
// SAFETY: as above; `c` outlives the call.
oakengine_node_factory_name_from_id(c.as_ptr(), buf, size)
})
})
.filter(|n| !n.is_empty())
.unwrap_or(type_id);
let enabled = oakengine_node_is_enabled(effect) != 0;
let expanded = self.expanded_effects.contains(&identity);
out.push(Arc::new(RealEffect {
id: EffectId(identity),
title: title.into(),
enabled,
expanded,
}) as Arc<dyn EffectData>);
// SAFETY: `effect` is a box from `oakengine_node_effect_at`.
oakengine_node_free(effect);
}
oakengine_node_free(node);
}
out
}
/// The facade track-type constant for a [`TrackKind`].
fn track_type_of(kind: TrackKind) -> c_int {
match kind {
@@ -1166,13 +1355,17 @@ impl TimelineDataSource for RealEngine {
impl EffectStackDataSource for RealEngine {
fn effects(&self) -> Vec<Arc<dyn EffectData>> {
// The effect-chain surface of the facade is not bound in this
// increment; the stack shows its empty state.
Vec::new()
self.selected_effect_cards()
}
fn target_label(&self) -> Option<SharedString> {
None
let label = self.selected_clip_label()?;
let node = self.selected_clip_node()?;
// SAFETY: `node` is a live box; freed below. A clip that cannot
// host effects keeps the empty state (no label, no cards).
let hosts = unsafe { Self::node_hosts_effects(node) };
unsafe { oakengine_node_free(node) };
hosts.then_some(label)
}
}
@@ -1333,12 +1526,150 @@ impl AppEngine for RealEngine {
cx.notify();
}
fn apply_effect_event(&mut self, _event: &EffectStackEvent, cx: &mut Context<Self>) {
// No effect model in the real engine yet; requests are logged by the
// caller.
fn set_selected_clips(&mut self, clips: Vec<ClipId>, cx: &mut Context<Self>) {
// The effect stack targets exactly one clip: an empty or
// multi-clip selection keeps the empty state (see
// `EffectStackDataSource::target_label`).
self.selected_clip = (clips.len() == 1).then(|| clips[0]);
cx.notify();
}
fn addable_effects(&self) -> Vec<(String, String)> {
// The factory entries flagged `video_effect` and not hidden from
// the create menu (per the facade contract). A scratch node per
// entry just to read its flags (freed immediately).
let mut out = Vec::new();
let count = unsafe { oakengine_node_factory_id_count() };
let video_flag = unsafe { oakengine_node_flag_video_effect() };
let hidden_flag = unsafe { oakengine_node_flag_dont_show_in_create_menu() };
for i in 0..count.max(0) {
let type_id =
read_string(|buf, size| unsafe { oakengine_node_factory_id_at(i, buf, size) });
let Some(c_id) = CString::new(type_id.clone()).ok() else {
continue;
};
let node = unsafe { oakengine_node_factory_create_from_id(c_id.as_ptr()) };
if node.is_null() {
continue;
}
let flags = unsafe { oakengine_node_get_flags(node) };
// SAFETY: `node` is an owned box from the factory.
unsafe { oakengine_node_free(node) };
if flags & video_flag != 0 && flags & hidden_flag == 0 {
let name = read_string(|buf, size| {
// SAFETY: `c_id` outlives the call; buf/size follow the
// facade two-stage convention.
unsafe { oakengine_node_factory_name_from_id(c_id.as_ptr(), buf, size) }
});
let name = if name.is_empty() { type_id.clone() } else { name };
out.push((type_id, name));
}
}
out
}
fn add_effect(&mut self, index: usize, type_id: &str, cx: &mut Context<Self>) -> Result<(), String> {
let Some(host) = self.selected_clip_node() else {
return Err("no selected clip".into());
};
let c_id = CString::new(type_id).map_err(|_| "invalid effect type id".to_string())?;
// SAFETY: `host` is a live box; freed below.
let rc = unsafe { oakengine_node_effect_insert(host, index as c_int, c_id.as_ptr()) };
unsafe { oakengine_node_free(host) };
if rc != 0 {
return Err(format!("facade error {rc}"));
}
self.apply_edit(rc, "add effect", cx);
Ok(())
}
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>) {
match event {
EffectStackEvent::EnableToggled { effect, enabled } => {
let Some(host) = self.selected_clip_node() else {
cx.notify();
return;
};
// SAFETY: both boxes are live and freed below.
let rc = unsafe {
let Some(eff) = Self::chain_effect_by_identity(host, effect.0) else {
oakengine_node_free(host);
cx.notify();
return;
};
let rc = oakengine_node_effect_set_enabled(eff, *enabled as c_int);
oakengine_node_free(eff);
oakengine_node_free(host);
rc
};
self.apply_edit(rc, "toggle effect", cx);
}
EffectStackEvent::ExpansionToggled { effect, expanded } => {
// View state only (not undoable); kept here so the card
// list re-reads it after the notify.
if *expanded {
self.expanded_effects.insert(effect.0);
} else {
self.expanded_effects.remove(&effect.0);
}
cx.notify();
}
EffectStackEvent::RemoveRequested(id) => {
let Some(host) = self.selected_clip_node() else {
cx.notify();
return;
};
// SAFETY: both boxes are live and freed below.
let rc = unsafe {
let Some(eff) = Self::chain_effect_by_identity(host, id.0) else {
oakengine_node_free(host);
cx.notify();
return;
};
let rc = oakengine_node_effect_remove(host, eff);
oakengine_node_free(eff);
oakengine_node_free(host);
rc
};
self.apply_edit(rc, "remove effect", cx);
}
EffectStackEvent::ReorderRequested { effect, new_index } => {
let Some(host) = self.selected_clip_node() else {
cx.notify();
return;
};
// SAFETY: both boxes are live and freed below.
let rc = unsafe {
let Some(eff) = Self::chain_effect_by_identity(host, effect.0) else {
oakengine_node_free(host);
cx.notify();
return;
};
let rc = oakengine_node_effect_move(host, eff, *new_index as c_int);
oakengine_node_free(eff);
oakengine_node_free(host);
rc
};
self.apply_edit(rc, "reorder effect", cx);
}
EffectStackEvent::AddRequested { index } => {
// The effect choice is a panel-owned menu (see
// `InspectorPanel`); the undoable insert runs through
// `AppEngine::add_effect` once the user picks a type.
// `index` is acknowledged here for parity with the
// request semantics (the panel passes it back).
let _ = index;
cx.notify();
}
// The app owns the context menu; parameter changes have no
// metadata to refresh yet.
EffectStackEvent::ContextMenuRequested { .. }
| EffectStackEvent::ParameterChanged { .. } => {
cx.notify();
}
}
}
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>) {
match event {
NodeGraphEvent::NodeMovePreview { .. }
+104 -4
View File
@@ -33,6 +33,11 @@ use crate::panels::ids::INSPECTOR;
pub struct InspectorPanel<E: AppEngine> {
stack: Entity<EffectStackView<E>>,
engine: Entity<E>,
/// An in-flight "add effect" flow: the stack insertion index carried
/// by the last [`EffectStackEvent::AddRequested`]. While set (and the
/// engine has a stack target), the panel renders a small menu of the
/// engine's addable effects instead of forwarding the bare request.
pending_add: Option<usize>,
}
impl<E: AppEngine> InspectorPanel<E> {
@@ -43,20 +48,115 @@ impl<E: AppEngine> InspectorPanel<E> {
.params_renderer(|_effect, _window, cx| cx.new(|_cx| ParamPlaceholder).into())
});
// The "edits are requests" loop: forward each request to the engine,
// which applies it to its model and notifies.
// which applies it to its model and notifies. `AddRequested` carries
// no effect type, so the panel records the insertion index and lets
// the user pick one from the small menu below (the actual insert
// runs through `AppEngine::add_effect`).
cx.subscribe(&stack, |this, _stack, event: &EffectStackEvent, cx| {
if let EffectStackEvent::AddRequested { index } = event {
this.pending_add = Some(*index);
}
this.engine
.update(cx, |engine, cx| engine.apply_effect_event(event, cx));
})
.detach();
Self { stack, engine }
Self {
stack,
engine,
pending_add: None,
}
}
/// The "add effect" menu: one clickable row per addable effect of the
/// engine. Selecting a row inserts that effect at the recorded stack
/// index; a dismiss row closes the menu without adding.
fn render_add_menu(
&mut self,
index: usize,
colors: &gpui::colors::Colors,
cx: &mut Context<Self>,
) -> impl IntoElement {
let effects = self.engine.read(cx).addable_effects();
let mut menu = div()
.id("inspector-add-menu")
.px_2()
.py_1()
.border_t_1()
.border_color(colors.separator)
.flex()
.flex_col()
.gap_1();
for (type_id, name) in &effects {
let engine = self.engine.clone();
let type_id = type_id.clone();
let name = name.clone();
let index = index;
menu = menu.child(
div()
.id(SharedString::from(format!("add-effect-{type_id}")))
.cursor_pointer()
.px_2()
.py_1()
.rounded_sm()
.hover(|style| style.bg(colors.selected))
.text_color(colors.text)
.text_sm()
.child(name)
.on_click(
cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| {
this.pending_add = None;
engine.update(cx, |engine, cx| {
if let Err(err) = engine.add_effect(index, &type_id, cx) {
println!("[inspector] add effect failed: {err}");
}
});
cx.notify();
}),
),
);
}
// A dismiss row, so a cancelled pick does not linger.
menu = menu.child(
div()
.id("add-effect-dismiss")
.cursor_pointer()
.px_2()
.py_1()
.rounded_sm()
.hover(|style| style.bg(colors.selected))
.text_color(colors.disabled)
.text_sm()
.child("")
.on_click(
cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| {
this.pending_add = None;
cx.notify();
}),
),
);
menu
}
}
impl<E: AppEngine> Render for InspectorPanel<E> {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.stack.clone())
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let mut root = div().id("inspector-panel").size_full().flex().flex_col();
root = root.child(self.stack.clone());
// The add-effect menu sits below the stack while an add is
// pending. It only makes sense while the engine has a stack
// target (a clip that can host effects).
if let Some(index) = self.pending_add {
if self.engine.read(cx).target_label().is_some() {
root = root.child(self.render_add_menu(index, colors.as_ref(), cx));
} else {
self.pending_add = None;
}
}
root
}
}