Compare commits
2 Commits
4974ad92df
...
3844ceacf2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3844ceacf2 | ||
|
|
64db742d31 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ test*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode
|
.vscode
|
||||||
.env
|
.env
|
||||||
|
env.sh
|
||||||
media/
|
media/
|
||||||
*.mp4
|
*.mp4
|
||||||
*.mov
|
*.mov
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
|||||||
- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem).
|
- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem).
|
||||||
- `add_customer_to_playlists` (früher `add_customer_to_lobby_playlist`) entfernt `welcome.html` als String *und* als Dict aus **allen** Screens des Standorts und fügt sie danach nur in den Zielscreens an Position 0 wieder ein – ein abgewählter Screen bleibt so nicht mit einer alten Begrüßung stehen. Eingefügt wird ein Dict, kein String, damit `enabled` und `schedule` mitgeführt werden können.
|
- `add_customer_to_playlists` (früher `add_customer_to_lobby_playlist`) entfernt `welcome.html` als String *und* als Dict aus **allen** Screens des Standorts und fügt sie danach nur in den Zielscreens an Position 0 wieder ein – ein abgewählter Screen bleibt so nicht mit einer alten Begrüßung stehen. Eingefügt wird ein Dict, kein String, damit `enabled` und `schedule` mitgeführt werden können.
|
||||||
- `welcome_target_screens(config, site)` fällt nur dann auf `lobby` zurück, wenn **kein** Screen des Standorts den Schlüssel `welcome_enabled` besitzt. Wurde er überall bewusst auf `False` gesetzt, ist die leere Liste das gewollte Ergebnis.
|
- `welcome_target_screens(config, site)` fällt nur dann auf `lobby` zurück, wenn **kein** Screen des Standorts den Schlüssel `welcome_enabled` besitzt. Wurde er überall bewusst auf `False` gesetzt, ist die leere Liste das gewollte Ergebnis.
|
||||||
|
- **Logo-Upload**: Pro Kunde kann im Willkommensseiten-Formular eine Bilddatei hochgeladen werden (`logo_file_1..3`); ein Upload hat Vorrang vor `search_customer_logo`. Ablage als `media/<site>/logos/logo_<slot>.png`, Auslieferung über `/media/<site>/logos/<datei>`. Die URL traegt einen `?v=<timestamp>`, damit Player kein altes Logo aus dem Cache zeigen.
|
||||||
|
- `remove_logo_background()` arbeitet mit Pillow (Flood Fill von den Raendern, weiche Kante). **Bewusst kein ML-Modell** wie rembg: der Container laeuft mit read-only Root-FS ohne Modell-Cache. Flood Fill statt globalem Farbvergleich ist wesentlich - sonst verschwindet auch Weiss *innerhalb* des Logos. Bereits transparente Bilder werden unveraendert durchgereicht; bei unruhigem Bildrand bleibt das Bild unangetastet und die Funktion gibt einen Hinweis zurueck.
|
||||||
|
- `welcome_data` in der Site-Config wird **feldweise** gespeichert (Slot 1-3, auch leere), nicht nur die erfolgreichen Kunden - sonst verschiebt sich die Zuordnung von Name, Logo und Ansprechpartner beim naechsten Aufruf. Ein zuvor hochgeladenes Logo wird beim erneuten Generieren wiederverwendet, solange der Kundenname im selben Feld unveraendert ist.
|
||||||
- Weder `save_welcome_page` noch `add_customer_to_playlists` legen Screens an. Das frühere `screens.setdefault("lobby", {})` erzeugte an Standorten ohne Lobby stillschweigend einen Screen.
|
- Weder `save_welcome_page` noch `add_customer_to_playlists` legen Screens an. Das frühere `screens.setdefault("lobby", {})` erzeugte an Standorten ohne Lobby stillschweigend einen Screen.
|
||||||
- `add_screen` legt ein Verzeichnis unter `media/<site>/<screen>/` an und einen Config-Eintrag.
|
- `add_screen` legt ein Verzeichnis unter `media/<site>/<screen>/` an und einen Config-Eintrag.
|
||||||
- `delete_screen` entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv.
|
- `delete_screen` entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
|
|||||||
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
|
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
|
||||||
- **Multi-Standort**: Standorte (sites) gruppieren Screens
|
- **Multi-Standort**: Standorte (sites) gruppieren Screens
|
||||||
- **Priority-Playlist**: globale Inhalte wirken auf alle Player
|
- **Priority-Playlist**: globale Inhalte wirken auf alle Player
|
||||||
- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch, pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum
|
- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch oder **manueller Upload mit automatischem Freistellen** (transparenter Hintergrund), pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum
|
||||||
- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge
|
- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge
|
||||||
- Auto-Reload bei Playlist-Änderungen
|
- Auto-Reload bei Playlist-Änderungen
|
||||||
- Newsticker pro Screen
|
- Newsticker pro Screen
|
||||||
@@ -221,6 +221,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
|
|||||||
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
|
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
|
||||||
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
||||||
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
||||||
|
| `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo |
|
||||||
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
||||||
|
|
||||||
### Heartbeat / Online-Status
|
### Heartbeat / Online-Status
|
||||||
@@ -295,6 +296,10 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
|
|||||||
|
|
||||||
**Zielscreens:** Jeder Screen mit `welcome_enabled` (Schalter „Willkommensseite hier anzeigen" im Tab Einstellungen). Besitzt kein Screen des Standorts den Schlüssel, greift das alte Verhalten und die Seite geht nur in `lobby`. Wird ein Screen später abgewählt, verschwindet die Willkommensseite dort beim nächsten Generieren.
|
**Zielscreens:** Jeder Screen mit `welcome_enabled` (Schalter „Willkommensseite hier anzeigen" im Tab Einstellungen). Besitzt kein Screen des Standorts den Schlüssel, greift das alte Verhalten und die Seite geht nur in `lobby`. Wird ein Screen später abgewählt, verschwindet die Willkommensseite dort beim nächsten Generieren.
|
||||||
|
|
||||||
|
**Logo-Upload:** Findet die automatische Suche kein Logo (oder passt es nicht), kann pro Kunde eine Bilddatei hochgeladen werden. Ein Upload hat Vorrang vor der Suche. Die Datei wird als `media/<site>/logos/logo_<slot>.png` abgelegt und über `/media/<site>/logos/<datei>` ausgeliefert.
|
||||||
|
|
||||||
|
**Freistellen:** Beim Upload wird der Hintergrund automatisch entfernt, damit das Logo nicht in einem weissen Kasten auf dem Hintergrundbild steht. Verfahren: Flood Fill von den Bildrändern her (bewusst kein globaler Farbvergleich – so bleibt Weiss *innerhalb* des Logos erhalten), weiche Kante gegen Treppeneffekte, Speicherung immer als PNG. Bereits transparente Bilder werden unverändert übernommen; ist der Bildrand nicht einfarbig, bleibt das Bild unangetastet und die Oberfläche weist darauf hin. Mit der Checkbox „Hintergrund behalten" lässt sich das Freistellen pro Kunde abschalten. Umgesetzt mit Pillow – bewusst ohne ML-Modell, da der Container mit read-only Root-FS läuft und kein Modell-Cache zur Verfügung steht.
|
||||||
|
|
||||||
**Anzeigen bis:** Optionales Datum im Formular. Ist es gesetzt, bekommt der Eintrag eine Zeitsteuerung (`daily 00:00–23:59` mit `valid_until`) und verschwindet danach auf allen Screens von selbst.
|
**Anzeigen bis:** Optionales Datum im Formular. Ist es gesetzt, bekommt der Eintrag eine Zeitsteuerung (`daily 00:00–23:59` mit `valid_until`) und verschwindet danach auf allen Screens von selbst.
|
||||||
|
|
||||||
**Ansprechpartner:** Pro Kunde können Kontaktdaten eingegeben werden (Markdown-Format).
|
**Ansprechpartner:** Pro Kunde können Kontaktdaten eingegeben werden (Markdown-Format).
|
||||||
@@ -502,6 +507,8 @@ Optional pro Eintrag. Ohne `schedule` läuft der Eintrag immer (sofern `enabled`
|
|||||||
- `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips
|
- `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips
|
||||||
- `load_priority_files()` – Priority-Playlist + Dateien laden
|
- `load_priority_files()` – Priority-Playlist + Dateien laden
|
||||||
- `welcome_target_screens(config, site)` – Zielscreens der Willkommensseite (Fallback `lobby`)
|
- `welcome_target_screens(config, site)` – Zielscreens der Willkommensseite (Fallback `lobby`)
|
||||||
|
- `save_uploaded_logo(file, site, slot, freistellen=True)` – hochgeladenes Logo freistellen und als PNG ablegen
|
||||||
|
- `remove_logo_background(image)` – Hintergrund per Flood Fill entfernen, gibt `(bild, hinweis)` zurück
|
||||||
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
|
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
|
||||||
- `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None`
|
- `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None`
|
||||||
- `load_users()` / `save_users()` / `get_user()` – User aus `users.json`
|
- `load_users()` / `save_users()` / `get_user()` – User aus `users.json`
|
||||||
|
|||||||
82
app.py
82
app.py
@@ -493,6 +493,14 @@ def media_background(site, filename):
|
|||||||
return send_from_directory(path, filename)
|
return send_from_directory(path, filename)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/media/<site>/logos/<path:filename>")
|
||||||
|
def media_logo(site, filename):
|
||||||
|
"""Liefert ein manuell hochgeladenes Kundenlogo aus.
|
||||||
|
Ohne Login erreichbar wie die übrigen Medien - der Player ist nicht angemeldet."""
|
||||||
|
path = os.path.join(MEDIA_DIR, site, generate_welcome_page.LOGO_DIRNAME)
|
||||||
|
return send_from_directory(path, filename)
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# User-Verwaltung (Flask-Login mit users.json)
|
# User-Verwaltung (Flask-Login mit users.json)
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@@ -901,6 +909,7 @@ def add_customer():
|
|||||||
customer_names = ["", "", ""]
|
customer_names = ["", "", ""]
|
||||||
contacts = ["", "", ""]
|
contacts = ["", "", ""]
|
||||||
valid_until = (request.form.get("valid_until") or "").strip()
|
valid_until = (request.form.get("valid_until") or "").strip()
|
||||||
|
logo_hinweise = []
|
||||||
site = request.args.get("site") or request.form.get("site") or ""
|
site = request.args.get("site") or request.form.get("site") or ""
|
||||||
|
|
||||||
# Fallback auf ersten für den User zugänglichen Standort
|
# Fallback auf ersten für den User zugänglichen Standort
|
||||||
@@ -934,31 +943,52 @@ def add_customer():
|
|||||||
error = "Kein Standort ausgewählt"
|
error = "Kein Standort ausgewählt"
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
found_logos = []
|
# Logo je Kunde bestimmen: ein hochgeladenes Logo hat Vorrang vor
|
||||||
for name in filled_names:
|
# der automatischen Suche - der Upload ist eine bewusste Entscheidung.
|
||||||
logo = generate_welcome_page.search_customer_logo(name)
|
# Zuletzt verwendete Logos, um einen früheren Upload nicht bei jedem
|
||||||
found_logos.append(logo)
|
# erneuten Generieren zu verlieren
|
||||||
|
frueher = load_config().get("sites", {}).get(site, {}).get("welcome_data", {})
|
||||||
|
frueher_namen = frueher.get("names", [])
|
||||||
|
frueher_urls = frueher.get("logo_urls", [])
|
||||||
|
logo_prefix = f"/media/{site}/{generate_welcome_page.LOGO_DIRNAME}/"
|
||||||
|
|
||||||
valid = [(n, l) for n, l in zip(filled_names, found_logos) if l]
|
found_logos = [None, None, None]
|
||||||
|
for i, name in enumerate(customer_names):
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
datei = request.files.get(f"logo_file_{i + 1}")
|
||||||
|
if datei and datei.filename:
|
||||||
|
url, hinweis = generate_welcome_page.save_uploaded_logo(
|
||||||
|
datei, site, i + 1,
|
||||||
|
freistellen=(f"logo_keep_bg_{i + 1}" not in request.form))
|
||||||
|
found_logos[i] = url
|
||||||
|
if hinweis:
|
||||||
|
logo_hinweise.append(f"{name}: {hinweis}")
|
||||||
|
elif not url:
|
||||||
|
logo_hinweise.append(f"{name}: Logo konnte nicht übernommen werden")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Kein neuer Upload: ein zuvor hochgeladenes Logo weiterverwenden,
|
||||||
|
# solange der Kundenname in diesem Feld derselbe geblieben ist
|
||||||
|
alt_url = frueher_urls[i] if i < len(frueher_urls) else None
|
||||||
|
alt_name = frueher_namen[i] if i < len(frueher_namen) else None
|
||||||
|
if alt_url and alt_name == name and str(alt_url).startswith(logo_prefix):
|
||||||
|
found_logos[i] = alt_url
|
||||||
|
continue
|
||||||
|
|
||||||
|
found_logos[i] = generate_welcome_page.search_customer_logo(name)
|
||||||
|
|
||||||
|
logo_urls = list(found_logos)
|
||||||
|
valid = [(customer_names[i], found_logos[i], contacts[i])
|
||||||
|
for i in range(len(customer_names))
|
||||||
|
if customer_names[i] and found_logos[i]]
|
||||||
|
|
||||||
if not valid:
|
if not valid:
|
||||||
error = "Kein Logo konnte gefunden werden"
|
error = "Kein Logo gefunden – bitte für die betroffenen Kunden ein Logo hochladen"
|
||||||
else:
|
else:
|
||||||
valid_names = [n for n, l in valid]
|
valid_names = [n for n, u, k in valid]
|
||||||
valid_urls = [l for n, l in valid]
|
valid_urls = [u for n, u, k in valid]
|
||||||
valid_contacts = []
|
valid_contacts = [k for n, u, k in valid]
|
||||||
for name in valid_names:
|
|
||||||
for i, cn in enumerate(customer_names):
|
|
||||||
if cn == name:
|
|
||||||
valid_contacts.append(contacts[i] if i < len(contacts) else "")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
valid_contacts.append("")
|
|
||||||
for i, name in enumerate(customer_names):
|
|
||||||
for j, (vn, vu) in enumerate(zip(valid_names, valid_urls)):
|
|
||||||
if vn == name:
|
|
||||||
logo_urls[i] = vu
|
|
||||||
break
|
|
||||||
|
|
||||||
bg_url = get_background_url(site)
|
bg_url = get_background_url(site)
|
||||||
targets = generate_welcome_page.welcome_target_screens(load_config(), site)
|
targets = generate_welcome_page.welcome_target_screens(load_config(), site)
|
||||||
@@ -978,7 +1008,14 @@ def add_customer():
|
|||||||
# Letzte Kundendaten für Background-Update speichern
|
# Letzte Kundendaten für Background-Update speichern
|
||||||
config = load_config()
|
config = load_config()
|
||||||
site_cfg = config.setdefault("sites", {}).setdefault(site, {})
|
site_cfg = config.setdefault("sites", {}).setdefault(site, {})
|
||||||
site_cfg["welcome_data"] = {"names": valid_names, "logo_urls": valid_urls, "contacts": valid_contacts}
|
# Feldweise speichern (Slot 1-3), nicht nur die erfolgreichen -
|
||||||
|
# sonst rutscht Kunde 2 beim naechsten Aufruf in Feld 1 und die
|
||||||
|
# Zuordnung von Logo und Ansprechpartner verschiebt sich mit.
|
||||||
|
site_cfg["welcome_data"] = {
|
||||||
|
"names": list(customer_names),
|
||||||
|
"logo_urls": list(found_logos),
|
||||||
|
"contacts": list(contacts)
|
||||||
|
}
|
||||||
save_config(config)
|
save_config(config)
|
||||||
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt (Screens: {', '.join(targets)})", current_user.email)
|
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt (Screens: {', '.join(targets)})", current_user.email)
|
||||||
success = (f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt "
|
success = (f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt "
|
||||||
@@ -1019,6 +1056,7 @@ def add_customer():
|
|||||||
customer_names=customer_names,
|
customer_names=customer_names,
|
||||||
contacts=contacts,
|
contacts=contacts,
|
||||||
welcome_screens=generate_welcome_page.welcome_target_screens(config, site) if site else [],
|
welcome_screens=generate_welcome_page.welcome_target_screens(config, site) if site else [],
|
||||||
|
logo_hinweise=logo_hinweise,
|
||||||
site=site,
|
site=site,
|
||||||
site_list=site_list,
|
site_list=site_list,
|
||||||
current_site=site,
|
current_site=site,
|
||||||
|
|||||||
2
env.sh
2
env.sh
@@ -1,2 +0,0 @@
|
|||||||
export OPENAI_API_KEY="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"
|
|
||||||
export BRANDFETCH_API_KEY="eBwCQW_DcQ_jvdqZRNph0JBtRA36XoMTufaaU4AoirOBnqXX9fMqHDw7uYfiz8kSFYMNKGMZtuHxrmud9hn0WQ"
|
|
||||||
@@ -9,6 +9,7 @@ Zielscreens ein (Screens mit "welcome_enabled", Fallback "lobby").
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -32,6 +33,159 @@ def get_openai_client():
|
|||||||
# Brandfetch-Logo-Suche
|
# Brandfetch-Logo-Suche
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Logo-Upload: Freistellen
|
||||||
|
# -------------------------------------------------
|
||||||
|
|
||||||
|
LOGO_MAX_SIZE = 800 # Kantenlänge, auf die grosse Uploads verkleinert werden
|
||||||
|
LOGO_TOLERANCE = 32 # Farbabstand, bis zu dem ein Pixel als Hintergrund gilt
|
||||||
|
LOGO_BORDER_UNIFORMITY = 48 # Streuung, bis zu der ein Rand als einfarbig durchgeht
|
||||||
|
|
||||||
|
|
||||||
|
def _color_distance(a, b):
|
||||||
|
"""Abstand zweier RGB-Werte (Manhattan über die Kanäle, gemittelt)."""
|
||||||
|
return (abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])) / 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def _border_color(px, w, h):
|
||||||
|
"""Ermittelt die Hintergrundfarbe aus den Randpixeln.
|
||||||
|
Gibt (farbe, einfarbig) zurück - einfarbig=False, wenn der Rand zu
|
||||||
|
stark streut und ein Freistellen deshalb nicht sinnvoll ist."""
|
||||||
|
rand = []
|
||||||
|
schritt = max(1, min(w, h) // 100)
|
||||||
|
for x in range(0, w, schritt):
|
||||||
|
rand.append(px[x, 0][:3])
|
||||||
|
rand.append(px[x, h - 1][:3])
|
||||||
|
for y in range(0, h, schritt):
|
||||||
|
rand.append(px[0, y][:3])
|
||||||
|
rand.append(px[w - 1, y][:3])
|
||||||
|
|
||||||
|
mitte = tuple(sorted(k[i] for k in rand)[len(rand) // 2] for i in range(3))
|
||||||
|
streuung = sum(_color_distance(k, mitte) for k in rand) / len(rand)
|
||||||
|
return mitte, streuung <= LOGO_BORDER_UNIFORMITY
|
||||||
|
|
||||||
|
|
||||||
|
def remove_logo_background(image, tolerance=LOGO_TOLERANCE):
|
||||||
|
"""Stellt ein Logo frei: entfernt den zusammenhängenden Hintergrund von den
|
||||||
|
Rändern her und macht ihn transparent.
|
||||||
|
|
||||||
|
Bewusst ein Flood Fill von den Rändern und kein globaler Farbvergleich -
|
||||||
|
so bleibt Weiss *innerhalb* des Logos (etwa Text in einer Fläche) erhalten.
|
||||||
|
|
||||||
|
Gibt (bild, hinweis) zurück. hinweis ist None bei Erfolg, sonst ein Text
|
||||||
|
für die Oberfläche - das Bild wird dann unverändert zurückgegeben.
|
||||||
|
"""
|
||||||
|
from collections import deque
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = image.convert("RGBA")
|
||||||
|
|
||||||
|
if max(img.size) > LOGO_MAX_SIZE:
|
||||||
|
img.thumbnail((LOGO_MAX_SIZE, LOGO_MAX_SIZE), Image.LANCZOS)
|
||||||
|
|
||||||
|
# Bereits transparent? Dann nicht anfassen.
|
||||||
|
alpha = img.getchannel("A")
|
||||||
|
if alpha.getextrema()[0] < 255:
|
||||||
|
return img, None
|
||||||
|
|
||||||
|
w, h = img.size
|
||||||
|
px = img.load()
|
||||||
|
bg, einfarbig = _border_color(px, w, h)
|
||||||
|
|
||||||
|
if not einfarbig:
|
||||||
|
return img, ("Der Bildrand ist nicht einfarbig – das Logo wurde unverändert "
|
||||||
|
"übernommen. Am besten ein PNG mit transparentem Hintergrund hochladen.")
|
||||||
|
|
||||||
|
# Flood Fill von allen Randpixeln aus
|
||||||
|
besucht = bytearray(w * h)
|
||||||
|
queue = deque()
|
||||||
|
for x in range(w):
|
||||||
|
for y in (0, h - 1):
|
||||||
|
queue.append((x, y))
|
||||||
|
for y in range(h):
|
||||||
|
for x in (0, w - 1):
|
||||||
|
queue.append((x, y))
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
x, y = queue.popleft()
|
||||||
|
i = y * w + x
|
||||||
|
if besucht[i]:
|
||||||
|
continue
|
||||||
|
besucht[i] = 1
|
||||||
|
if _color_distance(px[x, y], bg) > tolerance:
|
||||||
|
continue
|
||||||
|
px[x, y] = (0, 0, 0, 0)
|
||||||
|
if x > 0: queue.append((x - 1, y))
|
||||||
|
if x < w - 1: queue.append((x + 1, y))
|
||||||
|
if y > 0: queue.append((x, y - 1))
|
||||||
|
if y < h - 1: queue.append((x, y + 1))
|
||||||
|
|
||||||
|
# Weiche Kante: Pixel direkt an der Freistellung teilweise durchscheinen
|
||||||
|
# lassen, sonst bleibt ein harter Treppenrand stehen.
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
r, g, b, a = px[x, y]
|
||||||
|
if a == 0:
|
||||||
|
continue
|
||||||
|
nachbar_frei = (
|
||||||
|
(x > 0 and px[x - 1, y][3] == 0) or
|
||||||
|
(x < w - 1 and px[x + 1, y][3] == 0) or
|
||||||
|
(y > 0 and px[x, y - 1][3] == 0) or
|
||||||
|
(y < h - 1 and px[x, y + 1][3] == 0)
|
||||||
|
)
|
||||||
|
if not nachbar_frei:
|
||||||
|
continue
|
||||||
|
d = _color_distance((r, g, b), bg)
|
||||||
|
if d < tolerance * 2:
|
||||||
|
px[x, y] = (r, g, b, int(255 * max(0.0, (d - tolerance) / tolerance)))
|
||||||
|
|
||||||
|
if not any(px[x, y][3] == 0 for x in range(0, w, max(1, w // 50)) for y in (0, h - 1)):
|
||||||
|
return img, "Es wurde kein zusammenhängender Hintergrund gefunden."
|
||||||
|
|
||||||
|
return img, None
|
||||||
|
|
||||||
|
|
||||||
|
LOGO_DIRNAME = "logos"
|
||||||
|
LOGO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
|
||||||
|
|
||||||
|
|
||||||
|
def save_uploaded_logo(file_storage, site, slot, freistellen=True):
|
||||||
|
"""Nimmt ein hochgeladenes Logo entgegen, stellt es optional frei und legt es
|
||||||
|
unter media/<site>/logos/logo_<slot>.png ab.
|
||||||
|
|
||||||
|
Gespeichert wird immer als PNG - JPEG kann keine Transparenz.
|
||||||
|
Gibt (url, hinweis) zurück; url ist None, wenn die Datei unbrauchbar war.
|
||||||
|
Der Zeitstempel in der URL verhindert, dass Player ein altes Logo
|
||||||
|
aus dem Cache anzeigen."""
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
name = (file_storage.filename or "").lower()
|
||||||
|
if not name.endswith(LOGO_EXTENSIONS):
|
||||||
|
return None, f"Format nicht unterstützt ({', '.join(LOGO_EXTENSIONS)})."
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(file_storage.stream)
|
||||||
|
img.load()
|
||||||
|
except (UnidentifiedImageError, OSError) as e:
|
||||||
|
return None, f"Datei konnte nicht gelesen werden: {e}"
|
||||||
|
|
||||||
|
hinweis = None
|
||||||
|
if freistellen:
|
||||||
|
img, hinweis = remove_logo_background(img)
|
||||||
|
else:
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
|
||||||
|
logo_dir = os.path.join(MEDIA_DIR, site, LOGO_DIRNAME)
|
||||||
|
os.makedirs(logo_dir, exist_ok=True)
|
||||||
|
dateiname = f"logo_{slot}.png"
|
||||||
|
img.save(os.path.join(logo_dir, dateiname), "PNG")
|
||||||
|
|
||||||
|
url = f"/media/{site}/{LOGO_DIRNAME}/{dateiname}?v={int(time.time())}"
|
||||||
|
print(f"✅ Generate - Logo hochgeladen: {url}"
|
||||||
|
+ (f" (Hinweis: {hinweis})" if hinweis else ""))
|
||||||
|
return url, hinweis
|
||||||
|
|
||||||
|
|
||||||
def get_brandfetch_logo(domain):
|
def get_brandfetch_logo(domain):
|
||||||
"""Versucht, ein Firmenlogo über Brandfetch CDN abzurufen.
|
"""Versucht, ein Firmenlogo über Brandfetch CDN abzurufen.
|
||||||
Baut eine direkte CDN-URL: https://cdn.brandfetch.io/{domain}/logo"""
|
Baut eine direkte CDN-URL: https://cdn.brandfetch.io/{domain}/logo"""
|
||||||
|
|||||||
@@ -104,6 +104,14 @@
|
|||||||
{% if success %}
|
{% if success %}
|
||||||
<div class="alert alert-success">{{ success }}</div>
|
<div class="alert alert-success">{{ success }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if logo_hinweise %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<strong>Hinweise zu den hochgeladenen Logos:</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
{% for h in logo_hinweise %}<li>{{ h }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- ─── Logo-Vorschau (wird nach POST angezeigt) ─── -->
|
<!-- ─── Logo-Vorschau (wird nach POST angezeigt) ─── -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
@@ -122,36 +130,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── Eingabeformular (bis zu 3 Kunden) ─── -->
|
<!-- ─── Eingabeformular (bis zu 3 Kunden) ─── -->
|
||||||
<form method="POST">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="site" value="{{ site }}">
|
<input type="hidden" name="site" value="{{ site }}">
|
||||||
|
|
||||||
|
{# Echte Zeilenumbrueche statt - in einer Variablen wuerde Jinja
|
||||||
|
das kaufmaennische Und escapen und die Entity waere als Text sichtbar. #}
|
||||||
|
{% set slots = [
|
||||||
|
("z. B. Beispiel GmbH", "**Max Mustermann**\n+49 123 4567890\n*E-Mail*: max@beispiel.de"),
|
||||||
|
("z. B. Muster AG", "**Maria Schmidt**\n+49 456 7890123\n*E-Mail*: maria@muster.de"),
|
||||||
|
("z. B. Firma GmbH", "**Sabine Weber**\n+49 789 0123456\n*E-Mail*: sabine@firma.de")
|
||||||
|
] %}
|
||||||
|
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
|
{% for i in range(3) %}
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label" for="customer_name_1">Kunde 1</label>
|
<label class="form-label" for="customer_name_{{ i + 1 }}">Kunde {{ i + 1 }}{% if i > 0 %} (optional){% endif %}</label>
|
||||||
<input type="text" class="form-control" id="customer_name_1"
|
<input type="text" class="form-control" id="customer_name_{{ i + 1 }}"
|
||||||
name="customer_name_1" placeholder="z. B. Beispiel GmbH"
|
name="customer_name_{{ i + 1 }}" placeholder="{{ slots[i][0] }}"
|
||||||
value="{{ customer_names[0] if customer_names else '' }}">
|
value="{{ customer_names[i] if customer_names else '' }}">
|
||||||
<label class="form-label mt-2" for="contact_1">Ansprechpartner (optional)</label>
|
|
||||||
<textarea class="form-control" id="contact_1" name="contact_1" rows="4"
|
<label class="form-label mt-2" for="contact_{{ i + 1 }}">Ansprechpartner (optional)</label>
|
||||||
placeholder="**Max Mustermann** +49 123 4567890 *E-Mail*: max@beispiel.de">{{ contacts[0] if contacts else '' }}</textarea>
|
<textarea class="form-control" id="contact_{{ i + 1 }}" name="contact_{{ i + 1 }}" rows="4"
|
||||||
|
placeholder="{{ slots[i][1] }}">{{ contacts[i] if contacts else '' }}</textarea>
|
||||||
|
|
||||||
|
<label class="form-label mt-2" for="logo_file_{{ i + 1 }}">Logo hochladen (optional)</label>
|
||||||
|
<input type="file" class="form-control" id="logo_file_{{ i + 1 }}"
|
||||||
|
name="logo_file_{{ i + 1 }}" accept="image/*">
|
||||||
|
<div class="form-check mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
id="logo_keep_bg_{{ i + 1 }}" name="logo_keep_bg_{{ i + 1 }}">
|
||||||
|
<label class="form-check-label small" for="logo_keep_bg_{{ i + 1 }}">Hintergrund behalten</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label" for="customer_name_2">Kunde 2 (optional)</label>
|
|
||||||
<input type="text" class="form-control" id="customer_name_2"
|
|
||||||
name="customer_name_2" placeholder="z. B. Muster AG"
|
|
||||||
value="{{ customer_names[1] if customer_names else '' }}">
|
|
||||||
<label class="form-label mt-2" for="contact_2">Ansprechpartner (optional)</label>
|
|
||||||
<textarea class="form-control" id="contact_2" name="contact_2" rows="4"
|
|
||||||
placeholder="**Maria Schmidt** +49 456 7890123 *E-Mail*: maria@muster.de">{{ contacts[1] if contacts else '' }}</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
{% endfor %}
|
||||||
<label class="form-label" for="customer_name_3">Kunde 3 (optional)</label>
|
|
||||||
<input type="text" class="form-control" id="customer_name_3"
|
|
||||||
name="customer_name_3" placeholder="z. B. Firma GmbH"
|
|
||||||
value="{{ customer_names[2] if customer_names else '' }}">
|
|
||||||
<label class="form-label mt-2" for="contact_3">Ansprechpartner (optional)</label>
|
|
||||||
<textarea class="form-control" id="contact_3" name="contact_3" rows="4"
|
|
||||||
placeholder="**Sabine Weber** +49 789 0123456 *E-Mail*: sabine@firma.de">{{ contacts[2] if contacts else '' }}</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="text-muted small mb-3">
|
||||||
|
<i class="ti ti-info-circle me-1"></i>Ein hochgeladenes Logo hat Vorrang vor der automatischen Suche.
|
||||||
|
Der Hintergrund wird automatisch freigestellt, damit das Logo auf der Willkommensseite nicht in einem
|
||||||
|
weissen Kasten steht. Am besten funktioniert das bei einem Logo auf einfarbigem Grund; ein PNG mit
|
||||||
|
bereits transparentem Hintergrund wird unverändert übernommen.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="mt-4 mb-3">
|
<hr class="mt-4 mb-3">
|
||||||
|
|||||||
@@ -252,6 +252,9 @@
|
|||||||
<li><strong>Zielscreens:</strong> Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Playlist <em>jedes</em> Screens gesetzt, bei dem im Tab "Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist. So lässt sich dieselbe Begrüßung auf mehreren Screens zeigen, ohne sie mehrfach anzulegen. Ist an einem Standort nichts eingestellt, geht sie wie bisher in die Lobby.</li>
|
<li><strong>Zielscreens:</strong> Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Playlist <em>jedes</em> Screens gesetzt, bei dem im Tab "Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist. So lässt sich dieselbe Begrüßung auf mehreren Screens zeigen, ohne sie mehrfach anzulegen. Ist an einem Standort nichts eingestellt, geht sie wie bisher in die Lobby.</li>
|
||||||
<li>Wird ein Screen später abgewählt, verschwindet die Willkommensseite dort beim nächsten Generieren automatisch.</li>
|
<li>Wird ein Screen später abgewählt, verschwindet die Willkommensseite dort beim nächsten Generieren automatisch.</li>
|
||||||
<li><strong>Anzeigen bis:</strong> Optionales Datum im Formular. Ist es gesetzt, verschwindet die Willkommensseite nach diesem Tag auf allen Screens von selbst – kein manuelles Aufräumen nach dem Besuch. Ohne Angabe bleibt sie stehen.</li>
|
<li><strong>Anzeigen bis:</strong> Optionales Datum im Formular. Ist es gesetzt, verschwindet die Willkommensseite nach diesem Tag auf allen Screens von selbst – kein manuelles Aufräumen nach dem Besuch. Ohne Angabe bleibt sie stehen.</li>
|
||||||
|
<li><strong>Logo hochladen:</strong> Findet die automatische Suche kein Logo oder passt es nicht, kann pro Kunde eine eigene Bilddatei hochgeladen werden. Ein Upload hat immer Vorrang vor der automatischen Suche.</li>
|
||||||
|
<li><strong>Freistellen:</strong> Der Hintergrund eines hochgeladenen Logos wird automatisch entfernt, damit es auf der Willkommensseite nicht in einem weissen Kasten steht. Das gelingt am besten bei einem Logo auf einfarbigem Grund – ein PNG mit bereits transparentem Hintergrund wird unverändert übernommen. Bei unruhigem Bildrand bleibt das Bild unangetastet und es erscheint ein Hinweis. Mit „Hintergrund behalten" lässt sich das Freistellen abschalten.</li>
|
||||||
|
<li>Ein einmal hochgeladenes Logo bleibt beim erneuten Generieren erhalten, solange der Kundenname im selben Feld steht.</li>
|
||||||
<li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</li>
|
<li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</li>
|
||||||
<li>Pro Kunde können <strong>Ansprechpartner</strong> eingegeben werden (Markdown-Format):
|
<li>Pro Kunde können <strong>Ansprechpartner</strong> eingegeben werden (Markdown-Format):
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user