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:
parent
d5d3f09b28
commit
ad22ed7482
|
|
@ -738,11 +738,30 @@ async function process3DGenJob(job: Gen3DJob) {
|
||||||
const MODEL = "fal-ai/hunyuan3d-v21";
|
const MODEL = "fal-ai/hunyuan3d-v21";
|
||||||
|
|
||||||
try {
|
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}`, {
|
const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: falHeaders,
|
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) {
|
if (!submitRes.ok) {
|
||||||
const errText = await submitRes.text();
|
const errText = await submitRes.text();
|
||||||
|
|
@ -763,8 +782,8 @@ async function process3DGenJob(job: Gen3DJob) {
|
||||||
}
|
}
|
||||||
const { request_id } = await submitRes.json() as { request_id: string };
|
const { request_id } = await submitRes.json() as { request_id: string };
|
||||||
|
|
||||||
// 2. Poll for completion (up to 5 min)
|
// 3. Poll for completion (up to 8 min — Hunyuan3D with textures can take 3-5 min)
|
||||||
const deadline = Date.now() + 300_000;
|
const deadline = Date.now() + 480_000;
|
||||||
let completed = false;
|
let completed = false;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
|
@ -789,7 +808,7 @@ async function process3DGenJob(job: Gen3DJob) {
|
||||||
return;
|
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(
|
let resultRes = await fetch(
|
||||||
`https://queue.fal.run/${MODEL}/requests/${request_id}`,
|
`https://queue.fal.run/${MODEL}/requests/${request_id}`,
|
||||||
{ headers: falHeaders },
|
{ headers: falHeaders },
|
||||||
|
|
@ -807,8 +826,16 @@ async function process3DGenJob(job: Gen3DJob) {
|
||||||
if (!resultRes.ok) {
|
if (!resultRes.ok) {
|
||||||
const errBody = await resultRes.text().catch(() => "");
|
const errBody = await resultRes.text().catch(() => "");
|
||||||
console.error(`[3d-gen] Result fetch failed after retry (status=${resultRes.status}):`, errBody);
|
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.status = "failed";
|
||||||
job.error = "Failed to retrieve 3D model";
|
job.error = detail;
|
||||||
job.completedAt = Date.now();
|
job.completedAt = Date.now();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -824,7 +851,7 @@ async function process3DGenJob(job: Gen3DJob) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Download and save
|
// 5. Download and save
|
||||||
const modelRes = await fetch(modelUrl);
|
const modelRes = await fetch(modelUrl);
|
||||||
if (!modelRes.ok) {
|
if (!modelRes.ok) {
|
||||||
job.status = "failed";
|
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 */
|
/** Build public URL using the request's Host header so subdomains (jeff.rspace.online) work */
|
||||||
function publicUrl(c: any, path: string): string {
|
function publicUrl(c: any, path: string): string {
|
||||||
const host = c.req.header("host") || new URL(PUBLIC_ORIGIN).host;
|
const host = c.req.header("host") || new URL(PUBLIC_ORIGIN).host;
|
||||||
const proto = c.req.header("x-forwarded-proto") || "https";
|
// Always use https — site is behind Cloudflare/Traefik, x-forwarded-proto may be "http" internally
|
||||||
return `${proto}://${host}${path}`;
|
return `https://${host}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post("/api/image-stage", async (c) => {
|
app.post("/api/image-stage", async (c) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue