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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 20:26:36 -07:00
parent d5d3f09b28
commit ad22ed7482
1 changed files with 36 additions and 9 deletions

View File

@ -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) => {