まだプログラマーですが何か?

プログラマーネタ中心。たまに作成したウェブサービス関連の話も https://twitter.com/dotnsf

タグ:mediadevices

かなり自分向けですが、タイトル通りのウェブサービスを作ってみました。


【背景】
まずこのサービスを作った背景です。COVID-19 による在宅勤務やリモート会議が主流となる中で、ZOOM や WebEx などのウェブ会議サービスが多く使われるようになりました。このウェブ会議、サービスによって機能の違いはありますが、概ね以下のような機能を持っています:
・PC やスマホを使い、インターネット経由で仮想的な会議室に入って会議する
・音声は全員でリアルタイムに共有する
フロントカメラを使うことで参加者全員の表情を確認しながら進めることができる
ホスト役の PC 画面を全員で共有しながら会議できる(画面を共有する PC は後から変更可)

video_kaigi


これらの特徴によって、情報共有や伝達事項など「通知」目的のリモート会議であれば目的はほぼ達成できると思っています。通知したい人の画面を使ってプレゼンテーションを行い、その様子を他の参加者が音声と一緒にリアルタイムに確認し、質問なども行うことができるからです。
2020042601


一方、この形式ではまだリモート開催が難しい会議形態もあります。情報共有や伝達事項ではなく、ハンズオントレーニングなど「参加型」目的の場合です。ホスト役の人の画面を使ってハンズオン資料や作業内容を共有するところまでは何も変わりませんし、その様子を見ながら参加者が何の問題もなく作業を進めていける場合や、問題が発生しても自身で解決できる場合は問題になることもありません。Google Drive や Visual Studio Code Live Share のような仕組みを使うことで、全員で1つのファイルを同時に編集することはできます。この形がとれる場合においては音声と併用することでリモートサポートも可能になる範囲だと思っています。

ただローカル PC を使って作業する場合、特に参加者のスキルが必ずしも充分でない場合や、そうでなくても想定外のエラーが発生してしまった場合など、ウェブ会議では解決が途端に難しくなってしまいます。オンラインのウェブ会議ではなく、一箇所に(物理的に)集まって行う形式であれば、その場で本人の画面を覗き込めばエラー内容を確認できます。そのエラー内容が入力ミスなどのケアレスミスであれば正しい入力内容を指摘してあげるだけで解決できます。一方、入力内容が正しいのに想定外のエラーが発生しているようであれば、エラーメッセージやログ内容を参考にググったりして調べることになると思うのですが、画面が見えない限り、その場合分けすらも難しいという問題があります。また「エラーメッセージやログ内容を参考にググったり・・」と書きましたが、ウェブ会議で参加者のエラーメッセージやログ内容を正確に知るのは(その参加者のスキルレベルにもよりますが)かなり面倒です。実際にはエラー画面を共有してもらえればいいのですが、参加者がその手順を理解しているとは限りませんし、PC 画面を参加者全員に知られることに抵抗を感じる人もいると思われます。またその作業中は自分の(ホストの)画面を共有できなくなるので他の参加者の作業が止まってしまう可能性も危惧する必要があります。

更には、そもそもまったくセミナーに付いてこれない人がいて、序盤で躓いたまま全然進められていなかったりした場合でも、ウェブ会議のホスト側ではそのような人が存在していることに気付くことすらかなり困難です。
2020042602


比較的初心者を対象とするようなハンズオン勉強会を行うケースなど、上述のようなケースが想定されるオンライン会議をスムーズに行うには既存のウェブ会議システムだけでは難しいのではないか、、と考えていました。


【解決策の仮説】
上述のようなケースを解決するにはどうすればよいか? この問に対する明確な解答ではないのですが、1つ仮説を考えました:
従来のウェブ会議の逆を行うシステムがあれば解決するのではないか?

つまりホストの画面を参加者全員で共有するのではなく、参加者全員の画面の様子をホストが見れるようになっていれば、参加者側の操作でエラーが起こってもホストからエラーメッセージを確認できるし、作業についていけなくなったとしてもその様子をホスト側が確認できるのではないか?

