thats-me/dev/tools/lifeline-images/events.py
Kevin Adametz 10251e4f41 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>
2026-09-25 07:00:35 +00:00

155 lines
9.2 KiB
Python

#!/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}')