javascript

Скролл-фильм на лендинге весил 84 МБ. WebCodecs ужал его до 18 — вот что сработало, а что нет

  • среда, 26 августа 2026 г. в 00:00:09
https://habr.com/ru/articles/1074188/

У меня лендинг устроен как «фильм, который листаешь»: крутишь колесо — камера едет по сцене. Классическая реализация такого — секвенция кадров: сотни WebP, по картинке на каждую позицию скролла. Работает, но на мобильной версии это выливалось в 84 МБ трафика. На десктопе — 128. Для лендинга, который человек открывает с рекламы на телефоне где-нибудь в лифте, — приговор.

Расскажу, как я заменил секвенции кадров на видеопоток, который декодируется прямо в браузере через WebCodecs, почему выбросил контейнер mp4 целиком, и про три тупика, в которые успел сходить: два из них выглядят как очевидные улучшения, а на деле не дают ничего, третий вообще сломал сайт на живом iPhone так, что ни один автотест этого не увидел.

Почему не

Первый вопрос, который задают все: «а чего не обычный видеотег с привязкой currentTime к скроллу?»

Пробовали — и это известная боль. Скролл-фильму нужен произвольный доступ к конкретному кадру: пользователь дёргает страницу вверх-вниз, и на каждую позицию нужен свой кадр, мгновенно и точно. video.currentTime для этого не предназначен: seek асинхронный, точность зависит от расстановки опорных кадров, на мобильных Safari всё это ещё и подтормаживает и мерцает. Получается кисель вместо фильма.

Поэтому все и держат секвенции картинок: JPEG/WebP на каждый кадр, рисуем в canvas. Плавно, точно — и чудовищно тяжело, потому что каждый кадр сжат независимо, а межкадровую избыточность (в соседних кадрах 95% пикселей совпадают) никто не использует.

WebCodecs — ровно недостающее звено: это доступ к аппаратному видеодекодеру браузера из JS, без видеотега. Скармливаешь сжатые кадры — получаешь VideoFrame, который рисуется в canvas. Межкадровое сжатие работает, покадровый контроль остаётся у тебя.

Поток без контейнера — и зачем так

Обычно H.264 живёт в контейнере: mp4, mkv. Чтобы достать из mp4 отдельные сжатые кадры для WebCodecs, нужен демуксер — почти все туториалы тянут mp4box.js.

Я контейнер выбросил целиком. На сцену — один файл: сырой H.264 Annex-B, то, что ffmpeg отдаёт при -f h264. В этом формате кадры разделены стартовыми кодами 00 00 01 — и страница просто ищет их в ArrayBuffer и режет поток на кадры сама. Это ~30 строк кода вместо целой библиотеки-демуксера.

Что теряем без контейнера: метаданные (длительность, fps, размеры). Но у скролл-фильма всё это и так известно на этапе сборки — я кладу рядом index.json с манифестом: сколько кадров в сцене, какие варианты разрешений есть.

Кодирование (ffmpeg):

libx264 -crf 23 -preset veryslow -g 30 -bf 0 \
  -profile:v high -x264-params scenecut=0:open-gop=0 \
  -pix_fmt yuv420p -f h264

Два параметра здесь — результат боли, о них ниже: -g 30 и особенно -bf 0.

Плеер: аппаратных декодеров мало, и они не резиновые

Наивный подход «создаём VideoDecoder на сцену и держим» умирает на телефонах: аппаратных декодеров в системе единицы, и если твоя страница займёт три, браузер начнёт молча убивать их или падать в софтверное декодирование.

Что в итоге работает:

  • Живых декодеров одновременно — не больше двух. Декодер создаётся лениво, когда сцена реально приближается к вьюпорту, и убивается, когда ушла.

  • Скользящее окно кадров: держим декодированными 8 кадров вперёд и 6 назад от текущей позиции скролла. Пользователь резко прыгнул дальше — decoder.reset() и перезаход с ближайшего опорного кадра.

  • Тут и вылезает смысл -g 30: опорный кадр каждый 30-й — это максимум 29 кадров декодирования при прыжке в произвольное место. У меня сначала стоял -g 10 («чаще опорные = отзывчивее»), переход на 30 дал минус 13% веса бесплатно — на отзывчивость на реальных устройствах не повлияло вообще.

Подгонка под устройство: не гадать, а мерить

