React/Next.js中MediaRecorder setTimeout停止录制失败问题排查
问题:MediaRecorder自动停止录制触发
net::ERR_REQUEST_RANGE_NOT_SATISFIABLE错误 问题背景
开发的Web应用包含以下功能:
- 获取相机与麦克风权限
- 3秒倒计时后启动视频录制
- 支持手动停止录制,或录制满15秒自动停止
- 录制完成后上传至Firebase Storage,再转至coconut.io进行水印编码
目前手动停止录制功能正常,但添加自动停止逻辑后触发net::ERR_REQUEST_RANGE_NOT_SATISFIABLE错误,自动停止代码如下:
setTimeout(() => { stopRecording(); }, 15000);
原因分析
videoChunks状态同步问题:在startRecording中,localVideoChunks是局部变量,初始执行setVideoChunks(localVideoChunks)时存入的是空数组,后续ondataavailable事件中向局部的localVideoChunks添加数据,但组件状态中的videoChunks并未同步更新。自动停止时,stopRecording使用的是状态中空的videoChunks生成Blob,导致视频文件无效,上传时触发范围请求错误。onstop事件绑定时机错误:在stopRecording中绑定onstop事件,可能存在事件未正确绑定就执行stop()的情况,导致录制数据处理异常。- 未清理定时器:组件卸载或重新录制时,之前的定时器未清除,可能导致重复触发
stopRecording,干扰正常录制流程。 getVideo函数重复执行:直接在组件顶层调用getVideo,每次组件渲染都会触发无限循环请求,可能影响录制与上传流程。
解决方案
- 使用
useRef存储录制的videoChunks,避免React状态更新的异步特性导致数据丢失 - 在
startRecording中提前绑定onstop事件,确保录制过程中事件正确触发 - 用
useRef存储定时器ID,在组件卸载或重新录制时清除定时器 - 使用
useEffect包裹getVideo,仅当coconutJobId变化时执行状态查询
完整修复代码
import { useState, useRef, useEffect } from "react"; import useUploadFile from "../hooks/useUploadFile"; import useStepStore from "../stores/stepStore"; import useLanguageStore from "../stores/languageStore"; import Timer from "../components/common/Timer"; import StepBanner from "../components/common/StepBanner"; const mimeType = 'video/webm; codecs="opus,vp8"'; const VideoMessage: React.FC = () => { const setStep = useStepStore((state) => state.setStep); const language = useLanguageStore((state) => state.language); const { uploadFile, downloadURL, uploading, progress, coconutJobId } = useUploadFile(); const [permission, setPermission] = useState<boolean>(false); const [permissionClicked, setPermissionClicked] = useState<boolean>(false); const mediaRecorder = useRef<MediaRecorder | null>(null); const liveVideoFeed = useRef<HTMLVideoElement | null>(null); const [recordingStatus, setRecordingStatus] = useState<"inactive" | "recording">("inactive"); const [stream, setStream] = useState<MediaStream | null>(null); const [recordedVideo, setRecordedVideo] = useState<string | null>(null); const videoChunksRef = useRef<BlobPart[]>([]); // 改用useRef存储chunk const [loading, setLoading] = useState(false); const [countdown, setCountdown] = useState(0); const stopTimerRef = useRef<NodeJS.Timeout | null>(null); // 存储定时器ID const getCameraPermission = async () => { setRecordedVideo(null); if ("MediaRecorder" in window) { try { const videoConstraints = { audio: false, video: true }; const audioConstraints = { audio: true }; const audioStream = await navigator.mediaDevices.getUserMedia(audioConstraints); const videoStream = await navigator.mediaDevices.getUserMedia(videoConstraints); setPermission(true); setPermissionClicked(true); const combinedStream = new MediaStream([ ...videoStream.getVideoTracks(), ...audioStream.getAudioTracks(), ]); setStream(combinedStream); if (liveVideoFeed.current) { liveVideoFeed.current.srcObject = videoStream; } } catch (err) { alert((err as Error).message); } } else { alert("The MediaRecorder API is not supported in your browser."); } }; const handleStartRecording = () => { // 清除之前可能存在的定时器 if (stopTimerRef.current) { clearTimeout(stopTimerRef.current); stopTimerRef.current = null; } videoChunksRef.current = []; // 重置chunk存储 setCountdown(3); const countdownInterval = setInterval(() => { setCountdown(prevCountdown => { if (prevCountdown <= 1) { clearInterval(countdownInterval); startRecording(); return 0; } else { return prevCountdown - 1; } }); }, 1000); }; const startRecording = async () => { setRecordingStatus("recording"); if (stream) { const media = new MediaRecorder(stream, { mimeType }); mediaRecorder.current = media; // 绑定ondataavailable事件,向ref中添加chunk mediaRecorder.current.ondataavailable = (event) => { if (typeof event.data === "undefined" || event.data.size === 0) return; videoChunksRef.current.push(event.data); }; // 提前绑定onstop事件 mediaRecorder.current.onstop = () => { const videoBlob = new Blob(videoChunksRef.current, { type: mimeType }); const videoUrl = URL.createObjectURL(videoBlob); setRecordedVideo(videoUrl); uploadFile(videoBlob); videoChunksRef.current = []; // 重置chunk setPermission(false); setRecordingStatus("inactive"); }; mediaRecorder.current.start(); // 设置自动停止定时器并存储ID stopTimerRef.current = setTimeout(() => { stopRecording(); }, 15000); } }; const stopRecording = () => { // 清除自动停止定时器 if (stopTimerRef.current) { clearTimeout(stopTimerRef.current); stopTimerRef.current = null; } if (mediaRecorder.current && mediaRecorder.current.state === "recording") { mediaRecorder.current.stop(); } }; // 使用useEffect包裹getVideo,仅在coconutJobId变化时执行 useEffect(() => { if (!coconutJobId) return; const getVideo = async () => { setLoading(true); while (true) { try { const res = await fetch('/api/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ coconutJobId }) }); const data = await res.json(); if (data.job.status === "job.completed") { setLoading(false); setStep(4); break; } } catch (err) { console.error(err); setLoading(false); break; } await new Promise(resolve => setTimeout(resolve, 5000)); } }; getVideo(); }, [coconutJobId, setStep]); return ( <div className="w-3/4 m-auto flex flex-col justify-center align-center items-center"> <StepBanner /> <div className="w-full mb-5"> <p className="text-left font-bold text-xl">{language === "en" ? "Record a video to send" : "Enregistrer une vidéo à envoyer"}</p> </div> <div className="indicator "> <div className="video-player m-auto w-[300px] aspect-[4/5] bg-black relative"> {countdown > 0 ? ( <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 mt-[-100px] z-10 text-white font-bold text-[100px]"> <Timer time={3} /> </div> ) : ""} {recordingStatus === "recording" && ( <div className="absolute top-[35%] left-[85%] transform -translate-x-1/2 -translate-y-1/2 mt-[-100px] z-10 text-white font-bold text-[40px] flex items-center space-x-2"> <div className="badge badge-primary badge-md bg-[red]"></div> <Timer time={15} /> </div> )} {!recordedVideo ? ( <video ref={liveVideoFeed} autoPlay muted loop className="live-player w-[300px] aspect-[4/5]" poster="/cards/2023/card_1.jpg"></video> ) : null} {recordedVideo ? ( <div className="recorded-player"> <video className="recorded aspect-[4/5] w-[300px]" src={recordedVideo} autoPlay muted loop></video> </div> ) : null} </div> </div> <div className="video-controls w-full mb-20"> {!permission && !permissionClicked ? ( <button onClick={getCameraPermission} type="button" className="btn z-9999 w-full bg-[#c41f3e] my-2 text-center rounded-xl h-10 align-middle text-white border-none"> {language === "en" ? "Allow Camera" : "Autoriser la caméra"} </button> ) : null} {permission && recordingStatus === "inactive" ? ( <button onClick={handleStartRecording} type="button" className="btn z-9999 w-full bg-[#c41f3e] my-2 text-center rounded-xl h-10 align-middle text-white border-none"> {language === "en" ? "Start Recording" : "Commencer l'enregistrement"} </button> ) : null} {recordingStatus === "recording" ? ( <button onClick={stopRecording} type="button" className="btn z-9999 w-full bg-[#c41f3e] my-2 text-center rounded-xl h-10 align-middle text-white border-none"> {language === "en" ? "Stop Recording" : "Arrêter l'enregistrement"} </button> ) : null} {recordedVideo ? ( <button className="btn z-9999 w-full min-w-full bg-[#c41f3e] my-2 text-center rounded-xl h-10 align-middle text-white border-none" disabled> <span className="loading loading-spinner loading-md"></span> </button> ) : null} </div> </div> ); }; export default VideoMessage;
内容的提问来源于stack exchange,提问作者emartindev
相关产品推荐
相关产品推荐

