From fd63c2bc6fa1d62bc95b3d48f28b2ca8413d1a3e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 15:48:43 -0700 Subject: [PATCH] feat(rsplat): default to image upload + switch job queue to Hunyuan3D - Default upload tab is now "Generate from Image" - Entire dotted drop area is clickable to open file browser - Update process3DGenJob to use Hunyuan3D v2.1 via fal.ai queue API (was still using old Trellis endpoint) Co-Authored-By: Claude Opus 4.6 --- server/index.ts | 77 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/server/index.ts b/server/index.ts index 032ea27..1992eb0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -723,20 +723,20 @@ async function sendSplatEmail(job: Gen3DJob) { async function process3DGenJob(job: Gen3DJob) { job.status = "processing"; - try { - const res = await fetch("https://fal.run/fal-ai/trellis", { - method: "POST", - headers: { - Authorization: `Key ${FAL_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ image_url: job.imageUrl }), - }); + 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 { + // 1. Submit to fal.ai queue + const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, { + method: "POST", + headers: falHeaders, + body: JSON.stringify({ input_image_url: job.imageUrl, 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); + let detail = "3D generation failed to start"; try { const parsed = JSON.parse(errText); if (parsed.detail) { @@ -750,9 +750,48 @@ async function process3DGenJob(job: Gen3DJob) { job.completedAt = Date.now(); return; } + const { request_id } = await submitRes.json() as { request_id: string }; - const data = await res.json(); - const modelUrl = data.glb_url || data.model_mesh?.url || data.output?.url; + // 2. Poll for completion (up to 5 min) + const deadline = Date.now() + 300_000; + let completed = false; + 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") { completed = true; break; } + if (status.status === "FAILED") { + job.status = "failed"; + job.error = "3D generation failed on fal.ai"; + job.completedAt = Date.now(); + return; + } + } + if (!completed) { + job.status = "failed"; + job.error = "3D generation timed out"; + job.completedAt = Date.now(); + return; + } + + // 3. Fetch result + const resultRes = await fetch( + `https://queue.fal.run/${MODEL}/requests/${request_id}`, + { headers: falHeaders }, + ); + if (!resultRes.ok) { + job.status = "failed"; + job.error = "Failed to retrieve 3D model"; + job.completedAt = Date.now(); + return; + } + + const data = await resultRes.json(); + const modelUrl = data.model_glb?.url || data.model_glb_pbr?.url; if (!modelUrl) { job.status = "failed"; job.error = "No 3D model returned"; @@ -760,6 +799,7 @@ async function process3DGenJob(job: Gen3DJob) { return; } + // 4. Download and save const modelRes = await fetch(modelUrl); if (!modelRes.ok) { job.status = "failed"; @@ -769,14 +809,13 @@ async function process3DGenJob(job: Gen3DJob) { } const modelBuf = await modelRes.arrayBuffer(); - const ext = modelUrl.includes(".ply") ? "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)}.glb`; const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await Bun.write(resolve(dir, filename), modelBuf); job.status = "complete"; job.resultUrl = `/data/files/generated/${filename}`; - job.resultFormat = ext; + job.resultFormat = "glb"; job.completedAt = Date.now(); console.log(`[3d-gen] Job ${job.id} complete: ${job.resultUrl}`); @@ -1171,7 +1210,7 @@ app.post("/api/image-stage", async (c) => { return c.json({ url: `${PUBLIC_ORIGIN}/data/files/generated/${filename}` }); }); -// Image-to-3D via fal.ai Trellis (async job queue) +// Image-to-3D via fal.ai Hunyuan3D v2.1 (async job queue) app.post("/api/3d-gen", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);