/**
* 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 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";
}
}
// ── 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, processing_status, source_file_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 ──
// 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 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: 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);
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, 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]
);
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: ``,
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}/${splat.slug}.${splat.file_format}`;
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);
// 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) {
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();