Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ You've spent hours in the darkroom, days on location, years perfecting your craf

### Why Photographers Choose Cimaise

**Blazing Fast** — Your images load instantly with automatic AVIF, WebP, and JPEG optimization. Six responsive breakpoints ensure perfect delivery on any device. No more visitors leaving because your site is slow.
**Blazing Fast** — Your images load instantly with automatic AVIF, WebP, and JPEG optimization (plus optional JPEG-XL), powered by a libvips engine that also imports iPhone HEIC photos. Six responsive breakpoints ensure perfect delivery on any device. No more visitors leaving because your site is slow.

**Film-Ready** — Unlike generic CMSs, Cimaise speaks your language. Track cameras, lenses, film stocks, developers, and labs. Whether you shoot Portra 400 on a Hasselblad or digital on a Leica, your metadata is organized and searchable.

Expand Down Expand Up @@ -634,21 +634,34 @@ Navigation menus show accurate album counts per category. Protected albums (NSFW

**Upload once. Cimaise handles everything.**

Variants are produced by a capability-detected engine: a fast, low-memory
**libvips** path (shrink-on-load) when available, with automatic fallback to
**Imagick**/GD so it runs unchanged on any host. **HEIC/HEIF uploads** (iPhone
photos) are accepted whenever the server can decode them (libheif via libvips,
or the Imagick HEIC delegate) and converted to standard web variants.

Every photo you upload automatically generates optimized variants:

```text
Your Upload (8000x5333 RAW/JPEG)
Your Upload (8000x5333 RAW / JPEG / PNG / WebP / HEIC)
Originals stored safely in storage/originals/
Public variants generated:
├── Small (768px) → AVIF, WebP, JPEG
├── Medium (1200px) → AVIF, WebP, JPEG
├── Large (1920px) → AVIF, WebP, JPEG
├── XL (2560px) → AVIF, WebP, JPEG
└── XXL (3840px) → AVIF, WebP, JPEG
├── Small (768px) → AVIF, WebP, JPEG (+ JPEG-XL, opt-in)
├── Medium (1200px) → AVIF, WebP, JPEG (+ JPEG-XL, opt-in)
├── Large (1920px) → AVIF, WebP, JPEG (+ JPEG-XL, opt-in)
├── XL (2560px) → AVIF, WebP, JPEG (+ JPEG-XL, opt-in)
└── XXL (3840px) → AVIF, WebP, JPEG (+ JPEG-XL, opt-in)
```

**JPEG-XL (opt-in)** — when enabled in *Settings → Image Processing* and the
server can encode it (a libvips build with libjxl, or the standalone `cjxl`
binary), Cimaise also emits `.jxl` variants and serves them via `<picture>` to
browsers that support them, falling back to AVIF/WebP/JPEG everywhere else. Off
by default; check *Admin → Diagnostics → Imaging Engine* to see what your host
supports.

### Client-Side Compression

Before upload, Cimaise compresses images in your browser:
Expand All @@ -675,6 +688,7 @@ From Admin → Settings → Image Processing:
| AVIF | 50% | 40-70% |
| WebP | 75% | 60-90% |
| JPEG | 85% | 70-95% |
| JPEG-XL (opt-in) | 60% | 50-90% |

---

Expand All @@ -695,7 +709,8 @@ Cimaise focuses on what photographers actually need:
- **Home Page Layout** — 12 templates, switchable anytime

### Image Handling
- **Format Enable/Disable** — Turn off AVIF if your host doesn't support it
- **Format Enable/Disable** — Turn off AVIF if your host doesn't support it, or turn on JPEG-XL where the server can encode it
- **HEIC/HEIF Import** — Accept iPhone photos directly when the server can decode them (libheif/Imagick); originals keep their extension, variants are standard web formats
- **Quality Sliders** — Balance quality vs file size per format
- **Breakpoints** — Customize which sizes get generated
- **Lazy Loading** — Above-fold images load instantly, below-fold on scroll
Expand Down
26 changes: 26 additions & 0 deletions app/Controllers/Admin/DiagnosticsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,32 @@ private function runDiagnostics(): array
]
];

