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:
@@ -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
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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>);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user