事情起因很简单:我想把一个 YouTube 视频下载下来,转录成文字,顺便把关键画面截出来加描述。
如果是去年的我,大概率会在本地装 Whisper 跑转录,再想办法搞个 VLM 做图像描述。但今年我家里有台 Mac mini 24 小时挂着跑 ASR 和 VLM 服务,全部通过 Tailscale 组网暴露给我的设备。那为什么不直接用?
这篇记录完整流水线的搭建过程。目标视频是 38C3 上 Dragon Sector 团队关于 Newag 列车 DRM 锁的后续报告——去年他们曝光了波兰火车制造商 Newag 在列车控制软件里埋了 GPS 围栏、日期炸弹、序列号校验等恶意逻辑,今年的演讲是后续:议会听证、刑事调查、以及他们被 Newag 起诉两次的经历。
NOTEMac mini 上的 VLM 和 ASR 服务是怎么搭的?参考前一篇《16GB Mac mini 上从 LLM 到多模态 VLM service》。
第一步:下载视频
工具是 yt-dlp,视频不需要高清(只是转录和截图用),480p 足够:
yt-dlp -f "best[height<=480]" -o "/tmp/temp/%(title)s.%(ext)s" \ "https://www.youtube.com/watch?v=8OB2NqcSDXQ"-f "best[height<=480]" 的意思是在所有高度不超过 480 像素的格式里选最好的。yt-dlp 的格式选择语法支持方括号里写过滤条件,height、fps、vcodec 都可以用。如果想更精细地控制,可以用 -S "res:480" 按分辨率排序。
下载下来大约 59 MB,44 分钟的视频。文件名自动提取:
38C3 - We've not been trained for this: life after the Newag DRM disclosure.mp4第二步:转录——用 Tailnet ASR 服务
我家 Mac mini 上跑着一个基于 FunASR 的语音识别服务,模型是 paraformer-zh + CAM++,支持中英文识别和说话人分离(speaker diarization)。服务自注册在 Tailscale 上的 service registry 里。
先查一下服务是否在线:
curl -s https://service.<tailnet>.ts.net/services | python3 -m json.toolASR 服务在 http://mac-mini.<tailnet>.ts.net:8099/transcribe,状态 healthy。
视频是 mp4 格式,ASR 服务接受音频文件,所以先用 ffmpeg 提取音频转成 WAV:
ffmpeg -i "video.mp4" -vn -acodec pcm_s16le -ar 16000 -ac 1 "audio.wav" -y参数说明:
-vn:丢掉视频流-acodec pcm_s16le:16-bit PCM 编码(ASR 模型偏好的原始格式)-ar 16000:16kHz 采样率(语音识别标准)-ac 1:单声道
44 分钟视频提取出约 83 MB 的 WAV 文件。然后直接 POST 给 ASR 服务:
curl -X POST "http://mac-mini.<tailnet>.ts.net:8099/transcribe" \ -F "file=@audio.wav" \ -o result.json70 秒处理完 44 分钟的音频。返回的 JSON 里包含:
{ "duration_s": 2659.0, "processing_time_s": 70.5, "speaker_count": 9, "segments": [ { "speaker": "说话人0", "speaker_id": 0, "start": 16190, "end": 16970, "text": "microphone on," } // ...813 segments ], "full_text": "...", "transcript": "[00:00:16.190 -> 00:00:16.970] 说话人0: microphone on,\n..."}813 个段落,识别出 9 个说话人(三位演讲者 + 多位提问观众),每段都带起止时间戳和说话人标签。
值得一提的是,paraformer-zh 是中英混合模型,对纯英文演讲加波兰语专有名词的识别准确度不如 Whisper 英文专用模型——比如 “Sergej Bazanski” 被转成了 “search ch sant scheme”,“Newag” 基本能识别但偶尔飘。不过对于理解视频内容来说完全够用,而且 Whisper base 模型对波兰语名字也没好到哪去。关键优势是说话人分离——Whisper 本身不提供这个功能。
第三步:场景检测 + 截图
这一步的目标是找到视频里画面发生显著变化的时间点(通常是切换幻灯片),在这些时间点截图。
ffmpeg 的 select 滤镜有个内置的 scene 评分函数,值域 0 到 1,越接近 1 表示相邻帧之间的差异越大。先跑一遍检测:
ffmpeg -i video.mp4 \ -vf "select='gt(scene,0.3)',showinfo" \ -vsync vfr -an -f null /dev/null 2>&1 \ | grep "pts_time" | sed 's/.*pts_time:\([0-9.]*\).*/\1/'阈值 0.3 只检测到 21 个场景变化,而且大部分集中在后半段(幻灯片切换频繁的部分),前半段只有寥寥几个。降到 0.15:
ffmpeg -i video.mp4 \ -vf "select='gt(scene,0.15)',showinfo" \ -vsync vfr -an -f null /dev/null得到 38 个时间点,分布更均匀。阈值的选择取决于内容类型:
| 阈值 | 效果 | 适用场景 |
|---|---|---|
| 0.4+ | 只捕捉剧烈变化 | 电影场景切换 |
| 0.3 | 中等敏感度 | 一般演讲/教程 |
| 0.15 | 捕捉较小变化 | 幻灯片切换(渐变动画) |
| 0.05 | 非常敏感 | 几乎每个画面变化都抓 |
然后在每个检测到的时间点截一帧:
while IFS= read -r ts; do mm=$(printf "%02d" $(echo "$ts / 60" | bc)) ss=$(printf "%02d" $(echo "$ts % 60 / 1" | bc)) fname="frame_${mm}m${ss}s.jpg" ffmpeg -ss "$ts" -i video.mp4 -frames:v 1 -q:v 2 "$fname" -ydone < timestamps.txt-ss 放在 -i 前面是 seek 模式,速度快(不需要解码整个视频到该位置)。-q:v 2 是 JPEG 质量(1-31,越小越好)。
最终得到 37 张截图,文件名直接带时间戳:frame_00m10s.jpg、frame_02m10s.jpg、frame_11m34s.jpg ……
第四步:用 VLM 描述每帧画面
Mac mini 上跑的 Qwen3.5-9B-MLX-4bit 是原生多模态模型(early-fusion 架构),支持文本和图像输入,API 兼容 OpenAI 格式。
对每张截图,把图片 base64 编码后连同 prompt 一起发给 VLM:
import base64, json, urllib.request
with open("frame_02m10s.jpg", "rb") as f: b64 = base64.b64encode(f.read()).decode()
payload = json.dumps({ "model": "mlx-community/Qwen3.5-9B-MLX-4bit", "messages": [{ "role": "user", "content": [ {"type": "text", "text": "Describe what you see in 2-3 sentences..."}, {"type": "image_url", "image_url": { "url": f"data:image/jpeg;base64,{b64}" }} ] }], "max_tokens": 200}).encode()
req = urllib.request.Request( "http://llm.<tailnet>.ts.net:8000/v1/chat/completions", data=payload, headers={"Content-Type": "application/json"})with urllib.request.urlopen(req, timeout=120) as resp: result = json.loads(resp.read()) print(result["choices"][0]["message"]["content"])37 张图逐一发送(没做并行——9B 模型在 16GB Mac mini 上跑 vision 本身就要占满内存,并行反而可能 OOM)。每帧描述大约 3-5 秒返回。
部分描述效果:
[00:15] This frame shows three people on stage at the 38C3 conference, with a podium displaying “ILLEGAL 38C3 INSTRUCTIONS” in red and white.
[25:25] This frame shows a presentation slide featuring a screenshot of The Wall Street Journal front page dated May 21, 2024, with headlines including “Hackers Got Trains Back On Line.”
[30:00] This frame shows a presentation slide titled “Criminal proceedings in Cracow,” detailing that Newag’s offices in Nowy Sącz were raided by police and prosecutors in February 2024.
9B 量化模型的 vision 能力出乎意料地好:能识别幻灯片上的文字、区分不同的演讲者、读懂代码截图、甚至认出报纸名。偶尔会把 “38C3” 读成 “3803”,但这在量化模型上完全可以接受。
最终输出
所有结果保存为两种格式:
-
JSON(结构化,方便程序消费):
[{"file": "frame_00m15s.jpg","timestamp": "00:15","seconds": 15,"description": "Three people on stage..."}] -
TXT(人类可读,方便检索):
[00:15] frame_00m15s.jpgThree people on stage at the 38C3 conference...[02:10] frame_02m10s.jpgA presentation slide with "Recap: the story so far"...
加上之前的 ASR 转录结果,一个 44 分钟的视频现在有了:
- 完整文字转录(带说话人标签和时间戳)
- 37 张关键帧截图
- 每张截图的自然语言描述
这些素材足够做很多下游任务:写摘要、生成字幕、做视频索引、或者像这篇文章一样——理解一个不想花 44 分钟看完的视频在讲什么。
流水线总结
yt-dlp (下载) ↓ffmpeg -vn (提取音频 WAV) ↓Tailnet ASR (paraformer-zh, 说话人分离) → transcript.json ↓ffmpeg select=gt(scene,0.15) (场景检测) ↓ffmpeg -ss (逐时间点截图) ↓Tailnet VLM (Qwen3.5-9B, vision) → frames_described.json全程没有在本地安装任何 ML 模型。计算全部发生在 Tailnet 内网的 Mac mini 上,笔记本只负责下载视频和发请求。处理 44 分钟视频的总耗时大约 5 分钟(ffmpeg 提取 + ASR 70 秒 + VLM 描述 37 帧约 3 分钟)。
一个 16GB 的 Mac mini,跑 ASR + VLM 两个服务,就把”视频理解”这件事从”得开个 Colab 或者买 API”变成了”curl 一下自己的内网”。自建基础设施的价值不在搭建那天,在每次用它的时候。