Serve images without PHP, add the 720 and 1280 steps, trim two hot paths
- Image widths are 480, 720, 960, 1280, 1600 and 2400. A phone needed 721 pixels and got 960. Existing images receive the new steps from a task; stored pages pick them up at render time, so no document is rewritten. Mobile backgrounds use 720. - A variant served once is mirrored to public/media, and a rewrite rule lets the web server deliver it from there. var/media stays the source. - The hero preload follows the image each device really shows. - The post navigation reads the order and then two posts instead of up to 500 documents. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
dca2665138
commit
7d0369bada
32 files changed
+550
-57
No files matched your search
@@ -11,8 +11,34 @@ final class MediaLibrary
|
||||
/** Enough to fill a screen, few enough that the browser does not decode a hundred images. */
|
||||
public const PER_PAGE = 24;
|
||||
|
||||
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
|
||||
public const WIDTHS = [480, 960, 1600, 2400];
|
||||
/**
|
||||
* The widths every upload is rendered to, in WebP. A source narrower than a step is skipped.
|
||||
*
|
||||
* 720 und 1280 kamen mit 1.42.0 dazu: Zwischen 480 und 960 lag genau der Bereich, den ein
|
||||
* Telefon braucht (412 px Breite mal 1,75 sind 721 px) - es lud deshalb die 960er Fassung mit
|
||||
* fast doppelt so vielen Bildpunkten. Vorhandene Bilder bekommen die neuen Stufen über
|
||||
* fillVariants() nachgereicht.
|
||||
*/
|
||||
public const WIDTHS = [480, 720, 960, 1280, 1600, 2400];
|
||||
|
||||
/** WebP-Qualität aller Varianten. */
|
||||
private const QUALITY = 82;
|
||||
|
||||
/**
|
||||
* Die Breiten, in denen ein Bild dieser Breite vorliegt: jede Stufe darunter und es selbst.
|
||||
*
|
||||
* Eine Stufe knapp unter der eigenen Breite entfällt - neben einem 760 px breiten Bild ist
|
||||
* eine Fassung mit 720 px eine zweite Datei ohne Nutzen.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function steps(int $width): array
|
||||
{
|
||||
$steps = array_filter(self::WIDTHS, static fn (int $step): bool => $step <= $width * 0.9);
|
||||
$steps[] = min($width, max(self::WIDTHS));
|
||||
|
||||
return array_values(array_unique($steps));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bilder, die in dieser Anfrage schon gesucht wurden.
|
||||
@@ -165,7 +191,7 @@ final class MediaLibrary
|
||||
// Four widths, and never larger than the original: 480 for a phone, 960 for a tablet
|
||||
// or a column, 1600 for a full width section, 2400 for a full bleed image on a large
|
||||
// display. A smaller source simply produces fewer variants.
|
||||
foreach (array_unique(array_map(static fn (int $step): int => min($step, $width), self::WIDTHS)) as $targetWidth) {
|
||||
foreach (self::steps($width) as $targetWidth) {
|
||||
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
|
||||
$target = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
try {
|
||||
@@ -174,7 +200,7 @@ final class MediaLibrary
|
||||
imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
|
||||
$path = $this->directory.'/'.$id.'-'.$targetWidth.'.webp';
|
||||
$paths[] = $path;
|
||||
if (!imagewebp($target, $path, 82) || !is_file($path) || !filesize($path)) {
|
||||
if (!imagewebp($target, $path, self::QUALITY) || !is_file($path) || !filesize($path)) {
|
||||
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
|
||||
}
|
||||
$variants[] = ['src' => '/media/'.$id.'/'.$targetWidth, 'width' => $targetWidth, 'height' => $targetHeight, 'bytes' => filesize($path)];
|
||||
@@ -201,6 +227,84 @@ final class MediaLibrary
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reicht vorhandenen Bildern die Stufen nach, die es bei ihrem Upload noch nicht gab.
|
||||
*
|
||||
* Das Original wird nicht aufbewahrt; gerechnet wird aus der größten vorhandenen Variante.
|
||||
* Bestehende Varianten bleiben unangetastet, ihre Adressen stehen in gespeicherten Seiten.
|
||||
* Die Schreibversion des Bildes ändert sich nicht - ein offenes Formular mit Titel und
|
||||
* Alternativtext soll daran nicht scheitern.
|
||||
*
|
||||
* Arbeitet portionsweise, weil ein Lauf an einem PHP-Prozess mit fremdem Zeitlimit hängt.
|
||||
*
|
||||
* @return array{done: int, left: int} ergänzte Bilder und solche, die noch warten
|
||||
*/
|
||||
public function fillVariants(int $limit = 8, float $seconds = 8.0): array
|
||||
{
|
||||
$start = microtime(true);
|
||||
$done = 0;
|
||||
$left = 0;
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, variants FROM media ORDER BY created_at DESC, id DESC') as $row) {
|
||||
$variants = json_decode($row['variants'], true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($variants) || [] === $variants) {
|
||||
continue;
|
||||
}
|
||||
usort($variants, static fn (array $a, array $b): int => $a['width'] <=> $b['width']);
|
||||
$largest = end($variants);
|
||||
$missing = array_diff(self::steps((int) $largest['width']), array_column($variants, 'width'));
|
||||
$file = $this->directory.'/'.$row['id'].'-'.$largest['width'].'.webp';
|
||||
if ([] === $missing || !is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
if ($done >= $limit || microtime(true) - $start > $seconds || !function_exists('imagecreatefromwebp')) {
|
||||
++$left;
|
||||
continue;
|
||||
}
|
||||
$memoryLimit = ini_parse_quantity((string) ini_get('memory_limit'));
|
||||
if ($memoryLimit > 0 && memory_get_usage(true) + $largest['width'] * $largest['height'] * 12 + 16777216 > $memoryLimit) {
|
||||
// Passt nicht in den Arbeitsspeicher dieses Hostings: Das Bild behält seine Stufen.
|
||||
continue;
|
||||
}
|
||||
$source = @imagecreatefromwebp($file);
|
||||
if (!$source) {
|
||||
continue;
|
||||
}
|
||||
$written = [];
|
||||
try {
|
||||
foreach ($missing as $width) {
|
||||
$height = max(1, (int) round($largest['height'] * $width / $largest['width']));
|
||||
$target = imagecreatetruecolor($width, $height);
|
||||
try {
|
||||
imagealphablending($target, false);
|
||||
imagesavealpha($target, true);
|
||||
imagecopyresampled($target, $source, 0, 0, 0, 0, $width, $height, (int) $largest['width'], (int) $largest['height']);
|
||||
$path = $this->directory.'/'.$row['id'].'-'.$width.'.webp';
|
||||
$written[] = $path;
|
||||
if (!imagewebp($target, $path, self::QUALITY) || !is_file($path) || !filesize($path)) {
|
||||
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
|
||||
}
|
||||
$variants[] = ['src' => '/media/'.$row['id'].'/'.$width, 'width' => $width, 'height' => $height, 'bytes' => filesize($path)];
|
||||
} finally {
|
||||
imagedestroy($target);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $error) {
|
||||
foreach ($written as $path) {
|
||||
@unlink($path);
|
||||
}
|
||||
throw $error;
|
||||
} finally {
|
||||
imagedestroy($source);
|
||||
}
|
||||
usort($variants, static fn (array $a, array $b): int => $a['width'] <=> $b['width']);
|
||||
$this->db->executeStatement('UPDATE media SET variants = ? WHERE id = ?', [json_encode($variants, JSON_THROW_ON_ERROR), $row['id']]);
|
||||
++$done;
|
||||
}
|
||||
$this->gemerkt = [];
|
||||
|
||||
return ['done' => $done, 'left' => $left];
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): array
|
||||
{
|
||||
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_string($data['alt'] ?? null) || strlen($data['alt']) > 300 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* Legt Bildvarianten dorthin, wo der Webserver sie ohne PHP ausliefern kann.
|
||||
*
|
||||
* Jedes Bild einer Seite war eine Anfrage an die Anwendung: Kernel starten, Datenbank fragen,
|
||||
* Datei durchreichen. Gemessen auf kant-it.de (10.11.2026) rund 250 ms bis zum ersten Byte, eine
|
||||
* statische Datei daneben rund 100 ms - und jede dieser Anfragen belegt einen der wenigen
|
||||
* PHP-Prozesse, die ein geteiltes Hosting gleichzeitig hergibt.
|
||||
*
|
||||
* Der Medienordner unter var/ bleibt die Quelle; Sicherung, Website-Paket und Wiederherstellung
|
||||
* kennen nur ihn. Dieser Ordner ist ein Abbild, das sich von selbst füllt: Beim ersten Abruf über
|
||||
* PHP landet die Variante hier, alle weiteren beantwortet der Webserver (Regel in
|
||||
* public/.htaccess). Fehlt das Abbild oder lässt es sich nicht schreiben, bleibt alles beim Alten.
|
||||
*
|
||||
* Die Adressen der Bilder ändern sich nicht. Was hier liegt, war auch vorher für jeden abrufbar,
|
||||
* der die Adresse kennt - die Auslieferung über PHP prüft keine Anmeldung.
|
||||
*/
|
||||
final class MediaMirror
|
||||
{
|
||||
/** @param string $directory das Abbild, üblich public/media; leer schaltet es ab */
|
||||
public function __construct(private string $directory, private string $mediaDirectory)
|
||||
{
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return '' !== $this->directory;
|
||||
}
|
||||
|
||||
/** Legt eine Variante ab. Scheitert still: das Abbild ist eine Abkürzung, keine Voraussetzung. */
|
||||
public function publish(string $id, int $width, string $source): void
|
||||
{
|
||||
if (!$this->enabled() || 1 !== preg_match('/^[a-f0-9]{32}$/D', $id) || $width < 1 || $width > 9999) {
|
||||
return;
|
||||
}
|
||||
$folder = $this->directory.'/'.$id;
|
||||
$target = $folder.'/'.$width.'.webp';
|
||||
if (is_file($target)) {
|
||||
return;
|
||||
}
|
||||
if (!is_dir($folder) && !@mkdir($folder, 0o775, true) && !is_dir($folder)) {
|
||||
return;
|
||||
}
|
||||
// Ein harter Verweis kostet keinen zweiten Platz auf der Platte. Wo das Dateisystem ihn
|
||||
// nicht kennt oder der Medienordner woanders liegt, wird kopiert - über eine Zwischendatei,
|
||||
// damit der Webserver nie ein halbes Bild ausliefert.
|
||||
if (@link($source, $target)) {
|
||||
return;
|
||||
}
|
||||
$teil = $target.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
if (@copy($source, $teil) && filesize($source) === filesize($teil) && @rename($teil, $target)) {
|
||||
return;
|
||||
}
|
||||
@unlink($teil);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt aus dem Abbild, was es im Medienordner nicht mehr gibt - nach dem Import eines
|
||||
* Website-Pakets etwa. Ohne das lieferte der Webserver Bilder weiter aus, die die Anwendung
|
||||
* selbst nicht mehr kennt.
|
||||
*
|
||||
* @return int entfernte Dateien
|
||||
*/
|
||||
public function prune(): int
|
||||
{
|
||||
if (!$this->enabled() || !is_dir($this->directory)) {
|
||||
return 0;
|
||||
}
|
||||
$removed = 0;
|
||||
foreach (scandir($this->directory) ?: [] as $id) {
|
||||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $id) || !is_dir($this->directory.'/'.$id)) {
|
||||
continue;
|
||||
}
|
||||
foreach (scandir($this->directory.'/'.$id) ?: [] as $name) {
|
||||
if ('.' === $name || '..' === $name) {
|
||||
continue;
|
||||
}
|
||||
$width = 1 === preg_match('/^(\d{1,4})\.webp$/D', $name, $treffer) ? $treffer[1] : null;
|
||||
if (null === $width || !is_file($this->mediaDirectory.'/'.$id.'-'.$width.'.webp')) {
|
||||
$removed += (int) @unlink($this->directory.'/'.$id.'/'.$name);
|
||||
}
|
||||
}
|
||||
// Gelingt nur, wenn der Ordner leer ist - genau dann soll er weg.
|
||||
@rmdir($this->directory.'/'.$id);
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
}
|
||||
@@ -239,26 +239,37 @@ final class PostRepository
|
||||
[$pageId, 'category'],
|
||||
);
|
||||
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
|
||||
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale, $language)['posts'];
|
||||
// present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse.
|
||||
// Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist.
|
||||
$own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId]));
|
||||
$index = null;
|
||||
foreach ($all as $position => $post) {
|
||||
if ($post['href'] === $own) {
|
||||
$index = $position;
|
||||
break;
|
||||
}
|
||||
// Geholt wird erst nur die Reihenfolge - Kennungen, keine Dokumente - und danach genau
|
||||
// das, was gezeigt wird: die zwei Nachbarn. Vorher lud jeder Aufruf eines Beitrags bis zu
|
||||
// 500 veröffentlichte Fassungen mit vollem Dokument, um zwei Titel zu lesen, und ab dem
|
||||
// 501. Beitrag einer Kategorie gab es gar keine Nachbarn mehr.
|
||||
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL";
|
||||
$params = ['default', $locale];
|
||||
$join = '';
|
||||
if ('' !== $termId) {
|
||||
$join = ' JOIN page_term pt ON pt.page_id = p.id AND pt.term_id = ?';
|
||||
array_unshift($params, $termId);
|
||||
}
|
||||
if (null === $index) {
|
||||
$order = $this->db->fetchFirstColumn('SELECT p.id FROM page p'.$join.' WHERE '.$where.' ORDER BY p.published_at IS NULL, p.published_at DESC, p.path ASC', $params);
|
||||
$index = array_search($pageId, $order, true);
|
||||
if (false === $index) {
|
||||
return ['previous' => null, 'next' => null];
|
||||
}
|
||||
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
|
||||
$wanted = array_filter(['previous' => $order[$index - 1] ?? null, 'next' => $order[$index + 1] ?? null]);
|
||||
if ([] === $wanted) {
|
||||
return ['previous' => null, 'next' => null];
|
||||
}
|
||||
$rows = $this->db->fetchAllAssociativeIndexed(
|
||||
'SELECT p.id AS kennung, p.id, p.path, p.excerpt, p.author, p.featured_media_id, p.published_at, r.document FROM page p'
|
||||
.' JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.id IN ('.implode(',', array_fill(0, count($wanted), '?')).')',
|
||||
array_values($wanted),
|
||||
);
|
||||
$images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows));
|
||||
$terms = $this->termsForMany(array_keys($rows), $language);
|
||||
$post = fn (?string $id): ?array => null !== $id && isset($rows[$id]) ? $this->present($rows[$id], $images, $terms[$id] ?? ['category' => [], 'tag' => []], $language) : null;
|
||||
|
||||
return [
|
||||
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
|
||||
'previous' => $all[$index - 1] ?? null,
|
||||
'next' => $all[$index + 1] ?? null,
|
||||
];
|
||||
return ['previous' => $post($wanted['previous'] ?? null), 'next' => $post($wanted['next'] ?? null)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -488,7 +488,11 @@ final class StyleCompiler
|
||||
*/
|
||||
public static function variantFor(array $variants, string $device): array
|
||||
{
|
||||
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||
// 720 für das Telefon seit 1.42.0: 412 px Breite mal 1,75 sind 721 px. Vorher 960 - mit
|
||||
// fast doppelt so vielen Bildpunkten war der Hintergrund des Heros das, worauf das größte
|
||||
// Element der Seite am längsten wartete. Ein Bild, dem diese Stufe noch fehlt,
|
||||
// bekommt die nächstgrößere, also wie bisher 960.
|
||||
$wanted = ['mobile' => 720, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||
$chosen = $variants[0];
|
||||
foreach ($variants as $variant) {
|
||||
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MediaMirror;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\VideoLibrary;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -18,7 +19,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
final class MediaController extends AbstractController
|
||||
{
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos)
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos, private MediaMirror $mirror)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -380,6 +381,9 @@ final class MediaController extends AbstractController
|
||||
public function serve(string $id, int $width): Response
|
||||
{
|
||||
$path = $this->media->file($id, $width) ?? throw $this->createNotFoundException();
|
||||
// Diese Anfrage kam noch bei PHP an; die nächste nach demselben Bild beantwortet der
|
||||
// Webserver selbst.
|
||||
$this->mirror->publish($id, $width, $path);
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
@@ -323,6 +323,10 @@ final class PageController extends AbstractController
|
||||
$areas['footer'] = $this->partials->expandRepeatedLinks($areas['footer'], $sections, $seen, $pristine);
|
||||
}
|
||||
}
|
||||
// Bilder nennen die Größen, die die Mediathek heute hat - nicht die vom Tag, an dem das
|
||||
// Bild in die Seite kam. So erreichen nachgereichte Stufen jede gespeicherte Seite, ohne
|
||||
// dass ein Dokument umgeschrieben wird.
|
||||
[$page['document'], $areas, $sections] = $this->currentImageSources($page['document'], $areas, $sections);
|
||||
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
|
||||
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
|
||||
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
|
||||
@@ -1042,6 +1046,59 @@ final class PageController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt bei jedem Bild aus der Mediathek die Liste der Größen auf ihren heutigen Stand.
|
||||
*
|
||||
* Angefasst wird nur ein Bild, das seine Kennung trägt und dessen Adresse zu ihr gehört; die
|
||||
* Adresse und die Maße des Bildes selbst bleiben, wie sie gespeichert sind.
|
||||
*
|
||||
* @param array<string, array> $areas
|
||||
* @param array<string, array> $sections
|
||||
*
|
||||
* @return array{0: array, 1: array<string, array>, 2: array<string, array>}
|
||||
*/
|
||||
private function currentImageSources(array $document, array $areas, array $sections): array
|
||||
{
|
||||
$ids = [];
|
||||
$collect = static function (array $blocks) use (&$collect, &$ids): void {
|
||||
foreach ($blocks as $block) {
|
||||
$id = $block['props']['mediaId'] ?? null;
|
||||
if ('image' === ($block['type'] ?? null) && is_string($id) && isset($block['props']['sources'])) {
|
||||
$ids[$id] = true;
|
||||
}
|
||||
$collect($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
foreach ([$document, ...array_values($areas), ...array_values($sections)] as $one) {
|
||||
$collect($one['blocks'] ?? []);
|
||||
}
|
||||
if ([] === $ids) {
|
||||
return [$document, $areas, $sections];
|
||||
}
|
||||
$media = $this->media->findMany(array_keys($ids));
|
||||
$apply = static function (array $blocks) use (&$apply, $media): array {
|
||||
foreach ($blocks as $index => $block) {
|
||||
$id = $block['props']['mediaId'] ?? null;
|
||||
if ('image' === ($block['type'] ?? null) && is_string($id) && isset($block['props']['sources'], $media[$id])
|
||||
&& str_starts_with((string) ($block['props']['src'] ?? ''), '/media/'.$id.'/') && [] !== $media[$id]['variants']) {
|
||||
$blocks[$index]['props']['sources'] = array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $media[$id]['variants']);
|
||||
}
|
||||
if (isset($block['children'])) {
|
||||
$blocks[$index]['children'] = $apply($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
};
|
||||
$one = static function (array $doc) use ($apply): array {
|
||||
$doc['blocks'] = $apply($doc['blocks'] ?? []);
|
||||
|
||||
return $doc;
|
||||
};
|
||||
|
||||
return [$one($document), array_map($one, $areas), array_map($one, $sections)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The first image of the page, in the order it is rendered.
|
||||
*
|
||||
@@ -1069,23 +1126,32 @@ final class PageController extends AbstractController
|
||||
// obere. Sonst der erste Abschnitt des Dokuments.
|
||||
foreach ([$header, $document] as $kandidat) {
|
||||
$block = ($kandidat['blocks'] ?? [])[0] ?? null;
|
||||
$id = $block['style']['desktop']['backgroundImage'] ?? null;
|
||||
if (!is_string($id) || '' === $id) {
|
||||
continue;
|
||||
}
|
||||
$item = $this->media->find($id);
|
||||
if (null === $item || [] === ($item['variants'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
// Je Gerät das Bild, das die Regel dort wirklich nimmt: Tablet erbt vom Desktop, das
|
||||
// Telefon vom Tablet - dieselbe Vererbung wie im Compiler. Vorher zählte nur der
|
||||
// Desktop-Wert: Ein Telefon mit eigenem oder abgeschaltetem Hintergrund lud dann mit
|
||||
// hoher Priorität ein Bild, das es nie zeigt, gegen sein eigentliches größtes Element.
|
||||
$sichtbar = ['all' => [], 'desktop' => ['tablet', 'mobile'], 'tablet' => ['desktop', 'mobile'], 'mobile' => ['desktop', 'tablet'], 'hide-desktop' => ['desktop'], 'hide-tablet' => ['tablet'], 'hide-mobile' => ['mobile'], 'hidden' => ['desktop', 'tablet', 'mobile']];
|
||||
$aus = $sichtbar[$block['layout']['visibility'] ?? 'all'] ?? [];
|
||||
$verweise = [];
|
||||
$geerbt = null;
|
||||
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
|
||||
$id = $block['style'][$device]['backgroundImage'] ?? $geerbt;
|
||||
$geerbt = $id;
|
||||
if (!is_string($id) || '' === $id || 'none' === $id || in_array($device, $aus, true)) {
|
||||
continue;
|
||||
}
|
||||
$item = $this->media->find($id);
|
||||
if (null === $item || [] === ($item['variants'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
$verweise[] = [
|
||||
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
|
||||
'media' => $media,
|
||||
];
|
||||
}
|
||||
|
||||
return $verweise;
|
||||
if ([] !== $verweise) {
|
||||
return $verweise;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Ops;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaMirror;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
@@ -49,6 +50,7 @@ final class SitePackage
|
||||
private Updater $updater,
|
||||
private string $root,
|
||||
private string $mediaDirectory,
|
||||
private ?MediaMirror $mirror = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -231,6 +233,8 @@ final class SitePackage
|
||||
return $fail();
|
||||
}
|
||||
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
|
||||
// Was der Webserver noch aus dem Abbild lieferte, gehört zur Website von vorher.
|
||||
$this->mirror?->prune();
|
||||
} finally {
|
||||
if (!$inWartung) {
|
||||
$this->updater->maintenance(false);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Content\MediaMirror;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Scheduler;
|
||||
use App\Ops\Updater;
|
||||
@@ -26,7 +27,7 @@ final class CleanupTask implements Task
|
||||
/** So viele Zeilen bleiben in den Protokollen stehen. */
|
||||
private const KEEP_LINES = 200;
|
||||
|
||||
public function __construct(private string $projectDir)
|
||||
public function __construct(private string $projectDir, private ?MediaMirror $mirror = null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -57,12 +58,16 @@ final class CleanupTask implements Task
|
||||
{
|
||||
$limit = time() - self::KEEP_DAYS * 86400;
|
||||
|
||||
return sprintf(
|
||||
$bericht = sprintf(
|
||||
'%d Sitzungsdatei(en), %d Update-Ordner entfernt, %d Protokoll(e) gekürzt.',
|
||||
$this->sessions($limit),
|
||||
$this->updateFolders($limit),
|
||||
$this->logs(),
|
||||
);
|
||||
// Bildvarianten im öffentlichen Abbild, deren Bild es nicht mehr gibt.
|
||||
$verwaist = $this->mirror?->prune() ?? 0;
|
||||
|
||||
return 0 === $verwaist ? $bericht : $bericht.sprintf(' %d verwaiste Bilddatei(en) aus public/media entfernt.', $verwaist);
|
||||
}
|
||||
|
||||
/** Sitzungsdateien. Der Garbage Collector von PHP ist auf vielen Hostings abgeschaltet. */
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
|
||||
/**
|
||||
* Reicht vorhandenen Bildern Größen nach, die es bei ihrem Upload noch nicht gab.
|
||||
*
|
||||
* Mit 1.42.0 kamen die Stufen 720 und 1280 dazu. Neue Uploads bekommen sie sofort; für alles,
|
||||
* was schon in der Mediathek liegt, rechnet diese Aufgabe sie portionsweise aus der größten
|
||||
* vorhandenen Variante. Ist nichts mehr offen, kostet ein Lauf eine Abfrage.
|
||||
*/
|
||||
final class ImageVariantsTask implements Task
|
||||
{
|
||||
public function __construct(private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'image-variants';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Bildgrößen ergänzen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Ohne sie laden Telefone bei älteren Bildern eine größere Fassung als nötig. Angezeigt wird trotzdem alles.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 900;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$ergebnis = $this->media->fillVariants();
|
||||
if (0 === $ergebnis['done'] && 0 === $ergebnis['left']) {
|
||||
return 'Alle Bilder haben alle Größen.';
|
||||
}
|
||||
|
||||
return sprintf('%d Bild(er) ergänzt, %d warten auf den nächsten Lauf.', $ergebnis['done'], $ergebnis['left']);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user