Главная/Статьи/Конвертация изображений в WebP на лету

Конвертация изображений в WebP на лету

Автоматическая конвертация JPG/PNG в WebP для улучшения Page Speed. Решения для Nginx, PHP и Битрикс.

ДМ
Дмитрий Мещеряков
📅 22 сентября 2020 г.📖 7 мин чтения

WebP даёт 25–35% экономии веса при том же визуальном качестве — на сайте с большим каталогом это самый дешёвый способ ускорить загрузку. Способов внедрения несколько, и выбор между ними определяется не техническими предпочтениями, а тем, что у вас уже есть: доступ к конфигу nginx, объём каталога, наличие CDN.

Разберём варианты и то, где каждый из них ломается.

Зачем нужен WebP

WebP — современный формат изображений от Google:

  • На 25-35% меньше размер по сравнению с JPEG
  • Поддержка прозрачности (как PNG, но меньше размер)
  • Поддерживается всеми современными браузерами

Google PageSpeed настоятельно рекомендует использовать WebP.

Способ 1: Nginx с модулем ngx_pagespeed

Самый эффективный способ — конвертация на уровне веб-сервера:

nginx
# Включение модуля pagespeed
pagespeed on;
pagespeed FileCachePath /var/cache/pagespeed;

# Конвертация изображений в WebP
pagespeed EnableFilters convert_jpeg_to_webp;
pagespeed EnableFilters convert_png_to_jpeg;
pagespeed EnableFilters convert_to_webp_lossless;

# Оптимизация качества
pagespeed JpegRecompressionQuality 85;
pagespeed WebpRecompressionQuality 80;

Способ 2: Nginx с проверкой поддержки

⚠️ Важно

Отдавая разное содержимое по одному адресу, обязательно добавьте Vary: Accept. Иначе промежуточный кэш — CDN, кэш провайдера, прокси в офисной сети — сохранит WebP-версию, полученную для одного посетителя, и отдаст её всем остальным, включая браузеры, которые формат не поддерживают. Пользователь увидит вместо картинок пустые места или предложение скачать файл.

Проявляется это выборочно и невоспроизводимо на вашей машине — то есть относится к худшему классу ошибок. Заголовок Vary сообщает кэшам, что ответ зависит от заголовка Accept, и снимает проблему.

Отдаём WebP если браузер поддерживает:

nginx
map $http_accept $webp_suffix {
    default   "";
    "~*webp"  ".webp";
}

server {
    location ~* ^(.+)\.(jpe?g|png)$ {
        # Проверяем существование WebP-версии
        set $webp_file $1$webp_suffix;
        
        if (-f $document_root$webp_file) {
            rewrite ^ $webp_file break;
        }
        
        # Если WebP нет — отдаём оригинал
        try_files $uri =404;
    }
}

Способ 3: PHP-конвертация

Установка библиотеки

bash
composer require rosell-dk/webp-convert

Базовая конвертация

php
<?php

use WebPConvert\WebPConvert;

require 'vendor/autoload.php';

$source = __DIR__ . '/images/photo.jpg';
$destination = __DIR__ . '/images/photo.webp';

WebPConvert::convert($source, $destination, [
    'quality' => 80,
    'max-quality' => 85,
    'default-quality' => 75,
]);

Класс для массовой конвертации

php
<?php

namespace Local\Media;

use WebPConvert\WebPConvert;

class WebpConverter
{
    private array $options = [
        'quality' => 'auto',
        'max-quality' => 85,
        'default-quality' => 75,
        'encoding' => 'auto',
        'near-lossless' => 60,
    ];

    /**
     * Конвертация одного файла
     */
    public function convert(string $source, ?string $destination = null): ?string
    {
        if (!file_exists($source)) {
            return null;
        }

        // Проверяем расширение
        $ext = strtolower(pathinfo($source, PATHINFO_EXTENSION));
        if (!in_array($ext, ['jpg', 'jpeg', 'png'])) {
            return null;
        }

        $destination = $destination ?? $this->getWebpPath($source);

        // Пропускаем если уже конвертирован
        if (file_exists($destination) && filemtime($destination) >= filemtime($source)) {
            return $destination;
        }

        try {
            WebPConvert::convert($source, $destination, $this->options);
            return $destination;
        } catch (\Throwable $e) {
            error_log("WebP conversion failed: {$source} - {$e->getMessage()}");
            return null;
        }
    }

    /**
     * Конвертация директории
     */
    public function convertDirectory(string $dir, bool $recursive = true): array
    {
        $converted = [];
        $pattern = $recursive ? '*.{jpg,jpeg,png}' : '*.{jpg,jpeg,png}';
        $flags = GLOB_BRACE | ($recursive ? 0 : 0);

        if ($recursive) {
            $files = $this->globRecursive($dir, $pattern);
        } else {
            $files = glob($dir . '/' . $pattern, GLOB_BRACE);
        }

        foreach ($files as $file) {
            $result = $this->convert($file);
            if ($result) {
                $converted[] = $result;
            }
        }

        return $converted;
    }

    /**
     * Получение пути для WebP
     */
    private function getWebpPath(string $source): string
    {
        return preg_replace('/\.(jpe?g|png)$/i', '.webp', $source);
    }

    /**
     * Рекурсивный glob
     */
    private function globRecursive(string $dir, string $pattern): array
    {
        $files = glob($dir . '/' . $pattern, GLOB_BRACE);
        
        foreach (glob($dir . '/*', GLOB_ONLYDIR) as $subdir) {
            $files = array_merge($files, $this->globRecursive($subdir, $pattern));
        }
        
        return $files;
    }
}

// Использование
$converter = new WebpConverter();
$converter->convertDirectory('/upload/iblock/', true);

Способ 4: Замена в HTML (Битрикс)

Обработчик OnEndBufferContent для замены путей:

php
<?php
// /local/php_interface/init.php

use Bitrix\Main\EventManager;

EventManager::getInstance()->addEventHandler(
    'main',
    'OnEndBufferContent',
    'replaceImagesToWebp'
);

function replaceImagesToWebp(string &$content): void
{
    // Пропускаем админку
    if (defined('ADMIN_SECTION') && ADMIN_SECTION === true) {
        return;
    }

    // Проверяем поддержку WebP браузером
    if (!isWebpSupported()) {
        return;
    }

    // Заменяем в тегах <img>
    $content = preg_replace_callback(
        '/<img([^>]+)src=["\']([^"\']+\.(jpe?g|png))["\']([^>]*)>/i',
        function ($matches) {
            $webpPath = preg_replace('/\.(jpe?g|png)$/i', '.webp', $matches[2]);
            $fullPath = $_SERVER['DOCUMENT_ROOT'] . $webpPath;
            
            if (file_exists($fullPath)) {
                return '<img' . $matches[1] . 'src="' . $webpPath . '"' . $matches[4] . '>';
            }
            
            return $matches[0];
        },
        $content
    );

    // Заменяем в background-image
    $content = preg_replace_callback(
        '/background(-image)?:\s*url\(["\']?([^"\')\s]+\.(jpe?g|png))["\']?\)/i',
        function ($matches) {
            $webpPath = preg_replace('/\.(jpe?g|png)$/i', '.webp', $matches[2]);
            $fullPath = $_SERVER['DOCUMENT_ROOT'] . $webpPath;
            
            if (file_exists($fullPath)) {
                return 'background' . $matches[1] . ': url(' . $webpPath . ')';
            }
            
            return $matches[0];
        },
        $content
    );
}

function isWebpSupported(): bool
{
    return strpos($_SERVER['HTTP_ACCEPT'] ?? '', 'image/webp') !== false;
}

Способ 5: Picture element (универсальный)

💡 Совет

Это самый предсказуемый из всех вариантов, и при прочих равных я выбираю его. Выбор формата делает браузер по разметке, а не сервер по заголовку: не нужен доступ к конфигу nginx, не возникает проблем с промежуточными кэшами, поведение одинаково на любом хостинге и с любым CDN.

Плата — разметка становится многословнее, и её нужно генерировать. На проекте с шаблонизатором это одна функция-хелпер, после которой вопрос закрыт.

HTML с fallback для старых браузеров:

