跳到主要内容

getUserMedia

2024年02月04日
柏拉文
越努力,越幸运

一、认识


mediaDevices.getUserMedia() 会提示用户给予使用媒体输入的许可,媒体输入会产生一个 MediaStream,里面包含了请求的媒体类型的轨道。此流可以包含一个视频轨道(来自硬件或者虚拟视频源,比如相机、视频采集设备和屏幕共享服务等等)、一个音频轨道(同样来自硬件或虚拟音频源,比如麦克风、A/D 转换器等等),也可能是其他轨道类型。

它返回一个 Promise 对象,成功后会 resolve 回调一个 MediaStream 对象。若用户拒绝了使用权限,或者需要的媒体源不可用,promisereject 回调一个 PermissionDeniedError 或者 NotFoundError

二、语法


const promise = navigator.mediaDevices.getUserMedia(constraints);

const promise = navigator.mediaDevices.getUserMedia({
audio: true,
video: true
});

// 选择特定摄像头和麦克风
const promise = navigator.mediaDevices.getUserMedia({
audio: {
deviceId: 'xxx'
},
video: {
deviceId: 'xxx'
}
});

// 请求 1080p 高质量视频和高清音频
const promise = navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: { ideal: 48000 },
sampleSize: { ideal: 24 },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
},
video: {
noiseSuppression: true,
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 },
}
});
  • constraints: constraints 参数是一个包含了 videoaudio 两个成员的 MediaStreamConstraints 对象,用于说明请求的媒体类型。必须至少一个类型或者两个同时可以被指定。如果浏览器无法找到指定的媒体类型或者无法满足相对应的参数要求,那么返回的 Promise 对象就会处于 rejected[失败]状态,NotFoundError 作为 rejected[失败]回调的参数。

    • audio: 音频参数用于控制麦克风音质、降噪、回声消除、自动增益 等。

      • deviceId: 指定音频输入设备。deviceId 通过 enumerateDevices() 获取所有麦克风。

      • sampleRate: 数字,指定音频的采样率。{ ideal: 48000 }, 48kHz(48000Hz)高清音质(推荐); { ideal: 44100 }, 44100Hz 标准 CD 质量; { ideal: 8000 }, 8kHz 低质量电话音质。

      • sampleSize: 采样大小(sampleSize)。{ ideal: 8 }, 8-bit; { ideal: 16 }, 16-bit 标准; { ideal: 24 }, 24-bit 高质量; { ideal: 32 }, 32-bit 专业级。

      • channelCount: 数字,指定音频的通道数。 1=单声道,2=立体声, 单声道(1):适合语音通话; 立体声(2):适合音乐录制;

      • autoGainControl: 布尔值, 指定是否启用 自动增益控制 autoGainControl 。 自动调整音量,防止声音过大或过小。

      • echoCancellation: 布尔值,指定是否启用 回声消除(echoCancellation。用于语音通话,减少耳机或扬声器导致的回声。

      • noiseSuppression: 布尔值,指定是否启用 噪声抑制(noiseSuppression。减少背景噪音(如风声、键盘声)。

    • video: 视频参数用于控制摄像头的行为,例如 分辨率、帧率、摄像头方向、设备 ID 等。

      • width: 数字, 指定视频宽度分辨率。比如: width: 1920 或者 width: { ideal: 1280 } 或者 width: { min: 1024, ideal: 1280, max: 1920}

      • height: 数字, 指定视频高度分辨率。比如: height: 1080 或者 width: { ideal: 1080 } 或者 width: { min: 576, ideal: 1080, max: 1280}

      • deviceId: 指定视频输入设备

      • frameRate: 数字,指定视频的 帧率(frameRate)。视频帧率(FPS),控制流畅度,通常 30FPS 适用于一般应用,60FPS 适用于高性能场景

      • facingMode: 字符串,指定选择前置还是后置摄像头。 user(前置摄像头),environment(后置摄像头)。如果要动态切换, 必须使用后置摄像头, 如果: facingMode: { exact: "environment" }

      • resolution: 对象,指定理想或精确的分辨率,如:{ width: 1280, height: 720 }

      • aspectRatio: 数字,指定视频宽高比、纵横比(aspectRatio), 比如: aspectRatio: 1.7777 或者 aspectRatio: { ideal: 1.7777777778 } 或者 aspectRatio: { min: 1, ideal: 1.7777777778, max: 2 }

      • noiseSuppression: 降噪, 布尔值。开启摄像头的降噪功能,提高视频清晰度(部分浏览器支持)。

  • promise: 返回一个 Promise,这个 Promise 成功后的回调函数带一个 MediaStream 对象作为其参数。

三、错误


3.1 AbortError

AbortError 中止错误 尽管用户和操作系统都授予了访问设备硬件的权利,而且未出现可能抛出 NotReadableError 异常的硬件问题,但仍然有一些问题的出现导致了设备无法被使用。

3.2 NotAllowedError

NotAllowedError 拒绝错误 用户拒绝了当前的浏览器实例的访问请求;或者用户拒绝了当前会话的访问;或者用户在全局范围内拒绝了所有媒体访问请求。

3.3 NotFoundError

NotFoundError 找不到错误 找不到满足请求参数的媒体类型。

3.4 NotReadableError

NotReadableError 无法读取错误 尽管用户已经授权使用相应的设备,操作系统上某个硬件、浏览器或者网页层面发生的错误导致设备无法被访问。

3.5 OverconstrainedError

OverconstrainedError 无法满足要求错误 指定的要求无法被设备满足,此异常是一个类型为OverconstrainedError的对象,拥有一个constraint属性,这个属性包含了当前无法被满足的constraint对象,还拥有一个message属性,包含了阅读友好的字符串用来说明情况。

因为这个异常甚至可以在用户尚未授权使用当前设备的情况下抛出,所以应当可以当作一个探测设备能力属性的手段[fingerprinting surface]。

3.6 SecurityError

SecurityError 安全错误getUserMedia() 被调用的 Document 上面,使用设备媒体被禁止。这个机制是否开启或者关闭取决于单个用户的偏好设置。

3.7 TypeError

TypeError 类型错误 constraints 对象未设置,或者都被设置为false

四、场景


4.1 获取流

const stream = navigator.mediaDevices.getUserMedia({
audio: true,
video: true
});

4.2 添加流

const stream = navigator.mediaDevices.getUserMedia({
video: true
});

const audioStream = navigator.mediaDevices.getUserMedia({
audio: true
});

audioStream.getAudioTracks().forEach((audioTrack) => stream.addTrack(audioTrack))

4.3 音频录制

4.4 视频录制

描述: 仅仅将获取的视频流和音频流, 链接到 video 元素

<div class="operation">
<button id="start-record">录制</button>
<button id="stop-record">停止</button>
</div>
<div class="video-container">
<video id="video" autoplay></video>
</div>

<script>
const video = document.getElementById('video');
const startRecordEl = document.getElementById('start-record');
const stopRecordEl = document.getElementById('stop-record');

async function startRecord() {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});

video.srcObject = stream;
video.play();
}

async function stopRecord() {
const stream = video.srcObject;
const tracks = stream.getTracks();

tracks.forEach(track => {
track.stop();
});

video.srcObject = null;
}

async function run() {
const camera = await checkPermissions('camera'); // 检测相机权限
const microphone = await checkPermissions('microphone'); // 检测麦克风权限
if (camera.state === 'granted' && microphone.state === 'granted') {
stopRecord();
startRecordEl.addEventListener('click', startRecord);
stopRecordEl.addEventListener('click', stopRecord);
} else {
alert('请允许使用摄像头和麦克风');
}
}

run();
</script>

描述: 将获取的视频流和音频流, 链接到 video 元素, 并且通过MediaRecorder获取视频流和音频流

<div class="operation">
<button id="start-record">录制</button>
<button id="stop-record">停止</button>
</div>
<div class="video-container">
<video id="video" autoplay></video>
</div>
<script>
let mediaRecorder = null;
const recordedBlobs = [];
const video = document.getElementById('video');
const startRecordEl = document.getElementById('start-record');
const stopRecordEl = document.getElementById('stop-record');

function startRecord() {
const timeSlice = 5000;
mediaRecorder.start(timeSlice);
}

async function stopRecord() {
const stream = video.srcObject;
const tracks = stream.getTracks();

tracks.forEach(track => {
track.stop();
});

video.srcObject = null;
mediaRecorder.stop();
}

