我的下载文件夹里躺着几百个视频。有刷到的教程、有想反复看的访谈、有觉得文案写得好的带货口播,下载那一刻都觉得有用,之后再没点开过。偶尔想找「上次那个讲分布式事务的视频」,对着 video_1727xxxx.mp4 这样的文件名完全无从下手。真看完的也没好到哪去,两星期后别人问起讲了什么,只记得「讲得还行」。
这是两个问题:库乱了,没人分类;看过了,没记住。市面上的工具各管一段:钉钉 AI 听记管录音转写,抖音的 @豆包 管站内视频问答,但我的视频来源杂,分类体系我想自己定,结果也得留在我自己手里。于是写了 video-tagger:导入本地视频或抖音链接,自动识别内容类型打标签;值得分析的视频再进入第二阶段,转写加关键帧喂给多模态模型,按模板生成结构化报告。
先解决一个认知问题:视频直传 API 听不见声音
动手前做了一轮调研,结论直接影响整个架构:国内能直接接收视频的多模态 API,Qwen-VL、豆包、GLM 系列,全都不理解视频的音轨。它们看得见画面,听不见人说话。
而我要处理的视频里,口播类占大头,价值密度最高的部分恰恰在说的内容里。画面上一个人对镜头讲了二十分钟,API 只看到「一个人在说话」,说了什么一无所知。指望视频直传等于把最重要的信息扔掉一半。
所以最终方案是混合管线:云端 ASR 把音频转成文字,本地抽关键帧,两者组装成多模态 prompt 一起喂给 VLM。二十分钟以内的视频,这样组装出来大约 110K token,单次请求就能处理完,不需要 map-reduce 分片。成本算过,一条视频全流程不到五毛钱。
两层分类:一个负责看,一个负责判
分类管线拆成两层,各干各的。
感知层是 VLM。它拿到的输入是 10 张均匀抽样的帧和完整转写,输出不是分类结论,而是一组受控的观察标签:
1 | person-facing-camera 有人正对镜头说话 |
标签用英文枚举而不是让模型自由发挥,是为了让下游拿到的东西永远稳定。自由文本这轮叫「游戏实况」下轮叫「主机游戏录像」,决策层就没法比对了。
决策层是 Jev,就是 agent-guard 里用过的那个 TypeSafe 决策原语。它接收感知层的观察标签和转写摘要,输出 11 个类别的概率分布加置信度。选它有两个原因:输出天然是概率而不是一句自说自话的结论;它不会为了圆一个判断现场编理由。因为 Jev 只收文本且中文精度一般,感知层顺便承担了中英桥接的角色。
判定低于 0.85 置信度的不会直接入库,会自动补抽 3 段 10 秒音频样本转写,带着新证据重新判一次。Jev 服务挂掉时降级成 VLM 直接出分类,结果上标记「降级判定」,链路里没有单点。
内置的 11 类是口播、带货口播、访谈、解说、教学、剧情、唱歌、舞蹈、游戏、纯音乐、其他。这套体系在设置里完全可编辑,「哪些类型值得进入分析」是配置项,改个标记立即生效,不用重跑任何模型。
每个判定都能翻开看证据
分类结果不是黑盒。点开任何一条任务,能看到那 10 张抽样帧的缩略图、感知层打出的观察标签、一段中文的证据摘要、完整的概率分布。模型说是「教学」类,你能看到它依据的是「有人正对镜头、画面出现代码或 PPT、语言为讲解语气」这几条。
这个设计最初是为了自己 debug 方便,后来发现它决定了敢不敢信这个工具。概率分布里第二高的类别是什么、差多少,比单独一个结论有用得多。
关键帧:不是均匀切了完事
关键帧的质量直接决定 VLM 能「看」到什么。实际用了四层策略串起来:
- ffmpeg 场景切换检测,按镜头边界取帧
- 相邻帧间隔超过阈值的空档,兜底补帧
- pHash 去重,重复镜头只留一张
- 总量超过 150 张时均匀抽稀
pHash 没用 imagehash 库,用 numpy 手写了 64-bit 版本:DCT-II 取左上 8×8 块的中位数,两张帧汉明距离小于 10 判定为重复。就几十行代码,省一个依赖。
还有个特殊分支:没有解说的视频。判断标准是转写字数低于每分钟 5 个字,纯音乐、游戏录屏基本都是这种情况,画面信息密度远高于语音。这时采样间隔降到 1.5 秒、帧放大到 1024px,让 VLM 直接读画面里内嵌的字幕和 UI 文字。这一招省掉了独立的 OCR 环节。
分析:分类的价值在这里兑现
纯分类只是把库理顺了,真正要用起来的是第二阶段。产品思路后来收敛成一句话:分析是核心价值,分类是分析的前置步骤。分类结果驱动三个路由:
- 价值门控:唱跳、游戏类在配置里标记为不分析,直接拦截,不浪费预算
- 模板路由:教学类推荐知识点提取模板,访谈类推荐观点树模板,解说类配求真核查模板
- 模型路由:简单类型走 flash 档,复杂类型上 plus 档
产出的报告是结构化 Markdown:摘要、观点树、按章节切分的时间轴(每段带时间锚点,点击跳转)、待办事项、金句摘录。一份二十分钟的访谈,两分钟扫完报告就能决定要不要看原片。
工程上值得一提的几处
全链路缓存,缓存键是 file_hash + stage + version。转写、抽帧、分类、分析每个阶段独立缓存,换一版提示词重新分析,转写和抽帧不会重跑;改了某段处理代码,把对应 version 加一,旧缓存自动失效。
崩溃恢复。sidecar 启动时把上次被杀进程留下的 in-flight 任务重新入队,已经完成的阶段靠 artifact 缓存直接跳过。杀进程重开,任务从断点继续。
sidecar 安全。Python 服务只监听 127.0.0.1 的随机端口,启动时生成一次性 token,Tauri 壳从握手行里解析出来注入前端,之后每个请求都带 X-Auth-Token 校验,CORS 只放行 tauri webview 的 origin。本地起服务不设防的常见偷懒,这里没敢省。
SSE 带重放。任务进度走 SSE 推送,每个任务保留最近 200 条事件历史,晚订阅的客户端先收到重放再接实时流。刷新页面不丢进度。
抖音链接直接下载。粘链接就能下,下载模块隔离成可整体替换的适配器,签名失效时只影响 downloading 阶段。签名算法从 JS 移植到 Python 后,用「对拍」做验收:同一组随机数和时间戳在两边分别生成签名,测试逐字节比对,保证移植没有位级偏差。
测试不烧钱。写了个脚本用自定义 httpx transport 录制真实 API 响应,录制时自动脱敏,去掉 Authorization 头、图片换占位符、擦掉临时凭据,CI 回放这些 fixture 做契约测试。24 个测试文件跑一遍零 API 成本,云端契约改了立刻能发现。
两天,从设计文档到双平台安装包
这个项目从第一个提交到打 v0.1.0 的 tag,日历上是两天。
第一天白天全在写文档:8 篇设计文档加 58 条决策记录,覆盖需求、竞品、里程碑、技术方案、架构、风险、UI 和交接。写之前被两轮共 16 个问题的 grilling 折过一遍,又做了三轮头脑风暴查漏。定了条规矩:要推翻任何决策,先更新决策记录再动代码。项目初始化时装过 openspec 工作流,最终在决策记录里明确不采用,这个体量的项目,一份 ADR 索引跟得上节奏,结构化 spec 流程反而拖慢。
第一天晚上,AI 会话输出了单次 163 个文件、2.5 万行的功能提交,V1 完整应用加 V2 分析加全套测试一次成型。这是立项时就定好的分工:决策记录 D56 写明 AI 承担主要编码,人负责评测集采集、真机实测和双机验证。
第二天全在填打包的坑,一个接一个:
- PyInstaller 的
_MEIPASS每次启动解压到随机目录,缓存和抽出来的帧每次都被清空,改成写进固定的 app-data 目录 - macOS 打包后 WKWebView 的 CORS 行为和开发时不一样,所有 fetch 报 Load failed
- 打包版按 PATH 找 ffmpeg,用户机器上没有,必须解析随包分发的那个二进制
- Windows 上 msys2 的分号路径处理,CI 调了五次才绿
- lame 静态库 brew 装的和 Linux 构建有差异,最后从源码编译了 ffmpeg 全家桶随应用分发(LGPL 合规)
第二天上午结束,CI 产出 aarch64 的 dmg 和 x64 的 exe,tag v0.1.0。合计代码量约 1.1 万行,其中 Python sidecar 5300 行、React 前端 1700 行、测试 3000 行,Rust 壳只有 159 行,薄到只做拉起进程、握手、托盘和通知。
说点实话
评测报告三轮跑下来,分类 top-1 准确率从 40% 到 50%,离 90% 的验收线还差得远。低置信自动补证的触发率在 60% 到 80% 之间,设计时的预估是 15% 以下。这两个数字说明提示词还有很大打磨空间,不是能靠堆模型解决的活,得一轮一轮看 badcase,把每类视频的判定边界喂清楚。
工具能两天出片,准确率不能。这也是 VibeCoding 项目做多了之后最直接的体会:AI 把「能跑」的门槛踩平了,剩下全是「跑得对」的部分。
代码在 canyuda/video-tagger,双平台安装包在仓库的 release 里。