- oak-cli: new engine.rs assembly layer maps every facade call to module Rust APIs (oaknode graph/serializer, oaktimeline commands, oakrender ticket arena, oaktask ExportTask, oakcommon config); the ffi/optional/host layers and build.rs link config are gone - oak-worker: the worker session + POSIX shm transport moved into the crate (oakrender backend + serde_json control plane); no dylib - both binaries carry zero liboakengine references (otool verified); tests green (30 cli / 41 worker)
250 lines
8.4 KiB
Rust
250 lines
8.4 KiB
Rust
// Oak Video Editor - Non-Linear Video Editor
|
|
// Copyright (C) 2026 Oak Team
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
//! `oak-cli render <project.ove> <start_seconds> <end_seconds> <out_dir>` —
|
|
//! render the first sequence to PPM frames plus a PCM s16 WAV (port of
|
|
//! `cmd_render()` in cli/main.cpp).
|
|
//!
|
|
//! Runs entirely through the module crates (M14 R2):
|
|
//!
|
|
//! 1. `crate::engine::load_project` — the oaknode serializer (the
|
|
//! process chdirs into the project directory first, like the C++
|
|
//! CLI, so relative footage paths resolve during rendering).
|
|
//! 2. Sequence 0's frame rate and geometry via the sequence behavior.
|
|
//! 3. `crate::engine::render_manager_init` — the oakrender manager's
|
|
//! ticket arena drives the render.
|
|
//! 4. For every frame timestamp in `[start, end)` the video montage at
|
|
//! that time is submitted as a ticket
|
|
//! (`crate::engine::render_frame`) → [`crate::ppm::write_ppm`]
|
|
//! (P6, 8-bit RGB).
|
|
//! 5. The audio range through the audio montage + ticket
|
|
//! (`crate::engine::render_audio`) → [`crate::wav::write_wav`].
|
|
//!
|
|
//! Exit codes: a frame/audio render failure exits 2 (rendering
|
|
//! unavailable, mirroring the C++ code for a missing render backend);
|
|
//! project/sequence/argument failures exit 1; bad seconds exit 64
|
|
//! (usage). Frame progress goes to stderr (`frame N: T s`).
|
|
|
|
use oakcore_rs::TimeRange;
|
|
|
|
use crate::cmd::{EXIT_ERROR, EXIT_OK, EXIT_RENDER_UNAVAILABLE, EXIT_USAGE};
|
|
use crate::engine;
|
|
use crate::ppm;
|
|
use crate::wav;
|
|
|
|
/// Run `render` with the validated (or rejected) seconds arguments.
|
|
pub fn run(project: String, start_seconds: &str, end_seconds: &str, out_dir: &str) -> i32 {
|
|
let start: f64 = match start_seconds.parse() {
|
|
Ok(v) => v,
|
|
Err(_) => {
|
|
eprintln!("error: invalid start seconds \"{start_seconds}\"");
|
|
return EXIT_USAGE;
|
|
}
|
|
};
|
|
let end: f64 = match end_seconds.parse() {
|
|
Ok(v) => v,
|
|
Err(_) => {
|
|
eprintln!("error: invalid end seconds \"{end_seconds}\"");
|
|
return EXIT_USAGE;
|
|
}
|
|
};
|
|
if end <= start {
|
|
eprintln!("error: invalid end seconds \"{end_seconds}\"");
|
|
return EXIT_USAGE;
|
|
}
|
|
|
|
run_render(&project, start, end, out_dir)
|
|
}
|
|
|
|
/// The render body.
|
|
fn run_render(project: &str, start: f64, end: f64, out_dir: &str) -> i32 {
|
|
// 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}");
|
|
return EXIT_ERROR;
|
|
}
|
|
};
|
|
if let Some(dir) = abs.parent() {
|
|
let _ = std::env::set_current_dir(dir);
|
|
}
|
|
|
|
let project_ref = match engine::load_project(&abs.to_string_lossy()) {
|
|
Ok(p) => p,
|
|
Err(detail) => {
|
|
if detail.is_empty() {
|
|
eprintln!("error: render: cannot load project \"{project}\"");
|
|
} else {
|
|
eprintln!("error: render: {detail}");
|
|
}
|
|
return EXIT_ERROR;
|
|
}
|
|
};
|
|
|
|
// The render manager must be up before any ticket submission (the
|
|
// facade's OAKENGINE_INIT_RENDER render boot).
|
|
if let Err(e) = engine::render_manager_init() {
|
|
eprintln!("error: render: cannot initialize the render manager: {e}");
|
|
return EXIT_RENDER_UNAVAILABLE;
|
|
}
|
|
|
|
let sequences = {
|
|
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
|
engine::sequence_ids(&guard)
|
|
};
|
|
let seq_id = match sequences.first() {
|
|
Some(id) => *id,
|
|
None => {
|
|
eprintln!("error: render: project has no sequences");
|
|
engine::render_manager_shutdown();
|
|
return EXIT_ERROR;
|
|
}
|
|
};
|
|
|
|
let (fr_num, fr_den, width, height) = {
|
|
let guard = project_ref.lock().unwrap_or_else(|e| e.into_inner());
|
|
let fr = engine::sequence_frame_rate(&guard, seq_id);
|
|
let (w, h) = engine::sequence_geometry(&guard, seq_id);
|
|
(fr.numerator() as i32, fr.denominator() as i32, w, h)
|
|
};
|
|
if fr_num <= 0 || fr_den <= 0 {
|
|
eprintln!("error: render: invalid sequence frame rate {fr_num}/{fr_den}");
|
|
engine::render_manager_shutdown();
|
|
return EXIT_ERROR;
|
|
}
|
|
if width <= 0 || height <= 0 {
|
|
eprintln!("error: render: sequence has no video geometry");
|
|
engine::render_manager_shutdown();
|
|
return EXIT_ERROR;
|
|
}
|
|
|
|
if let Err(e) = std::fs::create_dir_all(out_dir) {
|
|
eprintln!("error: render: cannot create output directory \"{out_dir}\": {e}");
|
|
engine::render_manager_shutdown();
|
|
return EXIT_ERROR;
|
|
}
|
|
|
|
// 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 time_r = oakcore_rs::Rational::new(index * i64::from(fr_den), i64::from(fr_num));
|
|
let montage = engine::video_montage(&project_ref, seq_id, time_r);
|
|
let frame = match engine::render_frame(seq_id, time_r, montage, width, height) {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
let msg = if e.is_empty() {
|
|
format!("frame at {time:.6} s failed to render")
|
|
} else {
|
|
format!("frame at {time:.6} s failed: {e}")
|
|
};
|
|
eprintln!("error: render: {msg}");
|
|
engine::render_manager_shutdown();
|
|
return EXIT_RENDER_UNAVAILABLE;
|
|
}
|
|
};
|
|
if let Err(msg) = write_frame_ppm(&frame, out_dir, written) {
|
|
eprintln!("error: render: {msg}");
|
|
engine::render_manager_shutdown();
|
|
return EXIT_ERROR;
|
|
}
|
|
eprintln!("frame {written}: {time:.6} s");
|
|
written += 1;
|
|
index += 1;
|
|
}
|
|
|
|
if written == 0 {
|
|
eprintln!("error: render: empty frame range");
|
|
engine::render_manager_shutdown();
|
|
return EXIT_ERROR;
|
|
}
|
|
|
|
// Audio range in the sequence time base.
|
|
let start_ts = (start * fr_num as f64 / fr_den as f64).round() as i64;
|
|
let length_ts = ((end - start) * fr_num as f64 / fr_den as f64).round() as i64;
|
|
let range = TimeRange::new(
|
|
oakcore_rs::Rational::new(start_ts * i64::from(fr_den), i64::from(fr_num)),
|
|
oakcore_rs::Rational::new(
|
|
(start_ts + length_ts) * i64::from(fr_den),
|
|
i64::from(fr_num),
|
|
),
|
|
);
|
|
let montage = engine::audio_montage(&project_ref, seq_id, range);
|
|
let code = match engine::render_audio(seq_id, range, montage) {
|
|
Ok(audio) => write_audio_wav(&audio, out_dir),
|
|
Err(e) => {
|
|
let msg = if e.is_empty() {
|
|
"audio render failed".to_string()
|
|
} else {
|
|
format!("audio render failed: {e}")
|
|
};
|
|
eprintln!("error: render: {msg}");
|
|
EXIT_RENDER_UNAVAILABLE
|
|
}
|
|
};
|
|
engine::render_manager_shutdown();
|
|
code
|
|
}
|
|
|
|
/// Write a rendered frame as `frame_%05d.ppm` in `out_dir`. The render
|
|
/// frames are always in the internal RGBA layout
|
|
/// (`VideoParams::k_internal_channel_count == 4`), so 4 channels feed the
|
|
/// [`crate::ppm`] writer.
|
|
fn write_frame_ppm(frame: &engine::RenderedFrame, out_dir: &str, index: u64) -> Result<(), String> {
|
|
if frame.data.is_empty() || frame.width <= 0 || frame.height <= 0 || frame.linesize <= 0 {
|
|
return Err(format!(
|
|
"frame {index} has no pixel data ({}x{}, linesize {})",
|
|
frame.width, frame.height, frame.linesize
|
|
));
|
|
}
|
|
let path = std::path::Path::new(out_dir).join(format!("frame_{index:05}.ppm"));
|
|
ppm::write_ppm(
|
|
&path,
|
|
frame.width,
|
|
frame.height,
|
|
frame.format,
|
|
4,
|
|
frame.linesize,
|
|
&frame.data,
|
|
)
|
|
.map_err(|e| format!("cannot write \"{}\": {e}", path.display()))
|
|
}
|
|
|
|
/// Write the rendered audio buffer as `audio.wav` in `out_dir` (the
|
|
/// buffer is interleaved f32).
|
|
fn write_audio_wav(audio: &engine::RenderedAudio, out_dir: &str) -> i32 {
|
|
let rate = audio.sample_rate;
|
|
let channels = audio.channel_count;
|
|
if rate <= 0 || channels <= 0 || audio.data.is_empty() {
|
|
eprintln!("error: render: audio buffer is empty");
|
|
return EXIT_RENDER_UNAVAILABLE;
|
|
}
|
|
let samples = (audio.data.len() / channels as usize) as i64;
|
|
let path = std::path::Path::new(out_dir).join("audio.wav");
|
|
if let Err(e) = wav::write_wav(&path, rate, channels, samples, &audio.data) {
|
|
eprintln!("error: render: cannot write \"{}\": {e}", path.display());
|
|
return EXIT_ERROR;
|
|
}
|
|
EXIT_OK
|
|
}
|