webRTC指定设备加自定义用户头像

做音视频业务是比较复杂的,单纯的调用往往不能满足后续复杂的迭代业务,如果没有经过实战验证光会使用API出了问题眼前一黑
场景1:适用于1路视频流1路音频流,通过replaceTrack切换桌面流业务;

1>如果用户手动点击浏览器原生按钮的话,音视频轨道会停止,这个时候需要重新创建音视频流;

2> 如果业务刚好有手动切换麦克风和摄像头的功能;那么停止后选择就近切换的麦克风摄像头逻辑是合理的,而不是直接选择系统默认;
场景2:客户有多个采集设备,平时用和会议用完全是不同的;需要指定特殊设备的情况下;

1>直接枚举设备navigator.mediaDevices.enumerateDevices(),如果没有授权返回是空数组;这个时候你需要主动触发navigator.mediaDevices.getUserMedia({audio: true});

2>'await tnavigator.permissions.query()' 有兼容性问题; ("granted"_已授权 / "denied"_已拒绝 / "prompt"'_待询问 / 未决定)

3>个别win谷歌版本只能使用'exact'严格模式匹配; 'ideal'设备不生效还是返回系统默认设备,

综上最理想的路径就是直接走严格模式,不匹配再走系统默认模式

效果图

当麦克风采集设备和摄像头采集设备有问题时;

原因是:有的用户你提示报错了,他也不会注意,后面会@你说视频黑屏了,你找还得去服务器看日志;这样所见所得;比较比较直观;

强制指定麦克风

1.调用handleExactMicMedia(); 使用'exact'强制匹配麦克风,不能存在就报错;

2.不满足调用handleDefaultMicMedia(); 返回系统默认

typescript 复制代码
// 指定麦克风设备id
let audioInputDevId = '';
/* 使用'exact'强制匹配麦克风,不能存在就报错 */
function handleExactMicMedia() {
    return new Promise((resolve, reject) => {
        if (!audioInputDevId) {return reject('没有指定设备');}
        const mediaConstraints = {
            audio: { 
                deviceId: { exact: audioInputDevId },
                echoCancellation: { ideal: true },
                autoGainControl: { ideal: true },
                noiseSuppression: { ideal: true },
            }
        }
        navigator.mediaDevices.getUserMedia(mediaConstraints).then((mediaStream) => {
            resolve({
                'mediaStream': mediaStream,
                'mediaConstraints': {audio: true},
                'originalTracks': [...mediaStream.getTracks()],
                'error': 1,
                'errorText': ''
            });
        }).catch((error) => {
            // 表示设备不存在
            if (error.name === 'OverconstrainedError' && error.constraint === 'deviceId') {
                audioInputDevId = '';
            }
            reject('指定设备失败');
        });
    })
}
// 创建系统默认麦克风轨道,不存在就创建个空的;
function handleDefaultMicMedia(onFail) {
    return new Promise((resolve) => {
        const mediaConstraints = {
            audio: {
                echoCancellation: { ideal: true },
                autoGainControl: { ideal: true },
                noiseSuppression: { ideal: true },
            }
        }
        navigator.mediaDevices.getUserMedia(mediaConstraints).then((mediaStream) => {
            resolve({
                'mediaStream': mediaStream,
                'mediaConstraints': {audio: true},
                'originalTracks': [...mediaStream.getTracks()],
                'error': 1,
                'errorText': ''
            });
        }).catch((error) => {
            const errorInfo = BackGetUserMediaError(error.message, 1)
            onFail(errorInfo);
            // 创建一个空stream
            const curEmptStream = handleCreateEmptyAudioTranck();
            resolve({
                'mediaStream': curEmptStream,
                'mediaConstraints': {audio: true},
                'originalTracks': [...curEmptStream.getTracks()],
                'error': 0,
                'errorText': errorInfo.message
            });
        });
    })
};
强制指定摄像头

1.调用handleExactCameraMedia(); 使用'exact'强制匹配摄像头,不能存在就报错;

2.不满足调用handleDefaultCameraMedia(); 返回系统默认

typescript 复制代码
// 指定摄像头设备id
let videoInputDevId = '';
function handleExactCameraMedia() {
    return new Promise((resolve, reject) => {
        if (!videoInputDevId) {return reject('没有指定设备');}
        const mediaConstraints = {
            video: { 
                deviceId: { exact: videoInputDevId },
                width: { ideal: 1280 },
                height: { ideal: 720 },
                // 移动设备优先前置摄像头,   后置摄像头值为:{ exact: "environment" }
                facingMode: "user",
                frameRate: { ideal: 15, max: 30 },
            }
        }
        navigator.mediaDevices.getUserMedia(mediaConstraints).then((mediaStream) => {
            resolve({
                'mediaStream': mediaStream,
                'mediaConstraints': {video: true},
                'originalTracks': [...mediaStream.getTracks()],
                'error': 1,
                'errorText': ''
            });
        }).catch((error) => {
            // 表示设备不存在
            if (error.name === 'OverconstrainedError' && error.constraint === 'deviceId') {
                videoInputDevId  = '';
            }
            reject('指定设备失败');
        });
    })
}
function handleDefaultCameraMedia(onFail, audioErrorText) {
    return new Promise((resolve) => {
        const mediaConstraints = {
            video: {
                width: { ideal: 1280 },
                height: { ideal: 720 },
                // 移动设备优先前置摄像头,   后置摄像头值为:{ exact: "environment" }
                facingMode: "user",
                frameRate: { ideal: 15, max: 30 },
            }
        }
        navigator.mediaDevices.getUserMedia(mediaConstraints).then((mediaStream) => {
            resolve({
                'mediaStream': mediaStream,
                'mediaConstraints': {video: true},
                'originalTracks': [...mediaStream.getTracks()],
                'error': 1,
                'errorText': ''
            });
        }).catch((error) => {
            const errorInfo = BackGetUserMediaError(error.message, 2)
            onFail(errorInfo);
            // 创建一个空stream
            const curEmptStream = handleCanvasVideoStream(audioErrorText, errorInfo.message);
            resolve({
                'mediaStream': curEmptStream,
                'mediaConstraints': {audio: true},
                'originalTracks': [...curEmptStream.getTracks()],
                'error': 0,
                'errorText': errorInfo.message
            });
        });
    })
};
公共方法utils
typescript 复制代码
// 获取错误
function BackGetUserMediaError(cause: string, mediaType: number) {
    let message = '';
    let mediaName= mediaType === 1 ? '麦克风' : '摄像头'
    if (cause === 'Permission denied') {
        message = `${mediaName}获取权限被拒绝,请检查浏览器是否开启使用权限`;
    } else if (cause === 'Requested device not found') {
        message = `未检测到${mediaName}设备`;
    } else if (cause === 'Could not start video source') {
        /* 
            1.QQ浏览器_设备被占用会走这里; 
            2.QQ浏览器_系统设置关闭摄像头应用使用权限; 
            3.谷歌浏览器_win7或低版本谷歌会存在临时拔掉摄像头但枚举可以拿到设备调用后会报错
        */
        message = '摄像头启动失败,可能被其它应用占用或被拔出或系统关闭了摄像头调用权限';
    } else if (cause === 'Device in use') {
        // 谷歌浏览,假如设备被占用的时候会报错
        message = `${mediaName}设备被另一个应用或进程占用`;
    } else if (cause === 'Starting videoinput failed') {
        // 火狐浏览器, 假如设备被占用的时候会报错
        message = '摄像头启动失败,可能被被另一个应用或进程占用';
    } else if (cause === 'Failed to allocate videosource') {
        // 火狐-win10系统设置关闭摄像头应用使用权限
        message = '摄像头启动失败,可能系统关闭了摄像头使用权限';
    } else if (cause === 'Permission denied by system') {
        /*  存在麦克风摄像头混用报错
            1.谷歌浏览器_win10系统设置关闭摄像头应用使用权限;
            2.谷歌浏览器_win10系统设置关闭麦克风应用使用权限;
            3.QQ浏览器_系统win10系统设置关闭麦克风使用权限
        */
        message = `${mediaName}启动失败,可能系统关闭了设备使用权限`;
    } else {
        message = `${mediaName}启动失败`
    };

    return {code: 0, cause, message};
}
/** 创建空的音频轨道 */
export function handleCreateEmptyAudioTranck() {
    let audioCtx = new AudioContext();
    let dest = audioCtx.createMediaStreamDestination();
    let aStream = dest.stream;
    return aStream;
}
/** 创建空的视频轨道 */
export function handleCanvasVideoStream(audioErrorText, videoErrorText) {
    const streamCanvas: any = new CreateEmptyVideoCanvasStream();
    if (audioErrorText) {
        streamCanvas.updateAudioErrorText(audioErrorText);
    }
    if (videoErrorText) {
        streamCanvas.updateVideoErrorText(videoErrorText);
    }
    return streamCanvas.getStream();
}
/* 存入当前用户信息, 方便绘制占位头像 */
let mediaStreamLoginInfo = {
    id: '',
    name: '',
    account: '',
}
class CreateEmptyVideoCanvasStream {
    myCanvas: HTMLCanvasElement;
    ctx: CanvasRenderingContext2D;
    streamCanvas: MediaStream;
    refreshTimer: any;
    backgroundCache: any;
    audioErrorText: string;
    videoErrorText: string;
    constructor() {
        this.myCanvas = document.createElement('canvas');
        this.ctx = this.myCanvas.getContext('2d');
        this.startDraw();
        this.streamCanvas = this.myCanvas.captureStream(15);
        this.bindEvent();
        this.refreshTimer = null;
        this.backgroundCache = null;
        this.audioErrorText = '';
        this.videoErrorText = '';
    }
    updateAudioErrorText(text: string) {
        this.audioErrorText = text;
       
    }
    updateVideoErrorText(text: string) {
         this.videoErrorText = text;
    }
    // 绑定事件
    bindEvent() {
        const videoTrack =  this.streamCanvas.getVideoTracks()[0];
        if (videoTrack) {
            videoTrack.addEventListener('ended', () => {
              this.destroy();
            });
        }
    }
    // 销毁
    destroy() {
        try {
            this.refreshTimer && clearInterval(this.refreshTimer);
            this.refreshTimer = null;
            this.myCanvas = null;
            this.ctx = null;
            if (this.streamCanvas) {
                this.streamCanvas.getTracks().forEach(track => {
                    track.stop();
                });
            }
            this.streamCanvas = null;
            if (this.backgroundCache) {
                this.backgroundCache.close();
                this.backgroundCache = null;
            }
        } catch(error) {console.error('摄像头占用canvas销毁执行失败');}
    }
    // 获取Stream
    getStream() {
        return this.streamCanvas;
    }
    // 开始绘制
    startDraw() {
        if (!this.myCanvas || !this.ctx) {
            return;
        };
        this.startAnimation();
    }
    startAnimation() {
        this.backstopAnimation();
    }
    // 绘制底部提示文字
    drawBottomText() {
        if (!this.myCanvas || !this.ctx) {return;};
        const canvas = this.myCanvas;
        const ctx = this.ctx;
        ctx.restore();
        //  4. 在主画布底部居中绘制"摄像头创建失败"提示
        ctx.font = '30px SimHei, Heiti, sans-serif';      // 提示文字大小
        ctx.fillStyle = 'white';       // 提示文字颜色(白色更醒目)
        ctx.textAlign = 'center';      // 水平居中
        ctx.textBaseline = 'bottom';   // 文字底部对齐(避免紧贴画布边缘)
        const tipY = canvas.height - 30;  // 距离主画布底部30px
        if (this.audioErrorText) {
            ctx.fillText(this.audioErrorText, canvas.width / 2, tipY);
        }
        if (this.videoErrorText) {
            ctx.fillText(this.videoErrorText, canvas.width / 2, tipY - 34);
        }
    }
    drawUseAvatar() {
        if (!this.myCanvas || !this.ctx) {return;};
        const canvas = this.myCanvas;
        const ctx = this.ctx;
        const useName = mediaStreamLoginInfo.name;
        const canvasWidth = 1280;
        const canvasHeight =  720;
        canvas.width = 1280;
        canvas.height = 720;
        // 保存上下文初始状态(重要:确保每次绘制都是干净的状态)
        ctx.save();
        // 重置变换矩阵
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        // 清空画布
        ctx.clearRect(0, 0, canvasWidth, canvasHeight);
        // 绘制黑色背景
        ctx.fillStyle = '#000000';
        ctx.fillRect(0, 0, canvasWidth, canvasHeight);
        // 计算蓝色方框的位置(居中)
        const boxSize = Math.min(120, canvasWidth * 0.2, canvasHeight * 0.2); // 自适应大小
        const boxX = (canvasWidth - boxSize) / 2;
        const boxY = (canvasHeight - boxSize) / 2 - 50; // 稍微向上移动一点,给下方名字留出空间
        // 圆角半径
        const borderRadius = 15;
        // 绘制蓝色方框
        ctx.fillStyle = '#1689F4';
        ctx.beginPath();
        // 左上角圆角
        ctx.moveTo(boxX + borderRadius, boxY);
        // 上边缘
        ctx.lineTo(boxX + boxSize - borderRadius, boxY);
        // 右上角圆角
        ctx.arcTo(boxX + boxSize, boxY, boxX + boxSize, boxY + borderRadius, borderRadius);
        // 右边缘
        ctx.lineTo(boxX + boxSize, boxY + boxSize - borderRadius);
        // 右下角圆角
        ctx.arcTo(boxX + boxSize, boxY + boxSize, boxX + boxSize - borderRadius, boxY + boxSize, borderRadius);
        // 下边缘
        ctx.lineTo(boxX + borderRadius, boxY + boxSize);
        // 左下角圆角
        ctx.arcTo(boxX, boxY + boxSize, boxX, boxY + boxSize - borderRadius, borderRadius);
        // 左边缘
        ctx.lineTo(boxX, boxY + borderRadius);
        // 左上角收尾圆角
        ctx.arcTo(boxX, boxY, boxX + borderRadius, boxY, borderRadius);
        // 闭合路径并填充
        ctx.closePath();
        ctx.fill();
        // 获取名字的第一个字
        const firstChar = useName.charAt(0);
        // 在蓝色方框中绘制第一个字(居中)
        ctx.fillStyle = '#FFFFFF'; // 白色文字
        ctx.font = `${Math.min(80, boxSize * 0.6)}px Arial`; // 自适应字体大小
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(firstChar, boxX + boxSize / 2, boxY + boxSize / 2);
        
        // 在蓝色方框下方绘制全名
        const nameFontSize = Math.min(40, boxSize * 0.3);
        ctx.font = `${nameFontSize}px Arial`; // 自适应字体大小
        const displayName = useName.length > 12 ? `${useName.substring(0, 12)}...` : useName;
        ctx.fillText(displayName, canvasWidth / 2, boxY + boxSize + 60);
        // 恢复上下文初始状态(确保后续绘制不受当前状态影响)
        ctx.restore();
    }
    // 保底绘制文字
    backstopAnimation() {
        try {
            this.refreshTimer = setInterval(() => {
                if (!this.myCanvas || !this.ctx) {
                    this.refreshTimer && clearInterval(this.refreshTimer);
                    this.refreshTimer = null;
                    return;
                };
                if (!this.streamCanvas) {return;}
                const videoTrack =  this.streamCanvas.getVideoTracks()[0];
                if (!videoTrack) {
                    return;
                }
                // 表示轨道已经嘎了
                if (videoTrack.readyState === 'ended') {
                    this.destroy();
                    return;
                }
                const canvas = this.myCanvas;
                const ctx = this.ctx;
                // 先保存当前状态,避免影响后续绘制(如果有的话)
                ctx.save();
                // 重置变换矩阵和清空画布,确保文字显示清晰
                ctx.setTransform(1, 0, 0, 1, 0, 0);
                ctx.clearRect(0, 0, 1280, 720);
                try { this.drawUseAvatar(); } catch(error) {}
                // 在主画布底部居中绘制"摄像头创建失败"提示
                try  {this.drawBottomText(); } catch(error) {}
                // 恢复状态
                ctx.restore();
            }, 200);
        } catch(error) {
            console.log('回执失败了', error);
            this.refreshTimer && clearInterval(this.refreshTimer);
            this.refreshTimer = null;
            setTimeout(() => {
                if (!this.myCanvas || !this.ctx) { return; };
                if (!this.streamCanvas) {return;}
                const videoTrack =  this.streamCanvas.getVideoTracks()[0];
                if (!videoTrack) { return; }
                // 表示轨道已经嘎了
                if (videoTrack.readyState === 'ended') {
                    this.destroy();
                    return;
                }
                this.backstopAnimation();
            }, 1000)
        }
    }
}
相关推荐
vfvfb3 小时前
音频批量加速 mp3批量加速1.5倍
音视频
ACP广源盛139246256733 小时前
GSV6701A@ACP#6701A产品规格详解及产品应用分享
网络·嵌入式硬件·音视频
EasyDSS3 小时前
视频推流平台EasyDSS无人机推流直播在安防监控中的智能应用
音视频·无人机
你好音视频3 小时前
FFmpeg FLV编码器原理深度解析
c++·ffmpeg·音视频
summerkissyou19874 小时前
Android10-Audio-音频焦点申请-调用流程
音视频
胡伯来了4 小时前
17 Transformers - 音频领域的任务类
音视频·transformer·transformers·大数据模型
TEL189246224774 小时前
IT6636:3输入1输出HDMI 2.1V重定时开关,内置MCU
音视频·实时音视频·视频编解码
简鹿视频4 小时前
怎么把mkv视频格式转换为asf视频格式
ffmpeg·音视频·实时音视频·视频编解码·格式工厂
八月的雨季 最後的冰吻5 小时前
FFmepg-- 37-ffplay源码- 播放器中音频输出模块
ffmpeg·音视频