Add sample splats + photo/video upload pipeline for rSplat

Seed gallery with real Bonsai and Nike Shoe splat scenes from Hugging Face,
replacing the synthetic Rainbow Sphere. Add photo/video upload endpoint
(POST /api/splats/from-media) with processing status tracking for future
COLMAP + OpenSplat generation. Gallery now shows upload mode toggle
(splat file vs photos/video) and processing status overlays on cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-22 03:27:41 +00:00
parent 3c986f1709
commit 4afce2dc37
4 changed files with 446 additions and 26 deletions

View File

@ -1,7 +1,7 @@
/** /**
* <folk-splat-viewer> Gaussian splat gallery + 3D viewer web component. * <folk-splat-viewer> Gaussian splat gallery + 3D viewer web component.
* *
* Gallery mode: card grid of splats with upload form. * Gallery mode: card grid of splats with upload form (splat files or photos/video).
* Viewer mode: full-viewport Three.js + GaussianSplats3D renderer. * Viewer mode: full-viewport Three.js + GaussianSplats3D renderer.
* *
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled). * Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
@ -16,6 +16,8 @@ interface SplatItem {
file_size_bytes: number; file_size_bytes: number;
view_count: number; view_count: number;
contributor_name?: string; contributor_name?: string;
processing_status?: string;
source_file_count?: number;
created_at: string; created_at: string;
} }
@ -27,6 +29,7 @@ export class FolkSplatViewer extends HTMLElement {
private _splatTitle = ""; private _splatTitle = "";
private _splatDesc = ""; private _splatDesc = "";
private _viewer: any = null; private _viewer: any = null;
private _uploadMode: "splat" | "media" = "splat";
static get observedAttributes() { static get observedAttributes() {
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"]; return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
@ -73,27 +76,49 @@ export class FolkSplatViewer extends HTMLElement {
// ── Gallery ── // ── Gallery ──
private renderGallery() { private renderGallery() {
const cards = this._splats.map((s) => ` const cards = this._splats.map((s) => {
<a class="splat-card" href="/${this._spaceSlug}/splat/view/${s.slug}"> const status = s.processing_status || "ready";
const isReady = status === "ready";
const tag = isReady ? "a" : "div";
const href = isReady ? ` href="/${this._spaceSlug}/splat/view/${s.slug}"` : "";
const statusClass = !isReady ? ` splat-card--${status}` : "";
let overlay = "";
if (status === "pending") {
overlay = `<div class="splat-card__overlay"><span class="splat-badge splat-badge--pending">Queued</span></div>`;
} else if (status === "processing") {
overlay = `<div class="splat-card__overlay"><div class="splat-card__spinner"></div><span class="splat-badge splat-badge--processing">Generating...</span></div>`;
} else if (status === "failed") {
overlay = `<div class="splat-card__overlay"><span class="splat-badge splat-badge--failed">Failed</span></div>`;
}
const sourceInfo = !isReady && s.source_file_count
? `<span>${s.source_file_count} source file${s.source_file_count > 1 ? "s" : ""}</span>`
: `<span>${s.view_count} views</span>`;
return `
<${tag} class="splat-card${statusClass}"${href}>
<div class="splat-card__preview"> <div class="splat-card__preview">
<span>🔮</span> ${overlay}
<span>${isReady ? "🔮" : "📸"}</span>
</div> </div>
<div class="splat-card__body"> <div class="splat-card__body">
<div class="splat-card__title">${esc(s.title)}</div> <div class="splat-card__title">${esc(s.title)}</div>
<div class="splat-card__meta"> <div class="splat-card__meta">
<span class="splat-badge splat-badge--${s.file_format}">${s.file_format}</span> <span class="splat-badge splat-badge--${s.file_format}">${s.file_format}</span>
<span>${formatSize(s.file_size_bytes)}</span> ${isReady ? `<span>${formatSize(s.file_size_bytes)}</span>` : ""}
<span>${s.view_count} views</span> ${sourceInfo}
</div> </div>
</div> </div>
</a> </${tag}>
`).join(""); `;
}).join("");
const empty = this._splats.length === 0 ? ` const empty = this._splats.length === 0 ? `
<div class="splat-empty"> <div class="splat-empty">
<div class="splat-empty__icon">🔮</div> <div class="splat-empty__icon">🔮</div>
<h3>No splats yet</h3> <h3>No splats yet</h3>
<p>Upload a .ply, .splat, or .spz file to get started</p> <p>Upload a .ply, .splat, or .spz file or photos/video to generate one</p>
</div> </div>
` : ""; ` : "";
@ -104,24 +129,69 @@ export class FolkSplatViewer extends HTMLElement {
${empty} ${empty}
<div class="splat-grid">${cards}</div> <div class="splat-grid">${cards}</div>
<div class="splat-upload" id="splat-drop"> <div class="splat-upload" id="splat-drop">
<div class="splat-upload__icon">📤</div> <div class="splat-upload__toggle">
<p class="splat-upload__text"> <button class="splat-upload__toggle-btn active" data-mode="splat">Upload Splat</button>
Drag &amp; drop a <strong>.ply</strong>, <strong>.splat</strong>, or <strong>.spz</strong> file here <button class="splat-upload__toggle-btn" data-mode="media">Upload Photos/Video</button>
<br>or <strong id="splat-browse">browse</strong> to upload </div>
</p>
<input type="file" id="splat-file" accept=".ply,.splat,.spz" hidden> <!-- Splat upload mode -->
<div class="splat-upload__form" id="splat-form"> <div class="splat-upload__mode" id="splat-mode-splat">
<input type="text" id="splat-title-input" placeholder="Title (required)" required> <div class="splat-upload__icon">📤</div>
<textarea id="splat-desc-input" placeholder="Description (optional)" rows="2"></textarea> <p class="splat-upload__text">
<input type="text" id="splat-tags-input" placeholder="Tags (comma-separated)"> Drag &amp; drop a <strong>.ply</strong>, <strong>.splat</strong>, or <strong>.spz</strong> file here
<button class="splat-upload__btn" id="splat-submit" disabled>Upload Splat</button> <br>or <strong id="splat-browse">browse</strong> to upload
<div class="splat-upload__status" id="splat-status"></div> </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>
<!-- Media upload mode -->
<div class="splat-upload__mode" id="splat-mode-media" style="display:none">
<div class="splat-upload__icon">📸</div>
<p class="splat-upload__text">
Upload <strong>photos</strong> (up to 100) or a <strong>video</strong> to generate a 3D splat
<br>Accepted: .jpg, .png, .heic, .mp4, .mov, .webm
<br>or <strong id="media-browse">browse</strong> to select files
</p>
<input type="file" id="media-files" accept=".jpg,.jpeg,.png,.heic,.mp4,.mov,.webm" multiple hidden>
<div class="splat-upload__selected" id="media-selected"></div>
<div class="splat-upload__form" id="media-form">
<input type="text" id="media-title-input" placeholder="Title (required)" required>
<textarea id="media-desc-input" placeholder="Description (optional)" rows="2"></textarea>
<input type="text" id="media-tags-input" placeholder="Tags (comma-separated)">
<button class="splat-upload__btn" id="media-submit" disabled>Upload for Processing</button>
<div class="splat-upload__status" id="media-status"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
`; `;
this.setupUploadHandlers(); this.setupUploadHandlers();
this.setupMediaHandlers();
this.setupToggle();
}
private setupToggle() {
const buttons = this.querySelectorAll<HTMLButtonElement>(".splat-upload__toggle-btn");
const splatMode = this.querySelector("#splat-mode-splat") as HTMLElement;
const mediaMode = this.querySelector("#splat-mode-media") as HTMLElement;
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
const mode = btn.dataset.mode as "splat" | "media";
this._uploadMode = mode;
buttons.forEach((b) => b.classList.toggle("active", b.dataset.mode === mode));
splatMode.style.display = mode === "splat" ? "" : "none";
mediaMode.style.display = mode === "media" ? "" : "none";
});
});
} }
private setupUploadHandlers() { private setupUploadHandlers() {
@ -145,7 +215,6 @@ export class FolkSplatViewer extends HTMLElement {
if (fileInput.files?.[0]) { if (fileInput.files?.[0]) {
selectedFile = fileInput.files[0]; selectedFile = fileInput.files[0];
form.classList.add("active"); form.classList.add("active");
// Auto-populate title from filename
const name = selectedFile.name.replace(/\.(ply|splat|spz)$/i, ""); const name = selectedFile.name.replace(/\.(ply|splat|spz)$/i, "");
titleInput.value = name.replace(/[-_]/g, " "); titleInput.value = name.replace(/[-_]/g, " ");
titleInput.dispatchEvent(new Event("input")); titleInput.dispatchEvent(new Event("input"));
@ -165,7 +234,7 @@ export class FolkSplatViewer extends HTMLElement {
e.preventDefault(); e.preventDefault();
drop.classList.remove("splat-upload--dragover"); drop.classList.remove("splat-upload--dragover");
const file = e.dataTransfer?.files[0]; const file = e.dataTransfer?.files[0];
if (file && /\.(ply|splat|spz)$/i.test(file.name)) { if (this._uploadMode === "splat" && file && /\.(ply|splat|spz)$/i.test(file.name)) {
selectedFile = file; selectedFile = file;
form.classList.add("active"); form.classList.add("active");
const name = file.name.replace(/\.(ply|splat|spz)$/i, ""); const name = file.name.replace(/\.(ply|splat|spz)$/i, "");
@ -219,7 +288,6 @@ export class FolkSplatViewer extends HTMLElement {
const splat = await res.json() as SplatItem; const splat = await res.json() as SplatItem;
status.textContent = "Uploaded!"; status.textContent = "Uploaded!";
// Navigate to viewer
setTimeout(() => { setTimeout(() => {
window.location.href = `/${this._spaceSlug}/splat/view/${splat.slug}`; window.location.href = `/${this._spaceSlug}/splat/view/${splat.slug}`;
}, 500); }, 500);
@ -230,6 +298,93 @@ export class FolkSplatViewer extends HTMLElement {
}); });
} }
private setupMediaHandlers() {
const browse = this.querySelector("#media-browse") as HTMLElement;
const fileInput = this.querySelector("#media-files") as HTMLInputElement;
const form = this.querySelector("#media-form") as HTMLElement;
const selected = this.querySelector("#media-selected") as HTMLElement;
const titleInput = this.querySelector("#media-title-input") as HTMLInputElement;
const descInput = this.querySelector("#media-desc-input") as HTMLTextAreaElement;
const tagsInput = this.querySelector("#media-tags-input") as HTMLInputElement;
const submitBtn = this.querySelector("#media-submit") as HTMLButtonElement;
const status = this.querySelector("#media-status") as HTMLElement;
if (!fileInput) return;
let selectedFiles: File[] = [];
browse?.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
if (fileInput.files && fileInput.files.length > 0) {
selectedFiles = Array.from(fileInput.files);
form.classList.add("active");
const totalSize = selectedFiles.reduce((sum, f) => sum + f.size, 0);
selected.innerHTML = `<div class="splat-upload__file-count">${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""} selected (${formatSize(totalSize)})</div>`;
if (!titleInput.value.trim() && selectedFiles.length > 0) {
const name = selectedFiles[0].name.replace(/\.[^.]+$/, "");
titleInput.value = name.replace(/[-_]/g, " ");
}
titleInput.dispatchEvent(new Event("input"));
}
});
titleInput?.addEventListener("input", () => {
submitBtn.disabled = !titleInput.value.trim() || selectedFiles.length === 0;
});
submitBtn?.addEventListener("click", async () => {
if (selectedFiles.length === 0 || !titleInput.value.trim()) return;
submitBtn.disabled = true;
status.textContent = "Uploading...";
const formData = new FormData();
for (const f of selectedFiles) {
formData.append("files", f);
}
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/from-media`, {
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;
}
status.textContent = "Uploaded! Queued for processing.";
setTimeout(() => {
window.location.href = `/${this._spaceSlug}/splat`;
}, 1000);
} catch (e) {
status.textContent = "Network error";
submitBtn.disabled = false;
}
});
}
// ── Viewer ── // ── Viewer ──
private renderViewer() { private renderViewer() {

View File

@ -107,6 +107,64 @@
.splat-badge--splat { background: #7c3aed; color: #ede9fe; } .splat-badge--splat { background: #7c3aed; color: #ede9fe; }
.splat-badge--spz { background: #2563eb; color: #dbeafe; } .splat-badge--spz { background: #2563eb; color: #dbeafe; }
/* ── Processing status badges ── */
.splat-badge--pending { background: #92400e; color: #fef3c7; }
.splat-badge--processing { background: #1e40af; color: #dbeafe; }
.splat-badge--failed { background: #991b1b; color: #fecaca; }
/* ── Processing card variants ── */
.splat-card--pending,
.splat-card--processing,
.splat-card--failed {
cursor: default;
}
.splat-card--pending:hover,
.splat-card--processing:hover,
.splat-card--failed:hover {
transform: none;
border-color: var(--splat-border);
box-shadow: none;
}
.splat-card--pending .splat-card__preview {
background: linear-gradient(135deg, #451a03 0%, #78350f 50%, #1e293b 100%);
}
.splat-card--processing .splat-card__preview {
background: linear-gradient(135deg, #1e1b4b 0%, #1e3a5f 50%, #1e293b 100%);
}
.splat-card--failed .splat-card__preview {
background: linear-gradient(135deg, #450a0a 0%, #7f1d1d 50%, #1e293b 100%);
}
/* ── Processing overlay ── */
.splat-card__overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
gap: 0.5rem;
z-index: 2;
}
.splat-card__spinner {
width: 28px;
height: 28px;
border: 2px solid rgba(147, 197, 253, 0.3);
border-top-color: #93c5fd;
border-radius: 50%;
animation: splat-spin 0.8s linear infinite;
}
/* ── Upload ── */ /* ── Upload ── */
.splat-upload { .splat-upload {
@ -140,6 +198,42 @@
cursor: pointer; cursor: pointer;
} }
/* ── Upload mode toggle ── */
.splat-upload__toggle {
display: inline-flex;
border-radius: 9999px;
overflow: hidden;
border: 1px solid var(--splat-border);
margin-bottom: 1.5rem;
}
.splat-upload__toggle-btn {
padding: 0.5rem 1.25rem;
border: none;
background: transparent;
color: var(--splat-text-muted);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.splat-upload__toggle-btn:hover {
color: var(--splat-text);
}
.splat-upload__toggle-btn.active {
background: var(--splat-accent);
color: white;
}
/* ── Upload modes ── */
.splat-upload__mode {
/* shown/hidden via JS display toggle */
}
.splat-upload__form { .splat-upload__form {
display: none; display: none;
flex-direction: column; flex-direction: column;
@ -202,6 +296,23 @@
color: var(--splat-accent); color: var(--splat-accent);
} }
/* ── Media file count ── */
.splat-upload__selected {
min-height: 0;
}
.splat-upload__file-count {
display: inline-block;
padding: 0.375rem 0.75rem;
border-radius: 6px;
background: rgba(129, 140, 248, 0.15);
color: var(--splat-accent);
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
/* ── Viewer ── */ /* ── Viewer ── */
.splat-viewer { .splat-viewer {
@ -349,4 +460,9 @@
right: 0.5rem; right: 0.5rem;
max-width: none; max-width: none;
} }
.splat-upload__toggle-btn {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
} }

View File

@ -15,6 +15,9 @@ CREATE TABLE IF NOT EXISTS rsplat.splats (
view_count INTEGER DEFAULT 0, view_count INTEGER DEFAULT 0,
payment_tx TEXT, payment_tx TEXT,
payment_network TEXT, payment_network TEXT,
processing_status TEXT DEFAULT 'ready',
processing_error TEXT,
source_file_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
@ -22,3 +25,16 @@ 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_slug ON rsplat.splats (slug);
CREATE INDEX IF NOT EXISTS idx_splats_status ON rsplat.splats (status); 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); CREATE INDEX IF NOT EXISTS idx_splats_created ON rsplat.splats (created_at DESC);
-- Source files table (photos/video uploaded for splatting)
CREATE TABLE IF NOT EXISTS rsplat.source_files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
splat_id UUID REFERENCES rsplat.splats(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
mime_type TEXT,
file_size_bytes BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_source_files_splat ON rsplat.source_files (splat_id);

View File

@ -20,7 +20,15 @@ import {
import { setupX402FromEnv } from "../../shared/x402/hono-middleware"; import { setupX402FromEnv } from "../../shared/x402/hono-middleware";
const SPLATS_DIR = process.env.SPLATS_DIR || "/data/splats"; const SPLATS_DIR = process.env.SPLATS_DIR || "/data/splats";
const SOURCES_DIR = resolve(SPLATS_DIR, "sources");
const VALID_FORMATS = ["ply", "splat", "spz"]; 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 ── // ── Types ──
@ -41,6 +49,9 @@ export interface SplatRow {
view_count: number; view_count: number;
payment_tx: string | null; payment_tx: string | null;
payment_network: string | null; payment_network: string | null;
processing_status: string;
processing_error: string | null;
source_file_count: number;
created_at: string; created_at: string;
} }
@ -103,7 +114,7 @@ routes.get("/api/splats", async (c) => {
const offset = parseInt(c.req.query("offset") || "0"); const offset = parseInt(c.req.query("offset") || "0");
let query = `SELECT id, slug, title, description, file_format, file_size_bytes, let query = `SELECT id, slug, title, description, file_format, file_size_bytes,
tags, contributor_name, view_count, created_at tags, contributor_name, view_count, processing_status, source_file_count, created_at
FROM rsplat.splats WHERE status = 'published' AND space_slug = $1`; FROM rsplat.splats WHERE status = 'published' AND space_slug = $1`;
const params: (string | number)[] = [spaceSlug]; const params: (string | number)[] = [spaceSlug];
@ -253,6 +264,124 @@ routes.post("/api/splats", async (c) => {
return c.json(rows[0], 201); return c.json(rows[0], 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 instanceof File) {
files.push(value);
}
}
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
const existing = await sql.unsafe(
`SELECT 1 FROM rsplat.splats WHERE slug = $1`, [slug]
);
if (existing.length > 0) {
slug = `${slug}-${shortId}`;
}
// Save source files to disk
const sourceDir = resolve(SOURCES_DIR, slug);
await mkdir(sourceDir, { recursive: true });
const sourceRows: { path: string; name: string; mime: string; size: number }[] = [];
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);
sourceRows.push({
path: `sources/${slug}/${safeName}`,
name: f.name,
mime: f.type,
size: buffer.length,
});
}
// Insert splat record (pending processing)
const paymentTx = c.get("x402Payment") || null;
const splatRows = await sql.unsafe(
`INSERT INTO rsplat.splats (slug, title, description, file_path, file_format, file_size_bytes, tags, space_slug, contributor_id, contributor_name, source, processing_status, source_file_count, payment_tx)
VALUES ($1, $2, $3, '', 'ply', 0, $4, $5, $6, $7, 'media', 'pending', $8, $9)
RETURNING id, slug, title, description, file_format, tags, processing_status, source_file_count, created_at`,
[slug, title, description, tags, spaceSlug, claims.sub, claims.username || null, files.length, paymentTx]
);
const splatId = splatRows[0].id;
// Insert source file records
for (const sf of sourceRows) {
await sql.unsafe(
`INSERT INTO rsplat.source_files (splat_id, file_path, file_name, mime_type, file_size_bytes)
VALUES ($1, $2, $3, $4, $5)`,
[splatId, sf.path, sf.name, sf.mime, sf.size]
);
}
return c.json(splatRows[0], 201);
});
// ── API: Delete splat (owner only) ── // ── API: Delete splat (owner only) ──
routes.delete("/api/splats/:id", async (c) => { routes.delete("/api/splats/:id", async (c) => {
const token = extractToken(c.req.raw.headers); const token = extractToken(c.req.raw.headers);
@ -290,7 +419,7 @@ routes.get("/", async (c) => {
const rows = await sql.unsafe( const rows = await sql.unsafe(
`SELECT id, slug, title, description, file_format, file_size_bytes, `SELECT id, slug, title, description, file_format, file_size_bytes,
tags, contributor_name, view_count, created_at tags, contributor_name, view_count, processing_status, source_file_count, created_at
FROM rsplat.splats WHERE status = 'published' AND space_slug = $1 FROM rsplat.splats WHERE status = 'published' AND space_slug = $1
ORDER BY created_at DESC LIMIT 50`, ORDER BY created_at DESC LIMIT 50`,
[spaceSlug] [spaceSlug]
@ -391,6 +520,10 @@ async function initDB(): Promise<void> {
const schemaSql = await readFile(schemaPath, "utf-8"); const schemaSql = await readFile(schemaPath, "utf-8");
await sql.unsafe(`SET search_path TO rsplat, public`); await sql.unsafe(`SET search_path TO rsplat, public`);
await sql.unsafe(schemaSql); await sql.unsafe(schemaSql);
// Migration: add new columns to existing table
await sql.unsafe(`ALTER TABLE rsplat.splats ADD COLUMN IF NOT EXISTS processing_status TEXT DEFAULT 'ready'`);
await sql.unsafe(`ALTER TABLE rsplat.splats ADD COLUMN IF NOT EXISTS processing_error TEXT`);
await sql.unsafe(`ALTER TABLE rsplat.splats ADD COLUMN IF NOT EXISTS source_file_count INTEGER DEFAULT 0`);
await sql.unsafe(`SET search_path TO public`); await sql.unsafe(`SET search_path TO public`);
console.log("[Splat] Database schema initialized"); console.log("[Splat] Database schema initialized");
} catch (e) { } catch (e) {