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> = OnceLock::new(); -/// Cached `oakengine_shutdown` resolution. -static SHUTDOWN: OnceLock> = OnceLock::new(); -/// Cached `oakengine_export_render` resolution. -static EXPORT_RENDER: OnceLock> = OnceLock::new(); -/// Cached `oakengine_export_last_error` resolution. -static EXPORT_LAST_ERROR: OnceLock> = OnceLock::new(); -/// Cached `oakengine_export_set_progress_callback` resolution. -static EXPORT_SET_PROGRESS: OnceLock> = OnceLock::new(); - -/// `oakengine_init(flags)` — see the module docs for the absent-symbol -/// behavior. Returns `OAKENGINE_E_FAILED` when a present symbol reports -/// failure, `OAKENGINE_OK` otherwise. -/// -/// # Safety -/// The resolved function follows the engine init.h contract. -pub unsafe fn engine_init(flags: c_int) -> c_int { - let cell = INIT.get_or_init(|| lookup::("oakengine_init")); - match *cell { - Some(f) => unsafe { f(flags) }, - None => crate::ffi::OAKENGINE_OK, - } -} - -/// `oakengine_shutdown()` — no-op when the symbol is absent. -/// -/// # Safety -/// The resolved function follows the engine init.h contract. -pub unsafe fn engine_shutdown() -> c_int { - let cell = SHUTDOWN.get_or_init(|| lookup::("oakengine_shutdown")); - match *cell { - Some(f) => unsafe { f() }, - None => crate::ffi::OAKENGINE_OK, - } -} - -/// `oakengine_export_render(...)` through the resolved symbol. -/// -/// Returns `Some(rc)` when the exporter family is present (the engine -/// answer), `None` when the loaded dylib does not export it. -/// -/// # Safety -/// `seq`/`path`/`opts` must follow the engine exporter.h contract. -pub unsafe fn export_render( - seq: *mut OakEngineSequence, - path: *const c_char, - in_ts: i64, - out_ts: i64, - width: c_int, - height: c_int, - opts: *const OakExportOptions, -) -> Option { - let cell = EXPORT_RENDER.get_or_init(|| lookup::("oakengine_export_render")); - match *cell { - Some(f) => Some(unsafe { f(seq, path, in_ts, out_ts, width, height, opts) }), - None => None, - } -} - -/// `oakengine_export_last_error` through the resolved symbol; the fixed -/// explanation below when the family is absent. -/// -/// # Safety -/// The resolved function follows the engine exporter.h contract. -pub unsafe fn export_last_error() -> String { - let cell = - EXPORT_LAST_ERROR.get_or_init(|| lookup::("oakengine_export_last_error")); - match *cell { - Some(f) => crate::ffi::string_get(|buf, size| unsafe { f(buf, size) }), - None => "the exporter family (exporter.h) is not exported by the built liboakengine: \ - oakengine_export_render/oakengine_export_last_error are not wrapped (the facade's \ - oakengine_export_render_with_params is an unbacked stub)" - .to_string(), - } -} - -/// `oakengine_export_set_progress_callback` through the resolved symbol; -/// a no-op when absent (the CLI has no progress UI). -/// -/// # Safety -/// The resolved function follows the engine exporter.h contract. -pub unsafe fn export_set_progress_callback( - f: Option, - userdata: *mut c_void, -) { - let cell = EXPORT_SET_PROGRESS - .get_or_init(|| lookup::("oakengine_export_set_progress_callback")); - if let Some(fn_) = *cell { - unsafe { fn_(f, userdata) }; - } -} diff --git a/crates/oak-cli/tests/cli.rs b/crates/oak-cli/tests/cli.rs index bd57e4aef..19e02dead 100644 --- a/crates/oak-cli/tests/cli.rs +++ b/crates/oak-cli/tests/cli.rs @@ -18,10 +18,9 @@ //! `oak_cli_info`/`oak_cli_render`/`oak_cli_probe`/`oak_cli_transcode` //! equivalents). //! -//! The binary is a pure C-ABI consumer of `liboakengine` (see src/ffi.rs), -//! so these tests need the built dylib present: run `cargo build -p -//! oakengine` BEFORE `cargo test -p oak-cli` (the link happens at build -//! time, the load at runtime through the rpath build.rs installs). +//! The binary links the oak* module crates directly (M14 R2; see +//! src/engine.rs) — no `liboakengine` dylib is needed at build or run +//! time, so `cargo test -p oak-cli` stands alone. //! //! The data-producing paths run against the repo fixtures //! (`tests/project_with_footage.ove`, `tests/demo.mp4` — real H.264/AAC @@ -29,10 +28,10 @@ //! codes (0 success, 1 general error, 2 rendering unavailable, 64 usage //! error), and the argument-validation paths assert the C++ messages. //! -//! The engine's footage probe records the decoder id but drops the -//! codec's stream descriptions (oaknode module gap), so the probe output -//! carries real stream counts of 0 and a 0 duration until the module -//! fills them in — the assertions pin the real contract. +//! The module probe records the decoder id but drops the codec's stream +//! descriptions (oaknode module gap), so the probe output carries real +//! stream counts of 0 and a 0 duration until the module fills them in — +//! the assertions pin the real contract. use std::path::{Path, PathBuf}; use std::process::Command; @@ -78,9 +77,8 @@ fn run(args: &[&str]) -> (i32, String, String) { ) } -/// stderr without dyld's objc class-duplication notices (the engine -/// dylib embeds FFmpeg's libavdevice, which collides with the host's on -/// macOS). +/// stderr, kept for the historical dyld objc notices (the module-linked +/// binary no longer embeds FFmpeg's libavdevice, so it is a pass-through). fn real_errors(stderr: &str) -> String { stderr .lines() @@ -119,7 +117,7 @@ fn help_prints_the_cpp_usage_text_and_exits_zero() { !real_errors(&stderr).contains("error:"), "stderr: {stderr}" ); - assert!(stdout.starts_with("oak-cli - headless consumer of the liboakengine C ABI\n")); + assert!(stdout.starts_with("oak-cli - headless consumer of the oak editor modules (direct Rust ABI)\n")); assert!(stdout.contains("oak-cli transcode [width] [--format ppm|mp4]")); assert!(stdout.contains("Exit codes:")); assert!(stdout.contains("64 usage error")); @@ -312,9 +310,9 @@ fn transcode_the_image_fixture_to_ppm_frames() { } #[test] -fn transcode_mp4_reports_the_unwrapped_exporter_family() { - // The facade wraps the exporter family (oakengine_export_render), - // so the mp4 path renders and writes a real file. +fn transcode_mp4_writes_a_real_mp4() { + // The module export task (crate::engine::export_sequence) drives the + // mp4 path end to end and writes a real file. let dir = TempDir::new("transcode_mp4"); let image = fixture_image(); let out = dir.0.join("out.mp4"); diff --git a/crates/oak-worker/Cargo.toml b/crates/oak-worker/Cargo.toml index 3e7167eae..9c1ccdabe 100644 --- a/crates/oak-worker/Cargo.toml +++ b/crates/oak-worker/Cargo.toml @@ -29,8 +29,12 @@ path = "src/main.rs" serde = { version = "1", features = ["derive"] } serde_json = "1" -# No oakengine cargo dependency: the worker links the built liboakengine -# dylib through build.rs (link-search + rpath) and the -# `#[link(name = "oakengine", kind = "dylib")]` extern block in -# src/engine_ipc.rs. Every call into editor functionality goes through the -# oakengine_* C ABI declared there. +# POSIX shm_open/ftruncate/mmap/munmap/shm_unlink for the shared-memory +# frame-slot transport (src/ipc.rs). +libc = "0.2" + +# M14 R2: oak-worker is a PURE module-crate consumer — the worker runtime +# (src/worker.rs, src/ipc.rs) lives in this binary and calls the oak* +# rlibs directly (oakrender for the render backend + color config). No +# liboakengine dylib, no C ABI, no build.rs link step. +oakrender = { path = "../oakrender" } diff --git a/crates/oak-worker/README.md b/crates/oak-worker/README.md index 3b41132f3..782f665b5 100644 --- a/crates/oak-worker/README.md +++ b/crates/oak-worker/README.md @@ -11,20 +11,18 @@ cargo build --release # binary: target/release/oak-worker cargo test # unit + integration tests ``` -The worker is **self-contained** (single-lib unification): the engine's -frozen C++ ABI does not include the worker/IPC families, so the whole -runtime is compiled into this binary and links the module crates directly -— no `liboakengine` dylib is needed at build or run time. +The worker is **self-contained** (M14 R2): the whole runtime is compiled +into this binary and links the module crates directly — no `liboakengine` +dylib is needed at build or run time. - `src/worker.rs` is the port of `engine/src/capi/worker.cpp` `oakengine_worker_main()` and owns the whole runtime: render backend selection through the oakrender crate's direct Rust API (dynamic → OpenGL fallback), the startup handshake and the NDJSON control loop. - `src/main.rs` only parses `--backend` (clap) and forwards. + `src/main.rs` only scans argv for `--backend` and forwards. - `src/ipc.rs` owns the shared-memory frame-slot transport (the real `SpscRingBuffer` + `FrameSlotPool` over POSIX `shm_open`/`mmap`) and the - NDJSON control-plane message structs; `src/transport.rs` attaches - through it. + NDJSON control-plane message structs. The oakrender module crate (`../oakrender`) is a plain Rust dependency; it depends on `ocio-rs` with the `bundled` feature, whose first-time build @@ -40,7 +38,7 @@ CARGO_TARGET_DIR=/path/to/oak/crates/oakrender/target cargo build --release Same flow as the C++ main, in the same order: -1. **parse `--backend `** (clap; default `opengl`; `none` skips +1. **parse `--backend `** (default `opengl`; `none` skips renderer creation and the process exits 1, like the C++ main). 2. **initialize the render backend** (inside `src/worker.rs`): the oakrender `DisplayRenderer` direct Rust API, falling back to the direct @@ -67,7 +65,7 @@ frame-slot pool with the exact version-1 shared layout; a `handshake` genuinely attaches the output and input pools), unknown-type/ malformed-message errors, shutdown/EOF termination. -**Stubbed (documented in `src/transport.rs`):** +**Stubbed (documented in `src/worker.rs`):** | area | reason | |---|---| @@ -88,24 +86,23 @@ worker reads them off its `QOpenGLContext`). ``` src/ - main.rs clap entry; thin shell forwarding to worker::worker_main - (renderer init, handshake, NDJSON loop all live there) - worker.rs the real worker runtime: backend selection (oakrender - DisplayRenderer), WorkerSession, handshake + NDJSON loop - ipc.rs control-plane message structs + NDJSON framing (serde), - AND the real shared-memory frame-slot transport - (SpscRingBuffer + FrameSlotPool over POSIX shm) - session.rs in-process session mirror (message dispatch + real shm - handshake attach), exercised by the unit tests - transport.rs shared-memory frame-slot transport over crate::ipc -tests/worker.rs binary-level tests (help, clap errors, --backend none exit 1) + main.rs argv --backend scanning (default opengl; last flag wins); + forwards to worker::worker_main + worker.rs the real worker runtime: backend selection (oakrender + DisplayRenderer, dynamic -> OpenGL fallback), WorkerSession, + handshake + NDJSON loop (M14 R2: the facade's port, owned by + this binary since the facade C ABI was cut) + ipc.rs control-plane message structs + NDJSON framing (serde), + AND the real shared-memory frame-slot transport + (SpscRingBuffer + FrameSlotPool over POSIX shm) +tests/worker.rs binary-level tests (--backend none exit 1) ``` The NDJSON control-loop behavior is exercised in-process in `src/worker.rs` -and `src/session.rs` against the local real shared memory (no GPU needed -via `--backend none` sessions); a binary-level loop test would require a -working GPU backend and is deliberately not part of the unit suite. Run -the binary against a created segment to see the real attach path: +against the local real shared memory (no GPU needed via `--backend none` +sessions); a binary-level loop test would require a working GPU backend and +is deliberately not part of the unit suite. Run the binary against a +created segment to see the real attach path: ```sh target/release/oak-worker --backend opengl <<< '{"type":"shutdown"}' diff --git a/crates/oak-worker/build.rs b/crates/oak-worker/build.rs deleted file mode 100644 index 7fc7d3840..000000000 --- a/crates/oak-worker/build.rs +++ /dev/null @@ -1,40 +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-worker` binary. -//! -//! The worker is a pure C-ABI consumer of the built `liboakengine` dylib -//! (crates/oakengine, crate-type cdylib): src/engine_ipc.rs declares the -//! `oakengine_*` symbols with `#[link(name = "oakengine", kind = "dylib")]`. -//! This build script points the linker at the target profile directory -//! that holds the dylib (OUT_DIR is -//! `//build/oak-worker-/out`, so the profile dir is -//! the third ancestor — where cargo places `liboakengine.dylib` / -//! `liboakengine.so`) and embeds an rpath so the binary finds the dylib at -//! runtime without environment variables. - -use std::path::Path; - -fn main() { - let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set by cargo"); - let profile_dir = Path::new(&out_dir) - .ancestors() - .nth(3) - .expect("OUT_DIR is nested at least 3 levels under the profile dir"); - - println!("cargo:rustc-link-search=native={}", profile_dir.display()); - println!("cargo:rustc-link-arg=-Wl,-rpath,{}", profile_dir.display()); -} diff --git a/crates/oak-worker/src/engine_ipc.rs b/crates/oak-worker/src/engine_ipc.rs deleted file mode 100644 index 1f1fb6174..000000000 --- a/crates/oak-worker/src/engine_ipc.rs +++ /dev/null @@ -1,652 +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 `liboakengine` C-ABI surface, as consumed by the worker binary. -//! -//! The engine facade (crates/oakengine) is the cdylib and the ONLY -//! implementation owner of the worker/IPC runtime; this crate is a pure -//! C-ABI consumer. Everything here goes through the `extern "C"` -//! declarations of the `oakengine_*` symbols below — never a direct Rust -//! call into an oak* module crate. -//! -//! The module provides three layers: -//! -//! - **The externs.** `#[link(name = "oakengine", kind = "dylib")]` -//! declarations of the frozen `oakengine_worker_*` and -//! `oakengine_ipc_*` exports (`engine/include/oakengine/worker.h` and -//! `ipc.h`), plus the opaque `Oak*` handle mirrors and the POD -//! [`FrameSlotMeta`] / [`ShmMode`] mirrors the signatures reference. -//! build.rs points the linker at the built dylib and embeds its rpath. -//! - **In-process wrapper types.** [`SharedMemoryRegion`] and -//! [`FrameSlotPool`] wrap the opaque handles with `Drop` and a safe -//! surface, so [`crate::transport`]/[`crate::session`] (and their -//! tests) can attach real shared-memory pools through the engine. -//! - **The wire protocol.** [`HandshakeMsg`]/[`RenderFrameMsg`]/ -//! [`LoadGraphMsg`] + [`error_message`] + the `TYPE_*` constants — -//! serde-only structs matching the engine's NDJSON control plane; the -//! worker-side session mirror validates and builds these lines -//! locally. - -#![allow(dead_code)] - -use std::ffi::{c_char, c_int, c_void}; - -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; - -// --------------------------------------------------------------------------- -// Opaque handle mirrors (engine/include/oakengine/{worker,ipc}.h) -// --------------------------------------------------------------------------- - -/// Opaque `OakWorkerSession` handle (worker.h). The engine owns the box; -/// this crate only ever sees the pointer. -#[repr(C)] -pub struct OakWorkerSession { - _opaque: [u8; 0], -} - -/// Opaque `OakSharedMemoryRegion` handle (ipc.h). -#[repr(C)] -pub struct OakSharedMemoryRegion { - _opaque: [u8; 0], -} - -/// Opaque `OakFrameSlotPool` handle (ipc.h). -#[repr(C)] -pub struct OakFrameSlotPool { - _opaque: [u8; 0], -} - -/// `OAK_IPC_SHM_KEY_CAP` — capacity of shm key strings (ipc.h), incl. NUL. -pub const OAK_IPC_SHM_KEY_CAP: usize = 128; -/// `OAK_IPC_COLORSPACE_CAP` — capacity of `oak_frame_slot_meta::colorspace`. -pub const OAK_IPC_COLORSPACE_CAP: usize = 128; - -/// Per-slot metadata describing the frame currently occupying a slot — -/// field-for-field `oak_frame_slot_meta` from `engine/include/oakengine/ipc.h` -/// (the POD lives in shared memory; the layout is the version-1 wire -/// protocol). -#[repr(C)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FrameSlotMeta { - /// Caller-defined tag (ticket id, or footage stream hash). - pub id: i64, - /// Frame timestamp numerator. - pub time_num: i64, - /// Frame timestamp denominator. - pub time_den: i64, - /// Frame width. - pub width: i32, - /// Frame height. - pub height: i32, - /// `PixelFormat::Format` value. - pub format: i32, - /// Channel count. - pub channel_count: i32, - /// Bytes per scanline (stride). - pub linesize: i32, - /// Valid bytes written into the slot's data block. - pub data_size: i32, - /// Input colorspace name. - pub colorspace: [c_char; OAK_IPC_COLORSPACE_CAP], -} - -/// `OAK_IPC_SHM_MODE_CREATE` / `OAK_IPC_SHM_MODE_ATTACH` (ipc.h). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ShmMode { - /// Create (and own) the segment. Fails if it already exists; the owner - /// unlinks it on close. - Create, - /// Attach to a segment created by the peer. Does not unlink on close. - Attach, -} - -// --------------------------------------------------------------------------- -// C ABI exports (engine/include/oakengine/{worker,ipc}.h) -// --------------------------------------------------------------------------- - -// The built `liboakengine` dylib (crates/oakengine, cdylib). build.rs -// emits the link-search path and rpath for the target profile dir. -#[link(name = "oakengine", kind = "dylib")] -extern "C" { - // ---- engine/include/oakengine/worker.h ---- - /// Full render-worker main (argv-based `--backend` scanning, startup - /// handshake, NDJSON control loop). Returns the process exit code. - pub fn oakengine_worker_main(argc: c_int, argv: *mut *mut c_char) -> c_int; - /// Create a session for the given render backend (NULL = none). - pub fn oakengine_worker_session_create(backend: *const c_char) -> *mut OakWorkerSession; - /// Free a session. NULL no-op. - pub fn oakengine_worker_session_free(self_: *mut OakWorkerSession); - /// 1 when the session holds an initialized render backend. - pub fn oakengine_worker_session_has_renderer(self_: *const OakWorkerSession) -> c_int; - /// Load the runtime services; 1 on success, 0 for a NULL session. - pub fn oakengine_worker_session_initialize_runtime(self_: *mut OakWorkerSession) -> c_int; - /// Build the startup handshake (buf/size convention; returns the - /// required size, -1 on failure). - pub fn oakengine_worker_session_startup_handshake( - self_: *const OakWorkerSession, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// Handle one NDJSON control line; 0 for "no response", the response - /// length for a response, -1 on a fatal handler failure. - pub fn oakengine_worker_session_handle_json( - self_: *mut OakWorkerSession, - line: *const c_char, - response_buf: *mut c_char, - response_buf_size: c_int, - ) -> c_int; - /// 1 once a shutdown control message has been received. - pub fn oakengine_worker_session_shutdown_requested(self_: *const OakWorkerSession) -> c_int; - - // ---- engine/include/oakengine/ipc.h ---- - /// Allocate an empty (invalid) region object. - pub fn oakengine_ipc_shm_create() -> *mut OakSharedMemoryRegion; - /// Free a region object. NULL no-op. - pub fn oakengine_ipc_shm_free(self_: *mut OakSharedMemoryRegion); - /// Open the segment; 1 on success, 0 on failure (shm_error carries the - /// reason). Mode: 0 = create, 1 = attach. - pub fn oakengine_ipc_shm_open( - self_: *mut OakSharedMemoryRegion, - key: *const c_char, - size: usize, - mode: c_int, - ) -> c_int; - /// Unmap and (if owner) unlink. - pub fn oakengine_ipc_shm_close(self_: *mut OakSharedMemoryRegion); - /// 1 when the region holds a live mapping. - pub fn oakengine_ipc_shm_is_valid(self_: *const OakSharedMemoryRegion) -> c_int; - /// The mapped data pointer (NULL when invalid). - pub fn oakengine_ipc_shm_data(self_: *mut OakSharedMemoryRegion) -> *mut c_void; - /// Mapping size in bytes. - pub fn oakengine_ipc_shm_size(self_: *const OakSharedMemoryRegion) -> usize; - /// The key the region was opened with (buf/size convention). - pub fn oakengine_ipc_shm_key( - self_: *const OakSharedMemoryRegion, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// Reason of the last failed open (buf/size convention). - pub fn oakengine_ipc_shm_error( - self_: *const OakSharedMemoryRegion, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// Build a unique segment key ("olive-rw--", buf/size - /// convention). - pub fn oakengine_ipc_shm_make_key( - owner_pid: i64, - worker_index: c_int, - buf: *mut c_char, - buf_size: c_int, - ) -> c_int; - /// Total bytes a region must provide to back a pool of - /// `slot_count` x `slot_data_bytes`. - pub fn oakengine_ipc_framepool_bytes_needed(slot_count: u32, slot_data_bytes: usize) -> usize; - /// Lay out and initialize a brand-new pool over `mem` (owner side, - /// once). The handle does not own `mem`. - pub fn oakengine_ipc_framepool_create( - mem: *mut c_void, - slot_count: u32, - slot_data_bytes: usize, - ) -> *mut OakFrameSlotPool; - /// Map an existing pool (peer side). NULL when the segment holds no - /// pool. - pub fn oakengine_ipc_framepool_attach(mem: *mut c_void) -> *mut OakFrameSlotPool; - /// Copy the view (same shared memory, independent handle). - pub fn oakengine_ipc_framepool_copy(self_: *const OakFrameSlotPool) -> *mut OakFrameSlotPool; - /// Free a pool view. NULL no-op. - pub fn oakengine_ipc_framepool_free(self_: *mut OakFrameSlotPool); - /// 1 when the pool was attached to a valid pool header. - pub fn oakengine_ipc_framepool_is_valid(self_: *const OakFrameSlotPool) -> c_int; - /// Number of slots in the pool. - pub fn oakengine_ipc_framepool_slot_count(self_: *const OakFrameSlotPool) -> u32; - /// Bytes available in every slot's pixel-data block. - pub fn oakengine_ipc_framepool_slot_data_bytes(self_: *const OakFrameSlotPool) -> usize; - /// Take a free slot; 1 on success (`*index` set). - pub fn oakengine_ipc_framepool_acquire(self_: *mut OakFrameSlotPool, index: *mut u32) -> c_int; - /// Pointer to a slot's pixel data block. - pub fn oakengine_ipc_framepool_slot_data( - self_: *mut OakFrameSlotPool, - index: u32, - ) -> *mut c_void; - /// Immutable pixel data for a slot. - pub fn oakengine_ipc_framepool_slot_data_const( - self_: *const OakFrameSlotPool, - index: u32, - ) -> *const c_void; - /// Mutable per-slot metadata (borrowed). - pub fn oakengine_ipc_framepool_meta( - self_: *mut OakFrameSlotPool, - index: u32, - ) -> *mut FrameSlotMeta; - /// Immutable per-slot metadata. - pub fn oakengine_ipc_framepool_meta_const( - self_: *const OakFrameSlotPool, - index: u32, - ) -> *const FrameSlotMeta; - /// Publish a filled slot; 1 on success. - pub fn oakengine_ipc_framepool_publish(self_: *mut OakFrameSlotPool, index: u32) -> c_int; - /// Take the next published slot; 1 on success (`*index` set). - pub fn oakengine_ipc_framepool_consume(self_: *mut OakFrameSlotPool, index: *mut u32) -> c_int; - /// Return a consumed slot to the free pool; 1 on success. - pub fn oakengine_ipc_framepool_release(self_: *mut OakFrameSlotPool, index: u32) -> c_int; -} - -// --------------------------------------------------------------------------- -// Wire protocol (the engine's NDJSON control plane, mirrored locally) -// --------------------------------------------------------------------------- - -/// `"handshake"`. -pub const TYPE_HANDSHAKE: &str = "handshake"; -/// `"load_graph"`. -pub const TYPE_LOAD_GRAPH: &str = "load_graph"; -/// `"render_frame"`. -pub const TYPE_RENDER_FRAME: &str = "render_frame"; -/// `"frame_ready"`. -pub const TYPE_FRAME_READY: &str = "frame_ready"; -/// `"cancel"`. -pub const TYPE_CANCEL: &str = "cancel"; -/// `"graph_update"`. -pub const TYPE_GRAPH_UPDATE: &str = "graph_update"; -/// `"shutdown"`. -pub const TYPE_SHUTDOWN: &str = "shutdown"; -/// `"error"`. -pub const TYPE_ERROR: &str = "error"; - -/// `handshake` — field-for-field equivalent of `oak_ipc_handshake` -/// (ipc.h). Wire field names match the C++ serializer. -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct HandshakeMsg { - /// Protocol version. - pub protocol_version: i32, - /// Worker->main output shared-memory segment key. - pub shm_key: String, - /// Main->worker input shared-memory segment key (optional). - pub input_shm_key: String, - /// Number of main->worker input frame slots. - pub input_slots: i32, - /// Number of worker->main output frame slots. - pub output_slots: i32, - /// Per-output-slot pixel block size. - pub slot_data_bytes: i64, - /// Per-input-slot pixel block size. - pub input_slot_data_bytes: i64, -} - -impl HandshakeMsg { - /// The worker's startup handshake (`worker.cpp startup_handshake()`). - pub fn to_json(&self) -> Value { - json!({ - "type": TYPE_HANDSHAKE, - "protocol_version": self.protocol_version, - "shm_key": self.shm_key, - "input_shm_key": self.input_shm_key, - "input_slots": self.input_slots, - "output_slots": self.output_slots, - "slot_data_bytes": self.slot_data_bytes, - "input_slot_data_bytes": self.input_slot_data_bytes, - }) - } -} - -/// `render_frame` — request a frame render. Wire names per ipcmessage.cpp: -/// `ticket`, `node`, `channels` (not the ipc.h POD names). -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct RenderFrameMsg { - /// Correlates with the eventual frame_ready. - pub ticket: i64, - /// Viewer node stable uuid in the loaded graph. - pub node: String, - pub time_num: i64, - pub time_den: i64, - /// Forced output size (0 = graph default). - pub width: i32, - pub height: i32, - /// Forced PixelFormat (-1 = default). - pub format: i32, - /// Channel count (0 = default). - pub channels: i32, - /// RenderMode. - pub mode: i32, - /// Optional decoded input slot (-1 = none). - pub input_slot: i32, - /// Ordered decoded input slots. - pub input_slots: Vec, - /// Output color transform present? - pub has_color_transform: bool, - pub color_is_display: bool, - pub color_output: String, - pub color_view: String, - pub color_look: String, -} - -/// `load_graph` — path to a temporary file holding the serialized graph. -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct LoadGraphMsg { - pub path: String, -} - -/// Build a worker-side error report, mirroring `error_message()` in -/// worker.cpp: `{"type":"error","message":...}` plus `"ticket"` when -/// non-zero. -pub fn error_message(message: &str, ticket: Option) -> Value { - match ticket.filter(|t| *t != 0) { - Some(t) => json!({ "type": TYPE_ERROR, "message": message, "ticket": t }), - None => json!({ "type": TYPE_ERROR, "message": message }), - } -} - -// --------------------------------------------------------------------------- -// In-process wrapper types over the opaque C-ABI handles -// --------------------------------------------------------------------------- - -/// Two-stage read of a buf/size-convention string getter (the engine's -/// `write_string` reports the length excluding the NUL for a NULL buffer). -unsafe fn region_string( - region: *const OakSharedMemoryRegion, - getter: unsafe extern "C" fn(*const OakSharedMemoryRegion, *mut c_char, c_int) -> c_int, -) -> String { - // SAFETY: the engine's buf/size convention: a NULL/0 buffer only - // queries the required length. - let len = unsafe { getter(region, std::ptr::null_mut(), 0) }; - if len <= 0 { - return String::new(); - } - let mut buf = vec![0 as c_char; len as usize + 1]; - // SAFETY: `buf` provides len + 1 writable bytes (length + NUL). - unsafe { getter(region, buf.as_mut_ptr(), buf.len() as c_int) }; - // SAFETY: the engine NUL-terminates what it writes into `buf`. - unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } - .to_string_lossy() - .into_owned() -} - -/// A named, fixed-size POSIX shared-memory segment mapped inside the -/// engine process (C-ABI wrapper around `OakSharedMemoryRegion`). One side -/// opens the segment in [`ShmMode::Create`] (owner, unlinks on close); the -/// peer attaches by key. The wrapper owns the engine-side handle and -/// closes/frees it on drop. -pub struct SharedMemoryRegion { - handle: *mut OakSharedMemoryRegion, -} - -impl SharedMemoryRegion { - /// An empty (invalid) region. - pub fn new() -> SharedMemoryRegion { - // SAFETY: shm_create is the factory for owned handles (never - // observes caller memory). - SharedMemoryRegion { - handle: unsafe { oakengine_ipc_shm_create() }, - } - } - - /// Build a unique segment key for a worker, e.g. - /// "olive-rw--" (the engine's `SharedMemoryRegion::make_key` - /// behind the C ABI). Centralized so the owner and the spawned worker - /// agree on the same name. - pub fn make_key(owner_pid: i64, worker_index: i32) -> String { - let mut buf = [0 as c_char; OAK_IPC_SHM_KEY_CAP]; - // SAFETY: `buf` is writable and sized by the engine's key capacity. - let n = unsafe { - oakengine_ipc_shm_make_key(owner_pid, worker_index, buf.as_mut_ptr(), buf.len() as c_int) - }; - if n <= 0 { - return String::new(); - } - // SAFETY: the engine NUL-terminates the key within `buf`. - unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } - .to_string_lossy() - .into_owned() - } - - /// Open the segment identified by `key` with the given `size` in bytes - /// (through the engine). Returns true on success; on failure - /// [`Self::error`] carries a human-readable reason. - pub fn open(&mut self, key: &str, size: usize, mode: ShmMode) -> bool { - let Ok(key_c) = std::ffi::CString::new(key) else { - return false; - }; - // SAFETY: `key_c` is a valid C string; `self.handle` is the live - // handle this wrapper owns. - unsafe { oakengine_ipc_shm_open(self.handle, key_c.as_ptr(), size, mode as c_int) == 1 } - } - - /// Unmap and (if owner) unlink the segment. - pub fn close(&mut self) { - // SAFETY: the engine's shm_close is a NULL no-op. - unsafe { oakengine_ipc_shm_close(self.handle) }; - } - - /// True when the region holds a live mapping. - pub fn is_valid(&self) -> bool { - // SAFETY: the engine's shm_is_valid returns 0 for NULL handles. - unsafe { oakengine_ipc_shm_is_valid(self.handle) == 1 } - } - - /// The mapped data pointer (null when invalid). - pub fn data(&self) -> *mut u8 { - // SAFETY: the engine returns NULL for invalid regions. - unsafe { oakengine_ipc_shm_data(self.handle) as *mut u8 } - } - - /// The mapping size in bytes. - pub fn size(&self) -> usize { - // SAFETY: the engine returns 0 for NULL handles. - unsafe { oakengine_ipc_shm_size(self.handle) } - } - - /// The key the region was opened with. - pub fn key(&self) -> String { - // SAFETY: region_string follows the engine's buf/size convention. - unsafe { region_string(self.handle, oakengine_ipc_shm_key) } - } - - /// Human-readable reason of the last failed open. - pub fn error(&self) -> String { - // SAFETY: region_string follows the engine's buf/size convention. - unsafe { region_string(self.handle, oakengine_ipc_shm_error) } - } -} - -impl Drop for SharedMemoryRegion { - fn drop(&mut self) { - // Close first so the owner unlinks the segment, then free the - // engine-side box. Both are NULL no-ops in the engine. - // SAFETY: `self.handle` is the handle this wrapper owns and is not - // used after this. - unsafe { - oakengine_ipc_shm_close(self.handle); - oakengine_ipc_shm_free(self.handle); - } - } -} - -/// A fixed-size pool of equal-sized frame slots in shared memory with -/// lock-free hand-off (C-ABI wrapper around `OakFrameSlotPool`). The pool -/// does NOT own the memory — it is a view the engine attaches over a -/// mapped [`SharedMemoryRegion`]. Lifecycle: the filler `acquire`s a free -/// slot, writes meta + pixels, then `publish`es it; the drainer `consume`s -/// the next ready slot, reads it, and `release`s it back to the free ring. -/// -/// `Clone` mirrors the engine's pool-view copy (same shared memory, -/// independent handle). -pub struct FrameSlotPool { - handle: *mut OakFrameSlotPool, -} - -impl FrameSlotPool { - /// Total bytes a region must provide to back a pool of - /// `slot_count` x `slot_data_bytes` (the engine's - /// `FrameSlotPool::bytes_needed` behind the C ABI). - pub fn bytes_needed(slot_count: u32, slot_data_bytes: usize) -> usize { - // SAFETY: pure computation, no handles involved. - unsafe { oakengine_ipc_framepool_bytes_needed(slot_count, slot_data_bytes) } - } - - /// Lay out and initialize a brand-new pool over `mem` (owner side, - /// once) through the engine. - /// - /// # Safety - /// `mem` must be a valid, writable, aligned buffer of at least - /// [`Self::bytes_needed`] bytes, not concurrently written during this - /// call. - pub unsafe fn create(mem: *mut u8, slot_count: u32, slot_data_bytes: usize) -> FrameSlotPool { - // SAFETY: forwarded to the engine's create contract. - FrameSlotPool { - handle: unsafe { - oakengine_ipc_framepool_create(mem as *mut c_void, slot_count, slot_data_bytes) - }, - } - } - - /// Map an existing, already-initialized pool (peer side) through the - /// engine. The returned pool reports `is_valid() == false` when the - /// segment does not contain a pool. - /// - /// # Safety - /// `mem` must point to a mapped segment that either contains a pool or - /// is an arbitrary buffer whose first bytes are readable. - pub unsafe fn attach(mem: *mut u8) -> FrameSlotPool { - // SAFETY: forwarded to the engine's attach contract. - FrameSlotPool { - handle: unsafe { oakengine_ipc_framepool_attach(mem as *mut c_void) }, - } - } - - /// True when the pool was attached to a segment containing a valid pool - /// header. - pub fn is_valid(&self) -> bool { - // SAFETY: the engine returns 0 for NULL handles. - unsafe { oakengine_ipc_framepool_is_valid(self.handle) == 1 } - } - - /// Number of slots in the pool (0 for an invalid pool). - pub fn slot_count(&self) -> u32 { - // SAFETY: the engine returns 0 for NULL handles. - unsafe { oakengine_ipc_framepool_slot_count(self.handle) } - } - - /// Bytes available in every slot's pixel-data block (0 for invalid). - pub fn slot_data_bytes(&self) -> usize { - // SAFETY: the engine returns 0 for NULL handles. - unsafe { oakengine_ipc_framepool_slot_data_bytes(self.handle) } - } - - /// Take ownership of a free slot. Returns false (leaving `index` - /// untouched) if none is free. - /// - /// # Safety - /// The pool must be a valid view of a live segment. - pub unsafe fn acquire(&self, index: &mut u32) -> bool { - // SAFETY: forwarded to the engine's acquire contract. - unsafe { oakengine_ipc_framepool_acquire(self.handle, index) == 1 } - } - - /// Pointer to a slot's pixel data block (`slot_data_bytes` available). - /// - /// # Safety - /// `index` must be in `0..slot_count`; the pool must be a valid view of - /// a live segment. - pub unsafe fn slot_data(&self, index: u32) -> *mut u8 { - // SAFETY: forwarded to the engine's slot_data contract. - unsafe { oakengine_ipc_framepool_slot_data(self.handle, index) as *mut u8 } - } - - /// Immutable pixel data for a slot. - /// - /// # Safety - /// `index` must be in `0..slot_count`; the pool must be a valid view of - /// a live segment. - pub unsafe fn slot_data_const(&self, index: u32) -> *const u8 { - // SAFETY: forwarded to the engine's slot_data_const contract. - unsafe { oakengine_ipc_framepool_slot_data_const(self.handle, index) as *const u8 } - } - - /// Mutable metadata for a slot. The filler writes this before - /// [`Self::publish`]. The returned pointer addresses shared memory; it - /// is borrowed, not owned. - /// - /// # Safety - /// `index` must be in `0..slot_count`; the pool must be a valid view of - /// a live segment. - pub unsafe fn meta(&self, index: u32) -> *mut FrameSlotMeta { - // SAFETY: forwarded to the engine's meta contract. - unsafe { oakengine_ipc_framepool_meta(self.handle, index) } - } - - /// Immutable metadata for a slot (drainer side). - /// - /// # Safety - /// `index` must be in `0..slot_count`; the pool must be a valid view of - /// a live segment. - pub unsafe fn meta_const(&self, index: u32) -> *const FrameSlotMeta { - // SAFETY: forwarded to the engine's meta_const contract. - unsafe { oakengine_ipc_framepool_meta_const(self.handle, index) } - } - - /// Publish a filled slot to the drainer. Must follow a successful - /// [`Self::acquire`] of `index`. Returns false if the ready ring is - /// full. - /// - /// # Safety - /// `index` must be a slot previously acquired and not yet released. - pub unsafe fn publish(&self, index: u32) -> bool { - // SAFETY: forwarded to the engine's publish contract. - unsafe { oakengine_ipc_framepool_publish(self.handle, index) == 1 } - } - - /// Take the next published slot. Returns false if nothing is ready. - /// - /// # Safety - /// The pool must be a valid view of a live segment. - pub unsafe fn consume(&self, index: &mut u32) -> bool { - // SAFETY: forwarded to the engine's consume contract. - unsafe { oakengine_ipc_framepool_consume(self.handle, index) == 1 } - } - - /// Return a consumed slot to the free pool for reuse. Must follow a - /// successful [`Self::consume`] of `index`. Returns false if the free - /// ring is full. - /// - /// # Safety - /// `index` must be a slot previously consumed and not yet re-acquired. - pub unsafe fn release(&self, index: u32) -> bool { - // SAFETY: forwarded to the engine's release contract. - unsafe { oakengine_ipc_framepool_release(self.handle, index) == 1 } - } -} - -impl Clone for FrameSlotPool { - fn clone(&self) -> FrameSlotPool { - // SAFETY: the engine's framepool_copy yields NULL for a NULL - // handle. - FrameSlotPool { - handle: unsafe { oakengine_ipc_framepool_copy(self.handle) }, - } - } -} - -impl Drop for FrameSlotPool { - fn drop(&mut self) { - // SAFETY: the engine's framepool_free is a NULL no-op; the handle - // is not used after this. - unsafe { oakengine_ipc_framepool_free(self.handle) }; - } -} diff --git a/crates/oak-worker/src/ipc.rs b/crates/oak-worker/src/ipc.rs new file mode 100644 index 000000000..921cddf85 --- /dev/null +++ b/crates/oak-worker/src/ipc.rs @@ -0,0 +1,1477 @@ +// 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 . + +//! Render-worker IPC: the control-plane NDJSON protocol and the +//! shared-memory frame-slot transport, owned by the oak-worker binary +//! since M14 R2 (the facade keeps its own copy for the frozen +//! `oakengine_ipc_*` C ABI). The transport is the Rust port of +//! `engine/render/ipc/` + `ipcmessage.cpp`. +//! +//! Two halves: +//! +//! - **Control plane.** One compact JSON object per line on the stdio +//! pipes (worker.cpp / ipcmessage.cpp `write_message`/`read_message`). +//! Every message carries a `"type"` string; the field names below are +//! the ones the C++ serializers actually emit +//! (`engine/render/ipc/ipcmessage.cpp`): note `ticket` / `node` / +//! `channels` / `slot` — the longer names (`ticket_id`, `node_uuid`, +//! `channel_count`, `output_slot`) exist only on the C POD structs in +//! `ipc.h`. [`write_message`]/[`error_message`] build the wire lines. +//! - **Data plane.** Named shared memory holding the frame-slot pools — +//! the port of `engine/render/ipc/` (`sharedmemoryregion.cpp`, +//! `frameslotpool.cpp`): [`SharedMemoryRegion`] maps a named POSIX +//! segment (`shm_open` + `mmap`, `munmap` + `shm_unlink` on close), +//! and [`FrameSlotPool`] lays out a fixed pool of equal-sized frame +//! slots inside it with lock-free hand-off through two +//! [`SpscRingBuffer`]s of slot indices (free + ready). Each ring is a +//! single-producer/single-consumer structure; the filler owns +//! `free.pop` + `ready.push`, the drainer owns `ready.pop` + +//! `free.push`, so no mutex is ever taken. +//! +//! **The in-memory layout is the version-1 wire protocol** the app and the +//! render worker share, and it never changes: the byte offsets below are +//! copied field-for-field from the C++ implementation (64-byte cache-line +//! alignment, the `Header`/`SpscRingBuffer`/`oak_frame_slot_meta` POD +//! structs). A segment written by the C++ side attaches here and vice +//! versa. +//! +//! This module is deliberately unsafe-heavy and self-contained: it touches +//! raw shared memory and raw POSIX syscalls, and everything else in the +//! crate reaches it through the safe wrapper methods. +//! +//! Message types (M = main/editor, W = worker): +//! handshake M<->W negotiate protocol version + announce shm geometry +//! load_graph M ->W path to a temp file holding the serialized graph +//! render_frame M ->W request a frame render (ticket, node, time, params) +//! frame_ready W ->M a rendered frame is published (slot + ticket) +//! cancel M ->W abandon an in-flight ticket +//! graph_update M ->W reserved (no payload struct yet) +//! shutdown M ->W finish current work and exit cleanly +//! error W ->M worker-side failure report ("message" field) +//! +//! Items the worker does not emit yet (frame_ready, graph_update, +//! `FrameReadyMsg`) and message ids it ignores (`cancel`) are kept as the +//! documented protocol surface; `dead_code` until the frame-slot transport +//! is driven by a real graph (see [`crate::worker`]). + +#![allow(dead_code)] + +use std::ffi::{c_char, c_int}; +use std::io::{self, Write}; +use std::ptr; +use std::sync::atomic::{AtomicU32, Ordering}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + + +/// `"handshake"`. +pub const TYPE_HANDSHAKE: &str = "handshake"; +/// `"load_graph"`. +pub const TYPE_LOAD_GRAPH: &str = "load_graph"; +/// `"render_frame"`. +pub const TYPE_RENDER_FRAME: &str = "render_frame"; +/// `"frame_ready"`. +pub const TYPE_FRAME_READY: &str = "frame_ready"; +/// `"cancel"`. +pub const TYPE_CANCEL: &str = "cancel"; +/// `"graph_update"`. +pub const TYPE_GRAPH_UPDATE: &str = "graph_update"; +/// `"shutdown"`. +pub const TYPE_SHUTDOWN: &str = "shutdown"; +/// `"error"`. +pub const TYPE_ERROR: &str = "error"; + +/// `handshake` — field-for-field equivalent of `oak_ipc_handshake` +/// (ipc.h). Wire field names match the C++ serializer. +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct HandshakeMsg { + /// Protocol version. + pub protocol_version: i32, + /// Worker->main output shared-memory segment key. + pub shm_key: String, + /// Main->worker input shared-memory segment key (optional). + pub input_shm_key: String, + /// Number of main->worker input frame slots. + pub input_slots: i32, + /// Number of worker->main output frame slots. + pub output_slots: i32, + /// Per-output-slot pixel block size. + pub slot_data_bytes: i64, + /// Per-input-slot pixel block size. + pub input_slot_data_bytes: i64, +} + +impl HandshakeMsg { + /// The worker's startup handshake (`worker.cpp startup_handshake()`). + pub fn to_json(&self) -> Value { + json!({ + "type": TYPE_HANDSHAKE, + "protocol_version": self.protocol_version, + "shm_key": self.shm_key, + "input_shm_key": self.input_shm_key, + "input_slots": self.input_slots, + "output_slots": self.output_slots, + "slot_data_bytes": self.slot_data_bytes, + "input_slot_data_bytes": self.input_slot_data_bytes, + }) + } +} + +/// `render_frame` — request a frame render. Wire names per ipcmessage.cpp: +/// `ticket`, `node`, `channels` (not the ipc.h POD names). +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct RenderFrameMsg { + /// Correlates with the eventual frame_ready. + pub ticket: i64, + /// Viewer node stable uuid in the loaded graph. + pub node: String, + /// Frame timestamp numerator. + pub time_num: i64, + /// Frame timestamp denominator. + pub time_den: i64, + /// Forced output size (0 = graph default). + pub width: i32, + /// Forced output height (0 = graph default). + pub height: i32, + /// Forced PixelFormat (-1 = default). + pub format: i32, + /// Channel count (0 = default). + pub channels: i32, + /// RenderMode. + pub mode: i32, + /// Optional decoded input slot (-1 = none). + pub input_slot: i32, + /// Ordered decoded input slots. + pub input_slots: Vec, + /// Output color transform present? + pub has_color_transform: bool, + /// Color transform targets the display space. + pub color_is_display: bool, + /// Output color space name. + pub color_output: String, + /// Output color view name. + pub color_view: String, + /// Output color look name. + pub color_look: String, +} + +/// `frame_ready` — a rendered frame is published (wire names `ticket`/ +/// `slot`). +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct FrameReadyMsg { + /// Correlates with the render_frame request. + pub ticket: i64, + /// Index into the worker->main output FrameSlotPool. + pub slot: i32, +} + +/// `cancel` — abandon an in-flight ticket by id. +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct CancelMsg { + /// The in-flight ticket id to abandon. + pub ticket: i64, +} + +/// `load_graph` — path to a temporary file holding the serialized graph. +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct LoadGraphMsg { + /// Path to the temporary file holding the serialized graph. + pub path: String, +} + +/// Build a worker-side error report, mirroring `error_message()` in +/// worker.cpp: `{"type":"error","message":...}` plus `"ticket"` when +/// non-zero. +pub fn error_message(message: &str, ticket: Option) -> Value { + match ticket.filter(|t| *t != 0) { + Some(t) => json!({ "type": TYPE_ERROR, "message": message, "ticket": t }), + None => json!({ "type": TYPE_ERROR, "message": message }), + } +} + +/// Write one NDJSON message line (compact JSON + `\n`), the Rust port of +/// `ipcmessage.cpp write_message()`. +pub fn write_message(w: &mut impl Write, msg: &Value) -> io::Result<()> { + let line = + serde_json::to_string(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + w.write_all(line.as_bytes())?; + w.write_all(b"\n") +} + +// --------------------------------------------------------------------------- +// Shared-memory frame-slot transport +// --------------------------------------------------------------------------- + +/// `OAK_IPC_SHM_KEY_CAP` — capacity of shm key strings (ipc.h), incl. NUL. +pub const OAK_IPC_SHM_KEY_CAP: usize = 128; +/// `OAK_IPC_COLORSPACE_CAP` — capacity of `oak_frame_slot_meta::colorspace`. +pub const OAK_IPC_COLORSPACE_CAP: usize = 128; + +/// Byte alignment of every sub-region of a frame slot pool (the C++ +/// `k_align = 64`; cache-line alignment). +const K_ALIGN: usize = 64; + +/// `k_magic = 0x4F4B5350` ("OKSP") — the frame slot pool header magic. +pub const FRAMEPOOL_MAGIC: u32 = 0x4F4B5350; + +/// Round `value` up to the next multiple of `align` (power of two). +const fn align_up(value: usize, align: usize) -> usize { + (value + (align - 1)) & !(align - 1) +} + +/// `OAK_IPC_SHM_MODE_CREATE` / `OAK_IPC_SHM_MODE_ATTACH` (ipc.h). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShmMode { + /// Create (and own) the segment. Fails if it already exists; the owner + /// unlinks it on close. + Create, + /// Attach to a segment created by the peer. Does not unlink on close. + Attach, +} + +impl ShmMode { + /// Map the C ABI mode integer (`OAK_IPC_SHM_MODE_CREATE` = 0, + /// `OAK_IPC_SHM_MODE_ATTACH` = 1) back to the enum. + fn from_c(v: c_int) -> ShmMode { + match v { + 0 => ShmMode::Create, + _ => ShmMode::Attach, + } + } +} + +/// Per-slot metadata describing the frame currently occupying a slot — +/// field-for-field `oak_frame_slot_meta` from `engine/include/oakengine/ipc.h`. +/// +/// This POD lives in shared memory alongside the pixel data and is part of +/// the version-1 wire protocol; `#[repr(C)]` keeps the C ABI layout. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FrameSlotMeta { + /// Caller-defined tag (ticket id, or footage stream hash). + pub id: i64, + /// Frame timestamp numerator. + pub time_num: i64, + /// Frame timestamp denominator. + pub time_den: i64, + /// Frame width. + pub width: i32, + /// Frame height. + pub height: i32, + /// `PixelFormat::Format` value. + pub format: i32, + /// Channel count. + pub channel_count: i32, + /// Bytes per scanline (stride). + pub linesize: i32, + /// Valid bytes written into the slot's data block. + pub data_size: i32, + /// Input colorspace name. + pub colorspace: [c_char; OAK_IPC_COLORSPACE_CAP], +} + +impl Default for FrameSlotMeta { + fn default() -> Self { + FrameSlotMeta { + id: 0, + time_num: 0, + time_den: 0, + width: 0, + height: 0, + format: 0, + channel_count: 0, + linesize: 0, + data_size: 0, + colorspace: [0; OAK_IPC_COLORSPACE_CAP], + } + } +} + +/// `sizeof(oak_frame_slot_meta)` (8+8+8 + 4*6 + 128). +const FRAME_SLOT_META_SIZE: usize = 176; + +// --------------------------------------------------------------------------- +// SpscRingBuffer +// --------------------------------------------------------------------------- + +/// A lock-free single-producer / single-consumer ring buffer of `u32` +/// indices, living in shared memory — the port of +/// `engine/include/oakengine/spscringbuffer.h`. +/// +/// Layout (offsets from the buffer base, matching the C++ class): +/// +/// ```text +/// 0 head_ u32 producer cursor (relaxed read, release write) +/// 4 tail_ u32 consumer cursor (relaxed read, release write) +/// 8 capacity_ u32 slot count (written once by create()) +/// 12 slots u32[capacity] +/// ``` +/// +/// One slot is always left empty to disambiguate full and empty, so a +/// buffer with `capacity` slots holds at most `capacity - 1` live entries. +/// The payload is a `u32` slot index — never a pointer. +/// +/// `SpscRingBuffer` is a thin view over a raw pointer; it is `Copy` and +/// owns nothing. All methods are `unsafe` because they read and write the +/// shared segment concurrently with a peer process. +#[derive(Clone, Copy)] +pub struct SpscRingBuffer { + /// Base of the ring header (`head_` at offset 0). + base: *mut u8, +} + +// The shared memory the ring lives in is usable from any thread of the +// local process; synchronization with the peer is the ring's own atomics. +unsafe impl Send for SpscRingBuffer {} +unsafe impl Sync for SpscRingBuffer {} + +impl SpscRingBuffer { + /// `sizeof(SpscRingBuffer)` — header bytes before the slot array. + pub const HEADER_BYTES: usize = 12; + + /// Total bytes required for the header plus `capacity` index slots + /// (`SpscRingBuffer::bytes_needed`). + pub fn bytes_needed(capacity: u32) -> usize { + Self::HEADER_BYTES + capacity as usize * 4 + } + + /// In-place construct a ring header at `mem` with `capacity` index + /// slots. `mem` must provide at least [`Self::bytes_needed`] bytes and + /// be suitably aligned (mmap-backed segments are). Done exactly once by + /// whichever process owns the segment's creation; the peer uses + /// [`Self::attach`] instead. + /// + /// # Safety + /// `mem` must be a valid, writable, aligned buffer of at least + /// [`Self::bytes_needed`] bytes, and must not be concurrently written + /// during this call. + pub unsafe fn create(mem: *mut u8, capacity: u32) -> SpscRingBuffer { + let ring = SpscRingBuffer { base: mem }; + unsafe { + ring.store_capacity(capacity); + ring.head().store(0, Ordering::Relaxed); + ring.tail().store(0, Ordering::Relaxed); + for i in 0..capacity as usize { + *ring.slot_ptr(i) = 0; + } + } + ring + } + + /// Re-interpret already-initialized shared memory as a ring buffer + /// (peer-process side). No writes are performed. + /// + /// # Safety + /// `mem` must point to a buffer previously initialized by + /// [`Self::create`] (or an ABI-identical C++ side) that stays mapped + /// for as long as this view is used. + pub unsafe fn attach(mem: *mut u8) -> SpscRingBuffer { + SpscRingBuffer { base: mem } + } + + /// The ring's capacity (slot count). + /// + /// # Safety + /// `self` must point at a live ring (created or attached). + pub unsafe fn capacity(&self) -> u32 { + unsafe { (self.base.add(8) as *const u32).read() } + } + + /// Producer side: enqueue an index. Returns false if the buffer is full. + /// + /// # Safety + /// Exactly one producer may call this concurrently with exactly one + /// consumer calling [`Self::pop`]; the ring must be live. + pub unsafe fn push(&self, value: u32) -> bool { + unsafe { + let head = self.head().load(Ordering::Relaxed); + let next = self.increment(head); + if next == self.tail().load(Ordering::Acquire) { + return false; + } + *self.slot_ptr(head as usize) = value; + self.head().store(next, Ordering::Release); + } + true + } + + /// Consumer side: dequeue an index into `out`. Returns false if the + /// buffer is empty. + /// + /// # Safety + /// Exactly one consumer may call this concurrently with exactly one + /// producer calling [`Self::push`]; the ring must be live. + pub unsafe fn pop(&self, out: &mut u32) -> bool { + unsafe { + let tail = self.tail().load(Ordering::Relaxed); + if tail == self.head().load(Ordering::Acquire) { + return false; + } + *out = *self.slot_ptr(tail as usize); + self.tail().store(self.increment(tail), Ordering::Release); + } + true + } + + /// Approximate number of entries currently queued; may be stale the + /// instant it returns. For metrics/backpressure, not correctness. + /// + /// # Safety + /// The ring must be live. + pub unsafe fn size_approx(&self) -> u32 { + unsafe { + let head = self.head().load(Ordering::Acquire); + let tail = self.tail().load(Ordering::Acquire); + let cap = self.capacity(); + (head + cap - tail) % cap + } + } + + /// Approximate empty check (see [`Self::size_approx`]). + /// + /// # Safety + /// The ring must be live. + pub unsafe fn is_empty_approx(&self) -> bool { + unsafe { self.head().load(Ordering::Acquire) == self.tail().load(Ordering::Acquire) } + } + + #[inline] + fn increment(&self, index: u32) -> u32 { + // `capacity_` is small; this avoids requiring a power-of-two capacity. + unsafe { (index + 1) % self.capacity() } + } + + #[inline] + unsafe fn head(&self) -> &AtomicU32 { + unsafe { &*(self.base as *const AtomicU32) } + } + + #[inline] + unsafe fn tail(&self) -> &AtomicU32 { + unsafe { &*(self.base.add(4) as *const AtomicU32) } + } + + #[inline] + unsafe fn store_capacity(&self, capacity: u32) { + unsafe { *(self.base.add(8) as *mut u32) = capacity }; + } + + #[inline] + unsafe fn slot_ptr(&self, index: usize) -> *mut u32 { + unsafe { self.base.add(Self::HEADER_BYTES + index * 4) as *mut u32 } + } +} + +// --------------------------------------------------------------------------- +// FrameSlotPool +// --------------------------------------------------------------------------- + +/// Pool header written by create() and read back by attach(). Field-for- +/// field the C++ `FrameSlotPool::Header` (offsets: 0,4,8,16,24,32,40; +/// 48 bytes total). +#[repr(C)] +struct PoolHeader { + magic: u32, + slot_count: u32, + slot_data_bytes: u64, + free_ring_offset: u64, + ready_ring_offset: u64, + meta_offset: u64, + data_offset: u64, +} + +const POOL_HEADER_SIZE: usize = 48; + +/// A fixed-size pool of equal-sized frame slots in shared memory with +/// lock-free hand-off — the port of the C++ `FrameSlotPool` +/// (`engine/src/oliveimpl/render/ipc/frameslotpool.{h,cpp}`). +/// +/// One pool models a single direction of frame flow. It does NOT own the +/// memory; it is a view over a mapped [`SharedMemoryRegion`] (or any +/// ABI-identical segment). Lifecycle: the filler `acquire`s a free slot, +/// writes meta + pixels, then `publish`es it; the drainer `consume`s the +/// next ready slot, reads it, and `release`s it back to the free ring. +/// +/// [`FrameSlotPool`] is `Clone` — the clone is another view of the same +/// segment (the C++ `copy()`), useful to hand both sides a handle without +/// owning the mapping twice. +pub struct FrameSlotPool { + /// Segment base. + base: *mut u8, + /// The pool header at `base + 0`. + header: *mut PoolHeader, + /// Free-ring view (filler pops, drainer pushes). + free_ring: SpscRingBuffer, + /// Ready-ring view (filler pushes, drainer pops). + ready_ring: SpscRingBuffer, + /// Metadata array at `base + meta_offset`. + meta: *mut FrameSlotMeta, + /// Pixel data blocks at `base + data_offset`. + data: *mut u8, +} + +// Views into shared memory are safe to share within the process; the rings +// carry their own synchronization. +unsafe impl Send for FrameSlotPool {} +unsafe impl Sync for FrameSlotPool {} + +impl Clone for FrameSlotPool { + fn clone(&self) -> FrameSlotPool { + FrameSlotPool { + base: self.base, + header: self.header, + free_ring: self.free_ring, + ready_ring: self.ready_ring, + meta: self.meta, + data: self.data, + } + } +} + +impl FrameSlotPool { + /// Total bytes a region must provide to back a pool of + /// `slot_count` x `slot_data_bytes` + /// (`FrameSlotPool::bytes_needed`). + pub fn bytes_needed(slot_count: u32, slot_data_bytes: usize) -> usize { + let ring_cap = slot_count + 1; + let mut total = align_up(POOL_HEADER_SIZE, K_ALIGN); + let ring_bytes = align_up(SpscRingBuffer::bytes_needed(ring_cap), K_ALIGN); + total += ring_bytes; // free ring + total += ring_bytes; // ready ring + total += align_up(FRAME_SLOT_META_SIZE * slot_count as usize, K_ALIGN); // metadata + total += align_up(slot_data_bytes, K_ALIGN) * slot_count as usize; // pixel data + total + } + + /// Lay out and initialize a brand-new pool over `mem` (owner side, once). + /// + /// Writes the header, initializes both rings, seeds the free ring with + /// every slot index and zeroes the metadata. `mem` must provide at + /// least [`Self::bytes_needed`] bytes of writable, aligned memory (an + /// mmap-backed segment) and must outlive the returned pool. + /// + /// # Safety + /// `mem` must be a valid, writable, aligned buffer of at least + /// [`Self::bytes_needed`] bytes, not concurrently written during this + /// call. + pub unsafe fn create(mem: *mut u8, slot_count: u32, slot_data_bytes: usize) -> FrameSlotPool { + let ring_cap = slot_count + 1; + let free_off = align_up(POOL_HEADER_SIZE, K_ALIGN); + let ready_off = free_off + align_up(SpscRingBuffer::bytes_needed(ring_cap), K_ALIGN); + let meta_off = ready_off + align_up(SpscRingBuffer::bytes_needed(ring_cap), K_ALIGN); + let data_off = meta_off + align_up(FRAME_SLOT_META_SIZE * slot_count as usize, K_ALIGN); + + let pool = unsafe { + FrameSlotPool { + base: mem, + header: mem as *mut PoolHeader, + free_ring: SpscRingBuffer::create(mem.add(free_off), ring_cap), + ready_ring: SpscRingBuffer::create(mem.add(ready_off), ring_cap), + meta: mem.add(meta_off) as *mut FrameSlotMeta, + data: mem.add(data_off), + } + }; + unsafe { + (*pool.header).magic = FRAMEPOOL_MAGIC; + (*pool.header).slot_count = slot_count; + (*pool.header).slot_data_bytes = slot_data_bytes as u64; + (*pool.header).free_ring_offset = free_off as u64; + (*pool.header).ready_ring_offset = ready_off as u64; + (*pool.header).meta_offset = meta_off as u64; + (*pool.header).data_offset = data_off as u64; + } + // `ptr::write_bytes` counts in elements of T, so cast to bytes. + unsafe { + ptr::write_bytes( + pool.meta as *mut u8, + 0, + slot_count as usize * std::mem::size_of::(), + ); + } + // Seed the free ring with every slot index so the filler can + // acquire() immediately. + for i in 0..slot_count { + unsafe { pool.free_ring.push(i) }; + } + pool + } + + /// Map an existing, already-initialized pool (peer side). + /// + /// Reads the geometry from the in-memory header written by + /// [`Self::create`]; the returned pool reports `is_valid() == false` + /// when the magic does not match. + /// + /// # Safety + /// `mem` must point to a mapped segment that either contains a pool + /// initialized by [`Self::create`] (or an ABI-identical C++ side) or is + /// an arbitrary buffer whose first 4 bytes we must be able to read. + pub unsafe fn attach(mem: *mut u8) -> FrameSlotPool { + if mem.is_null() { + return FrameSlotPool::invalid(); + } + let header = mem as *mut PoolHeader; + // SAFETY: `mem` is a live mapping of at least the header size. + if unsafe { (*header).magic } != FRAMEPOOL_MAGIC { + return FrameSlotPool::invalid(); + } + let pool = unsafe { + FrameSlotPool { + base: mem, + header, + free_ring: SpscRingBuffer::attach(mem.add((*header).free_ring_offset as usize)), + ready_ring: SpscRingBuffer::attach(mem.add((*header).ready_ring_offset as usize)), + meta: mem.add((*header).meta_offset as usize) as *mut FrameSlotMeta, + data: mem.add((*header).data_offset as usize), + } + }; + pool + } + + /// An invalid pool (attach on a non-pool segment). + fn invalid() -> FrameSlotPool { + FrameSlotPool { + base: ptr::null_mut(), + header: ptr::null_mut(), + free_ring: SpscRingBuffer { + base: ptr::null_mut(), + }, + ready_ring: SpscRingBuffer { + base: ptr::null_mut(), + }, + meta: ptr::null_mut(), + data: ptr::null_mut(), + } + } + + /// True when the pool was attached to a segment containing a valid pool + /// header. + pub fn is_valid(&self) -> bool { + !self.header.is_null() + } + + /// Number of slots in the pool (0 for an invalid pool). + pub fn slot_count(&self) -> u32 { + if self.is_valid() { + unsafe { (*self.header).slot_count } + } else { + 0 + } + } + + /// Bytes available in every slot's pixel-data block (0 for invalid). + pub fn slot_data_bytes(&self) -> usize { + if self.is_valid() { + unsafe { (*self.header).slot_data_bytes as usize } + } else { + 0 + } + } + + /// Byte stride between consecutive slot data blocks. + fn slot_stride(&self) -> usize { + align_up(self.slot_data_bytes(), K_ALIGN) + } + + // ---- Filler side ---- + + /// Take ownership of a free slot. Returns false (leaving `index` + /// untouched) if none is free. + /// + /// # Safety + /// The pool must be a valid view of a live segment. + pub unsafe fn acquire(&self, index: &mut u32) -> bool { + unsafe { self.free_ring.pop(index) } + } + + /// Pointer to a slot's pixel data block (`slot_data_bytes` available). + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn slot_data(&self, index: u32) -> *mut u8 { + unsafe { self.data.add(index as usize * self.slot_stride()) } + } + + /// Mutable metadata for a slot. The filler writes this before + /// [`Self::publish`]. The returned pointer addresses shared memory; it + /// is borrowed, not owned. + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn meta(&self, index: u32) -> *mut FrameSlotMeta { + unsafe { self.meta.add(index as usize) } + } + + /// Publish a filled slot to the drainer. Must follow a successful + /// [`Self::acquire`] of `index`. Returns false if the ready ring is + /// full (the filler must then release the slot and retry later). + /// + /// # Safety + /// `index` must be a slot previously acquired and not yet released. + pub unsafe fn publish(&self, index: u32) -> bool { + unsafe { self.ready_ring.push(index) } + } + + // ---- Drainer side ---- + + /// Take the next published slot. Returns false if nothing is ready. + /// + /// # Safety + /// The pool must be a valid view of a live segment. + pub unsafe fn consume(&self, index: &mut u32) -> bool { + unsafe { self.ready_ring.pop(index) } + } + + /// Return a consumed slot to the free pool for reuse. Must follow a + /// successful [`Self::consume`] of `index`. Returns false if the free + /// ring is full (the drainer must not release the slot yet). + /// + /// # Safety + /// `index` must be a slot previously consumed and not yet re-acquired. + pub unsafe fn release(&self, index: u32) -> bool { + unsafe { self.free_ring.push(index) } + } + + /// Immutable metadata for a slot (drainer side). + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn meta_const(&self, index: u32) -> *const FrameSlotMeta { + unsafe { self.meta.add(index as usize) } + } + + /// Immutable pixel data for a slot. + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn slot_data_const(&self, index: u32) -> *const u8 { + unsafe { self.data.add(index as usize * self.slot_stride()) } + } +} + +// --------------------------------------------------------------------------- +// SharedMemoryRegion +// --------------------------------------------------------------------------- + +/// A named, fixed-size POSIX shared-memory segment mapped into the process +/// address space — the port of the C++ `SharedMemoryRegion` +/// (`engine/render/ipc/sharedmemoryregion.cpp`). +/// +/// One process opens the segment in [`ShmMode::Create`] (owner: fails if +/// the name already exists, zeroes the mapping, unlinks on close); the +/// peer opens the same key in [`ShmMode::Attach`]. The mapping is a raw +/// contiguous byte range; the ring buffers and frame slot pools are laid +/// out inside it. Nothing here is locked — synchronization is entirely the +/// caller's responsibility via the lock-free structures placed in the +/// mapping. +pub struct SharedMemoryRegion { + /// The key the region was opened with (no leading slash). + key: String, + /// Requested mapping size in bytes. + size: usize, + /// The mmap'd data pointer; null when invalid. + data: *mut u8, + /// File descriptor from `shm_open` (-1 when invalid). + fd: i32, + /// Open mode. + mode: ShmMode, + /// Human-readable reason of the last failed open. + error: String, + /// The platform-prefixed name actually passed to `shm_open`. + shm_name: String, +} + +impl SharedMemoryRegion { + /// An empty (invalid) region. + pub fn new() -> SharedMemoryRegion { + SharedMemoryRegion { + key: String::new(), + size: 0, + data: ptr::null_mut(), + fd: -1, + mode: ShmMode::Attach, + error: String::new(), + shm_name: String::new(), + } + } + + /// Build a unique segment key for a worker, e.g. + /// "olive-rw--" (`SharedMemoryRegion::make_key`). + /// Centralized so the owner and the spawned worker agree on the same + /// name. + pub fn make_key(owner_pid: i64, worker_index: i32) -> String { + format!("olive-rw-{owner_pid}-{worker_index}") + } + + /// Open the segment identified by `key` with the given `size` in bytes. + /// + /// `key` is a short identifier (no leading slash needed; the platform + /// prefix is added internally). Returns true on success; on failure + /// [`Self::error`] carries a human-readable reason. An existing region + /// is closed first. + pub fn open(&mut self, key: &str, size: usize, mode: ShmMode) -> bool { + self.close(); + self.key = key.to_string(); + self.size = size; + self.mode = mode; + + // POSIX shared-memory names must start with a single slash and + // contain no others. + let shm_name = format!("/{}", key.replace('/', "_")); + let name_c = match std::ffi::CString::new(shm_name.clone()) { + Ok(c) => c, + Err(_) => { + self.error = format!("invalid shm key {key:?} (contains NUL)"); + return false; + } + }; + self.shm_name = shm_name; + + let mut oflag = libc::O_RDWR; + if mode == ShmMode::Create { + oflag |= libc::O_CREAT | libc::O_EXCL; + // Clear any stale segment left by a crashed previous run with + // the same name. + unsafe { libc::shm_unlink(name_c.as_ptr()) }; + } + + let fd = unsafe { libc::shm_open(name_c.as_ptr(), oflag, 0o600) }; + if fd < 0 { + self.error = format!( + "shm_open({}) failed: {}", + self.shm_name, + std::io::Error::last_os_error() + ); + return false; + } + self.fd = fd; + + if mode == ShmMode::Create { + if unsafe { libc::ftruncate(fd, size as libc::off_t) } != 0 { + self.error = format!("ftruncate failed: {}", std::io::Error::last_os_error()); + self.close(); + return false; + } + } else { + // mmap() succeeds even beyond the real segment size and only + // faults (SIGBUS) on access, so verify the segment is large + // enough up front. + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } != 0 { + self.error = format!("fstat failed: {}", std::io::Error::last_os_error()); + self.close(); + return false; + } + if (st.st_size as usize) < size { + self.error = format!( + "shared memory segment is {} bytes, smaller than the requested {}", + st.st_size, size + ); + self.close(); + return false; + } + } + + let data = unsafe { + libc::mmap( + ptr::null_mut(), + size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + 0, + ) + }; + if data == libc::MAP_FAILED { + self.error = format!("mmap failed: {}", std::io::Error::last_os_error()); + self.close(); + return false; + } + self.data = data as *mut u8; + self.error.clear(); + + if mode == ShmMode::Create { + unsafe { ptr::write_bytes(self.data, 0, size) }; + } + true + } + + /// Unmap and (if owner) unlink the segment. Also called by `Drop`. + pub fn close(&mut self) { + if !self.data.is_null() { + unsafe { libc::munmap(self.data as *mut std::ffi::c_void, self.size) }; + self.data = ptr::null_mut(); + } + if self.fd >= 0 { + unsafe { libc::close(self.fd) }; + self.fd = -1; + } + if self.mode == ShmMode::Create && !self.shm_name.is_empty() { + // Only the owner unlinks, so the name is freed once both sides + // have unmapped. + if let Ok(c) = std::ffi::CString::new(self.shm_name.clone()) { + unsafe { libc::shm_unlink(c.as_ptr()) }; + } + self.shm_name.clear(); + } + self.size = 0; + } + + /// True when the region holds a live mapping. + pub fn is_valid(&self) -> bool { + !self.data.is_null() + } + + /// The mapped data pointer (null when invalid). + pub fn data(&self) -> *mut u8 { + self.data + } + + /// The mapping size in bytes. + pub fn size(&self) -> usize { + self.size + } + + /// The key the region was opened with. + pub fn key(&self) -> &str { + &self.key + } + + /// Human-readable reason of the last failed open. + pub fn error(&self) -> &str { + &self.error + } +} + +impl Default for SharedMemoryRegion { + fn default() -> Self { + SharedMemoryRegion::new() + } +} + +impl Drop for SharedMemoryRegion { + fn drop(&mut self) { + self.close(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- Control-plane protocol ------------------------------------------ + + #[test] + fn handshake_wire_format_matches_cpp_field_names() { + let hs = HandshakeMsg { + protocol_version: 1, + shm_key: "olive-rw-1234-0-out".into(), + input_shm_key: "".into(), + input_slots: 0, + output_slots: 6, + slot_data_bytes: 4096, + input_slot_data_bytes: 0, + }; + let value = hs.to_json(); + // Key order is not part of the contract (JSON objects; the C++ + // QJsonObject is hash-ordered too), but the names must match the + // C++ serializer exactly. + assert_eq!(value["type"], "handshake"); + assert_eq!(value["protocol_version"], 1); + assert_eq!(value["shm_key"], "olive-rw-1234-0-out"); + assert_eq!(value["input_shm_key"], ""); + assert_eq!(value["input_slots"], 0); + assert_eq!(value["output_slots"], 6); + assert_eq!(value["slot_data_bytes"], 4096); + assert_eq!(value["input_slot_data_bytes"], 0); + // And the serialized line must parse back to the same object. + let round: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&value).unwrap()).unwrap(); + assert_eq!(round, value); + } + + #[test] + fn render_frame_parse_accepts_cpp_field_names() { + let json = r#"{"type":"render_frame","ticket":42,"node":"abcd","time_num":1,"time_den":24,"width":1920,"height":1080,"format":-1,"channels":0,"mode":0,"input_slot":-1,"input_slots":[],"has_color_transform":false,"color_output":"","color_view":"","color_look":""}"#; + let m: RenderFrameMsg = serde_json::from_str(json).unwrap(); + assert_eq!(m.ticket, 42); + assert_eq!(m.node, "abcd"); + assert_eq!(m.time_num, 1); + assert_eq!(m.time_den, 24); + assert_eq!(m.width, 1920); + assert_eq!(m.input_slot, -1); + } + + #[test] + fn render_frame_defaults_on_missing_fields() { + // The C++ parser defaults missing fields (QJsonValue defaults); + // serde(default) mirrors that. + let m: RenderFrameMsg = + serde_json::from_str(r#"{"type":"render_frame","ticket":7}"#).unwrap(); + assert_eq!(m.ticket, 7); + assert_eq!(m.time_den, 0); + assert!(m.node.is_empty()); + assert!(!m.has_color_transform); + } + + #[test] + fn error_message_carries_ticket_only_when_nonzero() { + assert_eq!( + error_message("boom", None), + json!({ "type": "error", "message": "boom" }) + ); + assert_eq!( + error_message("boom", Some(0)), + json!({ "type": "error", "message": "boom" }) + ); + assert_eq!( + error_message("boom", Some(9)), + json!({ "type": "error", "message": "boom", "ticket": 9 }) + ); + } + + #[test] + fn write_message_emits_one_json_line() { + let mut buf = Vec::new(); + write_message(&mut buf, &json!({ "type": "shutdown" })).unwrap(); + assert_eq!(String::from_utf8(buf).unwrap(), "{\"type\":\"shutdown\"}\n"); + } + + // ---- Shared-memory transport ----------------------------------------- + + /// A unique, temporary POSIX segment key for a test (pid + counter), so + /// parallel test runs never collide. + fn test_key(name: &str) -> String { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + SharedMemoryRegion::make_key(i64::from(std::process::id()), (n & 0x7FFF) as i32) + + &format!("-{name}") + } + + /// Create one segment and map it a second time — the in-process + /// equivalent of two processes sharing a segment. Returns + /// `(owner_region, peer_region)`; both must be kept alive for the + /// whole test (the peer is an attach that does not unlink). + fn two_mappings(key: &str, size: usize) -> (SharedMemoryRegion, SharedMemoryRegion) { + let mut owner = SharedMemoryRegion::new(); + assert!( + owner.open(key, size, ShmMode::Create), + "create failed: {}", + owner.error() + ); + let mut peer = SharedMemoryRegion::new(); + assert!( + peer.open(key, size, ShmMode::Attach), + "attach failed: {}", + peer.error() + ); + (owner, peer) + } + + // ---- SpscRingBuffer ------------------------------------------------- + + #[test] + fn ring_bytes_needed_matches_cpp_layout() { + // 12 header bytes + capacity * 4. + assert_eq!(SpscRingBuffer::bytes_needed(4), 12 + 16); + assert_eq!(SpscRingBuffer::bytes_needed(5), 12 + 20); + assert_eq!(SpscRingBuffer::bytes_needed(0), 12); + } + + #[test] + fn ring_empty_full_and_single_entry() { + let key = test_key("ring-empty"); + let size = SpscRingBuffer::bytes_needed(4); + let (owner, peer) = two_mappings(&key, size); + // SAFETY: both mappings are live and at least `size` bytes. + let prod = unsafe { SpscRingBuffer::create(owner.data(), 4) }; + let cons = unsafe { SpscRingBuffer::attach(peer.data()) }; + + assert!(unsafe { cons.is_empty_approx() }); + let mut v = 99; + assert!(!unsafe { cons.pop(&mut v) }); + assert_eq!(v, 99); + + assert!(unsafe { prod.push(7) }); + assert!(!unsafe { cons.is_empty_approx() }); + assert_eq!(unsafe { cons.size_approx() }, 1); + assert!(unsafe { cons.pop(&mut v) }); + assert_eq!(v, 7); + assert!(unsafe { cons.is_empty_approx() }); + } + + #[test] + fn ring_capacity_minus_one_live_entries() { + // A ring of capacity N holds at most N-1 entries (one slot is + // always left empty to tell full from empty). + let key = test_key("ring-cap"); + let size = SpscRingBuffer::bytes_needed(4); + let (owner, peer) = two_mappings(&key, size); + // SAFETY: live mappings. + let prod = unsafe { SpscRingBuffer::create(owner.data(), 4) }; + let cons = unsafe { SpscRingBuffer::attach(peer.data()) }; + + for i in 0..3 { + assert!(unsafe { prod.push(i) }); + } + // The 4th push must fail: head would collide with tail. + assert!(!unsafe { prod.push(99) }); + + let mut v = 0; + for expected in 0..3 { + assert!(unsafe { cons.pop(&mut v) }); + assert_eq!(v, expected); + } + assert!(!unsafe { cons.pop(&mut v) }); + } + + #[test] + fn ring_wraparound_preserves_order() { + // Fill, drain, then wrap past the end of the slot array: cursors + // are modulo-capacity, order must be preserved across the wrap. + let key = test_key("ring-wrap"); + let size = SpscRingBuffer::bytes_needed(4); + let (owner, peer) = two_mappings(&key, size); + // SAFETY: live mappings. + let prod = unsafe { SpscRingBuffer::create(owner.data(), 4) }; + let cons = unsafe { SpscRingBuffer::attach(peer.data()) }; + + for i in 0..3 { + assert!(unsafe { prod.push(i) }); + } + let mut v = 0; + for _ in 0..3 { + assert!(unsafe { cons.pop(&mut v) }); + } + // Ring is empty again; push past the wrap point. + for i in 3..6 { + assert!(unsafe { prod.push(i) }); + } + for expected in 3..6 { + assert!(unsafe { cons.pop(&mut v) }); + assert_eq!(v, expected); + } + } + + // ---- FrameSlotPool -------------------------------------------------- + + #[test] + fn framepool_bytes_needed_matches_cpp_offsets() { + // Recompute by hand with the C++ layout: header 64, each ring + // align_up(12 + 4*(n+1), 64), meta align_up(176*n, 64), data + // align_up(slot_bytes, 64) * n. + let check = |n: u32, slot: usize| { + let ring = align_up(12 + 4 * (n as usize + 1), 64); + let expected = + 64 + ring + ring + align_up(176 * n as usize, 64) + align_up(slot, 64) * n as usize; + assert_eq!(FrameSlotPool::bytes_needed(n, slot), expected); + }; + check(4, 4096); + check(6, 1_000_000); + check(1, 64); + check(3, 100); + } + + #[test] + fn framepool_create_attach_two_processes_both_directions() { + // "Two processes": two mappings of the same segment. Owner creates + // the pool; the peer attaches. A filler on one side and a drainer + // on the other exchange slots in both directions. + let key = test_key("pool-bidi"); + let slots = 4u32; + let slot_bytes = 64usize; + let size = FrameSlotPool::bytes_needed(slots, slot_bytes); + let (owner, peer) = two_mappings(&key, size); + + // SAFETY: both mappings are live and sized by bytes_needed. + let filler = unsafe { FrameSlotPool::create(owner.data(), slots, slot_bytes) }; + let drainer = unsafe { FrameSlotPool::attach(peer.data()) }; + + assert!(filler.is_valid()); + assert!(drainer.is_valid()); + assert_eq!(drainer.slot_count(), slots); + assert_eq!(drainer.slot_data_bytes(), slot_bytes); + + // Filler acquires every slot exactly once (seeded free ring), then + // the free ring is empty. + let mut got = Vec::new(); + for _ in 0..slots { + let mut s = 0; + assert!(unsafe { filler.acquire(&mut s) }); + got.push(s); + } + got.sort_unstable(); + assert_eq!(got, vec![0, 1, 2, 3]); + let mut extra = 0; + assert!(!unsafe { filler.acquire(&mut extra) }); + // Drainer sees nothing ready yet. + assert!(!unsafe { drainer.consume(&mut extra) }); + + // Filler writes pixels + meta into two slots and publishes them. + for (i, slot) in [0u32, 2u32].iter().enumerate() { + // SAFETY: `slot` was acquired above. + let data = unsafe { filler.slot_data(*slot) }; + unsafe { ptr::write_bytes(data, (i * 40 + 1) as u8, slot_bytes) }; + // SAFETY: slot in range. + let meta = unsafe { &mut *filler.meta(*slot) }; + meta.id = 100 + *slot as i64; + meta.width = 8; + meta.height = 8; + meta.data_size = slot_bytes as i32; + assert!(unsafe { filler.publish(*slot) }); + } + + // Drainer consumes them through its own mapping and sees the same + // payloads and metadata. + let mut consumed = Vec::new(); + for _ in 0..2 { + let mut s = 0; + assert!(unsafe { drainer.consume(&mut s) }); + // SAFETY: s was consumed. + let data = unsafe { drainer.slot_data_const(s) }; + let meta = unsafe { &*drainer.meta_const(s) }; + assert_eq!(meta.id, 100 + s as i64); + assert_eq!(meta.width, 8); + assert_eq!(meta.data_size, slot_bytes as i32); + // SAFETY: slot_bytes readable in the slot block. + let first = unsafe { *data }; + assert_eq!(first, ((s as usize / 2) * 40 + 1) as u8); + consumed.push(s); + } + consumed.sort_unstable(); + assert_eq!(consumed, vec![0, 2]); + assert!(!unsafe { drainer.consume(&mut extra) }); + + // Drainer releases the slots back; the filler can acquire them + // again — the full round trip through both rings. + for s in consumed { + assert!(unsafe { drainer.release(s) }); + } + let mut s = 0; + assert!(unsafe { filler.acquire(&mut s) }); + assert_eq!(s, 0); + } + + #[test] + fn framepool_wraparound_and_full_edges() { + // Small pool: cycle every slot many times, verifying the rings' + // modulo behavior end to end. + let key = test_key("pool-wrap"); + let slots = 3u32; + let slot_bytes = 32usize; + let size = FrameSlotPool::bytes_needed(slots, slot_bytes); + let (owner, peer) = two_mappings(&key, size); + + // SAFETY: live mappings. + let filler = unsafe { FrameSlotPool::create(owner.data(), slots, slot_bytes) }; + let drainer = unsafe { FrameSlotPool::attach(peer.data()) }; + + for cycle in 0..4u32 { + let mut published = Vec::new(); + for _ in 0..slots { + let mut s = 0; + assert!(unsafe { filler.acquire(&mut s) }, "cycle {cycle}"); + // SAFETY: acquired slot. + unsafe { ptr::write_bytes(filler.slot_data(s), cycle as u8, slot_bytes) }; + // SAFETY: slot in range. + let meta = unsafe { &mut *filler.meta(s) }; + meta.id = i64::from(cycle * 100 + s); + assert!(unsafe { filler.publish(s) }); + published.push(s); + } + // Pool is full on the filler side. + let mut x = 0; + assert!(!unsafe { filler.acquire(&mut x) }); + + // Drain everything on the drainer side. + let mut consumed = Vec::new(); + for _ in 0..slots { + let mut s = 0; + assert!(unsafe { drainer.consume(&mut s) }); + // SAFETY: consumed slot. + let meta = unsafe { &*drainer.meta_const(s) }; + assert_eq!(meta.id, i64::from(cycle * 100 + s)); + // SAFETY: 1 byte readable. + assert_eq!(unsafe { *drainer.slot_data_const(s) }, cycle as u8); + consumed.push(s); + } + assert!(!unsafe { drainer.consume(&mut x) }); + consumed.sort_unstable(); + assert_eq!(consumed, vec![0, 1, 2]); + + for s in consumed { + assert!(unsafe { drainer.release(s) }); + } + } + } + + #[test] + fn framepool_attach_rejects_wrong_magic() { + let key = test_key("pool-badmagic"); + let size = FrameSlotPool::bytes_needed(2, 16); + let (owner, _peer) = two_mappings(&key, size); + // Overwrite the header area with garbage — no pool magic. + // SAFETY: owner mapping is live. + unsafe { ptr::write_bytes(owner.data(), 0xAB, 64) }; + // SAFETY: buffer is live. + let pool = unsafe { FrameSlotPool::attach(owner.data()) }; + assert!(!pool.is_valid()); + assert_eq!(pool.slot_count(), 0); + assert_eq!(pool.slot_data_bytes(), 0); + } + + #[test] + fn framepool_pool_over_reused_segment_is_consistent() { + // A pool that has been cycled fully and then attached fresh reports + // the same geometry as bytes_needed computed it. + let key = test_key("pool-geometry"); + let slots = 5u32; + let slot_bytes = 1000usize; + let size = FrameSlotPool::bytes_needed(slots, slot_bytes); + let (owner, peer) = two_mappings(&key, size); + // SAFETY: live mappings. + let _ = unsafe { FrameSlotPool::create(owner.data(), slots, slot_bytes) }; + let attached = unsafe { FrameSlotPool::attach(peer.data()) }; + assert!(attached.is_valid()); + assert_eq!(attached.slot_count(), slots); + assert_eq!(attached.slot_data_bytes(), slot_bytes); + // Slot stride is 64-aligned (matches the C++ data layout). + // SAFETY: valid pool. + let s0 = unsafe { attached.slot_data(0) }; + let s1 = unsafe { attached.slot_data(1) }; + assert_eq!(s1 as usize - s0 as usize, align_up(slot_bytes, K_ALIGN)); + } + + // ---- SharedMemoryRegion --------------------------------------------- + + #[test] + fn region_create_attach_write_visibility() { + let key = test_key("region-vis"); + let size = 4096usize; + let (mut owner, mut peer) = two_mappings(&key, size); + assert!(owner.is_valid()); + assert!(peer.is_valid()); + assert_eq!(owner.size(), size); + assert_eq!(peer.size(), size); + assert_eq!(owner.key(), key); + assert_eq!(peer.key(), key); + + // Owner writes; peer sees it through its own mapping. + // SAFETY: both mappings are live with `size` bytes. + unsafe { + let dst = owner.data() as *mut u32; + *dst = 0xDEADBEEF; + } + // SAFETY: peer mapping live. + let seen = unsafe { *(peer.data() as *const u32) }; + assert_eq!(seen, 0xDEADBEEF); + + // Peer writes back; owner sees it. + // SAFETY: peer mapping live. + unsafe { + let dst = peer.data() as *mut u32; + *dst = 0x12345678; + } + // SAFETY: owner mapping live. + assert_eq!(unsafe { *(owner.data() as *const u32) }, 0x12345678); + + // Closing the ATTACH side does not unlink: while the owner lives, + // a third mapping can still open the name. + peer.close(); + assert!(!peer.is_valid()); + let mut third = SharedMemoryRegion::new(); + assert!(third.open(&key, size, ShmMode::Attach), "{}", third.error()); + assert!(third.is_valid()); + third.close(); + + // Closing the OWNER unlinks the segment; further attaches fail. + owner.close(); + assert!(!owner.is_valid()); + let mut fourth = SharedMemoryRegion::new(); + assert!(!fourth.open(&key, size, ShmMode::Attach)); + } + + #[test] + fn region_create_replaces_stale_segment() { + // Mirrors the C++: Create unlinks any stale segment with the same + // name first (crash cleanup), so a second Create SUCCEEDS and owns + // a fresh, zeroed segment. + let key = test_key("region-exists"); + let size = 128usize; + let (owner, _peer) = two_mappings(&key, size); + assert!(owner.is_valid()); + // SAFETY: owner mapping live. + unsafe { *(owner.data() as *mut u32) = 0xCAFEBABE }; + + let mut second = SharedMemoryRegion::new(); + assert!( + second.open(&key, size, ShmMode::Create), + "{}", + second.error() + ); + assert!(second.is_valid()); + // The replacement segment is fresh (zeroed by create). + // SAFETY: second mapping live. + assert_eq!(unsafe { *(second.data() as *const u32) }, 0); + } + + #[test] + fn region_attach_fails_when_segment_too_small() { + // macOS rounds shm segment sizes up to a 16 KiB minimum, so use + // sizes above that to exercise the size check. + let key = test_key("region-small"); + let (owner, _peer) = two_mappings(&key, 4096); + assert!(owner.is_valid()); + + // Attaching with a larger size than the segment must fail (the + // fstat check, mirroring the C++). + let mut big = SharedMemoryRegion::new(); + assert!(!big.open(&key, 65536, ShmMode::Attach)); + assert!(!big.is_valid()); + assert!(!big.error().is_empty()); + } + + #[test] + fn region_make_key_format() { + assert_eq!(SharedMemoryRegion::make_key(4242, 3), "olive-rw-4242-3"); + assert_eq!(SharedMemoryRegion::make_key(1, 0), "olive-rw-1-0"); + } + + #[test] + fn region_keys_are_isolation_safe() { + // Keys with slashes are flattened to a single-slash POSIX name. + let key = "a/b/c"; + let size = 64usize; + let (owner, peer) = two_mappings(key, size); + assert!(owner.is_valid()); + assert!(peer.is_valid()); + // The actual POSIX name is "/a_b_c". + // SAFETY: mapping live. + unsafe { *(owner.data() as *mut u32) = 7 }; + // SAFETY: peer mapping live. + assert_eq!(unsafe { *(peer.data() as *const u32) }, 7); + } +} diff --git a/crates/oak-worker/src/main.rs b/crates/oak-worker/src/main.rs index 4922d2849..8ef665fa5 100644 --- a/crates/oak-worker/src/main.rs +++ b/crates/oak-worker/src/main.rs @@ -16,31 +16,27 @@ //! oak-worker: headless render worker process (Rust). //! -//! A thin C-ABI shell over the `liboakengine` facade dylib, mirroring -//! `worker/workermain.cpp`: all runtime logic — render backend selection -//! (dynamic -> OpenGL fallback through the oakrender module), the startup -//! handshake and the NDJSON control loop — lives in the engine -//! (`oakengine_worker_main`, the port of `engine/src/capi/worker.cpp`, -//! contract in `engine/include/oakengine/worker.h`). This crate keeps only -//! the argv forwarding, the `#[link]` declarations ([`engine_ipc`]) and -//! the in-process session mirror ([`session`], [`transport`]) that its -//! unit tests exercise against the engine's real shared-memory transport. +//! The whole runtime lives in this binary (M14 R2): render backend +//! selection (dynamic -> OpenGL fallback through the oakrender crate's +//! direct Rust API), the startup handshake and the NDJSON control loop +//! ([`worker`], the port of `engine/src/capi/worker.cpp`), plus the +//! shared-memory frame-slot transport ([`ipc`], the port of +//! `engine/render/ipc/`). No `liboakengine` dylib is linked; the facade +//! keeps its own copies of both modules for the frozen +//! `oakengine_worker_*` / `oakengine_ipc_*` C ABI (external consumers). //! //! See README.md for the full status. #![deny(unsafe_op_in_unsafe_fn)] #![warn(missing_docs)] -mod engine_ipc; -mod session; -mod transport; +mod ipc; +mod worker; -use std::ffi::{c_char, c_int, CString}; use std::process::exit; /// Protocol version announced in the startup handshake -/// (`k_protocol_version` in worker.cpp). Mirrors the engine worker -/// module's `PROTOCOL_VERSION`. +/// (`k_protocol_version` in worker.cpp). pub const PROTOCOL_VERSION: i32 = 1; /// Log a worker-side message to stderr, mirroring worker.cpp `log_error()` @@ -49,26 +45,26 @@ pub fn log_error(message: &str) { eprintln!("worker: {message}"); } +/// Scan argv for `--backend ` (worker.cpp `oakengine_worker_main`). +/// The default is `"opengl"`; the value is lowercased; the last flag wins. +fn parse_backend(args: &[String]) -> String { + let mut backend = "opengl".to_string(); + let mut i = 1usize; + while i < args.len() { + if args[i] == "--backend" && i + 1 < args.len() { + backend = args[i + 1].to_ascii_lowercase(); + i += 2; + } else { + i += 1; + } + } + backend +} + fn main() { - // Forward argv verbatim: the engine's oakengine_worker_main() scans - // for `--backend` itself (workermain.cpp does the same). - let args: Vec = std::env::args_os() - .map(|a| a.to_string_lossy().into_owned()) - .collect(); - let cstrings: Vec = args - .iter() - .map(|a| CString::new(a.as_str()).unwrap_or_else(|_| CString::new("").unwrap())) - .collect(); - let mut argv: Vec<*mut c_char> = cstrings - .iter() - .map(|c| c.as_ptr() as *mut c_char) - .collect(); - // SAFETY: `argv` is an array of `argc` NUL-terminated C strings, kept - // alive for the whole call; the engine only reads them. - let code = unsafe { - engine_ipc::oakengine_worker_main(argv.len() as c_int, argv.as_mut_ptr()) - }; - exit(code); + let args: Vec = std::env::args().collect(); + let backend = parse_backend(&args); + exit(worker::worker_main(&backend)); } #[cfg(test)] @@ -79,4 +75,44 @@ mod tests { fn protocol_version_is_one() { assert_eq!(PROTOCOL_VERSION, 1); } + + #[test] + fn backend_parsing_matches_engine_main() { + // Default is opengl. + assert_eq!(parse_backend(&["oak-worker".to_string()]), "opengl"); + // Value lowercased. + assert_eq!( + parse_backend(&[ + "oak-worker".to_string(), + "--backend".to_string(), + "Vulkan".to_string() + ]), + "vulkan" + ); + // "none" skips renderer creation. + assert_eq!( + parse_backend(&[ + "oak-worker".to_string(), + "--backend".to_string(), + "none".to_string() + ]), + "none" + ); + // Last flag wins. + assert_eq!( + parse_backend(&[ + "oak-worker".to_string(), + "--backend".to_string(), + "vulkan".to_string(), + "--backend".to_string(), + "opengl".to_string() + ]), + "opengl" + ); + // Missing value leaves the default. + assert_eq!( + parse_backend(&["oak-worker".to_string(), "--backend".to_string()]), + "opengl" + ); + } } diff --git a/crates/oak-worker/src/session.rs b/crates/oak-worker/src/session.rs deleted file mode 100644 index f6e81a964..000000000 --- a/crates/oak-worker/src/session.rs +++ /dev/null @@ -1,527 +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 worker-side session state machine — the in-process mirror of -//! `OakWorkerSession` in `engine/src/capi/worker.cpp` (whose production -//! Rust port lives in the engine behind the `oakengine_worker_session_*` -//! C ABI). -//! -//! The session holds the attached shared-memory frame-slot pools -//! ([`crate::transport::AttachedPools`]) and the shutdown flag, and -//! answers one NDJSON control message at a time. Renderer creation and the -//! main loop are the engine's job (see `crate::main`); this module keeps -//! the message handling testable in-process against the engine's real -//! shared-memory transport ([`crate::engine_ipc`], consumed through the -//! `oakengine_ipc_*` C ABI). Where the C++ session has machinery the Rust -//! mirror lacks, the handler reproduces the *validation* faithfully and -//! then reports the documented stub ([`crate::transport`]) — it never -//! fakes a result. -//! -//! The production session is the engine's `OakWorkerSession`; this mirror -//! exists to keep the message handling testable in-process without a GPU -//! backend, so its public surface is exercised by the unit tests only. - -#![allow(dead_code)] - -use serde_json::Value; - -use crate::engine_ipc::{self as ipc, HandshakeMsg, LoadGraphMsg, RenderFrameMsg}; -use crate::transport::{self, AttachedPools}; - -/// Worker-side session: attached frame-slot pools + message-handling state. -pub struct WorkerSession { - /// Attached shared-memory frame-slot pools (output + optional input). - pools: Option, - shutdown_requested: bool, -} - -impl WorkerSession { - /// A fresh session with no attached pools. - pub fn new() -> WorkerSession { - WorkerSession { - pools: None, - shutdown_requested: false, - } - } - - /// 1 once the handshake has attached the shared-memory frame-slot - /// pools. - pub fn has_pools(&self) -> bool { - self.pools.is_some() - } - - /// 1 once a shutdown control message has been received. - pub fn shutdown_requested(&self) -> bool { - self.shutdown_requested - } - - /// The attached output pool (the worker->main frame-slot pool). - pub fn output_pool(&self) -> Option<&crate::engine_ipc::FrameSlotPool> { - self.pools.as_ref().map(|p| &p.output_pool) - } - - /// The startup handshake the worker sends to its parent - /// (`worker.cpp startup_handshake()`): protocol version 1 and empty - /// shared-memory geometry — the parent creates the segments and - /// announces their geometry in its handshake reply. - pub fn startup_handshake(&self) -> Value { - HandshakeMsg { - protocol_version: crate::PROTOCOL_VERSION, - shm_key: String::new(), - input_shm_key: String::new(), - input_slots: 0, - output_slots: 0, - slot_data_bytes: 0, - input_slot_data_bytes: 0, - } - .to_json() - } - - /// Handle one complete NDJSON control line and produce the response, if - /// any — the port of worker.cpp `handle()`. A malformed line yields an - /// error response (the loop continues), never a failure. - pub fn handle_line(&mut self, line: &str) -> Option { - let msg: Value = match serde_json::from_str::(line) { - Ok(v) if v.is_object() => v, - _ => return Some(ipc::error_message("malformed control message", None)), - }; - let typ = msg.get("type").and_then(Value::as_str).unwrap_or(""); - match typ { - ipc::TYPE_HANDSHAKE => self.handle_handshake(&msg), - ipc::TYPE_LOAD_GRAPH => self.handle_load_graph(&msg), - ipc::TYPE_RENDER_FRAME => self.handle_render_frame(&msg), - // cancel: the C++ worker does synchronous single-frame work - // (nothing in flight), so a cancel produces no response. - ipc::TYPE_CANCEL => None, - ipc::TYPE_SHUTDOWN => { - self.shutdown_requested = true; - None - } - other => Some(ipc::error_message( - &format!("unknown message type: {other}"), - None, - )), - } - } - - /// `handshake`: validate and attach the shared-memory frame-slot pools. - /// Validation mirrors worker.cpp `attach_output_pool()`; the attachment - /// itself goes through the real [`crate::transport`]. - fn handle_handshake(&mut self, msg: &Value) -> Option { - let hs: HandshakeMsg = match serde_json::from_value(msg.clone()) { - Ok(hs) => hs, - Err(_) => return Some(ipc::error_message("invalid handshake message", None)), - }; - if hs.protocol_version != crate::PROTOCOL_VERSION { - return Some(ipc::error_message( - &format!("unsupported protocol version {}", hs.protocol_version), - None, - )); - } - if hs.shm_key.is_empty() || hs.output_slots <= 0 || hs.slot_data_bytes <= 0 { - return Some(ipc::error_message( - "handshake missing output shared-memory geometry", - None, - )); - } - if hs.input_slots > 0 && (hs.input_shm_key.is_empty() || hs.input_slot_data_bytes <= 0) { - return Some(ipc::error_message( - "handshake missing input shared-memory geometry", - None, - )); - } - match transport::attach_pools(&hs) { - Ok(pools) => { - self.pools = Some(pools); - None - } - Err(msg) => Some(ipc::error_message(&msg, None)), - } - } - - /// `load_graph`: the file checks are real (mirror worker.cpp - /// `load_graph()`); the deserialization is the documented stub. - fn handle_load_graph(&mut self, msg: &Value) -> Option { - let load: LoadGraphMsg = match serde_json::from_value(msg.clone()) { - Ok(l) => l, - Err(_) => return Some(ipc::error_message("invalid load_graph message", None)), - }; - match std::fs::metadata(&load.path) { - Err(_) => Some(ipc::error_message( - &format!("graph file does not exist: {}", load.path), - None, - )), - Ok(md) if md.len() == 0 => Some(ipc::error_message( - &format!("graph file is empty: {}", load.path), - None, - )), - Ok(md) => { - crate::log_error(&format!( - "LoadGraph: loading {} ({} bytes)", - load.path, - md.len() - )); - Some(ipc::error_message(transport::GRAPH_STUB, None)) - } - } - } - - /// `render_frame`: the graph/render pipeline has no Rust backing, so a - /// render request is answered with a clear error carrying the ticket — - /// the same `error_message()` shape the C++ worker uses for its own - /// failures. - fn handle_render_frame(&mut self, msg: &Value) -> Option { - let render: RenderFrameMsg = match serde_json::from_value(msg.clone()) { - Ok(r) => r, - Err(_) => return Some(ipc::error_message("invalid render_frame message", None)), - }; - Some(ipc::error_message( - transport::RENDER_STUB, - Some(render.ticket), - )) - } -} - -impl Default for WorkerSession { - fn default() -> Self { - WorkerSession::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::engine_ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode}; - use serde_json::json; - - /// A unique, temporary POSIX segment key for a test. - fn test_key(name: &str) -> String { - static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - SharedMemoryRegion::make_key(i64::from(std::process::id()), (n & 0x7FFF) as i32) - + &format!("-t-{name}") - } - - /// The "parent" side of a handshake: create an output segment holding a - /// pool, optionally an input segment, and return the handshake message - /// plus the owner regions (kept alive by the caller). - fn parent_side( - slots: i32, - slot_bytes: i64, - input: bool, - ) -> (Value, SharedMemoryRegion, Option) { - let out_key = test_key("out"); - let out_bytes = FrameSlotPool::bytes_needed(slots as u32, slot_bytes as usize); - let mut out_region = SharedMemoryRegion::new(); - assert!( - out_region.open(&out_key, out_bytes, ShmMode::Create), - "{}", - out_region.error() - ); - // SAFETY: live mapping sized by bytes_needed. - let _ = - unsafe { FrameSlotPool::create(out_region.data(), slots as u32, slot_bytes as usize) }; - - let (in_key, in_bytes, in_region) = if input { - let in_key = test_key("in"); - let in_bytes = FrameSlotPool::bytes_needed(slots as u32, slot_bytes as usize); - let mut in_region = SharedMemoryRegion::new(); - assert!(in_region.open(&in_key, in_bytes, ShmMode::Create)); - // SAFETY: live mapping. - let _ = unsafe { - FrameSlotPool::create(in_region.data(), slots as u32, slot_bytes as usize) - }; - (Some(in_key), Some(in_bytes), Some(in_region)) - } else { - (None, None, None) - }; - - let hs = json!({ - "type": "handshake", - "protocol_version": crate::PROTOCOL_VERSION, - "shm_key": out_key, - "input_shm_key": in_key.unwrap_or_default(), - "input_slots": if input { slots } else { 0 }, - "output_slots": slots, - "slot_data_bytes": slot_bytes, - "input_slot_data_bytes": in_bytes.unwrap_or(0), - }); - (hs, out_region, in_region) - } - - #[test] - fn session_starts_without_pools() { - let s = WorkerSession::new(); - assert!(!s.has_pools()); - assert!(!s.shutdown_requested()); - } - - #[test] - fn startup_handshake_is_protocol_version_1_with_empty_geometry() { - let s = WorkerSession::new(); - let hs = s.startup_handshake(); - assert_eq!( - hs, - json!({ - "type": "handshake", - "protocol_version": 1, - "shm_key": "", - "input_shm_key": "", - "input_slots": 0, - "output_slots": 0, - "slot_data_bytes": 0, - "input_slot_data_bytes": 0, - }) - ); - } - - #[test] - fn malformed_line_yields_error_response() { - let mut s = WorkerSession::new(); - let resp = s.handle_line("this is not json").unwrap(); - assert_eq!(resp["type"], "error"); - assert_eq!(resp["message"], "malformed control message"); - } - - #[test] - fn non_object_json_yields_error_response() { - let mut s = WorkerSession::new(); - let resp = s.handle_line("[1,2,3]").unwrap(); - assert_eq!(resp["message"], "malformed control message"); - } - - #[test] - fn unknown_message_type_yields_error_response() { - let mut s = WorkerSession::new(); - let resp = s.handle_line(r#"{"type":"frobnicate"}"#).unwrap(); - assert_eq!(resp["type"], "error"); - assert_eq!(resp["message"], "unknown message type: frobnicate"); - } - - #[test] - fn missing_type_field_yields_unknown_error() { - let mut s = WorkerSession::new(); - let resp = s.handle_line(r#"{"hello":1}"#).unwrap(); - assert_eq!(resp["message"], "unknown message type: "); - } - - #[test] - fn cancel_produces_no_response() { - let mut s = WorkerSession::new(); - assert!(s.handle_line(r#"{"type":"cancel","ticket":5}"#).is_none()); - assert!(!s.shutdown_requested()); - } - - #[test] - fn shutdown_sets_flag_and_has_no_response() { - let mut s = WorkerSession::new(); - assert!(s.handle_line(r#"{"type":"shutdown"}"#).is_none()); - assert!(s.shutdown_requested()); - } - - #[test] - fn handshake_wrong_protocol_version() { - let mut s = WorkerSession::new(); - let resp = s - .handle_line(r#"{"type":"handshake","protocol_version":99,"shm_key":"k","output_slots":1,"slot_data_bytes":16}"#) - .unwrap(); - assert_eq!(resp["message"], "unsupported protocol version 99"); - } - - #[test] - fn handshake_missing_geometry() { - let mut s = WorkerSession::new(); - let resp = s - .handle_line(r#"{"type":"handshake","protocol_version":1}"#) - .unwrap(); - assert_eq!( - resp["message"], - "handshake missing output shared-memory geometry" - ); - } - - #[test] - fn handshake_missing_input_geometry_is_an_error() { - let mut s = WorkerSession::new(); - let (mut hs, _out, _in) = parent_side(2, 256, false); - // Ask for input slots without announcing their geometry. - hs["input_slots"] = json!(2); - let resp = s.handle_line(&hs.to_string()).unwrap(); - assert_eq!( - resp["message"], - "handshake missing input shared-memory geometry" - ); - } - - #[test] - fn handshake_attaches_real_output_pool() { - let mut s = WorkerSession::new(); - let (hs, out_region, _in) = parent_side(4, 4096, false); - let resp = s.handle_line(&hs.to_string()); - assert!(resp.is_none(), "unexpected error: {resp:?}"); - assert!(s.has_pools()); - let out_pool = s.output_pool().unwrap(); - assert_eq!(out_pool.slot_count(), 4); - assert_eq!(out_pool.slot_data_bytes(), 4096); - // The pool is real shared state: the parent's publish lands in the - // worker's ready ring. - // SAFETY: `out_region` is a live mapping of the pool the session - // attached to. - let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) }; - let mut slot = 0; - assert!(unsafe { parent_pool.acquire(&mut slot) }); - assert_eq!(slot, 0); - // SAFETY: acquired slot. - let meta = unsafe { &mut *parent_pool.meta(slot) }; - meta.id = 7; - assert!(unsafe { parent_pool.publish(slot) }); - let mut consumed = 0; - assert!(unsafe { out_pool.consume(&mut consumed) }); - assert_eq!(consumed, 0); - // SAFETY: consumed slot. - assert_eq!(unsafe { (*out_pool.meta_const(consumed)).id }, 7); - unsafe { out_pool.release(consumed) }; - } - - #[test] - fn handshake_attaches_input_pool_too() { - let mut s = WorkerSession::new(); - let (hs, _out, _in) = parent_side(2, 256, true); - let resp = s.handle_line(&hs.to_string()); - assert!(resp.is_none(), "unexpected error: {resp:?}"); - assert!(s.has_pools()); - let pools = s.pools.as_ref().unwrap(); - assert!(pools.input_pool.is_some()); - let in_pool = pools.input_pool.as_ref().unwrap(); - assert_eq!(in_pool.slot_count(), 2); - assert_eq!(in_pool.slot_data_bytes(), 256); - } - - #[test] - fn handshake_attach_failure_reports_error() { - let mut s = WorkerSession::new(); - // A key that was never created. - let resp = s - .handle_line( - &json!({ - "type": "handshake", - "protocol_version": 1, - "shm_key": format!("olive-rw-{}-missing", std::process::id()), - "output_slots": 4, - "slot_data_bytes": 4096, - }) - .to_string(), - ) - .unwrap(); - assert_eq!(resp["type"], "error"); - assert!(resp["message"] - .as_str() - .unwrap() - .starts_with("failed to attach shared memory: ")); - assert!(!s.has_pools()); - } - - #[test] - fn handshake_rejects_non_pool_segment() { - let mut s = WorkerSession::new(); - // A real segment of the right size that does not contain a pool - // (zeroed memory -> wrong magic). - let key = test_key("nopool"); - let bytes = FrameSlotPool::bytes_needed(4, 4096); - let mut region = SharedMemoryRegion::new(); - assert!(region.open(&key, bytes, ShmMode::Create)); - let resp = s - .handle_line( - &json!({ - "type": "handshake", - "protocol_version": 1, - "shm_key": key, - "output_slots": 4, - "slot_data_bytes": 4096, - }) - .to_string(), - ) - .unwrap(); - assert_eq!( - resp["message"], - "shared memory does not contain a frame slot pool" - ); - assert!(!s.has_pools()); - } - - #[test] - fn handshake_bad_json_shape_is_invalid_handshake() { - let mut s = WorkerSession::new(); - let resp = s - .handle_line(r#"{"type":"handshake","protocol_version":"x"}"#) - .unwrap(); - assert_eq!(resp["message"], "invalid handshake message"); - } - - #[test] - fn load_graph_file_checks_are_real_then_stub() { - let mut s = WorkerSession::new(); - - let missing = "/definitely/not/a/real/graph.ove"; - let resp = s - .handle_line(&json!({ "type": "load_graph", "path": missing }).to_string()) - .unwrap(); - assert_eq!( - resp["message"], - format!("graph file does not exist: {missing}") - ); - - let empty = std::env::temp_dir().join("oak_worker_test_empty_graph.ove"); - std::fs::write(&empty, b"").unwrap(); - let resp = s - .handle_line( - &json!({ "type": "load_graph", "path": empty.display().to_string() }).to_string(), - ) - .unwrap(); - assert_eq!( - resp["message"], - format!("graph file is empty: {}", empty.display()) - ); - let _ = std::fs::remove_file(&empty); - - let real = std::env::temp_dir().join("oak_worker_test_graph.ove"); - std::fs::write(&real, b"").unwrap(); - let resp = s - .handle_line( - &json!({ "type": "load_graph", "path": real.display().to_string() }).to_string(), - ) - .unwrap(); - assert!(resp["message"] - .as_str() - .unwrap() - .contains("node-graph deserialization is not yet available")); - let _ = std::fs::remove_file(&real); - } - - #[test] - fn render_frame_reports_stub_with_ticket() { - let mut s = WorkerSession::new(); - let resp = s - .handle_line(r#"{"type":"render_frame","ticket":123,"node":"abc"}"#) - .unwrap(); - assert_eq!(resp["type"], "error"); - assert_eq!(resp["ticket"], 123); - assert!(resp["message"] - .as_str() - .unwrap() - .contains("frame rendering is not yet available")); - } -} diff --git a/crates/oak-worker/src/transport.rs b/crates/oak-worker/src/transport.rs deleted file mode 100644 index 232319cb5..000000000 --- a/crates/oak-worker/src/transport.rs +++ /dev/null @@ -1,124 +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 . - -//! Shared-memory frame-slot transport. -//! -//! The C++ worker exchanges bulk pixel data with the editor through named -//! shared-memory segments holding an `olive::ipc::FrameSlotPool` — a fixed -//! pool of frame slots whose free/ready queues are synchronized by the -//! lock-free single-producer/single-consumer `SpscRingBuffer`. That -//! machinery is implemented in the engine facade (behind the -//! `oakengine_ipc_*` C ABI in `engine/include/oakengine/ipc.h`) — this -//! module is the worker-side transport over it, attached through -//! [`crate::engine_ipc`]'s C-ABI wrappers. -//! -//! [`attach_pools`] mirrors the C++ `attach_output_pool()`: attach the -//! output segment in [`ShmMode::Attach`], map the [`FrameSlotPool`] it -//! contains (rejecting segments without the pool magic), and attach the -//! input pool when the handshake announces one. The returned -//! [`AttachedPools`] is owned by the session and dropped (unmapped) with -//! it. -//! -//! The node-graph and render-pipeline stubs below carry the same rationale -//! as before: `oaknode` is a `todo!()` skeleton and the oakrender crate -//! does not yet evaluate an arbitrary loaded graph to a frame. -//! -//! The production attach path is the engine's `OakWorkerSession` -//! `handle_handshake`; this module is the transport surface the in-process -//! session mirror ([`crate::session`]) uses, exercised by the unit tests. - -#![allow(dead_code)] - -use crate::engine_ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode}; - -use crate::engine_ipc::HandshakeMsg; - -/// Why `load_graph` answers "not yet available" (after the real file checks). -pub const GRAPH_STUB: &str = "load_graph: node-graph deserialization is not yet available in the \ - Rust worker (the oaknode crate is a todo!() skeleton; see worker/rust/README.md)"; - -/// Why `render_frame` answers "not yet available". -pub const RENDER_STUB: &str = "render_frame: frame rendering is not yet available in the Rust \ - worker (no node-graph or render-pipeline backing; the shm frame-slot transport is \ - attached but there is no graph to render; see worker/rust/README.md)"; - -/// The shared-memory frame-slot pools attached by a successful handshake, -/// kept alive for the session's lifetime. -pub struct AttachedPools { - /// Worker->main output segment (unmapped on drop). - pub output_region: SharedMemoryRegion, - /// Output frame-slot pool view. - pub output_pool: FrameSlotPool, - /// Main->worker input segment, when the handshake announced one. - pub input_region: Option, - /// Input frame-slot pool view. - pub input_pool: Option, -} - -/// Attach the handshake's shared-memory frame-slot pools — the real port of -/// worker.cpp `attach_output_pool()`. -/// -/// The output segment must exist and contain a valid [`FrameSlotPool`] -/// (magic check); the input pool is attached when `input_slots > 0`. -/// Returns `Err(message)` describing the failure, matching the C++ error -/// strings. -pub fn attach_pools(hs: &HandshakeMsg) -> Result { - // Attach the worker->main output pool. - let bytes = FrameSlotPool::bytes_needed(hs.output_slots as u32, hs.slot_data_bytes as usize); - let mut output_region = SharedMemoryRegion::new(); - if !output_region.open(&hs.shm_key, bytes, ShmMode::Attach) { - return Err(format!( - "failed to attach shared memory: {}", - output_region.error() - )); - } - // SAFETY: `output_region` is a live mapping of at least `bytes` bytes - // (checked inside `open`). - let output_pool = unsafe { FrameSlotPool::attach(output_region.data()) }; - if !output_pool.is_valid() { - return Err("shared memory does not contain a frame slot pool".to_string()); - } - - // Attach the main->worker input pool when the handshake announced one. - let mut input_region = None; - let mut input_pool = None; - if hs.input_slots > 0 { - let input_bytes = - FrameSlotPool::bytes_needed(hs.input_slots as u32, hs.input_slot_data_bytes as usize); - let mut region = SharedMemoryRegion::new(); - if !region.open(&hs.input_shm_key, input_bytes, ShmMode::Attach) { - return Err(format!( - "failed to attach input shared memory: {}", - region.error() - )); - } - // SAFETY: `region` is a live mapping of at least `input_bytes` - // bytes (checked inside `open`). - let pool = unsafe { FrameSlotPool::attach(region.data()) }; - if !pool.is_valid() { - return Err("input shared memory does not contain a frame slot pool".to_string()); - } - input_region = Some(region); - input_pool = Some(pool); - } - - Ok(AttachedPools { - output_region, - output_pool, - input_region, - input_pool, - }) -} diff --git a/crates/oak-worker/src/worker.rs b/crates/oak-worker/src/worker.rs new file mode 100644 index 000000000..650ca3f6a --- /dev/null +++ b/crates/oak-worker/src/worker.rs @@ -0,0 +1,812 @@ +// 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 render worker runtime — the Rust port of +//! `engine/src/capi/worker.cpp`, owned by the oak-worker binary since +//! M14 R2 (the facade keeps its own copy for the frozen +//! `oakengine_worker_*` C ABI). +//! +//! - **Backend selection.** [`Renderer::create`] initializes the render +//! backend through the oakrender crate's direct Rust API +//! ([`oakrender::backend::DisplayRenderer`]), falling back to the +//! direct OpenGL renderer exactly like the C++ `create_renderer()` +//! chain. +//! - **The session.** [`WorkerSession`] holds the renderer, the +//! shared-memory frame-slot pools ([`crate::ipc::FrameSlotPool`]) and +//! the shutdown flag, and answers one NDJSON control message at a time. +//! - **The main loop.** [`worker_main`] creates the session, loads the +//! runtime config, writes the startup handshake, and serves the +//! stdin/stdout NDJSON loop until a `shutdown` message or EOF. +//! +//! The control-plane protocol is the same NDJSON the C++ worker speaks +//! (`engine/render/ipc/ipcmessage.cpp`): one compact JSON object per line, +//! `"type"`-dispatched ([`crate::ipc`]), with `handshake` carrying the +//! shared-memory geometry the worker attaches to via the real +//! [`crate::ipc`] transport. `load_graph`/`render_frame` reproduce the +//! C++ validation and then answer with the documented "not yet available" +//! errors (the oaknode graph crate is still a skeleton). + +use std::io::{self, BufRead, Write}; + +use serde_json::Value; + +use oakrender::backend::{BackendKind, DisplayRenderer}; + +use crate::ipc::{ + error_message, write_message, FrameSlotPool, HandshakeMsg, LoadGraphMsg, RenderFrameMsg, + SharedMemoryRegion, ShmMode, TYPE_CANCEL, TYPE_HANDSHAKE, TYPE_LOAD_GRAPH, TYPE_RENDER_FRAME, + TYPE_SHUTDOWN, +}; +use crate::{log_error, PROTOCOL_VERSION}; + +/// Why `load_graph` answers "not yet available" (after the real file checks). +const GRAPH_STUB: &str = "load_graph: node-graph deserialization is not yet available in the \ + Rust worker (the oaknode crate is a todo!() skeleton; see worker/rust/README.md)"; + +/// Why `render_frame` answers "not yet available". +const RENDER_STUB: &str = "render_frame: frame rendering is not yet available in the Rust \ + worker (no node-graph or render-pipeline backing; the shm frame-slot transport is \ + attached but there is no graph to render; see worker/rust/README.md)"; + +// --------------------------------------------------------------------------- +// Renderer (backend selection) +// --------------------------------------------------------------------------- + +/// Whether `backend` requests no renderer (worker.cpp +/// `backend_requests_no_renderer()`: NULL, "" and "none"). +pub fn is_no_backend(backend: &str) -> bool { + backend.is_empty() || backend.eq_ignore_ascii_case("none") +} + +/// A live, initialized oakrender display renderer (destroyed on drop). +pub struct Renderer { + /// The oakrender crate's value-typed display renderer (single-lib + /// unification; the CHandle-based C ABI is deleted). + inner: DisplayRenderer, +} + +impl Renderer { + /// Create and initialize a renderer through the oakrender crate's + /// direct Rust API, trying the named dynamic backend first and falling + /// back to the direct OpenGL renderer — the exact fallback chain of + /// worker.cpp `create_renderer()`. + pub fn create(backend: &str) -> Result { + match Self::create_dynamic(backend) { + Ok(r) => Ok(r), + Err(first) => { + log_error(&format!( + "failed to initialize dynamic {backend} backend: {first}; falling back to direct OpenGL renderer" + )); + Self::create_opengl().map_err(|second| { + format!("{first}; direct OpenGL fallback also failed: {second}") + }) + } + } + } + + /// Try the named dynamic backend (`DisplayRenderer::new` + + /// `init`, the single-lib equivalent of + /// `oakrender_display_renderer_create_dynamic` + `_init`). + fn create_dynamic(backend: &str) -> Result { + let renderer = DisplayRenderer::new(BackendKind::from_config_string(backend)); + Self::init_inner(renderer, &format!("dynamic {backend}")) + } + + /// Fall back to the direct OpenGL renderer. + fn create_opengl() -> Result { + let renderer = DisplayRenderer::new(BackendKind::Gl); + Self::init_inner(renderer, "direct OpenGL") + } + + /// Initialize a freshly created renderer. + fn init_inner(mut renderer: DisplayRenderer, what: &str) -> Result { + // NULL gl_context makes the backend use its default device/context + // path. + if let Err(e) = renderer.init(std::ptr::null_mut()) { + return Err(format!("failed to initialize {what} renderer ({e})")); + } + Ok(Renderer { inner: renderer }) + } + + /// 1 when the renderer is OpenGL-based (the C++ worker uses the GL + /// context to announce the negotiated GL version in the handshake). + /// + /// Not called yet: the oakrender module exposes no GL context + /// version, so the startup handshake omits `gl_major`/`gl_minor`. + #[allow(dead_code)] + pub fn is_open_gl(&self) -> bool { + self.inner.is_open_gl() + } +} + +// --------------------------------------------------------------------------- +// WorkerSession +// --------------------------------------------------------------------------- + +/// The worker-side session state machine — the Rust mirror of +/// `OakWorkerSession` in worker.cpp. Holds the renderer, the attached +/// shared-memory frame-slot pools and the shutdown flag, and answers one +/// NDJSON control message at a time. +pub struct WorkerSession { + renderer: Option, + shutdown_requested: bool, + runtime_initialized: bool, + output_region: Option, + output_pool: Option, + input_region: Option, + input_pool: Option, +} + +impl WorkerSession { + /// Create a session for `backend`, mirroring + /// `oakengine_worker_session_create()`: "none"/"" skips renderer + /// creation, anything else initializes the render backend through the + /// oakrender crate's direct Rust API (dynamic -> OpenGL fallback). + pub fn create(backend: &str) -> Result { + let renderer = if is_no_backend(backend) { + None + } else { + Some(Renderer::create(backend)?) + }; + Ok(WorkerSession { + renderer, + shutdown_requested: false, + runtime_initialized: false, + output_region: None, + output_pool: None, + input_region: None, + input_pool: None, + }) + } + + /// 1 when the session holds a successfully initialized render backend. + pub fn has_renderer(&self) -> bool { + self.renderer.is_some() + } + + /// 1 once a shutdown control message has been received. + pub fn shutdown_requested(&self) -> bool { + self.shutdown_requested + } + + /// Load the runtime services the session depends on — the Rust analog + /// of the C++ `initialize_runtime()`. Of the C++ list (config, node + /// factory, color manager, frame/disk managers, project serializer) + /// only the color-manager default config has a Rust backing linked into + /// the worker binary; the rest are logged and skipped. Always returns + /// true (the C++ returns true unconditionally). + pub fn initialize_runtime(&mut self) -> bool { + if self.runtime_initialized { + return true; + } + log_error("runtime: loading color-manager default config"); + if let Err(e) = oakrender::color::set_up_default_config() { + log_error(&format!( + "runtime: color-manager default config failed ({e}); continuing" + )); + } + log_error( + "runtime: config / node factory / frame manager / disk manager / project \ + serializer have no Rust backing in the worker binary; skipped", + ); + self.runtime_initialized = true; + true + } + + /// The startup handshake the worker sends to its parent + /// (`worker.cpp startup_handshake()`): protocol version 1 and empty + /// shared-memory geometry — the parent creates the segments and + /// announces their geometry in its handshake reply. + /// + /// Deviation from the C++: `gl_major`/`gl_minor` are omitted because + /// the oakrender module exposes no GL context version. + pub fn startup_handshake(&self) -> Value { + HandshakeMsg { + protocol_version: PROTOCOL_VERSION, + shm_key: String::new(), + input_shm_key: String::new(), + input_slots: 0, + output_slots: 0, + slot_data_bytes: 0, + input_slot_data_bytes: 0, + } + .to_json() + } + + /// Handle one complete NDJSON control line and produce the response, if + /// any — the port of worker.cpp `handle()`. A malformed line yields an + /// error response (the loop continues), never a failure. + pub fn handle_line(&mut self, line: &str) -> Option { + let msg: Value = match serde_json::from_str::(line) { + Ok(v) if v.is_object() => v, + _ => return Some(error_message("malformed control message", None)), + }; + let typ = msg.get("type").and_then(Value::as_str).unwrap_or(""); + match typ { + TYPE_HANDSHAKE => self.handle_handshake(&msg), + TYPE_LOAD_GRAPH => self.handle_load_graph(&msg), + TYPE_RENDER_FRAME => self.handle_render_frame(&msg), + // cancel: the worker does synchronous single-frame work + // (nothing in flight), so a cancel produces no response. + TYPE_CANCEL => None, + TYPE_SHUTDOWN => { + self.shutdown_requested = true; + None + } + other => Some(error_message( + &format!("unknown message type: {other}"), + None, + )), + } + } + + /// `handshake`: validate and attach the shared-memory frame-slot pools + /// — the real port of worker.cpp `attach_output_pool()`. + fn handle_handshake(&mut self, msg: &Value) -> Option { + let hs: HandshakeMsg = match serde_json::from_value(msg.clone()) { + Ok(hs) => hs, + Err(_) => return Some(error_message("invalid handshake message", None)), + }; + if hs.protocol_version != PROTOCOL_VERSION { + return Some(error_message( + &format!("unsupported protocol version {}", hs.protocol_version), + None, + )); + } + if hs.shm_key.is_empty() || hs.output_slots <= 0 || hs.slot_data_bytes <= 0 { + return Some(error_message( + "handshake missing output shared-memory geometry", + None, + )); + } + + // A re-handshake replaces the pools (worker.cpp resets the input + // pool before attaching the output). + self.input_pool = None; + self.input_region = None; + self.output_pool = None; + self.output_region = None; + + let bytes = + FrameSlotPool::bytes_needed(hs.output_slots as u32, hs.slot_data_bytes as usize); + let mut output_region = SharedMemoryRegion::new(); + if !output_region.open(&hs.shm_key, bytes, ShmMode::Attach) { + return Some(error_message( + &format!("failed to attach shared memory: {}", output_region.error()), + None, + )); + } + // SAFETY: `output_region` is a live mapping of at least `bytes` + // bytes (checked above). + let output_pool = unsafe { FrameSlotPool::attach(output_region.data()) }; + if !output_pool.is_valid() { + return Some(error_message( + "shared memory does not contain a frame slot pool", + None, + )); + } + self.output_region = Some(output_region); + self.output_pool = Some(output_pool); + + if hs.input_slots > 0 { + if hs.input_shm_key.is_empty() || hs.input_slot_data_bytes <= 0 { + return Some(error_message( + "handshake missing input shared-memory geometry", + None, + )); + } + let input_bytes = FrameSlotPool::bytes_needed( + hs.input_slots as u32, + hs.input_slot_data_bytes as usize, + ); + let mut input_region = SharedMemoryRegion::new(); + if !input_region.open(&hs.input_shm_key, input_bytes, ShmMode::Attach) { + return Some(error_message( + &format!( + "failed to attach input shared memory: {}", + input_region.error() + ), + None, + )); + } + // SAFETY: `input_region` is a live mapping of at least + // `input_bytes` bytes (checked above). + let input_pool = unsafe { FrameSlotPool::attach(input_region.data()) }; + if !input_pool.is_valid() { + return Some(error_message( + "input shared memory does not contain a frame slot pool", + None, + )); + } + self.input_region = Some(input_region); + self.input_pool = Some(input_pool); + } + + // Success: no response (worker.cpp leaves `response` untouched). + None + } + + /// `load_graph`: the file checks are real (mirror worker.cpp + /// `load_graph()`); the deserialization is the documented stub. + fn handle_load_graph(&mut self, msg: &Value) -> Option { + let load: LoadGraphMsg = match serde_json::from_value(msg.clone()) { + Ok(l) => l, + Err(_) => return Some(error_message("invalid load_graph message", None)), + }; + match std::fs::metadata(&load.path) { + Err(_) => Some(error_message( + &format!("graph file does not exist: {}", load.path), + None, + )), + Ok(md) if md.len() == 0 => Some(error_message( + &format!("graph file is empty: {}", load.path), + None, + )), + Ok(md) => { + log_error(&format!( + "LoadGraph: loading {} ({} bytes)", + load.path, + md.len() + )); + Some(error_message(GRAPH_STUB, None)) + } + } + } + + /// `render_frame`: the graph/render pipeline has no Rust backing, so a + /// render request is answered with a clear error carrying the ticket. + fn handle_render_frame(&mut self, msg: &Value) -> Option { + let render: RenderFrameMsg = match serde_json::from_value(msg.clone()) { + Ok(r) => r, + Err(_) => return Some(error_message("invalid render_frame message", None)), + }; + Some(error_message(RENDER_STUB, Some(render.ticket))) + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +/// Full render-worker main, transport-agnostic in the backend name. +/// +/// Mirrors `oakengine_worker_main()` in worker.cpp: create the session +/// (which initializes the render backend), load the runtime config, write +/// the startup handshake, then serve the NDJSON control loop on +/// stdin/stdout until a `shutdown` message or EOF. Returns the process +/// exit code. +pub fn worker_main(backend: &str) -> i32 { + // 1. Session creation initializes the render backend through the + // oakrender crate's direct Rust API + // (oakengine_worker_session_create()). + let mut session = match WorkerSession::create(backend) { + Ok(s) => s, + Err(msg) => { + log_error(&msg); + return 1; + } + }; + if !session.has_renderer() { + // Mirrors oakengine_worker_main(): without a renderer the worker + // cannot do anything, so it exits 1. ("--backend none" lands here.) + log_error("no renderer initialized"); + return 1; + } + + // 2. Runtime services (config load etc.). + if !session.initialize_runtime() { + return 1; + } + + // 3. Startup handshake before the loop (mirrors worker.cpp main). + let handshake = session.startup_handshake(); + let stdout = io::stdout(); + let mut out = io::BufWriter::new(stdout.lock()); + if let Err(e) = write_message(&mut out, &handshake) { + log_error(&format!("failed to write startup handshake: {e}")); + return 1; + } + if let Err(e) = out.flush() { + log_error(&format!("failed to flush startup handshake: {e}")); + return 1; + } + + // 4. NDJSON control loop until a shutdown message or EOF. + let stdin = io::stdin(); + let mut reader = stdin.lock(); + let mut line = String::new(); + let mut exit_code = 0; + while !session.shutdown_requested() { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => break, // EOF: the parent closed the control pipe. + Ok(_) => {} + Err(e) => { + log_error(&format!("failed to read control line: {e}")); + break; + } + } + if line.trim().is_empty() { + // Blank lines are skipped silently (read_message() semantics). + continue; + } + if let Some(response) = session.handle_line(&line) { + if let Err(e) = write_message(&mut out, &response) { + log_error(&format!("failed to write response: {e}")); + exit_code = 1; + break; + } + if let Err(e) = out.flush() { + log_error(&format!("failed to flush response: {e}")); + exit_code = 1; + break; + } + } + } + exit_code +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode}; + use serde_json::json; + use std::ptr; + + fn test_key(name: &str) -> String { + static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + SharedMemoryRegion::make_key(i64::from(std::process::id()), (n & 0x7FFF) as i32) + + &format!("-w-{name}") + } + + /// The "parent" side of a handshake: create an output segment holding a + /// pool, optionally an input segment, and return the handshake message + /// plus the owner regions (kept alive by the caller). + fn parent_side( + slots: i32, + slot_bytes: i64, + input: bool, + ) -> (Value, SharedMemoryRegion, Option) { + let out_key = test_key("out"); + let out_bytes = FrameSlotPool::bytes_needed(slots as u32, slot_bytes as usize); + let mut out_region = SharedMemoryRegion::new(); + assert!( + out_region.open(&out_key, out_bytes, ShmMode::Create), + "{}", + out_region.error() + ); + // SAFETY: live mapping sized by bytes_needed. + let _pool = + unsafe { FrameSlotPool::create(out_region.data(), slots as u32, slot_bytes as usize) }; + + let (in_key, in_bytes, in_region) = if input { + let in_key = test_key("in"); + let in_bytes = FrameSlotPool::bytes_needed(slots as u32, slot_bytes as usize); + let mut in_region = SharedMemoryRegion::new(); + assert!(in_region.open(&in_key, in_bytes, ShmMode::Create)); + // SAFETY: live mapping. + let _ = unsafe { + FrameSlotPool::create(in_region.data(), slots as u32, slot_bytes as usize) + }; + (Some(in_key), Some(in_bytes), Some(in_region)) + } else { + (None, None, None) + }; + + let hs = json!({ + "type": "handshake", + "protocol_version": PROTOCOL_VERSION, + "shm_key": out_key, + "input_shm_key": in_key.unwrap_or_default(), + "input_slots": if input { slots } else { 0 }, + "output_slots": slots, + "slot_data_bytes": slot_bytes, + "input_slot_data_bytes": in_bytes.unwrap_or(0), + }); + (hs, out_region, in_region) + } + + #[test] + fn no_backend_detection_matches_cpp() { + assert!(is_no_backend("")); + assert!(is_no_backend("none")); + assert!(is_no_backend("NONE")); + assert!(!is_no_backend("opengl")); + assert!(!is_no_backend("vulkan")); + } + + #[test] + fn none_backend_session_has_no_renderer_but_serves_messages() { + let mut s = WorkerSession::create("none").unwrap(); + assert!(!s.has_renderer()); + let resp = s.handle_line(r#"{"type":"shutdown"}"#); + assert!(resp.is_none()); + assert!(s.shutdown_requested()); + } + + #[test] + fn startup_handshake_is_protocol_version_1_with_empty_geometry() { + let s = WorkerSession::create("none").unwrap(); + let hs = s.startup_handshake(); + assert_eq!( + hs, + json!({ + "type": "handshake", + "protocol_version": 1, + "shm_key": "", + "input_shm_key": "", + "input_slots": 0, + "output_slots": 0, + "slot_data_bytes": 0, + "input_slot_data_bytes": 0, + }) + ); + } + + #[test] + fn malformed_line_yields_error_response() { + let mut s = WorkerSession::create("none").unwrap(); + let resp = s.handle_line("this is not json").unwrap(); + assert_eq!(resp["type"], "error"); + assert_eq!(resp["message"], "malformed control message"); + } + + #[test] + fn unknown_message_type_yields_error_response() { + let mut s = WorkerSession::create("none").unwrap(); + let resp = s.handle_line(r#"{"type":"frobnicate"}"#).unwrap(); + assert_eq!(resp["message"], "unknown message type: frobnicate"); + } + + #[test] + fn cancel_and_shutdown_produce_no_response() { + let mut s = WorkerSession::create("none").unwrap(); + assert!(s.handle_line(r#"{"type":"cancel","ticket":5}"#).is_none()); + assert!(s.handle_line(r#"{"type":"shutdown"}"#).is_none()); + assert!(s.shutdown_requested()); + } + + #[test] + fn handshake_wrong_protocol_version() { + let mut s = WorkerSession::create("none").unwrap(); + let resp = s + .handle_line( + r#"{"type":"handshake","protocol_version":99,"shm_key":"k","output_slots":1,"slot_data_bytes":16}"#, + ) + .unwrap(); + assert_eq!(resp["message"], "unsupported protocol version 99"); + } + + #[test] + fn handshake_missing_geometry() { + let mut s = WorkerSession::create("none").unwrap(); + let resp = s + .handle_line(r#"{"type":"handshake","protocol_version":1}"#) + .unwrap(); + assert_eq!( + resp["message"], + "handshake missing output shared-memory geometry" + ); + } + + #[test] + fn handshake_attaches_real_output_pool() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, out_region, _in) = parent_side(4, 4096, false); + let resp = s.handle_line(&hs.to_string()); + assert!(resp.is_none(), "unexpected error: {resp:?}"); + // The session now holds a real attached pool with the parent's + // geometry. + let out_pool = s.output_pool.as_ref().unwrap(); + assert_eq!(out_pool.slot_count(), 4); + assert_eq!(out_pool.slot_data_bytes(), 4096); + + // The two views share the same rings, not copies: the parent pops a + // free slot and the worker's pool sees the ring cursor move; the + // parent's publish lands in the worker's ready ring. + // SAFETY: `out_region` is a live mapping containing the pool the + // session attached to. + let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) }; + let mut parent_slot = 0; + assert!(unsafe { parent_pool.acquire(&mut parent_slot) }); + assert_eq!(parent_slot, 0); + let mut worker_slot = 0; + assert!(unsafe { out_pool.acquire(&mut worker_slot) }); + assert_eq!(worker_slot, 1, "worker must see the parent's free-ring pop"); + + // SAFETY: `parent_slot` was acquired by the parent; slot_bytes + // writable. + unsafe { + ptr::write_bytes(parent_pool.slot_data(parent_slot), 0xAB, 64); + } + assert!(unsafe { parent_pool.publish(parent_slot) }); + let mut consumed = 0; + assert!(unsafe { out_pool.consume(&mut consumed) }); + assert_eq!(consumed, parent_slot); + // SAFETY: `consumed` was consumed by the worker's pool. + assert_eq!(unsafe { *out_pool.slot_data_const(consumed) }, 0xAB); + // Clean up so the region drop at test end unlinks cleanly. + unsafe { out_pool.release(consumed) }; + unsafe { out_pool.release(worker_slot) }; + } + + #[test] + fn handshake_attaches_input_pool_too() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, _out, _in) = parent_side(2, 256, true); + let resp = s.handle_line(&hs.to_string()); + assert!(resp.is_none(), "unexpected error: {resp:?}"); + assert!(s.input_pool.is_some()); + let in_pool = s.input_pool.as_ref().unwrap(); + assert_eq!(in_pool.slot_count(), 2); + assert_eq!(in_pool.slot_data_bytes(), 256); + } + + #[test] + fn handshake_attach_failure_reports_error() { + let mut s = WorkerSession::create("none").unwrap(); + // A key that was never created. + let resp = s + .handle_line( + &json!({ + "type": "handshake", + "protocol_version": 1, + "shm_key": format!("olive-rw-{}-missing", std::process::id()), + "output_slots": 4, + "slot_data_bytes": 4096, + }) + .to_string(), + ) + .unwrap(); + assert_eq!(resp["type"], "error"); + assert!(resp["message"] + .as_str() + .unwrap() + .starts_with("failed to attach shared memory: ")); + assert!(s.output_pool.is_none()); + } + + #[test] + fn handshake_rejects_non_pool_segment() { + let mut s = WorkerSession::create("none").unwrap(); + // A real segment of the right size that does not contain a pool + // (zeroed memory → wrong magic). Sized so the attach size check + // passes and the magic check fires. + let key = test_key("nopool"); + let bytes = FrameSlotPool::bytes_needed(4, 4096); + let mut region = SharedMemoryRegion::new(); + assert!(region.open(&key, bytes, ShmMode::Create)); + let resp = s + .handle_line( + &json!({ + "type": "handshake", + "protocol_version": 1, + "shm_key": key, + "output_slots": 4, + "slot_data_bytes": 4096, + }) + .to_string(), + ) + .unwrap(); + assert_eq!( + resp["message"], + "shared memory does not contain a frame slot pool" + ); + } + + #[test] + fn handshake_missing_input_geometry_is_an_error() { + let mut s = WorkerSession::create("none").unwrap(); + let (mut hs, _out, _in) = parent_side(2, 256, false); + // Ask for input slots without announcing their geometry. + hs["input_slots"] = json!(2); + let resp = s.handle_line(&hs.to_string()).unwrap(); + assert_eq!( + resp["message"], + "handshake missing input shared-memory geometry" + ); + } + + #[test] + fn load_graph_checks_are_real_then_stub() { + let mut s = WorkerSession::create("none").unwrap(); + + let missing = "/definitely/not/a/real/graph.ove"; + let resp = s + .handle_line(&json!({ "type": "load_graph", "path": missing }).to_string()) + .unwrap(); + assert_eq!( + resp["message"], + format!("graph file does not exist: {missing}") + ); + + let empty = std::env::temp_dir().join("oak_worker_main_test_empty.ove"); + std::fs::write(&empty, b"").unwrap(); + let resp = s + .handle_line( + &json!({ "type": "load_graph", "path": empty.display().to_string() }).to_string(), + ) + .unwrap(); + assert_eq!( + resp["message"], + format!("graph file is empty: {}", empty.display()) + ); + let _ = std::fs::remove_file(&empty); + + let real = std::env::temp_dir().join("oak_worker_main_test_graph.ove"); + std::fs::write(&real, b"").unwrap(); + let resp = s + .handle_line( + &json!({ "type": "load_graph", "path": real.display().to_string() }).to_string(), + ) + .unwrap(); + assert!(resp["message"] + .as_str() + .unwrap() + .contains("node-graph deserialization is not yet available")); + let _ = std::fs::remove_file(&real); + } + + #[test] + fn render_frame_reports_stub_with_ticket() { + let mut s = WorkerSession::create("none").unwrap(); + let resp = s + .handle_line(r#"{"type":"render_frame","ticket":123,"node":"abc"}"#) + .unwrap(); + assert_eq!(resp["type"], "error"); + assert_eq!(resp["ticket"], 123); + assert!(resp["message"] + .as_str() + .unwrap() + .contains("frame rendering is not yet available")); + } + + // ---- oak-worker's in-process session tests (M14 R2: folded from the + // ---- former src/session.rs mirror; the facade's production session + // ---- tests above cover the rest) ------------------------------------- + + #[test] + fn session_starts_without_pools() { + let s = WorkerSession::create("none").unwrap(); + assert!(s.output_pool.is_none()); + assert!(s.input_pool.is_none()); + assert!(!s.shutdown_requested()); + } + + #[test] + fn non_object_json_yields_error_response() { + let mut s = WorkerSession::create("none").unwrap(); + let resp = s.handle_line("[1,2,3]").unwrap(); + assert_eq!(resp["message"], "malformed control message"); + } + + #[test] + fn missing_type_field_yields_unknown_error() { + let mut s = WorkerSession::create("none").unwrap(); + let resp = s.handle_line(r#"{"hello":1}"#).unwrap(); + assert_eq!(resp["message"], "unknown message type: "); + } + + #[test] + fn handshake_bad_json_shape_is_invalid_handshake() { + let mut s = WorkerSession::create("none").unwrap(); + let resp = s + .handle_line(r#"{"type":"handshake","protocol_version":"x"}"#) + .unwrap(); + assert_eq!(resp["message"], "invalid handshake message"); + } +}