实现一个web视频动效播放器video-alpha-player

前言:动效播放可选的技术方案

谈起web上的动效播放,大家可能会想起一系列的方案,Svga、Lottie、Spine(后续计划讲一讲)、序列帧动画、PAG...(gif/apng/webp格式就不说了,资源体积是硬伤,只适合简单动图预览)。

还有一种比较冷门的动效播放--视频动效播放,当然只是播一段不透明的视频未免场景也太过于小众,可能只是在公司官网这种页面里能见到,但是!但是!如果带上透明度的视频播放是不是就比较炫酷了,而且使用场景大大增加,可以与svga媲美,但如果只是跟svga一样,那为什么还需要带通道的视频动效播放?答案只有一个,那当然是同等条件下表现比SVGA要好。

视频素材如下,左边是动画通道,右边是动画画面

小试牛刀:用Canvas2D实现视频动效播放器

直接Github看源码

实现思路

先创建一个离线的视频播放,然后再创建一个离线的画布,将视频播放内容逐帧绘制到离线的画布上,然后分别取画布上左半边和右半边的像素数据,进行透明通道值替换(左边部分使用RGB通道存储了原透明视频的Alpha值,右边部分使用RGB通道存储了原透明视频的RGB,通过重新将每个像素点的Alpha值和RGB值进行组合,重新得到RGBA视频画面,实现透明视频的动画效果),最后就是将处理后的画布数据绘制到视觉画布上就OK了。

借个图来说明:图片来源

实现步骤

1、创建video播放标签

ts 复制代码
// 创建video元素
this.videoScript = document.createElement("video");
this.videoScript.width = opts.width * 2;
this.videoScript.height = opts.height;
this.videoScript.controls = false;
this.videoScript.playsInline = true;
this.videoScript.loop = opts.loop || false;
this.videoScript.autoplay = false;
this.videoScript.muted = true;
this.videoScript.crossOrigin = "Anonymous";
// @ts-ignore
this.videoScript["x5-video-player-type"] = "h5";

this.videoScript.src = url;
this.videoScript.addEventListener('loadedmetadata', () => {
    // const videoWidth = this.videoScript.videoWidth;
    // const videoHeight = this.videoScript.videoHeight;
    // console.log('视频宽度:', videoWidth);
    // console.log('视频高度:', videoHeight);
    this.isReady = true;
    if (opts.loadedCallback) opts.loadedCallback();
    if (opts.autoplay) this.play();
});
this.videoScript.addEventListener("ended", this.handleEnd);

2、创建一个离屏canvas,用于绘制video

ts 复制代码
const offlineCanvas = document.createElement("canvas");
offlineCanvas.width = opts.width * dpr * 2;
offlineCanvas.height = opts.height * dpr;
this.offlineCanvas = offlineCanvas;
this.offlineContext = offlineCanvas.getContext("2d", {
      willReadFrequently: true,
});

3、播放的时候取离线画布的数据,并进行通道值计算,将计算后的画布数据放到视觉中的画布进行渲染

ts 复制代码
  animation() {
    // 视频未准备好,不做处理
    if (!this.isReady) {
      this.rafId = requestAnimationFrame(() => {
        this.animation();
      });
      return;
    }
    const { context, offlineContext } = this;
    if (!context || !offlineContext) {
      console.error("canvas context is null");
      return;
    }
    // 先获取离线画布的数据,然后绘制到主画布上
    const oW = this.offlineCanvas!.width;
    const oH = this.offlineCanvas!.height;
    offlineContext.drawImage(this.videoScript, 0, 0, oW, oH);
    const imgdata2 = offlineContext.getImageData(0, 0, oW / 2, oH);
    const imgdata1 = offlineContext.getImageData(oW / 2, 0, oW / 2, oH);

    // 将imgdata2的alpha通道数据赋值给imgdata1的alpha通道数据
    // 补充一下,此处imgdata2的R就是imgdata1需要的A
    const data2 = imgdata2.data;
    for (let i = 3, n = data2.length; i < n; i += 4) {
      imgdata1.data[i] = data2[i - 3];
    }
    const newImageData = imgdata1;
    context.putImageData(newImageData, 0, 0, 0, 0, oW / 2, oH);
    // 如果正在播放中,继续下一帧的绘制
    if (this.isPlaying) {
      this.rafId = requestAnimationFrame(() => {
        this.animation();
      });
    }
  }

方案总结

