# Signage-Steuerung in der Symfony-Webseite (Swimmonitor)

Diese Beschreibung ist die Arbeitsgrundlage, um die Signage-Verwaltung als Modul in die
bestehende Symfony-Webseite einzubauen. Sie konkretisiert Abschnitt 5 bis 10 von
`PLAN-DIGITAL-SIGNAGE.md` und legt das Datenformat fest, das der Player auf dem Mini-PC
bereits versteht (`player/src/signage_player/domain.py`).

## 1. Stand

| Teil | Stand |
|---|---|
| Player auf dem Mini-PC (`signage01`) | Installiert. Zeitplan, Playlists, Sofortaufträge, Offline-Betrieb und Übernahme von Versionen funktionieren lokal. |
| VPN | `signage01` ist per OpenVPN verbunden (Tunnel-Adresse `192.168.55.30`), Netz `192.168.1.0/24` ist über den Tunnel erreichbar. |
| Server-Synchronisation im Player | **Fehlt noch.** Wird gegen die Geräte-API aus Abschnitt 4 gebaut. |
| Symfony-Modul | **Fehlt.** Inhalt dieses Dokuments. |

Grundsatz: Die Webseite schickt dem Gerät keine Befehle. Sie veröffentlicht einen Sollzustand,
das Gerät holt ihn sich ab, übernimmt ihn vollständig oder gar nicht und meldet zurück, was
wirklich läuft. Fällt Webseite oder VPN aus, spielt das Gerät die zuletzt übernommene Version
mit allen künftigen Terminen weiter.

## 2. Aufbau

```
Browser (Verwaltung)  ──HTTPS──>  Symfony: Modul "Signage"
                                     ├─ Verwaltungsoberfläche (Twig) und Verwaltungs-API
                                     ├─ Geräte-API  /signage/api/v1/player/...
                                     ├─ PostgreSQL: eigene Tabellen mit Präfix signage_
                                     └─ Medienablage im Dateisystem (nicht in der Datenbank)
                                              ▲
                                              │ HTTPS über VPN, Abruf alle 15 s
                                     Mini-PC signage01 (Player-Agent)
```

- Alles unter `/signage`, eigener Bereich ohne Abhängigkeit zu Fleet-, MRO- oder Dispo-Daten.
- Geräte-API nur aus dem VPN-Netz (`192.168.55.0/24`) erreichbar, zusätzlich zur Authentifizierung.
- HTTPS auch im VPN. Das Gerät muss dem Zertifikat der Webseite vertrauen (interne CA oder öffentliches Zertifikat).

## 3. Manifest einer Veröffentlichung

Eine Veröffentlichung ist ein **unveränderlicher** Snapshot pro Gerät. Die Webseite erzeugt sie
beim Klick auf „Veröffentlichen“ aus dem Entwurf. Das JSON muss exakt diesem Format entsprechen;
unbekannte Felder lehnt der Player ab (`extra="forbid"`).

```json
{
  "version": 2,
  "created_at": "2026-10-27T09:15:00Z",
  "device": {
    "timezone": "Europe/Berlin",
    "mode": "mirror",
    "outputs": [
      {
        "target": "shared",
        "connector": null,
        "edid_hash": null,
        "audio_device": null,
        "default_playlist_id": "standard"
      }
    ]
  },
  "media": [
    {
      "sha256": "956aa581ce5a267a549dc85daf77db33636ad8046fa9c3b074c5c31ad3ed6326",
      "size": 5939233,
      "name": "01-standard.mp4",
      "duration_s": 30.0
    }
  ],
  "playlists": [
    {"id": "standard", "name": "Standard", "items": ["956aa581ce5a267a549dc85daf77db33636ad8046fa9c3b074c5c31ad3ed6326"], "volume": 100}
  ],
  "events": [
    {
      "id": "ev-1",
      "title": "Werktags tagsüber",
      "target": "shared",
      "playlist_id": "standard",
      "start_date": "2026-10-27",
      "until_date": null,
      "weekdays": [1, 2, 3, 4, 5],
      "start_time": "08:00:00",
      "end_time": "18:00:00",
      "end_day_offset": 0,
      "priority": 10,
      "exceptions": ["2026-12-24"]
    }
  ]
}
```

Regeln, die der Player prüft und die die Webseite deshalb schon beim Veröffentlichen prüfen muss:

| Feld | Regel |
|---|---|
| `version` | Ganzzahl ≥ 1, pro Gerät fortlaufend. Eine einmal vergebene Nummer darf nie mit anderem Inhalt erneut ausgeliefert werden, sonst lehnt der Player sie ab. |
| `created_at` | ISO 8601 **mit** Zeitzone, am besten UTC (`Z`). Geht die Uhr des Geräts vor diesem Zeitpunkt, spielt es nur die Standard-Playlist. |
| `device.mode` | `mirror`: genau eine Ausgabe `shared`. `split`: genau `screen-1` und `screen-2`. |
| `device.timezone` | IANA-Name, z. B. `Europe/Berlin`. |
| `device.playback` | Optional: `browser` = der Player startet kein mpv, die Samsung-Displays öffnen `/wand/shared` bzw. `/wand/screen-1` und `/wand/screen-2`. Fehlt das Feld, gilt HDMI. Die Webseite lässt es bei HDMI weg, damit ältere Player die Version annehmen. Ein Player ohne dieses Feld lehnt `browser` ab. |
| `outputs[].connector` | Anschlussname laut `xrandr`, z. B. `HDMI-1`; `null` = automatisch. |
| `outputs[].audio_device` | mpv-Audiogerät; `null` = stumm. |
| `outputs[].rotation` | Optional: `normal` (Querformat, Standard wenn das Feld fehlt), `left` (Hochformat, 90° gegen den Uhrzeigersinn), `right` (Hochformat, 90° im Uhrzeigersinn), `inverted` (180°). Der Player setzt sie per `xrandr`, sobald die Version aktiv wird. Im Spiegelmodus für beide Bildschirme; im getrennten Modus braucht jede gedrehte Ausgabe einen eigenen `connector`. Die Webseite lässt das Feld bei `normal` weg. |
| `media[].sha256` | 64 Zeichen, Kleinbuchstaben hex. Jede Datei nur einmal. |
| `media[].size` | Dateigröße in Byte, > 0. `name` (≤ 200 Zeichen) und `duration_s` sind optional. |
| `media` | Nur Medien, die in mindestens einer Playlist vorkommen. |
| `playlists` | Mindestens eine. `id`: 1–100 Zeichen aus `A-Z a-z 0-9 _ . : -`. `items` nicht leer, nur bekannte `sha256`. `volume` 0–100. |
| `events[].target` | Muss zum Modus passen (`shared` bzw. `screen-1`/`screen-2`). |
| `events[].weekdays` | Montag = 1 … Sonntag = 7, mindestens ein Tag. |
| `events[].start_time` / `end_time` | Lokale Gerätezeit **ohne** Zeitzone. Ende nach Beginn; über Mitternacht `end_day_offset: 1`, höchstens 24 Stunden. |
| `events[].until_date` | `null` = unbegrenzt, sonst nicht vor `start_date`. |
| `events[].priority` | 0–1000. Überlappende Termine gleicher Priorität auf derselben Ausgabe sind ein Konflikt und verhindern die Veröffentlichung. |
| Kennungen | `id` von Medien, Playlists und Terminen innerhalb eines Manifests eindeutig. |

Wiedergaberegeln (zur Anzeige in der Vorschau, der Player rechnet selbst):

1. Aktiver Sofortauftrag vor Kalender.
2. Unter passenden Terminen gewinnt die höchste Priorität.
3. Ohne Termin läuft die Standard-Playlist der Ausgabe.
4. Zeiträume gelten als `[Beginn, Ende)`. An jeder Termingrenze startet die neue Playlist von vorn.
5. Sommerzeit: eine fehlende Uhrzeit wird um die Lücke verschoben; die doppelte Stunde gilt beim ersten Vorkommen.

Zum Prüfen eines erzeugten Manifests auf dem Gerät oder einem Entwicklungsrechner:

```bash
signage-player preview manifest.json --date 2026-10-27 --days 7   # Exit-Code 1 bei Konflikten
```

## 4. Geräte-API (Webseite → wird vom Player abgerufen)

