🤣🤣电摇嘲讽还能这么玩?使用 Canvas 将视频转为像素风!

2,064 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第11天,点击查看活动详情

前言

今天闲来无事,上 b 站逛了圈,发现石头老师又出新视频了,这期讲的是将视频转为字符视频,看起来很有意思,本文来剖析它的实现原理。

前置知识

本文涉及到两个知识点,这里先跟大家过一下,有利于大家后续的学习。

CanvasRenderingContext2D.drawImage

Canvas 2D API 中的 CanvasRenderingContext2D.drawImage()  方法提供了多种在画布(Canvas)上绘制图像的方式。我们可以通过这个方法将视频帧在 canvas 上绘制成图像。

语法

drawImage(image, dx, dy, dWidth, dHeight)

参数

  • image

    绘制到上下文的元素。允许任何的画布图像源(本文使用到的是 HTMLVideoElement,更多细节请看下面)。

  • sx 可选

    需要绘制到目标上下文中的,image 的矩形(裁剪)选择框的左上角 X 轴坐标。可以使用 3 参数或 5 参数语法来省略这个参数。

  • sy 可选

    需要绘制到目标上下文中的,image 的矩形(裁剪)选择框的左上角 Y 轴坐标。可以使用 3 参数或 5 参数语法来省略这个参数。

  • sWidth 可选

    需要绘制到目标上下文中的,image 的矩形(裁剪)选择框的宽度。如果不说明,整个矩形(裁剪)从坐标的 sx 和 sy 开始,到 image 的右下角结束。可以使用 3 参数或 5 参数语法来省略这个参数。使用负值将翻转这个图像。

  • sHeight 可选

    需要绘制到目标上下文中的,image的矩形(裁剪)选择框的高度。使用负值将翻转这个图像。

更多

更多关于 drawImage 的细节,请看:CanvasRenderingContext2D.drawImage() - Web API 接口参考 | MDN (mozilla.org)

CanvasRenderingContext2D.getImageData

CanvasRenderingContext2D.getImageData()  返回一个 ImageData 对象,用来描述 canvas 区域隐含的像素数据,这个区域通过矩形表示,起始点为 (sx, sy)、宽为 sw、高为 sh

语法

ImageData ctx.getImageData(sx, sy, sw, sh);

参数

  • sx

    将要被提取的图像数据矩形区域的左上角 x 坐标。

  • sy

    将要被提取的图像数据矩形区域的左上角 y 坐标。

  • sw

    将要被提取的图像数据矩形区域的宽度。

  • sh

    将要被提取的图像数据矩形区域的高度。

返回值

一个 ImageData 对象,包含 canvas 给定的矩形图像数据。

这里我们暂且不用管 ImageData 对象是什么,我们只需要知道,ImageData 中有一个 data 属性,它是一个 Uint8ClampedArray ,描述了一个一维数组,包含以 rgba 顺序的数据,数据使用 0 至 255(包含)的整数表示。

这么说可能有点抽象,我们直接输出看看长啥样。

微信截图_20221004150800.png

更多

更多关于 getImageData 的细节,请看:CanvasRenderingContext2D.getImageData() - Web API 接口参考 | MDN (mozilla.org)

需求分析

前置知识学习完,我们就可以开始搞事了,但是,一下子就要将视频转为字符视频可能有点唐突了,那我们先尝试将视频转为图像。

将视频帧转为图像

<body>
    <video id="video"  oncanplay="init()" loop width="400" src="./video/1.mp4"></video>
    <canvas id="cvs"></canvas>
    <script>
        const ctx = cvs.getContext('2d');
        const ctx2 = cvs2.getContext('2d');
        cvs.height = cvs2.height = video.offsetHeight;
        cvs.width = cvs2.width = video.offsetWidth;
        const { width, height } = cvs;
        ctx.drawImage(video, 0, 0, width, height);
    </script>
</body>

我们可以通过前置知识中的 drawImage 方法,将 vedio 作为第一个参数传入,同时 x 轴位置和 y 轴位置都从 0 开始,表示从左上角开始裁切,widthheight 我们传入 video 的实际宽高(通过 offsetWidth 和 offsetHeight 获取),表示我们需要从左上角开始,裁剪出和 video 同样大小的图像。

如此,我们就可以实现将视频第一帧绘制到 canvas 画布的功能。

QQ截图20221005195730.png

相信小伙伴们到这一步都没有问题。

图像像素风

接下来我们要将 canvas 上的图像绘制为像素风。

这里我们需要准备一个 新的 canvas 来绘制像素风图像。

有的小伙伴可能比较疑惑为什么要用另一个 canvas 来绘制,这是因为我们一开始需要将视频帧绘制到一个 canvas 上,所以准备了一个 canvas,然后我们要对这个 canvas 上的图像进行像素化,如果用的是同一个canvas,会将图像覆盖掉,所以才需要准备一个新的 canvas 来绘制。

<video id="video"  oncanplay="init()" loop width="400" src="./video/1.mp4"></video>
<canvas id="cvs"></canvas>
<canvas id="cvs2" onclick="video.play()"></canvas>

