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;
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export interface SplatItem {
|
|||
processingError: string | null;
|
||||
sourceFileCount: number;
|
||||
sourceFiles: SourceFile[];
|
||||
thumbnailUrl: string | null;
|
||||
}
|
||||
|
||||
export interface SplatScenesDoc {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue