video标签常见问题整理

梳理笔记时发现了不少关于Video标签的问题,涉及到诸如自动播放、编码错误等问题,也包括背景虚化、视频外部字幕等业务场景,于是统一整理一下。

<!--more-->

1. 自动播放

移动端视频不能自动播放的问题由来已久,浏览器为什么要进行这个限制呢?

参考

大概意思是:

  • 自动播放可能会造成不好的用户体验,比如突然播放一段很尴尬的视频或声音
  • 自动播放浪费用户流量和手机电量

因此只有在下面这些场景中,autoplay属性才会生效

  • 静音或音量为0(这也是很多教程说设置muted属性的原因)

  • 用户和网页已有交互行为(包括点击、触摸、按下某个键等等)

    • 如果在页面初始化之后,直接调用video标签的play方法,会得到这样一个错误

      Uncaught (in promise) DOMException: play() failed because the user didn't interact with the document first. https://goo.gl/xX8pDD

  • 网站已被列入白名单,用户可以本地设置浏览器的autoPlayList白名单,移动端或者微信浏览器里面显然不太容易让用户去配置

  • 如果浏览器获得了autoplay policy权限,那么就可以将该权限委托给具有自动播放权限策略的跨源 iframe

但在某些业务场景下,需求方可能还是期望实现自动播放。

1.1. 通过视频帧图片模拟视频播放

参考:炫酷H5中序列图片视频化播放的高性能实现 - 张鑫旭

在某些使用视频作为页面背景的场景中,并不存在对视频播放进行控制的地方,只要有个可以动的背景,如果是这种场景,可以直接将视频转成图片,然后JavaScript控制图片循环展示,模拟视频播放效果。

这种方案实现成本比较低,通过一些工具提取视频帧,拿到图片列表就可以了。

但是缺点也很明显,不适合真正需要使用视频播放的场景中

  • 帧数过多,图片网络请求资源可能被阻塞
  • 如果需要控制播放速率、进度,需要手动实现
  • 没有声音,要单独播放音频

1.2. 监听页面交互事件调用play

既然需要用户与网页交互后再进行自动播放,那么可以引导用户进行一下交互,比如弹出一个confirm弹窗,用户点击之后,就可以播放视频了。

document.body.addEventListener('touchstart',playVideo)

这种交互体验比较差,并不是真正意义上的自动播放,但是对于单页应用某些特殊场景,比如从list页面跳转到detail页面,肯定会触发一个点击交互事件,这时候在detail页面也可以自动播放,用户是无感知的。

1.3. 配置Webview

如果网页是运行在可控制的App Webview里面,可以通过配置允许webview内的视频自动播放

Android端,调用setMediaPlaybackRequiresUserGesture参考

webView.getSettings().setMediaPlaybackRequiresUserGesture(false);

iOS端,需要设置mediaTypesRequiringUserActionForPlayback参考

其中audio表示音频自动播放、video表示视频自动播放、all表示音视频全部自动播放

@available(iOS 10.0, *)
public struct WKAudiovisualMediaTypes : OptionSet {
    public init(rawValue: UInt)
    public static var audio: WKAudiovisualMediaTypes { get }
    public static var video: WKAudiovisualMediaTypes { get }
    public static var all: WKAudiovisualMediaTypes { get }
}

1.4. 微信浏览器内复用Video节点

参考:移动端h5音/视频自动播放兼容

关于自动播放,微信浏览器还有一些比较特殊的限制

  • 即使视频设置了静音, 也可能不能自动播放, 而且直接黑屏, 什么事件都不会触发。
  • 页面加载完成后动态插入的video标签都不能播放, 一律黑屏, 除非手动交互。

网上查询到的微信浏览器内视频自动播放解决方案的时候,大都提到了WeixinJSBridgeReady这个事件,其中提到的一个特性:在WeixinJSBridgeReady事件回调中 1秒 内插入的所有video标签都能够直接执行 play() 做到自动播放(甚至不用静音)。

这个特性貌似对于自动播放很有用。因此有了下面的思路

  • WeixinJSBridgeReady回调里面插入一个不受自动播放限制的video元素,并持久化保存
  • 在需要插入video标签的时候,使用这个持久化的video元素替代,进行播放

写一下代码

import { isWx, isWxMiniProgram } from '@/utils/device'

type VideoEventHandler = (_: Event) => any
type VideoOptions = {
  src:string,
  onLoadedMetadata?: VideoEventHandler,
  onEnded?: VideoEventHandler,
  onTimeupdate?: VideoEventHandler
}

const noop = () => {
  //
}
export class VideoManager {
  dom: HTMLVideoElement | undefined

  initVideoDom() {
    const video = document.createElement('video')
    const attrs: Record<string, string> = {
      autoplay: '',
      playsinline: '', // 非全屏播放
      'webkit-playsinline': '',
      'x5-video-player-type': 'h5-page',
      preload: 'auto',
      crossorigin: 'anomymous',
    }
    Object.keys(attrs).forEach((key) => {
      video.setAttribute(key, attrs[key])
    })
    video.classList.add('persistence-video') // 一个全局样式
    this.dom = video
  }

  play() {
    this.dom?.play()
  }

  async init() {
    return new Promise((resolve) => {
      if (isWx || isWxMiniProgram) {
        document.addEventListener('WeixinJSBridgeReady', () => {
          this.initVideoDom()
          resolve(true)
        })
      } else {
        this.initVideoDom()
        resolve(true)
      }
    })
  }

  mute() {
    if (!this.dom) return
    this.dom.muted = true
  }

  reset() {
    if (!this.dom) return
    // 节点回收时调用该方法,重置各个状态
    this.dom.muted = false
    this.dom.oncanplay = noop
    this.dom.onloadedmetadata = noop
    this.dom.onended = noop
    this.dom.ontimeupdate = noop
  }

  register(parent:Element, {
    src, onLoadedMetadata, onEnded, onTimeupdate,
  }: VideoOptions) {
    if (!parent || !this.dom) return
    this.dom.setAttribute('src', src)
    this.dom.oncanplay = onLoadedMetadata || noop
    this.dom.onloadedmetadata = onLoadedMetadata || noop
    this.dom.onended = onEnded || noop
    this.dom.ontimeupdate = onTimeupdate || noop

    parent.appendChild(this.dom)
  }
}

const instance = new VideoManager()
instance.init()

export default instance

在其他使用video标签的地方,就可以通过videoManager.register(parentNode)的接口插入这个可以自动播放的video标签

封装一个CommonVideo组件,这里使用Vue3

<template>
  <div ref="videoWrap" class="common-video"></div>
</template>

<script lang="ts" setup>
import videoManager from '@/utils/videoManager'
// 注意该组件在同一轮渲染中只能使用一个
type VideoEventHandler = (_: Event) => any
type Props = {
  src: string,
  onLoadedMetadata?: VideoEventHandler,
  onEnded?: VideoEventHandler,
  onTimeupdate?: VideoEventHandler,
}

const videoWrap = ref<HTMLDivElement>()
const videoBackground = ref<HTMLCanvasElement>()
const props = withDefaults(defineProps<Props>(), {
  src: '',
})

onMounted(() => {
  if (videoWrap.value) {
    videoManager.register(videoWrap.value, {
      src: props.src,
      onLoadedMetadata: props.onLoadedMetadata,
      onEnded: props.onEnded,
      onTimeupdate: props.onTimeupdate,
    })
  }
})

onBeforeUnmount(() => {
  videoManager.reset()
})

</script>

<style scoped lang="scss">
.common-video {
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
}
</style>

测试一下,效果很不错~目前(2022年9月)还是可用的,不保证微信后续版本更新中还能生效,如果读者需要使用这个方式,可以先验证一下。

<CommonVideo src="https://cdn.shymean.com/test.mp4" />

复用节点的思路还可以用在跨页面视频连续播放等场景,结合FLIP动画可以模拟APP体验,这里不再展开。

2. 外部视频字幕

参考:HTML5 video视频字幕的使用和制作

视频制作渲染的工序还是比较复杂和耗时的,如果由于内嵌字幕等原因需要重新渲染、或者多语言字幕等场景需要渲染多份视频,就比较繁琐。

所幸video标签支持WebVTT外部字幕文件,一份WebVTT文件的内容格式大概如下

WEBVTT

00:00:00.001 --> 00:00:01.500
请把你的锅

00:00:01.501 --> 00:00:03.500
带回你的虾

00:00:03.501 --> 00:00:07.500
请把你的微笑留下……

其中00:00:00.001 --> 00:00:01.500是对应的时间段,下一行是字幕内容,看起来比较简单(滚动歌词看起来也是类似的格式)

有了字幕文件,就可以在video标签下放track标签来引用该字幕文件

<video src="xxx">
  <source src="./xxx.mp4" type="video/mp4">
    <track label="中文字幕" kind="subtitles" srcLang="zh" src="./xxx.vtt" default/>
</video>

track标签支持多个属性

  • kind,被赋予一个值subtitles,表示文件包含的内容的类型
  • label,会出现在用户界面上,提醒用户当前字幕对应的文案,一般用来描述字幕语言
  • src,字幕文件url
  • srclang,字幕所属语言
  • default,是否是默认字幕,当存在多个track标签时,浏览器会选择为default的这个作为默认字幕

此外,track标签也支持部分CSS规则,用来渲染字幕样式,比如color、opacity、font、line-height等,但在实际场景中发现定制的样式还是比较有限的。

另外一种思路是自己解析VTT文件,然后监听video的timeupdate事件,展示对应时间段的字幕内容,这里也不再展开了。

3. 模糊滤镜视频背景

现在短视频风盛行,在大部分设计中,视频流里面单个视频的尺寸宽高是固定的

.video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

如果要保持视频的原始分辨率比例,上下或左右就会出现留白,短视频App等常用的设计是渲染一个有模糊滤镜的视频背景填充

在网页端怎么实现呢?可以通过canvas来绘制,context.drawImage也是可以传video标签进去的!

function drawBackground(video:HTMLVideoElement, canvas: HTMLCanvasElement) {
  const context = canvas.getContext('2d')
  const video = videoManager.dom

  const { videoWidth, videoHeight } = video
  canvas.width = videoWidth
  canvas.height = videoHeight

  draw(video, context)

  function draw(video: HTMLVideoElement, context: CanvasRenderingContext2D) {
    context.drawImage(video, 0, 0, videoWidth, videoHeight)
    backgroundTimer = setTimeout(draw, 20, video, context)
  }
}

function onLoadedMetadata(e: Event) {
  // 视频ready之后开始绘制
  if (props.background) {
    // 对应的DOM节点
    drawBackground(video, canvas)
  }
}

这样,视频的每一帧都会绘制到canvas上面,实现动态背景

4. 视频预加载

在短视频流中,用户可以通过上下滑动快速无缝切换视频进行浏览。要想交互体验足够好,就需要视频加载的速度足够快,这意味着需要进行视频资源预加载。

在web开发中,可以通过link preload标签提前加载要被使用的资源,提升用户体验,但在 Link types: preload中提到

video preloading is included in the Preload spec, but is not currently implemented by browsers.

目前视频无法被link preload 标签预加载,因此需要调研一下其他的视频预加载方案

参考:

关于这一块正在学习中,后续有补充再更新...

5. 小结

本文整理了web开发中关于video标签常遇到的一些问题和业务场景,仅做备忘。