前端 视频录制剖析

Posted 很菜的小白在分享

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了前端 视频录制剖析相关的知识,希望对你有一定的参考价值。

前端 视频录制剖析

作者:@ 很菜的小白在分享
时间:2021年12月7日

音视频三部曲

前端 音频录制剖析
前端 视频录制剖析
前端 桌面共享剖析

介绍

身为一个优秀的前端 coder 我们可能会遇到各种各样的需求,昨天我接到了一个新的需求,需要在项目中添加一个视频录制功能【疑问】【疑问】【疑问】,为什么要实现这种东西呢? 身为打工人只能默默接收。
拿到需求的我一顿操作来到了MDN官网,潦草看了一下文档看起来很简单嘛,于是撸起袖子准备开始今天的 codeing。

       1. 目录

           1.1 授权摄像头

           1.2 处理设备返回的流

           1.3 录制视频

           1.4 生成视频文件

           1.5 其他

           1.5 完整代码

流程

1. 授权摄像头

html5 提供了Navigation.getUserMedia()【部分浏览器已废弃】MediaDevices.getUserMedia()【新】API,这里我们只讲解新API。
MediaDevices.getUserMedia() 会提示用户给予使用媒体输入的许可,媒体输入会产生一个 MediaStream(媒体流),里面包含了请求的媒体类型的轨道。此流可以包含一个视频轨道(来自硬件或者虚拟视频源,比如相机、视频采集设备和屏幕共享服务等等)、一个音频轨道(同样来自硬件或虚拟音频源,比如麦克风、A/D转换器等等),也可能是其它轨道类型。 —— MDN

注意

授权摄像头的 API 只能在 localhost 或 https 才可以拿到。

它返回一个 Promise 对象,MediaStream 就是从 resolve 中返回的,若用户拒绝授权或设备不可用则会触发 reject 返回错误信息。

语法

window.navigator.mediaDevices.getUserMedia().then(stream => 
	// resolve
).catch(error => 
	// reject
)

参数

options | Object

名称类型说明例子
audioboolean / Object授权音频Boolean: true / false | Object:
videoboolean / Object授权视频Boolean: true / false | Object: width: 1280,height:720
············

参考

2. 处理设备返回的流

经过权限获取后我们可以在结果中拿到 stream,这时我们要用一个容器来承载这些流数据,HTML5 还提供了另一个组件 <video> ,video 可以说是一个很强大的存在,目前我们在网页中看到的视频播放组件都是由 video 搭载的,同样它也可以搭载我们视频设备返回的流。
下面我们来看看 video 是如何来搭载我们的视频流的。

语法

<!-- HTML -->

<video id="video-record"></video>
/** javascript **/

let video = document.querySelector('#video-record')
function getUserMediaPermissions() 
	// 授权视频设备获取流数据
	window.navigator.mediaDevices.getUserMedia(video: true).then(stream => 
		// 将摄像头返回的流添加的 video 组件的 src 上,srcObject 是一个新属性
		if ('srcObject' in this.video) 
         	video.srcObject = stream
         else 
          	video.src = window.URL.createObjectURL(stream)
       		
	)

到这里就实现了将摄像头捕捉到的流通过 video 呈现到我们的网页了。是不是很开心。

我的仙人球!!咳咳~~ 因为摄像头是外接的像素不是特别清楚【呲牙】

3. 录制视频

重点来了,录制的核心部分。

原理

  1. 实时获取当前视频流轮询绘制到canvas上
  2. 将当前绘制的流(准确说是一个blob数据)添加到一个列表中
  3. 录制结束后将生成的 blobs 进行合并处理成一个整体,这时视频就诞生了。

创建画布视频捕获器

captureStream API 将会返回一个实时视频捕获的画布
语法

let mediaStream = canvas.captureStream(frameRate)
/*
frameRate: 设置双精准度浮点值为每个帧的捕获速率。如果未设置,则每次画布更改时都会捕获一个新帧。如果设置为0,则会捕获单个帧。
*/

轮询绘制 canvas

// JavaScript
let canvasOrigin = document.querySelector('#canvas-originally');
let canvasOriginContext = canvasOrigin.getContext('2d')
let video = document.querySelector('#video-record')

