V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
ezshine
V2EX  ›  程序员

使用 Javascript 制作 BadApple 字符画视频

  •  7
     
  •   ezshine ·
    ezshine · 2021-04-30 09:09:17 +08:00 · 2302 次点击
    这是一个创建于 1309 天前的主题,其中的信息可能已经有所发展或是发生改变。

    badapple

    本文配有视频:点击播放视频

    结合文章一起看效果更好


    写作的背景

    是这样的,最近有 B 站的小伙伴在我某个视频下评论,让我分享一下,这个BadApple动态效果怎么做。好家伙,我开始还不知道 badapple 是什么,结果我一看,so easy~。

    image.png

    不过,既然有小伙伴提出了这个问题,我就写一个完整教程,下次再有小伙伴问,直接看这篇,保管你用任何语言,任何框架都做得出来。

    先拆解需求

    1. 播放视频
    2. 将视频每一帧的画面转为点阵 /像素 RGB 值
    3. 将 RGB 转灰度值
    4. 按照灰度值填充字符

    需求很简单,稍微复杂的部分只有 RGB 转灰度,那我们直接开撸代码,使用vanilla.js框架(这是个梗,不明白的话自己搜)来完成开发。

    1. 播放视频

    使用 JS 创建一个video标签,并为它设置视频源路径

    var videoDom = document.createElement("video");
    videoDom.src = "./video/badapple.mp4";
    videoDom.style.width = "900px";
    videoDom.style.height = "675px";
    

    由于我们最终的效果并不需要看到这个视频原画面,所以我们也不用将这个dom添加到网页body当中去。

    添加一个控制视频播放和暂停的按钮

    var btnPlayAndPause = document.createElement("div");
    btnPlayAndPause.style.color = "#fff";
    btnPlayAndPause.style.textAlign = "center";
    btnPlayAndPause.style.position = "absolute";
    btnPlayAndPause.style.top = btnPlayAndPause.style.left = "0px";
    btnPlayAndPause.style.width = videoDom.style.width;
    btnPlayAndPause.style.height = btnPlayAndPause.style.lineHeight = videoDom.style.height;
    btnPlayAndPause.style.cursor = "pointer";
    btnPlayAndPause.style.fontSize = "30px";
    btnPlayAndPause.style.zIndex = 2;
    btnPlayAndPause.innerText = "play";
    document.body.appendChild(btnPlayAndPause);
    

    当按钮点击的时候,切换videoDom的播放 /暂停状态

    btnPlayAndPause.addEventListener("click",function(){
            if(btnPlayAndPause.innerText === "play"){
                    videoDom.play();
            }else{
                    videoDom.pause();
            }
    })
    

    监听videoDomcanplay事件,并渲染第一帧

    videoDom.addEventListener('canplay',function(){
        renderVideoFrame(videoDom);
    });
    

    监听videoDomplay(播放),pause(暂停),stop(停止)事件在播放时启动字符画面渲染,暂停或停止时也停止掉字符画面渲染。

    videoDom.addEventListener('play',function(){
        console.log("开始播放");
        btnPlayAndPause.innerText = "";
    
        startRender();
    });
    
    //监听播放结束
    videoDom.addEventListener('pause',function(){
        console.log("播放暂停");
        btnPlayAndPause.innerText = "play";
    
        stopRender();
    }); 
    
    //监听播放结束
    videoDom.addEventListener('ended',function(){
        console.log("播放结束");
        btnPlayAndPause.innerText = "play";
    
        stopRender();
    });
    

    画面渲染的绘制频率和浏览器的绘制频率保持一致,这样不会丢掉任何一个画面,但算力消耗会更大。

    var timerId;
    function startRender() {
            timerId = requestAnimationFrame(updateRender);
    }
    function updateRender(){
            renderVideoFrame(videoDom);
            timerId = requestAnimationFrame(updateRender);
    }
    function stopRender(){
            cancelAnimationFrame(timerId);
    }
    

    2. 将视频每一帧的画面转为点阵 /像素 RGB 值

    这里我们要利用html5canvas标签,首先将视频的画面原封不动的绘制到canvas上。

    function renderVideoFrame(videoDom) {
        var videoSize = {width:parseFloat(videoDom.videoWidth),height:parseFloat(videoDom.videoHeight)};
    
        var canvas = document.querySelector("#canvas");
        if(!canvas){
                canvas = document.createElement("canvas");
                canvas.id = "canvas";
                canvas.style.width = videoDom.style.width;
                canvas.style.height = videoDom.style.height;
                canvas.style.position = "absolute";
                canvas.style.zIndex = 1;
                canvas.style.left = canvas.style.top = "0";
                canvas.width = videoSize.width;
                canvas.height = videoSize.height;
    
                document.body.appendChild(canvas);
        }
    
        const ctx = canvas.getContext("2d");
    
        ctx.drawImage(videoDom, 0, 0, videoSize.width, videoSize.height);
    }
    

    注意看我这里做了判断,只在场景上没有指定canvas的时候,才创建它。

    接着通过contextdrawImage方法,将视频绘制到场景上,现在我们body虽然没有video标签,但我们也能看到视频了。

    image.png

    接着我们再通过contextgetImageData方法,获得画布里的全部点阵 /像素数据。

    var imgData = ctx.getImageData(0, 0, videoSize.width, videoSize.height).data;
    

    这是一个庞大的数组,数组的长度由width*height*4(宽度 x 高度 x4 )组成,4 代表 RGBA 四个值。

    //如果这个画布是宽 2 个像素,高 1 个像素的话,那么 getImageData 获得数组结构如下
    [r,g,b,a,r,g,b,a]
    

    这个理解了之后我们来看如何获得指定位置的RGBA值。

    for (var h = 0; h < videoSize.height; h++) {
        for(var w = 0; w < videoSize.width; w++){
                var position = (videoSize.width * h + w) * 4;
                var r = imgData[position], g = imgData[position + 1], b = imgData[position + 2];
        }
    }
    

    通过画布宽度和高度的两次for循环,换算得出所有点阵 /像素在数组中的起始序号。

    • r = imgData[position]
    • g = imgData[position + 1]
    • b = imgData[position + 2]

    大家可以看到,一块 200x300 的画布=6 万个点阵=长度为 24 万的数组,我们肯定不能按照像素 1:1 来绘制,这样运算量过大,并且绘制出来的效果也不好,你根本看不清文字内容。

    所以我们要加入一个间隔,比如1:12,这样运算量大大减少,但是绘制出来的精度也会降低。

    2021-04-19 16_09_36.gif

    实现这个效果我们不需要alpha,接下来的重头戏是把 RGB 转为灰度值,灰度值再转化为笔画密度的文字,比如黑色的像素块我们就用这个字来替换。

    3. RGB 转灰度值

    我采用了文中所列的第二种方法

    Gray = (R*30 + G*59 + B*11 + 50) / 100 - 0.5
    

    这个值在0.5-255.5之间

    4. 按照灰度值填充字符

    首先我们要建立一个灰度字符数组,按照笔画密度 /视觉灰度(从高到低)排列,最后留一个空白字符去表现纯白色。

    var asciiList = ['猿','帅','老','大', ' '];
    

    将灰度值转为字符数组的序号,使用Math.min方法来确保序号不会越界

    var i = Math.min(asciiList.length-1,parseInt(gray / (255 / asciiList.length)));
    

    总结

    这是一个自我接触计算机以来就知道的特效,我的职业历史上也用各种语言分别实现过。其实拆解需求后,核心就是获取画面点阵信息,RGB 数据转灰度或者二值化(仅黑白两色)。再根据灰度信息替换为字符即可。

    希望我的这篇教程让你彻底学会,以后不管用什么语言,什么环境,找到对应的 API,都能开发出来这个效果。

    One More Thing

    照我以往的风格,我也将这个特效代码做了个收藏夹的版本,B 站任意视频都可以用这个效果来播放~

    javascript:!(function(){console.log("badapple effect enabled");function renderVideoFrame(videoDom){var asciiList=['猿','帅','老','大',' '];var scale=parseInt(videoDom.videoHeight/parseFloat($(videoDom).css("height")));var gap=12/scale;console.log(scale);var videoSize={width:parseFloat(videoDom.videoWidth/scale),height:parseFloat(videoDom.videoHeight/scale)};var canvas=document.querySelector("#badapplecanvas");if(!canvas){canvas=document.createElement("canvas");canvas.id="badapplecanvas";canvas.style.width=videoDom.style.width;canvas.style.height=videoDom.style.height;canvas.style.position="absolute";canvas.style.background="#fff";canvas.style.zIndex=999;canvas.style.top="0";canvas.style.left=(parseFloat($(videoDom).css("width"))-videoSize.width)/2+"px";canvas.width=videoSize.width;canvas.height=videoSize.height;videoDom.parentElement.appendChild(canvas)}const ctx=canvas.getContext("2d");ctx.drawImage(videoDom,0,0,videoSize.width,videoSize.height);var imgData=ctx.getImageData(0,0,videoSize.width,videoSize.height).data;ctx.clearRect(0,0,videoSize.width,videoSize.height);ctx.font=gap+"px Verdana";for(var h=0;h<videoSize.height;h+=gap){for(var w=0;w<videoSize.width;w+=gap){var position=(videoSize.width*h+w)*4;var r=imgData[position],g=imgData[position+1],b=imgData[position+2];var gray=(r*30+g*59+b*11+50)/100;var i=Math.min(asciiList.length-1,parseInt(gray/(255/asciiList.length)));ctx.fillText(asciiList[i],w,h)}}}var videoDom=document.querySelector("video");videoDom.style.display="none";videoDom.addEventListener('canplay',function(){renderVideoFrame(videoDom)});videoDom.addEventListener('play',function(){console.log("开始播放");startRender()});videoDom.addEventListener('pause',function(){console.log("播放暂停");stopRender()});videoDom.addEventListener('ended',function(){console.log("播放结束");stopRender()});var timerId;function startRender(){timerId=requestAnimationFrame(updateRender)}function updateRender(){renderVideoFrame(videoDom);timerId=requestAnimationFrame(updateRender)}function stopRender(){cancelAnimationFrame(timerId)}})()
    

    新建一个网页书签,把以上代码复制粘贴到网址中,名称随便取。打开任意 B 站视频页,点击该书签,即可开启字符画播放模式,快试试吧...

    image.png

    11 条回复    2021-05-01 22:31:43 +08:00
    10bkill1p
        1
    10bkill1p  
       2021-04-30 09:17:34 +08:00
    好文帮顶,学习一下,对于 canvas 知之甚少
    hsfzxjy
        2
    hsfzxjy  
       2021-04-30 10:32:26 +08:00 via Android
    有屏幕就有 bad apple /doge
    ezshine
        3
    ezshine  
    OP
       2021-04-30 10:38:07 +08:00   ❤️ 1
    @hsfzxjy 有人的地方就有越共 /doge
    nekoyaki
        4
    nekoyaki  
       2021-04-30 11:03:09 +08:00
    我草,车万厨!
    kop1989
        5
    kop1989  
       2021-04-30 11:06:01 +08:00
    非常好的文章,感谢分享。
    对画布的理解+1
    w3313003
        6
    w3313003  
       2021-04-30 11:49:58 +08:00
    学习了
    DIYgods
        7
    DIYgods  
       2021-04-30 12:31:12 +08:00
    巧了,我用 B 站的高级弹幕做过 https://www.bilibili.com/video/BV1uW411e7gt
    xz410236056
        8
    xz410236056  
       2021-04-30 14:33:00 +08:00
    有屏幕的地方就有烂苹果
    ezshine
        9
    ezshine  
    OP
       2021-04-30 14:50:29 +08:00
    @DIYgods 你这个更加🐂🍺!佩服!竟然是弹幕,好想法!互粉一下吧~
    orzorzorzorz
        10
    orzorzorzorz  
       2021-04-30 20:48:48 +08:00
    建议加入车万版,等一手 v2 车万同人在线高并发、高可用、精妙抓手赋能并推动个人思考沉淀发展的精致同人作,各路 HR 在线发情,性感 leader 风骚发牌。

    不过楼主这个真厉害了,实在是吾辈楷模。 :doge
    ysc3839
        11
    ysc3839  
       2021-05-01 22:31:43 +08:00 via Android
    @DIYgods 当年见到高级弹幕时,我是想用里面的 path 功能做个类似的,不过印象中因为 path 有某些限制,后面也没改进,就放弃了……
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2107 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 00:37 · PVG 08:37 · LAX 16:37 · JFK 19:37
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.