#!/usr/bin/env python3 """Image generation service — proxies to ComfyUI API on :8188. Serves on :3600. Submits workflow to ComfyUI, polls for completion, returns image. Falls back to direct diffusers if ComfyUI is unavailable. Features: - Disk cache — same prompt returns cached image instantly - Negative prompt for quality (no faces, hands, text) - DreamShaper XL Turbo for high-quality editorial illustrations """ import base64 import hashlib import io import json import os import random import time import urllib.request import urllib.error from http.server import HTTPServer, BaseHTTPRequestHandler from pathlib import Path PORT = int(os.environ.get("IMAGEGEN_PORT", "3600")) COMFYUI_URL = os.environ.get("COMFYUI_URL", "http://localhost:8188") CACHE_DIR = Path(os.environ.get("IMAGEGEN_CACHE", "./data/_imagecache")) CACHE_DIR.mkdir(parents=True, exist_ok=True) WORKFLOW_PATH = "/opt/ComfyUI/workflows/editorial_hero.json" def _cache_key(prompt, width, height, steps): return hashlib.sha256(f"{prompt}|{width}|{height}|{steps}".encode()).hexdigest()[:24] def _cache_get(key): fp = CACHE_DIR / f"{key}.webp" return base64.b64encode(fp.read_bytes()).decode() if fp.exists() else None def _cache_put(key, img_bytes): (CACHE_DIR / f"{key}.webp").write_bytes(img_bytes) def _comfyui_generate(prompt, width=1024, height=512, steps=8, seed=None): """Submit workflow to ComfyUI and wait for result.""" # Load workflow template with open(WORKFLOW_PATH) as f: workflow = json.load(f) # Customize if seed is None: seed = random.randint(0, 2**32) workflow["3"]["inputs"]["seed"] = seed workflow["3"]["inputs"]["steps"] = steps workflow["5"]["inputs"]["width"] = width workflow["5"]["inputs"]["height"] = height workflow["6"]["inputs"]["text"] = prompt # Submit to ComfyUI payload = json.dumps({"prompt": workflow}).encode() req = urllib.request.Request( f"{COMFYUI_URL}/prompt", data=payload, headers={"Content-Type": "application/json"} ) resp = urllib.request.urlopen(req, timeout=10) result = json.loads(resp.read()) prompt_id = result["prompt_id"] # Poll for completion for _ in range(120): # up to 2 minutes time.sleep(0.5) try: status_req = urllib.request.Request(f"{COMFYUI_URL}/history/{prompt_id}") status_resp = urllib.request.urlopen(status_req, timeout=5) history = json.loads(status_resp.read()) if prompt_id in history: outputs = history[prompt_id].get("outputs", {}) # Find the SaveImage node output for node_id, node_out in outputs.items(): images = node_out.get("images", []) if images: img_info = images[0] # Fetch the image img_url = f"{COMFYUI_URL}/view?filename={img_info['filename']}&subfolder={img_info.get('subfolder', '')}&type={img_info.get('type', 'output')}" img_resp = urllib.request.urlopen(img_url, timeout=10) img_data = img_resp.read() # Convert to webp from PIL import Image img = Image.open(io.BytesIO(img_data)) buf = io.BytesIO() img.save(buf, format="WEBP", quality=90) return buf.getvalue(), seed return None, seed # completed but no images except Exception: continue return None, seed # timeout def _diffusers_fallback(prompt, width, height, steps, seed): """Fallback: use raw diffusers SDXL Turbo if ComfyUI is down.""" import torch from diffusers import AutoPipelineForText2Image pipe = AutoPipelineForText2Image.from_pretrained( "stabilityai/sdxl-turbo", torch_dtype=torch.float16, variant="fp16" ).to("cuda") pipe.enable_attention_slicing() if seed is None: seed = random.randint(0, 2**32) gen = torch.Generator("cuda").manual_seed(seed) result = pipe(prompt=prompt, num_inference_steps=steps, guidance_scale=0.0, width=width, height=height, generator=gen) buf = io.BytesIO() result.images[0].save(buf, format="WEBP", quality=90) del pipe torch.cuda.empty_cache() return buf.getvalue(), seed class ImageHandler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass def _json(self, code, data): self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(json.dumps(data).encode()) def do_OPTIONS(self): self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() def do_GET(self): if self.path == "/health": comfy_ok = False try: r = urllib.request.urlopen(f"{COMFYUI_URL}/system_stats", timeout=3) comfy_ok = r.status == 200 except: pass cached = len(list(CACHE_DIR.glob("*.webp"))) self._json(200, {"status": "ok", "comfyui": comfy_ok, "cached_images": cached}) elif self.path == "/cache/stats": files = list(CACHE_DIR.glob("*.webp")) self._json(200, {"count": len(files), "total_mb": round(sum(f.stat().st_size for f in files)/1024**2, 1)}) else: self._json(404, {"error": "not found"}) def do_POST(self): if self.path == "/generate": self._generate() elif self.path == "/blender": self._blender_render() elif self.path == "/img-to-3d": self._img_to_3d() elif self.path == "/scene-glb": self._scene_glb() else: self._json(404, {"error": "not found"}) def _generate(self): try: length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length)) if length else {} except: self._json(400, {"error": "invalid JSON"}); return prompt = body.get("prompt", "").strip() if not prompt: self._json(400, {"error": "prompt required"}); return width = min(max(int(body.get("width", 1280)), 256), 1920) height = min(max(int(body.get("height", 720)), 256), 1080) steps = min(max(int(body.get("steps", 50)), 1), 80) seed = body.get("seed") # Cache check key = _cache_key(prompt, width, height, steps) cached = _cache_get(key) if cached: self._json(200, {"image": cached, "format": "webp", "width": width, "height": height, "cached": True, "prompt": prompt[:200]}); return t0 = time.time() img_bytes = None # Try ComfyUI first try: comfy_check = urllib.request.urlopen(f"{COMFYUI_URL}/system_stats", timeout=3) if comfy_check.status == 200: img_bytes, seed = _comfyui_generate(prompt, width, height, steps, seed) backend = "comfyui" except: pass # Fallback to diffusers if not img_bytes: try: img_bytes, seed = _diffusers_fallback(prompt, width, height, steps, seed) backend = "diffusers" except Exception as e: self._json(500, {"error": str(e)[:300]}); return if not img_bytes: self._json(500, {"error": "generation failed"}); return elapsed_ms = int((time.time() - t0) * 1000) img_b64 = base64.b64encode(img_bytes).decode() _cache_put(key, img_bytes) self._json(200, { "image": img_b64, "format": "webp", "width": width, "height": height, "steps": steps, "seed": seed, "time_ms": elapsed_ms, "backend": backend, "prompt": prompt[:200], "cached": False, }) print(f"[IMAGEGEN] {backend} {width}x{height} in {elapsed_ms}ms") def _blender_render(self): """Render a 3D hero banner via Blender Cycles GPU.""" try: length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length)) if length else {} except: self._json(400, {"error": "invalid JSON"}); return seed = body.get("seed", random.randint(0, 99999)) # Cache check key = f"blender-{seed}" cached = _cache_get(key) if cached: self._json(200, {"image": cached, "format": "webp", "backend": "blender-cached", "cached": True, "seed": seed}); return t0 = time.time() output_png = f"/tmp/blender_render_{seed}.png" script = "/opt/ComfyUI/blender_scripts/hero_cycles.py" try: import subprocess result = subprocess.run( ["blender", "--background", "--python", script, "--", str(seed), output_png], capture_output=True, text=True, timeout=300 ) if not os.path.exists(output_png): self._json(500, {"error": "Blender render failed: " + result.stderr[-300:] if result.stderr else "no output"}); return # Convert to webp from PIL import Image img = Image.open(output_png) buf = io.BytesIO() img.save(buf, format="WEBP", quality=92) img_bytes = buf.getvalue() os.remove(output_png) elapsed_ms = int((time.time() - t0) * 1000) img_b64 = base64.b64encode(img_bytes).decode() _cache_put(key, img_bytes) self._json(200, { "image": img_b64, "format": "webp", "width": 1280, "height": 320, "seed": seed, "time_ms": elapsed_ms, "backend": "blender-cycles", "cached": False, }) print(f"[BLENDER] Rendered seed={seed} in {elapsed_ms}ms") except Exception as e: self._json(500, {"error": str(e)[:300]}) def _img_to_3d(self): """Full pipeline: AI image → 3D displacement → Blender render.""" try: length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length)) if length else {} except: self._json(400, {"error": "invalid JSON"}); return prompt = body.get("prompt", "abstract flowing golden energy, fractal patterns, dark background, sharp detail").strip() seed = body.get("seed", random.randint(0, 99999)) key = f"img3d-{hashlib.sha256(prompt.encode()).hexdigest()[:12]}-{seed % 4}" cached = _cache_get(key) if cached: self._json(200, {"image": cached, "format": "webp", "backend": "img3d-cached", "cached": True}); return t0 = time.time() try: import subprocess # Step 1: Generate source image via ComfyUI directly src_path = f"/tmp/img3d_src_{seed}.png" try: img_bytes_src, _ = _comfyui_generate(prompt, 512, 512, 25, seed) if not img_bytes_src: # Fallback to diffusers img_bytes_src, _ = _diffusers_fallback(prompt, 512, 512, 8, seed) if not img_bytes_src: self._json(500, {"error": "Failed to generate source image"}); return from PIL import Image img_src = Image.open(io.BytesIO(img_bytes_src)) img_src.save(src_path, "PNG") except Exception as e: self._json(500, {"error": f"Source image failed: {e}"}); return # Step 2: TripoSR — convert image to 3D mesh mesh_path = f"/tmp/triposr_mesh_{seed}.obj" out_path = f"/tmp/img3d_out_{seed}.png" try: # Free VRAM for TripoSR subprocess.run(["systemctl", "stop", "comfyui"], capture_output=True, timeout=10) time.sleep(3) triposr_script = f""" import torch, sys sys.path.insert(0, '/opt/TripoSR') from PIL import Image from tsr.system import TSR model = TSR.from_pretrained('stabilityai/TripoSR', config_name='config.yaml', weight_name='model.ckpt') model.to('cuda') image = Image.open('{src_path}').convert('RGB') with torch.no_grad(): scene_codes = model([image], device='cuda') meshes = model.extract_mesh(scene_codes, has_vertex_color=True, resolution=128) meshes[0].export('{mesh_path}') del model; torch.cuda.empty_cache() print('[TRIPOSR] mesh exported') """ result = subprocess.run( ["python3", "-c", triposr_script], capture_output=True, text=True, timeout=120) if not os.path.exists(mesh_path): # Fallback to displacement approach print(f"[IMG2-3D] TripoSR failed, falling back to displacement: {result.stderr[-200:]}") script = "/opt/ComfyUI/blender_scripts/image_to_3d.py" result = subprocess.run( ["blender", "--background", "--python", script, "--", src_path, out_path, str(seed)], capture_output=True, text=True, timeout=120) else: # Step 3: Render the TripoSR mesh in Blender with gold materials script = "/opt/ComfyUI/blender_scripts/triposr_render.py" result = subprocess.run( ["blender", "--background", "--python", script, "--", mesh_path, out_path, str(seed)], capture_output=True, text=True, timeout=120) try: os.remove(mesh_path) except: pass finally: # Always restart ComfyUI subprocess.run(["systemctl", "start", "comfyui"], capture_output=True, timeout=10) if not os.path.exists(out_path): self._json(500, {"error": "Blender 3D render failed"}); return from PIL import Image img = Image.open(out_path) buf = io.BytesIO() img.save(buf, format="WEBP", quality=92) img_bytes = buf.getvalue() # Cleanup temp files for f in [src_path, out_path]: try: os.remove(f) except: pass elapsed = int((time.time() - t0) * 1000) img_b64 = base64.b64encode(img_bytes).decode() _cache_put(key, img_bytes) self._json(200, { "image": img_b64, "format": "webp", "width": 1280, "height": 320, "seed": seed, "time_ms": elapsed, "backend": "img-to-3d", "cached": False, }) print(f"[IMG2-3D] seed={seed} prompt={prompt[:50]} in {elapsed}ms") except Exception as e: self._json(500, {"error": str(e)[:300]}) def _scene_glb(self): """Generate a 3D scene and export as GLB for Three.js viewer.""" try: length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length)) if length else {} except: self._json(400, {"error": "invalid JSON"}); return seed = body.get("seed", random.randint(0, 99999)) key = f"glb-{seed}" # Check cache glb_cache = CACHE_DIR / f"{key}.glb" if glb_cache.exists(): glb_b64 = base64.b64encode(glb_cache.read_bytes()).decode() self._json(200, {"glb": glb_b64, "seed": seed, "cached": True}) return t0 = time.time() glb_path = f"/tmp/scene_{seed}.glb" try: import subprocess result = subprocess.run( ["blender", "--background", "--python", "/opt/ComfyUI/blender_scripts/export_glb.py", "--", str(seed), glb_path], capture_output=True, text=True, timeout=120) if not os.path.exists(glb_path): self._json(500, {"error": "GLB export failed: " + result.stderr[-200:] if result.stderr else "no output"}) return glb_bytes = open(glb_path, 'rb').read() os.remove(glb_path) glb_cache.write_bytes(glb_bytes) glb_b64 = base64.b64encode(glb_bytes).decode() elapsed = int((time.time() - t0) * 1000) self._json(200, {"glb": glb_b64, "seed": seed, "time_ms": elapsed, "cached": False}) print(f"[GLB] seed={seed} in {elapsed}ms size={len(glb_bytes)//1024}KB") except Exception as e: self._json(500, {"error": str(e)[:300]}) if __name__ == "__main__": print(f"[IMAGEGEN] Starting on port {PORT}") print(f"[IMAGEGEN] ComfyUI backend: {COMFYUI_URL}") print(f"[IMAGEGEN] Cache: {CACHE_DIR}") HTTPServer(("0.0.0.0", PORT), ImageHandler).serve_forever()