160 lines
6.4 KiB
PHP
160 lines
6.4 KiB
PHP
<?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.');
|
|
}
|
|
}
|
|
} |