mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
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.
153 lines
5.0 KiB
PHP
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);
|