Add videos: library, block and section background

Videos join the media library as their own table (schema v28), with a
video block and a looping background on sections. video.js starts loops
only while visible, offers a pause button and leaves the still image for
reduced motion and data saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWYm8kjAWmZaB2QB2xuBhU
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-11-05 05:23:17 +00:00
1 parent 592e3d5342
commit 76613b9f8e
25 files changed
+674 -20

No files matched your search

+25 -2
View File
@@ -43,8 +43,10 @@ final class DocumentValidator
public const V24_TYPES = ['html'];
/** Schema v27 (ADR 111): die Sprachwahl, gefüllt aus den Sprachen, in denen die Seite vorliegt. */
public const V27_TYPES = ['language'];
/** Schema v28: ein eigenes Video aus der Mediathek. */
public const V28_TYPES = ['video'];
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
public const SCHEMA_VERSION = 27;
public const SCHEMA_VERSION = 28;
/**
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
@@ -155,7 +157,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 28 && in_array($type, self::V28_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
@@ -468,6 +470,22 @@ final class DocumentValidator
$this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.');
$this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.');
}
if ('video' === $type) {
// Nur eigene Dateien: die Richtlinie der Seite erlaubt Medien von der eigenen Adresse,
// und ein fremdes Video wäre ohnehin ein Fall für „Externer Inhalt“ mit Einwilligung.
$this->videoPath($props['src'] ?? '');
$this->videoPath($props['srcWebm'] ?? '');
if ('' !== ($props['poster'] ?? '')) {
$this->url($props['poster']);
}
$this->text($props['title'] ?? '', 160, true);
$this->require(in_array($props['mode'] ?? 'player', ['player', 'loop'], true), 'Ein Video ist ein Spieler oder eine Schleife.');
}
if ('section' === $type && (isset($props['backgroundVideo']) || isset($props['backgroundVideoWebm']))) {
$this->require($schemaVersion >= 28, 'Ein Hintergrundvideo braucht Schema v28.');
$this->videoPath($props['backgroundVideo'] ?? '');
$this->videoPath($props['backgroundVideoWebm'] ?? '');
}
if ('html' === $type) {
// Eigenes HTML geht unveraendert auf die Seite. Geprueft wird es hier, damit ein
// unbrauchbarer Wert beim Speichern eine Meldung bekommt statt beim Ausliefern
@@ -567,6 +585,11 @@ final class DocumentValidator
$this->require(is_string($value) && strlen($value) <= $max && ($emptyAllowed || '' !== trim($value)), 'Missing or oversized text.');
}
private function videoPath(mixed $path): void
{
$this->require('' === $path || (is_string($path) && 1 === preg_match(VideoLibrary::PATH, $path)), 'Ein Video kommt aus der Mediathek.');
}
private function url(mixed $url): void
{
$this->require(is_string($url) && strlen($url) <= 2048, 'Invalid URL.');
+1
View File
@@ -47,6 +47,7 @@ final class Translations
'icon' => ['label'],
'progress' => ['label'],
'embed' => ['title', 'hint'],
'video' => ['title'],
];
/** Felder der Seite selbst und ihre Höchstlänge. */
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Content;
use App\Domain\StaleWrite;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Videos neben den Bildern und PDFs der Mediathek.
*
* Wie ein PDF wird ein Video nicht umgerechnet, sondern so ausgeliefert, wie es kam: Auf dem
* Hosting gibt es kein ffmpeg, und PHP könnte das in der Laufzeit einer Anfrage auch nicht. Deshalb
* die Prüfung am Dateianfang statt an der Endung und beim Ausliefern Kopfzeilen, die den Browser
* nichts anderes darin sehen lassen als ein Video.
*
* Die Dateien liegen im Medienordner, als <id>.mp4 oder <id>.webm - so gehen sie mit jeder
* Sicherung und jedem Website-Paket mit.
*/
final class VideoLibrary
{
/** Ein Hintergrund oder ein kurzer Film. Für Längeres ist ein Videodienst der bessere Weg. */
public const MAX_BYTES = 64 * 1024 * 1024;
/** Die Adresse eines Videos, so steht sie im Dokument. */
public const PATH = '#^/media/[a-f0-9]{32}\.(?:mp4|webm)$#D';
private const EXTENSIONS = ['video/mp4' => 'mp4', 'video/webm' => 'webm'];
public function __construct(private Connection $db, private string $directory)
{
}
/** @return list<array<string, mixed>> neueste zuerst */
public function list(bool $withArchived = true): array
{
return array_map($this->present(...), $this->db->fetchAllAssociative(
'SELECT * FROM video'.($withArchived ? '' : ' WHERE archived = 0').' ORDER BY created_at DESC, id DESC',
));
}
public function find(string $id): ?array
{
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
return null;
}
$row = $this->db->fetchAssociative('SELECT * FROM video WHERE id = ?', [$id]);
return $row ? $this->present($row) : null;
}
/** Soll diese Datei als Video behandelt werden? Der Inhalt entscheidet, die Endung ist Rückfall. */
public function claims(UploadedFile $file): bool
{
return null !== $this->mimeOf($file->getPathname())
|| str_starts_with((string) $file->getMimeType(), 'video/')
|| in_array(strtolower($file->getClientOriginalExtension()), ['mp4', 'm4v', 'webm', 'mov'], true);
}
public function upload(UploadedFile $file): array
{
if (!$file->isValid() || $file->getSize() > self::MAX_BYTES) {
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 64 MB pro Video; das PHP-Hosting kann ein kleineres Limit setzen.');
}
$mime = $this->mimeOf($file->getPathname());
if (null === $mime) {
throw new \InvalidArgumentException('Das ist kein abspielbares Video. Bitte eine MP4- oder WebM-Datei wählen; MOV spielen nicht alle Browser ab.');
}
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
}
$id = bin2hex(random_bytes(16));
$path = $this->path($id, self::EXTENSIONS[$mime]);
if (!@copy($file->getPathname(), $path) || !is_file($path)) {
throw new \RuntimeException('Das Video konnte nicht gespeichert werden.');
}
try {
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Video', 0, 160, 'UTF-8');
$row = ['id' => $id, 'title' => $title, 'mime' => $mime, 'bytes' => (int) filesize($path), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
$this->db->insert('video', $row);
} catch (\Throwable $error) {
unlink($path);
throw $error;
}
return $this->present($row);
}
public function update(string $id, array $data): array
{
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
throw new \InvalidArgumentException('Titel (max. 160 Bytes) und Version prüfen.');
}
$count = $this->db->executeStatement('UPDATE video SET title = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], (int) $data['archived'], $id, $data['version']]);
if (1 !== $count) {
throw new StaleWrite();
}
return $this->find($id) ?? throw new StaleWrite();
}
/** Die Datei eines Videos, oder null. Archivierte werden weiter ausgeliefert, wie Bilder. */
public function file(string $id, string $extension): ?string
{
$item = $this->find($id);
if (null === $item || $extension !== self::EXTENSIONS[$item['mime']]) {
return null;
}
$path = $this->path($id, $extension);
return is_file($path) ? $path : null;
}
/**
* Der Typ nach den ersten Bytes. MP4 trägt ab Byte 4 das Kästchen „ftyp“, WebM beginnt mit dem
* EBML-Kopf und nennt seinen Dokumenttyp gleich danach. MOV hat auch „ftyp“, aber die Marke
* „qt“ - das spielen nicht alle Browser ab, also wird es abgewiesen statt still zu scheitern.
*/
private function mimeOf(string $path): ?string
{
$head = (string) file_get_contents($path, false, null, 0, 4096);
if ('ftyp' === substr($head, 4, 4) && 'qt ' !== substr($head, 8, 4)) {
return 'video/mp4';
}
return str_starts_with($head, "\x1A\x45\xDF\xA3") && str_contains($head, 'webm') ? 'video/webm' : null;
}
private function path(string $id, string $extension): string
{
return $this->directory.'/'.$id.'.'.$extension;
}
private function present(array $row): array
{
return [
'id' => $row['id'],
'title' => $row['title'],
'mime' => $row['mime'],
'bytes' => (int) $row['bytes'],
'archived' => (bool) $row['archived'],
'version' => (int) $row['version'],
'created_at' => $row['created_at'],
'src' => '/media/'.$row['id'].'.'.self::EXTENSIONS[$row['mime']],
];
}
}