OBSのブラウザソースは、Chromium Embedded Framework (CEF)で出来ているそうだ。ローカルファイルのHTMLをロードすることもできる。それを使ってAndroidスマホのカメラからの映像を表示しようと思った。WebRTCだ。
OBSのブラウザソースで表示したHTMLは、ボタンをクリックしたりテキスト入力したりできるがやりにくい。ページが読み込まれたら自動でWebRTCの接続をするようにした。
GET /offer
スマホ側がofferポーリングするPOST /offer
OBS側がofferをポストするGET /answer
スマホ側がanswerをポーリングするPOST /answer
OBS側がanswerをポストする
スマホ側もOBS側もローカルファイルからHTMLを読み込むので origin が "null"になる。CORSヘッダーで許可する必要がある。"null"の場合は、"*"ワイルドカードでの許可はできない。
from http.server import BaseHTTPRequestHandler, HTTPServer class Handler(BaseHTTPRequestHandler): offer = None answer = None def send_corsHeaders(self): self.send_header("Access-Control-Allow-Origin", "null") self.send_header("Access-Control-Allow-Method", "POST, GET") self.send_header("Access-Control-Allow-Headers", "*") def do_OPTIONS(self): self.send_response(200) self.send_corsHeaders() self.end_headers() def do_GET(self): if self.path == '/offer' and self.offer: size, data = self.offer elif self.path == '/answer' and self.answer: size, data = self.answer else: self.send_response(204) self.send_corsHeaders() self.end_headers() return self.send_response(200) self.send_corsHeaders() self.send_header("Content-Type","application/json") self.send_header("Content-Length", size) self.end_headers() self.wfile.write(data) def do_POST(self): size = int(self.headers["content-length"]) data = self.rfile.read(size) if self.path == '/offer': self.__class__.offer = [size, data] elif self.path == '/answer': self.__class__.answer = [size, data] else: self.send_response(204) self.send_corsHeaders() self.end_headers() return self.send_response(200) self.send_corsHeaders() self.end_headers() HTTPServer.allow_reuse_address = True HTTPServer.timeout = None with HTTPServer(("0.0.0.0", 8080), Handler) as server: server.serve_forever()
スマホ側のJavaScriptコード。OBS側もほぼ一緒。Androidの保存場所はダウンロードフォルダ(Download)にした、他の場所は許可のこととかわからんかったから。Chromeからは file:///sdcard/Download/camera.htmlでアクセスできる。file:///はスラッシュ3つ。開発中はパソコンにUSBで繋いでChromeのdevtoolsのremote devicesから開いて操作した。
SDPのメッセージには改行が含まれているので、JSON.stringifyしてもJSON.parseでエラーになってしまう。stringify、parseメソッドの第2引数にreplacer, reviver関数を渡して改行を処理した。
LAN内で接続するだけなので、RTCPeerConnectionにTURNサーバーを設定しない。それでもiceGatheringのために関数を抜ける必要があるので、awaitでiceGatheringStateがcompleteになるのを待つ。
const pc = new RTCPeerConnection(); const mediaConstraints = { video: { facingMode: "user", width: 200, height: 200 } }; const url_offer = "http://192.168.1.2:8080/offer"; const url_answer = "http://192.168.1.2:8080/answer"; const sleep = ms => new Promise(r => setTimeout(r, ms)); const replacer = (_, v) => { if (typeof v == "string") { return v.replace(/\r\n/g, "\n"); } return v; }; const reviver = (_, v) => { if (typeof v == "string") { return v.replace(/\n/g, "\r\n"); } return v; }; /* main */ (async () => { // attach stream const ms = await navigator.mediaDevices.getUserMedia(mediaConstraints); pc.addTrack(ms.getTracks()[0], ms); // build SDP pc.setLocalDescription(await pc.createOffer()); const msg = await new Promise(rslv => { const f = () => { if (pc.iceGatheringState == "complete") { pc.removeEventListener("icegatheringstatechange", f); rslv(pc.localDescription); } }; pc.addEventListener("icegatheringstatechange", f); }); // send SDP let res = await fetch(url_offer, { mode: "cors", method: "POST", body: JSON.stringify(msg, replacer), headers: { "Content-Type": "application/json" } }); // polling answer let ans; while (true) { const res = await fetch(url_answer, { mode: "cors", method: "GET" }); if (res.status === 200) { ans = await res.text().then(txt => JSON.parse(txt, reviver)); break; } await sleep(1000); } pc.setRemoteDescription(ans); })();
その他
Chromeのビデオの自動再生は、mutedの場合だけ有効。1
fetch api の response.json()に reviverがない理由はBody.jsonがネイティブで実装されているから2