feat: 新增全景图节点与工具链支持
- 新增CanvasNodeMetadata的panoramaConfig配置字段 - 新增全景图工具栏分组与操作上下文方法 - 新增全景图配置弹窗、全景查看节点与环视功能 - 新增四向视图导出、全屏环视等交互功能 - 更新画布工具栏与页面逻辑接入全景图工具
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user