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; // 每条视频积分消耗
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

View File

@ -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({

View File

@ -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
),
};
}

View File

@ -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);

View File

@ -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,

View File

@ -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}

View File

@ -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: [],
});

View File

@ -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
)
)
}

View File

@ -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;

View File

@ -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
),
)
}
/>

View File

@ -207,6 +207,7 @@ function videoPreset(
creditCostPerVideo: 0,
videoDurationOptions: durationOptions,
creditCostPerVideoByDuration: {},
creditCostPerVideoByResolution: {},
enableLoadingPlaceholder: true,
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) {
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,

View File

@ -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,

View File

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

View File

@ -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,

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_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