前端实现视频录制功能

一、功能代码

封装

// 请在以下环境调试使用媒体流功能:
// localhost域 ||  HTTPS协议域名 ||  file:///协议本地文件
// 其他情况,navigator.mediaDevices 的值或为 undefined。
// chrome调试参考 https://blog.youkuaiyun.com/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 {
  INIT_ERROR = 100, // 初始化失败
  START_RECORD_ERROR = 101, // 开始录制错误
  STOP_RECORD_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";
  videoEl.setAttribute("webkit-playsinline", "true");
  videoEl.setAttribute("x-webkit-airplay", "allow");
  videoEl.setAttribute("playsinline", "true");
  videoEl.setAttribute("x5-playsinline", "true");
  videoEl.setAttribute("x5-video-player-type", "h5");
  videoEl.setAttribute("x5-video-orientation", "h5");
  videoEl.setAttribute("x5-video-player-fullscreen", "true");
  return videoEl;
}

const navigator: NewNavigator = window.navigator;

// 初始化媒体流方法
(function initMediaDevices() {
  // 老的浏览器可能根本没有实现 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: MediaStream | null; // 记录媒体流
  private mediaRecorder: MediaRecorder | null; // 记录媒体录制流
  private recorderBlob: 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.recorderBlob = new Blob();
    this.imageUrl = "";
    this.videoEl = createVideoEl();
    this.stopRecordCallback = undefined;
    this.errorCallback = errorCallback;
    this.container.style.position = "relative";
    this.container.appendChild(this.videoEl);
  }
  // 截取最后一帧显示
  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 async getUserMedia(recordConfig?: RecordConfig) {
    const { front = true, width, height, audio = false } = recordConfig || {};
    const constraints = {
      video: {
        facingMode: front ? "user" : { exact: "environment" },
        width: { ideal: width },
        height: { ideal: height },
      },
      audio,
    };

    try {
      // 先清除存在的媒体流
      this.closeRecord();
      const stream = await navigator.mediaDevices.getUserMedia(constraints);

      // 初始化媒体流文件录制 ---start
      let chunks: Blob[] = [];
      this.mediaStream = stream;
      this.mediaRecorder = new MediaRecorder(stream);
      this.mediaRecorder.ondataavailable = (e: BlobEvent) => {
        chunks.push(e.data);
      };
      this.mediaRecorder.onstop = async () => {
        if (this.recording && this.stopRecordCallback) {
          const mimeType = this.mediaRecorder?.mimeType || "video/mp4";
          this.recorderBlob = new Blob(chunks, { type: mimeType });
          // 处理录制视频播放获取时长问题
          try {
            const fixBlob = await fixWebmDuration(this.recorderBlob);
            this.recorderBlob = fixBlob;
          } catch (error) {
            console.log(error);
          }
          try {
            this.stopRecordCallback({
              videoBlob: this.recorderBlob,
              imageUrl: this.imageUrl,
            });
          } catch (error) {
            console.log("录制完业务回调出错了:" + error);
          }
        }
        chunks = [];
        this.recording = false;
      };
      this.mediaRecorder.onerror = () => {};
      // 初始化媒体流文件录制 ---end

      // 初始化video播放内容 ---start
      const videoEl = this.videoEl;
      // 前置摄像头左右视觉效果对调
      videoEl.style.transform = front ? "rotateY(180deg)" : "";
      // 旧的浏览器可能没有srcObject
      if ("srcObject" in videoEl) {
        videoEl.srcObject = stream;
      } else {
        // 防止在新的浏览器里使用它,应为它已经不再支持了
        (videoEl as HTMLVideoElement).src = window.URL.createObjectURL(
          stream as any
        );
      }
      // 这里处理了一点关于ios video.play()播放NotAllowedError问题
      videoEl.onloadedmetadata = () => {
        const playPromise = videoEl.play();
        if (playPromise !== undefined) {
          playPromise
            .then(() => {
              videoEl.muted = true;
              setTimeout(() => {
                videoEl.play();
              }, 10);
            })
            .catch((error) => {
              console.log(error);
            });
        }
      };
      // 初始化video播放内容 ---end
    } catch (error: any) {
      let message = error.message;
      if (error.name === "OverconstrainedError") {
        message = `The resolution ${constraints.video.width.ideal}x${constraints.video.height.ideal} px is not supported by your device.`;
      } else if (error.name === "NotAllowedError") {
        message =
          "You need to grant this page permission to access your camera and microphone.";
      } else if (error.name === "PermissionDeniedError") {
        message =
          "Permissions have not been granted to use your camera and " +
          "microphone, you need to allow the page access to your devices in " +
          "order for the demo to work.";
      } else {
        message = `getUserMedia error: ${error.name}`;
      }

      this.errorCallback &&
        this.errorCallback({
          code: ErrorCode.INIT_ERROR,
          message,
        });
      console.log(message, error);
    }
  }

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

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

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

  // 关闭媒体流
  public closeRecord() {
    if (this.mediaStream) {
      try {
        const tracks = this.mediaStream.getTracks();
        tracks.forEach((track) => {
          track.stop();
        });
        this.mediaStream = null;
        this.mediaRecorder = null;
        this.recording = false;
      } catch (error) {
        console.log("error:", error);
      }
    }
  }
}

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”,实测有用
具体用法已经更新到封装的代码里

