Browse Source

feat: add VideoInput node for uploading/loading video files

Adds a new videoInput node type that allows users to drag-and-drop or
browse for video files (MP4, WebM, QuickTime), preview them inline with
native video controls, and feed them into downstream video processing
nodes. Includes full media externalization/hydration support, Shift+Y
keyboard shortcut, and ConnectionDropMenu integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
a064ddc106
  1. 22
      src/components/ConnectionDropMenu.tsx
  2. 60
      src/components/WorkflowCanvas.tsx
  3. 212
      src/components/nodes/VideoInputNode.tsx
  4. 1
      src/components/nodes/index.ts
  5. 10
      src/lib/quickstart/validation.ts
  6. 8
      src/store/execution/executeNode.ts
  7. 3
      src/store/utils/connectedInputs.ts
  8. 1
      src/store/utils/executionUtils.ts
  9. 10
      src/store/utils/nodeDefaults.ts
  10. 12
      src/store/workflowStore.ts
  11. 15
      src/types/nodes.ts
  12. 28
      src/utils/mediaStorage.ts

22
src/components/ConnectionDropMenu.tsx

@ -346,8 +346,17 @@ const TEXT_SOURCE_OPTIONS: MenuOption[] = [
},
];
// Video can only connect to videoStitch, generateVideo (video-to-video), or output nodes
// Video can connect to videoStitch, videoInput, generateVideo (video-to-video), or output nodes
const VIDEO_TARGET_OPTIONS: MenuOption[] = [
{
type: "videoInput",
label: "Video Input",
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="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
),
},
{
type: "videoStitch",
label: "Video Stitch",
@ -425,8 +434,17 @@ const VIDEO_TARGET_OPTIONS: MenuOption[] = [
},
];
// GenerateVideo and VideoStitch nodes produce video output
// GenerateVideo, VideoStitch, and VideoInput nodes produce video output
const VIDEO_SOURCE_OPTIONS: MenuOption[] = [
{
type: "videoInput",
label: "Video Input",
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="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
),
},
{
type: "generateVideo",
label: "Generate Video",

60
src/components/WorkflowCanvas.tsx

@ -25,6 +25,7 @@ import dynamic from "next/dynamic";
import {
ImageInputNode,
AudioInputNode,
VideoInputNode,
AnnotationNode,
PromptNode,
ArrayNode,
@ -55,7 +56,7 @@ import { MultiSelectToolbar } from "./MultiSelectToolbar";
import { EdgeToolbar } from "./EdgeToolbar";
import { GlobalImageHistory } from "./GlobalImageHistory";
import { GroupBackgroundsPortal, GroupControlsOverlay } from "./GroupsOverlay";
import { NodeType, NanoBananaNodeData, HandleType } from "@/types";
import { NodeType, NanoBananaNodeData, HandleType, PromptNodeData, LLMGenerateNodeData, PromptConstructorNodeData, AvailableVariable } from "@/types";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
import { FloatingNodeHeader } from "./nodes/FloatingNodeHeader";
import { ControlPanel } from "./nodes/ControlPanel";
@ -67,6 +68,9 @@ import { ChatPanel } from "./ChatPanel";
import { EditOperation } from "@/lib/chat/editOperations";
import { stripBinaryData } from "@/lib/chat/contextBuilder";
import { PromptEditorModal } from "./modals/PromptEditorModal";
import { PromptConstructorEditorModal } from "./modals/PromptConstructorEditorModal";
import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs";
import { parseVarTags } from "@/utils/parseVarTags";
import { AnnotationModal } from "./AnnotationModal";
import { browseRegistry } from "@/utils/browseRegistry";
import { useInlineParameters } from "@/hooks/useInlineParameters";
@ -77,6 +81,7 @@ import { useAnnotationStore } from "@/store/annotationStore";
const nodeTypes: NodeTypes = {
imageInput: ImageInputNode,
audioInput: AudioInputNode,
videoInput: VideoInputNode,
annotation: AnnotationNode,
prompt: PromptNode,
array: ArrayNode,
@ -137,6 +142,8 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[]
return { inputs: ["reference"], outputs: ["image"] };
case "audioInput":
return { inputs: ["audio"], outputs: ["audio"] };
case "videoInput":
return { inputs: ["video"], outputs: ["video"] };
case "annotation":
return { inputs: ["image"], outputs: ["image"] };
case "prompt":
@ -365,6 +372,7 @@ export function WorkflowCanvas() {
const NODE_TITLES: Record<string, string> = {
imageInput: 'Image Input',
audioInput: 'Audio Input',
videoInput: 'Video Input',
annotation: 'Annotation',
prompt: 'Prompt',
array: 'Array',
@ -1448,6 +1456,9 @@ export function WorkflowCanvas() {
case "t":
nodeType = "generateAudio";
break;
case "y":
nodeType = "videoInput";
break;
}
if (nodeType) {
@ -1457,6 +1468,7 @@ export function WorkflowCanvas() {
const defaultDimensions: Record<NodeType, { width: number; height: number }> = {
imageInput: { width: 300, height: 280 },
audioInput: { width: 300, height: 200 },
videoInput: { width: 300, height: 280 },
annotation: { width: 300, height: 280 },
prompt: { width: 320, height: 220 },
array: { width: 360, height: 360 },
@ -2043,6 +2055,8 @@ export function WorkflowCanvas() {
return "#3b82f6";
case "audioInput":
return "#a78bfa";
case "videoInput":
return "#c084fc"; // purple-400 (video input, distinct from generateVideo's #9333ea)
case "annotation":
return "#8b5cf6";
case "prompt":
@ -2213,10 +2227,50 @@ export function WorkflowCanvas() {
{expandingNode && expandingNode.type === 'promptConstructor' && (() => {
const node = getNodeById(expandingNode.id);
if (!node) return null;
// Compute available variables from connected text nodes (same logic as PromptConstructorNode)
const directTextNodes = edges
.filter((e) => e.target === expandingNode.id && e.targetHandle === "text")
.map((e) => nodes.find((n) => n.id === e.source))
.filter((n): n is typeof nodes[0] => n !== undefined);
const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, nodes, edges);
const vars: AvailableVariable[] = [];
const usedNames = new Set<string>();
connectedTextNodes.forEach((cn) => {
if (cn.type === "prompt") {
const promptData = cn.data as PromptNodeData;
if (promptData.variableName) {
vars.push({ name: promptData.variableName, value: promptData.prompt || "", nodeId: cn.id });
usedNames.add(promptData.variableName);
}
}
});
connectedTextNodes.forEach((cn) => {
let text: string | null = null;
if (cn.type === "prompt") text = (cn.data as PromptNodeData).prompt || null;
else if (cn.type === "llmGenerate") text = (cn.data as LLMGenerateNodeData).outputText || null;
else if (cn.type === "promptConstructor") {
const pcData = cn.data as PromptConstructorNodeData;
text = pcData.outputText ?? pcData.template ?? null;
}
if (text) {
parseVarTags(text).forEach(({ name, value }) => {
if (!usedNames.has(name)) {
vars.push({ name, value, nodeId: `${cn.id}-var-${name}` });
usedNames.add(name);
}
});
}
});
return createPortal(
<PromptEditorModal
<PromptConstructorEditorModal
isOpen={true}
initialPrompt={(node.data as any)?.template || ''}
initialTemplate={(node.data as PromptConstructorNodeData)?.template || ''}
availableVariables={vars}
onSubmit={(template) => {
updateNodeData(expandingNode.id, { template });
setExpandingNode(null);

212
src/components/nodes/VideoInputNode.tsx

@ -0,0 +1,212 @@
"use client";
import { useCallback, useRef } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { VideoInputNodeData } from "@/types";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
type VideoInputNodeType = Node<VideoInputNodeData, "videoInput">;
const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB
const ACCEPTED_FORMATS = "video/mp4,video/webm,video/quicktime";
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeType>) {
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const fileInputRef = useRef<HTMLInputElement>(null);
// Use blob URL for efficient playback of large base64 videos
const playbackUrl = useVideoBlobUrl(nodeData.video ?? null);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.match(/^video\//)) {
alert("Unsupported format. Use MP4, WebM, or QuickTime video files.");
return;
}
if (file.size > MAX_FILE_SIZE) {
alert("Video file too large. Maximum size is 200MB.");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target?.result as string;
// Extract duration and dimensions using HTML Video element
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
updateNodeData(id, {
video: base64,
videoRef: undefined,
filename: file.name,
format: file.type,
duration: video.duration,
dimensions: { width: video.videoWidth, height: video.videoHeight },
});
URL.revokeObjectURL(video.src);
};
video.onerror = () => {
// Still load the file even if metadata extraction fails
updateNodeData(id, {
video: base64,
videoRef: undefined,
filename: file.name,
format: file.type,
duration: null,
dimensions: null,
});
URL.revokeObjectURL(video.src);
};
// Use blob URL for metadata extraction to avoid base64 parsing overhead
const blob = new Blob([Uint8Array.from(atob(base64.split(",")[1]), c => c.charCodeAt(0))], { type: file.type });
video.src = URL.createObjectURL(blob);
};
reader.readAsDataURL(file);
},
[id, updateNodeData]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files?.[0];
if (!file) return;
const dt = new DataTransfer();
dt.items.add(file);
if (fileInputRef.current) {
fileInputRef.current.files = dt.files;
fileInputRef.current.dispatchEvent(new Event("change", { bubbles: true }));
}
},
[]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleRemove = useCallback(() => {
updateNodeData(id, {
video: null,
videoRef: undefined,
filename: null,
duration: null,
dimensions: null,
format: null,
});
}, [id, updateNodeData]);
return (
<BaseNode
id={id}
selected={selected}
minWidth={250}
minHeight={200}
>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FORMATS}
onChange={handleFileChange}
className="hidden"
/>
{nodeData.video ? (
<div className="relative group flex-1 flex flex-col min-h-0 gap-2">
{nodeData.isOptional && (
<span className="absolute top-1 left-1 z-10 text-[9px] font-medium text-neutral-300 bg-black/50 px-1.5 py-0.5 rounded">
Optional
</span>
)}
{/* Filename and duration */}
<div className="flex items-center justify-between shrink-0">
<span className="text-[10px] text-neutral-400 truncate max-w-[150px]" title={nodeData.filename || ""}>
{nodeData.filename}
</span>
<div className="flex items-center gap-1">
{nodeData.duration != null && (
<span className="text-[10px] text-neutral-500 bg-neutral-700/50 px-1.5 py-0.5 rounded">
{formatDuration(nodeData.duration)}
</span>
)}
{nodeData.dimensions && (
<span className="text-[10px] text-neutral-500 bg-neutral-700/50 px-1.5 py-0.5 rounded">
{nodeData.dimensions.width}x{nodeData.dimensions.height}
</span>
)}
</div>
</div>
{/* Video player */}
<div className="flex-1 min-h-[120px] bg-neutral-900/50 rounded overflow-hidden">
<video
src={playbackUrl ?? undefined}
controls
className="w-full h-full object-contain"
preload="metadata"
/>
</div>
{/* Remove button */}
<button
onClick={handleRemove}
className="absolute top-1 right-1 w-5 h-5 bg-black/60 text-white rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<div
role="button"
tabIndex={0}
aria-label="Upload video file"
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileInputRef.current?.click(); } }}
onDrop={handleDrop}
onDragOver={handleDragOver}
className={`w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center cursor-pointer hover:bg-neutral-800/60 transition-colors ${nodeData.isOptional ? "border-2 border-dashed border-neutral-600" : ""}`}
>
<svg className="w-8 h-8 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<span className="text-xs text-neutral-500 mt-2">
{nodeData.isOptional ? "Optional" : "Drop video or click"}
</span>
</div>
)}
<Handle
type="target"
position={Position.Left}
id="video"
data-handletype="video"
/>
<Handle
type="source"
position={Position.Right}
id="video"
data-handletype="video"
/>
</BaseNode>
);
}

1
src/components/nodes/index.ts

@ -1,5 +1,6 @@
export { ImageInputNode } from "./ImageInputNode";
export { AudioInputNode } from "./AudioInputNode";
export { VideoInputNode } from "./VideoInputNode";
export { AnnotationNode } from "./AnnotationNode";
export { PromptNode } from "./PromptNode";
export { ArrayNode } from "./ArrayNode";

10
src/lib/quickstart/validation.ts

@ -14,6 +14,7 @@ interface ValidationResult {
const VALID_NODE_TYPES: NodeType[] = [
"imageInput",
"audioInput",
"videoInput",
"annotation",
"prompt",
"array",
@ -43,6 +44,7 @@ const VALID_HANDLE_TYPES = ["image", "text", "audio", "video", "easeCurve", "3d"
const DEFAULT_DIMENSIONS: Record<NodeType, { width: number; height: number }> = {
imageInput: { width: 300, height: 280 },
audioInput: { width: 300, height: 200 },
videoInput: { width: 300, height: 280 },
annotation: { width: 300, height: 280 },
prompt: { width: 320, height: 220 },
array: { width: 360, height: 360 },
@ -232,6 +234,14 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData {
duration: null,
format: null,
};
case "videoInput":
return {
video: null,
filename: null,
duration: null,
dimensions: null,
format: null,
};
case "annotation":
return {
sourceImage: null,

8
src/store/execution/executeNode.ts

@ -53,6 +53,14 @@ export async function executeNode(
}
break;
}
case "videoInput": {
// If video is connected from upstream, use it (connection wins over upload)
const videoInputs = ctx.getConnectedInputs(ctx.node.id);
if (videoInputs.videos.length > 0 && videoInputs.videos[0]) {
ctx.updateNodeData(ctx.node.id, { video: videoInputs.videos[0] });
}
break;
}
case "annotation":
await executeAnnotation(ctx);
break;

3
src/store/utils/connectedInputs.ts

@ -10,6 +10,7 @@ import {
WorkflowEdge,
ImageInputNodeData,
AudioInputNodeData,
VideoInputNodeData,
AnnotationNodeData,
NanoBananaNodeData,
GenerateVideoNodeData,
@ -68,6 +69,8 @@ function getSourceOutput(
): { type: "image" | "text" | "video" | "audio" | "3d"; value: string | null } {
if (sourceNode.type === "imageInput") {
return { type: "image", value: (sourceNode.data as ImageInputNodeData).image };
} else if (sourceNode.type === "videoInput") {
return { type: "video", value: (sourceNode.data as VideoInputNodeData).video };
} else if (sourceNode.type === "audioInput") {
return { type: "audio", value: (sourceNode.data as AudioInputNodeData).audioFile };
} else if (sourceNode.type === "annotation") {

1
src/store/utils/executionUtils.ts

@ -134,6 +134,7 @@ export function clearNodeImageRefs(nodes: WorkflowNode[]): WorkflowNode[] {
delete data.sourceImageRef;
delete data.outputImageRef;
delete data.inputImageRefs;
delete data.videoRef;
return { ...node, data: data as WorkflowNodeData } as WorkflowNode;
});

10
src/store/utils/nodeDefaults.ts

@ -3,6 +3,7 @@ import {
ModelType,
ImageInputNodeData,
AudioInputNodeData,
VideoInputNodeData,
AnnotationNodeData,
PromptNodeData,
ArrayNodeData,
@ -37,6 +38,7 @@ import { loadGenerateImageDefaults, loadNodeDefaults } from "./localStorage";
export const defaultNodeDimensions: Record<NodeType, { width: number; height: number }> = {
imageInput: { width: 300, height: 280 },
audioInput: { width: 300, height: 200 },
videoInput: { width: 300, height: 280 },
annotation: { width: 300, height: 280 },
prompt: { width: 320, height: 220 },
array: { width: 340, height: 260 },
@ -97,6 +99,14 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
duration: null,
format: null,
} as AudioInputNodeData;
case "videoInput":
return {
video: null,
filename: null,
duration: null,
dimensions: null,
format: null,
} as VideoInputNodeData;
case "annotation":
return {
sourceImage: null,

12
src/store/workflowStore.ts

@ -1136,6 +1136,14 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
}
break;
}
case "videoInput": {
// If video is connected from upstream, use it (connection wins over upload)
const videoInputs = get().getConnectedInputs(node.id);
if (videoInputs.videos.length > 0 && videoInputs.videos[0]) {
get().updateNodeData(node.id, { video: videoInputs.videos[0] });
}
break;
}
case "glbViewer":
await executeGlbViewer(executionCtx);
break;
@ -1466,6 +1474,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
switch (node.type) {
case "imageInput":
case "audioInput":
case "videoInput":
// Data source nodes - no execution needed
break;
case "glbViewer":
@ -2017,6 +2026,9 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
mergedData.capturedImageRef = extData.capturedImageRef;
}
// Video refs
if (extData.videoRef && typeof extData.videoRef === 'string') {
mergedData.videoRef = extData.videoRef;
}
if (extData.outputVideoRef && typeof extData.outputVideoRef === 'string') {
mergedData.outputVideoRef = extData.outputVideoRef;
}

15
src/types/nodes.ts

@ -25,6 +25,7 @@ import type { LLMProvider, LLMModelType, SelectedModel, ProviderType } from "./p
export type NodeType =
| "imageInput"
| "audioInput"
| "videoInput"
| "annotation"
| "prompt"
| "array"
@ -75,6 +76,19 @@ export interface AudioInputNodeData extends BaseNodeData {
isOptional?: boolean;
}
/**
* Video input node - loads/uploads video files into the workflow
*/
export interface VideoInputNodeData extends BaseNodeData {
video: string | null; // Base64 data URL or blob URL
videoRef?: string; // External video reference for storage optimization
filename: string | null;
duration: number | null; // Duration in seconds
dimensions: { width: number; height: number } | null;
format: string | null; // MIME type (video/mp4, video/webm, etc.)
isOptional?: boolean;
}
/**
* Prompt node - text input for AI generation
*/
@ -456,6 +470,7 @@ export interface GLBViewerNodeData extends BaseNodeData {
export type WorkflowNodeData =
| ImageInputNodeData
| AudioInputNodeData
| VideoInputNodeData
| AnnotationNodeData
| PromptNodeData
| ArrayNodeData

28
src/utils/mediaStorage.ts

@ -158,6 +158,20 @@ async function externalizeNodeMedia(
break;
}
case "videoInput": {
const d = data as import("@/types").VideoInputNodeData;
// Externalize base64 video data
if (d.videoRef && isDataUrl(d.video)) {
newData = { ...d, video: null };
} else if (isDataUrl(d.video)) {
const videoRef = await saveVideoAndGetRef(d.video, workflowPath, savedMediaIds);
newData = { ...d, video: null, videoRef: videoRef || undefined };
} else {
newData = d;
}
break;
}
case "annotation": {
const d = data as import("@/types").AnnotationNodeData;
let sourceImageRef = d.sourceImageRef;
@ -821,6 +835,20 @@ async function hydrateNodeMedia(
break;
}
case "videoInput": {
const d = data as import("@/types").VideoInputNodeData;
if (d.videoRef && !d.video) {
const video = await loadMediaById(d.videoRef, workflowPath, loadedMedia, "video");
newData = {
...d,
video,
};
} else {
newData = d;
}
break;
}
case "annotation": {
const d = data as import("@/types").AnnotationNodeData;
let sourceImage = d.sourceImage;

Loading…
Cancel
Save