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:
2026-08-16 23:36:43 +08:00
parent a45a7af2ac
commit 022c0a7a5a
22 changed files with 4345 additions and 4784 deletions
Generated
+10 -2
View File
@@ -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
View File
@@ -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 = []
+10 -111
View File
@@ -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
}
+3 -1
View File
@@ -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.
+26
View File
@@ -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
View File
@@ -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());
}
}
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
//!
+572
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-258
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+55 -149
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+535
View File
@@ -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
View File
@@ -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
View File
@@ -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
);
}