Doku und Werkzeuge: Tickets, Audit, Logos, Playwright-MCP

- 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>
This commit is contained in:
Kevin Adametz 2026-09-25 07:00:35 +00:00
parent 83c6c5813d
commit 10251e4f41
15 changed files with 2163 additions and 0 deletions

View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Generate the age anchor portraits for every character of a lifeline.
Base portrait: text-only (children optionally with the parents as references).
Each further anchor: references = base portrait + previous anchor, so identity
stays pinned to the base while aging progresses step by step.
Existing files are kept; delete a file to regenerate it.
"""
import json, os, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gemini import generate, ROOT
lifeline = sys.argv[1]
bible = json.load(open(f'{ROOT}/backend/database/data/lifelines/characters/{lifeline}.json', encoding='utf-8'))
out_root = f'{ROOT}/backend/storage/app/private/lifeline-images/characters/{lifeline}'
PORTRAIT = bible.get('portrait', "Head-and-shoulders portrait, looking straight into the camera, neutral bright daylight, plain light wall background, plain clothing, natural skin, no retouching, no text.")
def era_for(year):
for span, desc in bible['era'].items():
a, b = map(int, span.split('-'))
if a <= year <= b: return desc
return ''
def preview(png):
subprocess.run(['php', '-r', '$im=imagecreatefromstring(file_get_contents($argv[1])); imagejpeg(imagescale($im,500),$argv[2],80);', png, png.replace('.png', '.preview.jpg')])
def run(key, c):
portrait = c.get('portrait', PORTRAIT)
d = f'{out_root}/{key}'; os.makedirs(d, exist_ok=True)
base_age = c.get('base', c['anchors'][0])
base = f'{d}/{base_age}.png'
if not os.path.exists(base):
desc = c.get('baseDescription', c['description'])
year = c['birthYear'] + base_age
prompt = f"Photo from {year} ({era_for(year)}): {portrait} {c.get('ageLabels', {}).get(str(base_age), f'A {base_age}-year-old')} named {c['name']}: {desc}"
generate(prompt, [], base, aspect='3:4'); preview(base)
print(f'{key} {base_age} (base) ok', flush=True)
prev = base
# anchors older than base, ascending; then anchors younger than base, descending
older = [a for a in c['anchors'] if a > base_age]
younger = sorted([a for a in c['anchors'] if a < base_age], reverse=True)
for seq in (older, younger):
prev = base
for age in seq:
png = f'{d}/{age}.png'
if os.path.exists(png):
prev = png; continue
year = c['birthYear'] + age
diff = age - (int(os.path.basename(prev)[:-4]))
direction = 'older' if diff > 0 else 'younger'
change = c['aging'].get(str(age), '')
if age == 0 and c.get('parents'):
parents = c.get('parents', [])
shown = 'the child of the person shown' if len(parents) == 1 else 'the child of the two people shown' if parents else ''
prompt = (f"Photo from {year}: a newborn baby a few days old, {shown}. {change}. "
f"Close-up of the baby's face wrapped in a white blanket, soft hospital light, {bible['style']}")
refs = []
for pk in parents:
pc = bible['characters'][pk]
page = year - pc['birthYear']
refs.append(f"{out_root}/{pk}/{min(pc['anchors'], key=lambda a: abs(a - page))}.png")
else:
label = c.get('ageLabels', {}).get(str(age), f"{age} years old")
prompt = (f"Photo from {year} ({era_for(year)}): {portrait} The exact same individual as in the reference photos, {c['name']}, "
f"now {label}, {abs(diff)} years {direction} than in the second reference photo. Keep every identifying feature "
f"unchanged: face shape, eyes, nose, mouth, ears and any moles, freckles or markings ({c['description']}). Age-related changes only: {change}.")
# From `adultBase` on, the identity reference is that adult anchor
# instead of the (teenage) base portrait.
ident = base
if c.get('adultBase') and age > c['adultBase'] and os.path.exists(f"{d}/{c['adultBase']}.png"):
ident = f"{d}/{c['adultBase']}.png"
refs = [ident, prev] if prev != ident else [ident]
generate(prompt, refs, png, aspect='3:4'); preview(png)
print(f'{key} {age} ok', flush=True)
prev = png
failed = []
for key, c in bible['characters'].items():
if not c.get('anchors'):
continue
try:
run(key, c)
except Exception as ex:
print(f'{key} FAILED, skipping remaining anchors of this character: {ex}', flush=True)
failed.append(key)
print('failed: ' + ', '.join(failed) if failed else 'all anchors ok')

View file

@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Build a local contact sheet (HTML) for a lifeline's generated images."""
import base64, html, json, os, sys
handle = sys.argv[1]
data = json.load(open(f'/workspace/backend/database/data/lifelines/{handle}.json', encoding='utf-8'))
d = f'/workspace/backend/storage/app/private/lifeline-images/{handle}'
MON = ['','Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']
cards = []
for e in data['events']:
slug = f"{e['age']:02d}-{e['month']:02d}"
p = f'{d}/{slug}.preview.jpg'
year = data['person']['birthYear'] + e['age']
img = f'<img src="data:image/jpeg;base64,{base64.b64encode(open(p,"rb").read()).decode()}">' if os.path.exists(p) else '<div class="missing">kein Bild</div>'
cards.append(f'<figure><div class="img">{img}</div><figcaption><b>{slug} · {MON[e["month"]]} {year}</b> · {html.escape(e["rating"])}<br><strong>{html.escape(e["title"])}</strong><br><small>{html.escape(e["note"])}</small></figcaption></figure>')
out = f'{d}/contact-sheet.html'
open(out, 'w', encoding='utf-8').write(f'''<!doctype html><meta charset="utf-8"><title>{html.escape(data["person"]["name"])} – Kontaktbogen</title>
<style>body{{font-family:system-ui;background:#111;color:#eee;margin:0;padding:20px}}h1{{font-weight:600}}
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:18px}}figure{{margin:0;background:#1c1c1c;border-radius:10px;overflow:hidden}}
.img{{aspect-ratio:4/3;background:#000;display:flex;align-items:center;justify-content:center}}img{{width:100%;height:100%;object-fit:contain}}
figcaption{{padding:10px 12px;font-size:13px;line-height:1.4}}small{{color:#aaa}}.missing{{color:#888}}</style>
<h1>{html.escape(data["person"]["name"])} – {len(cards)} Events</h1><div class="grid">{"".join(cards)}</div>''')
print(out)

