iPhone
HTML5
angular
camera
MediaStream

Angular ブラウザからiPhoneのカメラを起動して写真を撮る

やったこと

  • safariからiPhoneのカメラを起動する
  • canvasで写真撮る
  • box保存とサーバ送信する

angularでやってるけど、ほとんど標準のJS使った処理なのでtypescriptをヴァニラJSに置き換えれば何でもいける気がします。
ただし、reactだと魂を震えさせるアイツが一転して抵抗勢力となりもろもろツライ感じになるのでご注意ください。
https://github.com/facebook/react/pull/9146#issuecomment-319629271

環境

カメラついてるマシン

筆者はMBP(mac book pro)のフロントカメラでざっと作りながら、iPhone7で実機確認する手順でやっています。

mediaStream API対応 ブラウザ

中核です。カメラやスクリーンシェアリング、マイクのようなビデオやオーディオ入力装置を扱うAPIです。

safariはdesktop/mobileとも11から対応。

対応表
https://caniuse.com/#search=getusermedia

なお、APIが2つ存在するので注意です。Promiseを返すNavigator.MediaDevices.getUserMedia()を使います。コールバック型のNavigator.getUserMedia()はDeprecatedされてます。

APIリファレンス
https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia

angular/typescript

angular4.4.4/typescript2.3.3
どうせnativeElement(pure DOM)バリバリなのでバージョンは何でもいいと思うだよ。
バージョン低いとtypescriptの型定義でmediaDevicesあたりが出てこないかもしれない。2.3.3なら全部あったのでanyはありません。

https対策

httpsサイトからじゃないとcameraデバイスアクセスがNGになりますNotAllowedError (DOM Exception 35)。オレオレ証明書を作り、webpack dev serverなどでhttpsのローカルサーバを起動しても、safariだとやっぱりNGです。なので、筆者はいちいちherokuにデプロイして動作確認する苦行を積んだよ。(証明書タダでできるいい方法あったら教えてください)

流れ

今回使うhtmlを示します。

<video id="video" #video autoplay playsinline></video>
<canvas id="canvas" #canvas></canvas>
<button (click)="onClick()">写真撮る</button>

angularテンプレートだが、特有の要素は#のテンプレート変数にエクスポートしているところくらいだと思います。こいつでtypescript領域にhtmlElementを引っ張り込み、nativeElementというangularのDependencyInjection(DI)をかましつつDOM透過するための仕組みを使ってゴリゴリやっていきます。まあ、ヴァニラJSのdocument. getElementById()などに置き換えればほぼ互換されると思います。

これを使って順番にtypescriptを実装していきます。
流れです。

  1. videoタグでcameraと接続してキャプチャする。
  2. canvasで静止画像を描画する
  3. videoタグでキャプチャしているcameraを止める
  4. ローカルにファイル保存したりサーバ送信する

1. videoタグでcameraと接続してキャプチャする

カメラを起動するところです。@ViewChild('video')でvideoタグのhtmlElementを取得してtypescriptで操作する感じです。

export class PictureComponent {
  @ViewChild('video') videoElm: ElementRef;

  readonly medias: MediaStreamConstraints = {audio: false, video: {
    facingMode: 'user',  ・・・フロントカメラ指定
        ・・・リアカメラ指定するときは以下に差し替える
    // facingMode: {
    //   exact : 'environment'
    // },
  }};

  ngAfterViewInit() {
    this.startCamera();
  }

  private startCamera() {
    window.navigator.mediaDevices.getUserMedia(this.medias)
      .then(stream => this.videoElm.nativeElement.srcObject = stream)
      .catch(error => {
        console.error(error);
        alert(error);
      });
  }
}

medias変数

mediaを取得する際のconfigとなる。カメラ用の設定の肝はfacingModeになる。
本例ではfacingModeuserを指定することでフロントカメラを起動している。コメントアウトしている記述{exact : 'environment'}に差し替えるとリアカメラが起動する。
一応、cameraの解像度を指定して、指定解像度のcamera以外はエラーにすることもできる。
完全版はこちら。

const medias: MediaStreamConstraints = {
  audio: false,
  video: {
    facingMode: 'user',
    width: { min: 1024, ideal: 1280, max: 1920 },
    height: { min: 776, ideal: 720, max: 1080 },
  }
}

ちなみに、audioをtrueにすると、マイクデバイスと接続する。

startCamera()

startCamera()でcameraに接続している。mediaDevices.getUserMedia()を呼び出すと、ブラウザがユーザーにカメラの接続許可を適宜提示する。ユーザーがOKしてmediaDevices.getUserMedia()が成功すると、Promise型のstreamが返ってくるので、videoタグのsrcに突っ込むとキャプチャが開始される。

2. canvasで静止画像を描画する

