Browse Source

新增画布优化

feature/video-erase
Luckyu_js 2 weeks ago
parent
commit
828099993e
  1. 21
      src/components/PopiaiEmbedBridge.tsx
  2. 68
      src/components/WorkflowCanvas.tsx
  3. 30
      src/components/nodes/ArrayNode.tsx
  4. 38
      src/components/nodes/BaseNode.tsx
  5. 69
      src/components/nodes/ImageCompareNode.tsx
  6. 27
      src/components/nodes/PromptConstructorNode.tsx
  7. 20
      src/components/nodes/VideoFrameGrabNode.tsx
  8. 44
      src/components/nodes/VideoStitchNode.tsx
  9. 10
      src/components/nodes/VideoTrimNode.tsx
  10. 15
      src/components/workflowTypes.tsx
  11. 42
      src/store/utils/nodesById.ts
  12. 42
      src/store/workflowStore.ts

21
src/components/PopiaiEmbedBridge.tsx

@ -307,6 +307,7 @@ export function PopiaiEmbedBridge() {
const sessionId = searchParams.get("sessionId"); const sessionId = searchParams.get("sessionId");
let disposed = false; let disposed = false;
let unsubscribe: (() => void) | undefined; let unsubscribe: (() => void) | undefined;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const postToParent = (type: string, payload?: unknown, requestId?: string) => { const postToParent = (type: string, payload?: unknown, requestId?: string) => {
window.parent.postMessage( window.parent.postMessage(
@ -324,6 +325,18 @@ export function PopiaiEmbedBridge() {
postToParent("popitv:snapshot", { ...buildSnapshot(sessionId), ...extra }, requestId); postToParent("popitv:snapshot", { ...buildSnapshot(sessionId), ...extra }, requestId);
}; };
// Persist + notify the parent, coalesced. The store updates every frame during
// a drag; serializing to localStorage and postMessaging on each frame is wasteful.
const flushPersist = () => {
saveTimer = null;
if (sessionId) saveSessionWorkflow(sessionId);
postSnapshot();
};
const schedulePersist = () => {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(flushPersist, 200);
};
const handleMessage = (event: MessageEvent<BridgeMessage>) => { const handleMessage = (event: MessageEvent<BridgeMessage>) => {
if (event.origin !== allowedOrigin || event.data?.source !== SOURCE_POPIAI) return; if (event.origin !== allowedOrigin || event.data?.source !== SOURCE_POPIAI) return;
@ -386,14 +399,18 @@ export function PopiaiEmbedBridge() {
postToParent("popitv:ready", buildSnapshot(sessionId)); postToParent("popitv:ready", buildSnapshot(sessionId));
unsubscribe = useWorkflowStore.subscribe(() => { unsubscribe = useWorkflowStore.subscribe(() => {
if (sessionId) saveSessionWorkflow(sessionId); schedulePersist();
postSnapshot();
}); });
})(); })();
return () => { return () => {
disposed = true; disposed = true;
unsubscribe?.(); unsubscribe?.();
if (saveTimer) {
clearTimeout(saveTimer);
// Flush any pending write so the last change isn't lost on unmount.
flushPersist();
}
window.removeEventListener("message", handleMessage); window.removeEventListener("message", handleMessage);
}; };
}, [isEmbed]); }, [isEmbed]);

68
src/components/WorkflowCanvas.tsx

