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>
This commit is contained in:
Kevin Adametz 2026-09-25 09:31:09 +00:00
parent 10251e4f41
commit 522deffa1a
66 changed files with 556 additions and 38 deletions

4
.gitignore vendored
View file

@ -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
View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View 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')

View 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)

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

View file

@ -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 = [
{ {

View file

@ -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
}) })
}) })

View file

@ -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));

View 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
}

View file

@ -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,

View file

@ -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'

View file

@ -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)

View file

@ -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')
})
})

View file

@ -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)

View file

@ -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')
})
})