21 августа команда Yandex Cloud в формате Technical Preview открыла пользователям облака доступ к сервису для хранения, обработки и трансляции видео Cloud Video. Это инфраструктура для работы с видео, которая включает хранилище для контента и метаданных, видеоплеер, сервисы мониторинга и аналитики, CDN, а также возможности автоматической генерации субтитров и перевода. Сервис разработан на базе видеоплатформы Яндекса, которую компания запустила в 2016 году для использования во внутренних продуктах.

Меня зовут Константин Петряев, я разработчик в Yandex Infrastructure, и в команде видеоплатформы я 6 лет занимаюсь разработкой плеера. Моя коллега Оля Попова уже рассказала об истории его создания с нуля. А в этой статье я подробнее расскажу про задачу повышения качества видео в плеере. Остановлюсь на том, как мы боролись с тем, что стандартные прогнозы качества потокового видео всем нам «врут», — и как научили плеер выдавать наилучшее возможное качество видео для пользователя, с учётом параметров сети и других менее очевидных вводных.

Немного предыстории

После того, как мы написали свой плеер, мы начали тестировать различные сценарии. В том числе, поведение плеера при воспроизведении объёмного звука 5.1. Этот сценарий характерен тем, что звуковая дорожка содержит много данных. В некоторых случаях её битрейт превышает битрейты части видеодорожек.

Мы взяли наш плеер и попробовали воспроизвести в нём видео с таким звуком при ограничении скорости сети в 1,7 Мбит/с. Увидели примерно следующее: очень плохая по качеству картинка, при этом скорости сети явно хватает на большее.

Фактически, у нас ситуация, где видео воспроизводится в минимальном качестве 144 пикселя в высоту. Однако, если взглянуть на сетку битрейтов, мы обнаружим, что сети фактически хватает для воспроизведения аудиодорожки с видео высотой 360 пикселей:

Сегмент

Битрейт

Аудио 

452 Кбит/с 

144p 

412 Кбит/с 

240p 

812 Кбит/с 

360p 

947 Кбит/с 

478p 

1 615 Кбит/с

Кто и как отвечает за выбор качества в плеере

Компонент, ответственный за выбор конкретного качества для воспроизведения, называется Adaptive BitRate Manager, или ABR. Как правило, на вход компоненту передают список дорожек (или их сочетаний), а на выходе получают одну дорожку для видео и одну для аудио. При работе этот компонент почти всегда ориентируется на сетевые параметры. Подробнее с механизмом и принципом его работы можно ознакомиться в докладе Оли Поповой.

Одна из самых важный частей ABR — BandwidthEstimator. Задача этой части плеера — посчитать фактически доступную скорость сети и предоставить данные о ней в ABR. Как правило, он перерабатывает информацию о сети в 1–2 числа. По большому счету, именно с помощью связки BandwidthEstimator и правил по выбору качества внутри ABR плеер считает, какое качество видео и аудио он успеет скачать, чтобы пользователь мог наслаждаться просмотром без буферизаций и замыленной картинки.

Попытаемся понять, как работает BandwidthEstimator. Если мы возьмем девтулзы браузера и посмотрим на сетевые запросы, то увидим при загрузке такое:

У нас есть сегменты — кусочки аудио/видео. Мы знаем размер сегмента и знаем время, за которое мы этот сегмент скачали. Ну, тут всё элементарно! Делим размер на время, получаем скорость. Это же простая математика.

Именно так делает hls.js. Если очень сильно упростить код, работающий в данной библиотеке, то получим примерно следующее:

function onFragLoaded(fragmentData) {
 const {
  loading,
  size /* Bits */
 } = fragmentData;
 const loadingDuration /* Seconds */ = loading.end - loading.start; 

 bwEstimator.sample(loadingDuration, size); 
}

Что тут происходит: берётся размер сегмента, время, за которое его загрузили, и делится одно на другое. Однако скорость — величина переменная. И если смотреть на график скорости загрузки сегментов, мы увидим примерно следующую картину:

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

Так мы органично приходим к мысли, что данные надо как‑то сглаживать. И для этого уже есть решение — здесь практически всегда используется EWMA (Exponentially Weighted Moving Average, в переводе экспоненциально взвешенная скользящая средняя).

\text{EWMA}_t = αp_t + (1 − α)EWMA_{t−1}

где p_t — значение измерения в конкретный момент времени,
α — сглаживающая константа от 0 до 1.

Если мы посчитаем график для наших данных, то получим следующую картину. 

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

Именно этот механизм используют все популярные библиотеки в том или ином виде. Первыми это применили hls.js, уже оттуда механизм распространился на shaka‑player, rx‑player и dash.js (в случае этой библиотеки EWMA не является способом оценки скорости по умолчанию).

Как эта проблема решается в популярных библиотеках

Напомню, что у нас проблема с тем, какое качество качает плеер. Так как плеер писали мы сами, первое, что нужно проверить, — воспроизводится ли данная проблема в популярных решениях. Для этого мы взяли hls.js и shaka‑player, как те решения, с которыми нам доводилось работать.

Как себя ведёт hls.js. Мы собрали небольшой стенд, который:

  • раздаёт сегменты видео локально, с того же компьютера, на котором запущен (исключаем влияние внешней реальной сети);

  • слева сверху отображается текущая скорость сети, которую высчитал эстиматор;

  • справа сверху основного окна браузера отображается текущая высота видеодорожки;

  • а в самих девтулзах мы можем наблюдать конкретное ограничение скорости сети.

Как видно на демо, та скорость, которую намерял плеер, сильно отличается от ограничения, которое мы задали.

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

И если разделить объём на время, то мы получим скорость, примерно равную тому ограничению, которое задано.

7.6 * 8 * 2^{10}/59.72 = 1042.5\ \text {Кбит/с}

А вот эстимейт, который вернул hls.js:

844902/1024 = 825\ Кбит/с

Получается, оценка от плеера занижена на 21%.

Если мы занижаем оценку скорости сети, то и качество мы тоже занижаем. И чтобы развеять сомнения в том, что плеер может играть качество выше, я просто могу зафиксировать качество в 360p и снова включить плеер. Эстимейт, конечно, всё ещё врёт, но оно работает.

А теперь мы вернёмся чуть назад, к тому, что изначально мы тестировали звук 5.1. Может быть, проблема в нём? Убираем аудиодорожку и перепроводим опыт. С помощью этих манипуляций мы, наконец, видим эстимейт, близкий к заданному ограничению.

То есть hls.js ошибается в оценке скорости сети. И чтобы понять, как он ошибается, нам придётся разобраться в том, как оно вообще работает.

Представим, что у нас есть 3 сегмента. Мы скачали их со скоростями 1000, 900, 2000 Кбит/с. После первого сегмента в EWMA будет значение в 1000 Кбит/с, так как других значений ещё не было.

После второго и третьего получаем 990 и 1091 кбит/с. Как видим, значение скорости меняется, при этом не слишком сильно, т.к. сглаживающий коэффициент у нас равняется 0.1.

Попробуем упростить подсчёт. Представим, что все сегменты у нас одинаковые, размером 1500 Кбит, и все качаются за 1 секунду. Конечно же в EWMA в этом случае будет статичное значение в 1500 Кбит/с.

Тут‑то и начинается самое интересное. Эта модель очень хорошо работает для стандарта, например, HLS v3, где сегменты аудио и видео склеены воедино. Однако стандарт переработали и не один раз. Теперь аудио‑ и видеосегменты можно разделять. И тут как в известной байке про линукс («в линуксе вы можете настроить всё, и вы будете настраивать всё»). Вы будете разделять дорожки, просто потому, что это очень удобно.

И вот тут становится грустно, и всё из‑за следующей проблемы. Представим, что аудио в нашем примере весит в 2 раза меньше, чем видео — 500 Кбит. Тогда картина загрузки у нас будет примерно следующая.

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

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

В том числе из‑за того, что у нас фактически изменилась формула и стала двухшаговой.

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

function onFragLoaded(fragmentData) {
 if (ignoreFragment(fragmentData)) return;
 const {
  loading,
  size /* Bits */
 } = fragmentData;
 const loadingDuration /* Seconds */ = loading.end - loading.start; 

 bwEstimator.sample(loadingDuration, size); 
}

Он состоит в том, что часть сегментов не скармливается в эстиматор. Что это за сегменты? Те, которые помечены не как MAIN. В частности, это любые аудиосегменты.

function ignoreFragment(fragmentData) {
 // Only count non-alt-audio frags
 return frag.type !== PlaylistLevelType.MAIN ||
  frag.sn === ‘initSegment’ 
} 

