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

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

View File

@@ -2,6 +2,7 @@ import { useLayoutEffect, useRef, type RefObject } from "react";
import { Group, Leafer, Path, Rect } from "leafer-ui";
import { activeConnectionPath, canvasConnectionPath } from "@/components/canvas/canvas-connections";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { calculateCanvasPreviewTransform, sameCanvasViewport, shouldRebaseCanvasRaster } from "@/lib/canvas/canvas-leafer-viewport";
import type { CanvasTheme } from "@/lib/canvas-theme";
@@ -18,6 +19,7 @@ type CanvasLeaferGraphicsLayerProps = {
relatedConnectionIds: Set<string>;
scriptScrollTopById: Record<string, number>;
connectingParams: ConnectionHandle | null;
batchConnectionPreview: CanvasBatchConnectionPreview | null;
mouseWorld: Position;
connectionTargetNodeId: string | null;
connectionTargetAnchorRatio?: number;
@@ -42,6 +44,7 @@ type OverlayScene = LeaferScene & {
selectionBounds: Rect;
guides: Path;
draft: Path;
batchDrafts: Group;
};
export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps) {
@@ -125,7 +128,7 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps)
const overlay = overlayRef.current;
if (!overlay) return;
syncOverlayContent(overlay, props, viewportRef.current.k);
}, [props.connectingParams, props.connectionTargetAnchorRatio, props.connectionTargetNodeId, props.mouseWorld, props.nodeById, props.scriptScrollTopById, props.selectedNodeBounds, props.selectionBox, props.theme]);
}, [props.batchConnectionPreview, props.connectingParams, props.connectionTargetAnchorRatio, props.connectionTargetNodeId, props.mouseWorld, props.nodeById, props.scriptScrollTopById, props.selectedNodeBounds, props.selectionBox, props.theme]);
useLayoutEffect(() => {
const underlay = underlayRef.current;
@@ -180,12 +183,14 @@ function createOverlayScene(host: HTMLDivElement): OverlayScene {
const selectionBounds = new Rect({ visible: false, hittable: false, fill: "transparent" });
const guides = new Path({ visible: false, hittable: false });
const draft = new Path({ visible: false, hittable: false });
const batchDrafts = new Group({ visible: false, hittable: false });
world.add(selection);
world.add(selectionBounds);
world.add(guides);
world.add(draft);
world.add(batchDrafts);
leafer.add(world);
return { leafer, world, host, selection, selectionBounds, guides, draft };
return { leafer, world, host, selection, selectionBounds, guides, draft, batchDrafts };
}
function rebuildConnections(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps) {
@@ -236,6 +241,28 @@ function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLaye
opacity: 0.72,
});
}
scene.batchDrafts.removeAll(true);
const batch = props.batchConnectionPreview;
scene.batchDrafts.visible = Boolean(batch);
if (!batch) return;
const target = batch.targetNodeId ? props.nodeById.get(batch.targetNodeId) : undefined;
const stroke = batch.status === "invalid" ? props.theme.accent.danger : batch.status === "partial" ? props.theme.node.activeStroke : props.theme.accent.primary;
batch.sourceNodeIds.forEach((sourceNodeId) => {
const source = props.nodeById.get(sourceNodeId);
if (!source) return;
const handle: ConnectionHandle = { nodeId: source.id, handleType: "source" };
scene.batchDrafts.add(new Path({
path: activeConnectionPath(source, handle, batch.mouseWorld, target, props.scriptScrollTopById[source.id] || 0, batch.targetAnchorRatio),
stroke,
strokeWidth: 1.4,
strokeScaleFixed: true,
strokeCap: "round",
dashPattern: [8, 8],
opacity: 0.72,
hittable: false,
}));
});
}
function syncSelection(rect: Rect, selection: SelectionBox, theme: CanvasTheme) {

View File

@@ -213,7 +213,7 @@ function ErrorContent({ node, theme, onRetry }: Pick<CanvasNodeContentProps, "no
onMouseDown={(event) => event.stopPropagation()}
>
<RefreshCw className="size-3.5" />
重试
{node.metadata?.isBatchRoot ? "重试失败项" : "重试"}
</button>
)}
</div>

View File

