From ad22ed7482c7007f18665e2b1cd6927a3723a79d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 20:26:36 -0700 Subject: [PATCH] fix(rsplat): send image as data URI to fal.ai, fix http:// URL issue The publicUrl helper was generating http:// URLs (x-forwarded-proto from Traefik), causing fal.ai to fail with "Invalid image" 422 errors. Now reads the staged image from disk and sends as base64 data URI for reliable delivery. Also bumps poll timeout from 5 to 8 minutes and surfaces actual fal.ai error messages to the client. Co-Authored-By: Claude Opus 4.6 --- server/index.ts | 45 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/server/index.ts b/server/index.ts index 68787f6..4caf8bc 100644 --- a/server/index.ts +++ b/server/index.ts @@ -738,11 +738,30 @@ async function process3DGenJob(job: Gen3DJob) { const MODEL = "fal-ai/hunyuan3d-v21"; try { - // 1. Submit to fal.ai queue + // 1. Read staged image from disk and convert to data URI for reliable fal.ai delivery + let imageInput = job.imageUrl; + const stagedMatch = job.imageUrl.match(/\/(?:api\/files|data\/files)\/generated\/([^?#]+)/); + if (stagedMatch) { + try { + const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); + const file = Bun.file(resolve(dir, stagedMatch[1])); + if (await file.exists()) { + const buf = Buffer.from(await file.arrayBuffer()); + const ext = stagedMatch[1].split(".").pop()?.toLowerCase() || "jpg"; + const mime = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg"; + imageInput = `data:${mime};base64,${buf.toString("base64")}`; + console.log(`[3d-gen] Using data URI (${Math.round(buf.length / 1024)}KB) instead of URL`); + } + } catch (e: any) { + console.warn(`[3d-gen] Data URI fallback failed, using URL:`, e.message); + } + } + + // 2. 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 }), + body: JSON.stringify({ input_image_url: imageInput, textured_mesh: true, octree_resolution: 256 }), }); if (!submitRes.ok) { const errText = await submitRes.text(); @@ -763,8 +782,8 @@ async function process3DGenJob(job: Gen3DJob) { } const { request_id } = await submitRes.json() as { request_id: string }; - // 2. Poll for completion (up to 5 min) - const deadline = Date.now() + 300_000; + // 3. Poll for completion (up to 8 min — Hunyuan3D with textures can take 3-5 min) + const deadline = Date.now() + 480_000; let completed = false; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 3000)); @@ -789,7 +808,7 @@ async function process3DGenJob(job: Gen3DJob) { return; } - // 3. Fetch result (retry once after 3s on transient failure) + // 4. Fetch result (retry once after 3s on transient failure) let resultRes = await fetch( `https://queue.fal.run/${MODEL}/requests/${request_id}`, { headers: falHeaders }, @@ -807,8 +826,16 @@ async function process3DGenJob(job: Gen3DJob) { if (!resultRes.ok) { const errBody = await resultRes.text().catch(() => ""); console.error(`[3d-gen] Result fetch failed after retry (status=${resultRes.status}):`, errBody); + let detail = "Failed to retrieve 3D model"; + try { + const parsed = JSON.parse(errBody); + if (parsed.detail) { + detail = Array.isArray(parsed.detail) ? parsed.detail[0]?.msg || detail + : typeof parsed.detail === "string" ? parsed.detail : detail; + } + } catch {} job.status = "failed"; - job.error = "Failed to retrieve 3D model"; + job.error = detail; job.completedAt = Date.now(); return; } @@ -824,7 +851,7 @@ async function process3DGenJob(job: Gen3DJob) { return; } - // 4. Download and save + // 5. Download and save const modelRes = await fetch(modelUrl); if (!modelRes.ok) { job.status = "failed"; @@ -1207,8 +1234,8 @@ const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || "https://rspace.online"; /** Build public URL using the request's Host header so subdomains (jeff.rspace.online) work */ function publicUrl(c: any, path: string): string { const host = c.req.header("host") || new URL(PUBLIC_ORIGIN).host; - const proto = c.req.header("x-forwarded-proto") || "https"; - return `${proto}://${host}${path}`; + // Always use https — site is behind Cloudflare/Traefik, x-forwarded-proto may be "http" internally + return `https://${host}${path}`; } app.post("/api/image-stage", async (c) => {