You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
509 lines
18 KiB
509 lines
18 KiB
"use client";
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Handle, Position, NodeProps, Node } 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);
|
|
|
|
// 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]);
|
|
|
|
// Sync clipOrder with connected edges (side effect, must be in useEffect)
|
|
useEffect(() => {
|
|
const currentEdgeIds = videoEdges.map((e) => e.id);
|
|
const currentOrder = nodeData.clipOrder || [];
|
|
|
|
// Keep existing order for edges that still exist, append new ones
|
|
const validExisting = currentOrder.filter((eid) => currentEdgeIds.includes(eid));
|
|
const newEdges = currentEdgeIds.filter((eid) => !currentOrder.includes(eid));
|
|
const newOrder = [...validExisting, ...newEdges];
|
|
|
|
if (
|
|
newOrder.length !== currentOrder.length ||
|
|
!newOrder.every((eid, idx) => eid === currentOrder[idx])
|
|
) {
|
|
updateNodeData(id, { clipOrder: newOrder });
|
|
}
|
|
}, [videoEdges, nodeData.clipOrder, id, updateNodeData]);
|
|
|
|
// 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;
|
|
|
|
if (sourceNode.type === "generateVideo") {
|
|
videoData = (sourceNode.data as any).outputVideo || null;
|
|
}
|
|
|
|
clipMap.set(edge.id, { edge, sourceNode, videoData, duration });
|
|
});
|
|
|
|
let ordered: Array<{ edgeId: string; edge: any; sourceNode: any; videoData: string | null; duration: number | null }>;
|
|
|
|
if (nodeData.clipOrder && nodeData.clipOrder.length > 0) {
|
|
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 yet
|
|
videoEdges.forEach((edge) => {
|
|
if (!nodeData.clipOrder.includes(edge.id)) {
|
|
const clip = clipMap.get(edge.id);
|
|
if (clip) {
|
|
ordered.push({ edgeId: edge.id, ...clip });
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
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);
|
|
}
|
|
|
|
return ordered;
|
|
}, [videoEdges, nodes, nodeData.clipOrder]);
|
|
|
|
// 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 {
|
|
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"));
|
|
});
|
|
|
|
const seekTime = video.duration * 0.25;
|
|
video.currentTime = seekTime;
|
|
|
|
await new Promise<void>((resolve) => {
|
|
video.onseeked = () => resolve();
|
|
});
|
|
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
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]);
|
|
|
|
// 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]);
|
|
|
|
// Shared handles rendered in ALL states so connections always work
|
|
const renderHandles = () => (
|
|
<>
|
|
{/* 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"
|
|
isConnectable={true}
|
|
style={{ top: `${topPercent}%` }}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Audio input handle (left side, bottom) */}
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="audio"
|
|
data-handletype="audio"
|
|
isConnectable={true}
|
|
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"
|
|
isConnectable={true}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
// 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}
|
|
>
|
|
{renderHandles()}
|
|
<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}
|
|
>
|
|
{renderHandles()}
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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}
|
|
>
|
|
{renderHandles()}
|
|
|
|
<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>
|
|
);
|
|
}
|
|
|