Реализация автоматической оптимизации загружаемых изображений (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 часа отдельно.







