
В последние годы короткие видеоформаты повлияли на ожидания многих пользователей от видеосервисов. Всё чаще зритель рассчитывает, что ролик начнёт воспроизводиться почти мгновенно, а переключение между видео будет происходить без задержек. Однако на стороне разработчика видеоплатформы за таким сценарием стоит немало технических нюансов: если не оптимизировать плеер, бэкенд и процессы кодирования, пользовательский опыт быстро начинает страдать из‑за зависаний и долгого старта воспроизведения.
Привет, меня зовут Рамиль Габдрахманов, я руковожу разработкой видеоплееров в Yandex Infrastructure. Нашу видеоплатформу используют многие сервисы Яндекса: Кинопоиск, Яндекс Маркет, Яндекс Музыка и другие — а компании вне Яндекса могут использовать её через Yandex Cloud Video. В день наш плеер воспроизводит 103 847 867 931 секунду видео.
Сегодня расскажу о том, как устроены ленты коротких видео у нас, что под капотом и какие оптимизации мы применяем.
Навигация для тех, кто хочет сразу перейти к конкретным оптимизациям:
Устройство видеоленты: раньше и сейчас
Я работаю в сфере видео больше шести лет и ещё помню времена, когда мы разрабатывали Player SDK преимущественно под другой пользовательский сценарий:
Пользователь выбирает фильм в галерее.
Открывается экран с плеером.
Запускается воспроизведение.
Пользователь решает, что он насмотрелся, и возвращается обратно в галерею.
Ресурсы освобождаются.
По сути, раньше здесь и не было видеоленты, какой мы её знаем сейчас.
С распространением короткого формата видео мы получили другой пользовательский UX, c постоянным листанием коротких клипов. Для оптимизации не так важно, как именно пользователь управляет переходом от видео к видео. Главное — что эти переходы происходят быстро.
Вот как это выглядит с точки зрения жизненного цикла видео:

Видеоролик попадает на видеоплатформу, где есть выделенное хранилище медиа, транскодирование, система управления и желательно CDN для доставки контента.
Результатом обработки ролика в видеоплатформе будет метаинформация. Она содержит ссылку на видео в нужном формате, его название, обложку, возможно, информацию о правообладателе и так далее.
Метаинформация поступает в плеер.
Плеер интегрируется в приложение
Видео запускается и работает.
Саму ленту коротких видео в этом случае можно схематично представить как набор карточек с метаинформацией, к которой обращается плеер (в вертикальном формате мы листаем её вверх, но для удобства просмотра здесь я её транспонировал):

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

Нужна информация о списке элементов, которые пользователь видит на экране непосредственно сейчас и ожидает от них воспроизведения.
Необходима некоторая эпсилон‑окрестность элементов — не вся лента, но какая‑то её часть, которая готовится к воспроизведению.
Также важно хранить данные о видео, которые пользователь просмотрел вот‑вот недавно.
Это необходимый и достаточный объём информации, который нам нужно использовать в ListPlayerSDK. Видеоплеер в этом случае выступает как некий арбитр, дирижёр: он управляет ресурсами, отвечает за подготовку следующих видео, помогает переиспользовать мощности, оценивая пропускную способности сети и, как результат, — выбирая качество видео, с которым будет воспроизводиться лента. И здесь же можно реализовать визуальные хаки, за счёт которых мы видим, что «всё летает».
Какие метрики покажут, что мы на верном пути
Чтобы пользователь был доволен, нам важна скорость запуска воспроизведения, которая также будет конвертироваться в рост доли запущенных видео. Также необходимо, чтобы при этом не проседала стоимость реализации и качество, не случалось ошибок при запуске. А как инфраструктурной команде нам важно уметь переиспользовать все оптимизации и улучшения в разных приложениях.

