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.');