То есть hls.js делает вид, что ничего кроме основного потока видеосегментов не существует. И это нормальное инженерное решение. Почему? Потому что аудиодорожку вам качать всё равно придётся, влиять на её битрейт вы не можете (в отличие от видео). Поэтому если сделать вид, что аудио не существует, а сосредоточиться на видео, можно получить плюс‑минус нормальные данные.

Посмотрим на примере. Скорость сети у нас 1500 Кбит/с. Представим, что скорость сети во время загрузки аудио и видео делится равномерно (750 Кбит/с на каждый из типов контента). Тогда аудио и часть видео мы скачаем за 0,66 секунды. А монопольный кусочек видео за 0,33 секунды.

В сумме у нас 1500 Кбит, загруженных за секунду. Математика сошлась.

А что «видит» hls.js? Если убрать аудиодорожку, то получится следующая картина. Мы скачали 1000 Кбит видео за 1 секунду. То есть скорость на загрузку видео как будто ожидаемая.

Но если мы возьмём другую скорость, сохранится ли картина? Представим, что скорость сети равна 2000 Кбит/с. Тогда параллельный этап продлится 0,5 секунды, а монопольный видео — 0,25.

Опять убираем аудиодорожку. И вот наша скорость на видеосегменте равна 1000/0,75 = 1333 Кбит/с.

А реально мы качать можем 1500 Кбит видео в секунду. Если у нас следующее качество видео укладывается в 1400 Кбит/с, то получается неприглядная картина. Скорости нам хватает с запасом, но плеер этого не видит.

То есть данные о битрейте и процессе загрузки аудио нам всё‑таки нужны. Но как их получить?

В стандарте HLS единственный путь узнать о битрейте аудиосегментов — это тег ext‑x-bitrate. Поддержка этого тега запланирована в hls.js на версию 1.8, но актуальная сейчас версия — 1.5. Релизы минорные при этом выходят примерно раз в полгода. То есть ждать придётся долго.

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

А что насчёт DASH. Посмотрим на DASH, раз я считаю, что он здесь подходит лучше. Для этого возьмём shaka‑player и поставим его на колени в те же условия.

Мы видим, что shaka не понимает, какая сейчас скорость сети. Она считает, что скорость сети — 10 Мбит/с. Всё дело в том, что shaka скармливает данные о загрузке сегментов по частям, сэмплами: браузер сообщил о загрузке 10 Кбит, мы сообщили в эстиматор. Но этот сэмпл игнорируется эстиматором, если его размер меньше 16 Кбайт.

class BandwidthEstimator {
 minBytes = 16e3;
 sample(durationMs, numBytes) {
  if (numBytes < this.minBytes) {
   return;
  }
  const bandwidth = 8000 * numBytes / durationMs;
  this.realSample(bandwidth);
 }
}

На низких скоростях сети у вас нет способа сообщить shaka, что так делать не надо.

Справедливости ради, можно это по‑другому сконфигурировать. Но мы просто увеличим ограничение скорости сети, чтобы использовать дефолтную конфигурацию плеера.

Снова запускаем стенд, всё работает.

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

Почему занижение скорости сети плохо? Представим, что ваша скорость сети — 2 Мбит/с. Она находится внутри сетки битрейтов, и это вполне нормальная скорость для пользователя интернета. Если плеер ошибается в оценке, он занижает качество. А если занижается качество, отношение аудио и видео приближается к единице. Когда отношение приближается к единице, ошибка увеличивается.

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

Где у нас много потоков — в лентах! И если сделать простенькую ленту, где есть предзагрузка для нормальной работы, мы увидим примерно следующую картину.

Как такое посчитать? У нас есть большое количество запросов, они что‑то качают. Обычный эстиматор из shaka с таким справляется не очень хорошо.

Попробуем придумать модель для решения проблемы, что называется, с нуля. Вспомним схему, где видно проблему.

На самом деле, нам хочется, чтобы скорость считалась следующим образом. Мы закончили все загрузки, посчитали время, размер, из них выводим скорость. Дальше грузим следующие сегменты и повторяем цикл. Дёшево, сердито, работает безотказно!

Но в лентах мы можем не дождаться такого момента, когда запросов нет. Однако в браузерах существует замечательное событие progress. С его помощью можно узнать, сколько данных мы скачали за какой‑то промежуток времени.

Добавим эти события на схему, обязательно хаотично, потому что вы не знаете время срабатывания этого события. И если использовать данные «в лоб», как в предыдущей схеме, то мы получим ошибку в данных.

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

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

В этом случае вы практически не изменяете (или совсем не изменяете) существующие компоненты. Они остаются простыми и понятными. Выглядеть на схеме это может примерно вот так.

Здесь белая линия — момент времени, когда мы отдаём в эстиматор знание о том, что скачали объём, равный сумме объёмов из всех событий progress. Поздравляю, мы только что переизобрели дискретизацию — процесс измерения значения сигнала через определённые промежутки времени

Как это работает на практике

Давайте теперь посмотрим ближе к коду, как это может выглядеть. Вот очень упрощённая версия кода shaka player.

class Player {
 constructor() {
  this.networkLayer = new NetworkLayer();
  this.bwEstimator = new BWEstimator();
  this.abr = new ABR(this.bwEstimator); 
  
  this.networkLayer.on(‘Progress’,
   (size /* Bytes */, time /* Seconds */) => 
    this.bwEstimator.sample(size, time));
 } 
}

У плеера есть какой‑то сетевой слой, есть эстиматор и есть ABR. И сетевой слой по событию progress записывает в эстиматор данные: размер и время.

Подробнее, что делает сетевой слой:

class NetworkLayer extends EventEmitter {
 download(url){
  const request = new Request(url);
  request.on(‘Progress’, (size /* Bytes */) => {
   this.emit(‘Progress’, size, getTimeFromPreviousProgress());
  });
 } 
}

По событию progress в запросе он передаёт эти данные почти как есть, только обогащает их меткой времени с момента предыдущего события progress.

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

class ProgressAccumulator extends EventEmitter {
 requestProgress(size) {
  this.accumulatedSize += size;
 } 
 requestStarted() {
  setIntervalIfNotExist(this.handleInterval);
 } 
 requestFinished() {
  clearIntervalIfAllRequestsFinished();
 }
 handleInterval() {
  this.emit(‘Progress’, this.accumulatedSize, timeFromPrevInterval);
  this.accumulatedSize = 0;
 } 
} 

И немного пропатчим плеер, чтобы научить сетевой слой писать данные не в эстиматор, а в ProgressAccumulator. А наш аккумулятор уже будет сам писать данные в эстиматор.

class Player {
 constructor() {
  // . . .
  this.pa = new ProgressAccumulator(); 
  this.networkLayer.on(‘Progress’,
   (size, time) => this.pa.sample(size, time));
  this.pa.on(‘Progress’,
   (size, time) => this.bwEstimator.sample(size, time));
 } 
}

Теперь пришла пора посмотреть, работает ли вся эта машинерия на самом деле. Как видно по замерам на скриншоте, видео действительно начинает воспроизводиться в ощутимо большем качестве, а оценка скорости сети начинает сходиться с ограничением в девтулзах.

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

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

Как мы видим на демонстрации, плеер справа постоянно сваливается в буферизацию. То есть получается, что мы сделали только хуже. Но почему 3 пакета стало хуже? Давайте разберёмся.

Возьмём один сегмент длительностью в 1 секунду и размером в 1000 Кбит, скорость сети мы ограничим в те же 1000 Кбит/с. И представим, что у нас есть задержка в 0,1 секунды (также можно назвать её TTFB — time to first byte). В этом случае сегмент надо загрузить уже за 0,9 секунд. Время меньше, а значит это время нужно учитывать при формировании своего запроса вселенной в сеть.

Самый простой способ — «‎понимать», что сегмент будет загружаться за время, не равное битрейт/скорость сети, а за время, равное битрейт/скорость сети + TTFB. Таким образом, в нашем случае размер сегмента, который мы можем скачать, — это 900 Кбит. Значит можно считать, что эффективный битрейт скорости сети — 900 Кбит/с.

Если посмотреть на код, то потребуются примерно такие дополнения:

class AbrManager {
 chooseVariant() {
  const bw = this.getBandwidthEstimate();
  const ttfb = this.getTtfbEstimate();
  let chosen = variants[0];
  for (const variant of variants) {
   const effBw = bw / variant.duration * (variant.duration - ttfb);
   if ((variant.bandwidth / 0.95) < effBw) {
    chosen = variant;
   }
  }

Но чёрт с ним, с кодом! Надо смотреть, как работает. А работает оно, как будто, так себе: оценка скорости сети нормальная, правильная, но качество плохое. Работы проделали‑то ого‑го сколько, а результата нет.

Ну, не совсем. Немного уменьшаем задержку, повышаем скорость. И вуаля! Справа мы видим уже лучшее качество и отсутствие буферизаций.

Что же здесь можно получить, если смотреть на цифры? Мы применили этот подход у себя и получили то, что 3% пользователей стали смотреть качество 720p+ при том же количестве буферизаций.

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

Давайте пойдём еще дальше. Посмотрите на нашу формулу эффективной скорости сети и найдите тут проблему.

EffectiveBW = RealBW/SD * (SD − TTFB)

Что если наша задержка будет больше, чем длительность сегмента? В этом случае мы имеем отрицательную эффективную скорость сети. Поэтому, вместо того чтобы пытаться скачать сегмент всегда за время меньшее или равное его длительности, мы будем учиться загружать сегменты внахлёст.

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

Почему плохо накладывать запросы за сегментами друг на друга? Ну, как минимум, это несколько усложняет математическую модель. А ещё это может приводить к интересным ситуациям.

Возьмём shaka‑player, который грузит сегменты напрямую в буфер. Представим, что мы начинаем грузить сегменты внахлёст. И вот, у нас есть сегмент, который грузится, начинаем грузить второй, получать его тело, а первый запрос у нас оборвался. Теперь нам нужно что‑то делать со вторым. Возможно, закидывать его в какое‑то хранилище, пока не дозагрузим первый. Возможно, выкидывать. Но это требует дополнительных телодвижений. Самым удобным, на мой взгляд, тут является использование промежуточного хранилища, которое называется «виртуальный буфер».

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

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

Да, таких задержек в реальной жизни вы не увидите практически никогда. Однако это могут быть периодические флапы сети, какие‑то проблемы с CDN и так далее. И уметь переживать такое — сильно более предпочтительный вариант.

Вот что это даёт в плане качества:

За время между первым и вторым экспериментом достаточно сильно поменялась картина смотрения. Качество 144p практически изжило себя. В остальном по качеству кажется, на первый взгляд, что в столбце «до» и «после» изменений нет. Но вот где самые большие результаты:

Количество буферизаций упало, дополнительно снизилась и их длительность.

Вместо послесловия для разработчиков 

  • Знайте как работают базовые слои.

  • Если что‑то ведёт себя не так, как ожидаете — копайте.

  • Если делаете плеер — настоятельно рекомендую ознакомиться с нашим форком shaka player.

А также будем рады видеть всех видеопрофи в нашем тг‑канале «Страдания юного видеоинженера» — с удовольствием ответим на вопросы про плееры и видеоплатформу и здесь, и там.

Комментарии (5)


  1. sunnybear
    21.08.2024 08:27

    Если за 0.9с скачиваем 1000 бит, то скорость не 900 бит/с, а 1100 бит/с


    1. NumminorihSF Автор
      21.08.2024 08:27
      +2

      Таким образом, в нашем случае размер сегмента, который мы можем скачать, — это 900 Кбит. Значит можно считать, что эффективный битрейт скорости сети — 900 Кбит/с.

      В статье имеется ввиду не то, что мы 1000 бит выкачиваем за 0.9, а что при загрузке с фактической скоростью 1000бит/с у нас теперь меньше времени на загрузку. Поэтому и эффективная скорость ниже.


  1. ritorichesky_echpochmak
    21.08.2024 08:27
    +2

    У меня ещё не скоро перестанет полыхать с опыта просмотра видео выложенного в Yandex Диск, где само видео нормально не догружалось, сохранение позиции просмотра отсутствовало как класс и собственно плеер просто "фантастический" даже в сравнении с браузерным. Я почти попросил человека купить DVD-ROM и записать мне всё на диск, но удалось вытащить файлы через yt-dlp, чтобы смотреть нормально. Просто худшее место для хранения видео.
    При этом, полностью забив на UX, яндекс агрессивно стрижёт за яндекс плюс, в котором востребованного контента всё меньше и меньше, сервисные сборы всех мастей и не прекращает агрессивно бомбить баннерами и спамом везде где дотянутся.


    1. Lizdroz
      21.08.2024 08:27

      Ну хз-хз, в плюсе вполне себе контента, учитывая, что и фильмы с сериалами там постоянно новые появляются, и музыка


      1. ritorichesky_echpochmak
        21.08.2024 08:27

        Новые появляются, но востребованные, и те что я хотел смотреть, слушать - выкосили и не завозят.