Browse Source

新增画布优化

feature/video-erase
Luckyu_js 1 week 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");
let disposed = false;
let unsubscribe: (() => void) | undefined;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const postToParent = (type: string, payload?: unknown, requestId?: string) => {
window.parent.postMessage(
@ -324,6 +325,18 @@ export function PopiaiEmbedBridge() {
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>) => {
if (event.origin !== allowedOrigin || event.data?.source !== SOURCE_POPIAI) return;
@ -386,14 +399,18 @@ export function PopiaiEmbedBridge() {
postToParent("popitv:ready", buildSnapshot(sessionId));
unsubscribe = useWorkflowStore.subscribe(() => {
if (sessionId) saveSessionWorkflow(sessionId);
postSnapshot();
schedulePersist();
});
})();
return () => {
disposed = true;
unsubscribe?.();
if (saveTimer) {
clearTimeout(saveTimer);
// Flush any pending write so the last change isn't lost on unmount.
flushPersist();
}
window.removeEventListener("message", handleMessage);
};
}, [isEmbed]);

68
src/components/WorkflowCanvas.tsx

@ -217,16 +217,56 @@ function getWorkflowNodeDimensions(node: Node): { width: number; height: number
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 {
const cached = reactFlowNodeDimensionsCache.get(node);
if (cached) return cached as T;
const dimensions = getWorkflowNodeStyleDimensions(node);
if (!dimensions) return node;
return {
// No style dimensions, or the node already carries the right width/height:
// 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,
width: dimensions.width,
height: dimensions.height,
initialWidth: dimensions.width,
initialHeight: dimensions.height,
};
reactFlowNodeDimensionsCache.set(node, decorated);
return decorated;
}
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);
}
// Only create new node object if className changed
if (node.className === newClass && node.zIndex === zIndex) return node;
return { ...node, className: newClass, zIndex };
// Return a reference-stable decorated node. Keyed on the raw store node,
// so an unchanged node yields the identical object across frames and React
// 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]);
@ -1706,8 +1757,11 @@ export function WorkflowCanvas() {
}
}, [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(() => {
if (!canvasNavigationSettings.assistantEnabled) return undefined;
const strippedNodes = stripBinaryData(nodes);
return {
nodes: strippedNodes.map(n => ({
@ -1724,7 +1778,7 @@ export function WorkflowCanvas() {
targetHandle: e.targetHandle || undefined,
})),
};
}, [nodes, edges]);
}, [nodes, edges, canvasNavigationSettings.assistantEnabled]);
// Compute selected node IDs for chat context scoping
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 { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
import { getConnectedInputsPure } from "@/store/utils/connectedInputs";
import { selectNodeById } from "@/store/utils/nodesById";
import { parseTextToArray } from "@/utils/arrayParser";
import { useI18n } from "@/i18n";
import { defaultNodeDimensions } from "@/constants/nodeDimensions";
@ -39,15 +40,14 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
const removeNode = useWorkflowStore((state) => state.removeNode);
const addNode = useWorkflowStore((state) => state.addNode);
const onConnect = useWorkflowStore((state) => state.onConnect);
const nodes = useWorkflowStore((state) => state.nodes);
const edges = useWorkflowStore((state) => state.edges);
// Derive nodeData from the Zustand store (already subscribed via `nodes`)
// rather than React Flow props, so settings changes are reflected immediately.
const nodeData = useMemo(() => {
const n = nodes.find((nd) => nd.id === id);
return (n?.data as ArrayNodeData) ?? data;
}, [nodes, id, data]);
// Derive this node's data from the store (source of truth) via an O(1) indexed
// lookup so settings changes reflect immediately, without subscribing to the
// whole nodes array (which would re-render this node on every drag frame).
const nodeData = useWorkflowStore(
(state) => (selectNodeById(state.nodes, id)?.data as ArrayNodeData | undefined) ?? data
);
const { setNodes, getNodes } = useReactFlow();
const lastSyncedInputRef = useRef<string | null>(null);
const lastDerivedWriteRef = useRef<string | null>(null);
@ -63,10 +63,18 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
[edges, id]
);
const connectedText = useMemo(() => {
if (!hasIncomingTextConnection) return null;
return getConnectedInputsPure(id, nodes, edges).text;
}, [edges, hasIncomingTextConnection, id, nodes]);
const connectedText = useWorkflowStore((state) => {
if (
!state.edges.some((edge) => {
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.
useEffect(() => {

38
src/components/nodes/BaseNode.tsx

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

69
src/components/nodes/ImageCompareNode.tsx

@ -1,6 +1,5 @@
"use client";
import { useMemo } from "react";
import { Position, NodeProps, Node } from "@xyflow/react";
import {
ReactCompareSlider,
@ -9,6 +8,8 @@ import {
import { BaseNode } from "./BaseNode";
import { NodeHandle } from "./NodeHandle";
import { useWorkflowStore } from "@/store/workflowStore";
import { useShallow } from "zustand/shallow";
import { selectNodeById } from "@/store/utils/nodesById";
import { ImageCompareNodeData } from "@/types";
import { useI18n } from "@/i18n";
import { getSourceOutput } from "@/store/utils/connectedInputs";
@ -24,44 +25,46 @@ export function ImageCompareNode({
const nodeData = data;
const { t } = useI18n();
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)
const displayImages = useMemo(() => {
const connectedImages: Array<string | null> = [null, null];
const appendImage = (image: string) => {
const index = connectedImages.findIndex((item) => !item);
if (index !== -1) {
connectedImages[index] = image;
}
};
// Collect images in real-time from connected nodes (same pattern as OutputGalleryNode).
// Computed inside the selector and compared with useShallow so this node only
// re-renders when the derived image URLs change — not on every drag frame.
const displayImages = useWorkflowStore(
useShallow((state) => {
const connectedImages: Array<string | null> = [null, null];
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
const sortedEdges = edges
.filter((edge) => edge.target === id)
.sort((a, b) => {
const aTime = (a.data?.createdAt as number) || 0;
const bTime = (b.data?.createdAt as number) || 0;
return aTime - bTime;
});
// Get edges connected to this node, sorted by creation time for stable ordering
const sortedEdges = state.edges
.filter((edge) => edge.target === id)
.sort((a, b) => {
const aTime = (a.data?.createdAt as number) || 0;
const bTime = (b.data?.createdAt as number) || 0;
return aTime - bTime;
});
sortedEdges.forEach((edge) => {
const sourceNode = nodes.find((n) => n.id === edge.source);
if (!sourceNode) return;
sortedEdges.forEach((edge) => {
const sourceNode = selectNodeById(state.nodes, edge.source);
if (!sourceNode) return;
const output = getSourceOutput(
sourceNode,
edge.sourceHandle,
edge.data as Record<string, unknown> | undefined,
edges
);
const output = getSourceOutput(
sourceNode,
edge.sourceHandle,
edge.data as Record<string, unknown> | undefined,
state.edges
);
if (output.type === "image" && output.value) appendImage(output.value);
});
if (output.type === "image" && output.value) appendImage(output.value);
});
return connectedImages;
}, [edges, nodes, id]);
return connectedImages;
})
);
const imageA = displayImages[0] || nodeData.imageA || 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 { PromptConstructorNodeData, PromptNodeData, LLMGenerateNodeData, AvailableVariable } from "@/types";
import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs";
import { selectNodeById } from "@/store/utils/nodesById";
import { parseVarTags } from "@/utils/parseVarTags";
import { useI18n } from "@/i18n";
import { defaultNodeDimensions } from "@/constants/nodeDimensions";
@ -43,8 +44,6 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const removeNode = useWorkflowStore((state) => state.removeNode);
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
const [localTemplate, setLocalTemplate] = useState(nodeData.template);
@ -60,13 +59,16 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
}
}, [nodeData.template, isEditing]);
// Get available variables from connected prompt nodes (named variables + inline <var> tags)
const availableVariables = useMemo((): AvailableVariable[] => {
const directTextNodes = edges
// Get available variables from connected prompt nodes (named variables + inline
// <var> tags). Computed inside the store selector and returned as a JSON string
// 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)
.map((e) => nodes.find((n) => n.id === e.source))
.filter((n): n is typeof nodes[0] => n !== undefined && getNodeOutputMediaType(n) === "text");
const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, nodes, edges);
.map((e) => selectNodeById(state.nodes, e.source))
.filter((n): n is NonNullable<typeof n> => n !== undefined && getNodeOutputMediaType(n) === "text");
const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, state.nodes, state.edges);
const vars: AvailableVariable[] = [];
const usedNames = new Set<string>();
@ -113,8 +115,13 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
}
});
return vars;
}, [edges, nodes, id]);
return JSON.stringify(vars);
});
const availableVariables = useMemo(
(): AvailableVariable[] => JSON.parse(availableVariablesJson) as AvailableVariable[],
[availableVariablesJson]
);
// Autocomplete via shared hook
const {

20
src/components/nodes/VideoFrameGrabNode.tsx

@ -1,10 +1,11 @@
"use client";
import React, { useMemo } from "react";
import React from "react";
import { Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { NodeHandle } from "./NodeHandle";
import { useWorkflowStore } from "@/store/workflowStore";
import { selectNodeById } from "@/store/utils/nodesById";
import { VideoFrameGrabNodeData } from "@/types";
import { usePreviewImageUrl } from "@/hooks/usePreviewImageUrl";
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 regenerateNode = useWorkflowStore((state) => state.regenerateNode);
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
const sourceVideoUrl = useMemo(() => {
const incomingEdge = edges.find((e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID);
// Subscribe only to the derived upstream video URL (a string), not the whole
// nodes/edges arrays — so dragging unrelated nodes doesn't re-render this node.
const sourceVideoUrl = useWorkflowStore((state) => {
const incomingEdge = state.edges.find(
(e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID
);
if (!incomingEdge) return null;
const sourceNode = nodes.find((n) => n.id === incomingEdge.source);
const sourceNode = selectNodeById(state.nodes, incomingEdge.source);
if (!sourceNode) return null;
const d = sourceNode.data as Record<string, unknown>;
return (d.outputVideo as string | null) ?? null;
}, [edges, nodes, id]);
});
const hasSourceVideo = Boolean(sourceVideoUrl);
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 { NodeHandle } from "./NodeHandle";
import { useWorkflowStore } from "@/store/workflowStore";
import { useShallow } from "zustand/shallow";
import { selectNodeById } from "@/store/utils/nodesById";
import { VideoStitchNodeData } from "@/types";
import { checkEncoderSupport } from "@/hooks/useStitchVideos";
import { useLazyNodeVideoSrc } from "@/hooks/useLazyNodeVideoSrc";
@ -64,7 +66,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id));
const removeEdge = useWorkflowStore((state) => state.removeEdge);
@ -120,25 +121,38 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
}
}, [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
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) => {
const sourceNode = nodes.find((n) => n.id === edge.source);
if (!sourceNode) return;
let videoData: string | null = 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 });
// Skip edges whose source isn't a stitchable video producer.
if (!(edge.id in sourceVideoByEdge)) return;
const videoData = sourceVideoByEdge[edge.id];
clipMap.set(edge.id, { edge, videoData, duration: null });
});
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) {
ordered = nodeData.clipOrder
@ -174,7 +188,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
}
return ordered;
}, [videoEdges, nodes, nodeData.clipOrder]);
}, [videoEdges, sourceVideoByEdge, nodeData.clipOrder]);
// Pointer-based drag reorder (HTML5 drag doesn't work inside React Flow nodes)
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 { NodeHandle } from "./NodeHandle";
import { useWorkflowStore } from "@/store/workflowStore";
import { selectNodeById } from "@/store/utils/nodesById";
import { VideoTrimNodeData } from "@/types";
import { checkEncoderSupport } from "@/hooks/useStitchVideos";
import { useLazyNodeVideoSrc } from "@/hooks/useLazyNodeVideoSrc";
@ -30,7 +31,6 @@ export function VideoTrimNode({ id, data, selected }: NodeProps<VideoTrimNodeTyp
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id));
const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
// Track whether user wants to see source or output video
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, id]
);
const sourceVideoUrl = useMemo(() => {
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;
const sourceNode = nodes.find((n) => n.id === incomingEdge.source);
const sourceNode = selectNodeById(state.nodes, incomingEdge.source);
if (!sourceNode) return null;
const d = sourceNode.data as Record<string, unknown>;
// 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;
}, [edges, nodes, id]);
});
// When source video changes, load metadata to detect duration
useEffect(() => {

15
src/components/workflowTypes.tsx

@ -1,6 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { memo } from "react";
import type { EdgeTypes, NodeTypes } from "@xyflow/react";
import {
@ -44,20 +45,20 @@ export const workflowNodeTypes: NodeTypes = {
batchConnection: BatchConnectionNode,
group: GroupNode,
imageInput: ImageInputNode,
smartImage: SmartImageNode,
smartVideo: SmartVideoNode,
smartAudio: SmartAudioNode,
smartImage: memo(SmartImageNode),
smartVideo: memo(SmartVideoNode),
smartAudio: memo(SmartAudioNode),
audioInput: AudioInputNode,
videoInput: VideoInputNode,
annotation: AnnotationNode,
prompt: PromptNode,
smartText: SmartTextNode,
smartText: memo(SmartTextNode),
array: ArrayNode,
promptConstructor: PromptConstructorNode,
nanoBanana: GenerateImageNode,
generateVideo: GenerateVideoNode,
nanoBanana: memo(GenerateImageNode),
generateVideo: memo(GenerateVideoNode),
generate3d: Generate3DNode,
generateAudio: GenerateAudioNode,
generateAudio: memo(GenerateAudioNode),
llmGenerate: LLMGenerateNode,
output: OutputNode,
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";
import { createDefaultImageModel } from "./utils/defaultImageModel";
import { remapNodeDataIds } from "./utils/remapWorkflowIds";
import { selectNodeById } from "./utils/nodesById";
import { isHighDefinitionDerivedImageNode } from "@/utils/highDefinitionNodes";
import { isHighDefinitionDerivedVideoNode } from "@/utils/highDefinitionVideoNodes";
import { isVideoBoxEraseDerivedNode, isVideoSmartEraseDerivedNode } from "@/utils/videoEraseNodes";
@ -2105,23 +2106,28 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
return groupsDraft;
}, state.groups);
nextNodes = nextNodes.map((node) => {
if (node.type !== "group") return node;
const groupId = (node.data as { groupId?: string }).groupId;
const group = groupId ? nextGroups[groupId] : undefined;
if (!groupId || !group) return node;
const width = node.style?.width as number | undefined;
const height = node.style?.height as number | undefined;
if (width === group.size.width && height === group.size.height) return node;
return {
...node,
style: {
...node.style,
width: group.size.width,
height: group.size.height,
},
} as WorkflowNode;
});
// Only reconcile group-node styles when group sizes actually changed this
// call. For the common case (dragging a regular node) `nextGroups` keeps
// its reference, so we skip a full-array allocation every frame.
if (nextGroups !== state.groups) {
nextNodes = nextNodes.map((node) => {
if (node.type !== "group") return node;
const groupId = (node.data as { groupId?: string }).groupId;
const group = groupId ? nextGroups[groupId] : undefined;
if (!groupId || !group) return node;
const width = node.style?.width as number | undefined;
const height = node.style?.height as number | undefined;
if (width === group.size.width && height === group.size.height) return node;
return {
...node,
style: {
...node.style,
width: group.size.width,
height: group.size.height,
},
} as WorkflowNode;
});
}
if (removedGroupIds.size > 0) {
const originalNodeById = new Map(state.nodes.map((node) => [node.id, node]));
@ -2750,7 +2756,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
},
getNodeById: (id: string) => {
return get().nodes.find((node) => node.id === id);
return selectNodeById(get().nodes, id);
},
getConnectedInputs: (nodeId: string) => {

Loading…
Cancel
Save