diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 587a0f157..23e4dad20 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -1617,3 +1617,14 @@ v2(用户反馈首版难看后重做):菜单改用 antd Dropdown(placeme - [ ] 多个绘图节点连接到生成节点后,参考栏显示各自最新缩略图;编辑并保存绘图后重新打开面板,缩略图随 revision 更新。 - [ ] 用拖动手柄或聚焦后按左右方向键调整参考顺序,标签编号和提示词中的 `@图片N` 同步更新;刷新画布后顺序保持,配置节点承接引用时同样有效。 - [ ] 明暗主题下拖动手柄、缩略图预览、删除按钮和横向滚动无错位;拖动引用不触发画布节点拖拽。 + +## 全景图工具与查看器增强(2026-09-10) + +图片节点工具栏「全景图」先打开配置弹窗再创建节点:弹窗含素材管理(参考图多选/图生模式单选、素材选择器搜索)、提示词合成(内置等距柱状投影约束模板,可展开查看最终提示词并复制)、生成方式(AI 生成/图生全景)、投影方式(720° 球体/360° 环绕)、智能比例兜底说明;配置写入节点 `metadata.panoramaConfig`,参考图节点一并连线。全景查看器新增:顶部工具栏(全屏环视、截图当前视角、导出四向视图、重置视角、退出)、方向键旋转、three.js 按需加载 spinner、投影方式标签;截图走 `preserveDrawingBuffer` 同步重渲染,四宫格临时切方形画幅并带方向标注;全屏为 portal 覆盖层(滚轮调视场角),展开时节点体退回静态预览保证同一时刻只有一个 WebGL 上下文。截图经 `addPanoramaCaptureNode` 上传并在源节点右侧按列错开创建派生图片节点。tsc 零错误,需浏览器验收: + +- [ ] 图片节点工具栏点「全景图」:弹出配置弹窗,左侧可加/删参考图(图生模式仅单选),中间提示词与「查看最终提示词」联动,右侧模式切换与说明文案正确。 +- [ ] 确认后在源图右侧创建全景节点并连线,选中且弹出对话节点;图生模式无连线时节点直接显示 `directImageUrl` 源图。 +- [ ] 全景节点点「进入环视」:加载中出现 spinner;拖拽环视、方向键旋转、重置视角正常;节点内滚轮仍归画布缩放。 +- [ ] 工具栏「截图当前视角」与「导出四向视图」:源节点右侧出现派生图片节点(四向视图为 2×2 带方向标注),多次导出按列错开不重叠;导出后视角自动还原。 +- [ ] 「全屏环视」:覆盖层展开,滚轮缩放视场角生效;退出全屏回到节点内环视且视角保留;展开期间节点体显示静态预览。 +- [ ] 分享只读页(未提供导出动作)不显示截图/四宫格按钮,仅保留环视、重置与全屏;明暗主题与性能模式(仅预览)行为不变。 diff --git a/web/src/components/canvas/canvas-image-toolbar-tools.tsx b/web/src/components/canvas/canvas-image-toolbar-tools.tsx index 8be18d5b4..82ebbe550 100644 --- a/web/src/components/canvas/canvas-image-toolbar-tools.tsx +++ b/web/src/components/canvas/canvas-image-toolbar-tools.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from "react"; -import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Crop, SlidersHorizontal, Smile, Sun, Upload, Scaling } from "lucide-react"; +import { Brush, Camera, Copy, FileText, Globe2, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Crop, SlidersHorizontal, Smile, Sun, Upload, Scaling } from "lucide-react"; import type { CanvasNodeData } from "@/types/canvas"; import type { NodeToolbarGroup } from "@/lib/canvas/tool-registry"; -type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "view"; +type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view"; type ImageToolHandlers = { onUpload: (node: CanvasNodeData) => void; @@ -19,6 +19,7 @@ type ImageToolHandlers = { onSuperResolve: (node: CanvasNodeData) => void; onAngle: (node: CanvasNodeData) => void; onLighting: (node: CanvasNodeData) => void; + onPanorama: (node: CanvasNodeData) => void; onViewImage: (node: CanvasNodeData) => void; onCopyPrompt: (node: CanvasNodeData) => void; onReversePrompt: (node: CanvasNodeData) => void; @@ -171,6 +172,16 @@ const imageToolDefinitions: ImageToolDefinition[] = [ order: 10, run: (node, handlers) => handlers.onViewImage(node), }, + { + id: "panorama", + label: "全景图", + section: "视角", + description: "基于该图片创建 360° 全景查看节点", + icon: () => , + group: "panorama", + order: 80, + run: (node, handlers) => handlers.onPanorama(node), + }, ]; export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageToolHandlers) { diff --git a/web/src/components/canvas/canvas-node-action-context.ts b/web/src/components/canvas/canvas-node-action-context.ts index 1942cea83..5c4f01e4c 100644 --- a/web/src/components/canvas/canvas-node-action-context.ts +++ b/web/src/components/canvas/canvas-node-action-context.ts @@ -22,6 +22,8 @@ export type CanvasNodeActionContextValue = { openPortraitClearance?: (node: CanvasNodeData) => void; /** 打开节点级 AI 审美批改报告。 */ openArtCritique?: (node: CanvasNodeData) => void; + /** 全景节点导出截图:上传 dataUrl 并在源节点右侧创建派生图片节点。 */ + addPanoramaCaptureNode?: (node: CanvasNodeData, dataUrl: string, title: string) => Promise | void; }; export const CanvasNodeActionContext = createContext({}); diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx index d3dd9d398..0ea86d70a 100644 --- a/web/src/components/canvas/canvas-node-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-toolbar.tsx @@ -42,6 +42,7 @@ type CanvasNodeToolbarProps = { onSuperResolve: (node: CanvasNodeData) => void; onAngle: (node: CanvasNodeData) => void; onLighting: (node: CanvasNodeData) => void; + onPanorama: (node: CanvasNodeData) => void; onViewImage: (node: CanvasNodeData) => void; onExtractVideoFrames: (node: CanvasNodeData) => void; onExtractAudioFromVideo: (node: CanvasNodeData) => void; @@ -103,6 +104,7 @@ export function CanvasNodeToolbar({ onSuperResolve, onAngle, onLighting, + onPanorama, onViewImage, onExtractVideoFrames, onExtractAudioFromVideo, @@ -219,7 +221,7 @@ export function CanvasNodeToolbar({ } copyText(prompt, "提示词已复制"); }; - const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onLighting, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt }); + const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt }); // 构建 ToolContext——供注册表解析工具 const nodeHoverHandlers = { @@ -281,7 +283,8 @@ export function CanvasNodeToolbar({ const portraitTools = compact ? [] : inGroup("portrait"); const viewpointTools = compact ? [] : inGroup("viewpoint"); const lightingTools = compact ? [] : inGroup("lighting"); - const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("lighting"), ...inGroup("process")] : inGroup("process"); + const panoramaTools = compact ? [] : inGroup("panorama"); + const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("lighting"), ...inGroup("panorama"), ...inGroup("process")] : inGroup("process"); const workspaceTools = narrow ? [] : inGroup("workspace"); const utilityTools = inGroup("utility"); const moreTools = [...(narrow ? [...primary.slice(1), ...inGroup("workspace")] : []), ...inGroup("more")]; @@ -314,6 +317,7 @@ export function CanvasNodeToolbar({ style={{ ...dockStyle, border: 0 }} > {primaryTools.map((tool) => )} + {panoramaTools.map((tool) => )} {portraitTools.length ? } tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null} {viewpointTools.map((tool) => )} {lightingTools.map((tool) => )} diff --git a/web/src/components/canvas/canvas-panorama-config-modal.tsx b/web/src/components/canvas/canvas-panorama-config-modal.tsx new file mode 100644 index 000000000..96922b203 --- /dev/null +++ b/web/src/components/canvas/canvas-panorama-config-modal.tsx @@ -0,0 +1,544 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { + CheckCircle2, + Compass, + Copy, + Globe, + HelpCircle, + Image as ImageIcon, + Info, + Plus, + Search, + Sparkles, + Type, + 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 projectionLabel = projection === 'spherical' ? '720度球形' : '360度环绕'; + 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, + onCopyPrompt, + previewImageUrl, + initialProjection = 'spherical', + initialSourceMode, + initialSmartBase = true, + canvasNodes, +}: { + onSubmit: (composedPrompt: string, config: PanoramaGenerateConfig) => 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 [showSmartHelp, setShowSmartHelp] = useState(false); + const [assetQuery, setAssetQuery] = useState(''); + const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + const [draftSelectedIds, setDraftSelectedIds] = 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 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]); + + 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 projectionButtons = ( +
+ + +
+ ); + + const modeButtons = ( +
+ + +
+ ); + + const smartControl = ( +
+ + + {showSmartHelp && ( +
+ AI生成全景图会优先提交目标比例;如果供应商没有 2:1 / 4:1,会选择最接近的宽比例生成,再裁切、羽化并归一化。 +
+ )} +
+ ); + + const body = ( +
+ + +
+
+