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

@@ -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,