// 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 .
//! Adjustment layers on the graph render path: a video track whose enabled
//! adjustment block covers the request time composites every track
//! underneath, pushes that composite through the block's effect chain and
//! lets the chain's output replace the lower stack — one block affects all
//! lower tracks, clip boundaries included. A bare block (no effect chain)
//! has no head to feed and is inert.
use std::sync::{Arc, Mutex};
use oak_core::texture::Texture;
use oak_core::{PixelFormat, Rational, TimeRange};
use oak_node::block::{AdjustmentBlockBehavior, 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};
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_adjust_{tag}_{}.mp4", std::process::id()))
}
/// These tests verify graph/composite MECHANICS (the adjustment sweep), not
/// the color pipeline. Pin the working space to the legacy sRGB
/// pass-through so the decoded pixels stay display-referred and the
/// pixel-value assertions hold regardless of the ACEScg default. All tests
/// in this binary set the same value, so the shared global is race-free.
fn pin_legacy_working_space() {
oak_core::color::set_pipeline_color_settings(
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
oak_core::colormath::OutputColorSpec::default(),
);
}
/// V1 (a single track carrying every clip of `clips`, in order) plus an
/// optional V2 adjustment layer. V2 goes in last, so it is the top of the
/// stack and sweeps V1. `adjustment` is `(in, out, opacity)`: `Some(opacity)`
/// builds the effect chain (an Opacity node feeding the block's `tex_in`,
/// its own `tex_in` left open as the chain head), `None` leaves the block
/// bare.
fn build_project(
clips: &[(&str, Rational, Rational)],
adjustment: Option<(Rational, Rational, Option)>,
) -> (Arc>, NodeId) {
pin_legacy_working_space();
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 v1 = p.graph.add_node(tcore, tbehavior);
for &(path, in_, out) in clips {
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");
p.graph
.get_mut(clip)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("clip block")
.core
.range = TimeRange::new(in_, out);
p.graph
.get_mut(v1)
.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(v1);
if let Some((in_, out, opacity)) = adjustment {
let (tcore, tbehavior) = TrackBehavior::create();
let v2 = p.graph.add_node(tcore, tbehavior);
let (acore, abehavior) = oak_node::block::adjustment_create();
let block = p.graph.add_node(acore, abehavior);
p.graph
.get_mut(block)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("adjustment block")
.core
.range = TimeRange::new(in_, out);
if let Some(value) = opacity {
let (ecore, ebehavior) = oak_node::nodes::opacity::create();
let effect = p.graph.add_node(ecore, ebehavior);
p.graph
.connect(
effect,
block,
oak_node::block::adjustment_input::TEXTURE_INPUT,
-1,
)
.expect("connect opacity to the adjustment block");
p.graph.get_mut(effect).unwrap().core.set_standard_value(
oak_node::nodes::opacity::VALUE_INPUT,
-1,
oak_node::value::NodeValue::Float(value),
);
}
p.graph
.get_mut(v2)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("video track")
.append_block(block);
p.graph
.get_mut(tl)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::()
.expect("video track list")
.tracks
.push(v2);
}
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.
/// The raw frame bytes of a rendered texture (GPU textures are read back
/// for the assertion; the playback path itself never downloads).
fn frame_data(texture: &Texture) -> Vec {
texture.to_frame().expect("graph frame readback").data
}
/// Render one 64x64 F32 frame of `seq` at `time` and return its bytes.
fn render_frame(project: &Arc>, seq: NodeId, time: Rational) -> Vec {
let texture =
oak_render::eval::render_graph_frame(project, seq, time, (64, 64), PixelFormat::F32)
.expect("graph render");
frame_data(&texture)
}
/// 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())
}
/// An adjustment layer over a single V1 track whose two solid clips abut at
/// t=1/2: inside the layer's span BOTH clips come out of the effect chain
/// (the red clip on the left half, the blue one on the right half of the
/// span), so one block reaches across the clip boundary; outside the span
/// the render is byte-identical to the same project without the layer.
///
/// The chain is an Opacity node at 0.5, which scales the straight-alpha
/// vec4 by 0.5; the frame then goes through the alpha-over composite once
/// more (like `shader_job_opacity_halves_pixels`), so each color channel
/// ends up at a 0.25 ratio of the plain render and the alpha at 0.5.
/// Skipped (with a note) when no GPU adapter exists.
#[test]
fn adjustment_layer_affects_lower_tracks_across_clips() {
if oak_core::backend::shared_gpu_or_skip("adjustment_layer_affects_lower_tracks_across_clips")
.is_none()
{
return;
}
let red = clip_path("across_red");
let blue = clip_path("across_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");
let red_path = red.to_string_lossy().to_string();
let blue_path = blue.to_string_lossy().to_string();
let clips: Vec<(&str, Rational, Rational)> = vec![
(&red_path, Rational::new(0, 1), Rational::new(1, 2)),
(&blue_path, Rational::new(1, 2), Rational::new(1, 1)),
];
let plain = build_project(&clips, None);
let adjusted = build_project(
&clips,
Some((Rational::new(1, 4), Rational::new(3, 4), Some(0.5))),
);
let nodes_before = adjusted.0.lock().unwrap().graph.node_count();
// Inside the span, a point covered by each clip in turn.
for (time, label) in [
(Rational::new(1, 4), "the red clip"),
(Rational::new(5, 8), "the blue clip"),
] {
let base = render_frame(&plain.0, plain.1, time);
let swept = render_frame(&adjusted.0, adjusted.1, time);
// 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();
let mut alphas: Vec = Vec::new();
for y in 4..60 {
for x in (4..24).chain(40..60) {
for c in 0..3 {
let a = channel(&base, x, y, c);
if a > 0.02 {
ratios.push(channel(&swept, x, y, c) / a);
}
}
alphas.push(channel(&swept, x, y, 3));
}
}
assert!(
ratios.len() >= 512,
"{label}: too few comparable samples: {}",
ratios.len()
);
let mean = ratios.iter().sum::() / ratios.len() as f32;
assert!(
(mean - 0.25).abs() < 0.02,
"{label}: a 0.5-opacity layer must leave 0.25 of each channel (shader x0.5, alpha-over x0.5), got {mean}"
);
let mean_alpha = alphas.iter().sum::() / alphas.len() as f32;
assert!(
(mean_alpha - 0.5).abs() < 0.1,
"{label}: the swept frame's alpha must be 0.5, got {mean_alpha}"
);
}
// Outside the span, nothing changes — not even a rounding step.
for time in [Rational::new(1, 8), Rational::new(7, 8)] {
assert_eq!(
render_frame(&plain.0, plain.1, time),
render_frame(&adjusted.0, adjusted.1, time),
"t={time:?} is outside the layer's span and must render unchanged"
);
}
assert_eq!(
adjusted.0.lock().unwrap().graph.node_count(),
nodes_before,
"the sweep must tear its temporary source node back down"
);
let _ = std::fs::remove_file(&red);
let _ = std::fs::remove_file(&blue);
}
/// A bare adjustment layer (a block with no effect chain) is inert: there
/// is no chain head to feed, so the tracks underneath render byte for byte
/// as if the block were absent, and the block leaves no node behind.
#[test]
fn bare_adjustment_layer_passes_lower_tracks_through() {
let red = clip_path("bare_red");
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0])
.expect("red clip generation");
let red_path = red.to_string_lossy().to_string();
let clips: Vec<(&str, Rational, Rational)> =
vec![(&red_path, Rational::new(0, 1), Rational::new(1, 1))];
let plain = build_project(&clips, None);
let bare = build_project(
&clips,
Some((Rational::new(1, 4), Rational::new(3, 4), None)),
);
let nodes_before = bare.0.lock().unwrap().graph.node_count();
let time = Rational::new(1, 2);
let base = render_frame(&plain.0, plain.1, time);
assert!(
base.iter().any(|&b| b != 0),
"the reference frame is not black"
);
assert_eq!(
render_frame(&bare.0, bare.1, time),
base,
"a bare adjustment layer must not change the render"
);
assert_eq!(
bare.0.lock().unwrap().graph.node_count(),
nodes_before,
"a bare adjustment layer must not grow the graph"
);
let _ = std::fs::remove_file(&red);
}