mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
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.
This commit is contained in:
@@ -4,8 +4,11 @@
|
|||||||
> Context: CSS waste strategy — full stream DEFERRED (tasks 10/u/w/x/y/z): unusedSymbols report showed only ~6.2KB/2.9% of 216KB reclaimable; pruning + per-page split parked (docs/css-split-analysis.md). #t (Interdit admin route) confirmed already implemented via /admin/media.php + media-viewer.php + recapitulatif link. No pending live tasks.
|
> Context: CSS waste strategy — full stream DEFERRED (tasks 10/u/w/x/y/z): unusedSymbols report showed only ~6.2KB/2.9% of 216KB reclaimable; pruning + per-page split parked (docs/css-split-analysis.md). #t (Interdit admin route) confirmed already implemented via /admin/media.php + media-viewer.php + recapitulatif link. No pending live tasks.
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
- [x] #cover-home-lazy-intersect-fix [!high] Fix lazy covers not loading after initial images. Root cause: home grid scrolls inside <main class="home-main"> (overflow-y:auto, body height:100vh flex), but htmx `revealed` listens to window scroll only — so below-fold covers never revealed. Fix: hx-trigger="intersect threshold:0.05 once" (IntersectionObserver works inside any scroll container). VERIFIED LIVE: progressive scroll loads covers 5→11→15→20→22/22 (before: stuck ~5). Committed fix(home): use intersect trigger.
|
||||||
|
- [x] #cover-image-webp-avif-serve-lite [!medium] Serve lightweight webp cover thumbnails instead of full-res PNG originals (~99% on worst cases). DONE VERIFIED LIVE 2026-08-31: `cwebp -resize 800 q80` sidecars (+ GD fallback for CMYK JPEGs) generated by ThesisFileHandler at upload (FilePond + legacy); /cover-fragment emits <picture> (webp source + original fallback); backfill scripts/generate-cover-webp.php + `just deploy-cover-webp`. PROD: tools installed (docs), 46/46 covers backfilled (44 cwebp + 2 GD/CMYK), app code deployed, /cover-fragment + /media?path=*.webp verified 200 image/webp 61KB (vs 15.8MB orig). AVIF dropped (no viable prod resize; WEBP sufficient). NOTE: just deploy-code's final nginx/permissions step needs sudo password (operational, pre-existing — not a regression).
|
||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
|
- [x] #home-htmx-lazy-cover-images [!medium] Add htmx lazyloading on home page cover images: `revealed`-triggered cover-fragment endpoint, swap-in `<img>`, settle fade CSS, load htmx on home
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
- [x] #audit-all-docs-and [!high] Audit all docs/ and classify accurate vs stale
|
- [x] #audit-all-docs-and [!high] Audit all docs/ and classify accurate vs stale
|
||||||
|
|||||||
@@ -78,7 +78,15 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease, opacity 300ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover thumbnails are served as a <picture> (WebP + fallback); keep the
|
||||||
|
wrapper block-level so the <img> fills the 4:3 figure. */
|
||||||
|
.home-body figure picture {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover figure img,
|
.card:hover figure img,
|
||||||
@@ -97,6 +105,26 @@
|
|||||||
font-size: var(--step-3);
|
font-size: var(--step-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HTMX lazy-loaded cover: spinner + settle fade-in (mirrors htmx
|
||||||
|
docs' `htmx-settling img` transition pattern). */
|
||||||
|
.card__cover-loading {
|
||||||
|
animation: card-cover-pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-cover-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-body figure.htmx-settling img {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.card__media--gradient {
|
.card__media--gradient {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 4 / 3;
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ class HomeController
|
|||||||
// Layout
|
// Layout
|
||||||
'currentNav' => '',
|
'currentNav' => '',
|
||||||
'extraCss' => ['/assets/dist/public.min.css'],
|
'extraCss' => ['/assets/dist/public.min.css'],
|
||||||
|
// htmx powers the `revealed` lazy-loaded cover images on this page
|
||||||
|
'extraJs' => ['/assets/js/vendor/htmx.min.js'],
|
||||||
'bodyClass' => 'home-body',
|
'bodyClass' => 'home-body',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ trait ThesisFileHandler
|
|||||||
/** Cover image max size. */
|
/** Cover image max size. */
|
||||||
private const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
|
private const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebP cover-thumbnail tuning (served to the public home grid).
|
||||||
|
* `cwebp` must be installed on the server (see docs/environment.md).
|
||||||
|
*/
|
||||||
|
private const COVER_WEBP_MAX_WIDTH = 800; // px, tall-side kept proportional
|
||||||
|
private const COVER_WEBP_QUALITY = 80;
|
||||||
|
|
||||||
/** MIME types accepted for thesis files. */
|
/** MIME types accepted for thesis files. */
|
||||||
private const ALLOWED_MIME_TYPES = [
|
private const ALLOWED_MIME_TYPES = [
|
||||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||||
@@ -137,6 +144,104 @@ trait ThesisFileHandler
|
|||||||
$mimeType
|
$mimeType
|
||||||
);
|
);
|
||||||
error_log("ThesisFileHandler: cover uploaded → $relPath");
|
error_log("ThesisFileHandler: cover uploaded → $relPath");
|
||||||
|
|
||||||
|
$this->generateCoverWebp($targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a small WebP cover thumbnail next to the original cover.
|
||||||
|
*
|
||||||
|
* Covers are uploaded as full-resolution PNG/JPEG and — on the public home
|
||||||
|
* grid — rendered into a small 4:3 card. Serving the original is wasteful
|
||||||
|
* (up to ~15 MB for a ~300 px card). `cwebp -resize` both downscales and
|
||||||
|
* re-encodes in one pass, typically cutting payload by ~95%+.
|
||||||
|
*
|
||||||
|
* The thumbnail is a plain sidecar file (`{prefix}_COUVERTURE.webp`); it is
|
||||||
|
* NOT registered in thesis_files. Consumption is via /media (webp MIME is
|
||||||
|
* already allowed) using the same storage-relative path with a .webp ext.
|
||||||
|
*
|
||||||
|
* @param string $sourcePath Absolute path to the uploaded cover.
|
||||||
|
*/
|
||||||
|
private function generateCoverWebp(string $sourcePath): void
|
||||||
|
{
|
||||||
|
if (!is_file($sourcePath) || !is_readable($sourcePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPath = preg_replace('/\.[a-z0-9]+$/i', '.webp', $sourcePath);
|
||||||
|
if ($targetPath === null || $targetPath === $sourcePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: never break the upload if transcoding is unavailable
|
||||||
|
// (no `cwebp`, PATH issues, etc.). A missing thumbnail simply makes the
|
||||||
|
// public grid fall back to the original via the <picture> fallback.
|
||||||
|
$cmd = sprintf(
|
||||||
|
'cwebp -quiet -resize %d 0 -q %d %s -o %s 2>/dev/null',
|
||||||
|
self::COVER_WEBP_MAX_WIDTH,
|
||||||
|
self::COVER_WEBP_QUALITY,
|
||||||
|
escapeshellarg($sourcePath),
|
||||||
|
escapeshellarg($targetPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$code = 0;
|
||||||
|
exec($cmd, $out, $code);
|
||||||
|
|
||||||
|
if ($code === 0 && is_file($targetPath)) {
|
||||||
|
@chmod($targetPath, 0644);
|
||||||
|
error_log('ThesisFileHandler: cover webp generated → ' . basename($targetPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cwebp can't read CMYK JPEGs ("Unsupported color conversion"); those
|
||||||
|
// are increasingly common as scanned-cover uploads. GD (php-gd) handles
|
||||||
|
// the CMYK→RGB conversion implicitly on decode, so fall back to it with
|
||||||
|
// the same resize + quality before giving up.
|
||||||
|
if ($this->generateCoverWebpWithGd($sourcePath, $targetPath)) {
|
||||||
|
error_log('ThesisFileHandler: cover webp generated (GD fallback) → '
|
||||||
|
. basename($targetPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log("ThesisFileHandler: webp generation failed for cover (cwebp exit $code)"
|
||||||
|
. ' → ' . basename($targetPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GD-based WebP thumbnail generation (CMYK-JPEG fallback for cwebp).
|
||||||
|
*
|
||||||
|
* @param string $sourcePath Absolute path to the original cover.
|
||||||
|
* @param string $targetPath Absolute path to the .webp output.
|
||||||
|
* @return bool true on success.
|
||||||
|
*/
|
||||||
|
private function generateCoverWebpWithGd(string $sourcePath, string $targetPath): bool
|
||||||
|
{
|
||||||
|
if (!function_exists('imagecreatefromjpeg') || !function_exists('imagewebp')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$im = @imagecreatefromjpeg($sourcePath);
|
||||||
|
if ($im === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$srcW = imagesx($im);
|
||||||
|
$srcH = imagesy($im);
|
||||||
|
$dstW = min(self::COVER_WEBP_MAX_WIDTH, $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, self::COVER_WEBP_QUALITY);
|
||||||
|
|
||||||
|
imagedestroy($im);
|
||||||
|
imagedestroy($tgt);
|
||||||
|
|
||||||
|
if ($ok) {
|
||||||
|
@chmod($targetPath, 0644);
|
||||||
|
}
|
||||||
|
return $ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -979,6 +1084,10 @@ trait ThesisFileHandler
|
|||||||
);
|
);
|
||||||
error_log("ThesisFileHandler: $queueKey uploaded (filepond) → $targetName");
|
error_log("ThesisFileHandler: $queueKey uploaded (filepond) → $targetName");
|
||||||
|
|
||||||
|
if ($queueKey === 'cover') {
|
||||||
|
$this->generateCoverWebp($targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
$this->cleanupFilePondTmp($fileId);
|
$this->cleanupFilePondTmp($fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,39 @@ class Dispatcher
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /cover-fragment (HTMX lazy-load: returns just the cover markup.
|
||||||
|
// Consumed by the home page's `revealed`-triggered placeholders — the
|
||||||
|
// heavy image bytes are fetched only when a card scrolls into view.
|
||||||
|
//
|
||||||
|
// Prefers the small WebP thumbnail (same storage-relative path, .webp
|
||||||
|
// ext) when present, else falls back to the original via <picture>.
|
||||||
|
if ($path === '/cover-fragment' || $path === '/cover-fragment.php') {
|
||||||
|
return static function (): void {
|
||||||
|
$coverPath = $_GET['path'] ?? '';
|
||||||
|
|
||||||
|
// Same strict whitelist as MediaController: no traversal, no junk.
|
||||||
|
if (
|
||||||
|
$coverPath === ''
|
||||||
|
|| !preg_match('#^[a-zA-Z0-9/_\-.]+$#', $coverPath)
|
||||||
|
) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orig = '/media?path=' . urlencode($coverPath);
|
||||||
|
$webpPath = preg_replace('/\.[a-z0-9]+$/i', '.webp', $coverPath);
|
||||||
|
|
||||||
|
echo '<picture>'
|
||||||
|
. '<source type="image/webp" srcset="'
|
||||||
|
. htmlspecialchars('/media?path=' . urlencode((string) $webpPath), ENT_QUOTES, 'UTF-8')
|
||||||
|
. '">'
|
||||||
|
. '<img src="'
|
||||||
|
. htmlspecialchars($orig, ENT_QUOTES, 'UTF-8')
|
||||||
|
. '" alt="Couverture" loading="lazy">'
|
||||||
|
. '</picture>';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// /maintenance.php
|
// /maintenance.php
|
||||||
if ($path === '/maintenance' || $path === '/maintenance.php') {
|
if ($path === '/maintenance' || $path === '/maintenance.php') {
|
||||||
return function () {
|
return function () {
|
||||||
|
|||||||
@@ -17,10 +17,14 @@
|
|||||||
$thumb = $coverMap[$item['id']] ?? null;
|
$thumb = $coverMap[$item['id']] ?? null;
|
||||||
?>
|
?>
|
||||||
<?php if ($thumb): ?>
|
<?php if ($thumb): ?>
|
||||||
<figure>
|
<figure
|
||||||
<img src="/media?path=<?= urlencode($thumb) ?>"
|
hx-get="/cover-fragment?path=<?= urlencode($thumb) ?>"
|
||||||
alt="Couverture — <?= htmlspecialchars($item['title']) ?> par <?= htmlspecialchars($item['authors'] ?? '') ?>"
|
hx-trigger="intersect threshold:0.05 once"
|
||||||
loading="lazy">
|
hx-swap="innerHTML"
|
||||||
|
aria-busy="true">
|
||||||
|
<div class="card__media--placeholder" role="status">
|
||||||
|
<span class="card__cover-loading">…</span>
|
||||||
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="card__media--gradient"
|
<div class="card__media--gradient"
|
||||||
|
|||||||
+20
-1
@@ -16,6 +16,23 @@ archived at [`spec-sheet.md`](archive/spec-sheet.md)).
|
|||||||
| **App user** | `www-data`, group `xamxam` |
|
| **App user** | `www-data`, group `xamxam` |
|
||||||
| **Node.js** | *Not required on the server* — assets are built locally and rsync'd |
|
| **Node.js** | *Not required on the server* — assets are built locally and rsync'd |
|
||||||
|
|
||||||
|
## Image transcoding tooling (installed 2026-08-31)
|
||||||
|
|
||||||
|
Installed on the production server to generate lightweight WebP/AVIF cover
|
||||||
|
thumbnails (home-page cover grid — up to ~95% payload reduction):
|
||||||
|
|
||||||
|
- `php-gd` — PHP GD extension (WebP/AVIF encode/resize for on-the-fly serving)
|
||||||
|
- `webp` — provides the `cwebp` CLI (WebP conversion)
|
||||||
|
- `libavif-bin` — provides the `avifenc` CLI (AVIF conversion)
|
||||||
|
|
||||||
|
Install on any environment that must serve cover thumbnails:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install php-gd webp libavif-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumed by the cover-thumbnail pipeline (see `docs/...` once implemented).
|
||||||
|
|
||||||
## PHP (≥ 8.4)
|
## PHP (≥ 8.4)
|
||||||
|
|
||||||
Declared in `composer.json` (`"php": ">=8.4"`, platform lock `8.4`).
|
Declared in `composer.json` (`"php": ">=8.4"`, platform lock `8.4`).
|
||||||
@@ -36,7 +53,9 @@ Verified present in the production `php -m` output and on the FPM pool:
|
|||||||
- `session` — admin auth + CSRF (see [`security.md`](security.md))
|
- `session` — admin auth + CSRF (see [`security.md`](security.md))
|
||||||
- `sodium` — `Crypto` (libsodium) for encrypted fields (SMTP password)
|
- `sodium` — `Crypto` (libsodium) for encrypted fields (SMTP password)
|
||||||
- `zlib` / `phar` — Composer autoload + PHAR-based tooling
|
- `zlib` / `phar` — Composer autoload + PHAR-based tooling
|
||||||
- `gd` — not used directly, present transitively (image handling is `finfo`)
|
- `gd` — **intentionally installed** (`php-gd`, 2026-08-31) for cover-image
|
||||||
|
transcoding to WebP/AVIF; resize/encode derivations for the home-page cover
|
||||||
|
grid. Not used for MIME validation (that is `finfo`).
|
||||||
- `fileinfo` — upload MIME validation (`finfo`)
|
- `fileinfo` — upload MIME validation (`finfo`)
|
||||||
- `calendar`, `ctype`, `filter`, `hash`, `tokenizer`, `xml`, `libxml` — PHP core
|
- `calendar`, `ctype`, `filter`, `hash`, `tokenizer`, `xml`, `libxml` — PHP core
|
||||||
extensions, present by default
|
extensions, present by default
|
||||||
|
|||||||
@@ -176,6 +176,16 @@ deploy-migrate:
|
|||||||
ssh xamxam "cd /var/www/xamxam && REPO_ROOT=/var/www/xamxam bash /tmp/migrate.sh"
|
ssh xamxam "cd /var/www/xamxam && REPO_ROOT=/var/www/xamxam bash /tmp/migrate.sh"
|
||||||
ssh xamxam "rm -f /tmp/migrate.sh"
|
ssh xamxam "rm -f /tmp/migrate.sh"
|
||||||
|
|
||||||
|
[group('deploy')]
|
||||||
|
deploy-cover-webp dry_run='':
|
||||||
|
# Backfill small WebP cover thumbnails on the server (idempotent).
|
||||||
|
# Dry-run by default; pass --no-dry-run to actually generate. Runs as the
|
||||||
|
# SSH user (member of the xamxam group, dirs are setgid group-writable) so
|
||||||
|
# no sudo is needed. Deploy as www-data if group-write ever changes.
|
||||||
|
rsync -v scripts/generate-cover-webp.php xamxam:/tmp/generate-cover-webp.php
|
||||||
|
ssh xamxam "cd /var/www/xamxam && php /tmp/generate-cover-webp.php {{dry_run}}"
|
||||||
|
ssh xamxam "rm -f /tmp/generate-cover-webp.php"
|
||||||
|
|
||||||
[group('deploy')]
|
[group('deploy')]
|
||||||
deploy-env:
|
deploy-env:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/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);
|
||||||
Reference in New Issue
Block a user