- Mark the raw-pointer interop entry points unsafe with # Safety docs (oak-core upload/download/frame-from-pixels, oak-audio convert) and satisfy the existing callers (tests). - mut_from_ref: allow with the ABI contract documented (the handle get_mut helpers in oak-timeline/oak-render/oak-task take the shared reference the C ABI passes; exclusivity is the caller's unsafe contract). - Fix the eq_op in the white-balance normalization (green / green). - Apply cargo clippy --fix across the workspace (redundant closures and field names, field reassignment, items after test modules, ...). - Revert the replace_box fix in image_effect's clip_define: a redefinition must allocate a new box, otherwise the old clip handle stays valid and the HS-map replace contract (clip != clip2) breaks. - 283 warnings remain; they are all non-machine-applicable (chunks_exact -> as_chunks needs a manual iter_mut, too_many_arguments, complex types, missing Safety docs, ...) and are tracked as the follow-up.
242 lines
10 KiB
Rust
242 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/>.
|
|
|
|
//! GPU pixel tests for the layer-form transition node
|
|
//! (`org.olivevideoeditor.Olive.transitionfx`): cross dissolve, fade, wipe
|
|
//! and slide over `tex_in` + `blend_in`, with the progress either set on
|
|
//! the row or auto-filled by the renderer from the adjustment layer's own
|
|
//! span. Skips without a GPU adapter.
|
|
|
|
use oak_core::texture::Texture;
|
|
use oak_core::{PixelFormat, Rational};
|
|
use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
|
|
|
const TRANSITIONFX: &str = "org.olivevideoeditor.Olive.transitionfx";
|
|
|
|
fn texture_value(t: Texture) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(t)) }
|
|
fn gpu() -> bool { oak_core::backend::shared_gpu_or_skip("a transition effect test").is_some() }
|
|
fn filled_frame(size: (i32, i32), rgba: [f32; 4]) -> Texture {
|
|
let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
|
|
for px in f.data.chunks_exact_mut(16) { for (c, v) in px.chunks_exact_mut(4).zip(rgba) { c.copy_from_slice(&v.to_le_bytes()); } }
|
|
Texture::wrap_frame(f)
|
|
}
|
|
/// An 8x8 frame whose red channel carries each column's centre `u`
|
|
/// (`(x + 0.5) / width`), so a sample taken at `u` reads back as exactly
|
|
/// `u`: the slide tests use it to tell which part of the picture landed
|
|
/// where.
|
|
fn gradient_frame_x(size: (i32, i32)) -> Texture {
|
|
let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
|
|
let w = size.0 as usize;
|
|
for (i, px) in f.data.chunks_exact_mut(16).enumerate() {
|
|
let u = ((i % w) as f32 + 0.5) / w as f32;
|
|
for (c, v) in px.chunks_exact_mut(4).zip([u, 0.0, 0.0, 1.0]) { c.copy_from_slice(&v.to_le_bytes()); }
|
|
}
|
|
Texture::wrap_frame(f)
|
|
}
|
|
fn pixel_at(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
|
|
let stride = frame.linesize_bytes();
|
|
let at = y * stride + x * 16;
|
|
let mut out = [0f32; 4];
|
|
for c in 0..4 { out[c] = f32::from_le_bytes(frame.data[at + c*4..at + c*4 + 4].try_into().unwrap()); }
|
|
out
|
|
}
|
|
/// Evaluate one node row. `layer_progress` is the adjustment-layer sweep
|
|
/// position the graph driver records before walking an effect chain; the
|
|
/// shader job picks it up for `progress_in` when the node left the input
|
|
/// out of its params (the auto-fill path).
|
|
fn eval_node_row(
|
|
type_id: &str,
|
|
inputs: NodeValueRow,
|
|
frame_size: Option<(i32, i32)>,
|
|
layer_progress: Option<f64>,
|
|
) -> oak_core::texture::Frame {
|
|
use oak_node::traverser::RenderHooks;
|
|
let (core, behavior) = oak_node::factory::Factory::global().create_any(type_id).expect("node type registered");
|
|
let mut table = NodeValueTable::default();
|
|
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
|
let mut hooks = oak_render::eval::RenderEvalHooks::new();
|
|
hooks.frame_size = frame_size;
|
|
hooks.layer_progress = layer_progress;
|
|
hooks.resolve(oak_node::id::NodeId::INVALID, &inputs, &mut table);
|
|
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else { panic!("{type_id}: no texture produced") };
|
|
if handle.ctx.is_null() { panic!("{type_id}: null texture produced"); }
|
|
let tex = unsafe { oak_node::handle::get_checked::<Texture>(handle) }.expect("resolved texture");
|
|
assert!(matches!(tex, Texture::Gpu { .. }), "{type_id}: must render on the GPU");
|
|
tex.to_frame().expect("readback")
|
|
}
|
|
|
|
/// Assert the four channels of a pixel against the expected values.
|
|
fn assert_pixel(px: [f32; 4], want: [f32; 4], what: &str) {
|
|
for c in 0..4 {
|
|
assert!(
|
|
(px[c] - want[c]).abs() < 1e-3,
|
|
"{what}: channel {c}: got {px:?}, want {want:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The two-picture row the shader job carries: `tex_in` (From) and
|
|
/// `blend_in` (To), each painted over the whole 8x8 frame.
|
|
fn transition_row(tex: [f32; 4], blend: [f32; 4]) -> NodeValueRow {
|
|
let mut row = NodeValueRow::new();
|
|
row.insert("tex_in".to_string(), texture_value(filled_frame((8, 8), tex)));
|
|
row.insert("blend_in".to_string(), texture_value(filled_frame((8, 8), blend)));
|
|
row
|
|
}
|
|
|
|
/// Style combo index for a row (cross dissolve is 0, the default).
|
|
fn style(row: &mut NodeValueRow, index: i64) {
|
|
row.insert("type_in".to_string(), NodeValue::Combo(index));
|
|
}
|
|
|
|
/// Halfway cross dissolve lerps red and green to olive.
|
|
#[test]
|
|
fn crossdissolve_half_progress_blends() {
|
|
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
|
let mut row = transition_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
|
row.insert("progress_in".to_string(), NodeValue::Float(0.5));
|
|
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), None);
|
|
assert_pixel(pixel_at(&frame, 2, 2), [0.5, 0.5, 0.0, 1.0], "cross dissolve 0.5");
|
|
}
|
|
|
|
/// Progress 0 and 1 are the two pictures untouched.
|
|
#[test]
|
|
fn crossdissolve_ends_are_the_inputs() {
|
|
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
|
for (progress, want, what) in [
|
|
(0.0, [1.0, 0.0, 0.0, 1.0], "cross dissolve 0"),
|
|
(1.0, [0.0, 1.0, 0.0, 1.0], "cross dissolve 1"),
|
|
] {
|
|
let mut row = transition_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
|
row.insert("progress_in".to_string(), NodeValue::Float(progress));
|
|
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), None);
|
|
assert_pixel(pixel_at(&frame, 4, 4), want, what);
|
|
}
|
|
}
|
|
|
|
/// Fade mixes the picture up out of `color_in`: 0 is the colour, 1 the
|
|
/// picture, and a blue `color_in` proves the uniform binds by name (the
|
|
/// shader declares no `blend_in` at all). The row always carries the
|
|
/// input's default, exactly as the traverser fills every input in — a
|
|
/// row without `color_in` would leave the uniform zero-filled, which for
|
|
/// a colour is transparent black rather than the input's opaque default.
|
|
#[test]
|
|
fn fade_dips_through_the_color() {
|
|
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
|
let cases: [(f64, [f64; 4], [f32; 4]); 3] = [
|
|
(0.0, [0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0]),
|
|
(0.5, [0.0, 0.0, 1.0, 1.0], [0.5, 0.0, 0.5, 1.0]),
|
|
(1.0, [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]),
|
|
];
|
|
for (progress, color, want) in cases {
|
|
let mut row = transition_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
|
style(&mut row, 1);
|
|
row.insert("progress_in".to_string(), NodeValue::Float(progress));
|
|
row.insert("color_in".to_string(), NodeValue::Color(color));
|
|
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), None);
|
|
assert_pixel(pixel_at(&frame, 3, 5), want, "fade");
|
|
}
|
|
}
|
|
|
|
/// Wipe at half progress: the incoming picture covers the half the
|
|
/// direction comes from, the outgoing keeps the rest. All four combo
|
|
/// entries are the same path (axis and sign are two steps and two mixes).
|
|
#[test]
|
|
fn wipe_directions_place_the_incoming_picture() {
|
|
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
|
let red = [1.0, 0.0, 0.0, 1.0];
|
|
let green = [0.0, 1.0, 0.0, 1.0];
|
|
// (direction, pixel, expected) — tex_in is red (From), blend_in green (To).
|
|
let cases: [(i64, (usize, usize), [f32; 4]); 8] = [
|
|
(0, (2, 4), green),
|
|
(0, (6, 4), red),
|
|
(1, (2, 4), red),
|
|
(1, (6, 4), green),
|
|
(2, (4, 2), green),
|
|
(2, (4, 6), red),
|
|
(3, (4, 2), red),
|
|
(3, (4, 6), green),
|
|
];
|
|
for (direction, (x, y), want) in cases {
|
|
let mut row = transition_row(red, green);
|
|
style(&mut row, 2);
|
|
row.insert("progress_in".to_string(), NodeValue::Float(0.5));
|
|
row.insert("direction_in".to_string(), NodeValue::Combo(direction));
|
|
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), None);
|
|
assert_pixel(
|
|
pixel_at(&frame, x, y),
|
|
want,
|
|
&format!("wipe direction {direction} at ({x},{y})"),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Slide: a gradient stands in for the incoming picture, so the value at
|
|
/// a pixel names the column that landed there. At progress 0.25 the
|
|
/// incoming enters from the left with its trailing quarter on screen (the
|
|
/// frame shows the gradient's `[0.75, 1]` columns), while the outgoing
|
|
/// part shows the untouched `tex_in`. The reversed direction mirrors it.
|
|
#[test]
|
|
fn slide_moves_both_pictures_the_named_way() {
|
|
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
|
let red = [1.0, 0.0, 0.0, 1.0];
|
|
// (direction, pixel, expected red channel) at progress 0.25.
|
|
let cases: [(i64, (usize, usize), f32); 4] = [
|
|
(0, (0, 4), 0.8125),
|
|
(0, (6, 4), 1.0),
|
|
(1, (0, 4), 1.0),
|
|
(1, (7, 4), 0.1875),
|
|
];
|
|
for (direction, (x, y), red_channel) in cases {
|
|
let mut row = NodeValueRow::new();
|
|
row.insert("tex_in".to_string(), texture_value(filled_frame((8, 8), red)));
|
|
row.insert("blend_in".to_string(), texture_value(gradient_frame_x((8, 8))));
|
|
style(&mut row, 3);
|
|
row.insert("progress_in".to_string(), NodeValue::Float(0.25));
|
|
row.insert("direction_in".to_string(), NodeValue::Combo(direction));
|
|
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), None);
|
|
assert_pixel(
|
|
pixel_at(&frame, x, y),
|
|
[red_channel, 0.0, 0.0, 1.0],
|
|
&format!("slide direction {direction} at ({x},{y})"),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Auto-fill: the row carries no `progress_in` at all (the node dropped
|
|
/// it), so the renderer inserts the adjustment layer's own progress. The
|
|
/// same row renders differently for each hook value — without the fill the
|
|
/// uniform would stay 0 and both calls would be pure `tex_in`.
|
|
#[test]
|
|
fn progress_is_filled_from_the_adjustment_layer() {
|
|
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
|
let row = transition_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
|
let frame = eval_node_row(TRANSITIONFX, row.clone(), Some((8, 8)), Some(0.5));
|
|
assert_pixel(pixel_at(&frame, 4, 4), [0.5, 0.5, 0.0, 1.0], "auto progress 0.5");
|
|
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), Some(0.75));
|
|
assert_pixel(pixel_at(&frame, 4, 4), [0.25, 0.75, 0.0, 1.0], "auto progress 0.75");
|
|
}
|
|
|
|
/// An explicit `progress_in` on the row wins over the layer sweep.
|
|
#[test]
|
|
fn explicit_progress_beats_the_layer_sweep() {
|
|
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
|
let mut row = transition_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
|
row.insert("progress_in".to_string(), NodeValue::Float(0.25));
|
|
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), Some(0.75));
|
|
assert_pixel(pixel_at(&frame, 4, 4), [0.75, 0.25, 0.0, 1.0], "explicit progress");
|
|
}
|