Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/nodes/src/video/compositor/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ pub struct TextOverlayConfig {
/// Takes precedence over `font_path` when both are provided.
#[serde(default)]
pub font_data_base64: Option<String>,
/// Named font from a curated set of system fonts.
/// Takes precedence over `font_path` but not `font_data_base64`.
/// Available names: "dejavu-sans", "dejavu-serif", "dejavu-sans-mono",
/// "dejavu-sans-bold", "dejavu-serif-bold", "dejavu-sans-mono-bold",
/// "liberation-sans", "liberation-serif", "liberation-mono",
/// "freesans", "freeserif", "freemono".
#[serde(default)]
pub font_name: Option<String>,
}

pub(crate) const fn default_opacity() -> f32 {
Expand Down
9 changes: 7 additions & 2 deletions crates/nodes/src/video/compositor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1106,10 +1106,15 @@ mod tests {
font_size: 24,
font_path: None,
font_data_base64: None,
font_name: None,
};
let overlay = rasterize_text_overlay(&cfg);
assert_eq!(overlay.width, 64);
assert_eq!(overlay.height, 32);
// Width and height should be at least the original rect dimensions.
assert!(overlay.width >= 64);
assert!(overlay.height >= 32);
// The rect in the returned overlay should match the bitmap dimensions.
assert_eq!(overlay.rect.width, overlay.width);
assert_eq!(overlay.rect.height, overlay.height);
// Should have some non-zero pixels (text was drawn).
assert!(overlay.rgba_data.iter().any(|&b| b > 0));
}
Expand Down
73 changes: 67 additions & 6 deletions crates/nodes/src/video/compositor/overlay.rs
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 measure_text height includes above-origin pixels that blit_text_rgba clips, causing top-of-glyph clipping for tall ascenders

The new measure_text function (crates/nodes/src/video/mod.rs:56-89) computes height as max_bottom - max_top, where max_top can be negative for glyphs taller than 'A' (e.g. accented capitals like Á, É). However, rasterize_text_overlay (crates/nodes/src/video/compositor/overlay.rs:231-236) calls blit_text_rgba with origin_y: 0. In blit_text_rgba, any pixel at dst_y < 0 is clipped (crates/nodes/src/video/mod.rs:134). This means: for 'Á' with gy = -3, the top 3 pixel rows (containing the accent mark) are lost, while 3 unused rows are allocated at the bottom of the bitmap. The fix is to pass origin_y = -max_top (i.e., the absolute value of the most-negative gy) to shift rendering down so all glyphs fit. Since measure_text only returns (width, height), it would need to also return the y-offset.

(Refers to lines 231-236)

