feat(images): JPEG XL optimizer with webp/jpg fallback and <picture> 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.
This commit is contained in:
andre committed 2026-10-26 11:18:13 +00:00
1 parent 8fe73eb5d6
commit 4f44bc1609
14 files changed
+260 -20

No files matched your search

+226 -2
View File
@@ -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;
}
// <img> for $rel; a .jxl with a fallback becomes <picture> with the JXL source
// first. $attrs: extra <img> 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 = '<img src="' . htmlspecialchars(base_url($src)) . '"' . $a . '>';
if ($fb === '') return $img;
return '<picture><source type="image/jxl" srcset="' . htmlspecialchars(base_url($rel)) . '">' . $img . '</picture>';
}
// 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;
}