Basis: `https://<swimmonitor-host>/signage/api/v1`. JSON in UTF-8, Fehler als
`application/problem+json`. Das Gerät wird **nur** aus seinem Zugangstoken ermittelt, nie aus
einer mitgeschickten ID.

### `POST /player/enroll`

Einmaliger Einrichtungscode (in der Verwaltung pro Gerät erzeugt, 24 h gültig, einmal nutzbar)
gegen ein dauerhaftes Gerätetoken tauschen.

```json
// Anfrage
{"enrollment_code": "K7QF-2M9X", "hostname": "signage01", "player_version": "0.1.0"}
// Antwort 201
{"device_id": "dev-01", "token": "<zufällig, 256 Bit>"}
```

Danach alle Aufrufe mit `Authorization: Bearer <token>`. In der Datenbank nur einen Hash des
Tokens speichern. Token in der Verwaltung widerrufbar und neu ausstellbar.

### `GET /player/desired-state`

Abruf alle 15 Sekunden. Unterstützt `ETag`/`If-None-Match` → `304`, solange sich nichts ändert.

```json
{
  "release_version": 2,
  "commands": [
    {
      "id": "cmd-8f3a",
      "target": "shared",
      "playlist_id": "aktion",
      "issued_at": "2026-10-27T10:00:00Z",
      "expires_at": "2026-10-27T10:30:00Z"
    }
  ],
  "cancel_command_ids": []
}
```

- `release_version`: die Version, die aktiv sein soll; `null`, solange nichts veröffentlicht ist.
- `commands`: offene Sofortaufträge. Format genau wie `InstantPlayback` im Player; `playlist_id`
  muss in der Soll-Version vorkommen. Abgelaufene Aufträge nicht mehr ausliefern.
- `cancel_command_ids`: vorzeitig beendete Sofortaufträge. (Der Player braucht dafür noch eine
  kleine Erweiterung; Aufträge enden bisher nur über `expires_at`.)

### `GET /player/releases/{version}`

Liefert das Manifest aus Abschnitt 3, byteweise unverändert für dieselbe Version. Nur Versionen
des eigenen Geräts, sonst `404`.

### `GET /player/media/{sha256}`

Liefert die Videodatei, nur wenn sie in einer Version dieses Geräts vorkommt, sonst `404`.
Pflicht: `Content-Length`, `ETag` (= sha256), `Range`-Anfragen mit `206` für fortgesetzte
Downloads. In Symfony über `BinaryFileResponse` (unterstützt Range) oder X-Sendfile/X-Accel.

### `POST /player/heartbeat`

Alle 30 Sekunden. Ohne Heartbeat seit 120 Sekunden zeigt die Webseite „nicht erreichbar“ (das
heißt nicht, dass die Wiedergabe steht).

```json
{
  "sent_at": "2026-10-27T10:00:30Z",
  "player_version": "0.1.0",
  "active_version": 2,
  "pending_version": 3,
  "download": {"version": 3, "missing": 1, "bytes_done": 1048576, "bytes_total": 5929101},
  "outputs": [
    {
      "target": "shared",
      "alive": true,
      "playlist_id": "standard",
      "source": "default",
      "reference": null,
      "errors": []
    }
  ],
  "disk_free_bytes": 52345678901,
  "clock_offset_s": 0.2,
  "errors": []
}
```

`source` ist `default`, `event` oder `command`; `reference` die Termin- bzw. Auftrags-ID.

### `POST /player/command-results`

```json
{"command_id": "cmd-8f3a", "state": "running", "detail": null, "at": "2026-10-27T10:00:02Z"}
```

`state` entspricht dem Player: `accepted`, `running`, `finished`, `expired`, `rejected`. Die
Verwaltung zeigt einen Sofortauftrag erst nach `running` als laufend an.

### Ablauf auf dem Gerät (zur Orientierung)

1. `desired-state` abrufen. Ist `release_version` neu: Manifest laden, fehlende Medien per Range
   herunterladen, sha256 und Größe prüfen.
2. Erst wenn alles vollständig ist, wird die Version atomar aktiv. Bei Fehlern bleibt die alte
   Version aktiv, der Fehler steht im nächsten Heartbeat.
