feat(canvas): PR #500 - 合并多角度与切分并保留本地 Agent 改造

This commit is contained in:
ddCat
2026-09-14 19:48:17 +08:00
26 changed files with 1431 additions and 153 deletions

View File

@@ -1,7 +1,6 @@
import { CanvasNodeAnnotationDialog } from "@/components/canvas/canvas-node-annotation-dialog";
import { CanvasNodeCropDialog, type CanvasImageCropRect } from "@/components/canvas/canvas-node-crop-dialog";
import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "@/components/canvas/canvas-node-mask-edit-dialog";
import { CanvasNodeSplitDialog, type CanvasImageSplitParams } from "@/components/canvas/canvas-node-split-dialog";
import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
import type { CanvasNodeData } from "@/types/canvas";
import type { AiConfig } from "@/stores/use-config-store";
@@ -10,17 +9,14 @@ type CanvasProjectMediaDialogsProps = {
cropNode: CanvasNodeData | null;
annotationNode: CanvasNodeData | null;
maskEditNode: CanvasNodeData | null;
splitNode: CanvasNodeData | null;
upscaleNode: CanvasNodeData | null;
onCloseCrop: () => void;
onCloseAnnotation: () => void;
onCloseMaskEdit: () => void;
onCloseSplit: () => void;
onCloseUpscale: () => void;
onCrop: (node: CanvasNodeData, crop: CanvasImageCropRect) => void;
onAnnotate: (node: CanvasNodeData, dataUrl: string) => void;
onMaskEdit: (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => void;
onSplit: (node: CanvasNodeData, params: CanvasImageSplitParams) => void;
onUpscale: (node: CanvasNodeData, params: CanvasImageUpscaleParams) => void;
config: AiConfig;
};
@@ -29,17 +25,14 @@ export function CanvasProjectMediaDialogs({
cropNode,
annotationNode,
maskEditNode,
splitNode,
upscaleNode,
onCloseCrop,
onCloseAnnotation,
onCloseMaskEdit,
onCloseSplit,
onCloseUpscale,
onCrop,
onAnnotate,
onMaskEdit,
onSplit,
onUpscale,
config,
}: CanvasProjectMediaDialogsProps) {
@@ -48,7 +41,6 @@ export function CanvasProjectMediaDialogs({
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open onClose={onCloseCrop} onConfirm={(crop) => onCrop(cropNode, crop)} /> : null}
{annotationNode?.metadata?.content ? <CanvasNodeAnnotationDialog image={{ url: annotationNode.metadata.content, storageKey: annotationNode.metadata.storageKey }} open onClose={onCloseAnnotation} onConfirm={(dataUrl) => onAnnotate(annotationNode, dataUrl)} /> : null}
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} config={{ ...config, model: maskEditNode.metadata.model || config.model, imageModel: maskEditNode.metadata.model || config.imageModel, size: maskEditNode.metadata.size || config.size, quality: maskEditNode.metadata.quality || config.quality, count: String(maskEditNode.metadata.count || config.count) }} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
{splitNode?.metadata?.content ? <CanvasNodeSplitDialog dataUrl={splitNode.metadata.content} open onClose={onCloseSplit} onConfirm={(params) => onSplit(splitNode, params)} /> : null}
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open onClose={onCloseUpscale} onConfirm={(params) => onUpscale(upscaleNode, params)} /> : null}
</>
);

View File

