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

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

タグ:canvas

ネットにあまり情報がなく、自分で試行錯誤しながら色々調べてわかった備忘録的なネタです。

フロントエンドフレームワークではない、サーバーサイドアプリとしての Node.js で使える node-canvas というパッケージモジュールがあります:
https://www.npmjs.com/package/canvas

2023060100



これは HTML5 の Canvas 互換機能をサーバーサイドで使えるようになるモジュールです。UI のないサーバーサイドで HTML5 の Canvas ??と思う人がいるかもしれませんが、例えば画像を生成して返すような API を作ろうとした際に(仮想的な)Canvas を生成してグラフィックを描画し、最後に Canvas を画像化して(image/png などのコンテントタイプを指定して)出力する、といった使い方ができて便利です:
var { createCanvas } = require( 'canvas' );

  :
  :

app.get( '/image', function( req, res ){
// (仮想的な)Canvas を生成 var canvas = createCanvas( 200, 100 );

// グラフィックコンテキストを取得(ここから先は HTML5 の場合と同様) var ctx = canvas.getContext( '2d' );
// ctx に目的のグラフィックを描画していく
ctx.beginPath(); ctx.moveTo( 100, 0 ); ctx.lineTo( 200, 100 ); ctx.strokeStyle = 'red'; ctx.stroke(); : :
// 最後に Canvas の内容を 'imaga/png' で出力する res.contentType( 'image/png' ); var stream = canvas.createPNGStream(); stream.pipe( res ); });


便利なのですが、実はこの node-canvas は利用する上での大きな制約があります。システムにあらかじめ特定のライブラリがネイティブインストールされている必要があり、その前提でインストールすることによって使える、というものです。つまり普通にインストールしようとして "$ npm install canvas" と実行するだけでは正しくインストールできないことがあり、その前に必要なライブラリをインストールしておく必要があるのでした。例えば Ubuntu の場合は以下を実行しておく必要があります(他のシステムの場合はこちらの compiling 節を参照ください):
$ sudo apt install build-essensial libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev

さて、アプリを普通に動かすだけならこの(ドキュメントに書かれている)前提コマンドを知っているだけでいいのですが、Docker コンテナ上で動かすとなると少し事情が変わってきます。システム(= Docker ホスト)にあらかじめインストールしたライブラリは関係なく、Docker コンテナとして動かす、そのコンテナ上に上述の依存ライブラリを導入しておく必要があります。しかも Docker コンテナとして動かす場合は Dockerfile 内でベースイメージを指定することになるのですが、このベースイメージによっても(追加で)インストールが必要なライブラリが変わってきます。そういった複雑な事情がある中で、どのようにすれば Node.js で node-canvas が動くコンテナを作れるようになるか、というのがこのブログエントリの(自分の備忘録としての)目的です。もちろん自分以外で同様に Docker コンテナ内で node-canvas を使いたい人がいたとしたら、(特に日本語だとなかなかまとまった情報が見つからないので)以下の情報が有用であると思っています。

答としてはこんな感じです。node:16-alpine をベースとした場合のサンプルを記載します:
# base image
FROM node:16-alpine

# working directory
WORKDIR /usr/src/app

COPY package*.json ./

RUN apk update && apk upgrade && apk add python3 alpine-sdk

# apt install build-essensial libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
RUN apk add build-base cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev

RUN npm install

COPY . .

EXPOSE 8080
CMD ["node", "app.js"]


特に注目してほしいのは、上記 Dockerfile 内の赤字部分です。ここで "apt install build-essensial libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev" に相当するコマンドを実行して docker build 時の "RUN npm install" 実行直前に依存ライブラリをインストールしています。"npm install" でインストールする各モジュールを "apk add" 時にはどのように指定するか、1つ1つ調べる必要があり、その結果がこれでした。

この1行を追加しておくことで直後の "RUN npm install" (ここで node-canvas をインストール)も成功するようになり、Docker コンテナ内で node-canvas を使ったアプリケーションが期待通りに動くようになりました。


(補足)
"node-canvas" と互換性のある "@napi-rs/canvas" というモジュールが存在していて、こちらを使うとネイティブライブラリのインストールは不要です。絵文字などのフォントも内蔵しているようで、これはこれで便利そうです。

ただ上述のコードでは "canvas.createPNGStream()" を実行している箇所がありますが、この関数は @napi-rs/canvas では未実装らしくエラーになってしまいました。もしかするとこちらにも同様の互換関数があるのかもしれませんが未調査です。




スマホのカメラを HTML ページから起動するには、以下のようなタグを用意する方法があります:
<input type="file" accept="image/*" capture="camera"/>


この方法でボタンからカメラを起動すると、カメラで撮影した画像をこのタグの value として引き渡すことができます。「カメラで画像を撮影してアップロード」する方法としては非常に簡単です。

ただこの方法には1つ大きな難点があります。この方法で起動したカメラは HTML による装飾ができず、常にスマホの全画面を使って写真撮影することになります。つまり「撮影中は決まった UI しか使えない」のです。

具体的には以下のようになります(iOS 14 の場合)。<input> タグ自体は CSS で見た目を変えることができるため、例えばこのような画面を用意して、カメラアイコンをタップするとカメラを起動、させるように作ることができます。この「カメラを起動する前」までの UI は自由にデザイン可能です:
2021060801


しかし実際にカメラを起動するとこのような画面になります。全画面でカメラが有効になり、撮影ボタンを押すことで、その瞬間のカメラ映像を記録できます。ただ「撮影ボタンを押す」というアクションを避けることはできません(画面内に QR コードが認識したら自動的に・・・といったことはできません):
2021060802


撮影すると一度この確認画面になり、「写真を使用」を選択することで処理を続けることができるようになります(再撮影した場合は再度撮影画面を経てこの画面になります):
2021060803


カメラ画面を終了すると元の画面に戻ります。ここからはまた自由に見た目を調整することができ、また既に撮影済みの画像のデータを取得することもできるので(File API などと組み合わせることで)プレビュー表示したり、ここから QR コードを読み取って・・・といったこともできるようになります:
2021060804


といった具合です。撮影後は元の画面に戻るので撮影の前後ではデザイン装飾されたページを参照できるのですが、撮影時だけは(撮影し終わるか、キャンセルするまでは)どうにもできません。シャッターを押す、というプロセスを変えることができない UI なので、シャッターを押すことなく画面内に QR コードを確認したら内容を読み取って次の処理へ・・・といったカスタマイズはできないことを意味しています。簡単に実装できる一方で、この撮影前後でのインターフェースにかなり大きな制約を受けることになります。

今回、なんとかして HTML 画面内で上述のようなこと(シャッターを押すことなく画面内に QR コードを確認したら内容を読み取って次の処理)を実現できないかと考え、一応見た目的にはできそうな目処がたったのでサンプルと合わせて紹介します。


まず、サンプルアプリケーションはこちらで試すことができます。ウェブカメラ付きの PC ブラウザか、カメラ付きスマホのブラウザでこちらにアクセスしてみてください(以下の画像は iOS 14 の Safari ブラウザを使った場合の例です):
https://dotnsf.github.io/html_camera_inside/


まず以下のようなダイアログが表示されてカメラへのアクセス許可を求められる(今回の方法だとこの確認ダイアログを回避することはできないと思います)ので「許可」してください:
2021060805


(詳しくは後述しますが)PC ブラウザの場合はウェブカメラ(つまりフロントカメラ)、スマホの場合は背面カメラが有効になり、カメラが映すストリーム映像がウェブ画面上部に表示され続けます。この画面は HTML および CSS で装飾されているもので、自由にカスタマイズできます(上述の「決まったUIしか使えない」ものとは異なります)。今回の例では HTML 画面内に背面カメラの映像を映し続けるようにしています(この画面は HTML で作られています):
2021060806



実際のアプリケーションではここに「撮影」ボタンなどを用意するなどして、ある瞬間の映像を切り取って画像化したり、その画像をサーバーにアップロードすることも可能です。今回のサンプルではボタンで撮影するわけではなく、映像を常に監視しており、映像内に QR コードが認識されたタイミングでその QR コードデータの内容を表示するようにしています。適当な QR コードを探して、スマホや PC ブラウザの映像に映るようにして、動作を確認してみてください:
2021060807



以上がサンプルアプリケーションの紹介です。以下はソースコードの解説です。


このサンプルアプリケーションのソースコードをこちらで公開しています:
https://github.com/dotnsf/html_camera_inside


実質的なコードは index.html 1つだけです。このコードの中に以下のような記載があります:
  :
  :
  //. video
  video = document.createElement( 'video' );
  video.id = 'video';
  video.width = cameraSize.w;
  video.height = cameraSize.h;
  video.setAttribute( 'autoplay', true ); 
  video.setAttribute( 'muted', '' ); 
  video.setAttribute( 'playsinline', '' );
  document.getElementById( 'videoPreview' ).appendChild( video );

  //. media
  var data = {
    audio: false,
    video: {
      facingMode: 'user', //. front 
      width: { ideal: resolution.w },
      height: { ideal: resolution.h }
    }
  };
  if( isMobile() ){
    data.video.facingMode = 'environment'; //. back
  }
  media = navigator.mediaDevices.getUserMedia( data ).then( function( stream ){
    video.srcObject = stream;
  }).then( function( err ){
  });
  :
  :
<body>
  <!-- video(visible) -->
  <div class="container" id="videoPreview" style="text-align: center;">
    <h4>Video Preview</h4>
  </div>
  :
  :


この部分で「カメラを有効にして <body> 内に <video> 要素を生成して撮影した様子を表示」しています。以下2つに分けて説明します。

まずは青字部分、ここで <video> 要素を動的に生成して、<div id="videoPreview"></div> 内に追加しています。ここまではそんなに難しくないと思ってます。

そして赤字部分です。ここで MediaDevices.getUserMedia() メソッドを使ってカメラを有効にしています。その際に PC ブラウザではフロントカメラを、スマホでは背面カメラを有効にする必要があるのですが、getUserMedia() メソッドの引数となるオブジェクトの video.facingMode 属性を 'user'(フロント)にするか、'environment'(背面)にするかで切り替えています。getUserMedia() メソッドが成功したら、その結果を上述の <video> 属性の srcObject に指定することでカメラで撮影し続ける結果を <video> 要素内に表示することができるようになります。

なお、getUserMedia() メソッドは https でアクセスしている場合のみ利用することができます。したがって同様のアプリケーションを作る場合も https で利用できるサイトにページを設置する必要がある点に注意してください。

これだけでカメラで撮影しつつ、その映像を HTML ページ内に表示する、という所までは実現できます。ただ <video> タグのままだとこの先のデータ取り出しなどが必要になった場合に不便です。そこで <video> タグ内に表示される映像を(コマ送りで)<canvas> に表示するように処理を追加して、<canvas> の画像データを監視したり取り出して処理できるように改良しています(加えて <canvas> 自体は非表示の <div> 内に生成することで画面的には変化がないようにしています):
  :
  :
  //. canvas
  canvas = document.createElement( 'canvas' );
  canvas.id = 'canvas';
  canvas.width = canvasSize.w;
  canvas.height = canvasSize.h;
  document.getElementById( 'canvasPreview' ).appendChild( canvas );

  //. context
  ctx = canvas.getContext( '2d' );

  //. render video stream into canvas
  _canvasUpdate();
  :
  :

//. render video stream into canvas
function _canvasUpdate(){
  //. video to canvas(animation)
  ctx.drawImage( video, 0, 0, canvas.width, canvas.height );

  //. check QR code
  var img = ctx.getImageData( 0, 0, canvas.width, canvas.height );
  var result = jsQR( img.data, img.width, img.height );
  if( result && result.data ){ 
    alert( result.data ); 
  }else{
    requestAnimationFrame( _canvasUpdate );
  }
};

  :
  :

  <!-- canvas(invisible) -->
  <div  style="text-align: center; display: none;" id="canvasPreview" class="container">
    <h4>Canvas Preview</h4>
  </div>


上述の _canvasUpdate() メソッドでは <video> の映像を(コマ送りになるよう)切り出して ctx.drawImage() メソッドで <canvas> に転写しています。転写後に <canvas> から画像データを取り出し、 QR コードライブラリ : jsQR を使ってデコードします。デコードが成功していたら(画面内に QR コードが映っていたと判断して)そのデコード結果を alert() 表示します。デコードが成功しなかった場合は QR コードは映っていなかったことになるので、requestAnimationFrame メソッドを使って再度 _canvasUpdate() メソッドを呼び出します※。これを繰り返すことで <canvas> にも(不自然なコマ送りにならずに)自然な映像を表示することが実現できています。

※<video> と <canvas> を連動させるこの技術は他でも応用できそうな印象です。


なお上述の青字部分の、特に以下の部分(青太字の3行)についてコメントを加えておきます:
  video.id = 'video';
  video.width = cameraSize.w;
  video.height = cameraSize.h;
  video.setAttribute( 'autoplay', true ); 
  video.setAttribute( 'muted', '' ); 
  video.setAttribute( 'playsinline', '' );
  document.getElementById( 'videoPreview' ).appendChild( video );
  :
  :


ここは処理的には video.autoplay = true; という属性が設定されていれば正しく動くはずの部分です。ところが原因はよくわからないのですが、iOS ではこれ以外に muted と playsinline という2つの属性についてもなんらかの設定がされている必要があり、しかもこれらを setAttribute() メソッドを使って設定しないと正しく動かない、という現象が発生している模様です(実際に setAttribute() を使わずに直接設定して動かすと、最初だけ <video> 内に映像は描画されますが、それ以降は映像が更新されなくなってしまいました。そのため <canvas> への映像更新も止まってしまいました)。その対応のため現状のようなコードになっています。詳しくはこちらも参照ください:
Navigator.mediaDevices.getUserMedia not working on iOS 12 Safari



というわけで、色々苦労しつつもなんとか HTML & CSS で装飾可能なカメラ撮影画面をウェブページでも実現することができそうな目処が立ちました。「カメラを起動する」ための便利なタグを使うのではなく、どちらかというと動画撮影の機能を有効にした上で、ある瞬間を切り取って撮影したことにする、というアプローチで実現しています。具体的な UI の実現方法はここで紹介した方法以外にもあると思うので、このサンプルを参考にしつつも色々挑戦してみてください。



ほぼ備忘録目的のブログエントリです。

HTML で <canvas> を使うと画面に単純な画像だけでなく、グラフィックコンテキストを使って自由度の高い図形や関数曲線を描くことができるようになります。個人的にもよく使っています。

特筆すべきはピクセル単位での画素情報を取得することもできる点です。ここでの画素情報とは RGB 値や輝度の値といった情報です。例えば <canvas id="mycanvas"></canvas> 内に描かれている内容のピクセル単位での画素情報を動的に取得するには以下のような JavaScript コードを実装すれば可能です:
  var canvas = document.getElementById( 'mycanvas' );
  if( !canvas || !canvas.getContext ){
    return false;
  }
  var ctx = canvas.getContext( '2d' );

  var imagedata = ctx.getImageData( 0, 0, ctx.canvas.width, ctx.canvas.height );
  for( var i = 0; i < imagedata.height; i ++ ){
    for( var j = 0; j < imagedata.width; j ++ ){
      var idx = ( j + i * imagedata.width ) * 4;

      var r = imagedata.data[idx];    //. R(0-255)
      var g = imagedata.data[idx+1];  //. G(0-255)
      var b = imagedata.data[idx+2];  //. B(0-255)
      var a = imagedata.data[idx+3];  //. 輝度(0-255)

        :
    }
  }

この中で画素情報を取得する際に使っているのが getImageData() 関数です。これは canvas 内の開始点座標と幅、高さを指定し、その指定範囲内の画素情報を配列で取り出すことができます。また上述例のように1つのピクセルの情報は配列内の4つの値(R, G, B, 輝度)として格納されており、1つずつ取り出して参照することも可能です。画像を独自に減色したりする際等に必要な情報を取り出すことができて、個人的にも便利に使っています。


このように便利な canvas と getImageData() 関数ですが、この getImageData() 実行時に以下のようなエラーが発生して、画素情報を取得できないケースがありました:
'The canvas has been tainted by cross-origin data.'

ん? "Cross-Origin" ?? どういうこと???


これが発生した時の <canvas> 内は、事前に以下のような処理を行って画像が表示されていました:
  var img = new Image();
  img.src = 'https://www.xxx.com/images/abc.png';
  img.addEventListener( 'load', function(){
    var canvas = document.getElementById( 'mycanvas' );
    if( !canvas || !canvas.getContext ){
      return false;
    }
    var ctx = canvas.getContext( '2d' );
    ctx.drawImage( img, 0, 0, canvas.width, canvas.height );
  }, false );

要するに <canvas> 内には https://www.xxx.com/images/abc.png という(架空の) URL で表現される画像ファイルが表示されている状態でした。単に画像を表示するだけなら <img src="https://www.xxx.com/images/abc.png"/> みたいに記述すればいいのですが、後で前述のような画素情報を取り出す処理を実行したかったので、<img> ではなく <canvas> を使って表示していたのでした、という事情がありました。が、このケースだと getImageData() 実行時に上述のようなクロスオリジン関連のエラーが発生して情報を取得することができなくなっていました。これはいったい・・?


エラーメッセージからなんとなく想像はできたのですが、この getImageData() 関数はクロスオリジンの画像が表示されている場合は CORS の制約がかかるようです。つまりこの例であれば www.xxx.com というサーバー上にある https://www.xxx.com/images/abc.png という画像を表示しています(上のコード例では drawImage() 関数によって実際に表示されます)。この drawImage() 関数はクロスオリジンに関係なく実行することができるので、(自サーバーではない)www.xxx.com というサーバー上にある画像に対して実行してもエラーが発生することなく実行され、画像は <canvas> 上に表示されます。

しかし getImageData() 関数についてはクロスオリジン制約があるようです。したがって自サーバー上ではない URL を指定して表示された画像に対して実行すると、今回のようなエラーが発生してしまう、という制約があるようでした。


このエラーを回避しようとすると、www.xxx.com 側で CORS の制約を緩和するような設定を行うか、それが難しい場合は目的の画像をクロスオリジンにならない形の URL で指定できるようにする必要があります(そのため静的な HTML や JavaScript だけだと実現が難しいです)。具体的にはサーバーサイドの処理で、例えば GET /getimage のような REST API を用意し、この API が目的の画像バイナリと Content-Type を返すようにする、といった対応が必要になります。


現にこういう方法で回避できちゃう CORS 制約は、処理が面倒くさくなるだけであまり意味ないと思っちゃうんだけどな。。


 

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


【背景】
まずこのサービスを作った背景です。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だった」のようなフィードバックもいただけると嬉しいです。いずれにせよオンライン勉強会の体験改善は自分にとっても無視できないテーマなので、こういった形で解決できることがあれば引き続き取り組んでいきたいと思っています。


HTML5 Canvas を使って1年間かけて一周する「年間時計」を作ってみました:
https://dotnsf.github.io/yearclock/
2019112300


表示すると1分で一周する秒針と、1年で一周する年針(?)が表示されます。年針は針というよりはパイチャートの要領で面積で表示されます。ちなみに上図は 11 月後半に表示したものです。もうかなり進んでいますね。。

仕組みはそれほど複雑ではなく、HTML5 Canvas 内に円と弧を描画した上で秒針を setInterval() を使って1秒おきに描画しています。画像の透明度をうまく使って秒針の残像が残る形にしています。パックマン型に弧を描画する技術については先日公開したこちらのエントリを参照ください:
Canvas でパックマン型に塗りつぶした弧を描く


計算上では(うるう年でない年は)毎年 11月25日 の正午が一年間の 90% を経過する瞬間になります。今年も残すはあと1割!

このページのトップヘ