nodes: text becomes structured footage with a real text engine

Text is no longer a hand-written HTML effect (docs in
docs/zh/plans/text-footage-redesign.md):
- textv3 gains structured inputs - plain text, font family/size, font
  color, outline (enable/color/width), glow (enable/color/radius); the
  legacy text_in HTML is hidden and auto-migrated to plain text on load.
- Outline and glow render as GPU post-process chains (dilate/blur +
  colorize under the text); the font color tints the raster
  premultiplied. Plain text now rasterizes even with both passes off
  (previously a null deferred job), fixing a use-after-free where the
  handle was lifted out of an owning Option<NodeValue> before addref.
- A cosmic-text backend (the lockfile's 0.19) installs at engine
  startup through the textbackend hooks and feeds the font-family combo.
- The project panel gains 添加文本素材 next to 新建序列: a text entry
  in the bin that drops onto the timeline as a clip (one undo row), its
  parameters shown as structured fields in the inspector (multiline
  text area, no HTML anywhere). text3 is hidden from the effect add
  menus; legacy text3 chains keep evaluating.
This commit is contained in:
2026-09-10 22:02:57 +08:00
parent a7916aa93d
commit 1e0d48578e
9 changed files with 3371 additions and 107 deletions
Generated
+1
View File
@@ -4723,6 +4723,7 @@ dependencies = [
name = "oak-app"
version = "0.5.0"
dependencies = [
"cosmic-text",
"embed-resource",
"gpui",
"gpui_elements",
+5
View File
@@ -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,
+4
View File
@@ -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;
+861
View File
@@ -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 <http://www.gnu.org/licenses/>.
//! 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<TextSystem> {
static SYSTEM: OnceLock<Mutex<TextSystem>> = 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<String> {
static FAMILIES: OnceLock<Vec<String>> = OnceLock::new();
FAMILIES
.get_or_init(|| {
let mut names: Vec<String> = {
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,
/// `<br>` and the block-level end tags (`</p>`, `</div>`, `</li>`, 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] = [
("&amp;", "&"),
("&lt;", "<"),
("&gt;", ">"),
("&quot;", "\""),
("&#39;", "'"),
("&nbsp;", " "),
];
/// Block-level end tags that start a new line, plus `<br>` 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<char> = 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 `&lt;p&gt;`
// 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::<String>().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<u8> {
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: "<b>Hello</b>".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("<b>a</b>"), "a");
assert_eq!(html_to_plain("a<br>b"), "a\nb");
assert_eq!(html_to_plain("a<br/>b"), "a\nb");
assert_eq!(html_to_plain("<p>a</p><p>b</p>"), "a\nb");
assert_eq!(html_to_plain("a</DIV>b"), "a\nb");
assert_eq!(html_to_plain("&amp;&lt;&gt;&quot;&#39;&nbsp;"), "&<>\"' ");
// Unknown entities and bare `&` survive untouched.
assert_eq!(html_to_plain("a &b"), "a &b");
// A decoded `&lt;p&gt;` is text, never rescanned into a tag.
assert_eq!(html_to_plain("&lt;p&gt;"), "<p>");
// Unterminated tags drop the remainder; trailing breaks are dropped.
assert_eq!(html_to_plain("<p>a"), "a");
assert_eq!(html_to_plain("a</p>"), "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);
}
}
+584 -60
View File
@@ -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<SpinBox>, usize)>),
/// A colour swatch + popup picker (color).
Color(Entity<OfxColorPicker>),
/// A text field (string).
Text(Entity<EditableTextState>),
/// 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<EditableTextState>,
multiline: bool,
},
/// One curve editor per dimension (parametric parameter).
Curve(Vec<Entity<gpui_widgets::curve_editor::CurveEditor>>),
/// A push button (rendered inline, no entity).
@@ -122,6 +131,11 @@ impl<E: AppEngine> OfxParamsView<E> {
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<E: AppEngine> OfxParamsView<E> {
picker.update(cx, |picker, cx| picker.apply_viewer_pick(color, cx));
}
}
ControlKind::Text(editor) => {
ControlKind::Text { editor, .. } => {
let text = match &param.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<String> {
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) = &param.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 &param.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<f64> {
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<f64> = 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<E: AppEngine>(
@@ -510,17 +688,12 @@ fn build_control<E: AppEngine>(
) -> 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<E: AppEngine>(
ControlKind::CheckBox(check)
}
ValueType::Combo | ValueType::StrCombo => {
let options: Vec<String> = 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<E: AppEngine>(
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(&param.value);
@@ -707,12 +874,7 @@ fn wire_controls<E: AppEngine>(view: &OfxParamsView<E>, cx: &mut Context<OfxPara
});
let nv = match &param {
Some(p) if p.value_type == ValueType::StrCombo => {
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<E: AppEngine>(view: &OfxParamsView<E>, cx: &mut Context<OfxPara
})
.detach();
}
ControlKind::Text(_editor) => {
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<E: AppEngine> Render for OfxParamsView<E> {
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<E: AppEngine> Render for OfxParamsView<E> {
.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()),
"<p>engine text</p>"
"文本\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()),
"<p>engine text</p>",
"文本\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<OfxParamsView<MockEngine>>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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::<MockEngine>::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<String> = 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<usize>)>,
spins: Vec<(String, Vec<f64>)>,
colors: Vec<String>,
}
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<_>>(),
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
);
}
}
}
@@ -262,6 +262,27 @@ impl<E: AppEngine> Render for ProjectExplorerPanel<E> {
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<E: AppEngine> Render for ProjectExplorerPanel<E> {
.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<E: AppEngine> EventEmitter<NewSequenceRequested> for ProjectExplorerPanel<E> {}
/// 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<E: AppEngine> EventEmitter<NewTextFootageRequested> for ProjectExplorerPanel<E> {}
/// The project explorer asked the shell to rename entry `id`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenameRequested(pub u64);
File diff suppressed because it is too large Load Diff
@@ -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::<Texture>(handle) }.expect("resolved texture");
assert!(matches!(tex, Texture::Gpu { .. }), "{type_id}: must render on the GPU");
tex.to_frame().expect("readback")
}
const TEXT3: &str = "org.olivevideoeditor.Olive.text3";
/// The text backends are process globals: keep the tests that install
/// them off each other.
static BACKENDS: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Measure stub: a 16x16 document, so the raster is 16x16 whatever the
/// font engine would have laid out.
fn block_measure(_req: &TextLayoutRequest) -> TextLayoutSize { TextLayoutSize { width: 16.0, height: 16.0 } }
/// Render stub: paint white premultiplied coverage over the middle half
/// of the target (pixels 4..12 on both axes).
fn block_render(_req: &TextLayoutRequest, _transform: &TextRenderTransform, target: TextRenderTarget) {
if target.channel_count != 4 { return; }
let stride = target.linesize_bytes as usize;
let (w, h) = (target.width as usize, target.height as usize);
for y in h / 4..3 * h / 4 {
for x in w / 4..3 * w / 4 {
let at = y * stride + x * 4;
target.data[at..at + 4].copy_from_slice(&[255; 4]);
}
}
}
/// Insert boolean parameters (input id -> value).
fn set_bools(row: &mut NodeValueRow, params: &[(&str, bool)]) {
for (id, v) in params {
row.insert((*id).to_string(), NodeValue::Boolean(*v));
}
}
/// Insert float parameters (input id -> value).
fn set_floats(row: &mut NodeValueRow, params: &[(&str, f64)]) {
for (id, v) in params {
row.insert((*id).to_string(), NodeValue::Float(*v));
}
}
/// The evaluation row of the three tests: a 16x16 raster painted by
/// [`block_render`], with a 2px outline and/or a 4px glow. The outline
/// and glow colors are left at the node defaults (opaque black, opaque
/// yellow).
fn text_row(outline: bool, glow: bool) -> NodeValueRow {
let mut row = NodeValueRow::new();
row.insert("plain_text_in".to_string(), NodeValue::Text("X".to_string()));
set_bools(
&mut row,
&[
("use_args_in", false),
("outline_enabled_in", outline),
("glow_enabled_in", glow),
],
);
set_floats(&mut row, &[("outline_width_in", 2.0), ("glow_radius_in", 4.0)]);
row.insert("size_in".to_string(), NodeValue::Vec2([16.0, 16.0]));
row
}
/// Evaluate `TEXT3` with the raster backends installed. Callers hold
/// [`BACKENDS`].
fn eval_text(row: NodeValueRow) -> oak_core::texture::Frame {
set_text_backends(Some(block_measure), Some(block_render));
let frame = eval_node_row(TEXT3, row, None);
set_text_backends(None, None);
frame
}
/// Assert the four channels of a pixel against the expected values.
fn assert_pixel(px: [f32; 4], want: [f32; 4], what: &str) {
for c in 0..4 {
assert!(
(px[c] - want[c]).abs() < 1e-3,
"{what}: channel {c}: got {px:?}, want {want:?}"
);
}
}
/// Outline only: the coverage is dilated by 2 pixels and tinted opaque
/// black under the text, so the glyph pixel stays white, the 2px band
/// around it is `[0,0,0,1]` and everything further out is transparent.
#[test]
fn outline_dilates_and_strokes_under_the_text() {
if !gpu() {
eprintln!("no adapter; skipping");
return;
}
let _guard = BACKENDS.lock().unwrap();
set_text_backends(None, None);
let frame = eval_text(text_row(true, false));
assert_eq!((frame.width, frame.height), (16, 16), "raster size");
// The glyph is drawn over its own outline.
assert_pixel(pixel_at(&frame, 8, 8), [1.0, 1.0, 1.0, 1.0], "text over stroke");
// Inside the 2px dilate band (coverage 4..12 widened to 2..14).
for (x, y) in [(2, 8), (13, 8), (8, 2), (8, 13), (2, 2), (13, 13)] {
assert_pixel(pixel_at(&frame, x, y), [0.0, 0.0, 0.0, 1.0], &format!("stroke ({x},{y})"));
}
// One pixel outside the band.
for (x, y) in [(1, 8), (14, 8), (8, 1), (8, 14), (0, 0), (15, 15)] {
assert_pixel(pixel_at(&frame, x, y), [0.0, 0.0, 0.0, 0.0], &format!("outside ({x},{y})"));
}
}
/// Glow only: the coverage is box-blurred with radius 4 (a 9-tap pass
/// per axis) and tinted yellow under the text. The blurred alpha is
/// `(horizontal taps / 9) * (vertical taps / 9)`, e.g. 32/81 at (3,8)
/// and 1/81 at the clamped corners.
#[test]
fn glow_blurs_the_coverage_under_the_text() {
if !gpu() {
eprintln!("no adapter; skipping");
return;
}
let _guard = BACKENDS.lock().unwrap();
set_text_backends(None, None);
let frame = eval_text(text_row(false, true));
assert_eq!((frame.width, frame.height), (16, 16), "raster size");
// The glyph is drawn over the glow.
assert_pixel(pixel_at(&frame, 8, 8), [1.0, 1.0, 1.0, 1.0], "text over glow");
// Yellow is premultiplied: [a, a, 0, a].
let a = |v: f32| [v, v, 0.0, v];
assert_pixel(pixel_at(&frame, 3, 8), a(32.0 / 81.0), "glow 4px left of the block");
assert_pixel(pixel_at(&frame, 2, 8), a(8.0 / 27.0), "glow 2px left of the block");
assert_pixel(pixel_at(&frame, 1, 8), a(16.0 / 81.0), "glow 3px left of the block");
assert_pixel(pixel_at(&frame, 2, 2), a(1.0 / 9.0), "glow on the block corner");
// The clamped corners mirror each other.
assert_pixel(pixel_at(&frame, 0, 0), a(1.0 / 81.0), "glow top-left corner");
assert_pixel(pixel_at(&frame, 15, 15), a(1.0 / 81.0), "glow bottom-right corner");
}
/// Outline and glow: the glow blurs the *stroke* and is drawn beneath
/// it, so the opaque stroke hides it inside the band while it still
/// bleeds the 4px radius past the stroke edge.
#[test]
fn outline_and_glow_glow_the_stroke() {
if !gpu() {
eprintln!("no adapter; skipping");
return;
}
let _guard = BACKENDS.lock().unwrap();
set_text_backends(None, None);
let frame = eval_text(text_row(true, true));
assert_eq!((frame.width, frame.height), (16, 16), "raster size");
assert_pixel(pixel_at(&frame, 8, 8), [1.0, 1.0, 1.0, 1.0], "text over stroke and glow");
assert_pixel(pixel_at(&frame, 2, 8), [0.0, 0.0, 0.0, 1.0], "stroke hides the glow");
// Past the stroke edge the glow shows again, dimmer the wider the
// blur source (the stroke) is than the bare coverage.
let a = |v: f32| [v, v, 0.0, v];
assert_pixel(pixel_at(&frame, 1, 8), a(4.0 / 9.0), "glow just past the stroke");
assert_pixel(pixel_at(&frame, 15, 8), a(1.0 / 3.0), "glow at the right edge");
assert_pixel(pixel_at(&frame, 0, 0), a(1.0 / 9.0), "glow top-left corner");
assert_pixel(pixel_at(&frame, 14, 14), a(16.0 / 81.0), "glow bottom-right of the stroke");
}
+176
View File
@@ -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 原文**,默认值是
`<p style='font-size: 72pt; color: white;'>Sample Text</p>`)、`valign_in`、
`use_args_in`、`args_in`,外加 ShapeNodeBase 继承的 `pos_in`/`size_in`/`color_in`。
字体、字号、颜色全部编码在 HTML 里;轮廓、发光**根本不存在**。
`create()` 设 `VIDEO_EFFECT` 标志 → 出现在特效库/检查器"添加特效"菜单
(`crates/oak-app/src/oakui/effectchain.rs::addable_effects`,内置特效取
`VIDEO_EFFECT && !DONT_SHOW_IN_CREATE_MENU`)。
- `textv1.rs`(`textgenerator`):旧版,已带 `DONT_SHOW_IN_CREATE_MENU`。
- `textbackend.rs`:**只有 hook 层**。`TextLayoutRequest{text, mode, font_family,
font_size_pt, dots_per_meter, wrap_width, center_horizontally}` +
`set_text_backends(measure, render)` 两个函数指针。**没有任何已安装的文本引擎**,
即当前 text3 根本无法真正出字(后端决策被刻意推迟到 facade 层,见模块文档)。
- 字体引擎可用性:workspace lockfile 已有 `cosmic-text 0.19.0`(gpui 文本系统在用)与
`swash`。无需新增重量级依赖即可实现后端;GPU 侧轮廓/发光可作为覆盖率纹理的后处理
pass 实现,不走字体引擎。
### 1.2 素材/时间轴层
- bin 条目 = 项目根文件夹 `FolderBehavior.children`(`crates/oak-app/src/oakui/projectbrowser.rs`,
`roots()/children()`,条目 id = 节点 identity,名称 = `core.label`)。
- 时间轴接受 bin 拖放:footage 走 `engine.drop_footage_at` →
`graphops::place_footage_clip`(footage 节点连到 clip `tex_in`)。**没有**非 footage
条目的拖放路径。
- clip 由生成器喂入的机制已存在:clip 的 `tex_in` 可以接任意节点输出(多机位、
效果链都是这么接的,见 `effectchain::insert` 的连线模式)。
- 项目面板"新建序列"按钮:`crates/oak-app/src/panels/project_explorer.rs:246`,
emit `NewSequenceRequested` → app.rs 订阅处理。
### 1.3 渲染层
- text3 的 `value()` 产出 shader job(`ShaderJobPayload`),渲染端
`process_shader_job` 编译执行。文本栅格化在节点侧经 textbackend 完成
(当前无后端 → 空)。轮廓/发光若做 GPU 后处理,可复用同一个 job 管线
(多级 `iterations` 或嵌套 payload 都已支持)。
## 2. 目标
1. 用户永远看不到、也输不了 HTML。文本内容、字体、字号、位置、颜色、轮廓、发光
全部是结构化字段。
2. 文字是**素材**:项目面板"新建序列"旁出现"添加文本素材",创建后出现在 bin 里,
可拖到时间轴成为 clip;选中文本 clip 在检查器里编辑上述字段。
3. 特效库不再出现 text3(迁移完成后隐藏;迁移期不破坏旧项目)。
## 3. 方案
### 3.1 textv3 节点结构化输入(节点层)
给 `textv3.rs` 增加输入(保留 `text_in` 作内部合成载体与旧项目兼容):
| 新输入 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `plain_text_in` | Text | `"文本"` | 纯文本内容(多行) |
| `font_family_in` | Combo(StrCombo) | 空=后端默认 | 字体族(选项由后端枚举注入;手输亦可) |
| `font_size_in` | Float | 72.0 | 字号 pt,min 1 |
| `text_color_in` | Color | (1,1,1,1) | 字体颜色(替代 HTML color) |
| `outline_enabled_in` | Boolean | false | 轮廓开关 |
| `outline_color_in` | Color | (0,0,0,1) | 轮廓颜色 |
| `outline_width_in` | Float | 2.0 | 轮廓宽度 px,min 0 |
| `glow_enabled_in` | Boolean | false | 发光开关 |
| `glow_color_in` | Color | (1,1,0,1) | 发光颜色 |
| `glow_radius_in` | Float | 8.0 | 发光半径 px,min 0 |
- `value()` 变更:不再把用户文本当 HTML。若装了后端,用 `TextLayoutMode::PlainText`
+ `font_family_in`/`font_size_in` 布局;颜色经 `color_in`(已有的 shape 基类输入,
改名为 UI 上呈现为"字体颜色"还是保留 `color_in` 复用——**实现时选复用 `color_in`**,
少一个冗余输入;`text_color_in` 不建)。轮廓/发光:
- 首选 **GPU 后处理**:文本覆盖率纹理 → 轮廓 = 覆盖率膨胀(dilate)+底色垫底合成;
发光 = 覆盖率高斯模糊+加色合成。两者都是现成模糊/合成 shader 的组合,
作为 text3 `value()` 内的嵌套 payload 链(eval 已支持嵌套递归,深度上限 8)。
- 轮廓膨胀/模糊模糊 kernel 复用 `blur.rs` 的 box blur 迭代模式即可(视觉可接受,
避免新写高斯)。
- `text_in` 保留但改为**内部输入**(UI 隐藏,`input::flags::HIDDEN`):
旧项目文件里它是 HTML,载入时若 `plain_text_in` 为空而 `text_in` 非空,
`Retranslate`/加载钩子里做一次 HTML→纯文本剥离(简单正则去标签即可,
写 `strip_html_to_plain()` 单测覆盖)。
- `valign_in`/`use_args_in`/`args_in` 保留原样(已是 HIDDEN|STATIC 或正常输入)。
### 3.2 文本后端(cosmic-text)
- 新增 `crates/oak-app/src/oakui/textengine.rs`(app 层安装 hook,oak-node 不加依赖):
- 用 lockfile 已有的 `cosmic-text`(在 oak-app 的 Cargo.toml 提升为直接依赖,
版本与 gpui 一致 0.19,避免双版本)。
- 实现 `measure`/`render` 两个 `fn`,在 `RealEngine::create`(或 app 启动)
调 `oak_node::nodes::textbackend::set_text_backends(Some(..), Some(..))`。
(注意 textbackend 模块在 oak-node 是私有 mod 还是 pub——实现时若私有需改
`pub mod textbackend`;hook 函数签名是 plain `fn`,跨 crate 直接传。)
- `render` 输出 RGBA premultiplied(channel_count=4,白字默认色——节点侧
`color_in` 着色在 shader 里做,与 v1/v3 的 C++ 语义一致)。
- 字体枚举:`cosmic_text::fontdb` 系统字体库 → `font_family_in` 的 combo 选项
由引擎 `effect_params` 组装时注入(`combo_option` 属性)。
- 风险:cosmic-text 的 CJK 字体回退(fontdb 自带 fallback 链,Linux 上
Noto Sans CJK 通常可用);多行/换行由 wrap_width + 文本含 `\n` 覆盖。
### 3.3 素材化(bin + 时间轴)
- **创建入口**:项目面板标题栏"新建序列"按钮旁加"添加文本素材"按钮
(`project_explorer.rs` header,新 emit `NewTextFootageRequested`;app.rs 订阅)。
行为:在项目根文件夹创建一个 text3 生成器节点(`core.label = "文本"`),
作为 bin 条目出现。引擎方法 `AppEngine::create_text_footage(cx) -> Result<u64, String>`
(real 实现:graphops 建节点 + 挂到 root folder children + undoable;
mock 实现:记一条假条目)。
- **拖放到时间轴**:时间轴 drop 目前只认 footage。扩展 `drop_footage_at`
(或新增 `drop_generator_at`):若拖入的 bin 条目是 text3 节点,则
`block_clip_create` + 把 text3 节点连到 clip `tex_in` + 放置到轨道
(undoable,一条 undo)。clip 时长默认 5 秒(可拖长)。判定"条目是 text3":
`graphops` 按 identity 取节点比较 type_id。
- **bin 删除**:复用现有 `delete_entry`(从文件夹移除 + 断开图连接,已 undoable)。
文本 clip 删除走现有 clip 删除路径。
- **检查器编辑**:选中文本 clip 时,检查器显示其 **生成器节点**的参数
(现在选中 clip 显示效果链;对 generator-fed clip,链头即 text3——
检查器需要一个小改动:当 clip 的 `tex_in` 直连一个 generator 节点时,
把该生成器的参数也列出(或直接把选择路由到生成器节点)。
**实现时确定**:倾向"generator 节点作为链的第一张卡展示"——
`selected_effect_cards` 已经遍历 chain,chain() 目前把喂入节点(footage/
生成器)都算作链尾一张卡(见 effectchain.rs chain() 的已知怪癖),
text3 参数会自然出现;需要的是 text3 的参数在 build_control 下呈现为
结构化字段(文本=多行输入、字体=combo、颜色=颜色选择器、数值=spin)。
- **特效库隐藏 text3**:`textv3.rs::create()` 的 flags 增加
`DONT_SHOW_IN_CREATE_MENU`。旧项目里已存在的 text3 特效链节点**不受影响**
(只是不能再新增)。此项放在最后做,确认素材路径可用后再隐藏。
### 3.4 UI 文案(i18n)
8 个语言文件新增:`project.add_text_footage`(添加文本素材)、
`text.font_family`(字体)、`text.font_size`(字号)、`text.outline`(轮廓)、
`text.outline_width`(轮廓宽度)、`text.glow`(发光)、`text.glow_radius`(发光半径)、
`text.content`(文本内容)。zh-CN/en-US 翻译,其余语言给英文。
## 4. 工作项(可分配给子代理的最小单元)
1. **W1 节点输入扩展**:textv3 新输入 + `value()` 纯文本路径 + `text_in` 隐藏 +
HTML 剥离迁移 + 节点单测(输入存在/默认值/隐藏标志/纯文本 job 参数)。
2. **W2 轮廓/发光 GPU 后处理**:text3 `value()` 嵌套 payload(膨胀/模糊/合成),
eval.rs GPU 像素测试(白字黑轮廓边缘检测、发光半径扩散检测)。
3. **W3 cosmic-text 后端**:textengine.rs + hook 安装 + 字体枚举注入 +
集成测试(装后端后 text3 渲染出非空纹理,GPU 测试)。
4. **W4 素材创建+拖放**:面板按钮 + `create_text_footage` + drop 扩展 +
i18n + app 层测试(mock:按钮 emit;real:创建后 bin 有条目、拖放后轨道有 clip
且 tex_in 连到 text3)。
5. **W5 检查器结构化呈现**:确认 text3 参数以结构化字段出现在文本 clip 的检查器
(含聚焦保护已修的多行文本输入);颜色走 OfxColorPicker。
6. **W6 特效库隐藏 + 收尾**:DONT_SHOW_IN_CREATE_MENU、全量测试、文档更新
(docs/zh 如有特效清单)。
依赖顺序:W1→W2/W3(可并行)→W4→W5→W6。W2 与 W3 独立。
建议 W1+W2 一个子代理、W3 一个、W4+W5 一个、W6 收尾由主代理审查后执行。
## 5. 验收标准
1. 项目面板点"添加文本素材"→ bin 出现"文本"条目;拖到时间轴 → 出现文本 clip。
2. 选中文本 clip,检查器可编辑:内容(多行)、字体、字号、位置、颜色、轮廓
(开关/颜色/宽度)、发光(开关/颜色/半径);全程无 HTML 可见。
3. 编辑任一字段,暂停的画面立即更新(依赖已提交的暂停刷新修复)。
4. 特效库/添加特效菜单中不再出现 Text/text3。
5. 含旧 text3 特效的项目能打开、能渲染(HTML 自动剥成纯文本进 `plain_text_in`)。
6. `cargo test --workspace` 全绿,新增 GPU 测试在无 GPU 环境跳过。