diff --git a/crates/oakengine/src/bridge/node.rs b/crates/oakengine/src/bridge/node.rs index fd4b5907b..5d69682e8 100644 --- a/crates/oakengine/src/bridge/node.rs +++ b/crates/oakengine/src/bridge/node.rs @@ -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 { diff --git a/crates/oakengine/src/node.rs b/crates/oakengine/src/node.rs index 9c5fa9895..1cbddecbc 100644 --- a/crates/oakengine/src/node.rs +++ b/crates/oakengine/src/node.rs @@ -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); + +impl HandleGuard { + /// Wrap a handle vector. + fn new(v: Vec) -> 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 { + 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 { + 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> { + 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> { + let mut chain: HandleGuard = HandleGuard::new(Vec::new()); + let mut cur = node; + let mut seen: Vec = 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::(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 = (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 = 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 = 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") }) } diff --git a/crates/oakengine/src/timeline.rs b/crates/oakengine/src/timeline.rs index 4649e63b4..ed92a9256 100644 --- a/crates/oakengine/src/timeline.rs +++ b/crates/oakengine/src/timeline.rs @@ -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::(node)) + }) +} + /* ---- Editing primitives, round 2: split / ripple delete / trim / move ---- */ /// `oakengine_sequence_split_clip` — split the addressed clip at `time`. diff --git a/crates/oakengine/tests/it_node.rs b/crates/oakengine/tests/it_node.rs index 48669dc9b..22e622d96 100644 --- a/crates/oakengine/tests/it_node.rs +++ b/crates/oakengine/tests/it_node.rs @@ -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) // --------------------------------------------------------------------------- diff --git a/crates/oaknode/src/ffi.rs b/crates/oaknode/src/ffi.rs index cc0e31d69..6b4572d95 100644 --- a/crates/oaknode/src/ffi.rs +++ b/crates/oaknode/src/ffi.rs @@ -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`. diff --git a/src/app.rs b/src/app.rs index 50ea8ab2d..a057a4abe 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 OakApp { // 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 = + 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)); }, diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index e964fa385..f589d7def 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -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); + /// 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, _cx: &mut Context) {} + + /// 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) -> 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); diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs index 6af8674c1..5bb4c7cb7 100644 --- a/src/oakui/ffi.rs +++ b/src/oakui/ffi.rs @@ -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 diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 585cb2568..a23988c31 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -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, /// The selected material-bin entry (demo state). selected_item: Option, + /// 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, + /// 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, /// 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 { + 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> { + let Some(node) = self.selected_clip_node() else { + return Vec::new(); + }; + let mut out: Vec> = 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); + // 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> { - // 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 { - 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) { - // No effect model in the real engine yet; requests are logged by the - // caller. + fn set_selected_clips(&mut self, clips: Vec, cx: &mut Context) { + // 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) -> 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) { + 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) { match event { NodeGraphEvent::NodeMovePreview { .. } diff --git a/src/panels/inspector.rs b/src/panels/inspector.rs index 2dd272b8e..41fbab460 100644 --- a/src/panels/inspector.rs +++ b/src/panels/inspector.rs @@ -33,6 +33,11 @@ use crate::panels::ids::INSPECTOR; pub struct InspectorPanel { stack: Entity>, engine: Entity, + /// 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, } impl InspectorPanel { @@ -43,20 +48,115 @@ impl InspectorPanel { .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, + ) -> 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 Render for InspectorPanel { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().size_full().child(self.stack.clone()) + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 } }