Compare commits

..

2 Commits

Author SHA1 Message Date
5461a20257 chore(version): bump 4.16.0 to 4.16.1 2023-08-24 13:16:13 +02:00
5e3603b40b fix(lib): correctly format enums on Python>=3.11
Closes #256

fix(tests): update tests and fix

chore(lib): simplify fix and more tests

chore(docs): document patch
2023-08-24 13:14:27 +02:00
57 changed files with 1701 additions and 1167 deletions

View File

@ -1,9 +1,5 @@
[bumpversion]
current_version = 5.0.0-rc1
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-rc(?P<release>\d+))?
serialize =
{major}.{minor}.{patch}-rc{release}
{major}.{minor}.{patch}
current_version = 4.16.1
commit = True
message = chore(version): bump {current_version} to {new_version}

View File

@ -46,7 +46,7 @@ jobs:
poetry install --with test
- name: Run pytest
run: poetry run pytest -x -n auto
run: poetry run pytest -x
build-examples:
strategy:

2
.gitignore vendored
View File

@ -17,7 +17,7 @@ videos/
.manim-slides.toml
slides/
!tests/data/slides/
!tests/slides/
slides_assets/

View File

@ -24,12 +24,11 @@ repos:
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.284
rev: v0.0.282
hooks:
- id: ruff
args: [--fix]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.5.0
rev: v1.4.1
hooks:
- id: mypy
additional_dependencies: [types-requests, types-setuptools]

View File

