Site icon AppTractor

До того, как это стало стримом — рассказываем о настройке потокового вещания

Пользователь запускает приложение, нажимает кнопку — и вжух, уже стримит в сеть, а к его стриму подключаются другие пользователи. В плеере в этот момент происходит огромная работа: скрипты забирают изображение и звук, кодируют, пакуют в контейнеры, и передают данные в  местный разгрузочный порт, где контейнер снова разбивают на упакованное в него видео и аудио. Сколько усилий! Как эти усилия выглядят на самом деле и о способах настройки потокового вещания на примере работы с протоколом SRT рассказывает iOS-разработчик CleverPumpkin Никита Тархов.

Как работает информационный канал

Каждое видео в интернете проходит один и тот же путь от источника к принимающей стороне. Предположим: есть камера, которая условно 30 раз в секунду выдает нам raw-изображения, и такой же источник аудио, который выдает звуковые raw-данные. Для более эффективной их передачи используются кодеки, которые сжимают изображения, уменьшают вес и трафик в разы. Чтобы совместить переданные данные, используется мультиплексор, который наши аудио- и видеоданные и информацию о стримах собирает и пакует в некий контейнер, который на сетевом транспорте доставляется в интернет.

Все это происходит в момент после нажатия пользователем кнопки «начать стрим».

Стоит обратить ваше внимание на параметры частоты кадров и битрейта для видеокодека, которые непосредственно влияют на качество и работу в сети: чем выше битрейт, тем, соответственно, выше требование к сети. В аудио-кодеке используется такой параметр как частота дискретизации или Sample Rate и битрейт. Чем выше частота дискретизации, тем лучше качество звука.

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

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

Это то, что происходит после того, как зритель подключается к стриму.

Протокол SRT

Поговорим о происходящем с видео и аудио в стримах на примере SRT — транспортного  протокола для медиа контейнеров, который разрабатывался специально для работы в плохих условиях сети. Этот протокол мы использовали при работе над одним из самых свежих проектов CleverPumpkin — Dater.

Разберемся с протоколом побуквенно.

Протокол является транспортным и базируется на UDP-сокетах. Программный интерфейс — тоже сокеты, но уже SRT-сокеты. Сам протокол реализован на C++, но разработчики продумали и вынесли все в С интерфейс, тем самым дав возможность использовать SRT на любом тапочке с выходом в интернет. Также за счет С интерфейса библиотеку можно использовать в Swift, но это несет понятные сложности.

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

В полезной нагрузке могут быть абсолютно любые данные, но чаще всего это MPEG-TS – протокол для передачи аудио и видео в формате MPEG2.

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

Данные о вещании, аудио и видеопотоки передаются в мультиплексоры, которые образуют PES, PSI и NULL пакеты. Последний нужен для контроля битрейта. Например, чтобы поддержать требуемые цифровым телевидением стандарты, MPEG-TS умеет вставлять в поток пустые пакеты. PID (Program ID) такого пакета строго определен и равен 0x1FFF.

PES — это Packet Elementary Stream. Это набор Elementary Stream, каждый из которых может содержать информацию о видео или аудио. Elementary Stream инкапсулируется в транспортный поток (TS), размер которого строго ограничен 188 байтами. Каждый элементарный поток (ES) имеет свой уникальный PID, что позволяет классифицировать разные наборы ES и склеить в соответствующий PES пакет.

PSI — Program Specific Information — содержит в себе таблицу программ (PAT) и таблицу структуры программ (РМТ). Таблица программ показывает, какие стримы передаются, а сама Program Map Table (РМТ) содержит PID и основные характеристики элементарных потоков конкретной программы.

Итак, чтобы найти определенный канал, первым делом нужно обратить внимание на PAT, где прописано, что информация об интересующих нас каналах лежит в РМТ под соответствующими PID. Например, в РМТ задан PID 51. Соответственно, в Program Table по PID 51 будет находиться информация об интересующих нас стримах.

Способы реализации потокового вещания

Вариант 1. Трудоемкий. Можно написать все самому. Это непросто и требует правильной реализации стандарта MPEG-TS, который сам по себе не очень прост.

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

Вариант 3. Бесплатный, открытый, но немного замороченный вариант — использовать VLC, как мы это сделали на проекте Dater. VLC можно очень быстро интегровать, по сути это уже готовый плеер, который можно использовать Plug & Play. Поэтому он и был нами выбран изначально – из-за высокой скорости внедрения в проект.

Вариант 4. Самый простой и тоже бесплатный — использовать FFMpeg. Эта утилита, скорее всего, предустановлена на каждом Макбуке. Она нужна для преобразования любых картинок, видео во что угодно — в gif, в набор картинок, можно менять видео-кодеки и т.д. Но об этом позже. Разберем по порядку варианты 3 и 4.

Работа с VLC

При разработке на проекте Dater мы использовали его с кастомной реализацией.

Плюсы VLC:

Минус:

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

Какие плюсы можем выделить при работе в конкретном проекте?

Минусами стали:

Структура VLC такова, что тут есть очень много модулей доступа к потокам, в том числе поддерживается наш SRT. Ничего особенно сложного мы не делали — по аналогии с другими параметрами добавили reconnect interval и maximum reconnected attempts. Эти параметры прокидываются в библиотеку VLC.

В аудиовыходе поменяли Audio Unity, которые здесь используются для вывода звука, также добавили возможность использования VoiceProcessingIO в AudioUnit плеера.

Работа с FFMpeg

FFMpeg — это, пожалуй, самая удобная утилита для решения наших задач.

Плюсы:

Компиляция FFMpeg

Комьюнити утилиты создало FFMpegKit, благодаря чему компиляция происходит очень просто. На картинке видим использование утилит git clone, cd ffmpeg-kit и запуск скрипта компиляции для iOS. В конкретной ситуации мы просто включили поддержку SRT и поддержку VideoToolBox для использования аппаратного декодинга. См. картинку:

На выходе мы получили сразу восемь фреймворков, каждый из которых можно подключать по-отдельности: ffmpegkit.framework , Libavcodec.framework, Libavdevice.framework, Libavfilter.framework, Libswresample.framework, Libswscale.framework, Libavformat.framework, Libavutil.framework.

Пишем свой плеер

Мы для реализации плеера выбрали Objective-C, потому что API самого FFMpeg полностью написана на C, что затрудняет работу с ним в Swift. То есть реализация возможна, но получится очень громоздко, в отличие от Objective-C, где можно полноценно работать с С и легче писать код.

Создаем тред и занимаемся банальным подключением FFMpeg: берем URL, C-строку, даем FFMpeg указание открыть тот или иной инпут из libavformat. К нам должен вернуться нулевой код. Затем получаем информацию о стриме, запоминаем его контекст — и после этой строчки у нас появляется информация о кодеках, аудио-кодеках, видеокодеке и о размере картинки.

Теперь мы ищем кодек, а для этого сначала находим нужный видеопоток, проходя по всем стримам, которые есть в потоке. Ищем стрим с нужным типом видео, запоминаем его индекс. Находим кодек и берем его параметр — Codec ID. Выделяем память под кодек и включаем аппаратное ускорение. В этом случае мы просто выдаем пиксель-формат VideoToolBox.

Инициализируется аппаратное ускорение — в случае с iOS оно всегда найдется, потому что  VideoToolBox  есть на всех устройствах с этой ОС.

Нашли hardware device contexts, записали его в контекст кодека. Открываем кодек — и в принципе уже можем его декодировать, выделяя память под фрейм. В p-frame у нас будет храниться изображение в формате, стандартном для кодеков h264 и h265.

Теперь мы выделяем память под сетевой пакет, проходимся по фреймам. В пакете уже будет информация после демультиплекса — пакет MPEG-TS с данными о сжатом кадре. Мы проверяем, что это пакет от нашего стрим-индекса.

У нас уже настроен аппаратный кодек и пакет, в случае успешного выполнения кода мы из кодека получаем декодированный фрейм. Нам нужно прокинуть полученное во вьюху, которая все отрисует. И все, что нам остается — найти view controller.

Запуск ffmpeg внутри iOS приложения

Для более удобного обращения к API FFMPEG будем использовать Objective-C

Создадим контекст `AVFormat` из `URL` видео/стрима:

```objc
AVFormatContext* pFormatContext = NULL;
const char* cUrl = [url.absoluteString cStringUsingEncoding:kCFStringEncodingUTF8];

if (avformat_open_input(&pFormatContext, cUrl, NULL, NULL) != 0) {
   // Не удалось открыть источник
   return;
}

if (avformat_find_stream_info(pFormatContext, NULL) < 0) {
   // Не удалось получить информацию о стриме
   return;
}
```

Условимся, что мы делаем простой плеер, который умеет отображать только один стрим. Чтобы отображать его нам нужно получить информацию о кодеке. Ее можно получить непосредственно из `AVStream`.

​Найдем индекс интересующего нас стрима (самый первый видео стрим)

```objc
int videoStreamIndex = -1;

for(int i = 0; i < pFormatContext->nb_streams; i++) {

   if(pFormatContext->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
      videoStreamIndex = i;
      break;
   }
}

if (videoStreamIndex < 0) {
   // Стрим не найден
   return;
}
```

Далее получим информацию о кодеке путем вызова метода `avcodec_find_decoder`. В который передадим идентификатор кодека, его можно найти в структуре `AVStream`.

```objc
const AVCodec * pCodec = NULL;

// Поиск декодера по идентификатору
pCodec = avcodec_find_decoder(pFormatContext->streams[videoStreamIndex]->codecpar->codec_id);
if (pCodec == NULL) {
   // Декодер не найден
   return;
}
```

Теперь необходимо создать контекст `AVCodecContext` с которым мы будем дальше работать.

```objc
AVCodecContext *pCodecCtx = NULL;
AVBufferRef * hwDeviceCtx = NULL;

pCodecCtx = avcodec_alloc_context3(pCodec);
if (avcodec_parameters_to_context(pCodecCtx, pFormatContext->streams[videoStreamIndex]->codecpar) != 0) {
   // Не Удалось заполнить контекст параметрами
   return;
}

// Коллбэк получения идентификатора формата (см. ниже)
pCodecCtx->get_format = get_hw_format;

// Включение возможности аппаратного ускорения
if (av_hwdevice_ctx_create(&hwDeviceCtx, AV_HWDEVICE_TYPE_VIDEOTOOLBOX, NULL, NULL, 0) < 0) {
   // Не Удалось включить аппаратное ускорение
   return;
}

pCodecCtx->hw_device_ctx = hwDeviceCtx;

// Открытие кодека
if (avcodec_open2(pCodecCtx, pCodec, NULL) < 0) {
   // Не Удалось открыть кодек
   return;
}
```

Коллбэк получения идентификатора формата:

```objc
static enum AVPixelFormat get_hw_format(AVCodecContext *ctx, const enum AVPixelFormat *pix_fmts)
{
   return AV_PIX_FMT_VIDEOTOOLBOX;
}
```

С настройкой кодека все, теперь можно получать фреймы видео, декодировать их и отображать

```objc
AVFrame * pFrame = NULL;

// Выделяем память под видео фрейм
pFrame = av_frame_alloc();
if (pFrame == NULL) {
   // Не удалось выделить память
   return;
}

// Выделяем память под пакет с закодированными видеоданными
AVPacket * pPacket = av_packet_alloc();
if (pPacket == NULL) {
   // Не удалось выделить память
   return;
}

while (av_read_frame(pFormatContext, pPacket) >= 0) {

   // Нам нужны пакеты только от стрима, который мы выбрали выше
   if (pPacket->stream_index != videoStreamIndex) {
      av_packet_unref(pPacket);
      continue;
   }

   // Перенаправляем пакет с закодированным видео в декодер
   int ret = avcodec_send_packet(pCodecCtx, pPacket);

   if (ret < 0) {
      // Не удалось перенаправить пакет
      continue;
   }

   while (ret >= 0) {

      // Пытаемся получить декодированное изображение из декодера
      ret = avcodec_receive_frame(pCodecCtx, pFrame);

      if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
         // Стрим завершен
         break;
      } else if (ret < 0) {
         // Не удалось декодировать пакет
         return;
      }

      // Так как мы указали формат пикселя VideoToolbox, смело кастим pFrame->data[3] в CVPixelBufferRef.
      CVPixelBufferRef pixelBuffer = (CVPixelBufferRef)pFrame->data[3];

     // На этом моменте можно отображать CVPixelBufferRef любым удобным для вас способом
     [self->_delegate playerDidOutputFrame:pixelBuffer];
   }

   av_packet_unref(pPacket);
}
```

В нашей интеграции мы выделили такие плюсы FFMpeg:

Минусы

Выводы

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

С FFMpeg ситуация получше: это по сути движок, и очень редко надо лезть в него (в наших кейсах почти никогда). А т.к. сам плеер написан нами и имеет небольшой код, то его легко модифицировать. Наши основные модификации: логика реконнекта, фильтры картинок, фильтры аудио и методы отображения фреймов – всё это модификации самого плеера, а не движка.

К примеру, если нам надо реализовать фичу “картинка в картинке”, то на FFMpeg мы бы легко ее сделали, т.к. можем изменить тип рендеринга на не Metal методы, а VLC осуществляет рендеринг только через Metal и там нет такой возможности.

Exit mobile version