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 { import {
formatImageModelCreditDiscountLabel, formatImageModelCreditDiscountLabel,
getGenerationCreditCostFromImageGenConfig, getGenerationCreditCostFromImageGenConfig,
getImageSizeCreditMultiplier,
} from "@/lib/ai/image-model-pricing"; } from "@/lib/ai/image-model-pricing";
import type { AgentWorkflowUiConfig } from "@/types/agent"; 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_MAX_BYTES = 1024 * 1024;
const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config"; const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config";
const VIDEO_PRICING_PREVIEW_RESOLUTIONS = ["480p", "720p", "1080p"] as const; 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]; type AdminVideoResolution = (typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number];
const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record< const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record<
AdminVideoResolution, AdminVideoResolution,
@ -600,6 +602,7 @@ type ImageGenConfig = {
supportsWatermark?: boolean; // 是否支持水印开关 supportsWatermark?: boolean; // 是否支持水印开关
supportsSeed?: boolean; // 是否支持随机种子 supportsSeed?: boolean; // 是否支持随机种子
creditCostPerImage?: number; // 每张图片积分消耗 creditCostPerImage?: number; // 每张图片积分消耗
creditCostMultiplierBySize?: Record<string, number>; // 各图片尺寸积分倍率
creditDiscountRate?: number; // 积分折扣,按“折”配置,如 5 = 5折 creditDiscountRate?: number; // 积分折扣,按“折”配置,如 5 = 5折
creditCostPerVideo?: number; // 每条视频积分消耗 creditCostPerVideo?: number; // 每条视频积分消耗
videoDurationOptions?: number[]; // 视频秒数选项 videoDurationOptions?: number[]; // 视频秒数选项
@ -621,6 +624,11 @@ function createDefaultImageGenConfig(): ImageGenConfig {
supportsWatermark: false, supportsWatermark: false,
supportsSeed: false, supportsSeed: false,
creditCostPerImage: 0, creditCostPerImage: 0,
creditCostMultiplierBySize: {
"1K": 1,
"2K": 1,
"4K": 1,
},
creditCostPerVideo: 0, creditCostPerVideo: 0,
videoDurationOptions: [], videoDurationOptions: [],
creditCostPerVideoByDuration: {}, creditCostPerVideoByDuration: {},
@ -15098,6 +15106,63 @@ export function AdminSettingsClient() {
) : null} ) : null}
</div> </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"> <div className="space-y-2">
<Label>每条视频积分消耗</Label> <Label>每条视频积分消耗</Label>
<Input <Input
@ -15975,7 +16040,16 @@ export function AdminSettingsClient() {
? (model as any).capabilities ? (model as any).capabilities
: {}, : {},
imageGenConfig: imageGenConfig:
(model as any).imageGenConfig || createDefaultImageGenConfig(), {
...createDefaultImageGenConfig(),
...((model as any).imageGenConfig || {}),
creditCostMultiplierBySize: {
...createDefaultImageGenConfig()
.creditCostMultiplierBySize,
...((model as any).imageGenConfig
?.creditCostMultiplierBySize || {}),
},
},
}); });
setVideoDurationOptionsInput( setVideoDurationOptionsInput(
formatVideoDurationOptionsInput( formatVideoDurationOptionsInput(

View File

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

View File

@ -34,6 +34,7 @@ const imageGenConfigSchema = z.object({
supportsWatermark: z.boolean().optional(), supportsWatermark: z.boolean().optional(),
supportsSeed: z.boolean().optional(), supportsSeed: z.boolean().optional(),
creditCostPerImage: z.number().min(0).max(100000).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(), creditDiscountRate: z.number().min(0.1).max(10).optional(),
creditCostPerVideo: z.number().min(0).max(100000).optional(), creditCostPerVideo: z.number().min(0).max(100000).optional(),
videoDurationOptions: z.array(z.number().min(1).max(600)).max(20).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( const unitCost = selectedModels.reduce(
(sum, entry) => (sum, entry) =>
sum + getModelGenerationCreditCost(entry.model as any, "image"), sum +
getModelGenerationCreditCost(entry.model as any, "image", {
imageSize: config.imageSize || "2K",
}),
0 0
); );
const billingModelId = const billingModelId =

View File

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

View File

@ -3,6 +3,7 @@ import { getVideoCreditCostFromConfig, type VideoImageGenConfigLike } from "@/li
export type DiscountableImageGenConfigLike = VideoImageGenConfigLike & { export type DiscountableImageGenConfigLike = VideoImageGenConfigLike & {
creditCostPerImage?: number | null; creditCostPerImage?: number | null;
creditDiscountRate?: number | null; creditDiscountRate?: number | null;
creditCostMultiplierBySize?: Record<string, number | null | undefined> | null;
}; };
function normalizeRoundedCost(value: unknown) { function normalizeRoundedCost(value: unknown) {
@ -49,10 +50,40 @@ export function applyImageModelCreditDiscount(
return Math.max(0, Math.round((baseCost * normalizedDiscountRate) / 10)); 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( export function getGenerationCreditCostFromImageGenConfig(
config: DiscountableImageGenConfigLike | null | undefined, config: DiscountableImageGenConfigLike | null | undefined,
target: "image" | "video", target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null } options?: {
duration?: number | null;
imageSize?: string | null;
resolution?: string | null;
}
) { ) {
const sourceValue = const sourceValue =
target === "video" target === "video"
@ -67,19 +98,32 @@ export function getGenerationCreditCostFromImageGenConfig(
})() })()
: config?.creditCostPerImage ?? 0; : 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( 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( export function getModelGenerationCreditCost(
modelConfig: { imageGenConfig?: DiscountableImageGenConfigLike | null } | null | undefined, modelConfig: { imageGenConfig?: DiscountableImageGenConfigLike | null } | null | undefined,
target: "image" | "video", target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null } options?: {
duration?: number | null;
imageSize?: string | null;
resolution?: string | null;
}
) { ) {
return getGenerationCreditCostFromImageGenConfig( return getGenerationCreditCostFromImageGenConfig(
modelConfig?.imageGenConfig, modelConfig?.imageGenConfig,
@ -100,5 +144,29 @@ export function normalizeImageGenPricingConfig<
return { return {
...config, ...config,
creditDiscountRate: normalizedDiscountRate ?? undefined, 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; } as T;
} }

View File

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

View File

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