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:
+111
-165
@@ -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 [],
|
||||
"aspectRatio": aspect_ratio,
|
||||
"resolution": resolution,
|
||||
"outputFormat": "jpg",
|
||||
body = json.dumps({
|
||||
"instances": [{"prompt": prompt}],
|
||||
"parameters": {
|
||||
"sampleCount": 1,
|
||||
"aspectRatio": aspect_ratio,
|
||||
"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,12 +1088,21 @@ 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:
|
||||
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()
|
||||
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:
|
||||
img_data = resp.read()
|
||||
except Exception as e:
|
||||
self._json_reply(500, {"ok": False, "error": f"Download fehlgeschlagen: {e}"})
|
||||
return
|
||||
@@ -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 {{
|
||||
|
||||
Reference in New Issue
Block a user