async function prepareRecord() {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});

video.srcObject = stream;
video.play();

mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm'
});

mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) {
recordedBlobs.push(event.data);
}
};
}

async function run() {
const camera = await checkPermissions('camera'); // 检测相机权限
const microphone = await checkPermissions('microphone'); // 检测麦克风权限
if (camera.state === 'granted' && microphone.state === 'granted') {
prepareRecord();
startRecordEl.addEventListener('click', startRecord);
stopRecordEl.addEventListener('click', stopRecord);
} else {
alert('请允许使用摄像头和麦克风');
}
}

run();
</script>

4.5 拍摄照片

<div class="operation">
<button id="take-photo">拍摄</button>
</div>
<div class="video-container">
<video id="video" autoplay playsinline muted></video>
</div>
<div class="canvas-container">
<canvas id="canvas" hidden></canvas>
</div>
<div>
<img id="photo" alt="The screen capture will appear in this box." />
</div>
<script>
let height = 0;
const width = 320;
let streaming = false;
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const photo = document.getElementById('photo');
const takePhotoEl = document.getElementById('take-photo');

function takePhoto() {
const context = canvas.getContext('2d');
if (video.videoWidth && video.videoHeight) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
photo.setAttribute('src', canvas.toDataURL('image/png'));
}
}

function prepareTakePhoto() {
navigator.mediaDevices
.getUserMedia({ video: true })
.then(stream => {
video.srcObject = stream;
takePhotoEl.addEventListener('click', takePhoto);
})
.catch(err => {
console.error('navigator.getUserMedia error: ', err);
});
}

video.addEventListener(
'canplay',
function (ev) {
if (!streaming) {
height = video.videoHeight / (video.videoWidth / width);
video.setAttribute('width', width);
video.setAttribute('height', height);
canvas.setAttribute('width', width);
canvas.setAttribute('height', height);
streaming = true;
}
},
false
);

takePhotoEl.addEventListener(
'click',
function (ev) {
takePhoto();
ev.preventDefault();
},
false
);

prepareTakePhoto();
</script>

4.6 特定设备录制音频

描述: 选择特定的音频输入设备进行录制, 并支持切换设备。

4.7 特定设备录制视频

描述: 选择特定的视频输入设备进行录制, 并支持切换设备。在 Windows 操作系统中,通常一台摄像头设备在同一时间只能被一个进程所占用。如果你尝试在多个进程中访问同一个摄像头设备,通常会遭遇资源冲突的错误。这是由于摄像头驱动和系统资源管理机制导致的限制。

方案: 在浏览器中使用 JavaScript 检测摄像头是否被占用并不直接可能,因为浏览器的 API 通常不提供这种权限信息。浏览器的权限模型是设计来保护用户隐私的,所以它不会告诉一个页面是否有其他页面或应用正在使用摄像头。 然而,你可以尝试打开一个摄像头流来检测摄像头是否可用。如果摄像头已经被其他程序占用,getUserMedia() 方法通常会失败,并返回一个错误信息。

结论: 如果 getUserMedia() 成功,我们知道用户的摄像头是空闲的,所以可以进行正常操作。如果产生了一个错误,就可能是摄像头被占用,或者用户没有授权网页访问摄像头,或者摄像头根本就不存在。需要注意的是,即使能够成功地获取音视频流,这也不意味着可以清除之前的占用。如果摄像头被另一个浏览器标签页或其他应用程序占用,你的脚本不能直接中止那些会话;这是由操作系统和浏览器的隐私安全机制所控制的。

<div class="operation">
<button id="start-record">录制</button>
<button id="stop-record">停止</button>
<button id="switch-video-device">切换摄像头设备</button>
</div>
<div class="video-container">
<video id="video" autoplay></video>
</div>
<script>
let mediaRecorder = null;
const recordedBlobs = [];
const inputDevicesMap = {
audio: [],
video: []
};
const video = document.getElementById('video');
const startRecordEl = document.getElementById('start-record');
const stopRecordEl = document.getElementById('stop-record');
const switchVideoDeviceEl = document.getElementById('switch-video-device');

async function enumMediaDevices() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const inputDevices = devices.filter(item => {
return item.kind.endsWith('input') && item.deviceId !== '';
});
const audioInputs = inputDevices.filter(
item => item.kind === 'audioinput'
);
const videoInputs = inputDevices.filter(
item => item.kind === 'videoinput'
);

inputDevicesMap.audio = audioInputs;
inputDevicesMap.video = videoInputs;
} catch (error) {
console.error('enumerateDevices error:', error);
}
}

async function getStream(params) {
params = params || {};
const constraints = {
audio: { deviceId: params.audioDeviceId || 'default' },
video: { deviceId: params.videoDeviceId || 'default' }
};

try{
return await navigator.mediaDevices.getUserMedia(constraints);
}catch(error){
return null;
}
}

function startRecord() {
const timeSlice = 5000;
mediaRecorder.start(timeSlice);
}

async function stopRecord() {
const stream = video.srcObject;
const tracks = stream.getTracks();

tracks.forEach(track => {
track.stop();
});

video.srcObject = null;
mediaRecorder.stop();
}

async function prepareRecord(params) {
const stream = await getStream(params);

if(!stream){
return;
}

video.srcObject = stream;
video.play();

mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm'
});

recordedBlobs.length = 0;

mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) {
recordedBlobs.push(event.data);
console.log("recordedBlobs",recordedBlobs)
}
};
}

prepareRecord();
enumMediaDevices();

startRecordEl.addEventListener('click', startRecord);
stopRecordEl.addEventListener('click', stopRecord);
switchVideoDeviceEl.addEventListener('click', async () => {
const videoDeviceId = inputDevicesMap.video[0].deviceId;
prepareRecord({ videoDeviceId });
});
</script>

4.8 检测设备是否可用

描述: 选择特定的视频输入设备进行录制, 并支持切换设备。在 Windows 操作系统中,通常一台摄像头设备在同一时间只能被一个进程所占用。如果你尝试在多个进程中访问同一个摄像头设备,通常会遭遇资源冲突的错误。这是由于摄像头驱动和系统资源管理机制导致的限制。

方案: 在浏览器中使用 JavaScript 检测摄像头是否被占用并不直接可能,因为浏览器的 API 通常不提供这种权限信息。浏览器的权限模型是设计来保护用户隐私的,所以它不会告诉一个页面是否有其他页面或应用正在使用摄像头。 然而,你可以尝试打开一个摄像头流来检测摄像头是否可用。如果摄像头已经被其他程序占用,getUserMedia() 方法通常会失败,并返回一个错误信息。

结论: 如果 getUserMedia() 成功,我们知道用户的摄像头是空闲的,所以可以进行正常操作。如果产生了一个错误,就可能是摄像头被占用,或者用户没有授权网页访问摄像头,或者摄像头根本就不存在。需要注意的是,即使能够成功地获取音视频流,这也不意味着可以清除之前的占用。如果摄像头被另一个浏览器标签页或其他应用程序占用,你的脚本不能直接中止那些会话;这是由操作系统和浏览器的隐私安全机制所控制的。

async function checkDeviceIsCanUse() {
let stream = null;
try {
stream = await navigator.mediaDevices.getUserMedia({ video: true });
} catch (error) {
stream = null;
} finally {
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
}
return null;
}