getUserMediaPermissions() 
	// 授权视频设备获取流数据
	window.navigator.mediaDevices.getUserMedia(video: true).then(stream => 
		// 将摄像头返回的流添加的 video 组件的 src 上,srcObject 是一个新属性
		if ('srcObject' in this.video) 
         	video.srcObject = stream
         else 
          	video.src = window.URL.createObjectURL(stream)
       		
       	video.onloadedmetadata = (e) => 
           	video.play()
            canvasDrawLoop()
        
	)

canvasDrawLoo() 
	canvasOriginContext.drawImage(video, 0, 0, 1280, 760);
	requestAnimationFrame(canvasDrawLoop);

初始化媒体录制器

主角MediaRecorder API提供了录制的接口。
参数

名称类型说明
treamstream | DOM将要记录的流,可以是getUserMedia创建的流或者来自
audio、video以及<canvas>DOM元素
optionsObject一个字典对象,包含mimeType(类型)、
audioBitsPerSecond、videoBitsPerSecond、bitsPerSecond

方法

名称参数说明
isTypeSupported()-返回一个Boolean值,来表示设置的MIME类型
是否被当前用户的设备支持。
pause()-暂停媒体录音
requestData()-请求一个从开始到当前接收到的,存储为Blob类型的录音内容。
或者是返回从上一次调用requestData()方法之后的内容)。
调用这个方法后,创建一个记录继续进行,但会出现新的Blob对象
resume()-继续录制之后被暂停的动作。
start()timeslice / Number开始录制媒体
stop()-停止侵权。再次触发dataavailable事件,
返回一个存储Blob内容的录音数据。之后不再记录

Event

名称参数说明
ondataavailable()-该事件可用于获取摄像的媒体资源
(在事件的 data属性中会提供一个可用的Blob对象。)
onstart()-处理 start事件,该事件在媒体开始录制时触发MediaRecorder.start()。
stop()-处理stop事件,该事件会在媒体录制结束时、媒体流(MediaStream)
结束时、或者调用MediaRecorder.stop() (en-US)方法后触发。
·········

参考
代码实现

<canvas id="canvas-originally" :width="cameraInfo.width" :height="cameraInfo.height"></canvas>
// JavaScript
// 用来存放视频 blob 数据
let streams = []
let canvas = document.querySelector('#canvas-originally');
let canvasStream = canvas.captureStream(25) // 该方法返回的是一个 canvas 实时视频捕获的画布

// 初始化视频录制器
let options =  mimeType: "video/webm; codecs=vp9" ;
let recorder = new MediaRecorder(canvasStream, options)
recorder.start(100)
// 监听获取媒体资源
recorder.ondataavailable = (event) => 
	streams.push(event.data)

recorder.onstop = () => 
	// 合并 blobs 
	let blob = new Blob(streams, 
        type: 'video/mp4'
    )
    // 生成文件
    generateFile(blob)
	// do something

recorder.onstart = () => /*do something*/
recorder.onerror = (error) => /*do something*/

在合并 blob 后可以通过 URL.createObjectURL(blob) 来生成一个 blobUrl 可以在浏览器中预览了,到这里我们的工作已经完成一半了。

4. 生成视频文件

现实场景中我们可能并不是单纯的去录制就OK了,我们要的是将这个视频保存到服务器,这个时候我们就需要将这个视频生成文件上传到服务器,因为这时的视频其实只是一个 blob 数据流,与File还是不同的。
直接上代码。

// JavaScript
generateFile(blob) 
	let filename = new Date().getTime() + '.mp4';
	let file = new File([blob], name, type: 'video/mp4')

是不是感觉很简单,没错,就是这么两行代码。下面介绍一下 File 这个API。

通常情况下, File 对象是来自用户在一个 <input> 元素上选择文件后返回的 FileList 对象,也可以是来自由拖放操作生成的 DataTransfer 对象,或者来自 HTMLCanvasElement 上的 mozGetAsFile() API。在Gecko中,特权代码可以创建代表任何本地文件的File对象,而无需用户交互。

File 对象是特殊类型的 Blob,且可以用在任意的 Blob 类型的 context 中。比如说, FileReader, URL.createObjectURL(), createImageBitmap() (en-US), 及 XMLHttpRequest.send() 都能处理 Blob 和 File。

语法

new File(bits, name, options)

参数

