Task/manager lifecycles, precache and render boundaries, OTIO/FCPXML round trips, and the write-through/library contract tests.
1226 lines
40 KiB
Rust
1226 lines
40 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/>.
|
|
|
|
//! Boundary tests for the `RenderTask` base: empty ranges, stale viewer
|
|
//! nodes, hook errors, cancellation, forced sizes/formats, footage and
|
|
//! montage delivery (video + audio, audio first) and progress reporting.
|
|
//!
|
|
//! Renders run on the process-wide manager's test-only inline (`Threads`)
|
|
//! backend, serialized through one static mutex, so no GPU, no worker
|
|
//! processes and no sleeps are involved. Media is generated by
|
|
//! `oak_codec::testmedia`.
|
|
|
|
use std::sync::{Arc, Mutex, MutexGuard};
|
|
|
|
use oak_core::texture::Texture;
|
|
use oak_core::videoparams::VideoParams;
|
|
use oak_core::{PixelFormat, Rational, TimeRange};
|
|
use oak_node::block::ClipBlockBehavior;
|
|
use oak_node::footage::FootageBehavior;
|
|
use oak_node::id::NodeId;
|
|
use oak_node::node::NodeCore;
|
|
use oak_node::project::Project;
|
|
use oak_node::sequence::SequenceBehavior;
|
|
use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
|
|
use oak_render::manager::{RenderBackendChoice, RenderManager};
|
|
use oak_render::ticket::AudioSamples;
|
|
use oak_task::error::{Error, Result};
|
|
use oak_task::nodeops::{self, ProjectRef};
|
|
use oak_task::render::{ForceParams, RenderTask, RenderTaskBehavior};
|
|
use oak_task::task::{Task, TaskEvent};
|
|
|
|
/// The manager is process-wide and the inline backend is test-only.
|
|
static RENDER_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
/// Serialize render-manager use and make sure the inline backend is up.
|
|
fn render_serial() -> MutexGuard<'static, ()> {
|
|
let guard = RENDER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
if RenderManager::global().is_none() {
|
|
RenderManager::init_with_backend(RenderBackendChoice::Threads).expect("render manager");
|
|
}
|
|
guard
|
|
}
|
|
|
|
fn clip_path(tag: &str) -> std::path::PathBuf {
|
|
std::env::temp_dir().join(format!("oaktask_render_{tag}_{}.mp4", std::process::id()))
|
|
}
|
|
|
|
/// Locate an `oak-worker` binary for the private-dispatcher path: an
|
|
/// explicit `OAK_WORKER_BIN`, a sibling of the test executable, or the
|
|
/// workspace `target/<profile>/oak-worker`. `None` means the path is
|
|
/// unavailable in this environment (the test then skips with a reason).
|
|
fn locate_oak_worker() -> Option<std::path::PathBuf> {
|
|
if let Some(var) = std::env::var_os("OAK_WORKER_BIN") {
|
|
let path = std::path::PathBuf::from(var);
|
|
if path.is_file() {
|
|
return Some(path);
|
|
}
|
|
}
|
|
if let Some(dir) = std::env::current_exe().ok().and_then(|e| e.parent().map(|p| p.to_path_buf())) {
|
|
let path = dir.join("oak-worker");
|
|
if path.is_file() {
|
|
return Some(path);
|
|
}
|
|
}
|
|
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
for profile in ["debug", "release"] {
|
|
let path = manifest.join("../../target").join(profile).join("oak-worker");
|
|
if path.is_file() {
|
|
return Some(path);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// ---- fixture ----------------------------------------------------------
|
|
|
|
/// A project with one probed footage node (64x64, 1 s) and one sequence.
|
|
fn footage_project(media: &str, fps: i64) -> (ProjectRef, NodeId, VideoParams) {
|
|
let project = Project::new();
|
|
let (footage_id, sequence) = {
|
|
let mut p = project.lock().unwrap();
|
|
|
|
let mut footage = FootageBehavior::new(media);
|
|
footage.probe().expect("probe the generated clip");
|
|
let footage_id = p.graph.add_node(NodeCore::new(), Box::new(footage));
|
|
|
|
let (score, sbehavior) = SequenceBehavior::create();
|
|
let sequence = p.graph.add_node(score, sbehavior);
|
|
if let Some(seq) = p
|
|
.graph
|
|
.get_mut(sequence)
|
|
.and_then(|e| e.behavior.as_any_mut())
|
|
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
|
|
{
|
|
seq.video_params[0].frame_rate = Rational::new(fps, 1);
|
|
seq.video_params[0].width = 64;
|
|
seq.video_params[0].height = 64;
|
|
}
|
|
(footage_id, sequence)
|
|
};
|
|
let params = nodeops::sequence_video_params(&project, sequence, 0).expect("sequence params");
|
|
(project, footage_id, params)
|
|
}
|
|
|
|
/// A sequence with one video clip (and optionally an audio clip) of the
|
|
/// same media, both covering `[0, 1s)`. With `effect = Some(enabled)`, an
|
|
/// Opacity effect (factor 0) with that enable-state sits between the
|
|
/// footage and the clip so the effect-chain walk is exercised and its
|
|
/// effect is observable in the delivered pixels: the montage evaluator
|
|
/// applies the built-in Opacity on the CPU (unknown built-ins pass
|
|
/// through without a GPU, so those could not prove the chain ran).
|
|
fn sequence_project(
|
|
media: &str,
|
|
with_audio: bool,
|
|
effect: Option<bool>,
|
|
) -> (ProjectRef, NodeId, VideoParams) {
|
|
let project = Project::new();
|
|
let sequence = {
|
|
let mut p = project.lock().unwrap();
|
|
|
|
let mut footage = FootageBehavior::new(media);
|
|
footage.probe().expect("probe the generated clip");
|
|
let footage_id = p.graph.add_node(NodeCore::new(), Box::new(footage));
|
|
|
|
let (score, sbehavior) = SequenceBehavior::create();
|
|
let sequence = p.graph.add_node(score, sbehavior);
|
|
if let Some(seq) = p
|
|
.graph
|
|
.get_mut(sequence)
|
|
.and_then(|e| e.behavior.as_any_mut())
|
|
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
|
|
{
|
|
seq.video_params[0].frame_rate = Rational::new(1, 1);
|
|
seq.video_params[0].width = 64;
|
|
seq.video_params[0].height = 64;
|
|
}
|
|
|
|
let kinds: &[TrackType] = if with_audio {
|
|
&[TrackType::Video, TrackType::Audio]
|
|
} else {
|
|
&[TrackType::Video]
|
|
};
|
|
for &kind in kinds {
|
|
let (tcore, tbehavior) = TrackBehavior::create();
|
|
let track = p.graph.add_node(tcore, tbehavior);
|
|
p.graph
|
|
.get_mut(track)
|
|
.unwrap()
|
|
.behavior
|
|
.as_any_mut()
|
|
.unwrap()
|
|
.downcast_mut::<TrackBehavior>()
|
|
.unwrap()
|
|
.kind = kind;
|
|
|
|
let (ccore, cbehavior) = oak_node::block::clip_create();
|
|
let clip = p.graph.add_node(ccore, cbehavior);
|
|
if effect.is_some() && kind == TrackType::Video {
|
|
// The Opacity module is private; the factory is the public
|
|
// constructor. The value input is zeroed so an enabled
|
|
// effect zeroes every delivered channel (including alpha).
|
|
let (ecore, ebehavior) = oak_node::factory::Factory::global()
|
|
.create_any("org.olivevideoeditor.Olive.opacity")
|
|
.expect("opacity node registered");
|
|
let effect_node = p.graph.add_node(ecore, ebehavior);
|
|
p.graph
|
|
.get_mut(effect_node)
|
|
.unwrap()
|
|
.core
|
|
.set_standard_value(
|
|
oak_node::node::ENABLED_INPUT,
|
|
-1,
|
|
oak_node::value::NodeValue::Boolean(effect.unwrap_or(false)),
|
|
);
|
|
p.graph
|
|
.get_mut(effect_node)
|
|
.unwrap()
|
|
.core
|
|
.set_standard_value("opacity_in", -1, oak_node::value::NodeValue::Float(0.0));
|
|
p.graph
|
|
.connect(footage_id, effect_node, "tex_in", -1)
|
|
.expect("connect footage to effect");
|
|
p.graph
|
|
.connect(
|
|
effect_node,
|
|
clip,
|
|
oak_node::block::clip_input::TEXTURE_INPUT,
|
|
-1,
|
|
)
|
|
.expect("connect effect to clip");
|
|
} else {
|
|
p.graph
|
|
.connect(
|
|
footage_id,
|
|
clip,
|
|
oak_node::block::clip_input::TEXTURE_INPUT,
|
|
-1,
|
|
)
|
|
.expect("connect footage to clip");
|
|
}
|
|
p.graph
|
|
.get_mut(clip)
|
|
.unwrap()
|
|
.behavior
|
|
.as_any_mut()
|
|
.unwrap()
|
|
.downcast_mut::<ClipBlockBehavior>()
|
|
.expect("clip block")
|
|
.core
|
|
.range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 1));
|
|
p.graph
|
|
.get_mut(track)
|
|
.unwrap()
|
|
.behavior
|
|
.as_any_mut()
|
|
.unwrap()
|
|
.downcast_mut::<TrackBehavior>()
|
|
.unwrap()
|
|
.append_block(clip);
|
|
|
|
let (lcore, lbehavior) = TrackListBehavior::create();
|
|
let list = p.graph.add_node(lcore, lbehavior);
|
|
{
|
|
let list_b = p
|
|
.graph
|
|
.get_mut(list)
|
|
.unwrap()
|
|
.behavior
|
|
.as_any_mut()
|
|
.unwrap()
|
|
.downcast_mut::<TrackListBehavior>()
|
|
.unwrap();
|
|
list_b.kind = kind;
|
|
list_b.tracks.push(track);
|
|
}
|
|
p.graph
|
|
.get_mut(sequence)
|
|
.unwrap()
|
|
.behavior
|
|
.as_any_mut()
|
|
.unwrap()
|
|
.downcast_mut::<SequenceBehavior>()
|
|
.unwrap()
|
|
.track_lists
|
|
.push(list);
|
|
}
|
|
sequence
|
|
};
|
|
|
|
let params = nodeops::sequence_video_params(&project, sequence, 0).expect("sequence params");
|
|
(project, sequence, params)
|
|
}
|
|
|
|
/// Build a `RenderTask` for `viewer` with a 64x64 forced F32 render size.
|
|
fn render_task(project: &ProjectRef, viewer: NodeId, params: Option<VideoParams>) -> RenderTask {
|
|
let mut task = RenderTask::new(
|
|
Task::new("render", None),
|
|
params,
|
|
(project.clone(), viewer),
|
|
ForceParams {
|
|
force_width: 64,
|
|
force_height: 64,
|
|
// The default leaves `force_format` off (-1), so the pipeline
|
|
// frame stays F32.
|
|
..ForceParams::default()
|
|
},
|
|
None,
|
|
);
|
|
task.set_max_inflight(2);
|
|
task
|
|
}
|
|
|
|
/// The first RGBA pixel of a delivered texture as f32 (downloads GPU
|
|
/// textures; `None` when the texture cannot be read back).
|
|
fn first_pixel(frame: &Texture) -> Option<[f32; 4]> {
|
|
let f = frame.to_frame().ok()?;
|
|
if f.width <= 0 || f.height <= 0 || f.data.len() < 16 {
|
|
return None;
|
|
}
|
|
let mut px = [0.0f32; 4];
|
|
for (i, channel) in px.iter_mut().enumerate() {
|
|
let start = i * 4;
|
|
*channel = f32::from_le_bytes(f.data[start..start + 4].try_into().ok()?);
|
|
}
|
|
Some(px)
|
|
}
|
|
|
|
/// A `RenderTaskBehavior` recording the delivered payloads in order.
|
|
#[derive(Default)]
|
|
struct Recorder {
|
|
order: Vec<&'static str>,
|
|
frames: Vec<(i32, i32)>,
|
|
/// Pixel format of each delivered frame (the forced-format contract).
|
|
formats: Vec<PixelFormat>,
|
|
/// First RGBA pixel of each delivered frame (effect and generated-frame
|
|
/// observability).
|
|
first_pixels: Vec<Option<[f32; 4]>>,
|
|
timestamps: Vec<Rational>,
|
|
audio_samples: Vec<usize>,
|
|
fail_frames: bool,
|
|
fail_audio: bool,
|
|
/// Cancel the driving task from the first frame hook (exercises the
|
|
/// between-frames cancellation path deterministically).
|
|
cancel_on_first_frame: bool,
|
|
cancelled: bool,
|
|
}
|
|
|
|
impl Recorder {
|
|
fn new() -> Recorder {
|
|
Recorder::default()
|
|
}
|
|
}
|
|
|
|
impl RenderTaskBehavior for Recorder {
|
|
fn frame_downloaded(&mut self, task: &mut Task, frame: &Texture) -> Result<()> {
|
|
self.order.push("frame");
|
|
self.frames.push(frame.size());
|
|
self.formats.push(frame.format());
|
|
self.first_pixels.push(first_pixel(frame));
|
|
self.timestamps.push(
|
|
frame
|
|
.to_frame()
|
|
.map(|f| f.timestamp)
|
|
.unwrap_or(Rational::NULL),
|
|
);
|
|
if self.fail_frames {
|
|
return Err(Error::Failed("frame hook failed".to_string()));
|
|
}
|
|
if self.cancel_on_first_frame && !self.cancelled {
|
|
self.cancelled = true;
|
|
task.cancel();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn audio_downloaded(&mut self, _task: &mut Task, samples: &AudioSamples) -> Result<()> {
|
|
self.order.push("audio");
|
|
self.audio_samples.push(samples.samples.len());
|
|
if self.fail_audio {
|
|
return Err(Error::Failed("audio hook failed".to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn encode_subtitle(&mut self, _task: &mut Task, _text: &str) -> Result<()> {
|
|
Err(Error::Failed(
|
|
"subtitles are not rendered by this test".into(),
|
|
))
|
|
}
|
|
}
|
|
|
|
// ---- pure / prepare paths --------------------------------------------
|
|
|
|
/// A default-constructed render task exposes zero frames and every
|
|
/// configurator tolerates edge values. The window/progress configurators
|
|
/// have no getters; their observable effects are covered by
|
|
/// [`max_inflight_edges_still_deliver`] and
|
|
/// [`native_progress_signalling_can_be_disabled`].
|
|
#[test]
|
|
fn new_defaults_and_configurators_accept_edges() {
|
|
let project = Project::new();
|
|
let mut task = RenderTask::new(
|
|
Task::new("render", None),
|
|
None,
|
|
(project, NodeId::INVALID),
|
|
ForceParams::default(),
|
|
None,
|
|
);
|
|
assert_eq!(task.total_frames(), 0);
|
|
assert!(!task.force_params.has_force_matrix);
|
|
// Defaults must leave every override off (-1, not 0 = U8).
|
|
assert_eq!(task.force_params.force_format, -1);
|
|
task.set_render_inputs(0, true, TimeRange::default());
|
|
task.set_native_progress_signalling(false);
|
|
task.set_max_inflight(0);
|
|
task.set_max_inflight(usize::MAX);
|
|
assert_eq!(task.total_frames(), 0, "configuring does not plan frames");
|
|
assert_eq!(
|
|
task.force_params.force_format, -1,
|
|
"configuring keeps defaults"
|
|
);
|
|
}
|
|
|
|
/// The edge in-flight windows (`0`, `usize::MAX`) still render: the loop
|
|
/// clamps the window to at least one ticket. There is no `max_inflight`
|
|
/// getter, so the delivered frame is the observable effect.
|
|
#[test]
|
|
fn max_inflight_edges_still_deliver() {
|
|
let media = clip_path("inflight");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 1);
|
|
|
|
let _guard = render_serial();
|
|
for max in [0usize, usize::MAX] {
|
|
let mut task = render_task(&project, footage, Some(params.clone()));
|
|
task.set_max_inflight(max);
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
assert!(
|
|
task.render(&mut driver, &mut recorder).is_ok(),
|
|
"max_inflight {max} must still render"
|
|
);
|
|
assert_eq!(
|
|
recorder.frames,
|
|
vec![(64, 64)],
|
|
"max_inflight {max} delivers the frame"
|
|
);
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// An empty export range computes no frames and submits nothing.
|
|
#[test]
|
|
fn empty_export_range_renders_nothing() {
|
|
let media = clip_path("empty");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 1);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(0, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(task.total_frames(), 0);
|
|
assert!(recorder.frames.is_empty());
|
|
assert!(recorder.audio_samples.is_empty());
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// A viewer that is not a footage/sequence node fails the render with the
|
|
/// "no node connected to the viewer output" error.
|
|
#[test]
|
|
fn invalid_viewer_fails_the_render() {
|
|
let project = Project::new();
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, NodeId::INVALID, None);
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
let error = task.render(&mut driver, &mut recorder).unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("viewer output"),
|
|
"the error names the viewer: {error}"
|
|
);
|
|
assert_eq!(task.total_frames(), 1, "the frame plan is computed first");
|
|
assert!(recorder.frames.is_empty());
|
|
}
|
|
|
|
/// A sequence viewer with no track lists renders a generated (transparent)
|
|
/// frame instead of a montage.
|
|
#[test]
|
|
fn sequence_viewer_without_tracks_renders_a_generated_frame() {
|
|
let project = Project::new();
|
|
let sequence = {
|
|
let mut p = project.lock().unwrap();
|
|
let (score, sbehavior) = SequenceBehavior::create();
|
|
p.graph.add_node(score, sbehavior)
|
|
};
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, sequence, None);
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(recorder.order, vec!["frame"]);
|
|
assert_eq!(recorder.frames, vec![(64, 64)]);
|
|
assert_eq!(
|
|
recorder.first_pixels,
|
|
vec![Some([0.0, 0.0, 0.0, 0.0])],
|
|
"the generated frame is fully transparent"
|
|
);
|
|
}
|
|
|
|
// ---- footage path -----------------------------------------------------
|
|
|
|
/// A single-footage render delivers exactly one frame and reports native
|
|
/// progress to the driver.
|
|
#[test]
|
|
fn footage_frame_is_delivered_with_progress() {
|
|
let media = clip_path("footage");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 1);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let progress = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = progress.clone();
|
|
let mut driver = Task::new("render", None);
|
|
driver.set_event_listener(Box::new(move |event| {
|
|
if let TaskEvent::Progress(value) = event {
|
|
sink.lock().unwrap().push(value);
|
|
}
|
|
}));
|
|
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(recorder.order, vec!["frame"]);
|
|
assert_eq!(recorder.frames, vec![(64, 64)]);
|
|
assert_eq!(recorder.timestamps, vec![Rational::new(0, 1)]);
|
|
assert_eq!(
|
|
*progress.lock().unwrap(),
|
|
vec![1.0],
|
|
"the native progress reaches 1.0 after the single frame"
|
|
);
|
|
assert!(!driver.is_cancelled());
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// Forced dimensions and an explicit F32 format reach the delivered
|
|
/// texture (size and pixel format are both asserted on the payload).
|
|
#[test]
|
|
fn forced_size_and_format_are_applied() {
|
|
let media = clip_path("force");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, _) = footage_project(&media.to_string_lossy(), 1);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, None);
|
|
task.force_params.force_width = 32;
|
|
task.force_params.force_height = 32;
|
|
task.force_params.force_format = PixelFormat::F32 as i32;
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(recorder.frames, vec![(32, 32)]);
|
|
assert_eq!(
|
|
recorder.formats,
|
|
vec![PixelFormat::F32],
|
|
"the forced F32 format reaches the delivered texture"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// A forced **non-default** format reaches the delivered texture: the
|
|
/// generated-frame path supports U8, so if `force_format` were dropped the
|
|
/// delivered texture would be F32 and this fails. (The F32 case above pins
|
|
/// the delivery contract but cannot distinguish the forced value from the
|
|
/// pipeline default.)
|
|
#[test]
|
|
fn forced_non_default_format_reaches_the_delivered_texture() {
|
|
let project = Project::new();
|
|
let sequence = {
|
|
let mut p = project.lock().unwrap();
|
|
let (score, sbehavior) = SequenceBehavior::create();
|
|
p.graph.add_node(score, sbehavior)
|
|
};
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, sequence, None);
|
|
task.force_params.force_format = PixelFormat::U8 as i32;
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(recorder.frames, vec![(64, 64)]);
|
|
assert_eq!(
|
|
recorder.formats,
|
|
vec![PixelFormat::U8],
|
|
"the forced U8 format reaches the delivered texture"
|
|
);
|
|
}
|
|
|
|
/// A frame-hook error aborts the render after the first delivered frame.
|
|
#[test]
|
|
fn hook_error_stops_the_render() {
|
|
let media = clip_path("hookerr");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
// 2 fps over one second: two frames to schedule.
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 2);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
recorder.fail_frames = true;
|
|
let error = task.render(&mut driver, &mut recorder).unwrap_err();
|
|
assert!(error.to_string().contains("frame hook failed"));
|
|
assert_eq!(task.total_frames(), 2);
|
|
assert_eq!(
|
|
recorder.frames,
|
|
vec![(64, 64)],
|
|
"delivery stopped at the error"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// With native progress signalling disabled (the export path), the render
|
|
/// loop delivers frames without emitting progress itself.
|
|
#[test]
|
|
fn native_progress_signalling_can_be_disabled() {
|
|
let media = clip_path("noprogress");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 1);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, Some(params));
|
|
task.set_native_progress_signalling(false);
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let progress = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = progress.clone();
|
|
let mut driver = Task::new("render", None);
|
|
driver.set_event_listener(Box::new(move |event| {
|
|
if let TaskEvent::Progress(value) = event {
|
|
sink.lock().unwrap().push(value);
|
|
}
|
|
}));
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(recorder.frames.len(), 1, "frames are still delivered");
|
|
assert!(
|
|
progress.lock().unwrap().is_empty(),
|
|
"the loop emits no progress of its own"
|
|
);
|
|
assert!(driver.error().is_none());
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// Cancelling before the render aborts with `Error::Cancelled` and delivers
|
|
/// nothing (the in-flight ticket is drained).
|
|
#[test]
|
|
fn cancel_before_render_reports_cancelled() {
|
|
let media = clip_path("cancel");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 1);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
driver.cancel();
|
|
let mut recorder = Recorder::new();
|
|
let result = task.render(&mut driver, &mut recorder);
|
|
assert!(matches!(result, Err(Error::Cancelled)));
|
|
assert_eq!(task.total_frames(), 1);
|
|
assert!(
|
|
recorder.order.is_empty(),
|
|
"nothing is delivered after abort"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
// ---- montage / audio path --------------------------------------------
|
|
|
|
/// A one-clip sequence renders through the montage compositor.
|
|
#[test]
|
|
fn sequence_montage_renders_a_frame() {
|
|
let media = clip_path("montage");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, sequence, params) = sequence_project(&media.to_string_lossy(), false, None);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, sequence, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(recorder.order, vec!["frame"]);
|
|
assert_eq!(recorder.frames, vec![(64, 64)]);
|
|
assert_eq!(recorder.timestamps, vec![Rational::new(0, 1)]);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// An effect in the clip's chain is walked (params collected) and its
|
|
/// enable state applied: the fixture's Opacity (factor 0) zeroes every
|
|
/// delivered channel when enabled and passes the decoded clip through when
|
|
/// disabled, so the two branches differ observably — a regression that
|
|
/// drops the chain (or ignores the enable flag) cannot pass both.
|
|
#[test]
|
|
fn clip_effect_chain_is_walked_and_bypassed() {
|
|
let media = clip_path("effect");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let _guard = render_serial();
|
|
|
|
for enabled in [false, true] {
|
|
let (project, sequence, params) =
|
|
sequence_project(&media.to_string_lossy(), false, Some(enabled));
|
|
|
|
// Fixture sanity: the clip's effect input is fed by the Opacity
|
|
// node, and the node carries the factor-zero input the walk needs.
|
|
let upstream_type = {
|
|
let guard = project.lock().unwrap_or_else(|e| e.into_inner());
|
|
let clip = guard
|
|
.graph
|
|
.node_ids()
|
|
.into_iter()
|
|
.find(|id| {
|
|
guard
|
|
.graph
|
|
.get(*id)
|
|
.and_then(|e| e.behavior.as_any())
|
|
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
|
|
.is_some()
|
|
})
|
|
.expect("clip node");
|
|
let input = guard.graph.get(clip).unwrap().core.effect_input.clone();
|
|
let upstream = guard
|
|
.graph
|
|
.connected_output(clip, &input, -1)
|
|
.expect("effect upstream");
|
|
let entry = guard.graph.get(upstream).unwrap();
|
|
assert!(
|
|
matches!(
|
|
entry.core.standard_value(oak_node::node::ENABLED_INPUT, -1),
|
|
oak_node::value::NodeValue::Boolean(v) if v == enabled
|
|
),
|
|
"the fixture enable state"
|
|
);
|
|
assert!(
|
|
matches!(
|
|
entry.core.standard_value("opacity_in", -1),
|
|
oak_node::value::NodeValue::Float(v) if v == 0.0
|
|
),
|
|
"the fixture opacity factor"
|
|
);
|
|
entry.behavior.type_id().to_string()
|
|
};
|
|
assert_eq!(upstream_type, "org.olivevideoeditor.Olive.opacity");
|
|
|
|
let mut task = render_task(&project, sequence, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(recorder.order, vec!["frame"]);
|
|
assert_eq!(recorder.frames, vec![(64, 64)]);
|
|
|
|
let pixel = recorder.first_pixels[0].expect("CPU frame with a readable pixel");
|
|
if enabled {
|
|
assert_eq!(
|
|
pixel,
|
|
[0.0, 0.0, 0.0, 0.0],
|
|
"the enabled opacity zeroes every channel"
|
|
);
|
|
} else {
|
|
assert!(
|
|
pixel != [0.0, 0.0, 0.0, 0.0] && pixel[3] > 0.0,
|
|
"the disabled effect must pass the decoded clip through unchanged: {pixel:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// Audio is enabled: the audio ticket is delivered before the video frame
|
|
/// and carries the decoded samples.
|
|
#[test]
|
|
fn audio_is_delivered_before_the_video_frame() {
|
|
let media = clip_path("audio");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, sequence, params) = sequence_project(&media.to_string_lossy(), true, None);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, sequence, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
true,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(
|
|
recorder.order,
|
|
vec!["audio", "frame"],
|
|
"the audio range is delivered first"
|
|
);
|
|
assert_eq!(recorder.audio_samples.len(), 1);
|
|
assert!(
|
|
recorder.audio_samples[0] > 0,
|
|
"the decoded audio range is not empty"
|
|
);
|
|
assert_eq!(recorder.frames, vec![(64, 64)]);
|
|
assert_eq!(task.total_frames(), 1);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// The frame count covers the whole export range (not just the delivered
|
|
/// window) and the timestamps advance by the timebase.
|
|
#[test]
|
|
fn total_frames_and_timestamps_follow_the_range() {
|
|
let media = clip_path("count");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
// 4 fps over one second: frames at 0, 1/4, 1/2, 3/4.
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 4);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(task.total_frames(), 4);
|
|
assert_eq!(
|
|
recorder.timestamps,
|
|
vec![
|
|
Rational::new(0, 1),
|
|
Rational::new(1, 4),
|
|
Rational::new(1, 2),
|
|
Rational::new(3, 4),
|
|
]
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// Native progress is emitted once per delivered frame as a fraction of the
|
|
/// whole planned range (not just the in-flight window).
|
|
#[test]
|
|
fn progress_reaches_one_across_multiple_frames() {
|
|
let media = clip_path("multiprogress");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 4);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let progress = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = progress.clone();
|
|
let mut driver = Task::new("render", None);
|
|
driver.set_event_listener(Box::new(move |event| {
|
|
if let TaskEvent::Progress(value) = event {
|
|
sink.lock().unwrap().push(value);
|
|
}
|
|
}));
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(recorder.frames.len(), 4);
|
|
assert_eq!(
|
|
*progress.lock().unwrap(),
|
|
vec![0.25, 0.5, 0.75, 1.0],
|
|
"one progress report per frame, normalized by the plan"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// A range that does not start at zero plans and delivers exactly the
|
|
/// frames inside it.
|
|
#[test]
|
|
fn range_with_non_zero_start_plans_offset_frames() {
|
|
let media = clip_path("offset");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
// 4 fps: the half-open range [1/2, 1) holds frames at 1/2 and 3/4.
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 4);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(1, 2), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
assert!(task.render(&mut driver, &mut recorder).is_ok());
|
|
assert_eq!(task.total_frames(), 2);
|
|
assert_eq!(
|
|
recorder.timestamps,
|
|
vec![Rational::new(1, 2), Rational::new(3, 4)]
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// An audio-hook error aborts the render at the audio delivery, before any
|
|
/// video frame is handed to the hooks.
|
|
#[test]
|
|
fn audio_hook_error_stops_the_render() {
|
|
let media = clip_path("audioerr");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, sequence, params) = sequence_project(&media.to_string_lossy(), true, None);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, sequence, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
true,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
recorder.fail_audio = true;
|
|
let error = task.render(&mut driver, &mut recorder).unwrap_err();
|
|
assert!(error.to_string().contains("audio hook failed"));
|
|
assert_eq!(
|
|
recorder.order,
|
|
vec!["audio"],
|
|
"the error stops the render before the frame"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// A cancellation raised from the first frame hook aborts the render with
|
|
/// `Error::Cancelled`, delivers no further frames, and cancels the
|
|
/// still-in-flight tickets on the way out.
|
|
#[test]
|
|
fn cancel_between_frames_reports_cancelled() {
|
|
let media = clip_path("midcancel");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
// 4 fps over one second: four frames to schedule.
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 4);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, Some(params));
|
|
task.set_max_inflight(1);
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
recorder.cancel_on_first_frame = true;
|
|
let result = task.render(&mut driver, &mut recorder);
|
|
assert!(
|
|
matches!(result, Err(Error::Cancelled)),
|
|
"a cancel between frames maps to Cancelled: {result:?}"
|
|
);
|
|
assert_eq!(task.total_frames(), 4, "the whole plan was computed");
|
|
assert_eq!(recorder.frames.len(), 1, "delivery stopped at the cancel");
|
|
assert!(driver.is_cancelled());
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
// ---- private process dispatcher (no global manager) -------------------
|
|
|
|
/// Environment-only reason the private dispatcher cannot render here: the
|
|
/// worker pool itself could not be created or started (an unusable
|
|
/// `oak-worker`, exec denied, process-table exhaustion, ...). Everything
|
|
/// else — ticket errors, wrong payload kinds, unexpected timestamps, shm
|
|
/// conversion problems — is a product failure and must fail the test.
|
|
fn worker_pool_environment_error(error: &Error) -> bool {
|
|
matches!(error, Error::Failed(message) if message.starts_with("render worker pool"))
|
|
}
|
|
|
|
/// Shuts the process-wide render manager down and points `OAK_WORKER_BIN`
|
|
/// at `worker` for the private-dispatcher render, restoring both on drop —
|
|
/// so a panicking render can never leak the shut-down manager or the env
|
|
/// override into later tests. [`DispatcherEnvGuard::restore`] restores
|
|
/// eagerly, before the assertions.
|
|
struct DispatcherEnvGuard {
|
|
previous_worker_bin: Option<std::ffi::OsString>,
|
|
restored: bool,
|
|
}
|
|
|
|
impl DispatcherEnvGuard {
|
|
fn install(worker: &std::path::Path) -> DispatcherEnvGuard {
|
|
let previous_worker_bin = std::env::var_os("OAK_WORKER_BIN");
|
|
std::env::set_var("OAK_WORKER_BIN", worker);
|
|
let guard = DispatcherEnvGuard {
|
|
previous_worker_bin,
|
|
restored: false,
|
|
};
|
|
RenderManager::shutdown();
|
|
assert!(
|
|
RenderManager::global().is_none(),
|
|
"the private path needs no global manager"
|
|
);
|
|
guard
|
|
}
|
|
|
|
/// Restore the manager and the environment exactly once.
|
|
fn restore(&mut self) {
|
|
if self.restored {
|
|
return;
|
|
}
|
|
self.restored = true;
|
|
RenderManager::init_with_backend(RenderBackendChoice::Threads).expect("render manager");
|
|
match self.previous_worker_bin.take() {
|
|
Some(value) => std::env::set_var("OAK_WORKER_BIN", value),
|
|
None => std::env::remove_var("OAK_WORKER_BIN"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for DispatcherEnvGuard {
|
|
fn drop(&mut self) {
|
|
self.restore();
|
|
}
|
|
}
|
|
|
|
/// With the global render manager shut down, [`RenderTask::render`] stands
|
|
/// up its own process dispatcher and renders through `oak-worker`
|
|
/// processes: the `ShmFrame`/`ShmAudio` delivery seams, the poll pump and
|
|
/// the dispatcher teardown (`shm_frame_to_texture`'s BGRA8 and F32
|
|
/// branches).
|
|
///
|
|
/// Only the worker pool being unavailable is an environment skip (the
|
|
/// missing-binary case skips earlier): a pool that cannot be created or
|
|
/// started prints a reason. Every product failure — a `"Frame render
|
|
/// ticket failed"`, a bad shm payload/format, a poll timeout surfacing as
|
|
/// a ticket error — fails the test.
|
|
#[test]
|
|
fn private_dispatcher_renders_frames_and_audio_through_workers() {
|
|
let _guard = render_serial();
|
|
let Some(worker) = locate_oak_worker() else {
|
|
eprintln!("oak-worker binary not built; skipping the private dispatcher render");
|
|
return;
|
|
};
|
|
|
|
let media = clip_path("private");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
|
|
// The dispatcher resolves the worker binary from the environment when
|
|
// it is constructed by the render call below. The guard restores the
|
|
// manager and the env also when a render panics.
|
|
let mut env = DispatcherEnvGuard::install(&worker);
|
|
|
|
// Audio + video through the workers (the default BGRA8 wire format).
|
|
let (project, sequence, params) = sequence_project(&media.to_string_lossy(), true, None);
|
|
let mut task = render_task(&project, sequence, Some(params));
|
|
task.set_max_inflight(2);
|
|
task.set_render_inputs(
|
|
0,
|
|
true,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
let outcome = task.render(&mut driver, &mut recorder);
|
|
|
|
// A forced F32 ticket takes the no-conversion shm branch.
|
|
let (fproject, footage, _) = footage_project(&media.to_string_lossy(), 1);
|
|
let mut f32_task = render_task(&fproject, footage, None);
|
|
f32_task.force_params.force_format = PixelFormat::F32 as i32;
|
|
f32_task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut f32_driver = Task::new("render", None);
|
|
let mut f32_recorder = Recorder::new();
|
|
let f32_outcome = f32_task.render(&mut f32_driver, &mut f32_recorder);
|
|
|
|
// Restore the manager and the environment before asserting.
|
|
env.restore();
|
|
|
|
let mut skipped_environment = Vec::new();
|
|
match outcome {
|
|
Ok(()) => {
|
|
assert_eq!(
|
|
recorder.order,
|
|
vec!["audio", "frame"],
|
|
"the worker audio and frame land in order"
|
|
);
|
|
assert_eq!(recorder.frames, vec![(64, 64)]);
|
|
assert_eq!(
|
|
recorder.formats,
|
|
vec![PixelFormat::F32],
|
|
"the BGRA8 wire frame is converted to the F32 delivery format"
|
|
);
|
|
assert!(recorder.audio_samples[0] > 0);
|
|
}
|
|
Err(error) if worker_pool_environment_error(&error) => {
|
|
eprintln!("private dispatcher worker pool unavailable: {error}; skipping");
|
|
skipped_environment.push("video+audio");
|
|
}
|
|
Err(error) => panic!("the private dispatcher render failed (product error): {error}"),
|
|
}
|
|
match f32_outcome {
|
|
Ok(()) => {
|
|
assert_eq!(f32_recorder.frames, vec![(64, 64)]);
|
|
assert_eq!(
|
|
f32_recorder.formats,
|
|
vec![PixelFormat::F32],
|
|
"the forced F32 shm branch keeps the delivery format"
|
|
);
|
|
}
|
|
Err(error) if worker_pool_environment_error(&error) => {
|
|
eprintln!("private dispatcher F32 render unavailable: {error}; skipping");
|
|
skipped_environment.push("f32");
|
|
}
|
|
Err(error) => panic!("the private dispatcher F32 render failed (product error): {error}"),
|
|
}
|
|
if !skipped_environment.is_empty() {
|
|
eprintln!(
|
|
"private dispatcher render skipped (environment unavailable): {}",
|
|
skipped_environment.join(", ")
|
|
);
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
// ---- ticket failures --------------------------------------------------
|
|
|
|
/// A footage file removed after the probe makes the frame ticket's decode
|
|
/// fail; the render loop maps the ticket error (not a hook error).
|
|
#[test]
|
|
fn missing_media_frame_ticket_reports_failure() {
|
|
let media = clip_path("missingticket");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, params) = footage_project(&media.to_string_lossy(), 1);
|
|
// Probe already happened; remove the payload so the decode fails.
|
|
let _ = std::fs::remove_file(&media);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, footage, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
false,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
let error = task.render(&mut driver, &mut recorder).unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("Frame render ticket failed"),
|
|
"the decode failure is a ticket error: {error}"
|
|
);
|
|
assert!(recorder.frames.is_empty());
|
|
}
|
|
|
|
/// The same for the audio range: the audio ticket is queued first, so its
|
|
/// failure is reported as an audio ticket error before any frame.
|
|
#[test]
|
|
fn missing_media_audio_ticket_reports_failure() {
|
|
let media = clip_path("missingaudio");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, sequence, params) = sequence_project(&media.to_string_lossy(), true, None);
|
|
let _ = std::fs::remove_file(&media);
|
|
|
|
let _guard = render_serial();
|
|
let mut task = render_task(&project, sequence, Some(params));
|
|
task.set_render_inputs(
|
|
0,
|
|
true,
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
|
);
|
|
let mut driver = Task::new("render", None);
|
|
let mut recorder = Recorder::new();
|
|
let error = task.render(&mut driver, &mut recorder).unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("Audio render ticket failed"),
|
|
"the audio decode failure names the ticket: {error}"
|
|
);
|
|
assert!(recorder.order.is_empty());
|
|
}
|