JS30 全攻略 第19天

前言

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 installnpm start架起自己的 little server (localhost)。

如果發現下面的指令無效的話,表示沒有安裝Node.js,可以點這邊下載 LTS 的版本,都用預設的安裝就好。

  • 第一步 : 先移動到編輯檔案的工作目錄,接著輸入npm install,它會幫你安裝一些套件

  • 第二步 : 輸入npm start,它會去執行package.json裡的start,開始運行一個 little server。(紅色框是目前的網頁位置)


解析程式碼

HTML 部分

div(.controls) : 裡面放置用來拍照的按鈕和調整綠幕效果的按鈕。

canvas(.photo) : 用來放入鏡頭的影像,之後會搭配一些濾鏡。

video(.player) : 固定在右上角的小影像視窗。

div(.strip) : 用來放入擷取下來的圖片。

audio(.snap) : 放入按下拍照按鈕時要撥放的音效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<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>

JS 部分

老樣子,我們要先取得所有要用到的元素。

.player 是小鏡頭畫面。
canvas 是可以套上濾鏡的大鏡頭畫面。
ctxcanvas的渲染環境。
strip 是放照片的容器。
snap 是拍照的音效。

1
2
3
4
5
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 上印出錯誤訊息。

1
2
3
4
5
6
7
8
9
10
11
12
13
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.videoWidthvideo.videoHeight取得影像的寬、長,然後修改畫布的寬(canvas.width)、長(canvas.height)。

setInterval(),設定每隔一段時間就把影像更新到畫布(這邊是設定16毫秒)。

ctx.drawImage(),把影像畫到畫布(canvas)上。

video.addEventListener('canplay',paintToCanvas),如果影像現在是可以正常播放的話,就持續將影像輸出到畫布上。

1
2
3
4
5
6
7
8
9
10
11
12
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裡面並且是第一個位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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. 紅色濾鏡

增強紅色並減弱綠、藍色

1
2
3
4
5
6
7
8
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. 色彩分離

實際上是讓色板產生位移 (這部分不太好理解@@)

1
2
3
4
5
6
7
8
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(在畫面上消失)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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;
}

套用濾鏡 :

(以套用紅色濾鏡為例,其他濾鏡也是同理)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 程式碼到本地端測試~

分享到