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) {
|
||||
|
||||
Reference in new issue
Block a user