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;
+ }
}
}