Posted at

Video API と Canvas で遅れ鏡

遅れ鏡とは「 N 秒前のビデオカメラの映像」をリアルタイムに(?)再生するプログラムの通称です、たぶん。

これを JavaScript で実装したものがフォルダ整理していたら出てきました。昔作ったものなのでおかしなところがあるかもしれませんが、もったいないので公開しておきます。

動くデモはこちら

ブラウザから「カメラ撮ってもいーい?」と許可を求められるので求められるままに許可してください。

しばらくすると 見たくもない 自分の顔が遅れて表示されると思います。周囲を警戒しつつ横顔とか後頭部とか確認しましょう。

なお Chrome と Firefox 以外では試していません。


説明

話は簡単で、 navigator.mediaDevices.getUserMedia でカメラの制御権を取得し、一定間隔ごとに静止画像として保存、これを一定時間後に Canvas の drawImage で描画しているだけです。

カメラのサイズなどはストリームの getVideoTracks()[0].getSettings() を使って取得しています。

(このあたりは API の仕様が固まっていない印象を受けたので、現在はより良いアプローチがあるかもしれません。)


コード

GitHub はこちら


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

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()