Harden Instagram story image fitting and cover it end to end

- Guard MediaOptimizer::fitToCanvas and cropToAspectRatio against huge-dimension sources (getimagesize budget check) so they fail cleanly instead of exhausting GD memory.
- Fit and crop now translate decode/process failures into a clean InstagramPublishException and remove their temp files via finally; the two paths are symmetric.
- publishStory reads the story canvas dimensions via data_get.
- Previews: restore the IG empty-state background, align the autoFitsImage suppression predicate to isImage, drop the explanatory comment, and use single-quote imports.
- Tests: real end-to-end story fit, undecodable/download/container failures, the memory guard (fit and crop), temp-file cleanup, blurred-background pixel assertions, and the aspect-ratio warning suppression.
This commit is contained in:
Paulo Castellano 2026-07-16 20:09:43 -03:00
parent c74170a25a
commit 90ce205763
11 changed files with 295 additions and 28 deletions

View file

@ -8,6 +8,7 @@
use Illuminate\Support\Facades\Log;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
use RuntimeException;
class MediaOptimizer
{
@ -113,6 +114,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();
@ -156,6 +159,8 @@ public function cropToAspectRatio(string $filePath, float $ratio): string
*/
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();
@ -181,6 +186,27 @@ public function fitToCanvas(string $filePath, int $width, int $height): string
return $tempFile;
}
/**
* 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;
}
$estimatedMemory = $imageInfo[0] * $imageInfo[1] * ($imageInfo['channels'] ?? 4) * 1.5;
if ($estimatedMemory > 256 * 1024 * 1024) {
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,20 @@ 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);
return Storage::url($path);
} finally {
@unlink($cropped);
}
} finally {
@unlink($tempInput);
}
@ -66,14 +72,20 @@ protected function fitImageToCanvas(string $imageUrl, int $width, int $height):
throw $this->cropFailureException('Failed to download image for story fitting');
}
$fitted = app(MediaOptimizer::class)->fitToCanvas($tempInput, $width, $height);
try {
$fitted = app(MediaOptimizer::class)->fitToCanvas($tempInput, $width, $height);
} catch (\Throwable) {
throw $this->cropFailureException('Failed to process image for story fitting');
}
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($fitted));
try {
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($fitted));
@unlink($fitted);
return Storage::url($path);
return Storage::url($path);
} finally {
@unlink($fitted);
}
} finally {
@unlink($tempInput);
}

View file

@ -159,7 +159,7 @@ private function publishStory(string $instagramId, string $accessToken, $media):
$params['video_url'] = $media->url;
} else {
$dimensions = ContentType::InstagramStory->aiImageDimensions();
$params['image_url'] = $this->fitImageToCanvas($media->url, $dimensions['width'], $dimensions['height']);
$params['image_url'] = $this->fitImageToCanvas($media->url, data_get($dimensions, 'width'), data_get($dimensions, 'height'));
}
// Step 1: Create story container

View file

@ -168,7 +168,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
<!-- Video/Media - Full screen -->
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center">
<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>
@ -232,7 +232,7 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
<!-- Media - Full screen -->
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center">
<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>

View file

@ -2,7 +2,7 @@
import { IconPlus } from '@tabler/icons-vue';
import { computed } from 'vue';
import VerticalMediaCanvas from "@/components/posts/previews/VerticalMediaCanvas.vue";
import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue';
import type { MediaItem } from '@/types/media';
interface SocialAccount {

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import VerticalMediaCanvas from "@/components/posts/previews/VerticalMediaCanvas.vue";
import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue';
import type { MediaItem } from '@/types/media';
interface SocialAccount {

View file

@ -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 && ! (rules.autoFitsImage && ! itemIsVideo)) {
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,8 +17,6 @@ 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;
}

View file

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

@ -198,6 +198,131 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
});
});
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]);
@ -814,6 +939,42 @@ 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('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,21 @@ 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;
}
$tempFiles = [];
afterEach(function () use (&$tempFiles) {
@ -214,8 +229,8 @@ function createTestImage(int $width, int $height, string $format = 'image/jpeg')
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
it('fills the gaps with a darkened image-derived background, never a black letterbox', function () use (&$tempFiles) {
$source = createTestImage(1200, 900, 'image/jpeg', 'ff0000'); // 4:3 red, wider than 9:16
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
@ -227,10 +242,20 @@ function createTestImage(int $width, int $height, string $format = 'image/jpeg')
expect($out->width())->toBe(1080)
->and($out->height())->toBe(1920);
// A 4:3 image is letterboxed top & bottom on a 9:16 canvas. The band must be
// a darkened copy of the red image, not a black bar, and darker than the
// centered foreground on top of it.
$band = $out->colorAt(540, 40); // top background band
$foreground = $out->colorAt(540, 960); // centered image
expect($band->red()->value())->toBeGreaterThan(120)
->and($band->red()->value())->toBeGreaterThan($band->green()->value() + 60)
->and($foreground->red()->value())->toBeGreaterThan($band->red()->value());
});
it('does not letterbox an image that already matches the canvas ratio', function () use (&$tempFiles) {
$source = createTestImage(1080, 1920); // already 9:16
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;
@ -240,6 +265,42 @@ function createTestImage(int $width, int $height, string $format = 'image/jpeg')
$manager = new ImageManager(Driver::class);
$out = $manager->decodePath($result);
$ratio = $out->width() / $out->height();
expect(abs($ratio - 9 / 16))->toBeLessThan(0.01);
// 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');
});