3. Neue Sofortaufträge übernehmen und bestätigen.

## 5. Verwaltung in der Webseite

Rollen: **Lesen**, **Inhalte** (bearbeiten und veröffentlichen), **Administration** (Geräte,
Einrichtungscodes, Tokens). Jede Veröffentlichung, jeder Sofortauftrag und jede Geräteänderung
kommt ins Audit-Protokoll.

| Bereich | Funktion |
|---|---|
| Geräte | Liste mit letzter Rückmeldung, aktiver/ausstehender Version, Downloadfortschritt, aktuellem Inhalt je Ausgabe, Fehlern, freiem Speicher. Einrichtungscode erzeugen, Token widerrufen. |
| Medien | Upload (Größenlimit, temporärer Bereich), danach Prüfung mit `ffprobe`: abspielbar ist MP4/MOV, H.264, AAC. Andere Videocontainer und Codecs (MOV/HEVC, MKV, AVI, WebM, …) wandelt ffmpeg in dieses MP4 um. Erst danach auswählbar. sha256 und Größe beim Upload berechnen und speichern. |
| Playlists | Videos anordnen, Lautstärke 0–100. Medien, die in einer Playlist oder Version stecken, nicht löschbar. |
| Kalender | Termine je Gerät mit den Feldern aus Abschnitt 3. Vorschau „Was läuft wann auf welchem Bildschirm?“ und Konfliktanzeige vor dem Veröffentlichen. |
| Gerätekonfiguration | Modus `mirror`/`split`, Standard-Playlist je Ausgabe, Anschluss und Audiogerät. |
| Veröffentlichen | Entwurf prüfen → Manifest erzeugen → neue Version speichern. Anzeige getrennt: veröffentlicht, wird übertragen, bereit, aktiv (erst laut Heartbeat). |
| Sofortwiedergabe | Playlist mit Dauer starten oder beenden; Zustand aus `command-results`. Ohne Geräteverbindung keine Erfolgsmeldung. |

## 6. Umsetzung in Symfony

Vorschlag für die Struktur im bestehenden Projekt:

```
src/Signage/
  Entity/        Device, DeviceToken, EnrollmentCode, Media, Playlist, PlaylistItem,
                 ScheduleEvent, Release, DeviceCommand, Heartbeat, AuditEntry
  Controller/
    Admin/       Twig-Seiten der Verwaltung
    PlayerApi/   EnrollController, DesiredStateController, ReleaseController,
                 MediaController, HeartbeatController, CommandResultController
  Security/      PlayerTokenAuthenticator (eigene Firewall für ^/signage/api/v1/player)
  Service/       ManifestBuilder, ScheduleConflictChecker, MediaProbe (ffprobe), MediaStorage
  Message/       ProbeMedia (Symfony Messenger, Prüfung im Hintergrund)
templates/signage/
migrations/      Tabellen mit Präfix signage_
```

Hinweise:

- **Release** speichert das fertige Manifest-JSON unverändert (`jsonb` plus sha256 des Textes).
  Spätere Änderungen an Playlists oder Terminen ändern nie eine bestehende Version.
- **ManifestBuilder** erzeugt das JSON aus Abschnitt 3: Zeiten als `HH:MM:SS` ohne Zone,
  `created_at` in UTC, nur referenzierte Medien, Felder mit `null` statt weglassen ist erlaubt.
- **Konfliktprüfung**: gleiche Ausgabe, gleiche Priorität, überlappende Zeiträume in
  `Europe/Berlin` über einen Horizont von z. B. 400 Tagen. Gleiche Logik wie in
  `player/src/signage_player/schedule.py` (`find_conflicts`); bei Abweichungen gilt der Player.
- **Firewall** für die Geräte-API: zustandslos, kein Session-Cookie, kein CSRF; nur Bearer-Token.
  Die Verwaltung nutzt die normale Anmeldung der Webseite mit CSRF-Schutz.
- **Medienablage** außerhalb von `public/`, Dateiname = sha256, Auslieferung nur über den
  Controller.
- Uploads größer als die PHP-Grenzen: Chunk-Upload laut Plan (`POST /media/uploads`,
  `PUT …/chunks/{index}`, `POST …/complete`) oder vorerst passende `upload_max_filesize`.

## 7. Reihenfolge

1. Datenbank, Medien-Upload mit ffprobe, Playlists, Gerätekonfiguration, Veröffentlichen mit
   ManifestBuilder. Prüfung: erzeugtes Manifest lokal mit `signage-player preview` und
   `release-install` übernehmen.
2. Geräte-API: `enroll`, `desired-state`, `releases`, `media`, `heartbeat`.
3. Gleichzeitig im Player das Synchronisations-Modul gegen diese API bauen.
4. Kalender mit Vorschau und Konfliktanzeige.
5. Sofortwiedergabe mit `command-results`, Geräteübersicht, Rollen und Audit.

## 8. Abnahme

- [ ] Video hochladen, Playlist anlegen, veröffentlichen: läuft nach höchstens 15 s plus Downloadzeit auf `signage01`.
- [ ] Geräteübersicht zeigt die aktive Version erst, wenn der Heartbeat sie meldet.
- [ ] Datei auf dem Server beschädigt: Gerät behält die alte Version, Fehler erscheint in der Übersicht.
- [ ] Download unterbrochen (VPN trennen): wird nach Wiederverbindung fortgesetzt, nicht neu begonnen.
- [ ] Webseite aus, Gerät neu gestartet: Wiedergabe und künftige Termine laufen weiter.
- [ ] Sofortauftrag: Start und vorzeitiges Ende werden vom Gerät bestätigt; abgelaufene Aufträge starten nach Wiederverbindung nicht nachträglich.
- [ ] Token widerrufen: Gerät erhält `401`, spielt aber die letzte Version weiter.
- [ ] Zwei Termine gleicher Priorität überlappen: Veröffentlichung wird mit verständlicher Meldung abgelehnt.

## 9. Umsetzung im test_projekt (Stand 2026-10-26)

### Wo was liegt

| Teil | Ort |
|---|---|
| Entities, Tabellen `signage_*` | `src/Entity/Signage/`, `src/Repository/Signage/`, Migration `migrations/Version20261026170000.php` |
| Zeitplan (Port von `schedule.py`) | `src/Service/Signage/SignageSchedule.php`, Test `tests/Service/Signage/SignageScheduleTest.php` |
| Manifest, Veröffentlichen | `SignageManifestBuilder`, `SignageReleasePublisher` |
| Medien | `SignageMediaStorage` (`var/signage/media/<sha256>`, Uploads in `var/signage/uploads/`), `SignageMediaProbe` (ffprobe) |
| Geräte-API | `src/Controller/Signage/PlayerApiController.php`, `src/Security/Signage/`, `src/EventListener/Signage/SignagePlayerApiSubscriber.php` |
| Verwaltung | `src/Controller/Signage/Signage*Controller.php`, `templates/signage/` |

### Abweichungen vom Vorschlag

- **MariaDB statt PostgreSQL:** Das Manifest liegt als `LONGTEXT` (byteweise) plus sha256, nicht als `jsonb`.
- **ffprobe synchron** beim Abschluss des Uploads statt über Messenger (kein verlässlicher Worker auf dem Server).
- **Heartbeat:** Gespeichert wird nur die letzte Rückmeldung am Gerät, kein Verlauf.
- **Alle nicht leeren Playlists** stehen in jedem Manifest, damit Sofortaufträge jede Playlist starten können.
  Leere Playlists werden nicht veröffentlicht.
- **Einlösen eines Einrichtungscodes** widerruft alle bisherigen Tokens des Geräts.
- **Unveränderter Entwurf** erzeugt keine neue Version.

### Verhalten der Geräte-API (für die Player-Synchronisation)

- Basis: `https://dp-smartsolutions.de/ksbdueren/public/signage/api/v1`.
- Anfragen außerhalb von `SIGNAGE_PLAYER_ALLOWED_IPS` (Standard `192.168.55.0/24`) erhalten `403`.
  Maßgeblich ist `REMOTE_ADDR`, es gibt keine trusted proxies.
- `enroll`: `201 {"device_id": "<Gerätekennung>", "token": "…"}`. Ungültiger, abgelaufener oder
  benutzter Code ergibt `403`.
