diff --git a/ui/bun.lock b/ui/bun.lock
index 32800d18..834559fd 100644
--- a/ui/bun.lock
+++ b/ui/bun.lock
@@ -29,6 +29,7 @@
"@uiw/react-codemirror": "^4.25.8",
"@xyflow/react": "^12.10.1",
"codemirror": "^6.0.2",
+ "jotai": "^2.18.1",
"js-yaml": "^4.1.1",
"lodash-es": "^4.17.23",
"lucide-react": "^0.577.0",
@@ -1038,6 +1039,8 @@
"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=="],
+
"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..90772b75 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,7 @@
"@uiw/react-codemirror": "^4.25.8",
"@xyflow/react": "^12.10.1",
"codemirror": "^6.0.2",
+ "jotai": "^2.18.1",
"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..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[];
@@ -61,78 +62,9 @@ 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(
({
+ nodeId,
canvasWidth,
canvasHeight,
layers,
@@ -257,6 +189,7 @@ export const CompositorCanvas: React.FC = React.memo(
{layers.map((layer, i) => (
= React.memo(
);
- },
- areCanvasPropsEqual
+ }
);
CompositorCanvas.displayName = 'CompositorCanvas';
diff --git a/ui/src/components/compositorCanvasLayers.tsx b/ui/src/components/compositorCanvasLayers.tsx
index d1e304c9..14680ffc 100644
--- a/ui/src/components/compositorCanvasLayers.tsx
+++ b/ui/src/components/compositorCanvasLayers.tsx
@@ -18,6 +18,8 @@ import type {
ResizeHandle,
} from '@/hooks/useCompositorLayers';
import { friendlyLabel } from '@/nodes/compositorNodeParts';
+import { compositorLayerOpacityAtom, compositorLayerRotationAtom } from '@/stores/compositorAtoms';
+import { jotaiStore } from '@/stores/jotaiStore';
import {
LayerBox,
@@ -123,13 +125,34 @@ ResizeHandles.displayName = 'ResizeHandles';
// ── Video input layer ───────────────────────────────────────────────────────
export const VideoLayer: React.FC<{
+ nodeId: string;
layer: LayerState;
index: number;
isSelected: boolean;
onPointerDown: (layerId: string, e: React.PointerEvent) => 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 2c8a465a..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>;
@@ -42,10 +51,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