整个过程是不是很简单呢?但是你先别着急高兴,该方案采用CPU计算消耗极大,笔者曾经试过用wasm解决,但未果,原因在于性能瓶颈是在context.getImageData上,该步骤需要将画布图像数据复制到CPU内存,获取的是RGBA四通道数据(每个像素4字节),1920x1080画布约8MB数据,因此一不小心就会有Failed to execute 'getImageData' on 'CanvasRenderingContext2D': Out of memory at ImageData creation的报错

终极杀器:用WebGL实现视频动效播放器

直接Github看源码

实现思路

底层原理与Canvas2D一致,只是视频像素数据的处理放到了webgl去实现,webgl对色彩的处理有着天然的优势(GPU加速、以及通过GLSL着色器,开发者可以自定义色彩处理逻辑等)。

所以仍需要创建一个离线的视频播放用来获取视频里的像素信息,然后需要获取WebGL上下文,并绑定顶点着色器和片元着色器,接下来就是绘制时更新纹理数据

实现步骤

1、创建video播放管理器,便于后续控制播放

ts 复制代码
export interface VideoOptions {
	useBlob?: boolean,
	width: number,
	height: number,
	loop?: boolean
	autoplay?: boolean,
	endCallBack?: () => any,
	updateCallBack?: () => void,
	errorCallBack?: () => void
	onError?: (err?: ErrorEvent) => void,
}

export default class VideoPlayer {
	public videoScript: HTMLVideoElement;
	public ready: boolean = false; // 视频是否就绪
	private opts: VideoOptions;
	private playing: boolean = false; // 视频正在播放中
	private timeupdate: boolean = false;


	constructor(src: string, opts: VideoOptions) {
	   this.opts = opts || <VideoOptions>{};
      // 创建视频播放器
	   this.videoScript = document.createElement("video");
	   this.videoScript.width = opts.width * 2; // 宽度是画布的双倍
	   this.videoScript.height = opts.height;
	   this.videoScript.controls = false;
	   this.videoScript.playsInline = true;
	   this.videoScript.loop = opts.loop || false;
	   this.videoScript.autoplay = false;
	   this.videoScript.muted = true;
	   this.videoScript.crossOrigin = "Anonymous";
	   // @ts-ignore
	   this.videoScript["x5-video-player-type"] = "h5";

	 // 是否使用blob方式加载视频
	 if (opts.useBlob) {
	   this.loadVideoBlob(src).then((res: string) => this.videoScript.src = res)
        .catch((err: any) => {
		onsole.error('video error', err);
		if (opts.onError) opts.onError(err);
	      })
	  } else {
	    this.videoScript.src = src;
	  }

	  // 监听视频事件
	  this.videoScript.addEventListener('canplay', this.onReadyPlay.bind(this));
	  this.videoScript.addEventListener("playing", this.playingEventHandler.bind(this));
	  this.videoScript.addEventListener("timeupdate", this.timeupdateEventHandler.bind(this));
	  this.videoScript.addEventListener("ended", this.onEndedPlay.bind(this));
	  this.videoScript.addEventListener('error', this.onErrorPlay.bind(this));
	}
	private onReadyPlay() {
	  //
	}
	private onErrorPlay(err: ErrorEvent) {
	   if (this.opts.onError) this.opts.onError(err);
	}
	private onEndedPlay() {
	   if (this.opts.endCallBack) this.opts.endCallBack();
	}
	private playingEventHandler() {
	   this.playing = true;
	   this.checkReady();
	}
	private timeupdateEventHandler() {
	   this.timeupdate = true;
	   this.checkReady();
	}
	/**
	 * 检查视频是否就绪
	 */
	private checkReady() {
	  if (this.playing && this.timeupdate) {
	     this.ready = true;
	  }
	}
	private loadVideoBlob(url: string) {
	  return fetch(url).then(res => res.blob()).then(res => {
	     return URL.createObjectURL(res);
	  });
	}
	/**
	 * 开始视频播放
	 */
	public play() {
	  this.videoScript.currentTime = 0;
	  this.videoScript.play();
	}
	/**
	* 恢复视频播放
	*/
	public resume() {
	  this.videoScript.play();
	}
	/**
	 * 暂停视频播放
	 */
	public pause() {
	  this.videoScript.pause();
	}
	/**
	 * 销毁视频播放器
	 */
	public dispose() {
	  this.pause();
	  this.videoScript.removeEventListener('canplay', this.onReadyPlay.bind(this));
	  this.videoScript.removeEventListener("playing", this.playingEventHandler.bind(this));
	  this.videoScript.removeEventListener("timeupdate", this.timeupdateEventHandler.bind(this));
	  this.videoScript.removeEventListener("ended", this.onEndedPlay.bind(this));
	  this.videoScript.removeEventListener('error', this.onErrorPlay.bind(this));
	  this.videoScript.remove();
	}
}

