fix(rsplat): save-to-gallery, remove broken media upload, fix leaks
- Add POST /api/splats/save-generated so AI-generated 3D models persist - Add "Save to Gallery" button in viewer after AI generation - Remove non-functional "Upload Photos/Video" tab (no processing worker) - Add 120s server-side timeout on fal.ai Trellis 2 fetch - Fix GLB viewer memory leak (animation loop + resize listener on disconnect) - Show elapsed time + phase messages during generation progress - Bump CSS v3, JS v4 cache versions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8a82223b6f
commit
d008b78727
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* <folk-splat-viewer> — Gaussian splat gallery + 3D viewer web component.
|
||||
*
|
||||
* Gallery mode: card grid of splats with upload form (splat files or photos/video).
|
||||
* 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).
|
||||
|
|
@ -33,9 +33,11 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
private _splatTitle = "";
|
||||
private _splatDesc = "";
|
||||
private _viewer: any = null;
|
||||
private _uploadMode: "splat" | "media" | "generate" = "splat";
|
||||
private _uploadMode: "splat" | "generate" = "splat";
|
||||
private _inlineViewer = false;
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _generatedUrl = "";
|
||||
private _generatedTitle = "";
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
|
||||
|
|
@ -115,6 +117,10 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
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) {
|
||||
|
|
@ -189,7 +195,6 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
<div class="splat-upload" id="splat-drop">
|
||||
<div class="splat-upload__toggle">
|
||||
<button class="splat-upload__toggle-btn active" data-mode="splat">Upload Splat</button>
|
||||
<button class="splat-upload__toggle-btn" data-mode="media">Upload Photos/Video</button>
|
||||
<button class="splat-upload__toggle-btn" data-mode="generate">Generate from Image</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -210,40 +215,21 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media upload mode -->
|
||||
<div class="splat-upload__mode" id="splat-mode-media" style="display:none">
|
||||
<div class="splat-upload__icon">📸</div>
|
||||
<p class="splat-upload__text">
|
||||
Upload <strong>photos</strong> (up to 100) or a <strong>video</strong> to generate a 3D splat
|
||||
<br>Accepted: .jpg, .png, .heic, .mp4, .mov, .webm
|
||||
<br>or <strong id="media-browse">browse</strong> to select files
|
||||
</p>
|
||||
<input type="file" id="media-files" accept=".jpg,.jpeg,.png,.heic,.mp4,.mov,.webm" multiple hidden>
|
||||
<div class="splat-upload__selected" id="media-selected"></div>
|
||||
<div class="splat-upload__form" id="media-form">
|
||||
<input type="text" id="media-title-input" placeholder="Title (required)" required>
|
||||
<textarea id="media-desc-input" placeholder="Description (optional)" rows="2"></textarea>
|
||||
<input type="text" id="media-tags-input" placeholder="Tags (comma-separated)">
|
||||
<button class="splat-upload__btn" id="media-submit" disabled>Upload for Processing</button>
|
||||
<div class="splat-upload__status" id="media-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 Gaussian splat using AI
|
||||
Upload a single <strong>image</strong> to generate a 3D model using AI (Trellis 2)
|
||||
<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 Splat</button>
|
||||
<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">Generating 3D model...</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>
|
||||
|
|
@ -252,7 +238,6 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
`;
|
||||
|
||||
this.setupUploadHandlers();
|
||||
this.setupMediaHandlers();
|
||||
this.setupGenerateHandlers();
|
||||
this.setupToggle();
|
||||
this.setupDemoCardHandlers();
|
||||
|
|
@ -261,16 +246,14 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
private setupToggle() {
|
||||
const buttons = this.querySelectorAll<HTMLButtonElement>(".splat-upload__toggle-btn");
|
||||
const splatMode = this.querySelector("#splat-mode-splat") as HTMLElement;
|
||||
const mediaMode = this.querySelector("#splat-mode-media") as HTMLElement;
|
||||
const generateMode = this.querySelector("#splat-mode-generate") as HTMLElement;
|
||||
|
||||
buttons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const mode = btn.dataset.mode as "splat" | "media" | "generate";
|
||||
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";
|
||||
mediaMode.style.display = mode === "media" ? "" : "none";
|
||||
if (generateMode) generateMode.style.display = mode === "generate" ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
|
@ -380,93 +363,6 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
});
|
||||
}
|
||||
|
||||
private setupMediaHandlers() {
|
||||
const browse = this.querySelector("#media-browse") as HTMLElement;
|
||||
const fileInput = this.querySelector("#media-files") as HTMLInputElement;
|
||||
const form = this.querySelector("#media-form") as HTMLElement;
|
||||
const selected = this.querySelector("#media-selected") as HTMLElement;
|
||||
const titleInput = this.querySelector("#media-title-input") as HTMLInputElement;
|
||||
const descInput = this.querySelector("#media-desc-input") as HTMLTextAreaElement;
|
||||
const tagsInput = this.querySelector("#media-tags-input") as HTMLInputElement;
|
||||
const submitBtn = this.querySelector("#media-submit") as HTMLButtonElement;
|
||||
const status = this.querySelector("#media-status") as HTMLElement;
|
||||
|
||||
if (!fileInput) return;
|
||||
|
||||
let selectedFiles: File[] = [];
|
||||
|
||||
browse?.addEventListener("click", () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files && fileInput.files.length > 0) {
|
||||
selectedFiles = Array.from(fileInput.files);
|
||||
form.classList.add("active");
|
||||
const totalSize = selectedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||
selected.innerHTML = `<div class="splat-upload__file-count">${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""} selected (${formatSize(totalSize)})</div>`;
|
||||
if (!titleInput.value.trim() && selectedFiles.length > 0) {
|
||||
const name = selectedFiles[0].name.replace(/\.[^.]+$/, "");
|
||||
titleInput.value = name.replace(/[-_]/g, " ");
|
||||
}
|
||||
titleInput.dispatchEvent(new Event("input"));
|
||||
}
|
||||
});
|
||||
|
||||
titleInput?.addEventListener("input", () => {
|
||||
submitBtn.disabled = !titleInput.value.trim() || selectedFiles.length === 0;
|
||||
});
|
||||
|
||||
submitBtn?.addEventListener("click", async () => {
|
||||
if (selectedFiles.length === 0 || !titleInput.value.trim()) return;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
status.textContent = "Uploading...";
|
||||
|
||||
const formData = new FormData();
|
||||
for (const f of selectedFiles) {
|
||||
formData.append("files", f);
|
||||
}
|
||||
formData.append("title", titleInput.value.trim());
|
||||
formData.append("description", descInput.value.trim());
|
||||
formData.append("tags", tagsInput.value.trim());
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("encryptid_token") || "";
|
||||
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/from-media`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.status === 402) {
|
||||
status.textContent = "Payment required for upload (x402)";
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
status.textContent = "Sign in with rStack Identity to upload";
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Upload failed" }));
|
||||
status.textContent = (err as any).error || "Upload failed";
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = "Uploaded! Queued for processing.";
|
||||
setTimeout(() => {
|
||||
window.location.href = `/${this._spaceSlug}/rsplat`;
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
status.textContent = "Network error";
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupDemoCardHandlers() {
|
||||
this.querySelectorAll<HTMLElement>(".splat-card--demo").forEach((card) => {
|
||||
card.style.cursor = "pointer";
|
||||
|
|
@ -493,6 +389,7 @@ export class FolkSplatViewer extends 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;
|
||||
|
|
@ -521,25 +418,41 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
submitBtn.disabled = true;
|
||||
actions.style.display = "none";
|
||||
progress.style.display = "block";
|
||||
status.textContent = "Preparing image...";
|
||||
|
||||
// Elapsed time ticker
|
||||
const startTime = Date.now();
|
||||
const phases = [
|
||||
{ t: 0, msg: "Preparing 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 ticker = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 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(), 90000);
|
||||
const timeout = setTimeout(() => controller.abort(), 120_000);
|
||||
try {
|
||||
// Resize image to max 1024px to reduce payload and improve API success
|
||||
const dataUrl = await this.resizeImage(selectedFile!, 1024);
|
||||
|
||||
status.textContent = "Generating 3D model...";
|
||||
const res = await fetch("/api/3d-gen", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_url: dataUrl }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearInterval(ticker);
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.status === 524) {
|
||||
status.textContent = "Request timed out — the model took too long. Try a simpler image.";
|
||||
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;
|
||||
|
|
@ -565,18 +478,25 @@ 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);
|
||||
status.textContent = `Generated in ${elapsed}s`;
|
||||
|
||||
// Open inline viewer with generated splat
|
||||
// Store generated info for save-to-gallery
|
||||
this._generatedUrl = data.url;
|
||||
this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, "");
|
||||
|
||||
// Open inline viewer with generated model
|
||||
this._mode = "viewer";
|
||||
this._splatUrl = data.url;
|
||||
this._splatTitle = selectedFile.name.replace(/\.[^.]+$/, "");
|
||||
this._splatTitle = this._generatedTitle;
|
||||
this._splatDesc = "AI-generated 3D model";
|
||||
this._inlineViewer = true;
|
||||
this.renderViewer();
|
||||
} catch (e: any) {
|
||||
clearInterval(ticker);
|
||||
clearTimeout(timeout);
|
||||
if (e.name === "AbortError") {
|
||||
status.textContent = "Request timed out after 90s — try a simpler image.";
|
||||
status.textContent = "Request timed out — try a simpler image.";
|
||||
} else {
|
||||
status.textContent = "Network error — could not reach server";
|
||||
}
|
||||
|
|
@ -618,14 +538,20 @@ 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>`
|
||||
: "";
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="splat-viewer">
|
||||
<div class="splat-loading" id="splat-loading">
|
||||
<div class="splat-loading__spinner"></div>
|
||||
<div class="splat-loading__text">Loading splat...</div>
|
||||
<div class="splat-loading__text">Loading 3D model...</div>
|
||||
</div>
|
||||
<div class="splat-viewer__controls">
|
||||
${backEl}
|
||||
${saveEl}
|
||||
</div>
|
||||
${this._splatTitle ? `
|
||||
<div class="splat-viewer__info">
|
||||
|
|
@ -639,23 +565,98 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
|
||||
if (this._inlineViewer) {
|
||||
this.querySelector("#splat-back-btn")?.addEventListener("click", () => {
|
||||
if (this._viewer) {
|
||||
try { this._viewer.dispose(); } catch {}
|
||||
this._viewer = null;
|
||||
}
|
||||
this.cleanupViewer();
|
||||
this._mode = "gallery";
|
||||
this._inlineViewer = false;
|
||||
this._splatUrl = "";
|
||||
this._splatTitle = "";
|
||||
this._splatDesc = "";
|
||||
this._generatedUrl = "";
|
||||
this._generatedTitle = "";
|
||||
if (this._spaceSlug === "demo") this.loadDemoData();
|
||||
this.renderGallery();
|
||||
});
|
||||
}
|
||||
|
||||
if (showSave) {
|
||||
this.querySelector("#splat-save-btn")?.addEventListener("click", () => this.saveToGallery());
|
||||
}
|
||||
|
||||
this.initThreeViewer();
|
||||
}
|
||||
|
||||
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 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 = localStorage.getItem("encryptid_token") || "";
|
||||
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 Trellis 2",
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
saveBtn.textContent = "Sign in to save";
|
||||
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 };
|
||||
saveBtn.textContent = "Saved!";
|
||||
this._generatedUrl = "";
|
||||
|
||||
// Navigate to the saved splat after a moment
|
||||
setTimeout(() => {
|
||||
window.location.href = `/${this._spaceSlug}/rsplat/view/${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;
|
||||
|
|
|
|||
|
|
@ -374,13 +374,39 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Back button (inline viewer) ── */
|
||||
/* ── Back + Save buttons (inline viewer) ── */
|
||||
|
||||
button.splat-viewer__back {
|
||||
button.splat-viewer__back,
|
||||
button.splat-viewer__save {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.splat-viewer__save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--splat-accent);
|
||||
backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--splat-accent);
|
||||
transition: background 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.splat-viewer__save:hover {
|
||||
background: var(--splat-accent-hover);
|
||||
}
|
||||
|
||||
.splat-viewer__save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Viewer ── */
|
||||
|
||||
.splat-viewer {
|
||||
|
|
|
|||
|
|
@ -541,6 +541,94 @@ routes.post("/api/splats/from-media", async (c) => {
|
|||
}, 201);
|
||||
});
|
||||
|
||||
// ── API: Save generated 3D model to gallery ──
|
||||
routes.post("/api/splats/save-generated", 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 { url, title, description } = await c.req.json();
|
||||
|
||||
if (!url || !title?.trim()) {
|
||||
return c.json({ error: "url and title required" }, 400);
|
||||
}
|
||||
|
||||
// Verify the file exists on disk (must be a generated file)
|
||||
const generatedDir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||||
const filename = url.split("/").pop();
|
||||
if (!filename || !filename.startsWith("splat-")) {
|
||||
return c.json({ error: "Invalid generated file URL" }, 400);
|
||||
}
|
||||
|
||||
const srcPath = resolve(generatedDir, filename);
|
||||
const srcFile = Bun.file(srcPath);
|
||||
if (!(await srcFile.exists())) {
|
||||
return c.json({ error: "Generated file not found" }, 404);
|
||||
}
|
||||
|
||||
// Determine format and create slug
|
||||
const ext = filename.split(".").pop() || "glb";
|
||||
const slug = slugify(title.trim());
|
||||
const shortId = randomUUID().slice(0, 8);
|
||||
const doc = ensureDoc(dataSpace);
|
||||
const slugExists = Object.values(doc.items).some((item) => item.slug === slug);
|
||||
const finalSlug = slugExists ? `${slug}-${shortId}` : slug;
|
||||
|
||||
// Copy file to splats directory
|
||||
await mkdir(SPLATS_DIR, { recursive: true });
|
||||
const destFilename = `${finalSlug}.${ext}`;
|
||||
const destPath = resolve(SPLATS_DIR, destFilename);
|
||||
const buf = await srcFile.arrayBuffer();
|
||||
await Bun.write(destPath, buf);
|
||||
|
||||
// Add to Automerge
|
||||
const splatId = randomUUID();
|
||||
const now = Date.now();
|
||||
const docId = splatScenesDocId(dataSpace);
|
||||
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'save generated splat', (d) => {
|
||||
d.items[splatId] = {
|
||||
id: splatId,
|
||||
slug: finalSlug,
|
||||
title: title.trim(),
|
||||
description: (description || "AI-generated 3D model").trim(),
|
||||
filePath: destFilename,
|
||||
fileFormat: ext,
|
||||
fileSizeBytes: buf.byteLength,
|
||||
tags: ["ai-generated"],
|
||||
spaceSlug,
|
||||
contributorId: claims.sub,
|
||||
contributorName: claims.username || null,
|
||||
source: 'ai-generated',
|
||||
status: 'published',
|
||||
viewCount: 0,
|
||||
paymentTx: null,
|
||||
paymentNetwork: null,
|
||||
createdAt: now,
|
||||
processingStatus: 'ready',
|
||||
processingError: null,
|
||||
sourceFileCount: 0,
|
||||
sourceFiles: [],
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({
|
||||
id: splatId,
|
||||
slug: finalSlug,
|
||||
title: title.trim(),
|
||||
file_format: ext,
|
||||
file_size_bytes: buf.byteLength,
|
||||
created_at: new Date(now).toISOString(),
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// ── API: Delete splat (owner only) ──
|
||||
routes.delete("/api/splats/:id", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
|
|
@ -600,12 +688,12 @@ routes.get("/", async (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
head: `
|
||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=2">
|
||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=3">
|
||||
${IMPORTMAP}
|
||||
`,
|
||||
scripts: `
|
||||
<script type="module">
|
||||
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=3';
|
||||
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=4';
|
||||
const gallery = document.getElementById('gallery');
|
||||
gallery.splats = ${splatsJSON};
|
||||
gallery.spaceSlug = '${spaceSlug}';
|
||||
|
|
@ -664,12 +752,12 @@ routes.get("/view/:id", async (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
head: `
|
||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=2">
|
||||
<link rel="stylesheet" href="/modules/rsplat/splat.css?v=3">
|
||||
${IMPORTMAP}
|
||||
`,
|
||||
scripts: `
|
||||
<script type="module">
|
||||
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=3';
|
||||
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=4';
|
||||
</script>
|
||||
`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -989,6 +989,8 @@ app.post("/api/3d-gen", async (c) => {
|
|||
if (!image_url) return c.json({ error: "image_url required" }, 400);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 120_000); // 120s server-side timeout
|
||||
const res = await fetch("https://fal.run/fal-ai/trellis-2", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -996,7 +998,9 @@ app.post("/api/3d-gen", async (c) => {
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ image_url, resolution: 1024 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
|
|
@ -1029,6 +1033,10 @@ app.post("/api/3d-gen", async (c) => {
|
|||
|
||||
return c.json({ url: `/data/files/generated/${filename}`, format: ext });
|
||||
} catch (e: any) {
|
||||
if (e.name === "AbortError") {
|
||||
console.error("[3d-gen] server-side timeout after 120s");
|
||||
return c.json({ error: "3D generation timed out — try a simpler image" }, 504);
|
||||
}
|
||||
console.error("[3d-gen] error:", e.message);
|
||||
return c.json({ error: "3D generation failed" }, 502);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue