JS 30 是由加拿大的全端工程師 Wes Bos 免費提供的 JavaScript 簡單應用課程,課程主打 No Frameworks、No Compilers、No Libraries、No Boilerplate 在30天的30部教學影片裡,建立30個JavaScript的有趣小東西。
另外,Wes Bos 也很無私地在 Github 上公開了所有 JS 30 課程的程式碼,有興趣的話可以去 fork 或下載。
取得使用者的鏡頭影像,藉此實作出網頁版的相機以及影像濾鏡效果。
這次的範例會需要取得鏡頭的存取權限,而在取得過程必須是透過https或是localhost這類的secure origin才行,以下我們透過npm install和npm start架起自己的 little server (localhost)。
如果發現下面的指令無效的話,表示沒有安裝Node.js,可以點這邊下載 LTS 的版本,都用預設的安裝就好。
第一步 : 先移動到編輯檔案的工作目錄,接著輸入npm install,它會幫你安裝一些套件
第二步 : 輸入npm start,它會去執行package.json裡的start,開始運行一個 little server。(紅色框是目前的網頁位置)
div(.controls) : 裡面放置用來拍照的按鈕和調整綠幕效果的按鈕。
canvas(.photo) : 用來放入鏡頭的影像,之後會搭配一些濾鏡。
video(.player) : 固定在右上角的小影像視窗。
div(.strip) : 用來放入擷取下來的圖片。
audio(.snap) : 放入按下拍照按鈕時要撥放的音效。
<div class="photobooth">
<div class="controls">
<button onClick="takePhoto()">Take Photo</button>
<div class="rgb">
<label for="rmin">Red Min:</label>
<input type="range" min=0 max=255 name="rmin">
<label for="rmax">Red Max:</label>
<input type="range" min=0 max=255 name="rmax">
<br>
<label for="gmin">Green Min:</label>
<input type="range" min=0 max=255 name="gmin">
<label for="gmax">Green Max:</label>
<input type="range" min=0 max=255 name="gmax">
<br>
<label for="bmin">Blue Min:</label>
<input type="range" min=0 max=255 name="bmin">
<label for="bmax">Blue Max:</label>
<input type="range" min=0 max=255 name="bmax">
</div>
</div>
<canvas class="photo"></canvas>
<video class="player"></video>
<div class="strip"></div>
</div>
<audio class="snap" src="./snap.mp3" hidden></audio>

老樣子,我們要先取得所有要用到的元素。
.player 是小鏡頭畫面。canvas 是可以套上濾鏡的大鏡頭畫面。ctx 是canvas的渲染環境。strip 是放照片的容器。snap 是拍照的音效。
const video = document.querySelector('.player');
const canvas = document.querySelector('.photo');
const ctx = canvas.getContext('2d');
const strip = document.querySelector('.strip');
const snap = document.querySelector('.snap');
navigator.mediaDevices.getUserMedia(),用來取得使用者的媒體裝置,因為我們只需要取得影像,所以指定{video:true,audio:false}不存取音訊,最後回傳一個Promise。
我們用then()繼續進行處理,因為不能直接將取得的MediaStream指定為video的來源(它看不懂QQ),還需要透過window.URL.createObjectURL()把MediaStream換成video可以理解的URL,然後video.play()開始播放影像。
到這邊,我們還需要用catch()來處理例外發生的狀況,當無法順利取得媒體裝置或是媒體裝置不存在,就會在 console 上印出錯誤訊息。
function getVideo(){
navigator.mediaDevices.getUserMedia({video:true,audio:false})
.then(localMediaStream => {
console.log(localMediaStream);
video.src = window.URL.createObjectURL(localMediaStream);
video.play();
})
.catch(err => {
console.log(`OH NO!!!`,err);
});
}
getVideo();
canvas)上 :為了讓畫布的大小和取得的影像大小一致,使用 video.videoWidth、video.videoHeight取得影像的寬、長,然後修改畫布的寬(canvas.width)、長(canvas.height)。
setInterval(),設定每隔一段時間就把影像更新到畫布(這邊是設定16毫秒)。
ctx.drawImage(),把影像畫到畫布(canvas)上。
video.addEventListener('canplay',paintToCanvas),如果影像現在是可以正常播放的話,就持續將影像輸出到畫布上。
function paintToCanvas(){
const width = video.videoWidth;
const height = video.videoHeight;
canvas.width = width;
canvas.height = height;
setInterval(() =>{
ctx.drawImage(video,0,0,width,height);
},16);
}
video.addEventListener('canplay',paintToCanvas);
Take Photo時的快門音效、把擷取下來的圖片放入strip內供下載 :snap.currentTime = 0,確保每一次都是從頭開始播放音效,snap.play()開始播放。
canvas.toDataURL('image/jpeg')將canvas上的影像轉換成image/jpeg格式的URL檔案連結。
const link = document.createElement('a'),在文件上新增一個<a>標籤。
link.href = data,將標籤連結指定為取得的影像圖檔連結。
link.setAttribute('download','handsome'),設定這個連結是可被點擊下載,同時下載的檔案名稱為handsome。
link.innerHTML = <img src="${data}" alt="handsome man" />,在<a>內部放入我們取得的圖片,現在只要點擊圖片就會把圖片下載下來。
strip.insertBefore(link,strip.firstChild),將整個<a><img></a>(擷取的影像圖)插入到.strip裡面並且是第一個位置。
function takePhoto(){
//Play the sound
snap.currentTime = 0;
snap.play();
const data = canvas.toDataURL('image/jpeg');
console.log(data);
const link = document.createElement('a');
link.href = data;
link.setAttribute('download','handsome');
//link.textContent = 'Download Image';
link.innerHTML = `<img src="${data}" alt="handsome man" />`;
strip.insertBefore(link,strip.firstChild);
}
不同的濾鏡效果其實只是將CanvasRenderingContext2D.getImageData()取得的畫布像素(pixels)數據以每四個為一組(R-G-B-A)的方式修改,然後再將修改完的像素用CanvasRenderingContext2D.putImageData()放回畫布。
1. 紅色濾鏡
增強紅色並減弱綠、藍色
function redEffect(pixels){
for(let i=0;i < pixels.data.lenght;i+=4){
pixels.data[i + 0] = pixels.data[i + 0] + 100;// R
pixels.data[i + 1] = pixels.data[i + 0] - 50;// G
pixels.data[i + 2] = pixels.data[i + 0] * 0.5;// B
}
return pixels;
}
2. 色彩分離
實際上是讓色板產生位移 (這部分不太好理解@@)
function rgbSplit(pixels){
for(let i=0;i < pixels.data.lenght;i+=4){
pixels.data[i - 150] = pixels.data[i + 0];// R
pixels.data[i + 500] = pixels.data[i + 0];// G
pixels.data[i - 550] = pixels.data[i + 0];// B
}
return pixels;
}
3. 綠幕
讓一定數值範圍內的 R、G、B 消失。
建立一個空物件levels,接著放入每一個 range 的名稱和數值。
以每四個為一組的方式取得畫布像素的 R、G、B 數值,接著把顏色進行比對,舉紅色為例,如果像素的 R 值處在 rmin 和 rmax 之間,就把該像素的透明度設定為0(在畫面上消失)。
function greenScreen(pixels) {
const levels = {};
document.querySelectorAll('.rgb input').forEach((input) => {
levels[input.name] = input.value;
});
for (i = 0; i < pixels.data.length; i = i + 4) {
red = pixels.data[i + 0];
green = pixels.data[i + 1];
blue = pixels.data[i + 2];
alpha = pixels.data[i + 3];
if (red >= levels.rmin
&& green >= levels.gmin
&& blue >= levels.bmin
&& red <= levels.rmax
&& green <= levels.gmax
&& blue <= levels.bmax) {
// take it out!
pixels.data[i + 3] = 0;
}
}
return pixels;
}
(以套用紅色濾鏡為例,其他濾鏡也是同理)
function paintToCanvas(){
const width = video.videoWidth;
const height = video.videoHeight;
canvas.width = width;
canvas.height = height;
return setInterval(() =>{
ctx.drawImage(video,0,0,width,height);
//take the pixels out
let pixels = ctx.getImageData(0,0,width,height);
//mass with them
pixels = redEffect(pixels);
//put them back
ctx.putImageData(pixels,0,0);
},16);
}
這次的練習是目前為止最複雜的,連我自己本身也花了非常多的時間查資料,但仍然沒有辦法把細節交代清楚。
所以大家可能要多花些精力在學習這次的課程內容上,大家加油~~~
Enabling the Microphone/Camera in Chrome for (Local) Unsecure Origins
Navigator
Navigator.mediaDevices
MediaDevices.getUserMedia()
URL.createObjectURL()
CanvasRenderingContext2D.drawImage()
HTMLCanvasElement.toDataURL()
Document.createElement()
HTMLMediaElement
Element.setAttribute()
Node.insertBefore()
debugger
CanvasRenderingContext2D.putImageData()
CanvasRenderingContext2D.getImageData()
ps. 這次的網頁比較特殊,如果打開鏡頭仍然無法看到效果的話,可能就要自己 fork 程式碼到本地端測試~