Как запустить WebRTC на сервере, или как я пилю вебкам

d6f2d54c1ae7e77f98417bcb1b8ca57e.jpg

Всем привет!

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

ДИСКЛЕЙМЕР: это не бескорыстный акт передачи знаний с моей стороны. Я пытаюсь найти инвестиции для своего проекта и создал чат в тг, где буду постить обновления и какие-то мысли касательно его запуска. Так что если интересно, то подписывайтесь, а еще можете поделиться ссылкой с теми, у кого есть лишние бабки =)

Предыстория

Значит решил я создать сервис для одиноких мужчин, где они могут пообщаться с прекрасными дамами, aka вебкам. Соответственно встал вопрос, как организовать видеосвязь в браузере. Обычно для этого используется WebRTC, эта технология позволяет установить p2p соединение между браузерами для передачи видео, звука и прочих данных в реальном времени с минимальной задержкой. Однако была одна проблема: что делать, если приходит жалоба от пользователя, что ему показали не то (или не показали), что он хотел. Поскольку это p2p соединение напрямую между пользователями, у меня как у владельца сервиса нет возможности провалидировать жалобу. Первое, что пришло в голову это вместо WebRTC использовать MediaRecorder API для записи видео небольшими кусочками и отправки их по вебсокету через сервер, попутно сохраняя. Я набросал прототип и столкнулся с тем, что если получатель пропустил первый пакет (там где есть метаданные), то видео у него не воспроизводится. Пришлось поиском определенного набора байт в прервом пакете вычленять эти самые метаданные и сохранять их отдельно для отправки первым сообщением только что подключившемуся получателю, и это даже сработало. Вторая проблема этого решения — это задержка в пару секунд, и это только в локальной сети, что приемлимо для односторонней связи, но для двусторонней уже сомнительно. И третья проблема это то что видео у получателя со временем все больше и больше отстает, и нужно регулярно проматывать видео ближе к концу. Костыльность такого решения меня не устраивала, и я решил использовать WebRTC для связи собеседников и паралельно испольовать MediaRecorder для отправки записи от модели к серверу. Некоторое время оно так работало, пока я пилил другие фичи, но неэлегантность этого решения все еще не давала мне покоя, тк оно повышает требования к интернет соединению модели.

Я продолжил поиск на тему того, можно ли как-то установить WebRTC соединение с сервером, а там уже переправлять данные между подключенными к серверу пользователями и одновременно сохранять видеозапись. Нативная библиотека libwebrtc для C++, которая является частью chromium выглядела подходящим решением. Однако, как и любая уважающая себя библиотека для C++, она придерживается принципа «хорошая документация для слабаков». А в самом коде библиотеки хоть и есть примеры использования, но они изобилируют нерелевантной логикой и написаны любителями обмазаться паттернами и все переусложнить, к тому же они собираются системой сборки самой библиотеки и никак не проливают свет на то, как использовать libwebrtc в стороннем проекте.

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

Зачем запускать WebRTC на сервере

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

Демонстрационный проект

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

Он состоит из

a) простейшего сигнального сервера на nodejs (который, перебрасывая сообщения между клиентами, координирует установку webrtc соединения).

b) простейшего браузерного приложения которое отправляет видео с вебки и воспроизводит то, что получено от собеседника.

c) собственно, нативного webrtc приложения, которое просто принимает кадры от собеседника, рисует на них квадрат и отправляет обратно (звук просто отправляет обратно без изменений).

После прочтения этой статьи и просмотра кода вы поймете, как получить массив rgb пикселей кадра, который был получен от собеседника, без навязывания какого-либо способа рендеринга этих пикселей. Делайте с ними что хотите, можете сохранить, можете скормить алгоритму компьютерного зрения и тд. Также вы поймете, как отправить массив rgb пикселей собеседнику, неважно откуда вы его взяли (с камеры, сгенерировали и тд).

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

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

Как собрать проект

Чтобы запустить код, на примере которого мы будем изучать libwebrtc, нам нужны установленные на нашей машине NodeJS, инструменты компиляции с++ (clang++, cmake и пр), пакетный менеджер vcpkg. Я это тестировал на Ubuntu 22.04×86_64. Чем сильнее ваша конфигурация отличается от этой, тем сильнее вам придется импровизировать.

Чтобы скачать зависимости в корне проекта запустите

vckpg install

Затем нам нужно установить depot_tools (это инструмент для работы с кодом chromium, загрузки зависимостей и тд). Инструкция здесь. Нам нужно выполнить первые два пункта: склонировать репо, и прописать путь к depot_tools.

