diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 4b0692aac..019004933 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -193,8 +193,9 @@ const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai"; const HOME_CHAT_ANIMATION_MAX_BYTES = 1024 * 1024; const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config"; const VIDEO_PRICING_PREVIEW_RESOLUTIONS = ["480p", "720p", "1080p"] as const; +type AdminVideoResolution = (typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number]; const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record< - (typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number], + AdminVideoResolution, number > = { "480p": 1, @@ -202,6 +203,38 @@ const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record< "1080p": 2, }; +function normalizeAdminVideoResolutions(value: unknown): AdminVideoResolution[] { + if (!Array.isArray(value)) return []; + const allowed = new Set(VIDEO_PRICING_PREVIEW_RESOLUTIONS); + return Array.from( + new Set( + value + .map((item) => String(item || "").trim().toLowerCase()) + .filter((item): item is AdminVideoResolution => allowed.has(item)) + ) + ); +} + +function getAdminVideoSupportedResolutions(capabilities: unknown): AdminVideoResolution[] { + if (!capabilities || typeof capabilities !== "object") { + return [...VIDEO_PRICING_PREVIEW_RESOLUTIONS]; + } + const configured = normalizeAdminVideoResolutions( + (capabilities as { supportedResolutions?: unknown }).supportedResolutions + ); + return configured.length ? configured : [...VIDEO_PRICING_PREVIEW_RESOLUTIONS]; +} + +function updateAdminVideoSupportedResolutions( + capabilities: Record | null | undefined, + nextResolutions: AdminVideoResolution[] +) { + return { + ...(capabilities || {}), + supportedResolutions: nextResolutions, + }; +} + function formatCompactNumber(value: number, fractionDigits = 2) { if (!Number.isFinite(value)) return "0"; const fixed = value.toFixed(fractionDigits); @@ -553,6 +586,107 @@ function applyRecommendedEnhanceProPricing( }; } +type PointsCostField = + | "creditCostPer3DModelPbr" + | "creditCostPer3DModelSmartLowPoly" + | "creditCostPer3DModelQuad" + | "creditCostPer3DModelRefine" + | "creditCostPer3DModelTexture" + | "creditCostPer3DModelAutoRig" + | "creditCostPer3DModelHighpolyToLowpoly" + | "creditCostPerImageMultiView" + | "creditCostPerImageRemoveBackground" + | "creditCostPerImageBoxCutout" + | "creditCostPerImageWatermarkRemove" + | "creditCostPerImageExtend" + | "creditCostPerImageRepaint"; + +const THREE_D_GENERATION_POINT_FIELDS: Array<{ + key: PointsCostField; + label: string; + description: string; +}> = [ + { + key: "creditCostPer3DModelPbr", + label: "PBR 材质", + description: "勾选 PBR 材质时,在 3D 模型基础积分上额外扣除。", + }, + { + key: "creditCostPer3DModelSmartLowPoly", + label: "智能低模", + description: "勾选智能低模时额外扣除。", + }, + { + key: "creditCostPer3DModelQuad", + label: "四边面优化", + description: "勾选四边面优化时额外扣除。", + }, +]; + +const THREE_D_EDIT_POINT_FIELDS: Array<{ + key: PointsCostField; + label: string; + description: string; +}> = [ + { + key: "creditCostPer3DModelRefine", + label: "模型精修", + description: "提交 refine_model 精修任务时扣除。", + }, + { + key: "creditCostPer3DModelTexture", + label: "纹理重绘", + description: "提交 texture_model 纹理重绘任务时扣除。", + }, + { + key: "creditCostPer3DModelAutoRig", + label: "自动绑定", + description: "提交 animate_rig 自动绑定任务时扣除。", + }, + { + key: "creditCostPer3DModelHighpolyToLowpoly", + label: "模型减面", + description: "提交 highpoly_to_lowpoly 减面任务时扣除。", + }, +]; + +const IMAGE_TOOL_POINT_FIELDS: Array<{ + key: PointsCostField; + label: string; + description: string; +}> = [ + { + key: "creditCostPerImageMultiView", + label: "多视角", + description: "角色三视图 / 多机位九宫格等多视角图像生成时扣除。", + }, + { + key: "creditCostPerImageRemoveBackground", + label: "抠图", + description: "调用图片抠图功能时扣除。", + }, + { + key: "creditCostPerImageBoxCutout", + label: "框选抠图", + description: "调用框选抠图功能时扣除。", + }, + { + key: "creditCostPerImageWatermarkRemove", + label: "去水印", + description: "调用去水印功能并成功创建任务后扣除。", + }, + { + key: "creditCostPerImageExtend", + label: "扩图", + description: "调用图片拓展 / 扩图功能时扣除。", + }, + { + key: "creditCostPerImageRepaint", + label: "重绘", + description: "调用图片提示词重绘功能时扣除。", + }, +]; + type AiModel = { id: string; providerId: string; @@ -572,6 +706,7 @@ type AiModel = { isAgentModel?: boolean; agentCodingModelId?: string; isMultimodal?: boolean; + capabilities?: Record | null; imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置 }; @@ -1791,6 +1926,7 @@ export function AdminSettingsClient() { () => enhanceProConfig.apiKeys.map((apiKey, index) => ({ id: `saved-enhance-pro-key-${index}-${apiKey.slice(-6)}`, + index, name: `Krea Key ${index + 1}`, maskedKey: maskAdminSecret(apiKey), })), @@ -2680,6 +2816,7 @@ export function AdminSettingsClient() { fallbackModelId1: "", // 备用模型 ID 1 fallbackModelId2: "", // 备用模型 ID 2 excludeFromFailover: false, + capabilities: {} as Record, // 🆕 图片生成模型特性配置 imageGenConfig: createDefaultImageGenConfig(), }); @@ -2740,13 +2877,18 @@ export function AdminSettingsClient() { const isVideoProviderForModelConfig = isVideoModelDescriptor({ providerType: selectedModelProviderType, modelId: modelForm.modelId, + capabilities: modelForm.capabilities, supportsImageGen: modelForm.supportsImageGen, }); const isImageProviderForModelConfig = isImageGenerationModelDescriptor({ providerType: selectedModelProviderType, modelId: modelForm.modelId, + capabilities: modelForm.capabilities, supportsImageGen: modelForm.supportsImageGen, }); + const modelVideoSupportedResolutions = getAdminVideoSupportedResolutions( + modelForm.capabilities + ); const configuredVideoDurationOptions = getConfiguredVideoDurationOptions( modelForm.imageGenConfig ); @@ -3120,6 +3262,19 @@ export function AdminSettingsClient() { }); }; + const updatePointsCostField = (key: PointsCostField, value: unknown) => { + setSiteSettings((prev) => { + const config = normalizePointsSystemConfig(prev.pointsSystemConfig); + return { + ...prev, + pointsSystemConfig: { + ...config, + [key]: roundCreditCost(value), + }, + }; + }); + }; + const updateEnhanceProPointsBucket = ( index: number, updater: (item: EnhanceProPointsResolutionBucket) => EnhanceProPointsResolutionBucket @@ -3570,6 +3725,37 @@ export function AdminSettingsClient() { } }; + const handleAddEnhanceProPoolKey = () => { + const normalizedKey = normalizeEnhanceProKreaConfig({ + apiKey: enhanceProConfig.apiKey, + }).apiKey; + + if (!normalizedKey) { + toast.error("请先填写要加入 Key 池的 Krea Bearer Token"); + return; + } + + setEnhanceProConfig((prev) => { + const nextKeys = normalizeEnhanceProKreaConfig({ + apiKeys: [...prev.apiKeys, normalizedKey], + }).apiKeys; + return { + ...prev, + apiKey: "", + apiKeys: nextKeys, + }; + }); + setEnhanceProApiKeysDraft(""); + toast.success("Krea Key 已加入池,保存配置后生效"); + }; + + const handleDeleteEnhanceProPoolKey = (index: number) => { + setEnhanceProConfig((prev) => ({ + ...prev, + apiKeys: prev.apiKeys.filter((_, itemIndex) => itemIndex !== index), + })); + }; + const handleEnhanceProPresetChange = ( index: number, key: K, @@ -5539,6 +5725,7 @@ export function AdminSettingsClient() { fallbackModelId1: "", fallbackModelId2: "", excludeFromFailover: false, + capabilities: {}, imageGenConfig: createDefaultImageGenConfig(), }); setVideoDurationOptionsInput(""); @@ -8746,6 +8933,108 @@ export function AdminSettingsClient() { +
+
+ +
+ 这些费用会叠加到“3D模型单次积分消耗”上;填写 0 表示不额外扣分。 +
+
+
+ {THREE_D_GENERATION_POINT_FIELDS.map((field) => ( +
+ +
+ {field.description} +
+ + updatePointsCostField(field.key, e.target.value) + } + /> +
+ ))} +
+
+
+
+ +
+ 独立 3D 编辑任务按这里配置的单价扣除;填写 0 表示免费。 +
+
+
+ {THREE_D_EDIT_POINT_FIELDS.map((field) => ( +
+ +
+ {field.description} +
+ + updatePointsCostField(field.key, e.target.value) + } + /> +
+ ))} +
+
+
+
+ +
+ 多视角、抠图、框选抠图、去水印、扩图、重绘按这里单独扣分;填写 0 表示免费。 +
+
+
+ {IMAGE_TOOL_POINT_FIELDS.map((field) => ( +
+ +
+ {field.description} +
+ + updatePointsCostField(field.key, e.target.value) + } + /> +
+ ))} +
+
@@ -12734,6 +13023,67 @@ export function AdminSettingsClient() {
+
+
+
+ 前台分辨率显示 +
+

+ 关闭某个档位后,前台视频参数条不会再显示该分辨率,服务端也会拒绝该档位。 +

+
+
+ {VIDEO_PRICING_PREVIEW_RESOLUTIONS.map( + (resolutionKey) => { + const checked = modelVideoSupportedResolutions.includes( + resolutionKey + ); + return ( +
+ { + const currentSet = new Set( + modelVideoSupportedResolutions + ); + if (nextChecked) { + currentSet.add(resolutionKey); + } else { + currentSet.delete(resolutionKey); + } + const nextResolutions = + VIDEO_PRICING_PREVIEW_RESOLUTIONS.filter( + (item) => currentSet.has(item) + ); + if (nextResolutions.length === 0) { + toast.error( + "视频模型至少保留一个可用分辨率" + ); + return; + } + setModelForm({ + ...modelForm, + capabilities: + updateAdminVideoSupportedResolutions( + modelForm.capabilities, + nextResolutions + ), + }); + }} + /> + +
+ ); + } + )} +
+
+
- - setEnhanceProConfig((prev) => ({ - ...prev, - apiKey: e.target.value, - })) - } - placeholder="请输入 Krea Bearer Token" - /> +
+ + setEnhanceProConfig((prev) => ({ + ...prev, + apiKey: e.target.value, + })) + } + placeholder="请输入 Krea Bearer Token" + /> + +
+
+ 和 Tripo3D 一样,单个 Key 可以直接加入池;批量导入仍支持一次粘贴多行。 +
@@ -16066,6 +16435,17 @@ export function AdminSettingsClient() { API: {enhanceProConfig.baseUrl || ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
+
+ +
)) ) : ( diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index 6db54b247..9baa92ceb 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -4663,7 +4663,7 @@ export function AgentWorkflowSettingsClient({ - +
开启第一步:选择生图模式
@@ -4708,17 +4708,17 @@ export function AgentWorkflowSettingsClient({ />
-
-
-
开启套 PSD 模板
+
+
+
开启套 PSD 模板
关闭后前台隐藏所有“套PSD”按钮和 PSD 模板编辑弹窗入口。
- setConfig((prev) => ({ + checked={workflowPageFeatureSettings.psdTemplateEnabled} + onCheckedChange={(checked) => + setConfig((prev) => ({ ...prev, pageFeatureSettings: { ...(prev.pageFeatureSettings || @@ -4727,9 +4727,31 @@ export function AgentWorkflowSettingsClient({ }, })) } - /> -
- + /> +
+ +
+
+
开启批量处理
+
+ 关闭后前台顶部导航和无限画布工具栏都隐藏“批量处理”入口。 +
+
+ + setConfig((prev) => ({ + ...prev, + pageFeatureSettings: { + ...(prev.pageFeatureSettings || + defaultAgentWorkflowPageFeatureSettings), + batchProcessingEnabled: checked, + }, + })) + } + /> +
+ diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts index d150f8b06..7038e4dc1 100644 --- a/app/api/admin/ai-models/route.ts +++ b/app/api/admin/ai-models/route.ts @@ -41,6 +41,8 @@ const imageGenConfigSchema = z.object({ loadingColor: z.string().optional(), }).optional(); +const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional(); + function normalizeImageGenConfig(config: z.infer) { if (!config) return config; @@ -79,6 +81,7 @@ const createModelSchema = z.object({ isAgentModel: z.boolean().optional(), agentCodingModelId: z.string().max(128).optional(), isMultimodal: z.boolean().optional(), + capabilities: capabilitiesSchema, imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置 }); @@ -101,6 +104,7 @@ const updateModelSchema = z.object({ isAgentModel: z.boolean().optional(), agentCodingModelId: z.string().max(128).optional(), isMultimodal: z.boolean().optional(), + capabilities: capabilitiesSchema, imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置 }); diff --git a/app/api/canvas/3d/tasks/route.ts b/app/api/canvas/3d/tasks/route.ts index b9db18be7..00e88a0a4 100644 --- a/app/api/canvas/3d/tasks/route.ts +++ b/app/api/canvas/3d/tasks/route.ts @@ -1,12 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; -import { - consumeUserPoints, - getPointsSystemConfig, - getUserById, -} from "@/lib/db/queries"; +import { getPointsSystemConfig } from "@/lib/db/queries"; import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config"; import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; +import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; +import { get3DTaskCreditCost } from "@/lib/points/config"; import { getTripoProviderConfig } from "@/lib/tripo/server"; import { TripoApiError, tripoRequest } from "@/lib/tripo/client"; @@ -109,14 +107,6 @@ function assignMapped( } } -function shouldCharge3DPoints(taskType: CreateTaskRequest["taskType"]) { - return ( - taskType === "text_to_model" || - taskType === "image_to_model" || - taskType === "multiview_to_model" - ); -} - export async function POST(request: Request) { try { const session = await auth(); @@ -359,36 +349,21 @@ export async function POST(request: Request) { } const pointsConfig = await getPointsSystemConfig(); - const shouldChargePoints = shouldCharge3DPoints(taskType); - const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel)) - ? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel))) - : 0; - - if ( - shouldChargePoints && - pointsConfig.enabled && - unitCost > 0 && - pointsConfig.blockOnInsufficient !== false - ) { - const userData = await getUserById({ id: session.user.id }); - if (!userData?.isHighVip) { - const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0)); - - if (currentBalance < unitCost) { - return NextResponse.json( - { - success: false, - error: "积分不足", - code: "insufficient_points", - message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`, - balance: currentBalance, - expectedCost: unitCost, - unitCost, - }, - { status: 402 } - ); - } - } + const unitCost = get3DTaskCreditCost(pointsConfig, { + taskType, + pbr: body.pbr, + quad: body.quad, + smartLowPoly: body.smartLowPoly, + }); + const pointsCharge = await prepareFeaturePointsCharge({ + userId: session.user.id, + unitCost, + reason: "model3d_task", + modelId: "tripo-3d", + note: `3D模型任务消耗积分(${taskType})`, + }); + if (!pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); } const config = await getTripoProviderConfig( @@ -413,17 +388,13 @@ export async function POST(request: Request) { ); } - if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) { + if (pointsCharge.chargeable) { try { - const consumeResult = await consumeUserPoints({ - userId: session.user.id, - amount: unitCost, - reason: "model3d_generation", - modelId: "tripo-3d", - note: "3D模型生成消耗积分", + const consumeResult = await pointsCharge.consume({ + note: `3D模型任务消耗积分(${taskType} / ${taskId})`, }); - if (!consumeResult.ok) { + if (!consumeResult?.ok) { console.warn( `[Points] Failed to consume 3D points. user=${session.user.id} task=${taskId} cost=${unitCost}` ); diff --git a/app/api/canvas/box-cutout/route.ts b/app/api/canvas/box-cutout/route.ts index 5201fbdc8..86773fc3e 100644 --- a/app/api/canvas/box-cutout/route.ts +++ b/app/api/canvas/box-cutout/route.ts @@ -6,6 +6,9 @@ import { executeBoxCutoutJob, } from "@/lib/ai/box-cutout/service"; import { RmbgServiceError } from "@/lib/ai/rmbg/service"; +import { getPointsSystemConfig } from "@/lib/db/queries"; +import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; +import { getImageToolCreditCost } from "@/lib/points/config"; export const runtime = "nodejs"; export const maxDuration = 60; @@ -40,6 +43,19 @@ export async function POST(request: NextRequest) { } const resolvedImageUrl = new URL(rawImageUrl, request.url).toString(); + const pointsConfig = await getPointsSystemConfig(); + const pointsCharge = await prepareFeaturePointsCharge({ + userId: session.user.id, + unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"), + reason: "image_box_cutout", + modelId: "box-cutout", + note: "框选抠图消耗积分", + pointsConfig, + }); + if (!pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); + } + const result = await executeBoxCutoutJob({ userId: session.user.id, imageUrl: resolvedImageUrl, @@ -52,6 +68,14 @@ export async function POST(request: NextRequest) { sourceNodeId: body.sourceNodeId, }); + if (pointsCharge.chargeable) { + try { + await pointsCharge.consume(); + } catch (pointsError) { + console.error("[Points] Failed to consume box cutout points:", pointsError); + } + } + return NextResponse.json(result); } catch (error) { if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) { diff --git a/app/api/canvas/generate/image/route.ts b/app/api/canvas/generate/image/route.ts index e7f4725d2..1c6c5d147 100644 --- a/app/api/canvas/generate/image/route.ts +++ b/app/api/canvas/generate/image/route.ts @@ -9,6 +9,12 @@ import { generateCanvasImage, type CanvasGenerateImageRequest, } from "@/lib/canvas/generate-image"; +import { getPointsSystemConfig } from "@/lib/db/queries"; +import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; +import { + getImageToolCreditCost, + type ImageToolCreditFeature, +} from "@/lib/points/config"; export const maxDuration = 120; @@ -16,6 +22,13 @@ interface GenerateImageRequest extends CanvasGenerateImageRequest { async?: boolean; } +function normalizeBillingFeature( + value: unknown +): Extract | null { + if (value === "multi-view" || value === "repaint") return value; + return null; +} + function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest { return { prompt: String(body.prompt || "").trim(), @@ -34,6 +47,8 @@ function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImage typeof body.imageCount === "number" && Number.isFinite(body.imageCount) ? body.imageCount : 1, + promptExtend: + typeof body.promptExtend === "boolean" ? body.promptExtend : undefined, referenceImage: typeof body.referenceImage === "string" ? body.referenceImage : undefined, referenceImages: Array.isArray(body.referenceImages) @@ -107,6 +122,30 @@ export async function POST(request: Request) { ); } + const billingFeature = normalizeBillingFeature(body.billingFeature); + const pointsCharge = billingFeature + ? await (async () => { + const pointsConfig = await getPointsSystemConfig(); + return prepareFeaturePointsCharge({ + userId: session.user.id, + unitCost: getImageToolCreditCost(pointsConfig, billingFeature), + reason: + billingFeature === "multi-view" + ? "image_multi_view" + : "image_repaint", + modelId: "canvas-generate-image", + note: + billingFeature === "multi-view" + ? "图片多视角生成消耗积分" + : "图片重绘消耗积分", + pointsConfig, + }); + })() + : null; + if (pointsCharge && !pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); + } + const asyncMode = body.async === true; if (asyncMode) { const task = await createCanvasImageTask({ @@ -114,6 +153,18 @@ export async function POST(request: Request) { request: payload, }); await enqueueCanvasImageTask(task.id); + if (pointsCharge?.chargeable) { + try { + await pointsCharge.consume({ + note: + billingFeature === "multi-view" + ? `图片多视角生成消耗积分(${task.id})` + : `图片重绘消耗积分(${task.id})`, + }); + } catch (pointsError) { + console.error("[Points] Failed to consume canvas image task points:", pointsError); + } + } return NextResponse.json({ success: true, taskId: task.id, @@ -122,6 +173,13 @@ export async function POST(request: Request) { } const result = await generateCanvasImage(payload); + if (pointsCharge?.chargeable) { + try { + await pointsCharge.consume(); + } catch (pointsError) { + console.error("[Points] Failed to consume canvas image points:", pointsError); + } + } return NextResponse.json({ success: true, data: result, diff --git a/app/api/extend/route.ts b/app/api/extend/route.ts index 137fab98f..369584639 100644 --- a/app/api/extend/route.ts +++ b/app/api/extend/route.ts @@ -1,6 +1,9 @@ import { type NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service"; +import { getPointsSystemConfig } from "@/lib/db/queries"; +import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; +import { getImageToolCreditCost } from "@/lib/points/config"; import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage, @@ -39,6 +42,19 @@ export async function POST(request: NextRequest) { ); } + const pointsConfig = await getPointsSystemConfig(); + const pointsCharge = await prepareFeaturePointsCharge({ + userId: session.user.id, + unitCost: getImageToolCreditCost(pointsConfig, "extend"), + reason: "image_extend", + modelId: "image-extend", + note: "图片扩图消耗积分", + pointsConfig, + }); + if (!pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); + } + const result = await executeExtendJob({ userId: session.user.id, imageUrl, @@ -50,6 +66,14 @@ export async function POST(request: NextRequest) { yScale: body.yScale ? Number(body.yScale) : undefined, }); + if (pointsCharge.chargeable) { + try { + await pointsCharge.consume(); + } catch (pointsError) { + console.error("[Points] Failed to consume extend points:", pointsError); + } + } + return NextResponse.json({ success: true, original_url: result.original_url, @@ -68,6 +92,19 @@ export async function POST(request: NextRequest) { const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); + const pointsConfig = await getPointsSystemConfig(); + const pointsCharge = await prepareFeaturePointsCharge({ + userId: session.user.id, + unitCost: getImageToolCreditCost(pointsConfig, "extend"), + reason: "image_extend", + modelId: "image-extend", + note: "图片扩图消耗积分", + pointsConfig, + }); + if (!pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); + } + const result = await executeExtendJob({ userId: session.user.id, imageBase64: buffer.toString("base64"), @@ -76,6 +113,14 @@ export async function POST(request: NextRequest) { messageId: String(formData.get("messageId") || "") || null, }); + if (pointsCharge.chargeable) { + try { + await pointsCharge.consume(); + } catch (pointsError) { + console.error("[Points] Failed to consume extend points:", pointsError); + } + } + return NextResponse.json({ success: true, original_url: result.original_url, diff --git a/app/api/rmbg/route.ts b/app/api/rmbg/route.ts index ff6457434..889f44c42 100644 --- a/app/api/rmbg/route.ts +++ b/app/api/rmbg/route.ts @@ -6,6 +6,9 @@ import { fetchImageBufferFromUrl, RmbgServiceError, } from "@/lib/ai/rmbg/service"; +import { getPointsSystemConfig } from "@/lib/db/queries"; +import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; +import { getImageToolCreditCost } from "@/lib/points/config"; export const runtime = "nodejs"; export const maxDuration = 60; @@ -79,6 +82,19 @@ export async function POST(request: NextRequest) { ); } + const pointsConfig = await getPointsSystemConfig(); + const pointsCharge = await prepareFeaturePointsCharge({ + userId: session.user.id, + unitCost: getImageToolCreditCost(pointsConfig, "remove-background"), + reason: "image_remove_background", + modelId: "rmbg", + note: "图片抠图消耗积分", + pointsConfig, + }); + if (!pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); + } + const result = await executeRmbgJob({ userId: session.user.id, imageBuffer, @@ -90,6 +106,14 @@ export async function POST(request: NextRequest) { sourceImageUrl, }); + if (pointsCharge.chargeable) { + try { + await pointsCharge.consume(); + } catch (pointsError) { + console.error("[Points] Failed to consume RMBG points:", pointsError); + } + } + return NextResponse.json({ success: true, ...result, diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index f4550b8a3..c94f605e1 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -1103,6 +1103,7 @@ const updateSchema = z.object({ presetStepEnabled: z.boolean().optional(), materialStepEnabled: z.boolean().optional(), psdTemplateEnabled: z.boolean().optional(), + batchProcessingEnabled: z.boolean().optional(), }) .optional(), steps: z diff --git a/app/api/watermark-remove/route.ts b/app/api/watermark-remove/route.ts index 78b51cab4..b22886db2 100644 --- a/app/api/watermark-remove/route.ts +++ b/app/api/watermark-remove/route.ts @@ -6,9 +6,12 @@ import { eq } from "drizzle-orm"; import { uploadFile } from "@/lib/storage/service"; import { createMediaAssetRelation, + getPointsSystemConfig, getMediaAssetsByUserAndUrls, upsertMediaAsset, } from "@/lib/db/queries"; +import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; +import { getImageToolCreditCost } from "@/lib/points/config"; // PicWish API 需要公网可访问的 HTTP/HTTPS URL const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000; @@ -380,6 +383,19 @@ export async function POST(request: NextRequest) { console.log("[WatermarkRemove] 开始处理图片:", publicImageUrl); + const pointsConfig = await getPointsSystemConfig(); + const pointsCharge = await prepareFeaturePointsCharge({ + userId: session.user.id, + unitCost: getImageToolCreditCost(pointsConfig, "watermark-remove"), + reason: "image_watermark_remove", + modelId: "watermark-remove", + note: "去水印消耗积分", + pointsConfig, + }); + if (!pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); + } + // 创建去水印任务 const formData = new FormData(); formData.append("sync", "0"); @@ -416,6 +432,16 @@ export async function POST(request: NextRequest) { ); } + if (pointsCharge.chargeable) { + try { + await pointsCharge.consume({ + note: `去水印任务消耗积分(${taskId})`, + }); + } catch (pointsError) { + console.error("[Points] Failed to consume watermark remove points:", pointsError); + } + } + try { const result = await finalizeWatermarkTaskResult({ taskId, diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 68b832004..bb846af0b 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -1557,6 +1557,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { presetStepEnabled?: boolean; materialStepEnabled?: boolean; psdTemplateEnabled?: boolean; + batchProcessingEnabled?: boolean; }; steps?: Array<{ key: AgentWorkflowStepKey; @@ -1614,6 +1615,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { workflowPageFeatureSettings.presetStepEnabled !== false; const isWorkflowMaterialStepEnabled = workflowPageFeatureSettings.materialStepEnabled !== false; + const isBatchProcessingEnabled = + workflowPageFeatureSettings.batchProcessingEnabled !== false; const sanitizedWorkflowSelections = useMemo( () => sanitizeAgentWorkflowSelections({ @@ -2764,10 +2767,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { }, [isAgentMode]); useEffect(() => { - if (shouldOpenBatchProcessing) { + if (shouldOpenBatchProcessing && isBatchProcessingEnabled) { setSceneFusionDialogOpen(true); } - }, [shouldOpenBatchProcessing]); + }, [isBatchProcessingEnabled, shouldOpenBatchProcessing]); const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]); const buildPersistedCanvasData = useCallback(() => { @@ -8458,6 +8461,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
) : null} - - - - - 批量处理 - + {isBatchProcessingEnabled ? ( + + + + + 批量处理 + + ) : null} diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 25a8d77c6..6f55da7af 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -10,6 +10,7 @@ import { Check, ChevronDown, ChevronUp, + Crosshair, Download, Eye, EyeOff, @@ -615,6 +616,7 @@ export default function CanvasModel3DPage() { ); const [worldViewportMode, setWorldViewportMode] = useState("world"); + const [worldFirstPerson, setWorldFirstPerson] = useState(false); const [viewerResetToken, setViewerResetToken] = useState(0); const [taskHistory, setTaskHistory] = useState([]); @@ -2393,6 +2395,12 @@ export default function CanvasModel3DPage() { worldViewportMode, ]); + useEffect(() => { + if (workspaceMode !== "world" || worldViewportMode !== "splat") { + setWorldFirstPerson(false); + } + }, [workspaceMode, worldViewportMode]); + return (
{/* Top Navigation Bar */} @@ -3463,16 +3471,34 @@ export default function CanvasModel3DPage() { 3D 世界控制
- +
+ + +
@@ -3520,10 +3546,12 @@ export default function CanvasModel3DPage() { {worldViewportMode === "splat" ? ( <>
- 鼠标拖拽会围绕场景原点旋转 + {worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景原点旋转"}
- 左键拖拽旋转,滚轮缩放;已关闭横向平移。需要切换模型时,直接点击右侧资产卡片。 + {worldFirstPerson + ? "点击画面后可锁定鼠标视角,WASD 或方向键移动,Q/R 上升,E/F 下降,Esc 退出鼠标锁定。" + : "左键拖拽旋转,滚轮缩放;开启“漫游”后可用 WASD / 方向键移动。需要切换模型时,直接点击右侧资产卡片。"}
) : worldViewportMode === "mesh" ? ( @@ -3552,6 +3580,7 @@ export default function CanvasModel3DPage() { {workspaceMode === "world" ? ( void; rightSlot?: ReactNode; activeKey?: "featured" | "agent" | "canvas" | "gallery"; + batchProcessingEnabled?: boolean; } const NAV_ITEMS = [ @@ -48,6 +49,7 @@ export function AgentCanvasTopNavDark({ onBack, rightSlot = null, activeKey = "agent", + batchProcessingEnabled = true, }: AgentCanvasTopNavDarkProps) { const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot"; const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || ""; @@ -94,7 +96,9 @@ export function AgentCanvasTopNavDark({