Первая статья – Управляем роботами из 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
