feat: refine krea enhance pro billing
This commit is contained in:
parent
f75f77105d
commit
8e51697800
@ -368,6 +368,7 @@ type ImageGenConfig = {
|
||||
creditCostPerVideo?: number; // 每条视频积分消耗
|
||||
videoDurationOptions?: number[]; // 视频秒数选项
|
||||
creditCostPerVideoByDuration?: Record<string, number>; // 各秒数积分
|
||||
creditCostPerVideoByResolution?: Record<string, number>; // 各分辨率积分
|
||||
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
|
||||
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
|
||||
};
|
||||
@ -387,6 +388,7 @@ function createDefaultImageGenConfig(): ImageGenConfig {
|
||||
creditCostPerVideo: 0,
|
||||
videoDurationOptions: [],
|
||||
creditCostPerVideoByDuration: {},
|
||||
creditCostPerVideoByResolution: {},
|
||||
enableLoadingPlaceholder: true,
|
||||
loadingColor: "purple",
|
||||
};
|
||||
@ -2910,6 +2912,28 @@ export function AdminSettingsClient() {
|
||||
});
|
||||
};
|
||||
|
||||
const updateEnhanceProScaleFactorCost = (
|
||||
factorKey: string,
|
||||
nextCost: number
|
||||
) => {
|
||||
setSiteSettings((prev) => {
|
||||
const config = normalizePointsSystemConfig(prev.pointsSystemConfig);
|
||||
return {
|
||||
...prev,
|
||||
pointsSystemConfig: {
|
||||
...config,
|
||||
enhanceProPricing: {
|
||||
...config.enhanceProPricing,
|
||||
scaleFactorCosts: {
|
||||
...config.enhanceProPricing.scaleFactorCosts,
|
||||
[factorKey]: Math.max(0, Math.round(nextCost) || 0),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateMembershipConfigState = (
|
||||
updater: (
|
||||
config: ReturnType<typeof normalizeMembershipConfig>
|
||||
@ -5566,7 +5590,7 @@ export function AdminSettingsClient() {
|
||||
id={`enhance-pro-preset-scale-${preset.id}`}
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
max={enhanceProConfig.maxUpscaleFactor}
|
||||
step="0.5"
|
||||
value={preset.imageScalingFactor}
|
||||
onChange={(e) =>
|
||||
@ -5574,7 +5598,7 @@ export function AdminSettingsClient() {
|
||||
index,
|
||||
"imageScalingFactor",
|
||||
Math.min(
|
||||
32,
|
||||
enhanceProConfig.maxUpscaleFactor,
|
||||
Math.max(1, Number.parseFloat(e.target.value) || 1)
|
||||
)
|
||||
)
|
||||
@ -8208,6 +8232,60 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
className="text-xs text-muted-foreground"
|
||||
htmlFor="points3dWorldMarble11Cost"
|
||||
>
|
||||
Marble 1.1 积分
|
||||
</Label>
|
||||
<Input
|
||||
id="points3dWorldMarble11Cost"
|
||||
min={0}
|
||||
type="number"
|
||||
value={pointsSystemConfig.creditCostPer3DWorldMarble11}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
pointsSystemConfig: {
|
||||
...pointsSystemConfig,
|
||||
creditCostPer3DWorldMarble11: Math.max(
|
||||
0,
|
||||
Number.parseInt(e.target.value || "0", 10) || 0
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
className="text-xs text-muted-foreground"
|
||||
htmlFor="points3dWorldMarble11PlusCost"
|
||||
>
|
||||
Marble 1.1 Plus 积分
|
||||
</Label>
|
||||
<Input
|
||||
id="points3dWorldMarble11PlusCost"
|
||||
min={0}
|
||||
type="number"
|
||||
value={pointsSystemConfig.creditCostPer3DWorldMarble11Plus}
|
||||
onChange={(e) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
pointsSystemConfig: {
|
||||
...pointsSystemConfig,
|
||||
creditCostPer3DWorldMarble11Plus: Math.max(
|
||||
0,
|
||||
Number.parseInt(e.target.value || "0", 10) || 0
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
@ -8242,7 +8320,7 @@ export function AdminSettingsClient() {
|
||||
<div className="space-y-1">
|
||||
<Label>计费模式</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
固定单价适合先快速上线,分辨率档位更适合贴近增强成本。
|
||||
可按放大倍率、分辨率档位或固定单价计算本地积分。
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-full max-w-xs">
|
||||
@ -8257,7 +8335,9 @@ export function AdminSettingsClient() {
|
||||
chargeMode:
|
||||
value === "fixed"
|
||||
? "fixed"
|
||||
: "resolution_bucket",
|
||||
: value === "resolution_bucket"
|
||||
? "resolution_bucket"
|
||||
: "scale_factor",
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -8268,6 +8348,9 @@ export function AdminSettingsClient() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="scale_factor">
|
||||
放大倍率
|
||||
</SelectItem>
|
||||
<SelectItem value="resolution_bucket">
|
||||
分辨率档位
|
||||
</SelectItem>
|
||||
@ -8312,6 +8395,66 @@ export function AdminSettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label>只在启用放大时扣积分</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
开启后用户关闭“启用放大”时不扣除图像增强 Pro 积分。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={
|
||||
pointsSystemConfig.enhanceProPricing
|
||||
.chargeOnlyWhenUpscaling !== false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
setSiteSettings({
|
||||
...siteSettings,
|
||||
pointsSystemConfig: {
|
||||
...pointsSystemConfig,
|
||||
enhanceProPricing: {
|
||||
...pointsSystemConfig.enhanceProPricing,
|
||||
chargeOnlyWhenUpscaling: checked,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>放大倍率积分</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
“放大倍率”计费模式下使用;小数倍率会匹配大于等于该倍率的最近档位。
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||
{Object.entries(
|
||||
pointsSystemConfig.enhanceProPricing.scaleFactorCosts
|
||||
)
|
||||
.sort(([left], [right]) => Number(left) - Number(right))
|
||||
.map(([factorKey, cost]) => (
|
||||
<div key={factorKey} className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{factorKey}x
|
||||
</Label>
|
||||
<Input
|
||||
min={0}
|
||||
type="number"
|
||||
value={cost}
|
||||
onChange={(e) =>
|
||||
updateEnhanceProScaleFactorCost(
|
||||
factorKey,
|
||||
Number.parseInt(e.target.value || "0", 10) || 0
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label>按候选结果数量线性乘倍</Label>
|
||||
@ -8485,7 +8628,7 @@ export function AdminSettingsClient() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
||||
建议算法:按输出分辨率档位取基础积分,再乘以候选数量和模型倍率,最终向上取整。
|
||||
建议算法:按放大倍率或输出分辨率取基础积分,再乘以候选数量和模型倍率,最终向上取整。
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -11930,6 +12073,62 @@ export function AdminSettingsClient() {
|
||||
当前还没有配置秒数档位,将按“每条视频积分消耗”统一扣费。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="text-sm font-medium">
|
||||
各分辨率积分消耗
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(["480p", "720p", "1080p"] as const).map(
|
||||
(resolutionKey) => (
|
||||
<div
|
||||
key={resolutionKey}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label>{resolutionKey} 积分</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={
|
||||
modelForm.imageGenConfig
|
||||
?.creditCostPerVideoByResolution?.[
|
||||
resolutionKey
|
||||
] ??
|
||||
modelForm.imageGenConfig
|
||||
?.creditCostPerVideo ??
|
||||
0
|
||||
}
|
||||
onChange={(e) => {
|
||||
const nextValue = Math.max(
|
||||
0,
|
||||
Number.parseInt(
|
||||
e.target.value,
|
||||
10
|
||||
) || 0
|
||||
);
|
||||
setModelForm({
|
||||
...modelForm,
|
||||
imageGenConfig: {
|
||||
...modelForm.imageGenConfig,
|
||||
creditCostPerVideoByResolution: {
|
||||
...(modelForm.imageGenConfig
|
||||
?.creditCostPerVideoByResolution ||
|
||||
{}),
|
||||
[resolutionKey]: nextValue,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
配置后会优先按用户选择的视频分辨率扣积分;未命中再回退到秒数或统一价格。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -15006,7 +15205,7 @@ export function AdminSettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="enhanceProMaxBatchSize">最大候选图数量</Label>
|
||||
<Select
|
||||
@ -15037,6 +15236,39 @@ export function AdminSettingsClient() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="enhanceProMaxUpscaleFactor">
|
||||
最大放大倍数
|
||||
</Label>
|
||||
<Input
|
||||
id="enhanceProMaxUpscaleFactor"
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
step="0.5"
|
||||
value={enhanceProConfig.maxUpscaleFactor}
|
||||
onChange={(e) => {
|
||||
const nextMaxUpscaleFactor = Math.min(
|
||||
32,
|
||||
Math.max(1, Number.parseFloat(e.target.value) || 4)
|
||||
);
|
||||
setEnhanceProConfig((prev) => ({
|
||||
...prev,
|
||||
maxUpscaleFactor: nextMaxUpscaleFactor,
|
||||
presets: prev.presets.map((preset) => ({
|
||||
...preset,
|
||||
imageScalingFactor: Math.min(
|
||||
Math.max(1, preset.imageScalingFactor || 1),
|
||||
nextMaxUpscaleFactor
|
||||
),
|
||||
})),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
默认建议 2-4 倍;需要时仍可放宽到 Krea 支持的更高倍数。
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="enhanceProMaxOutputEdge">最大输出长边</Label>
|
||||
<Input
|
||||
|
||||
@ -4087,13 +4087,17 @@ const MODEL_UUID_REGEX =
|
||||
function getModelCreditCost(
|
||||
modelConfig: any,
|
||||
target: "image" | "video",
|
||||
options?: { duration?: number | null }
|
||||
options?: { duration?: number | null; resolution?: string | null }
|
||||
): number {
|
||||
const config = (modelConfig as any)?.imageGenConfig || {};
|
||||
const sourceValue =
|
||||
target === "video"
|
||||
? (() => {
|
||||
const resolvedVideoCost = getVideoCreditCostFromConfig(config, options?.duration);
|
||||
const resolvedVideoCost = getVideoCreditCostFromConfig(
|
||||
config,
|
||||
options?.duration,
|
||||
options?.resolution
|
||||
);
|
||||
if (resolvedVideoCost > 0) return resolvedVideoCost;
|
||||
return config.creditCostPerVideo ?? config.creditCostPerImage ?? 0;
|
||||
})()
|
||||
@ -4122,6 +4126,7 @@ async function consumeGeneratedVideoPoints(params: {
|
||||
messageId: string;
|
||||
modelRefs: Array<string | null | undefined>;
|
||||
duration?: number | null;
|
||||
resolution?: string | null;
|
||||
}) {
|
||||
try {
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
@ -4139,13 +4144,21 @@ async function consumeGeneratedVideoPoints(params: {
|
||||
const resolved = await resolveBillingModelByReference(ref);
|
||||
if (!resolved) continue;
|
||||
billingModel = resolved;
|
||||
if (getModelCreditCost(resolved, "video", { duration: params.duration }) > 0) break;
|
||||
if (
|
||||
getModelCreditCost(resolved, "video", {
|
||||
duration: params.duration,
|
||||
resolution: params.resolution,
|
||||
}) > 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!billingModel) return;
|
||||
|
||||
const unitCost = getModelCreditCost(billingModel, "video", {
|
||||
duration: params.duration,
|
||||
resolution: params.resolution,
|
||||
});
|
||||
if (unitCost <= 0) return;
|
||||
|
||||
@ -4156,7 +4169,7 @@ async function consumeGeneratedVideoPoints(params: {
|
||||
modelId: billingModel.id,
|
||||
chatId: params.chatId,
|
||||
messageId: params.messageId,
|
||||
note: `模型(${billingModel.displayName || billingModel.modelId || billingModel.id}) 生视频 1 条${params.duration ? `(${params.duration} 秒)` : ""}`,
|
||||
note: `模型(${billingModel.displayName || billingModel.modelId || billingModel.id}) 生视频 1 条${params.duration ? `(${params.duration} 秒)` : ""}${params.resolution ? `(${params.resolution})` : ""}`,
|
||||
});
|
||||
|
||||
if (!consumeResult.ok) {
|
||||
@ -6795,6 +6808,9 @@ export async function POST(request: Request) {
|
||||
messageId: finalMessageId,
|
||||
modelRefs: executionResult.modelRefs,
|
||||
duration: Number((finalVideoMetadata as any)?.duration) || undefined,
|
||||
resolution:
|
||||
String((finalVideoMetadata as any)?.resolution || "").trim() ||
|
||||
undefined,
|
||||
});
|
||||
|
||||
scheduleVideoArchive({
|
||||
@ -8099,6 +8115,9 @@ export async function POST(request: Request) {
|
||||
messageId: assistantMessageId,
|
||||
modelRefs: [result.modelRef || presetModelId],
|
||||
duration: Number((result.metadata as any)?.duration) || undefined,
|
||||
resolution:
|
||||
String((result.metadata as any)?.resolution || "").trim() ||
|
||||
undefined,
|
||||
});
|
||||
|
||||
scheduleVideoArchive({
|
||||
@ -8609,6 +8628,12 @@ export async function POST(request: Request) {
|
||||
Number((result.metadata as any)?.duration) ||
|
||||
Number(effectiveVideoParams?.duration) ||
|
||||
undefined,
|
||||
resolution:
|
||||
String(
|
||||
(result.metadata as any)?.resolution ||
|
||||
effectiveVideoParams?.resolution ||
|
||||
""
|
||||
).trim() || undefined,
|
||||
});
|
||||
|
||||
if (videoUrl) {
|
||||
@ -10705,6 +10730,9 @@ export async function POST(request: Request) {
|
||||
messageId,
|
||||
modelRefs: billedModelRefs,
|
||||
duration: Number(effectiveVideoParams?.duration) || undefined,
|
||||
resolution:
|
||||
String(effectiveVideoParams?.resolution || "").trim() ||
|
||||
undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@ -11698,6 +11726,7 @@ export async function POST(request: Request) {
|
||||
messageId: videoMessageId,
|
||||
modelRefs: [finalVideoModelData.id, providerModelData.id, finalModelId],
|
||||
duration: Number(videoOptions.duration) || undefined,
|
||||
resolution: String(videoOptions.resolution || "").trim() || undefined,
|
||||
});
|
||||
|
||||
dataStream.write({
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
buildVideoDurationCostMap,
|
||||
getConfiguredVideoDurationOptions,
|
||||
normalizeVideoResolutionCostMap,
|
||||
} from "@/lib/ai/video-model-config";
|
||||
|
||||
// 🆕 图片生成模型特性配置 Schema
|
||||
@ -35,6 +36,7 @@ const imageGenConfigSchema = z.object({
|
||||
creditCostPerVideo: z.number().min(0).max(100000).optional(),
|
||||
videoDurationOptions: z.array(z.number().min(1).max(600)).max(20).optional(),
|
||||
creditCostPerVideoByDuration: z.record(z.string(), z.number().min(0).max(100000)).optional(),
|
||||
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
|
||||
enableLoadingPlaceholder: z.boolean().optional(),
|
||||
loadingColor: z.string().optional(),
|
||||
}).optional();
|
||||
@ -53,6 +55,9 @@ function normalizeImageGenConfig(config: z.infer<typeof imageGenConfigSchema>) {
|
||||
...config,
|
||||
videoDurationOptions: videoDurationOptions.length ? videoDurationOptions : undefined,
|
||||
creditCostPerVideoByDuration,
|
||||
creditCostPerVideoByResolution: normalizeVideoResolutionCostMap(
|
||||
config.creditCostPerVideoByResolution
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from "@/lib/db/queries";
|
||||
import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config";
|
||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||
import { get3DWorldCreditCost } from "@/lib/points/config";
|
||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||||
import {
|
||||
disableWorldLabsProvider,
|
||||
@ -155,12 +156,17 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 });
|
||||
}
|
||||
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||
const resolvedWorldModel = resolveThreeDModelRealId(
|
||||
readOptionalTrimmedText(body.model),
|
||||
modelAliasConfig.worldModels,
|
||||
);
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const shouldChargePoints = shouldCharge3DPoints(mode);
|
||||
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DWorld))
|
||||
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DWorld)))
|
||||
: 0;
|
||||
const unitCost = get3DWorldCreditCost(
|
||||
pointsConfig,
|
||||
resolvedWorldModel || "marble-1.1",
|
||||
);
|
||||
|
||||
if (
|
||||
shouldChargePoints &&
|
||||
@ -193,10 +199,6 @@ export async function POST(request: Request) {
|
||||
readOptionalTrimmedText(body.displayName) ||
|
||||
readOptionalTrimmedText(body.prompt) ||
|
||||
`World ${new Date().toLocaleString("zh-CN")}`;
|
||||
const resolvedWorldModel = resolveThreeDModelRealId(
|
||||
readOptionalTrimmedText(body.model),
|
||||
modelAliasConfig.worldModels,
|
||||
);
|
||||
const worldPrompt = buildWorldPrompt(body, mode);
|
||||
|
||||
const candidates = preferredProviderId
|
||||
@ -266,8 +268,8 @@ export async function POST(request: Request) {
|
||||
userId: session.user.id,
|
||||
amount: unitCost,
|
||||
reason: "world3d_generation",
|
||||
modelId: "worldlabs-3d",
|
||||
note: "3D世界生成消耗积分",
|
||||
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
|
||||
note: `3D世界生成消耗积分(${resolvedWorldModel || "marble-1.1"})`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Points] Failed to consume World Labs 3D points:", error);
|
||||
|
||||
@ -313,7 +313,12 @@ async function createEnhanceProJob(params: {
|
||||
crop_to_fill: normalizeBoolean(params.requestBody.crop_to_fill, false),
|
||||
model: params.requestBody.model || params.config.defaultModel,
|
||||
upscaling_activated: normalizeBoolean(params.requestBody.upscaling_activated, false),
|
||||
image_scaling_factor: clampNumber(params.requestBody.image_scaling_factor, 1, 32, 1),
|
||||
image_scaling_factor: clampNumber(
|
||||
params.requestBody.image_scaling_factor,
|
||||
1,
|
||||
params.config.maxUpscaleFactor,
|
||||
1,
|
||||
),
|
||||
creativity: clampInteger(params.requestBody.creativity, 1, 6, 3),
|
||||
texture: clampInteger(params.requestBody.texture, 1, 5, 3),
|
||||
sharpen: clampNumber(params.requestBody.sharpen, 0, 1, 0.5),
|
||||
@ -647,12 +652,21 @@ export async function POST(request: NextRequest) {
|
||||
body.model === "Redefine"
|
||||
? body.model
|
||||
: config.defaultModel;
|
||||
const upscalingActivated = body.upscaling_activated === true;
|
||||
const imageScalingFactor = clampNumber(
|
||||
body.image_scaling_factor,
|
||||
1,
|
||||
config.maxUpscaleFactor,
|
||||
1,
|
||||
);
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsEstimate = calculateEnhanceProPointsEstimate(pointsConfig, {
|
||||
width: clampedDimensions.width,
|
||||
height: clampedDimensions.height,
|
||||
batchSize,
|
||||
model: selectedModel,
|
||||
upscalingActivated,
|
||||
scaleFactor: imageScalingFactor,
|
||||
});
|
||||
|
||||
if (pointsEstimate.chargeable && pointsConfig.blockOnInsufficient !== false) {
|
||||
@ -683,6 +697,8 @@ export async function POST(request: NextRequest) {
|
||||
...body,
|
||||
batchSize,
|
||||
model: selectedModel,
|
||||
upscaling_activated: upscalingActivated,
|
||||
image_scaling_factor: imageScalingFactor,
|
||||
},
|
||||
sourceImageUrl: sourceInput.sourceImageUrl,
|
||||
width: clampedDimensions.width,
|
||||
|
||||
@ -9764,14 +9764,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||||
: "h-9 w-9 rounded-xl"
|
||||
)}
|
||||
disabled={!selectedImageNode}
|
||||
onClick={() => {
|
||||
if (!selectedImageNode) {
|
||||
toast.info("请先选中一张图片");
|
||||
return;
|
||||
}
|
||||
router.push(`/canvas/${id}/realtime?mode=enhance-pro`);
|
||||
}}
|
||||
onClick={() => router.push(`/canvas/${id}/realtime?mode=enhance-pro`)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
@ -9781,7 +9774,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
<TooltipContent side="top">
|
||||
{selectedImageNode
|
||||
? "图像增强 Pro"
|
||||
: "先选中图片后进入图像增强 Pro"}
|
||||
: "进入图像增强 Pro 后上传图片"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
@ -40,6 +40,7 @@ export default function CanvasRealtimePage() {
|
||||
const handleApplyResult = useCallback(
|
||||
async ({
|
||||
imageUrl,
|
||||
sourceImageUrl,
|
||||
prompt,
|
||||
title,
|
||||
model,
|
||||
@ -47,6 +48,7 @@ export default function CanvasRealtimePage() {
|
||||
height,
|
||||
}: {
|
||||
imageUrl: string;
|
||||
sourceImageUrl?: string;
|
||||
prompt: string;
|
||||
title?: string;
|
||||
model?: string;
|
||||
@ -74,6 +76,7 @@ export default function CanvasRealtimePage() {
|
||||
const targetHeight = hasNaturalSize
|
||||
? Math.max(140, Math.round(safeHeight * scale))
|
||||
: fallbackSize;
|
||||
const compareSourceUrl = String(sourceImageUrl || "").trim();
|
||||
|
||||
const x =
|
||||
(stageWidth * 0.45 - viewportX) / viewportZoom - targetWidth / 2;
|
||||
@ -86,10 +89,16 @@ export default function CanvasRealtimePage() {
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: imageUrl,
|
||||
originalSrc: compareSourceUrl || undefined,
|
||||
status: "completed",
|
||||
title: title || "实时画布结果",
|
||||
prompt,
|
||||
model: model || "bfl/flux-2",
|
||||
metadata: compareSourceUrl
|
||||
? {
|
||||
compareSourceUrl,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
|
||||
@ -68,6 +68,7 @@ type EnhanceProClientConfig = {
|
||||
defaultOutputFormat: EnhanceProOutputFormat;
|
||||
allowBatchSize: boolean;
|
||||
maxBatchSize: number;
|
||||
maxUpscaleFactor: number;
|
||||
maxOutputEdge: number;
|
||||
enableAdvancedControls: boolean;
|
||||
presets: EnhanceProPreset[];
|
||||
@ -109,6 +110,7 @@ const FALLBACK_CONFIG: EnhanceProClientConfig = {
|
||||
defaultOutputFormat: "jpg",
|
||||
allowBatchSize: true,
|
||||
maxBatchSize: 4,
|
||||
maxUpscaleFactor: 4,
|
||||
maxOutputEdge: 4096,
|
||||
enableAdvancedControls: true,
|
||||
presets: defaultEnhanceProPresets(),
|
||||
@ -244,7 +246,11 @@ function applyPresetToForm(
|
||||
crop_to_fill: preset.cropToFill,
|
||||
model: preset.model,
|
||||
upscaling_activated: preset.upscalingActivated,
|
||||
image_scaling_factor: clampNumber(preset.imageScalingFactor, 1, 32),
|
||||
image_scaling_factor: clampNumber(
|
||||
preset.imageScalingFactor,
|
||||
1,
|
||||
config.maxUpscaleFactor
|
||||
),
|
||||
creativity: clampNumber(Math.round(preset.creativity), 1, 6),
|
||||
texture: clampNumber(Math.round(preset.texture), 1, 5),
|
||||
sharpen: clampNumber(preset.sharpen, 0, 1),
|
||||
@ -362,6 +368,7 @@ interface EnhanceProWorkspaceProps {
|
||||
onRequestAddImage?: () => void;
|
||||
onApplyResult: (payload: {
|
||||
imageUrl: string;
|
||||
sourceImageUrl?: string;
|
||||
prompt: string;
|
||||
title?: string;
|
||||
model?: string;
|
||||
@ -450,9 +457,19 @@ export function EnhanceProWorkspace({
|
||||
height: formState.height,
|
||||
batchSize: formState.batchSize,
|
||||
model: formState.model,
|
||||
upscalingActivated: formState.upscaling_activated,
|
||||
scaleFactor: formState.image_scaling_factor,
|
||||
}
|
||||
),
|
||||
[formState.batchSize, formState.height, formState.model, formState.width, pointsConfig]
|
||||
[
|
||||
formState.batchSize,
|
||||
formState.height,
|
||||
formState.image_scaling_factor,
|
||||
formState.model,
|
||||
formState.upscaling_activated,
|
||||
formState.width,
|
||||
pointsConfig,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -578,7 +595,7 @@ export function EnhanceProWorkspace({
|
||||
image_scaling_factor: clampNumber(
|
||||
formState.image_scaling_factor,
|
||||
1,
|
||||
32
|
||||
config.maxUpscaleFactor
|
||||
),
|
||||
creativity: clampNumber(Math.round(formState.creativity), 1, 6),
|
||||
texture: clampNumber(Math.round(formState.texture), 1, 5),
|
||||
@ -613,6 +630,7 @@ export function EnhanceProWorkspace({
|
||||
}
|
||||
onApplyResult({
|
||||
imageUrl: selectedGeneratedImage.originalUrl,
|
||||
sourceImageUrl: sourceImageUrl || undefined,
|
||||
prompt: formState.prompt.trim() || "图像增强 Pro",
|
||||
title: "图像增强 Pro 结果",
|
||||
model: selectedPreset?.model || config.defaultModel,
|
||||
@ -739,14 +757,14 @@ export function EnhanceProWorkspace({
|
||||
<div className="relative flex flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<Button
|
||||
className={cn(
|
||||
"h-14 rounded-[22px] px-8 text-[15px] font-semibold tracking-[-0.02em]",
|
||||
"h-16 rounded-[22px] px-10 text-[15px] font-semibold tracking-[-0.02em] sm:h-20 sm:px-12 sm:text-lg",
|
||||
PRIMARY_ACTION_BUTTON_CLASS
|
||||
)}
|
||||
disabled={!onRequestAddImage}
|
||||
onClick={onRequestAddImage}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon className="mr-2 h-5 w-5" />
|
||||
<ImageIcon className="mr-2 h-5 w-5 sm:h-6 sm:w-6" />
|
||||
添加图片
|
||||
</Button>
|
||||
<div className="text-[#666666] text-[13px] leading-6">
|
||||
@ -804,6 +822,9 @@ export function EnhanceProWorkspace({
|
||||
{pointsEstimate.chargeMode === "resolution_bucket" && pointsEstimate.bucketName
|
||||
? ` · ${pointsEstimate.bucketName}`
|
||||
: ""}
|
||||
{pointsEstimate.chargeMode === "scale_factor"
|
||||
? ` · ${pointsEstimate.scaleFactor}x`
|
||||
: ""}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-auto inline-flex items-center justify-center gap-2 rounded-[32px] border border-[#e5e5e5] bg-white p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_18px_38px_rgba(0,0,0,0.08)] backdrop-blur-xl">
|
||||
@ -1130,7 +1151,7 @@ export function EnhanceProWorkspace({
|
||||
<Input
|
||||
className={COMPACT_FIELD_CLASS}
|
||||
id="enhance-workspace-scale"
|
||||
max="32"
|
||||
max={config.maxUpscaleFactor}
|
||||
min="1"
|
||||
onChange={(event) =>
|
||||
handleNumericField(
|
||||
@ -1138,7 +1159,7 @@ export function EnhanceProWorkspace({
|
||||
clampNumber(
|
||||
Number.parseFloat(event.target.value) || 1,
|
||||
1,
|
||||
32
|
||||
config.maxUpscaleFactor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -148,6 +148,7 @@ type RealtimeCanvasDialogProps = {
|
||||
initialMode?: RealtimeWorkspaceMode;
|
||||
onApplyResult: (payload: {
|
||||
imageUrl: string;
|
||||
sourceImageUrl?: string;
|
||||
prompt: string;
|
||||
title?: string;
|
||||
model?: string;
|
||||
@ -2956,6 +2957,7 @@ export function RealtimeCanvasDialog({
|
||||
const handleApplyEnhanceResult = useCallback(
|
||||
(payload: {
|
||||
imageUrl: string;
|
||||
sourceImageUrl?: string;
|
||||
prompt: string;
|
||||
title?: string;
|
||||
model?: string;
|
||||
|
||||
@ -38,6 +38,7 @@ type EnhanceProClientConfig = {
|
||||
defaultOutputFormat: EnhanceProOutputFormat;
|
||||
allowBatchSize: boolean;
|
||||
maxBatchSize: number;
|
||||
maxUpscaleFactor: number;
|
||||
maxOutputEdge: number;
|
||||
enableAdvancedControls: boolean;
|
||||
presets: EnhanceProPreset[];
|
||||
@ -73,6 +74,7 @@ const FALLBACK_CONFIG: EnhanceProClientConfig = {
|
||||
defaultOutputFormat: "jpg",
|
||||
allowBatchSize: true,
|
||||
maxBatchSize: 4,
|
||||
maxUpscaleFactor: 4,
|
||||
maxOutputEdge: 4096,
|
||||
enableAdvancedControls: true,
|
||||
presets: defaultEnhanceProPresets(),
|
||||
@ -151,7 +153,11 @@ function applyPresetToForm(
|
||||
crop_to_fill: preset.cropToFill,
|
||||
model: preset.model,
|
||||
upscaling_activated: preset.upscalingActivated,
|
||||
image_scaling_factor: clampNumber(preset.imageScalingFactor, 1, 32),
|
||||
image_scaling_factor: clampNumber(
|
||||
preset.imageScalingFactor,
|
||||
1,
|
||||
config.maxUpscaleFactor
|
||||
),
|
||||
creativity: clampNumber(Math.round(preset.creativity), 1, 6),
|
||||
texture: clampNumber(Math.round(preset.texture), 1, 5),
|
||||
sharpen: clampNumber(preset.sharpen, 0, 1),
|
||||
@ -274,7 +280,11 @@ export function EnhanceProDialog({
|
||||
crop_to_fill: formState.crop_to_fill,
|
||||
model: formState.model,
|
||||
upscaling_activated: formState.upscaling_activated,
|
||||
image_scaling_factor: clampNumber(formState.image_scaling_factor, 1, 32),
|
||||
image_scaling_factor: clampNumber(
|
||||
formState.image_scaling_factor,
|
||||
1,
|
||||
config.maxUpscaleFactor
|
||||
),
|
||||
creativity: clampNumber(Math.round(formState.creativity), 1, 6),
|
||||
texture: clampNumber(Math.round(formState.texture), 1, 5),
|
||||
sharpen: clampNumber(formState.sharpen, 0, 1),
|
||||
@ -549,13 +559,17 @@ export function EnhanceProDialog({
|
||||
id="enhance-pro-image-scaling"
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
max={config.maxUpscaleFactor}
|
||||
step="0.5"
|
||||
value={formState.image_scaling_factor}
|
||||
onChange={(event) =>
|
||||
handleNumericField(
|
||||
"image_scaling_factor",
|
||||
clampNumber(Number.parseFloat(event.target.value) || 1, 1, 32),
|
||||
clampNumber(
|
||||
Number.parseFloat(event.target.value) || 1,
|
||||
1,
|
||||
config.maxUpscaleFactor
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@ -207,6 +207,7 @@ function videoPreset(
|
||||
creditCostPerVideo: 0,
|
||||
videoDurationOptions: durationOptions,
|
||||
creditCostPerVideoByDuration: {},
|
||||
creditCostPerVideoByResolution: {},
|
||||
enableLoadingPlaceholder: true,
|
||||
loadingColor: "blue",
|
||||
},
|
||||
|
||||
@ -508,6 +508,79 @@ async function downloadRemoteAsset(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyForLog(value: unknown) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2).slice(0, 4000);
|
||||
} catch {
|
||||
return String(value).slice(0, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyKreaHostedUrl(url: string) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return hostname === "krea.ai" || hostname.endsWith(".krea.ai");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadKreaReferenceAsset(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
sourceUrl: string
|
||||
) {
|
||||
const normalized = normalizeText(sourceUrl);
|
||||
if (!/^https?:\/\//i.test(normalized) || isLikelyKreaHostedUrl(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
try {
|
||||
const { buffer, contentType } = await downloadRemoteAsset(normalized);
|
||||
const mimeType = contentType || "application/octet-stream";
|
||||
if (!/^(image|video)\//i.test(mimeType)) return normalized;
|
||||
|
||||
const form = new FormData();
|
||||
const blob = new Blob([buffer], { type: mimeType });
|
||||
form.append("file", blob, generateFilename("krea-reference", detectExtensionFromContentType(mimeType)));
|
||||
form.append("description", "Reference asset uploaded for Krea API generation");
|
||||
|
||||
const response = await fetch(joinEndpoint(baseURL, "/assets"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${normalizeKreaApiKey(apiKey)}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
const rawText = await response.text();
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = rawText ? JSON.parse(rawText) : null;
|
||||
} catch {
|
||||
payload = rawText;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn("[Krea] Reference asset upload failed, falling back to source URL:", {
|
||||
status: response.status,
|
||||
sourceUrl: summarizeKreaUrl(normalized),
|
||||
response: stringifyForLog(payload || rawText),
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const assetUrl = normalizeText(payload?.image_url || payload?.url || payload?.asset_url);
|
||||
return assetUrl || normalized;
|
||||
} catch (error) {
|
||||
console.warn("[Krea] Reference asset upload failed, falling back to source URL:", {
|
||||
sourceUrl: summarizeKreaUrl(normalized),
|
||||
error,
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
async function inferReferenceAspectRatioFromImageUrl(url?: string | null) {
|
||||
const normalized = normalizeText(url);
|
||||
if (!normalized || !/^https?:\/\//i.test(normalized)) return undefined;
|
||||
@ -637,7 +710,7 @@ async function requestKreaJob(
|
||||
endpoint,
|
||||
status: response.status,
|
||||
requestFields: Object.keys(body),
|
||||
response: payload,
|
||||
response: stringifyForLog(payload || rawText),
|
||||
});
|
||||
const message =
|
||||
typeof payload?.error === "string"
|
||||
@ -915,6 +988,11 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
|
||||
const normalizedVideoUrls = (
|
||||
await Promise.all(videoUrls.map((url) => normalizeAssetUrl(url)))
|
||||
).filter(Boolean);
|
||||
const kreaImageUrls = (
|
||||
await Promise.all(
|
||||
normalizedImageUrls.map((url) => uploadKreaReferenceAsset(baseURL, apiKey, url))
|
||||
)
|
||||
).filter(Boolean);
|
||||
const requestFields =
|
||||
preset?.requestFields ||
|
||||
(kind === "video"
|
||||
@ -933,7 +1011,7 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
|
||||
endpoint,
|
||||
kind,
|
||||
prompt,
|
||||
imageUrls: normalizedImageUrls,
|
||||
imageUrls: kreaImageUrls,
|
||||
requestFields,
|
||||
});
|
||||
const { body, imageSize } = buildKreaBody({
|
||||
@ -942,7 +1020,7 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
|
||||
requestFields,
|
||||
requiredFields,
|
||||
prompt: promptForKrea,
|
||||
imageUrls: normalizedImageUrls,
|
||||
imageUrls: kreaImageUrls,
|
||||
videoUrls: normalizedVideoUrls,
|
||||
referenceAspectRatio,
|
||||
imageParams: options.imageParams,
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
export type VideoDurationCostMap = Record<string, number>;
|
||||
export type VideoResolutionCostMap = Record<string, number>;
|
||||
|
||||
export type VideoImageGenConfigLike = {
|
||||
creditCostPerVideo?: number | null;
|
||||
videoDurationOptions?: number[] | null;
|
||||
creditCostPerVideoByDuration?: Record<string, number> | null;
|
||||
creditCostPerVideoByResolution?: Record<string, number> | null;
|
||||
};
|
||||
|
||||
function normalizePositiveInteger(value: unknown) {
|
||||
@ -54,6 +56,42 @@ export function normalizeVideoDurationCostMap(
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export function normalizeVideoResolutionKey(value: unknown) {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
if (normalized === "480p" || normalized === "480") return "480p";
|
||||
if (normalized === "720p" || normalized === "720") return "720p";
|
||||
if (normalized === "1080p" || normalized === "1080") return "1080p";
|
||||
if (normalized === "1k") return "1K";
|
||||
if (normalized === "2k") return "2K";
|
||||
if (normalized === "4k") return "4K";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeVideoResolutionCostMap(
|
||||
value: unknown
|
||||
): VideoResolutionCostMap | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
|
||||
const entries: Array<readonly [string, number]> = [];
|
||||
for (const [resolutionKey, costValue] of Object.entries(
|
||||
value as Record<string, unknown>
|
||||
)) {
|
||||
const normalizedResolution = normalizeVideoResolutionKey(resolutionKey);
|
||||
const normalizedCost = Number(costValue);
|
||||
if (!normalizedResolution || !Number.isFinite(normalizedCost)) {
|
||||
continue;
|
||||
}
|
||||
entries.push([
|
||||
normalizedResolution,
|
||||
Math.max(0, Math.round(normalizedCost)),
|
||||
] as const);
|
||||
}
|
||||
|
||||
if (entries.length === 0) return undefined;
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export function getConfiguredVideoDurationOptions(
|
||||
config: VideoImageGenConfigLike | null | undefined
|
||||
): number[] {
|
||||
@ -85,9 +123,20 @@ export function buildVideoDurationCostMap(params: {
|
||||
|
||||
export function getVideoCreditCostFromConfig(
|
||||
config: VideoImageGenConfigLike | null | undefined,
|
||||
duration?: number | null
|
||||
duration?: number | null,
|
||||
resolution?: string | null
|
||||
): number {
|
||||
const normalizedDuration = normalizePositiveInteger(duration);
|
||||
const normalizedResolution = normalizeVideoResolutionKey(resolution);
|
||||
const resolutionCostMap = normalizeVideoResolutionCostMap(
|
||||
config?.creditCostPerVideoByResolution
|
||||
);
|
||||
if (normalizedResolution && resolutionCostMap) {
|
||||
if (Object.prototype.hasOwnProperty.call(resolutionCostMap, normalizedResolution)) {
|
||||
return resolutionCostMap[normalizedResolution] || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const durationOptions = getConfiguredVideoDurationOptions(config);
|
||||
const durationCostMap = normalizeVideoDurationCostMap(
|
||||
config?.creditCostPerVideoByDuration,
|
||||
|
||||
@ -3925,6 +3925,7 @@ interface ImageGenConfig {
|
||||
creditCostPerVideo?: number;
|
||||
videoDurationOptions?: number[];
|
||||
creditCostPerVideoByDuration?: Record<string, number>;
|
||||
creditCostPerVideoByResolution?: Record<string, number>;
|
||||
enableLoadingPlaceholder?: boolean;
|
||||
loadingColor?: string;
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@ export type EnhanceProKreaConfig = {
|
||||
defaultOutputFormat: EnhanceProOutputFormat;
|
||||
allowBatchSize: boolean;
|
||||
maxBatchSize: number;
|
||||
maxUpscaleFactor: number;
|
||||
maxOutputEdge: number;
|
||||
enableAdvancedControls: boolean;
|
||||
presets: EnhanceProPreset[];
|
||||
@ -195,7 +196,10 @@ function normalizeEnhanceProPreset(
|
||||
index: number,
|
||||
configDefaults: Pick<
|
||||
EnhanceProKreaConfig,
|
||||
"defaultModel" | "defaultOutputFormat" | "maxBatchSize"
|
||||
| "defaultModel"
|
||||
| "defaultOutputFormat"
|
||||
| "maxBatchSize"
|
||||
| "maxUpscaleFactor"
|
||||
>,
|
||||
): EnhanceProPreset {
|
||||
const defaults = defaultEnhanceProPresets()[index] || defaultEnhanceProPresets()[0];
|
||||
@ -271,7 +275,7 @@ function normalizeEnhanceProPreset(
|
||||
imageScalingFactor: clampFloat(
|
||||
raw.imageScalingFactor,
|
||||
1,
|
||||
32,
|
||||
configDefaults.maxUpscaleFactor,
|
||||
defaults.imageScalingFactor,
|
||||
),
|
||||
creativity: clampInteger(raw.creativity, 1, 6, defaults.creativity),
|
||||
@ -293,6 +297,7 @@ export function defaultEnhanceProKreaConfig(): EnhanceProKreaConfig {
|
||||
defaultOutputFormat: ENHANCE_PRO_KREA_DEFAULT_OUTPUT_FORMAT,
|
||||
allowBatchSize: true,
|
||||
maxBatchSize: 4,
|
||||
maxUpscaleFactor: 4,
|
||||
maxOutputEdge: 4096,
|
||||
enableAdvancedControls: true,
|
||||
presets: defaultEnhanceProPresets(),
|
||||
@ -311,15 +316,22 @@ export function normalizeEnhanceProKreaConfig(
|
||||
|
||||
const defaultModel = String(raw.defaultModel || "").trim();
|
||||
const defaultOutputFormat = String(raw.defaultOutputFormat || "").trim();
|
||||
const normalizedPresets = Array.isArray(raw.presets)
|
||||
? raw.presets.map((item, index) =>
|
||||
normalizeEnhanceProPreset(item, index, {
|
||||
defaultModel: defaults.defaultModel,
|
||||
defaultOutputFormat: defaults.defaultOutputFormat,
|
||||
maxBatchSize: clampInteger(raw.maxBatchSize, 1, 4, defaults.maxBatchSize),
|
||||
}),
|
||||
)
|
||||
: defaultEnhanceProPresets();
|
||||
const maxUpscaleFactor = clampFloat(
|
||||
raw.maxUpscaleFactor,
|
||||
1,
|
||||
32,
|
||||
defaults.maxUpscaleFactor,
|
||||
);
|
||||
const normalizedPresets = (
|
||||
Array.isArray(raw.presets) ? raw.presets : defaultEnhanceProPresets()
|
||||
).map((item, index) =>
|
||||
normalizeEnhanceProPreset(item, index, {
|
||||
defaultModel: defaults.defaultModel,
|
||||
defaultOutputFormat: defaults.defaultOutputFormat,
|
||||
maxBatchSize: clampInteger(raw.maxBatchSize, 1, 4, defaults.maxBatchSize),
|
||||
maxUpscaleFactor,
|
||||
}),
|
||||
);
|
||||
const presetWithDefault = normalizedPresets.some((preset) => preset.isDefault)
|
||||
? normalizedPresets
|
||||
: normalizedPresets.map((preset, index) => ({
|
||||
@ -342,7 +354,8 @@ export function normalizeEnhanceProKreaConfig(
|
||||
defaultModel:
|
||||
defaultModel === "Recovery" ||
|
||||
defaultModel === "Recovery V2" ||
|
||||
defaultModel === "Reimagine"
|
||||
defaultModel === "Reimagine" ||
|
||||
defaultModel === "Redefine"
|
||||
? defaultModel
|
||||
: defaults.defaultModel,
|
||||
defaultOutputFormat:
|
||||
@ -353,6 +366,7 @@ export function normalizeEnhanceProKreaConfig(
|
||||
: defaults.defaultOutputFormat,
|
||||
allowBatchSize: raw.allowBatchSize !== false,
|
||||
maxBatchSize: clampInteger(raw.maxBatchSize, 1, 4, defaults.maxBatchSize),
|
||||
maxUpscaleFactor,
|
||||
maxOutputEdge: clampInteger(
|
||||
raw.maxOutputEdge,
|
||||
512,
|
||||
@ -376,6 +390,7 @@ export function sanitizeEnhanceProKreaConfigForClient(
|
||||
defaultOutputFormat: config.defaultOutputFormat,
|
||||
allowBatchSize: config.allowBatchSize,
|
||||
maxBatchSize: config.maxBatchSize,
|
||||
maxUpscaleFactor: config.maxUpscaleFactor,
|
||||
maxOutputEdge: config.maxOutputEdge,
|
||||
enableAdvancedControls: config.enableAdvancedControls,
|
||||
presets: config.presets,
|
||||
|
||||
@ -3,7 +3,10 @@ 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";
|
||||
export type EnhanceProPointsChargeMode =
|
||||
| "fixed"
|
||||
| "resolution_bucket"
|
||||
| "scale_factor";
|
||||
|
||||
export type EnhanceProPointsResolutionBucket = {
|
||||
id: string;
|
||||
@ -16,7 +19,9 @@ export type EnhanceProPointsPricingConfig = {
|
||||
enabled: boolean;
|
||||
chargeMode: EnhanceProPointsChargeMode;
|
||||
fixedCost: number;
|
||||
chargeOnlyWhenUpscaling: boolean;
|
||||
batchSizeMultiplierEnabled: boolean;
|
||||
scaleFactorCosts: Record<string, number>;
|
||||
resolutionBuckets: EnhanceProPointsResolutionBucket[];
|
||||
modelMultipliers: Record<EnhanceProKreaModel, number>;
|
||||
};
|
||||
@ -26,6 +31,8 @@ export type PointsSystemConfig = {
|
||||
blockOnInsufficient: boolean;
|
||||
creditCostPer3DModel: number;
|
||||
creditCostPer3DWorld: number;
|
||||
creditCostPer3DWorldMarble11: number;
|
||||
creditCostPer3DWorldMarble11Plus: number;
|
||||
enhanceProPricing: EnhanceProPointsPricingConfig;
|
||||
};
|
||||
|
||||
@ -43,11 +50,21 @@ export const defaultPointsSystemConfig: PointsSystemConfig = {
|
||||
blockOnInsufficient: true,
|
||||
creditCostPer3DModel: 0,
|
||||
creditCostPer3DWorld: 0,
|
||||
creditCostPer3DWorldMarble11: 0,
|
||||
creditCostPer3DWorldMarble11Plus: 0,
|
||||
enhanceProPricing: {
|
||||
enabled: false,
|
||||
chargeMode: "resolution_bucket",
|
||||
chargeMode: "scale_factor",
|
||||
fixedCost: 45,
|
||||
chargeOnlyWhenUpscaling: true,
|
||||
batchSizeMultiplierEnabled: true,
|
||||
scaleFactorCosts: {
|
||||
"1": 0,
|
||||
"1.5": 15,
|
||||
"2": 20,
|
||||
"3": 35,
|
||||
"4": 45,
|
||||
},
|
||||
resolutionBuckets: [
|
||||
{
|
||||
id: "enhance-pro-1mp",
|
||||
@ -129,6 +146,46 @@ function normalizeMegapixels(input: unknown, fallback: number) {
|
||||
: 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,
|
||||
@ -186,12 +243,22 @@ export function normalizeEnhanceProPointsPricingConfig(
|
||||
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: raw.chargeMode === "fixed" ? "fixed" : "resolution_bucket",
|
||||
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: {
|
||||
@ -231,6 +298,8 @@ export function calculateEnhanceProPointsEstimate(
|
||||
height: number;
|
||||
batchSize?: number;
|
||||
model?: EnhanceProKreaModel;
|
||||
upscalingActivated?: boolean;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
) {
|
||||
const pricing = normalizeEnhanceProPointsPricingConfig(
|
||||
@ -245,6 +314,8 @@ export function calculateEnhanceProPointsEstimate(
|
||||
: "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 batchMultiplier = pricing.batchSizeMultiplierEnabled ? batchSize : 1;
|
||||
const modelMultiplier =
|
||||
pricing.modelMultipliers[model] || defaultPointsSystemConfig.enhanceProPricing.modelMultipliers[model];
|
||||
@ -254,10 +325,32 @@ export function calculateEnhanceProPointsEstimate(
|
||||
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 - scaleFactor) < 0.001
|
||||
) ||
|
||||
scaleFactorCostEntries.find((item) => scaleFactor <= item.factor) ||
|
||||
scaleFactorCostEntries[scaleFactorCostEntries.length - 1] ||
|
||||
null;
|
||||
const baseCost =
|
||||
pricing.chargeMode === "fixed"
|
||||
? pricing.fixedCost
|
||||
: Math.max(0, Number(bucket?.cost || 0));
|
||||
pricing.chargeOnlyWhenUpscaling && !upscalingActivated
|
||||
? 0
|
||||
: pricing.chargeMode === "fixed"
|
||||
? pricing.fixedCost
|
||||
: pricing.chargeMode === "scale_factor"
|
||||
? Math.max(0, Number(scaleFactorCost?.cost || 0))
|
||||
: 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;
|
||||
@ -266,6 +359,9 @@ export function calculateEnhanceProPointsEstimate(
|
||||
enabled,
|
||||
chargeable: enabled && cost > 0,
|
||||
chargeMode: pricing.chargeMode,
|
||||
chargeOnlyWhenUpscaling: pricing.chargeOnlyWhenUpscaling,
|
||||
upscalingActivated,
|
||||
scaleFactor,
|
||||
megapixels,
|
||||
batchSize,
|
||||
batchMultiplier,
|
||||
@ -273,11 +369,26 @@ export function calculateEnhanceProPointsEstimate(
|
||||
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 normalizePointsSystemConfig(value: unknown): PointsSystemConfig {
|
||||
if (!value || typeof value !== "object") {
|
||||
return defaultPointsSystemConfig;
|
||||
@ -288,6 +399,9 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
|
||||
creditCostPerEnhancePro?: number;
|
||||
};
|
||||
const legacy3DCost = normalizeCost(raw.creditCostPer3D);
|
||||
const generic3DWorldCost = normalizeCost(
|
||||
raw.creditCostPer3DWorld ?? legacy3DCost
|
||||
);
|
||||
const legacyEnhanceProCost = normalizeCost(raw.creditCostPerEnhancePro);
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
@ -298,6 +412,12 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
|
||||
creditCostPer3DWorld: normalizeCost(
|
||||
raw.creditCostPer3DWorld ?? legacy3DCost
|
||||
),
|
||||
creditCostPer3DWorldMarble11: normalizeCost(
|
||||
raw.creditCostPer3DWorldMarble11 ?? generic3DWorldCost
|
||||
),
|
||||
creditCostPer3DWorldMarble11Plus: normalizeCost(
|
||||
raw.creditCostPer3DWorldMarble11Plus ?? generic3DWorldCost
|
||||
),
|
||||
enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
|
||||
raw.enhanceProPricing,
|
||||
legacyEnhanceProCost
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user