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.
 
 

395 lines
16 KiB

"use client";
import { useCallback, useState, useMemo, useEffect, useRef } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { OutputNodeData } from "@/types";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { useVideoAutoplay } from "@/hooks/useVideoAutoplay";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia, MediaType } from "@/utils/downloadMedia";
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
import { HandleLabel } from "./HandleLabel";
import { useI18n } from "@/i18n";
import { defaultNodeDimensions } from "@/constants/nodeDimensions";
type OutputNodeType = Node<OutputNodeData, "output">;
type OutputKind = "image" | "video" | "audio" | "3d";
const OUTPUT_DIMENSIONS = defaultNodeDimensions.output;
interface OutputItem {
id: string;
kind: OutputKind;
src: string;
label: string;
}
function isVideoSource(src: string): boolean {
return src.startsWith("data:video/") || src.includes(".mp4") || src.includes(".webm");
}
function isAudioSource(src: string): boolean {
return src.startsWith("data:audio/");
}
function is3DSource(src: string): boolean {
return src.includes(".glb") || src.includes(".gltf");
}
function getOutputNodeTitle(id: string, data: OutputNodeData, label: string) {
if (data.customTitle) return data.customTitle;
if (data.label) return data.label;
const match = id.match(/(\d+)$/);
return match ? `${label} ${match[1]}` : label;
}
function OutputTypeIcon() {
return (
<span className="flex h-7 w-12 items-center justify-center rounded-full bg-rose-600 text-white shadow-lg shadow-rose-500/20">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.9}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5h14v14H5z" />
<path strokeLinecap="round" strokeLinejoin="round" d="m8 15 3-3 2 2 3-4 3 5" />
</svg>
</span>
);
}
function DeleteIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 7h12M9 7V5h6v2m-8 0 1 13h8l1-13" />
</svg>
);
}
export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
const nodeData = data;
const { t } = useI18n();
const removeNode = useWorkflowStore((state) => state.removeNode);
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 showLabels = useShowHandleLabels(selected);
const [showLightbox, setShowLightbox] = useState(false);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const previousEdgeCountRef = useRef<number | null>(null);
const videoAutoplayRef = useVideoAutoplay(id, selected);
const typeLabels = useMemo<Record<OutputKind, string>>(() => ({
image: t("toolbar.image"),
video: t("toolbar.video"),
audio: t("modelSearch.audio"),
"3d": "3D",
}), [t]);
const outputItems = useMemo<OutputItem[]>(() => {
const items: OutputItem[] = [];
const addItem = (kind: OutputKind, src: string | null | undefined) => {
if (!src) return;
if (items.some((item) => item.kind === kind && item.src === src)) return;
items.push({
id: `${kind}:${src}`,
kind,
src,
label: typeLabels[kind],
});
};
const imageKind: OutputKind | null = nodeData.image
? nodeData.contentType === "video" || isVideoSource(nodeData.image)
? "video"
: nodeData.contentType === "audio" || isAudioSource(nodeData.image)
? "audio"
: nodeData.contentType === "3d" || is3DSource(nodeData.image)
? "3d"
: "image"
: null;
if (imageKind === "image") addItem("image", nodeData.image);
addItem("video", nodeData.video ?? (imageKind === "video" ? nodeData.image : null));
addItem("audio", nodeData.audio ?? (imageKind === "audio" ? nodeData.image : null));
addItem("3d", nodeData.model3d ?? (imageKind === "3d" ? nodeData.image : null));
return items;
}, [nodeData.audio, nodeData.contentType, nodeData.image, nodeData.model3d, nodeData.video, typeLabels]);
const preferredItemId = useMemo(() => {
if (outputItems.length === 0) return null;
const explicitItem = nodeData.contentType
? outputItems.find((item) => item.kind === nodeData.contentType)
: null;
if (explicitItem) return explicitItem.id;
const legacyPriority: OutputKind[] = ["audio", "video", "3d", "image"];
return legacyPriority
.map((kind) => outputItems.find((item) => item.kind === kind))
.find(Boolean)?.id ?? outputItems[0].id;
}, [nodeData.contentType, outputItems]);
const activeItem = useMemo(() => {
if (outputItems.length === 0) return null;
return outputItems.find((item) => item.id === selectedItemId)
?? outputItems.find((item) => item.id === preferredItemId)
?? outputItems[0];
}, [outputItems, preferredItemId, selectedItemId]);
const contentSrc = activeItem?.src ?? null;
const isAudio = activeItem?.kind === "audio";
const isVideo = activeItem?.kind === "video";
const is3D = activeItem?.kind === "3d";
const imageSrc = activeItem?.kind === "image" ? contentSrc : null;
const adaptiveImage = useAdaptiveImageSrc(imageSrc, id);
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]);
const handleDelete = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
removeNode(id);
}, [id, removeNode]);
const handleDownload = useCallback(async () => {
if (!contentSrc || !activeItem) return;
const type: MediaType = activeItem.kind;
try {
await downloadMedia(contentSrc, type, nodeData.outputFilename ?? undefined);
} catch (err) {
console.error("Download failed:", err);
}
}, [activeItem, contentSrc, nodeData.outputFilename]);
const handleDownloadAll = useCallback(async () => {
for (const item of outputItems) {
const filename = nodeData.outputFilename
? `${nodeData.outputFilename}-${item.kind}`
: undefined;
try {
await downloadMedia(item.src, item.kind, filename);
} catch (err) {
console.error("Download failed:", err);
}
}
}, [nodeData.outputFilename, outputItems]);
const downloadCurrentTitle = outputItems.length > 1 && activeItem
? t("output.downloadCurrent", { type: activeItem.label })
: t("common.download");
const downloadAllTitle = t("output.downloadAll");
const title = getOutputNodeTitle(id, nodeData, t("node.output"));
return (
<>
<BaseNode
id={id}
selected={selected}
isExecuting={isRunning}
minWidth={OUTPUT_DIMENSIONS.width}
minHeight={OUTPUT_DIMENSIONS.height}
className={selected ? "!border-blue-500 !ring-0" : ""}
>
<div className="nodrag pointer-events-none absolute -top-10 left-0 z-[10001] flex items-center gap-2">
<OutputTypeIcon />
<span className="text-sm font-medium text-neutral-100 drop-shadow-sm">{title}</span>
</div>
{selected && (
<div className="nodrag nopan absolute left-1/2 -top-[86px] z-[10002] -translate-x-1/2">
<button
type="button"
onClick={handleDelete}
aria-label={t("prompt.deleteNode")}
className="flex h-10 min-w-[120px] items-center justify-center gap-2 rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-300 shadow-2xl shadow-black/40 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100"
>
<DeleteIcon />
<span className="whitespace-nowrap">{t("prompt.deleteNode")}</span>
</button>
</div>
)}
<Handle
type="target"
position={Position.Left}
id="image"
data-handletype="image"
style={{ top: "32%", zIndex: 10 }}
/>
<HandleLabel label={typeLabels.image} side="target" color="var(--handle-color-image)" top="calc(32% - 18px)" visible={showLabels} />
<Handle
type="target"
position={Position.Left}
id="video"
data-handletype="video"
style={{ top: "50%", zIndex: 10 }}
/>
<HandleLabel label={typeLabels.video} side="target" color="var(--handle-color-video)" top="calc(50% - 18px)" visible={showLabels} />
<Handle
type="target"
position={Position.Left}
id="audio"
data-handletype="audio"
style={{ top: "68%", background: "rgb(167, 139, 250)", zIndex: 10 }}
/>
<HandleLabel label={typeLabels.audio} side="target" color="var(--handle-color-audio)" top="calc(68% - 18px)" visible={showLabels} />
<div className="relative w-full h-full overflow-hidden rounded-lg">
{outputItems.length > 1 && (
<div className="absolute left-2 top-2 z-20 flex max-w-[calc(100%-5.5rem)] gap-1 overflow-x-auto rounded bg-black/50 p-1 backdrop-blur-sm">
{outputItems.map((item) => {
const isActive = activeItem?.id === item.id;
return (
<button
key={item.id}
type="button"
data-testid={`output-tab-${item.kind}`}
onClick={() => setSelectedItemId(item.id)}
aria-pressed={isActive}
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-medium transition-colors ${
isActive
? "bg-white text-neutral-900"
: "text-neutral-300 hover:bg-white/10 hover:text-white"
}`}
title={item.label}
>
{item.label} 1
</button>
);
})}
</div>
)}
{contentSrc ? (
<>
{isAudio ? (
<div className="w-full h-full flex items-center justify-center p-4">
<audio
src={contentSrc}
controls
className="w-full rounded"
/>
</div>
) : is3D ? (
<div className="w-full h-full flex flex-col items-center justify-center gap-2 p-4 text-center bg-neutral-900/60">
<svg className="w-9 h-9 text-sky-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9L12 21.75m0-9L3 7.5m9 5.25v9M3 7.5v9l9 5.25" />
</svg>
<span className="text-xs font-medium text-neutral-200">3D model</span>
<span className="max-w-full truncate text-[10px] text-neutral-500">
{contentSrc}
</span>
</div>
) : (
<div
className="relative cursor-pointer group w-full h-full"
onClick={() => setShowLightbox(true)}
>
{isVideo ? (
<video
ref={videoAutoplayRef}
src={videoBlobUrl ?? undefined}
controls
loop
muted
playsInline
className="w-full h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
) : (
<img
src={adaptiveImage ?? contentSrc}
alt="Output"
className="w-full h-full object-contain"
/>
)}
<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>
)}
{outputItems.length > 1 && (
<button
onClick={handleDownloadAll}
className="absolute top-2 right-10 p-1.5 bg-black/60 hover:bg-black/80 text-white text-xs rounded transition-colors flex items-center gap-1"
title={downloadAllTitle}
>
<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="M7 11l5 5 5-5M12 16V4M5 20h14" />
</svg>
</button>
)}
<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={downloadCurrentTitle}
>
<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 and 3D files) */}
{showLightbox && contentSrc && !isAudio && !is3D && (
<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>
)}
</>
);
}