Implement creator studio and upload updates
This commit is contained in:
160
app/Services/Images/SquareThumbnailService.php
Normal file
160
app/Services/Images/SquareThumbnailService.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Images;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use App\Data\Images\CropBoxData;
|
||||
use App\Data\Images\SquareThumbnailResultData;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class SquareThumbnailService
|
||||
{
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct(private readonly SubjectDetectorInterface $subjectDetector)
|
||||
{
|
||||
try {
|
||||
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Square thumbnail image manager configuration failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function generateFromPath(string $sourcePath, string $destinationPath, array $options = []): SquareThumbnailResultData
|
||||
{
|
||||
$this->assertImageManagerAvailable();
|
||||
|
||||
if (! is_file($sourcePath) || ! is_readable($sourcePath)) {
|
||||
throw new RuntimeException('Square thumbnail source image is not readable.');
|
||||
}
|
||||
|
||||
$size = @getimagesize($sourcePath);
|
||||
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
|
||||
throw new RuntimeException('Square thumbnail source image dimensions are invalid.');
|
||||
}
|
||||
|
||||
$sourceWidth = (int) $size[0];
|
||||
$sourceHeight = (int) $size[1];
|
||||
$config = $this->resolveOptions($options);
|
||||
$context = is_array($options['context'] ?? null) ? $options['context'] : $options;
|
||||
|
||||
$detection = null;
|
||||
if ($config['smart_crop']) {
|
||||
$detection = $this->subjectDetector->detect($sourcePath, $sourceWidth, $sourceHeight, $context);
|
||||
}
|
||||
|
||||
$cropBox = $this->calculateCropBox($sourceWidth, $sourceHeight, $detection?->cropBox, $config);
|
||||
$cropMode = $detection?->strategy ?? $config['fallback_strategy'];
|
||||
|
||||
$image = $this->manager->read($sourcePath)->crop($cropBox->width, $cropBox->height, $cropBox->x, $cropBox->y);
|
||||
$outputWidth = $config['target_width'];
|
||||
$outputHeight = $config['target_height'];
|
||||
|
||||
if ($config['allow_upscale']) {
|
||||
$image = $image->resize($config['target_width'], $config['target_height']);
|
||||
} else {
|
||||
$image = $image->resizeDown($config['target_width'], $config['target_height']);
|
||||
$outputWidth = min($config['target_width'], $cropBox->width);
|
||||
$outputHeight = min($config['target_height'], $cropBox->height);
|
||||
}
|
||||
|
||||
$encoded = (string) $image->encode(new WebpEncoder($config['quality']));
|
||||
File::ensureDirectoryExists(dirname($destinationPath));
|
||||
File::put($destinationPath, $encoded);
|
||||
|
||||
$result = new SquareThumbnailResultData(
|
||||
destinationPath: $destinationPath,
|
||||
cropBox: $cropBox,
|
||||
cropMode: $cropMode,
|
||||
sourceWidth: $sourceWidth,
|
||||
sourceHeight: $sourceHeight,
|
||||
targetWidth: $config['target_width'],
|
||||
targetHeight: $config['target_height'],
|
||||
outputWidth: $outputWidth,
|
||||
outputHeight: $outputHeight,
|
||||
detectionReason: $detection?->reason,
|
||||
meta: [
|
||||
'smart_crop' => $config['smart_crop'],
|
||||
'padding_ratio' => $config['padding_ratio'],
|
||||
'confidence' => $detection?->confidence,
|
||||
],
|
||||
);
|
||||
|
||||
if ($config['log']) {
|
||||
Log::debug('square-thumbnail-generated', $result->toArray());
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function calculateCropBox(int $sourceWidth, int $sourceHeight, ?CropBoxData $focusBox = null, array $options = []): CropBoxData
|
||||
{
|
||||
$config = $this->resolveOptions($options);
|
||||
|
||||
if ($focusBox === null) {
|
||||
return $this->safeCenterCrop($sourceWidth, $sourceHeight);
|
||||
}
|
||||
|
||||
$baseSide = max($focusBox->width, $focusBox->height);
|
||||
$side = max(1, (int) ceil($baseSide * (1 + ($config['padding_ratio'] * 2))));
|
||||
$side = min($side, min($sourceWidth, $sourceHeight));
|
||||
|
||||
$x = (int) round($focusBox->centerX() - ($side / 2));
|
||||
$y = (int) round($focusBox->centerY() - ($side / 2));
|
||||
|
||||
return (new CropBoxData($x, $y, $side, $side))->clampToImage($sourceWidth, $sourceHeight);
|
||||
}
|
||||
|
||||
private function safeCenterCrop(int $sourceWidth, int $sourceHeight): CropBoxData
|
||||
{
|
||||
$side = min($sourceWidth, $sourceHeight);
|
||||
$x = (int) floor(($sourceWidth - $side) / 2);
|
||||
$y = (int) floor(($sourceHeight - $side) / 2);
|
||||
|
||||
return new CropBoxData($x, $y, $side, $side);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{target_width: int, target_height: int, quality: int, smart_crop: bool, padding_ratio: float, allow_upscale: bool, fallback_strategy: string, log: bool}
|
||||
*/
|
||||
private function resolveOptions(array $options): array
|
||||
{
|
||||
$config = (array) config('uploads.square_thumbnails', []);
|
||||
$targetWidth = (int) ($options['target_width'] ?? $options['target_size'] ?? $config['width'] ?? config('uploads.derivatives.sq.size', 512));
|
||||
$targetHeight = (int) ($options['target_height'] ?? $options['target_size'] ?? $config['height'] ?? $targetWidth);
|
||||
|
||||
return [
|
||||
'target_width' => max(1, $targetWidth),
|
||||
'target_height' => max(1, $targetHeight),
|
||||
'quality' => max(1, min(100, (int) ($options['quality'] ?? $config['quality'] ?? 82))),
|
||||
'smart_crop' => (bool) ($options['smart_crop'] ?? $config['smart_crop'] ?? true),
|
||||
'padding_ratio' => max(0.0, min(0.5, (float) ($options['padding_ratio'] ?? $config['padding_ratio'] ?? 0.18))),
|
||||
'allow_upscale' => (bool) ($options['allow_upscale'] ?? $config['allow_upscale'] ?? false),
|
||||
'fallback_strategy' => (string) ($options['fallback_strategy'] ?? $config['fallback_strategy'] ?? 'center'),
|
||||
'log' => (bool) ($options['log'] ?? $config['log'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
private function assertImageManagerAvailable(): void
|
||||
{
|
||||
if ($this->manager === null) {
|
||||
throw new RuntimeException('Square thumbnail generation requires Intervention Image.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user