diff --git a/manim/__main__.py b/manim/__main__.py index d1b0fff204..c90d24bfac 100644 --- a/manim/__main__.py +++ b/manim/__main__.py @@ -7,6 +7,7 @@ from . import __version__, cli_ctx_settings, console from .cli.cfg.group import cfg +from .cli.checkhealth.commands import checkhealth from .cli.default_group import DefaultGroup from .cli.init.commands import init from .cli.new.group import new @@ -48,6 +49,7 @@ def main(ctx): pass +main.add_command(checkhealth) main.add_command(cfg) main.add_command(plugins) main.add_command(init) diff --git a/manim/cli/checkhealth/__init__.py b/manim/cli/checkhealth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manim/cli/checkhealth/checks.py b/manim/cli/checkhealth/checks.py new file mode 100644 index 0000000000..600784337b --- /dev/null +++ b/manim/cli/checkhealth/checks.py @@ -0,0 +1,173 @@ +"""Auxiliary module for the checkhealth subcommand, contains +the actual check implementations.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from typing import Callable + +from ..._config import config + +HEALTH_CHECKS = [] + + +def healthcheck( + description: str, + recommendation: str, + skip_on_failed: list[Callable | str] | None = None, + post_fail_fix_hook: Callable | None = None, +): + """Decorator used for declaring health checks. + + This decorator attaches some data to a function, + which is then added to a list containing all checks. + + Parameters + ---------- + description + A brief description of this check, displayed when + the checkhealth subcommand is run. + recommendation + Help text which is displayed in case the check fails. + skip_on_failed + A list of check functions which, if they fail, cause + the current check to be skipped. + post_fail_fix_hook + A function that is supposed to (interactively) help + to fix the detected problem, if possible. This is + only called upon explicit confirmation of the user. + + Returns + ------- + A check function, as required by the checkhealth subcommand. + """ + if skip_on_failed is None: + skip_on_failed = [] + skip_on_failed = [ + skip.__name__ if callable(skip) else skip for skip in skip_on_failed + ] + + def decorator(func): + func.description = description + func.recommendation = recommendation + func.skip_on_failed = skip_on_failed + func.post_fail_fix_hook = post_fail_fix_hook + HEALTH_CHECKS.append(func) + return func + + return decorator + + +@healthcheck( + description="Checking whether manim is on your PATH", + recommendation=( + "The command is currently not on your system's PATH.\n\n" + "You can work around this by calling the manim module directly " + "via instead of just .\n\n" + "To fix the PATH issue properly: " + "Usually, the Python package installer pip issues a warning " + "during the installation which contains more information. " + "Consider reinstalling manim via " + "followed by to see the warning again, " + "then consult the internet on how to modify your system's " + "PATH variable." + ), +) +def is_manim_on_path(): + path_to_manim = shutil.which("manim") + return path_to_manim is not None + + +@healthcheck( + description="Checking whether the executable belongs to manim", + recommendation=( + "The command does not belong to your installed version " + "of this library, it likely belongs to manimgl / manimlib.\n\n" + "Run manim via or via , or uninstall " + "and reinstall manim via to fix this." + ), + skip_on_failed=[is_manim_on_path], +) +def is_manim_executable_associated_to_this_library(): + path_to_manim = shutil.which("manim") + with open(path_to_manim, "rb") as f: + manim_exec = f.read() + + # first condition below corresponds to the executable being + # some sort of python script. second condition happens when + # the executable is actually a Windows batch file. + return b"manim.__main__" in manim_exec or b'"%~dp0\\manim"' in manim_exec + + +@healthcheck( + description="Checking whether ffmpeg is available", + recommendation=( + "Manim does not work without ffmpeg. Please follow our " + "installation instructions " + "at https://docs.manim.community/en/stable/installation.html " + "to download ffmpeg. Then, either ...\n\n" + "(a) ... make the ffmpeg executable available to your system's PATH,\n" + "(b) or, alternatively, use to create a " + "custom configuration and set the ffmpeg_executable variable to the " + "full absolute path to the ffmpeg executable." + ), +) +def is_ffmpeg_available(): + path_to_ffmpeg = shutil.which(config.ffmpeg_executable) + return path_to_ffmpeg is not None and os.access(path_to_ffmpeg, os.X_OK) + + +@healthcheck( + description="Checking whether ffmpeg is working", + recommendation=( + "Your installed version of ffmpeg does not support x264 encoding, " + "which manim requires. Please follow our installation instructions " + "at https://docs.manim.community/en/stable/installation.html " + "to download and install a newer version of ffmpeg." + ), + skip_on_failed=[is_ffmpeg_available], +) +def is_ffmpeg_working(): + ffmpeg_version = subprocess.run( + [config.ffmpeg_executable, "-version"], + stdout=subprocess.PIPE, + ).stdout.decode() + return ( + ffmpeg_version.startswith("ffmpeg version") + and "--enable-libx264" in ffmpeg_version + ) + + +@healthcheck( + description="Checking whether latex is available", + recommendation=( + "Manim cannot find on your system's PATH. " + "You will not be able to use Tex and MathTex mobjects " + "in your scenes.\n\n" + "Consult our installation instructions " + "at https://docs.manim.community/en/stable/installation.html " + "or search the web for instructions on how to install a " + "LaTeX distribution on your operating system." + ), +) +def is_latex_available(): + path_to_latex = shutil.which("latex") + return path_to_latex is not None and os.access(path_to_latex, os.X_OK) + + +@healthcheck( + description="Checking whether dvisvgm is available", + recommendation=( + "Manim could find , but not on your system's " + "PATH. Make sure your installed LaTeX distribution comes with " + "dvisvgm and consider installing a larger distribution if it " + "does not." + ), + skip_on_failed=[is_latex_available], +) +def is_dvisvgm_available(): + path_to_dvisvgm = shutil.which("dvisvgm") + return path_to_dvisvgm is not None and os.access(path_to_dvisvgm, os.X_OK) diff --git a/manim/cli/checkhealth/commands.py b/manim/cli/checkhealth/commands.py new file mode 100644 index 0000000000..5386f5e7e7 --- /dev/null +++ b/manim/cli/checkhealth/commands.py @@ -0,0 +1,81 @@ +"""A CLI utility helping to diagnose problems with +your Manim installation. + +""" + +from __future__ import annotations + +import sys + +import click +import cloup + +from .checks import HEALTH_CHECKS + + +@cloup.command( + context_settings=None, +) +def checkhealth(): + """This subcommand checks whether Manim is installed correctly + and has access to its required (and optional) system dependencies. + """ + click.echo(f"Python executable: {sys.executable}\n") + click.echo("Checking whether your installation of Manim Community is healthy...") + failed_checks = [] + + for check in HEALTH_CHECKS: + click.echo(f"- {check.description} ... ", nl=False) + if any( + failed_check.__name__ in check.skip_on_failed + for failed_check in failed_checks + ): + click.secho("SKIPPED", fg="blue") + continue + check_result = check() + if check_result: + click.secho("PASSED", fg="green") + else: + click.secho("FAILED", fg="red") + failed_checks.append(check) + + click.echo() + + if failed_checks: + click.echo( + "There are problems with your installation, " + "here are some recommendations to fix them:" + ) + for ind, failed_check in enumerate(failed_checks): + click.echo(failed_check.recommendation) + if ind + 1 < len(failed_checks): + click.confirm("Continue with next recommendation?") + + else: # no problems detected! + click.echo("No problems detected, your installation seems healthy!") + render_test_scene = click.confirm( + "Would you like to render and preview a test scene?" + ) + if render_test_scene: + import manim as mn + + class CheckHealthDemo(mn.Scene): + def construct(self): + banner = mn.ManimBanner().shift(mn.UP * 0.5) + self.play(banner.create()) + self.wait(0.5) + self.play(banner.expand()) + self.wait(0.5) + text_left = mn.Text("All systems operational!") + formula_right = mn.MathTex(r"\oint_{\gamma} f(z)~dz = 0") + text_tex_group = mn.VGroup(text_left, formula_right) + text_tex_group.arrange(mn.RIGHT, buff=1).next_to(banner, mn.DOWN) + self.play(mn.Write(text_tex_group)) + self.wait(0.5) + self.play( + mn.FadeOut(banner, shift=mn.UP), + mn.FadeOut(text_tex_group, shift=mn.DOWN), + ) + + with mn.tempconfig({"preview": True, "disable_caching": True}): + CheckHealthDemo().render() diff --git a/manim/constants.py b/manim/constants.py index d81d85902c..1f6a8684e6 100644 --- a/manim/constants.py +++ b/manim/constants.py @@ -62,6 +62,7 @@ "DEFAULT_POINT_DENSITY_1D", "DEFAULT_STROKE_WIDTH", "DEFAULT_FONT_SIZE", + "SCALE_FACTOR_PER_FONT_POINT", "PI", "TAU", "DEGREES", @@ -183,6 +184,7 @@ DEFAULT_POINT_DENSITY_1D: int = 10 DEFAULT_STROKE_WIDTH: int = 4 DEFAULT_FONT_SIZE: float = 48 +SCALE_FACTOR_PER_FONT_POINT: float = 1 / 960 # Mathematical constants PI: float = np.pi diff --git a/manim/mobject/logo.py b/manim/mobject/logo.py index dcb87bde2e..6242a4c645 100644 --- a/manim/mobject/logo.py +++ b/manim/mobject/logo.py @@ -4,19 +4,100 @@ __all__ = ["ManimBanner"] +import svgelements as se + from manim.animation.updaters.update import UpdateFromAlphaFunc from manim.mobject.geometry.arc import Circle from manim.mobject.geometry.polygram import Square, Triangle -from manim.mobject.text.tex_mobject import MathTex, Tex +from .. import constants as cst from ..animation.animation import override_animation from ..animation.composition import AnimationGroup, Succession from ..animation.creation import Create, SpiralIn from ..animation.fading import FadeIn -from ..constants import DOWN, LEFT, ORIGIN, RIGHT, TAU, UP +from ..mobject.svg.svg_mobject import VMobjectFromSVGPath from ..mobject.types.vectorized_mobject import VGroup -from ..utils.rate_functions import ease_in_out_cubic, ease_out_sine, smooth -from ..utils.tex_templates import TexFontTemplates +from ..utils.rate_functions import ease_in_out_cubic, smooth + +MANIM_SVG_PATHS: list[se.Path] = [ + se.Path( # double stroke letter M + "M4.64259-2.092154L2.739726-6.625156C2.660025-6.824408 2.650062-6.824408 " + "2.381071-6.824408H.52802C.348692-6.824408 .199253-6.824408 .199253-6.645" + "081C.199253-6.475716 .37858-6.475716 .428394-6.475716C.547945-6.475716 ." + "816936-6.455791 1.036115-6.37609V-1.05604C1.036115-.846824 1.036115-.408" + "468 .358655-.348692C.169365-.328767 .169365-.18929 .169365-.179328C.1693" + "65 0 .328767 0 .508095 0H2.052304C2.231631 0 2.381071 0 2.381071-.179328" + "C2.381071-.268991 2.30137-.33873 2.221669-.348692C1.454545-.408468 1.454" + "545-.826899 1.454545-1.05604V-6.017435L1.464508-6.027397L3.895392-.20921" + "5C3.975093-.029888 4.044832 0 4.104608 0C4.224159 0 4.254047-.079701 4.3" + "03861-.199253L6.744707-6.027397L6.75467-6.017435V-1.05604C6.75467-.84682" + "4 6.75467-.408468 6.07721-.348692C5.88792-.328767 5.88792-.18929 5.88792" + "-.179328C5.88792 0 6.047323 0 6.22665 0H8.886675C9.066002 0 9.215442 0 9" + ".215442-.179328C9.215442-.268991 9.135741-.33873 9.05604-.348692C8.28891" + "7-.408468 8.288917-.826899 8.288917-1.05604V-5.768369C8.288917-5.977584 " + "8.288917-6.41594 8.966376-6.475716C9.066002-6.485679 9.155666-6.535492 9" + ".155666-6.645081C9.155666-6.824408 9.006227-6.824408 8.826899-6.824408H6" + ".90411C6.645081-6.824408 6.625156-6.824408 6.535492-6.615193L4.64259-2.0" + "92154ZM4.343711-1.912827C4.423412-1.743462 4.433375-1.733499 4.552927-1." + "693649L4.11457-.637609H4.094645L1.823163-6.057285C1.77335-6.1868 1.69364" + "9-6.356164 1.554172-6.475716H2.420922L4.343711-1.912827ZM1.334994-.34869" + "2H1.165629C1.185554-.37858 1.205479-.408468 1.225405-.428394C1.235367-.4" + "38356 1.235367-.448319 1.24533-.458281L1.334994-.348692ZM7.103362-6.4757" + "16H8.159402C7.940224-6.22665 7.940224-5.967621 7.940224-5.788294V-1.0361" + "15C7.940224-.856787 7.940224-.597758 8.169365-.348692H6.884184C7.103362-" + ".597758 7.103362-.856787 7.103362-1.036115V-6.475716Z" + ), + se.Path( # letter a + "M1.464508-4.024907C1.464508-4.234122 1.743462-4.393524 2.092154-4.393524" + "C2.669988-4.393524 2.929016-4.124533 2.929016-3.516812V-2.789539C1.77335" + "-2.440847 .249066-2.042341 .249066-.916563C.249066-.308842 .71731 .13947" + "7 1.354919 .139477C1.92279 .139477 2.381071-.059776 2.929016-.557908C3.0" + "38605-.049813 3.257783 .139477 3.745953 .139477C4.174346 .139477 4.48318" + "8-.019925 4.861768-.428394L4.712329-.637609L4.612702-.537983C4.582814-.5" + "08095 4.552927-.498132 4.503113-.498132C4.363636-.498132 4.293898-.58779" + "6 4.293898-.747198V-3.347447C4.293898-4.184309 3.536737-4.712329 2.32129" + "5-4.712329C1.195517-4.712329 .438356-4.204234 .438356-3.457036C.438356-3" + ".048568 .67746-2.799502 1.085928-2.799502C1.484433-2.799502 1.763387-3.0" + "38605 1.763387-3.377335C1.763387-3.676214 1.464508-3.88543 1.464508-4.02" + "4907ZM2.919054-.996264C2.650062-.687422 2.450809-.56787 2.211706-.56787C" + "1.912827-.56787 1.703611-.836862 1.703611-1.235367C1.703611-1.8132 2.122" + "042-2.231631 2.919054-2.440847V-.996264Z" + ), + se.Path( # letter n + "M2.948941-4.044832C3.297634-4.044832 3.466999-3.775841 3.466999-3.217933" + "V-.806974C3.466999-.438356 3.337484-.278954 2.998755-.239103V0H5.339975V" + "-.239103C4.951432-.268991 4.851806-.388543 4.851806-.806974V-3.307597C4." + "851806-4.164384 4.323786-4.712329 3.506849-4.712329C2.909091-4.712329 2." + "450809-4.433375 2.082192-3.845579V-4.592777H.179328V-4.353674C.617684-4." + "283935 .707347-4.184309 .707347-3.765878V-.836862C.707347-.418431 .62764" + "6-.328767 .179328-.239103V0H2.580324V-.239103C2.211706-.288917 2.092154-" + ".438356 2.092154-.806974V-3.466999C2.092154-3.576588 2.530511-4.044832 2" + ".948941-4.044832Z" + ), + se.Path( # letter i + "M2.15193-4.592777H.239103V-4.353674C.67746-4.26401 .767123-4.174346 .767" + "123-3.765878V-.836862C.767123-.428394 .697385-.348692 .239103-.239103V0H" + "2.6401V-.239103C2.291407-.288917 2.15193-.428394 2.15193-.806974V-4.5927" + "77ZM1.454545-6.884184C1.026152-6.884184 .67746-6.535492 .67746-6.117061C" + ".67746-5.668742 1.006227-5.339975 1.444583-5.339975S2.221669-5.668742 2." + "221669-6.107098C2.221669-6.535492 1.882939-6.884184 1.454545-6.884184Z" + ), + se.Path( # letter m + "M2.929016-4.044832C3.317559-4.044832 3.466999-3.815691 3.466999-3.217933" + "V-.806974C3.466999-.398506 3.35741-.268991 2.988792-.239103V0H5.32005V-." + "239103C4.971357-.278954 4.851806-.428394 4.851806-.806974V-3.466999C4.85" + "1806-3.576588 5.310087-4.044832 5.69863-4.044832C6.07721-4.044832 6.2266" + "5-3.805729 6.22665-3.217933V-.806974C6.22665-.388543 6.117061-.268991 5." + "738481-.239103V0H8.109589V-.239103C7.721046-.259029 7.611457-.37858 7.61" + "1457-.806974V-3.307597C7.611457-4.164384 7.083437-4.712329 6.266501-4.71" + "2329C5.69863-4.712329 5.32005-4.483188 4.801993-3.845579C4.503113-4.4732" + "25 4.154421-4.712329 3.526775-4.712329S2.440847-4.443337 2.062267-3.8455" + "79V-4.592777H.179328V-4.353674C.617684-4.293898 .707347-4.174346 .707347" + "-3.765878V-.836862C.707347-.428394 .617684-.318804 .179328-.239103V0H2.5" + "50436V-.239103C2.201743-.288917 2.092154-.428394 2.092154-.806974V-3.466" + "999C2.092154-3.58655 2.530511-4.044832 2.929016-4.044832Z" + ), +] class ManimBanner(VGroup): @@ -67,27 +148,32 @@ def __init__(self, dark_theme: bool = True): self.font_color = "#ece6e2" if dark_theme else "#343434" self.scale_factor = 1 - self.M = MathTex(r"\mathbb{M}").scale(7).set_color(self.font_color) - self.M.shift(2.25 * LEFT + 1.5 * UP) + self.M = VMobjectFromSVGPath(MANIM_SVG_PATHS[0]).flip(cst.RIGHT).center() + self.M.set(stroke_width=0).scale( + 7 * cst.DEFAULT_FONT_SIZE * cst.SCALE_FACTOR_PER_FONT_POINT + ) + self.M.set_fill(color=self.font_color, opacity=1).shift( + 2.25 * cst.LEFT + 1.5 * cst.UP + ) - self.circle = Circle(color=logo_green, fill_opacity=1).shift(LEFT) - self.square = Square(color=logo_blue, fill_opacity=1).shift(UP) - self.triangle = Triangle(color=logo_red, fill_opacity=1).shift(RIGHT) + self.circle = Circle(color=logo_green, fill_opacity=1).shift(cst.LEFT) + self.square = Square(color=logo_blue, fill_opacity=1).shift(cst.UP) + self.triangle = Triangle(color=logo_red, fill_opacity=1).shift(cst.RIGHT) self.shapes = VGroup(self.triangle, self.square, self.circle) self.add(self.shapes, self.M) - self.move_to(ORIGIN) + self.move_to(cst.ORIGIN) anim = VGroup() - for i, ch in enumerate("anim"): - tex = Tex( - "\\textbf{" + ch + "}", - tex_template=TexFontTemplates.gnu_freeserif_freesans, + for ind, path in enumerate(MANIM_SVG_PATHS[1:]): + tex = VMobjectFromSVGPath(path).flip(cst.RIGHT).center() + tex.set(stroke_width=0).scale( + cst.DEFAULT_FONT_SIZE * cst.SCALE_FACTOR_PER_FONT_POINT ) - if i != 0: + if ind > 0: tex.next_to(anim, buff=0.01) - tex.align_to(self.M, DOWN) + tex.align_to(self.M, cst.DOWN) anim.add(tex) - anim.set_color(self.font_color) + anim.set_fill(color=self.font_color, opacity=1) anim.height = m_height_over_anim_height * self.M.height # Note: "anim" is only shown in the expanded state @@ -181,7 +267,7 @@ def construct(self): m_shape_offset = 6.25 * self.scale_factor shape_sliding_overshoot = self.scale_factor * 0.8 m_anim_buff = 0.06 - self.anim.next_to(self.M, buff=m_anim_buff).align_to(self.M, DOWN) + self.anim.next_to(self.M, buff=m_anim_buff).align_to(self.M, cst.DOWN) self.anim.set_opacity(0) self.shapes.save_state() m_clone = self.anim[-1].copy() @@ -193,7 +279,7 @@ def construct(self): def shift(vector): self.shapes.restore() - left_group.align_to(self.M.saved_state, LEFT) + left_group.align_to(self.M.saved_state, cst.LEFT) if direction == "right": self.shapes.shift(vector) elif direction == "center": @@ -203,7 +289,7 @@ def shift(vector): left_group.shift(-vector) def slide_and_uncover(mob, alpha): - shift(alpha * (m_shape_offset + shape_sliding_overshoot) * RIGHT) + shift(alpha * (m_shape_offset + shape_sliding_overshoot) * cst.RIGHT) # Add letters when they are covered for letter in mob.anim: @@ -225,7 +311,7 @@ def slide_back(mob, alpha): m_clone.move_to(mob.anim[-1]) mob.anim.set_opacity(1) - shift(alpha * shape_sliding_overshoot * LEFT) + shift(alpha * shape_sliding_overshoot * cst.LEFT) if alpha == 1: mob.remove(m_clone) diff --git a/manim/mobject/text/tex_mobject.py b/manim/mobject/text/tex_mobject.py index b92b5459e5..e265642ff0 100644 --- a/manim/mobject/text/tex_mobject.py +++ b/manim/mobject/text/tex_mobject.py @@ -38,8 +38,6 @@ from manim.utils.tex import TexTemplate from manim.utils.tex_file_writing import tex_to_svg_file -SCALE_FACTOR_PER_FONT_POINT = 1 / 960 - tex_string_to_mob_map = {} diff --git a/tests/interface/test_commands.py b/tests/interface/test_commands.py index 5f6f0dcb75..995daf2c37 100644 --- a/tests/interface/test_commands.py +++ b/tests/interface/test_commands.py @@ -1,13 +1,15 @@ from __future__ import annotations +import shutil import sys from pathlib import Path from textwrap import dedent from click.testing import CliRunner -from manim import __version__, capture +from manim import __version__, capture, tempconfig from manim.__main__ import main +from manim.cli.checkhealth.checks import HEALTH_CHECKS def test_manim_version(): @@ -62,6 +64,28 @@ def test_manim_plugins_subcommand(): assert dedent(expected_output) == result.output +def test_manim_checkhealth_subcommand(): + command = ["checkhealth"] + runner = CliRunner() + result = runner.invoke(main, command) + output_lines = result.output.split("\n") + num_passed = len([line for line in output_lines if "PASSED" in line]) + assert num_passed == len( + HEALTH_CHECKS + ), f"Some checks failed! Full output:\n{result.output}" + assert "No problems detected, your installation seems healthy!" in output_lines + + +def test_manim_checkhealth_failing_subcommand(): + command = ["checkhealth"] + runner = CliRunner() + with tempconfig({"ffmpeg_executable": "/path/to/nowhere"}): + result = runner.invoke(main, command) + output_lines = result.output.split("\n") + assert "- Checking whether ffmpeg is available ... FAILED" in output_lines + assert "- Checking whether ffmpeg is working ... SKIPPED" in output_lines + + def test_manim_init_subcommand(): command = ["init"] runner = CliRunner()