iT邦幫忙

2021 iThome 鐵人賽

DAY 19
0
自我挑戰組

JS30 學習日記系列 第 19

Day 19 - Unreal Webcam Fun [更新]

前言

JS 30 是由加拿大的全端工程師 Wes Bos 免費提供的 JavaScript 簡單應用課程,課程主打 No FrameworksNo CompilersNo LibrariesNo 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) : 放入按下拍照按鈕時要撥放的音效。

<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 是拍照的音效。

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.videoWidthvideo.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 程式碼到本地端測試~


上一篇
Day 18 - Tally String Times with Reduce
下一篇
Day 20 - Native Speech Recognition
系列文
JS30 學習日記30

尚未有邦友留言

立即登入留言