/** * 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. */ import { Hono } from "hono"; import { resolve } from "node:path"; import { mkdir, readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken, } from "@encryptid/sdk/server"; import { setupX402FromEnv } from "../../shared/x402/hono-middleware"; const SPLATS_DIR = process.env.SPLATS_DIR || "/data/splats"; const VALID_FORMATS = ["ply", "splat", "spz"]; // ── 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; 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"; } } // ── 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"); let query = `SELECT id, slug, title, description, file_format, file_size_bytes, tags, contributor_name, view_count, created_at FROM rsplat.splats WHERE status = 'published' AND space_slug = $1`; const params: (string | number)[] = [spaceSlug]; if (tag) { params.push(tag); query += ` AND $${params.length} = ANY(tags)`; } query += ` ORDER BY created_at DESC`; params.push(limit); query += ` LIMIT $${params.length}`; params.push(offset); query += ` OFFSET $${params.length}`; const rows = await sql.unsafe(query, params); return c.json({ splats: rows }); }); // ── API: Get splat details ── routes.get("/api/splats/:id", async (c) => { const id = c.req.param("id"); const rows = await sql.unsafe( `SELECT * FROM rsplat.splats WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, [id] ); if (rows.length === 0) return c.json({ error: "Splat not found" }, 404); // Increment view count await sql.unsafe( `UPDATE rsplat.splats SET view_count = view_count + 1 WHERE id = $1`, [rows[0].id] ); return c.json(rows[0]); }); // ── API: Serve splat file ── routes.get("/api/splats/:id/file", async (c) => { const id = c.req.param("id"); const rows = await sql.unsafe( `SELECT id, slug, file_path, file_format FROM rsplat.splats WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, [id] ); if (rows.length === 0) return c.json({ error: "Splat not found" }, 404); const splat = rows[0]; const filepath = resolve(SPLATS_DIR, splat.file_path); 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.file_format), "Content-Disposition": `inline; filename="${splat.slug}.${splat.file_format}"`, "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 const existing = await sql.unsafe( `SELECT 1 FROM rsplat.splats WHERE slug = $1`, [slug] ); if (existing.length > 0) { 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 DB const paymentTx = c.get("x402Payment") || null; const rows = await sql.unsafe( `INSERT INTO rsplat.splats (slug, title, description, file_path, file_format, file_size_bytes, tags, space_slug, contributor_id, contributor_name, payment_tx) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, slug, title, description, file_format, file_size_bytes, tags, created_at`, [slug, title, description, filename, format, buffer.length, tags, spaceSlug, claims.sub, claims.username || null, paymentTx] ); return c.json(rows[0], 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 id = c.req.param("id"); const rows = await sql.unsafe( `SELECT id, contributor_id FROM rsplat.splats WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, [id] ); if (rows.length === 0) return c.json({ error: "Splat not found" }, 404); if (rows[0].contributor_id !== claims.sub) { return c.json({ error: "Not authorized" }, 403); } await sql.unsafe( `UPDATE rsplat.splats SET status = 'removed' WHERE id = $1`, [rows[0].id] ); return c.json({ ok: true }); }); // ── Page: Gallery ── routes.get("/", async (c) => { const spaceSlug = c.req.param("space") || "demo"; const rows = await sql.unsafe( `SELECT id, slug, title, description, file_format, file_size_bytes, tags, contributor_name, view_count, created_at FROM rsplat.splats WHERE status = 'published' AND space_slug = $1 ORDER BY created_at DESC LIMIT 50`, [spaceSlug] ); const splatsJSON = JSON.stringify(rows); const html = renderShell({ title: `${spaceSlug} — rSplat | rSpace`, moduleId: "splat", 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 rows = await sql.unsafe( `SELECT * FROM rsplat.splats WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, [id] ); if (rows.length === 0) { const html = renderShell({ title: "Splat not found | rSpace", moduleId: "splat", spaceSlug, body: `

Splat not found

Back to gallery

`, modules: getModuleInfoList(), theme: "dark", }); return c.html(html, 404); } const splat = rows[0]; // Increment view count await sql.unsafe( `UPDATE rsplat.splats SET view_count = view_count + 1 WHERE id = $1`, [splat.id] ); const fileUrl = `/${spaceSlug}/splat/api/splats/${splat.slug}/file`; const html = renderShell({ title: `${splat.title} | rSplat`, moduleId: "splat", spaceSlug, body: ` `, modules: getModuleInfoList(), theme: "dark", head: ` ${IMPORTMAP} `, scripts: ` `, }); return c.html(html); }); // ── Initialize DB schema ── async function initDB(): Promise { try { const schemaPath = resolve(import.meta.dir, "db/schema.sql"); const schemaSql = await readFile(schemaPath, "utf-8"); await sql.unsafe(`SET search_path TO rsplat, public`); await sql.unsafe(schemaSql); await sql.unsafe(`SET search_path TO public`); console.log("[Splat] Database schema initialized"); } catch (e) { console.error("[Splat] Schema init failed:", e); } } // ── Module export ── export const splatModule: RSpaceModule = { id: "splat", name: "rSplat", icon: "🔮", description: "3D Gaussian splat viewer", routes, async onSpaceCreate(_spaceSlug: string) { // Splats are scoped by space_slug column. No per-space setup needed. }, }; // Run schema init on import initDB();