使用 Web Media Devices API 拍攝及錄影 USB鏡頭 的影像

最近忽發奇想,在下每次使用電子顯微鏡觀察電子晶片時都需要使用一些能夠存取鏡頭裝置的軟件,
雖然不算不方便,亦有支援 Linux 的軟件,但就是覺得有點麻煩,
在下曾經在網上見過有網頁能夠存取鏡頭裝置,並在網頁上顯示鏡頭影像,因此在下打算實作這個操作。

存取媒體串流

媒體裝置
const constraints = {
	"audio": false, // boolean
	"video": true, // boolean
};
let mediaStreamObject = await navigator.mediaDevices.getUserMedia(constraints);

執行 navigator.mediaDevices.getUserMedia 後,網頁瀏覽器 會先嘗試列出系統上可用的 USB鏡頭裝置,並請求使用者授權存取。

完成裝置存取權限請求後,使用者可以在下拉式選單中選擇目標鏡頭。

htmlVideoElement.srcObject = mediaStreamObject;

選定鏡頭後,按下 允許(Allow) 即可開始存取影像。
navigator.mediaDevices.getUserMedia 會回傳鏡頭的 媒體串流物件(MediaStream Object)
只要將 媒體串流物件 指派給 <video>元素 srcObject屬性 ,就能在網頁上即時顯示鏡頭畫面。

留意:<video>元素 的 srcObject屬性 不能以 htmlVideoElement.setAttribute("srcObject", mediaStreamObject) 指派。

測試拍攝功能,在下嘗試將鏡頭影像拍攝成圖片,
使用 <canvas>元素 繪製鏡頭影像,再將鏡頭影像儲存為圖像檔案。

測試錄影功能,在下嘗試使用 MediaRecorder API 錄製鏡頭串流,
同樣使用 <video>元素 將影像片段節錄,並影像片段節錄儲存為影片檔案。

基於 Chromium 的 網頁瀏覽器 都 內建 投放(Cast) 功能,
點擊後,網頁瀏覽器 會自動掃描相同網域內的 Chromecast 支援投放的電視裝置
選定後,就能將鏡頭的 即時鏡頭影像 投放到螢幕上,不需要額外硬體或軟體。

顯示裝置

在下想到,疫情後經常使用的網上會議軟件,
普遍都能夠讓參與者不需要額外安裝軟件,只需要使用網頁瀏覽器便能夠加入至會議,
而且能夠在網頁瀏覽器上 分享熒幕,因此在下同樣嘗試了解原理,
發現這個功能同樣來自 Web Media Devices API 的另一個功能。

執行 navigator.mediaDevices.getUserDisplay 後,網頁瀏覽器 會先嘗試列出系統上可用的 熒幕裝置 及 活動視窗 。

使用者可以在下拉式選單中選擇目標 熒幕裝置(Display) 活動視窗(Active Window)

獲得授權後,同樣回傳 媒體串流物件 , 因此往後的操作與 navigator.mediaDevices.getUserMedia 獲取 USB鏡頭裝置 完全相同。

基於 Chromium 的網頁瀏覽器同樣完整支援 Web Media Devices API,
會按照媒體類型清楚分類,讓使用者更容易找到並選擇要分。

另外,基於 Chromium 的網頁瀏覽器 還額外提供專屬的 分頁(Tab) 選項,
即使目標分頁不是目前正在檢視的活動分頁,也能直接從選單中選取並分享該分頁的內容,而不需要強制切回該分頁。

測試錄影視窗的效果。

// WebMediaDevicesAPI.js
class WebMediaDevicesAPI {
	#mediaStreamContainer = null;
	#mediaRecorder = null;
	#recordedChunks = [];
	constructor() {
	}
	async openDevice(mediaStreamContainer) {
		try {
			if (this.#mediaStreamContainer != null) {
				throw new Error("Media Stream is already opened");
			}
			this.#openStream(await navigator.mediaDevices.getUserMedia({"video": true, "audio": false}), mediaStreamContainer);
		} catch (error) {
			throw error;
		}
	}
	async openDisplay(mediaStreamContainer) {
		try {
			if (this.#mediaStreamContainer != null) {
				throw new Error("Media Stream is already opened");
			}
			this.#openStream(await navigator.mediaDevices.getDisplayMedia({"video": true, "audio": false}), mediaStreamContainer);
		} catch (error) {
			throw error;
		}
	}
	#openStream(mediaStream, mediaStreamContainer) {
		try {
			if (this.#mediaStreamContainer != null) {
				throw new Error("Media Stream Container is already opened");
			}
			this.#mediaStreamContainer = mediaStreamContainer;
			this.#mediaStreamContainer.srcObject = mediaStream;
			const TRACKS = this.#mediaStreamContainer.srcObject.getTracks();
			for (const I in TRACKS) {
				const SETTINGS = TRACKS[I].getSettings();
				this.#mediaStreamContainer.setAttribute("width", SETTINGS.width);
				this.#mediaStreamContainer.setAttribute("height", SETTINGS.height);
			}
		} catch (error) {
			throw error;
		}
	}
	closeStream() {
		try {
			if (this.#mediaStreamContainer == null) {
				throw new Error("No Media Stream");
			}
			const TRACKS = this.#mediaStreamContainer.srcObject.getTracks();
			for (const I in TRACKS) {
				TRACKS[I].stop();
			}
			this.#mediaStreamContainer.removeAttribute("width");
			this.#mediaStreamContainer.removeAttribute("height");
			this.#mediaStreamContainer.srcObject = null;
			this.#mediaStreamContainer = null;
		} catch (error) {
			throw error;
		}
	}
	captureFrame(captureContainer) {
		try {
			if (this.#mediaStreamContainer == null) {
				throw new Error("No Media Stream Container");
			}
			let width = this.#mediaStreamContainer.getAttribute("width");
			let height = this.#mediaStreamContainer.getAttribute("height");
			captureContainer.setAttribute("width", width);
			captureContainer.setAttribute("height", height);
			const CONTEXT = captureContainer.getContext("2d");
			CONTEXT.drawImage(this.#mediaStreamContainer, 0, 0, width, height);
		} catch (error) {
			throw error;
		}
	}
	startClip() {
		try {
			if (this.#mediaStreamContainer == null) {
				throw new Error("No Media Stream");
			}
			if (this.#mediaRecorder != null) {
				throw new Error("Media Recorder is already recorded");
			}
			this.#mediaRecorder = new MediaRecorder(this.#mediaStreamContainer.srcObject);
			this.#mediaRecorder.addEventListener("dataavailable", function(dataavailableEvent) {
				this.#recordedChunks.push(dataavailableEvent.data);
			}.bind(this));
			this.#mediaRecorder.start(1000);
		} catch (error) {
			throw error;
		}
	}
	stopClip(clipContainer) {
		try {
			if (this.#mediaRecorder == null) {
				throw new Error("No Media Recorder");
			}
			this.#mediaRecorder.addEventListener("stop", function(stopEvent) {
				clipContainer.src = URL.createObjectURL(new Blob(this.#recordedChunks));
				clipContainer.pause();
				this.#mediaRecorder = null;
				this.#recordedChunks = [];
			}.bind(this));
			this.#mediaRecorder.stop();
		} catch (error) {
			throw error;
		}
	}
}
<!DOCTYPE html>
<!-- index.html -->
<html lang="en">
	<head>
		<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
		<title></title>
		<style>
html, body {
	margin: 0;
	padding: 0;
}
video, canvas {
	width: 320px;
	height: 180px;
	border: 1px solid #000000;
}
		</style>
		<script src="WebMediaDevicesAPI.js"></script>
		<script>
// <!--
const WEB_MEDIA_DEVICES_API = new WebMediaDevicesAPI();
window.addEventListener("load", async function(loadEvent) {
	const STREAM_CONTAINER = document.getElementById("stream-container");
	const CAPTURE_CONTAINER = document.getElementById("capture-container");
	const CLIP_CONTAINER = document.getElementById("clip-container");
	document.getElementById("open-camera").addEventListener("click", async function(clickEvent) {
		WEB_MEDIA_DEVICES_API.openDevice(STREAM_CONTAINER);
	});
	document.getElementById("open-display").addEventListener("click", async function(clickEvent) {
		WEB_MEDIA_DEVICES_API.openDisplay(STREAM_CONTAINER);
	});
	document.getElementById("close-stream").addEventListener("click", async function(clickEvent) {
		WEB_MEDIA_DEVICES_API.closeStream();
	});
	document.getElementById("capture-frame").addEventListener("click", async function(clickEvent) {
		WEB_MEDIA_DEVICES_API.captureFrame(CAPTURE_CONTAINER);
	});
	document.getElementById("start-clip").addEventListener("click", async function(clickEvent) {
		WEB_MEDIA_DEVICES_API.startClip();
	});
	document.getElementById("stop-clip").addEventListener("click", async function(clickEvent) {
		WEB_MEDIA_DEVICES_API.stopClip(CLIP_CONTAINER);
	});
});
// -->
		</script>
	</head>
	<body>
		<div>
			<button id="open-camera">Open Camera</button>
			<button id="open-display">Open Display</button>
			<button id="close-stream">Close Stream</button>
			<button id="capture-frame">Capture Frame</button>
			<button id="start-clip">Start Clip</button>
			<button id="stop-clip">Stop Clip</button>
		</div>
		<div>
			<video id="stream-container" controls="true" mutes="false" autoplay="true" width="560" height="315"></video>
		</div>
		<div>
			<canvas id="capture-container" width="560" height="315"></canvas>
		</div>
		<div>
			<video id="clip-container" controls="true" mutes="false" autoplay="false" width="560" height="315"></video>
		</div>
	</body>
</html>

基於兩種存取方法,在下編寫 WebMediaDevicesAPI 類別,方便存取 鏡頭裝置 及 熒幕影像。

Video: Input Sources
Canvas: Mixed Output
Video: Converted From Canvas
Result: Clipped From Video

測試效果,必須順序操作。

總結

Web Media Devices API 其實並不是新技術,早在 2012年 就完成初步草擬,
到 2017年左右,各大主流網頁瀏覽器已陸續開始支援,即是已經將近 10年前 的 技術。

在下原本只是單純想測試 拍攝與錄製 USB鏡頭 的 影像,驗證一下在瀏覽器內能否順利存取 鏡頭裝置,
沒想到在尋找資料及測試中,發現一些實用技術。

只要 電腦 與 電視 在相同網路下,就能將 USB鏡頭 的 即時影像 投放到電視上,不需要 額外硬體 或 特定軟件。
雖然基於 Chromium 的 網頁瀏覽器 原本已經具備投放功能,但在下當初根本並沒有預料到能夠使用。

錄製功能也很方便,不需要安裝額外軟件,只使用 網頁瀏覽器 配合 MediaRecorder API,就能直接把 鏡頭畫面 或 螢幕內容 儲存成 影片檔案。
對於要簡單錄影、分享畫面的使用者來說,能夠省卻額外成本,尤其在沒有管理員權限時,無法安裝軟件的情況。

最後關於多個影片來源的合併,在下起初只是抱著嘗試心態,沒有想像能夠成功,結果確實能使用 網頁瀏覽器 達成簡單的影片合成效果。
雖然無法與正式的剪輯軟件相比,但應付畫中畫、左右並排、上下分割、串接影片等基本影片編輯。
關於使用 綱頁瀏覽器 編輯影音的可能性,在下會繼續探討及測試,希望不需要剪輯軟件都能夠完成更多基本編輯操作。

原本只是測試在 網頁瀏覽器 上顯示 鏡頭影像,結果意外做到剪輯效果。

要留意,要執行這個操作,網站必須使用 HTTPS 才能執行這些媒體存取操作,
否則需要使用 file://協定 直接開啟本機 HTML檔案 或 運行本地伺服器 才能使用。

參考資料