Cover-Spielerfoto: Varianten statt 12-MB-Original (iOS Safari)
Das Cover blieb am iPhone leer, während Chrome am Desktop es zeigte. Ursache war kein fehlendes Bild: das freigestellte Spielerfoto hatte 2584x5093 Pixel und 12,3 MB. iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt — hier gut 53 MB, und auf einer Ausgabe liegen 86 Bilder. Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa 238x470 CSS-Pixel. Es gibt jetzt 320/640/1000 als PNG mit erhaltener Transparenz (0,2 / 0,8 / 1,9 MB); sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte Variante. Das Original ist gelöscht. Dafür kann resize_image_variants() nun Alpha erhalten ($alpha = true schreibt PNG statt JPG) und die img-Komponente eine Endung ($image['ext']). Der Default bleibt JPG, weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern. bin/stadionzeitung-add.php erzeugt die Varianten künftig selbst. Dort stand "unverändert kopieren, Felix liefert bereits ein web-taugliches Foto" — genau diese Annahme ist gerissen. Außerdem: smtp.password stand in der Produktiv-Config noch auf dem Platzhalter POSTFACH_PASSWORT (von preflight auf dem Server gefunden, einziger harter Fehler). Übernommen wurde das Passwort aus der Dev-Config, mit dem der Versand am 27.07.2026 verifiziert wurde; Host, Postfach und Empfänger sind identisch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
This commit is contained in:
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Responsives Bild aus Varianten-Set ODER Einzeldatei. Props:
|
* Responsives Bild aus Varianten-Set ODER Einzeldatei. Props:
|
||||||
* $image array{base: string, widths: int[], alt: string} — Varianten <base>-<w>.jpg
|
* $image array{base: string, widths: int[], alt: string, ext?: string} — Varianten
|
||||||
|
* <base>-<w>.<ext>, Default-Endung jpg. ext: 'png' für freigestellte Motive,
|
||||||
|
* deren Transparenz JPG zerstören würde (Spielerfoto auf dem Cover).
|
||||||
* ODER array{src: string, width: int, height: int, alt: string} — Originaldatei 1:1
|
* ODER array{src: string, width: int, height: int, alt: string} — Originaldatei 1:1
|
||||||
* $sizes string (optional, default '100vw')
|
* $sizes string (optional, default '100vw')
|
||||||
* $eager bool (optional) — above the fold: eager + fetchpriority high
|
* $eager bool (optional) — above the fold: eager + fetchpriority high
|
||||||
@@ -24,15 +26,16 @@ if (!empty($image['src'])): ?>
|
|||||||
|
|
||||||
$widths = $image['widths'];
|
$widths = $image['widths'];
|
||||||
$largest = max($widths);
|
$largest = max($widths);
|
||||||
|
$ext = strtolower((string) ($image['ext'] ?? 'jpg'));
|
||||||
$srcset = implode(', ', array_map(
|
$srcset = implode(', ', array_map(
|
||||||
static fn (int $w): string => asset("{$image['base']}-{$w}.jpg") . " {$w}w",
|
static fn (int $w): string => asset("{$image['base']}-{$w}.{$ext}") . " {$w}w",
|
||||||
$widths
|
$widths
|
||||||
));
|
));
|
||||||
|
|
||||||
// Maße der größten Variante für width/height (CLS-Vermeidung).
|
// Maße der größten Variante für width/height (CLS-Vermeidung).
|
||||||
[$w, $h] = (function () use ($image, $largest): array {
|
[$w, $h] = (function () use ($image, $largest, $ext): array {
|
||||||
static $dims = [];
|
static $dims = [];
|
||||||
$file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.jpg";
|
$file = PUBLIC_PATH . "/assets/{$image['base']}-{$largest}.{$ext}";
|
||||||
$key = $file;
|
$key = $file;
|
||||||
if (!isset($dims[$key])) {
|
if (!isset($dims[$key])) {
|
||||||
$size = is_file($file) ? (getimagesize($file) ?: [0, 0]) : [0, 0];
|
$size = is_file($file) ? (getimagesize($file) ?: [0, 0]) : [0, 0];
|
||||||
@@ -42,7 +45,7 @@ $srcset = implode(', ', array_map(
|
|||||||
})();
|
})();
|
||||||
?>
|
?>
|
||||||
<img<?= $class !== '' ? ' class="' . e($class) . '"' : '' ?>
|
<img<?= $class !== '' ? ' class="' . e($class) . '"' : '' ?>
|
||||||
src="<?= e(asset("{$image['base']}-{$largest}.jpg")) ?>"
|
src="<?= e(asset("{$image['base']}-{$largest}.{$ext}")) ?>"
|
||||||
srcset="<?= e($srcset) ?>"
|
srcset="<?= e($srcset) ?>"
|
||||||
sizes="<?= e($sizes) ?>"
|
sizes="<?= e($sizes) ?>"
|
||||||
alt="<?= e($image['alt']) ?>"
|
alt="<?= e($image['alt']) ?>"
|
||||||
|
|||||||
@@ -81,7 +81,24 @@ $teaser = trim((string) ($issue['interview']['teaser'] ?? ''));
|
|||||||
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
|
<?php component('img', ['image' => $heroImage, 'sizes' => '480px', 'class' => 'stadionzeitung-cover__bg']); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($playerImage)): ?>
|
<?php if (!empty($playerImage)): ?>
|
||||||
<img class="stadionzeitung-cover__player" src="<?= e(asset($playerImage)) ?>" alt="" loading="lazy">
|
<?php
|
||||||
|
/* Varianten-Set (base/widths/ext=png) statt der Originaldatei. Das Original ist
|
||||||
|
ein Freisteller mit 2584x5093 Pixeln und 12 MB: iOS Safari verwirft Bilder,
|
||||||
|
deren dekodierte Größe sein Budget übersteigt (hier gut 53 MB), und auf einer
|
||||||
|
Ausgabe liegen rund 86 Bilder — das Cover blieb am iPhone leer, während Chrome
|
||||||
|
am Desktop es zeigte (31.07.2026). Dargestellt wird es mit 98cqi Höhe, bei
|
||||||
|
480px Seitenbreite also etwa 238x470 CSS-Pixel; die 1000er Variante deckt
|
||||||
|
damit auch Druck und dreifache Pixeldichte ab.
|
||||||
|
sizes nennt die echte Breite, sonst zöge ein Handy mit DPR 3 die größte. */
|
||||||
|
$playerBild = is_array($playerImage)
|
||||||
|
? $playerImage
|
||||||
|
: ['src' => $playerImage, 'width' => 0, 'height' => 0, 'alt' => ''];
|
||||||
|
?>
|
||||||
|
<?php component('img', [
|
||||||
|
'image' => $playerBild,
|
||||||
|
'class' => 'stadionzeitung-cover__player',
|
||||||
|
'sizes' => '(max-width: 600px) 50vw, 240px',
|
||||||
|
]); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="stadionzeitung-cover__inner">
|
<div class="stadionzeitung-cover__inner">
|
||||||
<header class="stadionzeitung-cover__masthead">
|
<header class="stadionzeitung-cover__masthead">
|
||||||
|
|||||||
@@ -208,8 +208,15 @@ function img_intrinsic_attrs(string $rel): string
|
|||||||
* Kernlogik von bin/img-resize.php (CLI-Wrapper) und bin/stadionzeitung-add.php
|
* Kernlogik von bin/img-resize.php (CLI-Wrapper) und bin/stadionzeitung-add.php
|
||||||
* (ruft das direkt im Loop auf, ohne 22× einen Subprozess zu starten).
|
* (ruft das direkt im Loop auf, ohne 22× einen Subprozess zu starten).
|
||||||
* Rückgabe: [['width' => int, 'height' => int, 'file' => string, 'bytes' => int], …].
|
* Rückgabe: [['width' => int, 'height' => int, 'file' => string, 'bytes' => int], …].
|
||||||
|
*
|
||||||
|
* $alpha = true schreibt stattdessen <destBase>-<breite>.png und erhält die
|
||||||
|
* Transparenz — nötig für freigestellte Motive (Spielerfoto auf dem
|
||||||
|
* Stadionzeitungs-Cover), bei denen JPG die Freistellung gegen Schwarz wegrechnen
|
||||||
|
* würde. $quality gilt dann nicht (PNG ist verlustfrei). Der Default bleibt JPG,
|
||||||
|
* weil bestehende Aufrufer PNG-Quellen absichtlich zu JPG verkleinern und deren
|
||||||
|
* Pfade in den data/*.json auf .jpg zeigen.
|
||||||
*/
|
*/
|
||||||
function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78): array
|
function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78, bool $alpha = false): array
|
||||||
{
|
{
|
||||||
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
|
$image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
|
||||||
'jpg', 'jpeg' => imagecreatefromjpeg($src),
|
'jpg', 'jpeg' => imagecreatefromjpeg($src),
|
||||||
@@ -229,9 +236,25 @@ function resize_image_variants(string $src, string $destBase, array $widths, int
|
|||||||
$w = min((int) $width, $srcW); // nie hochskalieren
|
$w = min((int) $width, $srcW); // nie hochskalieren
|
||||||
$h = (int) round($srcH * $w / $srcW);
|
$h = (int) round($srcH * $w / $srcW);
|
||||||
$frame = imagecreatetruecolor($w, $h);
|
$frame = imagecreatetruecolor($w, $h);
|
||||||
|
|
||||||
|
if ($alpha) {
|
||||||
|
// Transparenz retten: ohne Blending kopieren (sonst rechnet GD den
|
||||||
|
// Alphakanal gegen Schwarz weg) und die Fläche vorher transparent füllen.
|
||||||
|
imagealphablending($frame, false);
|
||||||
|
imagesavealpha($frame, true);
|
||||||
|
$leer = (int) imagecolorallocatealpha($frame, 0, 0, 0, 127);
|
||||||
|
imagefilledrectangle($frame, 0, 0, $w, $h, $leer);
|
||||||
|
}
|
||||||
|
|
||||||
imagecopyresampled($frame, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
|
imagecopyresampled($frame, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
|
||||||
$file = "{$destBase}-{$w}.jpg";
|
|
||||||
imagejpeg($frame, $file, $quality);
|
if ($alpha) {
|
||||||
|
$file = "{$destBase}-{$w}.png";
|
||||||
|
imagepng($frame, $file, 9);
|
||||||
|
} else {
|
||||||
|
$file = "{$destBase}-{$w}.jpg";
|
||||||
|
imagejpeg($frame, $file, $quality);
|
||||||
|
}
|
||||||
$out[] = ['width' => $w, 'height' => $h, 'file' => $file, 'bytes' => (int) filesize($file)];
|
$out[] = ['width' => $w, 'height' => $h, 'file' => $file, 'bytes' => (int) filesize($file)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -481,23 +481,49 @@ if ($total % 4 !== 0) {
|
|||||||
$log("Seitenzahl {$total} ist kein Vielfaches von 4 — die Druckfassung füllt mit " . (4 - $total % 4) . ' weißen Seiten auf. Besser gemeinsam lösen (z. B. eine Anzeige mehr).', 'WARN');
|
$log("Seitenzahl {$total} ist kein Vielfaches von 4 — die Druckfassung füllt mit " . (4 - $total % 4) . ' weißen Seiten auf. Besser gemeinsam lösen (z. B. eine Anzeige mehr).', 'WARN');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Spielerfoto fürs Cover: unverändert kopieren (kein resize_image_variants(),
|
// --- Spielerfoto fürs Cover: zuschneiden UND verkleinern. ---
|
||||||
// das würde über imagejpeg() die Transparenz zerstören — Felix liefert bereits
|
// Hier stand früher „unverändert kopieren, Felix liefert bereits ein web-taugliches
|
||||||
// ein web-taugliches Foto). Optional, kein Fehlschlag bei Fehlen. ---
|
// Foto" — die Annahme stimmte nicht. Das Hutschdorf-Foto hatte 2584x5093 Pixel und
|
||||||
|
// 12 MB; iOS Safari verwirft Bilder, deren dekodierte Größe sein Budget übersteigt
|
||||||
|
// (hier gut 53 MB), und auf einer Ausgabe liegen rund 86 Bilder. Ergebnis: das Cover
|
||||||
|
// blieb am iPhone leer, während Chrome am Desktop es zeigte (31.07.2026).
|
||||||
|
// Dargestellt wird das Foto mit 98cqi Höhe, bei 480px Seitenbreite also etwa
|
||||||
|
// 238x470 CSS-Pixel — die 1000er Variante deckt Druck und dreifache Pixeldichte ab.
|
||||||
|
// resize_image_variants() erhält bei $alpha = true die Freistellung (PNG statt JPG).
|
||||||
|
// Optional, kein Fehlschlag bei Fehlen.
|
||||||
$playerImage = null;
|
$playerImage = null;
|
||||||
if ($spieler !== null) {
|
if ($spieler !== null) {
|
||||||
$ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION));
|
||||||
$playerDest = "{$destDir}/spieler.{$ext}";
|
$istPng = $ext === 'png';
|
||||||
// PNG: auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
|
// PNG: erst auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
|
||||||
// leerem Rand aus dem Export-Tool). Andere Formate (kein Alpha) unverändert.
|
// leerem Rand aus dem Export-Tool), dann verkleinern. Der Zuschnitt geht in eine
|
||||||
$copied = $ext === 'png'
|
// Zwischendatei, damit das riesige Original nicht im Ausgaben-Ordner landet.
|
||||||
? crop_transparent_png_to_content($spieler, $playerDest)
|
$quelle = $spieler;
|
||||||
: copy($spieler, $playerDest);
|
$tmpCrop = "{$destDir}/spieler-zuschnitt.png";
|
||||||
if ($copied) {
|
if ($istPng && crop_transparent_png_to_content($spieler, $tmpCrop)) {
|
||||||
$playerImage = "img/stadionzeitung/{$slug}/spieler.{$ext}";
|
$quelle = $tmpCrop;
|
||||||
$log('Spielerfoto übernommen' . ($ext === 'png' ? ' (auf Inhalt zugeschnitten).' : ' (unverändert kopiert).'));
|
}
|
||||||
} else {
|
try {
|
||||||
$log("Spielerfoto konnte nicht kopiert werden: {$spieler}", 'WARN');
|
$varianten = resize_image_variants($quelle, "{$destDir}/spieler", [320, 640, 1000], 82, $istPng);
|
||||||
|
$playerImage = [
|
||||||
|
'base' => "img/stadionzeitung/{$slug}/spieler",
|
||||||
|
'widths' => array_map(static fn (array $v): int => $v['width'], $varianten),
|
||||||
|
'ext' => $istPng ? 'png' : 'jpg',
|
||||||
|
'alt' => '',
|
||||||
|
];
|
||||||
|
$groesse = array_sum(array_map(static fn (array $v): int => $v['bytes'], $varianten));
|
||||||
|
$log(sprintf(
|
||||||
|
'Spielerfoto übernommen: %d Varianten%s, zusammen %.1f MB (Original %.1f MB).',
|
||||||
|
count($varianten),
|
||||||
|
$istPng ? ' mit Transparenz, auf Inhalt zugeschnitten' : '',
|
||||||
|
$groesse / 1048576,
|
||||||
|
filesize($spieler) / 1048576
|
||||||
|
));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$log("Spielerfoto konnte nicht verarbeitet werden: {$spieler}", 'WARN');
|
||||||
|
}
|
||||||
|
if (is_file($tmpCrop)) {
|
||||||
|
unlink($tmpCrop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 840 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 MiB |
Reference in New Issue
Block a user