feat(instagram): auto-fit story images to 9:16 with blurred background

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.
This commit is contained in:
Paulo Castellano 2026-06-29 14:35:36 -03:00
parent bfa018a4cd
commit bfbcd642ea
10 changed files with 161 additions and 14 deletions

View file

@ -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}
*/

View file

@ -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;

View file

@ -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

View file

@ -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"
/>
</div>

View file

@ -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<Props>(), {
@ -25,6 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
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"
/>
<img
v-else-if="index === currentIndex"
:src="item.url"
:alt="item.original_filename"
:class="mediaClass"
/>
<template v-else-if="index === currentIndex">
<template v-if="blurBackground">
<img
:src="item.url"
alt=""
aria-hidden="true"
class="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl brightness-90"
/>
<img
:src="item.url"
:alt="item.original_filename"
class="absolute inset-0 h-full w-full object-contain"
/>
</template>
<img
v-else
:src="item.url"
:alt="item.original_filename"
:class="mediaClass"
/>
</template>
</template>
<template v-if="hasMultiple && showArrows">

View file

@ -107,7 +107,7 @@ export const getMediaValidationWarning = (
};
}
if (width > 0 && height > 0) {
if (width > 0 && height > 0 && ! (rules.autoFitsImage && isImage(m))) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) {
return {
@ -163,7 +163,7 @@ export const getMediaItemIssue = (item: MediaItem, contentType: string): string
const width = item.meta?.width ?? 0;
const height = item.meta?.height ?? 0;
if (width > 0 && height > 0) {
if (width > 0 && height > 0 && ! (rules.autoFitsImage && ! itemIsVideo)) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return 'aspect_ratio_too_narrow';
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return 'aspect_ratio_too_wide';

View file

@ -17,6 +17,9 @@ export interface MediaRules {
maxVideoDurationSec?: number;
aspectRatioMin?: number;
aspectRatioMax?: number;
// Images off the target ratio are auto-fitted with a blurred background at
// publish time, so the aspect-ratio warning is suppressed for images.
autoFitsImage?: boolean;
}
const MB = 1024 * 1024;
@ -40,7 +43,7 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
aspectRatioMin: 0.5, aspectRatioMax: 0.6, autoFitsImage: true,
},
// Facebook

View file

@ -9,8 +9,10 @@
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\InstagramPublisher;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
$this->workspace = Workspace::factory()->create();
@ -77,13 +79,24 @@
});
test('instagram publisher publishes story', function () {
Storage::fake();
$this->postPlatform->update(['content_type' => ContentType::InstagramStory]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('fitToCanvas')->once()->with(Mockery::type('string'), 1080, 1920)->andReturnUsing(function (string $tempFile) {
$out = tempnam(sys_get_temp_dir(), 'ig_fit_');
copy($tempFile, $out);
return $out;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake([
'*/12345678/media' => Http::response(['id' => 'container-123'], 200),
'*/container-123*' => Http::response(['status_code' => 'FINISHED'], 200),
'*/12345678/media_publish' => Http::response(['id' => 'story-123'], 200),
'*/story-123*' => Http::response(['permalink' => 'https://instagram.com/stories/abc123'], 200),
'*' => Http::response(file_get_contents(__DIR__.'/../../fixtures/1x1.png'), 200, ['Content-Type' => 'image/png']),
]);
$this->post->update([

View file

@ -11,6 +11,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\InstagramPublisher;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
@ -140,11 +141,11 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
});
});
test('instagram publisher can publish image story', function () {
test('instagram publisher fits an image story to 9:16 and posts the hosted copy', function () {
Storage::fake();
$this->postPlatform->update(['content_type' => ContentType::InstagramStory]);
$this->post->update([
'media' => [
[
'id' => 'test-media-story',
@ -154,9 +155,19 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
'original_filename' => 'story.jpg',
],
],
]);
// The fit itself is covered by MediaOptimizerTest; here we only assert the
// story is built from the hosted, fitted copy (9:16 canvas).
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('fitToCanvas')->once()->with(Mockery::type('string'), 1080, 1920)->andReturnUsing(function (string $tempFile) {
$out = tempnam(sys_get_temp_dir(), 'ig_fit_');
copy($tempFile, $out);
return $out;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response([
'id' => 'story-container-123',
@ -170,6 +181,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
'https://graph.instagram.com/v25.0/story-123456789*' => Http::response([
'permalink' => 'https://www.instagram.com/stories/testuser/123/',
], 200),
'*' => Http::response(file_get_contents(__DIR__.'/../../../fixtures/1x1.png'), 200, ['Content-Type' => 'image/png']),
]);
$result = $this->publisher->publish($this->postPlatform);
@ -177,7 +189,12 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
expect($result['id'])->toBe('story-123456789');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/ig_123456789/media');
if (! str_contains($request->url(), '/ig_123456789/media') || str_contains($request->url(), 'media_publish')) {
return false;
}
$imageUrl = (string) data_get($request->data(), 'image_url', '');
return str_contains($imageUrl, 'social-crops/') && ! str_contains($imageUrl, 'example.com');
});
});

View file

@ -213,3 +213,33 @@ function createTestImage(int $width, int $height, string $format = 'image/jpeg')
expect($cropped->width())->toBe(800);
expect($cropped->height())->toBe(800);
});
it('fits a wide image into a 9:16 canvas (blurred background, no crop)', function () use (&$tempFiles) {
$source = createTestImage(1200, 900); // 4:3
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->fitToCanvas($source, 1080, 1920);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$out = $manager->decodePath($result);
expect($out->width())->toBe(1080)
->and($out->height())->toBe(1920);
});
it('does not letterbox an image that already matches the canvas ratio', function () use (&$tempFiles) {
$source = createTestImage(1080, 1920); // already 9:16
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->fitToCanvas($source, 1080, 1920);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$out = $manager->decodePath($result);
$ratio = $out->width() / $out->height();
expect(abs($ratio - 9 / 16))->toBeLessThan(0.01);
});