Video API と Canvas で遅れ鏡

遅れ鏡とは「 N 秒前のビデオカメラの映像」をリアルタイムに（？）再生するプログラムの通称です、たぶん。
これを JavaScript で実装したものがフォルダ整理していたら出てきました。昔作ったものなのでおかしなところがあるかもしれませんが、もったいないので公開しておきます。

[動くデモはこちら](https://mimonelu.github.io/sandbox/delay-mirror/)

ブラウザから「カメラ撮ってもいーい？」と許可を求められるので求められるままに許可してください。
しばらくすると ~~見たくもない~~ 自分の顔が遅れて表示されると思います。周囲を警戒しつつ横顔とか後頭部とか確認しましょう。
なお Chrome と Firefox 以外では試していません。

## 説明

話は簡単で、 `navigator.mediaDevices.getUserMedia` でカメラの制御権を取得し、一定間隔ごとに静止画像として保存、これを一定時間後に Canvas の `drawImage` で描画しているだけです。
カメラのサイズなどはストリームの `getVideoTracks()[0].getSettings()` を使って取得しています。
（このあたりは API の仕様が固まっていない印象を受けたので、現在はより良いアプローチがあるかもしれません。）

## コード

[GitHub はこちら](https://github.com/mimonelu/sandbox/tree/master/delay-mirror)

### HTML

```html
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://mimonelu.github.io/motoko-css/motoko.css" rel="stylesheet">
<title>Delay Mirror</title>
<style>

  .canvas {
    background-color: #c0c0c0;
    display: block;
    width: 100%;
  }

  .video {
    display: none;
  }

</style>
<body class="motoko">
  <header>
    <h1>Delay Mirror</h1>
    <p>遅れ鏡です。N秒前のフロントカメラの映像を表示します。</p>
  </header>
  <article>
    <aside>
      <label><input name="delay" type="radio" value="0"> 0秒前（遅延なし）</label>
      <label><input name="delay" type="radio" value="3000" checked> 3秒前</label>
      <label><input name="delay" type="radio" value="6000"> 6秒前</label>
      <label><input name="delay" type="radio" value="12000"> 12秒前</label>
    </aside>
    <canvas class="canvas"></canvas>
    <video class="video" playsinline></video>
  </article>
  <footer>
    <div>&copy; 2019 mimonelu</div>
  </footer>
  <script src="./index.js"></script>
</body>
```

### JavaScript

```javascript
class App {

  constructor () {
    this.lazyCanvas = new LazyCanvas('.canvas')
    this.videoCamera = new VideoCamera('.video')
    this.videoCamera.start().then(() => {
      this.ui = new UI()
      this.ui.bindDelayButton((delay) => { this.start(delay) })
      this.start(3000)
    })
  }

  start (delay) {
    this.lazyCanvas.clear()
    this.lazyCanvas.startRecord(this.videoCamera)
    this.lazyCanvas.startRender(delay)
  }

}

class LazyCanvas {

  constructor (selector) {
    this.canvas = document.querySelector(selector)
    this.context = this.canvas.getContext('2d')
    this.interval = 100
    this.snapshots = []
    this.recordTimer = null
    this.renderTimer = null
  }

  clear () {
    this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.snapshots = []
    if (this.recordTimer != null) {
      clearInterval(this.recordTimer)
      this.recordTimer = null
    }
    if (this.renderTimer != null) {
      clearInterval(this.renderTimer)
      this.renderTimer = null
    }
  }

  startRecord (videoCamera) {
    const { width, height } = videoCamera.stream.getVideoTracks()[0].getSettings()
    this.recordTimer = setInterval(() => { this.addSnapshot(videoCamera.node, width, height) }, this.interval)
  }

  addSnapshot (videoNode, videoWidth, videoHeight) {
    const canvas = document.createElement('canvas')
    const context = canvas.getContext('2d')
    const height = Math.round(this.canvas.clientWidth * videoHeight / videoWidth)
    canvas.style['width'] = `${this.canvas.clientWidth}px`
    canvas.style['height'] = `${height}px`
    this.canvas.style['height'] = `${height}px`
    context.drawImage(videoNode, 0, 0, canvas.width, canvas.height)
    this.snapshots.push(canvas)
  }

  startRender (delay) {
    setTimeout(() => {
      this.renderTimer = setInterval(() => {
        this.context.drawImage(this.snapshots.shift(), 0, 0)
      }, this.interval)
    }, delay)
  }

}

class VideoCamera {

  constructor (selector) {
    this.node = document.querySelector(selector)
    this.stream = null
    this.fallback()
  }

  fallback () {
    if (navigator.mediaDevices == null) {
      navigator.mediaDevices = {}
    }
    if (navigator.mediaDevices.getUserMedia == null) {
      navigator.mediaDevices.getUserMedia =
        navigator.getUserMedia ||
        navigator.mozGetUserMedia ||
        navigator.webkitGetUserMedia
    }
  }

  start () {
    return navigator.mediaDevices.getUserMedia({
      audio: false,
      video: true
    }).then((stream) => {
      this.node.onloadedmetadata = () => { this.node.play() }
      this.node.srcObject = this.stream = stream
    }).catch((error) => {
      alert(`カメラへのアクセスが拒否された、もしくは何らかのエラーが発生しました。\nエラー: ${error.name}`)
    })
  }

}

class UI {

  bindDelayButton (callback) {
    document.getElementsByName('delay').forEach((node) => {
      node.addEventListener('click', (event) => {
        callback(parseInt(event.target.value, 10))
      })
    })
  }

}

new App()
```