Add rSplat module — Gaussian splat viewer with x402 gated uploads

New rSpace module for 3D Gaussian splat viewing. Gallery + full-viewport
Three.js/GaussianSplats3D viewer loaded via CDN importmap. EncryptID auth
on uploads, optional x402 micro-transaction gate. Reusable x402 Hono
middleware in shared/x402/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-21 20:55:45 +00:00
parent cf3be7d7a9
commit c99c25f174
12 changed files with 1294 additions and 2 deletions

View File

@ -46,7 +46,7 @@ COPY --from=build /encryptid-sdk /encryptid-sdk
RUN bun install --production
# Create data directories
RUN mkdir -p /data/communities /data/books /data/swag-artifacts /data/files
RUN mkdir -p /data/communities /data/books /data/swag-artifacts /data/files /data/splats
# Set environment
ENV NODE_ENV=production
@ -54,6 +54,7 @@ ENV STORAGE_DIR=/data/communities
ENV BOOKS_DIR=/data/books
ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts
ENV FILES_DIR=/data/files
ENV SPLATS_DIR=/data/splats
ENV PORT=3000
# Data volumes for persistence
@ -61,6 +62,7 @@ VOLUME /data/communities
VOLUME /data/books
VOLUME /data/swag-artifacts
VOLUME /data/files
VOLUME /data/splats
EXPOSE 3000

View File

@ -12,6 +12,7 @@ CREATE SCHEMA IF NOT EXISTS rcart;
CREATE SCHEMA IF NOT EXISTS providers;
CREATE SCHEMA IF NOT EXISTS rfiles;
CREATE SCHEMA IF NOT EXISTS rforum;
CREATE SCHEMA IF NOT EXISTS rsplat;
-- Grant usage to the rspace user
GRANT ALL ON SCHEMA rbooks TO rspace;
@ -19,3 +20,4 @@ GRANT ALL ON SCHEMA rcart TO rspace;
GRANT ALL ON SCHEMA providers TO rspace;
GRANT ALL ON SCHEMA rfiles TO rspace;
GRANT ALL ON SCHEMA rforum TO rspace;
GRANT ALL ON SCHEMA rsplat TO rspace;

View File

