前言
前几天,有小伙伴问我帧动画效果如何优化卡顿的问题. 其实是想实现 web 页面背景视频的效果. 关于这个效果,可以参考一下魔兽世界拉风的官网. 打开调试器可以看到,直接是把video元素定位到了最底下就可以了. 所以,如果你的项目不考虑兼容老旧的浏览器,或者你对帧动画不感兴趣, 这篇文章也许不适合你.
上效果图
解决方案
按照原定的需求,需要达到类似魔兽世界官网的效果. 但是使用的技术是canvas帧动画:
通过canvas来绘制每一帧的图片来实现视频播放的效果.
步骤
1.将视频导出为图片帧
本例中,我使用的软件是Adobe Premiere
打开Premiere -> 导入视频 -> 裁剪视频片段(建议不要太长,本例的视频大概 5 秒) -> 导出(ctrl + M) -> 选择jpg, 最后确定导出即可
如果你不太了解这个软件, 那就找 UI 妹子帮帮忙吧
2.构建基本的内容
先看看整个项目的结构
html 的部分
/index.html
<canvas id="cvs"></canvas>
<script src="./main.js"></script>
js 的部分
先查看原视频的基本属性,然后记下来,后面会使用到.
/main.js
const FRAME_LENGTH = 137; // 导出的图片帧的数量
const VIDEO_FPS = 23.976; // 原视频的帧率,这个会影响视频的流畅度
const VIDEO_WIDTH = 480; // 视频的尺寸
const VIDEO_HEIGHT = 360;
然后,我们希望把所有的图片资源加载完成后,再进行播放. 那么需要对资源加载这个任务进行异步处理.
我们来定义一个加载图片的异步函数
function loadImage(url) {
return new Promise((r) => {
const img = new Image();
img.onload = () => r(img);
// 这里做了特殊的处理
// 如果图片加载失败,仍然返回 resolve, 只是内容为空
img.onerror = () => r();
img.src = url;
});
}
本例中,所有的图片的文件名都是按序号排序的. 所以我定义了一个获取图片资源路径的函数,来方便加载图片. 这个函数视实际需求而定,并不是必要的.
function getImageSrcByIndex(index = 0) {
// 由于 PR 导出的图片会自动添加序号,并且是 3 位的长度
const idx = `00${index}`.slice(-3);
return `./frames/frame-${idx}.jpg`;
}
接下来,我们先初始化一些基本的功能
// /main.js
async function init() {
const cvs = window.document.getElementById("cvs");
const ctx = cvs.getContext("2d");
cvs.width = VIDEO_WIDTH;
cvs.height = VIDEO_HEIGHT;
}
window.addEventListener("load", init);
然后,加载所有的图片
// 先加载所有图片资源
const loadTasks = Array(FRAME_LENGTH)
.fill(0)
.map((v, i) => loadImage(getImageSrcByIndex(i)));
const frames = await Promise.all(loadTasks);
这一步,frames会是一个由图片对象组成的数组(Image[])
然后,我们来定义一个播放视频的函数
function runAnimation(ctx = undefined, frames = []) {
function draw(timestamp) {
console.log("绘制");
window.requestAnimationFrame(draw);
}
window.requestAnimationFrame(draw);
}
这里,使用到了requestAnimationFrame这个 api. 这个 api 的作用跟window.setInterval是一样的,都是不断的绘制内容,只是性能上更好.
然后就可以开始绘制图片了
function runAnimation(ctx = undefined, frames = []) {
let currFrameIndex = -1; // 当前需要绘制的图片的索引
function draw(timestamp) {
currFrameIndex = (currFrameIndex + 1) % frames.length;
// 因为可能存在图片加载失败的情况,需要判断一下
if (frames[currFrameIndex]) {
ctx.drawImage(frames[currFrameIndex], 0, 0);
}
window.requestAnimationFrame(draw);
}
window.requestAnimationFrame(draw);
}
在主函数中调用这个函数
// /main.js
async function init() {
const cvs = window.document.getElementById("cvs");
const ctx = cvs.getContext("2d");
cvs.width = VIDEO_WIDTH;
cvs.height = VIDEO_HEIGHT;
// 先加载所有图片资源
const loadTasks = Array(FRAME_LENGTH)
.fill(0)
.map((v, i) => loadImage(getImageSrcByIndex(i)));
const frames = await Promise.all(loadTasks);
runAnimation(ctx, frames);
}
window.addEventListener("load", init);
到这里,你应该能看到视频了. 但是还没有完, 你会发现视频播放得并不流畅,甚至会有变速的效果. 那是因为没有考虑帧率的问题. 由于视频的帧率是23.976,但是requestAnimationFrame会尝试调用 GPU, 它的执行间隔大概在'16ms'(视 GPU 性能而定), 这样就和视频的帧率不匹配, 视频每一帧的播放间隔应该是1000 ms / 23.976 fps.
另外requestAnimationFrame 的回调函数中传入的参数会告诉我们执行回调函数的时刻.
为了修复帧率的问题,我们调整一下runAnimation.
function runAnimation(ctx = undefined, fps = 30, frames = []) {
const tpf = Math.floor(1000 / fps); // 当前视频每一帧播放的时间间隔
let lastRenderTime = 0; // 最后一次绘制图片帧的时间
let currFrameIndex = -1;
function draw(timestamp) {
// 通过计算调用时间差值来判断是否需要绘制下一帧
const shouldRender = timestamp - lastRenderTime >= tpf;
if (shouldRender) {
lastRenderTime = timestamp;
currFrameIndex = (currFrameIndex + 1) % frames.length;
if (frames[currFrameIndex]) {
ctx.drawImage(frames[currFrameIndex], 0, 0);
}
}
// 循环播放
window.requestAnimationFrame(draw);
}
// 第一次绘制
window.requestAnimationFrame(draw);
}
最后,完整的代码
const FRAME_LENGTH = 137;
const VIDEO_FPS = 23.976;
const VIDEO_WIDTH = 480;
const VIDEO_HEIGHT = 360;
function loadImage(url) {
return new Promise((r) => {
const img = new Image();
img.onload = () => r(img);
img.onerror = () => r();
img.src = url;
});
}
function getImageSrcByIndex(index = 0) {
const idx = `000${index}`.slice(-3);
return `./frames/frame-${idx}.jpg`;
}
/**
*
* @param {object} ctx canvas.context
* @param {number} fps 视频的帧率
* @param {Image[]} frames 图片对象,每一帧的图片
* @returns
*/
function runAnimation(ctx = undefined, fps = 30, frames = []) {
if (
!ctx ||
typeof ctx.drawImage !== "function" ||
fps <= 1 ||
!Array.isArray(frames) ||
frames.length === 0
) {
return;
}
const tpf = Math.floor(1000 / fps);
let lastRenderTime = 0;
let currFrameIndex = -1;
function draw(timestamp) {
const shouldRender = timestamp - lastRenderTime >= tpf;
if (shouldRender) {
lastRenderTime = timestamp;
currFrameIndex = (currFrameIndex + 1) % frames.length;
if (frames[currFrameIndex]) {
ctx.drawImage(frames[currFrameIndex], 0, 0);
}
}
// 循环播放
window.requestAnimationFrame(draw);
}
// 第一次绘制
window.requestAnimationFrame(draw);
}
async function init() {
const el = window.document.getElementById("content");
const cvs = window.document.getElementById("cvs");
const ctx = cvs.getContext("2d");
cvs.width = VIDEO_WIDTH;
cvs.height = VIDEO_HEIGHT;
el.style.width = VIDEO_WIDTH + "px";
el.style.height = VIDEO_HEIGHT + "px";
// 先加载所有图片资源
const loadTasks = Array(FRAME_LENGTH)
.fill(0)
.map((v, i) => loadImage(getImageSrcByIndex(i)));
const frames = await Promise.all(loadTasks);
// 绘制
runAnimation(ctx, VIDEO_FPS, frames);
}
window.addEventListener("load", init);
以上
希望这篇文章可能帮到点进来的小伙伴.