- Ohne oder mit ungültigem Token: `401` mit `WWW-Authenticate: Bearer …`.
- `desired-state`: `commands` enthält nur offene, nicht abgelaufene Aufträge, deren Playlist und
  Ausgabe in der aktuellen Version vorkommen. Eine ID steht so lange in `cancel_command_ids`, bis
  der Auftrag abläuft oder das Gerät `finished`, `expired` oder `rejected` meldet.
- `releases/{version}`: `ETag` = sha256 des Manifest-Texts, `Cache-Control: immutable`.
- `media/{sha256}`: `ETag` = sha256, Range/`206` über `BinaryFileResponse`.
- `heartbeat`: höchstens 64 KB, `active_version`/`pending_version` Ganzzahl oder `null`, Antwort `204`.
- `command-results`: `204`; ein unbekannter Auftrag oder einer eines fremden Geräts ergibt `404`,
  ein unbekannter `state` ergibt `400`.

### Inbetriebnahme auf dem Server

1. `apt install ffmpeg` (liefert `ffprobe`). Liegt es nicht im `PATH` von www-data,
   `SIGNAGE_FFPROBE_BIN` in `shared/.env.local` setzen.
2. Deploy (Magallanes). Der on-deploy-Schritt verlinkt `var/signage` auf `shared/var/signage`.
   Danach einmalig `chown -R www-data: /var/www/html/ksbdueren/shared/var/signage` (oder Gruppe
   mit Schreibrecht).
3. Migration: erst `php bin/console doctrine:migrations:migrate --dry-run`, dann ohne `--dry-run`.
   Danach `cache:clear`.
4. Rollen vergeben: Benutzerverwaltung → „Signage: Lesen / Inhalte / Administration“.
   Superadmins haben alle Rechte.
5. Prüfen, mit welcher Absenderadresse `signage01` ankommt. Im Apache-Access-Log steht sie bei
   den Anfragen auf `/signage/api/`. Eine `403` dort deutet auf eine abweichende Adresse hin (NAT,
   Proxy); dann `SIGNAGE_PLAYER_ALLOWED_IPS` passend setzen. Nach dem ersten erfolgreichen
   Heartbeat zeigt die Geräteseite die Adresse unter „Letzte Rückmeldung … von“.
6. Auf `signage01`: `dp-smartsolutions.de` über die VPN-Adresse des Webservers auflösen
   (Hosts-Eintrag). Das öffentliche Zertifikat passt dann weiter zum Namen.
7. Verwaltung `/signage`: Gerät mit Kennung `signage01` anlegen, Videos hochladen, Playlist anlegen,
   Standard-Playlist setzen, veröffentlichen, Einrichtungscode erzeugen und am Gerät einlösen.
8. Ersten großen Upload testen: Der Abschluss rechnet sha256 und ffprobe synchron, also
   Proxy- und PHP-FPM-Timeouts beachten. Die Teilstücke haben 1 MB.

### Planungskalender (Drag & Drop)

Auf der Geräteseite im Tab „Zeitplan“, Karte „Planen“ (FullCalendar, Controller
`SignageCalendarController`, Skript `public/js/admin/signage-calendar.js`).

- Playlist oder einzelnes Video aus der Seitenleiste in den Kalender ziehen. Es entsteht ein
  Einzeltermin von 1 h mit Priorität 10. Für ein Video wird die Playlist `video-<id>` („Video: …“)
  angelegt bzw. wiederverwendet; das Video läuft im Termin in Schleife. Leere Playlists lassen sich
  nicht ziehen.
- Termin verschieben bzw. die Unterkante ziehen ändert Beginn und Ende (Raster 5 min,
  mindestens 1 min, höchstens 24 h, Ende spätestens am Folgetag).
- Bei einer Serie fragt die Seite: **Ganze Serie** (Zeiten neu, bei anderem Tag verschieben sich
  Wochentage, Beginn/Ende der Serie und Ausnahmen mit) oder **Nur dieser Tag** (Ausnahme in der
  Serie plus Einzeltermin).
