feat: 新增全景图节点与工具链支持
- 新增CanvasNodeMetadata的panoramaConfig配置字段 - 新增全景图工具栏分组与操作上下文方法 - 新增全景图配置弹窗、全景查看节点与环视功能 - 新增四向视图导出、全屏环视等交互功能 - 更新画布工具栏与页面逻辑接入全景图工具
This commit is contained in:
@@ -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 带方向标注),多次导出按列错开不重叠;导出后视角自动还原。
|
||||
- [ ] 「全屏环视」:覆盖层展开,滚轮缩放视场角生效;退出全屏回到节点内环视且视角保留;展开期间节点体显示静态预览。
|
||||
- [ ] 分享只读页(未提供导出动作)不显示截图/四宫格按钮,仅保留环视、重置与全屏;明暗主题与性能模式(仅预览)行为不变。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>({});
|
||||
|
||||
@@ -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} />)}
|
||||
|
||||
544
web/src/components/canvas/canvas-panorama-config-modal.tsx
Normal file
544
web/src/components/canvas/canvas-panorama-config-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user