Created
June 27, 2026 15:36
-
-
Save e-minguez/81a2a44441f28889fc8a16af83f2d296 to your computer and use it in GitHub Desktop.
Python small script to generate images on nan.builders using the image model
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """nanweb — mini web local para generar imágenes con la API de NaN (flux-2-klein). | |
| Sin dependencias: solo la librería estándar de Python 3. | |
| La API key va en el servidor (este proceso), nunca en el navegador. | |
| Uso: | |
| export NAN_API_KEY=sk-tu-key-aqui | |
| python3 nanweb.py | |
| # abre solo http://localhost:8765 (Ctrl+C para parar) | |
| """ | |
| import base64 | |
| import json | |
| import os | |
| import threading | |
| import urllib.error | |
| import urllib.request | |
| import webbrowser | |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| API_URL = "https://api.nan.builders/v1/images/generations" | |
| MODEL = "flux-2-klein" | |
| PORT = 8765 | |
| KEY = os.environ.get("NAN_API_KEY", "") | |
| PAGE = """<!doctype html> | |
| <html lang="es"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>NaN Image — flux-2-klein</title> | |
| <style> | |
| :root { color-scheme: dark; } | |
| * { box-sizing: border-box; } | |
| body { font-family: system-ui, sans-serif; background:#0f1115; color:#e6e6e6; | |
| margin:0; padding:24px; display:flex; justify-content:center; } | |
| .wrap { width:100%; max-width:720px; } | |
| h1 { font-size:18px; font-weight:600; margin:0 0 16px; color:#bfc7d5; } | |
| textarea { width:100%; min-height:84px; resize:vertical; background:#181b22; | |
| color:#e6e6e6; border:1px solid #2a2f3a; border-radius:8px; padding:10px; | |
| font-size:14px; font-family:inherit; } | |
| .row { display:flex; gap:10px; align-items:center; margin:12px 0; flex-wrap:wrap; } | |
| select, button { background:#181b22; color:#e6e6e6; border:1px solid #2a2f3a; | |
| border-radius:8px; padding:8px 14px; font-size:14px; } | |
| button.primary { background:#2563eb; border-color:#2563eb; cursor:pointer; font-weight:600; } | |
| button.primary:disabled { opacity:.5; cursor:default; } | |
| a.dl { color:#7aa2ff; text-decoration:none; font-size:14px; } | |
| #status { color:#8a93a6; font-size:13px; min-height:18px; margin-left:auto; } | |
| #out { margin-top:16px; } | |
| #out img { max-width:100%; border-radius:10px; border:1px solid #2a2f3a; display:block; } | |
| .err { color:#ff8080; white-space:pre-wrap; font-size:13px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <h1>NaN Image · flux-2-klein</h1> | |
| <textarea id="prompt" placeholder="Describe la imagen…">Un faro al atardecer sobre acantilados, estilo cinematico</textarea> | |
| <div class="row"> | |
| <select id="size"> | |
| <option>1024x1024</option> | |
| <option>1536x1024</option> | |
| <option>1024x1536</option> | |
| </select> | |
| <button id="go" class="primary">Generar</button> | |
| <span id="status"></span> | |
| </div> | |
| <div id="out"></div> | |
| </div> | |
| <script> | |
| const go = document.getElementById('go'); | |
| const statusEl = document.getElementById('status'); | |
| const out = document.getElementById('out'); | |
| go.addEventListener('click', async () => { | |
| const prompt = document.getElementById('prompt').value.trim(); | |
| const size = document.getElementById('size').value; | |
| if (!prompt) { statusEl.textContent = 'Escribe un prompt.'; return; } | |
| go.disabled = true; statusEl.textContent = 'Generando…'; out.innerHTML = ''; | |
| try { | |
| const r = await fetch('/generate', { | |
| method: 'POST', headers: {'Content-Type':'application/json'}, | |
| body: JSON.stringify({ prompt, size }) | |
| }); | |
| const data = await r.json(); | |
| if (!r.ok || data.error) { | |
| out.innerHTML = '<div class="err">' + (data.error || ('HTTP ' + r.status)) + '</div>'; | |
| statusEl.textContent = 'Error.'; | |
| } else { | |
| const src = 'data:' + data.mime + ';base64,' + data.b64; | |
| out.innerHTML = '<img src="' + src + '"><div class="row"><a class="dl" download="nan-image.' + | |
| data.ext + '" href="' + src + '">Descargar</a></div>'; | |
| statusEl.textContent = 'Listo.'; | |
| } | |
| } catch (e) { | |
| out.innerHTML = '<div class="err">' + e + '</div>'; | |
| statusEl.textContent = 'Error.'; | |
| } finally { go.disabled = false; } | |
| }); | |
| </script> | |
| </body> | |
| </html>""" | |
| def generate(prompt, size): | |
| body = { | |
| "model": MODEL, | |
| "prompt": prompt, | |
| "n": 1, | |
| "size": size, | |
| "response_format": "b64_json", | |
| } | |
| req = urllib.request.Request( | |
| API_URL, | |
| data=json.dumps(body).encode(), | |
| headers={ | |
| "Authorization": f"Bearer {KEY}", | |
| "Content-Type": "application/json", | |
| # Cloudflare delante de NaN da 403 (error 1010) con el UA por defecto | |
| # de urllib; mandamos uno de navegador para que no nos banee. | |
| "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/124.0 Safari/537.36", | |
| "Accept": "application/json", | |
| }, | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(req, timeout=120) as resp: | |
| data = json.load(resp) | |
| raw = base64.b64decode(data["data"][0]["b64_json"]) | |
| if raw[:3] == b"\xff\xd8\xff": | |
| return raw, "image/jpeg", "jpg" | |
| return raw, "image/png", "png" | |
| class Handler(BaseHTTPRequestHandler): | |
| def log_message(self, *args): | |
| pass # silencio | |
| def _send(self, code, body, ctype="application/json"): | |
| self.send_response(code) | |
| self.send_header("Content-Type", ctype) | |
| self.send_header("Content-Length", str(len(body))) | |
| self.end_headers() | |
| self.wfile.write(body) | |
| def do_GET(self): | |
| if self.path in ("/", "/index.html"): | |
| self._send(200, PAGE.encode(), "text/html; charset=utf-8") | |
| else: | |
| self._send(404, b"not found", "text/plain") | |
| def do_POST(self): | |
| if self.path != "/generate": | |
| self._send(404, json.dumps({"error": "not found"}).encode()) | |
| return | |
| try: | |
| length = int(self.headers.get("Content-Length", 0)) | |
| payload = json.loads(self.rfile.read(length) or b"{}") | |
| prompt = (payload.get("prompt") or "").strip() | |
| size = payload.get("size") or "1024x1024" | |
| if not KEY: | |
| raise RuntimeError("Falta NAN_API_KEY en el entorno del servidor.") | |
| if not prompt: | |
| raise RuntimeError("Prompt vacío.") | |
| raw, mime, ext = generate(prompt, size) | |
| b64 = base64.b64encode(raw).decode() | |
| self._send(200, json.dumps({"b64": b64, "mime": mime, "ext": ext}).encode()) | |
| except urllib.error.HTTPError as e: | |
| detail = e.read().decode(errors="replace") | |
| self._send(200, json.dumps({"error": f"HTTP {e.code}: {detail}"}).encode()) | |
| except Exception as e: # noqa: BLE001 | |
| self._send(200, json.dumps({"error": str(e)}).encode()) | |
| def main(): | |
| if not KEY: | |
| print("AVISO: NAN_API_KEY no está definida. Expórtala antes de generar.") | |
| url = f"http://localhost:{PORT}" | |
| print(f"NaN Image en {url} (Ctrl+C para parar)") | |
| server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler) | |
| threading.Timer(0.6, lambda: webbrowser.open(url)).start() | |
| try: | |
| server.serve_forever() | |
| except KeyboardInterrupt: | |
| print("\nAdiós.") | |
| server.shutdown() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment