OBS Studio の browser source に スマホのカメラ映像を出す

OBSのブラウザソースは、Chromium Embedded Framework (CEF)で出来ているそうだ。ローカルファイルのHTMLをロードすることもできる。それを使ってAndroidスマホのカメラからの映像を表示しようと思った。WebRTCだ。

OBSのブラウザソースで表示したHTMLは、ボタンをクリックしたりテキスト入力したりできるがやりにくい。ページが読み込まれたら自動でWebRTCの接続をするようにした。

github.com

Pythonでこの用途限定のシグナリングサーバーを書いた。

  • 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