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:
parent
e1acdab2e3
commit
af9736642c
3 changed files with 143 additions and 23 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue