592 lines
18 KiB
TypeScript

import type { EnhanceProKreaModel } from "@/lib/enhance-pro/config";
export const POINTS_SYSTEM_CONFIG_KEY = "points_system_config";
export const POINTS_PACKAGES_KEY = "points_packages";
export type EnhanceProPointsChargeMode =
| "fixed"
| "resolution_bucket"
| "scale_factor";
export type EnhanceProPointsResolutionBucket = {
id: string;
name: string;
maxMegapixels: number;
cost: number;
};
export type EnhanceProPointsPricingConfig = {
enabled: boolean;
chargeMode: EnhanceProPointsChargeMode;
fixedCost: number;
chargeOnlyWhenUpscaling: boolean;
batchSizeMultiplierEnabled: boolean;
scaleFactorCosts: Record<string, number>;
resolutionBuckets: EnhanceProPointsResolutionBucket[];
modelMultipliers: Record<EnhanceProKreaModel, number>;
};
export type PointsSystemConfig = {
enabled: boolean;
blockOnInsufficient: boolean;
creditCostPer3DModel: number;
creditCostPer3DModelPbr: number;
creditCostPer3DModelSmartLowPoly: number;
creditCostPer3DModelQuad: number;
creditCostPer3DModelRefine: number;
creditCostPer3DModelTexture: number;
creditCostPer3DModelAutoRig: number;
creditCostPer3DModelHighpolyToLowpoly: number;
creditCostPer3DWorld: number;
creditCostPer3DWorldMarble11: number;
creditCostPer3DWorldMarble11Plus: number;
creditCostPerImageMultiView: number;
creditCostPerImageRemoveBackground: number;
creditCostPerImageBoxCutout: number;
creditCostPerImageWatermarkRemove: number;
creditCostPerImageExtend: number;
creditCostPerImageRepaint: number;
creditCostPerImageAngleEdit: number;
enhanceProPricing: EnhanceProPointsPricingConfig;
};
export type ThreeDTaskCreditParams = {
taskType?: string | null;
pbr?: boolean | null;
quad?: boolean | null;
smartLowPoly?: boolean | null;
};
export type ImageToolCreditFeature =
| "multi-view"
| "remove-background"
| "box-cutout"
| "watermark-remove"
| "extend"
| "repaint"
| "angle-edit";
export type PointsPackage = {
id: string;
name: string;
points: number;
price: number;
currency: string;
isEnabled: boolean;
};
export const defaultPointsSystemConfig: PointsSystemConfig = {
enabled: false,
blockOnInsufficient: true,
creditCostPer3DModel: 0,
creditCostPer3DModelPbr: 0,
creditCostPer3DModelSmartLowPoly: 0,
creditCostPer3DModelQuad: 0,
creditCostPer3DModelRefine: 0,
creditCostPer3DModelTexture: 0,
creditCostPer3DModelAutoRig: 0,
creditCostPer3DModelHighpolyToLowpoly: 0,
creditCostPer3DWorld: 0,
creditCostPer3DWorldMarble11: 0,
creditCostPer3DWorldMarble11Plus: 0,
creditCostPerImageMultiView: 0,
creditCostPerImageRemoveBackground: 0,
creditCostPerImageBoxCutout: 0,
creditCostPerImageWatermarkRemove: 0,
creditCostPerImageExtend: 0,
creditCostPerImageRepaint: 0,
creditCostPerImageAngleEdit: 0,
enhanceProPricing: {
enabled: false,
chargeMode: "scale_factor",
fixedCost: 20,
chargeOnlyWhenUpscaling: false,
batchSizeMultiplierEnabled: true,
scaleFactorCosts: {
"1": 20,
"1.25": 25,
"1.5": 30,
"2": 40,
"3": 60,
"4": 80,
},
resolutionBuckets: [
{
id: "enhance-pro-1mp",
name: "1MP 以内",
maxMegapixels: 1,
cost: 20,
},
{
id: "enhance-pro-2mp",
name: "2MP 以内",
maxMegapixels: 2,
cost: 30,
},
{
id: "enhance-pro-4mp",
name: "4MP 以内",
maxMegapixels: 4,
cost: 45,
},
{
id: "enhance-pro-8mp",
name: "8MP 以内",
maxMegapixels: 8,
cost: 65,
},
{
id: "enhance-pro-16mp",
name: "16MP 以内",
maxMegapixels: 16,
cost: 90,
},
],
modelMultipliers: {
Redefine: 1,
Recovery: 1,
"Recovery V2": 1,
Reimagine: 1,
},
},
};
export const defaultPointsPackages: PointsPackage[] = [
{
id: "starter-1000",
name: "入门包",
points: 1000,
price: 9.9,
currency: "CNY",
isEnabled: true,
},
{
id: "pro-5000",
name: "专业包",
points: 5000,
price: 39.9,
currency: "CNY",
isEnabled: true,
},
];
function normalizeCost(input: unknown) {
const parsedValue = Number(input);
return Number.isFinite(parsedValue) && parsedValue > 0
? Math.min(100000, Math.round(parsedValue))
: 0;
}
function normalizeMultiplier(input: unknown, fallback: number) {
const parsedValue = Number(input);
return Number.isFinite(parsedValue) && parsedValue > 0
? Math.min(10, Math.max(0.1, Number(parsedValue.toFixed(2))))
: fallback;
}
function normalizeMegapixels(input: unknown, fallback: number) {
const parsedValue = Number(input);
return Number.isFinite(parsedValue) && parsedValue > 0
? Math.min(100, Math.max(0.1, Number(parsedValue.toFixed(2))))
: fallback;
}
function normalizeScaleFactor(input: unknown, fallback: number) {
const parsedValue = Number(input);
return Number.isFinite(parsedValue) && parsedValue > 0
? Math.min(32, Math.max(1, Number(parsedValue.toFixed(2))))
: fallback;
}
function tryParseScaleFactor(input: unknown) {
const parsedValue = Number(input);
if (!Number.isFinite(parsedValue) || parsedValue <= 0) return null;
return Math.min(32, Math.max(1, Number(parsedValue.toFixed(2))));
}
export function formatEnhanceProScaleFactorKey(input: unknown) {
const normalized = normalizeScaleFactor(input, 1);
return Number(normalized.toFixed(2)).toString();
}
function normalizeEnhanceProScaleFactorCosts(value: unknown) {
const defaults = defaultPointsSystemConfig.enhanceProPricing.scaleFactorCosts;
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaults;
}
const normalizedEntries = Object.entries(value as Record<string, unknown>)
.map(([factorKey, costValue]) => {
const factor = tryParseScaleFactor(factorKey);
if (factor === null) return null;
return [formatEnhanceProScaleFactorKey(factor), normalizeCost(costValue)] as const;
})
.filter((entry): entry is readonly [string, number] => Boolean(entry))
.sort((left, right) => Number(left[0]) - Number(right[0]));
if (normalizedEntries.length === 0) {
return defaults;
}
return Object.fromEntries(normalizedEntries);
}
function normalizeEnhanceProResolutionBucket(
value: unknown,
index: number,
fallback: EnhanceProPointsResolutionBucket
): EnhanceProPointsResolutionBucket | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return fallback;
}
const raw = value as Partial<EnhanceProPointsResolutionBucket>;
const id = String(raw.id || fallback.id || `enhance-pro-bucket-${index + 1}`).trim();
const name = String(raw.name || fallback.name || `档位 ${index + 1}`).trim();
const maxMegapixels = normalizeMegapixels(
raw.maxMegapixels,
fallback.maxMegapixels
);
const cost = normalizeCost(raw.cost ?? fallback.cost);
if (!id || !name || cost <= 0) {
return null;
}
return {
id: id.slice(0, 64),
name: name.slice(0, 64),
maxMegapixels,
cost,
};
}
export function normalizeEnhanceProPointsPricingConfig(
value: unknown,
legacyFixedCost?: number
): EnhanceProPointsPricingConfig {
const defaults = defaultPointsSystemConfig.enhanceProPricing;
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Partial<EnhanceProPointsPricingConfig>)
: {};
const resolutionBuckets = Array.isArray(raw.resolutionBuckets)
? raw.resolutionBuckets
.map((item, index) =>
normalizeEnhanceProResolutionBucket(
item,
index,
defaults.resolutionBuckets[index] || defaults.resolutionBuckets[defaults.resolutionBuckets.length - 1]
)
)
.filter((item): item is EnhanceProPointsResolutionBucket => Boolean(item))
.sort((left, right) => left.maxMegapixels - right.maxMegapixels)
: defaults.resolutionBuckets;
const modelMultipliersRaw =
raw.modelMultipliers && typeof raw.modelMultipliers === "object"
? raw.modelMultipliers
: defaults.modelMultipliers;
const chargeMode: EnhanceProPointsChargeMode =
raw.chargeMode === "fixed" ||
raw.chargeMode === "resolution_bucket" ||
raw.chargeMode === "scale_factor"
? raw.chargeMode
: defaults.chargeMode;
return {
enabled: raw.enabled === true,
chargeMode,
fixedCost: normalizeCost(raw.fixedCost ?? legacyFixedCost ?? defaults.fixedCost),
chargeOnlyWhenUpscaling: raw.chargeOnlyWhenUpscaling !== false,
batchSizeMultiplierEnabled: raw.batchSizeMultiplierEnabled !== false,
scaleFactorCosts: normalizeEnhanceProScaleFactorCosts(
raw.scaleFactorCosts
),
resolutionBuckets:
resolutionBuckets.length > 0 ? resolutionBuckets : defaults.resolutionBuckets,
modelMultipliers: {
Redefine: normalizeMultiplier(
modelMultipliersRaw?.Redefine,
defaults.modelMultipliers.Redefine
),
Recovery: normalizeMultiplier(
modelMultipliersRaw?.Recovery,
defaults.modelMultipliers.Recovery
),
"Recovery V2": normalizeMultiplier(
modelMultipliersRaw?.["Recovery V2"],
defaults.modelMultipliers["Recovery V2"]
),
Reimagine: normalizeMultiplier(
modelMultipliersRaw?.Reimagine,
defaults.modelMultipliers.Reimagine
),
},
};
}
export function getEnhanceProResolutionMegapixels(
width: number,
height: number
) {
const safeWidth = Math.max(1, Math.round(Number(width) || 1));
const safeHeight = Math.max(1, Math.round(Number(height) || 1));
return Number(((safeWidth * safeHeight) / 1_048_576).toFixed(2));
}
export function calculateEnhanceProPointsEstimate(
pointsConfig: PointsSystemConfig,
params: {
width: number;
height: number;
batchSize?: number;
model?: EnhanceProKreaModel;
upscalingActivated?: boolean;
scaleFactor?: number;
}
) {
const pricing = normalizeEnhanceProPointsPricingConfig(
pointsConfig.enhanceProPricing
);
const model: EnhanceProKreaModel =
params.model === "Recovery" ||
params.model === "Recovery V2" ||
params.model === "Reimagine" ||
params.model === "Redefine"
? params.model
: "Redefine";
const megapixels = getEnhanceProResolutionMegapixels(params.width, params.height);
const batchSize = Math.max(1, Math.round(Number(params.batchSize) || 1));
const upscalingActivated = params.upscalingActivated === true;
const scaleFactor = normalizeScaleFactor(params.scaleFactor ?? 1, 1);
const billingScaleFactor = upscalingActivated ? scaleFactor : 1;
const batchMultiplier = pricing.batchSizeMultiplierEnabled ? batchSize : 1;
const modelMultiplier =
pricing.modelMultipliers[model] || defaultPointsSystemConfig.enhanceProPricing.modelMultipliers[model];
const bucket =
pricing.chargeMode === "resolution_bucket"
? pricing.resolutionBuckets.find((item) => megapixels <= item.maxMegapixels) ||
pricing.resolutionBuckets[pricing.resolutionBuckets.length - 1] ||
null
: null;
const scaleFactorCosts = normalizeEnhanceProScaleFactorCosts(
pricing.scaleFactorCosts
);
const scaleFactorCostEntries = Object.entries(scaleFactorCosts)
.map(([factorKey, cost]) => ({
factorKey,
factor: Number(factorKey),
cost,
}))
.filter((item) => Number.isFinite(item.factor))
.sort((left, right) => left.factor - right.factor);
const scaleFactorCost =
scaleFactorCostEntries.find(
(item) => Math.abs(item.factor - billingScaleFactor) < 0.001
) || null;
const linearScaleFactorCost = Math.ceil(pricing.fixedCost * billingScaleFactor);
const baseCost =
pricing.chargeOnlyWhenUpscaling && !upscalingActivated
? 0
: pricing.chargeMode === "fixed"
? pricing.fixedCost
: pricing.chargeMode === "scale_factor"
? Math.max(0, Number(scaleFactorCost?.cost ?? linearScaleFactorCost))
: Math.max(0, Number(bucket?.cost || 0));
const rawCost = baseCost * batchMultiplier * modelMultiplier;
const cost = baseCost > 0 ? Math.max(1, Math.ceil(rawCost)) : 0;
const enabled = pointsConfig.enabled === true && pricing.enabled === true;
return {
enabled,
chargeable: enabled && cost > 0,
chargeMode: pricing.chargeMode,
chargeOnlyWhenUpscaling: pricing.chargeOnlyWhenUpscaling,
upscalingActivated,
scaleFactor,
billingScaleFactor,
megapixels,
batchSize,
batchMultiplier,
model,
modelMultiplier,
baseCost,
cost,
scaleFactorCostKey: scaleFactorCost?.factorKey || null,
bucketId: bucket?.id || null,
bucketName: bucket?.name || null,
};
}
export function get3DWorldCreditCost(
pointsConfig: PointsSystemConfig,
modelId?: string | null
) {
const normalizedModelId = String(modelId || "").trim().toLowerCase();
if (normalizedModelId === "marble-1.1-plus") {
return normalizeCost(pointsConfig.creditCostPer3DWorldMarble11Plus);
}
if (normalizedModelId === "marble-1.1") {
return normalizeCost(pointsConfig.creditCostPer3DWorldMarble11);
}
return normalizeCost(pointsConfig.creditCostPer3DWorld);
}
export function get3DTaskCreditCost(
pointsConfig: PointsSystemConfig,
params: ThreeDTaskCreditParams
) {
const taskType = String(params.taskType || "").trim();
if (
taskType === "text_to_model" ||
taskType === "image_to_model" ||
taskType === "multiview_to_model"
) {
let cost = normalizeCost(pointsConfig.creditCostPer3DModel);
if (params.pbr === true) {
cost += normalizeCost(pointsConfig.creditCostPer3DModelPbr);
}
if (params.smartLowPoly === true) {
cost += normalizeCost(pointsConfig.creditCostPer3DModelSmartLowPoly);
}
if (params.quad === true) {
cost += normalizeCost(pointsConfig.creditCostPer3DModelQuad);
}
return cost;
}
if (taskType === "refine_model") {
return normalizeCost(pointsConfig.creditCostPer3DModelRefine);
}
if (taskType === "texture_model") {
return normalizeCost(pointsConfig.creditCostPer3DModelTexture);
}
if (taskType === "animate_rig") {
return normalizeCost(pointsConfig.creditCostPer3DModelAutoRig);
}
if (taskType === "highpoly_to_lowpoly") {
return normalizeCost(pointsConfig.creditCostPer3DModelHighpolyToLowpoly);
}
return 0;
}
export function getImageToolCreditCost(
pointsConfig: PointsSystemConfig,
feature: ImageToolCreditFeature | null | undefined
) {
switch (feature) {
case "multi-view":
return normalizeCost(pointsConfig.creditCostPerImageMultiView);
case "remove-background":
return normalizeCost(pointsConfig.creditCostPerImageRemoveBackground);
case "box-cutout":
return normalizeCost(pointsConfig.creditCostPerImageBoxCutout);
case "watermark-remove":
return normalizeCost(pointsConfig.creditCostPerImageWatermarkRemove);
case "extend":
return normalizeCost(pointsConfig.creditCostPerImageExtend);
case "repaint":
return normalizeCost(pointsConfig.creditCostPerImageRepaint);
case "angle-edit":
return normalizeCost(pointsConfig.creditCostPerImageAngleEdit);
default:
return 0;
}
}
export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig {
if (!value || typeof value !== "object") {
return defaultPointsSystemConfig;
}
const raw = value as Partial<PointsSystemConfig> & {
creditCostPer3D?: number;
creditCostPerEnhancePro?: number;
};
const legacy3DCost = normalizeCost(raw.creditCostPer3D);
const generic3DWorldCost = normalizeCost(
raw.creditCostPer3DWorld ?? legacy3DCost
);
const legacyEnhanceProCost = normalizeCost(raw.creditCostPerEnhancePro);
return {
enabled: raw.enabled === true,
blockOnInsufficient: raw.blockOnInsufficient !== false,
creditCostPer3DModel: normalizeCost(
raw.creditCostPer3DModel ?? legacy3DCost
),
creditCostPer3DModelPbr: normalizeCost(raw.creditCostPer3DModelPbr),
creditCostPer3DModelSmartLowPoly: normalizeCost(
raw.creditCostPer3DModelSmartLowPoly
),
creditCostPer3DModelQuad: normalizeCost(raw.creditCostPer3DModelQuad),
creditCostPer3DModelRefine: normalizeCost(raw.creditCostPer3DModelRefine),
creditCostPer3DModelTexture: normalizeCost(raw.creditCostPer3DModelTexture),
creditCostPer3DModelAutoRig: normalizeCost(raw.creditCostPer3DModelAutoRig),
creditCostPer3DModelHighpolyToLowpoly: normalizeCost(
raw.creditCostPer3DModelHighpolyToLowpoly
),
creditCostPer3DWorld: normalizeCost(
raw.creditCostPer3DWorld ?? legacy3DCost
),
creditCostPer3DWorldMarble11: normalizeCost(
raw.creditCostPer3DWorldMarble11 ?? generic3DWorldCost
),
creditCostPer3DWorldMarble11Plus: normalizeCost(
raw.creditCostPer3DWorldMarble11Plus ?? generic3DWorldCost
),
creditCostPerImageMultiView: normalizeCost(raw.creditCostPerImageMultiView),
creditCostPerImageRemoveBackground: normalizeCost(
raw.creditCostPerImageRemoveBackground
),
creditCostPerImageBoxCutout: normalizeCost(raw.creditCostPerImageBoxCutout),
creditCostPerImageWatermarkRemove: normalizeCost(
raw.creditCostPerImageWatermarkRemove
),
creditCostPerImageExtend: normalizeCost(raw.creditCostPerImageExtend),
creditCostPerImageRepaint: normalizeCost(raw.creditCostPerImageRepaint),
creditCostPerImageAngleEdit: normalizeCost(
raw.creditCostPerImageAngleEdit
),
enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
raw.enhanceProPricing,
legacyEnhanceProCost
),
};
}
export function normalizePointsPackages(value: unknown): PointsPackage[] {
if (!Array.isArray(value)) {
return defaultPointsPackages;
}
const normalized = value
.map((item, index) => {
if (!item || typeof item !== "object") return null;
const raw = item as Partial<PointsPackage>;
const id = String(raw.id || `package-${index + 1}`).trim();
const name = String(raw.name || "").trim();
const points = Number(raw.points);
const price = Number(raw.price);
const currency = String(raw.currency || "CNY").trim().toUpperCase();
if (!id || !name || !Number.isFinite(points) || points <= 0) return null;
if (!Number.isFinite(price) || price < 0) return null;
return {
id: id.slice(0, 64),
name: name.slice(0, 64),
points: Math.round(points),
price: Number(price.toFixed(2)),
currency: currency.slice(0, 8) || "CNY",
isEnabled: raw.isEnabled !== false,
} satisfies PointsPackage;
})
.filter((item): item is PointsPackage => Boolean(item))
.slice(0, 30);
return normalized.length > 0 ? normalized : defaultPointsPackages;
}