diff --git a/Cargo.lock b/Cargo.lock index e9ee02ce7..8958e5286 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4723,6 +4723,7 @@ dependencies = [ name = "oak-app" version = "0.5.0" dependencies = [ + "cosmic-text", "embed-resource", "gpui", "gpui_elements", diff --git a/crates/oak-app/Cargo.toml b/crates/oak-app/Cargo.toml index 27f66722c..b17558c6a 100644 --- a/crates/oak-app/Cargo.toml +++ b/crates/oak-app/Cargo.toml @@ -65,6 +65,11 @@ serde_yaml = "0.9" # stderr backend (see oakapp::logging) so validation errors and warnings # are actually visible (RUST_LOG selects the verbosity). log = "0.4" +# Text shaping/rasterization for the text generator nodes: the app is the +# facade layer that installs oak-node's text backend hooks (see +# oakui/textengine.rs). Pinned to the version the gpui_wgpu backend already +# links so the lockfile keeps a single cosmic-text. +cosmic-text = "=0.19.0" # M14 R3: the app is a PURE module-crate consumer — every engine call is a # direct Rust call into the oak* rlibs (oak-node for the project graph, diff --git a/crates/oak-app/src/oakui/mod.rs b/crates/oak-app/src/oakui/mod.rs index 2bf5c7d20..bf7bac474 100644 --- a/crates/oak-app/src/oakui/mod.rs +++ b/crates/oak-app/src/oakui/mod.rs @@ -39,6 +39,9 @@ //! tested). //! * [`timecode`] — timecode / duration / fps / resolution formatting (pure, //! unit tested). +//! * [`textengine`] — the cosmic-text backend behind the text generator +//! nodes: the measure/render hooks they call through (`install`), and the +//! system font list feeding their font-family combo. pub mod audio_thread; pub mod component; @@ -57,6 +60,7 @@ pub mod projectbrowser; pub mod real; pub mod renderops; pub mod scopes; +pub mod textengine; pub mod timecode; pub mod transport; pub mod waveform; diff --git a/crates/oak-app/src/oakui/textengine.rs b/crates/oak-app/src/oakui/textengine.rs new file mode 100644 index 000000000..8897fe97b --- /dev/null +++ b/crates/oak-app/src/oakui/textengine.rs @@ -0,0 +1,861 @@ +// 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 . + +//! cosmic-text backend for the text generator nodes: the facade-layer half +//! of [`oak_node::nodes::textbackend`]. +//! +//! `oak-node` deliberately links no font or shaping crate: the text +//! generator nodes describe their job with the +//! [`TextLayoutRequest`](oak_node::nodes::textbackend::TextLayoutRequest) / +//! [`TextRenderTarget`](oak_node::nodes::textbackend::TextRenderTarget) PODs +//! and call the two function-pointer hooks that [`install`] fills in. This +//! module is that backend: layout and rasterization run on `cosmic-text` + +//! `swash`, the same stack the `gpui_wgpu` text system already links, so the +//! app keeps a single font stack and a single `cosmic-text` in the lockfile. +//! +//! [`RealEngine::new`](super::real::RealEngine::new) calls [`install`] once; +//! [`font_families`] is the other entry point, feeding the +//! `font_family_in` combo of the text nodes (see +//! [`super::effectchain::effect_params`]). +//! +//! # Units +//! +//! [`TextLayoutRequest::dots_per_meter`] is the paint device resolution +//! (Qt's `QTextDocument`/`QPainter` device metric); font sizes and geometry +//! in the request are points, and the laid-out document the hooks return and +//! draw in is sized in *device pixels*, exactly like the `QTextDocument` +//! this replaced. The conversion is `px = pt * dots_per_meter * 0.0254 / 72` +//! (3780 dots/meter, the Qt default for 96 DPI, gives the familiar 4/3 +//! factor); a zero or negative `dots_per_meter` falls back to 3780 and a +//! non-positive or non-finite `font_size_pt` to 72 pt. Both fallbacks match +//! the text nodes' own defaults (`oak_node::nodes::textv3`'s +//! `font_size_in` default is 72 pt). +//! +//! # Approximations compared to the former Qt implementation +//! +//! * Line spacing is `1.2 * font_size` (Qt's single spacing depends on the +//! font's own metrics). +//! * `Html` / `OliveHtml` requests are flattened by [`html_to_plain`] +//! instead of being laid out as rich text: tags carry no formatting here, +//! and the text is painted white throughout — per-span colors, bold / +//! italic runs and the rich-text alignment / list layout of the old +//! `QTextDocument` are not reproduced. +//! * Text decorations (underline / strikethrough) are not painted. +//! * `center_horizontally` maps to [`Align::Center`] over the wrap width; +//! like the C++ default `QTextOption(Qt::AlignCenter)` it is a no-op when +//! the request carries no wrap width. +//! * A request whose flattened text is empty measures 0×0 and paints +//! nothing (an empty `QTextDocument` reports one empty line's height). +//! +//! The layout and rasterization themselves follow the C++ contract to the +//! pixel: a document point `p` lands at +//! `((p.x + draw_offset_x) * scale, (p.y + draw_offset_y) * scale)`, clipped +//! to the scaled clip rect, and the two target formats are the Qt +//! `Format_Grayscale8` coverage buffer (channel count 1) and the +//! `Format_RGBA8888_Premultiplied` buffer (channel count 4). + +use std::borrow::Cow; +use std::sync::{Mutex, MutexGuard, Once, OnceLock}; + +use cosmic_text::{ + Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap, +}; +use oak_node::nodes::textbackend::{ + set_text_backends, TextLayoutMode, TextLayoutRequest, TextLayoutSize, TextRenderTarget, + TextRenderTransform, +}; + +/// Fallback font size in points (the text nodes' `font_size_in` default). +const DEFAULT_FONT_SIZE_PT: f64 = 72.0; + +/// Fallback paint device resolution: 3780 dots/meter ≈ 96 DPI, Qt's default. +const DEFAULT_DOTS_PER_METER: f64 = 3780.0; + +/// Fallback font size in device pixels, used only when the point size and +/// the resolution cannot produce a usable pixel size. +const DEFAULT_FONT_PX: f32 = 96.0; + +/// Points per inch (the numerator of the point → pixel conversion). +const POINTS_PER_INCH: f64 = 72.0; + +/// Meters per inch (the denominator of the point → pixel conversion). +const METERS_PER_INCH: f64 = 0.0254; + +/// Line height as a multiple of the font size. +const LINE_HEIGHT_SCALE: f32 = 1.2; + +/// Upper bound on the rasterized font size. A project file can carry an +/// arbitrary `font_size_pt` / `dots_per_meter`; without a ceiling the swash +/// bitmap cache would happily try to allocate gigabytes for a glyph. +const MAX_FONT_PX: f32 = 8192.0; + +/// Upper bound on the wrap width, keeping the layout solver's inputs sane +/// for a corrupt `wrap_width`. +const MAX_WRAP_PX: f32 = 1.0e6; + +/// Glyph rasterization state shared by every request. +/// +/// [`FontSystem`] scans the system font directories on construction, so it +/// is built once (lazily, on the first non-empty request) and reused; a +/// [`Buffer`] is cheap and stays per-request. +struct TextSystem { + font_system: FontSystem, + swash_cache: SwashCache, +} + +/// The process-wide text system. +fn system() -> &'static Mutex { + static SYSTEM: OnceLock> = OnceLock::new(); + SYSTEM.get_or_init(|| { + Mutex::new(TextSystem { + font_system: FontSystem::new(), + swash_cache: SwashCache::new(), + }) + }) +} + +/// Locks the text system, recovering from a poisoned lock: a panic while +/// shaping one text node must not take the whole app's text rendering down. +fn lock_system() -> MutexGuard<'static, TextSystem> { + system().lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Installs this module's hooks as the process-wide text backends (C++ +/// `set_text_backends()` at app startup). +/// +/// Idempotent: only the first call installs, so a second engine instance +/// cannot swap the hooks out from under a layout in flight. +pub fn install() { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + set_text_backends(Some(measure), Some(render)); + }); +} + +/// Measure hook: lays the request out and returns the document size in +/// device pixels (C++ `QTextDocument::size()`). +pub fn measure(req: &TextLayoutRequest) -> TextLayoutSize { + let text = request_text(req); + if text.is_empty() { + return TextLayoutSize::default(); + } + let mut sys = lock_system(); + let buffer = layout(&mut sys.font_system, req, &text); + let mut width = 0.0f32; + let mut height = 0.0f32; + for run in buffer.layout_runs() { + width = width.max(run.line_w); + height = height.max(run.line_top + run.line_height); + } + TextLayoutSize { + width: width as f64, + height: height as f64, + } +} + +/// Render hook: paints the request into `target` (C++ +/// `QAbstractTextDocumentLayout::draw()`). +pub fn render(req: &TextLayoutRequest, transform: &TextRenderTransform, target: TextRenderTarget) { + let TextRenderTarget { + data, + width, + height, + linesize_bytes, + channel_count, + } = target; + if width <= 0 || height <= 0 || linesize_bytes <= 0 || !matches!(channel_count, 1 | 4) { + return; + } + // `QPainter::scale(0, 0)` (degenerate transform in the stored + // parameters) paints nothing rather than collapsing to a matrix. + let scale = transform.scale; + if !scale.is_finite() || scale <= 0.0 { + return; + } + let text = request_text(req); + if text.is_empty() { + return; + } + let (clip_left, clip_top, clip_right, clip_bottom) = + clip_rect(transform, width, height, scale); + if !(clip_left < clip_right && clip_top < clip_bottom) { + return; + } + + let mut sys = lock_system(); + let mut buffer = layout(&mut sys.font_system, req, &text); + let TextSystem { + font_system, + swash_cache, + } = &mut *sys; + let offset_x = transform.draw_offset_x * scale; + for run in buffer.layout_runs() { + let offset_y = (run.line_y as f64 + transform.draw_offset_y) * scale; + for glyph in run.glyphs { + let physical = glyph.physical((offset_x as f32, offset_y as f32), scale as f32); + // The raster extends about one em around the glyph origin in + // both axes; skip the glyphs that cannot touch the clip rect + // instead of letting swash rasterize (and cache) them. + let em = glyph.font_size as f64 * scale; + let gx = physical.x as f64; + let gy = physical.y as f64; + if gx + em < clip_left + || gx - em > clip_right + || gy + em < clip_top + || gy - em > clip_bottom + { + continue; + } + let base = glyph.color_opt.unwrap_or(WHITE); + swash_cache.with_pixels(font_system, physical.cache_key, base, |px, py, color| { + let x = physical.x + px; + let y = physical.y + py; + if (x as f64) < clip_left + || (x as f64) >= clip_right + || (y as f64) < clip_top + || (y as f64) >= clip_bottom + { + return; + } + blend(data, linesize_bytes, channel_count, x, y, color); + }); + } + } +} + +/// The default text color: the backends always paint white unless the +/// markup overrides it (C++ `QPalette::Text` = `Qt::white`). +const WHITE: Color = Color::rgb(0xFF, 0xFF, 0xFF); + +/// The sorted, de-duplicated font families of the system font database. +/// +/// Feeds the text nodes' `font_family_in` combo (the `combo_option` +/// injection in [`super::effectchain::effect_params`]). Names are the +/// English family names, so they match what a project stores; an empty +/// database yields an empty list and the combo keeps free-form entry. +/// +/// The list is snapshotted on first use: `effect_params` rebuilds the +/// inspector's parameters on every engine change, and the font database +/// only changes when fonts are installed (an app restart). +pub fn font_families() -> Vec { + static FAMILIES: OnceLock> = OnceLock::new(); + FAMILIES + .get_or_init(|| { + let mut names: Vec = { + let sys = lock_system(); + sys.font_system + .db() + .faces() + .filter_map(|face| face.families.first().map(|(name, _)| name.clone())) + .collect() + }; + names.sort(); + names.dedup(); + names + }) + .clone() +} + +/// The pixel-space clip rectangle of a render, already intersected with the +/// target buffer. An empty (or inverted) rectangle means nothing is drawn; +/// a non-finite rect from a corrupt transform collapses to empty too. +fn clip_rect( + transform: &TextRenderTransform, + width: i32, + height: i32, + scale: f64, +) -> (f64, f64, f64, f64) { + let mut left = 0.0f64; + let mut top = 0.0f64; + let mut right = width as f64; + let mut bottom = height as f64; + if transform.clip_enabled { + let x = transform.clip_offset_x * scale; + let y = transform.clip_offset_y * scale; + let w = transform.clip_width * scale; + let h = transform.clip_height * scale; + if !(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite()) { + return (0.0, 0.0, 0.0, 0.0); + } + left = left.max(x); + top = top.max(y); + right = right.min(x + w.max(0.0)); + bottom = bottom.min(y + h.max(0.0)); + } + (left, top, right, bottom) +} + +/// Composites one source pixel over the target. +/// +/// `channel_count == 1` is the grayscale coverage buffer the v1/v2 nodes +/// tint afterwards: the glyph contributes its alpha as coverage. Channel +/// count 4 is the premultiplied RGBA buffer of v3, so the source is +/// premultiplied before the over-blend (the hook's colors are straight — +/// swash's mask pixels carry the (white) base color plus coverage-as-alpha, +/// and its color bitmaps are straight RGBA). +fn blend( + data: &mut [u8], + linesize_bytes: i32, + channel_count: i32, + x: i32, + y: i32, + color: Color, +) { + if x < 0 || y < 0 { + return; + } + let alpha = color.a(); + if alpha == 0 { + return; + } + let offset = y as usize * linesize_bytes as usize + x as usize * channel_count as usize; + if channel_count == 1 { + let Some(dst) = data.get_mut(offset) else { + return; + }; + let src = alpha as u32; + let out = src + (*dst as u32 * (255 - src)) / 255; + *dst = out.min(255) as u8; + return; + } + let Some(pixel) = data.get_mut(offset..offset + 4) else { + return; + }; + let src_alpha = alpha as u32; + let inverse = 255 - src_alpha; + let src = [ + (color.r() as u32 * src_alpha + 127) / 255, + (color.g() as u32 * src_alpha + 127) / 255, + (color.b() as u32 * src_alpha + 127) / 255, + src_alpha, + ]; + for (dst, value) in pixel.iter_mut().zip(src) { + *dst = (value + *dst as u32 * inverse / 255).min(255) as u8; + } +} + +/// The text a request lays out: the request's own text, or the flattened +/// markup for the two HTML modes. +fn request_text(req: &TextLayoutRequest) -> Cow<'_, str> { + match req.mode { + TextLayoutMode::PlainText => Cow::Borrowed(req.text.as_str()), + TextLayoutMode::Html | TextLayoutMode::OliveHtml => Cow::Owned(html_to_plain(&req.text)), + } +} + +/// Lays a request out into a shaped [`Buffer`]. +fn layout(font_system: &mut FontSystem, req: &TextLayoutRequest, text: &str) -> Buffer { + let metrics = Metrics::relative(font_px(req), LINE_HEIGHT_SCALE); + let mut buffer = Buffer::new(font_system, metrics); + let mut attrs = Attrs::new(); + if !req.font_family.is_empty() { + attrs = attrs.family(Family::Name(req.font_family.as_str())); + } + let alignment = if req.center_horizontally { + Some(Align::Center) + } else { + None + }; + buffer.set_text(text, &attrs, Shaping::Advanced, alignment); + if req.wrap_width.is_finite() && req.wrap_width > 0.0 { + buffer.set_size(Some(req.wrap_width.min(MAX_WRAP_PX as f64) as f32), None); + } + // CJK text has no spaces to break at, so word wrapping alone would + // overflow the wrap width; `WordOrGlyph` keeps the C++ behavior of + // wrapping inside a run of CJK. + buffer.set_wrap(Wrap::WordOrGlyph); + buffer.shape_until_scroll(font_system, false); + buffer +} + +/// The font size of a request in device pixels, with the documented +/// fallbacks and a sanity ceiling. +fn font_px(req: &TextLayoutRequest) -> f32 { + let pt = if req.font_size_pt.is_finite() && req.font_size_pt > 0.0 { + req.font_size_pt + } else { + DEFAULT_FONT_SIZE_PT + }; + let dots_per_meter = if req.dots_per_meter > 0 { + req.dots_per_meter as f64 + } else { + DEFAULT_DOTS_PER_METER + }; + let px = pt * dots_per_meter * (METERS_PER_INCH / POINTS_PER_INCH); + if !px.is_finite() || px <= 0.0 { + return DEFAULT_FONT_PX; + } + (px as f32).min(MAX_FONT_PX) +} + +/// Flattens the HTML the text nodes may carry into plain text. +/// +/// This mirrors `oak_node::nodes::textv3`'s legacy-HTML stripper (which +/// is crate-private): tags are dropped without being rescanned, the +/// entities it knows are decoded, unknown entities and bare `&` are kept +/// as-is, and whitespace is neither collapsed nor trimmed. On top of that, +/// `
` and the block-level end tags (`

`, ``, ``, the +/// headings, table rows, …) become line breaks so the paragraph structure +/// of `Html` / `OliveHtml` text survives flattening; the breaks a trailing +/// block end tag would add are dropped. +fn html_to_plain(html: &str) -> String { + /// Whether `chars` starts with the (ASCII) `entity` text. + fn starts_with(chars: &[char], entity: &str) -> bool { + let mut it = chars.iter(); + entity.chars().all(|c| it.next() == Some(&c)) + } + + const ENTITIES: [(&str, &str); 6] = [ + ("&", "&"), + ("<", "<"), + (">", ">"), + (""", "\""), + ("'", "'"), + (" ", " "), + ]; + + /// Block-level end tags that start a new line, plus `
` itself. + const LINE_BREAK_TAGS: [&str; 21] = [ + "br", + "/p", + "/div", + "/li", + "/tr", + "/h1", + "/h2", + "/h3", + "/h4", + "/h5", + "/h6", + "/blockquote", + "/pre", + "/table", + "/ul", + "/ol", + "/dl", + "/dt", + "/dd", + "/section", + "/figure", + ]; + + let chars: Vec = html.chars().collect(); + let mut out = String::with_capacity(html.len()); + let mut i = 0; + while i < chars.len() { + match chars[i] { + '<' => { + // Drop up to and including the tag's closing '>'; an + // unterminated tag drops the remainder. The tag text is + // discarded, never rescanned, so a decoded `<p>` + // cannot turn into a tag afterwards. + let start = i + 1; + let mut end = start; + while end < chars.len() && chars[end] != '>' { + end += 1; + } + let tag: String = chars[start..end].iter().collect::().to_lowercase(); + let trimmed = tag.trim_start(); + let (closing, rest) = match trimmed.strip_prefix('/') { + Some(rest) => (true, rest.trim_start()), + None => (false, trimmed), + }; + let name: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric()) + .collect(); + let name = if closing { format!("/{name}") } else { name }; + if !out.is_empty() && LINE_BREAK_TAGS.contains(&name.as_str()) { + out.push('\n'); + } + i = end + 1; + } + '&' => { + let decoded = ENTITIES + .iter() + .find(|(entity, _)| starts_with(&chars[i..], entity)); + match decoded { + Some((entity, replacement)) => { + out.push_str(replacement); + i += entity.chars().count(); + } + None => { + // Unknown entity (or a bare '&'): keep it as-is. + out.push('&'); + i += 1; + } + } + } + c => { + out.push(c); + i += 1; + } + } + } + while out.ends_with('\n') { + out.pop(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex as StdMutex; + + /// The hook statics are process-global; the one test that installs them + /// holds this so it cannot race a future one. + static HOOK_LOCK: StdMutex<()> = StdMutex::new(()); + + /// A plain-text request with the test defaults (72 pt at 3780 + /// dots/meter, no wrap, no centering, backend default family). + fn plain(text: &str, font_size_pt: f64, wrap_width: f64) -> TextLayoutRequest { + TextLayoutRequest { + text: text.to_string(), + mode: TextLayoutMode::PlainText, + font_family: String::new(), + font_size_pt, + dots_per_meter: 3780, + wrap_width, + center_horizontally: false, + } + } + + /// A transform with only the scale set (the rest neutral). + fn scaled(scale: f64) -> TextRenderTransform { + TextRenderTransform { + scale, + ..TextRenderTransform::default() + } + } + + /// Renders a request into a zeroed buffer and returns it. + fn render_into( + req: &TextLayoutRequest, + transform: &TextRenderTransform, + width: i32, + height: i32, + channel_count: i32, + ) -> Vec { + let mut data = vec![0u8; (width * height * channel_count) as usize]; + render( + req, + transform, + TextRenderTarget { + data: &mut data, + width, + height, + linesize_bytes: width * channel_count, + channel_count, + }, + ); + data + } + + /// Bounding box (`min_x`, `min_y`, `max_x`, `max_y`) of the nonzero + /// pixels of a grayscale buffer; `None` when nothing was painted. + fn ink_bounds(data: &[u8], width: i32, height: i32) -> Option<(i32, i32, i32, i32)> { + let mut bounds: Option<(i32, i32, i32, i32)> = None; + for y in 0..height { + for x in 0..width { + if data[(y * width + x) as usize] != 0 { + bounds = Some(match bounds { + None => (x, y, x, y), + Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)), + }); + } + } + } + bounds + } + + fn has_ink(data: &[u8]) -> bool { + data.iter().any(|b| *b != 0) + } + + #[test] + fn plain_text_measures_nonzero() { + let size = measure(&plain("Hello", 72.0, 0.0)); + assert!(size.width > 0.0, "width = {}", size.width); + assert!(size.height > 0.0, "height = {}", size.height); + } + + #[test] + fn measure_scales_with_font_size() { + let small = measure(&plain("Hello", 36.0, 0.0)); + let large = measure(&plain("Hello", 72.0, 0.0)); + assert!(large.width > small.width * 1.5); + assert!(large.height > small.height * 1.5); + } + + #[test] + fn cjk_text_measures_nonzero() { + // No explicit family: the default fallback chain has to resolve the + // glyphs through fontconfig. + let size = measure(&plain("中文字体测试", 72.0, 0.0)); + assert!(size.width > 0.0, "width = {}", size.width); + assert!(size.height > 0.0, "height = {}", size.height); + } + + #[test] + fn empty_text_measures_zero() { + let size = measure(&plain("", 72.0, 0.0)); + assert_eq!(size.width, 0.0); + assert_eq!(size.height, 0.0); + } + + #[test] + fn multi_line_is_taller_than_single_line() { + let one = measure(&plain("Hello", 72.0, 0.0)); + let two = measure(&plain("Hello\nWorld", 72.0, 0.0)); + assert!(two.height > one.height); + } + + #[test] + fn wrap_width_wraps() { + let unwrapped = measure(&plain("中文字体测试", 72.0, 0.0)); + let wrapped = measure(&plain("中文字体测试", 72.0, 200.0)); + assert!( + wrapped.width < unwrapped.width, + "wrapped {} >= unwrapped {}", + wrapped.width, + unwrapped.width + ); + assert!(wrapped.width <= 200.0, "wrapped width {}", wrapped.width); + assert!(wrapped.height > unwrapped.height); + } + + #[test] + fn html_mode_flattens_tags() { + let plain_req = plain("Hello", 72.0, 0.0); + let html_req = TextLayoutRequest { + text: "Hello".to_string(), + mode: TextLayoutMode::Html, + ..plain("Hello", 72.0, 0.0) + }; + assert_eq!(measure(&html_req).width, measure(&plain_req).width); + assert_eq!(measure(&html_req).height, measure(&plain_req).height); + } + + #[test] + fn html_to_plain_decodes_and_breaks() { + assert_eq!(html_to_plain("a"), "a"); + assert_eq!(html_to_plain("a
b"), "a\nb"); + assert_eq!(html_to_plain("a
b"), "a\nb"); + assert_eq!(html_to_plain("

a

b

"), "a\nb"); + assert_eq!(html_to_plain("ab"), "a\nb"); + assert_eq!(html_to_plain("&<>"' "), "&<>\"' "); + // Unknown entities and bare `&` survive untouched. + assert_eq!(html_to_plain("a &b"), "a &b"); + // A decoded `<p>` is text, never rescanned into a tag. + assert_eq!(html_to_plain("<p>"), "

"); + // Unterminated tags drop the remainder; trailing breaks are dropped. + assert_eq!(html_to_plain("

a"), "a"); + assert_eq!(html_to_plain("a

"), "a"); + } + + #[test] + fn font_families_are_sorted_and_unique() { + let families = font_families(); + assert!( + !families.is_empty(), + "the system font database has no family" + ); + let mut sorted = families.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(families, sorted); + // Cached: the second call hands out the same list. + assert_eq!(font_families(), families); + } + + #[test] + fn font_pixel_size_fallbacks() { + let base = plain("x", 72.0, 0.0); + // 72 pt at 3780 dots/meter == 96 DPI == 96.012 px. + assert!((font_px(&base) as f64 - 96.012).abs() < 0.01); + // 0 / non-finite point sizes fall back to 72 pt. + assert_eq!(font_px(&plain("x", 0.0, 0.0)), font_px(&base)); + assert_eq!(font_px(&plain("x", f64::NAN, 0.0)), font_px(&base)); + // Non-positive dots-per-meter falls back to 3780. + let mut req = plain("x", 72.0, 0.0); + req.dots_per_meter = 0; + assert_eq!(font_px(&req), font_px(&base)); + req.dots_per_meter = -3780; + assert_eq!(font_px(&req), font_px(&base)); + // Absurd sizes clamp instead of asking swash for a huge bitmap. + assert_eq!(font_px(&plain("x", 1.0e9, 0.0)), MAX_FONT_PX); + } + + #[test] + fn render_writes_premultiplied_white_rgba() { + let data = render_into(&plain("H", 72.0, 0.0), &scaled(1.0), 160, 160, 4); + let mut ink = 0usize; + for px in data.chunks_exact(4) { + let (r, g, b, a) = (px[0], px[1], px[2], px[3]); + if a == 0 && r == 0 && g == 0 && b == 0 { + continue; + } + ink += 1; + assert_eq!((r, g, b), (r, r, r), "non-white pixel {px:?}"); + assert!(r <= a, "not premultiplied: {px:?}"); + } + assert!(ink > 0, "the render painted nothing"); + } + + #[test] + fn render_writes_grayscale_coverage() { + let req = plain("H", 72.0, 0.0); + let gray = render_into(&req, &scaled(1.0), 160, 160, 1); + assert!(has_ink(&gray), "the render painted nothing"); + // The RGBA alpha is that same coverage (both blend over a zeroed + // buffer), so the two formats have to agree pixel for pixel. + let rgba = render_into(&req, &scaled(1.0), 160, 160, 4); + for (i, alpha) in rgba.chunks_exact(4).map(|px| px[3]).enumerate() { + assert_eq!(gray[i], alpha, "coverage mismatch at pixel {i}"); + } + } + + #[test] + fn render_scales_the_glyph() { + let req = plain("H", 72.0, 0.0); + let one = render_into(&req, &scaled(1.0), 256, 256, 1); + let two = render_into(&req, &scaled(2.0), 256, 256, 1); + let (x0, y0, x1, y1) = ink_bounds(&one, 256, 256).expect("scale 1 ink"); + let (u0, v0, u1, v1) = ink_bounds(&two, 256, 256).expect("scale 2 ink"); + assert!((u1 - u0) > (x1 - x0) * 3 / 2, "width did not scale"); + assert!((v1 - v0) > (y1 - y0) * 3 / 2, "height did not scale"); + } + + #[test] + fn render_skips_degenerate_scale() { + for scale in [0.0, -1.0, f64::NAN, f64::INFINITY] { + let data = render_into(&plain("H", 72.0, 0.0), &scaled(scale), 64, 64, 1); + assert!(!has_ink(&data), "scale {scale} painted something"); + } + } + + #[test] + fn render_clips_to_the_clip_rect() { + let req = plain("H", 72.0, 0.0); + let full = render_into(&req, &scaled(1.0), 160, 160, 1); + let (min_x, _, _, _) = ink_bounds(&full, 160, 160).expect("unclipped ink"); + assert!(min_x < 40, "the unclipped ink starts at {min_x}"); + + // A clip that keeps only the right half of the glyph. + let clipped = render_into( + &req, + &TextRenderTransform { + clip_enabled: true, + clip_offset_x: 40.0, + clip_width: 200.0, + clip_height: 200.0, + ..scaled(1.0) + }, + 160, + 160, + 1, + ); + let (cmin_x, _, cmax_x, cmax_y) = + ink_bounds(&clipped, 160, 160).expect("ink inside the clip"); + assert!(cmin_x >= 40, "ink left of the clip at {cmin_x}"); + assert!(cmin_x > min_x, "the clip removed nothing"); + assert!(cmax_x < 160 && cmax_y < 160); + + // An empty clip rect and a non-finite one paint nothing. + let empty = render_into( + &req, + &TextRenderTransform { + clip_enabled: true, + clip_width: 0.0, + clip_height: 0.0, + ..scaled(1.0) + }, + 160, + 160, + 1, + ); + assert!(!has_ink(&empty)); + let nan = render_into( + &req, + &TextRenderTransform { + clip_enabled: true, + clip_offset_x: f64::NAN, + clip_width: 64.0, + clip_height: 64.0, + ..scaled(1.0) + }, + 160, + 160, + 1, + ); + assert!(!has_ink(&nan)); + } + + #[test] + fn render_honors_draw_offset() { + let req = plain("H", 72.0, 0.0); + let base = render_into(&req, &scaled(1.0), 256, 256, 1); + let shifted = render_into( + &req, + &TextRenderTransform { + draw_offset_x: 100.0, + draw_offset_y: 100.0, + ..scaled(1.0) + }, + 256, + 256, + 1, + ); + let (bx, by, _, _) = ink_bounds(&base, 256, 256).expect("unshifted ink"); + let (sx, sy, _, _) = ink_bounds(&shifted, 256, 256).expect("shifted ink"); + // The offset is applied in device pixels after the scale, so the + // corners move by exactly the offset. + assert_eq!((sx, sy), (bx + 100, by + 100)); + } + + #[test] + fn hooks_install_and_drive_the_nodes() { + let _guard = HOOK_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + use oak_node::nodes::textbackend::{text_measure_backend, text_render_backend}; + + install(); + let measure_fn = text_measure_backend().expect("install() sets the measure hook"); + let render_fn = text_render_backend().expect("install() sets the render hook"); + + let req = plain("Hello", 72.0, 0.0); + let size = measure_fn(&req); + assert!(size.width > 0.0 && size.height > 0.0); + + let mut data = vec![0u8; 192 * 192]; + render_fn( + &req, + &scaled(1.0), + TextRenderTarget { + data: &mut data, + width: 192, + height: 192, + linesize_bytes: 192, + channel_count: 1, + }, + ); + assert!(has_ink(&data), "the render hook painted nothing"); + + // Leave the process hooks uninstalled like `textbackend`'s own tests + // do, so no later test depends on this module's state. + set_text_backends(None, None); + } +} diff --git a/crates/oak-app/src/panels/ofx_params.rs b/crates/oak-app/src/panels/ofx_params.rs index 9e9a22d6d..656eba14a 100644 --- a/crates/oak-app/src/panels/ofx_params.rs +++ b/crates/oak-app/src/panels/ofx_params.rs @@ -26,15 +26,19 @@ //! - boolean → [`CheckBox`] //! - combo → [`ComboBox`] fed from the repeated `("combo_option", _)` //! properties; string-combo values come from `("combo_value", _)` -//! - text → [`EditableTextState`] +//! - text → [`EditableTextState`] (`text_input`; the `("multiline", true)` +//! property that [`super::effectchain::effect_params`] adds to the v3 +//! text node's text inputs builds a `text_area` instead) //! - vec2 / vec3 → one [`SpinBox`] per component //! - color → a swatch + deferred popup picker ([`OfxColorPicker`]: //! R/G/B/A sliders, live preview, hex input, Cancel/OK) //! - push button → a clickable button (`AppEngine::effect_push_button`) //! -//! Secret (HIDDEN) inputs never reach the snapshot, so they render -//! nothing; `ui_group` / `ui_page` become section titles. Every edit is -//! routed through [`AppEngine::set_effect_param`] (undoable). +//! Secret (HIDDEN) inputs never reach the snapshot the facade builds, and +//! the view skips them again on its own (a plugin or mock engine can hand +//! one over; the inspector must not show a secret input even then). +//! `ui_group` / `ui_page` become section titles. Every edit is routed +//! through [`AppEngine::set_effect_param`] (undoable). //! //! The control set is built once per expanded card — the stack view caches //! the params view per effect (recreating it per render would kill @@ -93,8 +97,13 @@ enum ControlKind { Spin(Vec<(Entity, usize)>), /// A colour swatch + popup picker (color). Color(Entity), - /// A text field (string). - Text(Entity), + /// A text field: single-line by default, multi-line when the param + /// carries the `("multiline", true)` property (the v3 text node's text + /// inputs; the inspector then builds a `text_area`). + Text { + editor: Entity, + multiline: bool, + }, /// One curve editor per dimension (parametric parameter). Curve(Vec>), /// A push button (rendered inline, no entity). @@ -122,6 +131,11 @@ impl OfxParamsView { let mut control_id = 0usize; let controls = params .iter() + // The built-in facade already drops hidden inputs, but a plugin + // or mock engine's snapshot can carry them; a secret input must + // never reach the inspector, so filter here as well (the params + // view is the last stop before a control is built). + .filter(|param| param.flags & oak_node::input::flags::HIDDEN == 0) .map(|param| build_control(param, &mut control_id, window, cx)) .collect(); @@ -201,7 +215,7 @@ impl OfxParamsView { picker.update(cx, |picker, cx| picker.apply_viewer_pick(color, cx)); } } - ControlKind::Text(editor) => { + ControlKind::Text { editor, .. } => { let text = match ¶m.value { NodeValue::Text(s) => s.clone(), NodeValue::StrCombo(s) => s.clone(), @@ -425,6 +439,16 @@ fn curve_points_close( }) } +/// Whether a parameter asks for a multi-line text field (the +/// `("multiline", true)` property the facade adds to the v3 text node's +/// text inputs). +fn is_multiline(param: &EffectParam) -> bool { + param + .properties + .iter() + .any(|(k, v)| k == "multiline" && matches!(v, NodeValue::Boolean(true))) +} + /// The SliderValue for a param's current value (int → Integer, float → /// Float). fn slider_value(param: &EffectParam) -> SliderValue { @@ -435,17 +459,39 @@ fn slider_value(param: &EffectParam) -> SliderValue { } } -/// The selected option index of a combo/string-combo parameter. Integer -/// combos carry the index directly; string combos are matched against the -/// `("combo_value", _)` (or `("combo_option", _)`) list by value. -fn combo_index_for(param: &EffectParam) -> usize { - if param.value_type == ValueType::StrCombo { +/// The option list of a combo/string-combo parameter: the +/// `("combo_value", _)` strings when it has any, else the +/// `("combo_option", _)` labels. A string combo whose current value is not +/// in the list (a font family saved on another machine, say) keeps its +/// value: it goes in front, so the combo shows what the node actually +/// holds instead of silently falling back to the first option. +fn combo_haystack(param: &EffectParam) -> Vec { + let mut haystack = if param.value_type == ValueType::StrCombo { let values = crate::oakui::effectchain::combo_values(param); - let haystack = if values.is_empty() { + if values.is_empty() { crate::oakui::effectchain::combo_options(param) } else { values - }; + } + } else { + crate::oakui::effectchain::combo_options(param) + }; + if param.value_type == ValueType::StrCombo { + if let NodeValue::StrCombo(s) | NodeValue::Text(s) = ¶m.value { + if !s.is_empty() && !haystack.iter().any(|v| v == s) { + haystack.insert(0, s.clone()); + } + } + } + haystack +} + +/// The selected option index of a combo/string-combo parameter. Integer +/// combos carry the index directly; string combos are matched against the +/// [`combo_haystack`] list by value. +fn combo_index_for(param: &EffectParam) -> usize { + if param.value_type == ValueType::StrCombo { + let haystack = combo_haystack(param); match ¶m.value { NodeValue::StrCombo(s) | NodeValue::Text(s) => { haystack.iter().position(|v| v == s).unwrap_or(0) @@ -479,27 +525,159 @@ fn default_range(value_type: ValueType) -> (f64, f64) { } } -/// The min/max from the parameter's `("min", Float)` / `("max", Float)` +/// The min/max from the parameter's `("min", _)` / `("max", _)` /// properties, falling back to [`default_range`]. fn numeric_range(param: &EffectParam) -> (f64, f64) { - let prop = |key: &str| { - param - .properties - .iter() - .find(|(k, _)| k == key) - .and_then(|(_, v)| match v { - NodeValue::Float(f) => Some(*f), - NodeValue::Int(i) => Some(*i as f64), - _ => None, - }) - }; let (dmin, dmax) = default_range(param.value_type); ( - prop("min").unwrap_or(dmin), - prop("max").unwrap_or(dmax), + range_property(param, "min").unwrap_or(dmin), + range_property(param, "max").unwrap_or(dmax), ) } +/// The `("min", _)` / `("max", _)` property of a parameter as a finite +/// number: a NaN or infinite bound would make the slider's arithmetic +/// meaningless, so it is ignored. +fn range_property(param: &EffectParam, key: &str) -> Option { + param + .properties + .iter() + .find(|(k, _)| k == key) + .and_then(|(_, v)| match v { + NodeValue::Float(f) => Some(*f), + NodeValue::Int(i) => Some(*i as f64), + _ => None, + }) + .filter(|v| v.is_finite()) +} + +/// The slider range and step of an int/float parameter: `(min, max, step)`. +/// +/// The OFX translation only attaches min/max to colour inputs, but the +/// built-in nodes attach `("min", _)` to the parameters whose domain has a +/// floor and no ceiling (font size, outline width, glow radius). A flat +/// default range then snaps the value onto a coarse grid nowhere near it — +/// `font_size_in` at 72 showed as 50.995 over a 1..10000 range. Instead the +/// value itself defines the grid: a "nice" step that divides the value's +/// distance from its bound, over a range holding 200 of them, so a +/// min-only parameter slides up from its floor with the handle exactly on +/// the value. The slider's double-click entry types exact values, so the +/// range never has to cover everything. +fn slider_range_and_step(param: &EffectParam) -> (f64, f64, f64) { + let value = param.value.to_double(); + if param.value_type == ValueType::Int { + // Integer parameters step by one; only the bounds need ordering + // (an inverted range would panic the slider's clamp). + let (min, max) = numeric_range(param); + let (mut lo, mut hi) = (min.min(max), min.max(max)); + if value.is_finite() { + lo = lo.min(value); + hi = hi.max(value); + } + if hi <= lo { + hi = lo + 200.0; + } + return (lo, hi, 1.0); + } + match (range_property(param, "min"), range_property(param, "max")) { + // A floor but no ceiling. + (Some(min), None) => { + let v = if value.is_finite() { value.max(min) } else { min }; + let span = v - min; + let step = if span > 0.0 { + nice_grid_step(span) + } else { + 1.0 + }; + (min, min + 200.0 * step, step) + } + // A ceiling but no floor: the mirror image. + (None, Some(max)) => { + let v = if value.is_finite() { value.min(max) } else { max }; + let span = max - v; + let step = if span > 0.0 { + nice_grid_step(span) + } else { + 1.0 + }; + (max - 200.0 * step, max, step) + } + // Both bounds, or neither (the wide default range): one step over + // the whole range, rounded so the value lands on the grid. + (bound_min, bound_max) => { + let (dmin, dmax) = default_range(param.value_type); + let min = bound_min.unwrap_or(dmin); + let max = bound_max.unwrap_or(dmax); + let (lo, hi) = (min.min(max), min.max(max)); + let step0 = ((hi - lo) / 200.0).max(0.001); + let span = if value.is_finite() { + value.clamp(lo, hi) - lo + } else { + 0.0 + }; + let step = if span > 0.0 { + span / (span / step0).round().max(1.0) + } else { + step0 + }; + (lo, hi, step) + } + } +} + +/// A "nice" step for a slider whose value sits `span` above its bound: one +/// of `1/2/5 × 10^k`, close to a twenty-fifth of the span (a slider wants a +/// few dozen steps to feel controllable) and dividing the span into a whole +/// number of them, so the value itself is on the grid. +fn nice_grid_step(span: f64) -> f64 { + let target = span / 25.0; + if !(target.is_finite() && target > 0.0) { + return 1.0; + } + let exp = target.log10().floor() as i32; + let mut best: Option = None; + for k in -2..=2 { + for m in [1.0, 2.0, 5.0] { + let step = scale_by_pow10(m, exp + k); + if !(step.is_finite() && step > 0.0) || step < span / 100.0 || step > span / 8.0 { + continue; + } + let steps = span / step; + if steps < 1.0 || (steps - steps.round()).abs() > 1e-9 * steps.abs().max(1.0) { + continue; + } + let better = match best { + Some(b) => (step / target).ln().abs() < (b / target).ln().abs(), + None => true, + }; + if better { + best = Some(step); + } + } + } + best.unwrap_or_else(|| { + let steps = (span / target).round().max(1.0); + span / steps + }) +} + +/// `m × 10^exp`, by repeated multiplication rather than `powf`: the +/// result is then bit-identical to the literal grid values (0.1, 0.01, …) +/// that a slider step of that size is expected to land on. +fn scale_by_pow10(m: f64, exp: i32) -> f64 { + let mut v = m; + if exp >= 0 { + for _ in 0..exp { + v *= 10.0; + } + } else { + for _ in 0..-exp { + v /= 10.0; + } + } + v +} + /// Builds one [`ParamControl`] for `param`, creating the control entities /// (each consuming one control id from `next_id`). fn build_control( @@ -510,17 +688,12 @@ fn build_control( ) -> ParamControl { let kind = match param.value_type { ValueType::Int | ValueType::Float => { - let (min, max) = numeric_range(param); + let (min, max, step) = slider_range_and_step(param); let kind = if param.value_type == ValueType::Int { ValueKind::Integer } else { ValueKind::Float }; - let step = if param.value_type == ValueType::Int { - 1.0 - } else { - ((max - min) / 200.0).max(0.001) - }; let default_raw = param.value.to_double().clamp(min, max); let model = SliderModel::new(kind, min, max, step, default_raw); let slider = cx.new(|cx| Slider::new(*next_id, model, window, cx)); @@ -537,16 +710,7 @@ fn build_control( ControlKind::CheckBox(check) } ValueType::Combo | ValueType::StrCombo => { - let options: Vec = if param.value_type == ValueType::StrCombo { - let values = crate::oakui::effectchain::combo_values(param); - if values.is_empty() { - crate::oakui::effectchain::combo_options(param) - } else { - values - } - } else { - crate::oakui::effectchain::combo_options(param) - }; + let options = combo_haystack(param); if options.is_empty() { // No option list: show the raw value read-only. let text = if param.value_type == ValueType::Combo { @@ -579,7 +743,10 @@ fn build_control( let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); editor.update(cx, |editor, cx| editor.emplace(&text, cx)); *next_id += 1; - ControlKind::Text(editor) + ControlKind::Text { + editor, + multiline: is_multiline(param), + } } ValueType::Vec2 | ValueType::Vec3 => { let components = value_components(¶m.value); @@ -707,12 +874,7 @@ fn wire_controls(view: &OfxParamsView, cx: &mut Context { - let values = crate::oakui::effectchain::combo_values(p); - let haystack = if values.is_empty() { - crate::oakui::effectchain::combo_options(p) - } else { - values - }; + let haystack = combo_haystack(p); NodeValue::StrCombo( haystack.get(*value).cloned().unwrap_or_default(), ) @@ -789,7 +951,7 @@ fn wire_controls(view: &OfxParamsView, cx: &mut Context { + ControlKind::Text { .. } => { // The text field commits explicitly (the commit button in the // row). No event subscription here: the params view is rebuilt // on every card render, so committing on TextChanged would @@ -927,12 +1089,37 @@ impl Render for OfxParamsView { ControlKind::Color(picker) => { div().flex_1().child(picker.clone()).into_any_element() } - ControlKind::Text(editor) => { + ControlKind::Text { editor, multiline } => { let weak = editor.downgrade(); let engine = self.engine.clone(); let effect = self.effect; let input_id = control.input_id.clone(); let editor_commit = editor.clone(); + let field = if *multiline { + // A multi-line field. `text_input` above is + // single-line only (and keeps its theme colors + // to itself), so the element is built here with + // the same colors the component would use, in a + // fixed-height box the text scrolls inside. + gpui_elements::editable_text::text_area(format!( + "ofx-param-{}", + control.input_id + )) + .state(weak) + .accepts_input(true) + .h(px(96.0)) + .text_color(colors.text) + .placeholder_color(colors.disabled.into()) + .selection_color(colors.selected.into()) + .caret_color(colors.text.into()) + .marked_color(colors.text.into()) + .into_any_element() + } else { + text_input(format!("ofx-param-{}", control.input_id), cx) + .state(weak) + .accepts_input(true) + .into_any_element() + }; div() .flex_1() .flex() @@ -946,7 +1133,7 @@ impl Render for OfxParamsView { .bg(colors.background) .px_2() .py_1() - .child(text_input(format!("ofx-param-{}", control.input_id), cx).state(weak).accepts_input(true)), + .child(field), ) .child( // Explicit commit: reads the field and pushes the @@ -2691,19 +2878,22 @@ mod tests { cx.run_until_parked(); let host = window.root(cx).expect("host root"); let view = cx.read(|cx| host.read(cx).view.clone()); + // Two inputs are text fields: the single-line `args_in` and the + // multi-line `plain_text_in`. This test types into the latter. let editor = cx.read(|cx| { view.read(cx) .controls .iter() - .find_map(|c| match &c.kind { - ControlKind::Text(editor) => Some(editor.clone()), - _ => None, + .find(|c| c.input_id == "plain_text_in") + .map(|c| match &c.kind { + ControlKind::Text { editor, .. } => editor.clone(), + _ => panic!("plain_text_in should build a text control"), }) - .expect("a text control") + .expect("the plain_text_in control") }); assert_eq!( cx.read(|cx| editor.read(cx).as_str().to_string()), - "

engine text

" + "文本\nsecond line" ); // Focus the field and type: the in-progress text must survive the @@ -2745,9 +2935,343 @@ mod tests { cx.run_until_parked(); assert_eq!( cx.read(|cx| editor.read(cx).as_str().to_string()), - "

engine text

", + "文本\nsecond line", "the field re-syncs to the engine value on blur" ); } + + /// The v3 text node's parameter set renders as one structured control per + /// visible input — a multi-line text field, a font-family combo, colour + /// pickers, sliders, checkboxes, vec2 spinboxes — and the three hidden + /// inputs (the legacy text, the vertical align, the use-args toggle) + /// render nothing at all. + #[gpui::test] + async fn text3_params_render_as_structured_controls(cx: &mut TestAppContext) { + use crate::oakui::mock::MockEngine; + struct Host { + view: Entity>, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.view.clone()) + } + } + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(400.0), px(600.0)), |window, cx| { + let engine = cx.new(|cx| MockEngine::create(cx)); + let view = cx.new(|cx| OfxParamsView::::new(EffectId(900), engine, window, cx)); + Host { view } + }); + cx.run_until_parked(); + let host = window.root(cx).expect("host root"); + let view = cx.read(|cx| host.read(cx).view.clone()); + + // The mock hands the hidden inputs over (a plugin engine's snapshot + // can carry them too); the view must drop them before any control is + // built. + let raw_ids: Vec = cx.read(|cx| { + view.read(cx) + .engine + .read(cx) + .effect_params(EffectId(900)) + .expect("the mock carries the text3 parameter set") + .into_iter() + .map(|p| p.input_id) + .collect() + }); + for hidden in ["text_in", "valign_in", "use_args_in"] { + assert!( + raw_ids.iter().any(|id| id == hidden), + "the mock snapshot carries {hidden} ({raw_ids:?})" + ); + } + + // Draw once: the render pass is where `sync_values` reapplies the + // engine snapshot to the widgets (the sliders snap to their grid). + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx).into_mut(); + visual.update(|window, cx| { + window.draw(cx).clear(); + }); + + /// One plain-data snapshot of the control set (the kinds carry + /// entities, so they cannot be compared directly). + struct Snapshot { + kinds: Vec<(String, &'static str)>, + sliders: Vec<(String, f64)>, + texts: Vec<(String, String, bool)>, + combos: Vec<(String, Option)>, + spins: Vec<(String, Vec)>, + colors: Vec, + } + let snap = visual.read(|cx| { + let view = view.read(cx); + let mut snap = Snapshot { + kinds: Vec::new(), + sliders: Vec::new(), + texts: Vec::new(), + combos: Vec::new(), + spins: Vec::new(), + colors: Vec::new(), + }; + for control in &view.controls { + let kind = match &control.kind { + ControlKind::Slider(slider) => { + snap.sliders + .push((control.input_id.clone(), slider.read(cx).value().to_f64())); + "slider" + } + ControlKind::CheckBox(_) => "checkbox", + ControlKind::Combo(combo) => { + snap.combos.push((control.input_id.clone(), combo.read(cx).selected())); + "combo" + } + ControlKind::Spin(spins) => { + snap.spins.push(( + control.input_id.clone(), + spins.iter().map(|(spin, _)| spin.read(cx).value().to_f64()).collect(), + )); + "spin" + } + ControlKind::Color(_) => { + snap.colors.push(control.input_id.clone()); + "color" + } + ControlKind::Text { editor, multiline } => { + snap.texts.push(( + control.input_id.clone(), + editor.read(cx).as_str().to_string(), + *multiline, + )); + if *multiline { + "text-area" + } else { + "text" + } + } + ControlKind::Curve(_) => "curve", + ControlKind::PushButton => "button", + ControlKind::ReadOnly(_) => "readonly", + }; + snap.kinds.push((control.input_id.clone(), kind)); + } + snap + }); + + let kinds: Vec<(&str, &str)> = snap + .kinds + .iter() + .map(|(id, kind)| (id.as_str(), *kind)) + .collect(); + assert_eq!( + kinds, + vec![ + ("pos_in", "spin"), + ("size_in", "spin"), + ("plain_text_in", "text-area"), + ("font_family_in", "combo"), + ("font_size_in", "slider"), + ("outline_enabled_in", "checkbox"), + ("outline_color_in", "color"), + ("outline_width_in", "slider"), + ("glow_enabled_in", "checkbox"), + ("glow_color_in", "color"), + ("glow_radius_in", "slider"), + ("args_in", "text"), + ], + "one control per visible text3 input, hidden inputs and secret textures skipped" + ); + + // Each numeric widget shows the engine value (not a grid neighbour). + let slider_values: Vec<(&str, f64)> = snap + .sliders + .iter() + .map(|(id, value)| (id.as_str(), *value)) + .collect(); + for (id, want) in [ + ("font_size_in", 72.0), + ("outline_width_in", 2.0), + ("glow_radius_in", 8.0), + ] { + let got = slider_values + .iter() + .find(|(input_id, _)| *input_id == id) + .unwrap_or_else(|| panic!("{id} should be a slider: {slider_values:?}")); + assert!( + (got.1 - want).abs() <= 1e-9 * want.abs().max(1.0), + "{id} shows {} but the engine holds {want}", + got.1 + ); + } + + assert_eq!( + snap.texts + .iter() + .map(|(id, text, multiline)| (id.as_str(), text.as_str(), *multiline)) + .collect::>(), + vec![ + ("plain_text_in", "文本\nsecond line", true), + ("args_in", "", false), + ], + "the editable text is multi-line and holds the node's text verbatim" + ); + assert_eq!( + snap.combos, + vec![("font_family_in".to_string(), Some(0))], + "the font family is a (string) combo" + ); + assert_eq!( + snap.colors, + vec!["outline_color_in".to_string(), "glow_color_in".to_string()], + "both colours get a picker" + ); + assert_eq!( + snap.spins, + vec![ + ("pos_in".to_string(), vec![0.0, 0.0]), + ("size_in".to_string(), vec![400.0, 300.0]), + ], + "the vec2 params get one spinbox per component" + ); + } + + /// Every numeric parameter seeds its slider on the step grid, inside the + /// range, and a re-sync does not move it: `font_size_in` (72 with a floor + /// of 1) used to show 50.995, because the range came from the type + /// default and the value snapped onto a coarse grid nowhere near it. + #[test] + fn numeric_params_stay_on_their_slider_grid() { + fn param( + value_type: ValueType, + value: NodeValue, + properties: Vec<(&str, NodeValue)>, + ) -> EffectParam { + EffectParam { + input_id: "test_in".to_string(), + display_name: "Test".to_string(), + value_type, + value, + flags: 0, + properties: properties + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + } + } + fn close(got: f64, want: f64) -> bool { + (got - want).abs() <= 1e-9 * want.abs().max(1.0) + } + + struct Case { + label: &'static str, + param: EffectParam, + want_value: f64, + want_range: (f64, f64), + } + let cases = vec![ + Case { + label: "font size 72 over a floor of 1", + param: param( + ValueType::Float, + NodeValue::Float(72.0), + vec![("min", NodeValue::Float(1.0))], + ), + want_value: 72.0, + want_range: (1.0, 201.0), + }, + Case { + label: "outline width 2 over a floor of 0", + param: param( + ValueType::Float, + NodeValue::Float(2.0), + vec![("min", NodeValue::Float(0.0))], + ), + want_value: 2.0, + want_range: (0.0, 20.0), + }, + Case { + label: "glow radius 8 over a floor of 0", + param: param( + ValueType::Float, + NodeValue::Float(8.0), + vec![("min", NodeValue::Float(0.0))], + ), + want_value: 8.0, + want_range: (0.0, 100.0), + }, + Case { + label: "a value sitting on its floor", + param: param( + ValueType::Float, + NodeValue::Float(1.0), + vec![("min", NodeValue::Float(1.0))], + ), + want_value: 1.0, + want_range: (1.0, 201.0), + }, + Case { + label: "a bounded 0..1 float", + param: param( + ValueType::Float, + NodeValue::Float(0.5), + vec![("min", NodeValue::Float(0.0)), ("max", NodeValue::Float(1.0))], + ), + want_value: 0.5, + want_range: (0.0, 1.0), + }, + Case { + label: "an unbounded float (the wide default range)", + param: param(ValueType::Float, NodeValue::Float(0.7234), Vec::new()), + want_value: 0.7234, + want_range: (-10000.0, 10000.0), + }, + Case { + label: "an int over 0..10", + param: param( + ValueType::Int, + NodeValue::Int(5), + vec![("min", NodeValue::Int(0)), ("max", NodeValue::Int(10))], + ), + want_value: 5.0, + want_range: (0.0, 10.0), + }, + ]; + + for case in cases { + let label = case.label; + let (min, max, step) = slider_range_and_step(&case.param); + assert!( + min.is_finite() && max.is_finite() && step.is_finite() && step > 0.0, + "{label}: bad slider geometry ({min}, {max}) step {step}" + ); + assert_eq!((min, max), case.want_range, "{label}: the slider range"); + let kind = if case.param.value_type == ValueType::Int { + ValueKind::Integer + } else { + ValueKind::Float + }; + let engine_value = case.param.value.to_double(); + let mut model = SliderModel::new(kind, min, max, step, engine_value.clamp(min, max)); + assert!( + close(model.raw, case.want_value), + "{label}: seeded at {} instead of {}", + model.raw, + case.want_value + ); + // `sync_values` re-applies the engine value on every render: the + // snapshot must land back on the same position. + model.set_value(SliderValue::Float(engine_value)); + assert!( + close(model.raw, case.want_value), + "{label}: a re-sync moved the value to {} (want {})", + model.raw, + case.want_value + ); + assert!( + min <= model.raw && model.raw <= max, + "{label}: {} is outside {min}..{max}", + model.raw + ); + } + } } diff --git a/crates/oak-app/src/panels/project_explorer.rs b/crates/oak-app/src/panels/project_explorer.rs index 20812937a..bbf9ee268 100644 --- a/crates/oak-app/src/panels/project_explorer.rs +++ b/crates/oak-app/src/panels/project_explorer.rs @@ -262,6 +262,27 @@ impl Render for ProjectExplorerPanel { cx.emit(NewSequenceRequested); })) .child(crate::i18n::tr("project.new_sequence")); + // 添加文本素材: creates a text generator (a bin entry that drops on + // the timeline as a generator clip). + let add_text_button = div() + .id("project-add-text-footage") + .debug_selector(|| "project-add-text-footage".into()) + .px_2() + .py_0p5() + .rounded_sm() + .flex() + .items_center() + .cursor_pointer() + .text_color(colors.text) + .text_xs() + .hover(|style| style.bg(colors.selected)) + .tooltip(move |window, cx| { + tooltip_view(crate::i18n::tr("project.add_text_footage").into(), window, cx) + }) + .on_click(cx.listener(|_this, _event: &ClickEvent, _window, cx| { + cx.emit(NewTextFootageRequested); + })) + .child(crate::i18n::tr("project.add_text_footage")); let header = div() .flex() .items_center() @@ -274,6 +295,7 @@ impl Render for ProjectExplorerPanel { .text_sm() .text_color(colors.text) .child(div().flex_1().child(crate::i18n::tr("panel.project"))) + .child(add_text_button) .child(new_sequence_button); div() .size_full() @@ -316,6 +338,13 @@ pub struct NewSequenceRequested; impl EventEmitter for ProjectExplorerPanel {} +/// The project explorer asked the shell to create a text generator bin +/// entry (the header's 添加文本素材 action). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NewTextFootageRequested; + +impl EventEmitter for ProjectExplorerPanel {} + /// The project explorer asked the shell to rename entry `id`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RenameRequested(pub u64); diff --git a/crates/oak-node/src/nodes/textv3.rs b/crates/oak-node/src/nodes/textv3.rs index 430d35b53..5d2406049 100644 --- a/crates/oak-node/src/nodes/textv3.rs +++ b/crates/oak-node/src/nodes/textv3.rs @@ -26,17 +26,38 @@ //! RGBA8888-premultiplied buffer); it now runs behind the //! facade-installed hooks in [`super::textbackend`]. No Rust font crate //! is chosen here on purpose. +//! +//! REDESIGN (Rust-only, no C++ counterpart): on top of the ported v3 +//! node this adds a structured, user-facing input set — `plain_text_in`, +//! `font_family_in`, `font_size_in` and the outline/glow enable/color/ +//! size inputs — while the legacy `text_in` HTML input stays as the +//! serialized compatibility carrier (now hidden) and as the migration +//! source for pre-redesign projects ([`migrate_legacy_html`]). The +//! outline/glow inputs drive a GPU post-process chain in [`value`]: the +//! rasterized text coverage is dilated (square structuring element, +//! `outline_width_in` clamped to 0..7) and colorized into a stroke, +//! and/or blurred once per axis (two passes, `glow_radius_in` clamped +//! to 0..64) and colorized into a glow; the results are alpha-over'd +//! beneath the text. With both enabled the outline runs first and the +//! glow samples the stroke. With both disabled (or no render backend +//! installed) the node keeps the pre-redesign deferred null-job +//! behavior. use crate::factory::NodeMeta; +use crate::jobs::ShaderJobPayload; use crate::node::{Category, NodeBehavior, NodeCore}; use crate::value::{NodeValue, NodeValueRow, NodeValueTable}; +use oak_core::frame::VideoParamsPod; +use oak_core::texture::{Frame, Texture}; use oak_core::Rational; use super::textbackend::{TextLayoutMode, TextLayoutRequest, TextLayoutSize, TextRenderTransform}; /// Text input id (C++ `k_text_input`). Type: text; default -/// `"

Sample Text

"`; -/// properties: `vieweronly = true`. +/// `LEGACY_DEFAULT_TEXT_HTML`; properties: `vieweronly = true`; flags: +/// hidden (REDESIGN: carried internally and kept for the serialized +/// compatibility of pre-redesign projects; the user-facing text is +/// [`PLAIN_TEXT_INPUT`]). pub const TEXT_INPUT: &str = "text_in"; /// Vertical alignment input id (C++ `k_vertical_alignment_input`). @@ -52,6 +73,65 @@ pub const USE_ARGS_INPUT: &str = "use_args_in"; /// flags: array; properties: `arraystart = 1`. pub const ARGS_INPUT: &str = "args_in"; +/// Plain text input id (REDESIGN addition, no C++ counterpart). Type: +/// text; default [`DEFAULT_PLAIN_TEXT`]. The editable user-facing text +/// and — when a text layout backend is installed — the text the +/// generator lays out; the legacy (hidden) [`TEXT_INPUT`] HTML stays as +/// the compatibility carrier. +pub const PLAIN_TEXT_INPUT: &str = "plain_text_in"; + +/// Font family input id (REDESIGN addition, no C++ counterpart). Type: +/// str-combo; default empty (the backend's default font). The option +/// list is injected by the backend layer (a `combo_option` property), +/// so this node has no [`TextGeneratorV3::input_combo_strings`] entry +/// for it; free-form entry is allowed. +pub const FONT_FAMILY_INPUT: &str = "font_family_in"; + +/// Font size input id (REDESIGN addition, no C++ counterpart). Type: +/// float; default `72.0`; properties: `min = 1.0`. +pub const FONT_SIZE_INPUT: &str = "font_size_in"; + +/// Outline enable toggle input id (REDESIGN addition, no C++ +/// counterpart). Type: boolean; default `false`. +pub const OUTLINE_ENABLED_INPUT: &str = "outline_enabled_in"; + +/// Outline color input id (REDESIGN addition, no C++ counterpart). +/// Type: color; default opaque black. +pub const OUTLINE_COLOR_INPUT: &str = "outline_color_in"; + +/// Outline width input id (REDESIGN addition, no C++ counterpart). +/// Type: float; default `2.0`; properties: `min = 0.0`. +pub const OUTLINE_WIDTH_INPUT: &str = "outline_width_in"; + +/// Glow enable toggle input id (REDESIGN addition, no C++ +/// counterpart). Type: boolean; default `false`. +pub const GLOW_ENABLED_INPUT: &str = "glow_enabled_in"; + +/// Glow color input id (REDESIGN addition, no C++ counterpart). Type: +/// color; default opaque yellow. +pub const GLOW_COLOR_INPUT: &str = "glow_color_in"; + +/// Glow radius input id (REDESIGN addition, no C++ counterpart). Type: +/// float; default `8.0`; properties: `min = 0.0`. +pub const GLOW_RADIUS_INPUT: &str = "glow_radius_in"; + +/// Font color input id (REDESIGN addition, no C++ counterpart). Type: +/// color; default opaque white (the backend rasterizes in white, so the +/// default leaves the raster unchanged). The glyphs are tinted with it +/// during rasterization, premultiplied — the same treatment the outline +/// and glow colorize passes give their own layers. +pub const COLOR_INPUT: &str = "color_in"; + +/// Default of the legacy [`TEXT_INPUT`] HTML payload (the C++ +/// `k_text_input` default, verbatim). Used by [`create`] and by +/// [`migrate_legacy_html`] to recognize an untouched legacy value. +pub const LEGACY_DEFAULT_TEXT_HTML: &str = + "

Sample Text

"; + +/// Default of [`PLAIN_TEXT_INPUT`] (REDESIGN addition): the localized +/// placeholder text new text nodes start with. +pub const DEFAULT_PLAIN_TEXT: &str = "文本"; + /// Vertical alignment (C++ `TextGeneratorV3::VerticalAlignment`, values /// `k_v_align_top = 0`, `k_v_align_middle = 1`, `k_v_align_bottom = 2`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -96,11 +176,12 @@ pub struct TextGeneratorV3 { dont_emit_valign: bool, } -/// `Variant::to_string()` for the text input (Text payload, with a -/// numeric fallback for mis-typed connections). +/// `Variant::to_string()` for the text inputs (Text and string-combo +/// payloads — the latter for [`FONT_FAMILY_INPUT`] — with a numeric +/// fallback for mis-typed connections). fn to_text(v: &NodeValue) -> String { match v { - NodeValue::Text(s) => s.clone(), + NodeValue::Text(s) | NodeValue::StrCombo(s) => s.clone(), other => other.to_double().to_string(), } } @@ -121,6 +202,132 @@ fn to_vec2(v: &NodeValue) -> [f64; 2] { } } +/// Largest rasterization dimension accepted from `size_in`. Both the +/// CPU staging buffer and the intermediate GPU textures are `w * h * 4` +/// bytes, so an absurd shape size is clamped rather than allocated. +const MAX_RASTER_SIZE: i32 = 8192; + +/// Shader id of the outline dilation pass: a square dilation (the +/// algorithm of [`super::dilate`], with the radius read from +/// [`OUTLINE_WIDTH_INPUT`]). +pub const OUTLINE_DILATE_SHADER_ID: &str = "outline_dilate"; + +/// Shader id of the outline colorize pass: multiplies the dilated +/// coverage by [`OUTLINE_COLOR_INPUT`]. +pub const OUTLINE_COLORIZE_SHADER_ID: &str = "outline_colorize"; + +/// Shader id of the glow blur pass. The job runs one iteration per axis +/// (horizontal, then vertical). +pub const GLOW_BLUR_SHADER_ID: &str = "glow_blur"; + +/// Shader id of the glow colorize pass: multiplies the blurred coverage +/// by [`GLOW_COLOR_INPUT`]. +pub const GLOW_COLORIZE_SHADER_ID: &str = "glow_colorize"; + +/// Effect-texture input id shared by the four post-process shaders: the +/// coverage texture produced by the preceding pass. +const POST_TEXTURE_INPUT: &str = "tex_in"; + +/// Resolution input id. The raster resolution is inserted explicitly so +/// the evaluation pass cannot pre-fill it with the sequence resolution. +const RESOLUTION_INPUT: &str = "resolution_in"; + +/// Fragment shader of the outline dilation pass: the square +/// `(2r+1)^2` max filter of [`super::dilate`], with the radius taken +/// from `outline_width_in` (rounded and clamped to `0..=7`) instead of +/// the dilate node's own input and the rest copied verbatim. +const OUTLINE_DILATE_FRAG: &str = r#"uniform sampler2D tex_in; +uniform float outline_width_in; +uniform vec2 resolution_in; + +in vec2 ove_texcoord; +out vec4 frag_color; + +void main() { + int radius = int(clamp(outline_width_in, 0.0, 7.0) + 0.5); + vec2 texel = vec2(1.0) / resolution_in; + + vec4 acc = texture(tex_in, ove_texcoord); + for (int dy = -radius; dy <= radius; ++dy) { + for (int dx = -radius; dx <= radius; ++dx) { + vec2 uv = ove_texcoord + vec2(float(dx), float(dy)) * texel; + acc = max(acc, texture(tex_in, uv)); + } + } + + frag_color = acc; +} +"#; + +/// Fragment shader of the outline colorize pass: tint the coverage with +/// [`OUTLINE_COLOR_INPUT`] and emit it premultiplied, so it composites +/// as `color * alpha` over whatever is beneath it. +const OUTLINE_COLORIZE_FRAG: &str = r#"uniform sampler2D tex_in; +uniform vec4 outline_color_in; + +in vec2 ove_texcoord; +out vec4 frag_color; + +void main() { + vec4 coverage = texture(tex_in, ove_texcoord); + float alpha = coverage.a * outline_color_in.a; + + frag_color = vec4(outline_color_in.rgb * alpha, alpha); +} +"#; + +/// Fragment shader of the glow blur pass: a box blur over the axis +/// selected by `ove_iteration` (0 = horizontal, 1 = vertical), taps one +/// texel apart and averaged over `2r + 1`. `glow_radius_in` is rounded +/// and clamped to `0..=64`; a sub-pixel radius passes the texture +/// through. +const GLOW_BLUR_FRAG: &str = r#"uniform sampler2D tex_in; +uniform float glow_radius_in; +uniform vec2 resolution_in; + +uniform int ove_iteration; + +in vec2 ove_texcoord; +out vec4 frag_color; + +void main() { + int radius = int(clamp(glow_radius_in, 0.0, 64.0) + 0.5); + if (radius < 1) { + frag_color = texture(tex_in, ove_texcoord); + return; + } + + vec4 composite = vec4(0.0); + for (int i = -radius; i <= radius; ++i) { + vec2 uv = ove_texcoord; + if (ove_iteration == 0) { + uv.x += float(i) / resolution_in.x; + } else { + uv.y += float(i) / resolution_in.y; + } + composite += texture(tex_in, uv); + } + + frag_color = composite / float(radius * 2 + 1); +} +"#; + +/// Fragment shader of the glow colorize pass: tint the blurred coverage +/// with [`GLOW_COLOR_INPUT`] and emit it premultiplied. +const GLOW_COLORIZE_FRAG: &str = r#"uniform sampler2D tex_in; +uniform vec4 glow_color_in; + +in vec2 ove_texcoord; +out vec4 frag_color; + +void main() { + vec4 coverage = texture(tex_in, ove_texcoord); + float alpha = coverage.a * glow_color_in.a; + + frag_color = vec4(glow_color_in.rgb * alpha, alpha); +} +"#; + impl TextGeneratorV3 { /// Map our alignment to the gizmo's alignment int (C++ /// `get_qt_alignment_from_ours()`): Top -> `TextGizmo::k_align_top`, @@ -258,10 +465,21 @@ impl NodeBehavior for TextGeneratorV3 { /// "Text", `valign_in` -> "Vertical Alignment" (combo strings /// Top/Middle/Bottom), `args_in` -> "Arguments"; the base class /// retranslate covers the inherited shape inputs and `base_in` - /// ("Base"). + /// ("Base"). The redesign inputs are named here too ("Text" for + /// `plain_text_in`, "Font Family", "Font Size", "Outline"/"Outline + /// Color"/"Outline Width", "Glow"/"Glow Color"/"Glow Radius"). fn input_name<'a>(&self, id: &'a str) -> &'a str { match id { - TEXT_INPUT => "Text", + TEXT_INPUT | PLAIN_TEXT_INPUT => "Text", + FONT_FAMILY_INPUT => "Font Family", + FONT_SIZE_INPUT => "Font Size", + OUTLINE_ENABLED_INPUT => "Outline", + OUTLINE_COLOR_INPUT => "Outline Color", + OUTLINE_WIDTH_INPUT => "Outline Width", + GLOW_ENABLED_INPUT => "Glow", + GLOW_COLOR_INPUT => "Glow Color", + GLOW_RADIUS_INPUT => "Glow Radius", + COLOR_INPUT => "Color", VERTICAL_ALIGNMENT_INPUT => "Vertical Alignment", ARGS_INPUT => "Arguments", crate::nodes::generatorwithmerge::BASE_INPUT => "Base", @@ -271,7 +489,9 @@ impl NodeBehavior for TextGeneratorV3 { /// Combo input option labels (C++ `retranslate()` / /// `set_combo_box_strings`): `valign_in` -> "Top", "Middle", - /// "Bottom". + /// "Bottom". The redesign's `font_family_in` is a str-combo whose + /// option list is injected by the backend layer, so it has no + /// static labels here. fn input_combo_strings(&self, id: &str) -> Vec<&'static str> { match id { VERTICAL_ALIGNMENT_INPUT => vec!["Top", "Middle", "Bottom"], @@ -279,6 +499,22 @@ impl NodeBehavior for TextGeneratorV3 { } } + /// Shader code request: the merged generate shader is served under + /// the `"mrg"` request (shared with + /// [`crate::nodes::generatorwithmerge`]), the four post-process passes + /// (REDESIGN, no C++ counterpart) under their shader ids. Every other + /// request is unhandled. + fn shader_code(&self, request: &str) -> Option { + match request { + "mrg" => Some(crate::nodes::generatorwithmerge::merge_shader_frag().to_string()), + OUTLINE_DILATE_SHADER_ID => Some(OUTLINE_DILATE_FRAG.to_string()), + OUTLINE_COLORIZE_SHADER_ID => Some(OUTLINE_COLORIZE_FRAG.to_string()), + GLOW_BLUR_SHADER_ID => Some(GLOW_BLUR_FRAG.to_string()), + GLOW_COLORIZE_SHADER_ID => Some(GLOW_COLORIZE_FRAG.to_string()), + _ => None, + } + } + /// Evaluate outputs (C++ `value()`): if `use_args_in` is set and /// the args array is non-empty, expand `%N` placeholders in the /// text via [`Self::format_string`]; if the resulting text is @@ -289,13 +525,25 @@ impl NodeBehavior for TextGeneratorV3 { /// the job); otherwise pass the base input texture through /// unchanged. /// - /// The Rust model has no generate-job payload and no array value - /// representation: the job case goes through + /// REDESIGN: the text evaluated comes from [`PLAIN_TEXT_INPUT`] when + /// a text layout backend is installed (the structured path) and from + /// the legacy [`TEXT_INPUT`] HTML otherwise, so a backend-less build + /// keeps the pre-redesign behavior exactly. The C++ builds the layout + /// request here (`Texture::job(text_params, job)` carries the + /// laid-out document); the Rust job has no payload, so the request is + /// built by [`Self::layout_request`] instead and this method only + /// decides which text the job describes. + /// + /// REDESIGN (wave 2): with an outline and/or glow pass enabled, + /// [`Self::build_post_job`] rasterizes the evaluated text and the + /// pushed handle carries the post-process chain instead of the plain + /// generate job. Either way the job goes through /// [`crate::nodes::generatorwithmerge::GeneratorWithMerge::push_mergable_job`] - /// with a null handle, and the args array resolves to the single row - /// value when present (a per-element array model is deferred), so - /// `%N` expansion is exercised directly via [`Self::format_string`] - /// (`// CPP-PARITY: textv3.cpp` `value()`). + /// (with a null handle — the renderer-deferred generate job — when no + /// pass is enabled, which is the pre-redesign behavior); the args + /// array resolves to the single row value when present (a per-element + /// array model is deferred), so `%N` expansion is exercised directly + /// via [`Self::format_string`] (`// CPP-PARITY: textv3.cpp` `value()`). fn value( &self, core: &NodeCore, @@ -303,11 +551,7 @@ impl NodeBehavior for TextGeneratorV3 { time: Rational, table: &mut NodeValueTable, ) { - let text_val = inputs - .get(TEXT_INPUT) - .cloned() - .unwrap_or_else(|| core.value_at_time(TEXT_INPUT, -1, time)); - let mut text = to_text(&text_val); + let mut text = Self::job_text(Self::plain_text_path(), core, inputs, time); let use_args_val = inputs .get(USE_ARGS_INPUT) @@ -326,12 +570,27 @@ impl NodeBehavior for TextGeneratorV3 { if !text.is_empty() { // C++ `push_mergable_job(value, Texture::job(text_params, job), // table)` — merged over base_in when connected, else pushed - // directly. The null handle marks the renderer-deferred - // generate job. + // directly. An enabled outline/glow pass boxes the post-process + // chain (REDESIGN wave 2); with both passes off the plain + // raster is the output — the pre-backend deferred null job only + // applies when no render backend is installed. + let job = match self.build_post_job(core, inputs, &text, time) { + Some(job) => job, + None => { + let size = Self::raster_size(core, inputs, time); + let align = Self::alignment_arg(core, inputs); + let font_color = Self::color_arg(core, inputs, COLOR_INPUT, time); + match Self::rasterize_text(inputs, &text, size, align, font_color) { + // The addref runs while the value owns its handle + // reference (NodeValue::drop releases it) — taking + // the bare handle out first would dangle it. + Some(NodeValue::Texture(handle)) => unsafe { handle.addref() }, + _ => crate::handle::CHandle::null(), + } + } + }; crate::nodes::generatorwithmerge::GeneratorWithMerge::push_mergable_job( - inputs, - crate::handle::CHandle::null(), - table, + inputs, job, table, ); } else if let Some(base @ NodeValue::Texture(_)) = inputs.get(crate::nodes::generatorwithmerge::BASE_INPUT) @@ -385,12 +644,40 @@ impl NodeBehavior for TextGeneratorV3 { /// /// The text gizmo has no Rust model in this crate, so only the /// flag check is represented (`// CPP-PARITY: textv3.cpp` - /// `InputValueChangedEvent`). + /// `InputValueChangedEvent`). REDESIGN: a change of the legacy + /// [`TEXT_INPUT`] HTML also runs the one-shot HTML-to-plain-text + /// migration ([`migrate_legacy_html`] — one of its three call + /// sites, see the function). fn input_value_changed(&mut self, core: &mut NodeCore, input: &str, element: i32) { - let _ = (core, element); + let _ = element; if input == VERTICAL_ALIGNMENT_INPUT && !self.dont_emit_valign { // The C++ forwards the new alignment to the text gizmo here. } + if input == TEXT_INPUT { + migrate_legacy_html(core); + } + } + + /// Post-load fixups (C++ `PostLoadEvent`): runs the REDESIGN + /// HTML-to-plain-text migration ([`migrate_legacy_html`]) for load + /// pipelines that call this hook after the inputs are applied. + fn post_load(&mut self, core: &mut NodeCore) { + migrate_legacy_html(core); + } + + /// Custom load (C++ `load_custom()`): consume the `` segment + /// exactly like the default implementation, then run the REDESIGN + /// migration. The node-body parser writes the `` values before + /// the trailing `` element, so this is the hook that fires + /// with the legacy [`TEXT_INPUT`] value already loaded. + fn load_custom( + &mut self, + core: &mut NodeCore, + reader: &mut dyn crate::serializer::XmlRead, + ) -> bool { + reader.skip_current_element(); + migrate_legacy_html(core); + true } /// Deep copy (C++ `copy()`). @@ -412,23 +699,86 @@ impl NodeBehavior for TextGeneratorV3 { } impl TextGeneratorV3 { - /// Build the C++ `TextLayoutRequest` (textv3.cpp `generate_frame()`): - /// Olive-HTML text, 96 DPI (3780 dots/meter), wrapped to the shape - /// size X. Font family/size come from the markup; the backend defaults - /// are used when absent. + /// Whether the structured plain-text path is active (REDESIGN): + /// `true` when a text layout backend is installed. Without a backend + /// the node keeps the pre-redesign behavior (the legacy + /// [`TEXT_INPUT`] HTML is carried). + pub fn plain_text_path() -> bool { + super::textbackend::text_measure_backend().is_some() + } + + /// The text [`Self::value`] evaluates (REDESIGN split of the C++ + /// `value()` text extraction): [`PLAIN_TEXT_INPUT`] on the + /// `plain_text == true` path, the legacy [`TEXT_INPUT`] HTML + /// otherwise. Row values win over the core's value at `time`, like + /// the C++ input evaluation. + fn job_text( + plain_text: bool, + core: &NodeCore, + inputs: &NodeValueRow, + time: Rational, + ) -> String { + let id = if plain_text { + PLAIN_TEXT_INPUT + } else { + TEXT_INPUT + }; + let val = inputs + .get(id) + .cloned() + .unwrap_or_else(|| core.value_at_time(id, -1, time)); + to_text(&val) + } + + /// Build the C++ `TextLayoutRequest` (textv3.cpp `generate_frame()`) + /// for the active text path: with a backend installed, the structured + /// request — [`PLAIN_TEXT_INPUT`] as [`TextLayoutMode::PlainText`] + /// with `font_family_in`/`font_size_in` — else the pre-redesign + /// request, Olive-HTML text from [`TEXT_INPUT`] at 96 DPI (3780 + /// dots/meter) with the font taken from the markup. Both wrap to the + /// shape size X; the backend defaults are used when font family/size + /// are empty/zero. pub fn layout_request(row: &NodeValueRow) -> TextLayoutRequest { + Self::layout_request_path(Self::plain_text_path(), row) + } + + /// [`Self::layout_request`] with the path chosen explicitly: the + /// backend state is a process-global, so the tests drive both paths + /// through this parameter instead of installing hooks. + fn layout_request_path(plain_text: bool, row: &NodeValueRow) -> TextLayoutRequest { let size = row .get(crate::nodes::shapenodebase::SIZE_INPUT) .map(to_vec2) .unwrap_or([0.0, 0.0]); - TextLayoutRequest { - text: row.get(TEXT_INPUT).map(to_text).unwrap_or_else(String::new), - mode: TextLayoutMode::OliveHtml, - font_family: String::new(), - font_size_pt: 0.0, - dots_per_meter: 3780, - wrap_width: size[0], - center_horizontally: false, + if plain_text { + TextLayoutRequest { + text: row + .get(PLAIN_TEXT_INPUT) + .map(to_text) + .unwrap_or_else(String::new), + mode: TextLayoutMode::PlainText, + font_family: row + .get(FONT_FAMILY_INPUT) + .map(to_text) + .unwrap_or_else(String::new), + font_size_pt: row + .get(FONT_SIZE_INPUT) + .map(|v| v.to_double()) + .unwrap_or(0.0), + dots_per_meter: 3780, + wrap_width: size[0], + center_horizontally: false, + } + } else { + TextLayoutRequest { + text: row.get(TEXT_INPUT).map(to_text).unwrap_or_else(String::new), + mode: TextLayoutMode::OliveHtml, + font_family: String::new(), + font_size_pt: 0.0, + dots_per_meter: 3780, + wrap_width: size[0], + center_horizontally: false, + } } } @@ -504,11 +854,435 @@ impl TextGeneratorV3 { }; (req, doc) } + + /// Read an input: the row value when present, else the core's value at + /// `time` (the row-first lookup [`Self::job_text`] uses). + fn input_value(core: &NodeCore, inputs: &NodeValueRow, id: &str, time: Rational) -> NodeValue { + inputs + .get(id) + .cloned() + .unwrap_or_else(|| core.value_at_time(id, -1, time)) + } + + /// Read a float input (REDESIGN post-process inputs). + fn float_arg(core: &NodeCore, inputs: &NodeValueRow, id: &str, time: Rational) -> f64 { + Self::input_value(core, inputs, id, time).to_double() + } + + /// Read a boolean input (REDESIGN post-process inputs). + fn bool_arg(core: &NodeCore, inputs: &NodeValueRow, id: &str, time: Rational) -> bool { + let val = Self::input_value(core, inputs, id, time); + to_bool(&val) + } + + /// Read a color input; a mis-typed value falls back to opaque black + /// (the same fallback the C++ `Variant::to_color()` callers get for a + /// non-color). + fn color_arg(core: &NodeCore, inputs: &NodeValueRow, id: &str, time: Rational) -> [f64; 4] { + match Self::input_value(core, inputs, id, time) { + NodeValue::Color(c) => c, + _ => [0.0, 0.0, 0.0, 1.0], + } + } + + /// The vertical alignment of this evaluation: the row's `valign_in` + /// when present, else the standard value (the lookup + /// [`Self::vertical_alignment`] documents). + fn alignment_arg(core: &NodeCore, inputs: &NodeValueRow) -> VerticalAlignment { + match inputs.get(VERTICAL_ALIGNMENT_INPUT) { + Some(v) => VerticalAlignment::from_int(v.to_double() as i32), + None => Self::vertical_alignment(core), + } + } + + /// Clamp one raster dimension into `1..=MAX_RASTER_SIZE`; a + /// non-finite size falls back to a single pixel. + fn clamp_raster(v: f64) -> i32 { + if !v.is_finite() { + return 1; + } + (v.round() as i32).clamp(1, MAX_RASTER_SIZE) + } + + /// The rasterization size of this evaluation: the shape size + /// (`size_in`), rounded and clamped per dimension. + fn raster_size(core: &NodeCore, inputs: &NodeValueRow, time: Rational) -> (i32, i32) { + let size = to_vec2(&Self::input_value( + core, + inputs, + crate::nodes::shapenodebase::SIZE_INPUT, + time, + )); + (Self::clamp_raster(size[0]), Self::clamp_raster(size[1])) + } + + /// Box one post-process shader job (REDESIGN, no C++ counterpart): + /// this node's type id (the chain is all ours), an invalid node id + /// (the jobs are synthetic — no graph node evaluates them) and the + /// shader id selecting the pass in [`Self::shader_code`]. + fn shader_job( + time: Rational, + type_id: &str, + shader_id: &str, + effect_input: &str, + iterations: i32, + params: NodeValueRow, + ) -> crate::handle::CHandle { + crate::handle::make_owned(ShaderJobPayload { + node_id: crate::id::NodeId::INVALID, + time, + iterations, + type_id: type_id.to_string(), + shader_id: shader_id.to_string(), + effect_input: effect_input.to_string(), + params, + iterative_input: String::new(), + }) + } + + /// Box a `"mrg"` job drawing `blend` (the top layer) over `base` (the + /// backdrop): the merge node's premultiplied alpha-over, + /// `base = base * (1 - blend.a) + blend`. + fn merge_job( + time: Rational, + type_id: &str, + base: &NodeValue, + blend: &NodeValue, + ) -> crate::handle::CHandle { + let mut params = NodeValueRow::new(); + params.insert(crate::nodes::merge::BASE_INPUT.to_string(), base.clone()); + params.insert(crate::nodes::merge::BLEND_INPUT.to_string(), blend.clone()); + Self::shader_job( + time, + type_id, + "mrg", + crate::nodes::merge::BASE_INPUT, + 1, + params, + ) + } + + /// Rasterize the evaluated `text` into an RGBA premultiplied F32 + /// coverage texture (REDESIGN, no C++ counterpart — the C++ renders + /// straight into the output frame instead): layout the plain-text + /// request with `text` substituted for [`PLAIN_TEXT_INPUT`], measure + /// it, render into a `size`-sized staging buffer with the crate's + /// draw/clip transform, then widen the 8-bit coverage to float. + /// + /// `None` without a render backend (the documented no-backend + /// fallback), for an empty raster, or when the staging frame cannot be + /// allocated. The white raster is tinted by `color` (the font color, + /// [`COLOR_INPUT`]) while widening to float. + fn rasterize_text( + row: &NodeValueRow, + text: &str, + size: (i32, i32), + align: VerticalAlignment, + color: [f64; 4], + ) -> Option { + let render = super::textbackend::text_render_backend()?; + let (width, height) = size; + if width <= 0 || height <= 0 { + return None; + } + + let mut req_row = row.clone(); + req_row.insert( + PLAIN_TEXT_INPUT.to_string(), + NodeValue::Text(text.to_string()), + ); + let req = Self::layout_request_path(true, &req_row); + let doc = match super::textbackend::text_measure_backend() { + Some(measure) => measure(&req), + None => TextLayoutSize::default(), + }; + + // The backend writes 8-bit premultiplied RGBA over the existing + // (cleared) rows; the shape-local offsets keep the text rect at + // the raster origin, with the vertical alignment applied. + let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4]; + { + let target = super::textbackend::TextRenderTarget { + data: &mut rgba, + width, + height, + linesize_bytes: width * 4, + channel_count: 4, + }; + let draw = + Self::draw_offset(align, (0.0, 0.0), [width as f64, height as f64], doc.height); + let transform = + Self::render_transform(1.0, draw, (0.0, 0.0), [width as f64, height as f64]); + render(&req, &transform, target); + } + + let mut frame = Frame::new(); + frame.set_video_params(VideoParamsPod { + width, + height, + ..Default::default() + }); + if !frame.allocate() { + return None; + } + for (pixel, coverage) in frame.data.chunks_exact_mut(16).zip(rgba.chunks_exact(4)) { + // The backend rasterizes in opaque-premultiplied white; tint by + // [`COLOR_INPUT`] per channel (the alpha scales too — a + // half-transparent font color stays premultiplied). + for (c, (channel, byte)) in pixel.chunks_exact_mut(4).zip(coverage).enumerate() { + let v = f32::from(*byte) / 255.0 * color[c] as f32; + channel.copy_from_slice(&v.to_le_bytes()); + } + } + + Some(NodeValue::Texture(crate::handle::make_owned( + Texture::wrap_frame(frame), + ))) + } + + /// Build the outline/glow post-process chain (REDESIGN, no C++ + /// counterpart) for the evaluated `text`: rasterize the coverage, + /// dilate and colorize it into a stroke when the outline is enabled, + /// blur and colorize it into a glow when the glow is enabled, and + /// merge the results **beneath** the text (the text stays on top). + /// + /// With both enabled the outline runs first and the glow samples the + /// stroke; with both disabled — or without a render backend — `None`, + /// so the caller keeps the pre-redesign deferred null job. + fn build_post_job( + &self, + core: &NodeCore, + inputs: &NodeValueRow, + text: &str, + time: Rational, + ) -> Option { + let outline = Self::bool_arg(core, inputs, OUTLINE_ENABLED_INPUT, time); + let glow = Self::bool_arg(core, inputs, GLOW_ENABLED_INPUT, time); + if !outline && !glow { + return None; + } + + let type_id = self.type_id(); + let size = Self::raster_size(core, inputs, time); + let align = Self::alignment_arg(core, inputs); + let font_color = Self::color_arg(core, inputs, COLOR_INPUT, time); + let text_tex = Self::rasterize_text(inputs, text, size, align, font_color)?; + let resolution = NodeValue::Vec2([size.0 as f64, size.1 as f64]); + + let mut result = text_tex.clone(); + let mut stroke: Option = None; + + if outline { + let mut params = NodeValueRow::new(); + params.insert(POST_TEXTURE_INPUT.to_string(), text_tex.clone()); + params.insert( + OUTLINE_WIDTH_INPUT.to_string(), + NodeValue::Float(Self::float_arg(core, inputs, OUTLINE_WIDTH_INPUT, time)), + ); + params.insert(RESOLUTION_INPUT.to_string(), resolution.clone()); + let dilated = NodeValue::Texture(Self::shader_job( + time, + type_id, + OUTLINE_DILATE_SHADER_ID, + POST_TEXTURE_INPUT, + 1, + params, + )); + + let mut params = NodeValueRow::new(); + params.insert(POST_TEXTURE_INPUT.to_string(), dilated); + params.insert( + OUTLINE_COLOR_INPUT.to_string(), + NodeValue::Color(Self::color_arg(core, inputs, OUTLINE_COLOR_INPUT, time)), + ); + params.insert(RESOLUTION_INPUT.to_string(), resolution.clone()); + let colorized = NodeValue::Texture(Self::shader_job( + time, + type_id, + OUTLINE_COLORIZE_SHADER_ID, + POST_TEXTURE_INPUT, + 1, + params, + )); + + // The stroke is the widened, colorized coverage drawn over the + // text itself, so the glyphs stay on top of their outline. + let stroke_tex = + NodeValue::Texture(Self::merge_job(time, type_id, &colorized, &text_tex)); + stroke = Some(stroke_tex.clone()); + result = stroke_tex; + } + + if glow { + let source = stroke.clone().unwrap_or_else(|| text_tex.clone()); + let mut params = NodeValueRow::new(); + params.insert(POST_TEXTURE_INPUT.to_string(), source); + params.insert( + GLOW_RADIUS_INPUT.to_string(), + NodeValue::Float(Self::float_arg(core, inputs, GLOW_RADIUS_INPUT, time)), + ); + params.insert(RESOLUTION_INPUT.to_string(), resolution.clone()); + // Two iterations, one per axis (the shader picks the axis). + let blurred = NodeValue::Texture(Self::shader_job( + time, + type_id, + GLOW_BLUR_SHADER_ID, + POST_TEXTURE_INPUT, + 2, + params, + )); + + let mut params = NodeValueRow::new(); + params.insert(POST_TEXTURE_INPUT.to_string(), blurred); + params.insert( + GLOW_COLOR_INPUT.to_string(), + NodeValue::Color(Self::color_arg(core, inputs, GLOW_COLOR_INPUT, time)), + ); + params.insert(RESOLUTION_INPUT.to_string(), resolution.clone()); + let glow_tex = NodeValue::Texture(Self::shader_job( + time, + type_id, + GLOW_COLORIZE_SHADER_ID, + POST_TEXTURE_INPUT, + 1, + params, + )); + + // The glow is drawn over the stroke (or the bare text when the + // outline is off), which is drawn over the text. + let blend = stroke.clone().unwrap_or_else(|| text_tex.clone()); + result = NodeValue::Texture(Self::merge_job(time, type_id, &glow_tex, &blend)); + } + + let NodeValue::Texture(handle) = &result else { + return None; + }; + Some(unsafe { handle.addref() }) + } +} + +/// Strip an HTML fragment to plain text (REDESIGN helper, no C++ +/// counterpart): every `<...>` tag is dropped, then the entities +/// `&`, `<`, `>`, `"`, `'` and ` ` are decoded +/// (the latter to a plain space — a non-breaking space is not +/// representable in the plain-text input, a documented simplification). +/// Unknown entities and a bare `&` are copied verbatim; an unterminated +/// `<` swallows the rest of the input. +/// +/// This is a simple stripper, not a conforming HTML parser: tags are +/// dropped first and entities decoded afterwards in a single pass (so +/// `<p>` stays the literal text `

`), and whitespace is neither +/// collapsed nor trimmed (so `

a

b

` becomes `ab`). It only +/// exists to migrate the legacy [`TEXT_INPUT`] payload into +/// [`PLAIN_TEXT_INPUT`]. +pub fn strip_html_to_plain(html: &str) -> String { + /// Whether `chars` starts with the (ASCII) `entity` text. + fn starts_with(chars: &[char], entity: &str) -> bool { + let mut it = chars.iter(); + entity.chars().all(|c| it.next() == Some(&c)) + } + + const ENTITIES: [(&str, &str); 6] = [ + ("&", "&"), + ("<", "<"), + (">", ">"), + (""", "\""), + ("'", "'"), + (" ", " "), + ]; + + let chars: Vec = html.chars().collect(); + let mut out = String::with_capacity(html.len()); + let mut i = 0; + while i < chars.len() { + match chars[i] { + '<' => { + // Drop up to and including the tag's closing '>'; an + // unterminated tag drops the remainder. The tag text is + // discarded, never rescanned, so a decoded `<p>` + // cannot turn into a tag afterwards. + i += 1; + while i < chars.len() && chars[i] != '>' { + i += 1; + } + i += 1; + } + '&' => { + let decoded = ENTITIES + .iter() + .find(|(entity, _)| starts_with(&chars[i..], entity)); + match decoded { + Some((entity, replacement)) => { + out.push_str(replacement); + i += entity.chars().count(); + } + None => { + // Unknown entity (or a bare '&'): keep it as-is. + out.push('&'); + i += 1; + } + } + } + c => { + out.push(c); + i += 1; + } + } + } + out +} + +/// One-shot migration of a pre-redesign project's legacy [`TEXT_INPUT`] +/// HTML into [`PLAIN_TEXT_INPUT`] (REDESIGN helper, no C++ +/// counterpart): when the plain text is still untouched (empty or the +/// [`DEFAULT_PLAIN_TEXT`] default) and the legacy input holds a +/// non-empty, non-default HTML value, the stripped plain text is written +/// to `plain_text_in` and `true` is returned. The legacy value is never +/// modified, so an old project can still be saved in its original form; +/// after a successful migration the plain text is no longer the default +/// and further calls are no-ops (idempotent). +/// +/// A project created after the redesign serializes `plain_text_in` at +/// the same default, indistinguishable through the standard values from +/// a legacy node with an untouched default HTML payload; that case is +/// left alone (the default HTML is not migrated) rather than replacing +/// the redesign default with the legacy "Sample Text". +/// +/// Call sites: [`NodeBehavior::load_custom`] (fires in the node-body +/// parser after the `` elements — the hook the real load path +/// reaches), [`NodeBehavior::post_load`] (for load pipelines that call +/// it after the inputs are applied) and [`NodeBehavior::input_value_changed`] +/// for the legacy input. The migration only takes effect in the facade +/// once a loader calls one of them. +pub fn migrate_legacy_html(core: &mut NodeCore) -> bool { + if core.get_input(PLAIN_TEXT_INPUT).is_none() { + return false; + } + let plain_untouched = matches!( + &core.standard_value(PLAIN_TEXT_INPUT, -1), + NodeValue::Text(s) if s.is_empty() || s == DEFAULT_PLAIN_TEXT + ); + if !plain_untouched { + return false; + } + let plain = match &core.standard_value(TEXT_INPUT, -1) { + NodeValue::Text(t) if !t.is_empty() && t != LEGACY_DEFAULT_TEXT_HTML => { + strip_html_to_plain(t) + } + _ => return false, + }; + if plain.is_empty() { + return false; + } + core.set_standard_value(PLAIN_TEXT_INPUT, -1, NodeValue::Text(plain)); + true } /// Constructor (C++ `TextGeneratorV3::TextGeneratorV3()`): builds the /// shape base without its own gizmo behavior (`ShapeNodeBase(false)`), -/// adds `text_in`, `valign_in`, `use_args_in` and `args_in` with the +/// adds `text_in` (hidden, REDESIGN), the structured redesign inputs +/// (`plain_text_in`, `font_family_in`, `font_size_in`, `outline_*`, +/// `glow_*`), `valign_in`, `use_args_in` and `args_in` with the /// defaults, flags and properties documented on the constants, sets the /// inherited `size_in` standard value to `(400, 300)`, creates the /// `TextGizmo` bound to `text_in`, and initializes @@ -532,6 +1306,13 @@ pub fn create() -> (NodeCore, Box) { core.add_input(base); core.effect_input = crate::nodes::generatorwithmerge::BASE_INPUT.to_string(); core.flags |= crate::node::flags::VIDEO_EFFECT; + // REDESIGN (W6): text is a footage entry (the project panel's "add text + // footage" button) and a timeline clip, no longer an effect the user + // adds to a chain. The flag hides it from the effect library / add + // menus only — `VIDEO_EFFECT` stays so text3 nodes already in a project + // keep evaluating, and the factory keeps registering the type (the + // footage path and old project files create it directly). + core.flags |= crate::node::flags::DONT_SHOW_IN_CREATE_MENU; // ShapeNodeBase(false): pos/size, no color input. core.add_input(crate::input::Input::new( @@ -548,14 +1329,81 @@ pub fn create() -> (NodeCore, Box) { core.add_input(size); // Own inputs. + // REDESIGN: the structured user-facing inputs. `plain_text_in` is the + // text laid out when a backend is installed; the legacy `text_in` + // stays as the hidden compatibility carrier (see the constants). + core.add_input(crate::input::Input::new( + PLAIN_TEXT_INPUT, + crate::value::ValueType::Text, + NodeValue::Text(DEFAULT_PLAIN_TEXT.to_string()), + )); + let mut text = crate::input::Input::new( TEXT_INPUT, crate::value::ValueType::Text, - NodeValue::Text("

Sample Text

".to_string()), + NodeValue::Text(LEGACY_DEFAULT_TEXT_HTML.to_string()), ); + text.flags |= crate::input::flags::HIDDEN; text.properties = vec![("vieweronly".to_string(), NodeValue::Boolean(true))]; core.add_input(text); + core.add_input(crate::input::Input::new( + FONT_FAMILY_INPUT, + crate::value::ValueType::StrCombo, + NodeValue::StrCombo(String::new()), + )); + + let mut font_size = crate::input::Input::new( + FONT_SIZE_INPUT, + crate::value::ValueType::Float, + NodeValue::Float(72.0), + ); + font_size.properties = vec![("min".to_string(), NodeValue::Float(1.0))]; + core.add_input(font_size); + + core.add_input(crate::input::Input::new( + OUTLINE_ENABLED_INPUT, + crate::value::ValueType::Boolean, + NodeValue::Boolean(false), + )); + core.add_input(crate::input::Input::new( + OUTLINE_COLOR_INPUT, + crate::value::ValueType::Color, + NodeValue::Color([0.0, 0.0, 0.0, 1.0]), + )); + let mut outline_width = crate::input::Input::new( + OUTLINE_WIDTH_INPUT, + crate::value::ValueType::Float, + NodeValue::Float(2.0), + ); + outline_width.properties = vec![("min".to_string(), NodeValue::Float(0.0))]; + core.add_input(outline_width); + + core.add_input(crate::input::Input::new( + GLOW_ENABLED_INPUT, + crate::value::ValueType::Boolean, + NodeValue::Boolean(false), + )); + core.add_input(crate::input::Input::new( + GLOW_COLOR_INPUT, + crate::value::ValueType::Color, + NodeValue::Color([1.0, 1.0, 0.0, 1.0]), + )); + let mut glow_radius = crate::input::Input::new( + GLOW_RADIUS_INPUT, + crate::value::ValueType::Float, + NodeValue::Float(8.0), + ); + glow_radius.properties = vec![("min".to_string(), NodeValue::Float(0.0))]; + core.add_input(glow_radius); + + core.add_input(crate::input::Input::new( + COLOR_INPUT, + crate::value::ValueType::Color, + NodeValue::Color([1.0, 1.0, 1.0, 1.0]), + )); + + // Hidden alignment / args inputs, unchanged by the redesign. let mut valign = crate::input::Input::new( VERTICAL_ALIGNMENT_INPUT, crate::value::ValueType::Combo, @@ -610,7 +1458,9 @@ pub fn register(meta: &mut Vec) { #[cfg(test)] mod tests { use super::*; + use crate::handle::CHandle; use crate::node::NodeBehavior; + use crate::nodes::textbackend::TextRenderTarget; use crate::value::{NodeValueTable, ValueType}; use oak_core::Rational; @@ -620,6 +1470,16 @@ mod tests { dont_emit_valign: false, }; assert_eq!(n.input_name(TEXT_INPUT), "Text"); + // REDESIGN: the plain-text input shares the "Text" display name. + assert_eq!(n.input_name(PLAIN_TEXT_INPUT), "Text"); + assert_eq!(n.input_name(FONT_FAMILY_INPUT), "Font Family"); + assert_eq!(n.input_name(FONT_SIZE_INPUT), "Font Size"); + assert_eq!(n.input_name(OUTLINE_ENABLED_INPUT), "Outline"); + assert_eq!(n.input_name(OUTLINE_COLOR_INPUT), "Outline Color"); + assert_eq!(n.input_name(OUTLINE_WIDTH_INPUT), "Outline Width"); + assert_eq!(n.input_name(GLOW_ENABLED_INPUT), "Glow"); + assert_eq!(n.input_name(GLOW_COLOR_INPUT), "Glow Color"); + assert_eq!(n.input_name(GLOW_RADIUS_INPUT), "Glow Radius"); assert_eq!(n.input_name(VERTICAL_ALIGNMENT_INPUT), "Vertical Alignment"); assert_eq!(n.input_name(ARGS_INPUT), "Arguments"); assert_eq!( @@ -664,10 +1524,14 @@ mod tests { .properties .iter() .any(|(k, v)| k == "arraystart" && v == &NodeValue::Int(1))); - // No color input (ShapeNodeBase(false)). - assert!(core - .get_input(crate::nodes::shapenodebase::COLOR_INPUT) - .is_none()); + // The base has no color input of its own (ShapeNodeBase(false)); + // the font color input (REDESIGN wave 3) takes that exact slot. + assert_eq!( + core.get_input(crate::nodes::shapenodebase::COLOR_INPUT) + .unwrap() + .default, + NodeValue::Color([1.0, 1.0, 1.0, 1.0]) + ); assert_eq!( core.standard_value(crate::nodes::shapenodebase::SIZE_INPUT, -1), NodeValue::Vec2([400.0, 300.0]) @@ -676,8 +1540,21 @@ mod tests { core.effect_input, crate::nodes::generatorwithmerge::BASE_INPUT ); - // v3 is shown in the create menu (no DONT_SHOW_IN_CREATE_MENU flag). - assert_eq!(core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, 0); + // REDESIGN: v3 left the create menu (text is a footage/clip now), so + // this test flipped from the pre-redesign expectation (`== 0`). + assert_ne!(core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, 0); + // It stays a video effect: legacy chains must keep evaluating. + assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0); + } + + /// The W6 contract: hidden from the add menus, still a working effect + /// node for the chains (and the footage path) that create it directly. + #[test] + fn hidden_from_create_menu_but_still_a_video_effect() { + let (core, behavior) = create(); + assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.text3"); + assert_ne!(core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, 0); + assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0); } #[test] @@ -763,13 +1640,87 @@ mod tests { crate::nodes::shapenodebase::SIZE_INPUT.to_string(), NodeValue::Vec2([400.0, 300.0]), ); - let req = TextGeneratorV3::layout_request(&row); + // The legacy path is driven explicitly: the backend state is a + // process-global that other tests install hooks into. + let req = TextGeneratorV3::layout_request_path(false, &row); assert_eq!(req.text, "

Hi

"); assert_eq!(req.mode, TextLayoutMode::OliveHtml); assert_eq!(req.dots_per_meter, 3780); assert_eq!(req.wrap_width, 400.0); } + #[test] + fn layout_request_plain_text_path_uses_structured_inputs() { + let mut row = NodeValueRow::default(); + row.insert( + PLAIN_TEXT_INPUT.to_string(), + NodeValue::Text("hello".to_string()), + ); + row.insert( + FONT_FAMILY_INPUT.to_string(), + NodeValue::StrCombo("Noto Sans".to_string()), + ); + row.insert(FONT_SIZE_INPUT.to_string(), NodeValue::Float(48.0)); + row.insert( + crate::nodes::shapenodebase::SIZE_INPUT.to_string(), + NodeValue::Vec2([400.0, 300.0]), + ); + // The row also carries legacy HTML: the plain path must ignore it. + row.insert( + TEXT_INPUT.to_string(), + NodeValue::Text("

legacy

".to_string()), + ); + let req = TextGeneratorV3::layout_request_path(true, &row); + assert_eq!(req.text, "hello"); + assert_eq!(req.mode, TextLayoutMode::PlainText); + assert_eq!(req.font_family, "Noto Sans"); + assert_eq!(req.font_size_pt, 48.0); + assert_eq!(req.dots_per_meter, 3780); + assert_eq!(req.wrap_width, 400.0); + } + + #[test] + fn job_text_prefers_the_row_value() { + let (core, _behavior) = create(); + let mut row = NodeValueRow::default(); + row.insert( + PLAIN_TEXT_INPUT.to_string(), + NodeValue::Text("row plain".to_string()), + ); + row.insert( + TEXT_INPUT.to_string(), + NodeValue::Text("row html".to_string()), + ); + assert_eq!( + TextGeneratorV3::job_text(true, &core, &row, Rational::new(0, 1)), + "row plain" + ); + assert_eq!( + TextGeneratorV3::job_text(false, &core, &row, Rational::new(0, 1)), + "row html" + ); + } + + #[test] + fn job_text_falls_back_to_the_core_value() { + let (mut core, _behavior) = create(); + core.set_standard_value( + PLAIN_TEXT_INPUT, + -1, + NodeValue::Text("core plain".to_string()), + ); + core.set_standard_value(TEXT_INPUT, -1, NodeValue::Text("core html".to_string())); + let row = NodeValueRow::default(); + assert_eq!( + TextGeneratorV3::job_text(true, &core, &row, Rational::new(0, 1)), + "core plain" + ); + assert_eq!( + TextGeneratorV3::job_text(false, &core, &row, Rational::new(0, 1)), + "core html" + ); + } + #[test] fn base_and_draw_offsets() { let size = [400.0, 300.0]; @@ -790,8 +1741,20 @@ mod tests { ); } + /// Serializes the tests that install a process-global text backend. + /// (The `textbackend` module's own tests use a different lock, so they + /// are not mutually excluded — same exposure as the pre-existing + /// `measure_without_backend_returns_zero_size`.) + static BACKEND_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Measure hook for tests that only need "a backend is installed". + fn noop_measure(_req: &TextLayoutRequest) -> TextLayoutSize { + TextLayoutSize::default() + } + #[test] fn measure_without_backend_returns_zero_size() { + let _guard = BACKEND_LOCK.lock().unwrap(); crate::nodes::textbackend::set_text_backends(None, None); let mut row = NodeValueRow::default(); row.insert( @@ -803,6 +1766,40 @@ mod tests { assert_eq!(doc.height, 0.0); } + #[test] + fn value_uses_plain_text_when_a_backend_is_installed() { + let _guard = BACKEND_LOCK.lock().unwrap(); + crate::nodes::textbackend::set_text_backends(Some(noop_measure), None); + assert!(TextGeneratorV3::plain_text_path()); + + // The row carries an empty legacy HTML only: the non-empty plain + // text default is what makes the job push, so this fails on the + // legacy path (empty text, no base -> empty table). + let (core, behavior) = create(); + let mut row = NodeValueRow::default(); + row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new())); + let mut table = NodeValueTable::default(); + behavior.value(&core, &row, Rational::new(0, 1), &mut table); + assert!(matches!( + table.get(ValueType::Texture), + Some(NodeValue::Texture(h)) if h.is_null() + )); + + // The public layout request follows the installed backend. + let mut req_row = NodeValueRow::default(); + req_row.insert( + PLAIN_TEXT_INPUT.to_string(), + NodeValue::Text("hi".to_string()), + ); + assert_eq!( + TextGeneratorV3::layout_request(&req_row).mode, + TextLayoutMode::PlainText + ); + + crate::nodes::textbackend::set_text_backends(None, None); + assert!(!TextGeneratorV3::plain_text_path()); + } + #[test] fn value_pushes_job_when_text_nonempty() { let (core, behavior) = create(); @@ -862,6 +1859,10 @@ mod tests { fn value_pushes_nothing_when_text_empty_and_no_base() { let (core, behavior) = create(); let mut row = NodeValueRow::default(); + // Both text inputs are emptied explicitly: which one `value` reads + // depends on the process-global backend state, which the tests that + // install one (own lock, and `textbackend`'s) change concurrently. + row.insert(PLAIN_TEXT_INPUT.to_string(), NodeValue::Text(String::new())); row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new())); let mut table = NodeValueTable::default(); behavior.value(&core, &row, Rational::new(0, 1), &mut table); @@ -921,4 +1922,464 @@ mod tests { assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.text3"); assert_eq!(copy.name(), "Text"); } + + #[test] + fn redesign_inputs_have_defaults_and_flags() { + let (core, _behavior) = create(); + + let plain = core.get_input(PLAIN_TEXT_INPUT).unwrap(); + assert_eq!(plain.value_type, ValueType::Text); + assert_eq!( + plain.default, + NodeValue::Text(DEFAULT_PLAIN_TEXT.to_string()) + ); + + let family = core.get_input(FONT_FAMILY_INPUT).unwrap(); + assert_eq!(family.value_type, ValueType::StrCombo); + assert_eq!(family.default, NodeValue::StrCombo(String::new())); + + let size = core.get_input(FONT_SIZE_INPUT).unwrap(); + assert_eq!(size.value_type, ValueType::Float); + assert_eq!(size.default, NodeValue::Float(72.0)); + assert!(size + .properties + .iter() + .any(|(k, v)| k == "min" && v == &NodeValue::Float(1.0))); + + let outline = core.get_input(OUTLINE_ENABLED_INPUT).unwrap(); + assert_eq!(outline.value_type, ValueType::Boolean); + assert_eq!(outline.default, NodeValue::Boolean(false)); + + let outline_color = core.get_input(OUTLINE_COLOR_INPUT).unwrap(); + assert_eq!(outline_color.value_type, ValueType::Color); + assert_eq!( + outline_color.default, + NodeValue::Color([0.0, 0.0, 0.0, 1.0]) + ); + + let outline_width = core.get_input(OUTLINE_WIDTH_INPUT).unwrap(); + assert_eq!(outline_width.value_type, ValueType::Float); + assert_eq!(outline_width.default, NodeValue::Float(2.0)); + assert!(outline_width + .properties + .iter() + .any(|(k, v)| k == "min" && v == &NodeValue::Float(0.0))); + + let glow = core.get_input(GLOW_ENABLED_INPUT).unwrap(); + assert_eq!(glow.value_type, ValueType::Boolean); + assert_eq!(glow.default, NodeValue::Boolean(false)); + + let glow_color = core.get_input(GLOW_COLOR_INPUT).unwrap(); + assert_eq!(glow_color.value_type, ValueType::Color); + assert_eq!(glow_color.default, NodeValue::Color([1.0, 1.0, 0.0, 1.0])); + + let glow_radius = core.get_input(GLOW_RADIUS_INPUT).unwrap(); + assert_eq!(glow_radius.value_type, ValueType::Float); + assert_eq!(glow_radius.default, NodeValue::Float(8.0)); + assert!(glow_radius + .properties + .iter() + .any(|(k, v)| k == "min" && v == &NodeValue::Float(0.0))); + + // Every redesign input is user-facing (none hidden). + for id in [ + PLAIN_TEXT_INPUT, + FONT_FAMILY_INPUT, + FONT_SIZE_INPUT, + OUTLINE_ENABLED_INPUT, + OUTLINE_COLOR_INPUT, + OUTLINE_WIDTH_INPUT, + GLOW_ENABLED_INPUT, + GLOW_COLOR_INPUT, + GLOW_RADIUS_INPUT, + ] { + assert_eq!( + core.get_input(id).unwrap().flags & crate::input::flags::HIDDEN, + 0 + ); + } + + // The legacy input keeps its default and vieweronly property, and + // is now hidden. + let legacy = core.get_input(TEXT_INPUT).unwrap(); + assert_eq!( + legacy.default, + NodeValue::Text(LEGACY_DEFAULT_TEXT_HTML.to_string()) + ); + assert_ne!(legacy.flags & crate::input::flags::HIDDEN, 0); + assert!(legacy + .properties + .iter() + .any(|(k, v)| k == "vieweronly" && v == &NodeValue::Boolean(true))); + + // The shape base still has no color input of its own + // (ShapeNodeBase(false)); the REDESIGN wave-3 font color input + // takes that exact slot (white default). + assert_eq!( + core.get_input(crate::nodes::shapenodebase::COLOR_INPUT) + .unwrap() + .default, + NodeValue::Color([1.0, 1.0, 1.0, 1.0]) + ); + } + + #[test] + fn strip_html_to_plain_drops_tags() { + assert_eq!(strip_html_to_plain("

a

b

"), "ab"); + assert_eq!(strip_html_to_plain("
"), ""); + assert_eq!(strip_html_to_plain("

"), ""); + assert_eq!(strip_html_to_plain("a
b"), "ab"); + // Whitespace is neither collapsed nor trimmed. + assert_eq!(strip_html_to_plain("

a b

"), "a b"); + // A complete tag drops only itself; the text around it stays. + assert_eq!(strip_html_to_plain("

abc"), "abc"); + // An unterminated tag swallows the remainder. + assert_eq!(strip_html_to_plain("a\"' b" + ); + // A bare '&' and unknown entities are kept verbatim, one pass only. + assert_eq!(strip_html_to_plain("a & b &fake; c"), "a & b &fake; c"); + assert_eq!(strip_html_to_plain("&amp;"), "&"); + // Entities are decoded after tags are dropped: an encoded tag stays + // literal text. + assert_eq!(strip_html_to_plain("<p>"), "

"); + } + + #[test] + fn migrate_legacy_html_moves_stripped_text_once() { + let (mut core, _behavior) = create(); + core.set_standard_value( + TEXT_INPUT, + -1, + NodeValue::Text("

Hello World

".to_string()), + ); + assert!(migrate_legacy_html(&mut core)); + assert_eq!( + core.standard_value(PLAIN_TEXT_INPUT, -1), + NodeValue::Text("Hello World".to_string()) + ); + // The legacy value is left untouched and further calls are no-ops. + assert_eq!( + core.standard_value(TEXT_INPUT, -1), + NodeValue::Text("

Hello World

".to_string()) + ); + assert!(!migrate_legacy_html(&mut core)); + assert_eq!( + core.standard_value(PLAIN_TEXT_INPUT, -1), + NodeValue::Text("Hello World".to_string()) + ); + } + + #[test] + fn migrate_legacy_html_leaves_defaults_and_edits_alone() { + let (mut core, _behavior) = create(); + // A node at its defaults: the legacy default HTML is not migrated + // (a new node must keep the redesign default across a save/load). + assert!(!migrate_legacy_html(&mut core)); + assert_eq!( + core.standard_value(PLAIN_TEXT_INPUT, -1), + NodeValue::Text(DEFAULT_PLAIN_TEXT.to_string()) + ); + + // An edited plain text is never overwritten. + core.set_standard_value(PLAIN_TEXT_INPUT, -1, NodeValue::Text("mine".to_string())); + core.set_standard_value(TEXT_INPUT, -1, NodeValue::Text("

legacy

".to_string())); + assert!(!migrate_legacy_html(&mut core)); + assert_eq!( + core.standard_value(PLAIN_TEXT_INPUT, -1), + NodeValue::Text("mine".to_string()) + ); + + // A legacy HTML with no text content migrates nothing. + core.set_standard_value(PLAIN_TEXT_INPUT, -1, NodeValue::Text(String::new())); + core.set_standard_value(TEXT_INPUT, -1, NodeValue::Text("

".to_string())); + assert!(!migrate_legacy_html(&mut core)); + assert_eq!( + core.standard_value(PLAIN_TEXT_INPUT, -1), + NodeValue::Text(String::new()) + ); + } + + #[test] + fn migrate_legacy_html_noop_without_plain_text_input() { + // A pre-redesign core (no plain_text_in at all) must not panic. + let mut core = NodeCore::new(); + core.add_input(crate::input::Input::new( + TEXT_INPUT, + ValueType::Text, + NodeValue::Text("

x

".to_string()), + )); + assert!(!migrate_legacy_html(&mut core)); + } + + /// Render hook for the post-process tests: paints the middle half of + /// the target (`x`, `y` in `[dim / 4, 3 * dim / 4)`) solid white — a + /// coverage block whose dilation and box blur are exactly computable. + fn solid_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]); + } + } + } + + /// The evaluation row of the post-process tests: a 16x16 raster with a + /// 2-pixel black outline and/or a 4-pixel yellow glow. + fn post_row(outline: bool, glow: bool) -> NodeValueRow { + let mut row = NodeValueRow::new(); + row.insert( + PLAIN_TEXT_INPUT.to_string(), + NodeValue::Text("X".to_string()), + ); + row.insert(USE_ARGS_INPUT.to_string(), NodeValue::Boolean(false)); + row.insert( + crate::nodes::shapenodebase::SIZE_INPUT.to_string(), + NodeValue::Vec2([16.0, 16.0]), + ); + row.insert( + OUTLINE_ENABLED_INPUT.to_string(), + NodeValue::Boolean(outline), + ); + row.insert( + OUTLINE_COLOR_INPUT.to_string(), + NodeValue::Color([0.0, 0.0, 0.0, 1.0]), + ); + row.insert(OUTLINE_WIDTH_INPUT.to_string(), NodeValue::Float(2.0)); + row.insert(GLOW_ENABLED_INPUT.to_string(), NodeValue::Boolean(glow)); + row.insert( + GLOW_COLOR_INPUT.to_string(), + NodeValue::Color([1.0, 1.0, 0.0, 1.0]), + ); + row.insert(GLOW_RADIUS_INPUT.to_string(), NodeValue::Float(4.0)); + row + } + + /// Evaluate [`TextGeneratorV3::value`] and return the texture handle it + /// pushed. + fn push_value(core: &NodeCore, behavior: &dyn NodeBehavior, row: &NodeValueRow) -> CHandle { + let mut table = NodeValueTable::default(); + behavior.value(core, row, Rational::new(0, 1), &mut table); + match table.get(ValueType::Texture) { + Some(NodeValue::Texture(handle)) => *handle, + other => panic!("expected a texture row, got {other:?}"), + } + } + + /// The job payload boxed by a deferred texture handle. + fn job_of(handle: &CHandle) -> &ShaderJobPayload { + unsafe { crate::handle::get_checked::(handle) } + .expect("handle carries a ShaderJobPayload") + } + + /// The shader id of the pass a deferred texture handle runs. + fn shader_id_of(handle: &CHandle) -> &str { + &job_of(handle).shader_id + } + + /// A texture-typed job param (the effect input or a merge layer). + fn param_texture<'a>(handle: &'a CHandle, input: &str) -> &'a CHandle { + match job_of(handle).params.get(input) { + Some(NodeValue::Texture(tex)) => tex, + other => panic!("param {input:?} is not a texture: {other:?}"), + } + } + + /// A float job param. + fn param_float(handle: &CHandle, input: &str) -> f64 { + match job_of(handle).params.get(input) { + Some(NodeValue::Float(f)) => *f, + other => panic!("param {input:?} is not a float: {other:?}"), + } + } + + /// A color job param. + fn param_color(handle: &CHandle, input: &str) -> [f64; 4] { + match job_of(handle).params.get(input) { + Some(NodeValue::Color(c)) => *c, + other => panic!("param {input:?} is not a color: {other:?}"), + } + } + + /// A vec2 job param. + fn param_vec2(handle: &CHandle, input: &str) -> [f64; 2] { + match job_of(handle).params.get(input) { + Some(NodeValue::Vec2(v)) => *v, + other => panic!("param {input:?} is not a vec2: {other:?}"), + } + } + + /// Identity of the refcounted box behind a handle: every clone of a + /// job param addrefs the same box, so equal pointers mean "the same + /// texture was fed to both passes". + fn job_ptr(handle: &CHandle) -> usize { + handle.ctx as usize + } + + /// Assert `handle` boxes the 16x16 CPU coverage frame the rasterizer + /// staging-allocates (not a shader job). + fn assert_cpu_texture(handle: &CHandle) { + match unsafe { crate::handle::get_checked::(handle) } { + Some(Texture::Cpu(frame)) => assert_eq!((frame.width, frame.height), (16, 16)), + other => panic!("expected a CPU coverage frame, got {other:?}"), + } + } + + /// One pixel of a CPU coverage frame's F32 RGBA data. + fn pixel_of(handle: &CHandle, x: usize, y: usize) -> [f32; 4] { + let Some(Texture::Cpu(frame)) = (unsafe { crate::handle::get_checked::(handle) }) + else { + panic!("expected a CPU coverage frame"); + }; + let stride = frame.linesize_bytes() as usize; + let at = y * stride + x * 16; + let mut out = [0f32; 4]; + for (c, v) in out.iter_mut().enumerate() { + *v = f32::from_le_bytes(frame.data[at + c * 4..at + c * 4 + 4].try_into().unwrap()); + } + out + } + + #[test] + fn post_job_off_still_rasterizes_the_plain_text() { + let _guard = BACKEND_LOCK.lock().unwrap(); + crate::nodes::textbackend::set_text_backends(Some(noop_measure), Some(solid_render)); + let (core, behavior) = create(); + let row = post_row(false, false); + let handle = push_value(&core, behavior.as_ref(), &row); + crate::nodes::textbackend::set_text_backends(None, None); + // Both passes off with a backend installed: the plain (tinted) + // raster, not the pre-backend deferred null job. + assert_cpu_texture(&handle); + assert_eq!(pixel_of(&handle, 8, 8), [1.0, 1.0, 1.0, 1.0]); + assert_eq!(pixel_of(&handle, 0, 0), [0.0, 0.0, 0.0, 0.0]); + } + + #[test] + fn font_color_tints_the_raster_premultiplied() { + let _guard = BACKEND_LOCK.lock().unwrap(); + crate::nodes::textbackend::set_text_backends(Some(noop_measure), Some(solid_render)); + let (core, behavior) = create(); + let mut row = post_row(false, false); + row.insert( + COLOR_INPUT.to_string(), + NodeValue::Color([1.0, 0.0, 0.0, 0.5]), + ); + let handle = push_value(&core, behavior.as_ref(), &row); + crate::nodes::textbackend::set_text_backends(None, None); + assert_cpu_texture(&handle); + // The white coverage scales per channel, alpha included. + assert_eq!(pixel_of(&handle, 8, 8), [1.0, 0.0, 0.0, 0.5]); + assert_eq!(pixel_of(&handle, 0, 0), [0.0, 0.0, 0.0, 0.0]); + } + + #[test] + fn outline_chain_dilates_then_colorizes_over_the_text() { + let _guard = BACKEND_LOCK.lock().unwrap(); + crate::nodes::textbackend::set_text_backends(Some(noop_measure), Some(solid_render)); + let (core, behavior) = create(); + let row = post_row(true, false); + let handle = push_value(&core, behavior.as_ref(), &row); + crate::nodes::textbackend::set_text_backends(None, None); + + // The pushed chain is the stroke merged with the text on top: the + // text is the merge's top (`blend_in`) layer, its CPU coverage + // raster the head of that branch. + assert_eq!(shader_id_of(&handle), "mrg"); + let stroke = param_texture(&handle, crate::nodes::merge::BASE_INPUT); + let text = param_texture(&handle, crate::nodes::merge::BLEND_INPUT); + assert_cpu_texture(text); + + // The stroke is the colorized dilation of the raster. + assert_eq!(shader_id_of(stroke), OUTLINE_COLORIZE_SHADER_ID); + assert_eq!( + param_color(stroke, OUTLINE_COLOR_INPUT), + [0.0, 0.0, 0.0, 1.0] + ); + assert_eq!(param_vec2(stroke, RESOLUTION_INPUT), [16.0, 16.0]); + let dilated = param_texture(stroke, POST_TEXTURE_INPUT); + assert_eq!(shader_id_of(dilated), OUTLINE_DILATE_SHADER_ID); + assert_eq!(job_of(dilated).iterations, 1); + assert_eq!(param_float(dilated, OUTLINE_WIDTH_INPUT), 2.0); + assert_eq!(param_vec2(dilated, RESOLUTION_INPUT), [16.0, 16.0]); + assert_eq!( + job_ptr(param_texture(dilated, POST_TEXTURE_INPUT)), + job_ptr(text) + ); + } + + #[test] + fn glow_chain_blurs_once_per_axis_then_colorizes() { + let _guard = BACKEND_LOCK.lock().unwrap(); + crate::nodes::textbackend::set_text_backends(Some(noop_measure), Some(solid_render)); + let (core, behavior) = create(); + let row = post_row(false, true); + let handle = push_value(&core, behavior.as_ref(), &row); + crate::nodes::textbackend::set_text_backends(None, None); + + // Glow only: the glow is merged beneath the bare text. + assert_eq!(shader_id_of(&handle), "mrg"); + let glow = param_texture(&handle, crate::nodes::merge::BASE_INPUT); + let text = param_texture(&handle, crate::nodes::merge::BLEND_INPUT); + assert_cpu_texture(text); + + assert_eq!(shader_id_of(glow), GLOW_COLORIZE_SHADER_ID); + assert_eq!(param_color(glow, GLOW_COLOR_INPUT), [1.0, 1.0, 0.0, 1.0]); + assert_eq!(param_vec2(glow, RESOLUTION_INPUT), [16.0, 16.0]); + let blurred = param_texture(glow, POST_TEXTURE_INPUT); + assert_eq!(shader_id_of(blurred), GLOW_BLUR_SHADER_ID); + // Two iterations: one per axis (horizontal, then vertical). + assert_eq!(job_of(blurred).iterations, 2); + assert_eq!(param_float(blurred, GLOW_RADIUS_INPUT), 4.0); + assert_eq!(param_vec2(blurred, RESOLUTION_INPUT), [16.0, 16.0]); + assert_eq!( + job_ptr(param_texture(blurred, POST_TEXTURE_INPUT)), + job_ptr(text) + ); + } + + #[test] + fn outline_and_glow_glow_the_stroke() { + let _guard = BACKEND_LOCK.lock().unwrap(); + crate::nodes::textbackend::set_text_backends(Some(noop_measure), Some(solid_render)); + let (core, behavior) = create(); + let row = post_row(true, true); + let handle = push_value(&core, behavior.as_ref(), &row); + crate::nodes::textbackend::set_text_backends(None, None); + + // Both on: the glow is drawn over the stroke (which is drawn over + // the text), and it samples the stroke itself — the blur's input + // is the stroke's merge job, not the bare coverage raster. + assert_eq!(shader_id_of(&handle), "mrg"); + let glow = param_texture(&handle, crate::nodes::merge::BASE_INPUT); + let stroke = param_texture(&handle, crate::nodes::merge::BLEND_INPUT); + assert_eq!(shader_id_of(stroke), "mrg"); + + let stroke_colorized = param_texture(stroke, crate::nodes::merge::BASE_INPUT); + assert_eq!(shader_id_of(stroke_colorized), OUTLINE_COLORIZE_SHADER_ID); + assert_cpu_texture(param_texture(stroke, crate::nodes::merge::BLEND_INPUT)); + + let blurred = param_texture(glow, POST_TEXTURE_INPUT); + assert_eq!(shader_id_of(blurred), GLOW_BLUR_SHADER_ID); + assert_eq!( + job_ptr(param_texture(blurred, POST_TEXTURE_INPUT)), + job_ptr(stroke) + ); + } } diff --git a/crates/oak-render/tests/text_outline_glow.rs b/crates/oak-render/tests/text_outline_glow.rs new file mode 100644 index 000000000..d24c0086e --- /dev/null +++ b/crates/oak-render/tests/text_outline_glow.rs @@ -0,0 +1,203 @@ +//! 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::(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"); +} diff --git a/docs/zh/plans/text-footage-redesign.md b/docs/zh/plans/text-footage-redesign.md new file mode 100644 index 000000000..92426ada2 --- /dev/null +++ b/docs/zh/plans/text-footage-redesign.md @@ -0,0 +1,176 @@ +# 文本素材化与结构化文本编辑器改造计划 + +> 面向实现者的任务书(2026-09-10)。本文只描述方案与工作项,不含已执行的代码修改。 +> 提出背景(用户原话):"不能让用户手工输入 HTML;输入框输入不了内容(已修复,见 §0); +> 应该让用户手工编辑文本、手工设置字体、字号、位置、字体颜色、轮廓、发光;文字应该是一个 +> 单独的素材而不是一个特效——文字作为 clip 被拖动到时间轴上,而不是作为特效被拖动到检查器; +> 文字不应该被放在特效那里,应该在项目的'新建序列'旁边添加一个'添加文本素材'。" + +## 0. 已先行修复(不在本计划范围) + +- 输入框无法输入:参数视图每个引擎 tick 都把引擎值重刷进输入框,击键下一帧即被清掉。 + 已改为聚焦期间跳过重同步(`crates/oak-app/src/panels/ofx_params.rs` sync_values 的 + Text 分支,与曲线编辑器拖拽保护同款),含回归测试 + `text_field_keeps_in_progress_edits_while_focused`。**已提交**(`5f8db8e31`)。 + +## 1. 现状 + +### 1.1 节点层 + +- `crates/oak-node/src/nodes/textv3.rs`(type id `org.olivevideoeditor.Olive.text3`): + 当前"Text"特效。输入仅 `text_in`(**HTML 原文**,默认值是 + `

Sample Text

`)、`valign_in`、 + `use_args_in`、`args_in`,外加 ShapeNodeBase 继承的 `pos_in`/`size_in`/`color_in`。 + 字体、字号、颜色全部编码在 HTML 里;轮廓、发光**根本不存在**。 + `create()` 设 `VIDEO_EFFECT` 标志 → 出现在特效库/检查器"添加特效"菜单 + (`crates/oak-app/src/oakui/effectchain.rs::addable_effects`,内置特效取 + `VIDEO_EFFECT && !DONT_SHOW_IN_CREATE_MENU`)。 +- `textv1.rs`(`textgenerator`):旧版,已带 `DONT_SHOW_IN_CREATE_MENU`。 +- `textbackend.rs`:**只有 hook 层**。`TextLayoutRequest{text, mode, font_family, + font_size_pt, dots_per_meter, wrap_width, center_horizontally}` + + `set_text_backends(measure, render)` 两个函数指针。**没有任何已安装的文本引擎**, + 即当前 text3 根本无法真正出字(后端决策被刻意推迟到 facade 层,见模块文档)。 +- 字体引擎可用性:workspace lockfile 已有 `cosmic-text 0.19.0`(gpui 文本系统在用)与 + `swash`。无需新增重量级依赖即可实现后端;GPU 侧轮廓/发光可作为覆盖率纹理的后处理 + pass 实现,不走字体引擎。 + +### 1.2 素材/时间轴层 + +- bin 条目 = 项目根文件夹 `FolderBehavior.children`(`crates/oak-app/src/oakui/projectbrowser.rs`, + `roots()/children()`,条目 id = 节点 identity,名称 = `core.label`)。 +- 时间轴接受 bin 拖放:footage 走 `engine.drop_footage_at` → + `graphops::place_footage_clip`(footage 节点连到 clip `tex_in`)。**没有**非 footage + 条目的拖放路径。 +- clip 由生成器喂入的机制已存在:clip 的 `tex_in` 可以接任意节点输出(多机位、 + 效果链都是这么接的,见 `effectchain::insert` 的连线模式)。 +- 项目面板"新建序列"按钮:`crates/oak-app/src/panels/project_explorer.rs:246`, + emit `NewSequenceRequested` → app.rs 订阅处理。 + +### 1.3 渲染层 + +- text3 的 `value()` 产出 shader job(`ShaderJobPayload`),渲染端 + `process_shader_job` 编译执行。文本栅格化在节点侧经 textbackend 完成 + (当前无后端 → 空)。轮廓/发光若做 GPU 后处理,可复用同一个 job 管线 + (多级 `iterations` 或嵌套 payload 都已支持)。 + +## 2. 目标 + +1. 用户永远看不到、也输不了 HTML。文本内容、字体、字号、位置、颜色、轮廓、发光 + 全部是结构化字段。 +2. 文字是**素材**:项目面板"新建序列"旁出现"添加文本素材",创建后出现在 bin 里, + 可拖到时间轴成为 clip;选中文本 clip 在检查器里编辑上述字段。 +3. 特效库不再出现 text3(迁移完成后隐藏;迁移期不破坏旧项目)。 + +## 3. 方案 + +### 3.1 textv3 节点结构化输入(节点层) + +给 `textv3.rs` 增加输入(保留 `text_in` 作内部合成载体与旧项目兼容): + +| 新输入 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `plain_text_in` | Text | `"文本"` | 纯文本内容(多行) | +| `font_family_in` | Combo(StrCombo) | 空=后端默认 | 字体族(选项由后端枚举注入;手输亦可) | +| `font_size_in` | Float | 72.0 | 字号 pt,min 1 | +| `text_color_in` | Color | (1,1,1,1) | 字体颜色(替代 HTML color) | +| `outline_enabled_in` | Boolean | false | 轮廓开关 | +| `outline_color_in` | Color | (0,0,0,1) | 轮廓颜色 | +| `outline_width_in` | Float | 2.0 | 轮廓宽度 px,min 0 | +| `glow_enabled_in` | Boolean | false | 发光开关 | +| `glow_color_in` | Color | (1,1,0,1) | 发光颜色 | +| `glow_radius_in` | Float | 8.0 | 发光半径 px,min 0 | + +- `value()` 变更:不再把用户文本当 HTML。若装了后端,用 `TextLayoutMode::PlainText` + + `font_family_in`/`font_size_in` 布局;颜色经 `color_in`(已有的 shape 基类输入, + 改名为 UI 上呈现为"字体颜色"还是保留 `color_in` 复用——**实现时选复用 `color_in`**, + 少一个冗余输入;`text_color_in` 不建)。轮廓/发光: + - 首选 **GPU 后处理**:文本覆盖率纹理 → 轮廓 = 覆盖率膨胀(dilate)+底色垫底合成; + 发光 = 覆盖率高斯模糊+加色合成。两者都是现成模糊/合成 shader 的组合, + 作为 text3 `value()` 内的嵌套 payload 链(eval 已支持嵌套递归,深度上限 8)。 + - 轮廓膨胀/模糊模糊 kernel 复用 `blur.rs` 的 box blur 迭代模式即可(视觉可接受, + 避免新写高斯)。 +- `text_in` 保留但改为**内部输入**(UI 隐藏,`input::flags::HIDDEN`): + 旧项目文件里它是 HTML,载入时若 `plain_text_in` 为空而 `text_in` 非空, + `Retranslate`/加载钩子里做一次 HTML→纯文本剥离(简单正则去标签即可, + 写 `strip_html_to_plain()` 单测覆盖)。 +- `valign_in`/`use_args_in`/`args_in` 保留原样(已是 HIDDEN|STATIC 或正常输入)。 + +### 3.2 文本后端(cosmic-text) + +- 新增 `crates/oak-app/src/oakui/textengine.rs`(app 层安装 hook,oak-node 不加依赖): + - 用 lockfile 已有的 `cosmic-text`(在 oak-app 的 Cargo.toml 提升为直接依赖, + 版本与 gpui 一致 0.19,避免双版本)。 + - 实现 `measure`/`render` 两个 `fn`,在 `RealEngine::create`(或 app 启动) + 调 `oak_node::nodes::textbackend::set_text_backends(Some(..), Some(..))`。 + (注意 textbackend 模块在 oak-node 是私有 mod 还是 pub——实现时若私有需改 + `pub mod textbackend`;hook 函数签名是 plain `fn`,跨 crate 直接传。) + - `render` 输出 RGBA premultiplied(channel_count=4,白字默认色——节点侧 + `color_in` 着色在 shader 里做,与 v1/v3 的 C++ 语义一致)。 + - 字体枚举:`cosmic_text::fontdb` 系统字体库 → `font_family_in` 的 combo 选项 + 由引擎 `effect_params` 组装时注入(`combo_option` 属性)。 +- 风险:cosmic-text 的 CJK 字体回退(fontdb 自带 fallback 链,Linux 上 + Noto Sans CJK 通常可用);多行/换行由 wrap_width + 文本含 `\n` 覆盖。 + +### 3.3 素材化(bin + 时间轴) + +- **创建入口**:项目面板标题栏"新建序列"按钮旁加"添加文本素材"按钮 + (`project_explorer.rs` header,新 emit `NewTextFootageRequested`;app.rs 订阅)。 + 行为:在项目根文件夹创建一个 text3 生成器节点(`core.label = "文本"`), + 作为 bin 条目出现。引擎方法 `AppEngine::create_text_footage(cx) -> Result` + (real 实现:graphops 建节点 + 挂到 root folder children + undoable; + mock 实现:记一条假条目)。 +- **拖放到时间轴**:时间轴 drop 目前只认 footage。扩展 `drop_footage_at` + (或新增 `drop_generator_at`):若拖入的 bin 条目是 text3 节点,则 + `block_clip_create` + 把 text3 节点连到 clip `tex_in` + 放置到轨道 + (undoable,一条 undo)。clip 时长默认 5 秒(可拖长)。判定"条目是 text3": + `graphops` 按 identity 取节点比较 type_id。 +- **bin 删除**:复用现有 `delete_entry`(从文件夹移除 + 断开图连接,已 undoable)。 + 文本 clip 删除走现有 clip 删除路径。 +- **检查器编辑**:选中文本 clip 时,检查器显示其 **生成器节点**的参数 + (现在选中 clip 显示效果链;对 generator-fed clip,链头即 text3—— + 检查器需要一个小改动:当 clip 的 `tex_in` 直连一个 generator 节点时, + 把该生成器的参数也列出(或直接把选择路由到生成器节点)。 + **实现时确定**:倾向"generator 节点作为链的第一张卡展示"—— + `selected_effect_cards` 已经遍历 chain,chain() 目前把喂入节点(footage/ + 生成器)都算作链尾一张卡(见 effectchain.rs chain() 的已知怪癖), + text3 参数会自然出现;需要的是 text3 的参数在 build_control 下呈现为 + 结构化字段(文本=多行输入、字体=combo、颜色=颜色选择器、数值=spin)。 +- **特效库隐藏 text3**:`textv3.rs::create()` 的 flags 增加 + `DONT_SHOW_IN_CREATE_MENU`。旧项目里已存在的 text3 特效链节点**不受影响** + (只是不能再新增)。此项放在最后做,确认素材路径可用后再隐藏。 + +### 3.4 UI 文案(i18n) + +8 个语言文件新增:`project.add_text_footage`(添加文本素材)、 +`text.font_family`(字体)、`text.font_size`(字号)、`text.outline`(轮廓)、 +`text.outline_width`(轮廓宽度)、`text.glow`(发光)、`text.glow_radius`(发光半径)、 +`text.content`(文本内容)。zh-CN/en-US 翻译,其余语言给英文。 + +## 4. 工作项(可分配给子代理的最小单元) + +1. **W1 节点输入扩展**:textv3 新输入 + `value()` 纯文本路径 + `text_in` 隐藏 + + HTML 剥离迁移 + 节点单测(输入存在/默认值/隐藏标志/纯文本 job 参数)。 +2. **W2 轮廓/发光 GPU 后处理**:text3 `value()` 嵌套 payload(膨胀/模糊/合成), + eval.rs GPU 像素测试(白字黑轮廓边缘检测、发光半径扩散检测)。 +3. **W3 cosmic-text 后端**:textengine.rs + hook 安装 + 字体枚举注入 + + 集成测试(装后端后 text3 渲染出非空纹理,GPU 测试)。 +4. **W4 素材创建+拖放**:面板按钮 + `create_text_footage` + drop 扩展 + + i18n + app 层测试(mock:按钮 emit;real:创建后 bin 有条目、拖放后轨道有 clip + 且 tex_in 连到 text3)。 +5. **W5 检查器结构化呈现**:确认 text3 参数以结构化字段出现在文本 clip 的检查器 + (含聚焦保护已修的多行文本输入);颜色走 OfxColorPicker。 +6. **W6 特效库隐藏 + 收尾**:DONT_SHOW_IN_CREATE_MENU、全量测试、文档更新 + (docs/zh 如有特效清单)。 + +依赖顺序:W1→W2/W3(可并行)→W4→W5→W6。W2 与 W3 独立。 +建议 W1+W2 一个子代理、W3 一个、W4+W5 一个、W6 收尾由主代理审查后执行。 + +## 5. 验收标准 + +1. 项目面板点"添加文本素材"→ bin 出现"文本"条目;拖到时间轴 → 出现文本 clip。 +2. 选中文本 clip,检查器可编辑:内容(多行)、字体、字号、位置、颜色、轮廓 + (开关/颜色/宽度)、发光(开关/颜色/半径);全程无 HTML 可见。 +3. 编辑任一字段,暂停的画面立即更新(依赖已提交的暂停刷新修复)。 +4. 特效库/添加特效菜单中不再出现 Text/text3。 +5. 含旧 text3 特效的项目能打开、能渲染(HTML 自动剥成纯文本进 `plain_text_in`)。 +6. `cargo test --workspace` 全绿,新增 GPU 测试在无 GPU 环境跳过。