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:
parent
c74170a25a
commit
90ce205763
11 changed files with 295 additions and 28 deletions
|
|
@ -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}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue