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.
232 lines
8.7 KiB
232 lines
8.7 KiB
"use client";
|
|
|
|
import { useCallback, useState, useMemo, useEffect, useRef } 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 { OutputNodeData } from "@/types";
|
|
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
|
|
|
|
type OutputNodeType = Node<OutputNodeData, "output">;
|
|
|
|
export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
|
|
const nodeData = data;
|
|
const commentNavigation = useCommentNavigation(id);
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
|
|
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
|
|
const connectedEdgeCount = useWorkflowStore(
|
|
(state) => state.edges.filter((edge) => edge.target === id).length
|
|
);
|
|
const isRunning = useWorkflowStore((state) => state.isRunning);
|
|
const [showLightbox, setShowLightbox] = useState(false);
|
|
const previousEdgeCountRef = useRef<number | null>(null);
|
|
|
|
// Determine if content is audio
|
|
const isAudio = useMemo(() => {
|
|
if (nodeData.audio) return true;
|
|
if (nodeData.contentType === "audio") return true;
|
|
if (nodeData.image?.startsWith("data:audio/")) return true;
|
|
return false;
|
|
}, [nodeData.audio, nodeData.contentType, nodeData.image]);
|
|
|
|
// Determine if content is video
|
|
const isVideo = useMemo(() => {
|
|
if (isAudio) return false;
|
|
if (nodeData.video) return true;
|
|
if (nodeData.contentType === "video") return true;
|
|
if (nodeData.image?.startsWith("data:video/")) return true;
|
|
if (nodeData.image?.includes(".mp4") || nodeData.image?.includes(".webm")) return true;
|
|
return false;
|
|
}, [isAudio, nodeData.video, nodeData.contentType, nodeData.image]);
|
|
|
|
// Get the content source (audio, video, or image)
|
|
const contentSrc = useMemo(() => {
|
|
if (nodeData.audio) return nodeData.audio;
|
|
if (nodeData.video) return nodeData.video;
|
|
return nodeData.image;
|
|
}, [nodeData.audio, nodeData.video, nodeData.image]);
|
|
|
|
const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null);
|
|
|
|
// Auto-trigger execution when a new connection is made
|
|
useEffect(() => {
|
|
if (previousEdgeCountRef.current === null) {
|
|
// First run — just record the baseline, don't trigger
|
|
previousEdgeCountRef.current = connectedEdgeCount;
|
|
return;
|
|
}
|
|
if (connectedEdgeCount > previousEdgeCountRef.current) {
|
|
regenerateNode(id);
|
|
}
|
|
previousEdgeCountRef.current = connectedEdgeCount;
|
|
}, [connectedEdgeCount, id, regenerateNode]);
|
|
|
|
// Handle Run button click
|
|
const handleRun = useCallback(() => {
|
|
regenerateNode(id);
|
|
}, [id, regenerateNode]);
|
|
|
|
const handleDownload = useCallback(async () => {
|
|
if (!contentSrc) return;
|
|
|
|
const timestamp = Date.now();
|
|
const extension = isAudio ? "mp3" : isVideo ? "mp4" : "png";
|
|
// Use custom filename if provided, otherwise use timestamp
|
|
const filename = nodeData.outputFilename
|
|
? `${nodeData.outputFilename}.${extension}`
|
|
: `generated-${timestamp}.${extension}`;
|
|
|
|
// Handle URL-based content (needs fetch + blob conversion)
|
|
if (contentSrc.startsWith("http://") || contentSrc.startsWith("https://")) {
|
|
try {
|
|
const response = await fetch(contentSrc);
|
|
const blob = await response.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
|
|
const link = document.createElement("a");
|
|
link.href = blobUrl;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(blobUrl);
|
|
} catch (error) {
|
|
console.error("Failed to download:", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle data URL content (direct download)
|
|
const link = document.createElement("a");
|
|
link.href = contentSrc;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}, [contentSrc, isAudio, isVideo, nodeData.outputFilename]);
|
|
|
|
return (
|
|
<>
|
|
<BaseNode
|
|
id={id}
|
|
selected={selected}
|
|
isExecuting={isRunning}
|
|
contentClassName="flex-1 min-h-0 relative"
|
|
className="min-w-[200px]"
|
|
>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="image"
|
|
data-handletype="image"
|
|
style={{ zIndex: 10 }}
|
|
/>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="audio"
|
|
data-handletype="audio"
|
|
style={{ top: "60%", background: "rgb(167, 139, 250)", zIndex: 10 }}
|
|
/>
|
|
|
|
<div className="relative w-full h-full overflow-hidden rounded-lg">
|
|
{contentSrc ? (
|
|
<>
|
|
{isAudio ? (
|
|
<div className="w-full h-full flex items-center justify-center p-4">
|
|
<audio
|
|
src={contentSrc}
|
|
controls
|
|
className="w-full rounded"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="relative cursor-pointer group w-full h-full"
|
|
onClick={() => setShowLightbox(true)}
|
|
>
|
|
{isVideo ? (
|
|
<video
|
|
src={videoBlobUrl ?? undefined}
|
|
controls
|
|
loop
|
|
muted
|
|
autoPlay
|
|
playsInline
|
|
className="w-full h-full object-cover"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={contentSrc}
|
|
alt="Output"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)}
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center pointer-events-none">
|
|
<span className="text-[10px] font-medium text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 px-2 py-1 rounded">
|
|
View full size
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={handleDownload}
|
|
className="absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white text-xs rounded transition-colors flex items-center gap-1"
|
|
title="Download"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
</button>
|
|
</>
|
|
) : (
|
|
<div className="w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center">
|
|
<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="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
|
</svg>
|
|
<span className="text-xs text-neutral-500 mt-2">Connect input</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</BaseNode>
|
|
|
|
{/* Lightbox Modal (skip for audio) */}
|
|
{showLightbox && contentSrc && !isAudio && (
|
|
<div
|
|
className="fixed inset-0 bg-black/90 z-[100] flex items-center justify-center p-8"
|
|
onClick={() => setShowLightbox(false)}
|
|
>
|
|
<div className="relative max-w-full max-h-full">
|
|
{isVideo ? (
|
|
<video
|
|
src={videoBlobUrl ?? undefined}
|
|
controls
|
|
loop
|
|
autoPlay
|
|
playsInline
|
|
className="max-w-full max-h-[90vh] object-contain rounded"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={contentSrc}
|
|
alt="Output full size"
|
|
className="max-w-full max-h-[90vh] object-contain rounded"
|
|
/>
|
|
)}
|
|
<button
|
|
onClick={() => setShowLightbox(false)}
|
|
className="absolute top-4 right-4 w-8 h-8 bg-white/10 hover:bg-white/20 rounded text-white text-sm transition-colors flex items-center justify-center"
|
|
>
|
|
<svg className="w-4 h-4" 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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|