- dev/: Ticketliste, Audit und Vorschläge vom 17.09., Logo-SVGs, Lifeline-Bildwerkzeuge. - AGENTS.md auf den aktuellen Stand (syncQueue statt syncService, kein legacy-Ordner mehr). - .codex/ und __pycache__/ ignoriert; Playwright-MCP in .mcp.json. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
67 lines
3 KiB
Python
67 lines
3 KiB
Python
#!/usr/bin/env python3
|
||
"""Minimal Gemini image client (Nano Banana 2) – REST only, no SDK.
|
||
|
||
Reads GOOGLE_API_KEY from backend/.env. Usage as a module:
|
||
from gemini import generate
|
||
generate(prompt, refs=[path,...], out='x.png', aspect='4:3')
|
||
"""
|
||
import base64, json, os, re, sys, time, urllib.request, urllib.error
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||
MODEL = os.environ.get('LIFELINE_IMAGE_MODEL', 'gemini-3.1-flash-image')
|
||
|
||
def api_key():
|
||
for line in open(os.path.join(ROOT, 'backend', '.env')):
|
||
m = re.match(r'^GOOGLE_API_KEY=(.*)$', line.strip())
|
||
if m:
|
||
return m.group(1).strip().strip('"').strip("'")
|
||
sys.exit('GOOGLE_API_KEY missing in backend/.env')
|
||
|
||
def _mime(path):
|
||
with open(path, 'rb') as f:
|
||
head = f.read(8)
|
||
return 'image/png' if head.startswith(b'\x89PNG') else 'image/jpeg'
|
||
|
||
def generate(prompt, refs=(), out=None, aspect='4:3', size='1K', retries=3):
|
||
parts = []
|
||
for p in refs:
|
||
parts.append({'inline_data': {'mime_type': _mime(p), 'data': base64.b64encode(open(p, 'rb').read()).decode()}})
|
||
parts.append({'text': prompt})
|
||
body = {
|
||
'contents': [{'role': 'user', 'parts': parts}],
|
||
'generationConfig': {'responseModalities': ['IMAGE'], 'imageConfig': {'aspectRatio': aspect, 'imageSize': size}},
|
||
}
|
||
req = urllib.request.Request(
|
||
f'https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent',
|
||
data=json.dumps(body).encode(), method='POST',
|
||
headers={'Content-Type': 'application/json', 'x-goog-api-key': api_key()},
|
||
)
|
||
for attempt in range(retries):
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=180) as r:
|
||
res = json.load(r)
|
||
except urllib.error.HTTPError as e:
|
||
msg = e.read().decode()[:400]
|
||
if e.code in (429, 500, 503) and attempt < retries - 1:
|
||
time.sleep(10 * (attempt + 1)); continue
|
||
raise RuntimeError(f'HTTP {e.code}: {msg}')
|
||
cands = res.get('candidates') or []
|
||
for part in (cands[0].get('content', {}).get('parts', []) if cands else []):
|
||
data = part.get('inlineData') or part.get('inline_data')
|
||
if data:
|
||
raw = base64.b64decode(data['data'])
|
||
if out:
|
||
open(out, 'wb').write(raw)
|
||
return raw
|
||
reason = (cands[0].get('finishReason') if cands else None) or res.get('promptFeedback')
|
||
if attempt < retries - 1:
|
||
time.sleep(5); continue
|
||
raise RuntimeError(f'no image returned: {reason} {json.dumps(res)[:300]}')
|
||
|
||
if __name__ == '__main__':
|
||
import argparse
|
||
a = argparse.ArgumentParser()
|
||
a.add_argument('prompt'); a.add_argument('--ref', action='append', default=[]); a.add_argument('--out', required=True)
|
||
a.add_argument('--aspect', default='4:3'); a.add_argument('--size', default='1K')
|
||
ns = a.parse_args()
|
||
generate(ns.prompt, ns.ref, ns.out, ns.aspect, ns.size); print(ns.out)
|