event.stopPropagation()} />;
+ return (
+
event.stopPropagation()}>
+ {loading ? (
+
+ 全景加载中...
+
+ ) : null}
+
+ );
}
diff --git a/web/src/lib/canvas/tool-registry/tool-definition.ts b/web/src/lib/canvas/tool-registry/tool-definition.ts
index 21f56eea9..1591348fe 100644
--- a/web/src/lib/canvas/tool-registry/tool-definition.ts
+++ b/web/src/lib/canvas/tool-registry/tool-definition.ts
@@ -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 =
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 02c2ac187..1ac74cab4 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -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 })}
/>
+
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}
+ />
+
undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
- 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} />
+ 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} />
访客操作仅在当前页面临时生效
diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts
index e2f94d8a4..b9e2d1377 100644
--- a/web/src/pages/canvas/use-canvas-media-tools.ts
+++ b/web/src/pages/canvas/use-canvas-media-tools.ts
@@ -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(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([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,
diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts
index f80221304..a5c384b3b 100644
--- a/web/src/types/canvas.ts
+++ b/web/src/types/canvas.ts
@@ -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 = {