У меня была задача - передача видео с минимальной задержкой с 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.
Спасибо за прочтение, жду ваших комментариев и предложений, возможно кто то сталкивался с таким.
debagger
По поводу производительности - подумайте над отказом от пайпа stdout. Там бывают проблемы именно при таком варианте использования. Вот issue с обсуждением подобной ситуации: https://github.com/nodejs/node/issues/3429
don_alex_88 Автор
Спасибо за совет. На самом деле я также пробывал передавать фейковые данные в onFrame и результат был таким же. Проведу ретест и отпишусь :)