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 = `
Queued
`; + } else if (status === "processing") { + overlay = `
Generating...
`; + } else if (status === "failed") { + overlay = `
Failed
`; + } + + const sourceInfo = !isReady && s.source_file_count + ? `${s.source_file_count} source file${s.source_file_count > 1 ? "s" : ""}` + : `${s.view_count} views`; + + return ` + <${tag} class="splat-card${statusClass}${demoClass}" data-collab-id="splat:${s.id}"${href}${demoAttr}> +
+ ${overlay} + ${isReady ? "🔮" : "📸"} +
+
+
${esc(s.title)}
+
+ ${s.file_format} + ${isReady ? `${formatSize(s.file_size_bytes)}` : ""} + ${sourceInfo} +
+
+ + `; + } + 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 = `
Queued
`; - } else if (status === "processing") { - overlay = `
Generating...
`; - } else if (status === "failed") { - overlay = `
Failed
`; - } + // My Models section + const myModelsHtml = this._myHistory.length > 0 ? ` +
+

My Models

+
${this._myHistory.map((s) => this.renderCard(s)).join("")}
+
+ ` : ""; - const sourceInfo = !isReady && s.source_file_count - ? `${s.source_file_count} source file${s.source_file_count > 1 ? "s" : ""}` - : `${s.view_count} views`; - - return ` - <${tag} class="splat-card${statusClass}${demoClass}" data-collab-id="splat:${s.id}"${href}${demoAttr}> -
- ${overlay} - ${isReady ? "🔮" : "📸"} -
-
-
${esc(s.title)}
-
- ${s.file_format} - ${isReady ? `${formatSize(s.file_size_bytes)}` : ""} - ${sourceInfo} -
-
- - `; - }).join(""); - - const empty = this._splats.length === 0 ? ` + const empty = this._splats.length === 0 && this._myHistory.length === 0 ? `
🔮

No splats yet

@@ -190,7 +218,9 @@ export class FolkSplatViewer extends HTMLElement {

rSplat

+ ${myModelsHtml} ${empty} + ${this._splats.length > 0 ? `

Gallery

` : ""}
${cards}
@@ -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 = `Preview`; @@ -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) => {