1867 字
9 分钟
用自建 Tailnet 服务搭一条视频处理流水线

事情起因很简单:我想把一个 YouTube 视频下载下来,转录成文字,顺便把关键画面截出来加描述。

如果是去年的我,大概率会在本地装 Whisper 跑转录,再想办法搞个 VLM 做图像描述。但今年我家里有台 Mac mini 24 小时挂着跑 ASR 和 VLM 服务,全部通过 Tailscale 组网暴露给我的设备。那为什么不直接用?

这篇记录完整流水线的搭建过程。目标视频是 38C3 上 Dragon Sector 团队关于 Newag 列车 DRM 锁的后续报告——去年他们曝光了波兰火车制造商 Newag 在列车控制软件里埋了 GPS 围栏、日期炸弹、序列号校验等恶意逻辑,今年的演讲是后续:议会听证、刑事调查、以及他们被 Newag 起诉两次的经历。

NOTE

Mac mini 上的 VLM 和 ASR 服务是怎么搭的?参考前一篇《16GB Mac mini 上从 LLM 到多模态 VLM service》

第一步:下载视频#

工具是 yt-dlp,视频不需要高清(只是转录和截图用),480p 足够:

Terminal window
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 的格式选择语法支持方括号里写过滤条件,heightfpsvcodec 都可以用。如果想更精细地控制,可以用 -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 里。

先查一下服务是否在线:

Terminal window
curl -s https://service.<tailnet>.ts.net/services | python3 -m json.tool

ASR 服务在 http://mac-mini.<tailnet>.ts.net:8099/transcribe,状态 healthy。

视频是 mp4 格式,ASR 服务接受音频文件,所以先用 ffmpeg 提取音频转成 WAV:

Terminal window
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 服务:

Terminal window
curl -X POST "http://mac-mini.<tailnet>.ts.net:8099/transcribe" \
-F "file=@audio.wav" \
-o result.json

70 秒处理完 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 表示相邻帧之间的差异越大。先跑一遍检测:

Terminal window
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:

Terminal window
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非常敏感几乎每个画面变化都抓

然后在每个检测到的时间点截一帧:

Terminal window
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" -y
done < timestamps.txt

-ss 放在 -i 前面是 seek 模式,速度快(不需要解码整个视频到该位置)。-q:v 2 是 JPEG 质量(1-31,越小越好)。

最终得到 37 张截图,文件名直接带时间戳:frame_00m10s.jpgframe_02m10s.jpgframe_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.jpg
    Three people on stage at the 38C3 conference...
    [02:10] frame_02m10s.jpg
    A 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 一下自己的内网”。自建基础设施的价值不在搭建那天,在每次用它的时候。

用自建 Tailnet 服务搭一条视频处理流水线
https://blog.lishuyu.top/posts/自建视频处理流水线/
作者
猫猫魔女
发布于
2026-04-23
许可协议
CC BY-NC-SA 4.0