名称类型说明
bitsArrayBuffer
ArrayBufferView
Blob
Array
一个包含ArrayBuffer,ArrayBufferView,Blob,
或者 DOMString 对象的 Array — 或者任何这些对象的组合。
这是 UTF-8 编码的文件内容。
nameString文件名称,或者文件路径。
optionsObject包含文件可选属性:type, lastModified

属性

名称说明
File.lastModified返回当前 File 对象所引用文件最后修改时间,
自 UNIX 时间起始值(1970年1月1日 00:00:00 UTC)以来的毫秒数。
File.lastModifiedDate返回当前 File 对象所引用文件最后修改时间的 Date 对象。
File.name返回当前 File 对象所引用文件的名字。
File.size返回文件的大小。
File.webkitRelativePath返回 File 相关的 path 或 URL。
File.type返回文件的类型

参考

5. 其他

细心的同学可能发现了,我们生成的视频在本地播放器中无法拖动进度条。
这是个很严重的问题吗?
是的,灰常严重。
会有哪些问题?

  1. 首先产品经理肯定不会同意
  2. 用户体验不好
  3. 如果是要做视频切片处理的话会发现切出来的图片只有一张,别问为什么,因为我出现了。我的理解是,虽然我们录制了很久但始终为一帧,因为我们的进度无法拖动,也就没有时长的概念,导致获取到的视频长度为0,此时就只将视频的第一帧切出来了。

如何解决?
我因为时间问题就没太去研究这块了,找了一个插件 后续会研究一下这块

// JavaScript
// duration 长度可以通过开始录制时间和结束的时间算出来
fixWebmDuration(blob, duration, (fixedBlob) => 
    let blob = fixedBlob
    // 将处理后的 blob 生成文件
    this.generateFile(blob)
);

( 完 )
到这里就完成了视频录制的所有流程。如果在过程中遇到什么问题,可以私信我进行交流。

后续会更新一篇关于录屏的实现,敬请期待!!

前端 桌面共享剖析
前端 音频录制剖析

附完整代码:

<!-- HTML -->
<div class="video-record" v-show="cameraStatus">
	<div class="canvas-originally-container">
		<video id="video-record" ref="videoRecord"></video>
		<div class="status" v-if="recorderStatus"></div>
		<img src="../../../public/img/close.png" alt="" class="close-icon" @click="closeCamera">
		<canvas 
			id="canvas-originally" 
			:width="cameraInfo.width" 
			:height="cameraInfo.height" 
			ref="canvasOrigin"></canvas>
		<div class="start-record" @click="startRecord">
			<div class="start-record-inner"></div>
		</div>
	</div>
</div>
// JavaScript

