Skill: chentx1243/maple-video-article (version 1.0.5)

--- name: video-to-article description: 从视频生成图文并排的文章（md格式）。支持本地视频文件或在线视频URL（自动下载），自动完成文本提取、视频帧截取、时间轴匹配、文章撰写全流程。 metadata: { "openclaw": { "emoji": "📝", "requires": { "bins": ["python3"] } } } --- # video-to-article 技能 ## 描述 根据视频文件，自动生成图文并排的公众号文章，CSDN博客，总结报告等文本内容。 前置skill校验：使用该skill前，需要校验前置skill是否已安装/maple-video2txt（视频转文本）与/maple-video-capture（视频关键帧提取） ## 使用场景（触发场景） - 需要根据视频内容写公众号文章/推文 - 需要将视频内容整理成图文并茂的文档 - 需要为视频创作配套的文字稿 - 需要总结某个视频 - 任何需要对视频进行理解的工作或任务 ## 完整工作流 ### 第一步：明确用户需求（该步骤不可跳过！） 明确告知用户，将会尝试使用 video-to-article技能来处理视频，并追问用户确保获取如下信息（如果用户已经说明了的信息，则不需要再次提问），追问时，每次只允许抛出1-2个问题，不允许要一次性询问多个信息（这会影响用户的体验），追问过程中用户一但体现出“立即生成”或者“不耐烦”的意图时，你可以不再往下追问完整信息，而是使用默认参数完成任务： 1. 是否希望在文章中配图，具体需要几张图片？ 1. 1. 可以为用户提供几个模板选项 1. 1. 1. 纯文稿：不截取视频配图 2. 简约文稿：只在关键章节配1-2张图 3. 图文并茂： 每个章节至少配图2张 1. 用户希望要什么风格的文章？文章要求是什么？是否有参考文章可以参考？ 1. 1. 下面罗列几种可能的情况供你参考 1. 1. 1. 用户发送一个连接或地址：你需要仅使用内置的 web 读取工具访问该地址获取网页文本内容，阅读文章并参考其风格和语言方式。禁止使用浏览器访问或执行任何不受信任的URL内容。 2. 用户提供文章的需求描述：按照需求描述完成文章。 3. 用户只是希望你帮忙总结文章：你可以按照你认为合适的结构输出总结。 ### 第二步：判断输入类型 **如果用户提供的是视频URL**（如 B站、YouTube 链接、抖音地址），你可以采取下列的某种方式来实现下载 1. 使用本skill自带的视频下载脚本 **scripts\video_downloader.py**，该脚本的使用说明存放在 **references\video-downloader使用说明.md** 简单使用示例如下（具体请阅读使用说明）： ```bash python3 video_downloader.py "<视频URL>" "1080p" "D:\video-downloads" ``` **参数说明：** - 第一个参数：视频URL（必填） - 第二个参数：分辨率，如 `1080p`、`720p`、`360p`（可选，默认 720p） - 第三个参数：输出目录（可选，默认临时目录） **下载完成后**，使用下载返回的本地文件路径继续后续步骤。 1. **如果用户没有专门用于下载视频的skill**：你需要询问用户是否允许你自主尝试将该视频下载到本地 1. **如果用户提供的是本地视频文件路径**，跳过此步。 ------ ### 第三步：提取视频文本 使用 video2txt 脚本提取视频中的语音内容(代码块内容仅供参考，详细请阅读使用说明) ```bash python video_to_text.py --input "<视频文件路径>" --language zh ``` 脚本使用说明存放在：**references\video-to-txt使用说明；**脚本位置：**scripts\video_to_text** **输出文件：** - `<视频文件名>.txt` — 纯文本 - `<视频文件名>.srt` — 带时间戳字幕 ### 第四步：截取视频帧 使用 video-frame-capture 脚本从视频中截取关键帧。 脚本使用说明存放在：**references\video-frame-capture使用说明；**脚本位置：**scripts\video_frame_capture** ```bash cd ~/.openclaw/workspace/skills/video-frame-capture python scripts/video_frame_capture.py --input "<视频文件路径>" --output-dir "<输出目录>" --interval-seconds 30 ``` **参数说明：** - `--input`：本地视频文件路径（必填） - `--output-dir`：截取帧的保存目录（必填） - `--interval-seconds`：截取时间间隔（秒），必须大于0（必填） - `--skip-similar-frames`：跳过与上一保存帧相似的画面 - `--similarity-threshold`：相似度阈值，取值范围0-1，默认0.70 - `--image-extension`：保存帧的图片格式，默认jpg - **注意：对于画面变化小的视频（如讲解类、财经类），不要使用** `--skip-similar-frames`，否则会跳过大量帧 **输出：** 多张截图，命名格式：`视频名_时间戳_序号.jpg` ### 第五步：初步总结视频内容和信息（该步骤不可跳过，但不阻塞后续步骤） 基于第三步提取的文本内容，总结一段 **200字左右** 的背景信息文档，描述这段视频的核心内容。 **总结要点：** - 视频主题是什么 - 主要讲了哪些核心观点/事件 - 涉及的关键人物/品牌/产品 - 视频的整体立场/基调（客观科普、吐槽批评、推荐安利等） **输出：**将总结内容输出给用户，并开始下一步操作； ```bash 下方是一个输出案例：[]中为内容占位符；（）为补充说明； 视频是一个：[视频类型与性质]（例如：娱乐八卦，技术教程，游戏实况等） 视频内容主要描述：[视频内容] 从视频中提取了：[总共截取到的图片数量] 将采取其中：[计划用作写作的图片数量] 视频下载地址存储于：[] 视频帧截图存储于: [] 字幕文件存储于：[] 最终文稿计划存储于：[] ``` ------ ### 第六步：根据时间轴自动匹配图片与字幕段落 **核心规则：不要调用图片理解/识别skill！使用下方描述的方式进行匹配** 图片理解AI可能误判画面内容（比如把A产品当成B产品），而时间轴匹配是确定性的，只要字幕时间戳准确，配图就一定对得上，下面详细介绍步骤： 1. 读取字幕识别文件通常为 **.srt** 后缀，格式如下 [序号] [时间区域] [内容区域] ``` 1 00:00:00,000 --> 00:00:03,280 在我们公司每天100多个人在一个共同的空间里面协助 2 00:00:03,280 --> 00:00:04,440 那问题就来了 3 00:00:04,440 --> 00:00:06,440 我们会梦到尽思的东西吗 4 00:00:06,440 --> 00:00:10,760 我发现很少有人把梦大规模的给居象化可实化给做出来 ``` 2. 读取视频帧截取文件 通常为 **frames\result.json** ,格式如下： ``` [ { "file_index": 1, "file_path": "D:\\video-downloads\\frames\\100_..._00h00m00s_0001.jpg" ## 文件路径（涵盖时间轴） }, { "file_index": 2, "file_path": "D:\\video-downloads\\frames\\100_..._00h00m02s_0002.jpg" }, { "file_index": 3, "file_path": "D:\\video-downloads\\frames\\100_..._00h00m7s_0003.jpg" }, } ] ``` 3. **生成草稿（本步骤十分重要，请严格按照指示执行）** 草稿文件是一个融合了**“字幕文件.str”**与**“帧截取结果文件.json”**的临时文件（**temp.txt**），主要目的是，在原有字幕文件的基础上，加入一个“可用参考图片”的字段，将result.json中截取到的图片文件，按照时间轴，匹配到对应字幕出现的位置，具体结构如下： ``` 1 00:00:00,000 --> 00:00:03,280 在我们公司每天100多个人在一个共同的空间里面协助 imgs:{ "file_path": "D:\\video-downloads\\frames\\100_..._00h00m00s_0001.jpg", "file_path": "D:\\video-downloads\\frames\\100_..._00h00m02s_0002.jpg" } 2 00:00:03,280 --> 00:00:04,440 那问题就来了 3 00:00:04,440 --> 00:00:06,440 我们会梦到尽思的东西吗 4 00:00:06,440 --> 00:00:10,760 我发现很少有人把梦大规模的给居象化可实化给做出来 imgs:{ "file_path": "D:\\video-downloads\\frames\\100_..._00h00m7s_0003.jpg" } ``` 如上方案例所示，将图片路径，匹配到字幕对应的时间区间中 **具体步骤：** 1. 阅读第三步提取的字幕文件（字幕文件中会有时间信息） 2. 阅读第四步的帧截取时的结果数据 result.json 文件（该文件会包含截取到的所有图片与对应的路径以及时间轴） 3. 匹配图片地址，生成 temp.txt文件 **禁止调用图片理解skill的原因：** - AI可能误判画面内容，导致配图与文本不匹配 - 时间轴匹配是确定性的，不会出错 - 只要字幕时间戳准确，配图就一定对得上 - 省去不必要的API调用，提高效率 ### 第四步：撰写图文并排文章 阅读 temp.txt 文件，开始撰写文章： **文章结构和风格：** 1. 优先按照用户最新的要求来进行文本创作 2. 如果用户没有明确文本的写作要求和格式，你可以参考skill路径下 **assets**文件夹中的文章模板参考来进行写作； 3. assets中包含了各类文本（公众号文章，CSDN文章等）的写作模板框架 4. 如果用户指定某个assets下的写作风格模板，请严格参照指定的md模板来写作 例如： ``` 1. 基于视频帮我生成公众号文章 （用户明确了是公众号文章，你进入assets目录下，尝试查找公众号模板或相关文件） assets下有相关的模板则严格参照格式和风格语气；若无则按照你的理解来写； 2. 根据视频生成一篇文章 （用户没有明确说明文章风格，且在后续追问过程中，用户没有澄清） 你将按照assets通用文本格式参考进行写作（但无需严格参照，可适当发挥） 3. 根据视频，按照“影视飓风文本风格” 生成文本 （用户明确指明了按照影视飓风的文本风格，你应该从assets找到影视飓风相关的文本作为参考，并严格执行） 从assets找到影视飓风相关的文本作为参考，并严格执行 ``` **配图原则：** 1. temp.txt文件中，会写入每个字幕对应的时间轴下的可用的配图，你在配图时，要参考这个时间轴，确保图文内容尽可能的对应； **图文时间对齐参考**： ``` 以上方为例 1 00:00:00,000 --> 00:00:03,280 在我们公司每天100多个人在一个共同的空间里面协助 imgs:{ "file_path": "D:\\video-downloads\\frames\\100_..._00h00m00s_0001.jpg", "file_path": "D:\\video-downloads\\frames\\100_..._00h00m02s_0002.jpg" } 2 00:00:03,280 --> 00:00:04,440 那问题就来了 3 00:00:04,440 --> 00:00:06,440 我们会梦到尽思的东西吗 4 00:00:06,440 --> 00:00:10,760 我发现很少有人把梦大规模的给居象化可实化给做出来 imgs:{ "file_path": "D:\\video-downloads\\frames\\100_..._00h00m7s_0003.jpg" } 假设你需要基于这个temp文件写文章， 你要确保根据这段字幕总结或生成的文章内容里，只能包含这三张图片，以确保图文内容关联（下方仅做示意，执行时请严格遵循规范的markdown语法）： ---- 影视飓风的创始人提出了疑问，并开展了一个关于梦境的实验； （配图1D:\\video-downloads\\frames\\100_..._00h00m00s_0001.jpg）（配图2D:\\video-downloads\\frames\\100_..._00h00m02s_0002.jpg） 他们发现，很少人能把大规模的梦境具象化出来展示： （配图3D:\\video-downloads\\frames\\100_..._00h00m7s_0003.jpg） ---- ``` 上述只是一个例子，这个过程不是线性的，你可以自由的根据用户需求与你的想法，生成高质量的文章，但是要确保文章的图文能够匹配 ------ ## 依赖 - video2txt 技能（文本提取） - video-frame-capture 技能（帧截取） - Python 3.11+ - faster-whisper、opencv-python ## 输出文件 最终输出为 Markdown 文件，保存在视频文件同目录下： - `<视频文件名>_article.md` ## 注意事项 1. **视频帧截取间隔**：建议30秒，根据视频时长调整 2. **相似度过滤**：讲解类视频画面变化小，**不要使用** `--skip-similar-frames` 3. **🚫** **禁止调用图片理解skill**：不要使用任何图片识别/理解API，直接依靠时间轴匹配图片和字幕。可以手动浏览图片挑选，但不要用AI理解图片内容 4. **配图数量**：根据视频时长和内容丰富度决定，教程类建议20张以上，科普类6-8张即可 5. **图片路径**：CSDN/公众号发布时手动上传图片，md中使用相对路径引用 ## **更新记录：** - 2026-03-20：首次创建，基于《被山姆榨干的中产们》视频的生成流程总结 - 2026-03-21：新增第零步，支持视频URL自动下载（集成 video-downloader 技能） - 2026-03-21：新增图文时间轴对齐说明，利用图片时间戳与SRT字幕匹配，提高图文准确性 - 2026-03-21：明确禁止调用图片理解skill，完全依靠时间轴硬匹配。理由：AI可能误判画面内容，时间轴匹配更准确可靠 - 2026-03-25：合并组合工具，将文本提取与帧截取合并在该skill中

