<video>解析及分片上传

一、<video>标签解析

 <video
    ref="video"
    x5-video-player-type="h5"
    x5-video-player-fullscreen="true"
    x5-playsinline
    webkit-playsinline
    playsinline
    preload="auto"
    controlslist="nodownload noremoteplayback"
    disablePictureInPicture
    :src="src"
    :poster="videoImage"
    @play="onVideoPlay"
    @pause="onVideoPause"
    @ended="onEnded"
    @timeupdate="onVideoTimeUpdate"
    @error="onVideoError"
    @waiting="onWaiting"
    @click="changePlay"
    @loadedmetadata="loadedmetadata"
    @loadeddata="loadeddata"
    @canplay="onVideoCanPlay"
    @canplaythrough="canplaythrough"
    @seeking="onSeeking"
    @seeked="onseeked"
 />

常用属性解析

x5-video-player-type:(只支持安卓)启用同层H5播放器,就是在视频全屏的时候,div可以呈现在视频层上,也是WeChat安卓版特有的属性。同层播放别名也叫做沉浸式播放,播放的时候看似全屏,但是已经除去了control和微信的导航栏,只留下"X"和"<"两键。目前的同层播放器只在Android(包括微信)上生效,暂时不支持iOS。至于为什么同层播放只对安卓开放,是因为安卓不能像IOS一样局域播放,默认的全屏会使得一些界面操作被阻拦,如果是全屏H5还好,但是做直播的话,诸如弹幕那样的功能就无法实现了,所以这时候同层播放的概念就解决了这个问题。

x5-video-player-fullscreen:(只支持安卓)全屏设置。它又两个属性值,ture和false,true支持全屏播放,false不支持全屏播放。IOS 微信浏览器是Chrome的内核,不支持X5同层播放。安卓微信浏览器是X5内核,一些属性标签比如playsinline就不支持,所以始终全屏。

x5-playsinline:(只支持安卓)使视频内联播放

webkit-playsinline:可以使视频内联播放(解决IOS端,Android不支持)

playsinline:IOS微信浏览器支持小窗内播放,布尔属性,指明视频将内联(inline)播放,即在元素的播放区域内。请注意,没有此属性并不意味着视频始终是全屏播放的。

preload就:该枚举属性旨在提示浏览器,作者认为在播放视频之前,加载哪些内容会达到最佳的用户体验。可能是下列值之一:

  1. none: 表示不应该预加载视频。

  2. metadata: 表示仅预先获取视频的元数据(例如长度)。

  3. auto: 表示可以下载整个视频文件,即使用户不希望使用它。

  4. 空字符串: 和值为 auto 一致。每个浏览器的默认值都不相同,即使规范建议设置为 metadata。

controls:加上这个属性,浏览器会在视频底部提供一个控制面板,允许用户控制视频的播放,包括音量,跨帧,暂停/恢复播放。

controlslist :当浏览器显示视频底部的播放控制面板(例如,指定了 controls 属性)时,controlslist 属性会帮助浏览器选择在控制面板上显示哪些控件。允许接受的值有 nodownload, nofullscreen 和 noremoteplayback。如果要禁用画中画模式(和控件),请使用 disablePictureInPicture 属性。

disablePictureInPicture:防止浏览器显示画中画上下文菜单或在某些情况下自动请求画中画模式。该属性可以禁用 video 元素的画中画特性,右键菜单中的“画中画”选项会被禁用。

src:要嵌到页面的视频的 URL。可选;你也可以使用 video 块内的 <source> 元素来指定需要嵌到页面的视频。

poster:海报帧图片 URL,用于在视频处于下载中的状态时显示。如果未指定该属性,则在视频第一帧可用之前不会显示任何内容,然后将视频的第一帧会作为海报(poster)帧来显示。

loop:布尔属性;指定后,会在视频播放结束的时候,自动返回视频开始的地方,继续播放。

muted:布尔属性,指明在视频中音频的默认设置。设置后,音频会初始化为静音。默认值是 false, 意味着视频播放的时候音频也会播放。

常用事件解析

play:播放已开始。

pause:播放已暂停。

ended:视频停止播放,因为 media 已经到达结束点。(视频已播完)

timeupdate:currentTime 属性指定的时间发生变化。(视频播放时间变化,可以获取event.target.currentTime, event.target.duration)

error:视频错误处理

waiting:由于暂时缺少数据,播放已停止。(视频流加载中或者视频播放暂停)

click:点击视频

loadedmetadata:已加载元数据。

loadeddata:media 中的首帧已经完成加载。

