Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View 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.');
}
}
}