Как разработать аналог Zoom для ТВ-приставок на RDK и Linux

Разбираемся с фреймворком GStreamer
Пандемия COVID-19 стала катализатором для новых полезных сервисов. Например, Zoom стал настолько успешным, что по стоимости обогнал в этом месяце IBM. Нас вдохновил этот пример, и мы решили пойти еще дальше: а что если онлайн-конференции реализовать на приставках и Smart TV, чтобы общаться не только по работе, но устраивать удаленные посиделки на диване с друзьями? Но ведь тогда можно на футболе вместе покричать, и кино посмотреть или спортом заняться под контролем тренера.

Почему-то у операторов цифрового ТВ такой услуги не оказалось, хотя с инженерной точки зрения все эти функции вполне можно реализовать на ТВ-приставках на базе Linux/Android и RDK. Мы это проверили на практике и вот теперь делимся с читателями Хабра своим рецептом создания «аналога Zoom» и видеоконференций через Smart TV. Разберем архитектуру решения и кодирование видеопотока с использованием GStreamer. Информацию для работы с этим фреймворком мы собирали по крупицам, но оно того стоило.
Разбираемся с архитектурой
При разработке архитектуры приложений для ТВ-приставок важно учитывать потенциальные ограничения программной и аппаратной части этих устройств. Те, кто создает desktop-программы, как правило, не сталкиваются с этими сложностями, а для embedded-разработчиков это обычное дело.

Итак, с чем мы столкнемся на ТВ-приставках:

  1. Ограниченность ресурсов процессоров и самих приставок. Чаще всего в STB-устройствах используются разнообразные ARM-процессоры, они несут в себя ряд ограничений и дополнительных задач, в частности необходимость использования аппаратного кодирования/декодирование видеопотока. Всегда нужно учитывать, что быстродействие — одно из узких мест.

  2. Разность архитектур в приставках разных производителей. Некоторые базируются на Android, другие — на RDK, третьи — на дистрибутивах на базе Linux со своими ограничениями и нюансами. Поэтому на старте разработки лучше выбрать наиболее общие и кроссплатформенные решения в разных модулях программы. Уже не говоря о поддержке desktop-версии. А уже потом переходить к частным случаям.

  3. Сетевые ограничения. Многие приставки работают как по Ethernet так и по wifi. Сжатие и передача видео/аудиопотока — еще одно узкое место в приложениях такого рода.

  4. Безопасность передачи потока и др. вопросы безопасности данных.

  5. Поддержка камер и микрофонов на встроенных платформах.

Теперь можно разложить по полочкам и саму архитектуру. Аналог Zoom для ТВ-приставок будет состоять из нескольких больших компонентов и модулей:

  • Захват видеопотока
  • Захват аудиопотока
  • Сетевой модуль
  • Модуль кодирования видео/аудиопотока
  • Модуль декодирования видео/аудиопотока
  • Вывод видеоконференции на экран
  • Вывод звука
  • Цветовые преобразования
  • Несколько других второстепенных компонентов

В упрощенном виде один из вариантов архитектуры можно изобразить так:
В рамках статьи мы сфокусируемся на кодировании декодирования видеопотока и на возможной реализации с использованием фреймворка GStreamer, т.к. это один из ключевых моментов в разработке приложений для видеоконференций.
Кодирование/декодирование потока
1) Преимущества GStreamer

Как мы уже отметили, одним из узких мест является передача видеопотока. Предположим у вас есть камера, отдающие кадры на скорости 30 кадров с секунду на небольшом разрешении 640x480. Итого, в RGB24 получается:

640 х 480 х 3 х 30 = 27 648 000 байт в секунду, т.е. более 26 Мбайт в секунду, что по понятным причинам совершенно неприемлемо в рамках сетевой передачи.

Один из вариантов решения — реализовать кодирование какой-нибудь библиотекой. И тут, как можно уже было догадаться из названия статьи, выбор пал на фреймворк GStreamer. Почему именно эта библиотека? Вот несколько ее преимуществ на фоне остальных решений:

  1. Хорошая кроссплатформенность и отличная поддержка Linux и Android.

  2. На RDK Gstreamer является стандартом кодирования/декодирования и включен в дистрибутив по-умолчанию.

  3. Поддержках широкого набора модулей, фильтров и кодеков. Тот же FFmpeg, который можно использовать для тех же целей, де-факто является лишь одним из модулей GStreamer'а.

  4. Простота построения конвейера (pipeline). Выстроить цепочку кодирования/декодирования не составляет большого труда, а сам подход конвейера позволяет легко заменять кодеки, фильтры и прочее без лишнего переписывания кода.

  5. API для работы С/C++ идет комплекте.

  6. Поддержка аппаратных кодеров/декодеров, в частности OpenMAX API — что крайне важно при работе с ТВ-приставками.

