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