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)
|
# 模型名称(例如:gpt-5-nano)
|
||||||
GTP5_MODEL=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 = {
|
type AiProvider = {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
@ -394,6 +394,7 @@ const PROVIDER_TYPES = [
|
|||||||
{ value: "alibaba-image", label: "阿里生图模型" }, // 🆕 千问图片生成/编辑
|
{ value: "alibaba-image", label: "阿里生图模型" }, // 🆕 千问图片生成/编辑
|
||||||
{ value: "ruxa-image", label: "RUXA 生图模型" },
|
{ value: "ruxa-image", label: "RUXA 生图模型" },
|
||||||
{ value: "fal-image", label: "FAL 生图模型" },
|
{ value: "fal-image", label: "FAL 生图模型" },
|
||||||
|
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
|
||||||
{ value: "zhipu", label: "智普官方 4.7" },
|
{ value: "zhipu", label: "智普官方 4.7" },
|
||||||
{ value: "volcano-multimodal", label: "火山引擎多模态" },
|
{ value: "volcano-multimodal", label: "火山引擎多模态" },
|
||||||
{ value: "openai", label: "OpenAI 兼容" },
|
{ value: "openai", label: "OpenAI 兼容" },
|
||||||
@ -415,6 +416,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
|||||||
"ruxa-image": "https://api.ruxa.ai",
|
"ruxa-image": "https://api.ruxa.ai",
|
||||||
"fal-image": "https://fal.run",
|
"fal-image": "https://fal.run",
|
||||||
"fal-video": "https://fal.run",
|
"fal-video": "https://fal.run",
|
||||||
|
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||||
zhipu: "https://open.bigmodel.cn/api/paas/v4",
|
zhipu: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
|
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
openai: "https://api.openai.com/v1",
|
openai: "https://api.openai.com/v1",
|
||||||
@ -1089,7 +1091,7 @@ export function AdminSettingsClient() {
|
|||||||
const [providerForm, setProviderForm] = useState({
|
const [providerForm, setProviderForm] = useState({
|
||||||
id: "",
|
id: "",
|
||||||
name: "",
|
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: "",
|
baseUrl: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
@ -3349,6 +3351,7 @@ export function AdminSettingsClient() {
|
|||||||
"alibaba-image": "bg-amber-500",
|
"alibaba-image": "bg-amber-500",
|
||||||
"ruxa-image": "bg-emerald-500",
|
"ruxa-image": "bg-emerald-500",
|
||||||
"fal-image": "bg-sky-500",
|
"fal-image": "bg-sky-500",
|
||||||
|
"tripo-3d": "bg-teal-500",
|
||||||
"openai": "bg-green-500",
|
"openai": "bg-green-500",
|
||||||
"zhipu": "bg-lime-500",
|
"zhipu": "bg-lime-500",
|
||||||
"volcano-multimodal": "bg-fuchsia-500",
|
"volcano-multimodal": "bg-fuchsia-500",
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const providerTypes = [
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"fal-video",
|
"fal-video",
|
||||||
"zhipu",
|
"zhipu",
|
||||||
|
"tripo-3d",
|
||||||
"volcano-multimodal",
|
"volcano-multimodal",
|
||||||
] as const;
|
] as const;
|
||||||
type ProviderType = (typeof providerTypes)[number];
|
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 ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4";
|
||||||
const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
|
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 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(
|
function normalizeProviderTypeForStorage(
|
||||||
type?: ProviderType
|
type?: ProviderType
|
||||||
@ -156,7 +158,9 @@ export async function POST(request: Request) {
|
|||||||
? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL
|
? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL
|
||||||
: data.type === "doubao-video"
|
: data.type === "doubao-video"
|
||||||
? data.baseUrl || DOUBAO_VIDEO_BASE_URL
|
? data.baseUrl || DOUBAO_VIDEO_BASE_URL
|
||||||
: data.baseUrl,
|
: data.type === "tripo-3d"
|
||||||
|
? data.baseUrl || TRIPO_3D_BASE_URL
|
||||||
|
: data.baseUrl,
|
||||||
updatedBy: session.user.id,
|
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,
|
Grid3X3,
|
||||||
Settings,
|
Settings,
|
||||||
PenTool,
|
PenTool,
|
||||||
|
Box,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -771,6 +772,20 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : 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" />
|
<Separator orientation="horizontal" className="w-5 my-0.5" />
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { motion } from "framer-motion";
|
|||||||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||||||
import type { CanvasNode, Position } from "@/types/canvas";
|
import type { CanvasNode, Position } from "@/types/canvas";
|
||||||
import { ImageNode } from "./nodes/ImageNode";
|
import { ImageNode } from "./nodes/ImageNode";
|
||||||
|
import { Model3DNode } from "./nodes/Model3DNode";
|
||||||
import { TextNode } from "./nodes/TextNode";
|
import { TextNode } from "./nodes/TextNode";
|
||||||
import { VideoNode } from "./nodes/VideoNode";
|
import { VideoNode } from "./nodes/VideoNode";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -982,6 +983,9 @@ export function CanvasWorkspace() {
|
|||||||
{node.type === 'video' && (
|
{node.type === 'video' && (
|
||||||
<VideoNode node={node} isSelected={isSelected} />
|
<VideoNode node={node} isSelected={isSelected} />
|
||||||
)}
|
)}
|
||||||
|
{node.type === 'model3d' && (
|
||||||
|
<Model3DNode node={node} isSelected={isSelected} />
|
||||||
|
)}
|
||||||
{node.type === 'message' && (
|
{node.type === 'message' && (
|
||||||
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
|
<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>
|
<span className="text-sm">{(node.data as any).content}</span>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export { CanvasWorkspace } from './CanvasWorkspace';
|
export { CanvasWorkspace } from './CanvasWorkspace';
|
||||||
export { ImageNode } from './nodes/ImageNode';
|
export { ImageNode } from './nodes/ImageNode';
|
||||||
|
export { Model3DNode } from './nodes/Model3DNode';
|
||||||
export { TextNode } from './nodes/TextNode';
|
export { TextNode } from './nodes/TextNode';
|
||||||
export { VideoNode } from './nodes/VideoNode';
|
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",
|
"ruxa-image": "https://api.ruxa.ai",
|
||||||
"fal-image": "https://fal.run",
|
"fal-image": "https://fal.run",
|
||||||
"fal-video": "https://fal.run",
|
"fal-video": "https://fal.run",
|
||||||
|
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getProviderDefaultBaseUrl(type: AiProvider["type"]): string | undefined {
|
export function getProviderDefaultBaseUrl(type: AiProvider["type"]): string | undefined {
|
||||||
return PROVIDER_DEFAULT_BASE_URL[type];
|
return PROVIDER_DEFAULT_BASE_URL[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const providerBaseUrlDefaults = [
|
|||||||
{ type: "ruxa-image", baseUrl: "https://api.ruxa.ai" },
|
{ type: "ruxa-image", baseUrl: "https://api.ruxa.ai" },
|
||||||
{ type: "fal-image", baseUrl: "https://fal.run" },
|
{ type: "fal-image", baseUrl: "https://fal.run" },
|
||||||
{ type: "fal-video", baseUrl: "https://fal.run" },
|
{ type: "fal-video", baseUrl: "https://fal.run" },
|
||||||
|
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function assertMigrationJournalIsConsistent() {
|
function assertMigrationJournalIsConsistent() {
|
||||||
@ -87,7 +88,7 @@ const runMigrate = async () => {
|
|||||||
ADD CONSTRAINT "AiProvider_type_check"
|
ADD CONSTRAINT "AiProvider_type_check"
|
||||||
CHECK (type IN (
|
CHECK (type IN (
|
||||||
'alibaba', 'openai', 'custom', 'local', 'gemini',
|
'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) {
|
for (const item of providerBaseUrlDefaults) {
|
||||||
|
|||||||
@ -1250,7 +1250,7 @@ export async function createAiProvider({
|
|||||||
updatedBy,
|
updatedBy,
|
||||||
}: {
|
}: {
|
||||||
name: 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;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
@ -1288,7 +1288,7 @@ export async function updateAiProvider({
|
|||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: 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;
|
baseUrl?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
|
|||||||
@ -259,7 +259,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
|||||||
export const aiProvider = pgTable("AiProvider", {
|
export const aiProvider = pgTable("AiProvider", {
|
||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
|
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()
|
.notNull()
|
||||||
.default("custom"), // 提供商类型
|
.default("custom"), // 提供商类型
|
||||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||||
@ -551,7 +551,7 @@ export const canvasNode = pgTable("CanvasNode", {
|
|||||||
projectId: uuid("projectId")
|
projectId: uuid("projectId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => canvasProject.id, { onDelete: "cascade" }),
|
.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 }>(),
|
position: jsonb("position").$type<{ x: number; y: number }>(),
|
||||||
size: jsonb("size").$type<{ width: number; height: number }>(),
|
size: jsonb("size").$type<{ width: number; height: number }>(),
|
||||||
data: jsonb("data"), // 节点具体数据
|
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 {
|
export default {
|
||||||
canvas: canvasService,
|
canvas: canvasService,
|
||||||
image: imageGenerationService,
|
image: imageGenerationService,
|
||||||
video: videoGenerationService,
|
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 NodeStatus = 'pending' | 'generating' | 'completed' | 'failed';
|
||||||
export type ToolType = 'select' | 'pan' | 'draw' | 'text';
|
export type ToolType = 'select' | 'pan' | 'draw' | 'text';
|
||||||
|
|
||||||
@ -88,13 +88,30 @@ export interface SceneFusionNodeData {
|
|||||||
status: NodeStatus;
|
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 {
|
export interface CanvasNode {
|
||||||
id: string;
|
id: string;
|
||||||
type: NodeType;
|
type: NodeType;
|
||||||
position: Position;
|
position: Position;
|
||||||
size: Size;
|
size: Size;
|
||||||
data: MessageNodeData | ImageNodeData | VideoNodeData | TextNodeData | SceneFusionNodeData;
|
data:
|
||||||
|
| MessageNodeData
|
||||||
|
| ImageNodeData
|
||||||
|
| VideoNodeData
|
||||||
|
| TextNodeData
|
||||||
|
| SceneFusionNodeData
|
||||||
|
| Model3DNodeData;
|
||||||
connections: string[]; // 连接的节点 ID
|
connections: string[]; // 连接的节点 ID
|
||||||
parentId?: string; // 父节点 ID(用于对话上下文)
|
parentId?: string; // 父节点 ID(用于对话上下文)
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user