fix(rsplat): use fal.ai queue API to avoid timeout on Hunyuan3D
Synchronous fal.run endpoint times out for textured mesh generation. Switch to queue.fal.run submit/poll/result pattern with 5-minute deadline. Update client progress phases for longer generation time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ef60e29da3
commit
55b47901ab
|
|
@ -490,14 +490,15 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
const phases = [
|
||||
{ t: 0, msg: "Staging image..." },
|
||||
{ t: 3, msg: "Uploading to Hunyuan3D..." },
|
||||
{ t: 8, msg: "Reconstructing 3D geometry..." },
|
||||
{ t: 20, msg: "Generating mesh and textures..." },
|
||||
{ t: 45, msg: "Finalizing model..." },
|
||||
{ t: 75, msg: "Almost there..." },
|
||||
{ t: 10, msg: "Reconstructing 3D geometry..." },
|
||||
{ t: 30, msg: "Generating mesh and textures..." },
|
||||
{ t: 60, msg: "Baking textures..." },
|
||||
{ t: 120, msg: "Finalizing model..." },
|
||||
{ t: 180, msg: "Almost there..." },
|
||||
];
|
||||
|
||||
// Realistic progress curve — Hunyuan3D typically takes 30-60s
|
||||
const EXPECTED_SECONDS = 45;
|
||||
// Realistic progress curve — Hunyuan3D with textures takes 60-180s
|
||||
const EXPECTED_SECONDS = 120;
|
||||
const progressBar = progress.querySelector(".splat-generate__progress-bar") as HTMLElement;
|
||||
|
||||
const estimatePercent = (elapsed: number): number => {
|
||||
|
|
|
|||
|
|
@ -1019,47 +1019,62 @@ app.post("/api/image-stage", async (c) => {
|
|||
return c.json({ url: `${PUBLIC_ORIGIN}/data/files/generated/${filename}` });
|
||||
});
|
||||
|
||||
// Image-to-3D via fal.ai Hunyuan3D v2.1
|
||||
// Image-to-3D via fal.ai Hunyuan3D v2.1 (queue API for long-running jobs)
|
||||
app.post("/api/3d-gen", async (c) => {
|
||||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||||
|
||||
const { image_url } = await c.req.json();
|
||||
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/hunyuan3d-v21", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Key ${FAL_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ input_image_url: image_url, textured_mesh: true, octree_resolution: 256 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" };
|
||||
const MODEL = "fal-ai/hunyuan3d-v21";
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
console.error("[3d-gen] fal.ai error:", res.status, errText);
|
||||
let detail = "3D generation failed";
|
||||
try {
|
||||
const parsed = JSON.parse(errText);
|
||||
if (parsed.detail) {
|
||||
detail = typeof parsed.detail === "string" ? parsed.detail
|
||||
: Array.isArray(parsed.detail) ? parsed.detail[0]?.msg || detail
|
||||
: detail;
|
||||
}
|
||||
} catch {}
|
||||
return c.json({ error: detail }, 502);
|
||||
try {
|
||||
// 1. Submit to queue
|
||||
const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
|
||||
method: "POST",
|
||||
headers: falHeaders,
|
||||
body: JSON.stringify({ input_image_url: image_url, textured_mesh: true, octree_resolution: 256 }),
|
||||
});
|
||||
if (!submitRes.ok) {
|
||||
const errText = await submitRes.text();
|
||||
console.error("[3d-gen] fal.ai submit error:", submitRes.status, errText);
|
||||
return c.json({ error: "3D generation failed to start" }, 502);
|
||||
}
|
||||
const { request_id } = await submitRes.json() as { request_id: string };
|
||||
|
||||
// 2. Poll for completion (up to 5 min)
|
||||
const deadline = Date.now() + 300_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const statusRes = await fetch(
|
||||
`https://queue.fal.run/${MODEL}/requests/${request_id}/status`,
|
||||
{ headers: falHeaders },
|
||||
);
|
||||
if (!statusRes.ok) continue;
|
||||
const status = await statusRes.json() as { status: string };
|
||||
if (status.status === "COMPLETED") break;
|
||||
if (status.status === "FAILED") {
|
||||
console.error("[3d-gen] fal.ai job failed:", JSON.stringify(status));
|
||||
return c.json({ error: "3D generation failed" }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// 3. Fetch result
|
||||
const resultRes = await fetch(
|
||||
`https://queue.fal.run/${MODEL}/requests/${request_id}`,
|
||||
{ headers: falHeaders },
|
||||
);
|
||||
if (!resultRes.ok) {
|
||||
console.error("[3d-gen] fal.ai result error:", resultRes.status);
|
||||
return c.json({ error: "Failed to retrieve 3D model" }, 502);
|
||||
}
|
||||
|
||||
const data = await resultRes.json();
|
||||
const modelUrl = data.model_glb?.url || data.model_glb_pbr?.url;
|
||||
if (!modelUrl) return c.json({ error: "No 3D model returned" }, 502);
|
||||
|
||||
// Download the model file
|
||||
// 4. Download and save
|
||||
const modelRes = await fetch(modelUrl);
|
||||
if (!modelRes.ok) return c.json({ error: "Failed to download model" }, 502);
|
||||
|
||||
|
|
@ -1070,10 +1085,6 @@ app.post("/api/3d-gen", async (c) => {
|
|||
|
||||
return c.json({ url: `/data/files/generated/${filename}`, format: "glb" });
|
||||
} 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