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
+106 -160
View File
@@ -66,43 +66,56 @@ MONTHS = {
"September": 9, "Oktober": 10, "November": 11, "Dezember": 12
}
# ── NanoBanana API Configuration ──────────────────────────────────────────
NANOBANANA_API_URL = "https://api.nanobananaapi.ai"
NANOBANANA_UPLOAD_DIR = "/tmp/nanobanana_uploads"
os.makedirs(NANOBANANA_UPLOAD_DIR, exist_ok=True)
# ── Google Imagen API Configuration ────────────────────────────────────────
IMAGEN_API_URL = (
"https://generativelanguage.googleapis.com/v1beta/"
"models/imagen-4.0-generate-001:predict"
)
IMAGEN_KEY_SECRET = "bsn/google-imagen-key"
def _get_nanobanana_key() -> str:
"""NanoBanana API-Key aus bsn-secrets lesen."""
# Resolution Mapping (Imagen 4.0 verwendet 1K/2K direkt)
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:
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
)
return r.stdout.strip()
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."""
key = _get_nanobanana_key()
def _imagen_generate(
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:
return {"error": "Kein API-Key"}
return {"error": "Kein Google Imagen API-Key"}
data = json.dumps({
"prompt": prompt,
"imageUrls": image_urls if image_urls else [],
body = json.dumps({
"instances": [{"prompt": prompt}],
"parameters": {
"sampleCount": 1,
"aspectRatio": aspect_ratio,
"resolution": resolution,
"outputFormat": "jpg",
"sampleImageSize": IMAGEN_SIZE_MAP.get(resolution, "2048x2048"),
}
}).encode()
req = urllib.request.Request(
f"{NANOBANANA_API_URL}/api/v1/nanobanana/generate-2",
data=data, method="POST"
)
req.add_header("Authorization", "Bearer " + key)
req = urllib.request.Request(IMAGEN_API_URL, data=body, method="POST")
req.add_header("x-goog-api-key", key)
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", "Mozilla/5.0") # Cloudflare-Workaround
try:
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:
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]:
"""Liste aller unveröffentlichten Artikel (ohne joomla-id)."""
articles = []
@@ -885,8 +880,6 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
self.wfile.write(html.encode("utf-8"))
elif path == "/nanobanana/articles":
self._handle_nanobanana_articles()
elif path == "/nanobanana/status":
self._handle_nanobanana_status()
elif path.startswith("/media/"):
rel = path[len("/"):]
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":
if not self._check_token():
return
self._handle_nanobanana_generate()
elif path == "/nanobanana/status":
if not self._check_token():
return
self._handle_nanobanana_status()
self._handle_image_generate()
elif path == "/nanobanana/articles":
if not self._check_token():
return
@@ -981,11 +970,11 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
self.end_headers()
# ═══════════════════════════════════════════════════════════════════
# NanoBanana Handler
# Imagen Handler (Google Imagen — synchron, kein Polling)
# ═══════════════════════════════════════════════════════════════════
def _handle_nanobanana_generate(self):
"""NanoBanana Bildgenerierung starten."""
def _handle_image_generate(self):
"""Bildgenerierung via Google Imagen (synchron, Base64→JPEG)."""
content_type = self.headers.get("Content-Type", "")
if "multipart/form-data" not in content_type:
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)
parts = raw.split(b"--" + boundary.encode())
prompt = ""
image_urls = []
aspect_ratio = "16:9"
resolution = "2K"
resolution = "1K"
for part in parts:
if b"Content-Disposition" not in part:
@@ -1013,8 +1001,7 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
if headers_end == -1:
continue
headers_raw = part[:headers_end].decode("utf-8", errors="ignore")
content = part[headers_end + 4:]
content = content.rstrip(b"\r\n")
content = part[headers_end + 4:].rstrip(b"\r\n")
if 'name="prompt"' in headers_raw:
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")
elif 'name="resolution"' in headers_raw:
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:
self._json_reply(400, {"ok": False, "error": "Prompt fehlt"})
return
result = _nanobanana_generate(prompt, image_urls, aspect_ratio, resolution)
# Prüfe auf Transport-Fehler (Netzwerk/HTTP)
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)
# Google Imagen aufrufen (synchron!)
result = _imagen_generate(prompt, [], aspect_ratio, resolution)
if "error" in result:
self._json_reply(500, {"ok": False, "error": result["error"]})
return
# Null-Safe: data kann None sein
data = result.get("data") or {}
# Base64-Bild extrahieren
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, {
"ok": True,
"successFlag": data.get("successFlag"),
"resultImageUrl": (data.get("response") or {}).get("resultImageUrl"),
"completeTime": data.get("completeTime"),
"imageUrl": img_url,
"filepath": filepath,
})
def _handle_nanobanana_articles(self):
@@ -1107,8 +1088,17 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
self._json_reply(400, {"ok": False, "error": "article und imageUrl erforderlich"})
return
# Bild herunterladen
# Bild herunterladen oder lokal lesen
try:
if image_url.startswith("/media/"):
# Lokales Bild — direkt von Platte lesen
local_path = os.path.join(WORKSPACE, image_url.lstrip("/"))
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:
@@ -1671,7 +1661,7 @@ NANOBANANA_HTML = """<!DOCTYPE html>
<head>
<meta charset="utf-8">
<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>
:root {{
--bsn-blue: #20228a;
@@ -1949,8 +1939,8 @@ textarea:focus, select:focus {{ border-color: var(--bsn-blue); outline: none; bo
<div>
<label>🔍 Auflösung</label>
<select id="resolution">
<option value="2K">2K — 1920×1080 (Standard)</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>
</select>
</div>
@@ -1997,7 +1987,6 @@ const KEY = "{key}";
const BASE = window.location.origin;
let uploadedImageData = null; // File object
let currentResultUrl = null;
let pollingTimer = null;
// ── Upload-Zone ──
const dropZone = document.getElementById('dropZone');
@@ -2057,11 +2046,11 @@ async function startGeneration() {{
const btn = document.getElementById('startBtn');
btn.disabled = true;
btn.textContent = 'Sende…';
btn.textContent = 'Generiere…';
document.getElementById('resultBox').classList.remove('visible');
document.getElementById('spinner').classList.add('active');
document.getElementById('spinnerText').textContent = 'Sende an NanoBanana';
document.getElementById('spinnerText').textContent = 'Google Imagen generiert';
try {{
const formData = new FormData();
@@ -2089,68 +2078,25 @@ async function startGeneration() {{
throw new Error(data.error || 'Unbekannter Fehler (Status ' + resp.status + ')');
}}
document.getElementById('spinnerText').textContent = 'Warte auf Ergebnis…';
pollStatus(data.taskId);
// DIREKT Ergebnis anzeigen — Google Imagen ist synchron!
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) {{
document.getElementById('spinner').classList.remove('active');
btn.disabled = false;
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 ──
async function loadArticles() {{
try {{