745 lines
21 KiB
TypeScript
745 lines
21 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.
|
|
*
|
|
* 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, "&").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";
|
|
}
|
|
}
|
|
|
|
// ── 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 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(spaceSlug);
|
|
|
|
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 id = c.req.param("id");
|
|
|
|
const doc = ensureDoc(spaceSlug);
|
|
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(spaceSlug);
|
|
_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 id = c.req.param("id");
|
|
|
|
const doc = ensureDoc(spaceSlug);
|
|
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 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(spaceSlug);
|
|
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(spaceSlug);
|
|
_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 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(spaceSlug);
|
|
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(spaceSlug);
|
|
_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 id = c.req.param("id");
|
|
|
|
const doc = ensureDoc(spaceSlug);
|
|
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(spaceSlug);
|
|
_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 doc = ensureDoc(spaceSlug);
|
|
|
|
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 id = c.req.param("id");
|
|
|
|
const doc = ensureDoc(spaceSlug);
|
|
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(spaceSlug);
|
|
_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" },
|
|
],
|
|
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);
|
|
},
|
|
};
|