Управляем роботами из VR. Продолжение 1

от автора

Первая статья – Управляем роботами из VR

В прошлой статье, мы провели начальную подготовку и реализовали сигнализацию для компонентов средствами websocket. В этой статье мы реализуем работу по WebRTC (Части 3, 4).

Часть 3. Настройка WebRTC Connection + DataChannel

Итак, у нас реализован сервер сигнализации, который мы сможем использовать для обмена контекстом WebRTC — offer, answer, ice, подробнее про webrtc можно почитать в тут, не будем останавливаться на деталях.

В этой части мы будем реализовывать организацию WebRTC соединения, и создание DataChannel между 2мя пирами в JS-Python-C#.

Серверный компонент

Добавим в созданную ранее разметку main.html новые элементы:

main.html
...   <div class="row-md-6">     <form class="form-inline">       <div class="form-group">         <label for="content">Send offer to:</label>         <input type="text" id="offerTo" class="form-control" placeholder="Offer to...">       </div>       <button id="createOffer" class="btn btn-primary" type="submit">Create Offer</button>     </form>   </div>   <div class="row-md-6">     <form class="form-inline">       <div class="form-group">         <label for="content">Message directly:</label>         <input type="text" id="content2" class="form-control" placeholder="Message here...">       </div>       <button id="sendMessageDirectly" class="btn btn-primary" type="submit">Directly message</button>     </form>   </div> ...

Для реализации datachannel на стороне браузера доработаем наш скрипт следующим образом – создадим процесс обмена sdp/ice для отправки и приема OFFER. Создание DataChаannel и отправку сообщений между пирами:

