Всем привет!

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

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

Предыстория

Значит решил я создать сервис для одиноких мужчин, где они могут пообщаться с прекрасными дамами, 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 x86_64. Чем сильнее ваша конфигурация отличается от этой, тем сильнее вам придется импровизировать.

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

vcpkg 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<webrtc::VideoFrame>* 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<webrtc::VideoFrame>* 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<webrtc::VideoFrame>. Я это сделал в классе VideoReceiver.

class VideoReceiver : public rtc::VideoSinkInterface<webrtc::VideoFrame> {
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<webrtc::I420BufferInterface> 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<webrtc::I420Buffer> 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 сервера-посредника между собеседниками есть один недостаток, это лишнее кодирование-декодирование, по факту мне нужно сохранять запись только со стороны модели, но видео со стороны клиента было бы достаточно перекидывать собеседнику напрямую без декодирования-кодирования. Однако в моей бизнес-модели основная трата - это оплата работы модели и эти оптимизации принципиальной роли не сыграют. Сейчас моя задача запустить проект, а не потратить несколько месяцев на то чтобы поресерчить, как выполнить эту оптимизацию и можно ли ее выполнить вообще. Если проект выстрелит, буду оптимизировать, ну или найму кого-нибудь поумнее. Так что исходите из своих бизнес требований. А если вдруг кто-то шарит, как это сделать, напишите в коментах или напишите статью.

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

Комментарии (10)


  1. AcckiyGerman
    29.04.2024 20:25
    +8

    Порно - двигатель прогресса!


    1. stvoid
      29.04.2024 20:25
      +2

      Оооо, особенно в вэбе! Это на самом деле очень любопытно, особенно на примере "Оранжевого Ютуба", как минимум часть их фронта всегда было интересно поковырять.

      1. То как они внедряли vue по кусочкам, на странице де факто работало несколько инстансов vue, что-то работало с комментариями, что-то с лентов предлагаемых видео, какая-то большая часть классически отдавалась серверов.

      2. Как они пытались бороться со скачиваниями с помощью обфускации кусочков кода, где были спрятаны ссылки на варианты качества и т.п. Но самый находчивый искатель мог без труда отыскать те кусочки скрипта, которые можно было скормить локальной виртуалке с js, реализованной на чем угодно - главное сделать eval и просто получить из функции читаемые ссылки.

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

      Или, например, когда появился OF, он же полностью на vue (вроде как), поэтому мне кажется сейчас самое интересное это отследить как они перейдут с vue2 на vue3, потому что все начальные болячки в виде лагающих огромных лент у них остаются до сих пор и не понятно что им мешает это как-то решить.

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

      Ну и вообще интересно на этих вот штуках посмотреть как себя ведет SSR и просто чистый JS насилующий браузер и процессор юзеров. Еще интереснее узнать почему именно эти сервисы выбрали такой вариант, как они к нему пришли и т.п.


  1. gev
    29.04.2024 20:25

    Вот это не рассматривали?
    https://janus.conf.meetecho.com/


    1. RSATom
      29.04.2024 20:25

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


      1. gev
        29.04.2024 20:25

        Есть еще Kurento


  1. DrRulez
    29.04.2024 20:25

    Как вариант можно использовать вот это. Open Source Live Streaming Software - Red5 - Red5


  1. gmtd
    29.04.2024 20:25
    +2

    А нельзя было для решения задачи цензурирования просто скриншоты раз в две секунды делать и отсылать на сервер?


  1. RSATom
    29.04.2024 20:25
    +1

    libwebrtc несколько тяжелая библиотека для использования на сервере. И дорабатывать ее в случае необходимости отдельная боль. Например в GStreamer есть более легкий вариант в виде webrtcbin. Кроме того, на данный момент, есть несколько других проектов для работы с WebRTC ориентированные именно на сервер (первое что приходит в голову - Pion https://github.com/pion/webrtc).


  1. Rumantic
    29.04.2024 20:25
    +1

    Таких проектов много, возможно что-то из этого поможет:

    https://github.com/arut/nginx-rtmp-module

    Еще готовы сетап с минимальными движениями можно собрать на этом:

    https://github.com/AirenSoft/OvenMediaEngine

    Сам его использую на одном проекте, к удивлению, запустился на самом дешевом vps )


  1. IvanG
    29.04.2024 20:25

    Кг/ам, тема сисег не раскрыта