refactor(app): cut liboakengine, link module rlibs directly (M14 R3)
- real.rs rewritten over module Rust APIs (Arc<Mutex<Project>> + NodeId; the addref handle dance and renderer boxes are gone); AppEngine trait and all panels untouched - new app assembly layers: graphops (project/timeline/edit primitives), effectchain (chain composition with undo groups), renderops (montage build + ticket render + ExportTask export), library via oakstorage directly - module-side safe API additions: oakundo global value-semantic push/undo/redo + from_closures, oakstorage project_arc_of - deleted: src/oakui/ffi.rs, src/oakui/host_syms.rs, the dylib link config in build.rs (only the gpui IOSurface framework link remains) - the binary carries zero liboakengine references (otool/nm verified); 101 app tests green incl. the real-render and full-res e2e tests - behavior improvements for free: sequences land in the project graph (the facade scratch-project deviation is gone), footage drops take one undo record, effect remove/reorder undo restores edges
This commit is contained in:
Generated
+10
-2
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
+24
-17
@@ -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 = []
|
||||
|
||||
@@ -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/<profile>/deps/liboakengine.dylib` — when built as a
|
||||
//! dependency of the app (the normal case),
|
||||
//! * `target/<profile>/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/<profile>/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-<hash>.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-<hash>.{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<PathBuf> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ProjectArc> {
|
||||
// SAFETY: the documented precondition — handles from this module's
|
||||
// producers box a `ProjectArc`.
|
||||
unsafe { oaknode::handle::get::<ProjectArc>(h) }.cloned()
|
||||
}
|
||||
|
||||
/// Read the boxed project of a project handle.
|
||||
///
|
||||
/// # Safety
|
||||
|
||||
+111
-19
@@ -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<OpenGroup>> {
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn FnMut() + Send>,
|
||||
/// The undo closure.
|
||||
undo: Box<dyn FnMut() + Send>,
|
||||
}
|
||||
|
||||
/// `redo` trampoline for closure commands.
|
||||
///
|
||||
/// # Safety
|
||||
/// `userdata` must be the `Box<ClosureCommand>` 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
|
||||
|
||||
+2
-2
@@ -541,7 +541,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
.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<E: AppEngine>(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();
|
||||
|
||||
+6
-6
@@ -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<PreferencesEvent> 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) =
|
||||
|
||||
+6
-4
@@ -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
|
||||
//!
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<String> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
let mut chain = Vec::new();
|
||||
let mut cur = host;
|
||||
let mut seen: Vec<NodeId> = 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<Mutex<(Option<NodeEntry>, Option<NodeId>)>>;
|
||||
|
||||
/// 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<T>(name: &str, f: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
|
||||
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>, 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<NodeId, String> {
|
||||
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<NodeId> = 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-10
@@ -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;
|
||||
|
||||
-1103
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<HashMap<usize, MockAudioParams>> {
|
||||
static S: OnceLock<Mutex<HashMap<usize, MockAudioParams>>> = 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<HashMap<usize, (i32, i32)>> {
|
||||
static S: OnceLock<Mutex<HashMap<usize, (i32, i32)>>> = 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;
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -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)
|
||||
|
||||
+362
-609
File diff suppressed because it is too large
Load Diff
+55
-149
@@ -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<ProjectEntry> {
|
||||
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<ProjectEntry> {
|
||||
let Some(f) = g
|
||||
.get(folder)
|
||||
.and_then(|e| e.behavior.as_any())
|
||||
.and_then(|a| a.downcast_ref::<FolderBehavior>())
|
||||
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<ProjectEntry> {
|
||||
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<ProjectEntry> {
|
||||
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<ProjectEntry> {
|
||||
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<ProjectEntry> {
|
||||
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<ProjectEntry> {
|
||||
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<NodeId> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1038
-2423
File diff suppressed because it is too large
Load Diff
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<String> {
|
||||
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<MontageClip> {
|
||||
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<MontageClip> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
/// 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<RenderedFrame, String> {
|
||||
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<RenderedFrame, String> {
|
||||
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<RenderedFrame, String> {
|
||||
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<f32>,
|
||||
/// 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<RenderedAudio, String> {
|
||||
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<oaktask::export::EncodingParams, String> {
|
||||
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::<ExportEvent>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
+30
-48
@@ -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<ClipWaveform> {
|
||||
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
|
||||
|
||||
+11
-11
@@ -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::<MinMax>(), 8);
|
||||
assert_eq!(std::mem::size_of::<oakapp_minmax>(), 8);
|
||||
assert_eq!(
|
||||
std::mem::size_of::<oakaudio::waveform::SamplePerChannel>(),
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user