From e6adcd9cae2febfeabe7052acb3e564b9288aee4 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 3 Mar 2026 16:12:25 +0000 Subject: [PATCH 01/10] feat(compositor): text color, font selection, draggable layers, clipping fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Text color: add color picker (RGB + alpha slider) for text overlays - Font selection: add font_name field to TextOverlayConfig with 12 curated system fonts (DejaVu, Liberation, FreeFonts), dropdown in UI, warning when named font file is missing - Draggable layer list: replace z-index ▲/▼ buttons with drag-to-reorder using motion/react Reorder, reassigns z-indices on drop - Text clipping fix: expand bitmap height to ceil(font_size * 1.4) in rasterize_text_overlay and auto-expand UI rect height when font size increases Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- crates/nodes/src/video/compositor/config.rs | 7 + crates/nodes/src/video/compositor/mod.rs | 5 +- crates/nodes/src/video/compositor/overlay.rs | 57 ++- ui/src/hooks/useCompositorLayers.ts | 25 +- ui/src/nodes/CompositorNode.tsx | 351 +++++++++++-------- 5 files changed, 288 insertions(+), 157 deletions(-) diff --git a/crates/nodes/src/video/compositor/config.rs b/crates/nodes/src/video/compositor/config.rs index 6aa3d643..934abd6f 100644 --- a/crates/nodes/src/video/compositor/config.rs +++ b/crates/nodes/src/video/compositor/config.rs @@ -94,6 +94,13 @@ 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", "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..d354808b 100644 --- a/crates/nodes/src/video/compositor/mod.rs +++ b/crates/nodes/src/video/compositor/mod.rs @@ -1106,10 +1106,13 @@ 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); + // Height is expanded to at least ceil(font_size * 1.4) = 34 + // to avoid clipping descenders. + assert_eq!(overlay.height, 34); // 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..3424d164 100644 --- a/crates/nodes/src/video/compositor/overlay.rs +++ b/crates/nodes/src/video/compositor/overlay.rs @@ -127,16 +127,46 @@ 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,13 +178,26 @@ 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 height is clamped to at least `font_size * 1.4` so that text +/// (including descenders) is never clipped, even when the client sends a +/// stale/small rect height. #[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); + // Ensure the bitmap is tall enough to contain the rendered text. + // A factor of 1.4× the font size accounts for ascenders, descenders, + // and a small amount of padding. + let min_h = ((config.font_size.max(1) as f32) * 1.4).ceil() as u32; + let h = config.transform.rect.height.max(min_h).max(1); // Attempt to load the font; fall back to rectangle placeholders on error. let font = match load_font(config) { @@ -209,7 +252,13 @@ pub fn rasterize_text_overlay(config: &TextOverlayConfig) -> DecodedOverlay { rgba_data, width: w, height: h, - rect: config.transform.rect.clone(), + rect: { + // Use the expanded height so the blit renders the full bitmap + // (including descenders that may extend below the original rect). + let mut r = config.transform.rect.clone(); + r.height = h; + r + }, opacity: config.transform.opacity, rotation_degrees: config.transform.rotation_degrees, z_index: config.transform.z_index, diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index d78a86ce..fa5f5b2f 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; @@ -131,6 +133,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 +184,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 +224,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, @@ -413,7 +418,7 @@ 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 ) ); setImageOverlays((currentImg) => mergeOverlayState(currentImg, parseImageOverlays(params))); @@ -987,6 +992,7 @@ export const useCompositorLayers = ( height: 40, color: [255, 255, 255, 255], fontSize: 24, + fontName: 'dejavu-sans', opacity: 1.0, rotationDegrees: 0, zIndex: 100 + prev.length, @@ -1003,8 +1009,21 @@ export const useCompositorLayers = ( ); const updateTextOverlay = useCallback( - (id: string, updates: Partial>) => - updateOverlay(id, updates, setTextOverlays, (next) => [next, imageOverlaysRef.current]), + (id: string, updates: Partial>) => { + // Auto-expand rect height when font size increases beyond the + // current box height. Uses the same 1.4× factor as the backend + // to ensure the rendered text is never clipped. + if (updates.fontSize !== undefined) { + const existing = textOverlaysRef.current.find((o) => o.id === id); + if (existing) { + const minHeight = Math.ceil(updates.fontSize * 1.4); + if (existing.height < minHeight && !('height' in updates)) { + updates = { ...updates, height: minHeight }; + } + } + } + updateOverlay(id, updates, setTextOverlays, (next) => [next, imageOverlaysRef.current]); + }, [updateOverlay] ); diff --git a/ui/src/nodes/CompositorNode.tsx b/ui/src/nodes/CompositorNode.tsx index f3cecc1e..51ed2502 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,79 @@ 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-input-bg); + color: var(--sk-text); + outline: none; + min-width: 0; + pointer-events: auto; + cursor: pointer; + + &: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 +471,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 +482,6 @@ const LayerPropertyControls: React.FC<{ onRotationChange: (value: number) => void; disabled: boolean; children?: React.ReactNode; - footer?: React.ReactNode; }> = React.memo( ({ name, @@ -456,7 +493,6 @@ const LayerPropertyControls: React.FC<{ onRotationChange, disabled, children, - footer, }) => ( <> {rotationDegrees.toFixed(0)}° - - {footer} ) ); @@ -588,15 +622,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 +675,22 @@ 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; + for (let i = 0; i < reordered.length; i++) { + const entry = reordered[i]; + const newZ = maxZ - i; + if (entry.zIndex === newZ) continue; // already correct + if (entry.kind === 'video') onZIndexChange(entry.id, newZ); + else if (entry.kind === 'text') onUpdateText(entry.id, { zIndex: newZ }); + else if (entry.kind === 'image') onUpdateImage(entry.id, { zIndex: newZ }); + } + }, + [onZIndexChange, onUpdateText, onUpdateImage] ); return ( @@ -729,56 +727,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 +803,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 +817,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" + /> + + onUpdateText(selectedTextOverlay.id, { + color: [ + selectedTextOverlay.color[0], + selectedTextOverlay.color[1], + selectedTextOverlay.color[2], + Number.parseInt(e.target.value, 10), + ], + }) + } + disabled={disabled} + className="nodrag nopan" + title="Alpha" + /> + {selectedTextOverlay.color[3]} + )} @@ -849,9 +905,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 }) - )} /> )} From 90bcb03618caf1410ad11ffc36feb98385319511 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 3 Mar 2026 16:12:36 +0000 Subject: [PATCH 02/10] style: apply rustfmt to overlay.rs Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- crates/nodes/src/video/compositor/overlay.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/nodes/src/video/compositor/overlay.rs b/crates/nodes/src/video/compositor/overlay.rs index 3424d164..9ee14255 100644 --- a/crates/nodes/src/video/compositor/overlay.rs +++ b/crates/nodes/src/video/compositor/overlay.rs @@ -158,13 +158,14 @@ fn load_font(config: &TextOverlayConfig) -> Result { .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()))?; + 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?"); + 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 { From e622da9420e686655456dc3c46c2c6bf88c3b6c1 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 3 Mar 2026 16:19:56 +0000 Subject: [PATCH 03/10] fix: batch reorder z-index updates and add color comparison - Add reorderLayers() to useCompositorLayers that atomically updates z-index for all layer types (video, text, image) in a single commit, preventing race conditions from stale refs when handleReorder fired N individual update calls. - Add missing color array comparison to mergeOverlayState's hasExtraChanges comparator so server-echoed color changes are correctly detected. - Remove unused onZIndexChange prop from UnifiedLayerList since reorderLayers now handles all z-index mutations. Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/hooks/useCompositorLayers.ts | 88 ++++++++++++++++++++++++++++- ui/src/nodes/CompositorNode.tsx | 19 ++++--- 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index fa5f5b2f..95296629 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -112,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 { @@ -418,7 +421,11 @@ export const useCompositorLayers = ( mergeOverlayState( currentText, parseTextOverlays(params), - (a, b) => a.text !== b.text || a.fontSize !== b.fontSize || a.fontName !== b.fontName + (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))); @@ -1092,6 +1099,84 @@ 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 + if (hasVideoChanges) { + throttledConfigChange?.(nextLayers); + } + // Overlays + if (hasTextChanges || hasImgChanges) { + onParamChange(nodeId, 'text_overlays', serializeTextOverlays(nextText)); + onParamChange(nodeId, 'image_overlays', serializeImageOverlays(nextImg)); + } + } + } + }, + [nodeId, onConfigChange, onParamChange, params, throttledConfigChange] + ); + return { layers, selectedLayerId, @@ -1112,5 +1197,6 @@ export const useCompositorLayers = ( addImageOverlay, updateImageOverlay, removeImageOverlay, + reorderLayers, }; }; diff --git a/ui/src/nodes/CompositorNode.tsx b/ui/src/nodes/CompositorNode.tsx index 51ed2502..f65f77fc 100644 --- a/ui/src/nodes/CompositorNode.tsx +++ b/ui/src/nodes/CompositorNode.tsx @@ -560,7 +560,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; @@ -568,6 +567,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( ({ @@ -578,7 +578,6 @@ const UnifiedLayerList: React.FC<{ onSelectLayer, onOpacityChange, onRotationChange, - onZIndexChange, onToggleVisibility, onAddText, onUpdateText, @@ -586,6 +585,7 @@ const UnifiedLayerList: React.FC<{ onAddImage, onUpdateImage, onRemoveImage, + onReorderLayers, disabled, }) => { // Build a unified list of all layers sorted by z-index (highest first for @@ -681,16 +681,17 @@ const UnifiedLayerList: React.FC<{ 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) continue; // already correct - if (entry.kind === 'video') onZIndexChange(entry.id, newZ); - else if (entry.kind === 'text') onUpdateText(entry.id, { zIndex: newZ }); - else if (entry.kind === 'image') onUpdateImage(entry.id, { zIndex: newZ }); + if (entry.zIndex !== newZ) { + updates.push({ id: entry.id, kind: entry.kind, zIndex: newZ }); + } } + if (updates.length > 0) onReorderLayers(updates); }, - [onZIndexChange, onUpdateText, onUpdateImage] + [onReorderLayers] ); return ( @@ -929,7 +930,6 @@ const CompositorNode: React.FC = React.memo(({ id, data, se handleResizePointerDown, updateLayerOpacity, updateLayerRotation, - updateLayerZIndex, toggleLayerVisibility, layerRefs, textOverlays, @@ -940,6 +940,7 @@ const CompositorNode: React.FC = React.memo(({ id, data, se addImageOverlay, updateImageOverlay, removeImageOverlay, + reorderLayers, } = useCompositorLayers({ nodeId: id, sessionId: data.sessionId, @@ -1018,7 +1019,6 @@ const CompositorNode: React.FC = React.memo(({ id, data, se onSelectLayer={selectLayer} onOpacityChange={updateLayerOpacity} onRotationChange={updateLayerRotation} - onZIndexChange={updateLayerZIndex} onToggleVisibility={toggleLayerVisibility} onAddText={addTextOverlay} onUpdateText={updateTextOverlay} @@ -1026,6 +1026,7 @@ const CompositorNode: React.FC = React.memo(({ id, data, se onAddImage={addImageOverlay} onUpdateImage={updateImageOverlay} onRemoveImage={removeImageOverlay} + onReorderLayers={reorderLayers} disabled={disabled} /> From b14f6d3452d66faa303b6e3727d6f557bb22456d Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 3 Mar 2026 16:32:19 +0000 Subject: [PATCH 04/10] fix: use dynamic max z-index for new overlays instead of fixed bands Replace fixed z-index bands (text: 100+, image: 200+) with maxZIndex() + 1 so that new overlays always stack on top even after drag-to-reorder has normalized z-indices to [0, n-1]. Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/hooks/useCompositorLayers.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index 95296629..320a6477 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -982,6 +982,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( @@ -1002,7 +1014,7 @@ export const useCompositorLayers = ( fontName: 'dejavu-sans', opacity: 1.0, rotationDegrees: 0, - zIndex: 100 + prev.length, + zIndex: maxZIndex() + 1, visible: true, }, ]; @@ -1012,7 +1024,7 @@ export const useCompositorLayers = ( return next; }); }, - [commitOverlays] + [commitOverlays, maxZIndex] ); const updateTextOverlay = useCallback( @@ -1070,8 +1082,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, }, ]; @@ -1081,7 +1092,7 @@ export const useCompositorLayers = ( return next; }); }, - [commitOverlays] + [commitOverlays, maxZIndex] ); const updateImageOverlay = useCallback( From bb608d1c17e728448e63af1e5eba0de223e186cb Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 3 Mar 2026 16:38:30 +0000 Subject: [PATCH 05/10] fix: expand text overlay bitmap to measured text dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add measure_text() that computes actual pixel width/height from font metrics, and use it in rasterize_text_overlay to expand the bitmap to fit the full rendered string. Previously only height was expanded via a 1.4× heuristic; now both width and height use exact font measurements. On the UI side, updateTextOverlay now auto-expands the rect width (~0.6× font_size per character) in addition to height when the text would overflow the current box. Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- crates/nodes/src/video/compositor/mod.rs | 10 ++-- crates/nodes/src/video/compositor/overlay.rs | 35 +++++++++----- crates/nodes/src/video/mod.rs | 49 ++++++++++++++++++++ ui/src/hooks/useCompositorLayers.ts | 29 ++++++++---- 4 files changed, 97 insertions(+), 26 deletions(-) diff --git a/crates/nodes/src/video/compositor/mod.rs b/crates/nodes/src/video/compositor/mod.rs index d354808b..e4685923 100644 --- a/crates/nodes/src/video/compositor/mod.rs +++ b/crates/nodes/src/video/compositor/mod.rs @@ -1109,10 +1109,12 @@ mod tests { font_name: None, }; let overlay = rasterize_text_overlay(&cfg); - assert_eq!(overlay.width, 64); - // Height is expanded to at least ceil(font_size * 1.4) = 34 - // to avoid clipping descenders. - assert_eq!(overlay.height, 34); + // 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 9ee14255..7a2421f7 100644 --- a/crates/nodes/src/video/compositor/overlay.rs +++ b/crates/nodes/src/video/compositor/overlay.rs @@ -188,18 +188,10 @@ fn known_font_names() -> String { /// font glyph rendering. Falls back to solid-rectangle placeholders when /// font loading fails so the node keeps running. /// -/// The bitmap height is clamped to at least `font_size * 1.4` so that text -/// (including descenders) is never clipped, even when the client sends a -/// stale/small rect height. +/// 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); - // Ensure the bitmap is tall enough to contain the rendered text. - // A factor of 1.4× the font size accounts for ascenders, descenders, - // and a small amount of padding. - let min_h = ((config.font_size.max(1) as f32) * 1.4).ceil() as u32; - let h = config.transform.rect.height.max(min_h).max(1); - // Attempt to load the font; fall back to rectangle placeholders on error. let font = match load_font(config) { Ok(f) => Some(f), @@ -209,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]; @@ -254,9 +264,10 @@ pub fn rasterize_text_overlay(config: &TextOverlayConfig) -> DecodedOverlay { width: w, height: h, rect: { - // Use the expanded height so the blit renders the full bitmap - // (including descenders that may extend below the original 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 }, diff --git a/crates/nodes/src/video/mod.rs b/crates/nodes/src/video/mod.rs index 3804a222..9df20aad 100644 --- a/crates/nodes/src/video/mod.rs +++ b/crates/nodes/src/video/mod.rs @@ -42,6 +42,55 @@ 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/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index 320a6477..70156d4c 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -1029,16 +1029,25 @@ export const useCompositorLayers = ( const updateTextOverlay = useCallback( (id: string, updates: Partial>) => { - // Auto-expand rect height when font size increases beyond the - // current box height. Uses the same 1.4× factor as the backend - // to ensure the rendered text is never clipped. - if (updates.fontSize !== undefined) { - const existing = textOverlaysRef.current.find((o) => o.id === id); - if (existing) { - const minHeight = Math.ceil(updates.fontSize * 1.4); - if (existing.height < minHeight && !('height' in updates)) { - updates = { ...updates, height: minHeight }; - } + // 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]); From a878d66de61536520c07369a6dab44b3f120b1e1 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 3 Mar 2026 16:38:41 +0000 Subject: [PATCH 06/10] style: cargo fmt Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- crates/nodes/src/video/mod.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/nodes/src/video/mod.rs b/crates/nodes/src/video/mod.rs index 9df20aad..34e9580b 100644 --- a/crates/nodes/src/video/mod.rs +++ b/crates/nodes/src/video/mod.rs @@ -82,11 +82,8 @@ pub fn measure_text(font: &fontdue::Font, font_size: f32, text: &str) -> (u32, u } 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 - }; + let h = + if max_bottom > max_top { (max_bottom - max_top) as u32 } else { font_size.ceil() as u32 }; (w, h) } From 35cd503d73befde1075b218ce7ab97058509c984 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 3 Mar 2026 16:55:42 +0000 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20ato?= =?UTF-8?q?mic=20reorder=20commit=20and=20complete=20font=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace throttledConfigChange with immediate onParamChange in reorderLayers' onParamChange path so video layer and overlay z-index updates commit atomically in the same tick. - Extract serializeLayers() helper to avoid duplicating layer serialization logic between buildConfig and reorderLayers. - Add missing dejavu-serif-bold and dejavu-sans-mono-bold to the font_name doc comment in TextOverlayConfig. Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- crates/nodes/src/video/compositor/config.rs | 5 ++-- ui/src/hooks/useCompositorLayers.ts | 27 ++++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/nodes/src/video/compositor/config.rs b/crates/nodes/src/video/compositor/config.rs index 934abd6f..201697e8 100644 --- a/crates/nodes/src/video/compositor/config.rs +++ b/crates/nodes/src/video/compositor/config.rs @@ -97,8 +97,9 @@ pub struct TextOverlayConfig { /// 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", "liberation-sans", "liberation-serif", - /// "liberation-mono", "freesans", "freeserif", "freemono". + /// "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, } diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index 70156d4c..e25cf288 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -301,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] = { @@ -322,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 ?? []), @@ -1182,9 +1186,10 @@ export const useCompositorLayers = ( const config = buildConfig(params, nextLayers, nextText, nextImg); onConfigChange(nodeId, config); } else if (onParamChange) { - // Video layers + // Video layers — use immediate onParamChange (not throttled) so + // all layer types commit atomically in the same tick. if (hasVideoChanges) { - throttledConfigChange?.(nextLayers); + onParamChange(nodeId, 'layers', serializeLayers(nextLayers)); } // Overlays if (hasTextChanges || hasImgChanges) { @@ -1194,7 +1199,7 @@ export const useCompositorLayers = ( } } }, - [nodeId, onConfigChange, onParamChange, params, throttledConfigChange] + [nodeId, onConfigChange, onParamChange, params] ); return { From e3869552cd6917543c058816b2345053b115aee2 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 3 Mar 2026 17:04:50 +0000 Subject: [PATCH 08/10] feat: render font selection in canvas preview and fix dropdown contrast - Add FONT_FAMILY_MAP to CompositorCanvas that maps backend font names to CSS font-family values. Text overlays now preview the selected font in both display and edit mode on the canvas. - Bold font variants (e.g. dejavu-sans-bold) render with fontWeight 700. - Fix FontSelect contrast: use var(--sk-panel-bg) instead of undefined var(--sk-input-bg), add color-scheme hint, and style option elements explicitly for dark mode compatibility. Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/components/CompositorCanvas.tsx | 35 ++++++++++++++++++++++++-- ui/src/nodes/CompositorNode.tsx | 8 +++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/ui/src/components/CompositorCanvas.tsx b/ui/src/components/CompositorCanvas.tsx index ab011208..c3d5d90e 100644 --- a/ui/src/components/CompositorCanvas.tsx +++ b/ui/src/components/CompositorCanvas.tsx @@ -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 = { + '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` @@ -356,7 +383,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 +394,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/nodes/CompositorNode.tsx b/ui/src/nodes/CompositorNode.tsx index f65f77fc..d3ac1cf8 100644 --- a/ui/src/nodes/CompositorNode.tsx +++ b/ui/src/nodes/CompositorNode.tsx @@ -378,13 +378,19 @@ const FontSelect = styled.select` font-size: 10px; border: 1px solid var(--sk-border); border-radius: 3px; - background: var(--sk-input-bg); + 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); } From 33f06f86329d00b252ed74e1e85991e9ad223de3 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 3 Mar 2026 17:15:36 +0000 Subject: [PATCH 09/10] fix: improve font preview fallbacks and remove alpha slider - Add web-safe intermediate fonts (Verdana, Georgia, Arial, Times New Roman, Courier New) to the CSS font-family fallback stacks so the canvas preview shows a visible difference between sans-serif, serif, and monospace font groups even when the exact system fonts are not installed in the browser. - Remove the alpha slider from text color controls. Text opacity is already covered by the layer opacity slider, and a standalone alpha slider for a single channel was confusing. The color picker now always sends alpha=255. Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/components/CompositorCanvas.tsx | 22 ++++++++++++---------- ui/src/nodes/CompositorNode.tsx | 23 +---------------------- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/ui/src/components/CompositorCanvas.tsx b/ui/src/components/CompositorCanvas.tsx index c3d5d90e..25bddc12 100644 --- a/ui/src/components/CompositorCanvas.tsx +++ b/ui/src/components/CompositorCanvas.tsx @@ -36,20 +36,22 @@ function layerHue(index: number): number { // ── Font mapping ──────────────────────────────────────────────────────────── -/** Map backend font names to CSS font-family values for canvas preview. */ +/** 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", 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', + '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", sans-serif', - freeserif: '"FreeSerif", serif', - freemono: '"FreeMono", 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. */ diff --git a/ui/src/nodes/CompositorNode.tsx b/ui/src/nodes/CompositorNode.tsx index d3ac1cf8..b0bd143d 100644 --- a/ui/src/nodes/CompositorNode.tsx +++ b/ui/src/nodes/CompositorNode.tsx @@ -870,33 +870,12 @@ const UnifiedLayerList: React.FC<{ value={rgbaToHex(selectedTextOverlay.color)} onChange={(e) => onUpdateText(selectedTextOverlay.id, { - color: hexToRgba(e.target.value, selectedTextOverlay.color[3]), + color: hexToRgba(e.target.value, 255), }) } disabled={disabled} className="nodrag nopan" /> - - onUpdateText(selectedTextOverlay.id, { - color: [ - selectedTextOverlay.color[0], - selectedTextOverlay.color[1], - selectedTextOverlay.color[2], - Number.parseInt(e.target.value, 10), - ], - }) - } - disabled={disabled} - className="nodrag nopan" - title="Alpha" - /> - {selectedTextOverlay.color[3]} )} From 76ea25a154fc3b9ed1727ccad0738890f73e7884 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 3 Mar 2026 17:23:17 +0000 Subject: [PATCH 10/10] fix: preserve existing alpha when changing text color Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/nodes/CompositorNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/nodes/CompositorNode.tsx b/ui/src/nodes/CompositorNode.tsx index b0bd143d..f98293cc 100644 --- a/ui/src/nodes/CompositorNode.tsx +++ b/ui/src/nodes/CompositorNode.tsx @@ -870,7 +870,7 @@ const UnifiedLayerList: React.FC<{ value={rgbaToHex(selectedTextOverlay.color)} onChange={(e) => onUpdateText(selectedTextOverlay.id, { - color: hexToRgba(e.target.value, 255), + color: hexToRgba(e.target.value, selectedTextOverlay.color[3]), }) } disabled={disabled}