diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index d2a7b7a7..6c13c0fc 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -148,6 +148,39 @@ 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 + { + $foreground = $this->manager->decodePath($filePath); + $canvasRatio = $width / $height; + $imageRatio = $foreground->width() / $foreground->height(); + + $tempFile = tempnam(sys_get_temp_dir(), 'media_fit_'); + + if (abs($imageRatio - $canvasRatio) < 0.01) { + $sized = $foreground->scaleDown($width, $height); + file_put_contents($tempFile, (string) $sized->encodeUsingMediaType('image/jpeg', quality: 100)); + + return $tempFile; + } + + $canvas = $this->manager->decodePath($filePath) + ->cover($width, $height) + ->blur(40) + ->brightness(-12); + + $canvas->insert($foreground->scaleDown($width, $height), 0, 0, 'center'); + + file_put_contents($tempFile, (string) $canvas->encodeUsingMediaType('image/jpeg', quality: 100)); + + return $tempFile; + } + /** * @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..d2c67d9c 100644 --- a/app/Services/Social/Concerns/CropsImageForAspectRatio.php +++ b/app/Services/Social/Concerns/CropsImageForAspectRatio.php @@ -50,6 +50,35 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati } } + /** + * 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'); + } + + $fitted = app(MediaOptimizer::class)->fitToCanvas($tempInput, $width, $height); + + $path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; + Storage::put($path, file_get_contents($fitted)); + + @unlink($fitted); + + return Storage::url($path); + } finally { + @unlink($tempInput); + } + } + protected function aspectRatioToFloat(string $ratio): float { return AspectRatio::tryFrom($ratio)?->toFloat() ?? 1.0; diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 23e684d8..388f3808 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -158,7 +158,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, $dimensions['width'], $dimensions['height']); } // Step 1: Create story container diff --git a/resources/js/components/posts/previews/FacebookPreview.vue b/resources/js/components/posts/previews/FacebookPreview.vue index f0302ae2..9227cd52 100644 --- a/resources/js/components/posts/previews/FacebookPreview.vue +++ b/resources/js/components/posts/previews/FacebookPreview.vue @@ -3,6 +3,7 @@ import { IconDots, IconPhoto } from '@tabler/icons-vue'; import { computed } from 'vue'; import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue'; +import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue'; import type { MediaItem } from '@/types/media'; interface SocialAccount { @@ -214,17 +215,15 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc