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