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:
kantarciandClaude Fable 5.2 committed 2026-11-10 12:24:30 +01:00
1 parent dca2665138
commit 7d0369bada
32 files changed
+550 -57

No files matched your search

+108 -4
View File
@@ -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)) {
+92
View File
@@ -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;
}
}
+27 -16
View File
@@ -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)];
}
/**
+5 -1
View File
@@ -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) {