- Klick auf einen Termin: Löschen, bei Serien „diesen Tag auslassen“, oder zum Formular.
- Jede Änderung läuft in einer Transaktion und wird abgelehnt (`409`, Meldung), wenn sie einen
  neuen Konflikt im Entwurf erzeugt. Aufs Gerät kommt sie erst mit „Veröffentlichen“.
- Zeiten sind Wanduhrzeit in der Zeitzone des Geräts.

### Text-Videos

Bereich „Text-Videos“ in der Signage-Navigation (`SignageTextController`, `SignageTextRenderer`).

- **Vorlage:** Hintergrundvideo aus der Mediathek, Position (oben/Mitte/unten), Größe (S/M/L),
  Textfarbe, optional halbtransparenter Balken, Vorgabetexte. Die Vorschau zeigt ein Standbild mit
  dem Text (ffmpeg, gleicher Filter wie beim Rendern).
- **Video erstellen:** Zeile 1 (max. 80 Zeichen) und Zeile 2 (max. 120) eingeben. Der Server rendert
  ein neues MP4 (H.264, Ton wird übernommen) und legt es als normales Video in der Mediathek ab. Der
  Player bleibt unverändert. Gleicher Inhalt ergibt dasselbe Video (sha256).
- Gerendert wird direkt nach der Antwort (`kernel.terminate`); die Seite fragt den Status ab.
  Rückfall: Command `signage:text-video:render` (siehe `docs/CRONJOBS.md`).
- Ein Video, das als Hintergrund einer Vorlage dient, kann nicht gelöscht werden.
- Tabellen `signage_text_template`, `signage_text_render` (Migration `Version20261026220000`).

- **Format:** „Wie Hintergrund“, „Querformat 1920×1080“ oder „Hochformat 1080×1920“. Weicht der
  Hintergrund ab, wird er zugeschnitten oder mit unscharfem Rand eingepasst. Die Schriftgröße richtet
  sich nach der kürzeren Bildseite.

### Hoch- und Querformat

- Pro Ausgabe ist unter „Einstellungen“ die **Drehung** hinterlegt (`normal`, `left`, `right`,
  `inverted`; Standard `normal`). Sie geht als `outputs[].rotation` ins Manifest (Abschnitt 3), der
  Player dreht per `xrandr`, sobald die Version aktiv wird. Bei `normal` fehlt das Feld. Ein Player
  ohne Unterstützung für `rotation` lehnt eine gedrehte Version deshalb ab, eine ungedrehte nimmt
  er weiter an.
- Im getrennten Modus prüft das Veröffentlichen, dass jede Ausgabe einen eigenen Anschluss hat,
  sobald gedreht wird.
- Welche Richtung zur Montage passt, zeigt erst das Bild. Steht es auf dem Kopf, die andere
  Richtung veröffentlichen.
- `left`/`right` gelten in der Verwaltung als Hochformat (Hinweise, Kalender).
- Beim Hochladen wird die Drehung aus den Metadaten ausgewertet (Display-Matrix bzw. Tag `rotate`).
  Hochkant gefilmte Handyvideos gelten damit als 1080×1920 „hoch“. mpv und ffmpeg drehen solche
  Videos beim Abspielen bzw. Rendern selbst.
- Mediathek, Playlists und Kalender zeigen „hoch“/„quer“. Der Kalender markiert Playlists und
  Termine, deren Videos nicht zur Ausrichtung der gewählten Ausgabe passen. Beim Hineinziehen kommt
  eine Warnung, gespeichert wird trotzdem, weil mpv das Bild mit Rändern zeigt.

Zusätzlich auf dem Server:

1. `apt install ffmpeg fonts-dejavu-core`. Abweichende Pfade über `SIGNAGE_FFMPEG_BIN` bzw.
   `SIGNAGE_TEXT_FONT` in `shared/.env.local`.
2. Migration `Version20261026220000`: erst `doctrine:migrations:migrate --dry-run`, dann anwenden,
   danach `cache:clear`.
3. Optional den Cron `signage:text-video:render` einrichten.
4. Ein Text-Video erstellen und prüfen, ob der Auftrag auf „fertig“ springt. Bleibt er auf
   „wartet“, beendet PHP-FPM den Prozess nach der Antwort nicht sauber; dann den Cron einrichten.
