Browse Source

feat(41-03): register VideoStitch in all integration points

WorkflowCanvas.tsx:
- Import VideoStitchNode
- Add videoStitch to nodeTypes
- Add getNodeHandles case: inputs=["video", "audio"], outputs=["video"]
- Update isValidConnection to allow video→videoStitch connections
- Add minimap color: orange-500 (#f97316)

ConnectionDropMenu.tsx:
- Add videoStitch to VIDEO_TARGET_OPTIONS (first position)
- Add videoStitch to VIDEO_SOURCE_OPTIONS (produces video output)
- Add videoStitch to AUDIO_TARGET_OPTIONS (accepts audio input)

workflowStore.ts:
- Import VideoStitchNodeData type
- Add videoStitch to getSourceOutput (returns video type)
- Add executeWorkflow case for videoStitch with full stitching logic:
  * Check encoder support
  * Get connected inputs (videos, audio)
  * Convert video data URLs to Blobs
  * Prepare audio if connected (using prepareAudioAsync)
  * Call stitchVideosAsync with progress callback
  * Store output (blob URL for >20MB, data URL otherwise)
  * Handle errors

Note: nodeDefaults.ts already had videoStitch dimensions and
createDefaultNodeData from Plan 02, so no changes needed there.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
5e6862b43f
  1. 32
      src/components/ConnectionDropMenu.tsx
  2. 11
      src/components/WorkflowCanvas.tsx
  3. 86
      src/store/workflowStore.ts

32
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: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />
</svg>
),
},
{
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[] = [
</svg>
),
},
{
type: "videoStitch",
label: "Video Stitch",
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />
</svg>
),
},
];
// 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: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />
</svg>
),
},
];
// Audio source options (nodes that produce audio output)

11
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";
}

86
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<WorkflowStore>((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<WorkflowStore>((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<string>((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;
}
}
}

Loading…
Cancel
Save