致不灭的你:第12话 弹幕彩蛋分析

官方在评论区置顶了一条评论: alt

“#致不灭的你#12集已更新,在本集21分20秒之后,输入弹幕指令“少年与少女的永恒之爱”或者“花朵”将有弹幕彩蛋出现,一起见证铃和古古最后的爱恋!”

具体效果如下图所示: alt

详情看评论接口:https://api.bilibili.com/x/v2/reply/main?next=0&type=1&oid=248907536 返回的json数据

.data.top.upper.content.message

//此处省略其它节点数据
{
    "data":{
        "top":{
            "upper":{
                "content":{
                    "message":"#致不灭的你#12集已更新,在本集21分20秒之后,输入弹幕指令“少年与少女的永恒之爱”或者“花朵”将有弹幕彩蛋出现,一起见证铃和古古最后的爱恋!"
                }
            }
        }
    }
}

接下来分析一下输入弹幕指令“少年与少女的永恒之爱”或者“花朵”是如何转换成弹幕彩蛋!alt)

我这里使用的是Chrome DevTools,也就是谷歌浏览器开发者工具来调试播放页面。

HTML代码里打断点

我这里还不知道弹幕是放在哪一个元素节点里,所以用了比较繁琐(笨)的方法,以下把存放弹幕的Dom节点简称弹幕容器

  1. Chrome打开视频播放页,键盘输入Ctrl+Shift+C打开开发者工具并启用元素定位功能。接着是光标选中视频播放器,定位到Dom节点,这里我本来是希望能直接选中弹幕的,但发现只能定位到视频播放器。

    HTML <div class="bilibili-player-video"> <video crossorigin="anonymous" preload="auto" src="blob:https://www.bilibili.com/6bccdf52-dc1a-4aa3-938e-618d58f2f6d8"> </video> </div>

  2. 我就想直接把整个视频Dom节点删掉,或许就能选中弹幕节点了,结果还是不行。最后我尝试用排除法,选中视频节点的兄弟节点按下DEL一一删除,直到页面上的弹幕消失,那就说明被删除的节点就是弹幕容器,按下Ctrl+Z还原弹幕容器这个Dom节点。

    <div class="bilibili-player-video-danmaku" aria-live="polite">
        <!--省略弹幕子节点-->
    </div>
    
  3. 我当时看到的视频里的桔梗花几乎刷满半屏,所以很轻易就在弹幕容器的子节点里找到桔梗花的Dom节点。

    <div class="bilibili-player-video-danmaku" aria-live="polite">
    <!--省略其它节点-->
        <div class="b-danmaku" style="width: 42.1875px; height: 28.125px; top: 233px; left: 1001.75px; transform: translateX(-410.823px) translateY(0px) translateZ(0px); transition: -webkit-transform 0s linear 0s;">;<img src="//i0.hdslb.com/bfs/feed-admin/9c05fff7d25348445a5c905e12985d61a7cc0cf1.png" style="width:42.1875px;height:28.125px;"></div>
    </div>
    

    这里打开img标签的src属性指向的图片url,发现就是一朵桔梗花。 如果子节点里没发现桔梗花的Dom节点,那就回到视频拖动进度条到21分20秒之后,尽可能找到桔梗花较多的一帧,就很容易在弹幕容器里找到桔梗花的Dom节点。 我确定桔梗花就是在弹幕容器以后,选中弹幕容器Dom节点,右击Break on…>;subtree modifications打上一个Dom断点,让桔梗花弹幕渲染到弹幕播放器里时,自动定位jsc-player.996b9ac5.js的相关代码行里,代码经过压缩混淆看起来密密麻麻的,点一下Pretty-print格式化一下代码。 > 注意这个js代码文件名可能会随着版本更新后,发生变化,不过jsc-player应该是短期内不变的。 > 这里我还是感到比较幸运的,因为之前调试其它接口的时候,总是定位到我完全看不懂的代码行里。

    Javascript调用栈分析

    以下代码行数会随着版本更新后,发生变化,不过短期内应该不变的。

    上面Dom断点后,自动定位到jsc-player.996b9ac5.js:formatted:46735,这行代码是一个函数声明。

    function zl(i) {
            //i对象里包含了弹幕Dom节点、桔梗花原图url等
            var e = i.dom
              , t = i.textData
              , a = i.divCssText
              , r = i.fontSize
              , o = Sl[t.picture]
              , n = 0
              , l = 0;
              //...
              //这里构造桔梗花img标签
              e.innerHTML = '<img src="' + t.picture + '" style="width:' + n + "px;height:" + l + 'px;">;',
             //...
              /**调用另外一个函数,参数依然是i对象**/Fl(i)),
    }
    

    看了一下大概逻辑,可以发现这就是构造桔梗花弹幕的函数。主要是拼接img标签,缩放桔梗花原图,计算图片位置等。。。。。。可以看到函数最后还调用了另外一个函数jsc-player.996b9ac5.js:formatted:46720

    function Fl(i) {
        var e = i.append
              , t = i.parent
              , a = i.dom
              , r = i.className
              , o = i.divCssText;
            //...
            /**把包含桔梗花图片的弹幕Dom添加到弹幕容器里**/e && t.appendChild(a),
            //...
        }       
    

    zl函数已经是确定把文本弹幕渲染成桔梗花的逻辑,并不是把少年与少女的永恒之爱或者花朵判断为关键词,转换成桔梗花的逻辑,所以查看面板右侧的Call Stack调用栈,找到zl函数的调用栈。从调用栈中找到调用zl函数的b函数。 jsc-player.996b9ac5.js:formatted:46959

    var b = function(t) {
                    //这个createDom对象有一个dmPicture属性指向zl函数,t就是一个字符串参数dmPicture。
                    var o = e.createDom[t]({
                        text: r.text,
                        textData: i,
                        className: d,
                        divCssText: c,
                        fontSize: s,
                        prefix: p,
                        dom: a,
                        parent: r.parent,
                        append: l
                    })
    

    接着往下看调用b函数的textRender函数

    //这里判断i对象存在**picture**属性就会调用zl函数
    if (i.picture) {
                    if (Sl[i.picture])
                        return i.attr = -1,
                        //调用b函数
                        b("dmPicture");
                    i.picture = ""
                }
    

    这里把i对象称为弹幕对象,避免跟下面的i对象混淆。 接着找一下弹幕对象的picture属性究竟是在哪里赋值的。继续往调用栈下方看,一直找到一个i对象定义的onTime函数jsc-player.996b9ac5.js:formatted:47224,具体分析看下面的函数注释

    i.prototype.onTime = function(i, e) {
                //...
                        /**
                         * 这个r是一个数组,里面存储的就是一个个弹幕对象。
                         * timeLine也是一个数组,包含了视频播放器右侧的弹幕池里的所有弹幕。
                         * 因为这个timeLine数组大小=弹幕池的弹幕数量,r数组就是通过这个timeLine数组定义的getItemsByRange函数截取出来的。
                         * 这个getItemsByRange函数传入了两个实参,都是一种只包含stime属性的对象,这里推测getItemsByRange的作用是根据当前视频播放的时间点,从弹幕池中截取属于当前时间点的弹幕组成r数组。
                        **/
                      , r = this.timeLine.getItemsByRange({
                        stime: e ? e - a / 512 * this.config.duration / this.config.speedPlus / this.config.videoSpeed : this.lastTime - .001
                    }, {
                        stime: e || t
                    });
                    e && (this.config.danmakuNumber = 1e3),
                    this.insert(r, i, e || i),
                    e && (this.config.danmakuNumber = 300)
                }
                this.lastTime = t
            }
    

    接着找一下i.timeLine里的弹幕对象从哪里来的。在jsc-player.996b9ac5.js:formatted:47232打下断点,调用栈的栈顶右击Restart frame。调用栈刷新代码运行到新断点位置,再从调用栈里往下看有个nextFrame函数

    i.prototype.nextFrame = function(i, e) {
                //在这里断点发现timeLine早已赋值,结合下面的递归调用判断timeLine应该是在开始执行nextFrame前赋值的。
                var t = this;
                //...
                    //函数体里给i对象的一堆属性赋值,然而也没有timeLine。
                    this.fps = 0,
                    this.fpsStart = !0,
                    this.manager.onTime(this.time / 1e3, i),
                    this.animateTime = a),
                    this.manager.render(this.time / 1e3, this.paused, i)),
                    this.paused ? this.animateRange = a - this.animateTime : (this.fps++,
                    this.animateRange = 0,
                    //...
                    this.animate = window.requestAnimationFrame((function(i) {
                        //递归调用
                        t.nextFrame(null, i)
                    }
                    )),
                //...
                }
            }
    

    接着调用栈里往下找,定位到一个i.play函数方法体里调用i.nextFrame的位置,这行代码是在jsc-player.996b9ac5.js:formatted:47558。而i.play有如下定义:

    i.prototype.play = function(i) {
                //断点2
                var e = this;
                this.paused = !1,
                this.timeZero = this.pauseTime,
                this.pauseTime = 0,
                this.animate && window.cancelAnimationFrame(this.animate),
                this.animate = window.requestAnimationFrame((function() {
                    e.startTime = Date.now(),
                    e.animateTime = e.startTime - e.animateRange,
                    //这里开始调用i.nextFrame,直接这行代码再打一个断点
                    e.nextFrame(i)
                }
                )),
                this.fpsTimer && clearTimeout(this.fpsTimer),
                this.setFps()
            }
    

    断点后Restart frame发现这里也已经赋值timeLine数组,函数体第一行再断点Restart frame后确定play函数运行前,i.timeLine里的弹幕对象已存在,接着在调用栈里往下找。定位到jsc-player.996b9ac5.js:formatted:48853,这里又发现代码行是在另外一个i对象的play函数函数体里,避免混淆把之前的i对象称为danmaku对象。 这个i.play有如下定义:

    i.prototype.play = function() {
        var i;
        //调用栈定位
        this.danmaku.play(),
        this.player.advDanmaku && this.player.advDanmaku.play(),
        this.player.basDanmaku && this.player.basDanmaku.play(),
        this.player.danmakuSetting && this.player.danmakuSetting.maskStart(),
        null === (i = this.player.allPlugins) || void 0 === i || i.play()
    }
    

    接着跟踪i.danmaku.timeLine定义的位置,调用栈往下看定位到 jsc-player.996b9ac5.js:formatted:38770,这是在一个i._onVideoPlay函数体里

    i.prototype._onVideoPlay = function(i, e, t) {
                //打断点
                var a, r, o, n = this, l = this.player;
               //...
               //这里的i对象又是另外一个i对象,为了避免混淆我这里直接把链式调用写完整,上面把this.player赋值给l,而l.danmaku就是上面的i对象,也就是timeLine数组等于i.player.danmaku.danmaku.timeLine
               l.danmaku && l.danmaku.play(),
               l.subtitle && l.subtitle.play()
     }
    

    _onVideoPlay函数体首行打上断点Restart frame后,timeLine数组也已经存在弹幕对象。接着还是看调用栈,找到另外一个play函数,这是一个属于视频播放器video dom定义的play事件触发的函数。浏览一下play函数体,没有发现timeLine相关定义。

    $(i).on("play", (function() {
                    (null === performance || void 0 === performance ? void 0 : performance.timing) && !performance.timing.
                    //...
    

    这里在调用栈上方的Watch添加两个变量 a.danmaku.danmaku.timeLine a.controller.player.danmaku.danmaku.timeLine 接着首行打上断点,并且视频播放器点击一下暂停再播放,重新执行了play函数,确定了timeLine对象并不会在视频播放器播放前就已经初始化好了,所以只能查找video dom对象的其它事件继续分析。

    开发者工具切换到Elements,选中video dom查看右侧的Event Listeners,刷新了视频播放器绑定的所有事件。这里我推测loadstart事件初始化timeLine可能性较大,于是展开loadstart查看绑定的事件函数只有一个,就是在 jsc-player.996b9ac5.js:formatted:64977

    $(i).on("loadstart", (function() {
                    a.initialized || a.loadingpanel.ready(3),
                    a.sourceSwitching || clearInterval(a.timerChecker),
                    //这行设置状态的代码在play函数也出现多次
                    a.controller.setState(x.V_IDEL)
                }
                ))
    

    函数体里首行断点发现并没有执行,但是留意到这里也有一行熟悉的代码,于是我回到play事件函数,在jsc-player.996b9ac5.js:formatted:64977发现类似的设置装填的代码

    a.controller.setState(x.V_PLAY)
    

    我推测x状态对象可能存在弹幕加载完毕的状态常量,所以直接找到x状态对象定义的位置jsc-player.996b9ac5.js:formatted:11868。发现x并不是一个对象而是一个函数。

    , x = function() {
            function i() {}
            return i.TAB_WATCHLATER = 2,
            i.TAB_PLAYLIST = 3,
            i.TAB_DANMAKULIST = 4,
            i.TAB_BLOCKLIST = 5,
            i.UI_NORMAL = 0,
            i.UI_WIDE = 1,
            //...
            //jsc-player.996b9ac5.js:formatted:12045声明了弹幕加载完成的变量
            VIDEO_DANMAKU_LOADED: "video_danmaku_loaded",
            //...
    }
    

    接着把状态VIDEO_DANMAKU_LOADED作为关键词,在js里全局搜索VIDEO_DANMAKU_LOADED出现的位置,试图找到弹幕加载完后执行弹幕池初始化相关逻辑。定位了几个代码行,在jsc-player.996b9ac5.js:formatted:51188找到了弹幕加载完的事件函数。

    t.bind(x.EVENT.VIDEO_DANMAKU_LOADED, (function(i, t, a) {
                        if (!t)
                            return e.trackInfoPush("web_load_danmuku_fail", a),
                            !1;
                        e.trackInfoPush("web_load_danmuku", a)
                    }
                    )),
    

    这里函数体首行断点,查看回调函数,原以为三个实参中应该有一个是包含了timeLine数组,然而并没有。不过观察了函数体,觉得e.trackInfoPush有可能是初始化timeLine,但是没有在函数体里找到关键逻辑。

    经反复调试,在jsc-player.996b9ac5.js:formatted:43091找到获取弹幕并解码的逻辑。

    return i.prototype.loadDmPb = function(i, e, t) {
                    var a = this;
                    return void 0 === e && (e = !0),
                    void 0 === t && (t = "DmSegMobileReply"),
                    this.nativeXHR(i, e).then((function(i) {
                        if (!a.protoMessage[t]) {
                            var e = Vn.Root.fromJSON(Bn);
                            a.protoMessage[t] = e.lookupType("bilibili.community.service.dm.v1." + t)
                        }
                        var r = a.protoMessage[t].decode(new Uint8Array(i.data))
                        , o = a.protoMessage[t].toObject(r);
                        return Promise.resolve({
                            data: o,
                            loadTime: i.loadTime
                        })
                    }
                    )).catch((function(i) {
                        return Promise.reject(i)
                    }
                    ))
                }
    

    发现picture来源就是直接从接口 Protocol Buffers弹幕解码后的每一个弹幕对象中包含的action属性解析出来的

    {
            "id": 51423415803838470,
            "progress": 1449368,
            "mode": 1,
            "fontsize": 25,
            "color": 16777215,
            "midHash": "36e0d151",
            "content": "少年与少女永恒的爱",
            "ctime": 1625224556,
            "weight": 10,
            "action": "picture:i0.hdslb.com/bfs/feed-admin/9c05fff7d25348445a5c905e12985d61a7cc0cf1.png?scale=1.00",
            "idStr": "51423415803838469"
        }
    

    结合jsc-player.996b9ac5.js:formatted:44354声明的发送弹幕方法,尝试在视频进度条21分20秒以后模拟发送弹幕,发现接口携带的表单参数并不包含action,因此推断action应该是在服务端那边生成的。

    i.prototype.send = function(i) {
                    var e = this
                    , t = this.player
                    , a = this;
                    if (i && t.config.cid && this.status === x.SEND_STATUS_TYPING && !this.SENDING_DISABELD) {
                        var r = t.currentTime()
                        , o = new Date
                        , n = {
                            type: 1,
                            oid: t.config.cid,
                            msg: i,
                            aid: t.config.aid,
                            bvid: t.config.bvid,
                            progress: Math.ceil(1e3 * r),
                            color: this.config.color,
                            fontsize: this.config.fontsize,
                            pool: this.config.pool,
                            mode: this.config.mode,
                            rnd: this.config.rnd,
                            plat: 1
                        };
    

alt alt

断点调试记录

[
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 46727,
        "columnNumber": 8,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 46748,
        "columnNumber": 8,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 47558,
        "columnNumber": 12,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 47566,
        "columnNumber": 16,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 47843,
        "columnNumber": 20,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 46231,
        "columnNumber": 12,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 47231,
        "columnNumber": 18,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 47822,
        "columnNumber": 12,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 48852,
        "columnNumber": 12,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 38727,
        "columnNumber": 12,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 38709,
        "columnNumber": 12,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 64836,
        "columnNumber": 16,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 51188,
        "columnNumber": 20,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 43793,
        "columnNumber": 20,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 43124,
        "columnNumber": 24,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 43791,
        "columnNumber": 16,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 47616,
        "columnNumber": 16,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 43501,
        "columnNumber": 12,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 43533,
        "columnNumber": 16,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 43458,
        "columnNumber": 12,
        "condition": "",
        "enabled": false
    },
    {
        "url": "https://s1.hdslb.com/bfs/static/player/main/widgets/jsc-player.996b9ac5.js:formatted",
        "lineNumber": 43101,
        "columnNumber": 16,
        "condition": "",
        "enabled": true
    }
]