diff --git a/app/Services/Featured/FeaturedArtworkSelector.php b/app/Services/Featured/FeaturedArtworkSelector.php new file mode 100644 index 00000000..9216719b --- /dev/null +++ b/app/Services/Featured/FeaturedArtworkSelector.php @@ -0,0 +1,31 @@ +select('artworks.*') + ->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id') + ->where('af.is_active', true) + ->whereNull('af.deleted_at') + ->where(function (Builder $query): void { + $query->whereNull('af.expires_at') + ->orWhere('af.expires_at', '>', now()); + }) + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNull('artworks.deleted_at') + ->whereNotNull('artworks.published_at') + ->where('artworks.published_at', '<=', now()) + ->distinct() + ->orderByDesc('artworks.id'); + } +} diff --git a/app/Services/Images/FeaturedArtworkThumbnailGenerator.php b/app/Services/Images/FeaturedArtworkThumbnailGenerator.php new file mode 100644 index 00000000..61b4236f --- /dev/null +++ b/app/Services/Images/FeaturedArtworkThumbnailGenerator.php @@ -0,0 +1,217 @@ +manager = extension_loaded('gd') + ? new ImageManager(new GdDriver) + : new ImageManager(new ImagickDriver); + } catch (\Throwable) { + $this->manager = null; + } + } + + /** + * @return array{existing:list,missing:list,target_variants:list} + */ + public function plan(Artwork $artwork, bool $force = false): array + { + $disk = Storage::disk($this->storage->objectDiskName()); + $existing = []; + $missing = []; + + foreach ($this->paths->variantNames() as $variant) { + $objectPath = $this->paths->objectPath($artwork, $variant); + + if ($disk->exists($objectPath)) { + $existing[] = $variant; + + continue; + } + + $missing[] = $variant; + } + + return [ + 'existing' => $existing, + 'missing' => $missing, + 'target_variants' => $force ? $this->paths->variantNames() : $missing, + ]; + } + + /** + * @return array{existing:list,missing:list,target_variants:list,generated:int,skipped:int,generated_variants:list,generated_paths:list,failed:array} + */ + public function generate(Artwork $artwork, bool $force = false): array + { + $this->assertImageManager(); + + $plan = $this->plan($artwork, $force); + $targetVariants = $plan['target_variants']; + + if ($targetVariants === []) { + return $plan + [ + 'generated' => 0, + 'skipped' => count($plan['existing']), + 'generated_variants' => [], + 'generated_paths' => [], + 'failed' => [], + ]; + } + + ['path' => $sourcePath, 'temporary' => $temporaryPath] = $this->resolveSourcePath($artwork); + + $generatedVariants = []; + $generatedPaths = []; + $failed = []; + + try { + foreach ($targetVariants as $variant) { + $config = $this->paths->variantConfig($variant); + + if ($config === null) { + $failed[$variant] = 'Unknown featured thumbnail variant.'; + + continue; + } + + try { + $image = $this->manager->read($sourcePath)->cover((int) $config['width'], (int) $config['height']); + $encoded = (string) $image->encode(new WebpEncoder((int) $config['quality'])); + $objectPath = $this->paths->objectPath($artwork, $variant); + + $this->storage->putObjectContents($objectPath, $encoded, 'image/webp'); + + $generatedVariants[] = $variant; + $generatedPaths[] = $objectPath; + } catch (\Throwable $exception) { + $failed[$variant] = $exception->getMessage(); + } + } + } finally { + if ($temporaryPath !== null && $temporaryPath !== '') { + File::delete($temporaryPath); + } + } + + if ($generatedPaths !== []) { + $this->cdnPurge->purgeArtworkObjectPaths($generatedPaths, [ + 'artwork_id' => $artwork->id, + 'reason' => 'featured_artwork_thumbnails_generated', + 'force' => $force, + ]); + } + + return $plan + [ + 'generated' => count($generatedVariants), + 'skipped' => max(0, count($targetVariants) - count($generatedVariants) - count($failed)) + count($plan['existing']), + 'generated_variants' => $generatedVariants, + 'generated_paths' => $generatedPaths, + 'failed' => $failed, + ]; + } + + private function assertImageManager(): void + { + if ($this->manager !== null) { + return; + } + + throw new RuntimeException('Image processing is not available on this environment.'); + } + + /** + * @return array{path:string,temporary:?string} + */ + private function resolveSourcePath(Artwork $artwork): array + { + $localPath = $this->locator->resolveLocalPath($artwork); + + if ($this->isUsableSourceFile($localPath)) { + return ['path' => $localPath, 'temporary' => null]; + } + + $objectPath = $this->locator->resolveObjectPath($artwork); + $extension = strtolower((string) pathinfo($objectPath, PATHINFO_EXTENSION)); + + if (! in_array($extension, self::ALLOWED_SOURCE_EXTENSIONS, true)) { + throw new RuntimeException('Original artwork source is not a supported image file.'); + } + + $contents = $this->storage->readObject($objectPath); + + if (! is_string($contents) || $contents === '' || ! $this->isImageContents($contents)) { + throw new RuntimeException('Original artwork source is missing or is not a valid image.'); + } + + $temporaryPath = tempnam(sys_get_temp_dir(), 'featured-artwork-'); + if ($temporaryPath === false) { + throw new RuntimeException('Unable to allocate a temporary source file for featured thumbnail generation.'); + } + + $targetPath = $temporaryPath.($extension !== '' ? '.'.$extension : ''); + if (! @rename($temporaryPath, $targetPath)) { + File::delete($temporaryPath); + throw new RuntimeException('Unable to prepare a temporary source file for featured thumbnail generation.'); + } + + if (file_put_contents($targetPath, $contents) === false) { + File::delete($targetPath); + throw new RuntimeException('Unable to write the temporary source file for featured thumbnail generation.'); + } + + return ['path' => $targetPath, 'temporary' => $targetPath]; + } + + private function isUsableSourceFile(string $path): bool + { + if ($path === '' || ! is_file($path) || ! is_readable($path)) { + return false; + } + + $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION)); + if (! in_array($extension, self::ALLOWED_SOURCE_EXTENSIONS, true)) { + return false; + } + + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mime = strtolower((string) $finfo->file($path)); + + return str_starts_with($mime, 'image/'); + } + + private function isImageContents(string $contents): bool + { + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mime = strtolower((string) $finfo->buffer($contents)); + + return str_starts_with($mime, 'image/'); + } +}