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>
4
.gitignore
vendored
|
|
@ -59,3 +59,7 @@ __pycache__/
|
||||||
_static/
|
_static/
|
||||||
_work/
|
_work/
|
||||||
_storage/
|
_storage/
|
||||||
|
# Background review renders (large); README and overview are versioned
|
||||||
|
dev/backgrounds/*
|
||||||
|
!dev/backgrounds/README.md
|
||||||
|
!dev/backgrounds/uebersicht.jpg
|
||||||
|
|
|
||||||
56
dev/backgrounds/README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Hintergründe für die LifeWave – Sichtungsordner
|
||||||
|
|
||||||
|
Zehn Hintergründe, von grafisch bis fotografisch, alle quadratisch 2048 px,
|
||||||
|
damit `background-size: cover` hoch und quer gleichmäßig beschneidet. Die
|
||||||
|
Bildmitte ist überall ruhig, weil dort die Welle mit ihren Punkten läuft.
|
||||||
|
|
||||||
|
Palette: Tinte #292a2b / #1a1b1c, Gold #f5c26b / #d59040, Papier #f7f3ea.
|
||||||
|
|
||||||
|
| Nr. | Name | Quelle | Linienmodus | Passendes Preset |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 01 | Tinte | Gemini, ink-gewaschenes Papier | Glow | Gold |
|
||||||
|
| 02 | Goldstaub | Gemini, Goldstaub auf Samt | Glow | Gold, Seide |
|
||||||
|
| 03 | Horizont | Gemini, Nachglühen über leerer Ebene | Glow, Trennung | Abendrot |
|
||||||
|
| 04 | Papier | Gemini, Baumwollpapier | Einfarbig | Papier |
|
||||||
|
| 05 | Leinen | Gemini, flaches Anthrazit-Leinen | Glow | Seide |
|
||||||
|
| 06a | Aquarell warm | Gemini, Sepia/Ocker in den Ecken | Einfarbig | Papier |
|
||||||
|
| 06b | Aquarell dunkel | Gemini, Indigo-Tusche oben und unten | Einfarbig dunkel | Aurora |
|
||||||
|
| 07 | Kohle | Gemini, Schiefertafel | Glow, Einfarbig hell | Mondsichel |
|
||||||
|
| 08 | Nebel | Gemini | Glow | Aurora, Milchstraße |
|
||||||
|
| 09 | Dämmerung | Gemini | Glow | Abendrot |
|
||||||
|
| 10 | Nachthimmel | Gemini | Glow, Mondsichel | Mondsichel, Aurora |
|
||||||
|
|
||||||
|
Alle elf liegen in `photo/`, die unveränderten 2K-Ausgaben in `photo/original/`.
|
||||||
|
`uebersicht.jpg` zeigt den aktuellen Stand. Frühere Fassungen zum Vergleich:
|
||||||
|
`v1/` (erster Durchgang, grafische als SVG gerendert), `v2/` (zweiter
|
||||||
|
Durchgang von Tinte, Papier, Leinen und Aquarell warm, mit Blattkanten und
|
||||||
|
Falten). Die SVG-Quellen in `v1/graphic/` bleiben als Rückfall, falls eine
|
||||||
|
Version ohne KI gewünscht ist.
|
||||||
|
|
||||||
|
## Neu erzeugen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dev/tools/backgrounds
|
||||||
|
python3 generate_photo.py # alle elf, je eine Gemini-Anfrage
|
||||||
|
python3 generate_photo.py 08-nebel # nur eines
|
||||||
|
python3 build_graphic.py # SVG-Fassung der grafischen (v1), ohne API
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Fotos brauchen `GOOGLE_API_KEY` in `backend/.env`, siehe
|
||||||
|
`documentation/bildgenerierung-gemini-api.md`. Jede Anfrage zählt gegen das
|
||||||
|
Tageslimit von 100.
|
||||||
|
|
||||||
|
## Übernahme in die App
|
||||||
|
|
||||||
|
Seit 2026-09-25 eingebaut: alle elf aus `photo/` plus die vier Fassungen aus
|
||||||
|
`v2/` (Tinte Blatt, Papier Bütten, Leinen Falten, Aquarell Streifen), also
|
||||||
|
15 zur Auswahl. Dateien liegen unter `frontend/public/images/backgrounds/`
|
||||||
|
(2048 px, JPEG q5) mit Vorschauen in `thumbs/`; Namen, Reihenfolge und
|
||||||
|
Helligkeit (`tone`, steuert die Logo-Variante) in
|
||||||
|
`frontend/src/config/backgrounds.js`. Die alten `bg-image-1…10` sind
|
||||||
|
entfernt; gespeicherte Einstellungen mit einem alten Pfad werden auf den
|
||||||
|
nächstliegenden Nachfolger umgeschrieben.
|
||||||
|
|
||||||
|
Aussortieren heißt: Eintrag aus der Config nehmen und Datei löschen. Presets
|
||||||
|
in `wavePresets.js` zeigen auf: Nachthimmel, Leinen, Nebel, Tinte, Kohle,
|
||||||
|
Horizont, Dämmerung.
|
||||||
BIN
dev/backgrounds/uebersicht.jpg
Normal file
|
After Width: | Height: | Size: 157 KiB |
126
dev/tools/backgrounds/build_graphic.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
#!/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')
|
||||||
90
dev/tools/backgrounds/generate_photo.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generates the photographic LifeWave backgrounds with Gemini (Nano Banana 2).
|
||||||
|
|
||||||
|
Square, because the app crops with `cover` in both orientations. Every
|
||||||
|
prompt asks for an empty middle band and no focal point: the wave and its
|
||||||
|
dots are the subject, the background only sets the mood.
|
||||||
|
|
||||||
|
Usage: python3 generate_photo.py [name ...] (default: all five)
|
||||||
|
"""
|
||||||
|
import os, sys, time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lifeline-images'))
|
||||||
|
from gemini import generate # noqa: E402
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
OUT = os.path.join(ROOT, 'dev', 'backgrounds', 'photo')
|
||||||
|
|
||||||
|
COMMON = (' Square format, fills the frame edge to edge. No text, no watermark, no people, no animals, '
|
||||||
|
'no buildings, no objects. The centre band of the image is calm and almost uniform, because a '
|
||||||
|
'thin glowing line will be drawn over it later. Photographic, subdued, low contrast, fine grain.')
|
||||||
|
|
||||||
|
PROMPTS = {
|
||||||
|
# Second pass: the graphic ideas rendered as photographed material, so
|
||||||
|
# they carry inner detail without gaining a subject.
|
||||||
|
'01-tinte': (
|
||||||
|
'Macro photograph of the surface of deep charcoal-black handmade paper washed with black ink, so close that '
|
||||||
|
'the paper extends beyond every edge of the frame, no sheet edges, no border, no background. '
|
||||||
|
'Subtle mottled variations of anthracite and graphite, faint cotton fibres visible on close inspection, '
|
||||||
|
'a soft darker vignette towards the edges, raking light from the upper left giving the surface a gentle '
|
||||||
|
'matte relief. Nothing written on it, no objects.' + COMMON),
|
||||||
|
'02-goldstaub': (
|
||||||
|
'Macro photograph of fine gold dust and a few tiny gold leaf flakes scattered on dark anthracite velvet. '
|
||||||
|
'The dust gathers along the bottom edge and thins out quickly upwards, the upper two thirds of the frame '
|
||||||
|
'are almost empty dark velvet with only a handful of distant specks. Warm gold reflections, very shallow '
|
||||||
|
'depth of field so most specks are soft bokeh. Elegant, dark, sparse.' + COMMON),
|
||||||
|
'03-horizont': (
|
||||||
|
'Minimalist photograph of a vast empty plain at the very end of dusk. Black featureless land in the lower '
|
||||||
|
'third, a deep graphite night sky above, and between them only a thin, soft band of warm amber and gold '
|
||||||
|
'afterglow along the horizon at two thirds of the height. No sun, no clouds, no trees, no lights. '
|
||||||
|
'Cinematic, very dark, the glow is the only colour.' + COMMON),
|
||||||
|
'04-papier': (
|
||||||
|
'Macro photograph of the surface of warm cream handmade cotton paper, so close that the paper extends beyond '
|
||||||
|
'every edge of the frame, no sheet edges, no deckle edge, no border, no background. Soft uneven surface with '
|
||||||
|
'fine fibres, lit with raking light from the side so the texture shows as gentle relief, a subtle '
|
||||||
|
'darker vignette in the corners. No writing, no lines, no objects, no folds.' + COMMON),
|
||||||
|
'05-leinen': (
|
||||||
|
'Photograph of heavy dark charcoal-grey linen with a visible slub weave, lit from the side so the threads '
|
||||||
|
'catch a faint warm sheen. The fabric lies flat and even across the whole frame, no folds, no creases, no '
|
||||||
|
'seams, only the weave itself with a very gentle variation of light. Rich, tactile, dark, low contrast.' + COMMON),
|
||||||
|
'06a-aquarell-warm': (
|
||||||
|
'Extreme close-up of a watercolour wash on cold-pressed paper, the paper runs beyond every edge of the '
|
||||||
|
'frame, no border, no tape marks, no stripes, no bands, one continuous sheet. Blooms of warm sepia, golden '
|
||||||
|
'ochre and a little dusty indigo pigment gather in the corners with visible granulation and soft '
|
||||||
|
'cauliflower edges, the middle stays pale warm cream. No shapes, no drips, no splatter.' + COMMON),
|
||||||
|
'06b-aquarell-dunkel': (
|
||||||
|
'Extreme close-up of an ink wash on heavy paper, the paper runs beyond every edge of the frame, no border. '
|
||||||
|
'Deep indigo and graphite ink bleeds in from the top and bottom edges with visible granulation, the '
|
||||||
|
'middle band softens into a muted grey-blue wash, tiny specks of gold pigment caught in the darker '
|
||||||
|
'areas. Moody, dark overall. No shapes, no drips.' + COMMON),
|
||||||
|
'07-kohle': (
|
||||||
|
'Photograph of a dark slate chalkboard surface, matte anthracite with a faint haze of chalk dust and the '
|
||||||
|
'ghost traces of wiped chalk strokes near the top and bottom edges, the centre almost clean. Fine mineral '
|
||||||
|
'texture of the slate visible up close, soft even light.' + COMMON),
|
||||||
|
'08-nebel': (
|
||||||
|
'Photograph of gentle hills at dawn in dense morning fog, almost monochrome grey-blue, dark toned. '
|
||||||
|
'A faint treeline is barely visible at the very bottom, the rest of the frame is soft, empty fog with a '
|
||||||
|
'slight brightening towards the top. No sun, no sky detail.' + COMMON),
|
||||||
|
'09-daemmerung': (
|
||||||
|
'Long-exposure photograph of a calm sea at dusk. The horizon sits in the lower third. Deep blue-grey '
|
||||||
|
'sky above fading into a narrow warm amber band right at the horizon, perfectly smooth water below, '
|
||||||
|
'darker than the sky. No sun disc, no detailed clouds, no boats, no shore.' + COMMON),
|
||||||
|
'10-nachthimmel': (
|
||||||
|
'Photograph of a deep midnight-blue night sky turning to black, with faint thin wisps of high cloud. '
|
||||||
|
'No stars, no moon, no ground, no horizon, no light source. A smooth dark gradient with a hint of '
|
||||||
|
'texture from the clouds.' + COMMON),
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
names = sys.argv[1:] or list(PROMPTS)
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
for name in names:
|
||||||
|
out = os.path.join(OUT, f'{name}.jpg')
|
||||||
|
for size in ('2K', '1K'):
|
||||||
|
try:
|
||||||
|
generate(PROMPTS[name], out=out, aspect='1:1', size=size)
|
||||||
|
print(out, size, os.path.getsize(out) // 1024, 'KB')
|
||||||
|
break
|
||||||
|
except RuntimeError as error:
|
||||||
|
print(name, size, 'failed:', str(error)[:200])
|
||||||
|
time.sleep(2)
|
||||||
119
documentation/bildgenerierung-gemini-api.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# Google-Gemini-Bild-API: Zugang
|
||||||
|
|
||||||
|
Stand: 25.09.2026
|
||||||
|
|
||||||
|
## Überblick
|
||||||
|
|
||||||
|
Die Key-Images der Demo-Lifelines entstehen über die Google Gemini API, Modell `gemini-3.1-flash-image` (Produktname „Nano Banana 2"). Der Zugang läuft ausschließlich über REST mit einem API-Key aus dem Google AI Studio – kein SDK, kein OAuth, kein Google-Cloud-Projekt-Setup nötig.
|
||||||
|
|
||||||
|
Genutzt wird der Zugang nur von den Entwickler-Werkzeugen unter `dev/tools/lifeline-images/`:
|
||||||
|
|
||||||
|
| Datei | Aufgabe |
|
||||||
|
| --- | --- |
|
||||||
|
| `gemini.py` | Einziger API-Client: liest den Key, baut den Request, speichert das Bild |
|
||||||
|
| `anchors.py` | Erzeugt die Altersanker je Figur (Porträt-Kette) |
|
||||||
|
| `events.py` | Erzeugt ein Bild pro Lifeline-Event mit Ankern als Referenz |
|
||||||
|
| `contact_sheet.py`, `montage.py` | Reine Sichtungs-Werkzeuge, ohne API-Zugriff |
|
||||||
|
|
||||||
|
Die Laravel-Anwendung selbst spricht nie mit der API. Sie importiert nur fertige Dateien per `php artisan lifelines:import-images <handle>`.
|
||||||
|
|
||||||
|
## Zugangsdaten
|
||||||
|
|
||||||
|
Der Key steht als `GOOGLE_API_KEY` in `backend/.env` – sonst nirgends. `.env` ist in `backend/.gitignore` ausgeschlossen, der Key liegt also nicht im Repository.
|
||||||
|
|
||||||
|
```
|
||||||
|
GOOGLE_API_KEY=<key aus dem AI Studio>
|
||||||
|
```
|
||||||
|
|
||||||
|
`gemini.py` liest die Datei direkt zeilenweise und schneidet Anführungszeichen ab; fehlt der Eintrag, bricht das Skript mit `GOOGLE_API_KEY missing in backend/.env` ab. Der Key wird nur als HTTP-Header `x-goog-api-key` gesendet, nie in eine URL, einen Log oder eine Ausgabe geschrieben.
|
||||||
|
|
||||||
|
Regeln für den Umgang:
|
||||||
|
|
||||||
|
- Der Key gehört nur in die lokale Entwicklungsumgebung. Auf Produktion wird er **nicht** benötigt – dort landen ausschließlich die fertigen PNG-Dateien und der Import-Befehl.
|
||||||
|
- Generierte Bilder und Anker liegen unter `backend/storage/app/private/lifeline-images/` und sind ebenfalls vom Repository ausgeschlossen.
|
||||||
|
- Der Key ist ein Zahlungsmittel: Wer ihn hat, generiert auf Kosten des hinterlegten Guthabens. Nicht in Tickets, Chats oder Screenshots weitergeben.
|
||||||
|
- Bei Verdacht auf Weitergabe: im AI Studio löschen und neu erzeugen (siehe letzter Abschnitt).
|
||||||
|
|
||||||
|
## Endpunkt und Request
|
||||||
|
|
||||||
|
Ein Aufruf ist ein POST auf `generateContent` des Bildmodells:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent
|
||||||
|
Content-Type: application/json
|
||||||
|
x-goog-api-key: <GOOGLE_API_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Body enthält einen einzigen Nutzer-Turn. Referenzbilder stehen als `inline_data` **vor** dem Text; ihre Reihenfolge ist bedeutsam, weil der Prompt sie als „Reference photo 1/2/3" anspricht:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contents": [{ "role": "user", "parts": [
|
||||||
|
{ "inline_data": { "mime_type": "image/jpeg", "data": "<base64>" } },
|
||||||
|
{ "text": "<Prompt>" }
|
||||||
|
]}],
|
||||||
|
"generationConfig": {
|
||||||
|
"responseModalities": ["IMAGE"],
|
||||||
|
"imageConfig": { "aspectRatio": "4:3", "imageSize": "1K" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameter, die wir setzen:
|
||||||
|
|
||||||
|
| Feld | Werte | Bedeutung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `responseModalities` | `["IMAGE"]` | Nur Bild zurück, kein Begleittext |
|
||||||
|
| `imageConfig.aspectRatio` | `4:3` bzw. `3:4` | Querformat; `3:4` für Selfies und Porträt-Anker |
|
||||||
|
| `imageConfig.imageSize` | `1K` | Auflösungsstufe |
|
||||||
|
| Modell-ID | Umgebungsvariable `LIFELINE_IMAGE_MODEL` | Überschreibt die Vorgabe `gemini-3.1-flash-image` |
|
||||||
|
|
||||||
|
Einen `seed` verwenden wir nicht mehr; falls doch gesetzt, akzeptiert die API nur Werte bis 1.000.000.
|
||||||
|
|
||||||
|
## Antwort verarbeiten
|
||||||
|
|
||||||
|
Das Bild steckt base64-kodiert im ersten Kandidaten:
|
||||||
|
|
||||||
|
```
|
||||||
|
candidates[0].content.parts[*].inlineData.data
|
||||||
|
```
|
||||||
|
|
||||||
|
Zwei Eigenheiten, die im Client berücksichtigt sind:
|
||||||
|
|
||||||
|
- Das Feld heißt je nach Antwort `inlineData` oder `inline_data` – beide Schreibweisen prüfen.
|
||||||
|
- Die gelieferten Daten sind faktisch JPEG, obwohl wir die Dateien mit der Endung `.png` ablegen. Beim Hochladen als Referenz wird der MIME-Typ deshalb aus den Magic Bytes bestimmt (`\x89PNG` → `image/png`, sonst `image/jpeg`) und nicht aus der Dateiendung.
|
||||||
|
|
||||||
|
Kommt kein Bild zurück, steht der Grund in `candidates[0].finishReason` oder in `promptFeedback`. Der Client wiederholt den Aufruf bis zu dreimal (5 s Pause; bei HTTP 429/500/503 gestaffelt 10/20 s) und wirft danach einen Fehler mit dem Grund im Text.
|
||||||
|
|
||||||
|
## Kontingente, Abrechnung, Fehlerbilder
|
||||||
|
|
||||||
|
Guthaben und Tageslimit sind zwei getrennte Grenzen. Beide haben wir beim Aufbau der Bildsets erreicht, sie sehen im Log sehr ähnlich aus:
|
||||||
|
|
||||||
|
| HTTP | Meldung | Ursache | Abhilfe |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 402 | `Your prepayment credits are depleted` | Prepaid-Guthaben aufgebraucht | Im AI Studio unter Billing aufladen; wirkt sofort |
|
||||||
|
| 429 | `Quota exceeded for metric: generate_requests_per_model_per_day, limit: 100` | Tageslimit von 100 Anfragen pro Modell erreicht | Warten bis Mitternacht Pacific (ca. 9 Uhr MESZ) oder höhere Stufe beantragen |
|
||||||
|
| 200 mit `finishReason: IMAGE_OTHER` | `The model could not generate the image based on the prompt provided` | Inhaltsfilter des Modells, deterministisch pro Prompt | Prompt umformulieren – Wiederholen hilft nicht |
|
||||||
|
|
||||||
|
Zum Tageslimit: Es zählt Anfragen, nicht erfolgreiche Bilder. Fehlversuche und Wiederholungen zählen mit. Ein vollständiger Durchlauf für eine große Lifeline (Jonas, 252 Bilder) sprengt das Limit deshalb und muss über mehrere Tage laufen. Der aktuelle Verbrauch ist unter [ai.dev/rate-limit](https://ai.dev/rate-limit) einsehbar.
|
||||||
|
|
||||||
|
Zu `IMAGE_OTHER`: Der Filter reagiert auf Formulierungen, nicht auf Zufall. In unseren Sets waren die Auslöser unter anderem Rollenangaben wie „Singer of the band" (Prominenten-Filter), oberkörperfreie Referenzbilder und Kombinationen aus erschöpften Gesichtsbeschreibungen mit Bettszenen. Vorgehen: Prompt halbieren und die Hälften einzeln testen, bis die auslösende Passage feststeht.
|
||||||
|
|
||||||
|
## Key anlegen oder rotieren
|
||||||
|
|
||||||
|
1. [aistudio.google.com](https://aistudio.google.com/) mit dem Google-Konto öffnen, das die Rechnung trägt.
|
||||||
|
2. Unter **Get API key** einen Key für das gewünschte Projekt erzeugen. Der Wert wird nur einmal vollständig angezeigt.
|
||||||
|
3. Für das Bildmodell muss im Projekt Billing aktiv sein und Guthaben vorhanden – unter [ai.studio/projects](https://ai.studio/projects) zu prüfen. Ohne Guthaben antwortet die API mit HTTP 402.
|
||||||
|
4. Key in `backend/.env` als `GOOGLE_API_KEY=` eintragen. Kein `php artisan config:clear` nötig, da nur die Python-Werkzeuge ihn lesen.
|
||||||
|
5. Prüfen mit einem einzelnen Bild, zum Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd dev/tools/lifeline-images
|
||||||
|
python3 gemini.py "Ein roter Apfel auf einem weißen Tisch, Foto." --out /tmp/test.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Erfolg gibt den Pfad aus; bei Problemen erscheint die HTTP-Fehlermeldung im Klartext.
|
||||||
|
|
||||||
|
6. Alten Key nach der Umstellung im AI Studio löschen.
|
||||||
|
|
||||||
|
Das Tageslimit hängt an Projekt und Abrechnungsstufe, nicht am einzelnen Key. Ein neuer Key im selben Projekt umgeht das Limit also nicht.
|
||||||
BIN
frontend/public/images/backgrounds/aquarell-streifen.jpg
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
frontend/public/images/backgrounds/aquarell.jpg
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
frontend/public/images/backgrounds/daemmerung.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
frontend/public/images/backgrounds/goldstaub.jpg
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
frontend/public/images/backgrounds/horizont.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/images/backgrounds/kohle.jpg
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
frontend/public/images/backgrounds/leinen-falten.jpg
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
frontend/public/images/backgrounds/leinen.jpg
Normal file
|
After Width: | Height: | Size: 926 KiB |
BIN
frontend/public/images/backgrounds/nachthimmel.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/public/images/backgrounds/nebel.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
frontend/public/images/backgrounds/papier-buetten.jpg
Normal file
|
After Width: | Height: | Size: 515 KiB |
BIN
frontend/public/images/backgrounds/papier.jpg
Normal file
|
After Width: | Height: | Size: 637 KiB |
BIN
frontend/public/images/backgrounds/thumbs/aquarell-streifen.jpg
Normal file
|
After Width: | Height: | Size: 6 KiB |
BIN
frontend/public/images/backgrounds/thumbs/aquarell.jpg
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
frontend/public/images/backgrounds/thumbs/daemmerung.jpg
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/images/backgrounds/thumbs/goldstaub.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/images/backgrounds/thumbs/horizont.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/images/backgrounds/thumbs/kohle.jpg
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
frontend/public/images/backgrounds/thumbs/leinen-falten.jpg
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
frontend/public/images/backgrounds/thumbs/leinen.jpg
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
frontend/public/images/backgrounds/thumbs/nachthimmel.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/images/backgrounds/thumbs/nebel.jpg
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend/public/images/backgrounds/thumbs/papier-buetten.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/images/backgrounds/thumbs/papier.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/images/backgrounds/thumbs/tinte-blatt.jpg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
frontend/public/images/backgrounds/thumbs/tinte.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/images/backgrounds/thumbs/tusche.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/images/backgrounds/tinte-blatt.jpg
Normal file
|
After Width: | Height: | Size: 741 KiB |
BIN
frontend/public/images/backgrounds/tinte.jpg
Normal file
|
After Width: | Height: | Size: 676 KiB |
BIN
frontend/public/images/backgrounds/tusche.jpg
Normal file
|
After Width: | Height: | Size: 722 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 562 KiB |
|
Before Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
|
@ -176,9 +176,9 @@ export default defineConfig((ctx) => {
|
||||||
cfg.globPatterns = [
|
cfg.globPatterns = [
|
||||||
'**/*.{js,css,html,ico,svg,woff,woff2,json,webmanifest}',
|
'**/*.{js,css,html,ico,svg,woff,woff2,json,webmanifest}',
|
||||||
'icons/*.png',
|
'icons/*.png',
|
||||||
'images/bg-thumbs/*.jpg'
|
'images/backgrounds/thumbs/*.jpg'
|
||||||
]
|
]
|
||||||
cfg.globIgnores = [ ...(cfg.globIgnores || []), 'images/bg-image-*.jpg', 'images/gallery/**', 'videos/**', 'audio/**', 'demo/**', 'js/**' ]
|
cfg.globIgnores = [ ...(cfg.globIgnores || []), 'images/backgrounds/*.jpg', 'images/gallery/**', 'videos/**', 'audio/**', 'demo/**', 'js/**' ]
|
||||||
cfg.navigateFallbackDenylist = [ ...(cfg.navigateFallbackDenylist || []), /^\/api\//, /^\/event-media\// ]
|
cfg.navigateFallbackDenylist = [ ...(cfg.navigateFallbackDenylist || []), /^\/api\//, /^\/event-media\// ]
|
||||||
cfg.runtimeCaching = [
|
cfg.runtimeCaching = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { computed } from 'vue'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import { useSettingsStore } from 'stores/settings'
|
import { useSettingsStore } from 'stores/settings'
|
||||||
import { logoVariantForBackground } from 'src/utils/brand'
|
import { logoVariantForBackground } from 'src/utils/brand'
|
||||||
|
import { findBackgroundImage } from 'src/config/backgrounds'
|
||||||
import logoPositive from 'assets/logo-thatsme-positiv.svg'
|
import logoPositive from 'assets/logo-thatsme-positiv.svg'
|
||||||
import logoNegative from 'assets/logo-thatsme-negativ.svg'
|
import logoNegative from 'assets/logo-thatsme-negativ.svg'
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ const resolvedVariant = computed(() => {
|
||||||
bgEdge: fl.bgEdge,
|
bgEdge: fl.bgEdge,
|
||||||
bgCenter: fl.bgCenter,
|
bgCenter: fl.bgCenter,
|
||||||
backgroundImage: fl.backgroundImage,
|
backgroundImage: fl.backgroundImage,
|
||||||
|
imageTone: findBackgroundImage(fl.backgroundImage)?.tone ?? null,
|
||||||
isDark: $q.dark.isActive
|
isDark: $q.dark.isActive
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,10 @@
|
||||||
|
|
||||||
<!-- Hintergrundbild -->
|
<!-- Hintergrundbild -->
|
||||||
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
|
||||||
<span class="lw-settings__card-label">Hintergrundbild</span>
|
<div class="lw-settings__card-head">
|
||||||
|
<span class="lw-settings__card-label">Hintergrundbild</span>
|
||||||
|
<span class="lw-settings__card-value">{{ currentBackgroundLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="lw-settings__img-grid" role="listbox" aria-label="Hintergrundbild wählen">
|
<div class="lw-settings__img-grid" role="listbox" aria-label="Hintergrundbild wählen">
|
||||||
<button
|
<button
|
||||||
|
|
@ -522,6 +525,7 @@ import { useQuasar } from 'quasar'
|
||||||
import { useSettingsStore } from 'stores/settings'
|
import { useSettingsStore } from 'stores/settings'
|
||||||
import { usePanelDrag } from 'composables/usePanelDrag'
|
import { usePanelDrag } from 'composables/usePanelDrag'
|
||||||
import { isBuiltInPresetId, presetPreviewStyle } from 'src/config/wavePresets'
|
import { isBuiltInPresetId, presetPreviewStyle } from 'src/config/wavePresets'
|
||||||
|
import { BACKGROUND_IMAGES, findBackgroundImage } from 'src/config/backgrounds'
|
||||||
import ConfirmDialog from 'components/ConfirmDialog.vue'
|
import ConfirmDialog from 'components/ConfirmDialog.vue'
|
||||||
|
|
||||||
const props = defineProps({ open: { type: Boolean, default: false } })
|
const props = defineProps({ open: { type: Boolean, default: false } })
|
||||||
|
|
@ -575,15 +579,12 @@ const isCustomBackground = computed(() => {
|
||||||
return bg.startsWith('data:image/') || bg.startsWith('/settings/media/')
|
return bg.startsWith('data:image/') || bg.startsWith('/settings/media/')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bundled backgrounds: the full image is only loaded once one is chosen; the
|
// Bundled backgrounds (src/config/backgrounds.js): the picker shows the
|
||||||
// picker shows the small copies from public/images/bg-thumbs.
|
// small copies, the full image loads once one is chosen.
|
||||||
const BACKGROUND_IMAGES = Array.from({ length: 10 }, (_, index) => {
|
const currentBackgroundLabel = computed(() => {
|
||||||
const n = index + 1
|
const bg = fl.value.backgroundImage ?? ''
|
||||||
return {
|
if (!bg) return 'Keins'
|
||||||
src: `/images/bg-image-${n}.jpg`,
|
return findBackgroundImage(bg)?.label ?? 'Eigenes Bild'
|
||||||
thumb: `/images/bg-thumbs/bg-image-${n}.jpg`,
|
|
||||||
label: `Hintergrund ${n}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const HORIZON_MODES = [
|
const HORIZON_MODES = [
|
||||||
|
|
@ -919,6 +920,18 @@ async function onBackgroundUpload(event) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lw-settings__card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lw-settings__card-value {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
.lw-settings__img-grid {
|
.lw-settings__img-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
|
||||||
|
|
|
||||||
63
frontend/src/config/backgrounds.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
/**
|
||||||
|
* Bundled LifeWave backgrounds.
|
||||||
|
*
|
||||||
|
* All square 2048 px (see dev/backgrounds/README.md for how they were made),
|
||||||
|
* so `cover` crops them evenly in portrait and landscape. `tone` says what
|
||||||
|
* sits behind the header: it decides the logo variant, because with a photo
|
||||||
|
* the wave's own colours say nothing about the ground.
|
||||||
|
*
|
||||||
|
* Several motifs exist in two takes on purpose. The set is a selection to
|
||||||
|
* sort and vote on, not a final ten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = '/images/backgrounds'
|
||||||
|
|
||||||
|
export const BACKGROUND_IMAGES = [
|
||||||
|
{ id: 'tinte', label: 'Tinte', tone: 'dark' },
|
||||||
|
{ id: 'tinte-blatt', label: 'Tinte, Blatt', tone: 'dark' },
|
||||||
|
{ id: 'goldstaub', label: 'Goldstaub', tone: 'dark' },
|
||||||
|
{ id: 'horizont', label: 'Horizont', tone: 'dark' },
|
||||||
|
{ id: 'kohle', label: 'Kohle', tone: 'dark' },
|
||||||
|
{ id: 'leinen', label: 'Leinen', tone: 'dark' },
|
||||||
|
{ id: 'leinen-falten', label: 'Leinen, Falten', tone: 'dark' },
|
||||||
|
{ id: 'nebel', label: 'Nebel', tone: 'dark' },
|
||||||
|
{ id: 'daemmerung', label: 'Dämmerung', tone: 'dark' },
|
||||||
|
{ id: 'nachthimmel', label: 'Nachthimmel', tone: 'dark' },
|
||||||
|
{ id: 'tusche', label: 'Tusche', tone: 'dark' },
|
||||||
|
{ id: 'papier', label: 'Papier', tone: 'light' },
|
||||||
|
{ id: 'papier-buetten', label: 'Papier, Bütten', tone: 'light' },
|
||||||
|
{ id: 'aquarell', label: 'Aquarell', tone: 'light' },
|
||||||
|
{ id: 'aquarell-streifen', label: 'Aquarell, Streifen', tone: 'light' }
|
||||||
|
].map(image => ({
|
||||||
|
...image,
|
||||||
|
src: `${BASE}/${image.id}.jpg`,
|
||||||
|
thumb: `${BASE}/thumbs/${image.id}.jpg`
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function findBackgroundImage(src) {
|
||||||
|
return BACKGROUND_IMAGES.find(image => image.src === src) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The test images that shipped before 2026-09-25, mapped to their closest
|
||||||
|
* successor so a stored setting keeps a background instead of a broken path.
|
||||||
|
*/
|
||||||
|
const LEGACY_BACKGROUNDS = {
|
||||||
|
'/images/bg-image-1.jpg': 'nachthimmel',
|
||||||
|
'/images/bg-image-2.jpg': 'leinen',
|
||||||
|
'/images/bg-image-3.jpg': 'nebel',
|
||||||
|
'/images/bg-image-4.jpg': 'tinte',
|
||||||
|
'/images/bg-image-5.jpg': 'daemmerung',
|
||||||
|
'/images/bg-image-6.jpg': 'horizont',
|
||||||
|
'/images/bg-image-7.jpg': 'goldstaub',
|
||||||
|
'/images/bg-image-8.jpg': 'kohle',
|
||||||
|
'/images/bg-image-9.jpg': 'tinte',
|
||||||
|
'/images/bg-image-10.jpg': 'kohle'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrites a stored background path that no longer exists; others pass through. */
|
||||||
|
export function normalizeBackgroundImage(src) {
|
||||||
|
if (typeof src !== 'string') return ''
|
||||||
|
const legacy = LEGACY_BACKGROUNDS[src]
|
||||||
|
return legacy ? `${BASE}/${legacy}.jpg` : src
|
||||||
|
}
|
||||||
|
|
@ -83,14 +83,14 @@ export const WAVE_PRESETS = [
|
||||||
{
|
{
|
||||||
id: 'builtin:dusk',
|
id: 'builtin:dusk',
|
||||||
name: 'Abendrot',
|
name: 'Abendrot',
|
||||||
description: 'Warmer Horizont unten, Nacht oben, Linien in Orange und Rosé.',
|
description: 'Dämmerung über dem Meer, Linien in Orange und Rosé.',
|
||||||
preview: { ground: ['#3a1a2b', '#120711'], lines: ['#ffb86b', '#ff5e7e'] },
|
preview: { ground: ['#3a1a2b', '#120711'], lines: ['#ffb86b', '#ff5e7e'] },
|
||||||
floatingLines: {
|
floatingLines: {
|
||||||
lineMode: 'glow',
|
lineMode: 'glow',
|
||||||
gradientStops: '#ffb86b\n#ff5e7e\n#2a0f1f',
|
gradientStops: '#ffb86b\n#ff5e7e\n#2a0f1f',
|
||||||
bgCenter: '#3a1a2b',
|
bgCenter: '#3a1a2b',
|
||||||
bgEdge: '#120711',
|
bgEdge: '#120711',
|
||||||
backgroundImage: '',
|
backgroundImage: '/images/backgrounds/daemmerung.jpg',
|
||||||
speed: 0.8,
|
speed: 0.8,
|
||||||
glowSize: 20,
|
glowSize: 20,
|
||||||
glowStrength: 1.8,
|
glowStrength: 1.8,
|
||||||
|
|
@ -102,12 +102,12 @@ export const WAVE_PRESETS = [
|
||||||
{
|
{
|
||||||
id: 'builtin:milkyway',
|
id: 'builtin:milkyway',
|
||||||
name: 'Milchstraße',
|
name: 'Milchstraße',
|
||||||
description: 'Sternenhimmel mit feinen, kühlen Linien.',
|
description: 'Nachthimmel mit feinen, kühlen Linien.',
|
||||||
preview: { ground: ['#1b1a24', '#050408'], lines: ['#ffffff', '#9fb4ff'] },
|
preview: { ground: ['#1b1a24', '#050408'], lines: ['#ffffff', '#9fb4ff'] },
|
||||||
floatingLines: {
|
floatingLines: {
|
||||||
lineMode: 'glow',
|
lineMode: 'glow',
|
||||||
gradientStops: '#ffffff\n#9fb4ff\n#1a1f3a',
|
gradientStops: '#ffffff\n#9fb4ff\n#1a1f3a',
|
||||||
backgroundImage: '/images/bg-image-1.jpg',
|
backgroundImage: '/images/backgrounds/nachthimmel.jpg',
|
||||||
bgCenter: '#1b1a24',
|
bgCenter: '#1b1a24',
|
||||||
bgEdge: '#050408',
|
bgEdge: '#050408',
|
||||||
lineBrightness: 0.75,
|
lineBrightness: 0.75,
|
||||||
|
|
@ -121,7 +121,7 @@ export const WAVE_PRESETS = [
|
||||||
{
|
{
|
||||||
id: 'builtin:silk',
|
id: 'builtin:silk',
|
||||||
name: 'Seide',
|
name: 'Seide',
|
||||||
description: 'Schwarzer Stoff, darauf klare goldene Linien.',
|
description: 'Dunkles Leinen, darauf klare goldene Linien.',
|
||||||
preview: { ground: ['#2a2a2a', '#0b0b0b'], lines: ['#f5c26b', '#f5c26b'] },
|
preview: { ground: ['#2a2a2a', '#0b0b0b'], lines: ['#f5c26b', '#f5c26b'] },
|
||||||
floatingLines: {
|
floatingLines: {
|
||||||
lineMode: 'static',
|
lineMode: 'static',
|
||||||
|
|
@ -129,7 +129,7 @@ export const WAVE_PRESETS = [
|
||||||
staticLineShadowStrength: 0.35,
|
staticLineShadowStrength: 0.35,
|
||||||
lineThickness: 1.1,
|
lineThickness: 1.1,
|
||||||
lineCount: 8,
|
lineCount: 8,
|
||||||
backgroundImage: '/images/bg-image-2.jpg',
|
backgroundImage: '/images/backgrounds/leinen.jpg',
|
||||||
bgCenter: '#2a2a2a',
|
bgCenter: '#2a2a2a',
|
||||||
bgEdge: '#0b0b0b',
|
bgEdge: '#0b0b0b',
|
||||||
glowSize: 14,
|
glowSize: 14,
|
||||||
|
|
@ -141,12 +141,12 @@ export const WAVE_PRESETS = [
|
||||||
{
|
{
|
||||||
id: 'builtin:rain',
|
id: 'builtin:rain',
|
||||||
name: 'Regen',
|
name: 'Regen',
|
||||||
description: 'Tropfen auf dunklem Glas, Linien in Türkis.',
|
description: 'Nebel über den Hügeln, Linien in Türkis.',
|
||||||
preview: { ground: ['#1e2224', '#0a0c0d'], lines: ['#7fe3ff', '#1fa2c8'] },
|
preview: { ground: ['#1e2224', '#0a0c0d'], lines: ['#7fe3ff', '#1fa2c8'] },
|
||||||
floatingLines: {
|
floatingLines: {
|
||||||
lineMode: 'glow',
|
lineMode: 'glow',
|
||||||
gradientStops: '#7fe3ff\n#1fa2c8\n#0a1a22',
|
gradientStops: '#7fe3ff\n#1fa2c8\n#0a1a22',
|
||||||
backgroundImage: '/images/bg-image-3.jpg',
|
backgroundImage: '/images/backgrounds/nebel.jpg',
|
||||||
bgCenter: '#1e2224',
|
bgCenter: '#1e2224',
|
||||||
bgEdge: '#0a0c0d',
|
bgEdge: '#0a0c0d',
|
||||||
speed: 1.2,
|
speed: 1.2,
|
||||||
|
|
@ -159,12 +159,12 @@ export const WAVE_PRESETS = [
|
||||||
{
|
{
|
||||||
id: 'builtin:leaves',
|
id: 'builtin:leaves',
|
||||||
name: 'Blattwerk',
|
name: 'Blattwerk',
|
||||||
description: 'Dunkles Grün mit Gold, wie Sonne durch Blätter.',
|
description: 'Grün und Gold auf tintenschwarzem Papier.',
|
||||||
preview: { ground: ['#12301a', '#05110a'], lines: ['#f5c26b', '#8bc34a'] },
|
preview: { ground: ['#12301a', '#05110a'], lines: ['#f5c26b', '#8bc34a'] },
|
||||||
floatingLines: {
|
floatingLines: {
|
||||||
lineMode: 'glow',
|
lineMode: 'glow',
|
||||||
gradientStops: '#f5c26b\n#8bc34a\n#0d1a0c',
|
gradientStops: '#f5c26b\n#8bc34a\n#0d1a0c',
|
||||||
backgroundImage: '/images/bg-image-4.jpg',
|
backgroundImage: '/images/backgrounds/tinte.jpg',
|
||||||
bgCenter: '#12301a',
|
bgCenter: '#12301a',
|
||||||
bgEdge: '#05110a',
|
bgEdge: '#05110a',
|
||||||
speed: 0.7,
|
speed: 0.7,
|
||||||
|
|
@ -178,7 +178,7 @@ export const WAVE_PRESETS = [
|
||||||
{
|
{
|
||||||
id: 'builtin:moon',
|
id: 'builtin:moon',
|
||||||
name: 'Mondsichel',
|
name: 'Mondsichel',
|
||||||
description: 'Fast schwarz, wenige dünne helle Linien. Reduziert.',
|
description: 'Schiefertafel, wenige dünne helle Linien. Reduziert.',
|
||||||
preview: { ground: ['#161616', '#000000'], lines: ['#e8e8e8', '#8a8a8a'] },
|
preview: { ground: ['#161616', '#000000'], lines: ['#e8e8e8', '#8a8a8a'] },
|
||||||
floatingLines: {
|
floatingLines: {
|
||||||
lineMode: 'static',
|
lineMode: 'static',
|
||||||
|
|
@ -188,7 +188,7 @@ export const WAVE_PRESETS = [
|
||||||
lineThickness: 0.6,
|
lineThickness: 0.6,
|
||||||
spread: 0.035,
|
spread: 0.035,
|
||||||
speed: 0.5,
|
speed: 0.5,
|
||||||
backgroundImage: '/images/bg-image-8.jpg',
|
backgroundImage: '/images/backgrounds/kohle.jpg',
|
||||||
bgCenter: '#161616',
|
bgCenter: '#161616',
|
||||||
bgEdge: '#000000',
|
bgEdge: '#000000',
|
||||||
glowSize: 10,
|
glowSize: 10,
|
||||||
|
|
@ -201,12 +201,12 @@ export const WAVE_PRESETS = [
|
||||||
{
|
{
|
||||||
id: 'builtin:neon',
|
id: 'builtin:neon',
|
||||||
name: 'Farbrausch',
|
name: 'Farbrausch',
|
||||||
description: 'Magenta und Blau, Punkte mit weißem Rand. Laut und fröhlich.',
|
description: 'Magenta und Blau über dem Nachglühen, Punkte mit weißem Rand.',
|
||||||
preview: { ground: ['#ff4fd8', '#2b3cff'], lines: ['#ffffff', '#ff6ad5'] },
|
preview: { ground: ['#ff4fd8', '#2b3cff'], lines: ['#ffffff', '#ff6ad5'] },
|
||||||
floatingLines: {
|
floatingLines: {
|
||||||
lineMode: 'glow',
|
lineMode: 'glow',
|
||||||
gradientStops: '#ffffff\n#ff6ad5\n#2b0a3a',
|
gradientStops: '#ffffff\n#ff6ad5\n#2b0a3a',
|
||||||
backgroundImage: '/images/bg-image-6.jpg',
|
backgroundImage: '/images/backgrounds/horizont.jpg',
|
||||||
bgCenter: '#3a0a5a',
|
bgCenter: '#3a0a5a',
|
||||||
bgEdge: '#0d0620',
|
bgEdge: '#0d0620',
|
||||||
speed: 1.3,
|
speed: 1.3,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { apiJson } from 'src/services/apiClient'
|
import { apiJson } from 'src/services/apiClient'
|
||||||
import { WAVE_PRESETS, findWavePreset, isBuiltInPresetId } from 'src/config/wavePresets'
|
import { WAVE_PRESETS, findWavePreset, isBuiltInPresetId } from 'src/config/wavePresets'
|
||||||
|
import { normalizeBackgroundImage } from 'src/config/backgrounds'
|
||||||
import { useAuthStore } from 'stores/auth'
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = 'thatsme-settings'
|
const STORAGE_KEY_PREFIX = 'thatsme-settings'
|
||||||
|
|
@ -89,6 +90,14 @@ function loadFromStorage(userId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defaults plus stored values; a background path from the retired test set
|
||||||
|
// is rewritten to its successor so the setting does not point at a 404.
|
||||||
|
function normalizeFloatingLines(stored) {
|
||||||
|
const merged = { ...FLOATING_LINES_DEFAULTS, ...(stored ?? {}) }
|
||||||
|
merged.backgroundImage = normalizeBackgroundImage(merged.backgroundImage)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
function clone(value) {
|
function clone(value) {
|
||||||
return JSON.parse(JSON.stringify(value))
|
return JSON.parse(JSON.stringify(value))
|
||||||
}
|
}
|
||||||
|
|
@ -140,10 +149,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
const lastError = ref(null)
|
const lastError = ref(null)
|
||||||
|
|
||||||
const theme = ref(initialSettings?.theme ?? 'light')
|
const theme = ref(initialSettings?.theme ?? 'light')
|
||||||
const floatingLines = ref({
|
const floatingLines = ref(normalizeFloatingLines(initialSettings?.floatingLines))
|
||||||
...FLOATING_LINES_DEFAULTS,
|
|
||||||
...(initialSettings?.floatingLines ?? {})
|
|
||||||
})
|
|
||||||
|
|
||||||
// App preferences
|
// App preferences
|
||||||
const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark'
|
const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark'
|
||||||
|
|
@ -188,10 +194,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
|
||||||
function applySnapshot(snapshot) {
|
function applySnapshot(snapshot) {
|
||||||
theme.value = snapshot?.theme ?? 'light'
|
theme.value = snapshot?.theme ?? 'light'
|
||||||
floatingLines.value = {
|
floatingLines.value = normalizeFloatingLines(snapshot?.floatingLines)
|
||||||
...FLOATING_LINES_DEFAULTS,
|
|
||||||
...(snapshot?.floatingLines ?? {})
|
|
||||||
}
|
|
||||||
appearance.value = snapshot?.appearance ?? 'system'
|
appearance.value = snapshot?.appearance ?? 'system'
|
||||||
accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'gold')
|
accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'gold')
|
||||||
language.value = snapshot?.language ?? 'de'
|
language.value = snapshot?.language ?? 'de'
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,18 @@ export function relativeLuminance(hex) {
|
||||||
* header (in "split" horizon mode it is literally the top half), with the
|
* header (in "split" horizon mode it is literally the top half), with the
|
||||||
* centre colour as a smaller influence.
|
* centre colour as a smaller influence.
|
||||||
*
|
*
|
||||||
* @param {{ bgEdge?: string, bgCenter?: string, backgroundImage?: string, isDark?: boolean }} input
|
* A bundled photo says which tone it has (`imageTone`); an unknown photo
|
||||||
|
* (the user's own upload) falls back to the UI theme.
|
||||||
|
*
|
||||||
|
* @param {{ bgEdge?: string, bgCenter?: string, backgroundImage?: string, imageTone?: 'dark'|'light'|null, isDark?: boolean }} input
|
||||||
* @returns {'positive' | 'negative'}
|
* @returns {'positive' | 'negative'}
|
||||||
*/
|
*/
|
||||||
export function logoVariantForBackground({ bgEdge, bgCenter, backgroundImage, isDark = true } = {}) {
|
export function logoVariantForBackground({ bgEdge, bgCenter, backgroundImage, imageTone = null, isDark = true } = {}) {
|
||||||
if (backgroundImage) return isDark ? 'negative' : 'positive'
|
if (backgroundImage) {
|
||||||
|
if (imageTone === 'light') return 'positive'
|
||||||
|
if (imageTone === 'dark') return 'negative'
|
||||||
|
return isDark ? 'negative' : 'positive'
|
||||||
|
}
|
||||||
|
|
||||||
const edge = relativeLuminance(bgEdge)
|
const edge = relativeLuminance(bgEdge)
|
||||||
const centre = relativeLuminance(bgCenter)
|
const centre = relativeLuminance(bgCenter)
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,10 @@ describe('brand — logo variant follows the LifeWave background', () => {
|
||||||
expect(logoVariantForBackground({ bgEdge: 'transparent', isDark: true })).toBe('negative')
|
expect(logoVariantForBackground({ bgEdge: 'transparent', isDark: true })).toBe('negative')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('brand — bundled photos carry their own tone', () => {
|
||||||
|
it('lets a light photo win over a dark theme and the other way round', () => {
|
||||||
|
expect(logoVariantForBackground({ backgroundImage: '/images/backgrounds/papier.jpg', imageTone: 'light', isDark: true })).toBe('positive')
|
||||||
|
expect(logoVariantForBackground({ backgroundImage: '/images/backgrounds/tinte.jpg', imageTone: 'dark', isDark: false })).toBe('negative')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ describe('events store — media types and the voice note (remote user)', () =>
|
||||||
await flush(3)
|
await flush(3)
|
||||||
|
|
||||||
await eventsStore.deleteGhostVoiceNote('v1')
|
await eventsStore.deleteGhostVoiceNote('v1')
|
||||||
await flush(2)
|
await waitFor(() => api.callsTo(MEDIA_DELETE).length === 1, { label: 'DELETE sent' })
|
||||||
|
|
||||||
const deletes = api.callsTo(MEDIA_DELETE)
|
const deletes = api.callsTo(MEDIA_DELETE)
|
||||||
expect(deletes).toHaveLength(1)
|
expect(deletes).toHaveLength(1)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ vi.mock('src/services/apiClient', () => ({
|
||||||
import { BRAND_PRESET_ID, WAVE_PRESETS, findWavePreset, isBuiltInPresetId, presetPreviewStyle } from 'src/config/wavePresets'
|
import { BRAND_PRESET_ID, WAVE_PRESETS, findWavePreset, isBuiltInPresetId, presetPreviewStyle } from 'src/config/wavePresets'
|
||||||
import { FLOATING_LINES_DEFAULTS, useSettingsStore } from 'stores/settings'
|
import { FLOATING_LINES_DEFAULTS, useSettingsStore } from 'stores/settings'
|
||||||
import { relativeLuminance } from 'src/utils/brand'
|
import { relativeLuminance } from 'src/utils/brand'
|
||||||
|
import { BACKGROUND_IMAGES, normalizeBackgroundImage } from 'src/config/backgrounds'
|
||||||
|
|
||||||
// Vitest runs from the frontend directory.
|
// Vitest runs from the frontend directory.
|
||||||
const publicDir = resolve(process.cwd(), 'public')
|
const publicDir = resolve(process.cwd(), 'public')
|
||||||
|
|
@ -186,3 +187,30 @@ describe('settings store — accent colour defaults', () => {
|
||||||
expect(useSettingsStore().accentColor).toBe('grey')
|
expect(useSettingsStore().accentColor).toBe('grey')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('backgrounds — bundled set and legacy paths', () => {
|
||||||
|
it('ships every configured background with its thumbnail', () => {
|
||||||
|
expect(BACKGROUND_IMAGES.length).toBeGreaterThanOrEqual(10)
|
||||||
|
expect(new Set(BACKGROUND_IMAGES.map(image => image.id)).size).toBe(BACKGROUND_IMAGES.length)
|
||||||
|
for (const image of BACKGROUND_IMAGES) {
|
||||||
|
expect(existsSync(`${publicDir}${image.src}`), `${image.id} missing`).toBe(true)
|
||||||
|
expect(existsSync(`${publicDir}${image.thumb}`), `${image.id} thumb missing`).toBe(true)
|
||||||
|
expect(['dark', 'light']).toContain(image.tone)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rewrites a stored path from the retired test set and keeps everything else', () => {
|
||||||
|
expect(normalizeBackgroundImage('/images/bg-image-1.jpg')).toBe('/images/backgrounds/nachthimmel.jpg')
|
||||||
|
expect(normalizeBackgroundImage('/images/backgrounds/kohle.jpg')).toBe('/images/backgrounds/kohle.jpg')
|
||||||
|
expect(normalizeBackgroundImage('/settings/media/background?v=1')).toBe('/settings/media/background?v=1')
|
||||||
|
expect(normalizeBackgroundImage('')).toBe('')
|
||||||
|
expect(normalizeBackgroundImage(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the settings store applies the rewrite when loading', () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
api = createApiRouter()
|
||||||
|
localStorage.setItem('thatsme-settings:guest', JSON.stringify({ floatingLines: { backgroundImage: '/images/bg-image-3.jpg' } }))
|
||||||
|
expect(useSettingsStore().floatingLines.backgroundImage).toBe('/images/backgrounds/nebel.jpg')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||