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