@@ -23,6 +23,7 @@ type CanvasNodeProps = {
isFocusRelated: boolean;
isConnectionTarget: boolean;
isConnecting: boolean;
forceInputVisible?: boolean;
showImageInfo: boolean;
reduceMediaEffects?: boolean;
readOnly?: boolean;
@@ -66,6 +67,7 @@ export const CanvasNode = React.memo(function CanvasNode({
isRelated,
isFocusRelated,
isConnectionTarget,
forceInputVisible = false,
showImageInfo,
reduceMediaEffects = false,
readOnly = false,
@@ -442,7 +444,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</> : null}
</div>
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} visible={hovered} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} visible={hovered || forceInputVisible} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? <ConnectionSideRail side="right" scale={scale} visible={hovered} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
</div>

View File

@@ -135,7 +135,7 @@ export function CanvasToolbar({
onToggleSettingsPanel: () => { setAddOpen(false); setAppearanceOpen(false); setSettingsOpen((value) => !value); },
onDeleteSelected: onDelete,
// 以下为多选/节点悬停工具栏回调,主工具栏不使用,用 no-op 占位
onAlign: () => {}, onArrange: () => {}, onCreateStoryboard: () => {}, onCreateReferenceGroup: () => {}, onMergeVideos: () => {},
onAlign: () => {}, onArrange: () => {}, onCreateStoryboard: () => {}, onCreateReferenceGroup: () => {}, onBatchConnect: () => {}, onMergeVideos: () => {},
onNodeInfo: () => {}, onNodeDelete: () => {}, onNodeRetry: () => {}, onNodeEditText: () => {}, onNodeDecreaseFont: () => {}, onNodeIncreaseFont: () => {},
onNodeToggleDialog: () => {}, onNodeAnnotate: () => {}, onNodeGenerateImage: () => {}, onNodeUpload: () => {}, onNodeDownload: () => {}, onNodeSaveAsset: () => {},
onNodeMaskEdit: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {},

View File

@@ -13,6 +13,7 @@ export type PendingConnectionCreate = {
connection: ConnectionHandle;
position: Position;
quick?: boolean;
batchSourceNodeIds?: string[];
};
export function CanvasSelectionToolbar({ anchorRef, containerRef, count, children }: { anchorRef: RefObject<HTMLDivElement | null>; containerRef: RefObject<HTMLDivElement | null>; count: number; children: ReactNode }) {
@@ -173,7 +174,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
<div className="mb-1.5 flex items-center justify-between gap-2 px-1 py-0.5">
<span className="flex min-w-0 items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] border opacity-75" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}><WandSparkles className="size-3.5" /></span>
<span className="min-w-0"><span className="block truncate text-[var(--fs-label)] font-semibold">创建下一步</span><span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>引用当前节点</span></span>
<span className="min-w-0"><span className="block truncate text-[var(--fs-label)] font-semibold">创建下一步</span><span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{pending.batchSourceNodeIds?.length ? `引用已选 ${pending.batchSourceNodeIds.length} 个节点` : "引用当前节点"}</span></span>
</span>
<button type="button" className="grid size-6 shrink-0 place-items-center rounded-full border opacity-55 transition-opacity hover:opacity-100" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }} onClick={onClose} aria-label="关闭连线创建菜单"><X className="size-3" /></button>
</div>

View File

@@ -2,6 +2,7 @@ import { type ReactNode, useState } from "react";
import { ConfigProvider, Switch } from "antd";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers";
import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { type AiConfig } from "@/stores/use-config-store";
@@ -15,13 +16,17 @@ const qualityOptions = [
];
const DIMENSION_STEP = 16;
const aspectOptions = [
type AspectOption = { value: string; label: string; width: number; height: number; icon: string; size?: string };
const aspectOptions: AspectOption[] = [
{ value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" },
{ value: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" },
{ value: "2:3", label: "2:3", width: 1024, height: 1536, icon: "portrait" },
{ value: "4:3", label: "4:3", width: 1360, height: 1024, icon: "landscape" },
{ value: "3:4", label: "3:4", width: 1024, height: 1360, icon: "portrait" },
{ value: "16:9", label: "16:9", width: 1824, height: 1024, icon: "landscape" },
{ value: "2:1", label: "2:1", size: "2048x1024", width: 2048, height: 1024, icon: "landscape" },
{ value: "1:2", label: "1:2", size: "1024x2048", width: 1024, height: 2048, icon: "portrait" },
{ value: "21:9", label: "21:9", size: "2352x1008", width: 2352, height: 1008, icon: "landscape" },
{ value: "9:16", label: "9:16", width: 1024, height: 1824, icon: "portrait" },
{ value: "1:1-2k", label: "1:1(2k)", size: "2048x2048", width: 2048, height: 2048, icon: "square" },
@@ -52,14 +57,33 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
const effectiveMaxCount = Math.min(maxCount, profile.maxOutputs);
const count = Math.max(1, Math.min(effectiveMaxCount, Number(normalized.count)));
const activeSize = normalized.size;
const availableAspects = aspectOptions.filter((item) => imageOptionAllowed(profile, item));
const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize);
const pixelSizeValues = profile.size.values.filter((value) => value.trim().toLowerCase() !== "auto");
const usesResolutionPicker = supportsImageResolutionPresets(profile.size);
const resolutionOptions = usesResolutionPicker ? buildImageResolutionOptions(pixelSizeValues) : [];
const activeResolution = activeSize === "auto" ? undefined : imageResolutionOption(resolutionOptions, activeSize);
const activeRatio = activeResolution?.ratio || imageRatioForSize(activeSize);
const resolutionChoices = usesResolutionPicker ? imageResolutionChoices(profile.size.values) : [];
const availableAspects: AspectOption[] = usesResolutionPicker && activeSize === "auto"
? []
: usesResolutionPicker && activeResolution
? resolutionOptions.filter((item) => item.tier === activeResolution.tier).map((item) => ({ value: item.ratio, label: item.ratio, size: item.size, width: item.width, height: item.height, icon: item.width === item.height ? "square" : item.width > item.height ? "landscape" : "portrait" }))
: aspectOptions.filter((item) => imageOptionAllowed(profile, item));
const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize) || availableAspects.find((item) => item.label === activeRatio);
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
const selectAspect = (value: string) => {
const option = aspectOptions.find((item) => item.value === value);
const option = availableAspects.find((item) => item.value === value);
onConfigChange("size", option ? imageOptionValue(profile, option) : "auto");
};
const selectResolution = (choice: ImageResolutionChoice) => {
if (choice === "auto") {
onConfigChange("size", "auto");
return;
}
const ratio = activeRatio || availableAspects[0]?.label;
const size = imageSizeForResolution(resolutionOptions, choice, ratio) || resolutionOptions.find((item) => item.tier === choice)?.size;
if (size) onConfigChange("size", size);
};
const updateDimension = (key: "width" | "height", value: number | null) => {
const next = Math.max(1, Math.floor(value || dimensions[key] || 1024));
const width = key === "width" ? next : dimensions.width;
@@ -104,23 +128,33 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
/>
</span>
</div> : null}
{profile.size.parameter !== "none" ? <div className="space-y-2">
{resolutionChoices.length ? <div className="space-y-2">
<SettingTitle color={theme.node.muted}>分辨率</SettingTitle>
<div className={`grid gap-1.5 ${resolutionChoices.length <= 2 ? "grid-cols-2" : resolutionChoices.length === 4 ? "grid-cols-4" : "grid-cols-3"}`}>
{resolutionChoices.map((choice) => (
<OptionPill key={choice} selected={choice === "auto" ? activeSize === "auto" : activeResolution?.tier === choice} theme={theme} onClick={() => selectResolution(choice)}>
{choice === "auto" ? "自动" : choice.toUpperCase()}
</OptionPill>
))}
</div>
</div> : null}
{profile.size.allowCustom ? <div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<SettingTitle color={theme.node.muted}>尺寸</SettingTitle>
{profile.size.allowCustom ? <div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: theme.node.muted }}>
16倍数对齐
</span>
<span title="输入完成后自动向上补成 16 的倍数" onMouseDown={(event) => event.stopPropagation()}>
<Switch size="small" checked={snapDimensionToStep} onChange={setSnapDimensionToStep} />
</span>
</div> : null}
</div>
</div>
{profile.size.allowCustom ? <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1.5">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1.5">
<DimensionInput prefix="W" value={dimensions.width} disabled={activeSize === "auto"} theme={theme} alignToStep={snapDimensionToStep} onChange={(value) => updateDimension("width", value)} />
<span className="text-sm opacity-45">↔</span>
<DimensionInput prefix="H" value={dimensions.height} disabled={activeSize === "auto"} theme={theme} alignToStep={snapDimensionToStep} onChange={(value) => updateDimension("height", value)} />
</div> : null}
</div>
</div> : null}
{availableAspects.length ? <div className="space-y-2">
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
@@ -158,13 +192,13 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
);
}
function imageOptionAllowed(profile: ImageCapabilityConfig, option: (typeof aspectOptions)[number]) {
function imageOptionAllowed(profile: ImageCapabilityConfig, option: AspectOption) {
if (profile.size.parameter === "none") return false;
if (profile.size.allowCustom && profile.size.values.length === 0) return true;
return [option.value, option.size, option.width && option.height ? `${option.width}x${option.height}` : ""].filter(Boolean).some((value) => profile.size.values.includes(String(value)));
}
function imageOptionValue(profile: ImageCapabilityConfig, option: (typeof aspectOptions)[number]) {
function imageOptionValue(profile: ImageCapabilityConfig, option: AspectOption) {
const candidates = [option.size, option.value, option.width && option.height ? `${option.width}x${option.height}` : ""].filter(Boolean).map(String);
return candidates.find((value) => profile.size.values.includes(value)) || option.size || option.value || "auto";
}
@@ -192,7 +226,8 @@ function isGrokResolutionQuality(profile: ImageCapabilityConfig) {
}
export function imageSizeLabel(size: string) {
return aspectOptions.find((item) => (item.size || item.value) === size || item.value === size)?.label || size;
const resolutionLabel = formatImageResolutionSize(size, buildImageResolutionOptions([size]));
return resolutionLabel !== size ? resolutionLabel : aspectOptions.find((item) => (item.size || item.value) === size || item.value === size)?.label || size;
}
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {

View File

@@ -0,0 +1,139 @@
import { nanoid } from "nanoid";
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
import { normalizeConnection } from "@/lib/canvas/canvas-project-domain";
import type { AiConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ConnectionHandle, type Position } from "@/types/canvas";
export type CanvasBatchConnectionPreview = {
sourceNodeIds: string[];
targetNodeId: string | null;
targetHandleId?: string;
targetAnchorRatio?: number;
mouseWorld: Position;
status: "idle" | "valid" | "partial" | "invalid";
};
export type BatchConnectionPlanOptions = {
sourceNodeIds: string[];
targetNodeId: string;
targetHandleId?: string;
targetAnchorRatio?: number;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
config: AiConfig;
// 创建聚合节点时保留完整图结构;生成提交阶段再按模型能力截取参考素材。
allowCapacityOverflow?: boolean;
};
export type BatchConnectionSkip = {
nodeId: string;
reason: string;
};
export type BatchConnectionPlan = {
connected: string[];
duplicates: string[];
skipped: BatchConnectionSkip[];
connections: CanvasConnection[];
};
export type BatchConnectionCreateRequest = {
batchSourceNodeIds: string[];
connection: ConnectionHandle;
position: Position;
};
export function buildBatchConnectionCreateRequest(sourceNodeIds: string[], nodes: CanvasNodeData[], position: Position): BatchConnectionCreateRequest | null {
const eligibleSourceNodeIds = Array.from(new Set(sourceNodeIds)).filter((nodeId) => {
const node = nodes.find((item) => item.id === nodeId);
return Boolean(node && !batchSourceRestriction(node));
});
const firstSourceNodeId = eligibleSourceNodeIds[0];
if (!firstSourceNodeId) return null;
return {
batchSourceNodeIds: eligibleSourceNodeIds,
connection: { nodeId: firstSourceNodeId, handleType: "source" },
position,
};
}
export function hasBatchConnectionCandidate(sourceNodeIds: string[], targetNodeId: string, nodes: CanvasNodeData[]) {
return sourceNodeIds.some((sourceNodeId) => {
const source = nodes.find((node) => node.id === sourceNodeId);
return Boolean(source && !batchSourceRestriction(source) && normalizeConnection(source.id, targetNodeId, nodes, "source"));
});
}
export function planBatchConnections({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, nodes, connections, config, allowCapacityOverflow = false }: BatchConnectionPlanOptions): BatchConnectionPlan {
const connected: string[] = [];
const duplicates: string[] = [];
const skipped: BatchConnectionSkip[] = [];
const nextConnections: CanvasConnection[] = [];
const workingConnections = [...connections];
const seenSourceIds = new Set<string>();
sourceNodeIds.forEach((sourceNodeId) => {
if (seenSourceIds.has(sourceNodeId)) return;
seenSourceIds.add(sourceNodeId);
const source = nodes.find((node) => node.id === sourceNodeId);
if (!source) {
skipped.push({ nodeId: sourceNodeId, reason: "找不到连接源节点" });
return;
}
const sourceRestriction = batchSourceRestriction(source);
if (sourceRestriction) {
skipped.push({ nodeId: source.id, reason: sourceRestriction });
return;
}
if (source.id === targetNodeId) {
skipped.push({ nodeId: source.id, reason: "目标节点不能连接到自身" });
return;
}
const normalized = normalizeConnection(source.id, targetNodeId, nodes, "source");
if (!normalized) {
skipped.push({ nodeId: source.id, reason: "节点之间不能连接" });
return;
}
const fromHandleId = normalized.fromNodeId === source.id ? undefined : targetHandleId;
const toHandleId = normalized.toNodeId === targetNodeId ? targetHandleId : undefined;
const duplicate = workingConnections.some((connection) => connection.fromNodeId === normalized.fromNodeId
&& connection.toNodeId === normalized.toNodeId
&& connection.fromHandleId === fromHandleId
&& connection.toHandleId === toHandleId);
if (duplicate) {
duplicates.push(source.id);
return;
}
const policyError = canvasConnectionError(config, nodes, workingConnections, normalized, { ignoreCapacity: allowCapacityOverflow });
if (policyError) {
skipped.push({ nodeId: source.id, reason: policyError });
return;
}
const connection: CanvasConnection = {
id: nanoid(),
...normalized,
fromHandleId,
toHandleId,
fromAnchorRatio: normalized.fromNodeId === source.id ? 0.5 : targetAnchorRatio,
toAnchorRatio: normalized.toNodeId === targetNodeId ? targetAnchorRatio : 0.5,
};
workingConnections.push(connection);
nextConnections.push(connection);
connected.push(source.id);
});
return { connected, duplicates, skipped, connections: nextConnections };
}
export function batchSourceRestriction(node: CanvasNodeData) {
if (node.type === CanvasNodeType.Frame) return "背板不能作为连接源";
if (node.type === CanvasNodeType.Script) return "分镜脚本需要按行连接";
if (node.type === CanvasNodeType.Config) return "生成配置不能作为聚合连接源";
return "";
}

View File

@@ -3,8 +3,12 @@ import type { AiConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
type ConnectionCandidate = Pick<CanvasConnection, "fromNodeId" | "toNodeId">;
type CanvasConnectionPolicyOptions = {
// 仅跳过参考素材数量上限,媒体类型不兼容仍然拒绝。
ignoreCapacity?: boolean;
};
export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate: ConnectionCandidate) {
export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate: ConnectionCandidate, options: CanvasConnectionPolicyOptions = {}) {
const target = nodes.find((node) => node.id === candidate.toNodeId);
if (!target) return "找不到连线目标节点";
const mode = nodeGenerationMode(target);
@@ -15,10 +19,10 @@ export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[],
if (mode === "image") {
if (input.videoCount > 0) return "图片生成节点不能连接参考视频";
if (input.audioCount > 0) return "图片生成节点不能连接参考音频";
return capacityError(config, mode, "image", visualInputCount, "参考图");
return options.ignoreCapacity ? "" : capacityError(config, mode, "image", visualInputCount, "参考图");
}
if (mode === "video") {
return capacityError(config, mode, "image", visualInputCount, "参考图") || capacityError(config, mode, "video", input.videoCount, "参考视频") || capacityError(config, mode, "audio", input.audioCount, "参考音频");
return options.ignoreCapacity ? "" : capacityError(config, mode, "image", visualInputCount, "参考图") || capacityError(config, mode, "video", input.videoCount, "参考视频") || capacityError(config, mode, "audio", input.audioCount, "参考音频");
}
if (mode === "text" && input.audioCount > 0) return "文本生成节点不能连接参考音频";
if (mode === "audio" && input.characterCount > 1) return "角色配音一次只能连接一个角色卡";

View File

@@ -40,6 +40,24 @@ export type CanvasEmotionParams = {
faceBox: CanvasFaceBox;
};
export type CanvasEmotionEditMode = "provider-mask" | "local-composite";
export type CanvasEmotionEditPlan = {
mode: CanvasEmotionEditMode;
includeMask: boolean;
notice?: string;
};
export function resolveEmotionEditPlan(maskSupported: boolean): CanvasEmotionEditPlan {
return maskSupported
? { mode: "provider-mask", includeMask: true }
: { mode: "local-composite", includeMask: false, notice: "当前渠道不支持蒙版,使用脸部裁切与本地羽化融合" };
}
export function emotionProviderMask<T>(plan: CanvasEmotionEditPlan, mask: T) {
return plan.includeMask ? mask : undefined;
}
type BlendshapeName =
| "browInnerUp"
| "browDown_L"
@@ -167,14 +185,21 @@ export function buildEmotionPrompt(params: CanvasEmotionParams, editRegion: Canv
`目标情绪:${preset.label};表情要求:${preset.prompt}。情绪强度通过眉眼、嘴角和脸颊的肌肉张力表达,不要通过夸大嘴巴或重绘整张脸表达。`,
"第一张输入图是唯一编辑目标,第二张输入图仅用于核对同一人物身份,不得复制第二张图的构图、背景或光线。",
targetRegion,
"如果第一张输入图中出现其他人脸,其他人脸全部视为不可编辑背景;只允许修改目标框及其透明蒙版对应的这一张脸。",
"如果第一张输入图中出现其他人脸,其他人脸全部视为不可编辑背景;只允许修改目标人脸框及其邻近表情区域。",
"只改变眉眼开合、眼角、嘴角、脸颊和口腔内部的表情细节;保持眼睛大小与方向、嘴裂宽度、牙齿数量大小排列、嘴唇厚度、下巴轮廓和脸型自然且与原图一致。",
"输入图已裁切到目标人物头部区域;透明蒙版内允许编辑,白色蒙版区域必须保持不变,蒙版外绝对不要生成或修改任何内容。",
"输入图已裁切到目标人物头部区域;只重绘目标人脸及其表情相关细节,不要生成或修改裁切图中的其他内容。",
"严格保持人物身份、五官比例、肤色、发型、发丝、耳朵、配饰、服装、姿势、头部朝向、镜头、景深、光线、背景及画面其他人物不变;不要重绘眼镜、帽子或遮挡物。",
"禁止夸张卡通笑、嘴巴过大或拉宽、牙齿像整齐白墙、露出不自然牙龈、眼睛变形或眯成线、脸颊鼓包、塑料磨皮、重复五官,以及任何身份漂移。",
].join("\n");
}
export function normalizeEmotionPromptForProvider(prompt: string) {
return prompt
.replaceAll("实际可编辑范围以该人脸周围的透明椭圆蒙版为最终边界。", "最终仅将该人脸周围的椭圆区域融合回原图。")
.replaceAll("只允许修改目标框及其透明蒙版对应的这一张脸。", "只允许修改目标人脸框及其邻近表情区域。")
.replaceAll("输入图已裁切到目标人物头部区域;透明蒙版内允许编辑,白色蒙版区域必须保持不变,蒙版外绝对不要生成或修改任何内容。", "输入图已裁切到目标人物头部区域;只重绘目标人脸及其表情相关细节,不要生成或修改裁切图中的其他内容。");
}
function describeEmotionTarget(box: CanvasFaceBox, region: CanvasEmotionEditRegion) {
const x = clamp(Math.round(box.x - region.x), 0, Math.max(0, region.width - 1));
const y = clamp(Math.round(box.y - region.y), 0, Math.max(0, region.height - 1));
@@ -182,7 +207,7 @@ function describeEmotionTarget(box: CanvasFaceBox, region: CanvasEmotionEditRegi
const height = Math.max(1, Math.min(region.height - y, Math.round(box.height)));
const centerX = Math.round(((x + width / 2) / Math.max(1, region.width)) * 100);
const centerY = Math.round(((y + height / 2) / Math.max(1, region.height)) * 100);
return `目标人脸框(相对于第一张裁切输入图):x=${x}px,y=${y}px,width=${width}px,height=${height}px;人脸中心约位于裁切图的 ${centerX}% 横向、${centerY}% 纵向。实际可编辑范围以该人脸周围的透明椭圆蒙版为最终边界。`;
return `目标人脸框(相对于第一张裁切输入图):x=${x}px,y=${y}px,width=${width}px,height=${height}px;人脸中心约位于裁切图的 ${centerX}% 横向、${centerY}% 纵向。最终仅将该人脸周围的椭圆区域融合回原图。`;
}
export async function buildEmotionImageArtifacts(dataUrl: string, box: CanvasFaceBox, imageWidth: number, imageHeight: number): Promise<CanvasEmotionImageArtifacts> {

View File

@@ -0,0 +1,84 @@
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
export function failedImageBatchChildren(root: CanvasNodeData, nodes: CanvasNodeData[]) {
if (root.type !== CanvasNodeType.Image || !root.metadata?.isBatchRoot) return [];
const nodeById = new Map(nodes.map((node) => [node.id, node]));
return (root.metadata.batchChildIds || [])
.map((id) => nodeById.get(id))
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id && node.metadata.status === "error"));
}
export function markImageBatchRetrying(rootId: string, childIds: string[], nodes: CanvasNodeData[]): CanvasNodeData[] {
const retryingIds = new Set(childIds);
return nodes.map((node) => {
if (node.id !== rootId && !retryingIds.has(node.id)) return node;
return {
...node,
metadata: {
...node.metadata,
status: "loading",
...(node.id === rootId ? { batchFailedCount: childIds.length } : {}),
errorDetails: undefined,
generationErrorCode: undefined,
failedPromptFingerprint: undefined,
},
};
});
}
export function restoreUnsubmittedImageBatchChild(current: CanvasNodeData, original: CanvasNodeData): CanvasNodeData {
if (current.metadata?.status !== "loading") return current;
return {
...current,
metadata: {
...current.metadata,
status: "error",
errorDetails: original.metadata?.errorDetails || "重试请求未提交",
generationErrorCode: original.metadata?.generationErrorCode,
failedPromptFingerprint: original.metadata?.failedPromptFingerprint,
},
};
}
export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeData[]) {
if (root.type !== CanvasNodeType.Image || !root.metadata?.isBatchRoot) return root;
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const children = (root.metadata.batchChildIds || [])
.map((id) => nodeById.get(id))
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id));
if (!children.length) return root;
const primary = children.find((node) => node.id === root.metadata?.primaryImageId && node.metadata?.content) || children.find((node) => node.metadata?.content);
const loading = children.some((node) => node.metadata?.status === "loading");
const failed = children.find((node) => node.metadata?.status === "error");
const metadata: CanvasNodeMetadata = { ...root.metadata };
metadata.batchFailedCount = children.filter((node) => node.metadata?.status === "error").length;
if (primary) {
metadata.content = primary.metadata?.content;
metadata.storageKey = primary.metadata?.storageKey;
metadata.mimeType = primary.metadata?.mimeType;
metadata.bytes = primary.metadata?.bytes;
metadata.naturalWidth = primary.metadata?.naturalWidth;
metadata.naturalHeight = primary.metadata?.naturalHeight;
metadata.primaryImageId = primary.id;
metadata.status = "success";
delete metadata.errorDetails;
delete metadata.generationErrorCode;
delete metadata.failedPromptFingerprint;
} else {
delete metadata.content;
delete metadata.storageKey;
delete metadata.mimeType;
delete metadata.bytes;
delete metadata.naturalWidth;
delete metadata.naturalHeight;
delete metadata.primaryImageId;
metadata.status = loading ? "loading" : failed ? "error" : "idle";
metadata.errorDetails = failed?.metadata?.errorDetails;
metadata.generationErrorCode = failed?.metadata?.generationErrorCode;
metadata.failedPromptFingerprint = failed?.metadata?.failedPromptFingerprint;
}
return { ...root, metadata };
}

View File

@@ -17,6 +17,7 @@ function snapshotMetadata(metadata: CanvasNodeMetadata | undefined, rootId: stri
delete snapshot.isBatchRoot;
delete snapshot.batchRootId;
delete snapshot.batchChildIds;
delete snapshot.batchFailedCount;
delete snapshot.primaryImageId;
delete snapshot.imageBatchExpanded;
snapshot.status = snapshot.content ? "success" : "idle";

View File

@@ -37,6 +37,7 @@ export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyM
delete metadata.generationBatches;
delete metadata.batchRootId;
delete metadata.batchChildIds;
delete metadata.batchFailedCount;
delete metadata.isBatchRoot;
delete metadata.primaryImageId;
delete metadata.imageBatchExpanded;

View File

@@ -4,7 +4,7 @@ import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-ang
import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { scopedLocalStorage } from "@/lib/user-scope";
import type { GenerationTask } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
@@ -225,9 +225,10 @@ export function storyboardRowFromHandle(nodes: CanvasNodeData[], nodeId: string,
export function expandStoryboardTextMentions(prompt: string, references: CanvasResourceReference[]) {
let expanded = prompt;
references.filter((reference) => reference.active && reference.kind === "text" && reference.text?.trim()).forEach((reference) => {
const token = `@${reference.label}`;
if (!expanded.includes(token)) return;
expanded = expanded.split(token).join(`【项目设定:${reference.title}】\n${reference.text!.trim()}`);
const replacement = `【项目设定:${reference.title}】\n${reference.text!.trim()}`;
for (const token of [canvasResourceMentionToken(reference), `@${reference.label}`]) {
if (expanded.includes(token)) expanded = expanded.split(token).join(replacement);
}
});
return expanded;
}

View File

@@ -17,9 +17,11 @@ function isCharacterReference(ctx: ToolContext) { return isText(ctx) && ctx.node
function isEditableText(ctx: ToolContext) { return isText(ctx) && !isCharacterReference(ctx); }
function canOpenDialog(ctx: ToolContext) { return isEditableText(ctx) || isImage(ctx) || isVideo(ctx); }
function simpleMode(ctx: ToolContext) { return ctx.workspaceMode === "simple"; }
function isImageBatchRoot(ctx: ToolContext) { return isImage(ctx) && Boolean(ctx.nodeMetadata?.isBatchRoot && ctx.nodeMetadata.batchChildIds?.length); }
function canRetry(ctx: ToolContext) {
const requiresPromptChange = ctx.nodeMetadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(ctx.nodeMetadata?.errorDetails);
return ctx.nodeMetadata?.status === "error" && !requiresPromptChange;
const batchHasFailures = isImageBatchRoot(ctx) && (ctx.nodeMetadata?.batchFailedCount || (ctx.nodeMetadata?.status === "error" ? 1 : 0)) > 0;
return (ctx.nodeMetadata?.status === "error" || (batchHasFailures && ctx.nodeMetadata?.status !== "loading")) && !requiresPromptChange;
}
export const nodeHoverToolbarTools: ToolDefinition[] = [
@@ -52,8 +54,8 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
id: "retry",
toolbar: "node-hover",
category: "node-state",
label: "重新生成",
displayLabel: "重试",
label: (ctx) => isImageBatchRoot(ctx) ? "重试批次中的失败图片" : "重新生成",
displayLabel: (ctx) => isImageBatchRoot(ctx) ? "重试失败项" : "重试",
icon: <RefreshCw className="size-3.5" />,
defaultVisible: true,
defaultOrder: 30,

View File

@@ -1,4 +1,4 @@
import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizontalJustifyStart, AlignHorizontalSpaceAround, AlignHorizontalSpaceBetween, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, AlignVerticalSpaceAround, AlignVerticalSpaceBetween, Film, FolderTree, Grid3X3, LayoutTemplate, LoaderCircle, Workflow } from "lucide-react";
import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizontalJustifyStart, AlignHorizontalSpaceAround, AlignHorizontalSpaceBetween, AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, AlignVerticalSpaceAround, AlignVerticalSpaceBetween, Film, FolderTree, Grid3X3, LayoutTemplate, Link2, LoaderCircle, Workflow } from "lucide-react";
import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry";
@@ -20,6 +20,7 @@ export const selectionToolbarTools: ToolDefinition[] = [
// 分组组
{ id: "selection-create-storyboard", toolbar: "selection", category: "selection", label: "创建分镜组", icon: <LayoutTemplate />, defaultVisible: true, defaultOrder: 130, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateStoryboard() },
{ id: "selection-create-reference-group", toolbar: "selection", category: "selection", label: "创建引用组", icon: <FolderTree />, defaultVisible: true, defaultOrder: 140, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateReferenceGroup() },
{ id: "selection-batch-connect", toolbar: "selection", category: "selection", label: "批量连接", icon: <Link2 />, defaultVisible: true, defaultOrder: 145, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onBatchConnect() },
{
id: "selection-merge-videos",
toolbar: "selection",

View File

@@ -57,6 +57,7 @@ export type ToolbarHandlers = {
onArrange: (mode: "row" | "column" | "grid" | "flow") => void;
onCreateStoryboard: () => void;
onCreateReferenceGroup: () => void;
onBatchConnect: () => void;
onMergeVideos: () => void;
// 节点悬停工具栏——节点操作(均接收当前节点)
onNodeInfo: (node: CanvasNodeData) => void;

View File

@@ -0,0 +1,9 @@
const GROK_IMAGE_PROMPT_MAX_BYTES = 8000;
export function grokImagePromptLimitError(prompt: string, interfaceType?: string, model?: string) {
const modelName = model?.trim().toLowerCase().replace(/^models\//, "");
if (interfaceType !== "grok-image" || modelName !== "grok-imagine-image-quality") return null;
const promptBytes = new TextEncoder().encode(prompt).byteLength;
if (promptBytes <= GROK_IMAGE_PROMPT_MAX_BYTES) return null;
return `Grok 图片完整提示词为 ${promptBytes} UTF-8 字节,超过上游 ${GROK_IMAGE_PROMPT_MAX_BYTES} 字节限制。完整提示词包含当前输入及自动展开的连线文本、角色卡、画风和模板;系统不会自动删改,请回到相应节点精简后重试。`;
}

View File

@@ -0,0 +1,83 @@
export type ImageResolutionTier = "1k" | "2k" | "4k";
export type ImageResolutionOption = {
size: string;
tier: ImageResolutionTier;
ratio: string;
width: number;
height: number;
};
export type ImageResolutionChoice = "auto" | ImageResolutionTier;
const tierOrder: ImageResolutionTier[] = ["1k", "2k", "4k"];
const ratioOrder = ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "2:1", "1:2", "21:9"];
export function supportsImageResolutionPresets(size: { parameter: string; values: string[]; allowCustom?: boolean }) {
return size.parameter === "size" && size.values.some((value) => /^\d+x\d+$/i.test(value.trim()));
}
export function buildImageResolutionOptions(values: string[]) {
return values
.map(parseImageResolutionOption)
.filter((item): item is ImageResolutionOption => Boolean(item))
.sort((left, right) => {
const tierDifference = tierOrder.indexOf(left.tier) - tierOrder.indexOf(right.tier);
if (tierDifference) return tierDifference;
return ratioOrder.indexOf(left.ratio) - ratioOrder.indexOf(right.ratio);
});
}
export function imageResolutionChoices(values: string[]): ImageResolutionChoice[] {
const choices: ImageResolutionChoice[] = values.some((value) => value.trim().toLowerCase() === "auto") ? ["auto"] : [];
for (const option of buildImageResolutionOptions(values)) {
if (!choices.includes(option.tier)) choices.push(option.tier);
}
return choices;
}
export function imageResolutionOption(options: ImageResolutionOption[], size: string) {
return options.find((item) => item.size.toLowerCase() === size.trim().toLowerCase());
}
export function imageSizeForResolution(options: ImageResolutionOption[], tier: ImageResolutionTier, ratio: string) {
return options.find((item) => item.tier === tier && item.ratio === ratio)?.size;
}
export function imageRatioForSize(size: string) {
return parseImageResolutionOption(size)?.ratio || "";
}
export function formatImageResolutionSize(size: string, options: ImageResolutionOption[]) {
if (size.trim().toLowerCase() === "auto") return "自动";
const option = imageResolutionOption(options, size);
return option ? `${option.ratio} · ${option.tier.toUpperCase()}` : size;
}
function parseImageResolutionOption(value: string): ImageResolutionOption | null {
const match = value.trim().toLowerCase().match(/^(\d+)x(\d+)$/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
const pixels = width * height;
const tier: ImageResolutionTier | null = pixels <= 2_000_000 ? "1k" : pixels <= 4_300_000 ? "2k" : pixels <= 8_294_400 ? "4k" : null;
if (!tier) return null;
const ratio = closestRatio(width / height);
if (!ratio) return null;
return { size: `${width}x${height}`, tier, ratio, width, height };
}
function closestRatio(value: number) {
let result = "";
let bestDifference = Number.POSITIVE_INFINITY;
for (const label of ratioOrder) {
const [width, height] = label.split(":").map(Number);
const expected = width / height;
const difference = Math.abs(value - expected) / expected;
if (difference < bestDifference) {
result = label;
bestDifference = difference;
}
}
return bestDifference <= 0.025 ? result : "";
}

View File

@@ -62,7 +62,14 @@ export type VideoCapabilityConfig = {
defaultOperation: string;
};
const defaultImageSizes = ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", "2048x2048", "2048x1152", "1152x2048", "3840x2160", "2160x3840"];
// Keep explicit pixel presets for each resolution tier so the settings panel can
// switch between 1K, 2K and 4K without silently converting the requested ratio.
const defaultImageSizes = [
"auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16",
"1024x1024", "1360x1024", "1024x1360", "1536x1024", "1024x1536", "1024x1280", "1280x1024", "2048x878", "1824x1024", "1024x1824",
"2048x2048", "2304x1728", "1728x2304", "2496x1664", "1664x2496", "1792x2240", "2240x1792", "3136x1344", "2752x1536", "1536x2752",
"2880x2880", "3264x2448", "2448x3264", "3504x2336", "2336x3504", "2560x3200", "3200x2560", "3808x1632", "3840x2160", "2160x3840",
];
export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = ""): ImageCapabilityConfig {
const image: ImageCapabilityConfig = {

View File

@@ -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;
}),
);
}

View File

@@ -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 = {

View File

@@ -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>
);
}

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]))}