Затем нам нужно скачать исходники libwebrtc и сбилдить. В корне проекта выполните следующие команды

mkdir webrtc-checkout
cd webrtc-checkout
fetch --nohooks webrtc
cd src
git checkout branch-heads/6030
gclient sync
./build/install-build-deps.sh
gn gen out/Default --args='is_debug=false is_component_build=false rtc_include_tests=false use_custom_libcxx=false treat_warnings_as_errors=false use_ozone=true rtc_use_x11=false use_rtti=true rtc_build_examples=false'
ninja -C out/Default

Подробнее об этом процессе можно почитать здесь

После того как мы скачали и сбилдили зависимости нужно сбилдить сам проект. В корне проекта выполните

mkdir build
cd build
cmake ..
make

Теперь установите зависимости для кода на js. В корне проекта запустите

npm install

Как запустить

Запустите сигнальный сервер, через который клиенты будут координировать установку webrtc соединения

npm run server

Запустите веб сервер который отдает страницу с клиентом

npm run client

Перейдите по адресу который отобразился в терминале, дайте доступ странице к вебке

Теперь нужно запустить само нативное приложение которое будет манипулировать пикселями и отправлять измененные кадры обратно

./build/frame_manipulation_example

Введите «n» в ответ на вопрос, хотите ли отправить офер с нативного приложения.

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

Теперь разберемся что там происходит

Перед тем как пользоваться нативной библиотекой неплохо было бы понять, как использовать webrtc, чтобы соединить два браузера между собой (стаднартный сценарий использования). Чтобы увидеть, как это происходит запустите только сигнальный сервер и веб клиент (убейте frame_manipulation_example, если до этого его запустили)

npm run server
npm run client

Откройте веб клиент в двух вкладках и нажмите send offer в одной из них. Вы увидите что они отправляют и принимают друг у друга видео. Вся логика по установке этого взаимодействия лежит в файлах client.js и signaling-server.js.

В кратцах что происходит:

Мы создаем экземпляр RTCPeerConnection который отвечает за наше webrtc соединение.

Мы просим у браузера видеопоток с нашей вебки

Добавляем треки из него (видео и аудио) к нашему peerConnection

Когда мы нажимаем кнопку send offer мы создаем офер, в котором содержится инфа о наших треках, доступных возможностях и пр, и через сигнальный сервер отправляем другому клиенту. Другой клиент, когда получает офер, созает ответ, в котором тоже содержится аналогичная инфа о нем, и через сигнальный сервер передает первому клиенту. А еще они через тот же сигнальный сервер, к которому оба подключены по веб сокету, обменивются ICE кандидатами. В общих чертах, в них содержится инфа о том какой публичный ip имеет каждый из клиентов и через какие порты к ним можно достучаться (узнают они эту инфу от ICE серверов). В итоге, зная возможные публичные адреса и порты друг друга, они пытаются установить прямое p2p соединение между собой. Это очень утрированно.

А чтобы воспроизвести видео поток от собеседника, мы слушаем событие track на нашем peerConnection и устанавливаем полученный поток в качается источника видео-элементу.

Я не стал подробно расписывать, как пользоваться webrtc в браузере, тк в интернете тысячи статей и видосов об этом, я рекомендую ознакомиться с ними перед тем как продолжать. Что в интернете сложно найти — это подробно описанную инструкцию как же пользоваться библиотекой для libwebrtc для C++, вот на этом подробнее и остановимся.

В отличие от браузера, здесь мы должны сначала создать PeerConnectionFactory, с помощью которой мы будем создавать PeerConnection«ы.

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

Сам rtc: Thread ведет себя не так как можно подумать, в него можно постить таски, но не нужно надеяться на последовательность их выполнения, они могут выполниться параллельно в двух разных потоках.

В качестве 4 го параметра фактори принимает экземпляр webrtc: AudioDeviceModule либо nullptr, и тогда она будет использовать дефолтную реализацию аудио девайса, которая (сюрприз) воспроизводит звук на компьютере. Очень бредовое решение на мой взгляд, учитывая что впоследствии мы получаем аудиотрек, где у нас есть доступ к аудио данным и там мы могли бы сами решить, что делать с ними. Поэтому чтобы избежать такого эффекта, нужно передать кастомную реализацию webrtc: AudioDeviceModule. В проекте есть класс DummyAudioDeviceModule, который имплементирует все обязательные методы, но по сути ничего не делает, кроме запросов NeedMorePlayData каждые 10 мс, без этих запросов звук вообще не работает.

