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.
Files changed (64) hide show
  1. package/README.md +104 -0
  2. package/dist/demo.mp4 +0 -0
  3. package/dist/index.js +65 -0
  4. package/dist/mcp-app.html +142 -0
  5. package/dist/server.js +1492 -0
  6. package/package.json +67 -0
  7. package/references/composer/SKILL.md +154 -0
  8. package/references/composer/references/3b1b-series-patterns.md +217 -0
  9. package/references/composer/references/domain-planning-guides/calculus-planning.md +188 -0
  10. package/references/composer/references/domain-planning-guides/linear-algebra-planning.md +169 -0
  11. package/references/composer/references/domain-planning-guides/ml-planning.md +286 -0
  12. package/references/composer/references/domain-planning-guides/number-theory-planning.md +187 -0
  13. package/references/composer/references/domain-planning-guides/physics-planning.md +249 -0
  14. package/references/composer/references/domain-planning-guides/probability-planning.md +200 -0
  15. package/references/composer/references/mathematical-storytelling.md +359 -0
  16. package/references/composer/references/narrative-patterns.md +221 -0
  17. package/references/composer/references/opening-patterns.md +284 -0
  18. package/references/composer/references/pacing-guide.md +289 -0
  19. package/references/composer/references/scene-archetypes.md +534 -0
  20. package/references/composer/references/scene-examples.md +379 -0
  21. package/references/composer/references/visual-techniques.md +480 -0
  22. package/references/composer/templates/scenes-template.md +147 -0
  23. package/references/manimce/SKILL.md +166 -0
  24. package/references/manimce/examples/3d_visualization.py +373 -0
  25. package/references/manimce/examples/basic_animations.py +212 -0
  26. package/references/manimce/examples/graph_plotting.py +401 -0
  27. package/references/manimce/examples/lorenz_attractor.py +172 -0
  28. package/references/manimce/examples/math_visualization.py +315 -0
  29. package/references/manimce/examples/updater_patterns.py +369 -0
  30. package/references/manimce/rules/3b1b-translation.md +594 -0
  31. package/references/manimce/rules/3d.md +254 -0
  32. package/references/manimce/rules/advanced-animations.md +594 -0
  33. package/references/manimce/rules/animation-groups.md +212 -0
  34. package/references/manimce/rules/animations.md +128 -0
  35. package/references/manimce/rules/api-pitfalls.md +89 -0
  36. package/references/manimce/rules/axes.md +214 -0
  37. package/references/manimce/rules/camera.md +208 -0
  38. package/references/manimce/rules/cli.md +232 -0
  39. package/references/manimce/rules/color-conventions.md +444 -0
  40. package/references/manimce/rules/colors.md +199 -0
  41. package/references/manimce/rules/config.md +264 -0
  42. package/references/manimce/rules/creation-animations.md +158 -0
  43. package/references/manimce/rules/graphing.md +233 -0
  44. package/references/manimce/rules/grouping.md +220 -0
  45. package/references/manimce/rules/latex.md +202 -0
  46. package/references/manimce/rules/lines.md +241 -0
  47. package/references/manimce/rules/long-form-video.md +552 -0
  48. package/references/manimce/rules/mathematical-domains.md +689 -0
  49. package/references/manimce/rules/mobjects.md +116 -0
  50. package/references/manimce/rules/multi-scene-composition.md +112 -0
  51. package/references/manimce/rules/pedagogy.md +532 -0
  52. package/references/manimce/rules/physics-simulations.md +610 -0
  53. package/references/manimce/rules/positioning.md +211 -0
  54. package/references/manimce/rules/scenes.md +121 -0
  55. package/references/manimce/rules/shapes.md +300 -0
  56. package/references/manimce/rules/styling.md +177 -0
  57. package/references/manimce/rules/text-animations.md +222 -0
  58. package/references/manimce/rules/text.md +189 -0
  59. package/references/manimce/rules/timing.md +227 -0
  60. package/references/manimce/rules/transform-animations.md +157 -0
  61. package/references/manimce/rules/updaters.md +226 -0
  62. package/references/manimce/templates/basic_scene.py +64 -0
  63. package/references/manimce/templates/camera_scene.py +100 -0
  64. 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
+ };