この内容はあくまで仮説ですが、理屈の上では問題が解決しそうな気もしています。 この仮説を検証するための仕組みとして、ウェブ会議の逆(ホスト以外の参加者全員の画面をホストと共有する)を実現できる検証サービスを作ってみました。まだ使い勝手に難があることも理解していますが、仮説を検証するための機能までは実装できていると思っていて、特別なアプリケーションのインストールも必要としない形で実現できているので検証用に公開します。


【作ったサービス】
公開している URL は以下で紹介しますが、オープンソースとしても公開しています。ソースコードはこちらです:
https://github.com/dotnsf/screen_collect

また、このサービスを利用するには Windows または macOS で比較的最近の Chrome ブラウザを利用している必要があります。後述する MediaDevices インターフェースを使って実装している関係で、このインターフェースをサポートしていないスマホやタブレットのブラウザでは動きません。また他のブラウザでは未検証です。

以下、使い方を紹介します。


【使い方】
最初に、このサービスは実行順を守って動かす必要があります。最初にホスト側が会議室を準備し、その準備後にゲストが参加用 URL にアクセスすることで正しく実行されます(ホストが会議室を準備する前にゲストが URL にアクセスしてもその画面は共有対象になりません)。

まずホスト役の人が対応ブラウザを使って以下の URL にアクセスします(なおこのデモ用サービスでは Basic 認証は ID: admin, PW: password です):
https://screen-collect.mybluemix.net/view?room=XXXXXXXX

XXXXXXXX 部分は会議 ID のようなもので、ユニークな文字列を指定する必要があります(他の人と同じ値を使うと挙動がおかしくなります)。例えば日付や自分の名前を使って、以下の例のような URL を指定してください:
https://screen-collect.mybluemix.net/view?room=dotnsf-20200426

この時点では正しくアクセスできていても画面には何も表示されません(こんな感じの画面になれば正しくアクセスできています):
2020042601


この画面を使ってゲストの画面を共有することになるので、この画面を消さずに残しておいてください。またホストはこの画面をリロードしないよう気をつけてください(上述の順序の都合で、ホスト側ウィンドウをリロードした場合はゲスト側ウィンドウもリロードしてもらう必要があります):

最後にホストからゲストに参加用の URL を(メールやメッセンジャー等で)知らせる必要があります。ゲストへは上記 URL から "view" を除いた文字列を指定して通知してください。上記例の場合だと、この URL を教えることになります:
https://screen-collect.mybluemix.net/?room=dotnsf-20200426


ゲスト全員へ参加用 URL を通知し終わったらホスト側の準備は完了です。

ホスト側の準備が完了後にゲストが通知された URL にアクセスします(ゲストは Basic 認証なしでアクセス可能です)。なお試験的に一台の PC でホストとゲスト両方の画面をテストしたい場合は MediaDevice インターフェースのサポートの都合もあり、ホスト画面を FireFox で、ゲスト画面を Chrome で表示するようにしてください。

するとゲストには以下のような画面が表示されます:
2020042601


Room には URL で指定された会議 ID が表示されています。もしホストが URL を間違えて通知していても、ここで会議 ID を正しく修正して続行することで以下正しく実行されます。

そして Your name .. と書かれたフィールドに参加者の名前(本名でもニックネームでも可。ホストの人がわかる名前)を入力して Start ボタンをクリックします。
2020042602


すると画面には入力した名前が表示されますが、同時に別ウィンドウで画面共有ダイアログが表示されます。ここでゲストは共有範囲を選択します。共有範囲はアプリケーションウィンドウの単位でも、Chrome ブラウザのタブの単位でも、また全画面を選択することも可能です(以下の例では全画面を選択しています):
2020042603


共有範囲を選択したら「共有」ボタンをクリックします:
2020042604


するとゲスト画面では画面共有ダイアログが消え、代わりに小さな共有ウィンドウが表示されます(ゲストはこのウィンドウ画面を消さないよう注意してください):
2020042605


これでゲスト側の準備も完了しました。この後、ゲストは自由にアプリケーションを使ってください(下図ではゲストが Visual Studio Code を起動してプログラミングしている様子にしています):
2020042606


