Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-panorama-config-modal.tsx

524 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PanoramaProjection>(initialProjection);
const [sourceMode, setSourceMode] = useState<PanoramaSourceMode>(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<string[]>([]);
const [draftSelectedIds, setDraftSelectedIds] = useState<string[]>([]);
const [loadedImages, setLoadedImages] = useState<Record<string, boolean>>({});
const [failedImages, setFailedImages] = useState<Record<string, boolean>>({});
const canvasReferences = useMemo<PanoramaReferenceImage[]>(() => {
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<PanoramaReferenceImage[]>(() => {
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 (
<div className={`relative overflow-hidden bg-foreground/5 ${surfaceClass}`}>
<img
src={image.url}
alt={image.label}
draggable={false}
ref={trackImageReady(image.id)}
onLoad={() => 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 && <div className="absolute inset-0 animate-pulse bg-foreground/5" />}
{failed && (
<div className="absolute inset-0 grid place-items-center">
<span className="px-2 text-center text-[11px] leading-4 text-foreground/40">加载失败</span>
</div>
)}
</div>
);
};
const thumbSizeClass = sourceMode === "image" ? "w-[140px]" : "w-[100px]";
const selectedThumb = (image: PanoramaReferenceImage, index: number) => (
<div key={image.id} className={`group relative shrink-0 ${thumbSizeClass}`}>
{renderImageSurface(image, "aspect-[4/3] rounded-[var(--r-md)]")}
<span className="pointer-events-none absolute left-1.5 top-1.5 inline-flex items-center gap-1 rounded-full bg-black/55 px-1.5 py-0.5 text-[9px] font-medium text-white backdrop-blur-sm">
<span className="size-1 rounded-full" style={{ backgroundColor: image.color }} />
{sourceMode === "image" ? "源图" : `${index + 1}`}
</span>
<button
type="button"
onClick={() => setSelectedIds((current) => current.filter((id) => id !== image.id))}
aria-label={`移除${image.label}`}
className="absolute right-1 top-1 grid size-5 place-items-center rounded-full bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
>
<X className="size-3" />
</button>
</div>
);
const addTile = (
<button
type="button"
onClick={openAssetPicker}
className={`flex shrink-0 flex-col items-center justify-center gap-1.5 rounded-[var(--r-md)] bg-foreground/5 text-[10px] font-medium text-foreground/45 transition-colors hover:bg-foreground/10 hover:text-foreground/65 ${thumbSizeClass} aspect-[4/3]`}
>
<Plus className="size-4" strokeWidth={1.8} />
<span>添加</span>
</button>
);
return (
<>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
<header className="flex shrink-0 items-center gap-2.5 border-b border-border px-5 py-3">
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)] bg-foreground/5 text-foreground/70">
<Globe className="size-3.5" strokeWidth={1.8} />
</span>
<div className="min-w-0">
<h2 className="text-[13px] font-semibold leading-tight">全景图生成</h2>
<p className="text-[10px] text-foreground/45">AI 补全环境或直接转换</p>
</div>
</header>
<main className="ui-scrollbar nowheel min-h-0 min-w-0 flex-1 overflow-y-auto px-5 py-3">
<div className="space-y-3">
{/* 生成方式 */}
<div>
<div className="mb-1.5 text-[11px] font-semibold text-foreground">生成方式</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
aria-pressed={sourceMode === "ai"}
onClick={() => setSourceMode("ai")}
className={`flex items-center gap-2 rounded-[var(--r-md)] px-2.5 py-2 text-left transition-all ${
sourceMode === "ai"
? "bg-accent text-white"
: "bg-foreground/5 text-foreground hover:bg-foreground/10"
}`}
>
<Sparkles className="size-4 shrink-0" />
<div className="min-w-0">
<div className="text-[11px] font-semibold">AI 生成</div>
<div className="text-[9px] leading-tight opacity-75">补全环境</div>
</div>
</button>
<button
type="button"
aria-pressed={sourceMode === "image"}
onClick={() => setSourceMode("image")}
className={`flex items-center gap-2 rounded-[var(--r-md)] px-2.5 py-2 text-left transition-all ${
sourceMode === "image"
? "bg-accent text-white"
: "bg-foreground/5 text-foreground hover:bg-foreground/10"
}`}
>
<ImageIcon className="size-4 shrink-0" />
<div className="min-w-0">
<div className="text-[11px] font-semibold">图片直出</div>
<div className="text-[9px] leading-tight opacity-75">直接转换</div>
</div>
</button>
</div>
</div>
{/* 参考图 / 源图 */}
<div>
<div className="mb-1.5 flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold text-foreground">{sourceMode === "image" ? "全景源图" : "参考图"}</div>
<Button size="small" type="text" icon={<Plus className="size-3.5" />} onClick={openAssetPicker} className="!h-6 !px-2 !text-[10px]">
{sourceMode === "image" && selectedReferences.length > 0 ? "替换" : "添加"}
</Button>
</div>
<div className="flex flex-wrap gap-1.5">
{selectedReferences.map((image, index) => selectedThumb(image, index))}
{sourceMode !== "image" && addTile}
{sourceMode === "image" && selectedReferences.length === 0 && addTile}
</div>
</div>
{/* 场景描述 */}
{sourceMode === "ai" && (
<div>
<div className="mb-1.5 text-[11px] font-semibold text-foreground">场景描述</div>
<Input.TextArea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="例如:黄昏的海边木屋,能看到天空、地面和四周环境"
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</div>
)}
{/* 全景类型 */}
<div>
<div className="mb-1.5 text-[11px] font-semibold text-foreground">全景类型</div>
<div className="grid grid-cols-2 gap-2">
{(
[
{ 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 (
<button
key={option.value}
type="button"
aria-pressed={selected}
onClick={() => setProjection(option.value)}
className={`flex items-center gap-2 rounded-[var(--r-md)] px-2.5 py-2 text-left transition-all ${
selected
? "bg-accent text-white"
: "bg-foreground/5 text-foreground hover:bg-foreground/10"
}`}
>
<Icon className="size-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-semibold">{option.title}</span>
<span className={`rounded-sm px-1 py-px text-[9px] font-medium ${selected ? "bg-white/20" : "bg-foreground/8 text-foreground/55"}`}>{option.ratio}</span>
</div>
<div className={`text-[9px] leading-tight ${selected ? "opacity-80" : "text-foreground/50"}`}>{option.desc}</div>
</div>
</button>
);
})}
</div>
</div>
{/* 高级选项 */}
<div>
<div className="mb-1.5 text-[11px] font-semibold text-foreground">高级选项</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 rounded-[var(--r-md)] bg-foreground/5 px-2.5 py-1.5">
<div className="min-w-0">
<div className="text-[11px] font-semibold text-foreground">智能比例</div>
<div className="text-[9px] leading-tight text-foreground/50">模型不支持时自动裁切</div>
</div>
<Switch size="small" checked={smartBase} onChange={setSmartBase} />
</div>
{sourceMode === "ai" && (
<Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => onCopyPrompt?.(composed)} className="!h-6 !px-2 !text-[10px]">
复制完整提示词
</Button>
)}
</div>
</div>
</div>
</main>
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border px-5 py-2.5">
<span className={`truncate text-[10px] ${canSubmit ? "text-foreground/45" : "text-amber-500"}`}>
{sourceMode === "image" ? (directReference ? "已选源图" : "请选择源图") : selectedReferences.length > 0 ? `${selectedReferences.length} 张参考图` : "无参考图"}
</span>
<div className="flex shrink-0 items-center gap-2">
<Button size="small" onClick={onCancel}>
取消
</Button>
<Button size="small" type="primary" icon={sourceMode === "image" ? <ImageIcon className="size-3.5" /> : <Sparkles className="size-3.5" />} disabled={!canSubmit} onClick={handleSubmit}>
{sourceMode === "image" ? "创建全景图" : "生成全景图"}
</Button>
</div>
</footer>
</div>
{/* 选图面板 */}
{isAssetPickerOpen && (
<div className="absolute inset-0 z-[260] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
<div className="flex max-h-full w-[500px] max-w-full flex-col overflow-hidden rounded-[var(--r-lg)] border border-border bg-background shadow-2xl">
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">{sourceMode === "image" ? "选择源图" : "选择参考图"}</div>
<div className="mt-0.5 text-[11px] text-foreground/45">
{sourceMode === "image" ? "只选一张作为全景源" : `已选 ${draftSelectedIds.length} 张`}
</div>
</div>
<button
type="button"
onClick={() => setIsAssetPickerOpen(false)}
aria-label="关闭"
className="grid size-6 shrink-0 place-items-center rounded-[var(--r-md)] text-foreground/45 transition-colors hover:bg-foreground/5 hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="border-b border-border px-5 py-2">
<Input size="small" allowClear value={assetQuery} onChange={(event) => setAssetQuery(event.target.value)} placeholder="搜索图片名称" prefix={<Search className="size-3.5 text-foreground/35" />} />
</div>
<div className="ui-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
{filteredReferences.length === 0 ? (
<div className="rounded-[var(--r-md)] bg-foreground/5 px-4 py-8 text-center text-[11px] text-foreground/45">没有匹配的图片</div>
) : (
<div className="grid grid-cols-5 gap-2">
{filteredReferences.map((image) => {
const selected = draftSelectedIds.includes(image.id);
const failed = failedImages[image.id];
const loaded = loadedImages[image.id];
return (
<button
key={image.id}
type="button"
onClick={() => toggleDraftReference(image.id)}
aria-pressed={selected}
className={`group relative overflow-hidden rounded-[var(--r-md)] transition-shadow ${selected ? "ring-2 ring-accent" : ""}`}
>
{renderImageSurface(image, "aspect-square w-full")}
{selected && (
<span className="absolute right-0.5 top-0.5 grid size-4 place-items-center rounded-full bg-accent text-white">
<Check className="size-2.5" />
</span>
)}
</button>
);
})}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<Button size="small" onClick={() => setIsAssetPickerOpen(false)}>
取消
</Button>
<Button size="small" type="primary" disabled={sourceMode === "image" && draftSelectedIds.length === 0} onClick={confirmAssetPicker}>
{sourceMode === "image" ? "使用此图" : `加入 ${draftSelectedIds.length}`}
</Button>
</div>
</div>
</div>
)}
</>
);
},
);
PanoramaSetupForm.displayName = "PanoramaSetupForm";
export function CanvasPanoramaConfigModal({ open, onCancel, onConfirm, onCopyPrompt, previewImageUrl, initialProjection, initialSourceMode, initialSmartBase, nodes }: CanvasPanoramaConfigModalProps) {
return (
<AppModal open={open} centered footer={null} width={680} flush onCancel={onCancel}>
<div className="flex min-h-0 flex-col overflow-hidden" style={{ maxHeight: "min(640px, calc(100vh - 100px))" }}>
<div className="relative flex min-h-0 flex-1">
<PanoramaSetupForm
onSubmit={onConfirm}
onCancel={onCancel}
onCopyPrompt={onCopyPrompt}
previewImageUrl={previewImageUrl}
initialProjection={initialProjection}
initialSourceMode={initialSourceMode}
initialSmartBase={initialSmartBase}
canvasNodes={nodes}
/>
</div>
</div>
</AppModal>
);
}