feat: 新增全景图节点与工具链支持

- 新增CanvasNodeMetadata的panoramaConfig配置字段
- 新增全景图工具栏分组与操作上下文方法
- 新增全景图配置弹窗、全景查看节点与环视功能
- 新增四向视图导出、全屏环视等交互功能
- 更新画布工具栏与页面逻辑接入全景图工具
This commit is contained in:
bensharp
2026-09-10 19:57:21 +08:00
committed by ddCat
parent af342e8c24
commit aee082fbf7
11 changed files with 993 additions and 52 deletions

View File

@@ -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 带方向标注),多次导出按列错开不重叠;导出后视角自动还原。
- [ ] 「全屏环视」:覆盖层展开,滚轮缩放视场角生效;退出全屏回到节点内环视且视角保留;展开期间节点体显示静态预览。
- [ ] 分享只读页(未提供导出动作)不显示截图/四宫格按钮,仅保留环视、重置与全屏;明暗主题与性能模式(仅预览)行为不变。

View File

@@ -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: () => <Globe2 className="size-3.5" />,
group: "panorama",
order: 80,
run: (node, handlers) => handlers.onPanorama(node),
},
];
export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageToolHandlers) {

View File

@@ -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> | void;
};
export const CanvasNodeActionContext = createContext<CanvasNodeActionContextValue>({});

View File

