996 lines
36 KiB
TypeScript
996 lines
36 KiB
TypeScript
/**
|
|
* <folk-splat-viewer> — Gaussian splat gallery + 3D viewer web component.
|
|
*
|
|
* Gallery mode: card grid of splats with upload form + AI generation.
|
|
* Viewer mode: full-viewport Three.js + GaussianSplats3D renderer.
|
|
*
|
|
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
|
|
*/
|
|
|
|
import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas";
|
|
import type { DocumentId } from "../../../shared/local-first/document";
|
|
|
|
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;
|
|
demoUrl?: 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" | "generate" = "splat";
|
|
private _inlineViewer = false;
|
|
private _offlineUnsub: (() => void) | null = null;
|
|
private _generatedUrl = "";
|
|
private _generatedTitle = "";
|
|
private _savedSlug = "";
|
|
private _myHistory: SplatItem[] = [];
|
|
|
|
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 {
|
|
if (this._spaceSlug === "demo") {
|
|
this.loadDemoData();
|
|
} else {
|
|
this.subscribeOffline();
|
|
}
|
|
this.loadMyHistory();
|
|
this.renderGallery();
|
|
}
|
|
}
|
|
|
|
private async subscribeOffline() {
|
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
|
if (!runtime?.isInitialized) return;
|
|
|
|
try {
|
|
const docId = splatScenesDocId(this._spaceSlug) as DocumentId;
|
|
const doc = await runtime.subscribe(docId, splatScenesSchema);
|
|
this.renderFromDoc(doc);
|
|
|
|
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
|
|
this.renderFromDoc(updated);
|
|
});
|
|
} catch {
|
|
// Runtime unavailable — server-side hydration handles data
|
|
}
|
|
}
|
|
|
|
private renderFromDoc(doc: SplatScenesDoc) {
|
|
if (!doc?.items || Object.keys(doc.items).length === 0) return;
|
|
if (this._splats.length > 0) return; // Don't clobber server-hydrated data
|
|
this._splats = Object.values(doc.items).map(s => ({
|
|
id: s.id, slug: s.slug, title: s.title, description: s.description,
|
|
file_format: s.fileFormat, file_size_bytes: s.fileSizeBytes,
|
|
view_count: s.viewCount, contributor_name: s.contributorName ?? undefined,
|
|
processing_status: s.processingStatus ?? undefined,
|
|
source_file_count: s.sourceFileCount,
|
|
created_at: new Date(s.createdAt).toISOString(),
|
|
}));
|
|
if (this._mode === "gallery") this.renderGallery();
|
|
}
|
|
|
|
private loadDemoData() {
|
|
this._splats = [
|
|
{ id: "s1", slug: "train", title: "Train", description: "Classic Gaussian splatting demo scene — a model train on a track.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1842, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/train.splat" },
|
|
{ id: "s2", slug: "truck", title: "Truck", description: "Photogrammetry capture of a pickup truck — 360° Gaussian splat reconstruction.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1536, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/truck.splat" },
|
|
{ id: "s3", slug: "garden", title: "Garden", description: "Outdoor garden scene — dense foliage and complex lighting captured as Gaussian splats.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1203, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/garden.splat" },
|
|
];
|
|
}
|
|
|
|
private async loadMyHistory() {
|
|
const token = this.getAuthToken();
|
|
if (!token || this._spaceSlug === "demo") return;
|
|
|
|
try {
|
|
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/my-history`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
this._myHistory = data.splats || [];
|
|
if (this._mode === "gallery") this.renderGallery();
|
|
}
|
|
} catch { /* non-critical */ }
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this._offlineUnsub?.();
|
|
this._offlineUnsub = null;
|
|
if (this._viewer) {
|
|
try { this._viewer.dispose(); } catch {}
|
|
this._viewer = null;
|
|
}
|
|
if ((this as any)._glbCleanup) {
|
|
try { (this as any)._glbCleanup(); } catch {}
|
|
(this as any)._glbCleanup = 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 renderCard(s: SplatItem): string {
|
|
const status = s.processing_status || "ready";
|
|
const isReady = status === "ready";
|
|
const isDemo = !!s.demoUrl;
|
|
const tag = isReady ? (isDemo ? "div" : "a") : "div";
|
|
const href = isReady && !isDemo ? ` href="/${this._spaceSlug}/rsplat/${s.slug}"` : "";
|
|
const demoAttr = isDemo ? ` data-demo-url="${esc(s.demoUrl!)}" data-demo-title="${esc(s.title)}" data-demo-desc="${esc(s.description || "")}" role="button" tabindex="0"` : "";
|
|
const statusClass = !isReady ? ` splat-card--${status}` : "";
|
|
const demoClass = isDemo ? " splat-card--demo" : "";
|
|
|
|
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}${demoClass}" data-collab-id="splat:${s.id}"${href}${demoAttr}>
|
|
<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}>
|
|
`;
|
|
}
|
|
|
|
private renderGallery() {
|
|
const cards = this._splats.map((s) => this.renderCard(s)).join("");
|
|
|
|
// My Models section
|
|
const myModelsHtml = this._myHistory.length > 0 ? `
|
|
<div class="splat-my-models">
|
|
<h2 class="splat-my-models__title">My Models</h2>
|
|
<div class="splat-grid">${this._myHistory.map((s) => this.renderCard(s)).join("")}</div>
|
|
</div>
|
|
` : "";
|
|
|
|
const empty = this._splats.length === 0 && this._myHistory.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">
|
|
<div class="splat-gallery__header">
|
|
<h1>rSplat</h1>
|
|
<p class="splat-gallery__subtitle">Explore and create 3D Gaussian splat scenes</p>
|
|
</div>
|
|
${myModelsHtml}
|
|
${empty}
|
|
${this._splats.length > 0 ? `<h2 class="splat-section-title">Gallery</h2>` : ""}
|
|
<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="generate">Generate from Image</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>
|
|
|
|
<!-- Generate from image mode -->
|
|
<div class="splat-upload__mode" id="splat-mode-generate" style="display:none">
|
|
<div class="splat-upload__icon">✨</div>
|
|
<p class="splat-upload__text">
|
|
Upload a single <strong>image</strong> to generate a 3D model using AI (Hunyuan3D)
|
|
<br>or <strong id="generate-browse">browse</strong> to select an image
|
|
</p>
|
|
<input type="file" id="generate-file" accept=".jpg,.jpeg,.png,.webp" hidden>
|
|
<div class="splat-generate__preview" id="generate-preview"></div>
|
|
<div class="splat-generate__actions" id="generate-actions" style="display:none">
|
|
<button class="splat-upload__btn" id="generate-submit">Generate 3D Model</button>
|
|
</div>
|
|
<div class="splat-generate__progress" id="generate-progress" style="display:none">
|
|
<div class="splat-generate__progress-bar"></div>
|
|
<div class="splat-generate__progress-text" id="generate-progress-text">Generating 3D model...</div>
|
|
</div>
|
|
<div class="splat-upload__status" id="generate-status"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.setupUploadHandlers();
|
|
this.setupGenerateHandlers();
|
|
this.setupToggle();
|
|
this.setupDemoCardHandlers();
|
|
}
|
|
|
|
private setupToggle() {
|
|
const buttons = this.querySelectorAll<HTMLButtonElement>(".splat-upload__toggle-btn");
|
|
const splatMode = this.querySelector("#splat-mode-splat") as HTMLElement;
|
|
const generateMode = this.querySelector("#splat-mode-generate") as HTMLElement;
|
|
|
|
buttons.forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const mode = btn.dataset.mode as "splat" | "generate";
|
|
this._uploadMode = mode;
|
|
buttons.forEach((b) => b.classList.toggle("active", b.dataset.mode === mode));
|
|
splatMode.style.display = mode === "splat" ? "" : "none";
|
|
if (generateMode) generateMode.style.display = mode === "generate" ? "" : "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 = this.getAuthToken();
|
|
const res = await fetch(`/${this._spaceSlug}/rsplat/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}/rsplat/${splat.slug}`;
|
|
}, 500);
|
|
} catch (e) {
|
|
status.textContent = "Network error";
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
private setupDemoCardHandlers() {
|
|
this.querySelectorAll<HTMLElement>(".splat-card--demo").forEach((card) => {
|
|
card.style.cursor = "pointer";
|
|
card.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
const url = card.dataset.demoUrl;
|
|
const title = card.dataset.demoTitle || "";
|
|
const desc = card.dataset.demoDesc || "";
|
|
if (!url) return;
|
|
this._mode = "viewer";
|
|
this._splatUrl = url;
|
|
this._splatTitle = title;
|
|
this._splatDesc = desc;
|
|
this._inlineViewer = true;
|
|
this.renderViewer();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Image staging (replaces canvas-based resizeImage) ──
|
|
|
|
private async stageImage(file: File): Promise<string> {
|
|
// Client-side HEIC detection
|
|
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
|
if (ext === "heic" || ext === "heif" || file.type === "image/heic" || file.type === "image/heif") {
|
|
throw new Error("HEIC files are not supported. Please convert to JPEG or PNG first.");
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
const res = await fetch("/api/image-stage", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: "Image upload failed" }));
|
|
throw new Error((err as any).error || "Image upload failed");
|
|
}
|
|
|
|
const data = await res.json() as { url: string };
|
|
return data.url;
|
|
}
|
|
|
|
private setupGenerateHandlers() {
|
|
const browse = this.querySelector("#generate-browse") as HTMLElement;
|
|
const fileInput = this.querySelector("#generate-file") as HTMLInputElement;
|
|
const preview = this.querySelector("#generate-preview") as HTMLElement;
|
|
const actions = this.querySelector("#generate-actions") as HTMLElement;
|
|
const submitBtn = this.querySelector("#generate-submit") as HTMLButtonElement;
|
|
const progress = this.querySelector("#generate-progress") as HTMLElement;
|
|
const progressText = this.querySelector("#generate-progress-text") as HTMLElement;
|
|
const status = this.querySelector("#generate-status") as HTMLElement;
|
|
|
|
if (!fileInput) return;
|
|
|
|
let selectedFile: File | null = null;
|
|
|
|
browse?.addEventListener("click", () => fileInput.click());
|
|
|
|
fileInput.addEventListener("change", () => {
|
|
if (fileInput.files?.[0]) {
|
|
selectedFile = fileInput.files[0];
|
|
|
|
// Client-side HEIC check before preview
|
|
const ext = selectedFile.name.split(".").pop()?.toLowerCase() || "";
|
|
if (ext === "heic" || ext === "heif") {
|
|
status.textContent = "HEIC files are not supported. Please use JPEG or PNG.";
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
preview.innerHTML = `<img src="${reader.result}" alt="Preview">`;
|
|
preview.style.display = "block";
|
|
actions.style.display = "flex";
|
|
status.textContent = "";
|
|
};
|
|
reader.readAsDataURL(selectedFile);
|
|
}
|
|
});
|
|
|
|
submitBtn?.addEventListener("click", async () => {
|
|
if (!selectedFile) return;
|
|
|
|
submitBtn.disabled = true;
|
|
actions.style.display = "none";
|
|
progress.style.display = "block";
|
|
|
|
// Elapsed time ticker — resilient to iOS background-tab suspension
|
|
const startTime = Date.now();
|
|
let hiddenTime = 0;
|
|
let hiddenAt = 0;
|
|
const phases = [
|
|
{ t: 0, msg: "Staging image..." },
|
|
{ t: 3, msg: "Uploading to Hunyuan3D..." },
|
|
{ t: 10, msg: "Reconstructing 3D geometry..." },
|
|
{ t: 30, msg: "Generating mesh and textures..." },
|
|
{ t: 60, msg: "Baking textures..." },
|
|
{ t: 120, msg: "Finalizing model..." },
|
|
{ t: 180, msg: "Almost there..." },
|
|
];
|
|
|
|
// Realistic progress curve — Hunyuan3D with textures takes 60-180s
|
|
const EXPECTED_SECONDS = 120;
|
|
const progressBar = progress.querySelector(".splat-generate__progress-bar") as HTMLElement;
|
|
|
|
const estimatePercent = (elapsed: number): number => {
|
|
if (elapsed <= 0) return 0;
|
|
// Logarithmic curve: fast start, slows toward 95% asymptote
|
|
const ratio = elapsed / EXPECTED_SECONDS;
|
|
return Math.min(95, 100 * (1 - Math.exp(-2.5 * ratio)));
|
|
};
|
|
|
|
const onVisChange = () => {
|
|
if (document.hidden) {
|
|
hiddenAt = Date.now();
|
|
} else if (hiddenAt) {
|
|
hiddenTime += Date.now() - hiddenAt;
|
|
hiddenAt = 0;
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", onVisChange);
|
|
|
|
const ticker = setInterval(() => {
|
|
if (document.hidden) return; // Don't update while hidden
|
|
const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000);
|
|
const phase = [...phases].reverse().find(p => elapsed >= p.t);
|
|
const pct = Math.round(estimatePercent(elapsed));
|
|
if (progressBar) progressBar.style.setProperty("--splat-progress", `${pct}%`);
|
|
if (progressText && phase) {
|
|
progressText.textContent = `${phase.msg} ${pct}% (${elapsed}s)`;
|
|
}
|
|
}, 500);
|
|
|
|
try {
|
|
const imageUrl = await this.stageImage(selectedFile!);
|
|
|
|
const res = await fetch("/api/3d-gen", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ image_url: imageUrl }),
|
|
});
|
|
clearInterval(ticker);
|
|
document.removeEventListener("visibilitychange", onVisChange);
|
|
|
|
if (res.status === 524 || res.status === 504) {
|
|
status.textContent = "Generation timed out — try a simpler image.";
|
|
progress.style.display = "none";
|
|
actions.style.display = "flex";
|
|
submitBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
if (res.status === 503) {
|
|
status.textContent = "AI generation not available — FAL_KEY not configured";
|
|
progress.style.display = "none";
|
|
actions.style.display = "flex";
|
|
submitBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: "Generation failed" }));
|
|
status.textContent = (err as any).error || "Generation failed";
|
|
progress.style.display = "none";
|
|
actions.style.display = "flex";
|
|
submitBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
const data = await res.json() as { url: string; format: string };
|
|
// Jump to 100% before hiding
|
|
if (progressBar) progressBar.style.setProperty("--splat-progress", "100%");
|
|
if (progressText) progressText.textContent = "Complete!";
|
|
await new Promise(r => setTimeout(r, 400));
|
|
progress.style.display = "none";
|
|
const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000);
|
|
status.textContent = `Generated in ${elapsed}s`;
|
|
|
|
// Store generated info for save-to-gallery
|
|
this._generatedUrl = data.url;
|
|
this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, "");
|
|
|
|
// Auto-save if authenticated
|
|
await this.autoSave();
|
|
|
|
// Open inline viewer with generated model
|
|
this._mode = "viewer";
|
|
this._splatUrl = data.url;
|
|
this._splatTitle = this._generatedTitle;
|
|
this._splatDesc = "AI-generated 3D model";
|
|
this._inlineViewer = true;
|
|
this.renderViewer();
|
|
} catch (e: any) {
|
|
clearInterval(ticker);
|
|
document.removeEventListener("visibilitychange", onVisChange);
|
|
if (e.name === "AbortError") {
|
|
status.textContent = "Request timed out — try a simpler image.";
|
|
} else {
|
|
status.textContent = e.message || "Network error — could not reach server";
|
|
}
|
|
progress.style.display = "none";
|
|
actions.style.display = "flex";
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Auto-save after generation ──
|
|
|
|
private async autoSave() {
|
|
const token = this.getAuthToken();
|
|
if (!token || !this._generatedUrl || this._spaceSlug === "demo") return;
|
|
|
|
try {
|
|
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
url: this._generatedUrl,
|
|
title: this._generatedTitle || "AI Generated Model",
|
|
description: "AI-generated 3D model via Hunyuan3D",
|
|
}),
|
|
});
|
|
|
|
if (res.ok) {
|
|
const data = await res.json() as { slug: string };
|
|
this._savedSlug = data.slug;
|
|
}
|
|
} catch { /* auto-save is best-effort */ }
|
|
}
|
|
|
|
// ── Viewer ──
|
|
|
|
private renderViewer() {
|
|
const backEl = this._inlineViewer
|
|
? `<button class="splat-viewer__back" id="splat-back-btn">← Back to Gallery</button>`
|
|
: `<a class="splat-viewer__back" href="/${this._spaceSlug}/rsplat">← Gallery</a>`;
|
|
|
|
// Show "View in Gallery" if auto-saved, otherwise "Save" if generated
|
|
let actionEl = "";
|
|
if (this._savedSlug) {
|
|
actionEl = `<a class="splat-viewer__save" href="/${this._spaceSlug}/rsplat/${this._savedSlug}">View in Gallery</a>`;
|
|
} else if (this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") {
|
|
actionEl = `<button class="splat-viewer__save" id="splat-save-btn">Save to Gallery</button>`;
|
|
}
|
|
|
|
// Download button
|
|
const downloadLabel = this._splatUrl.endsWith(".glb") ? "Download GLB"
|
|
: this._splatUrl.endsWith(".ply") ? "Download PLY"
|
|
: this._splatUrl.endsWith(".splat") ? "Download SPLAT"
|
|
: this._splatUrl.endsWith(".spz") ? "Download SPZ"
|
|
: "Download";
|
|
const downloadEl = `<button class="splat-viewer__download" id="splat-download-btn">${downloadLabel}</button>`;
|
|
|
|
// Format info
|
|
const formatExt = this._splatUrl.split(".").pop()?.toUpperCase() || "";
|
|
const formatInfo = formatExt === "GLB" ? "GLB format — opens in Blender, Windows 3D Viewer, Unity"
|
|
: formatExt === "PLY" ? "PLY format — opens in MeshLab, CloudCompare, Blender"
|
|
: formatExt === "SPLAT" ? "SPLAT format — Gaussian splat point cloud"
|
|
: "";
|
|
|
|
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 3D model...</div>
|
|
</div>
|
|
<div class="splat-viewer__controls">
|
|
${backEl}
|
|
${actionEl}
|
|
${downloadEl}
|
|
</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>` : ""}
|
|
${formatInfo ? `<p class="splat-viewer__format-info">${formatInfo}</p>` : ""}
|
|
</div>
|
|
` : ""}
|
|
<div id="splat-container" class="splat-viewer__canvas"></div>
|
|
</div>
|
|
`;
|
|
|
|
if (this._inlineViewer) {
|
|
this.querySelector("#splat-back-btn")?.addEventListener("click", () => {
|
|
this.cleanupViewer();
|
|
this._mode = "gallery";
|
|
this._inlineViewer = false;
|
|
this._splatUrl = "";
|
|
this._splatTitle = "";
|
|
this._splatDesc = "";
|
|
this._generatedUrl = "";
|
|
this._generatedTitle = "";
|
|
this._savedSlug = "";
|
|
if (this._spaceSlug === "demo") this.loadDemoData();
|
|
this.renderGallery();
|
|
});
|
|
}
|
|
|
|
if (!this._savedSlug && this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") {
|
|
this.querySelector("#splat-save-btn")?.addEventListener("click", () => this.saveToGallery());
|
|
}
|
|
|
|
// Download handler
|
|
this.querySelector("#splat-download-btn")?.addEventListener("click", () => this.downloadModel());
|
|
|
|
this.initThreeViewer();
|
|
}
|
|
|
|
private async downloadModel() {
|
|
const btn = this.querySelector("#splat-download-btn") as HTMLButtonElement;
|
|
if (!btn || !this._splatUrl) return;
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = "Downloading...";
|
|
|
|
try {
|
|
const res = await fetch(this._splatUrl);
|
|
if (!res.ok) throw new Error("Download failed");
|
|
|
|
const blob = await res.blob();
|
|
const filename = this._splatUrl.split("/").pop() || "model.glb";
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
btn.textContent = "Downloaded!";
|
|
setTimeout(() => {
|
|
const ext = this._splatUrl.split(".").pop()?.toUpperCase() || "";
|
|
btn.textContent = `Download ${ext}`;
|
|
btn.disabled = false;
|
|
}, 2000);
|
|
} catch {
|
|
btn.textContent = "Download failed";
|
|
setTimeout(() => {
|
|
const ext = this._splatUrl.split(".").pop()?.toUpperCase() || "";
|
|
btn.textContent = `Download ${ext}`;
|
|
btn.disabled = false;
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
private cleanupViewer() {
|
|
if (this._viewer) {
|
|
try { this._viewer.dispose(); } catch {}
|
|
this._viewer = null;
|
|
}
|
|
if ((this as any)._glbCleanup) {
|
|
try { (this as any)._glbCleanup(); } catch {}
|
|
(this as any)._glbCleanup = null;
|
|
}
|
|
}
|
|
|
|
private getAuthToken(): string {
|
|
return localStorage.getItem("encryptid_token")
|
|
|| document.cookie.match(/encryptid_token=([^;]+)/)?.[1]
|
|
|| "";
|
|
}
|
|
|
|
private async saveToGallery() {
|
|
const saveBtn = this.querySelector("#splat-save-btn") as HTMLButtonElement;
|
|
if (!saveBtn || !this._generatedUrl) return;
|
|
|
|
saveBtn.disabled = true;
|
|
saveBtn.textContent = "Saving...";
|
|
|
|
try {
|
|
const token = this.getAuthToken();
|
|
if (!token) {
|
|
saveBtn.textContent = "Sign in to save";
|
|
saveBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
url: this._generatedUrl,
|
|
title: this._generatedTitle || "AI Generated Model",
|
|
description: "AI-generated 3D model via Hunyuan3D",
|
|
}),
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
saveBtn.textContent = "Session expired — sign in again";
|
|
saveBtn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: "Save failed" }));
|
|
saveBtn.textContent = (err as any).error || "Save failed";
|
|
setTimeout(() => {
|
|
saveBtn.textContent = "Save to Gallery";
|
|
saveBtn.disabled = false;
|
|
}, 2000);
|
|
return;
|
|
}
|
|
|
|
const data = await res.json() as { slug: string };
|
|
this._savedSlug = data.slug;
|
|
saveBtn.textContent = "Saved!";
|
|
this._generatedUrl = "";
|
|
|
|
// Replace save button with view link
|
|
setTimeout(() => {
|
|
window.location.href = `/${this._spaceSlug}/rsplat/${data.slug}`;
|
|
}, 800);
|
|
} catch {
|
|
saveBtn.textContent = "Network error";
|
|
setTimeout(() => {
|
|
saveBtn.textContent = "Save to Gallery";
|
|
saveBtn.disabled = false;
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
private async initThreeViewer() {
|
|
const container = this.querySelector("#splat-container") as HTMLElement;
|
|
const loading = this.querySelector("#splat-loading") as HTMLElement;
|
|
|
|
if (!container || !this._splatUrl) return;
|
|
|
|
const isGlb = this._splatUrl.endsWith(".glb") || this._splatUrl.endsWith(".gltf");
|
|
|
|
try {
|
|
if (isGlb) {
|
|
await this.initGlbViewer(container, loading);
|
|
} else {
|
|
await this.initSplatViewer(container, loading);
|
|
}
|
|
} 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";
|
|
}
|
|
}
|
|
}
|
|
|
|
private async initSplatViewer(container: HTMLElement, loading: HTMLElement | null) {
|
|
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";
|
|
}
|
|
});
|
|
}
|
|
|
|
private async initGlbViewer(container: HTMLElement, loading: HTMLElement | null) {
|
|
const THREE = await import("three");
|
|
const { OrbitControls } = await import("three/addons/controls/OrbitControls.js");
|
|
const { GLTFLoader } = await import("three/addons/loaders/GLTFLoader.js");
|
|
|
|
const w = container.clientWidth || 800;
|
|
const h = container.clientHeight || 600;
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x0f0f14);
|
|
|
|
const camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 1000);
|
|
camera.position.set(3, 2, 3);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setSize(w, h);
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.2;
|
|
container.appendChild(renderer.domElement);
|
|
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.08;
|
|
controls.target.set(0, 0, 0);
|
|
|
|
// Lighting
|
|
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
|
scene.add(ambient);
|
|
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
|
dirLight.position.set(5, 10, 7);
|
|
scene.add(dirLight);
|
|
const fillLight = new THREE.DirectionalLight(0x8888ff, 0.4);
|
|
fillLight.position.set(-5, 3, -5);
|
|
scene.add(fillLight);
|
|
|
|
// Load GLB
|
|
const loader = new GLTFLoader();
|
|
loader.load(
|
|
this._splatUrl!,
|
|
(gltf: any) => {
|
|
const model = gltf.scene;
|
|
// Auto-center and scale
|
|
const box = new THREE.Box3().setFromObject(model);
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
const size = box.getSize(new THREE.Vector3());
|
|
const maxDim = Math.max(size.x, size.y, size.z);
|
|
const scale = 2 / maxDim;
|
|
model.scale.setScalar(scale);
|
|
model.position.sub(center.multiplyScalar(scale));
|
|
scene.add(model);
|
|
|
|
controls.target.set(0, 0, 0);
|
|
controls.update();
|
|
|
|
if (loading) loading.classList.add("hidden");
|
|
},
|
|
undefined,
|
|
(err: any) => {
|
|
console.error("[rSplat] GLB load error:", err);
|
|
if (loading) {
|
|
const text = loading.querySelector(".splat-loading__text");
|
|
if (text) text.textContent = `Error loading GLB: ${err.message || err}`;
|
|
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
|
|
if (spinner) spinner.style.display = "none";
|
|
}
|
|
}
|
|
);
|
|
|
|
// Animation loop
|
|
let animId: number;
|
|
const animate = () => {
|
|
animId = requestAnimationFrame(animate);
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
};
|
|
animate();
|
|
|
|
// Handle resize
|
|
const onResize = () => {
|
|
const rw = container.clientWidth || 800;
|
|
const rh = container.clientHeight || 600;
|
|
camera.aspect = rw / rh;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(rw, rh);
|
|
};
|
|
window.addEventListener("resize", onResize);
|
|
|
|
// Store cleanup reference
|
|
(this as any)._glbCleanup = () => {
|
|
cancelAnimationFrame(animId);
|
|
window.removeEventListener("resize", onResize);
|
|
controls.dispose();
|
|
renderer.dispose();
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── 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);
|