// 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 .
//! 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::())
{
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);
}