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.
92 lines
2.9 KiB
PHP
92 lines
2.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services\Social\Concerns;
|
||
|
||
use App\Enums\PostPlatform\AspectRatio;
|
||
use App\Exceptions\Social\SocialPublishException;
|
||
use App\Services\Media\MediaOptimizer;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Support\Str;
|
||
|
||
trait CropsImageForAspectRatio
|
||
{
|
||
private const CROP_DIRECTORY = 'social-crops';
|
||
|
||
/**
|
||
* Crop the image to the user-selected aspect ratio and return a public URL
|
||
* the platform can fetch. Returns the original URL untouched when no ratio
|
||
* is set or 'original' is selected.
|
||
*/
|
||
protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRatio): string
|
||
{
|
||
if (! $aspectRatio || $aspectRatio === 'original') {
|
||
return $imageUrl;
|
||
}
|
||
|
||
$ratio = $this->aspectRatioToFloat($aspectRatio);
|
||
|
||
$tempInput = tempnam(sys_get_temp_dir(), 'crop_in_');
|
||
|
||
try {
|
||
$download = Http::sink($tempInput)->timeout(120)->get($imageUrl);
|
||
|
||
if ($download->failed()) {
|
||
throw $this->cropFailureException('Failed to download image for cropping');
|
||
}
|
||
|
||
$cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio);
|
||
|
||
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
|
||
Storage::put($path, file_get_contents($cropped));
|
||
|
||
@unlink($cropped);
|
||
|
||
return Storage::url($path);
|
||
} finally {
|
||
@unlink($tempInput);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
}
|
||
|
||
$fitted = app(MediaOptimizer::class)->fitToCanvas($tempInput, $width, $height);
|
||
|
||
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
|
||
Storage::put($path, file_get_contents($fitted));
|
||
|
||
@unlink($fitted);
|
||
|
||
return Storage::url($path);
|
||
} finally {
|
||
@unlink($tempInput);
|
||
}
|
||
}
|
||
|
||
protected function aspectRatioToFloat(string $ratio): float
|
||
{
|
||
return AspectRatio::tryFrom($ratio)?->toFloat() ?? 1.0;
|
||
}
|
||
|
||
/**
|
||
* The platform-specific exception thrown when the source image cannot be
|
||
* downloaded for cropping.
|
||
*/
|
||
abstract protected function cropFailureException(string $message): SocialPublishException;
|
||
}
|