八个看上去是算法出错的 bug
Video2Any 提取幻灯片的方式是:在浏览器里解码帧,逐帧和上一帧比较,变化够大的就留下来。结果不对的时候,我们第一个怀疑的总是这一步比较。但它通常不是原因。
下面是我们一周里排查的八个问题。真正出在检测器里的只有两个。剩下六个分别是:一个没有清掉的 ref 守卫、一个算错了数的测试脚本、一个虚拟滚动的列表、一次和接口请求的竞态、一次内存到了上限,还有一条错误日志——它把整条错误里唯一有用的那部分丢掉了。

八个问题
一、那个改了没有反应的控件
有用户反馈:一段很短的视频,密度选 Dense 也只出三张,和 Default 一样。我们对着阈值公式看了很久,才回过头去确认最基本的一件事——这个设置到底有没有传到检测器里。
没有传到。首次扫描由一个 ref 守着,保证每个文件只跑一次。你改动密度时,回调确实重建了,但这个守卫立刻把 effect 挡了回去,真正能触发重跑的只剩另一处提示里的一个按钮。所以你切换之后看到的还是上一次的结果,连时间戳都一样。修好之后,同一段片子 Sparse、Default、Dense 分别输出 3、6、13 张;在此之前三档都是 6 张。
二、测试脚本两次都算错了
为了解释一个反常的结果,我们在 Node 里离线复刻了一遍管线,喂进同一个文件。两次跑出来的数字都和线上对不上,而且偏的方向相反:第一次脚本给出 1 张、线上是 6 张;改完之后脚本给出 15 张、线上还是 6 张。
原因有两个。脚本用 ffmpeg 抽的是灰度帧,而浏览器交给引擎的是 canvas 上的 RGBA,两边缩放算法的差异就足以把标定出来的阈值从 0.618 挪到 0.650。另外脚本跳过了决定是否启用活动遮罩的那个函数,也就是说它走的根本不是线上那条路径。一个只复刻了算法、没有复刻管线的脚本,会非常肯定地给你一个错误的答案。
三、DOM 里只有屏幕上看得见的那些
有一个测试要确认 32 分钟的视频能不能出 30 分钟之后的幻灯片。它读了页面上每一张缩略图的时间戳,最后一张停在 27:00,看上去正是分析被截断的样子。
那个网格用了虚拟滚动,只有可见的行才真的存在于 DOM 里。我们先滚到底再读,真正的最后一张在 32:00。这个断言一直在量的是视口,不是数据。
四、每个 worker 各自去重
长视频会切成若干分片并行扫描。每个 worker 只在自己那一段里去掉重复的画面——讲者翻回前一页,会议镜头切回同一个机位——而它们彼此看不到对方的结果。
于是一个反复出现的画面,在几个分片里出现过,就会被保留几次。一段 29 分钟的会议录像,9 张里有 3 张是重复的。更麻烦的是,这让最终的张数取决于切了几片,也就是取决于用户的电脑有几个核。我们把去重挪到了主线程,每张幻灯片到达时就比对一次,所以重复的那张不会先显示出来再被删掉。
五、更正确的设计反而更差
每个分片在自己那一小段上各自标定阈值,这显然不合理:一个视频只有一个分布,应该只有一个阈值。于是我们就照这个思路改了:worker 把自己测到的比值报上来,主线程汇总、统一标定一次,再把结果发回去。
改完之后,切 4 片和切 8 片的差距确实从 3 张收窄到 1 张,但结果本身变差了。还是那段 29 分钟的会议:ffmpeg 自己的场景检测数出 7 处真实转场,分片各自标定加上前面那次去重能给出 6 张,统一标定只给出 4 张。汇总之后分布变宽,median + 2·MAD 被抬高,真实的换页就被压了下去。我们把这次改动 revert 了。一个设计可以更有道理,同时又比不过你手上正在跑的那个;要看出这一点,你手里必须有一个自己代码之外的参照。
六、那个 false 的意思是「还没问」
一位付费订阅的用户传了一段长视频,只解析出了前三十分钟。他点一下重新采样,整段就都出来了——这正是它看起来像检测问题的原因。
编辑器在还不知道你是谁的时候就开始工作了。权限标记的初始值是 false,这个 false 的意思不是「这个用户没有权限」,而是「请求还在路上」;长度上限读到它,就按免费档砍掉了后面的部分。等到重新采样跑起来,套餐信息已经回来了。我们先是改成等它回来,后来干脆让它不用等:worker 返回页面的时候本来就握着 session,直接把套餐写进文档,首次渲染就知道了。
七、所有错误都叫「Error」
语音转写的失败次数比成功还多,6 比 5,而每一条日志记下来都是「Error」这个字符串。19 次失败的提取也一样。
代码记的是 err.name,而任何人手写出来的 Error,这个字段永远是「Error」。真正带信息的是 message,它哪儿都没留下。我们改成记录 message 之后,才看清转写失败是怎么发生的:decodeAudioData 会按音频上下文的采样率重采样,所以在硬件默认的 48 kHz 立体声下解一段 55 分钟的课,光浮点采样就要先占掉约 630 MB,两小时是 1.4 GB。改成在一个本来就是 16 kHz 的上下文上解码,模型拿到的音频完全一样,内存只要大约六分之一。
八、它很快,用起来却像卡住了
一段 92 分钟的会议录像,扫完只要 52 秒。而第一张幻灯片出现在第 50 秒。
没有哪一步是慢的。阈值要从整段视频的帧差分布里标定出来,所以在读完整个视频之前,一张都选不出来——而这 50 秒里,屏幕上只有一个百分比。旁边什么都没有、只有一个数字在往上走,用户会以为程序卡住了。现在这里会写明正在做的是哪一半工作、视频已经读到了哪里,同样是 52 秒,投诉就没有了。
如果能跟一周前的自己说两句
在推敲代码之前,先确认输入真的送到了代码里。这八个里有一半属于这一类:设置没有送到、答案还没有回来、测试量错了对象。
另外,手上要留一个你自己管线之外的参照。ffmpeg 的场景检测不是我们要做的东西,也不需要是,它只要和我们的实现无关就够了。如果没有它,我们会把那次标定重写发上线——因为它确实是更好的设计——然后在 7 张真实幻灯片里悄悄少掉 3 张。