feat(engine): clip move, clip effect_input, mandatory static FFmpeg

- oakengine_sequence_move_clip implemented for real (oaktimeline
  TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple
  trim bugs the stub was hiding); same-track via the frozen C ABI,
  cross-track supported by the module command
- oaknode clip blocks now declare a tex_in texture input and set
  effect_input to it, so timeline clips can host effect chains; facade
  test covers effect insert/remove on a real clip
- oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a
  Homebrew upgrade left the system ffmpeg .pc pointing at a deleted
  dav1d Cellar path, breaking links); reads a git-ignored workspace
  .env for IDEs that cannot inject env vars (RustRover); links the C++
  stdlib for C++ codec libs (svt-av1)
- oakengine re-exports oaknode so tests share one crate instance;
  it_node uses the direct instance's value type where it calls the
  module FFI (the --workspace dev-dependency feature split builds
  oaknode twice)
This commit is contained in:
2026-08-11 23:04:48 +08:00
parent a209f63d52
commit 18ff60f147
351 changed files with 32880 additions and 15939 deletions
+20 -20
View File
@@ -21,24 +21,24 @@ use crate::cmd::{port_not_wired, require_or, EXIT_ERROR};
/// 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;
}
// 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)
if let Err(code) = require_or(
"info",
&[
&crate::deferred::INIT,
&crate::deferred::NODE,
&crate::deferred::TIMELINE,
],
EXIT_ERROR,
) {
return code;
}
// 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)
}
+14 -14
View File
@@ -47,25 +47,25 @@ pub const EXIT_USAGE: i32 = 64;
/// 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,
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)
}
}
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
eprintln!(
"error: {cmd}: internal error: facade families reported available but no port is wired yet"
);
code
}
+14 -14
View File
@@ -22,18 +22,18 @@ use crate::cmd::{port_not_wired, require_or, EXIT_ERROR};
/// 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;
}
// 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)
if let Err(code) = require_or(
"probe",
&[&crate::deferred::INIT, &crate::deferred::NODE],
EXIT_ERROR,
) {
return code;
}
// 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)
}
+38 -38
View File
@@ -27,43 +27,43 @@ use crate::cmd::{port_not_wired, require_or, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE
/// 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,
Err(_) => {
eprintln!("error: invalid start seconds \"{start_seconds}\"");
return EXIT_USAGE;
}
};
let end: f64 = match end_seconds.parse() {
Ok(v) => v,
Err(_) => {
eprintln!("error: invalid end seconds \"{end_seconds}\"");
return EXIT_USAGE;
}
};
if end <= start {
eprintln!("error: invalid end seconds \"{end_seconds}\"");
return EXIT_USAGE;
}
let start: f64 = match start_seconds.parse() {
Ok(v) => v,
Err(_) => {
eprintln!("error: invalid start seconds \"{start_seconds}\"");
return EXIT_USAGE;
}
};
let end: f64 = match end_seconds.parse() {
Ok(v) => v,
Err(_) => {
eprintln!("error: invalid end seconds \"{end_seconds}\"");
return EXIT_USAGE;
}
};
if end <= start {
eprintln!("error: invalid end seconds \"{end_seconds}\"");
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;
}
// 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)
if let Err(code) = require_or(
"render",
&[
&crate::deferred::INIT,
&crate::deferred::NODE,
&crate::deferred::TIMELINE,
&crate::deferred::RENDER,
],
EXIT_RENDER_UNAVAILABLE,
) {
return code;
}
// 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)
}
+39 -44
View File
@@ -22,49 +22,44 @@ use crate::cmd::{port_not_wired, require_or, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE
/// Run `transcode`. `width`/`format` are validated exactly like the C++ loop
/// over `argv[4..]`; the facade work is gated on the deferred families below.
pub fn run(
input_media: String,
out: String,
width: Option<String>,
format: Option<String>,
) -> i32 {
if let Some(w) = &width {
match w.parse::<i64>() {
Ok(n) if n > 0 => {}
_ => {
eprintln!("error: invalid width \"{w}\"");
return EXIT_USAGE;
}
}
}
if let Some(f) = &format {
if f != "ppm" && f != "mp4" {
eprintln!("error: unknown --format \"{f}\" (ppm|mp4)");
return EXIT_USAGE;
}
}
pub fn run(input_media: String, out: String, width: Option<String>, format: Option<String>) -> i32 {
if let Some(w) = &width {
match w.parse::<i64>() {
Ok(n) if n > 0 => {}
_ => {
eprintln!("error: invalid width \"{w}\"");
return EXIT_USAGE;
}
}
}
if let Some(f) = &format {
if f != "ppm" && f != "mp4" {
eprintln!("error: unknown --format \"{f}\" (ppm|mp4)");
return EXIT_USAGE;
}
}
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;
}
// 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)
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;
}
// 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)
}
+39 -44
View File
@@ -32,12 +32,12 @@
/// One deferred facade family: what it covers, which engine headers define
/// it, and why the facade does not wrap it yet.
pub struct DeferredFamily {
/// Short family name, as used in messages.
pub name: &'static str,
/// Engine headers involved.
pub headers: &'static str,
/// Why the family is not wrapped yet (from the facade's deferred.rs).
pub reason: &'static str,
/// Short family name, as used in messages.
pub name: &'static str,
/// Engine headers involved.
pub headers: &'static str,
/// Why the family is not wrapped yet (from the facade's deferred.rs).
pub reason: &'static str,
}
/// `init.h` — engine process initialization/shutdown.
@@ -99,50 +99,45 @@ pub const EXPORT: DeferredFamily = DeferredFamily {
/// carries the composed "not yet available" message naming each deferred
/// family and its reason, for the subcommands to print and exit on.
pub fn require(families: &[&DeferredFamily]) -> Result<(), String> {
if families.is_empty() {
return Ok(());
}
let mut detail = String::new();
for f in families {
detail.push_str(&format!("\n - {} ({}): {}", f.name, f.headers, f.reason));
}
Err(format!(
"not yet available in the Rust facade (oakengine): these family(ies) are still deferred \
if families.is_empty() {
return Ok(());
}
let mut detail = String::new();
for f in families {
detail.push_str(&format!("\n - {} ({}): {}", f.name, f.headers, f.reason));
}
Err(format!(
"not yet available in the Rust facade (oakengine): these family(ies) are still deferred \
(see src/facade/rust/src/deferred.rs):{detail}"
))
))
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
#[test]
fn empty_family_list_is_available() {
assert!(require(&[]).is_ok());
}
#[test]
fn empty_family_list_is_available() {
assert!(require(&[]).is_ok());
}
#[test]
fn deferred_family_lists_a_reason() {
let err = require(&[&INIT]).unwrap_err();
assert!(err.contains("not yet available"));
assert!(err.contains("init"));
assert!(err.contains("oakengine"));
}
#[test]
fn deferred_family_lists_a_reason() {
let err = require(&[&INIT]).unwrap_err();
assert!(err.contains("not yet available"));
assert!(err.contains("init"));
assert!(err.contains("oakengine"));
}
#[test]
fn all_cli_families_are_currently_deferred() {
// Keeps this file honest: if any family the CLI depends on flips to
// available, the subcommand ports in src/cmd/ become reachable and
// the tests asserting "not yet available" must be revisited.
let all: [&[&DeferredFamily]; 5] = [
&[&INIT],
&[&NODE],
&[&TIMELINE],
&[&RENDER],
&[&EXPORT],
];
for families in all {
assert!(require(families).is_err());
}
}
#[test]
fn all_cli_families_are_currently_deferred() {
// Keeps this file honest: if any family the CLI depends on flips to
// available, the subcommand ports in src/cmd/ become reachable and
// the tests asserting "not yet available" must be revisited.
let all: [&[&DeferredFamily]; 5] =
[&[&INIT], &[&NODE], &[&TIMELINE], &[&RENDER], &[&EXPORT]];
for families in all {
assert!(require(families).is_err());
}
}
}
+223 -246
View File
@@ -58,37 +58,37 @@ use std::ffi::{c_char, c_double, c_int, c_void};
#[repr(C)]
pub struct OakEngineProject {
_opaque: [u8; 0],
_opaque: [u8; 0],
}
#[repr(C)]
pub struct OakEngineSequence {
_opaque: [u8; 0],
_opaque: [u8; 0],
}
#[repr(C)]
pub struct OakEngineRenderer {
_opaque: [u8; 0],
_opaque: [u8; 0],
}
#[repr(C)]
pub struct OakEngineFrame {
_opaque: [u8; 0],
_opaque: [u8; 0],
}
#[repr(C)]
pub struct OakEngineAudioBuffer {
_opaque: [u8; 0],
_opaque: [u8; 0],
}
#[repr(C)]
pub struct OakEngineFootage {
_opaque: [u8; 0],
_opaque: [u8; 0],
}
#[repr(C)]
pub struct OakEngineClip {
_opaque: [u8; 0],
_opaque: [u8; 0],
}
// ---------------------------------------------------------------------------
@@ -99,46 +99,46 @@ pub struct OakEngineClip {
#[repr(C)]
#[derive(Clone, Copy)]
pub struct OakFootageVideoInfo {
pub stream_index: c_int,
pub width: c_int,
pub height: c_int,
pub frame_rate_num: c_int,
pub frame_rate_den: c_int,
pub duration_ts: i64,
pub time_base_num: c_int,
pub time_base_den: c_int,
pub color_primaries: c_int,
pub color_trc: c_int,
pub interlaced: c_int,
pub stream_index: c_int,
pub width: c_int,
pub height: c_int,
pub frame_rate_num: c_int,
pub frame_rate_den: c_int,
pub duration_ts: i64,
pub time_base_num: c_int,
pub time_base_den: c_int,
pub color_primaries: c_int,
pub color_trc: c_int,
pub interlaced: c_int,
}
/// `oak_footage_audio_info` (engine/include/oakengine/footage.h).
#[repr(C)]
#[derive(Clone, Copy)]
pub struct OakFootageAudioInfo {
pub stream_index: c_int,
pub sample_rate: c_int,
pub channel_layout: u64,
pub channel_count: c_int,
pub duration_ts: i64,
pub time_base_num: c_int,
pub time_base_den: c_int,
pub stream_index: c_int,
pub sample_rate: c_int,
pub channel_layout: u64,
pub channel_count: c_int,
pub duration_ts: i64,
pub time_base_num: c_int,
pub time_base_den: c_int,
}
/// `oak_export_options` (engine/include/oakengine/exporter.h).
#[repr(C)]
#[derive(Clone, Copy)]
pub struct OakExportOptions {
pub video_codec: c_int,
pub audio_codec: c_int,
pub video_bit_rate: i64,
pub audio_sample_rate: c_int,
pub audio_channel_count: c_int,
pub video_codec: c_int,
pub audio_codec: c_int,
pub video_bit_rate: i64,
pub audio_sample_rate: c_int,
pub audio_channel_count: c_int,
}
/// `oakengine_export_progress_fn` (exporter.h).
pub type OakEngineExportProgressFn =
Option<unsafe extern "C" fn(fraction: c_double, userdata: *mut c_void)>;
Option<unsafe extern "C" fn(fraction: c_double, userdata: *mut c_void)>;
// ---------------------------------------------------------------------------
// Constants (verbatim values from the engine headers).
@@ -172,216 +172,193 @@ pub const OAKENGINE_EXPORT_AUDIO_AAC: c_int = 0;
// ---------------------------------------------------------------------------
extern "C" {
// ---- init.h ----------------------------------------------------------
pub fn oakengine_init(flags: c_int) -> c_int;
pub fn oakengine_shutdown() -> c_int;
// ---- init.h ----------------------------------------------------------
pub fn oakengine_init(flags: c_int) -> c_int;
pub fn oakengine_shutdown() -> c_int;
// ---- project.h -------------------------------------------------------
pub fn oakengine_project_create() -> *mut OakEngineProject;
pub fn oakengine_project_free(self_: *mut OakEngineProject);
pub fn oakengine_project_new(self_: *mut OakEngineProject) -> c_int;
pub fn oakengine_project_load(
self_: *mut OakEngineProject,
path: *const c_char,
err: *mut c_char,
err_size: c_int,
) -> c_int;
pub fn oakengine_project_name(
self_: *const OakEngineProject,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_project_filename(
self_: *const OakEngineProject,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_project_is_modified(self_: *const OakEngineProject) -> c_int;
pub fn oakengine_project_sequence_count(
self_: *const OakEngineProject,
) -> c_int;
pub fn oakengine_project_sequence_at(
self_: *const OakEngineProject,
index: c_int,
) -> *mut OakEngineSequence;
pub fn oakengine_project_footage_count(
self_: *const OakEngineProject,
) -> c_int;
pub fn oakengine_project_footage_filename(
self_: *const OakEngineProject,
index: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_project_footage_is_online(
self_: *const OakEngineProject,
index: c_int,
) -> c_int;
// ---- project.h -------------------------------------------------------
pub fn oakengine_project_create() -> *mut OakEngineProject;
pub fn oakengine_project_free(self_: *mut OakEngineProject);
pub fn oakengine_project_new(self_: *mut OakEngineProject) -> c_int;
pub fn oakengine_project_load(
self_: *mut OakEngineProject,
path: *const c_char,
err: *mut c_char,
err_size: c_int,
) -> c_int;
pub fn oakengine_project_name(
self_: *const OakEngineProject,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_project_filename(
self_: *const OakEngineProject,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_project_is_modified(self_: *const OakEngineProject) -> c_int;
pub fn oakengine_project_sequence_count(self_: *const OakEngineProject) -> c_int;
pub fn oakengine_project_sequence_at(
self_: *const OakEngineProject,
index: c_int,
) -> *mut OakEngineSequence;
pub fn oakengine_project_footage_count(self_: *const OakEngineProject) -> c_int;
pub fn oakengine_project_footage_filename(
self_: *const OakEngineProject,
index: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_project_footage_is_online(
self_: *const OakEngineProject,
index: c_int,
) -> c_int;
// ---- footage.h -------------------------------------------------------
pub fn oakengine_project_import_footage(
project: *mut OakEngineProject,
path: *const c_char,
) -> *mut OakEngineFootage;
pub fn oakengine_footage_probe(path: *const c_char) -> *mut OakEngineFootage;
pub fn oakengine_footage_free(self_: *mut OakEngineFootage);
pub fn oakengine_footage_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
pub fn oakengine_footage_get_decoder_name(
self_: *mut OakEngineFootage,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_footage_get_duration(
self_: *mut OakEngineFootage,
seconds: *mut c_double,
) -> c_int;
pub fn oakengine_footage_get_video_stream_count(
self_: *const OakEngineFootage,
) -> c_int;
pub fn oakengine_footage_get_video_stream_info(
self_: *mut OakEngineFootage,
index: c_int,
out: *mut OakFootageVideoInfo,
) -> c_int;
pub fn oakengine_footage_get_audio_stream_count(
self_: *const OakEngineFootage,
) -> c_int;
pub fn oakengine_footage_get_audio_stream_info(
self_: *mut OakEngineFootage,
index: c_int,
out: *mut OakFootageAudioInfo,
) -> c_int;
pub fn oakengine_footage_get_subtitle_stream_count(
self_: *const OakEngineFootage,
) -> c_int;
// ---- footage.h -------------------------------------------------------
pub fn oakengine_project_import_footage(
project: *mut OakEngineProject,
path: *const c_char,
) -> *mut OakEngineFootage;
pub fn oakengine_footage_probe(path: *const c_char) -> *mut OakEngineFootage;
pub fn oakengine_footage_free(self_: *mut OakEngineFootage);
pub fn oakengine_footage_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
pub fn oakengine_footage_get_decoder_name(
self_: *mut OakEngineFootage,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_footage_get_duration(
self_: *mut OakEngineFootage,
seconds: *mut c_double,
) -> c_int;
pub fn oakengine_footage_get_video_stream_count(self_: *const OakEngineFootage) -> c_int;
pub fn oakengine_footage_get_video_stream_info(
self_: *mut OakEngineFootage,
index: c_int,
out: *mut OakFootageVideoInfo,
) -> c_int;
pub fn oakengine_footage_get_audio_stream_count(self_: *const OakEngineFootage) -> c_int;
pub fn oakengine_footage_get_audio_stream_info(
self_: *mut OakEngineFootage,
index: c_int,
out: *mut OakFootageAudioInfo,
) -> c_int;
pub fn oakengine_footage_get_subtitle_stream_count(self_: *const OakEngineFootage) -> c_int;
// ---- timeline.h ------------------------------------------------------
pub fn oakengine_sequence_new(
project: *mut OakEngineProject,
name: *const c_char,
) -> *mut OakEngineSequence;
pub fn oakengine_sequence_name(
self_: *const OakEngineSequence,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_sequence_get_length(
self_: *const OakEngineSequence,
seconds: *mut c_double,
) -> c_int;
pub fn oakengine_sequence_get_length_rational(
self_: *const OakEngineSequence,
num: *mut c_int,
den: *mut c_int,
) -> c_int;
pub fn oakengine_sequence_get_frame_rate(
self_: *const OakEngineSequence,
num: *mut c_int,
den: *mut c_int,
) -> c_int;
pub fn oakengine_sequence_get_video_params(
self_: *const OakEngineSequence,
width: *mut c_int,
height: *mut c_int,
par_num: *mut c_int,
par_den: *mut c_int,
) -> c_int;
pub fn oakengine_sequence_track_count(
self_: *const OakEngineSequence,
video: *mut c_int,
audio: *mut c_int,
subtitle: *mut c_int,
) -> c_int;
pub fn oakengine_sequence_get_playhead(
self_: *const OakEngineSequence,
timestamp: *mut i64,
) -> c_int;
pub fn oakengine_sequence_get_playhead_seconds(
self_: *const OakEngineSequence,
seconds: *mut c_double,
) -> c_int;
pub fn oakengine_sequence_add_track(
self_: *mut OakEngineSequence,
track_type: c_int,
) -> c_int;
pub fn oakengine_sequence_add_footage_clip(
seq: *mut OakEngineSequence,
footage: *mut OakEngineFootage,
track_type: c_int,
track_index: c_int,
in_ts: i64,
out_ts: i64,
media_in: i64,
) -> *mut OakEngineClip;
pub fn oakengine_sequence_last_error(buf: *mut c_char, buf_size: c_int)
-> c_int;
// ---- timeline.h ------------------------------------------------------
pub fn oakengine_sequence_new(
project: *mut OakEngineProject,
name: *const c_char,
) -> *mut OakEngineSequence;
pub fn oakengine_sequence_name(
self_: *const OakEngineSequence,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_sequence_get_length(
self_: *const OakEngineSequence,
seconds: *mut c_double,
) -> c_int;
pub fn oakengine_sequence_get_length_rational(
self_: *const OakEngineSequence,
num: *mut c_int,
den: *mut c_int,
) -> c_int;
pub fn oakengine_sequence_get_frame_rate(
self_: *const OakEngineSequence,
num: *mut c_int,
den: *mut c_int,
) -> c_int;
pub fn oakengine_sequence_get_video_params(
self_: *const OakEngineSequence,
width: *mut c_int,
height: *mut c_int,
par_num: *mut c_int,
par_den: *mut c_int,
) -> c_int;
pub fn oakengine_sequence_track_count(
self_: *const OakEngineSequence,
video: *mut c_int,
audio: *mut c_int,
subtitle: *mut c_int,
) -> c_int;
pub fn oakengine_sequence_get_playhead(
self_: *const OakEngineSequence,
timestamp: *mut i64,
) -> c_int;
pub fn oakengine_sequence_get_playhead_seconds(
self_: *const OakEngineSequence,
seconds: *mut c_double,
) -> c_int;
pub fn oakengine_sequence_add_track(self_: *mut OakEngineSequence, track_type: c_int) -> c_int;
pub fn oakengine_sequence_add_footage_clip(
seq: *mut OakEngineSequence,
footage: *mut OakEngineFootage,
track_type: c_int,
track_index: c_int,
in_ts: i64,
out_ts: i64,
media_in: i64,
) -> *mut OakEngineClip;
pub fn oakengine_sequence_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
// ---- renderer.h ------------------------------------------------------
pub fn oakengine_renderer_create(
seq: *mut OakEngineSequence,
width: c_int,
height: c_int,
pixel_format: c_int,
frame_rate_num: c_int,
frame_rate_den: c_int,
output_colorspace: *const c_char,
) -> *mut OakEngineRenderer;
pub fn oakengine_renderer_free(self_: *mut OakEngineRenderer);
pub fn oakengine_renderer_last_error(
self_: *const OakEngineRenderer,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_renderer_render_frame(
self_: *mut OakEngineRenderer,
timestamp: i64,
) -> *mut OakEngineFrame;
pub fn oakengine_renderer_render_audio(
self_: *mut OakEngineRenderer,
start_timestamp: i64,
length_timestamp: i64,
) -> *mut OakEngineAudioBuffer;
// ---- renderer.h ------------------------------------------------------
pub fn oakengine_renderer_create(
seq: *mut OakEngineSequence,
width: c_int,
height: c_int,
pixel_format: c_int,
frame_rate_num: c_int,
frame_rate_den: c_int,
output_colorspace: *const c_char,
) -> *mut OakEngineRenderer;
pub fn oakengine_renderer_free(self_: *mut OakEngineRenderer);
pub fn oakengine_renderer_last_error(
self_: *const OakEngineRenderer,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_renderer_render_frame(
self_: *mut OakEngineRenderer,
timestamp: i64,
) -> *mut OakEngineFrame;
pub fn oakengine_renderer_render_audio(
self_: *mut OakEngineRenderer,
start_timestamp: i64,
length_timestamp: i64,
) -> *mut OakEngineAudioBuffer;
// ---- renderer.h (OakEngineFrame) -------------------------------------
pub fn oakengine_frame_width(self_: *const OakEngineFrame) -> c_int;
pub fn oakengine_frame_height(self_: *const OakEngineFrame) -> c_int;
pub fn oakengine_frame_format(self_: *const OakEngineFrame) -> c_int;
pub fn oakengine_frame_channel_count(self_: *const OakEngineFrame) -> c_int;
pub fn oakengine_frame_linesize_bytes(self_: *const OakEngineFrame) -> c_int;
pub fn oakengine_frame_data(self_: *const OakEngineFrame) -> *const c_void;
pub fn oakengine_frame_free(self_: *mut OakEngineFrame);
// ---- renderer.h (OakEngineFrame) -------------------------------------
pub fn oakengine_frame_width(self_: *const OakEngineFrame) -> c_int;
pub fn oakengine_frame_height(self_: *const OakEngineFrame) -> c_int;
pub fn oakengine_frame_format(self_: *const OakEngineFrame) -> c_int;
pub fn oakengine_frame_channel_count(self_: *const OakEngineFrame) -> c_int;
pub fn oakengine_frame_linesize_bytes(self_: *const OakEngineFrame) -> c_int;
pub fn oakengine_frame_data(self_: *const OakEngineFrame) -> *const c_void;
pub fn oakengine_frame_free(self_: *mut OakEngineFrame);
// ---- renderer.h (OakEngineAudioBuffer) --------------------------------
pub fn oakengine_audio_sample_rate(
self_: *const OakEngineAudioBuffer,
) -> c_int;
pub fn oakengine_audio_channel_count(
self_: *const OakEngineAudioBuffer,
) -> c_int;
pub fn oakengine_audio_sample_count(
self_: *const OakEngineAudioBuffer,
) -> i64;
pub fn oakengine_audio_data(
self_: *const OakEngineAudioBuffer,
channel: c_int,
) -> *const f32;
pub fn oakengine_audio_free(self_: *mut OakEngineAudioBuffer);
// ---- renderer.h (OakEngineAudioBuffer) --------------------------------
pub fn oakengine_audio_sample_rate(self_: *const OakEngineAudioBuffer) -> c_int;
pub fn oakengine_audio_channel_count(self_: *const OakEngineAudioBuffer) -> c_int;
pub fn oakengine_audio_sample_count(self_: *const OakEngineAudioBuffer) -> i64;
pub fn oakengine_audio_data(self_: *const OakEngineAudioBuffer, channel: c_int) -> *const f32;
pub fn oakengine_audio_free(self_: *mut OakEngineAudioBuffer);
// ---- exporter.h ------------------------------------------------------
pub fn oakengine_export_render(
seq: *mut OakEngineSequence,
path: *const c_char,
in_ts: i64,
out_ts: i64,
width: c_int,
height: c_int,
opts: *const OakExportOptions,
) -> c_int;
pub fn oakengine_export_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
pub fn oakengine_export_set_progress_callback(
f: OakEngineExportProgressFn,
userdata: *mut c_void,
);
// ---- exporter.h ------------------------------------------------------
pub fn oakengine_export_render(
seq: *mut OakEngineSequence,
path: *const c_char,
in_ts: i64,
out_ts: i64,
width: c_int,
height: c_int,
opts: *const OakExportOptions,
) -> c_int;
pub fn oakengine_export_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
pub fn oakengine_export_set_progress_callback(
f: OakEngineExportProgressFn,
userdata: *mut c_void,
);
}
/// Read a facade string (buf/size convention) into an owned `String`,
@@ -393,17 +370,17 @@ extern "C" {
/// `getter` must be one of the `oakengine_*` string getters declared above
/// and `handle` a live handle for it.
pub unsafe fn facade_string(
getter: unsafe extern "C" fn(*const c_void, *mut c_char, c_int) -> c_int,
handle: *const c_void,
getter: unsafe extern "C" fn(*const c_void, *mut c_char, c_int) -> c_int,
handle: *const c_void,
) -> String {
unsafe {
let size = getter(handle, std::ptr::null_mut(), 0);
if size < 0 {
return String::new();
}
let mut s = vec![0u8; size as usize + 1];
let n = getter(handle, s.as_mut_ptr() as *mut c_char, size + 1);
s.truncate(n.max(0) as usize);
String::from_utf8_lossy(&s).into_owned()
}
unsafe {
let size = getter(handle, std::ptr::null_mut(), 0);
if size < 0 {
return String::new();
}
let mut s = vec![0u8; size as usize + 1];
let n = getter(handle, s.as_mut_ptr() as *mut c_char, size + 1);
s.truncate(n.max(0) as usize);
String::from_utf8_lossy(&s).into_owned()
}
}
+157 -140
View File
@@ -31,27 +31,27 @@
/// `Project: <name>` (`cmd_info`).
pub fn project_line(name: &str) -> String {
format!("Project: {name}")
format!("Project: {name}")
}
/// `File: <filename>` (`cmd_info`).
pub fn file_line(filename: &str) -> String {
format!("File: {filename}")
format!("File: {filename}")
}
/// `Modified: yes|no` (`cmd_info`).
pub fn modified_line(modified: bool) -> String {
format!("Modified: {}", if modified { "yes" } else { "no" })
format!("Modified: {}", if modified { "yes" } else { "no" })
}
/// `Sequences: <n>` (`cmd_info`).
pub fn sequences_line(count: i64) -> String {
format!("Sequences: {count}")
format!("Sequences: {count}")
}
/// `Footage: <n>` (`cmd_info`).
pub fn footage_line(count: i64) -> String {
format!("Footage: {count}")
format!("Footage: {count}")
}
/// One sequence block (`print_sequence` in cli/main.cpp).
@@ -64,29 +64,29 @@ pub fn footage_line(count: i64) -> String {
/// playhead: 0 (0.000000 s)
/// ```
pub fn sequence(
index: i64,
name: &str,
length_seconds: f64,
len_num: i64,
len_den: i64,
fr_num: i64,
fr_den: i64,
video: i64,
audio: i64,
subtitle: i64,
playhead: i64,
playhead_seconds: f64,
index: i64,
name: &str,
length_seconds: f64,
len_num: i64,
len_den: i64,
fr_num: i64,
fr_den: i64,
video: i64,
audio: i64,
subtitle: i64,
playhead: i64,
playhead_seconds: f64,
) -> String {
let fps = if fr_den != 0 {
fr_num as f64 / fr_den as f64
} else {
0.0
};
format!(
" [{index}] \"{name}\"\n length: {length_seconds:.6} s ({len_num}/{len_den})\n \
let fps = if fr_den != 0 {
fr_num as f64 / fr_den as f64
} else {
0.0
};
format!(
" [{index}] \"{name}\"\n length: {length_seconds:.6} s ({len_num}/{len_den})\n \
frame rate: {fr_num}/{fr_den} ({fps:.3} fps)\n tracks: video={video} audio={audio} \
subtitle={subtitle}\n playhead: {playhead} ({playhead_seconds:.6} s)"
)
)
}
/// One footage entry (`cmd_info`).
@@ -95,25 +95,25 @@ pub fn sequence(
/// [0] "/abs/path/demo.mp4" online
/// ```
pub fn footage_entry(index: i64, filename: &str, online: bool) -> String {
format!(
" [{index}] \"{filename}\" {}",
if online { "online" } else { "offline" }
)
format!(
" [{index}] \"{filename}\" {}",
if online { "online" } else { "offline" }
)
}
/// `Decoder: <name>` (`cmd_probe`).
pub fn decoder_line(decoder: &str) -> String {
format!("Decoder: {decoder}")
format!("Decoder: {decoder}")
}
/// `Duration: <seconds> s` (`cmd_probe`).
pub fn duration_line(seconds: f64) -> String {
format!("Duration: {seconds:.6} s")
format!("Duration: {seconds:.6} s")
}
/// `Video streams: <n>` (`cmd_probe`).
pub fn video_streams_line(count: i64) -> String {
format!("Video streams: {count}")
format!("Video streams: {count}")
}
/// One video-stream line (`cmd_probe`).
@@ -122,39 +122,39 @@ pub fn video_streams_line(count: i64) -> String {
/// [0] stream 0: 1920x1080, 25/1 fps (25.000), duration 217600/12800 (17.000000 s), primaries=1 trc=1, progressive
/// ```
pub fn video_stream(
index: i64,
stream_index: i64,
width: i64,
height: i64,
frame_rate_num: i64,
frame_rate_den: i64,
duration_ts: i64,
time_base_den: i64,
seconds: f64,
color_primaries: i64,
color_trc: i64,
interlaced: bool,
index: i64,
stream_index: i64,
width: i64,
height: i64,
frame_rate_num: i64,
frame_rate_den: i64,
duration_ts: i64,
time_base_den: i64,
seconds: f64,
color_primaries: i64,
color_trc: i64,
interlaced: bool,
) -> String {
let fps = if frame_rate_den != 0 {
frame_rate_num as f64 / frame_rate_den as f64
} else {
0.0
};
let interlace = if interlaced {
"interlaced"
} else {
"progressive"
};
format!(
" [{index}] stream {stream_index}: {width}x{height}, {frame_rate_num}/{frame_rate_den} \
let fps = if frame_rate_den != 0 {
frame_rate_num as f64 / frame_rate_den as f64
} else {
0.0
};
let interlace = if interlaced {
"interlaced"
} else {
"progressive"
};
format!(
" [{index}] stream {stream_index}: {width}x{height}, {frame_rate_num}/{frame_rate_den} \
fps ({fps:.3}), duration {duration_ts}/{time_base_den} ({seconds:.6} s), \
primaries={color_primaries} trc={color_trc}, {interlace}"
)
)
}
/// `Audio streams: <n>` (`cmd_probe`).
pub fn audio_streams_line(count: i64) -> String {
format!("Audio streams: {count}")
format!("Audio streams: {count}")
}
/// One audio-stream line (`cmd_probe`).
@@ -163,88 +163,105 @@ pub fn audio_streams_line(count: i64) -> String {
/// [0] stream 1: 48000 Hz, 2 channels, duration 816000/48000 (17.000000 s)
/// ```
pub fn audio_stream(
index: i64,
stream_index: i64,
sample_rate: i64,
channel_count: i64,
duration_ts: i64,
time_base_den: i64,
seconds: f64,
index: i64,
stream_index: i64,
sample_rate: i64,
channel_count: i64,
duration_ts: i64,
time_base_den: i64,
seconds: f64,
) -> String {
format!(
" [{index}] stream {stream_index}: {sample_rate} Hz, {channel_count} channels, \
format!(
" [{index}] stream {stream_index}: {sample_rate} Hz, {channel_count} channels, \
duration {duration_ts}/{time_base_den} ({seconds:.6} s)"
)
)
}
/// `Subtitle streams: <n>` (`cmd_probe`).
pub fn subtitle_streams_line(count: i64) -> String {
format!("Subtitle streams: {count}")
format!("Subtitle streams: {count}")
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
// Golden text captured from the C++ binary:
// cmake-build-debug/cli/oak-cli info tests/project_with_footage.ove
// cmake-build-debug/cli/oak-cli probe tests/demo.mp4
// Golden text captured from the C++ binary:
// cmake-build-debug/cli/oak-cli info tests/project_with_footage.ove
// cmake-build-debug/cli/oak-cli probe tests/demo.mp4
#[test]
fn golden_info_output() {
let mut out = String::new();
out.push_str(&project_line("project_with_footage"));
out.push('\n');
out.push_str(&file_line("/Users/sunyu/Projects/oak/tests/project_with_footage.ove"));
out.push('\n');
out.push_str(&modified_line(false));
out.push('\n');
out.push_str(&sequences_line(1));
out.push('\n');
out.push_str(&sequence(
0, "Fixture Sequence", 0.0, 0, 1, 30000, 1001, 0, 0, 0, 0, 0.0,
));
out.push('\n');
out.push_str(&footage_line(1));
out.push('\n');
out.push_str(&footage_entry(0, "/Users/sunyu/Projects/oak/tests/demo.mp4", true));
#[test]
fn golden_info_output() {
let mut out = String::new();
out.push_str(&project_line("project_with_footage"));
out.push('\n');
out.push_str(&file_line(
"/Users/sunyu/Projects/oak/tests/project_with_footage.ove",
));
out.push('\n');
out.push_str(&modified_line(false));
out.push('\n');
out.push_str(&sequences_line(1));
out.push('\n');
out.push_str(&sequence(
0,
"Fixture Sequence",
0.0,
0,
1,
30000,
1001,
0,
0,
0,
0,
0.0,
));
out.push('\n');
out.push_str(&footage_line(1));
out.push('\n');
out.push_str(&footage_entry(
0,
"/Users/sunyu/Projects/oak/tests/demo.mp4",
true,
));
const GOLDEN: &str = concat!(
"Project: project_with_footage\n",
"File: /Users/sunyu/Projects/oak/tests/project_with_footage.ove\n",
"Modified: no\n",
"Sequences: 1\n",
" [0] \"Fixture Sequence\"\n",
" length: 0.000000 s (0/1)\n",
" frame rate: 30000/1001 (29.970 fps)\n",
" tracks: video=0 audio=0 subtitle=0\n",
" playhead: 0 (0.000000 s)\n",
"Footage: 1\n",
" [0] \"/Users/sunyu/Projects/oak/tests/demo.mp4\" online",
);
assert_eq!(out, GOLDEN);
}
const GOLDEN: &str = concat!(
"Project: project_with_footage\n",
"File: /Users/sunyu/Projects/oak/tests/project_with_footage.ove\n",
"Modified: no\n",
"Sequences: 1\n",
" [0] \"Fixture Sequence\"\n",
" length: 0.000000 s (0/1)\n",
" frame rate: 30000/1001 (29.970 fps)\n",
" tracks: video=0 audio=0 subtitle=0\n",
" playhead: 0 (0.000000 s)\n",
"Footage: 1\n",
" [0] \"/Users/sunyu/Projects/oak/tests/demo.mp4\" online",
);
assert_eq!(out, GOLDEN);
}
#[test]
fn golden_probe_output() {
let mut out = String::new();
out.push_str(&decoder_line("ffmpeg"));
out.push('\n');
out.push_str(&duration_line(17.0));
out.push('\n');
out.push_str(&video_streams_line(1));
out.push('\n');
out.push_str(&video_stream(
0, 0, 1920, 1080, 25, 1, 217600, 12800, 17.0, 1, 1, false,
));
out.push('\n');
out.push_str(&audio_streams_line(1));
out.push('\n');
out.push_str(&audio_stream(0, 1, 48000, 2, 816000, 48000, 17.0));
out.push('\n');
out.push_str(&subtitle_streams_line(0));
#[test]
fn golden_probe_output() {
let mut out = String::new();
out.push_str(&decoder_line("ffmpeg"));
out.push('\n');
out.push_str(&duration_line(17.0));
out.push('\n');
out.push_str(&video_streams_line(1));
out.push('\n');
out.push_str(&video_stream(
0, 0, 1920, 1080, 25, 1, 217600, 12800, 17.0, 1, 1, false,
));
out.push('\n');
out.push_str(&audio_streams_line(1));
out.push('\n');
out.push_str(&audio_stream(0, 1, 48000, 2, 816000, 48000, 17.0));
out.push('\n');
out.push_str(&subtitle_streams_line(0));
const GOLDEN: &str = concat!(
const GOLDEN: &str = concat!(
"Decoder: ffmpeg\n",
"Duration: 17.000000 s\n",
"Video streams: 1\n",
@@ -253,21 +270,21 @@ mod tests {
" [0] stream 1: 48000 Hz, 2 channels, duration 816000/48000 (17.000000 s)\n",
"Subtitle streams: 0",
);
assert_eq!(out, GOLDEN);
}
assert_eq!(out, GOLDEN);
}
#[test]
fn fps_rounding_matches_printf() {
// 30000/1001 = 29.970029... -> %.3f -> "29.970"
let s = sequence(0, "S", 0.0, 0, 1, 30000, 1001, 0, 0, 0, 0, 0.0);
assert!(s.contains("frame rate: 30000/1001 (29.970 fps)"), "{s}");
}
#[test]
fn fps_rounding_matches_printf() {
// 30000/1001 = 29.970029... -> %.3f -> "29.970"
let s = sequence(0, "S", 0.0, 0, 1, 30000, 1001, 0, 0, 0, 0, 0.0);
assert!(s.contains("frame rate: 30000/1001 (29.970 fps)"), "{s}");
}
#[test]
fn offline_footage_prints_offline() {
assert_eq!(
footage_entry(2, "gone.mp4", false),
" [2] \"gone.mp4\" offline"
);
}
#[test]
fn offline_footage_prints_offline() {
assert_eq!(
footage_entry(2, "gone.mp4", false),
" [2] \"gone.mp4\" offline"
);
}
}
+82 -82
View File
@@ -83,101 +83,101 @@ Exit codes:\n\
/// is reproduced exactly; clap still enforces the argument shapes.
#[derive(Parser, Debug)]
#[command(
name = "oak-cli",
disable_help_flag = true,
disable_version_flag = true,
subcommand_required = true
name = "oak-cli",
disable_help_flag = true,
disable_version_flag = true,
subcommand_required = true
)]
struct Cli {
#[command(subcommand)]
command: Command,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Print project name, sequences and footage.
Info {
/// Path to the project file (.ove).
project: String,
},
/// Render the first sequence to PPM frames (P6, 8-bit RGB) and the
/// audio range to a PCM s16 WAV file in <out_dir>.
Render {
/// Path to the project file (.ove).
project: String,
/// Start of the rendered range, in seconds.
start_seconds: String,
/// End of the rendered range, in seconds (must be > start).
end_seconds: String,
/// Directory the PPM frames and audio.wav are written into.
out_dir: String,
},
/// Probe a media file: decoder, duration, video and audio streams.
Probe {
/// Media file to probe.
mediafile: String,
},
/// Transcode a media file end to end.
Transcode {
/// Source media file.
input_media: String,
/// Output MP4 path, or the output directory with --format ppm.
out: String,
/// Output width (defaults to the source width).
width: Option<String>,
/// Output format: "mp4" (default) or "ppm".
#[arg(long = "format")]
format: Option<String>,
},
/// Print project name, sequences and footage.
Info {
/// Path to the project file (.ove).
project: String,
},
/// Render the first sequence to PPM frames (P6, 8-bit RGB) and the
/// audio range to a PCM s16 WAV file in <out_dir>.
Render {
/// Path to the project file (.ove).
project: String,
/// Start of the rendered range, in seconds.
start_seconds: String,
/// End of the rendered range, in seconds (must be > start).
end_seconds: String,
/// Directory the PPM frames and audio.wav are written into.
out_dir: String,
},
/// Probe a media file: decoder, duration, video and audio streams.
Probe {
/// Media file to probe.
mediafile: String,
},
/// Transcode a media file end to end.
Transcode {
/// Source media file.
input_media: String,
/// Output MP4 path, or the output directory with --format ppm.
out: String,
/// Output width (defaults to the source width).
width: Option<String>,
/// Output format: "mp4" (default) or "ppm".
#[arg(long = "format")]
format: Option<String>,
},
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let args: Vec<String> = std::env::args().skip(1).collect();
// argv[1] handling that mirrors the C++ main() exactly.
if let Some(first) = args.first() {
if first == "--help" || first == "-h" {
print!("{USAGE}");
exit(cmd::EXIT_OK);
}
}
if let Some(first) = args.first() {
if !matches!(first.as_str(), "info" | "render" | "probe" | "transcode") {
eprintln!("error: unknown command \"{first}\"");
eprint_usage();
exit(cmd::EXIT_USAGE);
}
}
// argv[1] handling that mirrors the C++ main() exactly.
if let Some(first) = args.first() {
if first == "--help" || first == "-h" {
print!("{USAGE}");
exit(cmd::EXIT_OK);
}
}
if let Some(first) = args.first() {
if !matches!(first.as_str(), "info" | "render" | "probe" | "transcode") {
eprintln!("error: unknown command \"{first}\"");
eprint_usage();
exit(cmd::EXIT_USAGE);
}
}
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
// clap's own arity/format message, then the C++ usage text.
let _ = e.print();
eprint_usage();
exit(cmd::EXIT_USAGE);
}
};
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
// clap's own arity/format message, then the C++ usage text.
let _ = e.print();
eprint_usage();
exit(cmd::EXIT_USAGE);
}
};
let code = match cli.command {
Command::Info { project } => cmd::info::run(project),
Command::Render {
project,
start_seconds,
end_seconds,
out_dir,
} => cmd::render::run(project, &start_seconds, &end_seconds, &out_dir),
Command::Probe { mediafile } => cmd::probe::run(mediafile),
Command::Transcode {
input_media,
out,
width,
format,
} => cmd::transcode::run(input_media, out, width, format),
};
exit(code);
let code = match cli.command {
Command::Info { project } => cmd::info::run(project),
Command::Render {
project,
start_seconds,
end_seconds,
out_dir,
} => cmd::render::run(project, &start_seconds, &end_seconds, &out_dir),
Command::Probe { mediafile } => cmd::probe::run(mediafile),
Command::Transcode {
input_media,
out,
width,
format,
} => cmd::transcode::run(input_media, out, width, format),
};
exit(code);
}
fn eprint_usage() {
eprint!("{USAGE}");
eprint!("{USAGE}");
}
+132 -131
View File
@@ -41,155 +41,156 @@ pub const PIXEL_FORMAT_F32: i32 = 4;
/// bytes apart (stride). `channels` is the per-pixel channel count in the
/// source data; only the first three channels are emitted.
pub fn write_ppm(
path: &Path,
width: i32,
height: i32,
format: i32,
channels: i32,
linesize: i32,
data: &[u8],
path: &Path,
width: i32,
height: i32,
format: i32,
channels: i32,
linesize: i32,
data: &[u8],
) -> io::Result<()> {
let width = usize::try_from(width).map_err(|_| invalid_data("negative width"))?;
let height = usize::try_from(height).map_err(|_| invalid_data("negative height"))?;
let linesize = usize::try_from(linesize).unwrap_or(0);
let channels = usize::try_from(channels).map_err(|_| invalid_data("negative channel count"))?;
if channels < 3 {
return Err(invalid_data("channel count below 3"));
}
let width = usize::try_from(width).map_err(|_| invalid_data("negative width"))?;
let height = usize::try_from(height).map_err(|_| invalid_data("negative height"))?;
let linesize = usize::try_from(linesize).unwrap_or(0);
let channels = usize::try_from(channels).map_err(|_| invalid_data("negative channel count"))?;
if channels < 3 {
return Err(invalid_data("channel count below 3"));
}
let mut out = Vec::with_capacity(
format!("P6\n{width} {height}\n255\n").len() + width * height * 3,
);
out.extend_from_slice(format!("P6\n{width} {height}\n255\n").as_bytes());
let mut out =
Vec::with_capacity(format!("P6\n{width} {height}\n255\n").len() + width * height * 3);
out.extend_from_slice(format!("P6\n{width} {height}\n255\n").as_bytes());
let mut row = vec![0u8; width * 3];
for y in 0..height {
let line_start = y * linesize;
let line_end = line_start.checked_add(linesize);
let line = match line_end {
Some(end) if end <= data.len() => &data[line_start..end],
_ => {
return Err(invalid_data("pixel data buffer is shorter than the frame geometry"));
}
};
for x in 0..width {
for c in 0..3 {
let v = if format == PIXEL_FORMAT_F32 {
// f32: 4 bytes per channel.
let off = (x * channels + c) * 4;
let px = f32::from_ne_bytes([
line[off],
line[off + 1],
line[off + 2],
line[off + 3],
]);
let clamped = if px < 0.0 {
0.0
} else if px > 1.0 {
1.0
} else {
px
};
// static_cast<unsigned char>(clamped * 255.0f + 0.5f):
// truncation toward zero, same as Rust `as u8`.
(clamped * 255.0 + 0.5) as u8
} else if format == 0 {
// u8: 1 byte per channel.
line[x * channels + c]
} else {
return Err(invalid_data(&format!(
"unsupported frame pixel format {format}"
)));
};
row[x * 3 + c] = v;
}
}
out.extend_from_slice(&row);
}
let mut row = vec![0u8; width * 3];
for y in 0..height {
let line_start = y * linesize;
let line_end = line_start.checked_add(linesize);
let line = match line_end {
Some(end) if end <= data.len() => &data[line_start..end],
_ => {
return Err(invalid_data(
"pixel data buffer is shorter than the frame geometry",
));
}
};
for x in 0..width {
for c in 0..3 {
let v = if format == PIXEL_FORMAT_F32 {
// f32: 4 bytes per channel.
let off = (x * channels + c) * 4;
let px = f32::from_ne_bytes([
line[off],
line[off + 1],
line[off + 2],
line[off + 3],
]);
let clamped = if px < 0.0 {
0.0
} else if px > 1.0 {
1.0
} else {
px
};
// static_cast<unsigned char>(clamped * 255.0f + 0.5f):
// truncation toward zero, same as Rust `as u8`.
(clamped * 255.0 + 0.5) as u8
} else if format == 0 {
// u8: 1 byte per channel.
line[x * channels + c]
} else {
return Err(invalid_data(&format!(
"unsupported frame pixel format {format}"
)));
};
row[x * 3 + c] = v;
}
}
out.extend_from_slice(&row);
}
let mut f = std::fs::File::create(path)?;
f.write_all(&out)
let mut f = std::fs::File::create(path)?;
f.write_all(&out)
}
fn invalid_data(msg: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, msg.to_string())
io::Error::new(io::ErrorKind::InvalidData, msg.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
fn bytes(hex: &str) -> Vec<u8> {
let mut v = Vec::new();
for pair in hex.as_bytes().chunks(2) {
let s = std::str::from_utf8(pair).unwrap();
v.push(u8::from_str_radix(s, 16).unwrap());
}
v
}
fn bytes(hex: &str) -> Vec<u8> {
let mut v = Vec::new();
for pair in hex.as_bytes().chunks(2) {
let s = std::str::from_utf8(pair).unwrap();
v.push(u8::from_str_radix(s, 16).unwrap());
}
v
}
#[test]
fn writes_p6_header_and_u8_rows() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_ppm_u8.ppm");
// 2x2, 3 channels, linesize 6, u8.
let data = vec![
1, 2, 3, 4, 5, 6, //
7, 8, 9, 10, 11, 12, //
];
write_ppm(&path, 2, 2, 0, 3, 6, &data).unwrap();
#[test]
fn writes_p6_header_and_u8_rows() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_ppm_u8.ppm");
// 2x2, 3 channels, linesize 6, u8.
let data = vec![
1, 2, 3, 4, 5, 6, //
7, 8, 9, 10, 11, 12, //
];
write_ppm(&path, 2, 2, 0, 3, 6, &data).unwrap();
let got = std::fs::read(&path).unwrap();
let mut expected = b"P6\n2 2\n255\n".to_vec();
expected.extend_from_slice(&data);
assert_eq!(got, expected);
let _ = std::fs::remove_file(&path);
}
let got = std::fs::read(&path).unwrap();
let mut expected = b"P6\n2 2\n255\n".to_vec();
expected.extend_from_slice(&data);
assert_eq!(got, expected);
let _ = std::fs::remove_file(&path);
}
#[test]
fn f32_rows_are_clamped_and_quantized() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_ppm_f32.ppm");
// 1x1, RGBA (4 channels), linesize 16, f32.
let data = bytes("0000803f0000803f0000803f00000000"); // 1.0, 1.0, 1.0, 0.0
write_ppm(&path, 1, 1, PIXEL_FORMAT_F32, 4, 16, &data).unwrap();
#[test]
fn f32_rows_are_clamped_and_quantized() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_ppm_f32.ppm");
// 1x1, RGBA (4 channels), linesize 16, f32.
let data = bytes("0000803f0000803f0000803f00000000"); // 1.0, 1.0, 1.0, 0.0
write_ppm(&path, 1, 1, PIXEL_FORMAT_F32, 4, 16, &data).unwrap();
let got = std::fs::read(&path).unwrap();
assert_eq!(&got[..11], b"P6\n1 1\n255\n");
assert_eq!(&got[11..], &[255, 255, 255]); // 1.0 -> 255 (clamped * 255 + 0.5, truncated)
let _ = std::fs::remove_file(&path);
}
let got = std::fs::read(&path).unwrap();
assert_eq!(&got[..11], b"P6\n1 1\n255\n");
assert_eq!(&got[11..], &[255, 255, 255]); // 1.0 -> 255 (clamped * 255 + 0.5, truncated)
let _ = std::fs::remove_file(&path);
}
#[test]
fn clamps_f32_negative_and_over_one() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_ppm_clamp.ppm");
// 2x1 RGB f32: (-0.5, 0.25, 2.0) | (0.0, 0.5, 1.0)
let mut data = Vec::new();
for v in [-0.5f32, 0.25, 2.0, 0.0, 0.5, 1.0] {
data.extend_from_slice(&v.to_ne_bytes());
}
write_ppm(&path, 2, 1, PIXEL_FORMAT_F32, 3, 24, &data).unwrap();
#[test]
fn clamps_f32_negative_and_over_one() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_ppm_clamp.ppm");
// 2x1 RGB f32: (-0.5, 0.25, 2.0) | (0.0, 0.5, 1.0)
let mut data = Vec::new();
for v in [-0.5f32, 0.25, 2.0, 0.0, 0.5, 1.0] {
data.extend_from_slice(&v.to_ne_bytes());
}
write_ppm(&path, 2, 1, PIXEL_FORMAT_F32, 3, 24, &data).unwrap();
let got = std::fs::read(&path).unwrap();
// 0.0 -> 0, 0.25*255+0.5=64.25 -> 64, 1.0 -> 255, 0.5*255+0.5=128.0 -> 128
assert_eq!(&got[11..], &[0, 64, 255, 0, 128, 255]);
let _ = std::fs::remove_file(&path);
}
let got = std::fs::read(&path).unwrap();
// 0.0 -> 0, 0.25*255+0.5=64.25 -> 64, 1.0 -> 255, 0.5*255+0.5=128.0 -> 128
assert_eq!(&got[11..], &[0, 64, 255, 0, 128, 255]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn unsupported_format_is_an_error() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_ppm_bad.ppm");
let err = write_ppm(&path, 1, 1, 7, 3, 3, &[0, 0, 0]).unwrap_err();
assert!(err.to_string().contains("unsupported frame pixel format 7"));
}
#[test]
fn unsupported_format_is_an_error() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_ppm_bad.ppm");
let err = write_ppm(&path, 1, 1, 7, 3, 3, &[0, 0, 0]).unwrap_err();
assert!(err.to_string().contains("unsupported frame pixel format 7"));
}
#[test]
fn short_buffer_is_an_error() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_ppm_short.ppm");
let err = write_ppm(&path, 4, 4, 0, 3, 12, &[0u8; 10]).unwrap_err();
assert!(err.to_string().contains("shorter than the frame geometry"));
}
#[test]
fn short_buffer_is_an_error() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_ppm_short.ppm");
let err = write_ppm(&path, 4, 4, 0, 3, 12, &[0u8; 10]).unwrap_err();
assert!(err.to_string().contains("shorter than the frame geometry"));
}
}
+111 -108
View File
@@ -31,16 +31,11 @@ use std::io::{self, Write};
use std::path::Path;
fn write_u16_le(f: &mut impl Write, v: u16) -> io::Result<()> {
f.write_all(&[v as u8, (v >> 8) as u8])
f.write_all(&[v as u8, (v >> 8) as u8])
}
fn write_u32_le(f: &mut impl Write, v: u32) -> io::Result<()> {
f.write_all(&[
v as u8,
(v >> 8) as u8,
(v >> 16) as u8,
(v >> 24) as u8,
])
f.write_all(&[v as u8, (v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8])
}
/// Write interleaved float samples as a PCM s16 WAV file.
@@ -48,120 +43,128 @@ fn write_u32_le(f: &mut impl Write, v: u32) -> io::Result<()> {
/// `data` must hold `samples * channels` values in interleaved order
/// (`[s0c0, s0c1, s1c0, s1c1, ...]`), matching what the C++ loop over
/// `oakengine_audio_data(audio, ch)[i]` emits.
pub fn write_wav(path: &Path, rate: i32, channels: i32, samples: i64, data: &[f32]) -> io::Result<()> {
let rate = u32::try_from(rate).map_err(|_| invalid_data("negative sample rate"))?;
let channels = u32::try_from(channels).map_err(|_| invalid_data("negative channel count"))?;
let samples = u64::try_from(samples).map_err(|_| invalid_data("negative sample count"))?;
if channels == 0 {
return Err(invalid_data("zero channel count"));
}
let expected = samples
.checked_mul(u64::from(channels))
.ok_or_else(|| invalid_data("sample count overflow"))?;
if data.len() as u64 != expected {
return Err(invalid_data("sample buffer length does not match rate/channels/samples"));
}
pub fn write_wav(
path: &Path,
rate: i32,
channels: i32,
samples: i64,
data: &[f32],
) -> io::Result<()> {
let rate = u32::try_from(rate).map_err(|_| invalid_data("negative sample rate"))?;
let channels = u32::try_from(channels).map_err(|_| invalid_data("negative channel count"))?;
let samples = u64::try_from(samples).map_err(|_| invalid_data("negative sample count"))?;
if channels == 0 {
return Err(invalid_data("zero channel count"));
}
let expected = samples
.checked_mul(u64::from(channels))
.ok_or_else(|| invalid_data("sample count overflow"))?;
if data.len() as u64 != expected {
return Err(invalid_data(
"sample buffer length does not match rate/channels/samples",
));
}
let data_size = expected
.checked_mul(2)
.and_then(|v| u32::try_from(v).ok())
.ok_or_else(|| invalid_data("WAV data chunk exceeds 4 GiB"))?;
let byte_rate = rate
.checked_mul(channels)
.and_then(|v| v.checked_mul(2))
.ok_or_else(|| invalid_data("byte rate overflow"))?;
let block_align = channels
.checked_mul(2)
.and_then(|v| u16::try_from(v).ok())
.ok_or_else(|| invalid_data("block align overflow"))?;
let data_size = expected
.checked_mul(2)
.and_then(|v| u32::try_from(v).ok())
.ok_or_else(|| invalid_data("WAV data chunk exceeds 4 GiB"))?;
let byte_rate = rate
.checked_mul(channels)
.and_then(|v| v.checked_mul(2))
.ok_or_else(|| invalid_data("byte rate overflow"))?;
let block_align = channels
.checked_mul(2)
.and_then(|v| u16::try_from(v).ok())
.ok_or_else(|| invalid_data("block align overflow"))?;
let mut f = std::fs::File::create(path)?;
f.write_all(b"RIFF")?;
write_u32_le(&mut f, 36 + data_size)?;
f.write_all(b"WAVE")?;
f.write_all(b"fmt ")?;
write_u32_le(&mut f, 16)?; // fmt chunk size
write_u16_le(&mut f, 1)?; // PCM
write_u16_le(&mut f, channels as u16)?;
write_u32_le(&mut f, rate)?;
write_u32_le(&mut f, byte_rate)?;
write_u16_le(&mut f, block_align)?;
write_u16_le(&mut f, 16)?; // bits per sample
f.write_all(b"data")?;
write_u32_le(&mut f, data_size)?;
let mut f = std::fs::File::create(path)?;
f.write_all(b"RIFF")?;
write_u32_le(&mut f, 36 + data_size)?;
f.write_all(b"WAVE")?;
f.write_all(b"fmt ")?;
write_u32_le(&mut f, 16)?; // fmt chunk size
write_u16_le(&mut f, 1)?; // PCM
write_u16_le(&mut f, channels as u16)?;
write_u32_le(&mut f, rate)?;
write_u32_le(&mut f, byte_rate)?;
write_u16_le(&mut f, block_align)?;
write_u16_le(&mut f, 16)?; // bits per sample
f.write_all(b"data")?;
write_u32_le(&mut f, data_size)?;
for &v in data {
let clamped = if v < -1.0 {
-1.0
} else if v > 1.0 {
1.0
} else {
v
};
// static_cast<int16_t>(clamped * 32767.0f): truncation toward zero.
let s = (clamped * 32767.0) as i16;
write_u16_le(&mut f, s as u16)?;
}
f.flush()
for &v in data {
let clamped = if v < -1.0 {
-1.0
} else if v > 1.0 {
1.0
} else {
v
};
// static_cast<int16_t>(clamped * 32767.0f): truncation toward zero.
let s = (clamped * 32767.0) as i16;
write_u16_le(&mut f, s as u16)?;
}
f.flush()
}
fn invalid_data(msg: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, msg.to_string())
io::Error::new(io::ErrorKind::InvalidData, msg.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
#[test]
fn golden_mono_wav() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_wav_mono.wav");
// 2 samples mono at 44100 Hz: 0.0, 0.5
write_wav(&path, 44100, 1, 2, &[0.0, 0.5]).unwrap();
#[test]
fn golden_mono_wav() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_wav_mono.wav");
// 2 samples mono at 44100 Hz: 0.0, 0.5
write_wav(&path, 44100, 1, 2, &[0.0, 0.5]).unwrap();
let got = std::fs::read(&path).unwrap();
// 44-byte header + 2 samples * 2 bytes.
assert_eq!(got.len(), 48);
assert_eq!(&got[0..4], b"RIFF");
// chunk size = 36 + 4 = 40
assert_eq!(&got[4..8], &[40, 0, 0, 0]);
assert_eq!(&got[8..12], b"WAVE");
assert_eq!(&got[12..16], b"fmt ");
assert_eq!(&got[16..20], &[16, 0, 0, 0]);
assert_eq!(&got[20..22], &[1, 0]); // PCM
assert_eq!(&got[22..24], &[1, 0]); // mono
assert_eq!(&got[24..28], &[0x44, 0xAC, 0, 0]); // 44100
assert_eq!(&got[28..32], &[0x88, 0x58, 0x01, 0]); // byte rate 88200
assert_eq!(&got[32..34], &[2, 0]); // block align
assert_eq!(&got[34..36], &[16, 0]); // bits per sample
assert_eq!(&got[36..40], b"data");
assert_eq!(&got[40..44], &[4, 0, 0, 0]); // data size
// 0.0 -> 0; 0.5 * 32767 = 16383.5 -> truncates to 16383 (0x3FFF)
assert_eq!(&got[44..48], &[0x00, 0x00, 0xFF, 0x3F]);
let _ = std::fs::remove_file(&path);
}
let got = std::fs::read(&path).unwrap();
// 44-byte header + 2 samples * 2 bytes.
assert_eq!(got.len(), 48);
assert_eq!(&got[0..4], b"RIFF");
// chunk size = 36 + 4 = 40
assert_eq!(&got[4..8], &[40, 0, 0, 0]);
assert_eq!(&got[8..12], b"WAVE");
assert_eq!(&got[12..16], b"fmt ");
assert_eq!(&got[16..20], &[16, 0, 0, 0]);
assert_eq!(&got[20..22], &[1, 0]); // PCM
assert_eq!(&got[22..24], &[1, 0]); // mono
assert_eq!(&got[24..28], &[0x44, 0xAC, 0, 0]); // 44100
assert_eq!(&got[28..32], &[0x88, 0x58, 0x01, 0]); // byte rate 88200
assert_eq!(&got[32..34], &[2, 0]); // block align
assert_eq!(&got[34..36], &[16, 0]); // bits per sample
assert_eq!(&got[36..40], b"data");
assert_eq!(&got[40..44], &[4, 0, 0, 0]); // data size
// 0.0 -> 0; 0.5 * 32767 = 16383.5 -> truncates to 16383 (0x3FFF)
assert_eq!(&got[44..48], &[0x00, 0x00, 0xFF, 0x3F]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn stereo_interleaving_and_clamping() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_wav_stereo.wav");
// 1 sample stereo at 48000: (-1.0, 1.0) interleaved.
write_wav(&path, 48000, 2, 1, &[-1.0, 1.0]).unwrap();
#[test]
fn stereo_interleaving_and_clamping() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_wav_stereo.wav");
// 1 sample stereo at 48000: (-1.0, 1.0) interleaved.
write_wav(&path, 48000, 2, 1, &[-1.0, 1.0]).unwrap();
let got = std::fs::read(&path).unwrap();
assert_eq!(&got[22..24], &[2, 0]); // stereo
assert_eq!(&got[32..34], &[4, 0]); // block align
// -1.0 -> -32767 = 0x8001; 1.0 -> 32767 = 0x7FFF
assert_eq!(&got[44..48], &[0x01, 0x80, 0xFF, 0x7F]);
let _ = std::fs::remove_file(&path);
}
let got = std::fs::read(&path).unwrap();
assert_eq!(&got[22..24], &[2, 0]); // stereo
assert_eq!(&got[32..34], &[4, 0]); // block align
// -1.0 -> -32767 = 0x8001; 1.0 -> 32767 = 0x7FFF
assert_eq!(&got[44..48], &[0x01, 0x80, 0xFF, 0x7F]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn sample_count_mismatch_is_an_error() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_wav_bad.wav");
let err = write_wav(&path, 48000, 2, 10, &[0.0f32; 3]).unwrap_err();
assert!(err.to_string().contains("does not match"));
}
#[test]
fn sample_count_mismatch_is_an_error() {
let dir = std::env::temp_dir();
let path = dir.join("oak_cli_test_wav_bad.wav");
let err = write_wav(&path, 48000, 2, 10, &[0.0f32; 3]).unwrap_err();
assert!(err.to_string().contains("does not match"));
}
}