Files
Mike-Solar ee7ea18d94 clippy: clear the workspace errors and apply the machine fixes
- 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.
2026-09-15 19:29:32 +08:00

223 lines
9.8 KiB
Rust

//! GPU pixel tests for the Tier-1 blur/sharpen nodes (skip without GPU).
use oak_core::texture::Texture;
use oak_core::{PixelFormat, Rational};
use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
fn texture_value(t: Texture) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(t)) }
fn gpu() -> bool { oak_core::backend::shared_gpu_or_skip("an OFX-Misc GPU 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)
}
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
}
fn eval_node_row(type_id: &str, inputs: NodeValueRow, frame_size: Option<(i32, i32)>) -> 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.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")
}
const DIRBLUR: &str = "org.olivevideoeditor.Olive.dirblur";
const SHARPEN: &str = "org.olivevideoeditor.Olive.sharpen";
/// A 16x16 F32 frame painted pixel by pixel from `paint(x, y)`.
fn painted_frame(paint: impl Fn(usize, usize) -> [f32; 4]) -> Texture {
let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), (16, 16), PixelFormat::F32).unwrap();
let stride = f.linesize_bytes() as usize;
for y in 0..16 {
for x in 0..16 {
let at = y * stride + x * 16;
for (c, v) in paint(x, y).iter().enumerate() {
f.data[at + c * 4..at + c * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
}
}
Texture::wrap_frame(f)
}
/// A row carrying the effect input plus any explicitly set float inputs.
fn row_with(texture: Texture, scalars: &[(&str, f64)]) -> NodeValueRow {
let mut row = NodeValueRow::new();
row.insert("tex_in".to_string(), texture_value(texture));
for (name, v) in scalars {
row.insert((*name).to_string(), NodeValue::Float(*v));
}
row
}
/// Assert every channel of `px` is within `tol` of `want`.
fn assert_close(px: [f32; 4], want: [f32; 4], tol: f32, what: &str) {
for c in 0..4 {
assert!(
(px[c] - want[c]).abs() <= tol,
"{what}: channel {c}: got {px:?}, want {want:?} (tol {tol})"
);
}
}
/// Zero amount (the input default) collapses the tap spacing to zero, so
/// the directional blur is an exact identity anchored to the sampled
/// coordinate; it still renders on the GPU.
#[test]
fn dirblur_zero_amount_is_identity() {
if !gpu() { eprintln!("no adapter; skipping"); return; }
let src = painted_frame(|x, y| [x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0]);
let out = eval_node_row(DIRBLUR, row_with(src, &[]), None);
for (x, y) in [(0, 0), (5, 3), (8, 8), (14, 12), (15, 15)] {
assert_close(
pixel_at(&out, x, y),
[x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0],
1e-3,
&format!("dirblur zero amount ({x},{y})"),
);
}
}
/// A one-pixel-wide white vertical line smears along +x at angle 0:
/// pixels either side pick up the line, the line itself dims, and
/// nothing leaks along y.
#[test]
fn dirblur_smears_horizontally_without_vertical_spread() {
if !gpu() { eprintln!("no adapter; skipping"); return; }
let src = painted_frame(|x, y| {
if x == 8 && (4..12).contains(&y) { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }
});
let out = eval_node_row(
DIRBLUR,
row_with(src, &[("amount_in", 2.0), ("angle_in", 0.0)]),
None,
);
let spread_l = pixel_at(&out, 6, 8)[0];
let spread_r = pixel_at(&out, 10, 8)[0];
assert!((0.05..0.3).contains(&spread_l), "pixel left of the line picks up the smear: {spread_l}");
assert!((spread_l - spread_r).abs() < 0.02, "smear is symmetric about the line: {spread_l} vs {spread_r}");
// The line dims, but not to nothing: the 16 taps sit 2*2/15 px apart,
// so they land at 8 + t*4/15 for t = -7.5..7.5 and the sampler's
// 1 px-wide linear reconstruction gives them 13/15, 9/15, 5/15 and
// 1/15 of the line each, twice over, leaving the center pixel at
// 2*(13+9+5+1)/(15*16) = 7/30.
let on_line = pixel_at(&out, 8, 8)[0];
assert!(
(on_line - 7.0 / 30.0).abs() < 0.02,
"a one-pixel line dims to ~7/30 of its brightness: {on_line}"
);
// The smear redistributes the line's brightness without adding or
// losing any: the linear-filter triangle has unit area, so the 16
// output pixels (each the average of 16 unit-spaced samples) still
// sum to the whole line. This holds whatever the filter mode.
let row_energy: f32 = (0..16).map(|x| pixel_at(&out, x, 8)[0]).sum();
assert!(
(row_energy - 1.0).abs() < 0.02,
"the row keeps the line's total brightness: {row_energy}"
);
assert!(pixel_at(&out, 5, 8)[0] < 0.01, "two pixels out stays black");
assert!(pixel_at(&out, 8, 3)[0] < 0.01, "no spread above the line");
assert!(pixel_at(&out, 8, 12)[0] < 0.01, "no spread below the line");
let alpha = pixel_at(&out, 6, 8)[3];
assert!((alpha - 1.0).abs() < 1e-3, "alpha passes through: {alpha}");
}
/// At angle 90 the smear runs along +y: the same one-pixel line laid
/// horizontally bleeds into the rows above and below it, and not along
/// its own axis.
#[test]
fn dirblur_angle_90_smears_vertically() {
if !gpu() { eprintln!("no adapter; skipping"); return; }
let src = painted_frame(|x, y| {
if y == 8 && (4..12).contains(&x) { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }
});
let out = eval_node_row(
DIRBLUR,
row_with(src, &[("amount_in", 2.0), ("angle_in", 90.0)]),
None,
);
let above = pixel_at(&out, 8, 6)[0];
let below = pixel_at(&out, 8, 10)[0];
assert!((0.05..0.3).contains(&above), "row above the line picks up the smear: {above}");
assert!((above - below).abs() < 0.02, "smear is symmetric about the line: {above} vs {below}");
// Same arithmetic as the horizontal case, rotated: 7/30 of the line.
let on_line = pixel_at(&out, 8, 8)[0];
assert!(
(on_line - 7.0 / 30.0).abs() < 0.02,
"a one-pixel line dims to ~7/30 of its brightness: {on_line}"
);
let column_energy: f32 = (0..16).map(|y| pixel_at(&out, 8, y)[0]).sum();
assert!(
(column_energy - 1.0).abs() < 0.02,
"the column keeps the line's total brightness: {column_energy}"
);
assert!(pixel_at(&out, 8, 5)[0] < 0.01, "two rows out stays black");
assert!(pixel_at(&out, 3, 6)[0] < 0.01, "no spread left of the line");
assert!(pixel_at(&out, 1, 8)[0] < 0.01, "no spread along the line's axis");
}
/// A flat region is untouched by the unsharp mask whatever the amount
/// (the default 1.0 included), and an explicit zero amount is an exact
/// identity on a gradient.
#[test]
fn sharpen_flat_and_zero_amount_pass_through() {
if !gpu() { eprintln!("no adapter; skipping"); return; }
let rgba = [0.4, 0.6, 0.8, 1.0];
let out = eval_node_row(SHARPEN, row_with(filled_frame((16, 16), rgba), &[]), None);
assert_close(pixel_at(&out, 8, 8), rgba, 1e-3, "sharpen default amount on a flat frame");
let src = painted_frame(|x, y| [x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0]);
let out = eval_node_row(SHARPEN, row_with(src, &[("amount_in", 0.0)]), None);
for (x, y) in [(0, 0), (5, 3), (8, 8), (14, 12), (15, 15)] {
assert_close(
pixel_at(&out, x, y),
[x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0],
1e-3,
&format!("sharpen zero amount ({x},{y})"),
);
}
}
/// At a black/white step the 3x3 box blur is 2/3 white on the bright
/// side (overshoot to 4/3) and 1/3 white on the dark side (undershoot to
/// -1/3); flat columns pass through and the result is not clamped.
#[test]
fn sharpen_overshoots_and_undershoots_a_step_edge() {
if !gpu() { eprintln!("no adapter; skipping"); return; }
let src = painted_frame(|x, _| {
if x < 8 { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }
});
let out = eval_node_row(SHARPEN, row_with(src, &[("amount_in", 1.0)]), None);
let bright = pixel_at(&out, 8, 8);
assert!((bright[0] - 4.0 / 3.0).abs() < 0.01, "overshoot on the bright side: {bright:?}");
assert!(bright[0] > 1.0, "overshoot is not clamped: {bright:?}");
assert!((bright[3] - 1.0).abs() < 1e-3, "alpha passes through: {bright:?}");
let dark = pixel_at(&out, 7, 8);
assert!((dark[0] + 1.0 / 3.0).abs() < 0.01, "undershoot on the dark side: {dark:?}");
assert!(dark[0] < 0.0, "undershoot is not clamped: {dark:?}");
assert!((dark[3] - 1.0).abs() < 1e-3, "alpha passes through: {dark:?}");
assert_close(pixel_at(&out, 6, 8), [0.0, 0.0, 0.0, 1.0], 1e-3, "flat black column");
assert_close(pixel_at(&out, 9, 8), [1.0, 1.0, 1.0, 1.0], 1e-3, "flat white column");
}