feat(engine,app): node editor bound to the facade graph (M12 P2)

facade (API only extended): oakengine_sequence_as_node /
_sequence_node_count / _sequence_node_at / _sequence_remove_node;
remove validates ownership by project UUID (arena slot/generation
collide across projects). it_node covers enumeration/edits and the
NULL/illegal matrix.

app: NodeGraphDataSource enumerates the current sequence's graph —
footage / effect / clip / output cards at their context positions
(deterministic role-grid fallback), real edges plus synthesized
clip->sequence tex_in edges; connect/disconnect/remove/drag-release
all go through undoable facade commands, drag previews stay local.
This commit is contained in:
2026-08-14 13:33:49 +08:00
parent 4240df1d71
commit 48d3027d50
6 changed files with 974 additions and 272 deletions
+149
View File
@@ -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::<OakEngineNode>(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::<OakEngineNode>(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.
+206 -2
View File
@@ -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::<OakEngineClip>(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::<OakEngineSequence>(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.
// ---------------------------------------------------------------------------
+19
View File
@@ -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(
+475 -249
View File
@@ -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<RealNode>, Vec<RealEdge>) {
/// `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<TypedNode> {
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<RealNode>, Vec<RealEdge>) {
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<RealNode>, 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<RealNode>, 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<RealEdge>)> = 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<RealNode>, 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<u32, u32> = 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<Pixels> {
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<RealNode>,
edges: Vec<RealEdge>,
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::Node> {
self.nodes.clone()
}
fn edges(&self) -> Vec<Self::Edge> {
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<Pixels>, _d: u32, _row: f32) {}
+122 -19
View File
@@ -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<Self::Node> {
// 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<Self::Edge> {
// 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<Self>) {
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);
}
}
+3 -2
View File
@@ -15,9 +15,10 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! 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