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:
Jérome Eertmans
2022-11-09 15:59:19 +01:00
committed by GitHub
parent a373bdb460
commit 9aa715a0e4
12 changed files with 821 additions and 74 deletions

View File

@ -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

View File

@ -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
View File

@ -19,3 +19,7 @@ videos/
images/
docs/build/
docs/source/_static/slides_assets/
docs/source/_static/slides.html

View File

@ -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>`_.

View File

@ -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:

View File

@ -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)

View File

@ -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
View 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)

View File

@ -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>
"""

View File

@ -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()

View File

@ -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:

View File

@ -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)