/** * Splat module — Gaussian splat viewer with x402 gated uploads. * * Routes are relative to mount point (/:space/splat in unified). * Three.js + GaussianSplats3D loaded via CDN importmap. * * All metadata is stored in Automerge documents via SyncServer. * 3D files (.ply, .splat, .spz) remain on the filesystem. */ import { Hono } from "hono"; import { resolve } from "node:path"; import { mkdir } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { renderLanding } from "./landing"; import { verifyEncryptIDToken, extractToken, } from "@encryptid/sdk/server"; import { setupX402FromEnv } from "../../shared/x402/hono-middleware"; import type { SyncServer } from '../../server/local-first/sync-server'; import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc, type SplatItem, type SourceFile, } from './schemas'; let _syncServer: SyncServer | null = null; 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 ── export interface SplatRow { id: string; slug: string; title: string; description: string | null; file_path: string; file_format: string; file_size_bytes: number; tags: string[]; space_slug: string; contributor_id: string | null; contributor_name: string | null; source: string; status: string; 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; } // ── Helpers ── function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 80); } function escapeAttr(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } function getFileFormat(filename: string): string | null { const ext = filename.split(".").pop()?.toLowerCase(); return ext && VALID_FORMATS.includes(ext) ? ext : null; } function getMimeType(format: string): string { switch (format) { case "ply": return "application/x-ply"; case "splat": return "application/octet-stream"; case "spz": return "application/octet-stream"; default: return "application/octet-stream"; } } // ── Automerge helpers ── /** * Lazily create the Automerge doc for a space if it doesn't exist yet. */ function ensureDoc(space: string): SplatScenesDoc { const docId = splatScenesDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init', (d) => { const init = splatScenesSchema.init(); d.meta = init.meta; d.meta.spaceSlug = space; d.items = {}; }); _syncServer!.setDoc(docId, doc); } return doc; } /** * Find a splat item by slug or id within a doc's items map. * Returns [itemKey, item] or undefined. */ function findItem(doc: SplatScenesDoc, idOrSlug: string): [string, SplatItem] | undefined { for (const [key, item] of Object.entries(doc.items)) { if (item.slug === idOrSlug || item.id === idOrSlug) { return [key, item]; } } return undefined; } /** * Convert a SplatItem (camelCase) to a snake_case row for API responses, * preserving the shape the frontend expects. */ function itemToRow(item: SplatItem): SplatRow { return { id: item.id, slug: item.slug, title: item.title, description: item.description || null, file_path: item.filePath, file_format: item.fileFormat, file_size_bytes: item.fileSizeBytes, tags: item.tags ?? [], space_slug: item.spaceSlug, contributor_id: item.contributorId, contributor_name: item.contributorName, source: item.source ?? 'upload', status: item.status, view_count: item.viewCount, payment_tx: item.paymentTx, payment_network: item.paymentNetwork, processing_status: item.processingStatus ?? 'ready', processing_error: item.processingError, source_file_count: item.sourceFileCount, created_at: new Date(item.createdAt).toISOString(), }; } /** * Return the subset of SplatRow fields used in list/gallery responses. */ function itemToListRow(item: SplatItem) { return { id: item.id, slug: item.slug, title: item.title, description: item.description || null, file_format: item.fileFormat, file_size_bytes: item.fileSizeBytes, tags: item.tags ?? [], contributor_name: item.contributorName, view_count: item.viewCount, processing_status: item.processingStatus ?? 'ready', source_file_count: item.sourceFileCount, created_at: new Date(item.createdAt).toISOString(), }; } // ── CDN importmap for Three.js + GaussianSplats3D ── const IMPORTMAP = ``; // ── x402 middleware ── const x402Middleware = setupX402FromEnv({ description: "Upload Gaussian splat file", resource: "/api/splats", }); // ── Routes ── const routes = new Hono(); // ── API: List splats ── routes.get("/api/splats", async (c) => { const spaceSlug = c.req.param("space") || "demo"; const tag = c.req.query("tag"); const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); const offset = parseInt(c.req.query("offset") || "0"); const doc = ensureDoc(spaceSlug); let items = Object.values(doc.items) .filter((item) => item.status === 'published'); if (tag) { items = items.filter((item) => item.tags?.includes(tag)); } // Sort by createdAt descending items.sort((a, b) => b.createdAt - a.createdAt); // Apply offset and limit const paged = items.slice(offset, offset + limit); return c.json({ splats: paged.map(itemToListRow) }); }); // ── API: Get splat details ── routes.get("/api/splats/:id", async (c) => { const spaceSlug = c.req.param("space") || "demo"; const id = c.req.param("id"); const doc = ensureDoc(spaceSlug); const found = findItem(doc, id); if (!found || found[1].status !== 'published') { return c.json({ error: "Splat not found" }, 404); } const [itemKey, item] = found; // Increment view count const docId = splatScenesDocId(spaceSlug); _syncServer!.changeDoc(docId, 'increment view count', (d) => { d.items[itemKey].viewCount += 1; }); return c.json(itemToRow(item)); }); // ── API: Serve splat file ── // Matches both /api/splats/:id/file and /api/splats/:id/:filename (e.g. rainbow-sphere.splat) routes.get("/api/splats/:id/:filename", async (c) => { const spaceSlug = c.req.param("space") || "demo"; const id = c.req.param("id"); const doc = ensureDoc(spaceSlug); const found = findItem(doc, id); if (!found || found[1].status !== 'published') { return c.json({ error: "Splat not found" }, 404); } const splat = found[1]; const filepath = resolve(SPLATS_DIR, splat.filePath); const file = Bun.file(filepath); if (!(await file.exists())) { return c.json({ error: "Splat file not found on disk" }, 404); } return new Response(file, { headers: { "Content-Type": getMimeType(splat.fileFormat), "Content-Disposition": `inline; filename="${splat.slug}.${splat.fileFormat}"`, "Content-Length": String(file.size), "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=86400", }, }); }); // ── API: Upload splat (EncryptID auth + optional x402) ── routes.post("/api/splats", 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 file = formData.get("file") as File | null; 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 (!file) { return c.json({ error: "Splat file required" }, 400); } const format = getFileFormat(file.name); if (!format) { return c.json({ error: "Invalid file format. Accepted: .ply, .splat, .spz" }, 400); } if (!title) { return c.json({ error: "Title required" }, 400); } // 500MB limit if (file.size > 500 * 1024 * 1024) { return c.json({ error: "File too large. Maximum 500MB." }, 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 in Automerge doc const doc = ensureDoc(spaceSlug); const slugExists = Object.values(doc.items).some((item) => item.slug === slug); if (slugExists) { slug = `${slug}-${shortId}`; } // Save file to disk await mkdir(SPLATS_DIR, { recursive: true }); const filename = `${slug}.${format}`; const filepath = resolve(SPLATS_DIR, filename); const buffer = Buffer.from(await file.arrayBuffer()); await Bun.write(filepath, buffer); // Insert into Automerge doc const splatId = randomUUID(); const now = Date.now(); const paymentTx = (c as any).get("x402Payment") || null; const docId = splatScenesDocId(spaceSlug); _syncServer!.changeDoc(docId, 'add splat', (d) => { d.items[splatId] = { id: splatId, slug, title, description: description ?? '', filePath: filename, fileFormat: format, fileSizeBytes: buffer.length, tags, spaceSlug, contributorId: claims.sub, contributorName: claims.username || null, source: 'upload', status: 'published', viewCount: 0, paymentTx, paymentNetwork: null, createdAt: now, processingStatus: 'ready', processingError: null, sourceFileCount: 0, sourceFiles: [], }; }); return c.json({ id: splatId, slug, title, description, file_format: format, file_size_bytes: buffer.length, tags, created_at: new Date(now).toISOString(), }, 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 as unknown) instanceof File) { files.push(value as unknown as File); } } 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 in Automerge doc const doc = ensureDoc(spaceSlug); const slugExists = Object.values(doc.items).some((item) => item.slug === slug); if (slugExists) { slug = `${slug}-${shortId}`; } // Save source files to disk const sourceDir = resolve(SOURCES_DIR, slug); await mkdir(sourceDir, { recursive: true }); const sourceFileEntries: SourceFile[] = []; const sfId = () => randomUUID(); const splatId = randomUUID(); const now = Date.now(); 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); sourceFileEntries.push({ id: sfId(), splatId, filePath: `sources/${slug}/${safeName}`, fileName: f.name, mimeType: f.type, fileSizeBytes: buffer.length, createdAt: now, }); } // Insert splat record (pending processing) into Automerge doc const paymentTx = (c as any).get("x402Payment") || null; const docId = splatScenesDocId(spaceSlug); _syncServer!.changeDoc(docId, 'add splat from media', (d) => { d.items[splatId] = { id: splatId, slug, title, description: description ?? '', filePath: '', fileFormat: 'ply', fileSizeBytes: 0, tags, spaceSlug, contributorId: claims.sub, contributorName: claims.username || null, source: 'media', status: 'published', viewCount: 0, paymentTx, paymentNetwork: null, createdAt: now, processingStatus: 'pending', processingError: null, sourceFileCount: files.length, sourceFiles: sourceFileEntries, }; }); return c.json({ id: splatId, slug, title, description, file_format: 'ply', tags, processing_status: 'pending', source_file_count: files.length, created_at: new Date(now).toISOString(), }, 201); }); // ── API: Delete splat (owner only) ── routes.delete("/api/splats/:id", 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 id = c.req.param("id"); const doc = ensureDoc(spaceSlug); const found = findItem(doc, id); if (!found || found[1].status !== 'published') { return c.json({ error: "Splat not found" }, 404); } const [itemKey, item] = found; if (item.contributorId !== claims.sub) { return c.json({ error: "Not authorized" }, 403); } const docId = splatScenesDocId(spaceSlug); _syncServer!.changeDoc(docId, 'remove splat', (d) => { d.items[itemKey].status = 'removed'; }); return c.json({ ok: true }); }); // ── Page: Gallery ── routes.get("/", async (c) => { const spaceSlug = c.req.param("space") || "demo"; const doc = ensureDoc(spaceSlug); const items = Object.values(doc.items) .filter((item) => item.status === 'published') .sort((a, b) => b.createdAt - a.createdAt) .slice(0, 50); const rows = items.map(itemToListRow); const splatsJSON = JSON.stringify(rows); const html = renderShell({ title: `${spaceSlug} — rSplat | rSpace`, moduleId: "rsplat", spaceSlug, body: ``, modules: getModuleInfoList(), theme: "dark", head: ` ${IMPORTMAP} `, scripts: ` `, }); return c.html(html); }); // ── Page: Viewer ── routes.get("/view/:id", async (c) => { const spaceSlug = c.req.param("space") || "demo"; const id = c.req.param("id"); const doc = ensureDoc(spaceSlug); const found = findItem(doc, id); if (!found || found[1].status !== 'published') { const html = renderShell({ title: "Splat not found | rSpace", moduleId: "rsplat", spaceSlug, body: `

