/** * — Gaussian splat gallery + 3D viewer web component. * * Gallery mode: card grid of splats with upload form (splat files or photos/video). * Viewer mode: full-viewport Three.js + GaussianSplats3D renderer. * * Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled). */ import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; interface SplatItem { id: string; slug: string; title: string; description?: string; file_format: string; file_size_bytes: number; view_count: number; contributor_name?: string; processing_status?: string; source_file_count?: number; created_at: string; demoUrl?: string; } export class FolkSplatViewer extends HTMLElement { private _mode: "gallery" | "viewer" = "gallery"; private _splats: SplatItem[] = []; private _spaceSlug = "demo"; private _splatUrl = ""; private _splatTitle = ""; private _splatDesc = ""; private _viewer: any = null; private _uploadMode: "splat" | "media" | "generate" = "splat"; private _inlineViewer = false; private _offlineUnsub: (() => void) | null = null; static get observedAttributes() { return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"]; } set splats(val: SplatItem[]) { this._splats = val; if (this._mode === "gallery") this.renderGallery(); } set spaceSlug(val: string) { this._spaceSlug = val; } connectedCallback() { this._mode = (this.getAttribute("mode") as "gallery" | "viewer") || "gallery"; this._splatUrl = this.getAttribute("splat-url") || ""; this._splatTitle = this.getAttribute("splat-title") || ""; this._splatDesc = this.getAttribute("splat-desc") || ""; this._spaceSlug = this.getAttribute("space-slug") || "demo"; if (this._mode === "viewer") { this.renderViewer(); } else { if (this._spaceSlug === "demo") { this.loadDemoData(); } else { this.subscribeOffline(); } this.renderGallery(); } } private async subscribeOffline() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; try { const docId = splatScenesDocId(this._spaceSlug) as DocumentId; const doc = await runtime.subscribe(docId, splatScenesSchema); this.renderFromDoc(doc); this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this.renderFromDoc(updated); }); } catch { // Runtime unavailable — server-side hydration handles data } } private renderFromDoc(doc: SplatScenesDoc) { if (!doc?.items || Object.keys(doc.items).length === 0) return; if (this._splats.length > 0) return; // Don't clobber server-hydrated data this._splats = Object.values(doc.items).map(s => ({ id: s.id, slug: s.slug, title: s.title, description: s.description, file_format: s.fileFormat, file_size_bytes: s.fileSizeBytes, view_count: s.viewCount, contributor_name: s.contributorName ?? undefined, processing_status: s.processingStatus ?? undefined, source_file_count: s.sourceFileCount, created_at: new Date(s.createdAt).toISOString(), })); if (this._mode === "gallery") this.renderGallery(); } private loadDemoData() { this._splats = [ { id: "s1", slug: "train", title: "Train", description: "Classic Gaussian splatting demo scene — a model train on a track.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1842, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/train.splat" }, { id: "s2", slug: "truck", title: "Truck", description: "Photogrammetry capture of a pickup truck — 360° Gaussian splat reconstruction.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1536, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/truck.splat" }, { id: "s3", slug: "garden", title: "Garden", description: "Outdoor garden scene — dense foliage and complex lighting captured as Gaussian splats.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1203, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/garden.splat" }, ]; } disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; if (this._viewer) { try { this._viewer.dispose(); } catch {} this._viewer = null; } } attributeChangedCallback(name: string, _old: string, val: string) { if (name === "mode") this._mode = val as "gallery" | "viewer"; if (name === "splat-url") this._splatUrl = val; if (name === "splat-title") this._splatTitle = val; if (name === "splat-desc") this._splatDesc = val; if (name === "space-slug") this._spaceSlug = val; } // ── Gallery ── 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" : ""; 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}
`; }).join(""); const empty = this._splats.length === 0 ? `
🔮

No splats yet

Upload a .ply, .splat, or .spz file — or photos/video to generate one

` : ""; this.innerHTML = ` `; this.setupUploadHandlers(); this.setupMediaHandlers(); this.setupGenerateHandlers(); this.setupToggle(); this.setupDemoCardHandlers(); } private setupToggle() { const buttons = this.querySelectorAll(".splat-upload__toggle-btn"); const splatMode = this.querySelector("#splat-mode-splat") as HTMLElement; const mediaMode = this.querySelector("#splat-mode-media") as HTMLElement; const generateMode = this.querySelector("#splat-mode-generate") as HTMLElement; buttons.forEach((btn) => { btn.addEventListener("click", () => { const mode = btn.dataset.mode as "splat" | "media" | "generate"; 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"; }); }); } private setupUploadHandlers() { const drop = this.querySelector("#splat-drop") as HTMLElement; const fileInput = this.querySelector("#splat-file") as HTMLInputElement; const browse = this.querySelector("#splat-browse") as HTMLElement; const form = this.querySelector("#splat-form") as HTMLElement; const titleInput = this.querySelector("#splat-title-input") as HTMLInputElement; const descInput = this.querySelector("#splat-desc-input") as HTMLTextAreaElement; const tagsInput = this.querySelector("#splat-tags-input") as HTMLInputElement; const submitBtn = this.querySelector("#splat-submit") as HTMLButtonElement; const status = this.querySelector("#splat-status") as HTMLElement; if (!drop || !fileInput) return; let selectedFile: File | null = null; browse?.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", () => { if (fileInput.files?.[0]) { selectedFile = fileInput.files[0]; form.classList.add("active"); const name = selectedFile.name.replace(/\.(ply|splat|spz)$/i, ""); titleInput.value = name.replace(/[-_]/g, " "); titleInput.dispatchEvent(new Event("input")); } }); drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("splat-upload--dragover"); }); drop.addEventListener("dragleave", () => { drop.classList.remove("splat-upload--dragover"); }); drop.addEventListener("drop", (e) => { e.preventDefault(); drop.classList.remove("splat-upload--dragover"); const file = e.dataTransfer?.files[0]; if (this._uploadMode === "splat" && file && /\.(ply|splat|spz)$/i.test(file.name)) { selectedFile = file; form.classList.add("active"); const name = file.name.replace(/\.(ply|splat|spz)$/i, ""); titleInput.value = name.replace(/[-_]/g, " "); titleInput.dispatchEvent(new Event("input")); } }); titleInput?.addEventListener("input", () => { submitBtn.disabled = !titleInput.value.trim() || !selectedFile; }); submitBtn?.addEventListener("click", async () => { if (!selectedFile || !titleInput.value.trim()) return; submitBtn.disabled = true; status.textContent = "Uploading..."; const formData = new FormData(); formData.append("file", selectedFile); 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`, { 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; } const splat = await res.json() as SplatItem; status.textContent = "Uploaded!"; setTimeout(() => { window.location.href = `/${this._spaceSlug}/rsplat/view/${splat.slug}`; }, 500); } catch (e) { status.textContent = "Network error"; submitBtn.disabled = false; } }); } private setupMediaHandlers() { const browse = this.querySelector("#media-browse") as HTMLElement; const fileInput = this.querySelector("#media-files") as HTMLInputElement; const form = this.querySelector("#media-form") as HTMLElement; const selected = this.querySelector("#media-selected") as HTMLElement; const titleInput = this.querySelector("#media-title-input") as HTMLInputElement; const descInput = this.querySelector("#media-desc-input") as HTMLTextAreaElement; const tagsInput = this.querySelector("#media-tags-input") as HTMLInputElement; const submitBtn = this.querySelector("#media-submit") as HTMLButtonElement; const status = this.querySelector("#media-status") as HTMLElement; if (!fileInput) return; let selectedFiles: File[] = []; browse?.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", () => { if (fileInput.files && fileInput.files.length > 0) { selectedFiles = Array.from(fileInput.files); form.classList.add("active"); const totalSize = selectedFiles.reduce((sum, f) => sum + f.size, 0); selected.innerHTML = `
${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""} selected (${formatSize(totalSize)})
`; if (!titleInput.value.trim() && selectedFiles.length > 0) { const name = selectedFiles[0].name.replace(/\.[^.]+$/, ""); titleInput.value = name.replace(/[-_]/g, " "); } titleInput.dispatchEvent(new Event("input")); } }); titleInput?.addEventListener("input", () => { submitBtn.disabled = !titleInput.value.trim() || selectedFiles.length === 0; }); submitBtn?.addEventListener("click", async () => { if (selectedFiles.length === 0 || !titleInput.value.trim()) return; submitBtn.disabled = true; status.textContent = "Uploading..."; const formData = new FormData(); for (const f of selectedFiles) { formData.append("files", f); } formData.append("title", titleInput.value.trim()); formData.append("description", descInput.value.trim()); formData.append("tags", tagsInput.value.trim()); try { const token = localStorage.getItem("encryptid_token") || ""; const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/from-media`, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: formData, }); if (res.status === 402) { status.textContent = "Payment required for upload (x402)"; submitBtn.disabled = false; return; } if (res.status === 401) { status.textContent = "Sign in with rStack Identity to upload"; submitBtn.disabled = false; return; } if (!res.ok) { const err = await res.json().catch(() => ({ error: "Upload failed" })); status.textContent = (err as any).error || "Upload failed"; submitBtn.disabled = false; return; } status.textContent = "Uploaded! Queued for processing."; setTimeout(() => { window.location.href = `/${this._spaceSlug}/rsplat`; }, 1000); } catch (e) { status.textContent = "Network error"; submitBtn.disabled = false; } }); } private setupDemoCardHandlers() { this.querySelectorAll(".splat-card--demo").forEach((card) => { card.style.cursor = "pointer"; card.addEventListener("click", (e) => { e.preventDefault(); const url = card.dataset.demoUrl; const title = card.dataset.demoTitle || ""; const desc = card.dataset.demoDesc || ""; if (!url) return; this._mode = "viewer"; this._splatUrl = url; this._splatTitle = title; this._splatDesc = desc; this._inlineViewer = true; this.renderViewer(); }); }); } private setupGenerateHandlers() { const browse = this.querySelector("#generate-browse") as HTMLElement; const fileInput = this.querySelector("#generate-file") as HTMLInputElement; const preview = this.querySelector("#generate-preview") as 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 status = this.querySelector("#generate-status") as HTMLElement; if (!fileInput) return; let selectedFile: File | null = null; browse?.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", () => { if (fileInput.files?.[0]) { selectedFile = fileInput.files[0]; const reader = new FileReader(); reader.onload = () => { preview.innerHTML = `Preview`; preview.style.display = "block"; actions.style.display = "flex"; status.textContent = ""; }; reader.readAsDataURL(selectedFile); } }); submitBtn?.addEventListener("click", async () => { if (!selectedFile) return; submitBtn.disabled = true; actions.style.display = "none"; progress.style.display = "block"; status.textContent = ""; try { const reader = new FileReader(); const dataUrl = await new Promise((resolve) => { reader.onload = () => resolve(reader.result as string); reader.readAsDataURL(selectedFile!); }); const res = await fetch("/api/3d-gen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image_url: dataUrl }), }); if (res.status === 503) { status.textContent = "AI generation not available — FAL_KEY not configured"; progress.style.display = "none"; actions.style.display = "flex"; submitBtn.disabled = false; return; } if (!res.ok) { const err = await res.json().catch(() => ({ error: "Generation failed" })); status.textContent = (err as any).error || "Generation failed"; progress.style.display = "none"; actions.style.display = "flex"; submitBtn.disabled = false; return; } const data = await res.json() as { url: string; format: string }; progress.style.display = "none"; // Open inline viewer with generated splat this._mode = "viewer"; this._splatUrl = data.url; this._splatTitle = selectedFile.name.replace(/\.[^.]+$/, ""); this._splatDesc = "AI-generated 3D model"; this._inlineViewer = true; this.renderViewer(); } catch (e) { status.textContent = "Network error — could not reach server"; progress.style.display = "none"; actions.style.display = "flex"; submitBtn.disabled = false; } }); } // ── Viewer ── private renderViewer() { const backEl = this._inlineViewer ? `` : `← Gallery`; this.innerHTML = `
Loading splat...
${backEl}
${this._splatTitle ? `

${esc(this._splatTitle)}

${this._splatDesc ? `

${esc(this._splatDesc)}

` : ""}
` : ""}
`; if (this._inlineViewer) { this.querySelector("#splat-back-btn")?.addEventListener("click", () => { if (this._viewer) { try { this._viewer.dispose(); } catch {} this._viewer = null; } this._mode = "gallery"; this._inlineViewer = false; this._splatUrl = ""; this._splatTitle = ""; this._splatDesc = ""; if (this._spaceSlug === "demo") this.loadDemoData(); this.renderGallery(); }); } this.initThreeViewer(); } private async initThreeViewer() { const container = this.querySelector("#splat-container") as HTMLElement; const loading = this.querySelector("#splat-loading") as HTMLElement; if (!container || !this._splatUrl) return; try { // Dynamic import from CDN (via importmap) const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d"); const viewer = new GaussianSplats3D.Viewer({ cameraUp: [0, 1, 0], initialCameraPosition: [5, 3, 5], initialCameraLookAt: [0, 0, 0], rootElement: container, sharedMemoryForWorkers: false, }); this._viewer = viewer; viewer.addSplatScene(this._splatUrl, { showLoadingUI: false, progressiveLoad: true, }) .then(() => { viewer.start(); if (loading) loading.classList.add("hidden"); }) .catch((e: Error) => { console.error("[rSplat] Scene load error:", e); if (loading) { const text = loading.querySelector(".splat-loading__text"); if (text) text.textContent = `Error: ${e.message}`; const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement; if (spinner) spinner.style.display = "none"; } }); } catch (e) { console.error("[rSplat] Viewer init error:", e); if (loading) { const text = loading.querySelector(".splat-loading__text"); if (text) text.textContent = `Error loading viewer: ${(e as Error).message}`; const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement; if (spinner) spinner.style.display = "none"; } } } } // ── Helpers ── function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } customElements.define("folk-splat-viewer", FolkSplatViewer);