video-capture:入仓(加入 .gitignore 白名单)—— 取视频 + 抽帧的全局技能
- 用户 2026-10-12 令「可以加入」。
- 依据:product-planning 第③段的交接说明已把它当**依赖**指向 ⇒ 仓里有引用、仓里就该有实体,
⛔ 否则从本仓还原到别的机器会拿到一个「指向装不出来的技能」的悬空引用。
- 收录 5 个:SKILL.md、scripts/extract_frames.py、scripts/fetch_video.py、
references/视频抽帧与字幕规范.md、ATTRIBUTION.md。
- 目录内 env/ 与 logs/ 仍按本机配置规则挡在仓外(各机器自建)。
This commit is contained in:
1 parent
2666a611b1
commit
051482168f
6 files changed
+567
No files matched your search
@@ -15,6 +15,11 @@
|
||||
!/oil-ui-pro/
|
||||
!/product-planning/
|
||||
!/session-mechanism/
|
||||
# 🔴 2026-10-12 加(用户当轮令「可以加入」):新建的全局技能 `video-capture`(取视频 + 抽帧)要入库。
|
||||
# 判据=product-planning 第③段已把它当**依赖**引用(交接说明指向它)⇒ 仓里有引用、仓里就该有实体;
|
||||
# ⛔ 否则别的机器从本仓还原会拿到一个「指向装不出来的技能」的悬空引用。
|
||||
# ⚠️ 它目录内的 `env/`、`logs/` 仍被下面那几条本机配置规则挡着 —— 那部分照旧各机器自建。
|
||||
!/video-capture/
|
||||
|
||||
# 🔴 不入库:运行期产物与备份
|
||||
# 判据(记忆铁律):技能包=**可复制的资产**;⛔ 运行日志/缓存/备份/本机部署副本一律不入库。
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# 归属与来源(ATTRIBUTION)
|
||||
|
||||
本技能的抽帧算法与铁律来自用户自有技能,非第三方项目。
|
||||
|
||||
## 来源
|
||||
|
||||
- 上游技能:`aigc-idea-impression`
|
||||
- 上游文件:`E:/ProgramData/AIProject/aigc-idea-impression/references/操作规范/视频抽帧与字幕规范.md`
|
||||
(本技能 `references/视频抽帧与字幕规范.md` 是它的搬运件;2026-08-30 基于 70.3s 视频实测沉淀)
|
||||
- 算法:「双因素(画面 scene + 字幕区 crop 后 scene)+ 首尾帧 + 长间隔补帧」;
|
||||
逐点 `-ss` 抽帧;CRLF 行尾防护。
|
||||
|
||||
## 本技能的改动
|
||||
|
||||
- 抽帧与取视频用 Python 标准库 + ffmpeg/ffprobe 写成独立脚本
|
||||
(`scripts/extract_frames.py`、`scripts/fetch_video.py`),不依赖上游技能的其它上下文。
|
||||
- **判据与阈值原样保留**:0.3 / 0.1 / 0.05 三档、>5s 补中点、尾帧 `时长-0.3`、逐点 `-ss`、保持原分辨率。
|
||||
- 「取视频」保留最小合规集(本地文件 / 直链 URL);平台页视频按 SKILL.md §二 走浏览器工具下到本地。
|
||||
|
||||
## 沿革
|
||||
|
||||
- 2026-10-11:由 `product-planning` 包内的 `stage-delivery/assets/video-capture/` 独立出来,
|
||||
装到全局技能仓 `E:/ProgramData/.workbuddy/skills/video-capture/`,原位置改为交接说明。
|
||||
(独立原因:该能力跨项目复用;埋在 `product-planning` 的资产目录里时,非产品规划类任务命不中它。)
|
||||
|
||||
## 版权
|
||||
|
||||
算法与文字版权归原技能作者所有。如需字幕 OCR / 转录,按《视频抽帧与字幕规范》§五 做逐帧读取。
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: video-capture
|
||||
description: >
|
||||
视频解析独立工具:把一个视频落到本地,并按规范抽成「有信息量的关键帧 + 时间线」。
|
||||
两件事:① 取视频(本地文件 / 直链 URL / 平台页经浏览器下到本地);
|
||||
② 按规范抽帧 —— 双因素 + 首尾 + 补帧(画面 scene 0.3 + 字幕区 crop 后 0.1 +
|
||||
低阈值 0.05 补漏 + 首尾帧 + 间隔 >5s 补中点;逐点 -ss;保持原分辨率),
|
||||
产 c_<四位序号>_<真实秒数>s.jpg + frame_times.json。
|
||||
触发场景:分析视频、把这个视频抽帧、拆一下这条视频的画面、提取关键帧、视频解析、
|
||||
取视频、下载视频、视频拆解、字幕提取、画面反推、视频取画面。
|
||||
version: "1.0"
|
||||
updated_at: "2026-10-11"
|
||||
deployment: "global"
|
||||
agent_created: true
|
||||
---
|
||||
|
||||
# video-capture · 取视频 + 抽帧(独立技能)
|
||||
|
||||
把「一个视频」变成「一批有信息量的关键帧 + 时间线」,用于视频理解、分镜拆解、画面反推提示词、
|
||||
字幕转录、竞品 demo 拆解、视觉参考。
|
||||
|
||||
## 一、什么时候用
|
||||
|
||||
- 用户说「分析 XXX 视频」「把这个视频抽帧」「拆一下这条视频的画面」「提取关键帧」「视频解析」。
|
||||
- 竞品 demo 要拆画面、参考视频要取画面、教程视频要做图文梳理。
|
||||
|
||||
## 二、两步用法
|
||||
|
||||
### 第一步 · 取视频
|
||||
|
||||
1、本地文件(直接登记 + 校验):
|
||||
|
||||
```
|
||||
python scripts/fetch_video.py --file D:/a.mp4 --out ./video
|
||||
```
|
||||
|
||||
2、直链媒体 URL(`.mp4/.mov/.webm/.m4v/.mkv`):
|
||||
|
||||
```
|
||||
python scripts/fetch_video.py --url https://host/x.mp4 --out ./video
|
||||
```
|
||||
|
||||
3、平台页视频(B站/抖音/YouTube 等):**先用浏览器工具把视频下到本地**,再按第 1 条传 `--file`。
|
||||
本技能自带的是「落地」这一步;平台页的解析走浏览器打开站点 → 解析 → 下载,
|
||||
登录态与会员态**由用户自己在浏览器里完成**(会员登录属用户凭据,不代输)。
|
||||
|
||||
- 红线:只处理**用户有权分析**的素材;遇到登录墙就停下来问用户;
|
||||
⛔ 不绕过登录/验证码/风控;⛔ 不调用任何付费接口。
|
||||
- 平台解析接口常有速率限制(连发会 429/503):按**约 16 秒间隔**分批,失败重试 ≤3 次。
|
||||
|
||||
### 第二步 · 抽帧(双因素 + 首尾 + 补帧)
|
||||
|
||||
```
|
||||
python scripts/extract_frames.py --video ./video/a.mp4 --out ./frames
|
||||
```
|
||||
|
||||
- 产出 `frames/c_<四位序号>_<真实秒数>s.jpg` + `frames/frame_times.json`
|
||||
(记时长 / 分辨率 / 字幕裁剪区 / 三档阈值 / 帧表)。
|
||||
- 可选参数:`--crop "720:240:0:720"` 指定字幕区;`--max-gap 5` 长间隔补帧阈值;
|
||||
`--thresholds 0.3,0.1,0.05` 三档阈值。
|
||||
- 字幕区**默认取底部 15%**;横屏 1080p 可显式 `--crop "1920:162:0:918"`,
|
||||
竖屏 720×960 用 `--crop "720:240:0:720"`。**先抽一帧确认字幕实际位置再定 crop。**
|
||||
- 逐点 `-ss` 抽帧慢(700+ 帧约 7 分钟)⇒ 放**后台任务**里跑。
|
||||
- **ffmpeg 版本兼容**:脚本已自带新旧写法兼容(优先 `-fps_mode vfr`,跑不出结果再退回 `-vsync vfr`)
|
||||
⇒ ffmpeg 5.x / 9.x 都能直接跑。本机是 **ffmpeg 9.0.1**,可直接调脚本;
|
||||
(依据 2026-10-11 实测:旧写法只认 `-vsync`,而 9.0.1 已移除它 ⇒ 三路 scene 检测全失败、整支视频只出 3 帧,
|
||||
当时执行会话只能另存一份「逐字同算法的本地副本」才跑通 ⇒ 两套口径。已修。)
|
||||
|
||||
🔴🔴 **抽帧只走 `scripts/extract_frames.py`**:这个脚本就是下面那套铁律的实现,也是唯一入口。
|
||||
|
||||
- 写自己的 ffmpeg 抽帧命令会把规范丢掉 —— 尤其 `-vf "fps=1/N"` 这类**固定节奏抽样**:
|
||||
规范 §六 用三版实测否决过它(`fps=1` 浪费 56% 帧,且**画面静止但字幕换句的帧全丢**)。
|
||||
- 保持**原分辨率**:`scale=640:-1` 这类缩图会让帧上的字幕读不清,后续 OCR / 视觉读取全废。
|
||||
- 依据(2026-10-11 实测):一次「取视频 + 抽帧 + 梳理」的任务没加载本技能,
|
||||
自己写了 `ffmpeg -vf "fps=1/N,scale=640:-1"` 的脚本 ⇒ 22 个视频的关键帧按规范**全部返工**。
|
||||
|
||||
## 三、算法铁律
|
||||
|
||||
> 细则与实测案例以 [references/视频抽帧与字幕规范.md](references/视频抽帧与字幕规范.md) 为准(本技能的权威源)。
|
||||
|
||||
1、**画面变化**:`select='gt(scene,0.3)'` —— 抓镜头切换 / 画面突变。
|
||||
2、**字幕变化**:字幕区 `crop` 后 `select='gt(scene,0.1)'` —— 抓「画面静止但字幕换句」。
|
||||
3、**低阈值补漏**:`select='gt(scene,0.05)'` —— 补 0.05~0.3 之间的中等缓变盲区(**标准步骤,非可选补救**)。
|
||||
4、**首尾帧**:首帧 `0s` + 尾帧 `时长-0.3s`(`-ss 时长` 会超界)。
|
||||
5、**长间隔强制补帧**:排序合并去重后,任一段间隔 `>5s` 就在该段**中点补 1 帧**(物理上无盲区)。
|
||||
6、**逐点 `-ss` 抽帧**:`select='eq(t,xx)'` 精确时间匹配浮点误差大、命中率极低,用它。
|
||||
|
||||
**核心原则**:只抽「有信息量的帧」—— 画面变化点 + 字幕变化点 + 首尾帧 + 长间隔补帧;
|
||||
帧数随内容变化,⛔ 不按固定节奏凑数。
|
||||
|
||||
## 四、产物与命名
|
||||
|
||||
🔴 **落点约定:一个账号 / 一个系列 = 一个专属文件夹,三类产物同放其中**(2026-10-11 用户令)。
|
||||
命名格式 **`<平台名>_<账号ID>_<内容方向>`** —— 三段都要有:平台用来分组(同平台的账号排在一起)、
|
||||
账号 ID 用来认账号与复查、内容方向用来认这一批采的是什么。
|
||||
|
||||
例:`B站_44479620_AI生成界面与设计`、`抖音_1234567_品牌设计`
|
||||
|
||||
```
|
||||
<平台名>_<账号ID>_<内容方向>/
|
||||
├── 视频/ ← 本体(BV 号.mp4)
|
||||
├── 关键帧/<BV号>/ ← c_<序号>_<真实秒数>s.jpg + frame_times.json
|
||||
├── 梳理/ ← 逐视频的教程与方法 / 提炼稿
|
||||
└── 清单与索引-<日期>.md ← 全量投稿清单 + 入选索引
|
||||
```
|
||||
|
||||
- 三类产物(**视频本体 / 关键帧 / 提炼内容**)一律进这同一个文件夹,⛔ 不散到工作区各处。
|
||||
- **平台名**写站点通称(`B站`、`抖音`、`小红书`、`视频号`、`YouTube`、`TikTok`);**账号 ID** 用平台上的原始 ID(B 站=UID)。
|
||||
- **内容方向**写成一句能认主题的话(例 `AI生成界面与设计`、`短视频拍摄与剪辑`),⛔ 不写「合集」「系列」这种认不出的词。
|
||||
- 同一个账号的**另一个内容方向** ⇒ **另建一个**文件夹(名字里第三段不同),并列摆放。
|
||||
- 抓**另一个账号** ⇒ 同样另建(第二段换 ID;同平台时第一段不变)。
|
||||
- 过程件(脚本、原始 JSON)放该文件夹下的 `tmp-scrape/`(或等价子目录),保留原始数据。
|
||||
- 该文件夹落在哪,由调用方的项目约定决定(本项目= `执行会话/目标-<简称>-<短哈希>/` 之下)。
|
||||
|
||||
- 帧图:`<out>/c_<四位序号>_<真实秒数>s.jpg`(例 `c_0001_0s.jpg`、`c_0002_3.534s.jpg`)。
|
||||
时间戳保留 scene 检测输出的原始浮点精度,便于按帧对应视频时刻。
|
||||
- 帧表:`<out>/frame_times.json` —— `frames[]` 逐条含 `index / time / file`。
|
||||
|
||||
## 五、时间区间划分(每帧代表的时间段)
|
||||
|
||||
- **相邻帧中点划分(Voronoi)**:帧 i 代表 `[中点(i-1,i), 中点(i,i+1)]`。
|
||||
- 首帧从 0 起、尾帧到视频结束;用于分镜时间轴、字幕时间线、画面与台词对应。
|
||||
|
||||
## 六、字幕与转录
|
||||
|
||||
- 平台视频的字幕常是**烧录在画面上的**(非独立字幕轨)⇒ 从帧画面读(OCR/视觉读取)。
|
||||
- 字幕区 scene 检测已保证**每个换句时刻都有一帧**(含画面静止时)。
|
||||
- 转录产出 `字幕稿_日期.md`,格式=时间线表(时间 | 台词 | 帧文件)。
|
||||
- 若平台的字幕接口可用(返回非空),优先直接取轨;接口返回空属信源限制,如实注明后走画面读取。
|
||||
|
||||
## 七、边界
|
||||
|
||||
- ⛔ 不抓平台页内视频(本技能只做「落地 + 抽帧」);平台页请先用浏览器工具下到本地。
|
||||
- ⛔ 不调付费接口。
|
||||
- 只对用户有权分析的素材使用;版权归原作者。
|
||||
- `ffmpeg` / `ffprobe` 需在本机 PATH 上(脚本除此之外只用 Python 标准库)。
|
||||
|
||||
## 八、归属
|
||||
|
||||
算法与铁律来自 `aigc-idea-impression` 技能的《视频抽帧与字幕规范》(2026-08-30 实测沉淀),
|
||||
本技能是它的独立打包。来源与改动记录见 [ATTRIBUTION.md](ATTRIBUTION.md)。
|
||||
@@ -0,0 +1,136 @@
|
||||
# 视频抽帧与字幕规范(SOP)
|
||||
|
||||
> 抖音等平台视频下载后的抽帧与字幕提取标准流程。基于 2026-08-30 实测(《当队伍里有两只贪吃的猪》70.3s)沉淀。
|
||||
> 应用场景:视频理解、画面反推提示词、字幕转录、分镜分析。
|
||||
|
||||
---
|
||||
|
||||
## 一、抽帧策略(双因素 + 首尾 + 补帧,⭐ 权威方案)
|
||||
|
||||
**核心原则**:只抽"有信息量的帧"——画面变化点 + 字幕变化点 + 首尾帧 + 长间隔补帧,避免固定节奏(fps=1)浪费静止帧。
|
||||
|
||||
| 因素 | 命令 | 阈值 | 捕捉内容 |
|
||||
|------|------|:---:|---------|
|
||||
| ① 画面变化 | `select='gt(scene,0.3)'` | 0.3 | 镜头切换/画面突变 |
|
||||
| ② 字幕变化 | `crop=W:底1/4:0:Y` + `select='gt(scene,0.1)'` | 0.1 | 字幕换句(含画面静止时) |
|
||||
| ③ 低阈值补漏 | `select='gt(scene,0.05)'` | 0.05 | 中等缓变(0.05~0.3 盲区,如 53.93s 案例) |
|
||||
| ④ 首帧/尾帧 | `-ss 0` / `-ss DUR-0.3` | — | 开场/结尾画面(scene 不触发) |
|
||||
| ⑤ **长间隔强制补帧** | 帧间隔 >5s → 该段中点强制抽 1 帧 | — | 物理兜底,杜绝长盲区(如 8.4s 空隙) |
|
||||
|
||||
🔴🔴 **因素② 的适用边界(2026-10-11 实测订正 —— 它管不到所有素材)**:`scene` 量的是**像素变化比例**,
|
||||
**不是「字有没有换」**。所以「字幕换句」这一格只在特定画幅下成立:
|
||||
|
||||
- ✅ **成立**:**竖屏 720×960、字幕占底部 1/4、字号大、满宽字幕条**(抖音那类)——
|
||||
换句时该区域的像素变化幅度足够触发 0.1。
|
||||
- ⛔ **不成立**:**横屏 / 录屏 / 小字号窄字幕条**。实测 `BV1N6ph6dEec`(1280×720 录屏):
|
||||
`subtitle_crop = crop=1280:144:0:576`,而 **B 路(字幕路)命中 = 0**、画面主路(0.3)也只有 2;
|
||||
最终 219 帧全靠 C 路(0.05,78 帧)+ 长间隔补帧(139 帧)撑起来 ⇒ **帧间距中位数 3.12s**,
|
||||
而一句话常 2–4s ⇒ **必然漏句**(样板轮实测:该支 229 句里有 **83 句(36.2%)没有对应帧**)。
|
||||
- ⛔ **所以「字幕区 crop + scene 0.1」不能当成「逐句抽帧」的手段**,它只是「顺带捞到一部分换句」。
|
||||
- ✅ **要真正逐句,走这两条**(按代价从小到大):
|
||||
1. **固定 1fps 密采样**(同一支实测:1fps 读到 229 句、覆盖 ≈95%+)—— 代价是帧数大涨;
|
||||
2. 对字幕条做**帧间差分 / 逐帧 OCR 判定**(按「字变了没有」判,而不是 scene)。
|
||||
- ✅ **动手前的判据**:先抽 3–5 帧确认这支**到底有没有烧录字幕**(很多录屏类视频画面里根本没有台词字幕),
|
||||
再定投入方式:
|
||||
- **有烧录字幕** ⇒ 按上面两条拿逐句(1fps 密采样 / 帧间差分或 OCR 判定)。
|
||||
- **没有烧录字幕** ⇒ 走**逐帧 OCR 提画面文字** —— 录屏类的内容往往就在画面文字里
|
||||
(UI 文案、设计稿文字、代码、表格),此时「逐句台词」这个目标不成立,但「画面文字稿」成立且有用。
|
||||
- 🔴🔴 **只用 OCR**(用户 2026-10-11 令,逐字:「**就用OCR方案字幕和图片上的文字应该都能获取到 禁止使用ASR**」):
|
||||
字幕文字与画面里的文字一律走 **OCR / 视觉读取**;⛔ **禁止使用 ASR**
|
||||
(不装语音识别依赖、不下载语音模型、不跑语音转写)。
|
||||
|
||||
**执行流程**:
|
||||
1. 画面 scene 检测(0.3)→ 时间点 A
|
||||
2. 字幕区 scene 检测(0.1)→ 时间点 B
|
||||
3. **低阈值 scene 检测(0.05)→ 时间点 C**(补中等缓变盲区,**不是可选补救,是标准步骤**)
|
||||
4. 合并去重 A∪B∪C → 基础帧集
|
||||
5. **长间隔强制补帧**:排序后检查帧间隔,任一 >5s 则在该段中点补 1 帧(物理上无盲区)
|
||||
6. 必加首帧(0s)+ 尾帧(`DUR-0.3` 或 `-ss 69.9`)
|
||||
7. 逐时间点 `-ss` 抽帧(精确时间匹配 `eq(t,xx)` 命中率低,勿用)
|
||||
|
||||
**字幕区裁剪**(竖屏 720×960):字幕通常在底部 1/4 → `crop=720:240:0:720`。横屏或字幕位置不同时按实际调整。
|
||||
|
||||
## 二、抽帧命令模板
|
||||
|
||||
> ⚠️ **ffmpeg 版本**:下面用 `-fps_mode vfr`(ffmpeg ≥ 5.1 的写法,**9.x 已移除 `-vsync`**);
|
||||
> 老版本 ffmpeg 请把 `-fps_mode vfr` 换成 `-vsync vfr`。技能脚本 `scripts/extract_frames.py` 已自带这两种写法的自动兼容。
|
||||
|
||||
```bash
|
||||
FF="/path/ffmpeg.exe"; VIDEO="video.mp4"; mkdir -p frames_combined
|
||||
|
||||
# ① 画面变化点
|
||||
ffmpeg -i "$VIDEO" -vf "select='gt(scene,0.3)',showinfo" -fps_mode vfr -f null - 2>&1 \
|
||||
| grep -oE "pts_time:[0-9.]+" | cut -d: -f2 | sort -n -u > scene_03.txt
|
||||
|
||||
# ② 字幕变化点(竖屏底部 1/4 区域)
|
||||
ffmpeg -i "$VIDEO" -vf "crop=720:240:0:720,select='gt(scene,0.1)',showinfo" -fps_mode vfr -f null - 2>&1 \
|
||||
| grep -oE "pts_time:[0-9.]+" | cut -d: -f2 | sort -n -u > subtitle_01.txt
|
||||
|
||||
# ③ 低阈值复查(scene=0.05,找中等缓变盲区)
|
||||
ffmpeg -i "$VIDEO" -vf "select='gt(scene,0.05)',showinfo" -fps_mode vfr -f null - 2>&1 \
|
||||
| grep -oE "pts_time:[0-9.]+" | cut -d: -f2 | sort -n -u > scene_005.txt
|
||||
|
||||
# ④ 合并 + 长间隔强制补帧(间隔 >5s 补中点,物理杜绝盲区)
|
||||
sort -n -u scene_03.txt subtitle_01.txt scene_005.txt > times.txt
|
||||
echo "0" >> times.txt # 首帧
|
||||
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$VIDEO")
|
||||
# 长间隔补帧:遍历排序后时间点,间隔>5s 插入中点
|
||||
prev=""
|
||||
for t in $(sort -n -u times.txt); do
|
||||
if [ -n "$prev" ]; then
|
||||
gap=$(echo "$t - $prev" | bc)
|
||||
if [ "$(echo "$gap > 5" | bc)" = "1" ]; then
|
||||
mid=$(echo "($t + $prev) / 2" | bc)
|
||||
echo "$mid" >> times.txt
|
||||
fi
|
||||
fi
|
||||
prev="$t"
|
||||
done
|
||||
sort -n -u times.txt > times_final.txt
|
||||
|
||||
# ⑤ 逐时间点抽帧(尾帧用 DUR-0.3 防超界)
|
||||
i=0
|
||||
for t in $(cat times_final.txt); do
|
||||
[ "$(echo "$t > $DUR" | bc)" = "1" ] && t=$(echo "$DUR - 0.3" | bc)
|
||||
i=$((i+1))
|
||||
ffmpeg -y -ss "$t" -i "$VIDEO" -frames:v 1 -q:v 2 "frames_combined/c_$(printf %02d $i)_${t}s.jpg" 2>/dev/null
|
||||
done
|
||||
```
|
||||
|
||||
## 三、帧命名规则
|
||||
|
||||
- `frames_combined/c_XX_时间s.jpg`(如 `c_01_2.233333s.jpg`)
|
||||
- 时间戳保留原始精度(scene 检测输出浮点),便于按帧对应视频时刻
|
||||
|
||||
## 四、时间区间划分(每帧代表的时间段)
|
||||
|
||||
- **相邻帧中点划分(Voronoi)**:帧 i 代表 `[中点(i-1,i), 中点(i,i+1)]`
|
||||
- 首帧从 0 起、尾帧到视频结束(DUR)
|
||||
- 用于:分镜时间轴、字幕时间线、画面-台词对应
|
||||
|
||||
## 五、字幕提取
|
||||
|
||||
- 抖音字幕是**烧录在画面上的**(非独立字幕轨)→ 从帧画面转录(OCR / 视觉读取)
|
||||
- 🔴 **字幕区 scene 检测的覆盖范围(2026-10-11 实测订正)**:它**在「竖屏、底部 1/4、大字幕条」的素材上**
|
||||
能捞到大部分换句时刻(含画面静止时);**在横屏 / 录屏 / 小字素材上它不保证逐句**
|
||||
(实测漏 36.2%,详见 §一 的边界说明)⇒ 要逐句请按 §一 走 **1fps 密采样** 或 **帧间差分 / 逐帧 OCR 判定**。
|
||||
- 转录产出:`字幕稿_日期.md`,格式 = 时间线表(时间 | 台词 | 帧文件)
|
||||
- 长间隔段(如 8~9s 无变化):该段字幕可能持续同句,读帧确认即可
|
||||
|
||||
## 六、三版抽帧对比(为什么不用固定节奏)
|
||||
|
||||
| 方案 | 帧数(70s) | 感知画面 | 感知字幕 | 结论 |
|
||||
|------|:---:|:---:|:---:|------|
|
||||
| I 帧 | 12 | ❌ | ❌ | 编码关键帧,漏字幕 |
|
||||
| fps=1 | 70 | ❌ | ✅ | 固定节奏,浪费 56% |
|
||||
| **双因素+首尾+补帧** | **31** | ✅ | ✅ | 精准全覆盖,省帧 |
|
||||
|
||||
## 七、常见坑
|
||||
|
||||
- `select='eq(t,2.233333)'` 精确时间匹配命中率极低(浮点误差)→ 用 `-ss` 逐点抽
|
||||
- 尾帧 `-ss DUR` 会超出视频末尾 → 用 `DUR-0.3` 或 `-ss 69.9`(70s 视频)
|
||||
- scene=0.05 会产生大量抖动点(相邻 <0.05s)→ 与基础帧集比对,近似点(<0.05s 差)不算遗漏
|
||||
- 字幕区域随平台/画幅变化(抖音竖屏底部 1/4;B 站横屏底部 10-15%)→ 先抽一帧确认字幕位置再 crop
|
||||
- **字幕区 crop 参考值**:抖音竖屏 720×960 → `crop=720:240:0:720`;B 站横屏 1080p → `crop=1920:162:0:918`(底部 15%)
|
||||
- **CRLF 行尾坑**:时间戳列表若由 Python/Windows 生成(`\n` 被转成 `\r\n`),bash `while read` 的变量带 `\r` → ffmpeg `-ss "0.0\r"` 报 `Invalid duration for option ss` 全批失败。修复:先 `tr -d '\r' < times.txt > times_unix.txt` 再用,或 Python 写文件用 `newline='\n'`
|
||||
- **逐点 -ss 抽帧耗时**:700+ 帧约 7 分钟(每帧一次 ffmpeg 启动),务必后台执行(run_in_background);帧序号用 4 位 `c_%04d`
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
video-capture · 抽帧(双因素 + 首尾 + 补帧)
|
||||
|
||||
铁律来源:aigc-idea-impression 的《视频抽帧与字幕规范》(2026-08-30 实测沉淀)。
|
||||
算法:① 画面 scene 0.3 ② 字幕区 crop 后 scene 0.1(漏这条 = 画面静止但字幕换句的帧全丢)
|
||||
③ 低阈值 scene 0.05 ④ 首帧/尾帧 ⑤ 相邻帧间隔 >5s 补中点;逐点 -ss 抽帧(禁 eq(t))。
|
||||
|
||||
依赖:ffmpeg / ffprobe(本机已就绪)。仅 Python 标准库。
|
||||
用法:
|
||||
python extract_frames.py --video a.mp4 --out frames/ [--max-gap 5] [--crop "720:240:0:720"] [--thresholds 0.3,0.1,0.05]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run(cmd):
|
||||
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def ffprobe_meta(video):
|
||||
r = run([
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-show_entries", "stream=codec_type,width,height",
|
||||
"-of", "json", video,
|
||||
])
|
||||
info = {"duration": 0.0, "width": 0, "height": 0}
|
||||
try:
|
||||
j = json.loads(r.stdout or "{}")
|
||||
info["duration"] = float((j.get("format") or {}).get("duration") or 0.0)
|
||||
for s in (j.get("streams") or []):
|
||||
if s.get("codec_type") == "video":
|
||||
info["width"] = int(s.get("width") or 0)
|
||||
info["height"] = int(s.get("height") or 0)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
FPS_MODE_ARGS = ("-fps_mode", "vfr") # ffmpeg ≥ 5.1 的新写法(9.x 已移除 -vsync)
|
||||
LEGACY_FPS_ARGS = ("-vsync", "vfr") # 老 ffmpeg 的写法,兜底用
|
||||
|
||||
|
||||
def _scene_cmd(video, vf, fps_args):
|
||||
return ["ffmpeg", "-hide_banner", "-i", video, "-vf", vf, *fps_args, "-f", "null", "-"]
|
||||
|
||||
|
||||
def scene_times(video, vf, label):
|
||||
"""跑一次 scene 检测,返回时间点列表。
|
||||
|
||||
🔴 2026-10-11 修(实测踩到):本机 ffmpeg 9.0.1 **已移除 `-vsync`**
|
||||
⇒ 原来的 `-vsync vfr` 写法让三路 scene 检测**全部失败**、整支视频只出 3 帧
|
||||
(当时执行会话只能另存一份「逐字同算法的本地副本」才跑通 ⇒ 两套口径,正是要避免的)。
|
||||
⇒ 现在优先用 `-fps_mode vfr`,没跑出 `pts_time` 再退回 `-vsync vfr`,新旧 ffmpeg 都能跑。
|
||||
"""
|
||||
r = run(_scene_cmd(video, vf, FPS_MODE_ARGS))
|
||||
out = (r.stderr or "") + (r.stdout or "")
|
||||
if "pts_time:" not in out: # 新写法没跑出来 ⇒ 退回老写法
|
||||
r2 = run(_scene_cmd(video, vf, LEGACY_FPS_ARGS))
|
||||
out2 = (r2.stderr or "") + (r2.stdout or "")
|
||||
if "pts_time:" in out2:
|
||||
out = out2
|
||||
times = [float(t) for t in re.findall(r"pts_time:([0-9.]+)", out)]
|
||||
times = sorted(set(t for t in times if t >= 0))
|
||||
return times
|
||||
|
||||
|
||||
def default_subtitle_crop(w, h):
|
||||
"""字幕区默认取底部 15%。"""
|
||||
if w <= 0 or h <= 0:
|
||||
return None
|
||||
ch = max(1, int(h * 0.15))
|
||||
y = h - ch
|
||||
return f"crop={w}:{ch}:0:{y}"
|
||||
|
||||
|
||||
def extract(video, out_dir, max_gap, crop, thresholds):
|
||||
meta = ffprobe_meta(video)
|
||||
dur = meta["duration"]
|
||||
if dur <= 0:
|
||||
print(json.dumps({"ok": False, "error": "无法读取视频时长(文件损坏或非视频)"}), file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
t_pic, t_sub, t_low = thresholds
|
||||
times = set()
|
||||
|
||||
# ① 画面变化
|
||||
times.update(scene_times(video, f"select='gt(scene,{t_pic})',showinfo", "pic"))
|
||||
# ② 字幕区变化(crop 后检测)—— 漏这条会丢"画面静止但字幕换句"
|
||||
sub_crop = crop or default_subtitle_crop(meta["width"], meta["height"])
|
||||
if sub_crop:
|
||||
times.update(scene_times(video, f"{sub_crop},select='gt(scene,{t_sub})',showinfo", "sub"))
|
||||
# ③ 低阈值补漏
|
||||
times.update(scene_times(video, f"select='gt(scene,{t_low})',showinfo", "low"))
|
||||
# ④ 首尾帧
|
||||
times.add(0.0)
|
||||
times.add(max(0.0, dur - 0.3))
|
||||
|
||||
ordered = sorted(times)
|
||||
# ⑤ 长间隔补中点
|
||||
with_gap = []
|
||||
prev = None
|
||||
for t in ordered:
|
||||
if prev is not None and (t - prev) > max_gap:
|
||||
with_gap.append((t + prev) / 2.0)
|
||||
with_gap.append(t)
|
||||
prev = t
|
||||
ordered = sorted(set(round(t, 3) for t in (ordered + with_gap)))
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
frames = []
|
||||
for i, t in enumerate(ordered, 1):
|
||||
t = min(t, max(0.0, dur - 0.05))
|
||||
name = f"c_{i:04d}_{t}s.jpg"
|
||||
path = os.path.join(out_dir, name)
|
||||
run([
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-ss", f"{t}", "-i", video, "-frames:v", "1", "-q:v", "2", path,
|
||||
])
|
||||
frames.append({"index": i, "time": t, "file": name})
|
||||
|
||||
manifest = {
|
||||
"video": os.path.abspath(video),
|
||||
"duration": round(dur, 3),
|
||||
"resolution": f'{meta["width"]}x{meta["height"]}',
|
||||
"subtitleCrop": sub_crop,
|
||||
"thresholds": {"picture": t_pic, "subtitle": t_sub, "low": t_low},
|
||||
"maxGap": max_gap,
|
||||
"frameCount": len(frames),
|
||||
"frames": frames,
|
||||
}
|
||||
with open(os.path.join(out_dir, "frame_times.json"), "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
||||
return manifest
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--video", required=True)
|
||||
ap.add_argument("--out", default="frames")
|
||||
ap.add_argument("--max-gap", type=float, default=5.0)
|
||||
ap.add_argument("--crop", default="", help='字幕区裁剪,如 "720:240:0:720";缺省按分辨率底部 15%')
|
||||
ap.add_argument("--thresholds", default="0.3,0.1,0.05", help="画面,字幕,低阈值")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.exists(args.video):
|
||||
print(json.dumps({"ok": False, "error": f"找不到视频:{args.video}"}), file=sys.stderr)
|
||||
sys.exit(2)
|
||||
try:
|
||||
t = [float(x) for x in args.thresholds.split(",")]
|
||||
assert len(t) == 3
|
||||
except Exception:
|
||||
print("thresholds 需为 3 个数值,如 0.3,0.1,0.05", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
m = extract(args.video, args.out, args.max_gap, args.crop.strip(), t)
|
||||
print(json.dumps({
|
||||
"ok": True, "out": os.path.abspath(args.out),
|
||||
"frameCount": m["frameCount"], "duration": m["duration"],
|
||||
"resolution": m["resolution"], "subtitleCrop": m["subtitleCrop"],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
video-capture · 取视频(最小、合规集)
|
||||
|
||||
只做两件事,用于把「一个视频」落到本地待抽帧:
|
||||
1) 本地文件:直接登记(--file)
|
||||
2) 直链媒体 URL:下载(--url,需 .mp4/.mov/.webm/.m4v 等直链)
|
||||
⛔ 不做:平台页内抓取(抖音/B站/YouTube 等页面解析)、⛔ 不调任何付费接口。
|
||||
—— 如需平台视频,请先用浏览器工具把视频下到本地,再用 --file 传入(本工具不代抓)。
|
||||
|
||||
依赖:ffprobe(校验)+ Python 标准库。可选 curl(更快)。
|
||||
用法:
|
||||
python fetch_video.py --file D:/a.mp4 --out ./video
|
||||
python fetch_video.py --url https://host/x.mp4 --out ./video
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0 Safari/537.36"
|
||||
|
||||
|
||||
def probe(path):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-show_entries", "stream=codec_type,width,height", "-of", "json", path],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
j = json.loads(r.stdout or "{}")
|
||||
dur = float((j.get("format") or {}).get("duration") or 0)
|
||||
w = h = 0
|
||||
for s in (j.get("streams") or []):
|
||||
if s.get("codec_type") == "video":
|
||||
w, h = int(s.get("width") or 0), int(s.get("height") or 0)
|
||||
break
|
||||
return {"duration": round(dur, 3), "resolution": f"{w}x{h}"}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def download(url, dest):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA, "Referer": url})
|
||||
with urllib.request.urlopen(req, timeout=90) as r, open(dest, "wb") as f:
|
||||
shutil.copyfileobj(r, f)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
g = ap.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--file", help="本地视频路径")
|
||||
g.add_argument("--url", help="直链媒体 URL(.mp4/.mov/.webm/.m4v)")
|
||||
ap.add_argument("--out", default="video", help="输出目录")
|
||||
args = ap.parse_args()
|
||||
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
|
||||
if args.file:
|
||||
if not os.path.exists(args.file):
|
||||
print(json.dumps({"ok": False, "error": f"找不到文件:{args.file}"}), file=sys.stderr)
|
||||
sys.exit(2)
|
||||
dest = os.path.join(args.out, os.path.basename(args.file))
|
||||
if os.path.abspath(dest) != os.path.abspath(args.file):
|
||||
shutil.copy2(args.file, dest)
|
||||
else:
|
||||
if not os.path.splitext(args.url.split("?")[0])[1].lower() in (".mp4", ".mov", ".webm", ".m4v", ".mkv"):
|
||||
print(json.dumps({"ok": False, "error": "仅支持直链媒体 URL(.mp4/.mov/.webm/.m4v/.mkv);平台页内抓取不在本工具范围"}), file=sys.stderr)
|
||||
sys.exit(2)
|
||||
dest = os.path.join(args.out, "video" + os.path.splitext(args.url.split("?")[0])[1].lower())
|
||||
try:
|
||||
download(args.url, dest)
|
||||
except Exception as e:
|
||||
print(json.dumps({"ok": False, "error": f"下载失败:{e}"}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
meta = probe(dest)
|
||||
print(json.dumps({"ok": True, "video": os.path.abspath(dest), **meta}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user