@ -11,6 +11,7 @@ services:
- rspace-books:/data/books
- rspace-swag:/data/swag-artifacts
- rspace-files:/data/files
- rspace-splats:/data/splats
environment:
- NODE_ENV=production
- STORAGE_DIR=/data/communities
@ -23,6 +24,11 @@ services:
- FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d
- FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f
- FILES_DIR=/data/files
- SPLATS_DIR=/data/splats
- X402_PAY_TO=${X402_PAY_TO:-}
- X402_NETWORK=${X402_NETWORK:-eip155:84532}
- X402_UPLOAD_PRICE=${X402_UPLOAD_PRICE:-0.01}
- X402_FACILITATOR_URL=${X402_FACILITATOR_URL:-https://x402.org/facilitator}
- R2_ENDPOINT=${R2_ENDPOINT}
- R2_BUCKET=${R2_BUCKET:-rtube-videos}
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
@ -85,6 +91,7 @@ volumes:
rspace-books:
rspace-swag:
rspace-files:
rspace-splats:
rspace-pgdata:
networks:

View File

@ -0,0 +1,313 @@
/**
* <folk-splat-viewer> Gaussian splat gallery + 3D viewer web component.
*
* Gallery mode: card grid of splats with upload form.
* Viewer mode: full-viewport Three.js + GaussianSplats3D renderer.
*
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
*/
interface SplatItem {
id: string;
slug: string;
title: string;
description?: string;
file_format: string;
file_size_bytes: number;
view_count: number;
contributor_name?: string;
created_at: string;
}
export class FolkSplatViewer extends HTMLElement {
private _mode: "gallery" | "viewer" = "gallery";
private _splats: SplatItem[] = [];
private _spaceSlug = "demo";
private _splatUrl = "";
private _splatTitle = "";
private _splatDesc = "";
private _viewer: any = null;
static get observedAttributes() {
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
}
set splats(val: SplatItem[]) {
this._splats = val;
if (this._mode === "gallery") this.renderGallery();
}
set spaceSlug(val: string) {
this._spaceSlug = val;
}
connectedCallback() {
this._mode = (this.getAttribute("mode") as "gallery" | "viewer") || "gallery";
this._splatUrl = this.getAttribute("splat-url") || "";
this._splatTitle = this.getAttribute("splat-title") || "";
this._splatDesc = this.getAttribute("splat-desc") || "";
this._spaceSlug = this.getAttribute("space-slug") || "demo";
if (this._mode === "viewer") {
this.renderViewer();
} else {
this.renderGallery();
}
}
disconnectedCallback() {
if (this._viewer) {
try { this._viewer.dispose(); } catch {}
this._viewer = null;
}
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === "mode") this._mode = val as "gallery" | "viewer";
if (name === "splat-url") this._splatUrl = val;
if (name === "splat-title") this._splatTitle = val;
if (name === "splat-desc") this._splatDesc = val;
if (name === "space-slug") this._spaceSlug = val;
}
// ── Gallery ──
private renderGallery() {
const cards = this._splats.map((s) => `
<a class="splat-card" href="/${this._spaceSlug}/splat/view/${s.slug}">
<div class="splat-card__preview">
<span>🔮</span>
</div>
<div class="splat-card__body">
<div class="splat-card__title">${esc(s.title)}</div>
<div class="splat-card__meta">
<span class="splat-badge splat-badge--${s.file_format}">${s.file_format}</span>
<span>${formatSize(s.file_size_bytes)}</span>
<span>${s.view_count} views</span>
</div>
</div>
</a>
`).join("");
const empty = this._splats.length === 0 ? `
<div class="splat-empty">
<div class="splat-empty__icon">🔮</div>
<h3>No splats yet</h3>
<p>Upload a .ply, .splat, or .spz file to get started</p>
</div>
` : "";
this.innerHTML = `
<div class="splat-gallery">
<h1>rSplat</h1>
<p class="splat-gallery__subtitle">3D Gaussian Splat Gallery</p>
${empty}
<div class="splat-grid">${cards}</div>
<div class="splat-upload" id="splat-drop">
<div class="splat-upload__icon">📤</div>
<p class="splat-upload__text">
Drag &amp; drop a <strong>.ply</strong>, <strong>.splat</strong>, or <strong>.spz</strong> file here
<br>or <strong id="splat-browse">browse</strong> to upload
</p>
<input type="file" id="splat-file" accept=".ply,.splat,.spz" hidden>
<div class="splat-upload__form" id="splat-form">
<input type="text" id="splat-title-input" placeholder="Title (required)" required>
<textarea id="splat-desc-input" placeholder="Description (optional)" rows="2"></textarea>
<input type="text" id="splat-tags-input" placeholder="Tags (comma-separated)">
<button class="splat-upload__btn" id="splat-submit" disabled>Upload Splat</button>
<div class="splat-upload__status" id="splat-status"></div>
</div>
</div>
</div>
`;
this.setupUploadHandlers();
}
private setupUploadHandlers() {
const drop = this.querySelector("#splat-drop") as HTMLElement;
const fileInput = this.querySelector("#splat-file") as HTMLInputElement;
const browse = this.querySelector("#splat-browse") as HTMLElement;
const form = this.querySelector("#splat-form") as HTMLElement;
const titleInput = this.querySelector("#splat-title-input") as HTMLInputElement;
const descInput = this.querySelector("#splat-desc-input") as HTMLTextAreaElement;
const tagsInput = this.querySelector("#splat-tags-input") as HTMLInputElement;
const submitBtn = this.querySelector("#splat-submit") as HTMLButtonElement;
const status = this.querySelector("#splat-status") as HTMLElement;
if (!drop || !fileInput) return;
let selectedFile: File | null = null;
browse?.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
if (fileInput.files?.[0]) {
selectedFile = fileInput.files[0];
form.classList.add("active");
// Auto-populate title from filename
const name = selectedFile.name.replace(/\.(ply|splat|spz)$/i, "");
titleInput.value = name.replace(/[-_]/g, " ");
titleInput.dispatchEvent(new Event("input"));
}
});
drop.addEventListener("dragover", (e) => {
e.preventDefault();
drop.classList.add("splat-upload--dragover");
});
drop.addEventListener("dragleave", () => {
drop.classList.remove("splat-upload--dragover");
});
drop.addEventListener("drop", (e) => {
e.preventDefault();
drop.classList.remove("splat-upload--dragover");
const file = e.dataTransfer?.files[0];
if (file && /\.(ply|splat|spz)$/i.test(file.name)) {
selectedFile = file;
form.classList.add("active");
const name = file.name.replace(/\.(ply|splat|spz)$/i, "");
titleInput.value = name.replace(/[-_]/g, " ");
titleInput.dispatchEvent(new Event("input"));
}
});
titleInput?.addEventListener("input", () => {
submitBtn.disabled = !titleInput.value.trim() || !selectedFile;
});
submitBtn?.addEventListener("click", async () => {
if (!selectedFile || !titleInput.value.trim()) return;
submitBtn.disabled = true;
status.textContent = "Uploading...";
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("title", titleInput.value.trim());
formData.append("description", descInput.value.trim());
formData.append("tags", tagsInput.value.trim());
try {
const token = localStorage.getItem("encryptid_token") || "";
const res = await fetch(`/${this._spaceSlug}/splat/api/splats`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (res.status === 402) {
status.textContent = "Payment required for upload (x402)";
submitBtn.disabled = false;
return;
}
if (res.status === 401) {
status.textContent = "Sign in with rStack Identity to upload";
submitBtn.disabled = false;
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Upload failed" }));
status.textContent = (err as any).error || "Upload failed";
submitBtn.disabled = false;
return;
}
const splat = await res.json() as SplatItem;
status.textContent = "Uploaded!";
// Navigate to viewer
setTimeout(() => {
window.location.href = `/${this._spaceSlug}/splat/view/${splat.slug}`;
}, 500);
} catch (e) {
status.textContent = "Network error";
submitBtn.disabled = false;
}
});
}
// ── Viewer ──
private renderViewer() {
this.innerHTML = `
<div class="splat-viewer">
<div class="splat-loading" id="splat-loading">
<div class="splat-loading__spinner"></div>
<div class="splat-loading__text">Loading splat...</div>
</div>
<div class="splat-viewer__controls">
<a class="splat-viewer__back" href="/${this._spaceSlug}/splat"> Gallery</a>
</div>
${this._splatTitle ? `
<div class="splat-viewer__info">
<p class="splat-viewer__title">${esc(this._splatTitle)}</p>
${this._splatDesc ? `<p class="splat-viewer__desc">${esc(this._splatDesc)}</p>` : ""}
</div>
` : ""}
<div id="splat-container" class="splat-viewer__canvas"></div>
</div>
`;
this.initThreeViewer();
}
private async initThreeViewer() {
const container = this.querySelector("#splat-container") as HTMLElement;
const loading = this.querySelector("#splat-loading") as HTMLElement;
if (!container || !this._splatUrl) return;
try {
// Dynamic import from CDN (via importmap)
const THREE = await import("three");
const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d");
const viewer = new GaussianSplats3D.Viewer({
cameraUp: [0, -1, 0],
initialCameraPosition: [1, 0.5, 1],
initialCameraLookAt: [0, 0, 0],
rootElement: container,
sharedMemoryForWorkers: false,
});
this._viewer = viewer;
await viewer.addSplatScene(this._splatUrl, {
showLoadingUI: false,
progressiveLoad: true,
});
viewer.start();
if (loading) {
loading.classList.add("hidden");
}
} catch (e) {
console.error("[rSplat] Viewer init error:", e);
if (loading) {
const text = loading.querySelector(".splat-loading__text");
if (text) text.textContent = `Error loading splat: ${(e as Error).message}`;
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
if (spinner) spinner.style.display = "none";
}
}
}
}
// ── Helpers ──
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
customElements.define("folk-splat-viewer", FolkSplatViewer);

