§8 · Анимации
AnimaGeo-сцена — это полноценный manim.Scene. Анимация
строится через keyframe-интерполяцию (play_keyframes),
явный ValueTracker-updater или комбинацию. Размеры точек,
толщины и шрифты остаются pixel-invariant в видео: отрендеренный в
854×480 тест визуально неотличим от 1920×1080 продакшена, отличается
только детализацией.
manim CLI (см. §9). MP4-превью
ниже — встроенные <video>-теги с уже отрендеренными
файлами.
Статика vs MP4
| Аспект | SVG (exportSVG) | MP4 (manim-CLI) |
|---|---|---|
| Подписи | одноразово autoPlaceLabels() | keyframe_snapshots + интерполяция, или per-frame tracker |
| Углы | биссектриса один раз | dynamic_angles → аналитический пересчёт каждый кадр |
| Z-index | читается на экспорте | переустановка при updateGeoElements |
| ptUnit | фиксирован после applyStyle | фиксирован; изменение камеры не пересчитывает размеры |
| Холст | произвольный (px_size) | manim-canvas из config.pixel_width/height |
config.pixel_width/pixel_height,
не style.export.ptWidth. applyStyle согласует
через соотношение сторон. Рекомендация: px_size
пропорционально manim-canvas (1920×1080 для -qh,
854×480 для -ql).
Show / Hide / Update
Базовые строительные блоки анимации. Все возвращают список
Animation; «Play»-варианты оборачивают в
self.play(...).
| Метод | Что делает |
|---|---|
Show(names, mode='Fade'|'Create') | Показать элементы: Fade — мгновенное появление с прозрачностью; Create — рост (для линий). |
Hide(names) | Скрыть. |
Update(names) | FadeOut → пересоздать → FadeIn. Для случая, когда координаты изменились вне анимации. |
Shade(names) | Затенить (цвет заменяется на col_shade). |
Restore(names) | Восстановить после Shade. |
ShowCreate(name) | Тип-зависимый: Create для линий, Fade для заливок. |
self.playShow(['A', 'B', 'C'], mode='Create')
self.playHide(['poly'])
self.playShade(['A', 'B'])
self.playRestore(['A', 'B'])
Build-up: поэтапное появление
self.putCode("""
A = Point(-3.5, -1.8)
B = Point(3.5, -1.8)
C = Point(0.5, 2.5)
p, a, b, c = Polygon(A, B, C)
alpha = Angle(B, A, C)
""")
# Сначала скрыть всё
for el in self.geo.elements:
el.visible = False
self.addAllGeometry(show=False)
# Поэтапное появление
self.playShow(['A', 'B', 'C'], mode='Fade')
self.playShow(['a', 'b', 'c'], mode='Create', run_time=1.2)
self.playShow(['p'], mode='Fade')
self.playShow(['alpha'], mode='Create')
Keyframe-анимация
play_keyframes(json) интерполирует координаты,
curve/locus-параметры (tparam) и видимость элементов между кадрами.
Перед стартом playback сцена приводится к состоянию первого keyframe:
применяются его values и show/hide,
перестраивается геометрия и обновляются mobject-ы. Поэтому первый кадр рендера
соответствует keyframe 0, а не сохраненному состоянию файла
.ggb.
Формат JSON
{
"keyframes": [
{"t": 0, "values": {"A": [0, 0], "x": 35, "D": {"tparam": 0.0}}},
{"t": 2, "values": {"A": [4, 4], "x": 110,
"D": {"tparam": 3.14, "direction": "ccw"}},
"show": ["line1"], "hide": ["aux"], "easing": "smooth"},
{"t": 4, "values": {"A": [0, 0], "x": 35}}
]
}
Независимые элементы
Что можно анимировать — всё, что возвращает scene.get_independents():
| Type | JSON-формат | Интерполяция |
|---|---|---|
free_point | [x, y] | линейная по координатам |
tparam_point (на окружности) | {"tparam": rad, "direction": "short"/"cw"/"ccw"} | угловая |
tparam_point (на сегменте/линии/луче) | {"tparam": 0..1} | линейная |
number / measure | float | линейная |
angle | float (rad) | линейная |
boolean | true/false | snap на t=0.5 |
'tparam_point' и ключ "tparam".
Easing функции: linear, smooth (default),
in, out, in_out.
Пример 1 — точка на окружности
self.putCode("""
O = Point(0, 0); R = Point(3, 0)
c = Circle(O, R)
P = Point(c) # tparam-точка на окружности
Q = Point(-3, 0)
rOQ = Segment(O, Q); rOP = Segment(O, P)
s = Segment(Q, P); alpha = Angle(Q, O, P)
""")
self.geo.update_tparam('P', np.pi / 6)
self.geo.rebuild()
# вспомогательные радиусы — серым
for n in ('rOQ', 'rOP'):
self.element(n).style['stroke'] = '#b1b3b6'
self.element(n).style['stroke_width_px'] = 0.8
# O статична — подпись фиксируем вручную снизу
self.element('O').style.update({
'offset': [0, -16], 'label_anchor': 'TC',
'label_placement_locked': True,
})
# per-frame решатель: P движется по дуге, линейная
# интерполяция offset между keyframes давала бы промах
self.style_config.overlay.label_placement = {
'enabled': True, 'dynamic_angles': True,
'distance_px': 6, 'ema_alpha': 0.25,
'anchor_flip_frames': 10, 'solver_every_n_frames': 2,
}
self.autoPlaceLabels(dynamic=True)
# ValueTracker + sentinel updater
from manim import ValueTracker, linear, Mobject
a_param = ValueTracker(np.pi / 6)
self.add(a_param)
scene = self
def on_frame(mobj):
scene.geo.update_tparam('P', a_param.get_value())
scene.updateGeoElements(scene.geo.rebuild())
if scene._label_tracker is not None:
scene._apply_dynamic_labels()
sentinel = Mobject(); sentinel.add_updater(on_frame); self.add(sentinel)
target = 2 * np.pi + np.pi / 6 - 0.001
self.play(a_param.animate.set_value(target), run_time=5, rate_func=linear)
sentinel.clear_updaters()
self.clearLabelTracker()
autoPlaceLabels(dynamic=True) и
dynamic_angles=True. Без них подписи остались бы в
стартовых позициях, а дуга угла — с неверным углом.
Пример 2 — вершина треугольника скользит по прямой
self.putCode("""
A = Point(-3.5, -1.8); B = Point(3.5, -1.8)
L1 = Point(-3.5, 2.0); L2 = Point(3.5, 2.0)
rail = Line(L1, L2)
C = Point(rail) # C — tparam-точка на прямой
p, a, b, c = Polygon(A, B, C)
alpha = Angle(B, A, C); beta = Angle(A, B, C)
""")
self.element('C').tparam = 0.0
for n in ('rail', 'L1', 'L2'):
self.element(n).visible = False
# Для play_keyframes — keyframe_snapshots (не dynamic=True).
self.style_config.overlay.label_placement = {
'enabled': True,
'keyframe_snapshots': True,
'dynamic_angles': True,
'canonicalize_anchor': True,
}
self.autoPlaceLabels()
self.play_keyframes({
"keyframes": [
{"t": 0, "values": {"C": {"tparam": 0.2}}},
{"t": 2, "values": {"C": {"tparam": 0.8}}},
{"t": 4, "values": {"C": {"tparam": 0.2}}},
]
})
Keyframes v2: стили, видимость, эффекты
С "version": 2 ключевой кадр может нести styles —
целевое состояние стиля любого элемента (числа интерполируются, цвета — в Oklab,
дискретные ключи переключаются на середине), в том числе stroke_dash_period_px
и stroke_dash_ratio. Появление и исчезновение — эффекты внутри
интервала (enter: fade, create, grow, write;
exit: fade, uncreate, shrink). grow и
shrink масштабируют геометрию вокруг её центра, а подпись остаётся на месте
и плавно проявляется или гаснет.
{"version": 2, "keyframes": [
{"t": 0, "visible": {"c": false}, "styles": {"a": {"stroke_dash_period_px": 10}}},
{"t": 2, "visible": {"c": true}, "enter": {"c": {"effect": "grow", "duration": 0.8}},
"styles": {"a": {"stroke_dash_period_px": 20}}}
]}
Полное описание (камера, события, поэтапное построение) — docs/keyframes.ru.md.
addUpdater и ValueTracker
Для тонкого контроля — когда play_keyframes слишком
декларативен или нужно связать несколько движений — используется
updater: функция, которая вызывается каждый кадр и читает
текущее значение ValueTracker.
from manim import ValueTracker, linear
x = self.addVar('x', 0) # обёртка над ValueTracker
self.autoPlaceLabels(dynamic=True) # per-frame решатель подписей
self.addUpdater(x) # связывает x с перестройкой
self.play(x.animate.set_value(1), run_time=3, rate_func=linear)
self.clearUpdater(x)
self.clearLabelTracker()
Что происходит:
addVar('x', 0)создаёт независимую переменную x со стартовым значением 0.addUpdater(x)ставит per-frame hook: при изменении x вызываетсяupdate_var('x', new_value)иrebuild(), пересчитывая все зависимые элементы.autoPlaceLabels(dynamic=True)ставит LabelTracker: подписи пересчитываются каждыеsolver_every_n_framesкадров, сглаживаются EMA, флипы якоря проходят hysteresis.play(x.animate.set_value(1))— стандартный manim-анимационный вызов; manim сам тикает x от 0 до 1, ValueTracker триггерит updater.
# DSL-конструкция, где x — "угол" отдельного луча
self.putCode("""
O = Point(0, 0)
A = Point(cos(x), sin(x)) # x — переменная: радиан
s = Segment(O, A)
""")
x = self.addVar('x', 0)
self.autoPlaceLabels(dynamic=True)
self.addUpdater(x)
self.play(x.animate.set_value(np.pi), run_time=3, rate_func=linear)
self.clearUpdater(x)
self.clearLabelTracker()
Трекинг подписей
Три режима, уже упомянутые в §6:
| Режим | Активируется | Подходит для |
|---|---|---|
| Snapshot | overlay.label_placement.enabled=true + keyframe_snapshots=true (без enabled не работает и не трогает ручные смещения) | play_keyframes — решатель запускается только на keyframes, offset между ними интерполируется. |
| Dynamic tracker | autoPlaceLabels(dynamic=True) | addUpdater-анимации — решатель каждые N кадров, EMA-сглаживание, hysteresis на якорях. |
| Static frozen | autoPlaceLabels() | One-shot для SVG или если подписи не движутся. |
Рекомендуемая конфигурация для keyframe-анимации
"rendering": {
"label_placement": {
"enabled": true,
"keyframe_snapshots": true,
"dynamic_angles": true,
"canonicalize_anchor": true,
"interpolation": "smooth"
}
}
Рекомендуемая конфигурация для addUpdater
"rendering": {
"label_placement": {
"enabled": true,
"dynamic_angles": true,
"ema_alpha": 0.2,
"anchor_flip_frames": 6,
"solver_every_n_frames": 2
}
}
Плюс в коде: self.autoPlaceLabels(dynamic=True).
Как запустить MP4-рендер
SVG пишется из exportSVG. MP4 — из manim-CLI:
$ cd /path/to/animageo
$ PYTHONPATH=. manim -ql my_scene.py MyScene
# -ql = quick low (854×480, 15 fps) — для iteration
# -qh = quick high (1920×1080, 60 fps) — для продакшена
# --media_dir /tmp/animageo — куда складывать temp-файлы
# --disable_caching — пересчитать всё (обычно manim кэширует кадры)
Итоговый файл окажется в media/videos/<script>/480p15/MyScene.mp4.
Scene-подклассов, manim берёт их имена из CLI:
manim -ql scenes.py SceneA SceneB SceneC отрендерит три
отдельных MP4. Это полезно для пакетной регенерации примеров гайда.