diff --git a/crates/oak-cli/Cargo.toml b/crates/oak-cli/Cargo.toml
index 20dc1e832..397833d6b 100644
--- a/crates/oak-cli/Cargo.toml
+++ b/crates/oak-cli/Cargo.toml
@@ -28,13 +28,13 @@ path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
-# oakfacade is where the frozen oakengine_* C ABI exports live (staticlib +
-# rlib). oak-cli is a pure consumer of that facade, so it links the rlib
-# directly. The families this CLI needs (init / project / timeline / render /
-# footage / exporter) are still deferred in the facade
-# (src/facade/rust/src/deferred.rs), so this crate references no oakengine_*
-# symbol yet: the subcommands validate their arguments and report the
-# deferral (src/deferred.rs). The extern declarations in src/ffi.rs mirror
-# the engine headers verbatim and resolve against this rlib the moment a
-# family is wrapped -- no manifest change needed.
-oakengine = { path = "../oakengine" }
+# 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).
diff --git a/crates/oak-cli/build.rs b/crates/oak-cli/build.rs
new file mode 100644
index 000000000..fec83bcc1
--- /dev/null
+++ b/crates/oak-cli/build.rs
@@ -0,0 +1,58 @@
+// 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 7fa7fefb6..ecedb4779 100644
--- a/crates/oak-cli/src/cmd/info.rs
+++ b/crates/oak-cli/src/cmd/info.rs
@@ -16,29 +16,208 @@
//! `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.
+//!
+//! Footage filenames stored relative to the `.ove` file are resolved
+//! against the project directory for display (the C++ CLI's project-dir
+//! convention); the online flag reports whether the resolved file exists.
+//! A load failure prints the engine's error and exits 1.
-use crate::cmd::{port_not_wired, require_or, EXIT_ERROR};
+use std::ffi::CString;
+use std::path::Path;
+
+use crate::cmd::{EXIT_ERROR, EXIT_OK};
+use crate::ffi;
+use crate::fmt;
/// Run `info`. `project` is the .ove path from the command line.
pub fn run(project: String) -> i32 {
- if let Err(code) = require_or(
- "info",
- &[
- &crate::deferred::INIT,
- &crate::deferred::NODE,
- &crate::deferred::TIMELINE,
- ],
- EXIT_ERROR,
- ) {
- return code;
+ let code = run_info(&project);
+ unsafe {
+ crate::optional::engine_shutdown();
+ }
+ code
+}
+
+/// The info body; the caller owns the engine shutdown.
+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) {
+ Ok(p) => p,
+ Err(_) => {
+ eprintln!("error: info: invalid path (NUL byte)");
+ unsafe { crate::ffi::oakengine_project_free(handle) };
+ 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.
+ 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 filename = if filename.is_empty() {
+ abs.to_string_lossy().into_owned()
+ } else {
+ filename
+ };
+
+ println!("{}", fmt::project_line(&name));
+ println!("{}", fmt::file_line(&filename));
+ println!("{}", fmt::modified_line(modified));
+
+ // Footage paths in .ove files can be relative to the project file;
+ // 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 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 resolved = resolve_footage(&stored, project_dir.as_deref());
+ let online = resolved.is_file();
+ println!(
+ "{}",
+ fmt::footage_entry(index as i64, &resolved.to_string_lossy(), online)
+ );
+ }
+
+ 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)
+ });
+
+ 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);
+ }
+
+ 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,
+ )
+ );
+}
+
+/// Resolve a stored footage filename against the project directory (the
+/// C++ project-dir convention); absolute paths pass through.
+fn resolve_footage(stored: &str, project_dir: Option<&Path>) -> std::path::PathBuf {
+ let p = Path::new(stored);
+ if p.is_absolute() || project_dir.is_none() {
+ p.to_path_buf()
+ } else {
+ project_dir.unwrap().join(p)
}
- // Facade port (unreachable while the families above are deferred):
- // oakengine_init(OAKENGINE_INIT_HEADLESS)
- // project_create + project_load(project, ...)
- // name/filename/is_modified/sequence_count/sequence_at(...) +
- // fmt::sequence() / fmt::footage_entry() for each
- // project_free + oakengine_shutdown()
- // The formatters already exist in crate::fmt and are golden-tested.
- let _ = &project;
- port_not_wired("info", EXIT_ERROR)
}
diff --git a/crates/oak-cli/src/cmd/mod.rs b/crates/oak-cli/src/cmd/mod.rs
index c0bed6163..c41d32518 100644
--- a/crates/oak-cli/src/cmd/mod.rs
+++ b/crates/oak-cli/src/cmd/mod.rs
@@ -16,56 +16,32 @@
//! Subcommand implementations.
//!
-//! Each subcommand is a faithful port of its `cli/main.cpp` counterpart:
-//! the argument validation is real (same messages, same usage-error code),
-//! and the facade work gates on [`crate::deferred::require`] — while the
-//! families a subcommand needs are deferred, it prints the "not yet
-//! available" error with the reasons and exits with the C++-compatible code
-//! (1 for info/probe, 2 for render/transcode), never crashing.
+//! 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:
+//!
+//! - `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
+//!
+//! Exit codes: 0 success, 1 general error, 2 rendering unavailable,
+//! 64 usage error.
pub mod info;
pub mod probe;
pub mod render;
pub mod transcode;
-use crate::deferred::DeferredFamily;
-
/// 0 — success.
pub const EXIT_OK: i32 = 0;
/// 1 — general error (bad project/media file, no sequence, I/O failure).
pub const EXIT_ERROR: i32 = 1;
-/// 2 — rendering unavailable or failed (e.g. no GL render backend).
+/// 2 — rendering unavailable or failed (e.g. no render backend).
pub const EXIT_RENDER_UNAVAILABLE: i32 = 2;
/// 64 — usage error.
pub const EXIT_USAGE: i32 = 64;
-
-/// Gate a subcommand on its facade families.
-///
-/// When every family is wrapped this returns `Ok(())` and the subcommand's
-/// port runs; when any is deferred it prints the composed "not yet
-/// available" message to stderr and returns `Err(unavailable_code)` — the
-/// code the C++ binary would exit with when that family's work is
-/// impossible (1 for info/probe, 2 for render/transcode).
-pub fn require_or(
- cmd: &str,
- families: &[&DeferredFamily],
- unavailable_code: i32,
-) -> Result<(), i32> {
- match crate::deferred::require(families) {
- Ok(()) => Ok(()),
- Err(msg) => {
- eprintln!("error: {cmd}: {msg}");
- Err(unavailable_code)
- }
- }
-}
-
-/// Fallback for the (today unreachable) success arm of `require_or`: the
-/// gate reported the families available, but the call-through port is not
-/// wired yet. Never panics; reports an internal error and returns `code`.
-pub fn port_not_wired(cmd: &str, code: i32) -> i32 {
- eprintln!(
- "error: {cmd}: internal error: facade families reported available but no port is wired yet"
- );
- code
-}
diff --git a/crates/oak-cli/src/cmd/probe.rs b/crates/oak-cli/src/cmd/probe.rs
index 499aa36bf..6ca50d879 100644
--- a/crates/oak-cli/src/cmd/probe.rs
+++ b/crates/oak-cli/src/cmd/probe.rs
@@ -17,23 +17,180 @@
//! `oak-cli probe ` — probe a media file and print its decoder,
//! 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.
-use crate::cmd::{port_not_wired, require_or, EXIT_ERROR};
+use std::ffi::{CString, c_int};
+
+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 {
- if let Err(code) = require_or(
- "probe",
- &[&crate::deferred::INIT, &crate::deferred::NODE],
- EXIT_ERROR,
- ) {
- return code;
+ let code = run_probe(&mediafile);
+ unsafe {
+ crate::optional::engine_shutdown();
}
- // Facade port (unreachable while the families above are deferred):
- // oakengine_init(OAKENGINE_INIT_HEADLESS)
- // footage_probe(mediafile) -> decoder_name/duration/stream infos,
- // formatted with the fmt::* lines (golden-tested)
- // footage_free + oakengine_shutdown()
- let _ = &mediafile;
- port_not_wired("probe", EXIT_ERROR)
+ code
+}
+
+/// The probe body; the caller owns the engine shutdown.
+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})");
+ 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;
+ }
+
+ println!(
+ "{}",
+ fmt::decoder_line(&crate::ffi::string_get(|buf, size| unsafe {
+ crate::ffi::oakengine_footage_get_decoder_name(footage, buf, size)
+ }))
+ );
+
+ 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));
+ } else {
+ println!("{}", fmt::duration_line(0.0));
+ }
+
+ let video = unsafe { crate::ffi::oakengine_footage_get_video_stream_count(footage) }.max(0);
+ 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),
+ );
+ 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,
+ secs,
+ info.color_primaries as i64,
+ info.color_trc as i64,
+ info.interlaced != 0,
+ )
+ );
+ }
+ }
+
+ let audio = unsafe { crate::ffi::oakengine_footage_get_audio_stream_count(footage) }.max(0);
+ 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),
+ );
+ 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,
+ )
+ );
+ }
+ }
+
+ let subtitle =
+ unsafe { crate::ffi::oakengine_footage_get_subtitle_stream_count(footage) }.max(0);
+ 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 bfa9b30ad..d631a3df9 100644
--- a/crates/oak-cli/src/cmd/render.rs
+++ b/crates/oak-cli/src/cmd/render.rs
@@ -17,15 +17,38 @@
//! `oak-cli render ` —
//! 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:
+//!
+//! 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
+//! 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`].
+//!
+//! 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`).
-use crate::cmd::{port_not_wired, require_or, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE};
+use std::path::Path;
+
+use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE};
+use crate::ffi;
+use crate::ppm;
+use crate::wav;
/// Run `render` with the validated (or rejected) seconds arguments.
-///
-/// The seconds are validated exactly like the C++ `strtod` checks before any
-/// facade work; the facade work itself (init + project + sequence + renderer,
-/// then [`crate::ppm::write_ppm`] / [`crate::wav::write_wav`] per frame) is
-/// gated on the deferred families below.
pub fn run(project: String, start_seconds: &str, end_seconds: &str, out_dir: &str) -> i32 {
let start: f64 = match start_seconds.parse() {
Ok(v) => v,
@@ -46,24 +69,314 @@ pub fn run(project: String, start_seconds: &str, end_seconds: &str, out_dir: &st
return EXIT_USAGE;
}
- if let Err(code) = require_or(
- "render",
- &[
- &crate::deferred::INIT,
- &crate::deferred::NODE,
- &crate::deferred::TIMELINE,
- &crate::deferred::RENDER,
- ],
- EXIT_RENDER_UNAVAILABLE,
- ) {
- return code;
+ let code = run_render(&project, start, end, out_dir);
+ unsafe {
+ crate::optional::engine_shutdown();
}
- // Facade port (unreachable while the families above are deferred):
- // oakengine_init(HEADLESS | RENDER), chdir to the project dir,
- // project_load, sequence 0 frame rate -> start_ts/end_ts,
- // renderer_create(f32, fr_num, fr_den), then for each timestamp
- // render_frame -> ppm::write_ppm (progress on stderr), then
- // render_audio -> wav::write_wav. Both writers are golden-tested.
- let _ = (&project, &start, &end, &out_dir);
- port_not_wired("render", EXIT_RENDER_UNAVAILABLE)
+ code
+}
+
+/// The render body; the caller owns the engine shutdown.
+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;
+ }
+ };
+ if let Some(dir) = abs.parent() {
+ 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()) {
+ Ok(p) => p,
+ Err(_) => {
+ eprintln!("error: render: invalid path (NUL byte)");
+ unsafe {
+ crate::ffi::oakengine_project_free(handle);
+ crate::ffi::oakengine_render_manager_shutdown();
+ }
+ 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: render: cannot load project \"{project}\"");
+ } else {
+ eprintln!("error: render: {detail}");
+ }
+ 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);
+ }
+ 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();
+ }
+ 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();
+ }
+ 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();
+ }
+ 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;
+ let mut written: u64 = 0;
+ loop {
+ let time = index as f64 * fr_den as f64 / fr_num as f64;
+ 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();
+ }
+ return EXIT_RENDER_UNAVAILABLE;
+ }
+ if let Err(msg) = unsafe { 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();
+ }
+ return EXIT_ERROR;
+ }
+ unsafe {
+ crate::ffi::oakengine_frame_free(frame);
+ }
+ eprintln!("frame {written}: {time:.6} s");
+ written += 1;
+ index += 1;
+ }
+
+ 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();
+ }
+ 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();
+ }
+ 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();
+ }
+ 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 {
+ return Err(format!(
+ "frame {index} has no pixel data ({}x{}, linesize {linesize})",
+ width, height
+ ));
+ }
+ 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()))
+}
+
+/// 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 {
+ 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) {
+ eprintln!("error: render: cannot write \"{}\": {e}", path.display());
+ return EXIT_ERROR;
+ }
+ EXIT_OK
}
diff --git a/crates/oak-cli/src/cmd/transcode.rs b/crates/oak-cli/src/cmd/transcode.rs
index 1ea46b76f..183172dae 100644
--- a/crates/oak-cli/src/cmd/transcode.rs
+++ b/crates/oak-cli/src/cmd/transcode.rs
@@ -16,12 +16,58 @@
//! `oak-cli transcode [width] [--format ppm|mp4]` —
//! "media in, renders out" round trip (port of `cmd_transcode()` in
-//! cli/main.cpp).
+//! cli/main.cpp), entirely through the C ABI.
+//!
+//! 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).
+//!
+//! - `--format ppm` (and the image/still path): renders the frame range
+//! through `oakengine_renderer_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::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`).
+//!
+//! 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.
-use crate::cmd::{port_not_wired, require_or, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE};
+use std::ffi::CString;
+use std::path::Path;
-/// Run `transcode`. `width`/`format` are validated exactly like the C++ loop
-/// over `argv[4..]`; the facade work is gated on the deferred families below.
+use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_USAGE};
+use crate::ffi::{self, OakExportOptions};
+use crate::ppm;
+use crate::wav;
+
+/// Source description distilled from the probe (through the C ABI).
+struct SourceInfo {
+ width: i32,
+ height: i32,
+ fr_num: i32,
+ fr_den: i32,
+ duration: f64,
+ audio_streams: i32,
+}
+
+/// Run `transcode`. `width`/`format` are validated exactly like the C++
+/// loop over `argv[4..]`.
pub fn run(input_media: String, out: String, width: Option, format: Option) -> i32 {
if let Some(w) = &width {
match w.parse::() {
@@ -39,27 +85,481 @@ pub fn run(input_media: String, out: String, width: Option, format: Opti
}
}
- if let Err(code) = require_or(
- "transcode",
- &[
- &crate::deferred::INIT,
- &crate::deferred::NODE,
- &crate::deferred::TIMELINE,
- &crate::deferred::RENDER,
- &crate::deferred::EXPORT,
- ],
- EXIT_RENDER_UNAVAILABLE,
- ) {
- return code;
+ 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();
}
- // Facade port (unreachable while the families above are deferred):
- // probe the source for geometry/fps/duration, build a temporary
- // project (new + import_footage + sequence_new + add_track x2 +
- // add_footage_clip x2), then either the ppm path (render_frame /
- // render_audio -> ppm::write_ppm / wav::write_wav) or the mp4 path
- // (oakengine_export_render with H.264/AAC options + progress
- // callback). The C++ exits 2 when the render/export backend is
- // unavailable, which is also the code used here.
- let _ = (&input_media, &out, &width, &format);
- port_not_wired("transcode", EXIT_RENDER_UNAVAILABLE)
+ code
+}
+
+/// The transcode body; the caller owns the engine shutdown.
+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) => {
+ eprintln!("error: transcode: {msg}");
+ return EXIT_ERROR;
+ }
+ };
+
+ let out_w = width
+ .and_then(|w| w.parse::().ok())
+ .unwrap_or(src.width);
+ let out_h = if src.width > 0 && src.height > 0 {
+ ((out_w as f64 * src.height as f64 / src.width as f64).round() as i32).max(1)
+ } else {
+ (out_w as f64 * 9.0 / 16.0).round() as i32
+ };
+ let fr_num = if src.fr_num > 0 { src.fr_num } else { 25 };
+ let fr_den = if src.fr_den > 0 { src.fr_den } else { 1 };
+ // A still/duration-less source counts as a single frame.
+ 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) {
+ Ok(a) => a,
+ Err(msg) => {
+ eprintln!("error: transcode: {msg}");
+ return EXIT_ERROR;
+ }
+ };
+
+ let code = if is_ppm {
+ 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)
+ };
+ unsafe {
+ crate::ffi::oakengine_render_manager_shutdown();
+ crate::ffi::oakengine_project_free(assembly.project);
+ }
+ code
+}
+
+/// The assembled temporary project: project + footage + sequence (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,
+}
+
+/// Build the temporary project for the render/export stage.
+fn assemble_project(
+ input: &str,
+ out_w: i32,
+ out_h: i32,
+ fr_num: i32,
+ fr_den: i32,
+ 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());
+ }
+
+ 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 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(
+ 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,
+ 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 video clip"));
+ }
+
+ 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
+/// into PPM frames (+ the audio range into a WAV when the source has
+/// audio).
+fn transcode_ppm(
+ assembly: &Assembly,
+ out: &str,
+ out_w: i32,
+ out_h: i32,
+ fr_num: i32,
+ fr_den: i32,
+ frames: i64,
+ audio: bool,
+) -> i32 {
+ if let Err(e) = std::fs::create_dir_all(out) {
+ 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);
+ }
+ 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;
+ if audio {
+ code = write_audio_wav(renderer, out, frames);
+ }
+ unsafe { crate::ffi::oakengine_renderer_free(renderer) };
+ code
+}
+
+/// `--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})")
+ } else {
+ err
+ };
+ 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 {
+ return Err(format!(
+ "frame {index} has no pixel data ({}x{}, linesize {linesize})",
+ width, height
+ ));
+ }
+ 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()))
+}
+
+/// 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");
+ 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
+}
+
+/// Probe the source media through the C ABI 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.
+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);
+ }
+
+ 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) }
+ } else {
+ crate::ffi::OAKENGINE_E_NOT_FOUND
+ };
+ unsafe { crate::ffi::oakengine_footage_free(footage) };
+
+ 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,
+ }
+ } else {
+ // Engine gap fallback (see the docs above).
+ SourceInfo {
+ width: 1920,
+ height: 1080,
+ fr_num: 25,
+ fr_den: 1,
+ duration,
+ audio_streams,
+ }
+ };
+ Ok(src)
}
diff --git a/crates/oak-cli/src/deferred.rs b/crates/oak-cli/src/deferred.rs
deleted file mode 100644
index 5fcd0adad..000000000
--- a/crates/oak-cli/src/deferred.rs
+++ /dev/null
@@ -1,143 +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 .
-
-//! Facade-family availability, mirroring `src/facade/rust/src/deferred.rs`.
-//!
-//! Every `oak-cli` subcommand depends on one or more families of the
-//! `oakengine_*` C ABI. Those families live in the `oakengine` crate, and
-//! some of them are **deferred**: the facade does not wrap them yet, so the
-//! subcommands must report a clear "not yet available" error instead of
-//! calling into the facade (the calls would not link, and faking behavior
-//! would be worse).
-//!
-//! The entries below are kept field-for-field in sync with the facade's own
-//! deferral documentation (`src/facade/rust/src/deferred.rs`). All families
-//! this CLI consumes are currently deferred; when a family is wrapped, remove
-//! its entry here and the subcommand's call-through (see `src/cmd/`) becomes
-//! reachable.
-
-/// One deferred facade family: what it covers, which engine headers define
-/// it, and why the facade does not wrap it yet.
-pub struct DeferredFamily {
- /// Short family name, as used in messages.
- pub name: &'static str,
- /// Engine headers involved.
- pub headers: &'static str,
- /// Why the family is not wrapped yet (from the facade's deferred.rs).
- pub reason: &'static str,
-}
-
-/// `init.h` — engine process initialization/shutdown.
-///
-/// Not even listed in the facade's scope table yet (`src/facade/rust/README.md`):
-/// the facade currently wraps only undo/config/video_params/audio/plugin.
-pub const INIT: DeferredFamily = DeferredFamily {
- name: "init",
- headers: "init.h",
- reason: "the facade shell (oakengine_init/shutdown) is not wrapped in oakengine yet (its scope table covers only undo/common/audio/plugin)",
-};
-
-/// `project.h` + `footage.h` — the oaknode module family.
-///
-/// Facade deferred.rs "node": all 30 exports of the oaknode Rust crate are
-/// `todo!()` bodies, so the engine project/footage families have no module
-/// backing to wrap.
-pub const NODE: DeferredFamily = DeferredFamily {
- name: "node (project/footage)",
- headers: "project.h, footage.h",
- reason: "deferred: the oaknode crate is an unimplemented skeleton (every export is a todo!() body), so the project/footage families have no module backing",
-};
-
-/// `timeline.h` — sequence/track/clip family.
-///
-/// Facade deferred.rs "timeline": the oaktimeline crate's exports reference
-/// ~80 oaknode C ABI symbols the skeletal oaknode crate does not define, and
-/// its test-stubs collide with the real oakundo crate in the facade test
-/// link.
-pub const TIMELINE: DeferredFamily = DeferredFamily {
- name: "timeline",
- headers: "timeline.h",
- reason: "deferred: test linkage — the oaktimeline crate's exports reference oaknode C ABI symbols the skeletal oaknode crate does not define",
-};
-
-/// `renderer.h` — renderer/frame/audio-buffer family.
-///
-/// Facade deferred.rs "render": no structural blocker; the engine renderer.h
-/// family simply was not wrapped in the facade's current pass.
-pub const RENDER: DeferredFamily = DeferredFamily {
- name: "render",
- headers: "renderer.h",
- reason: "deferred for session scope: the engine renderer.h family is not wrapped in oakengine yet (no structural blocker)",
-};
-
-/// `exporter.h` — export/encode family.
-///
-/// Facade deferred.rs: exporter is a "genuinely facade-only area" (the
-/// liboakengine assembly layer) with no files in the oakengine crate.
-pub const EXPORT: DeferredFamily = DeferredFamily {
- name: "exporter",
- headers: "exporter.h",
- reason: "deferred: the exporter family is a facade-only assembly area with no Rust backing (src/facade/rust/src/deferred.rs)",
-};
-
-/// Check that every family in `families` is available in the facade.
-///
-/// Returns `Ok(())` when all are wrapped (none is today); otherwise `Err`
-/// carries the composed "not yet available" message naming each deferred
-/// family and its reason, for the subcommands to print and exit on.
-pub fn require(families: &[&DeferredFamily]) -> Result<(), String> {
- if families.is_empty() {
- return Ok(());
- }
- let mut detail = String::new();
- for f in families {
- detail.push_str(&format!("\n - {} ({}): {}", f.name, f.headers, f.reason));
- }
- Err(format!(
- "not yet available in the Rust facade (oakengine): these family(ies) are still deferred \
- (see src/facade/rust/src/deferred.rs):{detail}"
- ))
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn empty_family_list_is_available() {
- assert!(require(&[]).is_ok());
- }
-
- #[test]
- fn deferred_family_lists_a_reason() {
- let err = require(&[&INIT]).unwrap_err();
- assert!(err.contains("not yet available"));
- assert!(err.contains("init"));
- assert!(err.contains("oakengine"));
- }
-
- #[test]
- fn all_cli_families_are_currently_deferred() {
- // Keeps this file honest: if any family the CLI depends on flips to
- // available, the subcommand ports in src/cmd/ become reachable and
- // the tests asserting "not yet available" must be revisited.
- let all: [&[&DeferredFamily]; 5] =
- [&[&INIT], &[&NODE], &[&TIMELINE], &[&RENDER], &[&EXPORT]];
- for families in all {
- assert!(require(families).is_err());
- }
- }
-}
diff --git a/crates/oak-cli/src/ffi.rs b/crates/oak-cli/src/ffi.rs
index 2ca1e0551..6cd769647 100644
--- a/crates/oak-cli/src/ffi.rs
+++ b/crates/oak-cli/src/ffi.rs
@@ -14,37 +14,32 @@
// 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, not yet
+//! 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/init.h` (oakengine_init / shutdown)
//! - `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` (renderer + frame + audio buffer)
-//! - `engine/include/oakengine/exporter.h` (export options + render)
+//! - `engine/include/oakengine/renderer.h` (render manager init, renderer,
+//! frame + audio buffer)
+//! - `engine/include/oakengine/videoparams.h` (sequence video params)
//!
-//! All of these families are **deferred** in the Rust facade crate
-//! (`oakengine`, `src/facade/rust/src/deferred.rs`), so none of the symbols
-//! below is referenced from this crate yet — the subcommands gate on
-//! [`crate::deferred`] and report "not yet available" instead of calling
-//! them. The declarations exist so that:
+//! 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.
//!
-//! 1. the exact contract the CLI expects is pinned in one place (types,
-//! signatures, string conventions, error codes), and
-//! 2. when a family is wrapped by oakengine, the call-through code in
-//! `src/cmd/` resolves against the already-linked `oakengine` rlib
-//! without any manifest or signature churn.
-//!
-//! Nothing here is ever called today, so no symbol needs to exist in the
-//! facade yet; that keeps `cargo build` green standalone.
-//!
-//! `dead_code` is expected for this whole surface until the ports land: the
-//! declarations, the POD structs and [`facade_string`] exist precisely to be
-//! consumed by `src/cmd/` once the deferred families are wrapped.
+//! The subcommands in `src/cmd/` call this surface directly — no deferral
+//! gate, no module-crate calls.
#![allow(dead_code)]
#![allow(non_camel_case_types)]
@@ -168,14 +163,12 @@ pub const OAKENGINE_EXPORT_VIDEO_H264: c_int = 0;
pub const OAKENGINE_EXPORT_AUDIO_AAC: c_int = 0;
// ---------------------------------------------------------------------------
-// The facade surface (declarations only — see the module docs).
+// 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" {
- // ---- init.h ----------------------------------------------------------
- pub fn oakengine_init(flags: c_int) -> c_int;
- pub fn oakengine_shutdown() -> c_int;
-
// ---- project.h -------------------------------------------------------
pub fn oakengine_project_create() -> *mut OakEngineProject;
pub fn oakengine_project_free(self_: *mut OakEngineProject);
@@ -276,6 +269,18 @@ extern "C" {
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,
@@ -300,8 +305,27 @@ extern "C" {
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,
@@ -343,22 +367,6 @@ extern "C" {
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);
-
- // ---- exporter.h ------------------------------------------------------
- pub fn oakengine_export_render(
- seq: *mut OakEngineSequence,
- path: *const c_char,
- in_ts: i64,
- out_ts: i64,
- width: c_int,
- height: c_int,
- opts: *const OakExportOptions,
- ) -> c_int;
- pub fn oakengine_export_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
- pub fn oakengine_export_set_progress_callback(
- f: OakEngineExportProgressFn,
- userdata: *mut c_void,
- );
}
/// Read a facade string (buf/size convention) into an owned `String`,
@@ -366,21 +374,16 @@ extern "C" {
/// error/empty string, otherwise the getter is called twice (size query,
/// then fill) and the trailing NUL is stripped.
///
-/// # Safety
-/// `getter` must be one of the `oakengine_*` string getters declared above
-/// and `handle` a live handle for it.
-pub unsafe fn facade_string(
- getter: unsafe extern "C" fn(*const c_void, *mut c_char, c_int) -> c_int,
- handle: *const c_void,
-) -> String {
- unsafe {
- let size = getter(handle, std::ptr::null_mut(), 0);
- if size < 0 {
- return String::new();
- }
- let mut s = vec![0u8; size as usize + 1];
- let n = getter(handle, s.as_mut_ptr() as *mut c_char, size + 1);
- s.truncate(n.max(0) as usize);
- String::from_utf8_lossy(&s).into_owned()
+/// `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
new file mode 100644
index 000000000..94f43ea11
--- /dev/null
+++ b/crates/oak-cli/src/host.rs
@@ -0,0 +1,155 @@
+// 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 7af048adc..0596b37f3 100644
--- a/crates/oak-cli/src/main.rs
+++ b/crates/oak-cli/src/main.rs
@@ -29,16 +29,25 @@
//! Exit codes: 0 success, 1 general error, 2 rendering unavailable,
//! 64 usage error.
//!
-//! The facade families every subcommand depends on (init/project/timeline/
-//! render/footage/exporter) are still **deferred** in the `oakengine` crate
-//! (see `src/facade/rust/src/deferred.rs`), so each subcommand validates its
-//! arguments faithfully, then reports the deferral with its reason and exits
-//! with the C++-compatible code — never crashing, never faking output.
+//! 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.
mod cmd;
-mod deferred;
mod ffi;
mod fmt;
+mod host;
+mod optional;
mod ppm;
mod wav;
@@ -76,7 +85,7 @@ Usage:\n\
Exit codes:\n\
0 success\n\
1 general error (bad project/media file, no sequence, I/O failure)\n\
- 2 rendering unavailable or failed (e.g. no GL render backend)\n\
+ 2 rendering unavailable or failed (e.g. no render backend)\n\
64 usage error\n";
/// CLI surface. `--help`/`-h` are handled before clap so the C++ usage text
@@ -132,6 +141,10 @@ 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
new file mode 100644
index 000000000..4d6409942
--- /dev/null
+++ b/crates/oak-cli/src/optional.rs
@@ -0,0 +1,196 @@
+// 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