@@ -200,9 +200,10 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
),
)}
{props.selectedNodeBounds && !props.selectionBox && !props.isNodeDragging ? (
{props.selectedNodeBounds && !props.selectionBox ? (
<div
ref={props.selectionBoundsElementRef}
data-canvas-selection-bounds
className="pointer-events-none absolute z-[var(--z-panel-floating)] rounded-xl"
style={{
left: props.selectedNodeBounds.left - 12 / viewportScale,

View File

@@ -823,10 +823,8 @@ function InfiniteCanvasPage() {
setAnnotationNodeId,
setCropNodeId,
setMaskEditNodeId,
setSplitNodeId,
setUpscaleNodeId,
splitImageNode,
splitNodeId,
openVideoFrameExtractor,
openVideoSegmentExtractor,
upscaleImageNode,
@@ -868,7 +866,6 @@ function InfiniteCanvasPage() {
setCropNodeId(clearDeletedId);
setMaskEditNodeId(clearDeletedId);
setAnnotationNodeId(clearDeletedId);
setSplitNodeId(clearDeletedId);
setUpscaleNodeId(clearDeletedId);
setAngleNodeId(clearDeletedId);
setLightingNodeId(clearDeletedId);
@@ -902,7 +899,6 @@ function InfiniteCanvasPage() {
setLightingNodeId,
setMaskEditNodeId,
setSegmentDialogNodeId,
setSplitNodeId,
setUpscaleNodeId,
setRunningNodeId,
],
@@ -1337,7 +1333,6 @@ function InfiniteCanvasPage() {
selectedNodeBounds,
selectedVideoNodes,
skillMentionReferences,
splitNode,
superResolveNode,
toolbarNode,
upscaleNode,
@@ -1360,7 +1355,7 @@ function InfiniteCanvasPage() {
cropNodeId,
maskEditNodeId,
annotationNodeId,
splitNodeId,
splitNodeId: null,
upscaleNodeId,
superResolveNodeId,
angleNodeId,
@@ -2528,7 +2523,16 @@ function InfiniteCanvasPage() {
</div>
{angleNode?.metadata?.content ? (
<AppModal flush open centered title="多角度编辑器" footer={null} width={620} onCancel={() => setAngleNodeId(null)}>
<CanvasNodePanelOverlay
node={angleNode}
viewport={viewport}
containerRef={containerRef}
panelWidth={640}
panelHeight={540}
allowOverflow
dragOffset={dragPreview?.nodeIds.has(angleNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null}
isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(angleNode.id))}
>
<CanvasNodeAnglePanel
dataUrl={angleNode.metadata.content}
onClose={() => setAngleNodeId(null)}
@@ -2536,7 +2540,7 @@ function InfiniteCanvasPage() {
void generateAngleNode(angleNode, params);
}}
/>
</AppModal>
</CanvasNodePanelOverlay>
) : null}
{lightingNode?.metadata?.content ? (
@@ -2644,7 +2648,7 @@ function InfiniteCanvasPage() {
) : null}
<CanvasNodeToolbar
node={isCanvasNodeMoving || nodeImageSettingsOpen || emotionNodeId ? null : toolbarNode}
node={isCanvasNodeMoving || nodeImageSettingsOpen || emotionNodeId || angleNodeId ? null : toolbarNode}
workspaceMode={workspaceMode}
viewport={viewport}
containerRef={containerRef}
@@ -2667,7 +2671,7 @@ function InfiniteCanvasPage() {
}}
onPortraitTexture={openPortraitTextureEditor}
onCrop={(node) => setCropNodeId(node.id)}
onSplit={(node) => setSplitNodeId(node.id)}
onSplit={(node, params) => void splitImageNode(node, params)}
onUpscale={(node) => setUpscaleNodeId(node.id)}
onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
onAngle={(node) => {
@@ -2981,17 +2985,14 @@ function InfiniteCanvasPage() {
cropNode={cropNode}
annotationNode={annotationNode}
maskEditNode={maskEditNode}
splitNode={splitNode}
upscaleNode={upscaleNode}
onCloseCrop={() => setCropNodeId(null)}
onCloseAnnotation={() => setAnnotationNodeId(null)}
onCloseMaskEdit={() => setMaskEditNodeId(null)}
onCloseSplit={() => setSplitNodeId(null)}
onCloseUpscale={() => setUpscaleNodeId(null)}
onCrop={(node, crop) => void cropImageNode(node, crop)}
onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)}
onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)}
onSplit={(node, params) => void splitImageNode(node, params)}
onUpscale={(node, params) => void upscaleImageNode(node, params)}
config={effectiveConfig}
/>

View File

@@ -14,6 +14,7 @@ import type { PanoramaGenerateConfig } from "@/components/canvas/canvas-panorama
import type { CanvasVideoFrameParams } from "@/components/canvas/canvas-video-frame-dialog";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
import { isValidGridSplit, layoutGridSplitCells } from "@/lib/canvas/canvas-grid-split";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
@@ -113,7 +114,6 @@ export function useCanvasMediaTools({
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
const [annotationNodeId, setAnnotationNodeId] = useState<string | null>(null);
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
const [upscaleNodeId, setUpscaleNodeId] = useState<string | null>(null);
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
const [lightingNodeId, setLightingNodeId] = useState<string | null>(null);
@@ -615,25 +615,25 @@ export function useCanvasMediaTools({
}, [connectionsRef, message, persistMediaNodes, setConnections, setNodes]);
const splitImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageSplitParams) => {
if (!node.metadata?.content) return;
setSplitNodeId(null);
if (!node.metadata?.content || !isValidGridSplit(params)) return;
const pieces = await splitDataUrl(node.metadata.content, params);
const gap = 16;
const cellWidth = node.width / params.columns;
const cellHeight = node.height / params.rows;
const startX = node.position.x + node.width + 96;
const childNodes = await Promise.all(pieces.map(async (piece) => {
const sizedPieces = await Promise.all(pieces.map(async (piece) => {
const image = await uploadImage(piece.dataUrl);
return {
id: nanoid(),
type: CanvasNodeType.Image,
title: `${node.title || "图片"} · 宫格 ${piece.row + 1}-${piece.column + 1}`,
position: { x: startX + piece.column * (cellWidth + gap), y: node.position.y + piece.row * (cellHeight + gap) },
width: cellWidth,
height: cellHeight,
metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt },
} satisfies CanvasNodeData;
return { piece, image, size: fitNodeSize(image.width, image.height) };
}));
const positions = layoutGridSplitCells(
{ x: node.position.x + node.width + 96, y: node.position.y },
sizedPieces.map(({ piece, size }) => ({ row: piece.row, column: piece.column, width: size.width, height: size.height })),
);
const childNodes = sizedPieces.map(({ piece, image, size }, index) => ({
id: nanoid(),
type: CanvasNodeType.Image,
title: `${node.title || "图片"} · 宫格 ${piece.row + 1}-${piece.column + 1}`,
position: positions[index] || { x: node.position.x + node.width + 96, y: node.position.y },
width: size.width,
height: size.height,
metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt, manualSize: true },
} satisfies CanvasNodeData));
setNodes((current) => [...current, ...childNodes]);
setConnections((current) => [...current, ...childNodes.map((child) => ({ id: nanoid(), fromNodeId: node.id, toNodeId: child.id }))]);
setSelectedNodeIds(new Set(childNodes.map((child) => child.id)));
@@ -998,10 +998,8 @@ export function useCanvasMediaTools({
setAnnotationNodeId,
setCropNodeId,
setMaskEditNodeId,
setSplitNodeId,
setUpscaleNodeId,
splitImageNode,
splitNodeId,
openVideoFrameExtractor,
openVideoSegmentExtractor,
upscaleImageNode,