path: assets/通用文本格式参考.md
content: # 文章标题

**导读：** 简短引言，吸引读者

![封面图](图片路径)

---

## 一、开篇（引入话题/背景）

![配图](图片路径)

内容...

---

## 二、核心问题（深入分析）

![配图](图片路径)

内容...

---

## 三、数据/证据（用数据说话）

![配图](图片路径)

内容...

---

## 四、竞争/对比（如有）

![配图](图片路径)

内容...

---

## 五、总结与展望

内容...

---

*来源说明*

path: references/video-downloader使用说明.md
content: ---
name: video-downloader
description: 通用视频下载工具，支持 YouTube、B站、抖音等主流平台。使用 yt-dlp 下载视频，自动选择分辨率、合并音视频、清理文件名。
metadata:
  {
    "openclaw":
      {
        "emoji": "🎬",
        "requires": { "bins": ["yt-dlp", "ffmpeg"] },
        "install":
          [
            {
              "id": "brew",
              "kind": "brew",
              "formula": "yt-dlp",
              "bins": ["yt-dlp"],
              "label": "Install yt-dlp (brew)"
            },
            {
              "id": "brew",
              "kind": "brew",
              "formula": "ffmpeg",
              "bins": ["ffmpeg"],
              "label": "Install ffmpeg (brew)"
            }
          ],
      },
  }
---

# Video Downloader Skill

通用视频下载工具，支持 YouTube、B站、抖音等主流平台。

## 功能

- 下载任意 yt-dlp 支持的视频网站
- 自动选择分辨率（优先 1080p，可指定）
- 自动合并音视频（如果分离）
- 文件名清理（短名、无特殊字符）

## 用法

```bash
/video-downloader <视频URL> [分辨率] [输出目录]
```

参数：
- `视频URL`：目标视频链接
- `分辨率`：可选，如 `1080p`、`720p`、`480p`
- `输出目录`：可选，默认临时目录

示例：
```
/video-downloader "https://youtu.be/xxx" "1080p" "./downloads"
/video-downloader "https://www.bilibili.com/video/BV1xx" "720p"
```

## 输出

返回下载后的本地文件路径。可手动使用 `sendfiles-to-feishu` 技能发送到飞书。

## 依赖

- yt-dlp
- ffmpeg

## 特点

- 不重新编码，保留原质量
- 多平台支持（yt-dlp 支持的站点）
- 自动处理音视频分离
- 安全文件名，避免乱码

注意：此技能仅下载，不发送。发送请使用 `sendfiles-to-feishu`。

path: references/video-frame-capture使用说明/使用说明.md
content: ## 概述

该工具用于按固定时间间隔从本地视频中截取画面，并将图片保存到指定目录。适合做视频内容预览、关键帧采样、后续图像识别输入等场景。

## 适用场景

- 需要快速了解视频整体内容
- 需要从长视频中按时间间隔提取截图
- 需要跳过重复度较高的相似画面
- 需要把截帧结果继续交给后续脚本处理

## 快速开始

下面命令表示每隔 10 秒截取一帧：

```powershell
python scripts/video_frame_capture.py --input "D:\videos\meeting.mp4" --output-dir "D:\frames\meeting" --interval-seconds 10
```

## 参数说明

- `--input`：本地视频文件路径，必填
- `--output-dir`：输出目录，必填
- `--interval-seconds`：截帧间隔，单位秒，必填，且必须大于 0
- `--skip-similar-frames`：启用相似帧跳过逻辑
- `--similarity-threshold`：相似度阈值，范围 `0` 到 `1`，默认值为 `0.70`
- `--image-extension`：输出图片格式，可选 `jpg` 或 `png`，默认值为 `jpg`

## 输出文件命名规则

截取的图片文件名格式如下：

```text
视频文件名_00h00m00s_0001.jpg
```

示例：

```text
meeting_00h01m30s_0003.jpg
```

命名组成说明：

- 第一段：原视频文件名，不含扩展名
- 第二段：该帧在视频中的时间点，格式为 `hhmmss`
- 第三段：本次抓取尝试对应的四位序号

## 相似帧跳过机制

启用 `--skip-similar-frames` 后，脚本会将当前候选帧与上一张已保存图片进行比较：

