diff --git a/modules/rsplat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts
index fb1e323..a5c7a3f 100644
--- a/modules/rsplat/components/folk-splat-viewer.ts
+++ b/modules/rsplat/components/folk-splat-viewer.ts
@@ -38,6 +38,8 @@ export class FolkSplatViewer extends HTMLElement {
private _offlineUnsub: (() => void) | null = null;
private _generatedUrl = "";
private _generatedTitle = "";
+ private _savedSlug = "";
+ private _myHistory: SplatItem[] = [];
static get observedAttributes() {
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
@@ -67,6 +69,7 @@ export class FolkSplatViewer extends HTMLElement {
} else {
this.subscribeOffline();
}
+ this.loadMyHistory();
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() {
this._offlineUnsub?.();
this._offlineUnsub = null;
@@ -133,50 +152,59 @@ export class FolkSplatViewer extends HTMLElement {
// ── Gallery ──
+ private renderCard(s: SplatItem): string {
+ const status = s.processing_status || "ready";
+ const isReady = status === "ready";
+ const isDemo = !!s.demoUrl;
+ const tag = isReady ? (isDemo ? "div" : "a") : "div";
+ 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 statusClass = !isReady ? ` splat-card--${status}` : "";
+ const demoClass = isDemo ? " splat-card--demo" : "";
+
+ let overlay = "";
+ if (status === "pending") {
+ overlay = `
+
${esc(s.title)}
+
+ ${s.file_format}
+ ${isReady ? `${formatSize(s.file_size_bytes)}` : ""}
+ ${sourceInfo}
+
+
+ ${tag}>
+ `;
+ }
+
private renderGallery() {
- const cards = this._splats.map((s) => {
- const status = s.processing_status || "ready";
- const isReady = status === "ready";
- 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 href = isReady && !isDemo ? ` href="/${this._spaceSlug}/rsplat/view/${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 statusClass = !isReady ? ` splat-card--${status}` : "";
- const demoClass = isDemo ? " splat-card--demo" : "";
+ const cards = this._splats.map((s) => this.renderCard(s)).join("");
- let overlay = "";
- if (status === "pending") {
- overlay = `
-
${esc(s.title)}
-
- ${s.file_format}
- ${isReady ? `${formatSize(s.file_size_bytes)}` : ""}
- ${sourceInfo}
-
-
- ${tag}>
- `;
- }).join("");
-
- const empty = this._splats.length === 0 ? `
+ const empty = this._splats.length === 0 && this._myHistory.length === 0 ? `
@@ -354,7 +384,7 @@ export class FolkSplatViewer extends HTMLElement {
const splat = await res.json() as SplatItem;
status.textContent = "Uploaded!";
setTimeout(() => {
- window.location.href = `/${this._spaceSlug}/rsplat/view/${splat.slug}`;
+ window.location.href = `/${this._spaceSlug}/rsplat/${splat.slug}`;
}, 500);
} catch (e) {
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
{
+ // 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() {
const browse = this.querySelector("#generate-browse") as HTMLElement;
const fileInput = this.querySelector("#generate-file") as HTMLInputElement;
@@ -401,6 +457,14 @@ export class FolkSplatViewer extends HTMLElement {
fileInput.addEventListener("change", () => {
if (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();
reader.onload = () => {
preview.innerHTML = `
`;
@@ -419,37 +483,48 @@ export class FolkSplatViewer extends HTMLElement {
actions.style.display = "none";
progress.style.display = "block";
- // Elapsed time ticker
+ // Elapsed time ticker — resilient to iOS background-tab suspension
const startTime = Date.now();
+ let hiddenTime = 0;
+ let hiddenAt = 0;
const phases = [
- { t: 0, msg: "Preparing image..." },
+ { t: 0, msg: "Staging 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 onVisChange = () => {
+ if (document.hidden) {
+ hiddenAt = Date.now();
+ } else if (hiddenAt) {
+ hiddenTime += Date.now() - hiddenAt;
+ hiddenAt = 0;
+ }
+ };
+ document.addEventListener("visibilitychange", onVisChange);
+
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);
if (progressText && phase) {
progressText.textContent = `${phase.msg} (${elapsed}s)`;
}
}, 1000);
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 120_000);
try {
- const dataUrl = await this.resizeImage(selectedFile!, 1024);
+ const imageUrl = await this.stageImage(selectedFile!);
const res = await fetch("/api/3d-gen", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ image_url: dataUrl }),
- signal: controller.signal,
+ body: JSON.stringify({ image_url: imageUrl }),
});
clearInterval(ticker);
- clearTimeout(timeout);
+ document.removeEventListener("visibilitychange", onVisChange);
if (res.status === 524 || res.status === 504) {
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 };
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`;
// Store generated info for save-to-gallery
this._generatedUrl = data.url;
this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, "");
+ // Auto-save if authenticated
+ await this.autoSave();
+
// Open inline viewer with generated model
this._mode = "viewer";
this._splatUrl = data.url;
@@ -494,11 +572,11 @@ export class FolkSplatViewer extends HTMLElement {
this.renderViewer();
} catch (e: any) {
clearInterval(ticker);
- clearTimeout(timeout);
+ document.removeEventListener("visibilitychange", onVisChange);
if (e.name === "AbortError") {
status.textContent = "Request timed out — try a simpler image.";
} else {
- status.textContent = "Network error — could not reach server";
+ status.textContent = e.message || "Network error — could not reach server";
}
progress.style.display = "none";
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 {
- return new Promise((resolve, reject) => {
- const img = new Image();
- img.onload = () => {
- let { width, height } = img;
- if (width > maxSize || height > maxSize) {
- const scale = maxSize / Math.max(width, height);
- width = Math.round(width * scale);
- height = Math.round(height * scale);
- }
- const canvas = document.createElement("canvas");
- canvas.width = width;
- canvas.height = height;
- const ctx = canvas.getContext("2d")!;
- ctx.drawImage(img, 0, 0, width, height);
- resolve(canvas.toDataURL("image/jpeg", 0.9));
- };
- img.onerror = () => reject(new Error("Failed to load image"));
- img.src = URL.createObjectURL(file);
- });
+ private async autoSave() {
+ const token = localStorage.getItem("encryptid_token");
+ if (!token || !this._generatedUrl || this._spaceSlug === "demo") return;
+
+ try {
+ 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.ok) {
+ const data = await res.json() as { slug: string };
+ this._savedSlug = data.slug;
+ }
+ } catch { /* auto-save is best-effort */ }
}
// ── Viewer ──
@@ -538,9 +619,27 @@ export class FolkSplatViewer extends HTMLElement {
? ``
: `← Gallery`;
- const showSave = this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo";
- const saveEl = showSave
- ? ``
+ // Show "View in Gallery" if auto-saved, otherwise "Save" if generated
+ let actionEl = "";
+ if (this._savedSlug) {
+ actionEl = `View in Gallery`;
+ } else if (this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") {
+ actionEl = ``;
+ }
+
+ // 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 = ``;
+
+ // 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 = `
@@ -551,12 +650,14 @@ export class FolkSplatViewer extends HTMLElement {
${backEl}
- ${saveEl}
+ ${actionEl}
+ ${downloadEl}
${this._splatTitle ? `
${esc(this._splatTitle)}
${this._splatDesc ? `
${esc(this._splatDesc)}
` : ""}
+ ${formatInfo ? `
${formatInfo}
` : ""}
` : ""}
@@ -573,18 +674,60 @@ export class FolkSplatViewer extends HTMLElement {
this._splatDesc = "";
this._generatedUrl = "";
this._generatedTitle = "";
+ this._savedSlug = "";
if (this._spaceSlug === "demo") this.loadDemoData();
this.renderGallery();
});
}
- if (showSave) {
+ if (!this._savedSlug && this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") {
this.querySelector("#splat-save-btn")?.addEventListener("click", () => this.saveToGallery());
}
+ // Download handler
+ this.querySelector("#splat-download-btn")?.addEventListener("click", () => this.downloadModel());
+
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() {
if (this._viewer) {
try { this._viewer.dispose(); } catch {}
@@ -641,12 +784,13 @@ export class FolkSplatViewer extends HTMLElement {
}
const data = await res.json() as { slug: string };
+ this._savedSlug = data.slug;
saveBtn.textContent = "Saved!";
this._generatedUrl = "";
- // Navigate to the saved splat after a moment
+ // Replace save button with view link
setTimeout(() => {
- window.location.href = `/${this._spaceSlug}/rsplat/view/${data.slug}`;
+ window.location.href = `/${this._spaceSlug}/rsplat/${data.slug}`;
}, 800);
} catch {
saveBtn.textContent = "Network error";
diff --git a/modules/rsplat/components/splat.css b/modules/rsplat/components/splat.css
index 1667d1c..1e54c19 100644
--- a/modules/rsplat/components/splat.css
+++ b/modules/rsplat/components/splat.css
@@ -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 ── */
@media (max-width: 640px) {
diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts
index 2933a92..9faf990 100644
--- a/modules/rsplat/mod.ts
+++ b/modules/rsplat/mod.ts
@@ -629,6 +629,30 @@ routes.post("/api/splats/save-generated", async (c) => {
}, 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) ──
routes.delete("/api/splats/:id", async (c) => {
const token = extractToken(c.req.raw.headers);
@@ -688,12 +712,12 @@ routes.get("/", async (c) => {
modules: getModuleInfoList(),
theme: "dark",
head: `
-
+
${IMPORTMAP}
`,
scripts: `
`,
});
- 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 ──
@@ -807,6 +834,18 @@ function seedTemplateSplat(space: string) {
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 ──
export const splatModule: RSpaceModule = {
diff --git a/server/index.ts b/server/index.ts
index f3b9aea..0d41627 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -985,6 +985,40 @@ app.post("/api/video-gen/i2v", async (c) => {
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
app.post("/api/3d-gen", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
diff --git a/website/sw.ts b/website/sw.ts
index ce44907..d47d798 100644
--- a/website/sw.ts
+++ b/website/sw.ts
@@ -1,13 +1,15 @@
///
declare const self: ServiceWorkerGlobalScope;
-const CACHE_VERSION = "rspace-v2";
+const CACHE_VERSION = "rspace-v3";
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const HTML_CACHE = `${CACHE_VERSION}-html`;
const API_CACHE = `${CACHE_VERSION}-api`;
const ECOSYSTEM_CACHE = `${CACHE_VERSION}-ecosystem`;
const TILE_CACHE = `${CACHE_VERSION}-tiles`;
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)
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
@@ -234,6 +236,34 @@ self.addEventListener("fetch", (event) => {
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
event.respondWith(
caches.match(event.request).then((cached) => {