feat(rsplat): image staging endpoint, viewer improvements, SW updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 12:24:40 -07:00
parent 9ecffff692
commit 21b1e8fa0a
5 changed files with 400 additions and 96 deletions

View File

@ -38,6 +38,8 @@ export class FolkSplatViewer extends HTMLElement {
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _generatedUrl = ""; private _generatedUrl = "";
private _generatedTitle = ""; private _generatedTitle = "";
private _savedSlug = "";
private _myHistory: SplatItem[] = [];
static get observedAttributes() { static get observedAttributes() {
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"]; return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
@ -67,6 +69,7 @@ export class FolkSplatViewer extends HTMLElement {
} else { } else {
this.subscribeOffline(); this.subscribeOffline();
} }
this.loadMyHistory();
this.renderGallery(); this.renderGallery();
} }
} }
@ -110,6 +113,22 @@ export class FolkSplatViewer extends HTMLElement {
]; ];
} }
private async loadMyHistory() {
const token = localStorage.getItem("encryptid_token");
if (!token || this._spaceSlug === "demo") return;
try {
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/my-history`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
this._myHistory = data.splats || [];
if (this._mode === "gallery") this.renderGallery();
}
} catch { /* non-critical */ }
}
disconnectedCallback() { disconnectedCallback() {
this._offlineUnsub?.(); this._offlineUnsub?.();
this._offlineUnsub = null; this._offlineUnsub = null;
@ -133,14 +152,12 @@ export class FolkSplatViewer extends HTMLElement {
// ── Gallery ── // ── Gallery ──
private renderGallery() { private renderCard(s: SplatItem): string {
const cards = this._splats.map((s) => {
const status = s.processing_status || "ready"; const status = s.processing_status || "ready";
const isReady = status === "ready"; const isReady = status === "ready";
const isDemo = !!s.demoUrl; const isDemo = !!s.demoUrl;
// Demo cards use a button instead of a link to avoid server round-trip
const tag = isReady ? (isDemo ? "div" : "a") : "div"; const tag = isReady ? (isDemo ? "div" : "a") : "div";
const href = isReady && !isDemo ? ` href="/${this._spaceSlug}/rsplat/view/${s.slug}"` : ""; const href = isReady && !isDemo ? ` href="/${this._spaceSlug}/rsplat/${s.slug}"` : "";
const demoAttr = isDemo ? ` data-demo-url="${esc(s.demoUrl!)}" data-demo-title="${esc(s.title)}" data-demo-desc="${esc(s.description || "")}" role="button" tabindex="0"` : ""; const demoAttr = isDemo ? ` data-demo-url="${esc(s.demoUrl!)}" data-demo-title="${esc(s.title)}" data-demo-desc="${esc(s.description || "")}" role="button" tabindex="0"` : "";
const statusClass = !isReady ? ` splat-card--${status}` : ""; const statusClass = !isReady ? ` splat-card--${status}` : "";
const demoClass = isDemo ? " splat-card--demo" : ""; const demoClass = isDemo ? " splat-card--demo" : "";
@ -174,9 +191,20 @@ export class FolkSplatViewer extends HTMLElement {
</div> </div>
</${tag}> </${tag}>
`; `;
}).join(""); }
const empty = this._splats.length === 0 ? ` private renderGallery() {
const cards = this._splats.map((s) => this.renderCard(s)).join("");
// My Models section
const myModelsHtml = this._myHistory.length > 0 ? `
<div class="splat-my-models">
<h2 class="splat-my-models__title">My Models</h2>
<div class="splat-grid">${this._myHistory.map((s) => this.renderCard(s)).join("")}</div>
</div>
` : "";
const empty = this._splats.length === 0 && this._myHistory.length === 0 ? `
<div class="splat-empty"> <div class="splat-empty">
<div class="splat-empty__icon">🔮</div> <div class="splat-empty__icon">🔮</div>
<h3>No splats yet</h3> <h3>No splats yet</h3>
@ -190,7 +218,9 @@ export class FolkSplatViewer extends HTMLElement {
<h1>rSplat</h1> <h1>rSplat</h1>
<p class="splat-gallery__subtitle">Explore and create 3D Gaussian splat scenes</p> <p class="splat-gallery__subtitle">Explore and create 3D Gaussian splat scenes</p>
</div> </div>
${myModelsHtml}
${empty} ${empty}
${this._splats.length > 0 ? `<h2 class="splat-section-title">Gallery</h2>` : ""}
<div class="splat-grid">${cards}</div> <div class="splat-grid">${cards}</div>
<div class="splat-upload" id="splat-drop"> <div class="splat-upload" id="splat-drop">
<div class="splat-upload__toggle"> <div class="splat-upload__toggle">
@ -354,7 +384,7 @@ export class FolkSplatViewer extends HTMLElement {
const splat = await res.json() as SplatItem; const splat = await res.json() as SplatItem;
status.textContent = "Uploaded!"; status.textContent = "Uploaded!";
setTimeout(() => { setTimeout(() => {
window.location.href = `/${this._spaceSlug}/rsplat/view/${splat.slug}`; window.location.href = `/${this._spaceSlug}/rsplat/${splat.slug}`;
}, 500); }, 500);
} catch (e) { } catch (e) {
status.textContent = "Network error"; status.textContent = "Network error";
@ -382,6 +412,32 @@ export class FolkSplatViewer extends HTMLElement {
}); });
} }
// ── Image staging (replaces canvas-based resizeImage) ──
private async stageImage(file: File): Promise<string> {
// Client-side HEIC detection
const ext = file.name.split(".").pop()?.toLowerCase() || "";
if (ext === "heic" || ext === "heif" || file.type === "image/heic" || file.type === "image/heif") {
throw new Error("HEIC files are not supported. Please convert to JPEG or PNG first.");
}
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/image-stage", {
method: "POST",
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Image upload failed" }));
throw new Error((err as any).error || "Image upload failed");
}
const data = await res.json() as { url: string };
return data.url;
}
private setupGenerateHandlers() { private setupGenerateHandlers() {
const browse = this.querySelector("#generate-browse") as HTMLElement; const browse = this.querySelector("#generate-browse") as HTMLElement;
const fileInput = this.querySelector("#generate-file") as HTMLInputElement; const fileInput = this.querySelector("#generate-file") as HTMLInputElement;
@ -401,6 +457,14 @@ export class FolkSplatViewer extends HTMLElement {
fileInput.addEventListener("change", () => { fileInput.addEventListener("change", () => {
if (fileInput.files?.[0]) { if (fileInput.files?.[0]) {
selectedFile = fileInput.files[0]; selectedFile = fileInput.files[0];
// Client-side HEIC check before preview
const ext = selectedFile.name.split(".").pop()?.toLowerCase() || "";
if (ext === "heic" || ext === "heif") {
status.textContent = "HEIC files are not supported. Please use JPEG or PNG.";
return;
}
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
preview.innerHTML = `<img src="${reader.result}" alt="Preview">`; preview.innerHTML = `<img src="${reader.result}" alt="Preview">`;
@ -419,37 +483,48 @@ export class FolkSplatViewer extends HTMLElement {
actions.style.display = "none"; actions.style.display = "none";
progress.style.display = "block"; progress.style.display = "block";
// Elapsed time ticker // Elapsed time ticker — resilient to iOS background-tab suspension
const startTime = Date.now(); const startTime = Date.now();
let hiddenTime = 0;
let hiddenAt = 0;
const phases = [ const phases = [
{ t: 0, msg: "Preparing image..." }, { t: 0, msg: "Staging image..." },
{ t: 3, msg: "Uploading to Trellis 2..." }, { t: 3, msg: "Uploading to Trellis 2..." },
{ t: 8, msg: "Reconstructing 3D geometry..." }, { t: 8, msg: "Reconstructing 3D geometry..." },
{ t: 20, msg: "Generating mesh and textures..." }, { t: 20, msg: "Generating mesh and textures..." },
{ t: 45, msg: "Finalizing model..." }, { t: 45, msg: "Finalizing model..." },
{ t: 75, msg: "Almost there..." }, { t: 75, msg: "Almost there..." },
]; ];
const onVisChange = () => {
if (document.hidden) {
hiddenAt = Date.now();
} else if (hiddenAt) {
hiddenTime += Date.now() - hiddenAt;
hiddenAt = 0;
}
};
document.addEventListener("visibilitychange", onVisChange);
const ticker = setInterval(() => { const ticker = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000); if (document.hidden) return; // Don't update while hidden
const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000);
const phase = [...phases].reverse().find(p => elapsed >= p.t); const phase = [...phases].reverse().find(p => elapsed >= p.t);
if (progressText && phase) { if (progressText && phase) {
progressText.textContent = `${phase.msg} (${elapsed}s)`; progressText.textContent = `${phase.msg} (${elapsed}s)`;
} }
}, 1000); }, 1000);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000);
try { try {
const dataUrl = await this.resizeImage(selectedFile!, 1024); const imageUrl = await this.stageImage(selectedFile!);
const res = await fetch("/api/3d-gen", { const res = await fetch("/api/3d-gen", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_url: dataUrl }), body: JSON.stringify({ image_url: imageUrl }),
signal: controller.signal,
}); });
clearInterval(ticker); clearInterval(ticker);
clearTimeout(timeout); document.removeEventListener("visibilitychange", onVisChange);
if (res.status === 524 || res.status === 504) { if (res.status === 524 || res.status === 504) {
status.textContent = "Generation timed out — try a simpler image."; status.textContent = "Generation timed out — try a simpler image.";
@ -478,13 +553,16 @@ export class FolkSplatViewer extends HTMLElement {
const data = await res.json() as { url: string; format: string }; const data = await res.json() as { url: string; format: string };
progress.style.display = "none"; progress.style.display = "none";
const elapsed = Math.floor((Date.now() - startTime) / 1000); const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000);
status.textContent = `Generated in ${elapsed}s`; status.textContent = `Generated in ${elapsed}s`;
// Store generated info for save-to-gallery // Store generated info for save-to-gallery
this._generatedUrl = data.url; this._generatedUrl = data.url;
this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, ""); this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, "");
// Auto-save if authenticated
await this.autoSave();
// Open inline viewer with generated model // Open inline viewer with generated model
this._mode = "viewer"; this._mode = "viewer";
this._splatUrl = data.url; this._splatUrl = data.url;
@ -494,11 +572,11 @@ export class FolkSplatViewer extends HTMLElement {
this.renderViewer(); this.renderViewer();
} catch (e: any) { } catch (e: any) {
clearInterval(ticker); clearInterval(ticker);
clearTimeout(timeout); document.removeEventListener("visibilitychange", onVisChange);
if (e.name === "AbortError") { if (e.name === "AbortError") {
status.textContent = "Request timed out — try a simpler image."; status.textContent = "Request timed out — try a simpler image.";
} else { } else {
status.textContent = "Network error — could not reach server"; status.textContent = e.message || "Network error — could not reach server";
} }
progress.style.display = "none"; progress.style.display = "none";
actions.style.display = "flex"; actions.style.display = "flex";
@ -507,28 +585,31 @@ export class FolkSplatViewer extends HTMLElement {
}); });
} }
// ── Image helpers ── // ── Auto-save after generation ──
private resizeImage(file: File, maxSize: number): Promise<string> { private async autoSave() {
return new Promise((resolve, reject) => { const token = localStorage.getItem("encryptid_token");
const img = new Image(); if (!token || !this._generatedUrl || this._spaceSlug === "demo") return;
img.onload = () => {
let { width, height } = img; try {
if (width > maxSize || height > maxSize) { const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, {
const scale = maxSize / Math.max(width, height); method: "POST",
width = Math.round(width * scale); headers: {
height = Math.round(height * scale); "Content-Type": "application/json",
} Authorization: `Bearer ${token}`,
const canvas = document.createElement("canvas"); },
canvas.width = width; body: JSON.stringify({
canvas.height = height; url: this._generatedUrl,
const ctx = canvas.getContext("2d")!; title: this._generatedTitle || "AI Generated Model",
ctx.drawImage(img, 0, 0, width, height); description: "AI-generated 3D model via Trellis 2",
resolve(canvas.toDataURL("image/jpeg", 0.9)); }),
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = URL.createObjectURL(file);
}); });
if (res.ok) {
const data = await res.json() as { slug: string };
this._savedSlug = data.slug;
}
} catch { /* auto-save is best-effort */ }
} }
// ── Viewer ── // ── Viewer ──
@ -538,9 +619,27 @@ export class FolkSplatViewer extends HTMLElement {
? `<button class="splat-viewer__back" id="splat-back-btn">← Back to Gallery</button>` ? `<button class="splat-viewer__back" id="splat-back-btn">← Back to Gallery</button>`
: `<a class="splat-viewer__back" href="/${this._spaceSlug}/rsplat">← Gallery</a>`; : `<a class="splat-viewer__back" href="/${this._spaceSlug}/rsplat">← Gallery</a>`;
const showSave = this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo"; // Show "View in Gallery" if auto-saved, otherwise "Save" if generated
const saveEl = showSave let actionEl = "";
? `<button class="splat-viewer__save" id="splat-save-btn">Save to Gallery</button>` if (this._savedSlug) {
actionEl = `<a class="splat-viewer__save" href="/${this._spaceSlug}/rsplat/${this._savedSlug}">View in Gallery</a>`;
} else if (this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") {
actionEl = `<button class="splat-viewer__save" id="splat-save-btn">Save to Gallery</button>`;
}
// Download button
const downloadLabel = this._splatUrl.endsWith(".glb") ? "Download GLB"
: this._splatUrl.endsWith(".ply") ? "Download PLY"
: this._splatUrl.endsWith(".splat") ? "Download SPLAT"
: this._splatUrl.endsWith(".spz") ? "Download SPZ"
: "Download";
const downloadEl = `<button class="splat-viewer__download" id="splat-download-btn">${downloadLabel}</button>`;
// Format info
const formatExt = this._splatUrl.split(".").pop()?.toUpperCase() || "";
const formatInfo = formatExt === "GLB" ? "GLB format — opens in Blender, Windows 3D Viewer, Unity"
: formatExt === "PLY" ? "PLY format — opens in MeshLab, CloudCompare, Blender"
: formatExt === "SPLAT" ? "SPLAT format — Gaussian splat point cloud"
: ""; : "";
this.innerHTML = ` this.innerHTML = `
@ -551,12 +650,14 @@ export class FolkSplatViewer extends HTMLElement {
</div> </div>
<div class="splat-viewer__controls"> <div class="splat-viewer__controls">
${backEl} ${backEl}
${saveEl} ${actionEl}
${downloadEl}
</div> </div>
${this._splatTitle ? ` ${this._splatTitle ? `
<div class="splat-viewer__info"> <div class="splat-viewer__info">
<p class="splat-viewer__title">${esc(this._splatTitle)}</p> <p class="splat-viewer__title">${esc(this._splatTitle)}</p>
${this._splatDesc ? `<p class="splat-viewer__desc">${esc(this._splatDesc)}</p>` : ""} ${this._splatDesc ? `<p class="splat-viewer__desc">${esc(this._splatDesc)}</p>` : ""}
${formatInfo ? `<p class="splat-viewer__format-info">${formatInfo}</p>` : ""}
</div> </div>
` : ""} ` : ""}
<div id="splat-container" class="splat-viewer__canvas"></div> <div id="splat-container" class="splat-viewer__canvas"></div>
@ -573,18 +674,60 @@ export class FolkSplatViewer extends HTMLElement {
this._splatDesc = ""; this._splatDesc = "";
this._generatedUrl = ""; this._generatedUrl = "";
this._generatedTitle = ""; this._generatedTitle = "";
this._savedSlug = "";
if (this._spaceSlug === "demo") this.loadDemoData(); if (this._spaceSlug === "demo") this.loadDemoData();
this.renderGallery(); this.renderGallery();
}); });
} }
if (showSave) { if (!this._savedSlug && this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") {
this.querySelector("#splat-save-btn")?.addEventListener("click", () => this.saveToGallery()); this.querySelector("#splat-save-btn")?.addEventListener("click", () => this.saveToGallery());
} }
// Download handler
this.querySelector("#splat-download-btn")?.addEventListener("click", () => this.downloadModel());
this.initThreeViewer(); this.initThreeViewer();
} }
private async downloadModel() {
const btn = this.querySelector("#splat-download-btn") as HTMLButtonElement;
if (!btn || !this._splatUrl) return;
btn.disabled = true;
btn.textContent = "Downloading...";
try {
const res = await fetch(this._splatUrl);
if (!res.ok) throw new Error("Download failed");
const blob = await res.blob();
const filename = this._splatUrl.split("/").pop() || "model.glb";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
btn.textContent = "Downloaded!";
setTimeout(() => {
const ext = this._splatUrl.split(".").pop()?.toUpperCase() || "";
btn.textContent = `Download ${ext}`;
btn.disabled = false;
}, 2000);
} catch {
btn.textContent = "Download failed";
setTimeout(() => {
const ext = this._splatUrl.split(".").pop()?.toUpperCase() || "";
btn.textContent = `Download ${ext}`;
btn.disabled = false;
}, 2000);
}
}
private cleanupViewer() { private cleanupViewer() {
if (this._viewer) { if (this._viewer) {
try { this._viewer.dispose(); } catch {} try { this._viewer.dispose(); } catch {}
@ -641,12 +784,13 @@ export class FolkSplatViewer extends HTMLElement {
} }
const data = await res.json() as { slug: string }; const data = await res.json() as { slug: string };
this._savedSlug = data.slug;
saveBtn.textContent = "Saved!"; saveBtn.textContent = "Saved!";
this._generatedUrl = ""; this._generatedUrl = "";
// Navigate to the saved splat after a moment // Replace save button with view link
setTimeout(() => { setTimeout(() => {
window.location.href = `/${this._spaceSlug}/rsplat/view/${data.slug}`; window.location.href = `/${this._spaceSlug}/rsplat/${data.slug}`;
}, 800); }, 800);
} catch { } catch {
saveBtn.textContent = "Network error"; saveBtn.textContent = "Network error";

View File

@ -578,6 +578,63 @@ button.splat-viewer__save {
} }
} }
/* ── Download button ── */
.splat-viewer__download {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: var(--rs-glass-bg, rgba(30, 41, 59, 0.85));
backdrop-filter: blur(8px);
color: var(--splat-text);
font-size: 0.8rem;
font-weight: 600;
border: 1px solid var(--splat-border);
cursor: pointer;
font-family: inherit;
transition: background 0.2s;
}
.splat-viewer__download:hover {
background: var(--rs-bg-surface, rgba(51, 65, 85, 0.9));
}
.splat-viewer__download:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Format info ── */
.splat-viewer__format-info {
color: var(--splat-text-muted);
font-size: 0.7rem;
margin: 0.25rem 0 0;
opacity: 0.8;
}
/* ── My Models section ── */
.splat-my-models {
margin-bottom: 2rem;
}
.splat-my-models__title {
font-size: 1.125rem;
font-weight: 600;
color: var(--splat-text);
margin: 0 0 1rem;
}
.splat-section-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--splat-text);
margin: 0 0 1rem;
}
/* ── Responsive ── */ /* ── Responsive ── */
@media (max-width: 640px) { @media (max-width: 640px) {

View File

@ -629,6 +629,30 @@ routes.post("/api/splats/save-generated", async (c) => {
}, 201); }, 201);
}); });
// ── API: My history (authenticated user's splats) ──
routes.get("/api/splats/my-history", 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 doc = ensureDoc(dataSpace);
const items = Object.values(doc.items)
.filter((item) => item.status === 'published' && item.contributorId === claims.sub)
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 50);
return c.json({ splats: items.map(itemToListRow) });
});
// ── API: Delete splat (owner only) ── // ── API: Delete splat (owner only) ──
routes.delete("/api/splats/:id", async (c) => { routes.delete("/api/splats/:id", async (c) => {
const token = extractToken(c.req.raw.headers); const token = extractToken(c.req.raw.headers);
@ -688,12 +712,12 @@ routes.get("/", async (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
head: ` head: `
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=3"> <link rel="stylesheet" href="/modules/rsplat/splat.css?v=4">
${IMPORTMAP} ${IMPORTMAP}
`, `,
scripts: ` scripts: `
<script type="module"> <script type="module">
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=4'; import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=5';
const gallery = document.getElementById('gallery'); const gallery = document.getElementById('gallery');
gallery.splats = ${splatsJSON}; gallery.splats = ${splatsJSON};
gallery.spaceSlug = '${spaceSlug}'; gallery.spaceSlug = '${spaceSlug}';
@ -704,14 +728,10 @@ routes.get("/", async (c) => {
return c.html(html); return c.html(html);
}); });
// ── Page: Viewer ── // ── Shared viewer page renderer ──
routes.get("/view/:id", async (c) => { function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string) {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || spaceSlug;
const id = c.req.param("id");
const doc = ensureDoc(dataSpace); const doc = ensureDoc(dataSpace);
const found = findItem(doc, id); const found = findItem(doc, idOrSlug);
if (!found || found[1].status !== 'published') { if (!found || found[1].status !== 'published') {
const html = renderShell({ const html = renderShell({
@ -722,7 +742,7 @@ routes.get("/view/:id", async (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
}); });
return c.html(html, 404); return { html, status: 404 as const };
} }
const [itemKey, splat] = found; const [itemKey, splat] = found;
@ -752,17 +772,24 @@ routes.get("/view/:id", async (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
head: ` head: `
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=3"> <link rel="stylesheet" href="/modules/rsplat/splat.css?v=4">
${IMPORTMAP} ${IMPORTMAP}
`, `,
scripts: ` scripts: `
<script type="module"> <script type="module">
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=4'; import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=5';
</script> </script>
`, `,
}); });
return c.html(html); return { html, status: 200 as const };
}
// ── Page: Viewer (legacy /view/:id → 301 redirect to /:slug) ──
routes.get("/view/:id", async (c) => {
const spaceSlug = c.req.param("space") || "demo";
const id = c.req.param("id");
return c.redirect(`/${spaceSlug}/rsplat/${id}`, 301);
}); });
// ── Seed template data ── // ── Seed template data ──
@ -807,6 +834,18 @@ function seedTemplateSplat(space: string) {
console.log(`[Splat] Template seeded for "${space}": 2 splat entries`); console.log(`[Splat] Template seeded for "${space}": 2 splat entries`);
} }
// ── Page: Viewer (clean /:slug URL — registered last to avoid shadowing api/view) ──
const RESERVED_SLUGS = new Set(["api", "view", "template"]);
routes.get("/:slug", async (c) => {
const slug = c.req.param("slug");
if (RESERVED_SLUGS.has(slug)) return c.notFound();
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || spaceSlug;
const result = renderViewerPage(spaceSlug, dataSpace, slug);
return c.html(result.html, result.status);
});
// ── Module export ── // ── Module export ──
export const splatModule: RSpaceModule = { export const splatModule: RSpaceModule = {

View File

@ -985,6 +985,40 @@ app.post("/api/video-gen/i2v", async (c) => {
return c.json({ url: videoUrl, video_url: videoUrl }); return c.json({ url: videoUrl, video_url: videoUrl });
}); });
// Stage image for 3D generation (binary upload → HTTPS URL for fal.ai)
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || "https://rspace.online";
app.post("/api/image-stage", async (c) => {
const formData = await c.req.formData();
const file = formData.get("file") as File | null;
if (!file) return c.json({ error: "file required" }, 400);
// HEIC rejection
const ext = file.name.split(".").pop()?.toLowerCase() || "";
if (ext === "heic" || ext === "heif" || file.type === "image/heic" || file.type === "image/heif") {
return c.json({ error: "HEIC files are not supported. Please convert to JPEG or PNG first." }, 400);
}
// Validate type
const validTypes = ["image/jpeg", "image/png", "image/webp"];
const validExts = ["jpg", "jpeg", "png", "webp"];
if (!validTypes.includes(file.type) && !validExts.includes(ext)) {
return c.json({ error: "Only JPEG, PNG, and WebP images are supported" }, 400);
}
// 15MB limit
if (file.size > 15 * 1024 * 1024) {
return c.json({ error: "Image too large. Maximum 15MB." }, 400);
}
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
const outExt = ext === "png" ? "png" : ext === "webp" ? "webp" : "jpg";
const filename = `stage-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${outExt}`;
const buf = Buffer.from(await file.arrayBuffer());
await Bun.write(resolve(dir, filename), buf);
return c.json({ url: `${PUBLIC_ORIGIN}/data/files/generated/${filename}` });
});
// Image-to-3D via fal.ai Trellis // Image-to-3D via fal.ai Trellis
app.post("/api/3d-gen", async (c) => { app.post("/api/3d-gen", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);

View File

@ -1,13 +1,15 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
const CACHE_VERSION = "rspace-v2"; const CACHE_VERSION = "rspace-v3";
const STATIC_CACHE = `${CACHE_VERSION}-static`; const STATIC_CACHE = `${CACHE_VERSION}-static`;
const HTML_CACHE = `${CACHE_VERSION}-html`; const HTML_CACHE = `${CACHE_VERSION}-html`;
const API_CACHE = `${CACHE_VERSION}-api`; const API_CACHE = `${CACHE_VERSION}-api`;
const ECOSYSTEM_CACHE = `${CACHE_VERSION}-ecosystem`; const ECOSYSTEM_CACHE = `${CACHE_VERSION}-ecosystem`;
const TILE_CACHE = `${CACHE_VERSION}-tiles`; const TILE_CACHE = `${CACHE_VERSION}-tiles`;
const TILE_CACHE_MAX = 500; const TILE_CACHE_MAX = 500;
const MODEL_CACHE = `${CACHE_VERSION}-models`;
const MODEL_CACHE_MAX = 20;
// Vite-hashed assets are immutable (content hash in filename) // Vite-hashed assets are immutable (content hash in filename)
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/; const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
@ -234,6 +236,34 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// 3D model files: cache-first with LRU eviction
const MODEL_PATTERN = /\.(glb|ply|splat|spz)(\?|$)/;
const isModelPath = url.pathname.includes("/data/files/generated/") || url.pathname.includes("/rsplat/api/splats/");
if (MODEL_PATTERN.test(url.pathname) && isModelPath) {
event.respondWith(
caches.open(MODEL_CACHE).then(async (cache) => {
const cached = await cache.match(event.request);
if (cached) return cached;
const response = await fetch(event.request);
if (response.ok) {
const clone = response.clone();
cache.put(event.request, clone).then(async () => {
const keys = await cache.keys();
if (keys.length > MODEL_CACHE_MAX) {
const toDelete = keys.length - MODEL_CACHE_MAX;
for (let i = 0; i < toDelete; i++) {
await cache.delete(keys[i]);
}
}
}).catch(() => {});
}
return response;
}).catch(() => new Response("Model unavailable", { status: 503 }))
);
return;
}
// Other assets (images, fonts, etc.): stale-while-revalidate // Other assets (images, fonts, etc.): stale-while-revalidate
event.respondWith( event.respondWith(
caches.match(event.request).then((cached) => { caches.match(event.request).then((cached) => {