Browse Source

feat(41-03): build VideoStitchNode component

- Component with encoder detection on mount (checkEncoderSupport)
- Dynamic video input handles (video-0, video-1, etc.) based on connections
- Audio input handle at bottom (violet color, matching AudioInput)
- Video output handle
- Filmstrip UI with thumbnail extraction (seek to 25% duration)
- Clip ordering via clipOrder array with drag-to-reorder support
- X button per clip to disconnect
- Processing trigger button (disabled until 2+ clips connected)
- Progress overlay during stitching (shows percentage)
- Output video preview on completion
- Disabled state when encoder not supported
- Export added to nodes/index.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
434c0e017e
  1. 499
      src/components/nodes/VideoStitchNode.tsx
  2. 1
      src/components/nodes/index.ts

499
src/components/nodes/VideoStitchNode.tsx

@ -0,0 +1,499 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { useWorkflowStore } from "@/store/workflowStore";
import { VideoStitchNodeData } from "@/types";
import { checkEncoderSupport } from "@/hooks/useStitchVideos";
type VideoStitchNodeType = Node<VideoStitchNodeData, "videoStitch">;
export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNodeType>) {
const nodeData = data;
const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map());
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
const removeEdge = useWorkflowStore((state) => state.removeEdge);
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
// Check encoder support on mount
useEffect(() => {
if (nodeData.encoderSupported === null) {
checkEncoderSupport().then((supported) => {
updateNodeData(id, { encoderSupported: supported });
});
}
}, [id, nodeData.encoderSupported, updateNodeData]);
// Get connected video edges
const videoEdges = useMemo(() => {
return edges.filter(
(e) => e.target === id && e.targetHandle?.startsWith("video-")
);
}, [edges, id]);
// 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 }>();
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;
// Extract video data and duration from different node types
if (sourceNode.type === "generateVideo") {
videoData = (sourceNode.data as any).outputVideo || null;
}
clipMap.set(edge.id, { edge, sourceNode, videoData, duration });
});
// Order by clipOrder if available, otherwise by edge creation time
let ordered: Array<{ edgeId: string; edge: any; sourceNode: any; videoData: string | null; duration: number | null }>;
if (nodeData.clipOrder && nodeData.clipOrder.length > 0) {
// Use clipOrder for ordering
ordered = nodeData.clipOrder
.map((edgeId) => {
const clip = clipMap.get(edgeId);
if (!clip) return null;
return { edgeId, ...clip };
})
.filter((c): c is NonNullable<typeof c> => c !== null);
// Append any new edges not in clipOrder
videoEdges.forEach((edge) => {
if (!nodeData.clipOrder.includes(edge.id)) {
const clip = clipMap.get(edge.id);
if (clip) {
ordered.push({ edgeId: edge.id, ...clip });
}
}
});
} else {
// Fall back to edge creation order (sort by data.createdAt if available)
ordered = videoEdges
.sort((a, b) => {
const timeA = (a.data as any)?.createdAt ?? 0;
const timeB = (b.data as any)?.createdAt ?? 0;
return timeA - timeB;
})
.map((edge) => {
const clip = clipMap.get(edge.id);
if (!clip) return null;
return { edgeId: edge.id, ...clip };
})
.filter((c): c is NonNullable<typeof c> => c !== null);
}
// Update clipOrder if it's out of sync
const currentOrder = ordered.map((c) => c.edgeId);
if (
!nodeData.clipOrder ||
nodeData.clipOrder.length !== currentOrder.length ||
!nodeData.clipOrder.every((id, idx) => id === currentOrder[idx])
) {
updateNodeData(id, { clipOrder: currentOrder });
}
return ordered;
}, [videoEdges, nodes, nodeData.clipOrder, id, updateNodeData]);
// Extract thumbnails from connected videos
useEffect(() => {
const extractThumbnails = async () => {
const newThumbnails = new Map<string, string>();
for (const clip of orderedClips) {
if (!clip.videoData) continue;
if (thumbnails.has(clip.edgeId)) {
newThumbnails.set(clip.edgeId, thumbnails.get(clip.edgeId)!);
continue;
}
try {
// Create video element
const video = document.createElement("video");
video.src = clip.videoData;
video.crossOrigin = "anonymous";
video.muted = true;
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => resolve();
video.onerror = () => reject(new Error("Failed to load video"));
});
// Seek to 25% of duration
const seekTime = video.duration * 0.25;
video.currentTime = seekTime;
await new Promise<void>((resolve) => {
video.onseeked = () => resolve();
});
// Draw frame to canvas
const canvas = document.createElement("canvas");
canvas.width = 160;
canvas.height = 120;
const ctx = canvas.getContext("2d");
if (!ctx) continue;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const thumbnail = canvas.toDataURL("image/jpeg", 0.7);
newThumbnails.set(clip.edgeId, thumbnail);
// Store duration
clip.duration = video.duration;
} catch (error) {
console.warn(`Failed to extract thumbnail for clip ${clip.edgeId}:`, error);
}
}
setThumbnails(newThumbnails);
};
extractThumbnails();
}, [orderedClips]);
// Drag and drop for reordering clips
const [draggedClipId, setDraggedClipId] = useState<string | null>(null);
const handleDragStart = useCallback((e: React.DragEvent, edgeId: string) => {
setDraggedClipId(edgeId);
e.dataTransfer.effectAllowed = "move";
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}, []);
const handleDrop = useCallback(
(e: React.DragEvent, targetEdgeId: string) => {
e.preventDefault();
if (!draggedClipId || draggedClipId === targetEdgeId) {
setDraggedClipId(null);
return;
}
const currentOrder = [...nodeData.clipOrder];
const draggedIndex = currentOrder.indexOf(draggedClipId);
const targetIndex = currentOrder.indexOf(targetEdgeId);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedClipId(null);
return;
}
// Reorder
currentOrder.splice(draggedIndex, 1);
currentOrder.splice(targetIndex, 0, draggedClipId);
updateNodeData(id, { clipOrder: currentOrder });
setDraggedClipId(null);
},
[draggedClipId, nodeData.clipOrder, id, updateNodeData]
);
const handleRemoveClip = useCallback(
(edgeId: string) => {
removeEdge(edgeId);
},
[removeEdge]
);
const handleStitch = useCallback(() => {
regenerateNode(id);
}, [id, regenerateNode]);
// Disable if encoder not supported
if (nodeData.encoderSupported === false) {
return (
<BaseNode
id={id}
title="Video Stitch"
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
selected={selected}
commentNavigation={commentNavigation ?? undefined}
minWidth={500}
minHeight={280}
>
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-center px-4">
<svg className="w-8 h-8 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<span className="text-xs text-neutral-400">
Your browser doesn't support video encoding.
</span>
<a
href="https://discord.gg/placeholder"
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-blue-400 hover:text-blue-300 underline"
>
Doesn't seem right? Message Willie on Discord.
</a>
</div>
</BaseNode>
);
}
// Checking encoder state
if (nodeData.encoderSupported === null) {
return (
<BaseNode
id={id}
title="Video Stitch"
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
selected={selected}
commentNavigation={commentNavigation ?? undefined}
minWidth={500}
minHeight={280}
>
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-2 text-neutral-400">
<svg
className="w-4 h-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="text-xs">Checking encoder...</span>
</div>
</div>
</BaseNode>
);
}
// Dynamic video input handles
const videoHandles = useMemo(() => {
const count = Math.max(videoEdges.length + 1, 2);
return Array.from({ length: count }, (_, i) => ({ id: `video-${i}` }));
}, [videoEdges.length]);
return (
<BaseNode
id={id}
title="Video Stitch"
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
onRun={handleStitch}
selected={selected}
isExecuting={isRunning}
hasError={nodeData.status === "error"}
commentNavigation={commentNavigation ?? undefined}
minWidth={500}
minHeight={280}
>
{/* Dynamic video input handles (left side) */}
{videoHandles.map((handle, index) => {
const topPercent = ((index + 1) / (videoHandles.length + 1)) * 100;
return (
<Handle
key={handle.id}
type="target"
position={Position.Left}
id={handle.id}
data-handletype="video"
style={{ top: `${topPercent}%` }}
/>
);
})}
{/* Audio input handle (left side, bottom) */}
<Handle
type="target"
position={Position.Left}
id="audio"
data-handletype="audio"
style={{ top: "90%", background: "rgb(167, 139, 250)" }}
/>
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 8px)`,
top: "calc(90% - 18px)",
color: "rgb(167, 139, 250)",
}}
>
Audio
</div>
{/* Video output handle (right side) */}
<Handle
type="source"
position={Position.Right}
id="video"
data-handletype="video"
/>
<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Filmstrip UI */}
<div className="flex-1 flex flex-col min-h-0 gap-2">
{orderedClips.length === 0 ? (
<div className="flex-1 flex items-center justify-center border border-dashed border-neutral-600 rounded">
<span className="text-[10px] text-neutral-500">Connect videos to stitch</span>
</div>
) : (
<>
{/* Filmstrip */}
<div className="flex-1 overflow-x-auto overflow-y-hidden nowheel flex gap-2 p-2 bg-neutral-900/50 rounded">
{orderedClips.map((clip) => {
const thumbnail = thumbnails.get(clip.edgeId);
return (
<div
key={clip.edgeId}
draggable
onDragStart={(e) => handleDragStart(e, clip.edgeId)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, clip.edgeId)}
className="relative flex-shrink-0 w-20 h-[60px] bg-neutral-800 border border-neutral-600 rounded cursor-move hover:border-neutral-500 transition-colors group"
>
{thumbnail ? (
<img
src={thumbnail}
alt={`Clip ${clip.edgeId}`}
className="w-full h-full object-cover rounded"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<svg
className="w-4 h-4 text-neutral-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
{/* Duration badge */}
{clip.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 px-1 py-0.5 rounded text-[8px] text-white">
{Math.round(clip.duration)}s
</div>
)}
{/* Remove button */}
<button
onClick={() => handleRemoveClip(clip.edgeId)}
className="absolute top-0.5 right-0.5 w-4 h-4 bg-red-600/80 hover:bg-red-500 rounded text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Disconnect"
>
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
})}
</div>
{/* Stitch button */}
<button
onClick={handleStitch}
disabled={orderedClips.length < 2 || nodeData.status === "loading" || isRunning}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-neutral-700 disabled:text-neutral-500 disabled:cursor-not-allowed rounded text-white text-xs font-medium transition-colors"
>
{nodeData.status === "loading" ? "Processing..." : "Stitch"}
</button>
</>
)}
</div>
{/* Processing overlay */}
{nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 rounded flex flex-col items-center justify-center gap-2">
<svg
className="w-6 h-6 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="text-white text-xs">Processing... {Math.round(nodeData.progress)}%</span>
</div>
)}
{/* Output preview */}
{nodeData.outputVideo && nodeData.status !== "loading" && (
<div className="relative w-full">
<video
src={nodeData.outputVideo}
controls
autoPlay
loop
muted
className="w-full h-auto max-h-40 object-contain rounded"
playsInline
/>
<button
onClick={() => updateNodeData(id, { outputVideo: null, status: "idle" })}
className="absolute top-1 right-1 w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Clear video"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
</div>
</BaseNode>
);
}

1
src/components/nodes/index.ts

@ -10,4 +10,5 @@ export { SplitGridNode } from "./SplitGridNode";
export { OutputNode } from "./OutputNode";
export { OutputGalleryNode } from "./OutputGalleryNode";
export { ImageCompareNode } from "./ImageCompareNode";
export { VideoStitchNode } from "./VideoStitchNode";
export { GroupNode } from "./GroupNode";

Loading…
Cancel
Save