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