diff --git a/.gitignore b/.gitignore index 2822abd435..a764f52b44 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ build/ develop-eggs/ dist/ manimlib.egg-info/ +*.egg-info/ + +# uv +uv.lock downloads/ eggs/ @@ -151,5 +155,6 @@ dmypy.json # For manim /videos /custom_config.yml +/latex_cache test.py CLAUDE.md diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index b97adbb295..0000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -graft manimlib -recursive-exclude manimlib *.pyc *.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 78d01bd960..6c15885556 100644 --- a/README.md +++ b/README.md @@ -21,51 +21,58 @@ Note, there are two versions of manim. This repository began as a personal proj > [!Note] > **Note**: To install manim directly through pip, please pay attention to the name of the installed package. This repository is ManimGL of 3b1b. The package name is `manimgl` instead of `manim` or `manimlib`. Please use `pip install manimgl` to install the version in this repository. -Manim runs on Python 3.7 or higher. +Manim runs on Python 3.10 or higher (3.12+ recommended). System requirements are [FFmpeg](https://ffmpeg.org/), [OpenGL](https://www.opengl.org/) and [LaTeX](https://www.latex-project.org) (optional, if you want to use LaTeX). For Linux, [Pango](https://pango.org) along with its development headers are required. See instruction [here](https://github.com/ManimCommunity/ManimPango#building). +### Using uv (recommended) -### Directly +This project uses [uv](https://docs.astral.sh/uv/) for environment and dependency management. ```sh -# Install manimgl +# Install uv if you don't have it +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Clone and sync +git clone https://github.com/3b1b/manim.git +cd manim +uv sync + +# Try it out +uv run manimgl example_scenes.py OpeningManimExample +# or +uv run manim-render example_scenes.py OpeningManimExample +``` + +### Using pip + +```sh +# Install manimgl from PyPI pip install manimgl # Try it out manimgl ``` -For more options, take a look at the [Using manim](#using-manim) sections further below. - -If you want to hack on manimlib itself, clone this repository and in that directory execute: +If you want to hack on manimlib itself, clone this repository and install in editable mode: ```sh -# Install manimgl +git clone https://github.com/3b1b/manim.git +cd manim pip install -e . - -# Try it out manimgl example_scenes.py OpeningManimExample -# or -manim-render example_scenes.py OpeningManimExample ``` -### Directly (Windows) +### Platform-specific notes +**Windows:** 1. [Install FFmpeg](https://www.wikihow.com/Install-FFmpeg-on-Windows). 2. Install a LaTeX distribution. [MiKTeX](https://miktex.org/download) is recommended. -3. Install the remaining Python packages. - ```sh - git clone https://github.com/3b1b/manim.git - cd manim - pip install -e . - manimgl example_scenes.py OpeningManimExample - ``` +3. Clone and install as above. -### Mac OSX - -1. Install FFmpeg, LaTeX in terminal using homebrew. +**Mac OSX:** +1. Install FFmpeg and LaTeX via homebrew: ```sh brew install ffmpeg mactex ``` @@ -74,31 +81,23 @@ manim-render example_scenes.py OpeningManimExample > To avoid installing the full MacTeX bundle, which is ~6GB, you can alternatively install the > lightweight [BasicTeX](https://formulae.brew.sh/cask/basictex) and then gradually add - > only the LaTeX packages you actually need. A list of packages sufficient to run examples can + > only the LaTeX packages you actually need. A list of packages sufficient to run examples can > be found [here](https://github.com/3b1b/manim/issues/2133#issuecomment-2414547866). > For an overview of the MacTeX installer bundles, see https://www.tug.org/mactex/. -2. If you are using an ARM-based processor, install Cairo. +2. If you are using an ARM-based processor, install Cairo: ```sh arch -arm64 brew install pkg-config cairo ``` - -3. Install latest version of manim using these command. - ```sh - git clone https://github.com/3b1b/manim.git - cd manim - pip install -e . - manimgl example_scenes.py OpeningManimExample (make sure to add manimgl to path first.) - ``` - -## Anaconda Install -1. Install LaTeX as above. -2. Create a conda environment using `conda create -n manim python=3.9`. -3. Activate the environment using `conda activate manim`. -4. Install manimgl using `pip install -e .`. +3. Clone and install as above. +**Linux:** +Install Pango development headers (required for `manimpango`): +```sh +sudo apt-get install libpango1.0-dev # Debian/Ubuntu +``` ## Using manim Try running the following: @@ -119,7 +118,58 @@ When running in the CLI, some useful flags include: Take a look at custom_config.yml for further configuration. To add your customization, you can either edit this file, or add another file by the same name "custom_config.yml" to whatever directory you are running manim from. For example [this is the one](https://github.com/3b1b/videos/blob/master/custom_config.yml) for 3blue1brown videos. There you can specify where videos should be output to, where manim should look for image files and sounds you want to read in, and other defaults regarding style and video quality. -### Documentation +## MCP Server + +This repository includes an [MCP](https://modelcontextprotocol.io/) (Model Context Protocol) server that exposes ManimGL's functionality to LLMs. This lets AI assistants generate and render math explainer videos, inspect available objects and animations, and validate scene code. + +### Setup + +```sh +uv sync --extra mcp +uv run manimgl-mcp +``` + +### Tools + +| Tool | Description | +|---|---| +| `create_math_video` | Create a structured video plan from a natural language math topic description | +| `render` | Render a scene to mp4, gif, or png from Python code | +| `preview` | Capture a single frame as base64 PNG for quick feedback | +| `validate` | Check scene code for syntax/import errors without rendering | +| `list_topics` | Browse available math video topic templates by category and level | +| `get_topic_template` | Get a full video template with concept arc and renderable scene code | +| `get_math_helpers` | Get reusable math helper functions for a domain (calculus, linear algebra, etc.) | +| `get_example` | Get working example code for 18 common animation patterns | +| `list_mobjects` | List available mathematical object classes with signatures | +| `list_animations` | List available animation classes with signatures | + +### Resources + +| Resource | Description | +|---|---| +| `manim://style-guide` | Animation timing, color conventions, camera work, and common pitfalls | +| `manim://pedagogy` | 3Blue1Brown visual teaching principles: concrete before abstract, geometry before algebra | +| `manim://constants` | Direction vectors, frame dimensions, buffer sizes, and color palette | +| `manim://config` | Default rendering configuration (resolution, FPS, background) | + +### Client configuration + +To use the MCP server with an LLM client (Claude Desktop, Kiro, etc.), add the following to your MCP configuration: + +```json +{ + "mcpServers": { + "manimgl": { + "command": "uv", + "args": ["run", "--extra", "mcp", "manimgl-mcp"], + "cwd": "/path/to/this/repo" + } + } +} +``` + +## Documentation Documentation is in progress at [3b1b.github.io/manim](https://3b1b.github.io/manim/). And there is also a Chinese version maintained by [**@manim-kindergarten**](https://manim.org.cn): [docs.manim.org.cn](https://docs.manim.org.cn/) (in Chinese). [manim-kindergarten](https://github.com/manim-kindergarten/) wrote and collected some useful extra classes and some codes of videos in [manim_sandbox repo](https://github.com/manim-kindergarten/manim_sandbox). diff --git a/latex_cache/working.tex b/latex_cache/working.tex new file mode 100644 index 0000000000..48eebe2eda --- /dev/null +++ b/latex_cache/working.tex @@ -0,0 +1,34 @@ +\documentclass[preview]{standalone} + +\usepackage[english]{babel} +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{dsfont} +\usepackage{setspace} +\usepackage{tipa} +\usepackage{relsize} +\usepackage{textcomp} +\usepackage{mathrsfs} +\usepackage{calligra} +\usepackage{wasysym} +\usepackage{ragged2e} +\usepackage{physics} +\usepackage{xcolor} +\usepackage{microtype} +\usepackage{pifont} +\DisableLigatures{encoding = *, family = * } +\linespread{1} +%% Borrowed from https://tex.stackexchange.com/questions/6058/making-a-shorter-minus +\DeclareMathSymbol{\minus}{\mathbin}{AMSa}{"39} + + +\begin{document} + +\centering +\begin{align*} +x +\end{align*} + +\end{document} diff --git a/manimlib/__init__.py b/manimlib/__init__.py index ea7811391c..19767c9686 100644 --- a/manimlib/__init__.py +++ b/manimlib/__init__.py @@ -1,7 +1,4 @@ -try: - from importlib.metadata import version, PackageNotFoundError -except ImportError: # For Python <3.8 fallback - from importlib_metadata import version, PackageNotFoundError # type: ignore +from importlib.metadata import version, PackageNotFoundError try: __version__ = version("manimgl") diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py new file mode 100644 index 0000000000..430a19c69c --- /dev/null +++ b/mcp_server/__init__.py @@ -0,0 +1 @@ +# ManimGL MCP Server diff --git a/mcp_server/examples.py b/mcp_server/examples.py new file mode 100644 index 0000000000..d952e0c192 --- /dev/null +++ b/mcp_server/examples.py @@ -0,0 +1,489 @@ +""" +Curated example scenes for common ManimGL patterns. +""" +from __future__ import annotations + +EXAMPLES: dict[str, dict[str, str]] = { + "basic_shapes": { + "description": "Create and animate basic geometric shapes.", + "code": '''class BasicShapes(Scene): + def construct(self): + circle = Circle(radius=1.5, color=BLUE) + square = Square(side_length=2, color=GREEN) + triangle = Triangle(color=RED) + + shapes = VGroup(circle, square, triangle) + shapes.arrange(RIGHT, buff=1) + + self.play(ShowCreation(circle)) + self.play(FadeIn(square)) + self.play(GrowFromCenter(triangle)) + self.wait() +''', + }, + "transform": { + "description": "Transform one shape into another.", + "code": '''class TransformExample(Scene): + def construct(self): + circle = Circle(color=BLUE, fill_opacity=0.5) + square = Square(color=RED, fill_opacity=0.5) + + self.play(ShowCreation(circle)) + self.wait(0.5) + self.play(Transform(circle, square), run_time=2) + self.wait() +''', + }, + "tex": { + "description": "Render LaTeX mathematical expressions.", + "code": '''class TexExample(Scene): + def construct(self): + equation = Tex( + r"e^{i\\pi} + 1 = 0", + font_size=72, + ) + label = Text("Euler's Identity", font_size=36) + label.next_to(equation, DOWN, buff=0.5) + + self.play(Write(equation)) + self.play(FadeIn(label, shift=UP * 0.3)) + self.wait() +''', + }, + "graph": { + "description": "Plot a function on coordinate axes.", + "code": '''class GraphExample(Scene): + def construct(self): + axes = Axes( + x_range=[-3, 3, 1], + y_range=[-2, 2, 1], + axis_config={"include_numbers": True}, + ) + graph = axes.get_graph(lambda x: np.sin(x), color=BLUE) + label = axes.get_graph_label(graph, "\\\\sin(x)") + + self.play(ShowCreation(axes)) + self.play(ShowCreation(graph), Write(label)) + self.wait() +''', + }, + "3d": { + "description": "Create a 3D surface with camera rotation.", + "code": '''class ThreeDExample(ThreeDScene): + def construct(self): + axes = ThreeDAxes() + surface = ParametricSurface( + lambda u, v: np.array([u, v, np.sin(u) * np.cos(v)]), + u_range=[-3, 3], + v_range=[-3, 3], + resolution=(32, 32), + ) + surface.set_color_by_gradient(BLUE, GREEN) + + self.set_floor_plane("xz") + self.play(ShowCreation(axes)) + self.play(ShowCreation(surface), run_time=3) + self.wait() +''', + }, + "number_line": { + "description": "Animate a value tracker on a number line.", + "code": '''class NumberLineExample(Scene): + def construct(self): + number_line = NumberLine( + x_range=[-5, 5, 1], + include_numbers=True, + ) + tracker = ValueTracker(0) + dot = Dot(color=RED) + dot.add_updater( + lambda d: d.move_to(number_line.n2p(tracker.get_value())) + ) + + self.play(ShowCreation(number_line)) + self.add(dot) + self.play(tracker.animate.set_value(3), run_time=2) + self.play(tracker.animate.set_value(-2), run_time=2) + self.wait() +''', + }, + "complex_plane": { + "description": "Visualize a complex function transformation.", + "code": '''class ComplexPlaneExample(Scene): + def construct(self): + plane = ComplexPlane() + plane.add_coordinate_labels(font_size=24) + + moving_plane = plane.copy() + moving_plane.prepare_for_nonlinear_transform() + + self.play(ShowCreation(plane)) + self.wait(0.5) + self.play( + moving_plane.animate.apply_complex_function(lambda z: z**2), + run_time=3, + ) + self.wait() +''', + }, + "text_animation": { + "description": "Animate text with Write and FadeTransform.", + "code": '''class TextAnimation(Scene): + def construct(self): + text1 = Text("Hello, Manim!", font_size=60) + text2 = Text("Animations are fun", font_size=60, color=YELLOW) + + self.play(Write(text1)) + self.wait() + self.play(FadeTransform(text1, text2)) + self.wait() +''', + }, + # --- Technique examples inspired by 3b1b/videos --- + "value_tracker_graph": { + "description": "Use a ValueTracker to animate a dot sliding along a curve with a dynamic label.", + "code": '''class ValueTrackerGraph(Scene): + def construct(self): + axes = Axes( + x_range=[-1, 5, 1], y_range=[-1, 8, 2], + width=8, height=5, + axis_config={"include_numbers": True}, + ) + axes.to_edge(DOWN, buff=0.5) + graph = axes.get_graph(lambda x: 0.5 * x**2, color=BLUE) + + self.play(ShowCreation(axes), ShowCreation(graph)) + + # Tracker controls the x position + tracker = ValueTracker(0.5) + + dot = always_redraw(lambda: Dot( + axes.c2p(tracker.get_value(), 0.5 * tracker.get_value()**2), + color=YELLOW, radius=0.08, + )) + v_line = always_redraw(lambda: DashedLine( + axes.c2p(tracker.get_value(), 0), + axes.c2p(tracker.get_value(), 0.5 * tracker.get_value()**2), + color=GREY, stroke_width=2, + )) + label = always_redraw(lambda: Text( + f"({tracker.get_value():.1f}, {0.5 * tracker.get_value()**2:.1f})", + font_size=24, + ).next_to(dot, UR, buff=0.15)) + + self.play(FadeIn(dot), ShowCreation(v_line), Write(label)) + self.play(tracker.animate.set_value(4), run_time=4, rate_func=smooth) + self.play(tracker.animate.set_value(1.5), run_time=2) + self.wait() +''', + }, + "grid_transformation": { + "description": "Apply a linear transformation to a grid, showing how the plane deforms. Core 3b1b linear algebra technique.", + "code": '''class GridTransformation(Scene): + def construct(self): + plane = NumberPlane( + x_range=[-5, 5], y_range=[-4, 4], + background_line_style={"stroke_opacity": 0.5}, + ) + # Basis vectors + i_hat = Arrow(ORIGIN, RIGHT, buff=0, color=GREEN, stroke_width=5) + j_hat = Arrow(ORIGIN, UP, buff=0, color=RED, stroke_width=5) + + self.play(ShowCreation(plane), run_time=1) + self.play(GrowArrow(i_hat), GrowArrow(j_hat)) + self.wait() + + # Apply a rotation + scale matrix + matrix = [[0, -1], [1, 0]] # 90 degree rotation + label = Text("90° rotation", font_size=32, color=YELLOW) + label.to_corner(UL).set_backstroke(width=4) + + self.play( + plane.animate.apply_matrix(matrix), + i_hat.animate.put_start_and_end_on(ORIGIN, UP), + j_hat.animate.put_start_and_end_on(ORIGIN, LEFT), + Write(label), + run_time=3, + ) + self.wait(2) +''', + }, + "vector_field_2d": { + "description": "Visualize a 2D vector field with arrows. Inspired by 3b1b divergence/curl video.", + "code": '''class VectorField2D(Scene): + def construct(self): + plane = NumberPlane( + x_range=[-5, 5], y_range=[-4, 4], + background_line_style={"stroke_opacity": 0.3}, + ) + + def func(point): + x, y = point[:2] + return np.array([-y, x, 0]) * 0.3 + + field = VectorField( + func, plane, + x_range=[-4, 4], + y_range=[-3, 3], + density=2.0, + stroke_color=BLUE, + stroke_width=2, + ) + + title = Text("Rotational vector field", font_size=32) + title.to_edge(UP).set_backstroke(width=4) + + self.play(ShowCreation(plane), run_time=1) + self.play(ShowCreation(field), run_time=2) + self.play(Write(title)) + self.wait(2) +''', + }, + "color_gradient_surface": { + "description": "Create a 3D surface colored by height. Common pattern for visualizing scalar fields.", + "code": '''class ColorGradientSurface(ThreeDScene): + def construct(self): + frame = self.frame + frame.reorient(30, 70) + + axes = ThreeDAxes( + x_range=[-3, 3, 1], + y_range=[-3, 3, 1], + z_range=[-1, 2, 1], + ) + + surface = ParametricSurface( + lambda u, v: np.array([ + u, v, + np.exp(-(u**2 + v**2) / 2), + ]), + u_range=[-3, 3], + v_range=[-3, 3], + resolution=(48, 48), + ) + surface.set_color_by_gradient(BLUE, GREEN, YELLOW, RED) + surface.set_opacity(0.8) + + title = Text("Gaussian bell curve in 2D", font_size=32) + title.fix_in_frame() + title.to_edge(UP) + + self.play(ShowCreation(axes), run_time=1) + self.play(Write(title)) + self.play(ShowCreation(surface), run_time=3) + self.play(frame.animate.reorient(150, 60), run_time=5) + self.wait() +''', + }, + "progressive_equation": { + "description": "Build an equation step by step, highlighting each part. Key 3b1b presentation technique.", + "code": '''class ProgressiveEquation(Scene): + def construct(self): + # Build up: distance = rate x time + step1 = Text("distance", font_size=48, color=BLUE) + step2 = Text("distance = rate", font_size=48) + step2[0:8].set_color(BLUE) + step2[10:14].set_color(GREEN) + step3 = Text("distance = rate × time", font_size=48) + step3[0:8].set_color(BLUE) + step3[10:14].set_color(GREEN) + step3[16:].set_color(RED) + + self.play(Write(step1)) + self.wait() + self.play(FadeTransform(step1, step2)) + self.wait() + self.play(FadeTransform(step2, step3)) + self.wait() + + # Box the final result + box = SurroundingRectangle(step3, color=YELLOW, buff=0.2) + self.play(ShowCreation(box)) + self.wait(2) +''', + }, + "parametric_curve": { + "description": "Animate drawing a parametric curve (Lissajous figure).", + "code": '''class ParametricCurveExample(Scene): + def construct(self): + axes = Axes( + x_range=[-2, 2, 1], y_range=[-2, 2, 1], + width=6, height=6, + ) + + curve = ParametricCurve( + lambda t: axes.c2p(np.sin(3 * t), np.sin(2 * t)), + t_range=[0, TAU], + color=YELLOW, + stroke_width=3, + ) + + title = Text("Lissajous curve (3:2)", font_size=32) + title.to_edge(UP) + + self.play(ShowCreation(axes), Write(title)) + self.play(ShowCreation(curve), run_time=4, rate_func=linear) + self.wait(2) +''', + }, + "side_by_side": { + "description": "Show two related views side by side — the 'dual view' pattern from 3b1b videos.", + "code": '''class SideBySide(Scene): + def construct(self): + # Left: time domain + left_axes = Axes( + x_range=[0, 4*PI, PI], y_range=[-1.5, 1.5, 1], + width=5.5, height=3, + ) + left_axes.to_edge(LEFT, buff=0.5) + left_title = Text("Time domain", font_size=28) + left_title.next_to(left_axes, UP, buff=0.3) + + wave = left_axes.get_graph(lambda x: np.sin(x), color=BLUE) + + # Right: frequency domain (single spike) + right_axes = Axes( + x_range=[0, 5, 1], y_range=[0, 1.5, 0.5], + width=5.5, height=3, + ) + right_axes.to_edge(RIGHT, buff=0.5) + right_title = Text("Frequency domain", font_size=28) + right_title.next_to(right_axes, UP, buff=0.3) + + spike = Line( + right_axes.c2p(1, 0), right_axes.c2p(1, 1), + color=YELLOW, stroke_width=4, + ) + spike_dot = Dot(right_axes.c2p(1, 1), color=YELLOW, radius=0.08) + + # Divider + divider = DashedLine(3 * UP, 3 * DOWN, color=GREY, stroke_width=1) + + self.play( + ShowCreation(left_axes), ShowCreation(right_axes), + ShowCreation(divider), + Write(left_title), Write(right_title), + run_time=1.5, + ) + self.play(ShowCreation(wave), run_time=2) + self.play(ShowCreation(spike), FadeIn(spike_dot)) + self.wait(2) +''', + }, + "camera_orbit_3d": { + "description": "Orbit the camera around a 3D object to reveal its structure.", + "code": '''class CameraOrbit3D(ThreeDScene): + def construct(self): + frame = self.frame + frame.reorient(0, 75) + + # Torus + torus = ParametricSurface( + lambda u, v: np.array([ + (2 + 0.7 * np.cos(v)) * np.cos(u), + (2 + 0.7 * np.cos(v)) * np.sin(u), + 0.7 * np.sin(v), + ]), + u_range=[0, TAU], v_range=[0, TAU], + resolution=(48, 24), + ) + torus.set_color_by_gradient(BLUE, TEAL, GREEN) + torus.set_opacity(0.8) + + title = Text("Torus", font_size=36) + title.fix_in_frame() + title.to_corner(UL) + + self.play(ShowCreation(torus), Write(title), run_time=2) + self.wait() + + # Full orbit + self.play(frame.animate.reorient(360, 70), run_time=8, rate_func=linear) + self.wait() +''', + }, + "staggered_animation": { + "description": "Use LaggedStartMap for staggered entrance animations. Common 3b1b pattern.", + "code": '''class StaggeredAnimation(Scene): + def construct(self): + # Grid of colored squares + squares = VGroup( + Square(side_length=0.6, fill_opacity=0.8, fill_color=color) + for color in color_gradient([BLUE, GREEN, YELLOW, RED], 25) + ) + squares.arrange_in_grid(5, 5, buff=0.15) + + title = Text("LaggedStartMap demo", font_size=32) + title.to_edge(UP) + + self.play(Write(title)) + self.play(LaggedStartMap(FadeIn, squares, lag_ratio=0.05), run_time=2) + self.wait() + self.play(LaggedStartMap( + lambda m: m.animate.scale(0.5).set_opacity(0.3), + squares, + lag_ratio=0.03, + ), run_time=2) + self.wait() +''', + }, + "updater_chain": { + "description": "Chain multiple updaters so objects stay connected as one moves.", + "code": '''class UpdaterChain(Scene): + def construct(self): + # A dot orbiting a circle, with a line and label tracking it + circle = Circle(radius=2, color=BLUE_D, stroke_width=2) + center_dot = Dot(ORIGIN, color=WHITE, radius=0.05) + + tracker = ValueTracker(0) + + moving_dot = always_redraw(lambda: Dot( + 2 * np.array([ + np.cos(tracker.get_value()), + np.sin(tracker.get_value()), + 0, + ]), + color=YELLOW, radius=0.1, + )) + radius_line = always_redraw(lambda: Line( + ORIGIN, moving_dot.get_center(), + color=YELLOW, stroke_width=2, + )) + angle_arc = always_redraw(lambda: Arc( + start_angle=0, + angle=tracker.get_value() % TAU, + radius=0.5, + color=GREEN, + )) + angle_label = always_redraw(lambda: Text( + f"{np.degrees(tracker.get_value() % TAU):.0f}°", + font_size=24, color=GREEN, + ).next_to(angle_arc, RIGHT, buff=0.1)) + + self.add(circle, center_dot) + self.add(moving_dot, radius_line, angle_arc, angle_label) + self.play(tracker.animate.set_value(TAU), run_time=4, rate_func=linear) + self.play(tracker.animate.set_value(3 * TAU), run_time=6, rate_func=linear) + self.wait() +''', + }, +} + + +def get_example(topic: str) -> dict: + """ + Get example code for a given topic. + + Args: + topic: One of the available example topics. + + Returns: + Dict with "code" and "description", or an error with available topics. + """ + if topic in EXAMPLES: + return EXAMPLES[topic] + + return { + "error": f"Unknown topic '{topic}'.", + "available_topics": list(EXAMPLES.keys()), + } diff --git a/mcp_server/introspection.py b/mcp_server/introspection.py new file mode 100644 index 0000000000..9b06f6162e --- /dev/null +++ b/mcp_server/introspection.py @@ -0,0 +1,120 @@ +""" +Introspect manimlib to discover available Mobjects and Animations. +""" +from __future__ import annotations + +import inspect +from typing import Any + + +# Mapping of category names to module paths +MOBJECT_CATEGORIES = { + "geometry": "manimlib.mobject.geometry", + "text": "manimlib.mobject.svg.tex_mobject", + "svg": "manimlib.mobject.svg.svg_mobject", + "three_d": "manimlib.mobject.three_dimensions", + "coordinate_systems": "manimlib.mobject.coordinate_systems", + "functions": "manimlib.mobject.functions", + "number_line": "manimlib.mobject.number_line", + "numbers": "manimlib.mobject.numbers", + "matrix": "manimlib.mobject.matrix", + "probability": "manimlib.mobject.probability", + "boolean_ops": "manimlib.mobject.boolean_ops", + "vector_field": "manimlib.mobject.vector_field", + "value_tracker": "manimlib.mobject.value_tracker", + "dot_cloud": "manimlib.mobject.types.dot_cloud", + "image": "manimlib.mobject.types.image_mobject", + "surface": "manimlib.mobject.types.surface", +} + +ANIMATION_CATEGORIES = { + "creation": "manimlib.animation.creation", + "fading": "manimlib.animation.fading", + "growing": "manimlib.animation.growing", + "indication": "manimlib.animation.indication", + "movement": "manimlib.animation.movement", + "numbers": "manimlib.animation.numbers", + "rotation": "manimlib.animation.rotation", + "transform": "manimlib.animation.transform", + "transform_matching_parts": "manimlib.animation.transform_matching_parts", + "composition": "manimlib.animation.composition", + "update": "manimlib.animation.update", + "specialized": "manimlib.animation.specialized", +} + + +def _get_classes_from_module(module_path: str, base_class: type | None = None) -> list[dict]: + """Import a module and extract public classes with their signatures.""" + import importlib + + try: + mod = importlib.import_module(module_path) + except ImportError: + return [] + + results = [] + for name, obj in inspect.getmembers(mod, inspect.isclass): + # Skip private classes and imports from other modules + if name.startswith("_"): + continue + if not hasattr(obj, "__module__") or not obj.__module__.startswith(module_path.rsplit(".", 1)[0]): + continue + if base_class and not issubclass(obj, base_class): + continue + + try: + sig = str(inspect.signature(obj.__init__)) + # Remove 'self' parameter for cleaner display + sig = sig.replace("(self, ", "(", 1).replace("(self)", "()") + except (ValueError, TypeError): + sig = "(...)" + + doc = inspect.getdoc(obj) or "" + # Truncate long docstrings + if len(doc) > 200: + doc = doc[:200] + "..." + + results.append({ + "name": name, + "module": module_path, + "signature": sig, + "doc": doc, + }) + + return results + + +def list_mobjects(category: str = "all") -> list[dict]: + """ + List available Mobject classes. + + Args: + category: Filter by category name, or "all" for everything. + """ + from manimlib.mobject.mobject import Mobject + + if category != "all" and category in MOBJECT_CATEGORIES: + return _get_classes_from_module(MOBJECT_CATEGORIES[category], Mobject) + + results = [] + for cat_module in MOBJECT_CATEGORIES.values(): + results.extend(_get_classes_from_module(cat_module, Mobject)) + return results + + +def list_animations(category: str = "all") -> list[dict]: + """ + List available Animation classes. + + Args: + category: Filter by category name, or "all" for everything. + """ + from manimlib.animation.animation import Animation + + if category != "all" and category in ANIMATION_CATEGORIES: + return _get_classes_from_module(ANIMATION_CATEGORIES[category], Animation) + + results = [] + for cat_module in ANIMATION_CATEGORIES.values(): + results.extend(_get_classes_from_module(cat_module, Animation)) + return results diff --git a/mcp_server/math_helpers.py b/mcp_server/math_helpers.py new file mode 100644 index 0000000000..f74031c708 --- /dev/null +++ b/mcp_server/math_helpers.py @@ -0,0 +1,343 @@ +""" +Reusable mathematical helper functions organized by domain. + +These are code snippets an LLM can incorporate into scene code. +Distilled from patterns in the 3b1b/videos repository. +""" +from __future__ import annotations + +HELPERS: dict[str, dict[str, str]] = { + "calculus": { + "description": "Derivatives, integrals, limits, Taylor series visualization helpers.", + "code": r''' +# --- Calculus Helpers --- + +def get_tangent_line(axes, graph, x, length=4, color=YELLOW): + """Draw a tangent line to a graph at point x.""" + dx = 0.001 + x0 = x + y0 = axes.p2c(graph.pfp(axes.x_axis.p2n(axes.c2p(x0, 0))))[1] + # Numerical derivative + y1 = axes.p2c(graph.pfp(axes.x_axis.p2n(axes.c2p(x0 + dx, 0))))[1] + slope = (y1 - y0) / dx + line = Line( + axes.c2p(x0 - length/2, y0 - slope * length/2), + axes.c2p(x0 + length/2, y0 + slope * length/2), + color=color, + ) + return line + +def get_riemann_rects(axes, graph, x_range, dx=0.25, color=BLUE, opacity=0.5): + """Create Riemann sum rectangles under a curve.""" + rects = VGroup() + x = x_range[0] + while x < x_range[1]: + y = axes.p2c(graph.pfp(axes.x_axis.p2n(axes.c2p(x, 0))))[1] + rect = Rectangle( + width=axes.x_axis.get_unit_size() * dx, + height=abs(y) * axes.y_axis.get_unit_size(), + fill_color=color, + fill_opacity=opacity, + stroke_color=WHITE, + stroke_width=1, + ) + rect.move_to(axes.c2p(x + dx/2, y/2)) + rects.add(rect) + x += dx + return rects + +def get_area_under_curve(axes, graph, x_range, color=BLUE, opacity=0.3): + """Shade the area under a curve between x_range[0] and x_range[1].""" + area = axes.get_area_under_graph(graph, x_range, color=color) + area.set_fill(opacity=opacity) + return area + +def get_secant_line(axes, graph, x1, x2, length=6, color=RED): + """Draw a secant line between two points on a graph.""" + p1 = graph.pfp(axes.x_axis.p2n(axes.c2p(x1, 0))) + p2 = graph.pfp(axes.x_axis.p2n(axes.c2p(x2, 0))) + direction = normalize(p2 - p1) + line = Line( + p1 - direction * length/2, + p1 + direction * length/2, + color=color, + ) + return line +''', + }, + "linear_algebra": { + "description": "Matrix transformations, eigenvectors, basis vectors, grid deformations.", + "code": r''' +# --- Linear Algebra Helpers --- + +def get_basis_vectors(axes, colors=(GREEN, RED)): + """Create labeled basis vectors i-hat and j-hat.""" + i_hat = Arrow(axes.c2p(0, 0), axes.c2p(1, 0), buff=0, color=colors[0]) + j_hat = Arrow(axes.c2p(0, 0), axes.c2p(0, 1), buff=0, color=colors[1]) + i_label = Text("î", font_size=30, color=colors[0]) + j_label = Text("ĵ", font_size=30, color=colors[1]) + i_label.next_to(i_hat, DOWN, buff=0.1) + j_label.next_to(j_hat, LEFT, buff=0.1) + return VGroup(i_hat, j_hat), VGroup(i_label, j_label) + +def apply_matrix_to_points(matrix, points): + """Apply a 2x2 matrix to an array of 2D points.""" + mat = np.array(matrix) + result = np.zeros_like(points) + result[:, :2] = (mat @ points[:, :2].T).T + return result + +def get_column_vectors(matrix, axes, colors=(GREEN, RED)): + """Show where the basis vectors land after a matrix transformation.""" + col1 = Arrow( + axes.c2p(0, 0), + axes.c2p(matrix[0][0], matrix[1][0]), + buff=0, color=colors[0], + ) + col2 = Arrow( + axes.c2p(0, 0), + axes.c2p(matrix[0][1], matrix[1][1]), + buff=0, color=colors[1], + ) + return VGroup(col1, col2) + +def get_eigen_line(axes, eigenvector, length=8, color=PURPLE): + """Draw a line through the origin in the direction of an eigenvector.""" + direction = np.array([*eigenvector, 0]) + direction = direction / np.linalg.norm(direction) + return DashedLine( + axes.c2p(*(direction[:2] * -length/2)), + axes.c2p(*(direction[:2] * length/2)), + color=color, + ) +''', + }, + "complex_analysis": { + "description": "Complex plane, conformal maps, Euler's formula, Möbius transforms.", + "code": r''' +# --- Complex Analysis Helpers --- + +def complex_to_point(z): + """Convert a complex number to a manim 3D point.""" + return np.array([z.real, z.imag, 0]) + +def point_to_complex(point): + """Convert a manim point to a complex number.""" + return complex(point[0], point[1]) + +def get_complex_dot(z, color=YELLOW, radius=0.08): + """Place a dot at a complex number on the plane.""" + return Dot(complex_to_point(z), color=color, radius=radius) + +def get_unit_circle(plane, color=YELLOW, stroke_width=2): + """Draw the unit circle on a complex plane.""" + return Circle( + radius=plane.x_axis.get_unit_size(), + color=color, + stroke_width=stroke_width, + ).move_to(plane.n2p(0)) + +def mobius_transform(z, a=0, b=1, c=1, d=0): + """Apply a Möbius transformation (az + b) / (cz + d).""" + denom = c * z + d + if abs(denom) < 1e-10: + return complex(1e10, 0) + return (a * z + b) / denom + +def stereographic_proj(points3d, epsilon=1e-10): + """Project 3D sphere points to 2D plane via stereographic projection.""" + x, y, z = points3d.T + denom = 1 - z + denom[np.abs(denom) < epsilon] = np.inf + return np.array([x / denom, y / denom, 0 * z]).T + +def inv_stereographic_proj(points2d): + """Inverse stereographic projection: 2D plane to 3D sphere.""" + u, v = points2d[:, 0], points2d[:, 1] + norm_sq = u * u + v * v + denom = 1 + norm_sq + return np.array([ + 2 * u / denom, + 2 * v / denom, + (norm_sq - 1) / denom, + ]).T +''', + }, + "vector_calculus": { + "description": "Vector fields, divergence, curl, line integrals, surface integrals.", + "code": r''' +# --- Vector Calculus Helpers --- + +def numerical_gradient(scalar_func, point, dt=1e-7): + """Compute the gradient of a scalar function at a point.""" + p = np.array(point, dtype=float) + grad = np.zeros(3) + f0 = scalar_func(p) + for i in range(3): + dp = np.zeros(3) + dp[i] = dt + grad[i] = (scalar_func(p + dp) - f0) / dt + return grad + +def numerical_divergence(vector_func, point, dt=1e-7): + """Compute divergence of a vector field at a point.""" + p = np.array(point, dtype=float) + div = 0 + v0 = vector_func(p) + for i in range(3): + dp = np.zeros(3) + dp[i] = dt + div += (vector_func(p + dp)[i] - v0[i]) / dt + return div + +def numerical_curl_2d(vector_func, point, dt=1e-7): + """Compute the z-component of curl for a 2D vector field.""" + p = np.array(point, dtype=float) + v0 = vector_func(p) + dvx_dy = (vector_func(p + dt * UP)[0] - v0[0]) / dt + dvy_dx = (vector_func(p + dt * RIGHT)[1] - v0[1]) / dt + return dvy_dx - dvx_dy + +def get_flow_field(func, axes, x_range=(-5, 5), y_range=(-5, 5), + density=1.0, color=BLUE, stroke_width=1): + """Create a vector field visualization.""" + return VectorField( + func, axes, + x_range=x_range, + y_range=y_range, + density=density, + stroke_color=color, + stroke_width=stroke_width, + ) +''', + }, + "differential_geometry": { + "description": "Surfaces, curvature, tangent planes, geodesics, Gauss map.", + "code": r''' +# --- Differential Geometry Helpers --- + +def parametric_sphere(radius=1): + """Create a parametric sphere surface.""" + return ParametricSurface( + lambda u, v: radius * np.array([ + np.sin(u) * np.cos(v), + np.sin(u) * np.sin(v), + np.cos(u), + ]), + u_range=[0, PI], + v_range=[0, TAU], + resolution=(48, 48), + ) + +def parametric_torus(R=2, r=0.7): + """Create a parametric torus surface.""" + return ParametricSurface( + lambda u, v: np.array([ + (R + r * np.cos(v)) * np.cos(u), + (R + r * np.cos(v)) * np.sin(u), + r * np.sin(v), + ]), + u_range=[0, TAU], + v_range=[0, TAU], + resolution=(48, 48), + ) + +def parametric_mobius(width=1): + """Create a Möbius strip surface.""" + return ParametricSurface( + lambda u, v: np.array([ + (1 + v/2 * np.cos(u/2)) * np.cos(u), + (1 + v/2 * np.cos(u/2)) * np.sin(u), + v/2 * np.sin(u/2), + ]), + u_range=[0, TAU], + v_range=[-width, width], + resolution=(64, 16), + ) + +def get_tangent_vectors(surface_func, u, v, scale=0.5, du=0.001, dv=0.001): + """Compute tangent vectors to a parametric surface at (u, v).""" + p = surface_func(u, v) + tu = (surface_func(u + du, v) - p) / du * scale + tv = (surface_func(u, v + dv) - p) / dv * scale + return tu, tv + +def get_normal_vector(surface_func, u, v, scale=0.5, du=0.001, dv=0.001): + """Compute the unit normal to a parametric surface at (u, v).""" + tu, tv = get_tangent_vectors(surface_func, u, v, 1.0, du, dv) + normal = np.cross(tu, tv) + norm = np.linalg.norm(normal) + if norm < 1e-10: + return np.array([0, 0, 1.0]) + return normal / norm * scale + +def fibonacci_sphere_points(n=1000): + """Generate approximately uniform points on a unit sphere.""" + phi = np.pi * (np.sqrt(5) - 1) # golden angle + indices = np.arange(n) + y = 1 - (indices / (n - 1)) * 2 + radius = np.sqrt(1 - y * y) + theta = phi * indices + x = np.cos(theta) * radius + z = np.sin(theta) * radius + return np.column_stack([x, y, z]) +''', + }, + "probability": { + "description": "Distributions, Bayes' theorem, random walks, central limit theorem.", + "code": r''' +# --- Probability Helpers --- + +def get_bar_chart(axes, values, width=0.6, colors=None, opacity=0.8): + """Create a bar chart from a list of values.""" + if colors is None: + colors = color_gradient([BLUE, GREEN], len(values)) + bars = VGroup() + for i, (val, col) in enumerate(zip(values, colors)): + bar = Rectangle( + width=width * axes.x_axis.get_unit_size(), + height=val * axes.y_axis.get_unit_size(), + fill_color=col, + fill_opacity=opacity, + stroke_color=WHITE, + stroke_width=1, + ) + bar.move_to(axes.c2p(i + 0.5, val / 2)) + bars.add(bar) + return bars + +def gaussian(x, mu=0, sigma=1): + """Standard Gaussian/normal distribution PDF.""" + return np.exp(-0.5 * ((x - mu) / sigma) ** 2) / (sigma * np.sqrt(2 * np.pi)) + +def binomial_pmf(n, k, p=0.5): + """Binomial distribution PMF.""" + from math import comb + return comb(n, k) * p**k * (1 - p)**(n - k) + +def random_walk_points(n_steps=100, step_size=0.3): + """Generate a 2D random walk as a list of points.""" + angles = np.random.uniform(0, TAU, n_steps) + steps = step_size * np.column_stack([np.cos(angles), np.sin(angles), np.zeros(n_steps)]) + return np.cumsum(steps, axis=0) +''', + }, +} + + +def get_math_helpers(domain: str) -> dict: + """ + Get reusable math helper functions for a domain. + + Args: + domain: One of the available domains. + + Returns: + Dict with "description" and "code", or error with available domains. + """ + if domain in HELPERS: + return HELPERS[domain] + return { + "error": f"Unknown domain '{domain}'.", + "available_domains": list(HELPERS.keys()), + } diff --git a/mcp_server/renderer.py b/mcp_server/renderer.py new file mode 100644 index 0000000000..52f6dba4be --- /dev/null +++ b/mcp_server/renderer.py @@ -0,0 +1,175 @@ +""" +Subprocess-based ManimGL scene renderer. + +All scene execution happens in an isolated subprocess to prevent +bad user code from crashing the MCP server. +""" +from __future__ import annotations + +import base64 +import os +import subprocess +import tempfile +from pathlib import Path + +RENDER_TIMEOUT = 120 # seconds +OUTPUT_DIR = Path(tempfile.gettempdir()) / "manimgl_mcp" +# Project root — needed so `uv run` can find pyproject.toml +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +QUALITY_FLAGS = { + "low": ["-l"], + "medium": ["-m"], + "high": ["--hd"], + "4k": ["--uhd"], +} + + +def _ensure_output_dir() -> Path: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + return OUTPUT_DIR + + +def _find_output_file(output_dir: Path, fmt: str) -> Path | None: + """Walk the output directory tree for the most recent file matching fmt.""" + extensions = { + "mp4": {".mp4"}, + "gif": {".gif"}, + "png": {".png"}, + } + valid_exts = extensions.get(fmt, {f".{fmt}"}) + candidates = [ + f for f in output_dir.rglob("*") + if f.suffix.lower() in valid_exts and f.is_file() + ] + if not candidates: + return None + return max(candidates, key=lambda p: p.stat().st_mtime) + + +def _image_to_base64(path: Path) -> str: + """Read an image file and return its base64-encoded content.""" + data = path.read_bytes() + return base64.b64encode(data).decode("ascii") + + +def _write_temp_scene(code: str) -> Path: + """Write scene code to a temp .py file and return its path.""" + output_dir = _ensure_output_dir() + fd, path = tempfile.mkstemp(suffix=".py", dir=output_dir, prefix="scene_") + with os.fdopen(fd, "w") as f: + # Ensure manimlib is importable + f.write("from manimlib import *\n\n") + f.write(code) + return Path(path) + + +def _build_command( + scene_file: Path, + scene_name: str | None, + quality: str, + fmt: str, + output_dir: Path, +) -> list[str]: + """Build the uv run manimgl command.""" + cmd = ["uv", "run", "--extra", "mcp", "manimgl"] + cmd.append(str(scene_file)) + + if scene_name: + cmd.append(scene_name) + + # Write to file (not window) + cmd.append("-w") + + # Direct output to our temp directory + cmd.extend(["--video_dir", str(output_dir)]) + + # Quality + cmd.extend(QUALITY_FLAGS.get(quality, ["-m"])) + + # For png output, skip animations and save last frame + if fmt == "png": + cmd.append("-s") + + # For gif output + if fmt == "gif": + cmd.append("-i") + + return cmd + + +def render_scene( + code: str, + scene_name: str | None = None, + quality: str = "medium", + fmt: str = "mp4", +) -> dict: + """ + Render a ManimGL scene from code. + + Returns a dict with file_path, format, and optionally base64_image. + """ + output_dir = _ensure_output_dir() + scene_file = _write_temp_scene(code) + + try: + cmd = _build_command(scene_file, scene_name, quality, fmt, output_dir) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=RENDER_TIMEOUT, + cwd=str(PROJECT_ROOT), + ) + + if result.returncode != 0: + return { + "success": False, + "error": result.stderr.strip() or result.stdout.strip(), + } + + # Find the output file + search_fmt = "png" if fmt == "png" else fmt + output_file = _find_output_file(output_dir, search_fmt) + + if output_file is None: + return { + "success": False, + "error": "Render completed but no output file found.", + "stdout": result.stdout.strip(), + "stderr": result.stderr.strip(), + } + + response: dict = { + "success": True, + "file_path": str(output_file), + "format": fmt, + } + + # Include base64 for images (small enough for inline transport) + if fmt == "png": + response["base64_image"] = _image_to_base64(output_file) + + return response + + except subprocess.TimeoutExpired: + return { + "success": False, + "error": f"Render timed out after {RENDER_TIMEOUT} seconds.", + } + finally: + # Clean up the temp scene file + scene_file.unlink(missing_ok=True) + + +def render_frame( + code: str, + scene_name: str | None = None, + quality: str = "low", +) -> dict: + """ + Render a single frame (last frame) of a scene. + Always returns base64 PNG for quick visual feedback. + """ + return render_scene(code, scene_name, quality, fmt="png") diff --git a/mcp_server/server.py b/mcp_server/server.py new file mode 100644 index 0000000000..f203c383cc --- /dev/null +++ b/mcp_server/server.py @@ -0,0 +1,361 @@ +""" +ManimGL MCP Server — exposes ManimGL functionality to LLMs via FastMCP. + +Run with: uv run --extra mcp manimgl-mcp +""" +from __future__ import annotations + +import json +from pathlib import Path + +from fastmcp import FastMCP + +from mcp_server.renderer import render_scene as _render_scene +from mcp_server.renderer import render_frame as _render_frame +from mcp_server.validation import validate_scene_code +from mcp_server.introspection import ( + list_mobjects as _list_mobjects, + list_animations as _list_animations, + MOBJECT_CATEGORIES, + ANIMATION_CATEGORIES, +) +from mcp_server.examples import get_example as _get_example, EXAMPLES +from mcp_server.topics import ( + list_topics as _list_topics, + get_topic_template as _get_topic_template, + LEVELS as TOPIC_LEVELS, + CATEGORIES as TOPIC_CATEGORIES, +) +from mcp_server.math_helpers import get_math_helpers as _get_math_helpers, HELPERS +from mcp_server.style_guide import STYLE_GUIDE, PEDAGOGY_GUIDE +from mcp_server.video_planner import create_video_plan as _create_video_plan + +mcp = FastMCP("manimgl") + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + +@mcp.tool +def render( + code: str, + scene_name: str | None = None, + quality: str = "medium", + format: str = "mp4", +) -> str: + """Render a ManimGL scene from Python code. + + The code should define one or more Scene subclasses with a + construct() method. You do NOT need to include + 'from manimlib import *' — it is added automatically. + + Args: + code: Python source defining Scene subclass(es). + scene_name: Class name to render. If omitted, renders the + first Scene found. + quality: One of "low" (480p), "medium" (720p), "high" + (1080p), "4k". + format: Output format — "mp4", "gif", or "png". + "png" captures the final frame only. + """ + result = _render_scene(code, scene_name, quality, format) + return json.dumps(result, indent=2) + + +@mcp.tool +def preview( + code: str, + scene_name: str | None = None, + quality: str = "low", +) -> str: + """Render a single frame (final frame) of a scene for quick preview. + + Much faster than a full render. Returns a base64-encoded PNG image. + + Args: + code: Python source defining Scene subclass(es). + scene_name: Class name to render. Defaults to first found. + quality: One of "low", "medium", "high", "4k". + """ + result = _render_frame(code, scene_name, quality) + return json.dumps(result, indent=2) + + +@mcp.tool +def validate(code: str) -> str: + """Validate ManimGL scene code without rendering. + + Checks for syntax errors, undefined names, and import problems. + Does NOT invoke the GPU or run animations. + + Args: + code: Python source defining Scene subclass(es). + """ + result = validate_scene_code(code) + return json.dumps(result, indent=2) + + +@mcp.tool +def list_mobjects(category: str = "all") -> str: + """List available ManimGL Mobject (mathematical object) classes. + + Returns class names, constructor signatures, and docstrings. + + Args: + category: Filter by category. Options: "all", "geometry", + "text", "svg", "three_d", "coordinate_systems", + "functions", "number_line", "numbers", "matrix", + "probability", "boolean_ops", "vector_field", + "value_tracker", "dot_cloud", "image", "surface". + """ + results = _list_mobjects(category) + return json.dumps(results, indent=2) + + +@mcp.tool +def list_animations(category: str = "all") -> str: + """List available ManimGL Animation classes. + + Returns class names, constructor signatures, and docstrings. + + Args: + category: Filter by category. Options: "all", "creation", + "fading", "growing", "indication", "movement", + "numbers", "rotation", "transform", + "transform_matching_parts", "composition", + "update", "specialized". + """ + results = _list_animations(category) + return json.dumps(results, indent=2) + + +@mcp.tool +def get_example(topic: str) -> str: + """Get a working example scene for a given topic. + + Returns complete, runnable code you can pass directly to + the render or preview tools. + + Args: + topic: Example topic. Options include "basic_shapes", + "transform", "tex", "graph", "3d", "number_line", + "complex_plane", "text_animation", + "value_tracker_graph", "grid_transformation", + "vector_field_2d", "color_gradient_surface", + "progressive_equation", "parametric_curve", + "side_by_side", "camera_orbit_3d", + "staggered_animation", "updater_chain". + """ + result = _get_example(topic) + return json.dumps(result, indent=2) + + +@mcp.tool +def list_topics( + category: str | None = None, + level: str | None = None, +) -> str: + """Browse available math video topic templates. + + Each topic is a structured video plan with concept arc, + scene code, and pedagogical notes following 3Blue1Brown's + visual-first teaching approach. + + Args: + category: Filter by math category. Options: "geometry", + "linear_algebra", "complex_analysis", "calculus", + "differential_geometry". Omit for all. + level: Filter by difficulty. Options: "basic", + "intermediate", "calculus", "advanced". Omit for all. + """ + results = _list_topics(category, level) + return json.dumps(results, indent=2) + + +@mcp.tool +def get_topic_template(topic_id: str) -> str: + """Get the full video template for a math topic. + + Returns the concept arc (pedagogical outline), complete + renderable scene code, and visual design notes. + + The scene code can be passed directly to the render tool. + + Args: + topic_id: Topic identifier from list_topics. + """ + result = _get_topic_template(topic_id) + return json.dumps(result, indent=2, default=str) + + +@mcp.tool +def get_math_helpers(domain: str) -> str: + """Get reusable math helper functions for a domain. + + Returns Python code with documented helper functions you can + incorporate into scene code. Covers common mathematical + operations for visualization. + + Args: + domain: One of "calculus", "linear_algebra", + "complex_analysis", "vector_calculus", + "differential_geometry", "probability". + """ + result = _get_math_helpers(domain) + return json.dumps(result, indent=2) + + +@mcp.tool +def create_math_video( + topic: str, + level: str = "intermediate", + duration_hint: str = "medium", +) -> str: + """Create a structured plan for a math explainer video. + + Takes a natural language description of a math concept and + returns a complete video plan with: + - Matched topic templates with renderable scene code + - Relevant math helper functions + - Technique examples to draw from + - Pedagogical guidance (3Blue1Brown style) + - Duration and pacing notes + + The scene code in matched templates can be passed directly + to the render tool. For topics without an exact template + match, use the returned examples and helpers as building + blocks to write new scene code. + + Args: + topic: Natural language description, e.g. "how the + derivative relates to the slope of a tangent line" + or "visualize Gaussian curvature on different surfaces". + level: Target difficulty. One of "basic", "intermediate", + "calculus", "advanced". + duration_hint: Target length. One of "short" (~10s), + "medium" (~30s), "long" (~60s+). + """ + result = _create_video_plan(topic, level, duration_hint) + return json.dumps(result, indent=2, default=str) + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + +@mcp.resource("manim://constants") +def get_constants() -> str: + """Key ManimGL constants for positioning and colors. + + Includes direction vectors (UP, DOWN, LEFT, RIGHT, ORIGIN), + frame dimensions, buffer sizes, and the full color palette. + """ + constants = { + "directions": { + "ORIGIN": [0, 0, 0], + "UP": [0, 1, 0], + "DOWN": [0, -1, 0], + "RIGHT": [1, 0, 0], + "LEFT": [-1, 0, 0], + "IN": [0, 0, -1], + "OUT": [0, 0, 1], + "UL": [-1, 1, 0], + "UR": [1, 1, 0], + "DL": [-1, -1, 0], + "DR": [1, -1, 0], + }, + "frame": { + "FRAME_HEIGHT": 8.0, + "FRAME_WIDTH": "8.0 * aspect_ratio (default ~14.22 at 1920x1080)", + }, + "buffers": { + "SMALL_BUFF": 0.1, + "MED_SMALL_BUFF": 0.25, + "MED_LARGE_BUFF": 0.5, + "LARGE_BUFF": 1.0, + "DEFAULT_MOBJECT_TO_EDGE_BUFF": 0.5, + }, + "colors": { + "primary": [ + "WHITE", "BLACK", "GREY_A", "GREY_B", "GREY_C", + "GREY_D", "GREY_E", + ], + "spectrum": [ + "BLUE_A", "BLUE_B", "BLUE_C", "BLUE_D", "BLUE_E", + "TEAL_A", "TEAL_B", "TEAL_C", "TEAL_D", "TEAL_E", + "GREEN_A", "GREEN_B", "GREEN_C", "GREEN_D", "GREEN_E", + "YELLOW_A", "YELLOW_B", "YELLOW_C", "YELLOW_D", "YELLOW_E", + "GOLD_A", "GOLD_B", "GOLD_C", "GOLD_D", "GOLD_E", + "RED_A", "RED_B", "RED_C", "RED_D", "RED_E", + "MAROON_A", "MAROON_B", "MAROON_C", "MAROON_D", "MAROON_E", + "PURPLE_A", "PURPLE_B", "PURPLE_C", "PURPLE_D", "PURPLE_E", + ], + "aliases": { + "BLUE": "BLUE_C", + "TEAL": "TEAL_C", + "GREEN": "GREEN_C", + "YELLOW": "YELLOW_C", + "GOLD": "GOLD_C", + "RED": "RED_C", + "MAROON": "MAROON_C", + "PURPLE": "PURPLE_C", + "ORANGE": "#FF862F", + "PINK": "#D147BD", + }, + }, + "example_topics": list(EXAMPLES.keys()), + "mobject_categories": list(MOBJECT_CATEGORIES.keys()), + "animation_categories": list(ANIMATION_CATEGORIES.keys()), + } + return json.dumps(constants, indent=2) + + +@mcp.resource("manim://config") +def get_default_config() -> str: + """Default ManimGL configuration. + + Shows camera resolution, FPS, background color, file writer + settings, and other defaults that affect rendering. + """ + config_path = Path(__file__).resolve().parent.parent / "manimlib" / "default_config.yml" + if config_path.exists(): + return config_path.read_text() + return "default_config.yml not found" + + +@mcp.resource("manim://style-guide") +def get_style_guide() -> str: + """Visual style guide for creating ManimGL animations. + + Covers animation timing, color conventions, camera work, + text/equation presentation, updaters, staggered animations, + and common pitfalls. Distilled from 3Blue1Brown production code. + """ + return STYLE_GUIDE + + +@mcp.resource("manim://pedagogy") +def get_pedagogy_guide() -> str: + """Visual mathematics pedagogy principles. + + The 3Blue1Brown approach to teaching math through animation: + concrete before abstract, geometry before algebra, animation + shows process, one concept per scene, let the eye follow. + + Includes video structure patterns and color conventions + for different math domains. + """ + return PEDAGOGY_GUIDE + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/mcp_server/style_guide.py b/mcp_server/style_guide.py new file mode 100644 index 0000000000..d9d0f904eb --- /dev/null +++ b/mcp_server/style_guide.py @@ -0,0 +1,234 @@ +""" +3Blue1Brown-inspired style guide and visual pedagogy principles. + +Distilled from studying 74,000+ lines of production 3b1b video code +spanning 2015–2026. +""" +from __future__ import annotations + +STYLE_GUIDE = r""" +# ManimGL Visual Style Guide + +## Core Animation Patterns + +### Progressive Revelation +Build complexity one layer at a time. Never dump everything on screen at once. + +```python +# GOOD: Build up step by step +self.play(ShowCreation(axes)) +self.wait(0.5) +self.play(ShowCreation(graph)) +self.wait(0.5) +self.play(Write(label)) + +# BAD: Everything at once +self.add(axes, graph, label) +``` + +### Animation Timing +- `ShowCreation`: 1–2s for simple shapes, 2–3s for complex curves +- `Write`: 1–1.5s for short equations, 2–3s for long ones +- `FadeIn`/`FadeOut`: 0.5–1s +- `Transform`: 1.5–2s (the viewer needs time to track the change) +- `self.wait()`: 0.5–1s between related steps, 1–2s between concepts +- Camera rotations: 4–8s (slow enough to follow) + +### Color as Meaning +Assign colors to mathematical objects consistently throughout a video: + +```python +# Variables get specific colors +t2c = {"x": BLUE, "y": RED, "z": GREEN} +equation = Tex(R"f(x, y) = x^2 + y^2", t2c=t2c) + +# Common conventions: +# - Input variables: BLUE family +# - Output/result: YELLOW family +# - Auxiliary/parameter: RED family +# - Positive: GREEN, Negative: RED +# - Real part: BLUE, Imaginary part: YELLOW +``` + +### Text and Equations +- Use `Text()` for plain labels (no LaTeX needed) +- Use `Tex()` for LaTeX math (requires LaTeX installed) +- Use raw strings for LaTeX: `Tex(R"\frac{d}{dx}")` +- Use `t2c` for coloring parts of equations +- Use `font_size` parameter (default 48, use 36 for labels, 60–72 for emphasis) +- Position equations with `to_edge(UP)` or `to_corner(UL)` to keep them visible +- Use `fix_in_frame()` for equations that should not move with 3D camera +- Use `set_backstroke(BLACK, width)` to make text readable over busy backgrounds + +### Camera Work (3D Scenes) +```python +# Set initial orientation with euler angles (theta, phi) +self.frame.reorient(20, 70) # theta=20°, phi=70° + +# Animate camera rotation +self.play(self.frame.animate.reorient(160, 65), run_time=6) + +# For 2D scenes that need a slight 3D feel: +self.frame.reorient(0, 0) # Reset to flat +``` + +### Grouping and Layout +```python +# Use VGroup for related objects +formula_parts = VGroup(lhs, equals, rhs) +formula_parts.arrange(RIGHT, buff=0.2) + +# Use arrange for grids +items = VGroup(*[Square() for _ in range(12)]) +items.arrange_in_grid(3, 4, buff=0.5) + +# Position relative to other objects +label.next_to(graph, UP, buff=0.3) +``` + +### Updaters for Dynamic Relationships +```python +# Keep a label attached to a moving point +dot = Dot(color=RED) +label = Text("P") +label.add_updater(lambda m: m.next_to(dot, UR, buff=0.1)) + +# Track a value +tracker = ValueTracker(0) +dot.add_updater( + lambda m: m.move_to(axes.c2p(tracker.get_value(), func(tracker.get_value()))) +) +self.play(tracker.animate.set_value(3), run_time=2) +``` + +### Staggered Animations +```python +# Fade in a group of objects one by one +self.play(LaggedStartMap(FadeIn, objects, lag_ratio=0.15)) + +# Stagger with custom animations +self.play(LaggedStart( + ShowCreation(line1), + ShowCreation(line2), + ShowCreation(line3), + lag_ratio=0.3, +)) +``` + +## Scene Structure Template + +```python +class ConceptScene(Scene): + def construct(self): + # 1. Setup: Create coordinate system or base objects + # 2. Introduce: Show the first concrete example + # 3. Animate: Demonstrate the key transformation/concept + # 4. Label: Add equations or text explaining what happened + # 5. Generalize: Show how this extends (optional) + # 6. Conclude: Clean transition or final state + pass +``` + +## Common Pitfalls +- Don't use `Tex()` if LaTeX is not installed — use `Text()` instead +- Always call `self.play()` or `self.add()` — creating objects alone won't show them +- For 3D scenes, inherit from `ThreeDScene` not `Scene` +- Use `np.array([x, y, 0])` for 2D points (manim uses 3D internally) +- `self.wait()` defaults to 1 second; use `self.wait(0.5)` for quick pauses +""" + +PEDAGOGY_GUIDE = r""" +# Visual Mathematics Pedagogy + +## The 3Blue1Brown Approach + +The goal is to make the viewer *feel* the math, not just see symbols. +Every animation should answer: "What does this look like?" + +## Five Principles + +### 1. Concrete Before Abstract +Start with a specific, tangible example. Then generalize. + +- To teach derivatives: First show a car's position graph, draw a tangent line, + show the slope changing. THEN introduce f'(x) notation. +- To teach eigenvalues: First show a specific matrix stretching a specific vector. + THEN define the general concept. + +### 2. Geometry Before Algebra +Show the shape, the motion, the spatial relationship first. +Equations come after the viewer already has visual intuition. + +- Area of a circle: Show concentric rings unwrapping into a triangle. + The formula pi*r^2 emerges from the picture. +- Determinant: Show how a matrix scales area. The number is the scaling factor. + +### 3. Animation Shows Process +Static images show states. Animation shows *how you get there*. + +- Don't just show a Fourier series approximation — animate each term being added, + watching the approximation improve. +- Don't just show a transformed grid — animate the grid deforming smoothly. + +### 4. One Concept Per Scene +Each scene should have exactly one "aha moment." If you're explaining two things, +split into two scenes. + +### 5. Let the Eye Follow +Guide attention through motion. The viewer's eye naturally follows: +- Moving objects +- Color changes +- Growing/shrinking +- Objects appearing/disappearing + +Use this to direct attention to the important part of the frame. + +## Video Structure Patterns + +### The "What Does It Look Like?" Pattern +1. State the concept verbally (text on screen) +2. Show the simplest possible visual example +3. Animate the key transformation +4. Show how changing parameters changes the visual +5. Connect back to the formal definition + +### The "Build Up" Pattern +1. Start with a simple case (n=1 or 2D) +2. Show it works +3. Add complexity (n=2, or 3D) +4. Show the pattern holds +5. Generalize + +### The "Dual View" Pattern +Show two representations side by side: +- Time domain ↔ Frequency domain +- Position space ↔ Phase space +- Algebraic ↔ Geometric +Animate changes in one and show the effect in the other. + +## Color Conventions for Math Domains + +### Calculus +- Function: BLUE +- Derivative: YELLOW or GREEN +- Integral/area: BLUE with opacity 0.3 +- dx, dt: RED (small quantities) + +### Linear Algebra +- Basis vector i-hat: GREEN +- Basis vector j-hat: RED +- Transformed vectors: YELLOW +- Eigenvalues/vectors: PURPLE + +### Complex Analysis +- Real part: BLUE +- Imaginary part: YELLOW +- Modulus: WHITE +- Argument/angle: RED + +### Differential Geometry +- Surface: BLUE with gradient +- Tangent vectors: YELLOW +- Normal vectors: GREEN +- Curvature: RED +""" diff --git a/mcp_server/topics.py b/mcp_server/topics.py new file mode 100644 index 0000000000..4f85318304 --- /dev/null +++ b/mcp_server/topics.py @@ -0,0 +1,724 @@ +""" +Structured topic templates for mathematical video creation. + +Each template follows 3b1b pedagogy: concrete before abstract, +geometry before algebra, progressive revelation. +""" +from __future__ import annotations + +TOPICS: dict[str, dict] = { + # ===== BASIC ===== + "area_of_circle": { + "title": "Why is the area of a circle πr²?", + "level": "basic", + "category": "geometry", + "description": "Derive the area formula by unwrapping concentric rings into a triangle. Inspired by 3b1b Essence of Calculus chapter 1.", + "concept_arc": [ + "Show a filled circle with radius R", + "Peel off concentric rings and unroll them", + "Each ring becomes a thin rectangle: width = 2πr, height = dr", + "Stack the rectangles — they form a triangle", + "Triangle area = ½ × base × height = ½ × 2πR × R = πR²", + ], + "scenes": [ + { + "name": "CircleAreaDerivation", + "description": "Unwrap concentric rings to derive πr².", + "code": r'''class CircleAreaDerivation(Scene): + def construct(self): + # Show circle + radius = 2.0 + circle = Circle(radius=radius, color=BLUE, fill_opacity=0.5) + r_line = Line(ORIGIN, radius * RIGHT, color=YELLOW) + r_label = Text("R", font_size=36, color=YELLOW) + r_label.next_to(r_line, DOWN, buff=0.1) + + self.play(ShowCreation(circle)) + self.play(ShowCreation(r_line), Write(r_label)) + self.wait() + + # Show concentric rings + n_rings = 15 + rings = VGroup() + colors = color_gradient([BLUE_E, GREEN_C], n_rings) + for i in range(n_rings): + r_inner = radius * i / n_rings + r_outer = radius * (i + 1) / n_rings + ring = Annulus( + inner_radius=r_inner, + outer_radius=r_outer, + fill_color=colors[i], + fill_opacity=0.8, + stroke_color=WHITE, + stroke_width=0.5, + ) + rings.add(ring) + + self.play( + FadeOut(circle), + FadeIn(rings), + run_time=1.5, + ) + self.wait() + + # Unroll rings into rectangles on the right + axes = Axes( + x_range=[0, 2.5 * PI, PI], + y_range=[0, 2.5, 0.5], + width=6, height=3, + ) + axes.to_edge(RIGHT) + + rects = VGroup() + for i in range(n_rings): + r_mid = radius * (i + 0.5) / n_rings + dr = radius / n_rings + width = 2 * PI * r_mid + rect = Rectangle( + width=width * axes.x_axis.get_unit_size(), + height=dr * axes.y_axis.get_unit_size(), + fill_color=colors[i], + fill_opacity=0.8, + stroke_width=0.5, + ) + rect.move_to(axes.c2p(width / 2, dr * i + dr / 2)) + rects.add(rect) + + self.play( + rings.animate.scale(0.4).to_edge(LEFT), + ShowCreation(axes), + run_time=1.5, + ) + self.play( + LaggedStartMap(FadeIn, rects, lag_ratio=0.05), + run_time=2, + ) + self.wait() + + # Show triangle outline and formula + triangle = Polygon( + axes.c2p(0, 0), + axes.c2p(2 * PI * radius, 0), + axes.c2p(0, radius), + stroke_color=YELLOW, + stroke_width=3, + fill_opacity=0, + ) + formula = Text("Area = ½ × 2πR × R = πR²", font_size=32) + formula.next_to(axes, DOWN, buff=0.5) + + self.play(ShowCreation(triangle)) + self.play(Write(formula)) + self.wait(2) +''', + }, + ], + }, + "pythagorean_theorem": { + "title": "A Visual Proof of the Pythagorean Theorem", + "level": "basic", + "category": "geometry", + "description": "Prove a² + b² = c² by rearranging squares built on the sides of a right triangle.", + "concept_arc": [ + "Show a right triangle with sides a, b, c", + "Build squares on each side", + "Animate the rearrangement proof", + "Show the algebraic identity emerging from the geometry", + ], + "scenes": [ + { + "name": "PythagoreanProof", + "description": "Visual rearrangement proof of the Pythagorean theorem.", + "code": r'''class PythagoreanProof(Scene): + def construct(self): + # Right triangle + a, b = 1.5, 2.0 + c = np.sqrt(a**2 + b**2) + triangle = Polygon( + ORIGIN, a * RIGHT, a * RIGHT + b * UP, + stroke_color=WHITE, stroke_width=3, + fill_color=BLUE_E, fill_opacity=0.3, + ) + triangle.move_to(ORIGIN) + + # Labels + a_label = Text("a", font_size=36, color=GREEN) + b_label = Text("b", font_size=36, color=RED) + c_label = Text("c", font_size=36, color=YELLOW) + + verts = triangle.get_vertices() + a_label.next_to(Line(verts[0], verts[1]), DOWN, buff=0.2) + b_label.next_to(Line(verts[1], verts[2]), RIGHT, buff=0.2) + c_label.next_to(Line(verts[0], verts[2]), LEFT, buff=0.2) + + self.play(ShowCreation(triangle)) + self.play(Write(a_label), Write(b_label), Write(c_label)) + self.wait() + + # Squares on each side + sq_a = Square(side_length=a, color=GREEN, fill_opacity=0.4) + sq_b = Square(side_length=b, color=RED, fill_opacity=0.4) + sq_c = Square(side_length=c, color=YELLOW, fill_opacity=0.4) + + sq_a.next_to(Line(verts[0], verts[1]), DOWN, buff=0) + sq_b.next_to(Line(verts[1], verts[2]), RIGHT, buff=0) + sq_c.next_to(Line(verts[0], verts[2]), LEFT, buff=0) + + for sq in [sq_a, sq_b, sq_c]: + self.play(GrowFromCenter(sq), run_time=0.8) + self.wait() + + # Show equation + equation = Text("a² + b² = c²", font_size=48) + equation.to_edge(UP) + self.play(Write(equation)) + self.wait(2) +''', + }, + ], + }, + # ===== INTERMEDIATE ===== + "linear_transformations": { + "title": "Linear Transformations as Matrix Multiplication", + "level": "intermediate", + "category": "linear_algebra", + "description": "Show how a 2x2 matrix transforms the plane by tracking where basis vectors land. Inspired by 3b1b Essence of Linear Algebra chapter 3.", + "concept_arc": [ + "Show the standard grid with basis vectors i-hat and j-hat", + "Apply a matrix transformation — watch the grid deform", + "Highlight that i-hat and j-hat land on the columns of the matrix", + "Show that every vector's destination is determined by the basis", + ], + "scenes": [ + { + "name": "MatrixTransformation", + "description": "Visualize a 2x2 matrix as a grid transformation.", + "code": r'''class MatrixTransformation(Scene): + def construct(self): + # Setup grid + plane = NumberPlane( + x_range=[-4, 4], y_range=[-3, 3], + background_line_style={"stroke_opacity": 0.4}, + ) + plane.add_coordinate_labels(font_size=20) + + # Basis vectors + i_hat = Arrow(ORIGIN, RIGHT, buff=0, color=GREEN, stroke_width=6) + j_hat = Arrow(ORIGIN, UP, buff=0, color=RED, stroke_width=6) + i_label = Text("î", font_size=30, color=GREEN).next_to(i_hat, DOWN, 0.1) + j_label = Text("ĵ", font_size=30, color=RED).next_to(j_hat, LEFT, 0.1) + + self.play(ShowCreation(plane), run_time=1.5) + self.play( + GrowArrow(i_hat), GrowArrow(j_hat), + Write(i_label), Write(j_label), + ) + self.wait() + + # The matrix [[1, 1], [0, 1]] — a shear + matrix = [[1, 1], [0, 1]] + + # Show matrix + matrix_label = Text("[[1, 1], [0, 1]]", font_size=32) + matrix_label.to_corner(UL) + matrix_label.set_backstroke(width=4) + self.play(Write(matrix_label)) + + # Apply transformation + self.play( + plane.animate.apply_matrix(matrix), + i_hat.animate.put_start_and_end_on(ORIGIN, np.array([1, 0, 0])), + j_hat.animate.put_start_and_end_on(ORIGIN, np.array([1, 1, 0])), + i_label.animate.next_to(np.array([1, 0, 0]), DOWN, 0.1), + j_label.animate.next_to(np.array([1, 1, 0]), LEFT, 0.1), + run_time=3, + ) + self.wait() + + # Highlight columns + col_note = Text( + "Columns = where î and ĵ land", + font_size=28, color=YELLOW, + ) + col_note.to_edge(DOWN) + col_note.set_backstroke(width=4) + self.play(Write(col_note)) + self.wait(2) +''', + }, + ], + }, + "complex_multiplication": { + "title": "Complex Multiplication is Rotation and Scaling", + "level": "intermediate", + "category": "complex_analysis", + "description": "Show that multiplying complex numbers rotates and scales. Visualize on the complex plane.", + "concept_arc": [ + "Show the complex plane with a point z", + "Multiply by a complex number w — watch z rotate and scale", + "Show the polar form: |w| scales, arg(w) rotates", + "Euler's formula connects this to e^(iθ)", + ], + "scenes": [ + { + "name": "ComplexMultiplication", + "description": "Visualize complex multiplication as rotation + scaling.", + "code": r'''class ComplexMultiplication(Scene): + def construct(self): + # Complex plane + plane = ComplexPlane( + x_range=[-3, 3], y_range=[-3, 3], + width=6, height=6, + ) + plane.add_coordinate_labels(font_size=20) + self.play(ShowCreation(plane), run_time=1.5) + + # Point z = 1 + 0.5i + z = complex(2, 0.5) + z_dot = Dot(plane.n2p(z), color=YELLOW, radius=0.1) + z_label = Text("z", font_size=30, color=YELLOW) + z_label.next_to(z_dot, UR, buff=0.1) + z_line = Line(plane.n2p(0), plane.n2p(z), color=YELLOW, stroke_width=2) + + self.play(ShowCreation(z_line), FadeIn(z_dot), Write(z_label)) + self.wait() + + # Multiplier w = e^(iπ/4) ≈ rotation by 45° + w = np.exp(1j * PI / 4) + result = z * w + + # Show the rotation + arc = Arc( + start_angle=np.angle(z), + angle=np.angle(w), + radius=0.8, + color=GREEN, + stroke_width=3, + ).move_arc_center_to(plane.n2p(0)) + angle_label = Text("45°", font_size=24, color=GREEN) + angle_label.next_to(arc, RIGHT, buff=0.1) + + result_dot = Dot(plane.n2p(result), color=RED, radius=0.1) + result_line = Line(plane.n2p(0), plane.n2p(result), color=RED, stroke_width=2) + result_label = Text("z·w", font_size=30, color=RED) + result_label.next_to(result_dot, UR, buff=0.1) + + self.play(ShowCreation(arc), Write(angle_label)) + self.play( + ShowCreation(result_line), + FadeIn(result_dot), + Write(result_label), + run_time=1.5, + ) + self.wait() + + # Caption + caption = Text( + "Multiplying by w rotates by arg(w) and scales by |w|", + font_size=28, + ) + caption.to_edge(DOWN) + caption.set_backstroke(width=4) + self.play(Write(caption)) + self.wait(2) +''', + }, + ], + }, + # ===== CALCULUS ===== + "derivative_as_slope": { + "title": "The Derivative as the Slope of a Tangent Line", + "level": "calculus", + "category": "calculus", + "description": "Show the derivative visually: zoom into a curve until it looks like a straight line, and measure its slope.", + "concept_arc": [ + "Plot f(x) = x² and pick a point", + "Draw a secant line between two nearby points", + "Animate the second point approaching the first", + "The secant becomes the tangent — its slope is the derivative", + "Show the slope value changing as we move along the curve", + ], + "scenes": [ + { + "name": "DerivativeAsSlope", + "description": "Animate secant lines converging to the tangent.", + "code": r'''class DerivativeAsSlope(Scene): + def construct(self): + axes = Axes( + x_range=[-1, 4, 1], y_range=[-1, 10, 2], + width=8, height=5, + axis_config={"include_numbers": True}, + ) + axes.to_edge(DOWN, buff=0.5) + graph = axes.get_graph(lambda x: x**2, color=BLUE) + graph_label = Text("f(x) = x²", font_size=30, color=BLUE) + graph_label.to_corner(UR) + + self.play(ShowCreation(axes), ShowCreation(graph), Write(graph_label)) + self.wait() + + # Fixed point + x0 = 1.5 + dot0 = Dot(axes.c2p(x0, x0**2), color=YELLOW, radius=0.08) + self.play(FadeIn(dot0)) + + # Animate secant lines approaching tangent + dx_tracker = ValueTracker(2.0) + secant = always_redraw(lambda: Line( + axes.c2p(x0, x0**2), + axes.c2p( + x0 + dx_tracker.get_value(), + (x0 + dx_tracker.get_value())**2, + ), + color=RED, + stroke_width=3, + ).scale(3, about_point=axes.c2p(x0, x0**2))) + + dot1 = always_redraw(lambda: Dot( + axes.c2p( + x0 + dx_tracker.get_value(), + (x0 + dx_tracker.get_value())**2, + ), + color=RED, radius=0.06, + )) + + slope_label = always_redraw(lambda: Text( + f"slope = {((x0 + dx_tracker.get_value())**2 - x0**2) / dx_tracker.get_value():.2f}", + font_size=28, + ).to_corner(UL)) + + self.play(ShowCreation(secant), FadeIn(dot1), Write(slope_label)) + self.wait(0.5) + + # Shrink dx + self.play(dx_tracker.animate.set_value(0.01), run_time=4) + self.wait() + + # Final label + result = Text("f'(1.5) = 2 × 1.5 = 3.0", font_size=32, color=YELLOW) + result.to_edge(DOWN) + self.play(Write(result)) + self.wait(2) +''', + }, + ], + }, + "fourier_series": { + "title": "Fourier Series: Building Any Function from Sine Waves", + "level": "calculus", + "category": "calculus", + "description": "Show how adding sine waves of different frequencies can approximate any periodic function.", + "concept_arc": [ + "Show a square wave — a function that seems impossible to build from smooth curves", + "Add the first sine harmonic — a rough approximation", + "Add the 3rd, 5th, 7th harmonics one by one", + "Watch the approximation converge to the square wave", + ], + "scenes": [ + { + "name": "FourierApproximation", + "description": "Progressively add harmonics to approximate a square wave.", + "code": r'''class FourierApproximation(Scene): + def construct(self): + axes = Axes( + x_range=[-PI, PI, PI/2], + y_range=[-1.5, 1.5, 0.5], + width=10, height=4, + ) + axes.to_edge(DOWN, buff=1) + + # Target: square wave + square_wave = axes.get_graph( + lambda x: 1 if x > 0 else (-1 if x < 0 else 0), + discontinuities=[0], + color=WHITE, + stroke_width=2, + ) + + title = Text("Fourier Series Approximation", font_size=36) + title.to_edge(UP) + + self.play(ShowCreation(axes), Write(title)) + self.play(ShowCreation(square_wave), run_time=1.5) + self.wait() + + # Build up Fourier approximation + def fourier_partial_sum(n_terms): + def func(x): + result = 0 + for k in range(n_terms): + n = 2 * k + 1 # odd harmonics only + result += (4 / (n * PI)) * np.sin(n * x) + return result + return func + + colors = color_gradient([BLUE, GREEN, YELLOW], 8) + current_graph = None + + for i in range(1, 9): + new_graph = axes.get_graph( + fourier_partial_sum(i), + color=colors[i - 1], + stroke_width=3, + ) + n_label = Text( + f"{i} term{'s' if i > 1 else ''}", + font_size=28, color=colors[i - 1], + ) + n_label.next_to(axes, RIGHT, buff=0.3) + + if current_graph is None: + self.play(ShowCreation(new_graph), Write(n_label), run_time=1.5) + else: + self.play( + Transform(current_graph, new_graph), + Transform(prev_label, n_label), + run_time=1, + ) + + if current_graph is None: + current_graph = new_graph + prev_label = n_label + self.wait(0.3) + + self.wait(2) +''', + }, + ], + }, + # ===== ADVANCED ===== + "curvature_of_surfaces": { + "title": "Gaussian Curvature: How Surfaces Bend", + "level": "advanced", + "category": "differential_geometry", + "description": "Visualize Gaussian curvature by showing how surfaces with positive, zero, and negative curvature differ. Sphere vs cylinder vs saddle.", + "concept_arc": [ + "Show a sphere — positive curvature everywhere", + "Show a cylinder — zero Gaussian curvature (flat in one direction)", + "Show a saddle surface — negative curvature", + "Color-code curvature on a general surface", + ], + "scenes": [ + { + "name": "GaussianCurvature", + "description": "Compare surfaces with positive, zero, and negative Gaussian curvature.", + "code": r'''class GaussianCurvature(ThreeDScene): + def construct(self): + frame = self.frame + frame.reorient(20, 70) + + # Sphere (positive curvature) + sphere = ParametricSurface( + lambda u, v: np.array([ + np.sin(u) * np.cos(v), + np.sin(u) * np.sin(v), + np.cos(u), + ]), + u_range=[0, PI], v_range=[0, TAU], + resolution=(32, 32), + ) + sphere.set_color(BLUE) + sphere.set_opacity(0.7) + sphere.shift(3 * LEFT) + + # Cylinder (zero curvature) + cylinder = ParametricSurface( + lambda u, v: np.array([ + np.cos(v), + np.sin(v), + u, + ]), + u_range=[-1.2, 1.2], v_range=[0, TAU], + resolution=(16, 32), + ) + cylinder.set_color(GREEN) + cylinder.set_opacity(0.7) + + # Saddle (negative curvature) + saddle = ParametricSurface( + lambda u, v: np.array([u, v, u**2 - v**2]), + u_range=[-1, 1], v_range=[-1, 1], + resolution=(32, 32), + ) + saddle.set_color(RED) + saddle.set_opacity(0.7) + saddle.shift(3 * RIGHT) + + # Labels (fixed in frame) + labels = VGroup( + Text("K > 0", font_size=28, color=BLUE), + Text("K = 0", font_size=28, color=GREEN), + Text("K < 0", font_size=28, color=RED), + ) + labels.arrange(RIGHT, buff=2.5) + labels.to_edge(UP) + for label in labels: + label.fix_in_frame() + + # Animate + self.play( + ShowCreation(sphere), + Write(labels[0]), + run_time=2, + ) + self.play( + ShowCreation(cylinder), + Write(labels[1]), + run_time=2, + ) + self.play( + ShowCreation(saddle), + Write(labels[2]), + run_time=2, + ) + self.wait() + + # Rotate camera + self.play( + frame.animate.reorient(160, 60), + run_time=6, + rate_func=smooth, + ) + self.wait(2) +''', + }, + ], + }, + "stereographic_projection": { + "title": "Stereographic Projection: Mapping a Sphere to a Plane", + "level": "advanced", + "category": "differential_geometry", + "description": "Visualize how stereographic projection maps circles on a sphere to circles on a plane, preserving angles. Inspired by 3b1b's hairy ball and holomorphic dynamics videos.", + "concept_arc": [ + "Show a sphere sitting on a plane", + "Draw a point on the sphere and project it from the north pole to the plane", + "Show circles on the sphere mapping to circles on the plane", + "Demonstrate angle preservation (conformality)", + ], + "scenes": [ + { + "name": "StereographicProjection", + "description": "Animate stereographic projection from sphere to plane.", + "code": r'''class StereographicProjection(ThreeDScene): + def construct(self): + frame = self.frame + frame.reorient(30, 65) + + # Sphere + sphere = ParametricSurface( + lambda u, v: np.array([ + np.sin(u) * np.cos(v), + np.sin(u) * np.sin(v), + np.cos(u), + ]), + u_range=[0.05, PI], v_range=[0, TAU], + resolution=(32, 48), + ) + sphere.set_color(BLUE_D) + sphere.set_opacity(0.4) + + # North pole + north_pole = Dot3D(np.array([0, 0, 1]), color=RED, radius=0.06) + + # Plane at z = 0 + plane = ParametricSurface( + lambda u, v: np.array([u, v, 0]), + u_range=[-3, 3], v_range=[-3, 3], + resolution=(1, 1), + ) + plane.set_color(GREY) + plane.set_opacity(0.15) + + self.play(ShowCreation(sphere), FadeIn(north_pole), FadeIn(plane)) + self.wait() + + # Project latitude circles + for phi in [PI/6, PI/3, PI/2, 2*PI/3]: + # Circle on sphere + theta_range = np.linspace(0, TAU, 100) + sphere_points = np.array([ + [np.sin(phi)*np.cos(t), np.sin(phi)*np.sin(t), np.cos(phi)] + for t in theta_range + ]) + sphere_circle = VMobject() + sphere_circle.set_points_smoothly([*sphere_points, sphere_points[0]]) + sphere_circle.set_stroke(YELLOW, 3) + + # Projected circle on plane + z_vals = sphere_points[:, 2] + denom = 1 - z_vals + denom[denom == 0] = 1e-10 + proj_points = np.column_stack([ + sphere_points[:, 0] / denom, + sphere_points[:, 1] / denom, + np.zeros(len(denom)), + ]) + plane_circle = VMobject() + plane_circle.set_points_smoothly([*proj_points, proj_points[0]]) + plane_circle.set_stroke(GREEN, 3) + + # Projection lines (a few sample lines) + lines = VGroup() + for i in range(0, len(sphere_points), 25): + line = Line( + np.array([0, 0, 1]), + proj_points[i], + stroke_color=WHITE, + stroke_width=1, + stroke_opacity=0.3, + ) + lines.add(line) + + self.play( + ShowCreation(sphere_circle), + ShowCreation(lines), + run_time=1, + ) + self.play(ShowCreation(plane_circle), run_time=1) + self.wait(0.3) + + # Rotate to appreciate + self.play(frame.animate.reorient(180, 50), run_time=5) + self.wait(2) +''', + }, + ], + }, +} + + +# --- Lookup functions --- + +LEVELS = ["basic", "intermediate", "calculus", "advanced"] +CATEGORIES = sorted(set(t["category"] for t in TOPICS.values())) + + +def list_topics(category: str | None = None, level: str | None = None) -> list[dict]: + """List available topic templates, optionally filtered.""" + results = [] + for key, topic in TOPICS.items(): + if category and topic["category"] != category: + continue + if level and topic["level"] != level: + continue + results.append({ + "id": key, + "title": topic["title"], + "level": topic["level"], + "category": topic["category"], + "description": topic["description"], + "n_scenes": len(topic["scenes"]), + }) + return results + + +def get_topic_template(topic_id: str) -> dict: + """Get the full template for a topic, including all scene code.""" + if topic_id in TOPICS: + return TOPICS[topic_id] + return { + "error": f"Unknown topic '{topic_id}'.", + "available_topics": [ + {"id": k, "title": v["title"]} for k, v in TOPICS.items() + ], + } diff --git a/mcp_server/validation.py b/mcp_server/validation.py new file mode 100644 index 0000000000..573c1148f5 --- /dev/null +++ b/mcp_server/validation.py @@ -0,0 +1,52 @@ +""" +Scene code validation without rendering. + +Catches syntax errors and basic import/name errors by compiling +and executing the code in a namespace with manimlib pre-imported. +""" +from __future__ import annotations + +import traceback + + +def validate_scene_code(code: str) -> dict: + """ + Validate scene code without rendering. + + Returns {"valid": True} or {"valid": False, "error": "...", "line": N}. + """ + full_code = "from manimlib import *\n\n" + code + + # Phase 1: Syntax check via compile() + try: + compiled = compile(full_code, "", "exec") + except SyntaxError as e: + # Adjust line number to account for the import we prepended + line = (e.lineno or 0) - 2 + return { + "valid": False, + "error": f"SyntaxError: {e.msg}", + "line": max(line, 1), + } + + # Phase 2: Execute to catch NameError, ImportError, etc. + # This defines the classes but does NOT call construct() or run(). + namespace: dict = {} + try: + exec(compiled, namespace) + except Exception as e: + # Try to extract line number from traceback + tb = traceback.extract_tb(e.__traceback__) + line = None + for frame in reversed(tb): + if frame.filename == "": + line = max((frame.lineno or 0) - 2, 1) + break + + return { + "valid": False, + "error": f"{type(e).__name__}: {e}", + "line": line, + } + + return {"valid": True} diff --git a/mcp_server/video_planner.py b/mcp_server/video_planner.py new file mode 100644 index 0000000000..9029c5070e --- /dev/null +++ b/mcp_server/video_planner.py @@ -0,0 +1,260 @@ +""" +Video planning engine. + +Takes a math topic description and assembles relevant context +from templates, helpers, and style guides to help an LLM +create a multi-scene math video. +""" +from __future__ import annotations + +from mcp_server.topics import TOPICS +from mcp_server.math_helpers import HELPERS +from mcp_server.style_guide import STYLE_GUIDE, PEDAGOGY_GUIDE +from mcp_server.examples import EXAMPLES + + +# Map keywords to topic IDs for matching +_TOPIC_KEYWORDS: dict[str, list[str]] = { + "area_of_circle": [ + "circle", "area", "pi r squared", "πr²", "concentric", + "rings", "circumference", + ], + "pythagorean_theorem": [ + "pythagorean", "pythagoras", "a² + b²", "right triangle", + "hypotenuse", + ], + "linear_transformations": [ + "linear transformation", "matrix", "basis vector", + "shear", "rotation matrix", "grid", "i-hat", "j-hat", + "determinant", "eigenvalue", "eigenvector", "eigen", + ], + "complex_multiplication": [ + "complex number", "complex multiplication", "rotation", + "euler", "polar form", "argand", "imaginary", + ], + "derivative_as_slope": [ + "derivative", "slope", "tangent", "secant", "limit", + "differentiation", "rate of change", "dx", + ], + "fourier_series": [ + "fourier", "sine wave", "harmonic", "frequency", + "square wave", "periodic", "spectrum", + ], + "curvature_of_surfaces": [ + "curvature", "gaussian curvature", "sphere", "saddle", + "cylinder", "surface", "bend", "principal curvature", + ], + "stereographic_projection": [ + "stereographic", "projection", "sphere to plane", + "conformal", "map", "riemann sphere", + ], +} + +# Map keywords to helper domains +_DOMAIN_KEYWORDS: dict[str, list[str]] = { + "calculus": [ + "derivative", "integral", "limit", "tangent", "area under", + "riemann", "taylor", "series", "convergence", "dx", "dy", + "rate of change", "fundamental theorem", "antiderivative", + "calculus", + ], + "linear_algebra": [ + "matrix", "vector", "eigenvalue", "eigenvector", "eigen", "basis", + "linear transformation", "determinant", "span", "null space", + "rank", "linear algebra", "dot product", "cross product", + ], + "complex_analysis": [ + "complex", "imaginary", "euler", "conformal", "analytic", + "holomorphic", "residue", "contour", "möbius", "mobius", + "complex plane", "argand", + ], + "vector_calculus": [ + "vector field", "divergence", "curl", "gradient", "flux", + "line integral", "surface integral", "stokes", "green", + "gauss", "conservative", + ], + "differential_geometry": [ + "curvature", "geodesic", "manifold", "surface", "tangent plane", + "normal", "gauss map", "first fundamental form", "torus", + "möbius strip", "klein bottle", "differential geometry", + "stereographic", "topology", + ], + "probability": [ + "probability", "distribution", "gaussian", "normal", + "binomial", "random walk", "expected value", "variance", + "bayes", "central limit", "histogram", "dice", "coin", + ], +} + +# Map keywords to example IDs +_EXAMPLE_KEYWORDS: dict[str, list[str]] = { + "value_tracker_graph": [ + "tracker", "sliding", "dynamic label", "moving dot", + "derivative", "slope", "tangent", "curve", "function plot", + ], + "grid_transformation": [ + "grid", "linear transformation", "deform", "matrix", + "basis", "shear", "rotation", "eigen", "stretch", + ], + "vector_field_2d": [ + "vector field", "arrows", "flow", "divergence", "curl", + "gradient", "force field", + ], + "color_gradient_surface": [ + "3d surface", "height color", "gradient", "bell curve", + "gaussian", "scalar field", "heat map", + ], + "progressive_equation": [ + "equation", "step by step", "highlight", "formula", + "build up", "derivation", + ], + "parametric_curve": [ + "parametric", "lissajous", "curve drawing", "polar", + "cycloid", "spiral", + ], + "side_by_side": [ + "dual view", "side by side", "frequency", "time domain", + "fourier", "comparison", "two representations", + ], + "camera_orbit_3d": [ + "orbit", "camera rotation", "3d", "torus", "sphere", + "surface", "möbius", "mobius", "manifold", "topology", + ], + "staggered_animation": [ + "staggered", "lagged", "sequential", "grid", "many objects", + "array", "collection", + ], + "updater_chain": [ + "updater", "tracking", "connected", "orbit", "circle", + "angle", "dynamic", "linked", + ], + "basic_shapes": ["circle", "square", "triangle", "shapes", "geometry"], + "transform": ["transform", "morph", "interpolate", "transition"], + "graph": ["graph", "plot", "function", "axes", "sine", "cosine"], + "3d": ["3d surface", "parametric surface", "3d plot"], + "number_line": ["number line", "real line", "interval"], + "complex_plane": ["complex plane", "z squared", "complex function"], + "text_animation": ["text", "write", "fade", "title", "label"], +} + + +def _score_match(query: str, keywords: list[str]) -> int: + """Count how many keywords appear in the query.""" + query_lower = query.lower() + return sum(1 for kw in keywords if kw in query_lower) + + +def _find_best_matches(query: str, keyword_map: dict[str, list[str]], top_n: int = 3) -> list[str]: + """Return the top_n best-matching keys from a keyword map.""" + scored = [ + (key, _score_match(query, keywords)) + for key, keywords in keyword_map.items() + ] + scored.sort(key=lambda x: x[1], reverse=True) + return [key for key, score in scored[:top_n] if score > 0] + + +def create_video_plan( + topic: str, + level: str = "intermediate", + duration_hint: str = "medium", +) -> dict: + """ + Create a structured video plan for a math topic. + + Assembles relevant templates, helpers, examples, and style + guidance based on the topic description. + + Args: + topic: Natural language description of the math concept. + level: Target difficulty — basic, intermediate, calculus, advanced. + duration_hint: short (~10s), medium (~30s), long (~60s+). + + Returns: + A structured plan with matched templates, helpers, examples, + and pedagogical guidance. + """ + plan: dict = { + "topic": topic, + "level": level, + "duration_hint": duration_hint, + } + + # 1. Match topic templates + matched_topics = _find_best_matches(topic, _TOPIC_KEYWORDS, top_n=2) + if matched_topics: + plan["matched_templates"] = [] + for tid in matched_topics: + t = TOPICS[tid] + plan["matched_templates"].append({ + "id": tid, + "title": t["title"], + "concept_arc": t["concept_arc"], + "scenes": t["scenes"], + }) + else: + plan["matched_templates"] = [] + plan["template_note"] = ( + "No exact template match. Use the concept_arc pattern from " + "similar topics and adapt. The style guide and pedagogy " + "resources describe how to structure a scene." + ) + + # 2. Match helper domains + matched_domains = _find_best_matches(topic, _DOMAIN_KEYWORDS, top_n=2) + if matched_domains: + plan["math_helpers"] = { + domain: HELPERS[domain] + for domain in matched_domains + } + + # 3. Match relevant examples (by technique) + matched_examples = _find_best_matches(topic, _EXAMPLE_KEYWORDS, top_n=3) + if matched_examples: + plan["relevant_examples"] = { + eid: { + "description": EXAMPLES[eid]["description"], + "code": EXAMPLES[eid]["code"], + } + for eid in matched_examples + } + + # 4. Duration guidance + duration_map = { + "short": { + "target_seconds": 10, + "n_scenes": 1, + "guidance": "Single scene, 2-3 play() calls. Show one key idea.", + }, + "medium": { + "target_seconds": 30, + "n_scenes": "1-2", + "guidance": "One or two scenes. Introduce, demonstrate, conclude.", + }, + "long": { + "target_seconds": 60, + "n_scenes": "2-4", + "guidance": "Multiple scenes building on each other. Follow the concept arc pattern.", + }, + } + plan["duration_guidance"] = duration_map.get(duration_hint, duration_map["medium"]) + + # 5. Level-specific notes + level_notes = { + "basic": "Use simple shapes and concrete numbers. Avoid abstract notation. Animate slowly with generous wait() calls.", + "intermediate": "Can use coordinate systems, functions, and basic notation. Show the visual first, then the formula.", + "calculus": "Use graphs, tangent lines, areas. Show limiting processes with ValueTracker animations.", + "advanced": "Use 3D surfaces, vector fields, parametric objects. Camera orbits help reveal 3D structure. Fix equations in frame.", + } + plan["level_notes"] = level_notes.get(level, level_notes["intermediate"]) + + # 6. Always include pedagogy reminder + plan["pedagogy_principles"] = [ + "Concrete before abstract — start with a specific example", + "Geometry before algebra — show the shape, then the equation", + "Animation shows process — don't just display, transform", + "One concept per scene — split complex ideas", + "Progressive revelation — build complexity one layer at a time", + ] + + return plan diff --git a/pyproject.toml b/pyproject.toml index c9538153fd..2112d91ed6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,79 @@ +[project] +name = "manimgl" +version = "1.7.2" +description = "Animation engine for explanatory math videos" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [ + { name = "Grant Sanderson", email = "grant@3blue1brown.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Topic :: Scientific/Engineering", + "Topic :: Multimedia :: Video", + "Topic :: Multimedia :: Graphics", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", + "Natural Language :: English", +] +dependencies = [ + "addict", + "appdirs", + "audioop-lts; python_version >= '3.13'", + "colour", + "diskcache", + "ipython>=8.18.0", + "isosurfaces", + "fontTools", + "manimpango>=0.6.0", + "mapbox-earcut", + "matplotlib", + "moderngl", + "moderngl-window", + "numpy", + "Pillow", + "pydub", + "pygments", + "PyOpenGL", + "pyperclip", + "pyyaml", + "rich", + "scipy", + "screeninfo", + "skia-pathops", + "svgelements>=1.8.1", + "sympy", + "trimesh", + "tqdm", + "validators", + "pywavefront", +] + +[project.optional-dependencies] +mcp = ["fastmcp>=2.0"] + +[project.urls] +Homepage = "https://github.com/3b1b/manim" +Documentation = "https://3b1b.github.io/manim/" +"Bug Tracker" = "https://github.com/3b1b/manim/issues" +"Source Code" = "https://github.com/3b1b/manim" + +[project.scripts] +manimgl = "manimlib.__main__:main" +manim-render = "manimlib.__main__:main" +manimgl-mcp = "mcp_server.server:main" + [build-system] -requires = ["setuptools", "wheel"] \ No newline at end of file +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["manimlib", "mcp_server"] + +[tool.hatch.build.targets.sdist] +include = ["manimlib/**", "mcp_server/**", "README.md", "LICENSE.md"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5adbee3cec..0000000000 --- a/setup.cfg +++ /dev/null @@ -1,66 +0,0 @@ -[metadata] -name = manimgl -version = 1.7.2 -author = Grant Sanderson -author_email= grant@3blue1brown.com -description = Animation engine for explanatory math videos -long_description = file: README.md -long_description_content_type = text/markdown; charset=UTF-8 -home_page = https://github.com/3b1b/manim -project_urls = - Bug Tracker = https://github.com/3b1b/manim/issues - Documentation = https://3b1b.github.io/manim/ - Source Code = https://github.com/3b1b/manim -license = MIT -classifiers = - Development Status :: 4 - Beta - License :: OSI Approved :: MIT License - Topic :: Scientific/Engineering - Topic :: Multimedia :: Video - Topic :: Multimedia :: Graphics - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3 :: Only - Natural Language :: English - -[options] -packages = find: -include_package_data = True -install_requires = - addict - appdirs - audioop-lts; python_version >= "3.13" - colour - diskcache - ipython>=8.18.0 - isosurfaces - fontTools - manimpango>=0.6.0 - mapbox-earcut - matplotlib - moderngl - moderngl_window - numpy - Pillow - pydub - pygments - PyOpenGL - pyperclip - pyyaml - rich - scipy - screeninfo - setuptools - skia-pathops - svgelements>=1.8.1 - sympy - tqdm - typing-extensions; python_version < "3.11" - validators - -[options.entry_points] -console_scripts = - manimgl = manimlib.__main__:main - manim-render = manimlib.__main__:main diff --git a/setup.py b/setup.py deleted file mode 100644 index 864b617c60..0000000000 --- a/setup.py +++ /dev/null @@ -1,2 +0,0 @@ -import setuptools -setuptools.setup() \ No newline at end of file