Files
oak-editor/crates/oaknode/src/nodes/textbackend.rs
T
Mike-Solar 18ff60f147 feat(engine): clip move, clip effect_input, mandatory static FFmpeg
- oakengine_sequence_move_clip implemented for real (oaktimeline
  TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple
  trim bugs the stub was hiding); same-track via the frozen C ABI,
  cross-track supported by the module command
- oaknode clip blocks now declare a tex_in texture input and set
  effect_input to it, so timeline clips can host effect chains; facade
  test covers effect insert/remove on a real clip
- oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a
  Homebrew upgrade left the system ffmpeg .pc pointing at a deleted
  dav1d Cellar path, breaking links); reads a git-ignored workspace
  .env for IDEs that cannot inject env vars (RustRover); links the C++
  stdlib for C++ codec libs (svt-av1)
- oakengine re-exports oaknode so tests share one crate instance;
  it_node uses the direct instance's value type where it calls the
  module FFI (the --workspace dev-dependency feature split builds
  oaknode twice)
2026-08-11 23:04:48 +08:00

250 lines
9.0 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/>.
//! Shared text layout/rasterization backend hooks (C++
//! `src/node/src/generator/text/textbackend.h`, header-only — no class,
//! just POD structs and global function-pointer hooks in `olive::`).
//!
//! FONT/RASTER BACKEND DEPENDENCY — DELIBERATELY UNDECIDED:
//! The C++ text nodes do NOT link any font or raster library directly.
//! Text layout and rasterization were historically done with Qt's rich
//! text stack (`QTextDocument` / `QTextOption` /
//! `QAbstractTextDocumentLayout`) rasterized via `QPainter` into a
//! `QImage` (`Format_Grayscale8` for v1/v2 coverage buffers,
//! `Format_RGBA8888_Premultiplied` for v3). That Qt dependency has
//! already been removed from oaknode: the C++ now carries only the POD
//! job descriptions below plus two installable backend hooks
//! (`g_text_measure_backend` / `g_text_render_backend`) that the
//! facade/app layer fills in at runtime. No Rust font/shaping/raster
//! crate (freetype-rs, rusttype, cosmic-text, swash, etc.) is chosen
//! here on purpose; the backend decision belongs to the facade layer
//! that will install these hooks.
/// POD description of a text layout/rasterization job (C++
/// `TextLayoutRequest`). Replaces the `QTextDocument`/`QTextOption`
/// usage in the text generator nodes; this module carries only the
/// data.
pub struct TextLayoutRequest {
/// Plain text or markup, per `mode`.
pub text: String,
/// Layout mode (C++ `TextLayoutRequest::Mode`).
pub mode: TextLayoutMode,
/// Default font family (empty = backend default).
pub font_family: String,
/// Default font size in points (0 = default).
pub font_size_pt: f64,
/// Paint device resolution (0 = backend default).
pub dots_per_meter: i32,
/// Wrap width (C++ `QTextDocument::setTextWidth()`).
pub wrap_width: f64,
/// Center horizontally by default (C++ default
/// `QTextOption(Qt::AlignCenter)`).
pub center_horizontally: bool,
// Note: the backends always paint with white as the default text
// color (formerly PaintContext palette QPalette::Text = Qt::white);
// HTML markup may override it per span.
}
/// Layout mode (C++ `TextLayoutRequest::Mode`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextLayoutMode {
/// Plain text (C++ `QTextDocument::setPlainText()`).
PlainText,
/// HTML subset (C++ `QTextDocument::setHtml()`).
Html,
/// Olive's `Html::html_to_doc()` rich text.
OliveHtml,
}
/// Laid-out document size (C++ `TextLayoutSize`, formerly
/// `QTextDocument::size()`).
#[derive(Clone, Copy, Debug, Default)]
pub struct TextLayoutSize {
/// Document width in pixels.
pub width: f64,
/// Document height in pixels.
pub height: f64,
}
/// Target pixel buffer for text rasterization (C++
/// `TextRenderTarget`). `channel_count == 1` means 8-bit grayscale
/// coverage (formerly `QImage::Format_Grayscale8`, the node tints it
/// afterwards); `channel_count == 4` means 8-bit RGBA premultiplied
/// drawn over the existing (cleared) buffer content (formerly
/// `QImage::Format_RGBA8888_Premultiplied`).
pub struct TextRenderTarget<'a> {
/// Pixel buffer, `linesize_bytes * height` bytes.
pub data: &'a mut [u8],
/// Buffer width in pixels.
pub width: i32,
/// Buffer height in pixels.
pub height: i32,
/// Bytes per scanline.
pub linesize_bytes: i32,
/// Channel count: 1 = grayscale coverage, 4 = RGBA premultiplied.
pub channel_count: i32,
}
/// Draw transform, mirroring the original `QPainter` calls (C++
/// `TextRenderTransform`). The painter first applied
/// `scale(scale, scale)`, then translated to the base offset, set the
/// clip rect `(0, 0, clip_width, clip_height)` at that base offset, and
/// finally translated by the remaining draw offset before drawing. A
/// point `p` of the laid-out document therefore lands at
/// `((p.x + draw_offset_x) * scale, (p.y + draw_offset_y) * scale)`,
/// clipped to `((clip_offset_*) * scale, (clip_size) * scale)` relative
/// to the same origin. `clip_enabled == false` means no clip.
#[derive(Clone, Copy, Debug, Default)]
pub struct TextRenderTransform {
/// Uniform scale (C++ `QPainter::scale(scale, scale)`).
pub scale: f64,
/// Draw offset X, in pre-scale document coordinates.
pub draw_offset_x: f64,
/// Draw offset Y, in pre-scale document coordinates.
pub draw_offset_y: f64,
/// Whether the clip rect is active.
pub clip_enabled: bool,
/// Clip rect offset X, in pre-scale document coordinates.
pub clip_offset_x: f64,
/// Clip rect offset Y, in pre-scale document coordinates.
pub clip_offset_y: f64,
/// Clip rect width, in pre-scale document coordinates.
pub clip_width: f64,
/// Clip rect height, in pre-scale document coordinates.
pub clip_height: f64,
}
/// Measure hook (C++ `TextMeasureBackend`): lays out the request and
/// returns the document size (formerly `QTextDocument::size()`).
pub type TextMeasureBackend = fn(&TextLayoutRequest) -> TextLayoutSize;
/// Render hook (C++ `TextRenderBackend`): draws the request into the
/// target buffer (formerly `QAbstractTextDocumentLayout::draw()`).
pub type TextRenderBackend =
for<'a> fn(&TextLayoutRequest, &TextRenderTransform, TextRenderTarget<'a>);
/// Install the backend hooks (C++ `set_text_backends()`). When a hook
/// is `None` the caller falls back to a zero size / leaves the (already
/// cleared) buffer untouched — a documented behavior gap until the
/// facade installs a text engine. Setting a hook to `None` uninstalls
/// it (the C++ global is a plain function pointer, assignable any
/// number of times; a `Mutex` keeps the tests able to reset it).
pub fn set_text_backends(measure: Option<TextMeasureBackend>, render: Option<TextRenderBackend>) {
*MEASURE.lock().unwrap() = measure;
*RENDER.lock().unwrap() = render;
}
/// Currently installed measure hook (C++ `text_measure_backend()`).
pub fn text_measure_backend() -> Option<TextMeasureBackend> {
*MEASURE.lock().unwrap()
}
/// Currently installed render hook (C++ `text_render_backend()`).
pub fn text_render_backend() -> Option<TextRenderBackend> {
*RENDER.lock().unwrap()
}
/// Installed measure hook (C++ global `g_text_measure_backend`).
static MEASURE: std::sync::Mutex<Option<TextMeasureBackend>> = std::sync::Mutex::new(None);
/// Installed render hook (C++ global `g_text_render_backend`).
static RENDER: std::sync::Mutex<Option<TextRenderBackend>> = std::sync::Mutex::new(None);
#[cfg(test)]
mod tests {
use super::*;
// The two tests below share the process-global backend statics; a
// lock serializes them so `backend_hooks_default_none` cannot observe
// the hooks installed by `backend_hooks_install_and_query`.
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn backend_hooks_default_none() {
let _guard = LOCK.lock().unwrap();
set_text_backends(None, None);
assert_eq!(text_measure_backend(), None);
assert_eq!(text_render_backend(), None);
}
#[test]
fn backend_hooks_install_and_query() {
let _guard = LOCK.lock().unwrap();
fn measure(_r: &TextLayoutRequest) -> TextLayoutSize {
TextLayoutSize {
width: 12.0,
height: 34.0,
}
}
fn render(_r: &TextLayoutRequest, _t: &TextRenderTransform, mut target: TextRenderTarget) {
for b in target.data.iter_mut() {
*b = 255;
}
}
set_text_backends(Some(measure), Some(render));
assert_eq!(
text_measure_backend().unwrap()(&TextLayoutRequest {
text: String::new(),
mode: TextLayoutMode::PlainText,
font_family: String::new(),
font_size_pt: 0.0,
dots_per_meter: 0,
wrap_width: 0.0,
center_horizontally: false,
})
.width,
12.0
);
assert_eq!(
text_measure_backend().unwrap()(&TextLayoutRequest {
text: String::new(),
mode: TextLayoutMode::Html,
font_family: String::new(),
font_size_pt: 0.0,
dots_per_meter: 0,
wrap_width: 0.0,
center_horizontally: false,
})
.height,
34.0
);
let mut buf = vec![0u8; 4];
text_render_backend().unwrap()(
&TextLayoutRequest {
text: String::new(),
mode: TextLayoutMode::PlainText,
font_family: String::new(),
font_size_pt: 0.0,
dots_per_meter: 0,
wrap_width: 0.0,
center_horizontally: false,
},
&TextRenderTransform::default(),
TextRenderTarget {
data: &mut buf,
width: 2,
height: 2,
linesize_bytes: 2,
channel_count: 1,
},
);
assert_eq!(buf, vec![255u8; 4]);
// Restore the uninstalled state so other tests observe the
// no-backend fallback.
set_text_backends(None, None);
}
}