Your Name 27d44b3b77
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add configurable 3d model display aliases
2026-04-08 22:09:43 +08:00

473 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
consumeUserPoints,
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config";
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { getTripoProviderConfig } from "@/lib/tripo/server";
import { TripoApiError, tripoRequest } from "@/lib/tripo/client";
export const runtime = "nodejs";
export const maxDuration = 60;
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<string, unknown>;
files?: Array<Record<string, unknown>>;
imageTokens?: string[];
imageUrls?: 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<string, unknown>, 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<string, unknown>,
source: Record<string, unknown>,
map: Record<string, string>
) {
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;
}
}
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();
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<string, unknown> = {
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<Record<string, unknown>> = [];
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<string, unknown> = {};
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<string, unknown>, {
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",
});
if (
taskType === "text_to_model" ||
taskType === "image_to_model" ||
taskType === "multiview_to_model"
) {
payload.model_version = resolveThreeDModelRealId(
readOptionalTrimmedText(body.modelVersion),
modelAliasConfig.tripoModels,
);
}
if (body.compress === true) {
payload.compress = "geometry";
}
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 config = await getTripoProviderConfig();
const result = await tripoRequest<Record<string, unknown>>(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 (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
try {
const consumeResult = await consumeUserPoints({
userId: session.user.id,
amount: unitCost,
reason: "model3d_generation",
modelId: "tripo-3d",
note: "3D模型生成消耗积分",
});
if (!consumeResult.ok) {
console.warn(
`[Points] Failed to consume 3D points. user=${session.user.id} task=${taskId} cost=${unitCost}`
);
} else if (consumeResult.skipped) {
console.log(
`[Points] Skipped ${unitCost} 3D points for VIP user=${session.user.id} task=${taskId}`
);
} else {
console.log(
`[Points] Consumed ${unitCost} points for 3D model generation. user=${session.user.id} task=${taskId} balance=${consumeResult.balance}`
);
}
} catch (error) {
console.error("[Points] Failed to consume 3D points:", error);
}
}
return NextResponse.json({
success: true,
data: {
taskId,
status: String(result.data?.status || "queued"),
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 }
);
}
}