Task/manager lifecycles, precache and render boundaries, OTIO/FCPXML round trips, and the write-through/library contract tests.
294 lines
10 KiB
Rust
294 lines
10 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 `PreCacheTask`: construction (probed footage, missing
|
|
//! media, invalid ids), a real one-frame render through the render loop, an
|
|
//! empty range, cancellation before the run, and the no-op download hooks.
|
|
//!
|
|
//! Renders run on the process-wide manager's test-only inline (`Threads`)
|
|
//! backend, serialized through one static mutex; the media is generated by
|
|
//! `oak_codec::testmedia` (no network, no fixtures). Nothing here needs a
|
|
//! GPU.
|
|
|
|
use std::sync::{Arc, Mutex, MutexGuard};
|
|
|
|
use oak_core::texture::{Frame, Texture};
|
|
use oak_core::Rational;
|
|
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_render::manager::{RenderBackendChoice, RenderManager};
|
|
use oak_render::ticket::AudioSamples;
|
|
use oak_task::nodeops::{self, ProjectRef};
|
|
use oak_task::precache::PreCacheTask;
|
|
use oak_task::render::RenderTaskBehavior;
|
|
use oak_task::task::{Task, TaskBehavior, 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_precache_{tag}_{}.mp4", std::process::id()))
|
|
}
|
|
|
|
/// A project with one probed footage node and one sequence whose video
|
|
/// timebase is 1 fps (so the 1 s test clip renders as exactly one frame).
|
|
fn project_with_footage(media: &str) -> (ProjectRef, NodeId, NodeId) {
|
|
let project = Project::new();
|
|
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;
|
|
}
|
|
|
|
(project.clone(), footage_id, sequence)
|
|
}
|
|
|
|
/// Build a pre-cache task with a 64x64 forced render size (the sequence
|
|
/// default is 1080p; the forced size keeps the test frame small).
|
|
fn precache(project: &ProjectRef, footage: NodeId, sequence: NodeId, index: i32) -> PreCacheTask {
|
|
let mut task = PreCacheTask::new(
|
|
(project.clone(), footage),
|
|
index,
|
|
(project.clone(), sequence),
|
|
);
|
|
task.render.force_params.force_width = 64;
|
|
task.render.force_params.force_height = 64;
|
|
task
|
|
}
|
|
|
|
// ---- construction -----------------------------------------------------
|
|
|
|
/// The task label carries the footage filename and the frame index; the
|
|
/// public fields keep the inputs.
|
|
#[test]
|
|
fn new_labels_with_the_footage_filename_and_index() {
|
|
let media = clip_path("new");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
|
|
|
|
let task = PreCacheTask::new((project.clone(), footage), 3, (project.clone(), sequence));
|
|
assert_eq!(
|
|
task.render.base.title(),
|
|
format!("Pre-caching {}:3", media.to_string_lossy())
|
|
);
|
|
assert_eq!(task.index, 3);
|
|
assert!(Arc::ptr_eq(&task.footage.0, &project));
|
|
assert_eq!(task.footage.1, footage);
|
|
assert!(Arc::ptr_eq(&task.sequence.0, &project));
|
|
assert_eq!(task.sequence.1, sequence);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// Missing media (an unprobed footage node) still constructs a labeled task
|
|
/// with a zero-length range.
|
|
#[test]
|
|
fn new_accepts_unprobed_and_missing_media() {
|
|
let (project, _, sequence) = project_with_footage_listless();
|
|
|
|
let footage =
|
|
nodeops::footage_create(&project, Some("/definitely/not/here.mp4")).expect("footage node");
|
|
let mut task = PreCacheTask::new((project.clone(), footage), 0, (project.clone(), sequence));
|
|
assert_eq!(
|
|
task.render.base.title(),
|
|
"Pre-caching /definitely/not/here.mp4:0"
|
|
);
|
|
assert_eq!(
|
|
nodeops::node_length(&project, footage),
|
|
Rational::new(0, 1),
|
|
"the unprobed footage has no length"
|
|
);
|
|
|
|
// Running over the empty range is a no-op success.
|
|
let _guard = render_serial();
|
|
let mut driver = Task::new("precache", None);
|
|
assert!(task.run(&mut driver).is_ok());
|
|
assert_eq!(task.render.total_frames(), 0);
|
|
assert!(driver.error().is_none());
|
|
}
|
|
|
|
/// A project with no footage; only the sequence is exercised here.
|
|
fn project_with_footage_listless() -> (ProjectRef, NodeId, NodeId) {
|
|
let project = Project::new();
|
|
let sequence = {
|
|
let mut p = project.lock().unwrap();
|
|
let (score, sbehavior) = SequenceBehavior::create();
|
|
p.graph.add_node(score, sbehavior)
|
|
};
|
|
(project, NodeId::INVALID, sequence)
|
|
}
|
|
|
|
// ---- run --------------------------------------------------------------
|
|
|
|
/// A probed 1 s clip renders exactly one frame and reports no error.
|
|
#[test]
|
|
fn run_renders_the_footage_frame() {
|
|
let media = clip_path("run");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
|
|
let mut task = precache(&project, footage, sequence, 0);
|
|
|
|
let _guard = render_serial();
|
|
let mut driver = Task::new("precache", None);
|
|
let result = task.run(&mut driver);
|
|
assert!(result.is_ok(), "run failed: {:?}", driver.error());
|
|
assert_eq!(
|
|
task.render.total_frames(),
|
|
1,
|
|
"the 1 s range at 1 fps holds one frame"
|
|
);
|
|
assert!(!driver.is_cancelled());
|
|
assert!(driver.error().is_none());
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// A stale footage id yields an empty range: the run succeeds without
|
|
/// submitting any frame.
|
|
#[test]
|
|
fn run_with_a_stale_footage_id_renders_nothing() {
|
|
let (project, _, sequence) = project_with_footage_listless();
|
|
let mut task = PreCacheTask::new(
|
|
(project.clone(), NodeId::INVALID),
|
|
0,
|
|
(project.clone(), sequence),
|
|
);
|
|
|
|
let _guard = render_serial();
|
|
let mut driver = Task::new("precache", None);
|
|
assert!(task.run(&mut driver).is_ok());
|
|
assert_eq!(task.render.total_frames(), 0);
|
|
assert_eq!(
|
|
nodeops::node_length(&project, NodeId::INVALID),
|
|
Rational::new(0, 1)
|
|
);
|
|
}
|
|
|
|
/// Cancelling before the run aborts the render with the cancellation error
|
|
/// and delivers no frame.
|
|
#[test]
|
|
fn run_cancelled_before_start_reports_cancelled() {
|
|
let media = clip_path("cancel");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
|
|
let mut task = precache(&project, footage, sequence, 0);
|
|
|
|
let _guard = render_serial();
|
|
let mut driver = Task::new("precache", None);
|
|
driver.cancel();
|
|
let result = task.run(&mut driver);
|
|
assert!(result.is_err(), "a cancelled render fails");
|
|
assert!(driver.is_cancelled());
|
|
assert_eq!(
|
|
task.render.total_frames(),
|
|
1,
|
|
"the frame count is computed before the cancellation check"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|
|
|
|
/// The download hooks are no-ops: each accepts its payload and reports
|
|
/// success without touching the driver.
|
|
#[test]
|
|
fn download_hooks_are_no_ops() {
|
|
let (project, _, sequence) = project_with_footage_listless();
|
|
let mut task = PreCacheTask::new(
|
|
(project.clone(), NodeId::INVALID),
|
|
0,
|
|
(project.clone(), sequence),
|
|
);
|
|
let mut driver = Task::new("precache", None);
|
|
|
|
let frame = Texture::Cpu(Frame::dummy());
|
|
assert!(task.frame_downloaded(&mut driver, &frame).is_ok());
|
|
let audio = AudioSamples {
|
|
samples: vec![0.0; 8],
|
|
sample_rate: 48000,
|
|
channel_layout: 0x3,
|
|
channel_count: 2,
|
|
};
|
|
assert!(task.audio_downloaded(&mut driver, &audio).is_ok());
|
|
assert!(task.encode_subtitle(&mut driver, "a subtitle").is_ok());
|
|
assert!(driver.error().is_none());
|
|
}
|
|
|
|
/// `run` plans the render over the full footage length and honors the
|
|
/// window/progress configurators: the single frame still renders with the
|
|
/// in-flight window pinned to one ticket, and disabled native progress
|
|
/// keeps the driver's progress silent.
|
|
#[test]
|
|
fn render_plan_covers_the_whole_footage() {
|
|
let media = clip_path("range");
|
|
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
|
|
let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
|
|
let mut task = precache(&project, footage, sequence, 0);
|
|
|
|
let progress = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = progress.clone();
|
|
task.render.set_max_inflight(1);
|
|
task.render.set_native_progress_signalling(false);
|
|
let _guard = render_serial();
|
|
let mut driver = Task::new("precache", None);
|
|
driver.set_event_listener(Box::new(move |event| {
|
|
if let TaskEvent::Progress(value) = event {
|
|
sink.lock().unwrap().push(value);
|
|
}
|
|
}));
|
|
let result = task.run(&mut driver);
|
|
assert!(result.is_ok(), "run failed: {:?}", driver.error());
|
|
|
|
// The 10-frame / 10 fps test clip is 1 s long, i.e. one frame at the
|
|
// sequence's 1 fps timebase; the one-ticket window still delivers it.
|
|
assert_eq!(
|
|
nodeops::node_length(&project, footage),
|
|
Rational::new(1, 1),
|
|
"the probed footage length"
|
|
);
|
|
assert_eq!(task.render.total_frames(), 1);
|
|
assert!(
|
|
progress.lock().unwrap().is_empty(),
|
|
"disabled native progress signalling emits nothing"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
}
|