Скролл-фильм на лендинге весил 84 МБ. WebCodecs ужал его до 18 — вот что сработало, а что нет
- среда, 26 августа 2026 г. в 00:00:09
У меня лендинг устроен как «фильм, который листаешь»: крутишь колесо — камера едет по сцене. Классическая реализация такого — секвенция кадров: сотни 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% веса бесплатно — на отзывчивость на реальных устройствах не повлияло вообще.
Две ступени адаптации:
По экрану. Вариант потока выбирается как наименьший шириной ≥ innerWidth × min(devicePixelRatio, 2). Важная деталь: dpr режется до 2 — рендерить на canvas в dpr=3 на телефоне бессмысленно, глазом разницы нет, а вес и декодирование ×2. С этой формулой выяснилось, что кадры 1080p, которые я заботливо готовил «для флагманов», были избыточны для всех телефонов до единого.
По скорости. После загрузки первой сцены гоняю пробу: декодирую 20 кадров и меряю миллисекунды на кадр. Больше 7 мс — ступень разрешения вниз, больше 16 — две вниз. Ключевое слово — меряю: до этого пробовал угадывать по числу ядер CPU и памяти, корреляция с реальной скоростью декодирования — около нуля (бюджетник 2024 года декодирует лучше флагмана 2019-го).
Итог на медленном устройстве — 11.9 МБ вместо исходных 84.
Очевидная идея: H.264 — прошлый век, HEVC/AV1 жмут на 30–50% лучше, WebCodecs их умеет.
Реальность: на моём материале x265 выдал файл БОЛЬШЕ при худшем качестве — 9.8 МБ с RMSE 4.91 против 8.0 МБ / 5.22 у x264 на сравнимых настройках.
Причина, если подумать, очевидна: всё преимущество современных кодеков живёт в длинных GOP — сложном межкадровом предсказании на длинной дистанции. А скролл-фильму нужен опорный кадр каждые 30 кадров, иначе прыжки по таймлайну дороги. На коротком GOP HEVC теряет всё своё преимущество, а служебные накладные у него выше. Плюс с ним пришлось бы держать второй набор файлов для браузеров без поддержки.
Вывод: при коротком GOP старый добрый x264 — оптимум. Сэкономил бы день, если бы прикинул это до, а не после экспериментов.
Вторая «очевидная» идея: прогнать кадры через denoise (hqdn3d), кодеку будет легче жать чистую картинку.
Результат: 9.34 МБ против 9.55 — экономия в пределах погрешности, картинка заметно замылена. В корзину.
А вот это уже не «не помогло», а полноценные грабли, которые я хочу впечатать в чужую память.
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 — это лендинг моего сервиса, весь «фильм» на главной работает ровно по описанной схеме.
Выжимка, если лень читать всё:
Скролл-фильм = случайный доступ к кадрам → <video> не годится, секвенции картинок чудовищно тяжёлые. WebCodecs — ровно то, что нужно.
Сырой H.264 Annex-B без контейнера избавляет от демуксера: кадры режутся по стартовым кодам за 30 строк.
GOP=30 — рабочий баланс веса и отзывчивости; чаще — переплата.
HEVC/AV1 на коротком GOP не дают ничего. Не тратьте день.
B-кадры экономят 14%, но требуют индексации по presentation-порядку; счётчик выходов декодера с ними тихо ломается, и валидация файлов этого не видит — только глаза на живом устройстве.
Скорость декодирования — мерить пробой на реальном устройстве, а не угадывать по железу.