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