View File

@ -0,0 +1,352 @@
/* rSplat — Gaussian Splat Viewer */
:root {
--splat-bg: #0f172a;
--splat-surface: #1e293b;
--splat-border: #334155;
--splat-text: #e2e8f0;
--splat-text-muted: #94a3b8;
--splat-accent: #818cf8;
--splat-accent-hover: #6366f1;
--splat-card-bg: rgba(30, 41, 59, 0.8);
}
/* ── Gallery ── */
.splat-gallery {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
color: var(--splat-text);
min-height: calc(100vh - 56px);
background: var(--splat-bg);
}
.splat-gallery h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.splat-gallery__subtitle {
color: var(--splat-text-muted);
font-size: 0.875rem;
margin: 0 0 2rem;
}
.splat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
/* ── Card ── */
.splat-card {
background: var(--splat-card-bg);
border: 1px solid var(--splat-border);
border-radius: 12px;
overflow: hidden;
text-decoration: none;
color: inherit;
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
backdrop-filter: blur(8px);
}
.splat-card:hover {
transform: translateY(-2px);
border-color: var(--splat-accent);
box-shadow: 0 8px 24px rgba(129, 140, 248, 0.15);
}
.splat-card__preview {
height: 160px;
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #1e293b 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
position: relative;
}
.splat-card__body {
padding: 1rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.splat-card__title {
font-weight: 600;
font-size: 1rem;
line-height: 1.3;
}
.splat-card__meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--splat-text-muted);
}
.splat-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.splat-badge--ply { background: #4338ca; color: #e0e7ff; }
.splat-badge--splat { background: #7c3aed; color: #ede9fe; }
.splat-badge--spz { background: #2563eb; color: #dbeafe; }
/* ── Upload ── */
.splat-upload {
margin-top: 2rem;
border: 2px dashed var(--splat-border);
border-radius: 12px;
padding: 2rem;
text-align: center;
transition: border-color 0.2s, background 0.2s;
}
.splat-upload:hover,
.splat-upload--dragover {
border-color: var(--splat-accent);
background: rgba(129, 140, 248, 0.05);
}
.splat-upload__icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
.splat-upload__text {
color: var(--splat-text-muted);
font-size: 0.875rem;
margin: 0 0 1rem;
}
.splat-upload__text strong {
color: var(--splat-accent);
cursor: pointer;
}
.splat-upload__form {
display: none;
flex-direction: column;
gap: 0.75rem;
max-width: 400px;
margin: 1rem auto 0;
}
.splat-upload__form.active {
display: flex;
}
.splat-upload__form input,
.splat-upload__form textarea {
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--splat-border);
background: var(--splat-surface);
color: var(--splat-text);
font-size: 0.875rem;
}
.splat-upload__form textarea {
resize: vertical;
min-height: 60px;
}
.splat-upload__btn {
padding: 0.625rem 1.25rem;
border-radius: 8px;
border: none;
background: var(--splat-accent);
color: white;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
}
.splat-upload__btn:hover {
background: var(--splat-accent-hover);
}
.splat-upload__btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.splat-upload__status {
font-size: 0.8rem;
color: var(--splat-text-muted);
}
.splat-upload__login {
color: var(--splat-text-muted);
font-size: 0.875rem;
}
.splat-upload__login a {
color: var(--splat-accent);
}
/* ── Viewer ── */
.splat-viewer {
position: relative;
width: 100%;
height: calc(100vh - 56px);
background: #111827;
overflow: hidden;
}
.splat-viewer__canvas {
width: 100%;
height: 100%;
display: block;
}
.splat-viewer__controls {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 10;
display: flex;
gap: 0.5rem;
}
.splat-viewer__back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: rgba(30, 41, 59, 0.85);
backdrop-filter: blur(8px);
color: var(--splat-text);
text-decoration: none;
font-size: 0.8rem;
border: 1px solid var(--splat-border);
transition: background 0.2s;
}
.splat-viewer__back:hover {
background: rgba(51, 65, 85, 0.9);
}
.splat-viewer__info {
position: absolute;
bottom: 1rem;
left: 1rem;
z-index: 10;
padding: 0.75rem 1rem;
border-radius: 8px;
background: rgba(30, 41, 59, 0.85);
backdrop-filter: blur(8px);
border: 1px solid var(--splat-border);
color: var(--splat-text);
font-size: 0.8rem;
max-width: 320px;
}
.splat-viewer__title {
font-weight: 600;
margin: 0 0 0.25rem;
}
.splat-viewer__desc {
color: var(--splat-text-muted);
margin: 0;
}
/* ── Loading ── */
.splat-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #111827;
z-index: 20;
transition: opacity 0.4s;
}
.splat-loading.hidden {
opacity: 0;
pointer-events: none;
}
.splat-loading__spinner {
width: 48px;
height: 48px;
border: 3px solid var(--splat-border);
border-top-color: var(--splat-accent);
border-radius: 50%;
animation: splat-spin 0.8s linear infinite;
}
.splat-loading__text {
margin-top: 1rem;
color: var(--splat-text-muted);
font-size: 0.875rem;
}
@keyframes splat-spin {
to { transform: rotate(360deg); }
}
/* ── Empty state ── */
.splat-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--splat-text-muted);
}
.splat-empty__icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.splat-empty h3 {
font-size: 1.125rem;
color: var(--splat-text);
margin: 0 0 0.5rem;
}
.splat-empty p {
font-size: 0.875rem;
margin: 0;
}
/* ── Responsive ── */
@media (max-width: 640px) {
.splat-gallery {
padding: 1rem;
}
.splat-grid {
grid-template-columns: 1fr;
}
.splat-viewer__info {
left: 0.5rem;
right: 0.5rem;
max-width: none;
}
}

