Files
xamxam/scripts/generate-cover-webp.php
T
Pontoporeia fc66b37801 feat(home): htmx lazy-load cover images
Replace the eager <img> on the home page with an htmx placeholder <figure>
that fetches a /cover-fragment endpoint when it scrolls into view
(hx-trigger="revealed"), so heavy cover bytes load only on demand.
Add spinner + settle-fade transition CSS, and load htmx.min.js on home.
2026-09-18 16:26:36 +02:00

153 lines
5.0 KiB
PHP

#!/usr/bin/env php
<?php
/**
* generate-cover-webp.php — Backfill small WebP cover thumbnails.
*
* Covers are uploaded as full-resolution PNG/JPEG but rendered into a small
* 4:3 card on the public home grid. Serving the original is wasteful — a single
* cover can be ~15 MB. This scans every `*_COUVERTURE.*` file under the storage
* root and, for any that lacks a `.webp` sibling, downscales + re-encodes it in
* one `cwebp -resize` pass (typically ~95%+ smaller, to ~800px wide @ q80).
*
* Idempotent: skips covers that already have a `.webp` sibling. Safe to re-run.
*
* Usage:
* php scripts/generate-cover-webp.php # dry-run (list pending)
* php scripts/generate-cover-webp.php --no-dry-run # actually generate
* COVER_WEBP_WIDTH=1000 COVER_WEBP_QUALITY=85 php scripts/generate-cover-webp.php --no-dry-run
*
* Requires `cwebp` on PATH (see docs/environment.md). Exit code 0 on success,
* 1 on error.
*/
declare(strict_types=1);
// Resolve APP_ROOT robustly. In production the app code is deployed flat under
// /var/www/xamxam/ (src/, storage/, templates/ at the root, no app/ subdir).
$prodRoot = '/var/www/xamxam';
if (is_dir($prodRoot . '/src') && is_file($prodRoot . '/src/Database.php')) {
define('APP_ROOT', $prodRoot);
$storageRoot = $prodRoot . '/storage';
} else {
define('APP_ROOT', dirname(__DIR__) . '/app');
$storageRoot = APP_ROOT . '/storage';
}
$dryRun = !in_array('--no-dry-run', $argv, true);
// Tuning (mirrors ThesisFileHandler::generateCoverWebp defaults).
$maxWidth = (int) (getenv('COVER_WEBP_WIDTH') ?: 800);
$quality = (int) (getenv('COVER_WEBP_QUALITY') ?: 80);
if ($maxWidth < 1) {
$maxWidth = 800;
}
if ($quality < 0 || $quality > 100) {
$quality = 80;
}
if (!is_dir($storageRoot) || !is_readable($storageRoot)) {
fwrite(STDERR, "[generate-cover-webp] storage root not readable: $storageRoot\n");
exit(1);
}
if (!function_exists('exec')) {
fwrite(STDERR, "[generate-cover-webp] exec() is disabled; cannot run cwebp.\n");
exit(1);
}
// Recursively collect cover files.
$covers = [];
$rii = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($storageRoot, FilesystemIterator::SKIP_DOTS)
);
foreach ($rii as $entry) {
if (!$entry->isFile()) {
continue;
}
$name = $entry->getFilename();
// Only source covers (original upload) count as inputs; the generated
// `.webp` sidecars must NOT be re-scanned as sources.
if (!preg_match('/_COUVERTURE\.[a-z0-9]+$/i', $name) || str_ends_with($name, '.webp')) {
continue;
}
$covers[] = $entry->getPathname();
}
sort($covers);
$pending = [];
$existing = 0;
foreach ($covers as $sourcePath) {
$webpPath = preg_replace('/\.[a-z0-9]+$/i', '.webp', $sourcePath);
if ($webpPath !== null && is_file($webpPath)) {
$existing++;
continue;
}
$pending[] = $sourcePath;
}
$skipped = 0;
if ($dryRun) {
foreach ($pending as $p) {
printf("DRY-RUN → %s (%d bytes)\n", $p, (int) filesize($p));
}
printf(
"Found %d cover file(s): %d already have a .webp thumbnail, %d pending.\n",
count($covers),
$existing,
count($pending)
);
echo "Re-run with --no-dry-run to generate them.\n";
exit(0);
}
foreach ($pending as $sourcePath) {
$targetPath = preg_replace('/\.[a-z0-9]+$/i', '.webp', $sourcePath);
$cmd = sprintf(
'cwebp -quiet -resize %d 0 -q %d %s -o %s 2>/dev/null',
$maxWidth,
$quality,
escapeshellarg((string) $sourcePath),
escapeshellarg((string) $targetPath)
);
exec($cmd, $_, $code);
$ok = $code === 0 && $targetPath !== null && is_file($targetPath);
// cwebp can't read CMYK JPEGs; fall back to GD (php-gd) which handles
// CMYK->RGB on decode, same resize + quality.
if (!$ok && function_exists('imagecreatefromjpeg') && function_exists('imagewebp')) {
$im = @imagecreatefromjpeg((string) $sourcePath);
if ($im !== false && $targetPath !== null) {
$srcW = imagesx($im);
$srcH = imagesy($im);
$dstW = min($maxWidth, $srcW);
$dstH = (int) round($srcH * $dstW / $srcW);
$tgt = imagecreatetruecolor($dstW, $dstH);
imagecopyresampled($tgt, $im, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);
$ok = imagewebp($tgt, $targetPath, $quality);
imagedestroy($im);
imagedestroy($tgt);
if ($ok) {
@chmod($targetPath, 0644);
}
}
}
if ($ok && $targetPath !== null && is_file($targetPath)) {
@chmod($targetPath, 0644);
printf("✓ %s (%d → %d bytes)\n", basename($targetPath), (int) filesize($sourcePath), (int) filesize($targetPath));
} else {
printf("✗ failed: %s (cwebp exit %d)\n", (string) $sourcePath, $code);
$skipped++;
}
}
printf(
"Generated %d WebP thumbnail(s); %d failed. %d already present.\n",
count($pending) - $skipped,
$skipped,
$existing
);
exit($skipped > 0 ? 1 : 0);