Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 176
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
GenerateGalleryPreviewJob
0.00% covered (danger)
0.00%
0 / 176
0.00% covered (danger)
0.00%
0 / 8
600
0.00% covered (danger)
0.00%
0 / 1
 execute
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
30
 generatePreview
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 createSingleImagePreview
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 createSmartMontagePreview
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
42
 calculateOptimalGrid
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 addGradientBackground
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 applyImageStyling
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
2
 clearExistingPreview
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2declare(strict_types=1);
3
4namespace App\Job;
5
6use App\Model\Entity\Image;
7use App\Model\Entity\ImageGallery;
8use Cake\Datasource\FactoryLocator;
9use Cake\Log\LogTrait;
10use Cake\Queue\Job\JobInterface;
11use Cake\Queue\Job\Message;
12use Exception;
13use Imagick;
14use ImagickDraw;
15use ImagickPixel;
16use Interop\Queue\Processor;
17
18/**
19 * GenerateGalleryPreviewJob Class
20 *
21 * This class generates preview montage images for image galleries using ImageMagick.
22 * It creates a 2x3 grid layout showing up to 6 images from the gallery with
23 * professional spacing and styling.
24 */
25class GenerateGalleryPreviewJob implements JobInterface
26{
27    use LogTrait;
28
29    /**
30     * Maximum number of attempts to process the job
31     *
32     * @var int|null
33     */
34    public static int $maxAttempts = 3;
35
36    /**
37     * Whether there should be only one instance of a job on the queue at a time
38     *
39     * @var bool
40     */
41    public static bool $shouldBeUnique = false;
42
43    /**
44     * Preview image dimensions
45     */
46    private const PREVIEW_WIDTH = 400;
47    private const PREVIEW_HEIGHT = 300;
48    private const THUMB_WIDTH = 120;
49    private const THUMB_HEIGHT = 90;
50    private const SPACING = 12;
51    private const CORNER_RADIUS = 8;
52    private const SHADOW_OFFSET = 3;
53    private const SHADOW_BLUR = 6;
54
55    /**
56     * Executes the gallery preview generation task.
57     *
58     * @param \Cake\Queue\Job\Message $message The message containing the gallery ID
59     * @return string|null Returns Processor::ACK on success, Processor::REJECT on error
60     */
61    public function execute(Message $message): ?string
62    {
63        if (!extension_loaded('imagick')) {
64            $this->log(
65                'Imagick extension is not loaded',
66                'error',
67                ['group_name' => 'App\\Job\\GenerateGalleryPreviewJob'],
68            );
69
70            return Processor::REJECT;
71        }
72
73        $galleryId = $message->getArgument('gallery_id');
74        if (!$galleryId) {
75            $this->log(
76                'No gallery_id provided in message',
77                'error',
78                ['group_name' => 'App\\Job\\GenerateGalleryPreviewJob'],
79            );
80
81            return Processor::REJECT;
82        }
83
84        $this->log(
85            sprintf('Starting gallery preview generation for gallery ID: %s', $galleryId),
86            'info',
87            ['group_name' => 'App\\Job\\GenerateGalleryPreviewJob'],
88        );
89
90        try {
91            // Get gallery and images
92            $galleriesTable = FactoryLocator::get('Table')->get('ImageGalleries');
93            $gallery = $galleriesTable->get($galleryId, contain: [
94                'Images' => function ($q) {
95                    return $q->orderBy(['ImageGalleriesImages.position' => 'ASC'])
96                            ->limit(6); // Only need first 6 images for preview
97                },
98            ]);
99
100            if (empty($gallery->images)) {
101                $this->log(
102                    sprintf('Gallery %s has no images, skipping preview generation', $galleryId),
103                    'info',
104                    ['group_name' => 'App\\Job\\GenerateGalleryPreviewJob'],
105                );
106
107                // Clear any existing preview image
108                $this->clearExistingPreview($gallery);
109
110                return Processor::ACK;
111            }
112
113            // Generate the preview
114            $previewPath = $this->generatePreview($gallery);
115
116            // Update gallery with preview filename
117            $previewFilename = basename($previewPath);
118            $gallery->preview_image = $previewFilename;
119            $galleriesTable->save($gallery);
120
121            $this->log(
122                sprintf('Successfully generated preview for gallery %s: %s', $galleryId, $previewFilename),
123                'info',
124                ['group_name' => 'App\\Job\\GenerateGalleryPreviewJob'],
125            );
126
127            return Processor::ACK;
128        } catch (Exception $e) {
129            $this->log(
130                sprintf('Error generating preview for gallery %s: %s', $galleryId, $e->getMessage()),
131                'error',
132                ['group_name' => 'App\\Job\\GenerateGalleryPreviewJob'],
133            );
134
135            return Processor::REJECT;
136        }
137    }
138
139    /**
140     * Generate preview montage image
141     *
142     * @param \App\Model\Entity\ImageGallery $gallery
143     * @return string Path to generated preview image
144     * @throws \Exception
145     */
146    private function generatePreview(ImageGallery $gallery): string
147    {
148        // Ensure preview directory exists
149        $previewDir = WWW_ROOT . 'files' . DS . 'ImageGalleries' . DS . 'preview' . DS;
150        if (!is_dir($previewDir)) {
151            if (!mkdir($previewDir, 0755, true)) {
152                throw new Exception("Failed to create preview directory: {$previewDir}");
153            }
154        }
155
156        // Clear any existing preview
157        $this->clearExistingPreview($gallery);
158
159        $previewPath = $previewDir . $gallery->id . '.jpg';
160        $images = $gallery->images;
161        $imageCount = count($images);
162
163        if ($imageCount === 1) {
164            // Single image - just resize it
165            $this->createSingleImagePreview($images[0], $previewPath);
166        } else {
167            // Multiple images - create smart grid montage
168            $this->createSmartMontagePreview($images, $previewPath);
169        }
170
171        return $previewPath;
172    }
173
174    /**
175     * Create preview for single image
176     *
177     * @param \App\Model\Entity\Image $image
178     * @param string $outputPath
179     * @throws \Exception
180     */
181    private function createSingleImagePreview(Image $image, string $outputPath): void
182    {
183        $imagePath = WWW_ROOT . 'files' . DS . 'Images' . DS . 'image' . DS . $image->image;
184
185        if (!file_exists($imagePath)) {
186            throw new Exception("Source image not found: {$imagePath}");
187        }
188
189        $imagick = new Imagick($imagePath);
190
191        // Resize to fit preview dimensions while maintaining aspect ratio
192        $imagick->thumbnailImage(self::PREVIEW_WIDTH, self::PREVIEW_HEIGHT, true);
193
194        // Create canvas with gradient background
195        $canvas = new Imagick();
196        $canvas->newImage(self::PREVIEW_WIDTH, self::PREVIEW_HEIGHT, new ImagickPixel('white'));
197        $canvas->setImageFormat('jpeg');
198
199        // Add gradient background
200        $this->addGradientBackground($canvas);
201
202        // Center the image on canvas
203        $imageWidth = $imagick->getImageWidth();
204        $imageHeight = $imagick->getImageHeight();
205        $x = (self::PREVIEW_WIDTH - $imageWidth) / 2;
206        $y = (self::PREVIEW_HEIGHT - $imageHeight) / 2;
207
208        // Apply rounded corners and shadow to the image
209        $styledImage = $this->applyImageStyling($imagick);
210
211        $canvas->compositeImage($styledImage, Imagick::COMPOSITE_OVER, intval($x), intval($y));
212
213        $styledImage->clear();
214
215        $canvas->writeImage($outputPath);
216        $canvas->clear();
217        $imagick->clear();
218    }
219
220    /**
221     * Create smart montage preview for multiple images with dynamic grid layout
222     *
223     * @param array $images
224     * @param string $outputPath
225     * @throws \Exception
226     */
227    private function createSmartMontagePreview(array $images, string $outputPath): void
228    {
229        $imageCount = count($images);
230        $gridLayout = $this->calculateOptimalGrid($imageCount);
231        $maxImages = $gridLayout['cols'] * $gridLayout['rows'];
232
233        // Calculate thumbnail dimensions based on grid
234        $thumbWidth = intval(
235            (self::PREVIEW_WIDTH - (($gridLayout['cols'] + 1) * self::SPACING)) / $gridLayout['cols'],
236        );
237        $thumbHeight = intval((
238            self::PREVIEW_HEIGHT -
239            (($gridLayout['rows'] + 1) * self::SPACING)) / $gridLayout['rows']);
240
241        // Create final canvas with gradient background
242        $canvas = new Imagick();
243        $canvas->newImage(self::PREVIEW_WIDTH, self::PREVIEW_HEIGHT, new ImagickPixel('white'));
244        $canvas->setImageFormat('jpeg');
245        $this->addGradientBackground($canvas);
246
247        // Process and place each image
248        $processedImages = [];
249        foreach (array_slice($images, 0, $maxImages) as $index => $image) {
250            $imagePath = WWW_ROOT . 'files' . DS . 'Images' . DS . 'image' . DS . $image->image;
251
252            if (!file_exists($imagePath)) {
253                $this->log(
254                    sprintf('Skipping missing image: %s', $imagePath),
255                    'warning',
256                    ['group_name' => 'App\\Job\\GenerateGalleryPreviewJob'],
257                );
258                continue;
259            }
260
261            try {
262                $img = new Imagick($imagePath);
263                $img->thumbnailImage($thumbWidth, $thumbHeight, true);
264                $img->setImageFormat('jpeg');
265
266                // Apply styling (rounded corners, shadow)
267                $styledImage = $this->applyImageStyling($img);
268
269                // Calculate position in grid
270                $row = intval($index / $gridLayout['cols']);
271                $col = $index % $gridLayout['cols'];
272
273                $x = self::SPACING + ($col * ($thumbWidth + self::SPACING));
274                $y = self::SPACING + ($row * ($thumbHeight + self::SPACING));
275
276                // Composite onto canvas
277                $canvas->compositeImage($styledImage, Imagick::COMPOSITE_OVER, $x, $y);
278
279                $processedImages[] = $img;
280                $styledImage->clear();
281            } catch (Exception $e) {
282                $this->log(
283                    sprintf('Error processing image %s: %s', $imagePath, $e->getMessage()),
284                    'warning',
285                    ['group_name' => 'App\\Job\\GenerateGalleryPreviewJob'],
286                );
287            }
288        }
289
290        if (empty($processedImages)) {
291            throw new Exception('No valid images found for montage');
292        }
293
294        $canvas->writeImage($outputPath);
295
296        // Cleanup
297        $canvas->clear();
298        foreach ($processedImages as $img) {
299            $img->clear();
300        }
301    }
302
303    /**
304     * Calculate optimal grid layout based on image count
305     *
306     * @param int $imageCount
307     * @return array{cols: int, rows: int}
308     */
309    private function calculateOptimalGrid(int $imageCount): array
310    {
311        // Smart grid layouts based on image count
312        return match (true) {
313            $imageCount <= 1 => ['cols' => 1, 'rows' => 1],
314            $imageCount <= 2 => ['cols' => 2, 'rows' => 1],
315            $imageCount <= 3 => ['cols' => 3, 'rows' => 1],
316            $imageCount <= 4 => ['cols' => 2, 'rows' => 2],
317            $imageCount <= 6 => ['cols' => 3, 'rows' => 2],
318            $imageCount <= 9 => ['cols' => 3, 'rows' => 3],
319            default => ['cols' => 4, 'rows' => 3] // For 10+ images
320        };
321    }
322
323    /**
324     * Add gradient background to canvas
325     *
326     * @param \Imagick $canvas
327     */
328    private function addGradientBackground(Imagick $canvas): void
329    {
330        // Create subtle gradient from light gray to white
331        $gradient = new Imagick();
332        $gradient->newPseudoImage(
333            self::PREVIEW_WIDTH,
334            self::PREVIEW_HEIGHT,
335            'gradient:#f8f9fa-#ffffff',
336        );
337
338        // Composite gradient onto canvas
339        $canvas->compositeImage($gradient, Imagick::COMPOSITE_OVER, 0, 0);
340        $gradient->clear();
341    }
342
343    /**
344     * Apply styling to image (rounded corners and drop shadow)
345     *
346     * @param \Imagick $image
347     * @return \Imagick
348     */
349    private function applyImageStyling(Imagick $image): Imagick
350    {
351        $width = $image->getImageWidth();
352        $height = $image->getImageHeight();
353
354        // Create mask for rounded corners
355        $mask = new Imagick();
356        $mask->newImage($width, $height, new ImagickPixel('transparent'));
357
358        $draw = new ImagickDraw();
359        $draw->setFillColor(new ImagickPixel('white'));
360        $draw->roundRectangle(0, 0, $width - 1, $height - 1, self::CORNER_RADIUS, self::CORNER_RADIUS);
361        $mask->drawImage($draw);
362
363        // Apply mask to create rounded corners
364        $image->compositeImage($mask, Imagick::COMPOSITE_DSTIN, 0, 0);
365
366        // Create shadow
367        $shadow = clone $image;
368        $shadow->setImageBackgroundColor(new ImagickPixel('rgba(0,0,0,0.3)'));
369        $shadow->shadowImage(60, self::SHADOW_BLUR, self::SHADOW_OFFSET, self::SHADOW_OFFSET);
370
371        // Create final image with shadow
372        $final = new Imagick();
373        $final->newImage(
374            $width + self::SHADOW_OFFSET + self::SHADOW_BLUR,
375            $height + self::SHADOW_OFFSET + self::SHADOW_BLUR,
376            new ImagickPixel('transparent'),
377        );
378
379        // Composite shadow first, then image
380        $final->compositeImage($shadow, Imagick::COMPOSITE_OVER, 0, 0);
381        $final->compositeImage($image, Imagick::COMPOSITE_OVER, 0, 0);
382
383        // Cleanup
384        $mask->clear();
385        $shadow->clear();
386        $draw->clear();
387
388        return $final;
389    }
390
391    /**
392     * Clear existing preview image if it exists
393     *
394     * @param \App\Model\Entity\ImageGallery $gallery
395     */
396    private function clearExistingPreview(ImageGallery $gallery): void
397    {
398        if ($gallery->preview_image) {
399            $existingPath = WWW_ROOT . 'files' . DS . 'ImageGalleries' . DS . 'preview' . DS . $gallery->preview_image;
400            if (file_exists($existingPath)) {
401                unlink($existingPath);
402            }
403        }
404
405        // Also clean up any file with the gallery ID (in case preview_image field is out of sync)
406        $previewDir = WWW_ROOT . 'files' . DS . 'ImageGalleries' . DS . 'preview' . DS;
407        $galleryPreviewPath = $previewDir . $gallery->id . '.jpg';
408        if (file_exists($galleryPreviewPath)) {
409            unlink($galleryPreviewPath);
410        }
411    }
412}