Две ступени адаптации:

  1. По экрану. Вариант потока выбирается как наименьший шириной ≥ innerWidth × min(devicePixelRatio, 2). Важная деталь: dpr режется до 2 — рендерить на canvas в dpr=3 на телефоне бессмысленно, глазом разницы нет, а вес и декодирование ×2. С этой формулой выяснилось, что кадры 1080p, которые я заботливо готовил «для флагманов», были избыточны для всех телефонов до единого.

  2. По скорости. После загрузки первой сцены гоняю пробу: декодирую 20 кадров и меряю миллисекунды на кадр. Больше 7 мс — ступень разрешения вниз, больше 16 — две вниз. Ключевое слово — меряю: до этого пробовал угадывать по числу ядер CPU и памяти, корреляция с реальной скоростью декодирования — около нуля (бюджетник 2024 года декодирует лучше флагмана 2019-го).

Итог на медленном устройстве — 11.9 МБ вместо исходных 84.

Тупик №1: «возьми современный кодек» не работает

Очевидная идея: H.264 — прошлый век, HEVC/AV1 жмут на 30–50% лучше, WebCodecs их умеет.

Реальность: на моём материале x265 выдал файл БОЛЬШЕ при худшем качестве — 9.8 МБ с RMSE 4.91 против 8.0 МБ / 5.22 у x264 на сравнимых настройках.

Причина, если подумать, очевидна: всё преимущество современных кодеков живёт в длинных GOP — сложном межкадровом предсказании на длинной дистанции. А скролл-фильму нужен опорный кадр каждые 30 кадров, иначе прыжки по таймлайну дороги. На коротком GOP HEVC теряет всё своё преимущество, а служебные накладные у него выше. Плюс с ним пришлось бы держать второй набор файлов для браузеров без поддержки.

Вывод: при коротком GOP старый добрый x264 — оптимум. Сэкономил бы день, если бы прикинул это до, а не после экспериментов.

Тупик №2: шумодав перед сжатием

Вторая «очевидная» идея: прогнать кадры через denoise (hqdn3d), кодеку будет легче жать чистую картинку.

Результат: 9.34 МБ против 9.55 — экономия в пределах погрешности, картинка заметно замылена. В корзину.

Тупик №3: B-кадры. Минус 14% веса и сломанный сайт

А вот это уже не «не помогло», а полноценные грабли, которые я хочу впечатать в чужую память.

B-кадры (-bf 3) — двунаправленное предсказание, честные минус 14% веса. Включил, проверил валидатором (число кадров сходится, опорные на местах, декодируется без ошибок) — выкатил.

На живом iPhone фильм начал «жёстко дёргаться».

Причина: мой плеер считал номер кадра счётчиком выходов декодера — первый выданный VideoFrame = кадр 0, второй = кадр 1. Без B-кадров порядок декодирования совпадает с порядком показа, и это работает. С B-кадрами кодек передаёт кадры в одном порядке, а показывать их надо в другом — и реальный браузерный декодер начал выдавать кадры не в том порядке, который ожидал мой счётчик. Кадры «перемешались» через один — визуально это дёргание.

Самое противное: никакая валидация файлов это не ловит. Файл корректный. Декодер работает. Число кадров сходится. Ломается только соответствие «номер кадра ↔ позиция скролла», и увидеть это можно только глазами на устройстве.

Правильное лечение — индексировать кадры по presentation timestamp, а не счётчиком выходов. Я же выбрал -bf 0 и +14% веса: в моём бюджете это дешевле, чем усложнение плеера, которое опять же можно проверить только руками на зоопарке устройств.

Фолбэк

WebCodecs есть в Chrome/Edge/новых Safari, но не везде. Проверка при старте + перехват ошибок потока: любой сбой — молча грузится старый вариант со секвенцией WebP пониженного разрешения (810px, quality 72), 58 МБ. Дорого, но работает везде, и это уже фолбэк для меньшинства, а не основной путь.

Итог в цифрах

кадрами (WebP)

видеопотоком

мобильная версия

84 МБ

18.1 МБ

мобильная, медленное устройство

84 МБ

11.9 МБ

десктоп

128 МБ

32 МБ

фолбэк без WebCodecs

89 МБ

58 МБ

Живьём посмотреть можно на avitoma.ru — это лендинг моего сервиса, весь «фильм» на главной работает ровно по описанной схеме.

Выжимка, если лень читать всё:

  1. Скролл-фильм = случайный доступ к кадрам → <video> не годится, секвенции картинок чудовищно тяжёлые. WebCodecs — ровно то, что нужно.

  2. Сырой H.264 Annex-B без контейнера избавляет от демуксера: кадры режутся по стартовым кодам за 30 строк.

  3. GOP=30 — рабочий баланс веса и отзывчивости; чаще — переплата.

  4. HEVC/AV1 на коротком GOP не дают ничего. Не тратьте день.

  5. B-кадры экономят 14%, но требуют индексации по presentation-порядку; счётчик выходов декодера с ними тихо ломается, и валидация файлов этого не видит — только глаза на живом устройстве.

  6. Скорость декодирования — мерить пробой на реальном устройстве, а не угадывать по железу.