Merge pull request #123 from trypostit/feat/story-blurred-background

feat(instagram): auto-fit story images to 9:16 with blurred background
This commit is contained in:
Paulo Castellano 2026-07-17 10:45:28 -03:00 committed by GitHub
commit 67cf43a5f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 662 additions and 74 deletions

View file

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

View file

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

View file

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

View file

@ -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
<!-- ==================== REELS ==================== -->
<template v-else-if="isReel">
<div class="relative flex-1 bg-[#fafafa] dark:bg-black overflow-hidden">
<div class="relative flex-1 overflow-hidden">
<!-- Video/Media - Full screen -->
<div class="absolute inset-0">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPlayerPlayFilled"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center"
/>
</div>
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center bg-[#fafafa] dark:bg-black">
<IconPlayerPlayFilled class="h-12 w-12 text-muted-foreground/40" />
</div>
</template>
</VerticalMediaCanvas>
<!-- Top Bar - below status bar -->
<div class="absolute top-1 left-0 right-0 px-3 flex items-center justify-between z-10">
@ -229,17 +228,15 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
<!-- ==================== STORIES ==================== -->
<template v-else-if="isStory">
<div class="relative flex-1 bg-[#fafafa] dark:bg-black overflow-hidden">
<div class="relative flex-1 overflow-hidden">
<!-- Media - Full screen -->
<div class="absolute inset-0">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center"
/>
</div>
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center bg-[#fafafa] dark:bg-black">
<IconPhoto class="h-12 w-12 text-muted-foreground/40" />
</div>
</template>
</VerticalMediaCanvas>
<!-- Progress Bars - below status bar -->
<div class="absolute top-0.5 left-2 right-2 flex gap-0.5 z-10">

View file

@ -2,8 +2,7 @@
import { IconPlus } from '@tabler/icons-vue';
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia } from '@/composables/useMedia';
import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
@ -39,21 +38,16 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
<template>
<div class="w-full h-full bg-black text-white overflow-hidden flex flex-col relative">
<!-- Video/Media Area - Full screen -->
<div class="absolute inset-0">
<!-- Video content -->
<div v-if="media.length > 0 && isVideoMedia(media[0])" class="w-full h-full">
<VideoPreview :src="media[0].url" />
</div>
<div v-else-if="media.length > 0" class="w-full h-full">
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
</div>
<div v-else class="w-full h-full flex items-center justify-center bg-[#161823]">
<svg class="h-12 w-12 text-white/20" viewBox="0 0 24 24" fill="currentColor">
<path
d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
</div>
</div>
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center bg-[#161823]">
<svg class="h-12 w-12 text-white/20" viewBox="0 0 24 24" fill="currentColor">
<path
d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
</div>
</template>
</VerticalMediaCanvas>
<!-- Top Header (only when media exists) -->
<div v-if="media.length > 0"

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import { IconPhoto } from '@tabler/icons-vue';
import { computed } from 'vue';
import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
const props = defineProps<{
media: MediaItem[];
}>();
const item = computed<MediaItem | null>(() => props.media[0] ?? null);
</script>
<template>
<div class="absolute inset-0 overflow-hidden bg-black">
<template v-if="item">
<VideoPreview v-if="isVideoMedia(item)" :src="item.url" video-class="h-full w-full object-cover" />
<template v-else>
<div class="absolute inset-x-0 top-0 h-1/2 overflow-hidden">
<img :src="item.url" alt="" aria-hidden="true" class="h-full w-full blur-3xl brightness-110" />
</div>
<div class="absolute inset-x-0 bottom-0 h-1/2 -scale-y-100 overflow-hidden">
<img :src="item.url" alt="" aria-hidden="true" class="h-full w-full blur-3xl brightness-110" />
</div>
<img :src="item.url" :alt="item.original_filename"
class="absolute inset-0 h-full w-full object-contain" />
</template>
</template>
<template v-else>
<slot name="placeholder">
<div class="flex h-full w-full items-center justify-center">
<IconPhoto class="h-12 w-12 text-muted-foreground/40" />
</div>
</slot>
</template>
</div>
</template>

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 && isImage(item))) {
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,7 @@ export interface MediaRules {
maxVideoDurationSec?: number;
aspectRatioMin?: number;
aspectRatioMax?: number;
autoFitsImage?: boolean;
}
const MB = 1024 * 1024;
@ -40,7 +41,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([
@ -95,6 +108,15 @@
$result = $publisher->publish($this->postPlatform);
expect($result['id'])->toBe('story-123');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/12345678/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');
});
});
test('instagram publisher throws token expired exception on oauth error', function () {

View file

@ -765,6 +765,24 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string
->toThrow(FacebookPublishException::class, 'Failed to download image for cropping');
});
test('facebook image post throws a clean exception when the crop source is not decodable', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response('<html>error</html>', 200, ['Content-Type' => 'text/html']),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(FacebookPublishException::class, 'Failed to process image for cropping');
});
test('facebook single image post without aspect ratio uploads the original image (no crop)', function () {
Storage::fake();

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,10 +189,140 @@ 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');
});
});
test('instagram publisher fits a real off-ratio story image to a 1080x1920 jpeg', function () {
Storage::fake();
$this->postPlatform->update(['content_type' => ContentType::InstagramStory]);
$this->post->update([
'media' => [
[
'id' => 'test-media-story',
'path' => 'media/2026-01/story.jpg',
'url' => 'https://example.com/media/2026-01/story.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'story.jpg',
],
],
]);
Http::fake([
'https://example.com/media/2026-01/story.jpg' => Http::response(fakeJpegBytes(1200, 800), 200),
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'story-container-123'], 200),
'https://graph.instagram.com/v25.0/story-container-123*' => Http::response(['status_code' => 'FINISHED'], 200),
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'story-123456789'], 200),
'https://graph.instagram.com/v25.0/story-123456789*' => Http::response(['permalink' => 'https://www.instagram.com/stories/testuser/123/'], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('story-123456789');
$hosted = collect(Storage::allFiles())->first(fn (string $path) => str_starts_with($path, 'social-crops/'));
expect($hosted)->not->toBeNull();
$tempFile = tempnam(sys_get_temp_dir(), 'verify_story_');
file_put_contents($tempFile, Storage::get($hosted));
$fitted = (new ImageManager(Driver::class))->decodePath($tempFile);
expect($fitted->width())->toBe(1080)
->and($fitted->height())->toBe(1920);
@unlink($tempFile);
});
test('instagram publisher throws a clean exception when the story image is not decodable', function () {
Storage::fake();
$this->postPlatform->update(['content_type' => ContentType::InstagramStory]);
$this->post->update([
'media' => [
[
'id' => 'test-media-story',
'path' => 'media/story.jpg',
'url' => 'https://example.com/media/story.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'story.jpg',
],
],
]);
Http::fake([
'https://example.com/media/story.jpg' => Http::response('<html>error</html>', 200, ['Content-Type' => 'text/html']),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(InstagramPublishException::class, 'Failed to process image for story fitting');
});
test('instagram publisher surfaces a publish exception when the story container fails to create', function () {
Storage::fake();
$this->postPlatform->update(['content_type' => ContentType::InstagramStory]);
$this->post->update([
'media' => [
[
'id' => 'test-media-story',
'path' => 'media/story.jpg',
'url' => 'https://example.com/media/story.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'story.jpg',
],
],
]);
Http::fake([
'https://example.com/media/story.jpg' => Http::response(fakeJpegBytes(1200, 800), 200),
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['error' => ['message' => 'Invalid media', 'code' => 100]], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(InstagramPublishException::class);
});
test('instagram publisher does not leak the fitted temp file when hosting the story image fails', function () {
$this->postPlatform->update(['content_type' => ContentType::InstagramStory]);
$this->post->update([
'media' => [
[
'id' => 'test-media-story',
'path' => 'media/story.jpg',
'url' => 'https://example.com/media/story.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'story.jpg',
],
],
]);
Http::fake([
'https://example.com/media/story.jpg' => Http::response(fakeJpegBytes(1200, 800), 200),
]);
$fittedPath = null;
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('fitToCanvas')->once()->andReturnUsing(function (string $tempFile) use (&$fittedPath) {
$fittedPath = tempnam(sys_get_temp_dir(), 'media_fit_');
copy($tempFile, $fittedPath);
return $fittedPath;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Storage::shouldReceive('put')->once()->andThrow(new RuntimeException('disk full'));
expect(fn () => $this->publisher->publish($this->postPlatform))->toThrow(RuntimeException::class);
expect($fittedPath)->not->toBeNull()
->and(file_exists($fittedPath))->toBeFalse();
});
test('instagram publisher can publish video story', function () {
$this->postPlatform->update(['content_type' => ContentType::InstagramStory]);
@ -797,6 +939,73 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
->toThrow(InstagramPublishException::class, 'Failed to download image for cropping');
});
test('story image throws when the source image cannot be downloaded for fitting', function () {
Storage::fake();
$this->postPlatform->update(['content_type' => ContentType::InstagramStory]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response('', 404),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(InstagramPublishException::class, 'Failed to download image for story fitting');
});
test('feed image throws a clean exception when the crop source is not decodable', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response('<html>error</html>', 200, ['Content-Type' => 'text/html']),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(InstagramPublishException::class, 'Failed to process image for cropping');
});
test('instagram publisher does not leak the cropped temp file when hosting the feed image fails', function () {
$this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response(fakeJpegBytes(1200, 800), 200),
]);
$croppedPath = null;
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('cropToAspectRatio')->once()->andReturnUsing(function (string $tempFile) use (&$croppedPath) {
$croppedPath = tempnam(sys_get_temp_dir(), 'media_crop_');
copy($tempFile, $croppedPath);
return $croppedPath;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Storage::shouldReceive('put')->once()->andThrow(new RuntimeException('disk full'));
expect(fn () => $this->publisher->publish($this->postPlatform))->toThrow(RuntimeException::class);
expect($croppedPath)->not->toBeNull()
->and(file_exists($croppedPath))->toBeFalse();
});
test('feed image with original aspect ratio bypasses crop', function () {
Storage::fake();

View file

@ -7,10 +7,10 @@
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
function createTestImage(int $width, int $height, string $format = 'image/jpeg'): string
function createTestImage(int $width, int $height, string $format = 'image/jpeg', string $fill = 'cccccc'): string
{
$manager = new ImageManager(Driver::class);
$image = $manager->createImage($width, $height)->fill('cccccc');
$image = $manager->createImage($width, $height)->fill($fill);
$tempFile = tempnam(sys_get_temp_dir(), 'test_img_');
$encoded = $image->encodeUsingMediaType($format);
file_put_contents($tempFile, (string) $encoded);
@ -18,6 +18,37 @@ function createTestImage(int $width, int $height, string $format = 'image/jpeg')
return $tempFile;
}
/**
* A valid PNG header declaring huge dimensions with no pixel data: getimagesize
* reads the size cheaply, so the memory-budget guard fires without allocating a
* multi-gigabyte GD buffer.
*/
function createHugeHeaderImage(int $width, int $height): string
{
$ihdr = pack('N', $width).pack('N', $height)."\x08\x02\x00\x00\x00";
$png = "\x89PNG\r\n\x1a\n".pack('N', 13).'IHDR'.$ihdr.pack('N', 0);
$tempFile = tempnam(sys_get_temp_dir(), 'huge_hdr_');
file_put_contents($tempFile, $png);
return $tempFile;
}
/**
* A wide image split into a light top half and a dark bottom half, for asserting
* the story background mirrors the top onto the bottom.
*/
function createTwoToneImage(int $width, int $height, string $topHex, string $bottomHex): string
{
$manager = new ImageManager(Driver::class);
$image = $manager->createImage($width, $height)->fill($topHex);
$bottom = $manager->createImage($width, intdiv($height, 2))->fill($bottomHex);
$image->insert($bottom, 0, intdiv($height, 2), 'top-left');
$tempFile = tempnam(sys_get_temp_dir(), 'twotone_');
file_put_contents($tempFile, (string) $image->encodeUsingMediaType('image/jpeg'));
return $tempFile;
}
$tempFiles = [];
afterEach(function () use (&$tempFiles) {
@ -213,3 +244,104 @@ function createTestImage(int $width, int $height, string $format = 'image/jpeg')
expect($cropped->width())->toBe(800);
expect($cropped->height())->toBe(800);
});
it('fits an off-ratio image over a lightened, image-derived blurred background', function () use (&$tempFiles) {
$source = createTestImage(1200, 400, 'image/jpeg', '3366cc'); // wide, solid blue
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->fitToCanvas($source, 1080, 1920);
$tempFiles[] = $result;
$out = (new ImageManager(Driver::class))->decodePath($result);
expect($out->width())->toBe(1080)
->and($out->height())->toBe(1920);
// The background is derived from the image (blue), never a black letterbox,
// and lightened via gamma — the corner's blue channel exceeds the source's 0xcc.
$corner = $out->colorAt(20, 20);
expect($corner->blue()->value())->toBeGreaterThan($corner->red()->value())
->and($corner->blue()->value())->toBeGreaterThan(0xCC);
});
it('mirrors the blurred background so the bottom reads like the top', function () use (&$tempFiles) {
$source = createTwoToneImage(1200, 400, 'ffffff', '000000'); // light top, dark bottom
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->fitToCanvas($source, 1080, 1920);
$tempFiles[] = $result;
$out = (new ImageManager(Driver::class))->decodePath($result);
// The top half is mirrored onto the bottom, so the bottom band shows the
// image's light top — not the dark bottom a plain cover would surface.
expect($out->colorAt(540, 1900)->red()->value())->toBeGreaterThan(150);
})->skip(! extension_loaded('imagick'), 'Blurred-background mirror requires ext-imagick');
it('produces a valid canvas through the gd fallback for hosts without imagick', function () use (&$tempFiles) {
$source = createTestImage(1200, 400, 'image/jpeg', '3366cc');
$tempFiles[] = $source;
// The GD fallback never runs when imagick is loaded (as it is in CI), so
// exercise it directly to guard against it breaking undetected.
$method = new ReflectionMethod(MediaOptimizer::class, 'fitOntoBlurredBackgroundGd');
$method->setAccessible(true);
$canvas = $method->invoke(new MediaOptimizer, $source, 1080, 1920);
expect($canvas->width())->toBe(1080)
->and($canvas->height())->toBe(1920);
});
it('scales an already-9:16 image down to the canvas with no letterbox band', function () use (&$tempFiles) {
$source = createTestImage(2160, 3840, 'image/jpeg', 'ff0000'); // already 9:16, larger than canvas
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->fitToCanvas($source, 1080, 1920);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$out = $manager->decodePath($result);
// Scaled down to the exact canvas, and no darkened band was added: a corner
// (background region for the fit path) matches the centre.
$corner = $out->colorAt(0, 0);
$center = $out->colorAt(540, 960);
expect($out->width())->toBe(1080)
->and($out->height())->toBe(1920)
->and(abs($corner->red()->value() - $center->red()->value()))->toBeLessThan(10);
});
it('throws when the source bytes are not a decodable image', function () use (&$tempFiles) {
$bad = tempnam(sys_get_temp_dir(), 'bad_fit_');
file_put_contents($bad, '<html>404 not found</html>');
$tempFiles[] = $bad;
$optimizer = new MediaOptimizer;
expect(fn () => $optimizer->fitToCanvas($bad, 1080, 1920))->toThrow(Exception::class);
});
it('refuses to fit an image whose dimensions exceed the memory budget', function () use (&$tempFiles) {
$huge = createHugeHeaderImage(20000, 20000);
$tempFiles[] = $huge;
$optimizer = new MediaOptimizer;
expect(fn () => $optimizer->fitToCanvas($huge, 1080, 1920))
->toThrow(RuntimeException::class, 'exceed the safe processing budget');
});
it('refuses to crop an image whose dimensions exceed the memory budget', function () use (&$tempFiles) {
$huge = createHugeHeaderImage(20000, 20000);
$tempFiles[] = $huge;
$optimizer = new MediaOptimizer;
expect(fn () => $optimizer->cropToAspectRatio($huge, 0.8))
->toThrow(RuntimeException::class, 'exceed the safe processing budget');
});