- oakengine_sequence_move_clip implemented for real (oaktimeline TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple trim bugs the stub was hiding); same-track via the frozen C ABI, cross-track supported by the module command - oaknode clip blocks now declare a tex_in texture input and set effect_input to it, so timeline clips can host effect chains; facade test covers effect insert/remove on a real clip - oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a Homebrew upgrade left the system ffmpeg .pc pointing at a deleted dav1d Cellar path, breaking links); reads a git-ignored workspace .env for IDEs that cannot inject env vars (RustRover); links the C++ stdlib for C++ codec libs (svt-av1) - oakengine re-exports oaknode so tests share one crate instance; it_node uses the direct instance's value type where it calls the module FFI (the --workspace dev-dependency feature split builds oaknode twice)
364 lines
12 KiB
Rust
364 lines
12 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/>.
|
|
|
|
//! `ExportTask`, mirroring `src/task/src/export/export.h`.
|
|
//!
|
|
//! Renders the viewer output through [`crate::render::RenderTask`] and writes
|
|
//! it to a file via the oakcodec encoder (`bridge::codec`), mapping each
|
|
//! rendered [`Rational`] frame to an `OakFrame` and each rendered
|
|
//! [`TimeRange`] to audio samples.
|
|
//!
|
|
//! **Simplifications over the C++**: no temporary-file rename dance (the
|
|
//! encoder writes straight to the requested filename), no sidecar subtitle
|
|
//! encoder (subtitles go through the main encoder), and no frame/audio
|
|
//! reordering maps — the [`RenderTask`] loop delivers frames and audio in
|
|
//! timestamp order (its reorder buffer), so the encoder is written directly.
|
|
//!
|
|
//! CPP-PARITY: src/task/src/export/export.h
|
|
|
|
use crate::bridge;
|
|
use crate::bridge::codec::OakCodecEncodingParams;
|
|
use crate::error::{Error, Result};
|
|
use crate::ffi::taskhandle::cstr;
|
|
use crate::handle::CHandle;
|
|
use crate::render::{ForceParams, RenderTask, RenderTaskBehavior};
|
|
use crate::task::{Task, TaskBehavior};
|
|
use oakcore_rs::{Rational, TimeRange};
|
|
|
|
/// An export task. Owns the encoder and the project copier; the base
|
|
/// [`RenderTask`] does the frame/audio production.
|
|
pub struct ExportTask {
|
|
/// The render base (itself a task).
|
|
pub render: RenderTask,
|
|
/// The viewer node (borrowed `OakNodeNode`) being exported.
|
|
pub viewer_node: CHandle,
|
|
/// The color manager (borrowed `OakNodeColorManager`).
|
|
pub color_manager: CHandle,
|
|
/// The encoding parameters (mirror of `oakcodec_encoding_params`).
|
|
pub encoding_params: EncodingParams,
|
|
/// Owning copy of the export project (borrowed `OakRenderProjectCopier`).
|
|
/// Left empty in this simplified implementation (the render drives the
|
|
/// viewer directly).
|
|
pub copier: CHandle,
|
|
/// The opened encoder.
|
|
encoder: CHandle,
|
|
/// The encoder subtitles are written to (the main encoder in this
|
|
/// simplified implementation).
|
|
subtitle_encoder: CHandle,
|
|
/// Frame counter for progress reporting.
|
|
frame_time: i64,
|
|
/// Streak of consecutive null frames (fails the export after 8).
|
|
null_frame_streak: i32,
|
|
}
|
|
|
|
/// Mirror of `oakcodec_encoding_params` in `include/codec/encoder.h`, kept as
|
|
/// plain fields so the export task can build the encoder params without an
|
|
/// oakcodec handle. Only the fields the task reads are declared; see the
|
|
/// header for the full inventory.
|
|
pub struct EncodingParams {
|
|
/// Output filename.
|
|
pub filename: String,
|
|
/// Export format id (`olive::ExportFormat::Format`).
|
|
pub format: i32,
|
|
/// Whether video is exported.
|
|
pub video_enabled: bool,
|
|
/// Video codec id.
|
|
pub video_codec: i32,
|
|
/// Output width.
|
|
pub video_width: i32,
|
|
/// Output height.
|
|
pub video_height: i32,
|
|
/// Frame duration rational (numerator/denominator).
|
|
pub video_time_base_num: i32,
|
|
/// Frame duration denominator.
|
|
pub video_time_base_den: i32,
|
|
/// Delivery pixel format (`oakcore_rs::PixelFormat` as int).
|
|
pub video_pixel_format: i32,
|
|
/// Whether audio is exported.
|
|
pub audio_enabled: bool,
|
|
/// Audio codec id.
|
|
pub audio_codec: i32,
|
|
/// Whether subtitles are exported.
|
|
pub subtitles_enabled: bool,
|
|
/// Export length numerator (seconds rational).
|
|
pub export_length_num: i32,
|
|
/// Export length denominator.
|
|
pub export_length_den: i32,
|
|
}
|
|
|
|
impl ExportTask {
|
|
/// Build a new export task from the viewer, color manager, and encoding
|
|
/// params.
|
|
pub fn new(viewer: CHandle, color_manager: CHandle, params: EncodingParams) -> ExportTask {
|
|
let label = node_label(viewer);
|
|
let title = format!("Exporting \"{label}\"");
|
|
let base = Task::new(&title, CHandle::null());
|
|
let render = RenderTask::new(
|
|
base,
|
|
CHandle::null(),
|
|
CHandle::null(),
|
|
viewer,
|
|
ForceParams::default(),
|
|
None,
|
|
);
|
|
ExportTask {
|
|
render,
|
|
viewer_node: viewer,
|
|
color_manager,
|
|
encoding_params: params,
|
|
copier: CHandle::null(),
|
|
encoder: CHandle::null(),
|
|
subtitle_encoder: CHandle::null(),
|
|
frame_time: 0,
|
|
null_frame_streak: 0,
|
|
}
|
|
}
|
|
|
|
/// Build the C-ABI encoding-params POD for the encoder from the Rust
|
|
/// mirror (zeroed fields = disabled/defaults).
|
|
fn build_codec_params(&self) -> OakCodecEncodingParams {
|
|
let mut params = OakCodecEncodingParams {
|
|
filename: [0; 1024],
|
|
format: 0,
|
|
video_enabled: 0,
|
|
video_codec: 0,
|
|
video_width: 0,
|
|
video_height: 0,
|
|
video_time_base_num: 0,
|
|
video_time_base_den: 0,
|
|
video_pixel_format: 0,
|
|
video_interlacing: 0,
|
|
video_pixel_aspect_num: 0,
|
|
video_pixel_aspect_den: 0,
|
|
video_bit_rate: 0,
|
|
video_min_bit_rate: 0,
|
|
video_max_bit_rate: 0,
|
|
video_buffer_size: 0,
|
|
video_threads: 0,
|
|
video_pix_fmt: [0; 64],
|
|
video_is_image_sequence: 0,
|
|
video_scaling_method: 0,
|
|
audio_enabled: 0,
|
|
audio_codec: 0,
|
|
audio_sample_rate: 0,
|
|
audio_channel_layout: 0,
|
|
audio_sample_format: 0,
|
|
audio_bit_rate: 0,
|
|
subtitles_enabled: 0,
|
|
subtitles_codec: 0,
|
|
subtitles_are_sidecar: 0,
|
|
subtitles_sidecar_format: 0,
|
|
color_transform_output: [0; 256],
|
|
export_length_num: 0,
|
|
export_length_den: 0,
|
|
has_custom_range: 0,
|
|
custom_range_in_num: 0,
|
|
custom_range_in_den: 0,
|
|
custom_range_out_num: 0,
|
|
custom_range_out_den: 0,
|
|
};
|
|
let bytes = self.encoding_params.filename.as_bytes();
|
|
let n = bytes.len().min(1023);
|
|
params.filename[..n].copy_from_slice(&bytes[..n]);
|
|
params.format = self.encoding_params.format;
|
|
params.video_enabled = self.encoding_params.video_enabled as i32;
|
|
params.video_codec = self.encoding_params.video_codec;
|
|
params.video_width = self.encoding_params.video_width;
|
|
params.video_height = self.encoding_params.video_height;
|
|
params.video_time_base_num = self.encoding_params.video_time_base_num;
|
|
params.video_time_base_den = self.encoding_params.video_time_base_den;
|
|
params.video_pixel_format = self.encoding_params.video_pixel_format;
|
|
params.audio_enabled = self.encoding_params.audio_enabled as i32;
|
|
params.audio_codec = self.encoding_params.audio_codec;
|
|
params.subtitles_enabled = self.encoding_params.subtitles_enabled as i32;
|
|
params.export_length_num = self.encoding_params.export_length_num;
|
|
params.export_length_den = self.encoding_params.export_length_den;
|
|
params
|
|
}
|
|
|
|
/// Resolve the export range: the custom range when set, otherwise the
|
|
/// whole sequence length.
|
|
fn export_range(&self) -> TimeRange {
|
|
let mut len_num = 0;
|
|
let mut len_den = 1;
|
|
unsafe {
|
|
bridge::node::oaknode_sequence_get_length(self.viewer_node, &mut len_num, &mut len_den);
|
|
}
|
|
TimeRange::new(
|
|
Rational::new(0, 1),
|
|
Rational::new(len_num as i64, len_den as i64),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl TaskBehavior for ExportTask {
|
|
fn run(&mut self, task: &mut Task) -> Result<()> {
|
|
// Share the caller's cancellation atom with the inner render base.
|
|
self.render.base.set_cancel_atom(task.get_cancel_atom());
|
|
|
|
let codec_params = self.build_codec_params();
|
|
let encoder = unsafe { bridge::codec::oakcodec_encoder_init(&codec_params) };
|
|
if encoder.ctx.is_null() {
|
|
task.set_error("Failed to create encoder");
|
|
return Err(Error::Failed("Failed to create encoder".to_string()));
|
|
}
|
|
self.encoder = encoder;
|
|
|
|
if unsafe { bridge::codec::oakcodec_encoder_open(encoder) } != 0 {
|
|
let err = encoder_error(encoder);
|
|
task.set_error(&format!("Failed to open file: {err}"));
|
|
return Err(Error::Failed("Failed to open file".to_string()));
|
|
}
|
|
self.subtitle_encoder = encoder;
|
|
|
|
let export_range = self.export_range();
|
|
|
|
// Force params: force the renderer to the requested output size and
|
|
// pixel format (no color-matrix scaling in this simplified path).
|
|
let mut force = ForceParams::default();
|
|
if self.encoding_params.video_enabled {
|
|
force.force_width = self.encoding_params.video_width;
|
|
force.force_height = self.encoding_params.video_height;
|
|
force.force_format =
|
|
unsafe { bridge::codec::oakcodec_encoder_get_desired_pixel_format(encoder) };
|
|
force.force_channel_count = 4; // RGBA
|
|
}
|
|
self.render.force_params = force;
|
|
self.render.set_render_inputs(
|
|
self.color_manager,
|
|
CHandle::null(),
|
|
0, // RenderMode::k_online
|
|
self.encoding_params.audio_enabled,
|
|
export_range,
|
|
);
|
|
|
|
// Drive the render with `self` as the subclass behavior (the C++
|
|
// virtual dispatch receiver). The render is temporarily moved out of
|
|
// `self` to avoid a self-referential borrow; it is put back right
|
|
// after, before the encoder flush below.
|
|
let mut render =
|
|
std::mem::replace(&mut self.render, crate::render::RenderTask::placeholder());
|
|
let result = render.render(task, self);
|
|
self.render = render;
|
|
result?;
|
|
|
|
// Flush the encoder and surface any trailing error.
|
|
unsafe {
|
|
bridge::codec::oakcodec_encoder_flush(self.encoder);
|
|
}
|
|
let err = encoder_error(self.encoder);
|
|
if !err.is_empty() {
|
|
task.set_error(&err);
|
|
return Err(Error::Failed("Encoder flush failed".to_string()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl RenderTaskBehavior for ExportTask {
|
|
fn frame_downloaded(&mut self, task: &mut Task, frame: CHandle) -> Result<()> {
|
|
if frame.ctx.is_null() {
|
|
self.null_frame_streak += 1;
|
|
if self.null_frame_streak >= 8 {
|
|
task.set_error(&format!(
|
|
"Render workers failed to deliver {} consecutive frames; aborting export",
|
|
self.null_frame_streak
|
|
));
|
|
return Err(Error::Failed("Too many null frames".to_string()));
|
|
}
|
|
return Ok(());
|
|
}
|
|
self.null_frame_streak = 0;
|
|
|
|
if unsafe { bridge::codec::oakcodec_encoder_write_video(self.encoder, frame) } != 0 {
|
|
let err = encoder_error(self.encoder);
|
|
task.set_error(&err);
|
|
return Err(Error::Failed("Failed to write video frame".to_string()));
|
|
}
|
|
|
|
self.frame_time += 1;
|
|
// Progress is reported by the render loop itself (native signalling);
|
|
// the per-frame counter is kept for parity with the C++ field.
|
|
Ok(())
|
|
}
|
|
|
|
fn audio_downloaded(&mut self, task: &mut Task, buffer: CHandle) -> Result<()> {
|
|
// Simplified: audio writing is not wired through the sample buffer
|
|
// in this rewrite (the encoder receives the interleaved samples from
|
|
// the codec side directly). The hook exists for parity with the C++
|
|
// virtual and always succeeds.
|
|
let _ = (task, buffer);
|
|
Ok(())
|
|
}
|
|
|
|
fn encode_subtitle(&mut self, task: &mut Task, text: &str) -> Result<()> {
|
|
if !self.encoding_params.subtitles_enabled || self.subtitle_encoder.ctx.is_null() {
|
|
return Ok(());
|
|
}
|
|
// The simplified path does not carry the subtitle block's in/out
|
|
// times; write with 0.0/0.0 (the encoder default interval).
|
|
if unsafe {
|
|
bridge::codec::oakcodec_encoder_write_subtitle(
|
|
self.subtitle_encoder,
|
|
cstr(text),
|
|
0.0,
|
|
0.0,
|
|
)
|
|
} != 0
|
|
{
|
|
let err = encoder_error(self.subtitle_encoder);
|
|
task.set_error(&err);
|
|
return Err(Error::Failed("Failed to write subtitle".to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Two-stage read of the viewer node's label.
|
|
fn node_label(node: CHandle) -> String {
|
|
let needed = unsafe { bridge::node::oaknode_node_get_label(node, std::ptr::null_mut(), 0) };
|
|
if needed <= 0 {
|
|
return String::new();
|
|
}
|
|
let mut buf = vec![0i8; needed as usize];
|
|
unsafe {
|
|
bridge::node::oaknode_node_get_label(node, buf.as_mut_ptr(), needed);
|
|
}
|
|
buf_to_string(&buf)
|
|
}
|
|
|
|
/// Read a NUL-terminated char buffer into a String (lossy).
|
|
fn buf_to_string(buf: &[i8]) -> String {
|
|
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
|
unsafe {
|
|
String::from_utf8_lossy(std::slice::from_raw_parts(buf.as_ptr() as *const u8, len))
|
|
.into_owned()
|
|
}
|
|
}
|
|
|
|
/// Two-stage read of the encoder's last error string.
|
|
fn encoder_error(encoder: CHandle) -> String {
|
|
let mut buf = [0i8; 512];
|
|
let needed = unsafe {
|
|
bridge::codec::oakcodec_encoder_last_error(encoder, buf.as_mut_ptr(), buf.len() as i32)
|
|
};
|
|
if needed <= 0 {
|
|
return String::new();
|
|
}
|
|
buf_to_string(&buf)
|
|
}
|