Реалізація автоматичної оптимізації завантажених зображень (WebP/AVIF)
Неоптимізовані зображення — найчастіше головне джерело лишнього трафіку на сайті. PNG на 4 МБ замість WebP на 300 КБ — реальна історія з будь-якого проекту, де завантаження файлів додали без конвертації. Автоматизація на рівні бекенду знімає проблему незалежно від того, що саме завантажує користувач.
Формати та їх застосування
WebP — підтримується всіма браузерами з 2020 року. Lossy-компресія на 25–35% ефективніша за JPEG, lossless — на 25–34% краща за PNG. Хороший варіант за замовчуванням.
AVIF — заснований на AV1, ще ефективніший за WebP приблизно на 20–30%, особливо на фотографіях. Мінуси: кодування повільніше (в 5–10 разів на libavif), підтримка в Safari з'явилася з версії 16.4. На 2025 рік охоплення браузерів — близько 93%.
Практичний підхід: генерувати обидва формати, віддавати через <picture> з type="image/avif" в першому <source>.
Стек
На сервері обробку краще підняти через sharp (Node.js) або Intervention Image з Imagick (PHP). Для чисто серверної оптимізації без прив'язки до фреймворку — squoosh-cli або cjpeg/cwebp від Google як системні утиліти.
Для PHP-проектів intervention/image працює з Imagick, який підтримує WebP, AVIF (з ImageMagick 7.0.25+).
Перевірка версії на сервері:
convert --version
# ImageMagick 7.1.x ...
php -r "echo Imagick::getVersion()['versionString'];"
Якщо версія Imagick нижче 7 — AVIF недоступний. Тоді для AVIF використовуємо libavif через avifenc:
apt install libavif-bin
avifenc --speed 6 --quality 50 input.png output.avif
Сервіс оптимізації (PHP/Laravel)
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Facades\Image;
class ImageOptimizationService
{
private const WEBP_QUALITY = 82;
private const AVIF_QUALITY = 55; // AVIF шкала інша, 55 ≈ JPEG 85
private const MAX_WIDTH = 2560;
public function optimize(UploadedFile $file, string $storagePath): array
{
$img = Image::make($file);
// Не збільшуємо маленькі зображення
if ($img->width() > self::MAX_WIDTH) {
$img->resize(self::MAX_WIDTH, null, function ($c) {
$c->aspectRatio();
$c->upsize();
});
}
// Видаляємо EXIF (приватні дані + вага)
$img->orientate(); // застосовуємо EXIF-орієнтацію перед скиданням
$hash = hash('xxh3', file_get_contents($file->getRealPath()));
$dir = rtrim($storagePath, '/');
$result = [];
// WebP
$webpPath = "{$dir}/{$hash}.webp";
Storage::disk('public')->put(
$webpPath,
(string) $img->encode('webp', self::WEBP_QUALITY)
);
$result['webp'] = $webpPath;
// AVIF через avifenc якщо доступний, інакше через Imagick
$avifPath = "{$dir}/{$hash}.avif";
if ($this->avifEncAvailable()) {
$result['avif'] = $this->encodeAvifViaCli(
$img, $avifPath, self::AVIF_QUALITY
);
} elseif ($this->imagickSupportsAvif()) {
$imagick = new \Imagick();
$imagick->readImageBlob((string) $img->encode('png'));
$imagick->setImageFormat('avif');
$imagick->setImageCompressionQuality(self::AVIF_QUALITY);
Storage::disk('public')->put($avifPath, $imagick->getImageBlob());
$result['avif'] = $avifPath;
}
return $result;
}
private function avifEncAvailable(): bool
{
return !empty(shell_exec('which avifenc 2>/dev/null'));
}
private function imagickSupportsAvif(): bool
{
return in_array('AVIF', (new \Imagick())->queryFormats('AVIF'), true);
}
private function encodeAvifViaCli(\Intervention\Image\Image $img, string $storagePath, int $quality): string
{
$tmpIn = tempnam(sys_get_temp_dir(), 'avif_in_') . '.png';
$tmpOut = tempnam(sys_get_temp_dir(), 'avif_out_') . '.avif';
file_put_contents($tmpIn, (string) $img->encode('png'));
exec("avifenc --speed 6 --quality {$quality} {$tmpIn} {$tmpOut} 2>&1");
Storage::disk('public')->put($storagePath, file_get_contents($tmpOut));
unlink($tmpIn);
unlink($tmpOut);
return $storagePath;
}
}
Фонова обробка через Job
Конвертація AVIF — повільна операція (до 2–5 секунд на фото). Тримати HTTP-запит відкритим все це час не потрібно. Паттерн: спочатку зберегти оригінал, одразу відповісти клієнту, у фоні запустити оптимізацію.
// app/Jobs/OptimizeImageJob.php
class OptimizeImageJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public function __construct(
private int $mediaId,
private string $originalPath
) {}
public function handle(ImageOptimizationService $service): void
{
$media = Media::findOrFail($this->mediaId);
// Створюємо UploadedFile з існуючого файлу
$fullPath = Storage::disk('public')->path($this->originalPath);
$file = new \Illuminate\Http\UploadedFile($fullPath, basename($fullPath));
$variants = $service->optimize($file, dirname($this->originalPath));
$media->update([
'variants' => array_merge($media->variants ?? [], $variants),
'optimized_at' => now(),
]);
}
}
Диспатч після завантаження оригіналу:
$media = Media::create(['path' => $originalPath, ...]);
OptimizeImageJob::dispatch($media->id, $originalPath)->onQueue('media');
Віддача правильного формату
На рівні Blade/фронтенду використовуємо <picture>:
<picture>
@if($media->variantUrl('avif'))
<source srcset="{{ $media->variantUrl('avif') }}" type="image/avif">
@endif
<source srcset="{{ $media->variantUrl('webp') }}" type="image/webp">
<img src="{{ $media->url }}" alt="{{ $alt }}" loading="lazy" decoding="async">
</picture>
Nginx-варіант для автоматичної віддачі WebP без зміни коду (якщо файл існує):
location ~* \.(jpe?g|png)$ {
add_header Vary Accept;
try_files
$uri.webp
$uri
=404;
}
Працює тільки якщо WebP-файл лежить поруч з оригіналом з додаванням .webp до імені.
Сроки
Установка та настройка стека, сервіс оптимізації — 5–7 годин. Фоновий Job, інтеграція з моделлю, Blade-компонент <picture> — ще 4–5 годин. Настройка Nginx-варіанту без зміни шаблонів — 1–2 години окремо.







