feat: refine krea enhance pro billing

This commit is contained in:
Your Name 2026-04-15 20:29:07 +08:00
parent f75f77105d
commit 8e51697800
16 changed files with 649 additions and 62 deletions

View File

@ -368,6 +368,7 @@ type ImageGenConfig = {
creditCostPerVideo?: number; // 每条视频积分消耗 creditCostPerVideo?: number; // 每条视频积分消耗
videoDurationOptions?: number[]; // 视频秒数选项 videoDurationOptions?: number[]; // 视频秒数选项
creditCostPerVideoByDuration?: Record<string, number>; // 各秒数积分 creditCostPerVideoByDuration?: Record<string, number>; // 各秒数积分
creditCostPerVideoByResolution?: Record<string, number>; // 各分辨率积分
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图 enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green" loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
}; };
@ -387,6 +388,7 @@ function createDefaultImageGenConfig(): ImageGenConfig {
creditCostPerVideo: 0, creditCostPerVideo: 0,
videoDurationOptions: [], videoDurationOptions: [],
creditCostPerVideoByDuration: {}, creditCostPerVideoByDuration: {},
creditCostPerVideoByResolution: {},
enableLoadingPlaceholder: true, enableLoadingPlaceholder: true,
loadingColor: "purple", 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 = ( const updateMembershipConfigState = (
updater: ( updater: (
config: ReturnType<typeof normalizeMembershipConfig> config: ReturnType<typeof normalizeMembershipConfig>
@ -5566,7 +5590,7 @@ export function AdminSettingsClient() {
id={`enhance-pro-preset-scale-${preset.id}`} id={`enhance-pro-preset-scale-${preset.id}`}
type="number" type="number"
min="1" min="1"
max="32" max={enhanceProConfig.maxUpscaleFactor}
step="0.5" step="0.5"
value={preset.imageScalingFactor} value={preset.imageScalingFactor}
onChange={(e) => onChange={(e) =>
@ -5574,7 +5598,7 @@ export function AdminSettingsClient() {
index, index,
"imageScalingFactor", "imageScalingFactor",
Math.min( Math.min(
32, enhanceProConfig.maxUpscaleFactor,
Math.max(1, Number.parseFloat(e.target.value) || 1) Math.max(1, Number.parseFloat(e.target.value) || 1)
) )
) )
@ -8208,6 +8232,60 @@ export function AdminSettingsClient() {
} }
/> />
</div> </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> </div>
<div className="rounded-lg border p-4 space-y-4"> <div className="rounded-lg border p-4 space-y-4">
@ -8242,7 +8320,7 @@ export function AdminSettingsClient() {
<div className="space-y-1"> <div className="space-y-1">
<Label>计费模式</Label> <Label>计费模式</Label>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
固定单价适合先快速上线,分辨率档位更适合贴近增强成本。 可按放大倍率、分辨率档位或固定单价计算本地积分。
</div> </div>
</div> </div>
<div className="mt-2 w-full max-w-xs"> <div className="mt-2 w-full max-w-xs">
@ -8257,7 +8335,9 @@ export function AdminSettingsClient() {
chargeMode: chargeMode:
value === "fixed" value === "fixed"
? "fixed" ? "fixed"
: "resolution_bucket", : value === "resolution_bucket"
? "resolution_bucket"
: "scale_factor",
}, },
}, },
}) })
@ -8268,6 +8348,9 @@ export function AdminSettingsClient() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="scale_factor">
放大倍率
</SelectItem>
<SelectItem value="resolution_bucket"> <SelectItem value="resolution_bucket">
分辨率档位 分辨率档位
</SelectItem> </SelectItem>
@ -8312,6 +8395,66 @@ export function AdminSettingsClient() {
</div> </div>
</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="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label>按候选结果数量线性乘倍</Label> <Label>按候选结果数量线性乘倍</Label>
@ -8485,7 +8628,7 @@ export function AdminSettingsClient() {
</div> </div>
<div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground"> <div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
建议算法:按输出分辨率档位取基础积分,再乘以候选数量和模型倍率,最终向上取整。 建议算法:按放大倍率或输出分辨率取基础积分,再乘以候选数量和模型倍率,最终向上取整。
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -11930,6 +12073,62 @@ export function AdminSettingsClient() {
当前还没有配置秒数档位,将按“每条视频积分消耗”统一扣费。 当前还没有配置秒数档位,将按“每条视频积分消耗”统一扣费。
</p> </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> </div>
)} )}
@ -15006,7 +15205,7 @@ export function AdminSettingsClient() {
</div> </div>
</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"> <div className="space-y-2">
<Label htmlFor="enhanceProMaxBatchSize">最大候选图数量</Label> <Label htmlFor="enhanceProMaxBatchSize">最大候选图数量</Label>
<Select <Select
@ -15037,6 +15236,39 @@ export function AdminSettingsClient() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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"> <div className="space-y-2">
<Label htmlFor="enhanceProMaxOutputEdge">最大输出长边</Label> <Label htmlFor="enhanceProMaxOutputEdge">最大输出长边</Label>
<Input <Input

View File

@ -4087,13 +4087,17 @@ const MODEL_UUID_REGEX =
function getModelCreditCost( function getModelCreditCost(
modelConfig: any, modelConfig: any,
target: "image" | "video", target: "image" | "video",
options?: { duration?: number | null } options?: { duration?: number | null; resolution?: string | null }
): number { ): number {
const config = (modelConfig as any)?.imageGenConfig || {}; const config = (modelConfig as any)?.imageGenConfig || {};
const sourceValue = const sourceValue =
target === "video" target === "video"
? (() => { ? (() => {
const resolvedVideoCost = getVideoCreditCostFromConfig(config, options?.duration); const resolvedVideoCost = getVideoCreditCostFromConfig(
config,
options?.duration,
options?.resolution
);
if (resolvedVideoCost > 0) return resolvedVideoCost; if (resolvedVideoCost > 0) return resolvedVideoCost;
return config.creditCostPerVideo ?? config.creditCostPerImage ?? 0; return config.creditCostPerVideo ?? config.creditCostPerImage ?? 0;
})() })()
@ -4122,6 +4126,7 @@ async function consumeGeneratedVideoPoints(params: {
messageId: string; messageId: string;
modelRefs: Array<string | null | undefined>; modelRefs: Array<string | null | undefined>;
duration?: number | null; duration?: number | null;
resolution?: string | null;
}) { }) {
try { try {
const pointsConfig = await getPointsSystemConfig(); const pointsConfig = await getPointsSystemConfig();
@ -4139,13 +4144,21 @@ async function consumeGeneratedVideoPoints(params: {
const resolved = await resolveBillingModelByReference(ref); const resolved = await resolveBillingModelByReference(ref);
if (!resolved) continue; if (!resolved) continue;
billingModel = resolved; 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; if (!billingModel) return;
const unitCost = getModelCreditCost(billingModel, "video", { const unitCost = getModelCreditCost(billingModel, "video", {
duration: params.duration, duration: params.duration,
resolution: params.resolution,
}); });
if (unitCost <= 0) return; if (unitCost <= 0) return;
@ -4156,7 +4169,7 @@ async function consumeGeneratedVideoPoints(params: {
modelId: billingModel.id, modelId: billingModel.id,
chatId: params.chatId, chatId: params.chatId,
messageId: params.messageId, 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) { if (!consumeResult.ok) {
@ -6795,6 +6808,9 @@ export async function POST(request: Request) {
messageId: finalMessageId, messageId: finalMessageId,
modelRefs: executionResult.modelRefs, modelRefs: executionResult.modelRefs,
duration: Number((finalVideoMetadata as any)?.duration) || undefined, duration: Number((finalVideoMetadata as any)?.duration) || undefined,
resolution:
String((finalVideoMetadata as any)?.resolution || "").trim() ||
undefined,
}); });
scheduleVideoArchive({ scheduleVideoArchive({
@ -8099,6 +8115,9 @@ export async function POST(request: Request) {
messageId: assistantMessageId, messageId: assistantMessageId,
modelRefs: [result.modelRef || presetModelId], modelRefs: [result.modelRef || presetModelId],
duration: Number((result.metadata as any)?.duration) || undefined, duration: Number((result.metadata as any)?.duration) || undefined,
resolution:
String((result.metadata as any)?.resolution || "").trim() ||
undefined,
}); });
scheduleVideoArchive({ scheduleVideoArchive({
@ -8609,6 +8628,12 @@ export async function POST(request: Request) {
Number((result.metadata as any)?.duration) || Number((result.metadata as any)?.duration) ||
Number(effectiveVideoParams?.duration) || Number(effectiveVideoParams?.duration) ||
undefined, undefined,
resolution:
String(
(result.metadata as any)?.resolution ||
effectiveVideoParams?.resolution ||
""
).trim() || undefined,
}); });
if (videoUrl) { if (videoUrl) {
@ -10705,6 +10730,9 @@ export async function POST(request: Request) {
messageId, messageId,
modelRefs: billedModelRefs, modelRefs: billedModelRefs,
duration: Number(effectiveVideoParams?.duration) || undefined, duration: Number(effectiveVideoParams?.duration) || undefined,
resolution:
String(effectiveVideoParams?.resolution || "").trim() ||
undefined,
}); });
} }
@ -11698,6 +11726,7 @@ export async function POST(request: Request) {
messageId: videoMessageId, messageId: videoMessageId,
modelRefs: [finalVideoModelData.id, providerModelData.id, finalModelId], modelRefs: [finalVideoModelData.id, providerModelData.id, finalModelId],
duration: Number(videoOptions.duration) || undefined, duration: Number(videoOptions.duration) || undefined,
resolution: String(videoOptions.resolution || "").trim() || undefined,
}); });
dataStream.write({ dataStream.write({

View File

@ -18,6 +18,7 @@ import {
import { import {
buildVideoDurationCostMap, buildVideoDurationCostMap,
getConfiguredVideoDurationOptions, getConfiguredVideoDurationOptions,
normalizeVideoResolutionCostMap,
} from "@/lib/ai/video-model-config"; } from "@/lib/ai/video-model-config";
// 🆕 图片生成模型特性配置 Schema // 🆕 图片生成模型特性配置 Schema
@ -35,6 +36,7 @@ const imageGenConfigSchema = z.object({
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(),
creditCostPerVideoByDuration: z.record(z.string(), z.number().min(0).max(100000)).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(), enableLoadingPlaceholder: z.boolean().optional(),
loadingColor: z.string().optional(), loadingColor: z.string().optional(),
}).optional(); }).optional();
@ -53,6 +55,9 @@ function normalizeImageGenConfig(config: z.infer<typeof imageGenConfigSchema>) {
...config, ...config,
videoDurationOptions: videoDurationOptions.length ? videoDurationOptions : undefined, videoDurationOptions: videoDurationOptions.length ? videoDurationOptions : undefined,
creditCostPerVideoByDuration, creditCostPerVideoByDuration,
creditCostPerVideoByResolution: normalizeVideoResolutionCostMap(
config.creditCostPerVideoByResolution
),
}; };
} }

View File

@ -7,6 +7,7 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config"; import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config";
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { get3DWorldCreditCost } from "@/lib/points/config";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
import { import {
disableWorldLabsProvider, disableWorldLabsProvider,
@ -155,12 +156,17 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 }); return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 });
} }
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
const resolvedWorldModel = resolveThreeDModelRealId(
readOptionalTrimmedText(body.model),
modelAliasConfig.worldModels,
);
const pointsConfig = await getPointsSystemConfig(); const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(mode); const shouldChargePoints = shouldCharge3DPoints(mode);
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DWorld)) const unitCost = get3DWorldCreditCost(
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DWorld))) pointsConfig,
: 0; resolvedWorldModel || "marble-1.1",
);
if ( if (
shouldChargePoints && shouldChargePoints &&
@ -193,10 +199,6 @@ export async function POST(request: Request) {
readOptionalTrimmedText(body.displayName) || readOptionalTrimmedText(body.displayName) ||
readOptionalTrimmedText(body.prompt) || readOptionalTrimmedText(body.prompt) ||
`World ${new Date().toLocaleString("zh-CN")}`; `World ${new Date().toLocaleString("zh-CN")}`;
const resolvedWorldModel = resolveThreeDModelRealId(
readOptionalTrimmedText(body.model),
modelAliasConfig.worldModels,
);
const worldPrompt = buildWorldPrompt(body, mode); const worldPrompt = buildWorldPrompt(body, mode);
const candidates = preferredProviderId const candidates = preferredProviderId
@ -266,8 +268,8 @@ export async function POST(request: Request) {
userId: session.user.id, userId: session.user.id,
amount: unitCost, amount: unitCost,
reason: "world3d_generation", reason: "world3d_generation",
modelId: "worldlabs-3d", modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
note: "3D世界生成消耗积分", note: `3D世界生成消耗积分(${resolvedWorldModel || "marble-1.1"})`,
}); });
} catch (error) { } catch (error) {
console.error("[Points] Failed to consume World Labs 3D points:", error); console.error("[Points] Failed to consume World Labs 3D points:", error);

View File

@ -313,7 +313,12 @@ async function createEnhanceProJob(params: {
crop_to_fill: normalizeBoolean(params.requestBody.crop_to_fill, false), crop_to_fill: normalizeBoolean(params.requestBody.crop_to_fill, false),
model: params.requestBody.model || params.config.defaultModel, model: params.requestBody.model || params.config.defaultModel,
upscaling_activated: normalizeBoolean(params.requestBody.upscaling_activated, false), 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), creativity: clampInteger(params.requestBody.creativity, 1, 6, 3),
texture: clampInteger(params.requestBody.texture, 1, 5, 3), texture: clampInteger(params.requestBody.texture, 1, 5, 3),
sharpen: clampNumber(params.requestBody.sharpen, 0, 1, 0.5), sharpen: clampNumber(params.requestBody.sharpen, 0, 1, 0.5),
@ -647,12 +652,21 @@ export async function POST(request: NextRequest) {
body.model === "Redefine" body.model === "Redefine"
? body.model ? body.model
: config.defaultModel; : config.defaultModel;
const upscalingActivated = body.upscaling_activated === true;
const imageScalingFactor = clampNumber(
body.image_scaling_factor,
1,
config.maxUpscaleFactor,
1,
);
const pointsConfig = await getPointsSystemConfig(); const pointsConfig = await getPointsSystemConfig();
const pointsEstimate = calculateEnhanceProPointsEstimate(pointsConfig, { const pointsEstimate = calculateEnhanceProPointsEstimate(pointsConfig, {
width: clampedDimensions.width, width: clampedDimensions.width,
height: clampedDimensions.height, height: clampedDimensions.height,
batchSize, batchSize,
model: selectedModel, model: selectedModel,
upscalingActivated,
scaleFactor: imageScalingFactor,
}); });
if (pointsEstimate.chargeable && pointsConfig.blockOnInsufficient !== false) { if (pointsEstimate.chargeable && pointsConfig.blockOnInsufficient !== false) {
@ -683,6 +697,8 @@ export async function POST(request: NextRequest) {
...body, ...body,
batchSize, batchSize,
model: selectedModel, model: selectedModel,
upscaling_activated: upscalingActivated,
image_scaling_factor: imageScalingFactor,
}, },
sourceImageUrl: sourceInput.sourceImageUrl, sourceImageUrl: sourceInput.sourceImageUrl,
width: clampedDimensions.width, width: clampedDimensions.width,

View File

@ -9764,14 +9764,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS ? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
: "h-9 w-9 rounded-xl" : "h-9 w-9 rounded-xl"
)} )}
disabled={!selectedImageNode} onClick={() => router.push(`/canvas/${id}/realtime?mode=enhance-pro`)}
onClick={() => {
if (!selectedImageNode) {
toast.info("请先选中一张图片");
return;
}
router.push(`/canvas/${id}/realtime?mode=enhance-pro`);
}}
size="icon" size="icon"
variant="ghost" variant="ghost"
> >
@ -9781,7 +9774,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
<TooltipContent side="top"> <TooltipContent side="top">
{selectedImageNode {selectedImageNode
? "图像增强 Pro" ? "图像增强 Pro"
: "先选中图片后进入图像增强 Pro"} : "进入图像增强 Pro 后上传图片"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}