View File

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

View File

@@ -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;
}

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]);
}

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);

View File

@@ -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) {

View File

@@ -470,6 +470,7 @@ export function useCanvasUpload({
isBatchRoot: undefined,
batchRootId: undefined,
batchChildIds: undefined,
batchFailedCount: undefined,
batchUsesReferenceImages: undefined,
generationType: undefined,
model: undefined,

View File

@@ -5,6 +5,7 @@ import { createGenerationTask, waitForGenerationTask, type GenerationTask } from
import { LOCAL_DREAMINA_WAIT_STOPPED_CODE, LocalDreaminaGenerationClientError, runLocalDreaminaGenerationTask, type LocalDreaminaGenerationInput, type LocalDreaminaGenerationTask } from "@/services/local-dreamina-generation";
import { isLocalDreaminaBackgroundTask, localDreaminaTaskId, projectLocalDreaminaTask, stripLocalDreaminaTaskPrefix } from "@/services/local-dreamina-task-projection";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { grokImagePromptLimitError } from "@/lib/grok-image-prompt-limit";
import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -85,6 +86,7 @@ export async function runBackendGenerationTask(
dependencies: GenerationTaskDependencies = defaultDependencies,
) {
throwIfAborted(signal);
assertClientPromptLimit(mode, prompt, config, metadata);
if (isLocalDreaminaModel(config.model)) {
return await runLocalDreaminaGeneration(
{ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, mask, signal, metadata, onTaskUpdate, localIdempotencyKey, localResumeOnly, clientOperationId, retryOf, attemptGroupId },
@@ -99,6 +101,7 @@ export async function runBackendGenerationTask(
export async function runBackendGenerationTaskBatch(options: BackendGenerationTaskOptions & { count: number }, dependencies: GenerationTaskDependencies = defaultDependencies) {
const count = Math.max(1, Math.min(15, Math.floor(Number(options.count)) || 1));
throwIfAborted(options.signal);
assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata);
if (options.retryContextsByBatchIndex && options.retryContextsByBatchIndex.length !== count) throw new Error("生成重试批次任务数量不匹配");
if (isLocalDreaminaModel(options.config.model)) {
return Promise.allSettled(
@@ -305,6 +308,13 @@ function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
}
function assertClientPromptLimit(mode: BackendGenerationMode, prompt: string, config: AiConfig, metadata?: Record<string, unknown>) {
if (mode !== "image" || metadata?.promptTemplateOperation) return;
const requestConfig = resolveModelRequestConfig(config, config.model);
const promptLimitError = grokImagePromptLimitError(prompt, requestConfig.interfaceType, requestConfig.model);
if (promptLimitError) throw new Error(promptLimitError);
}
async function prepareGenerationReferences({
referenceImages = [],
referenceVideos = [],

View File

@@ -176,6 +176,7 @@ export type CanvasNodeMetadata = {
isBatchRoot?: boolean;
batchRootId?: string;
batchChildIds?: string[];
batchFailedCount?: number;
batchUsesReferenceImages?: boolean;
primaryImageId?: string;
imageBatchExpanded?: boolean;
@@ -316,6 +317,7 @@ export type CanvasNodeMetadata = {
sourceHeight?: number;
providerSize?: string;
maskStorageKey?: string;
editMode?: "provider-mask" | "local-composite";
};
portraitTexture?: PortraitTextureSettings;
};