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:
parent
83c6c5813d
commit
10251e4f41
15 changed files with 2163 additions and 0 deletions
86
dev/tools/lifeline-images/anchors.py
Normal file
86
dev/tools/lifeline-images/anchors.py
Normal 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')
|
||||
22
dev/tools/lifeline-images/contact_sheet.py
Normal file
22
dev/tools/lifeline-images/contact_sheet.py
Normal 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)
|
||||
155
dev/tools/lifeline-images/events.py
Normal file
155
dev/tools/lifeline-images/events.py
Normal 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}')
|
||||
67
dev/tools/lifeline-images/gemini.py
Normal file
67
dev/tools/lifeline-images/gemini.py
Normal 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)
|
||||
17
dev/tools/lifeline-images/montage.py
Normal file
17
dev/tools/lifeline-images/montage.py
Normal 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')
|
||||
Loading…
Add table
Add a link
Reference in a new issue