import { memo, useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { Button, Input, Switch } from "antd"; import { Check, Compass, Copy, Globe, Image as ImageIcon, Info, Plus, Search, Sparkles, X } from "lucide-react"; import { AppModal } from "@/components/ui/product/app-modal"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; export type PanoramaProjection = "spherical" | "cylindrical"; export type PanoramaSourceMode = "ai" | "image"; export interface PanoramaReferenceImage { id: string; url: string; label: string; color?: string; } export interface PanoramaGenerateConfig { projection: PanoramaProjection; sourceMode: PanoramaSourceMode; referenceImages: PanoramaReferenceImage[]; directImageUrl?: string | null; smartBase: boolean; } interface CanvasPanoramaConfigModalProps { open: boolean; onCancel: () => void; onConfirm: (composedPrompt: string, config: PanoramaGenerateConfig) => void; onCopyPrompt?: (prompt: string) => void; previewImageUrl?: string | null; initialProjection?: PanoramaProjection; initialSourceMode?: PanoramaSourceMode; initialSmartBase?: boolean; nodes: CanvasNodeData[]; } const PANORAMA_COLORS = ["#60a5fa", "#34d399", "#fbbf24", "#f87171", "#a78bfa", "#fb7185", "#38bdf8", "#4ade80"]; function normalizeInitialSourceMode(mode: PanoramaSourceMode | undefined, hasPreview: boolean): PanoramaSourceMode { if (mode === "image") return "image"; if (mode === "ai") return "ai"; return hasPreview ? "image" : "ai"; } function buildPanoramaPrompt(sourceMode: PanoramaSourceMode, projection: PanoramaProjection, userPrompt: string): string { const base = userPrompt.trim(); const common = [ "最终图像必须适合导入全景查看器,作为包裹场景的环境球内壁或水平环绕背景使用", "画面中不要出现摄影师、相机、镜头、三脚架、头显或任何拍摄设备", "不要分屏拼贴,不要多宫格,不要画中画,不要插入小图,只保留一个连续环境", "不要水印,不要文字,不要界面元素,不要边框,不要明显拼接线或接缝", "除非用户另有说明,保持真实摄影质感、电影级光影和自然空间纵深", ].join(", "); const ratio = projection === "spherical" ? "2:1" : "4:1"; const fallbackText = `生成一张可用于全景查看器的完整沉浸式全景图,画面必须是比例${ratio}的等距柱状投影结构,宽度是高度的${projection === "spherical" ? "2倍" : "4倍"},结合导入图片的主体、材质、色彩和风格,并根据文字描述补全四周环境、天空或天花板、地面或地板,让左右边缘自然衔接。${common}`; if (!base) { return fallbackText; } return `${base}。${fallbackText}`; } const PanoramaSetupForm = memo( ({ onSubmit, onCancel, onCopyPrompt, previewImageUrl, initialProjection = "spherical", initialSourceMode, initialSmartBase = true, canvasNodes, }: { onSubmit: (composedPrompt: string, config: PanoramaGenerateConfig) => void; onCancel: () => void; onCopyPrompt?: (prompt: string) => void; previewImageUrl?: string | null; initialProjection?: PanoramaProjection; initialSourceMode?: PanoramaSourceMode; initialSmartBase?: boolean; canvasNodes: CanvasNodeData[]; }) => { const [projection, setProjection] = useState(initialProjection); const [sourceMode, setSourceMode] = useState(normalizeInitialSourceMode(initialSourceMode, Boolean(previewImageUrl))); const [smartBase, setSmartBase] = useState(initialSmartBase); const [prompt, setPrompt] = useState(""); const [assetQuery, setAssetQuery] = useState(""); const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [draftSelectedIds, setDraftSelectedIds] = useState([]); const [loadedImages, setLoadedImages] = useState>({}); const [failedImages, setFailedImages] = useState>({}); const canvasReferences = useMemo(() => { const list: PanoramaReferenceImage[] = []; canvasNodes.forEach((node) => { if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return; const raw = node.metadata?.content; if (!raw) return; const label = (node.title || "").trim() || `图${list.length + 1}`; list.push({ id: node.id, url: raw, label, color: PANORAMA_COLORS[list.length % PANORAMA_COLORS.length], }); }); return list; }, [canvasNodes]); const allReferences = useMemo(() => { if (!previewImageUrl) return canvasReferences; const exists = canvasReferences.some((item) => item.url === previewImageUrl); if (exists) return canvasReferences; return [ { id: "selected-preview", url: previewImageUrl, label: "当前图片", color: PANORAMA_COLORS[0], }, ...canvasReferences, ]; }, [canvasReferences, previewImageUrl]); useEffect(() => { if (selectedIds.length > 0) { const available = new Set(allReferences.map((item) => item.id)); setSelectedIds((current) => current.filter((id) => available.has(id))); return; } if (previewImageUrl) { const first = allReferences.find((item) => item.url === previewImageUrl) ?? allReferences[0]; if (first) setSelectedIds([first.id]); } }, [allReferences, previewImageUrl, selectedIds.length]); useEffect(() => { if (sourceMode !== "image") return; setSelectedIds((current) => (current.length > 1 ? current.slice(0, 1) : current)); setDraftSelectedIds((current) => (current.length > 1 ? current.slice(0, 1) : current)); }, [sourceMode]); const selectedReferences = useMemo(() => { const byId = new Map(allReferences.map((item) => [item.id, item])); return selectedIds.map((id) => byId.get(id)).filter((item): item is PanoramaReferenceImage => Boolean(item)); }, [allReferences, selectedIds]); const filteredReferences = useMemo(() => { const query = assetQuery.trim().toLowerCase(); if (!query) return allReferences; return allReferences.filter((item) => `${item.label} ${item.id}`.toLowerCase().includes(query)); }, [allReferences, assetQuery]); const defaultAiPrompt = "生成一张可用于全景查看器的完整沉浸式全景图,画面必须是比例2比1的等距柱状投影结构,宽度是高度的2倍,结合导入图片的主体、材质、色彩和风格,并根据文字描述补全四周环境、天空或天花板、地面或地板,让左右边缘自然衔接"; const effectivePrompt = sourceMode === "image" ? prompt.trim() : prompt.trim() || defaultAiPrompt; const composed = sourceMode === "image" ? effectivePrompt : buildPanoramaPrompt(sourceMode, projection, effectivePrompt); const directReference = selectedReferences[0] ?? null; const outputRatio = projection === "spherical" ? "2:1" : "4:1"; const canSubmit = sourceMode === "ai" || Boolean(directReference); const handleSubmit = useCallback(() => { if (sourceMode === "image" && !directReference) return; onSubmit(composed, { projection, sourceMode, smartBase, referenceImages: selectedReferences, directImageUrl: sourceMode === "image" ? (directReference?.url ?? null) : null, }); }, [composed, directReference, onSubmit, projection, selectedReferences, smartBase, sourceMode]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { event.preventDefault(); handleSubmit(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [handleSubmit]); const markImageLoaded = useCallback((id: string) => { setLoadedImages((current) => (current[id] ? current : { ...current, [id]: true })); }, []); const markImageFailed = useCallback((id: string) => { setFailedImages((current) => (current[id] ? current : { ...current, [id]: true })); }, []); const trackImageReady = useCallback( (id: string) => (element: HTMLImageElement | null) => { if (element?.complete && element.naturalWidth > 0) markImageLoaded(id); }, [markImageLoaded], ); const toggleDraftReference = (id: string) => { setDraftSelectedIds((current) => { if (sourceMode === "image") { return current.includes(id) ? [] : [id]; } return current.includes(id) ? current.filter((item) => item !== id) : [...current, id]; }); }; const openAssetPicker = () => { setDraftSelectedIds(selectedIds); setIsAssetPickerOpen(true); }; const confirmAssetPicker = () => { setSelectedIds(draftSelectedIds); setIsAssetPickerOpen(false); }; const renderImageSurface = (image: PanoramaReferenceImage, surfaceClass: string) => { const loaded = loadedImages[image.id]; const failed = failedImages[image.id]; return (
{image.label} markImageLoaded(image.id)} onError={() => markImageFailed(image.id)} className={`h-full w-full object-cover transition-opacity duration-300 ${loaded && !failed ? "opacity-100" : "opacity-0"}`} /> {!failed && !loaded &&
} {failed && (
加载失败
)}
); }; const thumbSizeClass = sourceMode === "image" ? "w-[140px]" : "w-[100px]"; const selectedThumb = (image: PanoramaReferenceImage, index: number) => (
{renderImageSurface(image, "aspect-[4/3] rounded-[var(--r-md)]")} {sourceMode === "image" ? "源图" : `${index + 1}`}
); const addTile = ( ); return ( <>

全景图生成

AI 补全环境或直接转换

{/* 生成方式 */}
生成方式
{/* 参考图 / 源图 */}
{sourceMode === "image" ? "全景源图" : "参考图"}
{selectedReferences.map((image, index) => selectedThumb(image, index))} {sourceMode !== "image" && addTile} {sourceMode === "image" && selectedReferences.length === 0 && addTile}
{/* 场景描述 */} {sourceMode === "ai" && (
场景描述
setPrompt(event.target.value)} placeholder="例如:黄昏的海边木屋,能看到天空、地面和四周环境" autoSize={{ minRows: 2, maxRows: 4 }} />
)} {/* 全景类型 */}
全景类型
{( [ { value: "spherical" as const, icon: Globe, title: "球形全景", desc: "720° 沉浸式", ratio: "2:1" }, { value: "cylindrical" as const, icon: Compass, title: "环绕全景", desc: "360° 街景", ratio: "4:1" }, ] as const ).map((option) => { const selected = projection === option.value; const Icon = option.icon; return ( ); })}
{/* 高级选项 */}
高级选项
智能比例
模型不支持时自动裁切
{sourceMode === "ai" && ( )}
{sourceMode === "image" ? (directReference ? "已选源图" : "请选择源图") : selectedReferences.length > 0 ? `${selectedReferences.length} 张参考图` : "无参考图"}
{/* 选图面板 */} {isAssetPickerOpen && (
{sourceMode === "image" ? "选择源图" : "选择参考图"}
{sourceMode === "image" ? "只选一张作为全景源" : `已选 ${draftSelectedIds.length} 张`}
setAssetQuery(event.target.value)} placeholder="搜索图片名称" prefix={} />
{filteredReferences.length === 0 ? (
没有匹配的图片
) : (
{filteredReferences.map((image) => { const selected = draftSelectedIds.includes(image.id); const failed = failedImages[image.id]; const loaded = loadedImages[image.id]; return ( ); })}
)}
)} ); }, ); PanoramaSetupForm.displayName = "PanoramaSetupForm"; export function CanvasPanoramaConfigModal({ open, onCancel, onConfirm, onCopyPrompt, previewImageUrl, initialProjection, initialSourceMode, initialSmartBase, nodes }: CanvasPanoramaConfigModalProps) { return (
); }