416 lines
12 KiB
TypeScript
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, "&").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 = `<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();
|