前端实现视频录制功能

一、记录开发中封装的视频录制功能

封装

// 请在以下环境调试使用媒体流功能:
// localhost域 ||  HTTPS协议域名 ||  file:///协议本地文件
// 其他情况,navigator.mediaDevices 的值或为 undefined。
// chrome调试参考 https://blog.csdn.net/weixin_49293345/article/details/112600213?spm=1001.2014.3001.5501

import fixWebmDuration from "webm-duration-fix";

declare global {
  interface Navigator {
    webkitGetUserMedia: any;
    mozGetUserMedia: any;
  }
}

class NewNavigator extends Navigator {
  mediaDevices: any;
}

export enum ErrorCode {
  INITE_ERROR = 100, // 初始化失败
  STARTRECORD_ERROR = 101, // 开始录制错误
  STOPRECORD_ERROR = 102, // 结束录制错误
}

export interface RecordConfig {
  front?: boolean; // 控制前后摄像头
  audio?: boolean; // 是否录制音频
  width?: number; // 获取一个最接近 width*height 的相机分辨率
  height?: number;
}

export interface ErrorData {
  code: ErrorCode;
  message: string;
}

export type ErrorCallback = (error: ErrorData) => void;

export interface GetUserMediaConfig {
  audio: boolean;
  video: {
    width?: { min?: number; ideal?: number; max?: number };
    height?: { min?: number; ideal?: number; max?: number };
    facingMode: "user" | { exact: "environment" };
  };
}

export type StopRecordCallback = (data: {
  videoBlob: Blob;
  imageUrl: string;
}) => void;

function createVideoEl(): HTMLVideoElement {
  const videoEl = document.createElement("video");
  videoEl.style.width = "100%";
  videoEl.style.height = "100%";
  videoEl.style.objectFit = "cover";
  videoEl.style.position = "absolute";
  videoEl.style.left = "0";
  videoEl.style.top = "0";
  return videoEl;
}

const navigator: NewNavigator = window.navigator;

// 初始化媒体流方法
const initMediaDevices = function () {
  // 老的浏览器可能根本没有实现 mediaDevices,所以我们可以先设置一个空的对象
  if (navigator.mediaDevices === undefined) {
    navigator.mediaDevices = {};
  }
  // 一些浏览器部分支持 mediaDevices。我们不能直接给对象设置 getUserMedia
  // 因为这样可能会覆盖已有的属性。这里我们只会在没有getUserMedia属性的时候添加它。
  if (navigator.mediaDevices.getUserMedia === undefined) {
    navigator.mediaDevices.getUserMedia = function (
      constraints: GetUserMediaConfig
    ) {
      // 首先,如果有getUserMedia的话,就获得它
      const getUserMedia =
        navigator.webkitGetUserMedia || navigator.mozGetUserMedia;

      // 一些浏览器根本没实现它 - 那么就返回一个error到promise的reject来保持一个统一的接口
      if (!getUserMedia) {
        return Promise.reject(
          new Error("getUserMedia is not implemented in this browser")
        );
      }

      // 否则,为老的navigator.getUserMedia方法包裹一个Promise
      return new Promise(function (resolve, reject) {
        getUserMedia.call(navigator, constraints, resolve, reject);
      });
    };
  }
};

let video_timer: any = null;
const debounce = (fun: () => void, time: number) => {
  if (video_timer) {
    clearTimeout(video_timer);
  }
  video_timer = setTimeout(() => {
    fun();
    video_timer = null;
  }, time);
};

class RecordJs {
  private readonly container: HTMLElement;
  private readonly videoEl: HTMLVideoElement;
  private recording: boolean; // 是否正在录制
  private mediaStream: any; // 记录媒体流
  private mediaRecorder: any; // 记录媒体录制流
  private recorderFile: Blob; // 录制到的文件
  private imageUrl: string; // 截取到的图片
  private stopRecordCallback?: StopRecordCallback; // 录制完回调
  private errorCallback?: ErrorCallback; // 错误回调

