最近在项目中遇到了对视频进行截图的功能,用于在客户端生成视频封面。 主要用到的还是 Canvas 的 drawImage 方法。这篇文章就是将这个过程记录下来。

一、起步

首先我们需要一个 input[type="file"] 表单控件,用来实现视频文件的读取。

<input type="file" accept="video/mp4, video/avi, video/webm, video/ogg" multiple="">  

顺便吐槽一下 chrome 的 input[type="file"] 唤起非常慢的问题,在我同事的电脑上,这样的控件点击之后,10s 都没有什么反应。

<input type="file" accept="video/*" multiple="">  

这应该是 chrome 对于 HTMLInputElement 实现的一个 bug,MIME 类型的过多导致了 chrome 唤起控件很慢,去年就有人提出来了,不过 57 版本的 chrome 还是有点卡。在对 input[type="file"] 监听之后获取所有上传的 File 对象。

let videoResources = document.querySelector('input[type="file"]');

videoResources.addEventListener('change', () => {  
  if( videoResources.length <= 0 ) return;
  generateVideoCover( videoResources );
}, false);

let generateVideoCover = ( resources ) => {  
  for( let item of resources.files )
    file2Video( item ).then((o) => { return video2Img( o.media, o.file ); }).then( displayCover );
}

在 generateVideoCover 方法里对 file 对象进行 file2Video 转换。这里有点不同的地方是,HTMLVideoElement 的 load 事件不是 onload 而是 onloadeddata。在 onloadeddata 事件的回调里面 resolve 传递了创建的 video 标签和原始的 file 对象。Promise resolve 和函数的 return 保持一致,它只能传递一个参数(返回值),所以放在了一个类似 map 的对象里传递。

let file2Video = ( file ) => {  
  return new Promise((resolve, reject) => {
    let media = document.createElement('video');
    media.src = URL.createObjectURL( file );
    media.onloadeddata = () => { resolve({media: media, file: file}); };
  });
}

这时我们就完成了从 File 对象转化成 video dom 的转换,那怎么从 video 转换成 image 呢?Canvas 提供了 drawImage 方法,可以利用视频、图片、Canvas 元素这些 Canvas 图像源实现在 Canvas 绘制图像。

let video2Img = (media, file) => {  
  return new Promise((resolve, reject) => {
    let canvas = document.createElement('canvas');
    let ctx = canvas.getContext('2d');
    canvas.width  = media.videoWidth;
    canvas.height = media.videoHeight;
    ctx.drawImage(media, 0, 0, canvas.width, canvas.height);
    resolve( canvas.toDataURL('image/png') ); 
  });
}

二、色彩度判断问题

通过上面的代码,我们能够将视频转化成图片,这些都很简单。生成的图片其实是视频文件的待播放状态。也就是当前这个 video currentTime 为 0 的状态。但是存在的问题是,有些视频它在一开始的时候可能不是就有图像(或者图像色彩过少)的状态。将下面这张图片作为封面,肯定不合适。

电影封面

此时最好就要对当前截取到视频封面进行判断,如果色彩度过少或者呈现的是完全纯色的状态,应当再对视频进行截屏。Canvas 提供了 getImageData 用于返回 Canvas 区域的像素数据。

let canvas = document.getElementById('canvas');  
let ctx    = canvas.getContext('2d');  
ctx.rect(10, 10, 100, 100);  
ctx.fill();  
console.log(ctx.getImageData(50, 50, 100, 100));  

以一张 100x100 的宽高 Canvas(或图片)为例,它上面有多少个像素点呢?有 10000(100x100 = 10000)个像素点。每一个像素点的色彩值用 RGBA 的方式来表达。RGBA 代表的是 Red(红色)、Green(绿色)、Blue(蓝色)和Alpha的色彩空间,颜色值与 alpha 值都是在 0 - 255 之间。也就是说一张 100x100 的宽高 Canvas(或图片)想要描述它的色彩,需要 40000 个 0-255 的数值。getImageData 返回的是一个 ImageData 对象,他的 data 属性是一个 Uint8ClampedArray。而这个 data 属性 value 就是这 40000 个数值。

通过 getImageData 我们能知道图片的色彩,但是如何判断这张图片的色彩是否丰富或者说是否是纯色的?这 40000 个值的数据如果放在图标上其实是一张折线图,通过判断折线的 “抖动情况” 来判断当前这张图片是否色彩丰富。如下图可见,红色折线的离散度明显比绿色的高。

数据离散度

