在一个标签页里并行解码视频
Video2Any 只有一条约束,其余一切都是从它推出来的:视频不离开你的机器。没有上传,就没有服务器干活;于是解码、比较、渲染全都发生在一个还得保持响应的浏览器标签页里。
下面是这件事的实际代价,以及网站其余部分是用什么搭的。
解复用器得自己写
WebCodecs 给你一个 VideoDecoder,负责把编码块变成帧。但它不给你编码块。浏览器自己有 MP4 解析器——`<video>` 就是靠它工作的——却没有任何 API 把它交给你。所以容器得自己解:遍历 box、找到 moov、读采样表,再一块一块地把 EncodedVideoChunk 喂给解码器。
这么做有个额外好处。既然容器是自己读的,就能在浪费五分钟之前告诉用户这个文件为什么打不开:ProRes 是剪辑格式,没有浏览器能解;而你是通过读它的 moov 知道这件事的,而 ProRes 的 moov 经常在文件末尾而不是开头。我们的探测器两头都扫,就是为了这个。
两趟,是被内存逼的
最直觉的做法是把每一帧都解出来比一遍。一段 92 分钟 25fps 的录像是 138,000 帧,1080p 下这个量标签页装不下。
所以它分两趟。**扫描**只在采样点上解码,每个采样存成 160×90——小到整段视频的像素只有几十兆,大到足以分辨这一页和上一页。等检测器选完了,**抓取**才回头去取全分辨率的帧,而且只取选中的那些。第一趟有 900 个采样点的上限,这带来一个值得知道的怪结论:29 分钟和 92 分钟的视频花的时间差不多,因为成本是按采样点算的,不是按分钟。
一个分片一个 worker,各带一个解码器
视频按时间切开,每个分片交给一个 worker,各自持有一个 VideoDecoder。池子大小是 min(4, 核数 / 2):只用一半的核,因为标签页还得跑界面;上限是 4,原因在下一节。
每个分片会从自己范围之前一点开始扫,好让检测器判断第一帧时手里有个参照,然后把不属于自己范围的丢掉。幻灯片是随着每次抓取完成流式吐出来的,不是最后一起给。
切分的代价
两条,而且这才是有意思的部分。每个分片在自己那一小段上标定检测阈值,所以同一个视频切成四片和切成八片会得到不同的课件——**结果取决于机器有几个核**。另外每个分片只在自己内部去重,所以会议里反复切回的同一个镜头,在几个分片里出现就保留几次:一段 29 分钟的录像,9 张里 3 张是重复的。
重复这条修好了——判定挪到主线程,每张幻灯片到达时就比一次。标定那条没有。我们把全局版本写出来了,结果更差:还是那段会议,ffmpeg 数出 7 处真实转场,分片各自标定给 6 张,统一标定给 4 张。所以池子仍然是 4,因为多切分片大约快 25%,但仍然会改变答案。
读完之前,一张都给不出来
阈值来自整段视频帧间差分的分布,所以在最后一帧被采样之前,第一张幻灯片也选不出来。92 分钟的录像扫描要 52 秒,第一张出现在第 50 秒。
这不是性能问题,加多少并行都解决不了。这是反馈问题,而它的解法是把原来那个孤零零的百分比换成「正在读取视频 · 45:46 / 92:08」。
外围的栈
Cloudflare Workers 加 Hono 做接口,D1 存账号和元数据,R2 存那少数几样真正需要存的东西。React 19 配 TanStack Router、Tailwind v4、better-auth。八种语言,各有各的 URL 前缀和各自的路由 basepath。
整站是**预渲染的 SPA**而不是服务端渲染:每个公开页面都是部署时构建出来的静态 HTML 快照,这也是营销页面可缓存、够快,而转换器本身完全不需要服务器的原因。只有一个地方我们打破了它。`/app` 那些页面是静态壳子,所以编辑器启动时对你的账号一无所知,必须先问一句——而在答案回来之前就开工,正是某个付费订阅用户被按免费档砍到三十分钟的原因。worker 返回这些页面时本来就握着 session,于是它直接把套餐写进文档,首次渲染就知道。这些响应是私有的、永不缓存;其他页面的公开缓存一点没动。
何必呢
上个服务器,上面这些麻烦大半都没了。但那也意味着每一节课、每一场内部会议、每一段医疗录像都要从我们的机器上过一遍——而这个产品存在的全部理由,就是它们不用。
约束本身就是功能。上面写的是它的价钱。