Prompt for agents
In crates/nodes/src/video/mod.rs, change measure_text to return (u32, u32, i32) where the third element is the y-offset (the negative of max_top, i.e. how many pixels above origin the tallest glyph extends). Then in crates/nodes/src/video/compositor/overlay.rs rasterize_text_overlay, use that offset as origin_y when calling blit_text_rgba (lines 231-236), replacing the hardcoded 0. This ensures glyphs with tall ascenders (like accented capitals) are rendered fully within the bitmap instead of being clipped at the top.
Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the ascender clipping for accented characters. This is a pre-existing limitation (blit_text_rgba always used origin_y: 0), but now that we have measure_text it would be straightforward to return the y-offset and pass it through. Will address if requested.

Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,47 @@ fn prescale_rgba(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
/// Path to the system DejaVu Sans font (commonly available on Linux).
const DEJAVU_SANS_PATH: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";

/// Map of named fonts to their filesystem paths.
/// All fonts listed here are open-source and royalty-free, commonly installed
/// on Debian/Ubuntu systems via the `fonts-dejavu-core`, `fonts-liberation`,
/// and `fonts-freefont-ttf` packages.
const KNOWN_FONTS: &[(&str, &str)] = &[
("dejavu-sans", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
("dejavu-serif", "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf"),
("dejavu-sans-mono", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"),
("dejavu-sans-bold", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
("dejavu-serif-bold", "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf"),
("dejavu-sans-mono-bold", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf"),
("liberation-sans", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"),
("liberation-serif", "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf"),
("liberation-mono", "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf"),
("freesans", "/usr/share/fonts/truetype/freefont/FreeSans.ttf"),
("freeserif", "/usr/share/fonts/truetype/freefont/FreeSerif.ttf"),
("freemono", "/usr/share/fonts/truetype/freefont/FreeMono.ttf"),
];

/// Load font data, trying (in order):
/// 1. `font_data_base64` (inline base64-encoded TTF/OTF)
/// 2. `font_path` (filesystem path)
/// 3. Bundled system default (`DejaVuSans.ttf`)
/// 2. `font_name` (named font from [`KNOWN_FONTS`] map)
/// 3. `font_path` (filesystem path)
/// 4. Bundled system default (`DejaVuSans.ttf`)
fn load_font(config: &TextOverlayConfig) -> Result<fontdue::Font, String> {
let font_bytes: Vec<u8> = if let Some(ref b64) = config.font_data_base64 {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| format!("Invalid base64 in font_data_base64: {e}"))?
} else if let Some(ref name) = config.font_name {
let path =
KNOWN_FONTS.iter().find(|(n, _)| *n == name.as_str()).map(|(_, p)| *p).ok_or_else(
|| format!("Unknown font name '{name}'. Available: {}", known_font_names()),
)?;
std::fs::read(path).map_err(|e| {
tracing::warn!(
"Named font '{name}' not found at '{path}': {e}. Is the font package installed?"
);
format!("Failed to read named font '{name}' at '{path}': {e}")
})?
} else if let Some(ref path) = config.font_path {
std::fs::read(path).map_err(|e| format!("Failed to read font file '{path}': {e}"))?
} else {
Expand All @@ -148,14 +179,19 @@ fn load_font(config: &TextOverlayConfig) -> Result<fontdue::Font, String> {
.map_err(|e| format!("Failed to parse font: {e}"))
}

/// Comma-separated list of available font names for error messages.
fn known_font_names() -> String {
KNOWN_FONTS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
}

/// Rasterize a text overlay into an RGBA8 bitmap using `fontdue` for real
/// font glyph rendering. Falls back to solid-rectangle placeholders when
/// font loading fails so the node keeps running.
///
/// The bitmap dimensions are expanded to fit the measured text size so that
/// neither the width nor the height clips the rendered string.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
pub fn rasterize_text_overlay(config: &TextOverlayConfig) -> DecodedOverlay {
let w = config.transform.rect.width.max(1);
let h = config.transform.rect.height.max(1);

// Attempt to load the font; fall back to rectangle placeholders on error.
let font = match load_font(config) {
Ok(f) => Some(f),
Expand All @@ -165,6 +201,24 @@ pub fn rasterize_text_overlay(config: &TextOverlayConfig) -> DecodedOverlay {
},
};

let font_size = config.font_size.max(1) as f32;

// Measure actual text dimensions so the bitmap is large enough to hold
// the full rendered string without clipping.
let (measured_w, measured_h) = font.as_ref().map_or_else(
|| {
// Fallback estimate for placeholder rectangles.
let glyph_w = config.font_size.max(1) * 3 / 5;
let est_w = glyph_w * config.text.chars().count() as u32;
let est_h = (font_size * 1.4).ceil() as u32;
(est_w, est_h)
},
|f| crate::video::measure_text(f, font_size, &config.text),
);

let w = config.transform.rect.width.max(measured_w).max(1);
let h = config.transform.rect.height.max(measured_h).max(1);

let total_bytes = (w as usize) * (h as usize) * 4;
let mut rgba_data = vec![0u8; total_bytes];

Expand Down Expand Up @@ -209,7 +263,14 @@ pub fn rasterize_text_overlay(config: &TextOverlayConfig) -> DecodedOverlay {
rgba_data,
width: w,
height: h,
rect: config.transform.rect.clone(),
rect: {
// Use the expanded dimensions so the blit renders the full bitmap
// without clipping text that exceeds the original rect.
let mut r = config.transform.rect.clone();
r.width = w;
r.height = h;
r
},
opacity: config.transform.opacity,
rotation_degrees: config.transform.rotation_degrees,
z_index: config.transform.z_index,
Expand Down
46 changes: 46 additions & 0 deletions crates/nodes/src/video/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,52 @@ pub mod vp9;

// ── Shared font-rendering helpers ────────────────────────────────────────────

/// Measure the pixel dimensions a single-line text string would occupy when
/// rendered at `font_size`. Returns `(width, height)`.
///
/// The width is the sum of advance widths. The height uses the same baseline
/// logic as [`blit_text_rgba`] and adds enough room for descenders.
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
pub fn measure_text(font: &fontdue::Font, font_size: f32, text: &str) -> (u32, u32) {
if text.is_empty() {
return (0, 0);
}

let (ref_metrics, _) = font.rasterize('A', font_size);
let baseline_y = ref_metrics.height as f32;

let mut total_width: f32 = 0.0;
let mut max_top: i32 = 0; // highest pixel above origin_y (always >= 0)
let mut max_bottom: i32 = 0; // lowest pixel below origin_y

for ch in text.chars() {
let (metrics, _) = font.rasterize(ch, font_size);

let gy = (baseline_y - metrics.ymin as f32) as i32 - metrics.height as i32;
let glyph_bottom = gy + metrics.height as i32;

if gy < max_top {
max_top = gy;
}
if glyph_bottom > max_bottom {
max_bottom = glyph_bottom;
}

total_width += metrics.advance_width;
}

let w = total_width.ceil() as u32;
let h =
if max_bottom > max_top { (max_bottom - max_top) as u32 } else { font_size.ceil() as u32 };

(w, h)
}

/// Alpha-blend a single text string into a packed RGBA8 buffer.
///
/// `origin_x` / `origin_y` are the top-left pixel coordinates where the first
Expand Down
37 changes: 35 additions & 2 deletions ui/src/components/CompositorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,35 @@ function layerHue(index: number): number {
return (index * 137.508) % 360;
}

// ── Font mapping ────────────────────────────────────────────────────────────

/** Map backend font names to CSS font-family values for canvas preview.
* These use web-safe fallback stacks so the preview is visually
* representative even when the exact system font is not installed. */
const FONT_FAMILY_MAP: Record<string, string> = {
'dejavu-sans': '"DejaVu Sans", "Verdana", sans-serif',
'dejavu-serif': '"DejaVu Serif", "Georgia", serif',
'dejavu-sans-mono': '"DejaVu Sans Mono", "Courier New", monospace',
'dejavu-sans-bold': '"DejaVu Sans", "Verdana", sans-serif',
'dejavu-serif-bold': '"DejaVu Serif", "Georgia", serif',
'dejavu-sans-mono-bold': '"DejaVu Sans Mono", "Courier New", monospace',
'liberation-sans': '"Liberation Sans", "Arial", sans-serif',
'liberation-serif': '"Liberation Serif", "Times New Roman", serif',
'liberation-mono': '"Liberation Mono", "Courier New", monospace',
freesans: '"FreeSans", "Arial", sans-serif',
freeserif: '"FreeSerif", "Times New Roman", serif',
freemono: '"FreeMono", "Courier New", monospace',
};

/** Return true when the backend font name maps to a bold variant. */
function isBoldFont(fontName: string): boolean {
return fontName.endsWith('-bold');
}

function cssFontFamily(fontName: string): string {
return FONT_FAMILY_MAP[fontName] ?? 'sans-serif';
}

// ── Styled components ───────────────────────────────────────────────────────

const CanvasOuter = styled.div`
Expand Down Expand Up @@ -356,15 +385,19 @@ const TextOverlayLayer: React.FC<{
onChange={(e) => setEditText(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
style={{ fontSize: Math.max(10, overlay.fontSize * scale * 0.6) }}
style={{
fontSize: Math.max(10, overlay.fontSize * scale * 0.6),
fontFamily: cssFontFamily(overlay.fontName),
}}
/>
) : (
<TextContent>
<span
style={{
fontSize: Math.max(8, overlay.fontSize * scale),
color: textColor,
fontWeight: 600,
fontFamily: cssFontFamily(overlay.fontName),
fontWeight: isBoldFont(overlay.fontName) ? 700 : 600,
textShadow: '0 1px 3px rgba(0,0,0,0.7)',
lineHeight: 1.2,
textAlign: 'center',
Expand Down
Loading