Text is no longer a hand-written HTML effect (docs in docs/zh/plans/text-footage-redesign.md): - textv3 gains structured inputs - plain text, font family/size, font color, outline (enable/color/width), glow (enable/color/radius); the legacy text_in HTML is hidden and auto-migrated to plain text on load. - Outline and glow render as GPU post-process chains (dilate/blur + colorize under the text); the font color tints the raster premultiplied. Plain text now rasterizes even with both passes off (previously a null deferred job), fixing a use-after-free where the handle was lifted out of an owning Option<NodeValue> before addref. - A cosmic-text backend (the lockfile's 0.19) installs at engine startup through the textbackend hooks and feeds the font-family combo. - The project panel gains 添加文本素材 next to 新建序列: a text entry in the bin that drops onto the timeline as a clip (one undo row), its parameters shown as structured fields in the inspector (multiline text area, no HTML anywhere). text3 is hidden from the effect add menus; legacy text3 chains keep evaluating.
204 lines
8.5 KiB
Rust
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::GpuContext::shared().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() as usize;
|
|
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");
|
|
}
|