feat: add image size credit multipliers
This commit is contained in:
parent
a66c3107b9
commit
4aead3018f
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -4042,6 +4042,7 @@ interface ImageGenConfig {
|
||||
supportsWatermark?: boolean;
|
||||
supportsSeed?: boolean;
|
||||
creditCostPerImage?: number;
|
||||
creditCostMultiplierBySize?: Record<string, number>;
|
||||
creditDiscountRate?: number;
|
||||
creditCostPerVideo?: number;
|
||||
videoDurationOptions?: number[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user