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
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|