diff --git a/AGENTS.md b/AGENTS.md index 759f280..1ec370b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). - `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. +- **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//logos/logo_.png`, Auslieferung über `/media//logos/`. Die URL traegt einen `?v=`, 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. - `add_screen` legt ein Verzeichnis unter `media///` an und einen Config-Eintrag. - `delete_screen` entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv. diff --git a/README.md b/README.md index fe03123..cadef1f 100755 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens. - Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge - **Multi-Standort**: Standorte (sites) gruppieren Screens - **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 - Auto-Reload bei Playlist-Änderungen - 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/` | User bearbeiten speichern (Admin) | | `GET /media///` | Medien-Datei ausliefern | | `GET /media/priority/` | Priority-Medien (global) | +| `GET /media//logos/` | Manuell hochgeladenes Kundenlogo | | `GET /media//background/` | Hintergrundbild der Willkommensseite | ### 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. +**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//logos/logo_.png` abgelegt und über `/media//logos/` 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. **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 - `load_priority_files()` – Priority-Playlist + Dateien laden - `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 - `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None` - `load_users()` / `save_users()` / `get_user()` – User aus `users.json` diff --git a/app.py b/app.py index 90d6751..337a6a5 100755 --- a/app.py +++ b/app.py @@ -493,6 +493,14 @@ def media_background(site, filename): return send_from_directory(path, filename) +@app.route("/media//logos/") +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) # ------------------------------------------------- @@ -901,6 +909,7 @@ def add_customer(): customer_names = ["", "", ""] contacts = ["", "", ""] valid_until = (request.form.get("valid_until") or "").strip() + logo_hinweise = [] site = request.args.get("site") or request.form.get("site") or "" # Fallback auf ersten für den User zugänglichen Standort @@ -934,31 +943,52 @@ def add_customer(): error = "Kein Standort ausgewählt" else: try: - found_logos = [] - for name in filled_names: - logo = generate_welcome_page.search_customer_logo(name) - found_logos.append(logo) + # Logo je Kunde bestimmen: ein hochgeladenes Logo hat Vorrang vor + # der automatischen Suche - der Upload ist eine bewusste Entscheidung. + # Zuletzt verwendete Logos, um einen früheren Upload nicht bei jedem + # 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: - error = "Kein Logo konnte gefunden werden" + error = "Kein Logo gefunden – bitte für die betroffenen Kunden ein Logo hochladen" else: - valid_names = [n for n, l in valid] - valid_urls = [l for n, l in valid] - valid_contacts = [] - 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 + valid_names = [n for n, u, k in valid] + valid_urls = [u for n, u, k in valid] + valid_contacts = [k for n, u, k in valid] bg_url = get_background_url(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 config = load_config() 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) 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 " @@ -1019,6 +1056,7 @@ def add_customer(): customer_names=customer_names, contacts=contacts, welcome_screens=generate_welcome_page.welcome_target_screens(config, site) if site else [], + logo_hinweise=logo_hinweise, site=site, site_list=site_list, current_site=site, diff --git a/generate_welcome_page.py b/generate_welcome_page.py index 3b86f5d..7b2bc41 100644 --- a/generate_welcome_page.py +++ b/generate_welcome_page.py @@ -9,6 +9,7 @@ Zielscreens ein (Screens mit "welcome_enabled", Fallback "lobby"). import os import json +import time from openai import OpenAI BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -32,6 +33,159 @@ def get_openai_client(): # 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//logos/logo_.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): """Versucht, ein Firmenlogo über Brandfetch CDN abzurufen. Baut eine direkte CDN-URL: https://cdn.brandfetch.io/{domain}/logo""" diff --git a/templates/customer.html b/templates/customer.html index 83e2522..c9a316a 100644 --- a/templates/customer.html +++ b/templates/customer.html @@ -104,6 +104,14 @@ {% if success %}
{{ success }}
{% endif %} + {% if logo_hinweise %} +
+ Hinweise zu den hochgeladenen Logos: +
    + {% for h in logo_hinweise %}
  • {{ h }}
  • {% endfor %} +
+
+ {% endif %}
@@ -122,36 +130,46 @@
-
+ + + {# 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") + ] %} +
+ {% for i in range(3) %}
- - - - -
-
- - - - -
-
- - - - + + + + + + + + +
+ + +
+ {% endfor %} +
+ +
+ 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.

diff --git a/templates/help.html b/templates/help.html index 16bdad2..72eb143 100644 --- a/templates/help.html +++ b/templates/help.html @@ -252,6 +252,9 @@
  • Zielscreens: Die generierte welcome.html wird automatisch an den Anfang der Playlist jedes Screens gesetzt, bei dem im Tab "Einstellungen" die Option Willkommensseite hier anzeigen 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.
  • Wird ein Screen später abgewählt, verschwindet die Willkommensseite dort beim nächsten Generieren automatisch.
  • Anzeigen bis: 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.
  • +
  • Logo hochladen: 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.
  • +
  • Freistellen: 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.
  • +
  • Ein einmal hochgeladenes Logo bleibt beim erneuten Generieren erhalten, solange der Kundenname im selben Feld steht.
  • Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)
  • Pro Kunde können Ansprechpartner eingegeben werden (Markdown-Format):