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:
commit
67cf43a5f2
12 changed files with 662 additions and 74 deletions
|
|
@ -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}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue