xor

二兎を得るか、一兎をも得ざるか

ビデオチャット作るときの逆引きTipsまとめ

リモートワークのため社内用ビデオチャットツールを先日作ったのですが、せっかくなので一部をオープンソースにしてGitHubにあげておきました。

github.com

※リファクタしてないので見辛いです

SPAなのでGiHub Pages上でそのまま利用もできます。

ukkz.github.io

(OGP設定しないと…)
一旦開発が落ち着いたので、作業時のメモを簡単に整理して公開しておきます。

構成

  • Vue.js (@vue/cli v4.3.1)
  • Vuetify @2.3.1
  • Vuex @3.4.0
  • Vue-Router @3.3.4
  • SkyWay

まとめ一覧

ビデオストリームの情報を知りたい

使ったところ:実際の映像アスペクト比とブラウザ画面のアスペクト比から複数映像配置時の自動最適化 

VideoTrack.getSettings()でいろいろ情報がとれました。

// メディアストリームオブジェクト
const my stream = new MediaStream;
// ビデオトラックの配列(ないこともある)
const video_tracks = mystream.getVideoTracks();
// 少なくとも1つのビデオトラックがあるとき、そのトラックの情報を見る(空配列はtrue判定になるので必ずインデックス0を確認する)
if (video_tracks[0]) {
  const track_settings = video_tracks[0].getSettings();
  console.log( track_settings );
  /* 検出例(640x480の映像のとき)
    aspectRatio: 1.3333333333333333,
    deviceId: "1d3fa9…",
    frameRate: 30.000030517578125,
    groupId: “66fa41…",
    height: 480,
    resizeMode: "none",
    width: 640,
  */
}

アス比は循環小数になることがあるためあまり使い勝手がよくなさそうです。
ストリームやトラックに関しては以下がたいへん詳しく書かれておりました。

note.com

ビデオタグに関して幅と高さを知りたい・設定したい

使ったところ:映像にcanvasをオーバーレイさせるときのcanvasサイズの指定 

<video id="my-video" :srcObject.prop="my_stream" width="160px" height="120px"></video>
<style>
#my-video {
  width: 320px;
  height: 240px;
  background-color: red;
}
</style>

上記のDOMの場合のビデオタグの大きさは、インライン指定よりもCSS指定のほうが優先されます。
実際のビデオトラック(映像)はCSSで指定されたサイズ(CSSしていなければインライン指定サイズ)の枠内に収まるように配置されます。
上記で枠内の余白部分はbackground-colorで指定された色になります。
CSSもインラインもどちらも指定されていなければ、ビデオトラックのオリジナルサイズで配置されます。

