canvas帧动画实现背景视频

2,730 阅读4分钟

前言

前几天,有小伙伴问我帧动画效果如何优化卡顿的问题. 其实是想实现 web 页面背景视频的效果. 关于这个效果,可以参考一下魔兽世界拉风的官网. 打开调试器可以看到,直接是把video元素定位到了最底下就可以了. 所以,如果你的项目不考虑兼容老旧的浏览器,或者你对帧动画不感兴趣, 这篇文章也许不适合你.

上效果图 videoplayback_1.gif

解决方案

按照原定的需求,需要达到类似魔兽世界官网的效果. 但是使用的技术是canvas帧动画: 通过canvas来绘制每一帧的图片来实现视频播放的效果.

步骤

1.将视频导出为图片帧

本例中,我使用的软件是Adobe Premiere

打开Premiere -> 导入视频 -> 裁剪视频片段(建议不要太长,本例的视频大概 5 秒) -> 导出(ctrl + M) -> 选择jpg, 最后确定导出即可

如果你不太了解这个软件, 那就找 UI 妹子帮帮忙吧

2.构建基本的内容

先看看整个项目的结构

屏幕截图(8).png

屏幕截图(9).png

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);

以上

希望这篇文章可能帮到点进来的小伙伴.