
Whiteboard Video Workflow
- 335 installs
- 68 repo stars
- Updated June 14, 2026
- yangagent/whiteboard-animation-skill
Produce whiteboard explainer videos end-to-end—script, scene boards, voiceover timing, and export—for product marketing and educational distribution channels.
About
Whiteboard-video-workflow skill defines an end-to-end pipeline for creating explainer animations: script structuring, scene boarding, whiteboard visual style, voiceover timing, and export formats suited for product marketing, tutorials, and social distribution campaigns.
- Script-to-scene breakdown
- Whiteboard visual sequencing
- Voiceover and timing sync
- Brand-consistent illustration style
- Export for social and web
Whiteboard Video Workflow by the numbers
- 335 all-time installs (skills.sh)
- +19 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #488 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yangagent/whiteboard-animation-skill --skill whiteboard-video-workflowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 335 |
|---|---|
| repo stars | ★ 68 |
| Last updated | June 14, 2026 |
| Repository | yangagent/whiteboard-animation-skill ↗ |
What it does
Produce whiteboard explainer videos end-to-end—script, scene boards, voiceover timing, and export—for product marketing and educational distribution channels.
Files
Whiteboard Video Workflow
从 SRT 字幕文件到完整白板动画视频片段的自动化工作流。
输入参数
| 参数 | 必填 | 说明 |
|---|---|---|
srtPath | 是 | SRT 字幕文件的绝对路径 |
outputDir | 否 | 输出根目录,默认为 SRT 文件所在目录 |
工作流步骤
整个流程分为 10 步,必须严格按顺序执行。步骤 3、5、7 使用 subagent,其余步骤由主 agent 直接执行。
步骤 0: 环境预检
运行本 skill 的 scripts/check_env.py,一次性检查所有依赖(Python 虚拟环境、RUNNINGHUB_API_KEY):
python3 <skill-dir>/scripts/check_env.py- 成功(退出码 0):从输出中捕获
PYTHON_PATH=<路径>,记录该路径用于步骤 7,继续 - 失败(退出码 1):从输出的
ENV_RESULT=...JSON 中解析各项检查结果,向用户展示清晰易懂的错误说明和修复指引(见下方故障排查指引),终止工作流
注意:脚本会自动检测并安装可修复的依赖,只将无法自动修复的问题报告给大模型。
故障排查指引
当环境预检失败时,必须向用户清晰解释原因并给出具体修复步骤。根据失败项不同,给出对应说明:
RUNNINGHUB_API_KEY 未配置:
白板动画视频生成需要 RunningHub API 来调用 AI 模型先生成图片。你需要在 skill 目录下的 .env 文件中配置 API Key。>
修复方法:
1. 在<skill-dir>/.env文件中添加一行:RUNNINGHUB_API_KEY=你的API密钥
2. 如果文件不存在,先创建它
3. API Key 可在 RunningHub 获取
4. 配置完成后重新运行即可
Python 依赖安装失败:
白板动画依赖 OpenCV、NumPy、PyAV 等 Python 库来处理视频。自动安装未能成功。
>
修复方法:
1. 请确认你的 Python 版本为 3.9 或更高版本
2. 然后重新运行本工作流,脚本会再次尝试自动安装
多项检查同时失败时,逐条列出每个失败项及对应修复方法,便于用户一次性解决所有问题。
步骤 1: 确定输出目录
- 如果用户未指定
outputDir,则使用srtPath所在目录作为输出根目录 - 将
outputDir转换为绝对路径
步骤 2: 创建输出目录结构
运行本 skill 的 scripts/workflow_helper.py:
python3 <skill-dir>/scripts/workflow_helper.py init-dirs "<outputDir>"输出 JSON 含 storyboardDir、imageDir、videoDir 三个绝对路径,保存备用。
步骤 3: 解析 SRT 生成分镜脚本(subagent)
启动一个 subagent,指令为:
使用 Read 工具读取文件 <将本 skill 目录替换为实际绝对路径>/references/storyboard-parser.md,按照其中的工作流步骤执行。>
输入参数:
- srtPath = <将srtPath替换为实际绝对路径>- projectRoot = <将storyboardDir替换为实际绝对路径>- skill-dir = <将本 skill 目录替换为实际绝对路径>(用于定位脚本)>
完成后返回 storyboard.json 的绝对路径和场景数量。
>
注意:主 agent 必须将实际路径值填入指令中,不要传递变量名,subagent 无法访问主 agent 的上下文。
必须等待 subagent 完成并获取 storyboard.json 路径后才继续。
步骤 4: 解析 storyboard 生成图片提示词
运行本 skill 的 scripts/workflow_helper.py:
python3 <skill-dir>/scripts/workflow_helper.py gen-prompts "<storyboardJsonPath>"输出一个 JSON 字符串数组,每个元素是一个带白板风格前缀的图片生成提示词,数组索引与 storyboard 中的 scenes 顺序一一对应。
同时从 storyboard.json 中提取每个 scene 的 duration 值(毫秒),按顺序记录为数组备用。
步骤 5: 批量生成白板图片(subagent)
启动一个 subagent,指令为:
使用 Read 工具读取文件 <将本 skill 目录替换为实际绝对路径>/references/image-generator.md,按照其中的工作流步骤执行。>
使用批量模式,将以下 JSON 字符串数组作为 prompt 参数传入:
<将步骤4输出的提示词JSON数组的实际内容粘贴于此>>
参数:
- skill-dir = <将本 skill 目录替换为实际绝对路径>(用于定位脚本)- 输出目录 = <将imageDir替换为实际绝对路径>- 宽高比 = "16:9"
>
注意:主 agent 必须将实际提示词内容和路径值填入指令中,不要传递变量名,subagent 无法访问主 agent 的上下文。
>
重要: 返回所有生成图片的路径列表,顺序必须与提示词数组顺序一致。
必须等待 subagent 完成并获取所有图片路径后才继续。
步骤 6: 校验图片顺序
确认步骤 5 返回的图片路径数组长度与 storyboard 的 scenes 数量一致,顺序正确(第 i 张图片对应第 i 个 scene)。
步骤 7: 批量生成白板动画视频片段(subagent)
启动一个 subagent,指令为:
调用 whiteboard-animation skill,使用批量模式。跳过环境预检(主 agent 已确认环境就绪),直接运行批量生成脚本:
>
```
<将PYTHON_PATH替换为步骤0获取的实际值> <将whiteboard-animation skill目录替换为实际路径>/scripts/batch_generate.py \
--images <imagePaths[0]> <imagePaths[1]> ... \
--durations <durations[0]> <durations[1]> ... \
--output-dir <videoDir绝对路径>
```
>
主 agent 必须将 `PYTHON_PATH` 和 skill 目录的实际绝对路径填入指令中,不要传递变量名,subagent 无法访问主 agent 的上下文。
>
参数:
- --images:按分镜顺序排列的图片路径列表(空格分隔)- --durations:与图片一一对应的时长列表(单位:毫秒,直接使用 storyboard 中的 duration 值,无需转换)---output-dir:<videoDir绝对路径>
>
重要: 完成后,收集 <videoDir> 目录下所有生成的视频文件路径,按文件名时间戳排序,将完整的视频路径列表返回给主 agent。顺序必须与输入图片顺序一致。必须等待 subagent 完成并获取所有视频路径后才继续。
步骤 8: 合并视频片段
运行本 skill 的 scripts/workflow_helper.py,将所有视频片段按顺序合并为一个完整视频。必须使用步骤 0 获取的 `PYTHON_PATH`(PyAV 依赖在虚拟环境中):
<PYTHON_PATH> <skill-dir>/scripts/workflow_helper.py merge-videos "<outputDir>" <videoPath1> <videoPath2> ...- 第一个参数为输出目录(合并后的视频保存在此目录)
- 后续参数为按分镜顺序排列的视频片段路径
输出 JSON 含 mergedVideo(合并后的视频绝对路径)、totalSegments、sizeMB。
步骤 9: 输出结果
输出最终结果,包含合并后的完整视频路径和所有片段信息:
输出格式示例:
{
"mergedVideo": "/path/to/output/白板视频_20260329_120000.mp4",
"videoSegments": [
"/path/to/video/vid_20260329_120000_h264.mp4",
"/path/to/video/vid_20260329_120010_h264.mp4"
],
"totalSegments": 2,
"sizeMB": 15.3,
"outputDir": "/path/to/output"
}关键约束
- 步骤 0 必须在任何工作开始前执行,
check_env.py脚本会自动检测并安装可修复的依赖,只将无法修复的问题报告给大模型 - 步骤 3、5、7 必须使用 subagent 执行,主 agent 等待结果
- 步骤 0 获取的
PYTHON_PATH必须传递给步骤 7 的 subagent 使用,也用于步骤 8 的视频合并(PyAV 依赖在虚拟环境中),避免重复环境检查 - 步骤 7 使用 whiteboard-animation 的批量模式(batch_generate.py),由 subagent 一次传入所有图片和时长,脚本内部串行生成,subagent 完成后返回所有视频路径
- 图片和视频的顺序必须与 storyboard 的 scenes 顺序严格对应
- duration 贯穿全链路使用毫秒,从 storyboard 到 batch_generate.py 再到 generate_whiteboard.py 统一为毫秒,避免浮点转换丢失精度
- 步骤 8 使用 PyAV(Python 库)合并视频片段,通过 H.264 重新编码输出统一格式的最终视频
Resources
references/
storyboard-parser.md- SRT 分镜解析工作流指令,由步骤 3 的 subagent 读取执行image-generator.md- 图片生成工作流指令,由步骤 5 的 subagent 读取执行
scripts/
check_env.py- 一次性环境预检,检查 Python 虚拟环境、API Key,自动安装可修复的依赖workflow_helper.py- 提供init-dirs(创建目录结构)、gen-prompts(解析 storyboard 生成提示词)、merge-videos(合并视频片段)三个子命令generate-storyboard.py- 解析 SRT + groups.json 生成 storyboard.jsongenerate-image.py- 模型文生图,支持单张和批量并发模式prompt_template.py- 白板风格提示词模板
RUNNINGHUB_API_KEY=your_api_key_here
图片生成器
前置条件
必须设置环境变量 RUNNINGHUB_API_KEY。如果未设置,提示用户提供或导出该变量。
用法
运行内置脚本:
python3 <skill-dir>/scripts/generate-image.py "<提示词>" "<宽高比>" "<输出目录>"注意:<skill-dir> 是 whiteboard-video-workflow skill 的绝对路径,由主 agent 在 subagent 指令中提供。
参数: 1. prompt(必填)— 图片生成提示词。支持两种模式:
- 单张模式:传入普通字符串,如
"一只猫坐在窗台上"。 - 批量模式:传入 JSON 编码的字符串数组,如
'["提示词1","提示词2","提示词3"]'。每个数组元素对应一张图片,脚本会以 10 个并发同时生成。
2. aspect-ratio(可选,默认值:"16:9")— 图片宽高比(如 "1:1"、"9:16"、"16:9"、"4:3")。 3. output-dir(可选,默认值:当前工作目录)— 生成图片的保存目录。
示例:
单张生成:
python3 <skill-dir>/scripts/generate-image.py "一只猫坐在窗台上,夕阳西下" "16:9" "./output"批量生成:
python3 <skill-dir>/scripts/generate-image.py '["一只猫坐在窗台上","一只狗在草地上奔跑","日落时分的海边"]' "16:9" "./output"工作流程
1. 验证 prompt 不为空。如果缺失,询问用户。 2. 检测 prompt 是否为 JSON 数组格式,自动区分单张/批量模式。 3. 使用三个参数运行 scripts/generate-image.py。 4. 脚本会自动处理:
- 提交文生图请求
- 轮询任务状态(处理 QUEUED/RUNNING/SUCCESS/FAILED 状态)
- 提交失败和 FAILED 状态时自动重试(最多 3 次,间隔 3 秒)
- 下载结果图片,文件名基于时间戳命名(批量模式下文件名会附加序号后缀)
- 批量模式:以 10 个并发 worker 同时执行生成任务
5. 向用户报告保存的文件路径。
批量模式说明
- 当
prompt参数是 JSON 字符串数组时自动进入批量模式 - 并发数固定为 10,即同时最多运行 10 个生成任务
- 每张图片独立处理,单张失败不影响其他图片
- 输出文件名格式:
img_<timestamp>_<序号>.<ext>(如img_1714700000000_01.jpg) - 执行结束后会输出汇总信息:成功数和失败数
- 脚本输出的最后一行以
__RESULTS__前缀加上 JSON 数组,包含每张图片的保存路径或错误信息
资源文件
scripts/generate-image.py— 独立的 Python 脚本,处理完整的生成-轮询-下载流程,支持单张和批量并发模式
Storyboard Parser
将 SRT 字幕文件按语义分组,生成图片分镜脚本。
工作流程概览
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 读取 SRT 文件 │ --> │ AI 语义分组 │ --> │ JS 生成 │
│ │ │ + 验证连续性 │ │ storyboard.json│
└─────────────────┘ └─────────────────┘ └─────────────────┘职责分离:
- AI 负责:语义理解、分组判断、添加标签
- JS 负责:时间计算、数据生成(100% 精确)
目录定位(必须首先执行)
禁止事项:
- ❌ 禁止猜测项目路径
- ❌ 禁止在工具调用中使用相对路径
正确做法:
- ✅ 过程中生成的所有文件都保存在
{projectRoot}绝对路径的文件夹下
输入
srtPath(必传): SRT 文件的绝对路径。如果未提供则直接报错终止,不允许继续执行。projectRoot(可选): 项目根目录路径,所有输出文件将保存在此目录下。如果未传入,则使用srtPath所在的目录作为projectRoot(即srtPath的父目录)。
输出
1. 中间文件 {projectRoot}/groups.json(AI 生成) 2. 最终文件 {projectRoot}/storyboard.json(JS 脚本生成) 3. 返回结构:
{
"storyboardPath": "/path/to/storyboard.json",
"sceneCount": 17
}---
步骤 1: 读取并分析 SRT 文件
使用 Read 工具读取 SRT 文件内容。
SRT 格式示例:
1
00:00:04,900 --> 00:00:07,000
下次展示作品或者讲方案时
2
00:00:07,000 --> 00:00:08,500
想让全场眼前一亮
3
00:00:08,500 --> 00:00:10,333
可以试试这样的动画演示稿记录 SRT 总条数:统计字幕序号的最大值,后续验证需要用到。
---
步骤 2: 语义分组
根据以下规则将连续的字幕分组为场景:
分组原则(按优先级)
1. 语义完整性 - 不在句子中间切分,保持意思完整 2. 主题一致性 - 同一概念/话题的内容合并在一起 3. 时长控制 - 优先依据 SRT 的真实时间跨度分组,单场景尽量保持在 10-15 秒左右 4. 自然停顿 - 利用话题转换作为场景边界
时长控制细则
- 使用该组首条字幕的开始时间和末条字幕的结束时间,估算该组的实际时长
- 目标区间:每组尽量控制在 10-15 秒
- 可接受偏差:如果为了保持语义完整、避免在句子中间切分,可以略微短于 10 秒或长于 15 秒,但不要无理由偏离太多
- 过短处理:如果某组明显短于 10 秒,且与前后场景主题连续、合并后仍然自然,优先与相邻场景合并
- 过长处理:如果某组明显超过 15 秒,优先在自然停顿、转折词、举例引入、总结句等位置拆分
- 不要为了机械满足时长而破坏语义完整性,时长控制服从语义完整性与主题一致性
分组信号(强 - 应该开始新场景)
- 出现转折词:"但是"、"然而"、"接下来"、"首先"、"其次"
- 出现总结词:"总之"、"所以"、"因此"
- 出现引入词:"比如"、"例如"、"举个例子"
- 话题明显切换
分组信号(弱 - 可以合并)
- 连续的列举项
- 同一句话被拆成多条字幕
- 问答对(问题和回答应在同一场景)
视觉提示
根据场景内容和上下文内容,为每个分组添加 visualHint 字段,作为视觉提示, 每个分组后续会根据内容生成图片,visualHint最主要的作用就是根据上下文,确定图片的基准视觉元素。 只描述画面中应该出现的视觉元素和构图,不要添加解释性说明。 例如: visualHint: "图片元素包含..."
---
步骤 3: 输出 groups.json
生成的 JSON 格式:
{
"groups": [
{
"sceneId": "scene_001",
"fromIndex": 1,
"toIndex": 3,
"visualHint": "图片元素包含..."
},
{
"sceneId": "scene_002",
"fromIndex": 4,
"toIndex": 7,
"visualHint": "图片元素包含..."
}
]
}字段说明:
sceneId: 场景 ID,格式为scene_XXX(3位数字,从 001 开始)fromIndex: 该场景包含的起始字幕序号(含)toIndex: 该场景包含的结束字幕序号(含)visualHint: 视觉提示字符串
使用 Write 工具将 groups.json 写入 {projectRoot}/groups.json。
---
步骤 4: 验证分组连续性(必须执行)
在写入 groups.json 后,必须进行以下验证:
验证规则
1. 起始验证:第一个分组的 fromIndex 必须为 1 2. 连续性验证:每个分组的 fromIndex 必须等于上一个分组的 toIndex + 1 3. 结尾验证:最后一个分组的 toIndex 必须等于 SRT 总条数 4. sceneId 格式:必须是 scene_XXX,从 001 开始递增
自检方法
读取生成的 groups.json,逐项检查:
检查项 1: groups[0].fromIndex === 1 ?
检查项 2: 对于 i > 0,groups[i].fromIndex === groups[i-1].toIndex + 1 ?
检查项 3: groups[最后].toIndex === SRT总条数 ?
检查项 4: sceneId 是否连续 (scene_001, scene_002, ...)?如果验证失败:
- 输出错误信息
- 修正 groups.json
- 重新验证直到通过
---
步骤 5: 运行 Python 脚本生成 storyboard.json
验证通过后,执行以下命令:
python3 <skill-dir>/scripts/generate-storyboard.py \
"{srtPath}" \
"{projectRoot}/groups.json" \
"{projectRoot}/storyboard.json"注意:<skill-dir> 是 whiteboard-video-workflow skill 的绝对路径,由主 agent 在 subagent 指令中提供。
脚本会: 1. 解析 SRT 文件提取时间信息 2. 再次验证 groups.json 的完整性 3. 计算每个场景的 startTime、duration 4. 计算每个 segment 的 relativeStart、relativeDuration 5. 生成完整的 storyboard.json
脚本输出
成功时输出:
✅ 生成完成!
- 场景数量: 17
- 总时长: 136.5s
- 输出文件: /path/to/storyboard.json
__RESULT_JSON__
{"success":true,"storyboardPath":"/path/to/storyboard.json","sceneCount":17,"totalDuration":136466}失败时会输出错误信息并以非零状态码退出。
---
步骤 6: 返回结果
从脚本输出中提取结果,返回:
{
"storyboardPath": "{projectRoot}/storyboard.json",
"sceneCount": 17
}---
执行清单
1. [ ] 从 prompt 中提取 srtPath(必传,缺失则报错终止);提取 projectRoot(可选,未传入则取 srtPath 的父目录) 2. [ ] 读取 SRT 文件,记录总条数 3. [ ] 根据语义规则进行分组 4. [ ] 为每个分组添加 visualHint 5. [ ] 生成 groups.json 并写入文件 6. [ ] 验证分组连续性(起始为1、连续、结尾完整) 7. [ ] 如果验证失败,修正后重新验证 8. [ ] 运行 <skill-dir>/scripts/generate-storyboard.py 脚本 9. [ ] 确认脚本执行成功 10. [ ] 返回 { storyboardPath, sceneCount }
---
示例
输入 SRT(部分)
1
00:00:04,900 --> 00:00:07,000
下次展示作品或者讲方案时
2
00:00:07,000 --> 00:00:08,500
想让全场眼前一亮
3
00:00:08,500 --> 00:00:10,333
可以试试这样的动画演示稿
4
00:00:10,600 --> 00:00:11,233
当其他人
5
00:00:11,233 --> 00:00:13,433
还在一页页翻着静态幻灯片时
6
00:00:13,433 --> 00:00:14,700
你按下播放键
7
00:00:14,700 --> 00:00:16,533
所有目光都被你的画面吸引输出 groups.json
{
"groups": [
{
"sceneId": "scene_001",
"fromIndex": 1,
"toIndex": 3,
"visualHint": "图片元素包含..."
},
{
"sceneId": "scene_002",
"fromIndex": 4,
"toIndex": 7,
"visualHint": "图片元素包含..."
}
]
}验证检查
✅ groups[0].fromIndex = 1 (正确)
✅ groups[1].fromIndex = 4 = groups[0].toIndex(3) + 1 (连续)
✅ groups[最后].toIndex = 7 = SRT总条数 (假设只有7条)
✅ sceneId 连续: scene_001, scene_002最终 storyboard.json(由 JS 脚本生成)
{
"totalDuration": 11633,
"sceneCount": 2,
"scenes": [
{
"id": "scene_001",
"startTime": 4900,
"duration": 5433,
"segments": [
{
"text": "下次展示作品或者讲方案时",
"relativeStart": 0,
"relativeDuration": 2100
},
{
"text": "想让全场眼前一亮",
"relativeStart": 2100,
"relativeDuration": 1500
},
{
"text": "可以试试这样的动画演示稿",
"relativeStart": 3600,
"relativeDuration": 1833
}
],
"visualHint": "图片元素包含..."
},
{
"id": "scene_002",
"startTime": 10600,
"duration": 5933,
"segments": [
{
"text": "当其他人",
"relativeStart": 0,
"relativeDuration": 633
},
{
"text": "还在一页页翻着静态幻灯片时",
"relativeStart": 633,
"relativeDuration": 2200
},
{
"text": "你按下播放键",
"relativeStart": 2833,
"relativeDuration": 1267
},
{
"text": "所有目光都被你的画面吸引",
"relativeStart": 4100,
"relativeDuration": 1833
}
],
"visualHint": "图片元素包含..."
}
]
}#!/usr/bin/env python3
"""
Whiteboard Video Workflow - 环境预检脚本
一次性检查所有依赖:
1. Python 虚拟环境 + opencv/numpy/av(调用 setup_env.py)
2. RUNNINGHUB_API_KEY
用法:
python3 check_env.py # 检测并自动安装缺失依赖
python3 check_env.py --check-only # 仅检测,不安装
退出码:
0 - 全部就绪(最后一行输出 JSON 结果)
1 - 存在不可自动修复的问题
"""
import json
import subprocess
import sys
from pathlib import Path
# 各 skill 目录的相对路径(相对于本脚本所在目录)
SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
SKILLS_ROOT = SKILL_DIR.parent
ANIMATION_SKILL = SKILLS_ROOT / "whiteboard-animation"
IMAGE_GEN_SKILL = SKILL_DIR # .env 已迁入本 skill 根目录
def check_python_venv(check_only):
"""检查 Python 虚拟环境,必要时安装依赖"""
setup_script = ANIMATION_SKILL / "scripts" / "setup_env.py"
if not setup_script.exists():
return {"ok": False, "error": f"setup_env.py 不存在: {setup_script}"}
# 先检查
result = subprocess.run(
[sys.executable, str(setup_script), "--check"],
capture_output=True, text=True,
)
python_path = None
# 从输出中提取 PYTHON_PATH
for line in result.stdout.strip().splitlines():
if line.startswith("PYTHON_PATH="):
python_path = line.split("=", 1)[1]
if result.returncode == 0 and python_path:
return {"ok": True, "pythonPath": python_path}
# 检查失败,如果不是 check-only 则尝试安装
if not check_only:
print("[..] Python 依赖缺失,正在安装...")
result = subprocess.run(
[sys.executable, str(setup_script)],
capture_output=True, text=True,
)
if result.returncode == 0:
# 安装成功,再次检查
result2 = subprocess.run(
[sys.executable, str(setup_script), "--check"],
capture_output=True, text=True,
)
for line in result2.stdout.strip().splitlines():
if line.startswith("PYTHON_PATH="):
python_path = line.split("=", 1)[1]
if python_path:
return {"ok": True, "pythonPath": python_path}
return {"ok": False, "error": "Python 虚拟环境安装失败,请手动运行 setup_env.py"}
return {"ok": False, "error": "Python 虚拟环境未就绪,缺少依赖"}
def check_api_key():
"""检查 RUNNINGHUB_API_KEY"""
env_file = SKILL_DIR / ".env"
if not env_file.exists():
return {"ok": False, "error": f".env 文件不存在: {env_file},请创建并设置 RUNNINGHUB_API_KEY"}
content = env_file.read_text(encoding="utf-8")
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("RUNNINGHUB_API_KEY="):
value = stripped.split("=", 1)[1].strip().strip('"').strip("'")
if value:
return {"ok": True}
break
return {"ok": False, "error": f"RUNNINGHUB_API_KEY 未设置,请在 {env_file} 中设置"}
def main():
check_only = "--check-only" in sys.argv
results = {}
all_ok = True
# 1. Python 虚拟环境
print("[检查] Python 虚拟环境...")
results["python"] = check_python_venv(check_only)
if not results["python"]["ok"]:
all_ok = False
# 2. API Key
print("[检查] RUNNINGHUB_API_KEY...")
results["apiKey"] = check_api_key()
if not results["apiKey"]["ok"]:
all_ok = False
# 输出结果
output = {
"allOk": all_ok,
"checks": results,
}
if all_ok:
print(f"\n[OK] 所有环境检查通过")
print(f"PYTHON_PATH={results['python']['pythonPath']}")
else:
print(f"\n[失败] 部分检查未通过:")
for name, r in results.items():
status = "OK" if r["ok"] else f"失败 - {r.get('error', '未知错误')}"
print(f" {name}: {status}")
# 最后一行输出 JSON(供大模型解析)
print(f"\nENV_RESULT={json.dumps(output, ensure_ascii=False)}")
sys.exit(0 if all_ok else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
import asyncio
import json
import os
import random
import sys
import time
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import HTTPError
sys.path.insert(0, str(Path(__file__).resolve().parent))
from prompt_template import whiteboard_prompt_template
# --- Config ---
API_BASE = 'https://www.runninghub.cn/openapi/v2'
TEXT_TO_IMAGE_PATH = '/alibaba/wan-2.7/text-to-image'
QUERY_PATH = '/query'
MAX_RETRIES = 3
SUBMIT_MAX_RETRIES = 8
POLL_MAX_RETRIES = 5
RETRY_BASE_DELAY_S = 3.0
POLL_INTERVAL_S = 5.0
BATCH_CONCURRENCY = 10
SCRIPT_DIR = Path(__file__).resolve().parent
# --- Load .env from skill directory ---
def load_env():
if os.environ.get('RUNNINGHUB_API_KEY'):
return
env_path = SCRIPT_DIR.parent / '.env'
if env_path.exists():
for line in env_path.read_text(encoding='utf-8').splitlines():
trimmed = line.strip()
if not trimmed or trimmed.startswith('#'):
continue
eq_index = trimmed.find('=')
if eq_index == -1:
continue
key = trimmed[:eq_index].strip()
value = trimmed[eq_index + 1:].strip()
if key == 'RUNNINGHUB_API_KEY' and value:
os.environ['RUNNINGHUB_API_KEY'] = value
return
# --- Error classification ---
class RetryableError(Exception):
"""Errors worth retrying (rate-limit, server error, network)."""
def __init__(self, message, *, is_rate_limit=False):
super().__init__(message)
self.is_rate_limit = is_rate_limit
class FatalError(Exception):
"""Errors that should not be retried (bad request, auth, etc)."""
pass
def classify_error(e):
"""Return (retryable, is_rate_limit) for a given exception."""
msg = str(e).lower()
if isinstance(e, FatalError):
return False, False
if 'http 429' in msg or 'rate' in msg or 'too many' in msg:
return True, True
if 'http 5' in msg:
return True, False
# Default: treat as retryable network/transient error
return True, False
# --- HTTP helper (synchronous, used in thread) ---
def request_sync(method, url_path, body):
api_key = os.environ.get('RUNNINGHUB_API_KEY')
if not api_key:
raise FatalError('RUNNINGHUB_API_KEY not found. Set it in environment variable or .env file.')
url = API_BASE + url_path
payload = json.dumps(body).encode('utf-8')
req = Request(url, data=payload, method=method)
req.add_header('Content-Type', 'application/json')
req.add_header('Authorization', f'Bearer {api_key}')
try:
with urlopen(req, timeout=30) as resp:
data = resp.read().decode('utf-8')
return json.loads(data)
except HTTPError as e:
body_text = e.read().decode('utf-8', errors='replace')
if e.code == 400 or e.code == 401 or e.code == 403:
raise FatalError(f'HTTP {e.code}: {body_text}')
if e.code == 429:
raise RetryableError(f'HTTP 429 (rate limited): {body_text}', is_rate_limit=True)
# 5xx and other codes are retryable
raise RetryableError(f'HTTP {e.code}: {body_text}')
except json.JSONDecodeError as e:
raise RetryableError(f'Failed to parse response: {e}')
except Exception as e:
raise RetryableError(str(e))
# --- Retry wrapper with exponential backoff + jitter ---
def calc_backoff(attempt, base=RETRY_BASE_DELAY_S, is_rate_limit=False):
"""Exponential backoff with jitter. Rate-limit errors get 2x longer wait."""
multiplier = 2.0 if is_rate_limit else 1.0
delay = base * (2 ** (attempt - 1)) * multiplier
jitter = random.uniform(0.5, 1.5)
return delay * jitter
async def with_retry(fn, max_retries=MAX_RETRIES, context=''):
for attempt in range(1, max_retries + 1):
try:
return await fn()
except FatalError:
raise
except RetryableError as e:
if attempt == max_retries:
raise
delay = calc_backoff(attempt, is_rate_limit=e.is_rate_limit)
print(f'{context}Attempt {attempt}/{max_retries} failed: {e}. Retrying in {delay:.1f}s...')
await asyncio.sleep(delay)
except Exception as e:
retryable, is_rate_limit = classify_error(e)
if not retryable or attempt == max_retries:
raise
delay = calc_backoff(attempt, is_rate_limit=is_rate_limit)
print(f'{context}Attempt {attempt}/{max_retries} failed: {e}. Retrying in {delay:.1f}s...')
await asyncio.sleep(delay)
# --- Step 1: Submit text-to-image task ---
def image_size_for_aspect_ratio(aspect_ratio):
normalized = aspect_ratio.strip()
if normalized == '9:16':
return 1440, 2560
return 2560, 1440
def _submit_task_sync(prompt, aspect_ratio):
width, height = image_size_for_aspect_ratio(aspect_ratio)
res = request_sync('POST', TEXT_TO_IMAGE_PATH, {
'prompt': prompt,
'width': width,
'height': height,
'thinkingMode': True,
})
if not res.get('taskId'):
raise RetryableError(f'No taskId in response: {json.dumps(res)}')
return res['taskId']
async def submit_task(prompt, aspect_ratio, context=''):
async def _do():
task_id = await asyncio.to_thread(_submit_task_sync, prompt, aspect_ratio)
print(f'{context}Task submitted: {task_id}')
return task_id
return await with_retry(_do, max_retries=SUBMIT_MAX_RETRIES, context=context)
# --- Step 2: Poll for result ---
async def poll_result(task_id, context=''):
poll_errors = 0
while True:
try:
res = await asyncio.to_thread(
request_sync, 'POST', QUERY_PATH, {'taskId': task_id}
)
# Only consecutive poll errors count toward the retry limit.
poll_errors = 0
status = res.get('status')
if status == 'SUCCESS':
results = res.get('results')
if not results or len(results) == 0 or not results[0].get('url'):
raise RetryableError(f'SUCCESS but no image URL: {json.dumps(res)}')
return results[0]
if status == 'FAILED':
# Signal caller to re-submit; retry count is managed by generate_single
return {'_failed': True, 'res': res}
# QUEUED or RUNNING
print(f'{context}Status: {status}. Polling in {POLL_INTERVAL_S}s...')
await asyncio.sleep(POLL_INTERVAL_S)
except FatalError:
raise
except RetryableError as e:
poll_errors += 1
if poll_errors > POLL_MAX_RETRIES:
raise
delay = calc_backoff(poll_errors, is_rate_limit=e.is_rate_limit)
print(f'{context}Poll error (retry {poll_errors}/{POLL_MAX_RETRIES}): {e}. Waiting {delay:.1f}s...')
await asyncio.sleep(delay)
# --- Step 3: Download image ---
def download_file(url, dest_path):
"""Download file with redirect support"""
import shutil
from urllib.request import urlopen
with urlopen(url) as resp:
if resp.status >= 300 and resp.status < 400:
location = resp.headers.get('Location')
if location:
return download_file(location, dest_path)
if resp.status != 200:
raise RuntimeError(f'Download failed with status {resp.status}')
with open(dest_path, 'wb') as f:
shutil.copyfileobj(resp, f)
return dest_path
# --- Generate single image ---
async def generate_single(prompt, aspect_ratio, output_dir, index, total):
tag = f'[{index + 1}/{total}] ' if total > 1 else ''
submit_retries = 0
while submit_retries <= POLL_MAX_RETRIES:
task_id = await submit_task(prompt, aspect_ratio, context=tag)
result = await poll_result(task_id, context=tag)
if result.get('_failed'):
submit_retries += 1
if submit_retries > POLL_MAX_RETRIES:
raise RetryableError(f'{tag}Max retries exceeded for task submission.')
delay = calc_backoff(submit_retries)
print(f'{tag}Re-submitting task (attempt {submit_retries}/{POLL_MAX_RETRIES}) in {delay:.1f}s...')
await asyncio.sleep(delay)
continue
break
# Download with retry
ext = result.get('outputType', 'png')
timestamp = int(time.time() * 1000)
suffix = f'_{str(index + 1).zfill(len(str(total)))}' if total > 1 else ''
filename = f'img_{timestamp}{suffix}.{ext}'
filepath = str(Path(output_dir) / filename)
async def _download():
print(f'{tag}Downloading image to {filepath}...')
await asyncio.to_thread(download_file, result['url'], filepath)
print(f'{tag}Image saved: {filepath}')
return filepath
return await with_retry(_download, max_retries=MAX_RETRIES, context=tag)
# --- Batch runner with concurrency control + final retry for failures ---
async def run_batch(tasks, concurrency):
semaphore = asyncio.Semaphore(concurrency)
results = [None] * len(tasks)
async def worker(i, task):
async with semaphore:
try:
results[i] = await generate_single(
task['prompt'], task['aspectRatio'],
task['outputDir'], task['index'], task['total']
)
except Exception as e:
results[i] = {'error': str(e), 'task': task}
await asyncio.gather(*(worker(i, t) for i, t in enumerate(tasks)))
# Final retry pass: retry all failed tasks (with same concurrency limit)
failed_indices = [i for i, r in enumerate(results) if isinstance(r, dict) and r.get('error')]
if failed_indices:
print(f'\nRetrying {len(failed_indices)} failed tasks...')
await asyncio.sleep(RETRY_BASE_DELAY_S)
async def retry_worker(i):
async with semaphore:
task = results[i]['task']
try:
results[i] = await generate_single(
task['prompt'], task['aspectRatio'],
task['outputDir'], task['index'], task['total']
)
except Exception as e:
results[i] = {'error': str(e)}
await asyncio.gather(*(retry_worker(i) for i in failed_indices))
return results
# --- Main ---
async def main():
load_env()
args = sys.argv[1:]
prompt_arg = args[0] if len(args) > 0 else ''
aspect_ratio = args[1] if len(args) > 1 else '16:9'
output_dir = args[2] if len(args) > 2 else os.getcwd()
if not prompt_arg.strip():
print('Error: prompt is required and cannot be empty.')
sys.exit(1)
# Ensure output directory exists
Path(output_dir).mkdir(parents=True, exist_ok=True)
# Detect batch mode: prompt is a JSON-encoded array of strings
prompts = None
try:
parsed = json.loads(prompt_arg)
if isinstance(parsed, list) and len(parsed) > 0 and isinstance(parsed[0], str):
prompts = parsed
except (json.JSONDecodeError, ValueError):
pass
if not prompts:
prompts = [prompt_arg]
total = len(prompts)
is_batch = total > 1
if is_batch:
print(f'Batch mode: generating {total} images (concurrency: {BATCH_CONCURRENCY})...')
tasks = [
{
'prompt': whiteboard_prompt_template + prompt,
'aspectRatio': aspect_ratio,
'outputDir': output_dir,
'index': i,
'total': total,
}
for i, prompt in enumerate(prompts)
]
results = await run_batch(tasks, BATCH_CONCURRENCY)
# Summary
succeeded = [r for r in results if isinstance(r, str)]
failed = [r for r in results if isinstance(r, dict) and r.get('error')]
if is_batch:
print(f'\nBatch complete: {len(succeeded)} succeeded, {len(failed)} failed.')
if failed:
for f in failed:
print(f" Error: {f['error']}")
# Output results as JSON for programmatic use
print(f'\n__RESULTS__{json.dumps(results)}')
if __name__ == '__main__':
asyncio.run(main())
#!/usr/bin/env python3
"""
generate-storyboard.py
根据 SRT 文件和 AI 生成的分组信息,生成 storyboard.json
用法: python3 generate-storyboard.py <srtPath> <groupsPath> <outputPath>
输入:
- srtPath: SRT 字幕文件路径
- groupsPath: AI 生成的 groups.json 路径
- outputPath: 输出的 storyboard.json 路径
groups.json 格式:
{
"groups": [
{
"sceneId": "scene_001",
"fromIndex": 1,
"toIndex": 3,
"semanticTags": ["开场", "介绍"],
"visualHint": "大标题居中,配合动画演示稿相关图标"
},
...
]
}
"""
import json
import re
import sys
# ============ SRT 解析 ============
def parse_time_code(time_str):
"""解析时间码为毫秒"""
match = re.match(r'(\d{2}):(\d{2}):(\d{2})[,.](\d{3})', time_str.strip())
if not match:
raise ValueError(f'无效的时间码格式: {time_str}')
hours, minutes, seconds, ms = match.groups()
return (
int(hours) * 3600000 +
int(minutes) * 60000 +
int(seconds) * 1000 +
int(ms)
)
def parse_srt(srt_content):
"""解析 SRT 文件"""
subtitles = []
# 统一换行符,兼容 CRLF (\r\n) 和 LF (\n)
normalized = srt_content.replace('\r\n', '\n').replace('\r', '\n')
blocks = re.split(r'\n\s*\n', normalized.strip())
for block in blocks:
lines = block.strip().split('\n')
if len(lines) < 3:
continue
# 第一行: 序号
try:
index = int(lines[0].strip())
except ValueError:
continue
# 第二行: 时间码
time_line = lines[1].strip()
time_match = re.match(r'(.+?)\s*-->\s*(.+)', time_line)
if not time_match:
continue
start_ms = parse_time_code(time_match.group(1))
end_ms = parse_time_code(time_match.group(2))
# 第三行及以后: 文本
text = '\n'.join(lines[2:]).strip()
subtitles.append({'index': index, 'startMs': start_ms, 'endMs': end_ms, 'text': text})
# 按序号排序
subtitles.sort(key=lambda a: a['index'])
return subtitles
# ============ 分组验证 ============
def validate_groups(groups, total_subtitles):
"""验证分组数据的完整性和连续性"""
errors = []
if not groups:
errors.append('分组数据为空')
return {'valid': False, 'errors': errors}
# 检查第一个分组的 fromIndex 是否为 1
if groups[0]['fromIndex'] != 1:
errors.append(f"第一个分组的 fromIndex 必须为 1,实际为 {groups[0]['fromIndex']}")
# 检查最后一个分组的 toIndex 是否等于字幕总数
last_group = groups[-1]
if last_group['toIndex'] != total_subtitles:
errors.append(f"最后一个分组的 toIndex 必须为 {total_subtitles},实际为 {last_group['toIndex']}")
# 检查连续性和 sceneId 格式
for i, group in enumerate(groups):
expected_scene_id = f"scene_{str(i + 1).zfill(3)}"
# 检查 sceneId 格式
if group['sceneId'] != expected_scene_id:
errors.append(f"分组 {i + 1} 的 sceneId 应为 {expected_scene_id},实际为 {group['sceneId']}")
# 检查 fromIndex <= toIndex
if group['fromIndex'] > group['toIndex']:
errors.append(f"分组 {group['sceneId']} 的 fromIndex ({group['fromIndex']}) 大于 toIndex ({group['toIndex']})")
# 检查与前一个分组的连续性
if i > 0:
prev_group = groups[i - 1]
if group['fromIndex'] != prev_group['toIndex'] + 1:
errors.append(f"分组 {group['sceneId']} 的 fromIndex ({group['fromIndex']}) 与前一个分组的 toIndex ({prev_group['toIndex']}) 不连续")
return {
'valid': len(errors) == 0,
'errors': errors
}
# ============ 生成 Storyboard ============
def generate_scenes(subtitles, groups):
"""根据分组信息生成场景"""
scenes = []
# 创建字幕索引映射 (index -> subtitle)
subtitle_map = {sub['index']: sub for sub in subtitles}
# 第一遍:收集每个分组的字幕,计算 startTime
group_infos = []
for group in groups:
group_subtitles = []
for idx in range(group['fromIndex'], group['toIndex'] + 1):
sub = subtitle_map.get(idx)
if sub:
group_subtitles.append(sub)
group_infos.append({'group': group, 'groupSubtitles': group_subtitles})
# 第二遍:计算每个场景的 duration 和 segments
for i, info in enumerate(group_infos):
group = info['group']
group_subtitles = info['groupSubtitles']
if not group_subtitles:
print(f"警告: 分组 {group['sceneId']} 没有找到对应的字幕")
continue
# 计算场景的 startTime (第一条字幕的开始时间)
start_time = group_subtitles[0]['startMs']
# 生成 segments,计算相对时间
segments = []
for idx, sub in enumerate(group_subtitles):
# relativeStart: 相对于场景开始的时间
# 第一个 segment 的 relativeStart 必须为 0
relative_start = 0 if idx == 0 else sub['startMs'] - start_time
# relativeDuration: 该字幕的持续时间
relative_duration = sub['endMs'] - sub['startMs']
segments.append({
'text': sub['text'],
'relativeStart': relative_start,
'relativeDuration': relative_duration
})
# 计算场景的 duration:包含到下一个场景开始前的间隙
# 非最后一个场景:用下一个场景的 startTime 减去当前场景的 startTime
# 最后一个场景:用最后一条字幕的结束时间减去当前场景的 startTime
if i < len(group_infos) - 1:
next_group_start_time = group_infos[i + 1]['groupSubtitles'][0]['startMs']
duration = next_group_start_time - start_time
else:
last_segment = segments[-1]
duration = last_segment['relativeStart'] + last_segment['relativeDuration']
scene = {
'id': group['sceneId'],
'startTime': start_time,
'duration': duration,
'segments': segments
}
# 添加可选字段
if group.get('semanticTags'):
scene['semanticTags'] = group['semanticTags']
if group.get('visualHint'):
scene['visualHint'] = group['visualHint']
scenes.append(scene)
return scenes
def generate_storyboard(scenes):
"""生成完整的 storyboard 数据"""
if not scenes:
return {
'totalDuration': 0,
'sceneCount': 0,
'scenes': []
}
last_scene = scenes[-1]
total_duration = last_scene['startTime'] + last_scene['duration']
return {
'totalDuration': total_duration,
'sceneCount': len(scenes),
'scenes': scenes
}
# ============ 主函数 ============
def main():
args = sys.argv[1:]
if len(args) < 3:
print('用法: python3 generate-storyboard.py <srtPath> <groupsPath> <outputPath>')
print('')
print('参数:')
print(' srtPath SRT 字幕文件路径')
print(' groupsPath AI 生成的 groups.json 路径')
print(' outputPath 输出的 storyboard.json 路径')
sys.exit(1)
srt_path, groups_path, output_path = args[0], args[1], args[2]
try:
# 1. 解析 SRT 文件
print(f'📄 解析 SRT 文件: {srt_path}')
with open(srt_path, 'r', encoding='utf-8') as f:
srt_content = f.read()
subtitles = parse_srt(srt_content)
print(f' 找到 {len(subtitles)} 条字幕')
# 2. 读取分组信息
print(f'📋 读取分组信息: {groups_path}')
with open(groups_path, 'r', encoding='utf-8') as f:
groups_data = json.load(f)
groups = groups_data['groups']
print(f' 找到 {len(groups)} 个分组')
# 3. 验证分组数据
print('🔍 验证分组数据...')
validation = validate_groups(groups, len(subtitles))
if not validation['valid']:
print('❌ 分组验证失败:')
for err in validation['errors']:
print(f' - {err}')
sys.exit(1)
print(' ✅ 分组验证通过')
# 4. 生成场景
print('🎬 生成场景...')
scenes = generate_scenes(subtitles, groups)
# 5. 生成 storyboard
storyboard = generate_storyboard(scenes)
# 6. 写入文件
print(f'💾 写入文件: {output_path}')
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(storyboard, f, ensure_ascii=False, indent=2)
# 7. 输出摘要
print('')
print('✅ 生成完成!')
print(f' - 场景数量: {storyboard["sceneCount"]}')
print(f' - 总时长: {storyboard["totalDuration"] / 1000:.1f}s')
print(f' - 输出文件: {output_path}')
# 输出 JSON 结果供调用方使用
print('')
print('__RESULT_JSON__')
print(json.dumps({
'success': True,
'storyboardPath': output_path,
'sceneCount': storyboard['sceneCount'],
'totalDuration': storyboard['totalDuration']
}, ensure_ascii=False))
except FileNotFoundError as e:
print(f'❌ 错误: 文件不存在: {e}')
sys.exit(1)
except Exception as e:
print(f'❌ 错误: {e}')
sys.exit(1)
if __name__ == '__main__':
main()
whiteboard_prompt_template = "Minimal hand-drawn illustration, pure illustration without any text, off-white paper background(#F6F1E3), dark gray sketch lines, orange as the only accent color(#CD6441), lots of negative space, Notion-like doodle aesthetic, faceless round-headed human figure, clean editorial composition, conceptual rather than literal, simple background. Absolutely no text, no words, no letters, no typography, no realism, no 3D, no painterly texture, no high saturation, no complex scene, no photographic detail. The overall mood is restrained, lucid, and emotionally calm. Keep the whole series visually consistent."
#!/usr/bin/env python3
"""
Whiteboard Video Workflow Helper
Provides three commands:
1. init-dirs - Create storyboard/image/video output directories
2. gen-prompts - Parse storyboard.json and generate image prompts with whiteboard style prefix
3. merge-videos - Merge video segments into one final video using PyAV
Usage:
python workflow_helper.py init-dirs <output-dir>
python workflow_helper.py gen-prompts <storyboard-json-path>
python workflow_helper.py merge-videos <output-dir> <video1> <video2> ...
"""
import json
import sys
import unicodedata
from datetime import datetime
from pathlib import Path
def ends_with_symbol(text: str) -> bool:
"""Return True when the stripped text already ends with punctuation or a symbol."""
stripped = text.rstrip()
if not stripped:
return False
return unicodedata.category(stripped[-1])[0] in {"P", "S"}
def ensure_ending(text: str, ending: str) -> str:
"""Append ending only when the stripped text does not already end with a symbol."""
stripped = text.strip()
if not stripped:
return ""
if ends_with_symbol(stripped):
return stripped
return f"{stripped}{ending}"
def join_scene_text(text_parts: list[str]) -> str:
"""Join scene segment texts while preserving existing ending symbols."""
parts = [text.strip() for text in text_parts if text.strip()]
if not parts:
return ""
pieces = [ensure_ending(text, ",") for text in parts[:-1]]
pieces.append(ensure_ending(parts[-1], "。"))
return "".join(pieces)
def init_dirs(output_dir: str):
"""Create storyboard, image, video subdirectories under output_dir."""
base = Path(output_dir).resolve()
for name in ("storyboard", "image", "video"):
(base / name).mkdir(parents=True, exist_ok=True)
print(json.dumps({
"status": "ok",
"storyboardDir": str(base / "storyboard"),
"imageDir": str(base / "image"),
"videoDir": str(base / "video"),
}))
def gen_prompts(storyboard_path: str):
"""Parse storyboard.json and output a JSON array of image prompts."""
sb = json.loads(Path(storyboard_path).read_text(encoding="utf-8"))
prompts = []
for scene in sb.get("scenes", []):
visual_hint = ensure_ending(scene.get("visualHint", ""), "。")
if visual_hint:
content = f'视觉元素建议:\n"{visual_hint}"'
else:
content = ""
prompts.append(content)
print(json.dumps(prompts, ensure_ascii=False))
def merge_videos(output_dir: str, video_paths: list[str]):
"""Merge multiple video segments into one final video using PyAV (re-encode via H.264)."""
if not video_paths:
print(json.dumps({"status": "error", "error": "没有视频片段可合并"}))
sys.exit(1)
# 检查所有视频文件是否存在
for vp in video_paths:
if not Path(vp).exists():
print(json.dumps({"status": "error", "error": f"视频文件不存在: {vp}"}))
sys.exit(1)
# 生成输出文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = Path(output_dir).resolve() / f"whiteboard_{timestamp}.mp4"
import av
from fractions import Fraction
try:
# 从第一个片段获取编码参数
first_input = av.open(video_paths[0], mode="r")
in_stream = first_input.streams.video[0]
width = in_stream.codec_context.width
height = in_stream.codec_context.height
fps = in_stream.average_rate
first_input.close()
# 创建输出容器
time_base = Fraction(1, int(fps))
output_container = av.open(str(output_path), mode="w")
out_stream = output_container.add_stream("h264", rate=fps)
out_stream.width = width
out_stream.height = height
out_stream.pix_fmt = "yuv420p"
out_stream.time_base = time_base
out_stream.options = {"crf": "18"}
# 逐个读取输入片段,解码后重新编码写入输出
# 使用帧计数器生成单调递增的 PTS,避免多段拼接时时间戳倒退
frame_count = 0
for vp in video_paths:
input_container = av.open(vp, mode="r")
for frame in input_container.decode(video=0):
frame.pts = frame_count
frame.time_base = time_base
frame_count += 1
packet = out_stream.encode(frame)
if packet:
for p in packet:
output_container.mux(p)
input_container.close()
# flush
packet = out_stream.encode(None)
if packet:
for p in packet:
output_container.mux(p)
output_container.close()
except Exception as e:
# 清理可能残留的输出文件
if output_path.exists():
output_path.unlink()
print(json.dumps({"status": "error", "error": f"视频合并失败: {e}"}))
sys.exit(1)
output_size_mb = output_path.stat().st_size / (1024 * 1024)
print(json.dumps({
"status": "ok",
"mergedVideo": str(output_path),
"totalSegments": len(video_paths),
"sizeMB": round(output_size_mb, 1),
}, ensure_ascii=False))
def main():
if len(sys.argv) < 3:
print("Usage:")
print(" workflow_helper.py init-dirs <output-dir>")
print(" workflow_helper.py gen-prompts <storyboard-json-path>")
print(" workflow_helper.py merge-videos <output-dir> <video1> <video2> ...")
sys.exit(1)
cmd = sys.argv[1]
if cmd == "init-dirs":
init_dirs(sys.argv[2])
elif cmd == "gen-prompts":
gen_prompts(sys.argv[2])
elif cmd == "merge-videos":
if len(sys.argv) < 4:
print("Error: merge-videos requires output-dir and at least one video path")
sys.exit(1)
merge_videos(sys.argv[2], sys.argv[3:])
else:
print(f"Unknown command: {cmd}")
sys.exit(1)
if __name__ == "__main__":
main()