// 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 . //! Module-native engine helpers (M14 R2). //! //! oak-cli links the oak* module rlibs directly (oaknode / oaktimeline / //! oakcodec / oakrender / oaktask / oakcommon) instead of the built //! liboakengine dylib's C ABI. This module is the CLI's own assembly //! layer: it reproduces the facade operations the subcommands need — //! project load/create, footage probe, sequence + clip assembly, montage //! resolution, ticket rendering and the synchronous export path — over //! the modules' direct Rust APIs. //! //! The composition functions here mirror what the facade's C ABI exports //! (`oakengine_project_load`, `oakengine_sequence_add_footage_clip_ex`, //! `oakengine_renderer_render_frame`, ...) did over the same module APIs; //! the facade keeps its own copies for the frozen C ABI. Combinators that //! belong upstream (the effect-chain and timeline composites) are M14 //! follow-up candidates for `oaknode::ops`; kept local until then. use std::path::Path; use std::sync::{Arc, Mutex, MutexGuard}; use oakcore_rs::{Rational, TimeRange}; use oaknode::block::{self, ClipBlockBehavior}; use oaknode::footage::FootageBehavior; use oaknode::graph::Graph; use oaknode::id::NodeId; use oaknode::project::Project; use oaknode::sequence::SequenceBehavior; use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType}; use oaknode::value::VideoParams; use oaktimeline::undogeneral::TimelineAddTrackCommand; use oaktimeline::undopointer::TrackPlaceBlockCommand; use oaktimeline::util::NodeRef; use oakrender::manager::RenderManager; use oakrender::ticket::{ AudioTicketParams, MontageClip, TicketPayload, VideoTicketParams, }; /// The shared project reference (the modules' domain project handle). pub type ProjectRef = Arc>; /// Lock a project, recovering from a poisoned lock (a panicking command /// body must not wedge every later edit). fn lock(p: &ProjectRef) -> MutexGuard<'_, Project> { p.lock().unwrap_or_else(|e| e.into_inner()) } // --------------------------------------------------------------------------- // Project load / create // --------------------------------------------------------------------------- /// Load a `.ove` project file into a fresh project (the module /// serializer's `load` owns the graph). The filename is normalized to an /// absolute path and the modified flag cleared, mirroring the facade's /// `oakengine_project_load`. pub fn load_project(path: &str) -> Result { let xml = std::fs::read_to_string(path).map_err(|e| e.to_string())?; let project = oaknode::serializer::load(&xml).map_err(|e| e.to_string())?; let p = Path::new(path); let abs = if p.is_absolute() { p.to_path_buf() } else { std::env::current_dir() .map(|d| d.join(p)) .unwrap_or_else(|_| p.to_path_buf()) }; let mut guard = lock(&project); guard.set_filename(&abs.to_string_lossy()); guard.set_modified(false); drop(guard); Ok(project) } /// Project display name (`Project::name`; "(untitled)" when empty). pub fn project_name(p: &Project) -> String { p.name() } /// Project file path. pub fn project_filename(p: &Project) -> String { p.filename().to_string() } /// Project modified flag. pub fn project_modified(p: &Project) -> bool { p.is_modified() } // --------------------------------------------------------------------------- // Graph walks // --------------------------------------------------------------------------- /// Every sequence node in the graph, in arena order (the facade's /// `oakengine_project_sequence_count`/`sequence_at` walk order). pub fn sequence_ids(p: &Project) -> Vec { p.graph .node_ids() .into_iter() .filter(|&id| seq_behavior(&p.graph, id).is_some()) .collect() } /// Every footage node in the graph, in arena order. pub fn footage_ids(p: &Project) -> Vec { p.graph .node_ids() .into_iter() .filter(|&id| footage_behavior(&p.graph, id).is_some()) .collect() } /// Borrow the sequence behavior at `id`. fn seq_behavior(g: &Graph, id: NodeId) -> Option<&SequenceBehavior> { g.get(id)? .behavior .as_any()? .downcast_ref::() } /// Borrow the footage behavior at `id`. fn footage_behavior(g: &Graph, id: NodeId) -> Option<&FootageBehavior> { g.get(id)? .behavior .as_any()? .downcast_ref::() } /// The label of a node (`NodeCore::label`). pub fn node_label(g: &Graph, id: NodeId) -> String { g.get(id).map(|e| e.core.label.clone()).unwrap_or_default() } /// The footage filename at `id` (empty when the node is not footage). pub fn footage_filename(p: &Project, id: NodeId) -> String { footage_behavior(&p.graph, id) .map(|f| f.filename.clone()) .unwrap_or_default() } // --------------------------------------------------------------------------- // Sequence queries (info / render) // --------------------------------------------------------------------------- /// Sequence content length (seconds rational), 0/1 when unavailable. pub fn sequence_length(p: &Project, id: NodeId) -> Rational { match seq_behavior(&p.graph, id) { Some(s) => s.last_length, None => Rational::new(0, 1), } } /// Sequence frame rate (rational), 0/0 (the NULL sentinel) when the /// sequence has no video params — matching the facade's "0/0" report for /// such sequences. pub fn sequence_frame_rate(p: &Project, id: NodeId) -> Rational { match seq_behavior(&p.graph, id).and_then(|s| s.video_params.first()) { Some(v) => v.frame_rate, None => Rational::new(0, 0), } } /// Sequence output geometry `(width, height)` from its first video stream. pub fn sequence_geometry(p: &Project, id: NodeId) -> (i32, i32) { match seq_behavior(&p.graph, id).and_then(|s| s.video_params.first()) { Some(v) => (v.width, v.height), None => (0, 0), } } /// Sequence track counts `(video, audio, subtitle)`. pub fn sequence_track_counts(p: &Project, id: NodeId) -> (i64, i64, i64) { let mut counts = (0i64, 0i64, 0i64); let Some(seq) = seq_behavior(&p.graph, id) else { return counts; }; for &list_id in &seq.track_lists { let Some(list) = track_list_behavior(&p.graph, list_id) else { continue; }; let n = list.tracks.len() as i64; match list.kind { TrackType::Video => counts.0 += n, TrackType::Audio => counts.1 += n, TrackType::Subtitle => counts.2 += n, } } counts } /// Sequence playhead (seconds rational). pub fn sequence_playhead(p: &Project, id: NodeId) -> Rational { match seq_behavior(&p.graph, id) { Some(s) => s.playhead, None => Rational::new(0, 1), } } // --------------------------------------------------------------------------- // Transcode assembly // --------------------------------------------------------------------------- /// Create a sequence node in `project` (a scratch project, mirroring the /// facade's `oakengine_sequence_new` documented deviation) and label it. pub fn create_sequence(project: &ProjectRef, name: &str) -> NodeId { let id = { let mut guard = lock(project); let (core, behavior) = SequenceBehavior::create(); guard.graph.add_node(core, behavior) }; { let mut guard = lock(project); if let Some(e) = guard.graph.get_mut(id) { e.core.label = name.to_string(); } } id } /// Set the sequence's first video stream geometry + frame rate. pub fn set_sequence_video_params( p: &ProjectRef, id: NodeId, width: i32, height: i32, fr_num: i32, fr_den: i32, ) { let mut guard = lock(p); if let Some(s) = guard .graph .get_mut(id) .and_then(|e| e.behavior.as_any_mut()) .and_then(|a| a.downcast_mut::()) { if s.video_params.is_empty() { s.video_params.push(VideoParams { width, height, frame_rate: Rational::new(i64::from(fr_num), i64::from(fr_den)), pixel_format: 4, // f32 channels: 4, }); } else { let v = &mut s.video_params[0]; v.width = width; v.height = height; v.frame_rate = Rational::new(i64::from(fr_num), i64::from(fr_den)); } } } /// Borrow the track list behavior at `id`. fn track_list_behavior(g: &Graph, id: NodeId) -> Option<&TrackListBehavior> { g.get(id)? .behavior .as_any()? .downcast_ref::() } /// Borrow the track behavior at `id`. fn track_behavior(g: &Graph, id: NodeId) -> Option<&TrackBehavior> { g.get(id)? .behavior .as_any()? .downcast_ref::() } /// Find (or create) the sequence's track list of `kind` (the facade's /// `oaknode_sequence_get_track_list` find-or-create semantics). pub fn find_or_create_track_list( p: &ProjectRef, seq_id: NodeId, kind: TrackType, ) -> Option { let mut guard = lock(p); // Existing list of the kind. for &list_id in seq_behavior(&guard.graph, seq_id)?.track_lists.iter() { if track_list_behavior(&guard.graph, list_id).map(|l| l.kind) == Some(kind) { return Some(list_id); } } // Create it: a graph node owned by the sequence. let (core, behavior) = TrackListBehavior::create(); let mut behavior = behavior; if let Some(a) = behavior.as_any_mut() { if let Some(list) = a.downcast_mut::() { list.kind = kind; list.array_base = seq_behavior(&guard.graph, seq_id)?.track_lists.len() as i32; } } let list_id = guard.graph.add_node(core, behavior); if let Some(seq) = guard .graph .get_mut(seq_id) .and_then(|e| e.behavior.as_any_mut()) .and_then(|a| a.downcast_mut::()) { seq.track_lists.push(list_id); } if let Some(list) = track_list_behavior_mut(&mut guard.graph, list_id) { list.sequence = Some(seq_id); } Some(list_id) } /// Mutable track-list borrow helper. fn track_list_behavior_mut(g: &mut Graph, id: NodeId) -> Option<&mut TrackListBehavior> { g.get_mut(id)? .behavior .as_any_mut()? .downcast_mut::() } /// Append a track of `kind` to the sequence (the module's /// `TimelineAddTrackCommand`), returning the new track's index (the /// facade's `oakengine_sequence_add_track` contract). pub fn add_track(p: &ProjectRef, seq_id: NodeId, kind: TrackType) -> Result { let list_id = find_or_create_track_list(p, seq_id, kind) .ok_or_else(|| "sequence has no track list for this type".to_string())?; let mut cmd = TimelineAddTrackCommand::new(NodeRef::new(p.clone(), list_id)); cmd.redo(); let guard = lock(p); let n = track_list_behavior(&guard.graph, list_id) .map(|l| l.tracks.len() as i32) .ok_or_else(|| "add track command produced no track list".to_string())?; if n > 0 { Ok(n - 1) } else { Err("add track command produced no track".to_string()) } } /// Convert a frame timestamp (sequence frame-rate timebase ticks) to a /// seconds rational: `ts` ticks of `fr_den/fr_num` seconds. pub fn ts_to_seconds(ts: i64, fr_num: i32, fr_den: i32) -> Rational { Rational::new(ts * i64::from(fr_den), i64::from(fr_num)) } /// Place a footage clip on a track of the sequence (the facade's /// `oakengine_sequence_add_footage_clip_ex` semantics: the sequence lives /// in its own scratch project, so a scratch footage node is created there /// and connected to the clip; the real-project footage is untouched). /// /// `in_ts`/`out_ts`/`media_in_ts` are frame timestamps in the sequence's /// frame-rate timebase. pub fn place_footage_clip( project: &ProjectRef, seq_id: NodeId, filename: &str, kind: TrackType, track_index: i32, in_ts: i64, out_ts: i64, media_in_ts: i64, fr_num: i32, fr_den: i32, ) -> Result<(), String> { if in_ts < 0 || out_ts <= in_ts || media_in_ts < 0 { return Err("invalid clip range (need 0 <= in < out and media_in >= 0)".to_string()); } let list_id = find_or_create_track_list(project, seq_id, kind) .ok_or_else(|| "sequence has no track list for this type".to_string())?; // Track-index validation against the current list. let track_count = { let guard = lock(project); track_list_behavior(&guard.graph, list_id) .map(|l| l.tracks.len() as i32) .unwrap_or(0) }; if track_index < 0 || track_index >= track_count { return Err(format!( "track index {track_index} out of range ({track_count} tracks)" )); } let in_r = ts_to_seconds(in_ts, fr_num, fr_den); let out_r = ts_to_seconds(out_ts, fr_num, fr_den); let media_r = ts_to_seconds(media_in_ts, fr_num, fr_den); let length = out_r - in_r; // The scratch footage node (created directly in the sequence's project; // the graph edge cannot cross projects). let footage_id = { let mut guard = lock(project); let (mut core, behavior) = FootageBehavior::create(); core.set_standard_value("file_in", -1, oaknode::value::NodeValue::Text(filename.to_string())); let id = guard.graph.add_node(core, behavior); if let Some(f) = footage_behavior_mut(&mut guard.graph, id) { f.filename = filename.to_string(); let _ = f.probe(); } id }; // The clip block, positioned by media-in + length (the facade's // `oaknode_clip_set_media_in` + `oaknode_block_set_length_and_media_in`). let clip_id = { let mut guard = lock(project); let (core, behavior) = block::clip_create(); let id = guard.graph.add_node(core, behavior); if let Some(c) = clip_behavior_mut(&mut guard.graph, id) { c.core.media_in = media_r; c.core.set_length_and_media_in(length); } id }; // Place on the track (the module's TrackPlaceBlockCommand redo). let mut place = TrackPlaceBlockCommand::new(NodeRef::new(project.clone(), list_id), track_index, NodeRef::new(project.clone(), clip_id), in_r); place.redo(); // Connect the scratch footage to the clip's texture input. { let mut guard = lock(project); guard .graph .connect(footage_id, clip_id, block::clip_input::TEXTURE_INPUT, -1) .map_err(|e| e.to_string())?; } Ok(()) } /// Mutable footage borrow helper. fn footage_behavior_mut(g: &mut Graph, id: NodeId) -> Option<&mut FootageBehavior> { g.get_mut(id)? .behavior .as_any_mut()? .downcast_mut::() } /// Mutable clip borrow helper. fn clip_behavior_mut(g: &mut Graph, id: NodeId) -> Option<&mut ClipBlockBehavior> { g.get_mut(id)? .behavior .as_any_mut()? .downcast_mut::() } // --------------------------------------------------------------------------- // Montage resolution (the facade's build_video_montage / // build_audio_montage over the module graph) // --------------------------------------------------------------------------- /// The first footage node feeding `id` (upstream BFS over input edges), /// mirroring the facade's `oaknode_node_find_input_footage`. fn find_input_footage(g: &Graph, id: NodeId) -> Option { let mut frontier = vec![id]; let mut visited: Vec = Vec::new(); while !frontier.is_empty() { let mut next = Vec::new(); for cur in frontier { if visited.contains(&cur) { continue; } visited.push(cur); let entry = g.get(cur)?; if entry.behavior.type_id() == "org.olivevideoeditor.Olive.footage" && cur != id { return Some(cur); } for (src, _, _) in g.input_connections(cur) { next.push(src); } } frontier = next; } None } /// The footage filename feeding a clip (upstream BFS + footage behavior). fn clip_media(g: &Graph, block_id: NodeId) -> Option<(String, i32)> { let footage_id = find_input_footage(g, block_id)?; let filename = footage_behavior(g, footage_id)?.filename.clone(); Some((filename, 0)) } /// The video montage at sequence time `time`: every clip covering `time` /// on video tracks, ordered bottom-to-top (track index 0 is topmost, so /// it is composited last). pub fn video_montage(p: &ProjectRef, seq_id: NodeId, time: Rational) -> Vec { let g = lock(p); let mut clips = Vec::new(); let Some(seq) = seq_behavior(&g.graph, seq_id) else { return clips; }; for &list_id in &seq.track_lists { let Some(list) = track_list_behavior(&g.graph, list_id) else { continue; }; if list.kind != TrackType::Video { continue; } for &track_id in list.tracks.iter().rev() { let Some(track) = track_behavior(&g.graph, track_id) else { continue; }; for &block_id in &track.blocks { let Some(clip) = g .graph .get(block_id) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) else { continue; }; let in_ = clip.core.in_(); let out = clip.core.out(); if time < in_ || time >= out { continue; } let Some((filename, _)) = clip_media(&g.graph, block_id) else { continue; }; clips.push(MontageClip { filename, stream_index: 0, in_time: in_, out_time: out, media_in: clip.core.media_in, gain: 1.0, }); } } } clips } /// The audio montage over `range`: every audio clip overlapping the /// range, media times resolved from the clip ranges, audio stream 1. pub fn audio_montage(p: &ProjectRef, seq_id: NodeId, range: TimeRange) -> Vec { let g = lock(p); let mut clips = Vec::new(); let Some(seq) = seq_behavior(&g.graph, seq_id) else { return clips; }; for &list_id in &seq.track_lists { let Some(list) = track_list_behavior(&g.graph, list_id) else { continue; }; if list.kind != TrackType::Audio { continue; } for &track_id in &list.tracks { let Some(track) = track_behavior(&g.graph, track_id) else { continue; }; for &block_id in &track.blocks { let Some(clip) = g .graph .get(block_id) .and_then(|e| e.behavior.as_any()) .and_then(|a| a.downcast_ref::()) else { continue; }; let in_ = clip.core.in_(); let out = clip.core.out(); if out <= range.in_() || in_ >= range.out() { continue; } let Some((filename, _)) = clip_media(&g.graph, block_id) else { continue; }; clips.push(MontageClip { filename, stream_index: 1, in_time: in_, out_time: out, media_in: clip.core.media_in, gain: 1.0, }); } } } clips } // --------------------------------------------------------------------------- // Rendering (the facade's renderer over the oakrender ticket arena) // --------------------------------------------------------------------------- /// Bring up the process-wide render manager; already-initialized is /// success (the facade's `oakengine_render_manager_init` returns the /// module's OAKRENDER_E_STATE for a second init, which the CLI treats as /// "already up"). pub fn render_manager_init() -> Result<(), String> { match RenderManager::init() { Ok(()) => Ok(()), Err(oakrender::error::Error::State) => Ok(()), Err(e) => Err(e.to_string()), } } /// Tear down the process-wide render manager (no-op when down). pub fn render_manager_shutdown() { RenderManager::shutdown(); } /// A rendered frame's pixel payload (the module frame a video ticket /// produces). pub struct RenderedFrame { /// Width in pixels. pub width: i32, /// Height in pixels. pub height: i32, /// Pixel format (`oakcore_rs::PixelFormat` as int). pub format: i32, /// Bytes per scanline (stride). pub linesize: i32, /// Pixel data (at least `linesize * height` bytes). pub data: Vec, } /// Render one frame of the sequence's montage at `time` (seconds /// rational) into a `(width, height)` F32 frame. pub fn render_frame( seq_id: NodeId, time: Rational, montage: Vec, width: i32, height: i32, ) -> Result { let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?; let params = VideoTicketParams { viewer: seq_id.identity(), time, force_size: Some((width, height)), force_format: None, cache: None, cache_dir: None, cache_id: None, cache_timebase: None, footage: None, montage, }; let id = m.tickets.next_id(); m.tickets.submit_video_with_id(id, params, Box::new(|_| {})); m.tickets.wait(id).map_err(|e| e.to_string())?; let result = m.tickets.result(id).ok_or_else(|| "render ticket produced no result".to_string())?; match &result { Ok(TicketPayload::Video(oakrender::texture::Texture::Cpu(frame))) => { Ok(RenderedFrame { width: frame.width, height: frame.height, format: frame.format as i32, linesize: frame.linesize_bytes() as i32, data: frame.data.clone(), }) } Ok(TicketPayload::Video(_)) => Err("render produced a non-CPU frame".to_string()), _ => Err("render produced no video frame".to_string()), } } /// Rendered interleaved f32 audio (the module audio ticket payload). pub struct RenderedAudio { /// Interleaved samples (`frame_count * channel_count` values). pub data: Vec, /// Sample rate (Hz). pub sample_rate: i32, /// Channel count. pub channel_count: i32, } /// Render the audio range `[start, end)` (seconds rational) as /// interleaved f32 at 48 kHz stereo (the facade's /// `oakengine_renderer_render_audio` defaults; uncovered parts are /// silent). pub fn render_audio( seq_id: NodeId, range: TimeRange, montage: Vec, ) -> Result { let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?; let params = AudioTicketParams { viewer: seq_id.identity(), range, sample_rate: 48000, channel_layout: 0x3, montage, }; let id = m.tickets.next_id(); m.tickets .submit_audio_with_id(id, params, Box::new(|_| {})); m.tickets.wait(id).map_err(|e| e.to_string())?; let result = m.tickets.result(id).ok_or_else(|| "audio ticket produced no result".to_string())?; match result { Ok(TicketPayload::Audio(samples)) => Ok(RenderedAudio { data: samples.samples, sample_rate: samples.sample_rate, channel_count: samples.channel_count, }), _ => Err("audio render produced no samples".to_string()), } } // --------------------------------------------------------------------------- // Export (the facade's `oakengine_export_render` over the module export // task) // --------------------------------------------------------------------------- /// Synchronously export `[0, frames)` of the sequence to `out` as /// H.264/AAC MP4 (the facade's `oakengine_export_render` defaults: codec /// default bit rates, 48 kHz stereo, fit scaling; the output geometry is /// the sequence's). pub fn export_sequence( project: &ProjectRef, seq_id: NodeId, out: &str, fr_num: i32, fr_den: i32, frames: i64, ) -> Result<(), String> { let (width, height) = { let guard = lock(project); sequence_geometry(&guard, seq_id) }; if width <= 0 || height <= 0 { return Err("sequence has no valid video dimensions".to_string()); } if fr_num <= 0 || fr_den <= 0 { return Err("sequence has no valid frame rate".to_string()); } let out_num = frames * i64::from(fr_den); let encoding = oaktask::export::EncodingParams { filename: out.to_string(), format: oakcodec::exportformat::Format::MPEG4Video as i32, video_enabled: true, video_codec: oakcodec::exportcodec::Codec::H264 as i32, video_width: width, video_height: height, video_time_base_num: fr_den, video_time_base_den: fr_num, video_pixel_format: 0, audio_enabled: true, audio_codec: oakcodec::exportcodec::Codec::AAC as i32, audio_sample_rate: 48000, audio_channel_layout: 0x3, subtitles_enabled: false, export_length_num: out_num as i32, export_length_den: fr_num, has_custom_range: true, custom_range_in_num: 0, custom_range_in_den: fr_num, custom_range_out_num: out_num as i32, custom_range_out_den: fr_num, }; let inner = oaktask::export::ExportTask::new((project.clone(), seq_id), encoding); let mut driver = oaktask::task::Task::new("Exporting...", None); driver.set_behavior(Box::new(inner)); driver.start().map_err(|_| { driver .error() .map(|s| s.to_string()) .unwrap_or_else(|| "export failed".to_string()) }) }