"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; import { Spin } from "antd"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; import { ImageInputNodeData } from "@/types"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { downloadMedia } from "@/utils/downloadMedia"; import { useI18n } from "@/i18n"; import { calculateAspectFitSize } from "@/utils/nodeDimensions"; import { MediaPreviewModal } from "@/components/MediaPreviewModal"; import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles"; import { AssetPickerModal, PopiAssetItem } from "@/components/AssetPickerModal"; import { defaultNodeDimensions } from "@/constants/nodeDimensions"; import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload"; import { getPopiImageAssetPreview, resolvePopiImageAsset } from "@/lib/popiAssetApi"; type ImageInputNodeType = Node; const IMAGE_INPUT_DIMENSIONS = defaultNodeDimensions.imageInput; const IMAGE_DIMENSION_TIMEOUT_MS = 10_000; const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600]; export interface ImageInputNodeViewProps { id: string; data: ImageInputNodeData; selected?: boolean; renderInputHandle?: boolean; } function getImageNodeTitle(id: string, data: ImageInputNodeData, imageLabel: string) { if (data.customTitle) return data.customTitle; if (data.label) return data.label; const match = id.match(/(\d+)$/); return match ? `${imageLabel} ${match[1]}` : imageLabel; } function ImageTypeIcon() { return ( ); } function EmptyImageIcon() { return ( ); } type ImageToolbarIconKind = | "download" | "focus" | "delete" | "upload" | "assets"; function ImageToolbarIcon({ kind }: { kind: ImageToolbarIconKind }) { const commonProps = { className: "h-6 w-6", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.8, }; switch (kind) { case "download": return ; case "focus": return ; case "delete": return ; case "upload": return ; case "assets": return ; } } export function ImageInputNodeView({ id, data, selected, renderInputHandle = false }: ImageInputNodeViewProps) { const { t } = useI18n(); const nodeData = data; const adaptiveImage = useAdaptiveImageSrc(nodeData.image, id); const preferredDisplayImage = nodeData.previewImage || adaptiveImage || nodeData.image; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const { setNodes } = useReactFlow(); const fileInputRef = useRef(null); const prevImageRef = useRef(null); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); const [imageRetryNonce, setImageRetryNonce] = useState(0); const [displayFallbackImage, setDisplayFallbackImage] = useState(null); const [imageLoadFailed, setImageLoadFailed] = useState(false); const isUploading = nodeData.uploadStatus === "loading"; const displayImage = displayFallbackImage || preferredDisplayImage; const imageSrc = displayImage; useEffect(() => { setDisplayFallbackImage(null); setImageLoadFailed(false); setImageRetryNonce(0); }, [preferredDisplayImage]); useEffect(() => { if (!nodeData.image || !nodeData.dimensions || nodeData.image === prevImageRef.current) { prevImageRef.current = nodeData.image ?? null; return; } prevImageRef.current = nodeData.image; const aspectRatio = nodeData.dimensions.width / nodeData.dimensions.height; if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) return; requestAnimationFrame(() => { 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 : IMAGE_INPUT_DIMENSIONS.width; const currentHeight = typeof node.height === "number" ? node.height : typeof node.style?.height === "number" ? node.style.height : IMAGE_INPUT_DIMENSIONS.height; const newSize = calculateAspectFitSize(aspectRatio, currentWidth, currentHeight, true); return { ...node, width: newSize.width, height: newSize.height, style: { ...node.style, width: newSize.width, height: newSize.height }, }; }) ); }); }, [id, nodeData.dimensions, nodeData.image, setNodes]); const handleFileChange = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; if (!file.type.match(/^image\/(png|jpeg|webp)$/)) { alert(t("upload.imageUnsupported")); return; } // if (file.size > 10 * 1024 * 1024) { // alert(t("upload.imageTooLarge")); // return; // } updateNodeData(id, { uploadStatus: "loading", uploadError: null }); const uploadImage = (dimensions: { width: number; height: number } | null) => { uploadFileToGatewayMedia(file, "image") .then((uploaded) => { updateNodeData(id, { image: uploaded.url, imageRef: undefined, assetId: undefined, previewImage: undefined, assetDetailLoading: false, assetDetailError: null, uploadStatus: "idle", uploadError: null, filename: uploaded.filename, dimensions, }); }) .catch((error) => { const message = error instanceof Error ? error.message : "Media upload failed"; updateNodeData(id, { uploadStatus: "idle", uploadError: message }); alert(message); }); }; const objectUrl = URL.createObjectURL(file); const img = new Image(); let settled = false; let timeout: number; const finishDimensionRead = (dimensions: { width: number; height: number } | null) => { if (settled) return; settled = true; clearTimeout(timeout); URL.revokeObjectURL(objectUrl); uploadImage(dimensions); }; timeout = window.setTimeout(() => { finishDimensionRead(null); }, IMAGE_DIMENSION_TIMEOUT_MS); img.onload = () => { finishDimensionRead({ width: img.width, height: img.height }); }; img.onerror = () => { finishDimensionRead(null); }; img.src = objectUrl; }, [id, t, updateNodeData] ); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); const file = e.dataTransfer.files?.[0]; if (!file) return; const dt = new DataTransfer(); dt.items.add(file); if (fileInputRef.current) { fileInputRef.current.files = dt.files; fileInputRef.current.dispatchEvent(new Event("change", { bubbles: true })); } }, [] ); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const handleOpenFileDialog = useCallback(() => { if (isUploading) return; fileInputRef.current?.click(); }, [isUploading]); const handleSelect = useCallback(() => { selectSingleNode(id); }, [id, selectSingleNode]); const handleRemove = useCallback(() => { updateNodeData(id, { image: null, imageRef: undefined, assetId: undefined, previewImage: undefined, assetDetailLoading: false, assetDetailError: null, filename: null, dimensions: null, }); }, [id, updateNodeData]); const handleSelectAsset = useCallback((asset: PopiAssetItem) => { const preview = getPopiImageAssetPreview(asset); if (!preview) return; updateNodeData(id, { image: preview.imageUrl, imageRef: undefined, assetId: preview.assetId, previewImage: preview.previewImageUrl, assetDetailLoading: true, assetDetailError: null, filename: preview.filename, dimensions: preview.dimensions, }); setIsAssetPickerOpen(false); const applyResolvedAsset = ( resolved: NonNullable>>, dimensions: { width: number; height: number } | null ) => { const currentNode = useWorkflowStore.getState().getNodeById(id); const currentData = currentNode?.data as ImageInputNodeData | undefined; if (currentData?.assetId !== preview.assetId) return; updateNodeData(id, { image: resolved.imageUrl, imageRef: undefined, assetId: resolved.assetId, previewImage: resolved.previewImageUrl, assetDetailLoading: false, assetDetailError: null, filename: resolved.filename, dimensions, }); }; void resolvePopiImageAsset(asset) .then((resolved) => { if (!resolved?.imageUrl) return; if (resolved.dimensions) { applyResolvedAsset(resolved, resolved.dimensions); return; } const img = new Image(); img.onload = () => applyResolvedAsset(resolved, { width: img.width, height: img.height }); img.onerror = () => applyResolvedAsset(resolved, preview.dimensions); img.src = resolved.imageUrl; }) .catch((error) => { const currentNode = useWorkflowStore.getState().getNodeById(id); const currentData = currentNode?.data as ImageInputNodeData | undefined; if (currentData?.assetId !== preview.assetId) return; updateNodeData(id, { assetDetailLoading: false, assetDetailError: error instanceof Error ? error.message : "Failed to load asset detail", }); }); }, [id, updateNodeData]); const title = getImageNodeTitle(id, nodeData, t("node.image")); const hasImage = Boolean(displayImage); const mediaActionVisibility = selected ? "opacity-100" : "opacity-0 group-hover:opacity-100"; return (
{title}
{selected && !hasImage && (
)} {selected && hasImage && (
)} {nodeData.image ? (
{ e.preventDefault(); e.stopPropagation(); setIsPreviewOpen(true); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setIsPreviewOpen(true); } }} className="relative group h-full w-full cursor-zoom-in overflow-clip rounded-lg bg-neutral-800" > {nodeData.filename setImageLoadFailed(false)} onError={() => { if (nodeData.previewImage && displayImage === nodeData.previewImage && nodeData.image && nodeData.image !== nodeData.previewImage) { setDisplayFallbackImage(nodeData.image); setImageLoadFailed(false); setImageRetryNonce(0); return; } const retryIndex = imageRetryNonce; const retryDelay = IMAGE_LOAD_RETRY_DELAYS_MS[retryIndex]; if (retryDelay !== undefined && displayImage && /^https?:\/\//i.test(displayImage)) { window.setTimeout(() => { setImageRetryNonce((current) => current === retryIndex ? current + 1 : current); }, retryDelay); return; } setImageLoadFailed(true); }} className="h-full w-full rounded-lg object-contain" /> {imageLoadFailed && (
{t("imageInput.loadFailed")} {nodeData.filename || nodeData.image}
)} {nodeData.isOptional && ( {t("common.optional")} )} {selected && ( <> )}
) : (
{ e.preventDefault(); e.stopPropagation(); selectSingleNode(id); handleOpenFileDialog(); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectSingleNode(id); handleOpenFileDialog(); } }} onDrop={handleDrop} onDragOver={handleDragOver} className="flex h-full min-h-28 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-neutral-700 bg-neutral-900/40 text-center transition-colors hover:border-neutral-500 hover:bg-neutral-900/60" > 拖入图片或点击 {nodeData.isOptional && {t("common.optional")}}
)} {isUploading && (
)} {/* Handles rendered after visual content so they paint on top */} {renderInputHandle && ( )} {isPreviewOpen && (nodeData.image || displayImage) && ( setIsPreviewOpen(false)} /> )} {isAssetPickerOpen && ( setIsAssetPickerOpen(false)} onSelect={handleSelectAsset} /> )}
); } export function ImageInputNode({ id, data, selected }: NodeProps) { return ; }