2、获取webgl context,并初始化Shader和顶点缓存区,这块是实现视频透明播放的核心逻辑,从片元着色器代码可以看出,我们取了纹理右边的rgb,和左边的r用作alpha,合成了rgba的vec4(片元着色器里的坐标是0-1之间的浮点数,0.5即是纹理的中间位置)

ts 复制代码
// 伪代码,获取WebGL Context
this.gl = canvas.getContext("webgl")!;
          
const VSHADER_SOURCE = `
        attribute vec4 a_Position;
		    attribute vec2 a_uv;
        uniform mat4 u_Translation;
		    varying vec2 uv;
        void main() {   
           gl_Position = a_Position;
		       uv= a_uv;
        }
    `;

const FSHADER_SOURCE = `
        precision mediump float;
        uniform sampler2D uSampler;
	    	varying vec2 uv;
        void main() {
          vec3 color = texture2D( uSampler, vec2(0.5 + uv.x/2.0, uv.y) ).rgb;
          float alpha = texture2D( uSampler, vec2(uv.x/2.0, uv.y) ).r;
          gl_FragColor = vec4(color,1.0) * alpha;
        }
    `;

// 创建着色器程序
private _initShaders(vshader: string, fshader: string) {
  const program = this._createProgram(vshader, fshader);
  this.gl.useProgram(program);
  this._program = program;
}

// 初始化顶点缓冲区
private _initVertexBuffers() {
  // 定义顶点坐标和UV坐标
  const vertextList = new Float32Array([-1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, -1.0]);
  const uvList = new Float32Array([0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0]);
  // 绑定到GPU缓冲区
  this._initArrayBuffer("a_Position", vertextList, 2, gl.FLOAT);
  this._initArrayBuffer("a_uv", uvList, 2, gl.FLOAT);
}

3、每帧渲染时,将video帧数据绑定到纹理

ts 复制代码
public updateTexture(dom: HTMLVideoElement) {
  const { gl } = this;
  const level = 0;
  const internalFormat = gl.RGBA;
  const srcFormat = gl.RGBA;
  const srcType = gl.UNSIGNED_BYTE;
  gl.bindTexture(gl.TEXTURE_2D, this._texture);
  // 将视频帧绑定到纹理
  gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, srcFormat, srcType, dom);
}

至此已基本实现了动画的绘制,播放控制部分就不一一贴代码了,这里稍微再科普一下GLSL里的顶点坐标系和纹理坐标系,帮助理解着色器代码

顶点坐标系:

纹理坐标系:

方案总结

经过实测,同动画同机型条件下该方案性能略优于SVGA

注意点📢

部分IOS系统版本不支持视频的自动播放,要求用户必须有点击操作才可以,这块需要大家使用的时候注意一下,可以使用can-autoplay包进行条件探测,也可以通过context state探测,类似startaudiocontext包实现的那样

相关推荐
testleaf28 分钟前
前端面经整理【1】
前端·面试
好了来看下一题30 分钟前
使用 React+Vite+Electron 搭建桌面应用
前端·react.js·electron
啃火龙果的兔子30 分钟前
前端八股文-react篇
前端·react.js·前端框架
小前端大牛马36 分钟前
react中hook和高阶组件的选型
前端·javascript·vue.js
刺客-Andy37 分钟前
React第六十二节 Router中 createStaticRouter 的使用详解
前端·javascript·react.js
秋田君2 小时前
深入理解JavaScript设计模式之策略模式
javascript·设计模式·策略模式
萌萌哒草头将军3 小时前
🚀🚀🚀VSCode 发布 1.101 版本,Copilot 更全能!
前端·vue.js·react.js
GIS之路3 小时前
OpenLayers 图层叠加控制
前端·信息可视化
90后的晨仔3 小时前
ArkTS 语言中的number和Number区别是什么?
前端·harmonyos
菜鸡爱上编程3 小时前
React16,17,18,19更新对比
前端·javascript·reactjs·react