config.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. from __future__ import annotations
  2. import argparse
  3. from argparse import Namespace
  4. import colour
  5. import importlib
  6. import inspect
  7. import os
  8. import screeninfo
  9. import sys
  10. import yaml
  11. from manimlib.logger import log
  12. from manimlib.utils.dict_ops import merge_dicts_recursively
  13. from manimlib.utils.init_config import init_customization
  14. from typing import TYPE_CHECKING
  15. if TYPE_CHECKING:
  16. Module = importlib.util.types.ModuleType
  17. __config_file__ = "custom_config.yml"
  18. def parse_cli():
  19. try:
  20. parser = argparse.ArgumentParser()
  21. module_location = parser.add_mutually_exclusive_group()
  22. module_location.add_argument(
  23. "file",
  24. nargs="?",
  25. help="Path to file holding the python code for the scene",
  26. )
  27. parser.add_argument(
  28. "scene_names",
  29. nargs="*",
  30. help="Name of the Scene class you want to see",
  31. )
  32. parser.add_argument(
  33. "-w", "--write_file",
  34. action="store_true",
  35. help="Render the scene as a movie file",
  36. )
  37. parser.add_argument(
  38. "-s", "--skip_animations",
  39. action="store_true",
  40. help="Save the last frame",
  41. )
  42. parser.add_argument(
  43. "-l", "--low_quality",
  44. action="store_true",
  45. help="Render at a low quality (for faster rendering)",
  46. )
  47. parser.add_argument(
  48. "-m", "--medium_quality",
  49. action="store_true",
  50. help="Render at a medium quality",
  51. )
  52. parser.add_argument(
  53. "--hd",
  54. action="store_true",
  55. help="Render at a 1080p",
  56. )
  57. parser.add_argument(
  58. "--uhd",
  59. action="store_true",
  60. help="Render at a 4k",
  61. )
  62. parser.add_argument(
  63. "-f", "--full_screen",
  64. action="store_true",
  65. help="Show window in full screen",
  66. )
  67. parser.add_argument(
  68. "-p", "--presenter_mode",
  69. action="store_true",
  70. help="Scene will stay paused during wait calls until " + \
  71. "space bar or right arrow is hit, like a slide show"
  72. )
  73. parser.add_argument(
  74. "-g", "--save_pngs",
  75. action="store_true",
  76. help="Save each frame as a png",
  77. )
  78. parser.add_argument(
  79. "-i", "--gif",
  80. action="store_true",
  81. help="Save the video as gif",
  82. )
  83. parser.add_argument(
  84. "-t", "--transparent",
  85. action="store_true",
  86. help="Render to a movie file with an alpha channel",
  87. )
  88. parser.add_argument(
  89. "--vcodec",
  90. help="Video codec to use with ffmpeg",
  91. )
  92. parser.add_argument(
  93. "--pix_fmt",
  94. help="Pixel format to use for the output of ffmpeg, defaults to `yuv420p`",
  95. )
  96. parser.add_argument(
  97. "-q", "--quiet",
  98. action="store_true",
  99. help="",
  100. )
  101. parser.add_argument(
  102. "-a", "--write_all",
  103. action="store_true",
  104. help="Write all the scenes from a file",
  105. )
  106. parser.add_argument(
  107. "-o", "--open",
  108. action="store_true",
  109. help="Automatically open the saved file once its done",
  110. )
  111. parser.add_argument(
  112. "--finder",
  113. action="store_true",
  114. help="Show the output file in finder",
  115. )
  116. parser.add_argument(
  117. "--config",
  118. action="store_true",
  119. help="Guide for automatic configuration",
  120. )
  121. parser.add_argument(
  122. "--file_name",
  123. help="Name for the movie or image file",
  124. )
  125. parser.add_argument(
  126. "-n", "--start_at_animation_number",
  127. help="Start rendering not from the first animation, but " + \
  128. "from another, specified by its index. If you pass " + \
  129. "in two comma separated values, e.g. \"3,6\", it will end " + \
  130. "the rendering at the second value",
  131. )
  132. parser.add_argument(
  133. "-e", "--embed",
  134. nargs="?",
  135. const="",
  136. help="Creates a new file where the line `self.embed` is inserted " + \
  137. "into the Scenes construct method. " + \
  138. "If a string is passed in, the line will be inserted below the " + \
  139. "last line of code including that string."
  140. )
  141. parser.add_argument(
  142. "-r", "--resolution",
  143. help="Resolution, passed as \"WxH\", e.g. \"1920x1080\"",
  144. )
  145. parser.add_argument(
  146. "--fps",
  147. help="Frame rate, as an integer",
  148. )
  149. parser.add_argument(
  150. "-c", "--color",
  151. help="Background color",
  152. )
  153. parser.add_argument(
  154. "--leave_progress_bars",
  155. action="store_true",
  156. help="Leave progress bars displayed in terminal",
  157. )
  158. parser.add_argument(
  159. "--show_animation_progress",
  160. action="store_true",
  161. help="Show progress bar for each animation",
  162. )
  163. parser.add_argument(
  164. "--prerun",
  165. action="store_true",
  166. help="Calculate total framecount, to display in a progress bar, by doing " + \
  167. "an initial run of the scene which skips animations."
  168. )
  169. parser.add_argument(
  170. "--video_dir",
  171. help="Directory to write video",
  172. )
  173. parser.add_argument(
  174. "--config_file",
  175. help="Path to the custom configuration file",
  176. )
  177. parser.add_argument(
  178. "-v", "--version",
  179. action="store_true",
  180. help="Display the version of manimgl"
  181. )
  182. parser.add_argument(
  183. "--log-level",
  184. help="Level of messages to Display, can be DEBUG / INFO / WARNING / ERROR / CRITICAL"
  185. )
  186. args = parser.parse_args()
  187. args.write_file = any([args.write_file, args.open, args.finder])
  188. return args
  189. except argparse.ArgumentError as err:
  190. log.error(str(err))
  191. sys.exit(2)
  192. def get_manim_dir():
  193. manimlib_module = importlib.import_module("manimlib")
  194. manimlib_dir = os.path.dirname(inspect.getabsfile(manimlib_module))
  195. return os.path.abspath(os.path.join(manimlib_dir, ".."))
  196. def get_module(file_name: str | None) -> Module:
  197. if file_name is None:
  198. return None
  199. module_name = file_name.replace(os.sep, ".").replace(".py", "")
  200. spec = importlib.util.spec_from_file_location(module_name, file_name)
  201. module = importlib.util.module_from_spec(spec)
  202. spec.loader.exec_module(module)
  203. return module
  204. def get_indent(line: str):
  205. return len(line) - len(line.lstrip())
  206. def get_module_with_inserted_embed_line(
  207. file_name: str, scene_name: str, line_marker: str
  208. ):
  209. """
  210. This is hacky, but convenient. When user includes the argument "-e", it will try
  211. to recreate a file that inserts the line `self.embed()` into the end of the scene's
  212. construct method. If there is an argument passed in, it will insert the line after
  213. the last line in the sourcefile which includes that string.
  214. """
  215. with open(file_name, 'r') as fp:
  216. lines = fp.readlines()
  217. try:
  218. scene_line_number = next(
  219. i for i, line in enumerate(lines)
  220. if line.startswith(f"class {scene_name}")
  221. )
  222. except StopIteration:
  223. log.error(f"No scene {scene_name}")
  224. return
  225. prev_line_num = -1
  226. n_spaces = None
  227. if len(line_marker) == 0:
  228. # Find the end of the construct method
  229. in_construct = False
  230. for index in range(scene_line_number, len(lines) - 1):
  231. line = lines[index]
  232. if line.lstrip().startswith("def construct"):
  233. in_construct = True
  234. n_spaces = get_indent(line) + 4
  235. elif in_construct:
  236. if len(line.strip()) > 0 and get_indent(line) < (n_spaces or 0):
  237. prev_line_num = index - 1
  238. break
  239. if prev_line_num < 0:
  240. prev_line_num = len(lines) - 1
  241. elif line_marker.isdigit():
  242. # Treat the argument as a line number
  243. prev_line_num = int(line_marker) - 1
  244. elif len(line_marker) > 0:
  245. # Treat the argument as a string
  246. try:
  247. prev_line_num = next(
  248. i
  249. for i in range(scene_line_number, len(lines) - 1)
  250. if line_marker in lines[i]
  251. )
  252. except StopIteration:
  253. log.error(f"No lines matching {line_marker}")
  254. sys.exit(2)
  255. # Insert the embed line, rewrite file, then write it back when done
  256. if n_spaces is None:
  257. n_spaces = get_indent(lines[prev_line_num])
  258. inserted_line = " " * n_spaces + "self.embed()\n"
  259. new_lines = list(lines)
  260. new_lines.insert(prev_line_num + 1, inserted_line)
  261. new_file = file_name.replace(".py", "_insert_embed.py")
  262. with open(new_file, 'w') as fp:
  263. fp.writelines(new_lines)
  264. module = get_module(new_file)
  265. # This is to pretend the module imported from the edited lines
  266. # of code actually comes from the original file.
  267. module.__file__ = file_name
  268. os.remove(new_file)
  269. return module
  270. def get_scene_module(args: Namespace) -> Module:
  271. if args.embed is None:
  272. return get_module(args.file)
  273. else:
  274. return get_module_with_inserted_embed_line(
  275. args.file, args.scene_names[0], args.embed
  276. )
  277. def get_custom_config():
  278. global __config_file__
  279. global_defaults_file = os.path.join(get_manim_dir(), "manimlib", "default_config.yml")
  280. if os.path.exists(global_defaults_file):
  281. with open(global_defaults_file, "r") as file:
  282. custom_config = yaml.safe_load(file)
  283. if os.path.exists(__config_file__):
  284. with open(__config_file__, "r") as file:
  285. local_defaults = yaml.safe_load(file)
  286. if local_defaults:
  287. custom_config = merge_dicts_recursively(
  288. custom_config,
  289. local_defaults,
  290. )
  291. else:
  292. with open(__config_file__, "r") as file:
  293. custom_config = yaml.safe_load(file)
  294. # Check temporary storage(custom_config)
  295. if custom_config["directories"]["temporary_storage"] == "" and sys.platform == "win32":
  296. log.warning(
  297. "You may be using Windows platform and have not specified the path of" + \
  298. " `temporary_storage`, which may cause OSError. So it is recommended" + \
  299. " to specify the `temporary_storage` in the config file (.yml)"
  300. )
  301. return custom_config
  302. def init_global_config(config_file):
  303. global __config_file__
  304. # ensure __config_file__ always exists
  305. if config_file is not None:
  306. if not os.path.exists(config_file):
  307. log.error(f"Can't find {config_file}.")
  308. if sys.platform == 'win32':
  309. log.info(f"Copying default configuration file to {config_file}...")
  310. os.system(f"copy default_config.yml {config_file}")
  311. elif sys.platform in ["linux2", "darwin"]:
  312. log.info(f"Copying default configuration file to {config_file}...")
  313. os.system(f"cp default_config.yml {config_file}")
  314. else:
  315. log.info("Please create the configuration file manually.")
  316. log.info("Read configuration from default_config.yml.")
  317. else:
  318. __config_file__ = config_file
  319. global_defaults_file = os.path.join(get_manim_dir(), "manimlib", "default_config.yml")
  320. if not (os.path.exists(global_defaults_file) or os.path.exists(__config_file__)):
  321. log.info("There is no configuration file detected. Switch to the config file initializer:")
  322. init_customization()
  323. elif not os.path.exists(__config_file__):
  324. log.info(f"Using the default configuration file, which you can modify in `{global_defaults_file}`")
  325. log.info(
  326. "If you want to create a local configuration file, you can create a file named" + \
  327. f" `{__config_file__}`, or run `manimgl --config`"
  328. )
  329. def get_file_ext(args: Namespace) -> str:
  330. if args.transparent:
  331. file_ext = ".mov"
  332. elif args.gif:
  333. file_ext = ".gif"
  334. else:
  335. file_ext = ".mp4"
  336. return file_ext
  337. def get_animations_numbers(args: Namespace) -> tuple[int | None, int | None]:
  338. stan = args.start_at_animation_number
  339. if stan is None:
  340. return (None, None)
  341. elif "," in stan:
  342. return tuple(map(int, stan.split(",")))
  343. else:
  344. return int(stan), None
  345. def get_output_directory(args: Namespace, custom_config: dict) -> str:
  346. dir_config = custom_config["directories"]
  347. output_directory = args.video_dir or dir_config["output"]
  348. if dir_config["mirror_module_path"] and args.file:
  349. to_cut = dir_config["removed_mirror_prefix"]
  350. ext = os.path.abspath(args.file)
  351. ext = ext.replace(to_cut, "").replace(".py", "")
  352. if ext.startswith("_"):
  353. ext = ext[1:]
  354. output_directory = os.path.join(output_directory, ext)
  355. return output_directory
  356. def get_file_writer_config(args: Namespace, custom_config: dict) -> dict:
  357. result = {
  358. "write_to_movie": not args.skip_animations and args.write_file,
  359. "save_last_frame": args.skip_animations and args.write_file,
  360. "save_pngs": args.save_pngs,
  361. # If -t is passed in (for transparent), this will be RGBA
  362. "png_mode": "RGBA" if args.transparent else "RGB",
  363. "movie_file_extension": get_file_ext(args),
  364. "output_directory": get_output_directory(args, custom_config),
  365. "file_name": args.file_name,
  366. "input_file_path": args.file or "",
  367. "open_file_upon_completion": args.open,
  368. "show_file_location_upon_completion": args.finder,
  369. "quiet": args.quiet,
  370. **custom_config["file_writer_config"],
  371. }
  372. if args.vcodec:
  373. result["video_codec"] = args.vcodec
  374. elif args.transparent:
  375. result["video_codec"] = 'prores_ks'
  376. result["pixel_format"] = ''
  377. elif args.gif:
  378. result["video_codec"] = ''
  379. if args.pix_fmt:
  380. result["pixel_format"] = args.pix_fmt
  381. return result
  382. def get_window_config(args: Namespace, custom_config: dict, camera_config: dict) -> dict:
  383. # Default to making window half the screen size
  384. # but make it full screen if -f is passed in
  385. try:
  386. monitors = screeninfo.get_monitors()
  387. except screeninfo.ScreenInfoError:
  388. pass
  389. mon_index = custom_config["window_monitor"]
  390. monitor = monitors[min(mon_index, len(monitors) - 1)]
  391. aspect_ratio = camera_config["pixel_width"] / camera_config["pixel_height"]
  392. window_width = monitor.width
  393. if not (args.full_screen or custom_config["full_screen"]):
  394. window_width //= 2
  395. window_height = int(window_width / aspect_ratio)
  396. return dict(size=(window_width, window_height))
  397. def get_camera_config(args: Namespace, custom_config: dict) -> dict:
  398. camera_config = {}
  399. camera_resolutions = custom_config["camera_resolutions"]
  400. if args.resolution:
  401. resolution = args.resolution
  402. elif args.low_quality:
  403. resolution = camera_resolutions["low"]
  404. elif args.medium_quality:
  405. resolution = camera_resolutions["med"]
  406. elif args.hd:
  407. resolution = camera_resolutions["high"]
  408. elif args.uhd:
  409. resolution = camera_resolutions["4k"]
  410. else:
  411. resolution = camera_resolutions[camera_resolutions["default_resolution"]]
  412. if args.fps:
  413. fps = int(args.fps)
  414. else:
  415. fps = custom_config["fps"]
  416. width_str, height_str = resolution.split("x")
  417. width = int(width_str)
  418. height = int(height_str)
  419. camera_config.update({
  420. "pixel_width": width,
  421. "pixel_height": height,
  422. "frame_config": {
  423. "frame_shape": ((width / height) * get_frame_height(), get_frame_height()),
  424. },
  425. "fps": fps,
  426. })
  427. try:
  428. bg_color = args.color or custom_config["style"]["background_color"]
  429. camera_config["background_color"] = colour.Color(bg_color)
  430. except ValueError as err:
  431. log.error("Please use a valid color")
  432. log.error(err)
  433. sys.exit(2)
  434. # If rendering a transparent image/move, make sure the
  435. # scene has a background opacity of 0
  436. if args.transparent:
  437. camera_config["background_opacity"] = 0
  438. return camera_config
  439. def get_configuration(args: Namespace) -> dict:
  440. init_global_config(args.config_file)
  441. custom_config = get_custom_config()
  442. camera_config = get_camera_config(args, custom_config)
  443. window_config = get_window_config(args, custom_config, camera_config)
  444. start, end = get_animations_numbers(args)
  445. return {
  446. "module": get_scene_module(args),
  447. "scene_names": args.scene_names,
  448. "file_writer_config": get_file_writer_config(args, custom_config),
  449. "camera_config": camera_config,
  450. "window_config": window_config,
  451. "quiet": args.quiet or args.write_all,
  452. "write_all": args.write_all,
  453. "skip_animations": args.skip_animations,
  454. "start_at_animation_number": start,
  455. "end_at_animation_number": end,
  456. "preview": not args.write_file,
  457. "presenter_mode": args.presenter_mode,
  458. "leave_progress_bars": args.leave_progress_bars,
  459. "show_animation_progress": args.show_animation_progress,
  460. "prerun": args.prerun,
  461. "embed_exception_mode": custom_config["embed_exception_mode"],
  462. "embed_error_sound": custom_config["embed_error_sound"],
  463. }
  464. def get_frame_height():
  465. return 8.0
  466. def get_aspect_ratio():
  467. cam_config = get_camera_config(parse_cli(), get_custom_config())
  468. return cam_config['pixel_width'] / cam_config['pixel_height']
  469. def get_default_pixel_width():
  470. cam_config = get_camera_config(parse_cli(), get_custom_config())
  471. return cam_config['pixel_width']
  472. def get_default_pixel_height():
  473. cam_config = get_camera_config(parse_cli(), get_custom_config())
  474. return cam_config['pixel_height']