475 lines
18 KiB
TypeScript
475 lines
18 KiB
TypeScript
/**
|
|
* <folk-splat-viewer> — Gaussian splat gallery + 3D viewer web component.
|
|
*
|
|
* Gallery mode: card grid of splats with upload form (splat files or photos/video).
|
|
* 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;
|
|
processing_status?: string;
|
|
source_file_count?: number;
|
|
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;
|
|
private _uploadMode: "splat" | "media" = "splat";
|
|
|
|
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) => {
|
|
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">
|
|
${overlay}
|
|
<span>${isReady ? "🔮" : "📸"}</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>
|
|
${isReady ? `<span>${formatSize(s.file_size_bytes)}</span>` : ""}
|
|
${sourceInfo}
|
|
</div>
|
|
</div>
|
|
</${tag}>
|
|
`;
|
|
}).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 — or photos/video to generate one</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__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>
|
|
<p class="splat-upload__text">
|
|
Drag & 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>
|
|
|
|
<!-- 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>
|
|
`;
|
|
|
|
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() {
|
|
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");
|
|
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 (this._uploadMode === "splat" && 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!";
|
|
setTimeout(() => {
|
|
window.location.href = `/${this._spaceSlug}/splat/view/${splat.slug}`;
|
|
}, 500);
|
|
} catch (e) {
|
|
status.textContent = "Network error";
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
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 ──
|
|
|
|
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 GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d");
|
|
|
|
const viewer = new GaussianSplats3D.Viewer({
|
|
cameraUp: [0, 1, 0],
|
|
initialCameraPosition: [5, 3, 5],
|
|
initialCameraLookAt: [0, 0, 0],
|
|
rootElement: container,
|
|
sharedMemoryForWorkers: false,
|
|
});
|
|
|
|
this._viewer = viewer;
|
|
|
|
viewer.addSplatScene(this._splatUrl, {
|
|
showLoadingUI: false,
|
|
progressiveLoad: true,
|
|
})
|
|
.then(() => {
|
|
viewer.start();
|
|
if (loading) loading.classList.add("hidden");
|
|
})
|
|
.catch((e: Error) => {
|
|
console.error("[rSplat] Scene load error:", e);
|
|
if (loading) {
|
|
const text = loading.querySelector(".splat-loading__text");
|
|
if (text) text.textContent = `Error: ${e.message}`;
|
|
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
|
|
if (spinner) spinner.style.display = "none";
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error("[rSplat] Viewer init error:", e);
|
|
if (loading) {
|
|
const text = loading.querySelector(".splat-loading__text");
|
|
if (text) text.textContent = `Error loading viewer: ${(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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
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);
|