mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-21 20:46:01 +08:00
feat(cli): add convert option to generate html presentations (#66)
* wip(cli): convert slides to html using RevealJS * wip: convert - almost fully working * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: remove unused file * fix: add last slides in now performed during rendering * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * chore(ci): testing ConvertExample too * fix: ManimGL does not consider wait as an animation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
4
.github/workflows/pages.yml
vendored
4
.github/workflows/pages.yml
vendored
@ -42,6 +42,10 @@ jobs:
|
||||
run: pip install manim sphinx sphinx_click furo
|
||||
- name: Install local Python package
|
||||
run: pip install -e .
|
||||
- name: Build animation and convert it into HTML slides
|
||||
run: |
|
||||
manim example.py ConvertExample
|
||||
manim-slides convert ConvertExample docs/source/_static/slides.html -cembedded -ccontrols=true
|
||||
- name: Build docs
|
||||
run: cd docs && make html
|
||||
- name: Upload artifact
|
||||
|
10
.github/workflows/test_examples.yml
vendored
10
.github/workflows/test_examples.yml
vendored
@ -89,16 +89,16 @@ jobs:
|
||||
run: pip3 install -e .
|
||||
- name: Build slides with manim
|
||||
if: matrix.manim == 'manim'
|
||||
run: python -m manim -ql example.py Example ThreeDExample
|
||||
run: python -m manim -ql example.py Example ThreeDExample ConvertExample
|
||||
- name: Build slides with manimgl on Ubuntu
|
||||
if: matrix.manim == 'manimgl' && matrix.os == 'ubuntu-latest'
|
||||
run: xvfb-run -a -s "-screen 0 1400x900x24" manim-render -l example.py Example ThreeDExample
|
||||
run: xvfb-run -a -s "-screen 0 1400x900x24" manim-render -l example.py Example ThreeDExample ConvertExample
|
||||
- name: Build slides with manimgl on MacOS or Windows
|
||||
if: matrix.manim == 'manimgl' && (matrix.os == 'macos-latest' || matrix.os == 'windows-latest')
|
||||
run: manimgl -l example.py Example ThreeDExample
|
||||
run: manimgl -l example.py Example ThreeDExample ConvertExample
|
||||
- name: Test slides on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: xvfb-run -a -s "-screen 0 1400x900x24" manim-slides Example ThreeDExample --skip-all
|
||||
run: xvfb-run -a -s "-screen 0 1400x900x24" manim-slides Example ThreeDExample ConvertExample --skip-all
|
||||
- name: Test slides on MacOS or Windows
|
||||
if: matrix.os == 'macos-latest' || matrix.os == 'windows-latest'
|
||||
run: manim-slides Example ThreeDExample --skip-all
|
||||
run: manim-slides Example ThreeDExample ConvertExample --skip-all
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -19,3 +19,7 @@ videos/
|
||||
images/
|
||||
|
||||
docs/build/
|
||||
|
||||
docs/source/_static/slides_assets/
|
||||
|
||||
docs/source/_static/slides.html
|
||||
|
@ -4,13 +4,21 @@
|
||||
contain the root `toctree` directive.
|
||||
|
||||
.. image:: _static/logo.png
|
||||
:width: 600
|
||||
:width: 600px
|
||||
:align: center
|
||||
:alt: Manim Slide logo
|
||||
|
||||
Welcome to Manim Slide's CLI documentation!
|
||||
===========================================
|
||||
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<!-- From: https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page -->
|
||||
|
||||
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="_static/slides.html"></iframe></div>
|
||||
|
||||
|
||||
This page contains an exhaustive list of all the commands available with `manim-slides`.
|
||||
|
||||
If you need help installing or using Manim Slide, please refer to the `GitHub README <https://github.com/jeertmans/manim-slides>`_.
|
||||
|
213
example.py
213
example.py
@ -42,6 +42,219 @@ class Example(Slide):
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
||||
|
||||
class ConvertExample(Slide):
|
||||
def tinywait(self):
|
||||
self.wait(0.1)
|
||||
|
||||
def construct(self):
|
||||
|
||||
title = VGroup(
|
||||
Text("From Manim animations", t2c={"From": BLUE}),
|
||||
Text("to slides presentation", t2c={"to": BLUE}),
|
||||
Text("with Manim Slides", t2w={"[-12:]": BOLD}, t2c={"[-13:]": YELLOW}),
|
||||
).arrange(DOWN)
|
||||
|
||||
step_1 = Text("1. In your scenes file, import Manim Slides")
|
||||
step_2 = Text("2. Replace Scene with Slide")
|
||||
step_3 = Text("3. In construct, add pauses where you need")
|
||||
step_4 = Text("4. You can also create loops")
|
||||
step_5 = Text("5. Render you scene with Manim")
|
||||
step_6 = Text("6. Open your presentation with Manim Slides")
|
||||
|
||||
for step in [step_1, step_2, step_3, step_4, step_5, step_6]:
|
||||
step.scale(0.5).to_corner(UL)
|
||||
|
||||
step = step_1
|
||||
|
||||
self.play(FadeIn(title))
|
||||
|
||||
self.pause()
|
||||
|
||||
code = Code(
|
||||
code="""from manim import *
|
||||
|
||||
|
||||
class Example(Scene):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
)
|
||||
|
||||
code_step_1 = Code(
|
||||
code="""from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Scene):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
)
|
||||
|
||||
code_step_2 = Code(
|
||||
code="""from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
)
|
||||
|
||||
code_step_3 = Code(
|
||||
code="""from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
self.pause()
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
self.pause()
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
)
|
||||
|
||||
code_step_4 = Code(
|
||||
code="""from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
self.start_loop()
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
self.end_loop()
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
self.pause()
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
)
|
||||
|
||||
code_step_5 = Code(
|
||||
code="manim example.py Example",
|
||||
language="console",
|
||||
)
|
||||
|
||||
code_step_6 = Code(
|
||||
code="manim-slides Example",
|
||||
language="console",
|
||||
)
|
||||
|
||||
or_text = Text("or generate HTML presentation").scale(0.5)
|
||||
|
||||
code_step_7 = Code(
|
||||
code="manim-slides convert Example slides.html --open",
|
||||
language="console",
|
||||
).shift(DOWN)
|
||||
|
||||
self.clear()
|
||||
|
||||
self.play(FadeIn(code))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(FadeIn(step, shift=RIGHT))
|
||||
self.play(Transform(code, code_step_1))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(Transform(step, step_2))
|
||||
self.play(Transform(code, code_step_2))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(Transform(step, step_3))
|
||||
self.play(Transform(code, code_step_3))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(Transform(step, step_4))
|
||||
self.play(Transform(code, code_step_4))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(Transform(step, step_5))
|
||||
self.play(Transform(code, code_step_5))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(Transform(step, step_6))
|
||||
self.play(Transform(code, code_step_6))
|
||||
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
|
||||
|
||||
self.start_loop()
|
||||
self.play(FadeIn(watch_text))
|
||||
self.play(FadeOut(watch_text))
|
||||
self.end_loop()
|
||||
self.clear()
|
||||
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
self.start_loop()
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
self.end_loop()
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
self.remove(dot)
|
||||
self.add(square)
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.play(Rotate(square, angle=PI / 4))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
learn_more_text = (
|
||||
VGroup(
|
||||
Text("Learn more about Manim Slides:"),
|
||||
Text("https://github.com/jeertmans/manim-slides", color=YELLOW),
|
||||
)
|
||||
.arrange(DOWN)
|
||||
.scale(0.75)
|
||||
)
|
||||
|
||||
self.play(Transform(square, learn_more_text))
|
||||
self.tinywait()
|
||||
|
||||
|
||||
# For ThreeDExample, things are different
|
||||
|
||||
if not MANIMGL:
|
||||
|
@ -3,7 +3,7 @@ from typing import Callable
|
||||
import click
|
||||
from click import Context, Parameter
|
||||
|
||||
from .defaults import CONFIG_PATH
|
||||
from .defaults import CONFIG_PATH, FOLDER_PATH
|
||||
from .manim import logger
|
||||
|
||||
|
||||
@ -60,3 +60,15 @@ def verbosity_option(function: Callable) -> Callable:
|
||||
show_envvar=True,
|
||||
callback=callback,
|
||||
)(function)
|
||||
|
||||
|
||||
def folder_path_option(function: Callable) -> Callable:
|
||||
"""Wraps a function to add folder path option."""
|
||||
return click.option(
|
||||
"--folder",
|
||||
metavar="DIRECTORY",
|
||||
default=FOLDER_PATH,
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
help="Set slides folder.",
|
||||
show_default=True,
|
||||
)(function)
|
||||
|
@ -1,11 +1,27 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from pydantic import BaseModel, root_validator, validator
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from .manim import logger
|
||||
from .manim import FFMPEG_BIN, logger
|
||||
|
||||
|
||||
def merge_basenames(files: List[str]) -> str:
|
||||
"""
|
||||
Merge multiple filenames by concatenating basenames.
|
||||
"""
|
||||
|
||||
dirname = os.path.dirname(files[0])
|
||||
_, ext = os.path.splitext(files[0])
|
||||
|
||||
basename = "_".join(os.path.splitext(os.path.basename(file))[0] for file in files)
|
||||
|
||||
return os.path.join(dirname, basename + ext)
|
||||
|
||||
|
||||
class Key(BaseModel):
|
||||
@ -96,7 +112,7 @@ class SlideConfig(BaseModel):
|
||||
|
||||
if values["start_animation"] == values["end_animation"] == 0:
|
||||
raise ValueError(
|
||||
"You have to play at least one animation (e.g., `self.wait()`) before pausing. If you want to start paused, use the approriate command-line option when presenting."
|
||||
"You have to play at least one animation (e.g., `self.wait()`) before pausing. If you want to start paused, use the approriate command-line option when presenting. IMPORTANT: when using ManimGL, `self.wait()` is not considered to be an animation, so prefer to directly use `self.play(...)`."
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
@ -105,15 +121,19 @@ class SlideConfig(BaseModel):
|
||||
|
||||
return values
|
||||
|
||||
def is_slide(self):
|
||||
def is_slide(self) -> bool:
|
||||
return self.type == SlideType.slide
|
||||
|
||||
def is_loop(self):
|
||||
def is_loop(self) -> bool:
|
||||
return self.type == SlideType.loop
|
||||
|
||||
def is_last(self):
|
||||
def is_last(self) -> bool:
|
||||
return self.type == SlideType.last
|
||||
|
||||
@property
|
||||
def slides_slice(self) -> slice:
|
||||
return slice(self.start_animation, self.end_animation)
|
||||
|
||||
|
||||
class PresentationConfig(BaseModel):
|
||||
slides: List[SlideConfig]
|
||||
@ -149,5 +169,79 @@ class PresentationConfig(BaseModel):
|
||||
|
||||
return values
|
||||
|
||||
def move_to(self, dest: str, copy=True) -> "PresentationConfig":
|
||||
"""
|
||||
Moves (or copy) the files to a given directory.
|
||||
"""
|
||||
move = shutil.copy if copy else shutil.move
|
||||
|
||||
n = len(self.files)
|
||||
for i in range(n):
|
||||
file = self.files[i]
|
||||
basename = os.path.basename(file)
|
||||
dest_path = os.path.join(dest, basename)
|
||||
logger.debug(f"Moving / copying {file} to {dest_path}")
|
||||
move(file, dest_path)
|
||||
self.files[i] = dest_path
|
||||
|
||||
return self
|
||||
|
||||
def concat_animations(self, dest: Optional[str] = None) -> "PresentationConfig":
|
||||
"""
|
||||
Concatenate animations such that each slide contains one animation.
|
||||
"""
|
||||
|
||||
dest_paths = []
|
||||
|
||||
for i, slide_config in enumerate(self.slides):
|
||||
files = self.files[slide_config.slides_slice]
|
||||
|
||||
if len(files) > 1:
|
||||
dest_path = merge_basenames(files)
|
||||
|
||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
|
||||
f.writelines(f"file {os.path.abspath(path)}\n" for path in files)
|
||||
f.close()
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
f.name,
|
||||
"-c",
|
||||
"copy",
|
||||
dest_path,
|
||||
"-y",
|
||||
]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
output, error = process.communicate()
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
|
||||
dest_paths.append(dest_path)
|
||||
|
||||
else:
|
||||
dest_paths.append(files[0])
|
||||
|
||||
slide_config.start_animation = i
|
||||
slide_config.end_animation = i + 1
|
||||
|
||||
self.files = dest_paths
|
||||
|
||||
if dest:
|
||||
return self.move_to(dest)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
DEFAULT_CONFIG = Config()
|
||||
|
200
manim_slides/convert.py
Normal file
200
manim_slides/convert.py
Normal file
@ -0,0 +1,200 @@
|
||||
import os
|
||||
import webbrowser
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, Generator, List, Type
|
||||
|
||||
import click
|
||||
from click import Context, Parameter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .commons import folder_path_option, verbosity_option
|
||||
from .config import PresentationConfig
|
||||
from .defaults import REVEALJS_TEMPLATE
|
||||
from .present import get_scenes_presentation_config
|
||||
|
||||
|
||||
def validate_config_option(
|
||||
ctx: Context, param: Parameter, value: Any
|
||||
) -> Dict[str, str]:
|
||||
|
||||
config = {}
|
||||
|
||||
for c_option in value:
|
||||
try:
|
||||
key, value = c_option.split("=")
|
||||
config[key] = value
|
||||
except ValueError:
|
||||
raise click.BadParameter(
|
||||
f"Configuration options `{c_option}` could not be parsed into a proper (key, value) pair. Please use an `=` sign to separate key from value."
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class Converter(BaseModel):
|
||||
presentation_configs: List[PresentationConfig] = []
|
||||
assets_dir: str = "{basename}_assets"
|
||||
|
||||
def convert_to(self, dest: str):
|
||||
"""Converts self, i.e., a list of presentations, into a given format."""
|
||||
raise NotImplementedError
|
||||
|
||||
def open(self, file: str) -> bool:
|
||||
"""Opens a file, generated with converter, using appropriate application."""
|
||||
return webbrowser.open(file)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, s: str) -> Type["Converter"]:
|
||||
"""Returns the appropriate converter from a string name."""
|
||||
return {
|
||||
"html": RevealJS,
|
||||
}[s]
|
||||
|
||||
|
||||
class JSBool(str, Enum):
|
||||
true = "true"
|
||||
false = "false"
|
||||
|
||||
|
||||
class RevealTheme(str, Enum):
|
||||
black = "black"
|
||||
white = "white"
|
||||
league = "league"
|
||||
beige = "beige"
|
||||
sky = "sky"
|
||||
night = "night"
|
||||
serif = "serif"
|
||||
simple = "simple"
|
||||
soralized = "solarized"
|
||||
blood = "blood"
|
||||
moon = "moon"
|
||||
|
||||
|
||||
class RevealJS(Converter):
|
||||
background_color: str = "black"
|
||||
controls: JSBool = JSBool.false
|
||||
embedded: JSBool = JSBool.false
|
||||
fragments: JSBool = JSBool.false
|
||||
height: str = "100%"
|
||||
loop: JSBool = JSBool.false
|
||||
progress: JSBool = JSBool.false
|
||||
reveal_version: str = "3.7.0"
|
||||
reveal_theme: RevealTheme = RevealTheme.black
|
||||
shuffle: JSBool = JSBool.false
|
||||
title: str = "Manim Slides"
|
||||
width: str = "100%"
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
def get_sections_iter(self) -> Generator[str, None, None]:
|
||||
"""Generates a sequence of sections, one per slide, that will be included into the html template."""
|
||||
for presentation_config in self.presentation_configs:
|
||||
for slide_config in presentation_config.slides:
|
||||
file = presentation_config.files[slide_config.start_animation]
|
||||
file = os.path.join(self.assets_dir, os.path.basename(file))
|
||||
|
||||
if slide_config.is_loop():
|
||||
yield f'<section data-background-video="{file}" data-background-video-loop></section>'
|
||||
else:
|
||||
yield f'<section data-background-video="{file}"></section>'
|
||||
|
||||
def convert_to(self, dest: str):
|
||||
dirname = os.path.dirname(dest)
|
||||
basename, ext = os.path.splitext(os.path.basename(dest))
|
||||
|
||||
self.assets_dir = self.assets_dir.format(
|
||||
dirname=dirname, basename=basename, ext=ext
|
||||
)
|
||||
full_assets_dir = os.path.join(dirname, self.assets_dir)
|
||||
|
||||
os.makedirs(full_assets_dir, exist_ok=True)
|
||||
|
||||
for presentation_config in self.presentation_configs:
|
||||
presentation_config.concat_animations().move_to(full_assets_dir)
|
||||
|
||||
with open(dest, "w") as f:
|
||||
|
||||
sections = "".join(self.get_sections_iter())
|
||||
|
||||
content = REVEALJS_TEMPLATE.format(sections=sections, **self.dict())
|
||||
|
||||
f.write(content)
|
||||
|
||||
|
||||
def show_config_options(function: Callable) -> Callable:
|
||||
"""Wraps a function to add a `--show-config` option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
to = ctx.params.get("to")
|
||||
|
||||
if to:
|
||||
converter = Converter.from_string(to)(scenes=[])
|
||||
for key, value in converter.dict().items():
|
||||
click.echo(f"{key}: {repr(value)}")
|
||||
|
||||
ctx.exit()
|
||||
|
||||
else:
|
||||
raise click.UsageError(
|
||||
"Using --show-config option requires to first specify --to option."
|
||||
)
|
||||
|
||||
return click.option(
|
||||
"--show-config",
|
||||
is_flag=True,
|
||||
help="Show supported options for given format and exit.",
|
||||
default=None,
|
||||
expose_value=False,
|
||||
show_envvar=True,
|
||||
callback=callback,
|
||||
)(function)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("scenes", nargs=-1)
|
||||
@folder_path_option
|
||||
@click.argument("dest")
|
||||
@click.option(
|
||||
"--to",
|
||||
type=click.Choice(["html"], case_sensitive=False),
|
||||
default="html",
|
||||
show_default=True,
|
||||
help="Set the conversion format to use.",
|
||||
)
|
||||
@click.option(
|
||||
"--open",
|
||||
"open_result",
|
||||
is_flag=True,
|
||||
help="Open the newly created file using the approriate application.",
|
||||
)
|
||||
@click.option("-f", "--force", is_flag=True, help="Overwrite any existing file.")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_options",
|
||||
multiple=True,
|
||||
callback=validate_config_option,
|
||||
help="Configuration options passed to the converter. E.g., pass `-cbackground_color=red` to set the background color to red (if supported).",
|
||||
)
|
||||
@show_config_options
|
||||
@verbosity_option
|
||||
def convert(scenes, folder, dest, to, open_result, force, config_options):
|
||||
"""
|
||||
Convert SCENE(s) into a given format and writes the result in DEST.
|
||||
"""
|
||||
|
||||
presentation_configs = get_scenes_presentation_config(scenes, folder)
|
||||
|
||||
converter = Converter.from_string(to)(
|
||||
presentation_configs=presentation_configs, **config_options
|
||||
)
|
||||
|
||||
converter.convert_to(dest)
|
||||
|
||||
if open_result:
|
||||
converter.open(dest)
|
@ -1,2 +1,190 @@
|
||||
FOLDER_PATH: str = "./slides"
|
||||
CONFIG_PATH: str = ".manim-slides.json"
|
||||
|
||||
REVEALJS_TEMPLATE: str = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/css/reveal.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/css/theme/{reveal_theme}.css">
|
||||
|
||||
<!-- Theme used for syntax highlighting of code -->
|
||||
<!-- <link rel="stylesheet" href="lib/css/zenburn.css"> -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/zenburn.min.css">
|
||||
|
||||
<!-- Printing and PDF exports -->
|
||||
<script>
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.type = 'text/css';
|
||||
link.href = window.location.search.match(/print-pdf/gi) ? 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/css/print/pdf.css' : 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/css/print/paper.css';
|
||||
document.getElementsByTagName('head')[0].appendChild(link);
|
||||
</script>
|
||||
<!-- <link rel="stylesheet" href="index.css"> -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="slides">
|
||||
{sections}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--<script src="lib/js/head.min.js"></script>-->
|
||||
<script src="https://cdn.jsdelivr.net/npm/headjs@1.0.3/dist/1.0.0/head.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/js/reveal.min.js"></script>
|
||||
|
||||
<!-- <script src="index.js"></script> -->
|
||||
<script>
|
||||
// More info about config & dependencies:
|
||||
// - https://github.com/hakimel/reveal.js#configuration
|
||||
// - https://github.com/hakimel/reveal.js#dependencies
|
||||
Reveal.initialize({{
|
||||
// Display controls in the bottom right corner
|
||||
controls: {controls},
|
||||
|
||||
width: '{width}',
|
||||
height: '{height}',
|
||||
|
||||
// Display a presentation progress bar
|
||||
progress: {progress},
|
||||
|
||||
// Set default timing of 2 minutes per slide
|
||||
defaultTiming: 120,
|
||||
|
||||
// Display the page number of the current slide
|
||||
slideNumber: true,
|
||||
|
||||
// Push each slide change to the browser history
|
||||
history: false,
|
||||
|
||||
// Enable keyboard shortcuts for navigation
|
||||
keyboard: true,
|
||||
|
||||
// Enable the slide overview mode
|
||||
overview: true,
|
||||
|
||||
// Vertical centering of slides
|
||||
center: true,
|
||||
|
||||
// Enables touch navigation on devices with touch input
|
||||
touch: true,
|
||||
|
||||
// Loop the presentation
|
||||
loop: {loop},
|
||||
|
||||
// Change the presentation direction to be RTL
|
||||
rtl: false,
|
||||
|
||||
// Randomizes the order of slides each time the presentation loads
|
||||
shuffle: {shuffle},
|
||||
|
||||
// Turns fragments on and off globally
|
||||
fragments: {fragments},
|
||||
|
||||
// Flags if the presentation is running in an embedded mode,
|
||||
// i.e. contained within a limited portion of the screen
|
||||
embedded: {embedded},
|
||||
|
||||
// Flags if we should show a help overlay when the questionmark
|
||||
// key is pressed
|
||||
help: true,
|
||||
|
||||
// Flags if speaker notes should be visible to all viewers
|
||||
showNotes: false,
|
||||
|
||||
// Global override for autolaying embedded media (video/audio/iframe)
|
||||
// - null: Media will only autoplay if data-autoplay is present
|
||||
// - true: All media will autoplay, regardless of individual setting
|
||||
// - false: No media will autoplay, regardless of individual setting
|
||||
autoPlayMedia: null,
|
||||
|
||||
// Number of milliseconds between automatically proceeding to the
|
||||
// next slide, disabled when set to 0, this value can be overwritten
|
||||
// by using a data-autoslide attribute on your slides
|
||||
autoSlide: 0,
|
||||
|
||||
// Stop auto-sliding after user input
|
||||
autoSlideStoppable: true,
|
||||
|
||||
// Use this method for navigation when auto-sliding
|
||||
autoSlideMethod: Reveal.navigateNext,
|
||||
|
||||
// Enable slide navigation via mouse wheel
|
||||
mouseWheel: false,
|
||||
|
||||
// Hides the address bar on mobile devices
|
||||
hideAddressBar: true,
|
||||
|
||||
// Opens links in an iframe preview overlay
|
||||
previewLinks: true,
|
||||
|
||||
// Transition style
|
||||
transition: 'none', // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// Transition speed
|
||||
transitionSpeed: 'default', // default/fast/slow
|
||||
|
||||
// Transition style for full page slide backgrounds
|
||||
backgroundTransition: 'none', // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// Number of slides away from the current that are visible
|
||||
viewDistance: 3,
|
||||
|
||||
// Parallax background image
|
||||
parallaxBackgroundImage: '', // e.g. "'https://s3.amazonaws.com/hakim-static/reveal-js/reveal-parallax-1.jpg'"
|
||||
|
||||
// Parallax background size
|
||||
parallaxBackgroundSize: '', // CSS syntax, e.g. "2100px 900px"
|
||||
|
||||
// Number of pixels to move the parallax background per slide
|
||||
// - Calculated automatically unless specified
|
||||
// - Set to 0 to disable movement along an axis
|
||||
parallaxBackgroundHorizontal: null,
|
||||
parallaxBackgroundVertical: null,
|
||||
|
||||
|
||||
// The display mode that will be used to show slides
|
||||
display: 'block',
|
||||
|
||||
/*
|
||||
multiplex: {{
|
||||
// Example values. To generate your own, see the socket.io server instructions.
|
||||
secret: '13652805320794272084', // Obtained from the socket.io server. Gives this (the master) control of the presentation
|
||||
id: '1ea875674b17ca76', // Obtained from socket.io server
|
||||
url: 'https://reveal-js-multiplex-ccjbegmaii.now.sh' // Location of socket.io server
|
||||
}},
|
||||
*/
|
||||
|
||||
dependencies: [
|
||||
{{ src: 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/plugin/markdown/marked.js' }},
|
||||
{{ src: 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/plugin/markdown/markdown.js' }},
|
||||
{{ src: 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/plugin/notes/notes.js', async: true }},
|
||||
{{ src: 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/plugin/highlight/highlight.js', async: true, callback: function () {{ hljs.initHighlightingOnLoad(); }} }},
|
||||
//{{ src: '//cdn.socket.io/socket.io-1.3.5.js', async: true }},
|
||||
//{{ src: 'plugin/multiplex/master.js', async: true }},
|
||||
// and if you want speaker notes
|
||||
{{ src: 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/plugin/notes-server/client.js', async: true }}
|
||||
|
||||
],
|
||||
markdown: {{
|
||||
// renderer: myrenderer,
|
||||
smartypants: true
|
||||
}}
|
||||
}});
|
||||
Reveal.configure({{
|
||||
// PDF Configurations
|
||||
pdfMaxPagesPerSlide: 1
|
||||
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
"""
|
||||
|
@ -2,6 +2,7 @@ import click
|
||||
from click_default_group import DefaultGroup
|
||||
|
||||
from . import __version__
|
||||
from .convert import convert
|
||||
from .present import list_scenes, present
|
||||
from .wizard import init, wizard
|
||||
|
||||
@ -18,10 +19,11 @@ def cli() -> None:
|
||||
pass
|
||||
|
||||
|
||||
cli.add_command(convert)
|
||||
cli.add_command(init)
|
||||
cli.add_command(list_scenes)
|
||||
cli.add_command(present)
|
||||
cli.add_command(wizard)
|
||||
cli.add_command(init)
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
@ -3,7 +3,7 @@ import platform
|
||||
import sys
|
||||
import time
|
||||
from enum import IntEnum, auto, unique
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import click
|
||||
import cv2
|
||||
@ -78,7 +78,6 @@ class Presentation:
|
||||
self.lastframe: Optional[np.ndarray] = None
|
||||
|
||||
self.reset()
|
||||
self.add_last_slide()
|
||||
|
||||
@property
|
||||
def current_slide(self) -> SlideConfig:
|
||||
@ -185,17 +184,6 @@ class Presentation:
|
||||
)
|
||||
return max(fps, 1) # TODO: understand why we sometimes get 0 fps
|
||||
|
||||
def add_last_slide(self) -> None:
|
||||
"""Add a 'last' slide to the end of slides."""
|
||||
self.slides.append(
|
||||
SlideConfig(
|
||||
start_animation=self.last_slide.end_animation,
|
||||
end_animation=self.last_slide.end_animation + 1,
|
||||
type=SlideType.last,
|
||||
number=self.last_slide.number + 1,
|
||||
)
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Rests current presentation."""
|
||||
self.current_animation = 0
|
||||
@ -673,6 +661,63 @@ def _list_scenes(folder) -> List[str]:
|
||||
return scenes
|
||||
|
||||
|
||||
def prompt_for_scenes(folder: str) -> List[str]:
|
||||
"""Prompts the user to select scenes within a given folder."""
|
||||
|
||||
scene_choices = dict(enumerate(_list_scenes(folder), start=1))
|
||||
|
||||
for i, scene in scene_choices.items():
|
||||
click.secho(f"{i}: {scene}", fg="green")
|
||||
|
||||
click.echo()
|
||||
|
||||
click.echo("Choose number corresponding to desired scene/arguments.")
|
||||
click.echo("(Use comma separated list for multiple entries)")
|
||||
|
||||
def value_proc(value: Optional[str]) -> List[str]:
|
||||
indices = list(map(int, (value or "").strip().replace(" ", "").split(",")))
|
||||
|
||||
if not all(0 < i <= len(scene_choices) for i in indices):
|
||||
raise click.UsageError("Please only enter numbers displayed on the screen.")
|
||||
|
||||
return [scene_choices[i] for i in indices]
|
||||
|
||||
if len(scene_choices) == 0:
|
||||
raise click.UsageError(
|
||||
"No scenes were found, are you in the correct directory?"
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
scenes = click.prompt("Choice(s)", value_proc=value_proc)
|
||||
return scenes
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e))
|
||||
|
||||
|
||||
def get_scenes_presentation_config(
|
||||
scenes: List[str], folder: str
|
||||
) -> List[PresentationConfig]:
|
||||
"""Returns a list of presentation configurations based on the user input."""
|
||||
|
||||
if len(scenes) == 0:
|
||||
scenes = prompt_for_scenes(folder)
|
||||
|
||||
presentation_configs = []
|
||||
for scene in scenes:
|
||||
config_file = os.path.join(folder, f"{scene}.json")
|
||||
if not os.path.exists(config_file):
|
||||
raise click.UsageError(
|
||||
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
|
||||
)
|
||||
try:
|
||||
presentation_configs.append(PresentationConfig.parse_file(config_file))
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e))
|
||||
|
||||
return presentation_configs
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("scenes", nargs=-1)
|
||||
@config_path_option
|
||||
@ -774,53 +819,10 @@ def present(
|
||||
if skip_all:
|
||||
exit_after_last_slide = True
|
||||
|
||||
if len(scenes) == 0:
|
||||
scene_choices = _list_scenes(folder)
|
||||
|
||||
scene_choices = dict(enumerate(scene_choices, start=1))
|
||||
|
||||
for i, scene in scene_choices.items():
|
||||
click.secho(f"{i}: {scene}", fg="green")
|
||||
|
||||
click.echo()
|
||||
|
||||
click.echo("Choose number corresponding to desired scene/arguments.")
|
||||
click.echo("(Use comma separated list for multiple entries)")
|
||||
|
||||
def value_proc(value: str) -> List[str]:
|
||||
indices = list(map(int, value.strip().replace(" ", "").split(",")))
|
||||
|
||||
if not all(0 < i <= len(scene_choices) for i in indices):
|
||||
raise click.UsageError(
|
||||
"Please only enter numbers displayed on the screen."
|
||||
)
|
||||
|
||||
return [scene_choices[i] for i in indices]
|
||||
|
||||
if len(scene_choices) == 0:
|
||||
raise click.UsageError(
|
||||
"No scenes were found, are you in the correct directory?"
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
scenes = click.prompt("Choice(s)", value_proc=value_proc)
|
||||
break
|
||||
except ValueError as e:
|
||||
raise click.UsageError(e)
|
||||
|
||||
presentations = []
|
||||
for scene in scenes:
|
||||
config_file = os.path.join(folder, f"{scene}.json")
|
||||
if not os.path.exists(config_file):
|
||||
raise click.UsageError(
|
||||
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
|
||||
)
|
||||
try:
|
||||
pres_config = PresentationConfig.parse_file(config_file)
|
||||
presentations.append(Presentation(pres_config))
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e))
|
||||
presentations = [
|
||||
Presentation(presentation_config)
|
||||
for presentation_config in get_scenes_presentation_config(scenes, folder)
|
||||
]
|
||||
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
|
@ -14,8 +14,15 @@ from .manim import FFMPEG_BIN, MANIMGL, Scene, ThreeDScene, config, logger
|
||||
def reverse_video_file(src: str, dst: str) -> None:
|
||||
"""Reverses a video file, writting the result to `dst`."""
|
||||
command = [FFMPEG_BIN, "-i", src, "-vf", "reverse", dst]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
process.communicate()
|
||||
output, error = process.communicate()
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
|
||||
|
||||
class Slide(Scene):
|
||||
@ -96,6 +103,17 @@ class Slide(Scene):
|
||||
self.current_slide += 1
|
||||
self.pause_start_animation = self.current_animation
|
||||
|
||||
def add_last_slide(self) -> None:
|
||||
"""Adds a 'last' slide to the end of slides."""
|
||||
self.slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.last,
|
||||
start_animation=self.pause_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
)
|
||||
)
|
||||
|
||||
def start_loop(self) -> None:
|
||||
"""Starts a loop."""
|
||||
assert self.loop_start_animation is None, "You cannot nest loops"
|
||||
@ -124,6 +142,8 @@ class Slide(Scene):
|
||||
|
||||
Note that cached files only work with Manim.
|
||||
"""
|
||||
self.add_last_slide()
|
||||
|
||||
if not os.path.exists(self.output_folder):
|
||||
os.mkdir(self.output_folder)
|
||||
|
||||
|
Reference in New Issue
Block a user