  constructor(container: HTMLElement, errorCallback?: ErrorCallback) {
    this.container = container;
    this.recording = false;
    this.mediaStream = null;
    this.mediaRecorder = null;
    this.recorderFile = new Blob();
    this.imageUrl = "";
    this.stopRecordCallback = undefined;
    this.errorCallback = errorCallback;
    this.container.style.position = "relative";
    this.videoEl = createVideoEl();
    initMediaDevices();
  }
  // 截取最后一帧显示
  public cropImage(): string {
    const canvas = document.createElement("canvas");
    canvas.width = this.container.clientWidth;
    canvas.height = this.container.clientHeight;
    canvas
      .getContext("2d")
      ?.drawImage(this.videoEl, 0, 0, canvas.width, canvas.height);
    const dataUrl = canvas.toDataURL("image/png") || "";
    this.imageUrl = dataUrl;
    return dataUrl;
  }
  // 调用Api打开媒体流
  private _getUserMedia(recordConfig?: RecordConfig) {
    const { front = true, width, height, audio } = recordConfig || {};
    const constraints = {
      video: {
        facingMode: front ? "user" : { exact: "environment" },
        width: { ideal: width },
        height: { ideal: height },
      },
      audio,
    };

    navigator.mediaDevices
      .getUserMedia(constraints)
      .then((stream: any) => {
        // 先清除存在的媒体流
        this.closeRecord();
        // 初始化媒体流文件 ---start
        let chunks: any = [];
        this.mediaStream = stream;
        this.mediaRecorder = new MediaRecorder(stream);
        this.mediaRecorder.ondataavailable = (e: BlobEvent) => {
          this.mediaRecorder.blobs.push(e.data);
          chunks.push(e.data);
        };
        this.mediaRecorder.blobs = [];
        this.mediaRecorder.onstop = async () => {
          this.recorderFile = new Blob(chunks, {
            type: this.mediaRecorder.mimeType,
          });

          // 处理录制视频播放获取时长问题
          try {
            const fixBlob = await fixWebmDuration(this.recorderFile);
            this.recorderFile = fixBlob;
          } catch (error) {
            console.log(error);
          }

          chunks = [];
          this.recording = false;
          if (this.stopRecordCallback) {
            try {
              this.stopRecordCallback({
                videoBlob: this.recorderFile,
                imageUrl: this.imageUrl,
              });
            } catch (error: unknown) {
              console.log("录制完业务回调出错了:" + error);
            }
          }
        };
        // 初始化媒体流文件 ---end

        // 初始化video播放内容 ---start
        // 旧的浏览器可能没有srcObject
        const videoEl = this.videoEl;
        // 前置摄像头左右视觉效果对调
        videoEl.style.transform = front ? "rotateY(180deg)" : "";
        if ("srcObject" in videoEl) {
          videoEl.srcObject = stream;
        } else {
          // 防止在新的浏览器里使用它,应为它已经不再支持了
          (videoEl as HTMLVideoElement).src =
            window.URL.createObjectURL(stream);
        }
        if (this.container.lastChild?.nodeName === "VIDEO") {
          this.container.removeChild(this.container.lastChild);
        }

        this.container.appendChild(videoEl);
        videoEl.onloadedmetadata = () => {
          // 这里处理了一点关于ios video.play()播放NotAllowedError问题
          const playPromise = videoEl.play();
          this.container.appendChild(videoEl);
          if (playPromise !== undefined) {
            playPromise
              .then(() => {
                videoEl.muted = true;
                setTimeout(() => {
                  videoEl.play();
                }, 10);
              })
              .catch((error) => {
                console.log(error);
              });
          }
        };
        // 初始化video播放内容 ---end
      })
      .catch((error: Error) => {
        this.errorCallback &&
          this.errorCallback({
            code: ErrorCode.INITE_ERROR,
            message: error.message,
          });
        console.log(error);
      });
  }

  // 打开媒体流
  public openRecord(recordConfig?: RecordConfig) {
    // 打开媒体流方法为异步 防止频繁切换前后摄像头调用媒体流非正常显示问题
    debounce(() => this._getUserMedia(recordConfig), 300);
  }

  // 开始录制
  public startRecord() {
    if (this.recording) {
      return;
    }
    this.recording = true;
    try {
      this.mediaRecorder?.start();
    } catch (error) {
      this.errorCallback &&
        this.errorCallback({
          code: ErrorCode.STARTRECORD_ERROR,
          message: "开始录制出错了:" + error,
        });
      console.log("开始录制出错了:" + error);
    }
  }

  // 停止录制
  public stopRecord(callback?: StopRecordCallback) {
    if (this.recording && this.mediaStream) {
      this.stopRecordCallback = callback;
      this.cropImage();
      // 终止录制器
      if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
        try {
          this.mediaRecorder.stop();
        } catch (error) {
          this.errorCallback &&
            this.errorCallback({
              code: ErrorCode.STOPRECORD_ERROR,
              message: "结束录制出错了:" + error,
            });
          console.log("结束录制出错了:" + error);
        }
      }
    }
  }

  // 关闭媒体流
  public closeRecord() {
    this.stopRecord();
    if (!this.mediaStream) return;
    const tracks = (this.mediaStream as MediaStream).getTracks();
    tracks.forEach((track) => {
      if (typeof track.stop === "function") {
        track.stop();
      }
    });
  }
}

export default RecordJs;

使用

import RecordJs from './RecordJs' // 引入 RecordJs 
const recordJs = new RecordJs(Dom元素, 错误回调函数) // 初始化 给定一个dom作为容器
recordJs.openRecord({ front: true }) // 开启前摄像头
recordJs.startRecord(); // 开始录制
recordJs.stopRecord((data)=>{
	// 传入结束录制回调
	// data对象返回视频blob和截取的最后一帧图片url
	// do something...
})

二、关于录制后播放问题

1.如何播放

recordJs.stopRecord((data)=>{
	// 传入结束录制回调
	// data对象返回视频blob和截取的最后一帧图片url
	// do something...
	const { videoBlob } = data;
	const videoFile = new File([videoBlob], 'filename.mp4', { type: 'video/mp4' });
	const videoUrl = URL.createObjectURL(videoFile)
	// 将 videoUrl 给到播放组件 video的src或者其他
	
	// 如果使用的是video.js第三方播放器播放,
	// videojs.src()调用时需指定type,
	videojs.src({ src: videoUrl, type: 'video/mp4' }) 
})

2.播放时长显示问题,进度条不显示,或者最后几秒才出现总时长

借助方法库 “webm-duration-fix”: “^1.0.4”,实测有用
具体用法已经更新到封装的代码里

完!

你可能感兴趣的:(javascript,前端,前端,音视频,javascript)