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)) {