|
|
@ -1,9 +1,8 @@ |
|
|
"use client"; |
|
|
"use client"; |
|
|
|
|
|
|
|
|
import { useCallback, useState, useMemo, useEffect, useRef } from "react"; |
|
|
import { useCallback, useState, useMemo, useEffect, useRef } from "react"; |
|
|
import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; |
|
|
import { Handle, Position, NodeProps, Node } from "@xyflow/react"; |
|
|
import { BaseNode } from "./BaseNode"; |
|
|
import { BaseNode } from "./BaseNode"; |
|
|
import { useCommentNavigation } from "@/hooks/useCommentNavigation"; |
|
|
|
|
|
import { useWorkflowStore } from "@/store/workflowStore"; |
|
|
import { useWorkflowStore } from "@/store/workflowStore"; |
|
|
import { OutputNodeData } from "@/types"; |
|
|
import { OutputNodeData } from "@/types"; |
|
|
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl"; |
|
|
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl"; |
|
|
@ -12,11 +11,12 @@ import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; |
|
|
import { downloadMedia, MediaType } from "@/utils/downloadMedia"; |
|
|
import { downloadMedia, MediaType } from "@/utils/downloadMedia"; |
|
|
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels"; |
|
|
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels"; |
|
|
import { HandleLabel } from "./HandleLabel"; |
|
|
import { HandleLabel } from "./HandleLabel"; |
|
|
import { calculateAspectFitSize, getImageDimensions, getVideoDimensions } from "@/utils/nodeDimensions"; |
|
|
|
|
|
import { useI18n } from "@/i18n"; |
|
|
import { useI18n } from "@/i18n"; |
|
|
|
|
|
import { defaultNodeDimensions } from "@/constants/nodeDimensions"; |
|
|
|
|
|
|
|
|
type OutputNodeType = Node<OutputNodeData, "output">; |
|
|
type OutputNodeType = Node<OutputNodeData, "output">; |
|
|
type OutputKind = "image" | "video" | "audio" | "3d"; |
|
|
type OutputKind = "image" | "video" | "audio" | "3d"; |
|
|
|
|
|
const OUTPUT_DIMENSIONS = defaultNodeDimensions.output; |
|
|
|
|
|
|
|
|
interface OutputItem { |
|
|
interface OutputItem { |
|
|
id: string; |
|
|
id: string; |
|
|
@ -37,13 +37,37 @@ function is3DSource(src: string): boolean { |
|
|
return src.includes(".glb") || src.includes(".gltf"); |
|
|
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>) { |
|
|
export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) { |
|
|
const nodeData = data; |
|
|
const nodeData = data; |
|
|
const { t } = useI18n(); |
|
|
const { t } = useI18n(); |
|
|
const commentNavigation = useCommentNavigation(id); |
|
|
const removeNode = useWorkflowStore((state) => state.removeNode); |
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); |
|
|
|
|
|
const regenerateNode = useWorkflowStore((state) => state.regenerateNode); |
|
|
const regenerateNode = useWorkflowStore((state) => state.regenerateNode); |
|
|
const { setNodes } = useReactFlow(); |
|
|
|
|
|
const connectedEdgeCount = useWorkflowStore( |
|
|
const connectedEdgeCount = useWorkflowStore( |
|
|
(state) => state.edges.filter((edge) => edge.target === id).length |
|
|
(state) => state.edges.filter((edge) => edge.target === id).length |
|
|
); |
|
|
); |
|
|
@ -52,7 +76,6 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) { |
|
|
const [showLightbox, setShowLightbox] = useState(false); |
|
|
const [showLightbox, setShowLightbox] = useState(false); |
|
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null); |
|
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null); |
|
|
const previousEdgeCountRef = useRef<number | null>(null); |
|
|
const previousEdgeCountRef = useRef<number | null>(null); |
|
|
const prevFitKeyRef = useRef<string | null>(null); |
|
|
|
|
|
const videoAutoplayRef = useVideoAutoplay(id, selected); |
|
|
const videoAutoplayRef = useVideoAutoplay(id, selected); |
|
|
|
|
|
|
|
|
const typeLabels = useMemo<Record<OutputKind, string>>(() => ({ |
|
|
const typeLabels = useMemo<Record<OutputKind, string>>(() => ({ |
|
|
@ -123,55 +146,6 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) { |
|
|
const adaptiveImage = useAdaptiveImageSrc(imageSrc, id); |
|
|
const adaptiveImage = useAdaptiveImageSrc(imageSrc, id); |
|
|
const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null); |
|
|
const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
if (!contentSrc || isAudio || is3D) { |
|
|
|
|
|
prevFitKeyRef.current = null; |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const fitKey = `${isVideo ? "video" : "image"}:${contentSrc}`; |
|
|
|
|
|
if (fitKey === prevFitKeyRef.current) return; |
|
|
|
|
|
prevFitKeyRef.current = fitKey; |
|
|
|
|
|
|
|
|
|
|
|
requestAnimationFrame(() => { |
|
|
|
|
|
const dimensionsPromise = isVideo |
|
|
|
|
|
? getVideoDimensions(contentSrc) |
|
|
|
|
|
: getImageDimensions(contentSrc); |
|
|
|
|
|
|
|
|
|
|
|
dimensionsPromise.then((dims) => { |
|
|
|
|
|
if (!dims) return; |
|
|
|
|
|
|
|
|
|
|
|
const aspectRatio = dims.width / dims.height; |
|
|
|
|
|
if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) return; |
|
|
|
|
|
|
|
|
|
|
|
setNodes((nodes) => |
|
|
|
|
|
nodes.map((node) => { |
|
|
|
|
|
if (node.id !== id) return node; |
|
|
|
|
|
|
|
|
|
|
|
const currentWidth = typeof node.width === "number" |
|
|
|
|
|
? node.width |
|
|
|
|
|
: typeof node.style?.width === "number" |
|
|
|
|
|
? node.style.width |
|
|
|
|
|
: 300; |
|
|
|
|
|
const currentHeight = typeof node.height === "number" |
|
|
|
|
|
? node.height |
|
|
|
|
|
: typeof node.style?.height === "number" |
|
|
|
|
|
? node.style.height |
|
|
|
|
|
: 280; |
|
|
|
|
|
const newSize = calculateAspectFitSize(aspectRatio, currentWidth, currentHeight, true); |
|
|
|
|
|
|
|
|
|
|
|
return { |
|
|
|
|
|
...node, |
|
|
|
|
|
width: newSize.width, |
|
|
|
|
|
height: newSize.height, |
|
|
|
|
|
style: { ...node.style, width: newSize.width, height: newSize.height }, |
|
|
|
|
|
}; |
|
|
|
|
|
}) |
|
|
|
|
|
); |
|
|
|
|
|
}); |
|
|
|
|
|
}); |
|
|
|
|
|
}, [contentSrc, id, isAudio, is3D, isVideo, setNodes]); |
|
|
|
|
|
|
|
|
|
|
|
// Auto-trigger execution when a new connection is made
|
|
|
// Auto-trigger execution when a new connection is made
|
|
|
useEffect(() => { |
|
|
useEffect(() => { |
|
|
if (previousEdgeCountRef.current === null) { |
|
|
if (previousEdgeCountRef.current === null) { |
|
|
@ -185,10 +159,11 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) { |
|
|
previousEdgeCountRef.current = connectedEdgeCount; |
|
|
previousEdgeCountRef.current = connectedEdgeCount; |
|
|
}, [connectedEdgeCount, id, regenerateNode]); |
|
|
}, [connectedEdgeCount, id, regenerateNode]); |
|
|
|
|
|
|
|
|
// Handle Run button click
|
|
|
const handleDelete = useCallback((e: React.MouseEvent) => { |
|
|
const handleRun = useCallback(() => { |
|
|
e.preventDefault(); |
|
|
regenerateNode(id); |
|
|
e.stopPropagation(); |
|
|
}, [id, regenerateNode]); |
|
|
removeNode(id); |
|
|
|
|
|
}, [id, removeNode]); |
|
|
|
|
|
|
|
|
const handleDownload = useCallback(async () => { |
|
|
const handleDownload = useCallback(async () => { |
|
|
if (!contentSrc || !activeItem) return; |
|
|
if (!contentSrc || !activeItem) return; |
|
|
@ -217,6 +192,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) { |
|
|
? t("output.downloadCurrent", { type: activeItem.label }) |
|
|
? t("output.downloadCurrent", { type: activeItem.label }) |
|
|
: t("common.download"); |
|
|
: t("common.download"); |
|
|
const downloadAllTitle = t("output.downloadAll"); |
|
|
const downloadAllTitle = t("output.downloadAll"); |
|
|
|
|
|
const title = getOutputNodeTitle(id, nodeData, t("node.output")); |
|
|
|
|
|
|
|
|
return ( |
|
|
return ( |
|
|
<> |
|
|
<> |
|
|
@ -224,42 +200,53 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) { |
|
|
id={id} |
|
|
id={id} |
|
|
selected={selected} |
|
|
selected={selected} |
|
|
isExecuting={isRunning} |
|
|
isExecuting={isRunning} |
|
|
contentClassName="flex-1 min-h-0 relative" |
|
|
minWidth={OUTPUT_DIMENSIONS.width} |
|
|
className="min-w-[200px]" |
|
|
minHeight={OUTPUT_DIMENSIONS.height} |
|
|
aspectFitMedia={isAudio || is3D ? null : contentSrc} |
|
|
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 |
|
|
<Handle |
|
|
type="target" |
|
|
type="target" |
|
|
position={Position.Left} |
|
|
position={Position.Left} |
|
|
id="image" |
|
|
id="image" |
|
|
data-handletype="image" |
|
|
data-handletype="image" |
|
|
style={{ top: "28%", zIndex: 10 }} |
|
|
style={{ top: "32%", zIndex: 10 }} |
|
|
/> |
|
|
/> |
|
|
<HandleLabel label={typeLabels.image} side="target" color="var(--handle-color-image)" top="calc(28% - 18px)" visible={showLabels} /> |
|
|
<HandleLabel label={typeLabels.image} side="target" color="var(--handle-color-image)" top="calc(32% - 18px)" visible={showLabels} /> |
|
|
<Handle |
|
|
<Handle |
|
|
type="target" |
|
|
type="target" |
|
|
position={Position.Left} |
|
|
position={Position.Left} |
|
|
id="video" |
|
|
id="video" |
|
|
data-handletype="video" |
|
|
data-handletype="video" |
|
|
style={{ top: "44%", zIndex: 10 }} |
|
|
style={{ top: "50%", zIndex: 10 }} |
|
|
/> |
|
|
/> |
|
|
<HandleLabel label={typeLabels.video} side="target" color="var(--handle-color-video)" top="calc(44% - 18px)" visible={showLabels} /> |
|
|
<HandleLabel label={typeLabels.video} side="target" color="var(--handle-color-video)" top="calc(50% - 18px)" visible={showLabels} /> |
|
|
<Handle |
|
|
<Handle |
|
|
type="target" |
|
|
type="target" |
|
|
position={Position.Left} |
|
|
position={Position.Left} |
|
|
id="audio" |
|
|
id="audio" |
|
|
data-handletype="audio" |
|
|
data-handletype="audio" |
|
|
style={{ top: "60%", background: "rgb(167, 139, 250)", zIndex: 10 }} |
|
|
style={{ top: "68%", background: "rgb(167, 139, 250)", zIndex: 10 }} |
|
|
/> |
|
|
|
|
|
<HandleLabel label={typeLabels.audio} side="target" color="var(--handle-color-audio)" top="calc(60% - 18px)" visible={showLabels} /> |
|
|
|
|
|
<Handle |
|
|
|
|
|
type="target" |
|
|
|
|
|
position={Position.Left} |
|
|
|
|
|
id="3d" |
|
|
|
|
|
data-handletype="3d" |
|
|
|
|
|
style={{ top: "76%", background: "var(--handle-color-3d)", zIndex: 10 }} |
|
|
|
|
|
/> |
|
|
/> |
|
|
<HandleLabel label="3D" side="target" color="var(--handle-color-3d)" top="calc(76% - 18px)" visible={showLabels} /> |
|
|
<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"> |
|
|
<div className="relative w-full h-full overflow-hidden rounded-lg"> |
|
|
{outputItems.length > 1 && ( |
|
|
{outputItems.length > 1 && ( |
|
|
|