@@ -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) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{panoramaTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{viewpointTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{lightingTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}

View File

@@ -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<PanoramaProjection>(initialProjection);
const [sourceMode, setSourceMode] = useState<PanoramaSourceMode>(
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<string[]>([]);
const [draftSelectedIds, setDraftSelectedIds] = useState<string[]>([]);
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 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 = (
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setProjection('spherical')}
className={`flex items-center justify-center gap-1 rounded-md border px-2 py-1.5 text-xs transition ${projection === 'spherical' ? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white' : 'border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]'}`}
>
<Globe className="h-3 w-3" /> 720°球体
</button>
<button
type="button"
onClick={() => setProjection('cylindrical')}
className={`flex items-center justify-center gap-1 rounded-md border px-2 py-1.5 text-xs transition ${projection === 'cylindrical' ? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white' : 'border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]'}`}
>
<Compass className="h-3 w-3" /> 360°环绕
</button>
</div>
);
const modeButtons = (
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSourceMode('ai')}
className={`flex h-10 items-center justify-center gap-1 rounded-md border px-2 text-xs transition ${sourceMode !== 'image' ? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white' : 'border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]'}`}
>
<Type className="h-3 w-3" /> AI生成全景图
</button>
<button
type="button"
onClick={() => setSourceMode('image')}
className={`flex h-10 items-center justify-center gap-1 rounded-md border px-2 text-xs transition ${sourceMode === 'image' ? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white' : 'border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]'}`}
>
<ImageIcon className="h-3 w-3" /> 图生全景图
</button>
</div>
);
const smartControl = (
<div className="relative flex items-center gap-1.5">
<button
type="button"
onClick={() => setSmartBase((current) => !current)}
className={`inline-flex h-8 items-center gap-1 whitespace-nowrap rounded-md border px-2.5 text-xs transition-colors ${
smartBase ? 'border-[var(--color-accent)]/60 bg-[var(--color-accent)]/15 text-[var(--color-accent)]' : 'border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]'
}`}
>
{smartBase && <CheckCircle2 className="h-3 w-3" />}
智能比例合成
</button>
<button
type="button"
onClick={() => setShowSmartHelp((current) => !current)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
{showSmartHelp && (
<div className="absolute bottom-full right-0 z-30 mb-2 w-[340px] rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-[11px] leading-5 text-[var(--color-text-muted)] shadow-2xl">
AI生成全景图会优先提交目标比例;如果供应商没有 2:1 / 4:1,会选择最接近的宽比例生成,再裁切、羽化并归一化。
</div>
)}
</div>
);
const body = (
<div className="grid min-h-[560px] grid-cols-[270px_minmax(0,1fr)_330px]">
<aside className="flex flex-col border-r border-[var(--color-border)] bg-[var(--color-bg-subtle)]">
<div className="border-b border-[var(--color-border)] px-3 py-2">
<div className="text-xs font-semibold text-[var(--color-text)]">{sourceMode === 'image' ? '全景源图(单张)' : '全景素材'}</div>
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
{sourceMode === 'image'
? (selectedReferences.length > 0 ? '已选 1 张源图' : '请选择 1 张源图')
: `${selectedReferences.length} / ${allReferences.length} 张已加入`}
</div>
</div>
<div className="ui-scrollbar nowheel flex-1 overflow-y-auto p-2">
<div className="space-y-2">
{selectedReferences.map((image, index) => (
<div key={image.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-1.5">
<div className="aspect-video overflow-hidden rounded bg-[var(--color-bg-subtle)]">
<img src={image.url} alt={image.label} className="h-full w-full object-cover" draggable={false} />
</div>
<div className="mt-1 flex items-center gap-1">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: image.color }} />
<div className="min-w-0 flex-1 truncate text-[10px] text-[var(--color-text)]">{sourceMode === 'image' ? '源图' : `图${index + 1}`} · {image.label}</div>
<button
type="button"
onClick={() => setSelectedIds((current) => current.filter((id) => id !== image.id))}
className="rounded px-1 text-[10px] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"
>
×
</button>
</div>
</div>
))}
{(sourceMode !== 'image' || selectedReferences.length === 0) && (
<button
type="button"
onClick={openAssetPicker}
className="flex aspect-video w-full items-center justify-center rounded-lg border border-dashed border-[var(--color-border)] bg-[var(--color-bg-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-text-muted)] hover:text-[var(--color-text)]"
title={sourceMode === 'image' ? '选择全景源图' : '从资产加入图片'}
>
<Plus className="h-6 w-6" />
</button>
)}
{sourceMode === 'image' && selectedReferences.length > 0 && (
<button
type="button"
onClick={openAssetPicker}
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-2 text-[11px] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
title="替换源图"
>
替换源图
</button>
)}
</div>
</div>
</aside>
<main className="flex min-w-0 flex-col gap-4 p-6">
<div className="relative">
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={sourceMode === 'image'
? '可选备注:使用现成图片作为全景源,不会提交给 AI'
: '输入全景场景描述;文字+图片+内置全景提示词会一起提交'}
className="nodrag nowheel h-40 w-full resize-none rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-4 py-3 text-sm leading-6 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]/70 focus:border-[var(--color-accent)]"
/>
</div>
{sourceMode === 'ai' ? (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
AI生成全景图会把文字、导入图片和内置全景提示词一起提交,目标是先得到 2:1 全景底图,再进入全景查看器。
</div>
) : (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
图生全景图只使用第一张源图作为全景底图。图片接近 {projection === 'spherical' ? '2:1' : '4:1'} 时直接进入全景查看器;不符合时在本地裁切、羽化并归一化,不会提交给 AI。
</div>
)}
{sourceMode !== 'image' && (
<details className="text-[11px] text-[var(--color-text-muted)]">
<summary className="cursor-pointer hover:text-[var(--color-text)]">查看最终提示词</summary>
<pre className="mt-2 max-h-36 overflow-y-auto whitespace-pre-wrap rounded-lg bg-[var(--color-bg-subtle)] p-2 text-[11px] text-[var(--color-text-muted)]">{composed}</pre>
</details>
)}
<div className="mt-auto flex items-center justify-between gap-3 border-t border-[var(--color-border)] pt-4">
{sourceMode !== 'image' ? (
<div className="text-[11px] leading-5 text-[var(--color-text-muted)]">
使用当前配置生成全景图。
</div>
) : (
<div className="text-[11px] leading-5 text-[var(--color-text-muted)]">
使用现成图片作为全景源,比例处理在本地完成。
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={sourceMode === 'image' && !directReference}
className="flex h-8 items-center gap-1 whitespace-nowrap rounded-md bg-[var(--color-accent)] px-3 text-xs text-white hover:bg-[var(--color-accent)]/90 disabled:cursor-not-allowed disabled:opacity-40"
>
{sourceMode === 'image' ? <ImageIcon className="h-3 w-3" /> : <Sparkles className="h-3 w-3" />}
{sourceMode === 'image' ? '创建全景图' : '生成全景图'}
</button>
</div>
</div>
</main>
<aside className="flex flex-col gap-4 border-l border-[var(--color-border)] bg-[var(--color-bg-subtle)] p-4">
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">生成方式</div>
{modeButtons}
</section>
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">投影方式</div>
{projectionButtons}
</section>
{projection === 'cylindrical' && (
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-5 text-amber-700 dark:text-amber-200/85">
360° 环绕更适合横向街景/长廊;普通全景建议使用 720° 球体。
</div>
)}
<section className="flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">比例兜底</div>
<div className="flex items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span>
{sourceMode === 'image'
? `本地检查源图比例;符合 ${projection === 'spherical' ? '2:1' : '4:1'} 时直接使用,不符合时本地裁切、羽化并归一化。`
: `会优先请求 ${projection === 'spherical' ? '2:1' : '4:1'} 全景比例;如果当前供应商不支持,会自动改用最接近的宽比例,并在生成后走裁切/归一化兜底。`}
</span>
</div>
{sourceMode !== 'image' && (
<div className="flex flex-col gap-2 pt-1">
{smartControl}
<button
type="button"
onClick={() => onCopyPrompt?.(composed)}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 text-xs text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-hover)]"
title="复制完整提示词到剪贴板"
>
<Copy className="h-3 w-3" /> 复制提示词
</button>
</div>
)}
</section>
</aside>
</div>
);
return (
<>
{body}
{isAssetPickerOpen && (
<div className="absolute inset-0 z-[260] flex items-center justify-center bg-black/35 backdrop-blur-sm">
<div className="w-[560px] max-w-[calc(100%-32px)] rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text)]">{sourceMode === 'image' ? '选择要转换的全景源图' : '选择全景参考图'}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
{sourceMode === 'image'
? '图生全景图只使用一张现成图片作为全景源,不会提交给 AI'
: 'AI生成全景图支持多选,可作为风格 / 主体 / 元素参考'}
</div>
</div>
<button type="button" onClick={() => setIsAssetPickerOpen(false)} className="rounded-md p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]">
<X className="h-4 w-4" />
</button>
</div>
<div className="border-b border-[var(--color-border)] px-4 py-2">
<label className="flex h-8 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-2">
<Search className="h-3.5 w-3.5 text-[var(--color-text-muted)]" />
<input
value={assetQuery}
onChange={(event) => setAssetQuery(event.target.value)}
placeholder="搜索图片名称"
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]/70"
/>
</label>
</div>
<div className="ui-scrollbar max-h-[380px] overflow-y-auto p-4">
{filteredReferences.length === 0 ? (
<div className="rounded-lg border border-dashed border-[var(--color-border)] px-4 py-10 text-center text-xs text-[var(--color-text-muted)]">
没有匹配的图片资产
</div>
) : (
<div className="grid grid-cols-4 gap-2">
{filteredReferences.map((image) => {
const selected = draftSelectedIds.includes(image.id);
return (
<button
key={image.id}
type="button"
onClick={() => toggleDraftReference(image.id)}
className={`relative overflow-hidden rounded-lg border bg-[var(--color-bg)] text-left transition-colors ${
selected ? 'border-[var(--color-accent)]/80 ring-1 ring-[var(--color-accent)]/50' : 'border-[var(--color-border)] hover:border-[var(--color-text-muted)]'
}`}
>
<div className="aspect-square bg-[var(--color-bg-subtle)]">
<img src={image.url} alt={image.label} className="h-full w-full object-cover" draggable={false} />
</div>
<div className="truncate px-1.5 py-1 text-[10px] text-[var(--color-text)]">{image.label}</div>
{selected && <span className="absolute right-1 top-1 rounded bg-[var(--color-accent)] px-1 py-0.5 text-[9px] text-white">已选</span>}
</button>
);
})}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-[var(--color-border)] px-4 py-3">
<button type="button" onClick={() => setIsAssetPickerOpen(false)} className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]">
取消
</button>
<button
type="button"
onClick={confirmAssetPicker}
disabled={sourceMode === 'image' && draftSelectedIds.length === 0}
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs text-white hover:bg-[var(--color-accent)]/90 disabled:cursor-not-allowed disabled:opacity-45"
>
{sourceMode === 'image'
? (draftSelectedIds.length > 0 ? '使用此图' : '请选择一张源图')
: `加入 ${draftSelectedIds.length} 张`}
</button>
</div>
</div>
</div>
)}
</>
);
});
PanoramaSetupForm.displayName = 'PanoramaSetupForm';
export function CanvasPanoramaConfigModal({
open,
onCancel,
onConfirm,
onCopyPrompt,
previewImageUrl,
initialProjection,
initialSourceMode,
initialSmartBase,
nodes,
}: CanvasPanoramaConfigModalProps) {
return (
<AppModal
title="全景图生成"
open={open}
centered
footer={null}
width={980}
flush
onCancel={onCancel}
>
<div className="flex flex-col">
<div className="relative">
<PanoramaSetupForm
onSubmit={onConfirm}
onCopyPrompt={onCopyPrompt}
previewImageUrl={previewImageUrl}
initialProjection={initialProjection}
initialSourceMode={initialSourceMode}
initialSmartBase={initialSmartBase}
canvasNodes={nodes}
/>
</div>
</div>
</AppModal>
);
}

