Browse Source

fix: stabilize VideoStitchNode performance and add WaveSpeed provider badge

- Stabilize thumbnail extraction deps with a string key derived from clip
  edge IDs, preventing excessive re-runs on unrelated node changes
- Use ref-based cache for thumbnails and add cleanup abort for pending
  extractions
- Fix clipOrder sync triggering extra render by tracking last written order
- Add WaveSpeed case to ProviderBadge in GenerateImageNode and
  GenerateVideoNode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
a2fe2a310e
  1. 6
      src/components/nodes/GenerateImageNode.tsx
  2. 6
      src/components/nodes/GenerateVideoNode.tsx
  3. 38
      src/components/nodes/VideoStitchNode.tsx

6
src/components/nodes/GenerateImageNode.tsx

@ -15,7 +15,7 @@ import { getImageDimensions, calculateNodeSizePreservingHeight } from "@/utils/n
// Provider badge component - shows provider icon for all providers
function ProviderBadge({ provider }: { provider: ProviderType }) {
const providerName = provider === "gemini" ? "Gemini" : provider === "replicate" ? "Replicate" : provider === "kie" ? "Kie.ai" : "fal.ai";
const providerName = provider === "gemini" ? "Gemini" : provider === "replicate" ? "Replicate" : provider === "kie" ? "Kie.ai" : provider === "wavespeed" ? "WaveSpeed" : "fal.ai";
return (
<span className="text-neutral-500 shrink-0" title={providerName}>
@ -33,6 +33,10 @@ function ProviderBadge({ provider }: { provider: ProviderType }) {
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
) : provider === "wavespeed" ? (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12c0-1.1.4-2.1 1-2.9L7 12l-3 2.9C3.4 14.1 3 13.1 3 12zm4.5-7.5C8.7 3.6 10.3 3 12 3s3.3.6 4.5 1.5L12 9 7.5 4.5zM21 12c0 1.1-.4 2.1-1 2.9L17 12l3-2.9c.6.8 1 1.8 1 2.9zm-4.5 7.5C15.3 20.4 13.7 21 12 21s-3.3-.6-4.5-1.5L12 15l4.5 4.5z"/>
</svg>
) : (
<svg className="w-4 h-4" viewBox="0 0 1855 1855" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M1181.65 78C1212.05 78 1236.42 101.947 1239.32 131.261C1265.25 392.744 1480.07 600.836 1750.02 625.948C1780.28 628.764 1805 652.366 1805 681.816V1174.18C1805 1203.63 1780.28 1227.24 1750.02 1230.05C1480.07 1255.16 1265.25 1463.26 1239.32 1724.74C1236.42 1754.05 1212.05 1778 1181.65 1778H673.354C642.951 1778 618.585 1754.05 615.678 1724.74C589.754 1463.26 374.927 1255.16 104.984 1230.05C74.7212 1227.24 50 1203.63 50 1174.18V681.816C50 652.366 74.7213 628.764 104.984 625.948C374.927 600.836 589.754 392.744 615.678 131.261C618.585 101.946 642.951 78 673.353 78H1181.65ZM402.377 926.561C402.377 1209.41 638.826 1438.71 930.501 1438.71C1222.18 1438.71 1458.63 1209.41 1458.63 926.561C1458.63 643.709 1222.18 414.412 930.501 414.412C638.826 414.412 402.377 643.709 402.377 926.561Z" />

6
src/components/nodes/GenerateVideoNode.tsx

@ -15,7 +15,7 @@ import { getVideoDimensions, calculateNodeSizePreservingHeight } from "@/utils/n
// Provider badge component - shows provider icon for all providers
function ProviderBadge({ provider }: { provider: ProviderType }) {
const providerName = provider === "gemini" ? "Gemini" : provider === "replicate" ? "Replicate" : provider === "kie" ? "Kie.ai" : "fal.ai";
const providerName = provider === "gemini" ? "Gemini" : provider === "replicate" ? "Replicate" : provider === "kie" ? "Kie.ai" : provider === "wavespeed" ? "WaveSpeed" : "fal.ai";
return (
<span className="text-neutral-500 shrink-0" title={providerName}>
@ -33,6 +33,10 @@ function ProviderBadge({ provider }: { provider: ProviderType }) {
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
) : provider === "wavespeed" ? (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12c0-1.1.4-2.1 1-2.9L7 12l-3 2.9C3.4 14.1 3 13.1 3 12zm4.5-7.5C8.7 3.6 10.3 3 12 3s3.3.6 4.5 1.5L12 9 7.5 4.5zM21 12c0 1.1-.4 2.1-1 2.9L17 12l3-2.9c.6.8 1 1.8 1 2.9zm-4.5 7.5C15.3 20.4 13.7 21 12 21s-3.3-.6-4.5-1.5L12 15l4.5 4.5z"/>
</svg>
) : (
<svg className="w-4 h-4" viewBox="0 0 1855 1855" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M1181.65 78C1212.05 78 1236.42 101.947 1239.32 131.261C1265.25 392.744 1480.07 600.836 1750.02 625.948C1780.28 628.764 1805 652.366 1805 681.816V1174.18C1805 1203.63 1780.28 1227.24 1750.02 1230.05C1480.07 1255.16 1265.25 1463.26 1239.32 1724.74C1236.42 1754.05 1212.05 1778 1181.65 1778H673.354C642.951 1778 618.585 1754.05 615.678 1724.74C589.754 1463.26 374.927 1255.16 104.984 1230.05C74.7212 1227.24 50 1203.63 50 1174.18V681.816C50 652.366 74.7213 628.764 104.984 625.948C374.927 600.836 589.754 392.744 615.678 131.261C618.585 101.946 642.951 78 673.353 78H1181.65ZM402.377 926.561C402.377 1209.41 638.826 1438.71 930.501 1438.71C1222.18 1438.71 1458.63 1209.41 1458.63 926.561C1458.63 643.709 1222.18 414.412 930.501 414.412C638.826 414.412 402.377 643.709 402.377 926.561Z" />

38
src/components/nodes/VideoStitchNode.tsx

@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useCommentNavigation } from "@/hooks/useCommentNavigation";
@ -38,6 +38,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
}, [edges, id]);
// Sync clipOrder with connected edges (side effect, must be in useEffect)
const lastWrittenClipOrderRef = useRef<string[]>([]);
useEffect(() => {
const currentEdgeIds = videoEdges.map((e) => e.id);
const currentOrder = nodeData.clipOrder || [];
@ -47,10 +48,19 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
const newEdges = currentEdgeIds.filter((eid) => !currentOrder.includes(eid));
const newOrder = [...validExisting, ...newEdges];
// Skip if we just wrote this exact order (prevents extra render cycle)
if (
newOrder.length === lastWrittenClipOrderRef.current.length &&
newOrder.every((eid, idx) => eid === lastWrittenClipOrderRef.current[idx])
) {
return;
}
if (
newOrder.length !== currentOrder.length ||
!newOrder.every((eid, idx) => eid === currentOrder[idx])
) {
lastWrittenClipOrderRef.current = newOrder;
updateNodeData(id, { clipOrder: newOrder });
}
}, [videoEdges, nodeData.clipOrder, id, updateNodeData]);
@ -111,15 +121,27 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
return ordered;
}, [videoEdges, nodes, nodeData.clipOrder]);
// Stable key that only changes when clip edges or video data actually change
const clipKey = useMemo(
() => orderedClips.map((c) => `${c.edgeId}:${c.videoData ? "1" : "0"}`).join(","),
[orderedClips]
);
// Ref-based cache so the effect doesn't read stale `thumbnails` state
const thumbnailsRef = useRef<Map<string, string>>(new Map());
// Extract thumbnails from connected videos
useEffect(() => {
let cancelled = false;
const extractThumbnails = async () => {
const newThumbnails = new Map<string, string>();
for (const clip of orderedClips) {
if (cancelled) return;
if (!clip.videoData) continue;
if (thumbnails.has(clip.edgeId)) {
newThumbnails.set(clip.edgeId, thumbnails.get(clip.edgeId)!);
if (thumbnailsRef.current.has(clip.edgeId)) {
newThumbnails.set(clip.edgeId, thumbnailsRef.current.get(clip.edgeId)!);
continue;
}
@ -134,6 +156,8 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
video.onerror = () => reject(new Error("Failed to load video"));
});
if (cancelled) return;
const seekTime = video.duration * 0.25;
video.currentTime = seekTime;
@ -141,6 +165,8 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
video.onseeked = () => resolve();
});
if (cancelled) return;
const canvas = document.createElement("canvas");
canvas.width = 160;
canvas.height = 120;
@ -157,11 +183,15 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
}
}
if (!cancelled) {
thumbnailsRef.current = newThumbnails;
setThumbnails(newThumbnails);
}
};
extractThumbnails();
}, [orderedClips]);
return () => { cancelled = true; };
}, [clipKey]); // eslint-disable-line react-hooks/exhaustive-deps — orderedClips accessed via closure, clipKey is the stable dep
// Pointer-based drag reorder (HTML5 drag doesn't work inside React Flow nodes)
const [draggedClipId, setDraggedClipId] = useState<string | null>(null);

Loading…
Cancel
Save