// 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); }