diff --git a/src/components/ConnectionDropMenu.tsx b/src/components/ConnectionDropMenu.tsx index b4656aaf..48b74ee7 100644 --- a/src/components/ConnectionDropMenu.tsx +++ b/src/components/ConnectionDropMenu.tsx @@ -199,8 +199,17 @@ const TEXT_SOURCE_OPTIONS: MenuOption[] = [ }, ]; -// Video can only connect to generateVideo (video-to-video) or output nodes +// Video can only connect to videoStitch, generateVideo (video-to-video), or output nodes const VIDEO_TARGET_OPTIONS: MenuOption[] = [ + { + type: "videoStitch", + label: "Video Stitch", + icon: ( + + + + ), + }, { type: "generateVideo", label: "Generate Video", @@ -221,7 +230,7 @@ const VIDEO_TARGET_OPTIONS: MenuOption[] = [ }, ]; -// Only generateVideo nodes produce video output +// GenerateVideo and VideoStitch nodes produce video output const VIDEO_SOURCE_OPTIONS: MenuOption[] = [ { type: "generateVideo", @@ -232,11 +241,28 @@ const VIDEO_SOURCE_OPTIONS: MenuOption[] = [ ), }, + { + type: "videoStitch", + label: "Video Stitch", + icon: ( + + + + ), + }, ]; // Audio target options (nodes that accept audio input) const AUDIO_TARGET_OPTIONS: MenuOption[] = [ - // VideoStitch will be added in Plan 03 + { + type: "videoStitch", + label: "Video Stitch", + icon: ( + + + + ), + }, ]; // Audio source options (nodes that produce audio output) diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 693ee34c..a62a965c 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -32,6 +32,7 @@ import { OutputNode, OutputGalleryNode, ImageCompareNode, + VideoStitchNode, } from "./nodes"; import { EditableEdge, ReferenceEdge } from "./edges"; import { ConnectionDropMenu, MenuAction } from "./ConnectionDropMenu"; @@ -61,6 +62,7 @@ const nodeTypes: NodeTypes = { output: OutputNode, outputGallery: OutputGalleryNode, imageCompare: ImageCompareNode, + videoStitch: VideoStitchNode, }; const edgeTypes: EdgeTypes = { @@ -114,6 +116,8 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] return { inputs: ["image"], outputs: [] }; case "imageCompare": return { inputs: ["image"], outputs: [] }; + case "videoStitch": + return { inputs: ["video", "audio"], outputs: ["video"] }; default: return { inputs: [], outputs: [] }; } @@ -290,12 +294,13 @@ export function WorkflowCanvas() { if (sourceType === "video") { // Video source can ONLY connect to: // 1. generateVideo nodes (for video-to-video) - // 2. output nodes (for display) + // 2. videoStitch nodes (for concatenation) + // 3. output nodes (for display) const targetNode = nodes.find((n) => n.id === connection.target); if (!targetNode) return false; const targetNodeType = targetNode.type; - if (targetNodeType === "generateVideo" || targetNodeType === "output") { + if (targetNodeType === "generateVideo" || targetNodeType === "videoStitch" || targetNodeType === "output") { // For output node, we allow video even though its handle is typed as "image" // because output node can display both images and videos return true; @@ -1460,6 +1465,8 @@ export function WorkflowCanvas() { return "#ec4899"; case "imageCompare": return "#14b8a6"; + case "videoStitch": + return "#f97316"; default: return "#94a3b8"; } diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 1fd17d03..885546ee 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -30,6 +30,7 @@ import { ProviderSettings, RecentModel, OutputGalleryNodeData, + VideoStitchNodeData, } from "@/types"; import { useToast } from "@/components/Toast"; import { calculateGenerationCost } from "@/utils/costCalculator"; @@ -813,6 +814,8 @@ export const useWorkflowStore = create((set, get) => ({ } else if (sourceNode.type === "generateVideo") { // Return video type - generateVideo and output nodes handle this appropriately return { type: "video", value: (sourceNode.data as GenerateVideoNodeData).outputVideo }; + } else if (sourceNode.type === "videoStitch") { + return { type: "video", value: (sourceNode.data as VideoStitchNodeData).outputVideo }; } else if (sourceNode.type === "prompt") { return { type: "text", value: (sourceNode.data as PromptNodeData).prompt }; } else if (sourceNode.type === "promptConstructor") { @@ -1873,6 +1876,89 @@ export const useWorkflowStore = create((set, get) => ({ }); break; } + + case "videoStitch": { + const nodeData = node.data as VideoStitchNodeData; + + // Check encoder support + if (nodeData.encoderSupported === false) { + updateNodeData(node.id, { + status: "error", + error: "Browser does not support video encoding", + progress: 0, + }); + break; + } + + updateNodeData(node.id, { status: "loading", progress: 0, error: null }); + + try { + const inputs = getConnectedInputs(node.id); + + if (inputs.videos.length < 2) { + updateNodeData(node.id, { + status: "error", + error: "Need at least 2 video clips to stitch", + progress: 0, + }); + break; + } + + // Convert video data/blob URLs to Blobs + const videoBlobs = await Promise.all( + inputs.videos.map((v) => fetch(v).then((r) => r.blob())) + ); + + // Prepare audio if connected + 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()); + // Calculate total video duration from blobs (we'll use MediaBunny for this during stitch) + // For now, pass 0 and let stitch handle duration + audioData = await prepareAudioAsync(audioBlob, 0); + } + + // Stitch videos + const { stitchVideosAsync } = await import('@/hooks/useStitchVideos'); + const outputBlob = await stitchVideosAsync( + videoBlobs, + audioData, + (progress) => { + updateNodeData(node.id, { progress: progress.progress }); + } + ); + + // Store output - blob URL for large files, data URL for small + let outputVideo: string; + if (outputBlob.size > 20 * 1024 * 1024) { + // Large file: use blob URL + outputVideo = URL.createObjectURL(outputBlob); + } else { + // Small file: convert to data URL for workflow saving + const reader = new FileReader(); + outputVideo = await new Promise((resolve) => { + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(outputBlob); + }); + } + + updateNodeData(node.id, { + outputVideo, + status: "complete", + progress: 100, + error: null, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Stitch failed"; + updateNodeData(node.id, { + status: "error", + error: errorMessage, + progress: 0, + }); + } + break; + } } }