У меня была задача - передача видео с минимальной задержкой с Raspberry Pi до веб-интерфейса моего робота. Причем необходима была реализация на Node JS.

В этой статье я расскажу как можно реализовать стриминг с Raspberry Pi до веб-страницы используя WebRTC и Node JS.

Немного об WebRTC

WebRTC позволяет устанавливать p2p соединение между пользователями и передавать друг другу данные.

Принципиальная блок-схема показана на рисунке ниже:

Картинка с простор интернета
Картинка с простор интернета

Как видно из блок-схемы, для начала осуществления соединения необходимо на одной стороне сформировать offer, передать его используя сигнальный сервер (сокет например) на другую сторону. Отвечающая сторона формирует answer и отправляет его в качестве ответа на offer. Также стороны обмениваются iceCandidate между собой - они формируются автоматически.

Входные данные

Для реализации WebRTC в Node JS я использовал библиотеку node-webrtc. Установить ее можно командой 'npm i wrtc'. Эта библиотека имеет мощный функционал и много примеров. В качестве сигнального сервера я использовал сокет соединение. Также условимся, что сторона клиента будет формировать offer.

Веб-приложение написано на React.

Что происходит на Raspberry Pi

Перед формированием соединения нам еще необходимо получить MediaStream с камеры компьютера. Эта часть была одна из самых сложных для поиска. Решение было обнаружено в примерах wrtc и на просторах интернета.

Сам Raspberry Pi имеет встроенную библиотеку rspividiyv, которая передает видео-данные в необходимом нам формате yuv420p.

Для запуска этого процесса будем использовать spawn в node js.

const width = 640;
const height = 480;

const { spawn } = require('child_process');

const raspividProcess = spawn('raspividyuv', ['-t', '0','-w', ${width}, '-h', ${height}, '-fps','30', '-o', '-']);          

raspividProcess.stdout.pipe(videoChunker);

Далее необходимо подписаться на этот стрим:

raspividProcess.stdout.pipe(videoChunker);

где videoChunkerобработчик входных данных - он группирует все в буффер, код приведу ниже.

const { Transform } = require('stream')
class Chunker extends Transform {
  constructor (size) {
    super();
    this.size = size;
    this.buffers = [];
    this.length = 0;
  }

  _transform (chunk, encoding, callback) {
    this.buffers.push(chunk);
    this.length += chunk.length;

    if (this.length >= this.size) {
      const all = Buffer.concat(this.buffers);
      for (let i = 0; i <= all.length - this.size; i += this.size) {
        this.push(all.slice(i, i + this.size));
      }

      const rest = all.slice(Math.floor(this.length / this.size) * this.size);
      this.buffers = [rest];
      this.length = rest.length;
    }
    callback();
  }

  _flush (callback) {
    callback();
  }
}

const videoChunker = new Chunker(width*height*1.5);

Теперь начинается самое интересное - создание подключения.

Определим MediaStream и подпишемся на обновления videoChunker.

const { MediaStream, nonstandard } = require('wrtc');
const { RTCVideoSource } = nonstandard;

const videoStream = new MediaStream();
const videoSource = new RTCVideoSource();
const videoTrack = videoSource.createTrack();

videoChunker.on('data', (data) => {
  const i420Frame = {
    width,
    height,
    data: new Uint8ClampedArray(data)
  };
  videoSource.onFrame(i420Frame)
});

Тут хочу заметить, что необходимо чтобы width и height были такими же, что определенны выше.

Таким образом мы будем обновлять наш MediaStream.

Далее определяем RTCPeerConnection.

const pc = new RTCPeerConnection();

pc.addTrack(videoTrack, videoStream);
pc.addStream(videoStream);

И добавляем обмен данными. Тут используем socket. Его реализацию оставляю на вашу часть или могу описать в другой статье. Так как offer формирует веб-пользователь, нам нужно получать offer и iceCandidate а отправлять answer и iceCandidate с нашей стороны.

socket.on('fromClient', async (message) => {
  if (message.offer) {
    await pc.setRemoteDescription(message.offer);
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);

    socket.emit('toClient', ({ answer }));
  }

  if (message.iceCandidate) {
    pc.addIceCandidate(message.iceCandidate);
  }
});

pc.onicecandidate = (event) => {
  socket.emit('toClient', ({ iceCandidate: event.candidate }))
}

При получении offer мы вызываем setRemoteDescription и далее формируем answer и устанавливаем setLocalDescription.

Когда peer создает кандидатов отправляем их на сторону клиента (браузер). Теперь можно переходить на сторону клиента.

Что происходит в браузере

Наша цель - получить видео с Raspberry Pi. Для теста можно собрать приложение используя create-react-app. Кусок кода на React:

function App() {
  const remoteRef = useRef(null);

  useEffect(() => {
    const peer = new RTCPeerConnection();

    socket.on("toClient", async (message) => {
      if (message.answer) {
        await peer.setRemoteDescription(message.answer);
      }

      if (message.iceCandidate) {
        await peer.addIceCandidate(message.iceCandidate);
      }
    });

    peer.onicecandidate = (event) => {
      socket.emit("fromClient", { iceCandidate: event.candidate });
    };

    peer.ontrack = (event) => {
      remoteRef.current.srcObject = event.stream[0];
      remoteRef.current.play();
    };

    const init = async () => {
      const offer = await peer.createOffer();
      await peer.setLocalDescription(offer);

      socket.emit("fromClient", { offer });
    };

    init();
  }, []);

  return (
    <div className="App">
      <header className="App-header">
        <video ref={remoteRef} id="video" muted/>
      </header>
    </div>
  );
}

export default App;

Что тут происходит ? При инициализации компонента мы сразу создаем offer и передаем его на Raspberry Pi через сокет-соединение. Также нужно подписаться на сообщения с сокета и также добавить хандлеры для peer-соединения.

Подведем итоги

После всего сделанного должно получиться передавать видео напрямую с Raspberry Pi с минимальной задержкой.

Могу добавить, что существует такие библиотеки как uv4l и uv4l_raspicam у linux-projects. Моя же цель была использовать Node JS чтобы иметь доступ в кадру с видео-камеры робота и далее проводить с ним различный манипуляции.

Что можно привести в заключение - это прекрасно работает. Было проверено при множестве соединений. Единственный минус - библиотеке wrtc сильно загружает процессор! Даже при передаче данных через data-channel, как только происходит подключение то нагрузка на процессор возрастает.

Чтобы снизить загрузку были проведены различные эксперименты, такие как вынос в отдельный кластер и т.п. но результат такой же. На картинке ниже - загрузка процессора, в какие то моменты она доходит до 99%.

Загрузка процессора
Загрузка процессора

Я надеюсь, что разработчики библиотеки исправят этот баг, потому что другие реализации WebRTC не загружают так процессор. Или же придется пробывать переписать этот модуль на Python.

Спасибо за прочтение, жду ваших комментариев и предложений, возможно кто то сталкивался с таким.

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


  1. debagger
    21.07.2023 18:54

    По поводу производительности - подумайте над отказом от пайпа stdout. Там бывают проблемы именно при таком варианте использования. Вот issue с обсуждением подобной ситуации: https://github.com/nodejs/node/issues/3429


    1. don_alex_88 Автор
      21.07.2023 18:54

      Спасибо за совет. На самом деле я также пробывал передавать фейковые данные в onFrame и результат был таким же. Проведу ретест и отпишусь :)