refactor(cli,worker): cut liboakengine, link module rlibs directly (M14 R2)
- oak-cli: new engine.rs assembly layer maps every facade call to module Rust APIs (oaknode graph/serializer, oaktimeline commands, oakrender ticket arena, oaktask ExportTask, oakcommon config); the ffi/optional/host layers and build.rs link config are gone - oak-worker: the worker session + POSIX shm transport moved into the crate (oakrender backend + serde_json control plane); no dylib - both binaries carry zero liboakengine references (otool verified); tests green (30 cli / 41 worker)
This commit is contained in:
Generated
+9
@@ -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",
|
||||
]
|
||||
|
||||
+14
-11
@@ -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" }
|
||||
|
||||
+16
-38
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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/<profile>/`
|
||||
//! (un-hashed, unlike dependency artifacts). The profile dir is derived
|
||||
//! from `OUT_DIR` — `target/<profile>/build/oak-cli-<hash>/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-<hash> -> build -> <profile> (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");
|
||||
}
|
||||
}
|
||||
+113
-139
@@ -17,99 +17,71 @@
|
||||
//! `oak-cli info <project.ove>` — 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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+56
-132
@@ -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:
|
||||
//! <path>` 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<OakFootageVideoInfo> {
|
||||
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<OakFootageAudioInfo> {
|
||||
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
|
||||
}
|
||||
|
||||
+116
-249
@@ -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;
|
||||
}
|
||||
|
||||
+194
-375
@@ -16,47 +16,43 @@
|
||||
|
||||
//! `oak-cli transcode <input_media> <out> [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<String>, 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<Assembly, String> {
|
||||
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<SourceInfo, String> {
|
||||
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,
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<Mutex<Project>>;
|
||||
|
||||
/// 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<ProjectRef, String> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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::<SequenceBehavior>()
|
||||
}
|
||||
|
||||
/// Borrow the footage behavior at `id`.
|
||||
fn footage_behavior(g: &Graph, id: NodeId) -> Option<&FootageBehavior> {
|
||||
g.get(id)?
|
||||
.behavior
|
||||
.as_any()?
|
||||
.downcast_ref::<FootageBehavior>()
|
||||
}
|
||||
|
||||
/// 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::<SequenceBehavior>())
|
||||
{
|
||||
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::<TrackListBehavior>()
|
||||
}
|
||||
|
||||
/// Borrow the track behavior at `id`.
|
||||
fn track_behavior(g: &Graph, id: NodeId) -> Option<&TrackBehavior> {
|
||||
g.get(id)?
|
||||
.behavior
|
||||
.as_any()?
|
||||
.downcast_ref::<TrackBehavior>()
|
||||
}
|
||||
|
||||
/// 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<NodeId> {
|
||||
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::<TrackListBehavior>() {
|
||||
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::<SequenceBehavior>())
|
||||
{
|
||||
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::<TrackListBehavior>()
|
||||
}
|
||||
|
||||
/// 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<i32, String> {
|
||||
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::<FootageBehavior>()
|
||||
}
|
||||
|
||||
/// 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::<ClipBlockBehavior>()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<NodeId> {
|
||||
let mut frontier = vec![id];
|
||||
let mut visited: Vec<NodeId> = 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<MontageClip> {
|
||||
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::<ClipBlockBehavior>())
|
||||
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<MontageClip> {
|
||||
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::<ClipBlockBehavior>())
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
/// 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<MontageClip>,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Result<RenderedFrame, String> {
|
||||
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<f32>,
|
||||
/// 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<MontageClip>,
|
||||
) -> Result<RenderedAudio, String> {
|
||||
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())
|
||||
})
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<unsafe extern "C" fn(fraction: c_double, userdata: *mut c_void)>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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()
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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()
|
||||
}
|
||||
+10
-22
@@ -14,7 +14,7 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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 <project.ove>\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<String> = std::env::args().skip(1).collect();
|
||||
|
||||
// argv[1] handling that mirrors the C++ main() exactly.
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<unsafe extern "C" fn(c_double, *mut c_void)>,
|
||||
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<T: Copy>(name: &str) -> Option<T> {
|
||||
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<T: Copy>(_name: &str) -> Option<T> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Cached `oakengine_init` resolution.
|
||||
static INIT: OnceLock<Option<InitFn>> = OnceLock::new();
|
||||
/// Cached `oakengine_shutdown` resolution.
|
||||
static SHUTDOWN: OnceLock<Option<ShutdownFn>> = OnceLock::new();
|
||||
/// Cached `oakengine_export_render` resolution.
|
||||
static EXPORT_RENDER: OnceLock<Option<ExportRenderFn>> = OnceLock::new();
|
||||
/// Cached `oakengine_export_last_error` resolution.
|
||||
static EXPORT_LAST_ERROR: OnceLock<Option<ExportLastErrorFn>> = OnceLock::new();
|
||||
/// Cached `oakengine_export_set_progress_callback` resolution.
|
||||
static EXPORT_SET_PROGRESS: OnceLock<Option<ExportSetProgressFn>> = 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::<InitFn>("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::<ShutdownFn>("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<c_int> {
|
||||
let cell = EXPORT_RENDER.get_or_init(|| lookup::<ExportRenderFn>("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::<ExportLastErrorFn>("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<unsafe extern "C" fn(c_double, *mut c_void)>,
|
||||
userdata: *mut c_void,
|
||||
) {
|
||||
let cell = EXPORT_SET_PROGRESS
|
||||
.get_or_init(|| lookup::<ExportSetProgressFn>("oakengine_export_set_progress_callback"));
|
||||
if let Some(fn_) = *cell {
|
||||
unsafe { fn_(f, userdata) };
|
||||
}
|
||||
}
|
||||
+13
-15
@@ -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 <input_media> <out> [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");
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+21
-24
@@ -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 <name>`** (clap; default `opengl`; `none` skips
|
||||
1. **parse `--backend <name>`** (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"}'
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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
|
||||
//! `<target>/<profile>/build/oak-worker-<hash>/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());
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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-<pid>-<index>", 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<i32>,
|
||||
/// 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<i64>) -> 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-<pid>-<index>" (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) };
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <name>` (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<String> = std::env::args_os()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let cstrings: Vec<CString> = 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<String> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<AttachedPools>,
|
||||
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<Value> {
|
||||
let msg: Value = match serde_json::from_str::<Value>(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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<SharedMemoryRegion>) {
|
||||
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"<root/>").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"));
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<SharedMemoryRegion>,
|
||||
/// Input frame-slot pool view.
|
||||
pub input_pool: Option<FrameSlotPool>,
|
||||
}
|
||||
|
||||
/// 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<AttachedPools, String> {
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<Renderer, String> {
|
||||
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<Renderer, String> {
|
||||
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<Renderer, String> {
|
||||
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<Renderer, String> {
|
||||
// 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<Renderer>,
|
||||
shutdown_requested: bool,
|
||||
runtime_initialized: bool,
|
||||
output_region: Option<SharedMemoryRegion>,
|
||||
output_pool: Option<FrameSlotPool>,
|
||||
input_region: Option<SharedMemoryRegion>,
|
||||
input_pool: Option<FrameSlotPool>,
|
||||
}
|
||||
|
||||
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<WorkerSession, String> {
|
||||
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<Value> {
|
||||
let msg: Value = match serde_json::from_str::<Value>(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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<SharedMemoryRegion>) {
|
||||
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"<root/>").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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user