完!

<think>好的,用户想在鸿蒙系统的前端实现视频录制功能,需要了解实现方法、相关API、文档和示例代码。我需要根据提供的引用资料来整理答案。 首先,根据引用[3],鸿蒙当前仅支持使用AVRecorder进行视频录制,它集成了音频捕获、编码和封装功能,适合直接生成视频文件的场景。这说明用户应该使用AVRecorder来实现视频录制。 接下来,权限部分。引用[1]提到需要系统相册、麦克风和摄像头权限。所以需要指导用户如何配置这些权限,可能是在config.json中添加ohos.permission.MICROPHONE、ohos.permission.CAMERA和ohos.permission.READ_MEDIA。 然后是API部分。引用[3]和[4]提到了AVRecorder和相关库的链接,比如libnative_media_codecbase.so等。但用户需要的是前端实现,可能更关注JS API。不过现有引用中没有提到JS API的具体类,可能需要根据系统文档补充,比如@ohos.multimedia.media中的AVRecorder类。 开发步骤方面,应该包括初始化AVRecorder、设置参数、开始录制、停止录制等步骤。需要检查是否有示例代码,如引用[3]提到的开发步骤,可能需要结合代码示例来说明。 另外,引用[2]提到AudioCapturer用于音频录制,但用户的问题是关于视频录制,所以可能不需要这部分,除非需要同时处理音频,但AVRecorder已经集成音频,所以可能不需要单独处理。 关于动态库的链接,引用[4]中的CMake部分可能属于Native开发,而用户问的是前端,所以可能不需要这部分,或者需要说明在JS开发中是否需要配置这些库,但可能在前端框架中已经封装好了。 最后,确保示例代码使用JavaScript或ArkTS,并符合鸿蒙前端开发的规范。可能需要参考官方文档中的AVRecorder示例,配置参数如视频源、音频源、输出格式、文件路径等。 总结步骤:配置权限、导入模块、创建实例、设置监听、准备参数、开始/停止录制,并提供示例代码。同时,需要提醒用户注意事项,如权限申请、参数兼容性等。</think>在HarmonyOS前端实现视频录制功能需要按照以下步骤操作,涉及权限配置、API调用及参数设置: ### 一、权限配置 在`config.json`中添加三项权限: ```json "reqPermissions": [ {"name": "ohos.permission.MICROPHONE"}, {"name": "ohos.permission.CAMERA"}, {"name": "ohos.permission.READ_MEDIA"} ] ``` 权限说明: - 摄像头权限:获取视频输入源[^1] - 麦克风权限:录制音频流[^2] - 媒体读取权限:保存视频文件到相册[^1] ### 二、核心API说明 使用`@ohos.multimedia.media`模块的AVRecorder类: ```typescript import media from '@ohos.multimedia.media'; // 创建实例 let avRecorder: media.AVRecorder; media.createAVRecorder().then((recorder) => { avRecorder = recorder; }); ``` ### 三、实现步骤 1. **初始化配置** ```typescript let profile: media.AVRecorderProfile = { audioBitrate: 100000, // 音频码率 audioChannels: 2, // 立体声 audioCodec: media.CodecMimeType.AUDIO_AAC, audioSampleRate: 48000, fileFormat: media.ContainerFormatType.CFT_MPEG_4, videoBitrate: 2000000, // 视频码率 videoCodec: media.CodecMimeType.VIDEO_MPEG4, videoFrameWidth: 640, // 分辨率 videoFrameHeight: 480, videoFrameRate: 30 // 帧率 }; let config: media.AVRecorderConfig = { audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC, videoSourceType: media.VideoSourceType.VIDEO_SOURCE_TYPE_SURFACE_YUV, profile: profile, url: 'file://com.example/video.mp4', // 存储路径 rotation: 0 // 旋转角度 }; ``` 2. **事件监听** ```typescript avRecorder.on('stateChange', (state) => { console.log('Current state: ' + state); }); avRecorder.on('error', (err) => { console.error('Error occurred: ' + err.message); }); ``` 3. **录制控制** ```typescript // 开始录制 avRecorder.prepare(config).then(() => { return avRecorder.start(); }); // 停止录制 avRecorder.stop().then(() => { avRecorder.release(); }); ``` ### 四、完整示例代码(ArkTS) ```typescript import media from '@ohos.multimedia.media'; @Entry @Component struct VideoRecorder { private avRecorder: media.AVRecorder | null = null; build() { Column() { Button('开始录制') .onClick(() => this.startRecording()) Button('停止录制') .onClick(() => this.stopRecording()) } } async startRecording() { try { this.avRecorder = await media.createAVRecorder(); // 配置参数(参考上文) await this.avRecorder.prepare(config); await this.avRecorder.start(); } catch (err) { console.error('Start failed: ' + err.message); } } async stopRecording() { if (this.avRecorder) { await this.avRecorder.stop(); this.avRecorder.release(); } } } ``` ### 五、注意事项 1. 需要动态申请运行时权限[^1] 2. 视频编码参数需与设备支持的规格匹配[^3] 3. 文件存储路径需使用应用沙箱目[^3] 4. 录制过程中需保持幕常亮
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值