diff --git a/crates/oakengine/src/timeline.rs b/crates/oakengine/src/timeline.rs index 92c348f9a..8c65617a9 100644 --- a/crates/oakengine/src/timeline.rs +++ b/crates/oakengine/src/timeline.rs @@ -2208,6 +2208,155 @@ pub unsafe extern "C" fn oakengine_clip_as_node(self_: *const OakEngineClip) -> }) } +// ---- Sequence node-graph enumeration ---------------------------------------- +// +// The node-editor surface (M12 P2): the app displays the CURRENT sequence's +// node graph — the sequence node (the output) plus the blocks, effects and +// scratch footage of its timeline. The module keeps the sequence in its own +// scratch project (documented deviation, see `oakengine_sequence_new`), so +// the graph is exactly that project's node list; positions live in the +// sequence node's context position map (`oakengine_node_get_context_position`). + +/// `oakengine_sequence_as_node` — the sequence's node view (borrowed; +/// freed with `oakengine_node_free`). The graph surface addresses the +/// sequence through this handle: the sequence node is the graph's output +/// node AND the context for its position map. +#[no_mangle] +pub unsafe extern "C" fn oakengine_sequence_as_node( + self_: *const OakEngineSequence, +) -> *mut OakEngineNode { + guard_ptr(|| unsafe { + if self_.is_null() { + return Ok(std::ptr::null_mut()); + } + let h = unbox(self_)?; + let node = n::oaknode_sequence_as_node(h); + if node.is_null() { + return Ok(std::ptr::null_mut()); + } + Ok(box_handle::(node)) + }) +} + +/// `oakengine_sequence_node_count` — nodes in the sequence's owning +/// project (the sequence's graph; 0 for NULL/invalid). +#[no_mangle] +pub unsafe extern "C" fn oakengine_sequence_node_count( + self_: *const OakEngineSequence, +) -> c_int { + guard_int(|| unsafe { + if self_.is_null() { + return Ok(0); + } + let project = seq_project_of(unbox(self_)?); + if project.is_null() { + return Ok(0); + } + let count = n::oaknode_project_node_count(project); + release_handle(project); + Ok(count) + }) +} + +/// `oakengine_sequence_node_at` — boxed node at `index` (freed with +/// `oakengine_node_free`); NULL for an invalid index or sequence. +#[no_mangle] +pub unsafe extern "C" fn oakengine_sequence_node_at( + self_: *const OakEngineSequence, + index: c_int, +) -> *mut OakEngineNode { + guard_ptr(|| unsafe { + if self_.is_null() || index < 0 { + return Ok(std::ptr::null_mut()); + } + let project = seq_project_of(unbox(self_)?); + if project.is_null() { + return Ok(std::ptr::null_mut()); + } + let node = n::oaknode_project_node_at(project, index); + release_handle(project); + if node.is_null() { + return Ok(std::ptr::null_mut()); + } + Ok(box_handle::(node)) + }) +} + +/// The uuid of a project handle (empty on failure). Project handles are +/// per-call boxes around the same `Arc`, so ctx pointers cannot be compared; +/// the uuid is the stable identity. +/// +/// # Safety +/// `project` must be a live module project handle. +unsafe fn project_uuid(project: CHandle) -> String { + unsafe { + let mut buf = [0 as c_char; 256]; + let len = n::oaknode_project_get_uuid(project, buf.as_mut_ptr(), buf.len() as c_int); + if len < 0 { + String::new() + } else { + read_cstr(buf.as_ptr()) + } + } +} + +/// `oakengine_sequence_remove_node` — undoable removal of `node` from the +/// sequence's graph (its owning project; the module's remove command drops +/// incident edges). The sequence node itself (the graph's output) cannot be +/// removed. +#[no_mangle] +pub unsafe extern "C" fn oakengine_sequence_remove_node( + self_: *mut OakEngineSequence, + node: *mut OakEngineNode, +) -> c_int { + guard(|| unsafe { + if self_.is_null() || node.is_null() { + set_seq_error("invalid sequence or node"); + return Err(Error::Invalid); + } + let sh = unbox(self_)?; + let nh = unbox(node)?; + let project = seq_project_of(sh); + if project.is_null() { + return Err(Error::Invalid); + } + // The node must live in the sequence's OWNING project. Node + // identities are per-project arena slots (generation + index), so + // two projects produce colliding identities; compare the projects' + // uuids instead. + let mut node_project = CHandle::null(); + let rc = n::oaknode_node_get_project(nh, &mut node_project); + let node_uuid = if rc == 0 && !node_project.is_null() { + project_uuid(node_project) + } else { + String::new() + }; + release_handle(node_project); + let seq_uuid = project_uuid(project); + if node_uuid.is_empty() || node_uuid != seq_uuid { + release_handle(project); + set_seq_error("node does not belong to this sequence's graph"); + return Err(Error::Invalid); + } + // The sequence node is the graph's context/output; removing it would + // orphan every position map entry and the sequence itself. + let seq_node = n::oaknode_sequence_as_node(sh); + let is_self = !seq_node.is_null() + && n::oaknode_node_identity(seq_node) == n::oaknode_node_identity(nh); + release_handle(seq_node); + release_handle(project); + if is_self { + set_seq_error("the sequence node cannot be removed"); + return Err(Error::Invalid); + } + let cmd = n::oaknode_command_create_remove_node(nh); + if cmd.ctx.is_null() { + return Err(Error::Failed("remove node command failed".into())); + } + push_command(cmd, "Remove Node") + }) +} + /// `oakengine_clip_get_media_filename` — the clip's upstream footage /// filename (two-stage buf/size; M12 P4 — the waveform decorator needs /// the media file). Negative error when the clip has no media. diff --git a/crates/oakengine/tests/it_node.rs b/crates/oakengine/tests/it_node.rs index f7618fa2e..78e84837b 100644 --- a/crates/oakengine/tests/it_node.rs +++ b/crates/oakengine/tests/it_node.rs @@ -49,8 +49,8 @@ use std::sync::Mutex; use oakengine::common::OakVideoParamsPod; use oakengine::handle::{ - CHandle, OakEngineFootage, OakEngineKeyframe, OakEngineNode, OakEngineNodeDragger, - OakEngineProject, + free_box, CHandle, OakEngineClip, OakEngineFootage, OakEngineKeyframe, OakEngineNode, + OakEngineNodeDragger, OakEngineProject, OakEngineSequence, }; use oakengine::node::value_type as vt; use oakengine::node::*; @@ -85,6 +85,8 @@ 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"; +const TYPE_SEQUENCE: &std::ffi::CStr = c"org.olivevideoeditor.Olive.sequence"; +const TYPE_CLIPBLOCK: &std::ffi::CStr = c"org.olivevideoeditor.Olive.clipblock"; // --------------------------------------------------------------------------- // Fixtures @@ -5360,6 +5362,201 @@ fn project_add_node_owned_handle_leak() { }); } +// --------------------------------------------------------------------------- +// Sequence node-graph enumeration (M12 P2) +// --------------------------------------------------------------------------- + +/// The sequence-graph surface the app's node editor reads: the sequence +/// node (`as_node`), its owning project's node list (`count`/`at`), the +/// context-position map on the sequence node, and the sequence-scoped +/// remove. A fresh sequence owns a scratch project holding the sequence +/// node plus its three track lists; placing a clip adds the track, the +/// clip block and a scratch footage node. +#[test] +fn sequence_graph_enumeration_and_edits() { + with_owned(|| { + common::force_link(); + let _ = force_oakundo_command_link(); + let base = alive(); + + let project = oakengine_project_create(); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + assert!( + unsafe { oakengine::timeline::oakengine_sequence_as_node(std::ptr::null_mut()) } + .is_null(), + "NULL sequence -> NULL node" + ); + let name = std::ffi::CString::new("NodeGraph").unwrap(); + let seq = unsafe { oakengine::timeline::oakengine_sequence_new(project, name.as_ptr()) }; + assert!(!seq.is_null()); + let seq_node = unsafe { oakengine::timeline::oakengine_sequence_as_node(seq) }; + assert!(!seq_node.is_null()); + let mut buf = [0 as c_char; 256]; + let len = unsafe { oakengine_node_get_type_id(seq_node, buf.as_mut_ptr(), 256) }; + assert_eq!(len, TYPE_SEQUENCE.to_bytes().len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, TYPE_SEQUENCE.to_str().unwrap()); + + // ---- count / at --------------------------------------------------- + assert_eq!( + unsafe { oakengine::timeline::oakengine_sequence_node_count(seq) }, + 4, + "sequence + its three track lists" + ); + assert_eq!( + unsafe { oakengine::timeline::oakengine_sequence_node_count(std::ptr::null_mut()) }, + 0 + ); + assert!(!unsafe { oakengine::timeline::oakengine_sequence_node_at(seq, 0) }.is_null()); + assert!(unsafe { oakengine::timeline::oakengine_sequence_node_at(seq, -1) }.is_null()); + assert!(unsafe { oakengine::timeline::oakengine_sequence_node_at(seq, 99) }.is_null()); + assert!( + unsafe { oakengine::timeline::oakengine_sequence_node_at(std::ptr::null_mut(), 0) } + .is_null() + ); + + // ---- context positions live on the sequence node ------------------ + let mut x: f64 = -1.0; + let mut y: f64 = -1.0; + let mut expanded: c_int = -1; + assert_eq!( + unsafe { oakengine_node_set_context_position(seq_node, seq_node, 120.0, 340.0) }, + 0 + ); + assert_eq!( + unsafe { + oakengine_node_get_context_position( + seq_node, + seq_node, + &mut x, + &mut y, + &mut expanded, + ) + }, + 0 + ); + assert_eq!((x, y), (120.0, 340.0), "position round-trips"); + + // ---- place a clip: the graph grows -------------------------------- + assert_eq!(unsafe { oakengine::timeline::oakengine_sequence_add_track(seq, 0) }, 0); + let media = + std::env::temp_dir().join(format!("oak_it_nodegraph_{}.mp4", std::process::id())); + let media_c = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap(); + assert_eq!( + oakengine::testmedia::oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10), + 0, + "generate e2e test media" + ); + let footage = unsafe { oakengine_project_import_footage(project, media_c.as_ptr()) }; + assert!(!footage.is_null(), "import must succeed"); + let clip = unsafe { + oakengine::timeline::oakengine_sequence_add_footage_clip_ex( + seq, + footage, + 0, + 0, + 0, + 10, + 0, + ) + }; + assert!(!clip.is_null(), "clip placement must succeed"); + unsafe { free_box::(clip) }; + unsafe { oakengine_footage_free(footage) }; + + let count = unsafe { oakengine::timeline::oakengine_sequence_node_count(seq) }; + assert_eq!( + count, + 7, + "sequence + 3 track lists + track + clip + scratch footage" + ); + + // The clip block node is enumerable and typed; the scratch footage + // carries an outgoing edge into it (the media connection). + let mut clip_node: *mut OakEngineNode = std::ptr::null_mut(); + let mut footage_has_edge = false; + for i in 0..count { + let node = unsafe { oakengine::timeline::oakengine_sequence_node_at(seq, i) }; + if node.is_null() { + continue; + } + let tlen = unsafe { oakengine_node_get_type_id(node, buf.as_mut_ptr(), 256) }; + let type_id = if tlen > 0 { + unsafe { read_buf(&mut buf) } + } else { + String::new() + }; + if type_id == TYPE_CLIPBLOCK.to_str().unwrap() { + clip_node = node; + continue; + } + if type_id == TYPE_FOOTAGE.to_str().unwrap() { + footage_has_edge = unsafe { oakengine_node_output_connection_count(node) } > 0; + } + unsafe { oakengine_node_free(node) }; + } + assert!(!clip_node.is_null(), "the placed clip enumerates as a block node"); + assert!(footage_has_edge, "the footage node connects into the clip"); + + // ---- sequence-scoped remove --------------------------------------- + assert_eq!( + unsafe { + oakengine::timeline::oakengine_sequence_remove_node( + std::ptr::null_mut(), + clip_node, + ) + }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine::timeline::oakengine_sequence_remove_node(seq, std::ptr::null_mut()) }, + E_INVALID + ); + // The sequence node itself is the graph's output and cannot be removed. + assert_eq!( + unsafe { oakengine::timeline::oakengine_sequence_remove_node(seq, seq_node) }, + E_INVALID, + "the output node cannot be removed from the sequence graph" + ); + // A node from the app project is not part of the sequence's graph. + let app_node = unsafe { oakengine_project_add_node(project, TYPE_VALUE.as_ptr()) }; + assert!(!app_node.is_null()); + assert_eq!( + unsafe { oakengine::timeline::oakengine_sequence_remove_node(seq, app_node) }, + E_INVALID, + "app-project nodes are not in the sequence graph" + ); + unsafe { oakengine_node_free(app_node) }; + // Removing the clip block drops the graph node (edges included). + let remove_rc = + unsafe { oakengine::timeline::oakengine_sequence_remove_node(seq, clip_node) }; + if remove_rc != 0 { + let mut ebuf = [0 as c_char; 512]; + let elen = + unsafe { oakengine::timeline::oakengine_sequence_last_error(ebuf.as_mut_ptr(), 512) }; + eprintln!("[dbg] remove rc={remove_rc} err={elen} {}", unsafe { read_buf(&mut ebuf) }); + } + assert_eq!(remove_rc, 0); + assert_eq!( + unsafe { oakengine::timeline::oakengine_sequence_node_count(seq) }, + count - 1, + "the removed block leaves the sequence graph" + ); + + unsafe { oakengine_node_free(clip_node) }; + unsafe { oakengine_node_free(seq_node) }; + unsafe { free_box::(seq) }; + unsafe { oakengine_project_free(project) }; + let _ = std::fs::remove_file(&media); + // No `alive() == base` assertion here: the timeline family's + // `oakengine_sequence_add_track` and the `_ex` clip placement keep + // owned track/block handles (documented pre-existing facade behavior; + // the it_timeline tests never assert the counter on those paths). + // The node-family surfaces exercised above (as-node, count/at, + // context positions, sequence-scoped remove) are all borrowed and + // leak nothing of their own. + }); +} + // --------------------------------------------------------------------------- // Divergences found while exercising the family end to end — all fixed in // the facade (src/node.rs); each item below states the fixed behavior. @@ -5412,4 +5609,11 @@ fn project_add_node_owned_handle_leak() { // 10. `oakengine_footage_borrow` addrefs the wrapped handle, so the borrow // and the source node shell each own their own reference: freeing BOTH is // safe (no double-free). +// +// 11. `oakengine_sequence_remove_node` verifies membership through the +// project UUID, not node identities: every project's arena reuses the +// same slot/generation identities, so an identity-only check accepts a +// node from ANOTHER project whose identity collides (and would remove +// the wrong node). Project handles are per-call boxes around the same +// `Arc`, so ctx pointers cannot be compared either. // --------------------------------------------------------------------------- diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs index ea45a5c4d..a4c69a1d6 100644 --- a/src/oakui/ffi.rs +++ b/src/oakui/ffi.rs @@ -656,6 +656,25 @@ unsafe extern "C" { /// `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_sequence_as_node` — the sequence's node view (borrowed + /// box; freed with `oakengine_node_free`). The node editor's graph + /// output node and the context for its position map. + pub fn oakengine_sequence_as_node(self_: *const OakEngineSequence) -> *mut OakEngineNode; + /// `oakengine_sequence_node_count` — nodes in the sequence's owning + /// project (its graph; 0 for NULL/invalid). + pub fn oakengine_sequence_node_count(self_: *const OakEngineSequence) -> c_int; + /// `oakengine_sequence_node_at` — boxed graph node at `index` (freed + /// with `oakengine_node_free`); NULL for an invalid index or sequence. + pub fn oakengine_sequence_node_at( + self_: *const OakEngineSequence, + index: c_int, + ) -> *mut OakEngineNode; + /// `oakengine_sequence_remove_node` — undoable removal of `node` from + /// the sequence's graph (the sequence node itself is protected). + pub fn oakengine_sequence_remove_node( + self_: *mut OakEngineSequence, + node: *mut OakEngineNode, + ) -> c_int; /// `oakengine_clip_get_media_filename` — the clip's upstream footage /// filename (two-stage buf/size; M12 P4). pub fn oakengine_clip_get_media_filename( diff --git a/src/oakui/nodegraph.rs b/src/oakui/nodegraph.rs index 09af1367f..540c7edf4 100644 --- a/src/oakui/nodegraph.rs +++ b/src/oakui/nodegraph.rs @@ -17,36 +17,67 @@ //! M12 P2: the real node-graph surface. //! //! Builds the gpui node-graph data (`RealNode` / `RealPort` / `RealEdge`) -//! from the facade's project-node enumeration: +//! from the CURRENT SEQUENCE's graph (the facade's sequence node-graph +//! enumeration; see the `oakengine_sequence_*` exports): //! -//! - every project node becomes a card, titled with its label; -//! - declared inputs become input ports (id string as the label); -//! - every node exposes one "out" output port (the module declares no -//! outputs; edges are enumerated from `output_connection_at_ex`); -//! - edges connect the source node's main output to the target node's -//! input port; -//! - positions come from the project-root context's position map. +//! - the sequence node becomes the output card (rightmost); +//! - every clip block becomes a card titled with its label (or "Clip"); +//! - effects (everything else in the sequence graph) sit between; +//! - footage nodes (the media) feed the clip's `tex_in` from the left; +//! - declared inputs become input ports (id string as the label); every +//! node exposes one "out" output port (the module declares no outputs; +//! edges are enumerated from `output_connection_at_ex`); +//! - REAL edges connect the source node's main output to the target +//! node's input port; a synthesized "clip → output" wire per clip +//! connects the clip's main output to the sequence's `tex_in` (the +//! module's place-block flow never wires blocks to the sequence node — +//! the C++ reaches it through track nodes, which do not exist here); +//! - positions come from the sequence node's context position map, with a +//! deterministic role-grid fallback layout (footage | effects | clips | +//! output) when a node has no entry yet — the first drag persists +//! through the undoable position setter. //! //! Identity mapping: `NodeId` = the facade node identity (stable across //! frames). `PortId` packs `(node identity, kind, index)` — input port //! `(id << 4) | (index << 1)`, output port `(id << 4) | 1`. Identities -//! are pointer-aligned (low bits zero), so the shifts are injective. +//! are arena indices (low bits zero), so the shifts are injective. +//! +//! Structural timeline plumbing (track lists, tracks, gaps, transitions) +//! is NOT displayed: those nodes carry no graph edges in the module world +//! and would only add empty cards; the displayed graph is the media chain +//! clip → effects → output the C++ node editor centers on. -use std::ffi::{c_char, c_int, c_void}; +use std::collections::HashMap; +use std::ffi::{c_char, c_int}; -use gpui::node_graph::{EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeId, PortData, PortId, PortKind, PortDataType}; +use gpui::node_graph::{ + EdgeData, EdgeId, NodeData, NodeId, PortData, PortId, PortDataType, PortKind, +}; use gpui::{hsla, point, px, Hsla, Pixels, Point, SharedString}; use crate::oakui::ffi::{ - free_box, oakengine_node_connect, oakengine_node_disconnect_ex, oakengine_node_free, + oakengine_node_connect, oakengine_node_disconnect_ex, oakengine_node_free, oakengine_node_get_context_position, oakengine_node_get_label, oakengine_node_get_name, - oakengine_node_identity, oakengine_node_input_count, oakengine_node_input_id, - oakengine_node_input_is_connected, oakengine_node_output_connection_at_ex, - oakengine_node_output_connection_count, oakengine_node_set_context_position, - oakengine_project_node_at, oakengine_project_node_count, oakengine_project_remove_node, - oakengine_project_root, OakEngineNode, OakEngineProject, + oakengine_node_get_type_id, oakengine_node_identity, oakengine_node_input_count, + oakengine_node_input_id, oakengine_node_input_is_connected, + oakengine_node_output_connection_at_ex, oakengine_node_output_connection_count, + oakengine_node_set_context_position, oakengine_sequence_as_node, oakengine_sequence_node_at, + oakengine_sequence_node_count, oakengine_sequence_remove_node, OakEngineNode, + OakEngineSequence, }; +/// The sequence node type id (the graph's output card). +const TYPE_ID_SEQUENCE: &str = "org.olivevideoeditor.Olive.sequence"; +/// Clip block type id (the graph's clip cards). +const TYPE_ID_CLIP_BLOCK: &str = "org.olivevideoeditor.Olive.clipblock"; +/// Footage node type id (the graph's media cards). +const TYPE_ID_FOOTAGE: &str = "org.olivevideoeditor.Olive.footage"; +/// Structural timeline nodes never shown in the node editor. +const TYPE_ID_TRACK: &str = "org.olivevideoeditor.Olive.track"; +const TYPE_ID_TRACK_LIST: &str = "org.olivevideoeditor.Olive.tracklist"; +const TYPE_ID_GAP_BLOCK: &str = "org.olivevideoeditor.Olive.gapblock"; +const TYPE_ID_TRANSITION_BLOCK: &str = "org.olivevideoeditor.Olive.transitionblock"; + /// The "video" wire type used by the real graph (the facade exposes no /// per-input type names; all node ports are treated as video). fn video_type() -> PortDataType { @@ -95,8 +126,22 @@ fn read_str(f: impl Fn(*mut c_char, c_int) -> c_int) -> String { .into_owned() } -/// A stable edge id from `(from node, to node, input id)`. -fn edge_id(from: u64, to: u64, input_id: &str) -> EdgeId { +/// Read a NUL-terminated facade string out of a fixed buffer. +fn read_cstr_buf(buf: &[c_char]) -> String { + if buf.first().copied().unwrap_or(0) == 0 { + return String::new(); + } + String::from_utf8_lossy(unsafe { + std::slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len()) + }) + .trim_end_matches('\0') + .to_string() +} + +/// A stable REAL edge id from `(from node, to node, input id)` (FNV-1a, +/// high bit masked so it can never collide with the synthesized-wire tag +/// [`is_output_wire`] reserves). +fn real_edge_id(from: u64, to: u64, input_id: &str) -> EdgeId { let mut h: u64 = 0xcbf29ce484222325; for &b in [from.to_le_bytes(), to.to_le_bytes()].concat().iter() { h ^= b as u64; @@ -106,7 +151,19 @@ fn edge_id(from: u64, to: u64, input_id: &str) -> EdgeId { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); } - EdgeId(h) + EdgeId(h & 0x7fff_ffff_ffff_ffff) +} + +/// The synthesized "clip → output" wire's id: tagged with the high bit so +/// the app can tell structural wires apart from real edges (whose ids are +/// masked below the tag — see [`real_edge_id`]). +fn output_wire_id(clip: u64) -> EdgeId { + EdgeId(0x8000_0000_0000_0000 | real_edge_id(clip, clip, "out").0) +} + +/// Whether `id` is a synthesized structural wire (never a real edge). +pub fn is_output_wire(id: EdgeId) -> bool { + id.0 & 0x8000_0000_0000_0000 != 0 } /// A node card in the real graph. @@ -246,58 +303,139 @@ fn node_color(ident: u64) -> Hsla { hsla(hues[(ident as usize) % hues.len()], 0.5, 0.35, 1.0) } -/// Build the (nodes, edges) snapshot of `project`'s node graph. +/// The type id of a boxed node (empty on failure). /// /// # Safety -/// `project` must be a live facade project box. -pub unsafe fn build_graph(project: *mut OakEngineProject) -> (Vec, Vec) { +/// `node` must be a live facade node box. +unsafe fn node_type_id(node: *mut OakEngineNode) -> String { + let mut buf = [0 as c_char; 256]; + let len = unsafe { oakengine_node_get_type_id(node, buf.as_mut_ptr(), buf.len() as c_int) }; + if len <= 0 { + String::new() + } else { + read_cstr_buf(&buf) + } +} + +/// Whether a sequence-graph node should be shown in the node editor: the +/// media chain (sequence output, clip blocks, footage, effects) only; +/// structural timeline plumbing (tracks, track lists, gaps, transitions) +/// is hidden. +fn is_displayed(type_id: &str) -> bool { + !matches!( + type_id, + TYPE_ID_TRACK | TYPE_ID_TRACK_LIST | TYPE_ID_GAP_BLOCK | TYPE_ID_TRANSITION_BLOCK + ) +} + +/// A boxed node plus its type id (freed with `oakengine_node_free`). +struct TypedNode { + /// The boxed node. + ptr: *mut OakEngineNode, + /// The node's identity. + ident: u64, + /// The node's type id. + type_id: String, +} + +/// The displayable nodes of `seq`'s graph, in graph order. +/// +/// # Safety +/// `seq` must be a live facade sequence box. +unsafe fn graph_nodes(seq: *mut OakEngineSequence) -> Vec { + let mut out = Vec::new(); + let count = unsafe { oakengine_sequence_node_count(seq) }; + for i in 0..count.max(0) { + let node = unsafe { oakengine_sequence_node_at(seq, i) }; + if node.is_null() { + continue; + } + let type_id = unsafe { node_type_id(node) }; + let ident = unsafe { oakengine_node_identity(node) }; + if ident == 0 || !is_displayed(&type_id) { + unsafe { oakengine_node_free(node) }; + continue; + } + out.push(TypedNode { + ptr: node, + ident, + type_id, + }); + } + out +} + +/// Build the (nodes, edges) snapshot of `seq`'s node graph. +/// +/// # Safety +/// `seq` must be a live facade sequence box. +pub unsafe fn build_graph(seq: *mut OakEngineSequence) -> (Vec, Vec) { unsafe { let mut nodes = Vec::new(); let mut edges = Vec::new(); - if project.is_null() { + if seq.is_null() { return (nodes, edges); } - let root = oakengine_project_root(project); - let count = oakengine_project_node_count(project); - for i in 0..count { - let node = oakengine_project_node_at(project, i); - if node.is_null() { - continue; - } - let ident = oakengine_node_identity(node); - if ident == 0 { - oakengine_node_free(node); - continue; - } - // Title: the label, falling back to the type name. - let label = read_str(|buf, size| oakengine_node_get_label(node, buf, size)); - let name = read_str(|buf, size| oakengine_node_get_name(node, buf, size)); - let title = if label.is_empty() { name } else { label }; + let seq_node = oakengine_sequence_as_node(seq); + if seq_node.is_null() { + return (nodes, edges); + } + let seq_ident = oakengine_node_identity(seq_node); + let seq_label = read_str(|buf, size| oakengine_node_get_label(seq_node, buf, size)); + let seq_name = read_str(|buf, size| oakengine_node_get_name(seq_node, buf, size)); - // Position from the project-root context map. - let mut x: f64 = 0.0; - let mut y: f64 = 0.0; - let mut expanded: c_int = 0; - let has_pos = oakengine_node_get_context_position( - root, - node, - &mut x, - &mut y, - &mut expanded, - ) == 0 - && (x != 0.0 || y != 0.0); + // The sequence node itself may or may not be enumerated in its own + // project; ensure exactly one output card with the sequence identity + // (the enumerated copy, if any, is freed here). + let mut all = Vec::new(); + for typed in graph_nodes(seq) { + if typed.ident == seq_ident { + oakengine_node_free(typed.ptr); + } else { + all.push(typed); + } + } + all.push(TypedNode { + ptr: seq_node, + ident: seq_ident, + type_id: TYPE_ID_SEQUENCE.into(), + }); + all.sort_by_key(|n| n.ident); + + // Build every card's ports first (inputs, the implicit output), so + // real edges can resolve their target port index by matching the + // input id against the already-built cards. + let mut built: Vec<(TypedNode, RealNode)> = Vec::new(); + for typed in all { + let ident = typed.ident; + let title = if typed.type_id == TYPE_ID_SEQUENCE { + if seq_label.is_empty() { + seq_name.clone() + } else { + seq_label.clone() + } + } else { + let label = read_str(|buf, size| oakengine_node_get_label(typed.ptr, buf, size)); + let name = read_str(|buf, size| oakengine_node_get_name(typed.ptr, buf, size)); + if label.is_empty() { + name + } else { + label + } + }; // Inputs. - let input_count = oakengine_node_input_count(node); + let input_count = oakengine_node_input_count(typed.ptr); let mut inputs = Vec::with_capacity(input_count.max(0) as usize); for idx in 0..input_count { - let id_str = read_str(|buf, size| oakengine_node_input_id(node, idx, buf, size)); + let id_str = + read_str(|buf, size| oakengine_node_input_id(typed.ptr, idx, buf, size)); if id_str.is_empty() { continue; } let cid = std::ffi::CString::new(id_str.clone()).unwrap_or_default(); let connected = - oakengine_node_input_is_connected(node, cid.as_ptr()) == 1; + oakengine_node_input_is_connected(typed.ptr, cid.as_ptr()) == 1; inputs.push(RealPort { id: port_id(ident, PortKind::Input, idx as u32), kind: PortKind::Input, @@ -308,7 +446,7 @@ pub unsafe fn build_graph(project: *mut OakEngineProject) -> (Vec, Vec } // The single implicit output. - let out_count = oakengine_node_output_connection_count(node); + let out_count = oakengine_node_output_connection_count(typed.ptr); let outputs = vec![RealPort { id: port_id(ident, PortKind::Output, 0), kind: PortKind::Output, @@ -317,14 +455,37 @@ pub unsafe fn build_graph(project: *mut OakEngineProject) -> (Vec, Vec connected: out_count > 0, }]; - // Outgoing edges. + let position = context_position(seq_node, typed.ptr); + built.push(( + typed, + RealNode { + id: NodeId(ident), + title: title.into(), + position, + inputs, + outputs, + header_color: Some(node_color(ident)), + collapsed: false, + enabled: true, + }, + )); + } + + // Outgoing REAL edges: every source node's output connections, with + // the target port resolved to the index of the input whose id matches + // (the module stores edges by input id; the index may differ from 0 — + // e.g. a clip's `tex_in` sits after `enabled_in`). + let mut node_edges: Vec<(u64, Vec)> = Vec::new(); + for (typed, _) in &built { + let out_count = oakengine_node_output_connection_count(typed.ptr); + let mut edges_of = Vec::new(); for j in 0..out_count { let mut input_node: *mut OakEngineNode = std::ptr::null_mut(); let mut id_buf = [0 as c_char; 256]; let mut element: c_int = -1; let mut hidden: c_int = 0; let rc = oakengine_node_output_connection_at_ex( - node, + typed.ptr, j, &mut input_node, id_buf.as_mut_ptr(), @@ -343,140 +504,194 @@ pub unsafe fn build_graph(project: *mut OakEngineProject) -> (Vec, Vec if !input_node.is_null() { oakengine_node_free(input_node); } - // The facade already filled id_buf during - // output_connection_at_ex. - let mut len = 0usize; - while len < id_buf.len() && id_buf[len] != 0 { - len += 1; - } - let conn_id = String::from_utf8_lossy(unsafe { - std::slice::from_raw_parts(id_buf.as_ptr() as *const u8, len) - }) - .into_owned(); + let conn_id = read_cstr_buf(&id_buf); if to_ident == 0 || conn_id.is_empty() { continue; } - edges.push(RealEdge { - id: edge_id(ident, to_ident, &conn_id), - from_node: NodeId(ident), - from_port: port_id(ident, PortKind::Output, 0), + let to_index = built + .iter() + .find(|(t, _)| t.ident == to_ident) + .and_then(|(_, n)| { + n.inputs + .iter() + .position(|p| p.label.as_ref() == conn_id) + }) + .unwrap_or(0) as u32; + edges_of.push(RealEdge { + id: real_edge_id(typed.ident, to_ident, &conn_id), + from_node: NodeId(typed.ident), + from_port: port_id(typed.ident, PortKind::Output, 0), to_node: NodeId(to_ident), - to_port: port_id(to_ident, PortKind::Input, idx_of_input(to_ident, &conn_id, project, &nodes)), + to_port: port_id(to_ident, PortKind::Input, to_index), }); } - - nodes.push(RealNode { - id: NodeId(ident), - title: title.into(), - position: if has_pos { - point(px(x as f32), px(y as f32)) - } else { - point(px(0.0), px(0.0)) - }, - inputs, - outputs, - header_color: Some(node_color(ident)), - collapsed: false, - enabled: true, - }); - oakengine_node_free(node); + node_edges.push((typed.ident, edges_of)); } - if !root.is_null() { - oakengine_node_free(root); + + // Fallback layout: nodes without a persisted context position get a + // deterministic role grid — footage | effects | clips | output as + // columns, a per-role row counter as the row (the same grid the + // C++-era node editor lays chains out on). Nodes the user has + // already dragged keep their persisted position. `fallback_base` + // mirrors this grid so the first drag moves from the displayed + // position rather than the origin. + let mut row_at_role: HashMap = HashMap::new(); + for (typed, node) in built.iter_mut() { + if node.position != point(px(0.0), px(0.0)) { + continue; + } + let role = role_of(&typed.type_id, typed.ident == seq_ident); + let row = row_at_role.entry(role).or_insert(0); + let x = 40.0 + (role as f32) * 260.0; + let y = 40.0 + (*row as f32) * 180.0; + *row += 1; + node.position = point(px(x), px(y)); + } + + // Assemble: every built card + its real edges, plus the synthesized + // "clip → output" wires (each clip's main output into the sequence's + // `tex_in`, its first declared input). Every enumerated box is freed + // here (including the sequence node's own view). + let clip_input = port_id(seq_ident, PortKind::Input, 0); + for (typed, node) in built { + if typed.type_id == TYPE_ID_CLIP_BLOCK { + edges.push(RealEdge { + id: output_wire_id(typed.ident), + from_node: node.id, + from_port: port_id(typed.ident, PortKind::Output, 0), + to_node: NodeId(seq_ident), + to_port: clip_input, + }); + } + if let Some((_, real)) = node_edges.iter().find(|(id, _)| *id == typed.ident) { + edges.extend(real.iter().cloned()); + } + nodes.push(node); + oakengine_node_free(typed.ptr); } (nodes, edges) } } -/// Resolve the input port index for `input_id` on the node with identity -/// `ident` (the edge's target port must match the port the facade lists). -fn idx_of_input( +/// The role column of a displayed node (footage | effects | clips | +/// output); drives the fallback grid's x position. +fn role_of(type_id: &str, is_output: bool) -> u32 { + if is_output { + 3 + } else if type_id == TYPE_ID_FOOTAGE { + 0 + } else if type_id == TYPE_ID_CLIP_BLOCK { + 2 + } else { + 1 // effects + } +} + +/// The provisional position the builder assigns to an unplaced node: the +/// role column and the per-role row among the OTHER unplaced nodes in +/// sorted display order (mirrors the fallback grid in `build_graph`). +/// `apply_edit` uses it so the first drag release writes +/// `(displayed base + delta)` instead of `(origin + delta)`. +/// +/// # Safety +/// `seq`/`seq_node` must be live facade boxes. +unsafe fn fallback_base( + seq: *mut OakEngineSequence, + seq_node: *mut OakEngineNode, ident: u64, - input_id: &str, - project: *mut OakEngineProject, - nodes: &[RealNode], -) -> u32 { - // The target node was already snapshotted: find it and match the - // input label. - if let Some(n) = nodes.iter().find(|n| n.id.0 == ident) { - for (i, p) in n.inputs.iter().enumerate() { - if p.label.as_ref() == input_id { - return i as u32; +) -> (f64, f64) { + unsafe { + let seq_ident = oakengine_node_identity(seq_node); + let mut all = Vec::new(); + for typed in graph_nodes(seq) { + if typed.ident == seq_ident { + oakengine_node_free(typed.ptr); + } else { + all.push(typed); } } + all.push(TypedNode { + ptr: seq_node, + ident: seq_ident, + type_id: TYPE_ID_SEQUENCE.into(), + }); + all.sort_by_key(|n| n.ident); + let target = all + .iter() + .find(|n| n.ident == ident) + .expect("the moved node is part of the displayed graph"); + let target_role = role_of(&target.type_id, ident == seq_ident); + let mut row: u32 = 0; + for n in &all { + if n.ident == ident { + break; + } + if role_of(&n.type_id, n.ident == seq_ident) != target_role { + continue; + } + // Placed nodes do not consume a row. + let mut x: f64 = 0.0; + let mut y: f64 = 0.0; + let mut expanded: c_int = 0; + let placed = oakengine_node_get_context_position( + seq_node, + n.ptr, + &mut x, + &mut y, + &mut expanded, + ) == 0 + && (x != 0.0 || y != 0.0); + if !placed { + row += 1; + } + } + // Free the enumerated boxes; `seq_node` belongs to the caller. + for n in &all { + if n.ptr != seq_node { + oakengine_node_free(n.ptr); + } + } + ( + 40.0 + f64::from(target_role * 260), + 40.0 + f64::from(row * 180), + ) + } +} + +/// The context position of `node` in `seq_node`'s map, or the (0,0) +/// sentinel the fallback layout replaces. +/// +/// # Safety +/// Both pointers must be live facade node boxes. +unsafe fn context_position(seq_node: *mut OakEngineNode, node: *mut OakEngineNode) -> Point { + let mut x: f64 = 0.0; + let mut y: f64 = 0.0; + let mut expanded: c_int = 0; + if unsafe { + oakengine_node_get_context_position(seq_node, node, &mut x, &mut y, &mut expanded) + } == 0 + && (x != 0.0 || y != 0.0) + { + point(px(x as f32), px(y as f32)) + } else { + point(px(0.0), px(0.0)) } - // Fallback: re-fetch the node's inputs through the facade. - let _ = project; - let _ = ident; - 0 } // --------------------------------------------------------------------------- -// NodeGraphDataSource over the snapshot +// Connection rules and edit application // --------------------------------------------------------------------------- -/// The real graph data source (a [`NodeGraphDataSource`] snapshot). -pub struct RealGraphSource { - nodes: Vec, - edges: Vec, - project: *mut OakEngineProject, -} - -unsafe impl Send for RealGraphSource {} - -impl RealGraphSource { - /// Snapshot the current graph. - /// - /// # Safety - /// `project` must be a live facade project box. - pub unsafe fn snapshot(project: *mut OakEngineProject) -> Self { - let (nodes, edges) = unsafe { build_graph(project) }; - Self { - nodes, - edges, - project, - } - } - - /// Find the boxed facade node for a node identity (caller frees with - /// [`oakengine_node_free`]). - /// - /// # Safety - /// The returned pointer is a live box. - pub unsafe fn find_node(&self, ident: u64) -> *mut OakEngineNode { - unsafe { - if self.project.is_null() { - return std::ptr::null_mut(); - } - let count = oakengine_project_node_count(self.project); - for i in 0..count { - let node = oakengine_project_node_at(self.project, i); - if node.is_null() { - continue; - } - if oakengine_node_identity(node) == ident { - return node; - } - oakengine_node_free(node); - } - std::ptr::null_mut() - } - } -} - -impl NodeGraphDataSource for RealGraphSource { - type Node = RealNode; - type Edge = RealEdge; - - fn nodes(&self) -> Vec { - self.nodes.clone() - } - - fn edges(&self) -> Vec { - self.edges.clone() - } - - fn can_connect(&self, from: PortId, to: PortId) -> bool { +/// Whether connecting output port `from` to input port `to` is valid in +/// `seq`'s graph: output → input, distinct nodes, and the target input +/// must exist and be free. The sequence node's inputs are connectable +/// like any other (its `tex_in` starts unconnected; a user wire replaces +/// the synthesized one once the graph grows real edges). +/// +/// # Safety +/// `seq` must be a live facade sequence box. +pub unsafe fn can_connect(seq: *mut OakEngineSequence, from: PortId, to: PortId) -> bool { + unsafe { let (from_node, from_kind, _) = unpack_port(from); let (to_node, to_kind, to_index) = unpack_port(to); if from_kind != PortKind::Output || to_kind != PortKind::Input { @@ -485,60 +700,57 @@ impl NodeGraphDataSource for RealGraphSource { if from_node == to_node { return false; } - // The target input must exist and be free. - unsafe { - let node = self.find_node(to_node); - if node.is_null() { - return false; - } - let count = oakengine_node_input_count(node); - if to_index >= count.max(0) as u32 { - oakengine_node_free(node); - return false; - } - let id_str = read_str(|buf, size| oakengine_node_input_id(node, to_index as c_int, buf, size)); - if id_str.is_empty() { - oakengine_node_free(node); - return false; - } - let cid = std::ffi::CString::new(id_str).unwrap_or_default(); - let free = oakengine_node_input_is_connected(node, cid.as_ptr()) == 0; + let Ok(node) = find_boxed(seq, to_node) else { + return false; + }; + let count = oakengine_node_input_count(node); + if to_index >= count.max(0) as u32 { oakengine_node_free(node); - free + return false; } + let id_str = + read_str(|buf, size| oakengine_node_input_id(node, to_index as c_int, buf, size)); + if id_str.is_empty() { + oakengine_node_free(node); + return false; + } + let cid = std::ffi::CString::new(id_str).unwrap_or_default(); + let free = oakengine_node_input_is_connected(node, cid.as_ptr()) == 0; + oakengine_node_free(node); + free } } -/// Apply a node-graph edit to the facade (undoable). +/// Apply a node-graph edit to `seq`'s graph (undoable through the facade). /// /// # Safety -/// `project` must be a live facade project box. +/// `seq` must be a live facade sequence box. pub unsafe fn apply_edit( - project: *mut OakEngineProject, + seq: *mut OakEngineSequence, edit: &gpui::node_graph::NodeGraphEvent, ) -> Result<(), String> { use gpui::node_graph::NodeGraphEvent; unsafe { - let root = oakengine_project_root(project); + let seq_node = oakengine_sequence_as_node(seq); match edit { NodeGraphEvent::ConnectionRequested { from, to } => { let (from_node, from_kind, _) = unpack_port(*from); let (to_node, to_kind, to_index) = unpack_port(*to); if from_kind != PortKind::Output || to_kind != PortKind::Input { - if !root.is_null() { - oakengine_node_free(root); + if !seq_node.is_null() { + oakengine_node_free(seq_node); } return Err("connection endpoints must be output → input".into()); } - let src = find_boxed(project, from_node)?; - let dst = find_boxed(project, to_node)?; + let src = find_boxed(seq, from_node)?; + let dst = find_boxed(seq, to_node)?; let id_str = { let count = oakengine_node_input_count(dst); if to_index >= count.max(0) as u32 { oakengine_node_free(src); oakengine_node_free(dst); - if !root.is_null() { - oakengine_node_free(root); + if !seq_node.is_null() { + oakengine_node_free(seq_node); } return Err("target input out of range".into()); } @@ -549,8 +761,8 @@ pub unsafe fn apply_edit( if id_str.is_empty() { oakengine_node_free(src); oakengine_node_free(dst); - if !root.is_null() { - oakengine_node_free(root); + if !seq_node.is_null() { + oakengine_node_free(seq_node); } return Err("target input missing".into()); } @@ -558,8 +770,8 @@ pub unsafe fn apply_edit( let rc = oakengine_node_connect(src, dst, cid.as_ptr()); oakengine_node_free(src); oakengine_node_free(dst); - if !root.is_null() { - oakengine_node_free(root); + if !seq_node.is_null() { + oakengine_node_free(seq_node); } if rc != 0 { return Err(format!("connect failed rc={rc}")); @@ -567,12 +779,20 @@ pub unsafe fn apply_edit( Ok(()) } NodeGraphEvent::DisconnectionRequested { edge } => { - let e = self::edge_for(project, edge.0)?; + if is_output_wire(*edge) { + // The synthesized clip → output wire is structural; the + // facade has no such edge to remove. + if !seq_node.is_null() { + oakengine_node_free(seq_node); + } + return Ok(()); + } + let e = self::edge_for(seq, edge.0)?; let cid = std::ffi::CString::new(e.1).unwrap_or_default(); let rc = oakengine_node_disconnect_ex(e.0, cid.as_ptr(), -1); oakengine_node_free(e.0); - if !root.is_null() { - oakengine_node_free(root); + if !seq_node.is_null() { + oakengine_node_free(seq_node); } if rc != 0 { return Err(format!("disconnect failed rc={rc}")); @@ -581,56 +801,69 @@ pub unsafe fn apply_edit( } NodeGraphEvent::NodeMoveRequested { nodes, delta } => { for node in nodes { - let boxed = find_boxed(project, node.0)?; + let boxed = find_boxed(seq, node.0)?; let mut x: f64 = 0.0; let mut y: f64 = 0.0; let mut expanded: c_int = 0; - oakengine_node_get_context_position( - root, + let placed = oakengine_node_get_context_position( + seq_node, boxed, &mut x, &mut y, &mut expanded, - ); + ) == 0 + && (x != 0.0 || y != 0.0); + if !placed { + // The node was drawn at its provisional grid spot; + // move from there so the release does not jump it + // back toward the origin. + let (fx, fy) = fallback_base(seq, seq_node, node.0); + x = fx; + y = fy; + } let rc = oakengine_node_set_context_position( - root, + seq_node, boxed, x + f64::from(delta.x.as_f32()), y + f64::from(delta.y.as_f32()), ); oakengine_node_free(boxed); if rc != 0 { - if !root.is_null() { - oakengine_node_free(root); + if !seq_node.is_null() { + oakengine_node_free(seq_node); } return Err(format!("move failed rc={rc}")); } } - if !root.is_null() { - oakengine_node_free(root); + if !seq_node.is_null() { + oakengine_node_free(seq_node); } Ok(()) } NodeGraphEvent::DeleteRequested { nodes, .. } => { for node in nodes { - let boxed = find_boxed(project, node.0)?; - let rc = oakengine_project_remove_node(project, boxed); + // The output node is the graph's context; never deleted. + if node.0 == oakengine_node_identity(seq_node) { + continue; + } + let boxed = find_boxed(seq, node.0)?; + let rc = oakengine_sequence_remove_node(seq, boxed); oakengine_node_free(boxed); if rc != 0 { - if !root.is_null() { - oakengine_node_free(root); + if !seq_node.is_null() { + oakengine_node_free(seq_node); } return Err(format!("remove failed rc={rc}")); } } - if !root.is_null() { - oakengine_node_free(root); + if !seq_node.is_null() { + oakengine_node_free(seq_node); } Ok(()) } _ => { - if !root.is_null() { - oakengine_node_free(root); + if !seq_node.is_null() { + oakengine_node_free(seq_node); } Ok(()) } @@ -638,18 +871,16 @@ pub unsafe fn apply_edit( } } -/// Boxed facade node for an identity (freed with `oakengine_node_free`). +/// Boxed sequence-graph node for an identity (freed with +/// `oakengine_node_free`). /// /// # Safety -/// `project` must be live. -unsafe fn find_boxed( - project: *mut OakEngineProject, - ident: u64, -) -> Result<*mut OakEngineNode, String> { +/// `seq` must be a live facade sequence box. +unsafe fn find_boxed(seq: *mut OakEngineSequence, ident: u64) -> Result<*mut OakEngineNode, String> { unsafe { - let count = oakengine_project_node_count(project); - for i in 0..count { - let node = oakengine_project_node_at(project, i); + let count = oakengine_sequence_node_count(seq); + for i in 0..count.max(0) { + let node = oakengine_sequence_node_at(seq, i); if node.is_null() { continue; } @@ -662,18 +893,18 @@ unsafe fn find_boxed( } } -/// Resolve an edge id back to `(input node, input id)`. +/// Resolve a real edge id back to `(input node, input id)`. /// /// # Safety -/// `project` must be live. +/// `seq` must be a live facade sequence box. unsafe fn edge_for( - project: *mut OakEngineProject, + seq: *mut OakEngineSequence, edge: u64, ) -> Result<(*mut OakEngineNode, String), String> { unsafe { - let count = oakengine_project_node_count(project); - for i in 0..count { - let node = oakengine_project_node_at(project, i); + let count = oakengine_sequence_node_count(seq); + for i in 0..count.max(0) { + let node = oakengine_sequence_node_at(seq, i); if node.is_null() { continue; } @@ -701,20 +932,13 @@ unsafe fn edge_for( } else { oakengine_node_identity(input_node) }; - let mut len = 0usize; - while len < id_buf.len() && id_buf[len] != 0 { - len += 1; - } - let conn_id = String::from_utf8_lossy(unsafe { - std::slice::from_raw_parts(id_buf.as_ptr() as *const u8, len) - }) - .into_owned(); + let conn_id = read_cstr_buf(&id_buf); if !input_node.is_null() { oakengine_node_free(input_node); } - if self::edge_id(from, to, &conn_id).0 == edge { + if real_edge_id(from, to, &conn_id).0 == edge { // The input node was freed; re-box it. - let dst = find_boxed(project, to)?; + let dst = find_boxed(seq, to)?; return Ok((dst, conn_id)); } } @@ -723,3 +947,5 @@ unsafe fn edge_for( Err(format!("edge {edge} not found")) } } + +fn node_position_mut(mut _p: Point, _d: u32, _row: f32) {} diff --git a/src/oakui/real.rs b/src/oakui/real.rs index ef5111b2a..3d3e9f995 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -54,9 +54,15 @@ //! * 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). +//! and edits go through the facade's undoable effect commands. +//! * Node graph — the node editor reads the current sequence's graph +//! through the facade's sequence node-graph enumeration (see +//! [`NodeGraphDataSource`](NodeGraphDataSource) for `RealEngine`): +//! clip → effects → output with real edges plus the synthesized +//! clip-to-output wires; connect/disconnect/move/delete are undoable +//! facade commands (drag previews never persist). +//! * Audio meter still feeds silent data (the meter's facade surface is +//! not bound in this increment). //! * Clip moves go through `oakengine_sequence_move_clip` (same-track only; //! the facade's capi signature has no target-track parameter, so a //! cross-track drag reports "not supported" instead of applying). @@ -80,8 +86,8 @@ use gpui::effect_stack::{ EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent, }; use gpui::node_graph::{ - EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData, - PortDataType, PortId, PortKind, + EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortDataType, PortId, + PortKind, }; use gpui::timeline::{ ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent, TrackData, @@ -518,8 +524,8 @@ fn clip_color(index: u64) -> Hsla { } } -/// A node in the real node graph (M12 P2: built from the facade's -/// project-node enumeration by [`crate::oakui::nodegraph`]). +/// A node in the real node graph (M12 P2: built from the current +/// sequence's graph by [`crate::oakui::nodegraph`]). pub use crate::oakui::nodegraph::{RealEdge, RealNode, RealPort}; // --------------------------------------------------------------------------- @@ -1554,27 +1560,28 @@ impl NodeGraphDataSource for RealEngine { type Edge = RealEdge; fn nodes(&self) -> Vec { - // SAFETY: the project box is live while the engine holds it. + // SAFETY: the sequence box is live while the engine holds it. unsafe { - crate::oakui::nodegraph::build_graph(self.project_ptr().unwrap_or(std::ptr::null_mut())).0 + crate::oakui::nodegraph::build_graph(self.seq_ptr().unwrap_or(std::ptr::null_mut())).0 } } fn edges(&self) -> Vec { - // SAFETY: the project box is live while the engine holds it. + // SAFETY: the sequence box is live while the engine holds it. unsafe { - crate::oakui::nodegraph::build_graph(self.project_ptr().unwrap_or(std::ptr::null_mut())).1 + crate::oakui::nodegraph::build_graph(self.seq_ptr().unwrap_or(std::ptr::null_mut())).1 } } fn can_connect(&self, from: PortId, to: PortId) -> bool { - // SAFETY: the project box is live while the engine holds it. - let src = unsafe { - crate::oakui::nodegraph::RealGraphSource::snapshot( - self.project_ptr().unwrap_or(std::ptr::null_mut()), + // SAFETY: the sequence box is live while the engine holds it. + unsafe { + crate::oakui::nodegraph::can_connect( + self.seq_ptr().unwrap_or(std::ptr::null_mut()), + from, + to, ) - }; - src.can_connect(from, to) + } } } @@ -1892,15 +1899,19 @@ impl AppEngine for RealEngine { fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context) { match event { + // Preview events never persist: the widget draws dragged nodes at + // their model position plus the preview delta, and the position + // is written back (as one undoable step) only when the drag ends + // in `NodeMoveRequested`. NodeGraphEvent::NodeMovePreview { .. } | NodeGraphEvent::ViewChanged { .. } | NodeGraphEvent::BackgroundClicked { .. } | NodeGraphEvent::SelectionChanged { .. } => {} _ => { - // SAFETY: the project box is live while the engine holds it. + // SAFETY: the sequence box is live while the engine holds it. let result = unsafe { crate::oakui::nodegraph::apply_edit( - self.project_ptr().unwrap_or(std::ptr::null_mut()), + self.seq_ptr().unwrap_or(std::ptr::null_mut()), event, ) }; @@ -2914,4 +2925,96 @@ mod tests { let _ = std::fs::remove_file(&media); } + + /// M12 P2 acceptance: a real project with a sequence + footage clip + /// builds a NON-EMPTY node graph with the wires the node editor shows: + /// the footage feeds the clip's `tex_in` (a real edge), and every clip + /// connects to the sequence output through the synthesized wire. Runs + /// through the same facade path `RealEngine::nodes()`/`edges()` use. + #[test] + fn real_node_graph_enumerates_sequence() { + let _media = media_lock(); + let project = unsafe { oakengine_project_create() }; + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let name = CString::new("Node Editor").unwrap(); + let sequence = unsafe { oakengine_sequence_new(project, name.as_ptr()) }; + assert!(!sequence.is_null()); + assert_eq!(unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) }, 0); + + let media = std::env::temp_dir().join(format!( + "oakapp_nodegraph_{}.mp4", + std::process::id() + )); + let cpath = CString::new(media.to_string_lossy().into_owned()).unwrap(); + assert_eq!( + unsafe { oakengine_testmedia_write_clip(cpath.as_ptr(), 64, 64, 10, 10) }, + 0 + ); + let footage = unsafe { oakengine_project_import_footage(project, cpath.as_ptr()) }; + assert!(!footage.is_null(), "import must succeed"); + let clip = unsafe { + oakengine_sequence_add_footage_clip_ex( + sequence, + footage, + TRACK_TYPE_VIDEO, + 0, + 0, + 10, + 0, + ) + }; + assert!(!clip.is_null(), "clip placement must succeed"); + unsafe { oakengine_footage_free(footage) }; + unsafe { free_box(clip) }; + + // The graph through the same builder `RealEngine::nodes()` / + // `edges()` use (the sequence handle is the engine's). + let (nodes, edges) = unsafe { crate::oakui::nodegraph::build_graph(sequence) }; + assert!( + nodes.len() >= 3, + "sequence output + clip + footage (got {} nodes)", + nodes.len() + ); + assert!( + !edges.is_empty(), + "the built graph carries wires (got {} edges)", + edges.len() + ); + + // The output card is the sequence node; a wire lands on it. + let seq_node = unsafe { oakengine_sequence_as_node(sequence) }; + assert!(!seq_node.is_null()); + let output_id = NodeId(unsafe { oakengine_node_identity(seq_node) }); + unsafe { oakengine_node_free(seq_node) }; + assert!( + nodes.iter().any(|n| n.id == output_id), + "the sequence node is the graph's output card" + ); + let clip_edge = edges + .iter() + .find(|e| e.to_node == output_id) + .expect("a wire lands on the output card"); + assert!( + crate::oakui::nodegraph::is_output_wire(clip_edge.id), + "the clip→output wire is the synthesized one" + ); + + // The footage→clip media edge is a REAL graph edge: the footage + // node carries an outgoing connection (built from the module's + // `output_connection_at_ex`), so its wire is not the synthesized + // kind. + let real_edges = edges + .iter() + .filter(|e| !crate::oakui::nodegraph::is_output_wire(e.id)) + .count(); + assert!( + real_edges >= 1, + "the footage→clip media edge is real (got {real_edges} real edges)" + ); + + unsafe { free_box(sequence) }; + unsafe { oakengine_project_free(project) }; + let _ = std::fs::remove_file(&media); + } } diff --git a/src/panels/node_editor.rs b/src/panels/node_editor.rs index 2bef2e54a..ab993c441 100644 --- a/src/panels/node_editor.rs +++ b/src/panels/node_editor.rs @@ -15,9 +15,10 @@ // along with this program. If not, see . //! The node editor panel (节点编辑器): the real `gpui::node_graph` canvas over -//! the engine's mock graph, with the design's zoom controls (+ / − / 适配). +//! the engine's graph (the mock's demo graph, or the real engine's current +//! sequence graph), with the design's zoom controls (+ / − / 适配). //! -//! The graph is a full [`NodeGraphView`] fed by the [`MockEngine`]'s +//! The graph is a full [`NodeGraphView`] fed by the engine's //! [`NodeGraphDataSource`] implementation. Every gesture the view emits //! (move, connect, disconnect, delete, selection) is forwarded to the engine //! as a request; the engine applies it to its model and notifies, so the