Compare commits
2 Commits
v5.0.0-rc1
...
v4
Author | SHA1 | Date | |
---|---|---|---|
5461a20257 | |||
5e3603b40b |
@ -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}
|
||||
|
||||
|
2
.github/workflows/tests.yml
vendored
@ -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
@ -17,7 +17,7 @@ videos/
|
||||
.manim-slides.toml
|
||||
|
||||
slides/
|
||||
!tests/data/slides/
|
||||
!tests/slides/
|
||||
|
||||
slides_assets/
|
||||
|
||||
|
@ -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]
|
||||
|
60
CHANGELOG.md
@ -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 -->
|
@ -29,4 +29,4 @@ keywords:
|
||||
- PowerPoint
|
||||
- Python
|
||||
license: MIT
|
||||
version: v5.0.0-rc1
|
||||
version: v4.16.1
|
||||
|
@ -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).
|
||||
|
10
example.py
@ -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
|
||||
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "5.0.0-rc1"
|
||||
__version__ = "4.16.1"
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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
@ -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_())
|
@ -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()
|
@ -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)
|
||||
|
@ -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())
|
@ -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
|
||||
|
BIN
paper/docs.png
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 4.3 MiB |
24
poetry.lock
generated
@ -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]]
|
||||
|
@ -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"
|
||||
|
BIN
static/docs.png
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 209 KiB |
Before Width: | Height: | Size: 485 KiB After Width: | Height: | Size: 670 KiB |
BIN
static/icon.png
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.0 KiB |
BIN
static/logo.png
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 110 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 112 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 113 KiB |
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 124 KiB |
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 116 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
@ -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()
|
||||
|
@ -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), []))
|
@ -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"
|
||||
}
|
32
tests/slides/BasicExample.json
Normal 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"
|
||||
}
|
@ -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()
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
@ -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
|