From 8e51697800dc2ec53578d37fbeadfbeafbf12ff0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Apr 2026 20:29:07 +0800 Subject: [PATCH] feat: refine krea enhance pro billing --- .../settings/admin-settings-client.tsx | 244 +++++++++++++++++- app/(chat)/api/chat/route.ts | 37 ++- app/api/admin/ai-models/route.ts | 5 + app/api/canvas/world/tasks/route.ts | 20 +- app/api/enhance/pro/route.ts | 18 +- app/canvas/[id]/page.tsx | 11 +- app/canvas/[id]/realtime/page.tsx | 9 + components/canvas/EnhanceProWorkspace.tsx | 35 ++- components/canvas/RealtimeCanvasDialog.tsx | 2 + components/enhance-pro-dialog.tsx | 22 +- lib/ai/krea-models.ts | 1 + lib/ai/providers/krea.ts | 84 +++++- lib/ai/video-model-config.ts | 51 +++- lib/db/queries.ts | 1 + lib/enhance-pro/config.ts | 39 ++- lib/points/config.ts | 132 +++++++++- 16 files changed, 649 insertions(+), 62 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 4fed7fb0d..ddb60bbe3 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -368,6 +368,7 @@ type ImageGenConfig = { creditCostPerVideo?: number; // 每条视频积分消耗 videoDurationOptions?: number[]; // 视频秒数选项 creditCostPerVideoByDuration?: Record; // 各秒数积分 + creditCostPerVideoByResolution?: Record; // 各分辨率积分 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 @@ -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() { } /> +
+
+ + + setSiteSettings({ + ...siteSettings, + pointsSystemConfig: { + ...pointsSystemConfig, + creditCostPer3DWorldMarble11: Math.max( + 0, + Number.parseInt(e.target.value || "0", 10) || 0 + ), + }, + }) + } + /> +
+
+ + + setSiteSettings({ + ...siteSettings, + pointsSystemConfig: { + ...pointsSystemConfig, + creditCostPer3DWorldMarble11Plus: Math.max( + 0, + Number.parseInt(e.target.value || "0", 10) || 0 + ), + }, + }) + } + /> +
+
@@ -8242,7 +8320,7 @@ export function AdminSettingsClient() {
- 固定单价适合先快速上线,分辨率档位更适合贴近增强成本。 + 可按放大倍率、分辨率档位或固定单价计算本地积分。
@@ -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() { + + 放大倍率 + 分辨率档位 @@ -8312,6 +8395,66 @@ export function AdminSettingsClient() {
+
+
+ +
+ 开启后用户关闭“启用放大”时不扣除图像增强 Pro 积分。 +
+
+ + setSiteSettings({ + ...siteSettings, + pointsSystemConfig: { + ...pointsSystemConfig, + enhanceProPricing: { + ...pointsSystemConfig.enhanceProPricing, + chargeOnlyWhenUpscaling: checked, + }, + }, + }) + } + /> +
+ +
+
+ +
+ “放大倍率”计费模式下使用;小数倍率会匹配大于等于该倍率的最近档位。 +
+
+
+ {Object.entries( + pointsSystemConfig.enhanceProPricing.scaleFactorCosts + ) + .sort(([left], [right]) => Number(left) - Number(right)) + .map(([factorKey, cost]) => ( +
+ + + updateEnhanceProScaleFactorCost( + factorKey, + Number.parseInt(e.target.value || "0", 10) || 0 + ) + } + /> +
+ ))} +
+
+
@@ -8485,7 +8628,7 @@ export function AdminSettingsClient() {
- 建议算法:按输出分辨率档位取基础积分,再乘以候选数量和模型倍率,最终向上取整。 + 建议算法:按放大倍率或输出分辨率取基础积分,再乘以候选数量和模型倍率,最终向上取整。
@@ -11930,6 +12073,62 @@ export function AdminSettingsClient() { 当前还没有配置秒数档位,将按“每条视频积分消耗”统一扣费。

)} + +
+
+ 各分辨率积分消耗 +
+
+ {(["480p", "720p", "1080p"] as const).map( + (resolutionKey) => ( +
+ + { + const nextValue = Math.max( + 0, + Number.parseInt( + e.target.value, + 10 + ) || 0 + ); + setModelForm({ + ...modelForm, + imageGenConfig: { + ...modelForm.imageGenConfig, + creditCostPerVideoByResolution: { + ...(modelForm.imageGenConfig + ?.creditCostPerVideoByResolution || + {}), + [resolutionKey]: nextValue, + }, + }, + }); + }} + /> +
+ ) + )} +
+

+ 配置后会优先按用户选择的视频分辨率扣积分;未命中再回退到秒数或统一价格。 +

+
)} @@ -15006,7 +15205,7 @@ export function AdminSettingsClient() { -
+
+
+ + { + 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 + ), + })), + })); + }} + /> +

+ 默认建议 2-4 倍;需要时仍可放宽到 Krea 支持的更高倍数。 +