2) Знакомимся с GStreamer и разбираемся с конвейерами

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

gst-inspect-1.0 позволит посмотреть список доступных кодеков и модулей, так что можно сразу увидеть, что будет работать, и выбрать набор фильтров и кодеков.

gst-launch-1.0 позволяет запустить любой конвейер (pipeline).

В GStreamer используется схема декодирования, по которой поток последовательно проходит через разные компоненты, начиная с source, заканчивая sink-выводом. При этом source может быть любым — файл, устройство, для вывода (sink) также можно указать нужное — файл, экран, сетевые выводы и протоколы (наподобие RTP).

Подробнее про использование утилит и принцип построения конвейера можно почитать в другой статье на Хабре. Классический пример проигрывания mp4-файла:

gst-launch-1.0 filesrc location=file.mp4 ! qtdemux ! h264parse ! avdec_h264 ! videoconvert ! autovideosink

На входе принимается mp4-файл, который проходит через демуксер mp4 — qtdemux, парсер h264, затем через декодер, конвертер и, наконец, идет вывод на экран.

Можно заменить autovideosink на filesink с параметром файла и вывести декодированный поток прямиком в файл.

3) Пишем приложение на GStreamer C/C++ API. Попробуем декодировать

Теперь, когда мы разобрались немного с использованием gst-launch-1.0, проделаем все тоже самое, только уже в рамках приложения. Принцип останется тем же: мы встраиваем декодирующий конвейер (pipeline), однако уже с использованием библиотеки GStreamer и glib-событий.

О простейшем примере построения filesrc в filesink с кодом хорошо рассказано в другой хабрастатье — «GStreamer: элементы и контейнеры». Мы же рассмотрим чуть более реалистичный и сложный живой пример H264-декодирования.

Инициализация GStreamer-приложения происходит один раз при помощи

gstinit (NULL, NULL);

Если вы хотите сразу видеть происходящее в деталях, можно выстроить уровень логирования перед инициализацией:
gst_debug_set_active(TRUE);
gst_debug_set_default_threshold(GST_LEVEL_LOG);
Учтите: сколько бы у вас не было конвейеров в приложении, инициализировать gstinit достаточно один раз.

Создадим новый event-loop, в котором будут обрабатываться события:
GMainLoop *loop;
loop = g_main_loop_new (NULL, FALSE);
И теперь можно начать выстраивать наш конвейер:

Объявим необходимые элементы, в частности сам конвейер как тип GstElement:
GstElement *pipeline, *source, *demuxer, *parser, *decoder, *conv, *sink;

pipeline = gst_pipeline_new ("video-decoder");
source   = gst_element_factory_make ("filesrc",       "file-source");
demuxer  = gst_element_factory_make ("qtdemux",      "h264-demuxer");
parser   = gst_element_factory_make ("h264parse",      "h264-parser");
decoder  = gst_element_factory_make ("avdec_h264",     "h264-decoder");
conv     = gst_element_factory_make ("videoconvert",  "converter");
sink     = gst_element_factory_make ("appsink", "video-output");
Каждый элемент конвейера создается через gst_element_factory_make, где первым параметром идет тип, а вторым — его условное название для GStreamer, на которое он впоследствии будет опираться, например, при выдаче ошибок.

Также не мешало бы проверить, что все компоненты найдены, в противном случае gst_element_factory_make возвращает NULL.
if (!pipeline || !source || !demuxer || !parser || !decoder || !conv || !sink) {
		// не инициализирован один элемент - остановка
		return;
}
Установим тот самый параметр location через gob_ject_set:
gob_ject_set (G_OBJECT (source), "location", argv[1], NULL);
Остальные параметры в других элементах можно выставлять аналогичным образом.

Теперь необходим обработчик сообщений GStreamer, создадим соответствующий bus_call:
GstBus *bus;
	guint bus_watch_id;
	bus = gst_pipeline_get_bus (GST_PIPELINE (pipeline));
	bus_watch_id = gst_bus_add_watch (bus, bus_call, loop);
	gst_object_unref (bus);
gst_object_unref и другие подобные вызовы нужны для очищения выделенных объектов.

