rspace-online/modules/splat/mod.ts

416 lines
12 KiB
TypeScript

/**
* 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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 = `<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/",
"@mkkellogg/gaussian-splats-3d": "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.6/build/gaussian-splats-3d.module.js"
}
}
</script>`;
// ── 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<Response | null>((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: `<folk-splat-viewer id="gallery" mode="gallery"></folk-splat-viewer>`,
modules: getModuleInfoList(),
theme: "dark",
head: `
<link rel="stylesheet" href="/modules/splat/splat.css">
${IMPORTMAP}
`,
scripts: `
<script type="module">
import { FolkSplatViewer } from '/modules/splat/folk-splat-viewer.js';
const gallery = document.getElementById('gallery');
gallery.splats = ${splatsJSON};
gallery.spaceSlug = '${spaceSlug}';
</script>
`,
});
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: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Splat not found</h2><p><a href="/${spaceSlug}/splat" style="color:#818cf8;">Back to gallery</a></p></div>`,
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: `
<folk-splat-viewer
id="viewer"
mode="viewer"
splat-url="${escapeAttr(fileUrl)}"
splat-title="${escapeAttr(splat.title)}"
splat-desc="${escapeAttr(splat.description || '')}"
space-slug="${escapeAttr(spaceSlug)}"
></folk-splat-viewer>
`,
modules: getModuleInfoList(),
theme: "dark",
head: `
<link rel="stylesheet" href="/modules/splat/splat.css">
${IMPORTMAP}
`,
scripts: `
<script type="module">
import { FolkSplatViewer } from '/modules/splat/folk-splat-viewer.js';
</script>
`,
});
return c.html(html);
});
// ── Initialize DB schema ──
async function initDB(): Promise<void> {
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();