feat(canvas): add Tripo3D generation workspace and provider config
This commit is contained in:
parent
b578676251
commit
d272bd5ee8
@ -38,3 +38,8 @@ GTP5_API_KEY=your-gtp5-api-key
|
||||
|
||||
# 模型名称(例如:gpt-5-nano)
|
||||
GTP5_MODEL=gpt-5-nano
|
||||
|
||||
# Tripo3D(可选)
|
||||
# 优先使用系统设置-本地AI里的 `tripo-3d` 提供商;若未配置则回退到环境变量
|
||||
TRIPO_API_KEY=your-tripo-api-key
|
||||
TRIPO_BASE_URL=https://api.tripo3d.ai/v2/openapi
|
||||
|
||||
@ -123,7 +123,7 @@ type ToolConfig = {
|
||||
type AiProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "volcano-multimodal";
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "tripo-3d" | "volcano-multimodal";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
isEnabled: boolean;
|
||||
@ -394,6 +394,7 @@ const PROVIDER_TYPES = [
|
||||
{ value: "alibaba-image", label: "阿里生图模型" }, // 🆕 千问图片生成/编辑
|
||||
{ value: "ruxa-image", label: "RUXA 生图模型" },
|
||||
{ value: "fal-image", label: "FAL 生图模型" },
|
||||
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
|
||||
{ value: "zhipu", label: "智普官方 4.7" },
|
||||
{ value: "volcano-multimodal", label: "火山引擎多模态" },
|
||||
{ value: "openai", label: "OpenAI 兼容" },
|
||||
@ -415,6 +416,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||
"ruxa-image": "https://api.ruxa.ai",
|
||||
"fal-image": "https://fal.run",
|
||||
"fal-video": "https://fal.run",
|
||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||
zhipu: "https://open.bigmodel.cn/api/paas/v4",
|
||||
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
openai: "https://api.openai.com/v1",
|
||||
@ -1089,7 +1091,7 @@ export function AdminSettingsClient() {
|
||||
const [providerForm, setProviderForm] = useState({
|
||||
id: "",
|
||||
name: "",
|
||||
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "volcano-multimodal",
|
||||
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "tripo-3d" | "volcano-multimodal",
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
isEnabled: true,
|
||||
@ -3349,6 +3351,7 @@ export function AdminSettingsClient() {
|
||||
"alibaba-image": "bg-amber-500",
|
||||
"ruxa-image": "bg-emerald-500",
|
||||
"fal-image": "bg-sky-500",
|
||||
"tripo-3d": "bg-teal-500",
|
||||
"openai": "bg-green-500",
|
||||
"zhipu": "bg-lime-500",
|
||||
"volcano-multimodal": "bg-fuchsia-500",
|
||||
|
||||
@ -25,6 +25,7 @@ const providerTypes = [
|
||||
"fal-image",
|
||||
"fal-video",
|
||||
"zhipu",
|
||||
"tripo-3d",
|
||||
"volcano-multimodal",
|
||||
] as const;
|
||||
type ProviderType = (typeof providerTypes)[number];
|
||||
@ -50,6 +51,7 @@ const updateProviderSchema = z.object({
|
||||
const ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4";
|
||||
const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
|
||||
const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
|
||||
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||
|
||||
function normalizeProviderTypeForStorage(
|
||||
type?: ProviderType
|
||||
@ -156,7 +158,9 @@ export async function POST(request: Request) {
|
||||
? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL
|
||||
: data.type === "doubao-video"
|
||||
? data.baseUrl || DOUBAO_VIDEO_BASE_URL
|
||||
: data.baseUrl,
|
||||
: data.type === "tripo-3d"
|
||||
? data.baseUrl || TRIPO_3D_BASE_URL
|
||||
: data.baseUrl,
|
||||
updatedBy: session.user.id,
|
||||
});
|
||||
|
||||
|
||||
50
app/api/canvas/3d/balance/route.ts
Normal file
50
app/api/canvas/3d/balance/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
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 = 30;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getTripoProviderConfig();
|
||||
const result = await tripoRequest<Record<string, unknown>>(config, {
|
||||
method: "GET",
|
||||
path: "/user/balance",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: 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 : "查询余额失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
81
app/api/canvas/3d/tasks/[taskId]/route.ts
Normal file
81
app/api/canvas/3d/tasks/[taskId]/route.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
TripoApiError,
|
||||
pickTaskModelUrl,
|
||||
pickTaskPreviewImageUrl,
|
||||
tripoRequest,
|
||||
} from "@/lib/tripo/client";
|
||||
import { getTripoProviderConfig } from "@/lib/tripo/server";
|
||||
import type { TripoTaskData } from "@/lib/tripo/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 30;
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ taskId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { taskId } = await params;
|
||||
const normalizedTaskId = String(taskId || "").trim();
|
||||
if (!normalizedTaskId) {
|
||||
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getTripoProviderConfig();
|
||||
const result = await tripoRequest<TripoTaskData>(config, {
|
||||
method: "GET",
|
||||
path: `/task/${encodeURIComponent(normalizedTaskId)}`,
|
||||
});
|
||||
|
||||
const output =
|
||||
result.data?.output && typeof result.data.output === "object" ? result.data.output : {};
|
||||
const modelUrl = pickTaskModelUrl(output as Record<string, unknown>);
|
||||
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: result.data.task_id,
|
||||
type: result.data.type,
|
||||
status: result.data.status,
|
||||
progress: result.data.progress ?? null,
|
||||
output,
|
||||
modelUrl,
|
||||
previewImageUrl,
|
||||
errorCode: result.data.error_code ?? null,
|
||||
errorMessage: result.data.error_msg ?? null,
|
||||
},
|
||||
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 : "查询任务失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
223
app/api/canvas/3d/tasks/route.ts
Normal file
223
app/api/canvas/3d/tasks/route.ts
Normal file
@ -0,0 +1,223 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
108
app/api/canvas/3d/upload-image/route.ts
Normal file
108
app/api/canvas/3d/upload-image/route.ts
Normal file
@ -0,0 +1,108 @@
|
||||
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;
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
|
||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
function extractImageToken(data: unknown): string | null {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") return data.trim() || null;
|
||||
if (typeof data !== "object") return null;
|
||||
const record = data as Record<string, unknown>;
|
||||
const tokenCandidates = [record.image_token, record.file_token, record.token];
|
||||
for (const candidate of tokenCandidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ success: false, error: "请上传图片文件" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "仅支持 PNG/JPEG/WEBP 图片" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size <= 0 || file.size > MAX_UPLOAD_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "图片大小需在 1B~10MB 之间" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getTripoProviderConfig();
|
||||
|
||||
const uploadPaths = ["/upload/sts", "/upload"];
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const path of uploadPaths) {
|
||||
try {
|
||||
const submitData = new FormData();
|
||||
submitData.set("file", file, file.name || "upload.png");
|
||||
const result = await tripoRequest<Record<string, unknown>>(config, {
|
||||
method: "POST",
|
||||
path,
|
||||
formData: submitData,
|
||||
});
|
||||
const imageToken = extractImageToken(result.data);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
imageToken,
|
||||
file: result.data,
|
||||
},
|
||||
traceId: result.traceId,
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error instanceof TripoApiError && error.httpStatus === 404) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("图片上传失败");
|
||||
} 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 : "图片上传失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
597
app/canvas/[id]/model3d/page.tsx
Normal file
597
app/canvas/[id]/model3d/page.tsx
Normal file
@ -0,0 +1,597 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Box,
|
||||
Download,
|
||||
Loader2,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Model3DViewer } from "@/components/canvas/model3d/Model3DViewer";
|
||||
import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
||||
import { canvasService, model3DGenerationService, type Model3DTaskStatus } from "@/services/canvas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type GenerationMode = "text" | "image";
|
||||
|
||||
type TaskRecord = {
|
||||
taskId: string;
|
||||
status: Model3DTaskStatus;
|
||||
prompt: string;
|
||||
mode: GenerationMode;
|
||||
modelVersion: string;
|
||||
createdAt: number;
|
||||
progress?: number | null;
|
||||
modelUrl?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
const MODEL_VERSION_OPTIONS = [
|
||||
"Turbo-v1.0-20250506",
|
||||
"v3.1-20260211",
|
||||
"v3.0-20250812",
|
||||
"v2.5-20250123",
|
||||
"v2.0-20240919",
|
||||
"v1.4-20240625",
|
||||
] as const;
|
||||
|
||||
const FINAL_TASK_STATUS = new Set<Model3DTaskStatus>([
|
||||
"success",
|
||||
"failed",
|
||||
"banned",
|
||||
"expired",
|
||||
"cancelled",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
case "running":
|
||||
case "success":
|
||||
case "failed":
|
||||
case "banned":
|
||||
case "expired":
|
||||
case "cancelled":
|
||||
case "unknown":
|
||||
return status;
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function formatBalance(data: Record<string, any> | undefined): string | null {
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const candidates = [data.balance, data.available_balance, data.credits, data.amount];
|
||||
for (const value of candidates) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractModelFormat(modelUrl?: string | null): string {
|
||||
const value = String(modelUrl || "").toLowerCase();
|
||||
if (!value) return "3D";
|
||||
if (value.includes(".gltf")) return "gltf";
|
||||
if (value.includes(".glb")) return "glb";
|
||||
if (value.includes(".usdz")) return "usdz";
|
||||
if (value.includes(".fbx")) return "fbx";
|
||||
if (value.includes(".obj")) return "obj";
|
||||
if (value.includes(".stl")) return "stl";
|
||||
if (value.includes(".3mf")) return "3mf";
|
||||
return "3d";
|
||||
}
|
||||
|
||||
export default function CanvasModel3DPage() {
|
||||
const router = useRouter();
|
||||
const routeParams = useParams();
|
||||
const id = String(routeParams?.id || "");
|
||||
|
||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||
|
||||
const [mode, setMode] = useState<GenerationMode>("text");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [modelVersion, setModelVersion] = useState<(typeof MODEL_VERSION_OPTIONS)[number]>(
|
||||
"Turbo-v1.0-20250506"
|
||||
);
|
||||
const [faceLimit, setFaceLimit] = useState(120000);
|
||||
const [texture, setTexture] = useState(true);
|
||||
const [pbr, setPbr] = useState(true);
|
||||
const [autoSize, setAutoSize] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [referenceImage, setReferenceImage] = useState<File | null>(null);
|
||||
const [referencePreviewUrl, setReferencePreviewUrl] = useState<string | null>(null);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||||
const [balanceLabel, setBalanceLabel] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBalance = async () => {
|
||||
const result = await model3DGenerationService.getBalance();
|
||||
if (!result.success) return;
|
||||
const label = formatBalance(result.data);
|
||||
if (label) setBalanceLabel(label);
|
||||
};
|
||||
void loadBalance();
|
||||
}, []);
|
||||
|
||||
const upsertTaskHistory = useCallback((next: TaskRecord) => {
|
||||
setTaskHistory((prev) => {
|
||||
const index = prev.findIndex((item) => item.taskId === next.taskId);
|
||||
if (index >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], ...next };
|
||||
return updated;
|
||||
}
|
||||
return [next, ...prev].slice(0, 20);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!referenceImage) {
|
||||
setReferencePreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(referenceImage);
|
||||
setReferencePreviewUrl(objectUrl);
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [referenceImage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTask || FINAL_TASK_STATUS.has(currentTask.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
|
||||
const runPoll = async () => {
|
||||
if (cancelled) return;
|
||||
attempts += 1;
|
||||
const result = await model3DGenerationService.getTask(currentTask.taskId);
|
||||
if (!result.success || !result.data) {
|
||||
const failMessage = result.error || "任务查询失败";
|
||||
const failedRecord: TaskRecord = {
|
||||
...currentTask,
|
||||
status: "failed",
|
||||
error: failMessage,
|
||||
};
|
||||
setCurrentTask(failedRecord);
|
||||
upsertTaskHistory(failedRecord);
|
||||
toast.error(failMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = normalizeTaskStatus(result.data.status);
|
||||
const nextRecord: TaskRecord = {
|
||||
...currentTask,
|
||||
status,
|
||||
progress: result.data.progress ?? null,
|
||||
modelUrl: result.data.modelUrl ?? currentTask.modelUrl ?? null,
|
||||
previewImageUrl: result.data.previewImageUrl ?? currentTask.previewImageUrl ?? null,
|
||||
error: status === "failed" ? result.data.errorMessage || "任务失败" : null,
|
||||
};
|
||||
setCurrentTask(nextRecord);
|
||||
upsertTaskHistory(nextRecord);
|
||||
|
||||
if (status === "success") {
|
||||
toast.success("3D 模型生成完成");
|
||||
}
|
||||
|
||||
if (attempts >= 300 && !FINAL_TASK_STATUS.has(status)) {
|
||||
const timeoutRecord: TaskRecord = {
|
||||
...nextRecord,
|
||||
status: "failed",
|
||||
error: "任务轮询超时,请稍后重试",
|
||||
};
|
||||
setCurrentTask(timeoutRecord);
|
||||
upsertTaskHistory(timeoutRecord);
|
||||
toast.error("任务轮询超时,请稍后重试");
|
||||
}
|
||||
};
|
||||
|
||||
void runPoll();
|
||||
const timer = window.setInterval(() => {
|
||||
void runPoll();
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [currentTask, upsertTaskHistory]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const normalizedPrompt = prompt.trim();
|
||||
if (mode === "text" && !normalizedPrompt) {
|
||||
toast.error("请输入提示词");
|
||||
return;
|
||||
}
|
||||
if (mode === "image" && !referenceImage) {
|
||||
toast.error("图生3D请先上传参考图");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let imageToken: string | undefined;
|
||||
let filePayload: Record<string, any> | undefined;
|
||||
|
||||
if (mode === "image" && referenceImage) {
|
||||
setUploadingImage(true);
|
||||
const uploadResult = await model3DGenerationService.uploadImage(referenceImage);
|
||||
setUploadingImage(false);
|
||||
if (!uploadResult.success || !uploadResult.data) {
|
||||
throw new Error(uploadResult.error || "上传参考图失败");
|
||||
}
|
||||
imageToken = uploadResult.data.imageToken;
|
||||
filePayload = uploadResult.data.file;
|
||||
}
|
||||
|
||||
const createPayload: Record<string, any> = {
|
||||
taskType: mode === "text" ? "text_to_model" : "image_to_model",
|
||||
prompt: normalizedPrompt,
|
||||
modelVersion,
|
||||
faceLimit,
|
||||
texture,
|
||||
pbr,
|
||||
autoSize,
|
||||
};
|
||||
|
||||
if (mode === "image") {
|
||||
if (imageToken) {
|
||||
createPayload.imageToken = imageToken;
|
||||
} else if (filePayload) {
|
||||
createPayload.file = filePayload;
|
||||
} else {
|
||||
throw new Error("参考图上传未返回可用 token");
|
||||
}
|
||||
}
|
||||
|
||||
const createResult = await model3DGenerationService.createTask(createPayload);
|
||||
if (!createResult.success || !createResult.data?.taskId) {
|
||||
throw new Error(createResult.error || "创建任务失败");
|
||||
}
|
||||
|
||||
const newTask: TaskRecord = {
|
||||
taskId: createResult.data.taskId,
|
||||
status: normalizeTaskStatus(createResult.data.status),
|
||||
prompt: normalizedPrompt,
|
||||
mode,
|
||||
modelVersion,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
setCurrentTask(newTask);
|
||||
upsertTaskHistory(newTask);
|
||||
toast.success("已提交 3D 生成任务");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "创建任务失败");
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentModelUrl = currentTask?.modelUrl || null;
|
||||
const currentPreviewImage = currentTask?.previewImageUrl || referencePreviewUrl || null;
|
||||
|
||||
const importDisabled = !currentTask || currentTask.status !== "success" || !currentModelUrl;
|
||||
|
||||
const handleImportToCanvas = async () => {
|
||||
if (!currentModelUrl || !currentTask) {
|
||||
toast.error("暂无可导入的 3D 模型");
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportX = viewport.x || 0;
|
||||
const viewportY = viewport.y || 0;
|
||||
const viewportZoom = viewport.zoom || 1;
|
||||
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
|
||||
const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720;
|
||||
const targetWidth = 360;
|
||||
const targetHeight = 300;
|
||||
const x = (stageWidth * 0.48 - viewportX) / viewportZoom - targetWidth / 2;
|
||||
const y = (stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2;
|
||||
|
||||
addNode({
|
||||
type: "model3d",
|
||||
position: { x, y },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: currentModelUrl,
|
||||
previewImage: currentTask.previewImageUrl || undefined,
|
||||
prompt: currentTask.prompt,
|
||||
modelVersion: currentTask.modelVersion,
|
||||
format: extractModelFormat(currentModelUrl),
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: currentTask.taskId,
|
||||
mode: currentTask.mode,
|
||||
progress: currentTask.progress,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
|
||||
try {
|
||||
await canvasService.saveCanvasState(id, getCanvasState());
|
||||
} catch (error) {
|
||||
console.error("[Model3DPage] 保存画布失败:", error);
|
||||
}
|
||||
toast.success("3D 模型已导入画布");
|
||||
};
|
||||
|
||||
const handleDownloadCurrent = () => {
|
||||
if (!currentModelUrl) {
|
||||
toast.error("暂无可下载的 3D 模型");
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.href = currentModelUrl;
|
||||
link.download = `tripo-model-${currentTask?.taskId || "result"}.${extractModelFormat(
|
||||
currentModelUrl
|
||||
)}`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const statusLabel = useMemo(() => {
|
||||
if (!currentTask) return "未开始";
|
||||
if (currentTask.status === "queued") return "排队中";
|
||||
if (currentTask.status === "running") return "生成中";
|
||||
if (currentTask.status === "success") return "生成成功";
|
||||
if (currentTask.status === "failed") return "生成失败";
|
||||
if (currentTask.status === "cancelled") return "已取消";
|
||||
if (currentTask.status === "expired") return "已过期";
|
||||
if (currentTask.status === "banned") return "审核拦截";
|
||||
return "状态未知";
|
||||
}, [currentTask]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col bg-background p-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={() => router.push(`/canvas/${id}`)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">3D 生成工作台</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tripo3D 文生模型 / 图生模型 · 一键导入无限画布
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-md border bg-card px-3 py-1.5 text-xs text-muted-foreground">
|
||||
额度: {balanceLabel || "--"}
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => router.push(`/canvas/${id}`)}>
|
||||
返回画布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 xl:grid-cols-[320px_minmax(0,1fr)_300px]">
|
||||
<Card className="min-h-0">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">生成参数</CardTitle>
|
||||
<CardDescription>参考图布局,使用站内 UI 规范</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg border bg-muted/30 p-1">
|
||||
<Button
|
||||
variant={mode === "text" ? "default" : "ghost"}
|
||||
className="h-8"
|
||||
onClick={() => setMode("text")}
|
||||
>
|
||||
文生3D
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "image" ? "default" : "ghost"}
|
||||
className="h-8"
|
||||
onClick={() => setMode("image")}
|
||||
>
|
||||
图生3D
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model3d-prompt">提示词</Label>
|
||||
<Textarea
|
||||
id="model3d-prompt"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="例如:一个Q版机器人手办,白色机身,PBR材质"
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "image" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model3d-image">参考图</Label>
|
||||
<label
|
||||
htmlFor="model3d-image"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed px-3 py-6 text-sm transition-colors",
|
||||
"hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
<span>{referenceImage ? referenceImage.name : "点击上传 PNG/JPG/WEBP"}</span>
|
||||
</label>
|
||||
<Input
|
||||
id="model3d-image"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => setReferenceImage(event.target.files?.[0] || null)}
|
||||
/>
|
||||
{referencePreviewUrl && (
|
||||
<img
|
||||
src={referencePreviewUrl}
|
||||
alt="参考图预览"
|
||||
className="h-28 w-full rounded-md border object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>模型版本</Label>
|
||||
<Select value={modelVersion} onValueChange={(value) => setModelVersion(value as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODEL_VERSION_OPTIONS.map((item) => (
|
||||
<SelectItem value={item} key={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="face-limit">面数上限</Label>
|
||||
<Input
|
||||
id="face-limit"
|
||||
type="number"
|
||||
min={5000}
|
||||
max={500000}
|
||||
value={faceLimit}
|
||||
onChange={(event) => setFaceLimit(Number(event.target.value || 0))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>生成贴图</span>
|
||||
<Switch checked={texture} onCheckedChange={setTexture} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>PBR 材质</span>
|
||||
<Switch checked={pbr} onCheckedChange={setPbr} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>自动尺寸</span>
|
||||
<Switch checked={autoSize} onCheckedChange={setAutoSize} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button className="w-full gap-2" onClick={handleGenerate} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{uploadingImage ? "上传参考图..." : "提交任务中..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
开始生成
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-0">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Box className="h-4 w-4" />
|
||||
3D 预览
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{statusLabel}
|
||||
{typeof currentTask?.progress === "number" ? ` · ${Math.round(currentTask.progress)}%` : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-[calc(100%-4.5rem)] flex-col gap-3">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border">
|
||||
<Model3DViewer modelUrl={currentModelUrl} previewImageUrl={currentPreviewImage} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<Button variant="default" className="gap-2" onClick={handleImportToCanvas} disabled={importDisabled}>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
导入画布
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
onClick={handleDownloadCurrent}
|
||||
disabled={!currentModelUrl}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
下载模型
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2" onClick={handleGenerate} disabled={isSubmitting}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-0">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">任务列表</CardTitle>
|
||||
<CardDescription>最近 20 条任务记录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[calc(100%-4.5rem)] p-0">
|
||||
<ScrollArea className="h-full px-4 pb-4">
|
||||
<div className="space-y-2 pt-1">
|
||||
{taskHistory.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
暂无任务记录
|
||||
</div>
|
||||
) : (
|
||||
taskHistory.map((task) => (
|
||||
<button
|
||||
key={task.taskId}
|
||||
type="button"
|
||||
onClick={() => setCurrentTask(task)}
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-3 text-left text-xs transition-colors",
|
||||
currentTask?.taskId === task.taskId
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{task.mode === "text" ? "文生3D" : "图生3D"}</span>
|
||||
<span className="text-muted-foreground">{normalizeTaskStatus(task.status)}</span>
|
||||
</div>
|
||||
<p className="truncate text-muted-foreground">{task.prompt || "图生3D任务"}</p>
|
||||
<p className="mt-1 truncate text-[11px] text-muted-foreground">ID: {task.taskId}</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
Grid3X3,
|
||||
Settings,
|
||||
PenTool,
|
||||
Box,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@ -771,6 +772,20 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/canvas/${id}/model3d`)}
|
||||
className="rounded-xl h-9 w-9"
|
||||
>
|
||||
<Box className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">3D生成</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="horizontal" className="w-5 my-0.5" />
|
||||
|
||||
<Tooltip>
|
||||
|
||||
@ -6,6 +6,7 @@ import { motion } from "framer-motion";
|
||||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||||
import type { CanvasNode, Position } from "@/types/canvas";
|
||||
import { ImageNode } from "./nodes/ImageNode";
|
||||
import { Model3DNode } from "./nodes/Model3DNode";
|
||||
import { TextNode } from "./nodes/TextNode";
|
||||
import { VideoNode } from "./nodes/VideoNode";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -982,6 +983,9 @@ export function CanvasWorkspace() {
|
||||
{node.type === 'video' && (
|
||||
<VideoNode node={node} isSelected={isSelected} />
|
||||
)}
|
||||
{node.type === 'model3d' && (
|
||||
<Model3DNode node={node} isSelected={isSelected} />
|
||||
)}
|
||||
{node.type === 'message' && (
|
||||
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
|
||||
<span className="text-sm">{(node.data as any).content}</span>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export { CanvasWorkspace } from './CanvasWorkspace';
|
||||
export { ImageNode } from './nodes/ImageNode';
|
||||
export { Model3DNode } from './nodes/Model3DNode';
|
||||
export { TextNode } from './nodes/TextNode';
|
||||
export { VideoNode } from './nodes/VideoNode';
|
||||
|
||||
131
components/canvas/model3d/Model3DViewer.tsx
Normal file
131
components/canvas/model3d/Model3DViewer.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useMemo } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Environment, OrbitControls, useGLTF } from "@react-three/drei";
|
||||
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Model3DViewerProps {
|
||||
modelUrl?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
}
|
||||
|
||||
class ViewerErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode; fallback: React.ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode; fallback: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: { children: React.ReactNode }) {
|
||||
if (prevProps.children !== this.props.children && this.state.hasError) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function ModelScene({ url }: { url: string }) {
|
||||
const model = useGLTF(url);
|
||||
const scene = useMemo(() => model.scene.clone(), [model.scene]);
|
||||
return <primitive object={scene} scale={1} />;
|
||||
}
|
||||
|
||||
function isGlbOrGltf(url?: string | null) {
|
||||
const value = String(url || "").toLowerCase();
|
||||
return /\.(gltf|glb)(\?|#|$)/.test(value);
|
||||
}
|
||||
|
||||
function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-muted/20 p-4">
|
||||
{previewImageUrl ? (
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="3D 预览"
|
||||
className="max-h-[65%] max-w-full rounded-lg border object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Box className="h-14 w-14 text-muted-foreground/60" />
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">生成完成后可在此区域预览 3D 资产</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Model3DViewer({ modelUrl, previewImageUrl }: Model3DViewerProps) {
|
||||
if (!modelUrl) {
|
||||
return <EmptyState previewImageUrl={previewImageUrl} />;
|
||||
}
|
||||
|
||||
if (!isGlbOrGltf(modelUrl)) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-muted/20 p-4">
|
||||
<AlertCircle className="h-8 w-8 text-amber-500" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前文件格式不支持内嵌预览,请在新窗口打开
|
||||
</p>
|
||||
<Button asChild variant="secondary" className="gap-2">
|
||||
<a href={modelUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
打开3D文件
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewerErrorBoundary
|
||||
fallback={
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-muted/20 p-4">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-muted-foreground">3D 预览加载失败,请尝试在新窗口打开</p>
|
||||
<Button asChild variant="secondary" className="gap-2">
|
||||
<a href={modelUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
打开3D文件
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Canvas camera={{ position: [2, 1.5, 2], fov: 45 }}>
|
||||
<ambientLight intensity={0.8} />
|
||||
<directionalLight position={[5, 8, 5]} intensity={1.2} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<mesh>
|
||||
<boxGeometry args={[0.6, 0.6, 0.6]} />
|
||||
<meshStandardMaterial color="#94a3b8" wireframe />
|
||||
</mesh>
|
||||
}
|
||||
>
|
||||
<ModelScene url={modelUrl} />
|
||||
<Environment preset="city" />
|
||||
</Suspense>
|
||||
<OrbitControls enablePan enableZoom enableRotate />
|
||||
</Canvas>
|
||||
<div className="pointer-events-none absolute left-3 top-3 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3" />
|
||||
拖拽旋转 / 滚轮缩放
|
||||
</span>
|
||||
</div>
|
||||
</ViewerErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
184
components/canvas/nodes/Model3DNode.tsx
Normal file
184
components/canvas/nodes/Model3DNode.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Box,
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Model3DNodeProps {
|
||||
node: CanvasNode;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
function inferModelFileName(data: Model3DNodeData, nodeId: string) {
|
||||
const raw = String(data.src || "").trim();
|
||||
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const filename = url.pathname.split("/").filter(Boolean).pop();
|
||||
if (filename) return decodeURIComponent(filename);
|
||||
} catch {}
|
||||
return raw.split("/").pop() || `model3d-${nodeId.slice(0, 6)}.glb`;
|
||||
}
|
||||
|
||||
function inferModelFormat(data: Model3DNodeData) {
|
||||
if (data.format) return data.format.toUpperCase();
|
||||
const src = String(data.src || "").toLowerCase();
|
||||
if (src.endsWith(".gltf")) return "GLTF";
|
||||
if (src.endsWith(".glb")) return "GLB";
|
||||
if (src.endsWith(".usdz")) return "USDZ";
|
||||
if (src.endsWith(".fbx")) return "FBX";
|
||||
if (src.endsWith(".obj")) return "OBJ";
|
||||
if (src.endsWith(".stl")) return "STL";
|
||||
if (src.endsWith(".3mf")) return "3MF";
|
||||
return "3D";
|
||||
}
|
||||
|
||||
export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
|
||||
const data = node.data as Model3DNodeData;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!data.src) {
|
||||
toast.error("暂无可打开的 3D 地址");
|
||||
return;
|
||||
}
|
||||
window.open(data.src, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!data.src) {
|
||||
toast.error("暂无可下载的 3D 文件");
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.href = data.src;
|
||||
link.download = inferModelFileName(data, node.id);
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!data.src) {
|
||||
toast.error("暂无可复制的地址");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.src);
|
||||
toast.success("已复制 3D 链接");
|
||||
} catch {
|
||||
toast.error("复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full w-full overflow-hidden rounded-lg border bg-card shadow-sm",
|
||||
isSelected && "border-primary"
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{data.status === "generating" ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/40">
|
||||
<Loader2 className="h-7 w-7 animate-spin text-primary" />
|
||||
<span className="text-xs text-muted-foreground">3D 生成中...</span>
|
||||
</div>
|
||||
) : data.status === "failed" ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-destructive/10">
|
||||
<AlertCircle className="h-7 w-7 text-destructive" />
|
||||
<span className="text-xs text-destructive">3D 生成失败</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="relative flex-1 overflow-hidden bg-muted/30">
|
||||
{data.previewImage ? (
|
||||
<img
|
||||
src={data.previewImage}
|
||||
alt={data.prompt || "3D 预览"}
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Box className="h-12 w-12 text-muted-foreground/60" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 top-2 rounded-md bg-background/85 px-2 py-0.5 text-[10px] font-medium">
|
||||
{inferModelFormat(data)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 border-t px-3 py-2">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{data.prompt || inferModelFileName(data, node.id)}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{data.modelVersion ? `模型: ${data.modelVersion}` : "Tripo3D 结果"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHovered && data.status === "completed" && (
|
||||
<div className="absolute right-2 top-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleOpen}
|
||||
title="打开3D"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-7 w-7">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleOpen}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
打开 3D
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制链接
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
505
docs/无限画布-3D生成-Tripo3D-开发文档.md
Normal file
505
docs/无限画布-3D生成-Tripo3D-开发文档.md
Normal file
@ -0,0 +1,505 @@
|
||||
# 无限画布 3D 生成(Tripo3D)开发文档
|
||||
|
||||
## 0. 文档信息
|
||||
|
||||
- 文档版本:v1.0(开发方案)
|
||||
- 编写日期:2026-02-28
|
||||
- 适用项目:`ai-chatbot-main`(Next.js + 无限画布)
|
||||
- 目标:在无限画布左侧工具栏新增「3D生成」入口,并新增独立 3D 生成页面,接入 Tripo3D OpenAPI,实现文生3D/图生3D并回流到画布节点
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前无限画布已支持图片、视频、文本、实时画布等工作流,但缺少 3D 资产生产能力。新增 3D 生成能力后,用户可在同一工作空间内完成:
|
||||
|
||||
1. 文案到 3D(Text to 3D)
|
||||
2. 图片到 3D(Image to 3D)
|
||||
3. 3D 资产预览、格式转换、导入画布、下载复用
|
||||
|
||||
本次先完成「文档先行」:明确 API、架构、页面、数据流、错误处理、安全与验收标准,为后续编码提供统一基线。
|
||||
|
||||
---
|
||||
|
||||
## 2. Tripo3D API 研究结论(用于接入实现)
|
||||
|
||||
## 2.1 基础信息
|
||||
|
||||
- Base URL:`https://api.tripo3d.ai/v2/openapi`
|
||||
- 鉴权:`Authorization: Bearer <TRIPO_API_KEY>`
|
||||
- 通用响应结构:
|
||||
- 成功:`{ code: 0, data: ... }`
|
||||
- 失败:`{ code: 非0, message, suggestion }`
|
||||
- 响应头可包含追踪字段:`X-Tripo-Trace-ID`
|
||||
|
||||
## 2.2 本项目相关接口
|
||||
|
||||
| 能力 | 方法 | 路径 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 创建任务 | POST | `/task` | 发起 text_to_model / image_to_model / convert_model 等任务 |
|
||||
| 查询任务 | GET | `/task/{task_id}` | 轮询任务状态与输出 |
|
||||
| 上传图片(STS) | POST | `/upload/sts` | 图生3D前上传参考图并获取 token |
|
||||
| STS Token | POST | `/upload/sts/token` | 进阶上传流程(S3 直传) |
|
||||
| 查询余额 | GET | `/user/balance` | 展示可用额度 |
|
||||
|
||||
## 2.3 任务类型(本期聚焦)
|
||||
|
||||
- `text_to_model`(MVP 必做)
|
||||
- `image_to_model`(MVP 必做)
|
||||
- `convert_model`(MVP 建议做:统一为 GLB/GLTF)
|
||||
- `refine_model`(V1.1)
|
||||
- `multiview_to_model`(V1.1)
|
||||
|
||||
## 2.4 任务状态
|
||||
|
||||
- 进行中:`queued`、`running`
|
||||
- 结束态:`success`、`failed`、`banned`、`expired`、`cancelled`、`unknown`
|
||||
|
||||
建议前端状态映射:
|
||||
|
||||
- `queued/running` -> `generating`
|
||||
- `success` -> `completed`
|
||||
- 其他结束态 -> `failed`
|
||||
|
||||
## 2.5 输出字段(按可用性优先级)
|
||||
|
||||
任务成功后输出可能在以下字段出现:
|
||||
|
||||
1. `pbr_model`
|
||||
2. `model`
|
||||
3. `base_model`
|
||||
4. `generated_image`(预览图)
|
||||
5. `rendered_image`(预览图)
|
||||
|
||||
建议导入画布时优先选择:`pbr_model > model > base_model`。
|
||||
|
||||
## 2.6 已确认可选模型版本(写入产品文案与参数面板)
|
||||
|
||||
可用版本示例(官方文档中出现):
|
||||
|
||||
- `Turbo-v1.0-20250506`(建议默认)
|
||||
- `v3.1-20260211`
|
||||
- `v3.0-20250812`
|
||||
- `v2.5-20250123`
|
||||
- `v2.0-20240919`
|
||||
- `v1.4-20240625`(部分可能已废弃)
|
||||
|
||||
说明:模型版本可能变更,运行时应允许后台动态配置默认值与可选列表。
|
||||
|
||||
## 2.7 关键约束
|
||||
|
||||
- 速率与并发(文档值):
|
||||
- Refine 并发上限:5
|
||||
- 其他任务并发上限:10
|
||||
- 图片上传:10 QPS
|
||||
- 图片输入:
|
||||
- 格式:`webp | jpeg | png`
|
||||
- 尺寸:20~6000px(建议 >256px)
|
||||
- 输出 URL 可能短时有效(文档提到默认较短有效期),不能只在前端缓存,建议尽快回存到自有存储
|
||||
|
||||
## 2.8 典型错误码(需做前端友好映射)
|
||||
|
||||
- `1002`:鉴权失败
|
||||
- `1004`:参数错误
|
||||
- `1007`:触发限流
|
||||
- `2000`:超出限制
|
||||
- `2001`:任务不存在
|
||||
- `2002`:任务类型不支持
|
||||
- `2004`:文件类型不支持
|
||||
- `2008`:内容策略拦截
|
||||
- `2010`:余额不足
|
||||
- `2015`:模型版本废弃
|
||||
- `2017`:模型版本无效
|
||||
|
||||
---
|
||||
|
||||
## 3. 业务范围定义
|
||||
|
||||
## 3.1 MVP(本期上线)
|
||||
|
||||
1. 无限画布左侧工具栏新增 `3D生成` 按钮
|
||||
2. 新增页面:`/canvas/[id]/model3d`
|
||||
3. 支持文生3D、图生3D
|
||||
4. 支持任务轮询、结果预览、一键导入画布
|
||||
5. 支持下载 3D 文件(至少 GLB/GLTF)
|
||||
6. 错误态、超时态、重试机制可用
|
||||
|
||||
## 3.2 V1.1(增强)
|
||||
|
||||
1. refine_model 二次精修
|
||||
2. 批量任务队列与历史检索
|
||||
3. 多格式导出(USDZ/FBX/OBJ/STL/3MF)
|
||||
4. 3D 资产库与复用
|
||||
|
||||
---
|
||||
|
||||
## 4. 现有系统接入点(基于当前代码)
|
||||
|
||||
已确认关键文件:
|
||||
|
||||
- 画布主页面:`/Users/lanmu/Desktop/ai-chatbot-main/app/canvas/[id]/page.tsx`
|
||||
- 画布工作区:`/Users/lanmu/Desktop/ai-chatbot-main/components/canvas/CanvasWorkspace.tsx`
|
||||
- 画布状态:`/Users/lanmu/Desktop/ai-chatbot-main/store/canvas/index.ts`
|
||||
- 节点类型定义:`/Users/lanmu/Desktop/ai-chatbot-main/types/canvas/index.ts`
|
||||
- 现有独立功能页范式(实时画布):`/Users/lanmu/Desktop/ai-chatbot-main/app/canvas/[id]/realtime/page.tsx`
|
||||
- 全局设计 Token:`/Users/lanmu/Desktop/ai-chatbot-main/app/globals.css`
|
||||
|
||||
当前节点类型暂不含 3D,需扩展:
|
||||
|
||||
- 现状:`message | image | video | text | scene-fusion`
|
||||
- 新增:`model3d`
|
||||
|
||||
项目已具备 3D 渲染依赖(无需新增核心依赖):
|
||||
|
||||
- `three`
|
||||
- `@react-three/fiber`
|
||||
- `@react-three/drei`
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面与交互设计(参考图布局 + 站内 UI 规范)
|
||||
|
||||
## 5.1 页面路由
|
||||
|
||||
- 新页面:`/canvas/[id]/model3d`
|
||||
- 从画布侧栏按钮进入;关闭后返回 `/canvas/[id]`
|
||||
|
||||
## 5.2 结构分区(桌面端)
|
||||
|
||||
- 顶部:轻量标题栏(返回、项目名、余额、帮助)
|
||||
- 左侧参数区(约 320px):
|
||||
- 生成模式切换:文生3D / 图生3D
|
||||
- Prompt 输入
|
||||
- 参考图上传(图生3D)
|
||||
- 高级参数折叠(模型版本、面数、贴图、PBR、seed)
|
||||
- 主按钮:开始生成
|
||||
- 中央预览区(主视觉):
|
||||
- 3D Viewer(OrbitControls)
|
||||
- 状态覆盖层(排队中/生成中/失败)
|
||||
- 结果操作(导入画布、下载、重新生成)
|
||||
- 右侧任务区(约 300px):
|
||||
- 当前任务状态卡
|
||||
- 最近任务历史(可重开结果)
|
||||
- 输出资源信息(格式、大小、创建时间)
|
||||
|
||||
## 5.3 视觉规范(必须遵循站内风格)
|
||||
|
||||
使用现有 Token,不复制参考图配色:
|
||||
|
||||
- 背景:`bg-background`
|
||||
- 卡片:`bg-card border`
|
||||
- 主色:`bg-primary text-primary-foreground`
|
||||
- 辅助文本:`text-muted-foreground`
|
||||
- 圆角:与现有画布一致(`rounded-xl/2xl`)
|
||||
- 毛玻璃与阴影:沿用 `backdrop-blur` + `shadow-sm/2xl`
|
||||
|
||||
## 5.4 移动端策略
|
||||
|
||||
- 左右栏折叠为抽屉(参数抽屉 + 任务抽屉)
|
||||
- 中央 Viewer 全宽
|
||||
- 关键操作固定底栏(生成 / 导入画布)
|
||||
|
||||
---
|
||||
|
||||
## 6. 系统架构设计
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Canvas Sidebar: 3D生成"] --> B["/canvas/[id]/model3d 页面"]
|
||||
B --> C["Next.js API: /api/canvas/3d/*"]
|
||||
C --> D["Tripo OpenAPI"]
|
||||
D --> C
|
||||
C --> E["任务状态返回前端"]
|
||||
E --> B
|
||||
B --> F["导入画布 model3d 节点"]
|
||||
F --> G["CanvasStore + CanvasProject.canvasData"]
|
||||
```
|
||||
|
||||
## 6.1 前端层
|
||||
|
||||
- 新建页面组件:`app/canvas/[id]/model3d/page.tsx`
|
||||
- 新建 3D 业务组件(建议):
|
||||
- `components/canvas/Model3DStudio.tsx`
|
||||
- `components/canvas/model3d/Model3DViewer.tsx`
|
||||
- `components/canvas/model3d/TaskHistoryPanel.tsx`
|
||||
- 在 `app/canvas/[id]/page.tsx` 左侧工具栏新增入口按钮(图标建议 `Box`)
|
||||
|
||||
## 6.2 后端 API 代理层
|
||||
|
||||
新增接口(同域代理,隐藏 Tripo Key):
|
||||
|
||||
1. `POST /api/canvas/3d/upload-image`
|
||||
2. `POST /api/canvas/3d/tasks`
|
||||
3. `GET /api/canvas/3d/tasks/[taskId]`
|
||||
4. `GET /api/canvas/3d/balance`
|
||||
5. `POST /api/canvas/3d/import`(可选,做导入封装)
|
||||
|
||||
## 6.3 Tripo 适配层
|
||||
|
||||
新增服务层(建议):
|
||||
|
||||
- `lib/tripo/client.ts`:底层请求、鉴权头、trace-id 提取
|
||||
- `lib/tripo/types.ts`:请求/响应类型
|
||||
- `lib/tripo/mappers.ts`:错误码与状态映射
|
||||
|
||||
---
|
||||
|
||||
## 7. 详细数据流
|
||||
|
||||
## 7.1 文生3D流程
|
||||
|
||||
1. 前端提交 `prompt + modelVersion + params`
|
||||
2. 后端 `POST /task`(`type=text_to_model`)
|
||||
3. 返回 `taskId`
|
||||
4. 前端轮询 `GET /api/canvas/3d/tasks/[taskId]`
|
||||
5. 成功后拿到模型 URL(优先 `pbr_model`)
|
||||
6. 预览通过后点击「导入画布」
|
||||
7. 生成 `model3d` 节点并持久化画布
|
||||
|
||||
## 7.2 图生3D流程
|
||||
|
||||
1. 上传参考图到后端
|
||||
2. 后端调用 Tripo `POST /upload/sts`,拿到 image token
|
||||
3. 后端 `POST /task`(`type=image_to_model`)
|
||||
4. 后续轮询与导入同上
|
||||
|
||||
## 7.3 轮询策略
|
||||
|
||||
- 初始间隔:2s
|
||||
- 单任务最大时长:8~10 分钟(可配置)
|
||||
- 超时处理:标记失败并提示可重试
|
||||
- 页面离开后自动停止轮询
|
||||
|
||||
---
|
||||
|
||||
## 8. 内部 API 合同(建议定稿)
|
||||
|
||||
## 8.1 `POST /api/canvas/3d/upload-image`
|
||||
|
||||
请求:`multipart/form-data`(file)
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"imageToken": "xxxx",
|
||||
"source": "tripo-sts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8.2 `POST /api/canvas/3d/tasks`
|
||||
|
||||
请求(文生):
|
||||
|
||||
```json
|
||||
{
|
||||
"taskType": "text_to_model",
|
||||
"prompt": "a cute toy robot",
|
||||
"modelVersion": "Turbo-v1.0-20250506",
|
||||
"faceLimit": 120000,
|
||||
"texture": true,
|
||||
"pbr": true
|
||||
}
|
||||
```
|
||||
|
||||
请求(图生):
|
||||
|
||||
```json
|
||||
{
|
||||
"taskType": "image_to_model",
|
||||
"imageToken": "xxxx",
|
||||
"modelVersion": "Turbo-v1.0-20250506",
|
||||
"faceLimit": 120000,
|
||||
"texture": true,
|
||||
"pbr": true
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"taskId": "task_xxx",
|
||||
"status": "queued"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8.3 `GET /api/canvas/3d/tasks/[taskId]`
|
||||
|
||||
响应(成功):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"taskId": "task_xxx",
|
||||
"status": "success",
|
||||
"outputs": {
|
||||
"modelUrl": "https://...",
|
||||
"previewImageUrl": "https://..."
|
||||
},
|
||||
"raw": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应(失败):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "余额不足",
|
||||
"code": 2010,
|
||||
"retryable": false
|
||||
}
|
||||
```
|
||||
|
||||
## 8.4 `GET /api/canvas/3d/balance`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"balance": 123.45,
|
||||
"unit": "credits"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 无限画布节点扩展方案
|
||||
|
||||
## 9.1 类型定义
|
||||
|
||||
更新 `types/canvas/index.ts`:
|
||||
|
||||
- `NodeType` 新增 `model3d`
|
||||
- 新增 `Model3DNodeData`:
|
||||
- `src`(模型地址)
|
||||
- `previewImage?`
|
||||
- `format`(glb/gltf/usdz/fbx/obj...)
|
||||
- `prompt?`
|
||||
- `modelVersion?`
|
||||
- `status`
|
||||
- `metadata`(faceLimit、texture、seed、taskId、traceId 等)
|
||||
|
||||
## 9.2 组件渲染
|
||||
|
||||
- 新增:`components/canvas/nodes/Model3DNode.tsx`
|
||||
- 更新:`components/canvas/CanvasWorkspace.tsx` 渲染分支
|
||||
- 节点交互:
|
||||
- 预览缩略图
|
||||
- 下载
|
||||
- 复制链接
|
||||
- 删除
|
||||
- 在新窗口查看 3D
|
||||
|
||||
## 9.3 数据持久化
|
||||
|
||||
MVP 可不加新表,直接利用现有 `CanvasProject.canvasData` JSON 存储节点。
|
||||
|
||||
V1.1 如需任务历史和统计,可新增 `Canvas3DTask` 表。
|
||||
|
||||
---
|
||||
|
||||
## 10. 后端安全与配置
|
||||
|
||||
## 10.1 鉴权与密钥
|
||||
|
||||
- Tripo API Key 仅保留在服务端(`TRIPO_API_KEY`)
|
||||
- 前端严禁直连 Tripo API
|
||||
- 所有 `/api/canvas/3d/*` 必须校验登录态
|
||||
|
||||
## 10.2 输入约束
|
||||
|
||||
- 上传文件白名单:`image/png`, `image/jpeg`, `image/webp`
|
||||
- 文件大小上限建议:10MB(可配置)
|
||||
- prompt 长度限制(例如 500)
|
||||
|
||||
## 10.3 审计日志
|
||||
|
||||
每次调用记录:
|
||||
|
||||
- userId
|
||||
- taskId
|
||||
- taskType
|
||||
- modelVersion
|
||||
- tripo code / message
|
||||
- `X-Tripo-Trace-ID`
|
||||
|
||||
---
|
||||
|
||||
## 11. 错误处理与降级策略
|
||||
|
||||
| 场景 | 检测 | 前端提示 | 系统动作 |
|
||||
|---|---|---|---|
|
||||
| API Key 无效 | code=1002 | 服务配置异常 | 终止任务并告警 |
|
||||
| 速率限制 | code=1007 | 请求过于频繁,请稍后 | 指数退避重试 |
|
||||
| 余额不足 | code=2010 | 余额不足,请充值 | 终止任务 |
|
||||
| 模型版本失效 | code=2015/2017 | 当前模型不可用,已切换默认版本 | 自动回退默认模型 |
|
||||
| 内容审核拦截 | code=2008 | 内容不符合规范 | 终止任务 |
|
||||
| 任务超时 | 本地计时超阈值 | 生成超时,请重试 | 标记失败,可重提 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 性能与可观测性
|
||||
|
||||
## 12.1 性能
|
||||
|
||||
- 限制单用户并发任务数(建议 2~3)
|
||||
- 轮询仅对活跃任务执行
|
||||
- 3D 预览默认启用轻量灯光与压缩纹理,避免页面卡顿
|
||||
|
||||
## 12.2 可观测性
|
||||
|
||||
- API 耗时埋点:upload/create/poll/import
|
||||
- 失败率指标:按任务类型、模型版本、错误码聚合
|
||||
- 成功耗时 P50/P90/P99
|
||||
|
||||
---
|
||||
|
||||
## 13. 研发排期(建议)
|
||||
|
||||
## 阶段 A(1~2 天):后端通路
|
||||
|
||||
1. Tripo client + API 代理路由
|
||||
2. 上传、创建任务、轮询、余额接口
|
||||
3. 错误映射和日志
|
||||
|
||||
## 阶段 B(1~2 天):前端页面与交互
|
||||
|
||||
1. 新建 `/canvas/[id]/model3d`
|
||||
2. 三栏式页面 + 3D Viewer
|
||||
3. 任务状态与结果操作
|
||||
|
||||
## 阶段 C(1 天):画布集成与验收
|
||||
|
||||
1. 左侧工具栏新增 3D 入口
|
||||
2. `model3d` 节点落盘与展示
|
||||
3. 全链路联调 + 回归测试
|
||||
|
||||
---
|
||||
|
||||
## 14. 验收标准(Definition of Done)
|
||||
|
||||
1. 用户可从无限画布一键进入 3D 生成页
|
||||
2. 文生3D与图生3D至少各成功 10 次,成功率 >= 95%(测试环境)
|
||||
3. 生成成功后 1 次点击可导入画布,并在刷新后仍保留节点
|
||||
4. 失败场景提示清晰,不出现无响应状态
|
||||
5. 页面风格符合站内 UI 规范(Token、按钮、间距、圆角、暗黑模式)
|
||||
|
||||
---
|
||||
|
||||
## 15. 参考文档
|
||||
|
||||
- Tripo 文档入口:<https://platform.tripo3d.ai/docs/introduction>
|
||||
- Tripo OpenAPI Base:<https://api.tripo3d.ai/v2/openapi>
|
||||
|
||||
> 备注:Tripo 文档为动态站点,模型版本与限制可能变化;实现时应将默认版本、可选版本与限额做成后台可配置项,而不是硬编码。
|
||||
@ -13,9 +13,9 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
||||
"ruxa-image": "https://api.ruxa.ai",
|
||||
"fal-image": "https://fal.run",
|
||||
"fal-video": "https://fal.run",
|
||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||
};
|
||||
|
||||
export function getProviderDefaultBaseUrl(type: AiProvider["type"]): string | undefined {
|
||||
return PROVIDER_DEFAULT_BASE_URL[type];
|
||||
}
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@ const providerBaseUrlDefaults = [
|
||||
{ type: "ruxa-image", baseUrl: "https://api.ruxa.ai" },
|
||||
{ type: "fal-image", baseUrl: "https://fal.run" },
|
||||
{ type: "fal-video", baseUrl: "https://fal.run" },
|
||||
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
|
||||
] as const;
|
||||
|
||||
function assertMigrationJournalIsConsistent() {
|
||||
@ -87,7 +88,7 @@ const runMigrate = async () => {
|
||||
ADD CONSTRAINT "AiProvider_type_check"
|
||||
CHECK (type IN (
|
||||
'alibaba', 'openai', 'custom', 'local', 'gemini',
|
||||
'huanke-gemini', 'qianwen-video', 'doubao-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'ruxa-image', 'fal-image', 'fal-video', 'zhipu'
|
||||
'huanke-gemini', 'qianwen-video', 'doubao-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'ruxa-image', 'fal-image', 'fal-video', 'zhipu', 'tripo-3d'
|
||||
));
|
||||
`);
|
||||
for (const item of providerBaseUrlDefaults) {
|
||||
|
||||
@ -1250,7 +1250,7 @@ export async function createAiProvider({
|
||||
updatedBy,
|
||||
}: {
|
||||
name: string;
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu";
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "tripo-3d";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
isEnabled?: boolean;
|
||||
@ -1288,7 +1288,7 @@ export async function updateAiProvider({
|
||||
}: {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu";
|
||||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "tripo-3d";
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
isEnabled?: boolean;
|
||||
|
||||
@ -259,7 +259,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
||||
export const aiProvider = pgTable("AiProvider", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "fal-video", "zhipu"] })
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "fal-video", "zhipu", "tripo-3d"] })
|
||||
.notNull()
|
||||
.default("custom"), // 提供商类型
|
||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||
@ -551,7 +551,7 @@ export const canvasNode = pgTable("CanvasNode", {
|
||||
projectId: uuid("projectId")
|
||||
.notNull()
|
||||
.references(() => canvasProject.id, { onDelete: "cascade" }),
|
||||
type: varchar("type", { length: 32 }).notNull(), // 'message' | 'image' | 'video' | 'text' | 'scene-fusion'
|
||||
type: varchar("type", { length: 32 }).notNull(), // 'message' | 'image' | 'video' | 'text' | 'scene-fusion' | 'model3d'
|
||||
position: jsonb("position").$type<{ x: number; y: number }>(),
|
||||
size: jsonb("size").$type<{ width: number; height: number }>(),
|
||||
data: jsonb("data"), // 节点具体数据
|
||||
|
||||
146
lib/tripo/client.ts
Normal file
146
lib/tripo/client.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import "server-only";
|
||||
|
||||
import type { TripoApiEnvelope, TripoProviderConfig } from "./types";
|
||||
|
||||
const DEFAULT_TRIPO_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||
|
||||
type TripoRequestOptions = {
|
||||
method?: "GET" | "POST";
|
||||
path: string;
|
||||
json?: Record<string, unknown>;
|
||||
formData?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export class TripoApiError extends Error {
|
||||
code: number | null;
|
||||
suggestion: string | null;
|
||||
httpStatus: number;
|
||||
traceId: string | null;
|
||||
payload: unknown;
|
||||
|
||||
constructor(params: {
|
||||
message: string;
|
||||
code?: number | null;
|
||||
suggestion?: string | null;
|
||||
httpStatus: number;
|
||||
traceId?: string | null;
|
||||
payload?: unknown;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = "TripoApiError";
|
||||
this.code = params.code ?? null;
|
||||
this.suggestion = params.suggestion ?? null;
|
||||
this.httpStatus = params.httpStatus;
|
||||
this.traceId = params.traceId ?? null;
|
||||
this.payload = params.payload;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl?: string) {
|
||||
const resolved = String(baseUrl || "").trim() || DEFAULT_TRIPO_BASE_URL;
|
||||
return resolved.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
const text = await response.text().catch(() => "");
|
||||
return text || null;
|
||||
}
|
||||
|
||||
export async function tripoRequest<T = unknown>(
|
||||
config: TripoProviderConfig,
|
||||
options: TripoRequestOptions
|
||||
): Promise<{ data: T; traceId: string | null; raw: TripoApiEnvelope<T> | unknown }> {
|
||||
const method = options.method || "GET";
|
||||
const path = options.path.startsWith("/") ? options.path : `/${options.path}`;
|
||||
const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set("Authorization", `Bearer ${config.apiKey}`);
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (options.formData) {
|
||||
body = options.formData;
|
||||
} else if (options.json) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
body = JSON.stringify(options.json);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const traceId = response.headers.get("x-tripo-trace-id");
|
||||
const parsed = await parseResponseBody(response);
|
||||
|
||||
const envelope =
|
||||
parsed && typeof parsed === "object" && "code" in (parsed as Record<string, unknown>)
|
||||
? (parsed as TripoApiEnvelope<T>)
|
||||
: null;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
envelope?.message ||
|
||||
(typeof parsed === "string" ? parsed : `Tripo 请求失败(HTTP ${response.status})`);
|
||||
throw new TripoApiError({
|
||||
message,
|
||||
code: envelope?.code ?? null,
|
||||
suggestion: envelope?.suggestion ?? null,
|
||||
httpStatus: response.status,
|
||||
traceId,
|
||||
payload: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
if (!envelope) {
|
||||
throw new TripoApiError({
|
||||
message: "Tripo 响应格式异常",
|
||||
httpStatus: response.status,
|
||||
traceId,
|
||||
payload: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.code !== 0) {
|
||||
throw new TripoApiError({
|
||||
message: envelope.message || "Tripo 请求失败",
|
||||
code: envelope.code,
|
||||
suggestion: envelope.suggestion || null,
|
||||
httpStatus: response.status,
|
||||
traceId,
|
||||
payload: envelope,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: envelope.data,
|
||||
traceId,
|
||||
raw: envelope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pickTaskModelUrl(output?: Record<string, unknown> | null): string | null {
|
||||
if (!output) return null;
|
||||
const candidates = [output.pbr_model, output.model, output.base_model];
|
||||
for (const item of candidates) {
|
||||
const value = typeof item === "string" ? item.trim() : "";
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pickTaskPreviewImageUrl(output?: Record<string, unknown> | null): string | null {
|
||||
if (!output) return null;
|
||||
const candidates = [output.generated_image, output.rendered_image];
|
||||
for (const item of candidates) {
|
||||
const value = typeof item === "string" ? item.trim() : "";
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
31
lib/tripo/server.ts
Normal file
31
lib/tripo/server.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import "server-only";
|
||||
|
||||
import { getEnabledAiProviders } from "@/lib/db/queries";
|
||||
import type { TripoProviderConfig } from "./types";
|
||||
|
||||
const DEFAULT_TRIPO_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||
|
||||
export async function getTripoProviderConfig(): Promise<TripoProviderConfig> {
|
||||
const providers = await getEnabledAiProviders();
|
||||
const tripoProvider = providers.find((item) => item.type === "tripo-3d");
|
||||
|
||||
if (tripoProvider?.apiKey?.trim()) {
|
||||
return {
|
||||
apiKey: tripoProvider.apiKey.trim(),
|
||||
baseUrl: String(tripoProvider.baseUrl || "").trim() || DEFAULT_TRIPO_BASE_URL,
|
||||
providerId: tripoProvider.id,
|
||||
providerName: tripoProvider.name,
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = String(process.env.TRIPO_API_KEY || "").trim();
|
||||
if (!envApiKey) {
|
||||
throw new Error("未配置 Tripo3D API,请在系统设置-本地AI新增 Tripo3D 提供商");
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: envApiKey,
|
||||
baseUrl: String(process.env.TRIPO_BASE_URL || "").trim() || DEFAULT_TRIPO_BASE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
46
lib/tripo/types.ts
Normal file
46
lib/tripo/types.ts
Normal file
@ -0,0 +1,46 @@
|
||||
export type TripoTaskType = "text_to_model" | "image_to_model" | "convert_model";
|
||||
|
||||
export type TripoTaskStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "success"
|
||||
| "failed"
|
||||
| "banned"
|
||||
| "expired"
|
||||
| "cancelled"
|
||||
| "unknown";
|
||||
|
||||
export interface TripoTaskOutput {
|
||||
model?: string;
|
||||
base_model?: string;
|
||||
pbr_model?: string;
|
||||
generated_image?: string;
|
||||
rendered_image?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TripoTaskData {
|
||||
task_id: string;
|
||||
type: TripoTaskType | string;
|
||||
status: TripoTaskStatus | string;
|
||||
progress?: number;
|
||||
output?: TripoTaskOutput;
|
||||
error_code?: number;
|
||||
error_msg?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TripoProviderConfig {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
providerId?: string;
|
||||
providerName?: string;
|
||||
}
|
||||
|
||||
export interface TripoApiEnvelope<T> {
|
||||
code: number;
|
||||
data: T;
|
||||
message?: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
@ -418,8 +418,80 @@ export const videoGenerationService = {
|
||||
},
|
||||
};
|
||||
|
||||
export type Model3DTaskStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'banned'
|
||||
| 'expired'
|
||||
| 'cancelled'
|
||||
| 'unknown';
|
||||
|
||||
export const model3DGenerationService = {
|
||||
async uploadImage(file: File): Promise<ApiResponse<{ imageToken?: string; file?: Record<string, any> }>> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch('/api/canvas/3d/upload-image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '上传参考图失败' };
|
||||
}
|
||||
},
|
||||
|
||||
async createTask(params: Record<string, any>): Promise<ApiResponse<{ taskId: string; status: Model3DTaskStatus; raw?: any }>> {
|
||||
try {
|
||||
const response = await fetch('/api/canvas/3d/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '创建 3D 任务失败' };
|
||||
}
|
||||
},
|
||||
|
||||
async getTask(taskId: string): Promise<ApiResponse<{
|
||||
taskId: string;
|
||||
type: string;
|
||||
status: Model3DTaskStatus;
|
||||
progress?: number | null;
|
||||
output?: Record<string, any>;
|
||||
modelUrl?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
errorCode?: number | null;
|
||||
errorMessage?: string | null;
|
||||
}>> {
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/3d/tasks/${taskId}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '查询 3D 任务失败' };
|
||||
}
|
||||
},
|
||||
|
||||
async getBalance(): Promise<ApiResponse<Record<string, any>>> {
|
||||
try {
|
||||
const response = await fetch('/api/canvas/3d/balance');
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '查询余额失败' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
canvas: canvasService,
|
||||
image: imageGenerationService,
|
||||
video: videoGenerationService,
|
||||
model3d: model3DGenerationService,
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// 画布节点类型定义
|
||||
|
||||
export type NodeType = 'message' | 'image' | 'video' | 'text' | 'scene-fusion';
|
||||
export type NodeType = 'message' | 'image' | 'video' | 'text' | 'scene-fusion' | 'model3d';
|
||||
export type NodeStatus = 'pending' | 'generating' | 'completed' | 'failed';
|
||||
export type ToolType = 'select' | 'pan' | 'draw' | 'text';
|
||||
|
||||
@ -88,13 +88,30 @@ export interface SceneFusionNodeData {
|
||||
status: NodeStatus;
|
||||
}
|
||||
|
||||
// 3D 模型节点数据
|
||||
export interface Model3DNodeData {
|
||||
src: string;
|
||||
previewImage?: string;
|
||||
prompt?: string;
|
||||
modelVersion?: string;
|
||||
format?: string;
|
||||
status: NodeStatus;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 画布节点
|
||||
export interface CanvasNode {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
position: Position;
|
||||
size: Size;
|
||||
data: MessageNodeData | ImageNodeData | VideoNodeData | TextNodeData | SceneFusionNodeData;
|
||||
data:
|
||||
| MessageNodeData
|
||||
| ImageNodeData
|
||||
| VideoNodeData
|
||||
| TextNodeData
|
||||
| SceneFusionNodeData
|
||||
| Model3DNodeData;
|
||||
connections: string[]; // 连接的节点 ID
|
||||
parentId?: string; // 父节点 ID(用于对话上下文)
|
||||
createdAt: Date;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user