Затем объявим сам обработчик сообщений:
static gboolean
bus_call (GstBus     *bus,
          GstMessage *msg,
          gpointer    data)
{
  GMainLoop *loop = (GMainLoop *) data;

  switch (GST_MESSAGE_TYPE (msg)) {

    case GST_MESSAGE_EOS:
      LOGI ("End of stream\n");
      g_main_loop_quit (loop);
      break;

    case GST_MESSAGE_ERROR: {
      gchar  *debug;
      GError *error;

      gst_message_parse_error (msg, &error, &debug);
      g_free (debug);

      LOGE ("Error: %s\n", error->message);
      g_error_free (error);

      g_main_loop_quit (loop);
      break;
    }
    default:
      break;
  }

  return TRUE;
}
А теперь самое важное: собираем и добавляем все созданные элементы в единый конвейер, тот самый что выстраивали через gst-launch. Очередность добавления, естественно, важна:
gst_bin_add_many (GST_BIN (pipeline), source, demuxer, parser, decoder, conv, sink, NULL);
gst_element_link_many (source, demuxer, parser, decoder, conv, sink, NULL);
Стоит также заметить, что такой подход линковки элементов прекрасно работает для потоковых выходов, но в случае именно проигрывания (autovideosink) необходима дополнительная синхронизация и динамическая линковка демуксера и парсера:
  gst_element_link (source, demuxer);
  gst_element_link_many (parser, decoder, conv, sink, NULL);
  g_signal_connect (demuxer, "pad-added", G_CALLBACK (on_pad_added), parser);

static void
on_pad_added (GstElement *element,
              GstPad     *pad,
              gpointer    data)
{
  GstPad *sinkpad;
  GstElement *decoder = (GstElement *) data;

  /* We can now link this pad with the sink pad */
  g_print ("Dynamic pad created, linking demuxer/decoder\n");

  sinkpad = gst_element_get_static_pad (decoder, "sink");

  gst_pad_link (pad, sinkpad);

  gst_object_unref (sinkpad);
}
Динамическое соединение дает возможность определения типа и количества потоков в отличии от статического, и будет работать в некоторых случаях когда это требуется.

Ну и, наконец, переводим состояние конвейера в проигрывание:
gst_element_set_state (pipeline, GST_STATE_PLAYING);
И запускаем event-loop:
g_main_loop_run (loop);
После окончания этой процедуры все нужно почистить:
gst_element_set_state (pipeline, GST_STATE_NULL);
  gst_object_unref (GST_OBJECT (pipeline));
  g_source_remove (bus_watch_id);
  g_main_loop_unref (loop);
4) Выбираем энкодеры и декодеры. Фоллбэки

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

Поможет нам в этом функция gst_element_factory_find, которая проверяет, есть ли у нас кодек в factory элементов:
if(gst_element_factory_find("omxh264dec"))
		decoder  = gst_element_factory_make ("omxh264dec",     "h264-decoder");
	else
		decoder  = gst_element_factory_make ("avdec_h264",     "h264-decoder");
В данном примере мы приоритезировали выбор аппаратного декорера OMX на платформе RDK, и в случае его отсутствия будем выбирать программную реализацию.

Другой крайне полезной, но еще более редко используемой функцией является проверка того, что же мы на самом деле инициализировали в GstElement (какой кодек):
gst_plugin_feature_get_name(gst_element_get_factory(encoder))
Можно проделать это таким незамысловатым способом и вернуть название инициализированного кодека.

5) Цветовые модели видео

Не стоит оставлять без внимания и цветовые модели, раз уж мы заговорили о кодировании видео с камер. И тут чаще подразумевается YUV, нежели RGB.

Камеры просто обожают цветовую модель YUYV. Но это то, с чем любит работать GStreamer, для него более привычна модель I420. На выходе, если речь не идет о выводе в gl-фрейм, у нас также будут выходить I420-кадры. Будьте готовы подставить необходимые фильтры и выполнить преобразования. Некоторые энкодеры умеют работать и с другими цветовыми моделями, но чаще это исключения из правил.

Еще стоит заметить что у GStreamer'а есть свой модуль получения видеопотока с камеры, и его можно использовать для построения конвейера, но об этом расскажем как-нибудь в другой раз.
Разбираемся с буферами и берем данные на лету
1) Буфер ввода

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

Теперь будем работать с буферами и входами и выходами appsrc / appsink. Почему-то этому вопросу почти не уделиливнимания в официальной документации.

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

Вначале установим обработчик события need-data, который будет запускаться при необходимости подачи данных в конвейер и начнем подавать входной буфер:
g_signal_connect (source, "need-data", G_CALLBACK (encoder_cb_need_data), NULL);
Сам обработчик имеет следующий вид:
encoder_cb_need_data (GstElement *appsrc,
          guint       unused_size,
          gpointer    user_data)
{
  GstBuffer *buffer;
  GstFlowReturn ret;
  GstMapInfo map;

   int size;
   uint8_t* image;
  // get image

  buffer = gst_buffer_new_allocate (NULL, size, NULL);
  gst_buffer_map (buffer, &map, GST_MAP_WRITE);
  
  memcpy((guchar *)map.data, image,  gst_buffer_get_size( buffer ) );
  gst_buffer_unmap(buffer, &map);

  g_signal_emit_by_name (appsrc, "push-buffer", buffer, &ret);
  gst_buffer_unref(buffer);
}
image — это, условно говоря, псевдокод буфера картинки в I420.

