
Tutor Math Geometry
- 27 installs
- 8 repo stars
- Updated August 4, 2026
- wangyendt/wayne-skills
Helps with ai & agent building tasks.
About
tutor-math-geometry is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- tutor-math-geometry
- AI & Agent Building
- AI-coding skill
Tutor Math Geometry by the numbers
- 27 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #9,601 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wangyendt/wayne-skills --skill tutor-math-geometryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 27 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | wangyendt/wayne-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Tutor - 一对一辅导老师
核心工作流
WORKFLOW = [
# 步骤1: 数学建模
{
"step": 1,
"name": "analyze_problem",
"input": "题目图片/文本",
"output": "math_analysis.md",
"tasks": ["推导数学事实", "建立几何模型", "确定图形构建方法"]
},
# 步骤2: HTML可视化
{
"step": 2,
"name": "html_visualization",
"input": "math_analysis.md",
"output": "数学_{日期}_{题目}.html",
"tasks": ["SVG画图形", "展示画图过程", "标注关键要素"]
},
# 步骤3: 分镜脚本
{
"step": 3,
"name": "storyboard",
"input": "HTML内容",
"output": "{日期}_{题目}_分镜.md",
"tasks": ["定义幕结构(不限制幕数)", "设计画面/字幕/读白", "音频清单(时长留空)"]
},
# 步骤4: TTS生成
{
"step": 4,
"name": "generate_tts",
"input": "分镜脚本",
"output": "audio/audio_{三位幕号}_{幕名}.wav + audio_info.json",
"command": "python scripts/generate_tts.py audio_list.csv ./audio --voice xiaoxiao"
},
# 步骤5: 验证更新
{
"step": 5,
"name": "validate_audio",
"input": "分镜.md + audio/",
"output": "更新后的分镜.md(填充时长) + audio_info.json",
"command": "python scripts/validate_audio.py 分镜.md ./audio",
"check": ["音频存在性", "时长>0", "数量匹配"]
},
# 步骤6: 脚手架
{
"step": 6,
"name": "scaffold",
"input": "分镜.md + audio_info.json",
"output": "script.py (伪代码框架)",
"template": "templates/script_scaffold.py",
"must_include": [
"calculate_geometry() - 几何建模",
"assert_geometry() - 几何验证(题目条件+精度) + 画布范围检查(边界+中心)",
"COLORS - 颜色定义",
"SCENES[] - 幕信息数组(从audio_info.json加载时长)",
]
},
# 步骤7: 生成代码
{
"step": 7,
"name": "implement",
"input": "script.py脚手架 + 分镜.md + audio_info.json",
"output": "完整的script.py",
"rules": [
"根据分镜实现每幕动画",
"读白提到什么就高亮什么",
"画面时长 >= 音频时长",
"calculate_geometry()必须完整实现"
]
},
# 步骤8: 检查与渲染
{
"step": 8,
"name": "check_and_render",
"input": "script.py",
"output": "视频文件 + 关键帧截图",
"command": "python scripts/render.py",
"check": ["代码结构检查(check.py)", "几何正确性", "高亮同步", "字幕清晰", "动画流畅"],
"on_fail": "回到步骤7修改代码"
}
]
# 工作流状态转换
# step1 → step2 → step3 → step4 → step5 → step6 → step7 → step8
# ↑____________↓ (失败时循环)---
步骤1:分析题目,推导数学事实
目标:建立正确的数学计算方法和流程,形成画出符合数学的图形的方法。
输出格式
## 数学事实分析
### 已知条件
- 条件1:...
- 条件2:...
### 推导的事实
1. **事实名称**: 描述
- 计算过程: ...
- 数学表达: ...
### 图形构建方法
- 点的坐标: ...
- 边的关系: ...
- 圆/弧的定义: ...
### 需要证明的结论
- 结论1: ...注意:所有证明题需要证明的结论,在分镜中都可以作为已确立的事实来使用。
---
步骤2:HTML + SVG 可视化
目标:用 HTML + SVG 画出图形,展示画图过程和解答流程,为分镜做规划。 补充:如果用户已经提供了准确的HTML文件,描述了画图过程,可以按文件名要求复制一份,跳过生成文件。
输出要求
- 文件命名:
数学_{日期}_{题目简述}.html - 包含:题目陈述、SVG 图形、分步解答、关键要素标注
- SVG 需要展示画图过程(如:先画三角形 → 再画圆 → 标注点)
---
步骤3:生成分镜脚本
目标:定义视频结构,不限制幕数,结尾预留音频文件名(时长为空)。
文件命名
{日期}_{题目}_分镜.md
分镜脚本结构
# 分镜脚本 - {题目名称}
## 分镜设计
### 第1幕:{幕名}
**画面**: ...
**字幕**: ...(简洁,≤20字)
**读白**: ...(详细,口语化)
**动画**: ...
**目的**: ...
---
## 音频生成清单(步骤4填写)
| 幕号 | 文件名 | 读白文本 | 时长 | 说话人 | 情感 |
|------|--------|----------|------|--------|------|
| 1 | audio_001_{幕名}.wav | "读白文本" | | xiaoxiao | 热情 |
| 2 | audio_002_{幕名}.wav | "读白文本" | | xiaoxiao | 平和 |关键规范
- 幕号从1开始连续编号
- 文件名格式:
audio_{三位幕号}_{幕名}.wav - 时长列为空,由步骤5填充
- 不限制幕数,根据内容需要决定
字幕退场约定(重要)
为了避免动画中文字忘记退场,在分镜的动画部分使用以下约定指定退场时机:
**动画**:
- 0.0s: 字幕"勾股定理"淡入
- 2.0s: → 字幕退场(或:字幕淡出)
- 3.0s: 图形高亮退场标记方式(三选一): 1. → 箭头符号表示退场(推荐,最简洁) 2. 退场: 或 淡出: 显式标记 3. 持续X秒 指定显示时长
示例:
**动画**:
- 字幕"正方形性质"淡入,持续3秒 → 自动退场
- 0.0s: 标题淡入 | 3.0s: →
- 公式分步显示,第5秒全部退场参考示例
见 references/storyboard_sample.md - 第九十九题:证明四点共圆
---
步骤4:TTS 生成语音文件
使用脚本:scripts/generate_tts.py(Edge TTS 推荐)
执行命令
python scripts/generate_tts.py 分镜.md ./audio --voice xiaoxiao输出文件
audio/
├── audio_001_开场.wav
├── audio_002_展示图形.wav
├── audio_003_定理证明.wav
├── ...
└── audio_info.jsonaudio_info.json 格式
{
"files": [
{"scene": 1, "file": "audio_001_开场.wav", "duration": 8.5},
{"scene": 2, "file": "audio_002_展示图形.wav", "duration": 15.2}
]
}---
步骤5:验证并更新分镜时长
使用脚本:scripts/validate_audio.py
功能
1. 读取 audio/audio_info.json 获取音频时长 2. 验证所有音频文件存在且时长正常(>0秒) 3. 更新分镜脚本的"音频生成清单"中的时长列 4. 如果缺少音频或时长异常,报错提醒
执行命令
python scripts/validate_audio.py 分镜.md ./audio验证失败情况
- 缺少音频文件 → 报错:"缺少第X幕音频文件"
- 时长为0或异常 → 报错:"第X幕音频时长异常,请检查格式"
- 分镜与音频数量不匹配 → 报错:"分镜X幕,音频Y个,数量不匹配"
---
步骤6:生成 script.py 脚手架
使用模板:templates/script_scaffold.py
输出:script.py(肯定 build 不过的伪代码框架)
脚手架必须包含的部分
from manim import *
import json
import os
class MathScene(Scene):
"""
数学教学视频场景 - 脚手架
根据分镜脚本和音频信息生成完整动画
"""
# ========== 1. 颜色定义 ==========
COLORS = {
'background': '#1a1a2e', # 深蓝背景
'primary': '#4ecca3', # 主色(青色)
'secondary': '#e94560', # 辅助色(红色)
'highlight': '#ffc107', # 高亮色(黄色)
'text': '#ffffff', # 文字白色
'grid': '#2a2a4e', # 网格线
}
# ========== 2. 幕信息数组(供AI参考) ==========
# 格式: (幕号, 幕名, 音频文件名, 时长秒数)
# 注意:时长从 audio_info.json 读取,确保画面等待音频原则
SCENES = [
(1, "开场", "audio_001_开场.wav", None),
(2, "展示图形", "audio_002_展示图形.wav", None),
# ... 根据分镜动态生成
]
def __init__(self, **kwargs):
super().__init__(**kwargs)
# 加载音频时长信息
self.audio_timings = self._load_audio_timings()
def _load_audio_timings(self):
"""从 audio_info.json 加载音频时长"""
# TODO: AI实现 - 读取JSON并填充 SCENES 的时长
pass
# ========== 3. 几何计算函数 ==========
def calculate_geometry(self):
"""
计算所有几何元素的位置和属性
从步骤2的HTML画图中提取信息,建立数学模型:
- 所有点的坐标 (x, y) - ⚠️ 始终使用2D,z坐标始终为0
- 边的长度和方程
- 圆/弧的圆心和半径
- 交点计算
- 切线、法线等辅助线
返回: dict 包含所有几何对象的数据
"""
# TODO: AI根据题目几何关系实现
geometry = {
'points': {}, # {'A': (x, y), 'B': (x, y), ...}
'lines': {}, # {'AB': {'start': A, 'end': B, 'length': L}, ...}
'circles': {}, # {'circle1': {'center': O, 'radius': r}, ...}
'arcs': {}, # 圆弧定义
}
return geometry
# ========== 4. 几何验证函数 ==========
def assert_geometry(self, geometry):
"""
验证几何计算的正确性(最小验证原则)
验证内容:
1. 题目给定的事实(如:两条边相等,谁是谁的一半)
2. 精度问题:使用相对误差比较,而非绝对相等
3. 画布范围检查:确保图形在可视区域内
4. 错误提示:所有assert必须用中文描述问题,包含具体数值和修复建议
=== 几何条件验证 ===
基于题目给定的条件,编写最小但关键的验证:
- 如果题目说 AB = BC,验证 |AB - BC| < epsilon
- 如果题目说 E 是中点,验证 AE = EB(考虑浮点误差)
- 如果题目说某角是直角,验证向量点积接近0
- 如果题目给具体数值(如边长为5),验证计算结果匹配
精度建议:
- 相对误差:1e-6 或绝对误差 1e-4
- 示例:assert abs(len_AB - len_BC) < 1e-4, "中文错误提示:AB长度不等于BC长度"
=== 画布范围验证算法 ===
目标:确保整个图形在画布内,且位于视觉中心区域
算法步骤(AI需要根据几何数据实现):
1. 计算所有几何元素的外接矩形 bounding_box
- 遍历所有点、线、圆的边界点
- 取最小x, 最大x, 最小y, 最大y
2. 验证矩形在画布范围内(考虑边距)
- 画布默认范围:FRAME_WIDTH=14.2, FRAME_HEIGHT=8
- 建议边距:四周各留 0.5-1.0 单位
- 验证:min_x > -7+margin 且 max_x < 7-margin
min_y > -4+margin 且 max_y < 4-margin
3. 验证矩形中心在视觉中心区域
- 画布中心:(0, 0)
- 视觉中心区域:中心 ±20%(即 x∈[-1.4, 1.4], y∈[-0.8, 0.8])
- 计算矩形中心:(center_x, center_y) = ((min_x+max_x)/2, (min_y+max_y)/2)
- 验证:center_x ∈ [-1.4, 1.4] 且 center_y ∈ [-0.8, 0.8]
4. 如果图形超出范围,AI需要调整几何计算的缩放或平移
"""
# === TODO: AI实现 - 基于题目条件的验证(失败时用中文报错)===
# 示例代码(根据实际题目修改):
# epsilon = 1e-4
# len_ab = geometry['lines']['AB']['length']
# len_bc = geometry['lines']['BC']['length']
# assert abs(len_ab - len_bc) < epsilon, f"几何验证失败:AB长度({len_ab:.4f})不等于BC长度({len_bc:.4f}),题目要求两边相等"
#
# len_ae = geometry['lines']['AE']['length']
# len_ab = geometry['lines']['AB']['length']
# assert abs(len_ae * 2 - len_ab) < epsilon, f"几何验证失败:E不是AB中点,AE({len_ae:.4f})*2={len_ae*2:.4f} != AB({len_ab:.4f})"
# === TODO: AI实现 - 画布范围检查(失败时用中文报错)===
# 1. 计算bounding_box
# all_points = list(geometry['points'].values())
# min_x = min(p[0] for p in all_points)
# max_x = max(p[0] for p in all_points)
# min_y = min(p[1] for p in all_points)
# max_y = max(p[1] for p in all_points)
#
# 2. 验证画布边界(失败时报具体数值)
# margin = 0.5
# assert min_x > -7 + margin, f"画布验证失败:图形左边界(min_x={min_x:.2f})超出安全区域(>{-7+margin}),需要向右平移或缩小"
# assert max_x < 7 - margin, f"画布验证失败:图形右边界(max_x={max_x:.2f})超出安全区域(<{7-margin}),需要向左平移或缩小"
# assert min_y > -4 + margin, f"画布验证失败:图形下边界(min_y={min_y:.2f})超出安全区域(>{-4+margin}),需要向上平移或缩小"
# assert max_y < 4 - margin, f"画布验证失败:图形上边界(max_y={max_y:.2f})超出安全区域(<{4-margin}),需要向下平移或缩小"
#
# 3. 验证视觉中心(失败时报具体偏移)
# center_x = (min_x + max_x) / 2
# center_y = (min_y + max_y) / 2
# assert -1.4 <= center_x <= 1.4, f"视觉中心验证失败:图形中心X={center_x:.2f}超出视觉中心区域[-1.4, 1.4],建议调整几何计算使图形居中"
# assert -0.8 <= center_y <= 0.8, f"视觉中心验证失败:图形中心Y={center_y:.2f}超出视觉中心区域[-0.8, 0.8],建议调整几何计算使图形居中"
pass
# ========== 5. 图形元素定义 ==========
def define_elements(self, geometry):
"""
定义 Manim 图形对象(但不创建动画)
为每一幕需要的图形元素预先定义,便于结构化调用:
- 点、线、圆的基础对象
- 高亮效果对象
- 标注文字对象
"""
# TODO: AI根据分镜需求定义
elements = {
'points': {}, # Mobject 点对象
'lines': {}, # Mobject 线对象
'circles': {}, # Mobject 圆对象
'labels': {}, # 标签文字
}
return elements
# ========== 6. 构造主流程 ==========
def construct(self):
"""主构造流程"""
# 计算几何
geometry = self.calculate_geometry()
self.assert_geometry(geometry)
elements = self.define_elements(geometry)
# 设置背景
self.camera.background_color = self.COLORS['background']
# 按幕执行
for scene_num, scene_name, audio_file, duration in self.SCENES:
self.play_scene(scene_num, scene_name, audio_file, duration, elements, geometry)
def play_scene(self, scene_num, scene_name, audio_file, duration, elements, geometry):
"""
播放单幕动画 - 必须添加音频
========== 音频集成(强制要求) ==========
每幕必须添加对应的音频文件,否则视频将没有声音!
方式:在方法第一行就添加音频
self.add_sound(f"audio/{audio_file}")
注意:
- audio_file 参数已经包含文件名(如 "audio_001_开场.wav")
- 音频文件位于 audio/ 目录下
- 添加音频后,后续动画时长应 >= duration(音频时长)
========== 画面等待音频原则 ==========
- 动画总时长 >= 音频时长(duration 参数)
- 可以画面多等待(多出静音部分,后期可剪辑)
- 不能画面比音频短(会导致音频被截断)
========== 高亮规范(必须在读白提到时同步) ==========
- 提到某个边等于某个边 → 用动画高亮两条边
- 提到某个点/圆 → 闪烁或放大高亮
- 提到证明结论 → 用框或颜色强调
========== 绘制策略 ==========
- 简单图形:直接显示,然后高亮关键部分
- 复杂图形:逐步绘制,边画边讲解
"""
# TODO: AI实现 - 第一行必须添加音频!
# self.add_sound(f"audio/{audio_file}")
pass
# ========== 7. 音频集成 ==========
def add_audio_to_scene(self, audio_file, animation_duration):
"""
添加音频到场景
方式1(推荐):在script.py中直接添加音频文件
self.add_sound(audio_file)
方式2:如果需要在特定时间点触发,使用延时
确保:animation_duration >= 音频实际时长
"""
# TODO: AI实现音频集成
pass
---
步骤7:AI 生成最终代码
输入:
- 分镜脚本(已更新时长)
audio/audio_info.jsonscript.py脚手架
输出:完整的 script.py
AI 生成指导: 1. 根据分镜的"动画"描述实现具体动画代码 2. 根据"读白"内容确定高亮时机 3. 实现 calculate_geometry() 的具体计算 4. 实现 assert_geometry() 的验证逻辑 5. 为每幕实现 play_scene_X() 方法 6. 音频集成(必须):
- 每幕的第一行必须是
self.add_sound(f"audio/{audio_file}") - 确保动画总时长 >= 音频时长(使用
duration参数) - 如果动画太短,添加
self.wait()来延长
7. 验证音频文件路径正确:audio/audio_001_XXX.wav 8. 字幕显示与退场(关键):
- 使用脚手架提供的
show_subtitle_timed(text, duration)方法显示定时字幕 - 或使用
show_subtitle_with_audio(text, audio_duration)让字幕持续到音频结束 - 分镜中的
→或退场:标记表示文字需要在该时间点淡出 - 必须确保所有文字元素都有退场动画,避免残留到下一幕
---
步骤8:代码检查与渲染
8.1 代码结构检查(必须)
使用脚本:scripts/check.py
在渲染之前,必须先检查代码是否包含必要的函数和结构:
# 检查 script.py(默认)
python scripts/check.py
# 检查指定文件
python scripts/check.py my_script.py检查内容: 1. ✅ calculate_geometry() - 几何计算函数是否存在 2. ✅ assert_geometry() - 几何验证函数是否存在 3. ✅ define_elements() - 图形元素定义函数是否存在 4. ✅ 字幕类 Subtitle / TitleSubtitle 是否存在 5. ✅ 是否有 add_sound() 调用(音频集成) 6. ✅ 是否有继承 Scene 的类
检查结果:
- ❌ 错误:必须修复,否则无法渲染
- ⚠️ 警告:建议修复,但不会阻止渲染
8.2 渲染视频
方式1:使用渲染脚本(推荐,包含检查)
# 完整流程:检查 -> 渲染 -> 拷贝到根目录
python scripts/render.py
# 指定文件和场景
python scripts/render.py -f script.py -s MathScene
# 指定渲染质量
python scripts/render.py -q h # 1080p60 (默认)
python scripts/render.py -q k # 4K
python scripts/render.py -q m # 720p30
# 跳过检查(不推荐,仅用于快速测试)
python scripts/render.py --no-check方式2:直接使用 manim(跳过检查,不推荐)
manim -pqh script.py MathScene提取关键帧验证
python scripts/extract_frames.py media/videos/script/1080p60/MathScene.mp4 --interval 5验证内容
1. 几何图形是否正确(点、线、圆位置) 2. 高亮是否与讲解同步 3. 字幕是否清晰可读 4. 动画是否流畅 5. 音频是否正确添加:
- 每幕都有声音(没有静音幕)
- 音频与分镜中的读白内容一致
- 动画时长 >= 音频时长(没有截断音频)
渲染后必须执行
# 在construct()最后添加:拷贝视频到根目录
import shutil
import os
from pathlib import Path
# 渲染完成后拷贝到根目录
video_src = Path("media/videos/script/1920p60/SquareTriangleProblem.mp4")
video_dst = Path("最终视频.mp4")
if video_src.exists():
shutil.copy2(video_src, video_dst)
print(f"✓ 视频已拷贝到: {video_dst}")规则:渲染完成后必须将视频拷贝到项目根目录,不要留在media/深处。
如果发现问题
- 回到步骤7修改代码
- 重新渲染验证
---
数学解题原则(绝对重要)
❌ 禁止使用坐标系
绝不用坐标系来求解,应该用各种定义和推理。
错误示例(坐标法):
设B=(0,0), C=(5,0), A=(1.8, 2.4)...
面积 = ½ × 底 × 高正确示例(几何推理):
1. 由勾股定理:BC = √(AB² + AC²) = 5
2. 正方形性质:BE = BC = 5
3. 等积变换:S△ABE = S△ABC × (EB/BC) × sin(∠ABE)/sin(∠ABC)
4. 或用向量叉积、海伦公式等纯几何方法允许的可视化辅助:
- 为了动画展示可以显示坐标,但解题过程不能用坐标计算
- 可以用几何画板的思路:旋转、平移、对称等变换
推荐的几何推理方法
1. 等积变换 - 同底等高、等底同高 2. 相似三角形 - 比例关系 3. 勾股定理 - 边长关系 4. 向量/叉积 - 纯几何意义的运算 5. 旋转/对称 - 几何变换
---
文件结构
tutor/
├── SKILL.md # 本文件 - 工作流程定义
├── requirements.txt # Python 依赖
├── references/
│ └── storyboard_sample.md # 分镜脚本示例(参考)
├── templates/
│ └── script_scaffold.py # Manim 脚手架模板(含字幕类)
├── scripts/
│ ├── generate_tts.py # TTS 生成脚本
│ ├── validate_audio.py # 音频验证脚本
│ ├── check.py # 代码结构检查脚本(渲染前必执行)
│ └── render.py # 渲染流水线脚本(检查+渲染+拷贝)
└── sample/ # 示例项目(保留参考)
└── geometry_proof/---
依赖管理
使用 uv 管理依赖:
# 创建虚拟环境
uv venv .venv
# 安装依赖
uv pip install -r requirements.txt
# 激活环境
source .venv/bin/activate---
绕过LaTeX依赖(重要)
Manim默认需要LaTeX来渲染数学公式。如果不想安装LaTeX,全部使用Text替代MathTex:
# ❌ 需要LaTeX
MathTex(r"BC^2 = AB^2 + AC^2")
MathTex(r"\frac{1}{2} \times 5")
MathTex("A") # 甚至连字母都需要LaTeX
# ✅ 不需要LaTeX
Text("BC² = AB² + AC²", font_size=36)
Text("½ × 5", font_size=30)
Text("A", font_size=32) # 用Text直接显示字母常用替换对照表:
| 数学符号 | LaTeX写法 | 无LaTeX写法 |
|---|---|---|
| 上标 | x^2 | x² (直接输入Unicode) |
| 分数 | \frac{1}{2} | ½ 或 1/2 |
| 度数 | 90^\circ | 90° |
| 平方厘米 | \text{cm}^2 | cm² |
| 根号 | \sqrt{25} | √25 |
| 角度 | \angle A | ∠A |
脚手架默认不使用MathTex,如需数学公式请直接输入Unicode字符。
---
关键原则总结
| 原则 | 说明 |
|---|---|
| 数学先行 | 先建立正确的数学模型,再画图 |
| 音频必填 | 每幕必须调用 self.add_sound(),否则视频无声音 |
| 音画同步 | 画面等待音频(动画时长 >= 音频时长),确保讲解和动画同步 |
| 高亮对应 | 配音提到什么,画面高亮什么 |
| 最小验证 | assert_geometry 验证题目条件(带精度) + 画布范围(边界+中心) |
| 逐步抽象 | 从 HTML 可视化 → 分镜脚本 → Manim 代码 |
#!/usr/bin/env python3
"""
Tutor 技能项目初始化脚本
功能:
1. 检查依赖环境(uv, manim, edge-tts等)
2. 创建项目目录结构
3. 拷贝脚手架模板
4. 生成示例CSV文件
使用:
python init.py [项目目录]
默认在当前目录创建项目结构。
"""
import os
import sys
import shutil
import subprocess
from pathlib import Path
# ========== 配置 ==========
SKILL_DIR = Path(__file__).parent.resolve()
TEMPLATES_DIR = SKILL_DIR / "templates"
SCRIPTS_DIR = SKILL_DIR / "scripts"
# 依赖检查配置
DEPENDENCIES = {
"uv": {
"check": ["uv", "--version"],
"install_hint": "curl -LsSf https://astral.sh/uv/install.sh | sh",
"required": True,
},
"manim": {
"check": ["manim", "--version"],
"install_hint": "uv pip install manim",
"required": True,
},
"edge-tts": {
"check": ["edge-tts", "--version"],
"install_hint": "uv pip install edge-tts",
"required": True,
},
"ffmpeg": {
"check": ["ffmpeg", "-version"],
"install_hint": "brew install ffmpeg (macOS) 或 apt install ffmpeg (Linux)",
"required": False, # 可选但推荐
},
}
# 项目目录结构
PROJECT_STRUCTURE = {
"audio": "音频文件目录",
"media": "Manim渲染输出",
"assets": "静态资源",
}
# ========== 颜色输出 ==========
class Colors:
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
BLUE = "\033[94m"
RESET = "\033[0m"
def ok(msg):
print(f"{Colors.GREEN}✓{Colors.RESET} {msg}")
def warn(msg):
print(f"{Colors.YELLOW}⚠{Colors.RESET} {msg}")
def error(msg):
print(f"{Colors.RED}✗{Colors.RESET} {msg}")
def info(msg):
print(f"{Colors.BLUE}ℹ{Colors.RESET} {msg}")
# ========== 依赖检查 ==========
def check_dependency(name, config):
"""检查单个依赖"""
try:
result = subprocess.run(
config["check"],
capture_output=True,
check=False,
)
if result.returncode == 0:
version = result.stdout.decode().strip().split('\n')[0][:50]
ok(f"{name}: {version}")
return True
except FileNotFoundError:
pass
if config["required"]:
error(f"{name}: 未安装 (必需)")
info(f" 安装: {config['install_hint']}")
else:
warn(f"{name}: 未安装 (可选)")
info(f" 安装: {config['install_hint']}")
return not config["required"]
def check_all_dependencies():
"""检查所有依赖"""
print("=" * 50)
print("检查依赖环境")
print("=" * 50)
all_ok = True
for name, config in DEPENDENCIES.items():
if not check_dependency(name, config):
all_ok = False
print()
return all_ok
# ========== 项目初始化 ==========
def create_directory_structure(project_dir):
"""创建项目目录结构"""
print("=" * 50)
print("创建项目目录")
print("=" * 50)
project_path = Path(project_dir)
project_path.mkdir(parents=True, exist_ok=True)
for dirname, description in PROJECT_STRUCTURE.items():
dirpath = project_path / dirname
dirpath.mkdir(exist_ok=True)
ok(f"{dirname}/ - {description}")
print()
def copy_templates(project_dir):
"""拷贝脚手架模板"""
print("=" * 50)
print("拷贝模板文件")
print("=" * 50)
project_path = Path(project_dir)
# 拷贝 script_scaffold.py
scaffold_src = TEMPLATES_DIR / "script_scaffold.py"
scaffold_dst = project_path / "script.py"
if scaffold_src.exists():
shutil.copy2(scaffold_src, scaffold_dst)
ok(f"script.py - 脚手架模板 (从 script_scaffold.py)")
info(" 提示: 根据分镜实现 TODO 部分")
else:
error(f"模板不存在: {scaffold_src}")
# 拷贝 script_example.py 作为参考
example_src = TEMPLATES_DIR / "script_example.py"
example_dst = project_path / "script_example.py"
if example_src.exists():
shutil.copy2(example_src, example_dst)
ok(f"script_example.py - 完整示例 (参考用)")
else:
warn("script_example.py 模板不存在")
print()
def generate_csv_template(project_dir):
"""生成示例CSV文件"""
print("=" * 50)
print("生成音频列表模板")
print("=" * 50)
project_path = Path(project_dir)
csv_path = project_path / "audio_list.csv"
csv_content = """filename,text
audio_001_开场.wav,"大家好!今天我们来学习三角形内角和定理。"
audio_002_画三角形.wav,"首先,让我们画一个任意三角形。"
audio_003_标角度.wav,"标记三角形的三个内角。"
audio_004_画平行线.wav,"过顶点作底边的平行线。"
audio_005_证明.wav,"利用平行线性质进行证明。"
audio_006_总结.wav,"总结:三角形内角和等于180度。"
"""
if not csv_path.exists():
csv_path.write_text(csv_content, encoding='utf-8')
ok(f"audio_list.csv - 音频列表模板")
info(" 使用: python {}/scripts/generate_tts.py audio_list.csv ./audio".format(SKILL_DIR))
else:
warn("audio_list.csv 已存在,跳过")
print()
def generate_gitignore(project_dir):
"""生成 .gitignore 文件"""
project_path = Path(project_dir)
gitignore_path = project_path / ".gitignore"
if not gitignore_path.exists():
content = """# Manim
media/
__pycache__/
*.pyc
# Audio
audio/*.wav
audio/*.mp3
!audio/audio_info.json
# Video
*.mp4
*.mov
# Temp
.DS_Store
*.log
"""
gitignore_path.write_text(content)
ok(".gitignore")
# ========== 主流程 ==========
def main():
# 解析参数
project_dir = sys.argv[1] if len(sys.argv) > 1 else "."
print("\n" + "=" * 50)
print("Tutor 技能 - 项目初始化")
print("=" * 50)
print(f"项目目录: {Path(project_dir).resolve()}")
print(f"技能目录: {SKILL_DIR}")
print()
# 1. 检查依赖
if not check_all_dependencies():
print("=" * 50)
error("依赖检查失败,请先安装必需依赖")
print()
print("快速安装:")
print(" uv pip install manim edge-tts mutagen")
sys.exit(1)
# 2. 创建目录结构
create_directory_structure(project_dir)
# 3. 拷贝模板
copy_templates(project_dir)
# 4. 生成CSV
generate_csv_template(project_dir)
# 5. 生成gitignore
generate_gitignore(project_dir)
# 完成
print("=" * 50)
ok("项目初始化完成!")
print("=" * 50)
print()
print("下一步:")
print(" 1. 编辑 audio_list.csv 填写对白")
print(" 2. 生成音频: python {}/scripts/generate_tts.py audio_list.csv ./audio".format(SKILL_DIR))
print(" 3. 编辑 script.py 实现动画")
print(" 4. 渲染视频: manim -pqh script.py MathScene")
print()
if __name__ == "__main__":
main()
Tutor Math Geometry - 数学几何教学视频制作
⚠️ 重要声明:本技能基于 xiaotianfotos/skills 的 tutor 项目修改而来,保留了核心工作流和功能。原作者:https://github.com/xiaotianfotos
本版本修改:每次任务输出到独立文件夹 tutor_output/{日期}_{题目}/,避免文件混乱。专注数学几何教学视频。
核心工作流(与原版一致)
1. 数学分析 → 推导数学事实,建立几何模型 2. HTML 可视化 → SVG 画图形,展示画图过程 3. 分镜脚本 → 定义幕结构,设计画面/字幕/读白 4. TTS 音频 → 生成配音文件 5. 验证更新 → 填充音频时长 6. 脚手架 → 生成 Manim 代码框架 7. 实现代码 → 根据分镜实现动画 8. 渲染验证 → 生成最终视频
输出目录
每次任务生成的文件保存在 tutor_output/{日期}_{题目}/ 目录下:
math_analysis.md- 数学分析数学_{日期}_{题目}.html- HTML 可视化{日期}_{题目}_分镜.md- 分镜脚本audio/- TTS 音频文件script.py- Manim 动画代码media/- 渲染输出(视频)
目录说明
scripts/- TTS 生成、音频验证、代码检查、渲染等脚本templates/- Manim 脚手架模板references/- 分镜脚本示例sample/- 早期探索时找到的一种风格,可以作为参考
使用方式
# 初始化项目(会自动创建 tutor_output/{日期}_{题目}/ 目录)
python init.py [项目目录]
# 生成 TTS 音频
python scripts/generate_tts.py audio_list.csv ./audio
# 验证音频并更新分镜
python scripts/validate_audio.py 分镜.md ./audio
# 检查代码结构
python scripts/check.py
# 渲染视频
python scripts/render.py依赖
- uv
- manim
- edge-tts
视频分镜脚本 - 第九十九题:证明四点共圆
基本信息
- 时长预估: 约 120 秒
- 场景数: 6
- 风格: 几何动态演示,专业严谨
- 学生特点: 需要理解双曲线性质的几何意义,可能对蒙日定理不熟悉
- 配音设置: 使用 xiaoxiao 说话人,中文讲解
---
配音说明
读白 vs 字幕的区别
- 字幕:简洁、视觉化,配合画面,不超过 20 字
- 读白:详细、口语化、引导思考,比字幕更完整
---
分镜设计
第一幕:开场(8 秒)
画面: 深蓝色背景,标题"第九十九题:证明四点共圆"淡入,下方显示副标题"三角形内切圆与双曲线性质"
字幕: "第九十九题:证明四点共圆"
读白: "大家好!今天我们来讲解一道经典的平面几何问题,证明四点共圆。这道题综合运用了切线长定理和双曲线的性质。"
音频时机: 标题出现后 1 秒开始
情感: 热情、专业
动画:
- 0.0s: 背景渐变出现
- 0.5s: 标题从中心放大淡入
- 1.0s: 副标题从下方滑入
- 6.0s: → 标题、副标题退场(幕结束前淡出,避免残留)
目的: 吸引注意力,介绍主题
---
第二幕:展示完整图形(15 秒)
画面: 完整的几何图形逐渐绘制出现 1. 先画三角形 ABC 2. 画内切圆⊙I,标注切点 D、E、F 3. 标出点 K 4. 画内切圆⊙J,标注切点 D、M、N
字幕: "已知:两内切圆共切点 D"
读白: "首先,我们来看题目给出的图形。三角形 ABC 的内切圆 I,分别切 BC、CA、AB 于点 D、E、F。三角形内一点 K,使得三角形 KBC 的内切圆 J 也切 BC 于点 D。"
音频时机: 图形绘制完成后开始
情感: 平和、清晰
动画:
- 0.0s: 三角形 ABC 三边依次绘制
- 3.0s: 内切圆 I 渐显,切点闪烁高亮
- 5.0s: 点 K 出现
- 7.0s: 内切圆 J 渐显,切点 M、N 闪烁高亮
- 12.0s: → 字幕退场,图形保持
目的: 让学生熟悉图形结构,理解已知条件
---
第三幕:切线长定理(20 秒)
画面: 图形简化,只保留关键线段
- 高亮显示 BF 和 BD,用相同颜色标注
- 高亮显示 CE 和 CD,用相同颜色标注
- 高亮显示 BM 和 BD,用相同颜色标注
- 高亮显示 CN 和 CD,用相同颜色标注
字幕: "切线长定理:从圆外一点引圆的两条切线,切线长相等"
读白: "根据切线长定理,从圆外一点向圆引的两条切线长度相等。所以,BF 等于 BD,CE 等于 CD。同样,对于圆 J,BM 等于 BD,CN 等于 CD。这个结论非常重要,请同学们注意。"
音频时机: 高亮出现时同步
情感: 强调、重点
动画:
- 0.0s: BF、BD 依次高亮,出现等号
- 2.0s: CE、CD 依次高亮,出现等号
- 4.0s: BM、BD 依次高亮
- 6.0s: CN、CD 依次高亮
- 8.0s: 所有等式汇总显示
- 15.0s: → 等式文字退场,保留图形高亮
目的: 建立切线长相等的直观认识
---
第四幕:双曲线性质推导(35 秒)
画面: 公式逐步推导,配合图形标注
- 左侧显示公式推导
- 右侧图形中高亮 AB、AC 线段
- 然后高亮 KB、KC 线段
字幕: "AB - AC = KB - KC = BD - CD"
读白: "现在我们来计算 AB 减 AC。AB 可以写成 BD 加 AF,AC 可以写成 CD 加 AE。因为 AF 等于 AE,所以 AB 减 AC 就等于 BD 减 CD。同样的方法,对于 KB 减 KC,我们也可以得到 BD 减 CD。这就说明,AB 减 AC 等于 KB 减 KC!"
音频时机: 公式出现时同步
情感: 引导思考、逐步推进
动画:
- 0.0s: 公式第一步出现,图形中 AB、AC 高亮
- 2.0s: 公式第二步出现,KB、KC 高亮
- 4.0s: 公式第三步出现,保留前两步
- 6.0s: 最终等式用红色框突出显示,前序公式退场
- 8.0s: 双曲线虚线轨迹淡入(以 B、C 为焦点)
- 30.0s: → 公式文字退场,保留双曲线和图形
目的: 发现 A、K 在同一双曲线上的关键性质
---
第五幕:蒙日定理应用(30 秒)
画面:
- 显示双曲线(虚线),A、K 在双曲线上
- 连接 EF 和 MN
- 显示 EF 和 MN 的延长线交点
- 出现辅助圆(经过 E、F、M、N 的圆)
字幕: "蒙日定理:双曲线上任一点向两圆作切线,切点共圆"
读白: "我们发现,点 A 和点 K 到两定点 B、C 的距离差是定值。根据双曲线的定义,A 和 K 位于以 B、C 为焦点的同一支双曲线上。根据蒙日定理的推论,从双曲线上的点向有公共切点的两圆作切线,四个切点一定共圆。所以,E、F、M、N 四点共圆。"
音频时机: 双曲线出现后开始
情感: 肯定、强调结论
动画:
- 0.0s: 双曲线虚线淡入
- 2.0s: A、K 点闪烁,显示"在双曲线上"(持续2秒→退场)
- 5.0s: EF、MN 线段加粗
- 8.0s: 辅助圆从淡到实,经过四点
- 12.0s: 四点用圆圈高亮
- 25.0s: → 所有标注文字退场,保留最终图形
目的: 完成证明,展示四点共圆的直观图形
---
第六幕:总结(12 秒)
画面:
- 回到完整图形
- 依次高亮三个关键点
- 显示总结文字
字幕: "关键技巧:切线长定理 → 双曲线识别 → 蒙日定理"
读白: "总结一下,这道题的关键是:第一步,应用切线长定理找到相等线段;第二步,发现 AB 减 AC 等于 KB 减 KC,识别出双曲线;第三步,应用蒙日定理证明四点共圆。同学们,你学会了吗?"
音频时机: 总结文字出现时
情感: 鼓励、肯定
动画:
- 0.0s: 三个关键点依次高亮,每个持续2秒
- 2.0s: 关键点1出现时,对应图形部分闪烁
- 4.0s: 关键点2出现时,对应图形部分闪烁
- 6.0s: 关键点3出现时,对应图形部分闪烁
- 8.0s: "证明完成"字样淡入
- 10.0s: → 关键点文字退场,保留"证明完成"
- 11.0s: → "证明完成"退场,幕结束
目的: 强化记忆,鼓励学生
---
关键动画设计
1. 双曲线轨迹: 用虚线绘制以 B、C 为焦点的双曲线,A、K 在曲线上 2. 切线长高亮: 用相同颜色标注相等的切线长 3. 辅助圆绘制: 从 E 点开始,依次经过 F、M、N 绘制完整圆
配色方案
- 背景色:#1a1a2e(深蓝)
- 三角形边框:#ffffff(白色)
- 内切圆 I: #4ecca3(青色,虚线)
- 内切圆 J: #e94560(红色,虚线)
- 切线长高亮:#ffc107(黄色)
- 双曲线:#9c27b0(紫色,虚线)
- 辅助圆:#00bcd4(cyan,实线)
- 关键点:#ff5722(橙色圆点)
设计备忘
1. 图形绘制顺序很重要: 先建立三角形和第一个内切圆,再引入点 K 和第二个内切圆 2. 双曲线是难点: 需要用虚线清晰绘制,并标注"以 B、C 为焦点" 3. 蒙日定理不需要证明: 只需说明结论,避免过于复杂 4. 高亮时机要精确: 配音提到哪个元素,哪个元素就要高亮 5. 字幕退场约定: 使用 → 标记指定文字退场时机,避免文字残留到下一幕
---
音频生成清单
根据此分镜脚本,需要生成以下音频文件:
| 幕号 | 文件名 | 读白文本 | 时长 | 说话人 | 情感 |
|---|---|---|---|---|---|
| 1 | audio_001_开场.wav | "大家好!今天我们来讲解一道经典的平面几何问题,证明四点共圆。这道题综合运用了切线长定理和双曲线的性质。" | 8 秒 | vivian | 热情 |
| 2 | audio_002_展示图形.wav | "首先,我们来看题目给出的图形。三角形 ABC 的内切圆 I,分别切 BC、CA、AB 于点 D、E、F。三角形内一点 K,使得三角形 KBC 的内切圆 J 也切 BC 于点 D。" | 15 秒 | vivian | 平和 |
| 3 | audio_003_切线长定理.wav | "根据切线长定理,从圆外一点向圆引的两条切线长度相等。所以,BF 等于 BD,CE 等于 CD。同样,对于圆 J,BM 等于 BD,CN 等于 CD。这个结论非常重要,请同学们注意。" | 20 秒 | vivian | 强调 |
| 4 | audio_004_双曲线推导.wav | "现在我们来计算 AB 减 AC。AB 可以写成 BD 加 AF,AC 可以写成 CD 加 AE。因为 AF 等于 AE,所以 AB 减 AC 就等于 BD 减 CD。同样的方法,对于 KB 减 KC,我们也可以得到 BD 减 CD。这就说明,AB 减 AC 等于 KB 减 KC!" | 35 秒 | vivian | 引导 |
| 5 | audio_005_蒙日定理.wav | "我们发现,点 A 和点 K 到两定点 B、C 的距离差是定值。根据双曲线的定义,A 和 K 位于以 B、C 为焦点的同一支双曲线上。根据蒙日定理的推论,从双曲线上的点向有公共切点的两圆作切线,四个切点一定共圆。所以,E、F、M、N 四点共圆。" | 30 秒 | vivian | 肯定 |
| 6 | audio_006_总结.wav | "总结一下,这道题的关键是:第一步,应用切线长定理找到相等线段;第二步,发现 AB 减 AC 等于 KB 减 KC,识别出双曲线;第三步,应用蒙日定理证明四点共圆。同学们,你学会了吗?" | 12 秒 | vivian | 鼓励 |
# Tutor 技能依赖
# 使用 uv 管理依赖: uv pip install -r requirements.txt
# 核心依赖
manim>=0.18.0
numpy>=1.24.0
# TTS 依赖
edge-tts>=6.1.0
# 音频处理
mutagen>=1.47.0
# 工具库
pillow>=10.0.0
file '../audio/audio_001_开场.mp3'
file '../audio/audio_002_展示完整图形.mp3'
file '../audio/audio_003_切线长定理.mp3'
file '../audio/audio_004_双曲线性质推导.mp3'
file '../audio/audio_005_蒙日定理应用.mp3'
file '../audio/audio_006_总结.mp3'
#!/usr/bin/env python3
"""
使用 Edge TTS 从分镜脚本生成配音文件
用法:
python generate_edge_tts.py <分镜脚本.md> [输出目录] [--voice VOICE]
示例:
python generate_edge_tts.py 几何_20260128_正方形面积问题_分镜.md ./audio
python generate_edge_tts.py 分镜.md ./audio --voice zh-CN-YunjianNeural
"""
import asyncio
import edge_tts
import re
import os
import sys
import argparse
import json
from pathlib import Path
from typing import List, Dict, Optional
try:
import miniaudio
HAS_MINIAUDIO = True
except ImportError:
HAS_MINIAUDIO = False
# Edge TTS 支持的中文语音
VOICES = {
'xiaoxiao': 'zh-CN-XiaoxiaoNeural', # 女声,温暖(默认)
'xiaoyi': 'zh-CN-XiaoyiNeural', # 女声,清脆
'yunjian': 'zh-CN-YunjianNeural', # 男声,沉稳
'yunxi': 'zh-CN-YunxiNeural', # 男声,温和
'yunxia': 'zh-CN-YunxiaNeural', # 男声,活力
'yunyang': 'zh-CN-YunyangNeural', # 男声,专业
}
DEFAULT_VOICE = 'zh-CN-XiaoxiaoNeural'
def get_audio_duration(audio_path: str) -> float:
"""
获取音频文件的时长(秒)
参数:
audio_path: 音频文件路径
返回:
时长(秒)
"""
try:
if HAS_MINIAUDIO:
info = miniaudio.get_file_info(audio_path)
return info.duration
else:
# 使用 ffprobe 或 fallback
import subprocess
result = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', audio_path],
capture_output=True, text=True
)
if result.returncode == 0:
return float(result.stdout.strip())
except Exception as e:
print(f" 警告: 无法获取音频时长: {e}")
return 0.0
def parse_storyboard(md_content: str):
"""解析分镜脚本,提取读白"""
scenes = []
num_map = {'一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10}
lines = md_content.split('\n')
current_scene = None
in_voiceover = False
voiceover_lines = []
for line in lines:
if line.startswith('###') and '第' in line and '幕' in line and '(' in line and '秒)' in line:
if current_scene and current_scene.get('voiceover'):
scenes.append(current_scene)
num_char = ''
for c in line:
if c in num_map:
num_char = c
break
idx_mu = line.find('幕')
idx_bracket = line.find('(')
if idx_mu > 0 and idx_bracket > idx_mu:
title = line[idx_mu+1:idx_bracket].strip()
if ':' in title:
title = title[1:]
if ':' in title:
title = title[1:]
idx_end = line.find('秒)')
duration = line[idx_bracket+1:idx_end] if idx_end > 0 else '0'
current_scene = {
"scene_num": num_map.get(num_char, len(scenes) + 1),
"title": title.strip(),
"duration": f"{duration}秒",
"voiceover": ""
}
in_voiceover = False
voiceover_lines = []
continue
if current_scene and '**读白**' in line:
in_voiceover = True
if ':' in line or ':' in line:
content_part = line.split(':', 1)[-1].split(':', 1)[-1].strip()
if content_part and content_part.startswith('"') and content_part.endswith('"'):
content_part = content_part[1:-1]
if content_part and not content_part.startswith('-') and not content_part.startswith('示例'):
voiceover_lines = [content_part]
continue
if in_voiceover and current_scene:
if line.startswith('**') and '读白' not in line:
in_voiceover = False
if voiceover_lines:
current_scene['voiceover'] = ' '.join(voiceover_lines).strip()
continue
if line.strip() and not line.strip().startswith('示例') and not line.strip().startswith('-'):
voiceover_lines.append(line.strip())
if current_scene and '**情感**' in line:
if ':' in line or ':' in line:
content_part = line.split(':', 1)[-1].split(':', 1)[-1].strip()
if content_part and not content_part.startswith('-'):
current_scene['emotion'] = content_part.split('/')[0].strip()
if current_scene and current_scene.get('voiceover'):
scenes.append(current_scene)
return scenes
async def generate_audio(text: str, output_file: str, voice: str = DEFAULT_VOICE):
"""使用 Edge TTS 生成音频"""
communicate = edge_tts.Communicate(text, voice)
await communicate.save(output_file)
return output_file
async def main():
parser = argparse.ArgumentParser(
description='从分镜脚本生成 Edge TTS 配音音频',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 使用默认语音(Xiaoxiao 女声)
python generate_edge_tts.py 分镜.md ./audio
# 使用 Yunjian 男声
python generate_edge_tts.py 分镜.md ./audio --voice yunjian
可用语音:
xiaoxiao - 女声,温暖(默认)
xiaoyi - 女声,清脆
yunjian - 男声,沉稳
yunxi - 男声,温和
yunxia - 男声,活力
yunyang - 男声,专业
"""
)
parser.add_argument('storyboard', help='分镜脚本 Markdown 文件路径')
parser.add_argument('output_dir', nargs='?', default='./audio', help='音频输出目录(默认:./audio)')
parser.add_argument('--voice', default='xiaoxiao', choices=list(VOICES.keys()),
help='语音选择(默认:xiaoxiao)')
parser.add_argument('--yes', action='store_true', help='跳过确认直接生成')
args = parser.parse_args()
# 检查分镜文件
storyboard_path = Path(args.storyboard)
if not storyboard_path.exists():
print(f"错误: 分镜文件不存在: {storyboard_path}")
sys.exit(1)
# 读取分镜内容
print(f"读取分镜脚本: {storyboard_path}")
with open(storyboard_path, 'r', encoding='utf-8') as f:
content = f.read()
# 解析分镜
print("解析分镜脚本...")
scenes = parse_storyboard(content)
print(f"找到 {len(scenes)} 个场景\n")
if not scenes:
print("错误: 未找到任何场景(请检查分镜脚本格式)")
sys.exit(1)
# 创建输出目录
output_dir = Path(args.output_dir)
output_dir.mkdir(exist_ok=True, parents=True)
# 获取语音
voice = VOICES.get(args.voice, DEFAULT_VOICE)
# 显示场景列表
print("场景列表:")
for scene in scenes:
print(f" 场景 {scene['scene_num']}: {scene['title']}")
print(f" 读白: {scene['voiceover'][:50]}...")
print()
# 确认
if not args.yes:
response = input(f"是否继续生成音频? (使用语音: {args.voice}) (y/n): ").strip().lower()
if response != 'y':
print("已取消")
sys.exit(0)
# 生成所有场景的音频
print(f"\n开始生成音频 (使用 Edge TTS - {voice})...\n")
generated_files = []
scenes_with_duration = []
for scene in scenes:
num = scene['scene_num']
title = scene['title']
voiceover = scene['voiceover']
if not voiceover:
print(f"跳过场景 {num}: 无读白内容")
continue
filename = f"audio_{num:03d}_{title}.mp3"
output_path = output_dir / filename
print(f"生成场景 {num}: {title}")
try:
await generate_audio(voiceover, str(output_path), voice)
file_size = output_path.stat().st_size / 1024
# 获取音频时长
duration = get_audio_duration(str(output_path))
scene['duration'] = duration
scenes_with_duration.append(scene)
print(f" ✓ 已生成: {filename} ({file_size:.1f} KB, {duration:.2f}秒)")
generated_files.append(output_path)
except Exception as e:
print(f" ✗ 错误: {e}")
# 生成清单文件(包含时长)
manifest_path = output_dir / "audio_manifest.json"
manifest = {
"total_scenes": len(scenes_with_duration),
"voice": voice,
"scenes": [
{
"scene_num": s['scene_num'],
"title": s['title'],
"voiceover": s['voiceover'],
"duration": s.get('duration', 0),
"audio_file": f"audio_{s['scene_num']:03d}_{s['title']}.mp3"
}
for s in scenes_with_duration
]
}
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
# 生成时间轴文件(用于Manim生成)
timeline_path = output_dir / "timeline.json"
timeline = {
"total_duration": sum(s.get('duration', 0) for s in scenes_with_duration),
"scenes": [
{
"index": i + 1,
"scene_num": s['scene_num'],
"title": s['title'],
"duration": s.get('duration', 0),
"audio_file": f"audio_{s['scene_num']:03d}_{s['title']}.mp3",
"voiceover": s['voiceover'][:100] + "..." if len(s['voiceover']) > 100 else s['voiceover']
}
for i, s in enumerate(scenes_with_duration)
]
}
with open(timeline_path, 'w', encoding='utf-8') as f:
json.dump(timeline, f, ensure_ascii=False, indent=2)
print(f"\n{'='*60}")
print(f"音频生成完成!")
print(f"生成文件数: {len(generated_files)}/{len(scenes)}")
print(f"总时长: {timeline['total_duration']:.2f}秒")
print(f"输出目录: {output_dir}")
print(f"语音: {voice}")
print(f"\n生成的文件:")
for f in sorted(generated_files):
size_kb = f.stat().st_size / 1024
print(f" - {f.name} ({size_kb:.1f} KB)")
print(f"\n音频清单: {manifest_path}")
print(f"时间轴文件: {timeline_path}")
print(f"{'='*60}")
print("\n✅ 音频时长已自动记录,可直接用于生成Manim代码")
if __name__ == "__main__":
asyncio.run(main())
几何证明视频示例
这是一个完整的 Manim 视频制作示例,演示如何用代码生成带配音的几何教学视频。
✨ 核心特性:音频驱动画面同步
本示例实现了真正的音画同步:画面会根据每段音频的实际时长自动等待,确保语音播放完成后才进入下一幕。
传统方式: 音频驱动方式 (本示例):
预估时长 → 动画 生成音频 → 记录实际时长
↓ ↓
动画播放 (可能不同步) 动画自动等待语音结束
↓ ↓
合并音视频 完美同步的音视频📋 文件说明
| 文件 | 说明 |
|---|---|
scene.py | Manim 场景代码(竖屏 9:16,1080x1920) |
几何_20260218_四点共圆证明_分镜.md | 完整分镜脚本 |
storyboard.md | 简化版分镜模板 |
generate_edge_tts.py | 从分镜生成配音音频 |
render.sh | 一键渲染脚本 |
audio/ | 生成的音频文件目录 |
🚀 快速开始
1. 安装依赖
# 安装 Manim
pip install manim
# 安装 Edge TTS
pip install edge-tts
# 安装 ffmpeg
# macOS:
brew install ffmpeg
# Ubuntu/Debian:
# sudo apt install ffmpeg2. 一键渲染
# 生成音频并渲染视频
./render.sh
# 或跳过音频生成(如果已有音频)
./render.sh --skip-audio3. 查看输出
- 视频文件:
final_video.mp4 - 音频目录:
audio/ - 清单文件:
audio/audio_manifest.json
📝 音频驱动工作流程
1. 编辑分镜脚本 (.md)
↓
2. 生成 TTS 音频
python generate_edge_tts.py 分镜.md ./audio
↓
3. 自动记录时长 → audio/timeline.json
{
"scenes": [
{"index": 1, "duration": 11.66, ...},
{"index": 2, "duration": 17.18, ...}
]
}
↓
4. 渲染视频(自动同步)
scene.py 读取 timeline.json
→ 画面自动等待对应音频时长
↓
5. 合并音视频
./render.sh → final_video.mp4同步机制
在 scene.py 中:
def construct(self):
# 加载音频时长
AUDIO_TIMINGS = load_audio_timings() # 从 timeline.json
# 第一幕
self.start_scene("opening") # 开始计时
# ... 动画代码 ...
self.wait_for_audio(animation_time=10) # 自动等待至音频结束
# 第二幕
self.start_scene("show_figure")
# ... 动画代码 ...
self.wait_for_audio(animation_time=15)渲染时会输出等待信息:
▶️ 场景: opening (音频时长: 11.66秒)
⏱️ 动画用时 10.00秒, 等待 1.66秒🎨 自定义内容
修改分镜
编辑 storyboard.md 或创建新的分镜文件:
### 第一幕:开场(5秒)
**画面**: 标题淡入
**字幕**: "我的标题"
**读白**: "大家好,今天我们来学习..."修改动画
编辑 scene.py 中的动画逻辑:
def construct(self):
# 创建几何图形
triangle = Polygon(A, B, C)
self.play(Create(triangle))更换语音
# 使用男声(沉稳)
python generate_edge_tts.py 分镜.md ./audio --voice yunjian
# 可用语音: xiaoxiao, xiaoyi, yunjian, yunxi, yunxia, yunyang📐 画面比例
当前配置为 竖屏 9:16(1080x1920),适合短视频平台。
如需横屏,修改 scene.py:
config.pixel_width = 1920
config.pixel_height = 1080
config.frame_width = 16
config.frame_height = 9🔧 故障排除
找不到 manim
pip install manim
# 或
pip3 install manim找不到 ffmpeg
# macOS
brew install ffmpeg
# Linux
sudo apt install ffmpegEdge TTS 报错
检查网络连接(Edge TTS 需要联网)
音频时长不对
确保先生成音频,再根据实际时长调整动画。使用 audio/audio_manifest.json 中的时长数据。
📚 参考
#!/bin/bash
# 一键渲染脚本 - 音频驱动画面同步
# 流程: 生成音频 → 记录时长 → 渲染视频(根据时长) → 合并音视频
set -e
echo "=========================================="
echo " Manim 视频渲染 - 音频驱动版"
echo "=========================================="
echo ""
echo "🎬 本脚本将根据实际音频时长自动同步画面"
echo ""
# 检查参数
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
echo "用法: ./render.sh [选项]"
echo ""
echo "选项:"
echo " --audio 重新生成音频并渲染(默认)"
echo " --skip-audio 使用现有音频,仅重新渲染视频"
echo " --clean 清理环境并重新创建"
echo " --help 显示此帮助"
echo ""
echo "流程:"
echo " 1. 生成 TTS 音频"
echo " 2. 记录每段音频实际时长 → audio/timeline.json"
echo " 3. 渲染视频(画面自动等待对应音频时长)"
echo " 4. 合并音视频 → final_video.mp4"
exit 0
fi
# 检查 uv
if ! command -v uv &> /dev/null; then
echo "❌ 错误: 未找到 uv"
echo ""
echo "请安装 uv:"
echo " macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh"
echo " 或访问: https://github.com/astral-sh/uv"
exit 1
fi
# 检查 ffmpeg
if ! command -v ffmpeg &> /dev/null; then
echo "❌ 错误: 未找到 ffmpeg"
echo "请安装: brew install ffmpeg (macOS) 或 apt install ffmpeg (Linux)"
exit 1
fi
# 设置环境
VENV_DIR=".venv"
# 清理环境
if [ "$1" == "--clean" ]; then
echo "🧹 清理环境..."
rm -rf "$VENV_DIR" media/ final_video.mp4
echo "✅ 环境已清理"
echo ""
fi
# 创建虚拟环境
if [ ! -d "$VENV_DIR" ]; then
echo "📦 创建虚拟环境..."
uv venv "$VENV_DIR"
echo "✅ 虚拟环境已创建"
echo ""
fi
# 安装依赖
echo "📦 安装依赖..."
if [ -f "requirements.txt" ]; then
echo " 使用 requirements.txt"
uv pip install --python "$VENV_DIR/bin/python" -r requirements.txt -q
else
echo " 使用默认依赖"
uv pip install --python "$VENV_DIR/bin/python" manim edge-tts -q
fi
echo "✅ 依赖安装完成"
echo ""
PYTHON="$VENV_DIR/bin/python"
MANIM="$VENV_DIR/bin/manim"
# 步骤 1: 生成音频
generate_audio() {
echo "📢 步骤 1: 生成 TTS 音频"
echo "----------------------------------------"
# 查找分镜文件
STORYBOARD=$(ls *.md 2>/dev/null | grep -E "(分镜|storyboard)" | head -1)
if [ -z "$STORYBOARD" ]; then
STORYBOARD=$(ls *.md 2>/dev/null | head -1)
fi
if [ -z "$STORYBOARD" ]; then
echo "❌ 错误: 未找到分镜脚本 (.md 文件)"
exit 1
fi
echo "使用分镜: $STORYBOARD"
echo ""
# 生成音频(会自动创建 audio/timeline.json)
"$PYTHON" generate_edge_tts.py "$STORYBOARD" ./audio --yes
if [ ! -f "audio/timeline.json" ]; then
echo "❌ 错误: timeline.json 未生成"
exit 1
fi
echo ""
echo "✅ 音频生成完成,时长已记录"
echo ""
}
# 步骤 2: 渲染视频
render_video() {
echo "🎬 步骤 2: 渲染视频(音频驱动)"
echo "----------------------------------------"
# 确定场景文件
SCENE_NAME="GeometryProof"
if [ -f "scene.py" ]; then
SCENE_FILE="scene.py"
else
echo "❌ 错误: 未找到 scene.py"
exit 1
fi
echo "场景: $SCENE_FILE - $SCENE_NAME"
echo "分辨率: 1080x1920 (竖屏)"
echo ""
# 检查 timeline.json
if [ -f "audio/timeline.json" ]; then
TOTAL_DURATION=$(cat audio/timeline.json | grep -o '"total_duration": [0-9.]*' | cut -d' ' -f2)
echo "📊 音频总时长: ${TOTAL_DURATION}秒"
echo " (视频将自动同步各场景时长)"
echo ""
fi
# 渲染高质量 MP4
"$MANIM" -pqh --format=mp4 "$SCENE_FILE" "$SCENE_NAME"
echo ""
echo "✅ 视频渲染完成"
echo ""
}
# 步骤 3: 合并音视频
merge_audio_video() {
echo "🔧 步骤 3: 合并音视频"
echo "----------------------------------------"
# 查找生成的视频
VIDEO_FILE="media/videos/scene/1920p30/GeometryProof.mp4"
if [ ! -f "$VIDEO_FILE" ]; then
echo "❌ 错误: 未找到视频文件"
exit 1
fi
VIDEO_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$VIDEO_FILE" 2>/dev/null | cut -d. -f1)
echo "视频时长: ${VIDEO_DURATION}秒"
# 合并音频
if [ -d "audio" ] && [ "$(ls audio/audio_*.mp3 2>/dev/null | wc -l)" -gt 0 ]; then
echo "合并音频..."
# 创建音频列表
AUDIO_LIST="audio_list.txt"
echo "" > "$AUDIO_LIST"
for f in audio/audio_*.mp3; do
[ -f "$f" ] && echo "file '../$f'" >> "$AUDIO_LIST"
done
# 合并音频
ffmpeg -f concat -safe 0 -i "$AUDIO_LIST" -c copy /tmp/combined_audio.mp3 -y 2>/dev/null || true
if [ -f "/tmp/combined_audio.mp3" ]; then
AUDIO_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 /tmp/combined_audio.mp3 2>/dev/null | cut -d. -f1)
echo "音频时长: ${AUDIO_DURATION}秒"
echo ""
# 合并视频和音频(取较长者)
ffmpeg -i "$VIDEO_FILE" -i /tmp/combined_audio.mp3 -c:v copy -c:a aac \
-shortest final_video.mp4 -y 2>&1 | grep -E "(Duration|Output|Stream)" || true
echo ""
echo "✅ 已生成: final_video.mp4"
# 验证时长
if [ -f "final_video.mp4" ]; then
FINAL_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 final_video.mp4 2>/dev/null | cut -d. -f1)
echo " 最终时长: ${FINAL_DURATION}秒"
fi
else
cp "$VIDEO_FILE" final_video.mp4
echo "⚠️ 未找到音频,仅复制视频"
fi
else
cp "$VIDEO_FILE" final_video.mp4
echo "⚠️ 未找到音频,仅复制视频"
fi
}
# 执行流程
if [ "$1" != "--skip-audio" ]; then
generate_audio
else
echo "📢 步骤 1: 跳过音频生成"
echo " (使用现有 audio/timeline.json)"
echo ""
if [ ! -f "audio/timeline.json" ]; then
echo "❌ 错误: audio/timeline.json 不存在"
echo " 请先运行: ./render.sh (不带 --skip-audio)"
exit 1
fi
echo ""
fi
render_video
merge_audio_video
echo ""
echo "=========================================="
echo "✅ 全部完成!"
echo "=========================================="
echo ""
echo "输出文件:"
echo " 📹 final_video.mp4 - 最终视频(含音频)"
echo " 📁 media/videos/ - 原始视频文件"
echo " 🎵 audio/ - 音频文件和时长数据"
echo " 📊 audio/timeline.json - 音频时长记录"
echo ""
echo "💡 提示:"
echo " - 修改分镜后重新运行 ./render.sh 即可"
echo " - 画面会自动根据 audio/timeline.json 中的时长同步"
echo ""
# 视频生成依赖
manim>=0.20.0
# TTS 语音生成
edge-tts>=7.0.0
# 可选:音频处理(用于获取音频时长)
# miniaudio>=1.5.0
"""
第九十九题:证明四点共圆 - Manim 动画 (音频驱动版本)
根据 timeline.json 中的实际音频时长自动同步画面
"""
from manim import *
import numpy as np
import json
import os
# 配置 - 竖屏 9:16
config.background_color = "#1a1a2e"
config.frame_rate = 30
config.pixel_height = 1920
config.pixel_width = 1080
config.frame_height = 16
config.frame_width = 9
# 加载实际音频时长
def load_audio_timings():
"""从 timeline.json 加载音频时长,如果不存在则使用默认值"""
timeline_path = "audio/timeline.json"
if os.path.exists(timeline_path):
with open(timeline_path, 'r', encoding='utf-8') as f:
timeline = json.load(f)
# 转换为秒数
timings = {}
for scene in timeline.get('scenes', []):
idx = scene.get('index', 0)
duration = scene.get('duration', 5)
# 根据索引映射到场景名称
scene_names = ['opening', 'show_figure', 'tangent', 'hyperbola', 'monge', 'summary']
if idx <= len(scene_names):
timings[scene_names[idx-1]] = duration
print(f"✅ 已加载音频时长: {timings}")
return timings
# 默认时长(当 timeline.json 不存在时使用)
print("⚠️ 未找到 timeline.json,使用默认时长")
return {
"opening": 11.66,
"show_figure": 17.18,
"tangent": 18.46,
"hyperbola": 25.25,
"monge": 25.22,
"summary": 16.66
}
AUDIO_TIMINGS = load_audio_timings()
# 颜色定义
CIRCLE_I_COLOR = "#3498db" # 蓝色 - 内切圆 I (虚线)
CIRCLE_J_COLOR = "#e74c3c" # 红色 - 内切圆 J (虚线)
TRIANGLE_COLOR = "#2c3e50" # 深蓝灰 - 三角形 ABC
TRIANGLE_INNER_COLOR = "#7f8c8d" # 灰色 - 三角形 KBC
HIGHLIGHT_COLOR = "#ffc107" # 黄色 - 高亮
HYPERBOLA_COLOR = "#9c27b0" # 紫色 - 双曲线
AUX_CIRCLE_COLOR = "#00bcd4" # 青色 - 辅助圆(四点共圆)
POINT_COLOR = "#000000" # 黑色 - 点
I_POINT_COLOR = "#3498db" # 蓝色 - 内心 I
J_POINT_COLOR = "#e74c3c" # 红色 - 内心 J
def calculate_geometry():
"""根据 6.html 的数学模型计算所有点和圆"""
# 缩放因子(适配竖屏 9:16)
SCALE = 0.7
# 1. 定义 B、C 在 x 轴上
B = np.array([-5.0, 0.0, 0.0]) * SCALE
C = np.array([5.0, 0.0, 0.0]) * SCALE
# 2. 定义 A 点 (AB > AC,所以 A 在右侧)
A = np.array([1.5, 9.0, 0.0]) * SCALE
# 3. 计算边长
def dist(p1, p2):
return np.linalg.norm(p1 - p2)
AB = dist(A, B)
AC = dist(A, C)
BC = dist(B, C)
# 4. 计算 D 点(内切圆 I 在 BC 上的切点)
# BD = (AB + BC - AC) / 2
BD = (AB + BC - AC) / 2
CD = BC - BD
# D 在 BC 上,从 B 向 C 移动 BD 距离
D = B + (BD / BC) * (C - B)
# 5. 计算双曲线参数(K 点在双曲线上)
# 双曲线焦点:B(-5,0), C(5,0), 中心在原点
# 2a = |AB - AC|
diff_ABC = abs(AB - AC)
a_hyp = diff_ABC / 2
c_hyp = 5.0 # 焦点到中心的距离
b_hyp = np.sqrt(c_hyp**2 - a_hyp**2)
# 6. 定义 K 点(在双曲线上,y < y_A,在三角形内部)
yK = 4.5
# x^2 = a^2 * (1 + y^2/b^2)
xK = a_hyp * np.sqrt(1 + (yK**2) / (b_hyp**2))
K = np.array([xK, yK, 0.0])
# 7. 计算切点 F(在 AB 上,BF = BD)
vec_BA = (A - B) / AB
F = B + BD * vec_BA
# 8. 计算切点 E(在 AC 上,CE = CD)
vec_CA = (A - C) / AC
E = C + CD * vec_CA
# 9. 计算切点 M(在 KB 上,BM = BD)
KB = dist(K, B)
vec_BK = (K - B) / KB
M = B + BD * vec_BK
# 10. 计算切点 N(在 KC 上,CN = CD)
KC = dist(K, C)
vec_CK = (K - C) / KC
N = C + CD * vec_CK
# 11. 计算内心 I 和 J
# 因为 BC 是水平的,切点在 D,所以 I 和 J 都在 x = D.x 的垂直线上
# I.y = r_I, J.y = r_J
# 三角形 ABC 的面积和半周长
area_ABC = 0.5 * BC * A[1]
s_ABC = (AB + AC + BC) / 2
r_I = area_ABC / s_ABC
I = np.array([D[0], r_I, 0.0])
# 三角形 KBC 的面积和半周长
area_KBC = 0.5 * BC * K[1]
s_KBC = (KB + KC + BC) / 2
r_J = area_KBC / s_KBC
J = np.array([D[0], r_J, 0.0])
return {
'A': A, 'B': B, 'C': C, 'D': D, 'E': E, 'F': F,
'K': K, 'M': M, 'N': N, 'I': I, 'J': J,
'r_I': r_I, 'r_J': r_J,
'AB': AB, 'AC': AC, 'BC': BC,
'BD': BD, 'CD': CD,
'KB': KB, 'KC': KC
}
class GeometryProof(Scene):
"""几何证明动画 - 音频驱动,画面自动同步语音时长"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.scene_start_time = 0
self.current_scene_name = ""
def start_scene(self, scene_name):
"""开始一个场景,记录开始时间"""
self.current_scene_name = scene_name
self.scene_start_time = self.time
print(f"\n▶️ 场景: {scene_name} (音频时长: {AUDIO_TIMINGS.get(scene_name, 5):.2f}秒)")
def wait_for_audio(self, animation_time=0):
"""等待直到音频播放完成
参数:
animation_time: 已经用于动画的时间(秒)
"""
total_time = AUDIO_TIMINGS.get(self.current_scene_name, 5)
remaining = total_time - animation_time
if remaining > 0:
print(f" ⏱️ 动画用时 {animation_time:.2f}秒, 等待 {remaining:.2f}秒")
self.wait(remaining)
else:
print(f" ⚠️ 动画用时 {animation_time:.2f}秒 已超过音频时长!")
def construct(self):
# 计算所有几何元素
geo = calculate_geometry()
# ========== 第一幕:开场 ==========
self.start_scene("opening")
# 标题动画
title = Text("第九十九题:证明四点共圆", font_size=52, color=WHITE)
subtitle = Text("三角形内切圆与双曲线性质的综合运用", font_size=32, color=GRAY_B)
subtitle.next_to(title, DOWN, buff=0.5)
# 题目描述
problem_text = VGroup(
Text("已知:", font_size=28, color=GRAY_B),
Text("△ABC 内切圆⊙I 切 BC、CA、AB 于 D、E、F", font_size=24),
Text("K 为△ABC 内一点,△KBC 内切圆⊙J 切 BC 于 D", font_size=24),
Text("求证:E、F、M、N 四点共圆", font_size=28, color=HIGHLIGHT_COLOR)
).arrange(DOWN, aligned_edge=LEFT, buff=0.4)
problem_text.to_edge(DOWN, buff=1.5)
self.play(Write(title), run_time=3)
self.play(FadeIn(subtitle), run_time=2)
self.wait(2)
self.play(
FadeOut(title),
FadeOut(subtitle),
Write(problem_text),
run_time=4
)
# 等待音频完成
self.wait_for_audio(animation_time=10)
# 淡出问题描述
self.play(FadeOut(problem_text), run_time=1)
# ========== 第二幕:展示完整图形 ==========
self.start_scene("show_figure")
# 1. 先画三角形 ABC
triangle = Polygon(geo['A'], geo['B'], geo['C'],
color=TRIANGLE_COLOR, stroke_width=2.5, fill_opacity=0.05)
self.play(Create(triangle), run_time=2)
self.wait(0.5)
# 2. 显示顶点和标签
labels_ABC = VGroup(
Text("A", font_size=32).next_to(geo['A'], UP, buff=0.15),
Text("B", font_size=32).next_to(geo['B'], LEFT, buff=0.15),
Text("C", font_size=32).next_to(geo['C'], RIGHT, buff=0.15)
)
dots_ABC = VGroup(
Dot(geo['A'], color=POINT_COLOR, radius=0.08),
Dot(geo['B'], color=POINT_COLOR, radius=0.08),
Dot(geo['C'], color=POINT_COLOR, radius=0.08)
)
self.play(
FadeIn(dots_ABC),
FadeIn(labels_ABC),
run_time=2
)
# 3. 计算并显示 D 点
D_dot = Dot(geo['D'], color=POINT_COLOR, radius=0.08)
D_label = Text("D", font_size=32).next_to(geo['D'], DOWN, buff=0.15)
# BD 和 CD 的长度标注
BD_mid = (geo['B'] + geo['D']) / 2
BD_text = Text(f"BD = {geo['BD']:.2f}", font_size=24, color=GRAY_B)
BD_text.next_to(BD_mid, DOWN, buff=0.3)
self.play(
FadeIn(D_dot),
FadeIn(D_label),
Write(BD_text),
run_time=2
)
self.wait(0.5)
self.play(FadeOut(BD_text), run_time=0.5)
# 4. 画内切圆 I (蓝色虚线)
circle_I = DashedVMobject(
Circle(radius=geo['r_I'], color=CIRCLE_I_COLOR, stroke_width=3),
num_dashes=30,
dashed_ratio=0.5
)
circle_I.set_stroke(opacity=0.9)
circle_I.move_to(geo['I'])
I_dot = Dot(geo['I'], color=I_POINT_COLOR, radius=0.08)
I_label = Text("I", font_size=28, color=CIRCLE_I_COLOR).next_to(geo['I'], RIGHT, buff=0.1)
self.play(
Create(circle_I),
FadeIn(I_dot),
FadeIn(I_label),
run_time=3
)
# 5. 显示切点 E、F
E_dot = Dot(geo['E'], color=POINT_COLOR, radius=0.08)
E_label = Text("E", font_size=28).next_to(geo['E'], RIGHT, buff=0.1)
F_dot = Dot(geo['F'], color=POINT_COLOR, radius=0.08)
F_label = Text("F", font_size=28).next_to(geo['F'], LEFT, buff=0.1)
self.play(
FadeIn(E_dot), FadeIn(E_label),
FadeIn(F_dot), FadeIn(F_label),
run_time=2
)
self.wait(0.5)
# 6. 显示点 K
K_dot = Dot(geo['K'], color=POINT_COLOR, radius=0.08)
K_label = Text("K", font_size=28).next_to(geo['K'], UP, buff=0.15)
self.play(
FadeIn(K_dot), FadeIn(K_label),
run_time=2
)
# 7. 画三角形 KBC
triangle_KBC = Polygon(geo['K'], geo['B'], geo['C'],
color=TRIANGLE_INNER_COLOR, stroke_width=1.5, fill_opacity=0)
self.play(Create(triangle_KBC), run_time=1.5)
# 8. 画内切圆 J (红色虚线)
circle_J = DashedVMobject(
Circle(radius=geo['r_J'], color=CIRCLE_J_COLOR, stroke_width=3),
num_dashes=25,
dashed_ratio=0.5
)
circle_J.move_to(geo['J'])
circle_J.set_stroke(opacity=0.9)
J_dot = Dot(geo['J'], color=J_POINT_COLOR, radius=0.08)
J_label = Text("J", font_size=28, color=CIRCLE_J_COLOR).next_to(geo['J'], RIGHT, buff=0.1)
self.play(
Create(circle_J),
FadeIn(J_dot),
FadeIn(J_label),
run_time=2.5
)
# 9. 显示切点 M、N
M_dot = Dot(geo['M'], color=POINT_COLOR, radius=0.08)
M_label = Text("M", font_size=28).next_to(geo['M'], LEFT, buff=0.1)
N_dot = Dot(geo['N'], color=POINT_COLOR, radius=0.08)
N_label = Text("N", font_size=28).next_to(geo['N'], RIGHT, buff=0.1)
self.play(
FadeIn(M_dot), FadeIn(M_label),
FadeIn(N_dot), FadeIn(N_label),
run_time=2
)
# 显示字幕:已知条件
subtitle1 = Text("已知:两内切圆共切点 D", font_size=32, color=WHITE)
subtitle1.to_edge(DOWN, buff=0.8)
self.play(Write(subtitle1), run_time=2)
self.wait(1)
self.play(FadeOut(subtitle1), run_time=0.5)
# 等待音频完成 (2+0.5+2+2+0.5+3+2+0.5+2+1.5+2.5+2+2+1+0.5 = 大约16秒)
self.wait_for_audio(animation_time=16)
# ========== 第三幕:切线长定理 ==========
self.start_scene("tangent")
# 简化图形,突出切线长
self.play(
triangle.animate.set_stroke(width=0.8).set_fill(opacity=0.3),
triangle_KBC.animate.set_stroke(width=0.5).set_fill(opacity=0.3),
circle_I.animate.set_stroke(width=0.8, opacity=0.3),
circle_J.animate.set_stroke(width=0.8, opacity=0.3),
run_time=0.5
)
# 标题
tangent_title = Text("切线长定理", font_size=40, color=HIGHLIGHT_COLOR)
tangent_title.to_edge(UP, buff=0.5)
self.play(Write(tangent_title), run_time=1)
# 1. BF = BD
BF_line = Line(geo['B'], geo['F'], color=HIGHLIGHT_COLOR, stroke_width=6)
BD_line_1 = Line(geo['B'], geo['D'], color=HIGHLIGHT_COLOR, stroke_width=6)
eq1 = Text("BF = BD", font_size=36, color=HIGHLIGHT_COLOR).to_edge(RIGHT, buff=0.5).shift(UP * 2.5)
self.play(
Create(BF_line),
Create(BD_line_1),
Write(eq1),
run_time=2
)
self.wait(1)
self.play(FadeOut(BF_line), FadeOut(BD_line_1), FadeOut(eq1), run_time=0.5)
# 2. CE = CD
CE_line = Line(geo['C'], geo['E'], color=HIGHLIGHT_COLOR, stroke_width=6)
CD_line_1 = Line(geo['C'], geo['D'], color=HIGHLIGHT_COLOR, stroke_width=6)
eq2 = Text("CE = CD", font_size=36, color=HIGHLIGHT_COLOR).to_edge(RIGHT, buff=0.5).shift(UP * 1.8)
self.play(
Create(CE_line),
Create(CD_line_1),
Write(eq2),
run_time=2
)
self.wait(1)
self.play(FadeOut(CE_line), FadeOut(CD_line_1), FadeOut(eq2), run_time=0.5)
# 3. BM = BD
BM_line = Line(geo['B'], geo['M'], color=HIGHLIGHT_COLOR, stroke_width=6)
BD_line_2 = Line(geo['B'], geo['D'], color=HIGHLIGHT_COLOR, stroke_width=6)
eq3 = Text("BM = BD", font_size=36, color=HIGHLIGHT_COLOR).to_edge(RIGHT, buff=0.5).shift(UP * 1.1)
self.play(
Create(BM_line),
Create(BD_line_2),
Write(eq3),
run_time=2
)
self.wait(1)
self.play(FadeOut(BM_line), FadeOut(BD_line_2), FadeOut(eq3), run_time=0.5)
# 4. CN = CD
CN_line = Line(geo['C'], geo['N'], color=HIGHLIGHT_COLOR, stroke_width=6)
CD_line_2 = Line(geo['C'], geo['D'], color=HIGHLIGHT_COLOR, stroke_width=6)
eq4 = Text("CN = CD", font_size=36, color=HIGHLIGHT_COLOR).to_edge(RIGHT, buff=0.5).shift(UP * 0.4)
self.play(
Create(CN_line),
Create(CD_line_2),
Write(eq4),
run_time=2
)
self.wait(1.5)
self.play(
FadeOut(CN_line), FadeOut(CD_line_2), FadeOut(eq4),
FadeOut(tangent_title),
run_time=0.5
)
# 恢复图形
self.play(
triangle.animate.set_stroke(width=2.5, opacity=1),
triangle_KBC.animate.set_stroke(width=1.5, opacity=1),
circle_I.animate.set_stroke(width=2, opacity=1),
circle_J.animate.set_stroke(width=2, opacity=1),
run_time=1
)
# 等待音频完成
self.wait_for_audio(animation_time=17)
# ========== 第四幕:双曲线性质推导 ==========
self.start_scene("hyperbola")
hyperbola_title = Text("双曲线性质", font_size=40, color=HYPERBOLA_COLOR)
hyperbola_title.to_edge(UP, buff=0.5)
self.play(Write(hyperbola_title), run_time=1.5)
# 公式推导(分步显示)
# 步骤 1: AB - AC 的计算
formula1 = Text("AB - AC = (BD + AF) - (CD + AE)", font_size=30, color=WHITE)
formula1.to_edge(UP, buff=1.2)
# 高亮 AB 和 AC
AB_highlight = Line(geo['A'], geo['B'], color=BLUE, stroke_width=8)
AB_highlight.set_stroke(opacity=0.5)
AC_highlight = Line(geo['A'], geo['C'], color=BLUE, stroke_width=8)
AC_highlight.set_stroke(opacity=0.5)
self.play(
Write(formula1),
Create(AB_highlight),
Create(AC_highlight),
run_time=2.5
)
self.wait(1)
self.play(FadeOut(AB_highlight), FadeOut(AC_highlight), run_time=0.5)
# 步骤 2: 化简
formula2 = Text("= BD - CD (∵ AF = AE)", font_size=30, color=WHITE)
formula2.next_to(formula1, DOWN, buff=0.3, aligned_edge=LEFT)
self.play(Write(formula2), run_time=2)
self.wait(1)
# 步骤 3: KB - KC 的计算
formula3 = Text("KB - KC = (BM + MK) - (CN + NK)", font_size=30, color=WHITE)
formula3.next_to(formula2, DOWN, buff=0.5)
KB_highlight = Line(geo['K'], geo['B'], color=GREEN, stroke_width=8)
KB_highlight.set_stroke(opacity=0.5)
KC_highlight = Line(geo['K'], geo['C'], color=GREEN, stroke_width=8)
KC_highlight.set_stroke(opacity=0.5)
self.play(
Write(formula3),
Create(KB_highlight),
Create(KC_highlight),
run_time=2.5
)
self.wait(1)
self.play(FadeOut(KB_highlight), FadeOut(KC_highlight), run_time=0.5)
# 步骤 4: 化简
formula4 = Text("= BM - CN = BD - CD", font_size=30, color=WHITE)
formula4.next_to(formula3, DOWN, buff=0.3, aligned_edge=LEFT)
self.play(Write(formula4), run_time=2)
self.wait(1)
# 结论
conclusion_hyperbola = Text("∴ AB - AC = KB - KC", font_size=38, color=HIGHLIGHT_COLOR)
conclusion_hyperbola.next_to(formula4, DOWN, buff=0.5)
self.play(Write(conclusion_hyperbola), run_time=1.5)
self.wait(2)
# 清除公式
self.play(
FadeOut(formula1), FadeOut(formula2), FadeOut(formula3),
FadeOut(formula4), FadeOut(conclusion_hyperbola),
run_time=1
)
# 绘制双曲线
hyperbola = self._create_hyperbola(geo['B'], geo['C'], abs(geo['AB'] - geo['AC']) / 2)
hyperbola.set_stroke(HYPERBOLA_COLOR, width=2.5)
hyperbola.set_stroke(opacity=0.7)
self.play(Create(hyperbola), run_time=3)
# 显示 A 和 K 在双曲线上
A_on_curve = Text("A 在双曲线上", font_size=28, color=WHITE).next_to(geo['A'], UR, buff=0.2)
K_on_curve = Text("K 在双曲线上", font_size=28, color=WHITE).next_to(geo['K'], UR, buff=0.2)
# 创建 A 和 K 的高亮点
A_highlight = Dot(geo['A'], color=HIGHLIGHT_COLOR, radius=0.15)
K_highlight = Dot(geo['K'], color=HIGHLIGHT_COLOR, radius=0.15)
self.play(
Write(A_on_curve),
Create(A_highlight),
run_time=1.5
)
self.wait(0.5)
self.play(
Write(K_on_curve),
Create(K_highlight),
run_time=1.5
)
self.wait(2)
# 清除双曲线相关
self.play(
FadeOut(hyperbola), FadeOut(A_on_curve), FadeOut(K_on_curve),
FadeOut(A_highlight), FadeOut(K_highlight),
run_time=1
)
# 等待音频完成
self.wait_for_audio(animation_time=24)
# ========== 第五幕:蒙日定理应用 ==========
monge_title = Text("蒙日定理推论", font_size=40, color=AUX_CIRCLE_COLOR)
monge_title.to_edge(UP, buff=0.5)
self.play(Write(monge_title), run_time=1.5)
# 显示定理内容
monge_text = VGroup(
Text("从双曲线上的点向有公共切点的两圆作切线,", font_size=28),
Text("四个切点共圆", font_size=28, color=HIGHLIGHT_COLOR)
).arrange(DOWN, buff=0.3)
monge_text.next_to(monge_title, DOWN, buff=0.5)
self.play(Write(monge_text), run_time=3)
self.wait(1)
# 绘制辅助圆(E、F、M、N 的外接圆)
aux_circle = self._create_circumcircle(geo['E'], geo['F'], geo['M'])
aux_circle.set_stroke(AUX_CIRCLE_COLOR, width=3.5)
self.play(Create(aux_circle), run_time=3)
# 高亮 E、F、M、N 四点
EFMN_dots = VGroup(
Dot(geo['E'], color=AUX_CIRCLE_COLOR, radius=0.12),
Dot(geo['F'], color=AUX_CIRCLE_COLOR, radius=0.12),
Dot(geo['M'], color=AUX_CIRCLE_COLOR, radius=0.12),
Dot(geo['N'], color=AUX_CIRCLE_COLOR, radius=0.12)
)
# 连接四点形成四边形
quad = Polygon(geo['E'], geo['F'], geo['M'], geo['N'],
color=AUX_CIRCLE_COLOR, stroke_width=1.5)
quad.set_fill(opacity=0.1)
self.play(
Create(quad),
FadeIn(EFMN_dots),
run_time=2.5
)
# 显示结论
conclusion_final = Text("∴ E、F、M、N 四点共圆", font_size=44, color=HIGHLIGHT_COLOR)
conclusion_final.to_edge(DOWN, buff=1.5)
self.play(Write(conclusion_final), run_time=2)
self.wait(3)
# 清除
self.play(
FadeOut(monge_title), FadeOut(monge_text),
FadeOut(conclusion_final),
FadeOut(quad), FadeOut(EFMN_dots),
run_time=1
)
# 等待音频完成
self.wait_for_audio(animation_time=24)
# ========== 第六幕:总结 ==========
summary_title = Text("证明步骤总结", font_size=44, color=WHITE)
summary_title.to_edge(UP, buff=0.5)
step1 = Text("1. 切线长定理 → 相等线段", font_size=34, color=WHITE)
step2 = Text("2. 双曲线识别 → AB - AC = KB - KC", font_size=34, color=WHITE)
step3 = Text("3. 蒙日定理 → 四点共圆", font_size=34, color=HIGHLIGHT_COLOR)
steps = VGroup(step1, step2, step3).arrange(DOWN, aligned_edge=LEFT, buff=0.5)
steps.next_to(summary_title, DOWN, buff=0.8)
# 逐步显示步骤
self.play(Write(summary_title), run_time=1.5)
self.wait(0.5)
self.play(Write(step1), run_time=2)
self.wait(1)
self.play(Write(step2), run_time=2)
self.wait(1)
self.play(Write(step3), run_time=2)
self.wait(2)
# 证明完成
done_text = Text("证明完成!", font_size=64, color=HIGHLIGHT_COLOR)
done_text.next_to(steps, DOWN, buff=1)
self.play(Write(done_text), run_time=2)
# 等待音频完成
self.wait_for_audio(animation_time=15)
# 结束
self.play(
*[FadeOut(mob) for mob in self.mobjects],
run_time=1.5
)
def _create_hyperbola(self, F1, F2, a, num_points=200):
"""创建双曲线"""
c = np.linalg.norm(F1 - F2) / 2
center = (F1 + F2) / 2
if c > a:
b = np.sqrt(c**2 - a**2)
else:
b = 0.5
# 生成双曲线点(两支)
t = np.linspace(-1.5, 1.5, num_points)
points = []
for ti in t:
# 右支
x_r = a * np.cosh(ti)
y_r = b * np.sinh(ti)
points.append([center[0] + x_r, center[1] + y_r, 0])
# 左支
x_l = -a * np.cosh(ti)
y_l = b * np.sinh(ti)
points.append([center[0] + x_l, center[1] + y_l, 0])
# 创建曲线
hyperbola = VGroup()
for p in points:
if -6 < p[1] < 10: # 限制显示范围
dot = Dot(p, radius=0.025, color=HYPERBOLA_COLOR)
hyperbola.add(dot)
return hyperbola
def _create_circumcircle(self, A, B, C):
"""创建外接圆"""
# 计算外心
D = 2 * (A[0] * (B[1] - C[1]) + B[0] * (C[1] - A[1]) + C[0] * (A[1] - B[1]))
if abs(D) < 1e-10:
# 三点共线,返回一个大圆
return Circle(radius=10, color=AUX_CIRCLE_COLOR)
Ux = ((A[0]**2 + A[1]**2) * (B[1] - C[1]) +
(B[0]**2 + B[1]**2) * (C[1] - A[1]) +
(C[0]**2 + C[1]**2) * (A[1] - B[1])) / D
Uy = ((A[0]**2 + A[1]**2) * (C[0] - B[0]) +
(B[0]**2 + B[1]**2) * (A[0] - C[0]) +
(C[0]**2 + C[1]**2) * (B[0] - A[0])) / D
center = np.array([Ux, Uy, 0])
radius = np.linalg.norm(A - center)
return Circle(radius=radius, color=AUX_CIRCLE_COLOR).move_to(center)
if __name__ == "__main__":
# 使用命令行渲染:python -m manim -qh geometry_proof_audio.py GeometryProof
pass
视频分镜脚本 - 正方形面积问题
基本信息
- 时长预估: 约 60 秒
- 场景数: 5
- 风格: 清新活泼,适合初学者
- 画面比例: 竖屏 9:16 (1080x1920)
制作流程检查清单
- [ ] 1. 完成分镜脚本设计
- [ ] 2. 生成 TTS 音频(获取实际时长)
- [ ] 3. 更新分镜:填入实际音频时长
- [ ] 4. 根据音频时长编写 Manim 动画
- [ ] 5. 渲染无声视频
- [ ] 6. 合并音频和视频
---
分镜设计
第一幕:开场(5秒)
画面: 标题"正方形面积问题"淡入,下方显示完整图形
字幕: "正方形面积问题"
读白: "大家好!今天我们来学习一道经典的几何面积问题。"
音频时机: 标题出现后 0.5 秒开始
情感: 轻松、热情
---
第二幕:分析已知条件(12秒)
画面:
- 高亮正方形 ABCD
- 标注边长 5 厘米
字幕: "正方形边长为 5 厘米"
读白: "题目告诉我们,正方形 ABCD 的边长是 5 厘米。我们还知道三角形 CEF 的面积比三角形 ADF 的面积大 5 平方厘米。"
音频时机: 图形稳定后开始
情感: 平和、清晰
---
第三幕:核心技巧演示(20秒)
画面:
- 显示辅助四边形 ABCF
- 动画展示:小三角形 + 四边形 = 大三角形
字幕: "关键技巧:同加同减"
读白: "这里有一个关键技巧,叫做同加同减。我们给三角形 CEF 加上四边形 ABCF,就得到了大三角形 ABE。同样的,给三角形 ADF 加上四边形 ABCF,就得到了正方形 ABCD。"
音频时机: 辅助图形出现后开始
情感: 强调、重点
---
第四幕:计算过程(15秒)
画面:
- 逐步显示公式
- S_正方形 = 5 × 5 = 25
- S_三角形 ABE = 25 + 5 = 30
字幕: "正方形面积 = 25"
读白: "正方形的面积是 5 乘 5 等于 25 平方厘米。根据题目条件,三角形 ABE 的面积等于 25 加 5,也就是 30 平方厘米。"
音频时机: 公式出现时同步
情感: 平稳、耐心
---
第五幕:总结(8秒)
画面:
- 高亮最终答案
- 核心技巧文字
字幕: "答案:CE = 7 厘米"
读白: "所以这道题的答案是 CE 等于 7 厘米。记住这个同加同减的技巧,可以把不规则图形转化为规则图形!"
音频时机: 答案出现时同步
情感: 肯定、鼓励
---
音频生成清单
| 幕号 | 文件名 | 读白文本 | 预估时长 | 实际时长 | 说话人 | 情感 |
|---|---|---|---|---|---|---|
| 1 | audio_001_opening.mp3 | 大家好!今天我们来学习... | 5秒 | __秒 | xiaoxiao | 轻松 |
| 2 | audio_002_analysis.mp3 | 题目告诉我们... | 12秒 | __秒 | xiaoxiao | 平和 |
| 3 | audio_003_technique.mp3 | 这里有一个关键技巧... | 20秒 | __秒 | xiaoxiao | 热情 |
| 4 | audio_004_calculation.mp3 | 正方形的面积是... | 15秒 | __秒 | xiaoxiao | 平稳 |
| 5 | audio_005_conclusion.mp3 | 所以这道题的答案是... | 8秒 | __秒 | xiaoxiao | 肯定 |
---
Manim 配置参考
# 竖屏 9:16 配置
config.pixel_width = 1080
config.pixel_height = 1920
config.frame_width = 9
config.frame_height = 16
config.background_color = "#1a1a2e"配色方案
- 主题色: #e94560 (红色)
- 辅助色: #4ecca3 (绿色)
- 高亮色: #ffc107 (黄色)
- 背景色: #1a1a2e (深蓝)
视频分镜脚本 - 第九十九题:证明四点共圆
基本信息
- 时长预估: 约 120 秒
- 场景数: 6
- 风格: 几何动态演示,专业严谨
- 学生特点: 需要理解双曲线性质的几何意义,可能对蒙日定理不熟悉
- 配音设置: 使用 vivian 说话人,中文讲解
---
配音说明
读白 vs 字幕的区别
- 字幕:简洁、视觉化,配合画面,不超过 20 字
- 读白:详细、口语化、引导思考,比字幕更完整
---
分镜设计
第一幕:开场(8 秒)
画面: 深蓝色背景,标题"第九十九题:证明四点共圆"淡入,下方显示副标题"三角形内切圆与双曲线性质"
字幕: "第九十九题:证明四点共圆"
读白: "大家好!今天我们来讲解一道经典的平面几何问题,证明四点共圆。这道题综合运用了切线长定理和双曲线的性质。"
音频时机: 标题出现后 1 秒开始
情感: 热情、专业
动画:
- 背景渐变出现
- 标题从中心放大淡入
- 副标题从下方滑入
目的: 吸引注意力,介绍主题
---
第二幕:展示完整图形(15 秒)
画面: 完整的几何图形逐渐绘制出现 1. 先画三角形 ABC 2. 画内切圆⊙I,标注切点 D、E、F 3. 标出点 K 4. 画内切圆⊙J,标注切点 D、M、N
字幕: "已知:两内切圆共切点 D"
读白: "首先,我们来看题目给出的图形。三角形 ABC 的内切圆 I,分别切 BC、CA、AB 于点 D、E、F。三角形内一点 K,使得三角形 KBC 的内切圆 J 也切 BC 于点 D。"
音频时机: 图形绘制完成后开始
情感: 平和、清晰
动画:
- 三角形 ABC 三边依次绘制
- 内切圆 I 渐显,切点闪烁高亮
- 点 K 出现
- 内切圆 J 渐显,切点 M、N 闪烁高亮
目的: 让学生熟悉图形结构,理解已知条件
---
第三幕:切线长定理(20 秒)
画面: 图形简化,只保留关键线段
- 高亮显示 BF 和 BD,用相同颜色标注
- 高亮显示 CE 和 CD,用相同颜色标注
- 高亮显示 BM 和 BD,用相同颜色标注
- 高亮显示 CN 和 CD,用相同颜色标注
字幕: "切线长定理:从圆外一点引圆的两条切线,切线长相等"
读白: "根据切线长定理,从圆外一点向圆引的两条切线长度相等。所以,BF 等于 BD,CE 等于 CD。同样,对于圆 J,BM 等于 BD,CN 等于 CD。这个结论非常重要,请同学们注意。"
音频时机: 高亮出现时同步
情感: 强调、重点
动画:
- BF、BD 依次高亮,出现等号
- CE、CD 依次高亮,出现等号
- BM、BD 依次高亮
- CN、CD 依次高亮
- 所有等式汇总显示
目的: 建立切线长相等的直观认识
---
第四幕:双曲线性质推导(35 秒)
画面: 公式逐步推导,配合图形标注
- 左侧显示公式推导
- 右侧图形中高亮 AB、AC 线段
- 然后高亮 KB、KC 线段
字幕: "AB - AC = KB - KC = BD - CD"
读白: "现在我们来计算 AB 减 AC。AB 可以写成 BD 加 AF,AC 可以写成 CD 加 AE。因为 AF 等于 AE,所以 AB 减 AC 就等于 BD 减 CD。同样的方法,对于 KB 减 KC,我们也可以得到 BD 减 CD。这就说明,AB 减 AC 等于 KB 减 KC!"
音频时机: 公式出现时同步
情感: 引导思考、逐步推进
动画:
- 公式分步出现,每步停留 2 秒
- 图形中对应线段高亮
- 最终等式用红色框突出显示
- 出现双曲线虚线轨迹(以 B、C 为焦点)
目的: 发现 A、K 在同一双曲线上的关键性质
---
第五幕:蒙日定理应用(30 秒)
画面:
- 显示双曲线(虚线),A、K 在双曲线上
- 连接 EF 和 MN
- 显示 EF 和 MN 的延长线交点
- 出现辅助圆(经过 E、F、M、N 的圆)
字幕: "蒙日定理:双曲线上任一点向两圆作切线,切点共圆"
读白: "我们发现,点 A 和点 K 到两定点 B、C 的距离差是定值。根据双曲线的定义,A 和 K 位于以 B、C 为焦点的同一支双曲线上。根据蒙日定理的推论,从双曲线上的点向有公共切点的两圆作切线,四个切点一定共圆。所以,E、F、M、N 四点共圆。"
音频时机: 双曲线出现后开始
情感: 肯定、强调结论
动画:
- 双曲线虚线淡入
- A、K 点闪烁,显示"在双曲线上"
- EF、MN 线段加粗
- 辅助圆从淡到实,经过四点
- 四点用圆圈高亮
目的: 完成证明,展示四点共圆的直观图形
---
第六幕:总结(12 秒)
画面:
- 回到完整图形
- 依次高亮三个关键点
- 显示总结文字
字幕: "关键技巧:切线长定理 → 双曲线识别 → 蒙日定理"
读白: "总结一下,这道题的关键是:第一步,应用切线长定理找到相等线段;第二步,发现 AB 减 AC 等于 KB 减 KC,识别出双曲线;第三步,应用蒙日定理证明四点共圆。同学们,你学会了吗?"
音频时机: 总结文字出现时
情感: 鼓励、肯定
动画:
- 三个关键点依次高亮
- 每个关键点出现时,对应图形部分闪烁
- 最后出现"证明完成"字样
目的: 强化记忆,鼓励学生
---
关键动画设计
1. 双曲线轨迹: 用虚线绘制以 B、C 为焦点的双曲线,A、K 在曲线上 2. 切线长高亮: 用相同颜色标注相等的切线长 3. 辅助圆绘制: 从 E 点开始,依次经过 F、M、N 绘制完整圆
配色方案
- 背景色:#1a1a2e(深蓝)
- 三角形边框:#ffffff(白色)
- 内切圆 I: #4ecca3(青色,虚线)
- 内切圆 J: #e94560(红色,虚线)
- 切线长高亮:#ffc107(黄色)
- 双曲线:#9c27b0(紫色,虚线)
- 辅助圆:#00bcd4(cyan,实线)
- 关键点:#ff5722(橙色圆点)
设计备忘
1. 图形绘制顺序很重要: 先建立三角形和第一个内切圆,再引入点 K 和第二个内切圆 2. 双曲线是难点: 需要用虚线清晰绘制,并标注"以 B、C 为焦点" 3. 蒙日定理不需要证明: 只需说明结论,避免过于复杂 4. 高亮时机要精确: 配音提到哪个元素,哪个元素就要高亮
---
音频生成清单
根据此分镜脚本,需要生成以下音频文件:
| 幕号 | 文件名 | 读白文本 | 时长 | 说话人 | 情感 |
|---|---|---|---|---|---|
| 1 | audio_001_开场.wav | "大家好!今天我们来讲解一道经典的平面几何问题,证明四点共圆。这道题综合运用了切线长定理和双曲线的性质。" | 8 秒 | vivian | 热情 |
| 2 | audio_002_展示图形.wav | "首先,我们来看题目给出的图形。三角形 ABC 的内切圆 I,分别切 BC、CA、AB 于点 D、E、F。三角形内一点 K,使得三角形 KBC 的内切圆 J 也切 BC 于点 D。" | 15 秒 | vivian | 平和 |
| 3 | audio_003_切线长定理.wav | "根据切线长定理,从圆外一点向圆引的两条切线长度相等。所以,BF 等于 BD,CE 等于 CD。同样,对于圆 J,BM 等于 BD,CN 等于 CD。这个结论非常重要,请同学们注意。" | 20 秒 | vivian | 强调 |
| 4 | audio_004_双曲线推导.wav | "现在我们来计算 AB 减 AC。AB 可以写成 BD 加 AF,AC 可以写成 CD 加 AE。因为 AF 等于 AE,所以 AB 减 AC 就等于 BD 减 CD。同样的方法,对于 KB 减 KC,我们也可以得到 BD 减 CD。这就说明,AB 减 AC 等于 KB 减 KC!" | 35 秒 | vivian | 引导 |
| 5 | audio_005_蒙日定理.wav | "我们发现,点 A 和点 K 到两定点 B、C 的距离差是定值。根据双曲线的定义,A 和 K 位于以 B、C 为焦点的同一支双曲线上。根据蒙日定理的推论,从双曲线上的点向有公共切点的两圆作切线,四个切点一定共圆。所以,E、F、M、N 四点共圆。" | 30 秒 | vivian | 肯定 |
| 6 | audio_006_总结.wav | "总结一下,这道题的关键是:第一步,应用切线长定理找到相等线段;第二步,发现 AB 减 AC 等于 KB 减 KC,识别出双曲线;第三步,应用蒙日定理证明四点共圆。同学们,你学会了吗?" | 12 秒 | vivian | 鼓励 |
filename,text
audio_001_开场.wav,"大家好!今天我们来讲解这道经典的几何题。"
audio_002_展示图形.wav,"首先,让我们来看题目给出的图形。"
audio_003_定理应用.wav,"根据切线长定理,从圆外一点向圆引的两条切线长度相等。"
audio_004_推导过程.wav,"接下来,我们进行推导计算。"
audio_005_结论.wav,"因此,我们可以得出结论。"
audio_006_总结.wav,"总结一下这道题的解题思路。"
#!/usr/bin/env python3
"""
Manim 教学视频代码检查脚本
验证 script.py 是否包含必要的函数和结构
使用方法:
python scripts/check.py [script_file]
默认检查 script.py,也可以指定其他文件
"""
import ast
import sys
import os
from pathlib import Path
class CodeChecker:
"""代码结构检查器"""
# 必须包含的函数
REQUIRED_FUNCTIONS = [
'calculate_geometry',
'assert_geometry',
'define_elements',
]
# 推荐包含的函数(警告但不阻止)
RECOMMENDED_FUNCTIONS = [
'play_scene',
]
# 必须包含的类(内部类也算)
REQUIRED_CLASSES = [
'Subtitle',
'TitleSubtitle',
]
def __init__(self, file_path):
self.file_path = Path(file_path)
self.errors = []
self.warnings = []
self.tree = None
self.classes = {} # 类名 -> 方法列表
def parse(self):
"""解析 Python 文件"""
if not self.file_path.exists():
self.errors.append(f"文件不存在: {self.file_path}")
return False
try:
with open(self.file_path, 'r', encoding='utf-8') as f:
content = f.read()
self.tree = ast.parse(content)
return True
except SyntaxError as e:
self.errors.append(f"语法错误: {e}")
return False
except Exception as e:
self.errors.append(f"解析失败: {e}")
return False
def analyze(self):
"""分析代码结构"""
if not self.tree:
return
# 遍历顶层定义
for node in ast.iter_child_nodes(self.tree):
if isinstance(node, ast.ClassDef):
class_name = node.name
methods = []
inner_classes = []
for item in node.body:
if isinstance(item, ast.FunctionDef):
methods.append(item.name)
elif isinstance(item, ast.ClassDef):
inner_classes.append(item.name)
# 也记录内部类的方法
inner_methods = [n.name for n in item.body
if isinstance(n, ast.FunctionDef)]
self.classes[f"{class_name}.{item.name}"] = inner_methods
self.classes[class_name] = methods
def check_required_functions(self):
"""检查必需函数是否存在"""
all_methods = set()
for class_name, methods in self.classes.items():
all_methods.update(methods)
for func_name in self.REQUIRED_FUNCTIONS:
if func_name not in all_methods:
self.errors.append(
f"缺少必需函数: {func_name}()\n"
f" 请在 MathScene 类中实现此方法\n"
f" 作用: {self._get_function_description(func_name)}"
)
def check_recommended_functions(self):
"""检查推荐函数"""
all_methods = set()
for class_name, methods in self.classes.items():
all_methods.update(methods)
for func_name in self.RECOMMENDED_FUNCTIONS:
if func_name not in all_methods:
self.warnings.append(
f"缺少推荐函数: {func_name}()\n"
f" 建议实现以更好地控制每幕动画"
)
def check_subtitle_classes(self):
"""检查字幕类是否存在"""
# 检查 Subtitle 和 TitleSubtitle 是否作为内部类定义
found_subtitle = False
found_title = False
for class_name in self.classes.keys():
if '.' in class_name:
outer, inner = class_name.split('.')
if inner == 'Subtitle':
found_subtitle = True
if inner == 'TitleSubtitle':
found_title = True
if not found_subtitle:
self.warnings.append(
"未找到 Subtitle 类\n"
" 建议: 从 templates/script_scaffold.py 复制 Subtitle 类定义\n"
" 作用: 避免忘记渲染/退场导致的文字残留问题"
)
if not found_title:
self.warnings.append(
"未找到 TitleSubtitle 类\n"
" 建议: 从 templates/script_scaffold.py 复制 TitleSubtitle 类定义"
)
def check_scene_class(self):
"""检查是否有场景类继承自 Scene"""
found_scene = False
for node in ast.iter_child_nodes(self.tree):
if isinstance(node, ast.ClassDef):
for base in node.bases:
if isinstance(base, ast.Name) and base.id == 'Scene':
found_scene = True
break
elif isinstance(base, ast.Attribute):
if base.attr == 'Scene':
found_scene = True
break
if not found_scene:
self.errors.append(
"未找到继承自 Scene 的类\n"
" 必须有一个类继承自 Scene,例如: class MathScene(Scene):"
)
def check_add_sound(self):
"""检查是否有 add_sound 调用(音频集成)"""
has_add_sound = False
for node in ast.walk(self.tree):
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Attribute):
if node.func.attr == 'add_sound':
has_add_sound = True
break
elif isinstance(node.func, ast.Name):
if node.func.id == 'add_sound':
has_add_sound = True
break
if not has_add_sound:
self.warnings.append(
"未检测到 add_sound() 调用\n"
" 提醒: 每幕动画应该添加对应的音频文件\n"
" 示例: self.add_sound('audio/audio_001_开场.wav')"
)
def _get_function_description(self, func_name):
"""获取函数描述"""
descriptions = {
'calculate_geometry': '计算所有几何元素(点、线、圆)的坐标和属性',
'assert_geometry': '验证几何计算的正确性和画布范围',
'define_elements': '定义 Manim 图形对象(点、线、圆等)',
}
return descriptions.get(func_name, '未知功能')
def run(self):
"""运行所有检查"""
print(f"🔍 检查文件: {self.file_path}")
print("=" * 50)
# 解析
if not self.parse():
return False
# 分析
self.analyze()
# 各项检查
self.check_scene_class()
self.check_required_functions()
self.check_recommended_functions()
self.check_subtitle_classes()
self.check_add_sound()
# 输出结果
return self.report()
def report(self):
"""输出检查报告"""
success = len(self.errors) == 0
# 错误
if self.errors:
print("\n❌ 错误 (必须修复):")
for i, error in enumerate(self.errors, 1):
print(f"\n {i}. {error}")
# 警告
if self.warnings:
print("\n⚠️ 警告 (建议修复):")
for i, warning in enumerate(self.warnings, 1):
print(f"\n {i}. {warning}")
# 成功信息
if success and not self.warnings:
print("\n✅ 所有检查通过!可以开始渲染。")
elif success:
print("\n✅ 必要检查通过,但有警告建议处理。")
print("\n" + "=" * 50)
if success:
print("🎬 下一步: 运行渲染命令")
print(f" manim -pqh {self.file_path} MathScene")
else:
print("⛔ 检查失败,请修复错误后重试。")
return success
def main():
"""主函数"""
# 获取要检查的文件
if len(sys.argv) > 1:
script_file = sys.argv[1]
else:
script_file = "script.py"
# 检查文件路径
script_path = Path(script_file)
# 运行检查
checker = CodeChecker(script_path)
success = checker.run()
# 返回退出码
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
TTS 生成脚本
功能:
- 从 CSV 文件读取对白列表
- 使用 Edge TTS (xiaoxiao 语音) 生成音频
- 输出到指定目录
- 生成 audio_info.json 供验证脚本使用
CSV 格式:
filename,text
audio_001_开场.wav,"大家好,今天我们来学习..."
audio_002_介绍.wav,"首先,让我们来看这个图形..."
使用:
python generate_tts.py audio_list.csv ./audio --voice xiaoxiao
支持的声音:
xiaoxiao (晓晓,女声,默认)
xiaoyi (晓伊,女声)
yunyang (云扬,男声)
yunjian (云健,男声)
"""
import sys
import os
import csv
import json
import asyncio
from pathlib import Path
# 检查 edge-tts
try:
import edge_tts
except ImportError:
print("Error: edge-tts 未安装")
print("请运行: uv pip install edge-tts")
sys.exit(1)
# 声音映射表
VOICE_MAP = {
'xiaoxiao': 'zh-CN-XiaoxiaoNeural', # 晓晓,女声,默认
'xiaoyi': 'zh-CN-XiaoyiNeural', # 晓伊,女声
'yunyang': 'zh-CN-YunyangNeural', # 云扬,男声
'yunjian': 'zh-CN-YunjianNeural', # 云健,男声
'xiaoxiao-dialect': 'zh-CN-XiaoxiaoNeural', # 晓晓方言
'xiaoxiao-multilingual': 'zh-CN-XiaoxiaoMultilingualNeural',
}
async def generate_audio(text, output_path, voice='xiaoxiao'):
"""
生成单条音频
参数:
text: 文本内容
output_path: 输出文件路径
voice: 声音名称
返回:
(success, duration)
"""
voice_id = VOICE_MAP.get(voice, VOICE_MAP['xiaoxiao'])
try:
communicate = edge_tts.Communicate(text, voice_id)
await communicate.save(output_path)
# 获取时长
duration = await get_audio_duration(output_path)
return True, duration
except Exception as e:
print(f" Error generating {output_path}: {e}")
return False, 0
async def get_audio_duration(audio_path):
"""获取音频时长"""
try:
from mutagen.mp3 import MP3
audio = MP3(audio_path)
return audio.info.length
except:
pass
try:
import wave
with wave.open(audio_path, 'rb') as wf:
frames = wf.getnframes()
rate = wf.getframerate()
return frames / float(rate)
except:
pass
return 0
def parse_csv(csv_path):
"""
解析 CSV 文件
支持格式:
- 标准 CSV: filename,text
- 带 BOM 的 UTF-8
- 不同分隔符(优先逗号,支持分号)
"""
entries = []
# 尝试不同编码
encodings = ['utf-8-sig', 'utf-8', 'gbk', 'gb2312']
for encoding in encodings:
try:
with open(csv_path, 'r', encoding=encoding) as f:
# 尝试检测分隔符
sample = f.read(2048)
f.seek(0)
delimiter = ','
if ';' in sample and sample.count(';') > sample.count(','):
delimiter = ';'
reader = csv.DictReader(f, delimiter=delimiter)
for row in reader:
# 支持不同的列名
filename = row.get('filename') or row.get('文件名') or row.get('file')
text = row.get('text') or row.get('对白') or row.get('content') or row.get('读白')
if filename and text:
entries.append({
'filename': filename,
'text': text.strip()
})
print(f"✓ 解析 CSV 成功 ({encoding}), 共 {len(entries)} 条")
return entries
except Exception as e:
continue
# 如果都失败,尝试简单解析
try:
with open(csv_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
for line in lines[1:]: # 跳过标题行
parts = line.strip().split(',', 1)
if len(parts) == 2:
entries.append({
'filename': parts[0].strip(),
'text': parts[1].strip().strip('"')
})
if entries:
print(f"✓ 简单解析 CSV 成功, 共 {len(entries)} 条")
return entries
except:
pass
print("Error: 无法解析 CSV 文件")
return []
async def generate_all(csv_path, output_dir, voice='xiaoxiao'):
"""批量生成音频"""
# 解析 CSV
entries = parse_csv(csv_path)
if not entries:
return False
# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
# 生成音频
results = []
total = len(entries)
print(f"\n开始生成音频 (声音: {voice})...")
print("="*50)
for i, entry in enumerate(entries, 1):
filename = entry['filename']
text = entry['text']
# 确保文件扩展名正确
if not filename.endswith(('.wav', '.mp3')):
filename += '.wav'
output_path = os.path.join(output_dir, filename)
print(f"[{i}/{total}] {filename}")
print(f" 文本: {text[:50]}{'...' if len(text) > 50 else ''}")
success, duration = await generate_audio(text, output_path, voice)
if success:
# 从文件名提取幕号
scene_num = extract_scene_number(filename)
results.append({
'scene': scene_num,
'file': filename,
'text': text,
'duration': round(duration, 2)
})
print(f" ✓ 时长: {duration:.2f}s")
else:
print(f" ✗ 失败")
print()
# 生成 audio_info.json
if results:
info = {
'files': results,
'total_duration': sum(r['duration'] for r in results),
'count': len(results),
'voice': voice
}
info_path = os.path.join(output_dir, 'audio_info.json')
with open(info_path, 'w', encoding='utf-8') as f:
json.dump(info, f, ensure_ascii=False, indent=2)
print(f"已生成: {info_path}")
return len(results) == len(entries)
def extract_scene_number(filename):
"""从文件名提取幕号"""
# 支持格式: audio_001_xxx.wav, scene_01_xxx.wav, 001_xxx.wav
import re
match = re.search(r'\d+', filename)
if match:
return int(match.group())
return 0
def main():
if len(sys.argv) < 2:
print("Usage: python generate_tts.py <csv_file> [output_dir] [options]")
print("")
print("参数:")
print(" csv_file CSV 文件路径")
print(" output_dir 输出目录 (默认: ./audio)")
print("")
print("选项:")
print(" --voice VOICE 声音选择 (默认: xiaoxiao)")
print("")
print("可用声音:")
for k, v in VOICE_MAP.items():
print(f" {k:20s} - {v}")
print("")
print("示例:")
print(" python generate_tts.py audio_list.csv ./audio")
print(" python generate_tts.py audio_list.csv ./audio --voice yunyang")
sys.exit(1)
csv_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].startswith('--') else "./audio"
# 解析选项
voice = 'xiaoxiao'
for i, arg in enumerate(sys.argv):
if arg == '--voice' and i + 1 < len(sys.argv):
voice = sys.argv[i + 1]
# 检查文件
if not os.path.exists(csv_path):
print(f"Error: CSV 文件不存在: {csv_path}")
sys.exit(1)
print(f"CSV 文件: {csv_path}")
print(f"输出目录: {output_dir}")
print(f"使用声音: {voice}")
print("")
# 运行
success = asyncio.run(generate_all(csv_path, output_dir, voice))
if success:
print("\n✅ 全部生成成功!")
sys.exit(0)
else:
print("\n⚠️ 部分生成失败")
sys.exit(1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Manim 教学视频渲染脚本
完整流程: 检查代码 -> 渲染视频
使用方法:
python scripts/render.py [options]
选项:
-f, --file 指定脚本文件 (默认: script.py)
-s, --scene 指定场景类名 (默认: MathScene)
-q, --quality 渲染质量: l(ow)/m(edium)/h(igh)/k(4k) (默认: high)
-p, --preview 渲染后预览 (默认: 开启)
--no-check 跳过代码检查 (不推荐)
示例:
python scripts/render.py # 默认渲染 script.py
python scripts/render.py -f my_script.py # 渲染指定文件
python scripts/render.py -q k # 4K质量渲染
"""
import subprocess
import sys
import argparse
from pathlib import Path
class RenderPipeline:
"""渲染流水线"""
QUALITY_MAP = {
'l': '480p15',
'low': '480p15',
'm': '720p30',
'medium': '720p30',
'h': '1080p60',
'high': '1080p60',
'k': '2160p60',
'4k': '2160p60',
}
def __init__(self, script_file='script.py', scene_name='MathScene',
quality='high', preview=True, skip_check=False):
self.script_file = Path(script_file)
self.scene_name = scene_name
self.quality = self.QUALITY_MAP.get(quality, '1080p60')
self.preview = preview
self.skip_check = skip_check
# 检查脚本路径
self.script_dir = Path(__file__).parent.parent
self.check_script = self.script_dir / 'scripts' / 'check.py'
def run_check(self):
"""第一步: 运行代码检查"""
if self.skip_check:
print("⚠️ 跳过代码检查 (不推荐)")
return True
print("🔍 步骤 1/2: 代码结构检查")
print("=" * 50)
if not self.check_script.exists():
print(f"❌ 检查脚本不存在: {self.check_script}")
return False
try:
result = subprocess.run(
[sys.executable, str(self.check_script), str(self.script_file)],
cwd=self.script_dir,
capture_output=False
)
return result.returncode == 0
except Exception as e:
print(f"❌ 检查失败: {e}")
return False
def run_render(self):
"""第二步: 运行 Manim 渲染"""
print("\n🎬 步骤 2/2: 渲染视频")
print("=" * 50)
if not self.script_file.exists():
print(f"❌ 脚本文件不存在: {self.script_file}")
return False
# 构建 manim 命令
cmd = ['manim']
# 质量参数
cmd.extend(['-q', self.quality[0]]) # l/m/h/k
# 预览参数
if self.preview:
cmd.append('-p')
# 脚本和场景
cmd.extend([str(self.script_file), self.scene_name])
print(f"执行命令: {' '.join(cmd)}")
print()
try:
result = subprocess.run(cmd, cwd=self.script_dir)
return result.returncode == 0
except FileNotFoundError:
print("❌ 未找到 manim 命令,请确保已安装: pip install manim")
return False
except Exception as e:
print(f"❌ 渲染失败: {e}")
return False
def copy_to_root(self):
"""第三步: 拷贝视频到根目录"""
print("\n📁 拷贝视频到根目录")
print("=" * 50)
# 查找生成的视频文件
media_dir = self.script_dir / 'media' / 'videos' / self.script_file.stem
if not media_dir.exists():
print(f"⚠️ 媒体目录不存在: {media_dir}")
return
# 按分辨率优先级查找
possible_paths = [
media_dir / '2160p60' / f'{self.scene_name}.mp4',
media_dir / '1920p60' / f'{self.scene_name}.mp4',
media_dir / '1080p60' / f'{self.scene_name}.mp4',
media_dir / '720p30' / f'{self.scene_name}.mp4',
media_dir / '480p15' / f'{self.scene_name}.mp4',
]
video_src = None
for path in possible_paths:
if path.exists():
video_src = path
break
if video_src:
import shutil
video_dst = self.script_dir / f'{self.scene_name}.mp4'
try:
shutil.copy2(video_src, video_dst)
print(f"✅ 视频已拷贝: {video_dst}")
print(f" 源文件: {video_src}")
except Exception as e:
print(f"⚠️ 拷贝失败: {e}")
else:
print("⚠️ 未找到生成的视频文件")
def run(self):
"""运行完整流程"""
print("\n" + "=" * 50)
print("🎬 Manim 教学视频渲染流水线")
print("=" * 50)
print(f"脚本文件: {self.script_file}")
print(f"场景类名: {self.scene_name}")
print(f"渲染质量: {self.quality}")
print("=" * 50 + "\n")
# 步骤1: 检查
if not self.run_check():
print("\n⛔ 代码检查失败,终止渲染。")
print(" 请修复错误后重试,或使用 --no-check 跳过检查(不推荐)")
return False
# 步骤2: 渲染
if not self.run_render():
print("\n⛔ 渲染失败。")
return False
# 步骤3: 拷贝
self.copy_to_root()
print("\n" + "=" * 50)
print("✅ 渲染完成!")
print("=" * 50)
return True
def main():
"""主函数"""
parser = argparse.ArgumentParser(
description='Manim 教学视频渲染流水线',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
示例:
python scripts/render.py # 默认渲染 script.py
python scripts/render.py -f my_script.py # 渲染指定文件
python scripts/render.py -s MyScene # 指定场景类名
python scripts/render.py -q k # 4K质量渲染
python scripts/render.py --no-check # 跳过检查(不推荐)
'''
)
parser.add_argument(
'-f', '--file',
default='script.py',
help='要渲染的脚本文件 (默认: script.py)'
)
parser.add_argument(
'-s', '--scene',
default='MathScene',
help='场景类名 (默认: MathScene)'
)
parser.add_argument(
'-q', '--quality',
default='high',
choices=['l', 'low', 'm', 'medium', 'h', 'high', 'k', '4k'],
help='渲染质量: l/low(480p), m/medium(720p), h/high(1080p), k/4k(2160p) (默认: high)'
)
parser.add_argument(
'-p', '--preview',
action='store_true',
default=True,
help='渲染后预览 (默认: 开启)'
)
parser.add_argument(
'--no-preview',
action='store_true',
help='渲染后不预览'
)
parser.add_argument(
'--no-check',
action='store_true',
help='跳过代码检查 (不推荐)'
)
args = parser.parse_args()
# 处理 --no-preview
preview = not args.no_preview
# 创建流水线
pipeline = RenderPipeline(
script_file=args.file,
scene_name=args.scene,
quality=args.quality,
preview=preview,
skip_check=args.no_check
)
# 运行
success = pipeline.run()
# 退出码
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()