Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
35 changes: 33 additions & 2 deletions ui/src/components/CompositorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,33 @@ function layerHue(index: number): number {
return (index * 137.508) % 360;
}

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

/** Map backend font names to CSS font-family values for canvas preview. */
const FONT_FAMILY_MAP: Record<string, string> = {
'dejavu-sans': '"DejaVu Sans", sans-serif',
'dejavu-serif': '"DejaVu Serif", serif',
'dejavu-sans-mono': '"DejaVu Sans Mono", monospace',
'dejavu-sans-bold': '"DejaVu Sans", sans-serif',
'dejavu-serif-bold': '"DejaVu Serif", serif',
'dejavu-sans-mono-bold': '"DejaVu Sans Mono", 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", sans-serif',
freeserif: '"FreeSerif", serif',
freemono: '"FreeMono", 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 +383,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