feat(canvas): 画布媒体工作流 - 支持批量连线、失败重试与模型能力增强
合并 PR #230。保留当前主线样式与组件结构,吸收批量连线、失败批次重试、媒体模型能力和相关后端增强,并修复集成检查发现的分辨率选择器启用条件与测试稳定性问题。
This commit is contained in:
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 = {
|
||||
|
||||
Reference in New Issue
Block a user