const video_element = document.getElementById('my-video’);
// 以下、取得できる様々なサイズの例
console.log( video_element.width );        // 160px(インライン指定の幅・未指定ならundefined)
console.log( video_element.height );       // 120px(インライン指定の高さ・未指定ならundefined)
console.log( video_element.offsetWidth );  // 320px(CSS指定の幅・mounted前ならundefined)
console.log( video_element.offsetHeight ); // 240px(CSS指定の高さ・mounted前ならundefined)
console.log( video_element.videoWidth );   // 640px(ビデオトラックの実際の幅・メタデータ受信前ならundefined)
console.log( video_element.videoHeight );  // 480px(ビデオトラックの実際の高さ・メタデータ受信前ならundefined)

メディアストリームをビデオタグに動的に追加したい [Vue.js]

使ったところ:相手からストリームを受信する前にvideoタグをDOMにマウントしたいとき 

<video :srcObject.prop="my_stream"></video>

:srcObject.propdataで定義した変数を指定しておいて、相手からストリーム受信したり自分がgetUserMediaしたりしたタイミングでメディアストリームオブジェクト(空の場合new MediaStream()で生成)を入れればよいです。
Vue歴2ヶ月ですが、リアクティブっていいなあと思いました(にわか)

ビデオタグのプロパティについて

使ったところ:ビデオタグぜんぶ(ほぼテンプレ構文) 

<video :srcObject.prop="my_stream" muted autoplay playsinline></video>

muted:音声再生しません。自身のストリームだとハウリング起こすので必須の属性です。
autoplay:mutedとともに有効のとき自動再生します。カメラからのストリームだとこれがないとそもそもデータが流れないので表示されません。インライン(videoタグ内)で指定しない場合は、スクリプト内のどこかで明示的にplay()を実行しないと再生されません。
playsinline:インライン(Webページ上の表示そのまま)で再生します。これがないとiOSでは自動的に映像がフルスクリーンになってしまうのでほぼ必須です。

ボタンの背景色にあわせて文字色の白黒を変えたい [Vuetify]

使ったところ:ボタンのデザイン全般 

Vuetifyのv-btnでは基本的にdarkプロパティをつけておくと良さそうです。
背景色にあわせて文字色も自動で変えてくれる唯一のコンポーネントのようです。

<v-btn rounded dark color="blue darken-4" @click=“any">
  <v-icon>mdi-message-text</v-icon>
  チャット
</v-btn>

v-dialogを使った子コンポーネントを親コンポーネント側から開閉させたいが反応しない [Vuetify, Vue.js:v-model]

使ったところ:ボタンクリックでテキストチャットダイアログを開くとき 

v-dialogv-ifなどではなくv-modelで開閉します。
(開いているときにダイアログ外側をクリックorタップで閉じれるようにするため)
このv-dialogを用いた子コンポーネントを親コンポーネントから制御するときは、computedにおいてゲッターとセッターを利用するとうまくいきます。

qiita.com

<template>
  <v-container>
    <!-- v-dialogを開くボタン -->
    <v-btn @click.stop="chat_open = true">チャットを開く</v-btn>
    <!-- :showで開く・toggleイベント受信で閉じる -->
    <ChatWindow :show="chat_open" @toggle="chat_open = $event" />
  </v-container>
</template>

<script>
  data() {
    return {
      chat_open: false,
    }
  }
</script>
<template>
  <v-dialog v-model="toggle">
    // 略
  </v-dialog>
</template>

<script>
  name: 'ChatWindow',
  props: {
    show: {
      type: Boolean,
      required: true,
      default: false,
    },
  },
  computed: {
    toggle: {
      get() { return this.$props.show },
      set(v) { this.$emit('toggle', v) }
    },
  },
</script>

ゲッターはprops変数を受け取り、セッターは$emitで親コンポーネントに変化を通知します。
親側では変化を受信してpropsに与えた変数を子側から受け取った$event値に変更します。

チェックボックスなど設定値の変化でVuexの値を変えたい [Vue.js:v-model]

使ったところ:設定ダイアログで特定機能をスイッチボタンでON/OFFするとき 

v-dialogの例と同様で、入力系コンポーネントv-modelを使うときはゲッターとセッターを使います。
ゲッターは$store.stateから取り出し、セッターでは$store.commitでVuexにセットします。

<template>
  <v-container>
    <v-switch v-model="speech_onoff" :label="` 音声認識:${(speech_onoff)?'有効':'無効'} `"></v-switch>
  </v-container>
</template>

<script>
  computed: {
    speech_onoff: {
      get() { return this.$store.state.config.speech_recognition },
      set(onoff) { this.$store.commit('speechConfig', onoff) },
    },
  },
</script>

テキストチャットで長文入力したときに折り返しできない・overflowやword-breakが効かない [Vuetify]

使ったところ:テキストチャット内の吹き出し部分(個別の発言) 

pタグのようなブロック要素で囲みtext-wrapクラスを適用させましょう。
(Vuetifyのpタグはデフォルトでmargin-bottomが入ってしまうのでmb-0クラスと組み合わせるとよい)
text-align: left;にしておくと折り返しても左寄せで表示してくれます。

文字起こししたい

使ったところ:そのまま(喋った内容を文字列にしてテキストチャットに逐次送信) 

SpeechRecognition(Web Speech API)が便利です。すごく簡単。

qiita.com

音声入力を明示的に指定できるわけではない(というか選べない)みたいなので、マイクミュート時は手動でabortすべきですね。
2020年6月現在もChromiumベースのブラウザしか対応していないので注意です。
Samsung, Baidu, QQといった中韓系ブラウザは利用者層が音声入力を好むためか対応しておりなかなか興味深いです。

背景切り抜きなどの加工をしたい

使ったところ:画面共有とカメラ映像の合成(公開してないコンポーネントで利用) 

  • RGB値をキーにして合成(クロマキー)しているもの

qiita.com

qiita.com

  • 静止時と人物登場時の差分を抽出しているもの

qiita.com

  • OpenCV.jsを使って白黒のマスクを作り、輪郭抽出で背景を消すもの
    OpenCV.jsはminifiedでも容量がけっこう大きいのでロード時には注意です。

qiita.com

今回オープン版には実装してませんが内部向けで消したり合成したりの用途にはOpenCV.jsのパターンを使いました。
findContoursで輪郭抽出後、一番外の階層のある程度の大きさの輪郭をアルファで塗りつぶすだけで、クロマキー色を指定したりしなくても消したり背景合成したりすることができました。
上記参考記事では画像に対して利用されていますが、映像に対しても15fps程度の速さで処理できたのでそのうち別途記事かきます。

スクリーン共有でストリームを置き換えたら音声が送られなくなった

使ったところ:画面共有とカメラ映像の切替時 

getUserMediaで音声取得 + getDisplayMediaで映像取得 を組み合わせたメディアストリームを作ります。

www.m3tech.blog

const display_stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  audio: false
});
const camera_stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true
});
// ディスプレイ映像と外部音声のトラックから新しくメディアストリームを作成
const merged_stream = new MediaStream([ display_stream.getVideoTracks()[0], camera_stream.getAudioTracks()[0]  ]);
// または、既存のカメラからのストリームに画面キャプチャ用のトラック追加してもいい(受信側で区分する必要あり)
camera_stream.addTrack( display_stream.getVideoTracks()[0] );

