thats-me/dev/tools/backgrounds/build_graphic.py
Kevin Adametz 522deffa1a Hintergründe: 15 kuratierte Bilder statt Testfotos
Elf universelle Hintergründe von grafisch bis fotografisch (Tinte, Goldstaub,
Horizont, Kohle, Leinen, Nebel, Dämmerung, Nachthimmel, Tusche, Papier,
Aquarell) plus vier v2-Fassungen zur Abstimmung, alle 2048² JPEG mit
320er Vorschauen (7 MB statt 48 MB). Auswahl, Reihenfolge und Helligkeit
stehen in src/config/backgrounds.js; der Picker zeigt Name und aktuelle
Wahl, das Logo folgt der Bildhelligkeit, Presets zeigen auf die neuen
Bilder, alte Pfade werden beim Laden auf den Nachfolger umgeschrieben.
Der Service Worker cacht nur die Vorschauen vor.

Werkzeuge zum Neu-Rendern (SVG-Builder, Gemini-Prompts) liegen in
dev/tools/backgrounds, die Sichtungs-Renders bleiben lokal (gitignore).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 09:31:09 +00:00

126 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Builds the graphic LifeWave backgrounds as SVG and renders them to JPEG.
Square 2048 px, so `background-size: cover` crops them evenly in portrait and
landscape. Palette: ink #292a2b / #1a1b1c, gold #f5c26b / #d59040, paper
#f7f3ea. Deterministic: same seed, same file.
Usage: python3 build_graphic.py [out_dir] (default dev/backgrounds/graphic)
"""
import os, random, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, 'dev', 'backgrounds', 'graphic')
S = 2048
GRAIN = '''
<filter id="grain" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
<feComponentTransfer><feFuncA type="linear" slope="{alpha}"/></feComponentTransfer>
</filter>'''
def svg(defs, body):
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{S}" height="{S}" viewBox="0 0 {S} {S}">\n<defs>{defs}</defs>\n{body}\n</svg>\n'
def ink():
defs = '''
<radialGradient id="g" cx="50%" cy="46%" r="72%">
<stop offset="0" stop-color="#2f3032"/><stop offset="0.65" stop-color="#232425"/><stop offset="1" stop-color="#161718"/>
</radialGradient>''' + GRAIN.format(alpha=0.06)
body = f'<rect width="{S}" height="{S}" fill="url(#g)"/><rect width="{S}" height="{S}" filter="url(#grain)" opacity="0.5"/>'
return svg(defs, body)
def gold_dust():
rnd = random.Random(2026)
specks = []
for _ in range(5200):
# Denser and brighter towards the bottom; the middle band stays calm.
y = S * (1 - rnd.random() ** 0.45)
x = rnd.random() * S
r = rnd.choice([1.0, 1.3, 1.6, 2.0, 2.6, 3.4, 4.2])
depth = y / S
o = min(0.95, (0.08 + 1.1 * depth ** 1.6) * rnd.uniform(0.35, 1.0))
c = '#f5c26b' if rnd.random() < 0.7 else '#d59040'
specks.append(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{o:.2f}"/>')
defs = '''
<radialGradient id="g" cx="50%" cy="40%" r="75%">
<stop offset="0" stop-color="#2c2d2f"/><stop offset="1" stop-color="#151617"/>
</radialGradient>
<linearGradient id="warm" x1="0" y1="0" x2="0" y2="1">
<stop offset="0.5" stop-color="#d59040" stop-opacity="0"/><stop offset="1" stop-color="#d59040" stop-opacity="0.22"/>
</linearGradient>
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="2"/></filter>''' + GRAIN.format(alpha=0.05)
body = (f'<rect width="{S}" height="{S}" fill="url(#g)"/>'
f'<rect width="{S}" height="{S}" fill="url(#warm)"/>'
f'<g filter="url(#soft)">{"".join(specks[:2200])}</g><g>{"".join(specks[2200:])}</g>'
f'<rect width="{S}" height="{S}" filter="url(#grain)" opacity="0.4"/>')
return svg(defs, body)
def horizon():
defs = '''
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0f1013"/><stop offset="0.52" stop-color="#1b1c1f"/>
<stop offset="0.66" stop-color="#2e2415"/><stop offset="0.74" stop-color="#241c12"/><stop offset="1" stop-color="#12100b"/>
</linearGradient>
<filter id="blur" x="-50%" y="-400%" width="200%" height="900%"><feGaussianBlur stdDeviation="120"/></filter>''' + GRAIN.format(alpha=0.05)
body = (f'<rect width="{S}" height="{S}" fill="url(#sky)"/>'
f'<ellipse cx="{S/2}" cy="{S*0.665:.0f}" rx="{S*1.1:.0f}" ry="60" fill="#d59040" opacity="0.32" filter="url(#blur)"/>'
f'<ellipse cx="{S/2}" cy="{S*0.665:.0f}" rx="{S*0.7:.0f}" ry="14" fill="#f5c26b" opacity="0.22" filter="url(#blur)"/>'
f'<rect width="{S}" height="{S}" filter="url(#grain)" opacity="0.5"/>')
return svg(defs, body)
def paper():
defs = '''
<radialGradient id="g" cx="50%" cy="45%" r="75%">
<stop offset="0" stop-color="#f9f5ec"/><stop offset="0.7" stop-color="#f3ecdd"/><stop offset="1" stop-color="#e8dfcc"/>
</radialGradient>
<filter id="fibre" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.02 0.35" numOctaves="3" seed="3" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
<feComponentTransfer><feFuncA type="linear" slope="0.10"/></feComponentTransfer>
</filter>''' + GRAIN.format(alpha=0.05)
body = (f'<rect width="{S}" height="{S}" fill="url(#g)"/>'
f'<rect width="{S}" height="{S}" filter="url(#fibre)" opacity="0.6"/>'
f'<rect width="{S}" height="{S}" filter="url(#grain)" opacity="0.5"/>')
return svg(defs, body)
def charcoal():
rnd = random.Random(11)
smudges = ''.join(
f'<ellipse cx="{rnd.uniform(0.05, 0.95) * S:.0f}" cy="{rnd.choice([rnd.uniform(0.02, 0.22), rnd.uniform(0.78, 0.98)]) * S:.0f}" '
f'rx="{rnd.uniform(220, 520):.0f}" ry="{rnd.uniform(40, 110):.0f}" fill="#f7f3ea" opacity="{rnd.uniform(0.025, 0.06):.3f}" '
f'transform="rotate({rnd.uniform(-12, 12):.1f} {S/2} {S/2})" filter="url(#blur)"/>'
for _ in range(9))
defs = '''
<radialGradient id="g" cx="50%" cy="50%" r="75%">
<stop offset="0" stop-color="#2d2e30"/><stop offset="1" stop-color="#1d1e1f"/>
</radialGradient>
<filter id="chalk" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="4" seed="5" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
<feComponentTransfer><feFuncA type="table" tableValues="0 0 0.02 0.10 0.16"/></feComponentTransfer>
</filter>
<filter id="blur" x="-50%" y="-100%" width="200%" height="300%"><feGaussianBlur stdDeviation="40"/></filter>''' + GRAIN.format(alpha=0.08)
body = (f'<rect width="{S}" height="{S}" fill="url(#g)"/>'
f'<rect width="{S}" height="{S}" fill="#f7f3ea" filter="url(#chalk)" opacity="0.5"/>'
f'{smudges}'
f'<rect width="{S}" height="{S}" filter="url(#grain)" opacity="0.6"/>')
return svg(defs, body)
BACKGROUNDS = [
('01-tinte', ink), ('02-goldstaub', gold_dust), ('03-horizont', horizon),
('04-papier', paper), ('07-kohle', charcoal),
]
if __name__ == '__main__':
os.makedirs(OUT, exist_ok=True)
for name, fn in BACKGROUNDS:
svg_path = os.path.join(OUT, f'{name}.svg')
png_path = os.path.join(OUT, f'{name}.png')
jpg_path = os.path.join(OUT, f'{name}.jpg')
open(svg_path, 'w').write(fn())
subprocess.run(['rsvg-convert', '-w', str(S), '-h', str(S), svg_path, '-o', png_path], check=True)
subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', png_path, '-q:v', '3', jpg_path], check=True)
os.remove(png_path)
print(jpg_path, os.path.getsize(jpg_path) // 1024, 'KB')