robo.js
var connection; var userId = 'unknown'; var peerConnection; var dataChannel; var configuration = {   "iceServers" : [{   "urls" : "stun:stun2.1.google.com:19302"   }] };  function connect(){   connection = new WebSocket('wss://' + window.location.host + '/robopi_webrtc');   console.log("Connsection sucsess");      initRTCPeerConnection();      connection.onmessage = function(msg) {          var resp = JSON.parse(msg.data);            if(resp.type == 'USERID'){       console.log();       userId = resp.data;       document.getElementById("username").textContent = userId;     }     if(resp.type == 'NEWMEMBER'){       if(userId != resp.userId){         console.log("NEWMEMDER:" + resp.userId);       }     }     if(resp.type == 'OFFER'){       if(userId != resp.userId){         console.log(resp);         handleOffer(resp.payload, resp.userId)       }     }     if(resp.type == 'ICE'){       if(userId != resp.userId){         console.log(resp);         receiveIceCandidate(resp.payload);       }     }     if(resp.type == 'ANSWER'){       if(userId != resp.userId){         console.log(resp);         handleAnswer(resp.payload);       }     }   } }  function login() {   connection.send(JSON.stringify({'userId' : '', 'type' : 'LOGIN', 'data' : '' , 'toUserId' : ''})); }  function newmember() {   connection.send(JSON.stringify({'userId' : userId, 'type' : 'NEWMEMBER', 'data' : '' , 'toUserId' : ''})); }  function initRTCPeerConnection(){   peerConnection = new RTCPeerConnection(configuration);   console.log("peerConnection created");   // remote datachannel handler   peerConnection.ondatachannel = function(event){     dataChannel = event.channel;     // open handling     dataChannel.onopen = function(){     console.log("Data channel is open!");     }     // error handling     dataChannel.onerror = function(error){       console.log("Error in datachannel:", error);     };     // messaging handler     dataChannel.onmessage = function(event) {       console.log("incoming message:", event.data);     };     // closing handler     dataChannel.onclose = function() {       console.log("Data channel is closed");     };   }; }  function createRTCDatachannel(){   dataChannel = peerConnection.createDataChannel("datachannel", {      reliable: true    });   // open handling   dataChannel.onopen = function(){     console.log("Data channel is open!");   }   // error handling   dataChannel.onerror = function(error){     console.log("Error in datachannel:", error);   };   // messaging handler   dataChannel.onmessage = function(event) {     console.log("incoming message:", event.data);   };   // closing handler   dataChannel.onclose = function() {     console.log("Data channel is closed");   }; }  function createOffer(){   createRTCDatachannel();   peerConnection.createOffer(function(offer) {     connection.send(JSON.stringify({'userId' : userId, 'type' : 'OFFER', 'payload' : offer , 'toUserId' : $("#offerTo").val()}));     peerConnection.setLocalDescription(offer);   },   function(error) {     console.log("error in offer creating:" + error);   });   sendIceCandidate(); }  function sendIceCandidate(){   peerConnection.onicecandidate = function(event) {   if (event.candidate) {     console.log("sending ice candidate:" + event.candidate);     connection.send(JSON.stringify({'userId' : userId, 'type' : 'ICE', 'payload' : event.candidate , 'toUserId' : $("#offerTo").val()}));   }   }; }  function receiveIceCandidate(candidate){   peerConnection.addIceCandidate(new RTCIceCandidate(candidate));   console.log("sucsess receiving ice candidate"); }  function handleAnswer(answer){   peerConnection.setRemoteDescription(new RTCSessionDescription(answer));   console.log("handling amswer successfully!!"); }  function handleOffer(offer, fromUser) {   peerConnection.setRemoteDescription(new RTCSessionDescription(offer));   // create and send an answer to an offer   peerConnection.createAnswer(function(answer) {     peerConnection.setLocalDescription(answer);     connection.send(JSON.stringify({'userId' : userId, 'type' : 'ANSWER', 'payload' : answer , 'toUserId' : fromUser}));   }, function(error) {     console.log("error creating answer:" + error);   }); };  function sendMessageDirectly(){   var message = $("#content2").val();   dataChannel.send(message);   console.log("send message:" + message); }  $(function () {   $("form").on('submit', function (e) {   e.preventDefault();   });    $( "#connect" ).click(function() { connect(); }); $( "#login" ).click(function() { login(); }); $( "#newmember" ).click(function() { newmember(); }); $( "#createOffer" ).click(function() { createOffer(); }); $( "#sendMessageDirectly" ).click(function() { sendMessageDirectly(); }); });

Делаем проверку между 2мя браузерами, проверяем работоспособность, у нас должен создаваться datachannel и мы можем отправлять принимать сообщения между браузерами:

проверка:

Исполнительный компонент(Python-скрипт на RPI)

Для работы с WebRTC из пайтон будем использовать бибилиотеку aiortc:

pip3 install aiortc

Тут возникают первые проблемы с совместимостью реализаций WebRTC (ICE) и учитываем особенность – Python не будет являться инициатором соединения, т. е. он должен только обрабатывать входящие офферы:

part2.py
import asyncio import websockets import json import ssl from websockets import WebSocketClientProtocol from aiortc import RTCIceCandidate, RTCPeerConnection, RTCSessionDescription, RTCConfiguration, RTCIceServer  async def wsconsume(wsurl: str) -> None:     ssl_context = ssl.SSLContext()     async with websockets.connect(wsurl, ssl=ssl_context) as websocket:         await websocket.send(json.dumps({"userId": "", "type": "LOGIN", "data": "", "payload": "", "toUserId": ""}))         await wsconsumer_handler(websocket)   async def wsconsumer_handler(websocket: WebSocketClientProtocol) -> None:     local_user_id = ""     ice_servers = [RTCIceServer(urls=["stun:stun2.l.google.com:19302"])]     peer_conn = RTCPeerConnection(RTCConfiguration(iceServers=ice_servers))          @peer_conn.on("connectionstatechange")     async def on_connectionstatechange():         print("Connection state is %s" % peer_conn.connectionState)         if peer_conn.connectionState == "failed":             await peer_conn.close()      @peer_conn.on("signalingstatechange")     async def on_signalingstatechange():         print(f"changed signalingstatechange {peer_conn.signalingState}")      @peer_conn.on("icegatheringstatechange")     async def on_icegatheringstatechange():         print(f"changed icegatheringstatechange {peer_conn.iceGatheringState}")              @peer_conn.on("datachannel")     async def on_datachannel(channel):         print(f"changed datachannel to {channel}")         @channel.on("message")         async def on_message(rtc_message):             if isinstance(rtc_message, str):                 print("New message from datachannel " + rtc_message)                 channel.send("Reply from PyPi - " + rtc_message)          async for message in websocket:          msg = json.loads(message)          if msg.get("type") == 'USERID' and local_user_id != msg.get("userId"):             local_user_id = msg.get("data")             print("SET UID: " + local_user_id)             await websocket.send(json.dumps({"userId": local_user_id, "type": "NEWMEMBER", "data": "",                                              "payload": "", "toUserId": ""}))          if msg.get("type") == 'OFFER' and local_user_id == msg.get("toUserId"):             print("Handling offer: " + str(msg.get("payload")))                          await peer_conn.setRemoteDescription(                 RTCSessionDescription(sdp=msg.get("payload").get("sdp"), type=msg.get("payload").get("type")))                           answer = await peer_conn.createAnswer()             print("Creating answer:" + str(answer))             await peer_conn.setLocalDescription(answer)              await websocket.send(json.dumps({"userId": local_user_id, "type": "ANSWER", "data": "",                                              "payload": {"sdp": answer.sdp, "type": answer.type},                                              "toUserId": msg.get("userId")}))                      if msg.get("type") == 'ICE' and local_user_id == msg.get("toUserId"):             print("ICE INCOMING")             candidate = msg.get("payload").get("candidate").split()             new_ice = RTCIceCandidate(                 component=int(candidate[1]),                 foundation=candidate[0].split(":")[1],                 ip=candidate[4],                 port=int(candidate[5]),                 priority=int(candidate[3]),                 protocol=candidate[2],                 type=candidate[7],                 relatedAddress=None,                 relatedPort=None,                 sdpMid=msg.get("payload").get("sdpMid"),                 sdpMLineIndex=int(msg.get("payload").get("sdpMLineIndex")),                 tcpType=None             )             await peer_conn.addIceCandidate(new_ice)          if msg.get("type") == 'ANSWER' and local_user_id == msg.get("toUserId"):             print("ANSWER INCOMING")             await peer_conn.setRemoteDescription(                 RTCSessionDescription(sdp=msg.get("payload").get("sdp"), type=msg.get("payload").get("type")))  async def main():     task = asyncio.create_task(wsconsume('wss://192.168.10.146:9000/robopi_webrtc'))     await task   if __name__ == '__main__':     loop = asyncio.get_event_loop()     try:         loop.run_until_complete(main())     except KeyboardInterrupt:         loop.stop()         pass

Делаем проверку между браузером и пайтон скриптом, проверяем работоспособность, у нас должен создаваться datachannel и мы можем отправлять принимать сообщения между браузером и python скриптом:

проверка:

Управляющий компонент(Unity VR)

Для работы с WebRTC unity будем использовать библиотеку WebRTC for Unity

Добавляем ее по инструкции, там же приведены основные референсы по работе(чаcть не совсем корректна, поэтому нужно внимательно смотреть на сэмплы к пакету!)

добавление:

Cкрипты из браузера и Unity могут отправлять и принимать offer в обе стороны, т.е. могут быть инициаторами соединения, скрипт из Python может только принимать входящий offer и отправлять встречный offer после создания контекста, но инициатором соединения он не будет.

Дополняем наш UI в Unity новыми элементами:

Dropdown – для отображения Member.

Create Offer – для инициации WebRTC call.

Send Hello – для отправки сообщения, по нажатию на которую в datachannel будет отправляться типовое сообщение с датой/временем. Для отображения сообщений из datachannel будем писать их просто в debug log Unity.

Дополняем наш скрипт следующим образом:

Connection.cs
using System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using Unity.WebRTC; using UnityEngine; using UnityEngine.UI; using WebSocketSharp;  public class Connection : MonoBehaviour {      private GameObject uuid;     private WebSocket ws;     private ConcurrentQueue<string> incomingWebsocketMessages;     private string userId = "unknown";      private GameObject wscandidates;     private List<string> dropOptions;      private RTCPeerConnection webrtcConnection;     private RTCDataChannel dataChannel, remoteDataChannel;      private DelegateOnDataChannel onDataChannel;     private DelegateOnOpen onDataChannelOpen;     private DelegateOnMessage onDataChannelMessage;     private DelegateOnClose onDataChannelClose;     private DelegateOnIceConnectionChange onIceConnectionChange;     private DelegateOnIceCandidate onIceCandidate;      void Start()     {         uuid = GameObject.FindGameObjectWithTag("uuid");         incomingWebsocketMessages = new ConcurrentQueue<string>();          ws = new WebSocket("wss://192.168.10.146:9000/robopi_webrtc");         ws.SslConfiguration.EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12;          ws.OnOpen += (sender, e) =>         {             Debug.Log("OPEN WEBSOCKET");         };          ws.OnMessage += (sender, e) =>         {             if (e.IsText)             {                 incomingWebsocketMessages.Enqueue(e.Data);                 Debug.Log("Incoming websocket message:" + e.Data);             }         };          ws.OnClose += (sender, e) => {             Debug.Log("CLOSE WEBSOCKET:" + e.Reason);         };          dropOptions = new List<string> { "No candidates!" };         wscandidates = GameObject.FindGameObjectWithTag("candidates");         wscandidates.GetComponent<Dropdown>().AddOptions(dropOptions);          webrtcConnection = new RTCPeerConnection();          onDataChannelOpen = () =>         {             Debug.Log("OPEN LOCAL DATACHANNEL");             var welcome = "Welcome to Unity WebRTC! Local creation!";             dataChannel.Send(welcome);             dataChannel.OnMessage = onDataChannelMessage;         };          onDataChannel = channel =>         {             remoteDataChannel = channel;             Debug.Log("OPEN REMOTE DATACHANNEL");             var welcome = "Welcome to Unity WebRTC! Remote creation!";             remoteDataChannel.Send(welcome);             remoteDataChannel.OnMessage = onDataChannelMessage;         };          onDataChannelMessage = bytes =>         {             var messageText = System.Text.Encoding.UTF8.GetString(bytes);             Debug.Log("Incoming datachannel message: " + messageText);         };          onDataChannelClose = () =>         {             Debug.Log("CLOSE DATACHANNEL");         };          onIceConnectionChange = state => {             OnIceConnectionChange(webrtcConnection, state);         };          onIceCandidate = candidate => {             OnIceCandidate(webrtcConnection, candidate);         };      }      void Update()     {         if (incomingWebsocketMessages.TryDequeue(out var wsmessage))         {             var answer = JsonUtility.FromJson<WSMessage<string>>(wsmessage);              if (answer.type.Equals("USERID") && !answer.data.Equals(userId))             {                 userId = answer.data;                 SetUserId(userId);             }             else if (answer.type.Equals("NEWMEMBER") && !answer.userId.Equals(userId))             {                 string newcandidate = answer.userId;                 AddNewWsCandidate(newcandidate);             }             else if (answer.type.Equals("OFFER") && !answer.userId.Equals(userId))             {                 var incomingOffer = JsonUtility.FromJson<WSMessage<Offer>>(wsmessage);                 Debug.Log("INCOMING OFFER:" + incomingOffer);                  var desc = new RTCSessionDescription();                 desc.type = RTCSdpType.Offer;                 desc.sdp = incomingOffer.payload.sdp;                  wscandidates.GetComponent<Dropdown>().captionText.text = answer.userId;                  StartCoroutine(HandleCall(desc));             }             else if (answer.type.Equals("ICE") && !answer.userId.Equals(userId))             {                 var iceCandidate = JsonUtility.FromJson<WSMessage<Ice>>(wsmessage);                  Debug.Log("INCOMING ICE:" + answer);                  RTCIceCandidateInit init = new RTCIceCandidateInit();                 init.candidate = iceCandidate.payload.candidate;                 init.sdpMid = iceCandidate.payload.sdpMid;                 init.sdpMLineIndex = iceCandidate.payload.sdpMLineIndex;                  RemoteIceCandidate(webrtcConnection, new RTCIceCandidate(init));             }             else if (answer.type.Equals("ANSWER") && !answer.userId.Equals(userId))             {                 var incomingAnswer = JsonUtility.FromJson<WSMessage<Answer>>(wsmessage);                 Debug.Log("INCOMING ANSWER:" + incomingAnswer.payload.type + "  sdp:" + incomingAnswer.payload.sdp);                  var desc = new RTCSessionDescription();                 desc.type = RTCSdpType.Answer;                 desc.sdp = incomingAnswer.payload.sdp;                  StartCoroutine(ConsumeAnswer(desc));             }         }     }     public void AddNewWsCandidate(string candidate)     {         dropOptions.Add(candidate);         wscandidates.GetComponent<Dropdown>().ClearOptions();         wscandidates.GetComponent<Dropdown>().AddOptions(dropOptions);         wscandidates.GetComponent<Dropdown>().RefreshShownValue();     }      IEnumerator HandleCall(RTCSessionDescription desc)     {          webrtcConnection.OnIceCandidate = onIceCandidate;         webrtcConnection.OnIceConnectionChange = onIceConnectionChange;         webrtcConnection.OnDataChannel = onDataChannel;          var op = webrtcConnection.SetRemoteDescription(ref desc);         yield return op;          if (!op.IsError)         {             Debug.Log("Set Remote Description complete");         }         else         {             var error = op.Error;             Debug.Log("ERROR Set Session Description: " + error);         }          var op2 = webrtcConnection.CreateAnswer();         yield return op2;         if (!op2.IsError)         {             string remoteUuid = wscandidates.GetComponent<Dropdown>().captionText.text;              var wsAnswer = new WSMessage<Answer>             {                 userId = userId,                 type = "ANSWER",                 data = "",                 payload = new Answer(op2.Desc.sdp, "answer"),                 toUserId = remoteUuid             };              Debug.Log("CREATE ANSWER:" + JsonUtility.ToJson(wsAnswer));              ws.Send(JsonUtility.ToJson(wsAnswer));              Debug.Log("SEND ANSWER:" + JsonUtility.ToJson(wsAnswer));              yield return OnCreateAnswerSuccess(op2.Desc);         }         else         {             Debug.Log("ERROR Create Session Description:" + op2.Error.message);         }      }      IEnumerator OnCreateAnswerSuccess(RTCSessionDescription desc)     {         var op = webrtcConnection.SetLocalDescription(ref desc);         yield return op;          if (!op.IsError)         {             Debug.Log("Set Local Description complete");         }         else         {             var error = op.Error;             Debug.Log("ERROR Set Session Description: " + error.message);         }     }      IEnumerator ConsumeAnswer(RTCSessionDescription desc)     {          var op = webrtcConnection.SetRemoteDescription(ref desc);         yield return op;          if (!op.IsError)         {             Debug.Log("Set Remote Description complete");         }         else         {             var error = op.Error;             Debug.Log("ERROR Set Session Description: " + error.message);         }      }      public void CallWebRTC()     {         StartCoroutine(Call());     }      IEnumerator Call()     {         webrtcConnection.OnIceCandidate = onIceCandidate;         webrtcConnection.OnIceConnectionChange = onIceConnectionChange;          var option = new RTCDataChannelInit();         dataChannel = webrtcConnection.CreateDataChannel("datachannel", option);         dataChannel.OnOpen = onDataChannelOpen;         dataChannel.OnClose = onDataChannelClose;         webrtcConnection.OnDataChannel = onDataChannel;          var op = webrtcConnection.CreateOffer();         yield return op;          if (!op.IsError)         {              string remoteUuid = wscandidates.GetComponent<Dropdown>().captionText.text;              var wsOffer = new WSMessage<Offer>             {                 userId = userId,                 type = "OFFER",                 data = "",                 payload = new Offer(op.Desc.sdp, "offer"),                 toUserId = remoteUuid             };              Debug.Log("CREATE OFFER:" + JsonUtility.ToJson(wsOffer));              ws.Send(JsonUtility.ToJson(wsOffer));              Debug.Log("SEND OFFER:" + JsonUtility.ToJson(wsOffer));              yield return StartCoroutine(OnCreateOfferSuccess(op.Desc));         }         else         {             Debug.Log("ERROR Create Session Description: " + op.Error);         }      }      IEnumerator OnCreateOfferSuccess(RTCSessionDescription desc)     {         var op = webrtcConnection.SetLocalDescription(ref desc);         yield return op;          if (!op.IsError)         {             Debug.Log("Set Local Description complete");         }         else         {             var error = op.Error;             Debug.Log("ERROR Set Session Description: " + error);         }      }      public void SendDirectMessage()     {         var message = "Message from Unity " + DateTime.Now;         if (dataChannel != null && dataChannel.ReadyState == RTCDataChannelState.Open) dataChannel.Send(message);         if (remoteDataChannel != null && remoteDataChannel.ReadyState == RTCDataChannelState.Open) remoteDataChannel.Send(message);     }      void OnIceConnectionChange(RTCPeerConnection pc, RTCIceConnectionState state)     {         switch (state)         {             case RTCIceConnectionState.New:                 Debug.Log("IceConnectionState: New");                 break;             case RTCIceConnectionState.Checking:                 Debug.Log("IceConnectionState: Checking");                 break;             case RTCIceConnectionState.Closed:                 Debug.Log("IceConnectionState: Closed");                 break;             case RTCIceConnectionState.Completed:                 Debug.Log("IceConnectionState: Completed");                 break;             case RTCIceConnectionState.Connected:                 Debug.Log("IceConnectionState: Connected");                 break;             case RTCIceConnectionState.Disconnected:                 Debug.Log("IceConnectionState: Disconnected");                 break;             case RTCIceConnectionState.Failed:                 Debug.Log("IceConnectionState: Failed");                 break;             case RTCIceConnectionState.Max:                 Debug.Log("IceConnectionState: New");                 break;             default:                 break;         }     }      void OnIceCandidate(RTCPeerConnection webrtcConnection, RTCIceCandidate candidate)     {         webrtcConnection.AddIceCandidate(candidate);         Debug.Log("ADDED local ICE candidate:" + candidate.Candidate);         string remoteUuid = wscandidates.GetComponent<Dropdown>().captionText.text;         var iceCand = new WSMessage<Ice>         {             userId = userId,             type = "ICE",             data = "",             payload = new Ice(candidate.Candidate, (int)candidate.SdpMLineIndex, candidate.SdpMid, candidate.UserNameFragment),             toUserId = remoteUuid         };         ws.Send(JsonUtility.ToJson(iceCand));         Debug.Log("SEND ICE:" + JsonUtility.ToJson(iceCand));     }      void RemoteIceCandidate(RTCPeerConnection webrtcConnection, RTCIceCandidate candidate)     {         webrtcConnection.AddIceCandidate(candidate);         Debug.Log("ADDED remote ICE candidate:" + candidate.Candidate);     }      public void ConnectWebsocket()     {         ws.Connect();     }      public void DisconnectWebsocket()     {         ws.Close();     }      public void LoginWebsocket()     {         var hello = new WSMessage<string>         {             userId = "",             type = "LOGIN",             data = "",             payload = "",             toUserId = ""         };         ws.Send(JsonUtility.ToJson(hello));     }      public void SendNewmember()     {         var newmember = new WSMessage<string>         {             userId = userId,             type = "NEWMEMBER",             data = "",             payload = "",             toUserId = ""         };          ws.Send(JsonUtility.ToJson(newmember));     }      void SetUserId(string userId)     {         uuid.GetComponent<UnityEngine.UI.Text>().text = userId;     }  }  [Serializable] public class WSMessage<T> {     public string userId;     public string type;     public string data;     public T payload;     public string toUserId; }  [Serializable] public class Offer {     public string type;     public string sdp;      public Offer(string sdp, string type)     {         this.type = type;         this.sdp = sdp;     } }  [Serializable] public class Answer {     public string type;     public string sdp;     public Answer(string sdp, string type)     {         this.type = type;         this.sdp = sdp;     } }  [Serializable] public class Ice {     public string candidate;     public int sdpMLineIndex;     public string sdpMid;     public string usernameFragment;      public Ice(string candidate, int sdpMLineIndex, string sdpMid, string usernameFragment)     {         this.candidate = candidate;         this.sdpMLineIndex = sdpMLineIndex;         this.sdpMid = sdpMid;         this.usernameFragment = usernameFragment;     } }

Добавляем наши методы SendDirectMessage и CallWebRTC на созданные Button и можем приступить к проверке.

Проверка взаимодействия компонентов

Теперь можем провести проверку между браузером и Unity с двух сторон и между Unity и Python скриптом.

проверка:

Unity – Браузер (желательно в обе стороны)

Unity – Python:

Вуаля, мы видим, что сообщения успешно отправляются и принимаются, а это значит что у нас закончилась 3 часть.


Часть 4. Настройка WebRTC Media streaming

В предыдущих частях у нас получилось сделать datachannel между браузером-python-unity, теперь этот код пора обогатить медиаконтентом.

Серверный компонент

Добавим в созданную разметку main.html новые элементы для видео

main.html
... <div class="row-md-6">   <form class="form-inline">     <button id="startStream" class="btn btn-primary" type="submit">Start media streaming</button>   </form>   </div>   <div class="row-md-6">     <video autoplay id="myvideo" width="320" height="240" controls></video>     <video autoplay id="remoteVideo" width="320" height="240" controls></video> </div> ...

Доработаем наш скрипт следующим образом:

robo.js
... const constraints = {   video: true, audio: true }; var videoStream; ...  // Добавим в function initRTCPeerConnection() // video handling const remoteVideo = document.getElementById('remoteVideo'); peerConnection.addEventListener('track', async (event) => {   const remoteStream = event.streams[0];   remoteVideo.srcObject = remoteStream;   console.log('Received remote stream'); });  // Добавим старт медиа стриминга: function statrStream(){   navigator.mediaDevices.getUserMedia(constraints)   .then(stream => {     videoStream = stream;     stream.getTracks().forEach(track => {       peerConnection.addTrack(track, videoStream);       console.log("added track:" + track);     });       console.log("sucsess started media stream");     recreateOffer();   })   .catch(function(err){     console.log("errors when media stream");   });   attachVideoToScreen(); }  function recreateOffer(){   peerConnection.createOffer(function(offer) {     connection.send(JSON.stringify({'userId' : userId, 'type' : 'OFFER', 'payload' : offer , 'toUserId' : $("#offerTo").val()}));     peerConnection.setLocalDescription(offer);   },   function(error) {     console.log("error in offer creating:" + error);   }); }  function attachVideoToScreen(){   const localvideo = document.getElementById('myvideo');   if (videoStream != null) {     localvideo.srcObject = videoStream;     localvideo.onloadedmetadata = () => {       localvideo.play();     }     console.log("sucsess attached media stream");   } else {     setTimeout(attachVideoToScreen, 500);   } }  // Не забываем сделать кнопку активной: ... $( "#startStream" ).click(function() { statrStream(); }); ...

Теперь подключаем USB-камеру к компьютеру и можем сделать проверку работы видео/аудио между браузерами в обе стороны, я делаю между браузером компьютера и телефона и видим, что все идет успешно.

Исполнительный компонент(Python-скрипт на RPI)

При поступлении offer от Управляющего компонента нам нужно получить видео с USB-камеры и создать видеотрак, который добавить к webrtc-connection. В пакете aiortc для реализации передачи медиа есть специальные хелперы. Реализуем их в нашем Python скрипте:

part3.py
## добавим 2 метода: def force_codec(pc, sender, forced_codec):     kind = forced_codec.split("/")[0]     codecs = RTCRtpSender.getCapabilities(kind).codecs     transceiver = next(t for t in pc.getTransceivers() if t.sender == sender)     transceiver.setCodecPreferences(         [codec for codec in codecs if codec.mimeType == forced_codec]     )   async def add_video(peer_conn) -> None:     options = {"framerate": "30", "video_size": "640x480", "preset": "fast"}     relay_v = MediaRelay()     webcam = MediaPlayer("/dev/video0", format="v4l2", options=options)     video_sender = peer_conn.addTrack(relay_v.subscribe(webcam.video, buffered=False))     force_codec(peer_conn, video_sender, "video/H264")  ## И в раздел хендлинга оффера добавим вызов добавления видео и отправку оффера с новым кандидатом.              await add_video(peer_conn)                          offer = await peer_conn.createOffer()             await peer_conn.setLocalDescription(offer)             await websocket.send(json.dumps({"userId": local_user_id, "type": "OFFER", "data": "",                                              "payload": {"sdp": offer.sdp, "type": offer.type},                                              "toUserId": msg.get("userId")}))

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

Теперь можем провести проверку работы между Python и браузером. Если вдруг медиа траки принимаются в браузере, но видео не воспроизводится — то перезапускаем хром, так же можем пользоваться инструментом — chrome://webrtc-internals/

проверка:

Управляющий компонент(Unity VR)

С этой частью все несколько сложнее, для начала мы сделаем panel для отображения видео, для этого просто продублируем созданный ранее Connection Panel, переименуем его в Robo Video Panel, удалим из него все объекты и поместим в него 2 новых объекта:

  • Raw Image — для отображения видео из видеотрека, ставим тэг «remoteimage»

  • Audiosource — для аттача аудио из аудиотрека webrtc, ставим тэг «receivedaudiosource»(это на будущее)

Вот так:

и добавим собственно само добавление видео/аудио из траков:

Connection.cs:
... private RawImage remoteImage; private DelegateOnTrack webrtcСonnOntrack; private AudioSource receivedAudioSource; ...  // Аттачим объекты по тегам в Start методе и добавим делагат на событие добавления траков: void Start() …. remoteImage = GameObject.FindGameObjectWithTag("remoteimage").GetComponent<RawImage>(); receivedAudioSource = GameObject.FindGameObjectWithTag("receivedAudioSource").GetComponent<AudioSource>(); …. webrtcСonnOntrack = e =>         {             if (e != null)             {                 Debug.Log("NEW TRACK Recived: " + e.Track.Id);             }             if (e.Track is VideoStreamTrack video)             {                 video.OnVideoReceived += tex =>                 {                     remoteImage.texture = tex;                 };             }             if (e.Track is AudioStreamTrack track)             {                 receivedAudioSource.SetTrack(track);                 receivedAudioSource.loop = true;                 receivedAudioSource.Play();             }         };  // Теперь в хендлинге оффера добавим на OnTrack делегат webrtcСonnOntrack IEnumerator HandleCall(RTCSessionDescription desc) {     webrtcConnection.OnTrack = webrtcСonnOntrack; ... // И очень важный пункт после хендлинга оффера — провести WebRTC.Update(). ... StartCoroutine(WebRTC.Update()); } 

PS: так же можно сделать и трансляцию с камеры из Unity в медиа стрим если хочется смотреть смотреть в браузере, что делается в Unity:
MediaStream videoStream = customCamera.CaptureStream(1280, 720); MediaStreamTrack trackLocal = videoStream.GetVideoTracks().First(); webrtcConnection.AddTrack(track, videoStream); Debug.Log("ADDED OUTGOING TRACK:" + trackLocal.Id);

Проверка взаимодействия компонентов

Теперь проверим взаимодействие компонентов, сначала Unity-браузер, потом Unity-Python, как видим обмен контекстом успешен и видео с камеры отображается в Unity.

проверка:

Unity – Python:

PS: я использовал видеокамеру, которая по идее должна выдавать медиа с разрешением 1280х720 30fps, однако по факту она настроена на 640х480, т.к. даже с оптимизированными настройками на ffmpeg мне не удалось найти режима с минимальной задержкой видео. Возможно дело в камере, возможно в кривых руках.

options = {"framerate": "25", "input_format": "mjpeg", "video_size": "1280x720", "preset": "ultrafast", "tune":"zerolatency"}

PS2: судя по инфо по aiortc с камеры так же можно получить аудиопоток, однако у меня это с помощью хелпера MediaPlayer() с alsa не получилось, поэтому я оставил этот вопрос.

Очередная часть закончена, пора приступить к реализации управления.

Продолжение в следующей статье – Управляем роботами из VR. Продолжение 2


ссылка на оригинал статьи https://habr.com/ru/articles/730450/