feat: add image size credit multipliers
This commit is contained in:
parent
a66c3107b9
commit
4aead3018f
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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[];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user