refactor(cli,worker): cut liboakengine, link module rlibs directly (M14 R2)
- oak-cli: new engine.rs assembly layer maps every facade call to module Rust APIs (oaknode graph/serializer, oaktimeline commands, oakrender ticket arena, oaktask ExportTask, oakcommon config); the ffi/optional/host layers and build.rs link config are gone - oak-worker: the worker session + POSIX shm transport moved into the crate (oakrender backend + serde_json control plane); no dylib - both binaries carry zero liboakengine references (otool verified); tests green (30 cli / 41 worker)
This commit is contained in:
+113
-139
@@ -17,99 +17,71 @@
|
||||
//! `oak-cli info <project.ove>` — print the project name, its sequences and
|
||||
//! its footage (port of `cmd_info()` in cli/main.cpp).
|
||||
//!
|
||||
//! Runs entirely through the C ABI: `oakengine_init(OAKENGINE_INIT_HEADLESS)`
|
||||
//! → `oakengine_project_create` + `oakengine_project_load(path)` →
|
||||
//! `oakengine_project_name`/`filename`/`is_modified`/`sequence_count`/
|
||||
//! `sequence_at` (+ the `oakengine_sequence_*` getters) /`footage_count`/
|
||||
//! `footage_filename` → `oakengine_project_free` + `oakengine_shutdown()`.
|
||||
//! The output is formatted by `crate::fmt` exactly like the C++ binary.
|
||||
//! Runs entirely through the module crates (M14 R2):
|
||||
//! [`crate::engine::load_project`] (the oaknode serializer) produces the
|
||||
//! project, and the project's graph supplies the sequences and footage
|
||||
//! (arena order, like the facade's `project_sequence_at` walk). The output
|
||||
//! is formatted by `crate::fmt` exactly like the C++ binary.
|
||||
//!
|
||||
//! Footage filenames stored relative to the `.ove` file are resolved
|
||||
//! against the project directory for display (the C++ CLI's project-dir
|
||||
//! against the project directory for display (the C++ project-dir
|
||||
//! convention); the online flag reports whether the resolved file exists.
|
||||
//! A load failure prints the engine's error and exits 1.
|
||||
//! A load failure prints the module's error and exits 1.
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::cmd::{EXIT_ERROR, EXIT_OK};
|
||||
use crate::ffi;
|
||||
use crate::engine;
|
||||
use crate::fmt;
|
||||
|
||||
/// Run `info`. `project` is the .ove path from the command line.
|
||||
pub fn run(project: String) -> i32 {
|
||||
let code = run_info(&project);
|
||||
unsafe {
|
||||
crate::optional::engine_shutdown();
|
||||
}
|
||||
code
|
||||
run_info(&project)
|
||||
}
|
||||
|
||||
/// The info body; the caller owns the engine shutdown.
|
||||
/// The info body.
|
||||
fn run_info(project: &str) -> i32 {
|
||||
let rc = unsafe { crate::optional::engine_init(crate::ffi::OAKENGINE_INIT_HEADLESS) };
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: info: engine init failed ({rc})");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let handle = unsafe { crate::ffi::oakengine_project_create() };
|
||||
if handle.is_null() {
|
||||
eprintln!("error: info: cannot create project");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let path = match CString::new(project) {
|
||||
let project_ref = match engine::load_project(project) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("error: info: invalid path (NUL byte)");
|
||||
unsafe { crate::ffi::oakengine_project_free(handle) };
|
||||
Err(detail) => {
|
||||
if detail.is_empty() {
|
||||
eprintln!("error: info: cannot load project \"{project}\"");
|
||||
} else {
|
||||
eprintln!("error: info: {detail}");
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
let mut err = [0 as std::ffi::c_char; 4096];
|
||||
let rc = unsafe {
|
||||
crate::ffi::oakengine_project_load(handle, path.as_ptr(), err.as_mut_ptr(), err.len() as i32)
|
||||
};
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
// SAFETY: the engine NUL-terminates `err` on failure.
|
||||
let detail = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
if detail.is_empty() {
|
||||
eprintln!("error: info: cannot load project \"{project}\"");
|
||||
} else {
|
||||
eprintln!("error: info: {detail}");
|
||||
}
|
||||
unsafe { crate::ffi::oakengine_project_free(handle) };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let name = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_project_name(handle, buf, size)
|
||||
});
|
||||
let filename = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_project_filename(handle, buf, size)
|
||||
});
|
||||
let modified = unsafe { crate::ffi::oakengine_project_is_modified(handle) } != 0;
|
||||
|
||||
// The engine's serializer swaps a fresh project payload in on load,
|
||||
// wiping the pre-load filename (documented engine behavior); when the
|
||||
// engine reports an empty filename the CLI falls back to the path it
|
||||
// loaded — the C++ CLI's own project filename convention.
|
||||
// The module serializer swaps a fresh project payload in on load,
|
||||
// wiping the pre-load filename; when the project reports an empty
|
||||
// filename the CLI falls back to the path it loaded — the C++ CLI's own
|
||||
// project filename convention.
|
||||
let abs = std::fs::canonicalize(project).unwrap_or_else(|_| Path::new(project).to_path_buf());
|
||||
let name = if name.is_empty() || name == "(untitled)" {
|
||||
abs.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.and_then(|f| f.split('.').next().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "(untitled)".to_string())
|
||||
} else {
|
||||
name
|
||||
let name = {
|
||||
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let n = engine::project_name(&guard);
|
||||
if n.is_empty() || n == "(untitled)" {
|
||||
abs.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.and_then(|f| f.split('.').next().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "(untitled)".to_string())
|
||||
} else {
|
||||
n
|
||||
}
|
||||
};
|
||||
let filename = if filename.is_empty() {
|
||||
abs.to_string_lossy().into_owned()
|
||||
} else {
|
||||
filename
|
||||
let filename = {
|
||||
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let f = engine::project_filename(&guard);
|
||||
if f.is_empty() {
|
||||
abs.to_string_lossy().into_owned()
|
||||
} else {
|
||||
f
|
||||
}
|
||||
};
|
||||
let modified = {
|
||||
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
||||
engine::project_modified(&guard)
|
||||
};
|
||||
|
||||
println!("{}", fmt::project_line(&name));
|
||||
@@ -120,24 +92,25 @@ fn run_info(project: &str) -> i32 {
|
||||
// resolve them for the online check (the C++ project-dir convention).
|
||||
let project_dir = Path::new(project).parent().map(|p| p.to_path_buf());
|
||||
|
||||
let sequences = unsafe { crate::ffi::oakengine_project_sequence_count(handle) }.max(0);
|
||||
println!("{}", fmt::sequences_line(sequences as i64));
|
||||
for index in 0..sequences {
|
||||
// `sequence_at` returns an owned box with no matching free
|
||||
// export (borrowed contract); it stays alive for the project.
|
||||
let seq = unsafe { crate::ffi::oakengine_project_sequence_at(handle, index) };
|
||||
if seq.is_null() {
|
||||
continue;
|
||||
}
|
||||
print_sequence(seq, index as i64);
|
||||
let sequences = {
|
||||
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
||||
engine::sequence_ids(&guard)
|
||||
};
|
||||
println!("{}", fmt::sequences_line(sequences.len() as i64));
|
||||
for (index, seq_id) in sequences.iter().enumerate() {
|
||||
print_sequence(&project_ref, *seq_id, index as i64);
|
||||
}
|
||||
|
||||
let footage = unsafe { crate::ffi::oakengine_project_footage_count(handle) }.max(0);
|
||||
println!("{}", fmt::footage_line(footage as i64));
|
||||
for index in 0..footage {
|
||||
let stored = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_project_footage_filename(handle, index, buf, size)
|
||||
});
|
||||
let footage = {
|
||||
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
||||
engine::footage_ids(&guard)
|
||||
};
|
||||
println!("{}", fmt::footage_line(footage.len() as i64));
|
||||
for (index, footage_id) in footage.iter().enumerate() {
|
||||
let stored = {
|
||||
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
||||
engine::footage_filename(&guard, *footage_id)
|
||||
};
|
||||
let resolved = resolve_footage(&stored, project_dir.as_deref());
|
||||
let online = resolved.is_file();
|
||||
println!(
|
||||
@@ -146,67 +119,68 @@ fn run_info(project: &str) -> i32 {
|
||||
);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
}
|
||||
EXIT_OK
|
||||
}
|
||||
|
||||
/// Print one sequence block (`print_sequence` in cli/main.cpp) through
|
||||
/// the `oakengine_sequence_*` getters.
|
||||
fn print_sequence(seq: *mut ffi::OakEngineSequence, index: i64) {
|
||||
let name = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_sequence_name(seq, buf, size)
|
||||
});
|
||||
/// Print one sequence block (`print_sequence` in cli/main.cpp) through the
|
||||
/// module sequence queries.
|
||||
fn print_sequence(project: &engine::ProjectRef, seq_id: oaknode::id::NodeId, index: i64) {
|
||||
let (name, length, frame_rate, track_counts, playhead) = {
|
||||
let guard = project.lock().unwrap_or_else(|e| e.into_inner());
|
||||
(
|
||||
engine::node_label(&guard.graph, seq_id),
|
||||
engine::sequence_length(&guard, seq_id),
|
||||
engine::sequence_frame_rate(&guard, seq_id),
|
||||
engine::sequence_track_counts(&guard, seq_id),
|
||||
engine::sequence_playhead(&guard, seq_id),
|
||||
)
|
||||
};
|
||||
|
||||
let mut length = 0.0f64;
|
||||
let mut len_num: i32 = 0;
|
||||
let mut len_den: i32 = 0;
|
||||
unsafe {
|
||||
let _ = crate::ffi::oakengine_sequence_get_length(seq, &mut length);
|
||||
let _ = crate::ffi::oakengine_sequence_get_length_rational(seq, &mut len_num, &mut len_den);
|
||||
}
|
||||
|
||||
let mut fr_num: i32 = 0;
|
||||
let mut fr_den: i32 = 0;
|
||||
unsafe {
|
||||
let _ = crate::ffi::oakengine_sequence_get_frame_rate(seq, &mut fr_num, &mut fr_den);
|
||||
}
|
||||
|
||||
let mut video: i32 = 0;
|
||||
let mut audio: i32 = 0;
|
||||
let mut subtitle: i32 = 0;
|
||||
unsafe {
|
||||
let _ = crate::ffi::oakengine_sequence_track_count(
|
||||
seq,
|
||||
&mut video,
|
||||
&mut audio,
|
||||
&mut subtitle,
|
||||
);
|
||||
}
|
||||
|
||||
let mut playhead: i64 = 0;
|
||||
let mut playhead_seconds = 0.0f64;
|
||||
unsafe {
|
||||
let _ = crate::ffi::oakengine_sequence_get_playhead(seq, &mut playhead);
|
||||
let _ = crate::ffi::oakengine_sequence_get_playhead_seconds(seq, &mut playhead_seconds);
|
||||
}
|
||||
let length_secs = if length.denominator() != 0 {
|
||||
length.numerator() as f64 / length.denominator() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let playhead_secs = if playhead.denominator() != 0 {
|
||||
playhead.numerator() as f64 / playhead.denominator() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// The playhead is printed as a frame timestamp in the sequence frame-rate
|
||||
// timebase (round-half-up, like the facade's `rational_to_ts`).
|
||||
let playhead_ts = {
|
||||
let fr = frame_rate;
|
||||
if fr.denominator() == 0 {
|
||||
0
|
||||
} else {
|
||||
let n = playhead.numerator() as i128 * fr.denominator() as i128;
|
||||
let d = playhead.denominator() as i128 * fr.numerator() as i128;
|
||||
if d == 0 {
|
||||
0
|
||||
} else {
|
||||
let q = n / d;
|
||||
let r = (n % d).abs();
|
||||
let dd = d.abs();
|
||||
(q + if r * 2 >= dd { 1 } else { 0 }) as i64
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
fmt::sequence(
|
||||
index,
|
||||
&name,
|
||||
length,
|
||||
len_num as i64,
|
||||
len_den as i64,
|
||||
fr_num as i64,
|
||||
fr_den as i64,
|
||||
video as i64,
|
||||
audio as i64,
|
||||
subtitle as i64,
|
||||
playhead,
|
||||
playhead_seconds,
|
||||
length_secs,
|
||||
length.numerator(),
|
||||
length.denominator(),
|
||||
frame_rate.numerator(),
|
||||
frame_rate.denominator(),
|
||||
track_counts.0,
|
||||
track_counts.1,
|
||||
track_counts.2,
|
||||
playhead_ts,
|
||||
playhead_secs,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,18 +16,17 @@
|
||||
|
||||
//! Subcommand implementations.
|
||||
//!
|
||||
//! Every subcommand is a REAL implementation over the `oakengine_*` C ABI
|
||||
//! ([`crate::ffi`] + [`crate::optional`]) — a pure consumer of the built
|
||||
//! `liboakengine` dylib, exactly like the C++ `cli/main.cpp` host:
|
||||
//! Every subcommand is a REAL implementation over the oak* module crates
|
||||
//! ([`crate::engine`] + the modules directly) — M14 R2 cut the facade
|
||||
//! dylib out of this crate:
|
||||
//!
|
||||
//! - `probe` → `oakengine_footage_probe` + the footage getters
|
||||
//! - `info` → `oakengine_project_create/load` + project/sequence
|
||||
//! getters
|
||||
//! - `render` → `oakengine_render_manager_init`,
|
||||
//! `oakengine_renderer_create` / `render_frame` / `render_audio` and
|
||||
//! the frame/audio-buffer accessors
|
||||
//! - `transcode` → project/sequence/clip assembly +
|
||||
//! `oakengine_export_render` for mp4, the renderer frame loop for ppm
|
||||
//! - `probe` → an `oaknode::footage::FootageBehavior` probe
|
||||
//! - `info` → `crate::engine::load_project` + the project graph
|
||||
//! walks
|
||||
//! - `render` → `crate::engine::render_manager_init` + the video/
|
||||
//! audio montage tickets
|
||||
//! - `transcode` → sequence assembly + the montage tickets (ppm) or the
|
||||
//! module export task (mp4)
|
||||
//!
|
||||
//! Exit codes: 0 success, 1 general error, 2 rendering unavailable,
|
||||
//! 64 usage error.
|
||||
|
||||
+56
-132
@@ -18,179 +18,103 @@
|
||||
//! duration and video/audio/subtitle streams (port of `cmd_probe()` in
|
||||
//! cli/main.cpp).
|
||||
//!
|
||||
//! Runs entirely through the C ABI: `oakengine_init(OAKENGINE_INIT_HEADLESS)`
|
||||
//! → `oakengine_footage_probe(path)` → the `oakengine_footage_get_*`
|
||||
//! getters → `oakengine_footage_free` + `oakengine_shutdown()`. The output
|
||||
//! is formatted by `crate::fmt` exactly like the C++ `printf` calls.
|
||||
//!
|
||||
//! The engine's probe records what the oaknode footage module probes: the
|
||||
//! decoder id, the stream counts and (per stream) the module-visible
|
||||
//! params. The module currently drops the codec's probe description, so
|
||||
//! `get_duration` reports 0 and the stream counts report 0 for media the
|
||||
//! module has not loaded stream metadata for — the CLI prints exactly
|
||||
//! what the engine answers. A missing file / failed probe prints
|
||||
//! `oakengine_footage_last_error` on stderr and exits 1.
|
||||
//! Runs entirely through the module crates (M14 R2): an
|
||||
//! [`oaknode::footage::FootageBehavior`] probes the file through the
|
||||
//! oakcodec decoder registry and the CLI prints what the module records.
|
||||
//! The module currently records the decoder id but drops the codec's
|
||||
//! stream descriptions, so `duration` reports 0 and the stream counts
|
||||
//! report 0 for media the module has not loaded stream metadata for — the
|
||||
//! CLI prints exactly what the module answers, unchanged from the facade
|
||||
//! contract. A missing file prints `error: probe: file does not exist:
|
||||
//! <path>` on stderr and exits 1.
|
||||
|
||||
use std::ffi::{CString, c_int};
|
||||
use oaknode::footage::FootageBehavior;
|
||||
|
||||
use crate::cmd::{EXIT_ERROR, EXIT_OK};
|
||||
use crate::ffi::{self, OakFootageAudioInfo, OakFootageVideoInfo};
|
||||
use crate::fmt;
|
||||
|
||||
/// Run `probe`. `mediafile` is the media path from the command line.
|
||||
pub fn run(mediafile: String) -> i32 {
|
||||
let code = run_probe(&mediafile);
|
||||
unsafe {
|
||||
crate::optional::engine_shutdown();
|
||||
}
|
||||
code
|
||||
run_probe(&mediafile)
|
||||
}
|
||||
|
||||
/// The probe body; the caller owns the engine shutdown.
|
||||
/// The probe body.
|
||||
fn run_probe(mediafile: &str) -> i32 {
|
||||
let rc = unsafe { crate::optional::engine_init(crate::ffi::OAKENGINE_INIT_HEADLESS) };
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: probe: engine init failed ({rc})");
|
||||
if !std::path::Path::new(mediafile).exists() {
|
||||
eprintln!("error: probe: file does not exist: {mediafile}");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let path = match CString::new(mediafile) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("error: probe: invalid path (NUL byte)");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
let footage = unsafe { crate::ffi::oakengine_footage_probe(path.as_ptr()) };
|
||||
if footage.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_footage_last_error(buf, size)
|
||||
});
|
||||
eprintln!("error: probe: {err}");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
// The module probe records the decoder id (best effort; a failed probe
|
||||
// leaves the node usable with empty streams, exactly like the facade's
|
||||
// footage create).
|
||||
let mut footage = FootageBehavior::new(mediafile);
|
||||
let _ = footage.probe();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
fmt::decoder_line(&crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_footage_get_decoder_name(footage, buf, size)
|
||||
}))
|
||||
);
|
||||
println!("{}", fmt::decoder_line(&footage.decoder));
|
||||
|
||||
let mut duration = 0.0f64;
|
||||
let rc = unsafe { crate::ffi::oakengine_footage_get_duration(footage, &mut duration) };
|
||||
if rc == crate::ffi::OAKENGINE_OK {
|
||||
println!("{}", fmt::duration_line(duration));
|
||||
let duration = footage.duration();
|
||||
let duration_secs = if duration.denominator() != 0 {
|
||||
duration.numerator() as f64 / duration.denominator() as f64
|
||||
} else {
|
||||
println!("{}", fmt::duration_line(0.0));
|
||||
}
|
||||
0.0
|
||||
};
|
||||
println!("{}", fmt::duration_line(duration_secs));
|
||||
|
||||
let video = unsafe { crate::ffi::oakengine_footage_get_video_stream_count(footage) }.max(0);
|
||||
let video = footage.video_stream_count();
|
||||
println!("{}", fmt::video_streams_line(video as i64));
|
||||
for index in 0..video {
|
||||
if let Some(info) = unsafe { video_stream_info(footage, index) } {
|
||||
let secs = stream_seconds(
|
||||
info.duration_ts,
|
||||
(info.time_base_num, info.time_base_den),
|
||||
);
|
||||
if let Some(params) = footage.video_params(index) {
|
||||
let fr = params.frame_rate;
|
||||
let secs = if fr.denominator() != 0 {
|
||||
fr.numerator() as f64 / fr.denominator() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
fmt::video_stream(
|
||||
index as i64,
|
||||
info.stream_index as i64,
|
||||
info.width as i64,
|
||||
info.height as i64,
|
||||
info.frame_rate_num as i64,
|
||||
info.frame_rate_den as i64,
|
||||
info.duration_ts,
|
||||
info.time_base_den as i64,
|
||||
index as i64,
|
||||
params.width as i64,
|
||||
params.height as i64,
|
||||
fr.numerator(),
|
||||
fr.denominator(),
|
||||
0,
|
||||
fr.denominator(),
|
||||
secs,
|
||||
info.color_primaries as i64,
|
||||
info.color_trc as i64,
|
||||
info.interlaced != 0,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let audio = unsafe { crate::ffi::oakengine_footage_get_audio_stream_count(footage) }.max(0);
|
||||
let audio = footage.audio_stream_count();
|
||||
println!("{}", fmt::audio_streams_line(audio as i64));
|
||||
for index in 0..audio {
|
||||
// The stream-info getter reports what the engine can describe
|
||||
// (the module's audio stream descriptions are not reachable
|
||||
// yet); streams the engine cannot describe are counted only.
|
||||
if let Some(info) = unsafe { audio_stream_info(footage, index) } {
|
||||
let secs = stream_seconds(
|
||||
info.duration_ts,
|
||||
(info.time_base_num, info.time_base_den),
|
||||
);
|
||||
// The module's audio stream descriptions are not reachable yet
|
||||
// (the stream entries are dropped by the probe); streams the module
|
||||
// cannot describe are counted only.
|
||||
if let Some(params) = footage.audio_params(index) {
|
||||
println!(
|
||||
"{}",
|
||||
fmt::audio_stream(
|
||||
index as i64,
|
||||
info.stream_index as i64,
|
||||
info.sample_rate as i64,
|
||||
info.channel_count as i64,
|
||||
info.duration_ts,
|
||||
info.time_base_den as i64,
|
||||
secs,
|
||||
index as i64,
|
||||
params.sample_rate as i64,
|
||||
params.channel_layout.count_ones() as i64,
|
||||
0,
|
||||
1,
|
||||
0.0,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let subtitle =
|
||||
unsafe { crate::ffi::oakengine_footage_get_subtitle_stream_count(footage) }.max(0);
|
||||
let subtitle = footage.subtitle_stream_count();
|
||||
println!("{}", fmt::subtitle_streams_line(subtitle as i64));
|
||||
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
}
|
||||
EXIT_OK
|
||||
}
|
||||
|
||||
/// `oakengine_footage_get_video_stream_info` into an owned POD
|
||||
/// (`None` when the engine reports the stream as unavailable).
|
||||
unsafe fn video_stream_info(footage: *mut ffi::OakEngineFootage, index: c_int) -> Option<OakFootageVideoInfo> {
|
||||
let mut info = OakFootageVideoInfo {
|
||||
stream_index: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
frame_rate_num: 0,
|
||||
frame_rate_den: 0,
|
||||
duration_ts: 0,
|
||||
time_base_num: 0,
|
||||
time_base_den: 0,
|
||||
color_primaries: 0,
|
||||
color_trc: 0,
|
||||
interlaced: 0,
|
||||
};
|
||||
let rc = unsafe { crate::ffi::oakengine_footage_get_video_stream_info(footage, index, &mut info) };
|
||||
(rc == crate::ffi::OAKENGINE_OK).then_some(info)
|
||||
}
|
||||
|
||||
/// `oakengine_footage_get_audio_stream_info` into an owned POD
|
||||
/// (`None` when the engine reports the stream as unavailable).
|
||||
unsafe fn audio_stream_info(footage: *mut ffi::OakEngineFootage, index: c_int) -> Option<OakFootageAudioInfo> {
|
||||
let mut info = OakFootageAudioInfo {
|
||||
stream_index: 0,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
channel_count: 0,
|
||||
duration_ts: 0,
|
||||
time_base_num: 0,
|
||||
time_base_den: 0,
|
||||
};
|
||||
let rc = unsafe { crate::ffi::oakengine_footage_get_audio_stream_info(footage, index, &mut info) };
|
||||
(rc == crate::ffi::OAKENGINE_OK).then_some(info)
|
||||
}
|
||||
|
||||
/// Seconds a stream spans: `duration_ts` ticks of the stream time base
|
||||
/// (`num/den` seconds per tick).
|
||||
fn stream_seconds(duration_ts: i64, time_base: (c_int, c_int)) -> f64 {
|
||||
let (num, den) = time_base;
|
||||
if den == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
duration_ts as f64 * num as f64 / den as f64
|
||||
}
|
||||
|
||||
+116
-249
@@ -18,33 +18,30 @@
|
||||
//! render the first sequence to PPM frames plus a PCM s16 WAV (port of
|
||||
//! `cmd_render()` in cli/main.cpp).
|
||||
//!
|
||||
//! Runs entirely through the C ABI:
|
||||
//! Runs entirely through the module crates (M14 R2):
|
||||
//!
|
||||
//! 1. `oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER)`
|
||||
//! — plus `oakengine_render_manager_init()`, the Rust facade's
|
||||
//! replacement for the C++ engine core's OAKENGINE_INIT_RENDER boot
|
||||
//! (the ticket render path needs the oakrender manager up).
|
||||
//! 2. `oakengine_project_create` + `oakengine_project_load` (the
|
||||
//! 1. `crate::engine::load_project` — the oaknode serializer (the
|
||||
//! process chdirs into the project directory first, like the C++
|
||||
//! CLI, so relative footage paths resolve during rendering).
|
||||
//! 3. Sequence 0's frame rate via `oakengine_sequence_get_frame_rate`
|
||||
//! and geometry via `oakengine_sequence_get_video_params`.
|
||||
//! 4. `oakengine_renderer_create(seq, w, h, f32, fr_num, fr_den, null)`
|
||||
//! → for every frame timestamp in `[start, end)`
|
||||
//! `oakengine_renderer_render_frame` → the `oakengine_frame_*`
|
||||
//! accessors → [`crate::ppm::write_ppm`] (P6, 8-bit RGB).
|
||||
//! 5. The audio range through `oakengine_renderer_render_audio` → the
|
||||
//! `oakengine_audio_*` accessors → [`crate::wav::write_wav`].
|
||||
//! 2. Sequence 0's frame rate and geometry via the sequence behavior.
|
||||
//! 3. `crate::engine::render_manager_init` — the oakrender manager's
|
||||
//! ticket arena drives the render.
|
||||
//! 4. For every frame timestamp in `[start, end)` the video montage at
|
||||
//! that time is submitted as a ticket
|
||||
//! (`crate::engine::render_frame`) → [`crate::ppm::write_ppm`]
|
||||
//! (P6, 8-bit RGB).
|
||||
//! 5. The audio range through the audio montage + ticket
|
||||
//! (`crate::engine::render_audio`) → [`crate::wav::write_wav`].
|
||||
//!
|
||||
//! Exit codes: a renderer-create or per-frame render failure exits 2
|
||||
//! (rendering unavailable, mirroring the C++ code for a missing render
|
||||
//! backend); project/sequence/argument failures exit 1; bad seconds exit
|
||||
//! 64 (usage). Frame progress goes to stderr (`frame N: T s`).
|
||||
//! Exit codes: a frame/audio render failure exits 2 (rendering
|
||||
//! unavailable, mirroring the C++ code for a missing render backend);
|
||||
//! project/sequence/argument failures exit 1; bad seconds exit 64
|
||||
//! (usage). Frame progress goes to stderr (`frame N: T s`).
|
||||
|
||||
use std::path::Path;
|
||||
use oakcore_rs::TimeRange;
|
||||
|
||||
use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE};
|
||||
use crate::ffi;
|
||||
use crate::engine;
|
||||
use crate::ppm;
|
||||
use crate::wav;
|
||||
|
||||
@@ -69,39 +66,17 @@ pub fn run(project: String, start_seconds: &str, end_seconds: &str, out_dir: &st
|
||||
return EXIT_USAGE;
|
||||
}
|
||||
|
||||
let code = run_render(&project, start, end, out_dir);
|
||||
unsafe {
|
||||
crate::optional::engine_shutdown();
|
||||
}
|
||||
code
|
||||
run_render(&project, start, end, out_dir)
|
||||
}
|
||||
|
||||
/// The render body; the caller owns the engine shutdown.
|
||||
/// The render body.
|
||||
fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
|
||||
let rc = unsafe {
|
||||
crate::optional::engine_init(
|
||||
crate::ffi::OAKENGINE_INIT_HEADLESS | crate::ffi::OAKENGINE_INIT_RENDER,
|
||||
)
|
||||
};
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: render: engine init failed ({rc})");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
// The Rust facade's render boot: the ticket render path requires the
|
||||
// oakrender manager (the C++ OAKENGINE_INIT_RENDER equivalent).
|
||||
if unsafe { crate::ffi::oakengine_render_manager_init() } != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: render: cannot initialize the render manager");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
|
||||
// Absolute project path first — the C++ CLI chdirs into the project
|
||||
// directory so relative footage paths resolve during rendering.
|
||||
let abs = match std::fs::canonicalize(project) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("error: render: cannot open project \"{project}\": {e}");
|
||||
unsafe { crate::ffi::oakengine_render_manager_shutdown() };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
@@ -109,129 +84,61 @@ fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
|
||||
let _ = std::env::set_current_dir(dir);
|
||||
}
|
||||
|
||||
let handle = unsafe { crate::ffi::oakengine_project_create() };
|
||||
if handle.is_null() {
|
||||
eprintln!("error: render: cannot create project");
|
||||
unsafe { crate::ffi::oakengine_render_manager_shutdown() };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
let path = match std::ffi::CString::new(abs.as_os_str().as_encoded_bytes()) {
|
||||
let project_ref = match engine::load_project(&abs.to_string_lossy()) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("error: render: invalid path (NUL byte)");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
Err(detail) => {
|
||||
if detail.is_empty() {
|
||||
eprintln!("error: render: cannot load project \"{project}\"");
|
||||
} else {
|
||||
eprintln!("error: render: {detail}");
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
let mut err = [0 as std::ffi::c_char; 4096];
|
||||
let rc = unsafe {
|
||||
crate::ffi::oakengine_project_load(handle, path.as_ptr(), err.as_mut_ptr(), err.len() as i32)
|
||||
|
||||
// The render manager must be up before any ticket submission (the
|
||||
// facade's OAKENGINE_INIT_RENDER render boot).
|
||||
if let Err(e) = engine::render_manager_init() {
|
||||
eprintln!("error: render: cannot initialize the render manager: {e}");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
|
||||
let sequences = {
|
||||
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
||||
engine::sequence_ids(&guard)
|
||||
};
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
// SAFETY: the engine NUL-terminates `err` on failure.
|
||||
let detail = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
if detail.is_empty() {
|
||||
eprintln!("error: render: cannot load project \"{project}\"");
|
||||
} else {
|
||||
eprintln!("error: render: {detail}");
|
||||
let seq_id = match sequences.first() {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
eprintln!("error: render: project has no sequences");
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
if unsafe { crate::ffi::oakengine_project_sequence_count(handle) } < 1 {
|
||||
eprintln!("error: render: project has no sequences");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
// Borrowed sequence box (no free export); lives for the project.
|
||||
let seq = unsafe { crate::ffi::oakengine_project_sequence_at(handle, 0) };
|
||||
if seq.is_null() {
|
||||
eprintln!("error: render: sequence 0 unavailable");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let mut fr_num: i32 = 0;
|
||||
let mut fr_den: i32 = 0;
|
||||
unsafe {
|
||||
crate::ffi::oakengine_sequence_get_frame_rate(seq, &mut fr_num, &mut fr_den);
|
||||
}
|
||||
let (fr_num, fr_den, width, height) = {
|
||||
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let fr = engine::sequence_frame_rate(&guard, seq_id);
|
||||
let (w, h) = engine::sequence_geometry(&guard, seq_id);
|
||||
(fr.numerator() as i32, fr.denominator() as i32, w, h)
|
||||
};
|
||||
if fr_num <= 0 || fr_den <= 0 {
|
||||
eprintln!("error: render: invalid sequence frame rate {fr_num}/{fr_den}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let mut width: i32 = 0;
|
||||
let mut height: i32 = 0;
|
||||
unsafe {
|
||||
crate::ffi::oakengine_sequence_get_video_params(
|
||||
seq,
|
||||
&mut width,
|
||||
&mut height,
|
||||
&mut 0,
|
||||
&mut 0,
|
||||
);
|
||||
}
|
||||
if width <= 0 || height <= 0 {
|
||||
eprintln!("error: render: sequence has no video geometry");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(out_dir) {
|
||||
eprintln!("error: render: cannot create output directory \"{out_dir}\": {e}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let renderer = unsafe {
|
||||
crate::ffi::oakengine_renderer_create(
|
||||
seq,
|
||||
width,
|
||||
height,
|
||||
crate::ffi::PIXEL_FORMAT_F32,
|
||||
fr_num,
|
||||
fr_den,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if renderer.is_null() {
|
||||
// The engine's create path returns NULL without setting the
|
||||
// renderer's last error (invalid geometry/format or no module
|
||||
// backing); report the contract message.
|
||||
eprintln!("error: render: cannot create renderer");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
|
||||
// Frame loop: timestamps in the sequence time base (1/fr_num s).
|
||||
let start_frames = (start * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let mut index = start_frames;
|
||||
@@ -241,37 +148,26 @@ fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
|
||||
if time >= end {
|
||||
break;
|
||||
}
|
||||
let frame = unsafe { crate::ffi::oakengine_renderer_render_frame(renderer, index) };
|
||||
if frame.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
|
||||
});
|
||||
let msg = if err.is_empty() {
|
||||
format!("frame at {time:.6} s failed to render")
|
||||
} else {
|
||||
format!("frame at {time:.6} s failed: {err}")
|
||||
};
|
||||
eprintln!("error: render: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
let time_r = oakcore_rs::Rational::new(index * i64::from(fr_den), i64::from(fr_num));
|
||||
let montage = engine::video_montage(&project_ref, seq_id, time_r);
|
||||
let frame = match engine::render_frame(seq_id, time_r, montage, width, height) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let msg = if e.is_empty() {
|
||||
format!("frame at {time:.6} s failed to render")
|
||||
} else {
|
||||
format!("frame at {time:.6} s failed: {e}")
|
||||
};
|
||||
eprintln!("error: render: {msg}");
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
if let Err(msg) = unsafe { write_frame_ppm(frame, out_dir, written) } {
|
||||
};
|
||||
if let Err(msg) = write_frame_ppm(&frame, out_dir, written) {
|
||||
eprintln!("error: render: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_frame_free(frame);
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
unsafe {
|
||||
crate::ffi::oakengine_frame_free(frame);
|
||||
}
|
||||
eprintln!("frame {written}: {time:.6} s");
|
||||
written += 1;
|
||||
index += 1;
|
||||
@@ -279,102 +175,73 @@ fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
|
||||
|
||||
if written == 0 {
|
||||
eprintln!("error: render: empty frame range");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
// Audio range in the sequence time base.
|
||||
let start_ts = (start * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let length_ts = ((end - start) * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let audio = unsafe { crate::ffi::oakengine_renderer_render_audio(renderer, start_ts, length_ts) };
|
||||
if audio.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
|
||||
});
|
||||
let msg = if err.is_empty() {
|
||||
"audio render failed".to_string()
|
||||
} else {
|
||||
format!("audio render failed: {err}")
|
||||
};
|
||||
eprintln!("error: render: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
let range = TimeRange::new(
|
||||
oakcore_rs::Rational::new(start_ts * i64::from(fr_den), i64::from(fr_num)),
|
||||
oakcore_rs::Rational::new(
|
||||
(start_ts + length_ts) * i64::from(fr_den),
|
||||
i64::from(fr_num),
|
||||
),
|
||||
);
|
||||
let montage = engine::audio_montage(&project_ref, seq_id, range);
|
||||
let code = match engine::render_audio(seq_id, range, montage) {
|
||||
Ok(audio) => write_audio_wav(&audio, out_dir),
|
||||
Err(e) => {
|
||||
let msg = if e.is_empty() {
|
||||
"audio render failed".to_string()
|
||||
} else {
|
||||
format!("audio render failed: {e}")
|
||||
};
|
||||
eprintln!("error: render: {msg}");
|
||||
EXIT_RENDER_UNAVAILABLE
|
||||
}
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
let code = unsafe { write_audio_wav(audio, out_dir) };
|
||||
unsafe {
|
||||
crate::ffi::oakengine_audio_free(audio);
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
crate::ffi::oakengine_project_free(handle);
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
}
|
||||
};
|
||||
engine::render_manager_shutdown();
|
||||
code
|
||||
}
|
||||
|
||||
/// Write a rendered frame as `frame_%05d.ppm` in `out_dir` (the
|
||||
/// `oakengine_frame_*` accessors feed the [`crate::ppm`] writer).
|
||||
///
|
||||
/// `oakengine_frame_channel_count` is not backed by the current engine
|
||||
/// (returns 0); the render module's frames are always in the internal
|
||||
/// RGBA layout (`VideoParams::k_internal_channel_count == 4`), so a
|
||||
/// zero/negative channel report falls back to 4 channels.
|
||||
unsafe fn write_frame_ppm(frame: *mut ffi::OakEngineFrame, out_dir: &str, index: u64) -> Result<(), String> {
|
||||
let width = unsafe { crate::ffi::oakengine_frame_width(frame) };
|
||||
let height = unsafe { crate::ffi::oakengine_frame_height(frame) };
|
||||
let format = unsafe { crate::ffi::oakengine_frame_format(frame) };
|
||||
let channels = unsafe { crate::ffi::oakengine_frame_channel_count(frame) };
|
||||
let channels = if channels > 0 { channels } else { 4 };
|
||||
let linesize = unsafe { crate::ffi::oakengine_frame_linesize_bytes(frame) };
|
||||
let data = unsafe { crate::ffi::oakengine_frame_data(frame) };
|
||||
if data.is_null() || width <= 0 || height <= 0 || linesize <= 0 {
|
||||
/// Write a rendered frame as `frame_%05d.ppm` in `out_dir`. The render
|
||||
/// frames are always in the internal RGBA layout
|
||||
/// (`VideoParams::k_internal_channel_count == 4`), so 4 channels feed the
|
||||
/// [`crate::ppm`] writer.
|
||||
fn write_frame_ppm(frame: &engine::RenderedFrame, out_dir: &str, index: u64) -> Result<(), String> {
|
||||
if frame.data.is_empty() || frame.width <= 0 || frame.height <= 0 || frame.linesize <= 0 {
|
||||
return Err(format!(
|
||||
"frame {index} has no pixel data ({}x{}, linesize {linesize})",
|
||||
width, height
|
||||
"frame {index} has no pixel data ({}x{}, linesize {})",
|
||||
frame.width, frame.height, frame.linesize
|
||||
));
|
||||
}
|
||||
let len = (linesize as usize)
|
||||
.checked_mul(height as usize)
|
||||
.ok_or_else(|| "frame buffer size overflow".to_string())?;
|
||||
// SAFETY: the engine's frame buffer is valid for linesize * height
|
||||
// bytes for the duration of this call.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(data as *const u8, len) };
|
||||
let path = Path::new(out_dir).join(format!("frame_{index:05}.ppm"));
|
||||
ppm::write_ppm(&path, width, height, format, channels, linesize, bytes)
|
||||
.map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
|
||||
let path = std::path::Path::new(out_dir).join(format!("frame_{index:05}.ppm"));
|
||||
ppm::write_ppm(
|
||||
&path,
|
||||
frame.width,
|
||||
frame.height,
|
||||
frame.format,
|
||||
4,
|
||||
frame.linesize,
|
||||
&frame.data,
|
||||
)
|
||||
.map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Write the rendered audio buffer as `audio.wav` in `out_dir` (the
|
||||
/// `oakengine_audio_*` accessors feed the [`crate::wav`] writer). The
|
||||
/// buffer is interleaved f32; the engine returns the whole buffer base
|
||||
/// for every channel, so channel 0 covers all frames.
|
||||
unsafe fn write_audio_wav(audio: *mut ffi::OakEngineAudioBuffer, out_dir: &str) -> i32 {
|
||||
let rate = unsafe { crate::ffi::oakengine_audio_sample_rate(audio) };
|
||||
let channels = unsafe { crate::ffi::oakengine_audio_channel_count(audio) };
|
||||
let samples = unsafe { crate::ffi::oakengine_audio_sample_count(audio) };
|
||||
let data = unsafe { crate::ffi::oakengine_audio_data(audio, 0) };
|
||||
if data.is_null() || rate <= 0 || channels <= 0 || samples <= 0 {
|
||||
/// buffer is interleaved f32).
|
||||
fn write_audio_wav(audio: &engine::RenderedAudio, out_dir: &str) -> i32 {
|
||||
let rate = audio.sample_rate;
|
||||
let channels = audio.channel_count;
|
||||
if rate <= 0 || channels <= 0 || audio.data.is_empty() {
|
||||
eprintln!("error: render: audio buffer is empty");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
let len = match (samples as usize).checked_mul(channels as usize) {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
eprintln!("error: render: audio buffer size overflow");
|
||||
return EXIT_RENDER_UNAVAILABLE;
|
||||
}
|
||||
};
|
||||
// SAFETY: the engine's audio buffer is valid for samples * channels
|
||||
// floats for the duration of this call.
|
||||
let floats = unsafe { std::slice::from_raw_parts(data, len) };
|
||||
let path = Path::new(out_dir).join("audio.wav");
|
||||
if let Err(e) = wav::write_wav(&path, rate, channels, samples, floats) {
|
||||
let samples = (audio.data.len() / channels as usize) as i64;
|
||||
let path = std::path::Path::new(out_dir).join("audio.wav");
|
||||
if let Err(e) = wav::write_wav(&path, rate, channels, samples, &audio.data) {
|
||||
eprintln!("error: render: cannot write \"{}\": {e}", path.display());
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
+194
-375
@@ -16,47 +16,43 @@
|
||||
|
||||
//! `oak-cli transcode <input_media> <out> [width] [--format ppm|mp4]` —
|
||||
//! "media in, renders out" round trip (port of `cmd_transcode()` in
|
||||
//! cli/main.cpp), entirely through the C ABI.
|
||||
//! cli/main.cpp), entirely through the module crates (M14 R2).
|
||||
//!
|
||||
//! The source is probed with `oakengine_footage_probe` (geometry / frame
|
||||
//! rate / duration through the `oakengine_footage_get_*` getters), then a
|
||||
//! temporary project is assembled the same way the C++ CLI did:
|
||||
//! `oakengine_project_create` + `oakengine_project_new` +
|
||||
//! `oakengine_project_import_footage` + `oakengine_sequence_new` +
|
||||
//! `oakengine_sequence_set_video_params` + `oakengine_sequence_add_track`
|
||||
//! + `oakengine_sequence_add_footage_clip_ex` (the `_ex` variant: the
|
||||
//! engine keeps created sequences in their own scratch project — a
|
||||
//! documented deviation, so the plain variant's same-project check can
|
||||
//! never pass).
|
||||
//! The source is probed with an [`oaknode::footage::FootageBehavior`]
|
||||
//! (geometry / frame rate / duration), then a temporary sequence is
|
||||
//! assembled the same way the facade did: a scratch project holds the
|
||||
//! sequence (the facade's documented `oakengine_sequence_new` deviation),
|
||||
//! [`crate::engine::set_sequence_video_params`] sets the output geometry
|
||||
//! and frame rate, tracks are added with the module's
|
||||
//! `TimelineAddTrackCommand`, and clips are placed with the
|
||||
//! `TrackPlaceBlockCommand` (scratch footage connected to each clip —
|
||||
//! the facade's `oakengine_sequence_add_footage_clip_ex` semantics).
|
||||
//!
|
||||
//! - `--format ppm` (and the image/still path): renders the frame range
|
||||
//! through `oakengine_renderer_render_frame` into P6 PPM frames via
|
||||
//! through [`crate::engine::render_frame`] into P6 PPM frames via
|
||||
//! [`crate::ppm`], plus the audio range through
|
||||
//! `oakengine_renderer_render_audio` into a PCM s16 WAV via
|
||||
//! [`crate::engine::render_audio`] into a PCM s16 WAV via
|
||||
//! [`crate::wav`] when the source has audio streams.
|
||||
//! - `--format mp4` (default): H.264/AAC through
|
||||
//! `oakengine_export_render` with the engine's exporter options
|
||||
//! (codec-default bit rates). The exporter family is currently NOT
|
||||
//! wrapped by the Rust facade, so this path reports
|
||||
//! `oakengine_export_last_error` and exits 1 until the dylib grows it
|
||||
//! (see `crate::optional`).
|
||||
//! [`crate::engine::export_sequence`] (the module export task, the
|
||||
//! facade's `oakengine_export_render` equivalent).
|
||||
//!
|
||||
//! The engine's footage probe records the decoder id but drops the
|
||||
//! codec's stream descriptions (module gap), so when the stream info is
|
||||
//! unavailable the CLI falls back to `[width]` (or 1920), a 16:9 height,
|
||||
//! 25 fps and a single-frame range — the still-image contract the C++
|
||||
//! CLI used for duration-less sources. Failures exit 1 (general error);
|
||||
//! bad arguments exit 64.
|
||||
//! The module probe records the decoder id but drops the codec's stream
|
||||
//! descriptions (module gap), so when the stream info is unavailable the
|
||||
//! CLI falls back to `[width]` (or 1920), a 16:9 height, 25 fps and a
|
||||
//! single-frame range — the still-image contract the C++ CLI used for
|
||||
//! duration-less sources. Failures exit 1 (general error); bad arguments
|
||||
//! exit 64.
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
use oaknode::footage::FootageBehavior;
|
||||
use oaknode::track::TrackType;
|
||||
|
||||
use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_USAGE};
|
||||
use crate::ffi::{self, OakExportOptions};
|
||||
use crate::engine;
|
||||
use crate::ppm;
|
||||
use crate::wav;
|
||||
|
||||
/// Source description distilled from the probe (through the C ABI).
|
||||
/// Source description distilled from the probe (through the module).
|
||||
struct SourceInfo {
|
||||
width: i32,
|
||||
height: i32,
|
||||
@@ -86,25 +82,11 @@ pub fn run(input_media: String, out: String, width: Option<String>, format: Opti
|
||||
}
|
||||
|
||||
let is_ppm = format.as_deref().unwrap_or("mp4") == "ppm";
|
||||
let code = run_transcode(&input_media, &out, width.as_deref(), is_ppm);
|
||||
unsafe {
|
||||
crate::optional::engine_shutdown();
|
||||
}
|
||||
code
|
||||
run_transcode(&input_media, &out, width.as_deref(), is_ppm)
|
||||
}
|
||||
|
||||
/// The transcode body; the caller owns the engine shutdown.
|
||||
/// The transcode body.
|
||||
fn run_transcode(input: &str, out: &str, width: Option<&str>, is_ppm: bool) -> i32 {
|
||||
let rc = unsafe {
|
||||
crate::optional::engine_init(
|
||||
crate::ffi::OAKENGINE_INIT_HEADLESS | crate::ffi::OAKENGINE_INIT_RENDER,
|
||||
)
|
||||
};
|
||||
if rc != crate::ffi::OAKENGINE_OK {
|
||||
eprintln!("error: transcode: engine init failed ({rc})");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
let src = match probe_source(input) {
|
||||
Ok(s) => s,
|
||||
Err(msg) => {
|
||||
@@ -127,8 +109,11 @@ fn run_transcode(input: &str, out: &str, width: Option<&str>, is_ppm: bool) -> i
|
||||
let frames = (src.duration * fr_num as f64 / fr_den as f64).round() as i64;
|
||||
let frames = frames.max(1);
|
||||
|
||||
// The temporary project + sequence + clips both output paths share.
|
||||
let assembly = match assemble_project(input, out_w, out_h, fr_num, fr_den, frames, src.audio_streams) {
|
||||
// The temporary sequence both output paths share. The facade kept
|
||||
// created sequences in their own scratch project (documented
|
||||
// deviation); the CLI does the same, so only the scratch project holds
|
||||
// the sequence and its clips.
|
||||
let assembly = match assemble_sequence(input, out_w, out_h, fr_num, fr_den, frames, src.audio_streams) {
|
||||
Ok(a) => a,
|
||||
Err(msg) => {
|
||||
eprintln!("error: transcode: {msg}");
|
||||
@@ -137,36 +122,24 @@ fn run_transcode(input: &str, out: &str, width: Option<&str>, is_ppm: bool) -> i
|
||||
};
|
||||
|
||||
let code = if is_ppm {
|
||||
transcode_ppm(
|
||||
&assembly,
|
||||
out,
|
||||
out_w,
|
||||
out_h,
|
||||
fr_num,
|
||||
fr_den,
|
||||
frames,
|
||||
src.audio_streams > 0,
|
||||
)
|
||||
transcode_ppm(&assembly, out, out_w, out_h, fr_num, fr_den, frames, src.audio_streams > 0)
|
||||
} else {
|
||||
transcode_mp4(&assembly, out, out_w, out_h, frames)
|
||||
transcode_mp4(&assembly, out, fr_num, fr_den, frames)
|
||||
};
|
||||
unsafe {
|
||||
crate::ffi::oakengine_render_manager_shutdown();
|
||||
crate::ffi::oakengine_project_free(assembly.project);
|
||||
}
|
||||
engine::render_manager_shutdown();
|
||||
code
|
||||
}
|
||||
|
||||
/// The assembled temporary project: project + footage + sequence (with
|
||||
/// one video track/clip and, when the source has audio streams, one
|
||||
/// audio track/clip).
|
||||
/// The assembled temporary sequence: the scratch project + the sequence
|
||||
/// node (with one video track/clip and, when the source has audio
|
||||
/// streams, one audio track/clip).
|
||||
struct Assembly {
|
||||
project: *mut ffi::OakEngineProject,
|
||||
sequence: *mut ffi::OakEngineSequence,
|
||||
project: engine::ProjectRef,
|
||||
sequence: oaknode::id::NodeId,
|
||||
}
|
||||
|
||||
/// Build the temporary project for the render/export stage.
|
||||
fn assemble_project(
|
||||
/// Build the temporary sequence for the render/export stage.
|
||||
fn assemble_sequence(
|
||||
input: &str,
|
||||
out_w: i32,
|
||||
out_h: i32,
|
||||
@@ -175,136 +148,51 @@ fn assemble_project(
|
||||
frames: i64,
|
||||
audio_streams: i32,
|
||||
) -> Result<Assembly, String> {
|
||||
if unsafe { crate::ffi::oakengine_render_manager_init() } != crate::ffi::OAKENGINE_OK {
|
||||
return Err("cannot initialize the render manager".to_string());
|
||||
}
|
||||
let project = unsafe { crate::ffi::oakengine_project_create() };
|
||||
if project.is_null() {
|
||||
return Err("cannot create project".to_string());
|
||||
}
|
||||
if unsafe { crate::ffi::oakengine_project_new(project) } != crate::ffi::OAKENGINE_OK {
|
||||
unsafe { crate::ffi::oakengine_project_free(project) };
|
||||
return Err("cannot initialize project".to_string());
|
||||
if let Err(e) = engine::render_manager_init() {
|
||||
return Err(format!("cannot initialize the render manager: {e}"));
|
||||
}
|
||||
let project = oaknode::project::Project::new();
|
||||
let sequence = engine::create_sequence(&project, "transcode");
|
||||
engine::set_sequence_video_params(&project, sequence, out_w, out_h, fr_num, fr_den);
|
||||
|
||||
let input_c = CString::new(input).map_err(|_| "invalid path (NUL byte)".to_string())?;
|
||||
let footage = unsafe { crate::ffi::oakengine_project_import_footage(project, input_c.as_ptr()) };
|
||||
if footage.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_footage_last_error(buf, size)
|
||||
});
|
||||
unsafe { crate::ffi::oakengine_project_free(project) };
|
||||
return Err(err);
|
||||
}
|
||||
let video_track = engine::add_track(&project, sequence, TrackType::Video)
|
||||
.map_err(|e| format!("cannot add video track: {e}"))?;
|
||||
engine::place_footage_clip(
|
||||
&project,
|
||||
sequence,
|
||||
input,
|
||||
TrackType::Video,
|
||||
video_track,
|
||||
0,
|
||||
frames,
|
||||
0,
|
||||
fr_num,
|
||||
fr_den,
|
||||
)
|
||||
.map_err(|e| format!("cannot place video clip: {e}"))?;
|
||||
|
||||
let seq_name = CString::new("transcode").unwrap();
|
||||
let sequence = unsafe { crate::ffi::oakengine_sequence_new(project, seq_name.as_ptr()) };
|
||||
if sequence.is_null() {
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot create sequence"));
|
||||
}
|
||||
if unsafe {
|
||||
crate::ffi::oakengine_sequence_set_video_params(
|
||||
if audio_streams > 0 {
|
||||
let audio_track = engine::add_track(&project, sequence, TrackType::Audio)
|
||||
.map_err(|e| format!("cannot add audio track: {e}"))?;
|
||||
engine::place_footage_clip(
|
||||
&project,
|
||||
sequence,
|
||||
out_w,
|
||||
out_h,
|
||||
fr_num,
|
||||
fr_den,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
crate::ffi::PIXEL_FORMAT_F32,
|
||||
0,
|
||||
)
|
||||
} != crate::ffi::OAKENGINE_OK
|
||||
{
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot set sequence video params"));
|
||||
}
|
||||
|
||||
let video_track =
|
||||
unsafe { crate::ffi::oakengine_sequence_add_track(sequence, crate::ffi::OAKENGINE_TRACK_TYPE_VIDEO) };
|
||||
if video_track < 0 {
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot add video track"));
|
||||
}
|
||||
let clip = unsafe {
|
||||
crate::ffi::oakengine_sequence_add_footage_clip_ex(
|
||||
sequence,
|
||||
footage,
|
||||
crate::ffi::OAKENGINE_TRACK_TYPE_VIDEO,
|
||||
video_track,
|
||||
input,
|
||||
TrackType::Audio,
|
||||
audio_track,
|
||||
0,
|
||||
frames,
|
||||
0,
|
||||
fr_num,
|
||||
fr_den,
|
||||
)
|
||||
};
|
||||
if clip.is_null() {
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot place video clip"));
|
||||
.map_err(|e| format!("cannot place audio clip: {e}"))?;
|
||||
}
|
||||
|
||||
if audio_streams > 0 {
|
||||
let audio_track =
|
||||
unsafe { crate::ffi::oakengine_sequence_add_track(sequence, crate::ffi::OAKENGINE_TRACK_TYPE_AUDIO) };
|
||||
if audio_track < 0 {
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot add audio track"));
|
||||
}
|
||||
let clip = unsafe {
|
||||
crate::ffi::oakengine_sequence_add_footage_clip_ex(
|
||||
sequence,
|
||||
footage,
|
||||
crate::ffi::OAKENGINE_TRACK_TYPE_AUDIO,
|
||||
audio_track,
|
||||
0,
|
||||
frames,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if clip.is_null() {
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
crate::ffi::oakengine_project_free(project);
|
||||
}
|
||||
return Err(seq_error("cannot place audio clip"));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
crate::ffi::oakengine_footage_free(footage);
|
||||
}
|
||||
Ok(Assembly { project, sequence })
|
||||
}
|
||||
|
||||
/// `oakengine_sequence_last_error` (or the fallback when empty).
|
||||
fn seq_error(fallback: &str) -> String {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_sequence_last_error(buf, size)
|
||||
});
|
||||
if err.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
err
|
||||
}
|
||||
}
|
||||
|
||||
/// `--format ppm`: render the frame range through the engine renderer
|
||||
/// `--format ppm`: render the frame range through the module ticket arena
|
||||
/// into PPM frames (+ the audio range into a WAV when the source has
|
||||
/// audio).
|
||||
fn transcode_ppm(
|
||||
@@ -321,237 +209,168 @@ fn transcode_ppm(
|
||||
eprintln!("error: transcode: cannot create output directory \"{out}\": {e}");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
let renderer = unsafe {
|
||||
crate::ffi::oakengine_renderer_create(
|
||||
assembly.sequence,
|
||||
out_w,
|
||||
out_h,
|
||||
crate::ffi::PIXEL_FORMAT_F32,
|
||||
fr_num,
|
||||
fr_den,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if renderer.is_null() {
|
||||
// The engine's create path returns NULL without setting the
|
||||
// renderer's last error; report the contract message.
|
||||
eprintln!("error: transcode: cannot create renderer");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
for i in 0..frames {
|
||||
let frame = unsafe { crate::ffi::oakengine_renderer_render_frame(renderer, i) };
|
||||
if frame.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_renderer_last_error(renderer, buf, size)
|
||||
});
|
||||
let msg = if err.is_empty() {
|
||||
format!("frame {i} failed to render")
|
||||
} else {
|
||||
format!("frame {i} failed to render: {err}")
|
||||
};
|
||||
eprintln!("error: transcode: {msg}");
|
||||
unsafe { crate::ffi::oakengine_renderer_free(renderer) };
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
if let Err(msg) = unsafe { write_frame_ppm(frame, out, i) } {
|
||||
eprintln!("error: transcode: {msg}");
|
||||
unsafe {
|
||||
crate::ffi::oakengine_frame_free(frame);
|
||||
crate::ffi::oakengine_renderer_free(renderer);
|
||||
let time = oakcore_rs::Rational::new(i * i64::from(fr_den), i64::from(fr_num));
|
||||
let montage = engine::video_montage(&assembly.project, assembly.sequence, time);
|
||||
let frame = match engine::render_frame(assembly.sequence, time, montage, out_w, out_h) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let msg = if e.is_empty() {
|
||||
format!("frame {i} failed to render")
|
||||
} else {
|
||||
format!("frame {i} failed to render: {e}")
|
||||
};
|
||||
eprintln!("error: transcode: {msg}");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
if let Err(msg) = write_frame_ppm(&frame, out, i) {
|
||||
eprintln!("error: transcode: {msg}");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
unsafe { crate::ffi::oakengine_frame_free(frame) };
|
||||
}
|
||||
eprintln!("transcoded {frames} frames to \"{out}\"");
|
||||
|
||||
// Audio range (the assembly only adds audio clips when the source
|
||||
// has audio streams).
|
||||
let mut code = EXIT_OK;
|
||||
// Audio range (the assembly only adds audio clips when the source has
|
||||
// audio streams).
|
||||
if audio {
|
||||
code = write_audio_wav(renderer, out, frames);
|
||||
return write_audio_wav(assembly, out, frames, fr_num, fr_den);
|
||||
}
|
||||
unsafe { crate::ffi::oakengine_renderer_free(renderer) };
|
||||
code
|
||||
EXIT_OK
|
||||
}
|
||||
|
||||
/// `--format mp4`: H.264/AAC through `oakengine_export_render` (the
|
||||
/// exporter options mirror the C++ `cmd_transcode` defaults: codec-
|
||||
/// default bit rates, source audio rate or 48 kHz, stereo).
|
||||
fn transcode_mp4(assembly: &Assembly, out: &str, out_w: i32, out_h: i32, frames: i64) -> i32 {
|
||||
let out_c = match CString::new(out) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("error: transcode: invalid output path (NUL byte)");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
let opts = OakExportOptions {
|
||||
video_codec: crate::ffi::OAKENGINE_EXPORT_VIDEO_H264,
|
||||
audio_codec: crate::ffi::OAKENGINE_EXPORT_AUDIO_AAC,
|
||||
video_bit_rate: 0,
|
||||
audio_sample_rate: 48000,
|
||||
audio_channel_count: 2,
|
||||
};
|
||||
match unsafe {
|
||||
crate::optional::export_render(
|
||||
assembly.sequence,
|
||||
out_c.as_ptr(),
|
||||
0,
|
||||
frames,
|
||||
out_w,
|
||||
out_h,
|
||||
&opts,
|
||||
)
|
||||
} {
|
||||
Some(rc) if rc == crate::ffi::OAKENGINE_OK => EXIT_OK,
|
||||
Some(rc) => {
|
||||
let err = unsafe { crate::optional::export_last_error() };
|
||||
let msg = if err.is_empty() {
|
||||
format!("export failed ({rc})")
|
||||
/// `--format mp4`: H.264/AAC through the module export task (codec-default
|
||||
/// bit rates, 48 kHz stereo — the facade's `oakengine_export_render`
|
||||
/// defaults).
|
||||
fn transcode_mp4(assembly: &Assembly, out: &str, fr_num: i32, fr_den: i32, frames: i64) -> i32 {
|
||||
match engine::export_sequence(&assembly.project, assembly.sequence, out, fr_num, fr_den, frames) {
|
||||
Ok(()) => EXIT_OK,
|
||||
Err(msg) => {
|
||||
let err = if msg.is_empty() {
|
||||
"export failed".to_string()
|
||||
} else {
|
||||
err
|
||||
msg
|
||||
};
|
||||
eprintln!("error: transcode: {msg}");
|
||||
EXIT_ERROR
|
||||
}
|
||||
None => {
|
||||
let err = unsafe { crate::optional::export_last_error() };
|
||||
eprintln!("error: transcode: {err}");
|
||||
EXIT_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a rendered frame as `frame_%05d.ppm` in `out` (the
|
||||
/// `oakengine_frame_*` accessors feed the [`crate::ppm`] writer).
|
||||
///
|
||||
/// `oakengine_frame_channel_count` is not backed by the current engine
|
||||
/// (returns 0); the render module's frames are always in the internal
|
||||
/// RGBA layout (`VideoParams::k_internal_channel_count == 4`), so a
|
||||
/// zero/negative channel report falls back to 4 channels.
|
||||
unsafe fn write_frame_ppm(frame: *mut ffi::OakEngineFrame, out: &str, index: i64) -> Result<(), String> {
|
||||
let width = unsafe { crate::ffi::oakengine_frame_width(frame) };
|
||||
let height = unsafe { crate::ffi::oakengine_frame_height(frame) };
|
||||
let format = unsafe { crate::ffi::oakengine_frame_format(frame) };
|
||||
let channels = unsafe { crate::ffi::oakengine_frame_channel_count(frame) };
|
||||
let channels = if channels > 0 { channels } else { 4 };
|
||||
let linesize = unsafe { crate::ffi::oakengine_frame_linesize_bytes(frame) };
|
||||
let data = unsafe { crate::ffi::oakengine_frame_data(frame) };
|
||||
if data.is_null() || width <= 0 || height <= 0 || linesize <= 0 {
|
||||
/// Write a rendered frame as `frame_%05d.ppm` in `out` (the rendered
|
||||
/// frames are always in the internal RGBA layout, 4 channels).
|
||||
fn write_frame_ppm(frame: &engine::RenderedFrame, out: &str, index: i64) -> Result<(), String> {
|
||||
if frame.data.is_empty() || frame.width <= 0 || frame.height <= 0 || frame.linesize <= 0 {
|
||||
return Err(format!(
|
||||
"frame {index} has no pixel data ({}x{}, linesize {linesize})",
|
||||
width, height
|
||||
"frame {index} has no pixel data ({}x{}, linesize {})",
|
||||
frame.width, frame.height, frame.linesize
|
||||
));
|
||||
}
|
||||
let len = (linesize as usize)
|
||||
.checked_mul(height as usize)
|
||||
.ok_or_else(|| "frame buffer size overflow".to_string())?;
|
||||
// SAFETY: the engine's frame buffer is valid for linesize * height
|
||||
// bytes for the duration of this call.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(data as *const u8, len) };
|
||||
let path = Path::new(out).join(format!("frame_{index:05}.ppm"));
|
||||
ppm::write_ppm(&path, width, height, format, channels, linesize, bytes)
|
||||
.map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
|
||||
let path = std::path::Path::new(out).join(format!("frame_{index:05}.ppm"));
|
||||
ppm::write_ppm(
|
||||
&path,
|
||||
frame.width,
|
||||
frame.height,
|
||||
frame.format,
|
||||
4,
|
||||
frame.linesize,
|
||||
&frame.data,
|
||||
)
|
||||
.map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Render the audio range and write it as `audio.wav` in `out`. The
|
||||
/// assembled clips span `[0, frames)` sequence timestamps, so the range
|
||||
/// length is `frames` time-base ticks.
|
||||
fn write_audio_wav(renderer: *mut ffi::OakEngineRenderer, out: &str, frames: i64) -> i32 {
|
||||
let audio = unsafe { crate::ffi::oakengine_renderer_render_audio(renderer, 0, frames) };
|
||||
if audio.is_null() {
|
||||
eprintln!("error: transcode: audio render failed");
|
||||
fn write_audio_wav(
|
||||
assembly: &Assembly,
|
||||
out: &str,
|
||||
frames: i64,
|
||||
fr_num: i32,
|
||||
fr_den: i32,
|
||||
) -> i32 {
|
||||
let range = oakcore_rs::TimeRange::new(
|
||||
oakcore_rs::Rational::new(0, 1),
|
||||
oakcore_rs::Rational::new(frames * i64::from(fr_den), i64::from(fr_num)),
|
||||
);
|
||||
let montage = engine::audio_montage(&assembly.project, assembly.sequence, range);
|
||||
let audio = match engine::render_audio(assembly.sequence, range, montage) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("error: transcode: audio render failed: {e}");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
};
|
||||
if audio.data.is_empty() || audio.sample_rate <= 0 || audio.channel_count <= 0 {
|
||||
eprintln!("error: transcode: audio buffer is empty");
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
let rate = unsafe { crate::ffi::oakengine_audio_sample_rate(audio) };
|
||||
let channels = unsafe { crate::ffi::oakengine_audio_channel_count(audio) };
|
||||
let samples = unsafe { crate::ffi::oakengine_audio_sample_count(audio) };
|
||||
let data = unsafe { crate::ffi::oakengine_audio_data(audio, 0) };
|
||||
let code = if data.is_null() || rate <= 0 || channels <= 0 || samples <= 0 {
|
||||
eprintln!("error: transcode: audio buffer is empty");
|
||||
EXIT_ERROR
|
||||
} else {
|
||||
match (samples as usize).checked_mul(channels as usize) {
|
||||
None => {
|
||||
eprintln!("error: transcode: audio buffer size overflow");
|
||||
EXIT_ERROR
|
||||
}
|
||||
Some(len) => {
|
||||
// SAFETY: the engine's audio buffer is valid for
|
||||
// samples * channels floats for this call's duration.
|
||||
let floats = unsafe { std::slice::from_raw_parts(data, len) };
|
||||
let path = Path::new(out).join("audio.wav");
|
||||
if let Err(e) = wav::write_wav(&path, rate, channels, samples, floats) {
|
||||
eprintln!("error: transcode: cannot write \"{}\": {e}", path.display());
|
||||
EXIT_ERROR
|
||||
} else {
|
||||
EXIT_OK
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
unsafe { crate::ffi::oakengine_audio_free(audio) };
|
||||
code
|
||||
let samples = (audio.data.len() / audio.channel_count as usize) as i64;
|
||||
let path = std::path::Path::new(out).join("audio.wav");
|
||||
if let Err(e) = wav::write_wav(&path, audio.sample_rate, audio.channel_count, samples, &audio.data) {
|
||||
eprintln!("error: transcode: cannot write \"{}\": {e}", path.display());
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
EXIT_OK
|
||||
}
|
||||
|
||||
/// Probe the source media through the C ABI into a [`SourceInfo`].
|
||||
/// Probe the source media into a [`SourceInfo`].
|
||||
///
|
||||
/// The engine's footage probe records the decoder but (currently) drops
|
||||
/// the codec's stream descriptions, so a successful probe still reports
|
||||
/// zero streams / unavailable stream info. The CLI then falls back to
|
||||
/// the documented defaults: `[width]` or 1920, 16:9 height, 25 fps,
|
||||
/// duration 0 (a single frame — the still-image contract). A failed
|
||||
/// probe is a hard error.
|
||||
/// The module probe records the decoder but (currently) drops the codec's
|
||||
/// stream descriptions, so a successful probe still reports zero streams /
|
||||
/// unavailable stream info. The CLI then falls back to the documented
|
||||
/// defaults: `[width]` or 1920, 16:9 height, 25 fps, duration 0 (a single
|
||||
/// frame — the still-image contract). A missing file is a hard error.
|
||||
fn probe_source(path: &str) -> Result<SourceInfo, String> {
|
||||
let path_c = CString::new(path).map_err(|_| "invalid path (NUL byte)".to_string())?;
|
||||
let footage = unsafe { crate::ffi::oakengine_footage_probe(path_c.as_ptr()) };
|
||||
if footage.is_null() {
|
||||
let err = crate::ffi::string_get(|buf, size| unsafe {
|
||||
crate::ffi::oakengine_footage_last_error(buf, size)
|
||||
});
|
||||
return Err(err);
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(format!("file does not exist: {path}"));
|
||||
}
|
||||
let mut footage = FootageBehavior::new(path);
|
||||
if let Err(e) = footage.probe() {
|
||||
// The module probe failure keeps the node usable (like the facade's
|
||||
// footage create); the fallback below applies.
|
||||
let _ = e;
|
||||
}
|
||||
|
||||
let mut duration = 0.0f64;
|
||||
let _ = unsafe { crate::ffi::oakengine_footage_get_duration(footage, &mut duration) };
|
||||
let audio_streams = unsafe { crate::ffi::oakengine_footage_get_audio_stream_count(footage) }.max(0);
|
||||
|
||||
let video_streams =
|
||||
unsafe { crate::ffi::oakengine_footage_get_video_stream_count(footage) }.max(0);
|
||||
|
||||
let mut info = ffi::OakFootageVideoInfo {
|
||||
stream_index: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
frame_rate_num: 0,
|
||||
frame_rate_den: 0,
|
||||
duration_ts: 0,
|
||||
time_base_num: 0,
|
||||
time_base_den: 0,
|
||||
color_primaries: 0,
|
||||
color_trc: 0,
|
||||
interlaced: 0,
|
||||
};
|
||||
let rc = if video_streams > 0 {
|
||||
unsafe { crate::ffi::oakengine_footage_get_video_stream_info(footage, 0, &mut info) }
|
||||
let duration = footage.duration();
|
||||
let duration = if duration.denominator() != 0 {
|
||||
duration.numerator() as f64 / duration.denominator() as f64
|
||||
} else {
|
||||
crate::ffi::OAKENGINE_E_NOT_FOUND
|
||||
0.0
|
||||
};
|
||||
unsafe { crate::ffi::oakengine_footage_free(footage) };
|
||||
let audio_streams = footage.audio_stream_count() as i32;
|
||||
let video_streams = footage.video_stream_count() as i32;
|
||||
|
||||
let src = if rc == crate::ffi::OAKENGINE_OK && info.width > 0 && info.height > 0 {
|
||||
SourceInfo {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
fr_num: info.frame_rate_num,
|
||||
fr_den: info.frame_rate_den,
|
||||
duration,
|
||||
audio_streams,
|
||||
let info = if video_streams > 0 {
|
||||
footage.video_params(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let src = if let Some(v) = info {
|
||||
if v.width > 0 && v.height > 0 {
|
||||
SourceInfo {
|
||||
width: v.width,
|
||||
height: v.height,
|
||||
fr_num: v.frame_rate.numerator() as i32,
|
||||
fr_den: v.frame_rate.denominator() as i32,
|
||||
duration,
|
||||
audio_streams,
|
||||
}
|
||||
} else {
|
||||
// Module gap fallback (see the docs above).
|
||||
SourceInfo {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fr_num: 25,
|
||||
fr_den: 1,
|
||||
duration,
|
||||
audio_streams,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Engine gap fallback (see the docs above).
|
||||
// Module gap fallback (see the docs above).
|
||||
SourceInfo {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
|
||||
Reference in New Issue
Block a user