- 相似度高于阈值：跳过当前帧，不保存图片
- 相似度低于或等于阈值：保存当前帧，并将该帧作为新的比较基准

## 新增结果清单输出

脚本处理完成后，除了输出截帧图片，还会在 `--output-dir` 目录下额外生成一个 `result.json` 文件，用于记录本次实际保存成功的图片结果列表。

### result.json 规则

- 文件名固定为 `result.json`
- 保存位置为 `--output-dir/result.json`
- 内容为 JSON 数组
- 数组中的每一项对应一个实际保存成功的图片文件
- 每一项包含以下字段：
- `file_index`：图片保存序号，从 `1` 开始递增
- `file_path`：图片完整路径，包含文件名

### result.json 示例

```json
[
  {
    "file_index": 1,
    "file_path": "D:\\frames\\meeting\\meeting_00h00m00s_0001.jpg"
  },
  {
    "file_index": 2,
    "file_path": "D:\\frames\\meeting\\meeting_00h00m10s_0002.jpg"
  }
]
```

## 终端输出说明

脚本运行时会输出以下几类日志：

- 输入视频路径
- 输出目录路径
- 截帧间隔
- 是否启用相似帧跳过
- 视频总时长
- 当前进度和抓取位置
- 单张图片保存结果
- 最终统计信息
- `result.json` 的保存路径

## 使用示例

### 基础截帧

```powershell
python scripts/video_frame_capture.py --input "D:\videos\meeting.mp4" --output-dir "D:\frames\meeting" --interval-seconds 10
```

### 跳过相似帧

```powershell
python scripts/video_frame_capture.py --input "D:\videos\meeting.mp4" --output-dir "D:\frames\meeting" --interval-seconds 10 --skip-similar-frames
```

### 自定义相似度阈值

```powershell
python scripts/video_frame_capture.py --input "D:\videos\meeting.mp4" --output-dir "D:\frames\meeting" --interval-seconds 10 --skip-similar-frames --similarity-threshold 0.80
```

## 相关资源

### scripts/

- `video_frame_capture.py`：视频截帧主脚本

### references/

- `补充说明.md`：补充的格式、原理与技术说明

## 依赖环境

运行该工具需要安装 OpenCV 相关依赖：

```powershell
pip install -r requirements.txt
```


path: references/video-frame-capture使用说明/补充说明.md
content: # 支持的视频格式

## 支持的输入格式

视频帧截取工具支持以下主流视频格式，覆盖日常办公、媒体制作、网页传输等各类场景，兼容性适配绝大多数本地视频文件：

- **MP4**（.mp4）—— 目前最通用、兼容性最强的视频格式，适用于绝大多数设备与平台
- **MKV**（.mkv）——  Matroska开源视频容器格式，支持多音轨、多字幕和高清视频封装
- **MOV**（.mov）—— QuickTime专属视频格式，常用于苹果设备、专业视频剪辑场景
- **AVI**（.avi）—— 音视频交错格式，经典老牌视频格式，适配老旧设备与传统剪辑工程
- **FLV**（.flv）—— Flash视频格式，早年网页流媒体常用格式，部分存量视频仍为此格式
- **WMV**（.wmv）—— Windows媒体视频格式，微软平台专属，适配Windows系统原生播放
- **WebM**（.webm）—— 开源网页专用视频格式，体积小巧，适配网页端快速加载与播放
- **M4V**（.m4v）—— MPEG-4标准衍生视频格式，常用于苹果设备视频存储与传输

## 技术细节说明

### 帧提取原理

工具脚本依托OpenCV库的VideoCapture模块实现精准帧提取，全程按照指定时间间隔完成操作，核心执行流程如下：

1. 通过`cv2.VideoCapture`方法打开本地视频文件，完成视频流初始化
2. 根据视频总帧数和帧率（FPS）计算视频总时长，确定截取时间范围
3. 通过`CAP_PROP_POS_MSEC`参数精准定位到指定毫秒级时间戳位置
4. 按设定间隔完成帧捕获，并按照指定格式保存至目标目录

### 相似帧检测逻辑

启用`--skip-similar-frames`跳过相似帧参数后，脚本会通过以下步骤完成帧相似度比对，高效剔除重复冗余画面：

1. 将待比对帧统一缩放至320x180分辨率，降低计算量，提升比对效率
2. 将彩色帧转换为灰度图像，剔除色彩干扰，聚焦画面内容差异
3. 计算两帧之间的像素绝对差值，量化画面差异程度
4. 通过公式**1.0 - 平均像素差值/255.0**计算相似度得分，分值越接近1代表画面越相似
5. 相似度得分高于设定阈值的帧直接跳过，低于或等于阈值的帧则保存并更新基准对比帧

### 输出文件命名规范

所有截取的帧文件统一遵循标准化命名规则，方便后续查找、整理和溯源，格式如下：

```
视频文件名_时分秒时间戳_截取序号.jpg
```

示例：`meeting_00h01m30s_0003.jpg`

- **视频文件名**：原始视频文件去除后缀后的名称
- **时分秒时间戳**：帧在视频中对应的时间位置，格式为小时+分钟+秒
- **截取序号**：帧的截取顺序编号，固定4位数字，不足位数用0补齐

## 依赖环境

工具运行需安装以下Python第三方库，核心依赖为OpenCV相关包，配套库会自动关联安装：

- `opencv-python-headless`—— 核心视频处理与帧提取库，无GUI界面版本，适配服务器与轻量化环境
- `numpy`—— 数组运算与数值计算库，随OpenCV自动安装，用于帧数据处理与相似度计算

一键安装所有依赖命令：`pip install -r requirements.txt`


path: references/video-to-txt使用说明/requirements.txt
content: faster-whisper>=1.1.0
av>=12.0.0
opencc-python-reimplemented>=0.1.7


path: references/video-to-txt使用说明/video2txt使用说明.md
content: ---
name: video2txt
description: 将本地视频或音频文件转写为 SRT 字幕文件和 TXT 纯文本文件
metadata: { "openclaw": { "emoji": "video", "requires": { "bins": ["python3"] } } }
---

# video2txt 技能

## 描述

将本地视频或音频文件转写为 SRT 字幕文件和 TXT 纯文本文件。

## 功能

- 提取视频/音频中的语音内容
- 生成带时间戳的 SRT 字幕文件
- 生成纯文本 TXT 文件
- 支持多种视频和音频格式
- 默认使用中文识别，自动转换为简体中文

​	使用场景：

1. 需要读取视频内容或理解视频时

## 使用方法

### 基本命令

```bash
python video_to_text.py --input <视频/音频文件路径>
```

### 注意事项

- **后台执行**：调用此脚本时，务必使用 `background: true` 参数，避免弹出控制台窗口
- 脚本运行过程中会输出详细的进度日志（每 10% 报告一次），方便追踪执行状态

### 示例

```bash
# 基本用法
python video_to_text.py --input "D:\videos\meeting.mp4"

# 指定输出目录
python video_to_text.py --input "D:\videos\meeting.mp4" --output-dir "D:\captions"

# 指定输出路径
python video_to_text.py --input "D:\videos\meeting.mp4" --output-path "D:\captions\meeting_result"

# 指定语言和模型
python video_to_text.py --input "D:\videos\meeting.mp4" --language zh --model-size small
```

## 参数说明

| 参数 | 说明 | 默认值 |
|------|------|--------|
| `--input` | 输入文件路径（必需） | - |
| `--output-dir` | 输出目录 | 输入文件目录 |
| `--output-path` | 输出文件基础路径 | - |
| `--model-dir` | 模型下载目录 | 当前目录/models |
| `--model-size` | Whisper 模型大小 | base |
| `--language` | 识别语言 (auto/zh/en) | zh |
| `--device` | 推理设备 (cpu/cuda) | cpu |
| `--compute-type` | 计算类型 | int8 |
| `--beam-size` | 解码束大小 (1-5) | 2 |
| `--no-vad-filter` | 禁用 VAD 过滤 | false |

## 依赖

- faster-whisper >= 1.1.0
- av >= 12.0.0
- opencc-python-reimplemented >= 0.1.7
-  ffprobe/ffmpeg
- Whisper 模型文件（首次运行自动下载，需要发起网络请求，占用磁盘空间）

## 安装

1. 确保 Python 3.11 或 3.12 环境
2. 安装依赖：`python -m pip install -r requirements.txt`
3. 首次运行会自动下载 Whisper 模型到 models 目录

## 输出文件

- `<输入文件名>.srt` - 带时间戳的字幕文件
- `<输入文件名>.txt` - 纯文本文件

## 注意事项

