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:
parent
3c986f1709
commit
4afce2dc37
|
|
@ -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,6 +129,13 @@ 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__toggle">
|
||||||
|
<button class="splat-upload__toggle-btn active" data-mode="splat">Upload Splat</button>
|
||||||
|
<button class="splat-upload__toggle-btn" data-mode="media">Upload Photos/Video</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Splat upload mode -->
|
||||||
|
<div class="splat-upload__mode" id="splat-mode-splat">
|
||||||
<div class="splat-upload__icon">📤</div>
|
<div class="splat-upload__icon">📤</div>
|
||||||
<p class="splat-upload__text">
|
<p class="splat-upload__text">
|
||||||
Drag & drop a <strong>.ply</strong>, <strong>.splat</strong>, or <strong>.spz</strong> file here
|
Drag & drop a <strong>.ply</strong>, <strong>.splat</strong>, or <strong>.spz</strong> file here
|
||||||
|
|
@ -118,10 +150,48 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
<div class="splat-upload__status" id="splat-status"></div>
|
<div class="splat-upload__status" id="splat-status"></div>
|
||||||
</div>
|
</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>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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() {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue