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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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