+
{ - 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; 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({ diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts index ac195d07e..d150f8b06 100644 --- a/app/api/admin/ai-models/route.ts +++ b/app/api/admin/ai-models/route.ts @@ -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) { ...config, videoDurationOptions: videoDurationOptions.length ? videoDurationOptions : undefined, creditCostPerVideoByDuration, + creditCostPerVideoByResolution: normalizeVideoResolutionCostMap( + config.creditCostPerVideoByResolution + ), }; } diff --git a/app/api/canvas/world/tasks/route.ts b/app/api/canvas/world/tasks/route.ts index 9d196e95c..3989f8231 100644 --- a/app/api/canvas/world/tasks/route.ts +++ b/app/api/canvas/world/tasks/route.ts @@ -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); diff --git a/app/api/enhance/pro/route.ts b/app/api/enhance/pro/route.ts index d757309d8..979b652d3 100644 --- a/app/api/enhance/pro/route.ts +++ b/app/api/enhance/pro/route.ts @@ -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, diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 70bedc7b1..0e2e727a5 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -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) { {selectedImageNode ? "图像增强 Pro" - : "先选中图片后进入图像增强 Pro"} + : "进入图像增强 Pro 后上传图片"} ) : null} diff --git a/app/canvas/[id]/realtime/page.tsx b/app/canvas/[id]/realtime/page.tsx index c701496af..364a3b76f 100644 --- a/app/canvas/[id]/realtime/page.tsx +++ b/app/canvas/[id]/realtime/page.tsx @@ -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: [], }); diff --git a/components/canvas/EnhanceProWorkspace.tsx b/components/canvas/EnhanceProWorkspace.tsx index 41bb67205..d2fdda464 100644 --- a/components/canvas/EnhanceProWorkspace.tsx +++ b/components/canvas/EnhanceProWorkspace.tsx @@ -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({
@@ -804,6 +822,9 @@ export function EnhanceProWorkspace({ {pointsEstimate.chargeMode === "resolution_bucket" && pointsEstimate.bucketName ? ` · ${pointsEstimate.bucketName}` : ""} + {pointsEstimate.chargeMode === "scale_factor" + ? ` · ${pointsEstimate.scaleFactor}x` + : ""}
) : null}
@@ -1130,7 +1151,7 @@ export function EnhanceProWorkspace({ handleNumericField( @@ -1138,7 +1159,7 @@ export function EnhanceProWorkspace({ clampNumber( Number.parseFloat(event.target.value) || 1, 1, - 32 + config.maxUpscaleFactor ) ) } diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 2f0401976..16a245748 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -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; diff --git a/components/enhance-pro-dialog.tsx b/components/enhance-pro-dialog.tsx index e72b7ea95..7e979f392 100644 --- a/components/enhance-pro-dialog.tsx +++ b/components/enhance-pro-dialog.tsx @@ -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 + ), ) } /> diff --git a/lib/ai/krea-models.ts b/lib/ai/krea-models.ts index a4a548f53..72b234f98 100644 --- a/lib/ai/krea-models.ts +++ b/lib/ai/krea-models.ts @@ -207,6 +207,7 @@ function videoPreset( creditCostPerVideo: 0, videoDurationOptions: durationOptions, creditCostPerVideoByDuration: {}, + creditCostPerVideoByResolution: {}, enableLoadingPlaceholder: true, loadingColor: "blue", }, diff --git a/lib/ai/providers/krea.ts b/lib/ai/providers/krea.ts index 36f33c5f8..361b93590 100644 --- a/lib/ai/providers/krea.ts +++ b/lib/ai/providers/krea.ts @@ -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, diff --git a/lib/ai/video-model-config.ts b/lib/ai/video-model-config.ts index bd3158a63..5ece8d1aa 100644 --- a/lib/ai/video-model-config.ts +++ b/lib/ai/video-model-config.ts @@ -1,9 +1,11 @@ export type VideoDurationCostMap = Record; +export type VideoResolutionCostMap = Record; export type VideoImageGenConfigLike = { creditCostPerVideo?: number | null; videoDurationOptions?: number[] | null; creditCostPerVideoByDuration?: Record | null; + creditCostPerVideoByResolution?: Record | 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 = []; + for (const [resolutionKey, costValue] of Object.entries( + value as Record + )) { + 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, diff --git a/lib/db/queries.ts b/lib/db/queries.ts index b165aa730..9ab0894b1 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -3925,6 +3925,7 @@ interface ImageGenConfig { creditCostPerVideo?: number; videoDurationOptions?: number[]; creditCostPerVideoByDuration?: Record; + creditCostPerVideoByResolution?: Record; enableLoadingPlaceholder?: boolean; loadingColor?: string; } diff --git a/lib/enhance-pro/config.ts b/lib/enhance-pro/config.ts index 9d5ee4ea6..fc8a52bb7 100644 --- a/lib/enhance-pro/config.ts +++ b/lib/enhance-pro/config.ts @@ -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, diff --git a/lib/points/config.ts b/lib/points/config.ts index e560210f7..2fc5ddad0 100644 --- a/lib/points/config.ts +++ b/lib/points/config.ts @@ -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; resolutionBuckets: EnhanceProPointsResolutionBucket[]; modelMultipliers: Record; }; @@ -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) + .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