trypost/tests/Unit/Services/Media/MediaOptimizerTest.php
Paulo Castellano bfbcd642ea 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.
2026-06-29 14:35:36 -03:00

245 lines
7.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Services\Media\MediaOptimizer;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
function createTestImage(int $width, int $height, string $format = 'image/jpeg'): string
{
$manager = new ImageManager(Driver::class);
$image = $manager->createImage($width, $height)->fill('cccccc');
$tempFile = tempnam(sys_get_temp_dir(), 'test_img_');
$encoded = $image->encodeUsingMediaType($format);
file_put_contents($tempFile, (string) $encoded);
return $tempFile;
}
$tempFiles = [];
afterEach(function () use (&$tempFiles) {
foreach ($tempFiles as $file) {
@unlink($file);
}
$tempFiles = [];
});
it('resizes wide image for instagram', function () use (&$tempFiles) {
$source = createTestImage(3000, 2000);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->optimizeImage($source, Platform::Instagram);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$optimized = $manager->decodePath($result);
expect($optimized->width())->toBeLessThanOrEqual(1440);
$bytes = file_get_contents($result);
expect(ord($bytes[0]))->toBe(0xFF)
->and(ord($bytes[1]))->toBe(0xD8);
});
it('resizes for bluesky under 1mb', function () use (&$tempFiles) {
$source = createTestImage(2000, 2000);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->optimizeImage($source, Platform::Bluesky);
$tempFiles[] = $result;
expect(filesize($result))->toBeLessThan(976 * 1024);
});
it('does not upscale small images', function () use (&$tempFiles) {
$source = createTestImage(500, 500);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->optimizeImage($source, Platform::Instagram);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$optimized = $manager->decodePath($result);
expect($optimized->width())->toBe(500);
});
it('converts png to jpeg for instagram', function () use (&$tempFiles) {
$source = createTestImage(800, 600, 'image/png');
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->optimizeImage($source, Platform::Instagram);
$tempFiles[] = $result;
$bytes = file_get_contents($result);
expect(ord($bytes[0]))->toBe(0xFF)
->and(ord($bytes[1]))->toBe(0xD8);
});
it('resizes for tiktok max 1080', function () use (&$tempFiles) {
$source = createTestImage(2000, 2000);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->optimizeImage($source, Platform::TikTok);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$optimized = $manager->decodePath($result);
expect($optimized->width())->toBeLessThanOrEqual(1080);
});
it('resizes for pinterest max 1000', function () use (&$tempFiles) {
$source = createTestImage(2000, 3000);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->optimizeImage($source, Platform::Pinterest);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$optimized = $manager->decodePath($result);
expect($optimized->width())->toBeLessThanOrEqual(1000);
});
it('exposes the configured max width per platform', function () {
$optimizer = new MediaOptimizer;
expect($optimizer->maxWidthForPlatform(Platform::TikTok))->toBe(1080)
->and($optimizer->maxWidthForPlatform(Platform::Instagram))->toBe(1440)
->and($optimizer->maxWidthForPlatform(Platform::Pinterest))->toBe(1000);
});
it('reports a max width for every platform', function () {
$optimizer = new MediaOptimizer;
foreach (Platform::cases() as $platform) {
expect($optimizer->maxWidthForPlatform($platform))->toBeInt()->toBeGreaterThan(0);
}
});
it('handles all platforms without error', function () use (&$tempFiles) {
$source = createTestImage(1000, 800);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
foreach (Platform::cases() as $platform) {
$result = $optimizer->optimizeImage($source, $platform);
$tempFiles[] = $result;
expect(file_exists($result))->toBeTrue()
->and(filesize($result))->toBeGreaterThan(0);
}
});
it('returns valid temp file path', function () use (&$tempFiles) {
$source = createTestImage(800, 600);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->optimizeImage($source, Platform::Facebook);
$tempFiles[] = $result;
expect(file_exists($result))->toBeTrue()
->and(filesize($result))->toBeGreaterThan(0);
});
it('crops a wide image to a 1:1 square', function () use (&$tempFiles) {
$source = createTestImage(1920, 1080);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->cropToAspectRatio($source, 1.0);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$cropped = $manager->decodePath($result);
expect($cropped->width())->toBe($cropped->height());
expect($cropped->height())->toBe(1080);
});
it('crops a tall image to a 4:5 portrait', function () use (&$tempFiles) {
$source = createTestImage(1000, 2000);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->cropToAspectRatio($source, 4 / 5);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$cropped = $manager->decodePath($result);
$ratio = $cropped->width() / $cropped->height();
expect(abs($ratio - 0.8))->toBeLessThan(0.01);
});
it('crops a square image to a 16:9 landscape', function () use (&$tempFiles) {
$source = createTestImage(1080, 1080);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->cropToAspectRatio($source, 16 / 9);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$cropped = $manager->decodePath($result);
$ratio = $cropped->width() / $cropped->height();
expect(abs($ratio - 16 / 9))->toBeLessThan(0.01);
});
it('returns a copy when image already matches the target ratio', function () use (&$tempFiles) {
$source = createTestImage(800, 800);
$tempFiles[] = $source;
$optimizer = new MediaOptimizer;
$result = $optimizer->cropToAspectRatio($source, 1.0);
$tempFiles[] = $result;
$manager = new ImageManager(Driver::class);
$cropped = $manager->decodePath($result);
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);
});