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:
2026-08-16 19:07:07 +08:00
parent 36ca413a84
commit 061aab9180
31 changed files with 3446 additions and 2680 deletions
+199 -20
View File
@@ -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 -40
View File
@@ -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
View File
@@ -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
}
+338 -25
View File
@@ -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
}
+526 -26
View File
@@ -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)
}