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
+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