diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 8ac8497a..bc39b532 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -971,15 +971,7 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu $image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH); } - $dataUri = $image->exportAsBase64('png'); - if (!preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) { - return null; - } - $imageData = base64_decode($matches[1], true); - if ($imageData === false) { - return null; - } - return $imageData; + return $image->exportBinary('png'); } catch (Throwable $e) { warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage()); return null; diff --git a/services/nginx/app/modules/dynamicimages/interfaces/dynamicimages_image_i.php b/services/nginx/app/modules/dynamicimages/interfaces/dynamicimages_image_i.php index 1cd2816b..7dd8562e 100644 --- a/services/nginx/app/modules/dynamicimages/interfaces/dynamicimages_image_i.php +++ b/services/nginx/app/modules/dynamicimages/interfaces/dynamicimages_image_i.php @@ -86,6 +86,14 @@ interface dynamicimages_image_i */ public function exportAsBase64(?string $format = null, int $quality = 90): string; + /** + * Export the composed image as binary image data. + * @param string|null $format Optional target format (e.g. 'png', 'jpeg') + * @param int $quality Quality for lossy formats (0-100) + * @return string binary image data + */ + public function exportBinary(?string $format = null, int $quality = 90): string; + /** * Directly serve the composed image to the client with proper headers. * Convenience wrapper for outputting binary image data. @@ -94,4 +102,4 @@ interface dynamicimages_image_i * @param int $quality Quality for lossy formats (0-100) */ public function servePicture(?string $format = null, int $quality = 90): void; -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/dynamicimages/traits/dynamicimages_image_t.php b/services/nginx/app/modules/dynamicimages/traits/dynamicimages_image_t.php index 725b1b7a..c30a9662 100644 --- a/services/nginx/app/modules/dynamicimages/traits/dynamicimages_image_t.php +++ b/services/nginx/app/modules/dynamicimages/traits/dynamicimages_image_t.php @@ -321,20 +321,8 @@ trait dynamicimages_image_t */ public function exportAsBase64(?string $format = null, int $quality = 90): string { - // If a canvas is initialized, export that as PNG by default if ($this->image instanceof \Imagick) { - $img = clone $this->image; - $img->setImageFormat('png'); - // Quality influences compression for PNG differently; keep as hint - if ($format !== null && strtolower($format) !== 'png') { - // For now we only support PNG for composed images as requested - } - // Strip metadata to reduce size - $img->stripImage(); - $blob = $img->getImageBlob(); - $img->clear(); - $img->destroy(); - return 'data:image/png;base64,' . base64_encode($blob); + return 'data:image/png;base64,' . base64_encode($this->exportBinary($format, $quality)); } // Fallback: export first asset as-is @@ -355,6 +343,40 @@ trait dynamicimages_image_t return 'data:' . $mime . ';base64,' . base64_encode($data); } + public function exportBinary(?string $format = null, int $quality = 90): string + { + // If a canvas is initialized, export that as PNG by default + if ($this->image instanceof \Imagick) { + $img = clone $this->image; + $img->setImageFormat('png'); + // Quality influences compression for PNG differently; keep as hint + if ($format !== null && strtolower($format) !== 'png') { + // For now we only support PNG for composed images as requested + } + // Strip metadata to reduce size + $img->stripImage(); + $blob = $img->getImageBlob(); + $img->clear(); + $img->destroy(); + return $blob; + } + + // Fallback: export first asset as-is + if (empty($this->assets)) { + throw new \RuntimeException('No assets available to export.'); + } + $asset = $this->assets[0]; + $path = $asset->getPath(); + if (!is_readable($path)) { + throw new \RuntimeException('Asset is not readable: ' . $path); + } + $data = file_get_contents($path); + if ($data === false) { + throw new \RuntimeException('Failed to read asset: ' . $path); + } + return $data; + } + public function getAsset(string $asset_name): ?dynamicimages_asset { foreach ($this->assets as $asset) { @@ -371,22 +393,18 @@ trait dynamicimages_image_t */ public function outputImage(?string $format = null, int $quality = 90): void { - $dataUri = $this->exportAsBase64($format, $quality); - // Extract mime type and base64 data - if (preg_match('/^data:(image\/[a-zA-Z0-9+.-]+);base64,(.*)$/', $dataUri, $matches)) { - $mimeType = $matches[1]; - $base64Data = $matches[2]; - // Decode base64 data - $imageData = base64_decode($base64Data); - if ($imageData !== false) { - // Send appropriate headers - header('Content-Type: ' . $mimeType); - header('Content-Length: ' . strlen($imageData)); - // Output the image data - echo $imageData; - exit; - } + $mimeType = 'image/png'; + if (!$this->image instanceof \Imagick && !empty($this->assets)) { + $asset = $this->assets[0]; + $path = $asset->getPath(); + $imgInfo = is_readable($path) ? @getimagesize($path) : false; + $mimeType = is_array($imgInfo) && isset($imgInfo['mime']) ? $imgInfo['mime'] : 'application/octet-stream'; } + $imageData = $this->exportBinary($format, $quality); + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . strlen($imageData)); + echo $imageData; + exit; } /** diff --git a/services/nginx/app/routes/departmentLanesRoute.php b/services/nginx/app/routes/departmentLanesRoute.php index e5368c9a..eb49ef67 100644 --- a/services/nginx/app/routes/departmentLanesRoute.php +++ b/services/nginx/app/routes/departmentLanesRoute.php @@ -271,28 +271,21 @@ class departmentLanesRoute // Cache check $cacheKey = null; if (defined('redis')) { - // Cache only the default image variant to avoid unbounded cache key growth - // from request-controlled parameters (buttons/current_step/etc.). - $isDefaultVariant = $buttons === null - && $current_step === 0 - && !(bool)$only_current_step - && $vehicle_type === null; - - if ($isDefaultVariant) { - $cacheParams = [ - 'department' => $department_id, - 'lane' => $lane_id, - 'dynamic_image_id' => $dynamic_image_id, - 'dynamic_image_size' => $dynamic_image_size, - ]; - $cacheKey = 'dynamic_image:' . md5(json_encode($cacheParams)); - $cachedImage = redis->get($cacheKey); - if ($cachedImage) { - header('Content-Type: image/png'); - header('Content-Length: ' . strlen($cachedImage)); - echo $cachedImage; - exit; - } + $cacheKey = self::buildDynamicImageCacheKey([ + 'dynamic_image_id' => $dynamic_image_id, + 'buttons' => $buttons, + 'current_step' => $current_step, + 'only_current_step' => $only_current_step, + 'vehicle_type' => $vehicle_type, + 'dynamic_image_size' => $dynamic_image_size, + 'thumb_position' => $thumb_position, + ]); + $cachedImage = $cacheKey === null ? false : redis->get($cacheKey); + if ($cachedImage) { + header('Content-Type: image/png'); + header('Content-Length: ' . strlen($cachedImage)); + echo $cachedImage; + exit; } } @@ -323,19 +316,13 @@ class departmentLanesRoute $image->resizeToMaxWidth(self::RELEVANT_DYNAMIC_IMAGE_MAX_WIDTH); } - // If caching is enabled, we need to capture the output or use export if ($cacheKey && defined('redis')) { - $dataUri = $image->exportAsBase64('png'); - if (preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) { - $imageData = base64_decode($matches[1]); - if ($imageData !== false) { - redis->setEx($cacheKey, $imageData, 86400); // Cache for 24 hours - header('Content-Type: image/png'); - header('Content-Length: ' . strlen($imageData)); - echo $imageData; - exit; - } - } + $imageData = $image->exportBinary('png'); + redis->setEx($cacheKey, $imageData, 86400); // Cache for 24 hours + header('Content-Type: image/png'); + header('Content-Length: ' . strlen($imageData)); + echo $imageData; + exit; } $image->servePicture('png'); @@ -549,4 +536,34 @@ class departmentLanesRoute ? $mode : selfserve_dynamic_image_size_c::SIZE_ORIGINAL; } + + /** + * @param array{ + * dynamic_image_id:int, + * buttons:array|null, + * current_step:int, + * only_current_step:bool, + * vehicle_type:int|null, + * dynamic_image_size:string, + * thumb_position:int|null + * } $variant + */ + private static function buildDynamicImageCacheKey(array $variant): ?string + { + $cacheParams = [ + 'dynamic_image_id' => (int)$variant['dynamic_image_id'], + 'buttons' => $variant['buttons'], + 'current_step' => (int)$variant['current_step'], + 'only_current_step' => (bool)$variant['only_current_step'], + 'vehicle_type' => $variant['vehicle_type'], + 'dynamic_image_size' => (string)$variant['dynamic_image_size'], + ]; + + if ($variant['thumb_position'] !== null) { + $cacheParams['thumb_position'] = (int)$variant['thumb_position']; + } + + $json = json_encode($cacheParams); + return $json === false ? null : 'dynamic_image:' . md5($json); + } } diff --git a/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php b/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php index 9b33e6ad..369656ce 100644 --- a/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php +++ b/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php @@ -56,8 +56,16 @@ namespace { expect($moduleConfig)->toContain('public selfserve_dynamic_image_size_c $dynamic_image_size;'); expect($route)->toContain('$dynamic_image_size = self::getSelfServeDynamicImageSizeMode();'); expect($route)->toContain("'dynamic_image_size' => \$dynamic_image_size"); + expect($route)->toContain('self::buildDynamicImageCacheKey'); + expect($route)->toContain("'thumb_position' => \$thumb_position"); + expect($route)->toContain("'buttons' => \$buttons"); + expect($route)->toContain("'current_step' => \$current_step"); + expect($route)->toContain("'only_current_step' => \$only_current_step"); + expect($route)->toContain("'vehicle_type' => \$vehicle_type"); expect($route)->toContain("resizeToMaxWidth(self::RELEVANT_DYNAMIC_IMAGE_MAX_WIDTH)"); expect($imageTrait)->toContain('function resizeToMaxWidth(int $maxWidth)'); + expect($imageTrait)->toContain('function exportBinary(?string $format = null, int $quality = 90): string'); + expect($route)->toContain("\$imageData = \$image->exportBinary('png');"); }); it('accepts ordered dynamic image button tokens including program picker reset start and zero', function (): void { diff --git a/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php b/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php index 3349537d..f418b889 100644 --- a/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php +++ b/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php @@ -16,4 +16,5 @@ it('registers dynamic image pre-render cron task and related helpers', function expect($content)->toContain('getSelfServeDynamicImageSizeModeForCron'); expect($content)->toContain("'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron()"); expect($content)->toContain('resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH)'); + expect($content)->toContain("\$image->exportBinary('png')"); });