Splat not found

Back to gallery

`, modules: getModuleInfoList(), theme: "dark", }); return c.html(html, 404); } const [itemKey, splat] = found; // Increment view count const docId = splatScenesDocId(spaceSlug); _syncServer!.changeDoc(docId, 'increment view count', (d) => { d.items[itemKey].viewCount += 1; }); const fileUrl = `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.fileFormat}`; const html = renderShell({ title: `${splat.title} | rSplat`, moduleId: "rsplat", spaceSlug, body: ` `, modules: getModuleInfoList(), theme: "dark", head: ` ${IMPORTMAP} `, scripts: ` `, }); return c.html(html); }); // ── Module export ── export const splatModule: RSpaceModule = { id: "rsplat", name: "rSplat", icon: "🔮", description: "3D Gaussian splat viewer", scoping: { defaultScope: 'global', userConfigurable: true }, docSchemas: [{ pattern: '{space}:splat:scenes', description: 'Splat scene metadata', init: splatScenesSchema.init }], routes, landingPage: renderLanding, standaloneDomain: "rsplat.online", hidden: true, outputPaths: [ { path: "drawings", name: "Drawings", icon: "🔮", description: "3D Gaussian splat drawings" }, ], async onInit(ctx) { _syncServer = ctx.syncServer; console.log("[Splat] Automerge document store ready"); }, async onSpaceCreate(ctx: SpaceLifecycleContext) { // Eagerly create the Automerge doc for new spaces ensureDoc(ctx.spaceSlug); }, };