Video-System Self-Hosting

Systemübersicht und Architektur

\n

Dieses Dokument richtet sich an Entwickler und Administratoren mit Web- und Server-Grundlagen. Es beschreibt Architektur, Implementierung und Betrieb des Video-Self-Hosting-Systems.

\n

Das Video-Self-Hosting-System reduziert die Abhängigkeit von Drittanbietern wie Vimeo oder YouTube. Speicherung, Routing und Auslieferung erfolgen auf eigener Infrastruktur. Videos werden außerhalb des DocumentRoot gespeichert und über einen dedizierten Controller kontrolliert ausgeliefert.

\n

Komponenten

\n

Das System besteht aus sechs PHP-Services, einem Controller, einem JavaScript-Modul und einer CSS-Datei:

\n

PHP-Services (unter src/Services/Video/):

\n\n

Controller (VideoController):

\n\n

Frontend (public/assets/):

\n\n

Datenfluss

\n

Der Weg vom Placeholder zum gestreamten Video verläuft in fünf Schritten:

\n
    \n
  1. Template enthält {{video:slug|params}} Placeholder
  2. \n
  3. PlaceholderParser oder ArtefaktRenderer erkennt das Pattern
  4. \n
  5. VideoComponentService lädt Video-Metadaten aus der Datenbank
  6. \n
  7. HTML wird generiert: Video-Element, Data-Attribute, CSS, JS, HLS-Init-Script
  8. \n
  9. Client initialisiert je nach Browser-Unterstützung HLS.js oder native HLS-Wiedergabe und streamt Segmente über den VideoController
  10. \n
\n

Fallback-Kette

\n

Die Wiedergabe folgt einer dreistufigen Fallback-Kette:

\n
    \n
  1. HLS.js -- JavaScript-basierter HLS-Player (alle modernen Browser)
  2. \n
  3. Native HLS -- Safari und iOS unterstützen HLS nativ über das <video>-Element
  4. \n
  5. MP4 Direct -- Direkter Download der Quelldatei als letzter Fallback
  6. \n
\n

Sicherheitskonzept

\n

Videos liegen im Verzeichnis /var/www/videos/ -- außerhalb des DocumentRoot /var/www/dev.karlkratz.de/public/. Der vorgesehene Zugriffspfad führt über den VideoController, der Slugs validiert, Dateipfade auflöst und mittels realpath() Path-Traversal-Angriffe verhindert. Die tatsächliche Auslieferung erfolgt über Apache X-Sendfile, das die Dateiübertragung an den Webserver delegiert.

Verzeichnisstruktur und Dateispeicherung

Alle Videodateien werden außerhalb des DocumentRoot unter /var/www/videos/ gespeichert. Pro Video existiert ein Unterverzeichnis mit einer definierten Ordnerstruktur.

Namenskonvention

Verzeichnisse folgen dem Schema {datum}_{beschreibung}/:

Verzeichnisstruktur
/var/www/videos/
  2026-02-04_fallbesprechung-ki-protokollierung-artefakte/

Vollständige Verzeichnisstruktur

Verzeichnisstruktur
/var/www/videos/{verzeichnis}/
  video{id}.mp4                     Quelldatei (Original-Upload)
  poster.jpg                        Vorschaubild (Standard-Poster)

  hls/                              HLS-Streaming-Verzeichnis
    master.m3u8                     ABR Master-Playlist (referenziert alle Renditions)
    playlist.m3u8                   Single-Rendition Playlist (Abwärtskompatibilität)
    segment_0000.ts                 Single-Rendition Segmente
    segment_0001.ts
    ...
    1080p/                          Rendition: Full HD
      playlist.m3u8
      segment_0000.ts ... segment_NNNN.ts
    720p/                           Rendition: HD
      playlist.m3u8
      segment_0000.ts ... segment_NNNN.ts
    480p/                           Rendition: SD
      playlist.m3u8
      segment_0000.ts ... segment_NNNN.ts
    360p/                           Rendition: Mobil
      playlist.m3u8
      segment_0000.ts ... segment_NNNN.ts

  thumbnails/                       Thumbnail-Sprite-Verzeichnis
    sprite.webp                      Zusammengesetztes Thumbnail-Grid
    sprite.json                     Metadata (Größe, Spalten, Intervall)

  subtitles/                        Untertitel-Verzeichnis
    de.vtt                          Deutsche Untertitel (WebVTT)
    en.vtt                          Englische Untertitel

Speichervolumen

Ein typisches Video (85 Minuten, 2144x1080) erzeugt folgendes Datenvolumen:

Die tatsächlichen Dateigrößen hängen von Inhaltskomplexität und Encoding-Parametern ab. Die Bitrate-Vorgaben (-b:v, -maxrate) sind Obergrenzen -- bei wenig Bewegung im Bild können die Segmente deutlich kleiner ausfallen.

Pfad-Konventionen

Element Pfad relativ zum Video-Verzeichnis
Quelldatei *.mp4 (beliebiger Dateiname)
Poster poster.jpg
HLS-Master hls/master.m3u8
HLS-Single hls/playlist.m3u8
Rendition hls/{qualität}/playlist.m3u8
Sprite thumbnails/sprite.webp
Sprite-Meta thumbnails/sprite.json
Untertitel subtitles/{lang}.vtt

Diese einheitliche Namenskonvention ermöglicht es dem VideoController, alle zugehörigen Dateien eines Videos anhand des Slug-Verzeichnisses automatisch aufzulösen -- ohne zusätzliche Konfiguration pro Video.

Datenbank: Artefakte und Abhängigkeiten

Videos werden in der artefakte-Tabelle der Datenbank karlkratz_de verwaltet. Jedes Video ist ein Artefakt vom Typ video, dessen content-Feld den absoluten Pfad zur Videodatei enthält.

Video-Artefakt

SQL
INSERT INTO artefakte (slug, type, content, meta_title, meta_description, status)
VALUES (
    'fallbesprechung-ki-protokoll-video',
    'video',
    '/var/www/videos/2026-02-04_fallbesprechung-ki-protokollierung-artefakte/video1350955604.mp4',
    'Fallbesprechung: KI-Protokollierung',
    'Video der Fallbesprechung zum Thema KI-Protokollierung und Artefakte',
    'dev'
);

Relevante Spalten:

Spalte Inhalt
slug Eindeutiger Bezeichner (z.B. fallbesprechung-ki-protokoll-video)
type Immer video
content Absoluter Dateipfad zur MP4-Datei
meta_title Titel des Videos
meta_description Beschreibung
status dev oder prod
deleted_at Soft-Delete (NULL = aktiv)

Poster-Konvention

Poster werden als separates Artefakt mit dem Slug {video-slug}-poster gespeichert:

SQL
INSERT INTO artefakte (slug, type, content)
VALUES (
    'fallbesprechung-ki-protokoll-video-poster',
    'video',
    '/var/www/videos/2026-02-04_.../poster.jpg'
);

Der VideoComponentService prüft automatisch, ob ein Artefakt mit dem Suffix -poster existiert.

Kapitel-Artefakt

Kapitelmarken werden als eigenes Artefakt gespeichert. Das content-Feld enthält JSON:

JSON
{
    "slug": "fallbesprechung-kapitel",
    "type": "content",
    "content": "[{\"time\": 0, \"title\": \"Einleitung\"}, {\"time\": 120, \"title\": \"Thema 1\"}, {\"time\": 600, \"title\": \"Thema 2\"}]"
}

Transkript-Artefakt

Transkripte können als JSON-Artefakt oder als WebVTT-Datei vorliegen:

JSON
[
    {"start": 0.0, "end": 5.2, "text": "Willkommen zum Video."},
    {"start": 5.2, "end": 10.1, "text": "Heute besprechen wir..."}
]

Abhängigkeiten (artefakt_dependencies)

Wenn ein Seiten-Artefakt ein Video einbettet, wird die Abhängigkeit in artefakt_dependencies gespeichert:

Datensatz
page_slug: 'fallbesprechung-ki-protokoll-artefakt'
depends_on_slug: 'fallbesprechung-ki-protokoll-video'
dependency_type: 'video'

Dies ermöglicht die automatische Cache-Invalidierung: Wenn das Video-Artefakt aktualisiert wird, wird der Cache der einbettenden Seite ungültig.

Abfrage-Muster

Der VideoComponentService verwendet folgendes Query-Muster:

SQL
SELECT slug, content, meta_title, meta_description
FROM artefakte
WHERE slug = ? AND type = 'video' AND deleted_at IS NULL
LIMIT 1

Nur aktive (nicht gelöschte) Artefakte werden ausgeliefert. Die Spalte deleted_at dient als Soft-Delete-Mechanismus.

Das Placeholder-System

Videos werden über das Placeholder-System in Seiten eingebettet. Die Syntax folgt dem bestehenden Artefakt-Placeholder-Muster.

Syntax

Placeholder-Syntax
{{video:slug}}
{{video:slug|param1=wert1|param2=wert2}}

Beispiele:

Beispiele
{{video:fallbesprechung-ki-protokoll-video}}
{{video:fallbesprechung-ki-protokoll-video|lazy=true|autoplay=false}}
{{video:fallbesprechung-ki-protokoll-video|chapters=kapitel-slug|captions=de,en}}
{{video:fallbesprechung-ki-protokoll-video|transcript=transkript-slug|thumbnails=true}}
{{video:fallbesprechung-ki-protokoll-video|poster=/assets/img/custom-poster.jpg}}
{{video:123456789}}

Verfügbare Parameter

Parameter Typ Standard Beschreibung
width String 100% Breite des Containers
lazy Boolean true Lazy Loading mit Poster-Placeholder
autoplay Boolean false Automatische Wiedergabe
muted Boolean false Stummschaltung
loop Boolean false Endloswiedergabe
controls Boolean true Browser-native Steuerelemente
playsinline Boolean true Inline-Wiedergabe auf Mobilgeräten
preload String metadata Preload-Strategie (none, metadata, auto)
poster String -- URL oder Artefakt-Slug für Vorschaubild
chapters String -- Artefakt-Slug für Kapitelmarken (JSON)
captions String -- Sprachcodes für Untertitel (z.B. de,en)
transcript String -- Artefakt-Slug für Transkript-Daten
thumbnails Boolean false Thumbnail-Sprite-Preview aktivieren

Erkennung: Vimeo vs. Lokal

Numerische Slugs (z.B. 123456789) werden als Vimeo-Video-ID interpretiert und als iframe eingebettet. Text-Slugs verweisen auf lokale Videos. Voraussetzung: Lokale Video-Slugs dürfen nicht rein numerisch sein, da sie sonst als Vimeo-ID behandelt würden. Dies ist eine bewusste Design-Entscheidung zugunsten einfacher Syntax. Eine robustere Alternative wäre ein expliziter Präfix (z.B. vimeo:123456). Details zur Vimeo-Integration und den verfügbaren Parametern beschreibt das Kapitel Vimeo-Embedding.

Verarbeitungskette

Die Placeholder-Verarbeitung erfolgt an zwei Stellen:

  1. PlaceholderParser (src/Services/PlaceholderParser.php) -- für klassische Seiten
  2. ArtefaktRenderer (src/Services/ArtefaktRenderer.php) -- für Artefakt-basierte Seiten

Beide verwenden dasselbe Regex-Pattern:

Regex
/\{\{video:([a-zA-Z0-9_-]+)(?:\|([^}]+))?\}\}/i

Und delegieren an VideoComponentService::render().

Parsedown-Interaktion (ArtefaktRenderer)

Im ArtefaktRenderer durchläuft Artefakt-Content Parsedown für Markdown-Rendering. Standalone-{{video:...}}-Placeholder würden von Parsedown in <p>-Tags eingeschlossen, was zu invalidem HTML führt (<p><div class="video-container">...</div></p>).

