ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
## 引言 最近开发的时候遇到了一个需求,截取视频第一帧作为视频的封面,结果第一帧是黑屏,所以产品提出,希望可以截取的不是黑屏颜色稍微靓丽帧的作为封面。于是我们进行步骤拆解: 1. 截取第N秒作为视频封面。 2. 选择合适的帧作为视频封面。[](https://link.juejin.cn?target=) ## 注意事项 * 视频地址必须`同源`或者是`支持跨域访问`。 * 设置视频播放时间后,再监听`canplay`事件。 * 寻找合适帧需要加载时间。 ## 实现步骤 ### 一、获取视频基本信息(分辨率、时长) ~~~javascript // 获取视频基本信息 function getVideoBasicInfo(videoSrc) { return new Promise((resolve, reject) => { const video = document.createElement('video') video.src = videoSrc // 视频一定要添加预加载 video.preload = 'auto' // 视频一定要同源或者必须允许跨域 video.crossOrigin = 'Anonymous' // 监听:异常 video.addEventListener('error', error => { reject(error) }) // 监听:加载完成基本信息,设置要播放的时常 video.addEventListener('loadedmetadata', () => { const videoInfo = { video, width: video.videoWidth, height: video.videoHeight, duration: video.duration } resolve(videoInfo) }) }) } 复制代码 ~~~ ### 二、将视频绘入canvas用以生成图片地址 这里需要等待视频`canplay`事件后,再截取,否则会黑屏 ~~~javascript // 获取视频当前帧图像信息与饱和度 function getVideoPosterInfo(videoInfo) { return new Promise(resolve => { const { video, width, height } = videoInfo video.addEventListener('canplay', () => { const canvas = document.createElement('canvas') canvas.width = width canvas.height = height const ctx = canvas.getContext('2d') // 将视频对象直接绘入canvas ctx.drawImage(video, 0, 0, width, height) // 获取图像的整体平均饱和度 const saturation = getImageSaturation(canvas) const posterUrl = canvas.toDataURL('image/jpg') resolve({ posterUrl, saturation }) }) }) } 复制代码 ~~~ ### 三、“合适的帧” 这里我们产品提出需要以颜色稍微“靓丽”,经过苦思冥想,何为“靓丽”,众里寻她千百度,终于寻到“饱和度” > 饱和度:色彩的饱和度(saturation)指色彩的鲜艳程度,也称作纯度。 * 将绘制好的canvas,通过`getImageData`获取到其`像素数据`。 * 将像素数据整理好成rgba形式的数据。 * 将rgb数据转换成hsl数据 * 提取hsl数据的s,即饱和度数据,求整体平均值 ​ [](https://link.juejin.cn?target=) #### 1、获取canvas像素数据 这里我们通过调用`getImageData`这个API,获取像素数据,也就是一整个画布的每个像素点的颜色。他返回的是一个`Uint8ClampedArray(8位无符号整型固定数组)`,我们可以将其理解成为一个`类数组`,其每0、1、2、3位数据刚好可以对应rgba,即`Uint8ClampedArray[0]`可以对应上`RGBA的R`,以此类推,刚好可以获取整个画布的像素颜色情况。 ![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ee48732b7ef1406cbfe47f8646bb948a~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp) ~~~javascript // 获取一个图片的平均饱和度 function getImageSaturation(canvas) { const ctx = canvas.getContext('2d') const uint8ClampedArray = ctx.getImageData(0, 0, canvas.width, canvas.height).data // .... } 复制代码 ~~~ #### 2、将Uint8ClampedArray整理成rgba形式 这里我们通过遍历,根据下标整理数据,转换成rgba形式,方便后续操作 ​ ~~~javascript // 封装,将无符号整形数组转换成rgba数组 function binary2rgba(uint8ClampedArray) { const rgbaList = [] for (let i = 0; i < uint8ClampedArray.length; i++) { if (i % 4 === 0) { rgbaList.push({ r: uint8ClampedArray[i] }) continue } const currentRgba = rgbaList[rgbaList.length - 1] if (i % 4 === 1) { currentRgba.g = uint8ClampedArray[i] continue } if (i % 4 === 2) { currentRgba.b = uint8ClampedArray[i] continue } if (i % 4 === 3) { currentRgba.a = uint8ClampedArray[i] continue } } return rgbaList } // 获取一个图片的平均饱和度 function getImageSaturation(canvas) { const ctx = canvas.getContext('2d') const uint8ClampedArray = ctx.getImageData(0, 0, canvas.width, canvas.height).data const rgbaList = binary2rgba(uint8ClampedArray) // .... } 复制代码 ~~~ #### 3、将RGB转换成HSL,并求平均值 HSL即色相、饱和度、亮度(英语:Hue, Saturation, Lightness)。 色相(H)是色彩的基本属性,就是平常所说的颜色名称,如红色、黄色等。 饱和度(S)是指色彩的纯度,越高色彩越纯,低则逐渐变灰,取0-100%的数值。 明度(V),亮度(L),取0-100%。 ~~~javascript // 将rgb转换成hsl function rgb2hsl(r, g, b) { r = r / 255; g = g / 255; b = b / 255; var min = Math.min(r, g, b); var max = Math.max(r, g, b); var l = (min + max) / 2; var difference = max - min; var h, s, l; if (max == min) { h = 0; s = 0; } else { s = l > 0.5 ? difference / (2.0 - max - min) : difference / (max + min); switch (max) { case r: h = (g - b) / difference + (g < b ? 6 : 0); break; case g: h = 2.0 + (b - r) / difference; break; case b: h = 4.0 + (r - g) / difference; break; } h = Math.round(h * 60); } s = Math.round(s * 100);//转换成百分比的形式 l = Math.round(l * 100); return { h, s, l }; } // 获取一个图片的平均饱和度 function getImageSaturation(canvas) { const ctx = canvas.getContext('2d') const uint8ClampedArray = ctx.getImageData(0, 0, canvas.width, canvas.height).data const rgbaList = binary2rgba(uint8ClampedArray) const hslList = rgbaList.map(item => { return rgb2hsl(item.r, item.g, item.b) }) // 求平均值 const avarageSaturation = hslList.reduce((total, curr) => total + curr.s, 0) / hslList.length return avarageSaturation } 复制代码 ~~~ ### 四、传入视频地址与第N秒,获取第N秒的图片地址与饱和度 ~~~javascript // 根据视频地址与播放时长获取图片信息与图片平均饱和度 function getVideoPosterByFrame(videoSrc, targetTime) { return getVideoBasicInfo(videoSrc).then(videoInfo => { const { video, duration } = videoInfo video.currentTime = targetTime return getVideoPosterInfo(videoInfo) }) } 复制代码 ~~~ ### 五、传入视频地址与指定饱和度品质,截取指定饱和度的视频作为封面 ~~~javascript async function getBestPoster(videoSrc, targetSaturation) { const videoInfo = await getVideoBasicInfo(videoSrc) const { duration } = videoInfo for (let i = 0; i <= duration; i++) { const posterInfo = await getVideoPosterByFrame(videoSrc, i) const { posterUrl, saturation } = posterInfo if (saturation >= targetSaturation) { return posterUrl } } } 复制代码 ~~~ ## 整体代码与测试 ~~~javascript // 获取视频基本信息 function getVideoBasicInfo(videoSrc) { return new Promise((resolve, reject) => { const video = document.createElement('video') video.src = videoSrc // 视频一定要添加预加载 video.preload = 'auto' // 视频一定要同源或者必须允许跨域 video.crossOrigin = 'Anonymous' // 监听:异常 video.addEventListener('error', error => { reject(error) }) // 监听:加载完成基本信息,设置要播放的时常 video.addEventListener('loadedmetadata', () => { const videoInfo = { video, width: video.videoWidth, height: video.videoHeight, duration: video.duration } resolve(videoInfo) }) }) } // 将获取到的视频信息,转化为图片地址 function getVideoPosterInfo(videoInfo) { return new Promise(resolve => { const { video, width, height } = videoInfo video.addEventListener('canplay', () => { const canvas = document.createElement('canvas') canvas.width = width canvas.height = height const ctx = canvas.getContext('2d') ctx.drawImage(video, 0, 0, width, height) const saturation = getImageSaturation(canvas) const posterUrl = canvas.toDataURL('image/jpg') resolve({ posterUrl, saturation }) }) }) } // 获取一个图片的平均饱和度 function getImageSaturation(canvas) { const ctx = canvas.getContext('2d') const uint8ClampedArray = ctx.getImageData(0, 0, canvas.width, canvas.height).data console.log(uint8ClampedArray) const rgbaList = binary2rgba(uint8ClampedArray) const hslList = rgbaList.map(item => { return rgb2hsl(item.r, item.g, item.b) }) const avarageSaturation = hslList.reduce((total, curr) => total + curr.s, 0) / hslList.length return avarageSaturation } function rgb2hsl(r, g, b) { r = r / 255; g = g / 255; b = b / 255; var min = Math.min(r, g, b); var max = Math.max(r, g, b); var l = (min + max) / 2; var difference = max - min; var h, s, l; if (max == min) { h = 0; s = 0; } else { s = l > 0.5 ? difference / (2.0 - max - min) : difference / (max + min); switch (max) { case r: h = (g - b) / difference + (g < b ? 6 : 0); break; case g: h = 2.0 + (b - r) / difference; break; case b: h = 4.0 + (r - g) / difference; break; } h = Math.round(h * 60); } s = Math.round(s * 100);//转换成百分比的形式 l = Math.round(l * 100); return { h, s, l }; } function binary2rgba(uint8ClampedArray) { const rgbaList = [] for (let i = 0; i < uint8ClampedArray.length; i++) { if (i % 4 === 0) { rgbaList.push({ r: uint8ClampedArray[i] }) continue } const currentRgba = rgbaList[rgbaList.length - 1] if (i % 4 === 1) { currentRgba.g = uint8ClampedArray[i] continue } if (i % 4 === 2) { currentRgba.b = uint8ClampedArray[i] continue } if (i % 4 === 3) { currentRgba.a = uint8ClampedArray[i] continue } } return rgbaList } // 根据视频地址与播放时长获取图片信息与图片平均饱和度 function getVideoPosterByFrame(videoSrc, targetTime) { return getVideoBasicInfo(videoSrc).then(videoInfo => { const { video, duration } = videoInfo video.currentTime = targetTime return getVideoPosterInfo(videoInfo) }) } async function getBestPoster(videoSrc, targetSaturation) { const videoInfo = await getVideoBasicInfo(videoSrc) const { duration } = videoInfo for (let i = 0; i <= duration; i++) { const posterInfo = await getVideoPosterByFrame(videoSrc, i) const { posterUrl, saturation } = posterInfo // 判断当前饱和度是否大于等于期望的饱和度 if (saturation >= targetSaturation) { return posterUrl } } } // 这里通过http-server将视频地址与js进行同源 const videoSrc = 'http://192.168.2.1:8081/trailer.mp4' // 饱和度品质 0/10/30/50 const targetSaturation = 0 getBestPoster(videoSrc, targetSaturation).then(posterUrl => { const image = new Image() image.src = posterUrl document.body.append(image) }).catch(error => { console.log(error) }) 复制代码 ~~~ ### 饱和度:0 ![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/cbc9ee9066ac4f73bf666d6262609187~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)[](https://link.juejin.cn?target=) ### 饱和度:10 ![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f7aa9d86eb3744e08c0690293e24cd82~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)[](https://link.juejin.cn?target=) ### 饱和度:30 ![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3299634335fa49b0b4c395c5ce9ab443~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)[](https://link.juejin.cn?target=) ### 饱和度:50 ![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b30ba0ce595345bf8e1a331813f850be~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp)[](https://link.juejin.cn?target=)