View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Generate one key image per lifeline event with Nano Banana 2, using the
character bible and age anchors as references. Skips existing files.
python3 events.py nadja # all events
python3 events.py nadja 32-05 ... # only these slugs
python3 events.py nadja --dry-run # print prompts only
"""
import json, os, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gemini import generate, ROOT
lifeline = sys.argv[1]
only = {a for a in sys.argv[2:] if not a.startswith('--')}
dry = '--dry-run' in sys.argv
data = json.load(open(f'{ROOT}/backend/database/data/lifelines/{lifeline}.json', encoding='utf-8'))
bible = json.load(open(f'{ROOT}/backend/database/data/lifelines/characters/{lifeline}.json', encoding='utf-8'))
chars_dir = f'{ROOT}/backend/storage/app/private/lifeline-images/characters/{lifeline}'
out = f'{ROOT}/backend/storage/app/private/lifeline-images/{lifeline}'
os.makedirs(out, exist_ok=True)
MONTHS = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
def era_for(year):
for span, desc in bible['era'].items():
a, b = map(int, span.split('-'))
if a <= year <= b: return desc
return ''
def age_of(c, year, month):
"""Age in years; month-exact when the character has a birthMonth."""
if 'birthMonth' in c:
return ((year * 12 + month) - (c['birthYear'] * 12 + c['birthMonth'])) / 12
return year - c['birthYear']
def age_label(c, age):
if age < 2 and 'birthMonth' in c:
months = round(age * 12)
return 'a newborn baby' if months < 1 else f'a baby {months} months old'
return c.get('ageLabels', {}).get(str(int(age)), f'{int(age)} years old')
def anchor_for(key, year, month, forced=None):
c = bible['characters'][key]
age = age_of(c, year, month)
nearest = forced if forced in c['anchors'] else min(c['anchors'], key=lambda a: abs(a - age))
return f'{chars_dir}/{key}/{nearest}.png', age, nearest
import re
def detect_characters(e, year, month=1):
"""Characters named in the Bildmotiv (`note`), main character always first.
A character with a `diedYear` is not placed into scenes after it."""
text = e['note']
main = bible['characters'][lifeline]
others = []
for key, c in bible['characters'].items():
if key == lifeline: continue
if c.get('diedYear') and year > c['diedYear']: continue
if year < c['birthYear']: continue
aliases = c.get('aliases', [c['name']])
if not any(re.search(r'\b' + re.escape(a) + r"(s|')?\b", text) for a in aliases):
continue
# "<Name> fotografiert ..." / "<Name> macht ein Foto ..." puts that
# person behind the camera, not in the picture (a selfie is different).
behind_camera = any(
re.search(r'\b' + re.escape(a) + r'\b[^.;]{0,30}\b(fotografiert|macht ein Foto|macht ein Familienfoto|macht ein Gruppenfoto)\b', text)
or re.search(r'\baufgenommen von [^.;]{0,25}\b' + re.escape(a) + r'\b', text)
for a in aliases) and 'Selfie' not in text
if not behind_camera:
others.append(key)
# The main character is in the picture unless the motif is explicitly a
# photo *of* someone else ("Ein Selfie von Emil ...", "Ein Foto von Petra
# mit Clara", "Nina fotografiert Clara", a video-call screenshot of Daniel).
main_named = any(re.search(r'\b' + re.escape(a) + r"(s|')?\b", text) for a in main.get('aliases', [main['name']]))
other_names = [a for k in others for a in bible['characters'][k].get('aliases', [bible['characters'][k]['name']])]
photo_of_other = other_names and re.match(
r'^(Ein|Eine|Ein letztes|Ein altes)?\s*(Selfie|Foto|Handyfoto|Video-Standbild|Videocall-Screenshot|Video-Chat-Screenshot|Screenshot|Selfie-Video-Standbild)[^,.]*?\b(von|zeigt|zeigt einen|zeigt eine|mit)\s+(einen |eine |einem |sichtlich |erleichterten |besorgten )?(' + '|'.join(map(re.escape, other_names)) + r')\b', text)
photographer_of_other = other_names and re.match(r'^\w+ fotografiert (' + '|'.join(map(re.escape, other_names)) + r')\b', text)
# "beide" / "zu zweit" / "das Paar" without a name means the partner of
# that period; "Familie" means partner plus the children already born.
ym = f'{year:04d}-{month:02d}'
if re.search(r'\b(beide|zu zweit|Paar\w*|Familie\w*)\b', text):
for p in bible.get('partners', []):
if p['from'] <= ym <= p.get('to', '9999-12') and p['key'] not in others:
others.append(p['key'])
if re.search(r'\bFamilie\w*', text):
for k in bible.get('family', []):
c = bible['characters'][k]
if k not in others and year >= c['birthYear'] and not (c.get('diedYear') and year > c['diedYear']):
others.append(k)
# A band lifeline: "die Band" / "die vier" means all members of that period.
if bible.get('band') and re.search(r'\b(Band|die vier|alle vier|Nachtschicht|alle)\b', text):
for k in bible['band'] + [d['key'] for d in bible.get('bandDrummer', []) if d.get('from', '0000-00') <= ym <= d.get('to', '9999-12')]:
if k not in others:
others.append(k)
if not main.get('anchors'):
return others
if main_named or not (photo_of_other or photographer_of_other):
return [lifeline] + others
return others
def year_month_age(c, year, month):
return age_of(c, year, month)
def scene_for(e):
if e.get('imagePrompt'): return e['imagePrompt']
# "Heinz fotografiert das Auto, ..." -> the photographer is named separately
# so the model does not paint a person holding a camera into the scene.
m = re.match(r'^((?:Ein|Eine|Der|Die)\s+)?([\wäöüÄÖÜß]+(?:\s[\wäöüÄÖÜß]+)?) (fotografiert|macht ein Foto von|macht ein Familienfoto von|macht ein Gruppenfoto von) (.+)$', e['note'], re.S)
if m and e.get('photo') != 'Selfie':
who, rest = (m.group(1) or '') + m.group(2), m.group(4)
return (f"Photo taken by {who}, who is behind the camera and NOT visible in the picture. "
f"The following German description defines exactly what the picture shows: \"{rest}\"")
return (f"The following German description of the photo motif defines the scene exactly, including who took the photo "
f"(a photographer named in the description is not in the picture unless it is a selfie): \"{e['note']}\"")
def build(e):
year = data['person']['birthYear'] + e['age']
keys = e['imageCharacters'] if e.get('imageCharacters') is not None else detect_characters(e, year, e['month'])
refs, people = [], []
for i, k in enumerate(keys, 1):
path, age, nearest = anchor_for(k, year, e['month'], e.get('imageAnchorAges', {}).get(k))
c = bible['characters'][k]
aging = c['aging'].get(str(max((a for a in c['anchors'] if a <= age), default=nearest)), '')
refs.append(path)
people.append(f"Reference photo {i} is {c['name']}, now {age_label(c, age)} ({c['description']} {aging}).")
kind = ("Smartphone selfie taken at arm's length with the front camera: slight wide-angle distortion, the people close to the lens, one arm reaching out of frame; the phone itself is never visible in the picture."
if e.get('photo') == 'Selfie' else
"Candid photo taken by another person who stands behind the camera and is NOT visible in the picture: nobody in the picture holds a camera or a phone, no arm or hand with a camera in the foreground. Natural framing, subjects not posing stiffly.")
prompt = (f"Photo from {MONTHS[e['month']]} {year}, {era_for(year)}. {kind} Scene: {scene_for(e)}. "
f"{' '.join(people)} Every individual must match its reference photo exactly (same face shape, eyes, nose, mouth, hair or coat colour, distinguishing marks), "
f"only clothing, expression, pose and setting change. Clothing must NOT be copied from the reference photos: it changes from photo to photo and suits the scene and season. "
f"{bible.get('familyNote', '')} No other recognisable people in focus. No date stamp or printed date on the photo. {bible['style']}")
return prompt, refs
done = skipped = failed = 0
for e in data['events']:
slug = f"{e['age']:02d}-{e['month']:02d}"
if only and slug not in only: continue
png = f'{out}/{slug}.png'
if os.path.exists(png) and not dry:
skipped += 1; continue
prompt, refs = build(e)
if dry:
print(f'--- {slug} {e["title"]} refs: {[os.path.relpath(r, chars_dir) for r in refs]}'); continue
try:
generate(prompt, refs, png, aspect=e.get('imageAspect') or ('3:4' if e.get('photo') == 'Selfie' else '4:3'))
subprocess.run(['php', '-r', '$im=imagecreatefromstring(file_get_contents($argv[1])); imagejpeg(imagescale($im,700),$argv[2],80);', png, f'{out}/{slug}.preview.jpg'])
json.dump({'prompt': prompt, 'refs': refs, 'model': os.environ.get('LIFELINE_IMAGE_MODEL', 'gemini-3.1-flash-image')}, open(f'{out}/{slug}.json', 'w'), indent=1)
print(f'{slug} ok {e["title"]}', flush=True); done += 1
except Exception as ex:
print(f'{slug} FAILED {ex}', flush=True); failed += 1
if not dry: print(f'done={done} skipped={skipped} failed={failed}')

View file

@ -0,0 +1,67 @@
#!/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)

View file

@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Build 4x3 review montages (m1.jpg, m2.jpg, ...) of a lifeline's preview images."""
import json, os, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
handle = sys.argv[1]
cols = int(sys.argv[2]) if len(sys.argv) > 2 else 4
rows = int(sys.argv[3]) if len(sys.argv) > 3 else 3
per = cols * rows
d = json.load(open(f'{ROOT}/backend/database/data/lifelines/{handle}.json', encoding='utf-8'))
out = f'{ROOT}/backend/storage/app/private/lifeline-images/{handle}'
slugs = [f"{e['age']:02d}-{e['month']:02d}" for e in d['events'] if os.path.exists(f"{out}/{e['age']:02d}-{e['month']:02d}.preview.jpg")]
php = '''$slugs=json_decode($argv[1]);$cols=(int)$argv[3];$rows=(int)$argv[4];$w=(int)(1400/$cols);$h=(int)($w*0.85);$c=imagecreatetruecolor($w*$cols,$h*$rows);imagefill($c,0,0,0x202020);
foreach($slugs as $i=>$s){$im=imagecreatefromjpeg("$s.preview.jpg");$sw=imagesx($im);$sh=imagesy($im);$k=min($w/$sw,$h/$sh);$dw=(int)($sw*$k);$dh=(int)($sh*$k);imagecopyresampled($c,$im,($i%$cols)*$w+(int)(($w-$dw)/2),intdiv($i,$cols)*$h+(int)(($h-$dh)/2),0,0,$dw,$dh,$sw,$sh);imagestring($c,5,($i%$cols)*$w+6,intdiv($i,$cols)*$h+4,$s,0xFFFF00);}
imagejpeg($c,$argv[2],76);'''
for n in range(0, len(slugs), per):
subprocess.run(['php', '-r', php, json.dumps(slugs[n:n+per]), f'{out}/m{n//per+1:02d}.jpg', str(cols), str(rows)], cwd=out)
print(f'{(len(slugs)+per-1)//per} montages for {len(slugs)} images')