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:
parent
bfa018a4cd
commit
bfbcd642ea
10 changed files with 161 additions and 14 deletions
|
|
@ -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}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue