Your Name 1b1ff65de8
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add discounted model pricing and fix realtime 3d media handling
2026-05-13 20:02:52 +08:00

512 lines
16 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 { 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 }
);
}
}