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

Pythonでwindows apiを呼ぶ

呼ぶ = call

ウィンドウサイズをどうかしたかった。

Jupyter と Pythonwindows api 触れたら楽だね。pywin32と足りない分は自分でctypes使えばできる。

miniconda をインストールしたらもうpywin32も入ってた。

ウィンドウのサイズはスナップしたときとそうでないときで違うらしい、よくわからん。

  • DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS )
  • GetWindowRect()
  • GetClientRect()

どれもそのままではMoveWindowで使える値は取れないみたいだった。

gist.github.com

ウィンドウにしるしをつけようと思ったけどできなかった

OBS Studioのウィンドウキャプチャがウィンドウを見つける方法は、

  • タイトル
  • 実行ファイル名
  • なるべく一致 (??)

ということで、複数のChromeウィンドウがあったときに配信用と定めたものにキャプチャを限定できないか考えてみた。

タイトルは、ウィンドウタイトルのことだと思われる。windows api の setWindowTextで書いてしまえばいいと思った。しかしChromeの場合、アクティブなタブが変わる度にそのタブのdocument.Title + "- Google Chrome"になってしまうのでダメだった。

実行ファイル名は、ハードリンクで別名のexeを作ってそれから起動したらいけるんじゃないかと思ったけどchrome.exeのままだった。ダメだった。どういう仕組みかはわからなかった。

ということであきらめた。

https://www.reddit.com/r/PowerShell/comments/6lg1tm/possible_to_override_application_window_title/

twitchでスクリーンキャストストリーミングをはじめた

OBS Studio を使ってる。

配信用に設定したChromeを用意した。「ハードウェアアクセラレーションが使用可能な場合は使用する」がONになっているとOBSでウィンドウキャプチャができなかった。ユーザーディレクトリごと別に分けてしまって、"設定"画面で、ハードウェアアクセラレーションをOFFにした。それと配信に映ってしまわないように、google アカウントで同期するものを絞っておいた。--user-data-dir=pathのオプションを追加したショートカットファイルを作った。Windowsのタスクバーに追加した。ショートカットファイルのオプション違いはスタートメニューやタスクバーでまとめられてしまうけど、Chromeの --user-data-dir や、--profile 違いのショートカットキーはまとめられない。

Twitchのコミュニティガイドラインに目を通した。

Twitchユーザーの多様な年齢層や国際的なコミュニティの文化的背景を考慮

外出時またはショッピングモールおよびレストランに出かける時のような服装の着用が適切です

御意。

Vue.js v-for template の v-bind:key

virtual dom の nodeには一意のキーが必要 componentはnamespaceを作るけど、templateは作らない。 なので、例えば一つの.vueファイルの中で、<template v-for=が2つあったらそのtemplateの子要素にはその.vueファイルでuniqueになるようにkeyを与えなきゃならない。v-bind:key="'list1-' + index" v-bind:key="'list2-" + indexみたいに。 templateが2つじゃなくても、templateの子が2つ以上だったりしても同じ。だと思う。

と理解した。

forum.vuejs.org

JavaScriptの時刻

moment.jsを使っとけばいいのだけれど、JavaScriptのDateがどういうものか調べた。

Dateオブジェクトは、UNIX epochからのミリ秒をデータとして持つ。 UNIX epoch = 1970 1/1 00:00:00 (UTC)

引数なしコンストラクタcallのときは現在時(UTC)。

ホストシステムのタイムゾーン = 現在のタイムゾーン Intl.DateTimeFormat().resolvedOptions().timeZone1

Date.parseは、ISO8601の書式以外ではブラウザの実装依存の結果になる。moment.jsを使っとけばいい。

memolog.org