feat: add image size credit multipliers
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-22 20:49:14 +08:00
parent a66c3107b9
commit 4aead3018f
8 changed files with 183 additions and 16 deletions

View File

@ -279,6 +279,7 @@ import {
import {
formatImageModelCreditDiscountLabel,
getGenerationCreditCostFromImageGenConfig,
getImageSizeCreditMultiplier,
} from "@/lib/ai/image-model-pricing";
import type { AgentWorkflowUiConfig } from "@/types/agent";
@ -288,6 +289,7 @@ const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
const HOME_CHAT_ANIMATION_MAX_BYTES = 1024 * 1024;
const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config";
const VIDEO_PRICING_PREVIEW_RESOLUTIONS = ["480p", "720p", "1080p"] as const;
const IMAGE_PRICING_PREVIEW_SIZES = ["1K", "2K", "4K"] as const;
type AdminVideoResolution = (typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number];
const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record<
AdminVideoResolution,
@ -600,6 +602,7 @@ type ImageGenConfig = {
supportsWatermark?: boolean; // 是否支持水印开关
supportsSeed?: boolean; // 是否支持随机种子
creditCostPerImage?: number; // 每张图片积分消耗
creditCostMultiplierBySize?: Record<string, number>; // 各图片尺寸积分倍率
creditDiscountRate?: number; // 积分折扣,按“折”配置,如 5 = 5折
creditCostPerVideo?: number; // 每条视频积分消耗
videoDurationOptions?: number[]; // 视频秒数选项
@ -621,6 +624,11 @@ function createDefaultImageGenConfig(): ImageGenConfig {
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostMultiplierBySize: {
"1K": 1,
"2K": 1,
"4K": 1,
},
creditCostPerVideo: 0,
videoDurationOptions: [],
creditCostPerVideoByDuration: {},
@ -15098,6 +15106,63 @@ export function AdminSettingsClient() {
) : null}
</div>
<div className="col-span-2 space-y-3 rounded-md border bg-background/70 p-3">
<div className="space-y-1">
<div className="text-sm font-medium">
图片尺寸积分倍率
</div>
<p className="text-xs text-muted-foreground">
以“每张图片积分消耗”为基准,按用户选择的 1K/2K/4K
档位乘以倍率后扣费;未配置时默认 1 倍。
</p>
</div>
<div className="grid grid-cols-3 gap-4">
{IMAGE_PRICING_PREVIEW_SIZES.map((sizeKey) => (
<div key={sizeKey} className="space-y-2">
<Label>{sizeKey} 倍率</Label>
<Input
type="number"
min={0.01}
max={100}
step={0.1}
value={formatCompactNumber(
getImageSizeCreditMultiplier(
modelForm.imageGenConfig,
sizeKey
)
)}
onChange={(e) => {
const multiplier = normalizeAdminMultiplier(
e.target.value,
1
);
setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
creditCostMultiplierBySize: {
...(modelForm.imageGenConfig
?.creditCostMultiplierBySize || {}),
[sizeKey]: multiplier,
},
},
});
}}
/>
<p className="text-[11px] text-muted-foreground">
实扣{" "}
{getGenerationCreditCostFromImageGenConfig(
modelForm.imageGenConfig,
"image",
{ imageSize: sizeKey }
)}{" "}
积分/张
</p>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>每条视频积分消耗</Label>
<Input
@ -15975,7 +16040,16 @@ export function AdminSettingsClient() {
? (model as any).capabilities
: {},
imageGenConfig:
(model as any).imageGenConfig || createDefaultImageGenConfig(),
{
...createDefaultImageGenConfig(),
...((model as any).imageGenConfig || {}),
creditCostMultiplierBySize: {
...createDefaultImageGenConfig()
.creditCostMultiplierBySize,
...((model as any).imageGenConfig
?.creditCostMultiplierBySize || {}),
},
},
});
setVideoDurationOptionsInput(
formatVideoDurationOptionsInput(

View File

@ -4485,7 +4485,11 @@ const MODEL_UUID_REGEX =
function getModelCreditCost(
modelConfig: any,
target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null }
options?: {
duration?: number | null;
imageSize?: string | null;
resolution?: string | null;
}
): number {
return getModelGenerationCreditCost(modelConfig, target, options);
}
@ -10223,7 +10227,12 @@ export async function POST(request: Request) {
}
: undefined
);
const modelCreditCostPerImage = getModelCreditCost(currentModelConfig, "image");
const modelCreditCostPerImage = getModelCreditCost(currentModelConfig, "image", {
imageSize:
typeof effectiveImageParams?.imageSize === "string"
? effectiveImageParams.imageSize
: defaultImageQualityParams.imageSize,
});
const modelCreditCostPerVideo = getModelCreditCost(currentModelConfig, "video", {
duration: Number(effectiveVideoParams?.duration) || undefined,
resolution: String(effectiveVideoParams?.resolution || "").trim() || undefined,

View File

@ -34,6 +34,7 @@ const imageGenConfigSchema = z.object({
supportsWatermark: z.boolean().optional(),
supportsSeed: z.boolean().optional(),
creditCostPerImage: z.number().min(0).max(100000).optional(),
creditCostMultiplierBySize: z.record(z.string(), z.number().min(0.01).max(100)).optional(),
creditDiscountRate: z.number().min(0.1).max(10).optional(),
creditCostPerVideo: z.number().min(0).max(100000).optional(),
videoDurationOptions: z.array(z.number().min(1).max(600)).max(20).optional(),

View File

@ -506,7 +506,10 @@ export async function POST(request: Request) {
const unitCost = selectedModels.reduce(
(sum, entry) =>
sum + getModelGenerationCreditCost(entry.model as any, "image"),
sum +
getModelGenerationCreditCost(entry.model as any, "image", {
imageSize: config.imageSize || "2K",
}),
0
);
const billingModelId =

View File

@ -52,8 +52,12 @@ export async function GET() {
);
const realisticUnitCost =
realisticFirstModel && realisticSecondModel
? getModelGenerationCreditCost(realisticFirstModel.model as any, "image") +
getModelGenerationCreditCost(realisticSecondModel.model as any, "image")
? getModelGenerationCreditCost(realisticFirstModel.model as any, "image", {
imageSize: config.imageSize || "2K",
}) +
getModelGenerationCreditCost(realisticSecondModel.model as any, "image", {
imageSize: config.imageSize || "2K",
})
: 0;
return NextResponse.json({

View File

@ -3,6 +3,7 @@ import { getVideoCreditCostFromConfig, type VideoImageGenConfigLike } from "@/li
export type DiscountableImageGenConfigLike = VideoImageGenConfigLike & {
creditCostPerImage?: number | null;
creditDiscountRate?: number | null;
creditCostMultiplierBySize?: Record<string, number | null | undefined> | null;
};
function normalizeRoundedCost(value: unknown) {
@ -49,10 +50,40 @@ export function applyImageModelCreditDiscount(
return Math.max(0, Math.round((baseCost * normalizedDiscountRate) / 10));
}
function normalizeImageSizeKey(value: unknown) {
const normalized = String(value || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
}
return null;
}
export function normalizeImageSizeCreditMultiplier(value: unknown, fallback = 1) {
const normalized = Number(value);
if (!Number.isFinite(normalized) || normalized <= 0) return fallback;
return Math.min(100, Math.max(0.01, Number(normalized.toFixed(2))));
}
export function getImageSizeCreditMultiplier(
config: DiscountableImageGenConfigLike | null | undefined,
imageSize?: string | null
) {
const sizeKey = normalizeImageSizeKey(imageSize);
if (!sizeKey) return 1;
return normalizeImageSizeCreditMultiplier(
config?.creditCostMultiplierBySize?.[sizeKey],
1
);
}
export function getGenerationCreditCostFromImageGenConfig(
config: DiscountableImageGenConfigLike | null | undefined,
target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null }
options?: {
duration?: number | null;
imageSize?: string | null;
resolution?: string | null;
}
) {
const sourceValue =
target === "video"
@ -67,19 +98,32 @@ export function getGenerationCreditCostFromImageGenConfig(
})()
: config?.creditCostPerImage ?? 0;
return applyImageModelCreditDiscount(sourceValue, config);
const discountedCost = applyImageModelCreditDiscount(sourceValue, config);
if (target !== "image" || discountedCost <= 0) {
return discountedCost;
}
return Math.max(
0,
Math.round(discountedCost * getImageSizeCreditMultiplier(config, options?.imageSize))
);
}
export function getImageCreditCostFromConfig(
config: DiscountableImageGenConfigLike | null | undefined
config: DiscountableImageGenConfigLike | null | undefined,
imageSize?: string | null
) {
return getGenerationCreditCostFromImageGenConfig(config, "image");
return getGenerationCreditCostFromImageGenConfig(config, "image", { imageSize });
}
export function getModelGenerationCreditCost(
modelConfig: { imageGenConfig?: DiscountableImageGenConfigLike | null } | null | undefined,
target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null }
options?: {
duration?: number | null;
imageSize?: string | null;
resolution?: string | null;
}
) {
return getGenerationCreditCostFromImageGenConfig(
modelConfig?.imageGenConfig,
@ -100,5 +144,29 @@ export function normalizeImageGenPricingConfig<
return {
...config,
creditDiscountRate: normalizedDiscountRate ?? undefined,
creditCostMultiplierBySize: {
...config.creditCostMultiplierBySize,
...(config.creditCostMultiplierBySize?.["1K"] !== undefined
? {
"1K": normalizeImageSizeCreditMultiplier(
config.creditCostMultiplierBySize["1K"]
),
}
: {}),
...(config.creditCostMultiplierBySize?.["2K"] !== undefined
? {
"2K": normalizeImageSizeCreditMultiplier(
config.creditCostMultiplierBySize["2K"]
),
}
: {}),
...(config.creditCostMultiplierBySize?.["4K"] !== undefined
? {
"4K": normalizeImageSizeCreditMultiplier(
config.creditCostMultiplierBySize["4K"]
),
}
: {}),
},
} as T;
}

View File

@ -66,7 +66,11 @@ export function parseImageAltMetadata(altText: string, thumbnailUrl: string): {
function getModelCreditCost(
modelConfig: any,
target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null },
options?: {
duration?: number | null;
imageSize?: string | null;
resolution?: string | null;
},
): number {
return getModelGenerationCreditCost(modelConfig, target, options);
}
@ -118,6 +122,7 @@ export async function saveGeneratedImages(
const savedImageIds: string[] = [];
const savedAssetIds: string[] = [];
const savedOriginalUrls: string[] = [];
const savedImageSizes: string[] = [];
let relationSourceAssetIds = Array.isArray(options?.sourceAssetIds)
? options.sourceAssetIds.filter((assetId): assetId is string => Boolean(assetId))
: [];
@ -165,6 +170,7 @@ export async function saveGeneratedImages(
if (!savedOriginalUrls.includes(originalUrl)) {
savedOriginalUrls.push(originalUrl);
}
savedImageSizes.push(options?.imageSize || imageSize || "2K");
const media = await upsertMediaAsset({
userId,
@ -292,8 +298,10 @@ export async function saveGeneratedImages(
}
const modelConfig = await resolveBillingModelByReference(modelId);
const unitCost = getModelCreditCost(modelConfig, "image");
if (unitCost <= 0) {
const totalCost = savedImageSizes.reduce((sum, imageSize) => {
return sum + getModelCreditCost(modelConfig, "image", { imageSize });
}, 0);
if (totalCost <= 0) {
return {
savedImageCount,
savedImageIds,
@ -302,7 +310,6 @@ export async function saveGeneratedImages(
};
}
const totalCost = unitCost * savedImageCount;
const consumeResult = await consumeUserPoints({
userId,
amount: totalCost,
@ -323,7 +330,7 @@ export async function saveGeneratedImages(
);
} else {
console.log(
`[Points] Consumed ${totalCost} points (${unitCost} x ${savedImageCount}). balance=${consumeResult.balance}`,
`[Points] Consumed ${totalCost} points for ${savedImageCount} image(s). balance=${consumeResult.balance}`,
);
}
} catch (error) {

View File

@ -4042,6 +4042,7 @@ interface ImageGenConfig {
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditCostMultiplierBySize?: Record<string, number>;
creditDiscountRate?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];