Files
SkinbaseNova/app/Services/Images/FeaturedArtworkThumbnailGenerator.php

218 lines
7.4 KiB
PHP

<?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/');
}
}