// 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 . //! M12 phase 2: the graph-driven sequence renderer. //! //! Builds a real node graph (sequence -> video track list -> video tracks //! -> clip blocks -> footage), evaluates the clip overlapping the request //! time through the traverser and composites the decoded frames — the same //! path the engine's viewer uses. The LAST track is the topmost stack //! element (NLE stacking: the highest-numbered track wins). use std::sync::{Arc, Mutex}; 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}; use oak_render::texture::Texture; mod common; /// Unique temp path per test (the process id disambiguates parallel test /// binaries; the tag separates tests inside one binary). fn clip_path(tag: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("oakrender_graph_{tag}_{}.mp4", std::process::id())) } /// One sequence + one video track list with one track per clip /// `(filename, [in, out))`. The LAST entry's track composites on top /// (NLE stacking: the highest-numbered track is topmost). fn build_project(clips: &[(&str, Rational, Rational)]) -> (Arc>, NodeId) { let project = Project::new(); let seq; { let mut p = project.lock().unwrap(); let (score, sbehavior) = SequenceBehavior::create(); seq = p.graph.add_node(score, sbehavior); let (tcore, tbehavior) = TrackListBehavior::create(); let tl = p.graph.add_node(tcore, tbehavior); for &(path, in_, out) in clips { let (tcore, tbehavior) = TrackBehavior::create(); let track = p.graph.add_node(tcore, tbehavior); let mut footage = FootageBehavior::new(path); footage.probe().expect("probe the generated clip"); let footage = p.graph.add_node(NodeCore::new(), Box::new(footage)); let (ccore, cbehavior) = oak_node::block::clip_create(); let clip = p.graph.add_node(ccore, cbehavior); p.graph .connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1) .expect("connect footage to clip"); let clip_behavior = p .graph .get_mut(clip) .unwrap() .behavior .as_any_mut() .unwrap() .downcast_mut::() .expect("clip block"); clip_behavior.core.range = TimeRange::new(in_, out); p.graph .get_mut(track) .unwrap() .behavior .as_any_mut() .unwrap() .downcast_mut::() .expect("video track") .append_block(clip); p.graph .get_mut(tl) .unwrap() .behavior .as_any_mut() .unwrap() .downcast_mut::() .expect("video track list") .tracks .push(track); } p.graph .get_mut(seq) .unwrap() .behavior .as_any_mut() .unwrap() .downcast_mut::() .expect("sequence") .track_lists .push(tl); } (project, seq) } /// The raw CPU frame bytes of a rendered texture. fn frame_data(texture: &Texture) -> &[u8] { let Texture::Cpu(frame) = texture else { panic!("graph render produced a non-CPU texture"); }; &frame.data } /// Two clips on two tracks, non-overlapping in time: at each request time /// exactly one clip covers, and its output must match the single-track /// render byte for byte (same decode + same composite path). #[test] fn graph_sequence_renders_two_tracks() { let path_a = clip_path("two_tracks_a"); let path_b = clip_path("two_tracks_b"); oak_codec::testmedia::write_test_clip(&path_a, 64, 64, 10, 10).expect("clip A generation"); oak_codec::testmedia::write_test_clip(&path_b, 32, 32, 10, 10).expect("clip B generation"); let (project, seq) = build_project(&[ (&path_a.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)), (&path_b.to_string_lossy(), Rational::new(1, 1), Rational::new(2, 1)), ]); let t05 = oak_render::eval::render_graph_frame( &project, seq, Rational::new(1, 2), (64, 64), PixelFormat::F32, ) .expect("render t=0.5"); let t15 = oak_render::eval::render_graph_frame( &project, seq, Rational::new(3, 2), (64, 64), PixelFormat::F32, ) .expect("render t=1.5"); assert_eq!(t05.size(), (64, 64)); assert_eq!(t15.size(), (64, 64)); // Solo renders of each clip for byte comparison. let (solo_a, seq_a) = build_project(&[(&path_a.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1))]); let solo_a_tex = oak_render::eval::render_graph_frame( &solo_a, seq_a, Rational::new(1, 2), (64, 64), PixelFormat::F32, ) .expect("solo A render"); let (solo_b, seq_b) = build_project(&[(&path_b.to_string_lossy(), Rational::new(1, 1), Rational::new(2, 1))]); let solo_b_tex = oak_render::eval::render_graph_frame( &solo_b, seq_b, Rational::new(3, 2), (64, 64), PixelFormat::F32, ) .expect("solo B render"); // Each time picks exactly the clip covering it, unchanged by the other // track (B is 32x32 and must scale up to the 64x64 target). assert_eq!(frame_data(&t05), frame_data(&solo_a_tex), "t=0.5 renders clip A"); assert_eq!(frame_data(&t15), frame_data(&solo_b_tex), "t=1.5 renders clip B"); assert_ne!(frame_data(&t05), frame_data(&t15), "the two clips differ"); // Both frames carry real content. assert!(frame_data(&t05).iter().any(|&b| b != 0), "t=0.5 is not black"); assert!(frame_data(&t15).iter().any(|&b| b != 0), "t=1.5 is not black"); let _ = std::fs::remove_file(&path_a); let _ = std::fs::remove_file(&path_b); } /// NLE stacking regression: two OPAQUE solid-color clips covering the /// same time on two video tracks — the clip on the LAST track (V2, blue) /// composites on top of the clip on the first track (V1, red), matching /// the timeline UI (the highest-numbered track displays on top). #[test] fn graph_sequence_stacks_highest_track_on_top() { let red = clip_path("stack_red"); let blue = clip_path("stack_blue"); oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0]) .expect("red clip generation"); oak_codec::testmedia::write_test_clip_solid(&blue, 64, 64, 10, 10, [0.1, 0.1, 0.9, 1.0]) .expect("blue clip generation"); // V1 = red (bottom), V2 = blue (top). let (project, seq) = build_project(&[ (&red.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)), (&blue.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)), ]); let tex = oak_render::eval::render_graph_frame(&project, seq, Rational::new(0, 1), (64, 64), PixelFormat::F32) .expect("stacked render"); let data = frame_data(&tex); assert!( channel(data, 8, 8, 2) > 0.5 && channel(data, 8, 8, 0) < 0.4, "V2's blue covers V1's red (r={}, b={})", channel(data, 8, 8, 0), channel(data, 8, 8, 2) ); // Distinguishability guard: solo, the V1 clip really is red (the two // tracks carry different content). let (solo, solo_seq) = build_project(&[(&red.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1))]); let solo_tex = oak_render::eval::render_graph_frame(&solo, solo_seq, Rational::new(0, 1), (64, 64), PixelFormat::F32) .expect("solo V1 render"); let solo_data = frame_data(&solo_tex); assert!( channel(solo_data, 8, 8, 0) > 0.5 && channel(solo_data, 8, 8, 2) < 0.4, "solo V1 is red (r={}, b={})", channel(solo_data, 8, 8, 0), channel(solo_data, 8, 8, 2) ); let _ = std::fs::remove_file(&red); let _ = std::fs::remove_file(&blue); } /// The driver rejects bad arguments explainably: non-F32 format, a /// non-positive size, and a missing viewer. #[test] fn graph_render_rejects_bad_inputs() { let (project, seq) = build_project(&[]); let err = oak_render::eval::render_graph_frame(&project, seq, Rational::new(0, 1), (64, 64), PixelFormat::U8) .err() .expect("non-F32 format rejected"); assert_eq!(err.code(), oak_render::error::Error::Invalid.code()); let err = oak_render::eval::render_graph_frame(&project, seq, Rational::new(0, 1), (0, 64), PixelFormat::F32) .err() .expect("non-positive size rejected"); assert_eq!(err.code(), oak_render::error::Error::Invalid.code()); let err = oak_render::eval::render_graph_frame(&project, NodeId::INVALID, Rational::new(0, 1), (64, 64), PixelFormat::F32) .err() .expect("missing viewer rejected"); assert_eq!(err.code(), oak_render::error::Error::NotFound.code()); } /// One sequence + one track with a single clip, with an effect node /// inserted between the footage and the clip block: `insert_effect` /// receives the project lock plus the footage and clip node ids, rewires /// the graph, and returns the effect node id. The clip keeps the /// `(in, out)` range from `clip`. fn build_effect_project( clip: (&str, Rational, Rational), insert_effect: impl FnOnce(&mut Project, NodeId, NodeId) -> NodeId, ) -> (Arc>, NodeId) { let project = Project::new(); let seq; { let mut p = project.lock().unwrap(); let (score, sbehavior) = SequenceBehavior::create(); seq = p.graph.add_node(score, sbehavior); let (tcore, tbehavior) = TrackListBehavior::create(); let tl = p.graph.add_node(tcore, tbehavior); let (tcore, tbehavior) = TrackBehavior::create(); let track = p.graph.add_node(tcore, tbehavior); let mut footage = FootageBehavior::new(clip.0); footage.probe().expect("probe the generated clip"); let footage = p.graph.add_node(NodeCore::new(), Box::new(footage)); let (ccore, cbehavior) = oak_node::block::clip_create(); let clip_node = p.graph.add_node(ccore, cbehavior); p.graph .connect(footage, clip_node, oak_node::block::clip_input::TEXTURE_INPUT, -1) .expect("connect footage to clip"); let clip_behavior = p .graph .get_mut(clip_node) .unwrap() .behavior .as_any_mut() .unwrap() .downcast_mut::() .expect("clip block"); clip_behavior.core.range = TimeRange::new(clip.1, clip.2); let _effect = insert_effect(&mut p, footage, clip_node); p.graph .get_mut(track) .unwrap() .behavior .as_any_mut() .unwrap() .downcast_mut::() .expect("video track") .append_block(clip_node); p.graph .get_mut(tl) .unwrap() .behavior .as_any_mut() .unwrap() .downcast_mut::() .expect("video track list") .tracks .push(track); p.graph .get_mut(seq) .unwrap() .behavior .as_any_mut() .unwrap() .downcast_mut::() .expect("sequence") .track_lists .push(tl); } (project, seq) } /// The F32 RGBA channel of a 64x64 frame at `(x, y)`. fn channel(data: &[u8], x: usize, y: usize, c: usize) -> f32 { let off = (y * 64 + x) * 16 + c * 4; f32::from_le_bytes(data[off..off + 4].try_into().unwrap()) } /// M12 phase 3a: an opacity shader job (scalar 0.5) pushed by the effect /// node is resolved on the shared GPU context and composited — each color /// channel ends up as the plain render halved twice (the shader scales the /// straight-alpha vec4 by 0.5, then the alpha-over composite applies the /// halved alpha again), i.e. a 0.25 channel ratio. Skipped (with a note) /// when no GPU adapter exists. #[test] fn shader_job_opacity_halves_pixels() { if oak_render::backend::GpuContext::shared().is_none() { eprintln!("skipping shader_job_opacity_halves_pixels: no GPU adapter"); return; } let path = clip_path("opacity_job"); oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("clip generation"); let (plain_project, plain_seq) = build_project(&[( &path.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1), )]); let plain_tex = oak_render::eval::render_graph_frame( &plain_project, plain_seq, Rational::new(0, 1), (64, 64), PixelFormat::F32, ) .expect("plain render"); let plain = frame_data(&plain_tex).to_vec(); let (effect_project, effect_seq) = build_effect_project( (&path.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)), |p, footage, clip| { let (ecore, ebehavior) = oak_node::nodes::opacity::create(); let effect = p.graph.add_node(ecore, ebehavior); p.graph.disconnect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1); p.graph .connect(footage, effect, oak_node::nodes::opacity::TEXTURE_INPUT, -1) .expect("connect footage to effect"); p.graph .connect(effect, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1) .expect("connect effect to clip"); p.graph .get_mut(effect) .unwrap() .core .set_standard_value( oak_node::nodes::opacity::VALUE_INPUT, -1, oak_node::value::NodeValue::Float(0.5), ); effect }, ); let effect_tex = oak_render::eval::render_graph_frame( &effect_project, effect_seq, Rational::new(0, 1), (64, 64), PixelFormat::F32, ) .expect("opacity render"); let blurred = frame_data(&effect_tex).to_vec(); // Sample away from the x=32 half boundary (MPEG-2 chroma bleed and // luma ringing stay within a few pixels of it). let mut ratios: Vec = Vec::new(); for y in 4..60 { for x in (4..24).chain(40..60) { for c in 0..3 { let a = channel(&plain, x, y, c); if a > 0.02 { ratios.push(channel(&blurred, x, y, c) / a); } } } } assert!(ratios.len() >= 512, "too few comparable samples: {}", ratios.len()); let mean = ratios.iter().sum::() / ratios.len() as f32; assert!( (mean - 0.25).abs() < 0.02, "opacity channel ratio {mean} is not 0.25" ); let _ = std::fs::remove_file(&path); } /// M12 phase 3a: a box-blur shader job (radius 2, both axes) is resolved /// on the shared GPU context — the output differs from the plain render /// byte-wise, the hard left/right half boundary softens (the per-pixel /// step at the boundary shrinks), and left-half content bleeds into the /// boundary pixel on the right half. Skipped when no GPU adapter exists. #[test] fn shader_job_blur_smooths_edge() { if oak_render::backend::GpuContext::shared().is_none() { eprintln!("skipping shader_job_blur_smooths_edge: no GPU adapter"); return; } let path = clip_path("blur_job"); oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("clip generation"); let (plain_project, plain_seq) = build_project(&[( &path.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1), )]); let plain_tex = oak_render::eval::render_graph_frame( &plain_project, plain_seq, Rational::new(0, 1), (64, 64), PixelFormat::F32, ) .expect("plain render"); let plain = frame_data(&plain_tex).to_vec(); let (effect_project, effect_seq) = build_effect_project( (&path.to_string_lossy(), Rational::new(0, 1), Rational::new(1, 1)), |p, footage, clip| { let (ecore, ebehavior) = oak_node::nodes::blur::create(); let effect = p.graph.add_node(ecore, ebehavior); p.graph.disconnect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1); p.graph .connect(footage, effect, oak_node::nodes::blur::TEXTURE_INPUT, -1) .expect("connect footage to effect"); p.graph .connect(effect, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1) .expect("connect effect to clip"); let core = &mut p.graph.get_mut(effect).unwrap().core; core.set_standard_value( oak_node::nodes::blur::METHOD_INPUT, -1, oak_node::value::NodeValue::Combo(0), ); core.set_standard_value( oak_node::nodes::blur::RADIUS_INPUT, -1, oak_node::value::NodeValue::Float(2.0), ); core.set_standard_value( oak_node::nodes::blur::HORIZ_INPUT, -1, oak_node::value::NodeValue::Boolean(true), ); core.set_standard_value( oak_node::nodes::blur::VERT_INPUT, -1, oak_node::value::NodeValue::Boolean(true), ); effect }, ); let effect_tex = oak_render::eval::render_graph_frame( &effect_project, effect_seq, Rational::new(0, 1), (64, 64), PixelFormat::F32, ) .expect("blur render"); let blurred = frame_data(&effect_tex).to_vec(); // The blur must actually change pixels (a silently dropped job would // fall back to the pass-through input and byte-match the plain frame). assert_ne!(plain, blurred, "the blur job must actually change pixels"); // Row y=32 (vertically uniform): the boundary step x=31 -> x=32 must // shrink, and the right-side boundary pixel picks up left-half content. let r = |data: &[u8], x: usize| channel(data, x, 32, 0); let plain_step = (r(&plain, 32) - r(&plain, 31)).abs(); let blurred_step = (r(&blurred, 32) - r(&blurred, 31)).abs(); assert!( blurred_step < plain_step, "boundary step {blurred_step} not below the plain {plain_step}" ); assert!( r(&blurred, 32) > r(&plain, 32), "blurred boundary pixel {} not above the plain {}", r(&blurred, 32), r(&plain, 32) ); let _ = std::fs::remove_file(&path); }