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

React摄像头录制上传函数重复调用及分片顺序问题求助

类实时视频录制分片上传问题排查与优化

问题背景

开发类实时视频录制功能,每隔几秒将视频片段上传至云端,使用React实现,组件挂载时启动相机和MediaRecorder,卸载时停止。当前遇到两个核心问题:

  1. uploadToCloudinary上传函数被重复调用两次
  2. 视频分片无法严格按顺序上传,导致后端合并时顺序混乱
    同时希望获取低成本实时录制方案推荐。

现有代码

import React, { useRef, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { axiosServerInstance } from "@/utils/api/instances";
import { serverRoutes } from "@/utils/api/routes";

const cloudinaryUploadUrl = "https://api.cloudinary.com/v1_1/-----/video/upload";

const WebCam = () => {
  const videoRef = useRef(null);
  const mediaRecorderRef = useRef(null);
  const recordedChunksRef = useRef([]);
  const uploadIntervalRef = useRef(null);
  const [isUploading,setIsUploading]=useState(false)
  const isComponentMounted = useRef(true); 
  const proctorId = useSelector((state) => state?.assessment?.assessmentTestData?.report?.proctorId);

  const updateProctorVideoUrl = async (chunks) => {
    try {
        await axiosServerInstance.put(serverRoutes.submitVideoUrl, {
          proctorId,
          ...chunks,
        });
    } catch (error) {
      console.error("Error updating proctor:", error.response?.data || error.message);
    }
  };

  const uploadToCloudinary = async (blob) => {
    if (!isComponentMounted.current) return; 

    const formData = new FormData();
    formData.append("file", blob);
    formData.append("upload_preset", "-----");

    try {
      const response = await fetch(cloudinaryUploadUrl, {
        method: "POST",
        body: formData,
      });
      const data = await response.json();
      await updateProctorVideoUrl({
        asset_id: data.asset_id,
        public_id: data.public_id,
        url: data.url,
        proctorId,
      });
    } catch (error) {
      console.error("Error uploading video:", error);
    }
  };

  const startCamera = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ video: true });
      if (videoRef.current) {
        videoRef.current.srcObject = stream;
      }
      return stream;
    } catch (error) {
      console.error("Error accessing camera:", error);
      throw error;
    }
  };

  const startRecording = (stream) => {
    recordedChunksRef.current = [];
    const mediaRecorder = new MediaRecorder(stream, { mimeType: "video/mp4" });
    mediaRecorder.ondataavailable = (event) => {
      if (event.data.size > 0) {
        recordedChunksRef.current.push(event.data);
      }
    };
    mediaRecorder.onstop = async () => {
      if (recordedChunksRef.current.length > 0 && !isUploading) {
        setIsUploading(true);
        const blob = new Blob(recordedChunksRef.current, { type: "video/mp4" });
        await uploadToCloudinary(blob);
        recordedChunksRef.current = [];
        mediaRecorder.start();
        setIsUploading(false);
      }
    };
    
    mediaRecorder.start();
    mediaRecorderRef.current = mediaRecorder;
    
    uploadIntervalRef.current = setInterval(() => {
      if (mediaRecorder.state === "recording" && !isUploading) {
        mediaRecorder.stop();
      }
    }, 5000);
  };

  useEffect(() => {
    let stream;

    const init = async () => {
      isComponentMounted.current = true; 
      stream = await startCamera();
      startRecording(stream);
    };

    init();

    return () => {
      isComponentMounted.current = false; 

      if (uploadIntervalRef.current) {
        clearInterval(uploadIntervalRef.current);
      }

      if (stream) {
        stream.getTracks().forEach((track) => track.stop());
      }

      if (videoRef.current) {
        videoRef.current.srcObject = null;
      }
    };
  }, []);

  return <video ref={videoRef} autoPlay muted className="w-full h-full object-cover" />;
};

export default WebCam;

问题1:上传函数重复调用的修复

原因分析

  • setInterval与mediaRecorder.onstop的异步时序冲突:setInterval每5秒触发停止录制,而onstop回调中立即重启录制,可能导致两次ondataavailable事件的chunk被重复收集
  • isUploading状态更新是异步的,setIsUploading(true)后无法立即阻断setInterval的判断逻辑,导致重复触发停止录制

修复方案

改用递归setTimeout替代setInterval,确保每次上传完成后再调度下一次分片录制,避免时序重叠:

const startRecording = (stream) => {
  let mediaRecorder = new MediaRecorder(stream, { mimeType: "video/mp4" });
  mediaRecorderRef.current = mediaRecorder;

  const handleRecordAndUpload = async () => {
    if (!isComponentMounted.current || isUploading) return;
    mediaRecorder.stop();
  };

  mediaRecorder.ondataavailable = (event) => {
    if (event.data.size > 0) {
      recordedChunksRef.current.push(event.data);
    }
  };

  mediaRecorder.onstop = async () => {
    if (recordedChunksRef.current.length === 0 || isUploading) return;
    
    setIsUploading(true);
    try {
      const blob = new Blob(recordedChunksRef.current, { type: "video/mp4" });
      await uploadToCloudinary(blob);
      recordedChunksRef.current = [];
      mediaRecorder.start();
      // 上传完成后再调度下一次分片
      uploadIntervalRef.current = setTimeout(handleRecordAndUpload, 5000);
    } catch (error) {
      console.error("录制分片处理失败:", error);
    } finally {
      setIsUploading(false);
    }
  };

  mediaRecorder.start();
  uploadIntervalRef.current = setTimeout(handleRecordAndUpload, 5000);
};

// 组件卸载时清理setTimeout
useEffect(() => {
  // ... 原有初始化逻辑
  return () => {
    // ... 原有清理逻辑
    if (uploadIntervalRef.current) {
      clearTimeout(uploadIntervalRef.current);
    }
  };
}, []);

问题2:确保分片严格按顺序上传

核心思路

使用上传队列维护待上传分片,只有当前分片上传完成后,才处理队列中的下一个分片,同时给每个分片添加序号,后端可根据序号确认合并顺序。

实现方案

const WebCam = () => {
  // ... 原有ref和state
  const uploadQueueRef = useRef([]);
  const isProcessingQueueRef = useRef(false);
  const chunkCountRef = useRef(0); // 分片序号计数器

  // 队列处理函数
  const processUploadQueue = async () => {
    if (isProcessingQueueRef.current || uploadQueueRef.current.length === 0) return;
    
    isProcessingQueueRef.current = true;
    const { blob, index } = uploadQueueRef.current.shift();

    try {
      await uploadToCloudinary(blob, index);
    } catch (error) {
      console.error("分片上传失败,重新加入队列:", error);
      uploadQueueRef.current.unshift({ blob, index }); // 失败分片放回队列头部
    } finally {
      isProcessingQueueRef.current = false;
      if (uploadQueueRef.current.length > 0) {
        processUploadQueue(); // 递归处理下一个分片
      }
    }
  };

  // 上传函数新增分片序号参数
  const uploadToCloudinary = async (blob, chunkIndex) => {
    if (!isComponentMounted.current) return; 

    const formData = new FormData();
    formData.append("file", blob);
    formData.append("upload_preset", "-----");

    const response = await fetch(cloudinaryUploadUrl, {
      method: "POST",
      body: formData,
    });
    const data = await response.json();
    await updateProctorVideoUrl({
      asset_id: data.asset_id,
      public_id: data.public_id,
      url: data.url,
      proctorId,
      chunkIndex // 传递分片序号给后端
    });
  };

  // 修改onstop逻辑,将分片加入队列
  mediaRecorder.onstop = async () => {
    if (recordedChunksRef.current.length === 0 || isUploading) return;
    
    setIsUploading(true);
    try {
      const blob = new Blob(recordedChunksRef.current, { type: "video/mp4" });
      // 加入队列时携带分片序号
      uploadQueueRef.current.push({ blob, index: chunkCountRef.current++ });
      recordedChunksRef.current = [];
      mediaRecorder.start();
      processUploadQueue(); // 触发队列处理
    } catch (error) {
      console.error("录制分片处理失败:", error);
    } finally {
      setIsUploading(false);
    }
  };

  // ... 其他逻辑
};

低成本实时录制方案推荐

  1. WebRTC + 云对象存储:用WebRTC捕获媒体流,每隔几秒生成Blob上传至阿里云OSS、腾讯云COS等S3兼容存储,后端定时根据分片序号合并视频,成本低且兼容性好
  2. MediaRecorder + 云厂商视频处理服务:使用Cloudinary分段上传API、阿里云视频点播等自带分片合并能力的服务,无需自行开发后端合并逻辑,减少开发成本
  3. 开源WebRTC媒体服务器:如Kurento、Janus,适合需要实时转码、多人互动场景的团队,成本主要为服务器部署费用

内容的提问来源于stack exchange,提问作者Nick S

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.19 02:38:13