videoタグのキャプチャをベクター画像で扱えるようにするために、PictureComponentに以下を追加します。

  private captureData: string;

  onClick() {
    this.captureData = this.draw();
  }

  private draw() {
    // 写真のサイズを決める
    const WIDTH = this.videoElm.nativeElement.clientWidth;
    const HEIGHT = this.videoElm.nativeElement.clientHeight;

    // canvasを用意する
    const ctx = this.canvasElm.nativeElement.getContext('2d') as CanvasRenderingContext2D;
    this.canvasElm.nativeElement.width  = WIDTH;
    this.canvasElm.nativeElement.height = HEIGHT;

    // canvasの描画をしつつBase64データを取る
    return this.canvasElm.nativeElement.toDataURL(ctx.drawImage(this.videoElm.nativeElement, 0, 0, WIDTH, HEIGHT));
  }

内容はソースコメントの通り。

3. cameraを止める

後片付け大事です。放っておくとカメラと接続しっ放しになります。
止める方法が意外とサクッと出てこなくて調べるのにちょっと時間かかりましたが英語サイトで解決しました。

  onClick() {
    this.captureData = this.draw();
    this.stopCamera();
  }

  private stopCamera() {
    this.videoElm.nativeElement.pause(); ・・・一時停止
    const track = this.videoElm.nativeElement.srcObject.getTracks()[0] as MediaStreamTrack;;
    track.stop(); ・・・止める
  }

trackを取得して、track毎に止める方法に変わったらしいです。
https://developers.google.com/web/updates/2015/07/mediastream-deprecations?hl=en#stop-ended-and-active

あとはcomponentのライフサイクルに任せればメモリ解放されると思います。

4. ローカルにファイル保存したりサーバ送信する

サーバ送信は、BASE64でよければcaptureDataに取得したデータをJSONに突っ込めばOKです。クラウドStorageに上げるなどは各SDKに従いましょう。

ローカル保存する場合は、iOS/Androidで振る舞いが変わるかもしれません。筆者はiPhoneしか無いので、iPhoneの挙動を記載します。aタグでダウンロードリンクを作っていますが、programmaticallyにやろうとしてもタブで画像が開いてしまうだけになります(PCなら自動ダウンロードになります)。なので、ユーザーに手作業で保存してもらうスタイルになっています。androidならこれで自動保存までできるのかもしれません。

  constructor(
    private renderer: Renderer2,  ・・・angularのレンダラーを注入
  ) {}

  onClick() {
    this.captureData = this.draw();
    this.stopCamera();
    this.savePicture();
  }

  private savePicture() {
    this.apiService.savePicture(this.captureData); ・・・サーバ送信する何か

    const a = this.renderer.createElement('a') as HTMLAnchorElement;
    a.href = this.captureData;  ・・・キャプチャしたBase64データを突っ込む
    a.setAttribute('download', 'image.png');
    // a.click();  ・・・タブで開いてしまうのでコメントアウト
}

最終形

picture.component.html
<video id="video" #video autoplay playsinline></video>
<canvas id="canvas" #canvas></canvas>
<button (click)="onClick()">写真撮る</button>
picture.component.ts
export class PictureComponent {
  @ViewChild('video') videoElm: ElementRef;
  @ViewChild('canvas') canvasElm: ElementRef;

  readonly medias: MediaStreamConstraints = {audio: false, video: {
    facingMode: 'user',
    // facingMode: {
    //   exact : 'environment'
    // },
  }};
  private captureData: string;

  constructor(
    private renderer: Renderer2,
    private apiService: ApiService,
  ) { }

  ngAfterViewInit() {
    this.startCamera();
  }

  onClick() {
    this.captureData = this.draw();
    this.stopCamera();
    this.savePicture();
  }

  private draw() {
    // 写真のサイズを決める
    const WIDTH = this.videoElm.nativeElement.clientWidth;
    const HEIGHT = this.videoElm.nativeElement.clientHeight;

    // canvasを用意する
    const ctx = this.canvasElm.nativeElement.getContext('2d') as CanvasRenderingContext2D;
    this.canvasElm.nativeElement.width  = WIDTH;
    this.canvasElm.nativeElement.height = HEIGHT;

    // canvasの描画をしつつBase64データを取る
    return this.canvasElm.nativeElement.toDataURL(ctx.drawImage(this.videoElm.nativeElement, 0, 0, WIDTH, HEIGHT));
  }

  private startCamera() {
    window.navigator.mediaDevices.getUserMedia(this.medias)
      .then(stream => this.videoElm.nativeElement.srcObject = stream)
      .catch(error => {
        console.error(error);
        alert(error);
      });
  }

  private stopCamera() {
    this.videoElm.nativeElement.pause();
    const track = this.videoElm.nativeElement.srcObject.getTracks()[0] as MediaStreamTrack;
    track.stop();
  }

  private savePicture() {
    this.apiService.savePicture(this.captureData);

    const a = this.renderer.createElement('a') as HTMLAnchorElement;
    a.href = this.captureData;
    a.setAttribute('download', 'image.png');
    // a.click();
  }
}

参考にさせて頂いた記事