- 首次运行需要下载 Whisper 模型，可能需要几分钟时间
- 建议使用 Python 3.11 或 3.12，避免与 faster-whisper 的兼容性问题
- 中文识别会自动将繁体字转换为简体字
- 为了减少用户等待焦虑，每间隔10秒左右报告一次处理进度
- beam-size 默认为 2，如需调整可手动指定 --beam-size 参数


path: scripts/timeline_matcher.py
content: #!/usr/bin/env python3
"""
时间轴匹配脚本：将视频帧截图与SRT字幕按时间轴自动匹配。

用法：
    python timeline_matcher.py --frames-dir "<帧截图目录>" --srt "<SRT文件路径>" --max-images 8

功能：
1. 解析帧截图文件名中的时间戳 (格式：视频名_00h01m30s_0003.jpg)
2. 解析SRT字幕文件的所有时间段
3. 将每张图片匹配到最近的字幕段落（15秒误差内）
4. 输出JSON格式的配对结果
5. 筛选出均匀分布在时间轴上的代表性图片
"""

import os
import re
import json
import argparse
from pathlib import Path


def parse_frame_timestamp(filename: str) -> float | None:
    """
    从帧截图文件名中提取时间戳（秒）。
    格式：视频名_00h01m30s_0003.jpg
    """
    match = re.search(r'_(\d{2})h(\d{2})m(\d{2})s', filename)
    if not match:
        return None
    h, m, s = int(match.group(1)), int(match.group(2)), int(match.group(3))
    return float(h * 3600 + m * 60 + s)


def parse_srt_time(time_str: str) -> float:
    """
    解析SRT时间格式为秒数。
    格式：00:02:15,000
    """
    time_str = time_str.strip()
    match = re.match(r'(\d{2}):(\d{2}):(\d{2}),(\d{3})', time_str)
    if not match:
        return 0.0
    h = int(match.group(1))
    m = int(match.group(2))
    s = int(match.group(3))
    ms = int(match.group(4))
    return h * 3600 + m * 60 + s + ms / 1000.0


def parse_srt(srt_path: str) -> list[dict]:
    """
    解析SRT字幕文件，返回字幕块列表。
    每个字幕块包含：start_sec, end_sec, text
    """
    with open(srt_path, 'r', encoding='utf-8') as f:
        content = f.read()

    # SRT块分割：序号、时间轴、文本、空行
    blocks = re.split(r'\n\n+', content.strip())
    subtitles = []

    for block in blocks:
        lines = block.strip().split('\n')
        if len(lines) < 3:
            continue

        # 第二行是时间轴：00:00:32,000 --> 00:00:38,000
        time_match = re.match(
            r'(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})',
            lines[1]
        )
        if not time_match:
            continue

        start_sec = parse_srt_time(time_match.group(1))
        end_sec = parse_srt_time(time_match.group(2))
        text = ' '.join(lines[2:]).strip()

        subtitles.append({
            'start_sec': start_sec,
            'end_sec': end_sec,
            'text': text
        })

    return subtitles


