diff --git a/crates/nodes/src/video/compositor/config.rs b/crates/nodes/src/video/compositor/config.rs index 6aa3d643..201697e8 100644 --- a/crates/nodes/src/video/compositor/config.rs +++ b/crates/nodes/src/video/compositor/config.rs @@ -94,6 +94,14 @@ pub struct TextOverlayConfig { /// Takes precedence over `font_path` when both are provided. #[serde(default)] pub font_data_base64: Option, + /// 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, } pub(crate) const fn default_opacity() -> f32 { diff --git a/crates/nodes/src/video/compositor/mod.rs b/crates/nodes/src/video/compositor/mod.rs index b1bd4622..e4685923 100644 --- a/crates/nodes/src/video/compositor/mod.rs +++ b/crates/nodes/src/video/compositor/mod.rs @@ -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)); } diff --git a/crates/nodes/src/video/compositor/overlay.rs b/crates/nodes/src/video/compositor/overlay.rs index b7d462cd..7a2421f7 100644 --- a/crates/nodes/src/video/compositor/overlay.rs +++ b/crates/nodes/src/video/compositor/overlay.rs @@ -127,16 +127,47 @@ fn prescale_rgba(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec { /// 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 { let font_bytes: Vec = 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 { @@ -148,14 +179,19 @@ fn load_font(config: &TextOverlayConfig) -> Result { .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::>().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), @@ -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]; @@ -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, diff --git a/crates/nodes/src/video/mod.rs b/crates/nodes/src/video/mod.rs index 3804a222..34e9580b 100644 --- a/crates/nodes/src/video/mod.rs +++ b/crates/nodes/src/video/mod.rs @@ -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 diff --git a/ui/src/components/CompositorCanvas.tsx b/ui/src/components/CompositorCanvas.tsx index ab011208..25bddc12 100644 --- a/ui/src/components/CompositorCanvas.tsx +++ b/ui/src/components/CompositorCanvas.tsx @@ -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 = { + '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` @@ -356,7 +385,10 @@ 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), + }} /> ) : ( @@ -364,7 +396,8 @@ const TextOverlayLayer: React.FC<{ 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', diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index d78a86ce..e25cf288 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -39,6 +39,8 @@ export interface TextOverlayState { height: number; color: [number, number, number, number]; fontSize: number; + /** Named font from the server's curated set (e.g. "dejavu-sans"). */ + fontName: string; opacity: number; rotationDegrees: number; zIndex: number; @@ -110,6 +112,9 @@ export interface UseCompositorLayersResult { addImageOverlay: (dataBase64: string, naturalWidth?: number, naturalHeight?: number) => void; updateImageOverlay: (id: string, updates: Partial>) => void; removeImageOverlay: (id: string) => void; + /** Atomically reassign z-index values for all layer types in one commit. + * Each entry maps a layer id + kind to its new z-index. */ + reorderLayers: (entries: Array<{ id: string; kind: LayerKind; zIndex: number }>) => void; } interface Rect { @@ -131,6 +136,7 @@ interface TextOverlayConfig { rect: Rect; color?: [number, number, number, number]; font_size?: number; + font_name?: string; opacity?: number; rotation_degrees?: number; z_index?: number; @@ -181,6 +187,7 @@ function parseTextOverlays(params: Record): TextOverlayState[] height: o.rect?.height ?? 40, color: o.color ?? [255, 255, 255, 255], fontSize: o.font_size ?? 24, + fontName: o.font_name ?? 'dejavu-sans', opacity: o.opacity ?? 1.0, rotationDegrees: o.rotation_degrees ?? 0, zIndex: o.z_index ?? 100 + i, @@ -220,6 +227,7 @@ function serializeTextOverlays(overlays: TextOverlayState[]): TextOverlayConfig[ }, color: o.color, font_size: o.fontSize, + font_name: o.fontName, opacity: o.visible ? Math.round(o.opacity * 100) / 100 : 0, rotation_degrees: Math.round(o.rotationDegrees * 10) / 10, z_index: o.zIndex, @@ -293,13 +301,8 @@ function mergeOverlayState( return changed ? merged : current; } -/** Build the full compositor config from current params + updated layers */ -function buildConfig( - params: Record, - layers: LayerState[], - textOverlays?: TextOverlayState[], - imageOverlays?: ImageOverlayState[] -): Record { +/** Serialize video layers to the wire format used by the backend. */ +function serializeLayers(layers: LayerState[]): Record { const layersMap: Record = {}; for (const layer of layers) { layersMap[layer.id] = { @@ -314,11 +317,20 @@ function buildConfig( rotation_degrees: Math.round(layer.rotationDegrees * 10) / 10, }; } + return layersMap; +} +/** Build the full compositor config from current params + updated layers */ +function buildConfig( + params: Record, + layers: LayerState[], + textOverlays?: TextOverlayState[], + imageOverlays?: ImageOverlayState[] +): Record { return { width: params.width ?? 1280, height: params.height ?? 720, - layers: layersMap, + layers: serializeLayers(layers), image_overlays: imageOverlays ? serializeImageOverlays(imageOverlays) : (params.image_overlays ?? []), @@ -413,7 +425,11 @@ export const useCompositorLayers = ( mergeOverlayState( currentText, parseTextOverlays(params), - (a, b) => a.text !== b.text || a.fontSize !== b.fontSize + (a, b) => + a.text !== b.text || + a.fontSize !== b.fontSize || + a.fontName !== b.fontName || + a.color.some((v, i) => v !== b.color[i]) ) ); setImageOverlays((currentImg) => mergeOverlayState(currentImg, parseImageOverlays(params))); @@ -970,6 +986,18 @@ export const useCompositorLayers = ( [commitOverlays] ); + // ── Z-index helpers ────────────────────────────────────────────────────── + + /** Return the highest z-index currently in use across all layer types. + * Returns -1 when there are no layers so the first overlay gets z 0. */ + const maxZIndex = useCallback((): number => { + let max = -1; + for (const l of layersRef.current) if (l.zIndex > max) max = l.zIndex; + for (const o of textOverlaysRef.current) if (o.zIndex > max) max = o.zIndex; + for (const o of imageOverlaysRef.current) if (o.zIndex > max) max = o.zIndex; + return max; + }, []); + // ── Text overlay CRUD ───────────────────────────────────────────────────── const addTextOverlay = useCallback( @@ -987,9 +1015,10 @@ export const useCompositorLayers = ( height: 40, color: [255, 255, 255, 255], fontSize: 24, + fontName: 'dejavu-sans', opacity: 1.0, rotationDegrees: 0, - zIndex: 100 + prev.length, + zIndex: maxZIndex() + 1, visible: true, }, ]; @@ -999,12 +1028,34 @@ export const useCompositorLayers = ( return next; }); }, - [commitOverlays] + [commitOverlays, maxZIndex] ); const updateTextOverlay = useCallback( - (id: string, updates: Partial>) => - updateOverlay(id, updates, setTextOverlays, (next) => [next, imageOverlaysRef.current]), + (id: string, updates: Partial>) => { + // Auto-expand the rect so the rendered text is never clipped. + // The backend expands the bitmap to fit, but the UI should keep + // the interactive rect in sync. + const existing = textOverlaysRef.current.find((o) => o.id === id); + if (existing) { + const fontSize = updates.fontSize ?? existing.fontSize; + const text = updates.text ?? existing.text; + + // Height: ~1.4× font size covers ascenders + descenders. + const minHeight = Math.ceil(fontSize * 1.4); + if (existing.height < minHeight && !('height' in updates)) { + updates = { ...updates, height: minHeight }; + } + + // Width: ~0.6× font size per character is a reasonable estimate + // for proportional fonts. The backend will expand if still short. + const minWidth = Math.ceil(text.length * fontSize * 0.6); + if (existing.width < minWidth && !('width' in updates)) { + updates = { ...updates, width: minWidth }; + } + } + updateOverlay(id, updates, setTextOverlays, (next) => [next, imageOverlaysRef.current]); + }, [updateOverlay] ); @@ -1044,8 +1095,7 @@ export const useCompositorLayers = ( height: h, opacity: 1.0, rotationDegrees: 0, - // Z-index band: image overlays use 200+ (video: 0–99, text: 100–199) - zIndex: 200 + prev.length, + zIndex: maxZIndex() + 1, visible: true, }, ]; @@ -1055,7 +1105,7 @@ export const useCompositorLayers = ( return next; }); }, - [commitOverlays] + [commitOverlays, maxZIndex] ); const updateImageOverlay = useCallback( @@ -1073,6 +1123,85 @@ export const useCompositorLayers = ( [removeOverlay] ); + // ── Batch reorder ───────────────────────────────────────────────────────── + // + // Atomically update z-index for every layer in a single commit so that + // drag-to-reorder doesn't fire N individual updates that race against + // each other via stale refs. + + const reorderLayers = useCallback( + (entries: Array<{ id: string; kind: LayerKind; zIndex: number }>) => { + // Build lookup: id → new z-index + const zMap = new Map(); + for (const e of entries) zMap.set(e.id, e.zIndex); + + // Update video layers + let nextLayers = layersRef.current; + const hasVideoChanges = nextLayers.some((l) => { + const z = zMap.get(l.id); + return z !== undefined && z !== l.zIndex; + }); + if (hasVideoChanges) { + nextLayers = nextLayers + .map((l) => { + const z = zMap.get(l.id); + return z !== undefined && z !== l.zIndex ? { ...l, zIndex: z } : l; + }) + .sort((a, b) => a.zIndex - b.zIndex); + setLayers(nextLayers); + } + + // Update text overlays + let nextText = textOverlaysRef.current; + const hasTextChanges = nextText.some((o) => { + const z = zMap.get(o.id); + return z !== undefined && z !== o.zIndex; + }); + if (hasTextChanges) { + nextText = nextText.map((o) => { + const z = zMap.get(o.id); + return z !== undefined && z !== o.zIndex ? { ...o, zIndex: z } : o; + }); + setTextOverlays(nextText); + } + + // Update image overlays + let nextImg = imageOverlaysRef.current; + const hasImgChanges = nextImg.some((o) => { + const z = zMap.get(o.id); + return z !== undefined && z !== o.zIndex; + }); + if (hasImgChanges) { + nextImg = nextImg.map((o) => { + const z = zMap.get(o.id); + return z !== undefined && z !== o.zIndex ? { ...o, zIndex: z } : o; + }); + setImageOverlays(nextImg); + } + + // Single commit with all three updated arrays + if (hasVideoChanges || hasTextChanges || hasImgChanges) { + overlayCommitGuardRef.current = Date.now(); + if (onConfigChange) { + const config = buildConfig(params, nextLayers, nextText, nextImg); + onConfigChange(nodeId, config); + } else if (onParamChange) { + // Video layers — use immediate onParamChange (not throttled) so + // all layer types commit atomically in the same tick. + if (hasVideoChanges) { + onParamChange(nodeId, 'layers', serializeLayers(nextLayers)); + } + // Overlays + if (hasTextChanges || hasImgChanges) { + onParamChange(nodeId, 'text_overlays', serializeTextOverlays(nextText)); + onParamChange(nodeId, 'image_overlays', serializeImageOverlays(nextImg)); + } + } + } + }, + [nodeId, onConfigChange, onParamChange, params] + ); + return { layers, selectedLayerId, @@ -1093,5 +1222,6 @@ export const useCompositorLayers = ( addImageOverlay, updateImageOverlay, removeImageOverlay, + reorderLayers, }; }; diff --git a/ui/src/nodes/CompositorNode.tsx b/ui/src/nodes/CompositorNode.tsx index f3cecc1e..f98293cc 100644 --- a/ui/src/nodes/CompositorNode.tsx +++ b/ui/src/nodes/CompositorNode.tsx @@ -3,7 +3,8 @@ // SPDX-License-Identifier: MPL-2.0 import styled from '@emotion/styled'; -import { Eye, EyeOff, Image, Plus, Type, X } from 'lucide-react'; +import { Eye, EyeOff, GripVertical, Image, Plus, Type, X } from 'lucide-react'; +import { Reorder } from 'motion/react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { CompositorCanvas } from '@/components/CompositorCanvas'; @@ -124,13 +125,6 @@ const SliderInput = styled.input` } `; -const ZIndexRow = styled.div` - display: flex; - align-items: center; - gap: 4px; - font-size: 11px; -`; - const NumericInput = styled.input` width: 44px; padding: 3px 4px; @@ -164,35 +158,6 @@ const NumericInput = styled.input` -moz-appearance: textfield; `; -const StackButton = styled.button` - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - border: 1px solid var(--sk-border); - border-radius: 3px; - background: var(--sk-input-bg); - color: var(--sk-text-muted); - cursor: pointer; - font-size: 12px; - line-height: 1; - pointer-events: auto; - flex-shrink: 0; - - &:hover:not(:disabled) { - background: var(--sk-overlay-medium); - color: var(--sk-text); - border-color: var(--sk-primary); - } - - &:disabled { - cursor: not-allowed; - opacity: 0.4; - } -`; - const LayerInfoRow = styled.div` display: flex; align-items: center; @@ -357,6 +322,85 @@ const OverlayNumInput = styled(NumericInput)` font-size: 10px; `; +const ColorInput = styled.input` + width: 28px; + height: 22px; + padding: 0; + border: 1px solid var(--sk-border); + border-radius: 3px; + background: none; + cursor: pointer; + pointer-events: auto; + flex-shrink: 0; + + &::-webkit-color-swatch-wrapper { + padding: 1px; + } + &::-webkit-color-swatch { + border: none; + border-radius: 2px; + } +`; + +/** Convert [R, G, B, A] to a hex color string (#rrggbb) for */ +function rgbaToHex(color: [number, number, number, number]): string { + const [r, g, b] = color; + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} + +/** Convert a hex color string (#rrggbb) + alpha byte → [R, G, B, A] */ +function hexToRgba(hex: string, alpha: number): [number, number, number, number] { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return [r, g, b, alpha]; +} + +/** Available named fonts matching the server's KNOWN_FONTS map. */ +const FONT_OPTIONS = [ + { value: 'dejavu-sans', label: 'DejaVu Sans' }, + { value: 'dejavu-serif', label: 'DejaVu Serif' }, + { value: 'dejavu-sans-mono', label: 'DejaVu Sans Mono' }, + { value: 'dejavu-sans-bold', label: 'DejaVu Sans Bold' }, + { value: 'dejavu-serif-bold', label: 'DejaVu Serif Bold' }, + { value: 'dejavu-sans-mono-bold', label: 'DejaVu Mono Bold' }, + { value: 'liberation-sans', label: 'Liberation Sans' }, + { value: 'liberation-serif', label: 'Liberation Serif' }, + { value: 'liberation-mono', label: 'Liberation Mono' }, + { value: 'freesans', label: 'FreeSans' }, + { value: 'freeserif', label: 'FreeSerif' }, + { value: 'freemono', label: 'FreeMono' }, +] as const; + +const FontSelect = styled.select` + flex: 1; + padding: 2px 4px; + font-size: 10px; + border: 1px solid var(--sk-border); + border-radius: 3px; + background: var(--sk-panel-bg); + color: var(--sk-text); + color-scheme: dark light; + outline: none; + min-width: 0; + pointer-events: auto; + cursor: pointer; + + option { + background: var(--sk-panel-bg); + color: var(--sk-text); + } + + &:focus { + border-color: var(--sk-primary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +`; + const VisibilityButton = styled.button` display: inline-flex; align-items: center; @@ -433,7 +477,7 @@ interface CompositorNodeProps { /** Shared per-layer property controls: header (name + position), opacity * slider, rotation slider. Layer-type-specific controls can be injected * in two positions via `children` (between header and sliders, e.g. text - * input / font size) and `footer` (after sliders, e.g. z-index order). */ + * input / font size). */ const LayerPropertyControls: React.FC<{ name: string; x: number; @@ -444,7 +488,6 @@ const LayerPropertyControls: React.FC<{ onRotationChange: (value: number) => void; disabled: boolean; children?: React.ReactNode; - footer?: React.ReactNode; }> = React.memo( ({ name, @@ -456,7 +499,6 @@ const LayerPropertyControls: React.FC<{ onRotationChange, disabled, children, - footer, }) => ( <> {rotationDegrees.toFixed(0)}° - - {footer} ) ); @@ -526,7 +566,6 @@ const UnifiedLayerList: React.FC<{ onSelectLayer: (id: string | null) => void; onOpacityChange: (layerId: string, opacity: number) => void; onRotationChange: (layerId: string, degrees: number) => void; - onZIndexChange: (layerId: string, zIndex: number) => void; onToggleVisibility: (layerId: string) => void; onAddText: (text: string) => void; onUpdateText: (id: string, updates: Partial>) => void; @@ -534,6 +573,7 @@ const UnifiedLayerList: React.FC<{ onAddImage: (dataBase64: string, naturalWidth?: number, naturalHeight?: number) => void; onUpdateImage: (id: string, updates: Partial>) => void; onRemoveImage: (id: string) => void; + onReorderLayers: (entries: Array<{ id: string; kind: LayerKind; zIndex: number }>) => void; disabled: boolean; }> = React.memo( ({ @@ -544,7 +584,6 @@ const UnifiedLayerList: React.FC<{ onSelectLayer, onOpacityChange, onRotationChange, - onZIndexChange, onToggleVisibility, onAddText, onUpdateText, @@ -552,6 +591,7 @@ const UnifiedLayerList: React.FC<{ onAddImage, onUpdateImage, onRemoveImage, + onReorderLayers, disabled, }) => { // Build a unified list of all layers sorted by z-index (highest first for @@ -588,15 +628,6 @@ const UnifiedLayerList: React.FC<{ const selectedLayer = layers.find((l) => l.id === selectedLayerId); - // Compute unified stack navigation across all layer types - const sortedAllByZ = React.useMemo( - () => [...entries].sort((a, b) => a.zIndex - b.zIndex), - [entries] - ); - const unifiedStackIndex = sortedAllByZ.findIndex((e) => e.id === selectedLayerId); - const isBottommost = unifiedStackIndex === 0; - const isTopmost = unifiedStackIndex === sortedAllByZ.length - 1; - // Add overlay menu state const [menuOpen, setMenuOpen] = useState(false); const fileInputRef = useRef(null); @@ -650,49 +681,23 @@ const UnifiedLayerList: React.FC<{ } }; - /** Render z-index order controls for any layer type. Uses the unified - * stack to find neighbors regardless of whether the selected layer is - * a video input, text overlay, or image overlay. */ - const renderZIndexFooter = (currentZIndex: number, onZChange: (z: number) => void) => ( - - Order - - { - if (isBottommost) return; - const below = sortedAllByZ[unifiedStackIndex - 1]; - onZChange(below.zIndex - 1); - }} - > - ▼ - - - { - const val = Number.parseInt(e.target.value, 10); - if (!Number.isNaN(val)) onZChange(val); - }} - disabled={disabled} - className="nodrag nopan" - /> - - { - if (isTopmost) return; - const above = sortedAllByZ[unifiedStackIndex + 1]; - onZChange(above.zIndex + 1); - }} - > - ▲ - - - + /** Reassign z-index values after a drag-to-reorder in the layer list. + * The list is rendered top-to-bottom = highest z first, so position 0 + * in the reordered array gets the highest z-index. */ + const handleReorder = useCallback( + (reordered: UnifiedLayerEntry[]) => { + const maxZ = reordered.length - 1; + const updates: Array<{ id: string; kind: LayerKind; zIndex: number }> = []; + for (let i = 0; i < reordered.length; i++) { + const entry = reordered[i]; + const newZ = maxZ - i; + if (entry.zIndex !== newZ) { + updates.push({ id: entry.id, kind: entry.kind, zIndex: newZ }); + } + } + if (updates.length > 0) onReorderLayers(updates); + }, + [onReorderLayers] ); return ( @@ -729,56 +734,70 @@ const UnifiedLayerList: React.FC<{ {entries.length === 0 && No layers configured} - {entries.map((entry) => ( - onSelectLayer(entry.id === selectedLayerId ? null : entry.id)} - > - {iconForKind(entry.kind)} - - {entry.label} - - + {entries.map((entry) => ( + - z:{entry.zIndex} - - - { - e.stopPropagation(); - onToggleVisibility(entry.id); - }} + onClick={() => onSelectLayer(entry.id === selectedLayerId ? null : entry.id)} > - {entry.visible ? : } - - - {(entry.kind === 'text' || entry.kind === 'image') && ( - - { - e.stopPropagation(); - if (entry.kind === 'text') onRemoveText(entry.id); - else onRemoveImage(entry.id); + - - - - )} - - ))} + /> + {iconForKind(entry.kind)} + + {entry.label} + + + { + e.stopPropagation(); + onToggleVisibility(entry.id); + }} + > + {entry.visible ? : } + + + {(entry.kind === 'text' || entry.kind === 'image') && ( + + { + e.stopPropagation(); + if (entry.kind === 'text') onRemoveText(entry.id); + else onRemoveImage(entry.id); + }} + > + + + + )} + + + ))} + {/* Controls for the selected video layer */} {selectedLayer && ( @@ -791,9 +810,6 @@ const UnifiedLayerList: React.FC<{ onOpacityChange={(v) => onOpacityChange(selectedLayer.id, v)} onRotationChange={(v) => onRotationChange(selectedLayer.id, v)} disabled={disabled} - footer={renderZIndexFooter(selectedLayer.zIndex, (z) => - onZIndexChange(selectedLayer.id, z) - )} /> )} @@ -808,9 +824,6 @@ const UnifiedLayerList: React.FC<{ onOpacityChange={(v) => onUpdateText(selectedTextOverlay.id, { opacity: v })} onRotationChange={(v) => onUpdateText(selectedTextOverlay.id, { rotationDegrees: v })} disabled={disabled} - footer={renderZIndexFooter(selectedTextOverlay.zIndex, (z) => - onUpdateText(selectedTextOverlay.id, { zIndex: z }) - )} > + + Font + onUpdateText(selectedTextOverlay.id, { fontName: e.target.value })} + disabled={disabled} + className="nodrag nopan" + > + {FONT_OPTIONS.map((opt) => ( + + ))} + + + + Color + + onUpdateText(selectedTextOverlay.id, { + color: hexToRgba(e.target.value, selectedTextOverlay.color[3]), + }) + } + disabled={disabled} + className="nodrag nopan" + /> + )} @@ -849,9 +891,6 @@ const UnifiedLayerList: React.FC<{ onOpacityChange={(v) => onUpdateImage(selectedImageOverlay.id, { opacity: v })} onRotationChange={(v) => onUpdateImage(selectedImageOverlay.id, { rotationDegrees: v })} disabled={disabled} - footer={renderZIndexFooter(selectedImageOverlay.zIndex, (z) => - onUpdateImage(selectedImageOverlay.id, { zIndex: z }) - )} /> )} @@ -876,7 +915,6 @@ const CompositorNode: React.FC = React.memo(({ id, data, se handleResizePointerDown, updateLayerOpacity, updateLayerRotation, - updateLayerZIndex, toggleLayerVisibility, layerRefs, textOverlays, @@ -887,6 +925,7 @@ const CompositorNode: React.FC = React.memo(({ id, data, se addImageOverlay, updateImageOverlay, removeImageOverlay, + reorderLayers, } = useCompositorLayers({ nodeId: id, sessionId: data.sessionId, @@ -965,7 +1004,6 @@ const CompositorNode: React.FC = React.memo(({ id, data, se onSelectLayer={selectLayer} onOpacityChange={updateLayerOpacity} onRotationChange={updateLayerRotation} - onZIndexChange={updateLayerZIndex} onToggleVisibility={toggleLayerVisibility} onAddText={addTextOverlay} onUpdateText={updateTextOverlay} @@ -973,6 +1011,7 @@ const CompositorNode: React.FC = React.memo(({ id, data, se onAddImage={addImageOverlay} onUpdateImage={updateImageOverlay} onRemoveImage={removeImageOverlay} + onReorderLayers={reorderLayers} disabled={disabled} />