ai-chatbot/lib/scene-fusion/output-capabilities.ts

343 lines
10 KiB
TypeScript

export const SCENE_FUSION_ASPECT_RATIO_OPTIONS = [
{ label: "自动", value: "auto" },
{ label: "1:1", value: "1:1" },
{ label: "4:3", value: "4:3" },
{ label: "3:4", value: "3:4" },
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" },
] as const;
export const SCENE_FUSION_IMAGE_SIZE_OPTIONS = [
{ label: "标准 (1K)", value: "1K" },
{ label: "高清 (2K)", value: "2K" },
{ label: "超清 (4K)", value: "4K" },
] as const;
export type SceneFusionAspectRatioValue =
string;
export type SceneFusionImageSizeValue =
string;
export type SceneFusionImageGenConfig = {
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportedAspectRatios?: string[];
supportedImageSizes?: string[];
};
export type SceneFusionCapabilityCandidate = {
model: {
id: string;
displayName: string;
imageGenConfig?: SceneFusionImageGenConfig | null;
};
provider: {
type: string;
};
};
export type SceneFusionCapabilitySummary = {
strategy: "preferred-model" | "candidate-intersection" | "fallback-default";
strategyLabel: string;
preferredModelId: string | null;
preferredModelName: string | null;
candidateCount: number;
note: string | null;
aspectRatio: {
enabled: boolean;
options: SceneFusionAspectRatioValue[];
defaultValue: SceneFusionAspectRatioValue;
};
imageSize: {
enabled: boolean;
options: SceneFusionImageSizeValue[];
defaultValue: SceneFusionImageSizeValue;
};
};
const DEFAULT_ASPECT_RATIO: SceneFusionAspectRatioValue = "auto";
const DEFAULT_IMAGE_SIZE: SceneFusionImageSizeValue = "2K";
const ALL_ASPECT_RATIO_VALUES = SCENE_FUSION_ASPECT_RATIO_OPTIONS.map(
(option) => option.value
);
const ALL_IMAGE_SIZE_VALUES = SCENE_FUSION_IMAGE_SIZE_OPTIONS.map((option) => option.value);
export function normalizeSceneFusionProviderType(providerType: string): string {
return providerType === "gemini-image" ? "gemini" : providerType;
}
export function canProcessSceneFusionProviderType(providerType: string): boolean {
const normalized = normalizeSceneFusionProviderType(providerType);
switch (normalized) {
case "alibaba-image":
case "ruxa-image":
case "fal-image":
case "snapgen-image":
case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "gemini":
case "volcano-vision":
return true;
default:
return false;
}
}
function orderedIntersect<T extends string>(base: readonly T[], groups: T[][]): T[] {
if (groups.length === 0) return [...base];
return base.filter((item) => groups.every((group) => group.includes(item)));
}
function uniqueOrdered<T extends string>(values: T[]): T[] {
const seen = new Set<T>();
const result: T[] = [];
for (const value of values) {
if (!value || seen.has(value)) continue;
seen.add(value);
result.push(value);
}
return result;
}
function normalizeImageSizeValue(value: string) {
const trimmed = String(value || "").trim();
const kMatch = trimmed.match(/^(\d+(?:\.\d+)?)\s*k$/i);
if (kMatch?.[1]) {
return `${Number(kMatch[1]).toString()}K`;
}
return trimmed;
}
function getSupportedAspectRatiosByProvider(
providerType: string,
imageGenConfig?: SceneFusionImageGenConfig | null
): SceneFusionAspectRatioValue[] {
if (Array.isArray(imageGenConfig?.supportedAspectRatios)) {
const configured = uniqueOrdered(
imageGenConfig.supportedAspectRatios
.map((value) => String(value || "").trim())
.filter(Boolean)
);
if (configured.length > 0) return configured;
}
switch (normalizeSceneFusionProviderType(providerType)) {
case "volcano-vision":
return [];
case "ruxa-image":
return ["1:1", "4:3", "3:4", "16:9", "9:16"];
case "alibaba-image":
case "fal-image":
case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "gemini":
default:
return [...ALL_ASPECT_RATIO_VALUES];
}
}
function getSupportedImageSizesByConfig(
imageGenConfig?: SceneFusionImageGenConfig | null
): SceneFusionImageSizeValue[] {
if (imageGenConfig?.supportsSize === false) {
return [];
}
if (Array.isArray(imageGenConfig?.supportedImageSizes)) {
const configured = uniqueOrdered(
imageGenConfig.supportedImageSizes
.map((value) => normalizeImageSizeValue(value))
.filter(Boolean)
);
if (configured.length > 0) return configured;
}
return ALL_IMAGE_SIZE_VALUES.filter((value) => {
if (value === "1K") return imageGenConfig?.supports1K !== false;
if (value === "2K") return imageGenConfig?.supports2K !== false;
if (value === "4K") return imageGenConfig?.supports4K !== false;
return true;
});
}
function pickDefaultAspectRatio(
options: SceneFusionAspectRatioValue[]
): SceneFusionAspectRatioValue {
return (
options.find((value) => value === DEFAULT_ASPECT_RATIO) ||
options.find((value) => value === "1:1") ||
options[0] ||
DEFAULT_ASPECT_RATIO
);
}
function pickDefaultImageSize(options: SceneFusionImageSizeValue[]): SceneFusionImageSizeValue {
return (
options.find((value) => value === DEFAULT_IMAGE_SIZE) ||
options.find((value) => value === "1K") ||
options.find((value) => value === "4K") ||
options[0] ||
DEFAULT_IMAGE_SIZE
);
}
export function getSceneFusionCandidateCapability(candidate: SceneFusionCapabilityCandidate) {
const aspectRatioOptions = getSupportedAspectRatiosByProvider(
candidate.provider.type,
candidate.model.imageGenConfig
);
const imageSizeOptions = getSupportedImageSizesByConfig(candidate.model.imageGenConfig);
return {
aspectRatio: {
enabled: aspectRatioOptions.length > 0,
options: aspectRatioOptions,
defaultValue: pickDefaultAspectRatio(aspectRatioOptions),
},
imageSize: {
enabled: imageSizeOptions.length > 0,
options: imageSizeOptions,
defaultValue: pickDefaultImageSize(imageSizeOptions),
},
};
}
export function buildSceneFusionCapabilitySummary(
candidates: SceneFusionCapabilityCandidate[],
preferredModelId?: string | null
): SceneFusionCapabilitySummary {
const supportedCandidates = candidates.filter((candidate) =>
canProcessSceneFusionProviderType(candidate.provider.type)
);
if (supportedCandidates.length === 0) {
return {
strategy: "fallback-default",
strategyLabel: "未检测到可用模型,使用默认参数",
preferredModelId: null,
preferredModelName: null,
candidateCount: 0,
note: "后台当前没有可用于批量处理的生图模型,以下参数仅作为默认值展示。",
aspectRatio: {
enabled: true,
options: [...ALL_ASPECT_RATIO_VALUES],
defaultValue: DEFAULT_ASPECT_RATIO,
},
imageSize: {
enabled: true,
options: [...ALL_IMAGE_SIZE_VALUES],
defaultValue: DEFAULT_IMAGE_SIZE,
},
};
}
const preferredCandidate =
preferredModelId
? supportedCandidates.find((candidate) => candidate.model.id === preferredModelId) || null
: null;
const sourceCandidates = preferredCandidate ? [preferredCandidate] : supportedCandidates;
const strategy = preferredCandidate ? "preferred-model" : "candidate-intersection";
const aspectRatioGroups = sourceCandidates.map(
(candidate) => getSceneFusionCandidateCapability(candidate).aspectRatio.options
);
const imageSizeGroups = sourceCandidates.map(
(candidate) => getSceneFusionCandidateCapability(candidate).imageSize.options
);
const aspectRatioOptions = orderedIntersect(
uniqueOrdered([...ALL_ASPECT_RATIO_VALUES, ...aspectRatioGroups.flat()]),
aspectRatioGroups
);
const imageSizeOptions = orderedIntersect(
uniqueOrdered([...ALL_IMAGE_SIZE_VALUES, ...imageSizeGroups.flat()]),
imageSizeGroups
);
return {
strategy,
strategyLabel: preferredCandidate
? `按首选模型 ${preferredCandidate.model.displayName} 的能力展示`
: `未指定首选模型,按 ${supportedCandidates.length} 个候选模型交集展示`,
preferredModelId: preferredCandidate?.model.id || null,
preferredModelName: preferredCandidate?.model.displayName || null,
candidateCount: supportedCandidates.length,
note: preferredCandidate
? supportedCandidates.length > 1
? "当首选模型失败回退到其他模型时,系统会自动按目标模型能力降级不支持的参数。"
: "当前仅使用一个模型,参数会严格按该模型能力生效。"
: "多模型轮换时,不支持的参数已在前端按交集隐藏,后端也会继续做能力兜底。",
aspectRatio: {
enabled: aspectRatioOptions.length > 0,
options: aspectRatioOptions,
defaultValue: pickDefaultAspectRatio(aspectRatioOptions),
},
imageSize: {
enabled: imageSizeOptions.length > 0,
options: imageSizeOptions,
defaultValue: pickDefaultImageSize(imageSizeOptions),
},
};
}
export function normalizeSceneFusionOutputParamsForCandidate(
candidate: SceneFusionCapabilityCandidate,
requested: {
aspectRatio?: string | null;
imageSize?: string | null;
}
) {
const capability = getSceneFusionCandidateCapability(candidate);
const requestedAspectRatio = String(requested.aspectRatio || "").trim() as SceneFusionAspectRatioValue;
const requestedImageSize = String(requested.imageSize || "").trim().toUpperCase() as SceneFusionImageSizeValue;
const aspectRatio =
!capability.aspectRatio.enabled
? undefined
: capability.aspectRatio.options.includes(requestedAspectRatio)
? requestedAspectRatio
: requestedAspectRatio
? capability.aspectRatio.defaultValue
: undefined;
const imageSize =
!capability.imageSize.enabled
? undefined
: capability.imageSize.options.includes(requestedImageSize)
? requestedImageSize
: requestedImageSize
? capability.imageSize.defaultValue
: undefined;
const changes: string[] = [];
if (requestedAspectRatio && aspectRatio !== requestedAspectRatio) {
changes.push(
aspectRatio
? `aspectRatio:${requestedAspectRatio}->${aspectRatio}`
: `aspectRatio:${requestedAspectRatio}->default`
);
}
if (requestedImageSize && imageSize !== requestedImageSize) {
changes.push(
imageSize
? `imageSize:${requestedImageSize}->${imageSize}`
: `imageSize:${requestedImageSize}->default`
);
}
return {
aspectRatio,
imageSize,
changed: changes.length > 0,
changes,
};
}