<script>
const fixWebmDuration = require('../../utils/duration')
export default 
  name: 'videoFragmentation',
  data() 
    return 
      videoFile: 
        fileName: '20211009204948_1605318046468.mp4',
        url: 'http://demo-face-detection.obs.cn-east-3.myhuaweicloud.com/image/20211009204948_1605318046468.mp4'
      ,
      // 相机状态
      cameraStatus: false,
      cameraInfo: 
        time: 0,
        width: 1280,
        height: 760
      ,
      // 录制的视频播放器
      video: null,
      // 视频流列表
      streams: [],
      // 当前流数据
      curStream: null,
      // 录制实例化对象
      recorder: null,
      // 画布
      canvasOrigin: null,
      canvasOriginContext: null,
      // canvas 视频流
      canvasStream: null,
      // 录制后上传OBS生成的结果
      recorderVideo: 
        file: null,
        type: 2
      ,
      // 录制时间
      recorderTime: 10,
      // 录制进度
      recorderProgress: null,
      // 录制状态
      recorderStatus: false,
      loading: null,
      eventType: 'auto',
    
  ,
  mounted() 
    this.video = this.$refs.videoRecord
    this.canvasOrigin = this.$refs.canvasOrigin
    this.canvasOriginContext = this.canvasOrigin.getContext('2d')
    this.canvasStream = this.canvasOrigin.captureStream(25)
  ,
  methods: 
    /**
     * @description: 获取设备摄像头权限
     * @param  *
     * @return *
     */
    getUserMediaPermissions() 
      if (!window.navigator.mediaDevices.getUserMedia) 
        return;
      
      // 1. 获取用户摄像头权限
      window.navigator.mediaDevices.getUserMedia(video:  width:  ideal: 1024 ,
        height:  ideal: 776 )
        .then(stream => 
          this.curStream = stream

          // 2. 将摄像头返回的流赋给视频组件
          if ('srcObject' in this.video) 
            this.video.srcObject = stream
           else 
            this.video.src = window.URL.createObjectURL(stream)
          

          // 3. 监听数据加载完成
          this.video.onloadedmetadata = (e) => 
            // 4. 开始播放,并轮询绘制
            this.video.play()
            
            this.cameraStatus = true
            this.canvasDrawLoop()
          
        ).catch(error => 
          console.log('获取用户 Media 权限失败', error);
        )
    ,
    /**
     * @description: 开始录制
     * @param  *
     * @return *
     */
    startRecord() 
      this.recorderStatus = true
      this.initMediaRecorder(() => 
      	// 关闭摄像头使用
        this.curStream.getTracks()[0].stop()
        this.curStream = null
      )
    ,
    /**
     * @description: 生成mp4文件
     * @param  *
     * @return *
     * @param * blob 需要转 file 的 blob 数据
     */
    generateFile(blob) 
      let name = new Date().getTime()+'.mp4'
      let file = new File([blob], name, type: 'video/mp4')
      this.recorderVideo.file = file
    ,
    /**
     * @description: 初始化视频流记录
     * @param  *
     * @return *
     */
    initMediaRecorder(callback) 
      let options =  mimeType: "video/webm; codecs=vp9" ;
      // 1. 初始化视频录制
      this.recorder = new MediaRecorder(this.canvasStream, options);

      // 2. 获取媒体资源,ondataavailable 函数的回调中将返回每一帧的 blob 流文件
      this.recorder.ondataavailable = (event) => 
        this.streams.push(event.data)
      

      this.recorder.start(100)
		
      let duration = 0
      let startTime = 0

      // 3. 监听开始录制事件
      this.recorder.onstart = () => 
        	startTime = new Date().getTime()
        	this.recorderProgress = setInterval(() => 
	        	// 我的需求是录制10秒,所有这么写的
	          	this.cameraInfo.time += 1
	          	if (this.cameraInfo.time == this.recorderTime) 
	            	this.recorder.stop()
	          	
        	, 1000)
      

      // 4. 监听录制失败
      this.recorder.onerror = function (error) 
        console.log('error', error);
      

      // 5. 监听录制结束,结束后通过 Blob将流文件整合成类型为 mp4 的视频 blob 流
      this.recorder.onstop = (event) => 
        if (this.eventType == 'close') 
          this.resetCamera()
          return
        
        duration = new Date().getTime() - startTime
        
        let blob = new Blob(this.streams, 
          type: 'video/mp4'
        )
        
        fixWebmDuration(blob, duration, (fixedBlob) => 
          blob = fixedBlob
          this.recorderUrl = URL.createObjectURL(blob)
          console.log('recorderUrl', this.recorderUrl);
          // 6. 将 blob 转化为 File 文件
          this.generateFile(blob)
          callback()
          this.resetCamera()
        )

视频录制中尺寸及方向问题深入剖析

最近在处理视频录制的一些东西,趁闲暇时间把琐碎的东西整理下供后续用到查看

说到视频录制,现将整体流程图献上,让我们对视频录制有个整体的了解。

在整个流程中最为繁琐的点要属尺寸及方向,难点在预览及编码处理,本讲主要解析尺寸和方向。

谈到尺寸,脑袋里面一定要以下几个尺寸概念有所了解:

  • 预览帧尺寸
  • 拍摄帧尺寸
  • 视频编码尺寸
  • 显示预览控制尺寸

 预览帧尺寸

每台手机都支持很多预览帧尺寸,且长宽比例不一,通常我们选采用getSupportedPreviewSize接口获取系统支持的预览帧尺寸集合,然后在选出符合要求长宽比的尺寸集,最后再根据显示预览控件尺寸进行比对,选择一个刚好大于显示预览控件尺寸大小的预览帧尺寸即可,建议方法如下:

