mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-18 19:16:21 +08:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
2ba9b734a3 | |||
49c4a10453 | |||
8c578d2577 | |||
2a327c470b | |||
04dcf530f5 | |||
9a573f29f1 | |||
02f425f536 | |||
149b12fd01 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 4.10.0
|
||||
current_version = 4.11.0
|
||||
commit = True
|
||||
message = chore(version): bump {current_version} to {new_version}
|
||||
|
||||
|
@ -24,11 +24,11 @@ repos:
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: v0.0.254
|
||||
rev: v0.0.255
|
||||
hooks:
|
||||
- id: ruff
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.0.1
|
||||
rev: v1.1.1
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-requests, types-setuptools]
|
||||
|
19
example.py
19
example.py
@ -32,6 +32,25 @@ class BasicExample(Slide):
|
||||
self.next_slide() # Waits user to press continue to go to the next slide
|
||||
|
||||
|
||||
class MultipleAnimationsInLastSlide(Slide):
|
||||
"""This is used to check against solution for issue #161."""
|
||||
|
||||
def construct(self):
|
||||
circle = Circle(color=BLUE)
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.play(FadeIn(dot))
|
||||
self.next_slide()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT))
|
||||
self.play(dot.animate.move_to(UP))
|
||||
self.play(dot.animate.move_to(LEFT))
|
||||
self.play(dot.animate.move_to(DOWN))
|
||||
|
||||
self.next_slide()
|
||||
|
||||
|
||||
class TestFileTooLong(Slide):
|
||||
"""This is used to check against solution for issue #123."""
|
||||
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "4.10.0"
|
||||
__version__ = "4.11.0"
|
||||
|
@ -5,9 +5,9 @@ import subprocess
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from pydantic import BaseModel, FilePath, root_validator, validator
|
||||
from pydantic import BaseModel, FilePath, PositiveInt, root_validator, validator
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from .defaults import FFMPEG_BIN
|
||||
@ -20,7 +20,7 @@ def merge_basenames(files: List[FilePath]) -> Path:
|
||||
"""
|
||||
logger.info(f"Generating a new filename for animations: {files}")
|
||||
|
||||
dirname = files[0].parent
|
||||
dirname: Path = files[0].parent
|
||||
ext = files[0].suffix
|
||||
|
||||
basenames = (file.stem for file in files)
|
||||
@ -31,7 +31,7 @@ def merge_basenames(files: List[FilePath]) -> Path:
|
||||
# https://github.com/jeertmans/manim-slides/issues/123
|
||||
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
|
||||
|
||||
return dirname / (basename + ext)
|
||||
return dirname.joinpath(basename + ext)
|
||||
|
||||
|
||||
class Key(BaseModel): # type: ignore
|
||||
@ -149,13 +149,14 @@ class SlideConfig(BaseModel): # type: ignore
|
||||
class PresentationConfig(BaseModel): # type: ignore
|
||||
slides: List[SlideConfig]
|
||||
files: List[FilePath]
|
||||
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
|
||||
|
||||
@root_validator
|
||||
def animation_indices_match_files(
|
||||
cls, values: Dict[str, Union[List[SlideConfig], List[FilePath]]]
|
||||
) -> Dict[str, Union[List[SlideConfig], List[FilePath]]]:
|
||||
files = values.get("files")
|
||||
slides = values.get("slides")
|
||||
files: List[FilePath] = values.get("files") # type: ignore
|
||||
slides: List[SlideConfig] = values.get("slides") # type: ignore
|
||||
|
||||
if files is None or slides is None:
|
||||
return values
|
||||
@ -163,7 +164,7 @@ class PresentationConfig(BaseModel): # type: ignore
|
||||
n_files = len(files)
|
||||
|
||||
for slide in slides:
|
||||
if slide.end_animation > n_files: # type: ignore
|
||||
if slide.end_animation > n_files:
|
||||
raise ValueError(
|
||||
f"The following slide's contains animations not listed in files {files}: {slide}"
|
||||
)
|
||||
|
@ -24,7 +24,7 @@ from .logger import logger
|
||||
from .present import get_scenes_presentation_config
|
||||
|
||||
|
||||
def open_with_default(file: Path):
|
||||
def open_with_default(file: Path) -> None:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
subprocess.call(("open", str(file)))
|
||||
@ -66,7 +66,7 @@ class Converter(BaseModel): # type: ignore
|
||||
An empty string is returned if no template is used."""
|
||||
return ""
|
||||
|
||||
def open(self, file: Path) -> bool:
|
||||
def open(self, file: Path) -> Any:
|
||||
"""Opens a file, generated with converter, using appropriate application."""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -376,7 +376,7 @@ class PowerPoint(Converter):
|
||||
use_enum_values = True
|
||||
extra = "forbid"
|
||||
|
||||
def open(self, file: Path) -> bool:
|
||||
def open(self, file: Path) -> None:
|
||||
return open_with_default(file)
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
@ -389,7 +389,9 @@ class PowerPoint(Converter):
|
||||
|
||||
# From GitHub issue comment:
|
||||
# - https://github.com/scanny/python-pptx/issues/427#issuecomment-856724440
|
||||
def auto_play_media(media: pptx.shapes.picture.Movie, loop: bool = False):
|
||||
def auto_play_media(
|
||||
media: pptx.shapes.picture.Movie, loop: bool = False
|
||||
) -> None:
|
||||
el_id = xpath(media.element, ".//p:cNvPr")[0].attrib["id"]
|
||||
el_cnt = xpath(
|
||||
media.element.getparent().getparent().getparent(),
|
||||
@ -463,7 +465,7 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
|
||||
ctx.exit()
|
||||
|
||||
return click.option(
|
||||
return click.option( # type: ignore
|
||||
"--show-config",
|
||||
is_flag=True,
|
||||
help="Show supported options for given format and exit.",
|
||||
@ -491,7 +493,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
|
||||
ctx.exit()
|
||||
|
||||
return click.option(
|
||||
return click.option( # type: ignore
|
||||
"--show-template",
|
||||
is_flag=True,
|
||||
help="Show the template (currently) used for a given conversion format and exit.",
|
||||
|
@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
from click import Context, Parameter
|
||||
from pydantic import ValidationError
|
||||
from PySide6.QtCore import Qt, QThread, Signal, Slot
|
||||
from PySide6.QtGui import QCloseEvent, QIcon, QImage, QKeyEvent, QPixmap, QResizeEvent
|
||||
@ -70,10 +71,9 @@ class Presentation:
|
||||
"""Creates presentation from a configuration object."""
|
||||
|
||||
def __init__(self, config: PresentationConfig) -> None:
|
||||
self.slides: List[SlideConfig] = config.slides
|
||||
self.files: List[str] = config.files
|
||||
self.config = config
|
||||
|
||||
self.current_slide_index: int = 0
|
||||
self.__current_slide_index: int = 0
|
||||
self.current_animation: int = self.current_slide.start_animation
|
||||
self.current_file: str = ""
|
||||
|
||||
@ -87,6 +87,66 @@ class Presentation:
|
||||
|
||||
self.reset()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.slides)
|
||||
|
||||
@property
|
||||
def slides(self) -> List[SlideConfig]:
|
||||
"""Returns the list of slides."""
|
||||
return self.config.slides
|
||||
|
||||
@property
|
||||
def files(self) -> List[Path]:
|
||||
"""Returns the list of animation files."""
|
||||
return self.config.files
|
||||
|
||||
@property
|
||||
def resolution(self) -> Tuple[int, int]:
|
||||
"""Returns the resolution."""
|
||||
return self.config.resolution
|
||||
|
||||
@property
|
||||
def current_slide_index(self) -> int:
|
||||
return self.__current_slide_index
|
||||
|
||||
@current_slide_index.setter
|
||||
def current_slide_index(self, value: Optional[int]) -> None:
|
||||
if value is not None:
|
||||
if -len(self) <= value < len(self):
|
||||
self.__current_slide_index = value
|
||||
self.current_animation = self.current_slide.start_animation
|
||||
logger.debug(f"Set current slide index to {value}")
|
||||
else:
|
||||
logger.error(
|
||||
f"Could not load slide number {value}, playing first slide instead."
|
||||
)
|
||||
|
||||
def set_current_animation_and_update_slide_number(
|
||||
self, value: Optional[int]
|
||||
) -> None:
|
||||
if value is not None:
|
||||
n_files = len(self.files)
|
||||
if -n_files <= value < n_files:
|
||||
if value < 0:
|
||||
value += n_files
|
||||
|
||||
for i, slide in enumerate(self.slides):
|
||||
if value < slide.end_animation:
|
||||
self.current_slide_index = i
|
||||
self.current_animation = value
|
||||
|
||||
logger.debug(f"Playing animation {value}, at slide index {i}")
|
||||
return
|
||||
|
||||
assert (
|
||||
False
|
||||
), f"An error occurred when setting the current animation to {value}, please create an issue on GitHub!"
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
f"Could not load animation number {value}, playing first animation instead."
|
||||
)
|
||||
|
||||
@property
|
||||
def current_slide(self) -> SlideConfig:
|
||||
"""Returns currently playing slide."""
|
||||
@ -119,7 +179,7 @@ class Presentation:
|
||||
|
||||
self.release_cap()
|
||||
|
||||
file: str = self.files[animation]
|
||||
file: str = str(self.files[animation])
|
||||
|
||||
if self.reverse:
|
||||
file = "{}_reversed{}".format(*os.path.splitext(file))
|
||||
@ -138,7 +198,9 @@ class Presentation:
|
||||
|
||||
def rewind_current_slide(self) -> None:
|
||||
"""Rewinds current slide to first frame."""
|
||||
logger.debug("Rewinding curring slide")
|
||||
logger.debug("Rewinding current slide")
|
||||
self.current_slide.terminated = False
|
||||
|
||||
if self.reverse:
|
||||
self.current_animation = self.current_slide.end_animation - 1
|
||||
else:
|
||||
@ -176,9 +238,10 @@ class Presentation:
|
||||
|
||||
def load_previous_slide(self) -> None:
|
||||
"""Loads previous slide."""
|
||||
logger.debug("Loading previous slide")
|
||||
logger.debug(f"Loading previous slide, current is {self.current_slide_index}")
|
||||
self.cancel_reverse()
|
||||
self.current_slide_index = max(0, self.current_slide_index - 1)
|
||||
logger.debug(f"Loading slide index {self.current_slide_index}")
|
||||
self.rewind_current_slide()
|
||||
|
||||
@property
|
||||
@ -189,7 +252,8 @@ class Presentation:
|
||||
logger.warn(
|
||||
f"Something is wrong with video file {self.current_file}, as the fps returned by frame {self.current_frame_number} is 0"
|
||||
)
|
||||
return max(fps, 1) # TODO: understand why we sometimes get 0 fps
|
||||
# TODO: understand why we sometimes get 0 fps
|
||||
return max(fps, 1) # type: ignore
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Rests current presentation."""
|
||||
@ -217,7 +281,7 @@ class Presentation:
|
||||
return self.current_animation + 1
|
||||
|
||||
@property
|
||||
def is_last_animation(self) -> int:
|
||||
def is_last_animation(self) -> bool:
|
||||
"""Returns True if current animation is the last one of current slide."""
|
||||
if self.reverse:
|
||||
return self.current_animation == self.current_slide.start_animation
|
||||
@ -280,16 +344,20 @@ class Display(QThread): # type: ignore
|
||||
|
||||
change_video_signal = Signal(np.ndarray)
|
||||
change_info_signal = Signal(dict)
|
||||
change_presentation_sigal = Signal()
|
||||
finished = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
presentations: List[PresentationConfig],
|
||||
presentations: List[Presentation],
|
||||
config: Config = DEFAULT_CONFIG,
|
||||
start_paused: bool = False,
|
||||
skip_all: bool = False,
|
||||
record_to: Optional[str] = None,
|
||||
exit_after_last_slide: bool = False,
|
||||
start_at_scene_number: Optional[int] = None,
|
||||
start_at_slide_number: Optional[int] = None,
|
||||
start_at_animation_number: Optional[int] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.presentations = presentations
|
||||
@ -301,17 +369,48 @@ class Display(QThread): # type: ignore
|
||||
|
||||
self.state = State.PLAYING
|
||||
self.lastframe: Optional[np.ndarray] = None
|
||||
self.current_presentation_index = 0
|
||||
|
||||
self.__current_presentation_index = 0
|
||||
self.current_presentation_index = start_at_scene_number # type: ignore
|
||||
self.current_presentation.current_slide_index = start_at_slide_number # type: ignore
|
||||
self.current_presentation.set_current_animation_and_update_slide_number(
|
||||
start_at_animation_number
|
||||
)
|
||||
|
||||
self.run_flag = True
|
||||
|
||||
self.key = -1
|
||||
self.exit_after_last_slide = exit_after_last_slide
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.presentations)
|
||||
|
||||
@property
|
||||
def current_presentation_index(self) -> int:
|
||||
return self.__current_presentation_index
|
||||
|
||||
@current_presentation_index.setter
|
||||
def current_presentation_index(self, value: Optional[int]) -> None:
|
||||
if value is not None:
|
||||
if -len(self) <= value < len(self):
|
||||
self.__current_presentation_index = value
|
||||
self.current_presentation.release_cap()
|
||||
self.change_presentation_sigal.emit()
|
||||
else:
|
||||
logger.error(
|
||||
f"Could not load scene number {value}, playing first scene instead."
|
||||
)
|
||||
|
||||
@property
|
||||
def current_presentation(self) -> Presentation:
|
||||
"""Returns the current presentation."""
|
||||
return self.presentations[self.current_presentation_index]
|
||||
|
||||
@property
|
||||
def current_resolution(self) -> Tuple[int, int]:
|
||||
"""Returns the resolution of the current presentation."""
|
||||
return self.current_presentation.resolution
|
||||
|
||||
def run(self) -> None:
|
||||
"""Runs a series of presentations until end or exit."""
|
||||
while self.run_flag:
|
||||
@ -346,7 +445,7 @@ class Display(QThread): # type: ignore
|
||||
if self.record_to is not None:
|
||||
self.record_movie()
|
||||
|
||||
logger.debug("Closing video thread gracully and exiting")
|
||||
logger.debug("Closing video thread gracefully and exiting")
|
||||
self.finished.emit()
|
||||
|
||||
def record_movie(self) -> None:
|
||||
@ -520,7 +619,6 @@ class App(QWidget): # type: ignore
|
||||
*args: Any,
|
||||
config: Config = DEFAULT_CONFIG,
|
||||
fullscreen: bool = False,
|
||||
resolution: Tuple[int, int] = (1980, 1080),
|
||||
hide_mouse: bool = False,
|
||||
aspect_ratio: AspectRatio = AspectRatio.auto,
|
||||
resize_mode: Qt.TransformationMode = Qt.SmoothTransformation,
|
||||
@ -532,7 +630,12 @@ class App(QWidget): # type: ignore
|
||||
self.setWindowTitle(WINDOW_NAME)
|
||||
self.icon = QIcon(":/icon.png")
|
||||
self.setWindowIcon(self.icon)
|
||||
self.display_width, self.display_height = resolution
|
||||
|
||||
# create the video capture thread
|
||||
kwargs["config"] = config
|
||||
self.thread = Display(*args, **kwargs)
|
||||
|
||||
self.display_width, self.display_height = self.thread.current_resolution
|
||||
self.aspect_ratio = aspect_ratio
|
||||
self.resize_mode = resize_mode
|
||||
self.hide_mouse = hide_mouse
|
||||
@ -552,9 +655,6 @@ class App(QWidget): # type: ignore
|
||||
self.label.setPixmap(self.pixmap)
|
||||
self.label.setMinimumSize(1, 1)
|
||||
|
||||
# create the video capture thread
|
||||
kwargs["config"] = config
|
||||
self.thread = Display(*args, **kwargs)
|
||||
# create the info dialog
|
||||
self.info = Info()
|
||||
self.info.show()
|
||||
@ -568,6 +668,7 @@ class App(QWidget): # type: ignore
|
||||
# connect signals
|
||||
self.thread.change_video_signal.connect(self.update_image)
|
||||
self.thread.change_info_signal.connect(self.info.update_info)
|
||||
self.thread.change_presentation_sigal.connect(self.update_canvas)
|
||||
self.thread.finished.connect(self.closeAll)
|
||||
self.send_key_signal.connect(self.thread.set_key)
|
||||
|
||||
@ -621,6 +722,14 @@ class App(QWidget): # type: ignore
|
||||
|
||||
self.label.setPixmap(QPixmap.fromImage(qt_img))
|
||||
|
||||
@Slot()
|
||||
def update_canvas(self) -> None:
|
||||
"""Update the canvas when a presentation has changed."""
|
||||
logger.debug("Updating canvas")
|
||||
self.display_width, self.display_height = self.thread.current_resolution
|
||||
if not self.isFullScreen():
|
||||
self.resize(self.display_width, self.display_height)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
@ -690,7 +799,7 @@ def prompt_for_scenes(folder: Path) -> List[str]:
|
||||
while True:
|
||||
try:
|
||||
scenes = click.prompt("Choice(s)", value_proc=value_proc)
|
||||
return scenes
|
||||
return scenes # type: ignore
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e))
|
||||
|
||||
@ -718,6 +827,37 @@ def get_scenes_presentation_config(
|
||||
return presentation_configs
|
||||
|
||||
|
||||
def start_at_callback(
|
||||
ctx: Context, param: Parameter, values: str
|
||||
) -> Tuple[Optional[int], ...]:
|
||||
if values == "(None, None, None)":
|
||||
return (None, None, None)
|
||||
|
||||
def str_to_int_or_none(value: str) -> Optional[int]:
|
||||
if value.lower().strip() == "":
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise click.BadParameter(
|
||||
f"start index can only be an integer or an empty string, not `{value}`",
|
||||
ctx=ctx,
|
||||
param=param,
|
||||
)
|
||||
|
||||
values_tuple = values.split(",")
|
||||
n_values = len(values_tuple)
|
||||
if n_values == 3:
|
||||
return tuple(map(str_to_int_or_none, values_tuple))
|
||||
|
||||
raise click.BadParameter(
|
||||
f"exactly 3 arguments are expected but you gave {n_values}, please use commas to separate them",
|
||||
ctx=ctx,
|
||||
param=param,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("scenes", nargs=-1)
|
||||
@config_path_option
|
||||
@ -742,9 +882,8 @@ def get_scenes_presentation_config(
|
||||
"--resolution",
|
||||
metavar="<WIDTH HEIGHT>",
|
||||
type=(int, int),
|
||||
default=(1920, 1080),
|
||||
default=None,
|
||||
help="Window resolution WIDTH HEIGHT used if fullscreen is not set. You may manually resize the window afterward.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--to",
|
||||
@ -789,6 +928,43 @@ def get_scenes_presentation_config(
|
||||
help='Set the background color for borders when using "keep" resize mode. Can be any valid CSS color, e.g., "green", "#FF6500" or "rgba(255, 255, 0, .5)".',
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--sa",
|
||||
"--start-at",
|
||||
"start_at",
|
||||
metavar="<SCENE,SLIDE,ANIMATION>",
|
||||
type=str,
|
||||
callback=start_at_callback,
|
||||
default=(None, None, None),
|
||||
help="Start presenting at (x, y, z), equivalent to --sacn x --sasn y --saan z, and overrides values if not None.",
|
||||
)
|
||||
@click.option(
|
||||
"--sacn",
|
||||
"--start-at-scene-number",
|
||||
"start_at_scene_number",
|
||||
metavar="INDEX",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Start presenting at a given scene number (0 is first, -1 is last).",
|
||||
)
|
||||
@click.option(
|
||||
"--sasn",
|
||||
"--start-at-slide-number",
|
||||
"start_at_slide_number",
|
||||
metavar="INDEX",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Start presenting at a given slide number (0 is first, -1 is last).",
|
||||
)
|
||||
@click.option(
|
||||
"--saan",
|
||||
"--start-at-animation-number",
|
||||
"start_at_animation_number",
|
||||
metavar="INDEX",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Start presenting at a given animation number (0 is first, -1 is last). This conflicts with slide number since animation number is absolute to the presentation.",
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def present(
|
||||
@ -798,13 +974,17 @@ def present(
|
||||
start_paused: bool,
|
||||
fullscreen: bool,
|
||||
skip_all: bool,
|
||||
resolution: Tuple[int, int],
|
||||
resolution: Optional[Tuple[int, int]],
|
||||
record_to: Optional[Path],
|
||||
exit_after_last_slide: bool,
|
||||
hide_mouse: bool,
|
||||
aspect_ratio: str,
|
||||
resize_mode: str,
|
||||
background_color: str,
|
||||
start_at: Tuple[Optional[int], Optional[int], Optional[int]],
|
||||
start_at_scene_number: Optional[int],
|
||||
start_at_slide_number: Optional[int],
|
||||
start_at_animation_number: Optional[int],
|
||||
) -> None:
|
||||
"""
|
||||
Present SCENE(s), one at a time, in order.
|
||||
@ -819,9 +999,15 @@ def present(
|
||||
if skip_all:
|
||||
exit_after_last_slide = True
|
||||
|
||||
presentation_configs = get_scenes_presentation_config(scenes, folder)
|
||||
|
||||
if resolution is not None:
|
||||
for presentation_config in presentation_configs:
|
||||
presentation_config.resolution = resolution
|
||||
|
||||
presentations = [
|
||||
Presentation(presentation_config)
|
||||
for presentation_config in get_scenes_presentation_config(scenes, folder)
|
||||
for presentation_config in presentation_configs
|
||||
]
|
||||
|
||||
if config_path.exists():
|
||||
@ -840,6 +1026,15 @@ def present(
|
||||
"Recording only support '.avi' extension. For other video formats, please convert the resulting '.avi' file afterwards."
|
||||
)
|
||||
|
||||
if start_at[0]:
|
||||
start_at_scene_number = start_at[0]
|
||||
|
||||
if start_at[1]:
|
||||
start_at_slide_number = start_at[1]
|
||||
|
||||
if start_at[2]:
|
||||
start_at_animation_number = start_at[2]
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Manim Slides")
|
||||
a = App(
|
||||
@ -848,13 +1043,15 @@ def present(
|
||||
start_paused=start_paused,
|
||||
fullscreen=fullscreen,
|
||||
skip_all=skip_all,
|
||||
resolution=resolution,
|
||||
record_to=record_to,
|
||||
exit_after_last_slide=exit_after_last_slide,
|
||||
hide_mouse=hide_mouse,
|
||||
aspect_ratio=ASPECT_RATIO_MODES[aspect_ratio],
|
||||
resize_mode=RESIZE_MODES[resize_mode],
|
||||
background_color=background_color,
|
||||
start_at_scene_number=start_at_scene_number,
|
||||
start_at_slide_number=start_at_slide_number,
|
||||
start_at_animation_number=start_at_animation_number,
|
||||
)
|
||||
a.show()
|
||||
sys.exit(app.exec_())
|
||||
|
@ -2,7 +2,7 @@ import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from warnings import warn
|
||||
|
||||
from tqdm import tqdm
|
||||
@ -47,15 +47,23 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.output_folder = output_folder
|
||||
self.slides: List[SlideConfig] = []
|
||||
self.current_slide = 1
|
||||
self.current_animation = 0
|
||||
self.loop_start_animation: Optional[int] = None
|
||||
self.pause_start_animation = 0
|
||||
self.__output_folder = output_folder
|
||||
self.__slides: List[SlideConfig] = []
|
||||
self.__current_slide = 1
|
||||
self.__current_animation = 0
|
||||
self.__loop_start_animation: Optional[int] = None
|
||||
self.__pause_start_animation = 0
|
||||
|
||||
@property
|
||||
def partial_movie_files(self) -> List[str]:
|
||||
def __resolution(self) -> Tuple[int, int]:
|
||||
"""Returns the scene's resolution used during rendering."""
|
||||
if MANIMGL:
|
||||
return self.camera_config["pixel_width"], self.camera_config["pixel_height"]
|
||||
else:
|
||||
return config["pixel_width"], config["pixel_height"]
|
||||
|
||||
@property
|
||||
def __partial_movie_files(self) -> List[str]:
|
||||
"""Returns a list of partial movie files, a.k.a animations."""
|
||||
if MANIMGL:
|
||||
from manimlib.utils.file_ops import get_sorted_integer_files
|
||||
@ -64,34 +72,41 @@ class Slide(Scene): # type:ignore
|
||||
"remove_non_integer_files": True,
|
||||
"extension": self.file_writer.movie_file_extension,
|
||||
}
|
||||
return get_sorted_integer_files(
|
||||
return get_sorted_integer_files( # type: ignore
|
||||
self.file_writer.partial_movie_directory, **kwargs
|
||||
)
|
||||
else:
|
||||
return self.renderer.file_writer.partial_movie_files
|
||||
return self.renderer.file_writer.partial_movie_files # type: ignore
|
||||
|
||||
@property
|
||||
def show_progress_bar(self) -> bool:
|
||||
def __show_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be displayed."""
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "show_progress_bar", True)
|
||||
return getattr(self, "show_progress_bar", True)
|
||||
else:
|
||||
return config["progress_bar"] != "none"
|
||||
return config["progress_bar"] != "none" # type: ignore
|
||||
|
||||
@property
|
||||
def leave_progress_bar(self) -> bool:
|
||||
def __leave_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be left after completed."""
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "leave_progress_bars", False)
|
||||
return getattr(self, "leave_progress_bars", False)
|
||||
else:
|
||||
return config["progress_bar"] == "leave"
|
||||
return config["progress_bar"] == "leave" # type: ignore
|
||||
|
||||
@property
|
||||
def __start_at_animation_number(self) -> Optional[int]:
|
||||
if MANIMGL:
|
||||
return getattr(self, "start_at_animation_number", None)
|
||||
else:
|
||||
return config["from_animation_number"] # type: ignore
|
||||
|
||||
def play(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Overloads `self.play` and increment animation count."""
|
||||
super().play(*args, **kwargs)
|
||||
self.current_animation += 1
|
||||
self.__current_animation += 1
|
||||
|
||||
def next_slide(self):
|
||||
def next_slide(self) -> None:
|
||||
"""
|
||||
Creates a new slide with previous animations.
|
||||
|
||||
@ -133,19 +148,19 @@ class Slide(Scene): # type:ignore
|
||||
self.play(FadeOut(text))
|
||||
"""
|
||||
assert (
|
||||
self.loop_start_animation is None
|
||||
self.__loop_start_animation is None
|
||||
), "You cannot call `self.next_slide()` inside a loop"
|
||||
|
||||
self.slides.append(
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.slide,
|
||||
start_animation=self.pause_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
start_animation=self.__pause_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
)
|
||||
)
|
||||
self.current_slide += 1
|
||||
self.pause_start_animation = self.current_animation
|
||||
self.__current_slide += 1
|
||||
self.__pause_start_animation = self.__current_animation
|
||||
|
||||
def pause(self) -> None:
|
||||
"""
|
||||
@ -159,24 +174,24 @@ class Slide(Scene): # type:ignore
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self.next_slide()
|
||||
Slide.next_slide(self)
|
||||
|
||||
def add_last_slide(self) -> None:
|
||||
def __add_last_slide(self) -> None:
|
||||
"""Adds a 'last' slide to the end of slides."""
|
||||
|
||||
if (
|
||||
len(self.slides) > 0
|
||||
and self.current_animation == self.slides[-1].end_animation
|
||||
len(self.__slides) > 0
|
||||
and self.__current_animation == self.__slides[-1].end_animation
|
||||
):
|
||||
self.slides[-1].type = SlideType.last
|
||||
self.__slides[-1].type = SlideType.last
|
||||
return
|
||||
|
||||
self.slides.append(
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.last,
|
||||
start_animation=self.pause_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
start_animation=self.__pause_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
)
|
||||
)
|
||||
|
||||
@ -207,42 +222,42 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
self.end_loop()
|
||||
"""
|
||||
assert self.loop_start_animation is None, "You cannot nest loops"
|
||||
self.loop_start_animation = self.current_animation
|
||||
assert self.__loop_start_animation is None, "You cannot nest loops"
|
||||
self.__loop_start_animation = self.__current_animation
|
||||
|
||||
def end_loop(self) -> None:
|
||||
"""Ends an existing loop. See :func:`start_loop` for more details."""
|
||||
assert (
|
||||
self.loop_start_animation is not None
|
||||
self.__loop_start_animation is not None
|
||||
), "You have to start a loop before ending it"
|
||||
self.slides.append(
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.loop,
|
||||
start_animation=self.loop_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
start_animation=self.__loop_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
)
|
||||
)
|
||||
self.current_slide += 1
|
||||
self.loop_start_animation = None
|
||||
self.pause_start_animation = self.current_animation
|
||||
self.__current_slide += 1
|
||||
self.__loop_start_animation = None
|
||||
self.__pause_start_animation = self.__current_animation
|
||||
|
||||
def save_slides(self, use_cache: bool = True) -> None:
|
||||
def __save_slides(self, use_cache: bool = True) -> None:
|
||||
"""
|
||||
Saves slides, optionally using cached files.
|
||||
|
||||
Note that cached files only work with Manim.
|
||||
"""
|
||||
self.add_last_slide()
|
||||
self.__add_last_slide()
|
||||
|
||||
if not os.path.exists(self.output_folder):
|
||||
os.mkdir(self.output_folder)
|
||||
if not os.path.exists(self.__output_folder):
|
||||
os.mkdir(self.__output_folder)
|
||||
|
||||
files_folder = os.path.join(self.output_folder, "files")
|
||||
files_folder = os.path.join(self.__output_folder, "files")
|
||||
if not os.path.exists(files_folder):
|
||||
os.mkdir(files_folder)
|
||||
|
||||
scene_name = type(self).__name__
|
||||
scene_name = str(self)
|
||||
scene_files_folder = os.path.join(files_folder, scene_name)
|
||||
|
||||
old_animation_files = set()
|
||||
@ -257,12 +272,18 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
files = []
|
||||
for src_file in tqdm(
|
||||
self.partial_movie_files,
|
||||
self.__partial_movie_files,
|
||||
desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations",
|
||||
leave=self.leave_progress_bar,
|
||||
leave=self.__leave_progress_bar,
|
||||
ascii=True if platform.system() == "Windows" else None,
|
||||
disable=not self.show_progress_bar,
|
||||
disable=not self.__show_progress_bar,
|
||||
):
|
||||
if src_file is None and not MANIMGL:
|
||||
# This happens if rendering with -na,b (manim only)
|
||||
# where animations not in [a,b] will be skipped
|
||||
# but animations before a will have a None src_file
|
||||
continue
|
||||
|
||||
filename = os.path.basename(src_file)
|
||||
rev_filename = "{}_reversed{}".format(*os.path.splitext(filename))
|
||||
|
||||
@ -282,14 +303,27 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
files.append(dst_file)
|
||||
|
||||
if offset := self.__start_at_animation_number:
|
||||
self.__slides = [
|
||||
slide for slide in self.__slides if slide.end_animation > offset
|
||||
]
|
||||
|
||||
for slide in self.__slides:
|
||||
slide.start_animation -= offset
|
||||
slide.end_animation -= offset
|
||||
|
||||
logger.info(
|
||||
f"Copied {len(files)} animations to '{os.path.abspath(scene_files_folder)}' and generated reversed animations"
|
||||
)
|
||||
|
||||
slide_path = os.path.join(self.output_folder, "%s.json" % (scene_name,))
|
||||
slide_path = os.path.join(self.__output_folder, "%s.json" % (scene_name,))
|
||||
|
||||
with open(slide_path, "w") as f:
|
||||
f.write(PresentationConfig(slides=self.slides, files=files).json(indent=2))
|
||||
f.write(
|
||||
PresentationConfig(
|
||||
slides=self.__slides, files=files, resolution=self.__resolution
|
||||
).json(indent=2)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'"
|
||||
@ -298,11 +332,11 @@ class Slide(Scene): # type:ignore
|
||||
def run(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIMGL renderer"""
|
||||
super().run(*args, **kwargs)
|
||||
self.save_slides(use_cache=False)
|
||||
self.__save_slides(use_cache=False)
|
||||
|
||||
def render(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIM render"""
|
||||
# We need to disable the caching limit since we rely on intermidiate files
|
||||
# We need to disable the caching limit since we rely on intermediate files
|
||||
max_files_cached = config["max_files_cached"]
|
||||
config["max_files_cached"] = float("inf")
|
||||
|
||||
@ -310,7 +344,7 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
|
||||
self.save_slides()
|
||||
self.__save_slides()
|
||||
|
||||
|
||||
class ThreeDSlide(Slide, ThreeDScene): # type: ignore
|
||||
|
@ -10,28 +10,10 @@ profile = "black"
|
||||
py_version = 38
|
||||
|
||||
[tool.mypy]
|
||||
check-untyped-defs = true
|
||||
# Disallow dynamic typing
|
||||
disallow-any-generics = true
|
||||
disallow-incomplete-defs = true
|
||||
disallow-subclassing-any = true
|
||||
# Disallow untyped definitions and calls
|
||||
disallow-untyped-defs = true
|
||||
ignore-missing-imports = true
|
||||
install-types = true
|
||||
# None and optional handling
|
||||
no-implicit-optional = true
|
||||
no-warn-return-any = true
|
||||
non-interactive = true
|
||||
disallow_untyped_decorators = false
|
||||
install_types = true
|
||||
python_version = "3.8"
|
||||
# Strict equality
|
||||
strict-equality = true
|
||||
warn-no-return = true
|
||||
warn-redundant-casts = true
|
||||
# Config file
|
||||
warn-unused-configs = true
|
||||
# Configuring warnings
|
||||
warn-unused-ignores = true
|
||||
strict = true
|
||||
|
||||
[tool.poetry]
|
||||
authors = [
|
||||
@ -61,7 +43,7 @@ packages = [
|
||||
]
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/jeertmans/manim-slides"
|
||||
version = "4.10.0"
|
||||
version = "4.11.0"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
click = "^8.1.3"
|
||||
|
Reference in New Issue
Block a user