В отличии от колбэков, которые мы навешиваем на peerConnection в браузере, чтобы слушать какие либо события, здесь нам нужно имплементировать webrtc: PeerConnectionObserver и webrtc: CreateSessionDescriptionObserver, методы которых будут вызваны, когда произойдут те или иные события.

В моем примере я их имплементировал прямо в основном классе, где хранится все остальное состояние приложения

class MyWebrtcApplication : public webrtc::PeerConnectionObserver, public webrtc::CreateSessionDescriptionObserver

и когда создаю peerСonnection, я передаю поинтер на этот самый класс в качестве параметров

webrtc::PeerConnectionDependencies pc_dependencies(this);
auto error_or_peer_connection = factory->CreatePeerConnectionOrError(config, std::move(pc_dependencies));

Чтобы создать источник видео нужно имплементировать webrtc: VideoTrackSourceInterface. Я это сделал в классе MyVideoTrackSource.

class MyVideoTrackSource : public webrtc::VideoTrackSourceInterface {
public:
    rtc::VideoSinkInterface* sink_to_write = nullptr;
    // call this method to send frame
    void sendFrame(const webrtc::VideoFrame& frame) {
        if (!sink_to_write) return;
        sink_to_write->OnFrame(frame);
    }
    // VideoTrackSourceInterface implementation
    void AddOrUpdateSink(rtc::VideoSinkInterface* sink, const rtc::VideoSinkWants& wants) override {
        sink_to_write = sink;
        cout << "sink updated or added to my video source\n";
    }
...

Когда мы передадим этот источник в peerConnection (это происходит в методе run), метод AddOrUpdateSink будет вызван и мы получим sink, в который можно отправлять кадры, вызывая метод OnFrame (webrtc: VideoFrame frame). Как создать webrtc: VideoFrame из массивов байт, представляющих собой цветовые каналы отдельных пикселей, можно увидеть во второй половине метода transformFrame. Собственно это и есть ответ на вопрос, как отправлять кадры собеседнику.

Аналогичная ситуация с источником аудио, для создания источника аудио нужно имплементировать webrtc: AudioSourceInterface. Я это сделал в классе MyAudioSource. Он получает sink, у которого есть метод onData. Вызывая этот метод, можно передать аудиосэмплы, которые мы хотим отправить собеседнику.

class MyAudioSource : public webrtc::AudioSourceInterface {
public:
    webrtc::AudioTrackSinkInterface* sink_to_write = nullptr;
    // call this method to send audio data
    void sendAudioData(const void* audio_data,
                      int bits_per_sample,
                      int sample_rate,
                      size_t number_of_channels,
                      size_t number_of_frames) {
        if (!sink_to_write) return;
        sink_to_write->OnData(audio_data, bits_per_sample, sample_rate, number_of_channels, number_of_frames);
    }
    // AudioSourceInterface implementation
    void AddSink(webrtc::AudioTrackSinkInterface* sink) override {
        sink_to_write = sink;
        cout << "sink added to my audio source\n";
    }
...

Чтобы работать с полученными от собеседника кадрами нужно имплементировать rtc: VideoSinkInterface. Я это сделал в классе VideoReceiver.

class VideoReceiver : public rtc::VideoSinkInterface {
public:
    ...
    // VideoSinkInterface implementation
    void OnFrame(const webrtc::VideoFrame& frame) override {
        // this is called on each received frame

        // check FrameTransformer class implementation to see how to access raw rgb data of a frame
    
        // at this point we can render these frames, record them and do anything we want with them
        ...
    }
};

Когда вызывается метод OnAddTrack у webrtc: PeerConnectionObserver, мы используем VideoReceiver в качестве sink для полученного трека. Теперь при каждом полученном кадре у VideoReceiver будет вызываться метод OnFrame с ссылкой на экземпляр webrtc: VideoFrame в качесве параметра. Как достать массиив значений цвета для каждого пикселя из webrtc: VideoFrame можно увидеть в первой половине метода transformFrame. Это и есть ответ на то, как получить полностью декодированные кадры от собеседника, с которыми можно работать (отрендерить, скормить компьютерному зрению, сохранить и тд)

Аналогично со звуком, имплементируем public webrtc: AudioTrackSinkInterface (в моем случае это класс AudioReceiver) и используем его в качестве sink для аудио трека.

class AudioReceiver : public webrtc::AudioTrackSinkInterface {
public:
    ...
    // AudioTrackSinkInterface implementation
    void OnData(const void* audio_data, int bits_per_sample, int sample_rate, size_t number_of_channels, size_t number_of_frames) override {
        // this is called every ~10 ms with audio data

        // at this point we have access to raw audio data from the counterpart
        ...
    }
};

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

То что мои классы VideoReceiver и AudioReceiver принимают в качестве параметра ссылки на MyVideoTrackSource и MyAudioSource соответственно — это никак не относится к тому, как вы должны использовать libwebrtc, я это сделал для того чтобы полученные от собеседника данные отправить ему обратно. А видео кадры я еще и немного изменяю перед отправкой, рисуя небольшой квадрат поверх изображения. Мне это показалось самым простым решением демонстрации работы библиотеки без навязывания способа рендеринга и захвата кадров (и без соответствующего дополнительного нерелевантного кода, который бы отвлекал от сути).

В классе FrameTransformer вы можете увидить пример того как разобрать webrtc: VideoFrame на пиксели и наоборот собрать его из пикселей, попутно проводя с пикселями нужные вам манипуляции.

// This is an example of how to read, create and manipulate pixel data of each frame.
class FrameTransformer {
public:
    uint8_t argbdata[1920 * 1080 * 4];
    long counter = 0;
    webrtc::VideoFrame transformFrame(const webrtc::VideoFrame & frame) {
        rtc::scoped_refptr buffer(frame.video_frame_buffer()->ToI420());
        int width = buffer->width();
        int height = buffer->height();
        ...
        libyuv::I420ToARGB(buffer->DataY(), buffer->StrideY(),
            buffer->DataU(), buffer->StrideU(),
            buffer->DataV(), buffer->StrideV(),
            argbdata, width * 4, width, height);

        // now "argbdata" has completely decoded array of pixels and we can do anything with it

        // just painting moving diagonally 100x100 square on top of the received image as a test
        int h_start = counter % (height - 100);
        int w_start = counter % (width - 100);
        for (int h = h_start; h < h_start + 100; h++) {
            for (int w = w_start; w < w_start + 100; w++) {
                int pixIndex = h * width * 4 + w * 4;
                argbdata[pixIndex] = 255; // b
                argbdata[pixIndex + 1] = 255; // g
                argbdata[pixIndex + 2] = 0; // r
                argbdata[pixIndex + 3] = 255; // a
            }
        }
        counter++;

        // putting it to a new frame
        rtc::scoped_refptr new_buffer = webrtc::I420Buffer::Create(width, height);
        libyuv::ARGBToI420(argbdata, width * 4,
            new_buffer->MutableDataY(), buffer->StrideY(),
            new_buffer->MutableDataU(), buffer->StrideU(),
            new_buffer->MutableDataV(), buffer->StrideV(),
            width, height);
        
        webrtc::VideoFrame new_frame =
          webrtc::VideoFrame::Builder()
              .set_video_frame_buffer(new_buffer)
              .set_rotation(frame.rotation())
              .set_timestamp_us(frame.timestamp_us())
              .set_id(frame.id())
              .build();
        return new_frame;
    }
};

Для соединения с сигнальным сервером по вебсокету я использовал библиотеку ixwebsocket, тк она задокументирована и проста в использовании.

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

Как использовать libwebrtc в стороннем проекте

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

После того как мы сбилдили libwebrtc по инструкции выше, у нас появляется статическая библиотека которая лежит по пути ./webrtc-checkout/src/out/Default/obj/libwebrtc.a, ее нужно прилинковать, а также добавить в свой проект следующие include directories:

./webrtc-checkout/src
./webrtc-checkout/src/third_party/abseil-cpp
./webrtc-checkout/src/third_party/libyuv/include

Можете посмотреть CMakeLists.txt, чтобы увидеть, как это сделано в данном демо-проекте.

Еще кое-какие грабли

Иногда, когда вы пытаетесь подружить libwebrtc с какими то другими инструментами, типа ffmpeg или библиотекой для вебсокета с поддержкой ssl, то у вас могут возникнуть конфликты зависимостей, тк у libwebrtc очень много зависимостей, чтобы этого избежать можно попробовать обратиться к технике под названием pimpl (pointer to implementation) и возможно сбилдить какие-то части проекта как shared library и использовать --version-script для того, чтобы ограничить видимость тех или иных символов в них. В общем гуглите, как это сделать.

Еще не стоит вызывать метод close у PeerConnection, тк последующие PeerConnection«ы у фактори получаются мертворожденными и не хотят устанавливать соединение. Вызова деструктора у PeerConnection вроде достаточно, чтобы прибрать за собой.

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

Надеюсь информация была вам полезна, всем пока!

© Habrahabr.ru