Далее мы создаем через gst_buffer_new_allocate буфер необходимого размера, который будет соответствовать размеру буфера картинки.

При помощи gst_buffer_map устанавливаем буфер в режим записи и, используя memcpy, копируем нашу картинку в созданный буфер.

И, наконец, сигнализируем GStream'у о том, что буфер готов.

Ремарка: очень важно после записи использовать gst_buffer_unmap, а также очищать буфер после использования при помощи gst_buffer_unref. Иначе будет утечка памяти. В скудном количестве доступных примеров никто особо насчет вопроса освобождения памяти не заморачивался, а он, понятное дело, важный.

Теперь, когда мы закончили с обработчиком, нужно проделать еще одну вещь: настроить caps на поступление ожидаемого формата.

Делается это перед установкой обработчика сигнала need-data:
g_object_set (G_OBJECT (source),
        "stream-type", 0,
        "format", GST_FORMAT_TIME, NULL);

	g_object_set (G_OBJECT (source), "caps",
		gst_caps_new_simple ("video/x-raw",
					"format", G_TYPE_STRING, "I420",
					"width", G_TYPE_INT, 640,
					"height", G_TYPE_INT, 480,
					"framerate", GST_TYPE_FRACTION, 30, 1,
		NULL), 
	NULL);
Как и все параметры GstElement, установка параметров осуществляется через g_object_set.

В данном случае мы задали тип потока, и его caps — формат данных. Указав, что на выход appsrc будет поступать данные I420 c разрешением 640x480 и частотой 30 кадров в секунду.

Частота в нашем случае, да в целом, не играет роли. На практике мы не замечали, чтобы GStreamer как-то ограничивал вызовы need-data по частоте.

Готово, теперь наши кадры поступают в энкодер.

2) Буфер вывода

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

Подключаем обработчик к sink pad:
GstPad *pad = gst_element_get_static_pad (sink, "sink");
  	gst_pad_add_probe  (pad, GST_PAD_PROBE_TYPE_BUFFER, encoder_cb_have_data, NULL, NULL);
  	gst_object_unref (pad);
Похожим образом мы подключились к другому событию sink pad — GST_PAD_PROBE_TYPE_BUFFER, — которое будет вызываться по мере поступления буфера данных в sink pad.
static GstPadProbeReturn
encoder_cb_have_data (GstPad * pad,
                        GstPadProbeInfo * info,
                        gpointer user_data) {

  GstBuffer *buf = gst_pad_probe_info_get_buffer (info);
  GstMemory *bufMem = gst_buffer_get_memory(buf, 0);
  GstMapInfo bufInfo;
  gst_memory_map(bufMem, &bufInfo, GST_MAP_READ);

  // bufInfo.data, bufInfo.size

  gst_memory_unmap(bufMem, &bufInfo);

  return GST_PAD_PROBE_OK;
}
Колбэк имеет похожую структуру. Теперь нужно достучаться до памяти буфера. Вначале получаем GstBuffer, затем указатель его памяти, используя gst_buffer_get_memory по индексу 0 (как правило задействован только он). И, наконец, используя gst_memory_map, получаем адрес буфера данных bufInfo.data и его размер bufInfo.size.

Фактически мы добились цели — получили буфер с кодированными данными и его размер.

Итак, мы рассмотрели ключевые и самые интересные компоненты из приложения для Smart TV — аналога Zoom для ТВ-приставок: архитектуру, модули кодирования / декодирования через GStreamer, буферы ввода / вывода и используемые цветовые преобразования.

Для операторов цифрового ТВ такая программная платформа может стать новым абонентским сервисом. Для нас — инженеров — новым интересным embedded-проектом для реализации на разных приставках на базе RDK, Linux и Android. А для всех остальных — возможностью приятно проводить время за совместным просмотром фильмов и спортивных матчей, занятий спортом и посиделок с близкими в период карантина или удаленной работы.

Эту идею с сервисом видеоконференций по Smart TV можно развивать и дальше, как с точки зрения инженерных решений, так и по сценариям их использования.


Кирилл Зут
Руководитель подразделения по разработке решений для цифрового ТВ и потокового видео
Инженерная команда Кирилла реализует крупные долгосрочные проекты, которые охватывают все устройства для видеостриминга — от смартфонов до Smart TV.

Если вы хотите, чтобы вашими приложениями для Android и iOS пользовались миллионы людей из разных стран мира — присоединяйтесь. У нас сейчас как раз открыты вакансии!
Вас могут заинтересовать следующие вакансии:

Приглашаем в нашу команду Middle/Senior Android разработчика для развития Mobile приложений

Приглашаем в нашу команду Middle/Senior iOS разработчика для развития Mobile приложений