// 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 . //! Direct oaknode domain operations backing the task module (single-lib //! unification). //! //! The old `src/stubs.rs` stood in for the deleted oaknode / oaktimeline / //! oakrender C ABIs with `CHandle`-typed failure paths. Every one of those //! stubs is replaced here by real operations over `oaknode` domain types: //! a project is `Arc>` and every node is //! an `oaknode::id::NodeId` in that project's `oaknode::graph::Graph`. //! //! Undo commands follow the oaktimeline `undocommon` pattern: a command //! struct is boxed into an `oakundo::undocommand::UndoCommand` vtable //! command ([`box_command`]); `redo_now`/`undo_now` dispatch to it. use std::sync::{Arc, Mutex, MutexGuard}; use oakcommon::videoparams::VideoParams as CommonVideoParams; use oakcore_rs::Rational; use oaknode::block::{BlockCore, ClipBlockBehavior, GapBlockBehavior, TransitionBlockBehavior}; use oaknode::folder::FolderBehavior; use oaknode::footage::{FootageBehavior, StreamInfo}; use oaknode::graph::Graph; use oaknode::id::NodeId; use oaknode::node::{NodeBehavior, NodeCore}; use oaknode::project::Project; use oaknode::sequence::SequenceBehavior; use oaknode::track::{BlockRange, TrackBehavior, TrackListBehavior, TrackRange, TrackType}; use oakundo::undocommand::{OakUndoCommandVtable, UndoCommand}; /// A project, shared like the C++ `olive::Project` smart pointer. pub type ProjectRef = Arc>; /// A reference to one node: its owning project and its id there. Replaces /// the borrowed `CHandle` node handles of the deleted C ABI. pub type NodeRef = (ProjectRef, NodeId); /// `OAKNODE_TRANSITION_CROSS_DISSOLVE` (`include/node/transition.h`); /// transition kinds other than cross-dissolve are not modelled. pub const TRANSITION_CROSS_DISSOLVE: i32 = 0; /// `OAKNODE_TYPE_TRANSFORM` (`include/node/factory.h`): the transform node /// type id used by the OTIO loader. pub const TRANSFORM_TYPE_ID: &str = "org.olivevideoeditor.Olive.transform"; /// `OAKNODE_TYPE_VOLUME` (`include/node/factory.h`): the volume node type id /// used by the OTIO loader. pub const VOLUME_TYPE_ID: &str = "org.olivevideoeditor.Olive.volume"; /// `OAKNODE_TYPE_SEQUENCE` (`include/node/factory.h`): the sequence type id /// (matches `SequenceBehavior::type_id`). pub const SEQUENCE_TYPE_ID: &str = "org.olivevideoeditor.Olive.sequence"; /// Clip texture/effect input id (oaknode `clip_input::TEXTURE_INPUT`; the /// old `OAKNODE_SEQUENCE_TEXTURE_INPUT` value). pub const CLIP_TEXTURE_INPUT: &str = "tex_in"; /// Transition connection inputs (oaknode `transition_input`). pub const TRANSITION_OUT_BLOCK_INPUT: &str = "out_block_in"; /// Transition connection input (incoming side). pub const TRANSITION_IN_BLOCK_INPUT: &str = "in_block_in"; /// Block kinds, mirroring the `OAKNODE_BLOCK_*` constants of the deleted /// C ABI. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BlockKind { /// Unknown block type (`OAKNODE_BLOCK_OTHER`). Other, /// Media-bearing clip (`OAKNODE_BLOCK_CLIP`). Clip, /// Empty span (`OAKNODE_BLOCK_GAP`). Gap, /// Transition (`OAKNODE_BLOCK_TRANSITION`). Transition, } /// Lock the project mutex (poisoned locks recover their guard — task code /// has no invariants that survive a panic). fn lock_project(project: &ProjectRef) -> MutexGuard<'_, Project> { project.lock().unwrap_or_else(|e| e.into_inner()) } /// The shared block core of any block-typed node, or `None` for non-block /// nodes / stale ids. pub fn block_core_of(graph: &Graph, block: NodeId) -> Option { let entry = graph.get(block)?; let any = entry.behavior.as_any()?; if let Some(b) = any.downcast_ref::() { return Some(b.core.clone()); } if let Some(b) = any.downcast_ref::() { return Some(b.core.clone()); } if let Some(b) = any.downcast_ref::() { return Some(b.core.clone()); } None } /// Set the shared block core fields of any block-typed node (`track` /// ownership aside); false for non-block nodes / stale ids. fn set_block_core(graph: &mut Graph, block: NodeId, f: impl FnOnce(&mut BlockCore)) -> bool { let Some(entry) = graph.get_mut(block) else { return false; }; let Some(any) = entry.behavior.as_any_mut() else { return false; }; if let Some(b) = any.downcast_mut::() { f(&mut b.core); return true; } if let Some(b) = any.downcast_mut::() { f(&mut b.core); return true; } if let Some(b) = any.downcast_mut::() { f(&mut b.core); return true; } false } /// The user label of a node (C++ `Node::label`); empty for missing nodes. pub fn node_label(project: &ProjectRef, node: NodeId) -> String { let guard = lock_project(project); match guard.graph.get(node) { Some(entry) => entry.core.label.clone(), None => String::new(), } } /// Set the user label of a node (`oaknode_node_set_label`). pub fn set_node_label(project: &ProjectRef, node: NodeId, label: &str) { let mut guard = lock_project(project); if let Some(entry) = guard.graph.get_mut(node) { entry.core.label = label.to_string(); } } /// The node's stable type id (`oaknode_node_get_id`; C++ `Node::id()`). pub fn node_type_id(project: &ProjectRef, node: NodeId) -> String { let guard = lock_project(project); match guard.graph.get(node) { Some(entry) => entry.behavior.type_id().to_string(), None => String::new(), } } /// True when the node's behavior is the footage type. pub fn node_is_footage(graph: &Graph, node: NodeId) -> bool { graph .get(node) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .is_some() } /// True when the node's behavior is the sequence type. pub fn node_is_sequence(graph: &Graph, node: NodeId) -> bool { graph .get(node) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .is_some() } /// Set a node's position inside a context (the C++ /// `oaknode_node_set_context_position`; `expanded` replaces the old /// `force != 0` flag). pub fn node_set_context_position( project: &ProjectRef, node: NodeId, context: NodeId, x: f64, y: f64, expanded: bool, ) { let mut guard = lock_project(project); if let Some(entry) = guard.graph.get_mut(node) { entry.core.set_context_position(context, x, y, expanded); } } /// The total length of a footage or sequence node (footage: longest video /// stream; sequence: longest track list; `0/1` otherwise) — the domain /// replacement for `oaknode_footage_get_video_length` / /// `oaknode_sequence_get_length`. pub fn node_length(project: &ProjectRef, node: NodeId) -> Rational { let guard = lock_project(project); let Some(entry) = guard.graph.get(node) else { return Rational::new(0, 1); }; let Some(any) = entry.behavior.as_any() else { return Rational::new(0, 1); }; if let Some(f) = any.downcast_ref::() { return f.video_length(); } if let Some(seq) = any.downcast_ref::() { let blocks = GraphBlockRange { graph: &guard.graph, }; let tracks = GraphTrackRange { graph: &guard.graph, }; let mut longest = Rational::new(0, 1); for list_id in seq.track_lists.iter().filter(|i| i.valid()) { let Some(le) = guard.graph.get(*list_id) else { continue; }; let Some(list) = le .behavior .as_any() .and_then(|a| a.downcast_ref::()) else { continue; }; let len = list.total_length(&tracks); if len > longest { longest = len; } } let _ = blocks; if longest > Rational::new(0, 1) { return longest; } return seq.last_length; } Rational::new(0, 1) } /// Block-range access over the project graph (the adapter [`TrackBehavior`] /// queries need; `// CPP-PARITY: src/task/...` uses the node graph directly). pub struct GraphBlockRange<'a> { /// The graph the blocks live in. pub graph: &'a Graph, } impl BlockRange for GraphBlockRange<'_> { fn in_(&self, block: NodeId) -> Rational { block_core_of(self.graph, block) .map(|c| c.in_()) .unwrap_or_else(|| Rational::new(0, 1)) } fn out(&self, block: NodeId) -> Rational { block_core_of(self.graph, block) .map(|c| c.out()) .unwrap_or_else(|| Rational::new(0, 1)) } } /// Track-range access over the project graph (the adapter /// [`TrackListBehavior::total_length`] needs). pub struct GraphTrackRange<'a> { /// The graph the tracks live in. pub graph: &'a Graph, } impl TrackRange for GraphTrackRange<'_> { fn length(&self, track: NodeId) -> Rational { let Some(entry) = self.graph.get(track) else { return Rational::new(0, 1); }; let Some(t) = entry .behavior .as_any() .and_then(|a| a.downcast_ref::()) else { return Rational::new(0, 1); }; t.length(&GraphBlockRange { graph: self.graph }) } } /// Convert an oaknode video-params value to the oakcommon `VideoParams` /// value type the task code works with (the C ABI marshalled the same /// fields). fn common_from_node_video(v: oaknode::value::VideoParams) -> CommonVideoParams { let mut p = CommonVideoParams::new_basic( v.width, v.height, oakcommon::ocioutils::PixelFormat::from_code(v.pixel_format), v.channels, 1, 1, 0, 1, ); p.set_frame_rate(v.frame_rate.numerator() as i32, v.frame_rate.denominator() as i32); p } /// Convert an oakcommon video-params value back to the oaknode value type /// (the fields that survive the C ABI marshalling; `video_type`, /// `start_time`/`duration` and color fields have no oaknode counterpart). fn node_video_from_common(v: &CommonVideoParams) -> oaknode::value::VideoParams { oaknode::value::VideoParams { width: v.width(), height: v.height(), frame_rate: Rational::new(v.frame_rate().0 as i64, v.frame_rate().1 as i64), pixel_format: v.format().code(), channels: v.channel_count(), } } /// The sequence's `index`th video parameter stream as the oakcommon value /// type (`oaknode_sequence_get_video_params`). pub fn sequence_video_params( project: &ProjectRef, sequence: NodeId, index: usize, ) -> Option { let guard = lock_project(project); let entry = guard.graph.get(sequence)?; let seq = entry .behavior .as_any()? .downcast_ref::()?; seq.video_params.get(index).copied().map(common_from_node_video) } /// The sequence's audio output parameters `(sample_rate, channel_layout)` /// (defaults to 48000 Hz stereo, the sequence defaults). pub fn sequence_audio_params(project: &ProjectRef, sequence: NodeId) -> (i32, u64) { let guard = lock_project(project); let Some(entry) = guard.graph.get(sequence) else { return (48000, 0x3); }; let Some(seq) = entry .behavior .as_any() .and_then(|a| a.downcast_ref::()) else { return (48000, 0x3); }; match seq.audio_params.first() { Some(a) => (a.sample_rate, a.channel_layout), None => (48000, 0x3), } } /// Create a new footage node in `project` (optionally pre-set with a /// filename; unprobed — call [`footage_set_filename`] to probe). `None` is /// no longer reachable (the old null-handle allocation path); the `Option` /// keeps the call sites' shape. pub fn footage_create(project: &ProjectRef, filename: Option<&str>) -> Option { let mut guard = lock_project(project); let (core, behavior) = oaknode::footage::FootageBehavior::create(); let id = guard.graph.add_node(core, behavior); if let Some(filename) = filename { if let Some(entry) = guard.graph.get_mut(id) { if let Some(f) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { f.filename = filename.to_string(); } } } Some(id) } /// Set the footage cancellation flag (the C++ footage cancel atom; the /// footage behavior owns its own atom, mirrored from the task's). pub fn footage_set_cancelled(project: &ProjectRef, footage: NodeId, cancelled: bool) { let mut guard = lock_project(project); if let Some(entry) = guard.graph.get_mut(footage) { if let Some(f) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { f.set_cancel(cancelled); } } } /// Set the footage's filename and re-probe it through the oakcodec decoder /// registry (`oaknode_footage_set_filename`); returns the post-probe /// validity (the C++ set_filename → Probe() → is_valid chain). pub fn footage_set_filename(project: &ProjectRef, footage: NodeId, filename: &str) -> bool { let mut guard = lock_project(project); let Some(entry) = guard.graph.get_mut(footage) else { return false; }; let Some(f) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) else { return false; }; f.filename = filename.to_string(); f.valid = false; entry .core .set_standard_value("file_in", -1, oaknode::value::NodeValue::Text(filename.to_string())); if f.probe().is_err() { return false; } f.valid } /// Whether the footage's last probe succeeded (`oaknode_footage_is_valid`). pub fn footage_is_valid(project: &ProjectRef, footage: NodeId) -> bool { let guard = lock_project(project); guard .graph .get(footage) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .map(|f| f.valid) .unwrap_or(false) } /// The footage's absolute filename (`oaknode_footage_filename`). pub fn footage_filename(project: &ProjectRef, footage: NodeId) -> String { let guard = lock_project(project); guard .graph .get(footage) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .map(|f| f.filename.clone()) .unwrap_or_default() } /// Total stream count of the footage (`oaknode_footage_total_stream_count`). pub fn footage_total_stream_count(project: &ProjectRef, footage: NodeId) -> usize { let guard = lock_project(project); guard .graph .get(footage) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .map(|f| f.total_stream_count()) .unwrap_or(0) } /// The footage's `index`th video parameter stream as the oakcommon value /// type (`oaknode_footage_get_video_params`). pub fn footage_video_params( project: &ProjectRef, footage: NodeId, index: usize, ) -> Option { let guard = lock_project(project); let f = guard .graph .get(footage)? .behavior .as_any()? .downcast_ref::()?; f.video_params(index).map(common_from_node_video) } /// Overwrite (or create) the footage's `index`th video stream parameters /// (`oaknode_footage_set_video_params`). pub fn footage_set_video_params( project: &ProjectRef, footage: NodeId, index: usize, params: &CommonVideoParams, ) { let mut guard = lock_project(project); let Some(entry) = guard.graph.get_mut(footage) else { return; }; let Some(f) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) else { return; }; let node_params = node_video_from_common(params); let mut seen = 0usize; let mut found = false; for s in f.streams.iter_mut().filter(|s| s.is_video) { if seen == index { s.video = Some(node_params); found = true; break; } seen += 1; } if !found { for _ in seen..=index { f.streams.push(StreamInfo { index: f.streams.len() as i32, is_video: true, video: None, audio: None, duration: Rational::new(0, 1), }); } if let Some(s) = f.streams.iter_mut().filter(|s| s.is_video).nth(index) { s.video = Some(node_params); } } } /// Create a new bin folder node (`oaknode_folder_create`; `None` keeps the /// call-site shape of the old null-handle path). pub fn folder_create(project: &ProjectRef) -> Option { let mut guard = lock_project(project); let (core, behavior) = oaknode::folder::create(""); Some(guard.graph.add_node(core, behavior)) } /// Create a new sequence node with default parameters /// (`oaknode_sequence_create` + `oaknode_sequence_set_default_parameters`; /// the domain constructor applies the defaults itself). pub fn sequence_create(project: &ProjectRef) -> Option { let mut guard = lock_project(project); let (core, behavior) = oaknode::sequence::SequenceBehavior::create(); Some(guard.graph.add_node(core, behavior)) } /// The sequence's track list for `kind`, creating it on first use (the C++ /// `Sequence::get_track_list`). pub fn sequence_track_list( project: &ProjectRef, sequence: NodeId, kind: TrackType, ) -> Option { let base = match kind { TrackType::Video => 0usize, TrackType::Audio => 1usize, TrackType::Subtitle => 2usize, }; let mut guard = lock_project(project); { let entry = guard.graph.get(sequence)?; let seq = entry .behavior .as_any()? .downcast_ref::()?; if let Some(id) = seq.track_lists.get(base).filter(|i| i.valid()) { return Some(*id); } } let list_id = guard.graph.add_node( NodeCore::new(), Box::new(TrackListBehavior::new(kind)), ); if let Some(le) = guard.graph.get_mut(list_id) { if let Some(l) = le .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { l.sequence = Some(sequence); l.array_base = base as i32; } } if let Some(entry) = guard.graph.get_mut(sequence) { if let Some(seq) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { while seq.track_lists.len() <= base { seq.track_lists.push(NodeId::INVALID); } seq.track_lists[base] = list_id; } } Some(list_id) } /// Number of tracks on the list (`oaknode_tracklist_get_track_count`). pub fn tracklist_track_count(project: &ProjectRef, list: NodeId) -> usize { let guard = lock_project(project); guard .graph .get(list) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .map(|l| l.tracks.len()) .unwrap_or(0) } /// Track at `index` on the list (`oaknode_tracklist_get_track_at`). pub fn tracklist_track_at(project: &ProjectRef, list: NodeId, index: usize) -> Option { let guard = lock_project(project); guard .graph .get(list)? .behavior .as_any()? .downcast_ref::()? .track_at(index) } /// The track's media type (`oaknode_track_get_type`). pub fn track_type(project: &ProjectRef, track: NodeId) -> Option { let guard = lock_project(project); guard .graph .get(track) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .map(|t| t.kind) } /// Number of blocks on the track (`oaknode_track_get_block_count`). pub fn track_block_count(project: &ProjectRef, track: NodeId) -> usize { let guard = lock_project(project); guard .graph .get(track) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .map(|t| t.blocks.len()) .unwrap_or(0) } /// Block at `index` on the track (`oaknode_track_get_block_at`). pub fn track_block_at(project: &ProjectRef, track: NodeId, index: usize) -> Option { let guard = lock_project(project); guard .graph .get(track)? .behavior .as_any()? .downcast_ref::()? .block_at(index) } /// The track's total length (end of its last block; `oaknode_track_get_length`). pub fn track_length(project: &ProjectRef, track: NodeId) -> Rational { let guard = lock_project(project); let Some(entry) = guard.graph.get(track) else { return Rational::new(0, 1); }; let Some(t) = entry .behavior .as_any() .and_then(|a| a.downcast_ref::()) else { return Rational::new(0, 1); }; t.length(&GraphBlockRange { graph: &guard.graph }) } /// Append a block to a track and record the track ownership on the block /// (`oaknode_track_append_block`); false for stale ids. pub fn track_append_block(project: &ProjectRef, track: NodeId, block: NodeId) -> bool { let mut guard = lock_project(project); let _ = set_block_core(&mut guard.graph, block, |core| core.track = Some(track)); let Some(entry) = guard.graph.get_mut(track) else { return false; }; let Some(t) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) else { return false; }; t.append_block(block); true } /// Create a block node of the given kind (`oaknode_block_clip_create` / /// `oaknode_block_gap_create` / `oaknode_block_transition_create`; the /// transition kind argument is ignored — only cross-dissolve is modelled). pub fn block_create(project: &ProjectRef, kind: BlockKind) -> Option { let mut guard = lock_project(project); let (core, behavior): (NodeCore, Box) = match kind { BlockKind::Clip => oaknode::block::clip_create(), BlockKind::Gap => oaknode::block::gap_create(), BlockKind::Transition => oaknode::block::transition_create(), BlockKind::Other => return None, }; Some(guard.graph.add_node(core, behavior)) } /// The block's kind (`oaknode_block_get_kind`). pub fn block_kind(project: &ProjectRef, block: NodeId) -> BlockKind { let guard = lock_project(project); let Some(entry) = guard.graph.get(block) else { return BlockKind::Other; }; let Some(any) = entry.behavior.as_any() else { return BlockKind::Other; }; if any.downcast_ref::().is_some() { BlockKind::Clip } else if any.downcast_ref::().is_some() { BlockKind::Gap } else if any.downcast_ref::().is_some() { BlockKind::Transition } else { BlockKind::Other } } /// The block's timeline in point (`oaknode_block_get_in`). pub fn block_in(project: &ProjectRef, block: NodeId) -> Rational { let guard = lock_project(project); block_core_of(&guard.graph, block) .map(|c| c.in_()) .unwrap_or_else(|| Rational::new(0, 1)) } /// The block's timeline length (`oaknode_block_get_length`). pub fn block_length(project: &ProjectRef, block: NodeId) -> Rational { let guard = lock_project(project); block_core_of(&guard.graph, block) .map(|c| c.length()) .unwrap_or_else(|| Rational::new(0, 1)) } /// The block's media in point (C++ `Block::media_in`). pub fn block_media_in(project: &ProjectRef, block: NodeId) -> Rational { let guard = lock_project(project); block_core_of(&guard.graph, block) .map(|c| c.media_in) .unwrap_or_else(|| Rational::new(0, 1)) } /// Set the block's length keeping the media out anchored /// (`oaknode_block_set_length_and_media_out`). pub fn block_set_length_and_media_out(project: &ProjectRef, block: NodeId, n: i64, d: i64) { let mut guard = lock_project(project); let _ = set_block_core(&mut guard.graph, block, |core| { core.set_length_and_media_out(Rational::new(n, d)) }); } /// Set a clip's media in point (`oaknode_clip_set_media_in`). pub fn clip_set_media_in(project: &ProjectRef, block: NodeId, n: i64, d: i64) { let mut guard = lock_project(project); if let Some(entry) = guard.graph.get_mut(block) { if let Some(c) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { c.core.media_in = Rational::new(n, d); } } } /// Record the footage connected to a clip (the domain model keeps the /// clip-footage link in [`ClipBlockBehavior::footage`]). pub fn clip_set_footage(project: &ProjectRef, block: NodeId, footage: NodeId) { let mut guard = lock_project(project); if let Some(entry) = guard.graph.get_mut(block) { if let Some(c) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { c.footage = Some(footage); } } } /// The clip footage recorded on a clip block, if any. pub fn clip_footage(project: &ProjectRef, block: NodeId) -> Option { let guard = lock_project(project); guard .graph .get(block)? .behavior .as_any()? .downcast_ref::()? .footage } /// A transition's in offset (`oaknode_transition_get_in_offset`). pub fn transition_in_offset(project: &ProjectRef, block: NodeId) -> Rational { let guard = lock_project(project); guard .graph .get(block) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .map(|t| t.in_offset) .unwrap_or_else(|| Rational::new(0, 1)) } /// A transition's out offset (`oaknode_transition_get_out_offset`). pub fn transition_out_offset(project: &ProjectRef, block: NodeId) -> Rational { let guard = lock_project(project); guard .graph .get(block) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .map(|t| t.out_offset) .unwrap_or_else(|| Rational::new(0, 1)) } /// Set a transition's offsets and derive its length from them /// (`oaknode_transition_set_offsets_and_length`). pub fn transition_set_offsets_and_length( project: &ProjectRef, block: NodeId, in_n: i64, in_d: i64, out_n: i64, out_d: i64, ) { let mut guard = lock_project(project); if let Some(entry) = guard.graph.get_mut(block) { if let Some(t) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { t.in_offset = Rational::new(in_n, in_d); t.out_offset = Rational::new(out_n, out_d); let length = t.in_offset + t.out_offset; t.core.set_length_and_media_out(length); } } } /// Connect `from`'s output to `to`'s `input_id` (`oaknode_node_connect`); /// false on invalid endpoints/inputs or cycles. pub fn node_connect(project: &ProjectRef, from: NodeId, to: NodeId, input_id: &str) -> bool { let mut guard = lock_project(project); guard.graph.connect(from, to, input_id, -1).is_ok() } /// Walk the node's input chain to its footage source (C++ /// `node_find_input_footage`): footage nodes answer with themselves, clip /// blocks answer with their recorded footage, other nodes are walked /// upstream along graph edges. Pure graph query — callers already holding /// the project lock use this variant. pub fn find_input_footage(graph: &Graph, node: NodeId) -> Option { if node_is_footage(graph, node) { return Some(node); } if let Some(clip) = graph .get(node) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) { if let Some(f) = clip.footage { if graph.is_valid(f) { return Some(f); } } } let mut stack = vec![node]; let mut seen = std::collections::HashSet::new(); while let Some(n) = stack.pop() { if !seen.insert(n) { continue; } for (from, _, _) in graph.input_connections(n) { if node_is_footage(graph, from) { return Some(from); } stack.push(from); } } None } /// Locking variant of [`find_input_footage`] /// (`oaknode_node_find_input_footage`). pub fn node_find_input_footage(project: &ProjectRef, node: NodeId) -> Option { let guard = lock_project(project); find_input_footage(&guard.graph, node) } /// Map an `olive::core::PixelFormat` code to `oakcore_rs::PixelFormat` /// (unknown codes map to `Invalid`). pub fn pixel_format_from_code(code: i32) -> oakcore_rs::PixelFormat { match code { -1 => oakcore_rs::PixelFormat::Invalid, 0 => oakcore_rs::PixelFormat::U8, 1 => oakcore_rs::PixelFormat::U10, 2 => oakcore_rs::PixelFormat::U16, 3 => oakcore_rs::PixelFormat::F16, 4 => oakcore_rs::PixelFormat::F32, _ => oakcore_rs::PixelFormat::Invalid, } } // --------------------------------------------------------------------------- // Undo commands // --------------------------------------------------------------------------- /// Command trait for the task-local undo commands (mirrors the /// oaktimeline `undocommon::Command` pattern). trait TaskCommand { /// Apply the change. fn redo(&mut self); /// Revert the change. fn undo(&mut self); } /// Generic `redo` callback forwarding to [`TaskCommand::redo`]. /// /// # Safety /// `userdata` must be the `Box` produced by [`box_command`]. unsafe extern "C" fn redo_cb(userdata: *mut std::ffi::c_void) { if userdata.is_null() { return; } // SAFETY: box_command allocated a `Box`; the command still owns it. (unsafe { &mut *(userdata as *mut T) }).redo(); } /// Generic `undo` callback forwarding to [`TaskCommand::undo`]. /// /// # Safety /// As `redo_cb`. unsafe extern "C" fn undo_cb(userdata: *mut std::ffi::c_void) { if userdata.is_null() { return; } // SAFETY: as `redo_cb`. (unsafe { &mut *(userdata as *mut T) }).undo(); } /// Generic `free` callback dropping the boxed command. /// /// # Safety /// `userdata` must be the `Box` produced by [`box_command`]. unsafe extern "C" fn free_cb(userdata: *mut std::ffi::c_void) { if userdata.is_null() { return; } // SAFETY: the command owns this box; dropping it frees the command. unsafe { drop(Box::from_raw(userdata as *mut T)); } } /// Box a task command into an oakundo vtable command value. fn box_command(cmd: T) -> UndoCommand { let userdata = Box::into_raw(Box::new(cmd)) as *mut std::ffi::c_void; let vtable = OakUndoCommandVtable { redo: Some(redo_cb::), undo: Some(undo_cb::), free_fn: Some(free_cb::), }; UndoCommand::from_vtable(vtable, userdata) } /// `FolderAddChildCommand` — add an item to a bin folder /// (`oaknode_command_create_folder_add_child`). struct FolderAddChildCommand { /// The destination folder. folder: NodeRef, /// The item (footage/folder/sequence) to add. child: NodeRef, } impl TaskCommand for FolderAddChildCommand { fn redo(&mut self) { let mut guard = lock_project(&self.folder.0); if let Some(entry) = guard.graph.get_mut(self.folder.1) { if let Some(f) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { f.add_child(self.child.1); } } if let Some(entry) = guard.graph.get_mut(self.child.1) { entry.core.bin_folder = Some(self.folder.1); } } fn undo(&mut self) { let mut guard = lock_project(&self.folder.0); if let Some(entry) = guard.graph.get_mut(self.folder.1) { if let Some(f) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { f.remove_child(self.child.1); } } if let Some(entry) = guard.graph.get_mut(self.child.1) { entry.core.bin_folder = None; } } } /// Build the undo command that adds `child` to `folder` /// (`oaknode_command_create_folder_add_child`). pub fn folder_add_child_command(folder: NodeRef, child: NodeRef) -> UndoCommand { box_command(FolderAddChildCommand { folder, child }) } /// `RemoveNodeCommand` — detach a node from the project graph /// (`oaknode_command_create_remove_node`). The node's entry (core + /// behavior + generation) is kept between redo and undo; undo re-inserts /// it into its original slot. Edges are dropped on redo and not restored /// on undo (the C++ command's connections survive; this task module only /// removes unconnected nodes). struct RemoveNodeCommand { /// The owning project. project: ProjectRef, /// The node to remove. node: NodeId, /// The detached entry, captured at redo. entry: Option, /// The node's bin folder at redo time. folder: Option, /// The node's child index in that folder. folder_index: Option, } impl TaskCommand for RemoveNodeCommand { fn redo(&mut self) { let mut guard = lock_project(&self.project); let bin_folder = guard .graph .get(self.node) .and_then(|e| e.core.bin_folder); let index = bin_folder.and_then(|fid| { guard .graph .get(fid) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) .and_then(|f| f.index_of_child(self.node)) }); if let (Some(fid), Some(_)) = (bin_folder, index) { if let Some(entry) = guard.graph.get_mut(fid) { if let Some(f) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { f.remove_child(self.node); } } } let entry = guard.graph.take_node(self.node); self.entry = entry; self.folder = bin_folder; self.folder_index = index; } fn undo(&mut self) { let Some(entry) = self.entry.take() else { return; }; let mut guard = lock_project(&self.project); guard.graph.add_entry(entry, self.node); if let (Some(fid), Some(index)) = (self.folder, self.folder_index) { if let Some(entry) = guard.graph.get_mut(fid) { if let Some(f) = entry .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { let index = index.min(f.children.len()); f.children.insert(index, self.node); } } if let Some(entry) = guard.graph.get_mut(self.node) { entry.core.bin_folder = Some(fid); } } } } /// Build the undo command that removes `node` from its project /// (`oaknode_command_create_remove_node`). pub fn remove_node_command(project: ProjectRef, node: NodeId) -> UndoCommand { box_command(RemoveNodeCommand { project, node, entry: None, folder: None, folder_index: None, }) } /// `TimelineAddTrackCommand` — append a track to a track list /// (`TimelineAddTrackCommand::run_immediately` semantics). /// /// Domain replacement for oaktimeline's `TimelineAddTrackCommand`: that /// crate is mid-migration to `Arc>` + `NodeId` and still /// only exposes `CHandle`-based constructors (whose internals are local /// stubs), so oaktask cannot wire domain objects through it yet. This /// command performs the same work directly over the oaknode domain model; /// once oaktimeline's domain constructor lands, oaktask should switch to /// it. The C++ merge-node/automerge branch is not modelled (automerge is /// disabled by default and the sequence-input connection is implicit in /// the Rust track-list model). struct TimelineAddTrackCommand { /// The owning project. project: ProjectRef, /// The target track list. list: NodeId, /// The created track (set by redo). track: Option, /// The list's media type (copied at redo). kind: TrackType, } impl TaskCommand for TimelineAddTrackCommand { fn redo(&mut self) { let mut guard = lock_project(&self.project); let track_index = { let Some(entry) = guard.graph.get(self.list) else { return; }; let Some(list) = entry .behavior .as_any() .and_then(|a| a.downcast_ref::()) else { return; }; self.kind = list.kind; list.tracks.len() as i32 }; let track_id = guard.graph.add_node( NodeCore::new(), Box::new(TrackBehavior::new(self.kind)), ); if let Some(te) = guard.graph.get_mut(track_id) { if let Some(t) = te .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { t.index = track_index; t.track_list = Some(self.list); } } if let Some(le) = guard.graph.get_mut(self.list) { if let Some(l) = le .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { l.tracks.push(track_id); } } self.track = Some(track_id); } fn undo(&mut self) { let Some(track_id) = self.track.take() else { return; }; let mut guard = lock_project(&self.project); if let Some(le) = guard.graph.get_mut(self.list) { if let Some(l) = le .behavior .as_any_mut() .and_then(|a| a.downcast_mut::()) { l.tracks.retain(|t| *t != track_id); } } guard.graph.remove_node(track_id); } } /// Build the undo command that appends a track to `list` /// (`oaktimeline_add_track_command`; see [`TimelineAddTrackCommand`] for /// the oaktimeline-migration note). pub fn add_track_command(project: ProjectRef, list: NodeId) -> UndoCommand { box_command(TimelineAddTrackCommand { project, list, track: None, kind: TrackType::Video, }) }