View File

@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS rsplat.splats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT,
file_path TEXT NOT NULL,
file_format TEXT NOT NULL DEFAULT 'ply',
file_size_bytes BIGINT DEFAULT 0,
tags TEXT[] DEFAULT '{}',
space_slug TEXT NOT NULL DEFAULT 'demo',
contributor_id TEXT,
contributor_name TEXT,
source TEXT DEFAULT 'upload',
status TEXT NOT NULL DEFAULT 'published',
view_count INTEGER DEFAULT 0,
payment_tx TEXT,
payment_network TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_splats_space ON rsplat.splats (space_slug);
CREATE INDEX IF NOT EXISTS idx_splats_slug ON rsplat.splats (slug);
CREATE INDEX IF NOT EXISTS idx_splats_status ON rsplat.splats (status);
CREATE INDEX IF NOT EXISTS idx_splats_created ON rsplat.splats (created_at DESC);

415
modules/splat/mod.ts Normal file
View File

@ -0,0 +1,415 @@
/**
* 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();

View File

@ -24,7 +24,9 @@
"perfect-freehand": "^1.2.2",
"@aws-sdk/client-s3": "^3.700.0",
"imapflow": "^1.0.170",
"mailparser": "^3.7.2"
"mailparser": "^3.7.2",
"@x402/core": "^2.3.1",
"@x402/evm": "^2.3.1"
},
"devDependencies": {
"@types/nodemailer": "^6.4.0",

View File

@ -60,6 +60,7 @@ import { tubeModule } from "../modules/tube/mod";
import { inboxModule } from "../modules/inbox/mod";
import { dataModule } from "../modules/data/mod";
import { conicModule } from "../modules/conic/mod";
import { splatModule } from "../modules/splat/mod";
import { spaces } from "./spaces";
import { renderShell } from "./shell";
@ -86,6 +87,7 @@ registerModule(tubeModule);
registerModule(inboxModule);
registerModule(dataModule);
registerModule(conicModule);
registerModule(splatModule);
// ── Config ──
const PORT = Number(process.env.PORT) || 3000;

View File

@ -0,0 +1,104 @@
/**
* x402 Hono middleware reusable payment gate for rSpace modules.
*
* When X402_PAY_TO env is set, protects routes with x402 micro-transactions.
* When not set, acts as a no-op passthrough.
*/
import type { Context, Next, MiddlewareHandler } from "hono";
export interface X402Config {
payTo: string;
network: string;
amount: string;
facilitatorUrl: string;
resource?: string;
description?: string;
}
/**
* Create x402 payment middleware for Hono routes.
* Returns null if X402_PAY_TO is not configured (disabled).
*/
export function createX402Middleware(config: X402Config): MiddlewareHandler {
return async (c: Context, next: Next) => {
const paymentHeader = c.req.header("X-PAYMENT");
if (!paymentHeader) {
// Return 402 with payment requirements
const requirements = {
x402Version: 1,
scheme: "exact",
network: config.network,
maxAmountRequired: config.amount,
resource: config.resource || c.req.url,
description: config.description || "Payment required for upload",
payTo: config.payTo,
maxTimeoutSeconds: 300,
};
return c.json(
{ error: "Payment Required", paymentRequirements: requirements },
402,
{ "X-PAYMENT-REQUIREMENTS": JSON.stringify(requirements) }
);
}
// Verify payment via facilitator
try {
const verifyRes = await fetch(`${config.facilitatorUrl}/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payment: paymentHeader,
requirements: {
scheme: "exact",
network: config.network,
maxAmountRequired: config.amount,
payTo: config.payTo,
},
}),
});
if (!verifyRes.ok) {
const err = await verifyRes.text();
return c.json({ error: "Payment verification failed", details: err }, 402);
}
const result = await verifyRes.json() as { valid?: boolean };
if (!result.valid) {
return c.json({ error: "Payment invalid or insufficient" }, 402);
}
// Payment valid — store tx info for downstream handlers
c.set("x402Payment", paymentHeader);
await next();
} catch (e) {
console.error("[x402] Verification error:", e);
return c.json({ error: "Payment verification service unavailable" }, 503);
}
};
}
/**
* Initialize x402 from environment variables.
* Returns middleware or null if disabled.
*/
export function setupX402FromEnv(overrides?: Partial<X402Config>): MiddlewareHandler | null {
const payTo = process.env.X402_PAY_TO;
if (!payTo) {
console.log("[x402] Disabled — X402_PAY_TO not set");
return null;
}
const config: X402Config = {
payTo,
network: process.env.X402_NETWORK || "eip155:84532",
amount: process.env.X402_UPLOAD_PRICE || "0.01",
facilitatorUrl: process.env.X402_FACILITATOR_URL || "https://x402.org/facilitator",
...overrides,
};
console.log(`[x402] Enabled — payTo=${payTo}, network=${config.network}, amount=${config.amount}`);
return createX402Middleware(config);
}

41
shared/x402/types.d.ts vendored Normal file
View File

@ -0,0 +1,41 @@
declare module "@x402/core" {
export interface PaymentRequirements {
scheme: string;
network: string;
maxAmountRequired: string;
resource: string;
description: string;
mimeType?: string;
payTo: string;
maxTimeoutSeconds?: number;
outputSchema?: unknown;
extra?: Record<string, unknown>;
}
export function exact(
payTo: string,
amount: string | number,
extra?: Record<string, unknown>
): PaymentRequirements;
}
declare module "@x402/core/types" {
export interface PaymentPayload {
x402Version: number;
scheme: string;
network: string;
payload: unknown;
}
}
declare module "@x402/core/verify" {
export function verifyPayment(
payment: string,
requirements: import("@x402/core").PaymentRequirements,
opts?: { facilitatorUrl?: string }
): Promise<{ valid: boolean; error?: string }>;
}
declare module "@x402/evm" {
export function getEvmPaymentSchemes(): unknown[];
}

View File

@ -659,6 +659,34 @@ export default defineConfig({
resolve(__dirname, "modules/conic/components/conic.css"),
resolve(__dirname, "dist/modules/conic/conic.css"),
);
// Build splat module component
await build({
configFile: false,
root: resolve(__dirname, "modules/splat/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/splat"),
lib: {
entry: resolve(__dirname, "modules/splat/components/folk-splat-viewer.ts"),
formats: ["es"],
fileName: () => "folk-splat-viewer.js",
},
rollupOptions: {
external: ["three", "three/addons/", "@mkkellogg/gaussian-splats-3d"],
output: {
entryFileNames: "folk-splat-viewer.js",
},
},
},
});
// Copy splat CSS
mkdirSync(resolve(__dirname, "dist/modules/splat"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/splat/components/splat.css"),
resolve(__dirname, "dist/modules/splat/splat.css"),
);
},
},
},