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 _generatedUrl = "";
|
||||
private _generatedTitle = "";
|
||||
private _savedSlug = "";
|
||||
private _myHistory: SplatItem[] = [];
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
|
||||
|
|
@ -67,6 +69,7 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
} else {
|
||||
this.subscribeOffline();
|
||||
}
|
||||
this.loadMyHistory();
|
||||
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() {
|
||||
this._offlineUnsub?.();
|
||||
this._offlineUnsub = null;
|
||||
|
|
@ -133,14 +152,12 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
|
||||
// ── Gallery ──
|
||||
|
||||
private renderGallery() {
|
||||
const cards = this._splats.map((s) => {
|
||||
private renderCard(s: SplatItem): string {
|
||||
const status = s.processing_status || "ready";
|
||||
const isReady = status === "ready";
|
||||
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 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 statusClass = !isReady ? ` splat-card--${status}` : "";
|
||||
const demoClass = isDemo ? " splat-card--demo" : "";
|
||||
|
|
@ -174,9 +191,20 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
</div>
|
||||
</${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__icon">🔮</div>
|
||||
<h3>No splats yet</h3>
|
||||
|
|
@ -190,7 +218,9 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
<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">
|
||||
|
|
@ -354,7 +384,7 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
const splat = await res.json() as SplatItem;
|
||||
status.textContent = "Uploaded!";
|
||||
setTimeout(() => {
|
||||
window.location.href = `/${this._spaceSlug}/rsplat/view/${splat.slug}`;
|
||||
window.location.href = `/${this._spaceSlug}/rsplat/${splat.slug}`;
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
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() {
|
||||
const browse = this.querySelector("#generate-browse") as HTMLElement;
|
||||
const fileInput = this.querySelector("#generate-file") as HTMLInputElement;
|
||||
|
|
@ -401,6 +457,14 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
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">`;
|
||||
|
|
@ -419,37 +483,48 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
actions.style.display = "none";
|
||||
progress.style.display = "block";
|
||||
|
||||
// Elapsed time ticker
|
||||
// Elapsed time ticker — resilient to iOS background-tab suspension
|
||||
const startTime = Date.now();
|
||||
let hiddenTime = 0;
|
||||
let hiddenAt = 0;
|
||||
const phases = [
|
||||
{ t: 0, msg: "Preparing image..." },
|
||||
{ t: 0, msg: "Staging image..." },
|
||||
{ t: 3, msg: "Uploading to Trellis 2..." },
|
||||
{ t: 8, msg: "Reconstructing 3D geometry..." },
|
||||
{ t: 20, msg: "Generating mesh and textures..." },
|
||||
{ t: 45, msg: "Finalizing model..." },
|
||||
{ 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 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);
|
||||
if (progressText && phase) {
|
||||
progressText.textContent = `${phase.msg} (${elapsed}s)`;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 120_000);
|
||||
try {
|
||||
const dataUrl = await this.resizeImage(selectedFile!, 1024);
|
||||
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: dataUrl }),
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({ image_url: imageUrl }),
|
||||
});
|
||||
clearInterval(ticker);
|
||||
clearTimeout(timeout);
|
||||
document.removeEventListener("visibilitychange", onVisChange);
|
||||
|
||||
if (res.status === 524 || res.status === 504) {
|
||||
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 };
|
||||
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`;
|
||||
|
||||
// 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;
|
||||
|
|
@ -494,11 +572,11 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
this.renderViewer();
|
||||
} catch (e: any) {
|
||||
clearInterval(ticker);
|
||||
clearTimeout(timeout);
|
||||
document.removeEventListener("visibilitychange", onVisChange);
|
||||
if (e.name === "AbortError") {
|
||||
status.textContent = "Request timed out — try a simpler image.";
|
||||
} else {
|
||||
status.textContent = "Network error — could not reach server";
|
||||
status.textContent = e.message || "Network error — could not reach server";
|
||||
}
|
||||
progress.style.display = "none";
|
||||
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> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxSize || height > maxSize) {
|
||||
const scale = maxSize / Math.max(width, height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
resolve(canvas.toDataURL("image/jpeg", 0.9));
|
||||
};
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
img.src = URL.createObjectURL(file);
|
||||
private async autoSave() {
|
||||
const token = localStorage.getItem("encryptid_token");
|
||||
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 Trellis 2",
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { slug: string };
|
||||
this._savedSlug = data.slug;
|
||||
}
|
||||
} catch { /* auto-save is best-effort */ }
|
||||
}
|
||||
|
||||
// ── Viewer ──
|
||||
|
|
@ -538,9 +619,27 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
? `<button class="splat-viewer__back" id="splat-back-btn">← Back to Gallery</button>`
|
||||
: `<a class="splat-viewer__back" href="/${this._spaceSlug}/rsplat">← Gallery</a>`;
|
||||
|
||||
const showSave = this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo";
|
||||
const saveEl = showSave
|
||||
? `<button class="splat-viewer__save" id="splat-save-btn">Save to Gallery</button>`
|
||||
// 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 = `
|
||||
|
|
@ -551,12 +650,14 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
</div>
|
||||
<div class="splat-viewer__controls">
|
||||
${backEl}
|
||||
${saveEl}
|
||||
${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>
|
||||
|
|
@ -573,18 +674,60 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
this._splatDesc = "";
|
||||
this._generatedUrl = "";
|
||||
this._generatedTitle = "";
|
||||
this._savedSlug = "";
|
||||
if (this._spaceSlug === "demo") this.loadDemoData();
|
||||
this.renderGallery();
|
||||
});
|
||||
}
|
||||
|
||||
if (showSave) {
|
||||
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 {}
|
||||
|
|
@ -641,12 +784,13 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
const data = await res.json() as { slug: string };
|
||||
this._savedSlug = data.slug;
|
||||
saveBtn.textContent = "Saved!";
|
||||
this._generatedUrl = "";
|
||||
|
||||
// Navigate to the saved splat after a moment
|
||||
// Replace save button with view link
|
||||
setTimeout(() => {
|
||||
window.location.href = `/${this._spaceSlug}/rsplat/view/${data.slug}`;
|
||||
window.location.href = `/${this._spaceSlug}/rsplat/${data.slug}`;
|
||||
}, 800);
|
||||
} catch {
|
||||
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 ── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
|
|
|||
|
|
@ -629,6 +629,30 @@ routes.post("/api/splats/save-generated", async (c) => {
|
|||
}, 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) ──
|
||||
routes.delete("/api/splats/:id", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
|
|
@ -688,12 +712,12 @@ routes.get("/", async (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
head: `
|
||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=3">
|
||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=4">
|
||||
${IMPORTMAP}
|
||||
`,
|
||||
scripts: `
|
||||
<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');
|
||||
gallery.splats = ${splatsJSON};
|
||||
gallery.spaceSlug = '${spaceSlug}';
|
||||
|
|
@ -704,14 +728,10 @@ routes.get("/", async (c) => {
|
|||
return c.html(html);
|
||||
});
|
||||
|
||||
// ── Page: Viewer ──
|
||||
routes.get("/view/:id", async (c) => {
|
||||
const spaceSlug = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || spaceSlug;
|
||||
const id = c.req.param("id");
|
||||
|
||||
// ── Shared viewer page renderer ──
|
||||
function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string) {
|
||||
const doc = ensureDoc(dataSpace);
|
||||
const found = findItem(doc, id);
|
||||
const found = findItem(doc, idOrSlug);
|
||||
|
||||
if (!found || found[1].status !== 'published') {
|
||||
const html = renderShell({
|
||||
|
|
@ -722,7 +742,7 @@ routes.get("/view/:id", async (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
});
|
||||
return c.html(html, 404);
|
||||
return { html, status: 404 as const };
|
||||
}
|
||||
|
||||
const [itemKey, splat] = found;
|
||||
|
|
@ -752,17 +772,24 @@ routes.get("/view/:id", async (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
head: `
|
||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=3">
|
||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=4">
|
||||
${IMPORTMAP}
|
||||
`,
|
||||
scripts: `
|
||||
<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>
|
||||
`,
|
||||
});
|
||||
|
||||
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 ──
|
||||
|
|
@ -807,6 +834,18 @@ function seedTemplateSplat(space: string) {
|
|||
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 ──
|
||||
|
||||
export const splatModule: RSpaceModule = {
|
||||
|
|
|
|||
|
|
@ -985,6 +985,40 @@ app.post("/api/video-gen/i2v", async (c) => {
|
|||
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
|
||||
app.post("/api/3d-gen", async (c) => {
|
||||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
/// <reference lib="webworker" />
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
const CACHE_VERSION = "rspace-v2";
|
||||
const CACHE_VERSION = "rspace-v3";
|
||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||
const HTML_CACHE = `${CACHE_VERSION}-html`;
|
||||
const API_CACHE = `${CACHE_VERSION}-api`;
|
||||
const ECOSYSTEM_CACHE = `${CACHE_VERSION}-ecosystem`;
|
||||
const TILE_CACHE = `${CACHE_VERSION}-tiles`;
|
||||
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)
|
||||
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
|
||||
|
|
@ -234,6 +236,34 @@ self.addEventListener("fetch", (event) => {
|
|||
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
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue