MediaRecorder录制视频丢失时长问题及代码优化咨询
问题解决与代码优化方案
一、解决录制视频丢失时长信息的问题
核心原因
直接通过MediaRecorder生成的MP4 Blob,其moov原子(存储视频元数据的核心部分)常位于文件末尾,浏览器的<video>元素无法快速读取该数据,导致无法识别总时长。另外,仅指定video/mp4作为MIME类型,可能让浏览器使用非标准编码,进一步加剧元数据缺失问题。
解决方案
1. 指定带具体编码的MIME类型
创建MediaRecorder时,明确指定符合标准的MP4编码格式,确保生成的视频结构规范:
// 优先使用带编码的标准MIME类型 const mimeType = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'; if (MediaRecorder.isTypeSupported(mimeType)) { recorder = new MediaRecorder(stream, { mimeType }); } else { // 降级到浏览器支持的MP4格式 recorder = new MediaRecorder(stream, { mimeType: 'video/mp4' }); }
2. 调整MP4文件的moov原子位置(原生实现)
录制完成后,将Blob转换为ArrayBuffer,手动把moov原子移到文件开头,让浏览器能直接读取元数据:
// 录制完成后的处理函数 const handleRecorderStop = async (chunks) => { const tempBlob = new Blob(chunks, { type: 'video/mp4' }); const arrayBuffer = await tempBlob.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); // 查找moov原子的起始位置 let moovStart = -1; for (let i = 0; i < uint8Array.length - 8; i++) { if ( uint8Array[i] === 0x6D && // 'm' uint8Array[i+1] === 0x6F && // 'o' uint8Array[i+2] === 0x6F && // 'o' uint8Array[i+3] === 0x76 // 'v' ) { moovStart = i - 4; // 原子长度占4字节,往前偏移4位 break; } } if (moovStart !== -1) { // 将moov移到文件开头,重组Blob const moovPart = uint8Array.slice(moovStart); const ftypAndMdatPart = uint8Array.slice(0, moovStart); const correctedArray = new Uint8Array([...ftypAndMdatPart, ...moovPart]); setBlob(new Blob([correctedArray], { type: 'video/mp4' })); } else { // 找不到moov则直接使用原Blob setBlob(tempBlob); } }; // 在recorder.onstop中调用 recorder.onstop = () => handleRecorderStop(chunks);
3. 临时Video元素触发元数据加载(简易兼容方案)
如果不想处理MP4结构,可以创建隐藏视频元素加载Blob,等待元数据加载完成后再更新状态:
recorder.onstop = () => { const tempBlob = new Blob(chunks, { type: 'video/mp4' }); const tempVideo = document.createElement('video'); tempVideo.src = URL.createObjectURL(tempBlob); tempVideo.addEventListener('loadedmetadata', () => { // 此时已能正确获取视频时长 setBlob(tempBlob); URL.revokeObjectURL(tempVideo.src); // 释放资源 }); };
二、代码优化方案
1. 类型安全与变量初始化
明确变量类型并初始化,避免隐式推断和未定义报错:
let preview: HTMLVideoElement | null = null; let recorder: MediaRecorder | null = null; let interval: NodeJS.Timeout | null = null;
2. 资源清理增强
确保所有媒体资源和临时URL被正确释放,避免内存泄漏:
const stopAllTracks = () => { preview?.srcObject?.getTracks().forEach(track => { if (!track.ended) track.stop(); }); }; const revokeBlobUrl = () => { const currentBlob = blob(); if (currentBlob) { URL.revokeObjectURL(URL.createObjectURL(currentBlob)); } }; // 组件销毁时清理资源 onCleanup(() => { if (interval) clearInterval(interval); stopAllTracks(); revokeBlobUrl(); }); // 删除录制时同步清理 const deleteRecording = () => { revokeBlobUrl(); setBlob(null); setCount(0); setState("idle"); };
3. 函数拆分与单一职责
将逻辑拆分为小函数,提升可读性和可维护性:
const initMediaStream = async () => { try { return await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); } catch (err) { setMessage((err as Error).message); throw err; } }; const initRecorder = (stream: MediaStream) => { const mimeType = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'; const recorderOpts = MediaRecorder.isTypeSupported(mimeType) ? { mimeType } : { mimeType: 'video/mp4' }; const recorder = new MediaRecorder(stream, recorderOpts); const chunks: Blob[] = []; recorder.ondataavailable = ({ data }) => data.size && chunks.push(data); recorder.onstop = () => handleRecorderStop(chunks); return recorder; };
4. 用户体验与状态优化
- 防止录制状态下重复触发录制;
- 统一时长格式化显示:
const formatCount = (seconds: number) => { const hours = Math.floor(seconds / 3600).toString().padStart(2, '0'); const mins = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0'); const secs = (seconds % 60).toString().padStart(2, '0'); return `${hours}:${mins}:${secs}`; }; // 启动录制时增加状态判断 const startRecording = async () => { if (state() !== "idle") return; // ...原有逻辑 };
5. 错误处理增强
为空值添加判断,避免运行时报错:
const stopRecording = () => { stopAllTracks(); recorder?.stop(); // 空值安全调用 if (interval) clearInterval(interval); setState("stopped"); };
优化后的完整代码
export default function RecordVideo() { const [state, setState] = createSignal<State>("idle"); const [blob, setBlob] = createSignal<Blob | null>(null); const [count, setCount] = createSignal<number>(0); const [message, setMessage] = createSignal<string>("start recording"); let preview: HTMLVideoElement | null = null; let recorder: MediaRecorder | null = null; let interval: NodeJS.Timeout | null = null; const formatCount = (seconds: number) => { const hours = Math.floor(seconds / 3600).toString().padStart(2, '0'); const mins = Math.floor((seconds % 3600) / 60).toString().padStart(2, '0'); const secs = (seconds % 60).toString().padStart(2, '0'); return `${hours}:${mins}:${secs}`; }; const stopAllTracks = () => { preview?.srcObject?.getTracks().forEach(track => { if (!track.ended) track.stop(); }); }; const revokeBlobUrl = () => { const currentBlob = blob(); if (currentBlob) { URL.revokeObjectURL(URL.createObjectURL(currentBlob)); } }; const handleRecorderStop = async (chunks: Blob[]) => { const tempBlob = new Blob(chunks, { type: 'video/mp4' }); const arrayBuffer = await tempBlob.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); let moovStart = -1; for (let i = 0; i < uint8Array.length - 8; i++) { if ( uint8Array[i] === 0x6D && uint8Array[i+1] === 0x6F && uint8Array[i+2] === 0x6F && uint8Array[i+3] === 0x76 ) { moovStart = i - 4; break; } } if (moovStart !== -1) { const moovPart = uint8Array.slice(moovStart); const ftypAndMdatPart = uint8Array.slice(0, moovStart); const correctedArray = new Uint8Array([...ftypAndMdatPart, ...moovPart]); setBlob(new Blob([correctedArray], { type: 'video/mp4' })); } else { setBlob(tempBlob); } }; const initMediaStream = async () => { try { return await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); } catch (err) { setMessage((err as Error).message); throw err; } }; const initRecorder = (stream: MediaStream) => { const mimeType = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'; const recorderOpts = MediaRecorder.isTypeSupported(mimeType) ? { mimeType } : { mimeType: 'video/mp4' }; const recorder = new MediaRecorder(stream, recorderOpts); const chunks: Blob[] = []; recorder.ondataavailable = ({ data }) => data.size && chunks.push(data); recorder.onstop = () => handleRecorderStop(chunks); return recorder; }; const startRecording = async () => { if (state() !== "idle") return; try { const stream = await initMediaStream(); setState("recording"); if (preview) preview.srcObject = stream; recorder = initRecorder(stream); recorder.start(); interval = setInterval(() => setCount(prev => prev + 1), 1000); } catch (err) {} }; const stopRecording = () => { stopAllTracks(); recorder?.stop(); if (interval) clearInterval(interval); setState("stopped"); }; const deleteRecording = () => { revokeBlobUrl(); setBlob(null); setCount(0); setState("idle"); }; const downloadRecording = () => { const currentBlob = blob(); if (!currentBlob) return; const a = document.createElement("a"); a.href = URL.createObjectURL(currentBlob); a.download = `recording-${new Date().toISOString().slice(0,19)}.mp4`; a.type = "video/mp4"; a.click(); URL.revokeObjectURL(a.href); }; onCleanup(() => { if (interval) clearInterval(interval); stopAllTracks(); revokeBlobUrl(); }); return ( <section> <Switch> <Match when={state() === "idle"}> <div onClick={startRecording} style={{ cursor: 'pointer', padding: '1rem', backgroundColor: '#eee', display: 'inline-block' }}> {message()} </div> </Match> <Match when={state() === "recording"}> <div> <video ref={el => preview = el} autoplay muted width="640" height="480" /> <div style={{ marginTop: '0.5rem' }}> <span>{formatCount(count())}</span> <button onClick={stopRecording} style={{ marginLeft: '1rem' }}> stop </button> </div> </div> </Match> <Match when={state() === "stopped" && blob()}> <div> <video src={URL.createObjectURL(blob()!)} controls width="640" height="480" /> <div style={{ marginTop: '0.5rem' }}> <button onClick={deleteRecording} style={{ marginRight: '1rem' }}>delete</button> <button onClick={downloadRecording}> download </button> </div> </div> </Match> </Switch> </section> ); }
内容的提问来源于stack exchange,提问作者Thomas
相关产品推荐
相关产品推荐