html
<picture>
    <source srcset="/images/photo.webp" type="image/webp">
    <source srcset="/images/photo.jpg" type="image/jpeg">
    <img src="/images/photo.jpg" alt="Описание">
</picture>

Генерация picture в Битрикс

php
<?php
function getPictureHtml(int $fileId, string $alt = ''): string
{
    $file = \CFile::GetFileArray($fileId);
    if (!$file) {
        return '';
    }

    $src = $file['SRC'];
    $webpSrc = preg_replace('/\.(jpe?g|png)$/i', '.webp', $src);
    $webpExists = file_exists($_SERVER['DOCUMENT_ROOT'] . $webpSrc);

    $html = '<picture>';
    
    if ($webpExists) {
        $html .= '<source srcset="' . $webpSrc . '" type="image/webp">';
    }
    
    $html .= '<img src="' . $src . '" alt="' . htmlspecialchars($alt) . '" loading="lazy">';
    $html .= '</picture>';

    return $html;
}

Консольный скрипт конвертации

php
<?php
// /local/scripts/convert_to_webp.php

$_SERVER['DOCUMENT_ROOT'] = realpath(__DIR__ . '/../../');
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

use Local\Media\WebpConverter;

$directories = [
    '/upload/iblock/',
    '/upload/medialibrary/',
    '/local/templates/main/images/',
];

$converter = new WebpConverter();
$totalConverted = 0;

foreach ($directories as $dir) {
    $fullPath = $_SERVER['DOCUMENT_ROOT'] . $dir;
    
    if (!is_dir($fullPath)) {
        echo "Директория не найдена: {$dir}\n";
        continue;
    }

    echo "Обработка: {$dir}\n";
    $converted = $converter->convertDirectory($fullPath);
    
    echo "  Конвертировано: " . count($converted) . " файлов\n";
    $totalConverted += count($converted);
}

echo "\nИтого конвертировано: {$totalConverted} файлов\n";

Запуск:

bash
php /home/bitrix/www/local/scripts/convert_to_webp.php

Cron для автоматической конвертации

bash
# Каждую ночь в 3:00
0 3 * * * /usr/bin/php /home/bitrix/www/local/scripts/convert_to_webp.php >> /var/log/webp_convert.log 2>&1

Что учесть при внедрении

Не конвертируйте по запросу без ограничений. Схема «пришёл запрос на картинку — конвертируем на лету» превращается в вектор нагрузки: бот, перебирающий адреса изображений, заставит сервер молотить конвертацию непрерывно. Конвертируйте заранее пакетно, а на лету — только с сохранением результата и с ограничением на одновременные операции.

Проверяйте, что конвертация выгодна. Для небольших PNG с прозрачностью и для уже оптимизированных JPEG WebP иногда даёт файл больше исходного. Логика должна сравнивать размеры и оставлять оригинал, если выигрыша нет, — иначе оптимизация местами работает в минус.

Прозрачность и анимация переносятся не всегда. Проверьте на реальных изображениях каталога: логотипы с альфа-каналом и анимированные GIF — те случаи, где результат стоит посмотреть глазами, а не довериться коду.

Держите оригиналы. WebP — производный формат; исходники нужны для повторной генерации при смене размеров, для выгрузок и для маркетплейсов, которые его не принимают.

Место на диске удвоится. Каталог с картинками весом 20 ГБ после конвертации займёт около 35. Это нужно посчитать до запуска пакетной обработки, а не в момент, когда диск закончится посередине.

💡 Совет

Про качество: разумный диапазон для WebP — 75–85, ниже 70 на фотографиях появляются заметные артефакты. Но подбирать значение стоит на своих изображениях: для фотографий товаров и для схематичной графики оптимум разный, а разница между 80 и 85 по весу заметнее, чем по виду.

⚠️ Важно

Перед запуском проверьте поддержку формата в PHP: php -m | grep -iE "gd|imagick", а затем — что сборка GD действительно умеет WebP (gd_info() покажет соответствующий флаг). Наличие модуля само по себе ничего не гарантирует: сборки без поддержки WebP встречаются, и обнаруживается это на первой же конвертации.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.