def match_frames_to_subtitles(
    frames_dir: str,
    srt_path: str,
    max_images: int = 8,
    tolerance_sec: int = 15
) -> list[dict]:
    """
    将帧截图与字幕按时间轴匹配。

    Args:
        frames_dir: 帧截图目录
        srt_path: SRT字幕文件路径
        max_images: 最终筛选的图片数量（均匀分布）
        tolerance_sec: 时间匹配容忍秒数

    Returns:
        配对结果列表，包含图片路径、时间戳、对应字幕
    """
    # 1. 收集所有帧截图
    frames_dir = Path(frames_dir)
    frame_files = []
    for f in frames_dir.iterdir():
        if f.suffix.lower() in ('.jpg', '.jpeg', '.png', '.webp'):
            ts = parse_frame_timestamp(f.name)
            if ts is not None:
                frame_files.append({'path': str(f), 'timestamp_sec': ts})

    if not frame_files:
        print(f"[ERROR] 未找到带时间戳的帧截图: {frames_dir}")
        return []

    # 按时间排序
    frame_files.sort(key=lambda x: x['timestamp_sec'])
    total_duration = frame_files[-1]['timestamp_sec']

    print(f"[INFO] 找到 {len(frame_files)} 张帧截图")
    print(f"[INFO] 视频总时长: {total_duration // 60}分{total_duration % 60}秒")

    # 2. 解析字幕
    subtitles = parse_srt(srt_path)
    if not subtitles:
        print(f"[ERROR] 未解析到有效字幕: {srt_path}")
        return []

    print(f"[INFO] 找到 {len(subtitles)} 条字幕")

    # 3. 匹配：每张图片找到最合适的字幕
    results = []
    for frame in frame_files:
        frame_ts = frame['timestamp_sec']
        
        # 首先找到包含图片时间戳的字幕段
        best_sub = None
        for sub in subtitles:
            if sub['start_sec'] <= frame_ts <= sub['end_sec']:
                best_sub = sub
                break
        
        # 如果没有找到包含的，则找时间上最接近的字幕
        if not best_sub:
            best_diff = float('inf')
            for sub in subtitles:
                # 计算图片时间与字幕开始时间的差距
                diff_start = abs(frame_ts - sub['start_sec'])
                diff_end = abs(frame_ts - sub['end_sec'])
                diff = min(diff_start, diff_end)
                if diff < best_diff and diff <= tolerance_sec:
                    best_diff = diff
                    best_sub = sub
        
        # 如果找到匹配的字幕
        if best_sub:
            # 计算精确的时间差
            diff = abs(frame_ts - best_sub['start_sec'])
            
            # 格式化时间字符串（用于显示）
            start_h = int(best_sub['start_sec'] // 3600)
            start_m = int((best_sub['start_sec'] % 3600) // 60)
            start_s = int(best_sub['start_sec'] % 60)
            start_ms = int((best_sub['start_sec'] % 1) * 1000)
            
            end_h = int(best_sub['end_sec'] // 3600)
            end_m = int((best_sub['end_sec'] % 3600) // 60)
            end_s = int(best_sub['end_sec'] % 60)
            end_ms = int((best_sub['end_sec'] % 1) * 1000)
            
            srt_time_str = f"{start_h:02d}:{start_m:02d}:{start_s:02d},{start_ms:03d} --> {end_h:02d}:{end_m:02d}:{end_s:02d},{end_ms:03d}"
        else:
            srt_time_str = ''
            diff = tolerance_sec + 1
        
        # 格式化图片时间戳字符串
        frame_h = int(frame_ts // 3600)
        frame_m = int((frame_ts % 3600) // 60)
        frame_s = int(frame_ts % 60)
        
        results.append({
            'image': os.path.basename(frame['path']),
            'image_path': frame['path'],
            'timestamp_sec': frame_ts,
            'timestamp_str': f"{frame_h:02d}h{frame_m:02d}m{frame_s:02d}s",
            'subtitle_text': best_sub['text'] if best_sub else '',
            'srt_time': srt_time_str,
            'has_match': best_sub is not None,
            'selected': False
        })

    # 4. 筛选出均匀分布的N张图片（用于文章配图）
    matched = [r for r in results if r['has_match']]
    if not matched:
        print("[WARN] 没有任何图片成功匹配到字幕")
        return results

    if len(matched) <= max_images:
        for r in matched:
            r['selected'] = True
    else:
        # 均匀采样
        step = len(matched) / max_images
        for i in range(max_images):
            idx = int(i * step)
            matched[idx]['selected'] = True

    selected_count = sum(1 for r in results if r['selected'])
    matched_count = sum(1 for r in results if r['has_match'])
    print(f"[INFO] 匹配成功: {matched_count}/{len(results)} 张图片")
    print(f"[INFO] 已筛选: {selected_count} 张用于文章配图")

    return results


def main():
    parser = argparse.ArgumentParser(description='时间轴匹配：帧截图与SRT字幕自动配对')
    parser.add_argument('--frames-dir', required=True, help='帧截图目录')
    parser.add_argument('--srt', required=True, help='SRT字幕文件路径')
    parser.add_argument('--max-images', type=int, default=8, help='筛选的配图数量（默认8）')
    parser.add_argument('--tolerance', type=int, default=15, help='时间匹配容忍秒数（默认15）')
    parser.add_argument('--output', default=None, help='输出JSON文件路径（默认打印到stdout）')

    args = parser.parse_args()

    results = match_frames_to_subtitles(
        frames_dir=args.frames_dir,
        srt_path=args.srt,
        max_images=args.max_images,
        tolerance_sec=args.tolerance
    )

    output_json = json.dumps(results, ensure_ascii=False, indent=2)

    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            f.write(output_json)
        print(f"[INFO] 结果已保存: {args.output}")
    else:
        print("\n" + output_json)


if __name__ == '__main__':
    main()


path: scripts/video_downloader.py
content: #!/usr/bin/env python3
"""
Video Downloader - OpenClaw Skill
下载任意平台视频（YouTube、B站、抖音等），自动合并音视频，清理文件名。

用法：
  python video_downloader.py <视频URL> [分辨率] [输出目录]

示例：
  python video_downloader.py "https://youtu.be/xxx" "1080p"
  python video_downloader.py "https://www.bilibili.com/video/BV1xx" "720p" "./downloads"
"""

import os
import sys
import subprocess
import tempfile
import re
import importlib

# ===== 依赖检查 =====
REQUIRED_PACKAGES = {
    "yt_dlp": "yt-dlp",
}


def check_dependencies() -> list[str]:
    """Return list of missing required package names."""
    missing = []
    for module_name, pip_name in REQUIRED_PACKAGES.items():
        try:
            importlib.import_module(module_name)
        except ImportError:
            missing.append(pip_name)
    return missing


_missing = check_dependencies()
if _missing:
    print(
        f"ERROR: Missing required dependencies: {', '.join(_missing)}\n"
        f"Install them with:  pip install {' '.join(_missing)}",
        file=sys.stderr,
    )
    raise SystemExit(1)

# ===== 兼容性修复 =====
# Windows 上 yt-dlp 可能不在 PATH，用 python -m yt_dlp 更稳
YT_DLP_CMD = ['python3', '-m', 'yt_dlp']

# 通过 imageio-ffmpeg 获取 ffmpeg 路径（自带二进制，无需系统安装）
def get_ffmpeg_path():
    try:
        import imageio_ffmpeg
        return imageio_ffmpeg.get_ffmpeg_exe()
    except ImportError:
        # fallback: 直接调用系统命令
        return 'ffmpeg'

def get_ffprobe_path():
    """ffprobe 路径，优先用 ffmpeg 目录下的 ffprobe，否则用 ffmpeg 替代"""
    ffmpeg_path = get_ffmpeg_path()
    ffmpeg_dir = os.path.dirname(ffmpeg_path)
    # 尝试找同目录的 ffprobe
    ffprobe_name = 'ffprobe.exe' if os.name == 'nt' else 'ffprobe'
    ffprobe_path = os.path.join(ffmpeg_dir, ffprobe_name)
    if os.path.exists(ffprobe_path):
        return ffprobe_path
    # 如果 imageio-ffmpeg 没带 ffprobe，尝试 Gyan.dev 的完整版
    if os.name == 'nt':
        gyandir = os.path.join(ffmpeg_dir.replace('imageio_ffmpeg\\binaries', 'ffmpeg-bin'))
        gyandir = os.path.join(os.environ.get('USERPROFILE', ''), 'ffmpeg-bin')
        gyandir = os.path.expanduser('~\\ffmpeg-bin')
        if os.path.isdir(gyandir):
            for f in os.listdir(gyandir):
                if f.lower().startswith('ffprobe') and f.lower().endswith('.exe'):
                    return os.path.join(gyandir, f)
    # fallback: 用 ffmpeg 替代（某些功能不支持）
    return ffmpeg_path

FFMPEG = get_ffmpeg_path()
FFPROBE = get_ffprobe_path()

def sanitize_filename(name, max_len=50):
    name = os.path.basename(name)
    name = re.sub(r'[^\w\u4e00-\u9fff\-\.]', '_', name)
    if len(name) > max_len:
        base, ext = os.path.splitext(name)
        base = base[:max_len - len(ext)]
        name = base + ext
    return name

def has_audio_stream(filepath):
    """检测文件是否有音频流（用 ffmpeg -i 输出判断）"""
    try:
        result = subprocess.run(
            [FFMPEG, '-i', filepath, '-hide_banner'],
            capture_output=True, text=True, timeout=15,
            encoding='utf-8', errors='replace'
        )
        # ffmpeg 输出在 stderr，检查是否有 Audio 流
        return 'Audio:' in result.stderr
    except Exception:
        return False

def has_video_stream(filepath):
    """检测文件是否有视频流（用 ffmpeg -i 输出判断）"""
    try:
        result = subprocess.run(
            [FFMPEG, '-i', filepath, '-hide_banner'],
            capture_output=True, text=True, timeout=15,
            encoding='utf-8', errors='replace'
        )
        return 'Video:' in result.stderr
    except Exception:
        return False

def merge_audio_video_if_needed(video_path, output_dir):
    if has_video_stream(video_path) and has_audio_stream(video_path):
        return video_path
    print('检测到音视频分离，尝试合并...')
    
    # 清理 format_id 后缀 (如 .f30016)
    base_name = os.path.splitext(os.path.basename(video_path))[0]
    base_name = re.sub(r'\.f\d+$', '', base_name)  # 去掉 .f30016 这样的后缀
    
    dir_path = os.path.dirname(video_path)
    
    # 找匹配的音频/视频文件：清理 format_id 后匹配
    candidates = []
    for f in os.listdir(dir_path):
        f_full = os.path.join(dir_path, f)
        if f_full == video_path:
            continue
        f_base = os.path.splitext(f)[0]
        f_base_clean = re.sub(r'\.f\d+$', '', f_base)
        # 匹配：basename 相同（去掉 format_id 后）
        if f_base_clean == base_name:
            candidates.append(f_full)
        # 备选：同目录下只有两个媒体文件时也尝试合并
        elif len(candidates) == 0:
            ext = os.path.splitext(f)[1].lower()
            if ext in ('.m4a', '.mp3', '.webm', '.opus', '.aac'):
                candidates.append(f_full)
    
    if candidates:
        other_file = candidates[0]
        v_has = has_video_stream(video_path)
        a_has = has_audio_stream(video_path)
        o_has_a = has_audio_stream(other_file)
        o_has_v = has_video_stream(other_file)
        
        if (v_has and o_has_a) or (a_has and o_has_v):
            # 确定哪个是视频哪个是音频
            if v_has:
                vid_file, aud_file = video_path, other_file
            else:
                vid_file, aud_file = other_file, video_path
            
            merged_path = os.path.join(output_dir, f'{base_name}.mp4')
            cmd = [FFMPEG, '-y', '-i', vid_file, '-i', aud_file,
                   '-c:v', 'copy', '-c:a', 'aac', merged_path]
            try:
                result = subprocess.run(cmd, capture_output=True, text=True, 
                                       timeout=120, encoding='utf-8', errors='replace')
                if os.path.exists(merged_path):
                    # 合并成功，删除临时文件
                    os.remove(video_path)
                    for c in candidates:
                        if os.path.exists(c):
                            os.remove(c)
                    print(f'合并完成: {merged_path}')
                    return merged_path
            except subprocess.CalledProcessError as e:
                print(f'合并失败: {e}')
    
    # 如果没有找到匹配的文件，直接重命名为干净的名字
    clean_name = re.sub(r'\.f\d+$', '', os.path.basename(video_path))
    clean_path = os.path.join(output_dir, clean_name)
    if clean_path != video_path:
        if os.path.exists(clean_path):
            os.remove(clean_path)
        os.rename(video_path, clean_path)
        return clean_path
    return video_path

def download_video(url, output_dir=None, resolution=None):
    """下载视频，返回文件路径"""
    if output_dir is None:
        output_dir = tempfile.mkdtemp(prefix='video_download_')

    print(f'使用 ffmpeg: {FFMPEG}')
    print('获取视频格式信息...')

    list_cmd = YT_DLP_CMD + ['-F', '--no-warnings', url]
    try:
        result = subprocess.run(list_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 
                                text=True, timeout=30, encoding='utf-8', errors='replace')
        if result.returncode != 0:
            raise Exception(result.stderr)
        lines = result.stdout.splitlines() if result.stdout else []
    except Exception as e:
        raise Exception(f'获取格式列表失败: {e}')

    video_formats = []  # (height, width, format_id, has_audio)
    audio_formats = []  # (asr, format_id)  - 用于检查是否有可用音频流
    for line in lines:
        parts = line.split()
        if len(parts) >= 3 and parts[1] in ['mp4', 'webm', 'mkv', 'm4a', 'mp3', 'aac', 'opus']:
            try:
                res_str = parts[2]
                format_id = parts[0]
                # 判断是否有 audio note
                has_audio = 'audio' in line.lower() or parts[1] in ['m4a', 'mp3', 'aac', 'opus']
                if 'x' in res_str:
                    width, height = map(int, res_str.split('x'))
                    video_formats.append((height, width, format_id, has_audio))
                elif parts[1] in ['m4a', 'mp3', 'aac', 'opus']:
                    # 纯音频流（分辨率列可能是 "audio only" 或采样率）
                    audio_formats.append((0, format_id))
            except:
                continue

    # 检查是否有独立音频流可用
    has_separate_audio = len(audio_formats) > 0

    if not video_formats:
        print('警告: 无法解析格式列表，使用默认 best')
        format_spec = 'bestvideo+bestaudio/best'
    else:
        video_formats.sort(reverse=True)
        chosen = None
        if resolution:
            target_height = int(resolution.rstrip('p'))
            for h, w, fid, ha in video_formats:
                if h <= target_height:
                    chosen = (h, w, fid, ha)
                    break
            if not chosen:
                chosen = video_formats[-1]
        else:
            for h, w, fid, ha in video_formats:
                if h == 1080:
                    chosen = (h, w, fid, ha)
                    break
            if not chosen:
                chosen = video_formats[0]

        height, width, fmt_id, is合一流 = chosen
        # 关键修复：只有在选中的是纯视频流且有独立音频流可用时，才拼接 +bestaudio
        if has_separate_audio and not is合一流:
            format_spec = f'{fmt_id}+bestaudio/best'
            print(f'选择格式: {fmt_id} ({width}x{height}) + bestaudio（音视频分离模式）')
        else:
            format_spec = fmt_id
            print(f'选择格式: {fmt_id} ({width}x{height})（合一/默认模式）')

    print('开始下载...')
    template = os.path.join(output_dir, '%(title)s.%(ext)s')
    download_cmd = YT_DLP_CMD + [
        '-f', format_spec,
        '--output', template,
        '--merge-output-format', 'mp4',
        '--restrict-filenames',
        '--no-warnings',
        url
    ]

    try:
        result = subprocess.run(download_cmd, capture_output=True, text=True, timeout=600, encoding='utf-8')
        if result.returncode != 0:
            raise Exception(result.stderr or result.stdout)
    except subprocess.CalledProcessError as e:
        raise Exception(f'下载失败: {e}')

    files = [f for f in os.listdir(output_dir)
             if f.endswith(('.mp4', '.mkv', '.webm', '.mov', '.avi'))]
    if not files:
        raise Exception('未找到下载的视频文件')

    video_path = os.path.join(output_dir, files[0])
    safe_name = sanitize_filename(files[0])
    if safe_name != files[0]:
        new_path = os.path.join(output_dir, safe_name)
        os.rename(video_path, new_path)
        video_path = new_path

    video_path = merge_audio_video_if_needed(video_path, output_dir)

    size_mb = os.path.getsize(video_path) / (1024*1024)
    print(f'下载完成: {video_path} ({size_mb:.1f} MB)')
    return video_path

def main():
    if len(sys.argv) < 2:
        print('用法: python video_downloader.py <视频URL> [分辨率] [输出目录]')
        print('示例: python video_downloader.py "https://youtu.be/xxx" "1080p"')
        print('      python video_downloader.py "https://www.bilibili.com/video/BV1xx" "720p" "./downloads"')
        sys.exit(1)

    url = sys.argv[1]
    resolution = sys.argv[2] if len(sys.argv) > 2 else None
    output_dir = sys.argv[3] if len(sys.argv) > 3 else None

    if output_dir and not os.path.isdir(output_dir):
        os.makedirs(output_dir, exist_ok=True)

    try:
        result = download_video(url, output_dir, resolution)
        print(f'\n最终文件: {result}')
    except Exception as e:
        print(f'出错: {e}')
        sys.exit(1)

if __name__ == '__main__':
    main()


path: scripts/video_frame_capture.py
content: from __future__ import annotations

import argparse
import importlib
import json
import sys
from dataclasses import dataclass
from pathlib import Path

REQUIRED_PACKAGES = {
    "cv2": "opencv-python-headless",
}


def check_dependencies() -> list[str]:
    """Return list of missing required package names."""
    missing = []
    for module_name, pip_name in REQUIRED_PACKAGES.items():
        try:
            importlib.import_module(module_name)
        except ImportError:
            missing.append(pip_name)
    return missing


_missing = check_dependencies()
if _missing:
    print(
        f"ERROR: Missing required dependencies: {', '.join(_missing)}\n"
        f"Install them with:  pip install {' '.join(_missing)}",
        file=sys.stderr,
    )
    raise SystemExit(1)

SUPPORTED_INPUT_SUFFIXES = {
    ".mp4",
    ".mkv",
    ".mov",
    ".avi",
    ".flv",
    ".wmv",
    ".webm",
    ".m4v",
}
DEFAULT_SIMILARITY_THRESHOLD = 0.70


@dataclass(slots=True)
class CaptureStats:
    attempted: int = 0
    saved: int = 0
    skipped_similar: int = 0


@dataclass(slots=True)
class SavedFrame:
    index: int
    path: Path


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Capture frames from a video at fixed time intervals."
    )
    parser.add_argument("--input", required=True, help="Path to the local video file.")
    parser.add_argument(
        "--output-dir",
        required=True,
        help="Directory where captured frames will be stored.",
    )
    parser.add_argument(
        "--interval-seconds",
        type=float,
        required=True,
        help="Capture interval in seconds. Must be greater than 0.",
    )
    parser.add_argument(
        "--skip-similar-frames",
        action="store_true",
        help="Skip the current frame when similarity with the previous saved frame is above the threshold.",
    )
    parser.add_argument(
        "--similarity-threshold",
        type=float,
        default=DEFAULT_SIMILARITY_THRESHOLD,
        help="Similarity threshold in the range 0-1. Defaults to 0.70.",
    )
    parser.add_argument(
        "--image-extension",
        choices=("jpg", "png"),
        default="jpg",
        help="Image format for saved frames. Defaults to jpg.",
    )
    return parser.parse_args()


def fail(message: str, exit_code: int = 1) -> int:
    print(f"ERROR: {message}", file=sys.stderr)
    return exit_code


def log(message: str) -> None:
    print(f"[video_frame_capture] {message}")


def validate_args(args: argparse.Namespace) -> tuple[Path, Path]:
    input_path = Path(args.input).expanduser().resolve()
    output_dir = Path(args.output_dir).expanduser().resolve()

    if not input_path.exists():
        raise FileNotFoundError(f"Input file does not exist: {input_path}")
    if not input_path.is_file():
        raise ValueError(f"Input path is not a file: {input_path}")
    if input_path.suffix.lower() not in SUPPORTED_INPUT_SUFFIXES:
        supported = ", ".join(sorted(SUPPORTED_INPUT_SUFFIXES))
        raise ValueError(
            f"Unsupported input file type '{input_path.suffix}'. Supported types: {supported}"
        )
    if args.interval_seconds <= 0:
        raise ValueError("--interval-seconds must be greater than 0.")
    if not 0 <= args.similarity_threshold <= 1:
        raise ValueError("--similarity-threshold must be in the range 0 to 1.")

    output_dir.mkdir(parents=True, exist_ok=True)
    return input_path, output_dir


def load_cv2():
    try:
        import cv2
    except ImportError as exc:
        raise RuntimeError(
            "Missing dependency 'opencv-python-headless'. Install dependencies first, for example: "
            "pip install -r requirements.txt"
        ) from exc
    return cv2


def format_clock(seconds: float) -> str:
    total_seconds = max(0, int(seconds))
    hours, remainder = divmod(total_seconds, 3600)
    minutes, secs = divmod(remainder, 60)
    return f"{hours:02d}h{minutes:02d}m{secs:02d}s"


def build_output_name(video_stem: str, timestamp_seconds: float, capture_index: int, extension: str) -> str:
    return f"{video_stem}_{format_clock(timestamp_seconds)}_{capture_index:04d}.{extension}"


def write_image(output_path: Path, frame, image_extension: str, cv2_module) -> None:
    suffix = ".jpg" if image_extension == "jpg" else ".png"
    ok, encoded = cv2_module.imencode(suffix, frame)
    if not ok:
        raise RuntimeError(f"Failed to encode frame image: {output_path}")
    output_path.write_bytes(encoded.tobytes())


def calculate_similarity(frame_a, frame_b, cv2_module) -> float:
    resize_size = (320, 180)
    frame_a_resized = cv2_module.resize(frame_a, resize_size)
    frame_b_resized = cv2_module.resize(frame_b, resize_size)
    gray_a = cv2_module.cvtColor(frame_a_resized, cv2_module.COLOR_BGR2GRAY)
    gray_b = cv2_module.cvtColor(frame_b_resized, cv2_module.COLOR_BGR2GRAY)
    diff = cv2_module.absdiff(gray_a, gray_b)
    mean_diff = float(diff.mean())
    similarity = 1.0 - (mean_diff / 255.0)
    return max(0.0, min(1.0, similarity))


def write_result_json(output_dir: Path, saved_frames: list[SavedFrame]) -> Path:
    result_path = output_dir / "result.json"
    payload = [
        {
            "file_index": frame.index,
            "file_path": str(frame.path),
        }
        for frame in saved_frames
    ]
    result_path.write_text(
        json.dumps(payload, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    return result_path


def capture_frames(
    input_path: Path,
    output_dir: Path,
    interval_seconds: float,
    skip_similar_frames: bool,
    similarity_threshold: float,
    image_extension: str,
) -> tuple[CaptureStats, list[SavedFrame]]:
    cv2 = load_cv2()
    capture = cv2.VideoCapture(str(input_path))
    if not capture.isOpened():
        raise RuntimeError(f"Failed to open video file: {input_path}")

    fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0)
    total_frames = float(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0)
    duration_seconds = (total_frames / fps) if fps > 0 and total_frames > 0 else 0.0
    if duration_seconds <= 0:
        capture.release()
        raise RuntimeError("Failed to read video duration from the file.")

    log(f"Input video: {input_path}")
    log(f"Output directory: {output_dir}")
    log(f"Interval seconds: {interval_seconds}")
    log(f"Skip similar frames: {skip_similar_frames}")
    if skip_similar_frames:
        log(f"Similarity threshold: {similarity_threshold:.0%}")
    log(f"Video duration: {format_clock(duration_seconds)}")

    stats = CaptureStats()
    saved_frames: list[SavedFrame] = []
    timestamp_seconds = 0.0
    previous_saved_frame = None
    video_stem = input_path.stem

    try:
        while timestamp_seconds <= duration_seconds:
            stats.attempted += 1
            progress = min(100, int((timestamp_seconds / duration_seconds) * 100))
            log(
                f"Progress {progress:3d}% | seeking {format_clock(timestamp_seconds)} "
                f"| capture #{stats.attempted}"
            )

            capture.set(cv2.CAP_PROP_POS_MSEC, timestamp_seconds * 1000)
            ok, frame = capture.read()
            if not ok or frame is None:
                log(f"Reached unreadable position at {format_clock(timestamp_seconds)}, stopping.")
                break

            if skip_similar_frames and previous_saved_frame is not None:
                similarity = calculate_similarity(previous_saved_frame, frame, cv2)
                if similarity > similarity_threshold:
                    stats.skipped_similar += 1
                    log(
                        f"Skipped {format_clock(timestamp_seconds)} because similarity "
                        f"{similarity:.2%} > {similarity_threshold:.0%}"
                    )
                    timestamp_seconds += interval_seconds
                    continue

            output_name = build_output_name(video_stem, timestamp_seconds, stats.attempted, image_extension)
            output_path = output_dir / output_name
            write_image(output_path, frame, image_extension, cv2)

            previous_saved_frame = frame
            stats.saved += 1
            saved_frames.append(SavedFrame(index=stats.saved, path=output_path))
            log(f"Saved frame: {output_path.name}")
            timestamp_seconds += interval_seconds
    finally:
        capture.release()

    log(
        f"Completed. attempted={stats.attempted}, saved={stats.saved}, "
        f"skipped_similar={stats.skipped_similar}"
    )
    return stats, saved_frames


def main() -> int:
    args = parse_args()

    try:
        input_path, output_dir = validate_args(args)
        _, saved_frames = capture_frames(
            input_path=input_path,
            output_dir=output_dir,
            interval_seconds=args.interval_seconds,
            skip_similar_frames=args.skip_similar_frames,
            similarity_threshold=args.similarity_threshold,
            image_extension=args.image_extension,
        )
        result_path = write_result_json(output_dir, saved_frames)
        log(f"Saved result manifest: {result_path}")
    except Exception as exc:
        return fail(str(exc))

    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/video_to_text.py
content: from __future__ import annotations

import argparse
import importlib
import subprocess
import sys
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Iterable

REQUIRED_PACKAGES = {
    "faster_whisper": "faster-whisper",
    "opencc": "opencc-python-reimplemented",
}


def check_dependencies() -> list[str]:
    """Return list of missing required package names."""
    missing = []
    for module_name, pip_name in REQUIRED_PACKAGES.items():
        try:
            importlib.import_module(module_name)
        except ImportError:
            missing.append(pip_name)
    return missing


_missing = check_dependencies()
if _missing:
    print(
        f"ERROR: Missing required dependencies: {', '.join(_missing)}\n"
        f"Install them with:  pip install {' '.join(_missing)}",
        file=sys.stderr,
    )
    raise SystemExit(1)

# OpenClaw 技能版本
SUPPORTED_INPUT_SUFFIXES = {
    ".mp4",
    ".mkv",
    ".mov",
    ".avi",
    ".flv",
    ".wmv",
    ".webm",
    ".m4v",
    ".mp3",
    ".wav",
    ".m4a",
    ".aac",
    ".flac",
    ".ogg",
}
SUPPORTED_OUTPUT_SUFFIXES = {".srt", ".txt"}

DEFAULT_MODEL_SIZE = "base"
DEFAULT_DEVICE = "cpu"
DEFAULT_LANGUAGE = "zh"
DEFAULT_BEAM_SIZE = 2


@dataclass(slots=True)
class TranscriptSegment:
    index: int
    start: float
    end: float
    text: str


class ProgressReporter:
    def __init__(self, total_duration: float | None) -> None:
        self.total_duration = total_duration if total_duration and total_duration > 0 else None
        self.last_percent = -1
        self.last_segment_count = 0

    def update(self, current_end: float, segment_count: int) -> None:
        if self.total_duration is not None:
            percent = min(100, int((current_end / self.total_duration) * 100))
            if percent <= self.last_percent and segment_count == self.last_segment_count:
                return
            if percent == self.last_percent and percent not in (0, 100):
                return

            self.last_percent = percent
            self.last_segment_count = segment_count
            bar_width = 24
            filled = int((percent / 100) * bar_width)
            bar = "#" * filled + "-" * (bar_width - filled)
            sys.stdout.write(
                "\r"
                f"[transcribe] [{bar}] {percent:3d}% "
                f"({format_clock(current_end)} / {format_clock(self.total_duration)}) "
                f"segments={segment_count}"
            )
            sys.stdout.flush()

            # 每 10% 打印一行独立日志，方便后台追踪
            if percent % 10 == 0 and percent != self.last_percent:
                print(f"\n[progress] {percent}% done, {segment_count} segments so far")
            return

        if segment_count >= self.last_segment_count + 10:
            self.last_segment_count = segment_count
            print(
                f"[progress] processed {segment_count} segments, current position "
                f"{format_clock(current_end)}"
            )

    def finish(self) -> None:
        if self.total_duration is not None:
            sys.stdout.write("\n")
            sys.stdout.flush()


def log(message: str) -> None:
    print(f"[video_to_text] {message}")


def get_media_duration(file_path: Path) -> float | None:
    """使用 ffprobe 获取媒体文件时长（秒）"""
    try:
        from imageio_ffmpeg import get_ffmpeg_exe
        ffprobe = get_ffmpeg_exe().replace("ffmpeg", "ffprobe")
    except Exception:
        ffprobe = "ffprobe"

    cmd = [
        ffprobe,
        "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        str(file_path),
    ]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        if result.returncode == 0 and result.stdout.strip():
            return float(result.stdout.strip())
    except Exception:
        pass
    return None


def resolve_beam_size(beam_size_arg: str | int) -> int:
    """解析 beam-size 参数，默认使用 2"""
    # 如果用户指定了具体数字，直接使用
    if isinstance(beam_size_arg, int):
        return beam_size_arg

    # 尝试解析为数字
    try:
        return int(beam_size_arg)
    except (ValueError, TypeError):
        pass

    # 默认使用 2
    log(f"Using default beam-size={DEFAULT_BEAM_SIZE}")
    return DEFAULT_BEAM_SIZE


def parse_args() -> argparse.Namespace:
    script_dir = Path(__file__).resolve().parent
    default_model_dir = script_dir / "models"

    parser = argparse.ArgumentParser(
        description="Transcribe a local video/audio file into SRT and TXT using faster-whisper."
    )
    parser.add_argument("--input", required=True, help="Path to the local video/audio file.")
    parser.add_argument(
        "--output-dir",
        help="Directory for output files. Defaults to the input file directory.",
    )
    parser.add_argument(
        "--output-path",
        help=(
            "Base output path for the generated files. Example: "
            "D:\\out\\result or D:\\out\\result.srt. The script will generate "
            "D:\\out\\result.srt and D:\\out\\result.txt."
        ),
    )
    parser.add_argument(
        "--model-dir",
        default=str(default_model_dir),
        help=f"Directory for whisper model downloads. Defaults to {default_model_dir}.",
    )
    parser.add_argument(
        "--model-size",
        default=DEFAULT_MODEL_SIZE,
        help="Whisper model size or local model path. Defaults to 'base'.",
    )
    parser.add_argument(
        "--language",
        choices=("auto", "zh", "en"),
        default=DEFAULT_LANGUAGE,
        help="Recognition language. Defaults to zh.",
    )
    parser.add_argument(
        "--device",
        choices=("cpu", "cuda"),
        default=DEFAULT_DEVICE,
        help="Inference device. Defaults to cpu.",
    )
    parser.add_argument(
        "--compute-type",
        default="int8",
        help="faster-whisper compute_type. Defaults to int8 for CPU-friendly inference.",
    )
    parser.add_argument(
        "--beam-size",
        default=str(DEFAULT_BEAM_SIZE),
        help="Beam size for decoding. Defaults to 2.",
    )
    parser.add_argument(
        "--no-vad-filter",
        action="store_true",
        help="Disable VAD filtering. VAD is enabled by default to reduce non-speech noise.",
    )
    return parser.parse_args()


def fail(message: str, exit_code: int = 1) -> int:
    print(f"ERROR: {message}", file=sys.stderr)
    return exit_code


def validate_input_path(input_path: Path) -> None:
    if not input_path.exists():
        raise FileNotFoundError(f"Input file does not exist: {input_path}")
    if not input_path.is_file():
        raise ValueError(f"Input path is not a file: {input_path}")
    if input_path.suffix.lower() not in SUPPORTED_INPUT_SUFFIXES:
        supported = ", ".join(sorted(SUPPORTED_INPUT_SUFFIXES))
        raise ValueError(
            f"Unsupported input file type '{input_path.suffix}'. Supported types: {supported}"
        )


def build_output_paths(
    input_path: Path,
    output_dir: Path | None,
    output_path: Path | None,
) -> tuple[Path, Path]:
    if output_path is not None:
        target_parent = output_path.parent
        target_parent.mkdir(parents=True, exist_ok=True)
        base_name = (
            output_path.stem if output_path.suffix.lower() in SUPPORTED_OUTPUT_SUFFIXES else output_path.name
        )
        return target_parent / f"{base_name}.srt", target_parent / f"{base_name}.txt"

    target_dir = output_dir if output_dir is not None else input_path.parent
    target_dir.mkdir(parents=True, exist_ok=True)
    stem = input_path.stem
    return target_dir / f"{stem}.srt", target_dir / f"{stem}.txt"


def format_srt_timestamp(seconds: float) -> str:
    total_milliseconds = max(0, int(round(seconds * 1000)))
    hours, remainder = divmod(total_milliseconds, 3_600_000)
    minutes, remainder = divmod(remainder, 60_000)
    secs, milliseconds = divmod(remainder, 1000)
    return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}"


def format_clock(seconds: float) -> str:
    total_seconds = max(0, int(seconds))
    hours, remainder = divmod(total_seconds, 3600)
    minutes, secs = divmod(remainder, 60)
    if hours:
        return f"{hours:02d}:{minutes:02d}:{secs:02d}"
    return f"{minutes:02d}:{secs:02d}"


def normalize_text(text: str) -> str:
    return " ".join(text.strip().split())


@lru_cache(maxsize=1)
def get_opencc():
    try:
        from opencc import OpenCC
    except ImportError as exc:
        raise RuntimeError(
            "Missing dependency 'opencc'. Install dependencies first, for example: "
            "pip install -r requirements.txt"
        ) from exc

    return OpenCC("t2s")


def convert_chinese_text(text: str, language: str) -> str:
    if language != "zh":
        return text
    return get_opencc().convert(text)


def to_transcript_segment(raw_segment: object, language: str, index: int) -> TranscriptSegment | None:
    text = normalize_text(getattr(raw_segment, "text", ""))
    if not text:
        return None
    text = convert_chinese_text(text, language)

    start = float(getattr(raw_segment, "start", 0.0))
    end = float(getattr(raw_segment, "end", start))
    if end < start:
        end = start

    return TranscriptSegment(index=index, start=start, end=end, text=text)


def render_srt(segments: list[TranscriptSegment]) -> str:
    blocks: list[str] = []
    for segment in segments:
        blocks.append(
            "\n".join(
                [
                    str(segment.index),
                    f"{format_srt_timestamp(segment.start)} --> {format_srt_timestamp(segment.end)}",
                    segment.text,
                ]
            )
        )

    return "\n\n".join(blocks) + ("\n" if blocks else "")


def render_txt(segments: list[TranscriptSegment]) -> str:
    if not segments:
        return ""
    return "\n".join(segment.text for segment in segments) + "\n"


def load_whisper_model(model_size: str, model_dir: Path, device: str, compute_type: str):
    try:
        from faster_whisper import WhisperModel
    except ImportError as exc:
        raise RuntimeError(
            "Missing dependency 'faster-whisper'. Install dependencies first, for example: "
            "pip install -r requirements.txt"
        ) from exc

    model_dir.mkdir(parents=True, exist_ok=True)

    return WhisperModel(
        model_size,
        device=device,
        compute_type=compute_type,
        download_root=str(model_dir),
    )


def transcribe_file(
    input_path: Path,
    model_size: str,
    model_dir: Path,
    language: str,
    device: str,
    compute_type: str,
    beam_size: int,
    vad_filter: bool,
) -> tuple[list[TranscriptSegment], object]:
    log(f"Loading model '{model_size}' from {model_dir}")
    model = load_whisper_model(
        model_size=model_size,
        model_dir=model_dir,
        device=device,
        compute_type=compute_type,
    )

    log(f"Starting transcription on {input_path.name}")
    language_arg = None if language == "auto" else language
    raw_segments, info = model.transcribe(
        str(input_path),
        task="transcribe",
        language=language_arg,
        beam_size=beam_size,
        vad_filter=vad_filter,
    )

    output_language = language if language != "auto" else getattr(info, "language", "auto")
    total_duration = getattr(info, "duration", None)
    if isinstance(total_duration, (int, float)):
        log(f"Detected media duration: {format_clock(float(total_duration))}")

    segments: list[TranscriptSegment] = []
    progress = ProgressReporter(float(total_duration) if isinstance(total_duration, (int, float)) else None)

    for raw_segment in raw_segments:
        segment = to_transcript_segment(raw_segment, output_language, len(segments) + 1)
        if segment is None:
            continue
        segments.append(segment)
        progress.update(segment.end, len(segments))

    progress.finish()
    log(f"Transcription completed with {len(segments)} valid segments")
    return segments, info


def main() -> int:
    args = parse_args()

    if args.output_dir and args.output_path:
        return fail("Use either --output-dir or --output-path, not both.")

    input_path = Path(args.input).expanduser().resolve()
    output_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else None
    output_path = Path(args.output_path).expanduser().resolve() if args.output_path else None
    model_dir = Path(args.model_dir).expanduser().resolve()

    try:
        log(f"Validating input file: {input_path}")
        validate_input_path(input_path)
        srt_path, txt_path = build_output_paths(input_path, output_dir, output_path)
        log(f"Planned SRT output: {srt_path}")
        log(f"Planned TXT output: {txt_path}")

        # 获取视频时长（仅用于日志）
        duration = get_media_duration(input_path)
        if duration:
            log(f"Media duration: {format_clock(duration)}")
        beam_size = resolve_beam_size(args.beam_size)

        segments, info = transcribe_file(
            input_path=input_path,
            model_size=args.model_size,
            model_dir=model_dir,
            language=args.language,
            device=args.device,
            compute_type=args.compute_type,
            beam_size=beam_size,
            vad_filter=not args.no_vad_filter,
        )
    except Exception as exc:
        return fail(str(exc))

    log("Writing output files")
    srt_path.write_text(render_srt(segments), encoding="utf-8-sig")
    txt_path.write_text(render_txt(segments), encoding="utf-8-sig")

    detected_language = getattr(info, "language", "unknown")
    language_probability = getattr(info, "language_probability", None)
    probability_text = (
        f"{language_probability:.4f}" if isinstance(language_probability, (float, int)) else "n/a"
    )

    print(f"Input: {input_path}")
    print(f"SRT output: {srt_path}")
    print(f"TXT output: {txt_path}")
    print(f"Segments: {len(segments)}")
    print(f"Detected language: {detected_language}")
    print(f"Language confidence: {probability_text}")

    if not segments:
        print("No valid speech segments were recognized. Empty output files were generated.")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: _meta.json
content: ownerId: kn7daqywb4t90jb93nfwtyw2xh83b098
slug: maple-video-article
version: 1.0.4
publishedAt: 1774436605147

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)