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:
Jeff Emmett 2026-03-16 16:22:16 -07:00
parent 7da90088c4
commit 6b3fbd36b0
5 changed files with 96 additions and 34 deletions

View File

@ -17,9 +17,11 @@ interface SplatItem {
title: string;
description?: string;
file_format: string;
file_url?: string;
file_size_bytes: number;
view_count: number;
contributor_name?: string;
thumbnail_url?: string;
processing_status?: string;
source_file_count?: number;
created_at: string;
@ -97,8 +99,11 @@ export class FolkSplatViewer extends HTMLElement {
if (this._splats.length > 0) return; // Don't clobber server-hydrated data
this._splats = Object.values(doc.items).map(s => ({
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,
thumbnail_url: (s as any).thumbnailUrl ?? undefined,
processing_status: s.processingStatus ?? undefined,
source_file_count: s.sourceFileCount,
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>`;
}
// 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
? `<span>${s.source_file_count} source file${s.source_file_count > 1 ? "s" : ""}</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}>
<div class="splat-card__preview">
${overlay}
<span>${isReady ? "🔮" : "📸"}</span>
${previewContent}
</div>
<div class="splat-card__body">
<div class="splat-card__title">${esc(s.title)}</div>
@ -598,6 +613,8 @@ export class FolkSplatViewer extends HTMLElement {
clearInterval(pollInterval);
progress.style.display = "none";
const sourceImage = job.source_image || "";
// Show result with save option
const resultDiv = document.createElement("div");
resultDiv.className = "splat-generate__result";
@ -613,47 +630,49 @@ export class FolkSplatViewer extends HTMLElement {
status.textContent = "";
status.after(resultDiv);
// View button
// View button — also store source image for save flow
resultDiv.querySelector("#gen-view-btn")?.addEventListener("click", () => {
this._mode = "viewer";
this._splatUrl = job.url;
this._splatTitle = title;
this._splatDesc = "AI-generated 3D model";
this._generatedUrl = job.url;
this._generatedTitle = title;
(this as any)._sourceImageUrl = sourceImage;
this._inlineViewer = true;
this.renderViewer();
});
// Save to gallery button
// Save to gallery button — use save-generated with thumbnail
resultDiv.querySelector("#gen-save-btn")?.addEventListener("click", async () => {
const saveBtn = resultDiv.querySelector("#gen-save-btn") as HTMLButtonElement;
saveBtn.disabled = true;
saveBtn.textContent = "Saving...";
try {
// Download the generated file and re-upload as a splat
const fileRes = await fetch(job.url);
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`, {
const token = this.getAuthToken();
const saveRes = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, {
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.style.background = "#16a34a";
setTimeout(() => {
window.location.href = `/${this._spaceSlug}/rsplat`;
window.location.href = `/${this._spaceSlug}/rsplat/${data.slug}`;
}, 1000);
} 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.disabled = false;
}
@ -706,6 +725,7 @@ export class FolkSplatViewer extends HTMLElement {
url: this._generatedUrl,
title: this._generatedTitle || "AI Generated Model",
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,
title: this._generatedTitle || "AI Generated Model",
description: "AI-generated 3D model via Hunyuan3D",
thumbnail_url: (this as any)._sourceImageUrl || undefined,
}),
});

View File

@ -69,6 +69,19 @@
justify-content: center;
font-size: 3rem;
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 {

View File

@ -162,16 +162,21 @@ function itemToRow(item: SplatItem): SplatRow {
/**
* 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 {
id: item.id,
slug: item.slug,
title: item.title,
description: item.description || null,
file_format: item.fileFormat,
file_url: fileUrl,
file_size_bytes: item.fileSizeBytes,
tags: item.tags ?? [],
contributor_name: item.contributorName,
thumbnail_url: item.thumbnailUrl || null,
view_count: item.viewCount,
processing_status: item.processingStatus ?? 'ready',
source_file_count: item.sourceFileCount,
@ -181,6 +186,8 @@ function itemToListRow(item: SplatItem) {
// ── 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">
{
"imports": {
@ -225,7 +232,7 @@ routes.get("/api/splats", async (c) => {
// Apply offset and 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 ──
@ -382,6 +389,7 @@ routes.post("/api/splats", async (c) => {
processingError: null,
sourceFileCount: 0,
sourceFiles: [],
thumbnailUrl: null,
};
});
@ -525,6 +533,7 @@ routes.post("/api/splats/from-media", async (c) => {
processingError: null,
sourceFileCount: files.length,
sourceFiles: sourceFileEntries,
thumbnailUrl: null,
};
});
@ -555,7 +564,7 @@ routes.post("/api/splats/save-generated", async (c) => {
const spaceSlug = c.req.param("space") || "demo";
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()) {
return c.json({ error: "url and title required" }, 400);
@ -616,6 +625,7 @@ routes.post("/api/splats/save-generated", async (c) => {
processingError: null,
sourceFileCount: 0,
sourceFiles: [],
thumbnailUrl: thumbnail_url || null,
};
});
@ -650,7 +660,7 @@ routes.get("/api/splats/my-history", async (c) => {
.sort((a, b) => b.createdAt - a.createdAt)
.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) ──
@ -701,7 +711,7 @@ routes.get("/", async (c) => {
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 50);
const rows = items.map(itemToListRow);
const rows = items.map(i => itemToListRow(i, spaceSlug));
const splatsJSON = JSON.stringify(rows);
const html = renderShell({
@ -712,12 +722,13 @@ routes.get("/", async (c) => {
modules: getModuleInfoList(),
theme: "dark",
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}
`,
scripts: `
<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');
gallery.splats = ${splatsJSON};
gallery.spaceSlug = '${spaceSlug}';
@ -772,12 +783,13 @@ function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string
modules: getModuleInfoList(),
theme: "dark",
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}
`,
scripts: `
<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>
`,
});
@ -826,7 +838,7 @@ function seedTemplateSplat(space: string) {
source: 'upload', status: 'published', viewCount: 0,
paymentTx: null, paymentNetwork: null,
createdAt: now, processingStatus: 'ready', processingError: null,
sourceFileCount: 0, sourceFiles: [],
sourceFileCount: 0, sourceFiles: [], thumbnailUrl: null,
};
}
});

View File

@ -43,6 +43,7 @@ export interface SplatItem {
processingError: string | null;
sourceFileCount: number;
sourceFiles: SourceFile[];
thumbnailUrl: string | null;
}
export interface SplatScenesDoc {

View File

@ -782,12 +782,24 @@ async function process3DGenJob(job: Gen3DJob) {
return;
}
// 3. Fetch result
const resultRes = await fetch(
// 3. Fetch result (retry once after 3s on transient failure)
let resultRes = await fetch(
`https://queue.fal.run/${MODEL}/requests/${request_id}`,
{ headers: falHeaders },
);
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.error = "Failed to retrieve 3D model";
job.completedAt = Date.now();
@ -795,8 +807,10 @@ async function process3DGenJob(job: Gen3DJob) {
}
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) {
console.error(`[3d-gen] No model URL found in response:`, JSON.stringify(data).slice(0, 500));
job.status = "failed";
job.error = "No 3D model returned";
job.completedAt = Date.now();
@ -1273,6 +1287,7 @@ app.get("/api/3d-gen/:jobId", async (c) => {
response.format = job.resultFormat;
response.completed_at = job.completedAt;
response.email_sent = job.emailSent || false;
response.source_image = job.imageUrl;
} else if (job.status === "failed") {
response.error = job.error;
response.completed_at = job.completedAt;