private Size chooseOptimalSize(SortedSet<Size> sizes) 
        if (!mPreview.isReady())  // Not yet laid out
            return sizes.first(); // Return the smallest size
        
        int desiredWidth;
        int desiredHeight;
        final int surfaceWidth = mPreview.getWidth();
        final int surfaceHeight = mPreview.getHeight();
        if (isLandscape(mDisplayOrientation)) 
            desiredWidth = surfaceHeight;
            desiredHeight = surfaceWidth;
         else 
            desiredWidth = surfaceWidth;
            desiredHeight = surfaceHeight;
        
        Size result = null;
        for (Size size : sizes)  // Iterate from small to large
            if (desiredWidth <= size.getWidth() && desiredHeight <= size.getHeight()) 
                return size;

            
            result = size;
        
        return result;
    

拍摄帧尺寸

拍摄帧尺寸和预览帧尺寸相似,也是采用getSupportedPictureSize接口获取系统支持的拍摄帧尺寸集,然后和预览帧尺寸选择类似,仅在最后选择时不是选择刚好大于显示预览控件尺寸的size,而是选择符合长宽比例最大的size,这个分辨率高点清晰。

视频编码尺寸

视频编码尺寸,也就是说你想让编码录制好的视频分辨率多少,通常这个尺寸需业务方按需设置不同档位,然后根据不同档位进行设置调整

显示预览控件尺寸

显示预览控件尺寸,只要在该控件进行layout行为后,即可通过getWidth()/getHeight()接口获取对应的尺寸。

对尺寸的选择,最好各种size长宽比例一致,这样可以避免很多因size不同导致的问题。

了解了尺寸问题后,对图像出现拉伸及显示模糊等问题,就能很快定位并将其解决掉。接下来,我们就重点突破方向问题

谈到方向也是先系统罗列下几个所需了解的方向概念:

  • 手机自然方向
  • 屏幕切换方向
  • 传感器取景方向
  • 预览帧方向
  • 拍摄帧方向

 接着来逐一讲解各个方向,所谓手机自然方向,就是指:手机横屏/竖屏状态,以左上角为原点,往右为x轴方向,往左为y轴方向

传感器取景方向就是相机取景录制图像数据的方向,默认情况下,手机的传感器取景方向一般都是手机的右上角,当手机横屏时手机的传感器取景方向就和手机自然方向相同。

说到这,恐怕有人感觉这些文件及图片展示都还是比较抽象,不理解这些手机自然方向,手机传感器取景方向,x方向,y方向到底有什么作用,完全是一堆莫名的概念充斥着天灵。换种表达方式吧,我们拿了张白纸准备写字,习惯的表达是不是从左上角开始逐行写下来,直至写完整张纸,其实手机屏幕显示也是如此,它也是按自然方向从左往右(x方向)进行逐行(y方向)渲染,最终呈现手机上展示的内容。现在有个问题,相机传感器取景方向在竖屏情况下,x方向是向下,y方向是从右往左,这样不做任何修改的话,相机获取到的图片数据在手机屏幕显示就是成90度差。这样说,你可能觉得还是不直观,让我们看下下图:

现在的问题来了传感器取景方向和手机自然方向都了解了,但取到的图像方向显示到屏幕上,用户到看不正常呀,那怎么让所见即手机所示呢?为获取正常的预览显示方向,让我们先来了解下以下两个概念:

屏幕切换方向

这个方向其实不理解也没关系,可以通过系统接口获取Activity.getWindowManager().getDefaultDisplay().getRotation(),这个

方法获取到的值表示,当前屏幕以自然方向为基准逆时针选择了多少度,也就是说如果此时手机顺时针旋转对应的角度即可回到手机自然方向,如图所示:

相机图片方向

说白了这个方向其实就是传感器取景方向,不过不了解也没有关系,通过Camera.CameraInfo.orietation可以获取到,这个角度表示如果图片做顺时针旋转对应的角度即可回到手机自然方向,正常显示在屏幕上,如图:

图中蓝色点为手机自然方向的坐标原点,即渲染原点;红色原点为原始图片坐标原点,说白了,就是让两个点重合,x方向和y方向相同,即可让图片正常显示到手机上,从图中所示也非常清晰可以说明当旋转90的时候,就可以正常显示

说了这么多,我们对屏幕选择方向,相机图片方向有了清晰的认知,接下来就可以进入正题,我们如何保证预览看到的图片是正常显示的,而非90、180、270度的错位显示。官方推荐做法如下:

