diff --git a/Cargo.lock b/Cargo.lock
index f7a56abd0..78a529c16 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4753,12 +4753,21 @@ name = "oak-cli"
version = "0.1.0"
dependencies = [
"clap",
+ "oakcodec",
+ "oakcommon",
+ "oakcore-rs",
+ "oaknode",
+ "oakrender",
+ "oaktask",
+ "oaktimeline",
]
[[package]]
name = "oak-worker"
version = "0.1.0"
dependencies = [
+ "libc",
+ "oakrender",
"serde",
"serde_json",
]
diff --git a/crates/oak-cli/Cargo.toml b/crates/oak-cli/Cargo.toml
index 397833d6b..a36daec51 100644
--- a/crates/oak-cli/Cargo.toml
+++ b/crates/oak-cli/Cargo.toml
@@ -18,7 +18,7 @@
name = "oak-cli"
version = "0.1.0"
edition = "2021"
-description = "Oak Video Editor headless command-line consumer of the liboakengine C ABI facade (Rust)"
+description = "Oak Video Editor headless command-line consumer of the oak editor module crates (Rust)"
license = "GPL-3.0-or-later"
[[bin]]
@@ -28,13 +28,16 @@ path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
-# oak-cli is a PURE C-ABI consumer of the built `liboakengine` cdylib:
-# every engine call goes through the `extern "C"` declarations in
-# src/ffi.rs (and the dlsym-resolved optional families in src/optional.rs).
-# No oak* crate appears here — not even oakengine, which is cdylib-only:
-# the link happens through build.rs (rustc-link-search + rpath pointing at
-# the profile dir `cargo build -p oakengine` produces the dylib in) and
-# the `#[link(name = "oakengine", kind = "dylib")]` block in src/ffi.rs.
-# Build order: `cargo build -p oakengine` first, then `cargo build` /
-# `cargo test -p oak-cli` (linking and running the binary need the dylib;
-# `cargo check` does not link and works standalone).
+# M14 R2: oak-cli is a PURE module-crate consumer — every engine call is a
+# direct Rust call into the oak* rlibs (oaknode for projects/footage,
+# oaktimeline for the track/clip commands, oakrender for the ticket arena,
+# oakcodec for the export formats/codecs, oaktask for the export task,
+# oakcommon/oakcore-rs for the shared value types). No liboakengine dylib,
+# no C ABI, no build.rs link step, no host shims.
+oakcommon = { path = "../oakcommon" }
+oakcore-rs = { path = "../oakcore" }
+oaknode = { path = "../oaknode" }
+oaktimeline = { path = "../oaktimeline" }
+oakcodec = { path = "../oakcodec" }
+oakrender = { path = "../oakrender" }
+oaktask = { path = "../oaktask" }
diff --git a/crates/oak-cli/README.md b/crates/oak-cli/README.md
index e08f42ce5..196a024d6 100644
--- a/crates/oak-cli/README.md
+++ b/crates/oak-cli/README.md
@@ -1,6 +1,6 @@
# oak-cli (Rust)
-Headless command-line consumer of the `liboakengine` C ABI facade — the Rust
+Headless command-line consumer of the oak editor module crates — the Rust
rewrite of `cli/main.cpp` (which stays in the tree until cutover). Same
subcommands, same output format, same exit codes:
@@ -8,19 +8,20 @@ subcommands, same output format, same exit codes:
|---|---|
| 0 | success |
| 1 | general error (bad project/media file, no sequence, I/O failure) |
-| 2 | rendering unavailable or failed (e.g. no GL render backend) |
+| 2 | rendering unavailable or failed (e.g. no render backend) |
| 64 | usage error |
## Build and test
```sh
cargo build --release # binary: target/release/oak-cli
-cargo test # unit + integration tests (29 tests)
+cargo test # unit + integration tests
```
-The crate builds standalone: its only dependency besides `clap` is the
-`oakengine` rlib (`../oakengine`), which has no third-party
-dependencies.
+The crate is **self-contained** (M14 R2): it links the oak* module rlibs
+directly (`oaknode`, `oaktimeline`, `oakcodec`, `oakrender`, `oaktask`,
+`oakcommon`) — no `liboakengine` dylib, no C ABI, no build.rs link step.
+`cargo test -p oak-cli` stands alone.
## Subcommands
@@ -41,40 +42,17 @@ fixtures (`tests/project_with_footage.ove`, `tests/demo.mp4`); the PPM and
WAV writers (`src/ppm.rs`, `src/wav.rs`) are the exact ports of the C++
`write_ppm`/`write_wav` and are unit-tested.
-## Facade status: everything is currently deferred
-
-All four subcommands depend on facade families that are still **deferred**
-in the `oakengine` crate (`crates/oakengine/src/deferred.rs`), so today each
-subcommand validates its arguments, then prints a clear "not yet available"
-error naming the missing families and the reasons, and exits with the
-C++-compatible code — it never crashes and never fakes output:
-
-| subcommand | needs | current behavior |
-|---|---|---|
-| `info` | init + node (project/footage) + timeline | "not yet available", exit 1 |
-| `probe` | init + node (footage) | "not yet available", exit 1 |
-| `render` | init + node + timeline + render | "not yet available", exit 2 |
-| `transcode` | init + node + timeline + render + exporter | "not yet available", exit 2 |
-
-The deferral registry is `src/deferred.rs` (field-for-field in sync with the
-facade's own `deferred.rs`). When a family is wrapped by the facade:
-
-1. remove its entry from `src/deferred.rs`,
-2. wire the call-through in `src/cmd/` using the extern declarations in
- `src/ffi.rs` (verbatim mirrors of the engine headers) and the tested
- formatters/writers — no manifest or signature change is needed, because
- the externs resolve against the already-linked `oakfacade` rlib.
-
## Layout
```
src/
- main.rs clap surface, --help/-h + unknown-command handling, dispatch
- ffi.rs the oakengine_* surface oak-cli consumes (declarations only)
- deferred.rs facade-family availability registry (mirror of facade deferred.rs)
- fmt.rs golden output formatters (info/probe)
- ppm.rs P6 PPM writer (f32/u8 frames)
- wav.rs PCM s16 WAV writer (interleaved float samples)
- cmd/ per-subcommand validation + deferred gate
-tests/cli.rs binary-level tests (exit codes, messages, usage errors)
+ main.rs clap surface, --help/-h + unknown-command handling, dispatch
+ engine.rs module-native assembly layer (M14 R2): project load/create,
+ footage probe, sequence + clip assembly, montage resolution,
+ ticket rendering, synchronous export
+ fmt.rs golden output formatters (info/probe)
+ ppm.rs P6 PPM writer (f32/u8 frames)
+ wav.rs PCM s16 WAV writer (interleaved float samples)
+ cmd/ per-subcommand validation + module-crate calls
+tests/cli.rs binary-level tests (exit codes, messages, usage errors)
```
diff --git a/crates/oak-cli/build.rs b/crates/oak-cli/build.rs
deleted file mode 100644
index fec83bcc1..000000000
--- a/crates/oak-cli/build.rs
+++ /dev/null
@@ -1,58 +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 .
-
-//! Link configuration for the `oak-cli` binary.
-//!
-//! oak-cli is a pure C-ABI consumer of the built `liboakengine` cdylib
-//! (crates/oakengine): the `#[link(name = "oakengine", kind = "dylib")]`
-//! block in `src/ffi.rs` puts `-loakengine` into the binary link, and this
-//! script points the linker (and dyld, via the rpath) at the directory
-//! that holds the dylib.
-//!
-//! The dylib is produced by the engine's own build (`cargo build -p
-//! oakengine`); as a workspace member it lands in `target//`
-//! (un-hashed, unlike dependency artifacts). The profile dir is derived
-//! from `OUT_DIR` — `target//build/oak-cli-/out` — by
-//! walking three ancestors up, so custom `CARGO_TARGET_DIR` layouts work
-//! without duplication.
-//!
-//! `-Wl,-export_dynamic` exports the binary's own symbols: the CLI is the
-//! *host* process for the engine dylib (exactly like the C++ cli/main.cpp
-//! host), so the `oakcore_audioparams_*` shims in `src/host.rs` must be
-//! visible to the dylib's runtime lookups (its `-undefined
-//! dynamic_lookup` imports).
-//!
-//! Build order: `cargo build -p oakengine` must have run before the
-//! binary link (`cargo build -p oak-cli`, `cargo test -p oak-cli`).
-//! `cargo check` never links, so it stays green without the dylib.
-
-fn main() {
- let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap_or_default());
- // out -> oak-cli- -> build -> (debug/release)
- let profile_dir = out_dir
- .ancestors()
- .nth(3)
- .expect("OUT_DIR has a profile ancestor");
- println!("cargo:rustc-link-search=native={}", profile_dir.display());
- if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
- println!("cargo:rustc-link-arg=-Wl,-rpath,{}", profile_dir.display());
- println!("cargo:rustc-link-arg=-Wl,-export_dynamic");
- } else {
- println!("cargo:rustc-link-arg=-Wl,-rpath,{}", profile_dir.display());
- println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN");
- println!("cargo:rustc-link-arg=-Wl,--export-dynamic");
- }
-}
diff --git a/crates/oak-cli/src/cmd/info.rs b/crates/oak-cli/src/cmd/info.rs
index ecedb4779..c2f8ba460 100644
--- a/crates/oak-cli/src/cmd/info.rs
+++ b/crates/oak-cli/src/cmd/info.rs
@@ -17,99 +17,71 @@
//! `oak-cli info ` — print the project name, its sequences and
//! its footage (port of `cmd_info()` in cli/main.cpp).
//!
-//! Runs entirely through the C ABI: `oakengine_init(OAKENGINE_INIT_HEADLESS)`
-//! → `oakengine_project_create` + `oakengine_project_load(path)` →
-//! `oakengine_project_name`/`filename`/`is_modified`/`sequence_count`/
-//! `sequence_at` (+ the `oakengine_sequence_*` getters) /`footage_count`/
-//! `footage_filename` → `oakengine_project_free` + `oakengine_shutdown()`.
-//! The output is formatted by `crate::fmt` exactly like the C++ binary.
+//! Runs entirely through the module crates (M14 R2):
+//! [`crate::engine::load_project`] (the oaknode serializer) produces the
+//! project, and the project's graph supplies the sequences and footage
+//! (arena order, like the facade's `project_sequence_at` walk). The output
+//! is formatted by `crate::fmt` exactly like the C++ binary.
//!
//! Footage filenames stored relative to the `.ove` file are resolved
-//! against the project directory for display (the C++ CLI's project-dir
+//! against the project directory for display (the C++ project-dir
//! convention); the online flag reports whether the resolved file exists.
-//! A load failure prints the engine's error and exits 1.
+//! A load failure prints the module's error and exits 1.
-use std::ffi::CString;
use std::path::Path;
use crate::cmd::{EXIT_ERROR, EXIT_OK};
-use crate::ffi;
+use crate::engine;
use crate::fmt;
/// Run `info`. `project` is the .ove path from the command line.
pub fn run(project: String) -> i32 {
- let code = run_info(&project);
- unsafe {
- crate::optional::engine_shutdown();
- }
- code
+ run_info(&project)
}
-/// The info body; the caller owns the engine shutdown.
+/// The info body.
fn run_info(project: &str) -> i32 {
- let rc = unsafe { crate::optional::engine_init(crate::ffi::OAKENGINE_INIT_HEADLESS) };
- if rc != crate::ffi::OAKENGINE_OK {
- eprintln!("error: info: engine init failed ({rc})");
- return EXIT_ERROR;
- }
-
- let handle = unsafe { crate::ffi::oakengine_project_create() };
- if handle.is_null() {
- eprintln!("error: info: cannot create project");
- return EXIT_ERROR;
- }
-
- let path = match CString::new(project) {
+ let project_ref = match engine::load_project(project) {
Ok(p) => p,
- Err(_) => {
- eprintln!("error: info: invalid path (NUL byte)");
- unsafe { crate::ffi::oakengine_project_free(handle) };
+ Err(detail) => {
+ if detail.is_empty() {
+ eprintln!("error: info: cannot load project \"{project}\"");
+ } else {
+ eprintln!("error: info: {detail}");
+ }
return EXIT_ERROR;
}
};
- let mut err = [0 as std::ffi::c_char; 4096];
- let rc = unsafe {
- crate::ffi::oakengine_project_load(handle, path.as_ptr(), err.as_mut_ptr(), err.len() as i32)
- };
- if rc != crate::ffi::OAKENGINE_OK {
- // SAFETY: the engine NUL-terminates `err` on failure.
- let detail = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }
- .to_string_lossy()
- .into_owned();
- if detail.is_empty() {
- eprintln!("error: info: cannot load project \"{project}\"");
- } else {
- eprintln!("error: info: {detail}");
- }
- unsafe { crate::ffi::oakengine_project_free(handle) };
- return EXIT_ERROR;
- }
- let name = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_project_name(handle, buf, size)
- });
- let filename = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_project_filename(handle, buf, size)
- });
- let modified = unsafe { crate::ffi::oakengine_project_is_modified(handle) } != 0;
-
- // The engine's serializer swaps a fresh project payload in on load,
- // wiping the pre-load filename (documented engine behavior); when the
- // engine reports an empty filename the CLI falls back to the path it
- // loaded — the C++ CLI's own project filename convention.
+ // The module serializer swaps a fresh project payload in on load,
+ // wiping the pre-load filename; when the project reports an empty
+ // filename the CLI falls back to the path it loaded — the C++ CLI's own
+ // project filename convention.
let abs = std::fs::canonicalize(project).unwrap_or_else(|_| Path::new(project).to_path_buf());
- let name = if name.is_empty() || name == "(untitled)" {
- abs.file_name()
- .map(|f| f.to_string_lossy().into_owned())
- .and_then(|f| f.split('.').next().map(|s| s.to_string()))
- .unwrap_or_else(|| "(untitled)".to_string())
- } else {
- name
+ let name = {
+ let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
+ let n = engine::project_name(&guard);
+ if n.is_empty() || n == "(untitled)" {
+ abs.file_name()
+ .map(|f| f.to_string_lossy().into_owned())
+ .and_then(|f| f.split('.').next().map(|s| s.to_string()))
+ .unwrap_or_else(|| "(untitled)".to_string())
+ } else {
+ n
+ }
};
- let filename = if filename.is_empty() {
- abs.to_string_lossy().into_owned()
- } else {
- filename
+ let filename = {
+ let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
+ let f = engine::project_filename(&guard);
+ if f.is_empty() {
+ abs.to_string_lossy().into_owned()
+ } else {
+ f
+ }
+ };
+ let modified = {
+ let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
+ engine::project_modified(&guard)
};
println!("{}", fmt::project_line(&name));
@@ -120,24 +92,25 @@ fn run_info(project: &str) -> i32 {
// resolve them for the online check (the C++ project-dir convention).
let project_dir = Path::new(project).parent().map(|p| p.to_path_buf());
- let sequences = unsafe { crate::ffi::oakengine_project_sequence_count(handle) }.max(0);
- println!("{}", fmt::sequences_line(sequences as i64));
- for index in 0..sequences {
- // `sequence_at` returns an owned box with no matching free
- // export (borrowed contract); it stays alive for the project.
- let seq = unsafe { crate::ffi::oakengine_project_sequence_at(handle, index) };
- if seq.is_null() {
- continue;
- }
- print_sequence(seq, index as i64);
+ let sequences = {
+ let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
+ engine::sequence_ids(&guard)
+ };
+ println!("{}", fmt::sequences_line(sequences.len() as i64));
+ for (index, seq_id) in sequences.iter().enumerate() {
+ print_sequence(&project_ref, *seq_id, index as i64);
}
- let footage = unsafe { crate::ffi::oakengine_project_footage_count(handle) }.max(0);
- println!("{}", fmt::footage_line(footage as i64));
- for index in 0..footage {
- let stored = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_project_footage_filename(handle, index, buf, size)
- });
+ let footage = {
+ let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
+ engine::footage_ids(&guard)
+ };
+ println!("{}", fmt::footage_line(footage.len() as i64));
+ for (index, footage_id) in footage.iter().enumerate() {
+ let stored = {
+ let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
+ engine::footage_filename(&guard, *footage_id)
+ };
let resolved = resolve_footage(&stored, project_dir.as_deref());
let online = resolved.is_file();
println!(
@@ -146,67 +119,68 @@ fn run_info(project: &str) -> i32 {
);
}
- unsafe {
- crate::ffi::oakengine_project_free(handle);
- }
EXIT_OK
}
-/// Print one sequence block (`print_sequence` in cli/main.cpp) through
-/// the `oakengine_sequence_*` getters.
-fn print_sequence(seq: *mut ffi::OakEngineSequence, index: i64) {
- let name = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_sequence_name(seq, buf, size)
- });
+/// Print one sequence block (`print_sequence` in cli/main.cpp) through the
+/// module sequence queries.
+fn print_sequence(project: &engine::ProjectRef, seq_id: oaknode::id::NodeId, index: i64) {
+ let (name, length, frame_rate, track_counts, playhead) = {
+ let guard = project.lock().unwrap_or_else(|e| e.into_inner());
+ (
+ engine::node_label(&guard.graph, seq_id),
+ engine::sequence_length(&guard, seq_id),
+ engine::sequence_frame_rate(&guard, seq_id),
+ engine::sequence_track_counts(&guard, seq_id),
+ engine::sequence_playhead(&guard, seq_id),
+ )
+ };
- let mut length = 0.0f64;
- let mut len_num: i32 = 0;
- let mut len_den: i32 = 0;
- unsafe {
- let _ = crate::ffi::oakengine_sequence_get_length(seq, &mut length);
- let _ = crate::ffi::oakengine_sequence_get_length_rational(seq, &mut len_num, &mut len_den);
- }
-
- let mut fr_num: i32 = 0;
- let mut fr_den: i32 = 0;
- unsafe {
- let _ = crate::ffi::oakengine_sequence_get_frame_rate(seq, &mut fr_num, &mut fr_den);
- }
-
- let mut video: i32 = 0;
- let mut audio: i32 = 0;
- let mut subtitle: i32 = 0;
- unsafe {
- let _ = crate::ffi::oakengine_sequence_track_count(
- seq,
- &mut video,
- &mut audio,
- &mut subtitle,
- );
- }
-
- let mut playhead: i64 = 0;
- let mut playhead_seconds = 0.0f64;
- unsafe {
- let _ = crate::ffi::oakengine_sequence_get_playhead(seq, &mut playhead);
- let _ = crate::ffi::oakengine_sequence_get_playhead_seconds(seq, &mut playhead_seconds);
- }
+ let length_secs = if length.denominator() != 0 {
+ length.numerator() as f64 / length.denominator() as f64
+ } else {
+ 0.0
+ };
+ let playhead_secs = if playhead.denominator() != 0 {
+ playhead.numerator() as f64 / playhead.denominator() as f64
+ } else {
+ 0.0
+ };
+ // The playhead is printed as a frame timestamp in the sequence frame-rate
+ // timebase (round-half-up, like the facade's `rational_to_ts`).
+ let playhead_ts = {
+ let fr = frame_rate;
+ if fr.denominator() == 0 {
+ 0
+ } else {
+ let n = playhead.numerator() as i128 * fr.denominator() as i128;
+ let d = playhead.denominator() as i128 * fr.numerator() as i128;
+ if d == 0 {
+ 0
+ } else {
+ let q = n / d;
+ let r = (n % d).abs();
+ let dd = d.abs();
+ (q + if r * 2 >= dd { 1 } else { 0 }) as i64
+ }
+ }
+ };
println!(
"{}",
fmt::sequence(
index,
&name,
- length,
- len_num as i64,
- len_den as i64,
- fr_num as i64,
- fr_den as i64,
- video as i64,
- audio as i64,
- subtitle as i64,
- playhead,
- playhead_seconds,
+ length_secs,
+ length.numerator(),
+ length.denominator(),
+ frame_rate.numerator(),
+ frame_rate.denominator(),
+ track_counts.0,
+ track_counts.1,
+ track_counts.2,
+ playhead_ts,
+ playhead_secs,
)
);
}
diff --git a/crates/oak-cli/src/cmd/mod.rs b/crates/oak-cli/src/cmd/mod.rs
index c41d32518..3ba80c09a 100644
--- a/crates/oak-cli/src/cmd/mod.rs
+++ b/crates/oak-cli/src/cmd/mod.rs
@@ -16,18 +16,17 @@
//! Subcommand implementations.
//!
-//! Every subcommand is a REAL implementation over the `oakengine_*` C ABI
-//! ([`crate::ffi`] + [`crate::optional`]) — a pure consumer of the built
-//! `liboakengine` dylib, exactly like the C++ `cli/main.cpp` host:
+//! Every subcommand is a REAL implementation over the oak* module crates
+//! ([`crate::engine`] + the modules directly) — M14 R2 cut the facade
+//! dylib out of this crate:
//!
-//! - `probe` → `oakengine_footage_probe` + the footage getters
-//! - `info` → `oakengine_project_create/load` + project/sequence
-//! getters
-//! - `render` → `oakengine_render_manager_init`,
-//! `oakengine_renderer_create` / `render_frame` / `render_audio` and
-//! the frame/audio-buffer accessors
-//! - `transcode` → project/sequence/clip assembly +
-//! `oakengine_export_render` for mp4, the renderer frame loop for ppm
+//! - `probe` → an `oaknode::footage::FootageBehavior` probe
+//! - `info` → `crate::engine::load_project` + the project graph
+//! walks
+//! - `render` → `crate::engine::render_manager_init` + the video/
+//! audio montage tickets
+//! - `transcode` → sequence assembly + the montage tickets (ppm) or the
+//! module export task (mp4)
//!
//! Exit codes: 0 success, 1 general error, 2 rendering unavailable,
//! 64 usage error.
diff --git a/crates/oak-cli/src/cmd/probe.rs b/crates/oak-cli/src/cmd/probe.rs
index 6ca50d879..79d5e1d44 100644
--- a/crates/oak-cli/src/cmd/probe.rs
+++ b/crates/oak-cli/src/cmd/probe.rs
@@ -18,179 +18,103 @@
//! duration and video/audio/subtitle streams (port of `cmd_probe()` in
//! cli/main.cpp).
//!
-//! Runs entirely through the C ABI: `oakengine_init(OAKENGINE_INIT_HEADLESS)`
-//! → `oakengine_footage_probe(path)` → the `oakengine_footage_get_*`
-//! getters → `oakengine_footage_free` + `oakengine_shutdown()`. The output
-//! is formatted by `crate::fmt` exactly like the C++ `printf` calls.
-//!
-//! The engine's probe records what the oaknode footage module probes: the
-//! decoder id, the stream counts and (per stream) the module-visible
-//! params. The module currently drops the codec's probe description, so
-//! `get_duration` reports 0 and the stream counts report 0 for media the
-//! module has not loaded stream metadata for — the CLI prints exactly
-//! what the engine answers. A missing file / failed probe prints
-//! `oakengine_footage_last_error` on stderr and exits 1.
+//! Runs entirely through the module crates (M14 R2): an
+//! [`oaknode::footage::FootageBehavior`] probes the file through the
+//! oakcodec decoder registry and the CLI prints what the module records.
+//! The module currently records the decoder id but drops the codec's
+//! stream descriptions, so `duration` reports 0 and the stream counts
+//! report 0 for media the module has not loaded stream metadata for — the
+//! CLI prints exactly what the module answers, unchanged from the facade
+//! contract. A missing file prints `error: probe: file does not exist:
+//! ` on stderr and exits 1.
-use std::ffi::{CString, c_int};
+use oaknode::footage::FootageBehavior;
use crate::cmd::{EXIT_ERROR, EXIT_OK};
-use crate::ffi::{self, OakFootageAudioInfo, OakFootageVideoInfo};
use crate::fmt;
/// Run `probe`. `mediafile` is the media path from the command line.
pub fn run(mediafile: String) -> i32 {
- let code = run_probe(&mediafile);
- unsafe {
- crate::optional::engine_shutdown();
- }
- code
+ run_probe(&mediafile)
}
-/// The probe body; the caller owns the engine shutdown.
+/// The probe body.
fn run_probe(mediafile: &str) -> i32 {
- let rc = unsafe { crate::optional::engine_init(crate::ffi::OAKENGINE_INIT_HEADLESS) };
- if rc != crate::ffi::OAKENGINE_OK {
- eprintln!("error: probe: engine init failed ({rc})");
+ if !std::path::Path::new(mediafile).exists() {
+ eprintln!("error: probe: file does not exist: {mediafile}");
return EXIT_ERROR;
}
- let path = match CString::new(mediafile) {
- Ok(p) => p,
- Err(_) => {
- eprintln!("error: probe: invalid path (NUL byte)");
- return EXIT_ERROR;
- }
- };
- let footage = unsafe { crate::ffi::oakengine_footage_probe(path.as_ptr()) };
- if footage.is_null() {
- let err = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_footage_last_error(buf, size)
- });
- eprintln!("error: probe: {err}");
- return EXIT_ERROR;
- }
+ // The module probe records the decoder id (best effort; a failed probe
+ // leaves the node usable with empty streams, exactly like the facade's
+ // footage create).
+ let mut footage = FootageBehavior::new(mediafile);
+ let _ = footage.probe();
- println!(
- "{}",
- fmt::decoder_line(&crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_footage_get_decoder_name(footage, buf, size)
- }))
- );
+ println!("{}", fmt::decoder_line(&footage.decoder));
- let mut duration = 0.0f64;
- let rc = unsafe { crate::ffi::oakengine_footage_get_duration(footage, &mut duration) };
- if rc == crate::ffi::OAKENGINE_OK {
- println!("{}", fmt::duration_line(duration));
+ let duration = footage.duration();
+ let duration_secs = if duration.denominator() != 0 {
+ duration.numerator() as f64 / duration.denominator() as f64
} else {
- println!("{}", fmt::duration_line(0.0));
- }
+ 0.0
+ };
+ println!("{}", fmt::duration_line(duration_secs));
- let video = unsafe { crate::ffi::oakengine_footage_get_video_stream_count(footage) }.max(0);
+ let video = footage.video_stream_count();
println!("{}", fmt::video_streams_line(video as i64));
for index in 0..video {
- if let Some(info) = unsafe { video_stream_info(footage, index) } {
- let secs = stream_seconds(
- info.duration_ts,
- (info.time_base_num, info.time_base_den),
- );
+ if let Some(params) = footage.video_params(index) {
+ let fr = params.frame_rate;
+ let secs = if fr.denominator() != 0 {
+ fr.numerator() as f64 / fr.denominator() as f64
+ } else {
+ 0.0
+ };
println!(
"{}",
fmt::video_stream(
index as i64,
- info.stream_index as i64,
- info.width as i64,
- info.height as i64,
- info.frame_rate_num as i64,
- info.frame_rate_den as i64,
- info.duration_ts,
- info.time_base_den as i64,
+ index as i64,
+ params.width as i64,
+ params.height as i64,
+ fr.numerator(),
+ fr.denominator(),
+ 0,
+ fr.denominator(),
secs,
- info.color_primaries as i64,
- info.color_trc as i64,
- info.interlaced != 0,
+ 0,
+ 0,
+ false,
)
);
}
}
- let audio = unsafe { crate::ffi::oakengine_footage_get_audio_stream_count(footage) }.max(0);
+ let audio = footage.audio_stream_count();
println!("{}", fmt::audio_streams_line(audio as i64));
for index in 0..audio {
- // The stream-info getter reports what the engine can describe
- // (the module's audio stream descriptions are not reachable
- // yet); streams the engine cannot describe are counted only.
- if let Some(info) = unsafe { audio_stream_info(footage, index) } {
- let secs = stream_seconds(
- info.duration_ts,
- (info.time_base_num, info.time_base_den),
- );
+ // The module's audio stream descriptions are not reachable yet
+ // (the stream entries are dropped by the probe); streams the module
+ // cannot describe are counted only.
+ if let Some(params) = footage.audio_params(index) {
println!(
"{}",
fmt::audio_stream(
index as i64,
- info.stream_index as i64,
- info.sample_rate as i64,
- info.channel_count as i64,
- info.duration_ts,
- info.time_base_den as i64,
- secs,
+ index as i64,
+ params.sample_rate as i64,
+ params.channel_layout.count_ones() as i64,
+ 0,
+ 1,
+ 0.0,
)
);
}
}
- let subtitle =
- unsafe { crate::ffi::oakengine_footage_get_subtitle_stream_count(footage) }.max(0);
+ let subtitle = footage.subtitle_stream_count();
println!("{}", fmt::subtitle_streams_line(subtitle as i64));
- unsafe {
- crate::ffi::oakengine_footage_free(footage);
- }
EXIT_OK
}
-
-/// `oakengine_footage_get_video_stream_info` into an owned POD
-/// (`None` when the engine reports the stream as unavailable).
-unsafe fn video_stream_info(footage: *mut ffi::OakEngineFootage, index: c_int) -> Option {
- let mut info = OakFootageVideoInfo {
- stream_index: 0,
- width: 0,
- height: 0,
- frame_rate_num: 0,
- frame_rate_den: 0,
- duration_ts: 0,
- time_base_num: 0,
- time_base_den: 0,
- color_primaries: 0,
- color_trc: 0,
- interlaced: 0,
- };
- let rc = unsafe { crate::ffi::oakengine_footage_get_video_stream_info(footage, index, &mut info) };
- (rc == crate::ffi::OAKENGINE_OK).then_some(info)
-}
-
-/// `oakengine_footage_get_audio_stream_info` into an owned POD
-/// (`None` when the engine reports the stream as unavailable).
-unsafe fn audio_stream_info(footage: *mut ffi::OakEngineFootage, index: c_int) -> Option {
- let mut info = OakFootageAudioInfo {
- stream_index: 0,
- sample_rate: 0,
- channel_layout: 0,
- channel_count: 0,
- duration_ts: 0,
- time_base_num: 0,
- time_base_den: 0,
- };
- let rc = unsafe { crate::ffi::oakengine_footage_get_audio_stream_info(footage, index, &mut info) };
- (rc == crate::ffi::OAKENGINE_OK).then_some(info)
-}
-
-/// Seconds a stream spans: `duration_ts` ticks of the stream time base
-/// (`num/den` seconds per tick).
-fn stream_seconds(duration_ts: i64, time_base: (c_int, c_int)) -> f64 {
- let (num, den) = time_base;
- if den == 0 {
- return 0.0;
- }
- duration_ts as f64 * num as f64 / den as f64
-}
diff --git a/crates/oak-cli/src/cmd/render.rs b/crates/oak-cli/src/cmd/render.rs
index d631a3df9..a608d48b7 100644
--- a/crates/oak-cli/src/cmd/render.rs
+++ b/crates/oak-cli/src/cmd/render.rs
@@ -18,33 +18,30 @@
//! render the first sequence to PPM frames plus a PCM s16 WAV (port of
//! `cmd_render()` in cli/main.cpp).
//!
-//! Runs entirely through the C ABI:
+//! Runs entirely through the module crates (M14 R2):
//!
-//! 1. `oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER)`
-//! — plus `oakengine_render_manager_init()`, the Rust facade's
-//! replacement for the C++ engine core's OAKENGINE_INIT_RENDER boot
-//! (the ticket render path needs the oakrender manager up).
-//! 2. `oakengine_project_create` + `oakengine_project_load` (the
+//! 1. `crate::engine::load_project` — the oaknode serializer (the
//! process chdirs into the project directory first, like the C++
//! CLI, so relative footage paths resolve during rendering).
-//! 3. Sequence 0's frame rate via `oakengine_sequence_get_frame_rate`
-//! and geometry via `oakengine_sequence_get_video_params`.
-//! 4. `oakengine_renderer_create(seq, w, h, f32, fr_num, fr_den, null)`
-//! → for every frame timestamp in `[start, end)`
-//! `oakengine_renderer_render_frame` → the `oakengine_frame_*`
-//! accessors → [`crate::ppm::write_ppm`] (P6, 8-bit RGB).
-//! 5. The audio range through `oakengine_renderer_render_audio` → the
-//! `oakengine_audio_*` accessors → [`crate::wav::write_wav`].
+//! 2. Sequence 0's frame rate and geometry via the sequence behavior.
+//! 3. `crate::engine::render_manager_init` — the oakrender manager's
+//! ticket arena drives the render.
+//! 4. For every frame timestamp in `[start, end)` the video montage at
+//! that time is submitted as a ticket
+//! (`crate::engine::render_frame`) → [`crate::ppm::write_ppm`]
+//! (P6, 8-bit RGB).
+//! 5. The audio range through the audio montage + ticket
+//! (`crate::engine::render_audio`) → [`crate::wav::write_wav`].
//!
-//! Exit codes: a renderer-create or per-frame render failure exits 2
-//! (rendering unavailable, mirroring the C++ code for a missing render
-//! backend); project/sequence/argument failures exit 1; bad seconds exit
-//! 64 (usage). Frame progress goes to stderr (`frame N: T s`).
+//! Exit codes: a frame/audio render failure exits 2 (rendering
+//! unavailable, mirroring the C++ code for a missing render backend);
+//! project/sequence/argument failures exit 1; bad seconds exit 64
+//! (usage). Frame progress goes to stderr (`frame N: T s`).
-use std::path::Path;
+use oakcore_rs::TimeRange;
use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE};
-use crate::ffi;
+use crate::engine;
use crate::ppm;
use crate::wav;
@@ -69,39 +66,17 @@ pub fn run(project: String, start_seconds: &str, end_seconds: &str, out_dir: &st
return EXIT_USAGE;
}
- let code = run_render(&project, start, end, out_dir);
- unsafe {
- crate::optional::engine_shutdown();
- }
- code
+ run_render(&project, start, end, out_dir)
}
-/// The render body; the caller owns the engine shutdown.
+/// The render body.
fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
- let rc = unsafe {
- crate::optional::engine_init(
- crate::ffi::OAKENGINE_INIT_HEADLESS | crate::ffi::OAKENGINE_INIT_RENDER,
- )
- };
- if rc != crate::ffi::OAKENGINE_OK {
- eprintln!("error: render: engine init failed ({rc})");
- return EXIT_ERROR;
- }
-
- // The Rust facade's render boot: the ticket render path requires the
- // oakrender manager (the C++ OAKENGINE_INIT_RENDER equivalent).
- if unsafe { crate::ffi::oakengine_render_manager_init() } != crate::ffi::OAKENGINE_OK {
- eprintln!("error: render: cannot initialize the render manager");
- return EXIT_RENDER_UNAVAILABLE;
- }
-
// Absolute project path first — the C++ CLI chdirs into the project
// directory so relative footage paths resolve during rendering.
let abs = match std::fs::canonicalize(project) {
Ok(p) => p,
Err(e) => {
eprintln!("error: render: cannot open project \"{project}\": {e}");
- unsafe { crate::ffi::oakengine_render_manager_shutdown() };
return EXIT_ERROR;
}
};
@@ -109,129 +84,61 @@ fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
let _ = std::env::set_current_dir(dir);
}
- let handle = unsafe { crate::ffi::oakengine_project_create() };
- if handle.is_null() {
- eprintln!("error: render: cannot create project");
- unsafe { crate::ffi::oakengine_render_manager_shutdown() };
- return EXIT_ERROR;
- }
- let path = match std::ffi::CString::new(abs.as_os_str().as_encoded_bytes()) {
+ let project_ref = match engine::load_project(&abs.to_string_lossy()) {
Ok(p) => p,
- Err(_) => {
- eprintln!("error: render: invalid path (NUL byte)");
- unsafe {
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
+ Err(detail) => {
+ if detail.is_empty() {
+ eprintln!("error: render: cannot load project \"{project}\"");
+ } else {
+ eprintln!("error: render: {detail}");
}
return EXIT_ERROR;
}
};
- let mut err = [0 as std::ffi::c_char; 4096];
- let rc = unsafe {
- crate::ffi::oakengine_project_load(handle, path.as_ptr(), err.as_mut_ptr(), err.len() as i32)
+
+ // The render manager must be up before any ticket submission (the
+ // facade's OAKENGINE_INIT_RENDER render boot).
+ if let Err(e) = engine::render_manager_init() {
+ eprintln!("error: render: cannot initialize the render manager: {e}");
+ return EXIT_RENDER_UNAVAILABLE;
+ }
+
+ let sequences = {
+ let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
+ engine::sequence_ids(&guard)
};
- if rc != crate::ffi::OAKENGINE_OK {
- // SAFETY: the engine NUL-terminates `err` on failure.
- let detail = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }
- .to_string_lossy()
- .into_owned();
- if detail.is_empty() {
- eprintln!("error: render: cannot load project \"{project}\"");
- } else {
- eprintln!("error: render: {detail}");
+ let seq_id = match sequences.first() {
+ Some(id) => *id,
+ None => {
+ eprintln!("error: render: project has no sequences");
+ engine::render_manager_shutdown();
+ return EXIT_ERROR;
}
- unsafe {
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
- }
- return EXIT_ERROR;
- }
+ };
- if unsafe { crate::ffi::oakengine_project_sequence_count(handle) } < 1 {
- eprintln!("error: render: project has no sequences");
- unsafe {
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
- }
- return EXIT_ERROR;
- }
- // Borrowed sequence box (no free export); lives for the project.
- let seq = unsafe { crate::ffi::oakengine_project_sequence_at(handle, 0) };
- if seq.is_null() {
- eprintln!("error: render: sequence 0 unavailable");
- unsafe {
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
- }
- return EXIT_ERROR;
- }
-
- let mut fr_num: i32 = 0;
- let mut fr_den: i32 = 0;
- unsafe {
- crate::ffi::oakengine_sequence_get_frame_rate(seq, &mut fr_num, &mut fr_den);
- }
+ let (fr_num, fr_den, width, height) = {
+ let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
+ let fr = engine::sequence_frame_rate(&guard, seq_id);
+ let (w, h) = engine::sequence_geometry(&guard, seq_id);
+ (fr.numerator() as i32, fr.denominator() as i32, w, h)
+ };
if fr_num <= 0 || fr_den <= 0 {
eprintln!("error: render: invalid sequence frame rate {fr_num}/{fr_den}");
- unsafe {
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
- }
+ engine::render_manager_shutdown();
return EXIT_ERROR;
}
-
- let mut width: i32 = 0;
- let mut height: i32 = 0;
- unsafe {
- crate::ffi::oakengine_sequence_get_video_params(
- seq,
- &mut width,
- &mut height,
- &mut 0,
- &mut 0,
- );
- }
if width <= 0 || height <= 0 {
eprintln!("error: render: sequence has no video geometry");
- unsafe {
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
- }
+ engine::render_manager_shutdown();
return EXIT_ERROR;
}
if let Err(e) = std::fs::create_dir_all(out_dir) {
eprintln!("error: render: cannot create output directory \"{out_dir}\": {e}");
- unsafe {
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
- }
+ engine::render_manager_shutdown();
return EXIT_ERROR;
}
- let renderer = unsafe {
- crate::ffi::oakengine_renderer_create(
- seq,
- width,
- height,
- crate::ffi::PIXEL_FORMAT_F32,
- fr_num,
- fr_den,
- std::ptr::null(),
- )
- };
- if renderer.is_null() {
- // The engine's create path returns NULL without setting the
- // renderer's last error (invalid geometry/format or no module
- // backing); report the contract message.
- eprintln!("error: render: cannot create renderer");
- unsafe {
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
- }
- return EXIT_RENDER_UNAVAILABLE;
- }
-
// Frame loop: timestamps in the sequence time base (1/fr_num s).
let start_frames = (start * fr_num as f64 / fr_den as f64).round() as i64;
let mut index = start_frames;
@@ -241,37 +148,26 @@ fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
if time >= end {
break;
}
- let frame = unsafe { crate::ffi::oakengine_renderer_render_frame(renderer, index) };
- if frame.is_null() {
- let err = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
- });
- let msg = if err.is_empty() {
- format!("frame at {time:.6} s failed to render")
- } else {
- format!("frame at {time:.6} s failed: {err}")
- };
- eprintln!("error: render: {msg}");
- unsafe {
- crate::ffi::oakengine_renderer_free(renderer);
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
+ let time_r = oakcore_rs::Rational::new(index * i64::from(fr_den), i64::from(fr_num));
+ let montage = engine::video_montage(&project_ref, seq_id, time_r);
+ let frame = match engine::render_frame(seq_id, time_r, montage, width, height) {
+ Ok(f) => f,
+ Err(e) => {
+ let msg = if e.is_empty() {
+ format!("frame at {time:.6} s failed to render")
+ } else {
+ format!("frame at {time:.6} s failed: {e}")
+ };
+ eprintln!("error: render: {msg}");
+ engine::render_manager_shutdown();
+ return EXIT_RENDER_UNAVAILABLE;
}
- return EXIT_RENDER_UNAVAILABLE;
- }
- if let Err(msg) = unsafe { write_frame_ppm(frame, out_dir, written) } {
+ };
+ if let Err(msg) = write_frame_ppm(&frame, out_dir, written) {
eprintln!("error: render: {msg}");
- unsafe {
- crate::ffi::oakengine_frame_free(frame);
- crate::ffi::oakengine_renderer_free(renderer);
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
- }
+ engine::render_manager_shutdown();
return EXIT_ERROR;
}
- unsafe {
- crate::ffi::oakengine_frame_free(frame);
- }
eprintln!("frame {written}: {time:.6} s");
written += 1;
index += 1;
@@ -279,102 +175,73 @@ fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
if written == 0 {
eprintln!("error: render: empty frame range");
- unsafe {
- crate::ffi::oakengine_renderer_free(renderer);
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
- }
+ engine::render_manager_shutdown();
return EXIT_ERROR;
}
// Audio range in the sequence time base.
let start_ts = (start * fr_num as f64 / fr_den as f64).round() as i64;
let length_ts = ((end - start) * fr_num as f64 / fr_den as f64).round() as i64;
- let audio = unsafe { crate::ffi::oakengine_renderer_render_audio(renderer, start_ts, length_ts) };
- if audio.is_null() {
- let err = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
- });
- let msg = if err.is_empty() {
- "audio render failed".to_string()
- } else {
- format!("audio render failed: {err}")
- };
- eprintln!("error: render: {msg}");
- unsafe {
- crate::ffi::oakengine_renderer_free(renderer);
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
+ let range = TimeRange::new(
+ oakcore_rs::Rational::new(start_ts * i64::from(fr_den), i64::from(fr_num)),
+ oakcore_rs::Rational::new(
+ (start_ts + length_ts) * i64::from(fr_den),
+ i64::from(fr_num),
+ ),
+ );
+ let montage = engine::audio_montage(&project_ref, seq_id, range);
+ let code = match engine::render_audio(seq_id, range, montage) {
+ Ok(audio) => write_audio_wav(&audio, out_dir),
+ Err(e) => {
+ let msg = if e.is_empty() {
+ "audio render failed".to_string()
+ } else {
+ format!("audio render failed: {e}")
+ };
+ eprintln!("error: render: {msg}");
+ EXIT_RENDER_UNAVAILABLE
}
- return EXIT_RENDER_UNAVAILABLE;
- }
- let code = unsafe { write_audio_wav(audio, out_dir) };
- unsafe {
- crate::ffi::oakengine_audio_free(audio);
- crate::ffi::oakengine_renderer_free(renderer);
- crate::ffi::oakengine_project_free(handle);
- crate::ffi::oakengine_render_manager_shutdown();
- }
+ };
+ engine::render_manager_shutdown();
code
}
-/// Write a rendered frame as `frame_%05d.ppm` in `out_dir` (the
-/// `oakengine_frame_*` accessors feed the [`crate::ppm`] writer).
-///
-/// `oakengine_frame_channel_count` is not backed by the current engine
-/// (returns 0); the render module's frames are always in the internal
-/// RGBA layout (`VideoParams::k_internal_channel_count == 4`), so a
-/// zero/negative channel report falls back to 4 channels.
-unsafe fn write_frame_ppm(frame: *mut ffi::OakEngineFrame, out_dir: &str, index: u64) -> Result<(), String> {
- let width = unsafe { crate::ffi::oakengine_frame_width(frame) };
- let height = unsafe { crate::ffi::oakengine_frame_height(frame) };
- let format = unsafe { crate::ffi::oakengine_frame_format(frame) };
- let channels = unsafe { crate::ffi::oakengine_frame_channel_count(frame) };
- let channels = if channels > 0 { channels } else { 4 };
- let linesize = unsafe { crate::ffi::oakengine_frame_linesize_bytes(frame) };
- let data = unsafe { crate::ffi::oakengine_frame_data(frame) };
- if data.is_null() || width <= 0 || height <= 0 || linesize <= 0 {
+/// Write a rendered frame as `frame_%05d.ppm` in `out_dir`. The render
+/// frames are always in the internal RGBA layout
+/// (`VideoParams::k_internal_channel_count == 4`), so 4 channels feed the
+/// [`crate::ppm`] writer.
+fn write_frame_ppm(frame: &engine::RenderedFrame, out_dir: &str, index: u64) -> Result<(), String> {
+ if frame.data.is_empty() || frame.width <= 0 || frame.height <= 0 || frame.linesize <= 0 {
return Err(format!(
- "frame {index} has no pixel data ({}x{}, linesize {linesize})",
- width, height
+ "frame {index} has no pixel data ({}x{}, linesize {})",
+ frame.width, frame.height, frame.linesize
));
}
- let len = (linesize as usize)
- .checked_mul(height as usize)
- .ok_or_else(|| "frame buffer size overflow".to_string())?;
- // SAFETY: the engine's frame buffer is valid for linesize * height
- // bytes for the duration of this call.
- let bytes = unsafe { std::slice::from_raw_parts(data as *const u8, len) };
- let path = Path::new(out_dir).join(format!("frame_{index:05}.ppm"));
- ppm::write_ppm(&path, width, height, format, channels, linesize, bytes)
- .map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
+ let path = std::path::Path::new(out_dir).join(format!("frame_{index:05}.ppm"));
+ ppm::write_ppm(
+ &path,
+ frame.width,
+ frame.height,
+ frame.format,
+ 4,
+ frame.linesize,
+ &frame.data,
+ )
+ .map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
}
/// Write the rendered audio buffer as `audio.wav` in `out_dir` (the
-/// `oakengine_audio_*` accessors feed the [`crate::wav`] writer). The
-/// buffer is interleaved f32; the engine returns the whole buffer base
-/// for every channel, so channel 0 covers all frames.
-unsafe fn write_audio_wav(audio: *mut ffi::OakEngineAudioBuffer, out_dir: &str) -> i32 {
- let rate = unsafe { crate::ffi::oakengine_audio_sample_rate(audio) };
- let channels = unsafe { crate::ffi::oakengine_audio_channel_count(audio) };
- let samples = unsafe { crate::ffi::oakengine_audio_sample_count(audio) };
- let data = unsafe { crate::ffi::oakengine_audio_data(audio, 0) };
- if data.is_null() || rate <= 0 || channels <= 0 || samples <= 0 {
+/// buffer is interleaved f32).
+fn write_audio_wav(audio: &engine::RenderedAudio, out_dir: &str) -> i32 {
+ let rate = audio.sample_rate;
+ let channels = audio.channel_count;
+ if rate <= 0 || channels <= 0 || audio.data.is_empty() {
eprintln!("error: render: audio buffer is empty");
return EXIT_RENDER_UNAVAILABLE;
}
- let len = match (samples as usize).checked_mul(channels as usize) {
- Some(l) => l,
- None => {
- eprintln!("error: render: audio buffer size overflow");
- return EXIT_RENDER_UNAVAILABLE;
- }
- };
- // SAFETY: the engine's audio buffer is valid for samples * channels
- // floats for the duration of this call.
- let floats = unsafe { std::slice::from_raw_parts(data, len) };
- let path = Path::new(out_dir).join("audio.wav");
- if let Err(e) = wav::write_wav(&path, rate, channels, samples, floats) {
+ let samples = (audio.data.len() / channels as usize) as i64;
+ let path = std::path::Path::new(out_dir).join("audio.wav");
+ if let Err(e) = wav::write_wav(&path, rate, channels, samples, &audio.data) {
eprintln!("error: render: cannot write \"{}\": {e}", path.display());
return EXIT_ERROR;
}
diff --git a/crates/oak-cli/src/cmd/transcode.rs b/crates/oak-cli/src/cmd/transcode.rs
index 183172dae..04e6b7060 100644
--- a/crates/oak-cli/src/cmd/transcode.rs
+++ b/crates/oak-cli/src/cmd/transcode.rs
@@ -16,47 +16,43 @@
//! `oak-cli transcode [width] [--format ppm|mp4]` —
//! "media in, renders out" round trip (port of `cmd_transcode()` in
-//! cli/main.cpp), entirely through the C ABI.
+//! cli/main.cpp), entirely through the module crates (M14 R2).
//!
-//! The source is probed with `oakengine_footage_probe` (geometry / frame
-//! rate / duration through the `oakengine_footage_get_*` getters), then a
-//! temporary project is assembled the same way the C++ CLI did:
-//! `oakengine_project_create` + `oakengine_project_new` +
-//! `oakengine_project_import_footage` + `oakengine_sequence_new` +
-//! `oakengine_sequence_set_video_params` + `oakengine_sequence_add_track`
-//! + `oakengine_sequence_add_footage_clip_ex` (the `_ex` variant: the
-//! engine keeps created sequences in their own scratch project — a
-//! documented deviation, so the plain variant's same-project check can
-//! never pass).
+//! The source is probed with an [`oaknode::footage::FootageBehavior`]
+//! (geometry / frame rate / duration), then a temporary sequence is
+//! assembled the same way the facade did: a scratch project holds the
+//! sequence (the facade's documented `oakengine_sequence_new` deviation),
+//! [`crate::engine::set_sequence_video_params`] sets the output geometry
+//! and frame rate, tracks are added with the module's
+//! `TimelineAddTrackCommand`, and clips are placed with the
+//! `TrackPlaceBlockCommand` (scratch footage connected to each clip —
+//! the facade's `oakengine_sequence_add_footage_clip_ex` semantics).
//!
//! - `--format ppm` (and the image/still path): renders the frame range
-//! through `oakengine_renderer_render_frame` into P6 PPM frames via
+//! through [`crate::engine::render_frame`] into P6 PPM frames via
//! [`crate::ppm`], plus the audio range through
-//! `oakengine_renderer_render_audio` into a PCM s16 WAV via
+//! [`crate::engine::render_audio`] into a PCM s16 WAV via
//! [`crate::wav`] when the source has audio streams.
//! - `--format mp4` (default): H.264/AAC through
-//! `oakengine_export_render` with the engine's exporter options
-//! (codec-default bit rates). The exporter family is currently NOT
-//! wrapped by the Rust facade, so this path reports
-//! `oakengine_export_last_error` and exits 1 until the dylib grows it
-//! (see `crate::optional`).
+//! [`crate::engine::export_sequence`] (the module export task, the
+//! facade's `oakengine_export_render` equivalent).
//!
-//! The engine's footage probe records the decoder id but drops the
-//! codec's stream descriptions (module gap), so when the stream info is
-//! unavailable the CLI falls back to `[width]` (or 1920), a 16:9 height,
-//! 25 fps and a single-frame range — the still-image contract the C++
-//! CLI used for duration-less sources. Failures exit 1 (general error);
-//! bad arguments exit 64.
+//! The module probe records the decoder id but drops the codec's stream
+//! descriptions (module gap), so when the stream info is unavailable the
+//! CLI falls back to `[width]` (or 1920), a 16:9 height, 25 fps and a
+//! single-frame range — the still-image contract the C++ CLI used for
+//! duration-less sources. Failures exit 1 (general error); bad arguments
+//! exit 64.
-use std::ffi::CString;
-use std::path::Path;
+use oaknode::footage::FootageBehavior;
+use oaknode::track::TrackType;
use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_USAGE};
-use crate::ffi::{self, OakExportOptions};
+use crate::engine;
use crate::ppm;
use crate::wav;
-/// Source description distilled from the probe (through the C ABI).
+/// Source description distilled from the probe (through the module).
struct SourceInfo {
width: i32,
height: i32,
@@ -86,25 +82,11 @@ pub fn run(input_media: String, out: String, width: Option, format: Opti
}
let is_ppm = format.as_deref().unwrap_or("mp4") == "ppm";
- let code = run_transcode(&input_media, &out, width.as_deref(), is_ppm);
- unsafe {
- crate::optional::engine_shutdown();
- }
- code
+ run_transcode(&input_media, &out, width.as_deref(), is_ppm)
}
-/// The transcode body; the caller owns the engine shutdown.
+/// The transcode body.
fn run_transcode(input: &str, out: &str, width: Option<&str>, is_ppm: bool) -> i32 {
- let rc = unsafe {
- crate::optional::engine_init(
- crate::ffi::OAKENGINE_INIT_HEADLESS | crate::ffi::OAKENGINE_INIT_RENDER,
- )
- };
- if rc != crate::ffi::OAKENGINE_OK {
- eprintln!("error: transcode: engine init failed ({rc})");
- return EXIT_ERROR;
- }
-
let src = match probe_source(input) {
Ok(s) => s,
Err(msg) => {
@@ -127,8 +109,11 @@ fn run_transcode(input: &str, out: &str, width: Option<&str>, is_ppm: bool) -> i
let frames = (src.duration * fr_num as f64 / fr_den as f64).round() as i64;
let frames = frames.max(1);
- // The temporary project + sequence + clips both output paths share.
- let assembly = match assemble_project(input, out_w, out_h, fr_num, fr_den, frames, src.audio_streams) {
+ // The temporary sequence both output paths share. The facade kept
+ // created sequences in their own scratch project (documented
+ // deviation); the CLI does the same, so only the scratch project holds
+ // the sequence and its clips.
+ let assembly = match assemble_sequence(input, out_w, out_h, fr_num, fr_den, frames, src.audio_streams) {
Ok(a) => a,
Err(msg) => {
eprintln!("error: transcode: {msg}");
@@ -137,36 +122,24 @@ fn run_transcode(input: &str, out: &str, width: Option<&str>, is_ppm: bool) -> i
};
let code = if is_ppm {
- transcode_ppm(
- &assembly,
- out,
- out_w,
- out_h,
- fr_num,
- fr_den,
- frames,
- src.audio_streams > 0,
- )
+ transcode_ppm(&assembly, out, out_w, out_h, fr_num, fr_den, frames, src.audio_streams > 0)
} else {
- transcode_mp4(&assembly, out, out_w, out_h, frames)
+ transcode_mp4(&assembly, out, fr_num, fr_den, frames)
};
- unsafe {
- crate::ffi::oakengine_render_manager_shutdown();
- crate::ffi::oakengine_project_free(assembly.project);
- }
+ engine::render_manager_shutdown();
code
}
-/// The assembled temporary project: project + footage + sequence (with
-/// one video track/clip and, when the source has audio streams, one
-/// audio track/clip).
+/// The assembled temporary sequence: the scratch project + the sequence
+/// node (with one video track/clip and, when the source has audio
+/// streams, one audio track/clip).
struct Assembly {
- project: *mut ffi::OakEngineProject,
- sequence: *mut ffi::OakEngineSequence,
+ project: engine::ProjectRef,
+ sequence: oaknode::id::NodeId,
}
-/// Build the temporary project for the render/export stage.
-fn assemble_project(
+/// Build the temporary sequence for the render/export stage.
+fn assemble_sequence(
input: &str,
out_w: i32,
out_h: i32,
@@ -175,136 +148,51 @@ fn assemble_project(
frames: i64,
audio_streams: i32,
) -> Result {
- if unsafe { crate::ffi::oakengine_render_manager_init() } != crate::ffi::OAKENGINE_OK {
- return Err("cannot initialize the render manager".to_string());
- }
- let project = unsafe { crate::ffi::oakengine_project_create() };
- if project.is_null() {
- return Err("cannot create project".to_string());
- }
- if unsafe { crate::ffi::oakengine_project_new(project) } != crate::ffi::OAKENGINE_OK {
- unsafe { crate::ffi::oakengine_project_free(project) };
- return Err("cannot initialize project".to_string());
+ if let Err(e) = engine::render_manager_init() {
+ return Err(format!("cannot initialize the render manager: {e}"));
}
+ let project = oaknode::project::Project::new();
+ let sequence = engine::create_sequence(&project, "transcode");
+ engine::set_sequence_video_params(&project, sequence, out_w, out_h, fr_num, fr_den);
- let input_c = CString::new(input).map_err(|_| "invalid path (NUL byte)".to_string())?;
- let footage = unsafe { crate::ffi::oakengine_project_import_footage(project, input_c.as_ptr()) };
- if footage.is_null() {
- let err = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_footage_last_error(buf, size)
- });
- unsafe { crate::ffi::oakengine_project_free(project) };
- return Err(err);
- }
+ let video_track = engine::add_track(&project, sequence, TrackType::Video)
+ .map_err(|e| format!("cannot add video track: {e}"))?;
+ engine::place_footage_clip(
+ &project,
+ sequence,
+ input,
+ TrackType::Video,
+ video_track,
+ 0,
+ frames,
+ 0,
+ fr_num,
+ fr_den,
+ )
+ .map_err(|e| format!("cannot place video clip: {e}"))?;
- let seq_name = CString::new("transcode").unwrap();
- let sequence = unsafe { crate::ffi::oakengine_sequence_new(project, seq_name.as_ptr()) };
- if sequence.is_null() {
- unsafe {
- crate::ffi::oakengine_footage_free(footage);
- crate::ffi::oakengine_project_free(project);
- }
- return Err(seq_error("cannot create sequence"));
- }
- if unsafe {
- crate::ffi::oakengine_sequence_set_video_params(
+ if audio_streams > 0 {
+ let audio_track = engine::add_track(&project, sequence, TrackType::Audio)
+ .map_err(|e| format!("cannot add audio track: {e}"))?;
+ engine::place_footage_clip(
+ &project,
sequence,
- out_w,
- out_h,
- fr_num,
- fr_den,
- 1,
- 1,
- 0,
- crate::ffi::PIXEL_FORMAT_F32,
- 0,
- )
- } != crate::ffi::OAKENGINE_OK
- {
- unsafe {
- crate::ffi::oakengine_footage_free(footage);
- crate::ffi::oakengine_project_free(project);
- }
- return Err(seq_error("cannot set sequence video params"));
- }
-
- let video_track =
- unsafe { crate::ffi::oakengine_sequence_add_track(sequence, crate::ffi::OAKENGINE_TRACK_TYPE_VIDEO) };
- if video_track < 0 {
- unsafe {
- crate::ffi::oakengine_footage_free(footage);
- crate::ffi::oakengine_project_free(project);
- }
- return Err(seq_error("cannot add video track"));
- }
- let clip = unsafe {
- crate::ffi::oakengine_sequence_add_footage_clip_ex(
- sequence,
- footage,
- crate::ffi::OAKENGINE_TRACK_TYPE_VIDEO,
- video_track,
+ input,
+ TrackType::Audio,
+ audio_track,
0,
frames,
0,
+ fr_num,
+ fr_den,
)
- };
- if clip.is_null() {
- unsafe {
- crate::ffi::oakengine_footage_free(footage);
- crate::ffi::oakengine_project_free(project);
- }
- return Err(seq_error("cannot place video clip"));
+ .map_err(|e| format!("cannot place audio clip: {e}"))?;
}
- if audio_streams > 0 {
- let audio_track =
- unsafe { crate::ffi::oakengine_sequence_add_track(sequence, crate::ffi::OAKENGINE_TRACK_TYPE_AUDIO) };
- if audio_track < 0 {
- unsafe {
- crate::ffi::oakengine_footage_free(footage);
- crate::ffi::oakengine_project_free(project);
- }
- return Err(seq_error("cannot add audio track"));
- }
- let clip = unsafe {
- crate::ffi::oakengine_sequence_add_footage_clip_ex(
- sequence,
- footage,
- crate::ffi::OAKENGINE_TRACK_TYPE_AUDIO,
- audio_track,
- 0,
- frames,
- 0,
- )
- };
- if clip.is_null() {
- unsafe {
- crate::ffi::oakengine_footage_free(footage);
- crate::ffi::oakengine_project_free(project);
- }
- return Err(seq_error("cannot place audio clip"));
- }
- }
-
- unsafe {
- crate::ffi::oakengine_footage_free(footage);
- }
Ok(Assembly { project, sequence })
}
-/// `oakengine_sequence_last_error` (or the fallback when empty).
-fn seq_error(fallback: &str) -> String {
- let err = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_sequence_last_error(buf, size)
- });
- if err.is_empty() {
- fallback.to_string()
- } else {
- err
- }
-}
-
-/// `--format ppm`: render the frame range through the engine renderer
+/// `--format ppm`: render the frame range through the module ticket arena
/// into PPM frames (+ the audio range into a WAV when the source has
/// audio).
fn transcode_ppm(
@@ -321,237 +209,168 @@ fn transcode_ppm(
eprintln!("error: transcode: cannot create output directory \"{out}\": {e}");
return EXIT_ERROR;
}
- let renderer = unsafe {
- crate::ffi::oakengine_renderer_create(
- assembly.sequence,
- out_w,
- out_h,
- crate::ffi::PIXEL_FORMAT_F32,
- fr_num,
- fr_den,
- std::ptr::null(),
- )
- };
- if renderer.is_null() {
- // The engine's create path returns NULL without setting the
- // renderer's last error; report the contract message.
- eprintln!("error: transcode: cannot create renderer");
- return EXIT_ERROR;
- }
for i in 0..frames {
- let frame = unsafe { crate::ffi::oakengine_renderer_render_frame(renderer, i) };
- if frame.is_null() {
- let err = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
- });
- let msg = if err.is_empty() {
- format!("frame {i} failed to render")
- } else {
- format!("frame {i} failed to render: {err}")
- };
- eprintln!("error: transcode: {msg}");
- unsafe { crate::ffi::oakengine_renderer_free(renderer) };
- return EXIT_ERROR;
- }
- if let Err(msg) = unsafe { write_frame_ppm(frame, out, i) } {
- eprintln!("error: transcode: {msg}");
- unsafe {
- crate::ffi::oakengine_frame_free(frame);
- crate::ffi::oakengine_renderer_free(renderer);
+ let time = oakcore_rs::Rational::new(i * i64::from(fr_den), i64::from(fr_num));
+ let montage = engine::video_montage(&assembly.project, assembly.sequence, time);
+ let frame = match engine::render_frame(assembly.sequence, time, montage, out_w, out_h) {
+ Ok(f) => f,
+ Err(e) => {
+ let msg = if e.is_empty() {
+ format!("frame {i} failed to render")
+ } else {
+ format!("frame {i} failed to render: {e}")
+ };
+ eprintln!("error: transcode: {msg}");
+ return EXIT_ERROR;
}
+ };
+ if let Err(msg) = write_frame_ppm(&frame, out, i) {
+ eprintln!("error: transcode: {msg}");
return EXIT_ERROR;
}
- unsafe { crate::ffi::oakengine_frame_free(frame) };
}
eprintln!("transcoded {frames} frames to \"{out}\"");
- // Audio range (the assembly only adds audio clips when the source
- // has audio streams).
- let mut code = EXIT_OK;
+ // Audio range (the assembly only adds audio clips when the source has
+ // audio streams).
if audio {
- code = write_audio_wav(renderer, out, frames);
+ return write_audio_wav(assembly, out, frames, fr_num, fr_den);
}
- unsafe { crate::ffi::oakengine_renderer_free(renderer) };
- code
+ EXIT_OK
}
-/// `--format mp4`: H.264/AAC through `oakengine_export_render` (the
-/// exporter options mirror the C++ `cmd_transcode` defaults: codec-
-/// default bit rates, source audio rate or 48 kHz, stereo).
-fn transcode_mp4(assembly: &Assembly, out: &str, out_w: i32, out_h: i32, frames: i64) -> i32 {
- let out_c = match CString::new(out) {
- Ok(p) => p,
- Err(_) => {
- eprintln!("error: transcode: invalid output path (NUL byte)");
- return EXIT_ERROR;
- }
- };
- let opts = OakExportOptions {
- video_codec: crate::ffi::OAKENGINE_EXPORT_VIDEO_H264,
- audio_codec: crate::ffi::OAKENGINE_EXPORT_AUDIO_AAC,
- video_bit_rate: 0,
- audio_sample_rate: 48000,
- audio_channel_count: 2,
- };
- match unsafe {
- crate::optional::export_render(
- assembly.sequence,
- out_c.as_ptr(),
- 0,
- frames,
- out_w,
- out_h,
- &opts,
- )
- } {
- Some(rc) if rc == crate::ffi::OAKENGINE_OK => EXIT_OK,
- Some(rc) => {
- let err = unsafe { crate::optional::export_last_error() };
- let msg = if err.is_empty() {
- format!("export failed ({rc})")
+/// `--format mp4`: H.264/AAC through the module export task (codec-default
+/// bit rates, 48 kHz stereo — the facade's `oakengine_export_render`
+/// defaults).
+fn transcode_mp4(assembly: &Assembly, out: &str, fr_num: i32, fr_den: i32, frames: i64) -> i32 {
+ match engine::export_sequence(&assembly.project, assembly.sequence, out, fr_num, fr_den, frames) {
+ Ok(()) => EXIT_OK,
+ Err(msg) => {
+ let err = if msg.is_empty() {
+ "export failed".to_string()
} else {
- err
+ msg
};
- eprintln!("error: transcode: {msg}");
- EXIT_ERROR
- }
- None => {
- let err = unsafe { crate::optional::export_last_error() };
eprintln!("error: transcode: {err}");
EXIT_ERROR
}
}
}
-/// Write a rendered frame as `frame_%05d.ppm` in `out` (the
-/// `oakengine_frame_*` accessors feed the [`crate::ppm`] writer).
-///
-/// `oakengine_frame_channel_count` is not backed by the current engine
-/// (returns 0); the render module's frames are always in the internal
-/// RGBA layout (`VideoParams::k_internal_channel_count == 4`), so a
-/// zero/negative channel report falls back to 4 channels.
-unsafe fn write_frame_ppm(frame: *mut ffi::OakEngineFrame, out: &str, index: i64) -> Result<(), String> {
- let width = unsafe { crate::ffi::oakengine_frame_width(frame) };
- let height = unsafe { crate::ffi::oakengine_frame_height(frame) };
- let format = unsafe { crate::ffi::oakengine_frame_format(frame) };
- let channels = unsafe { crate::ffi::oakengine_frame_channel_count(frame) };
- let channels = if channels > 0 { channels } else { 4 };
- let linesize = unsafe { crate::ffi::oakengine_frame_linesize_bytes(frame) };
- let data = unsafe { crate::ffi::oakengine_frame_data(frame) };
- if data.is_null() || width <= 0 || height <= 0 || linesize <= 0 {
+/// Write a rendered frame as `frame_%05d.ppm` in `out` (the rendered
+/// frames are always in the internal RGBA layout, 4 channels).
+fn write_frame_ppm(frame: &engine::RenderedFrame, out: &str, index: i64) -> Result<(), String> {
+ if frame.data.is_empty() || frame.width <= 0 || frame.height <= 0 || frame.linesize <= 0 {
return Err(format!(
- "frame {index} has no pixel data ({}x{}, linesize {linesize})",
- width, height
+ "frame {index} has no pixel data ({}x{}, linesize {})",
+ frame.width, frame.height, frame.linesize
));
}
- let len = (linesize as usize)
- .checked_mul(height as usize)
- .ok_or_else(|| "frame buffer size overflow".to_string())?;
- // SAFETY: the engine's frame buffer is valid for linesize * height
- // bytes for the duration of this call.
- let bytes = unsafe { std::slice::from_raw_parts(data as *const u8, len) };
- let path = Path::new(out).join(format!("frame_{index:05}.ppm"));
- ppm::write_ppm(&path, width, height, format, channels, linesize, bytes)
- .map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
+ let path = std::path::Path::new(out).join(format!("frame_{index:05}.ppm"));
+ ppm::write_ppm(
+ &path,
+ frame.width,
+ frame.height,
+ frame.format,
+ 4,
+ frame.linesize,
+ &frame.data,
+ )
+ .map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
}
/// Render the audio range and write it as `audio.wav` in `out`. The
/// assembled clips span `[0, frames)` sequence timestamps, so the range
/// length is `frames` time-base ticks.
-fn write_audio_wav(renderer: *mut ffi::OakEngineRenderer, out: &str, frames: i64) -> i32 {
- let audio = unsafe { crate::ffi::oakengine_renderer_render_audio(renderer, 0, frames) };
- if audio.is_null() {
- eprintln!("error: transcode: audio render failed");
+fn write_audio_wav(
+ assembly: &Assembly,
+ out: &str,
+ frames: i64,
+ fr_num: i32,
+ fr_den: i32,
+) -> i32 {
+ let range = oakcore_rs::TimeRange::new(
+ oakcore_rs::Rational::new(0, 1),
+ oakcore_rs::Rational::new(frames * i64::from(fr_den), i64::from(fr_num)),
+ );
+ let montage = engine::audio_montage(&assembly.project, assembly.sequence, range);
+ let audio = match engine::render_audio(assembly.sequence, range, montage) {
+ Ok(a) => a,
+ Err(e) => {
+ eprintln!("error: transcode: audio render failed: {e}");
+ return EXIT_ERROR;
+ }
+ };
+ if audio.data.is_empty() || audio.sample_rate <= 0 || audio.channel_count <= 0 {
+ eprintln!("error: transcode: audio buffer is empty");
return EXIT_ERROR;
}
- let rate = unsafe { crate::ffi::oakengine_audio_sample_rate(audio) };
- let channels = unsafe { crate::ffi::oakengine_audio_channel_count(audio) };
- let samples = unsafe { crate::ffi::oakengine_audio_sample_count(audio) };
- let data = unsafe { crate::ffi::oakengine_audio_data(audio, 0) };
- let code = if data.is_null() || rate <= 0 || channels <= 0 || samples <= 0 {
- eprintln!("error: transcode: audio buffer is empty");
- EXIT_ERROR
- } else {
- match (samples as usize).checked_mul(channels as usize) {
- None => {
- eprintln!("error: transcode: audio buffer size overflow");
- EXIT_ERROR
- }
- Some(len) => {
- // SAFETY: the engine's audio buffer is valid for
- // samples * channels floats for this call's duration.
- let floats = unsafe { std::slice::from_raw_parts(data, len) };
- let path = Path::new(out).join("audio.wav");
- if let Err(e) = wav::write_wav(&path, rate, channels, samples, floats) {
- eprintln!("error: transcode: cannot write \"{}\": {e}", path.display());
- EXIT_ERROR
- } else {
- EXIT_OK
- }
- }
- }
- };
- unsafe { crate::ffi::oakengine_audio_free(audio) };
- code
+ let samples = (audio.data.len() / audio.channel_count as usize) as i64;
+ let path = std::path::Path::new(out).join("audio.wav");
+ if let Err(e) = wav::write_wav(&path, audio.sample_rate, audio.channel_count, samples, &audio.data) {
+ eprintln!("error: transcode: cannot write \"{}\": {e}", path.display());
+ return EXIT_ERROR;
+ }
+ EXIT_OK
}
-/// Probe the source media through the C ABI into a [`SourceInfo`].
+/// Probe the source media into a [`SourceInfo`].
///
-/// The engine's footage probe records the decoder but (currently) drops
-/// the codec's stream descriptions, so a successful probe still reports
-/// zero streams / unavailable stream info. The CLI then falls back to
-/// the documented defaults: `[width]` or 1920, 16:9 height, 25 fps,
-/// duration 0 (a single frame — the still-image contract). A failed
-/// probe is a hard error.
+/// The module probe records the decoder but (currently) drops the codec's
+/// stream descriptions, so a successful probe still reports zero streams /
+/// unavailable stream info. The CLI then falls back to the documented
+/// defaults: `[width]` or 1920, 16:9 height, 25 fps, duration 0 (a single
+/// frame — the still-image contract). A missing file is a hard error.
fn probe_source(path: &str) -> Result {
- let path_c = CString::new(path).map_err(|_| "invalid path (NUL byte)".to_string())?;
- let footage = unsafe { crate::ffi::oakengine_footage_probe(path_c.as_ptr()) };
- if footage.is_null() {
- let err = crate::ffi::string_get(|buf, size| unsafe {
- crate::ffi::oakengine_footage_last_error(buf, size)
- });
- return Err(err);
+ if !std::path::Path::new(path).exists() {
+ return Err(format!("file does not exist: {path}"));
+ }
+ let mut footage = FootageBehavior::new(path);
+ if let Err(e) = footage.probe() {
+ // The module probe failure keeps the node usable (like the facade's
+ // footage create); the fallback below applies.
+ let _ = e;
}
- let mut duration = 0.0f64;
- let _ = unsafe { crate::ffi::oakengine_footage_get_duration(footage, &mut duration) };
- let audio_streams = unsafe { crate::ffi::oakengine_footage_get_audio_stream_count(footage) }.max(0);
-
- let video_streams =
- unsafe { crate::ffi::oakengine_footage_get_video_stream_count(footage) }.max(0);
-
- let mut info = ffi::OakFootageVideoInfo {
- stream_index: 0,
- width: 0,
- height: 0,
- frame_rate_num: 0,
- frame_rate_den: 0,
- duration_ts: 0,
- time_base_num: 0,
- time_base_den: 0,
- color_primaries: 0,
- color_trc: 0,
- interlaced: 0,
- };
- let rc = if video_streams > 0 {
- unsafe { crate::ffi::oakengine_footage_get_video_stream_info(footage, 0, &mut info) }
+ let duration = footage.duration();
+ let duration = if duration.denominator() != 0 {
+ duration.numerator() as f64 / duration.denominator() as f64
} else {
- crate::ffi::OAKENGINE_E_NOT_FOUND
+ 0.0
};
- unsafe { crate::ffi::oakengine_footage_free(footage) };
+ let audio_streams = footage.audio_stream_count() as i32;
+ let video_streams = footage.video_stream_count() as i32;
- let src = if rc == crate::ffi::OAKENGINE_OK && info.width > 0 && info.height > 0 {
- SourceInfo {
- width: info.width,
- height: info.height,
- fr_num: info.frame_rate_num,
- fr_den: info.frame_rate_den,
- duration,
- audio_streams,
+ let info = if video_streams > 0 {
+ footage.video_params(0)
+ } else {
+ None
+ };
+
+ let src = if let Some(v) = info {
+ if v.width > 0 && v.height > 0 {
+ SourceInfo {
+ width: v.width,
+ height: v.height,
+ fr_num: v.frame_rate.numerator() as i32,
+ fr_den: v.frame_rate.denominator() as i32,
+ duration,
+ audio_streams,
+ }
+ } else {
+ // Module gap fallback (see the docs above).
+ SourceInfo {
+ width: 1920,
+ height: 1080,
+ fr_num: 25,
+ fr_den: 1,
+ duration,
+ audio_streams,
+ }
}
} else {
- // Engine gap fallback (see the docs above).
+ // Module gap fallback (see the docs above).
SourceInfo {
width: 1920,
height: 1080,
diff --git a/crates/oak-cli/src/engine.rs b/crates/oak-cli/src/engine.rs
new file mode 100644
index 000000000..2d0e79a25
--- /dev/null
+++ b/crates/oak-cli/src/engine.rs
@@ -0,0 +1,773 @@
+// Oak Video Editor - Non-Linear Video Editor
+// Copyright (C) 2026 Oak Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+//! Module-native engine helpers (M14 R2).
+//!
+//! oak-cli links the oak* module rlibs directly (oaknode / oaktimeline /
+//! oakcodec / oakrender / oaktask / oakcommon) instead of the built
+//! liboakengine dylib's C ABI. This module is the CLI's own assembly
+//! layer: it reproduces the facade operations the subcommands need —
+//! project load/create, footage probe, sequence + clip assembly, montage
+//! resolution, ticket rendering and the synchronous export path — over
+//! the modules' direct Rust APIs.
+//!
+//! The composition functions here mirror what the facade's C ABI exports
+//! (`oakengine_project_load`, `oakengine_sequence_add_footage_clip_ex`,
+//! `oakengine_renderer_render_frame`, ...) did over the same module APIs;
+//! the facade keeps its own copies for the frozen C ABI. Combinators that
+//! belong upstream (the effect-chain and timeline composites) are M14
+//! follow-up candidates for `oaknode::ops`; kept local until then.
+
+use std::path::Path;
+use std::sync::{Arc, Mutex, MutexGuard};
+
+use oakcore_rs::{Rational, TimeRange};
+use oaknode::block::{self, ClipBlockBehavior};
+use oaknode::footage::FootageBehavior;
+use oaknode::graph::Graph;
+use oaknode::id::NodeId;
+use oaknode::project::Project;
+use oaknode::sequence::SequenceBehavior;
+use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
+use oaknode::value::VideoParams;
+use oaktimeline::undogeneral::TimelineAddTrackCommand;
+use oaktimeline::undopointer::TrackPlaceBlockCommand;
+use oaktimeline::util::NodeRef;
+use oakrender::manager::RenderManager;
+use oakrender::ticket::{
+ AudioTicketParams, MontageClip, TicketPayload, VideoTicketParams,
+};
+
+/// The shared project reference (the modules' domain project handle).
+pub type ProjectRef = Arc>;
+
+/// Lock a project, recovering from a poisoned lock (a panicking command
+/// body must not wedge every later edit).
+fn lock(p: &ProjectRef) -> MutexGuard<'_, Project> {
+ p.lock().unwrap_or_else(|e| e.into_inner())
+}
+
+// ---------------------------------------------------------------------------
+// Project load / create
+// ---------------------------------------------------------------------------
+
+/// Load a `.ove` project file into a fresh project (the module
+/// serializer's `load` owns the graph). The filename is normalized to an
+/// absolute path and the modified flag cleared, mirroring the facade's
+/// `oakengine_project_load`.
+pub fn load_project(path: &str) -> Result {
+ let xml = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
+ let project = oaknode::serializer::load(&xml).map_err(|e| e.to_string())?;
+ let p = Path::new(path);
+ let abs = if p.is_absolute() {
+ p.to_path_buf()
+ } else {
+ std::env::current_dir()
+ .map(|d| d.join(p))
+ .unwrap_or_else(|_| p.to_path_buf())
+ };
+ let mut guard = lock(&project);
+ guard.set_filename(&abs.to_string_lossy());
+ guard.set_modified(false);
+ drop(guard);
+ Ok(project)
+}
+
+/// Project display name (`Project::name`; "(untitled)" when empty).
+pub fn project_name(p: &Project) -> String {
+ p.name()
+}
+
+/// Project file path.
+pub fn project_filename(p: &Project) -> String {
+ p.filename().to_string()
+}
+
+/// Project modified flag.
+pub fn project_modified(p: &Project) -> bool {
+ p.is_modified()
+}
+
+// ---------------------------------------------------------------------------
+// Graph walks
+// ---------------------------------------------------------------------------
+
+/// Every sequence node in the graph, in arena order (the facade's
+/// `oakengine_project_sequence_count`/`sequence_at` walk order).
+pub fn sequence_ids(p: &Project) -> Vec {
+ p.graph
+ .node_ids()
+ .into_iter()
+ .filter(|&id| seq_behavior(&p.graph, id).is_some())
+ .collect()
+}
+
+/// Every footage node in the graph, in arena order.
+pub fn footage_ids(p: &Project) -> Vec {
+ p.graph
+ .node_ids()
+ .into_iter()
+ .filter(|&id| footage_behavior(&p.graph, id).is_some())
+ .collect()
+}
+
+/// Borrow the sequence behavior at `id`.
+fn seq_behavior(g: &Graph, id: NodeId) -> Option<&SequenceBehavior> {
+ g.get(id)?
+ .behavior
+ .as_any()?
+ .downcast_ref::()
+}
+
+/// Borrow the footage behavior at `id`.
+fn footage_behavior(g: &Graph, id: NodeId) -> Option<&FootageBehavior> {
+ g.get(id)?
+ .behavior
+ .as_any()?
+ .downcast_ref::()
+}
+
+/// The label of a node (`NodeCore::label`).
+pub fn node_label(g: &Graph, id: NodeId) -> String {
+ g.get(id).map(|e| e.core.label.clone()).unwrap_or_default()
+}
+
+/// The footage filename at `id` (empty when the node is not footage).
+pub fn footage_filename(p: &Project, id: NodeId) -> String {
+ footage_behavior(&p.graph, id)
+ .map(|f| f.filename.clone())
+ .unwrap_or_default()
+}
+
+// ---------------------------------------------------------------------------
+// Sequence queries (info / render)
+// ---------------------------------------------------------------------------
+
+/// Sequence content length (seconds rational), 0/1 when unavailable.
+pub fn sequence_length(p: &Project, id: NodeId) -> Rational {
+ match seq_behavior(&p.graph, id) {
+ Some(s) => s.last_length,
+ None => Rational::new(0, 1),
+ }
+}
+
+/// Sequence frame rate (rational), 0/0 (the NULL sentinel) when the
+/// sequence has no video params — matching the facade's "0/0" report for
+/// such sequences.
+pub fn sequence_frame_rate(p: &Project, id: NodeId) -> Rational {
+ match seq_behavior(&p.graph, id).and_then(|s| s.video_params.first()) {
+ Some(v) => v.frame_rate,
+ None => Rational::new(0, 0),
+ }
+}
+
+/// Sequence output geometry `(width, height)` from its first video stream.
+pub fn sequence_geometry(p: &Project, id: NodeId) -> (i32, i32) {
+ match seq_behavior(&p.graph, id).and_then(|s| s.video_params.first()) {
+ Some(v) => (v.width, v.height),
+ None => (0, 0),
+ }
+}
+
+/// Sequence track counts `(video, audio, subtitle)`.
+pub fn sequence_track_counts(p: &Project, id: NodeId) -> (i64, i64, i64) {
+ let mut counts = (0i64, 0i64, 0i64);
+ let Some(seq) = seq_behavior(&p.graph, id) else {
+ return counts;
+ };
+ for &list_id in &seq.track_lists {
+ let Some(list) = track_list_behavior(&p.graph, list_id) else {
+ continue;
+ };
+ let n = list.tracks.len() as i64;
+ match list.kind {
+ TrackType::Video => counts.0 += n,
+ TrackType::Audio => counts.1 += n,
+ TrackType::Subtitle => counts.2 += n,
+ }
+ }
+ counts
+}
+
+/// Sequence playhead (seconds rational).
+pub fn sequence_playhead(p: &Project, id: NodeId) -> Rational {
+ match seq_behavior(&p.graph, id) {
+ Some(s) => s.playhead,
+ None => Rational::new(0, 1),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Transcode assembly
+// ---------------------------------------------------------------------------
+
+/// Create a sequence node in `project` (a scratch project, mirroring the
+/// facade's `oakengine_sequence_new` documented deviation) and label it.
+pub fn create_sequence(project: &ProjectRef, name: &str) -> NodeId {
+ let id = {
+ let mut guard = lock(project);
+ let (core, behavior) = SequenceBehavior::create();
+ guard.graph.add_node(core, behavior)
+ };
+ {
+ let mut guard = lock(project);
+ if let Some(e) = guard.graph.get_mut(id) {
+ e.core.label = name.to_string();
+ }
+ }
+ id
+}
+
+/// Set the sequence's first video stream geometry + frame rate.
+pub fn set_sequence_video_params(
+ p: &ProjectRef,
+ id: NodeId,
+ width: i32,
+ height: i32,
+ fr_num: i32,
+ fr_den: i32,
+) {
+ let mut guard = lock(p);
+ if let Some(s) = guard
+ .graph
+ .get_mut(id)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ if s.video_params.is_empty() {
+ s.video_params.push(VideoParams {
+ width,
+ height,
+ frame_rate: Rational::new(i64::from(fr_num), i64::from(fr_den)),
+ pixel_format: 4, // f32
+ channels: 4,
+ });
+ } else {
+ let v = &mut s.video_params[0];
+ v.width = width;
+ v.height = height;
+ v.frame_rate = Rational::new(i64::from(fr_num), i64::from(fr_den));
+ }
+ }
+}
+
+/// Borrow the track list behavior at `id`.
+fn track_list_behavior(g: &Graph, id: NodeId) -> Option<&TrackListBehavior> {
+ g.get(id)?
+ .behavior
+ .as_any()?
+ .downcast_ref::()
+}
+
+/// Borrow the track behavior at `id`.
+fn track_behavior(g: &Graph, id: NodeId) -> Option<&TrackBehavior> {
+ g.get(id)?
+ .behavior
+ .as_any()?
+ .downcast_ref::()
+}
+
+/// Find (or create) the sequence's track list of `kind` (the facade's
+/// `oaknode_sequence_get_track_list` find-or-create semantics).
+pub fn find_or_create_track_list(
+ p: &ProjectRef,
+ seq_id: NodeId,
+ kind: TrackType,
+) -> Option {
+ let mut guard = lock(p);
+ // Existing list of the kind.
+ for &list_id in seq_behavior(&guard.graph, seq_id)?.track_lists.iter() {
+ if track_list_behavior(&guard.graph, list_id).map(|l| l.kind) == Some(kind) {
+ return Some(list_id);
+ }
+ }
+ // Create it: a graph node owned by the sequence.
+ let (core, behavior) = TrackListBehavior::create();
+ let mut behavior = behavior;
+ if let Some(a) = behavior.as_any_mut() {
+ if let Some(list) = a.downcast_mut::() {
+ list.kind = kind;
+ list.array_base = seq_behavior(&guard.graph, seq_id)?.track_lists.len() as i32;
+ }
+ }
+ let list_id = guard.graph.add_node(core, behavior);
+ if let Some(seq) = guard
+ .graph
+ .get_mut(seq_id)
+ .and_then(|e| e.behavior.as_any_mut())
+ .and_then(|a| a.downcast_mut::())
+ {
+ seq.track_lists.push(list_id);
+ }
+ if let Some(list) = track_list_behavior_mut(&mut guard.graph, list_id) {
+ list.sequence = Some(seq_id);
+ }
+ Some(list_id)
+}
+
+/// Mutable track-list borrow helper.
+fn track_list_behavior_mut(g: &mut Graph, id: NodeId) -> Option<&mut TrackListBehavior> {
+ g.get_mut(id)?
+ .behavior
+ .as_any_mut()?
+ .downcast_mut::()
+}
+
+/// Append a track of `kind` to the sequence (the module's
+/// `TimelineAddTrackCommand`), returning the new track's index (the
+/// facade's `oakengine_sequence_add_track` contract).
+pub fn add_track(p: &ProjectRef, seq_id: NodeId, kind: TrackType) -> Result {
+ let list_id = find_or_create_track_list(p, seq_id, kind)
+ .ok_or_else(|| "sequence has no track list for this type".to_string())?;
+ let mut cmd = TimelineAddTrackCommand::new(NodeRef::new(p.clone(), list_id));
+ cmd.redo();
+ let guard = lock(p);
+ let n = track_list_behavior(&guard.graph, list_id)
+ .map(|l| l.tracks.len() as i32)
+ .ok_or_else(|| "add track command produced no track list".to_string())?;
+ if n > 0 {
+ Ok(n - 1)
+ } else {
+ Err("add track command produced no track".to_string())
+ }
+}
+
+/// Convert a frame timestamp (sequence frame-rate timebase ticks) to a
+/// seconds rational: `ts` ticks of `fr_den/fr_num` seconds.
+pub fn ts_to_seconds(ts: i64, fr_num: i32, fr_den: i32) -> Rational {
+ Rational::new(ts * i64::from(fr_den), i64::from(fr_num))
+}
+
+/// Place a footage clip on a track of the sequence (the facade's
+/// `oakengine_sequence_add_footage_clip_ex` semantics: the sequence lives
+/// in its own scratch project, so a scratch footage node is created there
+/// and connected to the clip; the real-project footage is untouched).
+///
+/// `in_ts`/`out_ts`/`media_in_ts` are frame timestamps in the sequence's
+/// frame-rate timebase.
+pub fn place_footage_clip(
+ project: &ProjectRef,
+ seq_id: NodeId,
+ filename: &str,
+ kind: TrackType,
+ track_index: i32,
+ in_ts: i64,
+ out_ts: i64,
+ media_in_ts: i64,
+ fr_num: i32,
+ fr_den: i32,
+) -> Result<(), String> {
+ if in_ts < 0 || out_ts <= in_ts || media_in_ts < 0 {
+ return Err("invalid clip range (need 0 <= in < out and media_in >= 0)".to_string());
+ }
+ let list_id = find_or_create_track_list(project, seq_id, kind)
+ .ok_or_else(|| "sequence has no track list for this type".to_string())?;
+
+ // Track-index validation against the current list.
+ let track_count = {
+ let guard = lock(project);
+ track_list_behavior(&guard.graph, list_id)
+ .map(|l| l.tracks.len() as i32)
+ .unwrap_or(0)
+ };
+ if track_index < 0 || track_index >= track_count {
+ return Err(format!(
+ "track index {track_index} out of range ({track_count} tracks)"
+ ));
+ }
+
+ let in_r = ts_to_seconds(in_ts, fr_num, fr_den);
+ let out_r = ts_to_seconds(out_ts, fr_num, fr_den);
+ let media_r = ts_to_seconds(media_in_ts, fr_num, fr_den);
+ let length = out_r - in_r;
+
+ // The scratch footage node (created directly in the sequence's project;
+ // the graph edge cannot cross projects).
+ let footage_id = {
+ let mut guard = lock(project);
+ let (mut core, behavior) = FootageBehavior::create();
+ core.set_standard_value("file_in", -1, oaknode::value::NodeValue::Text(filename.to_string()));
+ let id = guard.graph.add_node(core, behavior);
+ if let Some(f) = footage_behavior_mut(&mut guard.graph, id) {
+ f.filename = filename.to_string();
+ let _ = f.probe();
+ }
+ id
+ };
+
+ // The clip block, positioned by media-in + length (the facade's
+ // `oaknode_clip_set_media_in` + `oaknode_block_set_length_and_media_in`).
+ let clip_id = {
+ let mut guard = lock(project);
+ let (core, behavior) = block::clip_create();
+ let id = guard.graph.add_node(core, behavior);
+ if let Some(c) = clip_behavior_mut(&mut guard.graph, id) {
+ c.core.media_in = media_r;
+ c.core.set_length_and_media_in(length);
+ }
+ id
+ };
+
+ // Place on the track (the module's TrackPlaceBlockCommand redo).
+ let mut place =
+ TrackPlaceBlockCommand::new(NodeRef::new(project.clone(), list_id), track_index, NodeRef::new(project.clone(), clip_id), in_r);
+ place.redo();
+
+ // Connect the scratch footage to the clip's texture input.
+ {
+ let mut guard = lock(project);
+ guard
+ .graph
+ .connect(footage_id, clip_id, block::clip_input::TEXTURE_INPUT, -1)
+ .map_err(|e| e.to_string())?;
+ }
+ Ok(())
+}
+
+/// Mutable footage borrow helper.
+fn footage_behavior_mut(g: &mut Graph, id: NodeId) -> Option<&mut FootageBehavior> {
+ g.get_mut(id)?
+ .behavior
+ .as_any_mut()?
+ .downcast_mut::()
+}
+
+/// Mutable clip borrow helper.
+fn clip_behavior_mut(g: &mut Graph, id: NodeId) -> Option<&mut ClipBlockBehavior> {
+ g.get_mut(id)?
+ .behavior
+ .as_any_mut()?
+ .downcast_mut::()
+}
+
+// ---------------------------------------------------------------------------
+// Montage resolution (the facade's build_video_montage /
+// build_audio_montage over the module graph)
+// ---------------------------------------------------------------------------
+
+/// The first footage node feeding `id` (upstream BFS over input edges),
+/// mirroring the facade's `oaknode_node_find_input_footage`.
+fn find_input_footage(g: &Graph, id: NodeId) -> Option {
+ let mut frontier = vec![id];
+ let mut visited: Vec = Vec::new();
+ while !frontier.is_empty() {
+ let mut next = Vec::new();
+ for cur in frontier {
+ if visited.contains(&cur) {
+ continue;
+ }
+ visited.push(cur);
+ let entry = g.get(cur)?;
+ if entry.behavior.type_id() == "org.olivevideoeditor.Olive.footage" && cur != id {
+ return Some(cur);
+ }
+ for (src, _, _) in g.input_connections(cur) {
+ next.push(src);
+ }
+ }
+ frontier = next;
+ }
+ None
+}
+
+/// The footage filename feeding a clip (upstream BFS + footage behavior).
+fn clip_media(g: &Graph, block_id: NodeId) -> Option<(String, i32)> {
+ let footage_id = find_input_footage(g, block_id)?;
+ let filename = footage_behavior(g, footage_id)?.filename.clone();
+ Some((filename, 0))
+}
+
+/// The video montage at sequence time `time`: every clip covering `time`
+/// on video tracks, ordered bottom-to-top (track index 0 is topmost, so
+/// it is composited last).
+pub fn video_montage(p: &ProjectRef, seq_id: NodeId, time: Rational) -> Vec {
+ let g = lock(p);
+ let mut clips = Vec::new();
+ let Some(seq) = seq_behavior(&g.graph, seq_id) else {
+ return clips;
+ };
+ for &list_id in &seq.track_lists {
+ let Some(list) = track_list_behavior(&g.graph, list_id) else {
+ continue;
+ };
+ if list.kind != TrackType::Video {
+ continue;
+ }
+ for &track_id in list.tracks.iter().rev() {
+ let Some(track) = track_behavior(&g.graph, track_id) else {
+ continue;
+ };
+ for &block_id in &track.blocks {
+ let Some(clip) = g
+ .graph
+ .get(block_id)
+ .and_then(|e| e.behavior.as_any())
+ .and_then(|a| a.downcast_ref::())
+ else {
+ continue;
+ };
+ let in_ = clip.core.in_();
+ let out = clip.core.out();
+ if time < in_ || time >= out {
+ continue;
+ }
+ let Some((filename, _)) = clip_media(&g.graph, block_id) else {
+ continue;
+ };
+ clips.push(MontageClip {
+ filename,
+ stream_index: 0,
+ in_time: in_,
+ out_time: out,
+ media_in: clip.core.media_in,
+ gain: 1.0,
+ });
+ }
+ }
+ }
+ clips
+}
+
+/// The audio montage over `range`: every audio clip overlapping the
+/// range, media times resolved from the clip ranges, audio stream 1.
+pub fn audio_montage(p: &ProjectRef, seq_id: NodeId, range: TimeRange) -> Vec {
+ let g = lock(p);
+ let mut clips = Vec::new();
+ let Some(seq) = seq_behavior(&g.graph, seq_id) else {
+ return clips;
+ };
+ for &list_id in &seq.track_lists {
+ let Some(list) = track_list_behavior(&g.graph, list_id) else {
+ continue;
+ };
+ if list.kind != TrackType::Audio {
+ continue;
+ }
+ for &track_id in &list.tracks {
+ let Some(track) = track_behavior(&g.graph, track_id) else {
+ continue;
+ };
+ for &block_id in &track.blocks {
+ let Some(clip) = g
+ .graph
+ .get(block_id)
+ .and_then(|e| e.behavior.as_any())
+ .and_then(|a| a.downcast_ref::())
+ else {
+ continue;
+ };
+ let in_ = clip.core.in_();
+ let out = clip.core.out();
+ if out <= range.in_() || in_ >= range.out() {
+ continue;
+ }
+ let Some((filename, _)) = clip_media(&g.graph, block_id) else {
+ continue;
+ };
+ clips.push(MontageClip {
+ filename,
+ stream_index: 1,
+ in_time: in_,
+ out_time: out,
+ media_in: clip.core.media_in,
+ gain: 1.0,
+ });
+ }
+ }
+ }
+ clips
+}
+
+// ---------------------------------------------------------------------------
+// Rendering (the facade's renderer over the oakrender ticket arena)
+// ---------------------------------------------------------------------------
+
+/// Bring up the process-wide render manager; already-initialized is
+/// success (the facade's `oakengine_render_manager_init` returns the
+/// module's OAKRENDER_E_STATE for a second init, which the CLI treats as
+/// "already up").
+pub fn render_manager_init() -> Result<(), String> {
+ match RenderManager::init() {
+ Ok(()) => Ok(()),
+ Err(oakrender::error::Error::State) => Ok(()),
+ Err(e) => Err(e.to_string()),
+ }
+}
+
+/// Tear down the process-wide render manager (no-op when down).
+pub fn render_manager_shutdown() {
+ RenderManager::shutdown();
+}
+
+/// A rendered frame's pixel payload (the module frame a video ticket
+/// produces).
+pub struct RenderedFrame {
+ /// Width in pixels.
+ pub width: i32,
+ /// Height in pixels.
+ pub height: i32,
+ /// Pixel format (`oakcore_rs::PixelFormat` as int).
+ pub format: i32,
+ /// Bytes per scanline (stride).
+ pub linesize: i32,
+ /// Pixel data (at least `linesize * height` bytes).
+ pub data: Vec,
+}
+
+/// Render one frame of the sequence's montage at `time` (seconds
+/// rational) into a `(width, height)` F32 frame.
+pub fn render_frame(
+ seq_id: NodeId,
+ time: Rational,
+ montage: Vec,
+ width: i32,
+ height: i32,
+) -> Result {
+ let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?;
+ let params = VideoTicketParams {
+ viewer: seq_id.identity(),
+ time,
+ force_size: Some((width, height)),
+ force_format: None,
+ cache: None,
+ cache_dir: None,
+ cache_id: None,
+ cache_timebase: None,
+ footage: None,
+ montage,
+ };
+ let id = m.tickets.next_id();
+ m.tickets.submit_video_with_id(id, params, Box::new(|_| {}));
+ m.tickets.wait(id).map_err(|e| e.to_string())?;
+ let result = m.tickets.result(id).ok_or_else(|| "render ticket produced no result".to_string())?;
+ match &result {
+ Ok(TicketPayload::Video(oakrender::texture::Texture::Cpu(frame))) => {
+ Ok(RenderedFrame {
+ width: frame.width,
+ height: frame.height,
+ format: frame.format as i32,
+ linesize: frame.linesize_bytes() as i32,
+ data: frame.data.clone(),
+ })
+ }
+ Ok(TicketPayload::Video(_)) => Err("render produced a non-CPU frame".to_string()),
+ _ => Err("render produced no video frame".to_string()),
+ }
+}
+
+/// Rendered interleaved f32 audio (the module audio ticket payload).
+pub struct RenderedAudio {
+ /// Interleaved samples (`frame_count * channel_count` values).
+ pub data: Vec,
+ /// Sample rate (Hz).
+ pub sample_rate: i32,
+ /// Channel count.
+ pub channel_count: i32,
+}
+
+/// Render the audio range `[start, end)` (seconds rational) as
+/// interleaved f32 at 48 kHz stereo (the facade's
+/// `oakengine_renderer_render_audio` defaults; uncovered parts are
+/// silent).
+pub fn render_audio(
+ seq_id: NodeId,
+ range: TimeRange,
+ montage: Vec,
+) -> Result {
+ let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?;
+ let params = AudioTicketParams {
+ viewer: seq_id.identity(),
+ range,
+ sample_rate: 48000,
+ channel_layout: 0x3,
+ montage,
+ };
+ let id = m.tickets.next_id();
+ m.tickets
+ .submit_audio_with_id(id, params, Box::new(|_| {}));
+ m.tickets.wait(id).map_err(|e| e.to_string())?;
+ let result = m.tickets.result(id).ok_or_else(|| "audio ticket produced no result".to_string())?;
+ match result {
+ Ok(TicketPayload::Audio(samples)) => Ok(RenderedAudio {
+ data: samples.samples,
+ sample_rate: samples.sample_rate,
+ channel_count: samples.channel_count,
+ }),
+ _ => Err("audio render produced no samples".to_string()),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Export (the facade's `oakengine_export_render` over the module export
+// task)
+// ---------------------------------------------------------------------------
+
+/// Synchronously export `[0, frames)` of the sequence to `out` as
+/// H.264/AAC MP4 (the facade's `oakengine_export_render` defaults: codec
+/// default bit rates, 48 kHz stereo, fit scaling; the output geometry is
+/// the sequence's).
+pub fn export_sequence(
+ project: &ProjectRef,
+ seq_id: NodeId,
+ out: &str,
+ fr_num: i32,
+ fr_den: i32,
+ frames: i64,
+) -> Result<(), String> {
+ let (width, height) = {
+ let guard = lock(project);
+ sequence_geometry(&guard, seq_id)
+ };
+ if width <= 0 || height <= 0 {
+ return Err("sequence has no valid video dimensions".to_string());
+ }
+ if fr_num <= 0 || fr_den <= 0 {
+ return Err("sequence has no valid frame rate".to_string());
+ }
+ let out_num = frames * i64::from(fr_den);
+ let encoding = oaktask::export::EncodingParams {
+ filename: out.to_string(),
+ format: oakcodec::exportformat::Format::MPEG4Video as i32,
+ video_enabled: true,
+ video_codec: oakcodec::exportcodec::Codec::H264 as i32,
+ video_width: width,
+ video_height: height,
+ video_time_base_num: fr_den,
+ video_time_base_den: fr_num,
+ video_pixel_format: 0,
+ audio_enabled: true,
+ audio_codec: oakcodec::exportcodec::Codec::AAC as i32,
+ audio_sample_rate: 48000,
+ audio_channel_layout: 0x3,
+ subtitles_enabled: false,
+ export_length_num: out_num as i32,
+ export_length_den: fr_num,
+ has_custom_range: true,
+ custom_range_in_num: 0,
+ custom_range_in_den: fr_num,
+ custom_range_out_num: out_num as i32,
+ custom_range_out_den: fr_num,
+ };
+ let inner = oaktask::export::ExportTask::new((project.clone(), seq_id), encoding);
+ let mut driver = oaktask::task::Task::new("Exporting...", None);
+ driver.set_behavior(Box::new(inner));
+ driver.start().map_err(|_| {
+ driver
+ .error()
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| "export failed".to_string())
+ })
+}
diff --git a/crates/oak-cli/src/ffi.rs b/crates/oak-cli/src/ffi.rs
deleted file mode 100644
index 6cd769647..000000000
--- a/crates/oak-cli/src/ffi.rs
+++ /dev/null
@@ -1,389 +0,0 @@
-// Oak Video Editor - Non-Linear Video Editor
-// Copyright (C) 2026 Oak Team
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with this program. If not, see .
-
-//! The `oakengine_*` C ABI surface oak-cli consumes — **declared and
-//! linked**.
-//!
-//! This module mirrors — verbatim — every function, opaque handle and POD
-//! struct from the engine headers that the C++ `cli/main.cpp` touches:
-//!
-//! - `engine/include/oakengine/project.h` (project lifecycle + queries)
-//! - `engine/include/oakengine/footage.h` (probe / stream info / import)
-//! - `engine/include/oakengine/timeline.h` (sequence + track/clip editing)
-//! - `engine/include/oakengine/renderer.h` (render manager init, renderer,
-//! frame + audio buffer)
-//! - `engine/include/oakengine/videoparams.h` (sequence video params)
-//!
-//! The `#[link(name = "oakengine", kind = "dylib")]` block resolves every
-//! symbol against the built `liboakengine` cdylib at link time (the
-//! search path comes from `build.rs`; see the Cargo.toml comment for the
-//! build order). Five symbols from the original declaration surface are
-//! NOT exported by the current Rust facade — `oakengine_init`,
-//! `oakengine_shutdown` (init.h) and the exporter trio
-//! `oakengine_export_render` / `oakengine_export_last_error` /
-//! `oakengine_export_set_progress_callback` (exporter.h). Those live in
-//! [`crate::optional`], which resolves them with `dlsym` at call time so
-//! the prescribed call sequences keep working when the facade grows them.
-//!
-//! The subcommands in `src/cmd/` call this surface directly — no deferral
-//! gate, no module-crate calls.
-
-#![allow(dead_code)]
-#![allow(non_camel_case_types)]
-#![allow(clippy::missing_safety_doc)]
-
-use std::ffi::{c_char, c_double, c_int, c_void};
-
-// ---------------------------------------------------------------------------
-// Opaque engine handle types (engine/include/oakengine/*.h).
-// ---------------------------------------------------------------------------
-
-#[repr(C)]
-pub struct OakEngineProject {
- _opaque: [u8; 0],
-}
-
-#[repr(C)]
-pub struct OakEngineSequence {
- _opaque: [u8; 0],
-}
-
-#[repr(C)]
-pub struct OakEngineRenderer {
- _opaque: [u8; 0],
-}
-
-#[repr(C)]
-pub struct OakEngineFrame {
- _opaque: [u8; 0],
-}
-
-#[repr(C)]
-pub struct OakEngineAudioBuffer {
- _opaque: [u8; 0],
-}
-
-#[repr(C)]
-pub struct OakEngineFootage {
- _opaque: [u8; 0],
-}
-
-#[repr(C)]
-pub struct OakEngineClip {
- _opaque: [u8; 0],
-}
-
-// ---------------------------------------------------------------------------
-// POD structs (footage.h / exporter.h).
-// ---------------------------------------------------------------------------
-
-/// `oak_footage_video_info` (engine/include/oakengine/footage.h).
-#[repr(C)]
-#[derive(Clone, Copy)]
-pub struct OakFootageVideoInfo {
- pub stream_index: c_int,
- pub width: c_int,
- pub height: c_int,
- pub frame_rate_num: c_int,
- pub frame_rate_den: c_int,
- pub duration_ts: i64,
- pub time_base_num: c_int,
- pub time_base_den: c_int,
- pub color_primaries: c_int,
- pub color_trc: c_int,
- pub interlaced: c_int,
-}
-
-/// `oak_footage_audio_info` (engine/include/oakengine/footage.h).
-#[repr(C)]
-#[derive(Clone, Copy)]
-pub struct OakFootageAudioInfo {
- pub stream_index: c_int,
- pub sample_rate: c_int,
- pub channel_layout: u64,
- pub channel_count: c_int,
- pub duration_ts: i64,
- pub time_base_num: c_int,
- pub time_base_den: c_int,
-}
-
-/// `oak_export_options` (engine/include/oakengine/exporter.h).
-#[repr(C)]
-#[derive(Clone, Copy)]
-pub struct OakExportOptions {
- pub video_codec: c_int,
- pub audio_codec: c_int,
- pub video_bit_rate: i64,
- pub audio_sample_rate: c_int,
- pub audio_channel_count: c_int,
-}
-
-/// `oakengine_export_progress_fn` (exporter.h).
-pub type OakEngineExportProgressFn =
- Option;
-
-// ---------------------------------------------------------------------------
-// Constants (verbatim values from the engine headers).
-// ---------------------------------------------------------------------------
-
-/// OAKENGINE_OK / OAKENGINE_E_* (init.h).
-pub const OAKENGINE_OK: c_int = 0;
-pub const OAKENGINE_E_INVALID: c_int = -1;
-pub const OAKENGINE_E_STATE: c_int = -2;
-pub const OAKENGINE_E_FAILED: c_int = -3;
-pub const OAKENGINE_E_NOT_FOUND: c_int = -4;
-
-/// OAKENGINE_INIT_* (init.h).
-pub const OAKENGINE_INIT_HEADLESS: c_int = 0x01;
-pub const OAKENGINE_INIT_RENDER: c_int = 0x02;
-
-/// olive::core::PixelFormat::f32, the renderer's frame pixel format
-/// (`k_pixel_format_f32` in cli/main.cpp).
-pub const PIXEL_FORMAT_F32: c_int = 4;
-
-/// OAKENGINE_TRACK_TYPE_* (timeline.h).
-pub const OAKENGINE_TRACK_TYPE_VIDEO: c_int = 0;
-pub const OAKENGINE_TRACK_TYPE_AUDIO: c_int = 1;
-
-/// OAKENGINE_EXPORT_VIDEO_* / OAKENGINE_EXPORT_AUDIO_* (exporter.h).
-pub const OAKENGINE_EXPORT_VIDEO_H264: c_int = 0;
-pub const OAKENGINE_EXPORT_AUDIO_AAC: c_int = 0;
-
-// ---------------------------------------------------------------------------
-// The facade surface (the symbols the built liboakengine exports; see the
-// module docs — the five missing ones live in crate::optional).
-// ---------------------------------------------------------------------------
-
-#[link(name = "oakengine", kind = "dylib")]
-extern "C" {
- // ---- project.h -------------------------------------------------------
- pub fn oakengine_project_create() -> *mut OakEngineProject;
- pub fn oakengine_project_free(self_: *mut OakEngineProject);
- pub fn oakengine_project_new(self_: *mut OakEngineProject) -> c_int;
- pub fn oakengine_project_load(
- self_: *mut OakEngineProject,
- path: *const c_char,
- err: *mut c_char,
- err_size: c_int,
- ) -> c_int;
- pub fn oakengine_project_name(
- self_: *const OakEngineProject,
- buf: *mut c_char,
- buf_size: c_int,
- ) -> c_int;
- pub fn oakengine_project_filename(
- self_: *const OakEngineProject,
- buf: *mut c_char,
- buf_size: c_int,
- ) -> c_int;
- pub fn oakengine_project_is_modified(self_: *const OakEngineProject) -> c_int;
- pub fn oakengine_project_sequence_count(self_: *const OakEngineProject) -> c_int;
- pub fn oakengine_project_sequence_at(
- self_: *const OakEngineProject,
- index: c_int,
- ) -> *mut OakEngineSequence;
- pub fn oakengine_project_footage_count(self_: *const OakEngineProject) -> c_int;
- pub fn oakengine_project_footage_filename(
- self_: *const OakEngineProject,
- index: c_int,
- buf: *mut c_char,
- buf_size: c_int,
- ) -> c_int;
- pub fn oakengine_project_footage_is_online(
- self_: *const OakEngineProject,
- index: c_int,
- ) -> c_int;
-
- // ---- footage.h -------------------------------------------------------
- pub fn oakengine_project_import_footage(
- project: *mut OakEngineProject,
- path: *const c_char,
- ) -> *mut OakEngineFootage;
- pub fn oakengine_footage_probe(path: *const c_char) -> *mut OakEngineFootage;
- pub fn oakengine_footage_free(self_: *mut OakEngineFootage);
- pub fn oakengine_footage_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
- pub fn oakengine_footage_get_decoder_name(
- self_: *mut OakEngineFootage,
- buf: *mut c_char,
- buf_size: c_int,
- ) -> c_int;
- pub fn oakengine_footage_get_duration(
- self_: *mut OakEngineFootage,
- seconds: *mut c_double,
- ) -> c_int;
- pub fn oakengine_footage_get_video_stream_count(self_: *const OakEngineFootage) -> c_int;
- pub fn oakengine_footage_get_video_stream_info(
- self_: *mut OakEngineFootage,
- index: c_int,
- out: *mut OakFootageVideoInfo,
- ) -> c_int;
- pub fn oakengine_footage_get_audio_stream_count(self_: *const OakEngineFootage) -> c_int;
- pub fn oakengine_footage_get_audio_stream_info(
- self_: *mut OakEngineFootage,
- index: c_int,
- out: *mut OakFootageAudioInfo,
- ) -> c_int;
- pub fn oakengine_footage_get_subtitle_stream_count(self_: *const OakEngineFootage) -> c_int;
-
- // ---- timeline.h ------------------------------------------------------
- pub fn oakengine_sequence_new(
- project: *mut OakEngineProject,
- name: *const c_char,
- ) -> *mut OakEngineSequence;
- pub fn oakengine_sequence_name(
- self_: *const OakEngineSequence,
- buf: *mut c_char,
- buf_size: c_int,
- ) -> c_int;
- pub fn oakengine_sequence_get_length(
- self_: *const OakEngineSequence,
- seconds: *mut c_double,
- ) -> c_int;
- pub fn oakengine_sequence_get_length_rational(
- self_: *const OakEngineSequence,
- num: *mut c_int,
- den: *mut c_int,
- ) -> c_int;
- pub fn oakengine_sequence_get_frame_rate(
- self_: *const OakEngineSequence,
- num: *mut c_int,
- den: *mut c_int,
- ) -> c_int;
- pub fn oakengine_sequence_get_video_params(
- self_: *const OakEngineSequence,
- width: *mut c_int,
- height: *mut c_int,
- par_num: *mut c_int,
- par_den: *mut c_int,
- ) -> c_int;
- pub fn oakengine_sequence_set_video_params(
- self_: *mut OakEngineSequence,
- width: c_int,
- height: c_int,
- fps_num: c_int,
- fps_den: c_int,
- par_num: c_int,
- par_den: c_int,
- interlacing: c_int,
- format: c_int,
- undoable: c_int,
- ) -> c_int;
- pub fn oakengine_sequence_track_count(
- self_: *const OakEngineSequence,
- video: *mut c_int,
- audio: *mut c_int,
- subtitle: *mut c_int,
- ) -> c_int;
- pub fn oakengine_sequence_get_playhead(
- self_: *const OakEngineSequence,
- timestamp: *mut i64,
- ) -> c_int;
- pub fn oakengine_sequence_get_playhead_seconds(
- self_: *const OakEngineSequence,
- seconds: *mut c_double,
- ) -> c_int;
- pub fn oakengine_sequence_add_track(self_: *mut OakEngineSequence, track_type: c_int) -> c_int;
- pub fn oakengine_sequence_add_footage_clip(
- seq: *mut OakEngineSequence,
- footage: *mut OakEngineFootage,
- track_type: c_int,
- track_index: c_int,
- in_ts: i64,
- out_ts: i64,
- media_in: i64,
- ) -> *mut OakEngineClip;
- /// Like `oakengine_sequence_add_footage_clip` but skips the
- /// same-project check: sequences created through `oakengine_sequence_new`
- /// live in their own scratch project (documented engine deviation), so
- /// the CLI's transcode flow (footage in the real project, sequence in
- /// the scratch project) needs the `_ex` variant.
- pub fn oakengine_sequence_add_footage_clip_ex(
- seq: *mut OakEngineSequence,
- footage: *mut OakEngineFootage,
- track_type: c_int,
- track_index: c_int,
- in_ts: i64,
- out_ts: i64,
- media_in: i64,
- ) -> *mut OakEngineClip;
- pub fn oakengine_sequence_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
-
- // ---- renderer.h (render manager: the Rust facade's equivalent of the
- // ---- C++ OAKENGINE_INIT_RENDER engine-core render boot) -------------
- pub fn oakengine_render_manager_init() -> c_int;
- pub fn oakengine_render_manager_shutdown() -> c_int;
-
- // ---- renderer.h ------------------------------------------------------
- pub fn oakengine_renderer_create(
- seq: *mut OakEngineSequence,
- width: c_int,
- height: c_int,
- pixel_format: c_int,
- frame_rate_num: c_int,
- frame_rate_den: c_int,
- output_colorspace: *const c_char,
- ) -> *mut OakEngineRenderer;
- pub fn oakengine_renderer_free(self_: *mut OakEngineRenderer);
- pub fn oakengine_renderer_last_error(
- self_: *const OakEngineRenderer,
- buf: *mut c_char,
- buf_size: c_int,
- ) -> c_int;
- pub fn oakengine_renderer_render_frame(
- self_: *mut OakEngineRenderer,
- timestamp: i64,
- ) -> *mut OakEngineFrame;
- pub fn oakengine_renderer_render_audio(
- self_: *mut OakEngineRenderer,
- start_timestamp: i64,
- length_timestamp: i64,
- ) -> *mut OakEngineAudioBuffer;
-
- // ---- renderer.h (OakEngineFrame) -------------------------------------
- pub fn oakengine_frame_width(self_: *const OakEngineFrame) -> c_int;
- pub fn oakengine_frame_height(self_: *const OakEngineFrame) -> c_int;
- pub fn oakengine_frame_format(self_: *const OakEngineFrame) -> c_int;
- pub fn oakengine_frame_channel_count(self_: *const OakEngineFrame) -> c_int;
- pub fn oakengine_frame_linesize_bytes(self_: *const OakEngineFrame) -> c_int;
- pub fn oakengine_frame_data(self_: *const OakEngineFrame) -> *const c_void;
- pub fn oakengine_frame_free(self_: *mut OakEngineFrame);
-
- // ---- renderer.h (OakEngineAudioBuffer) --------------------------------
- pub fn oakengine_audio_sample_rate(self_: *const OakEngineAudioBuffer) -> c_int;
- pub fn oakengine_audio_channel_count(self_: *const OakEngineAudioBuffer) -> c_int;
- pub fn oakengine_audio_sample_count(self_: *const OakEngineAudioBuffer) -> i64;
- pub fn oakengine_audio_data(self_: *const OakEngineAudioBuffer, channel: c_int) -> *const f32;
- pub fn oakengine_audio_free(self_: *mut OakEngineAudioBuffer);
-}
-
-/// Read a facade string (buf/size convention) into an owned `String`,
-/// mirroring `facade_string()` in cli/main.cpp: a negative return is an
-/// error/empty string, otherwise the getter is called twice (size query,
-/// then fill) and the trailing NUL is stripped.
-///
-/// `fill` must be one of the `oakengine_*` string getters (handle
-/// getters closed over their live handle, last-error getters applied
-/// directly).
-pub fn string_get(mut fill: impl FnMut(*mut c_char, c_int) -> c_int) -> String {
- let size = fill(std::ptr::null_mut(), 0);
- if size < 0 {
- return String::new();
- }
- let mut s = vec![0u8; size as usize + 1];
- let n = fill(s.as_mut_ptr() as *mut c_char, size + 1);
- s.truncate(n.max(0) as usize);
- String::from_utf8_lossy(&s).into_owned()
-}
diff --git a/crates/oak-cli/src/host.rs b/crates/oak-cli/src/host.rs
deleted file mode 100644
index 94f43ea11..000000000
--- a/crates/oak-cli/src/host.rs
+++ /dev/null
@@ -1,155 +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 .
-
-//! Host-side `oakcore_audioparams_*` shims.
-//!
-//! The engine dylib links with `-undefined dynamic_lookup`, leaving a few
-//! liboakcore symbols (`oakcore_audioparams_*`, used by
-//! `oakengine_renderer_render_audio` and the sequence audio-params path)
-//! to be resolved from the host process at runtime. The C++ `cli/main.cpp`
-//! host linked liboakcore; the Rust CLI provides the same symbols itself —
-//! the binary is the host. `build.rs` passes `-Wl,-export_dynamic` so the
-//! linker exports them, and [`exports`] keeps them referenced.
-//!
-//! Semantics mirror the C++ `olive::core::AudioParams`:
-//! `{sample_rate, channel_layout, format, time_base}` with the time base
-//! defaulting to `1/sample_rate`, exactly like the liboakcore constructor
-//! (`oakcore_audioparams.cpp`).
-
-use std::ffi::c_void;
-
-/// The liboakcore `AudioParams` payload (`oakcore_audioparams.h`).
-struct AudioParams {
- sample_rate: i32,
- channel_layout: u64,
- format: i32,
- time_base_num: i32,
- time_base_den: i32,
-}
-
-/// `oakcore_audioparams_create` — allocate with `time_base = 1/sample_rate`.
-///
-/// # Safety
-/// None; returns an owned box cast to `void*` (NULL never happens for
-/// valid inputs; a zero sample rate keeps the caller's contract intact).
-#[no_mangle]
-pub unsafe extern "C" fn oakcore_audioparams_create(
- sample_rate: i32,
- channel_layout: u64,
- format: i32,
-) -> *mut c_void {
- let den = if sample_rate > 0 { sample_rate } else { 1 };
- Box::into_raw(Box::new(AudioParams {
- sample_rate,
- channel_layout,
- format,
- time_base_num: 1,
- time_base_den: den,
- })) as *mut c_void
-}
-
-/// `oakcore_audioparams_free` — NULL no-op.
-///
-/// # Safety
-/// `params` must be a pointer from [`oakcore_audioparams_create`] or NULL.
-#[no_mangle]
-pub unsafe extern "C" fn oakcore_audioparams_free(params: *mut c_void) {
- unsafe {
- if !params.is_null() {
- drop(Box::from_raw(params as *mut AudioParams));
- }
- }
-}
-
-/// `oakcore_audioparams_sample_rate` — 0 for NULL (liboakcore contract).
-///
-/// # Safety
-/// `params` must be a pointer from [`oakcore_audioparams_create`] or NULL.
-#[no_mangle]
-pub unsafe extern "C" fn oakcore_audioparams_sample_rate(params: *const c_void) -> i32 {
- unsafe {
- if params.is_null() {
- 0
- } else {
- (*(params as *const AudioParams)).sample_rate
- }
- }
-}
-
-/// `oakcore_audioparams_channel_layout` — 0 for NULL.
-///
-/// # Safety
-/// `params` must be a pointer from [`oakcore_audioparams_create`] or NULL.
-#[no_mangle]
-pub unsafe extern "C" fn oakcore_audioparams_channel_layout(params: *const c_void) -> u64 {
- unsafe {
- if params.is_null() {
- 0
- } else {
- (*(params as *const AudioParams)).channel_layout
- }
- }
-}
-
-/// `oakcore_audioparams_format` — 0 for NULL.
-///
-/// # Safety
-/// `params` must be a pointer from [`oakcore_audioparams_create`] or NULL.
-#[no_mangle]
-pub unsafe extern "C" fn oakcore_audioparams_format(params: *const c_void) -> i32 {
- unsafe {
- if params.is_null() {
- 0
- } else {
- (*(params as *const AudioParams)).format
- }
- }
-}
-
-/// `oakcore_audioparams_set_time_base` — NULL no-op.
-///
-/// # Safety
-/// `params` must be a pointer from [`oakcore_audioparams_create`] or NULL.
-#[no_mangle]
-pub unsafe extern "C" fn oakcore_audioparams_set_time_base(
- params: *mut c_void,
- num: i32,
- den: i32,
-) {
- unsafe {
- if params.is_null() {
- return;
- }
- let p = &mut *(params as *mut AudioParams);
- p.time_base_num = num;
- p.time_base_den = den;
- }
-}
-
-/// Keep-alive references so the linker never drops the host shims
-/// (referenced from `main`; the `-export_dynamic` flag exports them for
-/// the engine dylib's runtime lookups).
-pub fn exports() -> usize {
- let fns: [usize; 6] = [
- oakcore_audioparams_create as *const () as usize,
- oakcore_audioparams_free as *const () as usize,
- oakcore_audioparams_sample_rate as *const () as usize,
- oakcore_audioparams_channel_layout as *const () as usize,
- oakcore_audioparams_format as *const () as usize,
- oakcore_audioparams_set_time_base as *const () as usize,
- ];
- fns.iter().sum()
-}
diff --git a/crates/oak-cli/src/main.rs b/crates/oak-cli/src/main.rs
index 0596b37f3..6993c80f9 100644
--- a/crates/oak-cli/src/main.rs
+++ b/crates/oak-cli/src/main.rs
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-//! oak-cli: headless command-line consumer of the liboakengine C ABI facade.
+//! oak-cli: headless command-line consumer of the oak editor modules.
//!
//! Rust rewrite of `cli/main.cpp` (which stays in the tree until cutover).
//! Same subcommands, same output format, same exit codes:
@@ -29,25 +29,17 @@
//! Exit codes: 0 success, 1 general error, 2 rendering unavailable,
//! 64 usage error.
//!
-//! This crate is a PURE C-ABI consumer of the built `liboakengine`
-//! cdylib: every engine call goes through the `extern "C"` declarations
-//! in [`ffi`] (linked via `build.rs` + `#[link(name = "oakengine", kind =
-//! "dylib")]`) and the dlsym-resolved optional families in [`optional`].
-//! No module crate is ever called directly. [`host`] plays the C++ host
-//! role the engine dylib expects: it provides the `oakcore_audioparams_*`
-//! symbols the dylib resolves from the process at runtime. [`fmt`],
-//! [`ppm`] and [`wav`] are pure-Rust formatting/writing helpers (exact
-//! ports of the C++ `printf`/writers) — the only non-ABI code here.
-//!
-//! Build order: `cargo build -p oakengine` must run before linking this
-//! binary (`cargo build`/`cargo test -p oak-cli`); `cargo check` never
-//! links and works standalone.
+//! Since M14 R2 this crate links the oak* module rlibs directly
+//! (oaknode / oaktimeline / oakcodec / oakrender / oaktask / oakcommon) —
+//! no liboakengine dylib, no C ABI, no host shims. [`engine`] is the
+//! CLI's own assembly layer over the modules; the subcommands in
+//! `src/cmd/` call it (and the modules) directly. [`fmt`], [`ppm`] and
+//! [`wav`] are pure-Rust formatting/writing helpers (exact ports of the
+//! C++ `printf`/writers).
mod cmd;
-mod ffi;
+mod engine;
mod fmt;
-mod host;
-mod optional;
mod ppm;
mod wav;
@@ -57,7 +49,7 @@ use clap::{Parser, Subcommand};
/// The exact usage text of `cli/main.cpp`'s `print_usage()` (also the
/// `--help` output).
-const USAGE: &str = "oak-cli - headless consumer of the liboakengine C ABI\n\
+const USAGE: &str = "oak-cli - headless consumer of the oak editor modules (direct Rust ABI)\n\
\n\
Usage:\n\
oak-cli info \n\
@@ -141,10 +133,6 @@ enum Command {
}
fn main() {
- // Keep the host shims (src/host.rs) referenced so the linker
- // exports them for the engine dylib's runtime lookups.
- std::hint::black_box(host::exports());
-
let args: Vec = std::env::args().skip(1).collect();
// argv[1] handling that mirrors the C++ main() exactly.
diff --git a/crates/oak-cli/src/optional.rs b/crates/oak-cli/src/optional.rs
deleted file mode 100644
index 4d6409942..000000000
--- a/crates/oak-cli/src/optional.rs
+++ /dev/null
@@ -1,196 +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 .
-
-//! Optional `oakengine_*` families, resolved from the loaded
-//! `liboakengine` at call time.
-//!
-//! Two families of the frozen C ABI are not exported by the current Rust
-//! facade and therefore cannot be declared in [`crate::ffi`]'s link block:
-//!
-//! - `init.h` — `oakengine_init` / `oakengine_shutdown`. The C++ engine
-//! core (`EngineCore::instance()`) was removed with the C++ tree; the
-//! Rust facade has no process-global init state, so nothing needs
-//! initializing. The subcommands still go through the prescribed
-//! `oakengine_init(OAKENGINE_INIT_*)` call sequence; when the symbol
-//! is absent the call is a documented no-op returning
-//! `OAKENGINE_OK`. (`OAKENGINE_INIT_RENDER` semantics are provided by
-//! the real `oakengine_render_manager_init` export instead.)
-//! - `exporter.h` — `oakengine_export_render` /
-//! `oakengine_export_last_error` /
-//! `oakengine_export_set_progress_callback`. The facade never wrapped
-//! the exporter assembly layer (its `oakengine_export_render_with_params`
-//! is an unbacked stub returning `OAKENGINE_E_FAILED`), so mp4 export
-//! through the engine is not available yet. The transcode command
-//! attempts the real call; when the family is absent it reports the
-//! engine's export error (or a fixed explanation) and exits 1.
-//!
-//! Resolution uses `dlsym(RTLD_DEFAULT, ...)`: `liboakengine` is a direct
-//! dependency of the binary, so its exports are in the global scope. Each
-//! symbol is looked up once and cached; the lookup itself never fails the
-//! build, so `cargo check`/`cargo build` stay green regardless of which
-//! symbols the dylib currently carries.
-
-#![allow(dead_code)]
-#![allow(clippy::missing_safety_doc)]
-
-use std::ffi::{c_char, c_double, c_int, c_void};
-use std::sync::OnceLock;
-
-use crate::ffi::{OakEngineSequence, OakExportOptions};
-
-/// `oakengine_init` (init.h).
-pub type InitFn = unsafe extern "C" fn(flags: c_int) -> c_int;
-/// `oakengine_shutdown` (init.h).
-pub type ShutdownFn = unsafe extern "C" fn() -> c_int;
-/// `oakengine_export_render` (exporter.h).
-pub type ExportRenderFn = unsafe extern "C" fn(
- seq: *mut OakEngineSequence,
- path: *const c_char,
- in_ts: i64,
- out_ts: i64,
- width: c_int,
- height: c_int,
- opts: *const OakExportOptions,
-) -> c_int;
-/// `oakengine_export_last_error` (exporter.h).
-pub type ExportLastErrorFn = unsafe extern "C" fn(buf: *mut c_char, buf_size: c_int) -> c_int;
-/// `oakengine_export_set_progress_callback` (exporter.h).
-pub type ExportSetProgressFn = unsafe extern "C" fn(
- f: Option,
- userdata: *mut c_void,
-);
-
-#[cfg(unix)]
-extern "C" {
- fn dlsym(handle: *mut c_void, name: *const c_char) -> *mut c_void;
-}
-
-#[cfg(target_os = "macos")]
-const RTLD_DEFAULT: *mut c_void = -2isize as *mut c_void;
-#[cfg(all(unix, not(target_os = "macos")))]
-const RTLD_DEFAULT: *mut c_void = std::ptr::null_mut();
-
-/// Resolve one facade symbol with `dlsym`. `None` when the loaded dylib
-/// does not export it (or the platform has no dlsym).
-#[cfg(unix)]
-fn lookup(name: &str) -> Option {
- let cname = std::ffi::CString::new(name).ok()?;
- let ptr = unsafe { dlsym(RTLD_DEFAULT, cname.as_ptr()) };
- if ptr.is_null() {
- return None;
- }
- // SAFETY: dlsym returns the address of a live function with the ABI
- // `name` names; the cast pins its signature.
- Some(unsafe { std::mem::transmute_copy::<*mut c_void, T>(&ptr) })
-}
-
-#[cfg(not(unix))]
-fn lookup(_name: &str) -> Option {
- None
-}
-
-/// Cached `oakengine_init` resolution.
-static INIT: OnceLock