@ -217,16 +217,56 @@ function getWorkflowNodeDimensions(node: Node): { width: number; height: number
return defaults; return defaults;
} }
// Cache the dimension-decorated node keyed on the source node object. Upstream
// (`applyNodeChanges` + the `allNodes` memo) keeps the same object reference for
// nodes that didn't change this frame, so this WeakMap returns the exact same
// decorated reference across frames. Without it, the `{...node}` spread minted a
// brand-new object for every node on every drag frame, invalidating React Flow's
// per-node wrapper memo and re-committing the transform on EVERY node — not just
// the one being dragged. Keeping references stable means an unrelated node's DOM
// wrapper is left untouched during a drag.
const reactFlowNodeDimensionsCache = new WeakMap<Node, Node>();
// Cache the visual-state-decorated (className + zIndex) node keyed on the RAW
// store node. `applyNodeChanges` keeps the same reference for nodes that didn't
// move this frame, so keying on the raw node lets us return the exact same
// decorated object across frames — as long as the computed className/zIndex
// signature is unchanged. Store nodes never carry className/zIndex themselves
// (they're undefined), so without this cache the `{...node}` branch below minted
// a new object for EVERY node on EVERY frame, breaking React Flow's per-node
// `adoptUserNodes` reference check and re-rendering every wrapper during a drag.
const nodeVisualStateCache = new WeakMap<
WorkflowNode,
{ decorated: WorkflowNode; className: string; zIndex: number }
>();
function withReactFlowNodeDimensions<T extends Node>(node: T): T { function withReactFlowNodeDimensions<T extends Node>(node: T): T {
const cached = reactFlowNodeDimensionsCache.get(node);
if (cached) return cached as T;
const dimensions = getWorkflowNodeStyleDimensions(node); const dimensions = getWorkflowNodeStyleDimensions(node);
if (!dimensions) return node; // No style dimensions, or the node already carries the right width/height:
return { // reuse the original object so its reference stays stable downstream.
if (
!dimensions ||
(node.width === dimensions.width &&
node.height === dimensions.height &&
node.initialWidth === dimensions.width &&
node.initialHeight === dimensions.height)
) {
reactFlowNodeDimensionsCache.set(node, node);
return node;
}
const decorated: T = {
...node, ...node,
width: dimensions.width, width: dimensions.width,
height: dimensions.height, height: dimensions.height,
initialWidth: dimensions.width, initialWidth: dimensions.width,
initialHeight: dimensions.height, initialHeight: dimensions.height,
}; };
reactFlowNodeDimensionsCache.set(node, decorated);
return decorated;
} }
function getWorkflowNodeAbsolutePosition(node: Node, nodeById: Map<string, Node>): { x: number; y: number } { function getWorkflowNodeAbsolutePosition(node: Node, nodeById: Map<string, Node>): { x: number; y: number } {
@ -974,9 +1014,20 @@ export function WorkflowCanvas() {
zIndex = Math.max(zIndex, topStackLayer + 1); zIndex = Math.max(zIndex, topStackLayer + 1);
} }
// Only create new node object if className changed // Return a reference-stable decorated node. Keyed on the raw store node,
if (node.className === newClass && node.zIndex === zIndex) return node; // so an unchanged node yields the identical object across frames and React
return { ...node, className: newClass, zIndex }; // Flow leaves its wrapper untouched; only the node whose className/zIndex
// actually changed (e.g. the one being dragged) gets a new reference.
const cached = nodeVisualStateCache.get(node);
if (cached && cached.className === newClass && cached.zIndex === zIndex) {
return cached.decorated;
}
const decorated =
node.className === newClass && node.zIndex === zIndex
? node
: { ...node, className: newClass, zIndex };
nodeVisualStateCache.set(node, { decorated, className: newClass, zIndex });
return decorated;
}); });
}, [nodes, dimmedNodeIds, draggingGroupId, memberLayerMap, skippedNodeIds, stackLayerMap, topStackLayer]); }, [nodes, dimmedNodeIds, draggingGroupId, memberLayerMap, skippedNodeIds, stackLayerMap, topStackLayer]);
@ -1706,8 +1757,11 @@ export function WorkflowCanvas() {
} }
}, [loadWorkflow, showToast, captureSnapshot, t, providerSettings, closeCanvasAssistant, urlWorkflowId, markAsUnsaved]); }, [loadWorkflow, showToast, captureSnapshot, t, providerSettings, closeCanvasAssistant, urlWorkflowId, markAsUnsaved]);
// Create lightweight workflow state for chat (strip base64 images) // Create lightweight workflow state for chat (strip base64 images).
// Only computed while the chat panel is open — otherwise dragging any node
// would run stripBinaryData over every node on each frame for no reason.
const chatWorkflowState = useMemo(() => { const chatWorkflowState = useMemo(() => {
if (!canvasNavigationSettings.assistantEnabled) return undefined;
const strippedNodes = stripBinaryData(nodes); const strippedNodes = stripBinaryData(nodes);
return { return {
nodes: strippedNodes.map(n => ({ nodes: strippedNodes.map(n => ({
@ -1724,7 +1778,7 @@ export function WorkflowCanvas() {
targetHandle: e.targetHandle || undefined, targetHandle: e.targetHandle || undefined,
})), })),
}; };
}, [nodes, edges]); }, [nodes, edges, canvasNavigationSettings.assistantEnabled]);
// Compute selected node IDs for chat context scoping // Compute selected node IDs for chat context scoping
const selectedNodeIds = useMemo(() => nodes.filter(n => n.selected).map(n => n.id), [nodes]); const selectedNodeIds = useMemo(() => nodes.filter(n => n.selected).map(n => n.id), [nodes]);

30
src/components/nodes/ArrayNode.tsx

@ -8,6 +8,7 @@ import { useWorkflowStore } from "@/store/workflowStore";
import { ArrayNodeData } from "@/types"; import { ArrayNodeData } from "@/types";
import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles"; import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
import { getConnectedInputsPure } from "@/store/utils/connectedInputs"; import { getConnectedInputsPure } from "@/store/utils/connectedInputs";
import { selectNodeById } from "@/store/utils/nodesById";
import { parseTextToArray } from "@/utils/arrayParser"; import { parseTextToArray } from "@/utils/arrayParser";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { defaultNodeDimensions } from "@/constants/nodeDimensions"; import { defaultNodeDimensions } from "@/constants/nodeDimensions";
@ -39,15 +40,14 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
const removeNode = useWorkflowStore((state) => state.removeNode); const removeNode = useWorkflowStore((state) => state.removeNode);
const addNode = useWorkflowStore((state) => state.addNode); const addNode = useWorkflowStore((state) => state.addNode);
const onConnect = useWorkflowStore((state) => state.onConnect); const onConnect = useWorkflowStore((state) => state.onConnect);
const nodes = useWorkflowStore((state) => state.nodes);
const edges = useWorkflowStore((state) => state.edges); const edges = useWorkflowStore((state) => state.edges);
// Derive nodeData from the Zustand store (already subscribed via `nodes`) // Derive this node's data from the store (source of truth) via an O(1) indexed
// rather than React Flow props, so settings changes are reflected immediately. // lookup so settings changes reflect immediately, without subscribing to the
const nodeData = useMemo(() => { // whole nodes array (which would re-render this node on every drag frame).
const n = nodes.find((nd) => nd.id === id); const nodeData = useWorkflowStore(
return (n?.data as ArrayNodeData) ?? data; (state) => (selectNodeById(state.nodes, id)?.data as ArrayNodeData | undefined) ?? data
}, [nodes, id, data]); );
const { setNodes, getNodes } = useReactFlow(); const { setNodes, getNodes } = useReactFlow();
const lastSyncedInputRef = useRef<string | null>(null); const lastSyncedInputRef = useRef<string | null>(null);
const lastDerivedWriteRef = useRef<string | null>(null); const lastDerivedWriteRef = useRef<string | null>(null);
@ -63,10 +63,18 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
[edges, id] [edges, id]
); );
const connectedText = useMemo(() => { const connectedText = useWorkflowStore((state) => {
if (!hasIncomingTextConnection) return null; if (
return getConnectedInputsPure(id, nodes, edges).text; !state.edges.some((edge) => {
}, [edges, hasIncomingTextConnection, id, nodes]); if (edge.target !== id) return false;
const handle = edge.targetHandle || "text";
return handle === "text" || handle.startsWith("text-") || handle.includes("prompt");
})
) {
return null;
}
return getConnectedInputsPure(id, state.nodes, state.edges).text;
});
// Pull upstream text into this node whenever the connected input changes. // Pull upstream text into this node whenever the connected input changes.
useEffect(() => { useEffect(() => {

38
src/components/nodes/BaseNode.tsx

@ -1,9 +1,10 @@
"use client"; "use client";
import { ReactNode, useRef, useLayoutEffect } from "react"; import { ReactNode, useMemo, useRef, useLayoutEffect } from "react";
import type { MouseEvent as ReactMouseEvent } from "react"; import type { MouseEvent as ReactMouseEvent } from "react";
import { Node, useReactFlow } from "@xyflow/react"; import { Node, useReactFlow } from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { selectNodeById } from "@/store/utils/nodesById";
import { isPanningRef, isDraggingNodeRef } from "@/components/WorkflowCanvas"; import { isPanningRef, isDraggingNodeRef } from "@/components/WorkflowCanvas";
import { NodeGenerationComposer } from "@/components/composer/NodeGenerationComposer"; import { NodeGenerationComposer } from "@/components/composer/NodeGenerationComposer";
import { useCanvasPreviewMode } from "@/components/CanvasPreviewModeContext"; import { useCanvasPreviewMode } from "@/components/CanvasPreviewModeContext";
@ -94,7 +95,7 @@ export function BaseNode({
const isPreviewMode = useCanvasPreviewMode(); const isPreviewMode = useCanvasPreviewMode();
const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds); const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds);
const setHoveredNodeId = useWorkflowStore((state) => state.setHoveredNodeId); const setHoveredNodeId = useWorkflowStore((state) => state.setHoveredNodeId);
const node = useWorkflowStore((state) => state.nodes.find((candidate) => candidate.id === id) as WorkflowNode | undefined); const node = useWorkflowStore((state) => selectNodeById(state.nodes, id) as WorkflowNode | undefined);
const edges = useWorkflowStore((state) => state.edges); const edges = useWorkflowStore((state) => state.edges);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const { onExpandNode } = useNodeHeaderContext(); const { onExpandNode } = useNodeHeaderContext();
@ -261,21 +262,26 @@ export function BaseNode({
}, []); }, []);
const hasExpandedSettings = settingsExpanded && settingsPanel; const hasExpandedSettings = settingsExpanded && settingsPanel;
const nodeTitle = node ? getNodeHeaderTitle(node, edges, t) : ""; // Header-derived values depend only on this node, the edges, the translator and
const iconKind = node ? getNodeHeaderIconKind(node.type) : null; // the inline-parameters flag. Memoizing avoids re-running edge-traversing helpers
const dimensionsText = node ? getNodeHeaderDimensionsText(node) : null; // (title/effective-model/smart-mode checks) on unrelated re-renders.
const customTitle = typeof node?.data?.customTitle === "string" ? node.data.customTitle : undefined;
const effectiveModel = node ? getEffectiveNodeHeaderModel(node, edges) : undefined;
const nodeData = node?.data as WorkflowNodeData | undefined; const nodeData = node?.data as WorkflowNodeData | undefined;
const showBrowse = Boolean(node && inlineParametersEnabled && ( const { nodeTitle, iconKind, dimensionsText, customTitle, effectiveModel, showBrowse } = useMemo(() => ({
(node.type === "smartImage" && (!isSmartImageAutoTaskNode(nodeData) || isHighDefinitionDerivedImageNode(nodeData)) && isSmartImageGenerateMode(node.id, nodeData, edges)) || nodeTitle: node ? getNodeHeaderTitle(node, edges, t) : "",
(node.type === "smartAudio" && isSmartAudioGenerateMode(node.id, nodeData, edges)) || iconKind: node ? getNodeHeaderIconKind(node.type) : null,
(node.type === "smartVideo" && isSmartVideoGenerateMode(node.id, nodeData, edges)) || dimensionsText: node ? getNodeHeaderDimensionsText(node) : null,
node.type === "nanoBanana" || customTitle: typeof node?.data?.customTitle === "string" ? node.data.customTitle : undefined,
node.type === "generateVideo" || effectiveModel: node ? getEffectiveNodeHeaderModel(node, edges) : undefined,
node.type === "generate3d" || showBrowse: Boolean(node && inlineParametersEnabled && (
node.type === "generateAudio" (node.type === "smartImage" && (!isSmartImageAutoTaskNode(nodeData) || isHighDefinitionDerivedImageNode(nodeData)) && isSmartImageGenerateMode(node.id, nodeData, edges)) ||
)); (node.type === "smartAudio" && isSmartAudioGenerateMode(node.id, nodeData, edges)) ||
(node.type === "smartVideo" && isSmartVideoGenerateMode(node.id, nodeData, edges)) ||
node.type === "nanoBanana" ||
node.type === "generateVideo" ||
node.type === "generate3d" ||
node.type === "generateAudio"
)),
}), [node, edges, t, inlineParametersEnabled, nodeData]);
const browseAction = showBrowse ? ( const browseAction = showBrowse ? (
<button <button
onClick={() => browseRegistry.open(id)} onClick={() => browseRegistry.open(id)}

69
src/components/nodes/ImageCompareNode.tsx

@ -1,6 +1,5 @@
"use client"; "use client";
import { useMemo } from "react";
import { Position, NodeProps, Node } from "@xyflow/react"; import { Position, NodeProps, Node } from "@xyflow/react";
import { import {
ReactCompareSlider, ReactCompareSlider,
@ -9,6 +8,8 @@ import {
import { BaseNode } from "./BaseNode"; import { BaseNode } from "./BaseNode";
import { NodeHandle } from "./NodeHandle"; import { NodeHandle } from "./NodeHandle";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { useShallow } from "zustand/shallow";
import { selectNodeById } from "@/store/utils/nodesById";
import { ImageCompareNodeData } from "@/types"; import { ImageCompareNodeData } from "@/types";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { getSourceOutput } from "@/store/utils/connectedInputs"; import { getSourceOutput } from "@/store/utils/connectedInputs";
@ -24,44 +25,46 @@ export function ImageCompareNode({
const nodeData = data; const nodeData = data;
const { t } = useI18n(); const { t } = useI18n();
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
// Collect images in real-time from connected nodes (same pattern as OutputGalleryNode) // Collect images in real-time from connected nodes (same pattern as OutputGalleryNode).
const displayImages = useMemo(() => { // Computed inside the selector and compared with useShallow so this node only
const connectedImages: Array<string | null> = [null, null]; // re-renders when the derived image URLs change — not on every drag frame.
const appendImage = (image: string) => { const displayImages = useWorkflowStore(
const index = connectedImages.findIndex((item) => !item); useShallow((state) => {
if (index !== -1) { const connectedImages: Array<string | null> = [null, null];
connectedImages[index] = image; const appendImage = (image: string) => {
} const index = connectedImages.findIndex((item) => !item);
}; if (index !== -1) {
connectedImages[index] = image;
}
};
// Get edges connected to this node, sorted by creation time for stable ordering // Get edges connected to this node, sorted by creation time for stable ordering
const sortedEdges = edges const sortedEdges = state.edges
.filter((edge) => edge.target === id) .filter((edge) => edge.target === id)
.sort((a, b) => { .sort((a, b) => {
const aTime = (a.data?.createdAt as number) || 0; const aTime = (a.data?.createdAt as number) || 0;
const bTime = (b.data?.createdAt as number) || 0; const bTime = (b.data?.createdAt as number) || 0;
return aTime - bTime; return aTime - bTime;
}); });
sortedEdges.forEach((edge) => { sortedEdges.forEach((edge) => {
const sourceNode = nodes.find((n) => n.id === edge.source); const sourceNode = selectNodeById(state.nodes, edge.source);
if (!sourceNode) return; if (!sourceNode) return;
const output = getSourceOutput( const output = getSourceOutput(
sourceNode, sourceNode,
edge.sourceHandle, edge.sourceHandle,
edge.data as Record<string, unknown> | undefined, edge.data as Record<string, unknown> | undefined,
edges state.edges
); );
if (output.type === "image" && output.value) appendImage(output.value); if (output.type === "image" && output.value) appendImage(output.value);
}); });
return connectedImages; return connectedImages;
}, [edges, nodes, id]); })
);
const imageA = displayImages[0] || nodeData.imageA || null; const imageA = displayImages[0] || nodeData.imageA || null;
const imageB = displayImages[1] || nodeData.imageB || null; const imageB = displayImages[1] || nodeData.imageB || null;

27
src/components/nodes/PromptConstructorNode.tsx

@ -9,6 +9,7 @@ import { usePromptAutocomplete } from "@/hooks/usePromptAutocomplete";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { PromptConstructorNodeData, PromptNodeData, LLMGenerateNodeData, AvailableVariable } from "@/types"; import { PromptConstructorNodeData, PromptNodeData, LLMGenerateNodeData, AvailableVariable } from "@/types";
import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs"; import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs";
import { selectNodeById } from "@/store/utils/nodesById";
import { parseVarTags } from "@/utils/parseVarTags"; import { parseVarTags } from "@/utils/parseVarTags";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { defaultNodeDimensions } from "@/constants/nodeDimensions"; import { defaultNodeDimensions } from "@/constants/nodeDimensions";
@ -43,8 +44,6 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const removeNode = useWorkflowStore((state) => state.removeNode); const removeNode = useWorkflowStore((state) => state.removeNode);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
// Local state for template to prevent cursor jumping // Local state for template to prevent cursor jumping
const [localTemplate, setLocalTemplate] = useState(nodeData.template); const [localTemplate, setLocalTemplate] = useState(nodeData.template);
@ -60,13 +59,16 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
} }
}, [nodeData.template, isEditing]); }, [nodeData.template, isEditing]);
// Get available variables from connected prompt nodes (named variables + inline <var> tags) // Get available variables from connected prompt nodes (named variables + inline
const availableVariables = useMemo((): AvailableVariable[] => { // <var> tags). Computed inside the store selector and returned as a JSON string
const directTextNodes = edges // so the subscription is Object.is-stable during drag (variables depend on
// upstream text data, not node positions) — no re-render on unrelated drags.
const availableVariablesJson = useWorkflowStore((state) => {
const directTextNodes = state.edges
.filter((e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID) .filter((e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID)
.map((e) => nodes.find((n) => n.id === e.source)) .map((e) => selectNodeById(state.nodes, e.source))
.filter((n): n is typeof nodes[0] => n !== undefined && getNodeOutputMediaType(n) === "text"); .filter((n): n is NonNullable<typeof n> => n !== undefined && getNodeOutputMediaType(n) === "text");
const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, nodes, edges); const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, state.nodes, state.edges);
const vars: AvailableVariable[] = []; const vars: AvailableVariable[] = [];
const usedNames = new Set<string>(); const usedNames = new Set<string>();
@ -113,8 +115,13 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
} }
}); });
return vars; return JSON.stringify(vars);
}, [edges, nodes, id]); });
const availableVariables = useMemo(
(): AvailableVariable[] => JSON.parse(availableVariablesJson) as AvailableVariable[],
[availableVariablesJson]
);
// Autocomplete via shared hook // Autocomplete via shared hook
const { const {

20
src/components/nodes/VideoFrameGrabNode.tsx

@ -1,10 +1,11 @@
"use client"; "use client";
import React, { useMemo } from "react"; import React from "react";
import { Position, NodeProps, Node } from "@xyflow/react"; import { Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode"; import { BaseNode } from "./BaseNode";
import { NodeHandle } from "./NodeHandle"; import { NodeHandle } from "./NodeHandle";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { selectNodeById } from "@/store/utils/nodesById";
import { VideoFrameGrabNodeData } from "@/types"; import { VideoFrameGrabNodeData } from "@/types";
import { usePreviewImageUrl } from "@/hooks/usePreviewImageUrl"; import { usePreviewImageUrl } from "@/hooks/usePreviewImageUrl";
import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles"; import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
@ -17,20 +18,19 @@ export function VideoFrameGrabNode({ id, data, selected }: NodeProps<VideoFrameG
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id)); const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id));
const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
// Find connected source video from incoming edges // Subscribe only to the derived upstream video URL (a string), not the whole
const sourceVideoUrl = useMemo(() => { // nodes/edges arrays — so dragging unrelated nodes doesn't re-render this node.
const incomingEdge = edges.find((e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID); const sourceVideoUrl = useWorkflowStore((state) => {
const incomingEdge = state.edges.find(
(e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID
);
if (!incomingEdge) return null; if (!incomingEdge) return null;
const sourceNode = selectNodeById(state.nodes, incomingEdge.source);
const sourceNode = nodes.find((n) => n.id === incomingEdge.source);
if (!sourceNode) return null; if (!sourceNode) return null;
const d = sourceNode.data as Record<string, unknown>; const d = sourceNode.data as Record<string, unknown>;
return (d.outputVideo as string | null) ?? null; return (d.outputVideo as string | null) ?? null;
}, [edges, nodes, id]); });
const hasSourceVideo = Boolean(sourceVideoUrl); const hasSourceVideo = Boolean(sourceVideoUrl);
const canExtract = hasSourceVideo && nodeData.status !== "loading" && !isRunning; const canExtract = hasSourceVideo && nodeData.status !== "loading" && !isRunning;

44
src/components/nodes/VideoStitchNode.tsx

@ -5,6 +5,8 @@ import { Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode"; import { BaseNode } from "./BaseNode";
import { NodeHandle } from "./NodeHandle"; import { NodeHandle } from "./NodeHandle";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { useShallow } from "zustand/shallow";
import { selectNodeById } from "@/store/utils/nodesById";
import { VideoStitchNodeData } from "@/types"; import { VideoStitchNodeData } from "@/types";
import { checkEncoderSupport } from "@/hooks/useStitchVideos"; import { checkEncoderSupport } from "@/hooks/useStitchVideos";
import { useLazyNodeVideoSrc } from "@/hooks/useLazyNodeVideoSrc"; import { useLazyNodeVideoSrc } from "@/hooks/useLazyNodeVideoSrc";
@ -64,7 +66,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
const nodeData = data; const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const edges = useWorkflowStore((state) => state.edges); const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id)); const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id));
const removeEdge = useWorkflowStore((state) => state.removeEdge); const removeEdge = useWorkflowStore((state) => state.removeEdge);
@ -120,25 +121,38 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
} }
}, [videoEdges, nodeData.clipOrder, id, updateNodeData]); }, [videoEdges, nodeData.clipOrder, id, updateNodeData]);
// Subscribe only to a stable map of edgeId -> upstream video URL (strings),
// compared with useShallow, so dragging unrelated nodes doesn't re-render this
// node. Only source nodes that produce a stitchable video contribute a URL.
const sourceVideoByEdge = useWorkflowStore(
useShallow((state) => {
const result: Record<string, string | null> = {};
for (const edge of state.edges) {
if (edge.target !== id || edge.targetHandle !== SINGLE_INPUT_HANDLE_ID) continue;
const sourceNode = selectNodeById(state.nodes, edge.source);
if (!sourceNode) continue;
const type = sourceNode.type;
result[edge.id] =
type === "generateVideo" || type === "easeCurve" || type === "videoStitch" || type === "videoTrim"
? ((sourceNode.data as Record<string, unknown>).outputVideo as string | null) || null
: null;
}
return result;
})
);
// Get ordered clips based on clipOrder or connection order // Get ordered clips based on clipOrder or connection order
const orderedClips = useMemo(() => { const orderedClips = useMemo(() => {
const clipMap = new Map<string, { edge: any; sourceNode: any; videoData: string | null; duration: number | null }>(); const clipMap = new Map<string, { edge: any; videoData: string | null; duration: number | null }>();
videoEdges.forEach((edge) => { videoEdges.forEach((edge) => {
const sourceNode = nodes.find((n) => n.id === edge.source); // Skip edges whose source isn't a stitchable video producer.
if (!sourceNode) return; if (!(edge.id in sourceVideoByEdge)) return;
const videoData = sourceVideoByEdge[edge.id];
let videoData: string | null = null; clipMap.set(edge.id, { edge, videoData, duration: null });
let duration: number | null = null;
if (sourceNode.type === "generateVideo" || sourceNode.type === "easeCurve" || sourceNode.type === "videoStitch" || sourceNode.type === "videoTrim") {
videoData = (sourceNode.data as any).outputVideo || null;
}
clipMap.set(edge.id, { edge, sourceNode, videoData, duration });
}); });
let ordered: Array<{ edgeId: string; edge: any; sourceNode: any; videoData: string | null; duration: number | null }>; let ordered: Array<{ edgeId: string; edge: any; videoData: string | null; duration: number | null }>;
if (nodeData.clipOrder && nodeData.clipOrder.length > 0) { if (nodeData.clipOrder && nodeData.clipOrder.length > 0) {
ordered = nodeData.clipOrder ordered = nodeData.clipOrder
@ -174,7 +188,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
} }
return ordered; return ordered;
}, [videoEdges, nodes, nodeData.clipOrder]); }, [videoEdges, sourceVideoByEdge, nodeData.clipOrder]);
// Pointer-based drag reorder (HTML5 drag doesn't work inside React Flow nodes) // Pointer-based drag reorder (HTML5 drag doesn't work inside React Flow nodes)
const [draggedClipId, setDraggedClipId] = useState<string | null>(null); const [draggedClipId, setDraggedClipId] = useState<string | null>(null);

10
src/components/nodes/VideoTrimNode.tsx

@ -5,6 +5,7 @@ import { Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode"; import { BaseNode } from "./BaseNode";
import { NodeHandle } from "./NodeHandle"; import { NodeHandle } from "./NodeHandle";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { selectNodeById } from "@/store/utils/nodesById";
import { VideoTrimNodeData } from "@/types"; import { VideoTrimNodeData } from "@/types";
import { checkEncoderSupport } from "@/hooks/useStitchVideos"; import { checkEncoderSupport } from "@/hooks/useStitchVideos";
import { useLazyNodeVideoSrc } from "@/hooks/useLazyNodeVideoSrc"; import { useLazyNodeVideoSrc } from "@/hooks/useLazyNodeVideoSrc";
@ -30,7 +31,6 @@ export function VideoTrimNode({ id, data, selected }: NodeProps<VideoTrimNodeTyp
const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id)); const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id));
const edges = useWorkflowStore((state) => state.edges); const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
// Track whether user wants to see source or output video // Track whether user wants to see source or output video
const [showOutput, setShowOutput] = useState(false); const [showOutput, setShowOutput] = useState(false);
@ -54,17 +54,17 @@ export function VideoTrimNode({ id, data, selected }: NodeProps<VideoTrimNodeTyp
() => edges.some((e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID), () => edges.some((e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID),
[edges, id] [edges, id]
); );
const sourceVideoUrl = useMemo(() => { const sourceVideoUrl = useWorkflowStore((state) => {
const incomingEdge = edges.find((e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID); const incomingEdge = state.edges.find((e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID);
if (!incomingEdge) return null; if (!incomingEdge) return null;
const sourceNode = nodes.find((n) => n.id === incomingEdge.source); const sourceNode = selectNodeById(state.nodes, incomingEdge.source);
if (!sourceNode) return null; if (!sourceNode) return null;
const d = sourceNode.data as Record<string, unknown>; const d = sourceNode.data as Record<string, unknown>;
// Support common video output/input fields from generateVideo, videoStitch, easeCurve, videoTrim, and videoInput. // Support common video output/input fields from generateVideo, videoStitch, easeCurve, videoTrim, and videoInput.
return (d.outputVideo as string | null) ?? (d.video as string | null) ?? null; return (d.outputVideo as string | null) ?? (d.video as string | null) ?? null;
}, [edges, nodes, id]); });
// When source video changes, load metadata to detect duration // When source video changes, load metadata to detect duration
useEffect(() => { useEffect(() => {

15
src/components/workflowTypes.tsx

@ -1,6 +1,7 @@
"use client"; "use client";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { memo } from "react";
import type { EdgeTypes, NodeTypes } from "@xyflow/react"; import type { EdgeTypes, NodeTypes } from "@xyflow/react";
import { import {
@ -44,20 +45,20 @@ export const workflowNodeTypes: NodeTypes = {
batchConnection: BatchConnectionNode, batchConnection: BatchConnectionNode,
group: GroupNode, group: GroupNode,
imageInput: ImageInputNode, imageInput: ImageInputNode,
smartImage: SmartImageNode, smartImage: memo(SmartImageNode),
smartVideo: SmartVideoNode, smartVideo: memo(SmartVideoNode),
smartAudio: SmartAudioNode, smartAudio: memo(SmartAudioNode),
audioInput: AudioInputNode, audioInput: AudioInputNode,
videoInput: VideoInputNode, videoInput: VideoInputNode,
annotation: AnnotationNode, annotation: AnnotationNode,
prompt: PromptNode, prompt: PromptNode,
smartText: SmartTextNode, smartText: memo(SmartTextNode),
array: ArrayNode, array: ArrayNode,
promptConstructor: PromptConstructorNode, promptConstructor: PromptConstructorNode,
nanoBanana: GenerateImageNode, nanoBanana: memo(GenerateImageNode),
generateVideo: GenerateVideoNode, generateVideo: memo(GenerateVideoNode),
generate3d: Generate3DNode, generate3d: Generate3DNode,
generateAudio: GenerateAudioNode, generateAudio: memo(GenerateAudioNode),
llmGenerate: LLMGenerateNode, llmGenerate: LLMGenerateNode,
output: OutputNode, output: OutputNode,
outputGallery: OutputGalleryNode, outputGallery: OutputGalleryNode,

42
src/store/utils/nodesById.ts

@ -0,0 +1,42 @@
import type { WorkflowNode } from "@/types";
/**
* Cache an id -> node lookup map keyed on the exact `nodes` array reference.
*
* During a drag React Flow replaces the `nodes` array reference roughly once
* per frame. Previously every node component ran `nodes.find(...)` (O(N)) in its
* store selector, so a single drag frame cost O(N^2) across all nodes. By caching
* the map against the array reference, the map is built at most once per unique
* array (once per frame), and each per-node lookup becomes O(1).
*
* A WeakMap is used so the cache entry is released as soon as the old `nodes`
* array is garbage collected no manual invalidation needed.
*/
const nodeMapCache = new WeakMap<readonly WorkflowNode[], Map<string, WorkflowNode>>();
export function getNodesByIdMap(
nodes: readonly WorkflowNode[]
): Map<string, WorkflowNode> {
let map = nodeMapCache.get(nodes);
if (!map) {
map = new Map<string, WorkflowNode>();
for (const node of nodes) {
map.set(node.id, node);
}
nodeMapCache.set(nodes, map);
}
return map;
}
/**
* Look up a single node by id using the cached map. Returns the same node
* reference across calls when the underlying node is unchanged, so it is safe
* to use directly inside a Zustand selector (default `Object.is` comparison
* skips re-render when the node hasn't changed).
*/
export function selectNodeById(
nodes: readonly WorkflowNode[],
id: string
): WorkflowNode | undefined {
return getNodesByIdMap(nodes).get(id);
}

42
src/store/workflowStore.ts

@ -60,6 +60,7 @@ import {
} from "./utils/nodeDefaults"; } from "./utils/nodeDefaults";
import { createDefaultImageModel } from "./utils/defaultImageModel"; import { createDefaultImageModel } from "./utils/defaultImageModel";
import { remapNodeDataIds } from "./utils/remapWorkflowIds"; import { remapNodeDataIds } from "./utils/remapWorkflowIds";
import { selectNodeById } from "./utils/nodesById";
import { isHighDefinitionDerivedImageNode } from "@/utils/highDefinitionNodes"; import { isHighDefinitionDerivedImageNode } from "@/utils/highDefinitionNodes";
import { isHighDefinitionDerivedVideoNode } from "@/utils/highDefinitionVideoNodes"; import { isHighDefinitionDerivedVideoNode } from "@/utils/highDefinitionVideoNodes";
import { isVideoBoxEraseDerivedNode, isVideoSmartEraseDerivedNode } from "@/utils/videoEraseNodes"; import { isVideoBoxEraseDerivedNode, isVideoSmartEraseDerivedNode } from "@/utils/videoEraseNodes";
@ -2105,23 +2106,28 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
return groupsDraft; return groupsDraft;
}, state.groups); }, state.groups);
nextNodes = nextNodes.map((node) => { // Only reconcile group-node styles when group sizes actually changed this
if (node.type !== "group") return node; // call. For the common case (dragging a regular node) `nextGroups` keeps
const groupId = (node.data as { groupId?: string }).groupId; // its reference, so we skip a full-array allocation every frame.
const group = groupId ? nextGroups[groupId] : undefined; if (nextGroups !== state.groups) {
if (!groupId || !group) return node; nextNodes = nextNodes.map((node) => {
const width = node.style?.width as number | undefined; if (node.type !== "group") return node;
const height = node.style?.height as number | undefined; const groupId = (node.data as { groupId?: string }).groupId;
if (width === group.size.width && height === group.size.height) return node; const group = groupId ? nextGroups[groupId] : undefined;
return { if (!groupId || !group) return node;
...node, const width = node.style?.width as number | undefined;
style: { const height = node.style?.height as number | undefined;
...node.style, if (width === group.size.width && height === group.size.height) return node;
width: group.size.width, return {
height: group.size.height, ...node,
}, style: {
} as WorkflowNode; ...node.style,
}); width: group.size.width,
height: group.size.height,
},
} as WorkflowNode;
});
}
if (removedGroupIds.size > 0) { if (removedGroupIds.size > 0) {
const originalNodeById = new Map(state.nodes.map((node) => [node.id, node])); const originalNodeById = new Map(state.nodes.map((node) => [node.id, node]));
@ -2750,7 +2756,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
}, },
getNodeById: (id: string) => { getNodeById: (id: string) => {
return get().nodes.find((node) => node.id === id); return selectNodeById(get().nodes, id);
}, },
getConnectedInputs: (nodeId: string) => { getConnectedInputs: (nodeId: string) => {

Loading…
Cancel
Save