View File

@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { Globe, X } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Camera, Globe, Grid3x3, Loader2, Maximize2, RotateCcw, X } from "lucide-react";
import { useCanvasNodeActions } from "@/components/canvas/canvas-node-action-context";
import { useUpstreamNodes } from "@/components/canvas/canvas-node-graph-context";
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import type { CanvasTheme } from "@/lib/canvas-theme";
@@ -12,10 +14,31 @@ type PanoramaNodeContentProps = {
reduceMediaEffects?: boolean;
};
/** 节点外壳调用查看器能力的最小控制面;实现由 PanoramaViewport 注入。 */
type PanoramaViewportController = {
/** 渲染当前帧并返回 WebGL 画布(preserveDrawingBuffer 已开启,可直接 toDataURL/drawImage)。 */
captureCanvas: () => HTMLCanvasElement | null;
/** 以方形画幅渲染一帧,返回画布与恢复函数;四宫格导出用,画完必须调用 release 还原节点尺寸。 */
captureSquare: (size: number) => { canvas: HTMLCanvasElement; release: () => void } | null;
getView: () => { lon: number; lat: number };
setView: (lonDeg: number, latDeg: number) => void;
};
type PanoramaView = { lon: number; lat: number };
const PANORAMA_KEY_STEP = 4;
const PANORAMA_FOV_MIN = 25;
const PANORAMA_FOV_MAX = 110;
const PANORAMA_QUAD_SIZE = 1024;
function clampFov(value: number) {
return Math.max(PANORAMA_FOV_MIN, Math.min(PANORAMA_FOV_MAX, value));
}
/**
* 360° 全景查看器节点:取上游图片,拖拽环视。
*
* 三个刻意的取舍:
* 刻意的取舍:
* 1. **点击才激活 WebGL**。浏览器对同时存在的 WebGL 上下文有硬上限(十几个),
* 画布上可以有任意多个全景节点,自动激活必然在某个数量后整片黑。默认只显示平面预览,
* 与既有的 DeferredMediaLoad「点击加载」是同一套交互习惯。
@@ -23,20 +46,88 @@ type PanoramaNodeContentProps = {
* 上下文/纹理/几何体漏一个就逼近上限,节点还会被频繁增删。
* 3. **性能模式下不提供激活入口**。错题本里多条崩溃出在画布高频渲染,
* reduceMediaEffects 时只给静态预览。
* 4. **截图/四宫格导出走 preserveDrawingBuffer + 同步重渲染**。captureSquare
* 临时把 renderer 切到方形画幅再恢复,不依赖 rAF 时序;全屏展开时同一时刻
* 只挂一个 viewer 实例(节点体退回静态预览),上下文数量不翻倍。
* 5. **节点内不消费滚轮**,滚轮留给画布缩放;全屏覆盖层内画布缩放不存在,
* 才用滚轮调视场角。
*/
export function PanoramaNodeContent({ node, theme, reduceMediaEffects }: PanoramaNodeContentProps) {
const upstream = useUpstreamNodes(node.id);
const actions = useCanvasNodeActions();
const inherited = upstream.find((item) => getNodeResourceKind(item) === "image");
const url = node.metadata?.content || inherited?.metadata?.content || "";
const url = node.metadata?.content || inherited?.metadata?.content || node.metadata?.panoramaConfig?.directImageUrl || "";
const projection = node.metadata?.panoramaConfig?.projection || "spherical";
const projectionLabel = projection === "spherical" ? "720° 球体" : "360° 环绕";
const [active, setActive] = useState(false);
const [expanded, setExpanded] = useState(false);
const [failed, setFailed] = useState(false);
const controllerRef = useRef<PanoramaViewportController | null>(null);
const lastViewRef = useRef<PanoramaView | null>(null);
// 上游换图后退出环视,避免旧上下文继续持有已被替换的纹理。
useEffect(() => {
setActive(false);
setExpanded(false);
setFailed(false);
}, [url]);
const rememberView = () => {
const controller = controllerRef.current;
if (controller) lastViewRef.current = controller.getView();
};
const handleScreenshot = async () => {
const controller = controllerRef.current;
const exportNode = actions.addPanoramaCaptureNode;
if (!controller || !exportNode) return;
const canvas = controller.captureCanvas();
if (!canvas) return;
await exportNode(node, canvas.toDataURL("image/png"), `${node.title || "全景"} · 当前视角`);
};
const handleQuadExport = async () => {
const controller = controllerRef.current;
const exportNode = actions.addPanoramaCaptureNode;
if (!controller || !exportNode) return;
const restore = controller.getView();
const off = document.createElement("canvas");
off.width = PANORAMA_QUAD_SIZE * 2;
off.height = PANORAMA_QUAD_SIZE * 2;
const ctx = off.getContext("2d");
if (!ctx) return;
const yaws = [0, 90, 180, 270];
for (let index = 0; index < yaws.length; index += 1) {
controller.setView(yaws[index], 0);
const capture = controller.captureSquare(PANORAMA_QUAD_SIZE);
if (!capture) {
controller.setView(restore.lon, restore.lat);
return;
}
ctx.drawImage(capture.canvas, (index % 2) * PANORAMA_QUAD_SIZE, Math.floor(index / 2) * PANORAMA_QUAD_SIZE, PANORAMA_QUAD_SIZE, PANORAMA_QUAD_SIZE);
capture.release();
}
const labels = ["前 (0°)", "右 (90°)", "后 (180°)", "左 (270°)"];
ctx.font = "bold 36px sans-serif";
ctx.fillStyle = "rgba(255,255,255,0.85)";
ctx.strokeStyle = "rgba(0,0,0,0.65)";
ctx.lineWidth = 4;
labels.forEach((label, index) => {
const x = (index % 2) * PANORAMA_QUAD_SIZE + 24;
const y = Math.floor(index / 2) * PANORAMA_QUAD_SIZE + 56;
ctx.strokeText(label, x, y);
ctx.fillText(label, x, y);
});
controller.setView(restore.lon, restore.lat);
await exportNode(node, off.toDataURL("image/png"), `${node.title || "全景"} · 四向视图`);
};
const handleViewportError = () => {
setFailed(true);
setActive(false);
setExpanded(false);
};
if (!url) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.muted }}>
@@ -46,54 +137,162 @@ export function PanoramaNodeContent({ node, theme, reduceMediaEffects }: Panoram
);
}
if (!active) {
return (
<div className="relative h-full w-full overflow-hidden" style={{ background: theme.node.fill }}>
<img src={url} alt={node.title || "全景"} className="h-full w-full object-cover opacity-80" draggable={false} />
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 px-3 py-2" style={{ background: "linear-gradient(to top, rgba(0,0,0,.85), rgba(0,0,0,.35))", boxShadow: "0 -1px 0 rgba(0,0,0,.25)" }}>
<span className="min-w-0 truncate text-white" style={{ fontSize: "var(--fs-label)", textShadow: "0 1px 3px rgba(0,0,0,.85)" }}>
{failed ? "全景加载失败(图片可能不允许跨域读取)" : reduceMediaEffects ? "性能模式下仅显示预览" : "360° 全景"}
</span>
{reduceMediaEffects ? null : (
<button
type="button"
className="shrink-0 rounded-[var(--r-md)] px-2 py-1 font-medium text-white outline-none transition-colors"
style={{ fontSize: "var(--fs-label)", background: "rgba(0,0,0,.78)", boxShadow: "0 1px 4px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.32)" }}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => { setFailed(false); setActive(true); }}
>
进入环视
</button>
)}
</div>
const previewContent = (
<div className="relative h-full w-full overflow-hidden" style={{ background: theme.node.fill }}>
<img src={url} alt={node.title || "全景"} className="h-full w-full object-cover opacity-80" draggable={false} />
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 px-3 py-2" style={{ background: "linear-gradient(to top, rgba(0,0,0,.85), rgba(0,0,0,.35))", boxShadow: "0 -1px 0 rgba(0,0,0,.25)" }}>
<span className="min-w-0 truncate text-white" style={{ fontSize: "var(--fs-label)", textShadow: "0 1px 3px rgba(0,0,0,.85)" }}>
{failed ? "全景加载失败(图片可能不允许跨域读取)" : expanded ? "已展开全屏环视" : reduceMediaEffects ? "性能模式下仅显示预览" : `${projectionLabel} · 360° 全景`}
</span>
{reduceMediaEffects || expanded ? null : (
<button
type="button"
className="shrink-0 rounded-[var(--r-md)] px-2 py-1 font-medium text-white outline-none transition-colors"
style={{ fontSize: "var(--fs-label)", background: "rgba(0,0,0,.78)", boxShadow: "0 1px 4px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.32)" }}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => { setFailed(false); setActive(true); }}
>
进入环视
</button>
)}
</div>
</div>
);
if (expanded) {
// 展开时同一时刻只挂一个 viewer:节点体退回静态预览,viewer 移到全屏 portal。
return (
<>
{previewContent}
{createPortal(
<div
className="fixed inset-0 z-[1200] flex items-center justify-center bg-black/[0.88] p-5 backdrop-blur-sm"
// portal 挂在 body,React 合成事件仍沿 React 树冒泡回画布容器;不标记
// data-canvas-no-zoom 会被 handlePointerDown 判为画布空白并 setPointerCapture,
// click 被重定向到 body,覆盖层内所有按钮(含关闭)都收不到点击。
data-canvas-no-zoom
onMouseDown={(event) => event.stopPropagation()}
onClick={() => { rememberView(); setExpanded(false); }}
>
<div className="flex h-[min(88vh,920px)] w-[min(94vw,1440px)] flex-col overflow-hidden rounded-[var(--r-2xl)] border border-white/15 bg-black shadow-2xl" onClick={(event) => event.stopPropagation()}>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{node.title || "全景环视"}</div>
<div className="mt-0.5 text-white/45" style={{ fontSize: "var(--fs-tiny)" }}>{projectionLabel} · 拖拽环视 · 方向键旋转 · 滚轮缩放</div>
</div>
<button
type="button"
aria-label="退出全屏"
className="grid size-8 shrink-0 place-items-center rounded-[var(--r-md)] text-white/60 transition-colors hover:bg-white/10 hover:text-white"
onMouseDown={(event) => event.stopPropagation()}
onClick={() => { rememberView(); setExpanded(false); }}
>
<X className="size-4" />
</button>
</div>
<div className="relative min-h-0 flex-1">
<PanoramaViewport url={url} controllerRef={controllerRef} initialView={lastViewRef.current} allowWheelZoom autoFocus onError={handleViewportError} />
<PanoramaViewerToolbar
canCapture={Boolean(actions.addPanoramaCaptureNode)}
onScreenshot={handleScreenshot}
onQuadExport={handleQuadExport}
onResetView={() => controllerRef.current?.setView(0, 0)}
onClose={() => { rememberView(); setExpanded(false); }}
closeLabel="退出全屏"
/>
</div>
</div>
</div>,
document.body,
)}
</>
);
}
if (!active) {
return previewContent;
}
return (
<div className="relative h-full w-full overflow-hidden" style={{ background: "#000" }}>
<PanoramaViewport url={url} onError={() => { setFailed(true); setActive(false); }} />
<button
type="button"
aria-label="退出环视"
className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-black/70 text-white outline-none transition-colors hover:bg-black/90"
style={{ boxShadow: "0 1px 4px rgba(0,0,0,.5)" }}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => setActive(false)}
>
<X className="size-3.5" />
</button>
<PanoramaViewport url={url} controllerRef={controllerRef} initialView={lastViewRef.current} onError={handleViewportError} />
<PanoramaViewerToolbar
canCapture={Boolean(actions.addPanoramaCaptureNode)}
onScreenshot={handleScreenshot}
onQuadExport={handleQuadExport}
onResetView={() => controllerRef.current?.setView(0, 0)}
onExpand={() => { rememberView(); setExpanded(true); }}
onClose={() => setActive(false)}
closeLabel="退出环视"
/>
<div className="pointer-events-none absolute bottom-2 left-2 rounded bg-black/55 px-2 py-0.5 text-[10px] text-white/70 backdrop-blur-sm">
{projectionLabel} · 拖拽环视 · 方向键旋转
</div>
</div>
);
}
function PanoramaViewport({ url, onError }: { url: string; onError: () => void }) {
function PanoramaViewerToolbar({ canCapture, onScreenshot, onQuadExport, onResetView, onExpand, onClose, closeLabel }: {
canCapture: boolean;
onScreenshot: () => void | Promise<void>;
onQuadExport: () => void | Promise<void>;
onResetView: () => void;
onExpand?: () => void;
onClose: () => void;
closeLabel: string;
}) {
return (
<div className="absolute right-2.5 top-2.5 flex gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
{onExpand ? (
<ViewerToolbarButton title="全屏环视" onClick={onExpand}><Maximize2 className="size-4" /></ViewerToolbarButton>
) : null}
{canCapture ? (
<>
<ViewerToolbarButton title="截图当前视角" onClick={() => void onScreenshot()}><Camera className="size-4" /></ViewerToolbarButton>
<ViewerToolbarButton title="导出四向视图" onClick={() => void onQuadExport()}><Grid3x3 className="size-4" /></ViewerToolbarButton>
</>
) : null}
<ViewerToolbarButton title="重置视角" onClick={onResetView}><RotateCcw className="size-4" /></ViewerToolbarButton>
<ViewerToolbarButton title={closeLabel} danger onClick={onClose}><X className="size-4" /></ViewerToolbarButton>
</div>
);
}
function ViewerToolbarButton({ title, danger, onClick, children }: { title: string; danger?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
aria-label={title}
title={title}
className={`grid size-8 place-items-center rounded-[var(--r-md)] text-white outline-none transition-colors focus-visible:ring-2 focus-visible:ring-white/40 ${danger ? "bg-black/70 hover:bg-red-500/90" : "bg-black/70 hover:bg-black/90"}`}
style={{ boxShadow: "0 1px 4px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.22)" }}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onClick(); }}
>
{children}
</button>
);
}
function PanoramaViewport({ url, controllerRef, initialView, allowWheelZoom, autoFocus, onError }: {
url: string;
controllerRef: { current: PanoramaViewportController | null };
initialView?: PanoramaView | null;
allowWheelZoom?: boolean;
autoFocus?: boolean;
onError: () => void;
}) {
const hostRef = useRef<HTMLDivElement | null>(null);
// onError 是内联箭头函数,每次渲染都是新引用;直接放进 effect 依赖会让
// 父级任何重渲染(悬停/选中/画布状态)都销毁重建 WebGL 上下文 ——
// 表现就是环视画面闪动、视角回到初始位置。用 ref 稳定它。
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const [loading, setLoading] = useState(true);
useEffect(() => {
if (autoFocus) hostRef.current?.focus({ preventScroll: true });
}, [autoFocus]);
useEffect(() => {
const host = hostRef.current;
@@ -102,15 +301,19 @@ function PanoramaViewport({ url, onError }: { url: string; onError: () => void }
let frame = 0;
// three 体积大,按需加载,不进主包。
const teardown: Array<() => void> = [];
let lon = initialView?.lon ?? 0;
let lat = initialView?.lat ?? 0;
let fov = 75;
void (async () => {
const THREE = await import("three");
if (disposed) return;
const renderer = new THREE.WebGLRenderer({ antialias: true });
// 截图/四宫格导出要求渲染缓冲跨帧可读,必须开启 preserveDrawingBuffer。
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1100);
const camera = new THREE.PerspectiveCamera(fov, 1, 0.1, 1100);
const loader = new THREE.TextureLoader();
loader.setCrossOrigin("anonymous");
@@ -127,8 +330,6 @@ function PanoramaViewport({ url, onError }: { url: string; onError: () => void }
host.appendChild(renderer.domElement);
renderer.domElement.style.cssText = "width:100%;height:100%;display:block;cursor:grab";
let lon = 0;
let lat = 0;
let dragging = false;
let lastX = 0;
let lastY = 0;
@@ -152,11 +353,35 @@ function PanoramaViewport({ url, onError }: { url: string; onError: () => void }
dragging = false;
if (renderer.domElement.hasPointerCapture(event.pointerId)) renderer.domElement.releasePointerCapture(event.pointerId);
};
// 滚轮留给画布缩放,全景不吃 wheel —— 节点里再嵌一层缩放会让手感彻底乱。
// 节点内滚轮留给画布缩放(见组件头注释 5);仅全屏覆盖层消费滚轮调视场角。
const onWheel = (event: WheelEvent) => {
if (!allowWheelZoom) return;
event.preventDefault();
event.stopPropagation();
fov = clampFov(fov + event.deltaY * 0.05);
camera.fov = fov;
camera.updateProjectionMatrix();
};
const onKeyDown = (event: KeyboardEvent) => {
let lonStep = 0;
let latStep = 0;
if (event.key === "ArrowLeft") lonStep = -PANORAMA_KEY_STEP;
else if (event.key === "ArrowRight") lonStep = PANORAMA_KEY_STEP;
else if (event.key === "ArrowUp") latStep = PANORAMA_KEY_STEP;
else if (event.key === "ArrowDown") latStep = -PANORAMA_KEY_STEP;
else return;
event.preventDefault();
event.stopPropagation();
lon += lonStep;
lat = Math.max(-85, Math.min(85, lat + latStep));
host.focus({ preventScroll: true });
};
renderer.domElement.addEventListener("pointerdown", onPointerDown);
renderer.domElement.addEventListener("pointermove", onPointerMove);
renderer.domElement.addEventListener("pointerup", onPointerUp);
renderer.domElement.addEventListener("pointercancel", onPointerUp);
if (allowWheelZoom) renderer.domElement.addEventListener("wheel", onWheel, { passive: false });
host.addEventListener("keydown", onKeyDown);
const resize = () => {
const { clientWidth, clientHeight } = host;
@@ -169,14 +394,45 @@ function PanoramaViewport({ url, onError }: { url: string; onError: () => void }
observer.observe(host);
resize();
const tick = () => {
const applyView = () => {
const phi = THREE.MathUtils.degToRad(90 - lat);
const theta = THREE.MathUtils.degToRad(lon);
camera.lookAt(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta));
};
const tick = () => {
applyView();
renderer.render(scene, camera);
frame = requestAnimationFrame(tick);
};
tick();
setLoading(false);
const controller: PanoramaViewportController = {
captureCanvas: () => {
applyView();
renderer.render(scene, camera);
return renderer.domElement;
},
captureSquare: (size) => {
const { clientWidth, clientHeight } = host;
if (!clientWidth || !clientHeight) return null;
renderer.setSize(size, size, false);
camera.aspect = 1;
camera.updateProjectionMatrix();
applyView();
renderer.render(scene, camera);
return {
canvas: renderer.domElement,
release: resize,
};
},
getView: () => ({ lon, lat }),
setView: (lonDeg, latDeg) => {
lon = lonDeg;
lat = Math.max(-85, Math.min(85, latDeg));
},
};
controllerRef.current = controller;
teardown.push(() => {
cancelAnimationFrame(frame);
@@ -185,10 +441,13 @@ function PanoramaViewport({ url, onError }: { url: string; onError: () => void }
renderer.domElement.removeEventListener("pointermove", onPointerMove);
renderer.domElement.removeEventListener("pointerup", onPointerUp);
renderer.domElement.removeEventListener("pointercancel", onPointerUp);
if (allowWheelZoom) renderer.domElement.removeEventListener("wheel", onWheel);
host.removeEventListener("keydown", onKeyDown);
renderer.domElement.remove();
geometry.dispose();
material.dispose();
texture.dispose();
if (controllerRef.current === controller) controllerRef.current = null;
// forceContextLoss 才真正释放上下文;只 dispose 在部分浏览器上仍占着配额。
renderer.forceContextLoss();
renderer.dispose();
@@ -199,7 +458,15 @@ function PanoramaViewport({ url, onError }: { url: string; onError: () => void }
disposed = true;
teardown.forEach((fn) => fn());
};
}, [url]);
}, [allowWheelZoom, controllerRef, initialView, url]);
return <div ref={hostRef} className="h-full w-full" data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} />;
return (
<div ref={hostRef} className="relative h-full w-full outline-none focus-visible:ring-2 focus-visible:ring-white/40" tabIndex={0} data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()}>
{loading ? (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/70 text-xs text-white/85">
<Loader2 className="size-4 animate-spin" /> 全景加载中...
</div>
) : null}
</div>
);
}

View File

@@ -7,7 +7,7 @@ import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId, CanvasToolMo
/** 工具栏标识——每个工具栏有独立的注册表与偏好 */
export type ToolbarId = "main" | "selection" | "node-hover" | "add-node-menu";
export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "lighting" | "process" | "workspace" | "utility" | "more";
export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "lighting" | "panorama" | "process" | "workspace" | "utility" | "more";
/** 工具分类——用于分组渲染、危险隔离与 separator 自动插入 */
export type ToolCategory =

View File

@@ -56,6 +56,7 @@ import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-
import { CanvasDirectorTemplateModal } from "@/components/canvas/director/canvas-director-template-modal";
import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay";
import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal";
import { CanvasPanoramaConfigModal } from "@/components/canvas/canvas-panorama-config-modal";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { Minimap } from "@/components/canvas/canvas-mini-map";
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
@@ -800,6 +801,11 @@ function InfiniteCanvasPage() {
frameDialogNodeId,
generateAngleNode,
generateLightingNode,
openPanoramaConfig,
createPanoramaViewerWithConfig,
addPanoramaCaptureNode,
panoramaConfigNodeId,
setPanoramaConfigNodeId,
generateEmotionNode,
handleSegmentConfirm,
maskEditImageNode,
@@ -1449,7 +1455,8 @@ function InfiniteCanvasPage() {
updateMediaNode: updateMediaNodeFromContent,
openPortraitClearance,
openArtCritique,
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
addPanoramaCaptureNode,
}), [addPanoramaCaptureNode, deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
projectId,
domainProjectId: currentProject?.projectId,
@@ -2606,6 +2613,7 @@ function InfiniteCanvasPage() {
setDialogNodeId(null);
setLightingNodeId((current) => (current === node.id ? null : node.id));
}}
onPanorama={openPanoramaConfig}
onViewImage={(node) => setPreviewNodeId(node.id)}
onExtractVideoFrames={openVideoFrameExtractor}
onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)}
@@ -2845,6 +2853,26 @@ function InfiniteCanvasPage() {
onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })}
/>
<CanvasPanoramaConfigModal
open={Boolean(panoramaConfigNodeId)}
onCancel={() => setPanoramaConfigNodeId(null)}
onConfirm={(composedPrompt, config) => {
const node = nodes.find((n) => n.id === panoramaConfigNodeId);
if (node) {
createPanoramaViewerWithConfig(node, composedPrompt, config);
}
}}
onCopyPrompt={(prompt) => {
void navigator.clipboard?.writeText(prompt).then(() => message.success("已复制全景提示词"));
}}
previewImageUrl={
panoramaConfigNodeId
? nodes.find((n) => n.id === panoramaConfigNodeId)?.metadata?.content
: undefined
}
nodes={nodes}
/>
<CanvasScriptEditor
node={activeScriptNode}
nodes={nodes}