スマホとPCでエレメントのサイズ・デザインを簡単に切り替えたい [Vutify]

使ったところ:レスポンシブデザインとして全体 

v-if$vuetify.breakpoint.smAndDownを組み合わせましょう。
ブレイクポイントはほかにもたくさんあります。

vuetifyjs.com

v-ifv-elseで同一機能を異なるデザインで作成して並列に配置すると画面幅によって表示切り替えが可能です。
以下はボタンの例で、PCではアイコン+文字だが、smサイズ以下だとアイコンのみのボタンが表示されます。

<v-list-item-action>
  <v-btn v-if="$vuetify.breakpoint.smAndDown" fab depressed small @click="open = true"><v-icon>mdi-open</v-icon></v-btn>
  <v-btn v-else rounded depressed @click="open = true"><v-icon>mdi-open</v-icon>開く</v-btn>
</v-list-item-action>

ビデオを上下中央センタリングしたい

使ったところ(トリミングなし):自分の映像の確認用・チャットルーム内で話者の映像を拡大する用
使ったところ(トリミングあり):チャットルーム内での基本的な映像表示 

単純なCSSだけどメモ
一番外側の枠となるdivはあらかじめサイズが決まっている(100%指定とかでもよい)ものとし、その中にビデオを配置するときにどうしましょ?という話です。

<div class="video-frame">
  <video :srcObject.prop="local_media_stream" muted autoplay playsinline></video>
</div>

上記DOMが基本構造とします。

トリミングなし(上下または左右に余白を作る)

div.video-frame {
  position: relative;
  background-color: black;
  width: 100%;  // 枠幅:固定値でもよい
  height: 100%; // 枠高さ:固定値でもよい
}

div.video-frame video {
  position: absolute;
  top: 50%; left: 50%;
  transform: translateY(-50%) translateX(-50%) scale(-1, 1);
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
}

ビデオ側でwidthheightautoにしつつも最大サイズを親要素の100%にしておくとアスペクト比を変えずにフィットさせられます。
このままだと左上に寄ってしまうので、topleftを使って親要素の上左からそれぞれ半分(つまり真ん中)に寄せつつ、translateで自要素の幅と高さ半分ずつ戻すことでセンタリングさせています。

また、センタリングとは関係ないですが、scale(-1, 1)は左右フリップして鏡状態にしてくれるので自分のカメラ映像の時のみ使います。transformは多重に適用できないため、別のクラスなどでtransform: scale(-1, 1);を指定してもうまく反映されません。そのためここでまとめて指定しています。

余白は外枠であるvideo-frameクラスで指定した背景色になります。

トリミングあり(上下をフィットさせ左右をカットする)

div.video-frame {
  position: relative;
  background-color: black;
  width: 100%;  // 枠幅:固定値でもよい
  height: 100%; // 枠高さ:固定値でもよい
  overflow: hidden; // はみ出ても表示させない
}
div.video-frame video {
  position: absolute;  
  left: 50%;
  transform: translateX(-50%);
  width: auto;
  height: 100%;
}

width: auto;height: 100%;で、アスペクト比を維持したまま高さが外枠に合わせられます。
そのあと左右方向に対してleft: 50%;translateX(-50%)でセンタリングさせています。

トリミングあり(左右をフィットさせ上下をカットする)

div.video-frame {
  position: relative;
  background-color: black;
  width: 100%;  // 枠幅:固定値でもよい
  height: 100%; // 枠高さ:固定値でもよい
  overflow: hidden; // はみ出ても表示させない
}
div.video-frame video {
  position: absolute;  
  top: 50%;
  transform: translateY(-50%);
  width: 100%;
  height: auto;
}

上下と左右、widthheightを入れ替えると左右方向のフィッティングおよび上下センタリングにも対応できます。