diff --git a/crates/oak-cli/Cargo.toml b/crates/oak-cli/Cargo.toml index 20dc1e832..397833d6b 100644 --- a/crates/oak-cli/Cargo.toml +++ b/crates/oak-cli/Cargo.toml @@ -28,13 +28,13 @@ path = "src/main.rs" [dependencies] clap = { version = "4", features = ["derive"] } -# oakfacade is where the frozen oakengine_* C ABI exports live (staticlib + -# rlib). oak-cli is a pure consumer of that facade, so it links the rlib -# directly. The families this CLI needs (init / project / timeline / render / -# footage / exporter) are still deferred in the facade -# (src/facade/rust/src/deferred.rs), so this crate references no oakengine_* -# symbol yet: the subcommands validate their arguments and report the -# deferral (src/deferred.rs). The extern declarations in src/ffi.rs mirror -# the engine headers verbatim and resolve against this rlib the moment a -# family is wrapped -- no manifest change needed. -oakengine = { path = "../oakengine" } +# oak-cli is a PURE C-ABI consumer of the built `liboakengine` cdylib: +# every engine call goes through the `extern "C"` declarations in +# src/ffi.rs (and the dlsym-resolved optional families in src/optional.rs). +# No oak* crate appears here — not even oakengine, which is cdylib-only: +# the link happens through build.rs (rustc-link-search + rpath pointing at +# the profile dir `cargo build -p oakengine` produces the dylib in) and +# the `#[link(name = "oakengine", kind = "dylib")]` block in src/ffi.rs. +# Build order: `cargo build -p oakengine` first, then `cargo build` / +# `cargo test -p oak-cli` (linking and running the binary need the dylib; +# `cargo check` does not link and works standalone). diff --git a/crates/oak-cli/build.rs b/crates/oak-cli/build.rs new file mode 100644 index 000000000..fec83bcc1 --- /dev/null +++ b/crates/oak-cli/build.rs @@ -0,0 +1,58 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Link configuration for the `oak-cli` binary. +//! +//! oak-cli is a pure C-ABI consumer of the built `liboakengine` cdylib +//! (crates/oakengine): the `#[link(name = "oakengine", kind = "dylib")]` +//! block in `src/ffi.rs` puts `-loakengine` into the binary link, and this +//! script points the linker (and dyld, via the rpath) at the directory +//! that holds the dylib. +//! +//! The dylib is produced by the engine's own build (`cargo build -p +//! oakengine`); as a workspace member it lands in `target//` +//! (un-hashed, unlike dependency artifacts). The profile dir is derived +//! from `OUT_DIR` — `target//build/oak-cli-/out` — by +//! walking three ancestors up, so custom `CARGO_TARGET_DIR` layouts work +//! without duplication. +//! +//! `-Wl,-export_dynamic` exports the binary's own symbols: the CLI is the +//! *host* process for the engine dylib (exactly like the C++ cli/main.cpp +//! host), so the `oakcore_audioparams_*` shims in `src/host.rs` must be +//! visible to the dylib's runtime lookups (its `-undefined +//! dynamic_lookup` imports). +//! +//! Build order: `cargo build -p oakengine` must have run before the +//! binary link (`cargo build -p oak-cli`, `cargo test -p oak-cli`). +//! `cargo check` never links, so it stays green without the dylib. + +fn main() { + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap_or_default()); + // out -> oak-cli- -> build -> (debug/release) + let profile_dir = out_dir + .ancestors() + .nth(3) + .expect("OUT_DIR has a profile ancestor"); + println!("cargo:rustc-link-search=native={}", profile_dir.display()); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", profile_dir.display()); + println!("cargo:rustc-link-arg=-Wl,-export_dynamic"); + } else { + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", profile_dir.display()); + println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN"); + println!("cargo:rustc-link-arg=-Wl,--export-dynamic"); + } +} diff --git a/crates/oak-cli/src/cmd/info.rs b/crates/oak-cli/src/cmd/info.rs index 7fa7fefb6..ecedb4779 100644 --- a/crates/oak-cli/src/cmd/info.rs +++ b/crates/oak-cli/src/cmd/info.rs @@ -16,29 +16,208 @@ //! `oak-cli info ` — print the project name, its sequences and //! its footage (port of `cmd_info()` in cli/main.cpp). +//! +//! Runs entirely through the C ABI: `oakengine_init(OAKENGINE_INIT_HEADLESS)` +//! → `oakengine_project_create` + `oakengine_project_load(path)` → +//! `oakengine_project_name`/`filename`/`is_modified`/`sequence_count`/ +//! `sequence_at` (+ the `oakengine_sequence_*` getters) /`footage_count`/ +//! `footage_filename` → `oakengine_project_free` + `oakengine_shutdown()`. +//! The output is formatted by `crate::fmt` exactly like the C++ binary. +//! +//! Footage filenames stored relative to the `.ove` file are resolved +//! against the project directory for display (the C++ CLI's project-dir +//! convention); the online flag reports whether the resolved file exists. +//! A load failure prints the engine's error and exits 1. -use crate::cmd::{port_not_wired, require_or, EXIT_ERROR}; +use std::ffi::CString; +use std::path::Path; + +use crate::cmd::{EXIT_ERROR, EXIT_OK}; +use crate::ffi; +use crate::fmt; /// Run `info`. `project` is the .ove path from the command line. pub fn run(project: String) -> i32 { - if let Err(code) = require_or( - "info", - &[ - &crate::deferred::INIT, - &crate::deferred::NODE, - &crate::deferred::TIMELINE, - ], - EXIT_ERROR, - ) { - return code; + let code = run_info(&project); + unsafe { + crate::optional::engine_shutdown(); + } + code +} + +/// The info body; the caller owns the engine shutdown. +fn run_info(project: &str) -> i32 { + let rc = unsafe { crate::optional::engine_init(crate::ffi::OAKENGINE_INIT_HEADLESS) }; + if rc != crate::ffi::OAKENGINE_OK { + eprintln!("error: info: engine init failed ({rc})"); + return EXIT_ERROR; + } + + let handle = unsafe { crate::ffi::oakengine_project_create() }; + if handle.is_null() { + eprintln!("error: info: cannot create project"); + return EXIT_ERROR; + } + + let path = match CString::new(project) { + Ok(p) => p, + Err(_) => { + eprintln!("error: info: invalid path (NUL byte)"); + unsafe { crate::ffi::oakengine_project_free(handle) }; + return EXIT_ERROR; + } + }; + let mut err = [0 as std::ffi::c_char; 4096]; + let rc = unsafe { + crate::ffi::oakengine_project_load(handle, path.as_ptr(), err.as_mut_ptr(), err.len() as i32) + }; + if rc != crate::ffi::OAKENGINE_OK { + // SAFETY: the engine NUL-terminates `err` on failure. + let detail = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) } + .to_string_lossy() + .into_owned(); + if detail.is_empty() { + eprintln!("error: info: cannot load project \"{project}\""); + } else { + eprintln!("error: info: {detail}"); + } + unsafe { crate::ffi::oakengine_project_free(handle) }; + return EXIT_ERROR; + } + + let name = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_project_name(handle, buf, size) + }); + let filename = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_project_filename(handle, buf, size) + }); + let modified = unsafe { crate::ffi::oakengine_project_is_modified(handle) } != 0; + + // The engine's serializer swaps a fresh project payload in on load, + // wiping the pre-load filename (documented engine behavior); when the + // engine reports an empty filename the CLI falls back to the path it + // loaded — the C++ CLI's own project filename convention. + let abs = std::fs::canonicalize(project).unwrap_or_else(|_| Path::new(project).to_path_buf()); + let name = if name.is_empty() || name == "(untitled)" { + abs.file_name() + .map(|f| f.to_string_lossy().into_owned()) + .and_then(|f| f.split('.').next().map(|s| s.to_string())) + .unwrap_or_else(|| "(untitled)".to_string()) + } else { + name + }; + let filename = if filename.is_empty() { + abs.to_string_lossy().into_owned() + } else { + filename + }; + + println!("{}", fmt::project_line(&name)); + println!("{}", fmt::file_line(&filename)); + println!("{}", fmt::modified_line(modified)); + + // Footage paths in .ove files can be relative to the project file; + // resolve them for the online check (the C++ project-dir convention). + let project_dir = Path::new(project).parent().map(|p| p.to_path_buf()); + + let sequences = unsafe { crate::ffi::oakengine_project_sequence_count(handle) }.max(0); + println!("{}", fmt::sequences_line(sequences as i64)); + for index in 0..sequences { + // `sequence_at` returns an owned box with no matching free + // export (borrowed contract); it stays alive for the project. + let seq = unsafe { crate::ffi::oakengine_project_sequence_at(handle, index) }; + if seq.is_null() { + continue; + } + print_sequence(seq, index as i64); + } + + let footage = unsafe { crate::ffi::oakengine_project_footage_count(handle) }.max(0); + println!("{}", fmt::footage_line(footage as i64)); + for index in 0..footage { + let stored = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_project_footage_filename(handle, index, buf, size) + }); + let resolved = resolve_footage(&stored, project_dir.as_deref()); + let online = resolved.is_file(); + println!( + "{}", + fmt::footage_entry(index as i64, &resolved.to_string_lossy(), online) + ); + } + + unsafe { + crate::ffi::oakengine_project_free(handle); + } + EXIT_OK +} + +/// Print one sequence block (`print_sequence` in cli/main.cpp) through +/// the `oakengine_sequence_*` getters. +fn print_sequence(seq: *mut ffi::OakEngineSequence, index: i64) { + let name = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_sequence_name(seq, buf, size) + }); + + let mut length = 0.0f64; + let mut len_num: i32 = 0; + let mut len_den: i32 = 0; + unsafe { + let _ = crate::ffi::oakengine_sequence_get_length(seq, &mut length); + let _ = crate::ffi::oakengine_sequence_get_length_rational(seq, &mut len_num, &mut len_den); + } + + let mut fr_num: i32 = 0; + let mut fr_den: i32 = 0; + unsafe { + let _ = crate::ffi::oakengine_sequence_get_frame_rate(seq, &mut fr_num, &mut fr_den); + } + + let mut video: i32 = 0; + let mut audio: i32 = 0; + let mut subtitle: i32 = 0; + unsafe { + let _ = crate::ffi::oakengine_sequence_track_count( + seq, + &mut video, + &mut audio, + &mut subtitle, + ); + } + + let mut playhead: i64 = 0; + let mut playhead_seconds = 0.0f64; + unsafe { + let _ = crate::ffi::oakengine_sequence_get_playhead(seq, &mut playhead); + let _ = crate::ffi::oakengine_sequence_get_playhead_seconds(seq, &mut playhead_seconds); + } + + println!( + "{}", + fmt::sequence( + index, + &name, + length, + len_num as i64, + len_den as i64, + fr_num as i64, + fr_den as i64, + video as i64, + audio as i64, + subtitle as i64, + playhead, + playhead_seconds, + ) + ); +} + +/// Resolve a stored footage filename against the project directory (the +/// C++ project-dir convention); absolute paths pass through. +fn resolve_footage(stored: &str, project_dir: Option<&Path>) -> std::path::PathBuf { + let p = Path::new(stored); + if p.is_absolute() || project_dir.is_none() { + p.to_path_buf() + } else { + project_dir.unwrap().join(p) } - // Facade port (unreachable while the families above are deferred): - // oakengine_init(OAKENGINE_INIT_HEADLESS) - // project_create + project_load(project, ...) - // name/filename/is_modified/sequence_count/sequence_at(...) + - // fmt::sequence() / fmt::footage_entry() for each - // project_free + oakengine_shutdown() - // The formatters already exist in crate::fmt and are golden-tested. - let _ = &project; - port_not_wired("info", EXIT_ERROR) } diff --git a/crates/oak-cli/src/cmd/mod.rs b/crates/oak-cli/src/cmd/mod.rs index c0bed6163..c41d32518 100644 --- a/crates/oak-cli/src/cmd/mod.rs +++ b/crates/oak-cli/src/cmd/mod.rs @@ -16,56 +16,32 @@ //! Subcommand implementations. //! -//! Each subcommand is a faithful port of its `cli/main.cpp` counterpart: -//! the argument validation is real (same messages, same usage-error code), -//! and the facade work gates on [`crate::deferred::require`] — while the -//! families a subcommand needs are deferred, it prints the "not yet -//! available" error with the reasons and exits with the C++-compatible code -//! (1 for info/probe, 2 for render/transcode), never crashing. +//! Every subcommand is a REAL implementation over the `oakengine_*` C ABI +//! ([`crate::ffi`] + [`crate::optional`]) — a pure consumer of the built +//! `liboakengine` dylib, exactly like the C++ `cli/main.cpp` host: +//! +//! - `probe` → `oakengine_footage_probe` + the footage getters +//! - `info` → `oakengine_project_create/load` + project/sequence +//! getters +//! - `render` → `oakengine_render_manager_init`, +//! `oakengine_renderer_create` / `render_frame` / `render_audio` and +//! the frame/audio-buffer accessors +//! - `transcode` → project/sequence/clip assembly + +//! `oakengine_export_render` for mp4, the renderer frame loop for ppm +//! +//! Exit codes: 0 success, 1 general error, 2 rendering unavailable, +//! 64 usage error. pub mod info; pub mod probe; pub mod render; pub mod transcode; -use crate::deferred::DeferredFamily; - /// 0 — success. pub const EXIT_OK: i32 = 0; /// 1 — general error (bad project/media file, no sequence, I/O failure). pub const EXIT_ERROR: i32 = 1; -/// 2 — rendering unavailable or failed (e.g. no GL render backend). +/// 2 — rendering unavailable or failed (e.g. no render backend). pub const EXIT_RENDER_UNAVAILABLE: i32 = 2; /// 64 — usage error. pub const EXIT_USAGE: i32 = 64; - -/// Gate a subcommand on its facade families. -/// -/// When every family is wrapped this returns `Ok(())` and the subcommand's -/// port runs; when any is deferred it prints the composed "not yet -/// available" message to stderr and returns `Err(unavailable_code)` — the -/// code the C++ binary would exit with when that family's work is -/// impossible (1 for info/probe, 2 for render/transcode). -pub fn require_or( - cmd: &str, - families: &[&DeferredFamily], - unavailable_code: i32, -) -> Result<(), i32> { - match crate::deferred::require(families) { - Ok(()) => Ok(()), - Err(msg) => { - eprintln!("error: {cmd}: {msg}"); - Err(unavailable_code) - } - } -} - -/// Fallback for the (today unreachable) success arm of `require_or`: the -/// gate reported the families available, but the call-through port is not -/// wired yet. Never panics; reports an internal error and returns `code`. -pub fn port_not_wired(cmd: &str, code: i32) -> i32 { - eprintln!( - "error: {cmd}: internal error: facade families reported available but no port is wired yet" - ); - code -} diff --git a/crates/oak-cli/src/cmd/probe.rs b/crates/oak-cli/src/cmd/probe.rs index 499aa36bf..6ca50d879 100644 --- a/crates/oak-cli/src/cmd/probe.rs +++ b/crates/oak-cli/src/cmd/probe.rs @@ -17,23 +17,180 @@ //! `oak-cli probe ` — probe a media file and print its decoder, //! duration and video/audio/subtitle streams (port of `cmd_probe()` in //! cli/main.cpp). +//! +//! Runs entirely through the C ABI: `oakengine_init(OAKENGINE_INIT_HEADLESS)` +//! → `oakengine_footage_probe(path)` → the `oakengine_footage_get_*` +//! getters → `oakengine_footage_free` + `oakengine_shutdown()`. The output +//! is formatted by `crate::fmt` exactly like the C++ `printf` calls. +//! +//! The engine's probe records what the oaknode footage module probes: the +//! decoder id, the stream counts and (per stream) the module-visible +//! params. The module currently drops the codec's probe description, so +//! `get_duration` reports 0 and the stream counts report 0 for media the +//! module has not loaded stream metadata for — the CLI prints exactly +//! what the engine answers. A missing file / failed probe prints +//! `oakengine_footage_last_error` on stderr and exits 1. -use crate::cmd::{port_not_wired, require_or, EXIT_ERROR}; +use std::ffi::{CString, c_int}; + +use crate::cmd::{EXIT_ERROR, EXIT_OK}; +use crate::ffi::{self, OakFootageAudioInfo, OakFootageVideoInfo}; +use crate::fmt; /// Run `probe`. `mediafile` is the media path from the command line. pub fn run(mediafile: String) -> i32 { - if let Err(code) = require_or( - "probe", - &[&crate::deferred::INIT, &crate::deferred::NODE], - EXIT_ERROR, - ) { - return code; + let code = run_probe(&mediafile); + unsafe { + crate::optional::engine_shutdown(); } - // Facade port (unreachable while the families above are deferred): - // oakengine_init(OAKENGINE_INIT_HEADLESS) - // footage_probe(mediafile) -> decoder_name/duration/stream infos, - // formatted with the fmt::* lines (golden-tested) - // footage_free + oakengine_shutdown() - let _ = &mediafile; - port_not_wired("probe", EXIT_ERROR) + code +} + +/// The probe body; the caller owns the engine shutdown. +fn run_probe(mediafile: &str) -> i32 { + let rc = unsafe { crate::optional::engine_init(crate::ffi::OAKENGINE_INIT_HEADLESS) }; + if rc != crate::ffi::OAKENGINE_OK { + eprintln!("error: probe: engine init failed ({rc})"); + return EXIT_ERROR; + } + + let path = match CString::new(mediafile) { + Ok(p) => p, + Err(_) => { + eprintln!("error: probe: invalid path (NUL byte)"); + return EXIT_ERROR; + } + }; + let footage = unsafe { crate::ffi::oakengine_footage_probe(path.as_ptr()) }; + if footage.is_null() { + let err = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_footage_last_error(buf, size) + }); + eprintln!("error: probe: {err}"); + return EXIT_ERROR; + } + + println!( + "{}", + fmt::decoder_line(&crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_footage_get_decoder_name(footage, buf, size) + })) + ); + + let mut duration = 0.0f64; + let rc = unsafe { crate::ffi::oakengine_footage_get_duration(footage, &mut duration) }; + if rc == crate::ffi::OAKENGINE_OK { + println!("{}", fmt::duration_line(duration)); + } else { + println!("{}", fmt::duration_line(0.0)); + } + + let video = unsafe { crate::ffi::oakengine_footage_get_video_stream_count(footage) }.max(0); + println!("{}", fmt::video_streams_line(video as i64)); + for index in 0..video { + if let Some(info) = unsafe { video_stream_info(footage, index) } { + let secs = stream_seconds( + info.duration_ts, + (info.time_base_num, info.time_base_den), + ); + println!( + "{}", + fmt::video_stream( + index as i64, + info.stream_index as i64, + info.width as i64, + info.height as i64, + info.frame_rate_num as i64, + info.frame_rate_den as i64, + info.duration_ts, + info.time_base_den as i64, + secs, + info.color_primaries as i64, + info.color_trc as i64, + info.interlaced != 0, + ) + ); + } + } + + let audio = unsafe { crate::ffi::oakengine_footage_get_audio_stream_count(footage) }.max(0); + println!("{}", fmt::audio_streams_line(audio as i64)); + for index in 0..audio { + // The stream-info getter reports what the engine can describe + // (the module's audio stream descriptions are not reachable + // yet); streams the engine cannot describe are counted only. + if let Some(info) = unsafe { audio_stream_info(footage, index) } { + let secs = stream_seconds( + info.duration_ts, + (info.time_base_num, info.time_base_den), + ); + println!( + "{}", + fmt::audio_stream( + index as i64, + info.stream_index as i64, + info.sample_rate as i64, + info.channel_count as i64, + info.duration_ts, + info.time_base_den as i64, + secs, + ) + ); + } + } + + let subtitle = + unsafe { crate::ffi::oakengine_footage_get_subtitle_stream_count(footage) }.max(0); + println!("{}", fmt::subtitle_streams_line(subtitle as i64)); + + unsafe { + crate::ffi::oakengine_footage_free(footage); + } + EXIT_OK +} + +/// `oakengine_footage_get_video_stream_info` into an owned POD +/// (`None` when the engine reports the stream as unavailable). +unsafe fn video_stream_info(footage: *mut ffi::OakEngineFootage, index: c_int) -> Option { + 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 { + 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 } diff --git a/crates/oak-cli/src/cmd/render.rs b/crates/oak-cli/src/cmd/render.rs index bfa9b30ad..d631a3df9 100644 --- a/crates/oak-cli/src/cmd/render.rs +++ b/crates/oak-cli/src/cmd/render.rs @@ -17,15 +17,38 @@ //! `oak-cli render ` — //! render the first sequence to PPM frames plus a PCM s16 WAV (port of //! `cmd_render()` in cli/main.cpp). +//! +//! Runs entirely through the C ABI: +//! +//! 1. `oakengine_init(OAKENGINE_INIT_HEADLESS | OAKENGINE_INIT_RENDER)` +//! — plus `oakengine_render_manager_init()`, the Rust facade's +//! replacement for the C++ engine core's OAKENGINE_INIT_RENDER boot +//! (the ticket render path needs the oakrender manager up). +//! 2. `oakengine_project_create` + `oakengine_project_load` (the +//! process chdirs into the project directory first, like the C++ +//! CLI, so relative footage paths resolve during rendering). +//! 3. Sequence 0's frame rate via `oakengine_sequence_get_frame_rate` +//! and geometry via `oakengine_sequence_get_video_params`. +//! 4. `oakengine_renderer_create(seq, w, h, f32, fr_num, fr_den, null)` +//! → for every frame timestamp in `[start, end)` +//! `oakengine_renderer_render_frame` → the `oakengine_frame_*` +//! accessors → [`crate::ppm::write_ppm`] (P6, 8-bit RGB). +//! 5. The audio range through `oakengine_renderer_render_audio` → the +//! `oakengine_audio_*` accessors → [`crate::wav::write_wav`]. +//! +//! Exit codes: a renderer-create or per-frame render failure exits 2 +//! (rendering unavailable, mirroring the C++ code for a missing render +//! backend); project/sequence/argument failures exit 1; bad seconds exit +//! 64 (usage). Frame progress goes to stderr (`frame N: T s`). -use crate::cmd::{port_not_wired, require_or, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE}; +use std::path::Path; + +use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE}; +use crate::ffi; +use crate::ppm; +use crate::wav; /// Run `render` with the validated (or rejected) seconds arguments. -/// -/// The seconds are validated exactly like the C++ `strtod` checks before any -/// facade work; the facade work itself (init + project + sequence + renderer, -/// then [`crate::ppm::write_ppm`] / [`crate::wav::write_wav`] per frame) is -/// gated on the deferred families below. pub fn run(project: String, start_seconds: &str, end_seconds: &str, out_dir: &str) -> i32 { let start: f64 = match start_seconds.parse() { Ok(v) => v, @@ -46,24 +69,314 @@ pub fn run(project: String, start_seconds: &str, end_seconds: &str, out_dir: &st return EXIT_USAGE; } - if let Err(code) = require_or( - "render", - &[ - &crate::deferred::INIT, - &crate::deferred::NODE, - &crate::deferred::TIMELINE, - &crate::deferred::RENDER, - ], - EXIT_RENDER_UNAVAILABLE, - ) { - return code; + let code = run_render(&project, start, end, out_dir); + unsafe { + crate::optional::engine_shutdown(); } - // Facade port (unreachable while the families above are deferred): - // oakengine_init(HEADLESS | RENDER), chdir to the project dir, - // project_load, sequence 0 frame rate -> start_ts/end_ts, - // renderer_create(f32, fr_num, fr_den), then for each timestamp - // render_frame -> ppm::write_ppm (progress on stderr), then - // render_audio -> wav::write_wav. Both writers are golden-tested. - let _ = (&project, &start, &end, &out_dir); - port_not_wired("render", EXIT_RENDER_UNAVAILABLE) + code +} + +/// The render body; the caller owns the engine shutdown. +fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 { + let rc = unsafe { + crate::optional::engine_init( + crate::ffi::OAKENGINE_INIT_HEADLESS | crate::ffi::OAKENGINE_INIT_RENDER, + ) + }; + if rc != crate::ffi::OAKENGINE_OK { + eprintln!("error: render: engine init failed ({rc})"); + return EXIT_ERROR; + } + + // The Rust facade's render boot: the ticket render path requires the + // oakrender manager (the C++ OAKENGINE_INIT_RENDER equivalent). + if unsafe { crate::ffi::oakengine_render_manager_init() } != crate::ffi::OAKENGINE_OK { + eprintln!("error: render: cannot initialize the render manager"); + return EXIT_RENDER_UNAVAILABLE; + } + + // Absolute project path first — the C++ CLI chdirs into the project + // directory so relative footage paths resolve during rendering. + let abs = match std::fs::canonicalize(project) { + Ok(p) => p, + Err(e) => { + eprintln!("error: render: cannot open project \"{project}\": {e}"); + unsafe { crate::ffi::oakengine_render_manager_shutdown() }; + return EXIT_ERROR; + } + }; + if let Some(dir) = abs.parent() { + let _ = std::env::set_current_dir(dir); + } + + let handle = unsafe { crate::ffi::oakengine_project_create() }; + if handle.is_null() { + eprintln!("error: render: cannot create project"); + unsafe { crate::ffi::oakengine_render_manager_shutdown() }; + return EXIT_ERROR; + } + let path = match std::ffi::CString::new(abs.as_os_str().as_encoded_bytes()) { + Ok(p) => p, + Err(_) => { + eprintln!("error: render: invalid path (NUL byte)"); + unsafe { + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_ERROR; + } + }; + let mut err = [0 as std::ffi::c_char; 4096]; + let rc = unsafe { + crate::ffi::oakengine_project_load(handle, path.as_ptr(), err.as_mut_ptr(), err.len() as i32) + }; + if rc != crate::ffi::OAKENGINE_OK { + // SAFETY: the engine NUL-terminates `err` on failure. + let detail = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) } + .to_string_lossy() + .into_owned(); + if detail.is_empty() { + eprintln!("error: render: cannot load project \"{project}\""); + } else { + eprintln!("error: render: {detail}"); + } + unsafe { + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_ERROR; + } + + if unsafe { crate::ffi::oakengine_project_sequence_count(handle) } < 1 { + eprintln!("error: render: project has no sequences"); + unsafe { + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_ERROR; + } + // Borrowed sequence box (no free export); lives for the project. + let seq = unsafe { crate::ffi::oakengine_project_sequence_at(handle, 0) }; + if seq.is_null() { + eprintln!("error: render: sequence 0 unavailable"); + unsafe { + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_ERROR; + } + + let mut fr_num: i32 = 0; + let mut fr_den: i32 = 0; + unsafe { + crate::ffi::oakengine_sequence_get_frame_rate(seq, &mut fr_num, &mut fr_den); + } + if fr_num <= 0 || fr_den <= 0 { + eprintln!("error: render: invalid sequence frame rate {fr_num}/{fr_den}"); + unsafe { + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_ERROR; + } + + let mut width: i32 = 0; + let mut height: i32 = 0; + unsafe { + crate::ffi::oakengine_sequence_get_video_params( + seq, + &mut width, + &mut height, + &mut 0, + &mut 0, + ); + } + if width <= 0 || height <= 0 { + eprintln!("error: render: sequence has no video geometry"); + unsafe { + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_ERROR; + } + + if let Err(e) = std::fs::create_dir_all(out_dir) { + eprintln!("error: render: cannot create output directory \"{out_dir}\": {e}"); + unsafe { + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_ERROR; + } + + let renderer = unsafe { + crate::ffi::oakengine_renderer_create( + seq, + width, + height, + crate::ffi::PIXEL_FORMAT_F32, + fr_num, + fr_den, + std::ptr::null(), + ) + }; + if renderer.is_null() { + // The engine's create path returns NULL without setting the + // renderer's last error (invalid geometry/format or no module + // backing); report the contract message. + eprintln!("error: render: cannot create renderer"); + unsafe { + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_RENDER_UNAVAILABLE; + } + + // Frame loop: timestamps in the sequence time base (1/fr_num s). + let start_frames = (start * fr_num as f64 / fr_den as f64).round() as i64; + let mut index = start_frames; + let mut written: u64 = 0; + loop { + let time = index as f64 * fr_den as f64 / fr_num as f64; + if time >= end { + break; + } + let frame = unsafe { crate::ffi::oakengine_renderer_render_frame(renderer, index) }; + if frame.is_null() { + let err = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_renderer_last_error(renderer, buf, size) + }); + let msg = if err.is_empty() { + format!("frame at {time:.6} s failed to render") + } else { + format!("frame at {time:.6} s failed: {err}") + }; + eprintln!("error: render: {msg}"); + unsafe { + crate::ffi::oakengine_renderer_free(renderer); + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_RENDER_UNAVAILABLE; + } + if let Err(msg) = unsafe { write_frame_ppm(frame, out_dir, written) } { + eprintln!("error: render: {msg}"); + unsafe { + crate::ffi::oakengine_frame_free(frame); + crate::ffi::oakengine_renderer_free(renderer); + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_ERROR; + } + unsafe { + crate::ffi::oakengine_frame_free(frame); + } + eprintln!("frame {written}: {time:.6} s"); + written += 1; + index += 1; + } + + if written == 0 { + eprintln!("error: render: empty frame range"); + unsafe { + crate::ffi::oakengine_renderer_free(renderer); + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_ERROR; + } + + // Audio range in the sequence time base. + let start_ts = (start * fr_num as f64 / fr_den as f64).round() as i64; + let length_ts = ((end - start) * fr_num as f64 / fr_den as f64).round() as i64; + let audio = unsafe { crate::ffi::oakengine_renderer_render_audio(renderer, start_ts, length_ts) }; + if audio.is_null() { + let err = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_renderer_last_error(renderer, buf, size) + }); + let msg = if err.is_empty() { + "audio render failed".to_string() + } else { + format!("audio render failed: {err}") + }; + eprintln!("error: render: {msg}"); + unsafe { + crate::ffi::oakengine_renderer_free(renderer); + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + return EXIT_RENDER_UNAVAILABLE; + } + let code = unsafe { write_audio_wav(audio, out_dir) }; + unsafe { + crate::ffi::oakengine_audio_free(audio); + crate::ffi::oakengine_renderer_free(renderer); + crate::ffi::oakengine_project_free(handle); + crate::ffi::oakengine_render_manager_shutdown(); + } + code +} + +/// Write a rendered frame as `frame_%05d.ppm` in `out_dir` (the +/// `oakengine_frame_*` accessors feed the [`crate::ppm`] writer). +/// +/// `oakengine_frame_channel_count` is not backed by the current engine +/// (returns 0); the render module's frames are always in the internal +/// RGBA layout (`VideoParams::k_internal_channel_count == 4`), so a +/// zero/negative channel report falls back to 4 channels. +unsafe fn write_frame_ppm(frame: *mut ffi::OakEngineFrame, out_dir: &str, index: u64) -> Result<(), String> { + let width = unsafe { crate::ffi::oakengine_frame_width(frame) }; + let height = unsafe { crate::ffi::oakengine_frame_height(frame) }; + let format = unsafe { crate::ffi::oakengine_frame_format(frame) }; + let channels = unsafe { crate::ffi::oakengine_frame_channel_count(frame) }; + let channels = if channels > 0 { channels } else { 4 }; + let linesize = unsafe { crate::ffi::oakengine_frame_linesize_bytes(frame) }; + let data = unsafe { crate::ffi::oakengine_frame_data(frame) }; + if data.is_null() || width <= 0 || height <= 0 || linesize <= 0 { + return Err(format!( + "frame {index} has no pixel data ({}x{}, linesize {linesize})", + width, height + )); + } + let len = (linesize as usize) + .checked_mul(height as usize) + .ok_or_else(|| "frame buffer size overflow".to_string())?; + // SAFETY: the engine's frame buffer is valid for linesize * height + // bytes for the duration of this call. + let bytes = unsafe { std::slice::from_raw_parts(data as *const u8, len) }; + let path = Path::new(out_dir).join(format!("frame_{index:05}.ppm")); + ppm::write_ppm(&path, width, height, format, channels, linesize, bytes) + .map_err(|e| format!("cannot write \"{}\": {e}", path.display())) +} + +/// Write the rendered audio buffer as `audio.wav` in `out_dir` (the +/// `oakengine_audio_*` accessors feed the [`crate::wav`] writer). The +/// buffer is interleaved f32; the engine returns the whole buffer base +/// for every channel, so channel 0 covers all frames. +unsafe fn write_audio_wav(audio: *mut ffi::OakEngineAudioBuffer, out_dir: &str) -> i32 { + let rate = unsafe { crate::ffi::oakengine_audio_sample_rate(audio) }; + let channels = unsafe { crate::ffi::oakengine_audio_channel_count(audio) }; + let samples = unsafe { crate::ffi::oakengine_audio_sample_count(audio) }; + let data = unsafe { crate::ffi::oakengine_audio_data(audio, 0) }; + if data.is_null() || rate <= 0 || channels <= 0 || samples <= 0 { + eprintln!("error: render: audio buffer is empty"); + return EXIT_RENDER_UNAVAILABLE; + } + let len = match (samples as usize).checked_mul(channels as usize) { + Some(l) => l, + None => { + eprintln!("error: render: audio buffer size overflow"); + return EXIT_RENDER_UNAVAILABLE; + } + }; + // SAFETY: the engine's audio buffer is valid for samples * channels + // floats for the duration of this call. + let floats = unsafe { std::slice::from_raw_parts(data, len) }; + let path = Path::new(out_dir).join("audio.wav"); + if let Err(e) = wav::write_wav(&path, rate, channels, samples, floats) { + eprintln!("error: render: cannot write \"{}\": {e}", path.display()); + return EXIT_ERROR; + } + EXIT_OK } diff --git a/crates/oak-cli/src/cmd/transcode.rs b/crates/oak-cli/src/cmd/transcode.rs index 1ea46b76f..183172dae 100644 --- a/crates/oak-cli/src/cmd/transcode.rs +++ b/crates/oak-cli/src/cmd/transcode.rs @@ -16,12 +16,58 @@ //! `oak-cli transcode [width] [--format ppm|mp4]` — //! "media in, renders out" round trip (port of `cmd_transcode()` in -//! cli/main.cpp). +//! cli/main.cpp), entirely through the C ABI. +//! +//! The source is probed with `oakengine_footage_probe` (geometry / frame +//! rate / duration through the `oakengine_footage_get_*` getters), then a +//! temporary project is assembled the same way the C++ CLI did: +//! `oakengine_project_create` + `oakengine_project_new` + +//! `oakengine_project_import_footage` + `oakengine_sequence_new` + +//! `oakengine_sequence_set_video_params` + `oakengine_sequence_add_track` +//! + `oakengine_sequence_add_footage_clip_ex` (the `_ex` variant: the +//! engine keeps created sequences in their own scratch project — a +//! documented deviation, so the plain variant's same-project check can +//! never pass). +//! +//! - `--format ppm` (and the image/still path): renders the frame range +//! through `oakengine_renderer_render_frame` into P6 PPM frames via +//! [`crate::ppm`], plus the audio range through +//! `oakengine_renderer_render_audio` into a PCM s16 WAV via +//! [`crate::wav`] when the source has audio streams. +//! - `--format mp4` (default): H.264/AAC through +//! `oakengine_export_render` with the engine's exporter options +//! (codec-default bit rates). The exporter family is currently NOT +//! wrapped by the Rust facade, so this path reports +//! `oakengine_export_last_error` and exits 1 until the dylib grows it +//! (see `crate::optional`). +//! +//! The engine's footage probe records the decoder id but drops the +//! codec's stream descriptions (module gap), so when the stream info is +//! unavailable the CLI falls back to `[width]` (or 1920), a 16:9 height, +//! 25 fps and a single-frame range — the still-image contract the C++ +//! CLI used for duration-less sources. Failures exit 1 (general error); +//! bad arguments exit 64. -use crate::cmd::{port_not_wired, require_or, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE}; +use std::ffi::CString; +use std::path::Path; -/// Run `transcode`. `width`/`format` are validated exactly like the C++ loop -/// over `argv[4..]`; the facade work is gated on the deferred families below. +use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_USAGE}; +use crate::ffi::{self, OakExportOptions}; +use crate::ppm; +use crate::wav; + +/// Source description distilled from the probe (through the C ABI). +struct SourceInfo { + width: i32, + height: i32, + fr_num: i32, + fr_den: i32, + duration: f64, + audio_streams: i32, +} + +/// Run `transcode`. `width`/`format` are validated exactly like the C++ +/// loop over `argv[4..]`. pub fn run(input_media: String, out: String, width: Option, format: Option) -> i32 { if let Some(w) = &width { match w.parse::() { @@ -39,27 +85,481 @@ pub fn run(input_media: String, out: String, width: Option, format: Opti } } - if let Err(code) = require_or( - "transcode", - &[ - &crate::deferred::INIT, - &crate::deferred::NODE, - &crate::deferred::TIMELINE, - &crate::deferred::RENDER, - &crate::deferred::EXPORT, - ], - EXIT_RENDER_UNAVAILABLE, - ) { - return code; + let is_ppm = format.as_deref().unwrap_or("mp4") == "ppm"; + let code = run_transcode(&input_media, &out, width.as_deref(), is_ppm); + unsafe { + crate::optional::engine_shutdown(); } - // Facade port (unreachable while the families above are deferred): - // probe the source for geometry/fps/duration, build a temporary - // project (new + import_footage + sequence_new + add_track x2 + - // add_footage_clip x2), then either the ppm path (render_frame / - // render_audio -> ppm::write_ppm / wav::write_wav) or the mp4 path - // (oakengine_export_render with H.264/AAC options + progress - // callback). The C++ exits 2 when the render/export backend is - // unavailable, which is also the code used here. - let _ = (&input_media, &out, &width, &format); - port_not_wired("transcode", EXIT_RENDER_UNAVAILABLE) + code +} + +/// The transcode body; the caller owns the engine shutdown. +fn run_transcode(input: &str, out: &str, width: Option<&str>, is_ppm: bool) -> i32 { + let rc = unsafe { + crate::optional::engine_init( + crate::ffi::OAKENGINE_INIT_HEADLESS | crate::ffi::OAKENGINE_INIT_RENDER, + ) + }; + if rc != crate::ffi::OAKENGINE_OK { + eprintln!("error: transcode: engine init failed ({rc})"); + return EXIT_ERROR; + } + + let src = match probe_source(input) { + Ok(s) => s, + Err(msg) => { + eprintln!("error: transcode: {msg}"); + return EXIT_ERROR; + } + }; + + let out_w = width + .and_then(|w| w.parse::().ok()) + .unwrap_or(src.width); + let out_h = if src.width > 0 && src.height > 0 { + ((out_w as f64 * src.height as f64 / src.width as f64).round() as i32).max(1) + } else { + (out_w as f64 * 9.0 / 16.0).round() as i32 + }; + let fr_num = if src.fr_num > 0 { src.fr_num } else { 25 }; + let fr_den = if src.fr_den > 0 { src.fr_den } else { 1 }; + // A still/duration-less source counts as a single frame. + let frames = (src.duration * fr_num as f64 / fr_den as f64).round() as i64; + let frames = frames.max(1); + + // The temporary project + sequence + clips both output paths share. + let assembly = match assemble_project(input, out_w, out_h, fr_num, fr_den, frames, src.audio_streams) { + Ok(a) => a, + Err(msg) => { + eprintln!("error: transcode: {msg}"); + return EXIT_ERROR; + } + }; + + let code = if is_ppm { + transcode_ppm( + &assembly, + out, + out_w, + out_h, + fr_num, + fr_den, + frames, + src.audio_streams > 0, + ) + } else { + transcode_mp4(&assembly, out, out_w, out_h, frames) + }; + unsafe { + crate::ffi::oakengine_render_manager_shutdown(); + crate::ffi::oakengine_project_free(assembly.project); + } + code +} + +/// The assembled temporary project: project + footage + sequence (with +/// one video track/clip and, when the source has audio streams, one +/// audio track/clip). +struct Assembly { + project: *mut ffi::OakEngineProject, + sequence: *mut ffi::OakEngineSequence, +} + +/// Build the temporary project for the render/export stage. +fn assemble_project( + input: &str, + out_w: i32, + out_h: i32, + fr_num: i32, + fr_den: i32, + frames: i64, + audio_streams: i32, +) -> Result { + if unsafe { crate::ffi::oakengine_render_manager_init() } != crate::ffi::OAKENGINE_OK { + return Err("cannot initialize the render manager".to_string()); + } + let project = unsafe { crate::ffi::oakengine_project_create() }; + if project.is_null() { + return Err("cannot create project".to_string()); + } + if unsafe { crate::ffi::oakengine_project_new(project) } != crate::ffi::OAKENGINE_OK { + unsafe { crate::ffi::oakengine_project_free(project) }; + return Err("cannot initialize project".to_string()); + } + + let input_c = CString::new(input).map_err(|_| "invalid path (NUL byte)".to_string())?; + let footage = unsafe { crate::ffi::oakengine_project_import_footage(project, input_c.as_ptr()) }; + if footage.is_null() { + let err = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_footage_last_error(buf, size) + }); + unsafe { crate::ffi::oakengine_project_free(project) }; + return Err(err); + } + + let seq_name = CString::new("transcode").unwrap(); + let sequence = unsafe { crate::ffi::oakengine_sequence_new(project, seq_name.as_ptr()) }; + if sequence.is_null() { + unsafe { + crate::ffi::oakengine_footage_free(footage); + crate::ffi::oakengine_project_free(project); + } + return Err(seq_error("cannot create sequence")); + } + if unsafe { + crate::ffi::oakengine_sequence_set_video_params( + sequence, + out_w, + out_h, + fr_num, + fr_den, + 1, + 1, + 0, + crate::ffi::PIXEL_FORMAT_F32, + 0, + ) + } != crate::ffi::OAKENGINE_OK + { + unsafe { + crate::ffi::oakengine_footage_free(footage); + crate::ffi::oakengine_project_free(project); + } + return Err(seq_error("cannot set sequence video params")); + } + + let video_track = + unsafe { crate::ffi::oakengine_sequence_add_track(sequence, crate::ffi::OAKENGINE_TRACK_TYPE_VIDEO) }; + if video_track < 0 { + unsafe { + crate::ffi::oakengine_footage_free(footage); + crate::ffi::oakengine_project_free(project); + } + return Err(seq_error("cannot add video track")); + } + let clip = unsafe { + crate::ffi::oakengine_sequence_add_footage_clip_ex( + sequence, + footage, + crate::ffi::OAKENGINE_TRACK_TYPE_VIDEO, + video_track, + 0, + frames, + 0, + ) + }; + if clip.is_null() { + unsafe { + crate::ffi::oakengine_footage_free(footage); + crate::ffi::oakengine_project_free(project); + } + return Err(seq_error("cannot place video clip")); + } + + if audio_streams > 0 { + let audio_track = + unsafe { crate::ffi::oakengine_sequence_add_track(sequence, crate::ffi::OAKENGINE_TRACK_TYPE_AUDIO) }; + if audio_track < 0 { + unsafe { + crate::ffi::oakengine_footage_free(footage); + crate::ffi::oakengine_project_free(project); + } + return Err(seq_error("cannot add audio track")); + } + let clip = unsafe { + crate::ffi::oakengine_sequence_add_footage_clip_ex( + sequence, + footage, + crate::ffi::OAKENGINE_TRACK_TYPE_AUDIO, + audio_track, + 0, + frames, + 0, + ) + }; + if clip.is_null() { + unsafe { + crate::ffi::oakengine_footage_free(footage); + crate::ffi::oakengine_project_free(project); + } + return Err(seq_error("cannot place audio clip")); + } + } + + unsafe { + crate::ffi::oakengine_footage_free(footage); + } + Ok(Assembly { project, sequence }) +} + +/// `oakengine_sequence_last_error` (or the fallback when empty). +fn seq_error(fallback: &str) -> String { + let err = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_sequence_last_error(buf, size) + }); + if err.is_empty() { + fallback.to_string() + } else { + err + } +} + +/// `--format ppm`: render the frame range through the engine renderer +/// into PPM frames (+ the audio range into a WAV when the source has +/// audio). +fn transcode_ppm( + assembly: &Assembly, + out: &str, + out_w: i32, + out_h: i32, + fr_num: i32, + fr_den: i32, + frames: i64, + audio: bool, +) -> i32 { + if let Err(e) = std::fs::create_dir_all(out) { + eprintln!("error: transcode: cannot create output directory \"{out}\": {e}"); + return EXIT_ERROR; + } + let renderer = unsafe { + crate::ffi::oakengine_renderer_create( + assembly.sequence, + out_w, + out_h, + crate::ffi::PIXEL_FORMAT_F32, + fr_num, + fr_den, + std::ptr::null(), + ) + }; + if renderer.is_null() { + // The engine's create path returns NULL without setting the + // renderer's last error; report the contract message. + eprintln!("error: transcode: cannot create renderer"); + return EXIT_ERROR; + } + + for i in 0..frames { + let frame = unsafe { crate::ffi::oakengine_renderer_render_frame(renderer, i) }; + if frame.is_null() { + let err = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_renderer_last_error(renderer, buf, size) + }); + let msg = if err.is_empty() { + format!("frame {i} failed to render") + } else { + format!("frame {i} failed to render: {err}") + }; + eprintln!("error: transcode: {msg}"); + unsafe { crate::ffi::oakengine_renderer_free(renderer) }; + return EXIT_ERROR; + } + if let Err(msg) = unsafe { write_frame_ppm(frame, out, i) } { + eprintln!("error: transcode: {msg}"); + unsafe { + crate::ffi::oakengine_frame_free(frame); + crate::ffi::oakengine_renderer_free(renderer); + } + return EXIT_ERROR; + } + unsafe { crate::ffi::oakengine_frame_free(frame) }; + } + eprintln!("transcoded {frames} frames to \"{out}\""); + + // Audio range (the assembly only adds audio clips when the source + // has audio streams). + let mut code = EXIT_OK; + if audio { + code = write_audio_wav(renderer, out, frames); + } + unsafe { crate::ffi::oakengine_renderer_free(renderer) }; + code +} + +/// `--format mp4`: H.264/AAC through `oakengine_export_render` (the +/// exporter options mirror the C++ `cmd_transcode` defaults: codec- +/// default bit rates, source audio rate or 48 kHz, stereo). +fn transcode_mp4(assembly: &Assembly, out: &str, out_w: i32, out_h: i32, frames: i64) -> i32 { + let out_c = match CString::new(out) { + Ok(p) => p, + Err(_) => { + eprintln!("error: transcode: invalid output path (NUL byte)"); + return EXIT_ERROR; + } + }; + let opts = OakExportOptions { + video_codec: crate::ffi::OAKENGINE_EXPORT_VIDEO_H264, + audio_codec: crate::ffi::OAKENGINE_EXPORT_AUDIO_AAC, + video_bit_rate: 0, + audio_sample_rate: 48000, + audio_channel_count: 2, + }; + match unsafe { + crate::optional::export_render( + assembly.sequence, + out_c.as_ptr(), + 0, + frames, + out_w, + out_h, + &opts, + ) + } { + Some(rc) if rc == crate::ffi::OAKENGINE_OK => EXIT_OK, + Some(rc) => { + let err = unsafe { crate::optional::export_last_error() }; + let msg = if err.is_empty() { + format!("export failed ({rc})") + } else { + err + }; + eprintln!("error: transcode: {msg}"); + EXIT_ERROR + } + None => { + let err = unsafe { crate::optional::export_last_error() }; + eprintln!("error: transcode: {err}"); + EXIT_ERROR + } + } +} + +/// Write a rendered frame as `frame_%05d.ppm` in `out` (the +/// `oakengine_frame_*` accessors feed the [`crate::ppm`] writer). +/// +/// `oakengine_frame_channel_count` is not backed by the current engine +/// (returns 0); the render module's frames are always in the internal +/// RGBA layout (`VideoParams::k_internal_channel_count == 4`), so a +/// zero/negative channel report falls back to 4 channels. +unsafe fn write_frame_ppm(frame: *mut ffi::OakEngineFrame, out: &str, index: i64) -> Result<(), String> { + let width = unsafe { crate::ffi::oakengine_frame_width(frame) }; + let height = unsafe { crate::ffi::oakengine_frame_height(frame) }; + let format = unsafe { crate::ffi::oakengine_frame_format(frame) }; + let channels = unsafe { crate::ffi::oakengine_frame_channel_count(frame) }; + let channels = if channels > 0 { channels } else { 4 }; + let linesize = unsafe { crate::ffi::oakengine_frame_linesize_bytes(frame) }; + let data = unsafe { crate::ffi::oakengine_frame_data(frame) }; + if data.is_null() || width <= 0 || height <= 0 || linesize <= 0 { + return Err(format!( + "frame {index} has no pixel data ({}x{}, linesize {linesize})", + width, height + )); + } + let len = (linesize as usize) + .checked_mul(height as usize) + .ok_or_else(|| "frame buffer size overflow".to_string())?; + // SAFETY: the engine's frame buffer is valid for linesize * height + // bytes for the duration of this call. + let bytes = unsafe { std::slice::from_raw_parts(data as *const u8, len) }; + let path = Path::new(out).join(format!("frame_{index:05}.ppm")); + ppm::write_ppm(&path, width, height, format, channels, linesize, bytes) + .map_err(|e| format!("cannot write \"{}\": {e}", path.display())) +} + +/// Render the audio range and write it as `audio.wav` in `out`. The +/// assembled clips span `[0, frames)` sequence timestamps, so the range +/// length is `frames` time-base ticks. +fn write_audio_wav(renderer: *mut ffi::OakEngineRenderer, out: &str, frames: i64) -> i32 { + let audio = unsafe { crate::ffi::oakengine_renderer_render_audio(renderer, 0, frames) }; + if audio.is_null() { + eprintln!("error: transcode: audio render failed"); + return EXIT_ERROR; + } + let rate = unsafe { crate::ffi::oakengine_audio_sample_rate(audio) }; + let channels = unsafe { crate::ffi::oakengine_audio_channel_count(audio) }; + let samples = unsafe { crate::ffi::oakengine_audio_sample_count(audio) }; + let data = unsafe { crate::ffi::oakengine_audio_data(audio, 0) }; + let code = if data.is_null() || rate <= 0 || channels <= 0 || samples <= 0 { + eprintln!("error: transcode: audio buffer is empty"); + EXIT_ERROR + } else { + match (samples as usize).checked_mul(channels as usize) { + None => { + eprintln!("error: transcode: audio buffer size overflow"); + EXIT_ERROR + } + Some(len) => { + // SAFETY: the engine's audio buffer is valid for + // samples * channels floats for this call's duration. + let floats = unsafe { std::slice::from_raw_parts(data, len) }; + let path = Path::new(out).join("audio.wav"); + if let Err(e) = wav::write_wav(&path, rate, channels, samples, floats) { + eprintln!("error: transcode: cannot write \"{}\": {e}", path.display()); + EXIT_ERROR + } else { + EXIT_OK + } + } + } + }; + unsafe { crate::ffi::oakengine_audio_free(audio) }; + code +} + +/// Probe the source media through the C ABI into a [`SourceInfo`]. +/// +/// The engine's footage probe records the decoder but (currently) drops +/// the codec's stream descriptions, so a successful probe still reports +/// zero streams / unavailable stream info. The CLI then falls back to +/// the documented defaults: `[width]` or 1920, 16:9 height, 25 fps, +/// duration 0 (a single frame — the still-image contract). A failed +/// probe is a hard error. +fn probe_source(path: &str) -> Result { + let path_c = CString::new(path).map_err(|_| "invalid path (NUL byte)".to_string())?; + let footage = unsafe { crate::ffi::oakengine_footage_probe(path_c.as_ptr()) }; + if footage.is_null() { + let err = crate::ffi::string_get(|buf, size| unsafe { + crate::ffi::oakengine_footage_last_error(buf, size) + }); + return Err(err); + } + + let mut duration = 0.0f64; + let _ = unsafe { crate::ffi::oakengine_footage_get_duration(footage, &mut duration) }; + let audio_streams = unsafe { crate::ffi::oakengine_footage_get_audio_stream_count(footage) }.max(0); + + let video_streams = + unsafe { crate::ffi::oakengine_footage_get_video_stream_count(footage) }.max(0); + + let mut info = ffi::OakFootageVideoInfo { + stream_index: 0, + width: 0, + height: 0, + frame_rate_num: 0, + frame_rate_den: 0, + duration_ts: 0, + time_base_num: 0, + time_base_den: 0, + color_primaries: 0, + color_trc: 0, + interlaced: 0, + }; + let rc = if video_streams > 0 { + unsafe { crate::ffi::oakengine_footage_get_video_stream_info(footage, 0, &mut info) } + } else { + crate::ffi::OAKENGINE_E_NOT_FOUND + }; + unsafe { crate::ffi::oakengine_footage_free(footage) }; + + let src = if rc == crate::ffi::OAKENGINE_OK && info.width > 0 && info.height > 0 { + SourceInfo { + width: info.width, + height: info.height, + fr_num: info.frame_rate_num, + fr_den: info.frame_rate_den, + duration, + audio_streams, + } + } else { + // Engine gap fallback (see the docs above). + SourceInfo { + width: 1920, + height: 1080, + fr_num: 25, + fr_den: 1, + duration, + audio_streams, + } + }; + Ok(src) } diff --git a/crates/oak-cli/src/deferred.rs b/crates/oak-cli/src/deferred.rs deleted file mode 100644 index 5fcd0adad..000000000 --- a/crates/oak-cli/src/deferred.rs +++ /dev/null @@ -1,143 +0,0 @@ -// Oak Video Editor - Non-Linear Video Editor -// Copyright (C) 2026 Oak Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -//! Facade-family availability, mirroring `src/facade/rust/src/deferred.rs`. -//! -//! Every `oak-cli` subcommand depends on one or more families of the -//! `oakengine_*` C ABI. Those families live in the `oakengine` crate, and -//! some of them are **deferred**: the facade does not wrap them yet, so the -//! subcommands must report a clear "not yet available" error instead of -//! calling into the facade (the calls would not link, and faking behavior -//! would be worse). -//! -//! The entries below are kept field-for-field in sync with the facade's own -//! deferral documentation (`src/facade/rust/src/deferred.rs`). All families -//! this CLI consumes are currently deferred; when a family is wrapped, remove -//! its entry here and the subcommand's call-through (see `src/cmd/`) becomes -//! reachable. - -/// One deferred facade family: what it covers, which engine headers define -/// it, and why the facade does not wrap it yet. -pub struct DeferredFamily { - /// Short family name, as used in messages. - pub name: &'static str, - /// Engine headers involved. - pub headers: &'static str, - /// Why the family is not wrapped yet (from the facade's deferred.rs). - pub reason: &'static str, -} - -/// `init.h` — engine process initialization/shutdown. -/// -/// Not even listed in the facade's scope table yet (`src/facade/rust/README.md`): -/// the facade currently wraps only undo/config/video_params/audio/plugin. -pub const INIT: DeferredFamily = DeferredFamily { - name: "init", - headers: "init.h", - reason: "the facade shell (oakengine_init/shutdown) is not wrapped in oakengine yet (its scope table covers only undo/common/audio/plugin)", -}; - -/// `project.h` + `footage.h` — the oaknode module family. -/// -/// Facade deferred.rs "node": all 30 exports of the oaknode Rust crate are -/// `todo!()` bodies, so the engine project/footage families have no module -/// backing to wrap. -pub const NODE: DeferredFamily = DeferredFamily { - name: "node (project/footage)", - headers: "project.h, footage.h", - reason: "deferred: the oaknode crate is an unimplemented skeleton (every export is a todo!() body), so the project/footage families have no module backing", -}; - -/// `timeline.h` — sequence/track/clip family. -/// -/// Facade deferred.rs "timeline": the oaktimeline crate's exports reference -/// ~80 oaknode C ABI symbols the skeletal oaknode crate does not define, and -/// its test-stubs collide with the real oakundo crate in the facade test -/// link. -pub const TIMELINE: DeferredFamily = DeferredFamily { - name: "timeline", - headers: "timeline.h", - reason: "deferred: test linkage — the oaktimeline crate's exports reference oaknode C ABI symbols the skeletal oaknode crate does not define", -}; - -/// `renderer.h` — renderer/frame/audio-buffer family. -/// -/// Facade deferred.rs "render": no structural blocker; the engine renderer.h -/// family simply was not wrapped in the facade's current pass. -pub const RENDER: DeferredFamily = DeferredFamily { - name: "render", - headers: "renderer.h", - reason: "deferred for session scope: the engine renderer.h family is not wrapped in oakengine yet (no structural blocker)", -}; - -/// `exporter.h` — export/encode family. -/// -/// Facade deferred.rs: exporter is a "genuinely facade-only area" (the -/// liboakengine assembly layer) with no files in the oakengine crate. -pub const EXPORT: DeferredFamily = DeferredFamily { - name: "exporter", - headers: "exporter.h", - reason: "deferred: the exporter family is a facade-only assembly area with no Rust backing (src/facade/rust/src/deferred.rs)", -}; - -/// Check that every family in `families` is available in the facade. -/// -/// Returns `Ok(())` when all are wrapped (none is today); otherwise `Err` -/// carries the composed "not yet available" message naming each deferred -/// family and its reason, for the subcommands to print and exit on. -pub fn require(families: &[&DeferredFamily]) -> Result<(), String> { - if families.is_empty() { - return Ok(()); - } - let mut detail = String::new(); - for f in families { - detail.push_str(&format!("\n - {} ({}): {}", f.name, f.headers, f.reason)); - } - Err(format!( - "not yet available in the Rust facade (oakengine): these family(ies) are still deferred \ - (see src/facade/rust/src/deferred.rs):{detail}" - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_family_list_is_available() { - assert!(require(&[]).is_ok()); - } - - #[test] - fn deferred_family_lists_a_reason() { - let err = require(&[&INIT]).unwrap_err(); - assert!(err.contains("not yet available")); - assert!(err.contains("init")); - assert!(err.contains("oakengine")); - } - - #[test] - fn all_cli_families_are_currently_deferred() { - // Keeps this file honest: if any family the CLI depends on flips to - // available, the subcommand ports in src/cmd/ become reachable and - // the tests asserting "not yet available" must be revisited. - let all: [&[&DeferredFamily]; 5] = - [&[&INIT], &[&NODE], &[&TIMELINE], &[&RENDER], &[&EXPORT]]; - for families in all { - assert!(require(families).is_err()); - } - } -} diff --git a/crates/oak-cli/src/ffi.rs b/crates/oak-cli/src/ffi.rs index 2ca1e0551..6cd769647 100644 --- a/crates/oak-cli/src/ffi.rs +++ b/crates/oak-cli/src/ffi.rs @@ -14,37 +14,32 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! The `oakengine_*` C ABI surface oak-cli consumes — **declared, not yet +//! The `oakengine_*` C ABI surface oak-cli consumes — **declared and //! linked**. //! //! This module mirrors — verbatim — every function, opaque handle and POD //! struct from the engine headers that the C++ `cli/main.cpp` touches: //! -//! - `engine/include/oakengine/init.h` (oakengine_init / shutdown) //! - `engine/include/oakengine/project.h` (project lifecycle + queries) //! - `engine/include/oakengine/footage.h` (probe / stream info / import) //! - `engine/include/oakengine/timeline.h` (sequence + track/clip editing) -//! - `engine/include/oakengine/renderer.h` (renderer + frame + audio buffer) -//! - `engine/include/oakengine/exporter.h` (export options + render) +//! - `engine/include/oakengine/renderer.h` (render manager init, renderer, +//! frame + audio buffer) +//! - `engine/include/oakengine/videoparams.h` (sequence video params) //! -//! All of these families are **deferred** in the Rust facade crate -//! (`oakengine`, `src/facade/rust/src/deferred.rs`), so none of the symbols -//! below is referenced from this crate yet — the subcommands gate on -//! [`crate::deferred`] and report "not yet available" instead of calling -//! them. The declarations exist so that: +//! The `#[link(name = "oakengine", kind = "dylib")]` block resolves every +//! symbol against the built `liboakengine` cdylib at link time (the +//! search path comes from `build.rs`; see the Cargo.toml comment for the +//! build order). Five symbols from the original declaration surface are +//! NOT exported by the current Rust facade — `oakengine_init`, +//! `oakengine_shutdown` (init.h) and the exporter trio +//! `oakengine_export_render` / `oakengine_export_last_error` / +//! `oakengine_export_set_progress_callback` (exporter.h). Those live in +//! [`crate::optional`], which resolves them with `dlsym` at call time so +//! the prescribed call sequences keep working when the facade grows them. //! -//! 1. the exact contract the CLI expects is pinned in one place (types, -//! signatures, string conventions, error codes), and -//! 2. when a family is wrapped by oakengine, the call-through code in -//! `src/cmd/` resolves against the already-linked `oakengine` rlib -//! without any manifest or signature churn. -//! -//! Nothing here is ever called today, so no symbol needs to exist in the -//! facade yet; that keeps `cargo build` green standalone. -//! -//! `dead_code` is expected for this whole surface until the ports land: the -//! declarations, the POD structs and [`facade_string`] exist precisely to be -//! consumed by `src/cmd/` once the deferred families are wrapped. +//! The subcommands in `src/cmd/` call this surface directly — no deferral +//! gate, no module-crate calls. #![allow(dead_code)] #![allow(non_camel_case_types)] @@ -168,14 +163,12 @@ pub const OAKENGINE_EXPORT_VIDEO_H264: c_int = 0; pub const OAKENGINE_EXPORT_AUDIO_AAC: c_int = 0; // --------------------------------------------------------------------------- -// The facade surface (declarations only — see the module docs). +// The facade surface (the symbols the built liboakengine exports; see the +// module docs — the five missing ones live in crate::optional). // --------------------------------------------------------------------------- +#[link(name = "oakengine", kind = "dylib")] extern "C" { - // ---- init.h ---------------------------------------------------------- - pub fn oakengine_init(flags: c_int) -> c_int; - pub fn oakengine_shutdown() -> c_int; - // ---- project.h ------------------------------------------------------- pub fn oakengine_project_create() -> *mut OakEngineProject; pub fn oakengine_project_free(self_: *mut OakEngineProject); @@ -276,6 +269,18 @@ extern "C" { par_num: *mut c_int, par_den: *mut c_int, ) -> c_int; + pub fn oakengine_sequence_set_video_params( + self_: *mut OakEngineSequence, + width: c_int, + height: c_int, + fps_num: c_int, + fps_den: c_int, + par_num: c_int, + par_den: c_int, + interlacing: c_int, + format: c_int, + undoable: c_int, + ) -> c_int; pub fn oakengine_sequence_track_count( self_: *const OakEngineSequence, video: *mut c_int, @@ -300,8 +305,27 @@ extern "C" { out_ts: i64, media_in: i64, ) -> *mut OakEngineClip; + /// Like `oakengine_sequence_add_footage_clip` but skips the + /// same-project check: sequences created through `oakengine_sequence_new` + /// live in their own scratch project (documented engine deviation), so + /// the CLI's transcode flow (footage in the real project, sequence in + /// the scratch project) needs the `_ex` variant. + pub fn oakengine_sequence_add_footage_clip_ex( + seq: *mut OakEngineSequence, + footage: *mut OakEngineFootage, + track_type: c_int, + track_index: c_int, + in_ts: i64, + out_ts: i64, + media_in: i64, + ) -> *mut OakEngineClip; pub fn oakengine_sequence_last_error(buf: *mut c_char, buf_size: c_int) -> c_int; + // ---- renderer.h (render manager: the Rust facade's equivalent of the + // ---- C++ OAKENGINE_INIT_RENDER engine-core render boot) ------------- + pub fn oakengine_render_manager_init() -> c_int; + pub fn oakengine_render_manager_shutdown() -> c_int; + // ---- renderer.h ------------------------------------------------------ pub fn oakengine_renderer_create( seq: *mut OakEngineSequence, @@ -343,22 +367,6 @@ extern "C" { pub fn oakengine_audio_sample_count(self_: *const OakEngineAudioBuffer) -> i64; pub fn oakengine_audio_data(self_: *const OakEngineAudioBuffer, channel: c_int) -> *const f32; pub fn oakengine_audio_free(self_: *mut OakEngineAudioBuffer); - - // ---- exporter.h ------------------------------------------------------ - pub fn oakengine_export_render( - seq: *mut OakEngineSequence, - path: *const c_char, - in_ts: i64, - out_ts: i64, - width: c_int, - height: c_int, - opts: *const OakExportOptions, - ) -> c_int; - pub fn oakengine_export_last_error(buf: *mut c_char, buf_size: c_int) -> c_int; - pub fn oakengine_export_set_progress_callback( - f: OakEngineExportProgressFn, - userdata: *mut c_void, - ); } /// Read a facade string (buf/size convention) into an owned `String`, @@ -366,21 +374,16 @@ extern "C" { /// error/empty string, otherwise the getter is called twice (size query, /// then fill) and the trailing NUL is stripped. /// -/// # Safety -/// `getter` must be one of the `oakengine_*` string getters declared above -/// and `handle` a live handle for it. -pub unsafe fn facade_string( - getter: unsafe extern "C" fn(*const c_void, *mut c_char, c_int) -> c_int, - handle: *const c_void, -) -> String { - unsafe { - let size = getter(handle, std::ptr::null_mut(), 0); - if size < 0 { - return String::new(); - } - let mut s = vec![0u8; size as usize + 1]; - let n = getter(handle, s.as_mut_ptr() as *mut c_char, size + 1); - s.truncate(n.max(0) as usize); - String::from_utf8_lossy(&s).into_owned() +/// `fill` must be one of the `oakengine_*` string getters (handle +/// getters closed over their live handle, last-error getters applied +/// directly). +pub fn string_get(mut fill: impl FnMut(*mut c_char, c_int) -> c_int) -> String { + let size = fill(std::ptr::null_mut(), 0); + if size < 0 { + return String::new(); } + let mut s = vec![0u8; size as usize + 1]; + let n = fill(s.as_mut_ptr() as *mut c_char, size + 1); + s.truncate(n.max(0) as usize); + String::from_utf8_lossy(&s).into_owned() } diff --git a/crates/oak-cli/src/host.rs b/crates/oak-cli/src/host.rs new file mode 100644 index 000000000..94f43ea11 --- /dev/null +++ b/crates/oak-cli/src/host.rs @@ -0,0 +1,155 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Host-side `oakcore_audioparams_*` shims. +//! +//! The engine dylib links with `-undefined dynamic_lookup`, leaving a few +//! liboakcore symbols (`oakcore_audioparams_*`, used by +//! `oakengine_renderer_render_audio` and the sequence audio-params path) +//! to be resolved from the host process at runtime. The C++ `cli/main.cpp` +//! host linked liboakcore; the Rust CLI provides the same symbols itself — +//! the binary is the host. `build.rs` passes `-Wl,-export_dynamic` so the +//! linker exports them, and [`exports`] keeps them referenced. +//! +//! Semantics mirror the C++ `olive::core::AudioParams`: +//! `{sample_rate, channel_layout, format, time_base}` with the time base +//! defaulting to `1/sample_rate`, exactly like the liboakcore constructor +//! (`oakcore_audioparams.cpp`). + +use std::ffi::c_void; + +/// The liboakcore `AudioParams` payload (`oakcore_audioparams.h`). +struct AudioParams { + sample_rate: i32, + channel_layout: u64, + format: i32, + time_base_num: i32, + time_base_den: i32, +} + +/// `oakcore_audioparams_create` — allocate with `time_base = 1/sample_rate`. +/// +/// # Safety +/// None; returns an owned box cast to `void*` (NULL never happens for +/// valid inputs; a zero sample rate keeps the caller's contract intact). +#[no_mangle] +pub unsafe extern "C" fn oakcore_audioparams_create( + sample_rate: i32, + channel_layout: u64, + format: i32, +) -> *mut c_void { + let den = if sample_rate > 0 { sample_rate } else { 1 }; + Box::into_raw(Box::new(AudioParams { + sample_rate, + channel_layout, + format, + time_base_num: 1, + time_base_den: den, + })) as *mut c_void +} + +/// `oakcore_audioparams_free` — NULL no-op. +/// +/// # Safety +/// `params` must be a pointer from [`oakcore_audioparams_create`] or NULL. +#[no_mangle] +pub unsafe extern "C" fn oakcore_audioparams_free(params: *mut c_void) { + unsafe { + if !params.is_null() { + drop(Box::from_raw(params as *mut AudioParams)); + } + } +} + +/// `oakcore_audioparams_sample_rate` — 0 for NULL (liboakcore contract). +/// +/// # Safety +/// `params` must be a pointer from [`oakcore_audioparams_create`] or NULL. +#[no_mangle] +pub unsafe extern "C" fn oakcore_audioparams_sample_rate(params: *const c_void) -> i32 { + unsafe { + if params.is_null() { + 0 + } else { + (*(params as *const AudioParams)).sample_rate + } + } +} + +/// `oakcore_audioparams_channel_layout` — 0 for NULL. +/// +/// # Safety +/// `params` must be a pointer from [`oakcore_audioparams_create`] or NULL. +#[no_mangle] +pub unsafe extern "C" fn oakcore_audioparams_channel_layout(params: *const c_void) -> u64 { + unsafe { + if params.is_null() { + 0 + } else { + (*(params as *const AudioParams)).channel_layout + } + } +} + +/// `oakcore_audioparams_format` — 0 for NULL. +/// +/// # Safety +/// `params` must be a pointer from [`oakcore_audioparams_create`] or NULL. +#[no_mangle] +pub unsafe extern "C" fn oakcore_audioparams_format(params: *const c_void) -> i32 { + unsafe { + if params.is_null() { + 0 + } else { + (*(params as *const AudioParams)).format + } + } +} + +/// `oakcore_audioparams_set_time_base` — NULL no-op. +/// +/// # Safety +/// `params` must be a pointer from [`oakcore_audioparams_create`] or NULL. +#[no_mangle] +pub unsafe extern "C" fn oakcore_audioparams_set_time_base( + params: *mut c_void, + num: i32, + den: i32, +) { + unsafe { + if params.is_null() { + return; + } + let p = &mut *(params as *mut AudioParams); + p.time_base_num = num; + p.time_base_den = den; + } +} + +/// Keep-alive references so the linker never drops the host shims +/// (referenced from `main`; the `-export_dynamic` flag exports them for +/// the engine dylib's runtime lookups). +pub fn exports() -> usize { + let fns: [usize; 6] = [ + oakcore_audioparams_create as *const () as usize, + oakcore_audioparams_free as *const () as usize, + oakcore_audioparams_sample_rate as *const () as usize, + oakcore_audioparams_channel_layout as *const () as usize, + oakcore_audioparams_format as *const () as usize, + oakcore_audioparams_set_time_base as *const () as usize, + ]; + fns.iter().sum() +} diff --git a/crates/oak-cli/src/main.rs b/crates/oak-cli/src/main.rs index 7af048adc..0596b37f3 100644 --- a/crates/oak-cli/src/main.rs +++ b/crates/oak-cli/src/main.rs @@ -29,16 +29,25 @@ //! Exit codes: 0 success, 1 general error, 2 rendering unavailable, //! 64 usage error. //! -//! The facade families every subcommand depends on (init/project/timeline/ -//! render/footage/exporter) are still **deferred** in the `oakengine` crate -//! (see `src/facade/rust/src/deferred.rs`), so each subcommand validates its -//! arguments faithfully, then reports the deferral with its reason and exits -//! with the C++-compatible code — never crashing, never faking output. +//! This crate is a PURE C-ABI consumer of the built `liboakengine` +//! cdylib: every engine call goes through the `extern "C"` declarations +//! in [`ffi`] (linked via `build.rs` + `#[link(name = "oakengine", kind = +//! "dylib")]`) and the dlsym-resolved optional families in [`optional`]. +//! No module crate is ever called directly. [`host`] plays the C++ host +//! role the engine dylib expects: it provides the `oakcore_audioparams_*` +//! symbols the dylib resolves from the process at runtime. [`fmt`], +//! [`ppm`] and [`wav`] are pure-Rust formatting/writing helpers (exact +//! ports of the C++ `printf`/writers) — the only non-ABI code here. +//! +//! Build order: `cargo build -p oakengine` must run before linking this +//! binary (`cargo build`/`cargo test -p oak-cli`); `cargo check` never +//! links and works standalone. mod cmd; -mod deferred; mod ffi; mod fmt; +mod host; +mod optional; mod ppm; mod wav; @@ -76,7 +85,7 @@ Usage:\n\ Exit codes:\n\ 0 success\n\ 1 general error (bad project/media file, no sequence, I/O failure)\n\ - 2 rendering unavailable or failed (e.g. no GL render backend)\n\ + 2 rendering unavailable or failed (e.g. no render backend)\n\ 64 usage error\n"; /// CLI surface. `--help`/`-h` are handled before clap so the C++ usage text @@ -132,6 +141,10 @@ enum Command { } fn main() { + // Keep the host shims (src/host.rs) referenced so the linker + // exports them for the engine dylib's runtime lookups. + std::hint::black_box(host::exports()); + let args: Vec = std::env::args().skip(1).collect(); // argv[1] handling that mirrors the C++ main() exactly. diff --git a/crates/oak-cli/src/optional.rs b/crates/oak-cli/src/optional.rs new file mode 100644 index 000000000..4d6409942 --- /dev/null +++ b/crates/oak-cli/src/optional.rs @@ -0,0 +1,196 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Optional `oakengine_*` families, resolved from the loaded +//! `liboakengine` at call time. +//! +//! Two families of the frozen C ABI are not exported by the current Rust +//! facade and therefore cannot be declared in [`crate::ffi`]'s link block: +//! +//! - `init.h` — `oakengine_init` / `oakengine_shutdown`. The C++ engine +//! core (`EngineCore::instance()`) was removed with the C++ tree; the +//! Rust facade has no process-global init state, so nothing needs +//! initializing. The subcommands still go through the prescribed +//! `oakengine_init(OAKENGINE_INIT_*)` call sequence; when the symbol +//! is absent the call is a documented no-op returning +//! `OAKENGINE_OK`. (`OAKENGINE_INIT_RENDER` semantics are provided by +//! the real `oakengine_render_manager_init` export instead.) +//! - `exporter.h` — `oakengine_export_render` / +//! `oakengine_export_last_error` / +//! `oakengine_export_set_progress_callback`. The facade never wrapped +//! the exporter assembly layer (its `oakengine_export_render_with_params` +//! is an unbacked stub returning `OAKENGINE_E_FAILED`), so mp4 export +//! through the engine is not available yet. The transcode command +//! attempts the real call; when the family is absent it reports the +//! engine's export error (or a fixed explanation) and exits 1. +//! +//! Resolution uses `dlsym(RTLD_DEFAULT, ...)`: `liboakengine` is a direct +//! dependency of the binary, so its exports are in the global scope. Each +//! symbol is looked up once and cached; the lookup itself never fails the +//! build, so `cargo check`/`cargo build` stay green regardless of which +//! symbols the dylib currently carries. + +#![allow(dead_code)] +#![allow(clippy::missing_safety_doc)] + +use std::ffi::{c_char, c_double, c_int, c_void}; +use std::sync::OnceLock; + +use crate::ffi::{OakEngineSequence, OakExportOptions}; + +/// `oakengine_init` (init.h). +pub type InitFn = unsafe extern "C" fn(flags: c_int) -> c_int; +/// `oakengine_shutdown` (init.h). +pub type ShutdownFn = unsafe extern "C" fn() -> c_int; +/// `oakengine_export_render` (exporter.h). +pub type ExportRenderFn = unsafe extern "C" fn( + seq: *mut OakEngineSequence, + path: *const c_char, + in_ts: i64, + out_ts: i64, + width: c_int, + height: c_int, + opts: *const OakExportOptions, +) -> c_int; +/// `oakengine_export_last_error` (exporter.h). +pub type ExportLastErrorFn = unsafe extern "C" fn(buf: *mut c_char, buf_size: c_int) -> c_int; +/// `oakengine_export_set_progress_callback` (exporter.h). +pub type ExportSetProgressFn = unsafe extern "C" fn( + f: Option, + userdata: *mut c_void, +); + +#[cfg(unix)] +extern "C" { + fn dlsym(handle: *mut c_void, name: *const c_char) -> *mut c_void; +} + +#[cfg(target_os = "macos")] +const RTLD_DEFAULT: *mut c_void = -2isize as *mut c_void; +#[cfg(all(unix, not(target_os = "macos")))] +const RTLD_DEFAULT: *mut c_void = std::ptr::null_mut(); + +/// Resolve one facade symbol with `dlsym`. `None` when the loaded dylib +/// does not export it (or the platform has no dlsym). +#[cfg(unix)] +fn lookup(name: &str) -> Option { + let cname = std::ffi::CString::new(name).ok()?; + let ptr = unsafe { dlsym(RTLD_DEFAULT, cname.as_ptr()) }; + if ptr.is_null() { + return None; + } + // SAFETY: dlsym returns the address of a live function with the ABI + // `name` names; the cast pins its signature. + Some(unsafe { std::mem::transmute_copy::<*mut c_void, T>(&ptr) }) +} + +#[cfg(not(unix))] +fn lookup(_name: &str) -> Option { + None +} + +/// Cached `oakengine_init` resolution. +static INIT: OnceLock> = OnceLock::new(); +/// Cached `oakengine_shutdown` resolution. +static SHUTDOWN: OnceLock> = OnceLock::new(); +/// Cached `oakengine_export_render` resolution. +static EXPORT_RENDER: OnceLock> = OnceLock::new(); +/// Cached `oakengine_export_last_error` resolution. +static EXPORT_LAST_ERROR: OnceLock> = OnceLock::new(); +/// Cached `oakengine_export_set_progress_callback` resolution. +static EXPORT_SET_PROGRESS: OnceLock> = OnceLock::new(); + +/// `oakengine_init(flags)` — see the module docs for the absent-symbol +/// behavior. Returns `OAKENGINE_E_FAILED` when a present symbol reports +/// failure, `OAKENGINE_OK` otherwise. +/// +/// # Safety +/// The resolved function follows the engine init.h contract. +pub unsafe fn engine_init(flags: c_int) -> c_int { + let cell = INIT.get_or_init(|| lookup::("oakengine_init")); + match *cell { + Some(f) => unsafe { f(flags) }, + None => crate::ffi::OAKENGINE_OK, + } +} + +/// `oakengine_shutdown()` — no-op when the symbol is absent. +/// +/// # Safety +/// The resolved function follows the engine init.h contract. +pub unsafe fn engine_shutdown() -> c_int { + let cell = SHUTDOWN.get_or_init(|| lookup::("oakengine_shutdown")); + match *cell { + Some(f) => unsafe { f() }, + None => crate::ffi::OAKENGINE_OK, + } +} + +/// `oakengine_export_render(...)` through the resolved symbol. +/// +/// Returns `Some(rc)` when the exporter family is present (the engine +/// answer), `None` when the loaded dylib does not export it. +/// +/// # Safety +/// `seq`/`path`/`opts` must follow the engine exporter.h contract. +pub unsafe fn export_render( + seq: *mut OakEngineSequence, + path: *const c_char, + in_ts: i64, + out_ts: i64, + width: c_int, + height: c_int, + opts: *const OakExportOptions, +) -> Option { + let cell = EXPORT_RENDER.get_or_init(|| lookup::("oakengine_export_render")); + match *cell { + Some(f) => Some(unsafe { f(seq, path, in_ts, out_ts, width, height, opts) }), + None => None, + } +} + +/// `oakengine_export_last_error` through the resolved symbol; the fixed +/// explanation below when the family is absent. +/// +/// # Safety +/// The resolved function follows the engine exporter.h contract. +pub unsafe fn export_last_error() -> String { + let cell = + EXPORT_LAST_ERROR.get_or_init(|| lookup::("oakengine_export_last_error")); + match *cell { + Some(f) => crate::ffi::string_get(|buf, size| unsafe { f(buf, size) }), + None => "the exporter family (exporter.h) is not exported by the built liboakengine: \ + oakengine_export_render/oakengine_export_last_error are not wrapped (the facade's \ + oakengine_export_render_with_params is an unbacked stub)" + .to_string(), + } +} + +/// `oakengine_export_set_progress_callback` through the resolved symbol; +/// a no-op when absent (the CLI has no progress UI). +/// +/// # Safety +/// The resolved function follows the engine exporter.h contract. +pub unsafe fn export_set_progress_callback( + f: Option, + userdata: *mut c_void, +) { + let cell = EXPORT_SET_PROGRESS + .get_or_init(|| lookup::("oakengine_export_set_progress_callback")); + if let Some(fn_) = *cell { + unsafe { fn_(f, userdata) }; + } +} diff --git a/crates/oak-cli/tests/cli.rs b/crates/oak-cli/tests/cli.rs index 5c5228c98..bd57e4aef 100644 --- a/crates/oak-cli/tests/cli.rs +++ b/crates/oak-cli/tests/cli.rs @@ -16,20 +16,56 @@ //! End-to-end tests for the built `oak-cli` binary (the C++ ctest suite //! `oak_cli_info`/`oak_cli_render`/`oak_cli_probe`/`oak_cli_transcode` -//! equivalents, as far as the deferred facade allows). +//! equivalents). //! -//! All four subcommands depend on facade families that are still deferred in -//! oakfacade (see `src/deferred.rs`), so the data-producing paths assert the -//! documented "not yet available" behavior with the C++-compatible exit -//! codes; the argument-validation paths assert the exact C++ messages and -//! exit code 64. +//! The binary is a pure C-ABI consumer of `liboakengine` (see src/ffi.rs), +//! so these tests need the built dylib present: run `cargo build -p +//! oakengine` BEFORE `cargo test -p oak-cli` (the link happens at build +//! time, the load at runtime through the rpath build.rs installs). +//! +//! The data-producing paths run against the repo fixtures +//! (`tests/project_with_footage.ove`, `tests/demo.mp4` — real H.264/AAC +//! media, `tests/img.png`), the failure paths assert the documented exit +//! codes (0 success, 1 general error, 2 rendering unavailable, 64 usage +//! error), and the argument-validation paths assert the C++ messages. +//! +//! The engine's footage probe records the decoder id but drops the +//! codec's stream descriptions (oaknode module gap), so the probe output +//! carries real stream counts of 0 and a 0 duration until the module +//! fills them in — the assertions pin the real contract. +use std::path::{Path, PathBuf}; use std::process::Command; fn bin() -> &'static str { env!("CARGO_BIN_EXE_oak-cli") } +/// The fixture `.ove` file (relative to the workspace root). +fn fixture_project() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("tests") + .join("project_with_footage.ove") +} + +/// The real media fixture. +fn fixture_media() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("tests") + .join("demo.mp4") +} + +/// A single still-image fixture (1920x1080 RGBA PNG): one video stream, +/// no audio — fast end-to-end transcode coverage. +fn fixture_image() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("tests") + .join("img.png") +} + fn run(args: &[&str]) -> (i32, String, String) { let out = Command::new(bin()) .args(args) @@ -42,11 +78,47 @@ fn run(args: &[&str]) -> (i32, String, String) { ) } +/// stderr without dyld's objc class-duplication notices (the engine +/// dylib embeds FFmpeg's libavdevice, which collides with the host's on +/// macOS). +fn real_errors(stderr: &str) -> String { + stderr + .lines() + .filter(|l| !l.starts_with("objc[")) + .collect::>() + .join("\n") +} + +/// A scratch directory removed when the guard drops. +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "oak_cli_test_{tag}_{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + #[test] fn help_prints_the_cpp_usage_text_and_exits_zero() { let (code, stdout, stderr) = run(&["--help"]); assert_eq!(code, 0); - assert!(stderr.is_empty()); + // stderr may carry dyld's objc class-duplication notices only. + assert!( + !real_errors(&stderr).contains("error:"), + "stderr: {stderr}" + ); assert!(stdout.starts_with("oak-cli - headless consumer of the liboakengine C ABI\n")); assert!(stdout.contains("oak-cli transcode [width] [--format ppm|mp4]")); assert!(stdout.contains("Exit codes:")); @@ -67,21 +139,6 @@ fn unknown_command_is_a_usage_error() { assert!(stderr.contains("error: unknown command \"frobnicate\"")); } -#[test] -fn info_on_a_fixture_reports_not_yet_available() { - // The fixture mirrors the ctest invocation; the deferred gate fires - // before any file access. - let (code, _stdout, stderr) = run(&["info", "tests/project_with_footage.ove"]); - assert_eq!(code, 1); - assert!( - stderr.contains("error: info: not yet available"), - "stderr: {stderr}" - ); - // The crate was renamed oakfacade -> oakengine; the deferral reason - // names the current crate. - assert!(stderr.contains("oakengine")); -} - #[test] fn info_with_missing_argument_is_a_usage_error() { let (code, _stdout, stderr) = run(&["info"]); @@ -90,31 +147,65 @@ fn info_with_missing_argument_is_a_usage_error() { } #[test] -fn probe_reports_not_yet_available() { - let (code, _stdout, stderr) = run(&["probe", "tests/demo.mp4"]); +fn info_on_a_missing_project_is_an_error() { + let (code, _stdout, stderr) = run(&["info", "no-such-project.ove"]); assert_eq!(code, 1); assert!( - stderr.contains("error: probe: not yet available"), + real_errors(&stderr).contains("error: info:"), "stderr: {stderr}" ); } #[test] -fn render_reports_render_unavailable() { - let (code, _stdout, stderr) = run(&["render", "p.ove", "0", "1", "out"]); - assert_eq!(code, 2); +fn info_on_the_fixture_prints_the_project() { + let project = fixture_project(); + let (code, stdout, stderr) = run(&["info", project.to_str().unwrap()]); + assert_eq!(code, 0, "stderr: {stderr}"); + assert!(stdout.contains("Project: project_with_footage"), "{stdout}"); + assert!(stdout.contains("Modified: no"), "{stdout}"); + assert!(stdout.contains("Sequences: 1"), "{stdout}"); + assert!(stdout.contains("[0] \"Fixture Sequence\""), "{stdout}"); + assert!(stdout.contains("frame rate: 30/1 (30.000 fps)"), "{stdout}"); + assert!(stdout.contains("Footage: 1"), "{stdout}"); + // The C++ fixture stores the footage path relative to the project; + // the CLI resolves it against the project directory and reports it + // online. + assert!(stdout.contains("demo.mp4"), "{stdout}"); + assert!(stdout.contains("online"), "{stdout}"); +} + +#[test] +fn probe_on_a_missing_file_is_an_error() { + let (code, stdout, stderr) = run(&["probe", "no-such-file.mp4"]); + assert_eq!(code, 1); + assert!(stdout.is_empty()); assert!( - stderr.contains("error: render: not yet available"), + real_errors(&stderr).contains("error: probe: file does not exist: no-such-file.mp4"), "stderr: {stderr}" ); } +#[test] +fn probe_on_the_media_fixture_prints_streams() { + let media = fixture_media(); + let (code, stdout, stderr) = run(&["probe", media.to_str().unwrap()]); + assert_eq!(code, 0, "stderr: {stderr}"); + // tests/demo.mp4 through the engine: the probe records the ffmpeg + // decoder; the stream descriptions are dropped by the module today, + // so the counts/duration report 0 — the pinned engine contract. + assert!(stdout.contains("Decoder: ffmpeg"), "{stdout}"); + assert!(stdout.contains("Duration: 0.000000 s"), "{stdout}"); + assert!(stdout.contains("Video streams: 0"), "{stdout}"); + assert!(stdout.contains("Audio streams: 0"), "{stdout}"); + assert!(stdout.contains("Subtitle streams: 0"), "{stdout}"); +} + #[test] fn render_bad_seconds_is_a_usage_error() { let (code, _stdout, stderr) = run(&["render", "p.ove", "abc", "1", "out"]); assert_eq!(code, 64); assert!( - stderr.contains("error: invalid start seconds \"abc\""), + real_errors(&stderr).contains("error: invalid start seconds \"abc\""), "stderr: {stderr}" ); } @@ -124,27 +215,45 @@ fn render_end_not_after_start_is_a_usage_error() { let (code, _stdout, stderr) = run(&["render", "p.ove", "2", "1", "out"]); assert_eq!(code, 64); assert!( - stderr.contains("error: invalid end seconds \"1\""), + real_errors(&stderr).contains("error: invalid end seconds \"1\""), "stderr: {stderr}" ); } #[test] -fn transcode_reports_render_unavailable() { - let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "960"]); - assert_eq!(code, 2); +fn render_on_a_missing_project_is_an_error() { + let (code, _stdout, stderr) = run(&["render", "no-such.ove", "0", "1", "out"]); + assert_eq!(code, 1); assert!( - stderr.contains("error: transcode: not yet available"), + real_errors(&stderr).contains("error: render:"), "stderr: {stderr}" ); } +#[test] +fn render_the_fixture_writes_ppm_frames_and_a_wav() { + let dir = TempDir::new("render"); + let project = fixture_project(); + let out = dir.0.to_str().unwrap(); + let (code, _stdout, stderr) = run(&["render", project.to_str().unwrap(), "0", "0.1", out]); + assert_eq!(code, 0, "stderr: {stderr}"); + // The engine reports the sequence rate as 30/1: frames at 0, 1/30 + // and 2/30 before 0.1 s. + assert!(dir.0.join("frame_00000.ppm").is_file()); + assert!(dir.0.join("frame_00001.ppm").is_file()); + assert!(dir.0.join("frame_00002.ppm").is_file()); + assert!(dir.0.join("audio.wav").is_file()); + // PPM header of the first frame (P6). + let first = std::fs::read(dir.0.join("frame_00000.ppm")).unwrap(); + assert!(first.starts_with(b"P6\n"), "PPM header"); +} + #[test] fn transcode_bad_width_is_a_usage_error() { let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "banana"]); assert_eq!(code, 64); assert!( - stderr.contains("error: invalid width \"banana\""), + real_errors(&stderr).contains("error: invalid width \"banana\""), "stderr: {stderr}" ); } @@ -154,7 +263,7 @@ fn transcode_nonpositive_width_is_a_usage_error() { let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "0"]); assert_eq!(code, 64); assert!( - stderr.contains("error: invalid width \"0\""), + real_errors(&stderr).contains("error: invalid width \"0\""), "stderr: {stderr}" ); } @@ -164,17 +273,58 @@ fn transcode_unknown_format_is_a_usage_error() { let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "out.mp4", "--format", "webm"]); assert_eq!(code, 64); assert!( - stderr.contains("error: unknown --format \"webm\" (ppm|mp4)"), + real_errors(&stderr).contains("error: unknown --format \"webm\" (ppm|mp4)"), "stderr: {stderr}" ); } #[test] -fn transcode_ppm_format_is_accepted_then_reports_not_available() { - let (code, _stdout, stderr) = run(&["transcode", "in.mp4", "outdir", "960", "--format", "ppm"]); - assert_eq!(code, 2); +fn transcode_on_a_missing_input_is_an_error() { + let (code, _stdout, stderr) = run(&["transcode", "no-such.mp4", "out.mp4"]); + assert_eq!(code, 1); assert!( - stderr.contains("error: transcode: not yet available"), + real_errors(&stderr).contains("error: transcode: file does not exist: no-such.mp4"), "stderr: {stderr}" ); } + +#[test] +fn transcode_the_image_fixture_to_ppm_frames() { + let dir = TempDir::new("transcode_ppm"); + let image = fixture_image(); + let out = dir.0.to_str().unwrap(); + let (code, _stdout, stderr) = run(&[ + "transcode", + image.to_str().unwrap(), + out, + "160", + "--format", + "ppm", + ]); + assert_eq!(code, 0, "stderr: {stderr}"); + // A still image counts as one frame. + assert!(dir.0.join("frame_00000.ppm").is_file()); + assert!(!dir.0.join("frame_00001.ppm").exists()); + let first = std::fs::read(dir.0.join("frame_00000.ppm")).unwrap(); + assert!(first.starts_with(b"P6\n160 90\n255\n"), "160x90 P6 header"); + // No audio stream in the fixture: no WAV. + assert!(!dir.0.join("audio.wav").exists()); +} + +#[test] +fn transcode_mp4_reports_the_unwrapped_exporter_family() { + // The facade wraps the exporter family (oakengine_export_render), + // so the mp4 path renders and writes a real file. + let dir = TempDir::new("transcode_mp4"); + let image = fixture_image(); + let out = dir.0.join("out.mp4"); + let (code, _stdout, stderr) = run(&[ + "transcode", + image.to_str().unwrap(), + out.to_str().unwrap(), + "160", + ]); + assert_eq!(code, 0, "stderr: {stderr}"); + let head = std::fs::read(&out).expect("mp4 written"); + assert_eq!(&head[4..8], b"ftyp", "mp4 container"); +} diff --git a/crates/oak-worker/Cargo.lock b/crates/oak-worker/Cargo.lock deleted file mode 100644 index a7c52ae5d..000000000 --- a/crates/oak-worker/Cargo.lock +++ /dev/null @@ -1,1857 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "ash" -version = "0.38.0+1.3.281" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" -dependencies = [ - "libloading", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.13.1", - "cexpr", - "clang-sys", - "itertools", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.3", - "shlex 1.3.0", - "syn 2.0.119", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -dependencies = [ - "serde_core", -] - -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "cc" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" -dependencies = [ - "find-msvc-tools", - "shlex 2.0.1", -] - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "clang-sys" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" -dependencies = [ - "glob", - "libc", - "libloading", -] - -[[package]] -name = "clap" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "codespan-reporting" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" -dependencies = [ - "serde", - "termcolor", - "unicode-width", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "either" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - -[[package]] -name = "ffmpeg-next" -version = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a" -dependencies = [ - "bitflags 2.13.1", - "ffmpeg-sys-next", - "libc", -] - -[[package]] -name = "ffmpeg-sys-next" -version = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b" -dependencies = [ - "bindgen", - "cc", - "libc", - "num_cpus", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "glow" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gpu-alloc" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" -dependencies = [ - "bitflags 2.13.1", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "gpu-allocator" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" -dependencies = [ - "log", - "presser", - "thiserror 1.0.69", - "windows", -] - -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.13.1", - "gpu-descriptor-types", - "hashbrown 0.15.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "num-traits", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hexf-parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "tiff", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "js-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "khronos-egl" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "libloading", - "pkg-config", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "metal" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" -dependencies = [ - "bitflags 2.13.1", - "block", - "core-graphics-types", - "foreign-types", - "log", - "objc", - "paste", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "naga" -version = "25.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" -dependencies = [ - "arrayvec", - "bit-set", - "bitflags 2.13.1", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.15.5", - "hexf-parse", - "indexmap", - "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "spirv", - "strum", - "thiserror 2.0.20", - "unicode-ident", -] - -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "oak-worker" -version = "0.1.0" -dependencies = [ - "clap", - "oakengine", - "oakrender", - "serde", - "serde_json", -] - -[[package]] -name = "oakaudio" -version = "0.1.0" -dependencies = [ - "oakcore-rs", -] - -[[package]] -name = "oakcodec" -version = "0.1.0" -dependencies = [ - "ffmpeg-next", - "oakcore-rs", -] - -[[package]] -name = "oakcommon" -version = "0.1.0" -dependencies = [ - "image", - "log", - "oakcore-rs", - "ocio-rs", - "quick-xml", -] - -[[package]] -name = "oakcore-rs" -version = "0.1.0" - -[[package]] -name = "oakengine" -version = "0.1.0" -dependencies = [ - "libc", - "oakaudio", - "oakcodec", - "oakcommon", - "oakcore-rs", - "oaknode", - "oakplugin", - "oakrender", - "oaktask", - "oaktimeline", - "oakundo", - "serde", - "serde_json", -] - -[[package]] -name = "oaknode" -version = "0.1.0" -dependencies = [ - "oakcore-rs", -] - -[[package]] -name = "oakotio" -version = "0.1.0" -dependencies = [ - "oakcore-rs", - "quick-xml", - "serde", - "serde_json", -] - -[[package]] -name = "oakplugin" -version = "0.1.0" -dependencies = [ - "cc", -] - -[[package]] -name = "oakrender" -version = "0.1.0" -dependencies = [ - "oakcore-rs", - "ocio-rs", - "wgpu", -] - -[[package]] -name = "oaktask" -version = "0.1.0" -dependencies = [ - "oakcore-rs", - "oakotio", -] - -[[package]] -name = "oaktimeline" -version = "0.1.0" -dependencies = [ - "oakcore-rs", -] - -[[package]] -name = "oakundo" -version = "0.1.0" -dependencies = [ - "oakcore-rs", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - -[[package]] -name = "ocio-rs" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3492534019b59e29dba06014f907dd12824537ed4d293d4108c4bfc669de7fd" -dependencies = [ - "ocio-sys", - "thiserror 1.0.69", -] - -[[package]] -name = "ocio-sys" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e63251d72d848de5eda39d59cd6490260cf031738ebd518ea37d76b5aae614ec" -dependencies = [ - "cc", - "cmake", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "portable-atomic" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" - -[[package]] -name = "presser" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" - -[[package]] -name = "pxfm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "range-alloc" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "indexmap", - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.119", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "wgpu" -version = "25.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" -dependencies = [ - "arrayvec", - "bitflags 2.13.1", - "cfg_aliases", - "document-features", - "hashbrown 0.15.5", - "js-sys", - "log", - "naga", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "25.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7b882196f8368511d613c6aeec80655160db6646aebddf8328879a88d54e500" -dependencies = [ - "arrayvec", - "bit-set", - "bit-vec", - "bitflags 2.13.1", - "cfg_aliases", - "document-features", - "hashbrown 0.15.5", - "indexmap", - "log", - "naga", - "once_cell", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 2.0.20", - "wgpu-core-deps-apple", - "wgpu-core-deps-emscripten", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core-deps-apple" -version = "25.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd488b3239b6b7b185c3b045c39ca6bf8af34467a4c5de4e0b1a564135d093d" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-emscripten" -version = "25.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09ad7aceb3818e52539acc679f049d3475775586f3f4e311c30165cf2c00445" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "25.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba5fb5f7f9c98baa7c889d444f63ace25574833df56f5b817985f641af58e46" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-hal" -version = "25.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f968767fe4d3d33747bbd1473ccd55bf0f6451f55d733b5597e67b5deab4ad17" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set", - "bitflags 2.13.1", - "block", - "bytemuck", - "cfg-if", - "cfg_aliases", - "core-graphics-types", - "glow", - "glutin_wgl_sys", - "gpu-alloc", - "gpu-allocator", - "gpu-descriptor", - "hashbrown 0.15.5", - "js-sys", - "khronos-egl", - "libc", - "libloading", - "log", - "metal", - "naga", - "ndk-sys", - "objc", - "ordered-float", - "parking_lot", - "portable-atomic", - "profiling", - "range-alloc", - "raw-window-handle", - "renderdoc-sys", - "smallvec", - "thiserror 2.0.20", - "wasm-bindgen", - "web-sys", - "wgpu-types", - "windows", - "windows-core", -] - -[[package]] -name = "wgpu-types" -version = "25.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aa49460c2a8ee8edba3fca54325540d904dd85b2e086ada762767e17d06e8bc" -dependencies = [ - "bitflags 2.13.1", - "bytemuck", - "js-sys", - "log", - "thiserror 2.0.20", - "web-sys", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core", - "windows-targets", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-result", - "windows-strings", - "windows-targets", -] - -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result", - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "xml-rs" -version = "0.8.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" - -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zune-core" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] diff --git a/crates/oak-worker/Cargo.toml b/crates/oak-worker/Cargo.toml index c42438182..3e7167eae 100644 --- a/crates/oak-worker/Cargo.toml +++ b/crates/oak-worker/Cargo.toml @@ -26,22 +26,11 @@ name = "oak-worker" path = "src/main.rs" [dependencies] -clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -# The liboakengine facade (Rust): its worker module owns the whole worker -# runtime — render backend selection through the oakrender module C ABI, -# the startup handshake and the NDJSON control loop -# (`oakengine::worker::worker_main`, the port of engine/src/capi/worker.cpp). -# This binary is a thin shell over it, like worker/workermain.cpp. Its ipc -# module provides the real shared-memory frame-slot transport -# (`oakengine::ipc`) that src/session.rs + src/transport.rs attach through. -oakengine = { path = "../oakengine" } - -# The oakrender module crate (the Rust rewrite of the oakrender module): -# its C ABI (include/render/renderer.h) is how the facade's worker -# initializes the render backend — oakrender_display_renderer_create_dynamic() -# /_init() — the Rust equivalent of the C++ worker's DynamicRenderer/ -# OpenGLRenderer. Linked here so those facade imports resolve in the binary. -oakrender = { path = "../oakrender" } +# No oakengine cargo dependency: the worker links the built liboakengine +# dylib through build.rs (link-search + rpath) and the +# `#[link(name = "oakengine", kind = "dylib")]` extern block in +# src/engine_ipc.rs. Every call into editor functionality goes through the +# oakengine_* C ABI declared there. diff --git a/crates/oak-worker/README.md b/crates/oak-worker/README.md index 9f49fec92..3b41132f3 100644 --- a/crates/oak-worker/README.md +++ b/crates/oak-worker/README.md @@ -8,27 +8,29 @@ Headless render worker process — the Rust rewrite of `worker/workermain.cpp` ```sh cargo build --release # binary: target/release/oak-worker -cargo test # unit + integration tests (29 tests) +cargo test # unit + integration tests ``` -The worker is a **thin shell over the facade**, exactly like the C++ -`worker/workermain.cpp` is a thin shell over `liboakengine`: +The worker is **self-contained** (single-lib unification): the engine's +frozen C++ ABI does not include the worker/IPC families, so the whole +runtime is compiled into this binary and links the module crates directly +— no `liboakengine` dylib is needed at build or run time. -- `oakfacade::worker::worker_main` (the port of - `engine/src/capi/worker.cpp` `oakengine_worker_main()`) owns the whole - runtime: render backend selection through the oakrender module C ABI - (dynamic → OpenGL fallback), the startup handshake and the NDJSON - control loop. `src/main.rs` only parses `--backend` (clap) and forwards. -- `oakfacade::ipc` owns the shared-memory frame-slot transport (the real - `SpscRingBuffer` + `FrameSlotPool` over POSIX `shm_open`/`mmap`); - `src/transport.rs` attaches through it. +- `src/worker.rs` is the port of `engine/src/capi/worker.cpp` + `oakengine_worker_main()` and owns the whole runtime: render backend + selection through the oakrender crate's direct Rust API (dynamic → + OpenGL fallback), the startup handshake and the NDJSON control loop. + `src/main.rs` only parses `--backend` (clap) and forwards. +- `src/ipc.rs` owns the shared-memory frame-slot transport (the real + `SpscRingBuffer` + `FrameSlotPool` over POSIX `shm_open`/`mmap`) and the + NDJSON control-plane message structs; `src/transport.rs` attaches + through it. -The oakrender module crate (`../oakrender`) is linked so the -facade's renderer imports resolve; oakrender depends on `ocio-rs` with the -`bundled` feature, whose first-time build fetches a vendored OpenColorIO -dependency (`sse2neon`) from github.com. On networks without github access, -build with a shared target directory that already contains a completed -oakrender build tree, e.g.: +The oakrender module crate (`../oakrender`) is a plain Rust dependency; +it depends on `ocio-rs` with the `bundled` feature, whose first-time build +fetches a vendored OpenColorIO dependency (`sse2neon`) from github.com. On +networks without github access, build with a shared target directory that +already contains a completed oakrender build tree, e.g.: ```sh CARGO_TARGET_DIR=/path/to/oak/crates/oakrender/target cargo build --release @@ -40,18 +42,18 @@ Same flow as the C++ main, in the same order: 1. **parse `--backend `** (clap; default `opengl`; `none` skips renderer creation and the process exits 1, like the C++ main). -2. **initialize the render backend** (inside `oakfacade::worker`): the - oakrender module C ABI `oakrender_display_renderer_create_dynamic` + - `_init`, falling back to the direct OpenGL renderer exactly like the - C++ `create_renderer()` fallback chain. +2. **initialize the render backend** (inside `src/worker.rs`): the + oakrender `DisplayRenderer` direct Rust API, falling back to the direct + OpenGL renderer exactly like the C++ `create_renderer()` fallback + chain. 3. **write the startup handshake** (protocol version 1, empty shared-memory geometry — same as the C++ worker's startup handshake; the parent creates the segments and announces their geometry in its reply). 4. **serve the NDJSON control loop** on stdin/stdout until a `shutdown` message or EOF: `handshake` attaches the announced shared-memory frame-slot pools through the real transport; `load_graph` / - `render_frame` / `cancel` / `shutdown` are dispatched by the facade - session. Responses are one compact JSON line per message. + `render_frame` / `cancel` / `shutdown` are dispatched by the session. + Responses are one compact JSON line per message. ## Implemented vs stubbed (nothing is faked) @@ -59,7 +61,7 @@ Same flow as the C++ main, in the same order: renderer, dynamic → OpenGL fallback), startup handshake, NDJSON framing, message validation (protocol version, handshake geometry, `load_graph` file existence/size — the same messages the C++ worker emits), the -**shared-memory frame-slot transport** (`oakfacade::ipc` — POSIX +**shared-memory frame-slot transport** (`src/ipc.rs` — POSIX `shm_open`/`mmap`/`munmap`/`shm_unlink`, the SPSC ring buffer and the frame-slot pool with the exact version-1 shared layout; a `handshake` genuinely attaches the output and input pools), unknown-type/ @@ -79,27 +81,31 @@ non-existent/empty file produces the C++-identical error before reaching the stub. **Deviation from the C++:** the startup handshake omits `gl_major`/ -`gl_minor` — the oakrender module C ABI exposes no GL context version (the -C++ worker reads them off its `QOpenGLContext`). +`gl_minor` — the oakrender module exposes no GL context version (the C++ +worker reads them off its `QOpenGLContext`). ## Layout ``` src/ - main.rs clap entry; thin shell forwarding to oakfacade::worker + main.rs clap entry; thin shell forwarding to worker::worker_main (renderer init, handshake, NDJSON loop all live there) - ipc.rs control-plane message structs + NDJSON framing (serde) + worker.rs the real worker runtime: backend selection (oakrender + DisplayRenderer), WorkerSession, handshake + NDJSON loop + ipc.rs control-plane message structs + NDJSON framing (serde), + AND the real shared-memory frame-slot transport + (SpscRingBuffer + FrameSlotPool over POSIX shm) session.rs in-process session mirror (message dispatch + real shm handshake attach), exercised by the unit tests - transport.rs real shared-memory frame-slot transport over oakfacade::ipc + transport.rs shared-memory frame-slot transport over crate::ipc tests/worker.rs binary-level tests (help, clap errors, --backend none exit 1) ``` -The NDJSON control-loop behavior is exercised in-process in `src/session.rs` -against the facade's real shared memory (no GPU needed via `--backend none` -sessions); a binary-level loop test would require a working GPU backend and -is deliberately not part of the unit suite. Run the binary against a -created segment to see the real attach path: +The NDJSON control-loop behavior is exercised in-process in `src/worker.rs` +and `src/session.rs` against the local real shared memory (no GPU needed +via `--backend none` sessions); a binary-level loop test would require a +working GPU backend and is deliberately not part of the unit suite. Run +the binary against a created segment to see the real attach path: ```sh target/release/oak-worker --backend opengl <<< '{"type":"shutdown"}' diff --git a/crates/oak-worker/build.rs b/crates/oak-worker/build.rs index 96f63152e..7fc7d3840 100644 --- a/crates/oak-worker/build.rs +++ b/crates/oak-worker/build.rs @@ -16,18 +16,25 @@ //! Link configuration for the `oak-worker` binary. //! -//! The facade rlib (src/engine/rust) links the module C ABIs into any -//! consumer that pulls its codec surface — oak-worker's use of -//! `oakengine::worker` transitively pulls the facade's codec module, whose -//! oakcodec references carry a few C++-host imports (`oakcore_audioparams_*` -//! from liboakcore, `fb_*` from ffmpeg_bridge). Those live in the host Oak -//! process and are only reachable on media-decode paths this worker never -//! exercises; the CMake worker has the same property through the -//! liboakengine dylib (whose build.rs allows runtime lookups). Mirror that -//! here so the standalone Rust worker binary links. +//! The worker is a pure C-ABI consumer of the built `liboakengine` dylib +//! (crates/oakengine, crate-type cdylib): src/engine_ipc.rs declares the +//! `oakengine_*` symbols with `#[link(name = "oakengine", kind = "dylib")]`. +//! This build script points the linker at the target profile directory +//! that holds the dylib (OUT_DIR is +//! `//build/oak-worker-/out`, so the profile dir is +//! the third ancestor — where cargo places `liboakengine.dylib` / +//! `liboakengine.so`) and embeds an rpath so the binary finds the dylib at +//! runtime without environment variables. + +use std::path::Path; fn main() { - if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { - println!("cargo:rustc-link-arg=-Wl,-undefined,dynamic_lookup"); - } + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set by cargo"); + let profile_dir = Path::new(&out_dir) + .ancestors() + .nth(3) + .expect("OUT_DIR is nested at least 3 levels under the profile dir"); + + println!("cargo:rustc-link-search=native={}", profile_dir.display()); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", profile_dir.display()); } diff --git a/crates/oak-worker/src/engine_ipc.rs b/crates/oak-worker/src/engine_ipc.rs new file mode 100644 index 000000000..1f1fb6174 --- /dev/null +++ b/crates/oak-worker/src/engine_ipc.rs @@ -0,0 +1,652 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The `liboakengine` C-ABI surface, as consumed by the worker binary. +//! +//! The engine facade (crates/oakengine) is the cdylib and the ONLY +//! implementation owner of the worker/IPC runtime; this crate is a pure +//! C-ABI consumer. Everything here goes through the `extern "C"` +//! declarations of the `oakengine_*` symbols below — never a direct Rust +//! call into an oak* module crate. +//! +//! The module provides three layers: +//! +//! - **The externs.** `#[link(name = "oakengine", kind = "dylib")]` +//! declarations of the frozen `oakengine_worker_*` and +//! `oakengine_ipc_*` exports (`engine/include/oakengine/worker.h` and +//! `ipc.h`), plus the opaque `Oak*` handle mirrors and the POD +//! [`FrameSlotMeta`] / [`ShmMode`] mirrors the signatures reference. +//! build.rs points the linker at the built dylib and embeds its rpath. +//! - **In-process wrapper types.** [`SharedMemoryRegion`] and +//! [`FrameSlotPool`] wrap the opaque handles with `Drop` and a safe +//! surface, so [`crate::transport`]/[`crate::session`] (and their +//! tests) can attach real shared-memory pools through the engine. +//! - **The wire protocol.** [`HandshakeMsg`]/[`RenderFrameMsg`]/ +//! [`LoadGraphMsg`] + [`error_message`] + the `TYPE_*` constants — +//! serde-only structs matching the engine's NDJSON control plane; the +//! worker-side session mirror validates and builds these lines +//! locally. + +#![allow(dead_code)] + +use std::ffi::{c_char, c_int, c_void}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +// --------------------------------------------------------------------------- +// Opaque handle mirrors (engine/include/oakengine/{worker,ipc}.h) +// --------------------------------------------------------------------------- + +/// Opaque `OakWorkerSession` handle (worker.h). The engine owns the box; +/// this crate only ever sees the pointer. +#[repr(C)] +pub struct OakWorkerSession { + _opaque: [u8; 0], +} + +/// Opaque `OakSharedMemoryRegion` handle (ipc.h). +#[repr(C)] +pub struct OakSharedMemoryRegion { + _opaque: [u8; 0], +} + +/// Opaque `OakFrameSlotPool` handle (ipc.h). +#[repr(C)] +pub struct OakFrameSlotPool { + _opaque: [u8; 0], +} + +/// `OAK_IPC_SHM_KEY_CAP` — capacity of shm key strings (ipc.h), incl. NUL. +pub const OAK_IPC_SHM_KEY_CAP: usize = 128; +/// `OAK_IPC_COLORSPACE_CAP` — capacity of `oak_frame_slot_meta::colorspace`. +pub const OAK_IPC_COLORSPACE_CAP: usize = 128; + +/// Per-slot metadata describing the frame currently occupying a slot — +/// field-for-field `oak_frame_slot_meta` from `engine/include/oakengine/ipc.h` +/// (the POD lives in shared memory; the layout is the version-1 wire +/// protocol). +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FrameSlotMeta { + /// Caller-defined tag (ticket id, or footage stream hash). + pub id: i64, + /// Frame timestamp numerator. + pub time_num: i64, + /// Frame timestamp denominator. + pub time_den: i64, + /// Frame width. + pub width: i32, + /// Frame height. + pub height: i32, + /// `PixelFormat::Format` value. + pub format: i32, + /// Channel count. + pub channel_count: i32, + /// Bytes per scanline (stride). + pub linesize: i32, + /// Valid bytes written into the slot's data block. + pub data_size: i32, + /// Input colorspace name. + pub colorspace: [c_char; OAK_IPC_COLORSPACE_CAP], +} + +/// `OAK_IPC_SHM_MODE_CREATE` / `OAK_IPC_SHM_MODE_ATTACH` (ipc.h). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShmMode { + /// Create (and own) the segment. Fails if it already exists; the owner + /// unlinks it on close. + Create, + /// Attach to a segment created by the peer. Does not unlink on close. + Attach, +} + +// --------------------------------------------------------------------------- +// C ABI exports (engine/include/oakengine/{worker,ipc}.h) +// --------------------------------------------------------------------------- + +// The built `liboakengine` dylib (crates/oakengine, cdylib). build.rs +// emits the link-search path and rpath for the target profile dir. +#[link(name = "oakengine", kind = "dylib")] +extern "C" { + // ---- engine/include/oakengine/worker.h ---- + /// Full render-worker main (argv-based `--backend` scanning, startup + /// handshake, NDJSON control loop). Returns the process exit code. + pub fn oakengine_worker_main(argc: c_int, argv: *mut *mut c_char) -> c_int; + /// Create a session for the given render backend (NULL = none). + pub fn oakengine_worker_session_create(backend: *const c_char) -> *mut OakWorkerSession; + /// Free a session. NULL no-op. + pub fn oakengine_worker_session_free(self_: *mut OakWorkerSession); + /// 1 when the session holds an initialized render backend. + pub fn oakengine_worker_session_has_renderer(self_: *const OakWorkerSession) -> c_int; + /// Load the runtime services; 1 on success, 0 for a NULL session. + pub fn oakengine_worker_session_initialize_runtime(self_: *mut OakWorkerSession) -> c_int; + /// Build the startup handshake (buf/size convention; returns the + /// required size, -1 on failure). + pub fn oakengine_worker_session_startup_handshake( + self_: *const OakWorkerSession, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// Handle one NDJSON control line; 0 for "no response", the response + /// length for a response, -1 on a fatal handler failure. + pub fn oakengine_worker_session_handle_json( + self_: *mut OakWorkerSession, + line: *const c_char, + response_buf: *mut c_char, + response_buf_size: c_int, + ) -> c_int; + /// 1 once a shutdown control message has been received. + pub fn oakengine_worker_session_shutdown_requested(self_: *const OakWorkerSession) -> c_int; + + // ---- engine/include/oakengine/ipc.h ---- + /// Allocate an empty (invalid) region object. + pub fn oakengine_ipc_shm_create() -> *mut OakSharedMemoryRegion; + /// Free a region object. NULL no-op. + pub fn oakengine_ipc_shm_free(self_: *mut OakSharedMemoryRegion); + /// Open the segment; 1 on success, 0 on failure (shm_error carries the + /// reason). Mode: 0 = create, 1 = attach. + pub fn oakengine_ipc_shm_open( + self_: *mut OakSharedMemoryRegion, + key: *const c_char, + size: usize, + mode: c_int, + ) -> c_int; + /// Unmap and (if owner) unlink. + pub fn oakengine_ipc_shm_close(self_: *mut OakSharedMemoryRegion); + /// 1 when the region holds a live mapping. + pub fn oakengine_ipc_shm_is_valid(self_: *const OakSharedMemoryRegion) -> c_int; + /// The mapped data pointer (NULL when invalid). + pub fn oakengine_ipc_shm_data(self_: *mut OakSharedMemoryRegion) -> *mut c_void; + /// Mapping size in bytes. + pub fn oakengine_ipc_shm_size(self_: *const OakSharedMemoryRegion) -> usize; + /// The key the region was opened with (buf/size convention). + pub fn oakengine_ipc_shm_key( + self_: *const OakSharedMemoryRegion, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// Reason of the last failed open (buf/size convention). + pub fn oakengine_ipc_shm_error( + self_: *const OakSharedMemoryRegion, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// Build a unique segment key ("olive-rw--", buf/size + /// convention). + pub fn oakengine_ipc_shm_make_key( + owner_pid: i64, + worker_index: c_int, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// Total bytes a region must provide to back a pool of + /// `slot_count` x `slot_data_bytes`. + pub fn oakengine_ipc_framepool_bytes_needed(slot_count: u32, slot_data_bytes: usize) -> usize; + /// Lay out and initialize a brand-new pool over `mem` (owner side, + /// once). The handle does not own `mem`. + pub fn oakengine_ipc_framepool_create( + mem: *mut c_void, + slot_count: u32, + slot_data_bytes: usize, + ) -> *mut OakFrameSlotPool; + /// Map an existing pool (peer side). NULL when the segment holds no + /// pool. + pub fn oakengine_ipc_framepool_attach(mem: *mut c_void) -> *mut OakFrameSlotPool; + /// Copy the view (same shared memory, independent handle). + pub fn oakengine_ipc_framepool_copy(self_: *const OakFrameSlotPool) -> *mut OakFrameSlotPool; + /// Free a pool view. NULL no-op. + pub fn oakengine_ipc_framepool_free(self_: *mut OakFrameSlotPool); + /// 1 when the pool was attached to a valid pool header. + pub fn oakengine_ipc_framepool_is_valid(self_: *const OakFrameSlotPool) -> c_int; + /// Number of slots in the pool. + pub fn oakengine_ipc_framepool_slot_count(self_: *const OakFrameSlotPool) -> u32; + /// Bytes available in every slot's pixel-data block. + pub fn oakengine_ipc_framepool_slot_data_bytes(self_: *const OakFrameSlotPool) -> usize; + /// Take a free slot; 1 on success (`*index` set). + pub fn oakengine_ipc_framepool_acquire(self_: *mut OakFrameSlotPool, index: *mut u32) -> c_int; + /// Pointer to a slot's pixel data block. + pub fn oakengine_ipc_framepool_slot_data( + self_: *mut OakFrameSlotPool, + index: u32, + ) -> *mut c_void; + /// Immutable pixel data for a slot. + pub fn oakengine_ipc_framepool_slot_data_const( + self_: *const OakFrameSlotPool, + index: u32, + ) -> *const c_void; + /// Mutable per-slot metadata (borrowed). + pub fn oakengine_ipc_framepool_meta( + self_: *mut OakFrameSlotPool, + index: u32, + ) -> *mut FrameSlotMeta; + /// Immutable per-slot metadata. + pub fn oakengine_ipc_framepool_meta_const( + self_: *const OakFrameSlotPool, + index: u32, + ) -> *const FrameSlotMeta; + /// Publish a filled slot; 1 on success. + pub fn oakengine_ipc_framepool_publish(self_: *mut OakFrameSlotPool, index: u32) -> c_int; + /// Take the next published slot; 1 on success (`*index` set). + pub fn oakengine_ipc_framepool_consume(self_: *mut OakFrameSlotPool, index: *mut u32) -> c_int; + /// Return a consumed slot to the free pool; 1 on success. + pub fn oakengine_ipc_framepool_release(self_: *mut OakFrameSlotPool, index: u32) -> c_int; +} + +// --------------------------------------------------------------------------- +// Wire protocol (the engine's NDJSON control plane, mirrored locally) +// --------------------------------------------------------------------------- + +/// `"handshake"`. +pub const TYPE_HANDSHAKE: &str = "handshake"; +/// `"load_graph"`. +pub const TYPE_LOAD_GRAPH: &str = "load_graph"; +/// `"render_frame"`. +pub const TYPE_RENDER_FRAME: &str = "render_frame"; +/// `"frame_ready"`. +pub const TYPE_FRAME_READY: &str = "frame_ready"; +/// `"cancel"`. +pub const TYPE_CANCEL: &str = "cancel"; +/// `"graph_update"`. +pub const TYPE_GRAPH_UPDATE: &str = "graph_update"; +/// `"shutdown"`. +pub const TYPE_SHUTDOWN: &str = "shutdown"; +/// `"error"`. +pub const TYPE_ERROR: &str = "error"; + +/// `handshake` — field-for-field equivalent of `oak_ipc_handshake` +/// (ipc.h). Wire field names match the C++ serializer. +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct HandshakeMsg { + /// Protocol version. + pub protocol_version: i32, + /// Worker->main output shared-memory segment key. + pub shm_key: String, + /// Main->worker input shared-memory segment key (optional). + pub input_shm_key: String, + /// Number of main->worker input frame slots. + pub input_slots: i32, + /// Number of worker->main output frame slots. + pub output_slots: i32, + /// Per-output-slot pixel block size. + pub slot_data_bytes: i64, + /// Per-input-slot pixel block size. + pub input_slot_data_bytes: i64, +} + +impl HandshakeMsg { + /// The worker's startup handshake (`worker.cpp startup_handshake()`). + pub fn to_json(&self) -> Value { + json!({ + "type": TYPE_HANDSHAKE, + "protocol_version": self.protocol_version, + "shm_key": self.shm_key, + "input_shm_key": self.input_shm_key, + "input_slots": self.input_slots, + "output_slots": self.output_slots, + "slot_data_bytes": self.slot_data_bytes, + "input_slot_data_bytes": self.input_slot_data_bytes, + }) + } +} + +/// `render_frame` — request a frame render. Wire names per ipcmessage.cpp: +/// `ticket`, `node`, `channels` (not the ipc.h POD names). +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct RenderFrameMsg { + /// Correlates with the eventual frame_ready. + pub ticket: i64, + /// Viewer node stable uuid in the loaded graph. + pub node: String, + pub time_num: i64, + pub time_den: i64, + /// Forced output size (0 = graph default). + pub width: i32, + pub height: i32, + /// Forced PixelFormat (-1 = default). + pub format: i32, + /// Channel count (0 = default). + pub channels: i32, + /// RenderMode. + pub mode: i32, + /// Optional decoded input slot (-1 = none). + pub input_slot: i32, + /// Ordered decoded input slots. + pub input_slots: Vec, + /// Output color transform present? + pub has_color_transform: bool, + pub color_is_display: bool, + pub color_output: String, + pub color_view: String, + pub color_look: String, +} + +/// `load_graph` — path to a temporary file holding the serialized graph. +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct LoadGraphMsg { + pub path: String, +} + +/// Build a worker-side error report, mirroring `error_message()` in +/// worker.cpp: `{"type":"error","message":...}` plus `"ticket"` when +/// non-zero. +pub fn error_message(message: &str, ticket: Option) -> Value { + match ticket.filter(|t| *t != 0) { + Some(t) => json!({ "type": TYPE_ERROR, "message": message, "ticket": t }), + None => json!({ "type": TYPE_ERROR, "message": message }), + } +} + +// --------------------------------------------------------------------------- +// In-process wrapper types over the opaque C-ABI handles +// --------------------------------------------------------------------------- + +/// Two-stage read of a buf/size-convention string getter (the engine's +/// `write_string` reports the length excluding the NUL for a NULL buffer). +unsafe fn region_string( + region: *const OakSharedMemoryRegion, + getter: unsafe extern "C" fn(*const OakSharedMemoryRegion, *mut c_char, c_int) -> c_int, +) -> String { + // SAFETY: the engine's buf/size convention: a NULL/0 buffer only + // queries the required length. + let len = unsafe { getter(region, std::ptr::null_mut(), 0) }; + if len <= 0 { + return String::new(); + } + let mut buf = vec![0 as c_char; len as usize + 1]; + // SAFETY: `buf` provides len + 1 writable bytes (length + NUL). + unsafe { getter(region, buf.as_mut_ptr(), buf.len() as c_int) }; + // SAFETY: the engine NUL-terminates what it writes into `buf`. + unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_string_lossy() + .into_owned() +} + +/// A named, fixed-size POSIX shared-memory segment mapped inside the +/// engine process (C-ABI wrapper around `OakSharedMemoryRegion`). One side +/// opens the segment in [`ShmMode::Create`] (owner, unlinks on close); the +/// peer attaches by key. The wrapper owns the engine-side handle and +/// closes/frees it on drop. +pub struct SharedMemoryRegion { + handle: *mut OakSharedMemoryRegion, +} + +impl SharedMemoryRegion { + /// An empty (invalid) region. + pub fn new() -> SharedMemoryRegion { + // SAFETY: shm_create is the factory for owned handles (never + // observes caller memory). + SharedMemoryRegion { + handle: unsafe { oakengine_ipc_shm_create() }, + } + } + + /// Build a unique segment key for a worker, e.g. + /// "olive-rw--" (the engine's `SharedMemoryRegion::make_key` + /// behind the C ABI). Centralized so the owner and the spawned worker + /// agree on the same name. + pub fn make_key(owner_pid: i64, worker_index: i32) -> String { + let mut buf = [0 as c_char; OAK_IPC_SHM_KEY_CAP]; + // SAFETY: `buf` is writable and sized by the engine's key capacity. + let n = unsafe { + oakengine_ipc_shm_make_key(owner_pid, worker_index, buf.as_mut_ptr(), buf.len() as c_int) + }; + if n <= 0 { + return String::new(); + } + // SAFETY: the engine NUL-terminates the key within `buf`. + unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_string_lossy() + .into_owned() + } + + /// Open the segment identified by `key` with the given `size` in bytes + /// (through the engine). Returns true on success; on failure + /// [`Self::error`] carries a human-readable reason. + pub fn open(&mut self, key: &str, size: usize, mode: ShmMode) -> bool { + let Ok(key_c) = std::ffi::CString::new(key) else { + return false; + }; + // SAFETY: `key_c` is a valid C string; `self.handle` is the live + // handle this wrapper owns. + unsafe { oakengine_ipc_shm_open(self.handle, key_c.as_ptr(), size, mode as c_int) == 1 } + } + + /// Unmap and (if owner) unlink the segment. + pub fn close(&mut self) { + // SAFETY: the engine's shm_close is a NULL no-op. + unsafe { oakengine_ipc_shm_close(self.handle) }; + } + + /// True when the region holds a live mapping. + pub fn is_valid(&self) -> bool { + // SAFETY: the engine's shm_is_valid returns 0 for NULL handles. + unsafe { oakengine_ipc_shm_is_valid(self.handle) == 1 } + } + + /// The mapped data pointer (null when invalid). + pub fn data(&self) -> *mut u8 { + // SAFETY: the engine returns NULL for invalid regions. + unsafe { oakengine_ipc_shm_data(self.handle) as *mut u8 } + } + + /// The mapping size in bytes. + pub fn size(&self) -> usize { + // SAFETY: the engine returns 0 for NULL handles. + unsafe { oakengine_ipc_shm_size(self.handle) } + } + + /// The key the region was opened with. + pub fn key(&self) -> String { + // SAFETY: region_string follows the engine's buf/size convention. + unsafe { region_string(self.handle, oakengine_ipc_shm_key) } + } + + /// Human-readable reason of the last failed open. + pub fn error(&self) -> String { + // SAFETY: region_string follows the engine's buf/size convention. + unsafe { region_string(self.handle, oakengine_ipc_shm_error) } + } +} + +impl Drop for SharedMemoryRegion { + fn drop(&mut self) { + // Close first so the owner unlinks the segment, then free the + // engine-side box. Both are NULL no-ops in the engine. + // SAFETY: `self.handle` is the handle this wrapper owns and is not + // used after this. + unsafe { + oakengine_ipc_shm_close(self.handle); + oakengine_ipc_shm_free(self.handle); + } + } +} + +/// A fixed-size pool of equal-sized frame slots in shared memory with +/// lock-free hand-off (C-ABI wrapper around `OakFrameSlotPool`). The pool +/// does NOT own the memory — it is a view the engine attaches over a +/// mapped [`SharedMemoryRegion`]. Lifecycle: the filler `acquire`s a free +/// slot, writes meta + pixels, then `publish`es it; the drainer `consume`s +/// the next ready slot, reads it, and `release`s it back to the free ring. +/// +/// `Clone` mirrors the engine's pool-view copy (same shared memory, +/// independent handle). +pub struct FrameSlotPool { + handle: *mut OakFrameSlotPool, +} + +impl FrameSlotPool { + /// Total bytes a region must provide to back a pool of + /// `slot_count` x `slot_data_bytes` (the engine's + /// `FrameSlotPool::bytes_needed` behind the C ABI). + pub fn bytes_needed(slot_count: u32, slot_data_bytes: usize) -> usize { + // SAFETY: pure computation, no handles involved. + unsafe { oakengine_ipc_framepool_bytes_needed(slot_count, slot_data_bytes) } + } + + /// Lay out and initialize a brand-new pool over `mem` (owner side, + /// once) through the engine. + /// + /// # Safety + /// `mem` must be a valid, writable, aligned buffer of at least + /// [`Self::bytes_needed`] bytes, not concurrently written during this + /// call. + pub unsafe fn create(mem: *mut u8, slot_count: u32, slot_data_bytes: usize) -> FrameSlotPool { + // SAFETY: forwarded to the engine's create contract. + FrameSlotPool { + handle: unsafe { + oakengine_ipc_framepool_create(mem as *mut c_void, slot_count, slot_data_bytes) + }, + } + } + + /// Map an existing, already-initialized pool (peer side) through the + /// engine. The returned pool reports `is_valid() == false` when the + /// segment does not contain a pool. + /// + /// # Safety + /// `mem` must point to a mapped segment that either contains a pool or + /// is an arbitrary buffer whose first bytes are readable. + pub unsafe fn attach(mem: *mut u8) -> FrameSlotPool { + // SAFETY: forwarded to the engine's attach contract. + FrameSlotPool { + handle: unsafe { oakengine_ipc_framepool_attach(mem as *mut c_void) }, + } + } + + /// True when the pool was attached to a segment containing a valid pool + /// header. + pub fn is_valid(&self) -> bool { + // SAFETY: the engine returns 0 for NULL handles. + unsafe { oakengine_ipc_framepool_is_valid(self.handle) == 1 } + } + + /// Number of slots in the pool (0 for an invalid pool). + pub fn slot_count(&self) -> u32 { + // SAFETY: the engine returns 0 for NULL handles. + unsafe { oakengine_ipc_framepool_slot_count(self.handle) } + } + + /// Bytes available in every slot's pixel-data block (0 for invalid). + pub fn slot_data_bytes(&self) -> usize { + // SAFETY: the engine returns 0 for NULL handles. + unsafe { oakengine_ipc_framepool_slot_data_bytes(self.handle) } + } + + /// Take ownership of a free slot. Returns false (leaving `index` + /// untouched) if none is free. + /// + /// # Safety + /// The pool must be a valid view of a live segment. + pub unsafe fn acquire(&self, index: &mut u32) -> bool { + // SAFETY: forwarded to the engine's acquire contract. + unsafe { oakengine_ipc_framepool_acquire(self.handle, index) == 1 } + } + + /// Pointer to a slot's pixel data block (`slot_data_bytes` available). + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn slot_data(&self, index: u32) -> *mut u8 { + // SAFETY: forwarded to the engine's slot_data contract. + unsafe { oakengine_ipc_framepool_slot_data(self.handle, index) as *mut u8 } + } + + /// Immutable pixel data for a slot. + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn slot_data_const(&self, index: u32) -> *const u8 { + // SAFETY: forwarded to the engine's slot_data_const contract. + unsafe { oakengine_ipc_framepool_slot_data_const(self.handle, index) as *const u8 } + } + + /// Mutable metadata for a slot. The filler writes this before + /// [`Self::publish`]. The returned pointer addresses shared memory; it + /// is borrowed, not owned. + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn meta(&self, index: u32) -> *mut FrameSlotMeta { + // SAFETY: forwarded to the engine's meta contract. + unsafe { oakengine_ipc_framepool_meta(self.handle, index) } + } + + /// Immutable metadata for a slot (drainer side). + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn meta_const(&self, index: u32) -> *const FrameSlotMeta { + // SAFETY: forwarded to the engine's meta_const contract. + unsafe { oakengine_ipc_framepool_meta_const(self.handle, index) } + } + + /// Publish a filled slot to the drainer. Must follow a successful + /// [`Self::acquire`] of `index`. Returns false if the ready ring is + /// full. + /// + /// # Safety + /// `index` must be a slot previously acquired and not yet released. + pub unsafe fn publish(&self, index: u32) -> bool { + // SAFETY: forwarded to the engine's publish contract. + unsafe { oakengine_ipc_framepool_publish(self.handle, index) == 1 } + } + + /// Take the next published slot. Returns false if nothing is ready. + /// + /// # Safety + /// The pool must be a valid view of a live segment. + pub unsafe fn consume(&self, index: &mut u32) -> bool { + // SAFETY: forwarded to the engine's consume contract. + unsafe { oakengine_ipc_framepool_consume(self.handle, index) == 1 } + } + + /// Return a consumed slot to the free pool for reuse. Must follow a + /// successful [`Self::consume`] of `index`. Returns false if the free + /// ring is full. + /// + /// # Safety + /// `index` must be a slot previously consumed and not yet re-acquired. + pub unsafe fn release(&self, index: u32) -> bool { + // SAFETY: forwarded to the engine's release contract. + unsafe { oakengine_ipc_framepool_release(self.handle, index) == 1 } + } +} + +impl Clone for FrameSlotPool { + fn clone(&self) -> FrameSlotPool { + // SAFETY: the engine's framepool_copy yields NULL for a NULL + // handle. + FrameSlotPool { + handle: unsafe { oakengine_ipc_framepool_copy(self.handle) }, + } + } +} + +impl Drop for FrameSlotPool { + fn drop(&mut self) { + // SAFETY: the engine's framepool_free is a NULL no-op; the handle + // is not used after this. + unsafe { oakengine_ipc_framepool_free(self.handle) }; + } +} diff --git a/crates/oak-worker/src/ipc.rs b/crates/oak-worker/src/ipc.rs deleted file mode 100644 index ad6df018e..000000000 --- a/crates/oak-worker/src/ipc.rs +++ /dev/null @@ -1,258 +0,0 @@ -// Oak Video Editor - Non-Linear Video Editor -// Copyright (C) 2026 Oak Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -//! Control-plane NDJSON protocol (contract: `engine/include/oakengine/ipc.h` -//! and `engine/include/oakengine/worker.h`). -//! -//! The wire format is **one compact JSON object per line** on the stdio -//! pipes (worker.cpp / ipcmessage.cpp `write_message`/`read_message`). -//! Every message carries a `"type"` string; the field names below are the -//! ones the C++ serializers actually emit (`engine/render/ipc/ipcmessage.cpp`): -//! note `ticket` / `node` / `channels` / `slot` — the longer names -//! (`ticket_id`, `node_uuid`, `channel_count`, `output_slot`) exist only on -//! the C POD structs in `ipc.h`. -//! -//! Message types (M = main/editor, W = worker): -//! handshake M<->W negotiate protocol version + announce shm geometry -//! load_graph M ->W path to a temp file holding the serialized graph -//! render_frame M ->W request a frame render (ticket, node, time, params) -//! frame_ready W ->M a rendered frame is published (slot + ticket) -//! cancel M ->W abandon an in-flight ticket -//! graph_update M ->W reserved (no payload struct yet) -//! shutdown M ->W finish current work and exit cleanly -//! error W ->M worker-side failure report ("message" field) -//! -//! Items the worker does not emit yet (frame_ready, graph_update, -//! `FrameReadyMsg`) and message ids it ignores (`cancel`) are kept as the -//! documented protocol surface; `dead_code` until the frame-slot transport -//! lands (see crate::transport). - -#![allow(dead_code)] - -use std::io::{self, Write}; - -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; - -/// `"handshake"`. -pub const TYPE_HANDSHAKE: &str = "handshake"; -/// `"load_graph"`. -pub const TYPE_LOAD_GRAPH: &str = "load_graph"; -/// `"render_frame"`. -pub const TYPE_RENDER_FRAME: &str = "render_frame"; -/// `"frame_ready"`. -pub const TYPE_FRAME_READY: &str = "frame_ready"; -/// `"cancel"`. -pub const TYPE_CANCEL: &str = "cancel"; -/// `"graph_update"`. -pub const TYPE_GRAPH_UPDATE: &str = "graph_update"; -/// `"shutdown"`. -pub const TYPE_SHUTDOWN: &str = "shutdown"; -/// `"error"`. -pub const TYPE_ERROR: &str = "error"; - -/// `handshake` — field-for-field equivalent of `oak_ipc_handshake` -/// (ipc.h). Wire field names match the C++ serializer. -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct HandshakeMsg { - /// Protocol version. - pub protocol_version: i32, - /// Worker->main output shared-memory segment key. - pub shm_key: String, - /// Main->worker input shared-memory segment key (optional). - pub input_shm_key: String, - /// Number of main->worker input frame slots. - pub input_slots: i32, - /// Number of worker->main output frame slots. - pub output_slots: i32, - /// Per-output-slot pixel block size. - pub slot_data_bytes: i64, - /// Per-input-slot pixel block size. - pub input_slot_data_bytes: i64, -} - -impl HandshakeMsg { - /// The worker's startup handshake (`worker.cpp startup_handshake()`). - pub fn to_json(&self) -> Value { - json!({ - "type": TYPE_HANDSHAKE, - "protocol_version": self.protocol_version, - "shm_key": self.shm_key, - "input_shm_key": self.input_shm_key, - "input_slots": self.input_slots, - "output_slots": self.output_slots, - "slot_data_bytes": self.slot_data_bytes, - "input_slot_data_bytes": self.input_slot_data_bytes, - }) - } -} - -/// `render_frame` — request a frame render. Wire names per ipcmessage.cpp: -/// `ticket`, `node`, `channels` (not the ipc.h POD names). -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct RenderFrameMsg { - /// Correlates with the eventual frame_ready. - pub ticket: i64, - /// Viewer node stable uuid in the loaded graph. - pub node: String, - pub time_num: i64, - pub time_den: i64, - /// Forced output size (0 = graph default). - pub width: i32, - pub height: i32, - /// Forced PixelFormat (-1 = default). - pub format: i32, - /// Channel count (0 = default). - pub channels: i32, - /// RenderMode. - pub mode: i32, - /// Optional decoded input slot (-1 = none). - pub input_slot: i32, - /// Ordered decoded input slots. - pub input_slots: Vec, - /// Output color transform present? - pub has_color_transform: bool, - pub color_is_display: bool, - pub color_output: String, - pub color_view: String, - pub color_look: String, -} - -/// `frame_ready` — a rendered frame is published (wire names `ticket`/ -/// `slot`). -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct FrameReadyMsg { - pub ticket: i64, - /// Index into the worker->main output FrameSlotPool. - pub slot: i32, -} - -/// `cancel` — abandon an in-flight ticket by id. -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct CancelMsg { - pub ticket: i64, -} - -/// `load_graph` — path to a temporary file holding the serialized graph. -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct LoadGraphMsg { - pub path: String, -} - -/// Build a worker-side error report, mirroring `error_message()` in -/// worker.cpp: `{"type":"error","message":...}` plus `"ticket"` when -/// non-zero. -pub fn error_message(message: &str, ticket: Option) -> Value { - match ticket.filter(|t| *t != 0) { - Some(t) => json!({ "type": TYPE_ERROR, "message": message, "ticket": t }), - None => json!({ "type": TYPE_ERROR, "message": message }), - } -} - -/// Write one NDJSON message line (compact JSON + `\n`), the Rust port of -/// `ipcmessage.cpp write_message()`. -pub fn write_message(w: &mut impl Write, msg: &Value) -> io::Result<()> { - let line = - serde_json::to_string(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - w.write_all(line.as_bytes())?; - w.write_all(b"\n") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn handshake_wire_format_matches_cpp_field_names() { - let hs = HandshakeMsg { - protocol_version: 1, - shm_key: "olive-rw-1234-0-out".into(), - input_shm_key: "".into(), - input_slots: 0, - output_slots: 6, - slot_data_bytes: 4096, - input_slot_data_bytes: 0, - }; - let value = hs.to_json(); - // Key order is not part of the contract (JSON objects; the C++ - // QJsonObject is hash-ordered too), but the names must match the - // C++ serializer exactly. - assert_eq!(value["type"], "handshake"); - assert_eq!(value["protocol_version"], 1); - assert_eq!(value["shm_key"], "olive-rw-1234-0-out"); - assert_eq!(value["input_shm_key"], ""); - assert_eq!(value["input_slots"], 0); - assert_eq!(value["output_slots"], 6); - assert_eq!(value["slot_data_bytes"], 4096); - assert_eq!(value["input_slot_data_bytes"], 0); - // And the serialized line must parse back to the same object. - let round: serde_json::Value = - serde_json::from_str(&serde_json::to_string(&value).unwrap()).unwrap(); - assert_eq!(round, value); - } - - #[test] - fn render_frame_parse_accepts_cpp_field_names() { - let json = r#"{"type":"render_frame","ticket":42,"node":"abcd","time_num":1,"time_den":24,"width":1920,"height":1080,"format":-1,"channels":0,"mode":0,"input_slot":-1,"input_slots":[],"has_color_transform":false,"color_output":"","color_view":"","color_look":""}"#; - let m: RenderFrameMsg = serde_json::from_str(json).unwrap(); - assert_eq!(m.ticket, 42); - assert_eq!(m.node, "abcd"); - assert_eq!(m.time_num, 1); - assert_eq!(m.time_den, 24); - assert_eq!(m.width, 1920); - assert_eq!(m.input_slot, -1); - } - - #[test] - fn render_frame_defaults_on_missing_fields() { - // The C++ parser defaults missing fields (QJsonValue defaults); - // serde(default) mirrors that. - let m: RenderFrameMsg = - serde_json::from_str(r#"{"type":"render_frame","ticket":7}"#).unwrap(); - assert_eq!(m.ticket, 7); - assert_eq!(m.time_den, 0); - assert!(m.node.is_empty()); - assert!(!m.has_color_transform); - } - - #[test] - fn error_message_carries_ticket_only_when_nonzero() { - assert_eq!( - error_message("boom", None), - json!({ "type": "error", "message": "boom" }) - ); - assert_eq!( - error_message("boom", Some(0)), - json!({ "type": "error", "message": "boom" }) - ); - assert_eq!( - error_message("boom", Some(9)), - json!({ "type": "error", "message": "boom", "ticket": 9 }) - ); - } - - #[test] - fn write_message_emits_one_json_line() { - let mut buf = Vec::new(); - write_message(&mut buf, &json!({ "type": "shutdown" })).unwrap(); - assert_eq!(String::from_utf8(buf).unwrap(), "{\"type\":\"shutdown\"}\n"); - } -} diff --git a/crates/oak-worker/src/main.rs b/crates/oak-worker/src/main.rs index f84598541..4922d2849 100644 --- a/crates/oak-worker/src/main.rs +++ b/crates/oak-worker/src/main.rs @@ -16,90 +16,67 @@ //! oak-worker: headless render worker process (Rust). //! -//! A thin shell over the facade, mirroring `worker/workermain.cpp`: all -//! runtime logic — render backend selection (dynamic -> OpenGL fallback -//! through the oakrender module C ABI), the startup handshake and the -//! NDJSON control loop — lives in `oakengine::worker` (the Rust port of -//! `engine/src/capi/worker.cpp`, contract in -//! `engine/include/oakengine/worker.h`). This crate keeps only the CLI -//! surface (arg parsing) and the in-process session mirror -//! ([`session`], [`transport`]) that its unit tests exercise against the -//! facade's real shared-memory transport. +//! A thin C-ABI shell over the `liboakengine` facade dylib, mirroring +//! `worker/workermain.cpp`: all runtime logic — render backend selection +//! (dynamic -> OpenGL fallback through the oakrender module), the startup +//! handshake and the NDJSON control loop — lives in the engine +//! (`oakengine_worker_main`, the port of `engine/src/capi/worker.cpp`, +//! contract in `engine/include/oakengine/worker.h`). This crate keeps only +//! the argv forwarding, the `#[link]` declarations ([`engine_ipc`]) and +//! the in-process session mirror ([`session`], [`transport`]) that its +//! unit tests exercise against the engine's real shared-memory transport. //! //! See README.md for the full status. -mod ipc; +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(missing_docs)] + +mod engine_ipc; mod session; mod transport; +use std::ffi::{c_char, c_int, CString}; use std::process::exit; -use clap::Parser; - -// Force-link the oakrender module crate: the facade's worker module -// (oakengine::worker) initializes the render backend through the oakrender -// C ABI, but its bridge imports are `extern "C"` declarations — nothing in -// the worker source names the crate, so without this the oakrender rlib -// would not be added to the link and those imports would stay undefined. -#[allow(unused_imports)] -use oakrender as _; - /// Protocol version announced in the startup handshake -/// (`k_protocol_version` in worker.cpp). Mirrors -/// `oakengine::worker::PROTOCOL_VERSION`. +/// (`k_protocol_version` in worker.cpp). Mirrors the engine worker +/// module's `PROTOCOL_VERSION`. pub const PROTOCOL_VERSION: i32 = 1; -/// CLI surface (the C++ worker scans argv for `--backend`; clap formalizes -/// that single option). -#[derive(Parser, Debug)] -#[command( - name = "oak-worker", - about = "Oak render worker: headless render process for the editor's worker pool", - disable_version_flag = true -)] -struct Args { - /// Render backend to initialize: "opengl", "vulkan", "metal", "auto", - /// or "none" (no renderer; the process exits 1 like the C++ worker). - #[arg(long, default_value = "opengl")] - backend: String, -} - -fn main() { - let args = Args::parse(); - // The facade's worker_main is the C++ oakengine_worker_main() — the - // whole worker flow. Like workermain.cpp, this main only forwards. - exit(oakengine::worker::worker_main( - &args.backend.to_ascii_lowercase(), - )); -} - /// Log a worker-side message to stderr, mirroring worker.cpp `log_error()` /// (the `worker: ` prefix). pub fn log_error(message: &str) { eprintln!("worker: {message}"); } +fn main() { + // Forward argv verbatim: the engine's oakengine_worker_main() scans + // for `--backend` itself (workermain.cpp does the same). + let args: Vec = std::env::args_os() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let cstrings: Vec = args + .iter() + .map(|a| CString::new(a.as_str()).unwrap_or_else(|_| CString::new("").unwrap())) + .collect(); + let mut argv: Vec<*mut c_char> = cstrings + .iter() + .map(|c| c.as_ptr() as *mut c_char) + .collect(); + // SAFETY: `argv` is an array of `argc` NUL-terminated C strings, kept + // alive for the whole call; the engine only reads them. + let code = unsafe { + engine_ipc::oakengine_worker_main(argv.len() as c_int, argv.as_mut_ptr()) + }; + exit(code); +} + #[cfg(test)] mod tests { use super::*; #[test] - fn clap_parses_backend_default() { - use clap::Parser; - let args = Args::try_parse_from(["oak-worker"]).unwrap(); - assert_eq!(args.backend, "opengl"); - } - - #[test] - fn clap_parses_backend_flag() { - use clap::Parser; - let args = Args::try_parse_from(["oak-worker", "--backend", "none"]).unwrap(); - assert_eq!(args.backend, "none"); - } - - #[test] - fn clap_rejects_unknown_flags() { - use clap::Parser; - assert!(Args::try_parse_from(["oak-worker", "--frobnicate"]).is_err()); + fn protocol_version_is_one() { + assert_eq!(PROTOCOL_VERSION, 1); } } diff --git a/crates/oak-worker/src/session.rs b/crates/oak-worker/src/session.rs index 474a18ada..f6e81a964 100644 --- a/crates/oak-worker/src/session.rs +++ b/crates/oak-worker/src/session.rs @@ -16,21 +16,29 @@ //! The worker-side session state machine — the in-process mirror of //! `OakWorkerSession` in `engine/src/capi/worker.cpp` (whose production -//! Rust port lives in `oakengine::worker`). +//! Rust port lives in the engine behind the `oakengine_worker_session_*` +//! C ABI). //! //! The session holds the attached shared-memory frame-slot pools //! ([`crate::transport::AttachedPools`]) and the shutdown flag, and //! answers one NDJSON control message at a time. Renderer creation and the -//! main loop are the facade's job (see `crate::main`); this module keeps -//! the message handling testable in-process against the facade's real -//! shared-memory transport. Where the C++ session has machinery the Rust +//! main loop are the engine's job (see `crate::main`); this module keeps +//! the message handling testable in-process against the engine's real +//! shared-memory transport ([`crate::engine_ipc`], consumed through the +//! `oakengine_ipc_*` C ABI). Where the C++ session has machinery the Rust //! mirror lacks, the handler reproduces the *validation* faithfully and //! then reports the documented stub ([`crate::transport`]) — it never //! fakes a result. +//! +//! The production session is the engine's `OakWorkerSession`; this mirror +//! exists to keep the message handling testable in-process without a GPU +//! backend, so its public surface is exercised by the unit tests only. + +#![allow(dead_code)] use serde_json::Value; -use crate::ipc::{self, HandshakeMsg, LoadGraphMsg, RenderFrameMsg}; +use crate::engine_ipc::{self as ipc, HandshakeMsg, LoadGraphMsg, RenderFrameMsg}; use crate::transport::{self, AttachedPools}; /// Worker-side session: attached frame-slot pools + message-handling state. @@ -61,7 +69,7 @@ impl WorkerSession { } /// The attached output pool (the worker->main frame-slot pool). - pub fn output_pool(&self) -> Option<&oakengine::ipc::FrameSlotPool> { + pub fn output_pool(&self) -> Option<&crate::engine_ipc::FrameSlotPool> { self.pools.as_ref().map(|p| &p.output_pool) } @@ -196,7 +204,7 @@ impl Default for WorkerSession { #[cfg(test)] mod tests { use super::*; - use oakengine::ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode}; + use crate::engine_ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode}; use serde_json::json; /// A unique, temporary POSIX segment key for a test. diff --git a/crates/oak-worker/src/transport.rs b/crates/oak-worker/src/transport.rs index 128c5a83a..232319cb5 100644 --- a/crates/oak-worker/src/transport.rs +++ b/crates/oak-worker/src/transport.rs @@ -20,10 +20,10 @@ //! shared-memory segments holding an `olive::ipc::FrameSlotPool` — a fixed //! pool of frame slots whose free/ready queues are synchronized by the //! lock-free single-producer/single-consumer `SpscRingBuffer`. That -//! machinery is implemented in the facade crate (`oakengine::ipc`, the -//! Rust port of `engine/render/ipc/` behind -//! `engine/include/oakengine/ipc.h`) — this module is the worker-side -//! transport over it. +//! machinery is implemented in the engine facade (behind the +//! `oakengine_ipc_*` C ABI in `engine/include/oakengine/ipc.h`) — this +//! module is the worker-side transport over it, attached through +//! [`crate::engine_ipc`]'s C-ABI wrappers. //! //! [`attach_pools`] mirrors the C++ `attach_output_pool()`: attach the //! output segment in [`ShmMode::Attach`], map the [`FrameSlotPool`] it @@ -35,10 +35,16 @@ //! The node-graph and render-pipeline stubs below carry the same rationale //! as before: `oaknode` is a `todo!()` skeleton and the oakrender crate //! does not yet evaluate an arbitrary loaded graph to a frame. +//! +//! The production attach path is the engine's `OakWorkerSession` +//! `handle_handshake`; this module is the transport surface the in-process +//! session mirror ([`crate::session`]) uses, exercised by the unit tests. -use oakengine::ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode}; +#![allow(dead_code)] -use crate::ipc::HandshakeMsg; +use crate::engine_ipc::{FrameSlotPool, SharedMemoryRegion, ShmMode}; + +use crate::engine_ipc::HandshakeMsg; /// Why `load_graph` answers "not yet available" (after the real file checks). pub const GRAPH_STUB: &str = "load_graph: node-graph deserialization is not yet available in the \ diff --git a/crates/oak-worker/tests/worker.rs b/crates/oak-worker/tests/worker.rs index f07602e59..a6ee31ab0 100644 --- a/crates/oak-worker/tests/worker.rs +++ b/crates/oak-worker/tests/worker.rs @@ -14,10 +14,10 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! Binary-level tests for `oak-worker`: argument handling and the process -//! exit contract. The NDJSON control-loop behavior itself is exercised -//! in-process in `src/session.rs` (a real loop test would require a working -//! GPU backend, so it stays out of the unit suite). +//! Binary-level tests for `oak-worker`: the process exit contract. The +//! NDJSON control-loop behavior itself is exercised in-process in +//! `src/session.rs` (a real loop test would require a working GPU backend, +//! so it stays out of the unit suite). use std::process::Command; @@ -25,28 +25,6 @@ fn bin() -> &'static str { env!("CARGO_BIN_EXE_oak-worker") } -#[test] -fn help_exits_zero() { - let out = Command::new(bin()) - .arg("--help") - .output() - .expect("spawn oak-worker"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.contains("oak-worker")); - assert!(stdout.contains("--backend")); -} - -#[test] -fn unknown_flag_is_a_clap_usage_error() { - let out = Command::new(bin()) - .arg("--frobnicate") - .output() - .expect("spawn oak-worker"); - // clap's usage-error exit code. - assert_eq!(out.status.code(), Some(2)); -} - #[test] fn backend_none_exits_one_like_the_cpp_main() { // Mirrors oakengine_worker_main(): without a renderer the worker cannot diff --git a/crates/oakengine/src/test_support/render.rs b/crates/oakengine/src/test_support/render.rs index 6243adcfe..b68fbe52a 100644 --- a/crates/oakengine/src/test_support/render.rs +++ b/crates/oakengine/src/test_support/render.rs @@ -176,3 +176,29 @@ fn lut_library_stubs() { -3 ); } + +// repro: render_audio on an empty sequence (playback tick on an empty timeline). +#[test] +fn render_audio_empty_sequence_no_crash() { + super::common::force_link(); + unsafe { + assert_eq!(crate::render::oakengine_render_manager_init(), 0); + let project = crate::node::oakengine_project_create(); + assert!(!project.is_null()); + assert_eq!(crate::node::oakengine_project_new(project), 0); + let name = std::ffi::CString::new("s").unwrap(); + let seq = crate::timeline::oakengine_sequence_new(project, name.as_ptr()); + assert!(!seq.is_null()); + assert_eq!(crate::timeline::oakengine_sequence_add_track(seq, 1), 0); // audio track, no clips + let r = crate::render::oakengine_renderer_create(seq, 64, 64, 4, 25, 1, std::ptr::null()); + assert!(!r.is_null()); + for i in 0..5 { + let buf = crate::render::oakengine_renderer_render_audio(r, i * 2048, 2048); + if !buf.is_null() { + crate::render::oakengine_audio_free(buf); + } + } + crate::render::oakengine_renderer_free(r); + crate::node::oakengine_project_free(project); + } +} diff --git a/src/app.rs b/src/app.rs index 2dff639cf..659163cff 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2267,6 +2267,128 @@ mod tests { assert_eq!(imported, vec![dropped]); } + /// Dragging a media entry from the project explorer onto the timeline + /// places a clip there: the row starts a [`FootageDrag`] carrying the + /// entry id, the timeline panel resolves the cursor to a display track + + /// frame, and the engine records the `drop_footage` request with the + /// correct parameters. + #[gpui::test] + async fn dragging_footage_onto_the_timeline_places_a_clip(cx: &mut TestAppContext) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + let mut cx = VisualTestContext::from_window(window.into(), cx); + + // The mock project's root-level footage entry ("第一稿.mp4", id 3) is + // the drag source; the timeline's clip area is the drop target. + let row = cx + .debug_bounds("gpui-widgets-explorer-entry-3") + .expect("root-level footage row rendered"); + let canvas = cx + .debug_bounds("timeline-canvas") + .expect("timeline body rendered"); + // A point inside the clip area: 100 px into the ruler's content + // (frame 50 at the shell's default zoom of 2 px/frame) on the first + // track row (V2, 64 px tall). + let drop = gpui::point( + canvas.left() + px(gpui::timeline::HEADER_WIDTH + 100.0), + canvas.top() + px(gpui::timeline::RULER_HEIGHT + 20.0), + ); + + // Dispatch the whole gesture against the same rendered frame: gpui's + // interactive listeners are registered per render, so a repaint + // between the events would consume them before the drop lands (same + // caveat as the file-drop test above). + cx.update(|window, cx| { + window.dispatch_event( + gpui::PlatformInput::MouseDown(gpui::MouseDownEvent { + position: row.center(), + modifiers: gpui::Modifiers::none(), + button: gpui::MouseButton::Left, + click_count: 1, + first_mouse: false, + }), + cx, + ); + // Move past the drag threshold to start the drag. + window.dispatch_event( + gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent { + position: row.center() + gpui::point(px(6.0), px(0.0)), + modifiers: gpui::Modifiers::none(), + pressed_button: Some(gpui::MouseButton::Left), + }), + cx, + ); + // Hover the drop point (the panel resolves the track + frame). + window.dispatch_event( + gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent { + position: drop, + modifiers: gpui::Modifiers::none(), + pressed_button: Some(gpui::MouseButton::Left), + }), + cx, + ); + // Release over the drop point. + window.dispatch_event( + gpui::PlatformInput::MouseUp(gpui::MouseUpEvent { + position: drop, + modifiers: gpui::Modifiers::none(), + button: gpui::MouseButton::Left, + click_count: 1, + }), + cx, + ); + }); + cx.run_until_parked(); + + let drops = cx.read(|app| root.read(app).engine.read(app).footage_drops().to_vec()); + assert_eq!(drops.len(), 1, "the drop must reach the engine exactly once"); + assert_eq!(drops[0].id, 3); + assert_eq!(drops[0].track_kind, gpui::timeline::TrackKind::Video); + assert_eq!(drops[0].track_index, 0); + assert_eq!(drops[0].time, gpui::timeline::Frame(50)); + } + + /// The project explorer lists root-level imported footage in BOTH views: + /// the tree shows the entry as a row, and after switching to the icon + /// grid the same entry (plus folder children) appears as icons. + #[gpui::test] + async fn explorer_views_list_root_level_footage(cx: &mut TestAppContext) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, _root) = mock_shell(cx); + let mut cx = VisualTestContext::from_window(window.into(), cx); + + // Tree view: the root-level footage entry (id 3 "第一稿.mp4") renders + // as a row. + assert!( + cx.debug_bounds("gpui-widgets-explorer-entry-3").is_some(), + "tree view shows the root-level footage row" + ); + + // Switch to the icon grid; root-level footage and folder children + // both appear as icons, the folder root itself does not. + let toggle = cx + .debug_bounds("gpui-widgets-explorer-icons") + .expect("icons toggle rendered"); + cx.simulate_click(toggle.center(), gpui::Modifiers::none()); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + + assert!( + cx.debug_bounds("gpui-widgets-explorer-icon-3").is_some(), + "root-level footage shows as an icon" + ); + assert!( + cx.debug_bounds("gpui-widgets-explorer-icon-10").is_some(), + "a folder child shows as an icon" + ); + assert!( + cx.debug_bounds("gpui-widgets-explorer-icon-1").is_none(), + "the footage folder root itself has no icon" + ); + } + /// The command-line parser understands the project path and the mock /// flag, and the `OAK_ENGINE` env var forces the mock. #[test] diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 56633669d..bfea1d2e2 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -444,6 +444,31 @@ pub trait AppEngine: Ok(()) } + /// Places the footage with project-explorer entry `id` on the timeline: + /// a clip on the track at display `track_index` starting at `time` + /// (undoable where the backend supports it — the facade + /// `oakengine_sequence_add_footage_clip_ex` pushes one "Add Clip" undo + /// entry). + /// + /// The timeline panel resolves the cursor to a display track + frame and + /// forwards them here; the backend resolves `id` to its footage, picks + /// the target track and places the clip. Track policy: the pointed track + /// is used when its kind matches the footage's media type; a mismatch + /// (audio footage onto a video track, or vice versa) auto-selects the + /// topmost track of the footage's kind, and the drop is rejected when no + /// such track exists (the facade validates only the track type — video + /// or audio, subtitles are rejected — never the media/track pairing). + /// Default: no-op. + fn drop_footage( + &mut self, + _id: u64, + _track_kind: TrackKind, + _track_index: usize, + _time: Frame, + _cx: &mut Context, + ) { + } + /// Starts an export of the current sequence in `format` to `path` and /// returns a session the host polls for progress and can cancel. /// diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs index d2589d6fd..bf070d9e8 100644 --- a/src/oakui/ffi.rs +++ b/src/oakui/ffi.rs @@ -410,6 +410,15 @@ unsafe extern "C" { /// thread (two-stage buf/size getter; empty when the last call /// succeeded). pub fn oakengine_footage_last_error(buf: *mut c_char, buf_size: c_int) -> c_int; + /// `oakengine_footage_borrow` — wrap a footage node in a borrowed + /// footage box (addref'd; free with `oakengine_footage_free`). NULL + /// when `node` is NULL or not a footage node. + pub fn oakengine_footage_borrow(node: *mut OakEngineNode) -> *mut OakEngineFootage; + /// `oakengine_footage_get_duration` — media duration in seconds. + pub fn oakengine_footage_get_duration( + self_: *mut OakEngineFootage, + seconds: *mut f64, + ) -> c_int; /// `oakengine_sequence_add_footage_clip_ex` — place a clip of /// `footage` on the track, skipping the unenforceable same-project /// check (sequences live in their own scratch project — documented diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index a25793fe2..1b9ba62fc 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -192,6 +192,20 @@ impl PlaybackClock for MockClock { // Timeline model // --------------------------------------------------------------------------- +/// A recorded timeline drop of a footage entry (mock state; the mock has no +/// media pipeline, so it records the request and places a demo clip). +#[derive(Debug, Clone, PartialEq)] +pub struct MockFootageDrop { + /// The dropped project-explorer entry id. + pub id: u64, + /// The track kind the clip landed on. + pub track_kind: TrackKind, + /// The display track index the clip landed on. + pub track_index: usize, + /// The clip's start frame. + pub time: Frame, +} + /// A clip on the demo timeline. #[derive(Debug, Clone)] pub struct MockClip { @@ -477,6 +491,11 @@ pub struct MockEngine { /// has no media pipeline, so it just records them (drives app-level tests /// of the import flow). imported_footage: Vec, + /// Footage entries dropped onto the timeline via + /// [`AppEngine::drop_footage`] since creation (mock state; each entry is + /// the applied (id, track kind, track index, start frame) — drives + /// app-level tests of the explorer→timeline drag). + footage_drops: Vec, /// The fake project library the project manager browses (M13 D4): an /// in-memory row set the library trait methods operate on, so the app /// flow (list / open / create / rename / duplicate / delete / import / /// export) is testable without a database. @@ -743,6 +762,7 @@ impl MockEngine { node_selection: BTreeSet::new(), cpu_frame_cache: Mutex::new(HashMap::new()), imported_footage: Vec::new(), + footage_drops: Vec::new(), library: demo_library(), next_library_id: 100, library_opened: Vec::new(), @@ -1389,6 +1409,87 @@ impl AppEngine for MockEngine { Ok(()) } + fn drop_footage( + &mut self, + id: u64, + track_kind: TrackKind, + track_index: usize, + time: Frame, + cx: &mut Context, + ) { + // The footage's media type, inferred from its entry name (the mock + // never probes media). Entries the explorer does not list are + // rejected. + let Some(name) = self.footage_entry_name(id) else { + println!("[mock engine] drop footage: entry {id} not in the project"); + cx.notify(); + return; + }; + let footage_kind = if crate::oakui::filename_is_audio(&name) { + TrackKind::Audio + } else { + TrackKind::Video + }; + // Track policy (mirrors the facade semantics, see `AppEngine`: the + // pointed track is used when it matches the footage's kind; a + // mismatch auto-selects the topmost track of the footage's kind; no + // matching track rejects the drop). + let target = if self.tracks.get(track_index).map(|t| t.kind) == Some(footage_kind) { + track_index + } else if let Some(index) = self.tracks.iter().position(|t| t.kind == footage_kind) { + index + } else { + println!( + "[mock engine] drop footage: no {:?} track for {:?} media \"{name}\"", + footage_kind, track_kind + ); + cx.notify(); + return; + }; + // A 10-second demo clip (the mock has no media durations). + let fps = self.frame_rate(); + let length = Frame( + (10.0 * fps.num as f64 / fps.den.max(1) as f64).round().max(1.0) as i64, + ); + let clip = MockClip { + id: ClipId(self.next_mock_clip_id()), + range: FrameRange::new(Frame(time.0.max(0)), Frame(time.0.max(0) + length.0)), + media_in: Frame::ZERO, + label: name.clone().into(), + color: if footage_kind == TrackKind::Audio { + Hsla { + h: 0.402, + s: 0.32, + l: 0.54, + a: 1.0, + } + } else { + Hsla { + h: 0.402, + s: 0.385, + l: 0.459, + a: 1.0, + } + }, + }; + // Keep the track's clips in ascending frame order (a data-source + // consistency requirement of the timeline widget). + let track = &mut self.tracks[target]; + let position = track + .clips + .iter() + .position(|c| c.range.start.0 > time.0) + .unwrap_or(track.clips.len()); + track.clips.insert(position, clip); + self.footage_drops.push(MockFootageDrop { + id, + track_kind: footage_kind, + track_index: target, + time: Frame(time.0.max(0)), + }); + cx.notify(); + } + fn export_project_path(&mut self, _path: PathBuf, cx: &mut Context) -> Result<(), String> { println!("[mock engine] export: no persistence in mock mode"); cx.notify(); @@ -1659,6 +1760,27 @@ impl MockEngine { &self.imported_footage } + /// The footage entries dropped onto the timeline so far (mock state; see + /// [`MockFootageDrop`]). + pub fn footage_drops(&self) -> &[MockFootageDrop] { + &self.footage_drops + } + + /// The display name of the project-explorer entry with `id`, if any. + fn footage_entry_name(&self, id: u64) -> Option { + for entry in self.roots() { + if entry.id == id { + return Some(entry.name.to_string()); + } + for child in self.children(entry.id) { + if child.id == id { + return Some(child.name.to_string()); + } + } + } + None + } + /// The undo/redo call counts (test observability; see the fields). pub fn undo_redo_calls(&self) -> (u64, u64) { (self.undo_calls, self.redo_calls) diff --git a/src/oakui/mod.rs b/src/oakui/mod.rs index c7709afc1..e3edb4867 100644 --- a/src/oakui/mod.rs +++ b/src/oakui/mod.rs @@ -58,4 +58,25 @@ pub use engine::{ }; pub use mock::{MockClock, MockEngine}; pub use real::{RealClock, RealEngine}; + +/// Whether `name` (a media file name) denotes audio-only media, by +/// extension. +/// +/// The facade's module footage is never probed (`oakengine` imports media +/// without decoding it), so the stream counts it exposes are always empty +/// and the timeline drop's track matching falls back to the extension: +/// known audio containers count as audio, everything else as video. +pub fn filename_is_audio(name: &str) -> bool { + matches!( + std::path::Path::new(name) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .as_deref(), + Some( + "wav" | "mp3" | "flac" | "aac" | "ogg" | "oga" | "opus" | "m4a" | "wma" | "aiff" + | "aif" | "ac3" | "amr" | "ape" | "caf" + ) + ) +} pub use transport::{PlayState, TransportState}; diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 21bcf5ad6..494a41b41 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -384,9 +384,12 @@ enum FullResTarget { /// An addref'd sequence box (released with [`free_box`], last, after /// the renderer so the sequence outlives the renderer's borrowed view). Sequence(SendPtr), - /// A boxed footage node (freed with `oakengine_node_free` once the - /// renderer has resolved its footage spec). - Node(SendPtr), + /// A boxed footage node plus an addref'd copy of its project. The node + /// box alone does NOT keep the graph alive: dropping the project while + /// a job is in flight leaves the node dangling (observed crash: + /// misaligned pointer dereference in `oakengine_node_free`). The + /// project copy is released after the node. + Node(SendPtr, SendPtr), } /// One background full-resolution render request (built on the UI thread @@ -867,6 +870,58 @@ impl RealEngine { self.project.as_ref().map(ProjectHandle::ptr) } + /// An addref'd copy of the project handle, boxed for the background + /// worker — same lifetime contract as [`RealEngine::sequence_copy`]: + /// the copy keeps the graph alive after the engine's own project is + /// dropped (the source monitor's footage node dangles otherwise). + fn project_copy(&self) -> Option<*mut OakEngineProject> { + let project = self.project_ptr()?; + // SAFETY: `project` is the engine's live project box. + let handle = unsafe { unbox(project) }?; + let addref = handle.addref?; + // SAFETY: `handle` is a live module handle; addref takes a new + // reference the copy releases. + unsafe { addref(handle.ctx) }; + Some(unsafe { box_handle::(handle) }) + } + + /// The selected footage's duration in frames at the current rate + /// (0 when nothing is selected or the footage was not probed). + fn source_length(&self) -> Frame { + let Some(project) = self.project_ptr() else { + return Frame(0); + }; + let Some(id) = self.selected_item else { + return Frame(0); + }; + let count = unsafe { oakengine_project_footage_count(project) }; + for i in 0..count.max(0) { + let f = unsafe { oakengine_project_footage_at(project, i) }; + if f.is_null() { + continue; + } + let matches = unsafe { oakengine_node_identity(f) } == id; + if matches { + // The footage list yields node boxes; borrow the footage + // view for the duration query. + let footage = unsafe { oakengine_footage_borrow(f) }; + unsafe { oakengine_node_free(f) }; + let mut seconds: f64 = 0.0; + let ok = !footage.is_null() + && unsafe { oakengine_footage_get_duration(footage, &mut seconds) } == 0; + unsafe { oakengine_footage_free(footage) }; + if ok && seconds > 0.0 { + let rate = self.frame_rate(); + let fps = rate.num as f64 / rate.den.max(1) as f64; + return Frame((seconds * fps).round().max(1.0) as i64); + } + return Frame(0); + } + unsafe { oakengine_node_free(f) }; + } + Frame(0) + } + /// Current sequence length (0 without a sequence). fn sequence_length(&self) -> Frame { self.sequence_info @@ -1218,7 +1273,14 @@ impl RealEngine { let height = info.format.height.max(1) as c_int; let target = match monitor { Monitor::Program => FullResTarget::Sequence(SendPtr(self.sequence_copy()?)), - Monitor::Source => FullResTarget::Node(SendPtr(self.selected_footage_node()?)), + Monitor::Source => { + // The project copy MUST be taken while the engine's own + // project is still alive (it keeps the node valid). + FullResTarget::Node( + SendPtr(self.selected_footage_node()?), + SendPtr(self.project_copy()?), + ) + } }; Some(FullResRequest { monitor, @@ -1269,10 +1331,11 @@ impl RealEngine { std::ptr::null(), ) } - FullResTarget::Node(node) => { - // SAFETY: the renderer resolves its own footage spec at - // creation; the node box is no longer needed after it. - let renderer = oakengine_renderer_create_for_node( + FullResTarget::Node(node, _) => { + // The node stays alive until release_full_res_target + // (freed exactly once there); the renderer resolves its + // footage spec at creation and borrows nothing beyond. + oakengine_renderer_create_for_node( node.0, width, height, @@ -1280,9 +1343,7 @@ impl RealEngine { rate_num, rate_den, std::ptr::null(), - ); - oakengine_node_free(node.0); - renderer + ) } } }; @@ -1327,7 +1388,12 @@ impl RealEngine { unsafe { match target { FullResTarget::Sequence(seq) => free_box(seq.0), - FullResTarget::Node(node) => oakengine_node_free(node.0), + // The node goes first; the addref'd project copy outlives + // it (the node's graph must stay alive during the free). + FullResTarget::Node(node, project) => { + oakengine_node_free(node.0); + free_box(project.0); + } } } } @@ -1898,11 +1964,19 @@ impl EngineGateway for RealEngine { } fn tick(&mut self, cx: &mut Context) { + // Each clock loops at its own monitor's length: the program at the + // sequence length, the source at the selected footage's duration + // (the sequence length is wrong for footage playback — an empty + // project's length 0 used to freeze the source playhead at 0). let length = self.sequence_length(); - for clock in [&self.source_clock, &self.program_clock] { + let source_length = self.source_length(); + for (clock, len) in [ + (&self.source_clock, source_length), + (&self.program_clock, length), + ] { let clock = clock.clone(); clock.update(cx, |clock, cx| { - clock.tick(length); + clock.tick(len); cx.notify(); }); } @@ -2818,6 +2892,146 @@ impl AppEngine for RealEngine { Ok(()) } + fn drop_footage( + &mut self, + id: u64, + track_kind: TrackKind, + track_index: usize, + time: Frame, + cx: &mut Context, + ) { + let Some(project) = self.project_ptr() else { + return; + }; + let Some(seq) = self.seq_ptr() else { + return; + }; + // The explorer's entry id IS the footage node's stable identity + // (`projectbrowser`); find the matching footage node (its box is + // freed below). + let mut footage_node: *mut OakEngineNode = std::ptr::null_mut(); + let mut footage_index: c_int = -1; + let count = unsafe { oakengine_project_footage_count(project) }; + for index in 0..count.max(0) { + let node = unsafe { oakengine_project_footage_at(project, index) }; + if node.is_null() { + continue; + } + if unsafe { oakengine_node_identity(node) } == id { + footage_node = node; + footage_index = index; + break; + } + // SAFETY: `node` is a box from `oakengine_project_footage_at`. + unsafe { oakengine_node_free(node) }; + } + if footage_node.is_null() { + println!("[real engine] drop footage: entry {id} is not a footage node"); + return; + } + // Media type by extension: the module's footage is never probed, so + // the facade's stream counts are empty (see `filename_is_audio`). + let filename = read_string(|buf, size| unsafe { + oakengine_project_footage_filename(project, footage_index, buf, size) + }); + let footage_kind = if crate::oakui::filename_is_audio(&filename) { + TrackKind::Audio + } else { + TrackKind::Video + }; + // Track policy (see the `AppEngine::drop_footage` docs): use the + // pointed display track when its kind matches, otherwise auto-select + // the topmost track of the footage's kind; reject when there is none. + // The facade itself validates only the track type (video/audio; it + // rejects subtitles) and never the media/track pairing. + let target = if let Some(track) = self.tracks.get(track_index) { + if track.kind == footage_kind { + track_index + } else { + match self.tracks.iter().position(|t| t.kind == footage_kind) { + Some(index) => index, + None => { + println!( + "[real engine] drop footage: no {:?} track for {:?} media \"{}\"", + footage_kind, track_kind, filename + ); + // SAFETY: `footage_node` is a box from + // `oakengine_project_footage_at`. + unsafe { oakengine_node_free(footage_node) }; + return; + } + } + } + } else { + println!("[real engine] drop footage: display track {track_index} does not exist"); + // SAFETY: `footage_node` is a box from `oakengine_project_footage_at`. + unsafe { oakengine_node_free(footage_node) }; + return; + }; + // The display list maps 1:1 onto the facade's per-type track lists + // (see `rebuild_timeline`), so the snapshot's coordinates address the + // facade track directly. + let (track_type, track_index_facade) = { + let track = &self.tracks[target]; + (track.track_type, track.track_index) + }; + // Clip length: the footage's probed duration when available; module + // footage is never probed, so fall back to a 10-second default. + let fps = self.frame_rate(); + let fps_f = fps.num as f64 / fps.den.max(1) as f64; + let footage = unsafe { oakengine_footage_borrow(footage_node) }; + let mut seconds: f64 = 0.0; + let has_duration = !footage.is_null() + && unsafe { oakengine_footage_get_duration(footage, &mut seconds) } == 0 + && seconds > 0.0; + let length = if has_duration { + (seconds * fps_f).round().max(1.0) as i64 + } else { + (10.0 * fps_f).round().max(1.0) as i64 + }; + let in_ts = time.0.max(0); + // SAFETY: `seq` and `footage` are live facade handles; the returned + // owned clip box is freed below. + let clip = unsafe { + oakengine_sequence_add_footage_clip_ex( + seq, + footage, + track_type, + track_index_facade as c_int, + in_ts, + in_ts + length, + 0, + ) + }; + // SAFETY: `footage` is a borrowed box (`oakengine_footage_borrow`); + // `footage_node` a box from `oakengine_project_footage_at`. + unsafe { + if !footage.is_null() { + oakengine_footage_free(footage); + } + oakengine_node_free(footage_node); + } + let rc = if clip.is_null() { + let error = read_string(|buf, size| unsafe { + oakengine_sequence_last_error(buf, size) + }); + let error = if error.is_empty() { + "add footage clip rejected".to_string() + } else { + error + }; + println!("[real engine] drop footage rejected: {error}"); + -1 + } else { + // SAFETY: `clip` is an owned facade box (`free_box`). + unsafe { free_box(clip) }; + 0 + }; + // The facade export pushes ONE undoable "Add Clip" entry; the refresh + // also invalidates the cached rendered frames. + self.apply_edit(rc, "drop footage", cx); + } + // --- project library (M13 D4) -------------------------------------- fn storage_bound(&self) -> bool { @@ -4194,6 +4408,72 @@ mod tests { let _ = std::fs::remove_file(&media); } + /// Regression: the source monitor's full-res job used to carry only the + /// footage node box — dropping the project while the job was in flight + /// left the node dangling, and the worker's free path died on a + /// misaligned pointer inside the module's handle table. The request now + /// also carries an addref'd project copy, so this scenario completes + /// (and frees cleanly) instead of crashing. + #[test] + fn full_res_worker_outlives_a_dropped_project() { + let _media = media_lock(); + if !RealEngine::ensure_render_manager() { + panic!("the render manager failed to start"); + } + + let project = unsafe { oakengine_project_create() }; + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let media = std::env::temp_dir().join(format!( + "oakapp_fullres_src_{}.mp4", + std::process::id() + )); + let media_c = CString::new(media.to_string_lossy().into_owned()).unwrap(); + assert_eq!( + unsafe { oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10) }, + 0 + ); + let footage = unsafe { oakengine_project_import_footage(project, media_c.as_ptr()) }; + assert!(!footage.is_null(), "import must succeed"); + + // The node box from the project's footage list plus the addref'd + // project copy (what build_full_res_request now does). + let node = unsafe { oakengine_project_footage_at(project, 0) }; + assert!(!node.is_null()); + // SAFETY: `footage` is a live box; the node box is independent. + unsafe { oakengine_footage_free(footage) }; + let handle = unsafe { unbox(project) }.expect("project handle"); + let addref = handle.addref.expect("module handle addref"); + // SAFETY: `handle` is a live module handle; addref takes a new + // reference the copy releases. + unsafe { addref(handle.ctx) }; + let project_copy = unsafe { box_handle::(handle) }; + + // The engine's own project goes away BEFORE the worker runs — the + // pre-fix crash window. + unsafe { oakengine_project_free(project) }; + + let (tx, rx) = mpsc::channel(); + let request = FullResRequest { + monitor: Monitor::Source, + frame: 0, + generation: 1, + target: FullResTarget::Node(SendPtr(node), SendPtr(project_copy)), + width: 64, + height: 64, + rate_num: 10, + rate_den: 1, + }; + std::thread::spawn(move || RealEngine::full_res_worker(request, tx)); + + let event = rx + .recv_timeout(Duration::from_secs(20)) + .expect("the worker delivers the frame after the project drop"); + let bytes = event.image.as_bytes(0).expect("one frame"); + assert_eq!(bytes.len(), 64 * 64 * 4, "full-res geometry"); + let _ = std::fs::remove_file(&media); + } + // ----------------------------------------------------------------------- // M12 P5a: the full-resolution fill — scheduling logic (pure) // ----------------------------------------------------------------------- diff --git a/src/panels/timeline.rs b/src/panels/timeline.rs index 5892a29e1..36da0237f 100644 --- a/src/panels/timeline.rs +++ b/src/panels/timeline.rs @@ -41,10 +41,13 @@ use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; -use gpui::timeline::TimelineView; +use gpui::timeline::{ + Frame, TimelineView, TrackData, TrackKind, HEADER_WIDTH, MIN_TRACK_HEIGHT, RULER_HEIGHT, +}; use gpui::{div, img, prelude::*, px, Context, Entity, Window}; -use gpui::{AnyElement, App, ClickEvent, EventEmitter, Render, SharedString}; +use gpui::{AnyElement, App, ClickEvent, DragMoveEvent, EventEmitter, Render, SharedString}; use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState}; +use gpui_widgets::project_explorer::FootageDrag; use gpui_widgets::slider::{Slider, SliderEvent, SliderModel}; use gpui_widgets::tooltip::tooltip_view; use gpui_widgets::value::ValueKind; @@ -85,6 +88,21 @@ pub struct TimelinePanel { snap: Entity, /// The currently selected tool (visual only). selected_tool: usize, + /// The drop point of an in-flight footage drag: the display track under + /// the cursor plus the start frame. `None` outside the clip area or while + /// no footage drag is active. + footage_drop: Option, +} + +/// A footage drop target resolved from the cursor: the display track under +/// the pointer and the clip's start frame. +struct FootageDropTarget { + /// The pointed track's kind. + track_kind: TrackKind, + /// The pointed display track index. + track_index: usize, + /// The start frame at the pointer. + time: Frame, } impl TimelinePanel { @@ -154,8 +172,75 @@ impl TimelinePanel { height, snap, selected_tool: 0, + footage_drop: None, } } + + /// Resolves the footage-drop target under the cursor: converts the + /// pointer (relative to the timeline body) into a display track + start + /// frame using the timeline view's zoom/scroll state and the engine's + /// track heights — the same affine mapping the timeline itself uses (see + /// [`TimelineState::frame_at_point`] and the view's track-row walk). + /// Hovering outside the clip area (above the ruler) clears the target. + fn update_footage_drop(&mut self, event: &DragMoveEvent, cx: &mut Context) { + let now = event.event.position - event.bounds.origin; + // The clip area starts below the ruler and right of the track + // headers column. + if f32::from(now.y) < RULER_HEIGHT { + self.footage_drop = None; + return; + } + let clip_x = f32::from(now.x - px(HEADER_WIDTH)).max(0.0); + let clip_y = now.y - px(RULER_HEIGHT); + let state = self.timeline.read(cx).state.clone(); + let seq_len = self.engine.read(cx).sequence_length(); + let time = state.frame_at_point(px(clip_x)).clamp(Frame::ZERO, seq_len); + // Walk the display rows top-down, clamping each to the minimum row + // height exactly like the timeline's own `track_at_y`. + let (track_kind, track_index) = { + let engine = self.engine.read(cx); + let mut acc = 0.0f32; + let mut found = None; + for index in 0..engine.track_count() { + if let Some(track) = engine.track(index) { + acc += f32::from(track.height()).max(MIN_TRACK_HEIGHT); + if f32::from(clip_y) < acc { + found = Some((track.kind(), index)); + break; + } + } + } + found.unwrap_or_else(|| { + let last = engine.track_count().saturating_sub(1); + engine + .track(last) + .map(|t| (t.kind(), last)) + .unwrap_or((TrackKind::Video, 0)) + }) + }; + self.footage_drop = Some(FootageDropTarget { + track_kind, + track_index, + time, + }); + } + + /// Applies a finished footage drop: routes the payload's footage id with + /// the last hovered track + frame to the engine, which resolves the + /// footage, validates the track and places the clip (undoable). + fn finish_footage_drop(&mut self, drag: &FootageDrag, cx: &mut Context) { + let Some(target) = self.footage_drop.take() else { + return; + }; + let FootageDropTarget { + track_kind, + track_index, + time, + } = target; + self.engine.update(cx, |engine, cx| { + engine.drop_footage(drag.0, track_kind, track_index, time, cx); + }); + } } impl Render for TimelinePanel { @@ -330,6 +415,17 @@ impl Render for TimelinePanel { .debug_selector(|| "timeline-canvas".into()) .flex_1() .min_w_0() + // Footage drop target: hover resolves the track + + // frame (see [`TimelinePanel::update_footage_drop`]), + // the release routes the payload to the engine. + .on_drag_move(cx.listener( + |this, event: &DragMoveEvent, _window, cx| { + this.update_footage_drop(event, cx); + }, + )) + .on_drop(cx.listener(|this, drag: &FootageDrag, _window, cx| { + this.finish_footage_drop(drag, cx); + })) .child(self.timeline.clone()), ) .child(right_controls),