Files
oak-editor/crates/oaktask/src/export.rs
T
Mike-Solar 92daff1a83 feat(engine,app): timeline markers, work area, cross-track move (M12 P4)
- facade: oakengine_sequence_set_workarea_undoable (enable+range as one
  undo record); marker add/remove/list already existed and are now
  covered by it_timeline (10 new tests: markers, workarea, cross-track)
- fixes: stubs workarea_get honored NULL out-params (is_enabled always
  failed); export tasks honor custom ranges (export_params_pod dropped
  them; oaktask EncodingParams carries has_custom_range)
- app: markers on the ruler (gpui diamond markers), menu
  sequence-add/remove marker, set/clear work area (selected-clip bounds
  or playhead+1), ruler drag previews live and commits one undoable
  record (C++ ruler semantics); export uses the work area when enabled
- cross-track clip moves were already wired; now covered end to end
2026-08-16 16:06:19 +08:00

368 lines
14 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 direct oakcodec encoder API
//! (`oakcodec::encoder::{create_from_params, Encoder}` — single-lib
//! unification; the old encoder C ABI is gone), mapping each rendered
//! frame to an `Encoder::write_video` call and each rendered audio buffer
//! to `Encoder::write_audio`.
//!
//! The viewer and color-manager arguments of the deleted C ABI path are
//! replaced by a single [`crate::nodeops::NodeRef`] viewer (the color
//! manager no longer crosses into the render tickets — the direct ticket
//! arena performs no color management). Rendered frames arrive as
//! `oakrender::texture::Texture` values and are converted to
//! `oakcodec::frame::Frame` values for the encoder.
//!
//! **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 std::sync::Arc;
use oakcodec::encoder::{create_from_params, Encoder};
use oakcodec::encodingparams::EncodingParams as CodecEncodingParams;
use oakcommon::videoparams::VideoParams as CommonVideoParams;
use oakrender::texture::Texture;
use crate::error::{Error, Result};
use crate::nodeops::{self, NodeRef};
use crate::render::{ForceParams, RenderTask, RenderTaskBehavior};
use crate::task::{Task, TaskBehavior};
use oakcore_rs::{Rational, TimeRange};
/// An export task. Owns the encoder; the base [`RenderTask`] does the
/// frame/audio production.
pub struct ExportTask {
/// The render base (itself a task).
pub render: RenderTask,
/// The node being exported (footage or sequence).
pub viewer_node: NodeRef,
/// The encoding parameters (mirror of the codec-side params).
pub encoding_params: EncodingParams,
/// The opened encoder (direct oakcodec trait object).
encoder: Option<Arc<dyn Encoder>>,
}
/// Mirror of the codec encoding params, kept as plain fields so the export
/// task can be configured without a codec-side handle. Only the fields the
/// task reads are declared; see `oakcodec::encodingparams::EncodingParams`
/// 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,
/// Audio sample rate (Hz) the encoder opens with.
pub audio_sample_rate: i32,
/// ffmpeg-style channel layout mask the encoder opens with.
pub audio_channel_layout: u64,
/// 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,
/// Whether a custom in/out export range is set (work-area export). When
/// set, [`ExportTask::export_range`] renders exactly `[in, out)` instead
/// of the whole viewer length.
pub has_custom_range: bool,
/// Custom range in point numerator (seconds rational).
pub custom_range_in_num: i32,
/// Custom range in point denominator.
pub custom_range_in_den: i32,
/// Custom range out point numerator (seconds rational).
pub custom_range_out_num: i32,
/// Custom range out point denominator.
pub custom_range_out_den: i32,
}
impl ExportTask {
/// Build a new export task from the viewer node and the encoding
/// params. The old `viewer: CHandle` / `color_manager: CHandle`
/// signature is replaced by the domain viewer [`NodeRef`] (single-lib
/// unification; the color manager is dropped with the C ABI — see the
/// module docs).
pub fn new(viewer: NodeRef, params: EncodingParams) -> ExportTask {
let label = nodeops::node_label(&viewer.0, viewer.1);
let title = format!("Exporting \"{label}\"");
let base = Task::new(&title, None);
// The render base needs the viewer's frame rate to step one frame
// per video frame (the export range is in sequence/footage time);
// without it the frame loop falls back to a 1/1 timebase and
// exports one frame per second (observed in the facade's
// `it_export` run).
let video_params = nodeops::sequence_video_params(&viewer.0, viewer.1, 0)
.or_else(|| nodeops::footage_video_params(&viewer.0, viewer.1, 0));
let render = RenderTask::new(base, video_params, viewer.clone(), ForceParams::default(), None);
ExportTask {
render,
viewer_node: viewer,
encoding_params: params,
encoder: None,
}
}
/// Build the codec `EncodingParams` POD for the encoder from the Rust
/// mirror (zeroed fields = disabled/defaults).
fn build_codec_params(&self) -> CodecEncodingParams {
let mut params = CodecEncodingParams::default();
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 =
crate::nodeops::pixel_format_from_code(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.audio_sample_rate = self.encoding_params.audio_sample_rate;
params.audio_channel_layout = self.encoding_params.audio_channel_layout;
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 viewer length (direct `oaknode` domain query; the deleted
/// `oaknode_sequence_get_length` stub is gone). The custom range is the
/// work-area / in-out export: `export_params_pod` copies it from the
/// facade params handle, so `oakengine_export_render_with_params` and the
/// app's work-area export render exactly `[in, out)`.
fn export_range(&self) -> TimeRange {
if self.encoding_params.has_custom_range
&& self.encoding_params.custom_range_in_den != 0
&& self.encoding_params.custom_range_out_den != 0
{
let in_ = Rational::new(
i64::from(self.encoding_params.custom_range_in_num),
i64::from(self.encoding_params.custom_range_in_den),
);
let out = Rational::new(
i64::from(self.encoding_params.custom_range_out_num),
i64::from(self.encoding_params.custom_range_out_den),
);
if out > in_ {
return TimeRange::new(in_, out);
}
}
let length = nodeops::node_length(&self.viewer_node.0, self.viewer_node.1);
TimeRange::new(Rational::new(0, 1), length)
}
/// Copy a rendered `oakrender` CPU texture into an `oakcodec` frame
/// with the matching video params (row-wise copy — line sizes may
/// differ between the render and codec frame layouts).
fn to_codec_frame(texture: &Texture) -> Result<oakcodec::frame::Frame> {
let Texture::Cpu(frame) = texture else {
return Err(Error::Failed(
"Render produced a GPU texture; the CPU encoder path cannot consume it"
.to_string(),
));
};
let params = CommonVideoParams::new_basic(
frame.width,
frame.height,
oakcommon::ocioutils::PixelFormat::from_code(frame.format as i32),
4,
1,
1,
0,
1,
);
let mut out = oakcodec::frame::Frame::with_params(params);
out.set_timestamp(frame.timestamp);
out.allocate().map_err(|e| {
Error::Failed(format!("Failed to allocate encoder frame: {e:?}"))
})?;
let dst_stride = out.linesize_bytes() as usize;
let Some(dst) = out.data_mut() else {
return Err(Error::Failed(
"Encoder frame allocation produced no buffer".to_string(),
));
};
let src = frame.data.as_slice();
let src_stride = frame.linesize_bytes();
let row_bytes = std::cmp::min(src_stride, dst_stride)
.min(src.len())
.min(dst.len());
if src_stride == dst_stride && src.len() == dst.len() {
dst.copy_from_slice(src);
} else {
for y in 0..frame.height as usize {
let src_start = y * src_stride;
let dst_start = y * dst_stride;
if src_start + row_bytes > src.len() || dst_start + row_bytes > dst.len() {
break;
}
dst[dst_start..dst_start + row_bytes]
.copy_from_slice(&src[src_start..src_start + row_bytes]);
}
}
Ok(out)
}
}
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 Some(encoder) = create_from_params(&codec_params) else {
task.set_error("Failed to create encoder");
return Err(Error::Failed("Failed to create encoder".to_string()));
};
self.encoder = Some(encoder.clone());
if let Err(e) = encoder.open() {
let err = encoder.get_error();
task.set_error(&format!("Failed to open file: {err}"));
let _ = e;
return Err(Error::Failed("Failed to open file".to_string()));
}
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 = encoder
.desired_pixel_format()
.map(|f| f as i32)
.unwrap_or(-1);
force.force_channel_count = 4; // RGBA
}
self.render.force_params = force;
self.render.set_render_inputs(
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.
if let Err(_) = encoder.flush() {
// Fall through to the error read below (the flush error is
// surfaced through `get_error()` like the C ABI did).
}
let err = encoder.get_error();
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: &Texture) -> Result<()> {
let Some(encoder) = &self.encoder else {
return Ok(());
};
let codec_frame = Self::to_codec_frame(frame)?;
if let Err(_) = encoder.write_video(&codec_frame) {
let err = encoder.get_error();
task.set_error(&err);
return Err(Error::Failed("Failed to write frame".to_string()));
}
// Progress is reported by the render loop itself (native signalling).
Ok(())
}
fn audio_downloaded(
&mut self,
task: &mut Task,
samples: &oakrender::ticket::AudioSamples,
) -> Result<()> {
let Some(encoder) = &self.encoder else {
return Ok(());
};
let frame_count = if samples.channel_count > 0 {
samples.samples.len() / samples.channel_count as usize
} else {
0
};
if let Err(_) = encoder.write_audio(&samples.samples, frame_count as i32) {
let err = encoder.get_error();
task.set_error(&err);
return Err(Error::Failed("Failed to write audio".to_string()));
}
Ok(())
}
fn encode_subtitle(&mut self, task: &mut Task, text: &str) -> Result<()> {
if !self.encoding_params.subtitles_enabled {
return Ok(());
}
let Some(encoder) = &self.encoder else {
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 let Err(_) = encoder.write_subtitle(text, 0.0, 0.0) {
let err = encoder.get_error();
task.set_error(&err);
return Err(Error::Failed("Failed to write subtitle".to_string()));
}
Ok(())
}
}