Files
oak-editor/crates/oak-render/tests/text_outline_glow.rs
T
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

204 lines
8.5 KiB
Rust

//! GPU pixel tests for the text3 outline/glow post-process (skip when no
//! GPU adapter).
use oak_core::texture::Texture;
use oak_core::{PixelFormat, Rational};
use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
use oak_node::nodes::textbackend::{
set_text_backends, TextLayoutRequest, TextLayoutSize, TextRenderTarget, TextRenderTransform,
};
#[allow(dead_code)]
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 text outline/glow GPU test").is_some() }
#[allow(dead_code)]
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 TEXT3: &str = "org.olivevideoeditor.Olive.text3";
/// The text backends are process globals: keep the tests that install
/// them off each other.
static BACKENDS: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Measure stub: a 16x16 document, so the raster is 16x16 whatever the
/// font engine would have laid out.
fn block_measure(_req: &TextLayoutRequest) -> TextLayoutSize { TextLayoutSize { width: 16.0, height: 16.0 } }
/// Render stub: paint white premultiplied coverage over the middle half
/// of the target (pixels 4..12 on both axes).
fn block_render(_req: &TextLayoutRequest, _transform: &TextRenderTransform, target: TextRenderTarget) {
if target.channel_count != 4 { return; }
let stride = target.linesize_bytes as usize;
let (w, h) = (target.width as usize, target.height as usize);
for y in h / 4..3 * h / 4 {
for x in w / 4..3 * w / 4 {
let at = y * stride + x * 4;
target.data[at..at + 4].copy_from_slice(&[255; 4]);
}
}
}
/// Insert boolean parameters (input id -> value).
fn set_bools(row: &mut NodeValueRow, params: &[(&str, bool)]) {
for (id, v) in params {
row.insert((*id).to_string(), NodeValue::Boolean(*v));
}
}
/// Insert float parameters (input id -> value).
fn set_floats(row: &mut NodeValueRow, params: &[(&str, f64)]) {
for (id, v) in params {
row.insert((*id).to_string(), NodeValue::Float(*v));
}
}
/// The evaluation row of the three tests: a 16x16 raster painted by
/// [`block_render`], with a 2px outline and/or a 4px glow. The outline
/// and glow colors are left at the node defaults (opaque black, opaque
/// yellow).
fn text_row(outline: bool, glow: bool) -> NodeValueRow {
let mut row = NodeValueRow::new();
row.insert("plain_text_in".to_string(), NodeValue::Text("X".to_string()));
set_bools(
&mut row,
&[
("use_args_in", false),
("outline_enabled_in", outline),
("glow_enabled_in", glow),
],
);
set_floats(&mut row, &[("outline_width_in", 2.0), ("glow_radius_in", 4.0)]);
row.insert("size_in".to_string(), NodeValue::Vec2([16.0, 16.0]));
row
}
/// Evaluate `TEXT3` with the raster backends installed. Callers hold
/// [`BACKENDS`].
fn eval_text(row: NodeValueRow) -> oak_core::texture::Frame {
set_text_backends(Some(block_measure), Some(block_render));
let frame = eval_node_row(TEXT3, row, None);
set_text_backends(None, None);
frame
}
/// 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:?}"
);
}
}
/// Outline only: the coverage is dilated by 2 pixels and tinted opaque
/// black under the text, so the glyph pixel stays white, the 2px band
/// around it is `[0,0,0,1]` and everything further out is transparent.
#[test]
fn outline_dilates_and_strokes_under_the_text() {
if !gpu() {
eprintln!("no adapter; skipping");
return;
}
let _guard = BACKENDS.lock().unwrap();
set_text_backends(None, None);
let frame = eval_text(text_row(true, false));
assert_eq!((frame.width, frame.height), (16, 16), "raster size");
// The glyph is drawn over its own outline.
assert_pixel(pixel_at(&frame, 8, 8), [1.0, 1.0, 1.0, 1.0], "text over stroke");
// Inside the 2px dilate band (coverage 4..12 widened to 2..14).
for (x, y) in [(2, 8), (13, 8), (8, 2), (8, 13), (2, 2), (13, 13)] {
assert_pixel(pixel_at(&frame, x, y), [0.0, 0.0, 0.0, 1.0], &format!("stroke ({x},{y})"));
}
// One pixel outside the band.
for (x, y) in [(1, 8), (14, 8), (8, 1), (8, 14), (0, 0), (15, 15)] {
assert_pixel(pixel_at(&frame, x, y), [0.0, 0.0, 0.0, 0.0], &format!("outside ({x},{y})"));
}
}
/// Glow only: the coverage is box-blurred with radius 4 (a 9-tap pass
/// per axis) and tinted yellow under the text. The blurred alpha is
/// `(horizontal taps / 9) * (vertical taps / 9)`, e.g. 32/81 at (3,8)
/// and 1/81 at the clamped corners.
#[test]
fn glow_blurs_the_coverage_under_the_text() {
if !gpu() {
eprintln!("no adapter; skipping");
return;
}
let _guard = BACKENDS.lock().unwrap();
set_text_backends(None, None);
let frame = eval_text(text_row(false, true));
assert_eq!((frame.width, frame.height), (16, 16), "raster size");
// The glyph is drawn over the glow.
assert_pixel(pixel_at(&frame, 8, 8), [1.0, 1.0, 1.0, 1.0], "text over glow");
// Yellow is premultiplied: [a, a, 0, a].
let a = |v: f32| [v, v, 0.0, v];
assert_pixel(pixel_at(&frame, 3, 8), a(32.0 / 81.0), "glow 4px left of the block");
assert_pixel(pixel_at(&frame, 2, 8), a(8.0 / 27.0), "glow 2px left of the block");
assert_pixel(pixel_at(&frame, 1, 8), a(16.0 / 81.0), "glow 3px left of the block");
assert_pixel(pixel_at(&frame, 2, 2), a(1.0 / 9.0), "glow on the block corner");
// The clamped corners mirror each other.
assert_pixel(pixel_at(&frame, 0, 0), a(1.0 / 81.0), "glow top-left corner");
assert_pixel(pixel_at(&frame, 15, 15), a(1.0 / 81.0), "glow bottom-right corner");
}
/// Outline and glow: the glow blurs the *stroke* and is drawn beneath
/// it, so the opaque stroke hides it inside the band while it still
/// bleeds the 4px radius past the stroke edge.
#[test]
fn outline_and_glow_glow_the_stroke() {
if !gpu() {
eprintln!("no adapter; skipping");
return;
}
let _guard = BACKENDS.lock().unwrap();
set_text_backends(None, None);
let frame = eval_text(text_row(true, true));
assert_eq!((frame.width, frame.height), (16, 16), "raster size");
assert_pixel(pixel_at(&frame, 8, 8), [1.0, 1.0, 1.0, 1.0], "text over stroke and glow");
assert_pixel(pixel_at(&frame, 2, 8), [0.0, 0.0, 0.0, 1.0], "stroke hides the glow");
// Past the stroke edge the glow shows again, dimmer the wider the
// blur source (the stroke) is than the bare coverage.
let a = |v: f32| [v, v, 0.0, v];
assert_pixel(pixel_at(&frame, 1, 8), a(4.0 / 9.0), "glow just past the stroke");
assert_pixel(pixel_at(&frame, 15, 8), a(1.0 / 3.0), "glow at the right edge");
assert_pixel(pixel_at(&frame, 0, 0), a(1.0 / 9.0), "glow top-left corner");
assert_pixel(pixel_at(&frame, 14, 14), a(16.0 / 81.0), "glow bottom-right of the stroke");
}