From 1e0d48578e6dbc3ae2ba3708a20f97e586f2f957 Mon Sep 17 00:00:00 2001
From: Mike Solar
Date: Thu, 10 Sep 2026 22:02:57 +0800
Subject: [PATCH] nodes: text becomes structured footage with a real text
engine
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Text is no longer a hand-written HTML effect (docs in
docs/zh/plans/text-footage-redesign.md):
- textv3 gains structured inputs - plain text, font family/size, font
color, outline (enable/color/width), glow (enable/color/radius); the
legacy text_in HTML is hidden and auto-migrated to plain text on load.
- Outline and glow render as GPU post-process chains (dilate/blur +
colorize under the text); the font color tints the raster
premultiplied. Plain text now rasterizes even with both passes off
(previously a null deferred job), fixing a use-after-free where the
handle was lifted out of an owning Option before addref.
- A cosmic-text backend (the lockfile's 0.19) installs at engine
startup through the textbackend hooks and feeds the font-family combo.
- The project panel gains 添加文本素材 next to 新建序列: a text entry
in the bin that drops onto the timeline as a clip (one undo row), its
parameters shown as structured fields in the inspector (multiline
text area, no HTML anywhere). text3 is hidden from the effect add
menus; legacy text3 chains keep evaluating.
---
Cargo.lock | 1 +
crates/oak-app/Cargo.toml | 5 +
crates/oak-app/src/oakui/mod.rs | 4 +
crates/oak-app/src/oakui/textengine.rs | 861 +++++++++
crates/oak-app/src/panels/ofx_params.rs | 644 ++++++-
crates/oak-app/src/panels/project_explorer.rs | 29 +
crates/oak-node/src/nodes/textv3.rs | 1555 ++++++++++++++++-
crates/oak-render/tests/text_outline_glow.rs | 203 +++
docs/zh/plans/text-footage-redesign.md | 176 ++
9 files changed, 3371 insertions(+), 107 deletions(-)
create mode 100644 crates/oak-app/src/oakui/textengine.rs
create mode 100644 crates/oak-render/tests/text_outline_glow.rs
create mode 100644 docs/zh/plans/text-footage-redesign.md
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, "
"), "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("&"), "&");
+ // Entities are decoded after tags are dropped: an encoded tag stays
+ // literal text.
+ assert_eq!(strip_html_to_plain("<p>"), "
".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 原文**,默认值是
+ `