Lösung: Zwei Schutzmaßnahmen in resolveArticle():

  1. Div-Wrapping: Vor Parsedown wird

    Video nicht gefunden

    in <div>

    Video nicht gefunden

    </div>
    gewrappt. Parsedown behandelt Inhalte innerhalb von HTML-Block-Elementen als HTML-Block und fügt kein <p> hinzu.

  2. Script-Protection: Verschachtelte Artikel (`) werden VOR Parsedown aufgelöst. Wenn ein Kind-Artikel ein Video enthält, produziertVideoComponentServiceInline-<code>&lt;script&gt;</code>-Tags. Diese würden vom Parsedown des Eltern-Artikels als <code>&lt;pre&gt;&lt;code&gt;</code> interpretiert. Lösung: <code>&lt;script&gt;</code>-Blöcke werden vor Parsedown als`-Token extrahiert und danach wiederhergestellt.

Die eigentliche Video-Auflösung ({{video:...}} → HTML) erfolgt NACH Parsedown in resolveNonArticlePlaceholders().

Fehlerbehandlung

Bei nicht gefundenen Videos oder Fehlern wird ein Fehler-Element ausgegeben:

HTML
<div class="video-error">
    <p>Video 'slug' nicht gefunden</p>
</div>

Die Fehlermeldung erscheint direkt an der Stelle, an der das Video eingebettet wäre. So bleibt das Seitenlayout intakt, und der Fehler ist sofort sichtbar.

Vimeo-Embedding (Fallback)

Für externe Videos oder als Übergangslösung unterstützt das Placeholder-System auch Vimeo-Embeds. Die Erkennung erfolgt automatisch anhand des Slug-Formats.

Erkennung

Ein rein numerischer Slug wird als Vimeo-Video-ID interpretiert:

Template
{{video:123456789}}              Vimeo-Embed
{{video:mein-lokales-video}}     Lokales Video

Die Unterscheidung erfolgt in VideoComponentService::render():

PHP
if (ctype_digit($slugOrId)) {
    return $this->renderVimeo($slugOrId, $params);
}
return $this->renderLocalVideo($slugOrId, $params);

Generiertes HTML

HTML
<div class="video-container video-vimeo">
    <div style="padding:56.25% 0 0 0;position:relative;">
        <iframe src="https://player.vimeo.com/video/123456789?badge=0&autopause=0"
                frameborder="0"
                allow="autoplay; fullscreen; picture-in-picture"
                style="position:absolute;top:0;left:0;width:100%;height:100%;"
                loading="lazy">
        </iframe>
    </div>
    <script src="https://player.vimeo.com/api/player.js"></script>
</div>

Responsive Container

Der Padding-Trick (padding: 56.25% 0 0 0) erzeugt ein 16:9-Seitenverhältnis. Das iframe wird absolut positioniert und füllt den Container vollständig aus. So bleibt das Video bei jeder Bildschirmgröße proportional.

Verfügbare Parameter

Parameter Vimeo-Wert Standard
autoplay autoplay=1/0 0
muted muted=1/0 0
loop loop=1/0 0
width CSS-Breite des Containers 100%

Einschränkungen gegenüber lokalen Videos

Bei Vimeo-Embeds sind folgende Features nicht verfügbar:

Vimeo-Videos erhalten die CSS-Klasse .video-vimeo statt .video-local. Das JavaScript-Modul initialisiert nur .video-local-Container.

VideoComponentService: Die Rendering-Engine

Der VideoComponentService ist das Herzstück des Video-Systems. Er koordiniert alle Services, generiert das vollständige HTML und stellt sicher, dass Assets nur einmal pro Seite geladen werden.

Pfad

Dateipfad
src/Services/Video/VideoComponentService.php

Statische Flags

Drei statische Flags verhindern die mehrfache Einbindung von Assets bei mehreren Videos auf einer Seite:

PHP
private static bool $hlsJsIncluded = false;
private static bool $cssIncluded = false;
private static bool $jsIncluded = false;

Das erste Video auf der Seite bindet CSS, JS und HLS.js ein. Alle weiteren Videos nutzen die bereits geladenen Assets.

Rendering-Ablauf (renderLocalVideo)

  1. Datenbank-Lookup: loadVideoArtefakt($slug) -- lädt Video-Pfad, Titel, Beschreibung
  2. Datei-Prüfung: Existiert die MP4-Datei unter dem gespeicherten Pfad?
  3. Options-Aufbau: buildOptions() -- mergt Parameter mit DEFAULT_OPTIONS
  4. HLS-Erkennung: getHlsInfo() -- prüft ob master.m3u8 (ABR) oder playlist.m3u8 existiert
  5. Poster-Auflösung: getPosterUrl() -- dreistufige Kaskade (Parameter → Artefakt → Datei)
  6. Feature-Ladung (je nach Parameter):
  7. HTML-Generierung: renderPlayer() -- baut das vollständige Player-HTML

Data-Attribute am Video-Element

Der Renderer schreibt Feature-Daten als Data-Attribute auf das <video>-Element. JSON-Werte werden dabei kontextgerecht für HTML-Attribute escaped (htmlspecialchars mit ENT_QUOTES), da Kapitel, Transkripte und Sprite-Metadaten aus externen Artefakten stammen können. Das JavaScript liest die Attribute beim Initialisieren:

Attribut Inhalt Beispiel
data-video-slug Video-Slug fallbesprechung-ki-protokoll-video
data-hls-src HLS-Playlist URL /hls/slug/master.m3u8
data-mp4-src MP4-Fallback URL /video/slug
data-chapters Kapitel als JSON [{"time":0,"title":"Einleitung"}]
data-sprite Sprite-Metadata JSON {"url":"/video/slug/thumbnails/sprite.webp","columns":10,...}
data-renditions Verfügbare Qualitäten ["1080p","480p"]

HLS-Initialisierungsskript

Für jedes Video mit HLS wird ein Inline-Script generiert, das:

  1. HLS.js lokal lädt (einmalig pro Seite)
  2. Die HLS-Instanz erstellt und an das Video-Element bindet
  3. Die Instanz als video._hlsInstance speichert (für den Quality-Selector)
  4. Bei Klick auf den Lazy-Placeholder das Video initialisiert
  5. Bei HLS-Fehlern automatisch auf MP4 zurückfällt

Poster-Auflösungskaskade

Das Poster wird über eine dreistufige Kaskade aufgelöst: expliziter Parameter, Artefakt-Konvention ({slug}-poster) und Datei-Konvention (poster.jpg). Die vollständige Logik mit Code-Beispielen beschreibt das Kapitel Poster und Vorschaubilder.

Generiertes HTML (vereinfacht)

HTML
<link rel="stylesheet" href="/assets/css/video.css">
<div class="video-container video-local" id="video-a1b2c3d4">
  <div class="video-lazy-wrapper" data-video-lazy="true">
    <img src="/video/slug-poster" class="video-poster-placeholder">
    <button class="video-play-button">▶</button>
  </div>
  <video id="player-video-a1b2c3d4" class="video-player"
         controls playsinline preload="metadata"
         poster="/video/slug-poster"
         data-video-slug="slug"
         data-hls-src="/hls/slug/master.m3u8"
         data-mp4-src="/video/slug"
         data-chapters='[...]'
         data-renditions='["1080p","720p"]'
         style="display:none;">
    <source src="/video/slug" type="video/mp4">
    <track kind="subtitles" src="/video/slug/subtitles/de.vtt" srclang="de" label="Deutsch" default>
  </video>
</div>
<ol class="video-chapters-list">...</ol>
<div class="video-transcript">...</div>
<script src="/assets/js/hls.min.js"></script>
<script>/* HLS-Init */</script>
<script src="/assets/js/video-player.js"></script>

Der VideoComponentService erzeugt das gesamte Player-Markup in einem einzigen Durchlauf. CSS, JavaScript und HLS.js werden dabei nur einmal pro Seite eingebunden, auch wenn mehrere Videos auf einer Seite erscheinen.

Poster und Vorschaubilder

Poster-Bilder werden als Vorschau vor dem Abspielen angezeigt. Sie dienen sowohl als Lazy-Loading-Placeholder als auch als visueller Hinweis auf den Videoinhalt.

Dreistufige Auflösungskaskade

Der VideoComponentService sucht das Poster in drei Stufen:

Stufe 1: Parameter

Placeholder
{{video:mein-video|poster=/assets/img/custom-poster.jpg}}
{{video:mein-video|poster=mein-video-poster-slug}}

URLs (beginnend mit / oder http) werden direkt verwendet. Alle anderen Werte werden als Video-Artefakt-Slug interpretiert und über /video/{slug} gestreamt.

Stufe 2: Artefakt-Konvention

Automatische Suche nach einem Artefakt mit dem Slug {video-slug}-poster:

SQL
SELECT slug FROM artefakte
WHERE slug = 'mein-video-poster' AND type = 'video' AND deleted_at IS NULL

Stufe 3: Datei-Konvention

Prüfung auf eine Datei poster.jpg im Video-Verzeichnis:

Dateipfad
/var/www/videos/{verzeichnis}/poster.jpg

Verwendung im HTML

Das Poster wird an zwei Stellen eingesetzt:

  1. HTML5 poster-Attribut: <video poster="/video/slug-poster">
  2. Lazy-Wrapper: <img src="/video/slug-poster" class="video-poster-placeholder">

Der Lazy-Wrapper zeigt das Poster großflächig mit einem überlagerten Play-Button. Das poster-Attribut des Video-Elements dient als Fallback, falls das Video ohne Lazy-Loading eingebunden wird.

Poster-Streaming

Poster-Bilder werden über denselben VideoController-Endpunkt wie Videos ausgeliefert:

HTTP
// URL: /video/{slug}-poster
// Der Controller erkennt die Dateiendung (.jpg) und setzt:
Content-Type: image/jpeg
Cache-Control: public, max-age=86400

Das Poster-Artefakt zeigt in seinem content-Feld auf den absoluten Pfad der Bilddatei, z.B. /var/www/videos/{verzeichnis}/poster.jpg.

Empfohlenes Format

Ein gut gewähltes Poster-Bild gibt dem Zuschauer vorab einen visuellen Eindruck des Videoinhalts und motiviert zum Abspielen.

HTTP-Routing: .htaccess und index.php

Vier URL-Patterns leiten Video-Anfragen an den VideoController weiter. Die Verarbeitung erfolgt in zwei Stufen: Apache .htaccess schreibt die URL auf index.php um, und dort wird die Route an die passende Controller-Methode delegiert.

.htaccess RewriteRules

.htaccess
# Thumbnail Sprite Route: /video/{slug}/thumbnails/sprite.webp
RewriteRule ^video/([a-zA-Z0-9_-]+)/thumbnails/sprite\.jpg$ index.php?x=video/$1/thumbnails/sprite.webp [QSA,L]

# Subtitle Route: /video/{slug}/subtitles/{lang}.vtt
RewriteRule ^video/([a-zA-Z0-9_-]+)/subtitles/([a-z]{2})\.vtt$ index.php?x=video/$1/subtitles/$2.vtt [QSA,L]

# Video Streaming Route: /video/{slug}
RewriteRule ^video/([a-zA-Z0-9_-]+)/?$ index.php?x=video/$1 [QSA,L]

# HLS Streaming Route: /hls/{slug}/{datei}
RewriteRule ^hls/([a-zA-Z0-9_-]+)/(.+)$ index.php?x=hls/$1/$2 [QSA,L]

Die Reihenfolge ist entscheidend: Spezifischere Regeln (Thumbnails, Subtitles) stehen vor der allgemeinen Video-Route, da /video/{slug}/thumbnails/sprite.webp sonst als Video-Slug {slug}/thumbnails/sprite.webp interpretiert würde.

index.php Routing-Logik

Die index.php extrahiert den Slug aus $_GET['x'] und prüft in dieser Reihenfolge:

PHP
$slug = $_GET['x'] ?? 'index';

// 1. Thumbnail Sprite
if (preg_match('#^video/([a-zA-Z0-9_-]+)/thumbnails/sprite\.jpg$#', $slug, $m)) {
    $videoController->thumbnailAction($m[1]);
    exit;
}

// 2. Subtitle
if (preg_match('#^video/([a-zA-Z0-9_-]+)/subtitles/([a-z]{2})\.vtt$#', $slug, $m)) {
    $videoController->subtitleAction($m[1], $m[2]);
    exit;
}

// 3. Video Stream (MP4)
if (str_starts_with($slug, 'video/')) {
    $videoSlug = substr($slug, 6);
    $videoController->streamAction($videoSlug);
    exit;
}

// 4. HLS Stream
if (str_starts_with($slug, 'hls/')) {
    $hlsPath = substr($slug, 4);
    if (preg_match('#^([a-zA-Z0-9_-]+)/(.+)$#', $hlsPath, $matches)) {
        $videoController->hlsAction($matches[1], $matches[2]);
        exit;
    }
}

URL-Schema (Zusammenfassung)

URL Controller-Methode Zweck
/video/{slug} streamAction($slug) MP4-Video streamen
/video/{slug}/thumbnails/sprite.webp thumbnailAction($slug) Sprite-Sheet ausliefern
/video/{slug}/subtitles/{lang}.vtt subtitleAction($slug, $lang) WebVTT-Untertitel
/hls/{slug}/master.m3u8 hlsAction($slug, 'master.m3u8') ABR Master-Playlist
/hls/{slug}/playlist.m3u8 hlsAction($slug, 'playlist.m3u8') Single-Rendition Playlist
/hls/{slug}/segment_0001.ts hlsAction($slug, 'segment_0001.ts') HLS-Segment
/hls/{slug}/720p/playlist.m3u8 hlsAction($slug, '720p/playlist.m3u8') Rendition-Playlist
/hls/{slug}/720p/segment_0001.ts hlsAction($slug, '720p/segment_0001.ts') Rendition-Segment

Alle Video-URLs folgen einem konsistenten Schema mit dem Slug als zentralem Identifikator. Der Router leitet jeden Request an die passende Controller-Methode weiter, die ihrerseits den Dateipfad auflöst und die Datei via X-Sendfile ausliefert.

VideoController: Streaming und Auslieferung

Der VideoController ist der zentrale HTTP-Handler für alle Video-bezogenen Requests. Er validiert Eingaben, löst Dateipfade auf und liefert Dateien über Apache X-Sendfile aus.

Pfad

Dateipfad
src/Controllers/VideoController.php

Konstanten

PHP
private const VIDEO_BASE_PATH = '/var/www/videos';

private const MIME_TYPES = [
    'mp4'  => 'video/mp4',
    'webm' => 'video/webm',
    'ogg'  => 'video/ogg',
    'm3u8' => 'application/vnd.apple.mpegurl',
    'ts'   => 'video/mp2t',
    'jpg'  => 'image/jpeg',
    'png'  => 'image/png',
];

streamAction: MP4-Streaming

Streamt die Original-Videodatei mit HTTP Range-Request-Support. Dies ermöglicht das Springen im Video und die Fortschrittsanzeige im Browser.

Ablauf:

  1. Slug-Validierung: /^[a-zA-Z0-9_-]+$/
  2. Pfad-Auflösung: resolveVideoPath($slug)
  3. Range-Header parsen (falls vorhanden)
  4. Response-Header setzen: Content-Type, Content-Length, Accept-Ranges, Cache-Control
  5. X-Sendfile-Header für Apache
  6. PHP-Fallback: Chunk-basiertes Streaming (8 KB Buffer)

Range-Request (HTTP 206):

HTTP
HTTP/1.1 206 Partial Content
Content-Range: bytes 1000-2000/50000
Content-Length: 1001
Content-Type: video/mp4
Accept-Ranges: bytes

hlsAction: HLS-Streaming

Validiert und streamt HLS-Playlists und Segmente. Unterstützt sowohl Single-Rendition als auch Multi-Rendition (ABR):

Terminal
# Erlaubte Datei-Patterns (Regex):
^(?:(?:\d{3,4}p)/)?(?:master\.m3u8|playlist\.m3u8|segment_\d{4}\.ts)$

Dies erlaubt:

resolveVideoPath: Pfad-Auflösung

Die Pfad-Auflösung erfolgt in zwei Stufen:

  1. Datenbank-Lookup: SELECT content FROM artefakte WHERE slug = ? AND type = 'video'
  2. Glob-Fallback: glob('/var/www/videos/*{slug}*/*.mp4') als Rückfall (nicht deterministisch bei mehreren Treffern -- nur für die initiale Migration vorgesehen, nicht für den Produktivbetrieb)

In beiden Fällen wird der aufgelöste Pfad mit realpath() verifiziert und gegen VIDEO_BASE_PATH geprüft.

X-Sendfile

Apache X-Sendfile ist ein Modul, das die Dateiauslieferung vom PHP-Prozess an den Apache-Webserver delegiert. PHP setzt nur den Header X-Sendfile: /absoluter/pfad/zur/datei und Apache übernimmt die Dateiübertragung. Ob dabei tatsächlich Kernel-Level Sendfile (Zero-Copy) zum Einsatz kommt, hängt von TLS-Konfiguration und Betriebssystem ab.

Range-Support bei X-Sendfile ist abhängig von der Apache-Konfiguration und muss nach Änderungen am Server explizit getestet werden.

Vorteile:

Durch die Kombination aus sicherer Pfadauflösung und X-Sendfile vereint der VideoController Sicherheit mit Performance -- PHP validiert den Zugriff, Apache übernimmt die effiziente Auslieferung.

HLS-Streaming und Segmentierung

HTTP Live Streaming (HLS) ist ein von Apple entwickeltes Streaming-Protokoll. Das Video wird in kleine Segmente aufgeteilt, die einzeln heruntergeladen werden. Eine Playlist-Datei (.m3u8) beschreibt die verfügbaren Segmente.

Funktionsprinzip

  1. Das Quellvideo wird mit ffmpeg in 10-Sekunden-Segmente (.ts) zerlegt
  2. Eine Playlist-Datei (playlist.m3u8) listet alle Segmente mit ihren Dauern
  3. Der Browser lädt die Playlist und dann einzelne Segmente nach Bedarf
  4. Während der Wiedergabe werden nachfolgende Segmente im Voraus geladen (Buffering)

Playlist-Format

HLS Playlist
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:12
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:11.360000,
segment_0000.ts
#EXTINF:9.720000,
segment_0001.ts
#EXTINF:10.000000,
segment_0002.ts
...

Jeder #EXTINF-Eintrag gibt die Dauer des folgenden Segments in Sekunden an. EXT-X-PLAYLIST-TYPE:VOD signalisiert, dass es sich um ein abgeschlossenes Video handelt (Video on Demand).

Encoding-Befehl (Single-Rendition)

Terminal
ffmpeg -i input.mp4 \
    -c:v libx264 -preset medium -profile:v main -level 4.0 \
    -c:a aac -b:a 128k -ar 44100 \
    -f hls -hls_time 10 -hls_list_size 0 \
    -hls_segment_filename 'segment_%04d.ts' \
    -hls_playlist_type vod \
    playlist.m3u8

Parameter-Erläuterung:

Parameter Bedeutung
-c:v libx264 H.264 Video-Codec
-preset medium Encoding-Geschwindigkeit vs. Qualität
-profile:v main Kompatibilitätsprofil (breite Geräteunterstützung)
-c:a aac AAC Audio-Codec
-hls_time 10 Segment-Dauer in Sekunden
-hls_list_size 0 Alle Segmente in der Playlist (kein Sliding Window)
-hls_playlist_type vod Markiert als Video on Demand

Segment-Namenskonvention

Segmente werden mit 4-stelliger Nummerierung benannt: segment_0000.ts, segment_0001.ts, usw. Das .ts-Format (MPEG Transport Stream) ist das Standardformat für HLS-Segmente.

Vorteile gegenüber progressivem Download

HLS löst damit die zentralen Probleme des progressiven Downloads und bildet die Grundlage für ein bandbreitenschonendes Streaming-Erlebnis.

Adaptive Bitrate (ABR): Multi-Rendition Encoding

Adaptive Bitrate Streaming erzeugt mehrere Qualitätsstufen desselben Videos. Eine Master-Playlist referenziert alle verfügbaren Renditions. HLS.js wählt automatisch die optimale Qualität basierend auf der aktuellen Bandbreite.

HlsAbrService

Dateipfad
src/Services/Video/HlsAbrService.php

Rendition-Presets

Qualität Video-Bitrate Audio-Bitrate Max-Rate Buffer
1080p 4.500 kbps 192 kbps 5.000 kbps 7.500 kbps
480p 1.000 kbps 96 kbps 1.200 kbps 1.800 kbps

Automatische Rendition-Auswahl

Nur Qualitätsstufen, die kleiner oder gleich der Quellauflösung sind, werden erzeugt. Ein 720p-Quellvideo erzeugt nur 480p -- nicht 1080p.

Die Breite jeder Rendition wird aus dem Seitenverhältnis des Quellvideos berechnet und auf gerade Pixelzahlen gerundet (Anforderung der H.264-Spezifikation).

Master-Playlist

HLS Playlist
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=4692000,RESOLUTION=1920x1080,NAME="1080p"
1080p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1096000,RESOLUTION=854x480,NAME="480p"
480p/playlist.m3u8

Jede Zeile EXT-X-STREAM-INF enthält:

Encoding-Ablauf

Der generateAbr()-Prozess:

  1. Video-Info ermitteln (Auflösung, Dauer via ffprobe)
  2. Anwendbare Renditions bestimmen (basierend auf Quell-Höhe)
  3. Pro Rendition: ffmpeg-Encoding mit spezifischen Parametern
  4. Master-Playlist generieren

Jede Rendition wird mit folgendem Befehl erzeugt:

Terminal
ffmpeg -y -i /pfad/video.mp4 \
    -vf "scale=854:480" \
    -c:v libx264 -preset medium -profile:v main -level 4.0 \
    -b:v 1000k -maxrate 1200k -bufsize 1800k \
    -c:a aac -b:a 96k -ar 44100 \
    -f hls -hls_time 10 -hls_list_size 0 \
    -hls_segment_filename '/pfad/hls/480p/segment_%04d.ts' \
    -hls_playlist_type vod \
    /pfad/hls/480p/playlist.m3u8

Abwärtskompatibilität

Die bestehende hls/playlist.m3u8 (Single-Rendition) bleibt parallel zur master.m3u8 erhalten. Alte Einbettungen, die keine ABR-Unterstützung haben, funktionieren weiterhin.

Bandbreiten-Steuerung

HLS.js verwendet startLevel: -1 (Auto-Modus). Es beginnt mit einer mittleren Qualität und passt dann basierend auf:

Die automatische Qualitätsanpassung sorgt dafür, dass Zuschauer stets die bestmögliche Qualität erhalten, ohne manuell eingreifen zu müssen. Wer dennoch eine feste Qualität bevorzugt, kann diese über den Quality Selector wählen.

HLS.js: Client-seitige Wiedergabe

HLS.js ist eine JavaScript-Bibliothek, die HLS-Streaming in allen modernen Browsern ermöglicht. Safari unterstützt HLS nativ, andere Browser benötigen HLS.js als Polyfill.

Einbindung

HTML
<script src="/assets/js/hls.min.js"></script>

Die Bibliothek wird lokal geladen. Der VideoComponentService stellt sicher, dass der Script-Tag nur einmal pro Seite eingefügt wird (statisches Flag $hlsJsIncluded).

Initialisierung

Für jedes lokale Video generiert der VideoComponentService ein Inline-Script:

JavaScript
var video = document.getElementById('player-video-a1b2c3d4');
if (Hls && Hls.isSupported()) {
    var hls = new Hls({
        maxBufferLength: 30,
        maxMaxBufferLength: 60,
        startLevel: -1
    });
    video._hlsInstance = hls;
    hls.loadSource('/hls/slug/master.m3u8');
    hls.attachMedia(video);
    hls.on(Hls.Events.MANIFEST_PARSED, function() {
        video.play().catch(function() {});
    });
}

Konfiguration

Parameter Wert Bedeutung
maxBufferLength 30 Sek. Maximaler Buffer bei normaler Wiedergabe
maxMaxBufferLength 60 Sek. Absolutes Buffer-Maximum
startLevel -1 Auto-Modus (HLS.js wählt die Startqualität)

Instanz-Speicherung

Die HLS-Instanz wird als video._hlsInstance am Video-Element gespeichert. Dies ermöglicht dem Quality-Selector später den Zugriff:

JavaScript
var hls = video._hlsInstance;
hls.currentLevel = 2;    // Manueller Level-Wechsel
hls.currentLevel = -1;   // Zurück zu Auto

Fallback-Kette im Detail

JavaScript
function initPlayer() {
    // 1. HLS.js verfügbar?
    if (Hls && Hls.isSupported()) {
        // JavaScript-basiertes HLS
        var hls = new Hls({...});
        hls.loadSource(hlsUrl);
        hls.attachMedia(video);
    }
    // 2. Natives HLS (Safari)?
    else if (video.canPlayType('application/vnd.apple.mpegurl')) {
        video.src = hlsUrl;
    }
    // 3. Fallback auf MP4
    else {
        video.src = mp4Url;
    }
}

Auf Safari wird HLS nativ vom Browser gehandhabt -- inklusive ABR. Auf allen anderen Browsern übernimmt HLS.js die Playlist-Verarbeitung, Segment-Downloads und Qualitätswechsel.

Error-Handling

Bei fatalen HLS-Fehlern (z.B. Netzwerkprobleme, defekte Segmente) wird automatisch auf die MP4-Quelle umgeschaltet:

JavaScript
hls.on(Hls.Events.ERROR, function(event, data) {
    if (data.fatal) {
        console.warn('HLS error, falling back to MP4');
        video.src = mp4Url;
        video.play().catch(function() {});
    }
});

Dieser Fallback-Mechanismus versucht, bei fatalen HLS-Fehlern auf die MP4-Quelle umzuschalten -- der Zuschauer bemerkt idealerweise nur eine kurze Unterbrechung beim Wechsel zur MP4-Quelle.

Lazy Loading und Preload-Strategie

Lazy Loading verzögert das Laden des Videos, bis der Benutzer aktiv auf den Play-Button klickt. Die Preload-Strategie passt das Lade-Verhalten an die Verbindungsqualität an.

Lazy Loading: Poster-Placeholder

Wenn lazy=true (Standard), wird anstelle des Videos ein Poster-Bild mit einem Play-Button angezeigt:

HTML
<div class="video-lazy-wrapper" data-video-lazy="true">
    <img src="/video/slug-poster" class="video-poster-placeholder" loading="lazy">
    <button class="video-play-button" aria-label="Video abspielen">
        <svg>▶</svg>
    </button>
</div>
<video style="display:none;">...</video>

Das Video-Element ist initial versteckt (display:none). Erst beim Klick auf den Play-Button wird:

  1. Der Lazy-Wrapper ausgeblendet
  2. Das Video-Element sichtbar gemacht
  3. HLS.js initialisiert
  4. Die Wiedergabe gestartet

IntersectionObserver

Zusätzlich überwacht ein IntersectionObserver das Video-Container-Element:

JavaScript
var observer = new IntersectionObserver(function(entries) {
    entries.forEach(function(entry) {
        if (entry.isIntersecting) {
            container.classList.add('video-visible');
            observer.unobserve(container);
        }
    });
}, { threshold: 0.5 });

Sobald 50% des Containers im Viewport sichtbar sind, wird die CSS-Klasse .video-visible hinzugefügt. Dies kann für Animationen oder Vorab-Aktionen genutzt werden.

Preload-Strategie

Die Preload-Strategie wertet die Network Information API aus:

JavaScript
var conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (conn) {
    // Langsame Verbindung: Kein Preload
    if (conn.effectiveType === 'slow-2g' || conn.effectiveType === '2g') {
        video.preload = 'none';
    }
    // Data-Saver aktiv: Kein Preload
    if (conn.saveData) {
        video.preload = 'none';
    }
}

Preload-Werte:

Wert Verhalten
none Nichts vorladen (nur bei Interaktion)
metadata Nur Metadaten laden (Dauer, Auflösung) -- Standard
auto Browser entscheidet (oft vollständiges Buffering)

Auf langsamen Verbindungen (2G) und bei aktivem Data-Saver wird preload auf none gesetzt, um Datenvolumen zu sparen.

Zusammenspiel

  1. Seitenlade: Poster wird angezeigt, Video ist versteckt
  2. IntersectionObserver: Erkennt Sichtbarkeit (.video-visible)
  3. Preload-Strategie: Passt preload-Attribut an Verbindung an
  4. Klick auf Play: Video wird initialisiert und gestartet

Dieses Zusammenspiel stellt sicher, dass Videos keine Bandbreite verbrauchen, solange sie nicht sichtbar sind, und beim Abspielen dennoch sofort starten.

CSS-Architektur und Responsive Design

Alle Video-Styles sind in einer einzigen CSS-Datei organisiert, die modulare Sektionen für jedes Feature enthält.

Pfad

Dateipfad
public/assets/css/video.css

Design-Tokens

Die CSS-Datei verwendet CSS Custom Properties als Design-Tokens mit Fallback-Werten:

CSS
background: var(--color-bg-dark, #1a1a1a);
border-radius: var(--radius-md, 8px);
color: var(--color-primary, #4a90d9);
border: 1px solid var(--color-border-light, #ddd);
background: var(--color-mark, #fff3cd);

Dies ermöglicht die Anpassung an das bestehende Design-System ohne Änderung der Video-CSS.

Container und Layout

CSS
.video-container {
    position: relative;
    width: 100%;
    margin: 1.5rem 0;
    background: var(--color-bg-dark, #1a1a1a);
    border-radius: var(--radius-md, 8px);
    overflow: hidden;
}
.video-container.video-local {
    aspect-ratio: 16 / 9;
}

Der Container nutzt aspect-ratio: 16/9 für konsistente Proportionen. overflow: hidden verhindert, dass überlagerte Elemente über den abgerundeten Rand hinausragen.

Overlay Controls (Speed, Fullscreen, Quality)

Alle Overlay-Buttons (Speed, Fullscreen, Quality) sind in einem Flex-Wrapper gruppiert:

CSS
.video-overlay-controls {
    position: absolute;
    top: 12px;
    right: 12px;
    z-index: 8;
    display: flex;
    align-items: center;
    gap: 8px;
    opacity: 0;
    transition: opacity 0.2s ease;
}
.video-container:hover .video-overlay-controls,
.video-container:focus-within .video-overlay-controls {
    opacity: 1;
}

Der Flex-Wrapper positioniert sich oben rechts. Die Buttons darin teilen sich einen einheitlichen Stil ohne individuelle absolute Positionierung. Buttons sind im Ruhezustand unsichtbar und erscheinen bei Hover oder Fokus.

Sektionen in der CSS-Datei

  1. Container und Video-Element
  2. Lazy Loading Wrapper und Play-Button
  3. Vimeo Container
  4. Error State
  5. Chapter List
  6. Transcript (Header, Body, Segments, Search)
  7. Keyboard Feedback Overlay 8-10. Overlay Controls (Flex Wrapper, Buttons, Quality Menu)
  8. Thumbnail Preview
  9. Fullscreen State
  10. Responsive Breakpoints

Responsive Design

CSS
@media (max-width: 768px) {
    .video-play-button { width: 60px; height: 60px; }
    .video-overlay-controls { opacity: 1; }
}

Auf Mobilgeräten (unter 768px):

Durch diese Anpassungen bleibt der Player auf allen Bildschirmgrößen vollständig bedienbar -- ohne separate mobile Stylesheets oder JavaScript-Unterscheidungen.

JavaScript-Architektur und Initialisierung

Das gesamte JavaScript ist in einer einzigen Datei organisiert, die als IIFE (Immediately Invoked Function Expression) ausgeführt wird.

Pfad

Dateipfad
public/assets/js/video-player.js

IIFE-Pattern

JavaScript
(function() {
    'use strict';

    // Alle Funktionen sind lokal (kein Scope-Leak)
    function initKeyboard(container) { ... }
    function initSpeedControl(container) { ... }
    // ...

    // Öffentliche API
    window.VideoPlayer = { init: initVideoPlayer, initAll: initAll };
})();

Alle internen Funktionen sind innerhalb des IIFE gekapselt. Nur window.VideoPlayer ist von außen erreichbar.

Auto-Initialisierung

JavaScript
function initAll() {
    var containers = document.querySelectorAll('.video-container.video-local');
    for (var i = 0; i < containers.length; i++) {
        initVideoPlayer(containers[i]);
    }
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initAll);
} else {
    initAll();
}

Bei Seitenladung werden automatisch alle .video-container.video-local-Elemente initialisiert.

Re-Initialisierungsschutz

JavaScript
function initVideoPlayer(container) {
    if (container.dataset.videoInit) return;
    container.dataset.videoInit = 'true';
    // ...
}

Das data-video-init-Attribut verhindert, dass ein Container mehrfach initialisiert wird (z.B. bei dynamisch nachgeladenem Content).

Initialisierungsreihenfolge

JavaScript
initKeyboard(container);          // Tastatursteuerung
initSpeedControl(container);       // Geschwindigkeitsbutton
initFullscreen(container);         // Vollbild-Button
initQualitySelector(container);    // Qualitätswahl
initOverlayControls(container);    // Flex-Wrapper für Overlay-Buttons
initChapters(container);           // Kapitelmarken
initTranscript(container);         // Transkript
initThumbnailPreview(container);   // Thumbnail-Hover
initLazyLoading(container);        // IntersectionObserver
initPreloadStrategy(container);    // Verbindungsabhängiger Preload

Die Reihenfolge ist relevant: Keyboard und Buttons werden zuerst eingerichtet, Features die DOM-Siblings benötigen (Chapters, Transcript) danach, und Performance-Features (Lazy, Preload) zuletzt.

Öffentliche API für dynamischen Content

JavaScript
// Einzelnen Container initialisieren
var container = document.getElementById('video-neues-video');
window.VideoPlayer.init(container);

// Alle neuen Container finden und initialisieren
window.VideoPlayer.initAll();

Dies ist nützlich, wenn Videos per AJAX oder nach einem Artefakt-Update dynamisch in die Seite eingefügt werden.

Feature-Sektionen

Sektion Zeilen Funktion
Keyboard Navigation 15-194 Tastenbelegung und Feedback
Speed Control 197-256 Presets, Persistenz, Button
Fullscreen 259-301 Fullscreen API, Icon-Wechsel
Captions 304-331 Untertitel-Toggle
Chapter Markers 334-391 Click-to-Seek, Aktiv-Highlighting
Thumbnail Preview 394-450 Sprite-Hover-Logik
Quality Selector 456-599 ABR-Dropdown, HLS.js-Steuerung
Transcript 605-692 Suche, Click-to-Seek, Highlighting
Lazy Loading 698-714 IntersectionObserver
Preload Strategy 720-737 Network Information API
Overlay Controls 747-762 Flex-Wrapper für Speed/Fullscreen/Quality
Initialization 765-795 Auto-Init, API-Export

Die Initialisierung erfolgt automatisch für alle .video-local-Container auf der Seite. Die API wird als window.VideoPlayer exportiert, sodass externe Scripts bei Bedarf auf Player-Instanzen zugreifen können.

Tastatursteuerung

Das Video-Player-Modul bietet eine vollständige Tastatursteuerung. Alle Aktionen werden durch ein visuelles Feedback-Overlay bestätigt.

Tastenbelegung

Taste(n) Aktion Feedback
Leertaste / K Play/Pause umschalten ▶ oder ⏸
Pfeil links 5 Sekunden zurück ◀ 5s
Pfeil rechts 5 Sekunden vor 5s ▶
J 10 Sekunden zurück ◀◀ 10s
L 10 Sekunden vor 10s ▶▶
Pfeil hoch Lautstärke +5% 🔊 80%
Pfeil runter Lautstärke -5% 🔉 60%
M Stummschalten umschalten 🔇 Stumm / 🔊 Ton an
F Vollbild umschalten --
C Untertitel durchschalten UT: Deutsch / UT: Aus
< (Komma) Geschwindigkeit -0.25x 0.75x
> (Punkt) Geschwindigkeit +0.25x 1.25x
0-9 Zu 0-90% der Dauer springen 50% -- MM:SS
Home Zum Anfang springen Anfang
End Zum Ende springen Ende

Konfigurationskonstanten

JavaScript
var SEEK_SHORT = 5;    // Pfeiltasten: 5 Sekunden
var SEEK_LONG = 10;    // J/L: 10 Sekunden
var VOLUME_STEP = 0.05; // Lautstärke: 5% Schritte

Visuelles Feedback

Jede Tastenaktion zeigt kurz ein Overlay in der Video-Mitte:

JavaScript
function showFeedback(container, text) {
    var el = document.createElement('div');
    el.className = 'video-feedback';
    el.textContent = text;
    container.appendChild(el);

    setTimeout(function() { el.classList.add('video-feedback--fade'); }, 10);
    setTimeout(function() { el.remove(); }, 800);
}

Das Overlay erscheint sofort, beginnt nach 10ms zu verblassen (CSS-Transition 400ms) und wird nach 800ms entfernt.

Fokus-Management

Der Video-Container wird als fokussierbares Element konfiguriert:

JavaScript
container.setAttribute('tabindex', '0');
container.setAttribute('role', 'application');
container.setAttribute('aria-label', 'Video Player - Tastatursteuerung aktiv');

Ein Klick auf das Video fokussiert automatisch den Container, sodass Tastatureingaben erkannt werden.

Schutzmechanismen

Diese Schutzmechanismen stellen sicher, dass Tastaturkürzel nur dann greifen, wenn der Video-Player tatsächlich fokussiert und aktiv ist.

Geschwindigkeitssteuerung

Die Wiedergabegeschwindigkeit kann über einen Button oder per Tastatur gesteuert werden. Die Einstellung wird im Browser gespeichert und bei zukünftigen Besuchen wiederhergestellt.

Geschwindigkeits-Presets

JavaScript
var SPEED_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2];

Steuerung

Button (oben rechts im Video):

Tastatur:

Snap-to-Preset

Beim Ändern per Tastatur wird auf den nächsten Preset gerundet, wenn die Differenz unter 0.13x liegt:

JavaScript
var closest = SPEED_PRESETS.reduce(function(prev, curr) {
    return Math.abs(curr - newSpeed) < Math.abs(prev - newSpeed) ? curr : prev;
});
if (Math.abs(closest - newSpeed) < 0.13) newSpeed = closest;

Dies verhindert unübliche Geschwindigkeiten wie 1.03x oder 0.97x.

Persistenz

Die gewählte Geschwindigkeit wird im localStorage gespeichert:

JavaScript
// Speichern
localStorage.setItem('video-speed-preference', String(newSpeed));

// Wiederherstellen beim Laden
var saved = localStorage.getItem('video-speed-preference');
if (saved) video.playbackRate = parseFloat(saved);

Die Geschwindigkeit bleibt über Seitenbesuche und Browser-Neustarts hinweg erhalten. Alle Videos auf der Seite verwenden dieselbe Einstellung.

CSS

Der Speed-Button wird nur bei Hover oder Fokus sichtbar (opacity-Transition). Auf Mobilgeräten ist er dauerhaft sichtbar.

CSS
.video-speed-btn {
    position: absolute;
    top: 12px;
    right: 56px;
    opacity: 0;
    transition: opacity 0.2s ease;
}
.video-container:hover .video-speed-btn {
    opacity: 1;
}

Der Speed-Button erscheint nur bei Hover über den Video-Container. Auf Touch-Geräten ist er dauerhaft sichtbar, da Hover-Events dort nicht verfügbar sind.

Vollbild-Modus

Der Vollbild-Modus verwendet die Standard Fullscreen API mit webkit-Fallback für ältere Safari-Versionen. Der gesamte Video-Container (nicht nur das Video-Element) wird in den Vollbild-Modus versetzt.

Aktivierung

Implementierung

JavaScript
function toggleFullscreen(container) {
    if (document.fullscreenElement || document.webkitFullscreenElement) {
        if (document.exitFullscreen) document.exitFullscreen();
        else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
    } else {
        if (container.requestFullscreen) container.requestFullscreen();
        else if (container.webkitRequestFullscreen) container.webkitRequestFullscreen();
    }
}

Dynamischer Icon-Wechsel

Das Button-Icon wechselt zwischen Expand (vier Pfeile nach außen) und Collapse (vier Pfeile nach innen):

JavaScript
document.addEventListener('fullscreenchange', function() {
    var isFs = document.fullscreenElement === container;
    container.classList.toggle('video-fullscreen', isFs);
    // Icon aktualisieren
});

CSS im Vollbild

CSS
.video-container.video-fullscreen {
    border-radius: 0;
}
.video-container.video-fullscreen .video-player {
    width: 100vw;
    height: 100vh;
    object-fit: contain;
}

object-fit: contain stellt sicher, dass das Video seine Proportionen behält und schwarze Balken bei abweichendem Seitenverhältnis angezeigt werden.

Container-basierter Vollbild-Modus

Im Gegensatz zu video.requestFullscreen() wird der gesamte Container in den Vollbild-Modus versetzt. Dies hat den Vorteil, dass alle überlagerten Elemente (Speed-Button, Fullscreen-Button, Quality-Selector, Feedback-Overlay) weiterhin sichtbar sind.

Qualitätswahl (Quality Selector)

Der Quality Selector ermöglicht die manuelle Wahl der Videoqualität. Im Auto-Modus passt HLS.js die Qualität automatisch an die Bandbreite an.

Voraussetzung

Der Quality Selector erscheint nur, wenn ABR-Renditions vorhanden sind. Das Video-Element muss das Attribut data-renditions enthalten:

HTML
<video data-renditions='["1080p","720p","480p","360p"]'>

Dropdown-Menü

Der Selector besteht aus einem Button und einem Dropdown-Menü:

Die Optionen werden absteigend sortiert: 1080p → 720p → 480p → 360p, mit "Auto" als erste Option.

HLS.js-Integration

Die Qualitätswahl kommuniziert direkt mit der HLS.js-Instanz:

JavaScript
var hls = video._hlsInstance;

// Auto-Modus (HLS.js entscheidet)
hls.currentLevel = -1;

// Manuelle Qualität (z.B. 720p)
for (var i = 0; i < hls.levels.length; i++) {
    if (hls.levels[i].height === 720) {
        hls.currentLevel = i;
        break;
    }
}

Live-Update im Auto-Modus

Im Auto-Modus aktualisiert sich der Button-Text bei jedem Qualitätswechsel:

JavaScript
hls.on(Hls.Events.LEVEL_SWITCHED, function(event, data) {
    if (autoMode && hls.levels[data.level]) {
        var h = hls.levels[data.level].height;
        btn.textContent = 'Auto (' + h + 'p)';
    }
});

So sieht der Benutzer jederzeit, in welcher Qualität das Video gerade abgespielt wird, auch wenn HLS.js die Entscheidung trifft.

CSS

CSS
.video-quality-wrapper {
    position: absolute;
    top: 12px;
    right: 100px;
}
.video-quality-menu {
    position: absolute;
    bottom: calc(100% + 4px);
    background: rgba(20, 20, 20, 0.95);
    border-radius: 4px;
}
.video-quality-active::before {
    content: '● ';
}

Das Menü positioniert sich oberhalb des Buttons und zeigt die aktive Qualitätsstufe mit einem Punkt-Marker an. Die halbtransparente Hintergrundfarbe fügt sich in das Gesamtdesign des Players ein.

Kapitelmarken (ChapterService)

Kapitelmarken ermöglichen die Navigation innerhalb eines Videos über klickbare Zeitmarken. Die Kapitel werden als JSON in einem separaten Artefakt gespeichert.

ChapterService

Dateipfad
src/Services/Video/ChapterService.php

Datenformat

Kapitel werden als JSON-Array im content-Feld eines Artefakts gespeichert:

JSON
[
    {"time": 0, "title": "Einleitung"},
    {"time": 120, "title": "Grundlagen"},
    {"time": 600, "title": "Praxisbeispiel"},
    {"time": 1200, "title": "Zusammenfassung"}
]

Jedes Objekt hat zwei Felder:

Einbindung

Placeholder
{{video:mein-video|chapters=mein-video-kapitel}}

Der Wert von chapters= ist der Slug des Artefakts, das die Kapitel-JSON enthält.

HTML-Ausgabe

Der ChapterService generiert eine klickbare Liste unterhalb des Videos:

HTML
<div class="video-chapters">
    <h3 class="video-chapters-title">Kapitel</h3>
    <ol class="video-chapters-list">
        <li class="video-chapter-item" data-time="0">
            <button class="video-chapter-btn">
                <span class="video-chapter-time">0:00</span>
                <span class="video-chapter-label">Einleitung</span>
            </button>
        </li>
        ...
    </ol>
</div>

JavaScript-Interaktion

Klick auf Kapitel:

JavaScript
btn.addEventListener('click', function() {
    var time = parseInt(item.dataset.time, 10);
    video.currentTime = time;
    if (video.paused) video.play();
    container.focus();
});

Aktiv-Markierung:

Während der Wiedergabe wird das aktuelle Kapitel hervorgehoben. Das timeupdate-Event prüft bei jeder Aktualisierung, welches Kapitel aktiv ist:

JavaScript
video.addEventListener('timeupdate', function() {
    var current = video.currentTime;
    for (var i = chapters.length - 1; i >= 0; i--) {
        if (current >= chapters[i].time) {
            // Kapitel i ist aktiv
            break;
        }
    }
});

Die Suche beginnt beim letzten Kapitel und läuft rückwärts -- das erste Kapitel, dessen Startzeit kleiner oder gleich der aktuellen Position ist, ist das aktive.

Untertitel: WebVTT und SubtitleService

Untertitel werden im WebVTT-Format gespeichert und über den VideoController ausgeliefert. Der SubtitleService verwaltet die verfügbaren Spuren und generiert die HTML5 Track-Elemente.

SubtitleService

Dateipfad
src/Services/Video/SubtitleService.php

Speicherort

Dateipfad
/var/www/videos/{verzeichnis}/subtitles/
    de.vtt      Deutsche Untertitel
    en.vtt      Englische Untertitel
    fr.vtt      Französische Untertitel

WebVTT-Format

WebVTT
WEBVTT

00:00:00.000 --> 00:00:05.200
Willkommen zum Video.

00:00:05.200 --> 00:00:10.100
Heute besprechen wir das Thema
KI-Protokollierung.

00:00:10.100 --> 00:00:15.500
Beginnen wir mit den Grundlagen.

Unterstützte Sprachen

Code Label
de Deutsch
en English
fr Français
es Español
it Italiano

Einbindung

Placeholder
{{video:mein-video|captions=de}}
{{video:mein-video|captions=de,en}}
{{video:mein-video|captions=auto}}

HTML5 Track-Elemente

Der SubtitleService generiert <track>-Elemente innerhalb des <video>-Tags:

HTML
<track kind="subtitles" src="/video/slug/subtitles/de.vtt"
       srclang="de" label="Deutsch" default>
<track kind="subtitles" src="/video/slug/subtitles/en.vtt"
       srclang="en" label="English">

Die erste Spur mit srclang="de" erhält das default-Attribut.

Tastatur-Toggle

Die C-Taste schaltet durch die verfügbaren Untertitelspuren:

JavaScript
// Zyklus: Aus → Spur 1 → Spur 2 → ... → Aus
for (var i = 0; i < tracks.length; i++) {
    if (tracks[i].mode === 'showing') { activeIdx = i; break; }
}
// Aktuelle deaktivieren, nächste aktivieren
tracks[activeIdx].mode = 'hidden';
tracks[nextIdx].mode = 'showing';

Streaming

Untertitel werden über den VideoController gestreamt:

HTTP
// URL: /video/{slug}/subtitles/{lang}.vtt
// Headers:
Content-Type: text/vtt; charset=UTF-8
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=86400

Der CORS-Header ist nur notwendig, wenn WebVTT-Dateien von einer anderen Origin geladen werden. Bei Same-Origin-Zugriff kann er entfallen.

Transkript (TranscriptService)

Das Transkript zeigt den gesprochenen Text des Videos als durchsuchbare, interaktive Liste unterhalb des Players an.

TranscriptService

Dateipfad
src/Services/Video/TranscriptService.php

Datenquellen

Das Transkript kann aus zwei Quellen geladen werden:

  1. JSON-Artefakt (primär): Artefakt mit Segment-Array
  2. WebVTT-Datei (Fallback): Automatisches Parsen aus vorhandener .vtt-Datei

Segment-Format (JSON)

JSON
[
    {"start": 0.0, "end": 5.2, "text": "Willkommen zum Video."},
    {"start": 5.2, "end": 10.1, "text": "Heute besprechen wir das Thema KI-Protokollierung."},
    {"start": 10.1, "end": 15.5, "text": "Beginnen wir mit den Grundlagen."}
]

Einbindung

Placeholder
{{video:mein-video|transcript=transkript-slug}}

HTML-Ausgabe

HTML
<div class="video-transcript">
    <div class="video-transcript-header">
        <h3 class="video-transcript-title">Transkript</h3>
        <input class="video-transcript-search" placeholder="Suchen...">
    </div>
    <div class="video-transcript-body">
        <div class="video-transcript-segment" data-start="0" data-end="5.2">
            <button class="video-transcript-time">0:00</button>
            <span class="video-transcript-text">Willkommen zum Video.</span>
        </div>
        ...
    </div>
</div>

Interaktive Features

Klick-to-Seek: Ein Klick auf den Zeitstempel springt zur entsprechenden Position im Video und startet die Wiedergabe.

Aktiv-Highlighting: Das aktuell gesprochene Segment wird hervorgehoben und automatisch in den sichtbaren Bereich gescrollt:

JavaScript
video.addEventListener('timeupdate', function() {
    var t = video.currentTime;
    for (var i = segments.length - 1; i >= 0; i--) {
        var start = parseFloat(segments[i].dataset.start);
        var end = parseFloat(segments[i].dataset.end);
        if (t >= start && t < end) {
            active = segments[i];
            break;
        }
    }
    if (active) {
        active.classList.add('video-transcript-active');
        active.scrollIntoView({ block: 'center', behavior: 'smooth' });
    }
});

Volltextsuche: Das Suchfeld filtert Segmente in Echtzeit:

WebVTT-Parser

Falls kein JSON-Artefakt vorhanden ist, parst der TranscriptService eine vorhandene WebVTT-Datei:

Regex
// Regex für VTT-Zeitstempel:
/(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})/

Der folgende Text wird als Segment-Text extrahiert.

Thumbnail-Preview (Sprite-Sheet)

Die Thumbnail-Preview zeigt beim Hovern über das Video ein Vorschaubild der entsprechenden Stelle. Die Thumbnails werden als Sprite-Sheet (ein großes Bild mit allen Frames in einem Grid) gespeichert.

ThumbnailService

Dateipfad
src/Services/Video/ThumbnailService.php

Konfiguration

PHP
private const THUMB_WIDTH = 160;   // Breite pro Thumbnail in Pixel
private const THUMB_HEIGHT = 90;   // Höhe pro Thumbnail in Pixel
private const COLUMNS = 10;        // Spalten im Grid
private const INTERVAL = 10;       // Ein Frame alle 10 Sekunden

Generierung (CLI)

Terminal
php scripts/generate-video-thumbnails.php fallbesprechung-ki-protokoll-video

Schritte:

  1. Video-Dauer ermitteln (ffprobe)
  2. Frames extrahieren: ffmpeg -vf "fps=1/10,scale=160:90" erzeugt ein JPEG alle 10 Sekunden
  3. Sprite zusammensetzen: montage frame_*.jpg -tile 10x{rows} -geometry 160x90+0+0 -quality 60 sprite.webp
  4. Metadata schreiben: sprite.json
  5. Temporäre Einzelframes löschen

Sprite-Metadata (sprite.json)

JSON
{
    "width": 1600,
    "height": 540,
    "thumbWidth": 160,
    "thumbHeight": 90,
    "columns": 10,
    "rows": 6,
    "count": 52,
    "interval": 10,
    "duration": 5113
}

Einbindung

Placeholder
{{video:mein-video|thumbnails=true}}

Dies fügt dem Video-Element das Attribut data-sprite mit der JSON-Metadata hinzu.

JavaScript-Hover-Logik

JavaScript
video.addEventListener('mousemove', function(e) {
    var rect = video.getBoundingClientRect();
    var pct = (e.clientX - rect.left) / rect.width;   // 0.0 - 1.0
    var time = pct * video.duration;                    // Zeit in Sekunden

    var frameIdx = Math.floor(time / sprite.interval);  // Index im Grid
    var col = frameIdx % sprite.columns;                // Spalte
    var row = Math.floor(frameIdx / sprite.columns);    // Zeile

    // CSS Background-Position berechnen
    preview.style.backgroundPosition =
        '-' + (col * sprite.thumbWidth) + 'px ' +
        '-' + (row * sprite.thumbHeight) + 'px';
});

Das Sprite-Bild wird einmal geladen und als CSS-Background verwendet. Durch Verschieben der background-position wird der richtige Ausschnitt angezeigt.

Streaming

Das Sprite-Bild wird über den VideoController ausgeliefert:

HTTP
// URL: /video/{slug}/thumbnails/sprite.webp
// Headers:
Content-Type: image/webp
Cache-Control: public, max-age=604800   // 1 Woche

Das Sprite-Bild wird mit einer Woche Cache-Dauer ausgeliefert, da sich die Thumbnails eines veröffentlichten Videos nicht mehr ändern.

CLI-Tools: Encoding und Generierung

Zwei CLI-Scripts automatisieren die Vorbereitung von Videos für das Self-Hosting-System. Beide benötigen ffmpeg und werden über die Kommandozeile ausgeführt.

ABR-Encoding: generate-video-hls-abr.php

Terminal
php scripts/generate-video-hls-abr.php <video-slug> [--info] [--force]

Optionen:

Flag Beschreibung
(ohne) Encoding starten (überspringe wenn master.m3u8 existiert)
--info Nur Videoinfo und anwendbare Renditions anzeigen (Dry-Run)
--force Vorhandene ABR-Renditions überschreiben

Beispiel-Ausgabe (--info):

Terminal
Video: /var/www/videos/.../video1350955604.mp4
Resolution: 2144x1080
Duration: 01:25:13
Bitrate: 747 kbps

Applicable renditions (4):
  1080p: 2144x1080 @ 4500k video + 192k audio
  720p:  1430x720  @ 2500k video + 128k audio
  480p:  952x480   @ 1000k video + 96k audio
  360p:  714x360   @ 500k video + 64k audio

Beispiel-Ausgabe (Encoding):

Terminal
Starting ABR encoding...
------------------------------------------------------------
[14:23:01] 1080p: encoding
[14:35:42] 1080p: done
[14:35:42] 720p: encoding
[14:42:18] 720p: done
[14:42:18] 480p: encoding
[14:45:01] 480p: done
[14:45:01] 360p: encoding
[14:46:30] 360p: done
------------------------------------------------------------
Elapsed: 00:23:29

Generated files:
  Master: /var/www/videos/.../hls/master.m3u8
  1080p: 512 segments (1.8 GB)
  720p: 512 segments (920 MB)
  480p: 512 segments (380 MB)
  360p: 512 segments (195 MB)

Thumbnail-Generierung: generate-video-thumbnails.php

Terminal
php scripts/generate-video-thumbnails.php <video-slug>

Beispiel-Ausgabe:

Terminal
Generating thumbnail sprite for: fallbesprechung-ki-protokoll-video
Video: /var/www/videos/.../video1350955604.mp4
Done! Sprite generated.
  Frames: 52
  Size: 1600x540
  Interval: 10s

Voraussetzungen

Beide Scripts benötigen:

Ablauf: Neues Video einrichten

Terminal
# 1. Video-Datei in Verzeichnis ablegen
# /var/www/videos/2026-02-05_mein-thema/video.mp4

# 2. Artefakt in Datenbank anlegen (manuell oder via Admin)

# 3. HLS-Encoding starten
php scripts/generate-video-hls-abr.php mein-video-slug

# 4. Thumbnails generieren
php scripts/generate-video-thumbnails.php mein-video-slug

# 5. Optional: Untertitel-Dateien ablegen
# /var/www/videos/2026-02-05_mein-thema/subtitles/de.vtt

# 6. Optional: Kapitel- und Transkript-Artefakte anlegen

# 7. Video in Seite einbetten
# {{video:mein-video-slug|chapters=...|captions=de|thumbnails=true}}

Nach Abschluss dieser Schritte ist das Video über seinen Slug erreichbar und wird beim nächsten Seitenaufruf mit allen konfigurierten Features gerendert.

Sicherheit, Validierung und Performance

Das Video-System implementiert mehrere Sicherheitsschichten und Caching-Strategien für eine sichere und performante Auslieferung.

Slug-Validierung

Alle eingehenden Slugs werden gegen ein striktes Pattern validiert:

Regex
/^[a-zA-Z0-9_-]+$/

Nur alphanumerische Zeichen, Bindestriche und Unterstriche sind erlaubt. Dies verhindert Path-Traversal-Angriffe (z.B. ../../etc/passwd).

HLS-Datei-Whitelist

Die hlsAction validiert den Dateinamen mit einer Whitelist:

Regex
/^(?:(?:\d{3,4}p)/)?(?:master\.m3u8|playlist\.m3u8|segment_\d{4}\.ts)$/

Nur folgende Dateitypen sind erlaubt:

realpath()-Prüfung

Jeder aufgelöste Dateipfad wird mit realpath() verifiziert:

PHP
$realPath = realpath($hlsFile);
if (!$realPath || !str_starts_with($realPath, '/var/www/videos')) {
    $this->sendError(403, 'Zugriff verweigert');
    return;
}

realpath() löst symbolische Links und ..-Segmente auf. Die anschließende Prüfung verifiziert, dass der tatsächliche Pfad innerhalb des Video-Verzeichnisses liegt.

Untertitel-Sprach-Validierung

Sprachcodes für Untertitel werden auf genau zwei Kleinbuchstaben beschränkt:

Regex
/^[a-z]{2}$/

X-Sendfile: Sichere Dateiauslieferung

Die eigentliche Dateiauslieferung übernimmt Apache X-Sendfile auf Kernel-Level. PHP setzt nur den Header und wird sofort freigegeben. Details zur Funktionsweise beschreibt das Kapitel VideoController.

Zugriffskontrolle

Das System implementiert Slug-Validierung, Pfadprüfung und Soft-Delete, jedoch keine Autorisierung auf Benutzerebene. Alle Videos sind über ihre Slugs öffentlich erreichbar. Falls eingeschränkter Zugriff erforderlich ist, muss eine Session- oder Token-Prüfung im VideoController vor dem X-Sendfile-Header ergänzt werden.

Soft-Delete

Die Abfragen filtern auf deleted_at IS NULL, sodass gelöschte Videos über die Anwendung nicht mehr gefunden werden. Bestehende Browser- oder Proxy-Caches können Inhalte bis zum Ablauf ihrer Cache-Dauer jedoch weiterhin ausliefern.

Caching-Strategie

Asset Cache-Dauer Header
MP4-Video 1 Tag Cache-Control: public, max-age=86400
HLS-Playlists 1 Tag Cache-Control: public, max-age=86400
HLS-Segmente 1 Tag Cache-Control: public, max-age=86400
Thumbnail-Sprite 1 Woche Cache-Control: public, max-age=604800
Untertitel 1 Tag Cache-Control: public, max-age=86400

Bei Re-Encoding oder Änderungen an Renditions muss die Cache-Dauer der Playlists angepasst oder der Cache explizit invalidiert werden. | CSS/JS (Dev) | Kein Cache | Cache-Control: no-store, no-cache | | CSS/JS (Prod) | 1 Jahr | Cache-Control: public, max-age=31536000, immutable |

Performance-Optimierungen

Server-seitig:

Client-seitig:

Serverseitige und clientseitige Optimierungen greifen ineinander: Der Server liefert Dateien effizient aus, der Client lädt nur, was tatsächlich benötigt wird. Das System ist auf Performance ausgelegt. Ob Engpässe auftreten, hängt vom Lastprofil, der Infrastruktur und der Anzahl paralleler Streams ab.