From 022c0a7a5a7e5553b8f7ceae9c00bedfe021428f Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 16 Aug 2026 23:36:43 +0800 Subject: [PATCH] refactor(app): cut liboakengine, link module rlibs directly (M14 R3) - real.rs rewritten over module Rust APIs (Arc> + 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 --- Cargo.lock | 12 +- Cargo.toml | 41 +- build.rs | 121 +- crates/oakengine/src/undo.rs | 4 +- crates/oakstorage/src/nodeutil.rs | 26 + crates/oakundo/src/global.rs | 130 +- crates/oakundo/src/undocommand.rs | 69 + src/app.rs | 4 +- src/dialogs.rs | 12 +- src/lib.rs | 10 +- src/oakui/effectchain.rs | 572 +++++ src/oakui/engine.rs | 16 +- src/oakui/ffi.rs | 1103 --------- src/oakui/graphops.rs | 1456 ++++++++++++ src/oakui/host_syms.rs | 258 --- src/oakui/mod.rs | 24 +- src/oakui/nodegraph.rs | 971 +++----- src/oakui/projectbrowser.rs | 204 +- src/oakui/real.rs | 3461 +++++++++-------------------- src/oakui/renderops.rs | 535 +++++ src/oakui/waveform.rs | 78 +- tests/waveform_e2e.rs | 22 +- 22 files changed, 4345 insertions(+), 4784 deletions(-) create mode 100644 src/oakui/effectchain.rs delete mode 100644 src/oakui/ffi.rs create mode 100644 src/oakui/graphops.rs delete mode 100644 src/oakui/host_syms.rs create mode 100644 src/oakui/renderops.rs diff --git a/Cargo.lock b/Cargo.lock index 78a529c16..d772e2205 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4743,8 +4743,16 @@ dependencies = [ "gpui_platform", "gpui_widgets", "image", - "oakengine", - "serde_json", + "oakaudio", + "oakcodec", + "oakcommon", + "oakcore-rs", + "oaknode", + "oakrender", + "oakstorage", + "oaktask", + "oaktimeline", + "oakundo", "smallvec", ] diff --git a/Cargo.toml b/Cargo.toml index 39df11309..eeb3d6c82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,9 +28,10 @@ members = ["crates/*"] exclude = ["gpui"] # NOTE: oakstorage (crates/oakstorage) is a workspace member but NOT a -# default member (it stays out of the default `cargo build`/`cargo test` at -# the root, which would also drag in its heavy database backends — sea-orm). -# Build/test it explicitly with `cargo test -p oakstorage`. +# default member (it stays out of the default-members test matrix to keep +# `cargo test` at the root fast; the app links it as a normal path +# dependency, so it builds with the app). Build/test it explicitly with +# `cargo test -p oakstorage`. # NOTE: `crates/oakengine` is deliberately NOT a default member (it stays a # workspace member, so `cargo test -p oakengine` works): its in-flight # integration tests (`tests/it_*族.rs`, an ongoing rewrite) share temp files @@ -58,9 +59,8 @@ license = "GPL-3.0-or-later" [lib] name = "oakapp" path = "src/lib.rs" -# Doctests are disabled: the real engine binding links the `liboakengine` -# cdylib (see build.rs), which the doctest binary would have to resolve as -# well for every doc example. The doc examples' assertions are covered by +# Doctests are disabled: the app links the oak* module crates (which carry +# media/codec dependencies); the doc examples' assertions are covered by # unit tests instead (see `oakui/timecode`). [[bin]] @@ -82,18 +82,25 @@ smallvec = "1" # Editable-text widget (used by the file / export dialogs' path fields, the # same gpui-elements crate gpui_widgets builds on). gpui_elements = { path = "gpui/crates/gpui_elements" } -# The library list crosses the facade C ABI as JSON -# (`oakengine_library_list`, M13 D4); Value-only parsing, no derive. -serde_json = "1" -[build-dependencies] -# The real engine is NOT linked as an rlib: the app binds only the frozen -# `oakengine_*` C ABI through the built `liboakengine` cdylib (build.rs -# emits the link-search path / rpath / `#[link(name = "oakengine")]` -# externs). This build-dependency only orders the build — cargo compiles -# the engine's cdylib before the app's build script runs, so a fresh -# `cargo build` at the repo root always finds `liboakengine.dylib`. -oakengine = { path = "crates/oakengine" } +# M14 R3: the app is a PURE module-crate consumer — every engine call is a +# direct Rust call into the oak* rlibs (oaknode for the project graph, +# oaktimeline/oakundo for the edit commands and the global undo stack, +# oakrender for the ticket arena, oakcodec for the export formats/test +# media, oaktask for the export/interchange tasks, oakaudio for the +# manager/waveforms, oakcommon/oakcore-rs for the config store and shared +# value types, oakstorage for the write-through library). No liboakengine +# dylib, no C ABI, no build.rs link step, no host shims. +oakaudio = { path = "crates/oakaudio" } +oakcodec = { path = "crates/oakcodec" } +oakcommon = { path = "crates/oakcommon" } +oakcore-rs = { path = "crates/oakcore" } +oaknode = { path = "crates/oaknode" } +oakrender = { path = "crates/oakrender" } +oakstorage = { path = "crates/oakstorage" } +oaktask = { path = "crates/oaktask" } +oaktimeline = { path = "crates/oaktimeline" } +oakundo = { path = "crates/oakundo" } [features] default = [] diff --git a/build.rs b/build.rs index d95f4b192..4f58758a1 100644 --- a/build.rs +++ b/build.rs @@ -16,120 +16,19 @@ //! Build-time link configuration for the `oakapp` crate. //! -//! The app does NOT depend on the `oakengine` crate as an rlib: the real -//! engine binding ([`RealEngine`](crate::oakui::real)) calls only the -//! frozen `oakengine_*` C ABI, which lives in the built -//! `liboakengine.dylib` (crates/oakengine, crate-type `cdylib`). This -//! script points the linker at that dylib and arranges for `cargo run` to -//! find it at runtime without any environment variables. -//! -//! The dylib is built by cargo before this script runs (the `oakengine` -//! entry in `[build-dependencies]` below guarantees the build order). Cargo -//! puts it at: -//! -//! * `target//deps/liboakengine.dylib` — when built as a -//! dependency of the app (the normal case), -//! * `target//liboakengine.dylib` — when built as a workspace -//! member (`cargo build -p oakengine`). -//! -//! macOS: the dylib carries a Mach-O install name pointing back into -//! `target//deps/`, so dyld finds it by that absolute path at -//! load time; the `-rpath` flag covers `@rpath`-relative configurations. -//! -//! Linux: the app's own link needs the search path plus -//! `-Wl,--export-dynamic` (the ELF equivalent of `-export_dynamic`) so -//! process-global symbol lookups resolve from the binary at runtime. An -//! `$ORIGIN`-relative rpath lets a packaged binary find a sibling -//! `liboakengine.so`. -//! -//! Windows: the engine dylib now links there — the `oakcore_*` runtime -//! imports were folded into the cdylib in M12 P5 (crates/oakengine/src/ -//! stubs.rs, module `audio`), so `liboakengine.dll` carries no undefined -//! symbols. The app binary itself still has no Windows link -//! configuration here and the early return stays; that is a separate -//! effort (gpui win32 support). - -use std::path::PathBuf; +//! M14 R3: the app links the oak* module crates as plain rlibs — there is +//! no `liboakengine` dylib to locate anymore. The only remaining link +//! concern is gpui's macOS backend: gpui_macos reaches the IOSurface API +//! through the `core-video` crate, which depends on `io-surface` with +//! `default-features = false` — that disables io-surface's `link` feature, +//! so nothing adds the IOSurface.framework to the final link and the +//! binary fails with undefined `_IOSurface*` symbols. The app's build +//! script is the single place that configures the macOS link, so link the +//! framework here. fn main() { let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if os != "macos" && os != "linux" { - return; - } - - let target_dir = std::env::var("CARGO_TARGET_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target")); - let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); - let profile_dir = target_dir.join(&profile); - let deps_dir = profile_dir.join("deps"); - let dylib = if os == "macos" { - "liboakengine.dylib" - } else { - "liboakengine.so" - }; - - // The un-hashed dependency artifact is the normal case; the - // workspace-member copy is the fallback. If only the hashed artifact - // exists (liboakengine-.so), link it by full path. - if deps_dir.join(dylib).exists() { - link_search(&deps_dir, os == "macos"); - } else if profile_dir.join(dylib).exists() { - link_search(&profile_dir, os == "macos"); - } else if let Some(hashed) = find_hashed_dylib(&deps_dir, os == "macos") { - println!("cargo:rustc-link-arg={}", hashed.display()); - rpath_and_export(&deps_dir, os == "macos"); - } else { - panic!( - "{dylib} not found under {}: build the workspace from the repo root \ - (cargo build -p oakengine) so the liboakengine cdylib is produced before the app links", - profile_dir.display() - ); - } -} - -/// Emits the link-search path plus `-loakengine`, the runtime rpath and -/// the host-symbol export flag (see the module docs). -fn link_search(dir: &std::path::Path, macos: bool) { - println!("cargo:rustc-link-search=native={}", dir.display()); - println!("cargo:rustc-link-lib=dylib=oakengine"); - rpath_and_export(dir, macos); - if macos { - // gpui_macos reaches the IOSurface API through the `core-video` - // crate, which depends on `io-surface` with `default-features = - // false` — that disables io-surface's `link` feature, so nothing - // adds the IOSurface.framework to the final link and the binary - // fails with undefined `_IOSurface*` symbols. The app's build - // script is the single place that configures the macOS link, so - // link the framework here. + if os == "macos" { println!("cargo:rustc-link-lib=framework=IOSurface"); } } - -/// The rpath (absolute deps dir + `$ORIGIN` on Linux) and the flag that -/// exports the binary's own symbols for the dylib's runtime lookups. -fn rpath_and_export(dir: &std::path::Path, macos: bool) { - if macos { - println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display()); - println!("cargo:rustc-link-arg=-Wl,-export_dynamic"); - } else { - println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display()); - println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN"); - println!("cargo:rustc-link-arg=-Wl,--export-dynamic"); - } -} - -/// Finds `liboakengine-.{dylib,so}` in `deps/` (some cargo -/// configurations name dependency cdylibs with a hash suffix). -fn find_hashed_dylib(deps_dir: &std::path::Path, macos: bool) -> Option { - let suffix = if macos { ".dylib" } else { ".so" }; - let entries = std::fs::read_dir(deps_dir).ok()?; - for entry in entries.flatten() { - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.starts_with("liboakengine-") && name.ends_with(suffix) { - return Some(entry.path()); - } - } - None -} diff --git a/crates/oakengine/src/undo.rs b/crates/oakengine/src/undo.rs index 8a7f0bf3b..aea78856e 100644 --- a/crates/oakengine/src/undo.rs +++ b/crates/oakengine/src/undo.rs @@ -103,7 +103,9 @@ pub unsafe extern "C" fn oakengine_undo_push(command: *mut c_void, name: *const /// `oakengine_undo_group_begin` — start collecting commands into a group. #[no_mangle] pub extern "C" fn oakengine_undo_group_begin(name: *const c_char) -> c_int { - guard(|| oakundo::global::group_begin(name).map_err(map_group_err)) + guard(|| unsafe { + oakundo::global::group_begin(&crate::handle::read_cstr(name)).map_err(map_group_err) + }) } /// `oakengine_undo_group_end` — close the group and push it as one entry. diff --git a/crates/oakstorage/src/nodeutil.rs b/crates/oakstorage/src/nodeutil.rs index da7791425..f2bb912ed 100644 --- a/crates/oakstorage/src/nodeutil.rs +++ b/crates/oakstorage/src/nodeutil.rs @@ -35,6 +35,32 @@ pub fn make_project_owned(project: ProjectArc) -> CHandle { oaknode::handle::make_owned(project) } +/// Release one owned reference of a project handle produced by +/// [`make_project_owned`] (or the database backend's load). The handle is +/// dead afterwards; the write-through binding keeps its own reference, so +/// releasing the caller's copy never tears a bound project down. +pub fn release_project(mut h: CHandle) { + if let Some(release) = h.release { + // SAFETY: `h` is an owned handle from this module; the release runs + // the box's own destructor once per owned reference. + unsafe { release(h.ctx) }; + } + h.ctx = std::ptr::null_mut(); +} + +/// The boxed project of a handle produced by [`make_project_owned`] or the +/// database backend's load (both box a `ProjectArc`). `None` for an empty +/// handle. +/// +/// The handle ABI carries no type tag, so the caller must only pass handles +/// from those two producers; the unsafe downcast is contained here instead +/// of spread across every direct-rlib consumer. +pub fn project_arc_of(h: &CHandle) -> Option { + // SAFETY: the documented precondition — handles from this module's + // producers box a `ProjectArc`. + unsafe { oaknode::handle::get::(h) }.cloned() +} + /// Read the boxed project of a project handle. /// /// # Safety diff --git a/crates/oakundo/src/global.rs b/crates/oakundo/src/global.rs index 38ed9231f..c778e581e 100644 --- a/crates/oakundo/src/global.rs +++ b/crates/oakundo/src/global.rs @@ -31,8 +31,9 @@ use std::sync::{Mutex, OnceLock}; use crate::error::{Error, Result}; use crate::handle::CHandle; use crate::undocommand::{ - command_free, command_init_multi, command_multi_add_child, command_multi_child, - command_multi_child_count, command_redo_now, command_undo_now, + command_free, command_from_owned, command_init_multi, command_multi_add_child, + command_multi_child, command_multi_child_count, command_redo_now, command_undo_now, + UndoCommand, }; use crate::undostack::{ undostack_can_redo, undostack_can_undo, undostack_clear, undostack_command_is_done, @@ -60,18 +61,6 @@ fn stack() -> CHandle { *global_stack() } -/// Read a NUL-terminated C string; `NULL` yields an empty string. -fn read_name(name: *const c_char) -> String { - if name.is_null() { - String::new() - } else { - // SAFETY: the caller guarantees a valid NUL-terminated string. - unsafe { std::ffi::CStr::from_ptr(name) } - .to_string_lossy() - .into_owned() - } -} - // --------------------------------------------------------------------------- // Command-success observers // --------------------------------------------------------------------------- @@ -127,7 +116,7 @@ fn group_lock() -> std::sync::MutexGuard<'static, Option> { /// Start collecting commands into a group. [`Error::State`] when a group /// is already open. -pub fn group_begin(name: *const c_char) -> Result<()> { +pub fn group_begin(name: &str) -> Result<()> { let mut g = group_lock(); if g.is_some() { return Err(Error::State); @@ -138,7 +127,7 @@ pub fn group_begin(name: *const c_char) -> Result<()> { } *g = Some(OpenGroup { multi, - name: read_name(name), + name: name.to_string(), }); Ok(()) } @@ -259,6 +248,61 @@ pub fn push_or_run(command: CHandle, name: &str) -> c_int { rc } +// --------------------------------------------------------------------------- +// Safe value-typed surface (M14 R3) +// +// The direct-rlib frontends (the app) hold module [`UndoCommand`] values, +// not CHandles. The functions below are the safe twins of the handle-based +// API above; the handle marshalling they perform stays inside this crate. +// --------------------------------------------------------------------------- + +/// Push `command` onto the process-wide stack (redo then record), or into +/// the open group. On a stack record the command observers fire (the +/// oakstorage write-through persists the edit). +pub fn push(command: UndoCommand, name: &str) -> Result<()> { + // SAFETY: `command_from_owned` boxes the value; `push_or_run` takes the + // value out of the handle (stack push or group child), and + // `command_free` releases the remaining non-owning shell (dropping the + // command itself when nobody took it). + let mut handle = unsafe { command_from_owned(command) }; + if handle.is_null() { + return Err(Error::NoMem); + } + let rc = push_or_run(handle, name); + command_free(&mut handle); + if rc == 0 { + Ok(()) + } else { + Err(Error::from_code(rc)) + } +} + +/// Step the process-wide stack back one entry (no-op at the bottom). On +/// success the command observers fire, persisting the reverted state. +pub fn undo() -> Result<()> { + let i = index()?; + jump(i - 1) +} + +/// Step the process-wide stack forward one entry (no-op at the top). On +/// success the command observers fire. +pub fn redo() -> Result<()> { + let i = index()?; + jump(i + 1) +} + +/// Whether the process-wide stack has an entry to undo. +pub fn undoable() -> bool { + let mut v: c_int = 0; + can_undo(&mut v).is_ok() && v != 0 +} + +/// Whether the process-wide stack has an entry to redo. +pub fn redoable() -> bool { + let mut v: c_int = 0; + can_redo(&mut v).is_ok() && v != 0 +} + // --------------------------------------------------------------------------- // Stack queries and mutations // --------------------------------------------------------------------------- @@ -358,6 +402,13 @@ mod tests { COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } + /// A second counting observer (the observers are process-lifetime, so a + /// test must not share `COUNT` with the lifecycle test above). + static COUNT2: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); + fn counter2() { + COUNT2.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + /// The stack/group/observer state is process-wide: every test here runs /// serially under this lock, mirroring the facade's GLOBAL_STACK_LOCK /// pattern. @@ -395,8 +446,8 @@ mod tests { assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 1); // Group begin/end fires the observer once at end. - assert!(group_begin(c"grouped".as_ptr()).is_ok()); - assert!(group_begin(c"again".as_ptr()).is_err()); // State + assert!(group_begin("grouped").is_ok()); + assert!(group_begin("again").is_err()); // State let c1 = vtable_command(); let c2 = vtable_command(); assert_eq!(push_or_run(c1, "c1"), 0); @@ -409,7 +460,7 @@ mod tests { assert_eq!(COUNT.load(std::sync::atomic::Ordering::SeqCst), 2); // Abort fires nothing. - assert!(group_begin(c"abort".as_ptr()).is_ok()); + assert!(group_begin("abort").is_ok()); let c3 = vtable_command(); assert_eq!(push_or_run(c3, "c3"), 0); assert!(group_abort().is_ok()); @@ -422,4 +473,45 @@ mod tests { assert!(clear().is_ok()); } + + /// The safe value-typed surface (M14 R3): `push` redoes and records a + /// closure command, `undo`/`redo` step the stack, and the observers + /// fire on every recorded mutation. + #[test] + fn value_push_and_undo_redo() { + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + COUNT2.store(0, std::sync::atomic::Ordering::SeqCst); + add_observer(counter2); + + assert!(clear().is_ok()); + assert!(!undoable()); + assert!(!redoable()); + + let state = std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)); + let (r, u) = (state.clone(), state.clone()); + let cmd = UndoCommand::from_closures( + move || { + r.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }, + move || { + u.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + }, + ); + push(cmd, "bump").unwrap(); + assert_eq!(state.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!(undoable()); + assert_eq!(COUNT2.load(std::sync::atomic::Ordering::SeqCst), 1); + + undo().unwrap(); + assert_eq!(state.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!(!undoable()); + assert!(redoable()); + + redo().unwrap(); + assert_eq!(state.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!(undoable()); + assert!(!redoable()); + + assert!(clear().is_ok()); + } } diff --git a/crates/oakundo/src/undocommand.rs b/crates/oakundo/src/undocommand.rs index bd8ea9887..9d13d49af 100644 --- a/crates/oakundo/src/undocommand.rs +++ b/crates/oakundo/src/undocommand.rs @@ -134,6 +134,28 @@ impl UndoCommand { } } + /// New closure-backed command (M14 R3): `redo`/`undo` are plain Rust + /// closures, boxed behind the vtable machinery. This is the safe way + /// for direct-rlib consumers (the app) to build composite edit + /// commands without writing their own `extern "C"` trampolines. + pub fn from_closures( + redo: impl FnMut() + Send + 'static, + undo: impl FnMut() + Send + 'static, + ) -> Self { + let state = Box::into_raw(Box::new(ClosureCommand { + redo: Box::new(redo), + undo: Box::new(undo), + })); + UndoCommand::from_vtable( + OakUndoCommandVtable { + redo: Some(closure_redo), + undo: Some(closure_undo), + free_fn: Some(closure_free), + }, + state as *mut c_void, + ) + } + /// The invariant bottom-of-stack command ("New/Open Project"); all /// callbacks are no-ops and it is never undoable. pub(crate) fn empty() -> Self { @@ -283,6 +305,53 @@ impl Drop for UndoCommand { } } +/// Closure-backed command state (see [`UndoCommand::from_closures`]). +struct ClosureCommand { + /// The redo closure. + redo: Box, + /// The undo closure. + undo: Box, +} + +/// `redo` trampoline for closure commands. +/// +/// # Safety +/// `userdata` must be the `Box` produced by +/// [`UndoCommand::from_closures`]; the owning command keeps it alive. +unsafe extern "C" fn closure_redo(userdata: *mut c_void) { + if userdata.is_null() { + return; + } + // SAFETY: per the from_closures contract; the command owns the box. + let state = unsafe { &mut *(userdata as *mut ClosureCommand) }; + (state.redo)(); +} + +/// `undo` trampoline for closure commands. +/// +/// # Safety +/// As [`closure_redo`]. +unsafe extern "C" fn closure_undo(userdata: *mut c_void) { + if userdata.is_null() { + return; + } + // SAFETY: per the from_closures contract; the command owns the box. + let state = unsafe { &mut *(userdata as *mut ClosureCommand) }; + (state.undo)(); +} + +/// `free` trampoline for closure commands: drops the boxed closures. +/// +/// # Safety +/// As [`closure_redo`]; called at most once (the command's final release). +unsafe extern "C" fn closure_free(userdata: *mut c_void) { + if userdata.is_null() { + return; + } + // SAFETY: per the from_closures contract; this is the final release. + unsafe { drop(Box::from_raw(userdata as *mut ClosureCommand)) }; +} + /// `olive::MultiUndoCommand` — a composite of child commands. /// /// `redo` runs children in order; `undo` runs them in reverse. Owns one diff --git a/src/app.rs b/src/app.rs index 659163cff..c865d6692 100644 --- a/src/app.rs +++ b/src/app.rs @@ -541,7 +541,7 @@ impl OakApp { .update(cx, |timeline, cx| timeline.seek(frame, cx)); // Mirror the engine's work area into the ruler's view state (M12 P4). // Read every tick so undo/redo and the ruler-drag commit land on the - // band promptly; the read is a cheap facade getter. + // band promptly; the read is a cheap engine getter. let work_area = self.engine.read(cx).workarea(); self.timeline .update(cx, |timeline, _| timeline.state.work_area = work_area.map(|(s, e)| FrameRange::new(s, e))); @@ -1793,7 +1793,7 @@ fn run_with(args: AppArgs) { if cx.windows().is_empty() { // Persist the preferences (config.ini), then drain the // write-through backlog (save + snapshot of every still-bound - // project) and stop the facade's snapshot thread. + // project) and stop oakstorage's snapshot thread. crate::oakui::real::config_save(); crate::oakui::real::storage_flush(); cx.quit(); diff --git a/src/dialogs.rs b/src/dialogs.rs index b05bbf899..94dd61348 100644 --- a/src/dialogs.rs +++ b/src/dialogs.rs @@ -21,7 +21,7 @@ //! Each view owns its widgets and emits nothing itself — the host //! (`crate::app::OakApp`) reads the state (format / path) when a dialog //! button is clicked, and the preferences view writes its choices straight -//! through the config C ABI on selection. Theme/language changes +//! into the oakcommon config store on selection. Theme/language changes //! additionally emit a [`PreferencesEvent`] so the host can re-apply the //! shell chrome immediately. @@ -51,7 +51,7 @@ use crate::oakui::real::{ // --------------------------------------------------------------------------- /// A request the preferences dialog emits for the host shell (the settings -/// themselves are written through the config C ABI directly; these need +/// themselves are written into the config store directly; these need /// shell chrome — the menu bar / theme — to re-render). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PreferencesEvent { @@ -75,9 +75,9 @@ impl gpui::EventEmitter for PreferencesContent {} /// the write-through era's auto-save interval) and the default transition /// length (`DefaultTransitionLength`). /// * **音频 Audio** — the output / input devices (`AudioOutput` / -/// `AudioInput`, applied live through the audio facade). +/// `AudioInput`, applied live through the oakaudio manager). /// -/// Every row writes through the config C ABI on selection, so the choices +/// Every row writes into the config store on selection, so the choices /// survive restarts (the app loads the config at startup and saves it on /// exit). pub struct PreferencesContent { @@ -295,8 +295,8 @@ impl PreferencesContent { .detach(); // --- 音频 Audio: output / input devices ----------------------------- - // The enumeration goes through the facade even on the mock engine; - // the config choice applies the moment the device dropdown changes. + // The enumeration reads the oakaudio manager even on the mock + // engine; the config choice applies the moment the dropdown changes. let (audio_output, output_devices) = device_combo(5, true, window, cx); let (audio_input, input_devices) = diff --git a/src/lib.rs b/src/lib.rs index fd752aeef..383df445d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,10 +21,12 @@ //! the main layout from the design (`design/`), dockable panels built from //! the `gpui_widgets` library, and an engine seam (`oakui`) with two //! backends: the mock ([`oakui::MockEngine`]) feeding demo data, and the -//! real engine ([`oakui::RealEngine`]) bound to the built `liboakengine` -//! dylib through its frozen `oakengine_*` C ABI only (project open/save, -//! sequence/track/clip data, timeline edits through the oaktimeline edit -//! commands, the oaktask export path, and the config C ABI). +//! real engine ([`oakui::RealEngine`]) driving the oak* module crates +//! directly (M14 R3: project open/save through the oaknode serializer, +//! timeline edits through the oaktimeline edit commands on the oakundo +//! global stack, the oakrender ticket arena for the viewers, the oaktask +//! export path, the oakcommon config store, and the oakstorage +//! write-through library — no `liboakengine` dylib, no C ABI). //! //! # Layout //! diff --git a/src/oakui/effectchain.rs b/src/oakui/effectchain.rs new file mode 100644 index 000000000..8e0649959 --- /dev/null +++ b/src/oakui/effectchain.rs @@ -0,0 +1,572 @@ +// 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 . + +//! The effect-chain composition (M14 R3). +//! +//! The facade's `oakengine_node_effect_*` exports (chain walk, undoable +//! insert / remove / reorder / enable) were never sunk into a module — +//! they are composition over the oaknode graph and the oakundo global +//! stack, so they live in the app now. The semantics mirror +//! `crates/oakengine/src/node.rs` (`effect_chain`, `node_effect_insert_impl`, +//! `oakengine_node_effect_remove` / `..._move` / `..._set_enabled`), rebuilt +//! over the direct graph API: chain nodes are `NodeId`s in the host's own +//! project, and the undoable edits are one stack entry each (a multi +//! command or a closure command from [`oakundo::undocommand::UndoCommand`]). + +use std::sync::{Arc, Mutex}; + +use oaknode::graph::{Graph, NodeEntry}; +use oaknode::id::NodeId; +use oaknode::node::ENABLED_INPUT; +use oaknode::value::NodeValue; +use oakundo::undocommand::UndoCommand; + +use super::graphops::{connect_command, disconnect_command, lock, push_command as push, ProjectRef}; + +/// The effect-input id of `node`, or `None` when the node cannot host +/// effects (C++ `Node::GetEffectInputID`). +pub fn effect_input_of(g: &Graph, node: NodeId) -> Option { + let id = &g.get(node)?.core.effect_input; + if id.is_empty() { + None + } else { + Some(id.clone()) + } +} + +/// The node feeding `node`'s input `input_id`, or `None` when the input +/// is unconnected. +fn connected_node(g: &Graph, node: NodeId, input_id: &str) -> Option { + g.connected_output(node, input_id, -1) +} + +/// The effect chain of `host`, **closest-to-source first** (signal order: +/// the first element feeds the media side, the last feeds `host`'s effect +/// input). The walk follows each node's effect input upstream until an +/// unconnected input or a node without an effect input; a `seen` guard +/// protects against malformed cycles. +pub fn chain(g: &Graph, host: NodeId) -> Vec { + let mut chain = Vec::new(); + let mut cur = host; + let mut seen: Vec = Vec::new(); + loop { + if seen.contains(&cur) { + break; + } + seen.push(cur); + let Some(input) = effect_input_of(g, cur) else { + break; + }; + let Some(up) = connected_node(g, cur, &input) else { + break; + }; + chain.push(up); + cur = up; + } + chain.reverse(); + chain +} + +/// Whether `node` is enabled (the `enabled_in` standard value; the effect +/// stack's enable toggle). +pub fn is_enabled(g: &Graph, node: NodeId) -> bool { + g.get(node) + .map(|e| { + matches!( + e.core.standard_value(ENABLED_INPUT, -1), + NodeValue::Boolean(true) + ) + }) + .unwrap_or(false) +} + +/// The effect types the user can add to a clip's chain, as +/// (type id, display name) pairs — the factory entries flagged +/// `video_effect` and not hidden from the create menu. +pub fn addable_effects() -> Vec<(String, String)> { + let mut out = Vec::new(); + for meta in oaknode::factory::Factory::global().entries() { + // A scratch instance per entry just to read its flags (the factory + // metadata carries no flag copy). + let (core, _behavior) = (meta.create)(); + let flags = core.flags; + if flags & oaknode::node::flags::VIDEO_EFFECT != 0 + && flags & oaknode::node::flags::DONT_SHOW_IN_CREATE_MENU == 0 + { + let name = if meta.name.is_empty() { + meta.type_id.to_string() + } else { + meta.name.to_string() + }; + out.push((meta.type_id.to_string(), name)); + } + } + out +} + +// --------------------------------------------------------------------------- +// Command pieces +// --------------------------------------------------------------------------- + + + +/// Shared state of an add-node command: the node's entry while detached +/// plus its arena id once added (kept across undo/redo so the rewiring +/// commands of the same group keep addressing the same slot). +type AddNodeState = Arc, Option)>>; + +/// A command that adds a node entry to the project's graph on `redo` and +/// detaches it (entry preserved, arena slot retained) on `undo`. Returns +/// the command and its shared state — the caller reads the assigned id +/// after the eager redo (the group push executes redos immediately, so +/// the rewiring commands pushed next address the live id). +fn add_node_command(p: &ProjectRef, entry: NodeEntry) -> (UndoCommand, AddNodeState) { + let state: AddNodeState = Arc::new(Mutex::new((Some(entry), None))); + let (s1, s2) = (state.clone(), state.clone()); + let (p1, p2) = (p.clone(), p.clone()); + ( + UndoCommand::from_closures( + move || { + let mut st = s1.lock().unwrap_or_else(|e| e.into_inner()); + let Some(entry) = st.0.take() else { + return; + }; + let mut g = lock(&p1); + st.1 = Some(match st.1 { + Some(id) => g.graph.add_entry(entry, id), + None => g.graph.add_node(entry.core, entry.behavior), + }); + }, + move || { + let mut st = s2.lock().unwrap_or_else(|e| e.into_inner()); + let Some(id) = st.1 else { + return; + }; + let mut g = lock(&p2); + st.0 = g.graph.take_node(id); + }, + ), + state, + ) +} + +// --------------------------------------------------------------------------- +// Chain edits (each is ONE undo row) +// --------------------------------------------------------------------------- + +/// Run `f` inside an undo group: every push redoes eagerly (so a later +/// child's construction and validation see the post-edit graph — the +/// facade's `oakengine_undo_group_begin` flow), and the group closes as +/// ONE undo row on success / aborts (undoing the executed children) on +/// error. +fn grouped(name: &str, f: impl FnOnce() -> Result) -> Result { + oakundo::global::group_begin(name).map_err(|e| e.to_string())?; + match f() { + Ok(v) => { + oakundo::global::group_end().map_err(|e| e.to_string())?; + Ok(v) + } + Err(e) => { + let _ = oakundo::global::group_abort(); + Err(e) + } + } +} + +/// Undoable enable toggle of an effect node ("Toggle Effect"; the +/// `enabled_in` flag). +pub fn set_enabled(p: &ProjectRef, effect: NodeId, enabled: bool) -> Result<(), String> { + let old = { + let g = lock(p); + if !g.graph.is_valid(effect) { + return Err("toggle effect: node not found".to_string()); + } + is_enabled(&g.graph, effect) + }; + let (p1, p2) = (p.clone(), p.clone()); + push( + UndoCommand::from_closures( + move || { + let mut g = lock(&p1); + if let Some(e) = g.graph.get_mut(effect) { + e.core + .set_standard_value(ENABLED_INPUT, -1, NodeValue::Boolean(enabled)); + } + }, + move || { + let mut g = lock(&p2); + if let Some(e) = g.graph.get_mut(effect) { + e.core + .set_standard_value(ENABLED_INPUT, -1, NodeValue::Boolean(old)); + } + }, + ), + "Toggle Effect", + ) +} + +/// The neighbors of chain position `pos` in `chain` (length `len`): +/// `(upstream, downstream)` — the chain source is `None`, the host closes +/// the chain. +fn neighbors(g: &Graph, host: NodeId, chain: &[NodeId], pos: usize) -> (Option, NodeId) { + let len = chain.len(); + if pos == 0 { + if len == 0 { + (None, host) + } else { + let d = chain[0]; + let up = effect_input_of(g, d).and_then(|i| connected_node(g, d, &i)); + (up, d) + } + } else if pos >= len { + (Some(chain[len - 1]), host) + } else { + (Some(chain[pos - 1]), chain[pos]) + } +} + +/// Undoable insertion of a new effect of `type_id` at chain position +/// `index` (0 = closest to the source, `len` = closest to the host; +/// out-of-range indices clamp to the ends). The node is created from the +/// factory, added to the host's project, and wired into the chain — one +/// undo row for the whole edit ("Add Effect"). Returns the new node's id. +pub fn insert(p: &ProjectRef, host: NodeId, index: usize, type_id: &str) -> Result { + let (chain_vec, new_entry, new_input) = { + let g = lock(p); + // The host must be able to host effects. + if effect_input_of(&g.graph, host).is_none() { + return Err("node cannot host effects (no effect input)".to_string()); + } + let Some(meta) = oaknode::factory::Factory::global().find(type_id) else { + return Err(format!("unknown node type id \"{type_id}\"")); + }; + let (core, behavior) = (meta.create)(); + if core.effect_input.is_empty() { + return Err("node type has no effect input; cannot be chained".to_string()); + } + let new_input = core.effect_input.clone(); + let entry = NodeEntry { + core, + behavior, + generation: 0, + vacant: false, + }; + (chain(&g.graph, host), entry, new_input) + }; + let pos = index.min(chain_vec.len()); + let (upstream, downstream) = { + let g = lock(p); + neighbors(&g.graph, host, &chain_vec, pos) + }; + let downstream_input = { + let g = lock(p); + effect_input_of(&g.graph, downstream).unwrap_or_default() + }; + + grouped("Add Effect", || { + // 1. Add the node to the project (the eager redo runs now; the + // rewiring commands below address the live id). + let (add, state) = add_node_command(p, new_entry); + push(add, "Add Effect")?; + let fresh = state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .1 + .ok_or_else(|| "the add-node command produced no id".to_string())?; + // 2. Unhook the downstream input from its current upstream (when + // there is one). + if upstream.is_some() { + push(disconnect_command(p, downstream, &downstream_input)?, "Add Effect")?; + } + // 3. Wire the new effect between upstream and downstream (each + // command validates against the live post-edit graph). + push(connect_command(p, fresh, downstream, &downstream_input)?, "Add Effect")?; + if let Some(upstream) = upstream { + push(connect_command(p, upstream, fresh, &new_input)?, "Add Effect")?; + } + Ok(fresh) + }) +} + +/// Undoable removal of `effect` (a node in `host`'s chain): unhook both +/// edges and bridge the gap, one undo row ("Remove Effect"). The node +/// itself is left orphaned in the project graph: the module node-transfer +/// commands are one-way, so a reversible detach does not exist there yet +/// — documented limitation carried over from the facade. +pub fn remove(p: &ProjectRef, host: NodeId, effect: NodeId) -> Result<(), String> { + let (chain_vec, pos) = { + let g = lock(p); + let chain_vec = chain(&g.graph, host); + let Some(pos) = chain_vec.iter().position(|&c| c == effect) else { + return Err("remove effect: the node is not in the chain".to_string()); + }; + (chain_vec, pos) + }; + // The neighbors of the REMOVED node: upstream is chain[pos - 1] (or the + // chain source — the node feeding the first effect — for pos == 0), + // downstream is chain[pos + 1] (or the host at the chain's end). + let (upstream, downstream) = { + let g = lock(p); + let upstream = if pos == 0 { + effect_input_of(&g.graph, chain_vec[0]) + .and_then(|i| connected_node(&g.graph, chain_vec[0], &i)) + } else { + Some(chain_vec[pos - 1]) + }; + let downstream = if pos + 1 == chain_vec.len() { + host + } else { + chain_vec[pos + 1] + }; + (upstream, downstream) + }; + let (eff_input, downstream_input) = { + let g = lock(p); + ( + effect_input_of(&g.graph, effect).unwrap_or_default(), + effect_input_of(&g.graph, downstream).unwrap_or_default(), + ) + }; + + grouped("Remove Effect", || { + // 1. Unhook the effect from its upstream. + if upstream.is_some() { + push(disconnect_command(p, effect, &eff_input)?, "Remove Effect")?; + } + // 2. Unhook the downstream from the effect, then bridge it back to + // the upstream (validated against the live post-edit graph). + push(disconnect_command(p, downstream, &downstream_input)?, "Remove Effect")?; + if let Some(upstream) = upstream { + push(connect_command(p, upstream, downstream, &downstream_input)?, "Remove Effect")?; + } + Ok(()) + }) +} + +/// Undoable reorder of `effect` to chain position `new_index` (an +/// insertion index **after** removal, matching the effect stack's +/// `ReorderRequested`; `0..=len-1` where `len` is the post-removal chain +/// length). One undo row ("Reorder Effect"). +pub fn move_effect( + p: &ProjectRef, + host: NodeId, + effect: NodeId, + new_index: usize, +) -> Result<(), String> { + let (chain_vec, from) = { + let g = lock(p); + let chain_vec = chain(&g.graph, host); + let Some(from) = chain_vec.iter().position(|&c| c == effect) else { + return Err("reorder effect: the node is not in the chain".to_string()); + }; + (chain_vec, from) + }; + let len = chain_vec.len(); + let to = new_index.min(len - 1); + + let (upstream, downstream, eff_input, downstream_input) = { + let g = lock(p); + let upstream = if from == 0 { + effect_input_of(&g.graph, chain_vec[0]) + .and_then(|i| connected_node(&g.graph, chain_vec[0], &i)) + } else { + Some(chain_vec[from - 1]) + }; + let downstream = if from + 1 == len { + host + } else { + chain_vec[from + 1] + }; + ( + upstream, + downstream, + effect_input_of(&g.graph, effect).unwrap_or_default(), + effect_input_of(&g.graph, downstream).unwrap_or_default(), + ) + }; + // The post-removal chain and the reinsertion neighbors: position `to` + // (0..=len-1) sits between index `to - 1` and `to` of the remaining + // list, where index `-1` is the chain source and index `len-1` is the + // host. + let remaining: Vec = chain_vec + .iter() + .enumerate() + .filter(|(i, _)| *i != from) + .map(|(_, &c)| c) + .collect(); + let (up2, down2) = { + let g = lock(p); + let rlen = remaining.len(); + if to == 0 { + if rlen == 0 { + (None, host) + } else { + let d = remaining[0]; + let up = effect_input_of(&g.graph, d).and_then(|i| connected_node(&g.graph, d, &i)); + (up, d) + } + } else if to >= rlen { + (Some(remaining[rlen - 1]), host) + } else { + (Some(remaining[to - 1]), remaining[to]) + } + }; + let down2_input = { + let g = lock(p); + effect_input_of(&g.graph, down2).unwrap_or_default() + }; + + grouped("Reorder Effect", || { + // ---- remove the effect from position `from` ---------------------- + if upstream.is_some() { + push(disconnect_command(p, effect, &eff_input)?, "Reorder Effect")?; + } + push(disconnect_command(p, downstream, &downstream_input)?, "Reorder Effect")?; + if let Some(upstream) = upstream { + push(connect_command(p, upstream, downstream, &downstream_input)?, "Reorder Effect")?; + } + // ---- reinsert at position `to` (each command validates against + // the live post-edit graph) ------------------------------------ + if up2.is_some() { + push(disconnect_command(p, down2, &down2_input)?, "Reorder Effect")?; + } + push(connect_command(p, effect, down2, &down2_input)?, "Reorder Effect")?; + // The upstream -> effect connect addresses the EFFECT's own effect + // input (identical to the upstream's for the standard tex_in chains). + if let Some(up2) = up2 { + push(connect_command(p, up2, effect, &eff_input)?, "Reorder Effect")?; + } + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::oakui::graphops; + + /// The global undo stack is process-wide; serialize these tests (shared + /// with the other app test modules). + fn stack_lock() -> std::sync::MutexGuard<'static, ()> { + crate::oakui::graphops::test_lock() + } + + /// A project with a clip node (an effect-chain host: clips carry the + /// `tex_in` effect input). + fn project_with_clip() -> (ProjectRef, NodeId) { + let project = graphops::create_project(); + let clip = { + let mut g = lock(&project); + let (core, behavior) = oaknode::block::clip_create(); + g.graph.add_node(core, behavior) + }; + (project, clip) + } + + /// A video-effect type id the factory really registers. + fn some_effect_type() -> String { + addable_effects() + .into_iter() + .next() + .expect("the factory registers at least one video effect") + .0 + } + + #[test] + fn insert_walks_the_chain_and_undoes() { + let _g = stack_lock(); + oakundo::global::clear().unwrap(); + let (project, host) = project_with_clip(); + let ty = some_effect_type(); + + let first = insert(&project, host, 0, &ty).expect("insert into an empty chain"); + assert_eq!(chain(&lock(&project).graph, host), vec![first]); + let second = insert(&project, host, 0, &ty).expect("insert at the source side"); + assert_eq!(chain(&lock(&project).graph, host), vec![second, first]); + let third = insert(&project, host, 1, &ty).expect("insert in the middle"); + assert_eq!(chain(&lock(&project).graph, host), vec![second, third, first]); + + // One undo row per insert. + oakundo::global::undo().unwrap(); + assert_eq!(chain(&lock(&project).graph, host), vec![second, first]); + oakundo::global::undo().unwrap(); + assert_eq!(chain(&lock(&project).graph, host), vec![first]); + oakundo::global::redo().unwrap(); + assert_eq!(chain(&lock(&project).graph, host), vec![second, first]); + oakundo::global::clear().unwrap(); + } + + #[test] + fn remove_bridges_the_gap() { + let _g = stack_lock(); + oakundo::global::clear().unwrap(); + let (project, host) = project_with_clip(); + let ty = some_effect_type(); + let a = insert(&project, host, 0, &ty).unwrap(); + let b = insert(&project, host, 1, &ty).unwrap(); + let c = insert(&project, host, 2, &ty).unwrap(); + assert_eq!(chain(&lock(&project).graph, host), vec![a, b, c]); + + remove(&project, host, b).expect("remove the middle effect"); + assert_eq!(chain(&lock(&project).graph, host), vec![a, c]); + oakundo::global::undo().unwrap(); + assert_eq!(chain(&lock(&project).graph, host), vec![a, b, c]); + oakundo::global::clear().unwrap(); + } + + #[test] + fn move_reorders_the_chain() { + let _g = stack_lock(); + oakundo::global::clear().unwrap(); + let (project, host) = project_with_clip(); + let ty = some_effect_type(); + let a = insert(&project, host, 0, &ty).unwrap(); + let b = insert(&project, host, 1, &ty).unwrap(); + let c = insert(&project, host, 2, &ty).unwrap(); + + move_effect(&project, host, c, 0).expect("move the last effect to the source side"); + assert_eq!(chain(&lock(&project).graph, host), vec![c, a, b]); + oakundo::global::undo().unwrap(); + assert_eq!(chain(&lock(&project).graph, host), vec![a, b, c]); + oakundo::global::clear().unwrap(); + } + + #[test] + fn set_enabled_toggles_and_undoes() { + let _g = stack_lock(); + oakundo::global::clear().unwrap(); + let (project, host) = project_with_clip(); + let ty = some_effect_type(); + let eff = insert(&project, host, 0, &ty).unwrap(); + assert!(is_enabled(&lock(&project).graph, eff)); + + set_enabled(&project, eff, false).unwrap(); + assert!(!is_enabled(&lock(&project).graph, eff)); + oakundo::global::undo().unwrap(); + assert!(is_enabled(&lock(&project).graph, eff)); + oakundo::global::clear().unwrap(); + } + + #[test] + fn addable_effects_are_video_effects() { + let entries = addable_effects(); + assert!(!entries.is_empty()); + for (type_id, name) in &entries { + assert!(!type_id.is_empty()); + assert!(!name.is_empty()); + } + } +} diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index bfea1d2e2..697644955 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -19,22 +19,18 @@ //! //! # Why a gateway trait //! -//! The UI must never depend on *how* the engine is implemented. Today the -//! only implementation is the mock ([`super::mock::MockEngine`]) feeding demo -//! data; later a real backend will bind the `liboakengine` C ABI -//! (`src/facade/rust`, the frozen `oakengine_*` exports) behind the *same* -//! trait. Swapping backends then touches only the wiring in -//! [`crate::app`] — the panels, the widgets and the view state stay as they -//! are. +//! The UI must never depend on *how* the engine is implemented. The mock +//! ([`super::mock::MockEngine`]) feeds demo data; the real backend +//! ([`super::real::RealEngine`]) drives the oak* module crates' Rust APIs +//! directly (M14 R3: no C ABI, no FFI) behind the *same* trait. Swapping +//! backends touches only the wiring in [`crate::app`] — the panels, the +//! widgets and the view state stay as they are. //! //! The trait is intentionally narrow: open a project, inspect the current //! sequence, and drive the transport (play / pause / step / seek). Timeline //! edits arrive as widget request events and are applied by the host through //! methods on the engine type itself (see the `MockEngine` docs for the //! current mapping), so they do not need to be part of this seam yet. -//! -//! Everything here is plain Rust — no C ABI, no FFI. The C-ABI binding is a -//! later concern of the real backend only. use std::path::PathBuf; use std::sync::Arc; diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs deleted file mode 100644 index bf070d9e8..000000000 --- a/src/oakui/ffi.rs +++ /dev/null @@ -1,1103 +0,0 @@ -// 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 . - -//! The app's pure-C surface of the `liboakengine` dylib. -//! -//! The app links the built `liboakengine.dylib` (see `build.rs`) and calls -//! ONLY its `oakengine_*` C ABI — it never depends on the `oakengine` crate -//! as an rlib. This module declares every exported function the real engine -//! binding uses, with the exact signatures from the facade's `#[no_mangle]` -//! exports (`crates/oakengine/src/*.rs`). The facade wraps the module -//! C ABIs that are also embedded in the same dylib (`oakundo_*`, -//! `oakcommon_*`, ...), so everything the app touches resolves through an -//! `oakengine_*` symbol. -//! -//! # Handle layout mirrors -//! -//! The facade's opaque `OakEngine*` handle types are thin `#[repr(C)]` -//! newtypes around one module [`CHandle`] value (see -//! `crates/oakengine/src/handle.rs`), and boxes created with -//! `box_handle`/`free_box` live in the heap. The dylib ABI passes those -//! boxes as opaque pointers, but a pure-C consumer that needs to (a) build -//! a project box from a module handle (interchange load) or (b) free a -//! borrowed handle box (sequences / clips the facade returns but has no -//! `oakengine_*_free` for) must know the box layout. The mirrors below -//! reproduce it exactly (identical `repr(C)` field layout), so boxes -//! created by the facade can be read/freed from the app and vice versa. -//! -//! The module handle type itself is the frozen `{ctx, addref, release, -//! abi_version}` value handle (`include/common/handle.h`); `release` is -//! what `free_box` calls before deallocating the box. - -use std::ffi::{c_char, c_int, c_void}; - -/// The module value handle (`{ctx, addref, release, abi_version}`), mirror -/// of `oakcore_rs::handle::CHandle` / `include/common/handle.h`. -#[repr(C)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CHandle { - /// Opaque refcounted box pointer. - pub ctx: *mut c_void, - /// Atomic increment. - pub addref: Option, - /// Atomic decrement; destroys at zero. - pub release: Option, - /// ABI version. - pub abi_version: u32, -} - -impl CHandle { - /// Whether this is the empty (zero) handle. - pub fn is_null(&self) -> bool { - self.ctx.is_null() - } -} - -/// Opaque engine handle boxes, mirroring the facade's `engine_handle!` -/// newtypes (one `CHandle` per box). Only the types the app actually -/// touches are declared. -macro_rules! engine_handle { - ($($name:ident),* $(,)?) => { - $( - /// Opaque engine handle: a `#[repr(C)]` box holding one module - /// [`CHandle`]. - #[repr(C)] - #[derive(Clone, Copy)] - pub struct $name { - /// The wrapped module handle. - pub handle: CHandle, - } - - impl HandleBox for $name { - fn boxed_new(handle: CHandle) -> Self { - $name { handle } - } - fn handle(&self) -> CHandle { - self.handle - } - } - )* - }; -} - -engine_handle! { - OakEngineAudioBuffer, - OakEngineClip, - OakEngineEncodingParams, - OakEngineFootage, - OakEngineFrame, - OakEngineNode, - OakEngineProject, - OakEngineRenderer, - OakEngineSequence, - OakEngineTask, -} - -/// Uniform construction/extraction surface of the engine opaque boxes. -pub trait HandleBox: Sized { - /// Build the box from a module handle. - fn boxed_new(handle: CHandle) -> Self; - /// Extract the wrapped module handle (copy). - fn handle(&self) -> CHandle; -} - -/// Allocate a heap box for a module handle and return its raw pointer. -/// The box must later be released with [`free_box`] or a consuming -/// `oakengine_*_free` export. -/// -/// # Safety -/// The handle must be a live module handle (e.g. from a facade export that -/// hands one over for the app to box). -pub unsafe fn box_handle(handle: CHandle) -> *mut T { - // SAFETY: the caller passes a live handle; the box is managed by the - // C ABI consumers from here on. - Box::into_raw(Box::new(T::boxed_new(handle))) -} - -/// Dereference an engine opaque box and copy out its module handle. -/// Returns `None` for a NULL pointer or an empty handle. -/// -/// # Safety -/// `ptr` must point to a live box created by [`box_handle`] or by the -/// facade (or be NULL). -pub unsafe fn unbox(ptr: *const T) -> Option { - // SAFETY: see the function docs. - if ptr.is_null() { - return None; - } - let h = (*ptr).handle(); - if h.is_null() { - None - } else { - Some(h) - } -} - -/// Free a box created by [`box_handle`] (or returned by the facade): -/// release the module handle (via its `release` function pointer) and -/// deallocate the box. NULL and empty handles are no-ops. After the call -/// `ptr` is dangling; the caller must not use it again. -/// -/// # Safety -/// `ptr` must be a pointer previously returned by [`box_handle`] or by the -/// facade (or NULL) and must not be freed twice. -pub unsafe fn free_box(ptr: *mut T) { - // SAFETY: see the function docs. - if ptr.is_null() { - return; - } - let handle = (*ptr).handle(); - if let Some(release) = handle.release { - release(handle.ctx); - } - drop(Box::from_raw(ptr)); -} - -/// `engine/include/oakengine/videoparams.h` — POD mirror of VideoParams' -/// user-facing fields (Rust mirror of `oak_video_params`; the facade's -/// `oakengine::common::OakVideoParamsPod`). -#[repr(C)] -#[derive(Clone, Copy)] -pub struct OakVideoParamsPod { - /// Width. - pub width: c_int, - /// Height. - pub height: c_int, - /// Frame duration numerator (e.g. 1001/30000 s). - pub time_base_num: c_int, - /// Frame duration denominator. - pub time_base_den: c_int, - /// PixelFormat::Format value. - pub format: c_int, - /// Pixel aspect numerator. - pub pixel_aspect_num: c_int, - /// Pixel aspect denominator. - pub pixel_aspect_den: c_int, - /// Interlacing value. - pub interlacing: c_int, - /// ColorRange value. - pub color_range: c_int, - /// Preview resolution divider (1 = full). - pub divider: c_int, - /// VideoParams::Type value. - pub video_type: c_int, - /// 0/1 premultiplied alpha. - pub premultiplied_alpha: c_int, -} - -/// The task event callback the module subscription invokes on the task's -/// own thread (`oaktask` C ABI, `include/task/task.h`). -pub type OakTaskEventFn = unsafe extern "C" fn(event_id: c_int, value: f64, userdata: *mut c_void); - -// The `oakengine_*` C ABI surface the app binds. Signatures mirror the -// facade's `#[no_mangle] pub extern "C"` exports verbatim (the facade -// declares a few of them without `unsafe`; calling any extern-block item -// still requires an unsafe context on the current toolchain, so the call -// sites in `real.rs` carry their own `unsafe` blocks). -// -// String outputs follow the engine buf/size convention: the return value -// is the required length excluding the terminating NUL; negative values -// are error codes. -#[link(name = "oakengine")] -unsafe extern "C" { - // -- oakengine::codec (encoding formats + params) -- - - /// `oakengine_encoding_format_count` — number of export formats. - pub fn oakengine_encoding_format_count() -> c_int; - /// `oakengine_encoding_format_name` (buf/size; -1 invalid). - pub fn oakengine_encoding_format_name( - format: c_int, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_encoding_format_extension` (buf/size). - pub fn oakengine_encoding_format_extension( - format: c_int, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_encoding_format_video_codec_at` — codec id, -1 invalid. - pub fn oakengine_encoding_format_video_codec_at(format: c_int, index: c_int) -> c_int; - /// `oakengine_encoding_format_audio_codec_at` — codec id, -1 invalid. - pub fn oakengine_encoding_format_audio_codec_at(format: c_int, index: c_int) -> c_int; - /// `oakengine_encoding_params_create` — owned params box. - pub fn oakengine_encoding_params_create() -> *mut OakEngineEncodingParams; - /// `oakengine_encoding_params_destroy` — consuming free. - pub fn oakengine_encoding_params_destroy(params: *mut OakEngineEncodingParams); - /// `oakengine_encoding_params_set_filename`. - pub fn oakengine_encoding_params_set_filename( - params: *mut OakEngineEncodingParams, - filename: *const c_char, - ) -> c_int; - /// `oakengine_encoding_params_set_format` — rejects out-of-range values. - pub fn oakengine_encoding_params_set_format( - params: *mut OakEngineEncodingParams, - format: c_int, - ) -> c_int; - /// `oakengine_encoding_params_enable_video` — copy the POD-carryable - /// fields of `video` and enable the video track. - pub fn oakengine_encoding_params_enable_video( - params: *mut OakEngineEncodingParams, - video: *const OakVideoParamsPod, - codec: c_int, - ) -> c_int; - /// `oakengine_encoding_params_enable_audio`. - pub fn oakengine_encoding_params_enable_audio( - params: *mut OakEngineEncodingParams, - sample_rate: c_int, - channel_layout: u64, - sample_format: c_int, - codec: c_int, - ) -> c_int; - /// `oakengine_encoding_params_set_export_length`. - pub fn oakengine_encoding_params_set_export_length( - params: *mut OakEngineEncodingParams, - num: c_int, - den: c_int, - ); - /// `oakengine_encoding_params_set_custom_range` — in/out export range as - /// seconds rationals (the work-area export; the task renders exactly - /// `[in, out)`). - pub fn oakengine_encoding_params_set_custom_range( - params: *mut OakEngineEncodingParams, - in_num: i64, - in_den: i64, - out_num: i64, - out_den: i64, - ); - - // -- oakengine::common (config) -- - - /// `oakengine_config_get_string` — read a config string; a missing key - /// reads as an empty string. - pub fn oakengine_config_get_string( - key: *const c_char, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_config_set_string` — write a string value. - pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int; - /// `oakengine_config_get_int` — read an integer value (fallback when the - /// key is missing or not convertible). - pub fn oakengine_config_get_int(key: *const c_char, default_value: i64) -> i64; - /// `oakengine_config_set_int` — write an integer value. - pub fn oakengine_config_set_int(key: *const c_char, value: i64) -> c_int; - /// `oakengine_config_load` — load the configuration from disk (the app - /// calls it once at startup, before reading any preference). - pub fn oakengine_config_load() -> c_int; - /// `oakengine_config_save` — persist the configuration to disk (the app - /// calls it on exit). - pub fn oakengine_config_save() -> c_int; - - // -- oakengine::storage (write-through session state) -- - - /// `oakengine_storage_flush` — flush every bound project and stop the - /// snapshot thread (the app calls it on exit). - pub fn oakengine_storage_flush() -> c_int; - /// `oakengine_storage_is_bound` — 1 when the project is bound to a - /// library session. - pub fn oakengine_storage_is_bound(project: *mut OakEngineProject) -> c_int; - /// `oakengine_storage_last_error` — the last write-through / snapshot - /// error (buf/size; empty when none or not bound). - pub fn oakengine_storage_last_error( - project: *mut OakEngineProject, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - - // -- oakengine::library (project manager, M13 D4) -- - - /// `oakengine_library_list` — the library rows as a JSON array - /// (buf/size), most recently modified first; `"[]"` with storage off. - pub fn oakengine_library_list(buf: *mut c_char, buf_size: c_int) -> c_int; - /// `oakengine_library_create` — create a blank project row; reports its - /// uuid (buf/size on `out_uuid`; the return value is the uuid length, - /// negative on error). - pub fn oakengine_library_create( - name: *const c_char, - out_uuid: *mut c_char, - out_size: c_int, - ) -> c_int; - /// `oakengine_library_delete` — delete a row by uuid. - pub fn oakengine_library_delete(uuid: *const c_char) -> c_int; - /// `oakengine_library_rename` — rename a row. - pub fn oakengine_library_rename(uuid: *const c_char, name: *const c_char) -> c_int; - /// `oakengine_library_duplicate` — copy a row (history included); - /// reports the new uuid like `oakengine_library_create`. - pub fn oakengine_library_duplicate( - uuid: *const c_char, - name: *const c_char, - out_uuid: *mut c_char, - out_size: c_int, - ) -> c_int; - /// `oakengine_library_import` — import a `.ove`/`.otio`/`.fcpxml` file - /// as a new row; reports the new uuid like `oakengine_library_create`. - pub fn oakengine_library_import( - path: *const c_char, - out_uuid: *mut c_char, - out_size: c_int, - ) -> c_int; - /// `oakengine_library_export` — export a row to `path` (format by - /// extension). - pub fn oakengine_library_export(uuid: *const c_char, path: *const c_char) -> c_int; - /// `oakengine_project_load_library` — load a library row into a fresh - /// project shell (same contract as `oakengine_project_load`); binds the - /// project to the library session. - pub fn oakengine_project_load_library( - self_: *mut OakEngineProject, - uuid: *const c_char, - err: *mut c_char, - err_size: c_int, - ) -> c_int; - - // -- oakengine::node (project) -- - - /// `oakengine_project_create` — owned project box (no content yet). - pub fn oakengine_project_create() -> *mut OakEngineProject; - /// `oakengine_project_free` — consuming free of an owned project box. - pub fn oakengine_project_free(self_: *mut OakEngineProject); - /// `oakengine_project_new` — initialize a blank project. - pub fn oakengine_project_new(self_: *mut OakEngineProject) -> c_int; - /// `oakengine_project_load` — load from `path`; fills `err` (buf/size) - /// on failure. - pub fn oakengine_project_load( - self_: *mut OakEngineProject, - path: *const c_char, - err: *mut c_char, - err_size: c_int, - ) -> c_int; - /// `oakengine_project_save` — write the project to `path` (or the - /// recorded filename when NULL). Legacy manual-save ABI, now used only - /// as the .ove export path (导出工程文件…); the write-through library is - /// the primary persistence. - pub fn oakengine_project_save(self_: *mut OakEngineProject, path: *const c_char) -> c_int; - /// `oakengine_project_name` (buf/size). - pub fn oakengine_project_name( - self_: *const OakEngineProject, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_project_filename` (buf/size). - pub fn oakengine_project_filename( - self_: *const OakEngineProject, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_project_footage_count`. - pub fn oakengine_project_footage_count(self_: *const OakEngineProject) -> c_int; - /// `oakengine_project_import_footage` — probe and add to the root - /// folder; owned footage box (free with `oakengine_footage_free`). - pub fn oakengine_project_import_footage( - self_: *mut OakEngineProject, - path: *const c_char, - ) -> *mut OakEngineFootage; - /// `oakengine_footage_free` — release a footage handle. - pub fn oakengine_footage_free(self_: *mut OakEngineFootage); - /// `oakengine_footage_last_error` — last probe/import error on this - /// thread (two-stage buf/size getter; empty when the last call - /// succeeded). - pub fn oakengine_footage_last_error(buf: *mut c_char, buf_size: c_int) -> c_int; - /// `oakengine_footage_borrow` — wrap a footage node in a borrowed - /// footage box (addref'd; free with `oakengine_footage_free`). NULL - /// when `node` is NULL or not a footage node. - pub fn oakengine_footage_borrow(node: *mut OakEngineNode) -> *mut OakEngineFootage; - /// `oakengine_footage_get_duration` — media duration in seconds. - pub fn oakengine_footage_get_duration( - self_: *mut OakEngineFootage, - seconds: *mut f64, - ) -> c_int; - /// `oakengine_sequence_add_footage_clip_ex` — place a clip of - /// `footage` on the track, skipping the unenforceable same-project - /// check (sequences live in their own scratch project — documented - /// module deviation; M12 P0 montage path). Owned clip box. - pub fn oakengine_sequence_add_footage_clip_ex( - self_: *mut OakEngineSequence, - footage: *mut OakEngineFootage, - track_type: c_int, - track_index: c_int, - in_: i64, - out: i64, - media_in: i64, - ) -> *mut OakEngineClip; - /// `oakengine_project_footage_at` — boxed footage node at `index` (free - /// with `oakengine_node_free`); NULL for an invalid index. - pub fn oakengine_project_footage_at( - self_: *const OakEngineProject, - index: c_int, - ) -> *mut OakEngineNode; - /// `oakengine_project_footage_filename` (buf/size). - pub fn oakengine_project_footage_filename( - self_: *const OakEngineProject, - index: c_int, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_project_can_undo`. - pub fn oakengine_project_can_undo(self_: *const OakEngineProject) -> c_int; - /// `oakengine_project_can_redo`. - pub fn oakengine_project_can_redo(self_: *const OakEngineProject) -> c_int; - /// `oakengine_project_undo`. - pub fn oakengine_project_undo(self_: *mut OakEngineProject) -> c_int; - /// `oakengine_project_redo`. - pub fn oakengine_project_redo(self_: *mut OakEngineProject) -> c_int; - /// `oakengine_project_sequence_count`. - pub fn oakengine_project_sequence_count(self_: *const OakEngineProject) -> c_int; - /// `oakengine_project_sequence_at` — borrowed sequence box (free with - /// [`free_box`]). - pub fn oakengine_project_sequence_at( - self_: *const OakEngineProject, - index: c_int, - ) -> *mut OakEngineSequence; - /// `oakengine_project_set_filename`. - pub fn oakengine_project_set_filename( - self_: *mut OakEngineProject, - path: *const c_char, - ) -> c_int; - /// `oakengine_project_node_count` — parseable content check. - pub fn oakengine_project_node_count(self_: *const OakEngineProject) -> c_int; - /// `oakengine_project_node_at` — boxed project node at `index` (free - /// with `oakengine_node_free`); NULL for an invalid index. - pub fn oakengine_project_node_at( - self_: *const OakEngineProject, - index: c_int, - ) -> *mut OakEngineNode; - /// `oakengine_project_root` — the project's root folder node (boxed, - /// free with `oakengine_node_free`). - pub fn oakengine_project_root(self_: *const OakEngineProject) -> *mut OakEngineNode; - - /// `oakengine_folder_item_child_count` — direct children of a folder - /// node. - pub fn oakengine_folder_item_child_count(folder: *const OakEngineNode) -> c_int; - /// `oakengine_folder_item_child` — boxed child at `index` (free with - /// `oakengine_node_free`); NULL for an invalid index. - pub fn oakengine_folder_item_child( - folder: *const OakEngineNode, - index: c_int, - ) -> *mut OakEngineNode; - /// `oakengine_node_get_name` (buf/size). - pub fn oakengine_node_get_name( - self_: *const OakEngineNode, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_node_get_label` (buf/size). - pub fn oakengine_node_get_label( - self_: *const OakEngineNode, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_node_input_count`. - pub fn oakengine_node_input_count(self_: *const OakEngineNode) -> c_int; - /// `oakengine_node_input_id` (buf/size) at `index`. - pub fn oakengine_node_input_id( - self_: *const OakEngineNode, - index: c_int, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_node_input_is_connected` — 1 when the input has an edge. - pub fn oakengine_node_input_is_connected( - self_: *const OakEngineNode, - input_id: *const c_char, - ) -> c_int; - /// `oakengine_node_output_connection_count`. - pub fn oakengine_node_output_connection_count(self_: *const OakEngineNode) -> c_int; - /// `oakengine_node_output_connection_at_ex` — boxed input node + input - /// id (free the boxed node with `oakengine_node_free`). - pub fn oakengine_node_output_connection_at_ex( - self_: *const OakEngineNode, - index: c_int, - input_node: *mut *mut OakEngineNode, - input_id_buf: *mut c_char, - input_id_size: c_int, - element: *mut c_int, - hidden: *mut c_int, - ) -> c_int; - /// `oakengine_node_get_context_position` — graph position of `node` - /// in `context`'s position map. - pub fn oakengine_node_get_context_position( - context: *const OakEngineNode, - node: *const OakEngineNode, - x: *mut f64, - y: *mut f64, - expanded: *mut c_int, - ) -> c_int; - /// `oakengine_node_set_context_position` — undoable graph move. - pub fn oakengine_node_set_context_position( - context: *mut OakEngineNode, - node: *mut OakEngineNode, - x: f64, - y: f64, - ) -> c_int; - /// `oakengine_node_connect` — undoable edge from `output_node` into - /// `input_node`'s `input_id`. - pub fn oakengine_node_connect( - output_node: *mut OakEngineNode, - input_node: *mut OakEngineNode, - input_id: *const c_char, - ) -> c_int; - /// `oakengine_node_disconnect_ex` — undoable edge removal. - pub fn oakengine_node_disconnect_ex( - input_node: *mut OakEngineNode, - input_id: *const c_char, - element: c_int, - ) -> c_int; - /// `oakengine_project_remove_node` — undoable node removal (edges - /// disconnected). - pub fn oakengine_project_remove_node( - self_: *mut OakEngineProject, - node: *mut OakEngineNode, - ) -> c_int; - - // -- oakengine::task -- - - /// `oakengine_task_error` (buf/size). - pub fn oakengine_task_error( - task: *mut OakEngineTask, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_task_cancel` — set the task's cancel atom. - pub fn oakengine_task_cancel(task: *mut OakEngineTask) -> c_int; - /// `oakengine_task_start_sync` — run the task to completion. - pub fn oakengine_task_start_sync(task: *mut OakEngineTask) -> c_int; - /// `oakengine_task_free` — consuming free of an owned task box. - pub fn oakengine_task_free(task: *mut OakEngineTask) -> c_int; - /// `oakengine_task_create_project_load_otio` — owned interchange load - /// task box. - pub fn oakengine_task_create_project_load_otio(filename: *const c_char) -> *mut OakEngineTask; - /// `oakengine_task_create_project_save_otio` — owned interchange save - /// task box. - pub fn oakengine_task_create_project_save_otio( - project: *mut OakEngineProject, - ) -> *mut OakEngineTask; - /// `oakengine_task_create_export` — owned export task box. - pub fn oakengine_task_create_export( - sequence: *mut OakEngineSequence, - params: *mut OakEngineEncodingParams, - ) -> *mut OakEngineTask; - - // -- oakengine::timeline -- - - /// `oakengine_sequence_new` — in-memory sequence (facade scratch - /// project). - pub fn oakengine_sequence_new( - project: *mut OakEngineProject, - name: *const c_char, - ) -> *mut OakEngineSequence; - /// `oakengine_sequence_name` (buf/size). - pub fn oakengine_sequence_name( - self_: *const OakEngineSequence, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_sequence_get_length` — length in seconds. - pub fn oakengine_sequence_get_length( - self_: *const OakEngineSequence, - seconds: *mut f64, - ) -> c_int; - /// `oakengine_sequence_get_frame_rate` — num/den rational. - pub fn oakengine_sequence_get_frame_rate( - self_: *const OakEngineSequence, - num: *mut c_int, - den: *mut c_int, - ) -> c_int; - /// `oakengine_sequence_get_video_params` — width/height/par. - pub fn oakengine_sequence_get_video_params( - self_: *const OakEngineSequence, - width: *mut c_int, - height: *mut c_int, - par_num: *mut c_int, - par_den: *mut c_int, - ) -> c_int; - /// `oakengine_sequence_track_count` — per-type counts. - pub fn oakengine_sequence_track_count( - self_: *const OakEngineSequence, - video: *mut c_int, - audio: *mut c_int, - subtitle: *mut c_int, - ) -> c_int; - /// `oakengine_sequence_set_playhead`. - pub fn oakengine_sequence_set_playhead(self_: *mut OakEngineSequence, timestamp: i64) -> c_int; - /// `oakengine_sequence_add_track`. - pub fn oakengine_sequence_add_track(self_: *mut OakEngineSequence, track_type: c_int) -> c_int; - /// `oakengine_sequence_last_error` — last editing error for this - /// thread (two-stage string). - pub fn oakengine_sequence_last_error(buf: *mut c_char, buf_size: c_int) -> c_int; - /// `oakengine_sequence_clip_count`. - pub fn oakengine_sequence_clip_count( - self_: *mut OakEngineSequence, - track_type: c_int, - track_index: c_int, - ) -> c_int; - /// `oakengine_sequence_clip_at` — borrowed clip box (free with - /// [`free_box`]). - pub fn oakengine_sequence_clip_at( - self_: *mut OakEngineSequence, - track_type: c_int, - track_index: c_int, - clip_index: c_int, - ) -> *mut OakEngineClip; - /// `oakengine_clip_get_range` — clip timeline range and media in-point - /// as frame timestamps. - pub fn oakengine_clip_get_range( - self_: *const OakEngineClip, - in_: *mut i64, - out: *mut i64, - media_in: *mut i64, - ) -> c_int; - /// `oakengine_sequence_split_clip`. - pub fn oakengine_sequence_split_clip( - seq: *mut OakEngineSequence, - track_type: c_int, - track_index: c_int, - clip_index: c_int, - time: i64, - ) -> c_int; - /// `oakengine_sequence_move_clip_to_track` — move the clip to a - /// different track (M12 P4, cross-track). - pub fn oakengine_sequence_move_clip_to_track( - seq: *mut OakEngineSequence, - track_type: c_int, - track_index: c_int, - clip_index: c_int, - dest_track_index: c_int, - new_in: i64, - ) -> c_int; - /// `oakengine_sequence_move_clip` — move the clip so its in point becomes - /// `new_in` on the same track. - pub fn oakengine_sequence_move_clip( - seq: *mut OakEngineSequence, - track_type: c_int, - track_index: c_int, - clip_index: c_int, - new_in: i64, - ) -> c_int; - /// `oakengine_sequence_ripple_delete_clip`. - pub fn oakengine_sequence_ripple_delete_clip( - seq: *mut OakEngineSequence, - track_type: c_int, - track_index: c_int, - clip_index: c_int, - ) -> c_int; - /// `oakengine_clip_trim` — change the clip's timeline range. - pub fn oakengine_clip_trim(clip: *mut OakEngineClip, new_in: i64, new_out: i64) -> c_int; - /// `oakengine_sequence_delete_clips` — delete a clip array, optionally - /// rippling; `rippled` reports the ripple length. - pub fn oakengine_sequence_delete_clips( - seq: *mut OakEngineSequence, - clips: *mut *mut OakEngineClip, - clip_count: c_int, - ripple: c_int, - ripple_ranges_ts: *const i64, - ripple_range_count: c_int, - rippled: *mut c_int, - ) -> c_int; - /// `oakengine_sequence_remove_track`. - pub fn oakengine_sequence_remove_track( - seq: *mut OakEngineSequence, - track_type: c_int, - track_index: c_int, - ) -> c_int; - /// `oakengine_track_get_height` — height in internal units. - pub fn oakengine_track_get_height( - seq: *const OakEngineSequence, - track_type: c_int, - track_index: c_int, - height: *mut f64, - ) -> c_int; - /// `oakengine_track_set_height` — height in internal units. - pub fn oakengine_track_set_height( - seq: *mut OakEngineSequence, - track_type: c_int, - track_index: c_int, - height: f64, - ) -> c_int; - - /// `oakengine_sequence_marker_count` — number of timeline markers - /// (0 for NULL/invalid). - pub fn oakengine_sequence_marker_count(self_: *const OakEngineSequence) -> c_int; - /// `oakengine_sequence_marker_at` — marker at `index`: time as a frame - /// timestamp (the sequence's timebase), name via the buf/size - /// convention, and the color index. - pub fn oakengine_sequence_marker_at( - self_: *const OakEngineSequence, - index: c_int, - time: *mut i64, - name: *mut c_char, - name_size: c_int, - color: *mut c_int, - ) -> c_int; - /// `oakengine_sequence_marker_add` — undoable marker at `time_ts`. - pub fn oakengine_sequence_marker_add( - seq: *mut OakEngineSequence, - time_ts: i64, - name: *const c_char, - ) -> c_int; - /// `oakengine_sequence_marker_remove` — undoable removal of the marker - /// at `time_ts`. - pub fn oakengine_sequence_marker_remove(seq: *mut OakEngineSequence, time_ts: i64) -> c_int; - - /// `oakengine_sequence_workarea_is_enabled` — 1 when the work area is - /// enabled. - pub fn oakengine_sequence_workarea_is_enabled(self_: *const OakEngineSequence) -> c_int; - /// `oakengine_sequence_get_workarea` — work-area in/out as frame - /// timestamps (the reset sentinel out when never set). - pub fn oakengine_sequence_get_workarea( - self_: *const OakEngineSequence, - in_: *mut i64, - out: *mut i64, - ) -> c_int; - /// `oakengine_sequence_set_workarea` — set the enabled flag + range - /// live (NOT undoable; the ruler-drag preview path). - pub fn oakengine_sequence_set_workarea( - self_: *mut OakEngineSequence, - enabled: c_int, - in_: i64, - out: i64, - ) -> c_int; - /// `oakengine_sequence_set_workarea_undoable` — set the enabled flag + - /// range as ONE undoable entry ("Set Workarea"). `old_in`/`old_out` are - /// the range before the change (the caller captured it, e.g. the - /// drag-start range); the old enabled flag is captured by the engine. - pub fn oakengine_sequence_set_workarea_undoable( - self_: *mut OakEngineSequence, - enabled: c_int, - in_: i64, - out: i64, - old_in: i64, - old_out: i64, - ) -> c_int; - - /// `oakengine_track_height_internal_to_pixels`. - pub fn oakengine_track_height_internal_to_pixels(height: f64) -> c_int; - /// `oakengine_track_height_pixels_to_internal`. - pub fn oakengine_track_height_pixels_to_internal(pixels: c_int) -> f64; - - // -- oakengine::node (effect chain) -- - // - // The effect-stack surface over a selected clip: convert the clip to its - // node view, enumerate its effect chain (index 0 = closest to the - // source), and edit the chain (insert / remove / reorder / enable - // toggle), each edit packaged as an undoable facade command. Factory - // enumeration feeds the "add effect" menu. Node boxes are freed with - // `oakengine_node_free`; the clip box returned by - // `oakengine_sequence_clip_at` is freed with [`free_box`]. - - /// `oakengine_clip_as_node` — the clip's node view (borrowed box; - /// freed with `oakengine_node_free`). - pub fn oakengine_clip_as_node(self_: *const OakEngineClip) -> *mut OakEngineNode; - /// `oakengine_sequence_as_node` — the sequence's node view (borrowed - /// box; freed with `oakengine_node_free`). The node editor's graph - /// output node and the context for its position map. - pub fn oakengine_sequence_as_node(self_: *const OakEngineSequence) -> *mut OakEngineNode; - /// `oakengine_sequence_node_count` — nodes in the sequence's owning - /// project (its graph; 0 for NULL/invalid). - pub fn oakengine_sequence_node_count(self_: *const OakEngineSequence) -> c_int; - /// `oakengine_sequence_node_at` — boxed graph node at `index` (freed - /// with `oakengine_node_free`); NULL for an invalid index or sequence. - pub fn oakengine_sequence_node_at( - self_: *const OakEngineSequence, - index: c_int, - ) -> *mut OakEngineNode; - /// `oakengine_sequence_remove_node` — undoable removal of `node` from - /// the sequence's graph (the sequence node itself is protected). - pub fn oakengine_sequence_remove_node( - self_: *mut OakEngineSequence, - node: *mut OakEngineNode, - ) -> c_int; - /// `oakengine_clip_get_media_filename` — the clip's upstream footage - /// filename (two-stage buf/size; M12 P4). - pub fn oakengine_clip_get_media_filename( - self_: *const OakEngineClip, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_waveform_extract` — real waveform extraction (M12 P4): - /// two-stage min/max extraction of `filename`'s audio stream. - pub fn oakengine_waveform_extract( - filename: *const c_char, - stream_index: c_int, - samples_per_point: c_int, - out_pairs: *mut crate::oakui::waveform::MinMax, - capacity_points: c_int, - out_channel_count: *mut c_int, - ) -> c_int; - - - /// `oakengine_node_effect_count` — the chain length (0 without effects). - pub fn oakengine_node_effect_count(self_: *const OakEngineNode) -> c_int; - /// `oakengine_node_effect_at` — the `index`-th effect (borrowed box; - /// NULL out of range / no chain). - pub fn oakengine_node_effect_at( - self_: *const OakEngineNode, - index: c_int, - ) -> *mut OakEngineNode; - /// `oakengine_node_identity` — the node's stable identity (the stack's - /// card id). 0 for NULL/invalid. - pub fn oakengine_node_identity(self_: *const OakEngineNode) -> u64; - /// `oakengine_node_is_enabled` — 1/0 (the stack's enable switch). - pub fn oakengine_node_is_enabled(self_: *const OakEngineNode) -> c_int; - /// `oakengine_node_effect_set_enabled` — undoable enable toggle. - pub fn oakengine_node_effect_set_enabled(self_: *mut OakEngineNode, enabled: c_int) -> c_int; - /// `oakengine_node_effect_insert` — undoable insert at `index` - /// (0 = closest to the source; clamped to the ends). - pub fn oakengine_node_effect_insert( - self_: *mut OakEngineNode, - index: c_int, - type_id: *const c_char, - ) -> c_int; - /// `oakengine_node_effect_remove` — undoable removal of `effect` from - /// `self_`'s chain (the node is left orphaned in the project graph). - pub fn oakengine_node_effect_remove( - self_: *mut OakEngineNode, - effect: *mut OakEngineNode, - ) -> c_int; - /// `oakengine_node_effect_move` — undoable reorder of `effect` to - /// `new_index` (post-removal insertion index, matching the effect - /// stack's `ReorderRequested`). - pub fn oakengine_node_effect_move( - self_: *mut OakEngineNode, - effect: *mut OakEngineNode, - new_index: c_int, - ) -> c_int; - /// `oakengine_node_get_effect_input` — the id of the input the effect - /// chain attaches to (negative error when the node cannot host - /// effects; `element` receives -1 for non-array inputs). - pub fn oakengine_node_get_effect_input( - self_: *const OakEngineNode, - input_id: *mut c_char, - input_id_size: c_int, - element: *mut c_int, - ) -> c_int; - /// `oakengine_node_get_type_id` — the node's factory type id (buf/size). - pub fn oakengine_node_get_type_id( - self_: *const OakEngineNode, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_node_get_flags` — the node's flags bitmask (0 for - /// NULL/invalid). - pub fn oakengine_node_get_flags(self_: *const OakEngineNode) -> u64; - /// `oakengine_node_free` — free a node box (NULL no-op). - pub fn oakengine_node_free(node: *mut OakEngineNode); - /// `oakengine_node_factory_id_count` — factory entry count. - pub fn oakengine_node_factory_id_count() -> c_int; - /// `oakengine_node_factory_id_at` — type id at `index` (buf/size; - /// negative error out of range). - pub fn oakengine_node_factory_id_at(index: c_int, buf: *mut c_char, buf_size: c_int) -> c_int; - /// `oakengine_node_factory_name_from_id` — display name (buf/size). - pub fn oakengine_node_factory_name_from_id( - type_id: *const c_char, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_node_factory_create_from_id` — owned node, not added. - pub fn oakengine_node_factory_create_from_id(type_id: *const c_char) -> *mut OakEngineNode; - /// `oakengine_node_flag_video_effect`. - pub fn oakengine_node_flag_video_effect() -> u64; - /// `oakengine_node_flag_dont_show_in_create_menu`. - pub fn oakengine_node_flag_dont_show_in_create_menu() -> u64; - - // -- oakengine::render (CPU frame renderer) -- - // - // The renderer binds a sequence handle to an output geometry; each - // `render_frame` submits an oakrender ticket, waits for it and returns - // the produced frame (F32 RGBA when created with pixel format 4). The - // renderer box is opaque: it is freed ONLY with - // `oakengine_renderer_free` (never [`free_box`] — the facade box is not - // a module-handle box). Frames are freed with `oakengine_frame_free`. - - /// `oakengine_renderer_create` — owned renderer box (NULL on invalid - /// arguments: NULL sequence, non-positive geometry/rate, or a pixel - /// format outside 0..=4). `output_colorspace` may be NULL. - pub fn oakengine_renderer_create( - seq: *mut OakEngineSequence, - width: c_int, - height: c_int, - pixel_format: c_int, - frame_rate_num: c_int, - frame_rate_den: c_int, - output_colorspace: *const c_char, - ) -> *mut OakEngineRenderer; - /// `oakengine_renderer_create_for_node` — like `oakengine_renderer_create`, - /// but binds any node instead of a sequence: the surface for rendering a - /// single footage node (the source monitor). Freed and rendered exactly - /// like the sequence renderer. - pub fn oakengine_renderer_create_for_node( - node: *mut OakEngineNode, - width: c_int, - height: c_int, - pixel_format: c_int, - frame_rate_num: c_int, - frame_rate_den: c_int, - output_colorspace: *const c_char, - ) -> *mut OakEngineRenderer; - /// `oakengine_renderer_free` — consuming free (NULL no-op). - pub fn oakengine_renderer_free(self_: *mut OakEngineRenderer); - /// `oakengine_renderer_render_frame` — synchronous render of the frame at - /// `timestamp` (in the renderer's frame-rate units). NULL on failure - /// (see `oakengine_renderer_last_error`). - pub fn oakengine_renderer_render_frame( - self_: *mut OakEngineRenderer, - timestamp: i64, - ) -> *mut OakEngineFrame; - /// `oakengine_renderer_last_error` (buf/size). - pub fn oakengine_renderer_last_error( - self_: *const OakEngineRenderer, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_renderer_render_audio` — synchronously render - /// `length_timestamp` frames of audio starting at `start_timestamp` - /// (M12 P1). Owned `OakEngineAudioBuffer`. - pub fn oakengine_renderer_render_audio( - self_: *mut OakEngineRenderer, - start_timestamp: i64, - length_timestamp: i64, - ) -> *mut OakEngineAudioBuffer; - /// `oakengine_audio_sample_rate` — the rendered buffer's rate (Hz). - pub fn oakengine_audio_sample_rate(self_: *const OakEngineAudioBuffer) -> c_int; - /// `oakengine_audio_channel_count`. - pub fn oakengine_audio_channel_count(self_: *const OakEngineAudioBuffer) -> c_int; - /// `oakengine_audio_sample_count` — interleaved frame count. - pub fn oakengine_audio_sample_count(self_: *const OakEngineAudioBuffer) -> i64; - /// `oakengine_audio_data` — interleaved f32 samples. - pub fn oakengine_audio_data( - self_: *const OakEngineAudioBuffer, - channel: c_int, - ) -> *const f32; - /// `oakengine_audio_free` — release the buffer. - pub fn oakengine_audio_free(self_: *mut OakEngineAudioBuffer); - /// `oakengine_audio_push_to_output` — queue interleaved samples - /// described by `params` for playback (M12 P1). - pub fn oakengine_audio_push_to_output( - params: *const c_void, - samples: *const c_char, - samples_size: i64, - error_buf: *mut c_char, - error_buf_size: c_int, - ) -> c_int; - /// `oakcore_audioparams_create` — new owned audio params (release - /// with `oakcore_audioparams_free`). - pub fn oakcore_audioparams_create( - sample_rate: c_int, - channel_layout: u64, - format: c_int, - ) -> *mut c_void; - /// `oakcore_audioparams_free` (NULL no-op). - pub fn oakcore_audioparams_free(params: *mut c_void); - /// `oakengine_render_manager_shutdown` — tear down the render manager - /// (test/tooling; the app keeps it for the process lifetime). - pub fn oakengine_render_manager_shutdown() -> c_int; - /// `oakengine_testmedia_write_clip` — encode the known test pattern - /// into `path` (test/tooling only; M12 P0). - pub fn oakengine_testmedia_write_clip( - path: *const c_char, - width: c_int, - height: c_int, - frame_count: c_int, - fps: c_int, - ) -> c_int; - /// `oakengine_frame_width`. - pub fn oakengine_frame_width(self_: *const OakEngineFrame) -> c_int; - /// `oakengine_frame_height`. - pub fn oakengine_frame_height(self_: *const OakEngineFrame) -> c_int; - /// `oakengine_frame_format` — `PixelFormat::Format` (4 = F32). - pub fn oakengine_frame_format(self_: *const OakEngineFrame) -> c_int; - /// `oakengine_frame_linesize_bytes` — row stride in bytes. - pub fn oakengine_frame_linesize_bytes(self_: *const OakEngineFrame) -> c_int; - /// `oakengine_frame_data` — borrowed pixel data (valid until free). - pub fn oakengine_frame_data(self_: *const OakEngineFrame) -> *const c_void; - /// `oakengine_frame_free` — consuming free (NULL no-op). - pub fn oakengine_frame_free(self_: *mut OakEngineFrame); - - // -- oakengine::audio -- - - /// `oakengine_audio_output_levels` — per-channel linear peaks of the - /// buffered output into `peaks` (up to `capacity` entries); returns - /// the channel count (0 = nothing buffered), negative on error. - pub fn oakengine_audio_output_levels(peaks: *mut f32, capacity: c_int) -> c_int; - /// `oakengine_audio_create_instance` — create the AudioManager singleton - /// (no-op when it exists). The app calls it once at startup so playback - /// can open an output stream. - pub fn oakengine_audio_create_instance() -> c_int; - /// `oakengine_audio_get_output_device` — the output device index - /// (-1 = none/default). - pub fn oakengine_audio_get_output_device() -> i64; - /// `oakengine_audio_set_output_device` — set the output device index; - /// the stream reopens on the next pushed samples. - pub fn oakengine_audio_set_output_device(device: i64) -> c_int; - /// `oakengine_audio_get_input_device` — the input device index. - pub fn oakengine_audio_get_input_device() -> i64; - /// `oakengine_audio_set_input_device` — set the input device index. - pub fn oakengine_audio_set_input_device(device: i64) -> c_int; - /// `oakengine_audio_output_device_count` — the host's output device - /// count (enumeration order == device index). - pub fn oakengine_audio_output_device_count() -> c_int; - /// `oakengine_audio_output_device_name` — the name of output device - /// `index` (buf/size; the length excludes the NUL). - pub fn oakengine_audio_output_device_name( - index: c_int, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// `oakengine_audio_input_device_count` — the host's input device count. - pub fn oakengine_audio_input_device_count() -> c_int; - /// `oakengine_audio_input_device_name` — the name of input device - /// `index` (buf/size). - pub fn oakengine_audio_input_device_name( - index: c_int, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - - // -- oakengine::render (manager lifecycle) -- - - /// `oakengine_render_manager_init` — bring up the module's - /// process-global render manager. Without it `render_frame` fails with - /// NULL + last_error. Fails (nonzero) when the manager is already - /// initialized. - pub fn oakengine_render_manager_init() -> c_int; - /// `oakengine_render_manager_available` — 1 when the render manager is up. - pub fn oakengine_render_manager_available() -> c_int; - - // -- oakengine::task (interchange load result + event subscription) -- - - /// `oakengine_task_load_take_project` — take the project an - /// interchange load/load-otio task produced (ownership moves to the - /// caller; release with `oakengine_project_free`). NULL when the task - /// is not a load task or has no project yet. - pub fn oakengine_task_load_take_project(task: *mut OakEngineTask) -> *mut OakEngineProject; - /// `oakengine_task_subscribe` — register the task event callback - /// (`OAKTASK_EVENT_STARTED`=0, `OAKTASK_EVENT_PROGRESS`=1, - /// `OAKTASK_EVENT_FINISHED`=2). - pub fn oakengine_task_subscribe( - task: *mut OakEngineTask, - cb: Option, - userdata: *mut c_void, - ) -> i64; -} - -/// The C-ABI `MinMax` mirror (exported for the integration tests). -#[repr(C)] -#[derive(Debug, Clone, Copy)] -pub struct oakapp_minmax { - /// Minimum amplitude. - pub min: f32, - /// Maximum amplitude. - pub max: f32, -} diff --git a/src/oakui/graphops.rs b/src/oakui/graphops.rs new file mode 100644 index 000000000..ac0a04f8d --- /dev/null +++ b/src/oakui/graphops.rs @@ -0,0 +1,1456 @@ +// 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 . + +//! Domain helpers over the module graph (M14 R3). +//! +//! The real engine ([`crate::oakui::real`]) drives the oak* module crates +//! directly (no liboakengine C ABI). This module is the app's own assembly +//! layer for the project/timeline/storage domain: project lifecycle (new / +//! load / save over the oaknode serializer), sequence + track + clip +//! queries, footage import, the per-sequence marker-list / workarea +//! auxiliaries, and the project-library operations over oakstorage's +//! write-through backend. +//! +//! The functions mirror the semantics the facade's `oakengine_*` exports +//! had (the facade keeps its own copies for the frozen C ABI); the +//! composition mirrors `crates/oak-cli/src/engine.rs` (M14 R2), which +//! established the same mapping for the CLI. + +use std::path::Path; +use std::sync::{Arc, Mutex, MutexGuard}; + +use oakcore_rs::{Rational, TimeRange}; +use oaknode::block::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 oaktimeline::handle::CHandle; +use oaktimeline::util::NodeRef; + +use oakstorage::backend::StorageBackend; + +use super::engine::LibraryProject; + +/// 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). +pub fn lock(p: &ProjectRef) -> MutexGuard<'_, Project> { + p.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// A timeline node reference (the oaktimeline command addressing). +pub fn node_ref(p: &ProjectRef, id: NodeId) -> NodeRef { + NodeRef::new(p.clone(), id) +} + +/// The node id whose stable identity is `identity` (`None` for the +/// sentinel). The app's widget-facing ids (clip ids, explorer entry ids) +/// ARE the node identities. +pub fn id_of(identity: u64) -> Option { + NodeId::from_identity(identity) +} + +// --------------------------------------------------------------------------- +// Behavior borrows +// --------------------------------------------------------------------------- + +/// Borrow the sequence behavior at `id`. +pub fn sequence_behavior(g: &Graph, id: NodeId) -> Option<&SequenceBehavior> { + g.get(id)? + .behavior + .as_any()? + .downcast_ref::() +} + +/// Borrow the track-list behavior at `id`. +pub fn track_list_behavior(g: &Graph, id: NodeId) -> Option<&TrackListBehavior> { + g.get(id)? + .behavior + .as_any()? + .downcast_ref::() +} + +/// Borrow the track behavior at `id`. +pub fn track_behavior(g: &Graph, id: NodeId) -> Option<&TrackBehavior> { + g.get(id)? + .behavior + .as_any()? + .downcast_ref::() +} + +/// Borrow the clip behavior at `id`. +pub fn clip_behavior(g: &Graph, id: NodeId) -> Option<&ClipBlockBehavior> { + g.get(id)? + .behavior + .as_any()? + .downcast_ref::() +} + +/// Borrow the footage behavior at `id`. +pub fn footage_behavior(g: &Graph, id: NodeId) -> Option<&FootageBehavior> { + g.get(id)? + .behavior + .as_any()? + .downcast_ref::() +} + +/// Whether `id` is a folder node. +pub fn is_folder(g: &Graph, id: NodeId) -> bool { + g.get(id) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + .is_some() +} + +// --------------------------------------------------------------------------- +// Project lifecycle +// --------------------------------------------------------------------------- + +/// Create a blank, initialized project (the facade's `project_create` + +/// `project_new`, minus the undo-stack clear and the storage bind — the +/// engine's adopt path owns those). +pub fn create_project() -> ProjectRef { + let project = Project::new(); + lock(&project).initialize().ok(); + project +} + +/// Load a `.ove` project file (plain XML; the module serializer ignores +/// the legacy compression flag). The filename is normalized to an absolute +/// path and the modified flag cleared, mirroring the facade's +/// `oakengine_project_load`. +pub fn load_ove(path: &Path) -> Result { + let xml = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read \"{}\": {e}", path.display()))?; + let project = + oaknode::serializer::load(&xml).map_err(|e| format!("failed to parse: {e}"))?; + let abs = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|d| d.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + }; + let mut guard = lock(&project); + guard.set_filename(&abs.to_string_lossy()); + guard.set_modified(false); + drop(guard); + Ok(project) +} + +/// Write the project to `path` through the OVE serializer (the facade's +/// `oakengine_project_save` semantics: the target filename is recorded and +/// the modified flag cleared on success). +pub fn save_ove(project: &ProjectRef, path: &Path) -> Result<(), String> { + let xml = { + let guard = lock(project); + oaknode::serializer::save(&guard).map_err(|e| format!("failed to serialize: {e}"))? + }; + std::fs::write(path, &xml).map_err(|e| format!("failed to write \"{}\": {e}", path.display()))?; + let mut guard = lock(project); + guard.set_filename(&path.to_string_lossy()); + guard.set_modified(false); + Ok(()) +} + +/// The display name (`Project::name`: filename base or "(untitled)"). +pub fn project_name(p: &Project) -> String { + p.name() +} + +// --------------------------------------------------------------------------- +// Sequences +// --------------------------------------------------------------------------- + +/// Every sequence node in the graph, in arena order. +pub fn sequence_ids(p: &Project) -> Vec { + p.graph + .node_ids() + .into_iter() + .filter(|&id| sequence_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() +} + +/// Create a sequence node named `name` directly in the project's graph +/// (unlike the facade's `oakengine_sequence_new`, which kept the sequence +/// in a scratch project, the direct-rlib app keeps it in the project so +/// saves and the write-through library cover it). +pub fn create_sequence(project: &ProjectRef, name: &str) -> NodeId { + let mut guard = lock(project); + let (mut core, behavior) = SequenceBehavior::create(); + core.label = name.to_string(); + guard.graph.add_node(core, behavior) +} + +/// 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 type id of a node (empty when the id is stale). +pub fn node_type_id(g: &Graph, id: NodeId) -> String { + g.get(id) + .map(|e| e.behavior.type_id().to_string()) + .unwrap_or_default() +} + +/// The sequence's video format `(width, height, rate)` from its first +/// video stream. +pub fn sequence_video_params(g: &Graph, seq: NodeId) -> Option<(i32, i32, Rational)> { + let v = sequence_behavior(g, seq)?.video_params.first()?; + Some((v.width, v.height, v.frame_rate)) +} + +/// The sequence's frame duration as a `(num, den)` timebase pair (the +/// frame rate flipped; the facade's `seq_time_base`). `None` when the +/// sequence has no valid frame rate. +pub fn sequence_time_base(g: &Graph, seq: NodeId) -> Option<(i64, i64)> { + let (_, _, rate) = sequence_video_params(g, seq)?; + let num = rate.numerator(); + let den = rate.denominator(); + if num <= 0 || den <= 0 { + return None; + } + Some((den, num)) +} + +/// The sequence content length (rational seconds): the longest track out +/// point across every track list (the module's `verify_length` overall). +pub fn sequence_length(g: &Graph, seq: NodeId) -> Rational { + let mut best = Rational::new(0, 1); + let Some(s) = sequence_behavior(g, seq) else { + return best; + }; + for &list_id in &s.track_lists { + let Some(list) = track_list_behavior(g, list_id) else { + continue; + }; + for &track_id in &list.tracks { + let Some(track) = track_behavior(g, track_id) else { + continue; + }; + for &block_id in &track.blocks { + let Some(entry) = g.get(block_id) else { + continue; + }; + let Some(core) = block_core(entry) else { + continue; + }; + let out = core.out(); + if out > best { + best = out; + } + } + } + } + best +} + +/// Borrow a block's core (any block kind). +fn block_core(entry: &oaknode::graph::NodeEntry) -> Option<&oaknode::block::BlockCore> { + let any = entry.behavior.as_any()?; + if let Some(c) = any.downcast_ref::() { + return Some(&c.core); + } + if let Some(g) = any.downcast_ref::() { + return Some(&g.core); + } + any.downcast_ref::() + .map(|t| &t.core) +} + +/// The sequence playhead (rational seconds). +pub fn sequence_playhead(g: &Graph, seq: NodeId) -> Rational { + sequence_behavior(g, seq) + .map(|s| s.playhead) + .unwrap_or(Rational::new(0, 1)) +} + +/// Move the sequence playhead (rational seconds). +pub fn sequence_set_playhead(p: &ProjectRef, seq: NodeId, time: Rational) { + let mut guard = lock(p); + if let Some(s) = guard + .graph + .get_mut(seq) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + s.playhead = time; + } +} + +// --------------------------------------------------------------------------- +// Frame timestamps <-> rational seconds +// --------------------------------------------------------------------------- + +/// Greatest common divisor (1 when both are zero). +fn gcd(a: i64, b: i64) -> i64 { + let (mut a, mut b) = (a.abs(), b.abs()); + while b != 0 { + let t = b; + b = a % b; + a = t; + } + if a == 0 { + 1 + } else { + a + } +} + +/// Rational seconds -> timestamp in timebase units, rounding half away +/// from zero (the facade's `rational_to_ts`, `Timecode::k_round`). +pub fn rational_to_ts(r: Rational, tb: (i64, i64)) -> i64 { + let (num, den) = (r.numerator(), r.denominator()); + if den == 0 || tb.0 == 0 || tb.1 == 0 { + return 0; + } + let n = num as i128 * tb.1 as i128; + let d = den as i128 * tb.0 as i128; + let q = n / d; + let r = n % d; + let rr = if r < 0 { -r } else { r }; + let dd = if d < 0 { -d } else { d }; + if rr * 2 >= dd { + (q + if n < 0 { -1 } else { 1 }) as i64 + } else { + q as i64 + } +} + +/// Timestamp -> reduced rational seconds (`time = ts * tb`). +pub fn ts_to_rational(ts: i64, tb: (i64, i64)) -> Rational { + let num = ts as i128 * tb.0 as i128; + let den = tb.1 as i128; + let g = gcd((num % den) as i64, den as i64) as i128; + Rational::new((num / g) as i64, (den / g) as i64) +} + +// --------------------------------------------------------------------------- +// Tracks and clips +// --------------------------------------------------------------------------- + +/// The sequence's track list of `kind`, when it exists. +pub fn track_list_of(g: &Graph, seq: NodeId, kind: TrackType) -> Option { + for &list_id in &sequence_behavior(g, seq)?.track_lists { + if track_list_behavior(g, list_id).map(|l| l.kind) == Some(kind) { + return Some(list_id); + } + } + None +} + +/// Find (or create) the sequence's track list of `kind` (the facade's +/// `oaknode_sequence_get_track_list` find-or-create semantics; mirrors the +/// CLI's `find_or_create_track_list`). +pub fn find_or_create_track_list(p: &ProjectRef, seq: NodeId, kind: TrackType) -> Option { + let mut guard = lock(p); + if let Some(list) = track_list_of(&guard.graph, seq, kind) { + return Some(list); + } + // 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 = sequence_behavior(&guard.graph, seq)?.track_lists.len() as i32; + } + } + let list_id = guard.graph.add_node(core, behavior); + if let Some(s) = guard + .graph + .get_mut(seq) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + s.track_lists.push(list_id); + } + if let Some(l) = guard + .graph + .get_mut(list_id) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + l.sequence = Some(seq); + } + Some(list_id) +} + +/// The tracks of the sequence's `kind` list, in stack order. +pub fn track_ids(g: &Graph, seq: NodeId, kind: TrackType) -> Vec { + track_list_of(g, seq, kind) + .and_then(|list| track_list_behavior(g, list).map(|l| l.tracks.clone())) + .unwrap_or_default() +} + +/// The clip blocks of a track (gaps skipped), in timeline order. +pub fn clip_ids(g: &Graph, track: NodeId) -> Vec { + track_behavior(g, track) + .map(|t| { + t.blocks + .iter() + .copied() + .filter(|&b| clip_behavior(g, b).is_some()) + .collect() + }) + .unwrap_or_default() +} + +/// A clip's timeline range and media in-point (rational seconds). +pub fn clip_range(g: &Graph, clip: NodeId) -> Option<(Rational, Rational, Rational)> { + let c = clip_behavior(g, clip)?; + Some((c.core.in_(), c.core.out(), c.core.media_in)) +} + +/// The clip's owning track. +pub fn clip_track(g: &Graph, clip: NodeId) -> Option { + clip_behavior(g, clip)?.core.track +} + +/// The first footage node feeding `id` (upstream BFS over input edges), +/// mirroring the facade's `oaknode_node_find_input_footage`. +pub 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 media filename feeding a clip (upstream BFS + footage behavior). +pub fn clip_media_filename(g: &Graph, clip: NodeId) -> Option { + let footage = find_input_footage(g, clip)?; + Some(footage_behavior(g, footage)?.filename.clone()) +} + +// --------------------------------------------------------------------------- +// Footage +// --------------------------------------------------------------------------- + +/// The footage's probed duration in seconds (`None` when unprobed). +pub fn footage_duration_seconds(g: &Graph, id: NodeId) -> Option { + let d = footage_behavior(g, id)?.duration(); + let den = d.denominator(); + if den == 0 { + return None; + } + let seconds = d.numerator() as f64 / den as f64; + (seconds > 0.0).then_some(seconds) +} + +/// Import a media file into the project's root folder (the facade's +/// `oakengine_project_import_footage`: the node is created in the graph +/// and one undoable "Import Footage" entry adds it to the root folder). +pub fn import_footage(project: &ProjectRef, path: &Path) -> Result { + if !path.exists() { + return Err(format!("file does not exist: {}", path.display())); + } + let filename = path.to_string_lossy().into_owned(); + let (root, id) = { + let mut guard = lock(project); + if !guard.root.valid() { + return Err("the project has no root folder".to_string()); + } + let (mut core, behavior) = FootageBehavior::create(); + core.set_standard_value("file_in", -1, oaknode::value::NodeValue::Text(filename.clone())); + let id = guard.graph.add_node(core, behavior); + if let Some(f) = guard + .graph + .get_mut(id) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + f.filename = filename.clone(); + let _ = f.probe(); + } + let label = path + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_else(|| filename.clone()); + if let Some(e) = guard.graph.get_mut(id) { + e.core.label = label; + } + (guard.root, id) + }; + let cmd = oaktask::nodeops::folder_add_child_command( + (project.clone(), root), + (project.clone(), id), + ); + oakundo::global::push(cmd, "Import Footage").map_err(|e| e.to_string())?; + Ok(id) +} + +// --------------------------------------------------------------------------- +// Marker list / workarea auxiliaries +// +// The module's sequences never initialize their own marker/workarea state +// (`SequenceBehavior` defaults both to empty handles), so the app — like +// the facade before it — materializes one of each per open sequence. The +// engine owns the handles and releases them before the project drops. +// --------------------------------------------------------------------------- + +/// Create a marker-list handle (owned, refcount 1). +pub fn marker_list_create() -> CHandle { + oaktimeline::handle::make_owned(oaktimeline::marker::TimelineMarkerList::new()) +} + +/// Create a workarea handle (owned, refcount 1). +pub fn workarea_create() -> CHandle { + oaktimeline::handle::make_owned(oaktimeline::workarea::TimelineWorkArea::new()) +} + +/// Release an owned marker-list / workarea handle (NULL-safe; the handle +/// is dead afterwards). +pub fn release_handle(h: &mut CHandle) { + if let Some(release) = h.release { + // SAFETY: `h` is an owned handle from `marker_list_create` / + // `workarea_create`; the release runs the box's own destructor once + // per owned reference, and the caller drops the handle afterwards. + unsafe { release(h.ctx) }; + } + h.ctx = std::ptr::null_mut(); +} + +/// One marker as the timeline shows it: in-point, name, color index. +pub fn markers_of(list: &CHandle) -> Vec<(Rational, String, i32)> { + if list.is_null() { + return Vec::new(); + } + // SAFETY: `list` boxes a `TimelineMarkerList` (created by + // `marker_list_create`); the read is shared and brief. + let Some(l) = (unsafe { oaktimeline::handle::get::(list) }) + else { + return Vec::new(); + }; + (0..l.size()) + .filter_map(|i| l.at(i)) + .map(|m| (m.time().in_(), m.name().to_string(), m.color())) + .collect() +} + +/// The index of the first marker whose in-point equals `time`. +pub fn marker_index_at(list: &CHandle, time: Rational) -> Option { + if list.is_null() { + return None; + } + // SAFETY: as `markers_of`. + let l = unsafe { oaktimeline::handle::get::(list) }?; + (0..l.size()).find(|&i| l.at(i).map(|m| m.time().in_()) == Some(time)) +} + +/// The workarea's `(enabled, range)`. +pub fn workarea_state(wa: &CHandle) -> Option<(bool, TimeRange)> { + if wa.is_null() { + return None; + } + // SAFETY: `wa` boxes a `TimelineWorkArea` (created by + // `workarea_create`); the read is shared and brief. + let w = unsafe { oaktimeline::handle::get::(wa) }?; + Some((w.enabled(), *w.range())) +} + +/// Live (non-undoable) workarea write: enable flag plus range. +pub fn workarea_set(wa: &CHandle, enabled: bool, range: TimeRange) { + if wa.is_null() { + return; + } + // SAFETY: `wa` boxes a `TimelineWorkArea`; the engine writes it only + // from the UI thread. + if let Some(w) = unsafe { oaktimeline::handle::get_mut::(wa) } + { + w.set_enabled(enabled); + w.set_range(range); + } +} + +// --------------------------------------------------------------------------- +// Project library (M13 D4): the write-through database the manager browses +// +// The facade's library_* exports serialized rows to JSON only because they +// crossed the C ABI; the direct calls below return plain values. +// --------------------------------------------------------------------------- + +/// The configured default library as a parsed URI; an error when the +/// write-through backend is disabled or the path does not resolve. +fn library() -> Result { + if !oakstorage::writethrough::storage_enabled() { + return Err("the project library is not configured".to_string()); + } + let uri = oakstorage::writethrough::library_uri() + .ok_or_else(|| "the project library path does not resolve".to_string())?; + oakstorage::uri::StorageUri::parse(&uri).map_err(|e| e.to_string()) +} + +/// The library URI selecting one row (`…?project=`). +fn project_uri(uuid: &str) -> Result { + let uri = library()?; + oakstorage::uri::StorageUri::parse(&format!("{}?project={uuid}", uri.to_uri_string())) + .map_err(|e| e.to_string()) +} + +/// The library rows, most recently modified first (the project manager's +/// data source). With storage disabled the result is the empty list, not +/// an error (the facade contract). +pub fn library_list() -> Result, String> { + if !oakstorage::writethrough::storage_enabled() { + return Ok(Vec::new()); + } + let uri = library()?; + let backend = oakstorage::writethrough::backend(); + let infos = backend.list_projects(&uri).map_err(|e| e.to_string())?; + Ok(infos + .into_iter() + .map(|info| { + let stats = backend.project_stats(&uri, &info.uuid).unwrap_or_default(); + LibraryProject { + uuid: info.uuid, + name: info.name, + created_at: info.created_at.and_utc().timestamp(), + modified_at: info.modified_at.and_utc().timestamp(), + duration_ms: stats.duration_ms, + track_count: stats.track_count, + clip_count: stats.clip_count, + footage_count: stats.footage_count, + } + }) + .collect()) +} + +/// Create a blank project row named `name`; returns its uuid. The row +/// lands immediately (one `kind='import'` command), so the manager list +/// shows it before the first edit. +pub fn library_create(name: &str) -> Result { + if name.trim().is_empty() { + return Err("invalid name".to_string()); + } + let uri = library()?; + let project = create_project(); + let uuid = { + let mut guard = lock(&project); + guard + .settings + .insert("projectname".to_string(), name.to_string()); + guard.uuid.clone() + }; + let handle = oakstorage::nodeutil::make_project_owned(project); + let result = oakstorage::writethrough::backend() + .save(handle, &uri, 0) + .map_err(|e| e.to_string()); + oakstorage::nodeutil::release_project(handle); + result?; + Ok(uuid) +} + +/// Delete the library row `uuid` (cascades settings / snapshots / +/// journal). +pub fn library_delete(uuid: &str) -> Result<(), String> { + if uuid.is_empty() { + return Err("invalid uuid".to_string()); + } + oakstorage::writethrough::backend() + .delete_project(&library()?, uuid) + .map_err(|e| e.to_string()) +} + +/// Rename the library row `uuid` (the manager's list name). +pub fn library_rename(uuid: &str, name: &str) -> Result<(), String> { + if uuid.is_empty() || name.trim().is_empty() { + return Err("invalid uuid or name".to_string()); + } + oakstorage::writethrough::backend() + .rename_project(&library()?, uuid, name.trim()) + .map_err(|e| e.to_string()) +} + +/// Duplicate the library row `uuid` (history included) under a fresh +/// uuid; returns the new row's uuid. `None` name defaults to +/// ` (copy)` backend-side. +pub fn library_duplicate(uuid: &str) -> Result { + if uuid.is_empty() { + return Err("invalid uuid".to_string()); + } + let info = oakstorage::writethrough::backend() + .duplicate_project(&library()?, uuid, None) + .map_err(|e| e.to_string())?; + Ok(info.uuid) +} + +/// Import a `.ove` / `.otio` / `.fcpxml` project file as a new library +/// row; returns the new row's uuid. +pub fn library_import(path: &Path) -> Result { + let file_uri = oakstorage::uri::StorageUri::parse(&path.to_string_lossy()) + .map_err(|e| e.to_string())?; + oakstorage::writethrough::backend() + .import_from_file(&library()?, &file_uri) + .map_err(|e| e.to_string()) +} + +/// Export the library row `uuid` to `path`; the format is dispatched by +/// extension through the oakstorage registry. +pub fn library_export(uuid: &str, path: &Path) -> Result<(), String> { + if uuid.is_empty() { + return Err("invalid uuid".to_string()); + } + let file_uri = oakstorage::uri::StorageUri::parse(&path.to_string_lossy()) + .map_err(|e| e.to_string())?; + oakstorage::writethrough::backend() + .export_to_file(&library()?, uuid, &file_uri) + .map_err(|e| e.to_string()) +} + +/// Load the library row `uuid` as a fresh project (the modified flag is +/// cleared, mirroring the facade's `oakengine_project_load_library`; the +/// undo-stack clear and the storage bind are the engine adopt path's job). +pub fn library_open(uuid: &str) -> Result { + if uuid.is_empty() { + return Err("invalid uuid".to_string()); + } + let result = oakstorage::writethrough::backend() + .load(&project_uri(uuid)?) + .map_err(|e| e.to_string())?; + if result.project.is_null() { + return Err(format!( + "library load of {uuid} returned no project (info code {})", + result.version_info + )); + } + let handle = result.project; + let project = oakstorage::nodeutil::project_arc_of(&handle) + .ok_or_else(|| "library load returned a foreign project handle".to_string()); + // The loaded handle's ownership moves to the caller's Arc; release the + // handle shell (the Arc keeps the project alive). + oakstorage::nodeutil::release_project(handle); + let project = project?; + lock(&project).set_modified(false); + Ok(project) +} + +// --------------------------------------------------------------------------- +// Write-through binding (the engine's per-project session state) +// --------------------------------------------------------------------------- + +/// Bind `project` to the configured default library and return the handle +/// the binding was registered under (the caller keeps it for +/// [`storage_bound`] / [`storage_last_error`] queries and releases it with +/// [`storage_unbind`]). No-op (None still returned) semantics mirror the +/// facade: an unconfigured library leaves the project unbound but the +/// handle is still usable for queries. +pub fn storage_bind(project: &ProjectRef) -> CHandle { + let handle = oakstorage::nodeutil::make_project_owned(project.clone()); + oakstorage::writethrough::bind_project(handle); + handle +} + +/// Whether the project behind `handle` is bound to the write-through +/// session (the status bar's write state). +pub fn storage_bound(handle: &CHandle) -> bool { + oakstorage::writethrough::is_bound(*handle) +} + +/// The last write-through / snapshot error of the project behind +/// `handle`, if any. +pub fn storage_last_error(handle: &CHandle) -> Option { + oakstorage::writethrough::last_error(*handle) +} + +/// Flush the project's pending writes, drop its binding and release the +/// query handle (the engine's project-close path). +pub fn storage_unbind(handle: CHandle) { + oakstorage::writethrough::unbind_project(handle); + oakstorage::nodeutil::release_project(handle); +} + +/// Flush every bound project and stop the snapshot thread (app exit). +pub fn storage_flush() { + oakstorage::writethrough::flush_all(); +} + +// --------------------------------------------------------------------------- +// Undoable edit primitives +// +// The timeline edits the facade's `oakengine_sequence_*` / `oakengine_clip_*` +// exports performed, rebuilt over the oaktimeline command structs and the +// oakundo global stack's safe [`oakundo::global::push`]. Every entry point +// pushes exactly one undo row (composite edits assemble a multi command). +// --------------------------------------------------------------------------- + +/// Push one command onto the global undo stack (redo then record). +pub fn push_command(cmd: oakundo::undocommand::UndoCommand, name: &str) -> Result<(), String> { + oakundo::global::push(cmd, name).map_err(|e| e.to_string()) +} + +/// Assemble `children` into ONE multi command and push it (an empty set +/// is a no-op, mirroring the stack's empty-multi discard). +pub fn push_multi_command( + children: Vec, + name: &str, +) -> Result<(), String> { + if children.is_empty() { + return Ok(()); + } + let mut multi = oakundo::undocommand::UndoCommand::multi(); + for child in children { + multi.multi_add_child(child); + } + push_command(multi, name) +} + +/// Push one command (private alias). +fn push(cmd: oakundo::undocommand::UndoCommand, name: &str) -> Result<(), String> { + push_command(cmd, name) +} + +/// Assemble and push a multi command (private alias). +fn push_multi(children: Vec, name: &str) -> Result<(), String> { + push_multi_command(children, name) +} + +/// An undoable edge add (the module's `oaknode_node_connect_undoable` +/// semantics): validated at creation (existence, connectability, not +/// already connected); the redo connects, the undo disconnects the input. +pub fn connect_command( + p: &ProjectRef, + from: NodeId, + to: NodeId, + input_id: &str, +) -> Result { + { + let g = lock(p); + if !g.graph.is_valid(from) || !g.graph.is_valid(to) { + return Err("connect: node not found".to_string()); + } + let entry = g.graph.get(to).ok_or("connect: node not found")?; + let input = entry + .core + .get_input(input_id) + .ok_or_else(|| format!("connect: no input \"{input_id}\""))?; + if !input.is_connectable() { + return Err(format!("connect: input \"{input_id}\" is not connectable")); + } + if g.graph.connected_output(to, input_id, -1).is_some() { + return Err(format!("connect: input \"{input_id}\" is already connected")); + } + } + let (p1, p2) = (p.clone(), p.clone()); + let (id1, id2) = (input_id.to_string(), input_id.to_string()); + Ok(oakundo::undocommand::UndoCommand::from_closures( + move || { + let mut g = lock(&p1); + let _ = g.graph.connect(from, to, &id1, -1); + }, + move || { + let mut g = lock(&p2); + g.graph.disconnect_input(to, &id2, -1); + }, + )) +} + +/// An undoable edge remove: succeeds even when nothing is connected (the +/// redo is then a no-op, mirroring the C++ command's redo swallowing). The +/// undo re-connects the edge captured at construction — the module's +/// `oaknode_node_disconnect_undoable` did not model this (documented +/// deviation); the app retains the source node, so its effect-chain edits +/// undo faithfully. +pub fn disconnect_command( + p: &ProjectRef, + to: NodeId, + input_id: &str, +) -> Result { + let source = { + let g = lock(p); + let has_input = g + .graph + .get(to) + .map(|e| e.core.has_input(input_id)) + .unwrap_or(false); + if !has_input { + return Err(format!("disconnect: no input \"{input_id}\"")); + } + g.graph.connected_output(to, input_id, -1) + }; + let (p1, p2) = (p.clone(), p.clone()); + let (id1, id2) = (input_id.to_string(), input_id.to_string()); + Ok(oakundo::undocommand::UndoCommand::from_closures( + move || { + let mut g = lock(&p1); + g.graph.disconnect_input(to, &id1, -1); + }, + move || { + if let Some(from) = source { + let mut g = lock(&p2); + let _ = g.graph.connect(from, to, &id2, -1); + } + }, + )) +} + +/// An undoable context-position set (the facade's +/// `oakengine_node_set_context_position`): the first entry is established +/// by the redo itself; the undo restores the previous position or removes +/// the entry it created. +pub fn set_context_position_command( + p: &ProjectRef, + node: NodeId, + context: NodeId, + x: f64, + y: f64, +) -> Result { + let old = { + let g = lock(p); + if !g.graph.is_valid(node) || !g.graph.is_valid(context) { + return Err("set position: node not found".to_string()); + }; + g.graph + .get(node) + .and_then(|e| { + e.core + .context_positions + .iter() + .find(|(c, _, _)| *c == context) + .map(|(_, pos, expanded)| (*pos, *expanded)) + }) + }; + let (p1, p2) = (p.clone(), p.clone()); + Ok(oakundo::undocommand::UndoCommand::from_closures( + move || { + let mut g = lock(&p1); + if let Some(e) = g.graph.get_mut(node) { + e.core.set_context_position(context, x, y, false); + } + }, + move || { + let mut g = lock(&p2); + if let Some(e) = g.graph.get_mut(node) { + match old { + Some(((ox, oy), expanded)) => { + e.core.set_context_position(context, ox, oy, expanded); + } + None => { + e.core.remove_from_context(context); + } + } + } + }, + )) +} + +/// Append a track of `kind` to the sequence (undoable "Add Track"; the +/// module's `TimelineAddTrackCommand`), returning the new track's index. +pub fn add_track(p: &ProjectRef, seq: NodeId, kind: TrackType) -> Result { + let list = find_or_create_track_list(p, seq, kind) + .ok_or_else(|| "sequence has no track list for this type".to_string())?; + push( + oaktimeline::undogeneral::TimelineAddTrackCommand::new(node_ref(p, list)).to_command(), + "Add Track", + )?; + let g = lock(p); + let n = track_list_behavior(&g.graph, list) + .map(|l| l.tracks.len()) + .ok_or_else(|| "add track command produced no track list".to_string())?; + if n == 0 { + return Err("add track command produced no track".to_string()); + } + Ok(n - 1) +} + +/// Remove `track` from its list (undoable "Remove Track"; the module's +/// `TimelineRemoveTrackCommand` redo performs the full list+graph +/// removal, so no live compensation is needed). +pub fn remove_track(p: &ProjectRef, track: NodeId) -> Result<(), String> { + push( + oaktimeline::undogeneral::TimelineRemoveTrackCommand::new(node_ref(p, track)).to_command(), + "Remove Track", + ) +} + +/// A track's height in internal units (`None` when the id is stale). +pub fn track_height(p: &ProjectRef, track: NodeId) -> Option { + let g = lock(p); + track_behavior(&g.graph, track).map(|t| t.height) +} + +/// Set a track's height in internal units (NOT undoable, mirroring the +/// facade's `oakengine_track_set_height`). +pub fn set_track_height(p: &ProjectRef, track: NodeId, height: f64) { + if height <= 0.0 { + return; + } + let mut g = lock(p); + if let Some(t) = g + .graph + .get_mut(track) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + t.height = height; + } +} + +/// Place a clip of `footage` on track `track_index` of the sequence's +/// `kind` list (undoable "Add Clip", one row): the clip block is created +/// in the project, placed by the module's `TrackPlaceBlockCommand`, and +/// the footage is wired to the clip's `tex_in` — the facade's +/// `oakengine_sequence_add_footage_clip_ex` composition, minus the +/// scratch-project dance (the app keeps everything in one project). +/// +/// `in_ts`/`out_ts`/`media_in_ts` are frame timestamps in the sequence's +/// frame-rate timebase. On undo the block is detached from the track but +/// left as an orphan node in the project graph (the module command's +/// documented behavior). +pub fn place_footage_clip( + p: &ProjectRef, + seq: NodeId, + footage: NodeId, + kind: TrackType, + track_index: usize, + in_ts: i64, + out_ts: i64, + media_in_ts: i64, +) -> Result { + 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()); + } + if kind != TrackType::Video && kind != TrackType::Audio { + return Err("clips are only supported on video and audio tracks".to_string()); + } + let (tb, list) = { + let g = lock(p); + if footage_behavior(&g.graph, footage).is_none() { + return Err("the footage node is not in the project".to_string()); + } + let tb = sequence_time_base(&g.graph, seq) + .ok_or_else(|| "sequence has no valid frame rate".to_string())?; + let list = track_list_of(&g.graph, seq, kind) + .ok_or_else(|| "sequence has no track list for this type".to_string())?; + (tb, list) + }; + let track_count = { + let g = lock(p); + track_list_behavior(&g.graph, list) + .map(|l| l.tracks.len()) + .unwrap_or(0) + }; + if track_index >= track_count { + return Err(format!( + "track index {track_index} out of range ({track_count} tracks)" + )); + } + + let in_r = ts_to_rational(in_ts, tb); + let out_r = ts_to_rational(out_ts, tb); + let media_r = ts_to_rational(media_in_ts, tb); + let length = out_r - in_r; + + // 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 = { + let mut g = lock(p); + let (core, behavior) = oaknode::block::clip_create(); + let id = g.graph.add_node(core, behavior); + if let Some(c) = g + .graph + .get_mut(id) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + c.core.media_in = media_r; + c.core.set_length_and_media_in(length); + } + id + }; + + let place = oaktimeline::undopointer::TrackPlaceBlockCommand::new( + node_ref(p, list), + track_index as i32, + node_ref(p, clip), + in_r, + ) + .to_command(); + let edge = connect_command(p, footage, clip, oaknode::block::clip_input::TEXTURE_INPUT)?; + push_multi(vec![place, edge], "Add Clip")?; + Ok(clip) +} + +/// Split `clip` at `time_ts` (a frame timestamp strictly inside the +/// clip's range), undoable "Split Clip" (the module's +/// `BlockSplitCommand`). +pub fn split_clip(p: &ProjectRef, clip: NodeId, time_ts: i64) -> Result<(), String> { + let (tb, in_r, out_r) = { + let g = lock(p); + let tb = clip_track(&g.graph, clip) + .and_then(|t| track_behavior(&g.graph, t)) + .and_then(|t| t.track_list) + .and_then(|l| track_list_behavior(&g.graph, l)) + .and_then(|l| l.sequence) + .and_then(|s| sequence_time_base(&g.graph, s)) + .ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?; + let (in_r, out_r, _) = clip_range(&g.graph, clip) + .ok_or_else(|| "the node is not a clip".to_string())?; + (tb, in_r, out_r) + }; + let point = ts_to_rational(time_ts, tb); + if point <= in_r || point >= out_r { + return Err(format!("split time {time_ts} is not strictly inside the clip")); + } + push( + oaktimeline::undosplit::BlockSplitCommand::new(node_ref(p, clip), point).to_command(), + "Split Clip", + ) +} + +/// Trim `clip`'s timeline range to `[new_in_ts, new_out_ts)` (undoable +/// "Trim Clip"; the facade's `oakengine_clip_trim` semantics: one end at +/// a time, trim-in anchors the OUT, trim-out anchors the IN — the +/// module's own `BlockTrimCommand` applies its length setters with +/// inverted semantics, so the closures carry the correct mapping). +pub fn trim_clip(p: &ProjectRef, clip: NodeId, new_in_ts: i64, new_out_ts: i64) -> Result<(), String> { + use oaknode::block::BlockCore; + if new_in_ts < 0 || new_out_ts <= new_in_ts { + return Err("invalid trim range (need 0 <= new_in < new_out)".to_string()); + } + let (tb, old_in, old_out, old_length) = { + let g = lock(p); + let tb = clip_track(&g.graph, clip) + .and_then(|t| track_behavior(&g.graph, t)) + .and_then(|t| t.track_list) + .and_then(|l| track_list_behavior(&g.graph, l)) + .and_then(|l| l.sequence) + .and_then(|s| sequence_time_base(&g.graph, s)) + .ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?; + let (in_r, out_r, _) = + clip_range(&g.graph, clip).ok_or_else(|| "the node is not a clip".to_string())?; + let length = out_r - in_r; + (tb, in_r, out_r, length) + }; + if new_in_ts == rational_to_ts(old_in, tb) && new_out_ts == rational_to_ts(old_out, tb) { + return Ok(()); + } + let new_in = ts_to_rational(new_in_ts, tb); + let new_out = ts_to_rational(new_out_ts, tb); + + /// A trim closure command: `set` applies the length (in anchored or + /// out anchored) for both redo and undo with the captured values. + fn trim_cmd( + p: &ProjectRef, + clip: NodeId, + out_anchored: bool, + old: Rational, + new: Rational, + ) -> oakundo::undocommand::UndoCommand { + let (p1, p2) = (p.clone(), p.clone()); + oakundo::undocommand::UndoCommand::from_closures( + move || { + let mut g = lock(&p1); + if let Some(c) = g + .graph + .get_mut(clip) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + if out_anchored { + BlockCore::set_length_and_media_out(&mut c.core, new); + } else { + BlockCore::set_length_and_media_in(&mut c.core, new); + } + } + }, + move || { + let mut g = lock(&p2); + if let Some(c) = g + .graph + .get_mut(clip) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + if out_anchored { + BlockCore::set_length_and_media_out(&mut c.core, old); + } else { + BlockCore::set_length_and_media_in(&mut c.core, old); + } + } + }, + ) + } + + let mut children = Vec::new(); + if new_in != old_in { + // in-trim: length = block out - new in (out anchored). + children.push(trim_cmd(p, clip, true, old_length, old_out - new_in)); + } + if new_out != old_out { + // out-trim: length = new out - new in (in anchored); the old + // length is the post-in-trim length (out - new in) when both ends + // move. + let post_in_trim = old_out - new_in; + children.push(trim_cmd(p, clip, false, post_in_trim, new_out - new_in)); + } + push_multi(children, "Trim Clip") +} + +/// Move `clip` within its track so its in point becomes `new_in_ts` +/// (undoable "Move Clip"; the module's `TrackMoveBlockCommand` — the old +/// spot becomes a gap, length and media-in are preserved). +pub fn move_clip(p: &ProjectRef, clip: NodeId, new_in_ts: i64) -> Result<(), String> { + if new_in_ts < 0 { + return Err("invalid move target".to_string()); + } + let (tb, list, track_index) = { + let g = lock(p); + let track = clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?; + let list = track_behavior(&g.graph, track) + .and_then(|t| t.track_list) + .ok_or_else(|| "the clip's track has no list".to_string())?; + let track_index = track_behavior(&g.graph, track).map(|t| t.index).unwrap_or(0); + let tb = track_list_behavior(&g.graph, list) + .and_then(|l| l.sequence) + .and_then(|s| sequence_time_base(&g.graph, s)) + .ok_or_else(|| "the sequence has no valid frame rate".to_string())?; + (tb, list, track_index) + }; + push( + oaktimeline::undopointer::TrackMoveBlockCommand::new( + node_ref(p, list), + track_index, + node_ref(p, clip), + ts_to_rational(new_in_ts, tb), + ) + .to_command(), + "Move Clip", + ) +} + +/// Move `clip` to a different track at `new_in_ts` (undoable "Move Clip +/// to Track", one row): the source spot becomes a gap, the block's in +/// point is re-homed, and the clip is placed on the destination track +/// (the facade's `oakengine_sequence_move_clip_to_track` composition). +pub fn move_clip_to_track( + p: &ProjectRef, + clip: NodeId, + dest_track: NodeId, + new_in_ts: i64, +) -> Result<(), String> { + if new_in_ts < 0 { + return Err("invalid move target".to_string()); + } + let (tb, list, dest_index, source_track) = { + let g = lock(p); + let source_track = + clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?; + let list = track_behavior(&g.graph, dest_track) + .and_then(|t| t.track_list) + .ok_or_else(|| "the destination track has no list".to_string())?; + let dest_index = track_behavior(&g.graph, dest_track) + .map(|t| t.index) + .unwrap_or(0); + let tb = track_list_behavior(&g.graph, list) + .and_then(|l| l.sequence) + .and_then(|s| sequence_time_base(&g.graph, s)) + .ok_or_else(|| "the sequence has no valid frame rate".to_string())?; + (tb, list, dest_index, source_track) + }; + let in_r = ts_to_rational(new_in_ts, tb); + + let gap = oaktimeline::undogeneral::TrackReplaceBlockWithGapCommand::new( + node_ref(p, source_track), + node_ref(p, clip), + true, + ) + .to_command(); + // The module stores a block's position on the block itself, so the + // place command alone would keep the old in point: re-home it between + // the gap and the place (the facade's BlockInCmdData step). + let old_in = { + let g = lock(p); + clip_range(&g.graph, clip) + .map(|(in_r, _, _)| in_r) + .ok_or_else(|| "the node is not a clip".to_string())? + }; + let (p1, p2) = (p.clone(), p.clone()); + let rehome = oakundo::undocommand::UndoCommand::from_closures( + move || { + let mut g = lock(&p1); + if let Some(c) = g + .graph + .get_mut(clip) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + c.core.set_in(in_r); + } + }, + move || { + let mut g = lock(&p2); + if let Some(c) = g + .graph + .get_mut(clip) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + c.core.set_in(old_in); + } + }, + ); + let place = oaktimeline::undopointer::TrackPlaceBlockCommand::new( + node_ref(p, list), + dest_index, + node_ref(p, clip), + in_r, + ) + .to_command(); + push_multi(vec![gap, rehome, place], "Move Clip to Track") +} + +/// Delete `clip` leaving a gap (undoable "Delete Clips"; the facade's +/// single-clip `oakengine_sequence_delete_clips` composition). +pub fn delete_clip(p: &ProjectRef, clip: NodeId) -> Result<(), String> { + let source_track = { + let g = lock(p); + clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())? + }; + let gap = oaktimeline::undogeneral::TrackReplaceBlockWithGapCommand::new( + node_ref(p, source_track), + node_ref(p, clip), + true, + ) + .to_command(); + let remove = oaktask::nodeops::remove_node_command(p.clone(), clip); + push_multi(vec![gap, remove], "Delete Clips") +} + +/// Delete `clip` and ripple the following content left (undoable +/// "Ripple Delete Clip"; the module's `TrackRippleRemoveAreaCommand` +/// over the clip's range). +pub fn ripple_delete_clip(p: &ProjectRef, clip: NodeId) -> Result<(), String> { + let (track, range) = { + let g = lock(p); + let track = + clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?; + let (in_r, out_r, _) = + clip_range(&g.graph, clip).ok_or_else(|| "the node is not a clip".to_string())?; + (track, TimeRange::new(in_r, out_r)) + }; + push( + oaktimeline::undoripple::TrackRippleRemoveAreaCommand::new(node_ref(p, track), range) + .to_command(), + "Ripple Delete Clip", + ) +} + +/// Undoable marker add ("Add Marker"). `time` is a rational seconds +/// in-point; a marker at the same time is rejected (the engine's marker +/// insertion asserts on duplicate times). +pub fn marker_add(markers: &CHandle, time: Rational, name: &str, color: i32) -> Result<(), String> { + if marker_index_at(markers, time).is_some() { + return Err("a marker already exists at that time".to_string()); + } + push( + oaktimeline::marker::MarkerAddCommand::new( + *markers, + TimeRange::new(time, time), + name, + color, + ) + .to_command(), + "Add Marker", + ) +} + +/// Undoable marker remove ("Remove Marker"); `None`-equivalent when no +/// marker sits at `time` (the caller treats it as a benign no-op). +pub fn marker_remove(markers: &CHandle, time: Rational) -> Result<(), String> { + let Some(index) = marker_index_at(markers, time) else { + return Err("no marker at that time".to_string()); + }; + push( + oaktimeline::marker::MarkerRemoveCommand::new(*markers, index).to_command(), + "Remove Marker", + ) +} + +/// Undoable workarea set ("Set Workarea"): the enabled flag plus the +/// in/out range as ONE entry. `old_range` is supplied by the caller (the +/// range read before the change — e.g. the drag-start range of a ruler +/// work-area drag); the enabled flag's previous value is captured by the +/// module command itself. +pub fn workarea_set_undoable( + wa: &CHandle, + enabled: bool, + range: TimeRange, + old_range: TimeRange, +) -> Result<(), String> { + push_multi( + vec![ + oaktimeline::workarea::WorkareaSetEnabledCommand::new(*wa, enabled).to_command(), + oaktimeline::workarea::WorkareaSetRangeCommand::new_with_old(*wa, range, old_range) + .to_command(), + ], + "Set Workarea", + ) +} + +/// Undoable removal of `node` from the project graph ("Remove Node"; the +/// module's remove command drops incident edges and restores the entry on +/// undo). The sequence node itself (the graph's output) cannot be +/// removed. +pub fn remove_node(p: &ProjectRef, node: NodeId) -> Result<(), String> { + push( + oaktimeline::undocommon::create_remove_command(&node_ref(p, node)), + "Remove Node", + ) +} + +// --------------------------------------------------------------------------- +// Test serialization +// --------------------------------------------------------------------------- + +/// A process-wide test lock: the app's tests share the oakundo global +/// stack, the oakcommon config store and the codec decode sessions, so any +/// test touching them serializes on this lock. +#[cfg(test)] +pub fn test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} diff --git a/src/oakui/host_syms.rs b/src/oakui/host_syms.rs deleted file mode 100644 index a2acde73c..000000000 --- a/src/oakui/host_syms.rs +++ /dev/null @@ -1,258 +0,0 @@ -// 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 . - -//! In-process stand-ins for the C++ host symbols the module crates call. -//! -//! The oakcodec / oakcommon crates reference a handful of symbols that in -//! the real desktop product live in the C++ host process (`liboakcore` and -//! `ffmpeg_bridge`): `oakcore_audioparams_*`, `oakcore_rational_*` and -//! `fb_find_best_pix_fmt_of_list`. The facade's own test binaries provide -//! the same stubs in `crates/oakengine/tests/common/mod.rs`; this module is -//! the equivalent for the app binary, so linking oakengine (and through it -//! oakcodec/oakcommon) never leaves undefined symbols. -//! -//! The stubs are small, in-memory and functional enough for the app's use: -//! the audio-params handle carries the fields the codec probes read back, -//! and the rational helpers store the (num, den) pair behind an opaque -//! pointer. - -use std::collections::HashMap; -use std::ffi::{c_char, c_int, c_void}; -use std::sync::{Mutex, OnceLock}; - -/// Opaque `OakAudioParams` handle type (the real one lives in liboakcore). -#[repr(C)] -pub struct OakAudioParams { - _opaque: [u8; 0], -} - -/// Per-`OakAudioParams` backing state. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct MockAudioParams { - sample_rate: i32, - channel_layout: u64, - format: i32, - stream_index: i32, - duration: i64, - time_base_num: i32, - time_base_den: i32, -} - -fn audio_params_store() -> &'static Mutex> { - static S: OnceLock>> = OnceLock::new(); - S.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn audio_params_get(ctx: *const c_void) -> MockAudioParams { - let store = audio_params_store().lock().unwrap(); - store.get(&(ctx as usize)).cloned().unwrap_or_default() -} - -fn audio_params_set(ctx: *mut c_void, f: impl FnOnce(&mut MockAudioParams)) { - let mut store = audio_params_store().lock().unwrap(); - if let Some(p) = store.get_mut(&(ctx as usize)) { - f(p); - } -} - -/// Per-`OakRational` backing state (an owned `(num, den)` pair). -fn rational_store() -> &'static Mutex> { - static S: OnceLock>> = OnceLock::new(); - S.get_or_init(|| Mutex::new(HashMap::new())) -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_create( - sample_rate: c_int, - channel_layout: u64, - format: c_int, -) -> *mut OakAudioParams { - let p = MockAudioParams { - sample_rate, - channel_layout, - format, - stream_index: 0, - duration: 0, - time_base_num: 1, - time_base_den: sample_rate, - }; - let raw = Box::into_raw(Box::new(p.clone())); - audio_params_store().lock().unwrap().insert(raw as usize, p); - raw as *mut OakAudioParams -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_free(params: *mut OakAudioParams) { - if params.is_null() { - return; - } - audio_params_store() - .lock() - .unwrap() - .remove(&(params as usize)); - // SAFETY: produced by `oakcore_audioparams_create`; we hold the only - // reference after removal. - unsafe { drop(Box::from_raw(params as *mut MockAudioParams)) }; -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_sample_rate(params: *const OakAudioParams) -> c_int { - audio_params_get(params as *const c_void).sample_rate -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_set_sample_rate( - params: *mut OakAudioParams, - sample_rate: c_int, -) { - audio_params_set(params as *mut c_void, |p| p.sample_rate = sample_rate); -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_channel_layout(params: *const OakAudioParams) -> u64 { - audio_params_get(params as *const c_void).channel_layout -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_set_channel_layout(params: *mut OakAudioParams, layout: u64) { - audio_params_set(params as *mut c_void, |p| p.channel_layout = layout); -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_set_time_base( - params: *mut OakAudioParams, - num: c_int, - den: c_int, -) { - audio_params_set(params as *mut c_void, |p| { - p.time_base_num = num; - p.time_base_den = den; - }); -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_set_format(params: *mut OakAudioParams, format: c_int) { - audio_params_set(params as *mut c_void, |p| p.format = format); -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_set_stream_index(params: *mut OakAudioParams, index: c_int) { - audio_params_set(params as *mut c_void, |p| p.stream_index = index); -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_set_duration(params: *mut OakAudioParams, duration: i64) { - audio_params_set(params as *mut c_void, |p| p.duration = duration); -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_channel_count(params: *const OakAudioParams) -> c_int { - audio_params_get(params as *const c_void) - .channel_layout - .count_ones() as c_int -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_format(params: *const OakAudioParams) -> c_int { - audio_params_get(params as *const c_void).format -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_stream_index(params: *const OakAudioParams) -> c_int { - audio_params_get(params as *const c_void).stream_index -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_duration(params: *const OakAudioParams) -> i64 { - audio_params_get(params as *const c_void).duration -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_is_valid(params: *const OakAudioParams) -> c_int { - let p = audio_params_get(params as *const c_void); - (p.sample_rate > 0 && p.channel_layout != 0 && p.format >= 0) as c_int -} - -#[no_mangle] -pub extern "C" fn oakcore_audioparams_time_base(params: *const OakAudioParams) -> *mut c_void { - let p = audio_params_get(params as *const c_void); - let r = (p.time_base_num, p.time_base_den); - let raw = Box::into_raw(Box::new(r)); - rational_store().lock().unwrap().insert(raw as usize, r); - raw as *mut c_void -} - -#[no_mangle] -pub extern "C" fn oakcore_rational_numerator(rational: *const c_void) -> c_int { - rational_store() - .lock() - .unwrap() - .get(&(rational as usize)) - .map(|r| r.0) - .unwrap_or(0) -} - -#[no_mangle] -pub extern "C" fn oakcore_rational_denominator(rational: *const c_void) -> c_int { - rational_store() - .lock() - .unwrap() - .get(&(rational as usize)) - .map(|r| r.1) - .unwrap_or(0) -} - -#[no_mangle] -pub extern "C" fn oakcore_rational_free(rational: *mut c_void) { - if rational.is_null() { - return; - } - rational_store() - .lock() - .unwrap() - .remove(&(rational as usize)); - // SAFETY: produced by `oakcore_audioparams_time_base`; we hold the only - // reference after removal. - unsafe { drop(Box::from_raw(rational as *mut (i32, i32))) }; -} - -/// `fb_find_best_pix_fmt_of_list` — pick the entry of a -/// `FB_PIX_FMT_NONE`-terminated list closest to `pix_fmt` (the real -/// implementation lives in ffmpeg_bridge). Stub: exact matches win, -/// otherwise the first (most desirable) candidate. -#[no_mangle] -pub extern "C" fn fb_find_best_pix_fmt_of_list(list: *const c_int, pix_fmt: c_int) -> c_int { - if list.is_null() { - return 0; - } - let mut i = 0; - let mut first: c_int = 0; - loop { - // SAFETY: `list` is `FB_PIX_FMT_NONE`-terminated; the read is within - // bounds by construction. - let entry = unsafe { *list.add(i) }; - if entry == 0 { - return first; - } - if i == 0 { - first = entry; - } - if entry == pix_fmt { - return entry; - } - i += 1; - } -} diff --git a/src/oakui/mod.rs b/src/oakui/mod.rs index e3edb4867..593a4e57e 100644 --- a/src/oakui/mod.rs +++ b/src/oakui/mod.rs @@ -27,26 +27,29 @@ //! [`MockClock`](mock::MockClock), the demo implementation feeding every //! widget's data-source trait. //! * [`real`] — [`RealEngine`](real::RealEngine) and -//! [`RealClock`](real::RealClock), the real engine binding. It calls only -//! the frozen `oakengine_*` C ABI of the built `liboakengine` dylib (see -//! [`ffi`] and the crate's `build.rs`) behind the same +//! [`RealClock`](real::RealClock), the real engine. M14 R3: it calls the +//! oak* module crates' Rust APIs directly (oaknode / oaktimeline / +//! oakundo / oakrender / oaktask / oakcodec / oakaudio / oakcommon / +//! oakstorage — no `liboakengine` dylib, no C ABI) behind the same //! [`EngineGateway`](engine::EngineGateway) seam the mock implements. -//! * [`ffi`] — the pure-C declarations of that ABI (extern imports, handle -//! layout mirrors, the `OakVideoParamsPod`). +//! * [`graphops`] / [`effectchain`] / [`renderops`] — the app's assembly +//! layer over the module crates: project/timeline/storage helpers, the +//! effect-chain composition, and montage/render/export drivers. //! * [`transport`] — the play/pause/step/seek state machine (pure, unit //! tested). //! * [`timecode`] — timecode / duration / fps / resolution formatting (pure, //! unit tested). +pub mod effectchain; pub mod engine; -pub mod ffi; pub mod frames; -mod host_syms; +pub mod graphops; pub mod icons; pub mod mock; pub mod nodegraph; pub mod projectbrowser; pub mod real; +pub mod renderops; pub mod scopes; pub mod timecode; pub mod transport; @@ -62,10 +65,9 @@ pub use real::{RealClock, RealEngine}; /// Whether `name` (a media file name) denotes audio-only media, by /// extension. /// -/// The facade's module footage is never probed (`oakengine` imports media -/// without decoding it), so the stream counts it exposes are always empty -/// and the timeline drop's track matching falls back to the extension: -/// known audio containers count as audio, everything else as video. +/// The module footage is not reliably probed on import, so the timeline +/// drop's track matching falls back to the extension: known audio +/// containers count as audio, everything else as video. pub fn filename_is_audio(name: &str) -> bool { matches!( std::path::Path::new(name) diff --git a/src/oakui/nodegraph.rs b/src/oakui/nodegraph.rs index 540c7edf4..896e72f39 100644 --- a/src/oakui/nodegraph.rs +++ b/src/oakui/nodegraph.rs @@ -17,8 +17,8 @@ //! M12 P2: the real node-graph surface. //! //! Builds the gpui node-graph data (`RealNode` / `RealPort` / `RealEdge`) -//! from the CURRENT SEQUENCE's graph (the facade's sequence node-graph -//! enumeration; see the `oakengine_sequence_*` exports): +//! 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"); @@ -26,7 +26,7 @@ //! - footage nodes (the media) feed the clip's `tex_in` from the left; //! - declared inputs become input ports (id string as the label); every //! node exposes one "out" output port (the module declares no outputs; -//! edges are enumerated from `output_connection_at_ex`); +//! 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 @@ -37,10 +37,11 @@ //! output) when a node has no entry yet — the first drag persists //! through the undoable position setter. //! -//! Identity mapping: `NodeId` = the facade node identity (stable across +//! 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 indices (low bits zero), so the shifts are injective. +//! 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 @@ -48,23 +49,15 @@ //! clip → effects → output the C++ node editor centers on. use std::collections::HashMap; -use std::ffi::{c_char, c_int}; use gpui::node_graph::{ EdgeData, EdgeId, NodeData, NodeId, PortData, PortId, PortDataType, PortKind, }; use gpui::{hsla, point, px, Hsla, Pixels, Point, SharedString}; -use crate::oakui::ffi::{ - oakengine_node_connect, oakengine_node_disconnect_ex, oakengine_node_free, - oakengine_node_get_context_position, oakengine_node_get_label, oakengine_node_get_name, - oakengine_node_get_type_id, oakengine_node_identity, oakengine_node_input_count, - oakengine_node_input_id, oakengine_node_input_is_connected, - oakengine_node_output_connection_at_ex, oakengine_node_output_connection_count, - oakengine_node_set_context_position, oakengine_sequence_as_node, oakengine_sequence_node_at, - oakengine_sequence_node_count, oakengine_sequence_remove_node, OakEngineNode, - OakEngineSequence, -}; +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"; @@ -78,7 +71,7 @@ const TYPE_ID_TRACK_LIST: &str = "org.olivevideoeditor.Olive.tracklist"; const TYPE_ID_GAP_BLOCK: &str = "org.olivevideoeditor.Olive.gapblock"; const TYPE_ID_TRANSITION_BLOCK: &str = "org.olivevideoeditor.Olive.transitionblock"; -/// The "video" wire type used by the real graph (the facade exposes no +/// 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)) @@ -108,36 +101,6 @@ fn unpack_port(id: PortId) -> (u64, PortKind, u32) { } } -/// Two-stage string read over a facade `(buf, size)` getter. -fn read_str(f: impl Fn(*mut c_char, c_int) -> c_int) -> String { - let needed = f(std::ptr::null_mut(), 0); - if needed <= 0 { - return String::new(); - } - let mut buf = vec![0 as c_char; needed as usize + 1]; - f(buf.as_mut_ptr(), needed as c_int + 1); - let len = buf - .iter() - .position(|&c| c == 0) - .unwrap_or(buf.len()); - String::from_utf8_lossy(unsafe { - std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) - }) - .into_owned() -} - -/// Read a NUL-terminated facade string out of a fixed buffer. -fn read_cstr_buf(buf: &[c_char]) -> String { - if buf.first().copied().unwrap_or(0) == 0 { - return String::new(); - } - String::from_utf8_lossy(unsafe { - std::slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len()) - }) - .trim_end_matches('\0') - .to_string() -} - /// A stable REAL edge id from `(from node, to node, input id)` (FNV-1a, /// high bit masked so it can never collide with the synthesized-wire tag /// [`is_output_wire`] reserves). @@ -169,7 +132,7 @@ pub fn is_output_wire(id: EdgeId) -> bool { /// A node card in the real graph. #[derive(Debug, Clone)] pub struct RealNode { - /// Facade node identity. + /// The module node identity. pub id: NodeId, /// Card title. pub title: SharedString, @@ -303,20 +266,6 @@ fn node_color(ident: u64) -> Hsla { hsla(hues[(ident as usize) % hues.len()], 0.5, 0.35, 1.0) } -/// The type id of a boxed node (empty on failure). -/// -/// # Safety -/// `node` must be a live facade node box. -unsafe fn node_type_id(node: *mut OakEngineNode) -> String { - let mut buf = [0 as c_char; 256]; - let len = unsafe { oakengine_node_get_type_id(node, buf.as_mut_ptr(), buf.len() as c_int) }; - if len <= 0 { - String::new() - } else { - read_cstr_buf(&buf) - } -} - /// Whether a sequence-graph node should be shown in the node editor: the /// media chain (sequence output, clip blocks, footage, effects) only; /// structural timeline plumbing (tracks, track lists, gaps, transitions) @@ -328,249 +277,221 @@ fn is_displayed(type_id: &str) -> bool { ) } -/// A boxed node plus its type id (freed with `oakengine_node_free`). +/// A displayed node: its domain id plus its type id. struct TypedNode { - /// The boxed node. - ptr: *mut OakEngineNode, + /// 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 `seq`'s graph, in graph order. -/// -/// # Safety -/// `seq` must be a live facade sequence box. -unsafe fn graph_nodes(seq: *mut OakEngineSequence) -> Vec { - let mut out = Vec::new(); - let count = unsafe { oakengine_sequence_node_count(seq) }; - for i in 0..count.max(0) { - let node = unsafe { oakengine_sequence_node_at(seq, i) }; - if node.is_null() { - continue; - } - let type_id = unsafe { node_type_id(node) }; - let ident = unsafe { oakengine_node_identity(node) }; - if ident == 0 || !is_displayed(&type_id) { - unsafe { oakengine_node_free(node) }; - continue; - } - out.push(TypedNode { - ptr: node, - ident, - type_id, - }); - } +/// 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 } -/// Build the (nodes, edges) snapshot of `seq`'s node graph. -/// -/// # Safety -/// `seq` must be a live facade sequence box. -pub unsafe fn build_graph(seq: *mut OakEngineSequence) -> (Vec, Vec) { - unsafe { - let mut nodes = Vec::new(); - let mut edges = Vec::new(); - if seq.is_null() { - return (nodes, edges); - } - let seq_node = oakengine_sequence_as_node(seq); - if seq_node.is_null() { - return (nodes, edges); - } - let seq_ident = oakengine_node_identity(seq_node); - let seq_label = read_str(|buf, size| oakengine_node_get_label(seq_node, buf, size)); - let seq_name = read_str(|buf, size| oakengine_node_get_name(seq_node, buf, size)); +/// 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)), + } +} - // The sequence node itself may or may not be enumerated in its own - // project; ensure exactly one output card with the sequence identity - // (the enumerated copy, if any, is freed here). - let mut all = Vec::new(); - for typed in graph_nodes(seq) { - if typed.ident == seq_ident { - oakengine_node_free(typed.ptr); +/// 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 { - all.push(typed); + seq_label.clone() } - } - all.push(TypedNode { - ptr: seq_node, - ident: seq_ident, - type_id: TYPE_ID_SEQUENCE.into(), - }); - all.sort_by_key(|n| n.ident); - - // Build every card's ports first (inputs, the implicit output), so - // real edges can resolve their target port index by matching the - // input id against the already-built cards. - let mut built: Vec<(TypedNode, RealNode)> = Vec::new(); - for typed in all { - let ident = typed.ident; - let title = if typed.type_id == TYPE_ID_SEQUENCE { - if seq_label.is_empty() { - seq_name.clone() - } else { - seq_label.clone() - } + } else { + let label = 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 { - let label = read_str(|buf, size| oakengine_node_get_label(typed.ptr, buf, size)); - let name = read_str(|buf, size| oakengine_node_get_name(typed.ptr, buf, size)); - if label.is_empty() { - name - } else { - label - } - }; - - // Inputs. - let input_count = oakengine_node_input_count(typed.ptr); - let mut inputs = Vec::with_capacity(input_count.max(0) as usize); - for idx in 0..input_count { - let id_str = - read_str(|buf, size| oakengine_node_input_id(typed.ptr, idx, buf, size)); - if id_str.is_empty() { - continue; - } - let cid = std::ffi::CString::new(id_str.clone()).unwrap_or_default(); - let connected = - oakengine_node_input_is_connected(typed.ptr, cid.as_ptr()) == 1; - inputs.push(RealPort { - id: port_id(ident, PortKind::Input, idx as u32), - kind: PortKind::Input, - label: id_str.into(), - data_type: video_type(), - connected, - }); + label } + }; - // The single implicit output. - let out_count = oakengine_node_output_connection_count(typed.ptr); - 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(seq_node, typed.ptr); - built.push(( - typed, - RealNode { - id: NodeId(ident), - title: title.into(), - position, - inputs, - outputs, - header_color: Some(node_color(ident)), - collapsed: false, - enabled: true, - }, - )); - } - - // Outgoing REAL edges: every source node's output connections, with - // the target port resolved to the index of the input whose id matches - // (the module stores edges by input id; the index may differ from 0 — - // e.g. a clip's `tex_in` sits after `enabled_in`). - let mut node_edges: Vec<(u64, Vec)> = Vec::new(); - for (typed, _) in &built { - let out_count = oakengine_node_output_connection_count(typed.ptr); - let mut edges_of = Vec::new(); - for j in 0..out_count { - let mut input_node: *mut OakEngineNode = std::ptr::null_mut(); - let mut id_buf = [0 as c_char; 256]; - let mut element: c_int = -1; - let mut hidden: c_int = 0; - let rc = oakengine_node_output_connection_at_ex( - typed.ptr, - j, - &mut input_node, - id_buf.as_mut_ptr(), - id_buf.len() as c_int, - &mut element, - &mut hidden, - ); - if rc != 0 { - continue; - } - let to_ident = if input_node.is_null() { - 0 - } else { - oakengine_node_identity(input_node) - }; - if !input_node.is_null() { - oakengine_node_free(input_node); - } - let conn_id = read_cstr_buf(&id_buf); - if to_ident == 0 || conn_id.is_empty() { - continue; - } - 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)) { + // 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; } - 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)); + 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), + }); } - // Assemble: every built card + its real edges, plus the synthesized - // "clip → output" wires (each clip's main output into the sequence's - // `tex_in`, its first declared input). Every enumerated box is freed - // here (including the sequence node's own view). - let clip_input = port_id(seq_ident, PortKind::Input, 0); - for (typed, node) in built { - if typed.type_id == TYPE_ID_CLIP_BLOCK { - edges.push(RealEdge { - id: output_wire_id(typed.ident), - from_node: node.id, - from_port: port_id(typed.ident, PortKind::Output, 0), - to_node: NodeId(seq_ident), - to_port: clip_input, - }); - } - if let Some((_, real)) = node_edges.iter().find(|(id, _)| *id == typed.ident) { - edges.extend(real.iter().cloned()); - } - nodes.push(node); - oakengine_node_free(typed.ptr); - } - (nodes, edges) + // 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 | @@ -592,90 +513,54 @@ fn role_of(type_id: &str, is_output: bool) -> u32 { /// sorted display order (mirrors the fallback grid in `build_graph`). /// `apply_edit` uses it so the first drag release writes /// `(displayed base + delta)` instead of `(origin + delta)`. -/// -/// # Safety -/// `seq`/`seq_node` must be live facade boxes. -unsafe fn fallback_base( - seq: *mut OakEngineSequence, - seq_node: *mut OakEngineNode, - ident: u64, -) -> (f64, f64) { - unsafe { - let seq_ident = oakengine_node_identity(seq_node); - let mut all = Vec::new(); - for typed in graph_nodes(seq) { - if typed.ident == seq_ident { - oakengine_node_free(typed.ptr); - } else { - all.push(typed); - } +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; } - all.push(TypedNode { - ptr: seq_node, - ident: seq_ident, - type_id: TYPE_ID_SEQUENCE.into(), - }); - all.sort_by_key(|n| n.ident); - let target = all - .iter() - .find(|n| n.ident == ident) - .expect("the moved node is part of the displayed graph"); - let target_role = role_of(&target.type_id, ident == seq_ident); - let mut row: u32 = 0; - for n in &all { - if n.ident == ident { - break; - } - if role_of(&n.type_id, n.ident == seq_ident) != target_role { - continue; - } - // Placed nodes do not consume a row. - let mut x: f64 = 0.0; - let mut y: f64 = 0.0; - let mut expanded: c_int = 0; - let placed = oakengine_node_get_context_position( - seq_node, - n.ptr, - &mut x, - &mut y, - &mut expanded, - ) == 0 - && (x != 0.0 || y != 0.0); - if !placed { - row += 1; - } + if role_of(&n.type_id, n.ident == seq_ident) != target_role { + continue; } - // Free the enumerated boxes; `seq_node` belongs to the caller. - for n in &all { - if n.ptr != seq_node { - oakengine_node_free(n.ptr); - } + // 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), - ) } + ( + 40.0 + f64::from(target_role * 260), + 40.0 + f64::from(row * 180), + ) } -/// The context position of `node` in `seq_node`'s map, or the (0,0) -/// sentinel the fallback layout replaces. -/// -/// # Safety -/// Both pointers must be live facade node boxes. -unsafe fn context_position(seq_node: *mut OakEngineNode, node: *mut OakEngineNode) -> Point { - let mut x: f64 = 0.0; - let mut y: f64 = 0.0; - let mut expanded: c_int = 0; - if unsafe { - oakengine_node_get_context_position(seq_node, node, &mut x, &mut y, &mut expanded) - } == 0 - && (x != 0.0 || y != 0.0) - { - point(px(x as f32), px(y as f32)) - } else { - point(px(0.0), px(0.0)) +/// 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) } // --------------------------------------------------------------------------- @@ -683,269 +568,137 @@ unsafe fn context_position(seq_node: *mut OakEngineNode, node: *mut OakEngineNod // --------------------------------------------------------------------------- /// Whether connecting output port `from` to input port `to` is valid in -/// `seq`'s graph: output → input, distinct nodes, and the target input -/// must exist and be free. The sequence node's inputs are connectable -/// like any other (its `tex_in` starts unconnected; a user wire replaces -/// the synthesized one once the graph grows real edges). -/// -/// # Safety -/// `seq` must be a live facade sequence box. -pub unsafe fn can_connect(seq: *mut OakEngineSequence, from: PortId, to: PortId) -> bool { - unsafe { - let (from_node, from_kind, _) = unpack_port(from); - let (to_node, to_kind, to_index) = unpack_port(to); - if from_kind != PortKind::Output || to_kind != PortKind::Input { - return false; - } - if from_node == to_node { - return false; - } - let Ok(node) = find_boxed(seq, to_node) else { - return false; - }; - let count = oakengine_node_input_count(node); - if to_index >= count.max(0) as u32 { - oakengine_node_free(node); - return false; - } - let id_str = - read_str(|buf, size| oakengine_node_input_id(node, to_index as c_int, buf, size)); - if id_str.is_empty() { - oakengine_node_free(node); - return false; - } - let cid = std::ffi::CString::new(id_str).unwrap_or_default(); - let free = oakengine_node_input_is_connected(node, cid.as_ptr()) == 0; - oakengine_node_free(node); - free +/// 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 `seq`'s graph (undoable through the facade). -/// -/// # Safety -/// `seq` must be a live facade sequence box. -pub unsafe fn apply_edit( - seq: *mut OakEngineSequence, +/// 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; - unsafe { - let seq_node = oakengine_sequence_as_node(seq); - match edit { - NodeGraphEvent::ConnectionRequested { from, to } => { - let (from_node, from_kind, _) = unpack_port(*from); - let (to_node, to_kind, to_index) = unpack_port(*to); - if from_kind != PortKind::Output || to_kind != PortKind::Input { - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } - return Err("connection endpoints must be output → input".into()); - } - let src = find_boxed(seq, from_node)?; - let dst = find_boxed(seq, to_node)?; - let id_str = { - let count = oakengine_node_input_count(dst); - if to_index >= count.max(0) as u32 { - oakengine_node_free(src); - oakengine_node_free(dst); - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } - return Err("target input out of range".into()); - } - read_str(|buf, size| { - oakengine_node_input_id(dst, to_index as c_int, buf, size) - }) - }; - if id_str.is_empty() { - oakengine_node_free(src); - oakengine_node_free(dst); - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } + 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()); - } - let cid = std::ffi::CString::new(id_str).unwrap_or_default(); - let rc = oakengine_node_connect(src, dst, cid.as_ptr()); - oakengine_node_free(src); - oakengine_node_free(dst); - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } - if rc != 0 { - return Err(format!("connect failed rc={rc}")); - } - Ok(()) - } - NodeGraphEvent::DisconnectionRequested { edge } => { - if is_output_wire(*edge) { - // The synthesized clip → output wire is structural; the - // facade has no such edge to remove. - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } - return Ok(()); - } - let e = self::edge_for(seq, edge.0)?; - let cid = std::ffi::CString::new(e.1).unwrap_or_default(); - let rc = oakengine_node_disconnect_ex(e.0, cid.as_ptr(), -1); - oakengine_node_free(e.0); - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } - if rc != 0 { - return Err(format!("disconnect failed rc={rc}")); - } - Ok(()) - } - NodeGraphEvent::NodeMoveRequested { nodes, delta } => { - for node in nodes { - let boxed = find_boxed(seq, node.0)?; - let mut x: f64 = 0.0; - let mut y: f64 = 0.0; - let mut expanded: c_int = 0; - let placed = oakengine_node_get_context_position( - seq_node, - boxed, - &mut x, - &mut y, - &mut expanded, - ) == 0 - && (x != 0.0 || y != 0.0); - if !placed { - // The node was drawn at its provisional grid spot; - // move from there so the release does not jump it - // back toward the origin. - let (fx, fy) = fallback_base(seq, seq_node, node.0); - x = fx; - y = fy; - } - let rc = oakengine_node_set_context_position( - seq_node, - boxed, - x + f64::from(delta.x.as_f32()), - y + f64::from(delta.y.as_f32()), - ); - oakengine_node_free(boxed); - if rc != 0 { - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } - return Err(format!("move failed rc={rc}")); - } - } - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } - Ok(()) - } - NodeGraphEvent::DeleteRequested { nodes, .. } => { - for node in nodes { - // The output node is the graph's context; never deleted. - if node.0 == oakengine_node_identity(seq_node) { - continue; - } - let boxed = find_boxed(seq, node.0)?; - let rc = oakengine_sequence_remove_node(seq, boxed); - oakengine_node_free(boxed); - if rc != 0 { - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } - return Err(format!("remove failed rc={rc}")); - } - } - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } - Ok(()) - } - _ => { - if !seq_node.is_null() { - oakengine_node_free(seq_node); - } - Ok(()) - } + }; + (src, dst, id_str) + }; + let cmd = graphops::connect_command(project, src, dst, &id_str)?; + graphops::push_command(cmd, "Connect Nodes") } - } -} - -/// Boxed sequence-graph node for an identity (freed with -/// `oakengine_node_free`). -/// -/// # Safety -/// `seq` must be a live facade sequence box. -unsafe fn find_boxed(seq: *mut OakEngineSequence, ident: u64) -> Result<*mut OakEngineNode, String> { - unsafe { - let count = oakengine_sequence_node_count(seq); - for i in 0..count.max(0) { - let node = oakengine_sequence_node_at(seq, i); - if node.is_null() { - continue; + 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(()); } - if oakengine_node_identity(node) == ident { - return Ok(node); - } - oakengine_node_free(node); + 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") } - Err(format!("node {ident} not found")) - } -} - -/// Resolve a real edge id back to `(input node, input id)`. -/// -/// # Safety -/// `seq` must be a live facade sequence box. -unsafe fn edge_for( - seq: *mut OakEngineSequence, - edge: u64, -) -> Result<(*mut OakEngineNode, String), String> { - unsafe { - let count = oakengine_sequence_node_count(seq); - for i in 0..count.max(0) { - let node = oakengine_sequence_node_at(seq, i); - if node.is_null() { - continue; + 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")?; } - let out_count = oakengine_node_output_connection_count(node); - for j in 0..out_count { - let mut input_node: *mut OakEngineNode = std::ptr::null_mut(); - let mut id_buf = [0 as c_char; 256]; - let mut element: c_int = -1; - let mut hidden: c_int = 0; - if oakengine_node_output_connection_at_ex( - node, - j, - &mut input_node, - id_buf.as_mut_ptr(), - id_buf.len() as c_int, - &mut element, - &mut hidden, - ) != 0 - { + 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 from = oakengine_node_identity(node); - let to = if input_node.is_null() { - 0 - } else { - oakengine_node_identity(input_node) + let id = { + let guard = graphops::lock(project); + find_node(&guard.graph, node.0)? }; - let conn_id = read_cstr_buf(&id_buf); - if !input_node.is_null() { - oakengine_node_free(input_node); - } - if real_edge_id(from, to, &conn_id).0 == edge { - // The input node was freed; re-box it. - let dst = find_boxed(seq, to)?; - return Ok((dst, conn_id)); - } + graphops::remove_node(project, id)?; } - oakengine_node_free(node); + Ok(()) } - Err(format!("edge {edge} not found")) + _ => Ok(()), } } - -fn node_position_mut(mut _p: Point, _d: u32, _row: f32) {} diff --git a/src/oakui/projectbrowser.rs b/src/oakui/projectbrowser.rs index 8ff63df47..c2f3da1a4 100644 --- a/src/oakui/projectbrowser.rs +++ b/src/oakui/projectbrowser.rs @@ -16,173 +16,79 @@ //! M12 P3: the real project browser. //! -//! Builds the widget's [`ProjectEntry`] tree from the facade's folder -//! hierarchy: the project root folder's children are the top-level -//! entries, folders expand through `oakengine_folder_item_child_*`. -//! Entry ids are the nodes' stable identities (the facade identity), -//! so selection round-trips through `find_by_identity`. +//! Builds the widget's [`ProjectEntry`] tree from the project graph's +//! folder hierarchy (M14 R3: the direct oaknode walk — no facade): the +//! project root folder's children are the top-level entries, folders +//! expand through their `FolderBehavior` child lists. Entry ids are the +//! nodes' stable identities, so selection round-trips through +//! [`find_by_identity`]. -use std::ffi::{c_char, c_int}; - -use gpui::SharedString; use gpui_widgets::project_explorer::ProjectEntry; -use crate::oakui::ffi::{ - oakengine_folder_item_child, oakengine_folder_item_child_count, oakengine_node_free, - oakengine_node_get_label, oakengine_node_get_type_id, oakengine_node_identity, - oakengine_project_node_at, oakengine_project_node_count, oakengine_project_root, - OakEngineNode, OakEngineProject, -}; +use oaknode::folder::FolderBehavior; +use oaknode::id::NodeId; -/// The folder behavior's factory type id. -const TYPE_ID_FOLDER: &str = "org.olivevideoeditor.Olive.folder"; +use crate::oakui::graphops::{self, ProjectRef}; -/// Two-stage string read over a facade `(buf, size)` getter. -fn read_str(f: impl Fn(*mut c_char, c_int) -> c_int) -> String { - let needed = f(std::ptr::null_mut(), 0); - if needed <= 0 { - return String::new(); +/// One child entry of a folder with its identity. +fn child_entry(g: &oaknode::graph::Graph, child: NodeId) -> Option { + let entry = g.get(child)?; + let mut name = entry.core.label.clone(); + if name.is_empty() { + name = entry.behavior.name().to_string(); } - let mut buf = vec![0 as c_char; needed as usize + 1]; - f(buf.as_mut_ptr(), needed as c_int + 1); - let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); - String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) - .into_owned() + Some(ProjectEntry::new( + child.identity(), + name, + graphops::is_folder(g, child), + )) } -/// Whether `node` (a live box) is a folder node. -/// -/// # Safety -/// `node` must be a live box. -unsafe fn node_is_folder(node: *mut OakEngineNode) -> bool { - unsafe { - let id = read_str(|buf, size| oakengine_node_get_type_id(node, buf, size)); - id == TYPE_ID_FOLDER - } -} - -/// One child entry of `folder` (a live box) with its identity. -/// -/// # Safety -/// `child` must be a live box; freed by the caller. -unsafe fn child_entry(child: *mut OakEngineNode) -> ProjectEntry { - unsafe { - let ident = oakengine_node_identity(child); - let label = read_str(|buf, size| oakengine_node_get_label(child, buf, size)); - let is_dir = node_is_folder(child); - let mut name = label; - if name.is_empty() { - name = read_str(|buf, size| crate::oakui::ffi::oakengine_node_get_name(child, buf, size)); - } - ProjectEntry::new(ident, name, is_dir) - } +/// The children of a folder node. +fn folder_children(g: &oaknode::graph::Graph, folder: NodeId) -> Vec { + let Some(f) = g + .get(folder) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + else { + return Vec::new(); + }; + f.children + .iter() + .filter_map(|&child| child_entry(g, child)) + .collect() } /// The top-level entries: the project root folder's children. -/// -/// # Safety -/// `project` must be a live box. -pub unsafe fn roots(project: *mut OakEngineProject) -> Vec { - unsafe { - let mut out = Vec::new(); - if project.is_null() { - return out; - } - let root = oakengine_project_root(project); - if root.is_null() { - return out; - } - out = folder_children(root); - oakengine_node_free(root); - out +pub fn roots(project: &ProjectRef) -> Vec { + let guard = graphops::lock(project); + if !guard.root.valid() { + return Vec::new(); } + folder_children(&guard.graph, guard.root) } /// The children of the folder whose identity is `parent_id` (empty when -/// not found). -/// -/// # Safety -/// `project` must be a live box. -pub unsafe fn children(project: *mut OakEngineProject, parent_id: u64) -> Vec { - unsafe { - let Some(node) = find_by_identity(project, parent_id) else { - return Vec::new(); - }; - if !node_is_folder(node) { - oakengine_node_free(node); - return Vec::new(); - } - let out = folder_children(node); - oakengine_node_free(node); - out +/// not found or not a folder). +pub fn children(project: &ProjectRef, parent_id: u64) -> Vec { + let Some(folder) = find_by_identity(project, parent_id) else { + return Vec::new(); + }; + let guard = graphops::lock(project); + if !graphops::is_folder(&guard.graph, folder) { + return Vec::new(); } + folder_children(&guard.graph, folder) } -/// The children of a live folder box. -/// -/// # Safety -/// `folder` must be a live folder box. -unsafe fn folder_children(folder: *mut OakEngineNode) -> Vec { - unsafe { - let mut out = Vec::new(); - let count = oakengine_folder_item_child_count(folder); - for i in 0..count.max(0) { - let child = oakengine_folder_item_child(folder, i); - if child.is_null() { - continue; - } - out.push(child_entry(child)); - oakengine_node_free(child); - } - out - } -} - -/// The boxed project node with `identity` (freed with -/// [`oakengine_node_free`]), or `None`. -/// -/// # Safety -/// `project` must be a live box. -pub unsafe fn find_by_identity( - project: *mut OakEngineProject, - identity: u64, -) -> Option<*mut OakEngineNode> { - unsafe { - if project.is_null() || identity == 0 { - return None; - } - let count = oakengine_project_node_count(project); - for i in 0..count.max(0) { - let node = oakengine_project_node_at(project, i); - if node.is_null() { - continue; - } - if oakengine_node_identity(node) == identity { - return Some(node); - } - oakengine_node_free(node); - } +/// The project node with `identity` (validated against the graph), or +/// `None`. +pub fn find_by_identity(project: &ProjectRef, identity: u64) -> Option { + let id = graphops::id_of(identity)?; + let guard = graphops::lock(project); + if guard.graph.is_valid(id) { + Some(id) + } else { None } } - -/// The footage node at `index` (the project's footage list), or `None`. -/// -/// # Safety -/// `project` must be a live box. -pub unsafe fn footage_node_at( - project: *mut OakEngineProject, - index: c_int, -) -> Option<*mut OakEngineNode> { - unsafe { - if project.is_null() { - return None; - } - let node = crate::oakui::ffi::oakengine_project_footage_at(project, index); - if node.is_null() { - None - } else { - Some(node) - } - } -} diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 891a0a3e5..1f72398b7 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -14,81 +14,52 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! The real engine: [`RealEngine`] binds the built `liboakengine` dylib -//! (the frozen `oakengine_*` C ABI over the module crates, see [`ffi`]) -//! behind the same [`EngineGateway`](super::engine::EngineGateway) / -//! [`AppEngine`](super::engine::AppEngine) seam the mock implements. The -//! dylib is linked at build time (see the crate's `build.rs`); the app -//! never depends on the `oakengine` crate as an rlib, so every call below -//! is a pure `extern "C"` import declared in [`ffi`]. +//! The real engine: [`RealEngine`] drives the oak* module crates directly +//! (M14 R3 — no `liboakengine` dylib, no C ABI) behind the same +//! [`EngineGateway`](super::engine::EngineGateway) / +//! [`AppEngine`](super::engine::AppEngine) seam the mock implements. +//! +//! The engine owns the domain project (`Arc>`) +//! and the current sequence's `NodeId`; every read is a direct graph walk +//! and every edit is an oaktimeline/oakundo command pushed onto the +//! process-wide undo stack ([`oakundo::global`]). The assembly-layer +//! helpers live in [`super::graphops`] (project/timeline/storage), +//! [`super::effectchain`] (effect chains) and [`super::renderops`] +//! (montage resolution, ticket rendering, the export driver); the +//! node-graph and project-browser snapshots are [`super::nodegraph`] and +//! [`super::projectbrowser`]. //! //! # What is real here //! -//! * **Project** — open/save/save-as/close through the facade (`.ove` -//! serializer; `.otio` / `.fcpxml` through the oaktask interchange -//! loader). +//! * **Project** — open/save-as/close through the oaknode serializer +//! (`.ove`; `.otio` / `.fcpxml` through the oaktask interchange tasks). //! * **Sequence** — the current sequence's name / format / length / tracks / -//! clips are read live from the facade sequence handle. +//! clips are read live from the graph. //! * **Edits** — timeline edits (trim, split, delete, ripple-delete) and -//! track add/remove go through the facade's edit commands, each packaged -//! as an undoable entry on the facade's global undo stack. Undo/redo walk -//! that stack. +//! track add/remove go through the modules' edit commands, each packaged +//! as one undoable entry on the global undo stack. Undo/redo walk that +//! stack. //! * **Export** — the oaktask export task, driven on a background thread, //! with progress events and cancel wired to the module task's event -//! callback and cancel atom. +//! listener and cancel atom. //! * **Config** — the preferences (renderer backend, language, theme, //! cache dir, proxy policy, snapshot interval, default transition, -//! audio devices) round-trip through `oakengine_config_*`; the audio -//! device selection additionally applies live through -//! `oakengine_audio_*_device`. -//! -//! # What is still mock/stub -//! -//! * The source monitor renders the selected footage node's frame through -//! the facade CPU renderer -//! ([`RealEngine::render_source_frame`], -//! via the node-binding `oakengine_renderer_create_for_node`) at a proxy -//! resolution — the same pattern as the program monitor -//! ([`RealEngine::render_program_frame`]). Actual media *decode* is -//! still a module gap (the oakrender eval's footage hook is deferred), -//! so both viewers show the pipeline's generated frame, not the file's -//! pixels. -//! * **Full-resolution rendering is in-process (M12 P5a):** the proxy -//! frame (a 480px long edge) is rendered synchronously for immediate -//! display; when the playhead rests, a background thread renders the -//! same frame at the sequence's native size through its own dedicated -//! facade renderer and the cache swaps it in when it lands (see -//! [`RealEngine::schedule_full_res`]). The facade's worker *process* -//! module (`oakengine_worker_*`, NDJSON control plane) remains unbound: -//! its `load_graph`/`render_frame` are documented stubs, so there is no -//! render-capable process transport to bind. -//! * Effect stack — the selected clip's effect chain is bound: the stack -//! reads the chain through the facade (see -//! [`EffectStackDataSource`](EffectStackDataSource) for `RealEngine`) -//! and edits go through the facade's undoable effect commands. -//! * Node graph — the node editor reads the current sequence's graph -//! through the facade's sequence node-graph enumeration (see -//! [`NodeGraphDataSource`](NodeGraphDataSource) for `RealEngine`): -//! clip → effects → output with real edges plus the synthesized -//! clip-to-output wires; connect/disconnect/move/delete are undoable -//! facade commands (drag previews never persist). -//! * Audio meter still feeds silent data (the meter's facade surface is -//! not bound in this increment). -//! * Clip moves go through the facade's move exports: same-track moves use -//! `oakengine_sequence_move_clip`, cross-track moves -//! `oakengine_sequence_move_clip_to_track` (M12 P4) — each one undoable -//! entry, with the source spot becoming a gap. +//! audio devices) round-trip through the oakcommon config store; the +//! audio device selection additionally applies live through oakaudio's +//! manager. +//! * **Storage** — the write-through library binds every opened project +//! through [`oakstorage::writethrough`]; the manager window's library +//! operations call the oakstorage database backend directly. //! //! # Threading note //! -//! Long facade calls (`oakengine_task_start_sync`) run on background threads -//! so the UI never blocks; the export event callback delivers progress -//! through a channel the app drains on its tick loop. Cancellation through -//! `oakengine_task_cancel` mirrors the C++ capi contract (cancel atom set -//! from the UI thread while the task runs on its own thread). +//! Long renders (full-resolution fills, exports) run on background +//! threads; the export task's event listener delivers progress through a +//! channel the app drains on its tick loop. The background full-res +//! worker holds the project's `Arc`, so a project drop mid-render is a +//! non-event (the drained frame is discarded by the generation check). use std::collections::{BTreeSet, HashMap}; -use std::ffi::{c_char, c_int, c_void, CString}; use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; @@ -97,179 +68,56 @@ use std::time::Instant; use gpui::effect_stack::{ EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent, }; -use gpui::node_graph::{ - EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortDataType, PortId, - PortKind, -}; +use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent}; use gpui::timeline::{ ClipData, ClipId, Frame, FrameRange, FrameRate, Marker, TimelineDataSource, TimelineEvent, TrackData, TrackKind, TrimEdge, }; -use gpui::{ - hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString, -}; +use gpui::{prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString}; use gpui_widgets::audio_meter::AudioMeterDataSource; use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry}; use gpui_widgets::viewer::PlaybackClock; +use oaknode::id::NodeId; +use oaknode::track::TrackType; +use oaktimeline::handle::CHandle; + use super::engine::{ - AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, Project, - ScopeData, Sequence, VideoFormat, + AppEngine, EngineGateway, ExportSession, LibraryProject, Monitor, Project, ScopeData, Sequence, + VideoFormat, }; -use super::ffi::*; use super::frames::{f32_rgba_to_bgra_image, synthetic_frame_samples}; +use super::graphops::{self, ProjectRef}; use super::scopes::analyze_f32_rgba; use super::transport::TransportState; -/// `oakengine_timeline.h` track-type constants. -const TRACK_TYPE_VIDEO: c_int = 0; -const TRACK_TYPE_AUDIO: c_int = 1; -const TRACK_TYPE_SUBTITLE: c_int = 2; - -/// The sample-rate / layout / format defaults for export audio. -const EXPORT_SAMPLE_RATE: c_int = 48000; -/// Stereo channel-layout bitmask (`OLIVE_CHANNEL_LAYOUT_STEREO`). -const EXPORT_CHANNEL_LAYOUT: u64 = 0x3; -/// `oakcore_rs::SampleFormat::S16` as int (the encoder default). -const EXPORT_SAMPLE_FORMAT: c_int = 0; - /// The project name of a blank project before it is saved. const UNTITLED: &str = "Untitled Project"; -/// `PixelFormat::F32` (the render pipeline's internal format): F32 RGBA, -/// 16 bytes per pixel. The viewer renderer is created with this so the -/// frame accessors hand back float samples the app downconverts itself. -const PIXEL_FORMAT_F32: c_int = 4; - // --------------------------------------------------------------------------- -// Facade task event subscription -// --------------------------------------------------------------------------- -// -// The export path subscribes to task events through the facade's -// `oakengine_task_subscribe` (wrapping the module's `oaktask_task_subscribe`); -// the callback fires on the task's own thread. - -/// The C callback the facade task subscription invokes on the task's own -/// thread. `userdata` is the raw pointer of a leaked -/// `mpsc::Sender` the export thread reclaims after the run. -unsafe extern "C" fn export_event_cb(event_id: c_int, value: f64, userdata: *mut c_void) { - let Some(sender) = (userdata as *const mpsc::Sender).as_ref() else { - return; - }; - let event = match event_id { - 0 => ExportEvent::Started, - 1 => ExportEvent::Progress(value), - _ => return, // Finished is reported by the export thread (with the error). - }; - let _ = sender.send(event); -} - -/// Reclaims the leaked `mpsc::Sender` the export callback wrote through. -/// Takes the whole [`SendPtr`] so closures capture the wrapper (which is -/// `Send`) rather than the raw field. -fn reclaim_userdata(userdata: SendPtr>) { - drop(unsafe { Box::from_raw(userdata.0) }); -} - -/// A borrowed facade handle wrapper that is `Send`/`Sync`: the pointee is -/// only ever accessed through the facade C ABI (whose exports guard with -/// `catch_unwind` and synchronize their own state). -#[derive(Clone, Copy)] -struct SendPtr(*mut T); - -// SAFETY: see [`SendPtr`]. -unsafe impl Send for SendPtr {} -unsafe impl Sync for SendPtr {} - -// --------------------------------------------------------------------------- -// Handle RAII +// Handle wrappers // --------------------------------------------------------------------------- -/// An owned facade project handle; freed with `oakengine_project_free`. -/// -/// Raw facade pointers are not `Send`/`Sync`, so the wrapper carries -/// explicit unsafe impls; the handle is only ever dereferenced through the -/// facade functions (which guard with `catch_unwind`). -struct ProjectHandle(*mut OakEngineProject); +/// An owned module handle (the write-through query handle, the +/// per-sequence marker list and workarea) that is `Send`/`Sync`. +struct AuxHandle(CHandle); -// SAFETY: the pointer is only used through the facade C ABI; the facade -// guards every export with catch_unwind, and all calls are serialized on the -// owning entity's context. -unsafe impl Send for ProjectHandle {} -unsafe impl Sync for ProjectHandle {} +// SAFETY: the pointee is a refcounted module box only ever touched from +// the UI thread through its own accessor functions; the `Send`/`Sync` +// impls exist so the gpui entity can carry it. +unsafe impl Send for AuxHandle {} +unsafe impl Sync for AuxHandle {} -impl ProjectHandle { - fn ptr(&self) -> *mut OakEngineProject { - self.0 - } -} - -impl Drop for ProjectHandle { - fn drop(&mut self) { - unsafe { - oakengine_project_free(self.0); - } - } -} - -/// A borrowed facade sequence handle (boxed by the facade); freed with -/// [`free_box`] — and always before the project it was borrowed from. -struct SequenceHandle(*mut OakEngineSequence); - -// SAFETY: see [`ProjectHandle`]. -unsafe impl Send for SequenceHandle {} -unsafe impl Sync for SequenceHandle {} - -impl SequenceHandle { - fn ptr(&self) -> *mut OakEngineSequence { - self.0 - } -} - -impl Drop for SequenceHandle { - fn drop(&mut self) { - unsafe { - free_box(self.0); - } - } -} - -/// An owned facade renderer handle; freed with `oakengine_renderer_free`. -/// -/// The facade renderer box is NOT a module-handle box (it is the facade's -/// own `RendererBox`), so it must never go through [`free_box`]; the -/// dedicated free is the only valid deallocator. The renderer borrows the -/// sequence handle it was created from, so it must be dropped before the -/// sequence (see [`RealEngine::drop_project`]). -struct RendererHandle(*mut OakEngineRenderer); - -// SAFETY: see [`ProjectHandle`]. -unsafe impl Send for RendererHandle {} -unsafe impl Sync for RendererHandle {} - -impl RendererHandle { - fn ptr(&self) -> *mut OakEngineRenderer { - self.0 - } -} - -impl Drop for RendererHandle { - fn drop(&mut self) { - unsafe { - oakengine_renderer_free(self.0); - } - } -} - -/// The lifecycle state of the program monitor's lazily created renderer. +/// The lifecycle state of a monitor's render path (the renders are +/// stateless ticket submissions; the slot only remembers whether the path +/// ever failed so a broken setup doesn't retry — and re-log — on every +/// frame). enum RendererSlot { - /// No renderer yet; the next `cpu_frame` tries to create one. + /// No render attempted yet; the next `cpu_frame` tries one. Untried, - /// The live per-sequence renderer. - Ready(RendererHandle), - /// Creation failed (or no sequence is open); don't retry until the - /// project changes, so a broken setup doesn't retry — and log — on - /// every frame. + /// The render path works for this monitor. + Ready, + /// The last render failed; don't retry until the project changes. Unavailable, } @@ -377,19 +225,14 @@ impl MonitorFrameCache { } /// What a background full-res job renders: the program monitor's sequence -/// or the source monitor's selected footage node. The boxed handle is -/// owned by the job and freed by the worker thread. -#[derive(Clone, Copy)] +/// or the source monitor's selected footage node. The request also carries +/// the project's `Arc`, which keeps the graph alive regardless of what the +/// UI thread does with the engine's own reference. enum FullResTarget { - /// An addref'd sequence box (released with [`free_box`], last, after - /// the renderer so the sequence outlives the renderer's borrowed view). - Sequence(SendPtr), - /// A boxed footage node plus an addref'd copy of its project. The node - /// box alone does NOT keep the graph alive: dropping the project while - /// a job is in flight leaves the node dangling (observed crash: - /// misaligned pointer dereference in `oakengine_node_free`). The - /// project copy is released after the node. - Node(SendPtr, SendPtr), + /// The program monitor's sequence. + Sequence(NodeId), + /// The source monitor's selected footage node. + Footage(NodeId), } /// One background full-resolution render request (built on the UI thread @@ -402,16 +245,16 @@ struct FullResRequest { /// The engine's full-res generation when the job was scheduled (stale /// completions are discarded by the drain). generation: u64, + /// The project the target lives in (keeps the graph alive). + project: ProjectRef, /// The sequence or footage node to render. target: FullResTarget, /// Output width (the sequence's native size). - width: c_int, + width: i32, /// Output height. - height: c_int, - /// Frame-rate numerator. - rate_num: c_int, - /// Frame-rate denominator. - rate_den: c_int, + height: i32, + /// The sequence's timebase (frame duration = `tb.0 / tb.1` seconds). + tb: (i64, i64), } /// A completed full-res frame, delivered through the completion channel. @@ -427,44 +270,31 @@ struct FullResEvent { } // --------------------------------------------------------------------------- -// FFI helpers +// Frame conversion // --------------------------------------------------------------------------- -/// Builds a `CString` from a path (lossy on non-UTF-8). -fn cstr_path(path: &Path) -> Option { - CString::new(path.to_string_lossy().into_owned()).ok() -} - -/// Two-stage read of a facade buf/size string (the return value is the -/// length excluding the NUL). The closure must call the facade getter inside -/// its own `unsafe` block. -fn read_string(f: impl Fn(*mut c_char, c_int) -> c_int) -> String { - let needed = f(std::ptr::null_mut(), 0); - if needed <= 0 { - return String::new(); +/// Repack one F32 RGBA rendered frame (rows padded to linesize) into +/// tightly packed samples. Returns `(width, height, samples)` when the +/// frame is well-formed (positive geometry, the pipeline's F32 format). +fn read_f32_frame(frame: &super::renderops::RenderedFrame) -> Option<(u32, u32, Vec)> { + let (width, height, linesize) = (frame.width, frame.height, frame.linesize); + if width <= 0 || height <= 0 || frame.format != super::renderops::PIXEL_FORMAT_F32 { + return None; } - // The facade's two-stage getters report the length WITHOUT the - // trailing NUL (`string_result` subtracts one); the buffer must - // carry the NUL too. - let mut buf = vec![0 as c_char; needed as usize + 1]; - f(buf.as_mut_ptr(), needed as c_int + 1); - let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); - String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) - .into_owned() -} - -/// Reads the error buffer the OVE load/save serializer fills. -fn load_error(err: &mut [c_char]) -> String { - let len = err.iter().position(|&c| c == 0).unwrap_or(err.len()); - let text = String::from_utf8_lossy(unsafe { - std::slice::from_raw_parts(err.as_ptr() as *const u8, len) - }) - .into_owned(); - if text.is_empty() { - "the operation failed".to_string() - } else { - text + let row_bytes = (width * 4 * 4) as usize; + let linesize = (linesize as usize).max(row_bytes); + if frame.data.len() < linesize * height as usize { + return None; } + let mut samples = vec![0.0f32; (width * height * 4) as usize]; + for y in 0..height as usize { + let row = &frame.data[y * linesize..y * linesize + row_bytes]; + for (i, px) in row.chunks_exact(4).enumerate() { + let v = f32::from_ne_bytes([px[0], px[1], px[2], px[3]]); + samples[y * (width as usize) * 4 + i] = v; + } + } + Some((width as u32, height as u32, samples)) } // --------------------------------------------------------------------------- @@ -473,7 +303,7 @@ fn load_error(err: &mut [c_char]) -> String { /// The real engine's transport clock: the playhead plus the wall-clock /// anchor while playing. Mirrors the mock's clock; the engine additionally -/// writes the program playhead back to the facade sequence. +/// writes the program playhead back to the sequence. pub struct RealClock { /// The transport state (play/pause, playhead, loop range). pub transport: TransportState, @@ -542,9 +372,9 @@ impl PlaybackClock for RealClock { // Timeline model // --------------------------------------------------------------------------- -/// A clip on the real timeline: the facade data plus the C-ABI coordinates -/// (`track_type` / per-type `track_index` / per-track `clip_index`) the edit -/// commands are addressed with. +/// A clip on the real timeline: the widget snapshot plus the graph +/// addressing (the block's `NodeId`; the widget-facing id is its stable +/// identity). #[derive(Debug, Clone)] pub struct RealClip { id: ClipId, @@ -552,9 +382,8 @@ pub struct RealClip { media_in: Frame, label: SharedString, color: Hsla, - track_type: TrackKind, - track_index: usize, - clip_index: usize, + /// The block node in the project graph. + block: NodeId, } impl ClipData for RealClip { @@ -579,12 +408,12 @@ impl ClipData for RealClip { } } -/// One card of the real effect stack: the facade chain node's identity, -/// its factory display name, its enabled flag, and the app-owned -/// expansion state ([`RealEngine::expanded_effects`]). No source/output -/// cards: the host clip node is the implicit output, the chain's unlinked -/// upstream input is the implicit source (the effect stack shows only the -/// editable middle). +/// One card of the real effect stack: the chain node's identity, its +/// factory display name, its enabled flag, and the app-owned expansion +/// state ([`RealEngine::expanded_effects`]). No source/output cards: the +/// host clip node is the implicit output, the chain's unlinked upstream +/// input is the implicit source (the effect stack shows only the editable +/// middle). #[derive(Debug, Clone)] struct RealEffect { /// The node's stable identity (also the card's `EffectId`). @@ -630,7 +459,11 @@ pub struct RealTrack { solo: bool, visible: bool, clips: Vec, - track_type: c_int, + /// The track node in the project graph. + track: NodeId, + /// The track's per-type index (the facade's clip coordinates; edit + /// commands address nodes directly now, but the drop policy and the + /// widget's cross-track moves still speak display/per-type indices). track_index: usize, } @@ -670,8 +503,8 @@ impl TrackData for RealTrack { } } -/// A deterministic clip color from a stable per-clip index (the facade -/// exposes no clip color). +/// A deterministic clip color from a stable per-clip index (the module +/// graph exposes no clip color). fn clip_color(index: u64) -> Hsla { let hues = [0.55f32, 0.6, 0.08, 0.3, 0.78, 0.45, 0.9, 0.15]; Hsla { @@ -682,10 +515,9 @@ fn clip_color(index: u64) -> Hsla { } } -/// A marker color for a marker color index (the facade marker color -/// contract): a small palette around the amber accent, so adjacent markers -/// stay distinguishable. -fn marker_color(index: c_int) -> Hsla { +/// A marker color for a marker color index (a small palette around the +/// amber accent, so adjacent markers stay distinguishable). +fn marker_color(index: i32) -> Hsla { let hues = [0.10f32, 0.0, 0.55, 0.30, 0.78]; let h = hues[(index.max(0) as usize) % hues.len()]; Hsla { @@ -696,6 +528,24 @@ fn marker_color(index: c_int) -> Hsla { } } +/// The [`TrackKind`] of a module track type. +fn track_kind_of(kind: TrackType) -> TrackKind { + match kind { + TrackType::Video => TrackKind::Video, + TrackType::Audio => TrackKind::Audio, + TrackType::Subtitle => TrackKind::Subtitle, + } +} + +/// The module track type of a [`TrackKind`]. +fn track_type_of(kind: TrackKind) -> TrackType { + match kind { + TrackKind::Video => TrackType::Video, + TrackKind::Audio => TrackType::Audio, + TrackKind::Subtitle => TrackType::Subtitle, + } +} + /// A node in the real node graph (M12 P2: built from the current /// sequence's graph by [`crate::oakui::nodegraph`]). pub use crate::oakui::nodegraph::{RealEdge, RealNode, RealPort}; @@ -704,13 +554,21 @@ pub use crate::oakui::nodegraph::{RealEdge, RealNode, RealPort}; // The engine // --------------------------------------------------------------------------- -/// The real engine: the facade project/sequence plus the snapshot models the -/// widgets read. +/// The real engine: the domain project/sequence plus the snapshot models +/// the widgets read. pub struct RealEngine { - /// The owned facade project (None before any project is open). - project: Option, - /// The borrowed facade sequence (freed before the project on drop). - sequence: Option, + /// The domain project (None before any project is open). + project: Option, + /// The current sequence node. + sequence: Option, + /// The write-through binding's query handle (bound at adopt, released + /// at drop). + storage: Option, + /// The current sequence's marker list (the module's sequences carry + /// none; the app materializes one per open sequence, like the facade). + markers: Option, + /// The current sequence's work area (see `markers`). + workarea: Option, /// The gateway's cached project info. project_info: Project, /// The gateway's cached sequence info. @@ -724,7 +582,7 @@ pub struct RealEngine { /// Timeline waveform cache (M12 P4), created lazily at the current /// frame rate. waveforms: Mutex>>, - /// The selected material-bin entry (demo state). + /// The selected material-bin entry (a node identity). selected_item: Option, /// The single selected timeline clip — the effect stack's target /// (`None` for an empty or multi-clip selection, or before any @@ -759,17 +617,13 @@ pub struct RealEngine { full_res_rx: Mutex>, /// The sending half of `full_res_rx` (cloned into every job). full_res_tx: Mutex>, - /// The program monitor's cached renderer, created lazily from the - /// current sequence at a proxy resolution. The mutex both provides the - /// interior mutability `cpu_frame` (a `&self` read) needs and serializes - /// the synchronous render calls. Reset to [`RendererSlot::Untried`] - /// (before the sequence is freed) in [`RealEngine::drop_project`]. + /// The program monitor's render-path state (see [`RendererSlot`]). + /// Reset to [`RendererSlot::Untried`] in [`RealEngine::drop_project`]. renderer: Mutex, - /// The source monitor's cached renderer, created lazily from the - /// currently selected footage node at a proxy resolution (same slot - /// semantics as `renderer`). Reset to [`RendererSlot::Untried`] when - /// the selection changes or the project is dropped — the renderer binds - /// the footage node, so a new selection must bind the new node. + /// The source monitor's render-path state (same slot semantics as + /// `renderer`). Reset when the selection changes or the project is + /// dropped — the render binds the footage node, so a new selection + /// must re-render the new node. source_renderer: Mutex, } @@ -778,12 +632,11 @@ impl RealEngine { /// it for playback (M12 P1). Failures are silent: playback continues /// video-only. fn pull_audio_tick(&mut self, cx: &mut Context) { - let renderer = { - let slot = self.renderer.lock().unwrap_or_else(|e| e.into_inner()); - match &*slot { - RendererSlot::Ready(handle) => RendererHandle(handle.0), - _ => return, - } + let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else { + return; + }; + let Some(tb) = self.time_base() else { + return; }; let fps = self.frame_rate(); let frame = self.clock_frame(Monitor::Program, cx).0; @@ -792,34 +645,26 @@ impl RealEngine { } // ~1/60 s of sequence per tick. let chunk = ((fps.num as f64 / fps.den as f64) / 60.0).max(0.001) as i64; - let buf = unsafe { oakengine_renderer_render_audio(renderer.0, frame, chunk) }; - if buf.is_null() { + let Ok(buf) = super::renderops::render_audio_range(&project, seq, frame, chunk, tb) else { + return; + }; + if buf.sample_rate <= 0 || buf.channel_count <= 0 || buf.data.is_empty() { return; } - let rate = unsafe { oakengine_audio_sample_rate(buf) }; - let channels = unsafe { oakengine_audio_channel_count(buf) }; - let frames = unsafe { oakengine_audio_sample_count(buf) }; - let data = unsafe { oakengine_audio_data(buf, 0) }; - if rate > 0 && channels > 0 && frames > 0 && !data.is_null() { - // Packed F32 (10 = the engine's packed F32 sample format); - // layout: 1ch → mono mask, else stereo. - let layout: u64 = if channels == 1 { 0x4 } else { 0x3 }; - let params = - unsafe { oakcore_audioparams_create(rate, layout, 10) }; - if !params.is_null() { - unsafe { - oakengine_audio_push_to_output( - params, - data as *const c_char, - frames * i64::from(channels) * 4, - std::ptr::null_mut(), - 0, - ); - oakcore_audioparams_free(params); - } - } + // Packed F32; layout: 1ch → mono mask, else stereo. + let layout: u64 = if buf.channel_count == 1 { 0x4 } else { 0x3 }; + let bytes: Vec = buf.data.iter().flat_map(|v| v.to_ne_bytes()).collect(); + if let Some(mut manager) = oakaudio::manager::instance() { + let _ = manager.push_to_output( + oakaudio::params::AudioParams { + sample_rate: buf.sample_rate, + channel_layout: layout, + format: oakcore_rs::SampleFormat::F32, + }, + &bytes, + &mut [], + ); } - unsafe { oakengine_audio_free(buf) }; } /// Builds an engine with no project open. @@ -829,6 +674,9 @@ impl RealEngine { Self { project: None, sequence: None, + storage: None, + markers: None, + workarea: None, project_info: Project { name: UNTITLED.into(), path: PathBuf::new(), @@ -860,66 +708,35 @@ impl RealEngine { } } - /// The sequence pointer, if a project+sequence is open. - fn seq_ptr(&self) -> Option<*mut OakEngineSequence> { - self.sequence.as_ref().map(SequenceHandle::ptr) + /// The open project reference, if any. + fn project_ref(&self) -> Option<&ProjectRef> { + self.project.as_ref() } - /// The project pointer, if a project is open. - fn project_ptr(&self) -> Option<*mut OakEngineProject> { - self.project.as_ref().map(ProjectHandle::ptr) - } - - /// An addref'd copy of the project handle, boxed for the background - /// worker — same lifetime contract as [`RealEngine::sequence_copy`]: - /// the copy keeps the graph alive after the engine's own project is - /// dropped (the source monitor's footage node dangles otherwise). - fn project_copy(&self) -> Option<*mut OakEngineProject> { - let project = self.project_ptr()?; - // SAFETY: `project` is the engine's live project box. - let handle = unsafe { unbox(project) }?; - let addref = handle.addref?; - // SAFETY: `handle` is a live module handle; addref takes a new - // reference the copy releases. - unsafe { addref(handle.ctx) }; - Some(unsafe { box_handle::(handle) }) + /// The sequence's timebase `(rate_den, rate_num)`, if a sequence with + /// a valid frame rate is open. + fn time_base(&self) -> Option<(i64, i64)> { + let project = self.project_ref()?; + let guard = graphops::lock(project); + graphops::sequence_time_base(&guard.graph, self.sequence?) } /// The selected footage's duration in frames at the current rate /// (0 when nothing is selected or the footage was not probed). fn source_length(&self) -> Frame { - let Some(project) = self.project_ptr() else { + let (Some(project), Some(identity)) = (self.project_ref(), self.selected_item) else { return Frame(0); }; - let Some(id) = self.selected_item else { + let Some(id) = graphops::id_of(identity) else { return Frame(0); }; - let count = unsafe { oakengine_project_footage_count(project) }; - for i in 0..count.max(0) { - let f = unsafe { oakengine_project_footage_at(project, i) }; - if f.is_null() { - continue; - } - let matches = unsafe { oakengine_node_identity(f) } == id; - if matches { - // The footage list yields node boxes; borrow the footage - // view for the duration query. - let footage = unsafe { oakengine_footage_borrow(f) }; - unsafe { oakengine_node_free(f) }; - let mut seconds: f64 = 0.0; - let ok = !footage.is_null() - && unsafe { oakengine_footage_get_duration(footage, &mut seconds) } == 0; - unsafe { oakengine_footage_free(footage) }; - if ok && seconds > 0.0 { - let rate = self.frame_rate(); - let fps = rate.num as f64 / rate.den.max(1) as f64; - return Frame((seconds * fps).round().max(1.0) as i64); - } - return Frame(0); - } - unsafe { oakengine_node_free(f) }; - } - Frame(0) + let guard = graphops::lock(project); + let Some(seconds) = graphops::footage_duration_seconds(&guard.graph, id) else { + return Frame(0); + }; + let rate = self.frame_rate(); + let fps = rate.num as f64 / rate.den.max(1) as f64; + Frame((seconds * fps).round().max(1.0) as i64) } /// Current sequence length (0 without a sequence). @@ -930,479 +747,179 @@ impl RealEngine { .unwrap_or(Frame(0)) } - /// Mirrors the program playhead into the facade sequence (best effort). + /// Mirrors the program playhead into the sequence (best effort). fn mirror_program_playhead(&self, cx: &App) { - if let Some(seq) = self.seq_ptr() { - let frame = self.program_clock.read(cx).transport.frame().0; - unsafe { - oakengine_sequence_set_playhead(seq, frame); - } - } + let (Some(project), Some(seq)) = (self.project_ref(), self.sequence) else { + return; + }; + let Some(tb) = self.time_base() else { + return; + }; + let frame = self.program_clock.read(cx).transport.frame().0; + graphops::sequence_set_playhead(project, seq, graphops::ts_to_rational(frame, tb)); } - /// Brings up the module's process-global render manager if it is not - /// running yet (without it `render_frame` fails with NULL + last_error). - /// Returns false when the manager could not be started. - fn ensure_render_manager() -> bool { - unsafe { - if oakengine_render_manager_available() != 0 { - return true; - } - oakengine_render_manager_init(); - oakengine_render_manager_available() != 0 - } - } - - /// The proxy resolution the viewer renderer runs at: the sequence's - /// aspect scaled to a small long edge. Rendering is a synchronous call - /// made from `cpu_frame` (a `&self` read on the UI thread), so the - /// geometry stays tiny to keep the block short; the full-resolution - /// frame is rendered off-thread at the sequence's native size by the - /// background job (M12 P5a, see [`RealEngine::schedule_full_res`]). - fn proxy_render_size(&self) -> Option<(c_int, c_int)> { + /// The proxy resolution the viewer renders at: the sequence's aspect + /// scaled to a small long edge. Rendering is a synchronous call made + /// from `cpu_frame` (a `&self` read on the UI thread), so the geometry + /// stays tiny to keep the block short; the full-resolution frame is + /// rendered off-thread at the sequence's native size by the background + /// job (M12 P5a, see [`RealEngine::schedule_full_res`]). + fn proxy_render_size(&self) -> Option<(i32, i32)> { let info = self.sequence_info.as_ref()?; let (w, h) = (info.format.width.max(1), info.format.height.max(1)); const MAX_LONG_EDGE: u32 = 480; let scale = MAX_LONG_EDGE as f64 / w.max(h) as f64; let width = ((w as f64 * scale).round() as u32).max(2); let height = ((h as f64 * scale).round() as u32).max(2); - Some((width as c_int, height as c_int)) + Some((width as i32, height as i32)) } - /// Renders one program-monitor frame through the facade CPU renderer: - /// creates the per-sequence renderer lazily (cached in `self.renderer`), - /// renders `frame`, analyzes the scope samples from the F32 RGBA result, - /// and downconverts to BGRA8. Returns `None` (the caller falls back to - /// the synthetic pattern) when no sequence is open, the render manager is - /// unavailable, or the render itself fails. + /// Renders one program-monitor frame through the oakrender ticket + /// arena: builds the sequence's montage at `frame`, renders at the + /// proxy geometry, analyzes the scope samples from the F32 RGBA + /// result, and downconverts to BGRA8. Returns `None` (the caller falls + /// back to the synthetic pattern) when no sequence is open, the render + /// manager is unavailable, or the render itself fails. fn render_program_frame(&self, frame: Frame) -> Option<(RenderImage, ScopeData)> { - let seq = self.seq_ptr()?; - if !Self::ensure_render_manager() { + let project = self.project_ref()?.clone(); + let seq = self.sequence?; + let tb = self.time_base()?; + if !super::renderops::ensure_render_manager() { return None; } let mut slot = self.renderer.lock().unwrap(); - match &*slot { - RendererSlot::Unavailable => return None, - RendererSlot::Untried => { - let created = self.create_renderer(seq); - *slot = match created { - Some(handle) => RendererSlot::Ready(handle), - None => RendererSlot::Unavailable, - }; - } - RendererSlot::Ready(_) => {} - } - let RendererSlot::Ready(handle) = &*slot else { - return None; - }; - let renderer = handle.ptr(); - let frame_ptr = unsafe { oakengine_renderer_render_frame(renderer, frame.0) }; - if frame_ptr.is_null() { - let error = read_string(|buf, size| unsafe { - oakengine_renderer_last_error(renderer, buf, size) - }); - println!("[real engine] render_frame failed: {error}"); - // Don't retry (and re-log) on every frame. - *slot = RendererSlot::Unavailable; + if matches!(*slot, RendererSlot::Unavailable) { return None; } - // Read the frame (F32 RGBA, rows padded to linesize), repack it - // tightly, then downconvert. - let (width, height, linesize, format) = unsafe { - ( - oakengine_frame_width(frame_ptr), - oakengine_frame_height(frame_ptr), - oakengine_frame_linesize_bytes(frame_ptr), - oakengine_frame_format(frame_ptr), - ) - }; - let data = unsafe { oakengine_frame_data(frame_ptr) }; - let mut image = None; - if width > 0 && height > 0 && format == PIXEL_FORMAT_F32 && !data.is_null() { - let row_bytes = (width * 4 * 4) as usize; - let linesize = (linesize as usize).max(row_bytes); - let mut samples = vec![0.0f32; (width * height * 4) as usize]; - for y in 0..height as usize { - unsafe { - std::ptr::copy_nonoverlapping( - (data as *const u8).add(y * linesize), - samples.as_mut_ptr().add(y * row_bytes / 4) as *mut u8, - row_bytes, - ); - } - } - // The scopes read the same F32 samples the viewer displays. - let scope = analyze_f32_rgba(width as u32, height as u32, &samples); - image = Some(( - f32_rgba_to_bgra_image(width as u32, height as u32, &samples), - scope, - )); - } - unsafe { - oakengine_frame_free(frame_ptr); - } - image - } - - /// Creates the per-sequence renderer at the proxy resolution (see - /// [`RealEngine::proxy_render_size`]). The renderer borrows the sequence - /// handle; the caller owns the slot it is stored in. - fn create_renderer(&self, seq: *mut OakEngineSequence) -> Option { let (width, height) = self.proxy_render_size()?; - let rate = self.sequence_info.as_ref()?.format.rate; - // Pixel format 4 = PixelFormat::F32 (the pipeline format); - // timestamp units are frames at this rate. - let renderer = unsafe { - oakengine_renderer_create( - seq, - width, - height, - PIXEL_FORMAT_F32, - rate.num as c_int, - rate.den as c_int, - std::ptr::null(), - ) - }; - if renderer.is_null() { - println!("[real engine] renderer_create failed; viewer keeps the synthetic frame"); - return None; + match super::renderops::render_sequence_frame(&project, seq, frame.0, tb, width, height) { + Ok(rendered) => { + *slot = RendererSlot::Ready; + let (width, height, samples) = read_f32_frame(&rendered)?; + let scope = analyze_f32_rgba(width, height, &samples); + Some((f32_rgba_to_bgra_image(width, height, &samples), scope)) + } + Err(error) => { + println!("[real engine] render_frame failed: {error}"); + // Don't retry (and re-log) on every frame. + *slot = RendererSlot::Unavailable; + None + } } - Some(RendererHandle(renderer)) } /// The selected entry's footage node (M12 P3: entry ids are the - /// nodes' stable identities), or `None` when the selection is a - /// folder or absent. - /// - /// # Safety - /// The returned box is freed with `oakengine_node_free`. - fn selected_footage_node(&self) -> Option<*mut OakEngineNode> { - let project = self.project_ptr()?; - let id = self.selected_item?; - unsafe { - // The identity must resolve to a footage entry (a folder or - // sequence id must not be treated as footage). - let count = unsafe { oakengine_project_footage_count(project) }; - let mut is_footage = false; - for i in 0..count.max(0) { - let f = unsafe { oakengine_project_footage_at(project, i) }; - if f.is_null() { - continue; - } - let matches = unsafe { oakengine_node_identity(f) } == id; - unsafe { oakengine_node_free(f) }; - if matches { - is_footage = true; - break; - } - } - if !is_footage { - return None; - } - crate::oakui::projectbrowser::find_by_identity(project, id) - } + /// nodes' stable identities), or `None` when the selection is a folder + /// or absent. + fn selected_footage_node(&self) -> Option { + let project = self.project_ref()?; + let id = graphops::id_of(self.selected_item?)?; + let guard = graphops::lock(project); + graphops::footage_behavior(&guard.graph, id).map(|_| id) } - /// Creates the per-footage renderer at the proxy resolution (see - /// [`RealEngine::proxy_render_size`]). The renderer borrows the footage - /// node handle; the caller owns the slot it is stored in. - fn create_node_renderer(&self, node: *mut OakEngineNode) -> Option { - let (width, height) = self.proxy_render_size()?; - let rate = self.sequence_info.as_ref()?.format.rate; - let renderer = unsafe { - oakengine_renderer_create_for_node( - node, - width, - height, - PIXEL_FORMAT_F32, - rate.num as c_int, - rate.den as c_int, - std::ptr::null(), - ) - }; - if renderer.is_null() { - println!( - "[real engine] renderer_create_for_node failed; viewer keeps the synthetic frame" - ); - return None; - } - Some(RendererHandle(renderer)) - } - - /// Renders one source-monitor frame through the facade CPU renderer: - /// creates the per-footage renderer lazily (cached in - /// `self.source_renderer`, bound to the currently selected footage - /// node), renders `frame`, analyzes the scope samples from the F32 RGBA - /// result, and downconverts to BGRA8. Returns `None` (the caller falls + /// Renders one source-monitor frame through the ticket arena: the + /// currently selected footage node decoded at the proxy geometry (same + /// pipeline as the program monitor). Returns `None` (the caller falls /// back to the synthetic pattern) when no footage is selected, the /// render manager is unavailable, or the render itself fails. fn render_source_frame(&self, frame: Frame) -> Option<(RenderImage, ScopeData)> { + let project = self.project_ref()?.clone(); let node = self.selected_footage_node()?; - if !Self::ensure_render_manager() { - // SAFETY: `node` is a box from `selected_footage_node`. - unsafe { oakengine_node_free(node) }; + let tb = self.time_base()?; + if !super::renderops::ensure_render_manager() { return None; } let mut slot = self.source_renderer.lock().unwrap(); - match &*slot { - RendererSlot::Unavailable => { - unsafe { oakengine_node_free(node) }; - return None; - } - RendererSlot::Untried => { - let created = self.create_node_renderer(node); - *slot = match created { - Some(handle) => RendererSlot::Ready(handle), - None => RendererSlot::Unavailable, - }; - } - RendererSlot::Ready(_) => {} - } - // SAFETY: `node` is a live box; freed on every path below. - unsafe { oakengine_node_free(node) }; - let RendererSlot::Ready(handle) = &*slot else { - return None; - }; - let renderer = handle.ptr(); - let frame_ptr = unsafe { oakengine_renderer_render_frame(renderer, frame.0) }; - if frame_ptr.is_null() { - let error = read_string(|buf, size| unsafe { - oakengine_renderer_last_error(renderer, buf, size) - }); - println!("[real engine] source render_frame failed: {error}"); - // Don't retry (and re-log) on every frame. - *slot = RendererSlot::Unavailable; + if matches!(*slot, RendererSlot::Unavailable) { return None; } - // Read the frame (F32 RGBA, rows padded to linesize), repack it - // tightly, then downconvert. - let (width, height, linesize, format) = unsafe { - ( - oakengine_frame_width(frame_ptr), - oakengine_frame_height(frame_ptr), - oakengine_frame_linesize_bytes(frame_ptr), - oakengine_frame_format(frame_ptr), - ) - }; - let data = unsafe { oakengine_frame_data(frame_ptr) }; - let mut image = None; - if width > 0 && height > 0 && format == PIXEL_FORMAT_F32 && !data.is_null() { - let row_bytes = (width * 4 * 4) as usize; - let linesize = (linesize as usize).max(row_bytes); - let mut samples = vec![0.0f32; (width * height * 4) as usize]; - for y in 0..height as usize { - unsafe { - std::ptr::copy_nonoverlapping( - (data as *const u8).add(y * linesize), - samples.as_mut_ptr().add(y * row_bytes / 4) as *mut u8, - row_bytes, - ); - } + let (width, height) = self.proxy_render_size()?; + match super::renderops::render_footage_frame(&project, node, frame.0, tb, width, height) { + Ok(rendered) => { + *slot = RendererSlot::Ready; + let (width, height, samples) = read_f32_frame(&rendered)?; + let scope = analyze_f32_rgba(width, height, &samples); + Some((f32_rgba_to_bgra_image(width, height, &samples), scope)) } - let scope = analyze_f32_rgba(width as u32, height as u32, &samples); - image = Some(( - f32_rgba_to_bgra_image(width as u32, height as u32, &samples), - scope, - )); - } - unsafe { - oakengine_frame_free(frame_ptr); - } - image - } - - /// Repacks one F32 RGBA facade frame (rows padded to linesize) into - /// tightly packed samples. Returns `(width, height, samples)` when the - /// frame is well-formed (positive geometry, the pipeline's F32 format, - /// non-null data). - fn read_f32_frame(frame_ptr: *mut OakEngineFrame) -> Option<(u32, u32, Vec)> { - // SAFETY: `frame_ptr` is a live facade frame box. - let (width, height, linesize, format) = unsafe { - ( - oakengine_frame_width(frame_ptr), - oakengine_frame_height(frame_ptr), - oakengine_frame_linesize_bytes(frame_ptr), - oakengine_frame_format(frame_ptr), - ) - }; - let data = unsafe { oakengine_frame_data(frame_ptr) }; - if width <= 0 || height <= 0 || format != PIXEL_FORMAT_F32 || data.is_null() { - return None; - } - let row_bytes = (width * 4 * 4) as usize; - let linesize = (linesize as usize).max(row_bytes); - let mut samples = vec![0.0f32; (width * height * 4) as usize]; - for y in 0..height as usize { - // SAFETY: the facade frame holds `height` rows of at least - // `linesize` bytes; `samples` holds tightly packed rows. - unsafe { - std::ptr::copy_nonoverlapping( - (data as *const u8).add(y * linesize), - samples.as_mut_ptr().add(y * row_bytes / 4) as *mut u8, - row_bytes, - ); + Err(error) => { + println!("[real engine] source render_frame failed: {error}"); + *slot = RendererSlot::Unavailable; + None } } - Some((width as u32, height as u32, samples)) - } - - /// An addref'd copy of the sequence handle, boxed for the background - /// worker. The copy keeps the sequence alive even when the project is - /// dropped while a full-res job is in flight; the worker frees it last - /// (after the renderer, whose view of the sequence is borrowed). - fn sequence_copy(&self) -> Option<*mut OakEngineSequence> { - let seq = self.seq_ptr()?; - // SAFETY: `seq` is the engine's live sequence box. - let handle = unsafe { unbox(seq) }?; - let addref = handle.addref?; - // SAFETY: `handle` is a live module handle; addref takes a new - // reference the copy releases. - unsafe { addref(handle.ctx) }; - Some(unsafe { box_handle::(handle) }) } /// Builds the background full-res job for `monitor` at `frame` (the - /// program monitor's sequence via an addref'd copy, the source - /// monitor's selected footage node) at the sequence's native size. - /// Returns None when there is nothing to render (no sequence open, no - /// footage selected). + /// program monitor's sequence or the source monitor's selected footage + /// node) at the sequence's native size. Returns None when there is + /// nothing to render (no sequence open, no footage selected). fn build_full_res_request(&self, monitor: Monitor, frame: i64) -> Option { let info = self.sequence_info.as_ref()?; - let rate = info.format.rate; - let width = info.format.width.max(1) as c_int; - let height = info.format.height.max(1) as c_int; let target = match monitor { - Monitor::Program => FullResTarget::Sequence(SendPtr(self.sequence_copy()?)), - Monitor::Source => { - // The project copy MUST be taken while the engine's own - // project is still alive (it keeps the node valid). - FullResTarget::Node( - SendPtr(self.selected_footage_node()?), - SendPtr(self.project_copy()?), - ) - } + Monitor::Program => FullResTarget::Sequence(self.sequence?), + Monitor::Source => FullResTarget::Footage(self.selected_footage_node()?), }; Some(FullResRequest { monitor, frame, generation: self.full_res_generation, + project: self.project_ref()?.clone(), target, - width, - height, - rate_num: rate.num as c_int, - rate_den: rate.den as c_int, + width: info.format.width.max(1) as i32, + height: info.format.height.max(1) as i32, + tb: self.time_base()?, }) } - /// Runs one background full-resolution render (the worker thread of the - /// full-res path). The dedicated full-size renderer is created, used - /// and freed on this thread only, so it never aliases the UI thread's - /// proxy renderer; the target box is freed here too (the sequence copy - /// last, keeping the sequence alive for the renderer's borrowed view). - /// Reports the finished frame through `tx`. + /// Runs one background full-resolution render (the worker thread of + /// the full-res path) and reports the finished frame through `tx`. The + /// request owns a project `Arc`, so the render stays valid even when + /// the engine's project is dropped mid-flight (the drain discards the + /// stale completion). fn full_res_worker(request: FullResRequest, tx: mpsc::Sender) { let FullResRequest { monitor, frame, generation, + project, target, width, height, - rate_num, - rate_den, + tb, } = request; - if !Self::ensure_render_manager() { - Self::release_full_res_target(target); - return; - } - let renderer = unsafe { - match target { - FullResTarget::Sequence(seq) => { - // The sequence copy stays alive until after the - // renderer is freed below (the renderer's view is - // borrowed), so the box must not be freed here. - oakengine_renderer_create( - seq.0, - width, - height, - PIXEL_FORMAT_F32, - rate_num, - rate_den, - std::ptr::null(), - ) - } - FullResTarget::Node(node, _) => { - // The node stays alive until release_full_res_target - // (freed exactly once there); the renderer resolves its - // footage spec at creation and borrows nothing beyond. - oakengine_renderer_create_for_node( - node.0, - width, - height, - PIXEL_FORMAT_F32, - rate_num, - rate_den, - std::ptr::null(), - ) - } - } - }; - if renderer.is_null() { - // SAFETY: the sequence copy is still owned by us (a node was - // freed right after creation above). - Self::release_full_res_target(target); - return; - } - // SAFETY: `renderer` is the live box created above. - let frame_ptr = unsafe { oakengine_renderer_render_frame(renderer, frame) }; let mut event = None; - if !frame_ptr.is_null() { - if let Some((width, height, samples)) = Self::read_f32_frame(frame_ptr) { - let image = Arc::new(f32_rgba_to_bgra_image(width, height, &samples)); - event = Some(FullResEvent { - monitor, - frame, - generation, - image, - }); + if super::renderops::ensure_render_manager() { + let rendered = match target { + FullResTarget::Sequence(seq) => { + super::renderops::render_sequence_frame(&project, seq, frame, tb, width, height) + } + FullResTarget::Footage(node) => { + super::renderops::render_footage_frame(&project, node, frame, tb, width, height) + } + }; + if let Ok(rendered) = rendered { + if let Some((width, height, samples)) = read_f32_frame(&rendered) { + event = Some(FullResEvent { + monitor, + frame, + generation, + image: Arc::new(f32_rgba_to_bgra_image(width, height, &samples)), + }); + } } - // SAFETY: `frame_ptr` is a live frame box from render_frame. - unsafe { oakengine_frame_free(frame_ptr) }; } - // SAFETY: `renderer` is a live renderer box. - unsafe { oakengine_renderer_free(renderer) }; - // SAFETY: the sequence copy outlived the renderer (its borrowed - // view was dropped above). - Self::release_full_res_target(target); if let Some(event) = event { let _ = tx.send(event); } } - /// Frees the box a full-res job owns: the sequence copy with - /// [`free_box`], the footage node with `oakengine_node_free`. - /// - /// # Safety - /// `target` must be a live box owned by the calling job. - fn release_full_res_target(target: FullResTarget) { - unsafe { - match target { - FullResTarget::Sequence(seq) => free_box(seq.0), - // The node goes first; the addref'd project copy outlives - // it (the node's graph must stay alive during the free). - FullResTarget::Node(node, project) => { - oakengine_node_free(node.0); - free_box(project.0); - } - } - } - } - /// Schedules a background full-resolution render for `monitor`'s current /// playhead when the policy says so: the playhead is resting, the frame /// is not already cached full-res, and no job is in flight for this - /// monitor (M12 P5a). The job runs on its own thread with a dedicated - /// renderer, so the UI thread never blocks. + /// monitor (M12 P5a). The job runs on its own thread, so the UI thread + /// never blocks. fn schedule_full_res(&mut self, monitor: Monitor, cx: &mut Context) { let frame = self.clock_frame(monitor, cx).0; if frame < 0 { @@ -1442,56 +959,68 @@ impl RealEngine { } } - /// Adopts a newly created/loaded facade project, freeing any previous - /// one, and rebuilds every snapshot. `blank` projects get a default - /// sequence; loaded ones use the first sequence. - fn adopt_project(&mut self, project: *mut OakEngineProject, cx: &mut Context) { + /// Adopts a newly created/loaded project, dropping any previous one, + /// and rebuilds every snapshot. The undo stack is cleared (a project + /// switch starts a fresh history, mirroring the facade's + /// project_new/load) and the project is bound to the write-through + /// library. Projects without a sequence get a blank default. + fn adopt_project(&mut self, project: ProjectRef, cx: &mut Context) { self.drop_project(); - self.project = Some(ProjectHandle(project)); + oakundo::global::clear().ok(); // Cached display info. - let name = read_string(|buf, size| unsafe { oakengine_project_name(project, buf, size) }); - let path = PathBuf::from(read_string(|buf, size| unsafe { - oakengine_project_filename(project, buf, size) - })); + let (name, path, first_sequence) = { + let guard = graphops::lock(&project); + ( + guard.name(), + PathBuf::from(guard.filename().to_string()), + graphops::sequence_ids(&guard).first().copied(), + ) + }; self.project_info = Project { - name: if name.is_empty() { + name: if name.is_empty() || name == "(untitled)" { UNTITLED.into() } else { name }, path, }; + self.project = Some(project.clone()); + self.storage = Some(AuxHandle(graphops::storage_bind(&project))); // The sequence: the project's first, or a blank default. - let count = unsafe { oakengine_project_sequence_count(project) }; - let sequence = if count > 0 { - unsafe { oakengine_project_sequence_at(project, 0) } - } else { - let name_c = CString::new("Sequence 1").unwrap(); - unsafe { oakengine_sequence_new(project, name_c.as_ptr()) } - }; - if sequence.is_null() { - return; - } - self.sequence = Some(SequenceHandle(sequence)); + let seq = first_sequence + .unwrap_or_else(|| graphops::create_sequence(&project, "Sequence 1")); + self.sequence = Some(seq); + self.markers = Some(AuxHandle(graphops::marker_list_create())); + self.workarea = Some(AuxHandle(graphops::workarea_create())); self.refresh_sequence_info(); self.rebuild_timeline(); cx.notify(); } - /// Frees the project and every borrowed handle (renderer and sequence - /// first: the renderer borrows the sequence, and the sequence is - /// borrowed from the project). + /// Drops the project and every auxiliary handle. The undo stack is + /// cleared FIRST: its commands reference the marker/workarea + /// auxiliaries, so they must be gone before those handles release. fn drop_project(&mut self) { *self.renderer.lock().unwrap() = RendererSlot::Untried; *self.source_renderer.lock().unwrap() = RendererSlot::Untried; - drop(self.sequence.take()); - drop(self.project.take()); + oakundo::global::clear().ok(); + if let Some(mut markers) = self.markers.take() { + graphops::release_handle(&mut markers.0); + } + if let Some(mut workarea) = self.workarea.take() { + graphops::release_handle(&mut workarea.0); + } + if let Some(storage) = self.storage.take() { + graphops::storage_unbind(storage.0); + } + self.project = None; + self.sequence = None; self.cpu_frame_cache.lock().unwrap().clear(); // The sequence an in-flight full-res job may still be rendering is - // gone (the job holds its own addref'd copy, so it stays valid, but + // gone (the job holds its own project `Arc`, so it stays valid, but // its frame belongs to the dropped project): mark it stale. self.full_res_generation = self.full_res_generation.wrapping_add(1); self.tracks.clear(); @@ -1504,34 +1033,24 @@ impl RealEngine { } /// Refreshes the cached `Sequence` (name / format / length) from the - /// facade. + /// graph. fn refresh_sequence_info(&mut self) { - let Some(seq) = self.seq_ptr() else { + let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else { self.sequence_info = None; return; }; - let name = read_string(|buf, size| unsafe { oakengine_sequence_name(seq, buf, size) }); - let mut num: c_int = 0; - let mut den: c_int = 0; - let mut width: c_int = 0; - let mut height: c_int = 0; - let mut seconds: f64 = 0.0; - unsafe { - oakengine_sequence_get_frame_rate(seq, &mut num, &mut den); - oakengine_sequence_get_video_params( - seq, - &mut width, - &mut height, - std::ptr::null_mut(), - std::ptr::null_mut(), - ); - oakengine_sequence_get_length(seq, &mut seconds); - } - let rate = if num > 0 && den > 0 { - FrameRate::new(num as u32, den as u32) - } else { - VideoFormat::hd_1080p25().rate - }; + let guard = graphops::lock(&project); + let name = graphops::node_label(&guard.graph, seq); + let rate = graphops::sequence_video_params(&guard.graph, seq) + .map(|(_, _, r)| { + FrameRate::new(r.numerator().max(1) as u32, r.denominator().max(1) as u32) + }) + .unwrap_or(VideoFormat::hd_1080p25().rate); + let (width, height) = graphops::sequence_video_params(&guard.graph, seq) + .map(|(w, h, _)| (w.max(1) as u32, h.max(1) as u32)) + .unwrap_or((1920, 1080)); + let seconds = graphops::sequence_length(&guard.graph, seq); + let seconds = seconds.numerator() as f64 / seconds.denominator().max(1) as f64; let length = Frame((seconds * rate.num as f64 / rate.den as f64).round() as i64); self.sequence_info = Some(Sequence { name: if name.is_empty() { @@ -1540,35 +1059,30 @@ impl RealEngine { name }, format: VideoFormat { - width: width.max(1) as u32, - height: height.max(1) as u32, + width, + height, rate, }, length, }); } - /// Rebuilds the timeline snapshot from the facade sequence. + /// Rebuilds the timeline snapshot from the graph. fn rebuild_timeline(&mut self) { self.tracks.clear(); - let Some(seq) = self.seq_ptr() else { + let (Some(project), Some(seq)) = (self.project_ref(), self.sequence) else { return; }; - let mut video: c_int = 0; - let mut audio: c_int = 0; - let mut subtitle: c_int = 0; - unsafe { - oakengine_sequence_track_count(seq, &mut video, &mut audio, &mut subtitle); - } + let tb = self.time_base(); let mut out: Vec = Vec::new(); - // Per-type track lists, each displayed topmost-first. - for (kind, track_type, count) in [ - (TrackKind::Video, TRACK_TYPE_VIDEO, video), - (TrackKind::Audio, TRACK_TYPE_AUDIO, audio), - (TrackKind::Subtitle, TRACK_TYPE_SUBTITLE, subtitle), - ] { - for track_index in (0..count).rev() { - out.push(self.snapshot_track(kind, track_type, track_index as usize)); + { + let guard = graphops::lock(project); + // Per-type track lists, each displayed topmost-first. + for kind in [TrackType::Video, TrackType::Audio, TrackType::Subtitle] { + let tracks = graphops::track_ids(&guard.graph, seq, kind); + for (track_index, &track_id) in tracks.iter().enumerate().rev() { + out.push(Self::snapshot_track(&guard.graph, track_id, kind, track_index, tb)); + } } } self.tracks = out; @@ -1585,72 +1099,43 @@ impl RealEngine { } } - /// Snapshots one track (with its clips) from the facade. - fn snapshot_track(&self, kind: TrackKind, track_type: c_int, track_index: usize) -> RealTrack { - let Some(seq) = self.seq_ptr() else { - return RealTrack { - kind, - name: SharedString::new_static(""), - height: px(64.0), - locked: false, - muted: false, - solo: false, - visible: true, - clips: Vec::new(), - track_type, - track_index, - }; - }; + /// Snapshots one track (with its clips) from the graph. + fn snapshot_track( + graph: &oaknode::graph::Graph, + track: NodeId, + kind: TrackType, + track_index: usize, + tb: Option<(i64, i64)>, + ) -> RealTrack { let name = match kind { - TrackKind::Video => format!("V{}", track_index + 1), - TrackKind::Audio => format!("A{}", track_index + 1), - TrackKind::Subtitle => format!("S{}", track_index + 1), + TrackType::Video => format!("V{}", track_index + 1), + TrackType::Audio => format!("A{}", track_index + 1), + TrackType::Subtitle => format!("S{}", track_index + 1), }; // Height in internal units → pixels. - let mut internal: f64 = 0.0; - let height = unsafe { - if oakengine_track_get_height(seq, track_type, track_index as c_int, &mut internal) == 0 - { - px(oakengine_track_height_internal_to_pixels(internal).max(24) as f32) - } else { - px(64.0) - } - }; - - let clip_count = - unsafe { oakengine_sequence_clip_count(seq, track_type, track_index as c_int) }; - let mut clips = Vec::with_capacity(clip_count.max(0) as usize); - for clip_index in 0..clip_count.max(0) { - let clip = unsafe { - oakengine_sequence_clip_at(seq, track_type, track_index as c_int, clip_index) - }; - if clip.is_null() { - continue; - } - let mut in_ts: i64 = 0; - let mut out_ts: i64 = 0; - let mut media_in: i64 = 0; - unsafe { - oakengine_clip_get_range(clip, &mut in_ts, &mut out_ts, &mut media_in); - free_box(clip); - } - clips.push(RealClip { - id: ClipId( - (track_type as u64) * 1_000_000 - + (track_index as u64 + 1) * 1000 - + clip_index as u64, - ), - range: FrameRange::new(Frame(in_ts), Frame(out_ts)), - media_in: Frame(media_in), - label: format!("Clip {}", clip_index + 1).into(), - color: clip_color(clip_index as u64), - track_type: kind, - track_index, - clip_index: clip_index as usize, - }); - } + let height = graphops::track_behavior(graph, track) + .map(|t| { + px(oaknode::track::internal_height_to_pixel_height(t.height).max(24) as f32) + }) + .unwrap_or(px(64.0)); + let clips = graphops::clip_ids(graph, track) + .iter() + .enumerate() + .filter_map(|(clip_index, &block)| { + let (in_r, out_r, media_r) = graphops::clip_range(graph, block)?; + let to_ts = |r: oakcore_rs::Rational| tb.map(|tb| graphops::rational_to_ts(r, tb)).unwrap_or(0); + Some(RealClip { + id: ClipId(block.identity()), + range: FrameRange::new(Frame(to_ts(in_r)), Frame(to_ts(out_r))), + media_in: Frame(to_ts(media_r)), + label: format!("Clip {}", clip_index + 1).into(), + color: clip_color(clip_index as u64), + block, + }) + }) + .collect(); RealTrack { - kind, + kind: track_kind_of(kind), name: name.into(), height, locked: false, @@ -1658,12 +1143,11 @@ impl RealEngine { solo: false, visible: true, clips, - track_type, + track, track_index, } } - /// Rebuilds the material-bin snapshot from the facade project's footage. /// The waveform cache (created lazily at the current frame rate). fn waveform_cache(&self) -> Option> { let mut slot = self.waveforms.lock().unwrap_or_else(|e| e.into_inner()); @@ -1684,111 +1168,39 @@ impl RealEngine { cache: Arc, clip: &RealClip, ) { - let Some(seq) = self.seq_ptr() else { + let Some(project) = self.project_ref() else { return; }; - let clip_ptr = unsafe { - oakengine_sequence_clip_at( - seq, - TRACK_TYPE_AUDIO, - clip.track_index as c_int, - clip.clip_index as c_int, - ) + let filename = { + let guard = graphops::lock(project); + graphops::clip_media_filename(&guard.graph, clip.block) }; - if clip_ptr.is_null() { + let Some(filename) = filename else { return; - } - let filename = read_string(|buf, size| unsafe { - oakengine_clip_get_media_filename(clip_ptr, buf, size) - }); - unsafe { free_box(clip_ptr) }; - if filename.is_empty() { - return; - } + }; let duration_frames = (clip.range.end.0 - clip.range.start.0).max(1); cache.refresh(clip.id.0, &filename, duration_frames); } - /// Looks up the snapshot clip coordinates by `ClipId`. - fn clip_coords(&self, id: ClipId) -> Option<(TrackKind, usize, usize)> { - for track in &self.tracks { - if let Some(clip) = track.clips.iter().find(|c| c.id() == id) { - return Some((clip.track_type, clip.track_index, clip.clip_index)); - } - } - None + /// Looks up the snapshot clip's block node by `ClipId` (the id IS the + /// block's stable identity). + fn clip_block(&self, id: ClipId) -> Option { + let block = graphops::id_of(id.0)?; + let project = self.project_ref()?; + let guard = graphops::lock(project); + graphops::clip_behavior(&guard.graph, block)?; + // The clip must be on the CURRENT timeline (a stale id of a + // removed clip must not resolve). + self.tracks + .iter() + .any(|t| t.clips.iter().any(|c| c.id == id)) + .then_some(block) } - /// The selected clip's facade node view (a boxed node handle the - /// caller frees with `oakengine_node_free`), or `None` when no single - /// clip is selected or the clip box resolves to no node. - fn selected_clip_node(&self) -> Option<*mut OakEngineNode> { - let clip_id = self.selected_clip?; - let (kind, track_index, clip_index) = self.clip_coords(clip_id)?; - let seq = self.seq_ptr()?; - let clip = unsafe { - oakengine_sequence_clip_at( - seq, - Self::track_type_of(kind), - track_index as c_int, - clip_index as c_int, - ) - }; - if clip.is_null() { - return None; - } - let node = unsafe { oakengine_clip_as_node(clip) }; - // SAFETY: the clip box is a plain facade box (see `free_box`); the - // node box is independent (the facade boxed its own handle copy). - unsafe { free_box(clip) }; - // SAFETY: `node` is a fresh box the caller frees, or NULL. - if node.is_null() { - None - } else { - Some(node) - } - } - - /// Whether `node` can host effects (its effect-input id is non-empty; - /// the facade reports `E_NOT_FOUND` for nodes without one). - /// - /// # Safety - /// `node` must be a live node box (NULL reports false). - unsafe fn node_hosts_effects(node: *mut OakEngineNode) -> bool { - let mut buf = [0 as c_char; 64]; - let mut element: c_int = 0; - unsafe { - oakengine_node_get_effect_input( - node, - buf.as_mut_ptr(), - buf.len() as c_int, - &mut element, - ) >= 0 - } - } - - /// The effect in `host`'s chain whose identity is `identity` (a boxed - /// node handle the caller frees), or `None` when not a member. - /// - /// # Safety - /// `host` must be a live node box. - unsafe fn chain_effect_by_identity( - host: *mut OakEngineNode, - identity: u64, - ) -> Option<*mut OakEngineNode> { - let count = unsafe { oakengine_node_effect_count(host) }; - for i in 0..count.max(0) { - let effect = unsafe { oakengine_node_effect_at(host, i) }; - if effect.is_null() { - continue; - } - if unsafe { oakengine_node_identity(effect) } == identity { - return Some(effect); - } - // SAFETY: `effect` is a box from `oakengine_node_effect_at`. - unsafe { oakengine_node_free(effect) }; - } - None + /// The selected clip's block node, or `None` when no single clip is + /// selected. + fn selected_clip_node(&self) -> Option { + self.clip_block(self.selected_clip?) } /// The display label of the selected clip (its timeline snapshot @@ -1808,68 +1220,37 @@ impl RealEngine { /// cannot host effects yields an empty list (its `target_label` is /// `None`, so the stack shows the empty state). fn selected_effect_cards(&self) -> Vec> { - let Some(node) = self.selected_clip_node() else { + let Some(project) = self.project_ref() else { return Vec::new(); }; + let Some(host) = self.selected_clip_node() else { + return Vec::new(); + }; + let guard = graphops::lock(project); let mut out: Vec> = Vec::new(); - // SAFETY: `node` is a live box; freed on every return path. - unsafe { - if !Self::node_hosts_effects(node) { - oakengine_node_free(node); - return out; - } - let count = oakengine_node_effect_count(node); - for i in 0..count.max(0) { - let effect = oakengine_node_effect_at(node, i); - if effect.is_null() { - continue; - } - let identity = oakengine_node_identity(effect); - let type_id = read_string(|buf, size| { - // SAFETY: `effect` is a live box; buf/size follow the - // facade two-stage convention (the enclosing `unsafe` - // block covers this closure body). - oakengine_node_get_type_id(effect, buf, size) - }); - let title = CString::new(type_id.clone()) - .ok() - .map(|c| { - read_string(|buf, size| { - // SAFETY: as above; `c` outlives the call. - oakengine_node_factory_name_from_id(c.as_ptr(), buf, size) - }) - }) - .filter(|n| !n.is_empty()) - .unwrap_or(type_id); - let enabled = oakengine_node_is_enabled(effect) != 0; - let expanded = self.expanded_effects.contains(&identity); - out.push(Arc::new(RealEffect { - id: EffectId(identity), - title: title.into(), - enabled, - expanded, - }) as Arc); - // SAFETY: `effect` is a box from `oakengine_node_effect_at`. - oakengine_node_free(effect); - } - oakengine_node_free(node); + for node in super::effectchain::chain(&guard.graph, host) { + let identity = node.identity(); + let type_id = graphops::node_type_id(&guard.graph, node); + let title = oaknode::factory::Factory::global() + .find(&type_id) + .map(|m| m.name.to_string()) + .filter(|n| !n.is_empty()) + .unwrap_or(type_id); + out.push(Arc::new(RealEffect { + id: EffectId(identity), + title: title.into(), + enabled: super::effectchain::is_enabled(&guard.graph, node), + expanded: self.expanded_effects.contains(&identity), + }) as Arc); } out } - /// The facade track-type constant for a [`TrackKind`]. - fn track_type_of(kind: TrackKind) -> c_int { - match kind { - TrackKind::Video => TRACK_TYPE_VIDEO, - TrackKind::Audio => TRACK_TYPE_AUDIO, - TrackKind::Subtitle => TRACK_TYPE_SUBTITLE, - } - } - - /// Applies an edit command, then refreshes the snapshots and repaints. - fn apply_edit(&mut self, rc: c_int, what: &str, cx: &mut Context) { - if rc != 0 { - println!("[real engine] {what} failed (facade error {rc})"); + /// Applies an edit command's result, then refreshes the snapshots and + /// repaints. + fn apply_edit(&mut self, result: Result<(), String>, what: &str, cx: &mut Context) { + if let Err(error) = result { + println!("[real engine] {what} failed: {error}"); } self.refresh_sequence_info(); self.rebuild_timeline(); @@ -1879,16 +1260,6 @@ impl RealEngine { self.full_res_generation = self.full_res_generation.wrapping_add(1); cx.notify(); } - - /// Formats a facade error (two-stage task error buffer) into a message. - fn task_error(task: *mut OakEngineTask) -> String { - let text = read_string(|buf, size| unsafe { oakengine_task_error(task, buf, size) }); - if text.is_empty() { - "the task failed".to_string() - } else { - text - } - } } // --------------------------------------------------------------------------- @@ -2024,45 +1395,20 @@ impl TimelineDataSource for RealEngine { } fn markers(&self) -> Vec { - let Some(seq) = self.seq_ptr() else { + let Some(markers) = &self.markers else { return Vec::new(); }; - let count = unsafe { oakengine_sequence_marker_count(seq) }; - if count <= 0 { + let Some(tb) = self.time_base() else { return Vec::new(); - } - let mut out = Vec::with_capacity(count as usize); - for index in 0..count { - let mut time: i64 = 0; - let mut name_buf = [0 as c_char; 128]; - let mut color: c_int = 0; - let rc = unsafe { - oakengine_sequence_marker_at( - seq, - index, - &mut time, - name_buf.as_mut_ptr(), - name_buf.len() as c_int, - &mut color, - ) - }; - if rc != 0 { - continue; - } - let len = name_buf.iter().position(|&c| c == 0).unwrap_or(name_buf.len()); - let name: SharedString = - String::from_utf8_lossy(unsafe { - std::slice::from_raw_parts(name_buf.as_ptr() as *const u8, len) - }) - .into_owned() - .into(); - out.push(Marker { - frame: Frame(time), - label: name, + }; + graphops::markers_of(&markers.0) + .into_iter() + .map(|(time, name, color)| Marker { + frame: Frame(graphops::rational_to_ts(time, tb)), + label: name.into(), color: Some(marker_color(color)), - }); - } - out + }) + .collect() } } @@ -2073,12 +1419,13 @@ impl EffectStackDataSource for RealEngine { fn target_label(&self) -> Option { let label = self.selected_clip_label()?; - let node = self.selected_clip_node()?; - // SAFETY: `node` is a live box; freed below. A clip that cannot - // host effects keeps the empty state (no label, no cards). - let hosts = unsafe { Self::node_hosts_effects(node) }; - unsafe { oakengine_node_free(node) }; - hosts.then_some(label) + let project = self.project_ref()?; + let host = self.selected_clip_node()?; + let guard = graphops::lock(project); + // A clip that cannot host effects keeps the empty state (no label, + // no cards). + super::effectchain::effect_input_of(&guard.graph, host)?; + Some(label) } } @@ -2087,59 +1434,54 @@ impl NodeGraphDataSource for RealEngine { type Edge = RealEdge; fn nodes(&self) -> Vec { - // SAFETY: the sequence box is live while the engine holds it. - unsafe { - crate::oakui::nodegraph::build_graph(self.seq_ptr().unwrap_or(std::ptr::null_mut())).0 - } + let (Some(project), Some(seq)) = (self.project_ref(), self.sequence) else { + return Vec::new(); + }; + crate::oakui::nodegraph::build_graph(project, seq).0 } fn edges(&self) -> Vec { - // SAFETY: the sequence box is live while the engine holds it. - unsafe { - crate::oakui::nodegraph::build_graph(self.seq_ptr().unwrap_or(std::ptr::null_mut())).1 - } + let (Some(project), Some(seq)) = (self.project_ref(), self.sequence) else { + return Vec::new(); + }; + crate::oakui::nodegraph::build_graph(project, seq).1 } - fn can_connect(&self, from: PortId, to: PortId) -> bool { - // SAFETY: the sequence box is live while the engine holds it. - unsafe { - crate::oakui::nodegraph::can_connect( - self.seq_ptr().unwrap_or(std::ptr::null_mut()), - from, - to, - ) - } + fn can_connect(&self, from: gpui::node_graph::PortId, to: gpui::node_graph::PortId) -> bool { + let Some(project) = self.project_ref() else { + return false; + }; + crate::oakui::nodegraph::can_connect(project, from, to) } } impl ProjectDataSource for RealEngine { fn roots(&self) -> Vec { - // SAFETY: the project box is live while the engine holds it. - unsafe { - crate::oakui::projectbrowser::roots(self.project_ptr().unwrap_or(std::ptr::null_mut())) - } + let Some(project) = self.project_ref() else { + return Vec::new(); + }; + crate::oakui::projectbrowser::roots(project) } fn children(&self, parent_id: u64) -> Vec { - // SAFETY: the project box is live while the engine holds it. - unsafe { - crate::oakui::projectbrowser::children( - self.project_ptr().unwrap_or(std::ptr::null_mut()), - parent_id, - ) - } + let Some(project) = self.project_ref() else { + return Vec::new(); + }; + crate::oakui::projectbrowser::children(project, parent_id) } } impl AudioMeterDataSource for RealEngine { fn levels(&self) -> Vec { // Per-channel linear peaks of the engine's buffered audio output - // (facade `oakengine_audio_output_levels`, clamped to the meter's - // 0..1 range). Silent when nothing has been pushed to the output - // (no playback audio path yet) or on any facade error. + // (oakaudio's manager, clamped to the meter's 0..1 range). Silent + // when nothing has been pushed to the output (no playback audio + // path yet) or without an AudioManager instance. + let Some(manager) = oakaudio::manager::instance() else { + return vec![0.0, 0.0]; + }; let mut peaks = [0.0f32; 8]; - // SAFETY: `peaks` is a live 8-entry buffer; capacity matches. - let n = unsafe { oakengine_audio_output_levels(peaks.as_mut_ptr(), peaks.len() as c_int) }; + let n = manager.output_levels(&mut peaks).unwrap_or(0); if n <= 0 { return vec![0.0, 0.0]; } @@ -2180,7 +1522,7 @@ impl AppEngine for RealEngine { if let Some(image) = cache.entry(monitor).or_default().image_for(frame.0) { return image.clone(); } - // Both monitors render through the facade CPU renderer (falling + // Both monitors render through the oakrender ticket arena (falling // back to the synthetic pattern when rendering is unavailable): the // program monitor renders the current sequence, the source monitor // renders the currently selected footage node at the source clock's @@ -2222,46 +1564,36 @@ impl AppEngine for RealEngine { } fn add_track(&mut self, kind: TrackKind, cx: &mut Context) { - let Some(seq) = self.seq_ptr() else { + let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else { return; }; - let rc = unsafe { oakengine_sequence_add_track(seq, Self::track_type_of(kind)) }; - self.apply_edit(rc, "add track", cx); + let result = graphops::add_track(&project, seq, track_type_of(kind)).map(|_| ()); + self.apply_edit(result, "add track", cx); } fn remove_track(&mut self, index: usize, cx: &mut Context) { let Some(track) = self.tracks.get(index) else { return; }; - let (track_type, track_index) = (track.track_type, track.track_index); - let Some(seq) = self.seq_ptr() else { + let track_id = track.track; + let Some(project) = self.project.clone() else { return; }; - let rc = unsafe { oakengine_sequence_remove_track(seq, track_type, track_index as c_int) }; - self.apply_edit(rc, "remove track", cx); + self.apply_edit(graphops::remove_track(&project, track_id), "remove track", cx); } fn set_track_height(&mut self, height: Pixels, cx: &mut Context) { - let Some(seq) = self.seq_ptr() else { + let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else { return; }; - let internal = - unsafe { oakengine_track_height_pixels_to_internal(f32::from(height) as c_int) }; - let mut video: c_int = 0; - let mut audio: c_int = 0; - let mut subtitle: c_int = 0; - unsafe { - oakengine_sequence_track_count(seq, &mut video, &mut audio, &mut subtitle); - } - for (track_type, count) in [ - (TRACK_TYPE_VIDEO, video), - (TRACK_TYPE_AUDIO, audio), - (TRACK_TYPE_SUBTITLE, subtitle), - ] { - for index in 0..count { - unsafe { oakengine_track_set_height(seq, track_type, index, internal) }; + let internal = oaknode::track::pixel_height_to_internal_height(f32::from(height) as i32); + let guard = graphops::lock(&project); + for kind in [TrackType::Video, TrackType::Audio, TrackType::Subtitle] { + for track in graphops::track_ids(&guard.graph, seq, kind) { + graphops::set_track_height(&project, track, internal); } } + drop(guard); self.rebuild_timeline(); cx.notify(); } @@ -2271,9 +1603,9 @@ impl AppEngine for RealEngine { self.selected_item = Some(id); if changed { // The source monitor renders the selected footage node: a new - // selection must rebind the renderer and drop the stale cached - // frame (the cache key only tracks the playhead frame), and any - // in-flight full-res job for the old selection is stale. + // selection must drop the stale cached frame (the cache key only + // tracks the playhead frame), and any in-flight full-res job for + // the old selection is stale. *self.source_renderer.lock().unwrap() = RendererSlot::Untried; self.cpu_frame_cache.lock().unwrap().remove(&Monitor::Source); self.full_res_generation = self.full_res_generation.wrapping_add(1); @@ -2290,41 +1622,7 @@ impl AppEngine for RealEngine { } fn addable_effects(&self) -> Vec<(String, String)> { - // The factory entries flagged `video_effect` and not hidden from - // the create menu (per the facade contract). A scratch node per - // entry just to read its flags (freed immediately). - let mut out = Vec::new(); - let count = unsafe { oakengine_node_factory_id_count() }; - let video_flag = unsafe { oakengine_node_flag_video_effect() }; - let hidden_flag = unsafe { oakengine_node_flag_dont_show_in_create_menu() }; - for i in 0..count.max(0) { - let type_id = - read_string(|buf, size| unsafe { oakengine_node_factory_id_at(i, buf, size) }); - let Some(c_id) = CString::new(type_id.clone()).ok() else { - continue; - }; - let node = unsafe { oakengine_node_factory_create_from_id(c_id.as_ptr()) }; - if node.is_null() { - continue; - } - let flags = unsafe { oakengine_node_get_flags(node) }; - // SAFETY: `node` is an owned box from the factory. - unsafe { oakengine_node_free(node) }; - if flags & video_flag != 0 && flags & hidden_flag == 0 { - let name = read_string(|buf, size| { - // SAFETY: `c_id` outlives the call; buf/size follow the - // facade two-stage convention. - unsafe { oakengine_node_factory_name_from_id(c_id.as_ptr(), buf, size) } - }); - let name = if name.is_empty() { - type_id.clone() - } else { - name - }; - out.push((type_id, name)); - } - } - out + super::effectchain::addable_effects() } fn add_effect( @@ -2333,40 +1631,32 @@ impl AppEngine for RealEngine { type_id: &str, cx: &mut Context, ) -> Result<(), String> { + let Some(project) = self.project.clone() else { + return Err("no project open".into()); + }; let Some(host) = self.selected_clip_node() else { return Err("no selected clip".into()); }; - let c_id = CString::new(type_id).map_err(|_| "invalid effect type id".to_string())?; - // SAFETY: `host` is a live box; freed below. - let rc = unsafe { oakengine_node_effect_insert(host, index as c_int, c_id.as_ptr()) }; - unsafe { oakengine_node_free(host) }; - if rc != 0 { - return Err(format!("facade error {rc}")); - } - self.apply_edit(rc, "add effect", cx); - Ok(()) + let result = super::effectchain::insert(&project, host, index, type_id).map(|_| ()); + self.apply_edit(result.clone(), "add effect", cx); + result } fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context) { match event { EffectStackEvent::EnableToggled { effect, enabled } => { - let Some(host) = self.selected_clip_node() else { + let (Some(project), Some(_host)) = + (self.project.clone(), self.selected_clip_node()) + else { cx.notify(); return; }; - // SAFETY: both boxes are live and freed below. - let rc = unsafe { - let Some(eff) = Self::chain_effect_by_identity(host, effect.0) else { - oakengine_node_free(host); - cx.notify(); - return; - }; - let rc = oakengine_node_effect_set_enabled(eff, *enabled as c_int); - oakengine_node_free(eff); - oakengine_node_free(host); - rc + let Some(node) = graphops::id_of(effect.0) else { + cx.notify(); + return; }; - self.apply_edit(rc, "toggle effect", cx); + let result = super::effectchain::set_enabled(&project, node, *enabled); + self.apply_edit(result, "toggle effect", cx); } EffectStackEvent::ExpansionToggled { effect, expanded } => { // View state only (not undoable); kept here so the card @@ -2379,42 +1669,30 @@ impl AppEngine for RealEngine { cx.notify(); } EffectStackEvent::RemoveRequested(id) => { - let Some(host) = self.selected_clip_node() else { + let (Some(project), Some(host)) = (self.project.clone(), self.selected_clip_node()) + else { cx.notify(); return; }; - // SAFETY: both boxes are live and freed below. - let rc = unsafe { - let Some(eff) = Self::chain_effect_by_identity(host, id.0) else { - oakengine_node_free(host); - cx.notify(); - return; - }; - let rc = oakengine_node_effect_remove(host, eff); - oakengine_node_free(eff); - oakengine_node_free(host); - rc + let Some(node) = graphops::id_of(id.0) else { + cx.notify(); + return; }; - self.apply_edit(rc, "remove effect", cx); + let result = super::effectchain::remove(&project, host, node); + self.apply_edit(result, "remove effect", cx); } EffectStackEvent::ReorderRequested { effect, new_index } => { - let Some(host) = self.selected_clip_node() else { + let (Some(project), Some(host)) = (self.project.clone(), self.selected_clip_node()) + else { cx.notify(); return; }; - // SAFETY: both boxes are live and freed below. - let rc = unsafe { - let Some(eff) = Self::chain_effect_by_identity(host, effect.0) else { - oakengine_node_free(host); - cx.notify(); - return; - }; - let rc = oakengine_node_effect_move(host, eff, *new_index as c_int); - oakengine_node_free(eff); - oakengine_node_free(host); - rc + let Some(node) = graphops::id_of(effect.0) else { + cx.notify(); + return; }; - self.apply_edit(rc, "reorder effect", cx); + let result = super::effectchain::move_effect(&project, host, node, *new_index); + self.apply_edit(result, "reorder effect", cx); } EffectStackEvent::AddRequested { index } => { // The effect choice is a panel-owned menu (see @@ -2445,15 +1723,11 @@ impl AppEngine for RealEngine { | NodeGraphEvent::BackgroundClicked { .. } | NodeGraphEvent::SelectionChanged { .. } => {} _ => { - // SAFETY: the sequence box is live while the engine holds it. - let result = unsafe { - crate::oakui::nodegraph::apply_edit( - self.seq_ptr().unwrap_or(std::ptr::null_mut()), - event, - ) - }; - if let Err(e) = result { - println!("[real engine] node-graph request rejected: {e}"); + if let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) { + let result = crate::oakui::nodegraph::apply_edit(&project, seq, event); + if let Err(e) = result { + println!("[real engine] node-graph request rejected: {e}"); + } } } } @@ -2473,92 +1747,68 @@ impl AppEngine for RealEngine { edge, new_frame, } => { - let Some((track_type, track_index, clip_index)) = self.clip_coords(*clip) else { + let Some(block) = self.clip_block(*clip) else { + return; + }; + let Some(project) = self.project.clone() else { return; }; // Re-read the clip's current range, then compute the new - // in/out pair for `oakengine_clip_trim`. - let Some(seq) = self.seq_ptr() else { + // in/out pair for the trim. + let Some(tb) = self.time_base() else { return; }; - let clip_ptr = unsafe { - oakengine_sequence_clip_at( - seq, - Self::track_type_of(track_type), - track_index as c_int, - clip_index as c_int, + let (in_ts, out_ts) = { + let guard = graphops::lock(&project); + let Some((in_r, out_r, _)) = graphops::clip_range(&guard.graph, block) else { + return; + }; + ( + graphops::rational_to_ts(in_r, tb), + graphops::rational_to_ts(out_r, tb), ) }; - if clip_ptr.is_null() { - return; - } - let mut in_ts: i64 = 0; - let mut out_ts: i64 = 0; - let mut media_in: i64 = 0; - unsafe { - oakengine_clip_get_range(clip_ptr, &mut in_ts, &mut out_ts, &mut media_in); - } let (new_in, new_out) = match edge { TrimEdge::Start => (new_frame.0, out_ts), TrimEdge::End => (in_ts, new_frame.0), }; - let rc = unsafe { oakengine_clip_trim(clip_ptr, new_in, new_out) }; - unsafe { free_box(clip_ptr) }; - self.apply_edit(rc, "trim clip", cx); + let result = graphops::trim_clip(&project, block, new_in, new_out); + self.apply_edit(result, "trim clip", cx); } TimelineEvent::ClipMoveRequested { clip, new_track, new_start, } => { - // M12 P4: cross-track moves go through the dedicated - // facade export (one undoable entry); same-track moves - // use the classic export. - let Some((track_type, track_index, clip_index)) = self.clip_coords(*clip) else { + // Cross-track moves go through the gap + re-home + place + // composition (one undoable entry); same-track moves use + // the plain move command. + let Some(block) = self.clip_block(*clip) else { return; }; - let Some(seq) = self.seq_ptr() else { + let Some(project) = self.project.clone() else { return; }; - let rc = if *new_track as c_int != track_index as c_int { - unsafe { - oakengine_sequence_move_clip_to_track( - seq, - Self::track_type_of(track_type), - track_index as c_int, - clip_index as c_int, - *new_track as c_int, - new_start.0, - ) + let current_track = self + .tracks + .iter() + .position(|t| t.clips.iter().any(|c| c.block == block)); + let result = match (current_track, self.tracks.get(*new_track)) { + (Some(current), _) if current == *new_track => { + graphops::move_clip(&project, block, new_start.0) } - } else { - unsafe { - oakengine_sequence_move_clip( - seq, - Self::track_type_of(track_type), - track_index as c_int, - clip_index as c_int, - new_start.0, - ) + (_, Some(dest)) => { + graphops::move_clip_to_track(&project, block, dest.track, new_start.0) } + _ => Err("move clip: destination track out of range".to_string()), }; - self.apply_edit(rc, "move clip", cx); + self.apply_edit(result, "move clip", cx); } TimelineEvent::TrackHeightChanged { track, height } => { - if let Some(t) = self.tracks.get(*track) { - let internal = unsafe { - oakengine_track_height_pixels_to_internal(f32::from(height) as c_int) - }; - if let Some(seq) = self.seq_ptr() { - unsafe { - oakengine_track_set_height( - seq, - t.track_type, - t.track_index as c_int, - internal, - ) - }; - } + if let (Some(t), Some(project)) = (self.tracks.get(*track), self.project.clone()) { + let internal = + oaknode::track::pixel_height_to_internal_height(f32::from(*height) as i32); + graphops::set_track_height(&project, t.track, internal); self.rebuild_timeline(); } cx.notify(); @@ -2568,116 +1818,105 @@ impl AppEngine for RealEngine { | TimelineEvent::TrackSelected { .. } | TimelineEvent::TransitionChanged { .. } | TimelineEvent::ZoomChanged(_) => {} - TimelineEvent::WorkAreaPreview { start, end } => { - self.set_workarea_preview(*start, *end, cx); - } - TimelineEvent::WorkAreaCommitted { - start, - end, - old_start, - old_end, - } => { - self.commit_workarea(*old_start, *old_end, *start, *end, cx); - } + TimelineEvent::WorkAreaPreview { start, end } => { + self.set_workarea_preview(*start, *end, cx); + } + TimelineEvent::WorkAreaCommitted { + start, + end, + old_start, + old_end, + } => { + self.commit_workarea(*old_start, *old_end, *start, *end, cx); + } } } fn split_clip(&mut self, clip: ClipId, time: Frame, cx: &mut Context) { - let Some((track_type, track_index, clip_index)) = self.clip_coords(clip) else { + let (Some(block), Some(project)) = (self.clip_block(clip), self.project.clone()) else { return; }; - let Some(seq) = self.seq_ptr() else { - return; - }; - let rc = unsafe { - oakengine_sequence_split_clip( - seq, - Self::track_type_of(track_type), - track_index as c_int, - clip_index as c_int, - time.0, - ) - }; - self.apply_edit(rc, "split clip", cx); + let result = graphops::split_clip(&project, block, time.0); + self.apply_edit(result, "split clip", cx); } fn split_at_playhead(&mut self, cx: &mut Context) { let frame = self.clock_frame(Monitor::Program, cx); - let Some(seq) = self.seq_ptr() else { + let Some(project) = self.project.clone() else { return; }; - let targets: Vec<(c_int, usize, usize)> = self + let targets: Vec = self .tracks .iter() .flat_map(|track| { track.clips.iter().filter_map(|clip| { if clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0 { - Some((track.track_type, track.track_index, clip.clip_index)) + Some(clip.block) } else { None } }) }) .collect(); - let mut rc = 0; - for (track_type, track_index, clip_index) in targets { - rc = unsafe { - oakengine_sequence_split_clip( - seq, - track_type, - track_index as c_int, - clip_index as c_int, - frame.0, - ) - }; + let mut result = Ok(()); + for block in targets { + result = graphops::split_clip(&project, block, frame.0); } - self.apply_edit(rc, "split at playhead", cx); + self.apply_edit(result, "split at playhead", cx); } fn workarea(&self) -> Option<(Frame, Frame)> { - let seq = self.seq_ptr()?; - if unsafe { oakengine_sequence_workarea_is_enabled(seq) } == 0 { + let wa = self.workarea.as_ref()?; + let tb = self.time_base()?; + let (enabled, range) = graphops::workarea_state(&wa.0)?; + if !enabled { return None; } - let mut in_ts: i64 = 0; - let mut out_ts: i64 = 0; - if unsafe { oakengine_sequence_get_workarea(seq, &mut in_ts, &mut out_ts) } != 0 { - return None; - } - Some((Frame(in_ts), Frame(out_ts))) + Some(( + Frame(graphops::rational_to_ts(range.in_(), tb)), + Frame(graphops::rational_to_ts(range.out(), tb)), + )) } fn add_marker_at_playhead(&mut self, cx: &mut Context) { - let Some(seq) = self.seq_ptr() else { + let (Some(markers), Some(tb)) = (&self.markers, self.time_base()) else { return; }; let frame = self.clock_frame(Monitor::Program, cx); - let rc = unsafe { oakengine_sequence_marker_add(seq, frame.0, c"".as_ptr()) }; - self.apply_edit(rc, "add marker", cx); + let time = graphops::ts_to_rational(frame.0, tb); + let result = graphops::marker_add(&markers.0, time, "", 0); + self.apply_edit(result, "add marker", cx); } fn remove_marker_at_playhead(&mut self, cx: &mut Context) { - let Some(seq) = self.seq_ptr() else { + let (Some(markers), Some(tb)) = (&self.markers, self.time_base()) else { return; }; let frame = self.clock_frame(Monitor::Program, cx); - let rc = unsafe { oakengine_sequence_marker_remove(seq, frame.0) }; + let time = graphops::ts_to_rational(frame.0, tb); // Removing a marker that is not there is a benign no-op for the menu - // action (the facade reports NOT_FOUND); only rebuild on success. - if rc != 0 { + // action; only rebuild on success. + let Ok(()) = graphops::marker_remove(&markers.0, time) else { return; - } - self.apply_edit(rc, "remove marker", cx); + }; + self.apply_edit(Ok(()), "remove marker", cx); } fn set_workarea_preview(&mut self, start: Frame, end: Frame, cx: &mut Context) { - let Some(seq) = self.seq_ptr() else { + let (Some(wa), Some(tb)) = (&self.workarea, self.time_base()) else { return; }; // Live, non-undoable: the engine workarea tracks the drag so other // reads (export, snap) stay current; no timeline rebuild needed — the // band itself is widget-local state. - unsafe { oakengine_sequence_set_workarea(seq, 1, start.0, end.0) }; + graphops::workarea_set( + &wa.0, + true, + oakcore_rs::TimeRange::new( + graphops::ts_to_rational(start.0, tb), + graphops::ts_to_rational(end.0, tb), + ), + ); cx.notify(); } @@ -2689,86 +1928,55 @@ impl AppEngine for RealEngine { end: Frame, cx: &mut Context, ) { - let Some(seq) = self.seq_ptr() else { + let (Some(wa), Some(tb)) = (&self.workarea, self.time_base()) else { return; }; - let rc = unsafe { - oakengine_sequence_set_workarea_undoable( - seq, - 1, - start.0, - end.0, - old_start.0, - old_end.0, - ) - }; - self.apply_edit(rc, "set workarea", cx); + let result = graphops::workarea_set_undoable( + &wa.0, + true, + oakcore_rs::TimeRange::new( + graphops::ts_to_rational(start.0, tb), + graphops::ts_to_rational(end.0, tb), + ), + oakcore_rs::TimeRange::new( + graphops::ts_to_rational(old_start.0, tb), + graphops::ts_to_rational(old_end.0, tb), + ), + ); + self.apply_edit(result, "set workarea", cx); } fn clear_workarea(&mut self, cx: &mut Context) { - let Some(seq) = self.seq_ptr() else { + let (Some(wa), Some(tb)) = (&self.workarea, self.time_base()) else { return; }; let (old_start, old_end) = self.workarea().unwrap_or((Frame::ZERO, Frame::ZERO)); - let rc = unsafe { - oakengine_sequence_set_workarea_undoable( - seq, - 0, - old_start.0, - old_end.0, - old_start.0, - old_end.0, - ) - }; - self.apply_edit(rc, "clear workarea", cx); + let result = graphops::workarea_set_undoable( + &wa.0, + false, + oakcore_rs::TimeRange::new( + graphops::ts_to_rational(old_start.0, tb), + graphops::ts_to_rational(old_end.0, tb), + ), + oakcore_rs::TimeRange::new( + graphops::ts_to_rational(old_start.0, tb), + graphops::ts_to_rational(old_end.0, tb), + ), + ); + self.apply_edit(result, "clear workarea", cx); } fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context) { - let Some((track_type, track_index, clip_index)) = self.clip_coords(clip) else { + let (Some(block), Some(project)) = (self.clip_block(clip), self.project.clone()) else { return; }; - let Some(seq) = self.seq_ptr() else { - return; - }; - let rc = if ripple { - unsafe { - oakengine_sequence_ripple_delete_clip( - seq, - Self::track_type_of(track_type), - track_index as c_int, - clip_index as c_int, - ) - } + let result = if ripple { + graphops::ripple_delete_clip(&project, block) } else { - let clip_ptr = unsafe { - oakengine_sequence_clip_at( - seq, - Self::track_type_of(track_type), - track_index as c_int, - clip_index as c_int, - ) - }; - if clip_ptr.is_null() { - return; - } - let mut clips = [clip_ptr]; - let mut rippled: c_int = 0; - let rc = unsafe { - oakengine_sequence_delete_clips( - seq, - clips.as_mut_ptr(), - 1, - 0, - std::ptr::null(), - 0, - &mut rippled, - ) - }; - unsafe { free_box(clip_ptr) }; - rc + graphops::delete_clip(&project, block) }; self.apply_edit( - rc, + result, if ripple { "ripple delete clip" } else { @@ -2779,22 +1987,16 @@ impl AppEngine for RealEngine { } fn can_undo(&self) -> bool { - self.project_ptr() - .map(|p| unsafe { oakengine_project_can_undo(p) } != 0) - .unwrap_or(false) + self.project.is_some() && oakundo::global::undoable() } fn can_redo(&self) -> bool { - self.project_ptr() - .map(|p| unsafe { oakengine_project_can_redo(p) } != 0) - .unwrap_or(false) + self.project.is_some() && oakundo::global::redoable() } fn undo(&mut self, cx: &mut Context) { - if let Some(p) = self.project_ptr() { - unsafe { - oakengine_project_undo(p); - } + if self.project.is_some() { + oakundo::global::undo().ok(); self.refresh_sequence_info(); self.rebuild_timeline(); self.cpu_frame_cache.lock().unwrap().clear(); @@ -2804,10 +2006,8 @@ impl AppEngine for RealEngine { } fn redo(&mut self, cx: &mut Context) { - if let Some(p) = self.project_ptr() { - unsafe { - oakengine_project_redo(p); - } + if self.project.is_some() { + oakundo::global::redo().ok(); self.refresh_sequence_info(); self.rebuild_timeline(); self.cpu_frame_cache.lock().unwrap().clear(); @@ -2817,16 +2017,7 @@ impl AppEngine for RealEngine { } fn new_project(&mut self, cx: &mut Context) { - let project = unsafe { oakengine_project_create() }; - if project.is_null() { - println!("[real engine] failed to create a blank project"); - return; - } - if unsafe { oakengine_project_new(project) } != 0 { - unsafe { oakengine_project_free(project) }; - println!("[real engine] failed to initialize a blank project"); - return; - } + let project = graphops::create_project(); self.adopt_project(project, cx); } @@ -2846,7 +2037,7 @@ impl AppEngine for RealEngine { path: PathBuf, cx: &mut Context, ) -> Result<(), String> { - if self.project_ptr().is_none() { + if self.project.is_none() { return Err("no project open".into()); } let ext = path @@ -2865,28 +2056,11 @@ impl AppEngine for RealEngine { } fn import_footage(&mut self, path: PathBuf, cx: &mut Context) -> Result<(), String> { - let Some(project) = self.project_ptr() else { + let Some(project) = self.project.clone() else { return Err("no project open".into()); }; - let Some(path_c) = cstr_path(&path) else { - return Err("invalid import path".into()); - }; - // SAFETY: `project` is the live facade handle the engine owns; the - // returned footage box is freed below. - let footage = unsafe { oakengine_project_import_footage(project, path_c.as_ptr()) }; - if footage.is_null() { - let error = read_string(|buf, size| unsafe { - oakengine_footage_last_error(buf, size) - }); - return Err(if error.is_empty() { - format!("failed to import \"{}\"", path.display()) - } else { - error - }); - } - // SAFETY: `footage` is an owned facade box (`oakengine_footage_free`). - unsafe { oakengine_footage_free(footage) }; - // The material bin reads the folder tree live from the facade, so a + graphops::import_footage(&project, &path)?; + // The material bin reads the folder tree live from the graph, so a // notify is enough for the explorer to list the new entry. cx.notify(); Ok(()) @@ -2895,45 +2069,32 @@ impl AppEngine for RealEngine { fn drop_footage( &mut self, id: u64, - track_kind: TrackKind, + _track_kind: TrackKind, track_index: usize, time: Frame, cx: &mut Context, ) { - let Some(project) = self.project_ptr() else { - return; - }; - let Some(seq) = self.seq_ptr() else { + let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else { return; }; // The explorer's entry id IS the footage node's stable identity - // (`projectbrowser`); find the matching footage node (its box is - // freed below). - let mut footage_node: *mut OakEngineNode = std::ptr::null_mut(); - let mut footage_index: c_int = -1; - let count = unsafe { oakengine_project_footage_count(project) }; - for index in 0..count.max(0) { - let node = unsafe { oakengine_project_footage_at(project, index) }; - if node.is_null() { - continue; - } - if unsafe { oakengine_node_identity(node) } == id { - footage_node = node; - footage_index = index; - break; - } - // SAFETY: `node` is a box from `oakengine_project_footage_at`. - unsafe { oakengine_node_free(node) }; - } - if footage_node.is_null() { + // (`projectbrowser`). + let Some(footage) = graphops::id_of(id) else { println!("[real engine] drop footage: entry {id} is not a footage node"); return; - } - // Media type by extension: the module's footage is never probed, so - // the facade's stream counts are empty (see `filename_is_audio`). - let filename = read_string(|buf, size| unsafe { - oakengine_project_footage_filename(project, footage_index, buf, size) - }); + }; + let filename = { + let guard = graphops::lock(&project); + match graphops::footage_behavior(&guard.graph, footage) { + Some(f) => f.filename.clone(), + None => { + println!("[real engine] drop footage: entry {id} is not a footage node"); + return; + } + } + }; + // Media type by extension: the module's footage is not reliably + // probed, so the drop's track matching falls back to the extension. let footage_kind = if crate::oakui::filename_is_audio(&filename) { TrackKind::Audio } else { @@ -2942,8 +2103,6 @@ impl AppEngine for RealEngine { // Track policy (see the `AppEngine::drop_footage` docs): use the // pointed display track when its kind matches, otherwise auto-select // the topmost track of the footage's kind; reject when there is none. - // The facade itself validates only the track type (video/audio; it - // rejects subtitles) and never the media/track pairing. let target = if let Some(track) = self.tracks.get(track_index) { if track.kind == footage_kind { track_index @@ -2961,9 +2120,6 @@ impl AppEngine for RealEngine { "[real engine] drop footage: could not add a {:?} track for \"{}\"", footage_kind, filename ); - // SAFETY: `footage_node` is a box from - // `oakengine_project_footage_at`. - unsafe { oakengine_node_free(footage_node) }; return; } } @@ -2981,107 +2137,63 @@ impl AppEngine for RealEngine { "[real engine] drop footage: could not add a {:?} track", footage_kind ); - // SAFETY: `footage_node` is a box from `oakengine_project_footage_at`. - unsafe { oakengine_node_free(footage_node) }; return; } } } else { println!("[real engine] drop footage: display track {track_index} does not exist"); - // SAFETY: `footage_node` is a box from `oakengine_project_footage_at`. - unsafe { oakengine_node_free(footage_node) }; return; }; - // The display list maps 1:1 onto the facade's per-type track lists - // (see `rebuild_timeline`), so the snapshot's coordinates address the - // facade track directly. - let (track_type, track_index_facade) = { - let track = &self.tracks[target]; - (track.track_type, track.track_index) - }; - // Clip length: the footage's probed duration when available; module - // footage is never probed, so fall back to a 10-second default. + let track = &self.tracks[target]; + let (kind, track_index_facade) = (track_type_of(track.kind), track.track_index); + // Clip length: the footage's probed duration when available; + // otherwise a 10-second default. let fps = self.frame_rate(); let fps_f = fps.num as f64 / fps.den.max(1) as f64; - let footage = unsafe { oakengine_footage_borrow(footage_node) }; - let mut seconds: f64 = 0.0; - let has_duration = !footage.is_null() - && unsafe { oakengine_footage_get_duration(footage, &mut seconds) } == 0 - && seconds > 0.0; - let length = if has_duration { - (seconds * fps_f).round().max(1.0) as i64 - } else { - (10.0 * fps_f).round().max(1.0) as i64 + let seconds = { + let guard = graphops::lock(&project); + graphops::footage_duration_seconds(&guard.graph, footage) + }; + let length = match seconds { + Some(s) => (s * fps_f).round().max(1.0) as i64, + None => (10.0 * fps_f).round().max(1.0) as i64, }; let in_ts = time.0.max(0); - // SAFETY: `seq` and `footage` are live facade handles; the returned - // owned clip box is freed below. - let clip = unsafe { - oakengine_sequence_add_footage_clip_ex( - seq, - footage, - track_type, - track_index_facade as c_int, - in_ts, - in_ts + length, - 0, - ) - }; - // SAFETY: `footage` is a borrowed box (`oakengine_footage_borrow`); - // `footage_node` a box from `oakengine_project_footage_at`. - unsafe { - if !footage.is_null() { - oakengine_footage_free(footage); - } - oakengine_node_free(footage_node); - } - let rc = if clip.is_null() { - let error = read_string(|buf, size| unsafe { - oakengine_sequence_last_error(buf, size) - }); - let error = if error.is_empty() { - "add footage clip rejected".to_string() - } else { - error - }; - println!("[real engine] drop footage rejected: {error}"); - -1 - } else { - // SAFETY: `clip` is an owned facade box (`free_box`). - unsafe { free_box(clip) }; - 0 - }; - // The facade export pushes ONE undoable "Add Clip" entry; the refresh + let result = graphops::place_footage_clip( + &project, + seq, + footage, + kind, + track_index_facade, + in_ts, + in_ts + length, + 0, + ) + .map(|_| ()); + // The placement pushes ONE undoable "Add Clip" entry; the refresh // also invalidates the cached rendered frames. - self.apply_edit(rc, "drop footage", cx); + self.apply_edit(result, "drop footage", cx); } // --- project library (M13 D4) -------------------------------------- fn storage_bound(&self) -> bool { - self.project_ptr() - .map(|p| unsafe { oakengine_storage_is_bound(p) } != 0) + self.storage + .as_ref() + .map(|h| graphops::storage_bound(&h.0)) .unwrap_or(false) } fn storage_last_error(&self) -> Option { - let project = self.project_ptr()?; - let message = read_string(|buf, size| unsafe { - oakengine_storage_last_error(project, buf, size) - }); - if message.is_empty() { - None - } else { - Some(message) - } + graphops::storage_last_error(&self.storage.as_ref()?.0) } fn library_projects(&self) -> Result, String> { - library_list() + graphops::library_list() } fn library_create_project(&mut self, name: &str, cx: &mut Context) -> Result<(), String> { - let uuid = library_create(name)?; + let uuid = graphops::library_create(name)?; self.open_library_project(&uuid, cx) } @@ -3090,235 +2202,45 @@ impl AppEngine for RealEngine { } fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> { - let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?; - let rc = unsafe { oakengine_library_delete(uuid_c.as_ptr()) }; - if rc != 0 { - return Err(format!("failed to delete the project (error {rc})")); - } - Ok(()) + graphops::library_delete(uuid) + .map_err(|e| format!("failed to delete the project: {e}")) } fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> { - let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?; - let name_c = CString::new(name).map_err(|_| "invalid name".to_string())?; - let rc = unsafe { oakengine_library_rename(uuid_c.as_ptr(), name_c.as_ptr()) }; - if rc != 0 { - return Err(format!("failed to rename the project (error {rc})")); - } - Ok(()) + graphops::library_rename(uuid, name) + .map_err(|e| format!("failed to rename the project: {e}")) } fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> { - let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?; - let mut buf = [0 as c_char; 256]; - // Single call with a stack buffer: the duplicate has a side effect, - // so the two-stage (measure-then-read) pattern must not be used. - let rc = unsafe { - oakengine_library_duplicate( - uuid_c.as_ptr(), - std::ptr::null(), - buf.as_mut_ptr(), - buf.len() as c_int, - ) - }; - if rc < 0 { - return Err(format!("failed to duplicate the project (error {rc})")); - } - Ok(()) + graphops::library_duplicate(uuid) + .map(|_| ()) + .map_err(|e| format!("failed to duplicate the project: {e}")) } fn library_import_project(&mut self, path: PathBuf) -> Result { - let path_c = cstr_path(&path).ok_or("invalid import path")?; - let mut buf = [0 as c_char; 256]; - // Single call with a stack buffer (side effect; see duplicate). - let rc = unsafe { - oakengine_library_import(path_c.as_ptr(), buf.as_mut_ptr(), buf.len() as c_int) - }; - if rc < 0 { - return Err(format!( - "failed to import \"{}\" (error {rc})", - path.display() - )); - } - let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); - Ok( - String::from_utf8_lossy(unsafe { - std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) - }) - .into_owned(), - ) + graphops::library_import(&path) + .map_err(|e| format!("failed to import \"{}\": {e}", path.display())) } fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> { - let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?; - let path_c = cstr_path(&path).ok_or("invalid export path")?; - let rc = unsafe { oakengine_library_export(uuid_c.as_ptr(), path_c.as_ptr()) }; - if rc != 0 { - return Err(format!( - "failed to export the project to \"{}\" (error {rc})", - path.display() - )); - } - Ok(()) + graphops::library_export(uuid, &path) + .map_err(|e| format!("failed to export the project to \"{}\": {e}", path.display())) } fn start_export(&mut self, format: i32, path: PathBuf) -> Result { - let Some(seq) = self.seq_ptr() else { + let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else { return Err("no sequence open".into()); }; - - // Build the encoding params from the sequence's format. - let params = unsafe { oakengine_encoding_params_create() }; - if params.is_null() { - return Err("failed to create encoding params".into()); - } - let cpath = cstr_path(&path).ok_or("invalid output path")?; - let rc = unsafe { oakengine_encoding_params_set_filename(params, cpath.as_ptr()) }; - if rc != 0 { - unsafe { oakengine_encoding_params_destroy(params) }; - return Err(format!("failed to set the export filename (error {rc})")); - } - let rc = unsafe { oakengine_encoding_params_set_format(params, format) }; - if rc != 0 { - unsafe { oakengine_encoding_params_destroy(params) }; - return Err(format!("failed to set the export format (error {rc})")); - } - // Video params POD from the sequence; first video codec of the format. - let mut width: c_int = 0; - let mut height: c_int = 0; - let mut par_num: c_int = 1; - let mut par_den: c_int = 1; - let mut rate_num: c_int = 25; - let mut rate_den: c_int = 1; - unsafe { - oakengine_sequence_get_video_params( - seq, - &mut width, - &mut height, - &mut par_num, - &mut par_den, - ); - oakengine_sequence_get_frame_rate(seq, &mut rate_num, &mut rate_den); - } - let video_codec = unsafe { oakengine_encoding_format_video_codec_at(format, 0) }; - if video_codec < 0 { - unsafe { oakengine_encoding_params_destroy(params) }; - return Err(format!("format {format} has no video codec")); - } - let pod = OakVideoParamsPod { - width: width.max(1), - height: height.max(1), - time_base_num: rate_den.max(1), - time_base_den: rate_num.max(1), - format: 0, - pixel_aspect_num: par_num.max(1), - pixel_aspect_den: par_den.max(1), - interlacing: 0, - color_range: 0, - divider: 1, - video_type: 0, - premultiplied_alpha: 0, - }; - let rc = unsafe { oakengine_encoding_params_enable_video(params, &pod, video_codec) }; - if rc != 0 { - unsafe { oakengine_encoding_params_destroy(params) }; - return Err(format!("failed to enable video (error {rc})")); - } - let audio_codec = unsafe { oakengine_encoding_format_audio_codec_at(format, 0) }; - if audio_codec < 0 { - unsafe { oakengine_encoding_params_destroy(params) }; - return Err(format!("format {format} has no audio codec")); - } - let rc = unsafe { - oakengine_encoding_params_enable_audio( - params, - EXPORT_SAMPLE_RATE, - EXPORT_CHANNEL_LAYOUT, - EXPORT_SAMPLE_FORMAT, - audio_codec, - ) - }; - if rc != 0 { - unsafe { oakengine_encoding_params_destroy(params) }; - return Err(format!("failed to enable audio (error {rc})")); - } - // Export range: the work area when enabled (M12 P4), otherwise the - // whole sequence. Frames → seconds rationals in the sequence's - // frame-rate timebase (frame duration = rate_den / rate_num). - if let Some((in_ts, out_ts)) = self.workarea().filter(|(s, e)| e.0 > s.0) { - let tb_num = i64::from(rate_den.max(1)); - let tb_den = i64::from(rate_num.max(1)); - unsafe { - oakengine_encoding_params_set_custom_range( - params, - in_ts.0 * tb_num, - tb_den, - out_ts.0 * tb_num, - tb_den, - ); - oakengine_encoding_params_set_export_length( - params, - ((out_ts.0 - in_ts.0) * tb_num) as c_int, - rate_num.max(1), - ); - } - } else { - let length = self.sequence_length(); - if length.0 > 0 { - unsafe { - oakengine_encoding_params_set_export_length(params, length.0 as c_int, 1); - } - } - } - - let task = unsafe { oakengine_task_create_export(seq, params) }; - if task.is_null() { - unsafe { oakengine_encoding_params_destroy(params) }; - return Err("failed to create the export task".into()); - } - - // Progress events through the facade task subscription (the callback - // is invoked on the task's own thread with the raw userdata pointer). - let (tx, rx) = mpsc::channel::(); - let cb_userdata = SendPtr(Box::into_raw(Box::new(tx.clone()))); - unsafe { - oakengine_task_subscribe(task, Some(export_event_cb), cb_userdata.0 as *mut c_void); - } - - // The task pointer is shared between the cancel handle and the worker - // thread; the thread owns it and frees it when the run ends. - let shared = Arc::new(Mutex::new(Some(SendPtr(task)))); - let cancel = { - let shared = shared.clone(); - Box::new(move || { - if let Some(task) = shared.lock().unwrap().as_ref() { - unsafe { - oakengine_task_cancel(task.0); - } - } - }) - }; - let worker = shared.clone(); - std::thread::spawn(move || { - unsafe { - let task = worker - .lock() - .unwrap() - .as_ref() - .expect("export task present") - .0; - let ok = oakengine_task_start_sync(task); - let error = RealEngine::task_error(task); - oakengine_task_free(task); - *worker.lock().unwrap() = None; - // Reclaim the callback userdata (the task's listener is - // one-shot and dropped after the run). - reclaim_userdata(cb_userdata); - let _ = tx.send(ExportEvent::Finished(ok != 0, error)); - } - }); - - Ok(ExportSession { events: rx, cancel }) + let workarea = self.workarea().map(|(s, e)| (s.0, e.0)); + let params = super::renderops::encoding_params( + &project, + seq, + format, + &path, + workarea, + self.sequence_length().0, + )?; + Ok(super::renderops::spawn_export(&project, seq, params)) } fn backend_name(&self) -> &'static str { "real" @@ -3357,36 +2279,15 @@ impl ProjectFormat { } impl RealEngine { - /// Opens a `.ove` / `.ovexml` project through the facade serializer. + /// Opens a `.ove` / `.ovexml` project through the module serializer. fn open_ove(&mut self, path: &PathBuf, cx: &mut Context) -> Result<(), String> { - let project = unsafe { oakengine_project_create() }; - if project.is_null() { - return Err("failed to create a project".into()); - } - let Some(cpath) = cstr_path(path) else { - unsafe { oakengine_project_free(project) }; - return Err("invalid project path".into()); - }; - let mut err = [0 as c_char; 4096]; - let rc = unsafe { - oakengine_project_load( - project, - cpath.as_ptr(), - err.as_mut_ptr(), - err.len() as c_int, - ) - }; - if rc != 0 { - let message = load_error(&mut err); - unsafe { oakengine_project_free(project) }; - return Err(format!("failed to load \"{}\": {message}", path.display())); - } + let project = graphops::load_ove(path) + .map_err(|e| format!("failed to load \"{}\": {e}", path.display()))?; // The module serializer cannot parse every legacy document (e.g. the // ``-rooted format skips its nested `` body), which // loads "successfully" with no content; surface it instead of // pretending the project opened. - let nodes = unsafe { oakengine_project_node_count(project) }; - if nodes == 0 { + if graphops::lock(&project).graph.node_count() == 0 { println!( "[real engine] warning: \"{}\" loaded but contained no parseable content; starting from an empty project", path.display() @@ -3397,28 +2298,27 @@ impl RealEngine { } /// Opens an `.otio` / `.fcpxml` project through the oaktask interchange - /// loader and adopts the loaded project (`oakengine_task_load_take_project` - /// hands over the loader's project after a successful run). + /// loader and adopts the loaded project. fn open_interchange(&mut self, path: &PathBuf, cx: &mut Context) -> Result<(), String> { - let Some(cpath) = cstr_path(path) else { - return Err("invalid project path".into()); - }; - let task = unsafe { oakengine_task_create_project_load_otio(cpath.as_ptr()) }; - if task.is_null() { - return Err("failed to create the interchange load task".into()); - } - let rc = unsafe { oakengine_task_start_sync(task) }; - let error = Self::task_error(task); - let loaded = { - let project = unsafe { oakengine_task_load_take_project(task) }; - if project.is_null() { - None - } else { - Some(project) - } - }; - unsafe { oakengine_task_free(task) }; - if rc == 0 { + let title = format!("Loading '{}'", path.display()); + let result: Arc>> = Arc::new(Mutex::new(None)); + let mut driver = oaktask::task::Task::new(&title, None); + driver.set_behavior(Box::new(OtioLoadBehavior { + inner: oaktask::project::loadotio::LoadOTIOTask::new( + oaktask::project::load::ProjectLoadBaseTask::new( + oaktask::task::Task::new(&title, None), + path.to_string_lossy().into_owned(), + ), + ), + result: result.clone(), + })); + let run = driver.start(); + let loaded = result.lock().unwrap_or_else(|e| e.into_inner()).take(); + if run.is_err() { + let error = driver + .error() + .map(|s| s.to_string()) + .unwrap_or_else(|| "the task failed".to_string()); return Err(format!("failed to load \"{}\": {error}", path.display())); } match loaded { @@ -3437,80 +2337,111 @@ impl RealEngine { /// branch of 导出工程文件…; the write-through library stays the primary /// persistence, this only writes a file). fn export_ove(&mut self, path: &Path, _cx: &mut Context) -> Result<(), String> { - let Some(project) = self.project_ptr() else { + let Some(project) = self.project.clone() else { return Err("no project open".into()); }; - let Some(cpath) = cstr_path(path) else { - return Err("invalid project path".into()); - }; - let rc = unsafe { oakengine_project_save(project, cpath.as_ptr()) }; - if rc != 0 { - return Err(format!("failed to export the project (error {rc})")); - } - // The facade records the target filename (legacy save side effect); + graphops::save_ove(&project, path) + .map_err(|e| format!("failed to export the project: {e}"))?; + // The save records the target filename (legacy save side effect); // refresh the display name to match. - let name = read_string(|buf, size| unsafe { oakengine_project_name(project, buf, size) }); - if !name.is_empty() { + let (name, filename) = { + let guard = graphops::lock(&project); + (guard.name(), guard.filename().to_string()) + }; + if !name.is_empty() && name != "(untitled)" { self.project_info.name = name; } - let filename = - read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) }); if !filename.is_empty() { self.project_info.path = PathBuf::from(filename); } Ok(()) } - /// Exports as `.otio` / `.fcpxml` through the oaktask save task (the - /// facade derives the output filename from the project's own filename). + /// Exports as `.otio` / `.fcpxml` through the oaktask save task. fn export_interchange(&mut self, path: &PathBuf, _cx: &mut Context) -> Result<(), String> { - let Some(project) = self.project_ptr() else { + let Some(project) = self.project.clone() else { return Err("no project open".into()); }; - let Some(cpath) = cstr_path(path) else { - return Err("invalid project path".into()); - }; - let rc = unsafe { oakengine_project_set_filename(project, cpath.as_ptr()) }; - if rc != 0 { - return Err(format!("failed to set the output filename (error {rc})")); - } - let task = unsafe { oakengine_task_create_project_save_otio(project) }; - if task.is_null() { - return Err("failed to create the interchange save task".into()); - } - let rc = unsafe { oakengine_task_start_sync(task) }; - let error = Self::task_error(task); - unsafe { oakengine_task_free(task) }; - if rc == 0 { + let filename = path.to_string_lossy().into_owned(); + let mut driver = oaktask::task::Task::new("Saving project...", None); + driver.set_behavior(Box::new(oaktask::project::saveotio::SaveOTIOTask { + base: oaktask::task::Task::new("Saving project...", None), + project, + filename, + })); + if driver.start().is_err() { + let error = driver + .error() + .map(|s| s.to_string()) + .unwrap_or_else(|| "the task failed".to_string()); return Err(format!("failed to export \"{}\": {error}", path.display())); } self.project_info.path = path.clone(); Ok(()) } + + /// Opens the library row `uuid` through the oakstorage database backend + /// and adopts it (the adopt binds the project to the write-through + /// session, continuing the row's journal from its head seq). + fn open_library_project(&mut self, uuid: &str, cx: &mut Context) -> Result<(), String> { + let project = graphops::library_open(uuid) + .map_err(|e| format!("failed to open the library project: {e}"))?; + self.adopt_project(project, cx); + // The project's module name is filename-derived ("(untitled)" for a + // library row); display the library row name instead. + if let Ok(rows) = graphops::library_list() { + if let Some(row) = rows.iter().find(|row| row.uuid == uuid) { + self.project_info.name = row.name.clone(); + } + } + Ok(()) + } +} + +/// The OTIO load driver behavior: runs the module load task and stashes +/// the loaded project for the caller (the facade's `OtioLoadTaskBehavior` +/// pattern). +struct OtioLoadBehavior { + /// The module load task. + inner: oaktask::project::loadotio::LoadOTIOTask, + /// The result slot (the loaded project). + result: Arc>>, +} + +impl oaktask::task::TaskBehavior for OtioLoadBehavior { + fn run(&mut self, task: &mut oaktask::task::Task) -> oaktask::error::Result<()> { + self.inner.run(task)?; + if let Ok(project) = self.inner.base.take_project() { + *self.result.lock().unwrap_or_else(|e| e.into_inner()) = Some(project); + } + Ok(()) + } } /// Builds the export-format list: (format id, display name, extension) from /// the oakcodec encoding enumeration. /// -/// Pure helper so the export dialog can be unit tested; the facade is only -/// consulted for the real engine. -pub fn encoding_formats() -> Vec<(c_int, String, String)> { - let count = unsafe { oakengine_encoding_format_count() }; +/// Pure helper so the export dialog can be unit tested. +pub fn encoding_formats() -> Vec<(i32, String, String)> { let mut out = Vec::new(); - for i in 0..count.max(0) { - let name = read_string(|buf, size| unsafe { oakengine_encoding_format_name(i, buf, size) }); - let ext = - read_string(|buf, size| unsafe { oakengine_encoding_format_extension(i, buf, size) }); - out.push((i, name, ext)); + for i in 0..(oakcodec::exportformat::Format::Count as i32) { + let Some(format) = oakcodec::exportformat::Format::from_i32(i) else { + continue; + }; + out.push(( + i, + oakcodec::exportformat::Format::get_name(format), + oakcodec::exportformat::Format::get_extension(format), + )); } out } /// The format id of the default export container: MPEG-4 Video (`.mp4`). -pub const EXPORT_FORMAT_MP4: c_int = 2; +pub const EXPORT_FORMAT_MP4: i32 = 2; // --------------------------------------------------------------------------- -// Config C ABI (preferences) +// Config (preferences) — the oakcommon config store directly // --------------------------------------------------------------------------- /// The config key selecting the renderer backend (worker `create_renderer` @@ -3531,12 +2462,12 @@ pub const CONFIG_KEY_USE_PROXY: &str = "UseProxyMedia"; pub const CONFIG_KEY_PROXY_DIVIDER: &str = "ProxyDivider"; /// The config key holding the project snapshot interval in seconds /// (`Storage/SnapshotIntervalSec`; the write-through era's "auto-save -/// interval" — the facade's snapshot thread reads it every pass, default +/// interval" — oakstorage's snapshot thread reads it every pass, default /// 600, ≤ 0 snapshots every dirty save). pub const CONFIG_KEY_SNAPSHOT_INTERVAL_SEC: &str = "Storage/SnapshotIntervalSec"; /// The config key holding the default transition length in seconds /// (`DefaultTransitionLength`, decimal string; consumed by the engine's -/// add-default-transition command — currently a facade stub). +/// add-default-transition command — currently a module stub). pub const CONFIG_KEY_DEFAULT_TRANSITION_SEC: &str = "DefaultTransitionLength"; /// The config key holding the audio output device NAME (empty = system /// default; C++ parity `AudioOutput`). @@ -3544,63 +2475,47 @@ pub const CONFIG_KEY_AUDIO_OUTPUT: &str = "AudioOutput"; /// The config key holding the audio input device NAME (`AudioInput`). pub const CONFIG_KEY_AUDIO_INPUT: &str = "AudioInput"; -/// The default snapshot interval (seconds), mirroring the facade's +/// The default snapshot interval (seconds), mirroring oakstorage's /// compiled-in default. pub const DEFAULT_SNAPSHOT_INTERVAL_SEC: i64 = 600; /// The default transition length (seconds). pub const DEFAULT_TRANSITION_SEC: &str = "0.5"; +/// The process-wide config store. +fn config_store() -> &'static oakcommon::configstore::ConfigStore { + oakcommon::configstore::ConfigStore::instance() +} + /// Loads the persisted configuration from disk (once at startup, before /// any preference is read). pub fn config_load() { - unsafe { - oakengine_config_load(); - } + config_store().load().ok(); } /// Persists the configuration to disk (the app calls it on exit). pub fn config_save() { - unsafe { - oakengine_config_save(); - } + config_store().save().ok(); } -/// Reads a config string through the facade config C ABI (empty when -/// missing). +/// Reads a config string from the store (empty when missing). pub fn config_get_string(key: &str) -> String { - let Ok(key_c) = CString::new(key) else { - return String::new(); - }; - read_string(|buf, size| unsafe { oakengine_config_get_string(key_c.as_ptr(), buf, size) }) + config_store().get(None, key).unwrap_or_default() } -/// Writes a config string through the facade config C ABI. +/// Writes a config string to the store. pub fn config_set_string(key: &str, value: &str) { - let (Ok(key_c), Ok(value_c)) = (CString::new(key), CString::new(value)) else { - return; - }; - unsafe { - oakengine_config_set_string(key_c.as_ptr(), value_c.as_ptr()); - } + config_store().set(None, key, value); } -/// Reads a config integer through the facade config C ABI (`default` when -/// the key is missing or not an integer). +/// Reads a config integer from the store (`default` when the key is +/// missing or not an integer). pub fn config_get_int(key: &str, default: i64) -> i64 { - let Ok(key_c) = CString::new(key) else { - return default; - }; - unsafe { oakengine_config_get_int(key_c.as_ptr(), default) } + config_store().get_int64(None, key, default) } -/// Writes a config integer through the facade config C ABI. +/// Writes a config integer to the store. pub fn config_set_int(key: &str, value: i64) { - let Ok(key_c) = CString::new(key) else { - return; - }; - unsafe { - oakengine_config_set_int(key_c.as_ptr(), value); - } + config_store().set_int64(None, key, value); } /// Reads a config boolean through the string accessor (the store parses @@ -3641,32 +2556,18 @@ pub fn set_theme_dark(dark: bool) { } // --------------------------------------------------------------------------- -// Audio devices (preferences + startup wiring) +// Audio devices (preferences + startup wiring) — oakaudio's manager directly // --------------------------------------------------------------------------- /// The host's audio output device names in enumeration order (the index is -/// what `oakengine_audio_set_output_device` takes). +/// what the manager's `set_output_device` takes). pub fn audio_output_devices() -> Vec { - let count = unsafe { oakengine_audio_output_device_count() }; - let mut out = Vec::new(); - for i in 0..count.max(0) { - out.push(read_string(|buf, size| unsafe { - oakengine_audio_output_device_name(i, buf, size) - })); - } - out + oakaudio::manager::output_device_names() } /// The host's audio input device names (see [`audio_output_devices`]). pub fn audio_input_devices() -> Vec { - let count = unsafe { oakengine_audio_input_device_count() }; - let mut out = Vec::new(); - for i in 0..count.max(0) { - out.push(read_string(|buf, size| unsafe { - oakengine_audio_input_device_name(i, buf, size) - })); - } - out + oakaudio::manager::input_device_names() } /// Selects the output device by NAME (empty = system default), persists the @@ -3677,14 +2578,10 @@ pub fn set_audio_output_device(name: &str) { let index = if name.is_empty() { -1 } else { - audio_output_devices() - .iter() - .position(|n| n == name) - .map(|i| i as i64) - .unwrap_or(-1) + oakaudio::manager::device_index_by_name(name, true).unwrap_or(-1) }; - unsafe { - oakengine_audio_set_output_device(index); + if let Some(mut manager) = oakaudio::manager::instance() { + manager.set_output_device(index).ok(); } } @@ -3695,14 +2592,10 @@ pub fn set_audio_input_device(name: &str) { let index = if name.is_empty() { -1 } else { - audio_input_devices() - .iter() - .position(|n| n == name) - .map(|i| i as i64) - .unwrap_or(-1) + oakaudio::manager::device_index_by_name(name, false).unwrap_or(-1) }; - unsafe { - oakengine_audio_set_input_device(index); + if let Some(mut manager) = oakaudio::manager::instance() { + manager.set_input_device(index).ok(); } } @@ -3728,12 +2621,10 @@ pub fn audio_input_device() -> String { } /// Brings up the AudioManager singleton and applies the persisted device -/// choices. Called once at startup; without an instance the facade's -/// `push_to_output` fails silently and playback stays video-only. +/// choices. Called once at startup; without an instance `push_to_output` +/// fails silently and playback stays video-only. pub fn audio_init_from_config() { - unsafe { - oakengine_audio_create_instance(); - } + oakaudio::manager::ManagerInner::create_instance().ok(); let output = config_get_string(CONFIG_KEY_AUDIO_OUTPUT); if !output.is_empty() { set_audio_output_device(&output); @@ -3749,13 +2640,13 @@ pub fn audio_init_from_config() { // --------------------------------------------------------------------------- /// The config key selecting the storage backend (see -/// `crates/oakengine/src/storage.rs`). +/// `crates/oakstorage/src/writethrough.rs`). pub const CONFIG_KEY_STORAGE_BACKEND: &str = "Storage/Backend"; /// Enables the SQLite write-through library unless the user configured the /// backend explicitly (any existing value — including "off" — wins over the -/// app's default). The library path defaults facade-side to -/// `/library.db`. +/// app's default). The library path defaults to `/library.db`. pub fn configure_storage() { if config_get_string(CONFIG_KEY_STORAGE_BACKEND).is_empty() { config_set_string(CONFIG_KEY_STORAGE_BACKEND, "sqlite"); @@ -3763,113 +2654,28 @@ pub fn configure_storage() { } /// Flushes every bound project (write-through + snapshot) and stops the -/// facade's snapshot thread. The app calls this on exit. +/// snapshot thread. The app calls this on exit. pub fn storage_flush() { - unsafe { - oakengine_storage_flush(); - } + graphops::storage_flush(); } /// The library rows, most recently modified first (the project manager's -/// data source; JSON over the facade's `oakengine_library_list`). +/// data source). pub fn library_list() -> Result, String> { - let needed = unsafe { oakengine_library_list(std::ptr::null_mut(), 0) }; - if needed < 0 { - return Err(format!("failed to list the library (error {needed})")); - } - let mut buf = vec![0 as c_char; needed as usize + 1]; - unsafe { oakengine_library_list(buf.as_mut_ptr(), needed + 1) }; - let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); - let json = - String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) - .into_owned(); - let rows: serde_json::Value = - serde_json::from_str(&json).map_err(|e| format!("malformed library list: {e}"))?; - let Some(rows) = rows.as_array() else { - return Err("malformed library list (not an array)".into()); - }; - Ok(rows - .iter() - .map(|row| { - let s = |key: &str| row.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string(); - let n = |key: &str| row.get(key).and_then(|v| v.as_i64()).unwrap_or(0); - LibraryProject { - uuid: s("uuid"), - name: s("name"), - created_at: n("created_at"), - modified_at: n("modified_at"), - duration_ms: n("duration_ms"), - track_count: n("track_count") as i32, - clip_count: n("clip_count") as i32, - footage_count: n("footage_count") as i32, - } - }) - .collect()) -} - -/// Creates a blank project row in the library; returns its uuid. Single -/// call with a stack buffer: the create has a side effect, so the -/// two-stage (measure-then-read) pattern must not be used. -fn library_create(name: &str) -> Result { - let name_c = CString::new(name).map_err(|_| "invalid name".to_string())?; - let mut buf = [0 as c_char; 256]; - let rc = unsafe { oakengine_library_create(name_c.as_ptr(), buf.as_mut_ptr(), buf.len() as c_int) }; - if rc < 0 { - return Err(format!("failed to create the project (error {rc})")); - } - let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); - Ok( - String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) - .into_owned(), - ) -} - -impl RealEngine { - /// Opens the library row `uuid` through the facade's library-load path - /// (which binds the project to the write-through session) and adopts it. - fn open_library_project(&mut self, uuid: &str, cx: &mut Context) -> Result<(), String> { - let project = unsafe { oakengine_project_create() }; - if project.is_null() { - return Err("failed to create a project".into()); - } - let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?; - let mut err = [0 as c_char; 4096]; - let rc = unsafe { - oakengine_project_load_library( - project, - uuid_c.as_ptr(), - err.as_mut_ptr(), - err.len() as c_int, - ) - }; - if rc != 0 { - let message = load_error(&mut err); - unsafe { oakengine_project_free(project) }; - return Err(format!("failed to open the library project: {message}")); - } - self.adopt_project(project, cx); - // The facade's project name is filename-derived ("(untitled)" for a - // library row); display the library row name instead. - if let Ok(rows) = library_list() { - if let Some(row) = rows.iter().find(|row| row.uuid == uuid) { - self.project_info.name = row.name.clone(); - } - } - Ok(()) - } + graphops::library_list() } #[cfg(test)] mod tests { use super::*; - use std::sync::mpsc; + use std::sync::mpsc as std_mpsc; use std::time::Duration; - /// Serializes the media/FFmpeg-heavy tests: the engine dylib's static - /// FFmpeg is not thread-safe against concurrent decode sessions. + /// Serializes the media/FFmpeg-heavy tests (the codec library is not + /// thread-safe against concurrent decode sessions) and shares the + /// process-global undo stack with the other app test modules. fn media_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - LOCK.lock().unwrap_or_else(|e| e.into_inner()) + crate::oakui::graphops::test_lock() } #[test] @@ -3911,12 +2717,11 @@ mod tests { ); } - /// Serializes the config round-trip tests: the facade config is a + /// Serializes the config round-trip tests: the config store is a /// process-global store, so tests mutating the same keys must not run - /// concurrently. + /// concurrently (shared with the other app test modules). fn config_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - LOCK.lock().unwrap_or_else(|e| e.into_inner()) + crate::oakui::graphops::test_lock() } /// Restores `key`'s original value when dropped, so a round-trip test @@ -3935,8 +2740,8 @@ mod tests { } } - /// Every preferences-dialog key round-trips through the facade config - /// C ABI: the value written is the value read back. + /// Every preferences-dialog key round-trips through the config store: + /// the value written is the value read back. #[test] fn preferences_keys_round_trip_through_the_config() { let _guard = config_lock(); @@ -3995,9 +2800,9 @@ mod tests { ); } - /// The audio device enumeration crosses the facade without crashing; - /// the output and input lists are independent (either may be empty on a - /// headless box). + /// The audio device enumeration crosses oakaudio's manager without + /// crashing; the output and input lists are independent (either may be + /// empty on a headless box). #[test] fn audio_device_enumeration_is_stable() { let outputs = audio_output_devices(); @@ -4011,243 +2816,137 @@ mod tests { assert_eq!(audio_output_device(), ""); } - /// The snapshot-interval key is the one the facade's snapshot thread - /// reads (`Storage/SnapshotIntervalSec`, see crates/oakengine/src/ - /// storage.rs) — a rename here would silently disconnect the dialog. + /// The snapshot-interval key is the one oakstorage's snapshot thread + /// reads (`Storage/SnapshotIntervalSec`, see crates/oakstorage/src/ + /// writethrough.rs) — a rename here would silently disconnect the dialog. #[test] - fn snapshot_interval_key_matches_the_facade() { + fn snapshot_interval_key_matches_the_storage_module() { assert_eq!(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, "Storage/SnapshotIntervalSec"); } - - /// End-to-end through the facade: a project the engine itself writes - /// (save → load round-trip) keeps its identity, and the in-memory - /// sequence the app drives (created with `oakengine_sequence_new`) carries - /// real tracks. The repository's `tests/project_with_footage.ove` is a - /// legacy ``-rooted document the oaknode serializer cannot parse, - /// so the round-trip uses the engine's own current-format writer. + /// End-to-end through the module crates: a project the engine itself + /// writes (save → load round-trip) keeps its identity, and the + /// in-memory sequence the app drives carries real tracks. The + /// repository's `tests/project_with_footage.ove` is a legacy + /// ``-rooted document the oaknode serializer cannot parse, so + /// the round-trip uses the engine's own current-format writer. /// - /// NOTE (documented facade gaps): `oakengine_sequence_new` keeps the - /// sequence in a module scratch project (not the project's membership), so - /// the saved file carries no sequence and a loaded file registers none — - /// the app therefore opens any project and works against a fresh in-memory - /// sequence (see [`RealEngine::adopt_project`]). + /// NOTE: the direct-rlib app keeps the sequence IN the project's graph + /// (the facade kept it in a scratch project), so the saved file now + /// carries the sequence and its tracks. #[test] fn real_project_save_load_round_trip() { - let project = unsafe { oakengine_project_create() }; - assert!(!project.is_null()); - assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let _media = media_lock(); + let project = graphops::create_project(); + let seq = graphops::create_sequence(&project, "Round Trip"); + let v = graphops::add_track(&project, seq, TrackType::Video).expect("add a video track"); + let a = graphops::add_track(&project, seq, TrackType::Audio).expect("add an audio track"); + assert_eq!((v, a), (0, 0), "in-memory tracks"); + oakundo::global::clear().unwrap(); - // The in-memory sequence the app drives: real tracks over the facade. - let name = CString::new("Round Trip").unwrap(); - let sequence = unsafe { oakengine_sequence_new(project, name.as_ptr()) }; - assert!(!sequence.is_null()); - assert_eq!( - unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) }, - 0 - ); - assert_eq!( - unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_AUDIO) }, - 0 - ); - let mut video: c_int = -1; - let mut audio: c_int = -1; - let mut subtitle: c_int = -1; - unsafe { - oakengine_sequence_track_count(sequence, &mut video, &mut audio, &mut subtitle); - } - assert_eq!((video, audio, subtitle), (1, 1, 0), "in-memory tracks"); - - // Save as uncompressed `.ovexml` (the module serializer only reads - // plain XML). + // Save as uncompressed `.ovexml` (the module serializer reads plain + // XML). let save_path = std::env::temp_dir().join(format!("oakapp_roundtrip_{}.ovexml", std::process::id())); - let cpath = CString::new(save_path.to_string_lossy().into_owned()).unwrap(); - assert_eq!( - unsafe { oakengine_project_set_filename(project, cpath.as_ptr()) }, - 0 - ); - assert_eq!( - unsafe { oakengine_project_save(project, cpath.as_ptr()) }, - 0 - ); + graphops::save_ove(&project, &save_path).expect("save"); assert!(save_path.exists()); - unsafe { free_box(sequence) }; - unsafe { oakengine_project_free(project) }; - // Load it back through the same facade path the app uses: the file - // loads and the project identity round-trips. - let project2 = unsafe { oakengine_project_create() }; - assert!(!project2.is_null()); - let mut err = [0 as c_char; 4096]; - let rc = unsafe { - oakengine_project_load( - project2, - cpath.as_ptr(), - err.as_mut_ptr(), - err.len() as c_int, + // Load it back through the same path the app uses: the file loads + // and the project identity round-trips. + let loaded = graphops::load_ove(&save_path).expect("load"); + let (loaded_name, sequences) = { + let guard = graphops::lock(&loaded); + (graphops::project_name(&guard), graphops::sequence_ids(&guard)) + }; + assert!(!loaded_name.is_empty(), "the loaded project has a name"); + assert_eq!(sequences.len(), 1, "the sequence survives the round-trip"); + let (video, audio) = { + let guard = graphops::lock(&loaded); + ( + graphops::track_ids(&guard.graph, sequences[0], TrackType::Video).len(), + graphops::track_ids(&guard.graph, sequences[0], TrackType::Audio).len(), ) }; - assert_eq!(rc, 0, "project loads: {}", load_error(&mut err)); - let loaded_name = - read_string(|buf, size| unsafe { oakengine_project_name(project2, buf, size) }); - assert!(!loaded_name.is_empty(), "the loaded project has a name"); + assert_eq!((video, audio), (1, 1), "the tracks survive the round-trip"); - unsafe { oakengine_project_free(project2) }; let _ = std::fs::remove_file(&save_path); } - /// End-to-end CPU render through the same facade path - /// [`RealEngine::render_program_frame`] uses: with the render manager up, - /// `render_frame` on an in-memory sequence produces a real F32 frame at - /// the renderer's proxy geometry, and the samples are well-formed - /// (finite, in range). The sequence is empty, so the picture is black — content - /// correctness with real footage needs the footage-input surface the - /// facade does not bind yet (documented gap); what is asserted here is - /// the full transport: renderer lifecycle, frame geometry/format/stride - /// and sane sample values. + /// End-to-end CPU render through the same path + /// [`RealEngine::render_program_frame`] uses: with the render manager + /// up, rendering an in-memory sequence produces a real F32 frame at the + /// requested proxy geometry, and the samples are well-formed (finite, in + /// range). The sequence starts empty, so the picture is black; with a + /// clip of real media on the video track the same render produces the + /// decoded footage (known content, non-black). #[test] fn real_render_frame_e2e() { let _media = media_lock(); - if !RealEngine::ensure_render_manager() { + if !crate::oakui::renderops::ensure_render_manager() { panic!("the render manager failed to start"); } - let project = unsafe { oakengine_project_create() }; - assert!(!project.is_null()); - assert_eq!(unsafe { oakengine_project_new(project) }, 0); - let name = CString::new("Render E2E").unwrap(); - let sequence = unsafe { oakengine_sequence_new(project, name.as_ptr()) }; - assert!(!sequence.is_null()); + let project = graphops::create_project(); + let seq = graphops::create_sequence(&project, "Render E2E"); + let tb = graphops::sequence_time_base(&graphops::lock(&project).graph, seq) + .expect("the sequence has a frame rate"); // The app's proxy size: sequence aspect (default 1920x1080) scaled - // to a 480px long edge, F32 at 25 fps. - let renderer = unsafe { - oakengine_renderer_create( - sequence, - 480, - 270, - PIXEL_FORMAT_F32, - 25, - 1, - std::ptr::null(), - ) - }; - assert!(!renderer.is_null(), "renderer_create must succeed"); - - let frame = unsafe { oakengine_renderer_render_frame(renderer, 0) }; - assert!(!frame.is_null(), "render_frame must produce a frame"); - assert_eq!(unsafe { oakengine_frame_width(frame) }, 480); - assert_eq!(unsafe { oakengine_frame_height(frame) }, 270); - assert_eq!(unsafe { oakengine_frame_format(frame) }, PIXEL_FORMAT_F32); - let linesize = unsafe { oakengine_frame_linesize_bytes(frame) }; - assert!(linesize >= 480 * 4 * 4, "linesize covers a full row"); - let data = unsafe { oakengine_frame_data(frame) } as *const f32; - assert!(!data.is_null()); - - // Sample pixels across the frame: all values must be finite and in - // range; an empty sequence renders transparent black (all zeros). - let stride = linesize as usize / 4; - let mut nonzero = 0usize; - for &(x, y) in &[(0usize, 0usize), (240, 135), (479, 269)] { - let base = y * stride + x * 4; - let px = unsafe { std::slice::from_raw_parts(data.add(base), 4) }; - assert!( - px.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), - "samples in range: {px:?}" - ); - nonzero += px.iter().filter(|&&v| v != 0.0).count(); - } - assert_eq!(nonzero, 0, "an empty sequence renders transparent black"); - unsafe { oakengine_frame_free(frame) }; + // to a 480px long edge, F32 at the sequence's rate. + let frame = crate::oakui::renderops::render_sequence_frame(&project, seq, 0, tb, 480, 270) + .expect("render_frame must produce a frame"); + assert_eq!((frame.width, frame.height), (480, 270)); + assert_eq!(frame.format, crate::oakui::renderops::PIXEL_FORMAT_F32); + assert!(frame.linesize >= 480 * 4 * 4, "linesize covers a full row"); + let (_, _, samples) = read_f32_frame(&frame).expect("well-formed F32 frame"); + assert!( + samples.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), + "samples in range" + ); + assert!( + samples.iter().all(|&v| v == 0.0), + "an empty sequence renders transparent black" + ); // M12 P0: with a clip of real media on the video track, the same - // renderer must produce the decoded footage (known content, non + // render must produce the decoded footage (known content, non // black). The media is program-generated. let media = std::env::temp_dir().join(format!( "oakapp_e2e_media_{}.mp4", std::process::id() )); - let media_c = CString::new(media.to_string_lossy().into_owned()).unwrap(); - assert_eq!( - unsafe { oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10) }, - 0, - "generate e2e test media" - ); - let footage = unsafe { oakengine_project_import_footage(project, media_c.as_ptr()) }; - assert!(!footage.is_null(), "import_footage must succeed"); - assert_eq!(unsafe { oakengine_project_footage_count(project) }, 1); - assert_eq!( - unsafe { - oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) - }, - 0 - ); - // Clip covering [0, 10) frames at 25 fps. - let clip = unsafe { - oakengine_sequence_add_footage_clip_ex( - sequence, - footage, - TRACK_TYPE_VIDEO, - 0, - 0, - 10, - 0, - ) - }; - if clip.is_null() { - let msg = read_string(|buf, size| unsafe { - oakengine_sequence_last_error(buf, size) - }); - panic!("add_footage_clip failed: {msg}"); - } - unsafe { oakengine_footage_free(footage) }; - let frame = unsafe { oakengine_renderer_render_frame(renderer, 0) }; - assert!(!frame.is_null(), "render_frame with a clip must produce a frame"); - let data = unsafe { oakengine_frame_data(frame) } as *const f32; - let mut nonzero = 0usize; - for &(x, y) in &[(0usize, 0usize), (240, 135), (479, 269)] { - let base = y * stride + x * 4; - let px = unsafe { std::slice::from_raw_parts(data.add(base), 4) }; - assert!( - px.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), - "samples in range: {px:?}" - ); - nonzero += px.iter().filter(|&&v| v != 0.0).count(); - } + oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10) + .expect("generate e2e test media"); + let footage = graphops::import_footage(&project, &media).expect("import_footage"); + graphops::add_track(&project, seq, TrackType::Video).expect("add a video track"); + // Clip covering [0, 10) frames at the sequence's rate. + graphops::place_footage_clip(&project, seq, footage, TrackType::Video, 0, 0, 10, 0) + .expect("clip placement"); + let frame = crate::oakui::renderops::render_sequence_frame(&project, seq, 0, tb, 480, 270) + .expect("render_frame with a clip must produce a frame"); + let (_, _, samples) = read_f32_frame(&frame).expect("well-formed F32 frame"); + let nonzero = samples.iter().filter(|&&v| v != 0.0).count(); assert!( nonzero > 0, "the sequence with a footage clip must render non-black pixels" ); // Known content: the test clip's left half is red on frame 0 — // the center-left pixel must be red-dominant. - let base = 135 * stride + 120 * 4; - let px = unsafe { std::slice::from_raw_parts(data.add(base), 4) }; + let px = |x: usize, y: usize| &samples[(y * 480 + x) * 4..][..4]; + let center_left = px(120, 135); assert!( - px[0] > 0.5 && px[1] < 0.4 && px[2] < 0.4, - "center-left pixel stays red from the decoded clip: {px:?}" + center_left[0] > 0.5 && center_left[1] < 0.4 && center_left[2] < 0.4, + "center-left pixel stays red from the decoded clip: {center_left:?}" ); - unsafe { oakengine_frame_free(frame) }; - let _ = std::fs::remove_file(&media); // A second frame at a later timestamp renders too. - let frame2 = unsafe { oakengine_renderer_render_frame(renderer, 30) }; - assert!(!frame2.is_null()); - unsafe { oakengine_frame_free(frame2) }; + assert!(crate::oakui::renderops::render_sequence_frame(&project, seq, 30, tb, 480, 270).is_ok()); - // Invalid arguments are rejected (geometry, pixel format). - assert!(unsafe { - oakengine_renderer_create(sequence, 0, 270, PIXEL_FORMAT_F32, 25, 1, std::ptr::null()) - } - .is_null()); - assert!(unsafe { - oakengine_renderer_create(sequence, 480, 270, 99999, 25, 1, std::ptr::null()) - } - .is_null()); + // Invalid geometry is rejected. + assert!(crate::oakui::renderops::render_sequence_frame(&project, seq, 0, tb, 0, 270).is_err()); - unsafe { oakengine_renderer_free(renderer) }; - unsafe { free_box(sequence) }; - unsafe { oakengine_project_free(project) }; + oakundo::global::clear().unwrap(); + let _ = std::fs::remove_file(&media); } /// M12 P3 acceptance: importing a media file makes it appear in the @@ -4255,26 +2954,19 @@ mod tests { #[test] fn real_project_browser_lists_imported_footage() { let _media = media_lock(); - let project = unsafe { oakengine_project_create() }; - assert!(!project.is_null()); - assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let project = graphops::create_project(); - // Generate a real media file through the facade, import it. + // Generate a real media file, import it. let media = std::env::temp_dir().join(format!( "oakapp_browser_{}.mp4", std::process::id() )); - let cpath = CString::new(media.to_string_lossy().into_owned()).unwrap(); - assert_eq!( - unsafe { oakengine_testmedia_write_clip(cpath.as_ptr(), 64, 64, 10, 10) }, - 0 - ); - let footage = unsafe { oakengine_project_import_footage(project, cpath.as_ptr()) }; - assert!(!footage.is_null(), "import must succeed"); - unsafe { oakengine_footage_free(footage) }; + oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10) + .expect("generate test media"); + graphops::import_footage(&project, &media).expect("import must succeed"); // The project browser (ProjectDataSource) must list it. - let roots = unsafe { crate::oakui::projectbrowser::roots(project) }; + let roots = crate::oakui::projectbrowser::roots(&project); assert!(!roots.is_empty(), "the root folder lists entries"); let media_name = media.file_name().unwrap().to_string_lossy().into_owned(); let entry = roots @@ -4286,10 +2978,10 @@ mod tests { // Double-click behavior: the entry id resolves back to the footage // node through `find_by_identity`. - let node = unsafe { crate::oakui::projectbrowser::find_by_identity(project, entry.id) }; + let node = crate::oakui::projectbrowser::find_by_identity(&project, entry.id); assert!(node.is_some(), "selection resolves to a node"); - unsafe { oakengine_node_free(node.unwrap()) }; + oakundo::global::clear().unwrap(); let _ = std::fs::remove_file(&media); } @@ -4311,12 +3003,8 @@ mod tests { "oakapp_engine_import_{}.mp4", std::process::id() )); - let cpath = CString::new(media.to_string_lossy().into_owned()).unwrap(); - assert_eq!( - unsafe { oakengine_testmedia_write_clip(cpath.as_ptr(), 64, 64, 10, 10) }, - 0, - "generate e2e test media" - ); + oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10) + .expect("generate e2e test media"); let imported = cx .update(|app| engine.update(app, |engine, cx| engine.import_footage(media.clone(), cx))); assert!(imported.is_ok(), "import through the seam succeeds: {imported:?}"); @@ -4344,47 +3032,27 @@ mod tests { /// builds a NON-EMPTY node graph with the wires the node editor shows: /// the footage feeds the clip's `tex_in` (a real edge), and every clip /// connects to the sequence output through the synthesized wire. Runs - /// through the same facade path `RealEngine::nodes()`/`edges()` use. + /// through the same builder `RealEngine::nodes()`/`edges()` use. #[test] fn real_node_graph_enumerates_sequence() { let _media = media_lock(); - let project = unsafe { oakengine_project_create() }; - assert!(!project.is_null()); - assert_eq!(unsafe { oakengine_project_new(project) }, 0); - let name = CString::new("Node Editor").unwrap(); - let sequence = unsafe { oakengine_sequence_new(project, name.as_ptr()) }; - assert!(!sequence.is_null()); - assert_eq!(unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) }, 0); + let project = graphops::create_project(); + let seq = graphops::create_sequence(&project, "Node Editor"); + graphops::add_track(&project, seq, TrackType::Video).expect("add a video track"); let media = std::env::temp_dir().join(format!( "oakapp_nodegraph_{}.mp4", std::process::id() )); - let cpath = CString::new(media.to_string_lossy().into_owned()).unwrap(); - assert_eq!( - unsafe { oakengine_testmedia_write_clip(cpath.as_ptr(), 64, 64, 10, 10) }, - 0 - ); - let footage = unsafe { oakengine_project_import_footage(project, cpath.as_ptr()) }; - assert!(!footage.is_null(), "import must succeed"); - let clip = unsafe { - oakengine_sequence_add_footage_clip_ex( - sequence, - footage, - TRACK_TYPE_VIDEO, - 0, - 0, - 10, - 0, - ) - }; - assert!(!clip.is_null(), "clip placement must succeed"); - unsafe { oakengine_footage_free(footage) }; - unsafe { free_box(clip) }; + oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10) + .expect("generate test media"); + let footage = graphops::import_footage(&project, &media).expect("import must succeed"); + graphops::place_footage_clip(&project, seq, footage, TrackType::Video, 0, 0, 10, 0) + .expect("clip placement"); // The graph through the same builder `RealEngine::nodes()` / - // `edges()` use (the sequence handle is the engine's). - let (nodes, edges) = unsafe { crate::oakui::nodegraph::build_graph(sequence) }; + // `edges()` use. + let (nodes, edges) = crate::oakui::nodegraph::build_graph(&project, seq); assert!( nodes.len() >= 3, "sequence output + clip + footage (got {} nodes)", @@ -4397,10 +3065,7 @@ mod tests { ); // The output card is the sequence node; a wire lands on it. - let seq_node = unsafe { oakengine_sequence_as_node(sequence) }; - assert!(!seq_node.is_null()); - let output_id = NodeId(unsafe { oakengine_node_identity(seq_node) }); - unsafe { oakengine_node_free(seq_node) }; + let output_id = gpui::node_graph::NodeId(seq.identity()); assert!( nodes.iter().any(|n| n.id == output_id), "the sequence node is the graph's output card" @@ -4414,10 +3079,8 @@ mod tests { "the clip→output wire is the synthesized one" ); - // The footage→clip media edge is a REAL graph edge: the footage - // node carries an outgoing connection (built from the module's - // `output_connection_at_ex`), so its wire is not the synthesized - // kind. + // The footage→clip media edge is a REAL graph edge, so its wire is + // not the synthesized kind. let real_edges = edges .iter() .filter(|e| !crate::oakui::nodegraph::is_output_wire(e.id)) @@ -4427,66 +3090,48 @@ mod tests { "the footage→clip media edge is real (got {real_edges} real edges)" ); - unsafe { free_box(sequence) }; - unsafe { oakengine_project_free(project) }; + oakundo::global::clear().unwrap(); let _ = std::fs::remove_file(&media); } - /// Regression: the source monitor's full-res job used to carry only the - /// footage node box — dropping the project while the job was in flight - /// left the node dangling, and the worker's free path died on a - /// misaligned pointer inside the module's handle table. The request now - /// also carries an addref'd project copy, so this scenario completes - /// (and frees cleanly) instead of crashing. + /// Regression: the source monitor's full-res job renders the selected + /// footage while holding its own project `Arc` — dropping the engine's + /// project reference while the job is in flight leaves the job's copy + /// alive, so the render completes and frees cleanly. #[test] fn full_res_worker_outlives_a_dropped_project() { let _media = media_lock(); - if !RealEngine::ensure_render_manager() { + if !crate::oakui::renderops::ensure_render_manager() { panic!("the render manager failed to start"); } - let project = unsafe { oakengine_project_create() }; - assert!(!project.is_null()); - assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let project = graphops::create_project(); + let seq = graphops::create_sequence(&project, "Full Res Source"); let media = std::env::temp_dir().join(format!( "oakapp_fullres_src_{}.mp4", std::process::id() )); - let media_c = CString::new(media.to_string_lossy().into_owned()).unwrap(); - assert_eq!( - unsafe { oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10) }, - 0 - ); - let footage = unsafe { oakengine_project_import_footage(project, media_c.as_ptr()) }; - assert!(!footage.is_null(), "import must succeed"); + oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10) + .expect("generate test media"); + let footage = graphops::import_footage(&project, &media).expect("import must succeed"); + let tb = graphops::sequence_time_base(&graphops::lock(&project).graph, seq).unwrap(); - // The node box from the project's footage list plus the addref'd - // project copy (what build_full_res_request now does). - let node = unsafe { oakengine_project_footage_at(project, 0) }; - assert!(!node.is_null()); - // SAFETY: `footage` is a live box; the node box is independent. - unsafe { oakengine_footage_free(footage) }; - let handle = unsafe { unbox(project) }.expect("project handle"); - let addref = handle.addref.expect("module handle addref"); - // SAFETY: `handle` is a live module handle; addref takes a new - // reference the copy releases. - unsafe { addref(handle.ctx) }; - let project_copy = unsafe { box_handle::(handle) }; + // The worker's own project copy (what build_full_res_request hands + // it); the caller's reference goes away BEFORE the worker runs — + // the pre-Arc crash window. + let worker_project = project.clone(); + drop(project); - // The engine's own project goes away BEFORE the worker runs — the - // pre-fix crash window. - unsafe { oakengine_project_free(project) }; - - let (tx, rx) = mpsc::channel(); + let (tx, rx) = std_mpsc::channel(); let request = FullResRequest { monitor: Monitor::Source, frame: 0, generation: 1, - target: FullResTarget::Node(SendPtr(node), SendPtr(project_copy)), + project: worker_project, + target: FullResTarget::Footage(footage), width: 64, height: 64, - rate_num: 10, - rate_den: 1, + tb, }; std::thread::spawn(move || RealEngine::full_res_worker(request, tx)); @@ -4495,6 +3140,7 @@ mod tests { .expect("the worker delivers the frame after the project drop"); let bytes = event.image.as_bytes(0).expect("one frame"); assert_eq!(bytes.len(), 64 * 64 * 4, "full-res geometry"); + oakundo::global::clear().unwrap(); let _ = std::fs::remove_file(&media); } @@ -4619,72 +3265,42 @@ mod tests { assert!(current.needs_full_res(7, false)); } - /// M12 P5a end-to-end through the facade: a full-res job (the exact - /// request [`RealEngine::build_full_res_request`] builds) renders a real - /// frame on a background thread — the dedicated sequence renderer is - /// created on that thread, the footage clip is decoded, and the frame is - /// delivered through the completion channel with the renderer and the - /// sequence copy freed by the worker. + /// M12 P5a end-to-end: a full-res job (the exact request + /// [`RealEngine::build_full_res_request`] builds) renders a real frame + /// on a background thread — the footage clip is decoded and the frame + /// is delivered through the completion channel. #[test] fn full_res_worker_renders_real_frame() { let _media = media_lock(); - if !RealEngine::ensure_render_manager() { + if !crate::oakui::renderops::ensure_render_manager() { panic!("the render manager failed to start"); } - let project = unsafe { oakengine_project_create() }; - assert!(!project.is_null()); - assert_eq!(unsafe { oakengine_project_new(project) }, 0); - let name = CString::new("Full Res E2E").unwrap(); - let sequence = unsafe { oakengine_sequence_new(project, name.as_ptr()) }; - assert!(!sequence.is_null()); - assert_eq!( - unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) }, - 0 - ); + let project = graphops::create_project(); + let seq = graphops::create_sequence(&project, "Full Res E2E"); + graphops::add_track(&project, seq, TrackType::Video).expect("add a video track"); let media = std::env::temp_dir().join(format!( "oakapp_fullres_{}.mp4", std::process::id() )); - let media_c = CString::new(media.to_string_lossy().into_owned()).unwrap(); - assert_eq!( - unsafe { oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10) }, - 0 - ); - let footage = unsafe { oakengine_project_import_footage(project, media_c.as_ptr()) }; - assert!(!footage.is_null(), "import must succeed"); - let clip = unsafe { - oakengine_sequence_add_footage_clip_ex( - sequence, - footage, - TRACK_TYPE_VIDEO, - 0, - 0, - 10, - 0, - ) - }; - assert!(!clip.is_null(), "clip placement must succeed"); - unsafe { oakengine_footage_free(footage) }; - unsafe { free_box(clip) }; + oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10) + .expect("generate test media"); + let footage = graphops::import_footage(&project, &media).expect("import must succeed"); + graphops::place_footage_clip(&project, seq, footage, TrackType::Video, 0, 0, 10, 0) + .expect("clip placement"); + let tb = graphops::sequence_time_base(&graphops::lock(&project).graph, seq).unwrap(); - // The addref'd sequence copy the scheduler hands the worker. - let handle = unsafe { unbox(sequence) }.expect("sequence handle"); - let addref = handle.addref.expect("module handle addref"); - unsafe { addref(handle.ctx) }; - let copy = unsafe { box_handle::(handle) }; - - let (tx, rx) = mpsc::channel(); + let (tx, rx) = std_mpsc::channel(); let request = FullResRequest { monitor: Monitor::Program, frame: 0, generation: 1, - target: FullResTarget::Sequence(SendPtr(copy)), + project: project.clone(), + target: FullResTarget::Sequence(seq), width: 320, height: 180, - rate_num: 25, - rate_den: 1, + tb, }; std::thread::spawn(move || RealEngine::full_res_worker(request, tx)); @@ -4704,8 +3320,7 @@ mod tests { .count(); assert!(nonzero > 0, "the footage clip renders non-black pixels"); - unsafe { free_box(sequence) }; - unsafe { oakengine_project_free(project) }; + oakundo::global::clear().unwrap(); let _ = std::fs::remove_file(&media); } diff --git a/src/oakui/renderops.rs b/src/oakui/renderops.rs new file mode 100644 index 000000000..c7a8668b9 --- /dev/null +++ b/src/oakui/renderops.rs @@ -0,0 +1,535 @@ +// 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 . + +//! Montage resolution, ticket rendering and the export driver (M14 R3). +//! +//! The facade's renderer box (`crates/oakengine/src/render.rs`: geometry +//! validation, `build_video_montage` / `build_audio_montage`, +//! `clip_media`) and its export path (`oakengine_task_create_export` + +//! `oakengine_task_start_sync` + the task subscription) are app-side now: +//! the montage builders read the oaknode graph directly and the renders +//! go through the oakrender ticket arena, exactly like the CLI's +//! `engine.rs` (M14 R2). The export drives `oaktask::export::ExportTask` +//! synchronously on a background thread with the module task's event +//! listener and cancel atom wired to the app's [`ExportSession`]. + +use std::sync::mpsc; + +use oakcore_rs::{Rational, TimeRange}; +use oaknode::id::NodeId; +use oaknode::track::TrackType; +use oakrender::manager::RenderManager; +use oakrender::texture::Texture; +use oakrender::ticket::{AudioTicketParams, MontageClip, TicketPayload, VideoTicketParams}; + +use super::engine::{ExportEvent, ExportSession}; +use super::graphops::{ + clip_behavior, lock, sequence_behavior, track_behavior, track_list_behavior, ProjectRef, +}; + +/// The pixel format the viewers render in (`oakcore_rs::PixelFormat::F32`, +/// the pipeline's internal format; the app downconverts to BGRA itself). +pub const PIXEL_FORMAT_F32: i32 = 4; + +// --------------------------------------------------------------------------- +// Render manager +// --------------------------------------------------------------------------- + +/// Bring up the process-wide render manager if it is not running yet +/// (without it the ticket arena rejects submissions). Returns false when +/// the manager could not be started. +pub fn ensure_render_manager() -> bool { + if RenderManager::global().is_some() { + return true; + } + // Already-initialized is success (the module reports State for a + // second init). + let _ = RenderManager::init(); + RenderManager::global().is_some() +} + +// --------------------------------------------------------------------------- +// Montage resolution (the facade's build_video_montage / build_audio_montage +// over the module graph) +// --------------------------------------------------------------------------- + +/// The media `(filename, stream_index)` feeding a clip (upstream BFS + +/// footage behavior); video stream 0. +fn clip_media(g: &oaknode::graph::Graph, block_id: NodeId) -> Option { + super::graphops::clip_media_filename(g, block_id) +} + +/// 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: NodeId, time: Rational) -> Vec { + let g = lock(p); + let mut clips = Vec::new(); + let Some(s) = sequence_behavior(&g.graph, seq) else { + return clips; + }; + for &list_id in &s.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) = clip_behavior(&g.graph, block_id) 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: NodeId, range: TimeRange) -> Vec { + let g = lock(p); + let mut clips = Vec::new(); + let Some(s) = sequence_behavior(&g.graph, seq) else { + return clips; + }; + for &list_id in &s.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) = clip_behavior(&g.graph, block_id) 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 +} + +// --------------------------------------------------------------------------- +// Ticket rendering +// --------------------------------------------------------------------------- + +/// 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, +} + +/// The renderer geometry / format validation (the facade's +/// `make_renderer_box` argument checks). +fn validate_geometry(width: i32, height: i32, tb: (i64, i64)) -> Result<(), String> { + if width <= 0 || height <= 0 || tb.0 <= 0 || tb.1 <= 0 { + return Err("invalid render geometry or frame rate".to_string()); + } + Ok(()) +} + +/// Drive one video ticket synchronously. +fn render_video(params: VideoTicketParams) -> Result { + let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?; + 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(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()), + } +} + +/// Render one frame of the sequence's montage at `frame_ts` (a timestamp +/// in the `(tb.1 / tb.0)`-per-frame timebase) into a `(width, height)` +/// F32 frame. +pub fn render_sequence_frame( + p: &ProjectRef, + seq: NodeId, + frame_ts: i64, + tb: (i64, i64), + width: i32, + height: i32, +) -> Result { + validate_geometry(width, height, tb)?; + let time = Rational::new(frame_ts * tb.0, tb.1); + let montage = video_montage(p, seq, time); + render_video(VideoTicketParams { + viewer: seq.identity(), + time, + force_size: Some((width, height)), + force_format: None, + cache: None, + cache_dir: None, + cache_id: None, + cache_timebase: None, + footage: None, + montage, + }) +} + +/// Render one frame of a single footage node (the source monitor) at +/// `frame_ts`, decoded straight from the media file. +pub fn render_footage_frame( + p: &ProjectRef, + footage: NodeId, + frame_ts: i64, + tb: (i64, i64), + width: i32, + height: i32, +) -> Result { + validate_geometry(width, height, tb)?; + let filename = { + let g = lock(p); + super::graphops::footage_behavior(&g.graph, footage) + .map(|f| f.filename.clone()) + .ok_or_else(|| "the node is not footage".to_string())? + }; + let time = Rational::new(frame_ts * tb.0, tb.1); + render_video(VideoTicketParams { + viewer: footage.identity(), + time, + force_size: Some((width, height)), + force_format: None, + cache: None, + cache_dir: None, + cache_id: None, + cache_timebase: None, + footage: Some((filename, 0)), + montage: Vec::new(), + }) +} + +/// 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_ts, start_ts + len_ts)` (frame +/// timestamps in the `(tb.1 / tb.0)`-per-frame timebase) as interleaved +/// f32 at 48 kHz stereo (the facade's `oakengine_renderer_render_audio` +/// defaults; uncovered parts are silent). +pub fn render_audio_range( + p: &ProjectRef, + seq: NodeId, + start_ts: i64, + len_ts: i64, + tb: (i64, i64), +) -> Result { + if tb.0 <= 0 || tb.1 <= 0 { + return Err("the sequence has no valid frame rate".to_string()); + } + let range = TimeRange::new( + Rational::new(start_ts * tb.0, tb.1), + Rational::new((start_ts + len_ts) * tb.0, tb.1), + ); + let montage = audio_montage(p, seq, range); + let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?; + let id = m.tickets.next_id(); + m.tickets.submit_audio_with_id( + id, + AudioTicketParams { + viewer: seq.identity(), + range, + sample_rate: 48000, + channel_layout: 0x3, + montage, + }, + 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_task_create_export` + sync run over the +// module export task) +// --------------------------------------------------------------------------- + +/// The export audio defaults (the facade's): 48 kHz stereo. +const EXPORT_SAMPLE_RATE: i32 = 48000; +/// Stereo channel-layout bitmask (`OLIVE_CHANNEL_LAYOUT_STEREO`). +const EXPORT_CHANNEL_LAYOUT: u64 = 0x3; + +/// Build the export encoding params for `seq` from the app's export +/// dialog inputs: the container `format`, the target `path`, and the +/// work-area range when enabled (frame timestamps). The codecs are the +/// format's first video/audio codecs (the facade's +/// `oakengine_encoding_format_*_codec_at(format, 0)`). +pub fn encoding_params( + p: &ProjectRef, + seq: NodeId, + format: i32, + path: &std::path::Path, + workarea: Option<(i64, i64)>, + length_frames: i64, +) -> Result { + let container = oakcodec::exportformat::Format::from_i32(format) + .ok_or_else(|| format!("unknown export format {format}"))?; + let video_codec = oakcodec::exportformat::Format::get_video_codecs(container) + .first() + .copied() + .ok_or_else(|| format!("format {format} has no video codec"))?; + let audio_codec = oakcodec::exportformat::Format::get_audio_codecs(container) + .first() + .copied() + .ok_or_else(|| format!("format {format} has no audio codec"))?; + let (width, height, rate) = { + let g = lock(p); + super::graphops::sequence_video_params(&g.graph, seq) + .ok_or_else(|| "the sequence has no video parameters".to_string())? + }; + let rate_num = rate.numerator().max(1) as i32; + let rate_den = rate.denominator().max(1) as i32; + + // Export range: the work area when enabled, otherwise the whole + // sequence. Frames -> seconds rationals in the sequence's frame-rate + // timebase (frame duration = rate_den / rate_num). + let (has_custom_range, range_in, range_out, length) = + match workarea.filter(|(s, e)| e > s) { + Some((in_ts, out_ts)) => ( + true, + Rational::new(in_ts * i64::from(rate_den), i64::from(rate_num)), + Rational::new(out_ts * i64::from(rate_den), i64::from(rate_num)), + Rational::new((out_ts - in_ts) * i64::from(rate_den), i64::from(rate_num)), + ), + None => ( + false, + Rational::new(0, 1), + Rational::new(0, 1), + Rational::new(length_frames.max(0) * i64::from(rate_den), i64::from(rate_num)), + ), + }; + Ok(oaktask::export::EncodingParams { + filename: path.to_string_lossy().into_owned(), + format, + video_enabled: true, + video_codec: video_codec as i32, + video_width: width.max(1), + video_height: height.max(1), + video_time_base_num: rate_den, + video_time_base_den: rate_num, + video_pixel_format: 0, + audio_enabled: true, + audio_codec: audio_codec as i32, + audio_sample_rate: EXPORT_SAMPLE_RATE, + audio_channel_layout: EXPORT_CHANNEL_LAYOUT, + subtitles_enabled: false, + export_length_num: length.numerator() as i32, + export_length_den: length.denominator() as i32, + has_custom_range, + custom_range_in_num: range_in.numerator() as i32, + custom_range_in_den: range_in.denominator() as i32, + custom_range_out_num: range_out.numerator() as i32, + custom_range_out_den: range_out.denominator() as i32, + }) +} + +/// Start an export of `seq` with `params` on a background thread; the +/// returned session carries the event channel and the cancel handle (the +/// facade's export task wiring over the module task's event listener and +/// cancel atom). +pub fn spawn_export( + p: &ProjectRef, + seq: NodeId, + params: oaktask::export::EncodingParams, +) -> ExportSession { + let (tx, rx) = mpsc::channel::(); + let mut driver = oaktask::task::Task::new("Exporting...", None); + let cancel_atom = driver.get_cancel_atom(); + { + let tx = tx.clone(); + driver.set_event_listener(Box::new(move |event| { + let event = match event { + oaktask::task::TaskEvent::Started => ExportEvent::Started, + oaktask::task::TaskEvent::Progress(value) => ExportEvent::Progress(value), + // Finished is reported by the worker below (with the error). + oaktask::task::TaskEvent::Finished => return, + }; + let _ = tx.send(event); + })); + } + driver.set_behavior(Box::new(oaktask::export::ExportTask::new( + (p.clone(), seq), + params, + ))); + std::thread::spawn(move || { + let result = driver.start(); + let error = if result.is_ok() { + String::new() + } else { + driver + .error() + .map(|s| s.to_string()) + .unwrap_or_else(|| "export failed".to_string()) + }; + let _ = tx.send(ExportEvent::Finished(result.is_ok(), error)); + }); + ExportSession { + events: rx, + cancel: Box::new(move || cancel_atom.cancel()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::oakui::graphops; + + /// Serializes the media/FFmpeg-heavy tests (the codec library is not + /// thread-safe against concurrent decode sessions) and shares the + /// process-global undo stack with the other app test modules. + fn media_lock() -> std::sync::MutexGuard<'static, ()> { + crate::oakui::graphops::test_lock() + } + + /// A project with an HD sequence carrying one clip of the generated + /// media on its video track. + fn project_with_clip(media: &std::path::Path) -> (ProjectRef, NodeId, NodeId) { + let project = graphops::create_project(); + let seq = graphops::create_sequence(&project, "Montage Test"); + let footage = graphops::import_footage(&project, media).expect("import the generated media"); + graphops::add_track(&project, seq, TrackType::Video).expect("add a video track"); + graphops::place_footage_clip(&project, seq, footage, TrackType::Video, 0, 0, 10, 0) + .expect("place the clip"); + (project, seq, footage) + } + + #[test] + fn video_montage_covers_the_clip_range() { + let _media = media_lock(); + let media = std::env::temp_dir().join(format!("oakapp_montage_{}.mp4", std::process::id())); + oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media"); + + let (project, seq, _) = project_with_clip(&media); + let tb = graphops::sequence_time_base(&lock(&project).graph, seq).unwrap(); + let at = |frame: i64| graphops::ts_to_rational(frame, tb); + + let montage = video_montage(&project, seq, at(0)); + assert_eq!(montage.len(), 1, "one clip covers frame 0"); + assert_eq!(montage[0].filename, media.to_string_lossy()); + assert!(video_montage(&project, seq, at(9)).len() == 1, "frame 9 is still covered"); + assert!( + video_montage(&project, seq, at(10)).is_empty(), + "frame 10 is past the clip's out point" + ); + assert!( + video_montage(&project, seq, at(-1)).is_empty(), + "a negative time covers nothing" + ); + oakundo::global::clear().unwrap(); + let _ = std::fs::remove_file(&media); + } + + #[test] + fn audio_montage_overlaps_the_range() { + let _media = media_lock(); + let media = std::env::temp_dir().join(format!("oakapp_montage_a_{}.mp4", std::process::id())); + oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media"); + + let (project, seq, footage) = project_with_clip(&media); + graphops::add_track(&project, seq, TrackType::Audio).expect("add an audio track"); + graphops::place_footage_clip(&project, seq, footage, TrackType::Audio, 0, 0, 10, 0) + .expect("place the audio clip"); + let tb = graphops::sequence_time_base(&lock(&project).graph, seq).unwrap(); + let at = |frame: i64| graphops::ts_to_rational(frame, tb); + + let overlapping = audio_montage(&project, seq, TimeRange::new(at(0), at(5))); + assert_eq!(overlapping.len(), 1, "the range overlaps the audio clip"); + assert_eq!(overlapping[0].stream_index, 1, "the audio stream is selected"); + let disjoint = audio_montage(&project, seq, TimeRange::new(at(10), at(20))); + assert!(disjoint.is_empty(), "a range past the clip overlaps nothing"); + oakundo::global::clear().unwrap(); + let _ = std::fs::remove_file(&media); + } +} diff --git a/src/oakui/waveform.rs b/src/oakui/waveform.rs index 18d2304c4..c4fc397a1 100644 --- a/src/oakui/waveform.rs +++ b/src/oakui/waveform.rs @@ -17,16 +17,16 @@ //! M12 P4: timeline audio waveforms. //! //! The [`WaveformCache`] holds per-clip min/max peak data extracted -//! through `oakengine_waveform_extract` (the real FFmpeg-backed -//! extraction); the engine refreshes it when the timeline rebuilds. The -//! [`OakClipDecorator`] draws the peaks into the timeline's clip body. +//! through `oakaudio::waveform::extract` (the real FFmpeg-backed +//! extraction; M14 R3: a direct module call, no facade); the engine +//! refreshes it when the timeline rebuilds. The [`OakClipDecorator`] draws +//! the peaks into the timeline's clip body. //! //! The extraction is keyed by `(clip id, filename)` and cached for the //! engine lifetime; the plan's disk cache is a later refinement (the //! extractor itself is the real implementation). use std::collections::HashMap; -use std::ffi::{c_char, c_int}; use std::sync::{Arc, Mutex}; use gpui::timeline::clip::ClipDecorator; @@ -100,53 +100,35 @@ impl WaveformCache { } } -/// Extract a clip's waveform (first channel) via the facade's waveform -/// C ABI. +/// Extract a clip's waveform (first channel) via oakaudio's waveform +/// extractor. fn extract(filename: &str, duration_frames: i64, _fps: f32) -> Option { let cname = std::ffi::CString::new(filename).ok()?; - const SAMPLES_PER_POINT: c_int = 256; - unsafe { - let mut channels: c_int = 0; - let needed = crate::oakui::ffi::oakengine_waveform_extract( - cname.as_ptr(), - 0, // audio-stream index (probe numbering) - SAMPLES_PER_POINT, - std::ptr::null_mut(), - 0, - &mut channels, - ); - if needed <= 0 || channels <= 0 { - return None; - } - // The extractor's `out_pairs` receives `point_count * - // channel_count` channel-interleaved pairs (capacity is in points), - // so the buffer is sized for the media's channel count. - let channel_count = channels.max(1) as usize; - let mut raw = vec![MinMax::default(); needed as usize * channel_count]; - let rc = crate::oakui::ffi::oakengine_waveform_extract( - cname.as_ptr(), - 0, - SAMPLES_PER_POINT, - raw.as_mut_ptr(), - needed, - &mut channels, - ); - if rc < 0 { - return None; - } - // Keep only the first channel: the pairs are interleaved per point - // (point i channel c at index i * channels + c). - let count = rc.min(needed) as usize; - let mut peaks = Vec::with_capacity(count); - peaks.extend((0..count).map(|i| raw[i * channel_count])); - Some(ClipWaveform { - peaks, - channel_count: channels.max(1), - sample_rate: 48000, - samples_per_point: SAMPLES_PER_POINT, - duration_frames, - }) + const SAMPLES_PER_POINT: i32 = 256; + // Audio-stream index 0 (probe numbering). + let outcome = oakaudio::waveform::extract(&cname, 0, SAMPLES_PER_POINT).ok()?; + if outcome.channels <= 0 || outcome.points.is_empty() { + return None; } + // Keep only the first channel: the points are channel-interleaved + // (point i channel c at index i * channels + c). + let channel_count = outcome.channels.max(1) as usize; + let count = outcome.points.len() / channel_count; + let mut peaks = Vec::with_capacity(count); + peaks.extend((0..count).map(|i| { + let p = outcome.points[i * channel_count]; + MinMax { + min: p.min, + max: p.max, + } + })); + Some(ClipWaveform { + peaks, + channel_count: outcome.channels.max(1), + sample_rate: 48000, + samples_per_point: SAMPLES_PER_POINT, + duration_frames, + }) } /// The timeline's clip decorator: draws extracted waveforms into audio diff --git a/tests/waveform_e2e.rs b/tests/waveform_e2e.rs index 1bf83cb76..64fe9b692 100644 --- a/tests/waveform_e2e.rs +++ b/tests/waveform_e2e.rs @@ -20,20 +20,16 @@ //! Runs in its own test binary: the FFmpeg teardown state after a video //! decode + an audio decode in one process crashes at exit, so the //! waveform test stays isolated from the in-lib media tests. +//! +//! M14 R3: the extraction is a direct `oakaudio::waveform::extract` call +//! (no facade). -use oakapp::oakui::ffi::{ - oakengine_waveform_extract, oakengine_testmedia_write_clip, oakapp_minmax, -}; -use oakapp::oakui::waveform::{WaveformCache, MinMax}; +use oakapp::oakui::waveform::{MinMax, WaveformCache}; #[test] fn waveform_extract_and_cache_hit() { let media = std::env::temp_dir().join(format!("oakapp_waveform_{}.mp4", std::process::id())); - let cpath = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap(); - assert_eq!( - unsafe { oakengine_testmedia_write_clip(cpath.as_ptr(), 64, 64, 10, 10) }, - 0 - ); + oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media"); let filename = media.to_string_lossy().into_owned(); let cache = WaveformCache::new(25.0); cache.refresh(7, &filename, 250); @@ -52,9 +48,13 @@ fn waveform_extract_and_cache_hit() { let _ = std::fs::remove_file(&media); } -/// The MinMax mirror must stay layout-compatible with the oakaudio C ABI. +/// The MinMax mirror matches the oakaudio waveform point layout (two +/// f32s). #[test] fn minmax_layout_is_two_f32s() { assert_eq!(std::mem::size_of::(), 8); - assert_eq!(std::mem::size_of::(), 8); + assert_eq!( + std::mem::size_of::(), + 8 + ); }