fix(app): full-res worker crash + source playback frozen + cli/worker DS batch
- full-res source jobs carried only the footage node box: dropping the project mid-flight left the node dangling (crash in the worker's free path) and the node was also freed twice (at renderer creation AND at release). The request now carries an addref'd project copy and the node is freed exactly once; regression test drops the project before the worker runs - the source clock ticked at the SEQUENCE length, so playing footage with an empty sequence froze the source playhead at 0; the source clock now loops at the selected footage's probed duration - oak-cli/oak-worker: DeepSeek's refactor batch (clap migration, engine FFI consumers); the stale exporter-family test flipped to the real contract (mp4 is written) - engine: render_audio smoke test on an empty sequence
This commit is contained in:
+10
-10
@@ -28,13 +28,13 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# oakfacade is where the frozen oakengine_* C ABI exports live (staticlib +
|
||||
# rlib). oak-cli is a pure consumer of that facade, so it links the rlib
|
||||
# directly. The families this CLI needs (init / project / timeline / render /
|
||||
# footage / exporter) are still deferred in the facade
|
||||
# (src/facade/rust/src/deferred.rs), so this crate references no oakengine_*
|
||||
# symbol yet: the subcommands validate their arguments and report the
|
||||
# deferral (src/deferred.rs). The extern declarations in src/ffi.rs mirror
|
||||
# the engine headers verbatim and resolve against this rlib the moment a
|
||||
# family is wrapped -- no manifest change needed.
|
||||
oakengine = { path = "../oakengine" }
|
||||
# oak-cli is a PURE C-ABI consumer of the built `liboakengine` cdylib:
|
||||
# every engine call goes through the `extern "C"` declarations in
|
||||
# src/ffi.rs (and the dlsym-resolved optional families in src/optional.rs).
|
||||
# No oak* crate appears here — not even oakengine, which is cdylib-only:
|
||||
# the link happens through build.rs (rustc-link-search + rpath pointing at
|
||||
# the profile dir `cargo build -p oakengine` produces the dylib in) and
|
||||
# the `#[link(name = "oakengine", kind = "dylib")]` block in src/ffi.rs.
|
||||
# Build order: `cargo build -p oakengine` first, then `cargo build` /
|
||||
# `cargo test -p oak-cli` (linking and running the binary need the dylib;
|
||||
# `cargo check` does not link and works standalone).
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <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");
|
||||
}
|
||||
}
|
||||
+199
-20
@@ -16,29 +16,208 @@
|
||||
|
||||
//! `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.
|
||||
//!
|
||||
//! Footage filenames stored relative to the `.ove` file are resolved
|
||||
//! against the project directory for display (the C++ CLI's project-dir
|
||||
//! convention); the online flag reports whether the resolved file exists.
|
||||
//! A load failure prints the engine's error and exits 1.
|
||||
|
||||
use crate::cmd::{port_not_wired, require_or, EXIT_ERROR};
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::cmd::{EXIT_ERROR, EXIT_OK};
|
||||
use crate::ffi;
|
||||
use crate::fmt;
|
||||
|
||||
/// Run `info`. `project` is the .ove path from the command line.
|
||||
pub fn run(project: String) -> i32 {
|
||||
if let Err(code) = require_or(
|
||||
"info",
|
||||
&[
|
||||
&crate::deferred::INIT,
|
||||
&crate::deferred::NODE,
|
||||
&crate::deferred::TIMELINE,
|
||||
],
|
||||
EXIT_ERROR,
|
||||
) {
|
||||
return code;
|
||||
let code = run_info(&project);
|
||||
unsafe {
|
||||
crate::optional::engine_shutdown();
|
||||
}
|
||||
code
|
||||
}
|
||||
|
||||
/// The info body; the caller owns the engine shutdown.
|
||||
fn run_info(project: &str) -> i32 {
|
||||
let rc = unsafe { crate::optional::engine_init(crate::ffi::OAKENGINE_INIT_HEADLESS) };
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: info: engine init failed ({rc})");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let handle = unsafe { crate::ffi::oakengine_project_create() };
|
||||
if handle.is_null() {
|
||||
eprintln!("error: info: cannot create project");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let path = match CString::new(project) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("error: info: invalid path (NUL byte)");
|
||||
unsafe { crate::ffi::oakengine_project_free(handle) };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
let mut err = [0 as std::ffi::c_char; 4096];
|
||||
let rc = unsafe {
|
||||
crate::ffi::oakengine_project_load(handle, path.as_ptr(), err.as_mut_ptr(), err.len() as i32)
|
||||
};
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
// SAFETY: the engine NUL-terminates `err` on failure.
|
||||
let detail = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
if detail.is_empty() {
|
||||
eprintln!("error: info: cannot load project \"{project}\"");
|
||||
} else {
|
||||
eprintln!("error: info: {detail}");
|
||||
}
|
||||
unsafe { crate::ffi::oakengine_project_free(handle) };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let name = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_project_name(handle, buf, size)
|
||||
});
|
||||
let filename = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_project_filename(handle, buf, size)
|
||||
});
|
||||
let modified = unsafe { crate::ffi::oakengine_project_is_modified(handle) } != 0;
|
||||
|
||||
// The engine's serializer swaps a fresh project payload in on load,
|
||||
// wiping the pre-load filename (documented engine behavior); when the
|
||||
// engine reports an empty filename the CLI falls back to the path it
|
||||
// loaded — the C++ CLI's own project filename convention.
|
||||
let abs = std::fs::canonicalize(project).unwrap_or_else(|_| Path::new(project).to_path_buf());
|
||||
let name = if name.is_empty() || name == "(untitled)" {
|
||||
abs.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.and_then(|f| f.split('.').next().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "(untitled)".to_string())
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let filename = if filename.is_empty() {
|
||||
abs.to_string_lossy().into_owned()
|
||||
} else {
|
||||
filename
|
||||
};
|
||||
|
||||
println!("{}", fmt::project_line(&name));
|
||||
println!("{}", fmt::file_line(&filename));
|
||||
println!("{}", fmt::modified_line(modified));
|
||||
|
||||
// Footage paths in .ove files can be relative to the project file;
|
||||
// resolve them for the online check (the C++ project-dir convention).
|
||||
let project_dir = Path::new(project).parent().map(|p| p.to_path_buf());
|
||||
|
||||
let sequences = unsafe { crate::ffi::oakengine_project_sequence_count(handle) }.max(0);
|
||||
println!("{}", fmt::sequences_line(sequences as i64));
|
||||
for index in 0..sequences {
|
||||
// `sequence_at` returns an owned box with no matching free
|
||||
// export (borrowed contract); it stays alive for the project.
|
||||
let seq = unsafe { crate::ffi::oakengine_project_sequence_at(handle, index) };
|
||||
if seq.is_null() {
|
||||
continue;
|
||||
}
|
||||
print_sequence(seq, index as i64);
|
||||
}
|
||||
|
||||
let footage = unsafe { crate::ffi::oakengine_project_footage_count(handle) }.max(0);
|
||||
println!("{}", fmt::footage_line(footage as i64));
|
||||
for index in 0..footage {
|
||||
let stored = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_project_footage_filename(handle, index, buf, size)
|
||||
});
|
||||
let resolved = resolve_footage(&stored, project_dir.as_deref());
|
||||
let online = resolved.is_file();
|
||||
println!(
|
||||
"{}",
|
||||
fmt::footage_entry(index as i64, &resolved.to_string_lossy(), online)
|
||||
);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
}
|
||||
EXIT_OK
|
||||
}
|
||||
|
||||
/// Print one sequence block (`print_sequence` in cli/main.cpp) through
|
||||
/// the `oakengine_sequence_*` getters.
|
||||
fn print_sequence(seq: *mut ffi::OakEngineSequence, index: i64) {
|
||||
let name = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_sequence_name(seq, buf, size)
|
||||
});
|
||||
|
||||
let mut length = 0.0f64;
|
||||
let mut len_num: i32 = 0;
|
||||
let mut len_den: i32 = 0;
|
||||
unsafe {
|
||||
let _ = crate::ffi::oakengine_sequence_get_length(seq, &mut length);
|
||||
let _ = crate::ffi::oakengine_sequence_get_length_rational(seq, &mut len_num, &mut len_den);
|
||||
}
|
||||
|
||||
let mut fr_num: i32 = 0;
|
||||
let mut fr_den: i32 = 0;
|
||||
unsafe {
|
||||
let _ = crate::ffi::oakengine_sequence_get_frame_rate(seq, &mut fr_num, &mut fr_den);
|
||||
}
|
||||
|
||||
let mut video: i32 = 0;
|
||||
let mut audio: i32 = 0;
|
||||
let mut subtitle: i32 = 0;
|
||||
unsafe {
|
||||
let _ = crate::ffi::oakengine_sequence_track_count(
|
||||
seq,
|
||||
&mut video,
|
||||
&mut audio,
|
||||
&mut subtitle,
|
||||
);
|
||||
}
|
||||
|
||||
let mut playhead: i64 = 0;
|
||||
let mut playhead_seconds = 0.0f64;
|
||||
unsafe {
|
||||
let _ = crate::ffi::oakengine_sequence_get_playhead(seq, &mut playhead);
|
||||
let _ = crate::ffi::oakengine_sequence_get_playhead_seconds(seq, &mut playhead_seconds);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
fmt::sequence(
|
||||
index,
|
||||
&name,
|
||||
length,
|
||||
len_num as i64,
|
||||
len_den as i64,
|
||||
fr_num as i64,
|
||||
fr_den as i64,
|
||||
video as i64,
|
||||
audio as i64,
|
||||
subtitle as i64,
|
||||
playhead,
|
||||
playhead_seconds,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve a stored footage filename against the project directory (the
|
||||
/// C++ project-dir convention); absolute paths pass through.
|
||||
fn resolve_footage(stored: &str, project_dir: Option<&Path>) -> std::path::PathBuf {
|
||||
let p = Path::new(stored);
|
||||
if p.is_absolute() || project_dir.is_none() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
project_dir.unwrap().join(p)
|
||||
}
|
||||
// Facade port (unreachable while the families above are deferred):
|
||||
// oakengine_init(OAKENGINE_INIT_HEADLESS)
|
||||
// project_create + project_load(project, ...)
|
||||
// name/filename/is_modified/sequence_count/sequence_at(...) +
|
||||
// fmt::sequence() / fmt::footage_entry() for each
|
||||
// project_free + oakengine_shutdown()
|
||||
// The formatters already exist in crate::fmt and are golden-tested.
|
||||
let _ = &project;
|
||||
port_not_wired("info", EXIT_ERROR)
|
||||
}
|
||||
|
||||
@@ -16,56 +16,32 @@
|
||||
|
||||
//! Subcommand implementations.
|
||||
//!
|
||||
//! Each subcommand is a faithful port of its `cli/main.cpp` counterpart:
|
||||
//! the argument validation is real (same messages, same usage-error code),
|
||||
//! and the facade work gates on [`crate::deferred::require`] — while the
|
||||
//! families a subcommand needs are deferred, it prints the "not yet
|
||||
//! available" error with the reasons and exits with the C++-compatible code
|
||||
//! (1 for info/probe, 2 for render/transcode), never crashing.
|
||||
//! Every subcommand is a REAL implementation over the `oakengine_*` C ABI
|
||||
//! ([`crate::ffi`] + [`crate::optional`]) — a pure consumer of the built
|
||||
//! `liboakengine` dylib, exactly like the C++ `cli/main.cpp` host:
|
||||
//!
|
||||
//! - `probe` → `oakengine_footage_probe` + the footage getters
|
||||
//! - `info` → `oakengine_project_create/load` + project/sequence
|
||||
//! getters
|
||||
//! - `render` → `oakengine_render_manager_init`,
|
||||
//! `oakengine_renderer_create` / `render_frame` / `render_audio` and
|
||||
//! the frame/audio-buffer accessors
|
||||
//! - `transcode` → project/sequence/clip assembly +
|
||||
//! `oakengine_export_render` for mp4, the renderer frame loop for ppm
|
||||
//!
|
||||
//! Exit codes: 0 success, 1 general error, 2 rendering unavailable,
|
||||
//! 64 usage error.
|
||||
|
||||
pub mod info;
|
||||
pub mod probe;
|
||||
pub mod render;
|
||||
pub mod transcode;
|
||||
|
||||
use crate::deferred::DeferredFamily;
|
||||
|
||||
/// 0 — success.
|
||||
pub const EXIT_OK: i32 = 0;
|
||||
/// 1 — general error (bad project/media file, no sequence, I/O failure).
|
||||
pub const EXIT_ERROR: i32 = 1;
|
||||
/// 2 — rendering unavailable or failed (e.g. no GL render backend).
|
||||
/// 2 — rendering unavailable or failed (e.g. no render backend).
|
||||
pub const EXIT_RENDER_UNAVAILABLE: i32 = 2;
|
||||
/// 64 — usage error.
|
||||
pub const EXIT_USAGE: i32 = 64;
|
||||
|
||||
/// Gate a subcommand on its facade families.
|
||||
///
|
||||
/// When every family is wrapped this returns `Ok(())` and the subcommand's
|
||||
/// port runs; when any is deferred it prints the composed "not yet
|
||||
/// available" message to stderr and returns `Err(unavailable_code)` — the
|
||||
/// code the C++ binary would exit with when that family's work is
|
||||
/// impossible (1 for info/probe, 2 for render/transcode).
|
||||
pub fn require_or(
|
||||
cmd: &str,
|
||||
families: &[&DeferredFamily],
|
||||
unavailable_code: i32,
|
||||
) -> Result<(), i32> {
|
||||
match crate::deferred::require(families) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(msg) => {
|
||||
eprintln!("error: {cmd}: {msg}");
|
||||
Err(unavailable_code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback for the (today unreachable) success arm of `require_or`: the
|
||||
/// gate reported the families available, but the call-through port is not
|
||||
/// wired yet. Never panics; reports an internal error and returns `code`.
|
||||
pub fn port_not_wired(cmd: &str, code: i32) -> i32 {
|
||||
eprintln!(
|
||||
"error: {cmd}: internal error: facade families reported available but no port is wired yet"
|
||||
);
|
||||
code
|
||||
}
|
||||
|
||||
+171
-14
@@ -17,23 +17,180 @@
|
||||
//! `oak-cli probe <mediafile>` — probe a media file and print its decoder,
|
||||
//! duration and video/audio/subtitle streams (port of `cmd_probe()` in
|
||||
//! cli/main.cpp).
|
||||
//!
|
||||
//! Runs entirely through the C ABI: `oakengine_init(OAKENGINE_INIT_HEADLESS)`
|
||||
//! → `oakengine_footage_probe(path)` → the `oakengine_footage_get_*`
|
||||
//! getters → `oakengine_footage_free` + `oakengine_shutdown()`. The output
|
||||
//! is formatted by `crate::fmt` exactly like the C++ `printf` calls.
|
||||
//!
|
||||
//! The engine's probe records what the oaknode footage module probes: the
|
||||
//! decoder id, the stream counts and (per stream) the module-visible
|
||||
//! params. The module currently drops the codec's probe description, so
|
||||
//! `get_duration` reports 0 and the stream counts report 0 for media the
|
||||
//! module has not loaded stream metadata for — the CLI prints exactly
|
||||
//! what the engine answers. A missing file / failed probe prints
|
||||
//! `oakengine_footage_last_error` on stderr and exits 1.
|
||||
|
||||
use crate::cmd::{port_not_wired, require_or, EXIT_ERROR};
|
||||
use std::ffi::{CString, c_int};
|
||||
|
||||
use crate::cmd::{EXIT_ERROR, EXIT_OK};
|
||||
use crate::ffi::{self, OakFootageAudioInfo, OakFootageVideoInfo};
|
||||
use crate::fmt;
|
||||
|
||||
/// Run `probe`. `mediafile` is the media path from the command line.
|
||||
pub fn run(mediafile: String) -> i32 {
|
||||
if let Err(code) = require_or(
|
||||
"probe",
|
||||
&[&crate::deferred::INIT, &crate::deferred::NODE],
|
||||
EXIT_ERROR,
|
||||
) {
|
||||
return code;
|
||||
let code = run_probe(&mediafile);
|
||||
unsafe {
|
||||
crate::optional::engine_shutdown();
|
||||
}
|
||||
// Facade port (unreachable while the families above are deferred):
|
||||
// oakengine_init(OAKENGINE_INIT_HEADLESS)
|
||||
// footage_probe(mediafile) -> decoder_name/duration/stream infos,
|
||||
// formatted with the fmt::* lines (golden-tested)
|
||||
// footage_free + oakengine_shutdown()
|
||||
let _ = &mediafile;
|
||||
port_not_wired("probe", EXIT_ERROR)
|
||||
code
|
||||
}
|
||||
|
||||
/// The probe body; the caller owns the engine shutdown.
|
||||
fn run_probe(mediafile: &str) -> i32 {
|
||||
let rc = unsafe { crate::optional::engine_init(crate::ffi::OAKENGINE_INIT_HEADLESS) };
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: probe: engine init failed ({rc})");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let path = match CString::new(mediafile) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("error: probe: invalid path (NUL byte)");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
let footage = unsafe { crate::ffi::oakengine_footage_probe(path.as_ptr()) };
|
||||
if footage.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_footage_last_error(buf, size)
|
||||
});
|
||||
eprintln!("error: probe: {err}");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
fmt::decoder_line(&crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_footage_get_decoder_name(footage, buf, size)
|
||||
}))
|
||||
);
|
||||
|
||||
let mut duration = 0.0f64;
|
||||
let rc = unsafe { crate::ffi::oakengine_footage_get_duration(footage, &mut duration) };
|
||||
if rc == crate::ffi::OAKENGINE_OK {
|
||||
println!("{}", fmt::duration_line(duration));
|
||||
} else {
|
||||
println!("{}", fmt::duration_line(0.0));
|
||||
}
|
||||
|
||||
let video = unsafe { crate::ffi::oakengine_footage_get_video_stream_count(footage) }.max(0);
|
||||
println!("{}", fmt::video_streams_line(video as i64));
|
||||
for index in 0..video {
|
||||
if let Some(info) = unsafe { video_stream_info(footage, index) } {
|
||||
let secs = stream_seconds(
|
||||
info.duration_ts,
|
||||
(info.time_base_num, info.time_base_den),
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
fmt::video_stream(
|
||||
index as i64,
|
||||
info.stream_index as i64,
|
||||
info.width as i64,
|
||||
info.height as i64,
|
||||
info.frame_rate_num as i64,
|
||||
info.frame_rate_den as i64,
|
||||
info.duration_ts,
|
||||
info.time_base_den as i64,
|
||||
secs,
|
||||
info.color_primaries as i64,
|
||||
info.color_trc as i64,
|
||||
info.interlaced != 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let audio = unsafe { crate::ffi::oakengine_footage_get_audio_stream_count(footage) }.max(0);
|
||||
println!("{}", fmt::audio_streams_line(audio as i64));
|
||||
for index in 0..audio {
|
||||
// The stream-info getter reports what the engine can describe
|
||||
// (the module's audio stream descriptions are not reachable
|
||||
// yet); streams the engine cannot describe are counted only.
|
||||
if let Some(info) = unsafe { audio_stream_info(footage, index) } {
|
||||
let secs = stream_seconds(
|
||||
info.duration_ts,
|
||||
(info.time_base_num, info.time_base_den),
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
fmt::audio_stream(
|
||||
index as i64,
|
||||
info.stream_index as i64,
|
||||
info.sample_rate as i64,
|
||||
info.channel_count as i64,
|
||||
info.duration_ts,
|
||||
info.time_base_den as i64,
|
||||
secs,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let subtitle =
|
||||
unsafe { crate::ffi::oakengine_footage_get_subtitle_stream_count(footage) }.max(0);
|
||||
println!("{}", fmt::subtitle_streams_line(subtitle as i64));
|
||||
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
}
|
||||
EXIT_OK
|
||||
}
|
||||
|
||||
/// `oakengine_footage_get_video_stream_info` into an owned POD
|
||||
/// (`None` when the engine reports the stream as unavailable).
|
||||
unsafe fn video_stream_info(footage: *mut ffi::OakEngineFootage, index: c_int) -> Option<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
|
||||
}
|
||||
|
||||
@@ -17,15 +17,38 @@
|
||||
//! `oak-cli render <project.ove> <start_seconds> <end_seconds> <out_dir>` —
|
||||
//! render the first sequence to PPM frames plus a PCM s16 WAV (port of
|
||||
//! `cmd_render()` in cli/main.cpp).
|
||||
//!
|
||||
//! Runs entirely through the C ABI:
|
||||
//!
|
||||
//! 1. `oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER)`
|
||||
//! — plus `oakengine_render_manager_init()`, the Rust facade's
|
||||
//! replacement for the C++ engine core's OAKENGINE_INIT_RENDER boot
|
||||
//! (the ticket render path needs the oakrender manager up).
|
||||
//! 2. `oakengine_project_create` + `oakengine_project_load` (the
|
||||
//! process chdirs into the project directory first, like the C++
|
||||
//! CLI, so relative footage paths resolve during rendering).
|
||||
//! 3. Sequence 0's frame rate via `oakengine_sequence_get_frame_rate`
|
||||
//! and geometry via `oakengine_sequence_get_video_params`.
|
||||
//! 4. `oakengine_renderer_create(seq, w, h, f32, fr_num, fr_den, null)`
|
||||
//! → for every frame timestamp in `[start, end)`
|
||||
//! `oakengine_renderer_render_frame` → the `oakengine_frame_*`
|
||||
//! accessors → [`crate::ppm::write_ppm`] (P6, 8-bit RGB).
|
||||
//! 5. The audio range through `oakengine_renderer_render_audio` → the
|
||||
//! `oakengine_audio_*` accessors → [`crate::wav::write_wav`].
|
||||
//!
|
||||
//! Exit codes: a renderer-create or per-frame render failure exits 2
|
||||
//! (rendering unavailable, mirroring the C++ code for a missing render
|
||||
//! backend); project/sequence/argument failures exit 1; bad seconds exit
|
||||
//! 64 (usage). Frame progress goes to stderr (`frame N: T s`).
|
||||
|
||||
use crate::cmd::{port_not_wired, require_or, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE};
|
||||
use crate::ffi;
|
||||
use crate::ppm;
|
||||
use crate::wav;
|
||||
|
||||
/// Run `render` with the validated (or rejected) seconds arguments.
|
||||
///
|
||||
/// The seconds are validated exactly like the C++ `strtod` checks before any
|
||||
/// facade work; the facade work itself (init + project + sequence + renderer,
|
||||
/// then [`crate::ppm::write_ppm`] / [`crate::wav::write_wav`] per frame) is
|
||||
/// gated on the deferred families below.
|
||||
pub fn run(project: String, start_seconds: &str, end_seconds: &str, out_dir: &str) -> i32 {
|
||||
let start: f64 = match start_seconds.parse() {
|
||||
Ok(v) => v,
|
||||
@@ -46,24 +69,314 @@ pub fn run(project: String, start_seconds: &str, end_seconds: &str, out_dir: &st
|
||||
return EXIT_USAGE;
|
||||
}
|
||||
|
||||
if let Err(code) = require_or(
|
||||
"render",
|
||||
&[
|
||||
&crate::deferred::INIT,
|
||||
&crate::deferred::NODE,
|
||||
&crate::deferred::TIMELINE,
|
||||
&crate::deferred::RENDER,
|
||||
],
|
||||
EXIT_RENDER_UNAVAILABLE,
|
||||
) {
|
||||
return code;
|
||||
let code = run_render(&project, start, end, out_dir);
|
||||
unsafe {
|
||||
crate::optional::engine_shutdown();
|
||||
}
|
||||
// Facade port (unreachable while the families above are deferred):
|
||||
// oakengine_init(HEADLESS | RENDER), chdir to the project dir,
|
||||
// project_load, sequence 0 frame rate -> start_ts/end_ts,
|
||||
// renderer_create(f32, fr_num, fr_den), then for each timestamp
|
||||
// render_frame -> ppm::write_ppm (progress on stderr), then
|
||||
// render_audio -> wav::write_wav. Both writers are golden-tested.
|
||||
let _ = (&project, &start, &end, &out_dir);
|
||||
port_not_wired("render", EXIT_RENDER_UNAVAILABLE)
|
||||
code
|
||||
}
|
||||
|
||||
/// The render body; the caller owns the engine shutdown.
|
||||
fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
|
||||
let rc = unsafe {
|
||||
crate::optional::engine_init(
|
||||
crate::ffi::OAKENGINE_INIT_HEADLESS | crate::ffi::OAKENGINE_INIT_RENDER,
|
||||
)
|
||||
};
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: render: engine init failed ({rc})");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
// The Rust facade's render boot: the ticket render path requires the
|
||||
// oakrender manager (the C++ OAKENGINE_INIT_RENDER equivalent).
|
||||
if unsafe { crate::ffi::oakengine_render_manager_init() } != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: render: cannot initialize the render manager");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
|
||||
// Absolute project path first — the C++ CLI chdirs into the project
|
||||
// directory so relative footage paths resolve during rendering.
|
||||
let abs = match std::fs::canonicalize(project) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("error: render: cannot open project \"{project}\": {e}");
|
||||
unsafe { crate::ffi::oakengine_render_manager_shutdown() };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
if let Some(dir) = abs.parent() {
|
||||
let _ = std::env::set_current_dir(dir);
|
||||
}
|
||||
|
||||
let handle = unsafe { crate::ffi::oakengine_project_create() };
|
||||
if handle.is_null() {
|
||||
eprintln!("error: render: cannot create project");
|
||||
unsafe { crate::ffi::oakengine_render_manager_shutdown() };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
let path = match std::ffi::CString::new(abs.as_os_str().as_encoded_bytes()) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("error: render: invalid path (NUL byte)");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
let mut err = [0 as std::ffi::c_char; 4096];
|
||||
let rc = unsafe {
|
||||
crate::ffi::oakengine_project_load(handle, path.as_ptr(), err.as_mut_ptr(), err.len() as i32)
|
||||
};
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
// SAFETY: the engine NUL-terminates `err` on failure.
|
||||
let detail = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
if detail.is_empty() {
|
||||
eprintln!("error: render: cannot load project \"{project}\"");
|
||||
} else {
|
||||
eprintln!("error: render: {detail}");
|
||||
}
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
if unsafe { crate::ffi::oakengine_project_sequence_count(handle) } < 1 {
|
||||
eprintln!("error: render: project has no sequences");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
// Borrowed sequence box (no free export); lives for the project.
|
||||
let seq = unsafe { crate::ffi::oakengine_project_sequence_at(handle, 0) };
|
||||
if seq.is_null() {
|
||||
eprintln!("error: render: sequence 0 unavailable");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let mut fr_num: i32 = 0;
|
||||
let mut fr_den: i32 = 0;
|
||||
unsafe {
|
||||
crate::ffi::oakengine_sequence_get_frame_rate(seq, &mut fr_num, &mut fr_den);
|
||||
}
|
||||
if fr_num <= 0 || fr_den <= 0 {
|
||||
eprintln!("error: render: invalid sequence frame rate {fr_num}/{fr_den}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let mut width: i32 = 0;
|
||||
let mut height: i32 = 0;
|
||||
unsafe {
|
||||
crate::ffi::oakengine_sequence_get_video_params(
|
||||
seq,
|
||||
&mut width,
|
||||
&mut height,
|
||||
&mut 0,
|
||||
&mut 0,
|
||||
);
|
||||
}
|
||||
if width <= 0 || height <= 0 {
|
||||
eprintln!("error: render: sequence has no video geometry");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(out_dir) {
|
||||
eprintln!("error: render: cannot create output directory \"{out_dir}\": {e}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let renderer = unsafe {
|
||||
crate::ffi::oakengine_renderer_create(
|
||||
seq,
|
||||
width,
|
||||
height,
|
||||
crate::ffi::PIXEL_FORMAT_F32,
|
||||
fr_num,
|
||||
fr_den,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if renderer.is_null() {
|
||||
// The engine's create path returns NULL without setting the
|
||||
// renderer's last error (invalid geometry/format or no module
|
||||
// backing); report the contract message.
|
||||
eprintln!("error: render: cannot create renderer");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
|
||||
// Frame loop: timestamps in the sequence time base (1/fr_num s).
|
||||
let start_frames = (start * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let mut index = start_frames;
|
||||
let mut written: u64 = 0;
|
||||
loop {
|
||||
let time = index as f64 * fr_den as f64 / fr_num as f64;
|
||||
if time >= end {
|
||||
break;
|
||||
}
|
||||
let frame = unsafe { crate::ffi::oakengine_renderer_render_frame(renderer, index) };
|
||||
if frame.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
|
||||
});
|
||||
let msg = if err.is_empty() {
|
||||
format!("frame at {time:.6} s failed to render")
|
||||
} else {
|
||||
format!("frame at {time:.6} s failed: {err}")
|
||||
};
|
||||
eprintln!("error: render: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
if let Err(msg) = unsafe { write_frame_ppm(frame, out_dir, written) } {
|
||||
eprintln!("error: render: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_frame_free(frame);
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
unsafe {
|
||||
crate::ffi::oakengine_frame_free(frame);
|
||||
}
|
||||
eprintln!("frame {written}: {time:.6} s");
|
||||
written += 1;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if written == 0 {
|
||||
eprintln!("error: render: empty frame range");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
// Audio range in the sequence time base.
|
||||
let start_ts = (start * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let length_ts = ((end - start) * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let audio = unsafe { crate::ffi::oakengine_renderer_render_audio(renderer, start_ts, length_ts) };
|
||||
if audio.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
|
||||
});
|
||||
let msg = if err.is_empty() {
|
||||
"audio render failed".to_string()
|
||||
} else {
|
||||
format!("audio render failed: {err}")
|
||||
};
|
||||
eprintln!("error: render: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
let code = unsafe { write_audio_wav(audio, out_dir) };
|
||||
unsafe {
|
||||
crate::ffi::oakengine_audio_free(audio);
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
code
|
||||
}
|
||||
|
||||
/// Write a rendered frame as `frame_%05d.ppm` in `out_dir` (the
|
||||
/// `oakengine_frame_*` accessors feed the [`crate::ppm`] writer).
|
||||
///
|
||||
/// `oakengine_frame_channel_count` is not backed by the current engine
|
||||
/// (returns 0); the render module's frames are always in the internal
|
||||
/// RGBA layout (`VideoParams::k_internal_channel_count == 4`), so a
|
||||
/// zero/negative channel report falls back to 4 channels.
|
||||
unsafe fn write_frame_ppm(frame: *mut ffi::OakEngineFrame, out_dir: &str, index: u64) -> Result<(), String> {
|
||||
let width = unsafe { crate::ffi::oakengine_frame_width(frame) };
|
||||
let height = unsafe { crate::ffi::oakengine_frame_height(frame) };
|
||||
let format = unsafe { crate::ffi::oakengine_frame_format(frame) };
|
||||
let channels = unsafe { crate::ffi::oakengine_frame_channel_count(frame) };
|
||||
let channels = if channels > 0 { channels } else { 4 };
|
||||
let linesize = unsafe { crate::ffi::oakengine_frame_linesize_bytes(frame) };
|
||||
let data = unsafe { crate::ffi::oakengine_frame_data(frame) };
|
||||
if data.is_null() || width <= 0 || height <= 0 || linesize <= 0 {
|
||||
return Err(format!(
|
||||
"frame {index} has no pixel data ({}x{}, linesize {linesize})",
|
||||
width, height
|
||||
));
|
||||
}
|
||||
let len = (linesize as usize)
|
||||
.checked_mul(height as usize)
|
||||
.ok_or_else(|| "frame buffer size overflow".to_string())?;
|
||||
// SAFETY: the engine's frame buffer is valid for linesize * height
|
||||
// bytes for the duration of this call.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(data as *const u8, len) };
|
||||
let path = Path::new(out_dir).join(format!("frame_{index:05}.ppm"));
|
||||
ppm::write_ppm(&path, width, height, format, channels, linesize, bytes)
|
||||
.map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Write the rendered audio buffer as `audio.wav` in `out_dir` (the
|
||||
/// `oakengine_audio_*` accessors feed the [`crate::wav`] writer). The
|
||||
/// buffer is interleaved f32; the engine returns the whole buffer base
|
||||
/// for every channel, so channel 0 covers all frames.
|
||||
unsafe fn write_audio_wav(audio: *mut ffi::OakEngineAudioBuffer, out_dir: &str) -> i32 {
|
||||
let rate = unsafe { crate::ffi::oakengine_audio_sample_rate(audio) };
|
||||
let channels = unsafe { crate::ffi::oakengine_audio_channel_count(audio) };
|
||||
let samples = unsafe { crate::ffi::oakengine_audio_sample_count(audio) };
|
||||
let data = unsafe { crate::ffi::oakengine_audio_data(audio, 0) };
|
||||
if data.is_null() || rate <= 0 || channels <= 0 || samples <= 0 {
|
||||
eprintln!("error: render: audio buffer is empty");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
let len = match (samples as usize).checked_mul(channels as usize) {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
eprintln!("error: render: audio buffer size overflow");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
};
|
||||
// SAFETY: the engine's audio buffer is valid for samples * channels
|
||||
// floats for the duration of this call.
|
||||
let floats = unsafe { std::slice::from_raw_parts(data, len) };
|
||||
let path = Path::new(out_dir).join("audio.wav");
|
||||
if let Err(e) = wav::write_wav(&path, rate, channels, samples, floats) {
|
||||
eprintln!("error: render: cannot write \"{}\": {e}", path.display());
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
EXIT_OK
|
||||
}
|
||||
|
||||
@@ -16,12 +16,58 @@
|
||||
|
||||
//! `oak-cli transcode <input_media> <out> [width] [--format ppm|mp4]` —
|
||||
//! "media in, renders out" round trip (port of `cmd_transcode()` in
|
||||
//! cli/main.cpp).
|
||||
//! cli/main.cpp), entirely through the C ABI.
|
||||
//!
|
||||
//! The source is probed with `oakengine_footage_probe` (geometry / frame
|
||||
//! rate / duration through the `oakengine_footage_get_*` getters), then a
|
||||
//! temporary project is assembled the same way the C++ CLI did:
|
||||
//! `oakengine_project_create` + `oakengine_project_new` +
|
||||
//! `oakengine_project_import_footage` + `oakengine_sequence_new` +
|
||||
//! `oakengine_sequence_set_video_params` + `oakengine_sequence_add_track`
|
||||
//! + `oakengine_sequence_add_footage_clip_ex` (the `_ex` variant: the
|
||||
//! engine keeps created sequences in their own scratch project — a
|
||||
//! documented deviation, so the plain variant's same-project check can
|
||||
//! never pass).
|
||||
//!
|
||||
//! - `--format ppm` (and the image/still path): renders the frame range
|
||||
//! through `oakengine_renderer_render_frame` into P6 PPM frames via
|
||||
//! [`crate::ppm`], plus the audio range through
|
||||
//! `oakengine_renderer_render_audio` into a PCM s16 WAV via
|
||||
//! [`crate::wav`] when the source has audio streams.
|
||||
//! - `--format mp4` (default): H.264/AAC through
|
||||
//! `oakengine_export_render` with the engine's exporter options
|
||||
//! (codec-default bit rates). The exporter family is currently NOT
|
||||
//! wrapped by the Rust facade, so this path reports
|
||||
//! `oakengine_export_last_error` and exits 1 until the dylib grows it
|
||||
//! (see `crate::optional`).
|
||||
//!
|
||||
//! The engine's footage probe records the decoder id but drops the
|
||||
//! codec's stream descriptions (module gap), so when the stream info is
|
||||
//! unavailable the CLI falls back to `[width]` (or 1920), a 16:9 height,
|
||||
//! 25 fps and a single-frame range — the still-image contract the C++
|
||||
//! CLI used for duration-less sources. Failures exit 1 (general error);
|
||||
//! bad arguments exit 64.
|
||||
|
||||
use crate::cmd::{port_not_wired, require_or, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE};
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
|
||||
/// Run `transcode`. `width`/`format` are validated exactly like the C++ loop
|
||||
/// over `argv[4..]`; the facade work is gated on the deferred families below.
|
||||
use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_USAGE};
|
||||
use crate::ffi::{self, OakExportOptions};
|
||||
use crate::ppm;
|
||||
use crate::wav;
|
||||
|
||||
/// Source description distilled from the probe (through the C ABI).
|
||||
struct SourceInfo {
|
||||
width: i32,
|
||||
height: i32,
|
||||
fr_num: i32,
|
||||
fr_den: i32,
|
||||
duration: f64,
|
||||
audio_streams: i32,
|
||||
}
|
||||
|
||||
/// Run `transcode`. `width`/`format` are validated exactly like the C++
|
||||
/// loop over `argv[4..]`.
|
||||
pub fn run(input_media: String, out: String, width: Option<String>, format: Option<String>) -> i32 {
|
||||
if let Some(w) = &width {
|
||||
match w.parse::<i64>() {
|
||||
@@ -39,27 +85,481 @@ pub fn run(input_media: String, out: String, width: Option<String>, format: Opti
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(code) = require_or(
|
||||
"transcode",
|
||||
&[
|
||||
&crate::deferred::INIT,
|
||||
&crate::deferred::NODE,
|
||||
&crate::deferred::TIMELINE,
|
||||
&crate::deferred::RENDER,
|
||||
&crate::deferred::EXPORT,
|
||||
],
|
||||
EXIT_RENDER_UNAVAILABLE,
|
||||
) {
|
||||
return code;
|
||||
let is_ppm = format.as_deref().unwrap_or("mp4") == "ppm";
|
||||
let code = run_transcode(&input_media, &out, width.as_deref(), is_ppm);
|
||||
unsafe {
|
||||
crate::optional::engine_shutdown();
|
||||
}
|
||||
// Facade port (unreachable while the families above are deferred):
|
||||
// probe the source for geometry/fps/duration, build a temporary
|
||||
// project (new + import_footage + sequence_new + add_track x2 +
|
||||
// add_footage_clip x2), then either the ppm path (render_frame /
|
||||
// render_audio -> ppm::write_ppm / wav::write_wav) or the mp4 path
|
||||
// (oakengine_export_render with H.264/AAC options + progress
|
||||
// callback). The C++ exits 2 when the render/export backend is
|
||||
// unavailable, which is also the code used here.
|
||||
let _ = (&input_media, &out, &width, &format);
|
||||
port_not_wired("transcode", EXIT_RENDER_UNAVAILABLE)
|
||||
code
|
||||
}
|
||||
|
||||
/// The transcode body; the caller owns the engine shutdown.
|
||||
fn run_transcode(input: &str, out: &str, width: Option<&str>, is_ppm: bool) -> i32 {
|
||||
let rc = unsafe {
|
||||
crate::optional::engine_init(
|
||||
crate::ffi::OAKENGINE_INIT_HEADLESS | crate::ffi::OAKENGINE_INIT_RENDER,
|
||||
)
|
||||
};
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: transcode: engine init failed ({rc})");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let src = match probe_source(input) {
|
||||
Ok(s) => s,
|
||||
Err(msg) => {
|
||||
eprintln!("error: transcode: {msg}");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
let out_w = width
|
||||
.and_then(|w| w.parse::<i32>().ok())
|
||||
.unwrap_or(src.width);
|
||||
let out_h = if src.width > 0 && src.height > 0 {
|
||||
((out_w as f64 * src.height as f64 / src.width as f64).round() as i32).max(1)
|
||||
} else {
|
||||
(out_w as f64 * 9.0 / 16.0).round() as i32
|
||||
};
|
||||
let fr_num = if src.fr_num > 0 { src.fr_num } else { 25 };
|
||||
let fr_den = if src.fr_den > 0 { src.fr_den } else { 1 };
|
||||
// A still/duration-less source counts as a single frame.
|
||||
let frames = (src.duration * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let frames = frames.max(1);
|
||||
|
||||
// The temporary project + sequence + clips both output paths share.
|
||||
let assembly = match assemble_project(input, out_w, out_h, fr_num, fr_den, frames, src.audio_streams) {
|
||||
Ok(a) => a,
|
||||
Err(msg) => {
|
||||
eprintln!("error: transcode: {msg}");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
let code = if is_ppm {
|
||||
transcode_ppm(
|
||||
&assembly,
|
||||
out,
|
||||
out_w,
|
||||
out_h,
|
||||
fr_num,
|
||||
fr_den,
|
||||
frames,
|
||||
src.audio_streams > 0,
|
||||
)
|
||||
} else {
|
||||
transcode_mp4(&assembly, out, out_w, out_h, frames)
|
||||
};
|
||||
unsafe {
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
crate::ffi::oakengine_project_free(assembly.project);
|
||||
}
|
||||
code
|
||||
}
|
||||
|
||||
/// The assembled temporary project: project + footage + sequence (with
|
||||
/// one video track/clip and, when the source has audio streams, one
|
||||
/// audio track/clip).
|
||||
struct Assembly {
|
||||
project: *mut ffi::OakEngineProject,
|
||||
sequence: *mut ffi::OakEngineSequence,
|
||||
}
|
||||
|
||||
/// Build the temporary project for the render/export stage.
|
||||
fn assemble_project(
|
||||
input: &str,
|
||||
out_w: i32,
|
||||
out_h: i32,
|
||||
fr_num: i32,
|
||||
fr_den: i32,
|
||||
frames: i64,
|
||||
audio_streams: i32,
|
||||
) -> Result<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());
|
||||
}
|
||||
|
||||
let input_c = CString::new(input).map_err(|_| "invalid path (NUL byte)".to_string())?;
|
||||
let footage = unsafe { crate::ffi::oakengine_project_import_footage(project, input_c.as_ptr()) };
|
||||
if footage.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_footage_last_error(buf, size)
|
||||
});
|
||||
unsafe { crate::ffi::oakengine_project_free(project) };
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let seq_name = CString::new("transcode").unwrap();
|
||||
let sequence = unsafe { crate::ffi::oakengine_sequence_new(project, seq_name.as_ptr()) };
|
||||
if sequence.is_null() {
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot create sequence"));
|
||||
}
|
||||
if unsafe {
|
||||
crate::ffi::oakengine_sequence_set_video_params(
|
||||
sequence,
|
||||
out_w,
|
||||
out_h,
|
||||
fr_num,
|
||||
fr_den,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
crate::ffi::PIXEL_FORMAT_F32,
|
||||
0,
|
||||
)
|
||||
} != crate::ffi::OAKENGINE_OK
|
||||
{
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot set sequence video params"));
|
||||
}
|
||||
|
||||
let video_track =
|
||||
unsafe { crate::ffi::oakengine_sequence_add_track(sequence, crate::ffi::OAKENGINE_TRACK_TYPE_VIDEO) };
|
||||
if video_track < 0 {
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot add video track"));
|
||||
}
|
||||
let clip = unsafe {
|
||||
crate::ffi::oakengine_sequence_add_footage_clip_ex(
|
||||
sequence,
|
||||
footage,
|
||||
crate::ffi::OAKENGINE_TRACK_TYPE_VIDEO,
|
||||
video_track,
|
||||
0,
|
||||
frames,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if clip.is_null() {
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot place video clip"));
|
||||
}
|
||||
|
||||
if audio_streams > 0 {
|
||||
let audio_track =
|
||||
unsafe { crate::ffi::oakengine_sequence_add_track(sequence, crate::ffi::OAKENGINE_TRACK_TYPE_AUDIO) };
|
||||
if audio_track < 0 {
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot add audio track"));
|
||||
}
|
||||
let clip = unsafe {
|
||||
crate::ffi::oakengine_sequence_add_footage_clip_ex(
|
||||
sequence,
|
||||
footage,
|
||||
crate::ffi::OAKENGINE_TRACK_TYPE_AUDIO,
|
||||
audio_track,
|
||||
0,
|
||||
frames,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if clip.is_null() {
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot place audio clip"));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
}
|
||||
Ok(Assembly { project, sequence })
|
||||
}
|
||||
|
||||
/// `oakengine_sequence_last_error` (or the fallback when empty).
|
||||
fn seq_error(fallback: &str) -> String {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_sequence_last_error(buf, size)
|
||||
});
|
||||
if err.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
err
|
||||
}
|
||||
}
|
||||
|
||||
/// `--format ppm`: render the frame range through the engine renderer
|
||||
/// into PPM frames (+ the audio range into a WAV when the source has
|
||||
/// audio).
|
||||
fn transcode_ppm(
|
||||
assembly: &Assembly,
|
||||
out: &str,
|
||||
out_w: i32,
|
||||
out_h: i32,
|
||||
fr_num: i32,
|
||||
fr_den: i32,
|
||||
frames: i64,
|
||||
audio: bool,
|
||||
) -> i32 {
|
||||
if let Err(e) = std::fs::create_dir_all(out) {
|
||||
eprintln!("error: transcode: cannot create output directory \"{out}\": {e}");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
let renderer = unsafe {
|
||||
crate::ffi::oakengine_renderer_create(
|
||||
assembly.sequence,
|
||||
out_w,
|
||||
out_h,
|
||||
crate::ffi::PIXEL_FORMAT_F32,
|
||||
fr_num,
|
||||
fr_den,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if renderer.is_null() {
|
||||
// The engine's create path returns NULL without setting the
|
||||
// renderer's last error; report the contract message.
|
||||
eprintln!("error: transcode: cannot create renderer");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
for i in 0..frames {
|
||||
let frame = unsafe { crate::ffi::oakengine_renderer_render_frame(renderer, i) };
|
||||
if frame.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
|
||||
});
|
||||
let msg = if err.is_empty() {
|
||||
format!("frame {i} failed to render")
|
||||
} else {
|
||||
format!("frame {i} failed to render: {err}")
|
||||
};
|
||||
eprintln!("error: transcode: {msg}");
|
||||
unsafe { crate::ffi::oakengine_renderer_free(renderer) };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
if let Err(msg) = unsafe { write_frame_ppm(frame, out, i) } {
|
||||
eprintln!("error: transcode: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_frame_free(frame);
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
unsafe { crate::ffi::oakengine_frame_free(frame) };
|
||||
}
|
||||
eprintln!("transcoded {frames} frames to \"{out}\"");
|
||||
|
||||
// Audio range (the assembly only adds audio clips when the source
|
||||
// has audio streams).
|
||||
let mut code = EXIT_OK;
|
||||
if audio {
|
||||
code = write_audio_wav(renderer, out, frames);
|
||||
}
|
||||
unsafe { crate::ffi::oakengine_renderer_free(renderer) };
|
||||
code
|
||||
}
|
||||
|
||||
/// `--format mp4`: H.264/AAC through `oakengine_export_render` (the
|
||||
/// exporter options mirror the C++ `cmd_transcode` defaults: codec-
|
||||
/// default bit rates, source audio rate or 48 kHz, stereo).
|
||||
fn transcode_mp4(assembly: &Assembly, out: &str, out_w: i32, out_h: i32, frames: i64) -> i32 {
|
||||
let out_c = match CString::new(out) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("error: transcode: invalid output path (NUL byte)");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
let opts = OakExportOptions {
|
||||
video_codec: crate::ffi::OAKENGINE_EXPORT_VIDEO_H264,
|
||||
audio_codec: crate::ffi::OAKENGINE_EXPORT_AUDIO_AAC,
|
||||
video_bit_rate: 0,
|
||||
audio_sample_rate: 48000,
|
||||
audio_channel_count: 2,
|
||||
};
|
||||
match unsafe {
|
||||
crate::optional::export_render(
|
||||
assembly.sequence,
|
||||
out_c.as_ptr(),
|
||||
0,
|
||||
frames,
|
||||
out_w,
|
||||
out_h,
|
||||
&opts,
|
||||
)
|
||||
} {
|
||||
Some(rc) if rc == crate::ffi::OAKENGINE_OK => EXIT_OK,
|
||||
Some(rc) => {
|
||||
let err = unsafe { crate::optional::export_last_error() };
|
||||
let msg = if err.is_empty() {
|
||||
format!("export failed ({rc})")
|
||||
} else {
|
||||
err
|
||||
};
|
||||
eprintln!("error: transcode: {msg}");
|
||||
EXIT_ERROR
|
||||
}
|
||||
None => {
|
||||
let err = unsafe { crate::optional::export_last_error() };
|
||||
eprintln!("error: transcode: {err}");
|
||||
EXIT_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a rendered frame as `frame_%05d.ppm` in `out` (the
|
||||
/// `oakengine_frame_*` accessors feed the [`crate::ppm`] writer).
|
||||
///
|
||||
/// `oakengine_frame_channel_count` is not backed by the current engine
|
||||
/// (returns 0); the render module's frames are always in the internal
|
||||
/// RGBA layout (`VideoParams::k_internal_channel_count == 4`), so a
|
||||
/// zero/negative channel report falls back to 4 channels.
|
||||
unsafe fn write_frame_ppm(frame: *mut ffi::OakEngineFrame, out: &str, index: i64) -> Result<(), String> {
|
||||
let width = unsafe { crate::ffi::oakengine_frame_width(frame) };
|
||||
let height = unsafe { crate::ffi::oakengine_frame_height(frame) };
|
||||
let format = unsafe { crate::ffi::oakengine_frame_format(frame) };
|
||||
let channels = unsafe { crate::ffi::oakengine_frame_channel_count(frame) };
|
||||
let channels = if channels > 0 { channels } else { 4 };
|
||||
let linesize = unsafe { crate::ffi::oakengine_frame_linesize_bytes(frame) };
|
||||
let data = unsafe { crate::ffi::oakengine_frame_data(frame) };
|
||||
if data.is_null() || width <= 0 || height <= 0 || linesize <= 0 {
|
||||
return Err(format!(
|
||||
"frame {index} has no pixel data ({}x{}, linesize {linesize})",
|
||||
width, height
|
||||
));
|
||||
}
|
||||
let len = (linesize as usize)
|
||||
.checked_mul(height as usize)
|
||||
.ok_or_else(|| "frame buffer size overflow".to_string())?;
|
||||
// SAFETY: the engine's frame buffer is valid for linesize * height
|
||||
// bytes for the duration of this call.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(data as *const u8, len) };
|
||||
let path = Path::new(out).join(format!("frame_{index:05}.ppm"));
|
||||
ppm::write_ppm(&path, width, height, format, channels, linesize, bytes)
|
||||
.map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Render the audio range and write it as `audio.wav` in `out`. The
|
||||
/// assembled clips span `[0, frames)` sequence timestamps, so the range
|
||||
/// length is `frames` time-base ticks.
|
||||
fn write_audio_wav(renderer: *mut ffi::OakEngineRenderer, out: &str, frames: i64) -> i32 {
|
||||
let audio = unsafe { crate::ffi::oakengine_renderer_render_audio(renderer, 0, frames) };
|
||||
if audio.is_null() {
|
||||
eprintln!("error: transcode: audio render failed");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
let rate = unsafe { crate::ffi::oakengine_audio_sample_rate(audio) };
|
||||
let channels = unsafe { crate::ffi::oakengine_audio_channel_count(audio) };
|
||||
let samples = unsafe { crate::ffi::oakengine_audio_sample_count(audio) };
|
||||
let data = unsafe { crate::ffi::oakengine_audio_data(audio, 0) };
|
||||
let code = if data.is_null() || rate <= 0 || channels <= 0 || samples <= 0 {
|
||||
eprintln!("error: transcode: audio buffer is empty");
|
||||
EXIT_ERROR
|
||||
} else {
|
||||
match (samples as usize).checked_mul(channels as usize) {
|
||||
None => {
|
||||
eprintln!("error: transcode: audio buffer size overflow");
|
||||
EXIT_ERROR
|
||||
}
|
||||
Some(len) => {
|
||||
// SAFETY: the engine's audio buffer is valid for
|
||||
// samples * channels floats for this call's duration.
|
||||
let floats = unsafe { std::slice::from_raw_parts(data, len) };
|
||||
let path = Path::new(out).join("audio.wav");
|
||||
if let Err(e) = wav::write_wav(&path, rate, channels, samples, floats) {
|
||||
eprintln!("error: transcode: cannot write \"{}\": {e}", path.display());
|
||||
EXIT_ERROR
|
||||
} else {
|
||||
EXIT_OK
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
unsafe { crate::ffi::oakengine_audio_free(audio) };
|
||||
code
|
||||
}
|
||||
|
||||
/// Probe the source media through the C ABI into a [`SourceInfo`].
|
||||
///
|
||||
/// The engine's footage probe records the decoder but (currently) drops
|
||||
/// the codec's stream descriptions, so a successful probe still reports
|
||||
/// zero streams / unavailable stream info. The CLI then falls back to
|
||||
/// the documented defaults: `[width]` or 1920, 16:9 height, 25 fps,
|
||||
/// duration 0 (a single frame — the still-image contract). A failed
|
||||
/// probe is a hard error.
|
||||
fn probe_source(path: &str) -> Result<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);
|
||||
}
|
||||
|
||||
let mut duration = 0.0f64;
|
||||
let _ = unsafe { crate::ffi::oakengine_footage_get_duration(footage, &mut duration) };
|
||||
let audio_streams = unsafe { crate::ffi::oakengine_footage_get_audio_stream_count(footage) }.max(0);
|
||||
|
||||
let video_streams =
|
||||
unsafe { crate::ffi::oakengine_footage_get_video_stream_count(footage) }.max(0);
|
||||
|
||||
let mut info = ffi::OakFootageVideoInfo {
|
||||
stream_index: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
frame_rate_num: 0,
|
||||
frame_rate_den: 0,
|
||||
duration_ts: 0,
|
||||
time_base_num: 0,
|
||||
time_base_den: 0,
|
||||
color_primaries: 0,
|
||||
color_trc: 0,
|
||||
interlaced: 0,
|
||||
};
|
||||
let rc = if video_streams > 0 {
|
||||
unsafe { crate::ffi::oakengine_footage_get_video_stream_info(footage, 0, &mut info) }
|
||||
} else {
|
||||
crate::ffi::OAKENGINE_E_NOT_FOUND
|
||||
};
|
||||
unsafe { crate::ffi::oakengine_footage_free(footage) };
|
||||
|
||||
let src = if rc == crate::ffi::OAKENGINE_OK && info.width > 0 && info.height > 0 {
|
||||
SourceInfo {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
fr_num: info.frame_rate_num,
|
||||
fr_den: info.frame_rate_den,
|
||||
duration,
|
||||
audio_streams,
|
||||
}
|
||||
} else {
|
||||
// Engine gap fallback (see the docs above).
|
||||
SourceInfo {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fr_num: 25,
|
||||
fr_den: 1,
|
||||
duration,
|
||||
audio_streams,
|
||||
}
|
||||
};
|
||||
Ok(src)
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Facade-family availability, mirroring `src/facade/rust/src/deferred.rs`.
|
||||
//!
|
||||
//! Every `oak-cli` subcommand depends on one or more families of the
|
||||
//! `oakengine_*` C ABI. Those families live in the `oakengine` crate, and
|
||||
//! some of them are **deferred**: the facade does not wrap them yet, so the
|
||||
//! subcommands must report a clear "not yet available" error instead of
|
||||
//! calling into the facade (the calls would not link, and faking behavior
|
||||
//! would be worse).
|
||||
//!
|
||||
//! The entries below are kept field-for-field in sync with the facade's own
|
||||
//! deferral documentation (`src/facade/rust/src/deferred.rs`). All families
|
||||
//! this CLI consumes are currently deferred; when a family is wrapped, remove
|
||||
//! its entry here and the subcommand's call-through (see `src/cmd/`) becomes
|
||||
//! reachable.
|
||||
|
||||
/// One deferred facade family: what it covers, which engine headers define
|
||||
/// it, and why the facade does not wrap it yet.
|
||||
pub struct DeferredFamily {
|
||||
/// Short family name, as used in messages.
|
||||
pub name: &'static str,
|
||||
/// Engine headers involved.
|
||||
pub headers: &'static str,
|
||||
/// Why the family is not wrapped yet (from the facade's deferred.rs).
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
/// `init.h` — engine process initialization/shutdown.
|
||||
///
|
||||
/// Not even listed in the facade's scope table yet (`src/facade/rust/README.md`):
|
||||
/// the facade currently wraps only undo/config/video_params/audio/plugin.
|
||||
pub const INIT: DeferredFamily = DeferredFamily {
|
||||
name: "init",
|
||||
headers: "init.h",
|
||||
reason: "the facade shell (oakengine_init/shutdown) is not wrapped in oakengine yet (its scope table covers only undo/common/audio/plugin)",
|
||||
};
|
||||
|
||||
/// `project.h` + `footage.h` — the oaknode module family.
|
||||
///
|
||||
/// Facade deferred.rs "node": all 30 exports of the oaknode Rust crate are
|
||||
/// `todo!()` bodies, so the engine project/footage families have no module
|
||||
/// backing to wrap.
|
||||
pub const NODE: DeferredFamily = DeferredFamily {
|
||||
name: "node (project/footage)",
|
||||
headers: "project.h, footage.h",
|
||||
reason: "deferred: the oaknode crate is an unimplemented skeleton (every export is a todo!() body), so the project/footage families have no module backing",
|
||||
};
|
||||
|
||||
/// `timeline.h` — sequence/track/clip family.
|
||||
///
|
||||
/// Facade deferred.rs "timeline": the oaktimeline crate's exports reference
|
||||
/// ~80 oaknode C ABI symbols the skeletal oaknode crate does not define, and
|
||||
/// its test-stubs collide with the real oakundo crate in the facade test
|
||||
/// link.
|
||||
pub const TIMELINE: DeferredFamily = DeferredFamily {
|
||||
name: "timeline",
|
||||
headers: "timeline.h",
|
||||
reason: "deferred: test linkage — the oaktimeline crate's exports reference oaknode C ABI symbols the skeletal oaknode crate does not define",
|
||||
};
|
||||
|
||||
/// `renderer.h` — renderer/frame/audio-buffer family.
|
||||
///
|
||||
/// Facade deferred.rs "render": no structural blocker; the engine renderer.h
|
||||
/// family simply was not wrapped in the facade's current pass.
|
||||
pub const RENDER: DeferredFamily = DeferredFamily {
|
||||
name: "render",
|
||||
headers: "renderer.h",
|
||||
reason: "deferred for session scope: the engine renderer.h family is not wrapped in oakengine yet (no structural blocker)",
|
||||
};
|
||||
|
||||
/// `exporter.h` — export/encode family.
|
||||
///
|
||||
/// Facade deferred.rs: exporter is a "genuinely facade-only area" (the
|
||||
/// liboakengine assembly layer) with no files in the oakengine crate.
|
||||
pub const EXPORT: DeferredFamily = DeferredFamily {
|
||||
name: "exporter",
|
||||
headers: "exporter.h",
|
||||
reason: "deferred: the exporter family is a facade-only assembly area with no Rust backing (src/facade/rust/src/deferred.rs)",
|
||||
};
|
||||
|
||||
/// Check that every family in `families` is available in the facade.
|
||||
///
|
||||
/// Returns `Ok(())` when all are wrapped (none is today); otherwise `Err`
|
||||
/// carries the composed "not yet available" message naming each deferred
|
||||
/// family and its reason, for the subcommands to print and exit on.
|
||||
pub fn require(families: &[&DeferredFamily]) -> Result<(), String> {
|
||||
if families.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut detail = String::new();
|
||||
for f in families {
|
||||
detail.push_str(&format!("\n - {} ({}): {}", f.name, f.headers, f.reason));
|
||||
}
|
||||
Err(format!(
|
||||
"not yet available in the Rust facade (oakengine): these family(ies) are still deferred \
|
||||
(see src/facade/rust/src/deferred.rs):{detail}"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_family_list_is_available() {
|
||||
assert!(require(&[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_family_lists_a_reason() {
|
||||
let err = require(&[&INIT]).unwrap_err();
|
||||
assert!(err.contains("not yet available"));
|
||||
assert!(err.contains("init"));
|
||||
assert!(err.contains("oakengine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_cli_families_are_currently_deferred() {
|
||||
// Keeps this file honest: if any family the CLI depends on flips to
|
||||
// available, the subcommand ports in src/cmd/ become reachable and
|
||||
// the tests asserting "not yet available" must be revisited.
|
||||
let all: [&[&DeferredFamily]; 5] =
|
||||
[&[&INIT], &[&NODE], &[&TIMELINE], &[&RENDER], &[&EXPORT]];
|
||||
for families in all {
|
||||
assert!(require(families).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-58
@@ -14,37 +14,32 @@
|
||||
// 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, not yet
|
||||
//! The `oakengine_*` C ABI surface oak-cli consumes — **declared and
|
||||
//! linked**.
|
||||
//!
|
||||
//! This module mirrors — verbatim — every function, opaque handle and POD
|
||||
//! struct from the engine headers that the C++ `cli/main.cpp` touches:
|
||||
//!
|
||||
//! - `engine/include/oakengine/init.h` (oakengine_init / shutdown)
|
||||
//! - `engine/include/oakengine/project.h` (project lifecycle + queries)
|
||||
//! - `engine/include/oakengine/footage.h` (probe / stream info / import)
|
||||
//! - `engine/include/oakengine/timeline.h` (sequence + track/clip editing)
|
||||
//! - `engine/include/oakengine/renderer.h` (renderer + frame + audio buffer)
|
||||
//! - `engine/include/oakengine/exporter.h` (export options + render)
|
||||
//! - `engine/include/oakengine/renderer.h` (render manager init, renderer,
|
||||
//! frame + audio buffer)
|
||||
//! - `engine/include/oakengine/videoparams.h` (sequence video params)
|
||||
//!
|
||||
//! All of these families are **deferred** in the Rust facade crate
|
||||
//! (`oakengine`, `src/facade/rust/src/deferred.rs`), so none of the symbols
|
||||
//! below is referenced from this crate yet — the subcommands gate on
|
||||
//! [`crate::deferred`] and report "not yet available" instead of calling
|
||||
//! them. The declarations exist so that:
|
||||
//! The `#[link(name = "oakengine", kind = "dylib")]` block resolves every
|
||||
//! symbol against the built `liboakengine` cdylib at link time (the
|
||||
//! search path comes from `build.rs`; see the Cargo.toml comment for the
|
||||
//! build order). Five symbols from the original declaration surface are
|
||||
//! NOT exported by the current Rust facade — `oakengine_init`,
|
||||
//! `oakengine_shutdown` (init.h) and the exporter trio
|
||||
//! `oakengine_export_render` / `oakengine_export_last_error` /
|
||||
//! `oakengine_export_set_progress_callback` (exporter.h). Those live in
|
||||
//! [`crate::optional`], which resolves them with `dlsym` at call time so
|
||||
//! the prescribed call sequences keep working when the facade grows them.
|
||||
//!
|
||||
//! 1. the exact contract the CLI expects is pinned in one place (types,
|
||||
//! signatures, string conventions, error codes), and
|
||||
//! 2. when a family is wrapped by oakengine, the call-through code in
|
||||
//! `src/cmd/` resolves against the already-linked `oakengine` rlib
|
||||
//! without any manifest or signature churn.
|
||||
//!
|
||||
//! Nothing here is ever called today, so no symbol needs to exist in the
|
||||
//! facade yet; that keeps `cargo build` green standalone.
|
||||
//!
|
||||
//! `dead_code` is expected for this whole surface until the ports land: the
|
||||
//! declarations, the POD structs and [`facade_string`] exist precisely to be
|
||||
//! consumed by `src/cmd/` once the deferred families are wrapped.
|
||||
//! The subcommands in `src/cmd/` call this surface directly — no deferral
|
||||
//! gate, no module-crate calls.
|
||||
|
||||
#![allow(dead_code)]
|
||||
#![allow(non_camel_case_types)]
|
||||
@@ -168,14 +163,12 @@ pub const OAKENGINE_EXPORT_VIDEO_H264: c_int = 0;
|
||||
pub const OAKENGINE_EXPORT_AUDIO_AAC: c_int = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The facade surface (declarations only — see the module docs).
|
||||
// The facade surface (the symbols the built liboakengine exports; see the
|
||||
// module docs — the five missing ones live in crate::optional).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[link(name = "oakengine", kind = "dylib")]
|
||||
extern "C" {
|
||||
// ---- init.h ----------------------------------------------------------
|
||||
pub fn oakengine_init(flags: c_int) -> c_int;
|
||||
pub fn oakengine_shutdown() -> c_int;
|
||||
|
||||
// ---- project.h -------------------------------------------------------
|
||||
pub fn oakengine_project_create() -> *mut OakEngineProject;
|
||||
pub fn oakengine_project_free(self_: *mut OakEngineProject);
|
||||
@@ -276,6 +269,18 @@ extern "C" {
|
||||
par_num: *mut c_int,
|
||||
par_den: *mut c_int,
|
||||
) -> c_int;
|
||||
pub fn oakengine_sequence_set_video_params(
|
||||
self_: *mut OakEngineSequence,
|
||||
width: c_int,
|
||||
height: c_int,
|
||||
fps_num: c_int,
|
||||
fps_den: c_int,
|
||||
par_num: c_int,
|
||||
par_den: c_int,
|
||||
interlacing: c_int,
|
||||
format: c_int,
|
||||
undoable: c_int,
|
||||
) -> c_int;
|
||||
pub fn oakengine_sequence_track_count(
|
||||
self_: *const OakEngineSequence,
|
||||
video: *mut c_int,
|
||||
@@ -300,8 +305,27 @@ extern "C" {
|
||||
out_ts: i64,
|
||||
media_in: i64,
|
||||
) -> *mut OakEngineClip;
|
||||
/// Like `oakengine_sequence_add_footage_clip` but skips the
|
||||
/// same-project check: sequences created through `oakengine_sequence_new`
|
||||
/// live in their own scratch project (documented engine deviation), so
|
||||
/// the CLI's transcode flow (footage in the real project, sequence in
|
||||
/// the scratch project) needs the `_ex` variant.
|
||||
pub fn oakengine_sequence_add_footage_clip_ex(
|
||||
seq: *mut OakEngineSequence,
|
||||
footage: *mut OakEngineFootage,
|
||||
track_type: c_int,
|
||||
track_index: c_int,
|
||||
in_ts: i64,
|
||||
out_ts: i64,
|
||||
media_in: i64,
|
||||
) -> *mut OakEngineClip;
|
||||
pub fn oakengine_sequence_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
|
||||
// ---- renderer.h (render manager: the Rust facade's equivalent of the
|
||||
// ---- C++ OAKENGINE_INIT_RENDER engine-core render boot) -------------
|
||||
pub fn oakengine_render_manager_init() -> c_int;
|
||||
pub fn oakengine_render_manager_shutdown() -> c_int;
|
||||
|
||||
// ---- renderer.h ------------------------------------------------------
|
||||
pub fn oakengine_renderer_create(
|
||||
seq: *mut OakEngineSequence,
|
||||
@@ -343,22 +367,6 @@ extern "C" {
|
||||
pub fn oakengine_audio_sample_count(self_: *const OakEngineAudioBuffer) -> i64;
|
||||
pub fn oakengine_audio_data(self_: *const OakEngineAudioBuffer, channel: c_int) -> *const f32;
|
||||
pub fn oakengine_audio_free(self_: *mut OakEngineAudioBuffer);
|
||||
|
||||
// ---- exporter.h ------------------------------------------------------
|
||||
pub fn oakengine_export_render(
|
||||
seq: *mut OakEngineSequence,
|
||||
path: *const c_char,
|
||||
in_ts: i64,
|
||||
out_ts: i64,
|
||||
width: c_int,
|
||||
height: c_int,
|
||||
opts: *const OakExportOptions,
|
||||
) -> c_int;
|
||||
pub fn oakengine_export_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
pub fn oakengine_export_set_progress_callback(
|
||||
f: OakEngineExportProgressFn,
|
||||
userdata: *mut c_void,
|
||||
);
|
||||
}
|
||||
|
||||
/// Read a facade string (buf/size convention) into an owned `String`,
|
||||
@@ -366,21 +374,16 @@ extern "C" {
|
||||
/// error/empty string, otherwise the getter is called twice (size query,
|
||||
/// then fill) and the trailing NUL is stripped.
|
||||
///
|
||||
/// # Safety
|
||||
/// `getter` must be one of the `oakengine_*` string getters declared above
|
||||
/// and `handle` a live handle for it.
|
||||
pub unsafe fn facade_string(
|
||||
getter: unsafe extern "C" fn(*const c_void, *mut c_char, c_int) -> c_int,
|
||||
handle: *const c_void,
|
||||
) -> String {
|
||||
unsafe {
|
||||
let size = getter(handle, std::ptr::null_mut(), 0);
|
||||
if size < 0 {
|
||||
return String::new();
|
||||
}
|
||||
let mut s = vec![0u8; size as usize + 1];
|
||||
let n = getter(handle, s.as_mut_ptr() as *mut c_char, size + 1);
|
||||
s.truncate(n.max(0) as usize);
|
||||
String::from_utf8_lossy(&s).into_owned()
|
||||
/// `fill` must be one of the `oakengine_*` string getters (handle
|
||||
/// getters closed over their live handle, last-error getters applied
|
||||
/// directly).
|
||||
pub fn string_get(mut fill: impl FnMut(*mut c_char, c_int) -> c_int) -> String {
|
||||
let size = fill(std::ptr::null_mut(), 0);
|
||||
if size < 0 {
|
||||
return String::new();
|
||||
}
|
||||
let mut s = vec![0u8; size as usize + 1];
|
||||
let n = fill(s.as_mut_ptr() as *mut c_char, size + 1);
|
||||
s.truncate(n.max(0) as usize);
|
||||
String::from_utf8_lossy(&s).into_owned()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <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()
|
||||
}
|
||||
@@ -29,16 +29,25 @@
|
||||
//! Exit codes: 0 success, 1 general error, 2 rendering unavailable,
|
||||
//! 64 usage error.
|
||||
//!
|
||||
//! The facade families every subcommand depends on (init/project/timeline/
|
||||
//! render/footage/exporter) are still **deferred** in the `oakengine` crate
|
||||
//! (see `src/facade/rust/src/deferred.rs`), so each subcommand validates its
|
||||
//! arguments faithfully, then reports the deferral with its reason and exits
|
||||
//! with the C++-compatible code — never crashing, never faking output.
|
||||
//! This crate is a PURE C-ABI consumer of the built `liboakengine`
|
||||
//! cdylib: every engine call goes through the `extern "C"` declarations
|
||||
//! in [`ffi`] (linked via `build.rs` + `#[link(name = "oakengine", kind =
|
||||
//! "dylib")]`) and the dlsym-resolved optional families in [`optional`].
|
||||
//! No module crate is ever called directly. [`host`] plays the C++ host
|
||||
//! role the engine dylib expects: it provides the `oakcore_audioparams_*`
|
||||
//! symbols the dylib resolves from the process at runtime. [`fmt`],
|
||||
//! [`ppm`] and [`wav`] are pure-Rust formatting/writing helpers (exact
|
||||
//! ports of the C++ `printf`/writers) — the only non-ABI code here.
|
||||
//!
|
||||
//! Build order: `cargo build -p oakengine` must run before linking this
|
||||
//! binary (`cargo build`/`cargo test -p oak-cli`); `cargo check` never
|
||||
//! links and works standalone.
|
||||
|
||||
mod cmd;
|
||||
mod deferred;
|
||||
mod ffi;
|
||||
mod fmt;
|
||||
mod host;
|
||||
mod optional;
|
||||
mod ppm;
|
||||
mod wav;
|
||||
|
||||
@@ -76,7 +85,7 @@ Usage:\n\
|
||||
Exit codes:\n\
|
||||
0 success\n\
|
||||
1 general error (bad project/media file, no sequence, I/O failure)\n\
|
||||
2 rendering unavailable or failed (e.g. no GL render backend)\n\
|
||||
2 rendering unavailable or failed (e.g. no render backend)\n\
|
||||
64 usage error\n";
|
||||
|
||||
/// CLI surface. `--help`/`-h` are handled before clap so the C++ usage text
|
||||
@@ -132,6 +141,10 @@ enum Command {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Keep the host shims (src/host.rs) referenced so the linker
|
||||
// exports them for the engine dylib's runtime lookups.
|
||||
std::hint::black_box(host::exports());
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
// argv[1] handling that mirrors the C++ main() exactly.
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <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) };
|
||||
}
|
||||
}
|
||||
+192
-42
@@ -16,20 +16,56 @@
|
||||
|
||||
//! End-to-end tests for the built `oak-cli` binary (the C++ ctest suite
|
||||
//! `oak_cli_info`/`oak_cli_render`/`oak_cli_probe`/`oak_cli_transcode`
|
||||
//! equivalents, as far as the deferred facade allows).
|
||||
//! equivalents).
|
||||
//!
|
||||
//! All four subcommands depend on facade families that are still deferred in
|
||||
//! oakfacade (see `src/deferred.rs`), so the data-producing paths assert the
|
||||
//! documented "not yet available" behavior with the C++-compatible exit
|
||||
//! codes; the argument-validation paths assert the exact C++ messages and
|
||||
//! exit code 64.
|
||||
//! 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 data-producing paths run against the repo fixtures
|
||||
//! (`tests/project_with_footage.ove`, `tests/demo.mp4` — real H.264/AAC
|
||||
//! media, `tests/img.png`), the failure paths assert the documented exit
|
||||
//! 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.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn bin() -> &'static str {
|
||||
env!("CARGO_BIN_EXE_oak-cli")
|
||||
}
|
||||
|
||||
/// The fixture `.ove` file (relative to the workspace root).
|
||||
fn fixture_project() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("tests")
|
||||
.join("project_with_footage.ove")
|
||||
}
|
||||
|
||||
/// The real media fixture.
|
||||
fn fixture_media() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("tests")
|
||||
.join("demo.mp4")
|
||||
}
|
||||
|
||||
/// A single still-image fixture (1920x1080 RGBA PNG): one video stream,
|
||||
/// no audio — fast end-to-end transcode coverage.
|
||||
fn fixture_image() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("tests")
|
||||
.join("img.png")
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> (i32, String, String) {
|
||||
let out = Command::new(bin())
|
||||
.args(args)
|
||||
@@ -42,11 +78,47 @@ 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).
|
||||
fn real_errors(stderr: &str) -> String {
|
||||
stderr
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("objc["))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// A scratch directory removed when the guard drops.
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"oak_cli_test_{tag}_{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TempDir(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_prints_the_cpp_usage_text_and_exits_zero() {
|
||||
let (code, stdout, stderr) = run(&["--help"]);
|
||||
assert_eq!(code, 0);
|
||||
assert!(stderr.is_empty());
|
||||
// stderr may carry dyld's objc class-duplication notices only.
|
||||
assert!(
|
||||
!real_errors(&stderr).contains("error:"),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
assert!(stdout.starts_with("oak-cli - headless consumer of the liboakengine C ABI\n"));
|
||||
assert!(stdout.contains("oak-cli transcode <input_media> <out> [width] [--format ppm|mp4]"));
|
||||
assert!(stdout.contains("Exit codes:"));
|
||||
@@ -67,21 +139,6 @@ fn unknown_command_is_a_usage_error() {
|
||||
assert!(stderr.contains("error: unknown command \"frobnicate\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_on_a_fixture_reports_not_yet_available() {
|
||||
// The fixture mirrors the ctest invocation; the deferred gate fires
|
||||
// before any file access.
|
||||
let (code, _stdout, stderr) = run(&["info", "tests/project_with_footage.ove"]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(
|
||||
stderr.contains("error: info: not yet available"),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
// The crate was renamed oakfacade -> oakengine; the deferral reason
|
||||
// names the current crate.
|
||||
assert!(stderr.contains("oakengine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_with_missing_argument_is_a_usage_error() {
|
||||
let (code, _stdout, stderr) = run(&["info"]);
|
||||
@@ -90,31 +147,65 @@ fn info_with_missing_argument_is_a_usage_error() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_not_yet_available() {
|
||||
let (code, _stdout, stderr) = run(&["probe", "tests/demo.mp4"]);
|
||||
fn info_on_a_missing_project_is_an_error() {
|
||||
let (code, _stdout, stderr) = run(&["info", "no-such-project.ove"]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(
|
||||
stderr.contains("error: probe: not yet available"),
|
||||
real_errors(&stderr).contains("error: info:"),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reports_render_unavailable() {
|
||||
let (code, _stdout, stderr) = run(&["render", "p.ove", "0", "1", "out"]);
|
||||
assert_eq!(code, 2);
|
||||
fn info_on_the_fixture_prints_the_project() {
|
||||
let project = fixture_project();
|
||||
let (code, stdout, stderr) = run(&["info", project.to_str().unwrap()]);
|
||||
assert_eq!(code, 0, "stderr: {stderr}");
|
||||
assert!(stdout.contains("Project: project_with_footage"), "{stdout}");
|
||||
assert!(stdout.contains("Modified: no"), "{stdout}");
|
||||
assert!(stdout.contains("Sequences: 1"), "{stdout}");
|
||||
assert!(stdout.contains("[0] \"Fixture Sequence\""), "{stdout}");
|
||||
assert!(stdout.contains("frame rate: 30/1 (30.000 fps)"), "{stdout}");
|
||||
assert!(stdout.contains("Footage: 1"), "{stdout}");
|
||||
// The C++ fixture stores the footage path relative to the project;
|
||||
// the CLI resolves it against the project directory and reports it
|
||||
// online.
|
||||
assert!(stdout.contains("demo.mp4"), "{stdout}");
|
||||
assert!(stdout.contains("online"), "{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_on_a_missing_file_is_an_error() {
|
||||
let (code, stdout, stderr) = run(&["probe", "no-such-file.mp4"]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(stdout.is_empty());
|
||||
assert!(
|
||||
stderr.contains("error: render: not yet available"),
|
||||
real_errors(&stderr).contains("error: probe: file does not exist: no-such-file.mp4"),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_on_the_media_fixture_prints_streams() {
|
||||
let media = fixture_media();
|
||||
let (code, stdout, stderr) = run(&["probe", media.to_str().unwrap()]);
|
||||
assert_eq!(code, 0, "stderr: {stderr}");
|
||||
// tests/demo.mp4 through the engine: the probe records the ffmpeg
|
||||
// decoder; the stream descriptions are dropped by the module today,
|
||||
// so the counts/duration report 0 — the pinned engine contract.
|
||||
assert!(stdout.contains("Decoder: ffmpeg"), "{stdout}");
|
||||
assert!(stdout.contains("Duration: 0.000000 s"), "{stdout}");
|
||||
assert!(stdout.contains("Video streams: 0"), "{stdout}");
|
||||
assert!(stdout.contains("Audio streams: 0"), "{stdout}");
|
||||
assert!(stdout.contains("Subtitle streams: 0"), "{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bad_seconds_is_a_usage_error() {
|
||||
let (code, _stdout, stderr) = run(&["render", "p.ove", "abc", "1", "out"]);
|
||||
assert_eq!(code, 64);
|
||||
assert!(
|
||||
stderr.contains("error: invalid start seconds \"abc\""),
|
||||
real_errors(&stderr).contains("error: invalid start seconds \"abc\""),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
@@ -124,27 +215,45 @@ fn render_end_not_after_start_is_a_usage_error() {
|
||||
let (code, _stdout, stderr) = run(&["render", "p.ove", "2", "1", "out"]);
|
||||
assert_eq!(code, 64);
|
||||
assert!(
|
||||
stderr.contains("error: invalid end seconds \"1\""),
|
||||
real_errors(&stderr).contains("error: invalid end seconds \"1\""),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcode_reports_render_unavailable() {
|
||||
let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "960"]);
|
||||
assert_eq!(code, 2);
|
||||
fn render_on_a_missing_project_is_an_error() {
|
||||
let (code, _stdout, stderr) = run(&["render", "no-such.ove", "0", "1", "out"]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(
|
||||
stderr.contains("error: transcode: not yet available"),
|
||||
real_errors(&stderr).contains("error: render:"),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_the_fixture_writes_ppm_frames_and_a_wav() {
|
||||
let dir = TempDir::new("render");
|
||||
let project = fixture_project();
|
||||
let out = dir.0.to_str().unwrap();
|
||||
let (code, _stdout, stderr) = run(&["render", project.to_str().unwrap(), "0", "0.1", out]);
|
||||
assert_eq!(code, 0, "stderr: {stderr}");
|
||||
// The engine reports the sequence rate as 30/1: frames at 0, 1/30
|
||||
// and 2/30 before 0.1 s.
|
||||
assert!(dir.0.join("frame_00000.ppm").is_file());
|
||||
assert!(dir.0.join("frame_00001.ppm").is_file());
|
||||
assert!(dir.0.join("frame_00002.ppm").is_file());
|
||||
assert!(dir.0.join("audio.wav").is_file());
|
||||
// PPM header of the first frame (P6).
|
||||
let first = std::fs::read(dir.0.join("frame_00000.ppm")).unwrap();
|
||||
assert!(first.starts_with(b"P6\n"), "PPM header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcode_bad_width_is_a_usage_error() {
|
||||
let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "banana"]);
|
||||
assert_eq!(code, 64);
|
||||
assert!(
|
||||
stderr.contains("error: invalid width \"banana\""),
|
||||
real_errors(&stderr).contains("error: invalid width \"banana\""),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
@@ -154,7 +263,7 @@ fn transcode_nonpositive_width_is_a_usage_error() {
|
||||
let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "0"]);
|
||||
assert_eq!(code, 64);
|
||||
assert!(
|
||||
stderr.contains("error: invalid width \"0\""),
|
||||
real_errors(&stderr).contains("error: invalid width \"0\""),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
@@ -164,17 +273,58 @@ fn transcode_unknown_format_is_a_usage_error() {
|
||||
let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "--format", "webm"]);
|
||||
assert_eq!(code, 64);
|
||||
assert!(
|
||||
stderr.contains("error: unknown --format \"webm\" (ppm|mp4)"),
|
||||
real_errors(&stderr).contains("error: unknown --format \"webm\" (ppm|mp4)"),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcode_ppm_format_is_accepted_then_reports_not_available() {
|
||||
let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "outdir", "960", "--format", "ppm"]);
|
||||
assert_eq!(code, 2);
|
||||
fn transcode_on_a_missing_input_is_an_error() {
|
||||
let (code, _stdout, stderr) = run(&["transcode", "no-such.mp4", "out.mp4"]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(
|
||||
stderr.contains("error: transcode: not yet available"),
|
||||
real_errors(&stderr).contains("error: transcode: file does not exist: no-such.mp4"),
|
||||
"stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcode_the_image_fixture_to_ppm_frames() {
|
||||
let dir = TempDir::new("transcode_ppm");
|
||||
let image = fixture_image();
|
||||
let out = dir.0.to_str().unwrap();
|
||||
let (code, _stdout, stderr) = run(&[
|
||||
"transcode",
|
||||
image.to_str().unwrap(),
|
||||
out,
|
||||
"160",
|
||||
"--format",
|
||||
"ppm",
|
||||
]);
|
||||
assert_eq!(code, 0, "stderr: {stderr}");
|
||||
// A still image counts as one frame.
|
||||
assert!(dir.0.join("frame_00000.ppm").is_file());
|
||||
assert!(!dir.0.join("frame_00001.ppm").exists());
|
||||
let first = std::fs::read(dir.0.join("frame_00000.ppm")).unwrap();
|
||||
assert!(first.starts_with(b"P6\n160 90\n255\n"), "160x90 P6 header");
|
||||
// No audio stream in the fixture: no WAV.
|
||||
assert!(!dir.0.join("audio.wav").exists());
|
||||
}
|
||||
|
||||
#[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.
|
||||
let dir = TempDir::new("transcode_mp4");
|
||||
let image = fixture_image();
|
||||
let out = dir.0.join("out.mp4");
|
||||
let (code, _stdout, stderr) = run(&[
|
||||
"transcode",
|
||||
image.to_str().unwrap(),
|
||||
out.to_str().unwrap(),
|
||||
"160",
|
||||
]);
|
||||
assert_eq!(code, 0, "stderr: {stderr}");
|
||||
let head = std::fs::read(&out).expect("mp4 written");
|
||||
assert_eq!(&head[4..8], b"ftyp", "mp4 container");
|
||||
}
|
||||
|
||||
Generated
-1857
File diff suppressed because it is too large
Load Diff
@@ -26,22 +26,11 @@ name = "oak-worker"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# The liboakengine facade (Rust): its worker module owns the whole worker
|
||||
# runtime — render backend selection through the oakrender module C ABI,
|
||||
# the startup handshake and the NDJSON control loop
|
||||
# (`oakengine::worker::worker_main`, the port of engine/src/capi/worker.cpp).
|
||||
# This binary is a thin shell over it, like worker/workermain.cpp. Its ipc
|
||||
# module provides the real shared-memory frame-slot transport
|
||||
# (`oakengine::ipc`) that src/session.rs + src/transport.rs attach through.
|
||||
oakengine = { path = "../oakengine" }
|
||||
|
||||
# The oakrender module crate (the Rust rewrite of the oakrender module):
|
||||
# its C ABI (include/render/renderer.h) is how the facade's worker
|
||||
# initializes the render backend — oakrender_display_renderer_create_dynamic()
|
||||
# /_init() — the Rust equivalent of the C++ worker's DynamicRenderer/
|
||||
# OpenGLRenderer. Linked here so those facade imports resolve in the binary.
|
||||
oakrender = { path = "../oakrender" }
|
||||
# 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.
|
||||
|
||||
+40
-34
@@ -8,27 +8,29 @@ Headless render worker process — the Rust rewrite of `worker/workermain.cpp`
|
||||
|
||||
```sh
|
||||
cargo build --release # binary: target/release/oak-worker
|
||||
cargo test # unit + integration tests (29 tests)
|
||||
cargo test # unit + integration tests
|
||||
```
|
||||
|
||||
The worker is a **thin shell over the facade**, exactly like the C++
|
||||
`worker/workermain.cpp` is a thin shell over `liboakengine`:
|
||||
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.
|
||||
|
||||
- `oakfacade::worker::worker_main` (the port of
|
||||
`engine/src/capi/worker.cpp` `oakengine_worker_main()`) owns the whole
|
||||
runtime: render backend selection through the oakrender module C ABI
|
||||
(dynamic → OpenGL fallback), the startup handshake and the NDJSON
|
||||
control loop. `src/main.rs` only parses `--backend` (clap) and forwards.
|
||||
- `oakfacade::ipc` owns the shared-memory frame-slot transport (the real
|
||||
`SpscRingBuffer` + `FrameSlotPool` over POSIX `shm_open`/`mmap`);
|
||||
`src/transport.rs` attaches through it.
|
||||
- `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/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.
|
||||
|
||||
The oakrender module crate (`../oakrender`) is linked so the
|
||||
facade's renderer imports resolve; oakrender depends on `ocio-rs` with the
|
||||
`bundled` feature, whose first-time build fetches a vendored OpenColorIO
|
||||
dependency (`sse2neon`) from github.com. On networks without github access,
|
||||
build with a shared target directory that already contains a completed
|
||||
oakrender build tree, e.g.:
|
||||
The oakrender module crate (`../oakrender`) is a plain Rust dependency;
|
||||
it depends on `ocio-rs` with the `bundled` feature, whose first-time build
|
||||
fetches a vendored OpenColorIO dependency (`sse2neon`) from github.com. On
|
||||
networks without github access, build with a shared target directory that
|
||||
already contains a completed oakrender build tree, e.g.:
|
||||
|
||||
```sh
|
||||
CARGO_TARGET_DIR=/path/to/oak/crates/oakrender/target cargo build --release
|
||||
@@ -40,18 +42,18 @@ Same flow as the C++ main, in the same order:
|
||||
|
||||
1. **parse `--backend <name>`** (clap; default `opengl`; `none` skips
|
||||
renderer creation and the process exits 1, like the C++ main).
|
||||
2. **initialize the render backend** (inside `oakfacade::worker`): the
|
||||
oakrender module C ABI `oakrender_display_renderer_create_dynamic` +
|
||||
`_init`, falling back to the direct OpenGL renderer exactly like the
|
||||
C++ `create_renderer()` fallback chain.
|
||||
2. **initialize the render backend** (inside `src/worker.rs`): the
|
||||
oakrender `DisplayRenderer` direct Rust API, falling back to the direct
|
||||
OpenGL renderer exactly like the C++ `create_renderer()` fallback
|
||||
chain.
|
||||
3. **write the startup handshake** (protocol version 1, empty shared-memory
|
||||
geometry — same as the C++ worker's startup handshake; the parent
|
||||
creates the segments and announces their geometry in its reply).
|
||||
4. **serve the NDJSON control loop** on stdin/stdout until a `shutdown`
|
||||
message or EOF: `handshake` attaches the announced shared-memory
|
||||
frame-slot pools through the real transport; `load_graph` /
|
||||
`render_frame` / `cancel` / `shutdown` are dispatched by the facade
|
||||
session. Responses are one compact JSON line per message.
|
||||
`render_frame` / `cancel` / `shutdown` are dispatched by the session.
|
||||
Responses are one compact JSON line per message.
|
||||
|
||||
## Implemented vs stubbed (nothing is faked)
|
||||
|
||||
@@ -59,7 +61,7 @@ Same flow as the C++ main, in the same order:
|
||||
renderer, dynamic → OpenGL fallback), startup handshake, NDJSON framing,
|
||||
message validation (protocol version, handshake geometry, `load_graph`
|
||||
file existence/size — the same messages the C++ worker emits), the
|
||||
**shared-memory frame-slot transport** (`oakfacade::ipc` — POSIX
|
||||
**shared-memory frame-slot transport** (`src/ipc.rs` — POSIX
|
||||
`shm_open`/`mmap`/`munmap`/`shm_unlink`, the SPSC ring buffer and the
|
||||
frame-slot pool with the exact version-1 shared layout; a `handshake`
|
||||
genuinely attaches the output and input pools), unknown-type/
|
||||
@@ -79,27 +81,31 @@ non-existent/empty file produces the C++-identical error before reaching
|
||||
the stub.
|
||||
|
||||
**Deviation from the C++:** the startup handshake omits `gl_major`/
|
||||
`gl_minor` — the oakrender module C ABI exposes no GL context version (the
|
||||
C++ worker reads them off its `QOpenGLContext`).
|
||||
`gl_minor` — the oakrender module exposes no GL context version (the C++
|
||||
worker reads them off its `QOpenGLContext`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs clap entry; thin shell forwarding to oakfacade::worker
|
||||
main.rs clap entry; thin shell forwarding to worker::worker_main
|
||||
(renderer init, handshake, NDJSON loop all live there)
|
||||
ipc.rs control-plane message structs + NDJSON framing (serde)
|
||||
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 real shared-memory frame-slot transport over oakfacade::ipc
|
||||
transport.rs shared-memory frame-slot transport over crate::ipc
|
||||
tests/worker.rs binary-level tests (help, clap errors, --backend none exit 1)
|
||||
```
|
||||
|
||||
The NDJSON control-loop behavior is exercised in-process in `src/session.rs`
|
||||
against the facade's 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:
|
||||
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:
|
||||
|
||||
```sh
|
||||
target/release/oak-worker --backend opengl <<< '{"type":"shutdown"}'
|
||||
|
||||
+19
-12
@@ -16,18 +16,25 @@
|
||||
|
||||
//! Link configuration for the `oak-worker` binary.
|
||||
//!
|
||||
//! The facade rlib (src/engine/rust) links the module C ABIs into any
|
||||
//! consumer that pulls its codec surface — oak-worker's use of
|
||||
//! `oakengine::worker` transitively pulls the facade's codec module, whose
|
||||
//! oakcodec references carry a few C++-host imports (`oakcore_audioparams_*`
|
||||
//! from liboakcore, `fb_*` from ffmpeg_bridge). Those live in the host Oak
|
||||
//! process and are only reachable on media-decode paths this worker never
|
||||
//! exercises; the CMake worker has the same property through the
|
||||
//! liboakengine dylib (whose build.rs allows runtime lookups). Mirror that
|
||||
//! here so the standalone Rust worker binary links.
|
||||
//! 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() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
|
||||
println!("cargo:rustc-link-arg=-Wl,-undefined,dynamic_lookup");
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
// 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) };
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Control-plane NDJSON protocol (contract: `engine/include/oakengine/ipc.h`
|
||||
//! and `engine/include/oakengine/worker.h`).
|
||||
//!
|
||||
//! The wire format is **one compact JSON object per line** on the stdio
|
||||
//! pipes (worker.cpp / ipcmessage.cpp `write_message`/`read_message`).
|
||||
//! Every message carries a `"type"` string; the field names below are the
|
||||
//! ones the C++ serializers actually emit (`engine/render/ipc/ipcmessage.cpp`):
|
||||
//! note `ticket` / `node` / `channels` / `slot` — the longer names
|
||||
//! (`ticket_id`, `node_uuid`, `channel_count`, `output_slot`) exist only on
|
||||
//! the C POD structs in `ipc.h`.
|
||||
//!
|
||||
//! Message types (M = main/editor, W = worker):
|
||||
//! handshake M<->W negotiate protocol version + announce shm geometry
|
||||
//! load_graph M ->W path to a temp file holding the serialized graph
|
||||
//! render_frame M ->W request a frame render (ticket, node, time, params)
|
||||
//! frame_ready W ->M a rendered frame is published (slot + ticket)
|
||||
//! cancel M ->W abandon an in-flight ticket
|
||||
//! graph_update M ->W reserved (no payload struct yet)
|
||||
//! shutdown M ->W finish current work and exit cleanly
|
||||
//! error W ->M worker-side failure report ("message" field)
|
||||
//!
|
||||
//! Items the worker does not emit yet (frame_ready, graph_update,
|
||||
//! `FrameReadyMsg`) and message ids it ignores (`cancel`) are kept as the
|
||||
//! documented protocol surface; `dead_code` until the frame-slot transport
|
||||
//! lands (see crate::transport).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// `"handshake"`.
|
||||
pub const TYPE_HANDSHAKE: &str = "handshake";
|
||||
/// `"load_graph"`.
|
||||
pub const TYPE_LOAD_GRAPH: &str = "load_graph";
|
||||
/// `"render_frame"`.
|
||||
pub const TYPE_RENDER_FRAME: &str = "render_frame";
|
||||
/// `"frame_ready"`.
|
||||
pub const TYPE_FRAME_READY: &str = "frame_ready";
|
||||
/// `"cancel"`.
|
||||
pub const TYPE_CANCEL: &str = "cancel";
|
||||
/// `"graph_update"`.
|
||||
pub const TYPE_GRAPH_UPDATE: &str = "graph_update";
|
||||
/// `"shutdown"`.
|
||||
pub const TYPE_SHUTDOWN: &str = "shutdown";
|
||||
/// `"error"`.
|
||||
pub const TYPE_ERROR: &str = "error";
|
||||
|
||||
/// `handshake` — field-for-field equivalent of `oak_ipc_handshake`
|
||||
/// (ipc.h). Wire field names match the C++ serializer.
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct HandshakeMsg {
|
||||
/// Protocol version.
|
||||
pub protocol_version: i32,
|
||||
/// Worker->main output shared-memory segment key.
|
||||
pub shm_key: String,
|
||||
/// Main->worker input shared-memory segment key (optional).
|
||||
pub input_shm_key: String,
|
||||
/// Number of main->worker input frame slots.
|
||||
pub input_slots: i32,
|
||||
/// Number of worker->main output frame slots.
|
||||
pub output_slots: i32,
|
||||
/// Per-output-slot pixel block size.
|
||||
pub slot_data_bytes: i64,
|
||||
/// Per-input-slot pixel block size.
|
||||
pub input_slot_data_bytes: i64,
|
||||
}
|
||||
|
||||
impl HandshakeMsg {
|
||||
/// The worker's startup handshake (`worker.cpp startup_handshake()`).
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"type": TYPE_HANDSHAKE,
|
||||
"protocol_version": self.protocol_version,
|
||||
"shm_key": self.shm_key,
|
||||
"input_shm_key": self.input_shm_key,
|
||||
"input_slots": self.input_slots,
|
||||
"output_slots": self.output_slots,
|
||||
"slot_data_bytes": self.slot_data_bytes,
|
||||
"input_slot_data_bytes": self.input_slot_data_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `render_frame` — request a frame render. Wire names per ipcmessage.cpp:
|
||||
/// `ticket`, `node`, `channels` (not the ipc.h POD names).
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct RenderFrameMsg {
|
||||
/// Correlates with the eventual frame_ready.
|
||||
pub ticket: i64,
|
||||
/// Viewer node stable uuid in the loaded graph.
|
||||
pub node: String,
|
||||
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,
|
||||
}
|
||||
|
||||
/// `frame_ready` — a rendered frame is published (wire names `ticket`/
|
||||
/// `slot`).
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct FrameReadyMsg {
|
||||
pub ticket: i64,
|
||||
/// Index into the worker->main output FrameSlotPool.
|
||||
pub slot: i32,
|
||||
}
|
||||
|
||||
/// `cancel` — abandon an in-flight ticket by id.
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct CancelMsg {
|
||||
pub ticket: i64,
|
||||
}
|
||||
|
||||
/// `load_graph` — path to a temporary file holding the serialized graph.
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct LoadGraphMsg {
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one NDJSON message line (compact JSON + `\n`), the Rust port of
|
||||
/// `ipcmessage.cpp write_message()`.
|
||||
pub fn write_message(w: &mut impl Write, msg: &Value) -> io::Result<()> {
|
||||
let line =
|
||||
serde_json::to_string(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
w.write_all(line.as_bytes())?;
|
||||
w.write_all(b"\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn handshake_wire_format_matches_cpp_field_names() {
|
||||
let hs = HandshakeMsg {
|
||||
protocol_version: 1,
|
||||
shm_key: "olive-rw-1234-0-out".into(),
|
||||
input_shm_key: "".into(),
|
||||
input_slots: 0,
|
||||
output_slots: 6,
|
||||
slot_data_bytes: 4096,
|
||||
input_slot_data_bytes: 0,
|
||||
};
|
||||
let value = hs.to_json();
|
||||
// Key order is not part of the contract (JSON objects; the C++
|
||||
// QJsonObject is hash-ordered too), but the names must match the
|
||||
// C++ serializer exactly.
|
||||
assert_eq!(value["type"], "handshake");
|
||||
assert_eq!(value["protocol_version"], 1);
|
||||
assert_eq!(value["shm_key"], "olive-rw-1234-0-out");
|
||||
assert_eq!(value["input_shm_key"], "");
|
||||
assert_eq!(value["input_slots"], 0);
|
||||
assert_eq!(value["output_slots"], 6);
|
||||
assert_eq!(value["slot_data_bytes"], 4096);
|
||||
assert_eq!(value["input_slot_data_bytes"], 0);
|
||||
// And the serialized line must parse back to the same object.
|
||||
let round: serde_json::Value =
|
||||
serde_json::from_str(&serde_json::to_string(&value).unwrap()).unwrap();
|
||||
assert_eq!(round, value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_frame_parse_accepts_cpp_field_names() {
|
||||
let json = r#"{"type":"render_frame","ticket":42,"node":"abcd","time_num":1,"time_den":24,"width":1920,"height":1080,"format":-1,"channels":0,"mode":0,"input_slot":-1,"input_slots":[],"has_color_transform":false,"color_output":"","color_view":"","color_look":""}"#;
|
||||
let m: RenderFrameMsg = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(m.ticket, 42);
|
||||
assert_eq!(m.node, "abcd");
|
||||
assert_eq!(m.time_num, 1);
|
||||
assert_eq!(m.time_den, 24);
|
||||
assert_eq!(m.width, 1920);
|
||||
assert_eq!(m.input_slot, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_frame_defaults_on_missing_fields() {
|
||||
// The C++ parser defaults missing fields (QJsonValue defaults);
|
||||
// serde(default) mirrors that.
|
||||
let m: RenderFrameMsg =
|
||||
serde_json::from_str(r#"{"type":"render_frame","ticket":7}"#).unwrap();
|
||||
assert_eq!(m.ticket, 7);
|
||||
assert_eq!(m.time_den, 0);
|
||||
assert!(m.node.is_empty());
|
||||
assert!(!m.has_color_transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_message_carries_ticket_only_when_nonzero() {
|
||||
assert_eq!(
|
||||
error_message("boom", None),
|
||||
json!({ "type": "error", "message": "boom" })
|
||||
);
|
||||
assert_eq!(
|
||||
error_message("boom", Some(0)),
|
||||
json!({ "type": "error", "message": "boom" })
|
||||
);
|
||||
assert_eq!(
|
||||
error_message("boom", Some(9)),
|
||||
json!({ "type": "error", "message": "boom", "ticket": 9 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_message_emits_one_json_line() {
|
||||
let mut buf = Vec::new();
|
||||
write_message(&mut buf, &json!({ "type": "shutdown" })).unwrap();
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "{\"type\":\"shutdown\"}\n");
|
||||
}
|
||||
}
|
||||
@@ -16,90 +16,67 @@
|
||||
|
||||
//! oak-worker: headless render worker process (Rust).
|
||||
//!
|
||||
//! A thin shell over the facade, mirroring `worker/workermain.cpp`: all
|
||||
//! runtime logic — render backend selection (dynamic -> OpenGL fallback
|
||||
//! through the oakrender module C ABI), the startup handshake and the
|
||||
//! NDJSON control loop — lives in `oakengine::worker` (the Rust port of
|
||||
//! `engine/src/capi/worker.cpp`, contract in
|
||||
//! `engine/include/oakengine/worker.h`). This crate keeps only the CLI
|
||||
//! surface (arg parsing) and the in-process session mirror
|
||||
//! ([`session`], [`transport`]) that its unit tests exercise against the
|
||||
//! facade's real shared-memory transport.
|
||||
//! 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.
|
||||
//!
|
||||
//! See README.md for the full status.
|
||||
|
||||
mod ipc;
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod engine_ipc;
|
||||
mod session;
|
||||
mod transport;
|
||||
|
||||
use std::ffi::{c_char, c_int, CString};
|
||||
use std::process::exit;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
// Force-link the oakrender module crate: the facade's worker module
|
||||
// (oakengine::worker) initializes the render backend through the oakrender
|
||||
// C ABI, but its bridge imports are `extern "C"` declarations — nothing in
|
||||
// the worker source names the crate, so without this the oakrender rlib
|
||||
// would not be added to the link and those imports would stay undefined.
|
||||
#[allow(unused_imports)]
|
||||
use oakrender as _;
|
||||
|
||||
/// Protocol version announced in the startup handshake
|
||||
/// (`k_protocol_version` in worker.cpp). Mirrors
|
||||
/// `oakengine::worker::PROTOCOL_VERSION`.
|
||||
/// (`k_protocol_version` in worker.cpp). Mirrors the engine worker
|
||||
/// module's `PROTOCOL_VERSION`.
|
||||
pub const PROTOCOL_VERSION: i32 = 1;
|
||||
|
||||
/// CLI surface (the C++ worker scans argv for `--backend`; clap formalizes
|
||||
/// that single option).
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "oak-worker",
|
||||
about = "Oak render worker: headless render process for the editor's worker pool",
|
||||
disable_version_flag = true
|
||||
)]
|
||||
struct Args {
|
||||
/// Render backend to initialize: "opengl", "vulkan", "metal", "auto",
|
||||
/// or "none" (no renderer; the process exits 1 like the C++ worker).
|
||||
#[arg(long, default_value = "opengl")]
|
||||
backend: String,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
// The facade's worker_main is the C++ oakengine_worker_main() — the
|
||||
// whole worker flow. Like workermain.cpp, this main only forwards.
|
||||
exit(oakengine::worker::worker_main(
|
||||
&args.backend.to_ascii_lowercase(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Log a worker-side message to stderr, mirroring worker.cpp `log_error()`
|
||||
/// (the `worker: ` prefix).
|
||||
pub fn log_error(message: &str) {
|
||||
eprintln!("worker: {message}");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clap_parses_backend_default() {
|
||||
use clap::Parser;
|
||||
let args = Args::try_parse_from(["oak-worker"]).unwrap();
|
||||
assert_eq!(args.backend, "opengl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_parses_backend_flag() {
|
||||
use clap::Parser;
|
||||
let args = Args::try_parse_from(["oak-worker", "--backend", "none"]).unwrap();
|
||||
assert_eq!(args.backend, "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_rejects_unknown_flags() {
|
||||
use clap::Parser;
|
||||
assert!(Args::try_parse_from(["oak-worker", "--frobnicate"]).is_err());
|
||||
fn protocol_version_is_one() {
|
||||
assert_eq!(PROTOCOL_VERSION, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,21 +16,29 @@
|
||||
|
||||
//! The worker-side session state machine — the in-process mirror of
|
||||
//! `OakWorkerSession` in `engine/src/capi/worker.cpp` (whose production
|
||||
//! Rust port lives in `oakengine::worker`).
|
||||
//! 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 facade's job (see `crate::main`); this module keeps
|
||||
//! the message handling testable in-process against the facade's real
|
||||
//! shared-memory transport. Where the C++ session has machinery the Rust
|
||||
//! 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::ipc::{self, HandshakeMsg, LoadGraphMsg, RenderFrameMsg};
|
||||
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.
|
||||
@@ -61,7 +69,7 @@ impl WorkerSession {
|
||||
}
|
||||
|
||||
/// The attached output pool (the worker->main frame-slot pool).
|
||||
pub fn output_pool(&self) -> Option<&oakengine::ipc::FrameSlotPool> {
|
||||
pub fn output_pool(&self) -> Option<&crate::engine_ipc::FrameSlotPool> {
|
||||
self.pools.as_ref().map(|p| &p.output_pool)
|
||||
}
|
||||
|
||||
@@ -196,7 +204,7 @@ impl Default for WorkerSession {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oakengine::ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode};
|
||||
use crate::engine_ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode};
|
||||
use serde_json::json;
|
||||
|
||||
/// A unique, temporary POSIX segment key for a test.
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
//! 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 facade crate (`oakengine::ipc`, the
|
||||
//! Rust port of `engine/render/ipc/` behind
|
||||
//! `engine/include/oakengine/ipc.h`) — this module is the worker-side
|
||||
//! transport over it.
|
||||
//! 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
|
||||
@@ -35,10 +35,16 @@
|
||||
//! 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.
|
||||
|
||||
use oakengine::ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode};
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::ipc::HandshakeMsg;
|
||||
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 \
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Binary-level tests for `oak-worker`: argument handling and the process
|
||||
//! exit contract. The NDJSON control-loop behavior itself is exercised
|
||||
//! in-process in `src/session.rs` (a real loop test would require a working
|
||||
//! GPU backend, so it stays out of the unit suite).
|
||||
//! Binary-level tests for `oak-worker`: the process exit contract. The
|
||||
//! NDJSON control-loop behavior itself is exercised in-process in
|
||||
//! `src/session.rs` (a real loop test would require a working GPU backend,
|
||||
//! so it stays out of the unit suite).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -25,28 +25,6 @@ fn bin() -> &'static str {
|
||||
env!("CARGO_BIN_EXE_oak-worker")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_exits_zero() {
|
||||
let out = Command::new(bin())
|
||||
.arg("--help")
|
||||
.output()
|
||||
.expect("spawn oak-worker");
|
||||
assert_eq!(out.status.code(), Some(0));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.contains("oak-worker"));
|
||||
assert!(stdout.contains("--backend"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flag_is_a_clap_usage_error() {
|
||||
let out = Command::new(bin())
|
||||
.arg("--frobnicate")
|
||||
.output()
|
||||
.expect("spawn oak-worker");
|
||||
// clap's usage-error exit code.
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_none_exits_one_like_the_cpp_main() {
|
||||
// Mirrors oakengine_worker_main(): without a renderer the worker cannot
|
||||
|
||||
@@ -176,3 +176,29 @@ fn lut_library_stubs() {
|
||||
-3
|
||||
);
|
||||
}
|
||||
|
||||
// repro: render_audio on an empty sequence (playback tick on an empty timeline).
|
||||
#[test]
|
||||
fn render_audio_empty_sequence_no_crash() {
|
||||
super::common::force_link();
|
||||
unsafe {
|
||||
assert_eq!(crate::render::oakengine_render_manager_init(), 0);
|
||||
let project = crate::node::oakengine_project_create();
|
||||
assert!(!project.is_null());
|
||||
assert_eq!(crate::node::oakengine_project_new(project), 0);
|
||||
let name = std::ffi::CString::new("s").unwrap();
|
||||
let seq = crate::timeline::oakengine_sequence_new(project, name.as_ptr());
|
||||
assert!(!seq.is_null());
|
||||
assert_eq!(crate::timeline::oakengine_sequence_add_track(seq, 1), 0); // audio track, no clips
|
||||
let r = crate::render::oakengine_renderer_create(seq, 64, 64, 4, 25, 1, std::ptr::null());
|
||||
assert!(!r.is_null());
|
||||
for i in 0..5 {
|
||||
let buf = crate::render::oakengine_renderer_render_audio(r, i * 2048, 2048);
|
||||
if !buf.is_null() {
|
||||
crate::render::oakengine_audio_free(buf);
|
||||
}
|
||||
}
|
||||
crate::render::oakengine_renderer_free(r);
|
||||
crate::node::oakengine_project_free(project);
|
||||
}
|
||||
}
|
||||
|
||||
+122
@@ -2267,6 +2267,128 @@ mod tests {
|
||||
assert_eq!(imported, vec![dropped]);
|
||||
}
|
||||
|
||||
/// Dragging a media entry from the project explorer onto the timeline
|
||||
/// places a clip there: the row starts a [`FootageDrag`] carrying the
|
||||
/// entry id, the timeline panel resolves the cursor to a display track +
|
||||
/// frame, and the engine records the `drop_footage` request with the
|
||||
/// correct parameters.
|
||||
#[gpui::test]
|
||||
async fn dragging_footage_onto_the_timeline_places_a_clip(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (window, root) = mock_shell(cx);
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
|
||||
// The mock project's root-level footage entry ("第一稿.mp4", id 3) is
|
||||
// the drag source; the timeline's clip area is the drop target.
|
||||
let row = cx
|
||||
.debug_bounds("gpui-widgets-explorer-entry-3")
|
||||
.expect("root-level footage row rendered");
|
||||
let canvas = cx
|
||||
.debug_bounds("timeline-canvas")
|
||||
.expect("timeline body rendered");
|
||||
// A point inside the clip area: 100 px into the ruler's content
|
||||
// (frame 50 at the shell's default zoom of 2 px/frame) on the first
|
||||
// track row (V2, 64 px tall).
|
||||
let drop = gpui::point(
|
||||
canvas.left() + px(gpui::timeline::HEADER_WIDTH + 100.0),
|
||||
canvas.top() + px(gpui::timeline::RULER_HEIGHT + 20.0),
|
||||
);
|
||||
|
||||
// Dispatch the whole gesture against the same rendered frame: gpui's
|
||||
// interactive listeners are registered per render, so a repaint
|
||||
// between the events would consume them before the drop lands (same
|
||||
// caveat as the file-drop test above).
|
||||
cx.update(|window, cx| {
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseDown(gpui::MouseDownEvent {
|
||||
position: row.center(),
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
button: gpui::MouseButton::Left,
|
||||
click_count: 1,
|
||||
first_mouse: false,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
// Move past the drag threshold to start the drag.
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
|
||||
position: row.center() + gpui::point(px(6.0), px(0.0)),
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
pressed_button: Some(gpui::MouseButton::Left),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
// Hover the drop point (the panel resolves the track + frame).
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
|
||||
position: drop,
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
pressed_button: Some(gpui::MouseButton::Left),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
// Release over the drop point.
|
||||
window.dispatch_event(
|
||||
gpui::PlatformInput::MouseUp(gpui::MouseUpEvent {
|
||||
position: drop,
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
button: gpui::MouseButton::Left,
|
||||
click_count: 1,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let drops = cx.read(|app| root.read(app).engine.read(app).footage_drops().to_vec());
|
||||
assert_eq!(drops.len(), 1, "the drop must reach the engine exactly once");
|
||||
assert_eq!(drops[0].id, 3);
|
||||
assert_eq!(drops[0].track_kind, gpui::timeline::TrackKind::Video);
|
||||
assert_eq!(drops[0].track_index, 0);
|
||||
assert_eq!(drops[0].time, gpui::timeline::Frame(50));
|
||||
}
|
||||
|
||||
/// The project explorer lists root-level imported footage in BOTH views:
|
||||
/// the tree shows the entry as a row, and after switching to the icon
|
||||
/// grid the same entry (plus folder children) appears as icons.
|
||||
#[gpui::test]
|
||||
async fn explorer_views_list_root_level_footage(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
|
||||
let (window, _root) = mock_shell(cx);
|
||||
let mut cx = VisualTestContext::from_window(window.into(), cx);
|
||||
|
||||
// Tree view: the root-level footage entry (id 3 "第一稿.mp4") renders
|
||||
// as a row.
|
||||
assert!(
|
||||
cx.debug_bounds("gpui-widgets-explorer-entry-3").is_some(),
|
||||
"tree view shows the root-level footage row"
|
||||
);
|
||||
|
||||
// Switch to the icon grid; root-level footage and folder children
|
||||
// both appear as icons, the folder root itself does not.
|
||||
let toggle = cx
|
||||
.debug_bounds("gpui-widgets-explorer-icons")
|
||||
.expect("icons toggle rendered");
|
||||
cx.simulate_click(toggle.center(), gpui::Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
window.draw(cx).clear();
|
||||
});
|
||||
|
||||
assert!(
|
||||
cx.debug_bounds("gpui-widgets-explorer-icon-3").is_some(),
|
||||
"root-level footage shows as an icon"
|
||||
);
|
||||
assert!(
|
||||
cx.debug_bounds("gpui-widgets-explorer-icon-10").is_some(),
|
||||
"a folder child shows as an icon"
|
||||
);
|
||||
assert!(
|
||||
cx.debug_bounds("gpui-widgets-explorer-icon-1").is_none(),
|
||||
"the footage folder root itself has no icon"
|
||||
);
|
||||
}
|
||||
|
||||
/// The command-line parser understands the project path and the mock
|
||||
/// flag, and the `OAK_ENGINE` env var forces the mock.
|
||||
#[test]
|
||||
|
||||
@@ -444,6 +444,31 @@ pub trait AppEngine:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Places the footage with project-explorer entry `id` on the timeline:
|
||||
/// a clip on the track at display `track_index` starting at `time`
|
||||
/// (undoable where the backend supports it — the facade
|
||||
/// `oakengine_sequence_add_footage_clip_ex` pushes one "Add Clip" undo
|
||||
/// entry).
|
||||
///
|
||||
/// The timeline panel resolves the cursor to a display track + frame and
|
||||
/// forwards them here; the backend resolves `id` to its footage, picks
|
||||
/// the target track and places the clip. Track policy: the pointed track
|
||||
/// is used when its kind matches the footage's media type; a mismatch
|
||||
/// (audio footage onto a video track, or vice versa) auto-selects the
|
||||
/// topmost track of the footage's kind, and the drop is rejected when no
|
||||
/// such track exists (the facade validates only the track type — video
|
||||
/// or audio, subtitles are rejected — never the media/track pairing).
|
||||
/// Default: no-op.
|
||||
fn drop_footage(
|
||||
&mut self,
|
||||
_id: u64,
|
||||
_track_kind: TrackKind,
|
||||
_track_index: usize,
|
||||
_time: Frame,
|
||||
_cx: &mut Context<Self>,
|
||||
) {
|
||||
}
|
||||
|
||||
/// Starts an export of the current sequence in `format` to `path` and
|
||||
/// returns a session the host polls for progress and can cancel.
|
||||
///
|
||||
|
||||
@@ -410,6 +410,15 @@ unsafe extern "C" {
|
||||
/// thread (two-stage buf/size getter; empty when the last call
|
||||
/// succeeded).
|
||||
pub fn oakengine_footage_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
|
||||
/// `oakengine_footage_borrow` — wrap a footage node in a borrowed
|
||||
/// footage box (addref'd; free with `oakengine_footage_free`). NULL
|
||||
/// when `node` is NULL or not a footage node.
|
||||
pub fn oakengine_footage_borrow(node: *mut OakEngineNode) -> *mut OakEngineFootage;
|
||||
/// `oakengine_footage_get_duration` — media duration in seconds.
|
||||
pub fn oakengine_footage_get_duration(
|
||||
self_: *mut OakEngineFootage,
|
||||
seconds: *mut f64,
|
||||
) -> c_int;
|
||||
/// `oakengine_sequence_add_footage_clip_ex` — place a clip of
|
||||
/// `footage` on the track, skipping the unenforceable same-project
|
||||
/// check (sequences live in their own scratch project — documented
|
||||
|
||||
@@ -192,6 +192,20 @@ impl PlaybackClock for MockClock {
|
||||
// Timeline model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A recorded timeline drop of a footage entry (mock state; the mock has no
|
||||
/// media pipeline, so it records the request and places a demo clip).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MockFootageDrop {
|
||||
/// The dropped project-explorer entry id.
|
||||
pub id: u64,
|
||||
/// The track kind the clip landed on.
|
||||
pub track_kind: TrackKind,
|
||||
/// The display track index the clip landed on.
|
||||
pub track_index: usize,
|
||||
/// The clip's start frame.
|
||||
pub time: Frame,
|
||||
}
|
||||
|
||||
/// A clip on the demo timeline.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockClip {
|
||||
@@ -477,6 +491,11 @@ pub struct MockEngine {
|
||||
/// has no media pipeline, so it just records them (drives app-level tests
|
||||
/// of the import flow).
|
||||
imported_footage: Vec<PathBuf>,
|
||||
/// Footage entries dropped onto the timeline via
|
||||
/// [`AppEngine::drop_footage`] since creation (mock state; each entry is
|
||||
/// the applied (id, track kind, track index, start frame) — drives
|
||||
/// app-level tests of the explorer→timeline drag).
|
||||
footage_drops: Vec<MockFootageDrop>,
|
||||
/// The fake project library the project manager browses (M13 D4): an
|
||||
/// in-memory row set the library trait methods operate on, so the app
|
||||
/// flow (list / open / create / rename / duplicate / delete / import / /// export) is testable without a database.
|
||||
@@ -743,6 +762,7 @@ impl MockEngine {
|
||||
node_selection: BTreeSet::new(),
|
||||
cpu_frame_cache: Mutex::new(HashMap::new()),
|
||||
imported_footage: Vec::new(),
|
||||
footage_drops: Vec::new(),
|
||||
library: demo_library(),
|
||||
next_library_id: 100,
|
||||
library_opened: Vec::new(),
|
||||
@@ -1389,6 +1409,87 @@ impl AppEngine for MockEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_footage(
|
||||
&mut self,
|
||||
id: u64,
|
||||
track_kind: TrackKind,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// The footage's media type, inferred from its entry name (the mock
|
||||
// never probes media). Entries the explorer does not list are
|
||||
// rejected.
|
||||
let Some(name) = self.footage_entry_name(id) else {
|
||||
println!("[mock engine] drop footage: entry {id} not in the project");
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
let footage_kind = if crate::oakui::filename_is_audio(&name) {
|
||||
TrackKind::Audio
|
||||
} else {
|
||||
TrackKind::Video
|
||||
};
|
||||
// Track policy (mirrors the facade semantics, see `AppEngine`: the
|
||||
// pointed track is used when it matches the footage's kind; a
|
||||
// mismatch auto-selects the topmost track of the footage's kind; no
|
||||
// matching track rejects the drop).
|
||||
let target = if self.tracks.get(track_index).map(|t| t.kind) == Some(footage_kind) {
|
||||
track_index
|
||||
} else if let Some(index) = self.tracks.iter().position(|t| t.kind == footage_kind) {
|
||||
index
|
||||
} else {
|
||||
println!(
|
||||
"[mock engine] drop footage: no {:?} track for {:?} media \"{name}\"",
|
||||
footage_kind, track_kind
|
||||
);
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
// A 10-second demo clip (the mock has no media durations).
|
||||
let fps = self.frame_rate();
|
||||
let length = Frame(
|
||||
(10.0 * fps.num as f64 / fps.den.max(1) as f64).round().max(1.0) as i64,
|
||||
);
|
||||
let clip = MockClip {
|
||||
id: ClipId(self.next_mock_clip_id()),
|
||||
range: FrameRange::new(Frame(time.0.max(0)), Frame(time.0.max(0) + length.0)),
|
||||
media_in: Frame::ZERO,
|
||||
label: name.clone().into(),
|
||||
color: if footage_kind == TrackKind::Audio {
|
||||
Hsla {
|
||||
h: 0.402,
|
||||
s: 0.32,
|
||||
l: 0.54,
|
||||
a: 1.0,
|
||||
}
|
||||
} else {
|
||||
Hsla {
|
||||
h: 0.402,
|
||||
s: 0.385,
|
||||
l: 0.459,
|
||||
a: 1.0,
|
||||
}
|
||||
},
|
||||
};
|
||||
// Keep the track's clips in ascending frame order (a data-source
|
||||
// consistency requirement of the timeline widget).
|
||||
let track = &mut self.tracks[target];
|
||||
let position = track
|
||||
.clips
|
||||
.iter()
|
||||
.position(|c| c.range.start.0 > time.0)
|
||||
.unwrap_or(track.clips.len());
|
||||
track.clips.insert(position, clip);
|
||||
self.footage_drops.push(MockFootageDrop {
|
||||
id,
|
||||
track_kind: footage_kind,
|
||||
track_index: target,
|
||||
time: Frame(time.0.max(0)),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn export_project_path(&mut self, _path: PathBuf, cx: &mut Context<Self>) -> Result<(), String> {
|
||||
println!("[mock engine] export: no persistence in mock mode");
|
||||
cx.notify();
|
||||
@@ -1659,6 +1760,27 @@ impl MockEngine {
|
||||
&self.imported_footage
|
||||
}
|
||||
|
||||
/// The footage entries dropped onto the timeline so far (mock state; see
|
||||
/// [`MockFootageDrop`]).
|
||||
pub fn footage_drops(&self) -> &[MockFootageDrop] {
|
||||
&self.footage_drops
|
||||
}
|
||||
|
||||
/// The display name of the project-explorer entry with `id`, if any.
|
||||
fn footage_entry_name(&self, id: u64) -> Option<String> {
|
||||
for entry in self.roots() {
|
||||
if entry.id == id {
|
||||
return Some(entry.name.to_string());
|
||||
}
|
||||
for child in self.children(entry.id) {
|
||||
if child.id == id {
|
||||
return Some(child.name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The undo/redo call counts (test observability; see the fields).
|
||||
pub fn undo_redo_calls(&self) -> (u64, u64) {
|
||||
(self.undo_calls, self.redo_calls)
|
||||
|
||||
@@ -58,4 +58,25 @@ pub use engine::{
|
||||
};
|
||||
pub use mock::{MockClock, MockEngine};
|
||||
pub use real::{RealClock, RealEngine};
|
||||
|
||||
/// Whether `name` (a media file name) denotes audio-only media, by
|
||||
/// extension.
|
||||
///
|
||||
/// The facade's module footage is never probed (`oakengine` imports media
|
||||
/// without decoding it), so the stream counts it exposes are always empty
|
||||
/// and the timeline drop's track matching falls back to the extension:
|
||||
/// known audio containers count as audio, everything else as video.
|
||||
pub fn filename_is_audio(name: &str) -> bool {
|
||||
matches!(
|
||||
std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.as_deref(),
|
||||
Some(
|
||||
"wav" | "mp3" | "flac" | "aac" | "ogg" | "oga" | "opus" | "m4a" | "wma" | "aiff"
|
||||
| "aif" | "ac3" | "amr" | "ape" | "caf"
|
||||
)
|
||||
)
|
||||
}
|
||||
pub use transport::{PlayState, TransportState};
|
||||
|
||||
+294
-14
@@ -384,9 +384,12 @@ enum FullResTarget {
|
||||
/// An addref'd sequence box (released with [`free_box`], last, after
|
||||
/// the renderer so the sequence outlives the renderer's borrowed view).
|
||||
Sequence(SendPtr<OakEngineSequence>),
|
||||
/// A boxed footage node (freed with `oakengine_node_free` once the
|
||||
/// renderer has resolved its footage spec).
|
||||
Node(SendPtr<OakEngineNode>),
|
||||
/// A boxed footage node plus an addref'd copy of its project. The node
|
||||
/// box alone does NOT keep the graph alive: dropping the project while
|
||||
/// a job is in flight leaves the node dangling (observed crash:
|
||||
/// misaligned pointer dereference in `oakengine_node_free`). The
|
||||
/// project copy is released after the node.
|
||||
Node(SendPtr<OakEngineNode>, SendPtr<OakEngineProject>),
|
||||
}
|
||||
|
||||
/// One background full-resolution render request (built on the UI thread
|
||||
@@ -867,6 +870,58 @@ impl RealEngine {
|
||||
self.project.as_ref().map(ProjectHandle::ptr)
|
||||
}
|
||||
|
||||
/// An addref'd copy of the project handle, boxed for the background
|
||||
/// worker — same lifetime contract as [`RealEngine::sequence_copy`]:
|
||||
/// the copy keeps the graph alive after the engine's own project is
|
||||
/// dropped (the source monitor's footage node dangles otherwise).
|
||||
fn project_copy(&self) -> Option<*mut OakEngineProject> {
|
||||
let project = self.project_ptr()?;
|
||||
// SAFETY: `project` is the engine's live project box.
|
||||
let handle = unsafe { unbox(project) }?;
|
||||
let addref = handle.addref?;
|
||||
// SAFETY: `handle` is a live module handle; addref takes a new
|
||||
// reference the copy releases.
|
||||
unsafe { addref(handle.ctx) };
|
||||
Some(unsafe { box_handle::<OakEngineProject>(handle) })
|
||||
}
|
||||
|
||||
/// The selected footage's duration in frames at the current rate
|
||||
/// (0 when nothing is selected or the footage was not probed).
|
||||
fn source_length(&self) -> Frame {
|
||||
let Some(project) = self.project_ptr() else {
|
||||
return Frame(0);
|
||||
};
|
||||
let Some(id) = self.selected_item else {
|
||||
return Frame(0);
|
||||
};
|
||||
let count = unsafe { oakengine_project_footage_count(project) };
|
||||
for i in 0..count.max(0) {
|
||||
let f = unsafe { oakengine_project_footage_at(project, i) };
|
||||
if f.is_null() {
|
||||
continue;
|
||||
}
|
||||
let matches = unsafe { oakengine_node_identity(f) } == id;
|
||||
if matches {
|
||||
// The footage list yields node boxes; borrow the footage
|
||||
// view for the duration query.
|
||||
let footage = unsafe { oakengine_footage_borrow(f) };
|
||||
unsafe { oakengine_node_free(f) };
|
||||
let mut seconds: f64 = 0.0;
|
||||
let ok = !footage.is_null()
|
||||
&& unsafe { oakengine_footage_get_duration(footage, &mut seconds) } == 0;
|
||||
unsafe { oakengine_footage_free(footage) };
|
||||
if ok && seconds > 0.0 {
|
||||
let rate = self.frame_rate();
|
||||
let fps = rate.num as f64 / rate.den.max(1) as f64;
|
||||
return Frame((seconds * fps).round().max(1.0) as i64);
|
||||
}
|
||||
return Frame(0);
|
||||
}
|
||||
unsafe { oakengine_node_free(f) };
|
||||
}
|
||||
Frame(0)
|
||||
}
|
||||
|
||||
/// Current sequence length (0 without a sequence).
|
||||
fn sequence_length(&self) -> Frame {
|
||||
self.sequence_info
|
||||
@@ -1218,7 +1273,14 @@ impl RealEngine {
|
||||
let height = info.format.height.max(1) as c_int;
|
||||
let target = match monitor {
|
||||
Monitor::Program => FullResTarget::Sequence(SendPtr(self.sequence_copy()?)),
|
||||
Monitor::Source => FullResTarget::Node(SendPtr(self.selected_footage_node()?)),
|
||||
Monitor::Source => {
|
||||
// The project copy MUST be taken while the engine's own
|
||||
// project is still alive (it keeps the node valid).
|
||||
FullResTarget::Node(
|
||||
SendPtr(self.selected_footage_node()?),
|
||||
SendPtr(self.project_copy()?),
|
||||
)
|
||||
}
|
||||
};
|
||||
Some(FullResRequest {
|
||||
monitor,
|
||||
@@ -1269,10 +1331,11 @@ impl RealEngine {
|
||||
std::ptr::null(),
|
||||
)
|
||||
}
|
||||
FullResTarget::Node(node) => {
|
||||
// SAFETY: the renderer resolves its own footage spec at
|
||||
// creation; the node box is no longer needed after it.
|
||||
let renderer = oakengine_renderer_create_for_node(
|
||||
FullResTarget::Node(node, _) => {
|
||||
// The node stays alive until release_full_res_target
|
||||
// (freed exactly once there); the renderer resolves its
|
||||
// footage spec at creation and borrows nothing beyond.
|
||||
oakengine_renderer_create_for_node(
|
||||
node.0,
|
||||
width,
|
||||
height,
|
||||
@@ -1280,9 +1343,7 @@ impl RealEngine {
|
||||
rate_num,
|
||||
rate_den,
|
||||
std::ptr::null(),
|
||||
);
|
||||
oakengine_node_free(node.0);
|
||||
renderer
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1327,7 +1388,12 @@ impl RealEngine {
|
||||
unsafe {
|
||||
match target {
|
||||
FullResTarget::Sequence(seq) => free_box(seq.0),
|
||||
FullResTarget::Node(node) => oakengine_node_free(node.0),
|
||||
// The node goes first; the addref'd project copy outlives
|
||||
// it (the node's graph must stay alive during the free).
|
||||
FullResTarget::Node(node, project) => {
|
||||
oakengine_node_free(node.0);
|
||||
free_box(project.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1898,11 +1964,19 @@ impl EngineGateway for RealEngine {
|
||||
}
|
||||
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
// Each clock loops at its own monitor's length: the program at the
|
||||
// sequence length, the source at the selected footage's duration
|
||||
// (the sequence length is wrong for footage playback — an empty
|
||||
// project's length 0 used to freeze the source playhead at 0).
|
||||
let length = self.sequence_length();
|
||||
for clock in [&self.source_clock, &self.program_clock] {
|
||||
let source_length = self.source_length();
|
||||
for (clock, len) in [
|
||||
(&self.source_clock, source_length),
|
||||
(&self.program_clock, length),
|
||||
] {
|
||||
let clock = clock.clone();
|
||||
clock.update(cx, |clock, cx| {
|
||||
clock.tick(length);
|
||||
clock.tick(len);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
@@ -2818,6 +2892,146 @@ impl AppEngine for RealEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_footage(
|
||||
&mut self,
|
||||
id: u64,
|
||||
track_kind: TrackKind,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(project) = self.project_ptr() else {
|
||||
return;
|
||||
};
|
||||
let Some(seq) = self.seq_ptr() else {
|
||||
return;
|
||||
};
|
||||
// The explorer's entry id IS the footage node's stable identity
|
||||
// (`projectbrowser`); find the matching footage node (its box is
|
||||
// freed below).
|
||||
let mut footage_node: *mut OakEngineNode = std::ptr::null_mut();
|
||||
let mut footage_index: c_int = -1;
|
||||
let count = unsafe { oakengine_project_footage_count(project) };
|
||||
for index in 0..count.max(0) {
|
||||
let node = unsafe { oakengine_project_footage_at(project, index) };
|
||||
if node.is_null() {
|
||||
continue;
|
||||
}
|
||||
if unsafe { oakengine_node_identity(node) } == id {
|
||||
footage_node = node;
|
||||
footage_index = index;
|
||||
break;
|
||||
}
|
||||
// SAFETY: `node` is a box from `oakengine_project_footage_at`.
|
||||
unsafe { oakengine_node_free(node) };
|
||||
}
|
||||
if footage_node.is_null() {
|
||||
println!("[real engine] drop footage: entry {id} is not a footage node");
|
||||
return;
|
||||
}
|
||||
// Media type by extension: the module's footage is never probed, so
|
||||
// the facade's stream counts are empty (see `filename_is_audio`).
|
||||
let filename = read_string(|buf, size| unsafe {
|
||||
oakengine_project_footage_filename(project, footage_index, buf, size)
|
||||
});
|
||||
let footage_kind = if crate::oakui::filename_is_audio(&filename) {
|
||||
TrackKind::Audio
|
||||
} else {
|
||||
TrackKind::Video
|
||||
};
|
||||
// Track policy (see the `AppEngine::drop_footage` docs): use the
|
||||
// pointed display track when its kind matches, otherwise auto-select
|
||||
// the topmost track of the footage's kind; reject when there is none.
|
||||
// The facade itself validates only the track type (video/audio; it
|
||||
// rejects subtitles) and never the media/track pairing.
|
||||
let target = if let Some(track) = self.tracks.get(track_index) {
|
||||
if track.kind == footage_kind {
|
||||
track_index
|
||||
} else {
|
||||
match self.tracks.iter().position(|t| t.kind == footage_kind) {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
println!(
|
||||
"[real engine] drop footage: no {:?} track for {:?} media \"{}\"",
|
||||
footage_kind, track_kind, filename
|
||||
);
|
||||
// SAFETY: `footage_node` is a box from
|
||||
// `oakengine_project_footage_at`.
|
||||
unsafe { oakengine_node_free(footage_node) };
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("[real engine] drop footage: display track {track_index} does not exist");
|
||||
// SAFETY: `footage_node` is a box from `oakengine_project_footage_at`.
|
||||
unsafe { oakengine_node_free(footage_node) };
|
||||
return;
|
||||
};
|
||||
// The display list maps 1:1 onto the facade's per-type track lists
|
||||
// (see `rebuild_timeline`), so the snapshot's coordinates address the
|
||||
// facade track directly.
|
||||
let (track_type, track_index_facade) = {
|
||||
let track = &self.tracks[target];
|
||||
(track.track_type, track.track_index)
|
||||
};
|
||||
// Clip length: the footage's probed duration when available; module
|
||||
// footage is never probed, so fall back to a 10-second default.
|
||||
let fps = self.frame_rate();
|
||||
let fps_f = fps.num as f64 / fps.den.max(1) as f64;
|
||||
let footage = unsafe { oakengine_footage_borrow(footage_node) };
|
||||
let mut seconds: f64 = 0.0;
|
||||
let has_duration = !footage.is_null()
|
||||
&& unsafe { oakengine_footage_get_duration(footage, &mut seconds) } == 0
|
||||
&& seconds > 0.0;
|
||||
let length = if has_duration {
|
||||
(seconds * fps_f).round().max(1.0) as i64
|
||||
} else {
|
||||
(10.0 * fps_f).round().max(1.0) as i64
|
||||
};
|
||||
let in_ts = time.0.max(0);
|
||||
// SAFETY: `seq` and `footage` are live facade handles; the returned
|
||||
// owned clip box is freed below.
|
||||
let clip = unsafe {
|
||||
oakengine_sequence_add_footage_clip_ex(
|
||||
seq,
|
||||
footage,
|
||||
track_type,
|
||||
track_index_facade as c_int,
|
||||
in_ts,
|
||||
in_ts + length,
|
||||
0,
|
||||
)
|
||||
};
|
||||
// SAFETY: `footage` is a borrowed box (`oakengine_footage_borrow`);
|
||||
// `footage_node` a box from `oakengine_project_footage_at`.
|
||||
unsafe {
|
||||
if !footage.is_null() {
|
||||
oakengine_footage_free(footage);
|
||||
}
|
||||
oakengine_node_free(footage_node);
|
||||
}
|
||||
let rc = if clip.is_null() {
|
||||
let error = read_string(|buf, size| unsafe {
|
||||
oakengine_sequence_last_error(buf, size)
|
||||
});
|
||||
let error = if error.is_empty() {
|
||||
"add footage clip rejected".to_string()
|
||||
} else {
|
||||
error
|
||||
};
|
||||
println!("[real engine] drop footage rejected: {error}");
|
||||
-1
|
||||
} else {
|
||||
// SAFETY: `clip` is an owned facade box (`free_box`).
|
||||
unsafe { free_box(clip) };
|
||||
0
|
||||
};
|
||||
// The facade export pushes ONE undoable "Add Clip" entry; the refresh
|
||||
// also invalidates the cached rendered frames.
|
||||
self.apply_edit(rc, "drop footage", cx);
|
||||
}
|
||||
|
||||
// --- project library (M13 D4) --------------------------------------
|
||||
|
||||
fn storage_bound(&self) -> bool {
|
||||
@@ -4194,6 +4408,72 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
/// Regression: the source monitor's full-res job used to carry only the
|
||||
/// footage node box — dropping the project while the job was in flight
|
||||
/// left the node dangling, and the worker's free path died on a
|
||||
/// misaligned pointer inside the module's handle table. The request now
|
||||
/// also carries an addref'd project copy, so this scenario completes
|
||||
/// (and frees cleanly) instead of crashing.
|
||||
#[test]
|
||||
fn full_res_worker_outlives_a_dropped_project() {
|
||||
let _media = media_lock();
|
||||
if !RealEngine::ensure_render_manager() {
|
||||
panic!("the render manager failed to start");
|
||||
}
|
||||
|
||||
let project = unsafe { oakengine_project_create() };
|
||||
assert!(!project.is_null());
|
||||
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
|
||||
let media = std::env::temp_dir().join(format!(
|
||||
"oakapp_fullres_src_{}.mp4",
|
||||
std::process::id()
|
||||
));
|
||||
let media_c = CString::new(media.to_string_lossy().into_owned()).unwrap();
|
||||
assert_eq!(
|
||||
unsafe { oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10) },
|
||||
0
|
||||
);
|
||||
let footage = unsafe { oakengine_project_import_footage(project, media_c.as_ptr()) };
|
||||
assert!(!footage.is_null(), "import must succeed");
|
||||
|
||||
// The node box from the project's footage list plus the addref'd
|
||||
// project copy (what build_full_res_request now does).
|
||||
let node = unsafe { oakengine_project_footage_at(project, 0) };
|
||||
assert!(!node.is_null());
|
||||
// SAFETY: `footage` is a live box; the node box is independent.
|
||||
unsafe { oakengine_footage_free(footage) };
|
||||
let handle = unsafe { unbox(project) }.expect("project handle");
|
||||
let addref = handle.addref.expect("module handle addref");
|
||||
// SAFETY: `handle` is a live module handle; addref takes a new
|
||||
// reference the copy releases.
|
||||
unsafe { addref(handle.ctx) };
|
||||
let project_copy = unsafe { box_handle::<OakEngineProject>(handle) };
|
||||
|
||||
// The engine's own project goes away BEFORE the worker runs — the
|
||||
// pre-fix crash window.
|
||||
unsafe { oakengine_project_free(project) };
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let request = FullResRequest {
|
||||
monitor: Monitor::Source,
|
||||
frame: 0,
|
||||
generation: 1,
|
||||
target: FullResTarget::Node(SendPtr(node), SendPtr(project_copy)),
|
||||
width: 64,
|
||||
height: 64,
|
||||
rate_num: 10,
|
||||
rate_den: 1,
|
||||
};
|
||||
std::thread::spawn(move || RealEngine::full_res_worker(request, tx));
|
||||
|
||||
let event = rx
|
||||
.recv_timeout(Duration::from_secs(20))
|
||||
.expect("the worker delivers the frame after the project drop");
|
||||
let bytes = event.image.as_bytes(0).expect("one frame");
|
||||
assert_eq!(bytes.len(), 64 * 64 * 4, "full-res geometry");
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// M12 P5a: the full-resolution fill — scheduling logic (pure)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
+98
-2
@@ -41,10 +41,13 @@
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::timeline::TimelineView;
|
||||
use gpui::timeline::{
|
||||
Frame, TimelineView, TrackData, TrackKind, HEADER_WIDTH, MIN_TRACK_HEIGHT, RULER_HEIGHT,
|
||||
};
|
||||
use gpui::{div, img, prelude::*, px, Context, Entity, Window};
|
||||
use gpui::{AnyElement, App, ClickEvent, EventEmitter, Render, SharedString};
|
||||
use gpui::{AnyElement, App, ClickEvent, DragMoveEvent, EventEmitter, Render, SharedString};
|
||||
use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState};
|
||||
use gpui_widgets::project_explorer::FootageDrag;
|
||||
use gpui_widgets::slider::{Slider, SliderEvent, SliderModel};
|
||||
use gpui_widgets::tooltip::tooltip_view;
|
||||
use gpui_widgets::value::ValueKind;
|
||||
@@ -85,6 +88,21 @@ pub struct TimelinePanel<E: AppEngine> {
|
||||
snap: Entity<CheckBox>,
|
||||
/// The currently selected tool (visual only).
|
||||
selected_tool: usize,
|
||||
/// The drop point of an in-flight footage drag: the display track under
|
||||
/// the cursor plus the start frame. `None` outside the clip area or while
|
||||
/// no footage drag is active.
|
||||
footage_drop: Option<FootageDropTarget>,
|
||||
}
|
||||
|
||||
/// A footage drop target resolved from the cursor: the display track under
|
||||
/// the pointer and the clip's start frame.
|
||||
struct FootageDropTarget {
|
||||
/// The pointed track's kind.
|
||||
track_kind: TrackKind,
|
||||
/// The pointed display track index.
|
||||
track_index: usize,
|
||||
/// The start frame at the pointer.
|
||||
time: Frame,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> TimelinePanel<E> {
|
||||
@@ -154,8 +172,75 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
height,
|
||||
snap,
|
||||
selected_tool: 0,
|
||||
footage_drop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the footage-drop target under the cursor: converts the
|
||||
/// pointer (relative to the timeline body) into a display track + start
|
||||
/// frame using the timeline view's zoom/scroll state and the engine's
|
||||
/// track heights — the same affine mapping the timeline itself uses (see
|
||||
/// [`TimelineState::frame_at_point`] and the view's track-row walk).
|
||||
/// Hovering outside the clip area (above the ruler) clears the target.
|
||||
fn update_footage_drop(&mut self, event: &DragMoveEvent<FootageDrag>, cx: &mut Context<Self>) {
|
||||
let now = event.event.position - event.bounds.origin;
|
||||
// The clip area starts below the ruler and right of the track
|
||||
// headers column.
|
||||
if f32::from(now.y) < RULER_HEIGHT {
|
||||
self.footage_drop = None;
|
||||
return;
|
||||
}
|
||||
let clip_x = f32::from(now.x - px(HEADER_WIDTH)).max(0.0);
|
||||
let clip_y = now.y - px(RULER_HEIGHT);
|
||||
let state = self.timeline.read(cx).state.clone();
|
||||
let seq_len = self.engine.read(cx).sequence_length();
|
||||
let time = state.frame_at_point(px(clip_x)).clamp(Frame::ZERO, seq_len);
|
||||
// Walk the display rows top-down, clamping each to the minimum row
|
||||
// height exactly like the timeline's own `track_at_y`.
|
||||
let (track_kind, track_index) = {
|
||||
let engine = self.engine.read(cx);
|
||||
let mut acc = 0.0f32;
|
||||
let mut found = None;
|
||||
for index in 0..engine.track_count() {
|
||||
if let Some(track) = engine.track(index) {
|
||||
acc += f32::from(track.height()).max(MIN_TRACK_HEIGHT);
|
||||
if f32::from(clip_y) < acc {
|
||||
found = Some((track.kind(), index));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found.unwrap_or_else(|| {
|
||||
let last = engine.track_count().saturating_sub(1);
|
||||
engine
|
||||
.track(last)
|
||||
.map(|t| (t.kind(), last))
|
||||
.unwrap_or((TrackKind::Video, 0))
|
||||
})
|
||||
};
|
||||
self.footage_drop = Some(FootageDropTarget {
|
||||
track_kind,
|
||||
track_index,
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies a finished footage drop: routes the payload's footage id with
|
||||
/// the last hovered track + frame to the engine, which resolves the
|
||||
/// footage, validates the track and places the clip (undoable).
|
||||
fn finish_footage_drop(&mut self, drag: &FootageDrag, cx: &mut Context<Self>) {
|
||||
let Some(target) = self.footage_drop.take() else {
|
||||
return;
|
||||
};
|
||||
let FootageDropTarget {
|
||||
track_kind,
|
||||
track_index,
|
||||
time,
|
||||
} = target;
|
||||
self.engine.update(cx, |engine, cx| {
|
||||
engine.drop_footage(drag.0, track_kind, track_index, time, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> Render for TimelinePanel<E> {
|
||||
@@ -330,6 +415,17 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
|
||||
.debug_selector(|| "timeline-canvas".into())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
// Footage drop target: hover resolves the track +
|
||||
// frame (see [`TimelinePanel::update_footage_drop`]),
|
||||
// the release routes the payload to the engine.
|
||||
.on_drag_move(cx.listener(
|
||||
|this, event: &DragMoveEvent<FootageDrag>, _window, cx| {
|
||||
this.update_footage_drop(event, cx);
|
||||
},
|
||||
))
|
||||
.on_drop(cx.listener(|this, drag: &FootageDrag, _window, cx| {
|
||||
this.finish_footage_drop(drag, cx);
|
||||
}))
|
||||
.child(self.timeline.clone()),
|
||||
)
|
||||
.child(right_controls),
|
||||
|
||||
Reference in New Issue
Block a user