From df3b5aaaf32a174aa0a09ae5d4c4f73f6eba5584 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 1 Mar 2026 12:34:50 +0000 Subject: [PATCH 1/2] fix(compositor-ui): address 7 UX issues in compositor node Issue #1: Click outside text layer commits inline edit - Add document.activeElement.blur() in handlePaneClick before deselecting - Add useEffect on TextOverlayLayer watching isSelected to commit on deselect Issue #2: Preview panel resizable from all four edges - Add ResizeEdgeRight and ResizeEdgeBottom styled components - Extend handleResizeStart edge type to support right/bottom - Update resizeRef type to match Issue #3: Monitor view preview extracts MoQ peer settings from pipeline - Find transport::moq::peer node in pipeline and extract gateway_path/output_broadcast - Set correct serverUrl and outputBroadcast before connecting - Import updateUrlPath utility Issue #4: Deep-compare layer state to prevent position jumps on selection change - Skip setLayers/setTextOverlays/setImageOverlays when merged state is structurally equal - Prevents stale server-echoed values from causing visual glitches Issue #5: Rotate mouse delta for rotated layer resize handles - Transform (dx, dy) by -rotationDegrees in computeUpdatedLayer - Makes resize handles behave naturally regardless of layer rotation Issue #6: Visual separator between layer list and per-layer controls - Add borderTop and paddingTop to LayerInfoRow for both video and text controls Issue #7: Text layers support opacity and rotation sliders - Add rotationDegrees field to TextOverlayState, parse/serialize rotation_degrees - Add rotation transform to TextOverlayLayer canvas rendering - Replace numeric opacity input with slider matching video layer controls - Add rotation slider for text layers Co-Authored-By: Claudio Costa --- ui/src/components/CompositorCanvas.tsx | 24 +++++++ ui/src/components/OutputPreviewPanel.tsx | 53 +++++++++++++-- ui/src/hooks/useCompositorLayers.ts | 82 ++++++++++++++++++++++-- ui/src/nodes/CompositorNode.tsx | 57 ++++++++++++---- ui/src/views/MonitorView.tsx | 26 ++++++++ 5 files changed, 217 insertions(+), 25 deletions(-) diff --git a/ui/src/components/CompositorCanvas.tsx b/ui/src/components/CompositorCanvas.tsx index e846d2fd..88ff52cf 100644 --- a/ui/src/components/CompositorCanvas.tsx +++ b/ui/src/components/CompositorCanvas.tsx @@ -280,6 +280,22 @@ const TextOverlayLayer: React.FC<{ const cancelledRef = useRef(false); const committedRef = useRef(false); + // Issue #1 fix: when the layer is deselected while editing, commit the edit. + const prevSelectedRef = useRef(isSelected); + useEffect(() => { + if (prevSelectedRef.current && !isSelected && editing) { + // Layer was deselected while editing – commit + if (!cancelledRef.current && !committedRef.current) { + committedRef.current = true; + if (editText.trim() && editText !== overlay.text && onTextEdit) { + onTextEdit(overlay.id, { text: editText.trim() }); + } + } + setEditing(false); + } + prevSelectedRef.current = isSelected; + }, [isSelected, editing, editText, overlay.id, overlay.text, onTextEdit]); + const handlePointerDown = useCallback( (e: React.PointerEvent) => { if (editing) return; // don't start drag while editing @@ -346,6 +362,9 @@ const TextOverlayLayer: React.FC<{ border: `2px dashed ${borderColor}`, background: bgColor, filter: overlay.visible ? undefined : 'grayscale(0.6)', + // Issue #7: render rotation for text overlays (matching video layers) + transform: + overlay.rotationDegrees !== 0 ? `rotate(${overlay.rotationDegrees}deg)` : undefined, }} onPointerDown={handlePointerDown} onDoubleClick={handleDoubleClick} @@ -486,7 +505,12 @@ export const CompositorCanvas: React.FC = React.memo( return () => observer.disconnect(); }, [canvasWidth]); + // Issue #1 fix: blur any active element (e.g. inline text input) before + // deselecting so that the input's onBlur → commitEdit fires reliably. const handlePaneClick = useCallback(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } onSelectLayer(null); }, [onSelectLayer]); diff --git a/ui/src/components/OutputPreviewPanel.tsx b/ui/src/components/OutputPreviewPanel.tsx index c7af087f..327e5dba 100644 --- a/ui/src/components/OutputPreviewPanel.tsx +++ b/ui/src/components/OutputPreviewPanel.tsx @@ -60,6 +60,28 @@ const ResizeEdgeTop = styled.div` z-index: 25; `; +/** Issue #2: Invisible resize handle on the right edge */ +const ResizeEdgeRight = styled.div` + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: ew-resize; + z-index: 25; +`; + +/** Issue #2: Invisible resize handle on the bottom edge */ +const ResizeEdgeBottom = styled.div` + position: absolute; + bottom: -3px; + left: 0; + width: 100%; + height: 6px; + cursor: ns-resize; + z-index: 25; +`; + const DragHeader = styled.div` display: flex; align-items: center; @@ -189,7 +211,7 @@ const OutputPreviewPanel: React.FC = React.memo( startX: number; startY: number; origWidth: number; - edge: 'left' | 'top'; + edge: 'left' | 'top' | 'right' | 'bottom'; } | null>(null); const { status, watchStatus, videoRenderer, activeSessionId } = useStreamStore( @@ -243,8 +265,9 @@ const OutputPreviewPanel: React.FC = React.memo( ); // ── Resize handling ───────────────────────────────────────────────────── + // Issue #2 fix: support resizing from all four edges const handleResizeStart = useCallback( - (edge: 'left' | 'top', e: React.PointerEvent) => { + (edge: 'left' | 'top' | 'right' | 'bottom', e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); resizeRef.current = { @@ -256,18 +279,32 @@ const OutputPreviewPanel: React.FC = React.memo( const handleResizeMove = (ev: PointerEvent) => { if (!resizeRef.current) return; - if (resizeRef.current.edge === 'left') { + const curEdge = resizeRef.current.edge; + if (curEdge === 'left') { // Dragging left edge: moving left increases width (panel anchored to right) const dx = resizeRef.current.startX - ev.clientX; setPanelWidth( Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.origWidth + dx)) ); - } else { + } else if (curEdge === 'right') { + // Dragging right edge: moving right increases width + // Panel is anchored to right, so also shift position + const dx = ev.clientX - resizeRef.current.startX; + setPanelWidth( + Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.origWidth + dx)) + ); + } else if (curEdge === 'top') { // Dragging top edge: moving up increases height → increase width proportionally const dy = resizeRef.current.startY - ev.clientY; setPanelWidth( Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.origWidth + dy * 1.78)) ); + } else if (curEdge === 'bottom') { + // Dragging bottom edge: moving down increases height → increase width proportionally + const dy = ev.clientY - resizeRef.current.startY; + setPanelWidth( + Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.origWidth + dy * 1.78)) + ); } }; @@ -390,6 +427,14 @@ const OutputPreviewPanel: React.FC = React.memo( className="nodrag nopan" onPointerDown={(e) => handleResizeStart('top', e)} /> + handleResizeStart('right', e)} + /> + handleResizeStart('bottom', e)} + /> )} diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index f5870e03..9af4ea7a 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -40,6 +40,7 @@ export interface TextOverlayState { color: [number, number, number, number]; fontSize: number; opacity: number; + rotationDegrees: number; /** Client-side visibility toggle (hidden overlays send opacity=0 to backend) */ visible: boolean; } @@ -123,6 +124,7 @@ interface TextOverlayConfig { color?: [number, number, number, number]; font_size?: number; opacity?: number; + rotation_degrees?: number; } interface ImageOverlayConfig { @@ -169,6 +171,7 @@ function parseTextOverlays(params: Record): TextOverlayState[] color: o.color ?? [255, 255, 255, 255], fontSize: o.font_size ?? 24, opacity: o.opacity ?? 1.0, + rotationDegrees: o.rotation_degrees ?? 0, visible: true, })); } @@ -202,6 +205,7 @@ function serializeTextOverlays(overlays: TextOverlayState[]): TextOverlayConfig[ color: o.color, font_size: o.fontSize, opacity: o.visible ? Math.round(o.opacity * 100) / 100 : 0, + rotation_degrees: Math.round(o.rotationDegrees * 10) / 10, })); } @@ -334,7 +338,27 @@ export const useCompositorLayers = ( } : p; }); - setLayers(merged); + + // Issue #4 fix: only update layers state if the merged result actually + // differs from current state. This prevents unnecessary re-renders that + // cause positions to briefly revert when switching selected layers. + const layersChanged = + merged.length !== current.length || + merged.some( + (m, i) => + m.id !== current[i].id || + m.x !== current[i].x || + m.y !== current[i].y || + m.width !== current[i].width || + m.height !== current[i].height || + m.opacity !== current[i].opacity || + m.zIndex !== current[i].zIndex || + m.rotationDegrees !== current[i].rotationDegrees || + m.visible !== current[i].visible + ); + if (layersChanged) { + setLayers(merged); + } // Skip overlay re-parse if we just committed a local overlay change. // This prevents stale params from overwriting the local removal/add. @@ -343,7 +367,7 @@ export const useCompositorLayers = ( setTextOverlays((currentText) => { const parsed = parseTextOverlays(params); - return parsed.map((p) => { + const merged = parsed.map((p) => { const existing = currentText.find((o) => o.id === p.id); if (existing) { return { @@ -354,10 +378,27 @@ export const useCompositorLayers = ( } return p; }); + // Skip update if nothing changed + const changed = + merged.length !== currentText.length || + merged.some( + (m, i) => + m.id !== currentText[i].id || + m.x !== currentText[i].x || + m.y !== currentText[i].y || + m.width !== currentText[i].width || + m.height !== currentText[i].height || + m.opacity !== currentText[i].opacity || + m.rotationDegrees !== currentText[i].rotationDegrees || + m.text !== currentText[i].text || + m.fontSize !== currentText[i].fontSize || + m.visible !== currentText[i].visible + ); + return changed ? merged : currentText; }); setImageOverlays((currentImg) => { const parsed = parseImageOverlays(params); - return parsed.map((p) => { + const merged = parsed.map((p) => { const existing = currentImg.find((o) => o.id === p.id); if (existing) { return { @@ -368,6 +409,20 @@ export const useCompositorLayers = ( } return p; }); + // Skip update if nothing changed + const changed = + merged.length !== currentImg.length || + merged.some( + (m, i) => + m.id !== currentImg[i].id || + m.x !== currentImg[i].x || + m.y !== currentImg[i].y || + m.width !== currentImg[i].width || + m.height !== currentImg[i].height || + m.opacity !== currentImg[i].opacity || + m.visible !== currentImg[i].visible + ); + return changed ? merged : currentImg; }); }, [params, canvasWidth, canvasHeight]); @@ -388,7 +443,7 @@ export const useCompositorLayers = ( height: textOverlay.height, opacity: textOverlay.opacity, zIndex: 0, - rotationDegrees: 0, + rotationDegrees: textOverlay.rotationDegrees, visible: textOverlay.visible, }, kind: 'text', @@ -465,12 +520,24 @@ export const useCompositorLayers = ( clientX: number, clientY: number ): LayerState => { - const dx = (clientX - state.startX) / state.scale; - const dy = (clientY - state.startY) / state.scale; + const rawDx = (clientX - state.startX) / state.scale; + const rawDy = (clientY - state.startY) / state.scale; const orig = state.origLayer; if (state.type === 'drag') { - return { ...orig, x: orig.x + dx, y: orig.y + dy }; + return { ...orig, x: orig.x + rawDx, y: orig.y + rawDy }; + } + + // Issue #5 fix: transform mouse delta into the layer's local coordinate + // system so resize handles behave naturally on rotated layers. + let dx = rawDx; + let dy = rawDy; + if (orig.rotationDegrees !== 0) { + const rad = (-orig.rotationDegrees * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + dx = rawDx * cos - rawDy * sin; + dy = rawDx * sin + rawDy * cos; } // Resize @@ -779,6 +846,7 @@ export const useCompositorLayers = ( color: [255, 255, 255, 255], fontSize: 24, opacity: 1.0, + rotationDegrees: 0, visible: true, }, ]; diff --git a/ui/src/nodes/CompositorNode.tsx b/ui/src/nodes/CompositorNode.tsx index 92214964..48e42af0 100644 --- a/ui/src/nodes/CompositorNode.tsx +++ b/ui/src/nodes/CompositorNode.tsx @@ -847,10 +847,13 @@ const UnifiedLayerList: React.FC<{ ))} + {/* Issue #6: visual separator between layer list and per-layer controls */} {/* Controls for the selected video layer */} {selectedLayer && ( <> - + {selectedLayer.id} ({Math.round(selectedLayer.x)}, {Math.round(selectedLayer.y)}) @@ -934,9 +937,12 @@ const UnifiedLayerList: React.FC<{ )} {/* Controls for the selected text overlay */} + {/* Issue #6: visual separator + Issue #7: opacity/rotation sliders for text */} {selectedTextOverlay && ( <> - + Text ({Math.round(selectedTextOverlay.x)}, {Math.round(selectedTextOverlay.y)}) @@ -964,24 +970,47 @@ const UnifiedLayerList: React.FC<{ disabled={disabled} className="nodrag nopan" /> - Opacity - + + {/* Issue #7: opacity slider for text layers (same as video layers) */} + + Opacity + { - const v = Number.parseFloat(e.target.value); - if (!Number.isNaN(v)) - onUpdateText(selectedTextOverlay.id, { - opacity: Math.max(0, Math.min(1, v)), - }); - }} + onChange={(e) => + onUpdateText(selectedTextOverlay.id, { + opacity: Number.parseFloat(e.target.value), + }) + } disabled={disabled} className="nodrag nopan" /> - + {(selectedTextOverlay.opacity * 100).toFixed(0)}% + + + {/* Issue #7: rotation slider for text layers (same as video layers) */} + + Rotation + + onUpdateText(selectedTextOverlay.id, { + rotationDegrees: Number.parseFloat(e.target.value), + }) + } + disabled={disabled} + className="nodrag nopan" + /> + {selectedTextOverlay.rotationDegrees.toFixed(0)}° + )} diff --git a/ui/src/views/MonitorView.tsx b/ui/src/views/MonitorView.tsx index aba60cff..2836a75f 100644 --- a/ui/src/views/MonitorView.tsx +++ b/ui/src/views/MonitorView.tsx @@ -78,6 +78,7 @@ import { ESTIMATED_HEIGHT_BY_KIND, } from '@/utils/layoutConstants'; import { viewsLogger } from '@/utils/logger'; +import { updateUrlPath } from '@/utils/moqPeerSettings'; import { validatePipeline } from '@/utils/pipelineValidation'; import { nodeTypes, defaultEdgeOptions } from '@/utils/reactFlowDefaults'; import { collectNodeHeights } from '@/utils/reactFlowInstance'; @@ -1806,8 +1807,12 @@ const MonitorViewContent: React.FC = () => { const previewSetEnablePublish = useStreamStore((s) => s.setEnablePublish); const previewSetEnableWatch = useStreamStore((s) => s.setEnableWatch); const previewConfigLoaded = useStreamStore((s) => s.configLoaded); + const previewSetServerUrl = useStreamStore((s) => s.setServerUrl); + const previewSetOutputBroadcast = useStreamStore((s) => s.setOutputBroadcast); const isPreviewConnected = previewStatus === 'connected'; + // Issue #3 fix: extract MoQ peer settings from the selected session's pipeline + // so preview connects to the correct gateway path and output broadcast. const handleStartPreview = useCallback(async () => { // Configure for watch-only mode (no publish/mic) previewSetEnablePublish(false); @@ -1815,6 +1820,24 @@ const MonitorViewContent: React.FC = () => { if (!previewConfigLoaded) { await previewLoadConfig(); } + + // Extract gateway_path and output_broadcast from the pipeline's moq_peer node + const moqNode = pipeline + ? Object.values(pipeline.nodes).find((n) => n.kind === 'transport::moq::peer' && n.params) + : undefined; + if (moqNode?.params) { + const params = moqNode.params as Record; + const gatewayPath = params.gateway_path as string | undefined; + const outputBroadcast = params.output_broadcast as string | undefined; + const currentUrl = useStreamStore.getState().serverUrl; + if (gatewayPath && currentUrl) { + previewSetServerUrl(updateUrlPath(currentUrl, gatewayPath)); + } + if (outputBroadcast) { + previewSetOutputBroadcast(outputBroadcast); + } + } + await previewConnect(); }, [ previewSetEnablePublish, @@ -1822,6 +1845,9 @@ const MonitorViewContent: React.FC = () => { previewConfigLoaded, previewLoadConfig, previewConnect, + pipeline, + previewSetServerUrl, + previewSetOutputBroadcast, ]); // Handle entering staging mode From 8ab5b2c30143c64e776b74068381e1f5b4726f92 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Sun, 1 Mar 2026 13:18:29 +0000 Subject: [PATCH 2/2] fix(compositor-ui): fix preview drag, text state flicker, overlay throttling, multiline text - OutputPreviewPanel: make panel body draggable (not just header) with cursor: grab styling so preview behaves like other canvas nodes - useCompositorLayers: add throttledOverlayCommit for text/image overlay updates (sliders, etc.) to prevent flooding the server on every tick; increase overlay commit guard from 1.5s to 3s to prevent stale params from overwriting local state; arm guard immediately in updateTextOverlay and updateImageOverlay - CompositorCanvas: change InlineTextInput from to