diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index d2a7b7a7..3370a4a0 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -6,11 +6,29 @@ use App\Enums\SocialAccount\Platform; use Illuminate\Support\Facades\Log; +use Intervention\Image\Direction; use Intervention\Image\Drivers\Gd\Driver; +use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; use Intervention\Image\ImageManager; +use Intervention\Image\Interfaces\ImageInterface; +use RuntimeException; class MediaOptimizer { + private const MAX_DECODE_MEMORY_BYTES = 256 * 1024 * 1024; + + private const FIT_BLUR_SIGMA = 55; + + private const FIT_BLUR_GAMMA = 1.3; + + private const FIT_QUALITY = 90; + + private const FIT_GD_DOWNSCALE = 8; + + private const FIT_GD_BLUR = 45; + + private const FIT_GD_BRIGHTNESS = 12; + private ImageManager $manager; public function __construct() @@ -26,29 +44,18 @@ public function optimizeImage(string $filePath, Platform $platform): string { $config = $this->getImageConfig($platform); - // Check image dimensions to prevent GD memory overflow $imageInfo = @getimagesize($filePath); - if ($imageInfo) { - $width = $imageInfo[0]; - $height = $imageInfo[1]; - $channels = $imageInfo['channels'] ?? 4; - $estimatedMemory = $width * $height * $channels * 1.5; // 1.5x safety margin + if ($imageInfo !== false && $this->estimatedDecodeMemory($imageInfo) > self::MAX_DECODE_MEMORY_BYTES) { + Log::warning('MediaOptimizer: Image too large for GD processing', [ + 'width' => $imageInfo[0], + 'height' => $imageInfo[1], + 'platform' => $platform->value, + ]); - // If image would use more than 256MB of RAM, skip optimization and return as-is - if ($estimatedMemory > 256 * 1024 * 1024) { - Log::warning('MediaOptimizer: Image too large for GD processing', [ - 'width' => $width, - 'height' => $height, - 'estimated_memory' => $estimatedMemory, - 'platform' => $platform->value, - ]); + $tempFile = tempnam(sys_get_temp_dir(), 'media_opt_'); + copy($filePath, $tempFile); - // Copy original file to temp location and return - $tempFile = tempnam(sys_get_temp_dir(), 'media_opt_'); - copy($filePath, $tempFile); - - return $tempFile; - } + return $tempFile; } $image = $this->manager->decodePath($filePath); @@ -113,6 +120,8 @@ public function maxWidthForPlatform(Platform $platform): ?int */ public function cropToAspectRatio(string $filePath, float $ratio): string { + $this->assertWithinMemoryBudget($filePath); + $image = $this->manager->decodePath($filePath); $width = $image->width(); @@ -148,6 +157,131 @@ public function cropToAspectRatio(string $filePath, float $ratio): string return $tempFile; } + /** + * Fit an image inside a width×height canvas without cropping: the image is + * scaled to fit and centered, and the empty space is filled with a blurred, + * slightly darkened copy of the image. When the image already matches the + * canvas ratio it's just scaled down (no background). Returns a temp file. + */ + public function fitToCanvas(string $filePath, int $width, int $height): string + { + $this->assertWithinMemoryBudget($filePath); + + $foreground = $this->manager->decodePath($filePath); + $canvasRatio = $width / $height; + $imageRatio = $foreground->width() / $foreground->height(); + + $tempFile = tempnam(sys_get_temp_dir(), 'media_fit_'); + + try { + if (abs($imageRatio - $canvasRatio) < 0.01) { + $sized = $foreground->scaleDown($width, $height); + file_put_contents($tempFile, (string) $sized->encodeUsingMediaType('image/jpeg', quality: self::FIT_QUALITY)); + + return $tempFile; + } + + $canvas = extension_loaded('imagick') + ? $this->fitOntoBlurredBackground($filePath, $width, $height) + : $this->fitOntoBlurredBackgroundGd($filePath, $width, $height); + + file_put_contents($tempFile, (string) $canvas->encodeUsingMediaType('image/jpeg', quality: self::FIT_QUALITY)); + + return $tempFile; + } catch (\Throwable $e) { + @unlink($tempFile); + + throw $e; + } + } + + /** + * Fit the image onto the canvas over a soft, lightened blurred background + * built from the image itself: the image is scaled to fill the width, heavily + * gaussian-blurred so shapes dissolve into a colour wash, lightened, and the + * top half is mirrored onto the bottom so the background reads symmetrically. + * Imagick only — blurring at full width before stretching avoids the streaks + * and posterisation the GD path is prone to. + */ + private function fitOntoBlurredBackground(string $filePath, int $width, int $height): ImageInterface + { + $manager = new ImageManager(new ImagickDriver); + + $foreground = $manager->decodePath($filePath); + $sourceWidth = $foreground->width(); + $sourceHeight = $foreground->height(); + $scale = min($width / $sourceWidth, $height / $sourceHeight); + $foreground->resize(max(1, (int) round($sourceWidth * $scale)), max(1, (int) round($sourceHeight * $scale))); + + $scaledHeight = max(1, (int) round($sourceHeight * ($width / $sourceWidth))); + $topHalf = $manager->decodePath($filePath)->resize($width, $scaledHeight); + $core = $topHalf->core()->native(); + $core->blurImage(0, self::FIT_BLUR_SIGMA); + $core->gammaImage(self::FIT_BLUR_GAMMA); + $topHalf->resize($width, intdiv($height, 2)); + + $canvas = $manager->createImage($width, $height)->fill('000000'); + $canvas->insert($topHalf, 0, 0, 'top-left'); + $topHalf->flip(Direction::VERTICAL); + $canvas->insert($topHalf, 0, intdiv($height, 2), 'top-left'); + $canvas->insert($foreground, 0, 0, 'center'); + + return $canvas; + } + + /** + * GD fallback for hosts without ext-imagick: a downscale→blur→upscale + * background. Less refined than the Imagick path (no large smooth gaussian), + * but artefact-free enough for a story background. + */ + private function fitOntoBlurredBackgroundGd(string $filePath, int $width, int $height): ImageInterface + { + $foreground = $this->manager->decodePath($filePath); + $scale = min($width / $foreground->width(), $height / $foreground->height()); + $foreground->resize(max(1, (int) round($foreground->width() * $scale)), max(1, (int) round($foreground->height() * $scale))); + + $canvas = $this->manager->decodePath($filePath) + ->cover($width, $height) + ->resize(intdiv($width, self::FIT_GD_DOWNSCALE), intdiv($height, self::FIT_GD_DOWNSCALE)) + ->blur(self::FIT_GD_BLUR) + ->resize($width, $height) + ->brightness(self::FIT_GD_BRIGHTNESS); + + $canvas->insert($foreground, 0, 0, 'center'); + + return $canvas; + } + + /** + * Estimated GD memory (bytes) needed to decode an image, from its + * getimagesize() metadata. + * + * @param array{0: int, 1: int, channels?: int} $imageInfo + */ + private function estimatedDecodeMemory(array $imageInfo): float + { + return $imageInfo[0] * $imageInfo[1] * ($imageInfo['channels'] ?? 4) * 1.5; + } + + /** + * Reject a source whose pixel dimensions would blow the GD memory budget, + * before it is decoded — a small-byte, huge-dimension image would otherwise + * exhaust memory with an uncatchable fatal. Transforms that can't fall back + * to the original (crop, fit) call this; `optimizeImage` skips instead. + */ + private function assertWithinMemoryBudget(string $filePath): void + { + $imageInfo = @getimagesize($filePath); + + if ($imageInfo === false) { + return; + } + + if ($this->estimatedDecodeMemory($imageInfo) > self::MAX_DECODE_MEMORY_BYTES) { + throw new RuntimeException("Image dimensions ({$imageInfo[0]}x{$imageInfo[1]}) exceed the safe processing budget."); + } + } + /** * @return array{max_width: int, max_size: int, format: string, quality: int} */ diff --git a/app/Services/Social/Concerns/CropsImageForAspectRatio.php b/app/Services/Social/Concerns/CropsImageForAspectRatio.php index ae4cefdb..99028461 100644 --- a/app/Services/Social/Concerns/CropsImageForAspectRatio.php +++ b/app/Services/Social/Concerns/CropsImageForAspectRatio.php @@ -37,14 +37,55 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati throw $this->cropFailureException('Failed to download image for cropping'); } - $cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio); + try { + $cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio); + } catch (\Throwable) { + throw $this->cropFailureException('Failed to process image for cropping'); + } - $path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; - Storage::put($path, file_get_contents($cropped)); + try { + $path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; + Storage::put($path, file_get_contents($cropped)); - @unlink($cropped); + return Storage::url($path); + } finally { + @unlink($cropped); + } + } finally { + @unlink($tempInput); + } + } - return Storage::url($path); + /** + * Fit the image inside a width×height canvas with a blurred-background + * extension (no cropping), host it, and return a public URL. Used for + * stories so an off-ratio image isn't clipped by the platform. + */ + protected function fitImageToCanvas(string $imageUrl, int $width, int $height): string + { + $tempInput = tempnam(sys_get_temp_dir(), 'fit_in_'); + + try { + $download = Http::sink($tempInput)->timeout(120)->get($imageUrl); + + if ($download->failed()) { + throw $this->cropFailureException('Failed to download image for story fitting'); + } + + try { + $fitted = app(MediaOptimizer::class)->fitToCanvas($tempInput, $width, $height); + } catch (\Throwable) { + throw $this->cropFailureException('Failed to process image for story fitting'); + } + + try { + $path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; + Storage::put($path, file_get_contents($fitted)); + + return Storage::url($path); + } finally { + @unlink($fitted); + } } finally { @unlink($tempInput); } @@ -56,8 +97,8 @@ protected function aspectRatioToFloat(string $ratio): float } /** - * The platform-specific exception thrown when the source image cannot be - * downloaded for cropping. + * The platform-specific exception thrown when an image can't be prepared for + * publishing — a download, crop, or story-fit failure. */ abstract protected function cropFailureException(string $message): SocialPublishException; } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 79aafc4e..00a81dd2 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -167,7 +167,8 @@ private function publishStory(string $instagramId, string $accessToken, $media): if ($isVideo) { $params['video_url'] = $media->url; } else { - $params['image_url'] = $media->url; + $dimensions = ContentType::InstagramStory->aiImageDimensions(); + $params['image_url'] = $this->fitImageToCanvas($media->url, data_get($dimensions, 'width'), data_get($dimensions, 'height')); } // Step 1: Create story container diff --git a/resources/js/components/posts/previews/InstagramPreview.vue b/resources/js/components/posts/previews/InstagramPreview.vue index c442b604..324a369a 100644 --- a/resources/js/components/posts/previews/InstagramPreview.vue +++ b/resources/js/components/posts/previews/InstagramPreview.vue @@ -14,6 +14,7 @@ import { import { computed } from 'vue'; import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue'; +import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue'; import { ContentType } from '@/types/content-type'; import type { MediaItem } from '@/types/media'; @@ -163,17 +164,15 @@ const username = computed(() => props.socialAccount.username || props.socialAcco