From 55b47901abda5c0a6a0dac7b711935ddc41d0559 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 14:46:30 -0700 Subject: [PATCH] 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 --- .../rsplat/components/folk-splat-viewer.ts | 13 ++-- server/index.ts | 77 +++++++++++-------- 2 files changed, 51 insertions(+), 39 deletions(-) diff --git a/modules/rsplat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts index ce7d8fa..e3af9eb 100644 --- a/modules/rsplat/components/folk-splat-viewer.ts +++ b/modules/rsplat/components/folk-splat-viewer.ts @@ -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 => { diff --git a/server/index.ts b/server/index.ts index 45a6ca6..4dc199e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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); }