512 lines
16 KiB
TypeScript
512 lines
16 KiB
TypeScript
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 { 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;
|
||
|
||
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[];
|
||
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<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;
|
||
}
|
||
}
|
||
|
||
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",
|
||
});
|
||
|
||
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<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 (pointsCharge.chargeable) {
|
||
try {
|
||
const consumeResult = await pointsCharge.consume({
|
||
note: `3D模型任务消耗积分(${billableTaskLabel} / ${taskId})`,
|
||
});
|
||
|
||
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"),
|
||
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 }
|
||
);
|
||
}
|
||
}
|