2026-03-21

使用 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 類別,方便存取 鏡頭裝置 及 熒幕影像。

需要注意,影片截圖 及 影片節錄 的 解像度 取決於 <canvas>元素 及 <video>元素 的 width屬性 及 height屬性,而不是 CSS 的 width 及 height。
CSS 的 width 和 height 只控制元素在網頁上顯示的尺寸,並不會影響實際繪製的解像度。

如果只設定 width屬性 及 height屬性 ,而沒有設定 CSS 的 width 和 height,
當 width 和 height 屬性的設定值較大時,雖然可以獲得高解析度的輸出,
但元素尺寸過大,會導致頁面內容變形。因此,需要使用 CSS 的 width 和 height 設定為合適的顯示尺寸。

補充資料

在下忽發奇想,既然可以擷取單一影片或鏡頭,那麼能否直接把多個影片來源合併成一個新的影片,達到類似影片剪輯的效果?
查找資料後發現,瀏覽器原生 API 並不能直接將多個獨立的 MediaStream 合併成一個新的影片串流。
不過,卻有間接方法,透過 <canvas>元素 作為中繼,將多個影片來源的影格逐一繪製上去,
然後從 <canvas>元素 產生新的 MediaStream ,就能實現合併效果。

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
		<title></title>
		<style>
video, canvas {
	width: 320px;
	height: 180px;
	border: 1px solid #000000;
}
		</style>
		<script>
async function openDisplay(mediaStreamContainer) {
	try {
		openStream(await navigator.mediaDevices.getDisplayMedia({"video": true, "audio": false}), mediaStreamContainer);
	} catch (error) {
		throw error;
	}
}
async function openStream(mediaStream, mediaStreamContainer) {
	try {
		mediaStreamContainer.srcObject = mediaStream;
		mediaStreamContainer.play();
	} catch (error) {
		throw error;
	}
}
function mixFrame(videoContainer1, videoContainer2, captureContainer) {
	try {
		const CONTEXT = captureContainer.getContext("2d");
		var width = captureContainer.getAttribute("width");
		var height = captureContainer.getAttribute("height");
		CONTEXT.drawImage(videoContainer1, 0, 0, width, height);
		CONTEXT.drawImage(videoContainer2, 640, 360, 1280, 720);
	} catch (error) {
		throw error;
	}
	window.requestAnimationFrame(function() {
		mixFrame(videoContainer1, videoContainer2, captureContainer);
	});
}
function render(canvasSource, videoContainer) {
	videoContainer.srcObject = canvasSource.captureStream(60);
	videoContainer.play();
}
var mediaRecorder = null;
var recordedChunks = [];
function startClip(videoSource) {
	try {
		mediaRecorder = new MediaRecorder(videoSource.srcObject);
		mediaRecorder.addEventListener("dataavailable", function(dataavailableEvent) {
			recordedChunks.push(dataavailableEvent.data);
		});
		mediaRecorder.start(1000);
	} catch (error) {
		throw error;
	}
}
function stopClip(clipContainer) {
	try {
		mediaRecorder.addEventListener("stop", function(stopEvent) {
			clipContainer.src = URL.createObjectURL(new Blob(recordedChunks));
			mediaRecorder = null;
			recordedChunks = [];
		});
		mediaRecorder.stop();
	} catch (error) {
		throw error;
	}
}
		</script>
	</head>
	<body>
		<div>
			<button onclick="openDisplay(document.getElementById('video1'));">Open Display 1</button>
			<button onclick="openDisplay(document.getElementById('video2'));">Open Display 2</button>
			<button onclick="mixFrame(document.getElementById('video1'), document.getElementById('video2'), document.getElementById('canvas1'));">Mix Frame</button>
			<button onclick="render(document.getElementById('canvas1'), document.getElementById('video3'));">Render</button>
			<button onclick="startClip(document.getElementById('video3'))">Start Clip</button>
			<button onclick="stopClip(document.getElementById('video4'))">Stop Clip</button>
		</div>
		<fieldset>
			<legend>Video: Input Sources</legend>
			<div>
				<video id="video1" width="1920" height="1080"></video>
				<video id="video2" width="1920" height="1080"></video>
			</div>
		</fieldset>
		<fieldset>
			<legend>Canvas: Mixed Output</legend>
			<div>
				<canvas id="canvas1" width="1920" height="1080"></canvas>
			</div>
		</fieldset>
		<fieldset>
			<legend>Video: Converted From Canvas</legend>
			<div>
				<video id="video3" width="1920" height="1080"></video>
			</div>
		</fieldset>
		<fieldset>
			<legend>Result: Clipped From Video</legend>
			<div>
				<video id="video4" width="1920" height="1080"></video>
			</div>
		</fieldset>
	</body>
</html>

由於時間關係,在下直接將整個合併功能以一個 HTML檔案 編寫,希望在下有時間整理成更容易理解的編寫方法。

操作邏輯其實不算太複雜,但步驟需要很清楚,每一步都有明確目的。

  1. 先取得多個影片來源的 MediaStream ,例如 getUserMedia 、 getDisplayMedia 、 <video>元素 的 srcObject 。
  2. 建立 <canvas>元素 ,<canvas> 的 尺寸設定 將會成為 最終輸出的解像度
  3. 使用 window.requestAnimationFrame 建立一個持續更新的繪製迴圈。
  4. 每一幀(frame) 中用 drawImage() 將每個影片來源的當前影格繪製到 <canvas> 上。
  5. 因應需要調整繪製內容的位置、大小、疊加、特效等。
  6. 使用 canvas.captureStream(fps) 獲取新的 MediaStream 產生渲染結果。(通常 fps 使用 30 或 60)
  7. 將這個 MediaStream 指派給另一個 <video>元素 的 srcObject 就能顯示合併的影片。
  8. 或使用 MediaRecorder 將 MediaStream 錄製成 影片檔案。

簡單來說,就是把多個影片重新渲染,每一幀畫到 <canvas> 上,再從 <canvas> 捕捉成新的影片串流。
這種操作會相對消耗 CPU ,但能在瀏覽器內完成,不需額外安裝特定軟件或上載影片到外部伺服器,就能剪輯影片。

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檔案 或 運行本地伺服器 才能使用。

參考資料

沒有留言 :

張貼留言