显示 中文公式
示例代码
# -*- coding: utf-8 -*-
import os
import sys
from manim import *
# 将父目录添加到 sys.path 以便导入 manim_utils
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from manim_utils import LayoutAtom, LayoutDirection, custom_voiceover_tts, get_available_font, Layout
# 定义 CJK (中日韩) 字体名称
CJK_FONT_NAME = "Songti SC" # macOS 上的宋体-简
# 创建一个 TexTemplate 用于支持中文的 LaTeX 编译
cjk_template = TexTemplate(
tex_compiler="xelatex", # 使用 xelatex 编译器,对 Unicode 和现代字体支持更好
output_format=".xdv", # xelatex 的中间输出格式
preamble=rf"""
\usepackage{{amsmath}} # 数学公式包
\usepackage{{amssymb}} # 数学符号包
\usepackage{{fontspec}} # 允许使用系统安装的字体
\usepackage{{xeCJK}} # 核心包,用于在 LaTeX 中支持 CJK 字符
\setCJKmainfont{{{CJK_FONT_NAME}}} # 设置 CJK 字符的主字体
"""
)
class CombinedScene(Scene):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.current_scene_num_mob = None # 用于存储当前场景编号的 Mobject
self.highlight_color = PURE_RED # 定义高亮颜色
self.text_color = BLACK # 定义文本颜色
# 获取可用的系统字体,并设置为 Text 对象的默认字体
final_font = get_available_font()
if final_font:
Text.set_default(font=final_font)
# 为 MathTex 设置默认的 TexTemplate (支持中文) 和颜色
MathTex.set_default(tex_template=cjk_template)
Text.set_default(color=self.text_color)
MathTex.set_default(color=self.text_color)
def update_scene_number(self, number_str):
"""
在场景右上角更新或显示场景编号。
"""
new_scene_num = Text(number_str, font_size=24, color=self.text_color).to_corner(UR,
buff=MED_LARGE_BUFF).set_z_index(
100) # 确保编号在最上层
animations = [FadeIn(new_scene_num, run_time=0.5)]
if self.current_scene_num_mob:
# 如果已有场景编号,则淡出旧的
animations.append(FadeOut(self.current_scene_num_mob, run_time=0.5))
self.play(*animations)
self.current_scene_num_mob = new_scene_num
def construct(self):
"""
Manim 场景的主构建方法。
"""
self.camera.background_color = WHITE # 设置场景背景色为白色
self.play_scene_02() # 调用特定场景的播放方法
def play_scene_02(self):
"""
播放场景 02 的内容:计算转动动能。
"""
self.update_scene_number("02") # 更新场景编号为 "02"
# 使用自定义的 Layout 类来规划屏幕布局
# 整体垂直布局:标题区占 1 份,内容区占 8 份
# 内容区水平布局:左右区域各占 5 份
layout = Layout(LayoutDirection.VERTICAL, {
"title_area": (1.0, LayoutAtom()),
"content_area": (8.0, Layout(LayoutDirection.HORIZONTAL, {
"left_area": (5.0, LayoutAtom()),
"right_area": (5.0, LayoutAtom())
}))
}).resolve(self) # resolve 方法根据当前场景大小计算实际坐标
# --- 定义场景元素 (Mobjects) ---
# 标题
title_scene02 = Text("问题 (a): 计算转动动能", font_size=48, weight=BOLD, color=self.text_color)
layout["title_area"].place(title_scene02) # 将标题放置到布局的 "title_area"
# 左侧内容
kr_formula_text = Text("转动动能公式:", font_size=28)
kr_formula = MathTex(r"K_R = \frac{1}{2} I \omega^2", font_size=32)
conversion_intro_text = MathTex(
r"\text{首先,转换角速度 } \omega \text{ 的单位(rpm 到 rad/s):}", # MathTex 中使用 \text{} 来输入中文
font_size=32
)
omega_given = MathTex(r"I = 2.5 \, \text{kg} \cdot \text{m}^2, \quad \omega = 1200 \, \text{rpm}", font_size=28)
omega_conversion_formula = MathTex(
r"\omega (\text{rad/s}) = \omega (\text{rpm}) \times \frac{2\pi \, \text{rad}}{1 \, \text{rev}} \times \frac{1 \, \text{min}}{60 \, \text{s}}",
font_size=32
)
omega_conversion_calc = MathTex(
r"= 1200 \times \frac{2\pi}{60} \, \text{rad/s}",
font_size=32
)
omega_converted_value = MathTex(
r"= 40\pi \, \text{rad/s}",
font_size=32, color=self.highlight_color # 高亮显示结果
)
# 右侧内容
kr_calc_intro_text = Text("然后,代入数值计算动能:", font_size=28)
kr_substitution = MathTex(
r"K_R = \frac{1}{2} (2.5 \, \text{kg} \cdot \text{m}^2) (40\pi \, \text{rad/s})^2",
font_size=32
)
kr_calc_step1 = MathTex(
r"= \frac{1}{2} \times 2.5 \times (1600\pi^2) \, \text{J}",
font_size=32
)
kr_calc_step2 = MathTex(
r"= 1.25 \times 1600\pi^2 \, \text{J}",
font_size=32
)
kr_final_exact = MathTex(
r"= 2000\pi^2 \, \text{J}",
font_size=32, color=self.highlight_color # 高亮显示精确结果
)
kr_approx_intro = MathTex(
r"\text{近似计算 (}\pi \approx 3.14159\text{)}:",
font_size=32
)
kr_approx_calc = MathTex(
r"K_R \approx 2000 \times (3.14159)^2 \, \text{J}",
font_size=32
)
kr_approx_value = MathTex(
r"\approx 2000 \times 9.8696 \, \text{J} \approx 19739.2 \, \text{J}",
font_size=32, color=self.highlight_color # 高亮显示近似结果
)
# 将左侧元素组织成 VGroup 并排列
left_vgroup = VGroup(
kr_formula_text, kr_formula,
conversion_intro_text, omega_given, omega_conversion_formula, omega_conversion_calc, omega_converted_value,
).arrange(DOWN, buff=0.25, aligned_edge=LEFT) # 垂直向下排列,左对齐,间距 0.25
# 将右侧元素组织成 VGroup 并排列
right_vgroup = VGroup(
kr_calc_intro_text, kr_substitution, kr_calc_step1, kr_calc_step2, kr_final_exact,
kr_approx_intro, kr_approx_calc, kr_approx_value
).arrange(DOWN, buff=0.25, aligned_edge=LEFT)
# 将 VGroup 放置到布局的相应区域
layout["content_area"]["left_area"].place(left_vgroup, aligned_edge=UL, buff=0.3) # 左上对齐,边距 0.3
layout["content_area"]["right_area"].place(right_vgroup, aligned_edge=UL, buff=0.3)
# --- 定义语音解说文本 ---
voice_text_s02_p1 = "对于问题 (a),我们需要计算飞轮的转动动能。转动动能的公式是 K R 等于二分之一 I omega 平方。"
voice_text_s02_p2 = "首先,我们需要将角速度 omega 从每分钟转数 (rpm) 转换为弧度每秒 (rad/s)。已知的转动惯量 I 是 2.5 千克平方米,角速度 omega 是 1200 rpm。"
voice_text_s02_p3 = "转换公式是 omega (rad/s) 等于 omega (rpm) 乘以 2 pi 除以 60。所以,omega 等于 1200 乘以 2 pi 除以 60,等于 40 pi 弧度每秒。"
voice_text_s02_p4 = "现在,我们将这些值代入动能公式:K R 等于二分之一乘以 2.5 乘以 (40 pi) 的平方。"
voice_text_s02_p5 = "计算结果为:K R 等于二分之一乘以 2.5 乘以 1600 pi 平方焦耳,等于 1.25 乘以 1600 pi 平方焦耳,最终得到 2000 pi 平方焦耳。"
voice_text_s02_p6 = "如果我们取 pi 约等于 3.14159,那么 K R 大约等于 2000 乘以 9.8696 焦耳,约等于 19739.2 焦耳。"
# --- 动画序列与语音同步 ---
self.play(Write(title_scene02), run_time=1.0) # 播放标题动画
# 使用 custom_voiceover_tts 上下文管理器来处理语音和动画同步
with custom_voiceover_tts(voice_text_s02_p1) as tracker:
if tracker.audio_path and tracker.duration > 0: self.add_sound(tracker.audio_path) # 添加语音
self.play(FadeIn(kr_formula_text, shift=UP * 0.2), run_time=0.7) # 播放文本淡入动画
self.play(Write(kr_formula), run_time=1.2) # 播放公式书写动画
anim_duration = 0.7 + 1.2
# 等待时间 = max(0.001, 语音时长 - 动画时长),确保语音播放完毕
wait_time = max(0.001, tracker.duration - anim_duration) if tracker.duration > 0 else 0.5
self.wait(wait_time)
with custom_voiceover_tts(voice_text_s02_p2) as tracker:
if tracker.audio_path and tracker.duration > 0: self.add_sound(tracker.audio_path)
self.play(FadeIn(conversion_intro_text, shift=UP * 0.2), run_time=0.7)
self.play(Write(omega_given), run_time=1.2)
anim_duration = 0.7 + 1.2
wait_time = max(0.001, tracker.duration - anim_duration) if tracker.duration > 0 else 0.5
self.wait(wait_time)
with custom_voiceover_tts(voice_text_s02_p3) as tracker:
if tracker.audio_path and tracker.duration > 0: self.add_sound(tracker.audio_path)
self.play(Write(omega_conversion_formula), run_time=1.5)
# TransformMatchingTex 用于平滑地将一个 Tex 对象转换为另一个,匹配相似部分
self.play(TransformMatchingTex(omega_conversion_formula.copy(), omega_conversion_calc), run_time=1.2)
self.play(TransformMatchingTex(omega_conversion_calc.copy(), omega_converted_value), run_time=1.2)
anim_duration = 1.5 + 1.2 + 1.2
wait_time = max(0.001, tracker.duration - anim_duration) if tracker.duration > 0 else 0.5
self.wait(wait_time)
with custom_voiceover_tts(voice_text_s02_p4) as tracker:
if tracker.audio_path and tracker.duration > 0: self.add_sound(tracker.audio_path)
self.play(FadeIn(kr_calc_intro_text, shift=UP * 0.2), run_time=0.7)
self.play(Write(kr_substitution), run_time=1.5)
anim_duration = 0.7 + 1.5
wait_time = max(0.001, tracker.duration - anim_duration) if tracker.duration > 0 else 0.5
self.wait(wait_time)
with custom_voiceover_tts(voice_text_s02_p5) as tracker:
if tracker.audio_path and tracker.duration > 0: self.add_sound(tracker.audio_path)
self.play(TransformMatchingTex(kr_substitution.copy(), kr_calc_step1), run_time=1.2)
self.play(TransformMatchingTex(kr_calc_step1.copy(), kr_calc_step2), run_time=1.2)
self.play(TransformMatchingTex(kr_calc_step2.copy(), kr_final_exact), run_time=1.2)
anim_duration = 1.2 * 3
wait_time = max(0.001, tracker.duration - anim_duration) if tracker.duration > 0 else 0.5
self.wait(wait_time)
with custom_voiceover_tts(voice_text_s02_p6) as tracker:
if tracker.audio_path and tracker.duration > 0: self.add_sound(tracker.audio_path)
self.play(FadeIn(kr_approx_intro, shift=UP * 0.2), run_time=0.7)
self.play(Write(kr_approx_calc), run_time=1.2)
self.play(TransformMatchingTex(kr_approx_calc.copy(), kr_approx_value), run_time=1.5)
anim_duration = 0.7 + 1.2 + 1.5
wait_time = max(0.001, tracker.duration - anim_duration) if tracker.duration > 0 else 0.5
self.wait(wait_time)
self.wait(1) # 场景结束前等待1秒
# 存储场景元素,以便在更复杂的、非清除序列中潜在引用
self.scene02_elements = VGroup(title_scene02, left_vgroup, right_vgroup)
Manim 脚本详解:创建带中文公式和语音的物理教辅动画
1. 概述
该 Python 脚本使用 Manim 动画引擎(一个用于创建数学动画的库)来生成一个教学视频片段。这个片段具体演示了如何计算一个物理问题——飞轮的转动动能。脚本的核心特性包括:
- 中文支持:能够在
Text
(普通文本)和MathTex
(LaTeX 数学公式)对象中正确显示中文。 - 自定义场景基类:
CombinedScene
类提供了一些通用设置和辅助方法,如场景编号更新、默认字体和颜色配置。 - 布局管理:使用自定义的
Layout
工具类来规划屏幕区域,使内容组织更清晰。 - 语音同步:集成了
custom_voiceover_tts
工具,可以将预设的中文解说词转换为语音,并与动画播放同步。 - 分步演示:将物理问题的求解过程分解为多个步骤,并通过动画逐步展示公式、代入、计算和结果。
2. 代码结构详解
2.1. 环境设置与导入
# -*- coding: utf-8 -*-
: 声明文件编码为 UTF-8,支持中文字符。import os, sys
: 导入标准库。from manim import *
: 导入 Manim 库的所有内容。sys.path.append(...)
: 将父目录(假定manim_utils
在其中)添加到 Python 模块搜索路径。from manim_utils import ...
: 从自定义的manim_utils
模块导入布局、语音和字体相关的工具。
2.2. CJK 字体配置 (cjk_template
)
为了在 MathTex
(基于 LaTeX)中显示中文,脚本定义了一个 TexTemplate
:
tex_compiler="xelatex"
: 指定使用xelatex
编译器。xelatex
对 Unicode 和现代 OpenType/TrueType 字体有良好的支持,是处理 CJK 字符的首选。output_format=".xdv"
:xelatex
的默认输出格式。preamble
: LaTeX 导言区配置。\usepackage{{amsmath}}
,\usepackage{{amssymb}}
: 常用的数学公式和符号宏包。\usepackage{{fontspec}}
: 允许 LaTeX 使用系统安装的字体。\usepackage{{xeCJK}}
: 核心宏包,为xelatex
提供 CJK 字符排版支持。\setCJKmainfont{{{CJK_FONT_NAME}}}
: 设置 CJK 字符(如中文)渲染时使用的主字体。这里CJK_FONT_NAME
被设置为"Songti SC"
(macOS 上的宋体-简,用户可根据自己系统安装的字体修改,如 Windows 上的 "SimSun" 或 "Microsoft YaHei")。
2.3. CombinedScene
类
这是一个继承自 Manim Scene
的自定义基类,用于封装通用功能。
__init__(self, **kwargs)
:- 初始化
current_scene_num_mob
(用于显示场景编号的 Mobject)。 - 设置
highlight_color
(高亮颜色,如红色) 和text_color
(文本颜色,如黑色)。 - 调用
get_available_font()
(来自manim_utils
) 尝试获取一个合适的系统字体,并将其设置为Text
对象的默认字体。 - 将之前定义的
cjk_template
设置为MathTex
对象的默认tex_template
,确保所有MathTex
都能处理中文。 - 设置
Text
和MathTex
的默认颜色。
- 初始化
update_scene_number(self, number_str)
:- 创建一个
Text
Mobject 显示传入的number_str
(场景编号)。 - 将其放置在屏幕右上角 (
UR
),并设置较高的z_index
以确保它显示在其他元素之上。 - 如果已有旧的场景编号,则播放淡出旧编号、淡入新编号的动画。
- 创建一个
construct(self)
:- Manim 场景的入口方法。
- 设置相机背景色为白色 (
self.camera.background_color = WHITE
)。 - 调用
self.play_scene_02()
来执行特定场景的动画逻辑。
2.4. play_scene_02(self)
方法
这是实际创建动画内容的核心方法,演示了问题 (a) 的求解过程。
场景初始化与布局:
self.update_scene_number("02")
: 显示当前场景编号 "02"。layout = Layout(...)
: 使用自定义的Layout
类将屏幕划分为一个垂直的 "title_area" 和 "content_area"。 "content_area" 进一步水平划分为 "left_area" 和 "right_area"。这种布局方式有助于结构化地组织屏幕上的信息。
内容定义 (Mobjects):
- 标题:
title_scene02 = Text("问题 (a): 计算转动动能", ...)
,并使用layout["title_area"].place()
放置。 - 左侧内容 (公式、单位转换):
kr_formula_text = Text("转动动能公式:", ...)
kr_formula = MathTex(r"K_R = \frac{1}{2} I \omega^2", ...)
: 转动动能公式。conversion_intro_text = MathTex(r"\text{首先,转换角速度 } \omega \text{ 的单位...", ...)
: 注意,在MathTex
中,非数学文本(尤其是中文)需要用\text{...}
包裹。omega_given
,omega_conversion_formula
,omega_conversion_calc
,omega_converted_value
: 一系列MathTex
对象,逐步展示角速度从 rpm 到 rad/s 的转换过程。结果omega_converted_value
使用了highlight_color
。
- 右侧内容 (动能计算):
kr_calc_intro_text = Text("然后,代入数值计算动能:", ...)
kr_substitution
,kr_calc_step1
,kr_calc_step2
,kr_final_exact
: 一系列MathTex
对象,展示将数值代入动能公式并逐步计算的过程。精确结果kr_final_exact
使用了highlight_color
。kr_approx_intro
,kr_approx_calc
,kr_approx_value
: 一系列MathTex
对象,展示使用 π 的近似值进行计算的过程。近似结果kr_approx_value
使用了highlight_color
。
- 标题:
内容布局:
left_vgroup = VGroup(...)
和right_vgroup = VGroup(...)
: 分别将左侧和右侧的 Mobjects 组合成VGroup
(垂直组)。.arrange(DOWN, buff=0.25, aligned_edge=LEFT)
: 对VGroup
内的元素进行排列,使其垂直向下分布,元素间距为 0.25,并左对齐。layout["content_area"]["left_area"].place(left_vgroup, ...)
: 将left_vgroup
放置到布局中定义的左侧区域,并设置其对齐方式和边距。右侧同理。
语音解说文本:
voice_text_s02_p1
到voice_text_s02_p6
: 定义了六段中文解说词,对应动画的各个阶段。
动画序列 (Animation Sequence):
self.play(Write(title_scene02), ...)
: 首先播放标题的书写动画。with custom_voiceover_tts(voice_text) as tracker:
: 这是一个关键部分。custom_voiceover_tts
(来自manim_utils
) 是一个上下文管理器,它可能负责:- 接收文本 (
voice_text
)。 - 调用 TTS (Text-To-Speech) 引擎将文本转换为音频文件(如果尚未生成)。
- 返回一个
tracker
对象,该对象包含音频路径 (tracker.audio_path
) 和音频时长 (tracker.duration
)。
- 接收文本 (
if tracker.audio_path and tracker.duration > 0: self.add_sound(tracker.audio_path)
: 如果成功生成了音频,则将其添加到 Manim 场景中,随动画一起播放。self.play(...)
: 在with
代码块内部,播放与当前语音段落对应的 Mobject 动画(如FadeIn
,Write
,TransformMatchingTex
)。TransformMatchingTex
: 这是一个非常有用的动画,它可以智能地将一个MathTex
对象平滑过渡到另一个MathTex
对象,通过匹配和变换相似的子部分来实现。通常需要传递.copy()
后的对象作为源,以避免修改原始对象。
anim_duration = ...
: 计算当前动画块的总时长。wait_time = max(0.001, tracker.duration - anim_duration) if tracker.duration > 0 else 0.5
: 计算需要额外等待的时间,以确保动画播放时长与语音时长大致匹配。如果语音比动画长,则等待差值;否则,至少等待一个短暂的时间(0.5秒或0.001秒)。self.wait(wait_time)
: 执行等待。- 这个模式(
with custom_voiceover_tts ... play animations ... wait
) 重复多次,对应每一段解说和相关的动画步骤。
收尾:
self.wait(1)
: 在所有动画和语音结束后,场景额外等待 1 秒。self.scene02_elements = VGroup(...)
: 将本场景中创建的主要 Mobjects 存储到一个属性中,这可能用于更复杂的场景管理或调试,但在这个独立场景中作用不大。
3. 自定义工具 (manim_utils
)
脚本依赖于一个名为 manim_utils
的外部模块,其中包含:
LayoutAtom
,LayoutDirection
,Layout
: 用于屏幕布局管理的类和枚举。custom_voiceover_tts
: 用于处理文本到语音转换和同步的上下文管理器。get_available_font
: 用于获取系统可用字体的函数。
这些工具的具体实现未在提供的代码中给出,但其功能可以从使用方式中推断出来。
4. 如何运行
要运行此脚本,您需要:
- 安装 Manim Community Edition。
- 安装
xelatex
(通常通过 TeX Live, MiKTeX, 或 MacTeX 发行版安装)。 - 确保
CJK_FONT_NAME
(如 "Songti SC", "SimSun", "Microsoft YaHei" 等) 已在您的系统上安装。 - 确保
manim_utils.py
文件(或模块)与此脚本位于正确的相对路径,或者已安装到 Python 环境中。 - 确保
custom_voiceover_tts
所依赖的 TTS 服务或库已配置好(例如,它可能使用 Azure TTS, Google TTS, 或本地的 TTS 引擎)。
然后,在命令行中,导航到脚本所在目录,并执行类似以下的命令:
manim -pql your_script_name.py CombinedScene
your_script_name.py
是保存此代码的文件名。CombinedScene
是要渲染的类名。-pql
表示预览(preview)并以低质量(low quality)渲染。其他选项包括-pqm
(中等质量),-pqh
(高质量)。
5. 总结
该脚本是一个精心设计的 Manim 应用实例,它有效地结合了数学排版、中文支持、自定义布局和语音同步,以创建一个清晰、信息丰富的教学动画。通过 xelatex
和 xeCJK
的配置,解决了在 LaTeX 中显示中文的关键问题。自定义的 CombinedScene
和 Layout
工具提高了代码的模块化和可维护性。语音同步功能极大地增强了教学视频的表达力和用户体验。