public static void setCameraDisplayOrientation (Activity activity, int cameraId, android.hardware.Camera camera) 
	android.hardware.Camera.CameraInfo info = new android.hardware.Camera.CameraInfo();
	android.hardware.Camera.getCameraInfo (cameraId , info);
	int rotation = activity.getWindowManager ().getDefaultDisplay ().getRotation ();
	int degrees = 0;
	switch (rotation) 
		case Surface.ROTATION_0:
			degrees = 0;
			break;
		case Surface.ROTATION_90:
			degrees = 90;
			break;
		case Surface.ROTATION_180:
			degrees = 180;
			break;
		case Surface.ROTATION_270:
			degrees = 270;
			break;
	
	int result;
	if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) 
		result = (info.orientation + degrees) % 360;
		result = (360 - result) % 360;   // compensate the mirror
	 else 
		// back-facing
		result = ( info.orientation - degrees + 360) % 360;
	
	camera.setDisplayOrientation (result);

其中degrees就是屏幕旋转方向,info.orientation就是相机图像方向,最终获取的旋转方向就是通过这个两个方向获取的,可能很多同学对这段优秀的代码不了解,基本上都是copy使用,其实了解各个方向后,其实还是很简单的,实在还不懂,没关系,下面有图详解(该图以后置摄像头为例,前置加个mirror就可以):

 挑一个例子讲解下,你就很容易明白这个图了,那就以orientation=90 & degrees=270为例吧,你可以按着前文说明理解下,从这两个值可以获取到这些信息:1)屏幕已经往逆时针旋转了270度;2)相机图片需往顺时针转了90度;对1)而言,原本要顺时针旋转270度才能回到自然方向,对2)而言需要旋转90度才能回到自然方向也就是说此时相机图片相对于自然方向逆时针旋转了90度;当1)顺时针旋转了180时,相机图片原点就和屏幕原点重合了,此时预览图片就可以正常展示了。至于后续是否要一起再旋转90回到自然方向,就不是我们关心的事情了。我们仅关心图片在手机中正常展示即可。

采用官方的方法计算出旋转角度后,采用setDisplayOrientation设置后预览即可显示正常,但该方法仅仅是设置了预览帧方向,也就是你看到手机里的图片显示正常了,它不影响拍摄出来,录制出来的图像,那如何让拍摄出来的图片或视频显示也正常呢?

接下来就是拍摄帧方向和视频展示方向,其实这两个方向差不多,计算方式也一样,唯一不同的时采用不同的方式把方向信息写入图像或视频中,以供图片加载器或视频播放器获知这个方向信息作出响应旋转显示正常。

拍摄帧方向

方向记录采用Camera.Parameters.setRotation()

视频展示方向

方向记录采用MediaMuxer.setOrietationHint()

处理该方法其实官方也有推荐,也是结合屏幕切换方向以及相机图片方向进行处理(这个屏幕切换方向最好监听陀螺仪来获取)

 public void onOrientationChanged(int orientation) 
        if (orientation == ORIENTATION_UNKNOWN) 
            return;
        
        android.hardware.Camera.CameraInfo info = new android.hardware.Camera.CameraInfo();
        android.hardware.Camera.getCameraInfo(cameraId, info);

        orientation = (orientation + 45) / 90 * 90;
        int rotation = 0;

        if (info.facing == CameraInfo.CAMERA_FACING_FRONT) 
            rotation = (info.orientation - orientation + 360) % 360;
         else   // back-facing camera
            rotation = (info.orientation + orientation) % 360;
        
        mParameters.setRotation(rotation);
    

到此方向信息基本讲解完成,自行脑补横屏/竖屏、前置摄像头以及后置摄像头场景区别。

最后说明,文中示例说明图很多引用其他文章,但忘记原图链接,若原作者看到请知会,一定附上参考链接,感谢!

以上是关于前端 视频录制剖析的主要内容,如果未能解决你的问题,请参考以下文章

CSS3等视频重磅发布(附下载方式)

千锋HTML5大前端全套教程(视频+源码+学习路线图),免费领

最新HTML5大前端学习路线+视频教程(完整版)

视频分享尚硅谷前端HTML5视频_NodeJS核心技术视频

前端HTML5视频教程免费下载 – 业余草

尚硅谷前端HTML5视频_谷粒音乐实战视频教程免费下载