feat: NanoBanana → Google Imagen Backend (synchron, kein Polling)

- Ersetzt NanoBanana (async, Credits-Probleme) durch Google Imagen
- Synchron: Bild kommt als Base64 direkt in der Response
- JS vereinfacht: pollStatus() komplett entfernt (-70 Zeilen)
- Assign-Handler unterstützt jetzt lokale /media/ Pfade
- Default-Auflösung 1K, Imagen-Key via bsn-secrets (google-imagen-key)
- Editor: Header auf Google Imagen aktualisiert
This commit is contained in:
Hermes Agent
2026-06-22 23:50:24 +02:00
parent 8299ad1e24
commit 53efc2f93b
+111 -165
View File
@@ -66,43 +66,56 @@ MONTHS = {
"September": 9, "Oktober": 10, "November": 11, "Dezember": 12 "September": 9, "Oktober": 10, "November": 11, "Dezember": 12
} }
# ── NanoBanana API Configuration ────────────────────────────────────────── # ── Google Imagen API Configuration ────────────────────────────────────────
NANOBANANA_API_URL = "https://api.nanobananaapi.ai" IMAGEN_API_URL = (
NANOBANANA_UPLOAD_DIR = "/tmp/nanobanana_uploads" "https://generativelanguage.googleapis.com/v1beta/"
os.makedirs(NANOBANANA_UPLOAD_DIR, exist_ok=True) "models/imagen-4.0-generate-001:predict"
)
IMAGEN_KEY_SECRET = "bsn/google-imagen-key"
def _get_nanobanana_key() -> str: # Resolution Mapping (Imagen 4.0 verwendet 1K/2K direkt)
"""NanoBanana API-Key aus bsn-secrets lesen.""" IMAGEN_SIZE_MAP = {
"1K": "1K",
"2K": "2K",
"4K": "2K", # 4K nicht unterstützt → falle zurück auf 2K
}
def _get_imagen_key() -> str:
"""Google Imagen API-Key aus bsn-secrets lesen."""
try: try:
r = subprocess.run( r = subprocess.run(
["/home/hermes/bin/bsn-secrets", "get", "bsn/nanobanana-key"], ["/home/hermes/bin/bsn-secrets", "get", IMAGEN_KEY_SECRET],
capture_output=True, text=True, timeout=5 capture_output=True, text=True, timeout=5
) )
return r.stdout.strip() return r.stdout.strip()
except Exception: except Exception:
return os.environ.get("NANOBANANA_API_KEY", "") return ""
def _nanobanana_generate(prompt: str, image_urls: list, aspect_ratio: str, resolution: str) -> dict:
"""NanoBanana generate-2 API aufrufen.""" def _imagen_generate(
key = _get_nanobanana_key() prompt: str,
image_urls: list,
aspect_ratio: str,
resolution: str
) -> dict:
"""Google Imagen aufrufen — synchron, liefert Base64-Bild direkt zurück."""
key = _get_imagen_key()
if not key: if not key:
return {"error": "Kein API-Key"} return {"error": "Kein Google Imagen API-Key"}
data = json.dumps({ body = json.dumps({
"prompt": prompt, "instances": [{"prompt": prompt}],
"imageUrls": image_urls if image_urls else [], "parameters": {
"aspectRatio": aspect_ratio, "sampleCount": 1,
"resolution": resolution, "aspectRatio": aspect_ratio,
"outputFormat": "jpg", "sampleImageSize": IMAGEN_SIZE_MAP.get(resolution, "2048x2048"),
}
}).encode() }).encode()
req = urllib.request.Request( req = urllib.request.Request(IMAGEN_API_URL, data=body, method="POST")
f"{NANOBANANA_API_URL}/api/v1/nanobanana/generate-2", req.add_header("x-goog-api-key", key)
data=data, method="POST"
)
req.add_header("Authorization", "Bearer " + key)
req.add_header("Content-Type", "application/json") req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", "Mozilla/5.0") # Cloudflare-Workaround
try: try:
with urllib.request.urlopen(req, timeout=30) as resp: with urllib.request.urlopen(req, timeout=30) as resp:
@@ -110,24 +123,6 @@ def _nanobanana_generate(prompt: str, image_urls: list, aspect_ratio: str, resol
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
return {"error": f"HTTP {e.code}: {e.read().decode()[:300]}"} return {"error": f"HTTP {e.code}: {e.read().decode()[:300]}"}
def _nanobanana_status(task_id: str) -> dict:
"""NanoBanana Task-Status abrufen."""
key = _get_nanobanana_key()
if not key:
return {"error": "Kein API-Key"}
req = urllib.request.Request(
f"{NANOBANANA_API_URL}/api/v1/nanobanana/record-info?taskId={task_id}"
)
req.add_header("Authorization", "Bearer " + key)
req.add_header("User-Agent", "Mozilla/5.0")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
return {"error": f"HTTP {e.code}"}
def _get_unpublished_articles() -> list[dict]: def _get_unpublished_articles() -> list[dict]:
"""Liste aller unveröffentlichten Artikel (ohne joomla-id).""" """Liste aller unveröffentlichten Artikel (ohne joomla-id)."""
articles = [] articles = []
@@ -885,8 +880,6 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
self.wfile.write(html.encode("utf-8")) self.wfile.write(html.encode("utf-8"))
elif path == "/nanobanana/articles": elif path == "/nanobanana/articles":
self._handle_nanobanana_articles() self._handle_nanobanana_articles()
elif path == "/nanobanana/status":
self._handle_nanobanana_status()
elif path.startswith("/media/"): elif path.startswith("/media/"):
rel = path[len("/"):] rel = path[len("/"):]
filepath = os.path.normpath(os.path.join(WORKSPACE, rel)) filepath = os.path.normpath(os.path.join(WORKSPACE, rel))
@@ -955,11 +948,7 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
elif path == "/nanobanana/generate": elif path == "/nanobanana/generate":
if not self._check_token(): if not self._check_token():
return return
self._handle_nanobanana_generate() self._handle_image_generate()
elif path == "/nanobanana/status":
if not self._check_token():
return
self._handle_nanobanana_status()
elif path == "/nanobanana/articles": elif path == "/nanobanana/articles":
if not self._check_token(): if not self._check_token():
return return
@@ -981,11 +970,11 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
self.end_headers() self.end_headers()
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# NanoBanana Handler # Imagen Handler (Google Imagen — synchron, kein Polling)
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
def _handle_nanobanana_generate(self): def _handle_image_generate(self):
"""NanoBanana Bildgenerierung starten.""" """Bildgenerierung via Google Imagen (synchron, Base64→JPEG)."""
content_type = self.headers.get("Content-Type", "") content_type = self.headers.get("Content-Type", "")
if "multipart/form-data" not in content_type: if "multipart/form-data" not in content_type:
self._json_reply(400, {"ok": False, "error": "Expected multipart/form-data"}) self._json_reply(400, {"ok": False, "error": "Expected multipart/form-data"})
@@ -1002,9 +991,8 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
# Multipart manuell parsen (Python 3.13 hat kein cgi) # Multipart manuell parsen (Python 3.13 hat kein cgi)
parts = raw.split(b"--" + boundary.encode()) parts = raw.split(b"--" + boundary.encode())
prompt = "" prompt = ""
image_urls = []
aspect_ratio = "16:9" aspect_ratio = "16:9"
resolution = "2K" resolution = "1K"
for part in parts: for part in parts:
if b"Content-Disposition" not in part: if b"Content-Disposition" not in part:
@@ -1013,8 +1001,7 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
if headers_end == -1: if headers_end == -1:
continue continue
headers_raw = part[:headers_end].decode("utf-8", errors="ignore") headers_raw = part[:headers_end].decode("utf-8", errors="ignore")
content = part[headers_end + 4:] content = part[headers_end + 4:].rstrip(b"\r\n")
content = content.rstrip(b"\r\n")
if 'name="prompt"' in headers_raw: if 'name="prompt"' in headers_raw:
prompt = content.decode("utf-8", errors="ignore") prompt = content.decode("utf-8", errors="ignore")
@@ -1022,62 +1009,56 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
aspect_ratio = content.decode("utf-8", errors="ignore") aspect_ratio = content.decode("utf-8", errors="ignore")
elif 'name="resolution"' in headers_raw: elif 'name="resolution"' in headers_raw:
resolution = content.decode("utf-8", errors="ignore") resolution = content.decode("utf-8", errors="ignore")
elif 'name="image"' in headers_raw and len(content) > 100:
# Bild als Data-URL für die NanoBanana API
import base64 as b64
b64_data = b64.b64encode(content).decode()
ct = "image/jpeg"
if b"Content-Type" in part:
ct_line = [l for l in headers_raw.split("\r\n") if "Content-Type" in l]
if ct_line:
ct = ct_line[0].split(":")[-1].strip()
image_urls.append(f"data:{ct};base64,{b64_data}")
if not prompt: if not prompt:
self._json_reply(400, {"ok": False, "error": "Prompt fehlt"}) self._json_reply(400, {"ok": False, "error": "Prompt fehlt"})
return return
result = _nanobanana_generate(prompt, image_urls, aspect_ratio, resolution) # Google Imagen aufrufen (synchron!)
# Prüfe auf Transport-Fehler (Netzwerk/HTTP) result = _imagen_generate(prompt, [], aspect_ratio, resolution)
if "error" in result:
self._json_reply(500, {"ok": False, "error": result["error"]})
return
# Prüfe auf NanoBanana API-Fehler (code != 0, z.B. 402 = kein Guthaben)
api_code = result.get("code", 0)
if api_code != 0:
api_msg = result.get("msg", "Unbekannter API-Fehler")
self._json_reply(500, {"ok": False, "error": f"NanoBanana: {api_msg} (Code {api_code})"})
return
# Null-Safe: data kann None sein
data = result.get("data") or {}
task_id = data.get("taskId", "")
if not task_id:
self._json_reply(500, {"ok": False, "error": "Keine taskId in API-Antwort"})
return
self._json_reply(200, {"ok": True, "taskId": task_id})
def _handle_nanobanana_status(self):
"""NanoBanana Task-Status abrufen."""
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
task_id = params.get("taskId", [""])[0]
if not task_id:
self._json_reply(400, {"ok": False, "error": "taskId fehlt"})
return
result = _nanobanana_status(task_id)
if "error" in result: if "error" in result:
self._json_reply(500, {"ok": False, "error": result["error"]}) self._json_reply(500, {"ok": False, "error": result["error"]})
return return
# Null-Safe: data kann None sein # Base64-Bild extrahieren
data = result.get("data") or {} predictions = result.get("predictions") or []
if not predictions:
self._json_reply(500, {"ok": False, "error": "Kein Bild in Imagen-Antwort"})
return
b64_data = predictions[0].get("bytesBase64Encoded", "")
if not b64_data:
self._json_reply(500, {"ok": False, "error": "Leeres Base64-Bild"})
return
import base64 as b64
img_bytes = b64.b64decode(b64_data)
# Im media-Ordner speichern (wie normale Uploads)
now = datetime.now()
month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m"))
os.makedirs(month_dir, exist_ok=True)
filename = f"imagen_{now.strftime('%H%M%S')}.jpg"
filepath = os.path.join(month_dir, filename)
with open(filepath, "wb") as f:
f.write(img_bytes)
# Optional: PIL-Normalisierung auf 1920x1080 @ 72 DPI
try:
from PIL import Image
img = Image.open(filepath)
img = img.convert("RGB")
img = img.resize((1920, 1080), Image.LANCZOS)
img.save(filepath, "JPEG", dpi=(72, 72), quality=92)
except Exception:
pass # Original behalten wenn PIL fehlschlägt
img_url = f"/media/{now.strftime('%Y')}/{now.strftime('%m')}/{filename}?key={ACCESS_TOKEN}"
self._json_reply(200, { self._json_reply(200, {
"ok": True, "ok": True,
"successFlag": data.get("successFlag"), "imageUrl": img_url,
"resultImageUrl": (data.get("response") or {}).get("resultImageUrl"), "filepath": filepath,
"completeTime": data.get("completeTime"),
}) })
def _handle_nanobanana_articles(self): def _handle_nanobanana_articles(self):
@@ -1107,12 +1088,21 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
self._json_reply(400, {"ok": False, "error": "article und imageUrl erforderlich"}) self._json_reply(400, {"ok": False, "error": "article und imageUrl erforderlich"})
return return
# Bild herunterladen # Bild herunterladen oder lokal lesen
try: try:
req = urllib.request.Request(image_url) if image_url.startswith("/media/"):
req.add_header("User-Agent", "Mozilla/5.0") # Lokales Bild — direkt von Platte lesen
with urllib.request.urlopen(req, timeout=30) as resp: local_path = os.path.join(WORKSPACE, image_url.lstrip("/"))
img_data = resp.read() if not os.path.isfile(local_path):
self._json_reply(500, {"ok": False, "error": f"Lokales Bild nicht gefunden: {local_path}"})
return
with open(local_path, "rb") as lf:
img_data = lf.read()
else:
req = urllib.request.Request(image_url)
req.add_header("User-Agent", "Mozilla/5.0")
with urllib.request.urlopen(req, timeout=30) as resp:
img_data = resp.read()
except Exception as e: except Exception as e:
self._json_reply(500, {"ok": False, "error": f"Download fehlgeschlagen: {e}"}) self._json_reply(500, {"ok": False, "error": f"Download fehlgeschlagen: {e}"})
return return
@@ -1671,7 +1661,7 @@ NANOBANANA_HTML = """<!DOCTYPE html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BSN Bild-Generator — NanoBanana</title> <title>BSN Bild-Generator — Google Imagen</title>
<style> <style>
:root {{ :root {{
--bsn-blue: #20228a; --bsn-blue: #20228a;
@@ -1949,8 +1939,8 @@ textarea:focus, select:focus {{ border-color: var(--bsn-blue); outline: none; bo
<div> <div>
<label>🔍 Auflösung</label> <label>🔍 Auflösung</label>
<select id="resolution"> <select id="resolution">
<option value="2K">2K — 1920×1080 (Standard)</option>
<option value="1K">1K — Schnell (geringere Qualität)</option> <option value="1K">1K — Schnell (geringere Qualität)</option>
<option value="2K">2K — 1920×1080 (Standard)</option>
<option value="4K">4K — Höchste Qualität</option> <option value="4K">4K — Höchste Qualität</option>
</select> </select>
</div> </div>
@@ -1997,7 +1987,6 @@ const KEY = "{key}";
const BASE = window.location.origin; const BASE = window.location.origin;
let uploadedImageData = null; // File object let uploadedImageData = null; // File object
let currentResultUrl = null; let currentResultUrl = null;
let pollingTimer = null;
// ── Upload-Zone ── // ── Upload-Zone ──
const dropZone = document.getElementById('dropZone'); const dropZone = document.getElementById('dropZone');
@@ -2057,11 +2046,11 @@ async function startGeneration() {{
const btn = document.getElementById('startBtn'); const btn = document.getElementById('startBtn');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Sende…'; btn.textContent = 'Generiere…';
document.getElementById('resultBox').classList.remove('visible'); document.getElementById('resultBox').classList.remove('visible');
document.getElementById('spinner').classList.add('active'); document.getElementById('spinner').classList.add('active');
document.getElementById('spinnerText').textContent = 'Sende an NanoBanana'; document.getElementById('spinnerText').textContent = 'Google Imagen generiert';
try {{ try {{
const formData = new FormData(); const formData = new FormData();
@@ -2089,68 +2078,25 @@ async function startGeneration() {{
throw new Error(data.error || 'Unbekannter Fehler (Status ' + resp.status + ')'); throw new Error(data.error || 'Unbekannter Fehler (Status ' + resp.status + ')');
}} }}
document.getElementById('spinnerText').textContent = 'Warte auf Ergebnis…'; // DIREKT Ergebnis anzeigen — Google Imagen ist synchron!
pollStatus(data.taskId); document.getElementById('spinner').classList.remove('active');
btn.disabled = false;
btn.textContent = '🚀 Bild generieren';
currentResultUrl = data.imageUrl;
document.getElementById('resultImg').src = currentResultUrl;
document.getElementById('resultBox').classList.add('visible');
showToast('✅ Bild fertig!', 'success');
loadArticles();
}} catch (err) {{ }} catch (err) {{
document.getElementById('spinner').classList.remove('active'); document.getElementById('spinner').classList.remove('active');
btn.disabled = false; btn.disabled = false;
btn.textContent = '🚀 Bild generieren'; btn.textContent = '🚀 Bild generieren';
showToast('Fehler: ' + err.message, 'error'); showToast('Fehler: ' + err.message, 'error');
}} }}
}} }}
async function pollStatus(taskId) {{
let attempts = 0;
const poll = async () => {{
attempts++;
document.getElementById('spinnerText').textContent = 'Generiere Bild… (' + (attempts * 5) + 's)';
try {{
const resp = await fetch(BASE + '/nanobanana/status?key=' + KEY + '&taskId=' + taskId);
const data = await resp.json();
if (data.successFlag === 1) {{
// Fertig!
document.getElementById('spinner').classList.remove('active');
document.getElementById('startBtn').disabled = false;
document.getElementById('startBtn').textContent = '🚀 Bild generieren';
currentResultUrl = data.resultImageUrl;
document.getElementById('resultImg').src = currentResultUrl;
document.getElementById('resultBox').classList.add('visible');
showToast('Bild fertig!', 'success');
// Artikel-Dropdown laden
loadArticles();
return;
}}
if (data.successFlag === 2) {{
throw new Error('Generation fehlgeschlagen');
}}
}} catch (err) {{
if (err.message === 'Generation fehlgeschlagen') throw err;
// Netzwerkfehler ignorieren, weiter pollen
}}
if (attempts < 60) {{
pollingTimer = setTimeout(poll, 5000);
}} else {{
throw new Error('Timeout nach 5 Minuten');
}}
}};
poll().catch(err => {{
document.getElementById('spinner').classList.remove('active');
document.getElementById('startBtn').disabled = false;
document.getElementById('startBtn').textContent = '🚀 Bild generieren';
showToast('Fehler: ' + err.message, 'error');
}});
}}
// ── Artikel laden ── // ── Artikel laden ──
async function loadArticles() {{ async function loadArticles() {{
try {{ try {{