ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

H5端视频录制回溯方案:MediaRecorder与循环缓冲实战指南

H5端视频录制回溯方案:MediaRecorder与循环缓冲实战指南 开头就要像做过项目的人在复盘而不是写教科书。我得把这个标题拆得很透H5端视频录制这个技术栈其实很成熟了难点反而不在“录制”本身而在“回溯”这两个字上。我先想清楚回溯到底是什么场景然后给它落一个具体方案。先过一遍需求H5页面里打开摄像头录一段画面录完以后不是直接把整个视频交出去而是能在录制过程中或者录完之后回看某一个时间点的画面甚至能把“过去N秒”的片段单独抠出来。这个需求听起来有点像行车记录仪——一直在录但只在关键时刻保存事件发生前的画面。放到Web端就是摄像头开着内存里循环维护最近一段时间的画面用户点击“回溯保存”就把过去指定时长的片段封装成视频拿下来。这个功能我以前踩过不少坑。如果你现在打开掘金或者SegmentFault搜“H5录制视频”八成搜出来是教你直接用getUserMedia加MediaRecorder录一段完整视频然后upload。但“回溯”这个动作一加进来整个设计逻辑就不一样了。你是先启动录制再回溯还是边录边存还是录完之后拖进度条这三种产品形态对应三种技术方案复杂度完全不是一个量级。考虑到很多人是在公众号H5、uni-app打包的H5、或者App内嵌WebView里做这个功能我建议先想清楚自己属于哪种场景再决定写到哪一层。我这次会用一个比较通用的方案分段循环录制加回溯切片兼顾实现成本和用户体验。好下面我按实际开发顺序把这套东西讲透。先谈整体思路怎么拆解再逐个环节给代码和参数最后整理一份我真实踩过的坑清单你拿过去基本能直接复现。1. 需求拆解与方案选型先分清楚你要的是哪种“回溯”1.1 回溯功能的分层不是所有回溯都值得做“边录边存”我把市面上常见的“回溯”需求分成三层你在项目启动前必须先定档。第一层最简单叫“录后回看”。视频录完了允许用户拖动进度条重看某个节点甚至截取某一段存下来。这种做起来成本很低本质上是录制完成后的播放器交互。很多在线面试、口语测评、AI体感游戏都用这种后台把完整视频推到云端前端用video标签加currentTime控制就行。第二层叫“录制中回溯”。用户在录制过程中随时点一个按钮能回放“从上一分钟到现在的画面”。这个就需要本地有缓存因为流媒体正在实时写入你不可能把整段视频加载到内存反复拖进度条。常见的做法是分段录制加循环缓冲这也是我这次要重点讲的。第三层叫“事件触发式回溯”核心是连续性记录。比如AI视觉检测、安防监控、体育动作捕捉系统一直在录但平时不保存只在某个事件发生比如人体动作异常、客户进店时把事件前N秒的视频捞回来。国内很多做线下门店客流分析、运动姿态纠正的团队都在做这个核心逻辑是一套环形缓冲区内存里永远保留最近10秒或30秒的编码数据。我强烈建议你把需求定到第三层甚至第二层去设计因为第一层在Web端太容易被“阉割”。移动端H5的内存和网络都很脆弱一堆完整视频堆在内存里分分钟白屏。另外不管你最终做什么层级底层都绕不开getUserMedia、MediaRecorder、canvas帧捕获这三板斧我们先统一讲清原理。1.2 三大方案横向对比MediaRecorder / Canvas采样 / WebCodecs很多新手一上来就选MediaRecorder这没错但它不是所有场景的最优解。我做一个表把三个主流方案摊开对比。方案核心API浏览器兼容主流度延迟内存占用适用场景MediaRecorder分段录制MediaRecorder Blob timeslice高iOS 14.3支持安卓WebView参差低流式写入中大量Blob需处理录后回看、录制中回溯、事件回溯Canvas逐帧快照rAF drawImage WebAudio手动合成极高任何能跑canvas的浏览器都行高手动控制高帧图片占内存截图级回溯、封面预览、拍照模式WebCodecs直接编码VideoEncoder / AudioEncoder中低Safari近两年才完善最低最精细控制低原始帧直接编码低延迟直播、商务级自研播放器我在真实项目中Canvas方案一般只用来做“回溯封面的缩略图”不会真拿它去录视频。因为每帧drawImage到离屏canvas再编码成图片哪怕30帧截一帧一秒就得30张图内存直接爆炸。除非你只需要“一秒一张”的定格画面否则不建议。真正适合做“完整回溯”的还是MediaRecorder配合timeslice做分段。为什么因为浏览器自己帮我们把视频编码成了WebM或MP4分片我们把每个分片当成一个动态对象循环覆盖旧数据回溯时想取哪一段就取哪一段既不用手动拼帧也不容易爆内存。WebCodecs虽然更底层、更可控但它不给你搞定封装格式和音视频同步你得自己管理编码器状态、时间戳、容器写入开发周期至少翻三倍。对绝大多数“H5端视频录制回溯”的需求来说属于过度设计。我建议你先用MediaRecorder跑通流程如果后面遇到“首帧延迟高”或“WebView不支持”这种硬骨头再考虑WebCodecs局部替换。1.3 技术栈与运行环境微信H5 / uni-app / App内嵌WebView怎么选你可能注意到网上很多问这个需求的人最后场景都是“在微信公众号里打开H5页面”或者“用uni-app打包之后嵌入原生App”。这里有个很重要但容易被忽略的事实你的运行环境决定了API能力边界而不只是引入什么库。先说微信公众号JSSDK。如果你只是用H5的video标签播放视频不需要调用微信的能力但如果你要用公众号的录音、拍照、扫一扫能力就得在wx.ready回调之后调用wx.startRecord等接口。不过视频录制这块微信并没有提供专门的“视频回溯”API核心还是要靠浏览器标准的getUserMedia。在iOS微信内置WkWebView上getUserMedia权限机制是跟随系统Safari的必须走HTTPS且用户主动点击触发这点要提前规划。再说uni-app。很多团队习惯用uni.createCameraContext去调摄像头但这个方法返回的拍摄结果是一段普通视频文件你能拿到的是tempVideoFilePath而不是实时流。所以如果你想做“边录边回溯”这种动态功能uni.createCameraContext是不行的必须绕回H5的navigator.mediaDevices.getUserMedia。uni-app项目可以用条件编译在App端保留原生方案在H5端走Web API。最后说App内嵌WebView。这里有个“allow属性”的坑。如果H5页面被放在iframe或原生WebView里而webview没有给权限声明getUserMedia会直接NotAllowedError。安卓端需要在原生工程里配置android.permission.CAMERA和android.permission.RECORD_AUDIO。因为权限属于原生应用纯JS无法绕过。我建议你在做兼容层的时候把“能不能初始化摄像头”当成静态检测项而不是每次打开页面才报错。我在实际项目里一般会写一个checkCameraSupport()函数返回的是一个枚举值支持、权限被拒、环境不支持、非安全上下文等后面所有交互按钮都依赖这个值来控制。这就是开发的第一步先把运行环境摸清楚。2. 视频录制基础实现从权限申请到MediaRecorder落盘2.1 getUserMedia参数详解分辨率、帧率、摄像头方向不是乱填的视频录制第一步是获取摄像头流。核心代码看起来很简单但参数坑非常多。我贴一段我常用的封装。async function initCameraStream({ facingMode user, width 1280, height 720, frameRate 30, audio true, } {}) { if (!navigator.mediaDevices?.getUserMedia) { throw new Error(当前环境不支持getUserMedia); } const constraints { video: { facingMode, width: { ideal: width }, height: { ideal: height }, frameRate: { ideal: frameRate, max: 60 }, }, audio: audio ? { echoCancellation: true, noiseSuppression: true, autoGainControl: true, } : false, }; const stream await navigator.mediaDevices.getUserMedia(constraints); return stream; }这里几个参数我要单独解释。facingMode决定用前摄还是后摄。user是前置environment是后置。如果你给的是百度或安卓某些机型它们不一定严格遵守这个约束所以严格说它只是“偏好”。在样式上通常需要用video标签的CSStransform: scaleX(-1)把前置画面镜像回来否则用户看到的自己像是“反”的。width和height用ideal而不是exact是因为很多低端安卓机的摄像头分辨率只有固定的几档你写死exact: 4K相机直接初始化失败。用ideal的好处是让浏览器自动选择最接近的值兼容性最好。接入音频的时候三个音频处理开关尽量都开着。echoCancellation回音消除、noiseSuppression降噪、autoGainControl自动增益这在录口播、面试视频时特别重要不然对方能听到很吵的环境噪声。这里还要强调一下移动端的帧率。H5在移动端的性能有限如果帧率设到60fps大多数中端手机撑不到30秒就会发热、掉帧甚至摄像头黑屏。我个人建议短视频录制场景固定用24~30fps既保证流畅也不会让WebView内存峰值得太高。如果你后续要接AI动作分析那帧率反而要往高了设置甚至要手动控制采样率这个后面再聊回溯时细说。2.2 MediaRecorder创建与状态管理mimeType、timeslice、ondataavailable拿到stream之后创建MediaRecorder基本是模板代码但有几个细节容易踩雷。function createRecorder(stream, { mimeType video/webm;codecsvp8,opus, timeslice 3000, videoBitsPerSecond 2_500_000, } {}) { let preferredType mimeType; if (!MediaRecorder.isTypeSupported(preferredType)) { // 安卓上常见video/webmSafari上常见video/mp4 if (MediaRecorder.isTypeSupported(video/mp4)) { preferredType video/mp4; } else if (MediaRecorder.isTypeSupported(video/webm;codecsvp9,opus)) { preferredType video/webm;codecsvp9,opus; } else { preferredType ; } } const options { mimeType: preferredType || undefined, videoBitsPerSecond, }; if (timeslice) options.timeslice timeslice; const recorder new MediaRecorder(stream, options); const chunks []; recorder.ondataavailable (e) { if (e.data e.data.size 0) { chunks.push(e.data); } }; recorder.onstop () { const type preferredType || video/webm; const blob new Blob(chunks, { type }); const url URL.createObjectURL(blob); // 这里把url交给上层处理比如赋值给video.src或用于上传 }; recorder.onerror (err) { console.error(录制出错, err.error || err); }; return { recorder, chunks }; }第一个关键点是mimeType的探测。MediaRecorder.isTypeSupported很重要因为iOS Safari从14.3起才支持MediaRecorder而且支持的封装格式是video/mp4不是安卓常用的video/webm。如果你不做检测直接把video/webm扔给Safari它要么抛异常要么录出来是空白。我通常在创建录制器之前先用一行日志把这个值打出来方便区分手机类型。第二个关键点是timeslice。这个参数的意思是每隔多少毫秒触发一次ondataavailable事件把当前已经编码好的数据推给你。如果你不设置timeslice数据会在stop()时一次性返回这样你必须等录制完全结束才拿到数据无法做边录边传更无法做回溯循环缓冲。所以做回溯功能timeslice建议设在2000~5000毫秒之间。videoBitsPerSecond是视频码率。这个参数在移动端低网速场景下要动态调。我通常的做法是页面打开后先跑一个测速如果网速低于500kbps把码率降到1Mpbs以下甚至800kbps否则上传容易卡死如果在Wi-Fi环境下开到4Mpbs以上画质更清晰。顺便说一句MediaRecorder给的码率只是“期望值”浏览器不一定完全遵循但至少可以作为调优的参考。还有一个隐藏坑在iOS Safari上new MediaRecorder(stream)如果同时带音频和视频audio轨的采样率有时会导致整个录制片段时间戳错位症状是录出来视频画面正常、但没有声音或者声音比画面提前不少。遇到这个先用stream.getAudioTracks()看一下音频轨是否真的拿到了再加一个audio的channelCount: 2作为约束多数时候能解决。2.3 开始、暂停、恢复、停止这些状态变化别只靠UI录制过程中的状态管理很多项目会出低级事故用户点了停止但回调还没走完就立刻和服务端通信结果传了个空文件。所以一定要把MediaRecorder.state当成核心状态机来管。MediaRecorder一共有四种状态inactive、recording、paused、stopped。调用start(timeslice)会进入recording调用pause()会进入paused调用resume()回到recording调用stop()最终回到inactive。在移动端你还需要处理“应用切后台”的情况。页面进入后台后摄像头流可能被系统回收MediaRecorder虽然不会立刻崩但数据会一直不推过来。我见过最惨的情况是用户录了5分钟切后台一次再回来录制时间还在走但实际存的编码数据只有20秒。所以强烈建议监听visibilitychange事件切后台时自动暂停或停止录制并且给用户一个Toast提示。document.addEventListener(visibilitychange, () { if (document.hidden recorder.state recording) { recorder.pause(); // 通知UI层让用户知道录制已暂停 } if (!document.hidden recorder.state paused) { // 这里不自动恢复留着让用户主动操作更稳妥 } });我在项目里通常不自动恢复录制。原因是切回前台时摄像头流可能已经被回收了自动恢复多半会黑屏。不如让用户手动点“继续录制”重新拿到getUserMedia的视频流再续录逻辑更可靠。2.4 预览与视频播放objectURL和播放器标签的坑录完的视频要直接预览。这里有两个选择一是把Blob转成URL.createObjectURL赋给video标签的src二是把Blob通过FormData上传到自己的服务器再返回一个远程地址播放。本地预览我优先用URL.createObjectURL因为快、省流量后面如果想“回溯”某一段也全是本地操作。注意要及时释放URL.revokeObjectURL尤其是分段切片很多的情况下不释放会持续占内存。在iOS上如果你用objectURL播放一个video/webm格式的视频大概率是黑屏因为Safari不支持WebM播放但iOS录出来的是video/mp4所以这个坑一般只在安卓和PC上遇到。如果你的业务要求“录完必须实时存服务器”那上传之前一定要先把Blob转成二进制。服务器如果支持直传就用FormData加一个字段如果不支持再转FileReader.readAsArrayBuffer。后端要注意接收大文件时的超时设置移动端桥接特征比较强容易在这里卡个几十秒。function uploadBlob(blob, fileName record.webm) { const fd new FormData(); fd.append(file, new File([blob], fileName, { type: blob.type }), fileName); // 走你喜欢的fetch或axios return fetch(/api/video/upload, { method: POST, body: fd }); }3. 回溯功能设计环形缓冲加切片比想象中简单也比想象中麻烦3.1 回溯功能的两种产品形态循环保留最近N秒 / 任意时间点回看回溯功能的实现方案取决于你要的是“手动回溯”还是“自动回溯”。手动回溯就是你点击“回看”按钮之后呈现最近N秒的视频用户可以在播放器里拖到任意时间点这是体育训练类App最常见的交互。这种我推荐“切片数组时间戳表”的方案录制过程中每2~5秒产生一个小Blob存到数组里同时记录每段切片的开始时间回看时按时间定位并拼接。自动回溯更像行车记录仪。摄像头持续录但不断丢弃旧数据只保留最近30秒等到有事件发生时比如用户说“保存刚才的画面”直接把内存里的30秒片段导出。这个方案跟手动回溯类似只是“保留”和“丢弃”动作交给循环队列来做不需要用户拖进度条。我下面把两种形态统一成一个通用架构一个循环切片队列 一个时间索引表。这样不管是自动还是手动代码逻辑都很清晰。3.2 循环切片队列的实现数组越界的隐性风险设计一个固定长度的队列比如最多保留10段、每段3秒也就是30秒的回溯窗口。新的切片进来时如果队列已满就移除最老的一段。这里有个隐藏风险数组移除最老一段时如果该段的Blob还没被消费完播放器正在引用它就会出现“视频突然黑屏”。所以我在队列里管理的不是Blob本身而是Blob的UUID和引用计数。class SliceRingBuffer { constructor(maxSize) { this.maxSize maxSize; this.queue []; // 存 { id, blob, startTime, duration, refCount } this.indexMap new Map(); } push(slice) { if (this.queue.length this.maxSize) { const oldest this.queue.shift(); this.indexMap.delete(oldest.id); if (oldest.refCount 0) { URL.revokeObjectURL(oldest.blobUrl); } } // 给每个切片生成objectURL便于直接播放 slice.blobUrl URL.createObjectURL(slice.blob); slice.refCount 0; this.queue.push(slice); this.indexMap.set(slice.id, slice); } getSliceById(id) { const s this.indexMap.get(id); if (s) s.refCount; return s; } release(id) { const s this.indexMap.get(id); if (s --s.refCount 0) { URL.revokeObjectURL(s.blobUrl); } } getRecent(duration) { // 返回最近duration秒内的切片列表从旧到新 let acc 0; const result []; for (let i this.queue.length - 1; i 0; i--) { const slice this.queue[i]; acc slice.duration; result.unshift(slice); if (acc duration) break; } return result; } }这套环形缓冲的另一个关键点你不能把URL.createObjectURL在push的时候一次性创建太多。比如30秒回溯窗口、每段2秒最多也就15个objectURL还好。但如果窗口拉长到5分钟建议改成按需创建只在用户回看时创建这一段URL。否则移动端Safari的URL数量上限会让你崩溃。3.3 回溯时间线如何对齐每一段时间戳只存Blob不够还得知道每段时间上对应“用户看到的第几秒”。这段逻辑简单但容易粗心。我的做法是在recorder.start(timeslice)之后马上用performance.now()打一个时间戳startTs。每次ondataavailable触发时用当前时间减去startTs得到这段切片的相对开始时间同时记录它的“预期时长”本次dataavailable与上一次的时间差。因为实际上每次定时器触发可能有几十毫秒的偏差所以时间戳不能用“第几段”直接用“该段产生那一刻的本地时间”最靠谱。let sliceStart 0; function handleDataAvailable(e, recorderStartTime) { if (!e.data || e.data.size 0) return; const now performance.now(); const sliceDuration (now - sliceStart) / 1000; const sliceStartTime (sliceStart - recorderStartTime) / 1000; sliceStart now; const slice { id: Date.now().toString(36) Math.random().toString(36).slice(2), blob: e.data, startTime: sliceStartTime, duration: sliceDuration, }; ringBuffer.push(slice); }注意sliceStartTime是相对录制开始时间不是全球时间。这样后面做时间线播放器时直接把多个切片按startTime排序拼起来进度条就是相对录制开始后的第几秒。很多团队在这里会犯一个错误直接用Date.now()存时间戳。Date.now()受系统时间调整影响如果用户改了系统时间整个时间轴就乱了。所以一定要用performance.now()或至少用录制开始时的相对时间。3.4 回溯播放器多个切片无缝拼接播放手动回溯时用户看到的应该是连续画面不能有“第1段结束、第2段开始”的卡顿感。最简单的方式是把要回溯的所有切片按顺序拼成一个大的Blob再一次赋值给video标签。这个操作在分段不多的时候很有效。但如果回溯窗口很长拼接出来的Blob可能上百MB移动端内存会扛不住。所以我在实际项目里做的是“播放列表模式”计算好当前进度条对应哪一段切片然后动态切video.src。这个方案会更平滑但要注意中间衔接时老视频和新视频之间的当前时间对应关系。我这里给出一个先用Blob拼接的方案相对简单适合最多几十秒的回溯function concatSlices(slices) { const type slices[0]?.blob.type || video/webm; const mergedBlob new Blob(slices.map(s s.blob), { type }); return URL.createObjectURL(mergedBlob); }注意拼接出来的Blob能否顺利播放跟你录制的封装格式强相关。如果是video/webm大部分播放器能连续播放如果是video/mp4多个mp4分片直接拼成一个Blob很多浏览器是不认的你需要转封装或者只回看单段。所以我在做iOS兼容时“回溯”一般限制在单段以内或者通过WebCodecs重新编码而不是简单拼接MP4。这也是为什么我建议优先用WebM录制然后后端再转MP4天然利于前端做回溯切片。3.5 自动回溯事件触发的触发条件设计自动回溯的触发条件一般有两种一种是用户的主动操作如点击“保存这段”另一种是程序检测如动作识别、传感器到阈值等。对主动操作最简单直接调ringBuffer.getRecent(30)拿最近30秒切片。对自动检测要小心一个坑检测事件发生的时间点不一定等于你“开始回溯”的时间点。比如AI检测到用户起跳但起跳发生在T时刻保存时可能已经到T2了如果你只保存“当前时刻往前30秒”就会丢掉起跳瞬间。所以程序化触发最好同时记录检测时间向前回溯或向后保留一小段。我在项目里通常预留一个“事件前5秒、事件后5秒”的buffer检测时把包含事件前后各5秒的切片捞出来这样不会因为处理延迟丢关键画面。4. 移动端H5兼容性、性能与体验细节4.1 iOS Safari / 安卓WebView / 微信内置浏览器的差异汇总兼容性是我最想讲的部分因为代码写得再好真机一跑全翻车太常见了。先列一个我在项目里长期维护的兼容性表格。环境getUserMedia支持MediaRecorder支持推荐封装格式注意事项iOS Safari 14.3支持但必须HTTPS用户手势支持不传timeslice可能不触发ondataavailablevideo/mp4录完直接预览没问题分割回溯需自建时序iOS WkWebView微信支持需要权限弹窗经系统授权支持同Safarivideo/mp4弹窗需要在用户点击后立即触发延迟可能被系统拦截安卓 Chrome / 系统WebView支持但各家厂商兼容性差异大支持稳定度参差小米/OPPO有些版本偶发黑屏video/webm低端机建议降分辨率/fps安卓微信X5内核支持部分老版本不支持需检测video/webm 或 mp4优先做降级处理不支持就提示下载AppPC Chrome支持支持video/webm高清录制优先注意码率不要盲目开太高这里必须强调iOS Safari 14.3之前的版本不支持MediaRecorder。如果你的业务还需要覆盖iOS 13甚至12的老设备那基本只能退回“Canvas帧快照音频录制然后后端合成”的远古方案或者引导用户升级系统。我见过有些企业还把最低支持版本设到iOS 12导致视频录制功能上线半年没人用。4.2 内存与性能调优分段录制时的Blob生命周期管理移动端WebView的内存天花板比桌面低很多。即使你想“一直录”内存早晚会爆尤其录几十分钟那种。我通常做的几个优化第一自动分段。每录30秒或50MB可以用ondataavailable的e.data.size累计判断就主动stop()一次把这一大段转成Blob存到队列里然后立刻新建一个MediaRecorder继续录。这个过程用户无感知但内存峰值能被压得很低。第二及时释放URL.createObjectURL。所有创建过的objectURL播放完或者被替换后一定要revokeObjectURL。移动端Safari在这方面非常严格曾经遇到过只创建不释放页面在连续录像半小时后直接崩溃。第三降低Canvas的像素运算压力。如果你要做视频帧分析或缩略图不要每帧都处理建议使用requestAnimationFrame减速到每500ms处理一帧或者直接用MediaRecorder的timeslice回调来处理。4.3 弱网环境下的码率调节与服务端上传策略弱网是移动端尤其微信H5的常见场景。videoBitsPerSecond在创建MediaRecorder时写死其实不太行。我建议在进入录制页之前先简单测一下网速。async function estimateNetworkSpeed() { const startTime performance.now(); await fetch(/api/speedtest, { cache: no-store }); const elapsed (performance.now() - startTime) / 1000; // 假设测试文件大约200KB估算带宽 const speedKbps (200 * 8) / elapsed; return speedKbps; }然后根据测速结果把码率设置为网速在1500kbps以上3Mbps画质清晰适合回看网速在500-1500kbps1.5Mbps性价比高网速低于500kbps0.8Mbps保证能上传成功MediaRecorder在停止时产生的Blob通常比较大弱网下如果用fetch直接上传很容易超时或失败。我在项目里会配合“分段上报服务端合并”的方案录制过程中每段切片生成后立即把该切片上传到服务端临时目录录制结束再通知服务端按时间戳合并成完整文件。这样即使某一段上传失败也只丢那几秒不至于整个文件作废。这个方案对“回溯”特别合适因为回溯本身就是切片。4.4 权限被拒与降级体验摄像头打不开时别让页面白屏很多开发者把权限处理当成边缘需求但它实际上直接影响用户留存。一个合格的回溯录制功能至少要处理以下几种情况用户点了“允许”但系统设置里已经关闭了摄像头权限这个时候getUserMedia会直接reject错误为NotAllowedError。我建议检测到以后弹出引导层告诉用户去系统设置里开启并给一个“去设置”的按钮。非HTTPS环境下getUserMedia直接不支持。微信H5如果是通过http://访问开发环境需要手动配置HTTPS。iframe场景比如H5嵌入到其他App的WebView中父容器没有给allowcamera; microphonegetUserMedia会被block。我遇到过一例页面在桌面Chrome测试一切正常打包进App后打不开摄像头排查半天发现是客户端WebView的onPermissionRequest没有放行。这种情况前端无法解决需要在原生端配置所以你在开发文档里一定要把“原生端配合项”写清楚。降级体验也很重要。如果用户环境不支持摄像头或者权限被永远拒绝至少要让用户能选择“从相册上传视频”这样不会彻底卡死业务流程。我在表单类页面通常会把“拍摄/上传”做成两个Tab。5. 实测排坑与踩坑记录5.1 getUserMedia一直pending或直接报NotAllowedError症状页面调用getUserMedia后一直处于pending或者在手机浏览器上直接失败。排查步骤确认是否HTTPS。非localhost环境必须是HTTPS否则报InsecureContext。确认用户是否真正点击了按钮而不是在页面加载时直接调用。iOS Safari要求getUserMedia必须在用户手势的调用栈里触发否则会忽略或拒绝。确认嵌入的iframe是否设置了allow属性。这个坑最容易忽略。确认安卓WebView是否声明了权限。系统相机权限没开时错误信息可能是NotAllowedError或NotReadableError。还有一个容易被忽略的因素同时打开多个页面或标签页占用了摄像头第二次调用会失败或画面黑屏。需要给用户一个“重试”按钮释放上一次的stream再重新获取。5.2 录出来的视频在iOS端没有声音或只有黑屏这是高频问题。常见原因有三个。第一个是格式问题。iOS Safari生成的封装格式是video/mp4如果你在安卓上播放没问题但如果你拿video/webm在iOS上播放就黑屏。所以播放器要优先根据Blob的type选择video的格式支持。第二个是音频轨丢失。在iOS上创建MediaRecorder时如果音频约束里的echoCancellation/noiseSuppression同时为true部分机型会丢掉音频轨。我后来把音频约束改为{ echoCancellation: false, noiseSuppression: false, autoGainControl: false }测试问题反而缓解。这不是最优解但确实是实机验证出来的。如果你应用场景允许可以保留echoCancellation: true其余关掉。第三个是timeslice设为0或没设置。iOS Safari有已知bug不设置timeslice或设置得过大在stop()时可能只触发一次ondataavailable导致某些机型数据不完整。我实践下来timeslice设在2000ms数据稳定很多。5.3 循环缓冲导致内存暴涨objectURL没释放有次给客户做体育回放功能录了10分钟以后页面直接崩溃。用Chrome DevTools的Memory面板一看Blob和objectURL的Retained Size几百MB。原因是URL.createObjectURL(blob)创建后并不等于引用不占内存只有在被垃圾回收或者revokeObjectURL之后Blob才有机会释放。如果一直往环形队列里放切片、创建新URL但不释放旧的内存自然只增不减。解决办法就是我在3.2节里做的那样给每个切片维护refCount播放中的不释放播放后被替换的立即释放。同时环形窗口设置一个硬上限比如最多10段多了强制淘汰。5.4 回溯时多段拼接视频播放卡顿或黑屏拼接Blob的播放卡顿通常有两个原因。第一种是整个Blob体积太大尤其是MP4格式播放器必须下载完对应的moov元数据才能seek。第二种是分片之间的编码参数不一致比如前一段是30fps、后一段变成24fps播放器切换时会出现音频或画面中断。规避技巧保证每次创建MediaRecorder时videoBitsPerSecond、frameRate这类参数尽量一致不要中途改变摄像头约束。如果必须做长回溯推荐“单段切片播放”不要强行拼接整个大文件。播放时动态换video的src把卡顿问题拆到用户感知较少的地方。如果后端能转封装把WebM统一转成HLSM3U8或MP4之后再次切片服务端做时间轴前端只负责播HLS体验会好很多。5.5 真机调试的小技巧H5录制这种功能光靠桌面Chrome模拟器是不够的。我一般会在手机上这样操作安卓手机打开Chrome访问chrome://inspect在桌面Chrome里耦合DevTools查看getUserMedia的报错日志和MediaRecorder状态。iPhone连接Mac用Safari的“开发”菜单找到真机页面可以查看Web Inspector。在微信里调试时建议先用普通浏览器验证一遍再进微信测。如果是微信独有的问题有可能是内置内核差异也可能是X5内核没更新。有条件就准备一台老iPhoneiOS 13和一台千元安卓分别跑一遍。能兼容这两台基本覆盖90%的存量设备。6. 完整功能落地方案从录制到回溯的代码骨架为了让你能“抄作业”我把完整的核心流程串成一个类注释尽量详细。这不是完整生产代码但骨架可以直接用。class VideoRecorderWithReplay { constructor({ videoEl, replayEl, maxReplaySeconds 30, sliceMs 3000 } {}) { this.videoEl videoEl; this.replayEl replayEl; this.maxReplaySeconds maxReplaySeconds; this.sliceMs sliceMs; this.recorder null; this.stream null; this.buffer new SliceRingBuffer(Math.ceil(maxReplaySeconds / (sliceMs / 1000))); this.startTs 0; this.sliceStart 0; } async start() { this.stream await initCameraStream({}); this.videoEl.srcObject this.stream; await this.videoEl.play(); this._createRecorder(); } _createRecorder() { const { recorder, chunks } createRecorder(this.stream, { timeslice: this.sliceMs }); this.recorder recorder; this.startTs performance.now(); this.sliceStart this.startTs; recorder.ondataavailable (e) this._onData(e); recorder.onstop () this._onStop(); recorder.start(this.sliceMs); } _onData(e) { if (!e.data || e.data.size 0) return; const now performance.now(); const sliceDuration (now - this.sliceStart) / 1000; const sliceStartTime (this.sliceStart - this.startTs) / 1000; this.sliceStart now; this.buffer.push({ id: ${Date.now()}_${Math.random().toString(36).slice(2)}, blob: e.data, startTime: sliceStartTime, duration: sliceDuration, }); } _onStop() { // 停止后的资源清理比如释放stream tracks if (this.stream) { this.stream.getTracks().forEach(t t.stop()); } this.videoEl.srcObject null; } stop() { if (this.recorder this.recorder.state ! inactive) { this.recorder.stop(); } } getReplayBlob(duration this.maxReplaySeconds) { const slices this.buffer.getRecent(duration); if (!slices.length) return null; return concatSlices(slices); } loadReplay() { const url this.getReplayBlob(); if (url) { this.replayEl.src url; this.replayEl.controls true; } } }这段代码的核心是_onData里维护切片队列每一步都保留了startTime和duration。你要做“回溯N秒”调用getReplayBlob(N)你要在界面上展示时间线直接遍历buffer.queue把起止时间画在进度条上即可。_onStop里我做了stream.getTracks().forEach(track track.stop())这一步别省。如果摄像头一直不释放下一次调用getUserMedia可能失败而且麦克风指示灯会一直亮着特别容易被用户吐槽“侵犯隐私”。我一般还会加一个“合并手动分段”的逻辑如果业务需要录成一个大文件上传可以在stop之后把chunks拼成一个Blob统一上传和回溯切片并不冲突。回溯切片只保留最近一段而正式上传文件可以保留全部chunks这两个数据流是并行的。7. 后续扩展与个人沉淀功能做完之后很多需求会继续演进。我见过以下这些真实扩展方向你可以提前留好后门。第一加滤镜或贴纸。这个在视频录制里很常见但注意不要在getUserMedia的流上直接做滤镜因为MediaRecorder录的是原始流。正确做法是先用canvas把帧画出来做滤镜再把canvas的流canvas.captureStream()混流给MediaRecorder。这样录出来的视频就自带滤镜了。第二加AI动作分析。如果要做姿态识别、动作打分建议在录制时用requestAnimationFrame对每一帧做一个检测检测结果可以存成JSON数组回溯播放时根据时间戳把检测结果同步叠加到画面上。这个方案比录制后再分析更省时间。第三接H5游戏。很多H5游戏里需要录制玩家操作过程并把精彩瞬间回溯出来。那种场景其实不需要摄像头画面只需要录制canvas游戏画面。这个可以直接用canvas.captureStream()给MediaRecorder逻辑跟录制摄像头几乎一样。你只要把videoEl.srcObject换成canvas流就行。我这几年碰过不少这类项目最大的感触是技术本身不难难的是把边界想清楚。录像是API层面的活回溯是架构层面的活。如果你只是要在页面里录个短视频发后台那getUserMedia加MediaRecorder十分钟就能搞定但一旦涉及多段回看、内存优化、弱网上传、iOS兼容就必须在第一天就把数据模型设计好而不是事后再补。希望这套从方案选型到真机踩坑的记录能帮你少走一点弯路。
返回列表