From af9736642c15aed580ef1f928a7ee4a7f00ec312 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 17 Jul 2026 10:31:35 -0300 Subject: [PATCH] Render the story background as a soft, lightened blur - Rewrite fitToCanvas to build the blurred story background with Imagick: scale the image to fill the width, heavily gaussian-blur it so shapes dissolve into a colour wash, gamma-lighten it, and mirror the top half onto the bottom for a symmetric background; the foreground is contained (fills the width, never cropped). Falls back to a GD downscale-blur on hosts without ext-imagick. - Clean up the fit temp file if the blur/encode step throws. - Update the editor preview (VerticalMediaCanvas) to a matching mirrored, lightened blur so it tracks the publish output. - Cover the lightened image-derived background, the vertical mirror, and the GD fallback path with unit tests. --- app/Services/Media/MediaOptimizer.php | 93 +++++++++++++++++-- .../posts/previews/VerticalMediaCanvas.vue | 8 +- .../Services/Media/MediaOptimizerTest.php | 65 ++++++++++--- 3 files changed, 143 insertions(+), 23 deletions(-) diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 63baaba1..3370a4a0 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -6,14 +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() @@ -158,23 +173,83 @@ public function fitToCanvas(string $filePath, int $width, int $height): string $tempFile = tempnam(sys_get_temp_dir(), 'media_fit_'); - if (abs($imageRatio - $canvasRatio) < 0.01) { - $sized = $foreground->scaleDown($width, $height); - file_put_contents($tempFile, (string) $sized->encodeUsingMediaType('image/jpeg', quality: 100)); + 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) - ->blur(40) - ->brightness(-12); + ->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->scaleDown($width, $height), 0, 0, 'center'); + $canvas->insert($foreground, 0, 0, 'center'); - file_put_contents($tempFile, (string) $canvas->encodeUsingMediaType('image/jpeg', quality: 100)); - - return $tempFile; + return $canvas; } /** diff --git a/resources/js/components/posts/previews/VerticalMediaCanvas.vue b/resources/js/components/posts/previews/VerticalMediaCanvas.vue index 97cc7181..2741ac96 100644 --- a/resources/js/components/posts/previews/VerticalMediaCanvas.vue +++ b/resources/js/components/posts/previews/VerticalMediaCanvas.vue @@ -18,8 +18,12 @@ const item = computed(() => props.media[0] ?? null);