feat(canvas): 画布媒体工作流 - 支持批量连线、失败重试与模型能力增强

合并 PR #230。保留当前主线样式与组件结构,吸收批量连线、失败批次重试、媒体模型能力和相关后端增强,并修复集成检查发现的分辨率选择器启用条件与测试稳定性问题。
This commit is contained in:
WindC0X authored and GitHub committed 2026-08-15 15:48:16 +08:00
1 parent c7eb5e5886
commit 9ee71d4650
48 files changed
+1582 -83

No files matched your search

@@ -85,6 +85,7 @@ export async function executeImageGeneration({
size: generationConfig.size,
isBatchRoot: count > 1,
batchChildIds: count > 1 ? childIds : undefined,
batchFailedCount: count > 1 ? 0 : undefined,
batchUsesReferenceImages: referenceImages.length > 0,
primaryImageId: undefined,
...generationMetadata,
@@ -145,6 +146,7 @@ export async function executeImageGeneration({
if (count > 1 && !directCopiedBatch) startGenerationRequest(rootId, nodeId, nodeId, controller);
let hasSuccess = false;
let hasFailure = false;
let failureCount = 0;
let representativeFailure: GenerationFailureMetadata | undefined;
await Promise.all(
targetIds.map(async (targetId) => {
@@ -216,6 +218,7 @@ export async function executeImageGeneration({
const failure = generationFailureMetadata(error, prompt);
if (!representativeFailure || failure.generationErrorCode === CONTENT_MODERATION_ERROR_CODE) representativeFailure = failure;
hasFailure = true;
failureCount += 1;
setNodes((current) => current.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } } : node)));
return false;
} finally {
@@ -230,19 +233,29 @@ export async function executeImageGeneration({
}
if (hasFailure) showError(hasSuccess ? "部分图片生成失败" : "全部图片生成失败");
setNodes((current) =>
current.map((node) =>
node.id === nodeId && (isConfigNode || reuseSourceNode)
? {
...node,
metadata: {
...node.metadata,
status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR,
...(hasSuccess ? { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } : representativeFailure || { errorDetails: "全部图片生成失败" }),
},
}
: !directCopiedBatch && node.id === rootId && !hasSuccess
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...(representativeFailure || { errorDetails: "全部图片生成失败" }) } }
: node,
),
current.map((node) => {
if (node.id === nodeId && isConfigNode) {
return {
...node,
metadata: {
...node.metadata,
status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR,
...(hasSuccess ? { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } : representativeFailure || { errorDetails: "全部图片生成失败" }),
},
};
}
if (node.id === rootId && (reuseSourceNode || !directCopiedBatch)) {
return {
...node,
metadata: {
...node.metadata,
status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR,
batchFailedCount: count > 1 ? failureCount : undefined,
...(hasSuccess ? { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } : representativeFailure || { errorDetails: "全部图片生成失败" }),
},
};
}
return node;
}),
);
}
@@ -18,14 +18,15 @@ type CanvasProjectSelectionToolbarProps = {
onArrange: (mode: "row" | "column" | "grid" | "flow") => void;
onCreateStoryboard: () => void;
onCreateReferenceGroup: () => void;
onBatchConnect: () => void;
onMergeVideos: () => void;
};
export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onMergeVideos }: CanvasProjectSelectionToolbarProps) {
export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos }: CanvasProjectSelectionToolbarProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const handlers = {
onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onMergeVideos,
onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos,
} as Partial<ToolbarHandlers> as ToolbarHandlers;
const ctx: ToolContext = {
@@ -1,8 +1,10 @@
import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react";
import type { CSSProperties, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react";
import { Link2 } from "lucide-react";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
@@ -40,6 +42,8 @@ type CanvasProjectWorldLayersProps = {
mentionReferencesByNodeId: Map<string, CanvasResourceReference[]>;
mediaEffectsDisabledNodeId?: string | null;
selectedNodeBounds: NodeBounds;
batchSourceNodeIds: string[];
batchConnectionPreview: CanvasBatchConnectionPreview | null;
isNodeDragging: boolean;
selectionBoundsElementRef: RefObject<HTMLDivElement | null>;
renderCanvasNodeContent: (node: CanvasNodeData) => ReactNode;
@@ -65,6 +69,7 @@ type CanvasProjectWorldLayersProps = {
onOpenTextEditor: (node: CanvasNodeData) => void;
onOpenDirector: (node: CanvasNodeData) => void;
onOpenDrawing: (node: CanvasNodeData) => void;
onStartBatchConnection: (event: ReactPointerEvent, sourceNodeIds: string[]) => void;
};
const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
@@ -120,8 +125,9 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
isSelected={props.selectedNodeIds.has(node.id)}
isRelated={props.relatedNodeIds.has(node.id)}
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id}
isConnecting={Boolean(props.connectingParams)}
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}
isConnecting={Boolean(props.connectingParams || props.batchConnectionPreview)}
forceInputVisible={Boolean(props.batchConnectionPreview)}
batchCount={props.batchChildCountById.get(node.id) || 0}
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
batchClosing={Boolean(node.metadata?.batchRootId && props.collapsingBatchIds.has(node.metadata.batchRootId))}
@@ -168,8 +174,44 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
width: props.selectedNodeBounds.width + 24 / viewportScale,
height: props.selectedNodeBounds.height + 24 / viewportScale,
}}
/>
>
{props.batchSourceNodeIds.length > 0 ? (
<BatchConnectionHandle
scale={viewportScale}
count={props.batchSourceNodeIds.length}
active={Boolean(props.batchConnectionPreview)}
onPointerDown={(event) => props.onStartBatchConnection(event, props.batchSourceNodeIds)}
/>
) : null}
</div>
) : null}
</>
);
}
function BatchConnectionHandle({ scale, count, active, onPointerDown }: { scale: number; count: number; active: boolean; onPointerDown: (event: ReactPointerEvent) => void }) {
const inverseScale = 1 / Math.max(scale, 0.05);
const buttonStyle: CSSProperties = {
right: -18 * inverseScale,
top: "50%",
width: 30 * inverseScale,
height: 30 * inverseScale,
background: active ? "var(--workspace-accent)" : "var(--workspace-surface-strong)",
borderColor: active ? "var(--workspace-accent)" : "var(--workspace-border)",
color: active ? "var(--workspace-accent-foreground)" : "var(--foreground)",
};
return (
<button
type="button"
data-canvas-no-zoom
className="pointer-events-auto absolute grid -translate-y-1/2 translate-x-1/2 place-items-center rounded-full border shadow-md transition hover:scale-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={buttonStyle}
title={`批量连接 ${count} 个节点`}
aria-label={`批量连接 ${count} 个节点`}
onPointerDown={onPointerDown}
>
<Link2 style={{ width: 14 * inverseScale, height: 14 * inverseScale }} strokeWidth={2} />
<span className="sr-only">连接 {count} 个节点</span>
</button>
);
}
+50 -3
View File
@@ -64,8 +64,10 @@ import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-refer
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
@@ -785,6 +787,10 @@ function InfiniteCanvasPage() {
createConnectedNode,
getConnectionCreateDisabledReason,
handleConnectStart,
handleBatchConnectionTargetClick,
batchConnectionPreview,
beginBatchConnectionMode,
startBatchConnection,
mouseWorld,
pendingConnectionCreate,
setConnecting,
@@ -806,6 +812,10 @@ function InfiniteCanvasPage() {
setDrawingNodeId,
});
const batchSourceNodeIds = useMemo(() => nodes
.filter((node) => selectedNodeIds.has(node.id) && !batchSourceRestriction(node))
.map((node) => node.id), [nodes, selectedNodeIds]);
const handleCanvasSelectionStart = useCallback(() => {
setContextMenu(null);
setDialogNodeId(null);
@@ -852,6 +862,7 @@ function InfiniteCanvasPage() {
onCanvasSelectionStart: handleCanvasSelectionStart,
onNodeInteractionStart: handleNodeInteractionStart,
onNodeClick: handleSelectedNodeClick,
onBatchConnectionTarget: handleBatchConnectionTargetClick,
onDeselect: handleCanvasDeselect,
onSelectionBoxEnd: () => setCanvasTool((tool) => (tool === "box-select" ? "move" : tool)),
});
@@ -975,7 +986,7 @@ function InfiniteCanvasPage() {
const characterReferenceNode = characterReferenceNodeId ? nodeById.get(characterReferenceNodeId) || null : null;
const drawingNode = drawingNodeId ? nodeById.get(drawingNodeId) || null : null;
const pendingConnectionSourceNode = pendingConnectionCreate?.connection.handleType === "source" ? nodeById.get(pendingConnectionCreate.connection.nodeId) : null;
const canCreateDrawingFromConnection = pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content);
const canCreateDrawingFromConnection = !pendingConnectionCreate?.batchSourceNodeIds?.length && pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content);
const openTextNodeEditor = useCallback((node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Text) return;
@@ -1131,6 +1142,7 @@ function InfiniteCanvasPage() {
focusMode,
exitFocusMode,
toggleFocusMode,
beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)),
});
const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => {
@@ -1306,6 +1318,22 @@ function InfiniteCanvasPage() {
bindGenerationTask,
applyGenerationTaskResult,
});
const reconcileImageBatchRootNode = useCallback((rootId: string) => {
setNodes((current) => {
const root = current.find((item) => item.id === rootId);
if (!root) return current;
const reconciled = reconcileImageBatchRoot(root, current);
return current.map((item) => item.id === root.id ? reconciled : item);
});
}, [setNodes]);
const retryImageBatchChildren = useCallback((rootId: string, children: CanvasNodeData[]) => {
const childIds = children.map((child) => child.id);
setNodes((current) => markImageBatchRetrying(rootId, childIds, current));
void Promise.allSettled(children.map(async (child) => {
await handleRetryNode(child);
setNodes((current) => current.map((item) => item.id === child.id ? restoreUnsubmittedImageBatchChild(item, child) : item));
})).finally(() => reconcileImageBatchRootNode(rootId));
}, [handleRetryNode, reconcileImageBatchRootNode, setNodes]);
const generateImageFromTextNode = useCallback(
(node: CanvasNodeData) => {
@@ -1519,9 +1547,23 @@ function InfiniteCanvasPage() {
void generateScriptRows(node.id, prompt);
return;
}
if (node.type === CanvasNodeType.Image && node.metadata?.isBatchRoot) {
const failedChildren = failedImageBatchChildren(node, nodesRef.current);
if (!failedChildren.length) {
message.info("当前批次没有需要重试的失败图片");
return;
}
message.info(`正在重试 ${failedChildren.length} 个失败图片`);
retryImageBatchChildren(node.id, failedChildren);
return;
}
if (node.type === CanvasNodeType.Image && node.metadata?.batchRootId) {
retryImageBatchChildren(node.metadata.batchRootId, [node]);
return;
}
void handleRetryNode(node);
},
[generateScriptRows, handleRetryNode, message],
[generateScriptRows, handleRetryNode, message, nodesRef, retryImageBatchChildren],
);
const openCanvasNodeTaskDetails = useCallback(
(node: CanvasNodeData) => {
@@ -1663,6 +1705,7 @@ function InfiniteCanvasPage() {
relatedConnectionIds={relatedHighlight.connectionIds}
scriptScrollTopById={scriptScrollTopById}
connectingParams={connectingParams}
batchConnectionPreview={batchConnectionPreview}
mouseWorld={mouseWorld}
connectionTargetNodeId={connectionTargetNodeId}
connectionTargetAnchorRatio={connectionTargetAnchorRatio}
@@ -1714,6 +1757,8 @@ function InfiniteCanvasPage() {
mentionReferencesByNodeId={mentionReferencesByNodeId}
mediaEffectsDisabledNodeId={emotionNodeId}
selectedNodeBounds={selectedNodeBounds}
batchSourceNodeIds={batchSourceNodeIds}
batchConnectionPreview={batchConnectionPreview}
isNodeDragging={isNodeDragging}
selectionBoundsElementRef={selectionBoundsElementRef}
renderCanvasNodeContent={renderCanvasNodeContent}
@@ -1748,6 +1793,7 @@ function InfiniteCanvasPage() {
onOpenTextEditor={openTextNodeEditor}
onOpenDirector={editCanvasDirector}
onOpenDrawing={openDrawingNode}
onStartBatchConnection={startBatchConnection}
/>
</InfiniteCanvas>
@@ -1892,6 +1938,7 @@ function InfiniteCanvasPage() {
onArrange={arrangeSelectedNodes}
onCreateStoryboard={createStoryboardGroup}
onCreateReferenceGroup={createReferenceGroup}
onBatchConnect={() => beginBatchConnectionMode(Array.from(selectedNodeIds))}
onMergeVideos={() => void mergeSelectedVideos()}
/>
) : null}
@@ -1951,7 +1998,7 @@ function InfiniteCanvasPage() {
extractingAudio={segmentRunningMode === "audio"}
trimmingVideo={segmentRunningMode === "video"}
onReversePrompt={createImageReversePromptNodes}
onRetry={(node) => void handleRetryNode(node)}
onRetry={retryCanvasNode}
onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
onToggleLocked={(node) => toggleNodeLocked(node.id)}
onDelete={(node) => deleteNodes(new Set([node.id]))}
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type Dispatch, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
import { App } from "antd";
import { nanoid } from "nanoid";
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { getNodeSpec } from "@/constant/canvas";
import { batchSourceRestriction, buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections, type CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
@@ -38,6 +39,8 @@ type ConnectionDropTarget = {
isNearNode: boolean;
};
type BatchConnectionDropTarget = ConnectionDropTarget;
const CONNECTION_HANDLE_HIT_RADIUS = 40;
const CONNECTION_NODE_HIT_PADDING = 32;
const NODE_STATUS_IDLE = "idle" as const;
@@ -65,17 +68,32 @@ export function useCanvasConnectionController({
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState<string | null>(null);
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState<number | undefined>();
const [pendingConnectionCreate, setPendingConnectionCreate] = useState<PendingConnectionCreate | null>(null);
const [batchConnectionPreview, setBatchConnectionPreview] = useState<CanvasBatchConnectionPreview | null>(null);
const [mouseWorld, setMouseWorld] = useState<Position>({ x: 0, y: 0 });
const connectingParamsRef = useRef(connectingParams);
const connectingPointerIdRef = useRef<number | null>(null);
const connectingPointerStartRef = useRef<Position | null>(null);
const pendingConnectionCreateRef = useRef(pendingConnectionCreate);
const batchConnectionPreviewRef = useRef<CanvasBatchConnectionPreview | null>(null);
const batchConnectionPointerIdRef = useRef<number | null>(null);
const batchConnectionPointerStartRef = useRef<Position | null>(null);
useLayoutEffect(() => {
connectingParamsRef.current = connectingParams;
pendingConnectionCreateRef.current = pendingConnectionCreate;
}, [connectingParams, pendingConnectionCreate]);
const updateBatchConnectionPreview = useCallback((next: CanvasBatchConnectionPreview | null) => {
batchConnectionPreviewRef.current = next;
setBatchConnectionPreview(next);
}, []);
const clearBatchConnection = useCallback(() => {
batchConnectionPointerIdRef.current = null;
batchConnectionPointerStartRef.current = null;
updateBatchConnectionPreview(null);
}, [updateBatchConnectionPreview]);
const setConnecting = useCallback((next: ConnectionHandle | null) => {
connectingParamsRef.current = next;
setConnectingParams(next);
@@ -95,7 +113,39 @@ export function useCanvasConnectionController({
const cancelPendingConnectionCreate = useCallback(() => {
closeConnectionCreateMenu();
setConnecting(null);
}, [closeConnectionCreateMenu, setConnecting]);
clearBatchConnection();
}, [clearBatchConnection, closeConnectionCreateMenu, setConnecting]);
const previewBatchConnection = useCallback((sourceNodeIds: string[], targetNodeId: string | null, targetHandleId: string | undefined, targetAnchorRatio: number | undefined, mouseWorld: Position) => {
const plan = targetNodeId
? planBatchConnections({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, nodes: nodesRef.current, connections: connectionsRef.current, config })
: null;
const eligibleSourceCount = sourceNodeIds.filter((id) => {
const node = nodesRef.current.find((item) => item.id === id);
return Boolean(node && !batchSourceRestriction(node));
}).length;
const status = !targetNodeId || !plan ? "idle" : plan.connections.length === eligibleSourceCount ? "valid" : plan.connections.length ? "partial" : "invalid";
updateBatchConnectionPreview({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, mouseWorld, status });
return plan;
}, [config, connectionsRef, nodesRef, updateBatchConnectionPreview]);
const commitBatchConnection = useCallback((sourceNodeIds: string[], targetNodeId: string, targetHandleId?: string, targetAnchorRatio?: number) => {
const plan = planBatchConnections({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, nodes: nodesRef.current, connections: connectionsRef.current, config });
if (!plan.connections.length) {
const reason = plan.skipped[0]?.reason || "没有可建立的连接";
message.warning(reason);
return plan;
}
setNodes((currentNodes) => plan.connections.reduce((current, connection) => attachNodeToStoryboardRow(current, connection), currentNodes));
setConnections((currentConnections) => [...currentConnections, ...plan.connections]);
setContextMenu(null);
const skippedCount = plan.skipped.length;
const duplicateCount = plan.duplicates.length;
const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : "";
if (skippedCount) message.warning(`已连接 ${plan.connected.length} 个节点${suffix}:${plan.skipped[0].reason}`);
else message.success(`已连接 ${plan.connected.length} 个节点${suffix}`);
return plan;
}, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
const connectNodes = useCallback((current: ConnectionHandle, targetNodeId: string, targetHandleId?: string, targetAnchorRatio?: number) => {
if (current.nodeId === targetNodeId) return;
@@ -129,10 +179,21 @@ export function useCanvasConnectionController({
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
return;
}
const storyboardRow = type === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined;
const batchSourceNodeIds = pending.batchSourceNodeIds?.length ? Array.from(new Set(pending.batchSourceNodeIds)) : [];
const batchSourceNodes = batchSourceNodeIds
.map((nodeId) => nodesRef.current.find((node) => node.id === nodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const storyboardRow = batchSourceNodeIds.length ? undefined : type === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined;
const videoPrompt = storyboardRow ? (storyboardRow.videoMotionPrompt || storyboardRow.plotDescription).trim() : "";
const sourceNode = pending.connection.handleType === "source" ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined;
const scriptPrompt = type === CanvasNodeType.Script && sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : "";
const batchScriptPrompt = batchSourceNodes
.filter((node) => node.type === CanvasNodeType.Text)
.map((node) => (node.metadata?.content || node.metadata?.prompt || "").trim())
.filter(Boolean)
.join("\n\n");
const scriptPrompt = type === CanvasNodeType.Script
? batchSourceNodeIds.length ? batchScriptPrompt : sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : ""
: "";
const metadata = type === CanvasNodeType.Drawing
? { drawingEngine: defaultDrawingEngine }
: type === CanvasNodeType.Script && scriptPrompt
@@ -153,6 +214,40 @@ export function useCanvasConnectionController({
: pending.position;
const newNode = createCanvasNode(type, position, metadata);
if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`;
if (batchSourceNodeIds.length && type === CanvasNodeType.Drawing) {
message.error("批量连接暂不支持创建绘图,请先连接到普通节点");
closeConnectionCreateMenu();
return;
}
const batchPlan = batchSourceNodeIds.length
? planBatchConnections({ sourceNodeIds: batchSourceNodeIds, targetNodeId: newNode.id, nodes: [...nodesRef.current, newNode], connections: connectionsRef.current, config, allowCapacityOverflow: true })
: null;
if (batchPlan) {
if (!batchPlan.connections.length) {
const detail = batchPlan.skipped.slice(0, 3).map((item) => item.reason).join(";");
message.warning(detail ? `没有可建立的连接:${detail}` : "没有可建立的连接");
closeConnectionCreateMenu();
setConnecting(null);
return;
}
const nextConnections = [...connectionsRef.current, ...batchPlan.connections];
const nextNodes = batchPlan.connections.reduce((currentNodes, connection) => attachNodeToStoryboardRow(currentNodes, connection), [...nodesRef.current, newNode]);
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
const skippedCount = batchPlan.skipped.length;
const duplicateCount = batchPlan.duplicates.length;
const suffix = skippedCount || duplicateCount ? `,跳过 ${skippedCount + duplicateCount} 个` : "";
if (skippedCount) message.warning(`已创建并连接 ${batchPlan.connections.length} 个源节点${suffix}:${batchPlan.skipped[0].reason}`);
else message.success(`已创建节点并连接 ${batchPlan.connections.length} 个源节点${suffix}`);
closeConnectionCreateMenu();
setConnecting(null);
return;
}
const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType);
if (!connection) {
message.warning("配置节点之间不能连接");
@@ -209,6 +304,12 @@ export function useCanvasConnectionController({
}, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]);
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => {
if (pending.batchSourceNodeIds?.length) {
if (type === CanvasNodeType.Drawing) return "批量连接暂不支持绘图";
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type, title: "", position: pending.position, width: getNodeSpec(type).width, height: getNodeSpec(type).height };
const plan = planBatchConnections({ sourceNodeIds: pending.batchSourceNodeIds, targetNodeId: pendingNode.id, nodes: [...nodesRef.current, pendingNode], connections: connectionsRef.current, config, allowCapacityOverflow: true });
return plan.connections.length ? "" : plan.skipped[0]?.reason || "当前选中的节点不能连接到此类型";
}
const spec = getNodeSpec(type);
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type, title: "", position: pending.position, width: spec.width, height: spec.height };
const pendingNodes = [...nodesRef.current, pendingNode];
@@ -257,6 +358,118 @@ export function useCanvasConnectionController({
return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode };
}, [config, connectionsRef, nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]);
const getBatchConnectionDropTarget = useCallback((clientX: number, clientY: number, sourceNodeIds: string[]): BatchConnectionDropTarget => {
const source = sourceNodeIds
.map((id) => nodesRef.current.find((node) => node.id === id))
.find((node): node is CanvasNodeData => Boolean(node && !batchSourceRestriction(node)));
if (!source) return { nodeId: null, isNearNode: false };
const world = screenToCanvas(clientX, clientY);
const scale = Math.max(viewportRef.current.k, 0.05);
const padding = CONNECTION_NODE_HIT_PADDING / scale;
const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale;
let isNearNode = false;
let bestNodeId: string | null = null;
let bestHandleId: string | undefined;
let bestAnchorRatio: number | undefined;
let bestPriority = Number.POSITIVE_INFINITY;
const current: ConnectionHandle = { nodeId: source.id, handleType: "source" };
[...nodesRef.current]
.filter((node) => !isHiddenBatchChild(node, nodesRef.current) && !isNodeHiddenByCollapsedFrame(node, nodesRef.current) && !isFrameNode(node))
.reverse()
.forEach((node) => {
const scrollTop = scriptScrollTopById[node.id] || 0;
const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : undefined;
if (node.type === CanvasNodeType.Script && !targetHandleId) return;
const targetAnchorRatio = node.type === CanvasNodeType.Script ? undefined : Math.min(0.94, Math.max(0.06, (world.y - node.position.y) / Math.max(node.height, 1)));
const anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop, targetAnchorRatio);
const dx = world.x - anchor.x;
const dy = world.y - anchor.y;
const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius;
const hitsInside = world.x >= node.position.x && world.x <= node.position.x + node.width && world.y >= node.position.y && world.y <= node.position.y + node.height;
const hitsExpanded = world.x >= node.position.x - padding && world.x <= node.position.x + node.width + padding && world.y >= node.position.y - padding && world.y <= node.position.y + node.height + padding;
if (!hitsHandle && !hitsInside && !hitsExpanded) return;
isNearNode = true;
if (!hasBatchConnectionCandidate(sourceNodeIds, node.id, nodesRef.current)) return;
const priority = hitsInside ? 0 : hitsHandle ? 1 : 2;
if (priority < bestPriority) {
bestNodeId = node.id;
bestHandleId = targetHandleId;
bestAnchorRatio = targetAnchorRatio;
bestPriority = priority;
}
});
return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode };
}, [nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]);
const startBatchConnection = useCallback((event: ReactPointerEvent, sourceNodeIds: string[]) => {
const eligible = sourceNodeIds.filter((id) => {
const node = nodesRef.current.find((item) => item.id === id);
return Boolean(node && !batchSourceRestriction(node));
});
if (!eligible.length) {
message.warning("当前选区没有可作为连接源的节点");
return;
}
event.preventDefault();
event.stopPropagation();
batchConnectionPointerIdRef.current = event.pointerId;
batchConnectionPointerStartRef.current = { x: event.clientX, y: event.clientY };
setSelectedConnectionId(null);
const mouseWorld = screenToCanvas(event.clientX, event.clientY);
previewBatchConnection(eligible, null, undefined, undefined, mouseWorld);
}, [message, nodesRef, previewBatchConnection, screenToCanvas, setSelectedConnectionId]);
const beginBatchConnectionMode = useCallback((sourceNodeIds: string[]) => {
const eligible = sourceNodeIds.filter((id) => {
const node = nodesRef.current.find((item) => item.id === id);
return Boolean(node && !batchSourceRestriction(node));
});
const source = eligible.map((id) => nodesRef.current.find((node) => node.id === id)).find((node): node is CanvasNodeData => Boolean(node));
if (!source) {
message.warning("当前选区没有可作为连接源的节点");
return;
}
previewBatchConnection(eligible, null, undefined, undefined, { x: source.position.x + source.width, y: source.position.y + source.height / 2 });
setSelectedConnectionId(null);
}, [message, nodesRef, previewBatchConnection, setSelectedConnectionId]);
const finishBatchConnection = useCallback((clientX: number, clientY: number) => {
const batch = batchConnectionPreviewRef.current;
if (!batch) return false;
const target = getBatchConnectionDropTarget(clientX, clientY, batch.sourceNodeIds);
if (!target.nodeId) return false;
commitBatchConnection(batch.sourceNodeIds, target.nodeId, target.handleId, target.anchorRatio);
clearBatchConnection();
return true;
}, [clearBatchConnection, commitBatchConnection, getBatchConnectionDropTarget]);
const openBatchConnectionCreateMenu = useCallback((clientX: number, clientY: number) => {
const batch = batchConnectionPreviewRef.current;
if (!batch) return false;
const position = screenToCanvas(clientX, clientY);
const request = buildBatchConnectionCreateRequest(batch.sourceNodeIds, nodesRef.current, position);
if (!request) {
clearBatchConnection();
message.warning("当前选区没有可作为连接源的节点");
return false;
}
const pending: PendingConnectionCreate = request;
pendingConnectionCreateRef.current = pending;
setPendingConnectionCreate(pending);
setMouseWorld(position);
clearBatchConnection();
return true;
}, [clearBatchConnection, message, nodesRef, screenToCanvas]);
const handleBatchConnectionTargetClick = useCallback((event: ReactPointerEvent | ReactMouseEvent) => {
if (!batchConnectionPreviewRef.current) return false;
const completed = finishBatchConnection(event.clientX, event.clientY);
if (!completed) message.warning("请点击目标节点的输入端");
return true;
}, [finishBatchConnection, message]);
const finishConnection = useCallback((clientX: number, clientY: number) => {
if (pendingConnectionCreateRef.current) return;
const currentConnection = connectingParamsRef.current;
@@ -279,6 +492,12 @@ export function useCanvasConnectionController({
const handleConnectStart = useCallback((event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => {
event.preventDefault();
event.stopPropagation();
if (batchConnectionPreviewRef.current && handleType === "target") {
commitBatchConnection(batchConnectionPreviewRef.current.sourceNodeIds, nodeId, handleId, anchorRatio);
clearBatchConnection();
return;
}
if (batchConnectionPreviewRef.current) clearBatchConnection();
connectingPointerIdRef.current = event.pointerId;
connectingPointerStartRef.current = { x: event.clientX, y: event.clientY };
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
@@ -286,10 +505,17 @@ export function useCanvasConnectionController({
setConnectionTargetNodeId(null);
setConnectionTargetAnchorRatio(undefined);
setSelectedConnectionId(null);
}, [screenToCanvas, setConnecting, setSelectedConnectionId]);
}, [clearBatchConnection, commitBatchConnection, screenToCanvas, setConnecting, setSelectedConnectionId]);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
const batch = batchConnectionPreviewRef.current;
if (batch && (batchConnectionPointerIdRef.current === null || batchConnectionPointerIdRef.current === event.pointerId)) {
const target = getBatchConnectionDropTarget(event.clientX, event.clientY, batch.sourceNodeIds);
const mouseWorld = screenToCanvas(event.clientX, event.clientY);
previewBatchConnection(batch.sourceNodeIds, target.nodeId, target.handleId, target.anchorRatio, mouseWorld);
return;
}
const current = connectingParamsRef.current;
if (!current || connectingPointerIdRef.current !== event.pointerId || pendingConnectionCreateRef.current) return;
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, current);
@@ -298,6 +524,26 @@ export function useCanvasConnectionController({
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
};
const handlePointerUp = (event: PointerEvent) => {
if (batchConnectionPointerIdRef.current === event.pointerId) {
const start = batchConnectionPointerStartRef.current;
const batch = batchConnectionPreviewRef.current;
if (batch && start && Math.hypot(event.clientX - start.x, event.clientY - start.y) <= 5) {
const target = getBatchConnectionDropTarget(event.clientX, event.clientY, batch.sourceNodeIds);
if (target.nodeId) {
commitBatchConnection(batch.sourceNodeIds, target.nodeId, target.handleId, target.anchorRatio);
clearBatchConnection();
} else if (!target.isNearNode) openBatchConnectionCreateMenu(event.clientX, event.clientY);
else clearBatchConnection();
return;
}
const completed = finishBatchConnection(event.clientX, event.clientY);
if (!completed) {
const target = getBatchConnectionDropTarget(event.clientX, event.clientY, batch?.sourceNodeIds || []);
if (!target.isNearNode) openBatchConnectionCreateMenu(event.clientX, event.clientY);
else clearBatchConnection();
}
return;
}
if (connectingPointerIdRef.current !== event.pointerId) return;
const start = connectingPointerStartRef.current;
if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) <= 5) {
@@ -313,10 +559,15 @@ export function useCanvasConnectionController({
finishConnection(event.clientX, event.clientY);
};
const handlePointerCancel = (event: PointerEvent) => {
if (batchConnectionPointerIdRef.current === event.pointerId) {
clearBatchConnection();
return;
}
if (connectingPointerIdRef.current === event.pointerId) setConnecting(null);
};
const cancel = () => {
if (connectingParamsRef.current) setConnecting(null);
if (batchConnectionPreviewRef.current) clearBatchConnection();
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
@@ -328,7 +579,7 @@ export function useCanvasConnectionController({
window.removeEventListener("pointercancel", handlePointerCancel);
window.removeEventListener("blur", cancel);
};
}, [finishConnection, getConnectionDropTarget, screenToCanvas, setConnecting]);
}, [clearBatchConnection, commitBatchConnection, finishBatchConnection, finishConnection, getBatchConnectionDropTarget, getConnectionDropTarget, openBatchConnectionCreateMenu, previewBatchConnection, screenToCanvas, setConnecting]);
return {
cancelPendingConnectionCreate,
@@ -339,6 +590,10 @@ export function useCanvasConnectionController({
createConnectedNode,
getConnectionCreateDisabledReason,
handleConnectStart,
handleBatchConnectionTargetClick,
batchConnectionPreview,
beginBatchConnectionMode,
startBatchConnection,
mouseWorld,
pendingConnectionCreate,
setConnecting,
@@ -3,7 +3,7 @@ import { App } from "antd";
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { buildEmotionImageArtifacts, emotionGenerationSize } from "@/lib/canvas/canvas-emotion";
import { buildEmotionImageArtifacts, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
import {
buildAudioGenerationMetadata,
buildGenerationConfig,
@@ -25,6 +25,7 @@ import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/generation-error";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { navigateToSettings } from "@/lib/settings-navigation";
import type { Skill } from "@/services/api/skills";
import type { GenerationTask } from "@/services/api/task-center";
@@ -288,18 +289,23 @@ export function useCanvasGenerationRetry({
if (!sourceDataUrl) throw new Error("无法读取情绪编辑源图片");
const artifacts = await buildEmotionImageArtifacts(sourceDataUrl, emotionEdit.faceBox, emotionSource.metadata.naturalWidth || 0, emotionSource.metadata.naturalHeight || 0);
const emotionConfig = { ...generationConfig, size: emotionGenerationSize(artifacts.editRegion), quality: !generationConfig.quality || generationConfig.quality === "auto" ? "high" : generationConfig.quality };
const imageProfile = modelCapabilityConfigFor(emotionConfig, emotionConfig.model).image!;
const editPlan = resolveEmotionEditPlan(imageProfile.references.maskSupported);
const sourceReference = sourceNodeReferenceImages(emotionSource)[0];
if (!sourceReference) throw new Error("情绪编辑源图片不可用");
const editReference = { id: `${emotionSource.id}-${emotionEdit.presetId}-edit-region`, name: "emotion-edit-region.png", type: "image/png", dataUrl: artifacts.sourceDataUrl };
const characterReference = { id: `${emotionSource.id}-${emotionEdit.presetId}-character`, name: `${emotionEdit.characterName}-face.jpg`, type: "image/jpeg", dataUrl: artifacts.characterDataUrl };
const nextEmotionEdit = { ...emotionEdit, editRegion: artifacts.editRegion, sourceWidth: artifacts.imageWidth, sourceHeight: artifacts.imageHeight, providerSize: emotionConfig.size };
const nextEmotionEdit = { ...emotionEdit, editRegion: artifacts.editRegion, sourceWidth: artifacts.imageWidth, sourceHeight: artifacts.imageHeight, providerSize: emotionConfig.size, editMode: editPlan.mode };
const providerPrompt = normalizeEmotionPromptForProvider(mediaPrompt);
const mask = emotionProviderMask(editPlan, { id: `${emotionSource.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: artifacts.maskDataUrl });
if (editPlan.notice) message.info(editPlan.notice);
const generationMetadata = { ...buildImageGenerationMetadata("edit", emotionConfig, 1, [sourceReference]), size: `${artifacts.imageWidth}x${artifacts.imageHeight}` };
setNodes((current) =>
current.map((item) =>
item.id === node.id
? {
...item,
metadata: { ...item.metadata, prompt: mediaPrompt, ...generationMetadata, ...styleMetadata, emotionEdit: nextEmotionEdit },
metadata: { ...item.metadata, prompt: providerPrompt, ...generationMetadata, ...styleMetadata, emotionEdit: nextEmotionEdit },
}
: item,
),
@@ -308,12 +314,12 @@ export function useCanvasGenerationRetry({
projectId,
nodeId: node.id,
mode: "image",
prompt: mediaPrompt,
prompt: providerPrompt,
config: emotionConfig,
referenceImages: [editReference, characterReference],
mask: { id: `${emotionSource.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: artifacts.maskDataUrl },
mask,
signal: controller.signal,
metadata: { retry: true, sourceNodeId: emotionSource.id, edit: "emotion", emotion: nextEmotionEdit, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...styleMetadata },
metadata: { retry: true, sourceNodeId: emotionSource.id, edit: "emotion", emotionEditMode: editPlan.mode, emotion: nextEmotionEdit, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...styleMetadata },
});
return;
}
+8 -1
View File
@@ -34,6 +34,7 @@ type UseCanvasKeyboardOptions = {
focusMode: boolean;
exitFocusMode: () => void;
toggleFocusMode: () => void;
beginBatchConnection: () => void;
};
export function useCanvasKeyboard({
@@ -68,6 +69,7 @@ export function useCanvasKeyboard({
focusMode,
exitFocusMode,
toggleFocusMode,
beginBatchConnection,
}: UseCanvasKeyboardOptions) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -106,6 +108,11 @@ export function useCanvasKeyboard({
if (isTextEditingTarget) return;
const isCanvasControlTarget = Boolean(target?.closest("[data-canvas-no-zoom]"));
if (isCanvasControlTarget && !(isModifierShortcut && !event.altKey && (key === "c" || key === "v"))) return;
if (event.altKey && !isModifierShortcut && key === "l") {
event.preventDefault();
if (!event.repeat && selectedNodeIdsRef.current.size > 1) beginBatchConnection();
return;
}
if (event.key === "?" && !isModifierShortcut && !event.altKey) {
event.preventDefault();
setShortcutRequestNonce((value) => value + 1);
@@ -193,5 +200,5 @@ export function useCanvasKeyboard({
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("paste", handlePaste, true);
};
}, [cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}
+12 -6
View File
@@ -23,12 +23,13 @@ import {
runBackendCanvasGenerationTask,
} from "@/lib/canvas/canvas-project-generation";
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { compositeEmotionImage, emotionGenerationSize } from "@/lib/canvas/canvas-emotion";
import { compositeEmotionImage, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS, buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { captureVideoLastFrame } from "@/lib/canvas/canvas-video-frame";
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
import { extractVideoAudio, trimVideoSegment } from "@/lib/canvas/canvas-video-segment";
import { generationErrorMessage } from "@/lib/generation-error";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { navigateToSettings } from "@/lib/settings-navigation";
import { storeGeneratedVideo } from "@/services/api/video";
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
@@ -655,9 +656,11 @@ export function useCanvasMediaTools({
const generationConfig = { ...baseConfig, count: "1", size: providerSize, quality: !baseConfig.quality || baseConfig.quality === "auto" ? "high" : baseConfig.quality };
if (!isAiConfigReady(generationConfig, generationConfig.model)) { navigateToSettings({ continueCreation: true }); return; }
if (resolveModelRequestConfig(generationConfig, generationConfig.model).interfaceType !== "openai-image") {
message.error("表情编辑需要支持蒙版的 OpenAI Images 渠道,当前渠道已拒绝整图重绘");
message.error("表情编辑需要支持多参考图编辑的 OpenAI Images 渠道");
return;
}
const imageProfile = modelCapabilityConfigFor(generationConfig, generationConfig.model).image!;
const editPlan = resolveEmotionEditPlan(imageProfile.references.maskSupported);
const source = nodeReferenceImage(node);
if (!source) return;
const editReference = {
@@ -676,24 +679,27 @@ export function useCanvasMediaTools({
const styleExecution = resolveImageEditStyle(node, payload.prompt, generationConfig);
if (!styleExecution) return;
const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution;
const providerPrompt = normalizeEmotionPromptForProvider(effectivePrompt);
const generationMetadata = { ...buildImageGenerationMetadata("edit", generationConfig, 1, [source]), size: `${payload.imageWidth}x${payload.imageHeight}` };
const emotionEdit = { sourceNodeId: node.id, characterName: payload.characterName, presetId: payload.presetId, intimacy: payload.intimacy, arousal: payload.arousal, label: payload.label, faceBox: payload.faceBox, editRegion: payload.editRegion, sourceWidth: payload.imageWidth, sourceHeight: payload.imageHeight, providerSize };
const emotionEdit = { sourceNodeId: node.id, characterName: payload.characterName, presetId: payload.presetId, intimacy: payload.intimacy, arousal: payload.arousal, label: payload.label, faceBox: payload.faceBox, editRegion: payload.editRegion, sourceWidth: payload.imageWidth, sourceHeight: payload.imageHeight, providerSize, editMode: editPlan.mode };
if (editPlan.notice) message.info(editPlan.notice);
setEmotionNodeId(null);
setRunningNodeId(childId);
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title: `${payload.characterName} · ${payload.label}`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: node.width, height: node.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata, emotionEdit } }]);
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title: `${payload.characterName} · ${payload.label}`, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: node.width, height: node.height, metadata: { prompt: providerPrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata, emotionEdit } }]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
setSelectedConnectionId(null);
setDialogNodeId(childId);
const controller = startGenerationRequest(childId, node.id, childId);
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [editReference, characterReference], mask: { id: `${node.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: payload.maskDataUrl }, signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "emotion", emotion: emotionEdit, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
const mask = emotionProviderMask(editPlan, { id: `${node.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: payload.maskDataUrl });
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: providerPrompt, config: generationConfig, referenceImages: [editReference, characterReference], mask, signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "emotion", emotionEditMode: editPlan.mode, emotion: emotionEdit, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const composited = await compositeEmotionImage(node.metadata.content, image.dataUrl, payload.editRegion, payload.faceBox);
const uploaded = await uploadImage(composited);
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata, emotionEdit } } : item));
setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: providerPrompt, ...generationMetadata, emotionEdit } } : item));
} catch (error) {
if (isGenerationCanceled(error)) return;
const details = generationErrorMessage(error);
@@ -19,6 +19,7 @@ type UseCanvasSelectionControllerOptions = {
onCanvasSelectionStart: () => void;
onNodeInteractionStart: (selectionModifier: boolean) => void;
onNodeClick: (node: CanvasNodeData) => void;
onBatchConnectionTarget?: (event: ReactMouseEvent, nodeId: string) => boolean;
onDeselect: () => void;
onSelectionBoxEnd?: () => void;
};
@@ -57,6 +58,7 @@ export function useCanvasSelectionController({
onCanvasSelectionStart,
onNodeInteractionStart,
onNodeClick,
onBatchConnectionTarget,
onDeselect,
onSelectionBoxEnd,
}: UseCanvasSelectionControllerOptions) {
@@ -126,6 +128,7 @@ export function useCanvasSelectionController({
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
event.stopPropagation();
if (event.button !== 0) return;
if (onBatchConnectionTarget?.(event, nodeId)) return;
setSelectedConnectionId(null);
const currentNodes = nodesRef.current;
const nextSelected = new Set(selectedNodeIdsRef.current);
@@ -178,7 +181,7 @@ export function useCanvasSelectionController({
setIsNodeDragging(true);
setAlignmentGuides({});
setDragPreview({ x: 0, y: 0, nodeIds: new Set(initialSelectedNodes.map((item) => item.id)) });
}, [historyPausedRef, nodesRef, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
}, [historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => {
if (dragFrameRef.current) {
@@ -470,6 +470,7 @@ export function useCanvasUpload({
isBatchRoot: undefined,
batchRootId: undefined,
batchChildIds: undefined,
batchFailedCount: undefined,
batchUsesReferenceImages: undefined,
generationType: undefined,
model: undefined,