- 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>
155 lines
9.2 KiB
Python
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}')
|