From bfbcd642ea37e66e6a80bc72f2c920a645aeefd5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 29 Jun 2026 14:35:36 -0300 Subject: [PATCH 1/2] feat(instagram): auto-fit story images to 9:16 with blurred background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story images that aren't 9:16 were clipped by Instagram. They are now fitted onto a 1080x1920 canvas — the image is contained and a blurred, darkened copy of itself fills the letterbox gaps, so nothing is cropped and the background color adapts to the image. The fit happens at publish time (the hosted copy lives in social-crops/), and the post editor preview now renders the same blurred-background fit for stories, so the user sees exactly what will publish. The aspect-ratio warning is suppressed for story images since they're auto-fitted. Instagram only — Facebook stories are video-only in our flow. --- app/Services/Media/MediaOptimizer.php | 34 +++++++++++++++++++ .../Concerns/CropsImageForAspectRatio.php | 29 ++++++++++++++++ app/Services/Social/InstagramPublisher.php | 3 +- .../posts/previews/InstagramPreview.vue | 1 + .../posts/previews/PostMediaPreview.vue | 31 +++++++++++++---- resources/js/composables/useMedia.ts | 4 +-- resources/js/composables/useMediaRules.ts | 5 ++- .../Services/InstagramPublisherTest.php | 13 +++++++ .../Social/InstagramPublisherTest.php | 25 +++++++++++--- .../Services/Media/MediaOptimizerTest.php | 30 ++++++++++++++++ 10 files changed, 161 insertions(+), 14 deletions(-) diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index d2a7b7a7..3b208ecd 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -148,6 +148,40 @@ 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 + { + $probe = $this->manager->decodePath($filePath); + $canvasRatio = $width / $height; + $imageRatio = $probe->width() / $probe->height(); + + $tempFile = tempnam(sys_get_temp_dir(), 'media_fit_'); + + if (abs($imageRatio - $canvasRatio) < 0.01) { + $sized = $this->manager->decodePath($filePath)->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); + + $foreground = $this->manager->decodePath($filePath)->scaleDown($width, $height); + $canvas->insert($foreground, 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/InstagramPreview.vue b/resources/js/components/posts/previews/InstagramPreview.vue index c442b604..98bf98f7 100644 --- a/resources/js/components/posts/previews/InstagramPreview.vue +++ b/resources/js/components/posts/previews/InstagramPreview.vue @@ -237,6 +237,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco :placeholder-icon="IconPhoto" :show-arrows="false" :show-dots="false" + :blur-background="true" placeholder-class="w-full h-full flex items-center justify-center" /> diff --git a/resources/js/components/posts/previews/PostMediaPreview.vue b/resources/js/components/posts/previews/PostMediaPreview.vue index dcf08849..95621997 100644 --- a/resources/js/components/posts/previews/PostMediaPreview.vue +++ b/resources/js/components/posts/previews/PostMediaPreview.vue @@ -15,6 +15,9 @@ interface Props { dotInactiveClass?: string; mediaClass?: string; placeholderClass?: string; + // Fit images inside the frame with a blurred copy filling the gaps (matches + // the blurred-background extension applied to story images at publish time). + blurBackground?: boolean; } const props = withDefaults(defineProps(), { @@ -25,6 +28,7 @@ const props = withDefaults(defineProps(), { dotInactiveClass: 'bg-white/50 hover:bg-white/70', mediaClass: 'w-full h-full object-cover', placeholderClass: 'w-full h-full flex items-center justify-center bg-muted', + blurBackground: false, }); const currentIndex = ref(0); @@ -61,12 +65,27 @@ const goToSlide = (index: number) => { :src="item.url" :video-class="mediaClass" /> - +