import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { getPointsSystemConfig } from "@/lib/db/queries"; import { resolveThreeDModelDisplayName, resolveThreeDModelRealId, } from "@/lib/model3d/model-alias-config"; import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { createLogger } from "@/lib/observability/logger"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { get3DTaskCreditCost } from "@/lib/points/config"; import { getTripoProviderConfig, resolveTripoProviderConfigForTaskId, } from "@/lib/tripo/server"; import { TripoApiError, tripoRequest } from "@/lib/tripo/client"; export const runtime = "nodejs"; export const maxDuration = 60; const logger = createLogger("canvas-3d-task-create"); type CreateTaskRequest = { taskType: | "text_to_model" | "image_to_model" | "multiview_to_model" | "convert_model" | "refine_model" | "stylize_model" | "texture_model" | "animate_prerigcheck" | "animate_rig" | "animate_retarget" | "mesh_segmentation" | "mesh_completion" | "highpoly_to_lowpoly"; prompt?: string; imageToken?: string; imageUrl?: string; imageType?: string; file?: Record; files?: Array>; imageTokens?: string[]; imageUrls?: string[]; providerId?: string; originalModelTaskId?: string; draftModelTaskId?: string; format?: "GLTF" | "USDZ" | "FBX" | "OBJ" | "STL" | "3MF"; style?: "lego" | "voxel" | "voronoi" | "minecraft"; blockSize?: number; outFormat?: "glb" | "fbx"; rigType?: "biped" | "quadruped" | "hexapod" | "octopod" | "avian" | "serpentine" | "aquatic" | "others"; spec?: "mixamo" | "tripo"; animation?: string; animations?: string[]; texturePromptText?: string; texturePromptImageToken?: string; texturePromptStyleImageToken?: string; partNames?: string[]; bakeAnimation?: boolean; exportWithGeometry?: boolean; modelVersion?: string; negativePrompt?: string; faceLimit?: number; texture?: boolean; pbr?: boolean; imageSeed?: number; modelSeed?: number; textureSeed?: number; textureQuality?: "standard" | "detailed"; geometryQuality?: "standard" | "detailed"; textureAlignment?: "original_image" | "geometry"; autoSize?: boolean; orientation?: "default" | "align_image"; quad?: boolean; compress?: boolean; generateParts?: boolean; smartLowPoly?: boolean; forceSymmetry?: boolean; flattenBottom?: boolean; flattenBottomThreshold?: number; textureSize?: number; textureFormat?: string; scaleFactor?: number; pivotToCenterBottom?: boolean; withAnimation?: boolean; packUv?: boolean; bake?: boolean; exportVertexColors?: boolean; fbxPreset?: "blender" | "mixamo" | "3dsmax"; exportOrientation?: "+x" | "+y" | "-x" | "-y"; animateInPlace?: boolean; }; function hasOwn(data: Record, key: string) { return Object.prototype.hasOwnProperty.call(data, key); } function readOptionalTrimmedText(input: unknown) { const value = String(input || "").trim(); return value || null; } function assignMapped( payload: Record, source: Record, map: Record ) { for (const [sourceKey, targetKey] of Object.entries(map)) { if (!hasOwn(source, sourceKey)) continue; const value = source[sourceKey]; if (value === undefined || value === null) continue; if (typeof value === "string" && !value.trim()) continue; payload[targetKey] = value; } } export async function POST(request: Request) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const body = (await request.json()) as CreateTaskRequest; const taskType = body.taskType; if (!taskType) { return NextResponse.json({ success: false, error: "缺少 taskType" }, { status: 400 }); } const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const payload: Record = { type: taskType, }; if (taskType === "text_to_model") { const prompt = String(body.prompt || "").trim(); if (!prompt) { return NextResponse.json({ success: false, error: "请输入提示词" }, { status: 400 }); } payload.prompt = prompt; } if (taskType === "image_to_model") { const imageToken = String(body.imageToken || "").trim(); const imageUrl = String(body.imageUrl || "").trim(); const inputFile = imageToken ? { type: String(body.imageType || "jpg"), file_token: imageToken, } : imageUrl ? { type: String(body.imageType || "jpg"), url: imageUrl, } : body.file; if (!inputFile || typeof inputFile !== "object") { return NextResponse.json( { success: false, error: "图生3D缺少图片输入(imageToken 或 imageUrl)" }, { status: 400 } ); } payload.file = inputFile; } if (taskType === "multiview_to_model") { const inputFiles: Array> = []; if (Array.isArray(body.files)) { for (const item of body.files) { if (item && typeof item === "object") { inputFiles.push(item); } } } if (inputFiles.length === 0 && Array.isArray(body.imageTokens)) { for (const token of body.imageTokens) { const normalized = String(token || "").trim(); if (!normalized) continue; inputFiles.push({ type: String(body.imageType || "jpg"), file_token: normalized, }); } } if (inputFiles.length === 0 && Array.isArray(body.imageUrls)) { for (const imageUrl of body.imageUrls) { const normalized = String(imageUrl || "").trim(); if (!normalized) continue; inputFiles.push({ type: String(body.imageType || "jpg"), url: normalized, }); } } if (inputFiles.length < 2) { return NextResponse.json( { success: false, error: "多视图生成至少需要 2 张参考图" }, { status: 400 } ); } payload.files = inputFiles; } if (taskType === "convert_model") { const originalModelTaskId = String(body.originalModelTaskId || "").trim(); if (!originalModelTaskId) { return NextResponse.json( { success: false, error: "缺少 originalModelTaskId" }, { status: 400 } ); } if (!body.format) { return NextResponse.json({ success: false, error: "缺少 format" }, { status: 400 }); } payload.original_model_task_id = originalModelTaskId; payload.format = body.format; } if ( taskType === "stylize_model" || taskType === "texture_model" || taskType === "animate_prerigcheck" || taskType === "animate_rig" || taskType === "animate_retarget" || taskType === "mesh_segmentation" || taskType === "mesh_completion" || taskType === "highpoly_to_lowpoly" ) { const originalModelTaskId = String(body.originalModelTaskId || "").trim(); if (!originalModelTaskId) { return NextResponse.json( { success: false, error: "缺少 originalModelTaskId" }, { status: 400 } ); } payload.original_model_task_id = originalModelTaskId; } if (taskType === "stylize_model") { if (!body.style) { return NextResponse.json({ success: false, error: "缺少 style" }, { status: 400 }); } payload.style = body.style; } if (taskType === "animate_retarget") { const singleAnimation = String(body.animation || "").trim(); const animationList = Array.isArray(body.animations) ? body.animations.map((item) => String(item || "").trim()).filter(Boolean) : []; if (!singleAnimation && animationList.length === 0) { return NextResponse.json( { success: false, error: "缺少 animation / animations" }, { status: 400 } ); } if (singleAnimation) { payload.animation = singleAnimation; } else { payload.animations = animationList; } } if (taskType === "refine_model") { const draftModelTaskId = String(body.draftModelTaskId || "").trim(); if (!draftModelTaskId) { return NextResponse.json( { success: false, error: "缺少 draftModelTaskId" }, { status: 400 } ); } payload.draft_model_task_id = draftModelTaskId; } if (taskType === "texture_model") { const texturePromptText = String(body.texturePromptText || "").trim(); const texturePromptImageToken = String(body.texturePromptImageToken || "").trim(); const texturePromptStyleImageToken = String(body.texturePromptStyleImageToken || "").trim(); if (texturePromptText || texturePromptImageToken || texturePromptStyleImageToken) { const texturePrompt: Record = {}; if (texturePromptText) texturePrompt.text = texturePromptText; if (texturePromptImageToken) { texturePrompt.image = { type: String(body.imageType || "jpg"), file_token: texturePromptImageToken, }; } if (texturePromptStyleImageToken) { texturePrompt.style_image = { type: String(body.imageType || "jpg"), file_token: texturePromptStyleImageToken, }; } payload.texture_prompt = texturePrompt; } } assignMapped(payload, body as Record, { negativePrompt: "negative_prompt", faceLimit: "face_limit", texture: "texture", pbr: "pbr", imageSeed: "image_seed", modelSeed: "model_seed", textureSeed: "texture_seed", textureQuality: "texture_quality", geometryQuality: "geometry_quality", textureAlignment: "texture_alignment", autoSize: "auto_size", orientation: "orientation", quad: "quad", generateParts: "generate_parts", smartLowPoly: "smart_low_poly", forceSymmetry: "force_symmetry", flattenBottom: "flatten_bottom", flattenBottomThreshold: "flatten_bottom_threshold", textureSize: "texture_size", textureFormat: "texture_format", scaleFactor: "scale_factor", blockSize: "block_size", outFormat: "out_format", rigType: "rig_type", spec: "spec", partNames: "part_names", bakeAnimation: "bake_animation", exportWithGeometry: "export_with_geometry", pivotToCenterBottom: "pivot_to_center_bottom", withAnimation: "with_animation", packUv: "pack_uv", bake: "bake", exportVertexColors: "export_vertex_colors", fbxPreset: "fbx_preset", exportOrientation: "export_orientation", animateInPlace: "animate_in_place", }); const featureVisibility = modelAliasConfig.featureVisibility; const effectivePbr = featureVisibility.pbr !== false && body.pbr === true; const effectiveQuad = featureVisibility.quad !== false && body.quad === true; const effectiveSmartLowPoly = featureVisibility.smartLowPoly !== false && body.smartLowPoly === true; if (featureVisibility.pbr === false) { delete payload.pbr; } if (featureVisibility.quad === false) { delete payload.quad; } if (featureVisibility.smartLowPoly === false) { delete payload.smart_low_poly; } if ( taskType === "text_to_model" || taskType === "image_to_model" || taskType === "multiview_to_model" ) { const enabledTripoModels = modelAliasConfig.tripoModels.filter( (entry) => entry.enabled !== false, ); if (enabledTripoModels.length === 0) { return NextResponse.json( { success: false, error: "当前没有启用的 3D 生成模型", }, { status: 400 }, ); } payload.model_version = resolveThreeDModelRealId( readOptionalTrimmedText(body.modelVersion), enabledTripoModels, ); } const billableTaskLabel = taskType === "text_to_model" || taskType === "image_to_model" || taskType === "multiview_to_model" ? resolveThreeDModelDisplayName( payload.model_version, modelAliasConfig.tripoModels, taskType, ) : taskType; if (body.compress === true) { payload.compress = "geometry"; } const pointsConfig = await getPointsSystemConfig(); const unitCost = get3DTaskCreditCost(pointsConfig, { taskType, pbr: effectivePbr, quad: effectiveQuad, smartLowPoly: effectiveSmartLowPoly, }); const pointsCharge = await prepareFeaturePointsCharge({ userId: session.user.id, unitCost, reason: "model3d_task", modelId: "tripo-3d", note: `3D模型任务消耗积分(${billableTaskLabel})`, }); if (!pointsCharge.ok) { return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); } const preferredProviderId = readOptionalTrimmedText(body.providerId); const dependencyTaskId = taskType === "refine_model" ? String(body.draftModelTaskId || "").trim() : taskType === "convert_model" || taskType === "stylize_model" || taskType === "texture_model" || taskType === "animate_prerigcheck" || taskType === "animate_rig" || taskType === "animate_retarget" || taskType === "mesh_segmentation" || taskType === "mesh_completion" || taskType === "highpoly_to_lowpoly" ? String(body.originalModelTaskId || "").trim() : ""; const config = dependencyTaskId ? await resolveTripoProviderConfigForTaskId( dependencyTaskId, preferredProviderId, ) : await getTripoProviderConfig(preferredProviderId); const result = await tripoRequest>(config, { method: "POST", path: "/task", json: payload, }); const taskId = String(result.data?.task_id || result.data?.id || "").trim(); if (!taskId) { return NextResponse.json( { success: false, error: "Tripo 返回缺少 task_id", traceId: result.traceId, raw: result.data, }, { status: 502 } ); } if (pointsCharge.chargeable) { try { const consumeResult = await pointsCharge.consume({ note: `3D模型任务消耗积分(${billableTaskLabel} / ${taskId})`, }); if (!consumeResult?.ok) { logger.warn("points consume failed", { userId: session.user.id, taskId, unitCost, }); } else if (consumeResult.skipped) { logger.info("points consume skipped", { userId: session.user.id, taskId, unitCost, reason: "vip", }); } else { logger.info("points consumed", { userId: session.user.id, taskId, unitCost, balance: consumeResult.balance, }); } } catch (error) { logger.error("points consume threw", error, { userId: session.user.id, taskId, unitCost, }); } } return NextResponse.json({ success: true, data: { taskId, status: String(result.data?.status || "queued"), providerId: config.providerId || null, providerName: config.providerName || null, raw: result.data, }, traceId: result.traceId, }); } catch (error) { if (error instanceof TripoApiError) { return NextResponse.json( { success: false, error: error.message, code: error.code, suggestion: error.suggestion, traceId: error.traceId, }, { status: error.httpStatus || 502 } ); } return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "创建 3D 任务失败", }, { status: 500 } ); } }