В этом «треугольнике невозможностей» мы в большей степени концентрируемся на скорости, потому что она важна пользователям. При этом мы также не готовы жертвовать качеством и стоимостью для пользователя. Так что будем искать оптимизации, которые НЕ являются компромиссными.
Для видеоплатформы всё это выражается в таких метриках, которые важно отслеживать:
Время запуска воспроизведения.
Доля запущенных видео.
Количество и длительность прерываний (столдов, говоря нашим сленгом).
Суммарное время смотрения (как общая прокси‑метрика реакции аудитории).
Теперь посмотрим на оптимизацию по этапам: первое, о чём хочется поговорить, это будущее. Как говорил Экзюпери: «Ваша задача — не предвидеть будущее, а сделать его возможным».
Подготовка следующих видео
Когда инженеру говорят, что в будущем нужно что‑то сделать быстро, ему приходит мысль, что к этому стоит готовиться заранее. Вот и мы готовимся и делаем это за счёт лежащей на поверхности идеи: используем два плеера.
Первый воспроизводит видео, которое пользователь видит прямо сейчас.
Второй готовится к запуску следующих клипов, чтобы было достаточно всего лишь снять плеер с паузы, и видео бы начало воспроизведение.
Получается компромисс между объёмом используемых ресурсов и качеством работы.
Кроме этого, на Android мы скачиваем байты видео непосредственно на диск, без захвата декодера, без плеера. Схематично выглядит так:

Предзагрузчик берёт данные сети, скачивает и кладёт в кеш. Когда приходит время воспроизводить видео, врывается плеер и использует данные из кеша и сети — это позволяет ускорить запуск воспроизведения.
Мы используем фреймворк Media3, предзагрузку в котором можно разделить на два этапа: подготовка и загрузка.
// ПОДГОТОВКА val helper = DownloadHelper.forMediaItem(MediaItem, ...) helper.prepare(object: DownloadHelper.Callback { override fun onPrepared(helper: DownloadHelper) { TODO("Подготовка завершена. Получаем треки") } }) // ЗАГРУЗКА val dashDownloader = DashDownloader.Factory(..).build() dashDownloader.download(progressListener)
Подробнее можно посмотреть в документации Media3, она довольно хорошая и обширная.
На iOS и в вебе всё устроено иначе. Здесь мы предзагружаем во многом за счёт создания большего числа плееров наперёд.

Но для обеих платформ остаётся актуальным вопрос: N это сколько, как много клипов нам надо предзагружать? Мы насмотрелись китайских тиктоков и выбрали число четыре в качестве Baseline для наших экспериментов. Что получилось:

При уменьшении количества до двух падает время смотрения, растёт время запуска и уменьшается доля запущенных видео.
Если предзагружать больше четырёх, получаем ещё и рост стоимости, так как мы увеличили объём трафика, необходимого на секунду смотрения.
Безусловно, другим командам мы рекомендуем проводить своё A/B‑тестирование, поскольку в зависимости от инфраструктуры вокруг продукта эти данные могут отличаться.
Пару слов про кеширование. Когда мы начинаем что‑то предзагружать, используя кеш, где‑то рядом поджидает Cache Miss. Когда наши видео существуют в разном качестве, мы, конечно же, будем предзагружать какое‑то одно.
Схематично это устроено так:

Видео с несколькими качествами поступает в предзагрузчик, он выбирает какое‑то качество с помощью ABR, скачивает и кладёт на диск.
Через время плеер проделывает аналогичную работу: видео поступает с несколькими качествами, выбор также происходит за счёт ABR, но независимо от предыдущего.
Если выбор в обоих случаях совпал, то мы попали в кеш и возьмём данные из него. Иначе нам придётся сходить в сеть — и вот он, Cache Miss.
Вся проблема в том, что два выбиратора никак между собой не связаны и ничего друг про друга не знают. Именно это мы и модифицировали. Когда видео с несколькими качествами поступает в модуль, добавляется условие.

Чтобы это реализовать в Media3, нужно модифицировать две компоненты:
getNextChunk — метод в СhunkSource, который вызывается на скачивание каждого куска видео;
ExoTrackSelection — тот самый выбиратор.
В итоге получится такая схема работы:

По ссылке вы найдёте форк Media3 c моим дополнительным коммитом, где это реализовано.
Итак, что важно учесть при подготовке следующих видео в ленте:
Готовить пару плееров заранее.
Предзагружать несколько видео.
Следить за Cache Miss.
А мы переместимся на бекэнд.
Изменение кодирования
Поговорим про кодеки нового поколения. С каждой новинкой нам обещает кодеки лучше предыдущих: они требуют меньшее количество байт, чтобы показывать видео в том же качестве.
Это видно вот на таких бенчмарках: по оси Х видим количество бит данных, необходимых для кодирования одной секунды видео, а по оси Y — пользовательская оценка качества видео.

Но нам на платформах, особенно мобильных, важна именно аппаратная поддержка этих алгоритмов кодирования и декодирования. Так мы потребляем меньше ресурсов, CPU, заряда батареи. Что у нас есть с этой точки зрения?

Стандартный AVC поддержан в 100% сессий уже давно. Даже в те времена, когда Xzibit встраивал в разные места свои экраны, AVC уже поддерживался на них. С HEVC мы также наблюдаем высокую долю покрытия сессии, AV1 показывает тенденцию на рост.
Так как HEVC крайне хорошо распространён, мы добавили его в DASH‑плейлисты. Но оказались не у дел: ошибки воспроизведения выросли на 28%, суммарное время смотрения упало на 5%.
Дьявол оказался в деталях. AVC настолько стал стандартом, работающим на любом тапке, что на одном девайсе хардварных декодеров для него было, как правило от 4 до 16 экземпляров. У HEVC покрытие близко к 100%, но конкретных экземпляров кодеков значительно меньше, всего один‑два. В этом и была проблема.
Решением стала деградация до старого‑доброго AVC в случае, если не удаётся захватить HEVC‑декодер. В Media3 это делается следующим образом:
val player = ExoPlayer.Builder(context).build() player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() .setPreferredVideoMimeType(MimeTypes.VIDEO_H264) .build()
В результате, как и ожидалось, мы снизили на 15% трафик на секунду смотрения и подрастили долю смотрения в высоком качестве на 7%.
Фокус только на том, что нужно сейчас
Вернёмся к фронтенду. В придуманной нами схеме у нас есть сразу несколько плееров и предзагрузчиков, все они используют сеть.

Так мы получаем серьёзную конкуренцию за ресурсы. Есть риск, что у пользователя уже начнутся проблемы с воспроизведением, в то время как мы продолжаем тратить сетевые ресурсы на предзагрузку. Конечно же, это не дело, важнее должно быть то, что на экране пользователя прямо сейчас.
Приоритет проигрывания перед предзагрузкой. В нашей архитектуре приоритизация схематично выглядит следующим образом. У нас есть плеер и есть предзагрузчик, которые в общем‑то не имеют общей связи, но одновременно используют сеть. На их пути мы добавляем новую сущность, которая знает всё про плееры, — Priority Task Manager.

Priority Task Manager заранее увидит, что буфер в плеере вот‑вот опустошится, а значит пора подавать предзагрузчикам сигнал о том, что хватит, ничего предзагружать не надо.
Для того, чтобы такое сделать в Media3, можно воспользоваться одноимённым классом PriorityTaskManager. Из важного здесь рассмотрим пару его методов:
fun add(priority: Int) — добавить задачу с приоритетом;
fun remove(priority: Int) — убрать задачу с приоритетом;fun proceed(priority: Int) — блокировка потока, пока не будет достигнут целевой приоритет.
Внутри предзагрузка построена на SegmentDownloader. Когда ему поступает задача предзагрузить видео, он добавляет задачу с приоритетом в PriorityTaskManager. А дальше переходит к загрузке кусочков видео. Это происходит в цикле.

На первом шаге цикла вызывается метод proceed: если в PriorityTaskManager есть возможность выполнить задачу такого приоритета, она будет выполнена сразу же, и мы перейдём к скачиванию одного сегмента. Иначе поток вызова заблокируется, и предзагрузка прервётся.
В плеере будем смотреть на изменение глубины буфера:

Если окажется, что буфер меньше критического значения, то пора добавлять задачу с более высоким приоритетом и тем самым прерывать предзагрузку.
И раз уж мы заговорили про буфер. Исходя из принципа «делать то, что важно сейчас», нам важно иметь короткий буфер вперёд.
Динамическая буферизация. Какие факты здесь важно учесть: стандартные плееры на платформах из коробки имеют целевой размер буфера в 30–50 секунд.

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

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

Вне зависимости от глубины просмотра, стандартный плеер всегда будет стремиться достичь конкретный зафиксированный размер буфера вперёд. Нам же хотелось, чтобы размер буфера зависел от глубины просмотра.
В чём была наша идея: вначале, пока пользователь ещё не начал глубоко смотреть, нам лучше иметь небольшой буфер вперёд. А если мы обнаружим, что пользователь всё‑таки видео заинтересовался и начал смотреть глубже, то уже тогда позволять скачать буфер вперёд побольше. Для нас это страховка, что воспроизведение произойдёт гладко и без прерываний. По сравнению со стандартным вариантом выглядит примерно так:

Реализовать такой динамический буфер можно несколькими способами:
В стриминговом формате: легко реализуется, нужна лишь простая модификация плеера;
MP4 c moov‑атомами впереди: позволяет скачивать range‑запросами, однако это требует значительных изменений в плеере;
MP4 c moov‑атомами в конце: ещё более хитрое скачивание range‑запросами, которое требует ещё более значительных модификаций.
Так что остановимся подробнее на стриминговом протоколе. В этом случае исходное видео делится на сегменты и дальше кодируется в разные качества.

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

Таким образом, то количество сегментов, которое мы скачиваем вперёд, будет увеличиваться в зависимости от глубины просмотра.
Для внесения изменений в Media3 нужно изменить реализацию интерфейса LoadControl:
inteface LoadControl fun shouldContinueLoading(bufferedDuration: Long): Boolean return bufferedDuration < 50_000 }
Тут важно обратить внимание на метод shouldContinueLoading. Он вызывается на каждый такт работы плеера внутри. В параметры этого метода передаётся актуальная длина буфера, сколько плеер уже успел накачать, а в ответ мы возвращаем в виде Boolean. Соответственно, если ответ положительный, то плееру нужно продолжать накачивать буфер вперёд.
Если упрощать, стандартная реализация выглядит следующим образом: пока у нас целевой размер меньше 50 секунд, нужно продолжать качать. Мы же это меняем. Вначале мы высчитаем, а сколько пользователь конкретное видео просмотрел в глубину. Когда у нас есть эта информация, мы уже начнём вычислять целевую глубину нашего буфера.
fun shouldContinueLoading(bufferedDuration: Long): Boolean { val currentWatchedTime = … val targetBufferedDuration = when { currentWatchedTime < WATCHED_TIME_LIMIT_ONE -> 2_000 currentWatchedTime < WATCHED_TIME_LIMIT_TWO -> 10_000 else -> 50_000 } return bufferedDuration < targetBufferedDuration }
Пока пользователь просмотрел мало, таргет будет небольшой (конкретно здесь я привёл две секунды). Он начинает смотреть, глубина просмотра увеличивается, и мы этот таргет увеличим до 10, до 50 и так далее. В зависимости от продукта, количество таких границ может быть больше или меньше.
Видео — это ещё и звук. И заключительная идея, как делать только то, что важно сейчас, — это не скачивать аудио. Вроде бы оно и занимает немного, но в процентном соотношении это примерно одна десятая от видео в низком качестве. У пользователя же сам девайс или плеер может быть замьючен.
По нашей статистике, 80% смотрения происходит именно в замьюченном режиме. А учитывая, что сетевой ресурс для нас очень ценный, чем меньше нам нужно скачивать, тем быстрее будет воспроизводиться видео. Стандартные плееры по умолчанию скачивают и видео, и аудио.
Как это реализовать? В стриминговых форматах аудио может существовать отдельной дорожкой (в mp4 так не получится, увы). В Media3 и AvPlayer есть API для выключения скачивания аудио. Как раз им мы и воспользовались.
В Media3 выключение аудио происходит через TrackSelection‑параметры: можно указать явно, что стоит выключить звуковую дорожку.
val player = ExoPlayer.Builder(context).build() player.trackSelectionParameters=player.trackSelectionParameters.buildUpon() .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, /* disabled= */ true) .build()
Такая небольшая доработка даёт колоссальные результаты: мы обнаружили значительное уменьшение времени запуска (-20% в p50), уменьшение трафика на 5%, а пользователи откликнулись увеличением смотрения на 15%.
Обратная сторона доработки — у нас перераспределились прерывания (stalleds). Количество прерываний увеличилось, они происходят в те моменты, когда пользователи размьючивают плеер/девайс, а аудио ещё не скачалось. Но эти зависания такие короткие, что в целом они даже уменьшили среднюю длительность зависаний.
Подводя промежуточный итог:
У видео на экране приоритет выше, чем у видео, которые мы покажем через N свайпов.
В лентах коротких видео мало глубокого просмотра. Нет смысла держать длинный буфер вперёд.
Люди много смотрят без звука. Если статистика это подтверждает, будем скачивать аудио только тогда, когда оно действительно необходимо.
Выбор качества в конкурентной среде
Как мы помним, видео у нас есть в нескольких качествах. Правильный выбор окажет непосредственное влияние на то, как будет работать лента. Для этого у нас есть несколько эвристик:
Выбирать качество не больше размера экрана.
Воспроизводить без прерываний.
Необходимость уместиться в пропускную способность сети.
Сконцентрируемся на оценке пропускной способности сети. Когда у нас только один плеер, эта оценка легко вычисляется: замеряем количество байт, что скачал плеер, замеряем время и делим объём данных на длительность запроса. Вуаля! Пропускная способность сети нам известна.
В конкурентной же среде несколько плееров и предзагрузчиков используют канал сети одновременно. Нам нужно посчитать ширину канала целиком. Можно это визуализировать в виде такой трубы:

В конкурентной среде измерение каждого плеера и предзагрузчика по одному не даёт ответа на вопрос, а какая же у нас общая пропускная способность сети. Оказывается, что это дело крайне непростое и удостоено отдельного рассказа от Кости Петряева.
Поделюсь, какие результаты мы получили, когда реализовали этот подход к выбору качества:
+10% просмотров в высоком качестве;
−8% — длина прерываний;
+2% времени смотрения.
Здесь самое необычное: мы одновременно увеличили качество видео и уменьшили длину прерываний. Обычно эти метрики оппонируют друг к другу, но мы обнаружили те самые неоптимальные выборы. То есть, мы нашли и починили такие моменты, когда ошибочно занижали оценку пропускной способности сети и, как следствие, выбирали низкое качество видео.
Визуальное ускорение запуска
Но не оптимизацией единой, важно обращать внимание и на восприятие контента пользователем. И здесь возможна так называемая «проблема чёрных экранов».

Бывают ситуации, когда мы не успели полностью подготовиться к воспроизведению видео: всякое случается, сеть динамичная, плеер не успел что‑то скачать и так далее. В этот момент пользователь вместо контента наблюдает чёрное ничто, и зрители это явно замечают, считая запуск медленным.
Самый простой визуальный хак на этот случай: вместо показа видео начать показывать картинки первого кадра. Мы довольно сильно их пожали, примерно до 300 килобит, и если для отображения видео нам нужен был один мегабит данных, то для картинки нужно в три раза меньше.

Но и на этом можно не останавливаться, а показывать вместо картинки её блюр, который ещё легче, чуть больше, чем 200 бит. Чтобы получить такое сжатие, кодировали через библиотеку ThumbHash.

Информацию о блюре можно добавить к метаданным о карточке с видео, и он приходит сразу вместе с ними. Потом мы подгружаем картинку, после этого плавно меняем эту картинку на видео.
Итого, наша метаинформация о каждой карточке в настоящий момент выглядит следующим образом:
{ "first_frame_hash": "UQgGDAIxepZIepAnmYcKk7hP6Q==", "first_frame_url": "https://.../ff.webp", "stream": "https://.../manifest.mpd" }
У нас есть закодированный блюр картинки первого кадра, сама картинка первого кадра в WebP или JPEG, ну и наш видеострим.
Итого
Пробежимся по всем нашим оптимизациям и выводам, которые помогают сделать ленту коротких видео лучше:
Подготовка следующих видео. Мы используем два плеера, чтобы для запуска следующего видео было достаточно снять плеер с паузы. Наперёд мы предзагружаем четыре видео.
Изменение кодирования. Даже в таких очевидных штуках, как выбор кодека с высоким покрытием, стоит тестировать варианты и быть готовым к неожиданностям.
Оптимизация действий. Плееры, которые видны на экране, всегда важнее тех, что будут через n свайпов. Короткий буфер вперёд — классная оптимизация, которая легко организуется и даёт значимые результаты. И важно не забывать, что пользователь не всегда пользуется аудио, и можно улучшить работу если его не скачивать.
Сквозной замер пропускной способности. Выбор качества сильно зависит от того, правильно ли мы замеряем пропускную способность сети, и наладить это в ленте не так‑то просто.
Визуальное ускорение запуска. Пользователи могут воспринимать старт видео не так, как мы, и здесь можно хитрить. Например, показывать блюр, потом картинку и потом уже видео, если что‑то не успеваем предзагрузить.
Спасибо за внимание, буду рад ответить на вопросы. Также присоединяйтесь к нашему сообществу видеоинженеров и заглядывайте в Yandex Infrastructure, где мы рассказываем, как делаем внутреннюю инфраструктуру Яндекса.