- real.rs rewritten over module Rust APIs (Arc<Mutex<Project>> + NodeId; the addref handle dance and renderer boxes are gone); AppEngine trait and all panels untouched - new app assembly layers: graphops (project/timeline/edit primitives), effectchain (chain composition with undo groups), renderops (montage build + ticket render + ExportTask export), library via oakstorage directly - module-side safe API additions: oakundo global value-semantic push/undo/redo + from_closures, oakstorage project_arc_of - deleted: src/oakui/ffi.rs, src/oakui/host_syms.rs, the dylib link config in build.rs (only the gpui IOSurface framework link remains) - the binary carries zero liboakengine references (otool/nm verified); 101 app tests green incl. the real-render and full-res e2e tests - behavior improvements for free: sequences land in the project graph (the facade scratch-project deviation is gone), footage drops take one undo record, effect remove/reorder undo restores edges
705 lines
20 KiB
Rust
705 lines
20 KiB
Rust
// Oak Video Editor - Non-Linear Video Editor
|
|
// Copyright (C) 2026 Oak Team
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
//! M12 P2: the real node-graph surface.
|
|
//!
|
|
//! Builds the gpui node-graph data (`RealNode` / `RealPort` / `RealEdge`)
|
|
//! from the CURRENT SEQUENCE's graph (M14 R3: the direct oaknode graph walk
|
|
//! — the app links the module rlibs and reads the project graph itself):
|
|
//!
|
|
//! - 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 `Graph::output_connections`);
|
|
//! - 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 module node identity (stable across
|
|
//! frames). `PortId` packs `(node identity, kind, index)` — input port
|
|
//! `(id << 4) | (index << 1)`, output port `(id << 4) | 1`. Identities
|
|
//! are arena slots (low index bits, zero generation for most nodes), 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::collections::HashMap;
|
|
|
|
use gpui::node_graph::{
|
|
EdgeData, EdgeId, NodeData, NodeId, PortData, PortId, PortDataType, PortKind,
|
|
};
|
|
use gpui::{hsla, point, px, Hsla, Pixels, Point, SharedString};
|
|
|
|
use oaknode::id::NodeId as DomainNodeId;
|
|
|
|
use crate::oakui::graphops::{self, ProjectRef};
|
|
|
|
/// 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 module exposes no
|
|
/// per-input type names; all node ports are treated as video).
|
|
fn video_type() -> PortDataType {
|
|
PortDataType::new("video", hsla(0.55, 0.75, 0.6, 1.0))
|
|
}
|
|
|
|
/// The "output" wire type for the module's implicit outputs.
|
|
fn out_type() -> PortDataType {
|
|
PortDataType::new("video", hsla(0.08, 0.7, 0.55, 1.0))
|
|
}
|
|
|
|
/// Pack a node identity + kind + index into a global `PortId`.
|
|
fn port_id(node: u64, kind: PortKind, index: u32) -> PortId {
|
|
match kind {
|
|
PortKind::Input => PortId((node << 4) | ((index as u64) << 1)),
|
|
PortKind::Output => PortId((node << 4) | 1),
|
|
}
|
|
}
|
|
|
|
/// Unpack a `PortId` into `(node identity, kind, index)`.
|
|
fn unpack_port(id: PortId) -> (u64, PortKind, u32) {
|
|
let raw = id.0;
|
|
let node = raw >> 4;
|
|
if raw & 1 == 1 {
|
|
(node, PortKind::Output, 0)
|
|
} else {
|
|
(node, PortKind::Input, ((raw >> 1) & 0x7) as u32)
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
h = h.wrapping_mul(0x100000001b3);
|
|
}
|
|
for &b in input_id.as_bytes() {
|
|
h ^= b as u64;
|
|
h = h.wrapping_mul(0x100000001b3);
|
|
}
|
|
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.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RealNode {
|
|
/// The module node identity.
|
|
pub id: NodeId,
|
|
/// Card title.
|
|
pub title: SharedString,
|
|
/// Graph-space position.
|
|
pub position: Point<Pixels>,
|
|
/// Input ports (top to bottom).
|
|
pub inputs: Vec<RealPort>,
|
|
/// The single implicit output port.
|
|
pub outputs: Vec<RealPort>,
|
|
/// Category accent.
|
|
pub header_color: Option<Hsla>,
|
|
/// Collapsed state.
|
|
pub collapsed: bool,
|
|
/// Enabled state.
|
|
pub enabled: bool,
|
|
}
|
|
|
|
impl NodeData for RealNode {
|
|
type Port = RealPort;
|
|
|
|
fn id(&self) -> NodeId {
|
|
self.id
|
|
}
|
|
|
|
fn title(&self) -> SharedString {
|
|
self.title.clone()
|
|
}
|
|
|
|
fn position(&self) -> Point<Pixels> {
|
|
self.position
|
|
}
|
|
|
|
fn inputs(&self) -> Vec<Self::Port> {
|
|
self.inputs.clone()
|
|
}
|
|
|
|
fn outputs(&self) -> Vec<Self::Port> {
|
|
self.outputs.clone()
|
|
}
|
|
|
|
fn header_color(&self) -> Option<Hsla> {
|
|
self.header_color
|
|
}
|
|
|
|
fn is_collapsed(&self) -> bool {
|
|
self.collapsed
|
|
}
|
|
|
|
fn is_enabled(&self) -> bool {
|
|
self.enabled
|
|
}
|
|
}
|
|
|
|
/// A port on a real node.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RealPort {
|
|
/// Global port id.
|
|
pub id: PortId,
|
|
/// Direction.
|
|
pub kind: PortKind,
|
|
/// Port label.
|
|
pub label: SharedString,
|
|
/// Wire type.
|
|
pub data_type: PortDataType,
|
|
/// Whether an edge is attached.
|
|
pub connected: bool,
|
|
}
|
|
|
|
impl PortData for RealPort {
|
|
fn id(&self) -> PortId {
|
|
self.id
|
|
}
|
|
|
|
fn kind(&self) -> PortKind {
|
|
self.kind
|
|
}
|
|
|
|
fn label(&self) -> SharedString {
|
|
self.label.clone()
|
|
}
|
|
|
|
fn data_type(&self) -> PortDataType {
|
|
self.data_type.clone()
|
|
}
|
|
|
|
fn is_connected(&self) -> bool {
|
|
self.connected
|
|
}
|
|
}
|
|
|
|
/// A wire between two real nodes.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RealEdge {
|
|
/// Stable edge id.
|
|
pub id: EdgeId,
|
|
/// Source node.
|
|
pub from_node: NodeId,
|
|
/// Source output port.
|
|
pub from_port: PortId,
|
|
/// Target node.
|
|
pub to_node: NodeId,
|
|
/// Target input port.
|
|
pub to_port: PortId,
|
|
}
|
|
|
|
impl EdgeData for RealEdge {
|
|
fn id(&self) -> EdgeId {
|
|
self.id
|
|
}
|
|
|
|
fn from_node(&self) -> NodeId {
|
|
self.from_node
|
|
}
|
|
|
|
fn from_port(&self) -> PortId {
|
|
self.from_port
|
|
}
|
|
|
|
fn to_node(&self) -> NodeId {
|
|
self.to_node
|
|
}
|
|
|
|
fn to_port(&self) -> PortId {
|
|
self.to_port
|
|
}
|
|
}
|
|
|
|
/// Deterministic header accents per node identity.
|
|
fn node_color(ident: u64) -> Hsla {
|
|
let hues = [0.55f32, 0.6, 0.08, 0.3, 0.78, 0.45, 0.9, 0.15];
|
|
hsla(hues[(ident as usize) % hues.len()], 0.5, 0.35, 1.0)
|
|
}
|
|
|
|
/// 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 displayed node: its domain id plus its type id.
|
|
struct TypedNode {
|
|
/// The node's domain id.
|
|
id: DomainNodeId,
|
|
/// The node's identity.
|
|
ident: u64,
|
|
/// The node's type id.
|
|
type_id: String,
|
|
}
|
|
|
|
/// The displayable nodes of the sequence's graph, in identity order (the
|
|
/// sequence node itself exactly once).
|
|
fn graph_nodes(g: &oaknode::graph::Graph, seq: DomainNodeId) -> Vec<TypedNode> {
|
|
let mut out: Vec<TypedNode> = g
|
|
.node_ids()
|
|
.into_iter()
|
|
.filter_map(|id| {
|
|
let type_id = graphops::node_type_id(g, id);
|
|
if !is_displayed(&type_id) {
|
|
return None;
|
|
}
|
|
Some(TypedNode {
|
|
id,
|
|
ident: id.identity(),
|
|
type_id,
|
|
})
|
|
})
|
|
.filter(|n| n.id != seq)
|
|
.collect();
|
|
// The sequence node itself: exactly one output card.
|
|
out.push(TypedNode {
|
|
id: seq,
|
|
ident: seq.identity(),
|
|
type_id: TYPE_ID_SEQUENCE.into(),
|
|
});
|
|
out.sort_by_key(|n| n.ident);
|
|
out
|
|
}
|
|
|
|
/// The context position of `node` in the sequence's map, or the (0,0)
|
|
/// sentinel the fallback layout replaces.
|
|
fn context_position(
|
|
g: &oaknode::graph::Graph,
|
|
seq: DomainNodeId,
|
|
node: DomainNodeId,
|
|
) -> Point<Pixels> {
|
|
let placed = g.get(node).and_then(|e| {
|
|
e.core
|
|
.context_positions
|
|
.iter()
|
|
.find(|(c, _, _)| *c == seq)
|
|
.map(|(_, pos, _)| *pos)
|
|
});
|
|
match placed {
|
|
Some((x, y)) if x != 0.0 || y != 0.0 => point(px(x as f32), px(y as f32)),
|
|
_ => point(px(0.0), px(0.0)),
|
|
}
|
|
}
|
|
|
|
/// Build the (nodes, edges) snapshot of the sequence's node graph.
|
|
pub fn build_graph(project: &ProjectRef, seq: DomainNodeId) -> (Vec<RealNode>, Vec<RealEdge>) {
|
|
let g = graphops::lock(project);
|
|
let g = &g.graph;
|
|
let mut nodes = Vec::new();
|
|
let mut edges = Vec::new();
|
|
if !g.is_valid(seq) {
|
|
return (nodes, edges);
|
|
}
|
|
let seq_ident = seq.identity();
|
|
let seq_label = graphops::node_label(g, seq);
|
|
let seq_name = g
|
|
.get(seq)
|
|
.map(|e| e.behavior.name().to_string())
|
|
.unwrap_or_default();
|
|
|
|
let all = graph_nodes(g, seq);
|
|
|
|
// 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 = graphops::node_label(g, typed.id);
|
|
let name = g
|
|
.get(typed.id)
|
|
.map(|e| e.behavior.name().to_string())
|
|
.unwrap_or_default();
|
|
if label.is_empty() {
|
|
name
|
|
} else {
|
|
label
|
|
}
|
|
};
|
|
|
|
// Inputs.
|
|
let input_ids: Vec<String> = g
|
|
.get(typed.id)
|
|
.map(|e| e.core.inputs.iter().map(|i| i.id.clone()).collect())
|
|
.unwrap_or_default();
|
|
let mut inputs = Vec::with_capacity(input_ids.len());
|
|
for (idx, id_str) in input_ids.iter().enumerate() {
|
|
if id_str.is_empty() {
|
|
continue;
|
|
}
|
|
inputs.push(RealPort {
|
|
id: port_id(ident, PortKind::Input, idx as u32),
|
|
kind: PortKind::Input,
|
|
label: id_str.clone().into(),
|
|
data_type: video_type(),
|
|
connected: g.is_input_connected(typed.id, id_str, -1),
|
|
});
|
|
}
|
|
|
|
// The single implicit output.
|
|
let out_count = g.output_connections(typed.id).len();
|
|
let outputs = vec![RealPort {
|
|
id: port_id(ident, PortKind::Output, 0),
|
|
kind: PortKind::Output,
|
|
label: SharedString::new_static("out"),
|
|
data_type: out_type(),
|
|
connected: out_count > 0,
|
|
}];
|
|
|
|
let position = context_position(g, seq, typed.id);
|
|
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 mut edges_of = Vec::new();
|
|
for (to, conn_id, _element) in g.output_connections(typed.id) {
|
|
if !g.is_valid(to) || conn_id.is_empty() {
|
|
continue;
|
|
}
|
|
let to_ident = to.identity();
|
|
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, to_index),
|
|
});
|
|
}
|
|
node_edges.push((typed.ident, edges_of));
|
|
}
|
|
|
|
// 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).
|
|
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);
|
|
}
|
|
(nodes, edges)
|
|
}
|
|
|
|
/// 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)`.
|
|
fn fallback_base(g: &oaknode::graph::Graph, seq: DomainNodeId, ident: u64) -> (f64, f64) {
|
|
let seq_ident = seq.identity();
|
|
let all = graph_nodes(g, seq);
|
|
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 placed = g
|
|
.get(n.id)
|
|
.and_then(|e| {
|
|
e.core
|
|
.context_positions
|
|
.iter()
|
|
.find(|(c, _, _)| *c == seq)
|
|
.map(|(_, pos, _)| *pos)
|
|
})
|
|
.map(|(x, y)| x != 0.0 || y != 0.0)
|
|
.unwrap_or(false);
|
|
if !placed {
|
|
row += 1;
|
|
}
|
|
}
|
|
(
|
|
40.0 + f64::from(target_role * 260),
|
|
40.0 + f64::from(row * 180),
|
|
)
|
|
}
|
|
|
|
/// Resolve a domain node id from a widget identity, validating it against
|
|
/// the graph.
|
|
fn find_node(g: &oaknode::graph::Graph, ident: u64) -> Result<DomainNodeId, String> {
|
|
let Some(id) = graphops::id_of(ident) else {
|
|
return Err(format!("node {ident} not found"));
|
|
};
|
|
if !g.is_valid(id) {
|
|
return Err(format!("node {ident} not found"));
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Connection rules and edit application
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Whether connecting output port `from` to input port `to` is valid in
|
|
/// the 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).
|
|
pub fn can_connect(project: &ProjectRef, from: PortId, to: PortId) -> bool {
|
|
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 {
|
|
return false;
|
|
}
|
|
if from_node == to_node {
|
|
return false;
|
|
}
|
|
let guard = graphops::lock(project);
|
|
let g = &guard.graph;
|
|
let Ok(node) = find_node(g, to_node) else {
|
|
return false;
|
|
};
|
|
let Some(entry) = g.get(node) else {
|
|
return false;
|
|
};
|
|
let Some(id_str) = entry.core.inputs.get(to_index as usize).map(|i| i.id.clone()) else {
|
|
return false;
|
|
};
|
|
if id_str.is_empty() {
|
|
return false;
|
|
}
|
|
!g.is_input_connected(node, &id_str, -1)
|
|
}
|
|
|
|
/// Apply a node-graph edit to the sequence's graph (undoable through the
|
|
/// global undo stack).
|
|
pub fn apply_edit(
|
|
project: &ProjectRef,
|
|
seq: DomainNodeId,
|
|
edit: &gpui::node_graph::NodeGraphEvent,
|
|
) -> Result<(), String> {
|
|
use gpui::node_graph::NodeGraphEvent;
|
|
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 {
|
|
return Err("connection endpoints must be output → input".into());
|
|
}
|
|
let (src, dst, id_str) = {
|
|
let guard = graphops::lock(project);
|
|
let g = &guard.graph;
|
|
let src = find_node(g, from_node)?;
|
|
let dst = find_node(g, to_node)?;
|
|
let id_str = g
|
|
.get(dst)
|
|
.and_then(|e| e.core.inputs.get(to_index as usize))
|
|
.map(|i| i.id.clone())
|
|
.filter(|s| !s.is_empty());
|
|
let Some(id_str) = id_str else {
|
|
return Err("target input missing".into());
|
|
};
|
|
(src, dst, id_str)
|
|
};
|
|
let cmd = graphops::connect_command(project, src, dst, &id_str)?;
|
|
graphops::push_command(cmd, "Connect Nodes")
|
|
}
|
|
NodeGraphEvent::DisconnectionRequested { edge } => {
|
|
if is_output_wire(*edge) {
|
|
// The synthesized clip → output wire is structural; the
|
|
// graph has no such edge to remove.
|
|
return Ok(());
|
|
}
|
|
let (dst, conn_id) = {
|
|
let guard = graphops::lock(project);
|
|
let g = &guard.graph;
|
|
let mut found = None;
|
|
'outer: for (typed_from, to, conn_id, _) in g.output_connections_all() {
|
|
if real_edge_id(typed_from.identity(), to.identity(), &conn_id).0 == edge.0 {
|
|
found = Some((to, conn_id));
|
|
break 'outer;
|
|
}
|
|
}
|
|
found.ok_or_else(|| format!("edge {} not found", edge.0))?
|
|
};
|
|
let cmd = graphops::disconnect_command(project, dst, &conn_id)?;
|
|
graphops::push_command(cmd, "Disconnect Nodes")
|
|
}
|
|
NodeGraphEvent::NodeMoveRequested { nodes, delta } => {
|
|
for node in nodes {
|
|
let (id, base) = {
|
|
let guard = graphops::lock(project);
|
|
let g = &guard.graph;
|
|
let id = find_node(g, node.0)?;
|
|
let placed = g
|
|
.get(id)
|
|
.and_then(|e| {
|
|
e.core
|
|
.context_positions
|
|
.iter()
|
|
.find(|(c, _, _)| *c == seq)
|
|
.map(|(_, pos, _)| *pos)
|
|
})
|
|
.filter(|(x, y)| *x != 0.0 || *y != 0.0);
|
|
let base = match placed {
|
|
Some(pos) => pos,
|
|
None => fallback_base(g, seq, node.0),
|
|
};
|
|
(id, base)
|
|
};
|
|
let cmd = graphops::set_context_position_command(
|
|
project,
|
|
id,
|
|
seq,
|
|
base.0 + f64::from(delta.x.as_f32()),
|
|
base.1 + f64::from(delta.y.as_f32()),
|
|
)?;
|
|
graphops::push_command(cmd, "Set Position")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
NodeGraphEvent::DeleteRequested { nodes, .. } => {
|
|
for node in nodes {
|
|
// The output node is the graph's context; never deleted.
|
|
if node.0 == seq.identity() {
|
|
continue;
|
|
}
|
|
let id = {
|
|
let guard = graphops::lock(project);
|
|
find_node(&guard.graph, node.0)?
|
|
};
|
|
graphops::remove_node(project, id)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|