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:
+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) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user