View File

@@ -275,7 +275,7 @@ export default function SharedCanvasPage() {
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
</InfiniteCanvas>
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<div className="absolute bottom-5 left-5 z-[var(--z-panel-floating)]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onFitContent={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
<div className="pointer-events-none absolute bottom-5 right-5 z-[var(--z-panel-floating)] max-w-[340px] text-right text-xs leading-5" style={{ color: theme.node.muted }}>访客操作仅在当前页面临时生效</div>

View File

@@ -7,10 +7,11 @@ import type { CanvasImageMaskEditPayload } from "@/components/canvas/canvas-node
import type { CanvasImageSplitParams } from "@/components/canvas/canvas-node-split-dialog";
import type { CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
import type { CanvasVideoSegmentParams } from "@/components/canvas/canvas-video-segment-dialog";
import { buildLightingLabel, type CanvasImageLightingOptions } from "@/components/canvas/canvas-node-lighting-dialog";
import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
import type { PanoramaGenerateConfig } from "@/components/canvas/canvas-panorama-config-modal";
import type { CanvasVideoFrameParams } from "@/components/canvas/canvas-video-frame-dialog";
import type { CanvasVideoSegmentParams } from "@/components/canvas/canvas-video-segment-dialog";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
@@ -124,6 +125,7 @@ export function useCanvasMediaTools({
const [segmentDialogMode, setSegmentDialogMode] = useState<"audio" | "video" | null>(null);
const [segmentRunningMode, setSegmentRunningMode] = useState<"audio" | "video" | null>(null);
const segmentRunningRef = useRef(false);
const [panoramaConfigNodeId, setPanoramaConfigNodeId] = useState<string | null>(null);
const resolveImageEditStyle = useCallback((node: CanvasNodeData, prompt: string, config: AiConfig) => {
try {
@@ -551,6 +553,66 @@ export function useCanvasMediaTools({
const mergeSelectedVideos = useCallback(() => mergeVideosByIds(Array.from(selectedNodeIdsRef.current)), [mergeVideosByIds, selectedNodeIdsRef]);
const openPanoramaConfig = useCallback((node: CanvasNodeData) => {
setPanoramaConfigNodeId(node.id);
}, []);
const createPanoramaViewerWithConfig = useCallback((node: CanvasNodeData, composedPrompt: string, config: PanoramaGenerateConfig) => {
const panoramaSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Panorama];
const childId = nanoid();
const childNode: CanvasNodeData = {
id: childId,
type: CanvasNodeType.Panorama,
title: `${node.title || "图片"} · 全景`,
position: { x: node.position.x + node.width + 96, y: node.position.y },
width: panoramaSpec.width,
height: panoramaSpec.height,
metadata: {
prompt: composedPrompt || node.metadata?.prompt,
panoramaConfig: {
projection: config.projection,
sourceMode: config.sourceMode,
smartBase: config.smartBase,
directImageUrl: config.directImageUrl ?? null,
},
},
};
setNodes((current) => [...current, childNode]);
setConnections((current) => {
const linkTargets = new Set<string>([node.id]);
config.referenceImages.forEach((reference) => linkTargets.add(reference.id));
const extraConnections = config.referenceImages
.filter((reference) => reference.id !== node.id)
.map((reference) => ({ id: nanoid(), fromNodeId: reference.id, toNodeId: childId }));
return [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }, ...extraConnections];
});
setSelectedNodeIds(new Set([childId]));
setSelectedConnectionId(null);
setDialogNodeId(childId);
setPanoramaConfigNodeId(null);
message.success(config.sourceMode === "image" ? "已创建全景查看节点" : "已创建全景生成节点");
}, [message, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
const addPanoramaCaptureNode = useCallback(async (node: CanvasNodeData, dataUrl: string, title: string) => {
const image = await uploadImage(dataUrl);
const size = fitNodeSize(image.width || 720, image.height || 405);
// 已有导出时按列错开,避免多张截图叠在同一位置。
const outputCount = connectionsRef.current.filter((connection) => connection.fromNodeId === node.id).length;
const childNode: CanvasNodeData = {
id: nanoid(),
type: CanvasNodeType.Image,
title,
position: { x: node.position.x + node.width + 96, y: node.position.y + (outputCount % 5) * (size.height + 32) },
width: size.width,
height: size.height,
metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt },
};
setNodes((current) => [...current, childNode]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childNode.id }]);
await persistMediaNodes([childNode]);
message.success(`已导出「${title}」`);
}, [connectionsRef, message, persistMediaNodes, setConnections, setNodes]);
const splitImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageSplitParams) => {
if (!node.metadata?.content) return;
setSplitNodeId(null);
@@ -926,6 +988,11 @@ export function useCanvasMediaTools({
handleSegmentConfirm,
generateAngleNode,
generateLightingNode,
openPanoramaConfig,
createPanoramaViewerWithConfig,
addPanoramaCaptureNode,
panoramaConfigNodeId,
setPanoramaConfigNodeId,
maskEditImageNode,
maskEditNodeId,
mergeSelectedVideos,

View File

@@ -432,6 +432,13 @@ export type CanvasNodeMetadata = {
artCritique?: ArtCritiqueNodeState;
/** 摄像机控制选项,启用后生成时自动追加摄影机/镜头/焦距/光圈提示词。 */
cameraControl?: CameraControlOptions;
/** 全景节点配置:投影方式、生成方式和比例兜底开关。 */
panoramaConfig?: {
projection: "spherical" | "cylindrical";
sourceMode: "ai" | "image";
smartBase: boolean;
directImageUrl?: string | null;
};
};
export type CanvasNodeData = {