fix: drive image params from model config

This commit is contained in:
134 2026-06-24 11:12:11 +08:00
parent 7a58cdd12b
commit 9b94aae898
7 changed files with 270 additions and 93 deletions

View File

@ -641,6 +641,8 @@ type ImageGenConfig = {
supportsNegativePrompt?: boolean; // 是否支持反向提示词
supportsWatermark?: boolean; // 是否支持水印开关
supportsSeed?: boolean; // 是否支持随机种子
supportedAspectRatios?: string[]; // 支持的图片比例
supportedImageSizes?: string[]; // 支持的分辨率/K 数
creditCostPerImage?: number; // 每张图片积分消耗
creditCostMultiplierBySize?: Record<string, number>; // 各图片尺寸积分倍率
creditDiscountRate?: number; // 积分折扣,按“折”配置,如 5 = 5折

View File

@ -48,11 +48,11 @@ interface ImageParamsBarProps {
supportsWatermark?: boolean;
supportsSeed?: boolean;
supportedAspectRatios?: string[];
supportedImageSizes?: string[];
};
}
// 图片比例选项(根据 Nano Banana 文档)
const ASPECT_RATIO_OPTIONS = [
const DEFAULT_ASPECT_RATIO_OPTIONS = [
{ value: "auto", label: "自动" },
{ value: "1:1", label: "1:1" },
{ value: "16:9", label: "16:9" },
@ -65,30 +65,63 @@ const ASPECT_RATIO_OPTIONS = [
{ value: "4:5", label: "4:5" },
{ value: "21:9", label: "21:9" },
] as const;
type AspectRatioValue = (typeof ASPECT_RATIO_OPTIONS)[number]["value"];
type AspectRatioValue = string;
// 图片大小选项(仅支持 nano-banana-pro)
const IMAGE_SIZE_OPTIONS = [
const DEFAULT_IMAGE_SIZE_OPTIONS = [
{ value: "1K", label: "1K", disabled: false },
{ value: "2K", label: "2K", disabled: false },
{ value: "4K", label: "4K", disabled: false },
] as const;
type ImageSizeValue = (typeof IMAGE_SIZE_OPTIONS)[number]["value"];
type ImageSizeValue = string;
function normalizeImageSizeValue(value?: string, fallback: ImageSizeValue = "2K"): ImageSizeValue {
const normalized = String(value || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
const trimmed = String(value || "").trim();
if (!trimmed) return fallback;
const kMatch = trimmed.match(/^(\d+(?:\.\d+)?)\s*k$/i);
if (kMatch?.[1]) {
return `${Number(kMatch[1]).toString()}K`;
}
return fallback;
return trimmed;
}
const IMAGE_SIZE_PIXEL_MAP: Record<ImageSizeValue, number> = {
const IMAGE_SIZE_PIXEL_MAP: Record<string, number> = {
"0.5K": 512,
"1K": 1024,
"2K": 2048,
"4K": 4096,
"8K": 8192,
"10K": 10240,
"12K": 12288,
};
const DEFAULT_IMAGE_SIZE_VALUE = "2K";
function getImageSizePixelValue(imageSize: string) {
const normalized = normalizeImageSizeValue(imageSize, DEFAULT_IMAGE_SIZE_VALUE);
if (IMAGE_SIZE_PIXEL_MAP[normalized]) {
return IMAGE_SIZE_PIXEL_MAP[normalized];
}
const kMatch = normalized.match(/^(\d+(?:\.\d+)?)K$/i);
if (kMatch?.[1]) {
return Math.max(1, Math.round(Number(kMatch[1]) * 1024));
}
return IMAGE_SIZE_PIXEL_MAP[DEFAULT_IMAGE_SIZE_VALUE];
}
function uniqueValues(values: string[]) {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = String(value || "").trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
export function ImageParamsBar({
modelName = "Gemini 图片生成",
initialParams,
@ -156,38 +189,71 @@ export function ImageParamsBar({
// Gemini:1~2 张
return [1, 2];
};
const configuredImageSizes = uniqueValues(
Array.isArray(imageGenConfig?.supportedImageSizes)
? imageGenConfig.supportedImageSizes.map((size) =>
normalizeImageSizeValue(size, "")
)
: []
);
const buildImageSizeOptions = () => {
if (imageGenConfig?.supportsSize === false) {
return [];
}
if (configuredImageSizes.length > 0) {
return configuredImageSizes.map((value) => ({ value, label: value, disabled: false }));
}
return DEFAULT_IMAGE_SIZE_OPTIONS.filter((option) => {
if (option.disabled) return false;
if (!imageGenConfig) return true;
if (option.value === "1K") return imageGenConfig.supports1K !== false;
if (option.value === "2K") return imageGenConfig.supports2K !== false;
if (option.value === "4K") return imageGenConfig.supports4K !== false;
return true;
});
};
const availableSizeOptions = buildImageSizeOptions();
const isImageSizeAllowed = (s: ImageSizeValue) => {
if (!imageGenConfig) return true;
if (s === "1K") return imageGenConfig.supports1K !== false;
if (s === "2K") return imageGenConfig.supports2K !== false;
if (s === "4K") return imageGenConfig.supports4K !== false;
return true;
if (availableSizeOptions.length === 0) return true;
const normalized = normalizeImageSizeValue(s, "");
return availableSizeOptions.some((option) => option.value === normalized);
};
const getFallbackImageSize = () =>
(["2K", "1K", "4K"] as const).find(isImageSizeAllowed) || "2K";
["2K", "1K", "4K", DEFAULT_IMAGE_SIZE_VALUE].find(isImageSizeAllowed) ||
availableSizeOptions[0]?.value ||
DEFAULT_IMAGE_SIZE_VALUE;
const isAspectRatioAllowed = (aspectRatio: string) => {
const configured = imageGenConfig?.supportedAspectRatios;
if (Array.isArray(configured) && configured.length > 0) {
return configured.includes(aspectRatio);
}
if (providerType === "snapgen-image") {
return ["1:1", "16:9", "9:16", "4:3", "3:4"].includes(aspectRatio);
}
return true;
};
const availableAspectRatioOptions = ASPECT_RATIO_OPTIONS.filter((option) =>
isAspectRatioAllowed(option.value)
const configuredAspectRatios = uniqueValues(
Array.isArray(imageGenConfig?.supportedAspectRatios)
? imageGenConfig.supportedAspectRatios.map((ratio) => String(ratio || "").trim())
: []
);
const defaultAspectRatioLabel = new Map<string, string>(
DEFAULT_ASPECT_RATIO_OPTIONS.map((option) => [option.value, option.label])
);
const availableAspectRatioOptions = (
configuredAspectRatios.length > 0
? configuredAspectRatios
: DEFAULT_ASPECT_RATIO_OPTIONS.map((option) => option.value)
).map((value) => ({
value,
label: defaultAspectRatioLabel.get(value) || value,
}));
const isAspectRatioAllowed = (aspectRatio: string) =>
availableAspectRatioOptions.some((option) => option.value === aspectRatio);
const getFallbackAspectRatio = (): AspectRatioValue =>
(availableAspectRatioOptions.find((option) => option.value === "1:1") ||
availableAspectRatioOptions[0] ||
ASPECT_RATIO_OPTIONS[1])!.value;
availableAspectRatioOptions.find((option) => option.value === "1:1")?.value ||
availableAspectRatioOptions[0]?.value ||
"1:1";
const clampParamsByProvider = (raw: ImageGenerationParams): ImageGenerationParams => {
const options = getImageCountOptions();
@ -198,8 +264,8 @@ export function ImageParamsBar({
aspectRatio = getFallbackAspectRatio();
}
// 根据模型配置校正分辨率:如果当前选择的档位被禁用,则降级到可用的档位
let imageSize = normalizeImageSizeValue(raw.imageSize);
if (!isImageSizeAllowed(imageSize)) {
let imageSize = normalizeImageSizeValue(raw.imageSize, getFallbackImageSize());
if (availableSizeOptions.length > 0 && !isImageSizeAllowed(imageSize)) {
imageSize = getFallbackImageSize();
}
return {
@ -251,18 +317,12 @@ export function ImageParamsBar({
const showPromptExtend = isAlibabaImage || imageGenConfig?.supportsPromptExtend;
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt;
// 根据模型配置过滤可用的分辨率档位
const availableSizeOptions = IMAGE_SIZE_OPTIONS.filter((option) => {
if (option.disabled) return false;
return isImageSizeAllowed(option.value);
});
const getComputedOutputSize = (aspectRatio: string, imageSize: string) => {
if (providerType === "grsai-gpt-image") {
return getGrsaiGptImageOutputSize(aspectRatio, imageSize);
}
const size = IMAGE_SIZE_PIXEL_MAP[normalizeImageSizeValue(imageSize)];
const size = getImageSizePixelValue(imageSize);
if (aspectRatio === "auto" || aspectRatio === "1:1") {
return { width: size, height: size };
}
@ -325,7 +385,9 @@ export function ImageParamsBar({
imageGenConfig?.supports1K,
imageGenConfig?.supports2K,
imageGenConfig?.supports4K,
imageGenConfig?.supportsSize,
imageGenConfig?.supportedAspectRatios,
imageGenConfig?.supportedImageSizes,
initialParams,
onParamsChange,
providerType,
@ -357,23 +419,28 @@ export function ImageParamsBar({
const outputSize = getComputedOutputSize(params.aspectRatio, params.imageSize);
if (isMinimalInline) {
const currentImageSize = params.imageSize || getFallbackImageSize();
const currentAspectRatio = isAspectRatioAllowed(params.aspectRatio)
? params.aspectRatio
: getFallbackAspectRatio();
const sizeOptionKeySet = new Set<string>();
const sizeOptions = [
{ label: "1:1", aspectRatio: "1:1", imageSize: "1K" },
{ label: "3:2", aspectRatio: "3:2", imageSize: "1K" },
{ label: "2:3", aspectRatio: "2:3", imageSize: "1K" },
{ label: "4:3", aspectRatio: "4:3", imageSize: "1K" },
{ label: "3:4", aspectRatio: "3:4", imageSize: "1K" },
{ label: "9:16", aspectRatio: "9:16", imageSize: "1K" },
{ label: "1:1(2k)", aspectRatio: "1:1", imageSize: "2K" },
{ label: "16:9(2k)", aspectRatio: "16:9", imageSize: "2K" },
{ label: "9:16(2k)", aspectRatio: "9:16", imageSize: "2K" },
{ label: "16:9(4K)", aspectRatio: "16:9", imageSize: "4K" },
{ label: "9:16(4K)", aspectRatio: "9:16", imageSize: "4K" },
{ label: "auto", aspectRatio: "auto", imageSize: params.imageSize || "1K" },
].filter((option) =>
isAspectRatioAllowed(option.aspectRatio) &&
(option.aspectRatio === "auto" || isImageSizeAllowed(normalizeImageSizeValue(option.imageSize)))
);
...availableAspectRatioOptions.map((option) => ({
label: option.value === "auto" ? "auto" : option.label,
aspectRatio: option.value,
imageSize: currentImageSize,
})),
...availableSizeOptions.map((option) => ({
label: option.label,
aspectRatio: currentAspectRatio,
imageSize: option.value,
})),
].filter((option) => {
const key = `${option.aspectRatio}:${option.imageSize}`;
if (sizeOptionKeySet.has(key)) return false;
sizeOptionKeySet.add(key);
return true;
});
const imageCountOptions = getImageCountOptions();
const selectedAspectLabel = params.aspectRatio === "auto" ? "auto" : params.aspectRatio;
const selectedImageCount = params.imageCount || 1;
@ -464,6 +531,7 @@ export function ImageParamsBar({
</div>
</div>
{sizeOptions.length > 0 && (
<div>
<div className="mb-2 text-[14px] font-medium leading-none">Size</div>
<div className="grid grid-cols-4 gap-2">
@ -497,6 +565,7 @@ export function ImageParamsBar({
})}
</div>
</div>
)}
<div>
<div className="mb-2 text-[14px] font-medium leading-none">Image</div>
@ -613,6 +682,7 @@ export function ImageParamsBar({
</div>
</div>
{availableSizeOptions.length > 0 && (
<div>
<p className={cn("text-[11px] font-medium", isDarkInline ? "text-white/45" : "text-[#8a94a3]")}>选择分辨率</p>
<div
@ -644,6 +714,7 @@ export function ImageParamsBar({
))}
</div>
</div>
)}
<div>
<p className={cn("text-[11px] font-medium", isDarkInline ? "text-white/45" : "text-[#8a94a3]")}>尺寸</p>

View File

@ -40,6 +40,8 @@ type DynamicModel = {
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
supportedAspectRatios?: string[];
supportedImageSizes?: string[];
creditCostPerImage?: number;
creditCostMultiplierBySize?: Record<string, number>;
creditDiscountRate?: number;

View File

@ -398,28 +398,60 @@ export function SceneFusionView({
MAX_BATCH_ITEMS
);
const outputCapabilities = config?.outputCapabilities;
const availableAspectRatioOptions = useMemo(
const aspectRatioOptionLabel = useMemo(
() =>
SCENE_FUSION_ASPECT_RATIO_OPTIONS.filter((option) =>
outputCapabilities?.aspectRatio?.enabled === false
? false
: !outputCapabilities?.aspectRatio?.options?.length ||
outputCapabilities.aspectRatio.options.includes(option.value)
new Map<string, string>(
SCENE_FUSION_ASPECT_RATIO_OPTIONS.map((option) => [
option.value,
option.label,
])
),
[]
);
const imageSizeOptionLabel = useMemo(
() =>
new Map<string, string>(
SCENE_FUSION_IMAGE_SIZE_OPTIONS.map((option) => [
option.value,
option.label,
])
),
[]
);
const availableAspectRatioOptions = useMemo(
() => {
if (outputCapabilities?.aspectRatio?.enabled === false) {
return [];
}
const values = outputCapabilities?.aspectRatio?.options?.length
? outputCapabilities.aspectRatio.options
: SCENE_FUSION_ASPECT_RATIO_OPTIONS.map((option) => option.value);
return values.map((value) => ({
value,
label: aspectRatioOptionLabel.get(value) || value,
}));
},
[
aspectRatioOptionLabel,
outputCapabilities?.aspectRatio?.enabled,
outputCapabilities?.aspectRatio?.options,
]
);
const availableImageSizeOptions = useMemo(
() =>
SCENE_FUSION_IMAGE_SIZE_OPTIONS.filter((option) =>
outputCapabilities?.imageSize?.enabled === false
? false
: !outputCapabilities?.imageSize?.options?.length ||
outputCapabilities.imageSize.options.includes(option.value)
),
() => {
if (outputCapabilities?.imageSize?.enabled === false) {
return [];
}
const values = outputCapabilities?.imageSize?.options?.length
? outputCapabilities.imageSize.options
: SCENE_FUSION_IMAGE_SIZE_OPTIONS.map((option) => option.value);
return values.map((value) => ({
value,
label: imageSizeOptionLabel.get(value) || value,
}));
},
[
imageSizeOptionLabel,
outputCapabilities?.imageSize?.enabled,
outputCapabilities?.imageSize?.options,
]

View File

@ -141,9 +141,9 @@ export const SNAPGEN_IMAGE_MODEL_PRESETS: SnapgenImageModelPreset[] = [
},
imageGenConfig: {
...SNAPGEN_COMMON_IMAGE_CONFIG,
supportsSize: false,
maxImages: 8,
supportedAspectRatios: SNAPGEN_STANDARD_ASPECT_RATIOS,
supportedImageSizes: ["1K"],
supportedQualities: ["SPEED", "QUALITY"],
},
sortOrder: 30,
@ -175,9 +175,9 @@ export const SNAPGEN_IMAGE_MODEL_PRESETS: SnapgenImageModelPreset[] = [
},
imageGenConfig: {
...SNAPGEN_COMMON_IMAGE_CONFIG,
supportsSize: false,
maxImages: 4,
supportedAspectRatios: SNAPGEN_STANDARD_ASPECT_RATIOS,
supportedImageSizes: ["1K"],
},
sortOrder: 40,
},

View File

@ -132,8 +132,8 @@ const SCALE_ADJUST_VISUAL_DIFF_THRESHOLD = 14;
const SCALE_ADJUST_OUTSIDE_DIFF_THRESHOLD = 18;
const SCALE_ADJUST_OUTSIDE_DIFF_MIN_CHANGED_RATIO = 0.28;
const SCALE_ADJUST_OUTSIDE_PIXEL_DIFF_THRESHOLD = 16;
const SCALE_ADJUST_OUTSIDE_PIXEL_CHANGED_RATIO_THRESHOLD = 0.045;
const SCALE_ADJUST_OUTSIDE_PIXEL_MEAN_DIFF_THRESHOLD = 4.5;
const SCALE_ADJUST_OUTSIDE_PIXEL_CHANGED_RATIO_THRESHOLD = 0.16;
const SCALE_ADJUST_OUTSIDE_PIXEL_MEAN_DIFF_THRESHOLD = 14;
const SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN = 0.015;
const SCALE_ADJUST_DIFF_SAMPLE_SIZE = 112;
@ -1207,7 +1207,7 @@ type CanvasModelWithProvider = Awaited<
ReturnType<typeof getEnabledAiModelsWithProvider>
>[number];
type CanvasImageSizeValue = "1K" | "2K" | "4K";
type CanvasImageSizeValue = string;
const CANVAS_FAILOVER_SAME_NAME_THRESHOLD = 0.5;
interface CanvasImageGenConfig {
@ -1215,17 +1215,20 @@ interface CanvasImageGenConfig {
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportedImageSizes?: string[];
}
function normalizeCanvasImageSizeValue(
value?: string,
fallback: CanvasImageSizeValue = "2K"
): CanvasImageSizeValue {
const normalized = String(value || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
const trimmed = String(value || "").trim();
if (!trimmed) return fallback;
const kMatch = trimmed.match(/^(\d+(?:\.\d+)?)\s*k$/i);
if (kMatch?.[1]) {
return `${Number(kMatch[1]).toString()}K`;
}
return fallback;
return trimmed;
}
function isCanvasImageSizeSupported(
@ -1233,6 +1236,14 @@ function isCanvasImageSizeSupported(
size: CanvasImageSizeValue
) {
if (!config) return true;
const configuredSizes = Array.isArray(config.supportedImageSizes)
? config.supportedImageSizes
.map((item) => normalizeCanvasImageSizeValue(item, ""))
.filter(Boolean)
: [];
if (configuredSizes.length > 0) {
return configuredSizes.includes(normalizeCanvasImageSizeValue(size, ""));
}
if (size === "1K") return config.supports1K !== false;
if (size === "2K") return config.supports2K !== false;
if (size === "4K") return config.supports4K !== false;
@ -1250,12 +1261,20 @@ function pickSupportedCanvasImageSize(
"2K": ["1K"],
"1K": ["2K", "4K"],
};
const configuredSizes = Array.isArray(config?.supportedImageSizes)
? config.supportedImageSizes
.map((item) => normalizeCanvasImageSizeValue(item, ""))
.filter(Boolean)
: [];
return (
preferredFallbacks[requestedSize].find((candidate) =>
(preferredFallbacks[requestedSize] || ["2K", "1K", "4K"]).find((candidate) =>
isCanvasImageSizeSupported(config, candidate)
) ||
(["1K", "2K", "4K"] as const).find((candidate) =>
configuredSizes.find((candidate) =>
isCanvasImageSizeSupported(config, candidate)
) ||
["1K", "2K", "4K"].find((candidate) =>
isCanvasImageSizeSupported(config, candidate)
) ||
"2K"

View File

@ -14,15 +14,17 @@ export const SCENE_FUSION_IMAGE_SIZE_OPTIONS = [
] as const;
export type SceneFusionAspectRatioValue =
(typeof SCENE_FUSION_ASPECT_RATIO_OPTIONS)[number]["value"];
string;
export type SceneFusionImageSizeValue =
(typeof SCENE_FUSION_IMAGE_SIZE_OPTIONS)[number]["value"];
string;
export type SceneFusionImageGenConfig = {
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportedAspectRatios?: string[];
supportedImageSizes?: string[];
};
export type SceneFusionCapabilityCandidate = {
@ -88,12 +90,43 @@ function orderedIntersect<T extends string>(base: readonly T[], groups: T[][]):
return base.filter((item) => groups.every((group) => group.includes(item)));
}
function getSupportedAspectRatiosByProvider(providerType: string): SceneFusionAspectRatioValue[] {
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":
case "snapgen-image":
return ["1:1", "4:3", "3:4", "16:9", "9:16"];
case "alibaba-image":
case "fal-image":
@ -111,6 +144,15 @@ function getSupportedImageSizesByConfig(
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;
@ -141,7 +183,10 @@ function pickDefaultImageSize(options: SceneFusionImageSizeValue[]): SceneFusion
}
export function getSceneFusionCandidateCapability(candidate: SceneFusionCapabilityCandidate) {
const aspectRatioOptions = getSupportedAspectRatiosByProvider(candidate.provider.type);
const aspectRatioOptions = getSupportedAspectRatiosByProvider(
candidate.provider.type,
candidate.model.imageGenConfig
);
const imageSizeOptions = getSupportedImageSizesByConfig(candidate.model.imageGenConfig);
return {
@ -194,13 +239,19 @@ export function buildSceneFusionCapabilitySummary(
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(
ALL_ASPECT_RATIO_VALUES,
sourceCandidates.map((candidate) => getSceneFusionCandidateCapability(candidate).aspectRatio.options)
uniqueOrdered([...ALL_ASPECT_RATIO_VALUES, ...aspectRatioGroups.flat()]),
aspectRatioGroups
);
const imageSizeOptions = orderedIntersect(
ALL_IMAGE_SIZE_VALUES,
sourceCandidates.map((candidate) => getSceneFusionCandidateCapability(candidate).imageSize.options)
uniqueOrdered([...ALL_IMAGE_SIZE_VALUES, ...imageSizeGroups.flat()]),
imageSizeGroups
);
return {