(実際のオンライン勉強会などではホストとゲストが事前にここまで行った上で実際のハンズオン作業に入ってもらう想定です)


ここで改めてホスト側の画面を確認します。さっきまで何も表示されていなかったホスト画面でしたが、ゲストが共有設定を完了すると、ゲストの名前とその画面がパネル表示されているはずです。またゲストの画面は(このデモサービスでは)5秒おきに更新されます:
2020042602


更に別のゲストが共有設定を完了するとパネルが追加され、以下のような感じになります。ホストの1画面内にゲストの画面が追加されて一覧できるようになり、(5秒おきですが)そこで行われているオペレーションの内容も確認できます。パネルはゲストの人数に応じて(共有設定が完了するたびに)追加されていきます:
2020042603


パネル内画面は縮小表示されていますが、画像を選択してクリックするとゲストのスクリーンと同じ縦横比率で拡大して表示されます。これでゲスト側でエラーメッセージなどが表示されていてもホスト側から視認することができるようになりますし、オンラインセミナーに付いてこれなくなって遅れ気味の人もホスト側から確認できるようになる、と思っています:
2020042604


ゲスト側は Chrome のゲストウィンドウを消すか、以下のダイアログの「共有を停止」をクリックすることで画面共有を停止できます:
2020042607


以上がサービスの使い方です。特別なアプリケーションをインストールすることなく、ブラウザだけで複数ユーザーのスクリーン内容をホストに共有する、という「ありそうでなかった※」サービスだと思っています。

※参加者の表情をカメラで映して同時に共有する、という機能は多くのウェブ会議システムに装備されていますが、参加者のスクリーンを共有する機能はなぜか付属していない機能だと思っています。

今後リモート勉強会などであらかじめこの準備をしておくことで、トラブルシューティングやリモートサポートに活用できないかと考えています。また上述のようにソースコードも公開しているので、興味ある方は専用環境を構築してお使いいただいても構いません。

以下、実現方法に関する技術的な補足説明です。興味ある方は引き続きどうぞ。


【どうやって実現しているか】
まず、このサービスはホストもゲストもウェブブラウザだけで(アプリケーションのインストールなしで)実現しています。また HTTP(s) 以外のプロトコルは使っていません。したがって比較的多くの環境で利用できるようになっていると思っています。

で、これを実現している鍵というかキーワードが何度か上述されている MediaDevices インターフェースです。簡単にいうと HTML と JavaScript だけで画面共有とそのために必要な UI を実現する技術です。ただ比較的新しい技術である上に対応している OS やブラウザはかなり少なく、スマホ・タブレット系は 2020/04/26 時点では全滅といっていい状況です(まあスマホでハンズオンセミナーに参加する人も少ないとは思うけど・・・)。MediaDevices インターフェースについては過去にこれをテーマにブログで紹介したこともあるので、詳しくはそちらも参照してください(この時はホスト・ゲストに分けず、1つの HTML 画面内で画面ストリームを取得してそのまま表示する、という内容でした):
MediaDevices インターフェースを使った画面共有

今回上記で紹介したデモはこの応用といえるもので「ゲストで取得した画面ストリームの内容を使い、バックグラウンドで5秒おきにスクリーンショットを撮ってホストに画像をポストし、ホストで受け取った画像を表示する」ということをゲストブラウザ内の JavaScript とホストサーバーの WebSocket を使って実現しています。機能的にはわざわざスクリーンショット(画像)に変換せず、ストリームのまま(動画のまま)ホストに送信する選択肢もあるのですが、さすがに帯域的にもホストの処理内容的にも(そしてデモサーバーの処理能力的にも)厳しそうな感触があったので、意図的に5秒おきにスクリーンショットを送受信する形にしています(5秒という部分は別途専用環境を作る際には変更可能です)。ただこの結果、ネットワーク上のすべてのやり取りを HTTP(s) と(HTTP を拡張した)WebSocket に集約することができ、帯域問題に加えてファイアウォール的な制約をすり抜けやすい形で実現できたという副産物もあります。

