feat(rsplat): image staging endpoint, viewer improvements, SW updates
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9ecffff692
commit
21b1e8fa0a
|
|
@ -38,6 +38,8 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
private _offlineUnsub: (() => void) | null = null;
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
private _generatedUrl = "";
|
private _generatedUrl = "";
|
||||||
private _generatedTitle = "";
|
private _generatedTitle = "";
|
||||||
|
private _savedSlug = "";
|
||||||
|
private _myHistory: SplatItem[] = [];
|
||||||
|
|
||||||
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"];
|
||||||
|
|
@ -67,6 +69,7 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
} else {
|
} else {
|
||||||
this.subscribeOffline();
|
this.subscribeOffline();
|
||||||
}
|
}
|
||||||
|
this.loadMyHistory();
|
||||||
this.renderGallery();
|
this.renderGallery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +113,22 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadMyHistory() {
|
||||||
|
const token = localStorage.getItem("encryptid_token");
|
||||||
|
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() {
|
disconnectedCallback() {
|
||||||
this._offlineUnsub?.();
|
this._offlineUnsub?.();
|
||||||
this._offlineUnsub = null;
|
this._offlineUnsub = null;
|
||||||
|
|
@ -133,14 +152,12 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
|
|
||||||
// ── Gallery ──
|
// ── Gallery ──
|
||||||
|
|
||||||
private renderGallery() {
|
private renderCard(s: SplatItem): string {
|
||||||
const cards = this._splats.map((s) => {
|
|
||||||
const status = s.processing_status || "ready";
|
const status = s.processing_status || "ready";
|
||||||
const isReady = status === "ready";
|
const isReady = status === "ready";
|
||||||
const isDemo = !!s.demoUrl;
|
const isDemo = !!s.demoUrl;
|
||||||
// Demo cards use a button instead of a link to avoid server round-trip
|
|
||||||
const tag = isReady ? (isDemo ? "div" : "a") : "div";
|
const tag = isReady ? (isDemo ? "div" : "a") : "div";
|
||||||
const href = isReady && !isDemo ? ` href="/${this._spaceSlug}/rsplat/view/${s.slug}"` : "";
|
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 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 statusClass = !isReady ? ` splat-card--${status}` : "";
|
||||||
const demoClass = isDemo ? " splat-card--demo" : "";
|
const demoClass = isDemo ? " splat-card--demo" : "";
|
||||||
|
|
@ -174,9 +191,20 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
</${tag}>
|
</${tag}>
|
||||||
`;
|
`;
|
||||||
}).join("");
|
}
|
||||||
|
|
||||||
const empty = this._splats.length === 0 ? `
|
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">
|
||||||
<div class="splat-empty__icon">🔮</div>
|
<div class="splat-empty__icon">🔮</div>
|
||||||
<h3>No splats yet</h3>
|
<h3>No splats yet</h3>
|
||||||
|
|
@ -190,7 +218,9 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
<h1>rSplat</h1>
|
<h1>rSplat</h1>
|
||||||
<p class="splat-gallery__subtitle">Explore and create 3D Gaussian splat scenes</p>
|
<p class="splat-gallery__subtitle">Explore and create 3D Gaussian splat scenes</p>
|
||||||
</div>
|
</div>
|
||||||
|
${myModelsHtml}
|
||||||
${empty}
|
${empty}
|
||||||
|
${this._splats.length > 0 ? `<h2 class="splat-section-title">Gallery</h2>` : ""}
|
||||||
<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">
|
<div class="splat-upload__toggle">
|
||||||
|
|
@ -354,7 +384,7 @@ 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!";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/${this._spaceSlug}/rsplat/view/${splat.slug}`;
|
window.location.href = `/${this._spaceSlug}/rsplat/${splat.slug}`;
|
||||||
}, 500);
|
}, 500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.textContent = "Network error";
|
status.textContent = "Network error";
|
||||||
|
|
@ -382,6 +412,32 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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() {
|
private setupGenerateHandlers() {
|
||||||
const browse = this.querySelector("#generate-browse") as HTMLElement;
|
const browse = this.querySelector("#generate-browse") as HTMLElement;
|
||||||
const fileInput = this.querySelector("#generate-file") as HTMLInputElement;
|
const fileInput = this.querySelector("#generate-file") as HTMLInputElement;
|
||||||
|
|
@ -401,6 +457,14 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
fileInput.addEventListener("change", () => {
|
fileInput.addEventListener("change", () => {
|
||||||
if (fileInput.files?.[0]) {
|
if (fileInput.files?.[0]) {
|
||||||
selectedFile = 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();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
preview.innerHTML = `<img src="${reader.result}" alt="Preview">`;
|
preview.innerHTML = `<img src="${reader.result}" alt="Preview">`;
|
||||||
|
|
@ -419,37 +483,48 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
actions.style.display = "none";
|
actions.style.display = "none";
|
||||||
progress.style.display = "block";
|
progress.style.display = "block";
|
||||||
|
|
||||||
// Elapsed time ticker
|
// Elapsed time ticker — resilient to iOS background-tab suspension
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
let hiddenTime = 0;
|
||||||
|
let hiddenAt = 0;
|
||||||
const phases = [
|
const phases = [
|
||||||
{ t: 0, msg: "Preparing image..." },
|
{ t: 0, msg: "Staging image..." },
|
||||||
{ t: 3, msg: "Uploading to Trellis 2..." },
|
{ t: 3, msg: "Uploading to Trellis 2..." },
|
||||||
{ t: 8, msg: "Reconstructing 3D geometry..." },
|
{ t: 8, msg: "Reconstructing 3D geometry..." },
|
||||||
{ t: 20, msg: "Generating mesh and textures..." },
|
{ t: 20, msg: "Generating mesh and textures..." },
|
||||||
{ t: 45, msg: "Finalizing model..." },
|
{ t: 45, msg: "Finalizing model..." },
|
||||||
{ t: 75, msg: "Almost there..." },
|
{ t: 75, msg: "Almost there..." },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const onVisChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
hiddenAt = Date.now();
|
||||||
|
} else if (hiddenAt) {
|
||||||
|
hiddenTime += Date.now() - hiddenAt;
|
||||||
|
hiddenAt = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisChange);
|
||||||
|
|
||||||
const ticker = setInterval(() => {
|
const ticker = setInterval(() => {
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
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 phase = [...phases].reverse().find(p => elapsed >= p.t);
|
||||||
if (progressText && phase) {
|
if (progressText && phase) {
|
||||||
progressText.textContent = `${phase.msg} (${elapsed}s)`;
|
progressText.textContent = `${phase.msg} (${elapsed}s)`;
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 120_000);
|
|
||||||
try {
|
try {
|
||||||
const dataUrl = await this.resizeImage(selectedFile!, 1024);
|
const imageUrl = await this.stageImage(selectedFile!);
|
||||||
|
|
||||||
const res = await fetch("/api/3d-gen", {
|
const res = await fetch("/api/3d-gen", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ image_url: dataUrl }),
|
body: JSON.stringify({ image_url: imageUrl }),
|
||||||
signal: controller.signal,
|
|
||||||
});
|
});
|
||||||
clearInterval(ticker);
|
clearInterval(ticker);
|
||||||
clearTimeout(timeout);
|
document.removeEventListener("visibilitychange", onVisChange);
|
||||||
|
|
||||||
if (res.status === 524 || res.status === 504) {
|
if (res.status === 524 || res.status === 504) {
|
||||||
status.textContent = "Generation timed out — try a simpler image.";
|
status.textContent = "Generation timed out — try a simpler image.";
|
||||||
|
|
@ -478,13 +553,16 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
|
|
||||||
const data = await res.json() as { url: string; format: string };
|
const data = await res.json() as { url: string; format: string };
|
||||||
progress.style.display = "none";
|
progress.style.display = "none";
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000);
|
||||||
status.textContent = `Generated in ${elapsed}s`;
|
status.textContent = `Generated in ${elapsed}s`;
|
||||||
|
|
||||||
// Store generated info for save-to-gallery
|
// Store generated info for save-to-gallery
|
||||||
this._generatedUrl = data.url;
|
this._generatedUrl = data.url;
|
||||||
this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, "");
|
this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, "");
|
||||||
|
|
||||||
|
// Auto-save if authenticated
|
||||||
|
await this.autoSave();
|
||||||
|
|
||||||
// Open inline viewer with generated model
|
// Open inline viewer with generated model
|
||||||
this._mode = "viewer";
|
this._mode = "viewer";
|
||||||
this._splatUrl = data.url;
|
this._splatUrl = data.url;
|
||||||
|
|
@ -494,11 +572,11 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
this.renderViewer();
|
this.renderViewer();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
clearInterval(ticker);
|
clearInterval(ticker);
|
||||||
clearTimeout(timeout);
|
document.removeEventListener("visibilitychange", onVisChange);
|
||||||
if (e.name === "AbortError") {
|
if (e.name === "AbortError") {
|
||||||
status.textContent = "Request timed out — try a simpler image.";
|
status.textContent = "Request timed out — try a simpler image.";
|
||||||
} else {
|
} else {
|
||||||
status.textContent = "Network error — could not reach server";
|
status.textContent = e.message || "Network error — could not reach server";
|
||||||
}
|
}
|
||||||
progress.style.display = "none";
|
progress.style.display = "none";
|
||||||
actions.style.display = "flex";
|
actions.style.display = "flex";
|
||||||
|
|
@ -507,28 +585,31 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Image helpers ──
|
// ── Auto-save after generation ──
|
||||||
|
|
||||||
private resizeImage(file: File, maxSize: number): Promise<string> {
|
private async autoSave() {
|
||||||
return new Promise((resolve, reject) => {
|
const token = localStorage.getItem("encryptid_token");
|
||||||
const img = new Image();
|
if (!token || !this._generatedUrl || this._spaceSlug === "demo") return;
|
||||||
img.onload = () => {
|
|
||||||
let { width, height } = img;
|
try {
|
||||||
if (width > maxSize || height > maxSize) {
|
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, {
|
||||||
const scale = maxSize / Math.max(width, height);
|
method: "POST",
|
||||||
width = Math.round(width * scale);
|
headers: {
|
||||||
height = Math.round(height * scale);
|
"Content-Type": "application/json",
|
||||||
}
|
Authorization: `Bearer ${token}`,
|
||||||
const canvas = document.createElement("canvas");
|
},
|
||||||
canvas.width = width;
|
body: JSON.stringify({
|
||||||
canvas.height = height;
|
url: this._generatedUrl,
|
||||||
const ctx = canvas.getContext("2d")!;
|
title: this._generatedTitle || "AI Generated Model",
|
||||||
ctx.drawImage(img, 0, 0, width, height);
|
description: "AI-generated 3D model via Trellis 2",
|
||||||
resolve(canvas.toDataURL("image/jpeg", 0.9));
|
}),
|
||||||
};
|
|
||||||
img.onerror = () => reject(new Error("Failed to load image"));
|
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as { slug: string };
|
||||||
|
this._savedSlug = data.slug;
|
||||||
|
}
|
||||||
|
} catch { /* auto-save is best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Viewer ──
|
// ── Viewer ──
|
||||||
|
|
@ -538,9 +619,27 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
? `<button class="splat-viewer__back" id="splat-back-btn">← Back to Gallery</button>`
|
? `<button class="splat-viewer__back" id="splat-back-btn">← Back to Gallery</button>`
|
||||||
: `<a class="splat-viewer__back" href="/${this._spaceSlug}/rsplat">← Gallery</a>`;
|
: `<a class="splat-viewer__back" href="/${this._spaceSlug}/rsplat">← Gallery</a>`;
|
||||||
|
|
||||||
const showSave = this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo";
|
// Show "View in Gallery" if auto-saved, otherwise "Save" if generated
|
||||||
const saveEl = showSave
|
let actionEl = "";
|
||||||
? `<button class="splat-viewer__save" id="splat-save-btn">Save to Gallery</button>`
|
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 = `
|
this.innerHTML = `
|
||||||
|
|
@ -551,12 +650,14 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
<div class="splat-viewer__controls">
|
<div class="splat-viewer__controls">
|
||||||
${backEl}
|
${backEl}
|
||||||
${saveEl}
|
${actionEl}
|
||||||
|
${downloadEl}
|
||||||
</div>
|
</div>
|
||||||
${this._splatTitle ? `
|
${this._splatTitle ? `
|
||||||
<div class="splat-viewer__info">
|
<div class="splat-viewer__info">
|
||||||
<p class="splat-viewer__title">${esc(this._splatTitle)}</p>
|
<p class="splat-viewer__title">${esc(this._splatTitle)}</p>
|
||||||
${this._splatDesc ? `<p class="splat-viewer__desc">${esc(this._splatDesc)}</p>` : ""}
|
${this._splatDesc ? `<p class="splat-viewer__desc">${esc(this._splatDesc)}</p>` : ""}
|
||||||
|
${formatInfo ? `<p class="splat-viewer__format-info">${formatInfo}</p>` : ""}
|
||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : ""}
|
||||||
<div id="splat-container" class="splat-viewer__canvas"></div>
|
<div id="splat-container" class="splat-viewer__canvas"></div>
|
||||||
|
|
@ -573,18 +674,60 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
this._splatDesc = "";
|
this._splatDesc = "";
|
||||||
this._generatedUrl = "";
|
this._generatedUrl = "";
|
||||||
this._generatedTitle = "";
|
this._generatedTitle = "";
|
||||||
|
this._savedSlug = "";
|
||||||
if (this._spaceSlug === "demo") this.loadDemoData();
|
if (this._spaceSlug === "demo") this.loadDemoData();
|
||||||
this.renderGallery();
|
this.renderGallery();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSave) {
|
if (!this._savedSlug && this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") {
|
||||||
this.querySelector("#splat-save-btn")?.addEventListener("click", () => this.saveToGallery());
|
this.querySelector("#splat-save-btn")?.addEventListener("click", () => this.saveToGallery());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Download handler
|
||||||
|
this.querySelector("#splat-download-btn")?.addEventListener("click", () => this.downloadModel());
|
||||||
|
|
||||||
this.initThreeViewer();
|
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() {
|
private cleanupViewer() {
|
||||||
if (this._viewer) {
|
if (this._viewer) {
|
||||||
try { this._viewer.dispose(); } catch {}
|
try { this._viewer.dispose(); } catch {}
|
||||||
|
|
@ -641,12 +784,13 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json() as { slug: string };
|
const data = await res.json() as { slug: string };
|
||||||
|
this._savedSlug = data.slug;
|
||||||
saveBtn.textContent = "Saved!";
|
saveBtn.textContent = "Saved!";
|
||||||
this._generatedUrl = "";
|
this._generatedUrl = "";
|
||||||
|
|
||||||
// Navigate to the saved splat after a moment
|
// Replace save button with view link
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/${this._spaceSlug}/rsplat/view/${data.slug}`;
|
window.location.href = `/${this._spaceSlug}/rsplat/${data.slug}`;
|
||||||
}, 800);
|
}, 800);
|
||||||
} catch {
|
} catch {
|
||||||
saveBtn.textContent = "Network error";
|
saveBtn.textContent = "Network error";
|
||||||
|
|
|
||||||
|
|
@ -578,6 +578,63 @@ button.splat-viewer__save {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Download button ── */
|
||||||
|
|
||||||
|
.splat-viewer__download {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--rs-glass-bg, rgba(30, 41, 59, 0.85));
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: var(--splat-text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--splat-border);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splat-viewer__download:hover {
|
||||||
|
background: var(--rs-bg-surface, rgba(51, 65, 85, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.splat-viewer__download:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Format info ── */
|
||||||
|
|
||||||
|
.splat-viewer__format-info {
|
||||||
|
color: var(--splat-text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── My Models section ── */
|
||||||
|
|
||||||
|
.splat-my-models {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splat-my-models__title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--splat-text);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splat-section-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--splat-text);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,30 @@ routes.post("/api/splats/save-generated", async (c) => {
|
||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── API: My history (authenticated user's splats) ──
|
||||||
|
routes.get("/api/splats/my-history", async (c) => {
|
||||||
|
const token = extractToken(c.req.raw.headers);
|
||||||
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
|
|
||||||
|
let claims;
|
||||||
|
try {
|
||||||
|
claims = await verifyEncryptIDToken(token);
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Invalid token" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = c.get("effectiveSpace") || spaceSlug;
|
||||||
|
const doc = ensureDoc(dataSpace);
|
||||||
|
|
||||||
|
const items = Object.values(doc.items)
|
||||||
|
.filter((item) => item.status === 'published' && item.contributorId === claims.sub)
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
.slice(0, 50);
|
||||||
|
|
||||||
|
return c.json({ splats: items.map(itemToListRow) });
|
||||||
|
});
|
||||||
|
|
||||||
// ── 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);
|
||||||
|
|
@ -688,12 +712,12 @@ routes.get("/", async (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
head: `
|
head: `
|
||||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=3">
|
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=4">
|
||||||
${IMPORTMAP}
|
${IMPORTMAP}
|
||||||
`,
|
`,
|
||||||
scripts: `
|
scripts: `
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=4';
|
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=5';
|
||||||
const gallery = document.getElementById('gallery');
|
const gallery = document.getElementById('gallery');
|
||||||
gallery.splats = ${splatsJSON};
|
gallery.splats = ${splatsJSON};
|
||||||
gallery.spaceSlug = '${spaceSlug}';
|
gallery.spaceSlug = '${spaceSlug}';
|
||||||
|
|
@ -704,14 +728,10 @@ routes.get("/", async (c) => {
|
||||||
return c.html(html);
|
return c.html(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Page: Viewer ──
|
// ── Shared viewer page renderer ──
|
||||||
routes.get("/view/:id", async (c) => {
|
function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string) {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
|
||||||
const dataSpace = c.get("effectiveSpace") || spaceSlug;
|
|
||||||
const id = c.req.param("id");
|
|
||||||
|
|
||||||
const doc = ensureDoc(dataSpace);
|
const doc = ensureDoc(dataSpace);
|
||||||
const found = findItem(doc, id);
|
const found = findItem(doc, idOrSlug);
|
||||||
|
|
||||||
if (!found || found[1].status !== 'published') {
|
if (!found || found[1].status !== 'published') {
|
||||||
const html = renderShell({
|
const html = renderShell({
|
||||||
|
|
@ -722,7 +742,7 @@ routes.get("/view/:id", async (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
});
|
});
|
||||||
return c.html(html, 404);
|
return { html, status: 404 as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [itemKey, splat] = found;
|
const [itemKey, splat] = found;
|
||||||
|
|
@ -752,17 +772,24 @@ routes.get("/view/:id", async (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
head: `
|
head: `
|
||||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=3">
|
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=4">
|
||||||
${IMPORTMAP}
|
${IMPORTMAP}
|
||||||
`,
|
`,
|
||||||
scripts: `
|
scripts: `
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=4';
|
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=5';
|
||||||
</script>
|
</script>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.html(html);
|
return { html, status: 200 as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page: Viewer (legacy /view/:id → 301 redirect to /:slug) ──
|
||||||
|
routes.get("/view/:id", async (c) => {
|
||||||
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const id = c.req.param("id");
|
||||||
|
return c.redirect(`/${spaceSlug}/rsplat/${id}`, 301);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Seed template data ──
|
// ── Seed template data ──
|
||||||
|
|
@ -807,6 +834,18 @@ function seedTemplateSplat(space: string) {
|
||||||
console.log(`[Splat] Template seeded for "${space}": 2 splat entries`);
|
console.log(`[Splat] Template seeded for "${space}": 2 splat entries`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Page: Viewer (clean /:slug URL — registered last to avoid shadowing api/view) ──
|
||||||
|
const RESERVED_SLUGS = new Set(["api", "view", "template"]);
|
||||||
|
routes.get("/:slug", async (c) => {
|
||||||
|
const slug = c.req.param("slug");
|
||||||
|
if (RESERVED_SLUGS.has(slug)) return c.notFound();
|
||||||
|
|
||||||
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = c.get("effectiveSpace") || spaceSlug;
|
||||||
|
const result = renderViewerPage(spaceSlug, dataSpace, slug);
|
||||||
|
return c.html(result.html, result.status);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Module export ──
|
// ── Module export ──
|
||||||
|
|
||||||
export const splatModule: RSpaceModule = {
|
export const splatModule: RSpaceModule = {
|
||||||
|
|
|
||||||
|
|
@ -985,6 +985,40 @@ app.post("/api/video-gen/i2v", async (c) => {
|
||||||
return c.json({ url: videoUrl, video_url: videoUrl });
|
return c.json({ url: videoUrl, video_url: videoUrl });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stage image for 3D generation (binary upload → HTTPS URL for fal.ai)
|
||||||
|
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || "https://rspace.online";
|
||||||
|
app.post("/api/image-stage", async (c) => {
|
||||||
|
const formData = await c.req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
if (!file) return c.json({ error: "file required" }, 400);
|
||||||
|
|
||||||
|
// HEIC rejection
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||||
|
if (ext === "heic" || ext === "heif" || file.type === "image/heic" || file.type === "image/heif") {
|
||||||
|
return c.json({ error: "HEIC files are not supported. Please convert to JPEG or PNG first." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate type
|
||||||
|
const validTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
const validExts = ["jpg", "jpeg", "png", "webp"];
|
||||||
|
if (!validTypes.includes(file.type) && !validExts.includes(ext)) {
|
||||||
|
return c.json({ error: "Only JPEG, PNG, and WebP images are supported" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15MB limit
|
||||||
|
if (file.size > 15 * 1024 * 1024) {
|
||||||
|
return c.json({ error: "Image too large. Maximum 15MB." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||||||
|
const outExt = ext === "png" ? "png" : ext === "webp" ? "webp" : "jpg";
|
||||||
|
const filename = `stage-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${outExt}`;
|
||||||
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
await Bun.write(resolve(dir, filename), buf);
|
||||||
|
|
||||||
|
return c.json({ url: `${PUBLIC_ORIGIN}/data/files/generated/${filename}` });
|
||||||
|
});
|
||||||
|
|
||||||
// Image-to-3D via fal.ai Trellis
|
// Image-to-3D via fal.ai Trellis
|
||||||
app.post("/api/3d-gen", async (c) => {
|
app.post("/api/3d-gen", async (c) => {
|
||||||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
declare const self: ServiceWorkerGlobalScope;
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
const CACHE_VERSION = "rspace-v2";
|
const CACHE_VERSION = "rspace-v3";
|
||||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||||
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
||||||
const API_CACHE = `${CACHE_VERSION}-api`;
|
const API_CACHE = `${CACHE_VERSION}-api`;
|
||||||
const ECOSYSTEM_CACHE = `${CACHE_VERSION}-ecosystem`;
|
const ECOSYSTEM_CACHE = `${CACHE_VERSION}-ecosystem`;
|
||||||
const TILE_CACHE = `${CACHE_VERSION}-tiles`;
|
const TILE_CACHE = `${CACHE_VERSION}-tiles`;
|
||||||
const TILE_CACHE_MAX = 500;
|
const TILE_CACHE_MAX = 500;
|
||||||
|
const MODEL_CACHE = `${CACHE_VERSION}-models`;
|
||||||
|
const MODEL_CACHE_MAX = 20;
|
||||||
|
|
||||||
// Vite-hashed assets are immutable (content hash in filename)
|
// Vite-hashed assets are immutable (content hash in filename)
|
||||||
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
|
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
|
||||||
|
|
@ -234,6 +236,34 @@ self.addEventListener("fetch", (event) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3D model files: cache-first with LRU eviction
|
||||||
|
const MODEL_PATTERN = /\.(glb|ply|splat|spz)(\?|$)/;
|
||||||
|
const isModelPath = url.pathname.includes("/data/files/generated/") || url.pathname.includes("/rsplat/api/splats/");
|
||||||
|
if (MODEL_PATTERN.test(url.pathname) && isModelPath) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(MODEL_CACHE).then(async (cache) => {
|
||||||
|
const cached = await cache.match(event.request);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const response = await fetch(event.request);
|
||||||
|
if (response.ok) {
|
||||||
|
const clone = response.clone();
|
||||||
|
cache.put(event.request, clone).then(async () => {
|
||||||
|
const keys = await cache.keys();
|
||||||
|
if (keys.length > MODEL_CACHE_MAX) {
|
||||||
|
const toDelete = keys.length - MODEL_CACHE_MAX;
|
||||||
|
for (let i = 0; i < toDelete; i++) {
|
||||||
|
await cache.delete(keys[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}).catch(() => new Response("Model unavailable", { status: 503 }))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Other assets (images, fonts, etc.): stale-while-revalidate
|
// Other assets (images, fonts, etc.): stale-while-revalidate
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cached) => {
|
caches.match(event.request).then((cached) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue