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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.16 03:24:51