Пандемия COVID-19 стала катализатором для новых полезных сервисов. Например, Zoom стал настолько успешным, что по стоимости обогнал в этом месяце IBM. Нас вдохновил этот пример, и мы решили пойти еще дальше: а что если онлайн-конференции реализовать на приставках и Smart TV, чтобы общаться не только по работе, но устраивать удаленные посиделки на диване с друзьями? Но ведь тогда можно на футболе вместе покричать, и кино посмотреть или спортом заняться под контролем тренера.
Почему-то у операторов цифрового ТВ такой услуги не оказалось, хотя с инженерной точки зрения все эти функции вполне можно реализовать на ТВ-приставках на базе Linux/Android и RDK. Мы это проверили на практике и вот теперь делимся с читателями Хабра своим рецептом создания «аналога Zoom» и видеоконференций через Smart TV. Разберем архитектуру решения и кодирование видеопотока с использованием GStreamer. Информацию для работы с этим фреймворком мы собирали по крупицам, но оно того стоило.
Разбираемся с архитектурой
При разработке архитектуры приложений для ТВ-приставок важно учитывать потенциальные ограничения программной и аппаратной части этих устройств. Те, кто создает desktop-программы, как правило, не сталкиваются с этими сложностями, а для embedded-разработчиков это обычное дело.
Итак, с чем мы столкнемся на ТВ-приставках:
Ограниченность ресурсов процессоров и самих приставок. Чаще всего в STB-устройствах используются разнообразные ARM-процессоры, они несут в себя ряд ограничений и дополнительных задач, в частности необходимость использования аппаратного кодирования/декодирование видеопотока. Всегда нужно учитывать, что быстродействие — одно из узких мест.
Разность архитектур в приставках разных производителей. Некоторые базируются на Android, другие — на RDK, третьи — на дистрибутивах на базе Linux со своими ограничениями и нюансами. Поэтому на старте разработки лучше выбрать наиболее общие и кроссплатформенные решения в разных модулях программы. Уже не говоря о поддержке desktop-версии. А уже потом переходить к частным случаям.
Сетевые ограничения. Многие приставки работают как по Ethernet так и по wifi. Сжатие и передача видео/аудиопотока — еще одно узкое место в приложениях такого рода.
Безопасность передачи потока и др. вопросы безопасности данных.
Поддержка камер и микрофонов на встроенных платформах.
Теперь можно разложить по полочкам и саму архитектуру. Аналог Zoom для ТВ-приставок будет состоять из нескольких больших компонентов и модулей:
Захват видеопотока
Захват аудиопотока
Сетевой модуль
Модуль кодирования видео/аудиопотока
Модуль декодирования видео/аудиопотока
Вывод видеоконференции на экран
Вывод звука
Цветовые преобразования
Несколько других второстепенных компонентов
В упрощенном виде один из вариантов архитектуры можно изобразить так:
В рамках статьи мы сфокусируемся на кодировании декодирования видеопотока и на возможной реализации с использованием фреймворка GStreamer, т.к. это один из ключевых моментов в разработке приложений для видеоконференций.
Кодирование/декодирование потока
1) Преимущества GStreamer
Как мы уже отметили, одним из узких мест является передача видеопотока. Предположим у вас есть камера, отдающие кадры на скорости 30 кадров с секунду на небольшом разрешении 640x480. Итого, в RGB24 получается:
640 х 480 х 3 х 30 = 27 648 000 байт в секунду, т.е. более 26 Мбайт в секунду, что по понятным причинам совершенно неприемлемо в рамках сетевой передачи.
Один из вариантов решения — реализовать кодирование какой-нибудь библиотекой. И тут, как можно уже было догадаться из названия статьи, выбор пал на фреймворк GStreamer. Почему именно эта библиотека? Вот несколько ее преимуществ на фоне остальных решений:
Хорошая кроссплатформенность и отличная поддержка Linux и Android.
На RDK Gstreamer является стандартом кодирования/декодирования и включен в дистрибутив по-умолчанию.
Поддержках широкого набора модулей, фильтров и кодеков. Тот же FFmpeg, который можно использовать для тех же целей, де-факто является лишь одним из модулей GStreamer’а.
Простота построения конвейера (pipeline). Выстроить цепочку кодирования/декодирования не составляет большого труда, а сам подход конвейера позволяет легко заменять кодеки, фильтры и прочее без лишнего переписывания кода.
API для работы С/C++ идет комплекте.
Поддержка аппаратных кодеров/декодеров, в частности 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 можно развивать и дальше, как с точки зрения инженерных решений, так и по сценариям их использования. Так что делитесь мыслями в комментариях.