feat(rsplat): switch AI generation from Trellis 2 to SAM 3D
SAM 3D outputs native Gaussian splat .ply files (rendered via existing initSplatViewer) instead of GLB meshes, with full-scene support including people and backgrounds. Faster generation (5-30s vs 45-75s), $0.02/gen. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d270e7c03a
commit
0f1090db44
|
|
@ -249,7 +249,7 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
<div class="splat-upload__mode" id="splat-mode-generate" style="display:none">
|
<div class="splat-upload__mode" id="splat-mode-generate" style="display:none">
|
||||||
<div class="splat-upload__icon">✨</div>
|
<div class="splat-upload__icon">✨</div>
|
||||||
<p class="splat-upload__text">
|
<p class="splat-upload__text">
|
||||||
Upload a single <strong>image</strong> to generate a 3D model using AI (Trellis 2)
|
Upload a single <strong>image</strong> to generate a 3D Gaussian splat using AI (SAM 3D)
|
||||||
<br>or <strong id="generate-browse">browse</strong> to select an image
|
<br>or <strong id="generate-browse">browse</strong> to select an image
|
||||||
</p>
|
</p>
|
||||||
<input type="file" id="generate-file" accept=".jpg,.jpeg,.png,.webp" hidden>
|
<input type="file" id="generate-file" accept=".jpg,.jpeg,.png,.webp" hidden>
|
||||||
|
|
@ -489,15 +489,15 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
let hiddenAt = 0;
|
let hiddenAt = 0;
|
||||||
const phases = [
|
const phases = [
|
||||||
{ t: 0, msg: "Staging image..." },
|
{ t: 0, msg: "Staging image..." },
|
||||||
{ t: 3, msg: "Uploading to Trellis 2..." },
|
{ t: 2, msg: "Uploading to SAM 3D..." },
|
||||||
{ t: 8, msg: "Reconstructing 3D geometry..." },
|
{ t: 5, msg: "Segmenting scene..." },
|
||||||
{ t: 20, msg: "Generating mesh and textures..." },
|
{ t: 10, msg: "Generating Gaussian splats..." },
|
||||||
{ t: 45, msg: "Finalizing model..." },
|
{ t: 18, msg: "Finalizing model..." },
|
||||||
{ t: 75, msg: "Almost there..." },
|
{ t: 30, msg: "Almost there..." },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Realistic progress curve — typical Trellis 2 takes 45-75s
|
// Realistic progress curve — typical SAM 3D takes 5-30s
|
||||||
const EXPECTED_SECONDS = 60;
|
const EXPECTED_SECONDS = 20;
|
||||||
const progressBar = progress.querySelector(".splat-generate__progress-bar") as HTMLElement;
|
const progressBar = progress.querySelector(".splat-generate__progress-bar") as HTMLElement;
|
||||||
|
|
||||||
const estimatePercent = (elapsed: number): number => {
|
const estimatePercent = (elapsed: number): number => {
|
||||||
|
|
@ -618,7 +618,7 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: this._generatedUrl,
|
url: this._generatedUrl,
|
||||||
title: this._generatedTitle || "AI Generated Model",
|
title: this._generatedTitle || "AI Generated Model",
|
||||||
description: "AI-generated 3D model via Trellis 2",
|
description: "AI-generated 3D model via SAM 3D",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -786,7 +786,7 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: this._generatedUrl,
|
url: this._generatedUrl,
|
||||||
title: this._generatedTitle || "AI Generated Model",
|
title: this._generatedTitle || "AI Generated Model",
|
||||||
description: "AI-generated 3D model via Trellis 2",
|
description: "AI-generated 3D model via SAM 3D",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -717,7 +717,7 @@ routes.get("/", async (c) => {
|
||||||
`,
|
`,
|
||||||
scripts: `
|
scripts: `
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=6';
|
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=7';
|
||||||
const gallery = document.getElementById('gallery');
|
const gallery = document.getElementById('gallery');
|
||||||
gallery.splats = ${splatsJSON};
|
gallery.splats = ${splatsJSON};
|
||||||
gallery.spaceSlug = '${spaceSlug}';
|
gallery.spaceSlug = '${spaceSlug}';
|
||||||
|
|
@ -777,7 +777,7 @@ function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string
|
||||||
`,
|
`,
|
||||||
scripts: `
|
scripts: `
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=6';
|
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=7';
|
||||||
</script>
|
</script>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1019,7 +1019,7 @@ app.post("/api/image-stage", async (c) => {
|
||||||
return c.json({ url: `${PUBLIC_ORIGIN}/data/files/generated/${filename}` });
|
return c.json({ url: `${PUBLIC_ORIGIN}/data/files/generated/${filename}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Image-to-3D via fal.ai Trellis
|
// Image-to-3D via fal.ai SAM 3D
|
||||||
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);
|
||||||
|
|
||||||
|
|
@ -1029,13 +1029,13 @@ app.post("/api/3d-gen", async (c) => {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 120_000); // 120s server-side timeout
|
const timeout = setTimeout(() => controller.abort(), 120_000); // 120s server-side timeout
|
||||||
const res = await fetch("https://fal.run/fal-ai/trellis-2", {
|
const res = await fetch("https://fal.run/fal-ai/sam-3/3d-objects", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Key ${FAL_KEY}`,
|
Authorization: `Key ${FAL_KEY}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ image_url, resolution: 1024 }),
|
body: JSON.stringify({ image_url, export_textured_glb: true }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
@ -1056,7 +1056,10 @@ app.post("/api/3d-gen", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const modelUrl = data.model_glb?.url || data.glb_url || data.model_mesh?.url;
|
// SAM 3D: prefer Gaussian splat (.ply), fallback to GLB mesh
|
||||||
|
const splatUrl = data.gaussian_splat?.url;
|
||||||
|
const glbUrl = data.model_glb?.url || data.glb_url || data.model_mesh?.url;
|
||||||
|
const modelUrl = splatUrl || glbUrl;
|
||||||
if (!modelUrl) return c.json({ error: "No 3D model returned" }, 502);
|
if (!modelUrl) return c.json({ error: "No 3D model returned" }, 502);
|
||||||
|
|
||||||
// Download the model file
|
// Download the model file
|
||||||
|
|
@ -1064,7 +1067,7 @@ app.post("/api/3d-gen", async (c) => {
|
||||||
if (!modelRes.ok) return c.json({ error: "Failed to download model" }, 502);
|
if (!modelRes.ok) return c.json({ error: "Failed to download model" }, 502);
|
||||||
|
|
||||||
const modelBuf = await modelRes.arrayBuffer();
|
const modelBuf = await modelRes.arrayBuffer();
|
||||||
const ext = modelUrl.includes(".ply") ? "ply" : "glb";
|
const ext = splatUrl ? "ply" : "glb";
|
||||||
const filename = `splat-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
|
const filename = `splat-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
|
||||||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||||||
await Bun.write(resolve(dir, filename), modelBuf);
|
await Bun.write(resolve(dir, filename), modelBuf);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue