Пользователь запускает приложение, нажимает кнопку — и вжух, уже стримит в сеть, а к его стриму подключаются другие пользователи. В плеере в этот момент происходит огромная работа: скрипты забирают изображение и звук, кодируют, пакуют в контейнеры, и передают данные в местный разгрузочный порт, где контейнер снова разбивают на упакованное в него видео и аудио. Сколько усилий! Как эти усилия выглядят на самом деле и о способах настройки потокового вещания на примере работы с протоколом SRT рассказывает iOS-разработчик CleverPumpkin Никита Тархов.
Как работает информационный канал
Каждое видео в интернете проходит один и тот же путь от источника к принимающей стороне. Предположим: есть камера, которая условно 30 раз в секунду выдает нам raw-изображения, и такой же источник аудио, который выдает звуковые raw-данные. Для более эффективной их передачи используются кодеки, которые сжимают изображения, уменьшают вес и трафик в разы. Чтобы совместить переданные данные, используется мультиплексор, который наши аудио- и видеоданные и информацию о стримах собирает и пакует в некий контейнер, который на сетевом транспорте доставляется в интернет.
Все это происходит в момент после нажатия пользователем кнопки «начать стрим».
Стоит обратить ваше внимание на параметры частоты кадров и битрейта для видеокодека, которые непосредственно влияют на качество и работу в сети: чем выше битрейт, тем, соответственно, выше требование к сети. В аудио-кодеке используется такой параметр как частота дискретизации или Sample Rate и битрейт. Чем выше частота дискретизации, тем лучше качество звука.
Соответственно, контейнер, который грузится на сетевой транспорт, должен содержать информацию для декодинга, чтобы принимающая сторона поняла, как это расшифровать.
Принимающая сторона — зеркальное отображение отдающей: из интернета по сетевому транспорту приходит контейнер, демультиплексор этот контейнер разбивает на потоки видео и аудио. Мы получаем пакет со сжатым изображением, которое передается в видеокодек и декодируется в серию изображений, а сжатое аудио передается в аудиокодек — и превращается в звук.
Это то, что происходит после того, как зритель подключается к стриму.
Протокол SRT
Поговорим о происходящем с видео и аудио в стримах на примере SRT — транспортного протокола для медиа контейнеров, который разрабатывался специально для работы в плохих условиях сети. Этот протокол мы использовали при работе над одним из самых свежих проектов CleverPumpkin — Dater.
Разберемся с протоколом побуквенно.
- S — secure, то есть, этот протокол позволит зашифровать нашу информацию.
- R — reliable, что указывает на возможность восстановления после серьезной потери пакетов. Протокол предусматривает работу при нестабильных условиях в сети и может либо перезапросить, либо восстановить данные после потери пакетов. Протокол добавляет стабильности.
- С буквой Т все понятно — это transport, который благодаря S и R подстраивается к условиям сети.
Протокол является транспортным и базируется на 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:
- Он воспроизводит все, что угодно.
- У него очень большое сообщество.
- Бесплатный open source.
- Он разбит на модули, что позволяет легко ориентироваться по проекту.
Минус:
- Он единственный и большой — это готовый плеер. Чтобы что-то поменять, придется лезть в исходники и там что-то менять, потом перекомпилировать сам плеер и т.д. Все это требует немалого количества времени — перекомпиляция на Макбуке занимает примерно 40 минут. Сама необходимость лезть в «подкапотную» часть плеера проблемна еще и тем, что сам код написан достаточно сложно.
Успешно работать с VLC все это не мешает, хотя и доставляет некоторые сложности. Для создания стримов в приложении Dater мы очень много работали именно с VLC: оптимизировали и подбирали оптимальные параметры вещания, чтобы достичь требуемых показателей по задержкам и качеству воспроизведения.
Какие плюсы можем выделить при работе в конкретном проекте?
- Достаточно легкая интеграция в проект. Это, по сути, фреймворк, который предоставляет плеер, уже содержащий свой view (вьюху, проще говоря). Разработчику нужно всего лишь настроить плеер и настроить некоторые параметры, после чего добавить view в интерфейс.
- Все работает из коробки.
- Стабильность.
Минусами стали:
- Отсутствие изначальной поддержки SRT на iOS, ее пришлось прикручивать самостоятельно.
- Пришлось модифицировать исходники, чтобы добавить шумоподавление и логику повторного подключения.
Структура VLC такова, что тут есть очень много модулей доступа к потокам, в том числе поддерживается наш SRT. Ничего особенно сложного мы не делали — по аналогии с другими параметрами добавили reconnect interval и maximum reconnected attempts. Эти параметры прокидываются в библиотеку VLC.
В аудиовыходе поменяли Audio Unity, которые здесь используются для вывода звука, также добавили возможность использования VoiceProcessingIO в AudioUnit плеера.
Работа с FFMpeg
FFMpeg — это, пожалуй, самая удобная утилита для решения наших задач.
Плюсы:
- FFMpeg воспроизводит все, что угодно.
- У него такое же большое сообщество.
- Также есть бесплатный open source.
- Есть та же самая модульность.
- Позволяет сконфигурировать любое программное обеспечение по обработке видео. Хотите программу по обработке фотографий – 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:
- Все достаточно просто и интуитивно понятно.
- Стабильность работы.
- Полный контроль над всеми промежуточными процессами. Мы можем вклиниться в процесс получения MPEG-TS, самого транспортного потока, можем вклиниться в процесс декодинга информации между самим MPEG-TS и декодером, можем вклиниться уже после этого и добавить свои фильтры.
- Возможность написать свой I/O для вывода аудио и картинки уже внутри приложения и быстро его поменять, если нужно внести какие-то правки.
Минусы
- Работа с С и все вытекающие отсюда последствия.
Выводы
VLC – это по сути самостоятельный огромный проект, и чтобы добавить в него какую либо фичу, надо во всех случаях идти в отдельный проект, что-то менять, пересобирать, а если ожидаемый результат не совпал с фактическим, то все делать заново.
С FFMpeg ситуация получше: это по сути движок, и очень редко надо лезть в него (в наших кейсах почти никогда). А т.к. сам плеер написан нами и имеет небольшой код, то его легко модифицировать. Наши основные модификации: логика реконнекта, фильтры картинок, фильтры аудио и методы отображения фреймов – всё это модификации самого плеера, а не движка.
К примеру, если нам надо реализовать фичу “картинка в картинке”, то на FFMpeg мы бы легко ее сделали, т.к. можем изменить тип рендеринга на не Metal методы, а VLC осуществляет рендеринг только через Metal и там нет такой возможности.