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) {
+5 -1
View File
@@ -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'"]);
}
+76 -10
View File
@@ -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 [];
+4
View File
@@ -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);
+7 -2
View File
@@ -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. */
+49
View File
@@ -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']);
}
}