feat(rsplat): gallery thumbnails via model-viewer + fix 3D gen error handling
GLB models now render inline 3D previews using Google's <model-viewer> web component with auto-rotate. AI-generated models show source image thumbnails. Fixed fal.ai result fetch with retry logic and detailed logging for diagnosis. Save flow now uses save-generated API with thumbnail_url passthrough. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7da90088c4
commit
6b3fbd36b0
|
|
@ -17,9 +17,11 @@ interface SplatItem {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
file_format: string;
|
file_format: string;
|
||||||
|
file_url?: string;
|
||||||
file_size_bytes: number;
|
file_size_bytes: number;
|
||||||
view_count: number;
|
view_count: number;
|
||||||
contributor_name?: string;
|
contributor_name?: string;
|
||||||
|
thumbnail_url?: string;
|
||||||
processing_status?: string;
|
processing_status?: string;
|
||||||
source_file_count?: number;
|
source_file_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
@ -97,8 +99,11 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
if (this._splats.length > 0) return; // Don't clobber server-hydrated data
|
if (this._splats.length > 0) return; // Don't clobber server-hydrated data
|
||||||
this._splats = Object.values(doc.items).map(s => ({
|
this._splats = Object.values(doc.items).map(s => ({
|
||||||
id: s.id, slug: s.slug, title: s.title, description: s.description,
|
id: s.id, slug: s.slug, title: s.title, description: s.description,
|
||||||
file_format: s.fileFormat, file_size_bytes: s.fileSizeBytes,
|
file_format: s.fileFormat,
|
||||||
|
file_url: s.filePath ? `/${this._spaceSlug}/rsplat/api/splats/${s.slug}/${s.slug}.${s.fileFormat}` : undefined,
|
||||||
|
file_size_bytes: s.fileSizeBytes,
|
||||||
view_count: s.viewCount, contributor_name: s.contributorName ?? undefined,
|
view_count: s.viewCount, contributor_name: s.contributorName ?? undefined,
|
||||||
|
thumbnail_url: (s as any).thumbnailUrl ?? undefined,
|
||||||
processing_status: s.processingStatus ?? undefined,
|
processing_status: s.processingStatus ?? undefined,
|
||||||
source_file_count: s.sourceFileCount,
|
source_file_count: s.sourceFileCount,
|
||||||
created_at: new Date(s.createdAt).toISOString(),
|
created_at: new Date(s.createdAt).toISOString(),
|
||||||
|
|
@ -172,6 +177,16 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
overlay = `<div class="splat-card__overlay"><span class="splat-badge splat-badge--failed">Failed</span></div>`;
|
overlay = `<div class="splat-card__overlay"><span class="splat-badge splat-badge--failed">Failed</span></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thumbnail content: model-viewer for GLB, img for thumbnail_url, emoji fallback
|
||||||
|
let previewContent = "";
|
||||||
|
if (isReady && s.file_format === "glb" && s.file_url) {
|
||||||
|
previewContent = `<model-viewer src="${esc(s.file_url)}" auto-rotate interaction-prompt="none" shadow-intensity="0" environment-image="neutral" loading="lazy" class="splat-card__model-viewer"></model-viewer>`;
|
||||||
|
} else if (isReady && s.thumbnail_url) {
|
||||||
|
previewContent = `<img src="${esc(s.thumbnail_url)}" alt="${esc(s.title)}" class="splat-card__thumb" loading="lazy">`;
|
||||||
|
} else {
|
||||||
|
previewContent = `<span>${isReady ? "🔮" : "📸"}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
const sourceInfo = !isReady && s.source_file_count
|
const sourceInfo = !isReady && s.source_file_count
|
||||||
? `<span>${s.source_file_count} source file${s.source_file_count > 1 ? "s" : ""}</span>`
|
? `<span>${s.source_file_count} source file${s.source_file_count > 1 ? "s" : ""}</span>`
|
||||||
: `<span>${s.view_count} views</span>`;
|
: `<span>${s.view_count} views</span>`;
|
||||||
|
|
@ -180,7 +195,7 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
<${tag} class="splat-card${statusClass}${demoClass}" data-collab-id="splat:${s.id}"${href}${demoAttr}>
|
<${tag} class="splat-card${statusClass}${demoClass}" data-collab-id="splat:${s.id}"${href}${demoAttr}>
|
||||||
<div class="splat-card__preview">
|
<div class="splat-card__preview">
|
||||||
${overlay}
|
${overlay}
|
||||||
<span>${isReady ? "🔮" : "📸"}</span>
|
${previewContent}
|
||||||
</div>
|
</div>
|
||||||
<div class="splat-card__body">
|
<div class="splat-card__body">
|
||||||
<div class="splat-card__title">${esc(s.title)}</div>
|
<div class="splat-card__title">${esc(s.title)}</div>
|
||||||
|
|
@ -598,6 +613,8 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
progress.style.display = "none";
|
progress.style.display = "none";
|
||||||
|
|
||||||
|
const sourceImage = job.source_image || "";
|
||||||
|
|
||||||
// Show result with save option
|
// Show result with save option
|
||||||
const resultDiv = document.createElement("div");
|
const resultDiv = document.createElement("div");
|
||||||
resultDiv.className = "splat-generate__result";
|
resultDiv.className = "splat-generate__result";
|
||||||
|
|
@ -613,47 +630,49 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
status.textContent = "";
|
status.textContent = "";
|
||||||
status.after(resultDiv);
|
status.after(resultDiv);
|
||||||
|
|
||||||
// View button
|
// View button — also store source image for save flow
|
||||||
resultDiv.querySelector("#gen-view-btn")?.addEventListener("click", () => {
|
resultDiv.querySelector("#gen-view-btn")?.addEventListener("click", () => {
|
||||||
this._mode = "viewer";
|
this._mode = "viewer";
|
||||||
this._splatUrl = job.url;
|
this._splatUrl = job.url;
|
||||||
this._splatTitle = title;
|
this._splatTitle = title;
|
||||||
this._splatDesc = "AI-generated 3D model";
|
this._splatDesc = "AI-generated 3D model";
|
||||||
|
this._generatedUrl = job.url;
|
||||||
|
this._generatedTitle = title;
|
||||||
|
(this as any)._sourceImageUrl = sourceImage;
|
||||||
this._inlineViewer = true;
|
this._inlineViewer = true;
|
||||||
this.renderViewer();
|
this.renderViewer();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save to gallery button
|
// Save to gallery button — use save-generated with thumbnail
|
||||||
resultDiv.querySelector("#gen-save-btn")?.addEventListener("click", async () => {
|
resultDiv.querySelector("#gen-save-btn")?.addEventListener("click", async () => {
|
||||||
const saveBtn = resultDiv.querySelector("#gen-save-btn") as HTMLButtonElement;
|
const saveBtn = resultDiv.querySelector("#gen-save-btn") as HTMLButtonElement;
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
saveBtn.textContent = "Saving...";
|
saveBtn.textContent = "Saving...";
|
||||||
try {
|
try {
|
||||||
// Download the generated file and re-upload as a splat
|
const token = this.getAuthToken();
|
||||||
const fileRes = await fetch(job.url);
|
const saveRes = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, {
|
||||||
const blob = await fileRes.blob();
|
|
||||||
const ext = job.format || "glb";
|
|
||||||
const file = new File([blob], `${title}.${ext}`, { type: "application/octet-stream" });
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
formData.append("title", title);
|
|
||||||
formData.append("description", "AI-generated 3D model");
|
|
||||||
formData.append("tags", "ai-generated");
|
|
||||||
|
|
||||||
const uploadRes = await fetch(`/${this._spaceSlug}/rsplat/api/splats`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: job.url,
|
||||||
|
title,
|
||||||
|
description: "AI-generated 3D model via Hunyuan3D",
|
||||||
|
thumbnail_url: sourceImage || undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (uploadRes.ok) {
|
if (saveRes.ok) {
|
||||||
|
const data = await saveRes.json() as { slug: string };
|
||||||
saveBtn.textContent = "Saved!";
|
saveBtn.textContent = "Saved!";
|
||||||
saveBtn.style.background = "#16a34a";
|
saveBtn.style.background = "#16a34a";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/${this._spaceSlug}/rsplat`;
|
window.location.href = `/${this._spaceSlug}/rsplat/${data.slug}`;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
const err = await uploadRes.json().catch(() => ({ error: "Save failed" }));
|
const err = await saveRes.json().catch(() => ({ error: "Save failed" }));
|
||||||
saveBtn.textContent = (err as any).error || "Save failed";
|
saveBtn.textContent = (err as any).error || "Save failed";
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
@ -706,6 +725,7 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
url: this._generatedUrl,
|
url: this._generatedUrl,
|
||||||
title: this._generatedTitle || "AI Generated Model",
|
title: this._generatedTitle || "AI Generated Model",
|
||||||
description: "AI-generated 3D model via Hunyuan3D",
|
description: "AI-generated 3D model via Hunyuan3D",
|
||||||
|
thumbnail_url: (this as any)._sourceImageUrl || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -874,6 +894,7 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
url: this._generatedUrl,
|
url: this._generatedUrl,
|
||||||
title: this._generatedTitle || "AI Generated Model",
|
title: this._generatedTitle || "AI Generated Model",
|
||||||
description: "AI-generated 3D model via Hunyuan3D",
|
description: "AI-generated 3D model via Hunyuan3D",
|
||||||
|
thumbnail_url: (this as any)._sourceImageUrl || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,19 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splat-card__model-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
--poster-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splat-card__thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splat-card__body {
|
.splat-card__body {
|
||||||
|
|
|
||||||
|
|
@ -162,16 +162,21 @@ function itemToRow(item: SplatItem): SplatRow {
|
||||||
/**
|
/**
|
||||||
* Return the subset of SplatRow fields used in list/gallery responses.
|
* Return the subset of SplatRow fields used in list/gallery responses.
|
||||||
*/
|
*/
|
||||||
function itemToListRow(item: SplatItem) {
|
function itemToListRow(item: SplatItem, spaceSlug?: string) {
|
||||||
|
const fileUrl = item.filePath
|
||||||
|
? `/${spaceSlug || item.spaceSlug}/rsplat/api/splats/${item.slug}/${item.slug}.${item.fileFormat}`
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
slug: item.slug,
|
slug: item.slug,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.description || null,
|
description: item.description || null,
|
||||||
file_format: item.fileFormat,
|
file_format: item.fileFormat,
|
||||||
|
file_url: fileUrl,
|
||||||
file_size_bytes: item.fileSizeBytes,
|
file_size_bytes: item.fileSizeBytes,
|
||||||
tags: item.tags ?? [],
|
tags: item.tags ?? [],
|
||||||
contributor_name: item.contributorName,
|
contributor_name: item.contributorName,
|
||||||
|
thumbnail_url: item.thumbnailUrl || null,
|
||||||
view_count: item.viewCount,
|
view_count: item.viewCount,
|
||||||
processing_status: item.processingStatus ?? 'ready',
|
processing_status: item.processingStatus ?? 'ready',
|
||||||
source_file_count: item.sourceFileCount,
|
source_file_count: item.sourceFileCount,
|
||||||
|
|
@ -181,6 +186,8 @@ function itemToListRow(item: SplatItem) {
|
||||||
|
|
||||||
// ── CDN importmap for Three.js + GaussianSplats3D ──
|
// ── CDN importmap for Three.js + GaussianSplats3D ──
|
||||||
|
|
||||||
|
const MODEL_VIEWER_SCRIPT = `<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"></script>`;
|
||||||
|
|
||||||
const IMPORTMAP = `<script type="importmap">
|
const IMPORTMAP = `<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|
@ -225,7 +232,7 @@ routes.get("/api/splats", async (c) => {
|
||||||
// Apply offset and limit
|
// Apply offset and limit
|
||||||
const paged = items.slice(offset, offset + limit);
|
const paged = items.slice(offset, offset + limit);
|
||||||
|
|
||||||
return c.json({ splats: paged.map(itemToListRow) });
|
return c.json({ splats: paged.map(i => itemToListRow(i, spaceSlug)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── API: Get splat details ──
|
// ── API: Get splat details ──
|
||||||
|
|
@ -382,6 +389,7 @@ routes.post("/api/splats", async (c) => {
|
||||||
processingError: null,
|
processingError: null,
|
||||||
sourceFileCount: 0,
|
sourceFileCount: 0,
|
||||||
sourceFiles: [],
|
sourceFiles: [],
|
||||||
|
thumbnailUrl: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -525,6 +533,7 @@ routes.post("/api/splats/from-media", async (c) => {
|
||||||
processingError: null,
|
processingError: null,
|
||||||
sourceFileCount: files.length,
|
sourceFileCount: files.length,
|
||||||
sourceFiles: sourceFileEntries,
|
sourceFiles: sourceFileEntries,
|
||||||
|
thumbnailUrl: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -555,7 +564,7 @@ routes.post("/api/splats/save-generated", async (c) => {
|
||||||
|
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
const dataSpace = c.get("effectiveSpace") || spaceSlug;
|
const dataSpace = c.get("effectiveSpace") || spaceSlug;
|
||||||
const { url, title, description } = await c.req.json();
|
const { url, title, description, thumbnail_url } = await c.req.json();
|
||||||
|
|
||||||
if (!url || !title?.trim()) {
|
if (!url || !title?.trim()) {
|
||||||
return c.json({ error: "url and title required" }, 400);
|
return c.json({ error: "url and title required" }, 400);
|
||||||
|
|
@ -616,6 +625,7 @@ routes.post("/api/splats/save-generated", async (c) => {
|
||||||
processingError: null,
|
processingError: null,
|
||||||
sourceFileCount: 0,
|
sourceFileCount: 0,
|
||||||
sourceFiles: [],
|
sourceFiles: [],
|
||||||
|
thumbnailUrl: thumbnail_url || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -650,7 +660,7 @@ routes.get("/api/splats/my-history", async (c) => {
|
||||||
.sort((a, b) => b.createdAt - a.createdAt)
|
.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
.slice(0, 50);
|
.slice(0, 50);
|
||||||
|
|
||||||
return c.json({ splats: items.map(itemToListRow) });
|
return c.json({ splats: items.map(i => itemToListRow(i, spaceSlug)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── API: Delete splat (owner only) ──
|
// ── API: Delete splat (owner only) ──
|
||||||
|
|
@ -701,7 +711,7 @@ routes.get("/", async (c) => {
|
||||||
.sort((a, b) => b.createdAt - a.createdAt)
|
.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
.slice(0, 50);
|
.slice(0, 50);
|
||||||
|
|
||||||
const rows = items.map(itemToListRow);
|
const rows = items.map(i => itemToListRow(i, spaceSlug));
|
||||||
const splatsJSON = JSON.stringify(rows);
|
const splatsJSON = JSON.stringify(rows);
|
||||||
|
|
||||||
const html = renderShell({
|
const html = renderShell({
|
||||||
|
|
@ -712,12 +722,13 @@ routes.get("/", async (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
head: `
|
head: `
|
||||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=6">
|
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=7">
|
||||||
|
${MODEL_VIEWER_SCRIPT}
|
||||||
${IMPORTMAP}
|
${IMPORTMAP}
|
||||||
`,
|
`,
|
||||||
scripts: `
|
scripts: `
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=9';
|
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=10';
|
||||||
const gallery = document.getElementById('gallery');
|
const gallery = document.getElementById('gallery');
|
||||||
gallery.splats = ${splatsJSON};
|
gallery.splats = ${splatsJSON};
|
||||||
gallery.spaceSlug = '${spaceSlug}';
|
gallery.spaceSlug = '${spaceSlug}';
|
||||||
|
|
@ -772,12 +783,13 @@ function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
head: `
|
head: `
|
||||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=6">
|
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=7">
|
||||||
|
${MODEL_VIEWER_SCRIPT}
|
||||||
${IMPORTMAP}
|
${IMPORTMAP}
|
||||||
`,
|
`,
|
||||||
scripts: `
|
scripts: `
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=9';
|
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=10';
|
||||||
</script>
|
</script>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
@ -826,7 +838,7 @@ function seedTemplateSplat(space: string) {
|
||||||
source: 'upload', status: 'published', viewCount: 0,
|
source: 'upload', status: 'published', viewCount: 0,
|
||||||
paymentTx: null, paymentNetwork: null,
|
paymentTx: null, paymentNetwork: null,
|
||||||
createdAt: now, processingStatus: 'ready', processingError: null,
|
createdAt: now, processingStatus: 'ready', processingError: null,
|
||||||
sourceFileCount: 0, sourceFiles: [],
|
sourceFileCount: 0, sourceFiles: [], thumbnailUrl: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export interface SplatItem {
|
||||||
processingError: string | null;
|
processingError: string | null;
|
||||||
sourceFileCount: number;
|
sourceFileCount: number;
|
||||||
sourceFiles: SourceFile[];
|
sourceFiles: SourceFile[];
|
||||||
|
thumbnailUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SplatScenesDoc {
|
export interface SplatScenesDoc {
|
||||||
|
|
|
||||||
|
|
@ -782,12 +782,24 @@ async function process3DGenJob(job: Gen3DJob) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch result
|
// 3. Fetch result (retry once after 3s on transient failure)
|
||||||
const resultRes = await fetch(
|
let resultRes = await fetch(
|
||||||
`https://queue.fal.run/${MODEL}/requests/${request_id}`,
|
`https://queue.fal.run/${MODEL}/requests/${request_id}`,
|
||||||
{ headers: falHeaders },
|
{ headers: falHeaders },
|
||||||
);
|
);
|
||||||
if (!resultRes.ok) {
|
if (!resultRes.ok) {
|
||||||
|
console.warn(`[3d-gen] Result fetch failed (status=${resultRes.status}), retrying in 3s...`);
|
||||||
|
const errBody = await resultRes.text().catch(() => "");
|
||||||
|
console.warn(`[3d-gen] Result error body:`, errBody);
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
resultRes = await fetch(
|
||||||
|
`https://queue.fal.run/${MODEL}/requests/${request_id}`,
|
||||||
|
{ headers: falHeaders },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!resultRes.ok) {
|
||||||
|
const errBody = await resultRes.text().catch(() => "");
|
||||||
|
console.error(`[3d-gen] Result fetch failed after retry (status=${resultRes.status}):`, errBody);
|
||||||
job.status = "failed";
|
job.status = "failed";
|
||||||
job.error = "Failed to retrieve 3D model";
|
job.error = "Failed to retrieve 3D model";
|
||||||
job.completedAt = Date.now();
|
job.completedAt = Date.now();
|
||||||
|
|
@ -795,8 +807,10 @@ async function process3DGenJob(job: Gen3DJob) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await resultRes.json();
|
const data = await resultRes.json();
|
||||||
const modelUrl = data.model_glb?.url || data.model_glb_pbr?.url;
|
console.log(`[3d-gen] Result keys for ${job.id}:`, Object.keys(data));
|
||||||
|
const modelUrl = data.model_glb?.url || data.model_glb_pbr?.url || data.mesh?.url;
|
||||||
if (!modelUrl) {
|
if (!modelUrl) {
|
||||||
|
console.error(`[3d-gen] No model URL found in response:`, JSON.stringify(data).slice(0, 500));
|
||||||
job.status = "failed";
|
job.status = "failed";
|
||||||
job.error = "No 3D model returned";
|
job.error = "No 3D model returned";
|
||||||
job.completedAt = Date.now();
|
job.completedAt = Date.now();
|
||||||
|
|
@ -1273,6 +1287,7 @@ app.get("/api/3d-gen/:jobId", async (c) => {
|
||||||
response.format = job.resultFormat;
|
response.format = job.resultFormat;
|
||||||
response.completed_at = job.completedAt;
|
response.completed_at = job.completedAt;
|
||||||
response.email_sent = job.emailSent || false;
|
response.email_sent = job.emailSent || false;
|
||||||
|
response.source_image = job.imageUrl;
|
||||||
} else if (job.status === "failed") {
|
} else if (job.status === "failed") {
|
||||||
response.error = job.error;
|
response.error = job.error;
|
||||||
response.completed_at = job.completedAt;
|
response.completed_at = job.completedAt;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue