Your Name d272bd5ee8
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat(canvas): add Tripo3D generation workspace and provider config
2026-02-28 21:57:51 +08:00

224 lines
6.5 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 { 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" | "convert_model";
prompt?: string;
imageToken?: string;
imageUrl?: string;
imageType?: string;
file?: Record<string, unknown>;
originalModelTaskId?: string;
format?: "GLTF" | "USDZ" | "FBX" | "OBJ" | "STL" | "3MF";
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 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 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 === "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;
}
assignMapped(payload, body as Record<string, unknown>, {
modelVersion: "model_version",
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",
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 (body.compress === true) {
payload.compress = "geometry";
}
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 }
);
}
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 }
);
}
}