canplay:浏览器可以播放媒体文件了,但估计没有足够的数据来支撑播放到结束,不必停下来进一步缓冲内容。(视频可以开始,但不确定是否会播放)

canplaythrough:浏览器估计它可以在不停止内容缓冲的情况下播放媒体直到结束。(视频一定可以可播放)

seeking:跳帧(seek)操作开始。

seeked:跳帧(seek)操作完成。

视频内联播放处理

为兼容ios和安卓,需要加上这几个属性:

x5-video-player-type="h5"
x5-video-player-fullscreen="true"
x5-playsinline
webkit-playsinline
playsinline

自动播放

android始终不能自动播放。ios10后版本的safari和微信都不让视频自动播放了(顺带音频也不能自动播放了),但微信提供了一个事件WeixinJSBridgeReady,在微信嵌入webview全局的这个事件触发后,视频仍可以自动播放,这个应该是现在在ios端微信的视频自动播放的比较靠谱的方式,其他如手q或者其他浏览器,建议就引导用户触发触屏的行为后操作比较好。

// 引用微信jssdk
<script-- src="http://xxxx/jweixin-1.6.0.js"></script>
// 页面初始化后执行自动播放
wx && wx.getNetworkType({
    complete: () => {
      const videoEle = this.$refs.video
      videoEle.play()
    }
})
// 或者这样
WeixinJSBridge.invoke('getNetworkType', {}, (e)=> {
    const videoEle = this.$refs.video
    videoEle.play()
});

二、调起系统摄像获取视频及视频时长

本地上传支持格式

视频在ios上本地上传后能播放的文件类型:mov,mp4,m4v(微信会压缩视频,而且限制大小,mov或者mp4后缀改m4v可防止视频在微信中被压缩),3gp(手机上能播放,但是手机上input标签选不了3gp格式的视频)
视频在安卓上本地上传后能播放的文件类型:mov,mp4,m4v,3gp(手机上能播放,但是手机上input标签选不了3gp格式的视频),webm
总结:视频在ios,安卓能在本地上传后都能播放的文件类型:mov,mp4,m4v

手机录像支持格式

视频在录像中支持上传的文件类型(手机自带摄像基本覆盖ios和安卓了):mov,mp4

三、h5调起系统摄像功能&获取视频时长

// html
<input ref="input" accept="video/*" type="file" capture="user" @change="onChange" />
<!-- 虚拟组件,不做展示,用来获取视频时长 -->
<video
  ref="video"
  :src="videoUrl"
  class="hidden-video"
  :muted="false"
  controls="controls"
  @loadedmetadata="loadedmetadata"
></video>

// js
/** 录制 */
onChange(event) {
  const { target } = event
  const file = target.files[0]
  this.videoFile = file
  if (!file) return
  this.isLoadedmetadata = false
  this.isSubmit = false
  // 释放内存
  if (this.videoUrl) {
    URL.revokeObjectURL(this.videoUrl)
  }
  // 这句后会执行loadedmetadata方法往下走
  this.videoUrl = URL.createObjectURL(file)
  console.log('videoUrl: ', this.videoUrl)
  // 需要先加载微信jssdk
  // 自动播放hack:兼容ios小程序webview中用户手动点击播放才会触发加载事件
  try {
    if (window.wx) {
      wx.getNetworkType({
        complete: () => {
          const videoEle = this.$refs.video
          videoEle.play()
          videoEle.pause()
        }
      })
    }
  } catch (e) {
    console.log(e)
  }
  setTimeout(() => {
    // 兜底:如果不兼容loadedmetadata则直接提交视频
    if (!this.isLoadedmetadata) {
      this.isSubmit = true
      this._videoUpload()
    }
  }, 2000)
},
/** 已加载好元数据:微信及浏览器支持 */
loadedmetadata(event) {
  console.log('loadedmetadata 视频时长为', event.target.duration)
  if (this.isSubmit) return
  this.isLoadedmetadata = true
  this.videoDuration = event.target.duration || 0
  this._videoUpload()
},
_videoUpload() {}

调起视频录制:当accept="video/*"时capture只有两种值,一种是user,用于调用面向人脸的摄像头(例如手机前置摄像头),一种是environment,用于调用环境摄像头(例如手机后置摄像头)
获取视频时长:通过input的file拿到video链接URL.createObjectURL(file),触发loadedmetadata事件拿到视频时长,需要注意的是ios的小程序webview中需要触发自动播放后才能触发加载事件。
资源预览:通过input=flie得到文件file对象,如果需要预览,不管是视频还是图片,可以通过URL.createObjectURL()来实现格式转换。通过此方法会得到本地的blob URL,然后将此URL赋值给img的src,或者video的src,即可预览。

export const getFileSrc = (file) => {
    return URL.createObjectURL(file)
}

用户录像后video加载事件兼容

环境 安卓 ios
浏览器 loadedmetadata,loadeddata,canplaythrough支持 只loadedmetadata支持,自动播放后三个都支持
微信 loadedmetadata,loadeddata,canplaythrough支持 只loadedmetadata支持,自动播放后三个都支持
小程序内嵌h5 loadedmetadata,loadeddata,canplaythrough 支持 都不支持,自动播放后三个都支持

四、视频及其他文件通用校验

通过对文件类型及大小进行校验判断。

// 文件类型
export const FILE_TYPE = {
  VIDEO: 'video',
  IMAGE: 'image',
  AUDIO: 'audio'
}

// 错误类型
export const ERROR_TYPE = {
  ERROR_FILE_TYPE: 'ERROR_FILE_TYPE',
  ERROR_FILE_SIZE: 'ERROR_FILE_SIZE'
}

// 文案
const TYPE_MAP_TEXT = {
  [FILE_TYPE.VIDEO]: '视频',
  [FILE_TYPE.IMAGE]: '图片',
  [FILE_TYPE.AUDIO]: '音频'
}

// 限制类型
const ACCEPT_TYPE = {
  [FILE_TYPE.VIDEO]: ['mp4', 'mov', 'm4v'],
  [FILE_TYPE.IMAGE]: ['png', 'jpeg', 'jpg'],
  [FILE_TYPE.AUDIO]: ['mp3', 'wav', 'mov']
}

// 限制大小: 单位M
const MAX_SIZE = {
  [FILE_TYPE.VIDEO]: 50,
  [FILE_TYPE.IMAGE]: 10,
  [FILE_TYPE.AUDIO]: 30
}

/**
 * 上传文件校验
 * @param {File} file 文件<arrayBuffer>
 * @param {Number} type 需要判断的文件类型:视频video,图片image,音频audio
 * @param {Array} acceptType 限制的文件类型
 * @param {Array} maxSize 限制的文件大小,单位 M
 * @param {String} tipMsg 错误提示
 * @returns { errorMsg: 错误信息, errorType: 错误类型 }
 */
export default function ({
  file,
  type = FILE_TYPE.VIDEO,
  acceptType,
  maxSize,
  tipMsg = '上传',
  errorFileTypeMsg = '',
  errorFileSizeMsg = ''
} = {}) {
  let errorMsg = ''
  let errorType = ''
  if (!file) return
  const fileMimeType = file.type
  const fileName = file.name
  const fileType = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
  const fileSize = file.size / 1024 / 1024 // M
  const fileAcceptType = acceptType || ACCEPT_TYPE[type]
  const fileMaxSize = maxSize || MAX_SIZE[type]
  const pageId = log.getPageId()
  console.log('file:', file)
  console.log(`${pageId}_upload_${type}_info`, `${fileType}_${fileSize}`)
  const isValidMimeType = fileMimeType.includes(type)
  // 判断类型
  if (!isValidMimeType || (isValidMimeType && !fileAcceptType.includes(fileType))) {
    console.log(`${pageId}_upload_type_error`, fileType)
    errorMsg =
      errorFileTypeMsg || `不支持当前${TYPE_MAP_TEXT[type]}格式,仅支持${fileAcceptType.join('、')} ,请重新${tipMsg}`
    errorType = ERROR_TYPE.ERROR_FILE_TYPE
  }
  // 判断大小
  else if (fileSize > fileMaxSize) {
    console.log(`${pageId}_upload_size_over`, fileSize)
    errorMsg = errorFileSizeMsg || `${TYPE_MAP_TEXT[type]}大小不能超过${fileMaxSize}M,请重新${tipMsg}`
    errorType = ERROR_TYPE.ERROR_FILE_SIZE
  }
  return { errorMsg, errorType }
}

调用通用校验:

const validInfo = isValidFile({
    file: this.videoFile,
    type: FILE_TYPE.VIDEO,
    acceptType: ['mp4', 'mov'],
    maxSize: 30,
    tipMsg: '录制',
    errorFileSizeMsg: `视频过大,请在3-5秒内录制`
  })
  if (validInfo.errorMsg) {
    return this.$toast.show({ content: validInfo.errorMsg })
  }

五、视频分片上传

视频分片思路:
1、将文件按照指定分片分成n等分,最后一份为剩余份数
2、按照视频分片顺序依次上传文件分片给后端
3、按照当前视频次序计算上传进度
4、上传成功/失败后回调
5、后端拿到分片视频按照视频顺序进行拼接,进行存储或者返回给前端

import { request } from '@/plugins/request'
import { UPLOAD_CHUNK_FILE } from '@const/api/modules/common'

const SHARD_UPLOAD_SIZE = 5 // 分片限制5M

/**
 * 分片上传
 * @param {File} file 文件<arrayBuffer>
 * @param {Number} maxChunkSize 分片最大size,默认5M
 * @param {Object} params 额外的业务参数
 * @param {Function} onProgress 上传进度回调
 * @param {Function} onSuccess 分片上传完毕回调
 * @param {Function} onError 错误回调
 * @returns 文件md5
 */
export class ChunkUpload {
  constructor({
    api = UPLOAD_CHUNK_FILE,
    file,
    maxChunkSize = SHARD_UPLOAD_SIZE,
    params = {},
    onProgress = () => {},
    onSuccess = () => {},
    onError = () => {}
  } = {}) {
    this.api = api
    this.file = file // 源文件
    this.fileSize = file.size // 文件大小
    this.fileName = file.name // 文件名
    this.shardSize = maxChunkSize * 1024 * 1024 // 分片大小
    this.shardCount = Math.ceil(this.fileSize / this.shardSize) // 分片数
    this.index = 0 // 当前分片序号 从0开始
    this.params = params
    this.fileInfoId = ''
    this.isUploading = false
    this.progress = 0 // 整个文件上传进度
    this.onProgress = onProgress
    this.onSuccess = onSuccess
    this.onError = onError
  }
  async upload() {
    // id:时间_文件大小
    this.fileInfoId = `${new Date().getTime()}_${this.fileSize}`
    this.uploadHandle()
  }
  async uploadHandle() {
    this.isUploading = true
    const start = this.index * this.shardSize
    const end = start + this.shardSize
    const packet = this.file.slice(start, end) // 将文件进行切片
    let chunkShardSize = this.shardSize
    if (this.shardCount === this.index + 1) {
      // 最后一片大小
      chunkShardSize = this.fileSize - this.index * this.shardSize
    }
    request({
      api: this.api,
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      params: {
        id: this.fileInfoId,
        chunkCount: this.shardCount, // 分片总长度
        fileName: this.fileName,
        length: this.fileSize,
        chunkIndex: this.index + 1, // 当前是第几片
        chunkLength: chunkShardSize, // 当前片大小
        file: packet // slice方法用于切出文件的一部分
      },
      onUploadProgress: (progressEvent) => {
        let currentProcessStatus = (progressEvent.loaded / progressEvent.total) * 100 || 0
        // 当前分片占整个文件的占比
        const chunkShardRatio = chunkShardSize / this.fileSize
        if (this.index + 1 < this.shardCount) {
          this.progress = chunkShardRatio * this.index * 100 + chunkShardRatio * currentProcessStatus
        } else {
          // 最后一片
          this.progress = (this.shardSize / this.fileSize) * this.index * 100 + chunkShardRatio * currentProcessStatus
        }
        this.progress = Math.floor(this.progress)
        this.onProgress(this.progress)
      }
    })
      .then((res) => {
        if (this.index + 1 >= this.shardCount) {
          const { fileName, fileId, fileHash, picFileId, picFileHash } = res.data.result || {}
          this.isUploading = false
          this.onProgress(100)
          this.onSuccess({
            fileName,
            fileId,
            fileHash,
            picFileId,
            picFileHash
          })
        } else {
          this.index < this.shardCount && this.index++
          this.uploadHandle()
        }
      })
      .catch((err) => {
        console.error('fail', err)
        this.onError(err)
      })
  }
}

调用分片上传:

// 分片上传
uploadFpsChunk() {
    const chunkUploadInstance = new ChunkUpload({
      api: UPLOAD_FPS_CHUNK,
      file: this.videoFile,
      onProgress: (progress) => {
        console.log('上传进度:', progress)
      },
      onSuccess: (fileInfo = {}) => {
        console.log('分片上传完毕:', fileInfo)
        console.log(fileInfo)
      },
      onError: (err) => {
        console.error('分片上传失败:', err)
        console.error({ msg: err.msg || '上传中断,请检查网络' })
      }
    })
    chunkUploadInstance.upload()
},

参考文档

  1. video标签特殊属性详解:https://blog.csdn.net/web_ding/article/details/112601894

  2. <video>: 视频嵌入元素:https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/video

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,406评论 5 475
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,976评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,302评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,366评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,372评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,457评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,872评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,521评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,717评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,523评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,590评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,299评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,859评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,883评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,127评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,760评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,290评论 2 342

推荐阅读更多精彩内容