From 4f44bc160928cd3c0437b3db025b6b2ddb24c7f1 Mon Sep 17 00:00:00 2001 From: andre Date: Mon, 26 Oct 2026 11:18:13 +0000 Subject: [PATCH] feat(images): JPEG XL optimizer with webp/jpg fallback and serving img_optimize() decodes .jxl with djxl or Imagick, resizes with GD, re-encodes with cjxl and writes foto.jxl.webp beside it; skips cleanly without a decoder. Gallery, hero, navbar logos and JSON-LD serve the JXL with the fallback, and trash/removal move the fallback with its .jxl. --- CLAUDE.md | 7 +- config/functions_events.php | 1 + config/functions_gyms.php | 1 + config/functions_images.php | 228 +++++++++++++++++++++++++++++++++++- config/functions_site.php | 3 +- config/functions_trash.php | 3 + site/assets/css/style.css | 2 + site/assets/js/main.js | 3 +- site/includes/gallery.php | 14 +-- site/includes/include.php | 4 +- site/includes/navbar.php | 6 +- site/includes/seo.php | 2 +- site/index.php | 4 +- views/admin/gyms/_form.php | 2 +- 14 files changed, 260 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 42c9b84..bd43d18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,12 @@ Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration An unpublished gym 404s publicly but previews for staff. ## Imagens / JPEG XL (2026-10-26) -Fleet transition to JPEG XL (root `Y:\GitTea\CLAUDE.md`, "Images"); fitme3070 is the reference site. Uploads already accept `.jxl` (gyms header/logo, gallery photos). **To do:** `img_optimize()` (`config/functions_images.php`) still skips JXL because it is GD-only; it must resize/re-encode JXL (Imagick with JXL or `cjxl`/`djxl`, check what the dev `webdevops/php-apache:8.2` container and the VPS `fitme` image have) and write a `.webp`/`.jpg` fallback beside every `.jxl`; the gallery, hero and logos then serve `` with the JXL source and the fallback ``, and the gallery listing groups files by base name. Every image input in the backoffice shows the current image next to it with a remove control (gyms form does; check events, modalidades, team, galerias, system settings). +Fleet transition to JPEG XL (root `Y:\GitTea\CLAUDE.md`, "Images"); fitme3070 is the reference site. Uploads already accept `.jxl` (gyms header/logo, gallery photos). +- **Optimizer** (`config/functions_images.php`, built 2026-10-26): `img_optimize()` hands `.jxl` to `img_jxl_optimize()`, which decodes with `djxl` (or Imagick if it lists JXL), resizes with GD, re-encodes a smaller `.jxl` with `cjxl -d 1.0` when resized, and always writes the fallback **`foto.jxl.webp`** (`.jxl.jpg` if GD has no WebP) beside it. Idempotent (skips when the fallback is newer and within the cap). With no decoder it returns `skipped` («JPEG XL sem descodificador no servidor»), uploads never fail. +- **No decoder exists yet** (checked 2026-10-26): the VPS `fitme` image (php:8.4-apache, Debian trixie) has GD only; the dev `webdevops/php-apache:8.2` has Imagick without JXL. Both need `libjxl-tools` (cjxl/djxl) installed; until then `.jxl` uploads have no fallback and show only in browsers with JXL (Safari). After installing, press «Otimizar» on each gallery to backfill. +- **Serving:** `img_tag($rel, $attrs)` (``, plain `` otherwise), `img_css_bg($rel)` (hero: `url(fallback)` then `image-set()` with the JXL), `img_compat_rel()` (single-URL places: lightbox link, JSON-LD image, admin preview), `img_list_dir()` (gallery listings, hides `*.jxl.*`). Used in `site/includes/gallery.php` (lightbox shows the tile's `currentSrc`), `site/index.php` hero, `include.php` preload (`type="image/jxl"` for a JXL hero), `seo.php`, navbar logos. `picture { display: contents }` in `style.css` keeps the old img sizing. +- **Moves/deletes keep the pair together:** `img_fallback_follow($from, $to='')` in trash (`_trash_image_file`), `gyms_remove_image()`, `events_image_remove()`. +- Still open: events/modalidades/team uploads don't accept `.jxl` yet. Every image input in the backoffice shows the current image next to it with a remove control (gyms form does; check events, modalidades, team, galerias, system settings). The navbar only links sections the gym shows **and** has content for (`site_section_has_content()` in `config/functions_site.php`: Galeria = photos in its folder, Modalidades, Equipa = active members (`site_team_active()`, also used by the page), Horário, Mensalidades). The gallery section background is a 7% brand-colour tint, not white. diff --git a/config/functions_events.php b/config/functions_events.php index 948e0b8..a766c6a 100644 --- a/config/functions_events.php +++ b/config/functions_events.php @@ -225,6 +225,7 @@ function events_image_remove(?string $filename): void { if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return; $path = events_image_dir() . $filename; if (is_file($path)) @unlink($path); + img_fallback_follow($path); } // ─── Leads ───────────────────────────────────────────────────────────────────── diff --git a/config/functions_gyms.php b/config/functions_gyms.php index 872d6aa..bbef01d 100644 --- a/config/functions_gyms.php +++ b/config/functions_gyms.php @@ -313,6 +313,7 @@ function gyms_remove_image(?string $rel): void { if (!preg_match('~^uploads/gyms/[A-Za-z0-9_.-]+$~', $rel)) return; $path = gyms_upload_dir() . basename($rel); if (is_file($path)) @unlink($path); + img_fallback_follow($path); } // Single-field setter for views/admin/reviews/index.php — that page only diff --git a/config/functions_images.php b/config/functions_images.php index be90b46..48f23eb 100644 --- a/config/functions_images.php +++ b/config/functions_images.php @@ -89,6 +89,7 @@ function img_optimize(string $path, int $max_dim): array { if (!is_file($path)) return $fail('ficheiro não encontrado'); if (!extension_loaded('gd')) return $fail('extensão GD indisponível'); + if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'jxl') return img_jxl_optimize($path, $max_dim); $info = @getimagesize($path); if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster @@ -186,9 +187,9 @@ function img_optimize_dir(string $dir, int $max_dim): array { if (!is_dir($dir)) return $stats; foreach (scandir($dir) ?: [] as $file) { $path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file; - if (!is_file($path)) continue; + if (!is_file($path) || img_is_jxl_fallback($file)) continue; $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); - if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) continue; + if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl'], true)) continue; $r = img_optimize($path, $max_dim); $stats[$r['status']]++; $stats['saved'] += $r['saved']; @@ -214,3 +215,226 @@ function img_human_bytes(int $bytes): string { if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB'; return $bytes . ' B'; } + +// ─── JPEG XL ───────────────────────────────────────────────────────────────── +// O GD não lê JXL. Descodifica-se com os binários do libjxl (djxl/cjxl, pacote +// Debian libjxl-tools) ou com o Imagick se tiver o formato JXL; sem nenhum, o +// .jxl fica como foi enviado (status "skipped") e o upload não falha. +// Ao lado de cada foto.jxl grava-se foto.jxl.webp (ou .jpg sem WebP no GD): +// é a versão para os browsers sem JXL, servida por img_tag()/img_css_bg() e +// escondida das listagens (img_list_dir). Redimensiona-se a partir da versão +// descodificada e, havendo encoder, o .jxl é regravado ao novo tamanho. + +const IMG_JXL_DISTANCE = '1.0'; // cjxl -d: 1.0 = visualmente sem perdas + +// Backends on this server: ['imagick' => bool, 'djxl' => path|'', 'cjxl' => path|'']. +function img_jxl_tools(): array { + static $tools = null; + if ($tools !== null) return $tools; + $imagick = class_exists('Imagick') && in_array('JXL', \Imagick::queryFormats('JXL'), true); + $find = static function (string $bin): string { + if (!function_exists('proc_open')) return ''; + $dirs = array_merge(explode(PATH_SEPARATOR, (string) getenv('PATH')), ['/usr/bin', '/usr/local/bin']); + foreach (array_unique(array_filter($dirs)) as $d) { + $p = rtrim($d, '/\\') . DIRECTORY_SEPARATOR . $bin; + if (@is_file($p) && @is_executable($p)) return $p; + } + return ''; + }; + return $tools = ['imagick' => $imagick, 'djxl' => $find('djxl'), 'cjxl' => $find('cjxl')]; +} + +function img_jxl_can_decode(): bool { + $t = img_jxl_tools(); + return $t['imagick'] || $t['djxl'] !== ''; +} + +// Runs a binary with an argument list (no shell). True when it exits 0 and wrote $out. +function _img_run(string $bin, array $args, string $out): bool { + $proc = @proc_open(array_merge([$bin], $args), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($proc)) return false; + stream_get_contents($pipes[1]); stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + clearstatcache(true, $out); + return proc_close($proc) === 0 && is_file($out) && filesize($out) > 0; +} + +function img_jxl_decode(string $jxl, string $png): bool { + $t = img_jxl_tools(); + if ($t['djxl'] !== '' && _img_run($t['djxl'], [$jxl, $png], $png)) return true; + if ($t['imagick']) { + try { + $im = new \Imagick($jxl); + $im->setImageFormat('PNG'); + return $im->writeImage($png) && is_file($png); + } catch (\Throwable $e) { return false; } + } + return false; +} + +function img_jxl_encode(string $png, string $jxl): bool { + $t = img_jxl_tools(); + if ($t['cjxl'] !== '' && _img_run($t['cjxl'], [$png, $jxl, '-d', IMG_JXL_DISTANCE, '-e', '7'], $jxl)) return true; + if ($t['imagick']) { + try { + $im = new \Imagick($png); + $im->setImageFormat('JXL'); + $im->setImageCompressionQuality(90); + return $im->writeImage($jxl) && is_file($jxl); + } catch (\Throwable $e) { return false; } + } + return false; +} + +// "foto.jxl.webp" / "foto.jxl.jpg": the fallback copies, never listed as photos. +function img_is_jxl_fallback(string $name): bool { + return (bool) preg_match('/\.jxl\.(webp|jpe?g|png)$/i', $name); +} + +// Absolute path of the fallback beside $jxl_path, or '' when there is none. +function img_jxl_fallback(string $jxl_path): string { + foreach (['webp', 'jpg', 'png'] as $e) { + if (is_file($jxl_path . '.' . $e)) return $jxl_path . '.' . $e; + } + return ''; +} + +// Moves (or, with $to = '', deletes) the fallback that goes with an image file. +// Callers that move/delete an upload call this too, so the pair stays together. +function img_fallback_follow(string $from, string $to = ''): void { + if (!preg_match('/\.jxl$/i', $from)) return; + foreach (['webp', 'jpg', 'png'] as $e) { + if (!is_file($from . '.' . $e)) continue; + if ($to === '') @unlink($from . '.' . $e); + else @rename($from . '.' . $e, $to . '.' . $e); + } +} + +function img_jxl_optimize(string $path, int $max_dim): array { + $skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why]; + $fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why]; + + // Fallback newer than the .jxl and within the cap: nothing to do (idempotent). + $old = img_jxl_fallback($path); + if ($old !== '' && filemtime($old) >= filemtime($path)) { + $d = @getimagesize($old); + if ($d && $d[0] <= $max_dim && $d[1] <= $max_dim) return $skip('já dentro do limite'); + } + if (!img_jxl_can_decode()) return $skip('JPEG XL sem descodificador no servidor (libjxl-tools)'); + + $dec = $path . '.dec.png'; + if (!img_jxl_decode($path, $dec)) { @unlink($dec); return $fail('JPEG XL ilegível'); } + $info = @getimagesize($dec); + if (!$info) { @unlink($dec); return $fail('JPEG XL ilegível'); } + [$w, $h] = $info; + if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M'); + $src = @imagecreatefrompng($dec); + if (!$src) { @unlink($dec); return $fail('JPEG XL ilegível'); } + + $scale = min(1, $max_dim / max($w, $h, 1)); + $nw = max(1, (int) round($w * $scale)); + $nh = max(1, (int) round($h * $scale)); + if ($scale < 1) { + $dst = imagecreatetruecolor($nw, $nh); + imagealphablending($dst, false); + imagesavealpha($dst, true); + imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127)); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h); + imagedestroy($src); + $src = $dst; + } + + // Fallback: WebP keeps transparency; without WebP in GD, JPEG. + img_fallback_follow($path); + if (function_exists('imagewebp')) { + imagesavealpha($src, true); + $ok = imagewebp($src, $path . '.webp', IMG_WEBP_QUALITY); + } else { + imageinterlace($src, true); + $ok = imagejpeg($src, $path . '.jpg', IMG_JPEG_QUALITY); + } + + // Smaller JXL: re-encode from the resized pixels when there is an encoder. + $saved = 0; + if ($ok && $scale < 1 && imagepng($src, $dec)) { + $tmp = $path . '.opt.tmp'; + $before = (int) filesize($path); + if (img_jxl_encode($dec, $tmp) && filesize($tmp) < $before) { + if (!@rename($tmp, $path)) { @unlink($path); @rename($tmp, $path); } + clearstatcache(true, $path); + $saved = max(0, $before - (int) filesize($path)); + } + @unlink($tmp); + } + imagedestroy($src); + @unlink($dec); + if (!$ok) return $fail('falha ao gravar a versão alternativa'); + // The fallback must not look older than the .jxl (idempotence check above). + @touch(img_jxl_fallback($path)); + return ['status' => 'resized', 'saved' => $saved, + 'reason' => ($scale < 1 ? "{$w}×{$h} → {$nw}×{$nh}, " : '') . 'versão alternativa criada']; +} + +// ─── Servir imagens (JPEG XL com alternativa) ──────────────────────────────── +// $rel é o caminho relativo à raiz do projeto (o que a BD guarda / o glob dá). + +function _img_abs(string $rel): string { + return dirname(__DIR__) . '/' . ltrim($rel, '/'); +} + +// Fallback of a .jxl as a project-relative path, or '' (not JXL / no fallback). +function img_fallback_rel(string $rel): string { + if (!preg_match('/\.jxl$/i', $rel)) return ''; + $fb = img_jxl_fallback(_img_abs($rel)); + return $fb === '' ? '' : $rel . substr($fb, -strlen(strrchr($fb, '.'))); +} + +// Where only one image fits (lightbox, og:image, preload, e-mail): the +// fallback of a .jxl when there is one, else the file itself. +function img_compat_rel(string $rel): string { + return img_fallback_rel($rel) ?: $rel; +} + +// for $rel; a .jxl with a fallback becomes with the JXL source +// first. $attrs: extra attributes (trusted names, values escaped here); +// width/height come from the file (or its fallback) unless given. +function img_tag(string $rel, array $attrs = []): string { + $fb = img_fallback_rel($rel); + $src = $fb ?: $rel; + if (!isset($attrs['width'])) { + $d = @getimagesize(_img_abs($src)); + if ($d) { $attrs['width'] = $d[0]; $attrs['height'] = $d[1]; } + } + $a = ''; + foreach ($attrs as $k => $v) { + if ($v === null || $v === false) continue; + $a .= ' ' . $k . ($v === true ? '' : '="' . htmlspecialchars((string) $v) . '"'); + } + $img = ''; + if ($fb === '') return $img; + return '' . $img . ''; +} + +// CSS for a style="" attribute: url(fallback) first, then image-set() with the +// JXL, so browsers without image-set()/JXL keep the fallback. +function img_css_bg(string $rel): string { + $q = static fn(string $r) => "url('" . str_replace(["'", '\\'], ['%27', '%5C'], base_url($r)) . "')"; + $fb = img_fallback_rel($rel); + if ($fb === '') return 'background-image:' . $q($rel); + $type = str_ends_with(strtolower($fb), '.webp') ? 'image/webp' : 'image/jpeg'; + return 'background-image:' . $q($fb) . ';background-image:image-set(' . $q($rel) . ' type("image/jxl"), ' . $q($fb) . ' type("' . $type . '"))'; +} + +// Photos directly inside a project-relative folder ("uploads/gallery/3/"), +// sorted, without the .jxl fallbacks. +function img_list_dir(string $dir_rel): array { + $dir_rel = rtrim($dir_rel, '/') . '/'; + $out = []; + foreach (glob(_img_abs($dir_rel) . '*') ?: [] as $p) { + $name = basename($p); + if (!is_file($p) || img_is_jxl_fallback($name) || !preg_match('/\.(jpe?g|png|gif|webp|jxl)$/i', $name)) continue; + $out[] = $dir_rel . $name; + } + sort($out); + return $out; +} diff --git a/config/functions_site.php b/config/functions_site.php index 09dd189..323bc1f 100644 --- a/config/functions_site.php +++ b/config/functions_site.php @@ -39,8 +39,7 @@ function site_section_has_content(array $gym, string $sec): bool { 'equipa' => (bool) site_team_active($pdo, $gid), 'horario' => (bool) site_horario($pdo, '', $gid), 'mensalidades' => (bool) site_memberships($pdo, $gid), - 'galeria' => !empty($gym['gallery_id']) && preg_grep('/\.(jpe?g|png|gif|webp|jxl)$/i', - glob(dirname(__DIR__) . '/uploads/gallery/' . (int) $gym['gallery_id'] . '/*') ?: []), + 'galeria' => !empty($gym['gallery_id']) && img_list_dir('uploads/gallery/' . (int) $gym['gallery_id']), default => true, }; } diff --git a/config/functions_trash.php b/config/functions_trash.php index e8ed8a4..abd3f56 100644 --- a/config/functions_trash.php +++ b/config/functions_trash.php @@ -269,9 +269,12 @@ function _trash_image_file(PDO $pdo, int $imageId, string $action): void { if ($action === 'delete' && is_file($live)) { if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true); @rename($live, $trash); + img_fallback_follow($live, $trash); // foto.jxl.webp goes with foto.jxl } elseif ($action === 'restore' && is_file($trash)) { @rename($trash, $live); + img_fallback_follow($trash, $live); } elseif ($action === 'purge') { if (is_file($trash)) @unlink($trash); + img_fallback_follow($trash); } } diff --git a/site/assets/css/style.css b/site/assets/css/style.css index 19230a1..7021fec 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -4456,6 +4456,8 @@ h3.contact-title { cursor: zoom-in; } +/* .jxl images are (img_tag): let the size against its parent as before. */ +.gal-link picture, .navbar-brand picture, .gym-switch-btn picture { display: contents; } .gal-link img { width: 100%; height: 100%; diff --git a/site/assets/js/main.js b/site/assets/js/main.js index 93a7e41..bd872d8 100644 --- a/site/assets/js/main.js +++ b/site/assets/js/main.js @@ -722,7 +722,8 @@ document.addEventListener('DOMContentLoaded', function () { function show(i) { current = (i + links.length) % links.length; var src = links[current].querySelector('img'); - img.src = links[current].getAttribute('href'); + // The tile's chosen source (JXL inside where supported), else the link (fallback). + img.src = (src && src.currentSrc) || links[current].getAttribute('href'); img.alt = src ? src.alt : ''; // Restart the fade-in for each photo. img.style.animation = 'none'; void img.offsetWidth; img.style.animation = ''; diff --git a/site/includes/gallery.php b/site/includes/gallery.php index 9b67909..d7ee110 100644 --- a/site/includes/gallery.php +++ b/site/includes/gallery.php @@ -6,9 +6,10 @@ // tile (without dragging) opens it full size in a lightbox with prev / // next, arrow keys, Esc and swipe; the repeated copies are hidden from assistive // tech and open the same photo. -// Every format the admin upload accepts (functions_galerias.php), not just .jpg. -$files = preg_grep('/\.(jpe?g|png|gif|webp|jxl)$/i', glob(rtrim($gallery_path, '/') . '/*') ?: []); -$files = array_values($files ?: []); +// Every format the admin upload accepts (functions_galerias.php), without the +// .jxl.webp/.jpg fallbacks; a .jxl tile is (img_tag), the link goes to +// the fallback and the lightbox shows whichever the tile picked (currentSrc). +$files = img_list_dir($gallery_path); // Column span per tile, cycled: w = two columns, s = one. $gal_shapes = ['w', 's', 's', 's', 's', 'w', 's', 's', 's', 's', 's']; $gal_label = $gallery_h2 ?? 'Instalações'; @@ -45,15 +46,12 @@ $gal_label = $gallery_h2 ?? 'Instalações';
diff --git a/site/includes/include.php b/site/includes/include.php index 891e65a..a50e908 100644 --- a/site/includes/include.php +++ b/site/includes/include.php @@ -50,7 +50,9 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) { - + +> diff --git a/site/includes/navbar.php b/site/includes/navbar.php index 23b25c4..f29e0d7 100644 --- a/site/includes/navbar.php +++ b/site/includes/navbar.php @@ -13,7 +13,11 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);