Files
oak-editor/crates/oaknode/src/nodes/textv1.rs
T
Mike-Solar 9003b78176 feat: built-in effect params, clip click-select, effect drag-and-drop, project load with plugins
- serializer resolves node types through the factory's dynamic
  (runtime-registered OpenFX) entries, so a project carrying plugin
  nodes loads again (was: "unknown node type"); covered by a new
  CI-gated round-trip test driving the real fixture plugin
- built-in effect nodes expose their inputs as inspector parameters
  like the C++ parameter editor: localized input names from the
  behavior, combo option tables via the new
  NodeBehavior::input_combo_strings (16 nodes, string-for-string from
  the C++ set_combo_box_strings), connection/data inputs excluded
- effect library: live drag-and-drop — onto the inspector's effect
  stack (lands at the indicator position) and onto the node editor
  canvas (creates the node at the drop point); double-click still
  appends to the selected clip
- inspector parameter controls are no longer recreated per render
  (gpui stack view caches them per effect), so sliders drag and
  checkboxes click; the view observes the engine and silently re-syncs
  values (undo/redo land on the widgets)
- timeline: left-press selects clips (plain/keep-multi/Ctrl-Cmd
  toggle); clip moves clamp the shared delta so no clip of a linked
  group lands before frame 0 instead of failing with "invalid move
  target"
- oakplugin: createInstance-rejected instances skip the destroyInstance
  notification (the plugin never owned them); vendor-suite fetchSuite
  misses moved behind OAK_OFX_TRACE; the worker logs the discovered/
  registered plugin counts
- CI: the OFX probe step also runs the serialization round-trip test
- gpui submodule: params view caching, clip click-select, library
  drag payload, graph_position_at
2026-08-20 23:46:40 +08:00

486 lines
16 KiB
Rust

