/** * — 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). */ 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; } 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" = "splat"; 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 { this.renderGallery(); } } disconnectedCallback() { 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 tag = isReady ? "a" : "div"; const href = isReady ? ` href="/${this._spaceSlug}/splat/view/${s.slug}"` : ""; const statusClass = !isReady ? ` splat-card--${status}` : ""; 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}"${href}>
${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.setupToggle(); } 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; buttons.forEach((btn) => { btn.addEventListener("click", () => { const mode = btn.dataset.mode as "splat" | "media"; 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"; }); }); } 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}/splat/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}/splat/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}/splat/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}/splat`; }, 1000); } catch (e) { status.textContent = "Network error"; submitBtn.disabled = false; } }); } // ── Viewer ── private renderViewer() { this.innerHTML = `
Loading splat...
${this._splatTitle ? `

${esc(this._splatTitle)}

${this._splatDesc ? `

${esc(this._splatDesc)}

` : ""}
` : ""}
`; 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);