在大学的时候,我们都学过离散数学这门课,通过判断一组数据的离散度来反应这组数据的变化曲度,也就是 “抖动情况”。同样的,通过计算 Canvas 导出的像素点色值数据离散度来判断图片色彩是否丰富

一张 100x100 的图片都能有 40000 个值,一般的视频远不止这个大小,这个计算量实在吓人。在这里我们简单对图片色彩值进行抽样。一张图片,我们只取图片中几个部分的数据点。如下图所示:我们只取到这张电影截图中的标示了颜色条纹区域的像素点色值,这样能够减少一点计算量。

Canvas 对图片进行采样 tips:点击图片查看演示

let photoPixelCheck = ( canvas, pix ) => {  
  // 等分 21 份
  let equalDivision = canvas.height * canvas.width * 4 / 21;
  let index = -1;
  let filterPixList = [];
  // 只取 3、6、9、12、15
  for( let i = 0, iLength = pix.length; i < iLength; i += equalDivision )
    if( index++ && !(index % 3) && index !== 0 )
      filterPixList = filterPixList.concat( Array.from(pix.slice(0).slice( i, i + equalDivision )) );

  // 求总数
  let sum = filterPixList.reduce((acc, val) => { return acc + val; }, 0);
  // 求平均数
  let avg = sum / filterPixList.length;
  // 标准差
  let standardDeviation = filterPixList.reduce((acc, val) => {
    return acc + Math.pow(val - avg, 2);
  }, 0) / filterPixList.length;
  // 方差
  let variance = Math.sqrt( standardDeviation );
  return variance;
}

一般一张图片 30 以上的色彩方差,色彩度或者说图片内容就已经够了。通过对视频不同时间点的截图是用过 HTMLVideoElement.prototype.currentTime 来实现的,需要注意的是 currentTime 改变之后。如果发现当前截图的视频内容,图片色彩度过低,应当再进行一次截图。这里要限定一些执行条件。

let config = {  
  quality: 30,   // 图片色彩度的最低要求
  retryTime: 3,  // 如果图片不符合,重复截图的次数
  seekedTime: 0.3// 图片不符合是,currentTime 的递增时间
};

这里就需要用到递归 resolve 传递了一个函数进行下一次截图,同时使用了 retryTime 来限定递归次数。需要注意的一个地方时,对于 video 的 currentTime 改变之后,需要对 video 进行 seeked 的事件监听,截图的部分需要在这个回调进行。

let video2Img = (media, file, currentTime = 0, retryTime = -1) => {  
  return new Promise((resolve, reject) => {
    let canvas = document.createElement('canvas');
    let ctx = canvas.getContext('2d');
    media.currentTime = currentTime;
    retryTime += 1;
    media.onseeked = () => {
      canvas.width  = media.videoWidth;
      canvas.height = media.videoHeight;
      ctx.drawImage(media, 0, 0, canvas.width, canvas.height);
      let pix  = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
      let rate = photoPixelCheck( canvas, pix );
      let meta = { media: media, file: file, canvas: canvas };
      if( rate < config.quality && retryTime < config.retryTime )
        resolve(video2Img( media, file, currentTime += config.seekedTime, retryTime ))
      else
        resolve( meta );
    }
  });
}

video2Img 同样也是一个 Promise,reslove 之后返回下一个 Promise 链包含了 Canvas 的 meta 字段。通过调用 Canvas 的 toBlob 方法转化为 Blob 对象,再插入到页面中。

let displayCover = ( meta ) => {  
  return new Promise((resolve, reject) => {
    meta.canvas.toBlob((blob) => {
      let image  = document.createElement('img');
      image.src  = URL.createObjectURL( blob );
      document.body.appendChild( image );
      resolve( image );
    });
  });
}

顺便提一点的是,有时我们通过 ajax 传递文件的时候。需要当前这个文件是 File 类型,下面这个 toFile 方法能够将 dataURL、Blob 转化为 File 对象。

let toFile = ( arg, filename ) => {  
  if( arg instanceof Blob )
    return new File([blob], filename);
  let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
    bstr  = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
  while(n--){
    u8arr[n] = bstr.charCodeAt(n);
  }
  return new File([u8arr], filename, {type:mime});
}

三、结语

最后写了一个非常简单的 DEMO,DEMO 的位置在这里。这个问题的由来是我在项目中所遇到的一个思考,但是因为为了和 APP 客户端保持一致的功能特性。我们统一把视频的截图时间定在了视频的第一帧,没有做截图色彩度的处理。so,代码未经过测试,你懂得。

最近事情有点多,面临换工作和重新租房子,动荡的一个月开始了,希望能有时间留给代码。微笑

完。