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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 00:56:33 -07:00
parent 8a82223b6f
commit d008b78727
4 changed files with 259 additions and 136 deletions

View File

@ -1,7 +1,7 @@
/**
* <folk-splat-viewer> 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 {
<div class="splat-upload" id="splat-drop">
<div class="splat-upload__toggle">
<button class="splat-upload__toggle-btn active" data-mode="splat">Upload Splat</button>
<button class="splat-upload__toggle-btn" data-mode="media">Upload Photos/Video</button>
<button class="splat-upload__toggle-btn" data-mode="generate">Generate from Image</button>
</div>
@ -210,40 +215,21 @@ export class FolkSplatViewer extends HTMLElement {
</div>
</div>
<!-- Media upload mode -->
<div class="splat-upload__mode" id="splat-mode-media" style="display:none">
<div class="splat-upload__icon">📸</div>
<p class="splat-upload__text">
Upload <strong>photos</strong> (up to 100) or a <strong>video</strong> to generate a 3D splat
<br>Accepted: .jpg, .png, .heic, .mp4, .mov, .webm
<br>or <strong id="media-browse">browse</strong> to select files
</p>
<input type="file" id="media-files" accept=".jpg,.jpeg,.png,.heic,.mp4,.mov,.webm" multiple hidden>
<div class="splat-upload__selected" id="media-selected"></div>
<div class="splat-upload__form" id="media-form">
<input type="text" id="media-title-input" placeholder="Title (required)" required>
<textarea id="media-desc-input" placeholder="Description (optional)" rows="2"></textarea>
<input type="text" id="media-tags-input" placeholder="Tags (comma-separated)">
<button class="splat-upload__btn" id="media-submit" disabled>Upload for Processing</button>
<div class="splat-upload__status" id="media-status"></div>
</div>
</div>
<!-- Generate from image mode -->
<div class="splat-upload__mode" id="splat-mode-generate" style="display:none">
<div class="splat-upload__icon"></div>
<p class="splat-upload__text">
Upload a single <strong>image</strong> to generate a 3D Gaussian splat using AI
Upload a single <strong>image</strong> to generate a 3D model using AI (Trellis 2)
<br>or <strong id="generate-browse">browse</strong> to select an image
</p>
<input type="file" id="generate-file" accept=".jpg,.jpeg,.png,.webp" hidden>
<div class="splat-generate__preview" id="generate-preview"></div>
<div class="splat-generate__actions" id="generate-actions" style="display:none">
<button class="splat-upload__btn" id="generate-submit">Generate 3D Splat</button>
<button class="splat-upload__btn" id="generate-submit">Generate 3D Model</button>
</div>
<div class="splat-generate__progress" id="generate-progress" style="display:none">
<div class="splat-generate__progress-bar"></div>
<div class="splat-generate__progress-text">Generating 3D model...</div>
<div class="splat-generate__progress-text" id="generate-progress-text">Generating 3D model...</div>
</div>
<div class="splat-upload__status" id="generate-status"></div>
</div>
@ -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<HTMLButtonElement>(".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 = `<div class="splat-upload__file-count">${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""} selected (${formatSize(totalSize)})</div>`;
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<HTMLElement>(".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 {
? `<button class="splat-viewer__back" id="splat-back-btn">← Back to Gallery</button>`
: `<a class="splat-viewer__back" href="/${this._spaceSlug}/rsplat">← Gallery</a>`;
const showSave = this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo";
const saveEl = showSave
? `<button class="splat-viewer__save" id="splat-save-btn">Save to Gallery</button>`
: "";
this.innerHTML = `
<div class="splat-viewer">
<div class="splat-loading" id="splat-loading">
<div class="splat-loading__spinner"></div>
<div class="splat-loading__text">Loading splat...</div>
<div class="splat-loading__text">Loading 3D model...</div>
</div>
<div class="splat-viewer__controls">
${backEl}
${saveEl}
</div>
${this._splatTitle ? `
<div class="splat-viewer__info">
@ -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;

View File

@ -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 {

View File

@ -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<SplatScenesDoc>(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: `
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=2">
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=3">
${IMPORTMAP}
`,
scripts: `
<script type="module">
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=3';
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=4';
const gallery = document.getElementById('gallery');
gallery.splats = ${splatsJSON};
gallery.spaceSlug = '${spaceSlug}';
@ -664,12 +752,12 @@ routes.get("/view/:id", async (c) => {
modules: getModuleInfoList(),
theme: "dark",
head: `
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=2">
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=3">
${IMPORTMAP}
`,
scripts: `
<script type="module">
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=3';
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=4';
</script>
`,
});

View File

@ -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);
}