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.
259 lines
9.2 KiB
PHP
259 lines
9.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services\Media;
|
||
|
||
use App\Enums\SocialAccount\Platform;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Intervention\Image\Drivers\Gd\Driver;
|
||
use Intervention\Image\ImageManager;
|
||
|
||
class MediaOptimizer
|
||
{
|
||
private ImageManager $manager;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->manager = new ImageManager(Driver::class);
|
||
}
|
||
|
||
/**
|
||
* Optimize an image for a specific platform.
|
||
* Returns path to optimized temp file (caller must clean up).
|
||
*/
|
||
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 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,
|
||
]);
|
||
|
||
// Copy original file to temp location and return
|
||
$tempFile = tempnam(sys_get_temp_dir(), 'media_opt_');
|
||
copy($filePath, $tempFile);
|
||
|
||
return $tempFile;
|
||
}
|
||
}
|
||
|
||
$image = $this->manager->decodePath($filePath);
|
||
|
||
$maxWidth = data_get($config, 'max_width');
|
||
$maxSize = data_get($config, 'max_size');
|
||
$format = data_get($config, 'format');
|
||
$quality = data_get($config, 'quality');
|
||
|
||
// Resize if needed (maintain aspect ratio, never upscale)
|
||
if ($maxWidth && $image->width() > $maxWidth) {
|
||
$image->scaleDown(width: $maxWidth);
|
||
}
|
||
|
||
// Encode to target format at the target quality (never reduced)
|
||
$tempFile = tempnam(sys_get_temp_dir(), 'media_opt_');
|
||
$encoded = $image->encodeUsingMediaType($format, quality: $quality);
|
||
file_put_contents($tempFile, (string) $encoded);
|
||
|
||
// If still above the platform size budget, iteratively shrink DIMENSIONS
|
||
// (not quality) by 10 % per step until it fits. Postiz-style, preserves
|
||
// pixel quality while lowering the byte count.
|
||
while (filesize($tempFile) > $maxSize) {
|
||
$newWidth = (int) ($image->width() * 0.9);
|
||
$newHeight = (int) ($image->height() * 0.9);
|
||
|
||
// Safety floor: don't shrink below 100 px on the longer side.
|
||
if ($newWidth < 100 || $newHeight < 100) {
|
||
Log::warning('MediaOptimizer: image cannot fit platform size budget', [
|
||
'platform' => $platform->value,
|
||
'final_width' => $image->width(),
|
||
'final_height' => $image->height(),
|
||
'final_bytes' => filesize($tempFile),
|
||
'budget_bytes' => $maxSize,
|
||
]);
|
||
break;
|
||
}
|
||
|
||
$image->scale(width: $newWidth, height: $newHeight);
|
||
$encoded = $image->encodeUsingMediaType($format, quality: $quality);
|
||
file_put_contents($tempFile, (string) $encoded);
|
||
}
|
||
|
||
return $tempFile;
|
||
}
|
||
|
||
/**
|
||
* The maximum image width (px) enforced for a platform. Pull-from-URL
|
||
* publishers (e.g. TikTok) use this to decide whether a source image needs
|
||
* a resized, spec-compliant derivative before the platform fetches it.
|
||
*/
|
||
public function maxWidthForPlatform(Platform $platform): ?int
|
||
{
|
||
$maxWidth = data_get($this->getImageConfig($platform), 'max_width');
|
||
|
||
return is_int($maxWidth) ? $maxWidth : null;
|
||
}
|
||
|
||
/**
|
||
* Center-crop an image to the given aspect ratio (width / height).
|
||
* Returns path to a temp file (caller must clean up).
|
||
*/
|
||
public function cropToAspectRatio(string $filePath, float $ratio): string
|
||
{
|
||
$image = $this->manager->decodePath($filePath);
|
||
|
||
$width = $image->width();
|
||
$height = $image->height();
|
||
$current = $width / $height;
|
||
|
||
if (abs($current - $ratio) < 0.001) {
|
||
$tempFile = tempnam(sys_get_temp_dir(), 'media_crop_');
|
||
copy($filePath, $tempFile);
|
||
|
||
return $tempFile;
|
||
}
|
||
|
||
if ($current > $ratio) {
|
||
// Wider than target: keep height, shrink width.
|
||
$newWidth = (int) round($height * $ratio);
|
||
$newHeight = $height;
|
||
} else {
|
||
// Taller than target: keep width, shrink height.
|
||
$newWidth = $width;
|
||
$newHeight = (int) round($width / $ratio);
|
||
}
|
||
|
||
$offsetX = (int) round(($width - $newWidth) / 2);
|
||
$offsetY = (int) round(($height - $newHeight) / 2);
|
||
|
||
$image->crop($newWidth, $newHeight, $offsetX, $offsetY);
|
||
|
||
$tempFile = tempnam(sys_get_temp_dir(), 'media_crop_');
|
||
$encoded = $image->encodeUsingMediaType('image/jpeg', quality: 100);
|
||
file_put_contents($tempFile, (string) $encoded);
|
||
|
||
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
|
||
{
|
||
$probe = $this->manager->decodePath($filePath);
|
||
$canvasRatio = $width / $height;
|
||
$imageRatio = $probe->width() / $probe->height();
|
||
|
||
$tempFile = tempnam(sys_get_temp_dir(), 'media_fit_');
|
||
|
||
if (abs($imageRatio - $canvasRatio) < 0.01) {
|
||
$sized = $this->manager->decodePath($filePath)->scaleDown($width, $height);
|
||
file_put_contents($tempFile, (string) $sized->encodeUsingMediaType('image/jpeg', quality: 100));
|
||
|
||
return $tempFile;
|
||
}
|
||
|
||
$canvas = $this->manager->decodePath($filePath)
|
||
->cover($width, $height)
|
||
->blur(40)
|
||
->brightness(-12);
|
||
|
||
$foreground = $this->manager->decodePath($filePath)->scaleDown($width, $height);
|
||
$canvas->insert($foreground, 0, 0, 'center');
|
||
|
||
file_put_contents($tempFile, (string) $canvas->encodeUsingMediaType('image/jpeg', quality: 100));
|
||
|
||
return $tempFile;
|
||
}
|
||
|
||
/**
|
||
* @return array{max_width: int, max_size: int, format: string, quality: int}
|
||
*/
|
||
private function getImageConfig(Platform $platform): array
|
||
{
|
||
return match ($platform) {
|
||
Platform::Instagram, Platform::InstagramFacebook, Platform::Threads => [
|
||
'max_width' => 1440,
|
||
'max_size' => 8 * 1024 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
Platform::Facebook => [
|
||
'max_width' => 2048,
|
||
'max_size' => 4 * 1024 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
Platform::X => [
|
||
'max_width' => 2048,
|
||
'max_size' => 5 * 1024 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
Platform::TikTok => [
|
||
'max_width' => 1080,
|
||
'max_size' => 20 * 1024 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
Platform::LinkedIn, Platform::LinkedInPage => [
|
||
'max_width' => 2048,
|
||
'max_size' => 10 * 1024 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
Platform::Pinterest => [
|
||
'max_width' => 1000,
|
||
'max_size' => 20 * 1024 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
Platform::Bluesky => [
|
||
'max_width' => 2048,
|
||
'max_size' => 976 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
Platform::Mastodon => [
|
||
'max_width' => 2048,
|
||
'max_size' => 10 * 1024 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
Platform::YouTube => [
|
||
'max_width' => 1920,
|
||
'max_size' => 2 * 1024 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
Platform::Telegram => [
|
||
'max_width' => 2048,
|
||
'max_size' => 10 * 1024 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
Platform::Discord => [
|
||
'max_width' => 2048,
|
||
'max_size' => 8 * 1024 * 1024,
|
||
'format' => 'image/jpeg',
|
||
'quality' => 100,
|
||
],
|
||
};
|
||
}
|
||
}
|