You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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);

原因分析

  1. videoChunks状态同步问题:在startRecording中,localVideoChunks是局部变量,初始执行setVideoChunks(localVideoChunks)时存入的是空数组,后续ondataavailable事件中向局部的localVideoChunks添加数据,但组件状态中的videoChunks并未同步更新。自动停止时,stopRecording使用的是状态中空的videoChunks生成Blob,导致视频文件无效,上传时触发范围请求错误。
  2. onstop事件绑定时机错误:在stopRecording中绑定onstop事件,可能存在事件未正确绑定就执行stop()的情况,导致录制数据处理异常。
  3. 未清理定时器:组件卸载或重新录制时,之前的定时器未清除,可能导致重复触发stopRecording,干扰正常录制流程。
  4. getVideo函数重复执行:直接在组件顶层调用getVideo,每次组件渲染都会触发无限循环请求,可能影响录制与上传流程。

解决方案

  1. 使用useRef存储录制的videoChunks,避免React状态更新的异步特性导致数据丢失
  2. 在startRecording中提前绑定onstop事件,确保录制过程中事件正确触发
  3. 用useRef存储定时器ID,在组件卸载或重新录制时清除定时器
  4. 使用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.13 11:55:53