Files
oak-editor/src/oakui/renderops.rs
T
Mike-Solar 6dc8285f2a fix(app): probed stream indices for original media, central modal-defer, one less onscreen copy
- preview_footage_media decodes the original from the footage's actual
  first stream of the kind instead of hardcoded 0/1, fixing audio-first
  and other atypical stream layouts (with a unit test).
- spawn_modal now probes for a nested window update and defers the
  build instead of silently dropping the dialog — the phase-7
  Preferences/Action Search fix applied centrally to every modal
  (export, proxy settings, project manager, progress dialogs).
- The gpui_wgpu atlas no longer double-copies identity-format uploads,
  leaving a single CPU staging copy (gpui RenderImage ownership) plus
  the GPU upload on the onscreen path; the residual copy and the
  IOSurface route to true zero-copy are documented in the M15 design.
2026-08-19 01:19:52 +08:00

1040 lines
35 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/>.
//! Montage resolution, ticket rendering and the export driver (M14 R3).
//!
//! The facade's renderer box (`crates/oakengine/src/render.rs`: geometry
//! validation, `build_video_montage` / `build_audio_montage`,
//! `clip_media`) and its export path (`oakengine_task_create_export` +
//! `oakengine_task_start_sync` + the task subscription) are app-side now:
//! the montage builders read the oaknode graph directly and the renders
//! go through the oakrender ticket arena, exactly like the CLI's
//! `engine.rs` (M14 R2). The export drives `oaktask::export::ExportTask`
//! synchronously on a background thread with the module task's event
//! listener and cancel atom wired to the app's [`ExportSession`].
use std::sync::mpsc;
use gpui::RenderImage;
use oakcore_rs::{Rational, TimeRange};
use oaknode::id::NodeId;
use oaknode::track::TrackType;
use oakrender::manager::RenderManager;
use oakrender::procpool::ShmFrameRef;
use oakrender::texture::Texture;
use oakrender::ticket::{AudioTicketParams, MontageClip, TicketPayload, VideoTicketParams};
use super::engine::{ExportEvent, ExportSession};
use super::frames::{bgra_bytes_to_render_image, f32_rgba_to_bgra_image};
use super::graphops::{
clip_behavior, lock, sequence_behavior, track_behavior, track_list_behavior, ProjectRef,
};
use super::scopes::{analyze_bgra8, analyze_f32_rgba, ScopeData};
/// The pixel format the viewers render in (`oakcore_rs::PixelFormat::F32`,
/// the pipeline's internal format; the app downconverts to BGRA itself).
pub const PIXEL_FORMAT_F32: i32 = 4;
/// The BGRA8 slot wire format the process backend renders into (M15 S2):
/// the worker converts its F32 pipeline output to BGRA8 at the end of the
/// render, so the main process reads display-ready bytes from the slot.
pub const SLOT_FORMAT_BGRA8: i32 = oakrender::ipc::SLOT_FORMAT_BGRA8;
// ---------------------------------------------------------------------------
// Render manager
// ---------------------------------------------------------------------------
/// Bring up the process-wide render manager if it is not running yet
/// (without it the ticket arena rejects submissions). Returns false when
/// the manager could not be started.
pub fn ensure_render_manager() -> bool {
if RenderManager::global().is_some() {
return true;
}
// Already-initialized is success (the module reports State for a
// second init).
let _ = RenderManager::init();
RenderManager::global().is_some()
}
// ---------------------------------------------------------------------------
// Montage resolution (the facade's build_video_montage / build_audio_montage
// over the module graph)
// ---------------------------------------------------------------------------
/// Whether preview decoding may substitute proxy media: the global
/// `UseProxyMedia` config switch (C++ `Tools > Use Proxy Media`; the
/// export path never consults it — exports always decode the original).
pub fn use_proxy_media() -> bool {
oakcommon::configstore::ConfigStore::instance().get_bool(None, "UseProxyMedia", 1) != 0
}
/// The preview media of a footage node with the three-level proxy switch
/// applied (C++ `Footage::value` proxy arms + `FootageJob::should_use_proxy`):
/// the global switch, the footage's proxy-enabled flag and an on-disk
/// `Ready` proxy all have to line up, otherwise the original is returned.
/// Video decodes from the proxy's stream 0 (the proxy's only video
/// stream); audio from stream 1 when the proxy was generated with audio
/// (the first source audio stream's rank + 1). A missing proxy file falls
/// back to the original.
pub fn preview_footage_media(
f: &oaknode::footage::FootageBehavior,
is_video: bool,
) -> (String, i32) {
// The original media decodes from the footage's actual first stream of
// the kind (C++ maps per-stream); the 0/1 fallbacks cover footage
// whose streams were never probed (legacy project files).
let original_stream = f
.streams
.iter()
.find(|s| s.is_video == is_video)
.map(|s| s.index)
.unwrap_or(if is_video { 0 } else { 1 });
let original = (f.filename.clone(), original_stream);
if !use_proxy_media() || !f.proxy_enabled || f.proxy.is_empty() {
return original;
}
if oakcodec::proxymanager::ProxyManager::get_proxy_state(&f.proxy)
!= oakcodec::proxymanager::ProxyState::Ready
{
return original;
}
if is_video {
// The proxy stands in for the footage's first video stream only
// (C++ matches proxy_video_stream_index against the stream index).
let first_video = f.streams.iter().find(|s| s.is_video);
match first_video {
Some(stream) if f.proxy_video_stream_index == stream.index => (f.proxy.clone(), 0),
_ => original,
}
} else if oakcodec::proxymanager::ProxyManager::proxy_filename_has_audio(&f.proxy) {
(f.proxy.clone(), 1)
} else {
original
}
}
/// The preview media feeding a clip: the clip's footage with the proxy
/// switch applied ([`preview_footage_media`]).
fn clip_preview_media(
g: &oaknode::graph::Graph,
block_id: NodeId,
is_video: bool,
) -> Option<(String, i32)> {
let footage_id = super::graphops::find_input_footage(g, block_id)?;
let f = super::graphops::footage_behavior(g, footage_id)?;
if f.filename.is_empty() {
return None;
}
Some(preview_footage_media(f, is_video))
}
/// The video montage at sequence time `time`: every clip covering `time`
/// on video tracks, ordered bottom-to-top (track index 0 is topmost, so
/// it is composited last). Hidden tracks (the muted flag doubles as the
/// video visibility toggle, Olive parity) contribute nothing.
pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<MontageClip> {
let g = lock(p);
let mut clips = Vec::new();
let Some(s) = sequence_behavior(&g.graph, seq) else {
return clips;
};
for &list_id in &s.track_lists {
let Some(list) = track_list_behavior(&g.graph, list_id) else {
continue;
};
if list.kind != TrackType::Video {
continue;
}
for &track_id in list.tracks.iter().rev() {
let Some(track) = track_behavior(&g.graph, track_id) else {
continue;
};
if track.muted {
continue;
}
for &block_id in &track.blocks {
let Some(clip) = clip_behavior(&g.graph, block_id) else {
continue;
};
let in_ = clip.core.in_();
let out = clip.core.out();
if time < in_ || time >= out {
continue;
}
let Some((filename, stream_index)) =
clip_preview_media(&g.graph, block_id, true)
else {
continue;
};
clips.push(MontageClip {
filename,
stream_index,
in_time: in_,
out_time: out,
media_in: clip.core.media_in,
gain: 1.0,
});
}
}
}
clips
}
/// The video montage at sequence time `time` containing ONLY the clip on
/// `track` (a track of the sequence's video track list), if any covers
/// `time`. This is the multicam angle render: each angle is the source
/// sequence's track `i` at the playhead, so the montage carries just that
/// track's clip instead of the whole stack.
pub fn single_track_video_montage(
p: &ProjectRef,
seq: NodeId,
track: NodeId,
time: Rational,
) -> Vec<MontageClip> {
let g = lock(p);
let mut clips = Vec::new();
let Some(s) = sequence_behavior(&g.graph, seq) else {
return clips;
};
// The track must belong to the sequence's video track list.
let in_list = s.track_lists.iter().any(|&list_id| {
track_list_behavior(&g.graph, list_id)
.map(|l| l.kind == TrackType::Video && l.tracks.contains(&track))
.unwrap_or(false)
});
if !in_list {
return clips;
}
let Some(track) = track_behavior(&g.graph, track) else {
return clips;
};
if track.muted {
return clips;
}
for &block_id in &track.blocks {
let Some(clip) = clip_behavior(&g.graph, block_id) else {
continue;
};
let in_ = clip.core.in_();
let out = clip.core.out();
if time < in_ || time >= out {
continue;
}
let Some((filename, stream_index)) = clip_preview_media(&g.graph, block_id, true) else {
continue;
};
clips.push(MontageClip {
filename,
stream_index,
in_time: in_,
out_time: out,
media_in: clip.core.media_in,
gain: 1.0,
});
}
clips
}
/// Build the video ticket params for one multicam angle: the clip on
/// `track` (a video track of `seq`, the multicam's source sequence) at the
/// playhead timestamp `frame_ts`.
pub fn multicam_angle_frame_params(
p: &ProjectRef,
seq: NodeId,
track: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<VideoTicketParams, String> {
validate_geometry(width, height, tb)?;
let time = Rational::new(frame_ts * tb.0, tb.1);
Ok(VideoTicketParams {
viewer: seq.identity(),
time,
force_size: Some((width, height)),
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: None,
montage: single_track_video_montage(p, seq, track, time),
})
}
/// Render one multicam angle frame (the clip on `track` of the source
/// sequence at `frame_ts`) into a `(width, height)` frame.
pub fn render_multicam_angle_frame(
p: &ProjectRef,
seq: NodeId,
track: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<RenderedFrame, String> {
render_video(multicam_angle_frame_params(p, seq, track, frame_ts, tb, width, height)?)
}
/// The audio montage over `range`: every audio clip overlapping the
/// range, media times resolved from the clip ranges, audio stream 1.
/// Muted tracks are silenced (skipped entirely).
pub fn audio_montage(p: &ProjectRef, seq: NodeId, range: TimeRange) -> Vec<MontageClip> {
let g = lock(p);
let mut clips = Vec::new();
let Some(s) = sequence_behavior(&g.graph, seq) else {
return clips;
};
for &list_id in &s.track_lists {
let Some(list) = track_list_behavior(&g.graph, list_id) else {
continue;
};
if list.kind != TrackType::Audio {
continue;
}
for &track_id in &list.tracks {
let Some(track) = track_behavior(&g.graph, track_id) else {
continue;
};
if track.muted {
continue;
}
for &block_id in &track.blocks {
let Some(clip) = clip_behavior(&g.graph, block_id) else {
continue;
};
let in_ = clip.core.in_();
let out = clip.core.out();
if out <= range.in_() || in_ >= range.out() {
continue;
}
let Some((filename, stream_index)) =
clip_preview_media(&g.graph, block_id, false)
else {
continue;
};
clips.push(MontageClip {
filename,
stream_index,
in_time: in_,
out_time: out,
media_in: clip.core.media_in,
gain: 1.0,
});
}
}
}
clips
}
// ---------------------------------------------------------------------------
// Ticket rendering
// ---------------------------------------------------------------------------
/// A rendered frame's pixel payload (M15 S2): a process-backend shm slot
/// (BGRA8, zero-copy read) or an in-process F32 CPU frame (the test-only
/// inline backend).
pub enum RenderedFrame {
/// Process backend: a BGRA8 frame in a worker's shared-memory slot.
/// Read with `shm.slot_bytes(slot)` (no counted copy on the preview
/// path), build the display image, then release the slot through the
/// render manager.
Shm(ShmFrameRef),
/// In-process (test) backend: an F32 RGBA CPU frame.
CpuF32 {
/// Width in pixels.
width: i32,
/// Height in pixels.
height: i32,
/// Bytes per scanline (stride).
linesize: i32,
/// Pixel data (at least `linesize * height` bytes).
data: Vec<u8>,
},
}
impl RenderedFrame {
/// Width in pixels.
pub fn width(&self) -> i32 {
match self {
RenderedFrame::Shm(f) => f.meta.width,
RenderedFrame::CpuF32 { width, .. } => *width,
}
}
/// Height in pixels.
pub fn height(&self) -> i32 {
match self {
RenderedFrame::Shm(f) => f.meta.height,
RenderedFrame::CpuF32 { height, .. } => *height,
}
}
/// The pixel format: the BGRA8 slot wire format for the shm variant,
/// `PIXEL_FORMAT_F32` for the in-process variant.
pub fn format(&self) -> i32 {
match self {
RenderedFrame::Shm(_) => SLOT_FORMAT_BGRA8,
RenderedFrame::CpuF32 { .. } => PIXEL_FORMAT_F32,
}
}
/// True for the process-backend shm variant.
pub fn is_shm(&self) -> bool {
matches!(self, RenderedFrame::Shm(_))
}
/// Build the viewer display image plus the scope samples (M15 S2
/// zero-copy onscreen path). For the shm variant the slot's BGRA8
/// bytes are wrapped into the display buffer — the GPU-upload staging
/// copy, the single permitted main-process copy on the preview path
/// (design §3.5). The caller releases the slot afterwards.
pub fn to_display(&self) -> Option<(RenderImage, ScopeData)> {
match self {
RenderedFrame::Shm(f) => {
let meta = &f.meta;
let (w, h) = (meta.width.max(0) as u32, meta.height.max(0) as u32);
let pixels = f.shm.slot_bytes(f.slot);
let data = pixels.get(..meta.data_size.max(0) as usize)?;
let image = bgra_bytes_to_render_image(w, h, data)?;
let scope = analyze_bgra8(w, h, data);
Some((image, scope))
}
RenderedFrame::CpuF32 {
width,
height,
linesize,
data,
} => {
let (w, h) = ((*width).max(0) as u32, (*height).max(0) as u32);
let samples = repack_f32_rows(*width, *height, *linesize, data)?;
Some((
f32_rgba_to_bgra_image(w, h, &samples),
analyze_f32_rgba(w, h, &samples),
))
}
}
}
}
/// Repack one F32 RGBA rendered frame (rows padded to `linesize`) into
/// tightly packed samples. Returns `(width, height, samples)`-style
/// samples only; geometry is validated by the caller.
fn repack_f32_rows(width: i32, height: i32, linesize: i32, data: &[u8]) -> Option<Vec<f32>> {
if width <= 0 || height <= 0 {
return None;
}
let row_bytes = (width * 4 * 4) as usize;
let linesize = (linesize as usize).max(row_bytes);
if data.len() < linesize * height as usize {
return None;
}
let mut samples = vec![0.0f32; (width * height * 4) as usize];
for y in 0..height as usize {
let row = &data[y * linesize..y * linesize + row_bytes];
for (i, px) in row.chunks_exact(4).enumerate() {
let v = f32::from_ne_bytes([px[0], px[1], px[2], px[3]]);
samples[y * (width as usize) * 4 + i] = v;
}
}
Some(samples)
}
/// The renderer geometry / format validation (the facade's
/// `make_renderer_box` argument checks).
fn validate_geometry(width: i32, height: i32, tb: (i64, i64)) -> Result<(), String> {
if width <= 0 || height <= 0 || tb.0 <= 0 || tb.1 <= 0 {
return Err("invalid render geometry or frame rate".to_string());
}
Ok(())
}
/// Drive one video ticket synchronously (M15 S2: returns the payload
/// variant without copying frame bytes — the shm slot is read zero-copy
/// by the caller and released after building the display image).
fn render_video(params: VideoTicketParams) -> Result<RenderedFrame, String> {
let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?;
let id = m.tickets.next_id();
m.tickets.submit_video_with_id(id, params, Box::new(|_| {}));
m.tickets.wait(id).map_err(|e| e.to_string())?;
let result = m
.tickets
.result(id)
.ok_or_else(|| "render ticket produced no result".to_string())?;
match &result {
Ok(TicketPayload::Video(Texture::Cpu(frame))) => Ok(RenderedFrame::CpuF32 {
width: frame.width,
height: frame.height,
linesize: frame.linesize_bytes() as i32,
data: frame.data.clone(),
}),
Ok(TicketPayload::ShmFrame(frame)) => Ok(RenderedFrame::Shm(frame.clone())),
Ok(TicketPayload::Video(_)) => Err("render produced a non-CPU frame".to_string()),
_ => Err("render produced no video frame".to_string()),
}
}
/// Build the video ticket params for one sequence-montage frame (M15 S2:
/// shared by the synchronous render and the playback pre-render window).
pub fn sequence_frame_params(
p: &ProjectRef,
seq: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<VideoTicketParams, String> {
validate_geometry(width, height, tb)?;
let time = Rational::new(frame_ts * tb.0, tb.1);
Ok(VideoTicketParams {
viewer: seq.identity(),
time,
force_size: Some((width, height)),
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: None,
montage: video_montage(p, seq, time),
})
}
/// Render one frame of the sequence's montage at `frame_ts` (a timestamp
/// in the `(tb.1 / tb.0)`-per-frame timebase) into a `(width, height)`
/// frame.
pub fn render_sequence_frame(
p: &ProjectRef,
seq: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<RenderedFrame, String> {
render_video(sequence_frame_params(p, seq, frame_ts, tb, width, height)?)
}
/// Build the video ticket params for one single-footage frame (M15 S2:
/// shared by the synchronous render and the source-monitor pre-render
/// window).
pub fn footage_frame_params(
p: &ProjectRef,
footage: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<VideoTicketParams, String> {
validate_geometry(width, height, tb)?;
let (filename, stream_index) = {
let g = lock(p);
super::graphops::footage_behavior(&g.graph, footage)
.map(|f| preview_footage_media(f, true))
.ok_or_else(|| "the node is not footage".to_string())?
};
let time = Rational::new(frame_ts * tb.0, tb.1);
Ok(VideoTicketParams {
viewer: footage.identity(),
time,
force_size: Some((width, height)),
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: Some((filename, stream_index)),
montage: Vec::new(),
})
}
/// Render one frame of a single footage node (the source monitor) at
/// `frame_ts`, decoded straight from the media file.
pub fn render_footage_frame(
p: &ProjectRef,
footage: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<RenderedFrame, String> {
render_video(footage_frame_params(p, footage, frame_ts, tb, width, height)?)
}
/// Rendered interleaved f32 audio (the module audio ticket payload).
pub struct RenderedAudio {
/// Interleaved samples (`frame_count * channel_count` values).
pub data: Vec<f32>,
/// Sample rate (Hz).
pub sample_rate: i32,
/// Channel count.
pub channel_count: i32,
}
/// Render the audio range `[start_ts, start_ts + len_ts)` (frame
/// timestamps in the `(tb.1 / tb.0)`-per-frame timebase) as interleaved
/// f32 at 48 kHz stereo (the facade's `oakengine_renderer_render_audio`
/// defaults; uncovered parts are silent).
pub fn render_audio_range(
p: &ProjectRef,
seq: NodeId,
start_ts: i64,
len_ts: i64,
tb: (i64, i64),
) -> Result<RenderedAudio, String> {
if tb.0 <= 0 || tb.1 <= 0 {
return Err("the sequence has no valid frame rate".to_string());
}
let range = TimeRange::new(
Rational::new(start_ts * tb.0, tb.1),
Rational::new((start_ts + len_ts) * tb.0, tb.1),
);
let montage = audio_montage(p, seq, range);
let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?;
let id = m.tickets.next_id();
m.tickets.submit_audio_with_id(
id,
AudioTicketParams {
viewer: seq.identity(),
range,
sample_rate: 48000,
channel_layout: 0x3,
montage,
},
Box::new(|_| {}),
);
m.tickets.wait(id).map_err(|e| e.to_string())?;
let result = m
.tickets
.result(id)
.ok_or_else(|| "audio ticket produced no result".to_string())?;
match result {
Ok(TicketPayload::Audio(samples)) => Ok(RenderedAudio {
data: samples.samples,
sample_rate: samples.sample_rate,
channel_count: samples.channel_count,
}),
// M15 S3 process backend: the audio sits in a worker shm slot; copy
// the samples out and release the slot (the bytes must outlive it).
Ok(TicketPayload::ShmAudio(audio)) => {
let samples = audio.to_audio_samples();
if let Some(m) = RenderManager::global() {
m.release_audio_frame(&audio);
}
Ok(RenderedAudio {
data: samples.samples,
sample_rate: samples.sample_rate,
channel_count: samples.channel_count,
})
}
_ => Err("audio render produced no samples".to_string()),
}
}
/// Submit one audio-chunk render for the async playback prefetch (M15
/// S3): the chunk `[start_ts, start_ts + len_ts)` is rendered through the
/// process dispatcher; the completion copies the samples out of the shm
/// slot, releases it and sends `(start_ts, samples)` on `tx`. Render
/// errors send silence of the expected length so the playback buffer stays
/// aligned (the real-time path must never stall the UI thread on a worker).
pub fn submit_audio_chunk(
p: &ProjectRef,
seq: NodeId,
start_ts: i64,
len_ts: i64,
tb: (i64, i64),
tx: mpsc::Sender<(i64, RenderedAudio)>,
) -> Result<(), String> {
let range = TimeRange::new(
Rational::new(start_ts * tb.0, tb.1),
Rational::new((start_ts + len_ts) * tb.0, tb.1),
);
let montage = audio_montage(p, seq, range);
let sample_rate = 48000;
let channel_layout = 0x3u64;
let channels = channel_layout.count_ones().max(1) as i32;
let m = RenderManager::global().ok_or_else(|| "render manager is not initialized".to_string())?;
let id = m.tickets.next_id();
m.tickets.submit_audio_with_id(
id,
AudioTicketParams {
viewer: seq.identity(),
range,
sample_rate,
channel_layout,
montage,
},
Box::new(move |result| {
let data = match result {
Ok(TicketPayload::Audio(samples)) => RenderedAudio {
data: samples.samples,
sample_rate: samples.sample_rate,
channel_count: samples.channel_count,
},
Ok(TicketPayload::ShmAudio(audio)) => {
let samples = audio.to_audio_samples();
if let Some(m) = RenderManager::global() {
m.release_audio_frame(&audio);
}
RenderedAudio {
data: samples.samples,
sample_rate: samples.sample_rate,
channel_count: samples.channel_count,
}
}
_ => {
// Silence of the expected length keeps the output buffer
// aligned (an underrun here just plays zeros).
let seconds = len_ts as f64 * tb.0 as f64 / tb.1 as f64;
let frames = (seconds * sample_rate as f64).round() as usize;
RenderedAudio {
data: vec![0.0; frames * channels as usize],
sample_rate,
channel_count: channels,
}
}
};
let _ = tx.send((start_ts, data));
}),
);
Ok(())
}
// ---------------------------------------------------------------------------
// Export (the facade's `oakengine_task_create_export` + sync run over the
// module export task)
// ---------------------------------------------------------------------------
/// The export audio defaults (the facade's): 48 kHz stereo.
const EXPORT_SAMPLE_RATE: i32 = 48000;
/// Stereo channel-layout bitmask (`OLIVE_CHANNEL_LAYOUT_STEREO`).
const EXPORT_CHANNEL_LAYOUT: u64 = 0x3;
/// Build the export encoding params for `seq` from the app's export
/// dialog inputs: the container `format`, the target `path`, and the
/// work-area range when enabled (frame timestamps). The codecs are the
/// format's first video/audio codecs (the facade's
/// `oakengine_encoding_format_*_codec_at(format, 0)`).
pub fn encoding_params(
p: &ProjectRef,
seq: NodeId,
format: i32,
path: &std::path::Path,
workarea: Option<(i64, i64)>,
length_frames: i64,
) -> Result<oaktask::export::EncodingParams, String> {
let container = oakcodec::exportformat::Format::from_i32(format)
.ok_or_else(|| format!("unknown export format {format}"))?;
let video_codec = oakcodec::exportformat::Format::get_video_codecs(container)
.first()
.copied()
.ok_or_else(|| format!("format {format} has no video codec"))?;
let audio_codec = oakcodec::exportformat::Format::get_audio_codecs(container)
.first()
.copied()
.ok_or_else(|| format!("format {format} has no audio codec"))?;
let (width, height, rate) = {
let g = lock(p);
super::graphops::sequence_video_params(&g.graph, seq)
.ok_or_else(|| "the sequence has no video parameters".to_string())?
};
let rate_num = rate.numerator().max(1) as i32;
let rate_den = rate.denominator().max(1) as i32;
// Export range: the work area when enabled, otherwise the whole
// sequence. Frames -> seconds rationals in the sequence's frame-rate
// timebase (frame duration = rate_den / rate_num).
let (has_custom_range, range_in, range_out, length) =
match workarea.filter(|(s, e)| e > s) {
Some((in_ts, out_ts)) => (
true,
Rational::new(in_ts * i64::from(rate_den), i64::from(rate_num)),
Rational::new(out_ts * i64::from(rate_den), i64::from(rate_num)),
Rational::new((out_ts - in_ts) * i64::from(rate_den), i64::from(rate_num)),
),
None => (
false,
Rational::new(0, 1),
Rational::new(0, 1),
Rational::new(length_frames.max(0) * i64::from(rate_den), i64::from(rate_num)),
),
};
Ok(oaktask::export::EncodingParams {
filename: path.to_string_lossy().into_owned(),
format,
video_enabled: true,
video_codec: video_codec as i32,
video_width: width.max(1),
video_height: height.max(1),
video_time_base_num: rate_den,
video_time_base_den: rate_num,
video_pixel_format: 0,
audio_enabled: true,
audio_codec: audio_codec as i32,
audio_sample_rate: EXPORT_SAMPLE_RATE,
audio_channel_layout: EXPORT_CHANNEL_LAYOUT,
subtitles_enabled: false,
export_length_num: length.numerator() as i32,
export_length_den: length.denominator() as i32,
has_custom_range,
custom_range_in_num: range_in.numerator() as i32,
custom_range_in_den: range_in.denominator() as i32,
custom_range_out_num: range_out.numerator() as i32,
custom_range_out_den: range_out.denominator() as i32,
})
}
/// Start an export of `seq` with `params` on a background thread; the
/// returned session carries the event channel and the cancel handle (the
/// facade's export task wiring over the module task's event listener and
/// cancel atom).
pub fn spawn_export(
p: &ProjectRef,
seq: NodeId,
params: oaktask::export::EncodingParams,
) -> ExportSession {
let (tx, rx) = mpsc::channel::<ExportEvent>();
let mut driver = oaktask::task::Task::new("Exporting...", None);
let cancel_atom = driver.get_cancel_atom();
{
let tx = tx.clone();
driver.set_event_listener(Box::new(move |event| {
let event = match event {
oaktask::task::TaskEvent::Started => ExportEvent::Started,
oaktask::task::TaskEvent::Progress(value) => ExportEvent::Progress(value),
// Finished is reported by the worker below (with the error).
oaktask::task::TaskEvent::Finished => return,
};
let _ = tx.send(event);
}));
}
driver.set_behavior(Box::new(oaktask::export::ExportTask::new(
(p.clone(), seq),
params,
)));
std::thread::spawn(move || {
let result = driver.start();
let error = if result.is_ok() {
String::new()
} else {
driver
.error()
.map(|s| s.to_string())
.unwrap_or_else(|| "export failed".to_string())
};
let _ = tx.send(ExportEvent::Finished(result.is_ok(), error));
});
ExportSession {
events: rx,
cancel: Box::new(move || cancel_atom.cancel()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oakui::graphops;
/// Serializes the media/FFmpeg-heavy tests (the codec library is not
/// thread-safe against concurrent decode sessions) and shares the
/// process-global undo stack with the other app test modules.
fn media_lock() -> std::sync::MutexGuard<'static, ()> {
crate::oakui::graphops::test_lock()
}
/// A project with an HD sequence carrying one clip of the generated
/// media on its video track.
fn project_with_clip(media: &std::path::Path) -> (ProjectRef, NodeId, NodeId) {
let project = graphops::create_project();
let seq = graphops::create_sequence(&project, "Montage Test");
let footage = graphops::import_footage(&project, media).expect("import the generated media");
graphops::add_track(&project, seq, TrackType::Video).expect("add a video track");
graphops::place_footage_clip(&project, seq, footage, TrackType::Video, 0, 0, 10, 0)
.expect("place the clip");
(project, seq, footage)
}
#[test]
fn video_montage_covers_the_clip_range() {
let _media = media_lock();
let media = std::env::temp_dir().join(format!("oakapp_montage_{}.mp4", std::process::id()));
oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media");
let (project, seq, _) = project_with_clip(&media);
let tb = graphops::sequence_time_base(&lock(&project).graph, seq).unwrap();
let at = |frame: i64| graphops::ts_to_rational(frame, tb);
let montage = video_montage(&project, seq, at(0));
assert_eq!(montage.len(), 1, "one clip covers frame 0");
assert_eq!(montage[0].filename, media.to_string_lossy());
assert!(video_montage(&project, seq, at(9)).len() == 1, "frame 9 is still covered");
assert!(
video_montage(&project, seq, at(10)).is_empty(),
"frame 10 is past the clip's out point"
);
assert!(
video_montage(&project, seq, at(-1)).is_empty(),
"a negative time covers nothing"
);
oakundo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
/// The original media decodes from the footage's actual first stream of
/// the kind (not the hardcoded 0/1 of a typical layout): a file whose
/// video stream is not stream 0 must still decode its own video when
/// the proxy switch is off or the proxy is not ready.
#[test]
fn preview_media_uses_the_probed_stream_indices() {
let stream = |index: i32, is_video: bool| oaknode::footage::StreamInfo {
index,
is_video,
video: None,
audio: None,
duration: oakcore_rs::Rational::new(0, 1),
};
// An audio-first container: audio at 0, video at 1… plus a second
// audio track at 2. The video must come from stream 1 and the audio
// from stream 0, not the legacy 0/1 assumption.
let mut f = oaknode::footage::FootageBehavior::new("/tmp/audio-first.mov");
f.streams = vec![stream(0, false), stream(1, true), stream(2, false)];
assert_eq!(preview_footage_media(&f, true).1, 1);
assert_eq!(preview_footage_media(&f, false).1, 0);
// Unprobed footage (no stream metadata) keeps the 0/1 fallbacks.
let unprobed = oaknode::footage::FootageBehavior::new("/tmp/legacy.mov");
assert_eq!(preview_footage_media(&unprobed, true).1, 0);
assert_eq!(preview_footage_media(&unprobed, false).1, 1);
}
/// The multicam angle montage ([`single_track_video_montage`]) carries
/// ONLY the clip on the requested track — the whole-stack `video_montage`
/// is the parity reference. This is the montage the angle-frame ticket
/// renders for each grid cell.
#[test]
fn single_track_montage_isolates_its_track() {
let _media = media_lock();
let media =
std::env::temp_dir().join(format!("oakapp_montage_ang_{}.mp4", std::process::id()));
oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media");
let (project, seq, footage) = project_with_clip(&media);
graphops::add_track(&project, seq, TrackType::Video).expect("add a second video track");
// A second clip on track 1 overlapping the same time.
graphops::place_footage_clip(&project, seq, footage, TrackType::Video, 1, 0, 10, 0)
.expect("place the second clip");
let tb = graphops::sequence_time_base(&lock(&project).graph, seq).unwrap();
let at = |frame: i64| graphops::ts_to_rational(frame, tb);
let tracks = {
let g = lock(&project);
graphops::track_ids(&g.graph, seq, TrackType::Video)
};
assert_eq!(tracks.len(), 2, "two video tracks");
// The whole stack sees both clips; the single-track montage sees only
// its own track's clip.
assert_eq!(video_montage(&project, seq, at(0)).len(), 2);
let track0_only = single_track_video_montage(&project, seq, tracks[0], at(0));
assert_eq!(track0_only.len(), 1, "track 0 contributes its own clip");
assert_eq!(track0_only[0].filename, media.to_string_lossy());
let track1_only = single_track_video_montage(&project, seq, tracks[1], at(0));
assert_eq!(track1_only.len(), 1, "track 1 contributes its own clip");
// A hidden video track contributes nothing (the angle's track is
// skipped, matching the full montage's muted-track rule).
graphops::set_track_muted(&project, tracks[0], true).expect("hide track 0");
assert!(
single_track_video_montage(&project, seq, tracks[0], at(0)).is_empty(),
"a hidden angle track renders nothing"
);
assert_eq!(
single_track_video_montage(&project, seq, tracks[1], at(0)).len(),
1,
"the other track is unaffected"
);
oakundo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
#[test]
fn audio_montage_overlaps_the_range() {
let _media = media_lock();
let media = std::env::temp_dir().join(format!("oakapp_montage_a_{}.mp4", std::process::id()));
oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media");
let (project, seq, footage) = project_with_clip(&media);
graphops::add_track(&project, seq, TrackType::Audio).expect("add an audio track");
graphops::place_footage_clip(&project, seq, footage, TrackType::Audio, 0, 0, 10, 0)
.expect("place the audio clip");
let tb = graphops::sequence_time_base(&lock(&project).graph, seq).unwrap();
let at = |frame: i64| graphops::ts_to_rational(frame, tb);
let overlapping = audio_montage(&project, seq, TimeRange::new(at(0), at(5)));
assert_eq!(overlapping.len(), 1, "the range overlaps the audio clip");
assert_eq!(overlapping[0].stream_index, 1, "the audio stream is selected");
let disjoint = audio_montage(&project, seq, TimeRange::new(at(10), at(20)));
assert!(disjoint.is_empty(), "a range past the clip overlaps nothing");
oakundo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
/// Hidden video tracks and muted audio tracks (both are the track's
/// `muted` flag) contribute nothing to the montages; undoing the flag
/// set restores them.
#[test]
fn montages_skip_muted_tracks() {
let _media = media_lock();
let media = std::env::temp_dir().join(format!("oakapp_montage_m_{}.mp4", std::process::id()));
oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media");
let (project, seq, footage) = project_with_clip(&media);
graphops::add_track(&project, seq, TrackType::Audio).expect("add an audio track");
graphops::place_footage_clip(&project, seq, footage, TrackType::Audio, 0, 0, 10, 0)
.expect("place the audio clip");
let (video_track, audio_track, tb) = {
let g = lock(&project);
(
graphops::track_ids(&g.graph, seq, TrackType::Video)[0],
graphops::track_ids(&g.graph, seq, TrackType::Audio)[0],
graphops::sequence_time_base(&g.graph, seq).unwrap(),
)
};
let at = |frame: i64| graphops::ts_to_rational(frame, tb);
let range = TimeRange::new(at(0), at(5));
assert_eq!(video_montage(&project, seq, at(0)).len(), 1);
assert_eq!(audio_montage(&project, seq, range).len(), 1);
graphops::set_track_muted(&project, video_track, true).expect("hide the video track");
graphops::set_track_muted(&project, audio_track, true).expect("mute the audio track");
assert!(
video_montage(&project, seq, at(0)).is_empty(),
"a hidden video track contributes nothing"
);
assert!(
audio_montage(&project, seq, range).is_empty(),
"a muted audio track contributes nothing"
);
oakundo::global::undo().unwrap();
oakundo::global::undo().unwrap();
assert_eq!(video_montage(&project, seq, at(0)).len(), 1, "undo restores the video clip");
assert_eq!(audio_montage(&project, seq, range).len(), 1, "undo restores the audio clip");
oakundo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
}