From 4aead3018fe32439dccd424f94c6d618bca8e3fc Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 22 May 2026 20:49:14 +0800 Subject: [PATCH] feat: add image size credit multipliers --- .../settings/admin-settings-client.tsx | 76 +++++++++++++++++- app/(chat)/api/chat/route.ts | 13 +++- app/api/admin/ai-models/route.ts | 1 + app/api/style-pro/generate/route.ts | 5 +- app/api/style-pro/model-options/route.ts | 8 +- lib/ai/image-model-pricing.ts | 78 +++++++++++++++++-- lib/ai/runtime/generated-image-persistence.ts | 17 ++-- lib/db/queries.ts | 1 + 8 files changed, 183 insertions(+), 16 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 54ea3fda8..567823a35 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -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; // 各图片尺寸积分倍率 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} +
+
+
+ 图片尺寸积分倍率 +
+

+ 以“每张图片积分消耗”为基准,按用户选择的 1K/2K/4K + 档位乘以倍率后扣费;未配置时默认 1 倍。 +

+
+
+ {IMAGE_PRICING_PREVIEW_SIZES.map((sizeKey) => ( +
+ + { + const multiplier = normalizeAdminMultiplier( + e.target.value, + 1 + ); + setModelForm({ + ...modelForm, + imageGenConfig: { + ...modelForm.imageGenConfig, + creditCostMultiplierBySize: { + ...(modelForm.imageGenConfig + ?.creditCostMultiplierBySize || {}), + [sizeKey]: multiplier, + }, + }, + }); + }} + /> +

+ 实扣{" "} + {getGenerationCreditCostFromImageGenConfig( + modelForm.imageGenConfig, + "image", + { imageSize: sizeKey } + )}{" "} + 积分/张 +

+
+ ))} +
+
+
- sum + getModelGenerationCreditCost(entry.model as any, "image"), + sum + + getModelGenerationCreditCost(entry.model as any, "image", { + imageSize: config.imageSize || "2K", + }), 0 ); const billingModelId = diff --git a/app/api/style-pro/model-options/route.ts b/app/api/style-pro/model-options/route.ts index 46c453ff4..a1af3f604 100644 --- a/app/api/style-pro/model-options/route.ts +++ b/app/api/style-pro/model-options/route.ts @@ -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({ diff --git a/lib/ai/image-model-pricing.ts b/lib/ai/image-model-pricing.ts index 112bb94f7..97c49dc8c 100644 --- a/lib/ai/image-model-pricing.ts +++ b/lib/ai/image-model-pricing.ts @@ -3,6 +3,7 @@ import { getVideoCreditCostFromConfig, type VideoImageGenConfigLike } from "@/li export type DiscountableImageGenConfigLike = VideoImageGenConfigLike & { creditCostPerImage?: number | null; creditDiscountRate?: number | null; + creditCostMultiplierBySize?: Record | 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; } diff --git a/lib/ai/runtime/generated-image-persistence.ts b/lib/ai/runtime/generated-image-persistence.ts index fa4e5b42c..b5bf806e4 100644 --- a/lib/ai/runtime/generated-image-persistence.ts +++ b/lib/ai/runtime/generated-image-persistence.ts @@ -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) { diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 108512f0c..f0b48c81b 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -4042,6 +4042,7 @@ interface ImageGenConfig { supportsWatermark?: boolean; supportsSeed?: boolean; creditCostPerImage?: number; + creditCostMultiplierBySize?: Record; creditDiscountRate?: number; creditCostPerVideo?: number; videoDurationOptions?: number[];