From fa6da5571c60c9abfcfcf5d0f0febe53b02c946d Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 17 Mar 2026 22:01:34 +0000 Subject: [PATCH 1/4] feat(ui): migrate compositor layer state to Jotai atoms (Phase 1) Replace useState-based compositor layer state management with Jotai atom families for fine-grained reactivity per compositor node. Changes: - Add jotai dependency and create vanilla store + compositor atom families - Wrap app in JotaiProvider for Jotai context - Rewrite useCompositorLayers to use useAtom instead of useState - Remove zero-render DOM hack: sliderActiveRef, layerRefs for opacity/ rotation, commitLayerAppearance, and direct DOM manipulation - Remove custom memo comparators: areCanvasPropsEqual, layerArrayGeometryEqual from CompositorCanvas - Remove useDragLocalValue hook (deleted) - Simplify compositorServerSync by removing sliderActiveRef guard - Update opacity/rotation controls to read from Jotai state directly - Remove onAppearanceCommit prop chain through inspector - Update perf tests: slider scenarios have intentionally higher render counts (atoms re-render per tick vs old zero-render DOM hack), with budget assertions verifying they stay within acceptable limits Drag/resize hot path still uses refs + rAF for pointer-move frequency updates, committing to Jotai atoms on pointer-up. Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/bun.lock | 6 + ui/package.json | 4 +- ui/src/App.tsx | 66 ++++--- ui/src/components/CompositorCanvas.tsx | 73 +------ ui/src/hooks/compositorOverlays.ts | 75 ++----- ui/src/hooks/compositorServerSync.ts | 17 +- .../useCompositorLayers.render-perf.test.ts | 17 +- ui/src/hooks/useCompositorLayers.ts | 185 ++++++++---------- ui/src/hooks/useDragLocalValue.ts | 24 --- ui/src/nodes/CompositorNode.tsx | 28 +-- ui/src/nodes/compositorNodeInspector.tsx | 5 - ui/src/nodes/compositorNodeWidgets.tsx | 90 +++------ ui/src/stores/compositorAtoms.ts | 76 +++++++ ui/src/stores/jotaiStore.ts | 16 ++ 14 files changed, 284 insertions(+), 398 deletions(-) delete mode 100644 ui/src/hooks/useDragLocalValue.ts create mode 100644 ui/src/stores/compositorAtoms.ts create mode 100644 ui/src/stores/jotaiStore.ts diff --git a/ui/bun.lock b/ui/bun.lock index 32800d18..313b80ad 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -29,6 +29,8 @@ "@uiw/react-codemirror": "^4.25.8", "@xyflow/react": "^12.10.1", "codemirror": "^6.0.2", + "jotai": "^2.18.1", + "jotai-zustand": "^0.6.0", "js-yaml": "^4.1.1", "lodash-es": "^4.17.23", "lucide-react": "^0.577.0", @@ -1038,6 +1040,10 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jotai": ["jotai@2.18.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA=="], + + "jotai-zustand": ["jotai-zustand@0.6.0", "", { "peerDependencies": { "jotai": ">=2.0.0" } }, "sha512-82LVFVZZXWsqpcfvDf4Yk6Gaq2RFezU0USfAA3O9WrxqflZNGlh0BkpUGzzYpdmDuDJIcYU7Rr1582UgI4DAiw=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], diff --git a/ui/package.json b/ui/package.json index 40d860fd..7c8583a1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -61,8 +61,8 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@moq/hang": "^0.2.0", - "@moq/signals": "^0.1.3", "@moq/publish": "^0.2.3", + "@moq/signals": "^0.1.3", "@moq/watch": "^0.2.3", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -78,6 +78,8 @@ "@uiw/react-codemirror": "^4.25.8", "@xyflow/react": "^12.10.1", "codemirror": "^6.0.2", + "jotai": "^2.18.1", + "jotai-zustand": "^0.6.0", "js-yaml": "^4.1.1", "lodash-es": "^4.17.23", "lucide-react": "^0.577.0", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ba59e3d3..4d45d46f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MPL-2.0 import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Provider as JotaiProvider } from 'jotai'; import React, { useState, useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; @@ -14,6 +15,7 @@ import { ToastProvider } from './context/ToastContext'; import Layout from './Layout'; import { fetchAuthMe } from './services/auth'; import { initializePermissions } from './services/permissions'; +import { jotaiStore } from './stores/jotaiStore'; import { ensureSchemasLoaded } from './stores/schemaStore'; import { getBasePathname } from './utils/baseHref'; import { getLogger } from './utils/logger'; @@ -98,39 +100,41 @@ const App: React.FC = () => { return ( - - - - - - - setRequiresLogin(false)} />} - /> - : } - > - } /> - } /> - } /> - } /> - } /> - } /> + + + + + + + } + path="/login" + element={ setRequiresLogin(false)} />} /> - } /> - } /> - - - - - - - + : } + > + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + + + + + + + + ); }; diff --git a/ui/src/components/CompositorCanvas.tsx b/ui/src/components/CompositorCanvas.tsx index 9ebd1fe1..58c3bd50 100644 --- a/ui/src/components/CompositorCanvas.tsx +++ b/ui/src/components/CompositorCanvas.tsx @@ -61,76 +61,6 @@ export interface CompositorCanvasProps { disabled?: boolean; } -/** - * Custom comparator for CompositorCanvas memo. - * - * Video layers use zero-render DOM updates for opacity/rotation during - * slider drags, so we only compare geometry fields for those. - * Text and image overlays update via React state (not the zero-render path), - * so we use reference equality — a new array reference means content changed. - * - * Known limitation: server-pushed opacity/rotation changes from other clients - * won't trigger a canvas re-render until the next geometry change. This is - * acceptable for the single-client case (echo-backs carry the same values). - * The Jotai migration will resolve this by giving each field its own atom. - */ -function areCanvasPropsEqual(prev: CompositorCanvasProps, next: CompositorCanvasProps): boolean { - // Scalar props - if (prev.canvasWidth !== next.canvasWidth) return false; - if (prev.canvasHeight !== next.canvasHeight) return false; - if (prev.selectedLayerId !== next.selectedLayerId) return false; - if (prev.disabled !== next.disabled) return false; - - // Callback identity (stable via useCallback in parent) - if (prev.onSelectLayer !== next.onSelectLayer) return false; - if (prev.onLayerPointerDown !== next.onLayerPointerDown) return false; - if (prev.onResizePointerDown !== next.onResizePointerDown) return false; - if (prev.onTextFocusRequest !== next.onTextFocusRequest) return false; - if (prev.onLayerContextMenu !== next.onLayerContextMenu) return false; - - // Ref identity (stable MutableRefObjects) - if (prev.layerRefs !== next.layerRefs) return false; - if (prev.snapGuideRefs !== next.snapGuideRefs) return false; - - // Video layers: compare geometry only, skip appearance (opacity, rotation, mirror) - // since those use the zero-render DOM path - if (!layerArrayGeometryEqual(prev.layers, next.layers)) return false; - - // Text/image overlays: use reference equality since they update via React state - // and content changes (text, font, color, opacity, rotation) need to re-render - if (prev.textOverlays !== next.textOverlays) return false; - if (prev.imageOverlays !== next.imageOverlays) return false; - - return true; -} - -/** - * Compare layer arrays by geometry + mirror fields, skipping opacity/rotation - * (those use the zero-render DOM path during slider drags). - * Mirror is included because updateLayerMirror updates via React state, not DOM. - */ -function layerArrayGeometryEqual(a: readonly LayerState[], b: readonly LayerState[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - const la = a[i]; - const lb = b[i]; - if ( - la.id !== lb.id || - la.x !== lb.x || - la.y !== lb.y || - la.width !== lb.width || - la.height !== lb.height || - la.zIndex !== lb.zIndex || - la.visible !== lb.visible || - la.mirrorHorizontal !== lb.mirrorHorizontal || - la.mirrorVertical !== lb.mirrorVertical - ) { - return false; - } - } - return true; -} - export const CompositorCanvas: React.FC = React.memo( ({ canvasWidth, @@ -305,8 +235,7 @@ export const CompositorCanvas: React.FC = React.memo( ); - }, - areCanvasPropsEqual + } ); CompositorCanvas.displayName = 'CompositorCanvas'; diff --git a/ui/src/hooks/compositorOverlays.ts b/ui/src/hooks/compositorOverlays.ts index 2c8a465a..fa220810 100644 --- a/ui/src/hooks/compositorOverlays.ts +++ b/ui/src/hooks/compositorOverlays.ts @@ -42,10 +42,6 @@ export interface OverlayDeps { layersRef: React.MutableRefObject; textOverlaysRef: React.MutableRefObject; imageOverlaysRef: React.MutableRefObject; - /** DOM element refs for layers — used for zero-render opacity/rotation updates. */ - layerRefs: React.MutableRefObject>; - /** Set to true during zero-render slider drags to guard against server echo-back overwrites. */ - sliderActiveRef: React.MutableRefObject; throttledConfigChange: ((layers: LayerState[]) => void) | null; throttledOverlayCommit: ((text: TextOverlayState[], img: ImageOverlayState[]) => void) | null; } @@ -62,8 +58,6 @@ export function useCompositorOverlays(deps: OverlayDeps) { layersRef, textOverlaysRef, imageOverlaysRef, - layerRefs, - sliderActiveRef, throttledConfigChange, throttledOverlayCommit, } = deps; @@ -77,70 +71,34 @@ export function useCompositorOverlays(deps: OverlayDeps) { // ── Layer property updates ─────────────────────────────────────────── // - // Opacity and rotation use zero-render updates during slider drags: - // update the ref + DOM element directly, send throttled server update, - // but skip setLayers() to avoid full CompositorNode re-renders. - // React state is synced on pointer-up via commitLayerAppearance(). + // With Jotai-backed state, opacity and rotation updates go through + // normal React state (setLayers). Per-node atom scoping keeps + // re-renders limited to the compositor instance, so the old + // zero-render DOM-mutation hack is no longer needed. const updateLayerOpacity = useCallback( (layerId: string, opacity: number) => { const clamped = Math.max(0, Math.min(1, opacity)); - // Update the ref (source of truth during drag) - const idx = layersRef.current.findIndex((l) => l.id === layerId); - if (idx === -1) return; - sliderActiveRef.current = true; - - layersRef.current = layersRef.current.map((l, i) => - i === idx ? { ...l, opacity: clamped } : l - ); - // Apply directly to DOM for instant visual feedback - const el = layerRefs.current.get(layerId); - if (el) { - const layer = layersRef.current[idx]; - el.style.opacity = String(layer.visible ? clamped : 0.2); - } - // Send to server (throttled) - throttledConfigChange?.(layersRef.current); + setLayers((prev) => { + const next = prev.map((l) => (l.id === layerId ? { ...l, opacity: clamped } : l)); + throttledConfigChange?.(next); + return next; + }); }, - [layersRef, layerRefs, sliderActiveRef, throttledConfigChange] + [setLayers, throttledConfigChange] ); const updateLayerRotation = useCallback( (layerId: string, degrees: number) => { - // Update the ref - const idx = layersRef.current.findIndex((l) => l.id === layerId); - if (idx === -1) return; - sliderActiveRef.current = true; - - layersRef.current = layersRef.current.map((l, i) => - i === idx ? { ...l, rotationDegrees: degrees } : l - ); - // Apply directly to DOM - const el = layerRefs.current.get(layerId); - if (el) { - const layer = layersRef.current[idx]; - const parts: string[] = []; - if (degrees !== 0) parts.push(`rotate(${degrees}deg)`); - if (layer.mirrorHorizontal) parts.push('scaleX(-1)'); - if (layer.mirrorVertical) parts.push('scaleY(-1)'); - el.style.transform = parts.length > 0 ? parts.join(' ') : ''; - } - // Send to server (throttled) - throttledConfigChange?.(layersRef.current); + setLayers((prev) => { + const next = prev.map((l) => (l.id === layerId ? { ...l, rotationDegrees: degrees } : l)); + throttledConfigChange?.(next); + return next; + }); }, - [layersRef, layerRefs, sliderActiveRef, throttledConfigChange] + [setLayers, throttledConfigChange] ); - /** - * Sync the ref-based layer state back to React state. - * Call this on pointer-up (onValueCommit) after a series of - * zero-render opacity/rotation updates. - */ - const commitLayerAppearance = useCallback(() => { - sliderActiveRef.current = false; - setLayers([...layersRef.current]); - }, [setLayers, layersRef, sliderActiveRef]); - const updateLayerPositionSize = useCallback( (layerId: string, patch: { x?: number; y?: number; width?: number; height?: number }) => { setLayers((prev) => { @@ -551,7 +509,6 @@ export function useCompositorOverlays(deps: OverlayDeps) { selectLayer, updateLayerOpacity, updateLayerRotation, - commitLayerAppearance, updateLayerPositionSize, updateLayerZIndex, toggleLayerVisibility, diff --git a/ui/src/hooks/compositorServerSync.ts b/ui/src/hooks/compositorServerSync.ts index ee864461..ec136098 100644 --- a/ui/src/hooks/compositorServerSync.ts +++ b/ui/src/hooks/compositorServerSync.ts @@ -154,7 +154,6 @@ export function useServerLayoutSync( sessionId: string | undefined, nodeId: string, dragStateRef: React.MutableRefObject, - sliderActiveRef: React.MutableRefObject, setLayers: React.Dispatch>, setTextOverlays: React.Dispatch>, setImageOverlays: React.Dispatch> @@ -164,9 +163,9 @@ export function useServerLayoutSync( const applyServerLayout = (viewData: unknown) => { if (!viewData || typeof viewData !== 'object') return; - // Skip during drag/resize or zero-render slider updates to avoid - // server echo-backs overwriting in-flight local state - if (dragStateRef.current || sliderActiveRef.current) return; + // Skip during drag/resize to avoid server echo-backs overwriting + // in-flight local state + if (dragStateRef.current) return; const layout = viewData as CompositorLayout; if (!Array.isArray(layout.layers)) return; @@ -199,13 +198,5 @@ export function useServerLayoutSync( } }); return unsubscribe; - }, [ - sessionId, - nodeId, - dragStateRef, - sliderActiveRef, - setLayers, - setTextOverlays, - setImageOverlays, - ]); + }, [sessionId, nodeId, dragStateRef, setLayers, setTextOverlays, setImageOverlays]); } diff --git a/ui/src/hooks/useCompositorLayers.render-perf.test.ts b/ui/src/hooks/useCompositorLayers.render-perf.test.ts index e4346bd6..a1976bfc 100644 --- a/ui/src/hooks/useCompositorLayers.render-perf.test.ts +++ b/ui/src/hooks/useCompositorLayers.render-perf.test.ts @@ -68,9 +68,10 @@ function defaultOptions( /** * Maximum renders allowed for 20 rapid slider ticks. * - * Expected: 1 (mount) + 1 (selectLayer) = 2. - * Opacity/rotation updates use the zero-render path (ref + DOM only, - * no setLayers), so the hook doesn't re-render during the drag. + * With Jotai-backed state, opacity/rotation updates go through React state + * (setLayers) rather than the old zero-render DOM path. Per-node atom + * scoping keeps re-renders limited to the compositor instance. + * Expected: 1 (mount) + 1 (sync effect) + 1 (selectLayer) + 20 (slider ticks) = 23. * Budget gives headroom for React batching variance. */ const SLIDER_BUDGET = 30; @@ -203,11 +204,15 @@ describe('useCompositorLayers render-performance', () => { // eslint-disable-next-line no-console console.log('\n' + report + '\n'); - // Fail if any scenario regressed compared to baseline + // Fail if any non-slider scenario regressed compared to baseline. + // Slider scenarios (opacity, rotation, mixed) have higher render counts + // after the Jotai migration removed the zero-render DOM hack — this is + // intentional and the individual budget assertions above still pass. const regressions = comparisons.filter((c) => c.status === 'slower'); + const unexpectedRegressions = regressions.filter((c) => !c.name.includes('slider')); expect( - regressions, - `Regressions detected:\n${regressions.map((r) => r.name).join(', ')}` + unexpectedRegressions, + `Unexpected regressions:\n${unexpectedRegressions.map((r) => r.name).join(', ')}` ).toHaveLength(0); // Only overwrite the baseline when running via `just perf-ui` (sets diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index 22983c10..9d50f459 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -3,24 +3,27 @@ // SPDX-License-Identifier: MPL-2.0 /** - * Hook for managing compositor layer state with zero-render drag/resize. + * Hook for managing compositor layer state. * - * During pointer-driven interactions (drag, resize), visual updates are - * applied directly to DOM elements via refs and requestAnimationFrame. - * React state is only committed on pointer-up (or throttled for live mode), - * keeping the experience butter-smooth with no mid-drag re-renders. - * - * Heavy subsystems are extracted into companion modules: - * - compositorCommit – commit adapter, throttled persistence - * - compositorServerSync – server-driven layout subscription - * - compositorOverlays – overlay CRUD, layer property updates, reorder - * - compositorDragResize – pointer drag / resize handlers - * - compositorLayerParsers – parsing, serialisation, pure helpers + * Drag/resize still uses the zero-render ref + requestAnimationFrame path for + * pointer-move performance, but layer appearance updates (opacity / rotation) + * now go through normal React state. Jotai keeps that state scoped per + * compositor node instance and removes the need for the old sliderActiveRef / + * direct-DOM mutation hack. */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useAtom } from 'jotai'; +import { useEffect, useCallback, useRef } from 'react'; import { PARAM_THROTTLE_MS } from '@/constants/timing'; +import { + cleanupCompositorAtoms, + compositorImageOverlaysAtom, + compositorIsDraggingAtom, + compositorLayersAtom, + compositorSelectedLayerAtom, + compositorTextOverlaysAtom, +} from '@/stores/compositorAtoms'; import { useCompositorCommit } from './compositorCommit'; import type { LayerKind } from './compositorConstants'; @@ -35,19 +38,19 @@ import { parseTextOverlays, } from './compositorLayerParsers'; import type { - LayerState, - TextOverlayState, ImageOverlayState, + LayerState, ResizeHandle, + TextOverlayState, } from './compositorLayerParsers'; import { useCompositorOverlays } from './compositorOverlays'; import { useServerLayoutSync } from './compositorServerSync'; export type { - LayerState, - TextOverlayState, ImageOverlayState, + LayerState, ResizeHandle, + TextOverlayState, } from './compositorLayerParsers'; export type { LayerKind } from './compositorConstants'; @@ -70,33 +73,24 @@ export interface UseCompositorLayersResult { handleResizePointerDown: (layerId: string, handle: ResizeHandle, e: React.PointerEvent) => void; updateLayerOpacity: (layerId: string, opacity: number) => void; updateLayerRotation: (layerId: string, degrees: number) => void; - /** Sync ref-based appearance state to React state. Call on slider pointer-up. */ - commitLayerAppearance: () => void; updateLayerPositionSize: ( layerId: string, patch: { x?: number; y?: number; width?: number; height?: number } ) => void; updateLayerZIndex: (layerId: string, zIndex: number) => void; toggleLayerVisibility: (layerId: string) => void; - /** Toggle horizontal or vertical mirroring for a layer (video, text, or image). */ updateLayerMirror: (layerId: string, axis: 'horizontal' | 'vertical') => void; - /** Update crop/zoom on a video layer. */ updateLayerCropZoom: ( layerId: string, patch: { cropX?: number; cropY?: number; cropZoom?: number } ) => void; - /** Ref map: layer elements register here for direct DOM manipulation during drag */ layerRefs: React.MutableRefObject>; - /** Refs to the snap guide line DOM elements for direct show/hide during drag */ snapGuideRefs: React.MutableRefObject<{ vertical: HTMLDivElement | null; horizontal: HTMLDivElement | null; }>; - /** Whether a drag/resize is currently in progress */ isDragging: boolean; - /** Text overlays */ textOverlays: TextOverlayState[]; - /** Image overlays */ imageOverlays: ImageOverlayState[]; addTextOverlay: (text: string) => void; updateTextOverlay: (id: string, updates: Partial>) => void; @@ -104,10 +98,7 @@ 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; - /** Pre-assembled deps bag for useCompositorKeyboard. */ keyboardDeps: CompositorKeyboardDeps; } @@ -125,27 +116,32 @@ export const useCompositorLayers = ( throttleMs = PARAM_THROTTLE_MS, } = options; - const [layers, setLayers] = useState(() => - parseLayers(params, canvasWidth, canvasHeight) - ); - const [textOverlays, setTextOverlays] = useState(() => - parseTextOverlays(params) - ); - const [imageOverlays, setImageOverlays] = useState(() => - parseImageOverlays(params) - ); - const [selectedLayerId, setSelectedLayerId] = useState(null); - const [isDragging, setIsDragging] = useState(false); + const [layers, setLayers] = useAtom(compositorLayersAtom(nodeId)); + const [textOverlays, setTextOverlays] = useAtom(compositorTextOverlaysAtom(nodeId)); + const [imageOverlays, setImageOverlays] = useAtom(compositorImageOverlaysAtom(nodeId)); + const [selectedLayerId, setSelectedLayerId] = useAtom(compositorSelectedLayerAtom(nodeId)); + const [isDragging, setIsDragging] = useAtom(compositorIsDraggingAtom(nodeId)); + + const layerRefs = useRef>(new Map()); + const snapGuideRefs = useRef<{ + vertical: HTMLDivElement | null; + horizontal: HTMLDivElement | null; + }>({ vertical: null, horizontal: null }); + const dragStateRef = useRef(null); // Stable refs — let throttled / memoised callbacks read latest values - // at call-time without triggering cascading dependency changes. + // at call-time without triggering dependency churn. const paramsRef = useRef(params); + const layersRef = useRef(layers); + const textOverlaysRef = useRef(textOverlays); + const imageOverlaysRef = useRef(imageOverlays); + useEffect(() => { paramsRef.current = params; }, [params]); - - const textOverlaysRef = useRef(textOverlays); - const imageOverlaysRef = useRef(imageOverlays); + useEffect(() => { + layersRef.current = layers; + }, [layers]); useEffect(() => { textOverlaysRef.current = textOverlays; }, [textOverlays]); @@ -153,83 +149,70 @@ export const useCompositorLayers = ( imageOverlaysRef.current = imageOverlays; }, [imageOverlays]); - const layerRefs = useRef>(new Map()); - const sliderActiveRef = useRef(false); - const snapGuideRefs = useRef<{ - vertical: HTMLDivElement | null; - horizontal: HTMLDivElement | null; - }>({ vertical: null, horizontal: null }); - const dragStateRef = useRef(null); - const layersRef = useRef(layers); - - // Safety net: clear sliderActiveRef when the selected layer changes or on - // unmount. If a slider component unmounts mid-drag (layer deselected, - // removed, or Escape pressed), onValueCommit never fires and the ref - // would remain stuck at true, permanently blocking server/prop sync. + // Clean up atom family entries when this compositor node unmounts or + // nodeId changes, so stale atoms don't accumulate. useEffect(() => { - if (sliderActiveRef.current) { - sliderActiveRef.current = false; - setLayers([...layersRef.current]); - } - }, [selectedLayerId, setLayers]); - - useEffect(() => { - // During zero-render slider drags, layersRef is the source of truth - // (updated directly by updateLayerOpacity/updateLayerRotation). - // Don't overwrite it with stale React state from concurrent operations. - if (!sliderActiveRef.current) { - layersRef.current = layers; - } - }, [layers]); + return () => { + cleanupCompositorAtoms(nodeId); + }; + }, [nodeId]); // ── Sync from props ───────────────────────────────────────────────────── // In Monitor view (sessionId is set), the server's view data is the source - // of truth for geometry (x, y, width, height). The "sync from props" effect - // must NOT overwrite server-resolved positions with config-parsed ones, - // otherwise a params echo-back from the server would clobber the accurate - // layout that useServerLayoutSync applied. + // of truth for geometry (x, y, width, height). The sync-from-props effect + // must NOT overwrite server-resolved positions with config-parsed ones. const isMonitorView = !!sessionId; useEffect(() => { - if (dragStateRef.current || sliderActiveRef.current) return; - const parsed = parseLayers(params, canvasWidth, canvasHeight); + if (dragStateRef.current) return; - const merged = mergeOverlayState( + const parsedLayers = parseLayers(params, canvasWidth, canvasHeight); + const mergedLayers = mergeOverlayState( layersRef.current, - parsed, + parsedLayers, (a, b) => a.cropZoom !== b.cropZoom || a.cropX !== b.cropX || a.cropY !== b.cropY, isMonitorView ); - if (merged !== layersRef.current) setLayers(merged); + if (mergedLayers !== layersRef.current) { + setLayers(mergedLayers); + } - setTextOverlays((cur) => + setTextOverlays((current) => mergeOverlayState( - cur, + current, parseTextOverlays(params), (a, b) => a.text !== b.text || a.fontSize !== b.fontSize || a.fontName !== b.fontName || - a.color.some((v, i) => v !== b.color[i]), + a.color.some((value, index) => value !== b.color[index]), isMonitorView ) ); - setImageOverlays((cur) => + + setImageOverlays((current) => mergeOverlayState( - cur, + current, parseImageOverlays(params), (a, b) => a.dataBase64 !== b.dataBase64, isMonitorView ) ); - }, [params, canvasWidth, canvasHeight, isMonitorView]); + }, [ + params, + canvasWidth, + canvasHeight, + isMonitorView, + setLayers, + setTextOverlays, + setImageOverlays, + ]); // ── Server-driven layout (Monitor view only) ─────────────────────────── useServerLayoutSync( sessionId, nodeId, dragStateRef, - sliderActiveRef, setLayers, setTextOverlays, setImageOverlays @@ -238,36 +221,43 @@ export const useCompositorLayers = ( // ── Find layer across all types ───────────────────────────────────────── const findAnyLayer = useCallback( (layerId: string): { state: LayerState; kind: LayerKind } | null => { - const v = layersRef.current.find((l) => l.id === layerId); - if (v) return { state: v, kind: 'video' }; - const t = textOverlaysRef.current.find((o) => o.id === layerId); - if (t) + const videoLayer = layersRef.current.find((layer) => layer.id === layerId); + if (videoLayer) { + return { state: videoLayer, kind: 'video' }; + } + + const textOverlay = textOverlaysRef.current.find((overlay) => overlay.id === layerId); + if (textOverlay) { return { state: { - ...t, + ...textOverlay, cropZoom: DEFAULT_CROP_ZOOM, cropX: DEFAULT_CROP_X, cropY: DEFAULT_CROP_Y, }, kind: 'text', }; - const img = imageOverlaysRef.current.find((o) => o.id === layerId); - if (img) + } + + const imageOverlay = imageOverlaysRef.current.find((overlay) => overlay.id === layerId); + if (imageOverlay) { return { state: { - ...img, + ...imageOverlay, cropZoom: DEFAULT_CROP_ZOOM, cropX: DEFAULT_CROP_X, cropY: DEFAULT_CROP_Y, }, kind: 'image', }; + } + return null; }, [] ); - // ── Commit / persistence ─────────────────────────────────────────────────── + // ── Commit / persistence ──────────────────────────────────────────────── const { commitAdapter, throttledConfigChange, throttledOverlayCommit } = useCompositorCommit({ nodeId, onConfigChange, @@ -289,8 +279,6 @@ export const useCompositorLayers = ( layersRef, textOverlaysRef, imageOverlaysRef, - layerRefs, - sliderActiveRef, throttledConfigChange, throttledOverlayCommit, }); @@ -323,7 +311,6 @@ export const useCompositorLayers = ( handleResizePointerDown, updateLayerOpacity: overlayOps.updateLayerOpacity, updateLayerRotation: overlayOps.updateLayerRotation, - commitLayerAppearance: overlayOps.commitLayerAppearance, updateLayerPositionSize: overlayOps.updateLayerPositionSize, updateLayerZIndex: overlayOps.updateLayerZIndex, toggleLayerVisibility: overlayOps.toggleLayerVisibility, diff --git a/ui/src/hooks/useDragLocalValue.ts b/ui/src/hooks/useDragLocalValue.ts deleted file mode 100644 index 84cb0918..00000000 --- a/ui/src/hooks/useDragLocalValue.ts +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 StreamKit Contributors -// -// SPDX-License-Identifier: MPL-2.0 - -/** - * Local slider state that syncs from a prop value when not actively dragging. - * - * Used by compositor controls (opacity, rotation) that use the zero-render - * update path: the parent updates refs + DOM directly during drags, so React - * state (and thus the prop) stays stale. This hook maintains a local value - * that the slider can use as its controlled `value`, while still syncing - * from the prop when the drag ends. - */ - -import { useState, useEffect, useRef } from 'react'; - -export function useDragLocalValue(propValue: number) { - const [localValue, setLocalValue] = useState(propValue); - const draggingRef = useRef(false); - useEffect(() => { - if (!draggingRef.current) setLocalValue(propValue); - }, [propValue]); - return { localValue, setLocalValue, draggingRef }; -} diff --git a/ui/src/nodes/CompositorNode.tsx b/ui/src/nodes/CompositorNode.tsx index a9a6a844..d4d40b07 100644 --- a/ui/src/nodes/CompositorNode.tsx +++ b/ui/src/nodes/CompositorNode.tsx @@ -80,7 +80,6 @@ const CompositorNode: React.FC = React.memo(({ id, data, se handleResizePointerDown, updateLayerOpacity, updateLayerRotation, - commitLayerAppearance, toggleLayerVisibility, updateLayerMirror, updateLayerCropZoom, @@ -173,28 +172,9 @@ const CompositorNode: React.FC = React.memo(({ id, data, se const showLiveIndicator = !!data.onConfigChange && !!data.sessionId; // Selected layer data for property controls. - // Memoize by value (not by reference) so that appearance-only changes - // (opacity, rotation) on the selected layer produce a new object, but - // changes to *other* layers don't invalidate these references. - const selectedLayer = useMemo( - () => layers.find((l) => l.id === selectedLayerId), - // layers array reference changes on every update, but we only care - // about the specific layer matching selectedLayerId. JSON key the - // selected layer's fields so the memo only recomputes when the - // selected layer's data actually changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedLayerId, layers.find((l) => l.id === selectedLayerId)] - ); - const selectedTextOverlay = useMemo( - () => textOverlays.find((o) => o.id === selectedLayerId), - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedLayerId, textOverlays.find((o) => o.id === selectedLayerId)] - ); - const selectedImageOverlay = useMemo( - () => imageOverlays.find((o) => o.id === selectedLayerId), - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedLayerId, imageOverlays.find((o) => o.id === selectedLayerId)] - ); + const selectedLayer = layers.find((l) => l.id === selectedLayerId); + const selectedTextOverlay = textOverlays.find((o) => o.id === selectedLayerId); + const selectedImageOverlay = imageOverlays.find((o) => o.id === selectedLayerId); // Determine the kind of the selected layer once const selectedLayerKind = useMemo(() => { @@ -300,7 +280,6 @@ const CompositorNode: React.FC = React.memo(({ id, data, se handleSelectedMirrorToggle={handleSelectedMirrorToggle} handleSelectedPositionSizeChange={handleSelectedPositionSizeChange} handleSelectedCropZoomChange={handleSelectedCropZoomChange} - onAppearanceCommit={commitLayerAppearance} dimensionsReadOnly={selectedLayerKind === 'text'} disabled={disabled} /> @@ -327,7 +306,6 @@ const CompositorNode: React.FC = React.memo(({ id, data, se handleSelectedMirrorToggle, handleSelectedPositionSizeChange, handleSelectedCropZoomChange, - commitLayerAppearance, ] ); diff --git a/ui/src/nodes/compositorNodeInspector.tsx b/ui/src/nodes/compositorNodeInspector.tsx index c383bac3..dfad17e8 100644 --- a/ui/src/nodes/compositorNodeInspector.tsx +++ b/ui/src/nodes/compositorNodeInspector.tsx @@ -347,8 +347,6 @@ export interface CompositorInspectorProps { handleSelectedMirrorToggle: (axis: 'horizontal' | 'vertical') => void; handleSelectedPositionSizeChange: (patch: PositionSizePatch) => void; handleSelectedCropZoomChange: (patch: CropZoomPatch) => void; - /** Called on slider pointer-up to sync ref-based appearance to React state. */ - onAppearanceCommit?: () => void; dimensionsReadOnly?: boolean; disabled: boolean; } @@ -365,7 +363,6 @@ export const CompositorInspector: React.FC = React.mem handleSelectedMirrorToggle, handleSelectedPositionSizeChange, handleSelectedCropZoomChange, - onAppearanceCommit, dimensionsReadOnly, disabled, }) => { @@ -388,13 +385,11 @@ export const CompositorInspector: React.FC = React.mem void; - onCommit?: () => void; disabled: boolean; -}> = React.memo(({ opacity, onChange, onCommit, disabled }) => { - const { localValue, setLocalValue, draggingRef } = useDragLocalValue(opacity); - - return ( - - Opacity - - { - draggingRef.current = true; - setLocalValue(v); - onChange(v); - }} - onValueCommit={() => { - draggingRef.current = false; - onCommit?.(); - }} - min={0} - max={1} - step={0.01} - disabled={disabled} - className="nodrag nopan" - > - - - - - - {(localValue * 100).toFixed(0)}% - - - ); -}); +}> = React.memo(({ opacity, onChange, disabled }) => ( + + Opacity + + onChange(v)} + min={0} + max={1} + step={0.01} + disabled={disabled} + className="nodrag nopan" + > + + + + + + {(opacity * 100).toFixed(0)}% + + +)); OpacityControl.displayName = 'OpacityControl'; export const RotationControl: React.FC<{ rotationDegrees: number; onChange: (value: number) => void; - onCommit?: () => void; disabled: boolean; -}> = React.memo(({ rotationDegrees, onChange, onCommit, disabled }) => { - const { localValue, setLocalValue, draggingRef } = useDragLocalValue(rotationDegrees); - const normalisedRotation = ((Math.round(localValue) % 360) + 360) % 360; +}> = React.memo(({ rotationDegrees, onChange, disabled }) => { + const normalisedRotation = ((Math.round(rotationDegrees) % 360) + 360) % 360; return ( @@ -254,25 +238,13 @@ export const RotationControl: React.FC<{ key={deg} isActive={normalisedRotation === deg} disabled={disabled} - onClick={() => { - setLocalValue(deg); - onChange(deg); - onCommit?.(); - }} + onClick={() => onChange(deg)} > {deg}° ))} - { - setLocalValue(0); - onChange(0); - onCommit?.(); - }} - className="nodrag nopan" - > + onChange(0)} className="nodrag nopan"> @@ -280,15 +252,7 @@ export const RotationControl: React.FC<{ { - draggingRef.current = true; - setLocalValue(v); - onChange(v); - }} - onValueCommit={() => { - draggingRef.current = false; - onCommit?.(); - }} + onValueChange={([v]) => onChange(v)} min={0} max={359} step={1} diff --git a/ui/src/stores/compositorAtoms.ts b/ui/src/stores/compositorAtoms.ts new file mode 100644 index 00000000..f10107b0 --- /dev/null +++ b/ui/src/stores/compositorAtoms.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +/** + * Jotai atoms for compositor layer state. + * + * Each compositor node instance (keyed by nodeId) owns its own set of + * layer / text-overlay / image-overlay atoms. Individual layer properties + * are stored per-layer so that an opacity slider drag only re-renders the + * slider component — not the entire compositor tree. + * + * The atom families are organised as: + * + * compositorLayersAtom(nodeId) → LayerState[] + * compositorTextOverlaysAtom(nodeId) → TextOverlayState[] + * compositorImageOverlaysAtom(nodeId)→ ImageOverlayState[] + * compositorSelectedLayerAtom(nodeId)→ string | null + * compositorIsDraggingAtom(nodeId) → boolean + * + * Derived atoms for individual fields can be added later; for Phase 1 we + * keep per-node granularity which already eliminates the zero-render DOM + * hack and the custom memo comparators. + */ + +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +import type { + LayerState, + TextOverlayState, + ImageOverlayState, +} from '@/hooks/compositorLayerParsers'; + +// ── Per-node atom families ────────────────────────────────────────────────── + +/** Video layers for a given compositor node. */ +export const compositorLayersAtom = atomFamily((_nodeId: string) => { + void _nodeId; + return atom([]); +}); + +/** Text overlays for a given compositor node. */ +export const compositorTextOverlaysAtom = atomFamily((_nodeId: string) => { + void _nodeId; + return atom([]); +}); + +/** Image overlays for a given compositor node. */ +export const compositorImageOverlaysAtom = atomFamily((_nodeId: string) => { + void _nodeId; + return atom([]); +}); + +/** Currently selected layer ID within a compositor node. */ +export const compositorSelectedLayerAtom = atomFamily((_nodeId: string) => { + void _nodeId; + return atom(null); +}); + +/** Whether a drag/resize is in progress for a compositor node. */ +export const compositorIsDraggingAtom = atomFamily((_nodeId: string) => { + void _nodeId; + return atom(false); +}); + +// ── Cleanup ───────────────────────────────────────────────────────────────── + +/** Remove all atoms for a compositor node. Call on unmount. */ +export function cleanupCompositorAtoms(nodeId: string): void { + compositorLayersAtom.remove(nodeId); + compositorTextOverlaysAtom.remove(nodeId); + compositorImageOverlaysAtom.remove(nodeId); + compositorSelectedLayerAtom.remove(nodeId); + compositorIsDraggingAtom.remove(nodeId); +} diff --git a/ui/src/stores/jotaiStore.ts b/ui/src/stores/jotaiStore.ts new file mode 100644 index 00000000..fea8dcd5 --- /dev/null +++ b/ui/src/stores/jotaiStore.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +/** + * Vanilla Jotai store for non-React contexts (WebSocket service, etc.). + * + * Components should use the default provider-less mode (useAtom / useAtomValue + * / useSetAtom) which shares this same store under the hood. + * Non-React code (e.g. WebSocketService) can use `jotaiStore.get(atom)` and + * `jotaiStore.set(atom, value)` directly. + */ + +import { createStore } from 'jotai'; + +export const jotaiStore = createStore(); From dc5edbf5ce102e62389fc0479d9e3b3369984311 Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 17 Mar 2026 22:12:08 +0000 Subject: [PATCH 2/4] fix(ui): use useLayoutEffect for sync-from-props, remove unused jotai-zustand - Change sync-from-props effect from useEffect to useLayoutEffect so Jotai atoms are populated before the first paint, preventing a flash of empty state ('No layers configured') - Remove unused jotai-zustand dependency (flagged by Knip CI check); it can be added when Phase 2 bridge needs it - Add regression test: 'layers are populated synchronously on initial render (no empty-state flash)' Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/bun.lock | 3 --- ui/package.json | 1 - .../useCompositorLayers.monitor-flow.test.ts | 18 ++++++++++++++++++ ui/src/hooks/useCompositorLayers.ts | 6 ++++-- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/ui/bun.lock b/ui/bun.lock index 313b80ad..834559fd 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -30,7 +30,6 @@ "@xyflow/react": "^12.10.1", "codemirror": "^6.0.2", "jotai": "^2.18.1", - "jotai-zustand": "^0.6.0", "js-yaml": "^4.1.1", "lodash-es": "^4.17.23", "lucide-react": "^0.577.0", @@ -1042,8 +1041,6 @@ "jotai": ["jotai@2.18.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA=="], - "jotai-zustand": ["jotai-zustand@0.6.0", "", { "peerDependencies": { "jotai": ">=2.0.0" } }, "sha512-82LVFVZZXWsqpcfvDf4Yk6Gaq2RFezU0USfAA3O9WrxqflZNGlh0BkpUGzzYpdmDuDJIcYU7Rr1582UgI4DAiw=="], - "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], diff --git a/ui/package.json b/ui/package.json index 7c8583a1..90772b75 100644 --- a/ui/package.json +++ b/ui/package.json @@ -79,7 +79,6 @@ "@xyflow/react": "^12.10.1", "codemirror": "^6.0.2", "jotai": "^2.18.1", - "jotai-zustand": "^0.6.0", "js-yaml": "^4.1.1", "lodash-es": "^4.17.23", "lucide-react": "^0.577.0", diff --git a/ui/src/hooks/useCompositorLayers.monitor-flow.test.ts b/ui/src/hooks/useCompositorLayers.monitor-flow.test.ts index 40741fe5..985a412c 100644 --- a/ui/src/hooks/useCompositorLayers.monitor-flow.test.ts +++ b/ui/src/hooks/useCompositorLayers.monitor-flow.test.ts @@ -138,6 +138,24 @@ afterEach(() => { // ── Tests ─────────────────────────────────────────────────────────────────── describe('Monitor view data flow integration', () => { + it('layers are populated synchronously on initial render (no empty-state flash)', () => { + seedStore(); + const opts = monitorOptions(); + // renderHook returns the first committed result — layers must be parsed + // from params immediately (useLayoutEffect), not deferred to a post-paint + // useEffect. If this assertion fails, the sync-from-props effect was + // changed back to useEffect and the compositor will flash "No layers". + const { result } = renderHook( + (props: UseCompositorLayersOptions) => useCompositorLayers(props), + { initialProps: opts } + ); + + expect(result.current.layers).toHaveLength(1); + expect(result.current.layers[0].id).toBe('in_0'); + expect(result.current.textOverlays).toHaveLength(0); + expect(result.current.imageOverlays).toHaveLength(0); + }); + it('server-resolved layer positions survive a params echo-back', () => { seedStore(); diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index 9d50f459..d1fa9452 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -13,7 +13,7 @@ */ import { useAtom } from 'jotai'; -import { useEffect, useCallback, useRef } from 'react'; +import { useEffect, useLayoutEffect, useCallback, useRef } from 'react'; import { PARAM_THROTTLE_MS } from '@/constants/timing'; import { @@ -163,7 +163,9 @@ export const useCompositorLayers = ( // must NOT overwrite server-resolved positions with config-parsed ones. const isMonitorView = !!sessionId; - useEffect(() => { + // useLayoutEffect so atoms are populated before the first paint, avoiding a + // flash of empty state (atoms default to []). + useLayoutEffect(() => { if (dragStateRef.current) return; const parsedLayers = parseLayers(params, canvasWidth, canvasHeight); From e2900240c02251efe51f701f9c764c99d0b0a04b Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Tue, 17 Mar 2026 22:23:29 +0000 Subject: [PATCH 3/4] fix(ui): use functional update for setLayers in sync-from-props The useLayoutEffect runs before the useEffect that syncs layersRef, so reading layersRef.current gives a stale value. Switch to a functional update (current) => mergeOverlayState(current, ...) to read the actual current atom value, consistent with how setTextOverlays and setImageOverlays already work. Signed-off-by: Devin AI Signed-off-by: StreamKit Devin Co-Authored-By: Claudio Costa --- ui/src/hooks/useCompositorLayers.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index d1fa9452..46bbf759 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -169,15 +169,17 @@ export const useCompositorLayers = ( if (dragStateRef.current) return; const parsedLayers = parseLayers(params, canvasWidth, canvasHeight); - const mergedLayers = mergeOverlayState( - layersRef.current, - parsedLayers, - (a, b) => a.cropZoom !== b.cropZoom || a.cropX !== b.cropX || a.cropY !== b.cropY, - isMonitorView + // Functional update so we read the actual current atom value, not the + // potentially stale layersRef (which is synced via useEffect, running + // after this useLayoutEffect). + setLayers((current) => + mergeOverlayState( + current, + parsedLayers, + (a, b) => a.cropZoom !== b.cropZoom || a.cropX !== b.cropX || a.cropY !== b.cropY, + isMonitorView + ) ); - if (mergedLayers !== layersRef.current) { - setLayers(mergedLayers); - } setTextOverlays((current) => mergeOverlayState( From 8e0fc3c08ddae2102cce8ab1ee92dcbf47bb6d4d Mon Sep 17 00:00:00 2001 From: StreamKit Devin Date: Wed, 18 Mar 2026 07:42:40 +0000 Subject: [PATCH 4/4] fix: per-layer atoms for render isolation + echo-back guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opacity/rotation slider drags were re-rendering unrelated components because the per-node compositorLayersAtom was updated on every tick, causing CompositorNode and its entire subtree to cascade. Root cause: updateLayerOpacity/updateLayerRotation called setLayers() which replaced the layers array, triggering hooks-changed in every subscriber. Fix: - Add per-layer appearance atoms (compositorLayerOpacityAtom, compositorLayerRotationAtom) keyed by nodeId:layerId - updateLayerOpacity/updateLayerRotation now write ONLY to per-layer atoms + mutate layersRef + throttledConfigChange — no setLayers() - OpacityControl, RotationControl, and VideoLayer subscribe to per-layer atoms via jotaiStore.sub() + forceRender pattern - compositorLayersAtom is NOT updated during slider drags, so CompositorNode doesn't re-render and no cascade occurs Echo-back guard: - Add appearanceActiveRef (200ms debounced) to block server sync from overwriting in-flight slider values - compositorServerSync checks both dragStateRef and appearanceActiveRef before applying server layout updates Tests: - compositorAtoms.test.ts: per-layer atom isolation (6 assertions) - monitor-flow: echo-back blocked during active slider drag Signed-off-by: Devin AI Co-Authored-By: Claudio Costa --- ui/src/components/CompositorCanvas.tsx | 3 + ui/src/components/compositorCanvasLayers.tsx | 29 +++- ui/src/hooks/compositorOverlays.ts | 72 ++++++-- ui/src/hooks/compositorServerSync.ts | 36 +++- .../useCompositorLayers.monitor-flow.test.ts | 76 +++++++++ ui/src/hooks/useCompositorLayers.ts | 60 ++++--- ui/src/nodes/CompositorNode.tsx | 6 + ui/src/nodes/compositorNodeInspector.tsx | 12 +- ui/src/nodes/compositorNodeWidgets.tsx | 76 ++++++--- ui/src/stores/compositorAtoms.test.ts | 158 ++++++++++++++++++ ui/src/stores/compositorAtoms.ts | 91 ++++++++-- 11 files changed, 532 insertions(+), 87 deletions(-) create mode 100644 ui/src/stores/compositorAtoms.test.ts diff --git a/ui/src/components/CompositorCanvas.tsx b/ui/src/components/CompositorCanvas.tsx index 58c3bd50..0c743eb3 100644 --- a/ui/src/components/CompositorCanvas.tsx +++ b/ui/src/components/CompositorCanvas.tsx @@ -42,6 +42,7 @@ const noopResizeStart = (() => {}) as ( // ── Main canvas ───────────────────────────────────────────────────────────── export interface CompositorCanvasProps { + nodeId: string; canvasWidth: number; canvasHeight: number; layers: LayerState[]; @@ -63,6 +64,7 @@ export interface CompositorCanvasProps { export const CompositorCanvas: React.FC = React.memo( ({ + nodeId, canvasWidth, canvasHeight, layers, @@ -187,6 +189,7 @@ export const CompositorCanvas: React.FC = React.memo( {layers.map((layer, i) => ( void; onResizeStart: (layerId: string, handle: ResizeHandle, e: React.PointerEvent) => void; layerRef: (el: HTMLDivElement | null) => void; -}> = React.memo(({ layer, index, isSelected, onPointerDown, onResizeStart, layerRef }) => { +}> = React.memo(({ nodeId, layer, index, isSelected, onPointerDown, onResizeStart, layerRef }) => { + // Read opacity/rotation from per-layer atoms so this component + // re-renders only when THIS layer's appearance changes, not when + // any layer in the array changes. + const atomKey = `${nodeId}:${layer.id}`; + const opacity = jotaiStore.get(compositorLayerOpacityAtom(atomKey)); + const rotationDegrees = jotaiStore.get(compositorLayerRotationAtom(atomKey)); + const [, forceRender] = useState(0); + useEffect(() => { + const unsub1 = jotaiStore.sub(compositorLayerOpacityAtom(atomKey), () => { + forceRender((c) => c + 1); + }); + const unsub2 = jotaiStore.sub(compositorLayerRotationAtom(atomKey), () => { + forceRender((c) => c + 1); + }); + return () => { + unsub1(); + unsub2(); + }; + }, [atomKey]); + const handlePointerDown = useCallback( (e: React.PointerEvent) => { onPointerDown(layer.id, e); @@ -148,9 +171,9 @@ export const VideoLayer: React.FC<{ aria-label={`Video layer: ${layer.id}`} style={layerBoxStyle(layer.x, layer.y, layer.width, layer.height, { visible: layer.visible, - opacity: layer.opacity, + opacity, zIndex: layer.zIndex, - rotationDegrees: layer.rotationDegrees, + rotationDegrees, mirrorHorizontal: layer.mirrorHorizontal, mirrorVertical: layer.mirrorVertical, borderColor, diff --git a/ui/src/hooks/compositorOverlays.ts b/ui/src/hooks/compositorOverlays.ts index fa220810..c886a681 100644 --- a/ui/src/hooks/compositorOverlays.ts +++ b/ui/src/hooks/compositorOverlays.ts @@ -10,6 +10,9 @@ import { useCallback, useEffect, useRef } from 'react'; +import { compositorLayerOpacityAtom, compositorLayerRotationAtom } from '@/stores/compositorAtoms'; +import { jotaiStore } from '@/stores/jotaiStore'; + import type { CommitAdapter } from './compositorCommit'; import { DEFAULT_OPACITY, @@ -33,7 +36,13 @@ import type { LayerState, TextOverlayState, ImageOverlayState } from './composit // ── Shared dependency bag ──────────────────────────────────────────────── +/** Delay (ms) before clearing the appearance-active guard after the + * last slider update. Must be long enough to cover one server + * round-trip so we don't apply the echo before the guard clears. */ +const APPEARANCE_GUARD_MS = 200; + export interface OverlayDeps { + nodeId: string; commitAdapter: CommitAdapter | null; setLayers: React.Dispatch>; setTextOverlays: React.Dispatch>; @@ -50,6 +59,7 @@ export interface OverlayDeps { export function useCompositorOverlays(deps: OverlayDeps) { const { + nodeId, commitAdapter, setLayers, setTextOverlays, @@ -62,6 +72,29 @@ export function useCompositorOverlays(deps: OverlayDeps) { throttledOverlayCommit, } = deps; + // ── Appearance-active guard ─────────────────────────────────────────── + // + // While the user is dragging an opacity / rotation slider, server + // echo-backs must not overwrite the in-flight value. This ref is set + // to `true` on every slider tick and cleared after a short debounce. + const appearanceActiveRef = useRef(false); + const appearanceTimerRef = useRef | null>(null); + + // Clean up the debounce timer on unmount. + useEffect(() => { + return () => { + if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current); + }; + }, []); + + const markAppearanceActive = useCallback(() => { + appearanceActiveRef.current = true; + if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current); + appearanceTimerRef.current = setTimeout(() => { + appearanceActiveRef.current = false; + }, APPEARANCE_GUARD_MS); + }, []); + // ── selectLayer ────────────────────────────────────────────────────── const selectLayer = useCallback( @@ -71,32 +104,38 @@ export function useCompositorOverlays(deps: OverlayDeps) { // ── Layer property updates ─────────────────────────────────────────── // - // With Jotai-backed state, opacity and rotation updates go through - // normal React state (setLayers). Per-node atom scoping keeps - // re-renders limited to the compositor instance, so the old - // zero-render DOM-mutation hack is no longer needed. + // Opacity and rotation writes go to per-layer Jotai atoms so that only + // OpacityControl / RotationControl and the affected VideoLayer + // re-render. The full layers-array atom is NOT updated during slider + // drags; instead we mutate the mutable ref and pass it to the + // throttled config-change callback for server persistence. const updateLayerOpacity = useCallback( (layerId: string, opacity: number) => { const clamped = Math.max(0, Math.min(1, opacity)); - setLayers((prev) => { - const next = prev.map((l) => (l.id === layerId ? { ...l, opacity: clamped } : l)); - throttledConfigChange?.(next); - return next; - }); + // 1. Per-layer atom → only subscribers re-render + jotaiStore.set(compositorLayerOpacityAtom(`${nodeId}:${layerId}`), clamped); + // 2. Mutable ref → throttledConfigChange reads the latest array + layersRef.current = layersRef.current.map((l) => + l.id === layerId ? { ...l, opacity: clamped } : l + ); + throttledConfigChange?.(layersRef.current); + // 3. Guard against server echo-backs + markAppearanceActive(); }, - [setLayers, throttledConfigChange] + [nodeId, layersRef, throttledConfigChange, markAppearanceActive] ); const updateLayerRotation = useCallback( (layerId: string, degrees: number) => { - setLayers((prev) => { - const next = prev.map((l) => (l.id === layerId ? { ...l, rotationDegrees: degrees } : l)); - throttledConfigChange?.(next); - return next; - }); + jotaiStore.set(compositorLayerRotationAtom(`${nodeId}:${layerId}`), degrees); + layersRef.current = layersRef.current.map((l) => + l.id === layerId ? { ...l, rotationDegrees: degrees } : l + ); + throttledConfigChange?.(layersRef.current); + markAppearanceActive(); }, - [setLayers, throttledConfigChange] + [nodeId, layersRef, throttledConfigChange, markAppearanceActive] ); const updateLayerPositionSize = useCallback( @@ -522,5 +561,6 @@ export function useCompositorOverlays(deps: OverlayDeps) { updateImageOverlay, removeImageOverlay, reorderLayers, + appearanceActiveRef, }; } diff --git a/ui/src/hooks/compositorServerSync.ts b/ui/src/hooks/compositorServerSync.ts index ec136098..3103601a 100644 --- a/ui/src/hooks/compositorServerSync.ts +++ b/ui/src/hooks/compositorServerSync.ts @@ -13,6 +13,7 @@ import { useEffect } from 'react'; +import { syncLayerAppearanceAtoms } from '@/stores/compositorAtoms'; import { useSessionStore, selectNodeViewData } from '@/stores/sessionStore'; import type { CompositorLayout, @@ -154,6 +155,7 @@ export function useServerLayoutSync( sessionId: string | undefined, nodeId: string, dragStateRef: React.MutableRefObject, + appearanceActiveRef: React.MutableRefObject, setLayers: React.Dispatch>, setTextOverlays: React.Dispatch>, setImageOverlays: React.Dispatch> @@ -163,24 +165,36 @@ export function useServerLayoutSync( const applyServerLayout = (viewData: unknown) => { if (!viewData || typeof viewData !== 'object') return; - // Skip during drag/resize to avoid server echo-backs overwriting - // in-flight local state - if (dragStateRef.current) return; + // Skip during drag/resize or appearance slider activity to avoid + // server echo-backs overwriting in-flight local state + if (dragStateRef.current || appearanceActiveRef.current) return; const layout = viewData as CompositorLayout; if (!Array.isArray(layout.layers)) return; - setLayers((prev) => mapServerLayers(prev, layout.layers)); + setLayers((prev) => { + const next = mapServerLayers(prev, layout.layers); + // Sync per-layer appearance atoms from server data so the + // inspector controls and canvas layers reflect resolved values. + syncLayerAppearanceAtoms(nodeId, next); + return next; + }); if (Array.isArray(layout.text_overlays)) { setTextOverlays((prev) => { const base = applyServerOverlays(prev, layout.text_overlays); - return mergeTextMeasurements(base, layout.text_overlays); + const merged = mergeTextMeasurements(base, layout.text_overlays); + syncLayerAppearanceAtoms(nodeId, merged); + return merged; }); } if (Array.isArray(layout.image_overlays)) { - setImageOverlays((prev) => applyServerOverlays(prev, layout.image_overlays)); + setImageOverlays((prev) => { + const next = applyServerOverlays(prev, layout.image_overlays); + syncLayerAppearanceAtoms(nodeId, next); + return next; + }); } }; @@ -198,5 +212,13 @@ export function useServerLayoutSync( } }); return unsubscribe; - }, [sessionId, nodeId, dragStateRef, setLayers, setTextOverlays, setImageOverlays]); + }, [ + sessionId, + nodeId, + dragStateRef, + appearanceActiveRef, + setLayers, + setTextOverlays, + setImageOverlays, + ]); } diff --git a/ui/src/hooks/useCompositorLayers.monitor-flow.test.ts b/ui/src/hooks/useCompositorLayers.monitor-flow.test.ts index 985a412c..0a6903d7 100644 --- a/ui/src/hooks/useCompositorLayers.monitor-flow.test.ts +++ b/ui/src/hooks/useCompositorLayers.monitor-flow.test.ts @@ -22,6 +22,8 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi, afterEach } from 'vitest'; +import { compositorLayerOpacityAtom, cleanupCompositorAtoms } from '@/stores/compositorAtoms'; +import { jotaiStore } from '@/stores/jotaiStore'; import { useSessionStore } from '@/stores/sessionStore'; import type { CompositorLayout } from '@/types/generated/compositor-types'; @@ -133,6 +135,7 @@ function pushServerViewData(layout: CompositorLayout) { afterEach(() => { // Clean up store between tests useSessionStore.getState().clearSession(SESSION_ID); + cleanupCompositorAtoms(NODE_ID); }); // ── Tests ─────────────────────────────────────────────────────────────────── @@ -543,6 +546,79 @@ describe('Monitor view data flow integration', () => { expect(result.current.imageOverlays[0].opacity).toBe(0.9); }); + it('server echo-back is blocked during active opacity slider drag', () => { + seedStore(); + + const params = makeParams({ + layers: { + in_0: { opacity: 1.0, z_index: 0 }, + in_1: { + rect: { x: 100, y: 200, width: 320, height: 240 }, + opacity: 0.8, + z_index: 1, + }, + }, + }); + const opts = monitorOptions({ params }); + const { result } = renderHook( + (props: UseCompositorLayersOptions) => useCompositorLayers(props), + { initialProps: opts } + ); + + // Verify initial state — in_1 parsed with opacity 0.8 + expect(result.current.layers.find((l) => l.id === 'in_1')!.opacity).toBe(0.8); + + // User drags opacity slider → updateLayerOpacity writes per-layer atom + // + marks appearanceActiveRef = true for 200ms + act(() => result.current.updateLayerOpacity('in_1', 0.3)); + + // While slider is active, server sends an echo-back with the OLD opacity. + // The echo-back should be blocked by the appearanceActiveRef guard. + const layout = makeServerLayout({ + layers: [ + { + id: 'in_0', + x: 0, + y: 0, + width: 1280, + height: 720, + opacity: 1.0, + z_index: 0, + rotation_degrees: 0, + mirror_horizontal: false, + mirror_vertical: false, + crop_zoom: 1.0, + crop_x: 0.5, + crop_y: 0.5, + }, + { + id: 'in_1', + x: 100, + y: 200, + width: 320, + height: 240, + opacity: 0.8, // stale server value + z_index: 1, + rotation_degrees: 0, + mirror_horizontal: false, + mirror_vertical: false, + crop_zoom: 1.0, + crop_x: 0.5, + crop_y: 0.5, + }, + ], + }); + act(() => pushServerViewData(layout)); + + // The user's slider value (0.3) should persist in the per-layer atom. + // updateLayerOpacity writes to the atom directly (not setLayers), and + // the echo-back guard blocks syncLayerAppearanceAtoms from overwriting + // it with the stale 0.8 value. The per-layer atom is the authoritative + // source during a drag — slider controls and VideoLayer read from it. + const atomValue = jotaiStore.get(compositorLayerOpacityAtom(`${NODE_ID}:in_1`)); + expect(atomValue).toBe(0.3); + }); + it('Design view (no sessionId) still uses parsed positions as source of truth', () => { // This is the control test — Design view should NOT preserve existing geometry. const opts: UseCompositorLayersOptions = { diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index 46bbf759..c665dbb7 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -23,6 +23,7 @@ import { compositorLayersAtom, compositorSelectedLayerAtom, compositorTextOverlaysAtom, + syncLayerAppearanceAtoms, } from '@/stores/compositorAtoms'; import { useCompositorCommit } from './compositorCommit'; @@ -66,6 +67,7 @@ export interface UseCompositorLayersOptions { } export interface UseCompositorLayersResult { + nodeId: string; layers: LayerState[]; selectedLayerId: string | null; selectLayer: (id: string | null) => void; @@ -172,17 +174,20 @@ export const useCompositorLayers = ( // Functional update so we read the actual current atom value, not the // potentially stale layersRef (which is synced via useEffect, running // after this useLayoutEffect). - setLayers((current) => - mergeOverlayState( + let mergedLayers: LayerState[] = []; + setLayers((current) => { + mergedLayers = mergeOverlayState( current, parsedLayers, (a, b) => a.cropZoom !== b.cropZoom || a.cropX !== b.cropX || a.cropY !== b.cropY, isMonitorView - ) - ); + ); + return mergedLayers; + }); - setTextOverlays((current) => - mergeOverlayState( + let mergedText: TextOverlayState[] = []; + setTextOverlays((current) => { + mergedText = mergeOverlayState( current, parseTextOverlays(params), (a, b) => @@ -191,37 +196,35 @@ export const useCompositorLayers = ( a.fontName !== b.fontName || a.color.some((value, index) => value !== b.color[index]), isMonitorView - ) - ); + ); + return mergedText; + }); - setImageOverlays((current) => - mergeOverlayState( + let mergedImg: ImageOverlayState[] = []; + setImageOverlays((current) => { + mergedImg = mergeOverlayState( current, parseImageOverlays(params), (a, b) => a.dataBase64 !== b.dataBase64, isMonitorView - ) - ); + ); + return mergedImg; + }); + + // Sync per-layer appearance atoms so OpacityControl / RotationControl + // and VideoLayer have the correct initial values. + syncLayerAppearanceAtoms(nodeId, [...mergedLayers, ...mergedText, ...mergedImg]); }, [ params, canvasWidth, canvasHeight, isMonitorView, + nodeId, setLayers, setTextOverlays, setImageOverlays, ]); - // ── Server-driven layout (Monitor view only) ─────────────────────────── - useServerLayoutSync( - sessionId, - nodeId, - dragStateRef, - setLayers, - setTextOverlays, - setImageOverlays - ); - // ── Find layer across all types ───────────────────────────────────────── const findAnyLayer = useCallback( (layerId: string): { state: LayerState; kind: LayerKind } | null => { @@ -275,6 +278,7 @@ export const useCompositorLayers = ( // ── Overlay CRUD, property updates, reorder ───────────────────────────── const overlayOps = useCompositorOverlays({ + nodeId, commitAdapter, setLayers, setTextOverlays, @@ -287,6 +291,17 @@ export const useCompositorLayers = ( throttledOverlayCommit, }); + // ── Server-driven layout (Monitor view only) ─────────────────────────── + useServerLayoutSync( + sessionId, + nodeId, + dragStateRef, + overlayOps.appearanceActiveRef, + setLayers, + setTextOverlays, + setImageOverlays + ); + // ── Drag / resize handlers ────────────────────────────────────────────── const { handleLayerPointerDown, handleResizePointerDown } = useCompositorDragResize({ canvasWidth, @@ -308,6 +323,7 @@ export const useCompositorLayers = ( }); return { + nodeId, layers, selectedLayerId, selectLayer: overlayOps.selectLayer, diff --git a/ui/src/nodes/CompositorNode.tsx b/ui/src/nodes/CompositorNode.tsx index d4d40b07..23e75942 100644 --- a/ui/src/nodes/CompositorNode.tsx +++ b/ui/src/nodes/CompositorNode.tsx @@ -73,6 +73,7 @@ const CompositorNode: React.FC = React.memo(({ id, data, se const canvasHeight = (data.params?.height as number) ?? 720; const { + nodeId: compositorNodeId, layers, selectedLayerId, selectLayer, @@ -270,9 +271,11 @@ const CompositorNode: React.FC = React.memo(({ id, data, se /> = React.memo(({ id, data, se removeImageOverlay, reorderLayers, disabled, + compositorNodeId, inspectorProps, selectedLayerName, selectedLayerKind, @@ -317,6 +321,7 @@ const CompositorNode: React.FC = React.memo(({ id, data, se {canvasHeaderContent} = React.memo(({ id, data, se canvasHeaderContent, canvasWidth, canvasHeight, + compositorNodeId, layers, textOverlays, imageOverlays, diff --git a/ui/src/nodes/compositorNodeInspector.tsx b/ui/src/nodes/compositorNodeInspector.tsx index dfad17e8..5af3f78e 100644 --- a/ui/src/nodes/compositorNodeInspector.tsx +++ b/ui/src/nodes/compositorNodeInspector.tsx @@ -337,9 +337,11 @@ export function useTextInspectorChildren( // ── Compositor inspector component ────────────────────────────────────────── export interface CompositorInspectorProps { + nodeId: string; inspectorProps: InspectorLayerProps | null; selectedLayerName: string; selectedLayerKind: LayerKindTag | null; + selectedLayerId: string | null; selectedLayer: LayerState | undefined; textInspectorChildren: React.ReactNode; handleSelectedOpacityChange: (v: number) => void; @@ -353,9 +355,11 @@ export interface CompositorInspectorProps { export const CompositorInspector: React.FC = React.memo( ({ + nodeId, inspectorProps, selectedLayerName, selectedLayerKind, + selectedLayerId, selectedLayer, textInspectorChildren, handleSelectedOpacityChange, @@ -366,7 +370,7 @@ export const CompositorInspector: React.FC = React.mem dimensionsReadOnly, disabled, }) => { - if (!inspectorProps) return null; + if (!inspectorProps || !selectedLayerId) return null; return ( <> @@ -383,12 +387,14 @@ export const CompositorInspector: React.FC = React.mem /> {textInspectorChildren} diff --git a/ui/src/nodes/compositorNodeWidgets.tsx b/ui/src/nodes/compositorNodeWidgets.tsx index 5a2effef..201bdd36 100644 --- a/ui/src/nodes/compositorNodeWidgets.tsx +++ b/ui/src/nodes/compositorNodeWidgets.tsx @@ -15,6 +15,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { SKTooltip } from '@/components/Tooltip'; import type { LayerKind } from '@/hooks/useCompositorLayers'; +import { compositorLayerOpacityAtom, compositorLayerRotationAtom } from '@/stores/compositorAtoms'; +import { jotaiStore } from '@/stores/jotaiStore'; import { AddMenu, @@ -195,38 +197,62 @@ export const InspectorHeaderSection: React.FC<{ InspectorHeaderSection.displayName = 'InspectorHeaderSection'; export const OpacityControl: React.FC<{ - opacity: number; + nodeId: string; + layerId: string; onChange: (value: number) => void; disabled: boolean; -}> = React.memo(({ opacity, onChange, disabled }) => ( - - Opacity - - onChange(v)} - min={0} - max={1} - step={0.01} - disabled={disabled} - className="nodrag nopan" - > - - - - - - {(opacity * 100).toFixed(0)}% - - -)); +}> = React.memo(({ nodeId, layerId, onChange, disabled }) => { + const opacity = jotaiStore.get(compositorLayerOpacityAtom(`${nodeId}:${layerId}`)); + // Subscribe to per-layer atom so this component re-renders on opacity + // changes without re-rendering the entire compositor tree. + const [, forceRender] = useState(0); + useEffect(() => { + const unsub = jotaiStore.sub(compositorLayerOpacityAtom(`${nodeId}:${layerId}`), () => { + forceRender((c) => c + 1); + }); + return unsub; + }, [nodeId, layerId]); + + return ( + + Opacity + + onChange(v)} + min={0} + max={1} + step={0.01} + disabled={disabled} + className="nodrag nopan" + > + + + + + + {(opacity * 100).toFixed(0)}% + + + ); +}); OpacityControl.displayName = 'OpacityControl'; export const RotationControl: React.FC<{ - rotationDegrees: number; + nodeId: string; + layerId: string; onChange: (value: number) => void; disabled: boolean; -}> = React.memo(({ rotationDegrees, onChange, disabled }) => { +}> = React.memo(({ nodeId, layerId, onChange, disabled }) => { + const rotationDegrees = jotaiStore.get(compositorLayerRotationAtom(`${nodeId}:${layerId}`)); + const [, forceRender] = useState(0); + useEffect(() => { + const unsub = jotaiStore.sub(compositorLayerRotationAtom(`${nodeId}:${layerId}`), () => { + forceRender((c) => c + 1); + }); + return unsub; + }, [nodeId, layerId]); + const normalisedRotation = ((Math.round(rotationDegrees) % 360) + 360) % 360; return ( diff --git a/ui/src/stores/compositorAtoms.test.ts b/ui/src/stores/compositorAtoms.test.ts new file mode 100644 index 00000000..e6aad297 --- /dev/null +++ b/ui/src/stores/compositorAtoms.test.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +/** + * Unit tests for compositor Jotai atoms. + * + * These tests verify two correctness properties that, when violated, + * cause the render-cascade bug reported in PR #173: + * + * 1. Per-layer atom isolation — writing to a per-layer opacity/rotation + * atom does NOT cause the full layers-array atom to emit, so + * CompositorNode (which subscribes to the array) is NOT re-rendered. + * + * 2. syncLayerAppearanceAtoms round-trips — after syncing from a + * merged layer list, every per-layer atom reflects the correct value. + */ + +import { describe, it, expect, afterEach } from 'vitest'; + +import type { LayerState } from '@/hooks/compositorLayerParsers'; +import { + compositorLayersAtom, + compositorLayerOpacityAtom, + compositorLayerRotationAtom, + syncLayerAppearanceAtoms, + cleanupCompositorAtoms, +} from '@/stores/compositorAtoms'; +import { jotaiStore } from '@/stores/jotaiStore'; + +const NODE = 'test-node'; + +afterEach(() => { + cleanupCompositorAtoms(NODE); +}); + +describe('per-layer atom isolation', () => { + it('writing per-layer opacity atom does NOT trigger the layers-array atom', () => { + // Seed the layers-array atom with proper LayerState objects + const layers: LayerState[] = [ + { + id: 'in_0', + x: 0, + y: 0, + width: 1280, + height: 720, + opacity: 1, + zIndex: 0, + rotationDegrees: 0, + mirrorHorizontal: false, + mirrorVertical: false, + visible: true, + cropZoom: 1, + cropX: 0.5, + cropY: 0.5, + }, + { + id: 'in_1', + x: 100, + y: 100, + width: 320, + height: 240, + opacity: 0.8, + zIndex: 1, + rotationDegrees: 0, + mirrorHorizontal: false, + mirrorVertical: false, + visible: true, + cropZoom: 1, + cropX: 0.5, + cropY: 0.5, + }, + ]; + jotaiStore.set(compositorLayersAtom(NODE), layers); + + // Subscribe to layers-array atom and track notifications + let layersNotified = 0; + const unsub = jotaiStore.sub(compositorLayersAtom(NODE), () => { + layersNotified++; + }); + + // Write to per-layer opacity atom (this is what slider ticks do) + jotaiStore.set(compositorLayerOpacityAtom(`${NODE}:in_1`), 0.5); + jotaiStore.set(compositorLayerOpacityAtom(`${NODE}:in_1`), 0.6); + jotaiStore.set(compositorLayerOpacityAtom(`${NODE}:in_1`), 0.7); + + // The layers-array atom should NOT have been notified + expect(layersNotified).toBe(0); + + unsub(); + }); + + it('writing per-layer rotation atom does NOT trigger the layers-array atom', () => { + jotaiStore.set(compositorLayersAtom(NODE), []); + + let layersNotified = 0; + const unsub = jotaiStore.sub(compositorLayersAtom(NODE), () => { + layersNotified++; + }); + + jotaiStore.set(compositorLayerRotationAtom(`${NODE}:in_0`), 15); + jotaiStore.set(compositorLayerRotationAtom(`${NODE}:in_0`), 30); + jotaiStore.set(compositorLayerRotationAtom(`${NODE}:in_0`), 45); + + expect(layersNotified).toBe(0); + + unsub(); + }); + + it('per-layer atoms for different layers are independent', () => { + // Subscribe to layer A opacity + let layerANotified = 0; + const unsubA = jotaiStore.sub(compositorLayerOpacityAtom(`${NODE}:in_0`), () => { + layerANotified++; + }); + + // Write to layer B opacity — should NOT notify layer A + jotaiStore.set(compositorLayerOpacityAtom(`${NODE}:in_1`), 0.3); + + expect(layerANotified).toBe(0); + + // Write to layer A — should notify only layer A + jotaiStore.set(compositorLayerOpacityAtom(`${NODE}:in_0`), 0.9); + expect(layerANotified).toBe(1); + + unsubA(); + }); +}); + +describe('syncLayerAppearanceAtoms', () => { + it('syncs all per-layer atoms from a merged layer list', () => { + const items = [ + { id: 'in_0', opacity: 0.75, rotationDegrees: 15 }, + { id: 'in_1', opacity: 0.5, rotationDegrees: 90 }, + ]; + + syncLayerAppearanceAtoms(NODE, items); + + expect(jotaiStore.get(compositorLayerOpacityAtom(`${NODE}:in_0`))).toBe(0.75); + expect(jotaiStore.get(compositorLayerRotationAtom(`${NODE}:in_0`))).toBe(15); + expect(jotaiStore.get(compositorLayerOpacityAtom(`${NODE}:in_1`))).toBe(0.5); + expect(jotaiStore.get(compositorLayerRotationAtom(`${NODE}:in_1`))).toBe(90); + }); + + it('cleanup removes per-layer atoms created by sync', () => { + syncLayerAppearanceAtoms(NODE, [{ id: 'in_0', opacity: 0.5, rotationDegrees: 45 }]); + + // Verify atom exists with value + expect(jotaiStore.get(compositorLayerOpacityAtom(`${NODE}:in_0`))).toBe(0.5); + + cleanupCompositorAtoms(NODE); + + // After cleanup, a fresh read should return default values + // because the atom family entry was removed. + expect(jotaiStore.get(compositorLayerOpacityAtom(`${NODE}:in_0`))).toBe(1); + expect(jotaiStore.get(compositorLayerRotationAtom(`${NODE}:in_0`))).toBe(0); + }); +}); diff --git a/ui/src/stores/compositorAtoms.ts b/ui/src/stores/compositorAtoms.ts index f10107b0..a8bf2cd7 100644 --- a/ui/src/stores/compositorAtoms.ts +++ b/ui/src/stores/compositorAtoms.ts @@ -6,21 +6,23 @@ * Jotai atoms for compositor layer state. * * Each compositor node instance (keyed by nodeId) owns its own set of - * layer / text-overlay / image-overlay atoms. Individual layer properties - * are stored per-layer so that an opacity slider drag only re-renders the - * slider component — not the entire compositor tree. + * layer / text-overlay / image-overlay atoms. Per-layer appearance atoms + * (opacity, rotation) are stored separately so that a slider drag only + * re-renders the slider component and the affected canvas layer — not + * the entire compositor tree. * * The atom families are organised as: * - * compositorLayersAtom(nodeId) → LayerState[] - * compositorTextOverlaysAtom(nodeId) → TextOverlayState[] - * compositorImageOverlaysAtom(nodeId)→ ImageOverlayState[] - * compositorSelectedLayerAtom(nodeId)→ string | null - * compositorIsDraggingAtom(nodeId) → boolean + * compositorLayersAtom(nodeId) → LayerState[] + * compositorTextOverlaysAtom(nodeId) → TextOverlayState[] + * compositorImageOverlaysAtom(nodeId) → ImageOverlayState[] + * compositorSelectedLayerAtom(nodeId) → string | null + * compositorIsDraggingAtom(nodeId) → boolean * - * Derived atoms for individual fields can be added later; for Phase 1 we - * keep per-node granularity which already eliminates the zero-render DOM - * hack and the custom memo comparators. + * Per-layer appearance atoms (keyed by `${nodeId}:${layerId}`): + * + * compositorLayerOpacityAtom(key) → number + * compositorLayerRotationAtom(key) → number */ import { atom } from 'jotai'; @@ -32,6 +34,8 @@ import type { ImageOverlayState, } from '@/hooks/compositorLayerParsers'; +import { jotaiStore } from './jotaiStore'; + // ── Per-node atom families ────────────────────────────────────────────────── /** Video layers for a given compositor node. */ @@ -64,6 +68,61 @@ export const compositorIsDraggingAtom = atomFamily((_nodeId: string) => { return atom(false); }); +// ── Per-layer appearance atoms ────────────────────────────────────────────── +// +// Keyed by `${nodeId}:${layerId}`. These are the source of truth for +// opacity and rotation during slider drags, so the full layers-array +// atom is NOT updated during high-frequency slider ticks. Only the +// per-layer atom changes → only OpacityControl / RotationControl and +// the affected VideoLayer re-render. + +/** Per-layer opacity (0–1). Default matches DEFAULT_OPACITY = 1. */ +export const compositorLayerOpacityAtom = atomFamily((_key: string) => { + void _key; + return atom(1); +}); + +/** Per-layer rotation in degrees. Default matches DEFAULT_ROTATION_DEGREES = 0. */ +export const compositorLayerRotationAtom = atomFamily((_key: string) => { + void _key; + return atom(0); +}); + +// ── Per-layer atom key tracking ───────────────────────────────────────────── +// +// We need to track which per-layer atom keys belong to each node so that +// cleanupCompositorAtoms can remove them when the node unmounts. + +const activeLayerKeys = new Map>(); + +function trackKey(nodeId: string, key: string): void { + let keys = activeLayerKeys.get(nodeId); + if (!keys) { + keys = new Set(); + activeLayerKeys.set(nodeId, keys); + } + keys.add(key); +} + +// ── Sync helper ───────────────────────────────────────────────────────────── + +/** Synchronise per-layer appearance atoms from a merged layer list. + * + * Call this in sync-from-props and server-sync so that the per-layer atoms + * reflect the authoritative data. Uses the vanilla Jotai store so it can + * be called from effects without going through React state. */ +export function syncLayerAppearanceAtoms( + nodeId: string, + items: ReadonlyArray<{ id: string; opacity: number; rotationDegrees: number }> +): void { + for (const item of items) { + const key = `${nodeId}:${item.id}`; + trackKey(nodeId, key); + jotaiStore.set(compositorLayerOpacityAtom(key), item.opacity); + jotaiStore.set(compositorLayerRotationAtom(key), item.rotationDegrees); + } +} + // ── Cleanup ───────────────────────────────────────────────────────────────── /** Remove all atoms for a compositor node. Call on unmount. */ @@ -73,4 +132,14 @@ export function cleanupCompositorAtoms(nodeId: string): void { compositorImageOverlaysAtom.remove(nodeId); compositorSelectedLayerAtom.remove(nodeId); compositorIsDraggingAtom.remove(nodeId); + + // Clean up per-layer appearance atoms + const keys = activeLayerKeys.get(nodeId); + if (keys) { + for (const key of keys) { + compositorLayerOpacityAtom.remove(key); + compositorLayerRotationAtom.remove(key); + } + activeLayerKeys.delete(nodeId); + } }