Total Complexity | 84 |
Total Lines | 490 |
Duplicated Lines | 0 % |
Changes | 0 |
Complex classes like ThumbnailService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use ThumbnailService, and based on these observations, apply Extract Interface, too.
1 | <?php declare(strict_types=1); |
||
31 | #[Package('buyers-experience')] |
||
32 | class ThumbnailService |
||
33 | { |
||
34 | /** |
||
35 | * @internal |
||
36 | */ |
||
37 | public function __construct( |
||
38 | private readonly EntityRepository $thumbnailRepository, |
||
39 | private readonly FilesystemOperator $filesystemPublic, |
||
40 | private readonly FilesystemOperator $filesystemPrivate, |
||
41 | private readonly EntityRepository $mediaFolderRepository, |
||
42 | private readonly EventDispatcherInterface $dispatcher, |
||
43 | private readonly EntityIndexer $indexer, |
||
44 | private readonly Connection $connection |
||
45 | ) { |
||
46 | } |
||
47 | |||
48 | public function generate(MediaCollection $collection, Context $context): int |
||
49 | { |
||
50 | $delete = []; |
||
51 | |||
52 | $generate = []; |
||
53 | |||
54 | foreach ($collection as $media) { |
||
55 | if ($media->getThumbnails() === null) { |
||
56 | throw MediaException::thumbnailAssociationNotLoaded(); |
||
57 | } |
||
58 | |||
59 | if (!$this->mediaCanHaveThumbnails($media, $context)) { |
||
60 | $delete = [...$delete, ...$media->getThumbnails()->getIds()]; |
||
61 | |||
62 | continue; |
||
63 | } |
||
64 | |||
65 | $mediaFolder = $media->getMediaFolder(); |
||
66 | if ($mediaFolder === null) { |
||
67 | continue; |
||
68 | } |
||
69 | |||
70 | $config = $mediaFolder->getConfiguration(); |
||
71 | if ($config === null) { |
||
72 | continue; |
||
73 | } |
||
74 | |||
75 | $delete = [...$delete, ...$media->getThumbnails()->getIds()]; |
||
76 | |||
77 | $generate[] = $media; |
||
78 | } |
||
79 | |||
80 | // disable media indexing to trigger it once after processing all thumbnails |
||
81 | $context->addState(EntityIndexerRegistry::DISABLE_INDEXING); |
||
82 | |||
83 | if (!empty($delete)) { |
||
84 | $context->addState(MediaDeletionSubscriber::SYNCHRONE_FILE_DELETE); |
||
85 | |||
86 | $delete = \array_values(\array_map(fn (string $id) => ['id' => $id], $delete)); |
||
87 | |||
88 | $this->thumbnailRepository->delete($delete, $context); |
||
89 | } |
||
90 | |||
91 | $updates = []; |
||
92 | foreach ($generate as $media) { |
||
93 | if ($media->getMediaFolder() === null || $media->getMediaFolder()->getConfiguration() === null) { |
||
94 | continue; |
||
95 | } |
||
96 | |||
97 | $config = $media->getMediaFolder()->getConfiguration(); |
||
98 | |||
99 | $thumbnails = $this->generateAndSave($media, $config, $context, $config->getMediaThumbnailSizes()); |
||
100 | |||
101 | foreach ($thumbnails as $thumbnail) { |
||
102 | $updates[] = $thumbnail; |
||
103 | } |
||
104 | } |
||
105 | |||
106 | $this->indexer->handle(new MediaIndexingMessage($collection->getIds())); |
||
107 | |||
108 | return \count($updates); |
||
109 | } |
||
110 | |||
111 | /** |
||
112 | * @throws MediaException |
||
113 | */ |
||
114 | public function updateThumbnails(MediaEntity $media, Context $context, bool $strict): int |
||
115 | { |
||
116 | if (!$this->mediaCanHaveThumbnails($media, $context)) { |
||
117 | $this->deleteAssociatedThumbnails($media, $context); |
||
118 | |||
119 | return 0; |
||
120 | } |
||
121 | |||
122 | $mediaFolder = $media->getMediaFolder(); |
||
123 | if ($mediaFolder === null) { |
||
124 | return 0; |
||
125 | } |
||
126 | |||
127 | $config = $mediaFolder->getConfiguration(); |
||
128 | if ($config === null) { |
||
129 | return 0; |
||
130 | } |
||
131 | |||
132 | $strict = \func_get_args()[2] ?? false; |
||
133 | |||
134 | if ($config->getMediaThumbnailSizes() === null) { |
||
135 | return 0; |
||
136 | } |
||
137 | if ($media->getThumbnails() === null) { |
||
138 | return 0; |
||
139 | } |
||
140 | |||
141 | $toBeCreatedSizes = new MediaThumbnailSizeCollection($config->getMediaThumbnailSizes()->getElements()); |
||
142 | $toBeDeletedThumbnails = new MediaThumbnailCollection($media->getThumbnails()->getElements()); |
||
143 | |||
144 | foreach ($toBeCreatedSizes as $thumbnailSize) { |
||
145 | foreach ($toBeDeletedThumbnails as $thumbnail) { |
||
146 | if (!$this->isSameDimension($thumbnail, $thumbnailSize)) { |
||
147 | continue; |
||
148 | } |
||
149 | |||
150 | if ($strict === true && !$this->getFileSystem($media)->fileExists($thumbnail->getPath())) { |
||
151 | continue; |
||
152 | } |
||
153 | |||
154 | $toBeDeletedThumbnails->remove($thumbnail->getId()); |
||
155 | $toBeCreatedSizes->remove($thumbnailSize->getId()); |
||
156 | |||
157 | continue 2; |
||
158 | } |
||
159 | } |
||
160 | |||
161 | $delete = \array_values(\array_map(static fn (string $id) => ['id' => $id], $toBeDeletedThumbnails->getIds())); |
||
162 | |||
163 | $update = $this->connection->transactional(function () use ($delete, $media, $config, $context, $toBeCreatedSizes): array { |
||
164 | return $context->state(function () use ($delete, $media, $config, $context, $toBeCreatedSizes): array { |
||
165 | $this->thumbnailRepository->delete($delete, $context); |
||
166 | |||
167 | $updated = $this->generateAndSave($media, $config, $context, $toBeCreatedSizes); |
||
168 | |||
169 | $this->indexer->handle(new MediaIndexingMessage([$media->getId()])); |
||
170 | |||
171 | return $updated; |
||
172 | }, EntityIndexerRegistry::DISABLE_INDEXING); |
||
173 | }); |
||
174 | |||
175 | return \count($update); |
||
176 | } |
||
177 | |||
178 | public function deleteThumbnails(MediaEntity $media, Context $context): void |
||
179 | { |
||
180 | $this->deleteAssociatedThumbnails($media, $context); |
||
181 | } |
||
182 | |||
183 | /** |
||
184 | * @return array<array{id:string, mediaId:string, width:int, height:int}> |
||
185 | */ |
||
186 | private function generateAndSave(MediaEntity $media, MediaFolderConfigurationEntity $config, Context $context, ?MediaThumbnailSizeCollection $sizes): array |
||
187 | { |
||
188 | if ($sizes === null || $sizes->count() === 0) { |
||
189 | return []; |
||
190 | } |
||
191 | |||
192 | $image = $this->getImageResource($media); |
||
193 | |||
194 | $imageSize = $this->getOriginalImageSize($image); |
||
195 | |||
196 | $records = []; |
||
197 | |||
198 | $type = $media->getMediaType(); |
||
199 | if ($type === null) { |
||
200 | throw MediaException::mediaTypeNotLoaded($media->getId()); |
||
201 | } |
||
202 | |||
203 | $mapped = []; |
||
204 | foreach ($sizes as $size) { |
||
205 | $id = Uuid::randomHex(); |
||
206 | |||
207 | $mapped[$size->getId()] = $id; |
||
208 | |||
209 | $records[] = [ |
||
210 | 'id' => $id, |
||
211 | 'mediaId' => $media->getId(), |
||
212 | 'width' => $size->getWidth(), |
||
213 | 'height' => $size->getHeight(), |
||
214 | ]; |
||
215 | } |
||
216 | |||
217 | // write thumbnail records to trigger path generation afterward |
||
218 | $context->scope(Context::SYSTEM_SCOPE, function ($context) use ($records): void { |
||
219 | $context->addState(EntityIndexerRegistry::DISABLE_INDEXING); |
||
220 | |||
221 | $this->thumbnailRepository->create($records, $context); |
||
222 | }); |
||
223 | |||
224 | $ids = \array_column($records, 'id'); |
||
225 | |||
226 | // triggers the path generation for the persisted thumbnails |
||
227 | $this->dispatcher->dispatch(new UpdateThumbnailPathEvent($ids)); |
||
228 | |||
229 | // create hash map for easy path access |
||
230 | $paths = $this->connection->fetchAllKeyValue( |
||
231 | 'SELECT LOWER(HEX(id)), path FROM media_thumbnail WHERE id IN (:ids)', |
||
232 | ['ids' => Uuid::fromHexToBytesList($ids)], |
||
233 | ['ids' => ArrayParameterType::STRING] |
||
234 | ); |
||
235 | |||
236 | try { |
||
237 | foreach ($sizes as $size) { |
||
238 | $id = $mapped[$size->getId()]; |
||
239 | |||
240 | $thumbnailSize = $this->calculateThumbnailSize($imageSize, $size, $config); |
||
241 | |||
242 | $thumbnail = $this->createNewImage($image, $type, $imageSize, $thumbnailSize); |
||
243 | |||
244 | $path = $paths[$id]; |
||
245 | |||
246 | $this->writeThumbnail($thumbnail, $media, $path, $config->getThumbnailQuality()); |
||
247 | |||
248 | $fileSystem = $this->getFileSystem($media); |
||
249 | if ($imageSize === $thumbnailSize && $fileSystem->fileSize($media->getPath()) < $fileSystem->fileSize($path)) { |
||
250 | // write file to file system |
||
251 | $fileSystem->write($path, $fileSystem->read($media->getPath())); |
||
252 | } |
||
253 | |||
254 | imagedestroy($thumbnail); |
||
255 | } |
||
256 | imagedestroy($image); |
||
257 | } finally { |
||
258 | return $records; |
||
259 | } |
||
260 | } |
||
261 | |||
262 | private function ensureConfigIsLoaded(MediaEntity $media, Context $context): void |
||
263 | { |
||
264 | $mediaFolderId = $media->getMediaFolderId(); |
||
265 | if ($mediaFolderId === null) { |
||
266 | return; |
||
267 | } |
||
268 | |||
269 | if ($media->getMediaFolder() !== null) { |
||
270 | return; |
||
271 | } |
||
272 | |||
273 | $criteria = new Criteria([$mediaFolderId]); |
||
274 | $criteria->addAssociation('configuration.mediaThumbnailSizes'); |
||
275 | |||
276 | /** @var MediaFolderEntity $folder */ |
||
277 | $folder = $this->mediaFolderRepository->search($criteria, $context)->get($mediaFolderId); |
||
278 | $media->setMediaFolder($folder); |
||
279 | } |
||
280 | |||
281 | private function getImageResource(MediaEntity $media): \GdImage |
||
325 | } |
||
326 | |||
327 | /** |
||
328 | * @return array{width: int, height: int} |
||
329 | */ |
||
330 | private function getOriginalImageSize(\GdImage $image): array |
||
331 | { |
||
332 | return [ |
||
333 | 'width' => imagesx($image), |
||
334 | 'height' => imagesy($image), |
||
335 | ]; |
||
336 | } |
||
337 | |||
338 | /** |
||
339 | * @param array{width: int, height: int} $imageSize |
||
340 | * |
||
341 | * @return array{width: int, height: int} |
||
342 | */ |
||
343 | private function calculateThumbnailSize( |
||
344 | array $imageSize, |
||
345 | MediaThumbnailSizeEntity $preferredThumbnailSize, |
||
346 | MediaFolderConfigurationEntity $config |
||
347 | ): array { |
||
348 | if (!$config->getKeepAspectRatio() || $preferredThumbnailSize->getWidth() !== $preferredThumbnailSize->getHeight()) { |
||
349 | $calculatedWidth = $preferredThumbnailSize->getWidth(); |
||
350 | $calculatedHeight = $preferredThumbnailSize->getHeight(); |
||
351 | |||
352 | $useOriginalSizeInThumbnails = $imageSize['width'] < $calculatedWidth || $imageSize['height'] < $calculatedHeight; |
||
353 | |||
354 | return $useOriginalSizeInThumbnails ? [ |
||
355 | 'width' => $imageSize['width'], |
||
356 | 'height' => $imageSize['height'], |
||
357 | ] : [ |
||
358 | 'width' => $calculatedWidth, |
||
359 | 'height' => $calculatedHeight, |
||
360 | ]; |
||
361 | } |
||
362 | |||
363 | if ($imageSize['width'] >= $imageSize['height']) { |
||
364 | $aspectRatio = $imageSize['height'] / $imageSize['width']; |
||
365 | |||
366 | $calculatedWidth = $preferredThumbnailSize->getWidth(); |
||
367 | $calculatedHeight = (int) ceil($preferredThumbnailSize->getHeight() * $aspectRatio); |
||
368 | |||
369 | $useOriginalSizeInThumbnails = $imageSize['width'] < $calculatedWidth || $imageSize['height'] < $calculatedHeight; |
||
370 | |||
371 | return $useOriginalSizeInThumbnails ? [ |
||
372 | 'width' => $imageSize['width'], |
||
373 | 'height' => $imageSize['height'], |
||
374 | ] : [ |
||
375 | 'width' => $calculatedWidth, |
||
376 | 'height' => $calculatedHeight, |
||
377 | ]; |
||
378 | } |
||
379 | |||
380 | $aspectRatio = $imageSize['width'] / $imageSize['height']; |
||
381 | |||
382 | $calculatedWidth = (int) ceil($preferredThumbnailSize->getWidth() * $aspectRatio); |
||
383 | $calculatedHeight = $preferredThumbnailSize->getHeight(); |
||
384 | |||
385 | $useOriginalSizeInThumbnails = $imageSize['width'] < $calculatedWidth || $imageSize['height'] < $calculatedHeight; |
||
386 | |||
387 | return $useOriginalSizeInThumbnails ? [ |
||
388 | 'width' => $imageSize['width'], |
||
389 | 'height' => $imageSize['height'], |
||
390 | ] : [ |
||
391 | 'width' => $calculatedWidth, |
||
392 | 'height' => $calculatedHeight, |
||
393 | ]; |
||
394 | } |
||
395 | |||
396 | /** |
||
397 | * @param array{width: int, height: int} $originalImageSize |
||
398 | * @param array{width: int, height: int} $thumbnailSize |
||
399 | */ |
||
400 | private function createNewImage(\GdImage $mediaImage, MediaType $type, array $originalImageSize, array $thumbnailSize): \GdImage |
||
401 | { |
||
402 | $thumbnail = imagecreatetruecolor($thumbnailSize['width'], $thumbnailSize['height']); |
||
403 | |||
404 | if ($thumbnail === false) { |
||
405 | throw MediaException::cannotCreateImage(); |
||
406 | } |
||
407 | |||
408 | if (!$type->is(ImageType::TRANSPARENT)) { |
||
409 | $colorWhite = (int) imagecolorallocate($thumbnail, 255, 255, 255); |
||
410 | imagefill($thumbnail, 0, 0, $colorWhite); |
||
411 | } else { |
||
412 | imagealphablending($thumbnail, false); |
||
413 | } |
||
414 | |||
415 | imagesavealpha($thumbnail, true); |
||
416 | imagecopyresampled( |
||
417 | $thumbnail, |
||
418 | $mediaImage, |
||
419 | 0, |
||
420 | 0, |
||
421 | 0, |
||
422 | 0, |
||
423 | $thumbnailSize['width'], |
||
424 | $thumbnailSize['height'], |
||
425 | $originalImageSize['width'], |
||
426 | $originalImageSize['height'] |
||
427 | ); |
||
428 | |||
429 | return $thumbnail; |
||
430 | } |
||
431 | |||
432 | private function writeThumbnail(\GdImage $thumbnail, MediaEntity $media, string $url, int $quality): void |
||
433 | { |
||
434 | ob_start(); |
||
435 | switch ($media->getMimeType()) { |
||
436 | case 'image/png': |
||
437 | imagepng($thumbnail); |
||
438 | |||
439 | break; |
||
440 | case 'image/gif': |
||
441 | imagegif($thumbnail); |
||
442 | |||
443 | break; |
||
444 | case 'image/jpg': |
||
445 | case 'image/jpeg': |
||
446 | imagejpeg($thumbnail, null, $quality); |
||
447 | |||
448 | break; |
||
449 | case 'image/webp': |
||
450 | if (!\function_exists('imagewebp')) { |
||
451 | throw MediaException::thumbnailCouldNotBeSaved($url); |
||
452 | } |
||
453 | |||
454 | imagewebp($thumbnail, null, $quality); |
||
455 | |||
456 | break; |
||
457 | } |
||
458 | $imageFile = ob_get_contents(); |
||
459 | ob_end_clean(); |
||
460 | |||
461 | try { |
||
462 | $this->getFileSystem($media)->write($url, (string) $imageFile); |
||
463 | } catch (\Exception) { |
||
464 | throw MediaException::thumbnailCouldNotBeSaved($url); |
||
465 | } |
||
466 | } |
||
467 | |||
468 | private function mediaCanHaveThumbnails(MediaEntity $media, Context $context): bool |
||
469 | { |
||
470 | if (!$media->hasFile()) { |
||
471 | return false; |
||
472 | } |
||
473 | |||
474 | if (!$this->thumbnailsAreGeneratable($media)) { |
||
475 | return false; |
||
476 | } |
||
477 | |||
478 | $this->ensureConfigIsLoaded($media, $context); |
||
479 | |||
480 | if ($media->getMediaFolder() === null || $media->getMediaFolder()->getConfiguration() === null) { |
||
481 | return false; |
||
482 | } |
||
483 | |||
484 | return $media->getMediaFolder()->getConfiguration()->getCreateThumbnails(); |
||
485 | } |
||
486 | |||
487 | private function thumbnailsAreGeneratable(MediaEntity $media): bool |
||
488 | { |
||
489 | return $media->getMediaType() instanceof ImageType |
||
490 | && !$media->getMediaType()->is(ImageType::VECTOR_GRAPHIC) |
||
491 | && !$media->getMediaType()->is(ImageType::ANIMATED) |
||
492 | && !$media->getMediaType()->is(ImageType::ICON); |
||
493 | } |
||
494 | |||
495 | private function deleteAssociatedThumbnails(MediaEntity $media, Context $context): void |
||
496 | { |
||
497 | if (!$media->getThumbnails()) { |
||
498 | throw MediaException::mediaContainsNoThumbnails(); |
||
499 | } |
||
500 | |||
501 | $delete = $media->getThumbnails()->getIds(); |
||
502 | |||
503 | $delete = \array_values(\array_map(static fn (string $id) => ['id' => $id], $delete)); |
||
504 | |||
505 | $this->thumbnailRepository->delete($delete, $context); |
||
506 | } |
||
507 | |||
508 | private function getFileSystem(MediaEntity $media): FilesystemOperator |
||
515 | } |
||
516 | |||
517 | private function isSameDimension(MediaThumbnailEntity $thumbnail, MediaThumbnailSizeEntity $thumbnailSize): bool |
||
518 | { |
||
521 | } |
||
522 | } |
||
523 |