From d008b78727e069c5cc51b4312b44d58824efc1d1 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 00:56:33 -0700 Subject: [PATCH] fix(rsplat): save-to-gallery, remove broken media upload, fix leaks - Add POST /api/splats/save-generated so AI-generated 3D models persist - Add "Save to Gallery" button in viewer after AI generation - Remove non-functional "Upload Photos/Video" tab (no processing worker) - Add 120s server-side timeout on fal.ai Trellis 2 fetch - Fix GLB viewer memory leak (animation loop + resize listener on disconnect) - Show elapsed time + phase messages during generation progress - Bump CSS v3, JS v4 cache versions Co-Authored-By: Claude Opus 4.6 --- .../rsplat/components/folk-splat-viewer.ts | 261 +++++++++--------- modules/rsplat/components/splat.css | 30 +- modules/rsplat/mod.ts | 96 ++++++- server/index.ts | 8 + 4 files changed, 259 insertions(+), 136 deletions(-) diff --git a/modules/rsplat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts index 838c297..fb1e323 100644 --- a/modules/rsplat/components/folk-splat-viewer.ts +++ b/modules/rsplat/components/folk-splat-viewer.ts @@ -1,7 +1,7 @@ /** * — Gaussian splat gallery + 3D viewer web component. * - * Gallery mode: card grid of splats with upload form (splat files or photos/video). + * Gallery mode: card grid of splats with upload form + AI generation. * Viewer mode: full-viewport Three.js + GaussianSplats3D renderer. * * Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled). @@ -33,9 +33,11 @@ export class FolkSplatViewer extends HTMLElement { private _splatTitle = ""; private _splatDesc = ""; private _viewer: any = null; - private _uploadMode: "splat" | "media" | "generate" = "splat"; + private _uploadMode: "splat" | "generate" = "splat"; private _inlineViewer = false; private _offlineUnsub: (() => void) | null = null; + private _generatedUrl = ""; + private _generatedTitle = ""; static get observedAttributes() { return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"]; @@ -115,6 +117,10 @@ export class FolkSplatViewer extends HTMLElement { try { this._viewer.dispose(); } catch {} this._viewer = null; } + if ((this as any)._glbCleanup) { + try { (this as any)._glbCleanup(); } catch {} + (this as any)._glbCleanup = null; + } } attributeChangedCallback(name: string, _old: string, val: string) { @@ -189,7 +195,6 @@ export class FolkSplatViewer extends HTMLElement {
-
@@ -210,40 +215,21 @@ export class FolkSplatViewer extends HTMLElement {
- - - @@ -252,7 +238,6 @@ export class FolkSplatViewer extends HTMLElement { `; this.setupUploadHandlers(); - this.setupMediaHandlers(); this.setupGenerateHandlers(); this.setupToggle(); this.setupDemoCardHandlers(); @@ -261,16 +246,14 @@ export class FolkSplatViewer extends HTMLElement { private setupToggle() { const buttons = this.querySelectorAll(".splat-upload__toggle-btn"); const splatMode = this.querySelector("#splat-mode-splat") as HTMLElement; - const mediaMode = this.querySelector("#splat-mode-media") as HTMLElement; const generateMode = this.querySelector("#splat-mode-generate") as HTMLElement; buttons.forEach((btn) => { btn.addEventListener("click", () => { - const mode = btn.dataset.mode as "splat" | "media" | "generate"; + const mode = btn.dataset.mode as "splat" | "generate"; this._uploadMode = mode; buttons.forEach((b) => b.classList.toggle("active", b.dataset.mode === mode)); splatMode.style.display = mode === "splat" ? "" : "none"; - mediaMode.style.display = mode === "media" ? "" : "none"; if (generateMode) generateMode.style.display = mode === "generate" ? "" : "none"; }); }); @@ -380,93 +363,6 @@ export class FolkSplatViewer extends HTMLElement { }); } - private setupMediaHandlers() { - const browse = this.querySelector("#media-browse") as HTMLElement; - const fileInput = this.querySelector("#media-files") as HTMLInputElement; - const form = this.querySelector("#media-form") as HTMLElement; - const selected = this.querySelector("#media-selected") as HTMLElement; - const titleInput = this.querySelector("#media-title-input") as HTMLInputElement; - const descInput = this.querySelector("#media-desc-input") as HTMLTextAreaElement; - const tagsInput = this.querySelector("#media-tags-input") as HTMLInputElement; - const submitBtn = this.querySelector("#media-submit") as HTMLButtonElement; - const status = this.querySelector("#media-status") as HTMLElement; - - if (!fileInput) return; - - let selectedFiles: File[] = []; - - browse?.addEventListener("click", () => fileInput.click()); - - fileInput.addEventListener("change", () => { - if (fileInput.files && fileInput.files.length > 0) { - selectedFiles = Array.from(fileInput.files); - form.classList.add("active"); - const totalSize = selectedFiles.reduce((sum, f) => sum + f.size, 0); - selected.innerHTML = `
${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""} selected (${formatSize(totalSize)})
`; - if (!titleInput.value.trim() && selectedFiles.length > 0) { - const name = selectedFiles[0].name.replace(/\.[^.]+$/, ""); - titleInput.value = name.replace(/[-_]/g, " "); - } - titleInput.dispatchEvent(new Event("input")); - } - }); - - titleInput?.addEventListener("input", () => { - submitBtn.disabled = !titleInput.value.trim() || selectedFiles.length === 0; - }); - - submitBtn?.addEventListener("click", async () => { - if (selectedFiles.length === 0 || !titleInput.value.trim()) return; - - submitBtn.disabled = true; - status.textContent = "Uploading..."; - - const formData = new FormData(); - for (const f of selectedFiles) { - formData.append("files", f); - } - formData.append("title", titleInput.value.trim()); - formData.append("description", descInput.value.trim()); - formData.append("tags", tagsInput.value.trim()); - - try { - const token = localStorage.getItem("encryptid_token") || ""; - const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/from-media`, { - method: "POST", - headers: token ? { Authorization: `Bearer ${token}` } : {}, - body: formData, - }); - - if (res.status === 402) { - status.textContent = "Payment required for upload (x402)"; - submitBtn.disabled = false; - return; - } - - if (res.status === 401) { - status.textContent = "Sign in with rStack Identity to upload"; - submitBtn.disabled = false; - return; - } - - if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Upload failed" })); - status.textContent = (err as any).error || "Upload failed"; - submitBtn.disabled = false; - return; - } - - status.textContent = "Uploaded! Queued for processing."; - setTimeout(() => { - window.location.href = `/${this._spaceSlug}/rsplat`; - }, 1000); - } catch (e) { - status.textContent = "Network error"; - submitBtn.disabled = false; - } - }); - } - private setupDemoCardHandlers() { this.querySelectorAll(".splat-card--demo").forEach((card) => { card.style.cursor = "pointer"; @@ -493,6 +389,7 @@ export class FolkSplatViewer extends HTMLElement { const actions = this.querySelector("#generate-actions") as HTMLElement; const submitBtn = this.querySelector("#generate-submit") as HTMLButtonElement; const progress = this.querySelector("#generate-progress") as HTMLElement; + const progressText = this.querySelector("#generate-progress-text") as HTMLElement; const status = this.querySelector("#generate-status") as HTMLElement; if (!fileInput) return; @@ -521,25 +418,41 @@ export class FolkSplatViewer extends HTMLElement { submitBtn.disabled = true; actions.style.display = "none"; progress.style.display = "block"; - status.textContent = "Preparing image..."; + + // Elapsed time ticker + const startTime = Date.now(); + const phases = [ + { t: 0, msg: "Preparing image..." }, + { t: 3, msg: "Uploading to Trellis 2..." }, + { t: 8, msg: "Reconstructing 3D geometry..." }, + { t: 20, msg: "Generating mesh and textures..." }, + { t: 45, msg: "Finalizing model..." }, + { t: 75, msg: "Almost there..." }, + ]; + const ticker = setInterval(() => { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + const phase = [...phases].reverse().find(p => elapsed >= p.t); + if (progressText && phase) { + progressText.textContent = `${phase.msg} (${elapsed}s)`; + } + }, 1000); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 90000); + const timeout = setTimeout(() => controller.abort(), 120_000); try { - // Resize image to max 1024px to reduce payload and improve API success const dataUrl = await this.resizeImage(selectedFile!, 1024); - status.textContent = "Generating 3D model..."; const res = await fetch("/api/3d-gen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image_url: dataUrl }), signal: controller.signal, }); + clearInterval(ticker); clearTimeout(timeout); - if (res.status === 524) { - status.textContent = "Request timed out — the model took too long. Try a simpler image."; + if (res.status === 524 || res.status === 504) { + status.textContent = "Generation timed out — try a simpler image."; progress.style.display = "none"; actions.style.display = "flex"; submitBtn.disabled = false; @@ -565,18 +478,25 @@ export class FolkSplatViewer extends HTMLElement { const data = await res.json() as { url: string; format: string }; progress.style.display = "none"; + const elapsed = Math.floor((Date.now() - startTime) / 1000); + status.textContent = `Generated in ${elapsed}s`; - // Open inline viewer with generated splat + // Store generated info for save-to-gallery + this._generatedUrl = data.url; + this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, ""); + + // Open inline viewer with generated model this._mode = "viewer"; this._splatUrl = data.url; - this._splatTitle = selectedFile.name.replace(/\.[^.]+$/, ""); + this._splatTitle = this._generatedTitle; this._splatDesc = "AI-generated 3D model"; this._inlineViewer = true; this.renderViewer(); } catch (e: any) { + clearInterval(ticker); clearTimeout(timeout); if (e.name === "AbortError") { - status.textContent = "Request timed out after 90s — try a simpler image."; + status.textContent = "Request timed out — try a simpler image."; } else { status.textContent = "Network error — could not reach server"; } @@ -618,14 +538,20 @@ export class FolkSplatViewer extends HTMLElement { ? `` : `← Gallery`; + const showSave = this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo"; + const saveEl = showSave + ? `` + : ""; + this.innerHTML = `
-
Loading splat...
+
Loading 3D model...
${backEl} + ${saveEl}
${this._splatTitle ? `
@@ -639,23 +565,98 @@ export class FolkSplatViewer extends HTMLElement { if (this._inlineViewer) { this.querySelector("#splat-back-btn")?.addEventListener("click", () => { - if (this._viewer) { - try { this._viewer.dispose(); } catch {} - this._viewer = null; - } + this.cleanupViewer(); this._mode = "gallery"; this._inlineViewer = false; this._splatUrl = ""; this._splatTitle = ""; this._splatDesc = ""; + this._generatedUrl = ""; + this._generatedTitle = ""; if (this._spaceSlug === "demo") this.loadDemoData(); this.renderGallery(); }); } + if (showSave) { + this.querySelector("#splat-save-btn")?.addEventListener("click", () => this.saveToGallery()); + } + this.initThreeViewer(); } + private cleanupViewer() { + if (this._viewer) { + try { this._viewer.dispose(); } catch {} + this._viewer = null; + } + if ((this as any)._glbCleanup) { + try { (this as any)._glbCleanup(); } catch {} + (this as any)._glbCleanup = null; + } + } + + private async saveToGallery() { + const saveBtn = this.querySelector("#splat-save-btn") as HTMLButtonElement; + if (!saveBtn || !this._generatedUrl) return; + + saveBtn.disabled = true; + saveBtn.textContent = "Saving..."; + + try { + const token = localStorage.getItem("encryptid_token") || ""; + if (!token) { + saveBtn.textContent = "Sign in to save"; + saveBtn.disabled = false; + return; + } + + const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + url: this._generatedUrl, + title: this._generatedTitle || "AI Generated Model", + description: "AI-generated 3D model via Trellis 2", + }), + }); + + if (res.status === 401) { + saveBtn.textContent = "Sign in to save"; + saveBtn.disabled = false; + return; + } + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Save failed" })); + saveBtn.textContent = (err as any).error || "Save failed"; + setTimeout(() => { + saveBtn.textContent = "Save to Gallery"; + saveBtn.disabled = false; + }, 2000); + return; + } + + const data = await res.json() as { slug: string }; + saveBtn.textContent = "Saved!"; + this._generatedUrl = ""; + + // Navigate to the saved splat after a moment + setTimeout(() => { + window.location.href = `/${this._spaceSlug}/rsplat/view/${data.slug}`; + }, 800); + } catch { + saveBtn.textContent = "Network error"; + setTimeout(() => { + saveBtn.textContent = "Save to Gallery"; + saveBtn.disabled = false; + }, 2000); + } + } + private async initThreeViewer() { const container = this.querySelector("#splat-container") as HTMLElement; const loading = this.querySelector("#splat-loading") as HTMLElement; diff --git a/modules/rsplat/components/splat.css b/modules/rsplat/components/splat.css index 6c56c73..1667d1c 100644 --- a/modules/rsplat/components/splat.css +++ b/modules/rsplat/components/splat.css @@ -374,13 +374,39 @@ cursor: pointer; } -/* ── Back button (inline viewer) ── */ +/* ── Back + Save buttons (inline viewer) ── */ -button.splat-viewer__back { +button.splat-viewer__back, +button.splat-viewer__save { cursor: pointer; font-family: inherit; } +.splat-viewer__save { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + border-radius: 8px; + background: var(--splat-accent); + backdrop-filter: blur(8px); + color: white; + text-decoration: none; + font-size: 0.8rem; + font-weight: 600; + border: 1px solid var(--splat-accent); + transition: background 0.2s, opacity 0.2s; +} + +.splat-viewer__save:hover { + background: var(--splat-accent-hover); +} + +.splat-viewer__save:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* ── Viewer ── */ .splat-viewer { diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts index 0f65da7..2933a92 100644 --- a/modules/rsplat/mod.ts +++ b/modules/rsplat/mod.ts @@ -541,6 +541,94 @@ routes.post("/api/splats/from-media", async (c) => { }, 201); }); +// ── API: Save generated 3D model to gallery ── +routes.post("/api/splats/save-generated", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims; + try { + claims = await verifyEncryptIDToken(token); + } catch { + return c.json({ error: "Invalid token" }, 401); + } + + const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || spaceSlug; + const { url, title, description } = await c.req.json(); + + if (!url || !title?.trim()) { + return c.json({ error: "url and title required" }, 400); + } + + // Verify the file exists on disk (must be a generated file) + const generatedDir = resolve(process.env.FILES_DIR || "./data/files", "generated"); + const filename = url.split("/").pop(); + if (!filename || !filename.startsWith("splat-")) { + return c.json({ error: "Invalid generated file URL" }, 400); + } + + const srcPath = resolve(generatedDir, filename); + const srcFile = Bun.file(srcPath); + if (!(await srcFile.exists())) { + return c.json({ error: "Generated file not found" }, 404); + } + + // Determine format and create slug + const ext = filename.split(".").pop() || "glb"; + const slug = slugify(title.trim()); + const shortId = randomUUID().slice(0, 8); + const doc = ensureDoc(dataSpace); + const slugExists = Object.values(doc.items).some((item) => item.slug === slug); + const finalSlug = slugExists ? `${slug}-${shortId}` : slug; + + // Copy file to splats directory + await mkdir(SPLATS_DIR, { recursive: true }); + const destFilename = `${finalSlug}.${ext}`; + const destPath = resolve(SPLATS_DIR, destFilename); + const buf = await srcFile.arrayBuffer(); + await Bun.write(destPath, buf); + + // Add to Automerge + const splatId = randomUUID(); + const now = Date.now(); + const docId = splatScenesDocId(dataSpace); + _syncServer!.changeDoc(docId, 'save generated splat', (d) => { + d.items[splatId] = { + id: splatId, + slug: finalSlug, + title: title.trim(), + description: (description || "AI-generated 3D model").trim(), + filePath: destFilename, + fileFormat: ext, + fileSizeBytes: buf.byteLength, + tags: ["ai-generated"], + spaceSlug, + contributorId: claims.sub, + contributorName: claims.username || null, + source: 'ai-generated', + status: 'published', + viewCount: 0, + paymentTx: null, + paymentNetwork: null, + createdAt: now, + processingStatus: 'ready', + processingError: null, + sourceFileCount: 0, + sourceFiles: [], + }; + }); + + return c.json({ + id: splatId, + slug: finalSlug, + title: title.trim(), + file_format: ext, + file_size_bytes: buf.byteLength, + created_at: new Date(now).toISOString(), + }, 201); +}); + // ── API: Delete splat (owner only) ── routes.delete("/api/splats/:id", async (c) => { const token = extractToken(c.req.raw.headers); @@ -600,12 +688,12 @@ routes.get("/", async (c) => { modules: getModuleInfoList(), theme: "dark", head: ` - + ${IMPORTMAP} `, scripts: ` `, }); diff --git a/server/index.ts b/server/index.ts index 125f03c..a0cdcff 100644 --- a/server/index.ts +++ b/server/index.ts @@ -989,6 +989,8 @@ app.post("/api/3d-gen", async (c) => { if (!image_url) return c.json({ error: "image_url required" }, 400); try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 120_000); // 120s server-side timeout const res = await fetch("https://fal.run/fal-ai/trellis-2", { method: "POST", headers: { @@ -996,7 +998,9 @@ app.post("/api/3d-gen", async (c) => { "Content-Type": "application/json", }, body: JSON.stringify({ image_url, resolution: 1024 }), + signal: controller.signal, }); + clearTimeout(timeout); if (!res.ok) { const errText = await res.text(); @@ -1029,6 +1033,10 @@ app.post("/api/3d-gen", async (c) => { return c.json({ url: `/data/files/generated/${filename}`, format: ext }); } catch (e: any) { + if (e.name === "AbortError") { + console.error("[3d-gen] server-side timeout after 120s"); + return c.json({ error: "3D generation timed out — try a simpler image" }, 504); + } console.error("[3d-gen] error:", e.message); return c.json({ error: "3D generation failed" }, 502); }