manim-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -0
- package/dist/demo.mp4 +0 -0
- package/dist/index.js +65 -0
- package/dist/mcp-app.html +142 -0
- package/dist/server.js +1492 -0
- package/package.json +67 -0
- package/references/composer/SKILL.md +154 -0
- package/references/composer/references/3b1b-series-patterns.md +217 -0
- package/references/composer/references/domain-planning-guides/calculus-planning.md +188 -0
- package/references/composer/references/domain-planning-guides/linear-algebra-planning.md +169 -0
- package/references/composer/references/domain-planning-guides/ml-planning.md +286 -0
- package/references/composer/references/domain-planning-guides/number-theory-planning.md +187 -0
- package/references/composer/references/domain-planning-guides/physics-planning.md +249 -0
- package/references/composer/references/domain-planning-guides/probability-planning.md +200 -0
- package/references/composer/references/mathematical-storytelling.md +359 -0
- package/references/composer/references/narrative-patterns.md +221 -0
- package/references/composer/references/opening-patterns.md +284 -0
- package/references/composer/references/pacing-guide.md +289 -0
- package/references/composer/references/scene-archetypes.md +534 -0
- package/references/composer/references/scene-examples.md +379 -0
- package/references/composer/references/visual-techniques.md +480 -0
- package/references/composer/templates/scenes-template.md +147 -0
- package/references/manimce/SKILL.md +166 -0
- package/references/manimce/examples/3d_visualization.py +373 -0
- package/references/manimce/examples/basic_animations.py +212 -0
- package/references/manimce/examples/graph_plotting.py +401 -0
- package/references/manimce/examples/lorenz_attractor.py +172 -0
- package/references/manimce/examples/math_visualization.py +315 -0
- package/references/manimce/examples/updater_patterns.py +369 -0
- package/references/manimce/rules/3b1b-translation.md +594 -0
- package/references/manimce/rules/3d.md +254 -0
- package/references/manimce/rules/advanced-animations.md +594 -0
- package/references/manimce/rules/animation-groups.md +212 -0
- package/references/manimce/rules/animations.md +128 -0
- package/references/manimce/rules/api-pitfalls.md +89 -0
- package/references/manimce/rules/axes.md +214 -0
- package/references/manimce/rules/camera.md +208 -0
- package/references/manimce/rules/cli.md +232 -0
- package/references/manimce/rules/color-conventions.md +444 -0
- package/references/manimce/rules/colors.md +199 -0
- package/references/manimce/rules/config.md +264 -0
- package/references/manimce/rules/creation-animations.md +158 -0
- package/references/manimce/rules/graphing.md +233 -0
- package/references/manimce/rules/grouping.md +220 -0
- package/references/manimce/rules/latex.md +202 -0
- package/references/manimce/rules/lines.md +241 -0
- package/references/manimce/rules/long-form-video.md +552 -0
- package/references/manimce/rules/mathematical-domains.md +689 -0
- package/references/manimce/rules/mobjects.md +116 -0
- package/references/manimce/rules/multi-scene-composition.md +112 -0
- package/references/manimce/rules/pedagogy.md +532 -0
- package/references/manimce/rules/physics-simulations.md +610 -0
- package/references/manimce/rules/positioning.md +211 -0
- package/references/manimce/rules/scenes.md +121 -0
- package/references/manimce/rules/shapes.md +300 -0
- package/references/manimce/rules/styling.md +177 -0
- package/references/manimce/rules/text-animations.md +222 -0
- package/references/manimce/rules/text.md +189 -0
- package/references/manimce/rules/timing.md +227 -0
- package/references/manimce/rules/transform-animations.md +157 -0
- package/references/manimce/rules/updaters.md +226 -0
- package/references/manimce/templates/basic_scene.py +64 -0
- package/references/manimce/templates/camera_scene.py +100 -0
- package/references/manimce/templates/threed_scene.py +138 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1492 @@
|
|
|
1
|
+
// server.ts
|
|
2
|
+
import {
|
|
3
|
+
registerAppResource,
|
|
4
|
+
registerAppTool,
|
|
5
|
+
RESOURCE_MIME_TYPE
|
|
6
|
+
} from "@modelcontextprotocol/ext-apps/server";
|
|
7
|
+
import {
|
|
8
|
+
McpServer,
|
|
9
|
+
ResourceTemplate
|
|
10
|
+
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { execSync, execFile, spawn } from "node:child_process";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import readline from "node:readline";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
var DIST_DIR = import.meta.filename.endsWith(".ts") ? path.join(import.meta.dirname, "dist") : import.meta.dirname;
|
|
19
|
+
var REFERENCES_DIR = import.meta.filename.endsWith(".ts") ? path.join(import.meta.dirname, "references") : path.join(import.meta.dirname, "..", "references");
|
|
20
|
+
var VIEW_URI = "ui://manim-player/mcp-app.html";
|
|
21
|
+
var OUTPUT_DIR = path.join(os.tmpdir(), "manim_mcp_output");
|
|
22
|
+
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
23
|
+
var TTS_CACHE_DIR = path.join(os.homedir(), ".manim-mcp", "tts_cache");
|
|
24
|
+
fs.mkdirSync(TTS_CACHE_DIR, { recursive: true });
|
|
25
|
+
var SCENE_CACHE_DIR = path.join(os.homedir(), ".manim-mcp", "scene_cache");
|
|
26
|
+
fs.mkdirSync(SCENE_CACHE_DIR, { recursive: true });
|
|
27
|
+
var MAX_PARALLEL_RENDERS = 2;
|
|
28
|
+
var RENDER_WORKER_PY = `
|
|
29
|
+
import sys, os, json, traceback, importlib, importlib.util
|
|
30
|
+
|
|
31
|
+
# Save real stdout for JSON protocol, redirect stdout to stderr so manim logging doesn't corrupt it
|
|
32
|
+
_real_stdout = os.fdopen(os.dup(1), "w")
|
|
33
|
+
os.dup2(2, 1) # stdout -> stderr
|
|
34
|
+
|
|
35
|
+
# Pre-import manim once \u2014 this is the expensive part (~700ms)
|
|
36
|
+
from manim import *
|
|
37
|
+
from manim.scene.scene import Scene
|
|
38
|
+
from manim._config import config as manim_config
|
|
39
|
+
|
|
40
|
+
def _send(msg):
|
|
41
|
+
_real_stdout.write(json.dumps(msg) + "\\n")
|
|
42
|
+
_real_stdout.flush()
|
|
43
|
+
|
|
44
|
+
def render_scene(scene_file, class_name, media_dir, quality_flag):
|
|
45
|
+
quality_map = {
|
|
46
|
+
"-ql": "low_quality",
|
|
47
|
+
"-qm": "medium_quality",
|
|
48
|
+
"-qh": "high_quality",
|
|
49
|
+
"-qp": "production_quality",
|
|
50
|
+
}
|
|
51
|
+
# Reset config state that may be mutated by previous renders
|
|
52
|
+
manim_config.quality = quality_map.get(quality_flag, "low_quality")
|
|
53
|
+
manim_config.media_dir = media_dir
|
|
54
|
+
manim_config.write_to_movie = True
|
|
55
|
+
manim_config.save_last_frame = False
|
|
56
|
+
manim_config.disable_caching = True
|
|
57
|
+
manim_config.input_file = scene_file
|
|
58
|
+
|
|
59
|
+
spec = importlib.util.spec_from_file_location("scene_module", scene_file)
|
|
60
|
+
mod = importlib.util.module_from_spec(spec)
|
|
61
|
+
spec.loader.exec_module(mod)
|
|
62
|
+
|
|
63
|
+
scene_cls = getattr(mod, class_name)
|
|
64
|
+
scene = scene_cls()
|
|
65
|
+
scene.render()
|
|
66
|
+
|
|
67
|
+
# Find the final combined mp4, not partial movie clips
|
|
68
|
+
candidates = []
|
|
69
|
+
for root, dirs, files in os.walk(media_dir):
|
|
70
|
+
for f in sorted(files):
|
|
71
|
+
if f.endswith(".mp4"):
|
|
72
|
+
full = os.path.join(root, f)
|
|
73
|
+
is_partial = "partial_movie_files" in full
|
|
74
|
+
matches_class = class_name in f
|
|
75
|
+
size = os.path.getsize(full)
|
|
76
|
+
candidates.append((not is_partial, matches_class, size, full))
|
|
77
|
+
if candidates:
|
|
78
|
+
candidates.sort(reverse=True) # non-partial + class match + largest first
|
|
79
|
+
return candidates[0][3]
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
_send({"ready": True})
|
|
83
|
+
|
|
84
|
+
while True:
|
|
85
|
+
line = sys.stdin.readline()
|
|
86
|
+
if not line:
|
|
87
|
+
break
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
cmd = json.loads(line)
|
|
93
|
+
video_path = render_scene(
|
|
94
|
+
cmd["scene_file"],
|
|
95
|
+
cmd["class_name"],
|
|
96
|
+
cmd["media_dir"],
|
|
97
|
+
cmd["quality_flag"],
|
|
98
|
+
)
|
|
99
|
+
if video_path:
|
|
100
|
+
_send({"success": True, "videoPath": video_path})
|
|
101
|
+
else:
|
|
102
|
+
_send({"success": False, "error": "Render succeeded but no .mp4 found"})
|
|
103
|
+
except Exception as e:
|
|
104
|
+
tb = traceback.format_exc()
|
|
105
|
+
_send({"success": False, "error": tb[-2000:]})
|
|
106
|
+
`;
|
|
107
|
+
var renderWorkers = [];
|
|
108
|
+
function spawnRenderWorker() {
|
|
109
|
+
const pythonBin = path.join(MANAGED_VENV, "bin", "python");
|
|
110
|
+
const servicesDir = path.join(MANAGED_VENV_DIR, "services");
|
|
111
|
+
const espeakLib = process.platform === "darwin" ? "/opt/homebrew/lib/libespeak-ng.dylib" : "/usr/lib/x86_64-linux-gnu/libespeak-ng.so.1";
|
|
112
|
+
const envPath = [TINYTEX_BIN, "/Library/TeX/texbin", process.env.PATH].filter(Boolean).join(":");
|
|
113
|
+
const proc = spawn(pythonBin, ["-u", "-c", RENDER_WORKER_PY], {
|
|
114
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
115
|
+
env: {
|
|
116
|
+
...process.env,
|
|
117
|
+
PATH: envPath,
|
|
118
|
+
PYTHONPATH: [servicesDir, process.env.PYTHONPATH].filter(Boolean).join(":"),
|
|
119
|
+
PHONEMIZER_ESPEAK_LIBRARY: espeakLib
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const rl = readline.createInterface({ input: proc.stdout });
|
|
123
|
+
const worker = { process: proc, rl, busy: false, ready: false, pendingReady: null };
|
|
124
|
+
proc.stderr?.on("data", (_chunk) => {
|
|
125
|
+
});
|
|
126
|
+
proc.on("exit", () => {
|
|
127
|
+
worker.ready = false;
|
|
128
|
+
renderWorkers = renderWorkers.filter((w) => w !== worker);
|
|
129
|
+
});
|
|
130
|
+
return worker;
|
|
131
|
+
}
|
|
132
|
+
function waitForWorkerReady(worker) {
|
|
133
|
+
if (worker.ready) return Promise.resolve();
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
worker.pendingReady = resolve;
|
|
136
|
+
const onLine = (line) => {
|
|
137
|
+
try {
|
|
138
|
+
const msg = JSON.parse(line);
|
|
139
|
+
if (msg.ready) {
|
|
140
|
+
worker.ready = true;
|
|
141
|
+
worker.rl.removeListener("line", onLine);
|
|
142
|
+
if (worker.pendingReady) {
|
|
143
|
+
worker.pendingReady();
|
|
144
|
+
worker.pendingReady = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
worker.rl.on("line", onLine);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async function getAvailableWorker() {
|
|
154
|
+
let worker = renderWorkers.find((w) => w.ready && !w.busy);
|
|
155
|
+
if (worker) return worker;
|
|
156
|
+
if (renderWorkers.length < MAX_PARALLEL_RENDERS) {
|
|
157
|
+
worker = spawnRenderWorker();
|
|
158
|
+
renderWorkers.push(worker);
|
|
159
|
+
await waitForWorkerReady(worker);
|
|
160
|
+
return worker;
|
|
161
|
+
}
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
const interval = setInterval(() => {
|
|
164
|
+
const free = renderWorkers.find((w) => w.ready && !w.busy);
|
|
165
|
+
if (free) {
|
|
166
|
+
clearInterval(interval);
|
|
167
|
+
resolve(free);
|
|
168
|
+
}
|
|
169
|
+
}, 50);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function preWarmWorkers() {
|
|
173
|
+
const needed = MAX_PARALLEL_RENDERS - renderWorkers.length;
|
|
174
|
+
for (let i = 0; i < needed; i++) {
|
|
175
|
+
const worker = spawnRenderWorker();
|
|
176
|
+
renderWorkers.push(worker);
|
|
177
|
+
waitForWorkerReady(worker).catch(() => {
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function renderViaWorker(worker, cmd) {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
worker.busy = true;
|
|
184
|
+
const timeout = setTimeout(() => {
|
|
185
|
+
worker.busy = false;
|
|
186
|
+
resolve({ success: false, error: "Worker render timed out (300s)" });
|
|
187
|
+
}, 3e5);
|
|
188
|
+
const onLine = (line) => {
|
|
189
|
+
try {
|
|
190
|
+
const msg = JSON.parse(line);
|
|
191
|
+
if ("success" in msg) {
|
|
192
|
+
clearTimeout(timeout);
|
|
193
|
+
worker.rl.removeListener("line", onLine);
|
|
194
|
+
worker.busy = false;
|
|
195
|
+
resolve(msg);
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
worker.rl.on("line", onLine);
|
|
201
|
+
worker.process.stdin.write(JSON.stringify(cmd) + "\n");
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
var MANAGED_VENV_DIR = path.join(os.homedir(), ".manim-mcp");
|
|
205
|
+
var MANAGED_VENV = path.join(MANAGED_VENV_DIR, ".venv");
|
|
206
|
+
var BASE_DEPS = ["manim", "manim-voiceover", "kokoro-onnx", "soundfile", "'setuptools<75'"];
|
|
207
|
+
var MLX_DEPS = ["mlx-audio[kokoro]", "misaki", "num2words", "spacy"];
|
|
208
|
+
var PIPER_DEPS = ["piper-tts"];
|
|
209
|
+
var ELEVENLABS_DEPS = ["elevenlabs"];
|
|
210
|
+
var KOKORO_MODEL_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.fp16.onnx";
|
|
211
|
+
var KOKORO_VOICES_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin";
|
|
212
|
+
var KOKORO_SERVICE_PY = `from manim_voiceover.services.base import SpeechService
|
|
213
|
+
import numpy as np
|
|
214
|
+
import soundfile as sf
|
|
215
|
+
from pathlib import Path
|
|
216
|
+
from pydub import AudioSegment
|
|
217
|
+
import tempfile, platform, sys
|
|
218
|
+
|
|
219
|
+
KOKORO_VOICES = {
|
|
220
|
+
"en-us": "af_heart", "en-gb": "bf_emma", "ja": "jf_alpha",
|
|
221
|
+
"cmn": "zf_xiaobei", "es": "ef_dora", "fr": "ff_siwis", "fr-fr": "ff_siwis",
|
|
222
|
+
"hi": "hf_alpha", "it": "if_sara", "pt-br": "pf_dora",
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# Map manim-voiceover lang codes to mlx-audio lang_code (single char)
|
|
226
|
+
MLX_LANG_CODES = {
|
|
227
|
+
"en-us": "a", "en-gb": "b", "ja": "j", "cmn": "z",
|
|
228
|
+
"es": "e", "fr": "f", "fr-fr": "f", "hi": "h", "it": "i", "pt-br": "p",
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
def _use_mlx():
|
|
232
|
+
"""Use MLX backend on Apple Silicon Macs."""
|
|
233
|
+
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
|
234
|
+
|
|
235
|
+
# Lazy-loaded singleton for MLX model (heavy to init, reuse across calls)
|
|
236
|
+
_mlx_model = None
|
|
237
|
+
|
|
238
|
+
def _get_mlx_model():
|
|
239
|
+
global _mlx_model
|
|
240
|
+
if _mlx_model is None:
|
|
241
|
+
from mlx_audio.tts import load
|
|
242
|
+
_mlx_model = load("mlx-community/Kokoro-82M-bf16")
|
|
243
|
+
return _mlx_model
|
|
244
|
+
|
|
245
|
+
class KokoroService(SpeechService):
|
|
246
|
+
def __init__(self, lang="en-us", cache_dir=None, **kwargs):
|
|
247
|
+
self.lang = lang
|
|
248
|
+
self.voice = KOKORO_VOICES.get(lang, "af_heart")
|
|
249
|
+
self.use_mlx = _use_mlx()
|
|
250
|
+
if not self.use_mlx:
|
|
251
|
+
from kokoro_onnx import Kokoro
|
|
252
|
+
model_dir = Path.home() / ".manim-mcp" / "models" / "kokoro"
|
|
253
|
+
self.kokoro = Kokoro(
|
|
254
|
+
str(model_dir / "kokoro-v1.0.fp16.onnx"),
|
|
255
|
+
str(model_dir / "voices-v1.0.bin"),
|
|
256
|
+
)
|
|
257
|
+
SpeechService.__init__(self, cache_dir=cache_dir, transcription_model=None, **kwargs)
|
|
258
|
+
|
|
259
|
+
def generate_from_text(self, text, cache_dir=None, path=None, **kwargs):
|
|
260
|
+
if cache_dir is None:
|
|
261
|
+
cache_dir = self.cache_dir
|
|
262
|
+
cache_dir = Path(cache_dir)
|
|
263
|
+
input_data = {"input_text": text, "service": "kokoro", "lang": self.lang}
|
|
264
|
+
cached = self.get_cached_result(input_data, cache_dir)
|
|
265
|
+
if cached is not None:
|
|
266
|
+
return cached
|
|
267
|
+
audio_basename = (path or self.get_audio_basename(input_data))
|
|
268
|
+
mp3_path = audio_basename + ".mp3"
|
|
269
|
+
if self.use_mlx:
|
|
270
|
+
model = _get_mlx_model()
|
|
271
|
+
lang_code = MLX_LANG_CODES.get(self.lang, "a")
|
|
272
|
+
chunks = list(model.generate(text, voice=self.voice, lang_code=lang_code, speed=1.0))
|
|
273
|
+
samples = np.concatenate([np.array(c.audio).flatten() for c in chunks])
|
|
274
|
+
sr = chunks[0].sample_rate if chunks else 24000
|
|
275
|
+
else:
|
|
276
|
+
samples, sr = self.kokoro.create(text, voice=self.voice, lang=self.lang, speed=1.0)
|
|
277
|
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as tmp:
|
|
278
|
+
sf.write(tmp.name, samples, sr)
|
|
279
|
+
audio = AudioSegment.from_wav(tmp.name)
|
|
280
|
+
audio.export(str(Path(cache_dir) / mp3_path), format="mp3")
|
|
281
|
+
return {
|
|
282
|
+
"input_text": text,
|
|
283
|
+
"input_data": input_data,
|
|
284
|
+
"original_audio": mp3_path,
|
|
285
|
+
}
|
|
286
|
+
`;
|
|
287
|
+
var PIPER_SERVICE_PY = `from manim_voiceover.services.base import SpeechService
|
|
288
|
+
from piper.voice import PiperVoice
|
|
289
|
+
import wave
|
|
290
|
+
import tempfile
|
|
291
|
+
from pathlib import Path
|
|
292
|
+
from pydub import AudioSegment
|
|
293
|
+
|
|
294
|
+
class PiperService(SpeechService):
|
|
295
|
+
def __init__(self, lang="en_US", model_name="lessac", quality="medium", speed=1.0, cache_dir=None, **kwargs):
|
|
296
|
+
model_dir = Path.home() / ".manim-mcp" / "models" / "piper"
|
|
297
|
+
model_path = model_dir / f"{lang}-{model_name}-{quality}.onnx"
|
|
298
|
+
config_path = model_dir / f"{lang}-{model_name}-{quality}.onnx.json"
|
|
299
|
+
self.voice = PiperVoice.load(str(model_path), config_path=str(config_path))
|
|
300
|
+
self.lang = lang
|
|
301
|
+
self.speed = speed
|
|
302
|
+
SpeechService.__init__(self, cache_dir=cache_dir, transcription_model=None, **kwargs)
|
|
303
|
+
|
|
304
|
+
def generate_from_text(self, text, cache_dir=None, path=None, **kwargs):
|
|
305
|
+
if cache_dir is None:
|
|
306
|
+
cache_dir = self.cache_dir
|
|
307
|
+
cache_dir = Path(cache_dir)
|
|
308
|
+
input_data = {"input_text": text, "service": "piper", "lang": self.lang}
|
|
309
|
+
cached = self.get_cached_result(input_data, cache_dir)
|
|
310
|
+
if cached is not None:
|
|
311
|
+
return cached
|
|
312
|
+
audio_basename = (path or self.get_audio_basename(input_data))
|
|
313
|
+
mp3_path = audio_basename + ".mp3"
|
|
314
|
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as tmp:
|
|
315
|
+
with wave.open(tmp.name, "wb") as wav_file:
|
|
316
|
+
self.voice.synthesize_wav(text, wav_file)
|
|
317
|
+
audio = AudioSegment.from_wav(tmp.name)
|
|
318
|
+
if self.speed != 1.0:
|
|
319
|
+
new_rate = int(audio.frame_rate * self.speed)
|
|
320
|
+
audio = audio._spawn(audio.raw_data, overrides={"frame_rate": new_rate})
|
|
321
|
+
audio = audio.set_frame_rate(44100)
|
|
322
|
+
audio.export(str(Path(cache_dir) / mp3_path), format="mp3")
|
|
323
|
+
return {
|
|
324
|
+
"input_text": text,
|
|
325
|
+
"input_data": input_data,
|
|
326
|
+
"original_audio": mp3_path,
|
|
327
|
+
}
|
|
328
|
+
`;
|
|
329
|
+
var KOKORO_LANGS = {
|
|
330
|
+
en: "en-us",
|
|
331
|
+
"en-us": "en-us",
|
|
332
|
+
"en-gb": "en-gb",
|
|
333
|
+
ja: "ja",
|
|
334
|
+
zh: "cmn",
|
|
335
|
+
"zh-cn": "cmn",
|
|
336
|
+
cmn: "cmn",
|
|
337
|
+
es: "es",
|
|
338
|
+
fr: "fr-fr",
|
|
339
|
+
hi: "hi",
|
|
340
|
+
it: "it",
|
|
341
|
+
pt: "pt-br"
|
|
342
|
+
};
|
|
343
|
+
var PIPER_LANG_MODELS = {
|
|
344
|
+
de: { locale: "de_DE", model: "thorsten" },
|
|
345
|
+
pt: { locale: "pt_BR", model: "faber" },
|
|
346
|
+
ru: { locale: "ru_RU", model: "denis" },
|
|
347
|
+
nl: { locale: "nl_NL", model: "mls" },
|
|
348
|
+
pl: { locale: "pl_PL", model: "gosia" },
|
|
349
|
+
cs: { locale: "cs_CZ", model: "jirka" },
|
|
350
|
+
tr: { locale: "tr_TR", model: "dfki" },
|
|
351
|
+
ar: { locale: "ar_JO", model: "kareem", speed: 1.5 },
|
|
352
|
+
hu: { locale: "hu_HU", model: "anna" }
|
|
353
|
+
};
|
|
354
|
+
function getTtsConfig(lang) {
|
|
355
|
+
if (process.env.PREFER_ELEVENLABS && process.env.ELEVEN_API_KEY) return { service: "elevenlabs" };
|
|
356
|
+
const kokoroLang = KOKORO_LANGS[lang];
|
|
357
|
+
if (kokoroLang) return { service: "kokoro", lang: kokoroLang };
|
|
358
|
+
const piper = PIPER_LANG_MODELS[lang];
|
|
359
|
+
if (piper) return { service: "piper", locale: piper.locale, model: piper.model, speed: piper.speed ?? 1 };
|
|
360
|
+
if (process.env.ELEVEN_API_KEY) return { service: "elevenlabs" };
|
|
361
|
+
return { service: "kokoro", lang: "en-us" };
|
|
362
|
+
}
|
|
363
|
+
var TINYTEX_BIN = process.platform === "darwin" ? path.join(os.homedir(), "Library", "TinyTeX", "bin", "universal-darwin") : path.join(os.homedir(), ".TinyTeX", "bin", "x86_64-linux");
|
|
364
|
+
var LATEX_AVAILABLE = false;
|
|
365
|
+
try {
|
|
366
|
+
execSync("which latex", { timeout: 5e3, stdio: "pipe" });
|
|
367
|
+
LATEX_AVAILABLE = true;
|
|
368
|
+
} catch {
|
|
369
|
+
if (fs.existsSync(path.join(TINYTEX_BIN, "latex"))) {
|
|
370
|
+
LATEX_AVAILABLE = true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
console.error(`[manim] LaTeX available: ${LATEX_AVAILABLE}`);
|
|
374
|
+
if (!LATEX_AVAILABLE && fs.existsSync("/Library/TeX/texbin/latex")) {
|
|
375
|
+
LATEX_AVAILABLE = true;
|
|
376
|
+
}
|
|
377
|
+
var PROJECT_ROOT = import.meta.filename.endsWith(".ts") ? import.meta.dirname : path.join(import.meta.dirname, "..");
|
|
378
|
+
function findManimBin() {
|
|
379
|
+
const localVenv = path.join(PROJECT_ROOT, ".venv", "bin", "manim");
|
|
380
|
+
if (fs.existsSync(localVenv)) {
|
|
381
|
+
console.error("[manim] Using local venv:", localVenv);
|
|
382
|
+
return localVenv;
|
|
383
|
+
}
|
|
384
|
+
const managedBin = path.join(MANAGED_VENV, "bin", "manim");
|
|
385
|
+
if (fs.existsSync(managedBin)) {
|
|
386
|
+
console.error("[manim] Using managed venv:", managedBin);
|
|
387
|
+
return managedBin;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
const pathBin = execSync("which manim", { encoding: "utf-8", timeout: 5e3 }).trim();
|
|
391
|
+
if (pathBin) {
|
|
392
|
+
console.error("[manim] Using system manim:", pathBin);
|
|
393
|
+
return pathBin;
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
}
|
|
397
|
+
return installManagedVenv();
|
|
398
|
+
}
|
|
399
|
+
function findPython() {
|
|
400
|
+
for (const cmd of ["python3.13", "python3.12", "python3", "python"]) {
|
|
401
|
+
try {
|
|
402
|
+
const version = execSync(`${cmd} --version`, { encoding: "utf-8", timeout: 5e3 }).trim();
|
|
403
|
+
console.error(`[manim] Found ${version}`);
|
|
404
|
+
return cmd;
|
|
405
|
+
} catch {
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
throw new Error(
|
|
409
|
+
"Python 3 not found. Install Python 3.9+ from https://python.org and try again."
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
function installManagedVenv() {
|
|
413
|
+
console.error("[manim] First run \u2014 installing manim + voiceover + local TTS deps...");
|
|
414
|
+
const python = findPython();
|
|
415
|
+
fs.mkdirSync(MANAGED_VENV_DIR, { recursive: true });
|
|
416
|
+
console.error("[manim] Creating virtual environment...");
|
|
417
|
+
execSync(`${python} -m venv "${MANAGED_VENV}"`, {
|
|
418
|
+
timeout: 3e4,
|
|
419
|
+
stdio: "pipe"
|
|
420
|
+
});
|
|
421
|
+
const pip = path.join(MANAGED_VENV, "bin", "pip");
|
|
422
|
+
console.error(`[manim] Installing ${BASE_DEPS.join(", ")}...`);
|
|
423
|
+
execSync(`"${pip}" install ${BASE_DEPS.join(" ")}`, {
|
|
424
|
+
timeout: 3e5,
|
|
425
|
+
stdio: "pipe"
|
|
426
|
+
});
|
|
427
|
+
if (process.env.ELEVEN_API_KEY) {
|
|
428
|
+
console.error("[manim] ELEVEN_API_KEY detected \u2014 installing elevenlabs...");
|
|
429
|
+
try {
|
|
430
|
+
execSync(`"${pip}" install ${ELEVENLABS_DEPS.join(" ")}`, {
|
|
431
|
+
timeout: 12e4,
|
|
432
|
+
stdio: "pipe"
|
|
433
|
+
});
|
|
434
|
+
} catch (e) {
|
|
435
|
+
console.error("[manim] Warning: failed to install elevenlabs:", e);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (process.platform === "darwin" && process.arch === "arm64") {
|
|
439
|
+
console.error("[manim] Apple Silicon detected \u2014 installing MLX for faster TTS...");
|
|
440
|
+
try {
|
|
441
|
+
execSync(`"${pip}" install ${MLX_DEPS.join(" ")}`, {
|
|
442
|
+
timeout: 3e5,
|
|
443
|
+
stdio: "pipe"
|
|
444
|
+
});
|
|
445
|
+
} catch (e) {
|
|
446
|
+
console.error("[manim] Warning: MLX install failed (falling back to ONNX):", e);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
downloadKokoroModels();
|
|
450
|
+
writeServiceFiles();
|
|
451
|
+
installTinyTeX();
|
|
452
|
+
const manimBin = path.join(MANAGED_VENV, "bin", "manim");
|
|
453
|
+
if (!fs.existsSync(manimBin)) {
|
|
454
|
+
throw new Error("Installation completed but manim binary not found. Check Python installation.");
|
|
455
|
+
}
|
|
456
|
+
console.error("[manim] Setup complete!");
|
|
457
|
+
return manimBin;
|
|
458
|
+
}
|
|
459
|
+
function downloadKokoroModels() {
|
|
460
|
+
const modelDir = path.join(MANAGED_VENV_DIR, "models", "kokoro");
|
|
461
|
+
const modelPath = path.join(modelDir, "kokoro-v1.0.fp16.onnx");
|
|
462
|
+
const voicesPath = path.join(modelDir, "voices-v1.0.bin");
|
|
463
|
+
if (fs.existsSync(modelPath) && fs.existsSync(voicesPath)) {
|
|
464
|
+
console.error("[manim] Kokoro models already downloaded.");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
fs.mkdirSync(modelDir, { recursive: true });
|
|
468
|
+
if (!fs.existsSync(modelPath)) {
|
|
469
|
+
console.error("[manim] Downloading Kokoro model (~300MB)...");
|
|
470
|
+
execSync(`curl -L -o "${modelPath}" "${KOKORO_MODEL_URL}"`, {
|
|
471
|
+
timeout: 6e5,
|
|
472
|
+
stdio: "pipe"
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
if (!fs.existsSync(voicesPath)) {
|
|
476
|
+
console.error("[manim] Downloading Kokoro voices...");
|
|
477
|
+
execSync(`curl -L -o "${voicesPath}" "${KOKORO_VOICES_URL}"`, {
|
|
478
|
+
timeout: 12e4,
|
|
479
|
+
stdio: "pipe"
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
console.error("[manim] Kokoro models downloaded.");
|
|
483
|
+
}
|
|
484
|
+
function writeServiceFiles() {
|
|
485
|
+
const servicesDir = path.join(MANAGED_VENV_DIR, "services");
|
|
486
|
+
fs.mkdirSync(servicesDir, { recursive: true });
|
|
487
|
+
fs.writeFileSync(path.join(servicesDir, "kokoro_service.py"), KOKORO_SERVICE_PY);
|
|
488
|
+
fs.writeFileSync(path.join(servicesDir, "piper_service.py"), PIPER_SERVICE_PY);
|
|
489
|
+
console.error("[manim] Custom TTS service files written.");
|
|
490
|
+
}
|
|
491
|
+
function installTinyTeX() {
|
|
492
|
+
try {
|
|
493
|
+
execSync("which latex", { timeout: 5e3, stdio: "pipe" });
|
|
494
|
+
console.error("[manim] LaTeX already installed.");
|
|
495
|
+
return;
|
|
496
|
+
} catch {
|
|
497
|
+
}
|
|
498
|
+
if (fs.existsSync(TINYTEX_BIN)) {
|
|
499
|
+
console.error("[manim] TinyTeX already installed at", TINYTEX_BIN);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (process.platform === "darwin" || process.platform === "linux") {
|
|
503
|
+
console.error("[manim] Installing TinyTeX for LaTeX rendering...");
|
|
504
|
+
try {
|
|
505
|
+
execSync('curl -sL "https://yihui.org/tinytex/install-bin-unix.sh" | sh', {
|
|
506
|
+
timeout: 3e5,
|
|
507
|
+
stdio: "pipe"
|
|
508
|
+
});
|
|
509
|
+
const tlmgr = path.join(TINYTEX_BIN, "tlmgr");
|
|
510
|
+
if (fs.existsSync(tlmgr)) {
|
|
511
|
+
console.error("[manim] Installing LaTeX packages for manim...");
|
|
512
|
+
execSync(`"${tlmgr}" install amsmath amssymb amsfonts standalone preview doublestroke setspace rsfs relsize mathtools physics dvisvgm dvipng jknapltx calligra etoolbox l3packages l3kernel pgf xcolor xkeyval`, {
|
|
513
|
+
timeout: 12e4,
|
|
514
|
+
stdio: "pipe"
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
console.error("[manim] TinyTeX installed.");
|
|
518
|
+
} catch (e) {
|
|
519
|
+
console.error("[manim] Warning: TinyTeX install failed (LaTeX rendering won't work):", e);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function ensurePiperDeps(locale, model) {
|
|
524
|
+
const pip = path.join(MANAGED_VENV, "bin", "pip");
|
|
525
|
+
try {
|
|
526
|
+
execSync(`"${pip}" show piper-tts`, { timeout: 1e4, stdio: "pipe" });
|
|
527
|
+
} catch {
|
|
528
|
+
console.error("[manim] Installing piper-tts for extended language support...");
|
|
529
|
+
execSync(`"${pip}" install ${PIPER_DEPS.join(" ")}`, {
|
|
530
|
+
timeout: 12e4,
|
|
531
|
+
stdio: "pipe"
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
const modelDir = path.join(MANAGED_VENV_DIR, "models", "piper");
|
|
535
|
+
const modelFile = `${locale}-${model}-medium.onnx`;
|
|
536
|
+
const modelPath = path.join(modelDir, modelFile);
|
|
537
|
+
if (!fs.existsSync(modelPath)) {
|
|
538
|
+
fs.mkdirSync(modelDir, { recursive: true });
|
|
539
|
+
const langPrefix = locale.split("_")[0];
|
|
540
|
+
const baseUrl = `https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/${langPrefix}/${locale}/${model}/medium`;
|
|
541
|
+
console.error(`[manim] Downloading Piper voice model: ${modelFile}...`);
|
|
542
|
+
execSync(`curl -L -o "${modelPath}" "${baseUrl}/${modelFile}"`, {
|
|
543
|
+
timeout: 12e4,
|
|
544
|
+
stdio: "pipe"
|
|
545
|
+
});
|
|
546
|
+
const configFile = `${modelFile}.json`;
|
|
547
|
+
const configPath = path.join(modelDir, configFile);
|
|
548
|
+
execSync(`curl -L -o "${configPath}" "${baseUrl}/${configFile}"`, {
|
|
549
|
+
timeout: 3e4,
|
|
550
|
+
stdio: "pipe"
|
|
551
|
+
});
|
|
552
|
+
console.error(`[manim] Piper voice model downloaded: ${modelFile}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
var MANIM_BIN;
|
|
556
|
+
function getManimBin() {
|
|
557
|
+
if (!MANIM_BIN) MANIM_BIN = findManimBin();
|
|
558
|
+
return MANIM_BIN;
|
|
559
|
+
}
|
|
560
|
+
var videoStore = /* @__PURE__ */ new Map();
|
|
561
|
+
function getVoiceoverImport(ttsConfig) {
|
|
562
|
+
switch (ttsConfig.service) {
|
|
563
|
+
case "elevenlabs":
|
|
564
|
+
return `import sys, os
|
|
565
|
+
sys.path.insert(0, os.path.expanduser("~/.manim-mcp/services"))
|
|
566
|
+
from manim_voiceover import VoiceoverScene
|
|
567
|
+
from manim_voiceover.services.elevenlabs import ElevenLabsService`;
|
|
568
|
+
case "kokoro":
|
|
569
|
+
return `import sys, os
|
|
570
|
+
sys.path.insert(0, os.path.expanduser("~/.manim-mcp/services"))
|
|
571
|
+
from manim_voiceover import VoiceoverScene
|
|
572
|
+
from kokoro_service import KokoroService`;
|
|
573
|
+
case "piper":
|
|
574
|
+
return `import sys, os
|
|
575
|
+
sys.path.insert(0, os.path.expanduser("~/.manim-mcp/services"))
|
|
576
|
+
from manim_voiceover import VoiceoverScene
|
|
577
|
+
from piper_service import PiperService`;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function getVoiceoverInit(ttsConfig) {
|
|
581
|
+
const cacheDir = TTS_CACHE_DIR.replace(/"/g, '\\"');
|
|
582
|
+
switch (ttsConfig.service) {
|
|
583
|
+
case "elevenlabs":
|
|
584
|
+
return `self.set_speech_service(
|
|
585
|
+
ElevenLabsService(
|
|
586
|
+
voice_id="nPczCjzI2devNBz1zQrb",
|
|
587
|
+
voice_settings={"stability": 0.5, "similarity_boost": 0.75},
|
|
588
|
+
transcription_model=None,
|
|
589
|
+
cache_dir="${cacheDir}",
|
|
590
|
+
)
|
|
591
|
+
)`;
|
|
592
|
+
case "kokoro":
|
|
593
|
+
return `self.set_speech_service(KokoroService(lang="${ttsConfig.lang}", cache_dir="${cacheDir}"))`;
|
|
594
|
+
case "piper":
|
|
595
|
+
return `self.set_speech_service(PiperService(lang="${ttsConfig.locale}", model_name="${ttsConfig.model}", speed=${ttsConfig.speed}, cache_dir="${cacheDir}"))`;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function fixGeneratedCode(code, language = "en") {
|
|
599
|
+
let fixed = code;
|
|
600
|
+
const ttsConfig = getTtsConfig(language);
|
|
601
|
+
const voiceoverImport = getVoiceoverImport(ttsConfig);
|
|
602
|
+
const voiceoverInit = getVoiceoverInit(ttsConfig);
|
|
603
|
+
fixed = fixed.replace(
|
|
604
|
+
/from manim_voiceover\.services\.\w+ import \w+/g,
|
|
605
|
+
""
|
|
606
|
+
);
|
|
607
|
+
fixed = fixed.replace(
|
|
608
|
+
/import sys, os\nsys\.path\.insert\(0, os\.path\.expanduser\("~\/\.manim-mcp\/services"\)\)\n/g,
|
|
609
|
+
""
|
|
610
|
+
);
|
|
611
|
+
fixed = fixed.replace(/from (?:kokoro_service|piper_service) import \w+\n?/g, "");
|
|
612
|
+
if (/VoiceoverScene/.test(fixed)) {
|
|
613
|
+
if (/from manim_voiceover import/.test(fixed)) {
|
|
614
|
+
fixed = fixed.replace(
|
|
615
|
+
/from manim_voiceover import VoiceoverScene/,
|
|
616
|
+
voiceoverImport
|
|
617
|
+
);
|
|
618
|
+
} else {
|
|
619
|
+
fixed = fixed.replace(
|
|
620
|
+
/from manim import \*/,
|
|
621
|
+
`from manim import *
|
|
622
|
+
${voiceoverImport}`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const svcPattern = /self\.set_(?:speech_service|speech_synthesizer|tts_service|voice_service|tts)\(/g;
|
|
627
|
+
let svcMatch;
|
|
628
|
+
while ((svcMatch = svcPattern.exec(fixed)) !== null) {
|
|
629
|
+
const start = svcMatch.index;
|
|
630
|
+
let depth = 1;
|
|
631
|
+
let i = start + svcMatch[0].length;
|
|
632
|
+
while (i < fixed.length && depth > 0) {
|
|
633
|
+
if (fixed[i] === "(") depth++;
|
|
634
|
+
else if (fixed[i] === ")") depth--;
|
|
635
|
+
i++;
|
|
636
|
+
}
|
|
637
|
+
fixed = fixed.slice(0, start) + voiceoverInit + fixed.slice(i);
|
|
638
|
+
svcPattern.lastIndex = start + voiceoverInit.length;
|
|
639
|
+
}
|
|
640
|
+
if (/class \w+\(VoiceoverScene\)/.test(fixed) && !/set_speech_service/.test(fixed)) {
|
|
641
|
+
fixed = fixed.replace(
|
|
642
|
+
/(def construct\(self\):)/,
|
|
643
|
+
`$1
|
|
644
|
+
${voiceoverInit}
|
|
645
|
+
`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
fixed = fixed.replace(
|
|
649
|
+
/\.get_te(?:x|xt)\(([^)]*?)(?:,\s*font_size\s*=\s*\d+)/g,
|
|
650
|
+
".get_text($1"
|
|
651
|
+
);
|
|
652
|
+
fixed = fixed.replace(/self\.(?:safe_wait|wait)\(tracker\.get_remaining\(\)\)/g, "self.wait()");
|
|
653
|
+
fixed = fixed.replace(/tracker\.get_remaining\(\)/g, "tracker.duration");
|
|
654
|
+
fixed = fixed.replace(/\bCYAN\b/g, "TEAL");
|
|
655
|
+
fixed = fixed.replace(/(?:color\s*=\s*|ManimColor\s*\(\s*)["']#[0-9A-Fa-f]{3,8}["']/g, (match) => {
|
|
656
|
+
return match.replace(/["']#[0-9A-Fa-f]{3,8}["']/, "BLUE");
|
|
657
|
+
});
|
|
658
|
+
if (!LATEX_AVAILABLE) {
|
|
659
|
+
fixed = fixed.replace(
|
|
660
|
+
/\b(MathTex|Tex)\(\s*r?"((?:[^"\\]|\\.)*)"/g,
|
|
661
|
+
(_match, _cls, latex) => {
|
|
662
|
+
const unicode = latexToUnicode(latex);
|
|
663
|
+
return `Text("${unicode}"`;
|
|
664
|
+
}
|
|
665
|
+
);
|
|
666
|
+
fixed = fixed.replace(
|
|
667
|
+
/\b(MathTex|Tex)\(\s*r?"""((?:[^"\\]|\\.|"(?!""))*?)"""/g,
|
|
668
|
+
(_match, _cls, latex) => {
|
|
669
|
+
const unicode = latexToUnicode(latex);
|
|
670
|
+
return `Text("${unicode}"`;
|
|
671
|
+
}
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
fixed = fixed.replace(/\n{3,}/g, "\n\n");
|
|
675
|
+
return fixed;
|
|
676
|
+
}
|
|
677
|
+
function latexToUnicode(latex) {
|
|
678
|
+
let s = latex;
|
|
679
|
+
s = s.replace(/\\(?:left|right|,|;|!|quad|qquad|text\s*\{([^}]*)\})/g, "$1");
|
|
680
|
+
s = s.replace(/\\frac\s*\{([^}]*)\}\s*\{([^}]*)\}/g, "($1)/($2)");
|
|
681
|
+
const greek = {
|
|
682
|
+
alpha: "\u03B1",
|
|
683
|
+
beta: "\u03B2",
|
|
684
|
+
gamma: "\u03B3",
|
|
685
|
+
delta: "\u03B4",
|
|
686
|
+
epsilon: "\u03B5",
|
|
687
|
+
zeta: "\u03B6",
|
|
688
|
+
eta: "\u03B7",
|
|
689
|
+
theta: "\u03B8",
|
|
690
|
+
iota: "\u03B9",
|
|
691
|
+
kappa: "\u03BA",
|
|
692
|
+
lambda: "\u03BB",
|
|
693
|
+
mu: "\u03BC",
|
|
694
|
+
nu: "\u03BD",
|
|
695
|
+
xi: "\u03BE",
|
|
696
|
+
pi: "\u03C0",
|
|
697
|
+
rho: "\u03C1",
|
|
698
|
+
sigma: "\u03C3",
|
|
699
|
+
tau: "\u03C4",
|
|
700
|
+
upsilon: "\u03C5",
|
|
701
|
+
phi: "\u03C6",
|
|
702
|
+
chi: "\u03C7",
|
|
703
|
+
psi: "\u03C8",
|
|
704
|
+
omega: "\u03C9",
|
|
705
|
+
Gamma: "\u0393",
|
|
706
|
+
Delta: "\u0394",
|
|
707
|
+
Theta: "\u0398",
|
|
708
|
+
Lambda: "\u039B",
|
|
709
|
+
Xi: "\u039E",
|
|
710
|
+
Pi: "\u03A0",
|
|
711
|
+
Sigma: "\u03A3",
|
|
712
|
+
Phi: "\u03A6",
|
|
713
|
+
Psi: "\u03A8",
|
|
714
|
+
Omega: "\u03A9"
|
|
715
|
+
};
|
|
716
|
+
for (const [name, sym] of Object.entries(greek)) {
|
|
717
|
+
s = s.replace(new RegExp(`\\\\${name}\\b`, "g"), sym);
|
|
718
|
+
}
|
|
719
|
+
const symbols = {
|
|
720
|
+
"\\\\cdot": "\xB7",
|
|
721
|
+
"\\\\times": "\xD7",
|
|
722
|
+
"\\\\div": "\xF7",
|
|
723
|
+
"\\\\pm": "\xB1",
|
|
724
|
+
"\\\\leq": "\u2264",
|
|
725
|
+
"\\\\geq": "\u2265",
|
|
726
|
+
"\\\\neq": "\u2260",
|
|
727
|
+
"\\\\approx": "\u2248",
|
|
728
|
+
"\\\\infty": "\u221E",
|
|
729
|
+
"\\\\sqrt": "\u221A",
|
|
730
|
+
"\\\\sum": "\u03A3",
|
|
731
|
+
"\\\\prod": "\u220F",
|
|
732
|
+
"\\\\int": "\u222B",
|
|
733
|
+
"\\\\partial": "\u2202",
|
|
734
|
+
"\\\\nabla": "\u2207",
|
|
735
|
+
"\\\\forall": "\u2200",
|
|
736
|
+
"\\\\exists": "\u2203",
|
|
737
|
+
"\\\\in": "\u2208",
|
|
738
|
+
"\\\\notin": "\u2209",
|
|
739
|
+
"\\\\subset": "\u2282",
|
|
740
|
+
"\\\\rightarrow": "\u2192",
|
|
741
|
+
"\\\\leftarrow": "\u2190",
|
|
742
|
+
"\\\\Rightarrow": "\u21D2",
|
|
743
|
+
"\\\\neg": "\xAC",
|
|
744
|
+
"\\\\lnot": "\xAC"
|
|
745
|
+
};
|
|
746
|
+
for (const [pat, sym] of Object.entries(symbols)) {
|
|
747
|
+
s = s.replace(new RegExp(pat, "g"), sym);
|
|
748
|
+
}
|
|
749
|
+
s = s.replace(/\^{([^}]*)}/g, (_m, inner) => {
|
|
750
|
+
const sup = {
|
|
751
|
+
"0": "\u2070",
|
|
752
|
+
"1": "\xB9",
|
|
753
|
+
"2": "\xB2",
|
|
754
|
+
"3": "\xB3",
|
|
755
|
+
"4": "\u2074",
|
|
756
|
+
"5": "\u2075",
|
|
757
|
+
"6": "\u2076",
|
|
758
|
+
"7": "\u2077",
|
|
759
|
+
"8": "\u2078",
|
|
760
|
+
"9": "\u2079",
|
|
761
|
+
"+": "\u207A",
|
|
762
|
+
"-": "\u207B",
|
|
763
|
+
"n": "\u207F",
|
|
764
|
+
"i": "\u2071",
|
|
765
|
+
"T": "\u1D40"
|
|
766
|
+
};
|
|
767
|
+
return inner.split("").map((c) => sup[c] ?? c).join("");
|
|
768
|
+
});
|
|
769
|
+
s = s.replace(/\^([0-9nTi])/g, (_m, c) => {
|
|
770
|
+
const sup = { "0": "\u2070", "1": "\xB9", "2": "\xB2", "3": "\xB3", "4": "\u2074", "5": "\u2075", "6": "\u2076", "7": "\u2077", "8": "\u2078", "9": "\u2079", "n": "\u207F", "T": "\u1D40", "i": "\u2071" };
|
|
771
|
+
return sup[c] ?? c;
|
|
772
|
+
});
|
|
773
|
+
s = s.replace(/_{([^}]*)}/g, (_m, inner) => {
|
|
774
|
+
const sub = {
|
|
775
|
+
"0": "\u2080",
|
|
776
|
+
"1": "\u2081",
|
|
777
|
+
"2": "\u2082",
|
|
778
|
+
"3": "\u2083",
|
|
779
|
+
"4": "\u2084",
|
|
780
|
+
"5": "\u2085",
|
|
781
|
+
"6": "\u2086",
|
|
782
|
+
"7": "\u2087",
|
|
783
|
+
"8": "\u2088",
|
|
784
|
+
"9": "\u2089",
|
|
785
|
+
"+": "\u208A",
|
|
786
|
+
"-": "\u208B",
|
|
787
|
+
"k": "\u2096",
|
|
788
|
+
"i": "\u1D62",
|
|
789
|
+
"j": "\u2C7C",
|
|
790
|
+
"n": "\u2099"
|
|
791
|
+
};
|
|
792
|
+
return inner.split("").map((c) => sub[c] ?? c).join("");
|
|
793
|
+
});
|
|
794
|
+
s = s.replace(/_([0-9kijn])/g, (_m, c) => {
|
|
795
|
+
const sub = { "0": "\u2080", "1": "\u2081", "2": "\u2082", "3": "\u2083", "4": "\u2084", "5": "\u2085", "6": "\u2086", "7": "\u2087", "8": "\u2088", "9": "\u2089", "k": "\u2096", "i": "\u1D62", "j": "\u2C7C", "n": "\u2099" };
|
|
796
|
+
return sub[c] ?? c;
|
|
797
|
+
});
|
|
798
|
+
s = s.replace(/\\([a-zA-Z]+)/g, "$1");
|
|
799
|
+
s = s.replace(/[{}]/g, "");
|
|
800
|
+
s = s.replace(/\$/g, "");
|
|
801
|
+
return s;
|
|
802
|
+
}
|
|
803
|
+
async function runWithConcurrency(tasks, limit) {
|
|
804
|
+
const results = new Array(tasks.length);
|
|
805
|
+
let nextIndex = 0;
|
|
806
|
+
async function worker() {
|
|
807
|
+
while (nextIndex < tasks.length) {
|
|
808
|
+
const i = nextIndex++;
|
|
809
|
+
results[i] = await tasks[i]();
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker());
|
|
813
|
+
await Promise.all(workers);
|
|
814
|
+
return results;
|
|
815
|
+
}
|
|
816
|
+
function extractVoiceoverTexts(code) {
|
|
817
|
+
const texts = [];
|
|
818
|
+
const patterns = [
|
|
819
|
+
// Triple-quoted strings: text="""...""" or text='''...'''
|
|
820
|
+
/self\.voiceover\s*\(\s*text\s*=\s*"""([\s\S]*?)"""/g,
|
|
821
|
+
/self\.voiceover\s*\(\s*text\s*=\s*'''([\s\S]*?)'''/g,
|
|
822
|
+
// Single-line strings: text="..." or text='...'
|
|
823
|
+
/self\.voiceover\s*\(\s*text\s*=\s*"([^"]*?)"/g,
|
|
824
|
+
/self\.voiceover\s*\(\s*text\s*=\s*'([^']*?)'/g
|
|
825
|
+
];
|
|
826
|
+
for (const pattern of patterns) {
|
|
827
|
+
let match;
|
|
828
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
829
|
+
const text = match[1].trim();
|
|
830
|
+
if (text.length > 0) texts.push(text);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return texts;
|
|
834
|
+
}
|
|
835
|
+
var TTS_PRESYNC_PY = `
|
|
836
|
+
import sys, os, json
|
|
837
|
+
|
|
838
|
+
# Redirect stdout to stderr so only our JSON protocol goes to real stdout
|
|
839
|
+
_real_stdout = os.fdopen(os.dup(1), "w")
|
|
840
|
+
os.dup2(2, 1)
|
|
841
|
+
|
|
842
|
+
from pathlib import Path
|
|
843
|
+
cache_dir = Path(sys.argv[1])
|
|
844
|
+
lang = sys.argv[2]
|
|
845
|
+
|
|
846
|
+
# Read texts from stdin (JSON array)
|
|
847
|
+
texts = json.loads(sys.stdin.readline())
|
|
848
|
+
|
|
849
|
+
if not texts:
|
|
850
|
+
_real_stdout.write(json.dumps({"done": True, "count": 0}) + "\\n")
|
|
851
|
+
_real_stdout.flush()
|
|
852
|
+
sys.exit(0)
|
|
853
|
+
|
|
854
|
+
sys.stderr.write(f"[tts-presync] Pre-synthesizing {len(texts)} voiceover(s) for lang={lang}\\n")
|
|
855
|
+
sys.stderr.flush()
|
|
856
|
+
|
|
857
|
+
# Import and init the service (loads model once)
|
|
858
|
+
sys.path.insert(0, os.path.join(os.path.expanduser("~"), ".manim-mcp", "services"))
|
|
859
|
+
from kokoro_service import KokoroService
|
|
860
|
+
svc = KokoroService(lang=lang, cache_dir=cache_dir)
|
|
861
|
+
|
|
862
|
+
count = 0
|
|
863
|
+
for i, text in enumerate(texts):
|
|
864
|
+
# Normalize whitespace like _wrap_generate_from_text does
|
|
865
|
+
normalized = " ".join(text.split())
|
|
866
|
+
input_data = {"input_text": normalized, "service": "kokoro", "lang": lang}
|
|
867
|
+
cached = svc.get_cached_result(input_data, cache_dir)
|
|
868
|
+
if cached is not None:
|
|
869
|
+
sys.stderr.write(f"[tts-presync] ({i+1}/{len(texts)}) cached\\n")
|
|
870
|
+
sys.stderr.flush()
|
|
871
|
+
continue
|
|
872
|
+
try:
|
|
873
|
+
svc._wrap_generate_from_text(text)
|
|
874
|
+
count += 1
|
|
875
|
+
sys.stderr.write(f"[tts-presync] ({i+1}/{len(texts)}) synthesized\\n")
|
|
876
|
+
sys.stderr.flush()
|
|
877
|
+
except Exception as e:
|
|
878
|
+
sys.stderr.write(f"[tts-presync] ({i+1}/{len(texts)}) error: {e}\\n")
|
|
879
|
+
sys.stderr.flush()
|
|
880
|
+
|
|
881
|
+
_real_stdout.write(json.dumps({"done": True, "count": count}) + "\\n")
|
|
882
|
+
_real_stdout.flush()
|
|
883
|
+
`;
|
|
884
|
+
function startTtsPreSynthesis(allTexts, lang) {
|
|
885
|
+
if (allTexts.length === 0) return Promise.resolve();
|
|
886
|
+
const ttsConfig = getTtsConfig(lang);
|
|
887
|
+
if (ttsConfig.service !== "kokoro") return Promise.resolve();
|
|
888
|
+
const kokoroLang = ttsConfig.lang;
|
|
889
|
+
console.error(`[manim] Starting speculative TTS pre-synthesis: ${allTexts.length} text(s), lang=${kokoroLang}`);
|
|
890
|
+
return new Promise((resolve) => {
|
|
891
|
+
const pythonBin = path.join(MANAGED_VENV, "bin", "python");
|
|
892
|
+
const envPath = [path.dirname(pythonBin), process.env.PATH].filter(Boolean).join(":");
|
|
893
|
+
const espeakLib = process.platform === "darwin" ? "/opt/homebrew/lib/libespeak-ng.dylib" : "/usr/lib/x86_64-linux-gnu/libespeak-ng.so.1";
|
|
894
|
+
const servicesDir = path.join(MANAGED_VENV_DIR, "services");
|
|
895
|
+
const pythonPath = [servicesDir, process.env.PYTHONPATH].filter(Boolean).join(":");
|
|
896
|
+
const child = spawn(pythonBin, ["-c", TTS_PRESYNC_PY, TTS_CACHE_DIR, kokoroLang], {
|
|
897
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
898
|
+
env: {
|
|
899
|
+
...process.env,
|
|
900
|
+
PATH: envPath,
|
|
901
|
+
PYTHONPATH: pythonPath,
|
|
902
|
+
PHONEMIZER_ESPEAK_LIBRARY: espeakLib
|
|
903
|
+
},
|
|
904
|
+
timeout: 12e4
|
|
905
|
+
});
|
|
906
|
+
child.stdin.write(JSON.stringify(allTexts) + "\n");
|
|
907
|
+
child.stdin.end();
|
|
908
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
909
|
+
rl.on("line", (line) => {
|
|
910
|
+
try {
|
|
911
|
+
const msg = JSON.parse(line);
|
|
912
|
+
if (msg.done) {
|
|
913
|
+
console.error(`[manim] TTS pre-synthesis done: ${msg.count} new, ${allTexts.length - msg.count} cached`);
|
|
914
|
+
}
|
|
915
|
+
} catch {
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
child.on("close", () => resolve());
|
|
919
|
+
child.on("error", (err) => {
|
|
920
|
+
console.error(`[manim] TTS pre-synthesis error: ${err.message}`);
|
|
921
|
+
resolve();
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
function sceneHash(fixedCode, qualityFlag) {
|
|
926
|
+
return crypto.createHash("sha256").update(`${qualityFlag}
|
|
927
|
+
${fixedCode}`).digest("hex").slice(0, 16);
|
|
928
|
+
}
|
|
929
|
+
function getSceneCacheHit(hash) {
|
|
930
|
+
const cached = path.join(SCENE_CACHE_DIR, `${hash}.mp4`);
|
|
931
|
+
if (fs.existsSync(cached)) {
|
|
932
|
+
const stat = fs.statSync(cached);
|
|
933
|
+
if (stat.size > 1024) return cached;
|
|
934
|
+
}
|
|
935
|
+
return void 0;
|
|
936
|
+
}
|
|
937
|
+
function storeSceneCache(hash, videoPath) {
|
|
938
|
+
const cached = path.join(SCENE_CACHE_DIR, `${hash}.mp4`);
|
|
939
|
+
try {
|
|
940
|
+
fs.copyFileSync(videoPath, cached);
|
|
941
|
+
} catch {
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async function renderManimScene(code, className, quality = "l", language = "en") {
|
|
945
|
+
const fixedCode = fixGeneratedCode(code, language);
|
|
946
|
+
const qualityFlag = { l: "-ql", m: "-qm", h: "-qh" }[quality] ?? "-ql";
|
|
947
|
+
const hash = sceneHash(fixedCode, qualityFlag);
|
|
948
|
+
const cached = getSceneCacheHit(hash);
|
|
949
|
+
if (cached) {
|
|
950
|
+
console.error(`[manim] Scene cache HIT for ${className} (${hash})`);
|
|
951
|
+
return { success: true, videoPath: cached };
|
|
952
|
+
}
|
|
953
|
+
const sceneDir = path.join(OUTPUT_DIR, `${className}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`);
|
|
954
|
+
fs.mkdirSync(sceneDir, { recursive: true });
|
|
955
|
+
const sceneFile = path.join(sceneDir, `${className}.py`);
|
|
956
|
+
fs.writeFileSync(sceneFile, fixedCode);
|
|
957
|
+
let result;
|
|
958
|
+
try {
|
|
959
|
+
const worker = await getAvailableWorker();
|
|
960
|
+
result = await renderViaWorker(worker, {
|
|
961
|
+
scene_file: sceneFile,
|
|
962
|
+
class_name: className,
|
|
963
|
+
media_dir: sceneDir,
|
|
964
|
+
quality_flag: qualityFlag
|
|
965
|
+
});
|
|
966
|
+
} catch (workerError) {
|
|
967
|
+
result = await renderManimSceneFallback(sceneFile, className, sceneDir, qualityFlag);
|
|
968
|
+
}
|
|
969
|
+
if (result.success && result.videoPath) {
|
|
970
|
+
storeSceneCache(hash, result.videoPath);
|
|
971
|
+
}
|
|
972
|
+
return result;
|
|
973
|
+
}
|
|
974
|
+
function renderManimSceneFallback(sceneFile, className, sceneDir, qualityFlag) {
|
|
975
|
+
return new Promise((resolve) => {
|
|
976
|
+
const envPath = [TINYTEX_BIN, "/Library/TeX/texbin", process.env.PATH].filter(Boolean).join(":");
|
|
977
|
+
const servicesDir = path.join(MANAGED_VENV_DIR, "services");
|
|
978
|
+
const pythonPath = [servicesDir, process.env.PYTHONPATH].filter(Boolean).join(":");
|
|
979
|
+
const espeakLib = process.platform === "darwin" ? "/opt/homebrew/lib/libespeak-ng.dylib" : "/usr/lib/x86_64-linux-gnu/libespeak-ng.so.1";
|
|
980
|
+
execFile(getManimBin(), [
|
|
981
|
+
"render",
|
|
982
|
+
qualityFlag,
|
|
983
|
+
"--media_dir",
|
|
984
|
+
sceneDir,
|
|
985
|
+
sceneFile,
|
|
986
|
+
className
|
|
987
|
+
], {
|
|
988
|
+
timeout: 3e5,
|
|
989
|
+
cwd: sceneDir,
|
|
990
|
+
env: {
|
|
991
|
+
...process.env,
|
|
992
|
+
PATH: envPath,
|
|
993
|
+
PYTHONPATH: pythonPath,
|
|
994
|
+
PHONEMIZER_ESPEAK_LIBRARY: espeakLib
|
|
995
|
+
}
|
|
996
|
+
}, (error, _stdout, stderr) => {
|
|
997
|
+
if (error) {
|
|
998
|
+
resolve({
|
|
999
|
+
success: false,
|
|
1000
|
+
error: (stderr ?? String(error)).slice(-2e3)
|
|
1001
|
+
});
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const videoPath = findVideo(sceneDir, className);
|
|
1005
|
+
if (!videoPath) {
|
|
1006
|
+
resolve({ success: false, error: "Render succeeded but no .mp4 found" });
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
resolve({ success: true, videoPath });
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
function findVideo(dir, className) {
|
|
1014
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: true });
|
|
1015
|
+
const mp4s = [];
|
|
1016
|
+
for (const e of entries) {
|
|
1017
|
+
if (e.isFile() && e.name.endsWith(".mp4")) {
|
|
1018
|
+
const fullPath = path.join(e.parentPath ?? e.path, e.name);
|
|
1019
|
+
const isPartial = fullPath.includes("partial_movie_files");
|
|
1020
|
+
const matchesClass = e.name.includes(className);
|
|
1021
|
+
const size = fs.statSync(fullPath).size;
|
|
1022
|
+
mp4s.push({ path: fullPath, isPartial, matchesClass, size });
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
mp4s.sort((a, b) => {
|
|
1026
|
+
if (!a.isPartial && b.isPartial) return -1;
|
|
1027
|
+
if (a.isPartial && !b.isPartial) return 1;
|
|
1028
|
+
if (a.matchesClass && !b.matchesClass) return -1;
|
|
1029
|
+
if (!a.matchesClass && b.matchesClass) return 1;
|
|
1030
|
+
return b.size - a.size;
|
|
1031
|
+
});
|
|
1032
|
+
return mp4s[0]?.path;
|
|
1033
|
+
}
|
|
1034
|
+
function concatVideos(videoPaths, outputPath) {
|
|
1035
|
+
if (videoPaths.length === 0) return false;
|
|
1036
|
+
if (videoPaths.length === 1) {
|
|
1037
|
+
fs.copyFileSync(videoPaths[0], outputPath);
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
const listFile = path.join(OUTPUT_DIR, `concat_${Date.now()}.txt`);
|
|
1041
|
+
fs.writeFileSync(listFile, videoPaths.map((p) => `file '${p}'`).join("\n"));
|
|
1042
|
+
try {
|
|
1043
|
+
execSync(`ffmpeg -y -f concat -safe 0 -i "${listFile}" -c copy "${outputPath}"`, {
|
|
1044
|
+
timeout: 12e4,
|
|
1045
|
+
stdio: "pipe"
|
|
1046
|
+
});
|
|
1047
|
+
return true;
|
|
1048
|
+
} catch {
|
|
1049
|
+
return false;
|
|
1050
|
+
} finally {
|
|
1051
|
+
try {
|
|
1052
|
+
fs.unlinkSync(listFile);
|
|
1053
|
+
} catch {
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
function listRefFiles(subdir, ext) {
|
|
1058
|
+
const dir = path.join(REFERENCES_DIR, subdir);
|
|
1059
|
+
if (!fs.existsSync(dir)) return [];
|
|
1060
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(ext)).sort();
|
|
1061
|
+
}
|
|
1062
|
+
function readRef(subpath) {
|
|
1063
|
+
return fs.readFileSync(path.join(REFERENCES_DIR, subpath), "utf-8");
|
|
1064
|
+
}
|
|
1065
|
+
function createServer() {
|
|
1066
|
+
const server = new McpServer(
|
|
1067
|
+
{ name: "Manim Video Generator", version: "1.0.0" },
|
|
1068
|
+
{
|
|
1069
|
+
instructions: `You are driving a 3Blue1Brown-style math animation server with comprehensive Manim reference materials.
|
|
1070
|
+
|
|
1071
|
+
WORKFLOW:
|
|
1072
|
+
1. FIRST: Call get_manim_reference to load the best practices and style guide for the topic
|
|
1073
|
+
2. Plan 2-4 scenes (titles, descriptions, narration scripts) following 3b1b storytelling principles
|
|
1074
|
+
3. Write Manim code for each scene following the loaded reference guidelines
|
|
1075
|
+
4. Call render_video with ALL scenes at once \u2014 they render in parallel, get concatenated, and show as one video
|
|
1076
|
+
|
|
1077
|
+
For simple topics, 2 scenes is enough. For complex topics, use 3-4.
|
|
1078
|
+
Default quality is "l" (480p) for fast iteration. Only use "m" or "h" if the user asks.
|
|
1079
|
+
|
|
1080
|
+
SCENE COMPOSITION \u2014 CRITICAL:
|
|
1081
|
+
Each scene renders independently with NO shared state. This means each scene starts from a blank canvas.
|
|
1082
|
+
DO NOT rebuild the same visual in every scene. Each scene MUST have a DISTINCT visual focus:
|
|
1083
|
+
- BAD: Scene 1 draws triangle + labels \u2192 Scene 2 draws same triangle + labels + squares \u2192 Scene 3 draws same triangle + labels + equation
|
|
1084
|
+
- GOOD: Scene 1 draws triangle, introduces the question \u2192 Scene 2 shows geometric proof with rearrangement (NO triangle rebuild) \u2192 Scene 3 shows equation + numerical verification
|
|
1085
|
+
Think of each scene as a different CAMERA ANGLE on the topic \u2014 different layout, different focal objects, different stage of the argument.
|
|
1086
|
+
If Scene N needs a visual from Scene N-1, use a BRIEF recap (1-2 seconds) with a simplified/smaller version, not a full rebuild.
|
|
1087
|
+
|
|
1088
|
+
LANGUAGE SUPPORT:
|
|
1089
|
+
Pass the \`language\` parameter to render_video. Write ALL narration in the requested language.
|
|
1090
|
+
Kokoro (best, local): en, zh, ja, ko, es, fr, hi, it | Piper (extended): de, pt, ru, nl, cs, ar, hu, pl, tr | ElevenLabs (premium, needs ELEVEN_API_KEY): all
|
|
1091
|
+
|
|
1092
|
+
VOICEOVER (MANDATORY):
|
|
1093
|
+
Use VoiceoverScene base class. The server auto-injects the correct TTS service \u2014 just write:
|
|
1094
|
+
with self.voiceover(text="...") as tracker:
|
|
1095
|
+
self.play(Create(obj), run_time=tracker.duration)
|
|
1096
|
+
KEY: Always use tracker.duration to sync animation with speech!
|
|
1097
|
+
GOTCHA: tracker.get_remaining() does NOT exist. The voiceover context manager auto-waits when the block exits. Just use self.wait() if you need a pause, never tracker.get_remaining().
|
|
1098
|
+
|
|
1099
|
+
The server auto-fixes common issues: wrong TTS service, CYAN\u2192TEAL, tracker.get_remaining()\u2192self.wait().
|
|
1100
|
+
LaTeX: If LaTeX is installed, use MathTex/Tex. If not, the server auto-converts to Text() with Unicode (\u03C0, \xB2, \u221A, \u222B, etc.).
|
|
1101
|
+
TTS defaults to local models (Kokoro/Piper). Set PREFER_ELEVENLABS=1 + ELEVEN_API_KEY to use ElevenLabs instead.
|
|
1102
|
+
|
|
1103
|
+
IF A RENDER FAILS: Read the error, fix the code, call render_video again.`
|
|
1104
|
+
}
|
|
1105
|
+
);
|
|
1106
|
+
server.registerResource(
|
|
1107
|
+
"video",
|
|
1108
|
+
new ResourceTemplate("videos://{id}", { list: void 0 }),
|
|
1109
|
+
{
|
|
1110
|
+
description: "Rendered Manim video (base64 blob)",
|
|
1111
|
+
mimeType: "video/mp4"
|
|
1112
|
+
},
|
|
1113
|
+
async (uri, { id }) => {
|
|
1114
|
+
const idStr = Array.isArray(id) ? id[0] : id;
|
|
1115
|
+
const buffer = videoStore.get(idStr);
|
|
1116
|
+
if (!buffer) {
|
|
1117
|
+
throw new Error(`Video not found: ${idStr}`);
|
|
1118
|
+
}
|
|
1119
|
+
console.error(`[manim] Serving video ${idStr}: ${buffer.byteLength} bytes`);
|
|
1120
|
+
return {
|
|
1121
|
+
contents: [{
|
|
1122
|
+
uri: uri.href,
|
|
1123
|
+
mimeType: "video/mp4",
|
|
1124
|
+
blob: buffer.toString("base64")
|
|
1125
|
+
}]
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
);
|
|
1129
|
+
const manimceRulesDir = path.join(REFERENCES_DIR, "manimce", "rules");
|
|
1130
|
+
if (fs.existsSync(manimceRulesDir)) {
|
|
1131
|
+
server.registerResource(
|
|
1132
|
+
"manimce-rules",
|
|
1133
|
+
new ResourceTemplate("skills://manimce/rules/{name}", {
|
|
1134
|
+
list: async () => ({
|
|
1135
|
+
resources: listRefFiles("manimce/rules", ".md").map((f) => ({
|
|
1136
|
+
uri: `skills://manimce/rules/${f.replace(".md", "")}`,
|
|
1137
|
+
name: `ManimCE: ${f.replace(".md", "").replace(/-/g, " ")}`,
|
|
1138
|
+
mimeType: "text/markdown"
|
|
1139
|
+
}))
|
|
1140
|
+
})
|
|
1141
|
+
}),
|
|
1142
|
+
{ description: "ManimCE best practices rule files (animations, positioning, LaTeX, 3D, colors, timing, etc.)", mimeType: "text/markdown" },
|
|
1143
|
+
async (uri, { name }) => {
|
|
1144
|
+
const fileName = (Array.isArray(name) ? name[0] : name) + ".md";
|
|
1145
|
+
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: readRef(`manimce/rules/${fileName}`) }] };
|
|
1146
|
+
}
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
const manimceExamplesDir = path.join(REFERENCES_DIR, "manimce", "examples");
|
|
1150
|
+
if (fs.existsSync(manimceExamplesDir)) {
|
|
1151
|
+
server.registerResource(
|
|
1152
|
+
"manimce-examples",
|
|
1153
|
+
new ResourceTemplate("skills://manimce/examples/{name}", {
|
|
1154
|
+
list: async () => ({
|
|
1155
|
+
resources: listRefFiles("manimce/examples", ".py").map((f) => ({
|
|
1156
|
+
uri: `skills://manimce/examples/${f.replace(".py", "")}`,
|
|
1157
|
+
name: `Example: ${f.replace(".py", "").replace(/_/g, " ")}`,
|
|
1158
|
+
mimeType: "text/x-python"
|
|
1159
|
+
}))
|
|
1160
|
+
})
|
|
1161
|
+
}),
|
|
1162
|
+
{ description: "Working ManimCE code examples", mimeType: "text/x-python" },
|
|
1163
|
+
async (uri, { name }) => {
|
|
1164
|
+
const fileName = (Array.isArray(name) ? name[0] : name) + ".py";
|
|
1165
|
+
return { contents: [{ uri: uri.href, mimeType: "text/x-python", text: readRef(`manimce/examples/${fileName}`) }] };
|
|
1166
|
+
}
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
const composerRefsDir = path.join(REFERENCES_DIR, "composer", "references");
|
|
1170
|
+
if (fs.existsSync(composerRefsDir)) {
|
|
1171
|
+
server.registerResource(
|
|
1172
|
+
"composer-references",
|
|
1173
|
+
new ResourceTemplate("skills://composer/{name}", {
|
|
1174
|
+
list: async () => ({
|
|
1175
|
+
resources: listRefFiles("composer/references", ".md").map((f) => ({
|
|
1176
|
+
uri: `skills://composer/${f.replace(".md", "")}`,
|
|
1177
|
+
name: `Composer: ${f.replace(".md", "").replace(/-/g, " ")}`,
|
|
1178
|
+
mimeType: "text/markdown"
|
|
1179
|
+
}))
|
|
1180
|
+
})
|
|
1181
|
+
}),
|
|
1182
|
+
{ description: "3Blue1Brown video composition guides: narrative patterns, scene archetypes, pacing, storytelling", mimeType: "text/markdown" },
|
|
1183
|
+
async (uri, { name }) => {
|
|
1184
|
+
const fileName = (Array.isArray(name) ? name[0] : name) + ".md";
|
|
1185
|
+
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: readRef(`composer/references/${fileName}`) }] };
|
|
1186
|
+
}
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
const referenceTopics = [
|
|
1190
|
+
"animations",
|
|
1191
|
+
"positioning",
|
|
1192
|
+
"latex",
|
|
1193
|
+
"colors",
|
|
1194
|
+
"timing",
|
|
1195
|
+
"axes",
|
|
1196
|
+
"3d",
|
|
1197
|
+
"camera",
|
|
1198
|
+
"creation-animations",
|
|
1199
|
+
"transform-animations",
|
|
1200
|
+
"animation-groups",
|
|
1201
|
+
"text",
|
|
1202
|
+
"styling",
|
|
1203
|
+
"grouping",
|
|
1204
|
+
"graphing",
|
|
1205
|
+
"updaters",
|
|
1206
|
+
"shapes",
|
|
1207
|
+
"lines",
|
|
1208
|
+
"scenes",
|
|
1209
|
+
"mobjects",
|
|
1210
|
+
"pedagogy",
|
|
1211
|
+
"advanced-animations",
|
|
1212
|
+
"mathematical-domains",
|
|
1213
|
+
"color-conventions",
|
|
1214
|
+
"physics-simulations",
|
|
1215
|
+
"3b1b-translation",
|
|
1216
|
+
"long-form-video",
|
|
1217
|
+
"text-animations",
|
|
1218
|
+
"multi-scene-composition",
|
|
1219
|
+
"config",
|
|
1220
|
+
"cli",
|
|
1221
|
+
"api-pitfalls"
|
|
1222
|
+
];
|
|
1223
|
+
server.tool(
|
|
1224
|
+
"get_manim_reference",
|
|
1225
|
+
`Load Manim best practices and reference material. Call this BEFORE writing scene code.
|
|
1226
|
+
Topics: ${referenceTopics.join(", ")}
|
|
1227
|
+
Use "all-core" to get the most important rules (animations, positioning, colors, timing, axes, 3d, latex, pedagogy) in one call.
|
|
1228
|
+
Use "composer" to get the 3b1b video planning and storytelling guide.`,
|
|
1229
|
+
{
|
|
1230
|
+
topics: z.array(z.string()).min(1).describe('Topics to load. Use specific names or "all-core" for essential rules, "composer" for storytelling guide.')
|
|
1231
|
+
},
|
|
1232
|
+
async ({ topics }) => {
|
|
1233
|
+
preWarmWorkers();
|
|
1234
|
+
const sections = [];
|
|
1235
|
+
const resolvedTopics = /* @__PURE__ */ new Set();
|
|
1236
|
+
for (const t of topics) {
|
|
1237
|
+
if (t === "all-core") {
|
|
1238
|
+
["animations", "positioning", "colors", "timing", "axes", "3d", "latex", "pedagogy", "creation-animations", "transform-animations", "multi-scene-composition", "api-pitfalls"].forEach((r) => resolvedTopics.add(r));
|
|
1239
|
+
} else if (t === "composer") {
|
|
1240
|
+
try {
|
|
1241
|
+
sections.push(readRef("composer/SKILL.md"));
|
|
1242
|
+
} catch {
|
|
1243
|
+
}
|
|
1244
|
+
for (const ref of ["narrative-patterns", "scene-archetypes", "pacing-guide", "visual-techniques"]) {
|
|
1245
|
+
try {
|
|
1246
|
+
sections.push(readRef(`composer/references/${ref}.md`));
|
|
1247
|
+
} catch {
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
} else {
|
|
1251
|
+
resolvedTopics.add(t);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (resolvedTopics.size > 0) {
|
|
1255
|
+
try {
|
|
1256
|
+
sections.unshift(readRef("manimce/SKILL.md"));
|
|
1257
|
+
} catch {
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
for (const topic of resolvedTopics) {
|
|
1261
|
+
try {
|
|
1262
|
+
sections.push(readRef(`manimce/rules/${topic}.md`));
|
|
1263
|
+
} catch {
|
|
1264
|
+
sections.push(`[Topic "${topic}" not found]`);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
const reference = sections.join("\n\n---\n\n");
|
|
1268
|
+
return {
|
|
1269
|
+
content: [{ type: "text", text: reference }]
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
);
|
|
1273
|
+
const sceneSchema = z.object({
|
|
1274
|
+
code: z.string().describe("Complete Manim Python code for this scene. Must contain a class extending VoiceoverScene, Scene, MovingCameraScene, or ThreeDScene."),
|
|
1275
|
+
title: z.string().optional().describe("Short title for this scene")
|
|
1276
|
+
});
|
|
1277
|
+
registerAppTool(
|
|
1278
|
+
server,
|
|
1279
|
+
"render_video",
|
|
1280
|
+
{
|
|
1281
|
+
title: "Render Manim Video",
|
|
1282
|
+
description: `Render one or more Manim scenes in parallel, concatenate them, and return one combined video inline.
|
|
1283
|
+
Each scene has complete Python code with voiceover baked in via manim-voiceover.
|
|
1284
|
+
Scenes render concurrently \u2014 voice is generated and synced during rendering automatically.
|
|
1285
|
+
The server auto-fixes TTS service injection based on language, and fixes common issues like CYAN \u2192 TEAL.`,
|
|
1286
|
+
inputSchema: {
|
|
1287
|
+
scenes: z.array(sceneSchema).min(1).max(6).describe("Array of scenes to render in parallel. Order matters for final video."),
|
|
1288
|
+
quality: z.enum(["l", "m", "h"]).default("l").describe("Video quality: l=480p (default), m=720p, h=1080p"),
|
|
1289
|
+
language: z.enum([
|
|
1290
|
+
"en",
|
|
1291
|
+
"en-us",
|
|
1292
|
+
"en-gb",
|
|
1293
|
+
"es",
|
|
1294
|
+
"fr",
|
|
1295
|
+
"de",
|
|
1296
|
+
"it",
|
|
1297
|
+
"pt",
|
|
1298
|
+
"pl",
|
|
1299
|
+
"tr",
|
|
1300
|
+
"ru",
|
|
1301
|
+
"nl",
|
|
1302
|
+
"cs",
|
|
1303
|
+
"ar",
|
|
1304
|
+
"zh",
|
|
1305
|
+
"zh-cn",
|
|
1306
|
+
"ja",
|
|
1307
|
+
"hu",
|
|
1308
|
+
"ko",
|
|
1309
|
+
"hi"
|
|
1310
|
+
]).default("en").describe("Language for voiceover narration. Kokoro (best): en,zh,ja,ko,es,fr,hi,it. Piper (extended): de,pt,ru,nl,cs,ar,hu,pl,tr.")
|
|
1311
|
+
},
|
|
1312
|
+
outputSchema: z.object({
|
|
1313
|
+
videoUri: z.string(),
|
|
1314
|
+
overview: z.string(),
|
|
1315
|
+
scenes: z.array(z.string()),
|
|
1316
|
+
durationSeconds: z.number()
|
|
1317
|
+
}),
|
|
1318
|
+
_meta: { ui: { resourceUri: VIEW_URI } }
|
|
1319
|
+
},
|
|
1320
|
+
async ({ scenes, quality, language }) => {
|
|
1321
|
+
const lang = language ?? "en";
|
|
1322
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
1323
|
+
const jobStart = Date.now();
|
|
1324
|
+
console.error(`[manim] [${id}] Starting ${scenes.length} scene(s), max ${MAX_PARALLEL_RENDERS} parallel (lang=${lang})...`);
|
|
1325
|
+
const ttsConfig = getTtsConfig(lang);
|
|
1326
|
+
if (ttsConfig.service === "piper") {
|
|
1327
|
+
try {
|
|
1328
|
+
ensurePiperDeps(ttsConfig.locale, ttsConfig.model);
|
|
1329
|
+
} catch (e) {
|
|
1330
|
+
return {
|
|
1331
|
+
content: [{ type: "text", text: `Failed to install Piper TTS for language "${lang}": ${e}` }],
|
|
1332
|
+
isError: true
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
writeServiceFiles();
|
|
1337
|
+
const sceneMeta = [];
|
|
1338
|
+
for (let i = 0; i < scenes.length; i++) {
|
|
1339
|
+
const s = scenes[i];
|
|
1340
|
+
const classMatch = s.code.match(/class\s+(\w+)\s*\(\s*(?:Scene|VoiceoverScene|MovingCameraScene|ThreeDScene)\s*\)/);
|
|
1341
|
+
if (!classMatch) {
|
|
1342
|
+
return {
|
|
1343
|
+
content: [{ type: "text", text: `Scene ${i}: code must contain a class extending Scene or VoiceoverScene.` }],
|
|
1344
|
+
isError: true
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
sceneMeta.push({ className: classMatch[1], code: s.code, title: s.title });
|
|
1348
|
+
}
|
|
1349
|
+
const allVoiceoverTexts = [...new Set(sceneMeta.flatMap((s) => extractVoiceoverTexts(s.code)))];
|
|
1350
|
+
if (allVoiceoverTexts.length > 0) {
|
|
1351
|
+
console.error(`[manim] [${id}] Pre-synthesizing ${allVoiceoverTexts.length} voiceover(s) before rendering...`);
|
|
1352
|
+
await startTtsPreSynthesis(allVoiceoverTexts, lang);
|
|
1353
|
+
console.error(`[manim] [${id}] TTS pre-synthesis complete, starting renders...`);
|
|
1354
|
+
}
|
|
1355
|
+
const renderResults = await runWithConcurrency(
|
|
1356
|
+
sceneMeta.map((s, i) => async () => {
|
|
1357
|
+
console.error(`[manim] [${id}] Rendering scene ${i + 1}/${sceneMeta.length}: ${s.className}`);
|
|
1358
|
+
const startTime = Date.now();
|
|
1359
|
+
const result = await renderManimScene(s.code, s.className, quality, lang);
|
|
1360
|
+
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
1361
|
+
console.error(`[manim] [${id}] Scene ${i + 1} ${result.success ? "rendered" : "errored"} in ${elapsed}s`);
|
|
1362
|
+
return result;
|
|
1363
|
+
}),
|
|
1364
|
+
MAX_PARALLEL_RENDERS
|
|
1365
|
+
);
|
|
1366
|
+
const videoPaths = [];
|
|
1367
|
+
const sceneNames = [];
|
|
1368
|
+
const errors = [];
|
|
1369
|
+
for (let i = 0; i < renderResults.length; i++) {
|
|
1370
|
+
const r = renderResults[i];
|
|
1371
|
+
const name = sceneMeta[i].title ?? sceneMeta[i].className;
|
|
1372
|
+
if (r.success && r.videoPath) {
|
|
1373
|
+
videoPaths.push(r.videoPath);
|
|
1374
|
+
sceneNames.push(name);
|
|
1375
|
+
console.error(`[manim] [${id}] Scene ${i + 1} OK: ${name}`);
|
|
1376
|
+
} else {
|
|
1377
|
+
errors.push(`Scene ${i + 1} (${name}): ${r.error}`);
|
|
1378
|
+
console.error(`[manim] [${id}] Scene ${i + 1} FAILED: ${r.error?.slice(0, 200)}`);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (videoPaths.length === 0) {
|
|
1382
|
+
return {
|
|
1383
|
+
content: [{ type: "text", text: `All scenes failed to render:
|
|
1384
|
+
|
|
1385
|
+
${errors.join("\n\n")}` }],
|
|
1386
|
+
isError: true
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
const finalPath = path.join(OUTPUT_DIR, `final_${id}.mp4`);
|
|
1390
|
+
if (!concatVideos(videoPaths, finalPath)) {
|
|
1391
|
+
return {
|
|
1392
|
+
content: [{ type: "text", text: "Failed to concatenate scene videos" }],
|
|
1393
|
+
isError: true
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
let durationSeconds = 0;
|
|
1397
|
+
try {
|
|
1398
|
+
const probe = execSync(`ffprobe -v quiet -print_format json -show_format "${finalPath}"`, { timeout: 1e4 });
|
|
1399
|
+
durationSeconds = parseFloat(JSON.parse(probe.toString()).format?.duration ?? "0");
|
|
1400
|
+
} catch {
|
|
1401
|
+
}
|
|
1402
|
+
durationSeconds = Math.round(durationSeconds * 10) / 10;
|
|
1403
|
+
const videoId = `manim-${id}`;
|
|
1404
|
+
videoStore.set(videoId, fs.readFileSync(finalPath));
|
|
1405
|
+
const overview = sceneMeta[0].title ?? sceneNames.join(" \u2192 ");
|
|
1406
|
+
const data = {
|
|
1407
|
+
videoUri: `videos://${videoId}`,
|
|
1408
|
+
overview,
|
|
1409
|
+
scenes: sceneNames,
|
|
1410
|
+
durationSeconds
|
|
1411
|
+
};
|
|
1412
|
+
const summary = [
|
|
1413
|
+
`Video rendered: "${overview}" (${durationSeconds}s)`,
|
|
1414
|
+
`${sceneNames.length}/${scenes.length} scenes succeeded`
|
|
1415
|
+
];
|
|
1416
|
+
if (errors.length > 0) {
|
|
1417
|
+
summary.push(`
|
|
1418
|
+
Failed scenes:
|
|
1419
|
+
${errors.join("\n")}`);
|
|
1420
|
+
}
|
|
1421
|
+
const jobElapsed = ((Date.now() - jobStart) / 1e3).toFixed(1);
|
|
1422
|
+
console.error(`[manim] [${id}] Done: ${sceneNames.length} scenes, ${durationSeconds}s video, ${jobElapsed}s wall time`);
|
|
1423
|
+
return {
|
|
1424
|
+
content: [{ type: "text", text: summary.join("\n") }],
|
|
1425
|
+
structuredContent: data
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
);
|
|
1429
|
+
const DEMO_VIDEO_PATH = path.join(DIST_DIR, "demo.mp4");
|
|
1430
|
+
registerAppTool(
|
|
1431
|
+
server,
|
|
1432
|
+
"show_demo_video",
|
|
1433
|
+
{
|
|
1434
|
+
title: "Show Demo Video",
|
|
1435
|
+
description: "Show a pre-rendered demo video inline to test the MCP video player. No generation needed.",
|
|
1436
|
+
inputSchema: {},
|
|
1437
|
+
outputSchema: z.object({
|
|
1438
|
+
videoUri: z.string(),
|
|
1439
|
+
overview: z.string(),
|
|
1440
|
+
scenes: z.array(z.string()),
|
|
1441
|
+
durationSeconds: z.number()
|
|
1442
|
+
}),
|
|
1443
|
+
_meta: { ui: { resourceUri: VIEW_URI } }
|
|
1444
|
+
},
|
|
1445
|
+
async () => {
|
|
1446
|
+
if (!fs.existsSync(DEMO_VIDEO_PATH)) {
|
|
1447
|
+
return {
|
|
1448
|
+
content: [{ type: "text", text: "Demo video not found at " + DEMO_VIDEO_PATH }],
|
|
1449
|
+
isError: true
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
const videoId = "demo-pythagorean";
|
|
1453
|
+
if (!videoStore.has(videoId)) {
|
|
1454
|
+
videoStore.set(videoId, fs.readFileSync(DEMO_VIDEO_PATH));
|
|
1455
|
+
}
|
|
1456
|
+
const data = {
|
|
1457
|
+
videoUri: `videos://${videoId}`,
|
|
1458
|
+
overview: "Pythagorean Theorem \u2014 visual proof with squares on triangle sides",
|
|
1459
|
+
scenes: ["The Hook", "Building the Proof"],
|
|
1460
|
+
durationSeconds: 41
|
|
1461
|
+
};
|
|
1462
|
+
return {
|
|
1463
|
+
content: [{
|
|
1464
|
+
type: "text",
|
|
1465
|
+
text: `Demo video: "${data.overview}" (${data.durationSeconds}s)`
|
|
1466
|
+
}],
|
|
1467
|
+
structuredContent: data
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
);
|
|
1471
|
+
registerAppResource(
|
|
1472
|
+
server,
|
|
1473
|
+
"Manim Video Player",
|
|
1474
|
+
VIEW_URI,
|
|
1475
|
+
{ mimeType: RESOURCE_MIME_TYPE },
|
|
1476
|
+
async () => {
|
|
1477
|
+
const html = await fs.promises.readFile(
|
|
1478
|
+
path.join(DIST_DIR, "mcp-app.html"),
|
|
1479
|
+
"utf-8"
|
|
1480
|
+
);
|
|
1481
|
+
return {
|
|
1482
|
+
contents: [
|
|
1483
|
+
{ uri: VIEW_URI, mimeType: RESOURCE_MIME_TYPE, text: html }
|
|
1484
|
+
]
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
);
|
|
1488
|
+
return server;
|
|
1489
|
+
}
|
|
1490
|
+
export {
|
|
1491
|
+
createServer
|
|
1492
|
+
};
|