View File

@ -40,6 +40,7 @@ export default function CanvasRealtimePage() {
const handleApplyResult = useCallback( const handleApplyResult = useCallback(
async ({ async ({
imageUrl, imageUrl,
sourceImageUrl,
prompt, prompt,
title, title,
model, model,
@ -47,6 +48,7 @@ export default function CanvasRealtimePage() {
height, height,
}: { }: {
imageUrl: string; imageUrl: string;
sourceImageUrl?: string;
prompt: string; prompt: string;
title?: string; title?: string;
model?: string; model?: string;
@ -74,6 +76,7 @@ export default function CanvasRealtimePage() {
const targetHeight = hasNaturalSize const targetHeight = hasNaturalSize
? Math.max(140, Math.round(safeHeight * scale)) ? Math.max(140, Math.round(safeHeight * scale))
: fallbackSize; : fallbackSize;
const compareSourceUrl = String(sourceImageUrl || "").trim();
const x = const x =
(stageWidth * 0.45 - viewportX) / viewportZoom - targetWidth / 2; (stageWidth * 0.45 - viewportX) / viewportZoom - targetWidth / 2;
@ -86,10 +89,16 @@ export default function CanvasRealtimePage() {
size: { width: targetWidth, height: targetHeight }, size: { width: targetWidth, height: targetHeight },
data: { data: {
src: imageUrl, src: imageUrl,
originalSrc: compareSourceUrl || undefined,
status: "completed", status: "completed",
title: title || "实时画布结果", title: title || "实时画布结果",
prompt, prompt,
model: model || "bfl/flux-2", model: model || "bfl/flux-2",
metadata: compareSourceUrl
? {
compareSourceUrl,
}
: undefined,
}, },
connections: [], connections: [],
}); });

View File

@ -68,6 +68,7 @@ type EnhanceProClientConfig = {
defaultOutputFormat: EnhanceProOutputFormat; defaultOutputFormat: EnhanceProOutputFormat;
allowBatchSize: boolean; allowBatchSize: boolean;
maxBatchSize: number; maxBatchSize: number;
maxUpscaleFactor: number;
maxOutputEdge: number; maxOutputEdge: number;
enableAdvancedControls: boolean; enableAdvancedControls: boolean;
presets: EnhanceProPreset[]; presets: EnhanceProPreset[];
@ -109,6 +110,7 @@ const FALLBACK_CONFIG: EnhanceProClientConfig = {
defaultOutputFormat: "jpg", defaultOutputFormat: "jpg",
allowBatchSize: true, allowBatchSize: true,
maxBatchSize: 4, maxBatchSize: 4,
maxUpscaleFactor: 4,
maxOutputEdge: 4096, maxOutputEdge: 4096,
enableAdvancedControls: true, enableAdvancedControls: true,
presets: defaultEnhanceProPresets(), presets: defaultEnhanceProPresets(),
@ -244,7 +246,11 @@ function applyPresetToForm(
crop_to_fill: preset.cropToFill, crop_to_fill: preset.cropToFill,
model: preset.model, model: preset.model,
upscaling_activated: preset.upscalingActivated, 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), creativity: clampNumber(Math.round(preset.creativity), 1, 6),
texture: clampNumber(Math.round(preset.texture), 1, 5), texture: clampNumber(Math.round(preset.texture), 1, 5),
sharpen: clampNumber(preset.sharpen, 0, 1), sharpen: clampNumber(preset.sharpen, 0, 1),
@ -362,6 +368,7 @@ interface EnhanceProWorkspaceProps {
onRequestAddImage?: () => void; onRequestAddImage?: () => void;
onApplyResult: (payload: { onApplyResult: (payload: {
imageUrl: string; imageUrl: string;
sourceImageUrl?: string;
prompt: string; prompt: string;
title?: string; title?: string;
model?: string; model?: string;
@ -450,9 +457,19 @@ export function EnhanceProWorkspace({
height: formState.height, height: formState.height,
batchSize: formState.batchSize, batchSize: formState.batchSize,
model: formState.model, 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(() => { useEffect(() => {
@ -578,7 +595,7 @@ export function EnhanceProWorkspace({
image_scaling_factor: clampNumber( image_scaling_factor: clampNumber(
formState.image_scaling_factor, formState.image_scaling_factor,
1, 1,
32 config.maxUpscaleFactor
), ),
creativity: clampNumber(Math.round(formState.creativity), 1, 6), creativity: clampNumber(Math.round(formState.creativity), 1, 6),
texture: clampNumber(Math.round(formState.texture), 1, 5), texture: clampNumber(Math.round(formState.texture), 1, 5),
@ -613,6 +630,7 @@ export function EnhanceProWorkspace({
} }
onApplyResult({ onApplyResult({
imageUrl: selectedGeneratedImage.originalUrl, imageUrl: selectedGeneratedImage.originalUrl,
sourceImageUrl: sourceImageUrl || undefined,
prompt: formState.prompt.trim() || "图像增强 Pro", prompt: formState.prompt.trim() || "图像增强 Pro",
title: "图像增强 Pro 结果", title: "图像增强 Pro 结果",
model: selectedPreset?.model || config.defaultModel, 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"> <div className="relative flex flex-col items-center justify-center gap-3 px-6 text-center">
<Button <Button
className={cn( 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 PRIMARY_ACTION_BUTTON_CLASS
)} )}
disabled={!onRequestAddImage} disabled={!onRequestAddImage}
onClick={onRequestAddImage} onClick={onRequestAddImage}
type="button" 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> </Button>
<div className="text-[#666666] text-[13px] leading-6"> <div className="text-[#666666] text-[13px] leading-6">
@ -804,6 +822,9 @@ export function EnhanceProWorkspace({
{pointsEstimate.chargeMode === "resolution_bucket" && pointsEstimate.bucketName {pointsEstimate.chargeMode === "resolution_bucket" && pointsEstimate.bucketName
? ` · ${pointsEstimate.bucketName}` ? ` · ${pointsEstimate.bucketName}`
: ""} : ""}
{pointsEstimate.chargeMode === "scale_factor"
? ` · ${pointsEstimate.scaleFactor}x`
: ""}
</div> </div>
) : null} ) : 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"> <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 <Input
className={COMPACT_FIELD_CLASS} className={COMPACT_FIELD_CLASS}
id="enhance-workspace-scale" id="enhance-workspace-scale"
max="32" max={config.maxUpscaleFactor}
min="1" min="1"
onChange={(event) => onChange={(event) =>
handleNumericField( handleNumericField(
@ -1138,7 +1159,7 @@ export function EnhanceProWorkspace({
clampNumber( clampNumber(
Number.parseFloat(event.target.value) || 1, Number.parseFloat(event.target.value) || 1,
1, 1,
32 config.maxUpscaleFactor
) )
) )
} }

View File

@ -148,6 +148,7 @@ type RealtimeCanvasDialogProps = {
initialMode?: RealtimeWorkspaceMode; initialMode?: RealtimeWorkspaceMode;
onApplyResult: (payload: { onApplyResult: (payload: {
imageUrl: string; imageUrl: string;
sourceImageUrl?: string;
prompt: string; prompt: string;
title?: string; title?: string;
model?: string; model?: string;
@ -2956,6 +2957,7 @@ export function RealtimeCanvasDialog({
const handleApplyEnhanceResult = useCallback( const handleApplyEnhanceResult = useCallback(
(payload: { (payload: {
imageUrl: string; imageUrl: string;
sourceImageUrl?: string;
prompt: string; prompt: string;
title?: string; title?: string;
model?: string; model?: string;

View File

@ -38,6 +38,7 @@ type EnhanceProClientConfig = {
defaultOutputFormat: EnhanceProOutputFormat; defaultOutputFormat: EnhanceProOutputFormat;
allowBatchSize: boolean; allowBatchSize: boolean;
maxBatchSize: number; maxBatchSize: number;
maxUpscaleFactor: number;
maxOutputEdge: number; maxOutputEdge: number;
enableAdvancedControls: boolean; enableAdvancedControls: boolean;
presets: EnhanceProPreset[]; presets: EnhanceProPreset[];
@ -73,6 +74,7 @@ const FALLBACK_CONFIG: EnhanceProClientConfig = {
defaultOutputFormat: "jpg", defaultOutputFormat: "jpg",
allowBatchSize: true, allowBatchSize: true,
maxBatchSize: 4, maxBatchSize: 4,
maxUpscaleFactor: 4,
maxOutputEdge: 4096, maxOutputEdge: 4096,
enableAdvancedControls: true, enableAdvancedControls: true,
presets: defaultEnhanceProPresets(), presets: defaultEnhanceProPresets(),
@ -151,7 +153,11 @@ function applyPresetToForm(
crop_to_fill: preset.cropToFill, crop_to_fill: preset.cropToFill,
model: preset.model, model: preset.model,
upscaling_activated: preset.upscalingActivated, 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), creativity: clampNumber(Math.round(preset.creativity), 1, 6),
texture: clampNumber(Math.round(preset.texture), 1, 5), texture: clampNumber(Math.round(preset.texture), 1, 5),
sharpen: clampNumber(preset.sharpen, 0, 1), sharpen: clampNumber(preset.sharpen, 0, 1),
@ -274,7 +280,11 @@ export function EnhanceProDialog({
crop_to_fill: formState.crop_to_fill, crop_to_fill: formState.crop_to_fill,
model: formState.model, model: formState.model,
upscaling_activated: formState.upscaling_activated, 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), creativity: clampNumber(Math.round(formState.creativity), 1, 6),
texture: clampNumber(Math.round(formState.texture), 1, 5), texture: clampNumber(Math.round(formState.texture), 1, 5),
sharpen: clampNumber(formState.sharpen, 0, 1), sharpen: clampNumber(formState.sharpen, 0, 1),
@ -549,13 +559,17 @@ export function EnhanceProDialog({
id="enhance-pro-image-scaling" id="enhance-pro-image-scaling"
type="number" type="number"
min="1" min="1"
max="32" max={config.maxUpscaleFactor}
step="0.5" step="0.5"
value={formState.image_scaling_factor} value={formState.image_scaling_factor}
onChange={(event) => onChange={(event) =>
handleNumericField( handleNumericField(
"image_scaling_factor", "image_scaling_factor",
clampNumber(Number.parseFloat(event.target.value) || 1, 1, 32), clampNumber(
Number.parseFloat(event.target.value) || 1,
1,
config.maxUpscaleFactor
),
) )
} }
/> />

View File

@ -207,6 +207,7 @@ function videoPreset(
creditCostPerVideo: 0, creditCostPerVideo: 0,
videoDurationOptions: durationOptions, videoDurationOptions: durationOptions,
creditCostPerVideoByDuration: {}, creditCostPerVideoByDuration: {},
creditCostPerVideoByResolution: {},
enableLoadingPlaceholder: true, enableLoadingPlaceholder: true,
loadingColor: "blue", loadingColor: "blue",
}, },

View File

@ -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) { async function inferReferenceAspectRatioFromImageUrl(url?: string | null) {
const normalized = normalizeText(url); const normalized = normalizeText(url);
if (!normalized || !/^https?:\/\//i.test(normalized)) return undefined; if (!normalized || !/^https?:\/\//i.test(normalized)) return undefined;
@ -637,7 +710,7 @@ async function requestKreaJob(
endpoint, endpoint,
status: response.status, status: response.status,
requestFields: Object.keys(body), requestFields: Object.keys(body),
response: payload, response: stringifyForLog(payload || rawText),
}); });
const message = const message =
typeof payload?.error === "string" typeof payload?.error === "string"
@ -915,6 +988,11 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
const normalizedVideoUrls = ( const normalizedVideoUrls = (
await Promise.all(videoUrls.map((url) => normalizeAssetUrl(url))) await Promise.all(videoUrls.map((url) => normalizeAssetUrl(url)))
).filter(Boolean); ).filter(Boolean);
const kreaImageUrls = (
await Promise.all(
normalizedImageUrls.map((url) => uploadKreaReferenceAsset(baseURL, apiKey, url))
)
).filter(Boolean);
const requestFields = const requestFields =
preset?.requestFields || preset?.requestFields ||
(kind === "video" (kind === "video"
@ -933,7 +1011,7 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
endpoint, endpoint,
kind, kind,
prompt, prompt,
imageUrls: normalizedImageUrls, imageUrls: kreaImageUrls,
requestFields, requestFields,
}); });
const { body, imageSize } = buildKreaBody({ const { body, imageSize } = buildKreaBody({
@ -942,7 +1020,7 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
requestFields, requestFields,
requiredFields, requiredFields,
prompt: promptForKrea, prompt: promptForKrea,
imageUrls: normalizedImageUrls, imageUrls: kreaImageUrls,
videoUrls: normalizedVideoUrls, videoUrls: normalizedVideoUrls,
referenceAspectRatio, referenceAspectRatio,
imageParams: options.imageParams, imageParams: options.imageParams,

View File

@ -1,9 +1,11 @@
export type VideoDurationCostMap = Record<string, number>; export type VideoDurationCostMap = Record<string, number>;
export type VideoResolutionCostMap = Record<string, number>;
export type VideoImageGenConfigLike = { export type VideoImageGenConfigLike = {
creditCostPerVideo?: number | null; creditCostPerVideo?: number | null;
videoDurationOptions?: number[] | null; videoDurationOptions?: number[] | null;
creditCostPerVideoByDuration?: Record<string, number> | null; creditCostPerVideoByDuration?: Record<string, number> | null;
creditCostPerVideoByResolution?: Record<string, number> | null;
}; };
function normalizePositiveInteger(value: unknown) { function normalizePositiveInteger(value: unknown) {
@ -54,6 +56,42 @@ export function normalizeVideoDurationCostMap(
return Object.fromEntries(entries); 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( export function getConfiguredVideoDurationOptions(
config: VideoImageGenConfigLike | null | undefined config: VideoImageGenConfigLike | null | undefined
): number[] { ): number[] {
@ -85,9 +123,20 @@ export function buildVideoDurationCostMap(params: {
export function getVideoCreditCostFromConfig( export function getVideoCreditCostFromConfig(
config: VideoImageGenConfigLike | null | undefined, config: VideoImageGenConfigLike | null | undefined,
duration?: number | null duration?: number | null,
resolution?: string | null
): number { ): number {
const normalizedDuration = normalizePositiveInteger(duration); 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 durationOptions = getConfiguredVideoDurationOptions(config);
const durationCostMap = normalizeVideoDurationCostMap( const durationCostMap = normalizeVideoDurationCostMap(
config?.creditCostPerVideoByDuration, config?.creditCostPerVideoByDuration,

View File

@ -3925,6 +3925,7 @@ interface ImageGenConfig {
creditCostPerVideo?: number; creditCostPerVideo?: number;
videoDurationOptions?: number[]; videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>; creditCostPerVideoByDuration?: Record<string, number>;
creditCostPerVideoByResolution?: Record<string, number>;
enableLoadingPlaceholder?: boolean; enableLoadingPlaceholder?: boolean;
loadingColor?: string; loadingColor?: string;
} }

View File

@ -49,6 +49,7 @@ export type EnhanceProKreaConfig = {
defaultOutputFormat: EnhanceProOutputFormat; defaultOutputFormat: EnhanceProOutputFormat;
allowBatchSize: boolean; allowBatchSize: boolean;
maxBatchSize: number; maxBatchSize: number;
maxUpscaleFactor: number;
maxOutputEdge: number; maxOutputEdge: number;
enableAdvancedControls: boolean; enableAdvancedControls: boolean;
presets: EnhanceProPreset[]; presets: EnhanceProPreset[];
@ -195,7 +196,10 @@ function normalizeEnhanceProPreset(
index: number, index: number,
configDefaults: Pick< configDefaults: Pick<
EnhanceProKreaConfig, EnhanceProKreaConfig,
"defaultModel" | "defaultOutputFormat" | "maxBatchSize" | "defaultModel"
| "defaultOutputFormat"
| "maxBatchSize"
| "maxUpscaleFactor"
>, >,
): EnhanceProPreset { ): EnhanceProPreset {
const defaults = defaultEnhanceProPresets()[index] || defaultEnhanceProPresets()[0]; const defaults = defaultEnhanceProPresets()[index] || defaultEnhanceProPresets()[0];
@ -271,7 +275,7 @@ function normalizeEnhanceProPreset(
imageScalingFactor: clampFloat( imageScalingFactor: clampFloat(
raw.imageScalingFactor, raw.imageScalingFactor,
1, 1,
32, configDefaults.maxUpscaleFactor,
defaults.imageScalingFactor, defaults.imageScalingFactor,
), ),
creativity: clampInteger(raw.creativity, 1, 6, defaults.creativity), creativity: clampInteger(raw.creativity, 1, 6, defaults.creativity),
@ -293,6 +297,7 @@ export function defaultEnhanceProKreaConfig(): EnhanceProKreaConfig {
defaultOutputFormat: ENHANCE_PRO_KREA_DEFAULT_OUTPUT_FORMAT, defaultOutputFormat: ENHANCE_PRO_KREA_DEFAULT_OUTPUT_FORMAT,
allowBatchSize: true, allowBatchSize: true,
maxBatchSize: 4, maxBatchSize: 4,
maxUpscaleFactor: 4,
maxOutputEdge: 4096, maxOutputEdge: 4096,
enableAdvancedControls: true, enableAdvancedControls: true,
presets: defaultEnhanceProPresets(), presets: defaultEnhanceProPresets(),
@ -311,15 +316,22 @@ export function normalizeEnhanceProKreaConfig(
const defaultModel = String(raw.defaultModel || "").trim(); const defaultModel = String(raw.defaultModel || "").trim();
const defaultOutputFormat = String(raw.defaultOutputFormat || "").trim(); const defaultOutputFormat = String(raw.defaultOutputFormat || "").trim();
const normalizedPresets = Array.isArray(raw.presets) const maxUpscaleFactor = clampFloat(
? raw.presets.map((item, index) => raw.maxUpscaleFactor,
normalizeEnhanceProPreset(item, index, { 1,
defaultModel: defaults.defaultModel, 32,
defaultOutputFormat: defaults.defaultOutputFormat, defaults.maxUpscaleFactor,
maxBatchSize: clampInteger(raw.maxBatchSize, 1, 4, defaults.maxBatchSize), );
}), const normalizedPresets = (
) Array.isArray(raw.presets) ? raw.presets : defaultEnhanceProPresets()
: 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) const presetWithDefault = normalizedPresets.some((preset) => preset.isDefault)
? normalizedPresets ? normalizedPresets
: normalizedPresets.map((preset, index) => ({ : normalizedPresets.map((preset, index) => ({
@ -342,7 +354,8 @@ export function normalizeEnhanceProKreaConfig(
defaultModel: defaultModel:
defaultModel === "Recovery" || defaultModel === "Recovery" ||
defaultModel === "Recovery V2" || defaultModel === "Recovery V2" ||
defaultModel === "Reimagine" defaultModel === "Reimagine" ||
defaultModel === "Redefine"
? defaultModel ? defaultModel
: defaults.defaultModel, : defaults.defaultModel,
defaultOutputFormat: defaultOutputFormat:
@ -353,6 +366,7 @@ export function normalizeEnhanceProKreaConfig(
: defaults.defaultOutputFormat, : defaults.defaultOutputFormat,
allowBatchSize: raw.allowBatchSize !== false, allowBatchSize: raw.allowBatchSize !== false,
maxBatchSize: clampInteger(raw.maxBatchSize, 1, 4, defaults.maxBatchSize), maxBatchSize: clampInteger(raw.maxBatchSize, 1, 4, defaults.maxBatchSize),
maxUpscaleFactor,
maxOutputEdge: clampInteger( maxOutputEdge: clampInteger(
raw.maxOutputEdge, raw.maxOutputEdge,
512, 512,
@ -376,6 +390,7 @@ export function sanitizeEnhanceProKreaConfigForClient(
defaultOutputFormat: config.defaultOutputFormat, defaultOutputFormat: config.defaultOutputFormat,
allowBatchSize: config.allowBatchSize, allowBatchSize: config.allowBatchSize,
maxBatchSize: config.maxBatchSize, maxBatchSize: config.maxBatchSize,
maxUpscaleFactor: config.maxUpscaleFactor,
maxOutputEdge: config.maxOutputEdge, maxOutputEdge: config.maxOutputEdge,
enableAdvancedControls: config.enableAdvancedControls, enableAdvancedControls: config.enableAdvancedControls,
presets: config.presets, presets: config.presets,

View File

@ -3,7 +3,10 @@ import type { EnhanceProKreaModel } from "@/lib/enhance-pro/config";
export const POINTS_SYSTEM_CONFIG_KEY = "points_system_config"; export const POINTS_SYSTEM_CONFIG_KEY = "points_system_config";
export const POINTS_PACKAGES_KEY = "points_packages"; export const POINTS_PACKAGES_KEY = "points_packages";
export type EnhanceProPointsChargeMode = "fixed" | "resolution_bucket"; export type EnhanceProPointsChargeMode =
| "fixed"
| "resolution_bucket"
| "scale_factor";
export type EnhanceProPointsResolutionBucket = { export type EnhanceProPointsResolutionBucket = {
id: string; id: string;
@ -16,7 +19,9 @@ export type EnhanceProPointsPricingConfig = {
enabled: boolean; enabled: boolean;
chargeMode: EnhanceProPointsChargeMode; chargeMode: EnhanceProPointsChargeMode;
fixedCost: number; fixedCost: number;
chargeOnlyWhenUpscaling: boolean;
batchSizeMultiplierEnabled: boolean; batchSizeMultiplierEnabled: boolean;
scaleFactorCosts: Record<string, number>;
resolutionBuckets: EnhanceProPointsResolutionBucket[]; resolutionBuckets: EnhanceProPointsResolutionBucket[];
modelMultipliers: Record<EnhanceProKreaModel, number>; modelMultipliers: Record<EnhanceProKreaModel, number>;
}; };
@ -26,6 +31,8 @@ export type PointsSystemConfig = {
blockOnInsufficient: boolean; blockOnInsufficient: boolean;
creditCostPer3DModel: number; creditCostPer3DModel: number;
creditCostPer3DWorld: number; creditCostPer3DWorld: number;
creditCostPer3DWorldMarble11: number;
creditCostPer3DWorldMarble11Plus: number;
enhanceProPricing: EnhanceProPointsPricingConfig; enhanceProPricing: EnhanceProPointsPricingConfig;
}; };
@ -43,11 +50,21 @@ export const defaultPointsSystemConfig: PointsSystemConfig = {
blockOnInsufficient: true, blockOnInsufficient: true,
creditCostPer3DModel: 0, creditCostPer3DModel: 0,
creditCostPer3DWorld: 0, creditCostPer3DWorld: 0,
creditCostPer3DWorldMarble11: 0,
creditCostPer3DWorldMarble11Plus: 0,
enhanceProPricing: { enhanceProPricing: {
enabled: false, enabled: false,
chargeMode: "resolution_bucket", chargeMode: "scale_factor",
fixedCost: 45, fixedCost: 45,
chargeOnlyWhenUpscaling: true,
batchSizeMultiplierEnabled: true, batchSizeMultiplierEnabled: true,
scaleFactorCosts: {
"1": 0,
"1.5": 15,
"2": 20,
"3": 35,
"4": 45,
},
resolutionBuckets: [ resolutionBuckets: [
{ {
id: "enhance-pro-1mp", id: "enhance-pro-1mp",
@ -129,6 +146,46 @@ function normalizeMegapixels(input: unknown, fallback: number) {
: fallback; : 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( function normalizeEnhanceProResolutionBucket(
value: unknown, value: unknown,
index: number, index: number,
@ -186,12 +243,22 @@ export function normalizeEnhanceProPointsPricingConfig(
raw.modelMultipliers && typeof raw.modelMultipliers === "object" raw.modelMultipliers && typeof raw.modelMultipliers === "object"
? raw.modelMultipliers ? raw.modelMultipliers
: defaults.modelMultipliers; : defaults.modelMultipliers;
const chargeMode: EnhanceProPointsChargeMode =
raw.chargeMode === "fixed" ||
raw.chargeMode === "resolution_bucket" ||
raw.chargeMode === "scale_factor"
? raw.chargeMode
: defaults.chargeMode;
return { return {
enabled: raw.enabled === true, enabled: raw.enabled === true,
chargeMode: raw.chargeMode === "fixed" ? "fixed" : "resolution_bucket", chargeMode,
fixedCost: normalizeCost(raw.fixedCost ?? legacyFixedCost ?? defaults.fixedCost), fixedCost: normalizeCost(raw.fixedCost ?? legacyFixedCost ?? defaults.fixedCost),
chargeOnlyWhenUpscaling: raw.chargeOnlyWhenUpscaling !== false,
batchSizeMultiplierEnabled: raw.batchSizeMultiplierEnabled !== false, batchSizeMultiplierEnabled: raw.batchSizeMultiplierEnabled !== false,
scaleFactorCosts: normalizeEnhanceProScaleFactorCosts(
raw.scaleFactorCosts
),
resolutionBuckets: resolutionBuckets:
resolutionBuckets.length > 0 ? resolutionBuckets : defaults.resolutionBuckets, resolutionBuckets.length > 0 ? resolutionBuckets : defaults.resolutionBuckets,
modelMultipliers: { modelMultipliers: {
@ -231,6 +298,8 @@ export function calculateEnhanceProPointsEstimate(
height: number; height: number;
batchSize?: number; batchSize?: number;
model?: EnhanceProKreaModel; model?: EnhanceProKreaModel;
upscalingActivated?: boolean;
scaleFactor?: number;
} }
) { ) {
const pricing = normalizeEnhanceProPointsPricingConfig( const pricing = normalizeEnhanceProPointsPricingConfig(
@ -245,6 +314,8 @@ export function calculateEnhanceProPointsEstimate(
: "Redefine"; : "Redefine";
const megapixels = getEnhanceProResolutionMegapixels(params.width, params.height); const megapixels = getEnhanceProResolutionMegapixels(params.width, params.height);
const batchSize = Math.max(1, Math.round(Number(params.batchSize) || 1)); 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 batchMultiplier = pricing.batchSizeMultiplierEnabled ? batchSize : 1;
const modelMultiplier = const modelMultiplier =
pricing.modelMultipliers[model] || defaultPointsSystemConfig.enhanceProPricing.modelMultipliers[model]; pricing.modelMultipliers[model] || defaultPointsSystemConfig.enhanceProPricing.modelMultipliers[model];
@ -254,10 +325,32 @@ export function calculateEnhanceProPointsEstimate(
pricing.resolutionBuckets[pricing.resolutionBuckets.length - 1] || pricing.resolutionBuckets[pricing.resolutionBuckets.length - 1] ||
null null
: 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 = const baseCost =
pricing.chargeMode === "fixed" pricing.chargeOnlyWhenUpscaling && !upscalingActivated
? pricing.fixedCost ? 0
: Math.max(0, Number(bucket?.cost || 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 rawCost = baseCost * batchMultiplier * modelMultiplier;
const cost = baseCost > 0 ? Math.max(1, Math.ceil(rawCost)) : 0; const cost = baseCost > 0 ? Math.max(1, Math.ceil(rawCost)) : 0;
const enabled = pointsConfig.enabled === true && pricing.enabled === true; const enabled = pointsConfig.enabled === true && pricing.enabled === true;
@ -266,6 +359,9 @@ export function calculateEnhanceProPointsEstimate(
enabled, enabled,
chargeable: enabled && cost > 0, chargeable: enabled && cost > 0,
chargeMode: pricing.chargeMode, chargeMode: pricing.chargeMode,
chargeOnlyWhenUpscaling: pricing.chargeOnlyWhenUpscaling,
upscalingActivated,
scaleFactor,
megapixels, megapixels,
batchSize, batchSize,
batchMultiplier, batchMultiplier,
@ -273,11 +369,26 @@ export function calculateEnhanceProPointsEstimate(
modelMultiplier, modelMultiplier,
baseCost, baseCost,
cost, cost,
scaleFactorCostKey: scaleFactorCost?.factorKey || null,
bucketId: bucket?.id || null, bucketId: bucket?.id || null,
bucketName: bucket?.name || 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 { export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig {
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return defaultPointsSystemConfig; return defaultPointsSystemConfig;
@ -288,6 +399,9 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
creditCostPerEnhancePro?: number; creditCostPerEnhancePro?: number;
}; };
const legacy3DCost = normalizeCost(raw.creditCostPer3D); const legacy3DCost = normalizeCost(raw.creditCostPer3D);
const generic3DWorldCost = normalizeCost(
raw.creditCostPer3DWorld ?? legacy3DCost
);
const legacyEnhanceProCost = normalizeCost(raw.creditCostPerEnhancePro); const legacyEnhanceProCost = normalizeCost(raw.creditCostPerEnhancePro);
return { return {
enabled: raw.enabled === true, enabled: raw.enabled === true,
@ -298,6 +412,12 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
creditCostPer3DWorld: normalizeCost( creditCostPer3DWorld: normalizeCost(
raw.creditCostPer3DWorld ?? legacy3DCost raw.creditCostPer3DWorld ?? legacy3DCost
), ),
creditCostPer3DWorldMarble11: normalizeCost(
raw.creditCostPer3DWorldMarble11 ?? generic3DWorldCost
),
creditCostPer3DWorldMarble11Plus: normalizeCost(
raw.creditCostPer3DWorldMarble11Plus ?? generic3DWorldCost
),
enhanceProPricing: normalizeEnhanceProPointsPricingConfig( enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
raw.enhanceProPricing, raw.enhanceProPricing,
legacyEnhanceProCost legacyEnhanceProCost