// 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 . //! 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, /// Input ports (top to bottom). pub inputs: Vec, /// The single implicit output port. pub outputs: Vec, /// Category accent. pub header_color: Option, /// 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 { self.position } fn inputs(&self) -> Vec { self.inputs.clone() } fn outputs(&self) -> Vec { self.outputs.clone() } fn header_color(&self) -> Option { 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 { let mut out: Vec = 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 { 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, Vec) { 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 = 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)> = 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 = 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 { 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(()), } }