diff --git a/src/components/nodes/VideoStitchNode.tsx b/src/components/nodes/VideoStitchNode.tsx new file mode 100644 index 00000000..003615ba --- /dev/null +++ b/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; + +export function VideoStitchNode({ id, data, selected }: NodeProps) { + 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>(new Map()); + const regenerateNode = useWorkflowStore((state) => state.regenerateNode); + const isRunning = useWorkflowStore((state) => state.isRunning); + const removeEdge = useWorkflowStore((state) => state.removeEdge); + const videoRefs = useRef>(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(); + + 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 => 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 => 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(); + + 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((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((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(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 ( + updateNodeData(id, { customTitle: title || undefined })} + onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + selected={selected} + commentNavigation={commentNavigation ?? undefined} + minWidth={500} + minHeight={280} + > +
+ + + + + Your browser doesn't support video encoding. + + + Doesn't seem right? Message Willie on Discord. + +
+
+ ); + } + + // Checking encoder state + if (nodeData.encoderSupported === null) { + return ( + updateNodeData(id, { customTitle: title || undefined })} + onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + selected={selected} + commentNavigation={commentNavigation ?? undefined} + minWidth={500} + minHeight={280} + > +
+
+ + + + + Checking encoder... +
+
+
+ ); + } + + // 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 ( + 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 ( + + ); + })} + + {/* Audio input handle (left side, bottom) */} + +
+ Audio +
+ + {/* Video output handle (right side) */} + + +
+ {/* Filmstrip UI */} +
+ {orderedClips.length === 0 ? ( +
+ Connect videos to stitch +
+ ) : ( + <> + {/* Filmstrip */} +
+ {orderedClips.map((clip) => { + const thumbnail = thumbnails.get(clip.edgeId); + return ( +
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 ? ( + {`Clip + ) : ( +
+ + + + +
+ )} + + {/* Duration badge */} + {clip.duration && ( +
+ {Math.round(clip.duration)}s +
+ )} + + {/* Remove button */} + +
+ ); + })} +
+ + {/* Stitch button */} + + + )} +
+ + {/* Processing overlay */} + {nodeData.status === "loading" && ( +
+ + + + + Processing... {Math.round(nodeData.progress)}% +
+ )} + + {/* Output preview */} + {nodeData.outputVideo && nodeData.status !== "loading" && ( +
+
+ )} +
+
+ ); +} diff --git a/src/components/nodes/index.ts b/src/components/nodes/index.ts index fa135738..806e44e1 100644 --- a/src/components/nodes/index.ts +++ b/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";