diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index a62a965c..ac6e4c5c 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -432,6 +432,18 @@ export function WorkflowCanvas() { return handleType === "image" ? "image" : null; } + // VideoStitch has dynamic indexed video input handles (video-0, video-1, ...) + if (node.type === "videoStitch" && needInput && handleType === "video") { + for (let i = 0; i < 50; i++) { + const candidateHandle = `video-${i}`; + const isOccupied = edges.some( + (edge) => edge.target === node.id && edge.targetHandle === candidateHandle + ); + if (!isOccupied) return candidateHandle; + } + return null; + } + // Fall back to static handles const staticHandles = getNodeHandles(node.type || ""); const handleList = needInput ? staticHandles.inputs : staticHandles.outputs; diff --git a/src/components/nodes/VideoStitchNode.tsx b/src/components/nodes/VideoStitchNode.tsx index 003615ba..5fa0bfd1 100644 --- a/src/components/nodes/VideoStitchNode.tsx +++ b/src/components/nodes/VideoStitchNode.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; +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"; @@ -20,7 +20,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps 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(() => { @@ -38,6 +37,24 @@ export function VideoStitchNode({ id, data, selected }: NodeProps { + 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(); @@ -49,7 +66,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps; if (nodeData.clipOrder && nodeData.clipOrder.length > 0) { - // Use clipOrder for ordering ordered = nodeData.clipOrder .map((edgeId) => { const clip = clipMap.get(edgeId); @@ -70,7 +84,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps => c !== null); - // Append any new edges not in clipOrder + // Append any new edges not in clipOrder yet videoEdges.forEach((edge) => { if (!nodeData.clipOrder.includes(edge.id)) { const clip = clipMap.get(edge.id); @@ -80,7 +94,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps { const timeA = (a.data as any)?.createdAt ?? 0; @@ -95,18 +108,8 @@ export function VideoStitchNode({ id, data, selected }: NodeProps => 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]); + }, [videoEdges, nodes, nodeData.clipOrder]); // Extract thumbnails from connected videos useEffect(() => { @@ -121,7 +124,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps reject(new Error("Failed to load video")); }); - // Seek to 25% of duration const seekTime = video.duration * 0.25; video.currentTime = seekTime; @@ -140,7 +141,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps resolve(); }); - // Draw frame to canvas const canvas = document.createElement("canvas"); canvas.width = 160; canvas.height = 120; @@ -151,7 +151,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps { + 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 ( + + ); + })} + + {/* Audio input handle (left side, bottom) */} + +
+ Audio +
+ + {/* Video output handle (right side) */} + + + ); + // Disable if encoder not supported if (nodeData.encoderSupported === false) { return ( @@ -230,6 +284,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps + {renderHandles()}
@@ -265,6 +320,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps + {renderHandles()}
{ - const count = Math.max(videoEdges.length + 1, 2); - return Array.from({ length: count }, (_, i) => ({ id: `video-${i}` })); - }, [videoEdges.length]); - return ( - {/* 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) */} - + {renderHandles()}
{/* Filmstrip UI */} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 885546ee..1894a6d1 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -2572,6 +2572,81 @@ export const useWorkflowStore = create((set, get) => ({ await logger.endSession(); return; } + } else if (node.type === "videoStitch") { + const nodeData = node.data as VideoStitchNodeData; + + if (nodeData.encoderSupported === false) { + updateNodeData(nodeId, { + status: "error", + error: "Browser does not support video encoding", + progress: 0, + }); + set({ isRunning: false, currentNodeId: null }); + await logger.endSession(); + return; + } + + updateNodeData(nodeId, { status: "loading", progress: 0, error: null }); + + try { + const inputs = getConnectedInputs(nodeId); + + if (inputs.videos.length < 2) { + updateNodeData(nodeId, { + status: "error", + error: "Need at least 2 video clips to stitch", + progress: 0, + }); + set({ isRunning: false, currentNodeId: null }); + await logger.endSession(); + return; + } + + const videoBlobs = await Promise.all( + inputs.videos.map((v) => fetch(v).then((r) => r.blob())) + ); + + let audioData = null; + if (inputs.audio.length > 0 && inputs.audio[0]) { + const { prepareAudioAsync } = await import('@/hooks/useAudioMixing'); + const audioBlob = await fetch(inputs.audio[0]).then((r) => r.blob()); + audioData = await prepareAudioAsync(audioBlob, 0); + } + + const { stitchVideosAsync } = await import('@/hooks/useStitchVideos'); + const outputBlob = await stitchVideosAsync( + videoBlobs, + audioData, + (progress) => { + updateNodeData(nodeId, { progress: progress.progress }); + } + ); + + let outputVideo: string; + if (outputBlob.size > 20 * 1024 * 1024) { + outputVideo = URL.createObjectURL(outputBlob); + } else { + const reader = new FileReader(); + outputVideo = await new Promise((resolve) => { + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(outputBlob); + }); + } + + updateNodeData(nodeId, { + outputVideo, + status: "complete", + progress: 100, + error: null, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Stitch failed"; + updateNodeData(nodeId, { + status: "error", + error: errorMessage, + progress: 0, + }); + } } logger.info('node.execution', 'Node regeneration completed successfully', { nodeId });