// Imaging engine capabilities (#109)
$caps = \App\Services\Imaging\ImageEngine::capabilities();
$engine = $caps['vips'] ? 'libvips' : ($caps['imagick'] ? 'Imagick' : ($caps['gd'] ? 'GD' : 'none'));
$results['imaging'] = [
'name' => 'Imaging Engine',
'status' => ($caps['vips'] || $caps['imagick']) ? 'ok' : 'warning',
'value' => $engine,
'message' => $caps['vips']
? 'libvips active (fast, low-memory variant generation)'
: ($caps['imagick']
? 'Imagick active (install php-vips for faster, lower-memory generation)'
: ($caps['gd'] ? 'GD active — basic image processing only; install php-vips or Imagick for full variant generation' : 'No capable image engine')),
'details' => [
'Engine' => $engine,
'libvips' => $caps['vips'] ? 'Yes' : 'No',
'Imagick' => $caps['imagick'] ? 'Yes' : 'No',
'GD' => $caps['gd'] ? 'Yes' : 'No',
'HEIC/HEIF read' => $caps['heif_read'] ? 'Yes' : 'No',
'AVIF write' => $caps['avif_write'] ? 'Yes' : 'No',
'JPEG-XL write' => $caps['jxl_write'] ? 'Yes' : 'No',
'Optimizers' => implode(', ', array_keys(array_filter([
'jpegoptim' => $caps['opt_jpegoptim'],
]))) ?: 'none',
],
];

return $results;
}

Expand Down
2 changes: 2 additions & 0 deletions app/Controllers/Admin/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public function save(Request $request, Response $response): Response
'avif' => isset($data['fmt_avif']),
'webp' => isset($data['fmt_webp']),
'jpg' => isset($data['fmt_jpg']),
'jxl' => isset($data['fmt_jxl']), // #109 — emitted only when the build can write JPEG-XL
];
// Invariant: never persist an all-disabled set. With every format off,
// generateVariantsForImage() skips all encodes and NO variant can ever be
Expand All @@ -94,6 +95,7 @@ public function save(Request $request, Response $response): Response
'avif' => max(1, min(100, (int)($data['q_avif'] ?? 50))),
'webp' => max(1, min(100, (int)($data['q_webp'] ?? 75))),
'jpg' => max(1, min(100, (int)($data['q_jpg'] ?? 85))),
'jxl' => max(1, min(100, (int)($data['q_jxl'] ?? 60))),
];
$preview = [
'width' => max(64, (int)($data['preview_w'] ?? 480)),
Expand Down
45 changes: 39 additions & 6 deletions app/Controllers/Frontend/MediaController.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class MediaController extends BaseController
'jpeg' => 'image/jpeg',
'webp' => 'image/webp',
'avif' => 'image/avif',
'jxl' => 'image/jxl',
'png' => 'image/png',
'gif' => 'image/gif',
'tif' => 'image/tiff',
Expand Down Expand Up @@ -88,6 +89,14 @@ private function mimeFromExtension(string $realPath, bool $strict = false): ?str
return $extMime;
}

// JPEG-XL (#109): many libmagic builds don't recognize JXL, so a
// finfo_file() cross-check would spuriously fail the strict gate.
// Verify the JXL magic bytes directly instead — still a real
// magic-byte check (not extension trust), just one finfo can't do.
if ($extMime === 'image/jxl') {
return (!$strict || self::looksLikeJxl($realPath)) ? 'image/jxl' : null;
}

$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) {
// In strict mode we must be able to verify; without finfo we cannot.
Expand All @@ -113,6 +122,30 @@ private function mimeFromExtension(string $realPath, bool $strict = false): ?str
return $extMime ?? $detected;
}

/**
* Verify a file is a genuine JPEG-XL stream by its magic bytes, independent
* of libmagic (which often lacks JXL support). Accepts both encodings:
* - bare codestream: starts with 0xFF 0x0A
* - ISO-BMFF container: the 12-byte "JXL " signature box
*/
private static function looksLikeJxl(string $realPath): bool
{
$fh = @fopen($realPath, 'rb');
if ($fh === false) {
return false;
}
$head = fread($fh, 12);
fclose($fh);
if ($head === false || strlen($head) < 2) {
return false;
}
if (substr($head, 0, 2) === "\xFF\x0A") {
return true; // bare codestream
}
return strlen($head) >= 12
&& $head === "\x00\x00\x00\x0C\x4A\x58\x4C\x20\x0D\x0A\x87\x0A"; // ISO-BMFF box
}

/**
* Stream a file into the response body.
*
Expand Down Expand Up @@ -336,7 +369,7 @@ public function serveProtected(Request $request, Response $response, array $args
}

// Validate format
if (!\in_array($format, ['jpg', 'webp', 'avif'], true)) {
if (!\in_array($format, ['jpg', 'webp', 'avif', 'jxl'], true)) {
return $response->withStatus(400);
}

Expand Down Expand Up @@ -463,7 +496,7 @@ public function serveProtected(Request $request, Response $response, array $args
// a finfo_file() magic-byte cross-check because the path is DB-sourced.
$detectedMime = $this->mimeFromExtension($realPath, strict: true);

$allowedMimes = ['image/jpeg', 'image/webp', 'image/avif', 'image/png'];
$allowedMimes = ['image/jpeg', 'image/webp', 'image/avif', 'image/jxl', 'image/png'];
if ($detectedMime === null || !\in_array($detectedMime, $allowedMimes, true)) {
return $response->withStatus(403);
}
Expand Down Expand Up @@ -627,7 +660,7 @@ public function servePublic(Request $request, Response $response, array $args):
// Parse filename to extract image ID
// Format: {imageId}_{variant}.{format} or {imageId}_blur.{format}
$filename = basename((string) $path);
if (!preg_match('/^(\d+)_([a-z0-9_-]+)\.(jpg|webp|avif|png)$/i', $filename, $matches)) {
if (!preg_match('/^(\d+)_([a-z0-9_-]+)\.(jpg|webp|avif|jxl|png)$/i', $filename, $matches)) {
// Any numeric-prefixed media filename could be an album variant
// from an older/custom generator. Never let it fall through to
// unauthenticated static serving merely because its shape changed.
Expand All @@ -642,7 +675,7 @@ public function servePublic(Request $request, Response $response, array $args):

$imageId = (int)$matches[1];
$variant = strtolower($matches[2]);
$format = strtolower($matches[3]); // regex-validated: jpg|webp|avif|png
$format = strtolower($matches[3]); // regex-validated: jpg|webp|avif|jxl|png

// Get image and album info
$pdo = $this->db->pdo();
Expand Down Expand Up @@ -819,7 +852,7 @@ private function serveResolvedFile(
bool $protected
): Response {
$detectedMime = $this->mimeFromExtension($realPath, strict: true);
if ($detectedMime === null || !\in_array($detectedMime, ['image/jpeg', 'image/webp', 'image/avif', 'image/png'], true)) {
if ($detectedMime === null || !\in_array($detectedMime, ['image/jpeg', 'image/webp', 'image/avif', 'image/jxl', 'image/png'], true)) {
return $response->withStatus(403);
}

Expand Down Expand Up @@ -886,7 +919,7 @@ private function serveStaticFile(Request $request, Response $response, string $r

// Validate MIME type via fast extension lookup; finfo only if unknown extension
$detectedMime = $this->mimeFromExtension($realPath);
$allowedMimes = ['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/gif'];
$allowedMimes = ['image/jpeg', 'image/webp', 'image/avif', 'image/jxl', 'image/png', 'image/gif'];
if ($detectedMime === null || !\in_array($detectedMime, $allowedMimes, true)) {
return $response->withStatus(403);
}
Expand Down
8 changes: 4 additions & 4 deletions app/Controllers/Frontend/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1237,7 +1237,7 @@ public function album(Request $request, Response $response, array $args): Respon
}

// Build responsive sources for <picture>
$sources = ['avif' => [], 'webp' => [], 'jpg' => []];
$sources = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []];
foreach (($image['variants'] ?? []) as $v) {
if (!isset($sources[$v['format']])) {
continue;
Expand Down Expand Up @@ -1829,7 +1829,7 @@ public function albumTemplate(Request $request, Response $response, array $args)
// Never use original_path (points to /storage/originals/ which is not web-accessible)
$bestUrl = '';
$lightboxUrl = '';
$sources = ['avif' => [], 'webp' => [], 'jpg' => []];
$sources = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []];

try {
$variants = $variantsByImage[(int) $img['id']] ?? [];
Expand Down Expand Up @@ -2785,7 +2785,7 @@ private function processImageSourcesBatch(array $images, bool $isProtectedAlbum
} catch (\Throwable $e) {
Logger::warning('PageController: Error processing image sources (batch)', ['error' => $e->getMessage()], 'frontend');
foreach ($images as &$image) {
$image['sources'] = ['avif' => [], 'webp' => [], 'jpg' => []];
$image['sources'] = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []];
$image['variants'] = [];
// Security: never expose non-public storage paths
$fallback = $image['original_path'] ?? '';
Expand All @@ -2803,7 +2803,7 @@ private function processImageSourcesBatch(array $images, bool $isProtectedAlbum
// This eliminates hundreds of is_file() calls per page load
$imageIndex = 0;
foreach ($images as &$image) {
$sources = ['avif' => [], 'webp' => [], 'jpg' => []];
$sources = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []];
$variants = $variantsByImage[(int) $image['id']] ?? [];

foreach ($variants as $variant) {
Expand Down
4 changes: 2 additions & 2 deletions app/Services/CacheWarmService.php
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,7 @@ private function processImageSourcesBatch(array $images): array
} catch (\Throwable $e) {
Logger::warning('CacheWarmService: Error processing image sources (batch)', ['error' => $e->getMessage()], 'frontend');
foreach ($images as &$image) {
$image['sources'] = ['avif' => [], 'webp' => [], 'jpg' => []];
$image['sources'] = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []];
$image['variants'] = [];
// Security: never expose non-public storage paths
$fallback = $image['original_path'] ?? '';
Expand All @@ -881,7 +881,7 @@ private function processImageSourcesBatch(array $images): array
// PERFORMANCE: Trust database records instead of checking filesystem for every variant
$imageIndex = 0;
foreach ($images as &$image) {
$sources = ['avif' => [], 'webp' => [], 'jpg' => []];
$sources = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []];
$variants = $variantsByImage[(int) $image['id']] ?? [];
Comment on lines +884 to 885

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Allinea anche il path single-image al supporto JXL.

Qui hai aggiornato processImageSourcesBatch(), ma a Line 1007 buildImageSources() inizializza ancora sources senza jxl. Questo crea output incoerente tra i due percorsi e può impedire l’emissione di JXL nei flussi che passano da processImageSources().

Diff proposto
     private function buildImageSources(array $image, array $variants): array
     {
         $sources = [
             'avif' => [],
+            'jxl' => [],
             'webp' => [],
             'jpg' => [],
         ];

Also applies to: 1007-1011

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Services/CacheWarmService.php` around lines 884 - 885, The
`processImageSourcesBatch()` method at line 884 was updated to include 'jxl' in
the sources array initialization, but the `buildImageSources()` method at line
1007 still initializes sources without 'jxl'. This creates an inconsistency
between the two paths for handling image sources. Update the sources array
initialization in `buildImageSources()` to include 'jxl' key alongside 'avif',
'webp', and 'jpg', matching the implementation in `processImageSourcesBatch()`
so both methods handle JXL support consistently.


foreach ($variants as $variant) {
Expand Down
4 changes: 2 additions & 2 deletions app/Services/ImageVariantsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,11 @@ public static function getBestLightboxVariant(array $variants): ?array
* Build responsive sources for <picture> element
*
* @param array $variants All variants for an image
* @return array Sources grouped by format ['avif' => [], 'webp' => [], 'jpg' => []]
* @return array Sources grouped by format ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []]
*/
public static function buildResponsiveSources(array $variants): array
{
$sources = ['avif' => [], 'webp' => [], 'jpg' => []];
$sources = ['avif' => [], 'jxl' => [], 'webp' => [], 'jpg' => []];

foreach ($variants as $variant) {
// Skip storage paths
Expand Down
Loading
Loading