怎么对图像进行像素化呢?聪明的小伙伴想到了之前介绍的 getImageData 方法了。

上面提到过,ImageData 对象的 data 属性,是按 rgba 顺序的 表示所有像素点颜色信息的一维数组。因此我们 4 个一组进行处理,前三个是 rgb,我们将它们三个相加后乘上一个比例,作为灰度值赋值给 fillStyle ,第四个是透明值,大家根据喜好进行调整。

for(let i=0; i<data.length; i+=4) {
    const x = parseInt(i % (width*4) / 4);
    const y = parseInt(i / (width * 4));
    const g = parseInt((data[i]+data[i+1]+data[i+2])/3);
    ctx2.fillStyle = `rgba(${g}, ${g}, ${g}, ${data[i+3]})`;
    ctx2.fillText('□', x, y);
}

微信截图_20221004154755.png

可以发现如果我们处理所有的像素点,那么最后呈现出来的图像(最后一张图)很圆润(像素点很密),和原图差别不大。

我们为了模拟像素风视频,需要将像素点进行 稀释

有的小伙伴可能不知道稀释是什么意思,我拿下面三张图片举个例子。

1.png

2.png

QQ截图20221004185209.png

图1的像素点比较多,展现出来的效果会更圆润,专业点说就是没有锯齿。

图2的像素点相较于图1来说偏少,我们可以很明显的看出一块一块的方格,它是有锯齿的。

图3的像素点就更少了,方格我们甚至可以数的出来。

为了让它的像素点少一点,至少要看着少一点,我们才要进行稀释。

我们设置一个稀释比例 bl,当 xy 的值满足条件的时候,绘制一个像素点,这样我们不仅可以满足我们稀释的要求,还可以少处理很多像素点,减少绘制性能消耗。

const bl = 8;
for(let i=0; i<data.length; i+=4) {
    const x = parseInt(i % (width*4) / 4);
    const y = parseInt(i / (width * 4));
    if(x % bl === 0 && y % bl === 0) {
        const g = parseInt((data[i]+data[i+1]+data[i+2])/3);
        ctx2.fillStyle = `rgba(${g}, ${g}, ${g}, ${data[i+3]})`;
        ctx2.fillText('□', x, y);
    }
}

微信截图_20221004162657.png

视频像素风

通过前面几个步骤,我们已经可以将视频帧绘制为像素风图像了,那么怎么将视频变为像素风视频呢?

其实很容易,我们一次只能绘制一帧的视频,那如果我 对每帧视频都进行绘制(下一帧绘制前先将画布清空,然后绘制新的一帧),输出的是不是就是像素风视频了?

const playVideo = () => {
    requestAnimationFrame(playVideo);
    ... do something
    ctx2.clearRect(0, 0, width, height); // 清空画布(清空上一帧图像)
    ... 绘制新的一帧
}

注意这里用到了 requestAnimationFrame 这个 api(setTimeInterval 的延迟时间 delay 指的是在 delay 秒后,将回调函数加入事件队列,如果该回调在事件队列中仍在执行,则会跳过当前回调函数进队列,导致了延迟的发生),它的作用是替代 setInterval,调用频率为 每秒 60 次,提高性能。

我们递归调用直至视频处理结束。

20221004_162245.gif

完整代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        body {
            display: flex;
        }
        video, canvas {
            border: 1px solid;
        }
    </style>
</head>
<body>
    <video id="video"  oncanplay="init()" loop width="400" src="./video/1.mp4"></video>
    <canvas id="cvs" hidden></canvas>
    <canvas id="cvs2" onclick="video.play()"></canvas>
    <script>
        const init = () => {
            const ctx = cvs.getContext('2d');
            const ctx2 = cvs2.getContext('2d');
            cvs.height = cvs2.height = video.offsetHeight;
            cvs.width = cvs2.width = video.offsetWidth;
            const playVideo = () => {
                requestAnimationFrame(playVideo);
                const { width, height } = cvs;
                ctx.drawImage(video, 0, 0, width, height);
                const data = ctx.getImageData(0, 0, width, height).data;
                ctx2.clearRect(0, 0, width, height);
                const bl = 8;
                for(let i=0; i<data.length; i+=4) {
                    const x = parseInt(i % (width*4) / 4);
                    const y = parseInt(i / (width * 4));
                    if(x % bl === 0 && y % bl === 0) {
                        const g = parseInt((data[i]+data[i+1]+data[i+2])/3);
                        ctx2.fillStyle = `rgba(${g}, ${g}, ${g}, ${data[i+3]})`;
                        ctx2.fillText('□', x, y);
                    }
                }
            }
            playVideo();
        }
    </script>
</body>
</html>

源码地址

所有源码包括视频已上传到 github,大家可以自行观看。

juejin-demo/canvas-demo at main · catwatermelon/juejin-demo (github.com)

结束语

学习可能是枯燥的,但是如果能在玩中学到东西,那是最好不过的了。通过这个案例,我们学会了两个 canvas 方法,并能通过这两个方法实现字符视频,希望大家阅读之后都能有所收获。