diff --git a/.gitignore b/.gitignore index 14a32e0..03c0be2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ !/oil-ui-pro/ !/product-planning/ !/session-mechanism/ +# 🔴 2026-10-12 加(用户当轮令「可以加入」):新建的全局技能 `video-capture`(取视频 + 抽帧)要入库。 +# 判据=product-planning 第③段已把它当**依赖**引用(交接说明指向它)⇒ 仓里有引用、仓里就该有实体; +# ⛔ 否则别的机器从本仓还原会拿到一个「指向装不出来的技能」的悬空引用。 +# ⚠️ 它目录内的 `env/`、`logs/` 仍被下面那几条本机配置规则挡着 —— 那部分照旧各机器自建。 +!/video-capture/ # 🔴 不入库:运行期产物与备份 # 判据(记忆铁律):技能包=**可复制的资产**;⛔ 运行日志/缓存/备份/本机部署副本一律不入库。 diff --git a/video-capture/ATTRIBUTION.md b/video-capture/ATTRIBUTION.md new file mode 100644 index 0000000..ae6deed --- /dev/null +++ b/video-capture/ATTRIBUTION.md @@ -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 / 转录,按《视频抽帧与字幕规范》§五 做逐帧读取。 diff --git a/video-capture/SKILL.md b/video-capture/SKILL.md new file mode 100644 index 0000000..062b35f --- /dev/null +++ b/video-capture/SKILL.md @@ -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) +├── 关键帧// ← c_<序号>_<真实秒数>s.jpg + frame_times.json +├── 梳理/ ← 逐视频的教程与方法 / 提炼稿 +└── 清单与索引-<日期>.md ← 全量投稿清单 + 入选索引 +``` + +- 三类产物(**视频本体 / 关键帧 / 提炼内容**)一律进这同一个文件夹,⛔ 不散到工作区各处。 +- **平台名**写站点通称(`B站`、`抖音`、`小红书`、`视频号`、`YouTube`、`TikTok`);**账号 ID** 用平台上的原始 ID(B 站=UID)。 +- **内容方向**写成一句能认主题的话(例 `AI生成界面与设计`、`短视频拍摄与剪辑`),⛔ 不写「合集」「系列」这种认不出的词。 +- 同一个账号的**另一个内容方向** ⇒ **另建一个**文件夹(名字里第三段不同),并列摆放。 +- 抓**另一个账号** ⇒ 同样另建(第二段换 ID;同平台时第一段不变)。 +- 过程件(脚本、原始 JSON)放该文件夹下的 `tmp-scrape/`(或等价子目录),保留原始数据。 +- 该文件夹落在哪,由调用方的项目约定决定(本项目= `执行会话/目标-<简称>-<短哈希>/` 之下)。 + +- 帧图:`/c_<四位序号>_<真实秒数>s.jpg`(例 `c_0001_0s.jpg`、`c_0002_3.534s.jpg`)。 + 时间戳保留 scene 检测输出的原始浮点精度,便于按帧对应视频时刻。 +- 帧表:`/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)。 diff --git a/video-capture/references/视频抽帧与字幕规范.md b/video-capture/references/视频抽帧与字幕规范.md new file mode 100644 index 0000000..959c051 --- /dev/null +++ b/video-capture/references/视频抽帧与字幕规范.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` \ No newline at end of file diff --git a/video-capture/scripts/extract_frames.py b/video-capture/scripts/extract_frames.py new file mode 100644 index 0000000..3e3166d --- /dev/null +++ b/video-capture/scripts/extract_frames.py @@ -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() diff --git a/video-capture/scripts/fetch_video.py b/video-capture/scripts/fetch_video.py new file mode 100644 index 0000000..37f3809 --- /dev/null +++ b/video-capture/scripts/fetch_video.py @@ -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()