Browse Source
- Component with encoder detection on mount (checkEncoderSupport) - Dynamic video input handles (video-0, video-1, etc.) based on connections - Audio input handle at bottom (violet color, matching AudioInput) - Video output handle - Filmstrip UI with thumbnail extraction (seek to 25% duration) - Clip ordering via clipOrder array with drag-to-reorder support - X button per clip to disconnect - Processing trigger button (disabled until 2+ clips connected) - Progress overlay during stitching (shows percentage) - Output video preview on completion - Disabled state when encoder not supported - Export added to nodes/index.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
2 changed files with 500 additions and 0 deletions
@ -0,0 +1,499 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import React, { useCallback, useEffect, useMemo, useState, useRef } from "react"; |
||||
|
import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; |
||||
|
import { BaseNode } from "./BaseNode"; |
||||
|
import { useCommentNavigation } from "@/hooks/useCommentNavigation"; |
||||
|
import { useWorkflowStore } from "@/store/workflowStore"; |
||||
|
import { VideoStitchNodeData } from "@/types"; |
||||
|
import { checkEncoderSupport } from "@/hooks/useStitchVideos"; |
||||
|
|
||||
|
type VideoStitchNodeType = Node<VideoStitchNodeData, "videoStitch">; |
||||
|
|
||||
|
export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNodeType>) { |
||||
|
const nodeData = data; |
||||
|
const commentNavigation = useCommentNavigation(id); |
||||
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); |
||||
|
const edges = useWorkflowStore((state) => state.edges); |
||||
|
const nodes = useWorkflowStore((state) => state.nodes); |
||||
|
const [thumbnails, setThumbnails] = useState<Map<string, string>>(new Map()); |
||||
|
const regenerateNode = useWorkflowStore((state) => state.regenerateNode); |
||||
|
const isRunning = useWorkflowStore((state) => state.isRunning); |
||||
|
const removeEdge = useWorkflowStore((state) => state.removeEdge); |
||||
|
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map()); |
||||
|
|
||||
|
// Check encoder support on mount
|
||||
|
useEffect(() => { |
||||
|
if (nodeData.encoderSupported === null) { |
||||
|
checkEncoderSupport().then((supported) => { |
||||
|
updateNodeData(id, { encoderSupported: supported }); |
||||
|
}); |
||||
|
} |
||||
|
}, [id, nodeData.encoderSupported, updateNodeData]); |
||||
|
|
||||
|
// Get connected video edges
|
||||
|
const videoEdges = useMemo(() => { |
||||
|
return edges.filter( |
||||
|
(e) => e.target === id && e.targetHandle?.startsWith("video-") |
||||
|
); |
||||
|
}, [edges, id]); |
||||
|
|
||||
|
// Get ordered clips based on clipOrder or connection order
|
||||
|
const orderedClips = useMemo(() => { |
||||
|
const clipMap = new Map<string, { edge: any; sourceNode: any; videoData: string | null; duration: number | null }>(); |
||||
|
|
||||
|
videoEdges.forEach((edge) => { |
||||
|
const sourceNode = nodes.find((n) => n.id === edge.source); |
||||
|
if (!sourceNode) return; |
||||
|
|
||||
|
let videoData: string | null = null; |
||||
|
let duration: number | null = null; |
||||
|
|
||||
|
// Extract video data and duration from different node types
|
||||
|
if (sourceNode.type === "generateVideo") { |
||||
|
videoData = (sourceNode.data as any).outputVideo || null; |
||||
|
} |
||||
|
|
||||
|
clipMap.set(edge.id, { edge, sourceNode, videoData, duration }); |
||||
|
}); |
||||
|
|
||||
|
// Order by clipOrder if available, otherwise by edge creation time
|
||||
|
let ordered: Array<{ edgeId: string; edge: any; sourceNode: any; videoData: string | null; duration: number | null }>; |
||||
|
|
||||
|
if (nodeData.clipOrder && nodeData.clipOrder.length > 0) { |
||||
|
// Use clipOrder for ordering
|
||||
|
ordered = nodeData.clipOrder |
||||
|
.map((edgeId) => { |
||||
|
const clip = clipMap.get(edgeId); |
||||
|
if (!clip) return null; |
||||
|
return { edgeId, ...clip }; |
||||
|
}) |
||||
|
.filter((c): c is NonNullable<typeof c> => c !== null); |
||||
|
|
||||
|
// Append any new edges not in clipOrder
|
||||
|
videoEdges.forEach((edge) => { |
||||
|
if (!nodeData.clipOrder.includes(edge.id)) { |
||||
|
const clip = clipMap.get(edge.id); |
||||
|
if (clip) { |
||||
|
ordered.push({ edgeId: edge.id, ...clip }); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} else { |
||||
|
// Fall back to edge creation order (sort by data.createdAt if available)
|
||||
|
ordered = videoEdges |
||||
|
.sort((a, b) => { |
||||
|
const timeA = (a.data as any)?.createdAt ?? 0; |
||||
|
const timeB = (b.data as any)?.createdAt ?? 0; |
||||
|
return timeA - timeB; |
||||
|
}) |
||||
|
.map((edge) => { |
||||
|
const clip = clipMap.get(edge.id); |
||||
|
if (!clip) return null; |
||||
|
return { edgeId: edge.id, ...clip }; |
||||
|
}) |
||||
|
.filter((c): c is NonNullable<typeof c> => 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]); |
||||
|
|
||||
|
// Extract thumbnails from connected videos
|
||||
|
useEffect(() => { |
||||
|
const extractThumbnails = async () => { |
||||
|
const newThumbnails = new Map<string, string>(); |
||||
|
|
||||
|
for (const clip of orderedClips) { |
||||
|
if (!clip.videoData) continue; |
||||
|
if (thumbnails.has(clip.edgeId)) { |
||||
|
newThumbnails.set(clip.edgeId, thumbnails.get(clip.edgeId)!); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// Create video element
|
||||
|
const video = document.createElement("video"); |
||||
|
video.src = clip.videoData; |
||||
|
video.crossOrigin = "anonymous"; |
||||
|
video.muted = true; |
||||
|
|
||||
|
await new Promise<void>((resolve, reject) => { |
||||
|
video.onloadedmetadata = () => resolve(); |
||||
|
video.onerror = () => reject(new Error("Failed to load video")); |
||||
|
}); |
||||
|
|
||||
|
// Seek to 25% of duration
|
||||
|
const seekTime = video.duration * 0.25; |
||||
|
video.currentTime = seekTime; |
||||
|
|
||||
|
await new Promise<void>((resolve) => { |
||||
|
video.onseeked = () => resolve(); |
||||
|
}); |
||||
|
|
||||
|
// Draw frame to canvas
|
||||
|
const canvas = document.createElement("canvas"); |
||||
|
canvas.width = 160; |
||||
|
canvas.height = 120; |
||||
|
const ctx = canvas.getContext("2d"); |
||||
|
if (!ctx) continue; |
||||
|
|
||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); |
||||
|
const thumbnail = canvas.toDataURL("image/jpeg", 0.7); |
||||
|
newThumbnails.set(clip.edgeId, thumbnail); |
||||
|
|
||||
|
// Store duration
|
||||
|
clip.duration = video.duration; |
||||
|
} catch (error) { |
||||
|
console.warn(`Failed to extract thumbnail for clip ${clip.edgeId}:`, error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
setThumbnails(newThumbnails); |
||||
|
}; |
||||
|
|
||||
|
extractThumbnails(); |
||||
|
}, [orderedClips]); |
||||
|
|
||||
|
// Drag and drop for reordering clips
|
||||
|
const [draggedClipId, setDraggedClipId] = useState<string | null>(null); |
||||
|
|
||||
|
const handleDragStart = useCallback((e: React.DragEvent, edgeId: string) => { |
||||
|
setDraggedClipId(edgeId); |
||||
|
e.dataTransfer.effectAllowed = "move"; |
||||
|
}, []); |
||||
|
|
||||
|
const handleDragOver = useCallback((e: React.DragEvent) => { |
||||
|
e.preventDefault(); |
||||
|
e.dataTransfer.dropEffect = "move"; |
||||
|
}, []); |
||||
|
|
||||
|
const handleDrop = useCallback( |
||||
|
(e: React.DragEvent, targetEdgeId: string) => { |
||||
|
e.preventDefault(); |
||||
|
if (!draggedClipId || draggedClipId === targetEdgeId) { |
||||
|
setDraggedClipId(null); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const currentOrder = [...nodeData.clipOrder]; |
||||
|
const draggedIndex = currentOrder.indexOf(draggedClipId); |
||||
|
const targetIndex = currentOrder.indexOf(targetEdgeId); |
||||
|
|
||||
|
if (draggedIndex === -1 || targetIndex === -1) { |
||||
|
setDraggedClipId(null); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Reorder
|
||||
|
currentOrder.splice(draggedIndex, 1); |
||||
|
currentOrder.splice(targetIndex, 0, draggedClipId); |
||||
|
|
||||
|
updateNodeData(id, { clipOrder: currentOrder }); |
||||
|
setDraggedClipId(null); |
||||
|
}, |
||||
|
[draggedClipId, nodeData.clipOrder, id, updateNodeData] |
||||
|
); |
||||
|
|
||||
|
const handleRemoveClip = useCallback( |
||||
|
(edgeId: string) => { |
||||
|
removeEdge(edgeId); |
||||
|
}, |
||||
|
[removeEdge] |
||||
|
); |
||||
|
|
||||
|
const handleStitch = useCallback(() => { |
||||
|
regenerateNode(id); |
||||
|
}, [id, regenerateNode]); |
||||
|
|
||||
|
// Disable if encoder not supported
|
||||
|
if (nodeData.encoderSupported === false) { |
||||
|
return ( |
||||
|
<BaseNode |
||||
|
id={id} |
||||
|
title="Video Stitch" |
||||
|
customTitle={nodeData.customTitle} |
||||
|
comment={nodeData.comment} |
||||
|
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })} |
||||
|
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} |
||||
|
selected={selected} |
||||
|
commentNavigation={commentNavigation ?? undefined} |
||||
|
minWidth={500} |
||||
|
minHeight={280} |
||||
|
> |
||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-center px-4"> |
||||
|
<svg className="w-8 h-8 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> |
||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> |
||||
|
</svg> |
||||
|
<span className="text-xs text-neutral-400"> |
||||
|
Your browser doesn't support video encoding. |
||||
|
</span> |
||||
|
<a |
||||
|
href="https://discord.gg/placeholder" |
||||
|
target="_blank" |
||||
|
rel="noopener noreferrer" |
||||
|
className="text-[10px] text-blue-400 hover:text-blue-300 underline" |
||||
|
> |
||||
|
Doesn't seem right? Message Willie on Discord. |
||||
|
</a> |
||||
|
</div> |
||||
|
</BaseNode> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Checking encoder state
|
||||
|
if (nodeData.encoderSupported === null) { |
||||
|
return ( |
||||
|
<BaseNode |
||||
|
id={id} |
||||
|
title="Video Stitch" |
||||
|
customTitle={nodeData.customTitle} |
||||
|
comment={nodeData.comment} |
||||
|
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })} |
||||
|
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} |
||||
|
selected={selected} |
||||
|
commentNavigation={commentNavigation ?? undefined} |
||||
|
minWidth={500} |
||||
|
minHeight={280} |
||||
|
> |
||||
|
<div className="flex-1 flex items-center justify-center"> |
||||
|
<div className="flex items-center gap-2 text-neutral-400"> |
||||
|
<svg |
||||
|
className="w-4 h-4 animate-spin" |
||||
|
fill="none" |
||||
|
viewBox="0 0 24 24" |
||||
|
> |
||||
|
<circle |
||||
|
className="opacity-25" |
||||
|
cx="12" |
||||
|
cy="12" |
||||
|
r="10" |
||||
|
stroke="currentColor" |
||||
|
strokeWidth="3" |
||||
|
/> |
||||
|
<path |
||||
|
className="opacity-75" |
||||
|
fill="currentColor" |
||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
||||
|
/> |
||||
|
</svg> |
||||
|
<span className="text-xs">Checking encoder...</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</BaseNode> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Dynamic video input handles
|
||||
|
const videoHandles = useMemo(() => { |
||||
|
const count = Math.max(videoEdges.length + 1, 2); |
||||
|
return Array.from({ length: count }, (_, i) => ({ id: `video-${i}` })); |
||||
|
}, [videoEdges.length]); |
||||
|
|
||||
|
return ( |
||||
|
<BaseNode |
||||
|
id={id} |
||||
|
title="Video Stitch" |
||||
|
customTitle={nodeData.customTitle} |
||||
|
comment={nodeData.comment} |
||||
|
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })} |
||||
|
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} |
||||
|
onRun={handleStitch} |
||||
|
selected={selected} |
||||
|
isExecuting={isRunning} |
||||
|
hasError={nodeData.status === "error"} |
||||
|
commentNavigation={commentNavigation ?? undefined} |
||||
|
minWidth={500} |
||||
|
minHeight={280} |
||||
|
> |
||||
|
{/* Dynamic video input handles (left side) */} |
||||
|
{videoHandles.map((handle, index) => { |
||||
|
const topPercent = ((index + 1) / (videoHandles.length + 1)) * 100; |
||||
|
return ( |
||||
|
<Handle |
||||
|
key={handle.id} |
||||
|
type="target" |
||||
|
position={Position.Left} |
||||
|
id={handle.id} |
||||
|
data-handletype="video" |
||||
|
style={{ top: `${topPercent}%` }} |
||||
|
/> |
||||
|
); |
||||
|
})} |
||||
|
|
||||
|
{/* Audio input handle (left side, bottom) */} |
||||
|
<Handle |
||||
|
type="target" |
||||
|
position={Position.Left} |
||||
|
id="audio" |
||||
|
data-handletype="audio" |
||||
|
style={{ top: "90%", background: "rgb(167, 139, 250)" }} |
||||
|
/> |
||||
|
<div |
||||
|
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right" |
||||
|
style={{ |
||||
|
right: `calc(100% + 8px)`, |
||||
|
top: "calc(90% - 18px)", |
||||
|
color: "rgb(167, 139, 250)", |
||||
|
}} |
||||
|
> |
||||
|
Audio |
||||
|
</div> |
||||
|
|
||||
|
{/* Video output handle (right side) */} |
||||
|
<Handle |
||||
|
type="source" |
||||
|
position={Position.Right} |
||||
|
id="video" |
||||
|
data-handletype="video" |
||||
|
/> |
||||
|
|
||||
|
<div className="flex-1 flex flex-col min-h-0 gap-2"> |
||||
|
{/* Filmstrip UI */} |
||||
|
<div className="flex-1 flex flex-col min-h-0 gap-2"> |
||||
|
{orderedClips.length === 0 ? ( |
||||
|
<div className="flex-1 flex items-center justify-center border border-dashed border-neutral-600 rounded"> |
||||
|
<span className="text-[10px] text-neutral-500">Connect videos to stitch</span> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<> |
||||
|
{/* Filmstrip */} |
||||
|
<div className="flex-1 overflow-x-auto overflow-y-hidden nowheel flex gap-2 p-2 bg-neutral-900/50 rounded"> |
||||
|
{orderedClips.map((clip) => { |
||||
|
const thumbnail = thumbnails.get(clip.edgeId); |
||||
|
return ( |
||||
|
<div |
||||
|
key={clip.edgeId} |
||||
|
draggable |
||||
|
onDragStart={(e) => handleDragStart(e, clip.edgeId)} |
||||
|
onDragOver={handleDragOver} |
||||
|
onDrop={(e) => handleDrop(e, clip.edgeId)} |
||||
|
className="relative flex-shrink-0 w-20 h-[60px] bg-neutral-800 border border-neutral-600 rounded cursor-move hover:border-neutral-500 transition-colors group" |
||||
|
> |
||||
|
{thumbnail ? ( |
||||
|
<img |
||||
|
src={thumbnail} |
||||
|
alt={`Clip ${clip.edgeId}`} |
||||
|
className="w-full h-full object-cover rounded" |
||||
|
/> |
||||
|
) : ( |
||||
|
<div className="w-full h-full flex items-center justify-center"> |
||||
|
<svg |
||||
|
className="w-4 h-4 text-neutral-500" |
||||
|
fill="none" |
||||
|
viewBox="0 0 24 24" |
||||
|
stroke="currentColor" |
||||
|
> |
||||
|
<circle |
||||
|
className="opacity-25" |
||||
|
cx="12" |
||||
|
cy="12" |
||||
|
r="10" |
||||
|
stroke="currentColor" |
||||
|
strokeWidth="3" |
||||
|
/> |
||||
|
<path |
||||
|
className="opacity-75" |
||||
|
fill="currentColor" |
||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
||||
|
/> |
||||
|
</svg> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Duration badge */} |
||||
|
{clip.duration && ( |
||||
|
<div className="absolute bottom-1 right-1 bg-black/70 px-1 py-0.5 rounded text-[8px] text-white"> |
||||
|
{Math.round(clip.duration)}s |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Remove button */} |
||||
|
<button |
||||
|
onClick={() => handleRemoveClip(clip.edgeId)} |
||||
|
className="absolute top-0.5 right-0.5 w-4 h-4 bg-red-600/80 hover:bg-red-500 rounded text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center" |
||||
|
title="Disconnect" |
||||
|
> |
||||
|
<svg className="w-2.5 h-2.5" 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> |
||||
|
|
||||
|
{/* Stitch button */} |
||||
|
<button |
||||
|
onClick={handleStitch} |
||||
|
disabled={orderedClips.length < 2 || nodeData.status === "loading" || isRunning} |
||||
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-neutral-700 disabled:text-neutral-500 disabled:cursor-not-allowed rounded text-white text-xs font-medium transition-colors" |
||||
|
> |
||||
|
{nodeData.status === "loading" ? "Processing..." : "Stitch"} |
||||
|
</button> |
||||
|
</> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{/* Processing overlay */} |
||||
|
{nodeData.status === "loading" && ( |
||||
|
<div className="absolute inset-0 bg-neutral-900/70 rounded flex flex-col items-center justify-center gap-2"> |
||||
|
<svg |
||||
|
className="w-6 h-6 animate-spin text-white" |
||||
|
fill="none" |
||||
|
viewBox="0 0 24 24" |
||||
|
> |
||||
|
<circle |
||||
|
className="opacity-25" |
||||
|
cx="12" |
||||
|
cy="12" |
||||
|
r="10" |
||||
|
stroke="currentColor" |
||||
|
strokeWidth="3" |
||||
|
/> |
||||
|
<path |
||||
|
className="opacity-75" |
||||
|
fill="currentColor" |
||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
||||
|
/> |
||||
|
</svg> |
||||
|
<span className="text-white text-xs">Processing... {Math.round(nodeData.progress)}%</span> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Output preview */} |
||||
|
{nodeData.outputVideo && nodeData.status !== "loading" && ( |
||||
|
<div className="relative w-full"> |
||||
|
<video |
||||
|
src={nodeData.outputVideo} |
||||
|
controls |
||||
|
autoPlay |
||||
|
loop |
||||
|
muted |
||||
|
className="w-full h-auto max-h-40 object-contain rounded" |
||||
|
playsInline |
||||
|
/> |
||||
|
<button |
||||
|
onClick={() => updateNodeData(id, { outputVideo: null, status: "idle" })} |
||||
|
className="absolute top-1 right-1 w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors" |
||||
|
title="Clear video" |
||||
|
> |
||||
|
<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> |
||||
|
</BaseNode> |
||||
|
); |
||||
|
} |
||||
Loading…
Reference in new issue