"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; 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 ( ); } function DeleteIcon() { return ( ); } export function OutputNode({ id, data, selected }: NodeProps) { 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(null); const previousEdgeCountRef = useRef(null); const videoAutoplayRef = useVideoAutoplay(id, selected); const typeLabels = useMemo>(() => ({ image: t("toolbar.image"), video: t("toolbar.video"), audio: t("modelSearch.audio"), "3d": "3D", }), [t]); const outputItems = useMemo(() => { 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 ( <>
{title}
{selected && (
)}
{outputItems.length > 1 && (
{outputItems.map((item) => { const isActive = activeItem?.id === item.id; return ( ); })}
)} {contentSrc ? ( <> {isAudio ? (
) : is3D ? (
3D model {contentSrc}
) : (
setShowLightbox(true)} > {isVideo ? (
)} {outputItems.length > 1 && ( )} ) : (
Connect input
)}
{/* Lightbox Modal (skip for audio and 3D files) */} {showLightbox && contentSrc && !isAudio && !is3D && (
setShowLightbox(false)} >
{isVideo ? (
)} ); }