@ -1,60 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
<!-- start changelog -->
## [v5 (Unreleased)](https://github.com/jeertmans/languagetool-rust/compare/v4.16.0...HEAD)
Prior to v5, there was no real CHANGELOG other than the GitHub releases,
with most of the content automatically generated by GitHub from merged
pull requests.
In an effort to better document changes, this CHANGELOG document is now created.
### Added
- Added the following option aliases to `manim-slides present`:
`-F` and `--full-screen` for `fullscreen`,
`-H` for `--hide-mouse`,
and `-S` for `--screen-number`.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Added a full screen key binding (defaults to <kbd>F</kbd>) in the
presenter.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
### Changed
- Automatically concatenate all animations from a slide into one.
This is a **breaking change** because the config file format is
different from the previous one. For migration help, see associated PR.
[#242](https://github.com/jeertmans/manim-slides/pull/242)
- Changed the player interface to only use PySide6, and not a combination of
PySide6 and OpenCV. A few features have been removed (see removed section),
but the new player should be much easier to maintain and more performant,
than its predecessor.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Changed the slide config format to exclude unecessary information.
`StypeType` is removed in favor to one boolean `loop` field. This is
a **breaking change** and one should re-render the slides to apply changes.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Renamed key bindings in the config. This is a **breaking change** and one
should either manually rename them (see list below) or re-init a config.
List of changes: `CONTINUE` to `NEXT`, `BACK` to `PREVIOUS`, and
`REWIND` to `REPLAY`.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
### Removed
- Removed `--start-at-animation-number` option from `manim-slides present`.
[#242](https://github.com/jeertmans/manim-slides/pull/242)
- Removed the following options from `manim-slides present`:
`--resolution`, `--record-to`, `--resize-mode`, and `--background-color`.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Removed `PERF` verbosity level because not used anymore.
[#245](https://github.com/jeertmans/manim-slides/pull/245)
<!-- end changelog -->

View File

@ -29,4 +29,4 @@ keywords:
- PowerPoint
- Python
license: MIT
version: v5.0.0-rc1
version: v4.16.1

View File

@ -1,6 +1,3 @@
# Changelog
```{include} ../../CHANGELOG.md
:start-after: <!-- start changelog -->
:end-before: <!-- end changelog -->
```
Changes between releases are listed in Manim Slides' [Github releases](https://github.com/jeertmans/manim-slides/releases). You can read the [latest release here](https://github.com/jeertmans/manim-slides/releases).

View File

@ -2,14 +2,16 @@
# type: ignore
import sys
if "manimlib" in sys.modules:
if "manim" in sys.modules:
from manim import *
MANIMGL = False
elif "manimlib" in sys.modules:
from manimlib import *
MANIMGL = True
else:
from manim import *
MANIMGL = False
raise ImportError("This script must be run with either `manim` or `manimgl`")
from manim_slides import Slide, ThreeDSlide

View File

@ -1 +1 @@
__version__ = "5.0.0-rc1"
__version__ = "4.16.1"

View File

@ -54,10 +54,10 @@ def verbosity_option(function: F) -> F:
"-v",
"--verbosity",
type=click.Choice(
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
["PERF", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
case_sensitive=False,
),
help="Verbosity of CLI output.",
help="Verbosity of CLI output. PERF will log performances (timing) information.",
default=None,
expose_value=False,
envvar="MANIM_SLIDES_VERBOSITY",

View File

@ -1,7 +1,11 @@
import hashlib
import json
import shutil
import subprocess
import tempfile
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from typing import Any, Dict, List, Optional, Set, Tuple, Union
import rtoml
from pydantic import (
@ -9,40 +13,42 @@ from pydantic import (
Field,
FilePath,
PositiveInt,
PrivateAttr,
field_validator,
model_validator,
)
from pydantic_extra_types.color import Color
from PySide6.QtCore import Qt
from .defaults import FFMPEG_BIN
from .logger import logger
Receiver = Callable[..., Any]
def merge_basenames(files: List[FilePath]) -> Path:
"""
Merge multiple filenames by concatenating basenames.
"""
logger.info(f"Generating a new filename for animations: {files}")
dirname: Path = files[0].parent
ext = files[0].suffix
basenames = (file.stem for file in files)
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
# We use hashes to prevent too-long filenames, see issue #123:
# https://github.com/jeertmans/manim-slides/issues/123
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
return dirname.joinpath(basename + ext)
class Signal(BaseModel): # type: ignore[misc]
__receivers: List[Receiver] = PrivateAttr(default_factory=list)
def connect(self, receiver: Receiver) -> None:
self.__receivers.append(receiver)
def disconnect(self, receiver: Receiver) -> None:
self.__receivers.remove(receiver)
def emit(self, *args: Any) -> None:
for receiver in self.__receivers:
receiver(*args)
class Key(BaseModel): # type: ignore[misc]
class Key(BaseModel): # type: ignore
"""Represents a list of key codes, with optionally a name."""
ids: List[PositiveInt] = Field(unique=True)
name: Optional[str] = None
__signal: Signal = PrivateAttr(default_factory=Signal)
@field_validator("ids")
@classmethod
def ids_is_non_empty_set(cls, ids: Set[Any]) -> Set[Any]:
@ -61,22 +67,14 @@ class Key(BaseModel): # type: ignore[misc]
return m
@property
def signal(self) -> Signal:
return self.__signal
def connect(self, function: Receiver) -> None:
self.__signal.connect(function)
class Keys(BaseModel): # type: ignore[misc]
class Keys(BaseModel): # type: ignore
QUIT: Key = Key(ids=[Qt.Key_Q], name="QUIT")
PLAY_PAUSE: Key = Key(ids=[Qt.Key_Space], name="PLAY / PAUSE")
NEXT: Key = Key(ids=[Qt.Key_Right], name="NEXT")
PREVIOUS: Key = Key(ids=[Qt.Key_Left], name="PREVIOUS")
CONTINUE: Key = Key(ids=[Qt.Key_Right], name="CONTINUE / NEXT")
BACK: Key = Key(ids=[Qt.Key_Left], name="BACK")
REVERSE: Key = Key(ids=[Qt.Key_V], name="REVERSE")
REPLAY: Key = Key(ids=[Qt.Key_R], name="REPLAY")
FULL_SCREEN: Key = Key(ids=[Qt.Key_F], name="TOGGLE FULL SCREEN")
REWIND: Key = Key(ids=[Qt.Key_R], name="REWIND")
PLAY_PAUSE: Key = Key(ids=[Qt.Key_Space], name="PLAY / PAUSE")
HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE")
@model_validator(mode="before")
@ -100,21 +98,8 @@ class Keys(BaseModel): # type: ignore[misc]
return self
def dispatch_key_function(self) -> Callable[[PositiveInt], None]:
_dispatch = {}
for _, key in self:
for _id in key.ids:
_dispatch[_id] = key.signal
def dispatch(key: PositiveInt) -> None:
if signal := _dispatch.get(key, None):
signal.emit()
return dispatch
class Config(BaseModel): # type: ignore[misc]
class Config(BaseModel): # type: ignore
"""General Manim Slides config"""
keys: Keys = Keys()
@ -133,10 +118,18 @@ class Config(BaseModel): # type: ignore[misc]
return self
class PreSlideConfig(BaseModel): # type: ignore
class SlideType(str, Enum):
slide = "slide"
loop = "loop"
last = "last"
class SlideConfig(BaseModel): # type: ignore
type: SlideType
start_animation: int
end_animation: int
loop: bool = False
number: int
terminated: bool = Field(False, exclude=True)
@field_validator("start_animation", "end_animation")
@classmethod
@ -145,12 +138,19 @@ class PreSlideConfig(BaseModel): # type: ignore
raise ValueError("Animation index (start or end) cannot be negative")
return v
@model_validator(mode="after")
@field_validator("number")
@classmethod
def number_is_strictly_posint(cls, v: int) -> int:
if v <= 0:
raise ValueError("Slide number cannot be negative or zero")
return v
@model_validator(mode="before")
def start_animation_is_before_end(
cls, pre_slide_config: "PreSlideConfig"
) -> "PreSlideConfig":
if pre_slide_config.start_animation >= pre_slide_config.end_animation:
if pre_slide_config.start_animation == pre_slide_config.end_animation == 0:
cls, values: Dict[str, Union[SlideType, int, bool]]
) -> Dict[str, Union[SlideType, int, bool]]:
if values["start_animation"] >= values["end_animation"]: # type: ignore
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. IMPORTANT: when using ManimGL, `self.wait()` is not considered to be an animation, so prefer to directly use `self.play(...)`."
)
@ -159,27 +159,25 @@ class PreSlideConfig(BaseModel): # type: ignore
"Start animation index must be strictly lower than end animation index"
)
return pre_slide_config
return values
def is_slide(self) -> bool:
return self.type == SlideType.slide
def is_loop(self) -> bool:
return self.type == SlideType.loop
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 SlideConfig(BaseModel): # type: ignore[misc]
file: FilePath
rev_file: FilePath
loop: bool = False
@classmethod
def from_pre_slide_config_and_files(
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
) -> "SlideConfig":
return cls(file=file, rev_file=rev_file, loop=pre_slide_config.loop)
class PresentationConfig(BaseModel): # type: ignore[misc]
class PresentationConfig(BaseModel): # type: ignore
slides: List[SlideConfig] = Field(min_length=1)
files: List[FilePath]
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
background_color: Color = "black"
@ -189,15 +187,12 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
with open(path, "r") as f:
obj = json.load(f)
slides = obj.setdefault("slides", [])
parent = path.parent.parent # Never fails, but parents[1] can fail
for slide in slides:
if file := slide.get("file", None):
slide["file"] = parent / file
if rev_file := slide.get("rev_file", None):
slide["rev_file"] = parent / rev_file
if files := obj.get("files", None):
# First parent is ../slides
# so we take the parent of this parent
parent = Path(path).parents[1]
for i in range(len(files)):
files[i] = parent / files[i]
return cls.model_validate(obj) # type: ignore
@ -206,25 +201,104 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
with open(path, "w") as f:
f.write(self.model_dump_json(indent=2))
def copy_to(self, folder: Path, use_cached: bool = True) -> "PresentationConfig":
@model_validator(mode="after")
def animation_indices_match_files(
cls, config: "PresentationConfig"
) -> "PresentationConfig":
files = config.files
slides = config.slides
n_files = len(files)
for slide in slides:
if slide.end_animation > n_files:
raise ValueError(
f"The following slide's contains animations not listed in files {files}: {slide}"
)
return config
def copy_to(self, dest: Path, use_cached: bool = True) -> "PresentationConfig":
"""
Copy the files to a given directory.
"""
for slide_config in self.slides:
file = slide_config.file
rev_file = slide_config.rev_file
n = len(self.files)
for i in range(n):
file = self.files[i]
dest_path = dest / self.files[i].name
self.files[i] = dest_path
if use_cached and dest_path.exists():
logger.debug(f"Skipping copy of {file}, using cached copy")
continue
logger.debug(f"Copying {file} to {dest_path}")
shutil.copy(file, dest_path)
dest = folder / file.name
rev_dest = folder / rev_file.name
return self
slide_config.file = dest
slide_config.rev_file = rev_dest
def concat_animations(
self, dest: Optional[Path] = None, use_cached: bool = True
) -> "PresentationConfig":
"""
Concatenate animations such that each slide contains one animation.
"""
if not use_cached or not dest.exists():
shutil.copy(file, dest)
dest_paths = []
if not use_cached or not rev_dest.exists():
shutil.copy(rev_file, rev_dest)
for i, slide_config in enumerate(self.slides):
files = self.files[slide_config.slides_slice]
slide_config.start_animation = i
slide_config.end_animation = i + 1
if len(files) > 1:
dest_path = merge_basenames(files)
dest_paths.append(dest_path)
if use_cached and dest_path.exists():
logger.debug(f"Concatenated animations already exist for slide {i}")
continue
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
f.writelines(f"file '{path.absolute()}'\n" for path in files)
f.close()
command: List[str] = [
str(FFMPEG_BIN),
"-f",
"concat",
"-safe",
"0",
"-i",
f.name,
"-c",
"copy",
str(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())
if not dest_path.exists():
raise ValueError(
"could not properly concatenate animations, use `-v INFO` for more details"
)
else:
dest_paths.append(files[0])
self.files = dest_paths
if dest:
return self.copy_to(dest)
return self

View File

@ -140,43 +140,48 @@ class Str(str):
def __str__(self) -> str:
"""Ensures that the string is correctly quoted."""
if self in ["true", "false", "null"]:
return super().__str__()
return self
else:
return f"'{super().__str__()}'"
class StrEnum(Enum):
def __str__(self) -> str:
return str(self.value)
Function = str # Basically, anything
class JsTrue(str, Enum):
class JsTrue(str, StrEnum):
true = "true"
class JsFalse(str, Enum):
class JsFalse(str, StrEnum):
false = "false"
class JsBool(Str, Enum): # type: ignore
class JsBool(Str, StrEnum): # type: ignore
true = "true"
false = "false"
class JsNull(Str, Enum): # type: ignore
class JsNull(Str, StrEnum): # type: ignore
null = "null"
class ControlsLayout(Str, Enum): # type: ignore
class ControlsLayout(Str, StrEnum): # type: ignore
edges = "edges"
bottom_right = "bottom-right"
class ControlsBackArrows(Str, Enum): # type: ignore
class ControlsBackArrows(Str, StrEnum): # type: ignore
faded = "faded"
hidden = "hidden"
visibly = "visibly"
class SlideNumber(Str, Enum): # type: ignore
class SlideNumber(Str, StrEnum): # type: ignore
true = "true"
false = "false"
hdotv = "h.v"
@ -185,24 +190,24 @@ class SlideNumber(Str, Enum): # type: ignore
candt = "c/t"
class ShowSlideNumber(Str, Enum): # type: ignore
class ShowSlideNumber(Str, StrEnum): # type: ignore
all = "all"
print = "print"
speaker = "speaker"
class KeyboardCondition(Str, Enum): # type: ignore
class KeyboardCondition(Str, StrEnum): # type: ignore
null = "null"
focused = "focused"
class NavigationMode(Str, Enum): # type: ignore
class NavigationMode(Str, StrEnum): # type: ignore
default = "default"
linear = "linear"
grid = "grid"
class AutoPlayMedia(Str, Enum): # type: ignore
class AutoPlayMedia(Str, StrEnum): # type: ignore
null = "null"
true = "true"
false = "false"
@ -211,25 +216,25 @@ class AutoPlayMedia(Str, Enum): # type: ignore
PreloadIframes = AutoPlayMedia
class AutoAnimateMatcher(Str, Enum): # type: ignore
class AutoAnimateMatcher(Str, StrEnum): # type: ignore
null = "null"
class AutoAnimateEasing(Str, Enum): # type: ignore
class AutoAnimateEasing(Str, StrEnum): # type: ignore
ease = "ease"
AutoSlide = Union[PositiveInt, JsFalse]
class AutoSlideMethod(Str, Enum): # type: ignore
class AutoSlideMethod(Str, StrEnum): # type: ignore
null = "null"
MouseWheel = Union[JsNull, float]
class Transition(Str, Enum): # type: ignore
class Transition(Str, StrEnum): # type: ignore
none = "none"
fade = "fade"
slide = "slide"
@ -238,13 +243,13 @@ class Transition(Str, Enum): # type: ignore
zoom = "zoom"
class TransitionSpeed(Str, Enum): # type: ignore
class TransitionSpeed(Str, StrEnum): # type: ignore
default = "default"
fast = "fast"
slow = "slow"
class BackgroundSize(Str, Enum): # type: ignore
class BackgroundSize(Str, StrEnum): # type: ignore
# From: https://developer.mozilla.org/en-US/docs/Web/CSS/background-size
# TODO: support more background size
contain = "contain"
@ -254,11 +259,11 @@ class BackgroundSize(Str, Enum): # type: ignore
BackgroundTransition = Transition
class Display(Str, Enum): # type: ignore
class Display(Str, StrEnum): # type: ignore
block = "block"
class RevealTheme(str, Enum):
class RevealTheme(str, StrEnum):
black = "black"
white = "white"
league = "league"
@ -362,7 +367,7 @@ class RevealJS(Converter):
"""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 = slide_config.file
file = presentation_config.files[slide_config.start_animation]
logger.debug(f"Writing video section with file {file}")
@ -377,7 +382,7 @@ class RevealJS(Converter):
# Later, this might be useful to only mute the first video, or to make it optional.
# Read more about this:
# https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_and_autoplay_blocking
if slide_config.loop:
if slide_config.is_loop():
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted data-background-video-loop></section>'
else:
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted></section>'
@ -399,6 +404,9 @@ class RevealJS(Converter):
"""Converts this configuration into a RevealJS HTML presentation, saved to DEST."""
if self.data_uri:
assets_dir = Path("") # Actually we won't care.
for presentation_config in self.presentation_configs:
presentation_config.concat_animations()
else:
dirname = dest.parent
basename = dest.stem
@ -414,7 +422,7 @@ class RevealJS(Converter):
full_assets_dir.mkdir(parents=True, exist_ok=True)
for presentation_config in self.presentation_configs:
presentation_config.copy_to(full_assets_dir)
presentation_config.concat_animations().copy_to(full_assets_dir)
with open(dest, "w") as f:
sections = "".join(self.get_sections_iter(assets_dir))
@ -467,14 +475,15 @@ class PDF(Converter):
images = []
for i, presentation_config in enumerate(self.presentation_configs):
presentation_config.concat_animations()
for slide_config in tqdm(
presentation_config.slides,
desc=f"Generating video slides for config {i + 1}",
leave=False,
):
images.append(
read_image_from_video_file(slide_config.file, self.frame_index)
)
file = presentation_config.files[slide_config.start_animation]
images.append(read_image_from_video_file(file, self.frame_index))
images[0].save(
dest,
@ -527,7 +536,7 @@ class PowerPoint(Converter):
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
def save_first_image_from_video_file(file: Path) -> Optional[str]:
cap = cv2.VideoCapture(file.as_posix())
cap = cv2.VideoCapture(str(file))
ret, frame = cap.read()
if ret:
@ -539,12 +548,13 @@ class PowerPoint(Converter):
return None
for i, presentation_config in enumerate(self.presentation_configs):
presentation_config.concat_animations()
for slide_config in tqdm(
presentation_config.slides,
desc=f"Generating video slides for config {i + 1}",
leave=False,
):
file = slide_config.file
file = presentation_config.files[slide_config.start_animation]
mime_type = mimetypes.guess_type(file)[0]

View File

@ -7,6 +7,7 @@ import logging
from rich.console import Console
from rich.logging import RichHandler
from rich.theme import Theme
__all__ = ["logger", "make_logger"]
@ -35,8 +36,9 @@ def make_logger() -> logging.Logger:
RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS
rich_handler = RichHandler(
show_time=True,
console=Console(),
console=Console(theme=Theme({"logging.level.perf": "magenta"})),
)
logging.addLevelName(5, "PERF")
logger = logging.getLogger("manim-slides")
logger.setLevel(logging.getLogger("manim").level)
logger.addHandler(rich_handler)

1170
manim_slides/present.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,303 +0,0 @@
import signal
import sys
from pathlib import Path
from typing import List, Optional, Tuple
import click
from click import Context, Parameter
from pydantic import ValidationError
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
from ..commons import config_path_option, folder_path_option, verbosity_option
from ..config import Config, PresentationConfig
from ..logger import logger
from .player import Player
ASPECT_RATIO_MODES = {
"keep": Qt.KeepAspectRatio,
"ignore": Qt.IgnoreAspectRatio,
}
@click.command()
@folder_path_option
@click.help_option("-h", "--help")
@verbosity_option
def list_scenes(folder: Path) -> None:
"""List available scenes."""
for i, scene in enumerate(_list_scenes(folder), start=1):
click.secho(f"{i}: {scene}", fg="green")
def _list_scenes(folder: Path) -> List[str]:
"""Lists available scenes in given directory."""
scenes = []
for filepath in folder.glob("*.json"):
try:
_ = PresentationConfig.from_file(filepath)
scenes.append(filepath.stem)
except (
Exception
) as e: # Could not parse this file as a proper presentation config
logger.warn(
f"Something went wrong with parsing presentation config `{filepath}`: {e}"
)
logger.debug(f"Found {len(scenes)} valid scene configuration files in `{folder}`.")
return scenes
def prompt_for_scenes(folder: Path) -> 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 # type: ignore
except ValueError as e:
raise click.UsageError(str(e))
def get_scenes_presentation_config(
scenes: List[str], folder: Path
) -> 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 = folder / f"{scene}.json"
if not config_file.exists():
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.from_file(config_file))
except ValidationError as e:
raise click.UsageError(str(e))
return presentation_configs
def start_at_callback(
ctx: Context, param: Parameter, values: str
) -> Tuple[Optional[int], ...]:
if values == "(None, None)":
return (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 == 2:
return tuple(map(str_to_int_or_none, values_tuple))
raise click.BadParameter(
f"exactly 2 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
@folder_path_option
@click.option("--start-paused", is_flag=True, help="Start paused.")
@click.option(
"-F",
"--full-screen",
"--fullscreen",
"full_screen",
is_flag=True,
help="Toggle full screen mode.",
)
@click.option(
"-s",
"--skip-all",
is_flag=True,
help="Skip all slides, useful the test if slides are working. "
"Automatically sets `--exit-after-last-slide` to True.",
)
@click.option(
"--exit-after-last-slide",
is_flag=True,
help="At the end of last slide, the application will be exited.",
)
@click.option(
"-H",
"--hide-mouse",
is_flag=True,
help="Hide mouse cursor.",
)
@click.option(
"--aspect-ratio",
type=click.Choice(["keep", "ignore"], case_sensitive=False),
default="keep",
help="Set the aspect ratio mode to be used when rescaling the video.",
show_default=True,
)
@click.option(
"--sa",
"--start-at",
"start_at",
metavar="<SCENE,SLIDE>",
type=str,
callback=start_at_callback,
default=(None, None),
help="Start presenting at (x, y), equivalent to --sacn x --sasn y, "
"and overrides values if not None.",
)
@click.option(
"--sacn",
"--start-at-scene-number",
"start_at_scene_number",
metavar="INDEX",
type=int,
default=0,
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=0,
help="Start presenting at a given slide number (0 is first, -1 is last).",
)
@click.option(
"-S",
"--screen",
"screen_number",
metavar="NUMBER",
type=int,
default=None,
help="Presents content on the given screen (a.k.a. display).",
)
@click.help_option("-h", "--help")
@verbosity_option
def present(
scenes: List[str],
config_path: Path,
folder: Path,
start_paused: bool,
full_screen: bool,
skip_all: bool,
exit_after_last_slide: bool,
hide_mouse: bool,
aspect_ratio: str,
start_at: Tuple[Optional[int], Optional[int], Optional[int]],
start_at_scene_number: int,
start_at_slide_number: int,
screen_number: Optional[int] = None,
) -> None:
"""
Present SCENE(s), one at a time, in order.
Each SCENE parameter must be the name of a Manim scene,
with existing SCENE.json config file.
You can present the same SCENE multiple times by repeating the parameter.
Use ``manim-slide list-scenes`` to list all available
scenes in a given folder.
"""
if skip_all:
exit_after_last_slide = True
presentation_configs = get_scenes_presentation_config(scenes, folder)
if config_path.exists():
try:
config = Config.from_file(config_path)
except ValidationError as e:
raise click.UsageError(str(e))
else:
logger.debug("No configuration file found, using default configuration.")
config = Config()
if start_at[0]:
start_at_scene_number = start_at[0]
if start_at[1]:
start_at_scene_number = start_at[1]
if maybe_app := QApplication.instance():
app = maybe_app
else:
app = QApplication(sys.argv)
app.setApplicationName("Manim Slides")
if screen_number is not None:
try:
screen = app.screens()[screen_number]
except IndexError:
logger.error(
f"Invalid screen number {screen_number}, "
f"allowed values are from 0 to {len(app.screens())-1} (incl.)"
)
screen = None
else:
screen = None
player = Player(
config,
presentation_configs,
start_paused=start_paused,
full_screen=full_screen,
skip_all=skip_all,
exit_after_last_slide=exit_after_last_slide,
hide_mouse=hide_mouse,
aspect_ratio_mode=ASPECT_RATIO_MODES[aspect_ratio],
presentation_index=start_at_scene_number,
slide_index=start_at_slide_number,
screen=screen,
)
player.show()
signal.signal(signal.SIGINT, signal.SIG_DFL)
sys.exit(app.exec_())

View File

@ -1,346 +0,0 @@
from pathlib import Path
from typing import Any, List, Optional
from PySide6.QtCore import Qt, QUrl, Signal, Slot
from PySide6.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen
from PySide6.QtMultimedia import QMediaPlayer
from PySide6.QtMultimediaWidgets import QVideoWidget
from PySide6.QtWidgets import QDialog, QGridLayout, QLabel, QMainWindow
from ..config import Config, PresentationConfig, SlideConfig
from ..logger import logger
from ..resources import * # noqa: F401, F403
WINDOW_NAME = "Manim Slides"
class Info(QDialog): # type: ignore[misc]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
layout = QGridLayout()
self.scene_label = QLabel()
self.slide_label = QLabel()
layout.addWidget(QLabel("Scene:"), 1, 1)
layout.addWidget(QLabel("Slide:"), 2, 1)
layout.addWidget(self.scene_label, 1, 2)
layout.addWidget(self.slide_label, 2, 2)
self.setLayout(layout)
self.setFixedWidth(150)
self.setFixedHeight(80)
if parent := self.parent():
self.closeEvent = parent.closeEvent
self.keyPressEvent = parent.keyPressEvent
class Player(QMainWindow): # type: ignore[misc]
presentation_changed: Signal = Signal()
slide_changed: Signal = Signal()
def __init__(
self,
config: Config,
presentation_configs: List[PresentationConfig],
*,
start_paused: bool = False,
full_screen: bool = False,
skip_all: bool = False,
exit_after_last_slide: bool = False,
hide_mouse: bool = False,
aspect_ratio_mode: Qt.AspectRatioMode = Qt.KeepAspectRatio,
presentation_index: int = 0,
slide_index: int = 0,
screen: Optional[QScreen] = None,
):
super().__init__()
# Wizard's config
self.config = config
# Presentation configs
self.presentation_configs = presentation_configs
self.__current_presentation_index = 0
self.__current_slide_index = 0
self.__current_file: Path = self.current_slide_config.file
self.current_presentation_index = presentation_index
self.current_slide_index = slide_index
self.__playing_reversed_slide = False
# Widgets
if screen:
self.setScreen(screen)
self.move(screen.geometry().topLeft())
if full_screen:
self.setWindowState(Qt.WindowFullScreen)
else:
w, h = self.current_presentation_config.resolution
geometry = self.geometry()
geometry.setWidth(w)
geometry.setHeight(h)
self.setGeometry(geometry)
if hide_mouse:
self.setCursor(Qt.BlankCursor)
self.setWindowTitle(WINDOW_NAME)
self.icon = QIcon(":/icon.png")
self.setWindowIcon(self.icon)
self.video_widget = QVideoWidget()
self.video_widget.setAspectRatioMode(aspect_ratio_mode)
self.setCentralWidget(self.video_widget)
self.media_player = QMediaPlayer(self)
self.media_player.setVideoOutput(self.video_widget)
self.presentation_changed.connect(self.presentation_changed_callback)
self.slide_changed.connect(self.slide_changed_callback)
self.info = Info(parent=self)
# Connecting key callbacks
self.config.keys.QUIT.connect(self.quit)
self.config.keys.PLAY_PAUSE.connect(self.play_pause)
self.config.keys.NEXT.connect(self.next)
self.config.keys.PREVIOUS.connect(self.previous)
self.config.keys.REVERSE.connect(self.reverse)
self.config.keys.REPLAY.connect(self.replay)
self.config.keys.FULL_SCREEN.connect(self.full_screen)
self.config.keys.HIDE_MOUSE.connect(self.hide_mouse)
self.dispatch = self.config.keys.dispatch_key_function()
# Misc
self.exit_after_last_slide = exit_after_last_slide
# Setting-up everything
if skip_all:
def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
self.media_player.setLoops(1) # Otherwise looping slides never end
if status == QMediaPlayer.EndOfMedia:
self.load_next_slide()
self.media_player.mediaStatusChanged.connect(media_status_changed)
if self.current_slide_config.loop:
self.media_player.setLoops(-1)
self.load_current_media(start_paused=start_paused)
self.presentation_changed.emit()
self.slide_changed.emit()
"""
Properties
"""
@property
def presentations_count(self) -> int:
return len(self.presentation_configs)
@property
def current_presentation_index(self) -> int:
return self.__current_presentation_index
@current_presentation_index.setter
def current_presentation_index(self, index: int) -> None:
if 0 <= index < self.presentations_count:
self.__current_presentation_index = index
elif -self.presentations_count <= index < 0:
self.__current_presentation_index = index + self.presentations_count
else:
logger.warn(f"Could not set presentation index to {index}")
return
self.presentation_changed.emit()
@property
def current_presentation_config(self) -> PresentationConfig:
return self.presentation_configs[self.current_presentation_index]
@property
def current_slides_count(self) -> int:
return len(self.current_presentation_config.slides)
@property
def current_slide_index(self) -> int:
return self.__current_slide_index
@current_slide_index.setter
def current_slide_index(self, index: int) -> None:
if 0 <= index < self.current_slides_count:
self.__current_slide_index = index
elif -self.current_slides_count <= index < 0:
self.__current_slide_index = index + self.current_slides_count
else:
logger.warn(f"Could not set slide index to {index}")
return
self.slide_changed.emit()
@property
def current_slide_config(self) -> SlideConfig:
return self.current_presentation_config.slides[self.current_slide_index]
@property
def current_file(self) -> Path:
return self.__current_file
@current_file.setter
def current_file(self, file: Path) -> None:
self.__current_file = file
@property
def playing_reversed_slide(self) -> bool:
return self.__playing_reversed_slide
@playing_reversed_slide.setter
def playing_reversed_slide(self, playing_reversed_slide: bool) -> None:
self.__playing_reversed_slide = playing_reversed_slide
"""
Loading slides
"""
def load_current_media(self, start_paused: bool = False) -> None:
url = QUrl.fromLocalFile(self.current_file)
self.media_player.setSource(url)
if start_paused:
self.media_player.pause()
else:
self.media_player.play()
def load_current_slide(self) -> None:
slide_config = self.current_slide_config
self.current_file = slide_config.file
if slide_config.loop:
self.media_player.setLoops(-1)
else:
self.media_player.setLoops(1)
self.load_current_media()
def load_previous_slide(self) -> None:
self.playing_reversed_slide = False
if self.current_slide_index > 0:
self.current_slide_index -= 1
elif self.current_presentation_index > 0:
self.current_presentation_index -= 1
self.current_slide_index = self.current_slides_count - 1
else:
logger.info("No previous slide.")
return
self.load_current_slide()
def load_next_slide(self) -> None:
if self.playing_reversed_slide:
self.playing_reversed_slide = False
elif self.current_slide_index < self.current_slides_count - 1:
self.current_slide_index += 1
elif self.current_presentation_index < self.presentations_count - 1:
self.current_presentation_index += 1
self.current_slide_index = 0
elif self.exit_after_last_slide:
self.quit()
else:
logger.info("No more slide to play.")
return
self.load_current_slide()
def load_reversed_slide(self) -> None:
self.playing_reversed_slide = True
self.current_file = self.current_slide_config.rev_file
self.load_current_media()
"""
Key callbacks and slots
"""
@Slot()
def presentation_changed_callback(self) -> None:
index = self.current_presentation_index
count = self.presentations_count
self.info.scene_label.setText(f"{index+1:4d}/{count:4<d}")
@Slot()
def slide_changed_callback(self) -> None:
index = self.current_slide_index
count = self.current_slides_count
self.info.slide_label.setText(f"{index+1:4d}/{count:4<d}")
def show(self) -> None:
super().show()
self.info.show()
@Slot()
def quit(self) -> None:
logger.info("Closing gracefully...")
self.info.deleteLater()
self.deleteLater()
@Slot()
def next(self) -> None:
if self.media_player.playbackState() == QMediaPlayer.PausedState:
self.media_player.play()
else:
self.load_next_slide()
@Slot()
def previous(self) -> None:
self.load_previous_slide()
@Slot()
def reverse(self) -> None:
self.load_reversed_slide()
@Slot()
def replay(self) -> None:
self.media_player.setPosition(0)
self.media_player.play()
@Slot()
def play_pause(self) -> None:
state = self.media_player.playbackState()
if state == QMediaPlayer.PausedState:
self.media_player.play()
elif state == QMediaPlayer.PlayingState:
self.media_player.pause()
@Slot()
def full_screen(self) -> None:
if self.windowState() == Qt.WindowFullScreen:
self.setWindowState(Qt.WindowNoState)
else:
self.setWindowState(Qt.WindowFullScreen)
@Slot()
def hide_mouse(self) -> None:
if self.cursor().shape() == Qt.BlankCursor:
self.setCursor(Qt.ArrowCursor)
else:
self.setCursor(Qt.BlankCursor)
def closeEvent(self, event: QCloseEvent) -> None:
self.quit()
def keyPressEvent(self, event: QKeyEvent) -> None:
key = event.key()
self.dispatch(key)
event.accept()

View File

@ -1,4 +1,6 @@
import platform
import shutil
import subprocess
from pathlib import Path
from typing import (
Any,
@ -15,9 +17,10 @@ from warnings import warn
import numpy as np
from tqdm import tqdm
from .config import PresentationConfig, PreSlideConfig, SlideConfig
from .config import PresentationConfig, SlideConfig, SlideType
from .defaults import FOLDER_PATH
from .manim import (
FFMPEG_BIN,
LEFT,
MANIMGL,
AnimationGroup,
@ -29,7 +32,20 @@ from .manim import (
config,
logger,
)
from .utils import concatenate_video_files, merge_basenames, reverse_video_file
def reverse_video_file(src: Path, dst: Path) -> None:
"""Reverses a video file, writting the result to `dst`."""
command = [str(FFMPEG_BIN), "-y", "-i", str(src), "-vf", "reverse", str(dst)]
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())
class Slide(Scene): # type:ignore
@ -53,7 +69,7 @@ class Slide(Scene): # type:ignore
super().__init__(*args, **kwargs)
self.__output_folder: Path = output_folder
self.__slides: List[PreSlideConfig] = []
self.__slides: List[SlideConfig] = []
self.__current_slide = 1
self.__current_animation = 0
self.__loop_start_animation: Optional[int] = None
@ -353,9 +369,11 @@ class Slide(Scene): # type:ignore
self.wait(self.wait_time_between_slides)
self.__slides.append(
PreSlideConfig(
SlideConfig(
type=SlideType.slide,
start_animation=self.__pause_start_animation,
end_animation=self.__current_animation,
number=self.__current_slide,
)
)
self.__current_slide += 1
@ -382,13 +400,15 @@ class Slide(Scene): # type:ignore
len(self.__slides) > 0
and self.__current_animation == self.__slides[-1].end_animation
):
self.__slides[-1].type = SlideType.last
return
self.__slides.append(
PreSlideConfig(
SlideConfig(
type=SlideType.last,
start_animation=self.__pause_start_animation,
end_animation=self.__current_animation,
loop=self.__loop_start_animation is not None,
number=self.__current_slide,
)
)
@ -438,10 +458,11 @@ class Slide(Scene): # type:ignore
self.__loop_start_animation is not None
), "You have to start a loop before ending it"
self.__slides.append(
PreSlideConfig(
SlideConfig(
type=SlideType.loop,
start_animation=self.__loop_start_animation,
end_animation=self.__current_animation,
loop=True,
number=self.__current_slide,
)
)
self.__current_slide += 1
@ -463,57 +484,51 @@ class Slide(Scene): # type:ignore
scene_files_folder.mkdir(parents=True, exist_ok=True)
# When rendering with -na,b (manim only)
# the animations not in [a,b] will be skipped,
# but animation before a will have a None source file.
files: List[Path] = list(filter(None, self.__partial_movie_files))
# We must filter slides that end before the animation offset
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 = max(0, slide.start_animation - offset)
slide.end_animation -= offset
slides: List[SlideConfig] = []
for pre_slide_config in tqdm(
self.__slides,
desc=f"Concatenating animation files to '{scene_files_folder}' and generating reversed animations",
files = []
for src_file in tqdm(
self.__partial_movie_files,
desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations",
leave=self.__leave_progress_bar,
ascii=True if platform.system() == "Windows" else None,
disable=not self.__show_progress_bar,
):
slide_files = files[pre_slide_config.slides_slice]
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
file = merge_basenames(slide_files)
dst_file = scene_files_folder / file.name
rev_file = scene_files_folder / f"{file.stem}_reversed{file.suffix}"
dst_file = scene_files_folder / src_file.name
rev_file = scene_files_folder / f"{src_file.stem}_reversed{src_file.suffix}"
# We only concat animations if it was not present
# We only copy animation if it was not present
if not use_cache or not dst_file.exists():
concatenate_video_files(slide_files, dst_file)
shutil.copyfile(src_file, dst_file)
# We only reverse video if it was not present
if not use_cache or not rev_file.exists():
reverse_video_file(dst_file, rev_file)
reverse_video_file(src_file, rev_file)
slides.append(
SlideConfig.from_pre_slide_config_and_files(
pre_slide_config, dst_file, rev_file
)
)
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"Generated {len(slides)} slides to '{scene_files_folder.absolute()}'"
f"Copied {len(files)} animations to '{scene_files_folder.absolute()}' and generated reversed animations"
)
slide_path = self.__output_folder / f"{scene_name}.json"
PresentationConfig(
slides=slides,
slides=self.__slides,
files=files,
resolution=self.__resolution,
background_color=self.__background_color,
).to_file(slide_path)

View File

@ -1,80 +0,0 @@
import hashlib
import subprocess
import tempfile
from pathlib import Path
from typing import List
from .manim import FFMPEG_BIN, logger
def concatenate_video_files(files: List[Path], dest: Path) -> None:
"""
Concatenate multiple video files into one.
"""
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
f.writelines(f"file '{path.absolute()}'\n" for path in files)
f.close()
command: List[str] = [
str(FFMPEG_BIN),
"-f",
"concat",
"-safe",
"0",
"-i",
f.name,
"-c",
"copy",
str(dest),
"-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())
if not dest.exists():
raise ValueError(
"could not properly concatenate files, use `-v DEBUG` for more details"
)
def merge_basenames(files: List[Path]) -> Path:
"""
Merge multiple filenames by concatenating basenames.
"""
dirname: Path = files[0].parent
ext = files[0].suffix
basenames = list(file.stem for file in files)
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
# We use hashes to prevent too-long filenames, see issue #123:
# https://github.com/jeertmans/manim-slides/issues/123
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
logger.debug(f"Generated a new basename for basenames: {basenames} -> '{basename}'")
return dirname.joinpath(basename + ext)
def reverse_video_file(src: Path, dst: Path) -> None:
"""Reverses a video file, writting the result to `dst`."""
command = [str(FFMPEG_BIN), "-y", "-i", str(src), "-vf", "reverse", str(dst)]
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())

View File

@ -72,7 +72,7 @@ class Wizard(QWidget): # type: ignore
# Create label for key name information
label = QLabel()
key_info = value["name"] or key
label.setText(key_info.title())
label.setText(key_info)
self.layout.addWidget(label, i, 0)
# Create button that will pop-up a dialog and ask to input a new key

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 4.3 MiB

24
poetry.lock generated
View File

@ -4354,22 +4354,22 @@ files = [
[[package]]
name = "tornado"
version = "6.3.3"
version = "6.3.2"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
optional = false
python-versions = ">= 3.8"
files = [
{file = "tornado-6.3.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:502fba735c84450974fec147340016ad928d29f1e91f49be168c0a4c18181e1d"},
{file = "tornado-6.3.3-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:805d507b1f588320c26f7f097108eb4023bbaa984d63176d1652e184ba24270a"},
{file = "tornado-6.3.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd19ca6c16882e4d37368e0152f99c099bad93e0950ce55e71daed74045908f"},
{file = "tornado-6.3.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ac51f42808cca9b3613f51ffe2a965c8525cb1b00b7b2d56828b8045354f76a"},
{file = "tornado-6.3.3-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71a8db65160a3c55d61839b7302a9a400074c9c753040455494e2af74e2501f2"},
{file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:ceb917a50cd35882b57600709dd5421a418c29ddc852da8bcdab1f0db33406b0"},
{file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:7d01abc57ea0dbb51ddfed477dfe22719d376119844e33c661d873bf9c0e4a16"},
{file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9dc4444c0defcd3929d5c1eb5706cbe1b116e762ff3e0deca8b715d14bf6ec17"},
{file = "tornado-6.3.3-cp38-abi3-win32.whl", hash = "sha256:65ceca9500383fbdf33a98c0087cb975b2ef3bfb874cb35b8de8740cf7f41bd3"},
{file = "tornado-6.3.3-cp38-abi3-win_amd64.whl", hash = "sha256:22d3c2fa10b5793da13c807e6fc38ff49a4f6e1e3868b0a6f4164768bb8e20f5"},
{file = "tornado-6.3.3.tar.gz", hash = "sha256:e7d8db41c0181c80d76c982aacc442c0783a2c54d6400fe028954201a2e032fe"},
{file = "tornado-6.3.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:c367ab6c0393d71171123ca5515c61ff62fe09024fa6bf299cd1339dc9456829"},
{file = "tornado-6.3.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b46a6ab20f5c7c1cb949c72c1994a4585d2eaa0be4853f50a03b5031e964fc7c"},
{file = "tornado-6.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2de14066c4a38b4ecbbcd55c5cc4b5340eb04f1c5e81da7451ef555859c833f"},
{file = "tornado-6.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05615096845cf50a895026f749195bf0b10b8909f9be672f50b0fe69cba368e4"},
{file = "tornado-6.3.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b17b1cf5f8354efa3d37c6e28fdfd9c1c1e5122f2cb56dac121ac61baa47cbe"},
{file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:29e71c847a35f6e10ca3b5c2990a52ce38b233019d8e858b755ea6ce4dcdd19d"},
{file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:834ae7540ad3a83199a8da8f9f2d383e3c3d5130a328889e4cc991acc81e87a0"},
{file = "tornado-6.3.2-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6a0848f1aea0d196a7c4f6772197cbe2abc4266f836b0aac76947872cd29b411"},
{file = "tornado-6.3.2-cp38-abi3-win32.whl", hash = "sha256:7efcbcc30b7c654eb6a8c9c9da787a851c18f8ccd4a5a3a95b05c7accfa068d2"},
{file = "tornado-6.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:0c325e66c8123c606eea33084976c832aa4e766b7dff8aedd7587ea44a604cdf"},
{file = "tornado-6.3.2.tar.gz", hash = "sha256:4b927c4f19b71e627b13f3db2324e4ae660527143f9e1f2e2fb404f3a187e2ba"},
]
[[package]]

View File

@ -43,7 +43,7 @@ packages = [
]
readme = "README.md"
repository = "https://github.com/jeertmans/manim-slides"
version = "5.0.0-rc1"
version = "4.16.1"
[tool.poetry.dependencies]
click = "^8.1.3"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1,62 +1,13 @@
import random
import string
from pathlib import Path
from typing import Generator, Iterator, List
from typing import Iterator
import pytest
from manim_slides.config import PresentationConfig
from manim_slides.logger import make_logger
_ = make_logger() # This is run so that logger is created
_ = make_logger() # This is run so that "PERF" level is created
@pytest.fixture
def data_folder() -> Iterator[Path]:
path = (Path(__file__).parent / "data").resolve()
assert path.exists()
yield path
@pytest.fixture
def slides_folder(data_folder: Path) -> Iterator[Path]:
path = (data_folder / "slides").resolve()
assert path.exists()
yield path
@pytest.fixture
def slides_file(data_folder: Path) -> Iterator[Path]:
path = (data_folder / "slides.py").resolve()
assert path.exists()
yield path
def random_path(
length: int = 20,
dirname: Path = Path("./media/videos/example"),
suffix: str = ".mp4",
touch: bool = False,
) -> Path:
basename = "".join(random.choices(string.ascii_letters, k=length))
filepath = dirname.joinpath(basename + suffix)
if touch:
filepath.touch()
return filepath
@pytest.fixture
def paths() -> Generator[List[Path], None, None]:
random.seed(1234)
yield [random_path() for _ in range(20)]
@pytest.fixture
def presentation_config(
slides_folder: Path,
) -> Generator[PresentationConfig, None, None]:
yield PresentationConfig.from_file(slides_folder / "BasicSlide.json")
def folder_path() -> Iterator[Path]:
yield (Path(__file__).parent / "slides").resolve()

View File

@ -1,24 +0,0 @@
# flake8: noqa: F403, F405
# type: ignore
from manim import *
from manim_slides import Slide
class BasicSlide(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
dot = Dot()
self.play(GrowFromCenter(circle))
self.next_slide()
self.start_loop()
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
self.wait(2.0)
self.end_loop()
self.play(dot.animate.move_to(ORIGIN))
self.next_slide()
self.play(self.wipe(Group(dot, circle), []))

View File

@ -1,29 +0,0 @@
{
"slides": [
{
"file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4",
"rev_file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4",
"loop": false
},
{
"file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4",
"rev_file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4",
"loop": true
},
{
"file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4",
"rev_file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4",
"loop": false
},
{
"file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4",
"rev_file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4",
"loop": false
}
],
"resolution": [
854,
480
],
"background_color": "black"
}

View File

@ -0,0 +1,32 @@
{
"slides": [
{
"type": "slide",
"start_animation": 0,
"end_animation": 1,
"number": 1
},
{
"type": "loop",
"start_animation": 1,
"end_animation": 2,
"number": 2
},
{
"type": "last",
"start_animation": 2,
"end_animation": 3,
"number": 3
}
],
"files": [
"slides/files/BasicExample/1413466013_3346521118_223132457.mp4",
"slides/files/BasicExample/1672018281_3136302242_2191168284.mp4",
"slides/files/BasicExample/1672018281_1369283980_3942561600.mp4"
],
"resolution": [
1920,
1080
],
"background_color": "black"
}

View File

@ -95,7 +95,7 @@ def test_folder_path_option() -> None:
@pytest.mark.parametrize(
("verbosity",),
[("DEBUG",), ("info",), ("waRNING",), ("eRRor",), ("CrItIcAl",)],
[("PeRF",), ("DEBUG",), ("info",), ("waRNING",), ("eRRor",), ("CrItIcAl",)],
)
def test_valid_verbosity_option(verbosity: str) -> None:
@click.command()

View File

@ -1,9 +1,80 @@
from typing import Any
import random
import string
import tempfile
from pathlib import Path
from typing import Any, Generator, List
import pytest
from pydantic import ValidationError
from manim_slides.config import Key, PresentationConfig
from manim_slides.config import (
Key,
PresentationConfig,
SlideConfig,
SlideType,
merge_basenames,
)
def random_path(
length: int = 20,
dirname: Path = Path("./media/videos/example"),
suffix: str = ".mp4",
touch: bool = False,
) -> Path:
basename = "".join(random.choices(string.ascii_letters, k=length))
filepath = dirname.joinpath(basename + suffix)
if touch:
filepath.touch()
return filepath
@pytest.fixture
def paths() -> Generator[List[Path], None, None]:
random.seed(1234)
yield [random_path() for _ in range(20)]
@pytest.fixture
def presentation_config(paths: List[Path]) -> Generator[PresentationConfig, None, None]:
dirname = Path(tempfile.mkdtemp())
files = [random_path(dirname=dirname, touch=True) for _ in range(10)]
slides = [
SlideConfig(
type=SlideType.slide,
start_animation=0,
end_animation=5,
number=1,
),
SlideConfig(
type=SlideType.loop,
start_animation=5,
end_animation=6,
number=2,
),
SlideConfig(
type=SlideType.last,
start_animation=6,
end_animation=10,
number=3,
),
]
yield PresentationConfig(
slides=slides,
files=files,
)
def test_merge_basenames(paths: List[Path]) -> None:
path = merge_basenames(paths)
assert path.suffix == paths[0].suffix
assert path.parent == paths[0].parent
class TestKey:

View File

@ -1,6 +1,126 @@
from enum import EnumMeta
import pytest
from manim_slides.convert import PDF, Converter, PowerPoint, RevealJS
from manim_slides.convert import (
PDF,
AutoAnimateEasing,
AutoAnimateMatcher,
AutoPlayMedia,
AutoSlideMethod,
BackgroundSize,
BackgroundTransition,
ControlsBackArrows,
ControlsLayout,
Converter,
Display,
JsBool,
JsFalse,
JsNull,
JsTrue,
KeyboardCondition,
NavigationMode,
PowerPoint,
PreloadIframes,
RevealJS,
RevealTheme,
ShowSlideNumber,
SlideNumber,
Transition,
TransitionSpeed,
)
@pytest.mark.parametrize(
("enum_type",),
[
(JsTrue,),
(JsFalse,),
(JsBool,),
(JsNull,),
(ControlsLayout,),
(ControlsBackArrows,),
(SlideNumber,),
(ShowSlideNumber,),
(KeyboardCondition,),
(NavigationMode,),
(AutoPlayMedia,),
(PreloadIframes,),
(AutoAnimateMatcher,),
(AutoAnimateEasing,),
(AutoSlideMethod,),
(Transition,),
(TransitionSpeed,),
(BackgroundSize,),
(BackgroundTransition,),
(Display,),
(RevealTheme,),
],
)
def test_format_enum(enum_type: EnumMeta) -> None:
for enum in enum_type: # type: ignore[var-annotated]
expected = str(enum)
got = f"{enum}"
assert expected == got
got = "{enum}".format(enum=enum)
assert expected == got
got = format(enum, "")
assert expected == got
@pytest.mark.parametrize(
("enum_type",),
[
(ControlsLayout,),
(ControlsBackArrows,),
(SlideNumber,),
(ShowSlideNumber,),
(KeyboardCondition,),
(NavigationMode,),
(AutoPlayMedia,),
(PreloadIframes,),
(AutoAnimateMatcher,),
(AutoAnimateEasing,),
(AutoSlideMethod,),
(Transition,),
(TransitionSpeed,),
(BackgroundSize,),
(BackgroundTransition,),
(Display,),
],
)
def test_quoted_enum(enum_type: EnumMeta) -> None:
for enum in enum_type: # type: ignore[var-annotated]
if enum in ["true", "false", "null"]:
continue
expected = "'" + enum.value + "'"
got = str(enum)
assert expected == got
@pytest.mark.parametrize(
("enum_type",),
[
(JsTrue,),
(JsFalse,),
(JsBool,),
(JsNull,),
(RevealTheme,),
],
)
def test_unquoted_enum(enum_type: EnumMeta) -> None:
for enum in enum_type: # type: ignore[var-annotated]
expected = enum.value
got = str(enum)
assert expected == got
class TestConverter:

View File

@ -16,29 +16,29 @@ def test_help() -> None:
assert results.exit_code == 0
def test_defaults_to_present(slides_folder: Path) -> None:
def test_defaults_to_present(folder_path: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(
cli, ["BasicSlide", "--folder", str(slides_folder), "-s"]
cli, ["BasicExample", "--folder", str(folder_path), "-s"]
)
assert results.exit_code == 0
def test_present(slides_folder: Path) -> None:
def test_present(folder_path: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(
cli, ["present", "BasicSlide", "--folder", str(slides_folder), "-s"]
cli, ["present", "BasicExample", "--folder", str(folder_path), "-s"]
)
assert results.exit_code == 0
def test_convert(slides_folder: Path) -> None:
def test_convert(folder_path: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -46,10 +46,10 @@ def test_convert(slides_folder: Path) -> None:
cli,
[
"convert",
"BasicSlide",
"BasicExample",
"basic_example.html",
"--folder",
str(slides_folder),
str(folder_path),
],
)
@ -71,7 +71,7 @@ def test_init() -> None:
assert results.exit_code == 0
def test_list_scenes(slides_folder: Path) -> None:
def test_list_scenes(folder_path: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -80,12 +80,12 @@ def test_list_scenes(slides_folder: Path) -> None:
[
"list-scenes",
"--folder",
str(slides_folder),
str(folder_path),
],
)
assert results.exit_code == 0
assert "BasicSlide" in results.output
assert "BasicExample" in results.output
def test_wizard() -> None:

View File

@ -1,12 +1,7 @@
from pathlib import Path
import pytest
from click.testing import CliRunner
from manim import Text
from manim.__main__ import main as cli
from pydantic import ValidationError
from manim_slides.config import PresentationConfig
from manim_slides.slide import Slide
@ -19,41 +14,6 @@ def assert_construct(cls: type) -> type:
return Wrapper
def test_render_basic_examples(
slides_file: Path, presentation_config: PresentationConfig
) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(cli, [str(slides_file), "BasicSlide", "-ql"])
assert results.exit_code == 0
local_slides_folder = Path("slides")
assert local_slides_folder.exists()
local_config_file = local_slides_folder / "BasicSlide.json"
assert local_config_file.exists()
local_presentation_config = PresentationConfig.from_file(local_config_file)
assert len(local_presentation_config.slides) == len(presentation_config.slides)
assert (
local_presentation_config.background_color
== presentation_config.background_color
)
assert (
local_presentation_config.background_color
== presentation_config.background_color
)
assert local_presentation_config.resolution == presentation_config.resolution
class TestSlide:
@assert_construct
class TestLoop(Slide):

View File

@ -1,23 +0,0 @@
from pathlib import Path
from typing import List
from manim_slides.utils import merge_basenames
def test_merge_basenames(paths: List[Path]) -> None:
path = merge_basenames(paths)
assert path.suffix == paths[0].suffix
assert path.parent == paths[0].parent
def test_merge_basenames_same_with_different_parent_directories(
paths: List[Path],
) -> None:
d1 = Path("a/b/c")
d2 = Path("d/e/f")
p1 = d1 / "one.txt"
p2 = d1 / "a/b/c/two.txt"
p3 = d2 / "d/e/f/one.txt"
p4 = d2 / "d/e/f/two.txt"
assert merge_basenames([p1, p2]).name == merge_basenames([p3, p4]).name