From 4afce2dc3768b50f395b3beea1cd198efdf38b0a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 22 Feb 2026 03:27:41 +0000 Subject: [PATCH] Add sample splats + photo/video upload pipeline for rSplat Seed gallery with real Bonsai and Nike Shoe splat scenes from Hugging Face, replacing the synthetic Rainbow Sphere. Add photo/video upload endpoint (POST /api/splats/from-media) with processing status tracking for future COLMAP + OpenSplat generation. Gallery now shows upload mode toggle (splat file vs photos/video) and processing status overlays on cards. Co-Authored-By: Claude Opus 4.6 --- modules/splat/components/folk-splat-viewer.ts | 203 +++++++++++++++--- modules/splat/components/splat.css | 116 ++++++++++ modules/splat/db/schema.sql | 16 ++ modules/splat/mod.ts | 137 +++++++++++- 4 files changed, 446 insertions(+), 26 deletions(-) diff --git a/modules/splat/components/folk-splat-viewer.ts b/modules/splat/components/folk-splat-viewer.ts index 2425f40..d06ed2f 100644 --- a/modules/splat/components/folk-splat-viewer.ts +++ b/modules/splat/components/folk-splat-viewer.ts @@ -1,7 +1,7 @@ /** * — Gaussian splat gallery + 3D viewer web component. * - * Gallery mode: card grid of splats with upload form. + * 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). @@ -16,6 +16,8 @@ interface SplatItem { file_size_bytes: number; view_count: number; contributor_name?: string; + processing_status?: string; + source_file_count?: number; created_at: string; } @@ -27,6 +29,7 @@ export class FolkSplatViewer extends HTMLElement { 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"]; @@ -73,27 +76,49 @@ export class FolkSplatViewer extends HTMLElement { // ── Gallery ── private renderGallery() { - const cards = this._splats.map((s) => ` - + 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} - ${formatSize(s.file_size_bytes)} - ${s.view_count} views + ${isReady ? `${formatSize(s.file_size_bytes)}` : ""} + ${sourceInfo}
-
- `).join(""); + + `; + }).join(""); const empty = this._splats.length === 0 ? `
🔮

No splats yet

-

Upload a .ply, .splat, or .spz file to get started

+

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

` : ""; @@ -104,24 +129,69 @@ export class FolkSplatViewer extends HTMLElement { ${empty}
${cards}
-
📤
-

- Drag & drop a .ply, .splat, or .spz file here -
or browse to upload -

- -
- - - - -
+
+ + +
+ + +
+
📤
+

+ Drag & drop a .ply, .splat, or .spz file here +
or browse to upload +

+ +
+ + + + +
+
+
+ + +
`; 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() { @@ -145,7 +215,6 @@ export class FolkSplatViewer extends HTMLElement { if (fileInput.files?.[0]) { selectedFile = fileInput.files[0]; form.classList.add("active"); - // Auto-populate title from filename const name = selectedFile.name.replace(/\.(ply|splat|spz)$/i, ""); titleInput.value = name.replace(/[-_]/g, " "); titleInput.dispatchEvent(new Event("input")); @@ -165,7 +234,7 @@ export class FolkSplatViewer extends HTMLElement { e.preventDefault(); drop.classList.remove("splat-upload--dragover"); const file = e.dataTransfer?.files[0]; - if (file && /\.(ply|splat|spz)$/i.test(file.name)) { + 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, ""); @@ -219,7 +288,6 @@ export class FolkSplatViewer extends HTMLElement { const splat = await res.json() as SplatItem; status.textContent = "Uploaded!"; - // Navigate to viewer setTimeout(() => { window.location.href = `/${this._spaceSlug}/splat/view/${splat.slug}`; }, 500); @@ -230,6 +298,93 @@ export class FolkSplatViewer extends HTMLElement { }); } + 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() { diff --git a/modules/splat/components/splat.css b/modules/splat/components/splat.css index 6639de5..4b6e5a3 100644 --- a/modules/splat/components/splat.css +++ b/modules/splat/components/splat.css @@ -107,6 +107,64 @@ .splat-badge--splat { background: #7c3aed; color: #ede9fe; } .splat-badge--spz { background: #2563eb; color: #dbeafe; } +/* ── Processing status badges ── */ + +.splat-badge--pending { background: #92400e; color: #fef3c7; } +.splat-badge--processing { background: #1e40af; color: #dbeafe; } +.splat-badge--failed { background: #991b1b; color: #fecaca; } + +/* ── Processing card variants ── */ + +.splat-card--pending, +.splat-card--processing, +.splat-card--failed { + cursor: default; +} + +.splat-card--pending:hover, +.splat-card--processing:hover, +.splat-card--failed:hover { + transform: none; + border-color: var(--splat-border); + box-shadow: none; +} + +.splat-card--pending .splat-card__preview { + background: linear-gradient(135deg, #451a03 0%, #78350f 50%, #1e293b 100%); +} + +.splat-card--processing .splat-card__preview { + background: linear-gradient(135deg, #1e1b4b 0%, #1e3a5f 50%, #1e293b 100%); +} + +.splat-card--failed .splat-card__preview { + background: linear-gradient(135deg, #450a0a 0%, #7f1d1d 50%, #1e293b 100%); +} + +/* ── Processing overlay ── */ + +.splat-card__overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + gap: 0.5rem; + z-index: 2; +} + +.splat-card__spinner { + width: 28px; + height: 28px; + border: 2px solid rgba(147, 197, 253, 0.3); + border-top-color: #93c5fd; + border-radius: 50%; + animation: splat-spin 0.8s linear infinite; +} + /* ── Upload ── */ .splat-upload { @@ -140,6 +198,42 @@ cursor: pointer; } +/* ── Upload mode toggle ── */ + +.splat-upload__toggle { + display: inline-flex; + border-radius: 9999px; + overflow: hidden; + border: 1px solid var(--splat-border); + margin-bottom: 1.5rem; +} + +.splat-upload__toggle-btn { + padding: 0.5rem 1.25rem; + border: none; + background: transparent; + color: var(--splat-text-muted); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, color 0.2s; +} + +.splat-upload__toggle-btn:hover { + color: var(--splat-text); +} + +.splat-upload__toggle-btn.active { + background: var(--splat-accent); + color: white; +} + +/* ── Upload modes ── */ + +.splat-upload__mode { + /* shown/hidden via JS display toggle */ +} + .splat-upload__form { display: none; flex-direction: column; @@ -202,6 +296,23 @@ color: var(--splat-accent); } +/* ── Media file count ── */ + +.splat-upload__selected { + min-height: 0; +} + +.splat-upload__file-count { + display: inline-block; + padding: 0.375rem 0.75rem; + border-radius: 6px; + background: rgba(129, 140, 248, 0.15); + color: var(--splat-accent); + font-size: 0.8rem; + font-weight: 500; + margin-bottom: 0.5rem; +} + /* ── Viewer ── */ .splat-viewer { @@ -349,4 +460,9 @@ right: 0.5rem; max-width: none; } + + .splat-upload__toggle-btn { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + } } diff --git a/modules/splat/db/schema.sql b/modules/splat/db/schema.sql index 1aa35a3..b5a2f5d 100644 --- a/modules/splat/db/schema.sql +++ b/modules/splat/db/schema.sql @@ -15,6 +15,9 @@ CREATE TABLE IF NOT EXISTS rsplat.splats ( view_count INTEGER DEFAULT 0, payment_tx TEXT, payment_network TEXT, + processing_status TEXT DEFAULT 'ready', + processing_error TEXT, + source_file_count INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() ); @@ -22,3 +25,16 @@ CREATE INDEX IF NOT EXISTS idx_splats_space ON rsplat.splats (space_slug); CREATE INDEX IF NOT EXISTS idx_splats_slug ON rsplat.splats (slug); CREATE INDEX IF NOT EXISTS idx_splats_status ON rsplat.splats (status); CREATE INDEX IF NOT EXISTS idx_splats_created ON rsplat.splats (created_at DESC); + +-- Source files table (photos/video uploaded for splatting) +CREATE TABLE IF NOT EXISTS rsplat.source_files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + splat_id UUID REFERENCES rsplat.splats(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + mime_type TEXT, + file_size_bytes BIGINT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_source_files_splat ON rsplat.source_files (splat_id); diff --git a/modules/splat/mod.ts b/modules/splat/mod.ts index 401667f..140de7f 100644 --- a/modules/splat/mod.ts +++ b/modules/splat/mod.ts @@ -20,7 +20,15 @@ import { import { setupX402FromEnv } from "../../shared/x402/hono-middleware"; const SPLATS_DIR = process.env.SPLATS_DIR || "/data/splats"; +const SOURCES_DIR = resolve(SPLATS_DIR, "sources"); const VALID_FORMATS = ["ply", "splat", "spz"]; +const VALID_MEDIA_TYPES = [ + "image/jpeg", "image/png", "image/heic", + "video/mp4", "video/quicktime", "video/webm", +]; +const VALID_MEDIA_EXTS = [".jpg", ".jpeg", ".png", ".heic", ".mp4", ".mov", ".webm"]; +const MAX_PHOTOS = 100; +const MAX_MEDIA_SIZE = 2 * 1024 * 1024 * 1024; // 2GB per file // ── Types ── @@ -41,6 +49,9 @@ export interface SplatRow { view_count: number; payment_tx: string | null; payment_network: string | null; + processing_status: string; + processing_error: string | null; + source_file_count: number; created_at: string; } @@ -103,7 +114,7 @@ routes.get("/api/splats", async (c) => { const offset = parseInt(c.req.query("offset") || "0"); let query = `SELECT id, slug, title, description, file_format, file_size_bytes, - tags, contributor_name, view_count, created_at + tags, contributor_name, view_count, processing_status, source_file_count, created_at FROM rsplat.splats WHERE status = 'published' AND space_slug = $1`; const params: (string | number)[] = [spaceSlug]; @@ -253,6 +264,124 @@ routes.post("/api/splats", async (c) => { return c.json(rows[0], 201); }); +// ── API: Upload photos/video for splatting ── +routes.post("/api/splats/from-media", async (c) => { + // Auth check + 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); + } + + // x402 check (if enabled) + if (x402Middleware) { + const paymentResult = await new Promise((resolve) => { + const fakeNext = async () => { resolve(null); }; + x402Middleware(c, fakeNext).then((res) => { + if (res instanceof Response) resolve(res); + }); + }); + if (paymentResult) return paymentResult; + } + + const spaceSlug = c.req.param("space") || "demo"; + const formData = await c.req.formData(); + const title = (formData.get("title") as string || "").trim(); + const description = (formData.get("description") as string || "").trim() || null; + const tagsRaw = (formData.get("tags") as string || "").trim(); + + if (!title) { + return c.json({ error: "Title required" }, 400); + } + + // Collect all files from formdata + const files: File[] = []; + for (const [key, value] of formData.entries()) { + if (key === "files" && value instanceof File) { + files.push(value); + } + } + + if (files.length === 0) { + return c.json({ error: "At least one photo or video file required" }, 400); + } + + // Validate file types + const hasVideo = files.some((f) => f.type.startsWith("video/")); + if (hasVideo && files.length > 1) { + return c.json({ error: "Only 1 video file allowed per upload" }, 400); + } + if (!hasVideo && files.length > MAX_PHOTOS) { + return c.json({ error: `Maximum ${MAX_PHOTOS} photos per upload` }, 400); + } + + for (const f of files) { + const ext = "." + f.name.split(".").pop()?.toLowerCase(); + if (!VALID_MEDIA_EXTS.includes(ext)) { + return c.json({ error: `Invalid file type: ${f.name}. Accepted: ${VALID_MEDIA_EXTS.join(", ")}` }, 400); + } + if (f.size > MAX_MEDIA_SIZE) { + return c.json({ error: `File too large: ${f.name}. Maximum 2GB.` }, 400); + } + } + + const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : []; + const shortId = randomUUID().slice(0, 8); + let slug = slugify(title); + + // Check slug collision + const existing = await sql.unsafe( + `SELECT 1 FROM rsplat.splats WHERE slug = $1`, [slug] + ); + if (existing.length > 0) { + slug = `${slug}-${shortId}`; + } + + // Save source files to disk + const sourceDir = resolve(SOURCES_DIR, slug); + await mkdir(sourceDir, { recursive: true }); + + const sourceRows: { path: string; name: string; mime: string; size: number }[] = []; + for (const f of files) { + const safeName = f.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const filepath = resolve(sourceDir, safeName); + const buffer = Buffer.from(await f.arrayBuffer()); + await Bun.write(filepath, buffer); + sourceRows.push({ + path: `sources/${slug}/${safeName}`, + name: f.name, + mime: f.type, + size: buffer.length, + }); + } + + // Insert splat record (pending processing) + const paymentTx = c.get("x402Payment") || null; + const splatRows = await sql.unsafe( + `INSERT INTO rsplat.splats (slug, title, description, file_path, file_format, file_size_bytes, tags, space_slug, contributor_id, contributor_name, source, processing_status, source_file_count, payment_tx) + VALUES ($1, $2, $3, '', 'ply', 0, $4, $5, $6, $7, 'media', 'pending', $8, $9) + RETURNING id, slug, title, description, file_format, tags, processing_status, source_file_count, created_at`, + [slug, title, description, tags, spaceSlug, claims.sub, claims.username || null, files.length, paymentTx] + ); + + const splatId = splatRows[0].id; + + // Insert source file records + for (const sf of sourceRows) { + await sql.unsafe( + `INSERT INTO rsplat.source_files (splat_id, file_path, file_name, mime_type, file_size_bytes) + VALUES ($1, $2, $3, $4, $5)`, + [splatId, sf.path, sf.name, sf.mime, sf.size] + ); + } + + return c.json(splatRows[0], 201); +}); + // ── API: Delete splat (owner only) ── routes.delete("/api/splats/:id", async (c) => { const token = extractToken(c.req.raw.headers); @@ -290,7 +419,7 @@ routes.get("/", async (c) => { const rows = await sql.unsafe( `SELECT id, slug, title, description, file_format, file_size_bytes, - tags, contributor_name, view_count, created_at + tags, contributor_name, view_count, processing_status, source_file_count, created_at FROM rsplat.splats WHERE status = 'published' AND space_slug = $1 ORDER BY created_at DESC LIMIT 50`, [spaceSlug] @@ -391,6 +520,10 @@ async function initDB(): Promise { const schemaSql = await readFile(schemaPath, "utf-8"); await sql.unsafe(`SET search_path TO rsplat, public`); await sql.unsafe(schemaSql); + // Migration: add new columns to existing table + await sql.unsafe(`ALTER TABLE rsplat.splats ADD COLUMN IF NOT EXISTS processing_status TEXT DEFAULT 'ready'`); + await sql.unsafe(`ALTER TABLE rsplat.splats ADD COLUMN IF NOT EXISTS processing_error TEXT`); + await sql.unsafe(`ALTER TABLE rsplat.splats ADD COLUMN IF NOT EXISTS source_file_count INTEGER DEFAULT 0`); await sql.unsafe(`SET search_path TO public`); console.log("[Splat] Database schema initialized"); } catch (e) {