rspace-online/modules/rsplat/mod.ts

767 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, "&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";
}
}
// ── 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<SplatScenesDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<SplatScenesDoc>(), '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 = `<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 dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
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(dataSpace);
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 dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const id = c.req.param("id");
const doc = ensureDoc(dataSpace);
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(dataSpace);
_syncServer!.changeDoc<SplatScenesDoc>(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 dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const id = c.req.param("id");
const doc = ensureDoc(dataSpace);
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<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 dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
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(dataSpace);
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(dataSpace);
_syncServer!.changeDoc<SplatScenesDoc>(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<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 dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
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(dataSpace);
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(dataSpace);
_syncServer!.changeDoc<SplatScenesDoc>(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 dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const id = c.req.param("id");
const doc = ensureDoc(dataSpace);
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(dataSpace);
_syncServer!.changeDoc<SplatScenesDoc>(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 dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const doc = ensureDoc(dataSpace);
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: `<folk-splat-viewer id="gallery" mode="gallery"></folk-splat-viewer>`,
modules: getModuleInfoList(),
theme: "dark",
head: `
<link rel="stylesheet" href="/modules/rsplat/splat.css">
${IMPORTMAP}
`,
scripts: `
<script type="module">
import { FolkSplatViewer } from '/modules/rsplat/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 dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const id = c.req.param("id");
const doc = ensureDoc(dataSpace);
const found = findItem(doc, id);
if (!found || found[1].status !== 'published') {
const html = renderShell({
title: "Splat not found | rSpace",
moduleId: "rsplat",
spaceSlug,
body: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Splat not found</h2><p><a href="/${spaceSlug}/rsplat" style="color:#818cf8;">Back to gallery</a></p></div>`,
modules: getModuleInfoList(),
theme: "dark",
});
return c.html(html, 404);
}
const [itemKey, splat] = found;
// Increment view count
const docId = splatScenesDocId(dataSpace);
_syncServer!.changeDoc<SplatScenesDoc>(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: `
<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/rsplat/splat.css">
${IMPORTMAP}
`,
scripts: `
<script type="module">
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js';
</script>
`,
});
return c.html(html);
});
// ── Seed template data ──
function seedTemplateSplat(space: string) {
if (!_syncServer) return;
const doc = ensureDoc(space);
if (Object.keys(doc.items).length > 0) return;
const docId = splatScenesDocId(space);
const now = Date.now();
const scenes: Array<{ title: string; slug: string; desc: string; tags: string[] }> = [
{
title: 'Community Garden Scan', slug: 'community-garden-scan',
desc: 'A 3D Gaussian splat capture of the community garden space, captured with a phone camera walk-around.',
tags: ['outdoor', 'community', 'garden'],
},
{
title: 'Workshop Space Scan', slug: 'workshop-space-scan',
desc: 'Interior scan of the maker workshop, showing CNC, 3D printer stations, and material shelving.',
tags: ['indoor', 'workshop', 'makerspace'],
},
];
_syncServer.changeDoc<SplatScenesDoc>(docId, 'seed template splats', (d) => {
for (const s of scenes) {
const id = crypto.randomUUID();
d.items[id] = {
id, slug: s.slug, title: s.title, description: s.desc,
filePath: '', fileFormat: 'splat', fileSizeBytes: 0,
tags: s.tags, spaceSlug: space,
contributorId: 'did:demo:seed', contributorName: 'Demo',
source: 'upload', status: 'published', viewCount: 0,
paymentTx: null, paymentNetwork: null,
createdAt: now, processingStatus: 'ready', processingError: null,
sourceFileCount: 0, sourceFiles: [],
};
}
});
console.log(`[Splat] Template seeded for "${space}": 2 splat entries`);
}
// ── 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,
seedTemplate: seedTemplateSplat,
feeds: [
{ id: "splat-scenes", name: "3D Scenes", kind: "resource", description: "Gaussian splat 3D captures" },
{ id: "splat-activity", name: "Capture Activity", kind: "data", description: "Upload and view events for 3D scenes" },
],
acceptsFeeds: ["data", "resource"],
standaloneDomain: "rsplat.online",
hidden: true,
outputPaths: [
{ path: "drawings", name: "Drawings", icon: "🔮", description: "3D Gaussian splat drawings" },
],
subPageInfos: [
{
path: "view",
title: "Splat Viewer",
icon: "🔮",
tagline: "rSplat Tool",
description: "Explore 3D Gaussian splat captures in an interactive WebGL viewer. Orbit, zoom, and inspect photorealistic 3D scenes.",
features: [
{ icon: "🖱", title: "Interactive 3D", text: "Orbit, pan, and zoom through photorealistic 3D captures in your browser." },
{ icon: "📷", title: "Multi-Angle Capture", text: "View scenes reconstructed from hundreds of photos using Gaussian splatting." },
{ icon: "📤", title: "Upload & Share", text: "Upload .ply or .splat files and share interactive 3D views with anyone." },
],
},
],
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);
},
};