diff --git a/docker-compose.yml b/docker-compose.yml index e3b6c37..48af458 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -282,6 +282,16 @@ services: networks: - rspace-internal + # ── Blender headless render worker ── + blender-worker: + build: ./docker/blender-worker + container_name: blender-worker + restart: unless-stopped + volumes: + - rspace-files:/data/files + networks: + - rspace-internal + # ── Scribus noVNC (rDesign DTP workspace) ── scribus-novnc: build: diff --git a/docker/blender-worker/Dockerfile b/docker/blender-worker/Dockerfile new file mode 100644 index 0000000..93e9e7a --- /dev/null +++ b/docker/blender-worker/Dockerfile @@ -0,0 +1,20 @@ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + blender \ + python3 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +ENV QT_QPA_PLATFORM=offscreen +ENV DISPLAY="" + +WORKDIR /app + +COPY server.py . + +RUN mkdir -p /data/files/generated + +EXPOSE 8810 + +CMD ["python3", "server.py"] diff --git a/docker/blender-worker/server.py b/docker/blender-worker/server.py new file mode 100644 index 0000000..a119b02 --- /dev/null +++ b/docker/blender-worker/server.py @@ -0,0 +1,104 @@ +"""Headless Blender render worker — accepts scripts via HTTP, returns rendered images.""" + +import json +import os +import random +import string +import subprocess +import time +from http.server import HTTPServer, BaseHTTPRequestHandler + +GENERATED_DIR = "/data/files/generated" +BLENDER_TIMEOUT = 90 # seconds (fits within CF 100s limit) + + +class RenderHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + self._json_response(200, {"ok": True, "service": "blender-worker"}) + else: + self._json_response(404, {"error": "not found"}) + + def do_POST(self): + if self.path != "/render": + self._json_response(404, {"error": "not found"}) + return + + try: + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + except (json.JSONDecodeError, ValueError): + self._json_response(400, {"error": "invalid JSON"}) + return + + script = body.get("script", "").strip() + if not script: + self._json_response(400, {"error": "script required"}) + return + + # Write script to temp file + script_path = "/tmp/scene.py" + render_path = "/tmp/render.png" + + with open(script_path, "w") as f: + f.write(script) + + # Clean any previous render + if os.path.exists(render_path): + os.remove(render_path) + + # Run Blender headless + try: + result = subprocess.run( + ["blender", "--background", "--python", script_path], + capture_output=True, + text=True, + timeout=BLENDER_TIMEOUT, + ) + except subprocess.TimeoutExpired: + self._json_response(504, { + "success": False, + "error": f"Blender timed out after {BLENDER_TIMEOUT}s", + }) + return + + # Check if render was produced + if not os.path.exists(render_path): + self._json_response(422, { + "success": False, + "error": "Blender finished but no render output at /tmp/render.png", + "stdout": result.stdout[-2000:] if result.stdout else "", + "stderr": result.stderr[-2000:] if result.stderr else "", + }) + return + + # Move render to shared volume with unique name + rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) + filename = f"blender-{int(time.time())}-{rand}.png" + dest = os.path.join(GENERATED_DIR, filename) + + os.makedirs(GENERATED_DIR, exist_ok=True) + os.rename(render_path, dest) + + self._json_response(200, { + "success": True, + "render_url": f"/data/files/generated/{filename}", + "filename": filename, + }) + + def _json_response(self, status, data): + body = json.dumps(data).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + print(f"[blender-worker] {fmt % args}") + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", 8810), RenderHandler) + print("[blender-worker] listening on :8810") + server.serve_forever() diff --git a/server/index.ts b/server/index.ts index e453264..9daf09d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1550,14 +1550,22 @@ app.get("/api/3d-gen/:jobId", async (c) => { return c.json(response); }); -// Blender 3D generation via LLM + RunPod -const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || ""; +// Blender 3D generation via LLM + headless worker sidecar +const BLENDER_WORKER_URL = process.env.BLENDER_WORKER_URL || "http://blender-worker:8810"; app.get("/api/blender-gen/health", async (c) => { const issues: string[] = []; const warnings: string[] = []; if (!GEMINI_API_KEY) issues.push("GEMINI_API_KEY not configured"); - if (!RUNPOD_API_KEY) warnings.push("RunPod not configured — script-only mode"); + + // Check blender-worker health + try { + const res = await fetch(`${BLENDER_WORKER_URL}/health`, { signal: AbortSignal.timeout(3000) }); + if (!res.ok) warnings.push("blender-worker unhealthy"); + } catch { + warnings.push("blender-worker unreachable — script-only mode"); + } + return c.json({ available: issues.length === 0, issues, warnings }); }); @@ -1596,38 +1604,37 @@ Output ONLY the Python code, no explanations or comments outside the code.`); return c.json({ error: "Failed to generate Blender script" }, 502); } - // Step 2: Execute on RunPod (headless Blender) — optional - if (!RUNPOD_API_KEY) { - return c.json({ script, render_url: null, blend_url: null }); - } - + // Step 2: Execute on blender-worker sidecar (headless Blender) try { - const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", { + const workerRes = await fetch(`${BLENDER_WORKER_URL}/render`, { method: "POST", - headers: { - Authorization: `Bearer ${RUNPOD_API_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - input: { - script, - render: true, - }, - }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ script }), + signal: AbortSignal.timeout(95_000), // 95s — worker has 90s internal timeout }); - if (!runpodRes.ok) { - return c.json({ script, error_detail: "RunPod execution failed" }); + const data = await workerRes.json() as { + success?: boolean; + render_url?: string; + error?: string; + stdout?: string; + stderr?: string; + }; + + if (data.success && data.render_url) { + return c.json({ script, render_url: data.render_url }); } - const runpodData = await runpodRes.json(); + // Worker ran but render failed — return script + error details return c.json({ - render_url: runpodData.output?.render_url || null, script, - blend_url: runpodData.output?.blend_url || null, + render_url: null, + error_detail: data.error || "Render failed", }); } catch (e) { - return c.json({ script, error_detail: "RunPod unavailable" }); + console.error("[blender-gen] worker error:", e); + // Worker unreachable — return script only + return c.json({ script, render_url: null, error_detail: "blender-worker unavailable" }); } }); diff --git a/shared/tab-cache.ts b/shared/tab-cache.ts index 1ac9a2a..0d1713b 100644 --- a/shared/tab-cache.ts +++ b/shared/tab-cache.ts @@ -344,6 +344,22 @@ export class TabCache { const el = document.createElement("script"); el.type = "module"; el.src = src; + // If a script fails (502, network error), evict the pane from cache + // so the next switchTo re-fetches everything instead of showing a blank pane. + el.onerror = () => { + console.warn("[TabCache] script load failed:", src, "— evicting pane", moduleId); + el.remove(); + if (moduleId) { + // moduleId here is "space-module" format — find and remove the pane + for (const [key, pane] of this.panes) { + if (pane.dataset.moduleId && moduleId.endsWith(pane.dataset.moduleId)) { + pane.remove(); + this.panes.delete(key); + break; + } + } + } + }; document.body.appendChild(el); }