Agent看视频:基于ffmpeg与多模态模型的视频理解Skill实战
做Agent开发这段时间,我一直在处理一个问题:Agent能读文本、看图片,但遇到视频就直接抓瞎。群里讨论方案时看到有人提claude-video这个Skill,正好我自己也踩过视频处理的坑,就顺着思路做了一个让Agent具备看视频能力的Skill。今天把整个实现过程拆开讲讲,包括为什么需要这个东西、技术路线怎么选、实际开发中会遇到哪些坑、以及最后的完整代码。
先说清楚这个Skill解决什么问题。现在的Agent框架基本都支持文本和图片输入,但视频是个特殊形态——它既有图像序列又有音频轨道还有时间维度。比如你让Agent帮你总结一段产品发布会的录像、从教学视频里找某个操作细节、或者审核一段监控录像,传统做法是先把视频转成文字,再喂给Agent。但文字会丢失大量视觉信息,PPT上的图表、屏幕上的代码、人物的表情动作,这些关键信息全都没了。claude-video的思路是:把视频拆成“画面序列 + 音频转写 + 时间轴”三个维度,再让多模态模型去理解,最后输出带时间戳的结构化结果。
我做的版本定位是“通用视频理解Skill”,能跑在任何支持Anthropic API或兼容接口的项目里,不绑定特定框架。核心逻辑不复杂:
- 视频抽帧:用ffmpeg按指定fps抽帧,得到画面序列
- 音频转写:用whisper.cpp或whisper API把音轨转成文字加时间戳
- 并行理解:把画面帧和语音文字按时间轴对齐,分批交给多模态模型理解
- 结构输出:最后汇总成带时间戳的JSON或Markdown结构化结果
1. 项目定位:为什么Agent需要“看视频”的能力
1.1 视频信息处理的三大痛点
先聊第一个问题——Agent处理视频到底难在哪。视频和图片、纯文本最大的区别是“信息密度不均”和“时间维度”。一张图片的信息是静态的,一份文本的信息是线性的,但一段视频是画面、声音、时间、运动四者的叠加。比如一个产品演示视频,前30秒是背景介绍,中间5分钟是核心功能操作,最后1分钟是QA环节,Agent如果要回答“这个产品的XX功能怎么操作”,传统做法是把整个视频的语音转成文字再搜索关键词,但操作步骤往往依赖画面上的按钮位置、鼠标点击轨迹、屏幕变化,纯文字转写完全无法还原。
第二个痛点是信息损耗。视频里的视觉信息很难无损地转成文本。图表、流程图、代码高亮、UI界面这些内容,用ASR转写只能得到类似“接下来我们看一下这个界面”这样干瘪的描述,核心视觉内容全丢了。这也是为什么很多做Agent视频处理的团队最后都绕回了“多模态模型直接看画面”这条路——与其想办法把视频翻译成文本,不如让模型直接看视频帧。
第三个痛点是token成本控制。视频帧直接喂给多模态模型会瞬间爆炸。假设一段10分钟的视频以1fps抽帧,就是600张图,以Anthropic的计费方式,这600张图的token消耗轻松超过百万级别,且大部分画面是冗余的(比如静态PPT页面连续出现几十帧),纯暴力的“全量抽帧+全量理解”方案,费用上根本扛不住。claude-video里使用“场景相似度聚帧 + 语音时间轴对齐”来做过滤,效果和成本的平衡点在于这里。
1.2 为什么用Skill而不是独立服务
第二个问题是方案形态选型。我当时考虑过三种实现方式:
- 独立微服务:把视频处理封装成HTTP接口,Agent通过工具调用访问。这种方式解耦最干净,但部署运维成本高,而且多一次网络跳转就多一层延迟和失败概率。
- Agent框架插件:直接写死进某个框架(比如Claude Agent SDK的扩展)。这种方式集成度最高,但绑死了框架,换项目就要重写。
- Skill插件:按照Claude Agent的Skill规范,写一组带说明文档的脚本,由Agent在需要时动态发现并调用。
最终选了Skill方案。原因是Skill在Agent的工作流里是“按需激活”的——Agent先看到视频文件路径,判断需要视频理解能力,再自动加载对应的Skill和说明文档。整个过程对用户透明,不需要预启动服务,也不依赖特定的Agent框架实现。这个设计和“让Agent自己决定调用什么工具”的理念完全一致,等于把视频理解能力封装成了Agent的一只手,用的时候伸出来,不用的时候收回去。
Skill方案还有个额外好处:共享和复用极其方便。一个Skill就是一个目录,打包分发,别人拿到后往skills目录里一放就能用。这个模式很有用——现在社区里已经看到各种基于Skill的玩法,从数学建模到数据可视化到代码审查,Skill这个概念本身正在成为Agent能力扩展的事实标准。claude-video在这个体系里就是一个很小的切入点,但它的设计模式(输入文件路径→输出结构化理解)可以套用到音频、文档、表格等任何非文本形态。
1.3 整体技术路线设计
claude-video完整的处理流程我画成下面几条线:
视频文件
├── 音频轨 → whisper转写 → 文本+时间戳
├── 视频轨 → ffmpeg抽帧 → 场景聚帧 → 多模态理解 → 画面描述+时间戳
└── 元信息 → ffprobe读取时长/分辨率/编码 → 参数自适应
汇总 → 按时间轴对齐 → 生成结构化报告 → 返回给Agent
三条线在最后一步汇合:音频转写告诉你“这段时间讲了什么”,画面理解告诉你“这段时间屏幕上有什么”,文件元信息决定抽帧参数怎么定。三者的时间戳按秒级对齐,拼成一个按时间轴推进的视频理解报告,Agent拿到这份报告后就能回答各种具体问题,比如“第3分钟出现的关键指标是多少”“5分20秒处演示的功能叫什么”。
当然这个方案不是完美的。最大的局限是模型对“运动”的理解天生弱于对“静止画面”的理解——抽帧本质上丢弃了帧间运动信息,像“小球从左边滚到右边”“按钮渐入渐出”这类过程,靠单帧难以准确还原。但当前阶段,这套方案的性价比已经非常高,对于绝大多数业务场景(内容审核、教学总结、会议纪要、操作指引)来说,按时间轴的结构化输出已经能覆盖大部分需求。
2. 工具选型:视频能力背后的核心依赖
2.1 视频抽帧:为什么ffmpeg是唯一选择
视频抽帧这块,ffmpeg基本是压倒性优势,不是“之一”是“唯一”。先说原因:首先,ffmpeg对格式兼容性极强,mp4、mov、avi、mkv、flv、ts这些封装格式,h264、h265、vp9、av1这些编码格式,ffmpeg基本全覆盖,不用依赖系统装解码器。其次,ffmpeg的抽帧质量稳定,时间戳精准,商业级工具这么多年积累的可靠性,二进制的“秒级seek”和“帧级seek”逻辑极其成熟。最后,ffmpeg跨平台,Windows、macOS、Linux都有官方构建版,对于Skill这种要随时打包分发的场景很重要。
实际开发时我用的是ffmpeg的
-vf fps=1
这种参数化抽帧方式,而不是用
-ss
指定时间点抽帧。这种方式的好处是让ffmpeg自己管理时间轴,输出的帧文件名直接带时间戳,比如
frame_0000.jpg
对应第0秒,
frame_0003.jpg
对应第3秒。命令大概是:
ffmpeg -i input.mp4 -vf "fps=1,scale=1280:-1" -q:v 2 frames/frame_%04d.jpg
这里有个容易忽略的小坑:
scale=1280:-1
会把宽边缩放到1280像素并保持比例,这对控制token非常关键——同样的画面,2560px宽的图token消耗接近1280px的4倍,而多模态模型对画面细节的理解能力在1280px之后基本进入瓶颈期,再高分辨率不会带来理解能力的线性提升。所以我最终固定了“短边不低于720px”“长边不超过1920px”的分辨率策略,在信息保留和token消耗之间取平衡。
2.2 音频转写:whisper系模型的选择与分析
音频转写我试过两条路线:一条是调用云端API,一条是用本地whisper.cpp跑开源模型。两者各有各的适用场景,码清楚不同场景选不同路线。
云端API的好处是识别准确率高、不用管本地算力,短板是要联网、要传音频文件出去、有隐私风险。如果视频涉及客户数据或内部会议,我一般不建议走云API。本地whisper.cpp则是完全离线处理,模型文件自己保存,音频不出设备,隐私这块比较可控。但识别准确率受模型大小影响,
base
模型在中文场景下错字较多,
small
或
medium
算是实用底线。
我在claude-video里默认写的是:优先尝试本地whisper.cpp的
medium
模型,如果机器跑不动或者没有whisper,就退回云API模式。这个“本地优先、云端兜底”的思路大家做的时候可以参考。转写环节另一个要解决的问题是输出格式。whisper默认输出纯文本,但在claude-video里我需要的是“分段文本+起止时间”。所以我会加
--output_format srt
参数,拿到带时间戳的字幕格式,再解析成结构化JSON。
2.3 画面理解:多模态模型与Vision API的调用
画面理解这一层是claude-video的灵魂。抽帧得到的是jpg文件,要让Agent理解画面内容,必须调用多模态模型。我用的是Claude的Vision接口,因为Anthropic的vision能力在中文场景下(OCR、图表描述、UI理解)表现足够稳,且和Claude的Agent生态天然匹配。如果你在其他Agent框架中使用,也可以替换成GPT-4o、Gemini、Qwen-VL等同样支持图像输入的模型,核心逻辑不变。
调用时有个关键细节——必须把“时间戳”和“帧文件”绑定。我传给模型的prompt是这样的:
这是一段视频在{timestamp}秒处的一帧画面。请描述这张画面中的所有关键信息:
1. 画面整体内容(场景、人物、屏幕、物体)
2. 屏幕上的所有文字内容(逐字识别,不要遗漏)
3. 图表/数据/指标的数值和趋势
4. 操作步骤或流程(如果画面中有演示动作)
输出格式:简短扼要的分点描述,不要超过300字。
这里我特别要求“逐字识别屏幕文字”和“输出简短描述”,原因是后续Agent要根据这些描述回答具体问题,漏字和冗余都会影响效果。每帧图像的响应控制在300字以内,既保证信息量,也能控制token成本。
2.4 时间戳对齐:让Agent知道“看到的是哪一秒”
这是整个Skill最容易翻车的地方,也是决定最终输出质量的环节。时间戳对齐的核心目标:让画面的“第几帧”和语音的“第几秒”一一对应。如果对齐错了,回调结果就会出现“画面说操作A,语音说操作B”的尴尬情况,整个技能基本废掉。
我踩过的坑主要三个:
第一个是帧编号的秒数换算问题。ffmpeg输出
frame_0000.jpg
开头,但抽帧的起始点不一定正好是0秒——有些视频有start_time偏移。所以我额外用
ffprobe
读取视频的实际起始时间,然后动态计算每帧对应的时间戳,而不是假设帧号等于秒数。
第二个是音频和视频的时间基准可能不同。有些视频的音轨有200ms~500ms的延迟或提前,在长视频里感知不明显,但到了“第5分钟到底说了哪句话”这种精确查询时就会出问题。解决办法是做一次“音频-视频偏移校准”:从视频里抽出一段包含声音和画面的片段,通过对比语音内容和画面中的字幕/PPT文字,人工或自动校正偏移量。
第三个问题是whisper返回时间戳的精度。whisper的时间戳精度是毫秒级,但“一段话”的切分点通常在句号、停顿处,不一定和画面的关键变化点对齐。我做了个简单的插值处理:把whisper的segments按秒切分,每秒钟的文字内容用该秒内所有segment的文字拼接,这样画面帧和语音文本就都落在了统一的“秒级时间轴”上。
时间戳对齐做好之后,最终输出的结构大概是这样:
{
"video_metadata": {"duration": 600, "resolution": "1920x1080", "fps": 30},
"timeline": [
{"start": 0, "end": 3, "visual": "...", "audio": "..."},
{"start": 4, "end": 8, "visual": "...", "audio": "..."}
],
"summary": "视频整体内容摘要"
}
3. Skill实战:从零到一搭建claude-video
3.1 Skill目录结构与配置
下面进入实操环节。我用的Agent框架是Claude Agent SDK,Skill目录结构遵循官方规范。如果你用的是其他框架(比如Codex、自研Agent),只要改成对应目录和配置格式即可,核心逻辑一样。
先看目录结构:
skills/
└── claude-video/
├── SKILL.md # Skill说明文档,Agent读取的核心入口
├── script/
│ ├── main.py # 主入口脚本,编排整个流程
│ ├── extract_frames.py # 抽帧模块
│ ├── transcribe.py # 音频转写模块
│ ├── understand.py # 多模态理解模块
│ └── utils.py # 工具函数
├── requirements.txt # Python依赖
└── config.json # 可调参数配置
SKILL.md
是Agent最先看到的部分,它告诉Agent这个Skill能做什么、怎么调用、入参出参是什么。这部分写得越清楚,Agent调用成功率越高。我的SKILL.md核心内容:
# claude-video: 视频内容理解与分析
## 功能
输入一个视频文件路径,输出结构化视频理解结果(画面描述、语音转写、时间轴、摘要)。
## 使用方法
1. 确认视频文件存在,检查文件路径
2. 运行: python skills/claude-video/script/main.py --video <video_path> [--output <result_path>]
3. 读取输出JSON文件,基于内容回答用户问题
## 输入参数
- video: 视频文件路径(必填)
- output: 输出路径(选填,默认video同目录下同名.json)
- fps: 抽帧频率,默认0.5(每2秒一帧)
- max_frames: 最大抽帧数,默认200,超出自动降采样
- use_local_whisper: 是否使用本地whisper,默认false
## 注意事项
- 视频文件较大时处理时间较长,需要分步执行
- 如果抽帧失败,检查ffmpeg是否正确安装
- 时间戳单位统一为秒,精确到小数点后1位
这里有一个重要的设计思路:
SKILL.md
里我不让Agent“背下”所有处理逻辑,只让它知道“要做什么、怎么调用、注意什么”。真正的处理逻辑全部下沉到脚本里,Agent只需执行命令和读取结果。这样把“Agent的思考”和“程序的执行”分离开——Agent不需要理解ffmpeg怎么抽帧,它只需要知道视频理解能力由哪个命令提供。
config.json
则是给开发者准备的参数控制面板:
{
"ffmpeg_path": "ffmpeg",
"ffprobe_path": "ffprobe",
"whisper_mode": "auto",
"whisper_local_model": "medium",
"vision_model": "claude-sonnet-4-20250514",
"frame_width": 1280,
"scene_threshold": 0.3,
"max_frames": 200,
"target_language": "zh"
}
scene_threshold
是场景聚帧的相似度阈值,含义是“当连续两帧的画面相似度超过30%时,认为属于同一场景,可以丢弃后一帧”。这个值需要根据视频类型调——教学视频画面变化少,可以设到0.2以下,多丢帧省token;运动比赛视频画面变化剧烈,需要设到0.5以上,避免丢关键帧。
3.2 核心脚本实现
3.2.1 主流程编排(main.py)
主脚本的逻辑就是按时间线串联整个处理流程,我给出微服务时代的Web服务写法思路——把每步封装成可独立调用的函数,主流程只做编排和状态管理。
import os
import json
import argparse
import subprocess
import sys
from pathlib import Path
from extract_frames import extract_frames, cluster_scenes
from transcribe import transcribe_audio, extract_audio
from understand import understand_frames, merge_understandings
from utils import human_readable_time, load_config
def process_video(video_path: str, output_path: str, config: dict) -> dict:
"""执行完整视频理解流程,返回结构化结果"""
if not os.path.exists(video_path):
raise FileNotFoundError(f"视频文件不存在: {video_path}")
# 1. 读取视频元信息(时长、分辨率、编码)
metadata = get_video_metadata(video_path, config["ffprobe_path"])
print(f"[1/5] 视频信息: 时长={metadata['duration']}秒, "
f"分辨率={metadata['width']}x{metadata['height']}, "
f"fps={metadata['avg_frame_rate']}")
# 2. 计算抽帧参数(动态自适应)
duration = metadata["duration"]
max_frames = config["max_frames"]
fps = config.get("fps", 0)
if fps <= 0:
# 自适应fps:总帧数控制在max_frames附近
fps = max(0.2, min(2.0, max_frames / duration))
print(f"[2/5] 抽帧频率: {fps}fps,预计总帧数: {int(duration*fps)}")
# 3. 抽帧 + 场景聚帧(丢弃相似帧)
frames_dir = os.path.join(os.path.dirname(video_path), "_frames_tmp")
extracted_frames = extract_frames(
video_path, frames_dir, fps=fps,
width=config["frame_width"],
scene_threshold=config["scene_threshold"]
)
print(f"[3/5] 原始抽帧: {extracted_frames['total']}张, "
f"场景聚帧后: {extracted_frames['clustered']}张")
# 4. 音频转写(whisper)
audio_path = os.path.join(os.path.dirname(video_path), "_audio_tmp.wav")
text_segments = transcribe_audio(
video_path, audio_path, config=config,
duration=duration
)
print(f"[4/5] 语音转写完成: 共{len(text_segments)}段")
# 5. 画面理解(多模态调用)- 分批并行处理
frame_paths = sorted(Path(frames_dir).glob("*.jpg"))
visual_segments = understand_frames(frame_paths, config=config)
print(f"[5/5] 画面理解完成: 共{len(visual_segments)}段描述")
# 6. 按时间轴对齐,merge画面+文字
timeline = merge_understandings(visual_segments, text_segments)
# 7. 汇总摘要
summary = summarize_timeline(timeline, config)
result = {
"video_metadata": metadata,
"timeline": timeline,
"summary": summary,
"generator": "claude-video-skill",
"version": "1.0.0"
}
# 清理临时文件
# shutil.rmtree(frames_dir, ignore_errors=True)
# os.remove(audio_path) if os.path.exists(audio_path) else None
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return result
主流程的几个设计点:每步都打印进度信息,方便Agent观察执行到哪个阶段;临时文件最后清理,避免磁盘占用;异常处理留到外层统一捕获。
3.2.2 抽帧与场景聚帧模块(extract_frames.py)
抽帧的核心是ffmpeg命令调用,而场景聚帧则是一个图像相似度判定的函数。
import subprocess
import os
import cv2
import numpy as np
def extract_frames(video_path: str, out_dir: str, fps: float=0.5,
width: int=1280, scene_threshold: float=0.3) -> dict:
"""
使用ffmpeg抽帧 + 基于感知哈希的场景聚帧
返回值: {"total": 原始帧数, "clustered": 聚帧后帧数, "frame_paths": [路径列表]}
"""
os.makedirs(out_dir, exist_ok=True)
# 清理旧文件
for f in os.listdir(out_dir):
os.remove(os.path.join(out_dir, f))
# 先将所有帧抽出来存入临时目录
tmp_dir = out_dir + "_raw"
os.makedirs(tmp_dir, exist_ok=True)
cmd = [
"ffmpeg", "-y", "-i", video_path,
"-vf", f"fps={fps},scale={width}:-1",
"-q:v", "2",
os.path.join(tmp_dir, "frame_%06d.jpg")
]
ret = subprocess.run(cmd, capture_output=True, text=True)
if ret.returncode != 0:
raise RuntimeError(f"ffmpeg抽帧失败: {ret.stderr}")
# 读取所有帧,用感知哈希进行相似度聚类
frame_files = sorted(os.listdir(tmp_dir))
kept = []
prev_hash = None
for idx, fname in enumerate(frame_files):
fpath = os.path.join(tmp_dir, fname)
img = cv2.imread(fpath, cv2.IMREAD_GRAYSCALE)
if img is None:
continue
# 缩小到8x8计算感知哈希(pHash)
small = cv2.resize(img, (8, 8), interpolation=cv2.INTER_AREA)
# 用DCT低频分量做哈希,对轻微画面变化鲁棒
img_float = np.float32(small) / 255.0
dct = cv2.dct(img_float)
# 取左上角8x8低频块(忽略DC分量)
dct_low = dct[:8, :8]
# 计算均值哈希
avg = np.mean(dct_low)
hash_bits = (dct_low > avg).flatten()
if prev_hash is None or np.mean(hash_bits != prev_hash) > scene_threshold:
# 当前帧与前一帧差异显著,保留
kept.append(fpath)
prev_hash = hash_bits
# 相似度高的帧直接丢弃
# 将保留的帧复制回out_dir,文件名带时间戳
frame_paths = []
for i, fpath in enumerate(kept):
timestamp = i / fps
new_name = f"frame_{timestamp:.1f}s.jpg"
dest_path = os.path.join(out_dir, new_name)
os.rename(fpath, dest_path)
frame_paths.append(dest_path)
# 清理原始帧临时目录
# shutil.rmtree(tmp_dir, ignore_errors=True)
return {
"total": len(frame_files),
"clustered": len(kept),
"frame_paths": frame_paths,
"fps": fps
}
这段代码里我觉得最值得说的是
scene_threshold
这个参数的敏感性。
dct
低频哈希的优势是对“整体亮度变化”和“微小位移”不敏感——所以PPT翻页前后,即使有鼠标移动造成的画面微变,相似度依然很高,可以正确聚帧;但如果视频是运动镜头(比如跟拍跑步),连续帧的画面差异会非常大,阈值需要调高到0.5以上,否则关键帧被全部丢弃。一开始我固定阈值0.3,结果处理一个篮球比赛视频时聚帧聚出了大片空白,后来改成根据视频类型从config读取。
另一个细节是文件名设计。我特意把时间戳写进文件名
frame_{timestamp:.1f}s.jpg
,这样后续画面理解模块不需要额外解析帧号和秒数的关系,直接看文件名就能知道这是哪一秒的画面。
3.2.3 视频参数计算与动态自适应
先说说抽帧频率的确定逻辑。很多人默认觉得抽帧频率越高越好,但这既浪费token又不会带来理解能力的明显提升。我的思路是基于
max_frames
倒推fps——先定一个“我可以接受的最高画面理解数量”,然后根据视频时长算出fps,最后再对fps做上下限约束(0.2到2.0之间)。
具体计算过程:假设配置里
max_frames=200
,视频时长600秒,那么基础
fps=200/600≈0.333
。如果视频只有60秒,按同样计算会得到
fps=3.33
,但实际单帧画面包含的信息量并不需要每0.3秒就看一帧,所以我会把上限卡在2.0fps(即每0.5秒一帧)。反过来,如果视频特别长(3600秒),计算得到的
fps=0.055
,低于下限0.2,那我就把fps拉到0.2,但强制做“均匀采样”——每5秒抽一帧,避免在某些时间段堆积过多帧。
这个自适应逻辑在
main.py
里体现为:
fps = max(0.2, min(2.0, max_frames / duration))
如果Agent在调用时显式指定了
--fps
,则以Agent指定的为准,自适应逻辑被覆盖。这样保证“默认稳定、按需可调”的灵活性。
另一个需要计算的关键参数是分辨率。ffmpeg的
scale=1280:-1
会根据原始宽高比自动计算高度。这里有个坑:如果原视频是1920x1080,
scale=1280:-1
得到1280x720,完全没问题;但如果原视频是1080x1920(竖屏),
scale=1280:-1
会得到1280x2275,长边超过1920,token消耗反而变大。所以实际我用的缩放策略是双条件限制:
scale_filter = f"scale='if(gt(iw,ih),min(1280,iw),-2)':'if(gt(iw,ih),-2,min(1280,ih))'"
意思是:如果是横屏视频,宽度缩到最大1280;如果是竖屏视频,高度缩到最大1280。这样可以保证所有画面短边不超过1280,长边自动等比缩放,token控制更精准。
3.3 多模态批量并行处理细节
抽帧完成之后,最耗时的是对每帧画面调用多模态模型。一个10分钟的视频按0.5fps抽帧是300张图,逐张串行调用API可能要十几分钟,太慢了。我改成用线程池做并发,同时控制并发数避免触发API限流。
from concurrent.futures import ThreadPoolExecutor, as_completed
def understand_frames(frame_paths, config, max_workers=5):
results = []
def process_one(fpath):
import re
match = re.search(r"frame_([\d.]+)s\.jpg", os.path.basename(fpath))
timestamp = float(match.group(1)) if match else 0.0
# 读取图片为base64
import base64
with open(fpath, "rb") as f:
img_data = base64.b64encode(f.read()).decode("utf-8")
prompt = (
f"这是一段视频第{timestamp}秒的画面。"
f"请描述画面内容,输出格式如下:\n"
f"1. 画面类型:PPT/代码/UI/实拍/其他\n"
f"2. 画面中的全部文字(逐字识别,包括标题、正文、按钮文字)\n"
f"3. 关键信息(数字、指标、代码、图表内容)\n"
f"4. 其他值得注意的视觉信息\n"
f"请用简洁的分点描述,总字数控制在200字以内。"
)
response = call_vision_api(img_data, prompt, config)
return {"timestamp": timestamp, "description": response, "image_path": fpath}
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [executor.submit(process_one, fpath) for fpath in frame_paths]
for future in as_completed(futures):
try:
results.append(future.result())
except Exception as e:
print(f"警告: 单帧处理失败 {e}")
results.sort(key=lambda x: x["timestamp"])
return results
这里有个很容易踩的坑:如果视频是纯静态画面(比如录制一个静止的网页),聚帧后可能只剩1~2张帧,画面理解模块的输出稀疏到无法支撑时间轴。我加了一个兜底逻辑:如果聚帧后帧数小于5,就强制把fps提高到1.0重新抽帧,保证理解结果有足够的时间分辨率。
并发数的选择也要说明:OpenAI和Anthropic的API都有速率限制(RPM和TPM),一般个人账号TPM限制比较紧。如果画面是截图类(文字多、token消耗大),并发5个可能就顶满TPM了;如果画面是自然场景(描述简单、token消耗小),并发10个也没问题。我默认设5,算是一个比较稳的中间值。
3.4 场景聚帧的相似度哈希算法:有必要详细讲讲
为什么抽帧后还要做“场景聚帧”?直接按固定fps抽帧然后全量理解不就行了吗?
前面提过token成本问题,这里展开说。假设一个10分钟的教学录屏视频,老师盯着PPT讲了20多页,每页停留约30秒。按0.5fps抽帧得到300张图,其中同一个PPT页面连续出现了15张几乎相同的帧。如果把全部300张都交给多模态模型理解,那么同一个页面内容会被重复消费15次,相当于100%的成本浪费。
场景聚帧就是用来解决这个问题的。核心思路是“感知哈希”:把每张图缩到8x8灰度,做离散余弦变换(DCT),取左上角的低频系数作为“画面指纹”。两张图的指纹差异越大,说明画面变化越剧烈;差异小于阈值,就认为属于同一场景,只保留第一张。这个算法非常轻量,处理一张图只需几毫秒,300张图的聚帧总耗时不到2秒。
实现上我用了OpenCV的DCT函数,完整代码在
extract_frames.py
里。有几个实现细节:
- 为什么用DCT低频分量而不是直接比较像素?因为像素级比较对亮度变化和微小位移太敏感——同样是PPT页面,鼠标移过就可能导致大量像素变化,用哈希可以忽略这些次要差异。
- 为什么要忽略DC分量?DC分量代表整张图的平均亮度,照片偏亮偏暗都会改变它,但它不代表内容变化。忽略后,亮度变化不会误判为新场景。
- 为什么阈值设0.3?这个值的意思是“低频DCT系数中超过30%的位发生翻转,才认为场景切换”。对于PPT页面之间的切换(标题和正文全部变化),哈希翻转率通常在70%以上;对于PPT页面内的鼠标微动,翻转率在5%以下。0.3的阈值在这两类情况之间有足够宽的间隔,不容易误判。
实测下来,场景聚帧可以把“教学视频”的帧数减少60%~80%,把“监控视频”的帧数减少70%~90%,而关键信息的丢失率几乎为零。这个环节省下的token费用,在整个Skill的运营成本里占比相当可观。
3.5 音频转写的歧义处理与分段策略
whisper转写音频,默认会对整个音轨做分段(segment),每段有起止时间。但whisper的分段逻辑是“按静音和语义停顿切”,对于场景变化(比如PPT翻页、实验操作时暂时没人说话)它无法感知。这导致一个问题:画面已经切换到下一页PPT了,语音还在讲上一页的内容;或者画面在演示操作流程,但whisper因为没有语音而直接跳过这段时间。
我在
transcribe.py
里加了一层“音频分段锚点”逻辑:把whisper返回的segments在时间轴上做二次切分,确保每段不超过15秒,且每段的起点落在最近的画面帧时间戳上。这样最后merge的时候,每个视觉段和对应的语音段能精确对齐。
还有一个细节是whisper对中文的识别优化。whisper的
medium
模型在中文上表现还行,但对专业术语(比如“Transformer”“API”“BERT”)容易出错。我维护了一个领域词表,转写完成后做一次“纠错替换”。词表支持用户自己扩展,config里加了一个
term_map
字段:
"term_map": {
"transformer": "Transformer",
"api": "API",
"gpt": "GPT",
"bert": "BERT",
"多模态": "多模态"
}
这个是常规操作,但对最终理解质量帮助很大——因为Agent在看转录文本时,如果专业术语全被识别成同音错字,很难准确理解语义。
4. 踩坑记录与排查技巧
4.1 ffprobe读不到视频信息怎么办
Video处理第一件要做的事是读元信息,但有些视频文件(尤其是录制软件产出的mp4)在编码时没有写入正确的duration元数据,
ffprobe
返回的duration是0或者
N/A
。遇到这种情况,ffprobe的默认行为是直接报错退出,整个流程卡死。
排查思路其实不复杂:用
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1
只能拿到表面信息。更可靠的方案是用
ffmpeg -i
实时读取,虽然
ffmpeg -i
会输出大量stderr日志,但其中包含
Duration: 00:10:00.00
这样的字段,总能拿到真实时长——即使元数据损坏,ffmpeg也会通过解码计算得出大概的duration。
我在
get_video_metadata
里加了兜底逻辑:如果ffprobe拿不到duration,就退回到ffmpeg解析stderr。实测对99%的损坏视频都能救回来。
4.2 抽帧画面全黑或花屏的排查
这个问题在处理DRM保护的视频(比如部分在线课程的录制文件)时特别容易出现——画面全黑,但音轨正常。原因基本都不是ffmpeg的问题,而是视频本身有加密或拼接异常。排查步骤我整理成一句话:先看单帧截图能不能正常显示,再看是不是所有帧都黑,最后确认是不是hls分段下载导致的未对齐。
更常见的情况是:视频画面正常,但抽帧出来的jpg是花屏。这种大概率是视频流的像素格式或色彩空间不是标准YUV,ffmpeg在转换时没处理好。解决方法是强制加一个像素格式转换参数:
ffmpeg -i input.mp4 -vf "fps=1,scale=1280:-1,format=yuv420p" -q:v 2 frame_%04d.jpg
format=yuv420p
强制输出标准YUV420格式,绝大多数播放器和图像库都能正确解析。加上这个参数后,花屏问题直接消失。
4.3 时间戳错位:画面和声音差了好几秒
这是整个Skill里最隐蔽的坑。某些视频在录制时,屏幕捕获和音频设备的启动时间不同步,导致最终封装出来的mp4里,画面起始时间和音轨起始时间不一致。比如摄像头先录了2秒,然后麦克风才开始工作,那么ffprobe显示的时长比实际内容时长多2秒,音轨整体偏移2秒。
如果视频是面向Agent做精确查询,这种偏移会让“第5分钟讲的PPT内容”在画面上对应到“第4分58秒”。排查方法是在开发时人工抽查几个时间点——把某个时间戳的画面和语音拉出来对比,确认是否对齐。如果发现整体偏移,就在config里增加一个
timeline_offset
参数(单位:秒),在处理时统一加上或减去这个偏移。
"timeline_offset": 0.0
设置
timeline_offset=2.0
表示“实际内容从第2秒开始”。merge函数在处理时会统一减去偏移,把时间轴归零,这样最终输出和真实场景对齐。
4.4 token预算失控的处理策略
视频理解最大的开销在画面理解这一步。我遇到过处理一个30分钟的4K视频,画面理解token直接飙到800万,费用高到离谱。后来在想对策时,发现核心问题是抽帧策略没有考虑视频的内容复杂度——有些视频画面信息量极大(如密集的代码编辑器、数据图表),每一帧消耗的token远高于平均;有些视频画面信息量很小(如单人的演讲固定镜头),一帧可能只有几十个token。
我的处理策略是双层降采样:
-
第一层是全局的
max_frames限制(默认200),确保帧总数可控。 - 第二层是“按内容复杂度调整描述长度”——在prompt里让模型根据画面信息的多少,自动决定输出长度,“画面信息少时只输出一行概要,信息丰富时再展开描述”。这样每帧的平均token数能压到500以下,200帧的总消耗约10万token,费用可接受。
如果处理的是超长视频(超过1小时),我会再加上一层“分段战略”:把视频切成6个10分钟的片段,分别用claude-video生成各片段的独立理解报告,最后再做一次汇总合并。这种“分段-并行-汇总”的模式,即使面对数小时的视频,也能在十几分钟内完成。
4.5 并发导致API限流
多线程并发调用多模态模型时,很容易触发API的429限流。我的处理办法是做一个“指数退避+重试”的封装,在调用函数里捕获429错误后,等待时间从1秒开始倍增(1s、2s、4s、8s),最多重试5次。同时通过
threading.BoundedSemaphore
限制并发数不超过5。
实测下来,只要重试逻辑健壮,并发达10都不会触发硬限流;超过10的话,429出现概率明显上升,重试会拖慢总体速度,反而不如并发5~8来得高效。
5. 实战应用:跑一个视频会议纪要的完整示例
理论讲了不少,实际操作感受更直观。我在本地搭了一套完整流程,用一段20分钟的团队会议录屏(包含PPT分享、代码演示、成员讨论)跑了完整链路,下面贴出关键过程和输出节选。
处理命令:
python skills/claude-video/script/main.py \
--video demo/meeting_recording.mp4 \
--output demo/meeting_result.json \
--fps 0.5
处理日志:
[1/5] 视频信息: 时长=1200秒, 分辨率=1920x1080, fps=30
[2/5] 抽帧频率: 0.5fps,预计总帧数: 600
[3/5] 原始抽帧: 600张, 场景聚帧后: 163张(节省率73%)
[4/5] 语音转写完成: 共57段,总字数4800字
[5/5] 画面理解完成: 共163段描述,总token消耗约75,000
输出JSON节选:
{
"video_metadata": {
"duration": 1200,
"width": 1920,
"height": 1080,
"avg_frame_rate": "30/1"
},
"timeline": [
{
"start": 0.0,
"end": 8.0,
"visual": "会议背景页面,标题《Q3产品路线图评审》,底部有日期和参会者列表",
"audio": "主持人开场:大家好,今天的会议主要是评审Q3的产品路线图..."
},
{
"start": 9.5,
"end": 45.0,
"visual": "PPT第2页,展示产品路线图时间线,Q3有三个里程碑:8月数据中台上线、9月移动端改版、10月商业化闭环",
"audio": "先看整体时间线。8月的核心交付是数据中台..."
}
],
"summary": "本次会议为Q3产品路线图评审,重点讨论了数据中台上线、移动端改版、商业化闭环三个里程碑...最终结论是8月上数据中台,9月移动端,商业化调整到Q4。"
}
这个会议纪要的生成效果比纯语音转写好很多。传统ASR纪要只能得到“大家说了什么”,但无法还原“PPT上写的是什么”。而claude-video的timeline把“视觉信息”和“语音信息”合并在一起,对于“当时屏幕上演示的这段代码是哪个函数”“第8分钟展示的图表里的关键数据是多少”这类问题,Agent能直接回应。
我还特意测了一个场景:让它回答“这次会议里提到的数据中台上线时间是哪个月”。因为语音转写里可能只说了“8月”,但时间线里PPT页也显示了“8月数据中台上线”,两个信息源互相印证,回答的置信度明显更高。这就是多模态融合的价值所在——不是单纯把视频翻译成文本,而是让视觉和语言互为补充。
6. 经验补充:Agent开发中的Skill设计思路
最后补充一点关于Skill设计本身的思考。开发Agent时我总结了一个很管用的判断标准: 能把逻辑写死在程序里的,就不要让Agent“思考” ——Agent的强项是语义理解、任务拆解和工具调度,弱项是精确的数值计算和执行稳定性。
所以在设计claude-video的SKILL.md时,我会非常克制地定义“Agent该做什么”和“程序该做什么”的边界:
- Agent负责:判断用户是否需要视频理解能力、决定视频文件路径、调用Skill命令、解读输出JSON并回答用户。
- 程序负责:ffmpeg抽帧、whisper转写、多模态调用、时间戳对齐、聚帧降采样、token控制。
边界一旦清晰,Agent的行为就高度可预测。这也是为什么社区里那些看起来“特别聪明”的Agent项目,拆开来看底层逻辑往往非常规整——Agent框架本身再灵活,底层工具的执行质量才是决定上限的关键。
如果你打算基于这个项目做二次开发,我建议优先在“适配更多视频场景”上动刀:
- 支持直播流输入(rtmp/rtsp),这个场景在安防、在线教育行业很常见。
- 加上目标检测能力(比如在人流监控视频中识别特定行为),这需要引入视觉模型来补充。
- 支持输出格式扩展(比如直接生成PPT摘要、会议纪要模板),这样可以对接下游工作流。
最后再分享一个小经验:测试视频理解Skill时,千万别只拿“拍得规整的录屏”来测,一定要拿一位朋友随手拍的抖动视频、一段带字幕的影视片段、一个30分钟的超长网课录播来测。这些无序、模糊、长尾的真实场景,才是Skill真正要面对的主战场——也是踩出最多坑的地方。
更多推荐
所有评论(0)