iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 19
0
Modern Web

JS30 錄系列 第 19

Day 19 - Webcam Fun - Part I - Take a Photo from Webcam

任務目標

擷取視訊鏡頭得到的影片,實現按鍵快照、預覽照片及下載存檔。

作法

首先是HTML檔的架構,程式碼如下:

<!--拍照鈕-->
<div class="controls">
  <button onClick="takePhoto()">Take Photo</button>
</div>

<div class="photobooth">
  <!-- 我們會將video的視訊同步畫到 canvas 畫布上,才能做畫面特效處理 -->
  <canvas class="photo"></canvas>
  <!--接收視訊串流的 video 元素-->
  <video class="player"></video>
  <!--展示拍照的地方-->
  <div class="strip"></div>
</div>

<!-- 拍照會用的音效檔 -->
<audio class="snap" src="http://wesbos.com/demos/photobooth/snap.mp3" hidden></audio>

<!-- 程式碼 -->
<script src="scripts.js"></script>

從視訊鏡頭取得影片

第一步得先從視訊鏡頭將影片串流到 <video> 元件中。串流 (stream)是指將一串影音資料壓縮後,經過網路分段傳送資料,在網路上即時傳輸影音的一種技術。

瀏覽器端的 Media Stream API 用來處理和影音串流有關的程序,其提供一個 MediaDevices 介面,讓我們能夠存取麥克風、攝影機等影音輸入裝置,並將其做為來源串流至 <video><audio> 等元素。

以下是擷取影像的自訂函式:

const video = document.querySelector('.player');

function getVideo() {
  navigator.mediaDevices.getUserMedia({ video: true, audio: false })
    .then(localMediaStream => {
			video.srcObject = localMediaStream;
			video.play();
    })
    .catch(err => {
      console.error(`OH NO!!!`, err);
    });
}

我們使用 mediaDevices 介面底下的 getUserMedia() 方法,來存取影片串流。 getUserMedia() 讓瀏覽器詢問使用者是否同意開啟影音輸入裝置,當使用者同意時,便會存取影音輸入裝置做為影片串流的源頭。

由於我們只需要存取攝像鏡頭的影片,因此在 getUserMedia() 方法中,輸入一個做為 MediaStreamConstraints 的物件,該物件可以告訴瀏覽器我們需要擷取的串流媒體有何限制,將 audio 屬性值設為 false 表示我們不需要存取聲音串流。

getUserMedia 回傳的是 Promise 物件,該 Promise 物件最終會得到一個 MediaStream 物件, MediaStream 代表我們所存取的一段段的媒體。

Promise 物件就像兌換券一樣,用 then 方法來安排兌換到最後的 MediaStream 物件後要做些什麼。萬一錯誤發生,用 catch 來接收錯誤訊息並安排如何處理。

因此在 then 方法中,我們用 <video> 元素的 srcObject 屬性來接收該 MediaStream 做為其源頭。 srcObject 屬性是只有 <video><audio> 這種 HTMLMediaElement 才擁有的屬性,其可以將任何支援的影音物件作為元素的來源。

設定完來源,再播放該 <video> ,就會看到即時的影音。

才不會。

由於 getUserMedia() 必須在瀏覽器認定目前參照的頁面位於 「Secure Origin」 的情況下才會被允許執行, localhost 也被算做 「Secure Origin」,因此我們必須開個簡單的 Server 來做為這些網頁的源頭,才能看到執行的效果。 作者的題目檔案有提供 browser-sync 做為簡單的架站包。

若電腦有安裝 NodeJS ,只要進入題目的資料夾中, 使用終端機命令列輸入 npm install 安裝架站包,再輸入 npm start 執行預先被寫好的命令列來運行伺服器即可。

或者利用 Python 的 SimpleHTTPServer 自己開一個伺服器也行。

將取得的影片畫到 Canvas 畫布上

成功將 <video> 設為攝影機後,接下來要將影片畫到 Canvas 畫布上。為什麼要這樣呢?因為 Canvas 就是一張由許多像素點組成的畫布,Canvas 的渲染環境支援很多方法操作畫布上的像素點們。只要將影片印到畫布上, Canvas 就有能力操作這些影片的複製品,做出各種酷炫效果。

請看以下函式:

const canvas = document.querySelector('.photo');
const ctx = canvas.getContext('2d');

function paintToCanvas() {
	const width = video.videoWidth;
	const height = video.videoHeight;
	// 讓 <canvas> 的像素大小與 <video> 相等
	canvas.width = width;
	canvas.height = height;

  // 第一次使用 rAF 開啟更新畫面的循環
	return window.requestAnimationFrame(timestamp => {
	  // 將 `<video>` 當下的畫面印到 <canvas> 內
		updateVideo(timestamp, width, height);
	});
}

// 轉印畫面的函式
function updateVideo(timestamp, width, height) {
  // 將 video 當下的畫面依照複製像素的方式複製到 canvas 上
	ctx.drawImage(video, 0, 0, width, height);

  // 呼叫下一個更新畫面的循環
	window.requestAnimationFrame(timestamp => {
		updateVideo(timestamp, width, height);
	})
}

我們希望 paintToCanvas() 函式做到的任務如下:

  1. 先將 <canvas> 畫布的像素點數量與 <video> 影片的像素點數量透過長與寬設為相等
  2. 每隔一段固定的時間,便呼叫 updateVideo() 函式,將當下的影片畫格印到 <canvas>

updateVideo() 函式內,我們用 canvas.getContext('2d') 渲染環境提供的 drawImage() 方法將 <video> 元素當下的畫面轉印到 <canvas> 內。 當 drawImage 內提供五個參數時,各參數代表含義舉例如下:

ctx.drawImage(source, dx, dy, W, H);

source 代表轉印畫面的來源,dxdy 分別代表距離畫面原點 (左上) 向右與向下位移多少開始擷取畫面,WH 代表擷取多寬及擷取多高。示意圖如下:
老樹猴

因此 drawImage() 會將擷取到的畫面,一個像素點一個像素點地複製到<canvas> 內。這也是為何我們在第一步將代表 <canvas> 實際像素點數量的長和寬與 <video> 設為相等的原因。

所謂影片可以想成是許多張相片不停播放的結果。如果要讓 <canvas> 內產生與 <video> 同步的影片,可以透過不停抓取 <video> 內的即時影像並複製到 <canvas> 上來實現。透過 setInterval(updateVideo, 16) 可以每16毫秒就抓取影像並更新。但在這邊要介紹一個更棒的 window.requestAnimationFrame() 方法。

window.requestAnimationFrame(callback)

requestAnimationFrame (rAF)是瀏覽器原生用來處理動畫的方式。一般來說,瀏覽器會在畫面產生變動的時候執行 repaint 的動作,將變動過的物件更新到頁面上。例如當我們捲動頁面時,看到頁面的畫面會改變,就是因為捲動頁面時,瀏覽器 repaint 了。

而 rAF 的作用就是告訴瀏覽器,在下個 repaint 發生前,先執行某個函式。 做為瀏覽器專門用來更新動畫的方法, rAF 會比 setInterval() 還要節省效能。

順暢的情況下 rAF 一秒鐘可以執行約60次,使用方法如下:

function callMe() {
  // 欲重複執行的程式
  
  // 在下一次 repaint 前執行 callMe ,形成循環
  window.requestAnimationFrame(callMe);
}

// 第一次呼叫,在第一次 repaint 前執行 callMe
window.requestAnimationFrame(callMe);

如註解,呼叫一次 rAF 只會在下一次 repaint 前執行該函式一次,但如果在函式後面再呼叫下一次的 rAF 執行自己,便會形成循環,讓每次 repaint 前都會執行該函式。

回到剛剛的狀況,我們用 paintToCanvas 函式初始化設定,以及呼叫第一次的 rAF ,在 rAF 內放入 updateVideo 函式,用來更新畫面至 <canvas> 上,然後於該函式尾端再次使用 rAF 呼叫 updateVideo 本身,開啟新的循環。以此來實現 <canvas><video> 畫面同步。

製作按鍵快照功能

最後,我們希望實現按鍵快照的效果,只要按個鍵,就能將 <canvas> 上當下的畫面照下來,並且可以下載到主機內。

程式碼如下:

const snap = document.querySelector('.snap');
const strip = document.querySelector('.strip');

function takePhoto() {
	// 拍照聲
	snap.currentTime = 0;
	snap.play();

	// 將畫面輸出為 DataURL 格式的資料
	const data = canvas.toDataURL('image/jpeg');
  
  // 製作連結
	const link = document.createElement('a');
	link.href = data;
	link.setAttribute('download', 'handsome');
	
	// 將連結以圖片表示
	link.innerHTML = `<img src=${data} alt="selfy">`;
	strip.insertBefore(link, strip.firstChild);
}

takePhoto 這個函式被指定給某個按鈕。
前兩行是讓函式執行時先播放快門聲。

接著用 <canvas> 元素提供的 toDataURL 方法將畫面轉換成 DataURL 格式的圖片儲存在頁面上。

接著製作一個連結 <a> ,將輸出的資料指定給該連結的來源。如此便可以透過點選該連結來下載拍下的畫面。 <a>download 屬性可以設定透過該連結下載的檔案,預設檔名為何。

我們甚至可以在 <a> 裡面放入一個 src 指向該 DataURL 的圖片,如此便能夠直接在頁面上預覽拍照結果。 strip 是展示拍下來照片的容器。

於是,一個可以對攝像鏡頭拍到的畫面快照的功能就完成了!

以上是 JS30 第十九篇 Part I!明天的 Part II 將實作畫面特效的部分,敬請期待!

Reference

Media Stream API
MediaDevices.getUserMedia()
MediaStreamConstraints
HTMLMediaElement.srcObject
canvas.getContext('2d').drawImage
window.requestAnimationFrame()
canvasElement.toDataURL
What is DataURL


上一篇
Day 18 - Adding Up Time With Reduce
下一篇
Day 20 - Webcam Fun - Part II - Pixel Manipulation
系列文
JS30 錄30

1 則留言

0
陳董 Don
iT邦新手 5 級 ‧ 2018-01-07 23:28:37

上火車了!

Arel iT邦新手 5 級 ‧ 2018-01-07 23:35:28 檢舉

使命必達~

我要留言

立即登入留言