;
+ onLayersChange?: (layers: Model3DLayer[]) => void;
+ onStatsChange?: (stats: Model3DStats) => void;
+}) {
const model = useGLTF(url);
const scene = useMemo(() => model.scene.clone(), [model.scene]);
+ const meta = useMemo(() => collectSceneMeta(scene), [scene]);
+
+ useEffect(() => {
+ onLayersChange?.(meta.layers);
+ onStatsChange?.(meta.stats);
+ }, [meta.layers, meta.stats, onLayersChange, onStatsChange]);
+
+ useEffect(() => {
+ for (const { layerId, mesh } of meta.meshRefs) {
+ const nextVisible = layerVisibility?.[layerId];
+ mesh.visible = nextVisible !== false;
+ }
+ }, [layerVisibility, meta.meshRefs]);
+
+ useEffect(() => {
+ for (const { mesh } of meta.meshRefs) {
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
+ for (const material of materials) {
+ if (!material) continue;
+ if ("wireframe" in material) {
+ (material as any).wireframe = settings.wireframe;
+ material.needsUpdate = true;
+ }
+ }
+ }
+ }, [meta.meshRefs, settings.wireframe]);
+
return ;
}
@@ -66,7 +193,14 @@ function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
);
}
-export function Model3DViewer({ modelUrl, previewImageUrl }: Model3DViewerProps) {
+export function Model3DViewer({
+ modelUrl,
+ previewImageUrl,
+ settings,
+ layerVisibility,
+ onLayersChange,
+ onStatsChange,
+}: Model3DViewerProps) {
if (!modelUrl) {
return ;
}
@@ -104,8 +238,20 @@ export function Model3DViewer({ modelUrl, previewImageUrl }: Model3DViewerProps)
}
>
diff --git a/lib/realtime/runware-capability.ts b/lib/realtime/runware-capability.ts
new file mode 100644
index 000000000..456a389a2
--- /dev/null
+++ b/lib/realtime/runware-capability.ts
@@ -0,0 +1,302 @@
+const RUNWARE_ENDPOINT = "https://api.runware.ai/v1";
+const DEFAULT_TIMEOUT_MS = 12_000;
+const PROBE_IMAGE_URL =
+ "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Example.jpg/512px-Example.jpg";
+const DEFAULT_MODEL_ID = "runware:101@1";
+
+export type RunwareCapabilityCode =
+ | "SUPPORTED"
+ | "UNSUPPORTED_SEED_IMAGE"
+ | "MODEL_REQUIRES_MASK"
+ | "AUTH_FAILED"
+ | "MODEL_INVALID"
+ | "UPSTREAM_ERROR";
+
+export type RunwareSeedImageProbeResult = {
+ supported: boolean;
+ code: RunwareCapabilityCode;
+ message: string;
+ status?: number;
+};
+
+type RunwareErrorInfo = {
+ code: string;
+ message: string;
+ parameter: string;
+};
+
+function toText(value: unknown) {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function collectErrorInfos(payload: unknown): RunwareErrorInfo[] {
+ const infos: RunwareErrorInfo[] = [];
+ const visited = new Set();
+ const queue: unknown[] = [payload];
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ if (!current || typeof current !== "object" || visited.has(current)) {
+ continue;
+ }
+ visited.add(current);
+
+ if (Array.isArray(current)) {
+ for (const item of current) {
+ queue.push(item);
+ }
+ continue;
+ }
+
+ const objectValue = current as Record;
+ const message = toText(objectValue.message || objectValue.error);
+ const code = toText(objectValue.code);
+ const parameter = toText(objectValue.parameter);
+ const lowerCode = code.toLowerCase();
+ const looksLikeErrorCode = containsAny(lowerCode, [
+ "error",
+ "invalid",
+ "unsupported",
+ "unauthorized",
+ "forbidden",
+ "notfound",
+ "not_found",
+ "failed",
+ ]);
+ if (message || parameter || looksLikeErrorCode) {
+ infos.push({ code, message, parameter });
+ }
+
+ const nestedErrors = objectValue.errors;
+ const nestedData = objectValue.data;
+ if (nestedErrors) {
+ queue.push(nestedErrors);
+ }
+ if (nestedData) {
+ queue.push(nestedData);
+ }
+ }
+
+ return infos;
+}
+
+function containsAny(text: string, patterns: string[]) {
+ return patterns.some((pattern) => text.includes(pattern));
+}
+
+function normalizeErrorText(errorInfo: RunwareErrorInfo) {
+ return `${errorInfo.code} ${errorInfo.parameter} ${errorInfo.message}`.toLowerCase();
+}
+
+function isUnsupportedSeedImage(errorInfo: RunwareErrorInfo) {
+ const text = normalizeErrorText(errorInfo);
+ return (
+ containsAny(text, [
+ "unsupported use of 'seedimage'",
+ 'unsupported use of "seedimage"',
+ "unsupported use of seedimage",
+ "seedimage is not supported",
+ "unsupportedparameter seedimage",
+ "unsupported parameter seedimage",
+ ]) ||
+ (text.includes("seedimage") && text.includes("unsupported"))
+ );
+}
+
+function isAuthFailure(status: number, errorInfo: RunwareErrorInfo) {
+ const text = normalizeErrorText(errorInfo);
+ if (status === 401 || status === 403) return true;
+ return containsAny(text, [
+ "unauthorized",
+ "forbidden",
+ "invalid api key",
+ "invalidapikey",
+ "authentication failed",
+ "auth failed",
+ "invalid credentials",
+ ]);
+}
+
+function isModelInvalid(errorInfo: RunwareErrorInfo) {
+ const text = normalizeErrorText(errorInfo);
+ return containsAny(text, [
+ "invalid model",
+ "model not found",
+ "unknown model",
+ "model does not exist",
+ "invalidmodel",
+ ]);
+}
+
+function modelRequiresMask(errorInfo: RunwareErrorInfo) {
+ const text = normalizeErrorText(errorInfo);
+ return containsAny(text, [
+ "missingmaskimage",
+ "missing maskimage",
+ "requires a mask image",
+ "requires mask image",
+ "requires a mask",
+ ]);
+}
+
+function responseHasImage(payload: unknown, taskUUID: string) {
+ if (!payload) return false;
+ const list = Array.isArray(payload)
+ ? payload
+ : Array.isArray((payload as Record)?.data)
+ ? ((payload as Record).data as unknown[])
+ : [];
+
+ return list.some((item) => {
+ if (!item || typeof item !== "object") return false;
+ const row = item as Record;
+ const image = toText(row.imageDataURI || row.imageURL || row.imageUrl);
+ if (!image) return false;
+ const rowTaskUUID = toText(row.taskUUID);
+ return !rowTaskUUID || rowTaskUUID === taskUUID;
+ });
+}
+
+function parseProbeFailure(
+ status: number,
+ modelId: string,
+ infos: RunwareErrorInfo[]
+): RunwareSeedImageProbeResult {
+ const firstMessage = infos.find((info) => info.message)?.message;
+
+ if (status === 401 || status === 403) {
+ return {
+ supported: false,
+ code: "AUTH_FAILED",
+ message: "Runware API Key 无效或无权限,请检查后重试。",
+ status,
+ };
+ }
+
+ if (infos.some((info) => isUnsupportedSeedImage(info))) {
+ return {
+ supported: false,
+ code: "UNSUPPORTED_SEED_IMAGE",
+ message: `模型 ${modelId} 不支持 seedImage,已阻止保存。`,
+ status,
+ };
+ }
+
+ if (infos.some((info) => modelRequiresMask(info))) {
+ return {
+ supported: false,
+ code: "MODEL_REQUIRES_MASK",
+ message: `模型 ${modelId} 依赖 maskImage,当前实时画布链路不兼容。`,
+ status,
+ };
+ }
+
+ if (infos.some((info) => isAuthFailure(status, info))) {
+ return {
+ supported: false,
+ code: "AUTH_FAILED",
+ message: "Runware API Key 无效或无权限,请检查后重试。",
+ status,
+ };
+ }
+
+ if (infos.some((info) => isModelInvalid(info))) {
+ return {
+ supported: false,
+ code: "MODEL_INVALID",
+ message: `模型 ${modelId} 不存在或不可用,请更换模型后重试。`,
+ status,
+ };
+ }
+
+ return {
+ supported: false,
+ code: "UPSTREAM_ERROR",
+ message: firstMessage || `Runware 探针请求失败(${status || 0})`,
+ status,
+ };
+}
+
+export async function probeRunwareSeedImageSupport(params: {
+ apiKey: string;
+ modelId?: string;
+ timeoutMs?: number;
+}): Promise {
+ const apiKey = params.apiKey.trim();
+ const modelId = (params.modelId || DEFAULT_MODEL_ID).trim() || DEFAULT_MODEL_ID;
+ const timeoutMs = Number.isFinite(params.timeoutMs) ? Number(params.timeoutMs) : DEFAULT_TIMEOUT_MS;
+
+ if (!apiKey) {
+ return {
+ supported: false,
+ code: "AUTH_FAILED",
+ message: "缺少 Runware API Key。",
+ };
+ }
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), Math.max(2_000, timeoutMs));
+
+ let status = 0;
+ let payload: unknown = null;
+
+ try {
+ const taskUUID = crypto.randomUUID();
+ const response = await fetch(RUNWARE_ENDPOINT, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify([
+ {
+ taskType: "imageInference",
+ taskUUID,
+ model: modelId,
+ positivePrompt: "probe",
+ seedImage: PROBE_IMAGE_URL,
+ width: 512,
+ height: 512,
+ strength: 0.2,
+ CFGScale: 7,
+ steps: 1,
+ numberResults: 1,
+ outputType: "dataURI",
+ },
+ ]),
+ cache: "no-store",
+ signal: controller.signal,
+ });
+
+ status = response.status;
+ payload = await response.json().catch(() => null);
+ const infos = collectErrorInfos(payload);
+
+ if (response.ok && infos.length === 0 && responseHasImage(payload, taskUUID)) {
+ return {
+ supported: true,
+ code: "SUPPORTED",
+ message: "模型支持 seedImage。",
+ status,
+ };
+ }
+
+ return parseProbeFailure(status, modelId, infos);
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ return {
+ supported: false,
+ code: "UPSTREAM_ERROR",
+ message: "Runware 探针请求超时,请稍后重试。",
+ };
+ }
+ return {
+ supported: false,
+ code: "UPSTREAM_ERROR",
+ message: error instanceof Error ? error.message : "Runware 探针请求失败。",
+ };
+ } finally {
+ clearTimeout(timer);
+ }
+}
diff --git a/lib/realtime/runware-ws.ts b/lib/realtime/runware-ws.ts
new file mode 100644
index 000000000..1fae3df5b
--- /dev/null
+++ b/lib/realtime/runware-ws.ts
@@ -0,0 +1,279 @@
+type RunwareTask = {
+ taskType: string;
+ taskUUID?: string;
+ [key: string]: unknown;
+};
+
+type RunwarePayload = {
+ data?: Array>;
+ errors?: Array>;
+ [key: string]: unknown;
+};
+
+type PendingTask = {
+ timeout: ReturnType;
+ resolve: (value: RunwarePayload) => void;
+ reject: (reason?: unknown) => void;
+};
+
+class RunwareWebSocketClient {
+ private readonly endpoint = "wss://ws-api.runware.ai/v1";
+ private socket: WebSocket | null = null;
+ private connectionSessionUUID = "";
+ private authenticated = false;
+ private connectPromise: Promise | null = null;
+ private pingTimer: ReturnType | null = null;
+ private authWaiter:
+ | {
+ timeout: ReturnType;
+ resolve: () => void;
+ reject: (reason?: unknown) => void;
+ }
+ | null = null;
+ private pending = new Map();
+
+ constructor(private readonly apiKey: string) {}
+
+ private closeSocket() {
+ if (!this.socket) return;
+ try {
+ this.socket.close();
+ } catch {}
+ this.socket = null;
+ }
+
+ private clearPingTimer() {
+ if (!this.pingTimer) return;
+ clearInterval(this.pingTimer);
+ this.pingTimer = null;
+ }
+
+ private rejectPending(reason: unknown) {
+ for (const [, task] of this.pending) {
+ clearTimeout(task.timeout);
+ task.reject(reason);
+ }
+ this.pending.clear();
+ }
+
+ private rejectAuthWaiter(reason: unknown) {
+ if (!this.authWaiter) return;
+ clearTimeout(this.authWaiter.timeout);
+ this.authWaiter.reject(reason);
+ this.authWaiter = null;
+ }
+
+ private startPing() {
+ this.clearPingTimer();
+ this.pingTimer = setInterval(() => {
+ const socket = this.socket;
+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
+ try {
+ socket.send(
+ JSON.stringify([
+ {
+ taskType: "ping",
+ ping: true,
+ },
+ ])
+ );
+ } catch {}
+ }, 55_000);
+ }
+
+ private sendAuth() {
+ const socket = this.socket;
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
+ throw new Error("Runware WebSocket 未连接");
+ }
+ const authTask: Record = {
+ taskType: "authentication",
+ apiKey: this.apiKey,
+ };
+ if (this.connectionSessionUUID) {
+ authTask.connectionSessionUUID = this.connectionSessionUUID;
+ }
+ socket.send(JSON.stringify([authTask]));
+ }
+
+ private parseMessage(raw: unknown) {
+ if (typeof raw !== "string") return null;
+ try {
+ const parsed = JSON.parse(raw) as RunwarePayload;
+ return parsed;
+ } catch {
+ return null;
+ }
+ }
+
+ private handleMessage(raw: unknown) {
+ const payload = this.parseMessage(raw);
+ if (!payload) return;
+ const dataList = Array.isArray(payload.data) ? payload.data : [];
+ const errorList = Array.isArray(payload.errors) ? payload.errors : [];
+
+ if (this.authWaiter) {
+ const authError = errorList.find((item) => item?.taskType === "authentication");
+ const authData = dataList.find((item) => item?.taskType === "authentication");
+ if (authError) {
+ const message = String(authError.message || "Runware WebSocket 鉴权失败");
+ this.rejectAuthWaiter(new Error(message));
+ } else if (authData) {
+ const sessionId = String(authData.connectionSessionUUID || "").trim();
+ if (sessionId) {
+ this.connectionSessionUUID = sessionId;
+ }
+ this.authenticated = true;
+ this.startPing();
+ this.authWaiter.resolve();
+ clearTimeout(this.authWaiter.timeout);
+ this.authWaiter = null;
+ }
+ }
+
+ for (const item of dataList) {
+ const taskUUID = String(item?.taskUUID || "").trim();
+ if (!taskUUID) continue;
+ const pendingTask = this.pending.get(taskUUID);
+ if (!pendingTask) continue;
+ clearTimeout(pendingTask.timeout);
+ this.pending.delete(taskUUID);
+ pendingTask.resolve(payload);
+ }
+
+ for (const item of errorList) {
+ const taskUUID = String(item?.taskUUID || "").trim();
+ if (!taskUUID) continue;
+ const pendingTask = this.pending.get(taskUUID);
+ if (!pendingTask) continue;
+ clearTimeout(pendingTask.timeout);
+ this.pending.delete(taskUUID);
+ const message = String(item?.message || item?.code || "Runware WebSocket 任务失败");
+ pendingTask.reject(new Error(message));
+ }
+ }
+
+ private handleSocketClose() {
+ this.authenticated = false;
+ this.clearPingTimer();
+ this.rejectAuthWaiter(new Error("Runware WebSocket 连接已断开"));
+ this.rejectPending(new Error("Runware WebSocket 连接已断开"));
+ this.socket = null;
+ }
+
+ async ensureConnected(timeoutMs = 10_000) {
+ if (this.socket?.readyState === WebSocket.OPEN && this.authenticated) {
+ return;
+ }
+ if (this.connectPromise) {
+ return this.connectPromise;
+ }
+
+ this.connectPromise = new Promise((resolve, reject) => {
+ this.closeSocket();
+ this.authenticated = false;
+ this.clearPingTimer();
+
+ const socket = new WebSocket(this.endpoint);
+ this.socket = socket;
+
+ const cleanupAndReject = (error: unknown) => {
+ this.handleSocketClose();
+ reject(error instanceof Error ? error : new Error("Runware WebSocket 连接失败"));
+ };
+
+ socket.onmessage = (event) => {
+ this.handleMessage(event.data);
+ };
+
+ socket.onerror = () => {
+ if (!this.authenticated) {
+ cleanupAndReject(new Error("Runware WebSocket 连接错误"));
+ }
+ };
+
+ socket.onclose = () => {
+ this.handleSocketClose();
+ };
+
+ socket.onopen = () => {
+ this.authWaiter = {
+ timeout: setTimeout(() => {
+ this.rejectAuthWaiter(new Error("Runware WebSocket 鉴权超时"));
+ this.closeSocket();
+ reject(new Error("Runware WebSocket 鉴权超时"));
+ }, timeoutMs),
+ resolve,
+ reject: (reason?: unknown) => {
+ cleanupAndReject(reason);
+ },
+ };
+ try {
+ this.sendAuth();
+ } catch (error) {
+ cleanupAndReject(error);
+ }
+ };
+ }).finally(() => {
+ this.connectPromise = null;
+ });
+
+ return this.connectPromise;
+ }
+
+ async sendTask(task: RunwareTask, timeoutMs = 90_000) {
+ const taskUUID = String(task.taskUUID || "").trim();
+ if (!taskUUID) {
+ throw new Error("Runware 任务缺少 taskUUID");
+ }
+
+ await this.ensureConnected();
+ const socket = this.socket;
+ if (!socket || socket.readyState !== WebSocket.OPEN || !this.authenticated) {
+ throw new Error("Runware WebSocket 未就绪");
+ }
+
+ return await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ this.pending.delete(taskUUID);
+ reject(new Error("Runware WebSocket 推理超时"));
+ }, timeoutMs);
+
+ this.pending.set(taskUUID, {
+ timeout,
+ resolve,
+ reject,
+ });
+
+ try {
+ socket.send(JSON.stringify([task]));
+ } catch (error) {
+ clearTimeout(timeout);
+ this.pending.delete(taskUUID);
+ reject(error instanceof Error ? error : new Error("Runware WebSocket 发送失败"));
+ }
+ });
+ }
+}
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __runwareWebSocketPool: Map | undefined;
+}
+
+function getRunwarePool() {
+ if (!globalThis.__runwareWebSocketPool) {
+ globalThis.__runwareWebSocketPool = new Map();
+ }
+ return globalThis.__runwareWebSocketPool;
+}
+
+export function getRunwareWebSocketClient(apiKey: string) {
+ const pool = getRunwarePool();
+ let client = pool.get(apiKey);
+ if (!client) {
+ client = new RunwareWebSocketClient(apiKey);
+ pool.set(apiKey, client);
+ }
+ return client;
+}
diff --git a/lib/tripo/client.ts b/lib/tripo/client.ts
index 2863ed414..547d2acb2 100644
--- a/lib/tripo/client.ts
+++ b/lib/tripo/client.ts
@@ -126,21 +126,48 @@ export async function tripoRequest(
export function pickTaskModelUrl(output?: Record | null): string | null {
if (!output) return null;
- const candidates = [output.pbr_model, output.model, output.base_model];
+ const candidates = [
+ output.pbr_model,
+ output.model,
+ output.base_model,
+ output.rigged_model,
+ output.animated_model,
+ output.low_poly_model,
+ output.result_model,
+ output.glb,
+ output.fbx,
+ output.usdz,
+ output.obj,
+ output.stl,
+ ];
for (const item of candidates) {
- const value = typeof item === "string" ? item.trim() : "";
- if (value) return value;
+ if (typeof item === "string") {
+ const value = item.trim();
+ if (value) return value;
+ continue;
+ }
+ if (item && typeof item === "object") {
+ const nested =
+ typeof (item as Record).url === "string"
+ ? String((item as Record).url).trim()
+ : "";
+ if (nested) return nested;
+ }
}
return null;
}
export function pickTaskPreviewImageUrl(output?: Record | null): string | null {
if (!output) return null;
- const candidates = [output.generated_image, output.rendered_image];
+ const candidates = [
+ output.generated_image,
+ output.rendered_image,
+ output.preview_image,
+ output.thumbnail,
+ ];
for (const item of candidates) {
const value = typeof item === "string" ? item.trim() : "";
if (value) return value;
}
return null;
}
-
diff --git a/lib/tripo/types.ts b/lib/tripo/types.ts
index 355293f7a..8b73a3be7 100644
--- a/lib/tripo/types.ts
+++ b/lib/tripo/types.ts
@@ -1,4 +1,17 @@
-export type TripoTaskType = "text_to_model" | "image_to_model" | "convert_model";
+export type TripoTaskType =
+ | "text_to_model"
+ | "image_to_model"
+ | "multiview_to_model"
+ | "convert_model"
+ | "refine_model"
+ | "stylize_model"
+ | "texture_model"
+ | "animate_prerigcheck"
+ | "animate_rig"
+ | "animate_retarget"
+ | "mesh_segmentation"
+ | "mesh_completion"
+ | "highpoly_to_lowpoly";
export type TripoTaskStatus =
| "queued"
@@ -43,4 +56,3 @@ export interface TripoApiEnvelope {
message?: string;
suggestion?: string;
}
-