もう少し詳しく2点説明します。まずゲスト側で(JavaScript だけで)共有範囲のスクリーンショットを撮る、という操作ですが、これは上述の MediaDevices インターフェースを使って共有アプリケーション画面のストリームを取得した後に、以下のような処理を行っています。

"Your name .." で指定した名前だけが表示されているように見える HTML 画面には、実は非表示属性の付いた <video> 要素と、同じく非表示属性の付いた <canvas> が存在しています:
<body>
  <h3 id="name"><%= name %></h3>

  <!-- Video element (live stream) -->
  <div class="hidetop">
    <video autoplay playsinline id="video" width="640" height="480"></video>
  </div>

  <!-- Canvas element (screenshot) -->
  <div class="hide">
    <canvas id="canvas" width="640" height="480"></canvas>
  </div>
</body>

↑(hidetop クラスと hide クラスでブロックごと非表示指定されているので、ゲストユーザーからは何も見えません)

そして5秒おきに以下の JavaScript 関数 : video2image() が実行されます:
function video2image(){
  if( localStream ){
    var canvas = document.getElementById( 'canvas' );
    var ctx = canvas.getContext( '2d' );

    var video = document.getElementById( 'video' );
    var w = video.offsetWidth;
    var h = video.offsetHeight;

    canvas.setAttribute( "width", w );
    canvas.setAttribute( "height", h );

    ctx.drawImage( video, 0, 0, w, h );
    var png = canvas.toDataURL( 'image/png' );

    //. 画像を通知
    var msg = {
      uuid: uuid,
      room: '<%= room %>',
      image_src: png
    };
    socketio.json.emit( 'image_client', msg );
  }
}

localStream がスクリーン画面のストリームです。このストリームは <video> 要素と接続しています(この辺りは上記のブログの内容を参照ください)。そして <video> の画面を(非表示の) <canvas> にコピーすることでスクリーンショットを取得し、その内容を canvas.toDataURL() で画像データに変換した上で、Socket.IO を使い、ゲストの ID と一緒に WebSocket でホストの image_client イベントに送信しています。これによりバックグラウンド処理で5秒おきにスクリーンショット画像をホストに送る、という処理が実現できています。

そしてホスト側にはスクリーンショット画像を WebSocket で受け取った後(image_client イベント発生時の)の処理が記述されています:
  socketio.on( 'image_client_view', function( msg ){
    var socket_id = msg.uuid; //msg.socket_id;
    if( socket_ids.indexOf( socket_id ) > -1 ){  //. socket_ids == [];
      $('#image_'+socket_id).prop( 'src', msg.image_src );
      $('#image_'+socket_id).prop( 'title', msg.comment );
    }
  });

メッセージの ID でどのゲストからのイベントであるかを識別した上で、その中の image_src を使って画像情報(=スクリーンショット)を取り出し、ホスト画面内の適切な箇所の画像(=対象ゲストのパネル内の画像)を動的に再描画しています。

実際にはこれらの抜粋処理以外にも、これらをつなぐための処理や初期化などをアプリケーション・サーバー側で行ったりしています。詳しい内容については公開しているソースコードを参照ください。大規模な実験を行うようなケースでは別途独自の環境を準備した上で行っていただけると助かります:
https://github.com/dotnsf/screen_collect


【最後に】
こういったサービスを作って試すことで「こういうケースでは使えそう、こういうケースだとまだ○○の問題がある」みたいな形で次のステップが見えてくると思っています。自分的にはある程度のスキルを持った参加者による「もくもく会」のような内容であれば使えそうだという感触を持っていますが、より初心者参加型に近い内容だと、これだけで充分なサポートができるかどうかは不安もあります。

ただそれらが見えてくることで改良点のヒントにもなるだろうし、用途や条件によっては実践で使うこともできるかもしれないと考えています。またこのデモサービスを使ってみていただいた感想や「こういうケースではOKだった/NGだった」のようなフィードバックもいただけると嬉しいです。いずれにせよオンライン勉強会の体験改善は自分にとっても無視できないテーマなので、こういった形で解決できることがあれば引き続き取り組んでいきたいと思っています。


比較的新しい Web API の1つである MediaDevices インターフェースを使って、HTML と JavaScript だけで(PCの)画面共有が実現できるようになりました。とりあえず使ってみるには PC のウェブブラウザ※で以下のサイトにアクセスしてみてください:
https://dotnsf.github.io/display_media_stream/

※対応ブラウザはこちら: 
https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia#Browser_compatibility



対応ブラウザで上記 URL にアクセスすると、画面共有のダイアログが表示されます:
2020033101


何かひとつ(下図の例では「アプリケーションウィンドウ」タブの「タスクマネージャー」)選択して、「共有」ボタンをクリックすると、、
2020033102


選択したアプリケーションのウィンドウ内画面がストリームでウェブブラウザに表示されます。最初に「全画面」を選んでいればウィンドウ全体がブラウザ内に表示されます:
2020033103


この機能を停止するには共有ダイアログの「共有を停止」ボタンをクリックしてください:
2020033104


以下、上記デモのソースコードを紹介します。コードそのものはこちらで公開しています:
https://github.com/dotnsf/display_media_stream


上記デモは実質的に index.html ファイルだけで実現しています。つまり HTML と JavaScript だけで実現しています。まず HTML 部(<body>部)は以下の通り、かなりシンプルです:
<body>
  <!-- Video element (live stream) -->
  <div>
    <video autoplay playsinline id="video" width="640" height="480"></video>
  </div>
</body>

<div> 要素の中に <video> 要素が1つだけ、autoplay 属性と playsinline 属性がついた状態で存在しています。一応初期サイズの指定もしていますが後で JavaScript で修正します。

次に肝となる JavaScript 部はこちらです:
<script>
var localVideo = null;

function gotLocalMediaStream( mediaStream ){
  localVideo.srcObject = mediaStream;
}

function handleLocalMediaStreamError( error ){
  console.log( "navigator.getUserMedia error: ", error );
}


$(function(){
  //. 画面サイズ取得
  var sw = window.parent.screen.width;
  var sh = window.parent.screen.height;
  //sw : sh = x : 480; => x = 480 * sw / sh;
  var x = Math.floor( 480 * sw / sh );
  $('#video').css( { width: x } );

  var mediaStreamConstraints = { video: true };
  localVideo = document.querySelector( "video" );
  navigator.mediaDevices.getDisplayMedia( mediaStreamConstraints ).then( gotLocalMediaStream ).catch( handleLocalMediaStreamError );
});
</script>

実質的には $(function(){ ... }); 部分が最初に実行されます。まずは window.parent.screen にアクセスして実画面のサイズを取得し、その縦横割合に合わせて上述の video 要素をリサイズ(縦は 480 に固定して、横を同割合になるようリサイズ)します。

そしてこの画面共有を実現しているのはこの1行です:
  navigator.mediaDevices.getDisplayMedia( mediaStreamConstraints ).then( gotLocalMediaStream ).catch( handleLocalMediaStreamError );

mediaDevices インターフェースの getDisplayMedia メソッドを、{ video: true } というオブジェクトを引数に実行しています。これがカメラやマイクではなくディスプレイ画面のストリームを取得するための処理で、成功すると gotLocalMediaStream 関数がコールバックされます。

その gotLocalMediaStream 関数は以下のような内容になっています:
<script>
var localVideo = null;

function gotLocalMediaStream( mediaStream ){
  localVideo.srcObject = mediaStream;
}

getDisplayMedia メソッドで取得したメディアストリームを引数にコールバックされ、その値を video 要素として取得済みの localVideo 変数の srcObject 属性に代入しています。これだけで後はディスプレイ画面の動画ストリームが video 要素の中で自動再生(autoplay)されます。


mediaDevices インターフェースや getDisplayMedia メソッドがまだ限られたブラウザ(Chrome, FireFox, Edge)の比較的新しいバージョンでしか使えず、スマホ系ブラウザでは全滅という状況ではあるのですが、ネイティブアプリを使わなくてもブラウザの JavaScript だけでここまでできるようになっていたんですね。video 要素として使えるということはスクリーンショットとかいろいろ応用できそう・・・


このページのトップヘ