"use client"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Handle, Position, NodeProps, Node } 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); // 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]); // Sync clipOrder with connected edges (side effect, must be in useEffect) useEffect(() => { const currentEdgeIds = videoEdges.map((e) => e.id); const currentOrder = nodeData.clipOrder || []; // Keep existing order for edges that still exist, append new ones const validExisting = currentOrder.filter((eid) => currentEdgeIds.includes(eid)); const newEdges = currentEdgeIds.filter((eid) => !currentOrder.includes(eid)); const newOrder = [...validExisting, ...newEdges]; if ( newOrder.length !== currentOrder.length || !newOrder.every((eid, idx) => eid === currentOrder[idx]) ) { updateNodeData(id, { clipOrder: newOrder }); } }, [videoEdges, nodeData.clipOrder, id, updateNodeData]); // 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; if (sourceNode.type === "generateVideo" || sourceNode.type === "easeCurve" || sourceNode.type === "videoStitch") { 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 }>; if (nodeData.clipOrder && nodeData.clipOrder.length > 0) { 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 yet videoEdges.forEach((edge) => { if (!nodeData.clipOrder.includes(edge.id)) { const clip = clipMap.get(edge.id); if (clip) { ordered.push({ edgeId: edge.id, ...clip }); } } }); } else { 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); } return ordered; }, [videoEdges, nodes, nodeData.clipOrder]); // 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 { 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")); }); const seekTime = video.duration * 0.25; video.currentTime = seekTime; await new Promise((resolve) => { video.onseeked = () => resolve(); }); 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); 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; } 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]); // 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]); // Shared handles rendered in ALL states so connections always work const renderHandles = () => ( <> {/* Dynamic video input handles (left side) */} {videoHandles.map((handle, index) => { const topPercent = ((index + 1) / (videoHandles.length + 1)) * 100; return (
Video {index + 1}
); })} {/* Audio input handle (left side, bottom) */}
Audio
{/* Video output handle (right side) */}
Output
); // 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} > {renderHandles()}
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} > {renderHandles()}
Checking encoder...
); } 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} > {renderHandles()}
{/* 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="nodrag relative w-full aspect-video 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" && (
)}
); }