// 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/>.
//! Legacy text generator v1 (C++
//! `src/node/src/generator/text/textv1.{h,cpp}`,
//! `olive::TextGeneratorV1`).
//!
//! FONT/RASTER BACKEND DEPENDENCY — DELIBERATELY UNDECIDED:
//! The C++ does NOT link any font/raster library directly (no freetype,
//! stb, harfbuzz, etc. anywhere in the tree). Text layout/rasterization
//! was historically Qt's rich text stack (`QTextDocument` +
//! `QAbstractTextDocumentLayout` + `QPainter` into a
//! `QImage::Format_Grayscale8` coverage buffer); it now runs behind the
//! facade-installed hooks in [`super::textbackend`]. No Rust font crate
//! is chosen here on purpose.
use crate::factory::NodeMeta;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{NodeValue, NodeValueRow, NodeValueTable};
use oakcore_rs::Rational;
use super::textbackend::{TextLayoutMode, TextLayoutRequest, TextLayoutSize};
/// Text input id (C++ `k_text_input`). Type: text; default
/// `"Sample Text"`.
pub const TEXT_INPUT: &str = "text_in";
/// HTML toggle input id (C++ `k_html_input`). Type: boolean; default
/// `false`.
pub const HTML_INPUT: &str = "html_in";
/// Text color input id (C++ `k_color_input`). Type: color; default
/// `Color(1.0, 1.0, 1.0)` (white).
pub const COLOR_INPUT: &str = "color_in";
/// Vertical alignment input id (C++ `k_v_align_input`). Type: combo;
/// default `1` (center); combo strings: "Top", "Center", "Bottom".
pub const V_ALIGN_INPUT: &str = "valign_in";
/// Font family input id (C++ `k_font_input`). Type: font (C++
/// `NodeValue::k_font` — no dedicated [`crate::value::ValueType`]
/// variant, travels as text); no default.
pub const FONT_INPUT: &str = "font_in";
/// Font size input id (C++ `k_font_size_input`). Type: float; default
/// `72.0`.
pub const FONT_SIZE_INPUT: &str = "font_size_in";
/// Legacy text generator v1. The C++ class has no own private members
/// (inputs/caches live in [`NodeCore`]), so this is a unit struct.
pub struct TextGeneratorV1;
/// `Variant::to_string()` for the text/font inputs (Text payload, with a
/// numeric fallback for mis-typed connections).
fn to_text(v: &NodeValue) -> String {
match v {
NodeValue::Text(s) => s.clone(),
other => other.to_double().to_string(),
}
}
impl TextGeneratorV1 {
/// Build the C++ `TextLayoutRequest` (textv1.cpp `generate_frame()`):
/// the text and font from the input row, HTML mode with newlines
/// replaced by `<br>` when `html_in` is set, horizontal centering on,
/// and the wrap width at 80% of the frame width (the "title safe"
/// area — `(width / 10) * 8`). The measure backend is not consulted
/// here.
pub fn layout_request(row: &NodeValueRow, frame_width: i32) -> TextLayoutRequest {
let text = row
.get(TEXT_INPUT)
.map(to_text)
.unwrap_or_else(|| String::new());
let html = matches!(row.get(HTML_INPUT), Some(NodeValue::Boolean(true)));
let mut mode = TextLayoutMode::PlainText;
let text = if html {
// QTextDocument::setHtml() doesn't translate newlines, so they
// were replaced with <br> tags first.
mode = TextLayoutMode::Html;
text.replace('\n', "<br>")
} else {
text
};
let tenth_of_width = frame_width / 10;
TextLayoutRequest {
text,
mode,
font_family: row.get(FONT_INPUT).map(to_text).unwrap_or_else(String::new),
font_size_pt: row
.get(FONT_SIZE_INPUT)
.map(NodeValue::to_double)
.unwrap_or(0.0),
dots_per_meter: 0,
wrap_width: (tenth_of_width * 8) as f64,
center_horizontally: true,
}
}
/// C++ draw offsets (textv1.cpp `generate_frame()`): x is pushed 10%
/// inwards for the title-safe area; the vertical offset depends on the
/// valign combo (top: 10% top margin; center: frame center; bottom:
/// 10% bottom margin). The C++ math is integer (`width()/10`,
/// `height()/2 - doc_height/2`, ...), mirrored here.
pub fn draw_offsets(
valign: i32,
frame_width: i32,
frame_height: i32,
doc_height: i32,
) -> (f64, f64) {
let tenth_of_width = frame_width / 10;
let offset_x = tenth_of_width as f64;
let offset_y = match valign {
// k_vertical_align_top: push 10% inwards for the title-safe area.
0 => (frame_height / 10) as f64,
// k_vertical_align_center.
1 => (frame_height / 2 - doc_height / 2) as f64,
// k_vertical_align_bottom: 10% bottom margin.
2 => (frame_height - doc_height - frame_height / 10) as f64,
_ => 0.0,
};
(offset_x, offset_y)
}
/// The layout/measure control flow of the C++ `generate_frame()` with
/// the backend hooks: build the request, measure via the installed
/// measure backend (zero size when none is installed — the documented
/// no-backend fallback), and compute the draw offsets for the given
/// frame size.
///
/// The render step and the alpha transplant need the frame's pixel
/// buffer, which the Rust frame handle does not expose; they are not
/// representable here (`// CPP-PARITY: textv1.cpp` `generate_frame`).
pub fn measure_and_layout(
row: &NodeValueRow,
frame_width: i32,
frame_height: i32,
) -> (TextLayoutRequest, TextLayoutSize, (f64, f64)) {
let req = Self::layout_request(row, frame_width);
let doc = match super::textbackend::text_measure_backend() {
Some(measure) => measure(&req),
None => TextLayoutSize::default(),
};
let valign = row
.get(V_ALIGN_INPUT)
.map(NodeValue::to_double)
.unwrap_or(0.0) as i32;
let offsets = Self::draw_offsets(valign, frame_width, frame_height, doc.height as i32);
(req, doc, offsets)
}
}
impl NodeBehavior for TextGeneratorV1 {
/// Human-readable name (C++ `name()`).
fn name(&self) -> &str {
"Text (Legacy)"
}
/// Stable type id (C++ `id()`).
fn type_id(&self) -> &str {
"org.olivevideoeditor.Olive.textgenerator"
}
/// Categories (C++ `category()`).
fn categories(&self) -> &[Category] {
&[Category::Generator]
}
/// Description (C++ `description()`).
fn description(&self) -> &str {
"Generate rich text."
}
/// Localized input names (C++ `retranslate()`): `text_in` ->
/// "Text", `html_in` -> "Enable HTML", `font_in` -> "Font",
/// `font_size_in` -> "Font Size", `color_in` -> "Color",
/// `valign_in` -> "Vertical Align" (combo strings Top/Center/
/// Bottom — set by the C++ `set_combo_box_strings`, which has no
/// trait surface here).
fn input_name<'a>(&self, id: &'a str) -> &'a str {
match id {
TEXT_INPUT => "Text",
HTML_INPUT => "Enable HTML",
COLOR_INPUT => "Color",
V_ALIGN_INPUT => "Vertical Align",
FONT_INPUT => "Font",
FONT_SIZE_INPUT => "Font Size",
_ => id,
}
}
/// Combo input option labels (C++ `retranslate()` /
/// `set_combo_box_strings`): `valign_in` -> "Top", "Center",
/// "Bottom".
fn input_combo_strings(&self, id: &str) -> Vec<&'static str> {
match id {
V_ALIGN_INPUT => vec!["Top", "Center", "Bottom"],
_ => Vec::new(),
}
}
/// Evaluate outputs (C++ `value()`): if the text input is
/// non-empty, push a texture generate job at the global video
/// params; otherwise push nothing.
///
/// The Rust model has no generate-job payload: the job case pushes a
/// null texture handle marking a renderer-deferred generate job
/// resolved via [`NodeBehavior::generate_frame`]
/// (`// CPP-PARITY: textv1.cpp` `value()`).
fn value(
&self,
core: &NodeCore,
inputs: &NodeValueRow,
time: Rational,
table: &mut NodeValueTable,
) {
let text = inputs.get(TEXT_INPUT).map(to_text).unwrap_or_else(|| {
core.value_at_time(TEXT_INPUT, -1, time)
.to_double()
.to_string()
});
if !text.is_empty() {
table.push(
crate::value::ValueType::Texture,
NodeValue::Texture(crate::handle::CHandle::null()),
None,
);
}
}
/// Direct frame generation (C++ `generate_frame()`): rasterizes
/// the text into a grayscale coverage buffer (32-bit-aligned
/// scanlines) via the installed text backend — HTML mode replaces
/// newlines with `<br>`, wrap width is 80% of frame width ("title
/// safe"), horizontal centering is on, vertical offset depends on
/// the valign combo (top: 10% margin; center: frame center; bottom:
/// 10% bottom margin) — then transplants the coverage as alpha
/// into the float frame multiplied by the color input. With no
/// backend installed, warns once and leaves the frame empty.
///
/// The Rust frame is an opaque [`crate::handle::CHandle`]
/// whose pixels cannot be read or written from this crate, so the
/// body is a documented no-op; the layout/measure/offset control flow
/// is ported in [`Self::layout_request`], [`Self::draw_offsets`] and
/// [`Self::measure_and_layout`], and exercised by the tests.
fn generate_frame(
&self,
core: &NodeCore,
frame: &mut crate::handle::CHandle,
time: Rational,
) {
let _ = (core, frame, time);
}
/// Deep copy (C++ `copy()`).
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
Some(Box::new(TextGeneratorV1))
}
}
/// Constructor (C++ `TextGeneratorV1::TextGeneratorV1()`): adds
/// `text_in`, `html_in`, `color_in`, `valign_in`, `font_in` and
/// `font_size_in` with the defaults documented on the constants, and
/// sets the `dont_show_in_create_menu` flag.
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
let mut core = NodeCore::new();
core.add_input(crate::input::Input::new(
TEXT_INPUT,
crate::value::ValueType::Text,
NodeValue::Text("Sample Text".to_string()),
));
core.add_input(crate::input::Input::new(
HTML_INPUT,
crate::value::ValueType::Boolean,
NodeValue::Boolean(false),
));
core.add_input(crate::input::Input::new(
COLOR_INPUT,
crate::value::ValueType::Color,
NodeValue::Color([1.0, 1.0, 1.0, 1.0]),
));
core.add_input(crate::input::Input::new(
V_ALIGN_INPUT,
crate::value::ValueType::Combo,
NodeValue::Combo(1),
));
core.add_input(crate::input::Input::new(
FONT_INPUT,
crate::value::ValueType::Text,
NodeValue::Text(String::new()),
));
core.add_input(crate::input::Input::new(
FONT_SIZE_INPUT,
crate::value::ValueType::Float,
NodeValue::Float(72.0),
));
core.flags |= crate::node::flags::DONT_SHOW_IN_CREATE_MENU;
(core, Box::new(TextGeneratorV1))
}
/// Register this node type (C++ `k_text_generator_v1` in
/// `factory.cpp::create_from_factory_index`).
pub fn register(meta: &mut Vec<NodeMeta>) {
meta.push(NodeMeta {
type_id: "org.olivevideoeditor.Olive.textgenerator",
name: "Text (Legacy)",
categories: &[Category::Generator],
create,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node::NodeBehavior;
use crate::value::{NodeValueTable, ValueType};
use oakcore_rs::Rational;
#[test]
fn input_names() {
let n = TextGeneratorV1;
assert_eq!(n.input_name(TEXT_INPUT), "Text");
assert_eq!(n.input_name(HTML_INPUT), "Enable HTML");
assert_eq!(n.input_name(COLOR_INPUT), "Color");
assert_eq!(n.input_name(V_ALIGN_INPUT), "Vertical Align");
assert_eq!(n.input_name(FONT_INPUT), "Font");
assert_eq!(n.input_name(FONT_SIZE_INPUT), "Font Size");
assert_eq!(n.input_name("other_in"), "other_in");
}
#[test]
fn create_wires_inputs() {
let (core, behavior) = create();
assert_eq!(
behavior.type_id(),
"org.olivevideoeditor.Olive.textgenerator"
);
assert_eq!(
core.get_input(TEXT_INPUT).unwrap().value_type,
ValueType::Text
);
assert_eq!(
core.get_input(TEXT_INPUT).unwrap().default,
NodeValue::Text("Sample Text".to_string())
);
assert_eq!(
core.get_input(HTML_INPUT).unwrap().value_type,
ValueType::Boolean
);
assert_eq!(
core.get_input(COLOR_INPUT).unwrap().default,
NodeValue::Color([1.0, 1.0, 1.0, 1.0])
);
assert_eq!(
core.get_input(V_ALIGN_INPUT).unwrap().default,
NodeValue::Combo(1)
);
assert_eq!(
core.get_input(FONT_SIZE_INPUT).unwrap().default,
NodeValue::Float(72.0)
);
assert_ne!(core.flags & crate::node::flags::DONT_SHOW_IN_CREATE_MENU, 0);
}
#[test]
fn layout_request_plain_text() {
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("Hello".to_string()));
row.insert(FONT_INPUT.to_string(), NodeValue::Text("Arial".to_string()));
row.insert(FONT_SIZE_INPUT.to_string(), NodeValue::Float(48.0));
row.insert(HTML_INPUT.to_string(), NodeValue::Boolean(false));
let req = TextGeneratorV1::layout_request(&row, 1920);
assert_eq!(req.text, "Hello");
assert_eq!(req.mode, TextLayoutMode::PlainText);
assert_eq!(req.font_family, "Arial");
assert_eq!(req.font_size_pt, 48.0);
assert!(req.center_horizontally);
// 1920 / 10 * 8 = 1536.
assert_eq!(req.wrap_width, 1536.0);
}
#[test]
fn layout_request_html_translates_newlines() {
let mut row = NodeValueRow::default();
row.insert(
TEXT_INPUT.to_string(),
NodeValue::Text("line1\nline2".to_string()),
);
row.insert(HTML_INPUT.to_string(), NodeValue::Boolean(true));
let req = TextGeneratorV1::layout_request(&row, 1280);
assert_eq!(req.text, "line1<br>line2");
assert_eq!(req.mode, TextLayoutMode::Html);
// 1280 / 10 * 8 = 1024.
assert_eq!(req.wrap_width, 1024.0);
}
#[test]
fn draw_offsets_per_valign() {
// Center (default of the v1 node): x = width/10, y = height/2.
let (x, y) = TextGeneratorV1::draw_offsets(1, 1920, 1080, 100);
assert_eq!(x, 192.0);
assert_eq!(y, 540.0 - 50.0);
// Top: y = height/10.
let (x, y) = TextGeneratorV1::draw_offsets(0, 1920, 1080, 100);
assert_eq!(x, 192.0);
assert_eq!(y, 108.0);
// Bottom: y = height - doc_height - height/10.
let (_, y) = TextGeneratorV1::draw_offsets(2, 1920, 1080, 100);
assert_eq!(y, 1080.0 - 100.0 - 108.0);
}
#[test]
fn measure_without_backend_returns_zero_size() {
crate::nodes::textbackend::set_text_backends(None, None);
// No backend is installed in cargo test; the fallback is a zero
// document size.
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("Hello".to_string()));
row.insert(V_ALIGN_INPUT.to_string(), NodeValue::Combo(1));
let (req, doc, offsets) = TextGeneratorV1::measure_and_layout(&row, 1920, 1080);
assert_eq!(doc.width, 0.0);
assert_eq!(doc.height, 0.0);
assert_eq!(req.wrap_width, 1536.0);
assert_eq!(offsets.0, 192.0);
assert_eq!(offsets.1, 540.0);
}
#[test]
fn value_pushes_job_when_text_nonempty() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text("Hi".to_string()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
// The C++ pushes a deferred generate job; the Rust model pushes a
// null texture handle marking that job.
assert!(matches!(
table.get(ValueType::Texture),
Some(NodeValue::Texture(h)) if h.is_null()
));
}
#[test]
fn value_pushes_nothing_when_text_empty() {
let (core, behavior) = create();
let mut row = NodeValueRow::default();
row.insert(TEXT_INPUT.to_string(), NodeValue::Text(String::new()));
let mut table = NodeValueTable::default();
behavior.value(&core, &row, Rational::new(0, 1), &mut table);
assert!(table.is_empty());
}
#[test]
fn generate_frame_is_documented_noop() {
let (core, behavior) = create();
let mut frame = crate::handle::CHandle::null();
behavior.generate_frame(&core, &mut frame, Rational::new(0, 1));
// The frame handle cannot be touched; it stays untouched (null).
assert!(frame.is_null());
}
#[test]
fn duplicate_copies_node() {
let (_core, behavior) = create();
let copy = behavior.duplicate(&_core).unwrap();
assert_eq!(copy.type_id(), "org.olivevideoeditor.Olive.textgenerator");
assert_eq!(copy.name(), "Text (Legacy)");
}
}