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.
This commit is contained in:
Paulo Castellano 2026-07-17 10:31:35 -03:00
parent e1acdab2e3
commit af9736642c
3 changed files with 143 additions and 23 deletions

View file

@ -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;
}
/**

View file

@ -18,8 +18,12 @@ const item = computed<MediaItem | null>(() => props.media[0] ?? null);
<template v-if="item">
<VideoPreview v-if="isVideoMedia(item)" :src="item.url" video-class="h-full w-full object-cover" />
<template v-else>
<img :src="item.url" alt="" aria-hidden="true"
class="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl brightness-90" />
<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>

View file

@ -33,6 +33,22 @@ function createHugeHeaderImage(int $width, int $height): string
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) {
@ -229,29 +245,54 @@ function createHugeHeaderImage(int $width, int $height): string
expect($cropped->height())->toBe(800);
});
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
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;
$manager = new ImageManager(Driver::class);
$out = $manager->decodePath($result);
$out = (new ImageManager(Driver::class))->decodePath($result);
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
// 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($band->red()->value())->toBeGreaterThan(120)
->and($band->red()->value())->toBeGreaterThan($band->green()->value() + 60)
->and($foreground->red()->value())->toBeGreaterThan($band->red()->value());
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) {