Баланс между качеством картинки и скоростью загрузки: как устроена работа с изображениями в Readymag

Баланс между качеством картинки и скоростью загрузки: как устроена работа с изображениями в Readymag

Автор: Сергей Нечаев, fullstack-разработчик в Readymag.

В этой статье я расскажу, как мы перепридумали работу с картинками в Readymag, чтобы они автоматически подстраивались под нужные размеры экрана и при этом не замедляли загрузку проектов. Cразу упомяну, что мы задействовали конвертацию на лету с использованием лямбда-функций Amazon AWS.

Как было раньше: предварительная конвертация картинок под все размеры

Виджет «Изображение» — один из самых популярных в Readymag. Задача разработчиков — не просто дать возможность пользователям добавлять картинки в проекты: изображения должны отображаться без артефактов и без потери резкости на всех девайсах: хоть на старом мониторе, хоть на ретина дисплеях с высокой плотностью пикселей.

В чем же проблема? Представьте, что пользователь загрузил картинку, размером 3000 × 2000 `. Но в проекте она размещается в виджете размером 900 px по ширине.

<p>Пример того, насколько оригинал картинки может превышать требуемый размер<br /></p>

Пример того, насколько оригинал картинки может превышать требуемый размер

Если отдавать браузеру оригинал и упаковывать его в 900 пкс через ``, мы сильно теряем в объеме передаваемых данных: зачем отдавать картинку большого размера, если нужна картинка почти в четыре раза меньше? К тому же, адаптацию изображения большего размера мы отдаем на откуп движку браузера: не все из них хорошо справляются с такой задачей, на картинке может появиться замыленность и нерезкость.

Самый очевидный шаг — сделать ресайз картинки до требуемых 900 px, чтобы в дальнейшем хранить и отдавать именно ее. Но число стационарных мониторов и ноутбуков с большой плотностью пикселей экрана растет, их DPR не равен 1. Например, у мониторов Apple MacBook DPR=2 уже давно. Чтобы изображение на таком экране выглядело красиво, достаточно резко и насыщенно, для видимой области 900 px нужно отдать картинку шириной 1800 px.

Некоторые экраны мобильных телефонов имеют еще больший DPR=3. Значит, для них нужна еще одна картинка размером 900 × 3 = 2700 px. Таким образом, если мы хотим показывать красивые, резкие изображения на мониторах с DRP=1/2/3, приходится хранить уже четыре картинки:

  • 3000 × 2000 (оригинал)
  • 900 × 600 (для DPR=1)
  • 1800 × 1200 (для DPR=2)
  • 2700 × 1800 (для DPR=3)

Еще три других варианта нам понадобятся для мобильной версии проекта — там размер виджета может быть свой, не 900, а, например, 400 px по ширине.

Получается, что для подобного решения на один оригинал нам нужно минимум три, а если у проекта есть мобильная версия, то сразу шесть дополнительных картинок. Их нужно отресайзить, возможно, кропнуть (это отнимает время) и сохранить (а это занимает место). При этом совсем не факт, что все изображения реально пригодятся.

Это не самое элегантное решение, но именно так долгое время была устроена работа с картинками в Readymag. При загрузке изображение сразу конвертировалось во все нужные размеры. Хуже того, если пользователь менял размер виджета, весь набор картинок генерировался заново. Мы жили с этим какое-то время, но потом захотели поменять две вещи:

  • Добавить автоматический Scale Layout — адаптацию проектов под любую ширину экранов браузера на основе CSS zoom.

  • Отдавать картинки в современном формате WebP.

Как мы сделали автоматический Scale Layout с помощью Amazon AWS

Кейс со Scale Layout не решается предварительной генерацией изображений: значение CSS zoom зависит от размера окна браузера и индивидуально для каждого пользователя. Выходит, заранее генерировать набор картинок мы не можем. Значит, нужно подстроиться под индивидуальные условия и отдавать картинки нужного разрешения и размера “на лету”.

Поскольку на тот момент мы уже использовали облачное хранилище S3 от Amazon, логично было поискать что-то, что могло бы подойти для нашей задачи, среди его сервисов. И такое решение нашлось: им стали лямбда-функции AWS Lambda, исполняемые на облачных мощностях Amazon.

Схема работы AWS Lambda.
Схема работы AWS Lambda.

Лямбда-функция не привязана к состоянию проекта пользователя и вообще ничего о нем не знает. Она работает только с одной задачей: взять указанную картинку и сконвертировать ее под запрошенные параметры.

Лямбда-функция спрятана за прокси-сервером Cloudfront. Прокси кэширует картинку и при идентичном запросе моментально отдает ее из кэша, не делая запрос к AWS Lambda. Так мы экономим время на генерацию картинки и деньги за работу лямбда-функции.

В итоге любую картинку можно запросить, указав нужные параметры. Например, конвертация в 1900 px и WebP формат выполняется запросом:

<img src=”https://aws-cloud.url/picture.jpg?w=1800&e=webp” />

Параметры генерируются индивидуально в зависимости от того, на каком устройстве происходит просмотр веб-страницы. Учитывается DRP экрана, включенный CSS zoom и его значение, множитель анимации (картинка может увеличиться), возможность браузера отображать WebP формат и ряд других параметров.

Как мы реализовали поддержку WebP

Изначально поддержку WebP мы хотели добавить с помощью тега , который позволяет перечислять разные наборы с указанием srcset и media-типа файлов и бэкапный вариант обычного . Браузер сам определяет, какой из перечисленных вариантов подходит лучше всего, и выбирает его. Это хорошее и элегантное решение, но в нашем случае его оказалось недостаточно.

Дело в том, что в Readymag-проектах некоторые картинки показываются в виде background-слоя, при этом для них также генерируется стандартный тег c набором srcset. Таким образом мы даем возможность поисковым системам распознавать эти картинки, а пользователям — скачивать их через контекстное меню браузера. Конечно, есть аналог srcset для бэкграунда в виде images-set, но Firefox до сих пор нормально не поддерживает его.

В Safari всплыл еще один неприятный момент: этот браузер не очень корректно работает с набором srcset, скачивая за один раз и бэкап-картинку, и ту, которая была выбрана для показа. Так, вместо экономии трафика мы получали рост.

К тому же механики работы одного srcset было мало, нам нужен был индикатор, по которому браузер смог бы выбирать поддерживаемый формат для background-картинки, но в HTML и CSS стандартах его не было.

Поэтому мы решили определять поддержку WebP прямо на стороне браузера. Для этого мы использовали набор предопределенных, зашитых прямо в код картинок минимального размера (около 0,1 Кб) разного формата (lossy, lossless, с прозрачным alpha каналом и т.п.), которые загружаются через new Image.

/** * Загружает картинку по указанному source и проверяет, что картинка загрузилась * imgSrc здесь используется вида * data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA== */ static loadImage(imgSrc: string): Promise<boolean> { return new Promise(resolve => { const img = new Image(); img.onload = () => { resolve(img.width > 0 && img.height > 0); }; img.onerror = () => resolve(false); img.src = imgSrc; }); }

Это добавило одну небольшую проверку при загрузке страницы, зато избавило нас от избыточных загрузок картинок через браузер плюс позволило сократить сам html код: теперь нам не надо было прописывать все возможные варианты картинок в source и srcset-ах. Сами srcset в минимальном виде мы тоже оставили, потому что поисковые системы учитывают качество отдачи изображений при ранжировании проектов.

С подстановкой остальных значений (вычисление размера CSS zoom, обрезки картинки и так далее) проблем тоже не возникает — это и так работало на лету для каждого виджета с изображениями. Мы просто добавили динамическую генерацию url картинки для каждого случая с учетом данных параметров. В итоге html код картинки стал выглядеть как-то так:

<img srcset="https://aws-cloud.url/picture.jpg?w=1800&e=webp 2x, https://aws-cloud.url/picture.jpg?w=2700&e=webp 3x" src="https://aws-cloud.url/picture.jpg?w=1800&e=webp" class="viewable" style="opacity: 1;">

Причем в src атрибуте всегда лежит ссылка на картинку с актуальными в данный момент параметрами, учитывая все зумы и анимации.

Как мы научились сокращать размер картинок без потери качества

Лямбда-функция написана на node.js и использует библиотеку sharp для работы с изображениями. Помимо ресайза и конвертации в WebP формат, лямбда умеет делать кроп и понимает разные параметры сжатия и качества.

Функция очень простая, она парсит запрос из API Gateway, получает картинку из хранилища S3, выполняет нужные преобразования изображения и возвращает результат.

exports.handler = async (event, _context, callback) => { try { const image = await getImage(BUCKET, event); const params = parseQueryParams(event); // если запрошена картинка без параметров, то она отдается сразу без преобразований if (!params) { const response = await responseWithObject(image); return callback(null, response); } // выполняем запрошенные преобразования image.modifiedBuffer = await processImage(image, params); // формируем и отдаем ответ в формате, понятном API Gateway const response = await responseWithObject(image, params); callback(null, response); } catch (e) { logger.error('Error while handling lambda request', e); callback(null, responseWithError(e)); } };

Написать лямбда-функцию конвертации картинок было не самой сложной задачей. Гораздо сложнее и интереснее оказалось подобрать для каждого формата такие параметры сжатия, которые позволили бы значительно сократить размер картинки, не ухудшив ее визуальное качество. Ведь пользователи Readymag очень требовательные.

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

Пример, как сжимается картинка из jpeg в webp
Пример, как сжимается картинка из jpeg в webp

Мы начали эксперименты с jpeg форматом. У него есть параметр “качество” — q — , или, другими словами, степень сжатия изображения. Каждый, кто хоть раз работал с картинками и готовил их к публикации в вебе, знает, что выбирая Save as, можно управлять этим параметром.

Выставление q <= 90 для jpeg формата ведет к существенному снижению размера картинки, но вносит очень заметные артефакты на однородных поверхностях, поэтому значение меньше 90 не подходит для использования. Но не все так просто.

С какими проблемами и ограничениями мы столкнулись

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

Формат субдискретизации сильно влияет на возможность и эффективность сжатия изображения. Но дело в том, что все популярные библиотеки конвертации по умолчанию при качестве менее 90 используют сабсэмплинг 4:2:1, а это вызывает деградацию цветов и артефакты.

Пример, как выглядят артефакты
Пример, как выглядят артефакты

Однако, принудительно установив сабсэмплинг 4:4:4, мы можем использовать меньшее качество для jpg, с меньшим размером файла, но без артефактов сжатия. Например, мы остановились на 87.

Пример той же картинки, но с сабсэмплингом 4:4:4
Пример той же картинки, но с сабсэмплингом 4:4:4

Увеличение исходных картинок. Как быть, если пользователь загрузил картинку размером 800 × 600 px, а условия показа в браузере требуют большего размера? Решили следовать такому алгоритму. Если мы отдаем jpeg формат, то увеличивать размер картинки нет смысла — это визуально мало на что влияет. Поэтому если запрошен размер картинки больше оригинала, то надо просто отдать оригинальный jpeg. С увеличением отлично справится сам браузер, как если бы мы вручную пытались его сделать. Но зачем передавать файл бóльшего размера, если не видно разницы?

А вот WebP картинка гораздо хуже апскейлится браузером, поэтому для режима scale layout и WebP формата надо апскейлить картинку под этот CSS zoom, если оригинал меньше. При апскейле WebP картинки не стоит сильно занижать качество, параметр должен быть не меньше 88, а лучше 90 и выше, иначе получаются очень мыльные границы. При этом размер webp картинки в апскейле с q = 90 все равно значительно выгоднее, чем меньший оригинал в jpg с таким же качеством.

Ограничения AWS Lambda. У Amazon есть ограничение на размер ответа от лямбда функции в API Gateway в 6 Мб. Вероятно, они не думали, что кому-то придет в голову гонять там картинки. Поэтому нам пришлось немного схитрить и ввести дополнительную проверку. Если размер полученной картинки больше 6 Мб (очень редкий случай, но, как мы знаем, если что-то может пойти не так, это обязательно случится), то мы сохраняем картинку в хранилище Amazon S3 и отдаем ответ с HTTP кодом 301 Moved permanently со ссылкой на эту картинку.

Максимально возможный размер файла WebP. Особенности формата WebP не ограничиваются нюансами при увеличении размера картинок браузером. Формат также предусматривает максимально возможный размер файла.

Очень редко кому может потребоваться картинка такого размера, однако, нам снова повезло, и этот случай реально произошел с одним из пользователей. Пришлось добавить дополнительную проверку на максимальный размер WebP изображения.

А еще sharp имеет ряд дополнительных параметров для оптимизации WebP картинки. Например, параметр с многообещающим названием smartSubsample на деле оказался непригодным, искажая цвета и добавляя лишний размер. А вот уменьшение reductionEffort со стандартного 4 до 2 дало более гладкие градиенты.

Выводы

Итак, что же мы получили, пересмотрев свой подход к обработке, хранению и выдаче картинок? Я считаю, очень много.

Мы отвязали показ изображения от заранее физически предопределенного файла. Теперь мы можем только тюнингом параметров конвертера Amazon AWS поменять выдачу картинок во всех проектах сразу. Например, если в будущем появятся более эффективные способы сжатия, мы можем экстраполировать их на все существующие картинки, не заставляя пользователей загружать их заново и переопубликовывать проекты.

Мы отдаем индивидуальный набор картинок для каждого проекта наших пользователей и каждого посетителя, кто этот проект смотрит. Мы подстраиваемся под индивидуальные параметры каждого устройства и браузера, выдавая оптимизированные изображения именно под данную связку.

Мы в разы сократили время между загрузкой картинки и возможностью что-то с с ней делать (например, обрезать или изменить масштаб). Раньше приходилось ждать, пока отработают все конвертации под нужный размер. Теперь этого ожидания нет вообще, работать с изображением можно сразу же с момента его считывания браузером, не дожидаясь даже окончания загрузки.

В итоге сократили размер проектов и уменьшили время их загрузки. Мы протестировали новый формат отдачи картинок на разных крупных проектах с трафиком от 100 Мб за один просмотр, и эффективность оказалась даже выше, чем мы ожидали. В среднем общий размер картинок, посылаемый пользователю в браузер, уменьшился в 2,5 раза. Сыграла роль не только конвертация в WebP, но и точная адаптация картинок под размеры виджетов.

Самое важное — это была очень интересная и познавательная задача для команды разработки :)

1111 показов
1.5K1.5K открытий
Начать дискуссию