Add FeaturedArtworkThumbnailGenerator and FeaturedArtworkSelector
This commit is contained in:
31
app/Services/Featured/FeaturedArtworkSelector.php
Normal file
31
app/Services/Featured/FeaturedArtworkSelector.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Featured;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class FeaturedArtworkSelector
|
||||
{
|
||||
public function querySelectedArtworks(): Builder
|
||||
{
|
||||
return Artwork::query()
|
||||
->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');
|
||||
}
|
||||
}
|
||||
217
app/Services/Images/FeaturedArtworkThumbnailGenerator.php
Normal file
217
app/Services/Images/FeaturedArtworkThumbnailGenerator.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use App\Support\ArtworkFeaturedImagePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class FeaturedArtworkThumbnailGenerator
|
||||
{
|
||||
private const ALLOWED_SOURCE_EXTENSIONS = ['avif', 'bmp', 'gif', 'jpg', 'jpeg', 'png', 'tif', 'tiff', 'webp'];
|
||||
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkFeaturedImagePath $paths,
|
||||
private readonly ArtworkOriginalFileLocator $locator,
|
||||
private readonly UploadStorageService $storage,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
) {
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
? new ImageManager(new GdDriver)
|
||||
: new ImageManager(new ImagickDriver);
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{existing:list<string>,missing:list<string>,target_variants:list<string>}
|
||||
*/
|
||||
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<string>,missing:list<string>,target_variants:list<string>,generated:int,skipped:int,generated_variants:list<string>,generated_paths:list<string>,failed:array<string,string>}
|
||||
*/
|
||||
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/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user