diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5fbef8f..1eedd47 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -46,7 +46,7 @@ jobs:
poetry install --with test
- name: Run pytest
- run: poetry run pytest -x
+ run: poetry run pytest -x -n auto
build-examples:
strategy:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 665dc35..32d54bf 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -27,6 +27,7 @@ repos:
rev: v0.0.284
hooks:
- id: ruff
+ args: [--fix]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.5.0
hooks:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1fd967e..25a8bce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,14 +13,42 @@ pull requests.
In an effort to better document changes, this CHANGELOG document is now created.
-### Chore
+### 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 F) 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)
diff --git a/manim_slides/config.py b/manim_slides/config.py
index f69c757..25f2e78 100644
--- a/manim_slides/config.py
+++ b/manim_slides/config.py
@@ -1,8 +1,7 @@
import json
import shutil
-from enum import Enum
from pathlib import Path
-from typing import Any, Dict, List, Optional, Set, Tuple, Union
+from typing import Any, Callable, Dict, List, Optional, Set, Tuple
import rtoml
from pydantic import (
@@ -10,6 +9,7 @@ from pydantic import (
Field,
FilePath,
PositiveInt,
+ PrivateAttr,
field_validator,
model_validator,
)
@@ -18,13 +18,31 @@ from PySide6.QtCore import Qt
from .logger import logger
+Receiver = Callable[..., Any]
-class Key(BaseModel): # type: ignore
+
+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]
"""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]:
@@ -43,14 +61,22 @@ class Key(BaseModel): # type: ignore
return m
+ @property
+ def signal(self) -> Signal:
+ return self.__signal
-class Keys(BaseModel): # type: ignore
+ def connect(self, function: Receiver) -> None:
+ self.__signal.connect(function)
+
+
+class Keys(BaseModel): # type: ignore[misc]
QUIT: Key = Key(ids=[Qt.Key_Q], name="QUIT")
- 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")
- REWIND: Key = Key(ids=[Qt.Key_R], name="REWIND")
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")
+ 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")
HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE")
@model_validator(mode="before")
@@ -74,8 +100,21 @@ class Keys(BaseModel): # type: ignore
return self
+ def dispatch_key_function(self) -> Callable[[PositiveInt], None]:
+ _dispatch = {}
-class Config(BaseModel): # type: ignore
+ 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]
"""General Manim Slides config"""
keys: Keys = Keys()
@@ -94,16 +133,10 @@ class Config(BaseModel): # type: ignore
return self
-class SlideType(str, Enum):
- slide = "slide"
- loop = "loop"
- last = "last"
-
-
class PreSlideConfig(BaseModel): # type: ignore
- type: SlideType
start_animation: int
end_animation: int
+ loop: bool = False
@field_validator("start_animation", "end_animation")
@classmethod
@@ -112,12 +145,12 @@ class PreSlideConfig(BaseModel): # type: ignore
raise ValueError("Animation index (start or end) cannot be negative")
return v
- @model_validator(mode="before")
+ @model_validator(mode="after")
def start_animation_is_before_end(
- 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:
+ 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:
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(...)`."
)
@@ -126,36 +159,26 @@ class PreSlideConfig(BaseModel): # type: ignore
"Start animation index must be strictly lower than end animation index"
)
- return values
+ return pre_slide_config
@property
def slides_slice(self) -> slice:
return slice(self.start_animation, self.end_animation)
-class SlideConfig(BaseModel): # type: ignore
- type: SlideType
+class SlideConfig(BaseModel): # type: ignore[misc]
file: FilePath
rev_file: FilePath
- terminated: bool = Field(False, exclude=True)
+ loop: bool = False
@classmethod
def from_pre_slide_config_and_files(
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
) -> "SlideConfig":
- return cls(type=pre_slide_config.type, file=file, rev_file=rev_file)
-
- 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
+ return cls(file=file, rev_file=rev_file, loop=pre_slide_config.loop)
-class PresentationConfig(BaseModel): # type: ignore
+class PresentationConfig(BaseModel): # type: ignore[misc]
slides: List[SlideConfig] = Field(min_length=1)
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
background_color: Color = "black"
diff --git a/manim_slides/convert.py b/manim_slides/convert.py
index de4731b..549a085 100644
--- a/manim_slides/convert.py
+++ b/manim_slides/convert.py
@@ -377,7 +377,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.is_loop():
+ if slide_config.loop:
yield f''
else:
yield f''
diff --git a/manim_slides/present.py b/manim_slides/present.py
deleted file mode 100644
index af0dbe7..0000000
--- a/manim_slides/present.py
+++ /dev/null
@@ -1,1084 +0,0 @@
-import os
-import platform
-import signal
-import sys
-import time
-from enum import Enum, IntFlag, auto, unique
-from pathlib import Path
-from typing import Any, Dict, List, Optional, Tuple, Union
-
-import click
-import cv2
-import numpy as np
-from click import Context, Parameter
-from pydantic import ValidationError
-from pydantic_extra_types.color import Color
-from PySide6.QtCore import Qt, QThread, Signal, Slot
-from PySide6.QtGui import (
- QCloseEvent,
- QIcon,
- QImage,
- QKeyEvent,
- QPixmap,
- QResizeEvent,
- QScreen,
-)
-from PySide6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget
-from tqdm import tqdm
-
-from .commons import config_path_option, verbosity_option
-from .config import DEFAULT_CONFIG, Config, PresentationConfig, SlideConfig
-from .defaults import FOLDER_PATH
-from .logger import logger
-from .resources import * # noqa: F401, F403
-
-os.environ.pop(
- "QT_QPA_PLATFORM_PLUGIN_PATH", None
-) # See why here: https://stackoverflow.com/a/67863156
-
-WINDOW_NAME = "Manim Slides"
-WINDOW_INFO_NAME = f"{WINDOW_NAME}: Info"
-WINDOWS = platform.system() == "Windows"
-
-
-class AspectRatio(Enum):
- ignore = Qt.IgnoreAspectRatio
- keep = Qt.KeepAspectRatio
- auto = "auto"
-
-
-ASPECT_RATIO_MODES = {
- "ignore": AspectRatio.ignore,
- "keep": AspectRatio.keep,
- "auto": AspectRatio.auto,
-}
-
-RESIZE_MODES = {
- "fast": Qt.FastTransformation,
- "smooth": Qt.SmoothTransformation,
-}
-
-
-@unique
-class State(IntFlag):
- """Represents all possible states of a slide presentation."""
-
- """A video is actively being played."""
- PLAYING = auto()
- """A video was manually paused."""
- PAUSED = auto()
- """Waiting for user to press next (or else)."""
- WAIT = auto()
- """Presentation was terminated."""
- END = auto()
-
- def __str__(self) -> str:
- return self.name.capitalize() # type: ignore
-
-
-def now() -> float:
- """Returns time.time() in seconds."""
- return time.time()
-
-
-class Presentation:
- """Creates presentation from a configuration object."""
-
- def __init__(self, config: PresentationConfig) -> None:
- self.config = config
-
- self.__current_slide_index: int = 0
- self.current_file: Path = self.current_slide.file
-
- self.loaded_slide_cap: int = -1
- self.cap = None # cap = cv2.VideoCapture
-
- self.reverse: bool = False
- self.reversed_slide: int = -1
-
- self.lastframe: Optional[np.ndarray] = None
-
- self.reset()
-
- def __len__(self) -> int:
- return len(self.slides)
-
- @property
- def slides(self) -> List[SlideConfig]:
- """Returns the list of slides."""
- return self.config.slides
-
- @property
- def resolution(self) -> Tuple[int, int]:
- """Returns the resolution."""
- return self.config.resolution
-
- @property
- def background_color(self) -> Color:
- """Returns the background color."""
- return self.config.background_color
-
- @property
- def current_slide_index(self) -> int:
- return self.__current_slide_index
-
- @current_slide_index.setter
- def current_slide_index(self, value: Optional[int]) -> None:
- if value is not None:
- if -len(self) <= value < len(self):
- self.__current_slide_index = value
- logger.debug(f"Set current slide index to {value}")
- else:
- logger.error(
- f"Could not load slide number {value}, playing first slide instead."
- )
-
- @property
- def current_slide(self) -> SlideConfig:
- """Returns currently playing slide."""
- return self.slides[self.current_slide_index]
-
- @property
- def first_slide(self) -> SlideConfig:
- """Returns first slide."""
- return self.slides[0]
-
- @property
- def last_slide(self) -> SlideConfig:
- """Returns last slide."""
- return self.slides[-1]
-
- def release_cap(self) -> None:
- """Releases current Video Capture, if existing."""
- if self.cap is not None:
- self.cap.release()
-
- self.loaded_slide_cap = -1
-
- def load_slide_cap(self, slide: int) -> None:
- """Loads video file of given slide."""
- # We must load a new VideoCapture file if:
- if (self.loaded_slide_cap != slide) or (
- self.reverse and self.reversed_slide != slide
- ): # cap already loaded
- logger.debug(f"Loading new cap for slide #{slide}")
-
- self.release_cap()
-
- if self.reverse:
- file = self.current_slide.rev_file
- self.reversed_slide = slide
- else:
- file = self.current_slide.file
-
- self.current_file = file
-
- self.cap = cv2.VideoCapture(file.as_posix())
- self.loaded_slide_cap = slide
-
- @property
- def current_cap(self) -> cv2.VideoCapture:
- """Returns current VideoCapture object."""
- self.load_slide_cap(self.current_slide_index)
- return self.cap
-
- def rewind_current_slide(self) -> None:
- """Rewinds current slide to first frame."""
- logger.debug("Rewinding current slide")
- self.current_slide.terminated = False
- self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
-
- def cancel_reverse(self) -> None:
- """Cancels any effet produced by a reversed slide."""
- if self.reverse:
- logger.debug("Cancelling effects from previous 'reverse' action'")
- self.reverse = False
- self.reversed_slide = -1
- self.release_cap()
-
- def reverse_current_slide(self) -> None:
- """Reverses current slide."""
- self.reverse = True
- self.rewind_current_slide()
-
- def load_next_slide(self) -> None:
- """Loads next slide."""
- logger.debug("Loading next slide")
- if self.reverse:
- self.cancel_reverse()
- self.rewind_current_slide()
- elif self.current_slide.is_last():
- self.current_slide.terminated = True
- else:
- self.current_slide_index = min(
- len(self.slides) - 1, self.current_slide_index + 1
- )
- self.rewind_current_slide()
-
- def load_previous_slide(self) -> None:
- """Loads previous slide."""
- logger.debug(f"Loading previous slide, current is {self.current_slide_index}")
- self.cancel_reverse()
- self.current_slide_index = max(0, self.current_slide_index - 1)
- logger.debug(f"Loading slide index {self.current_slide_index}")
- self.rewind_current_slide()
-
- @property
- def fps(self) -> int:
- """Returns the number of frames per second of the current video."""
- fps = self.current_cap.get(cv2.CAP_PROP_FPS)
- if fps == 0:
- logger.warn(
- f"Something is wrong with video file {self.current_file}, as the fps returned by frame {self.current_frame_number} is 0"
- )
- # TODO: understand why we sometimes get 0 fps
- return max(fps, 1) # type: ignore
-
- def reset(self) -> None:
- """Rests current presentation."""
- self.load_slide_cap(0)
- self.current_slide_index = 0
- self.slides[-1].terminated = False
-
- def load_last_slide(self) -> None:
- """Loads last slide."""
- self.current_slide_index = len(self.slides) - 1
- self.load_slide_cap(self.current_slide_index)
- self.slides[-1].terminated = False
-
- @property
- def is_last_slide(self) -> bool:
- """Returns True if current slide is the last one."""
- return self.current_slide_index == len(self.slides) - 1
-
- @property
- def current_frame_number(self) -> int:
- """Returns current frame number."""
- return int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
-
- def update_state(self, state: State) -> Tuple[np.ndarray, State]:
- """
- Updates the current state given the previous one.
-
- It does this by reading the video information and checking if the state is still correct.
- It returns the frame to show (lastframe) and the new state.
- """
- if state ^ State.PLAYING: # If not playing, we return the same
- if self.lastframe is None:
- _, self.lastframe = self.current_cap.read()
- return self.lastframe, state
-
- still_playing, frame = self.current_cap.read()
-
- if still_playing:
- self.lastframe = frame
- return self.lastframe, State.PLAYING
-
- # Video was terminated
- if self.current_slide.is_loop():
- if self.reverse:
- state = State.WAIT
-
- else:
- state = State.PLAYING
- self.rewind_current_slide()
- elif self.current_slide.is_last():
- state = State.END
- else:
- state = State.WAIT
-
- return self.lastframe, state
-
-
-class Display(QThread): # type: ignore
- """Displays one or more presentations one after each other."""
-
- change_video_signal = Signal(np.ndarray)
- change_info_signal = Signal(dict)
- change_presentation_signal = Signal()
- finished = Signal()
-
- def __init__(
- self,
- presentations: List[Presentation],
- config: Config = DEFAULT_CONFIG,
- start_paused: bool = False,
- skip_all: bool = False,
- record_to: Optional[str] = None,
- exit_after_last_slide: bool = False,
- start_at_scene_number: Optional[int] = None,
- start_at_slide_number: Optional[int] = None,
- ) -> None:
- super().__init__()
- self.presentations = presentations
- self.start_paused = start_paused
- self.config = config
- self.skip_all = skip_all
- self.record_to = record_to
- self.recordings: List[Tuple[Path, int, int]] = []
-
- self.state = State.PLAYING
- self.lastframe: Optional[np.ndarray] = None
-
- self.__current_presentation_index = 0
- self.current_presentation_index = start_at_scene_number # type: ignore
- self.current_presentation.current_slide_index = start_at_slide_number # type: ignore
-
- self.run_flag = True
-
- self.key = -1
- self.exit_after_last_slide = exit_after_last_slide
-
- def __len__(self) -> int:
- return len(self.presentations)
-
- @property
- def current_presentation_index(self) -> int:
- return self.__current_presentation_index
-
- @current_presentation_index.setter
- def current_presentation_index(self, value: Optional[int]) -> None:
- if value is not None:
- if -len(self) <= value < len(self):
- self.__current_presentation_index = value
- self.current_presentation.release_cap()
- self.change_presentation_signal.emit()
- else:
- logger.error(
- f"Could not load scene number {value}, playing first scene instead."
- )
-
- @property
- def current_presentation(self) -> Presentation:
- """Returns the current presentation."""
- return self.presentations[self.current_presentation_index]
-
- @property
- def current_resolution(self) -> Tuple[int, int]:
- """Returns the resolution of the current presentation."""
- return self.current_presentation.resolution
-
- @property
- def current_background_color(self) -> Color:
- """Returns the background color of the current presentation."""
- return self.current_presentation.background_color
-
- @property
- def is_last_presentation(self) -> bool:
- """Returns True if current presentation is the last one."""
- return self.current_presentation_index == len(self) - 1
-
- def start(self) -> None:
- super().start()
- self.change_presentation_signal.emit()
-
- def run(self) -> None:
- """Runs a series of presentations until end or exit."""
- while self.run_flag:
- last_time = now()
- self.lastframe, self.state = self.current_presentation.update_state(
- self.state
- )
- if self.state & (State.PLAYING | State.PAUSED):
- if self.start_paused:
- self.state = State.PAUSED
- self.start_paused = False
- if self.state & State.END:
- if self.current_presentation_index == len(self.presentations) - 1:
- if self.exit_after_last_slide:
- self.run_flag = False
- continue
-
- self.handle_key()
- self.show_video()
- self.show_info()
-
- lag = now() - last_time
- sleep_time = 1 / self.current_presentation.fps
-
- logger.log(
- 5,
- f"Took {lag:.3f} seconds to process the current frame, that must play at a rate of one every {sleep_time:.3f} seconds.",
- )
-
- if sleep_time - lag < 0:
- logger.warn(
- "The FPS rate could not be matched. "
- "This is normal when manually transitioning between slides.\n"
- "If you feel that the FPS are too low, "
- "consider checking this issue:\n"
- "https://github.com/jeertmans/manim-slides/issues/179."
- )
-
- sleep_time = max(sleep_time - lag, 0)
- time.sleep(sleep_time)
- last_time = now()
- self.current_presentation.release_cap()
-
- if self.record_to is not None:
- self.record_movie()
-
- logger.debug("Closing video thread gracefully and exiting")
- self.finished.emit()
-
- def record_movie(self) -> None:
- logger.debug(
- f"A total of {len(self.recordings)} frames will be saved to {self.record_to}"
- )
- file, frame_number, fps = self.recordings[0]
-
- cap = cv2.VideoCapture(str(file))
- cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number - 1)
- _, frame = cap.read()
-
- w, h = frame.shape[:2]
- fourcc = cv2.VideoWriter_fourcc(*"XVID")
- out = cv2.VideoWriter(self.record_to, fourcc, fps, (h, w))
-
- out.write(frame)
-
- for _file, frame_number, _ in tqdm(
- self.recordings[1:], desc="Creating recording file", leave=False
- ):
- if file != _file:
- cap.release()
- file = _file
- cap = cv2.VideoCapture(str(_file))
-
- cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number - 1)
- _, frame = cap.read()
- out.write(frame)
-
- cap.release()
- out.release()
-
- def show_video(self) -> None:
- """Shows updated video."""
- if self.record_to is not None:
- pres = self.current_presentation
- self.recordings.append(
- (pres.current_file, pres.current_frame_number, pres.fps)
- )
-
- frame: np.ndarray = self.lastframe
- self.change_video_signal.emit(frame)
-
- def show_info(self) -> None:
- """Shows updated information about presentations."""
- self.change_info_signal.emit(
- {
- "state": self.state,
- "slide_index": self.current_presentation.current_slide_index + 1,
- "n_slides": len(self.current_presentation.slides),
- "type": self.current_presentation.current_slide.type,
- "scene_index": self.current_presentation_index + 1,
- "n_scenes": len(self.presentations),
- }
- )
-
- @Slot(int)
- def set_key(self, key: int) -> None:
- """Sets the next key to be handled."""
- self.key = key
-
- def handle_key(self) -> None:
- """Handles key strokes."""
-
- key = self.key
- keys = self.config.keys
-
- if keys.QUIT.match(key):
- self.run_flag = False
- elif self.state == State.PLAYING and keys.PLAY_PAUSE.match(key):
- self.state = State.PAUSED
- elif self.state == State.PAUSED and keys.PLAY_PAUSE.match(key):
- self.state = State.PLAYING
- elif self.state & (State.END | State.WAIT) and (
- keys.CONTINUE.match(key) or keys.PLAY_PAUSE.match(key) or self.skip_all
- ):
- if (self.state & State.END) and not self.is_last_presentation:
- self.current_presentation_index += 1
- self.current_presentation.rewind_current_slide()
- else:
- self.current_presentation.load_next_slide()
- self.state = State.PLAYING
- elif (
- self.state == State.PLAYING and keys.CONTINUE.match(key)
- ) or self.skip_all:
- self.current_presentation.load_next_slide()
- elif keys.BACK.match(key):
- if self.current_presentation.current_slide_index == 0:
- if self.current_presentation_index == 0:
- self.current_presentation.load_previous_slide()
- else:
- self.current_presentation.cancel_reverse()
- self.current_presentation_index -= 1
- self.current_presentation.load_last_slide()
- self.state = State.PLAYING
- else:
- self.current_presentation.load_previous_slide()
- self.state = State.PLAYING
- elif keys.REVERSE.match(key):
- self.current_presentation.reverse_current_slide()
- self.state = State.PLAYING
- elif keys.REWIND.match(key):
- self.current_presentation.cancel_reverse()
- self.current_presentation.rewind_current_slide()
- self.state = State.PLAYING
-
- self.key = -1 # No more key to be handled
-
- def stop(self) -> None:
- """Stops current thread, without doing anything after."""
- self.run_flag = False
- self.wait()
-
-
-class Info(QWidget): # type: ignore
- def __init__(self) -> None:
- super().__init__()
- self.setWindowTitle(WINDOW_INFO_NAME)
-
- self.layout = QGridLayout()
-
- self.setLayout(self.layout)
-
- self.stateLabel = QLabel()
- self.slideLabel = QLabel()
- self.typeLabel = QLabel()
- self.sceneLabel = QLabel()
-
- self.layout.addWidget(self.stateLabel, 1, 0)
- self.layout.addWidget(self.slideLabel, 1, 1)
- self.layout.addWidget(self.typeLabel, 2, 0)
- self.layout.addWidget(self.sceneLabel, 2, 1)
-
- self.update_info({})
-
- @Slot(dict)
- def update_info(self, info: Dict[str, Union[str, int]]) -> None:
- self.stateLabel.setText("State: {}".format(info.get("state", "unknown")))
- self.slideLabel.setText(
- "Slide: {}/{}".format(
- info.get("slide_index", "na"), info.get("n_slides", "na")
- )
- )
- self.typeLabel.setText("Slide Type: {}".format(info.get("type", "unknown")))
- self.sceneLabel.setText(
- "Scene: {}/{}".format(
- info.get("scene_index", "na"), info.get("n_scenes", "na")
- )
- )
-
-
-class InfoThread(QThread): # type: ignore
- def __init__(self) -> None:
- super().__init__()
- self.dialog = Info()
- self.run_flag = True
-
- def start(self) -> None:
- super().start()
-
- self.dialog.show()
-
- def stop(self) -> None:
- self.dialog.deleteLater()
-
-
-class App(QWidget): # type: ignore
- send_key_signal = Signal(int)
-
- def __init__(
- self,
- *args: Any,
- config: Config = DEFAULT_CONFIG,
- fullscreen: bool = False,
- hide_mouse: bool = False,
- aspect_ratio: AspectRatio = AspectRatio.auto,
- resize_mode: Qt.TransformationMode = Qt.SmoothTransformation,
- background_color: str = "black",
- screen: Optional[QScreen] = None,
- **kwargs: Any,
- ):
- super().__init__()
-
- if screen:
- self.setScreen(screen)
- self.move(screen.geometry().topLeft())
-
- self.setWindowTitle(WINDOW_NAME)
- self.icon = QIcon(":/icon.png")
- self.setWindowIcon(self.icon)
-
- # create the video capture thread
- kwargs["config"] = config
- self.thread = Display(*args, **kwargs)
-
- self.display_width, self.display_height = self.thread.current_resolution
- self.aspect_ratio = aspect_ratio
- self.resize_mode = resize_mode
- self.hide_mouse = hide_mouse
- self.config = config
- if self.hide_mouse:
- self.setCursor(Qt.BlankCursor)
-
- self.label = QLabel(self)
-
- if self.aspect_ratio == AspectRatio.auto:
- self.label.setScaledContents(True)
- self.label.setAlignment(Qt.AlignCenter)
-
- self.pixmap = QPixmap(self.width(), self.height())
- self.label.setPixmap(self.pixmap)
- self.label.setMinimumSize(1, 1)
-
- # create the info dialog
- self.info = Info()
- self.info.show()
-
- # info widget will also listen to key presses
- self.info.keyPressEvent = self.keyPressEvent
-
- if fullscreen:
- self.showFullScreen()
- else:
- self.resize(self.display_width, self.display_height)
-
- # connect signals
- self.thread.change_video_signal.connect(self.update_image)
- self.thread.change_info_signal.connect(self.info.update_info)
- self.thread.change_presentation_signal.connect(self.update_canvas)
- self.thread.finished.connect(self.closeAll)
- self.send_key_signal.connect(self.thread.set_key)
-
- # start the thread
- self.thread.start()
-
- def keyPressEvent(self, event: QKeyEvent) -> None:
- key = event.key()
- if self.config.keys.HIDE_MOUSE.match(key):
- if self.hide_mouse:
- self.setCursor(Qt.ArrowCursor)
- self.hide_mouse = False
- else:
- self.setCursor(Qt.BlankCursor)
- self.hide_mouse = True
- # We send key to be handled by video display
- self.send_key_signal.emit(key)
- event.accept()
-
- def closeAll(self) -> None:
- logger.debug("Closing all QT windows")
- self.thread.stop()
- self.info.deleteLater()
- self.deleteLater()
-
- def resizeEvent(self, event: QResizeEvent) -> None:
- if not self.label.hasScaledContents():
- self.pixmap = self.pixmap.scaled(
- self.width(), self.height(), self.aspect_ratio.value, self.resize_mode
- )
- self.label.setPixmap(self.pixmap)
- self.label.resize(self.width(), self.height())
-
- def closeEvent(self, event: QCloseEvent) -> None:
- self.closeAll()
- event.accept()
-
- @Slot(np.ndarray)
- def update_image(self, cv_img: np.ndarray) -> None:
- """Updates the (image) label with a new opencv image"""
- h, w, ch = cv_img.shape
- bytes_per_line = ch * w
- qt_img = QImage(cv_img.data, w, h, bytes_per_line, QImage.Format_BGR888)
-
- if not self.label.hasScaledContents() and (
- w != self.width() or h != self.height()
- ):
- qt_img = qt_img.scaled(
- self.width(), self.height(), self.aspect_ratio.value, self.resize_mode
- )
-
- self.label.setPixmap(QPixmap.fromImage(qt_img))
-
- @Slot()
- def update_canvas(self) -> None:
- """Update the canvas when a presentation has changed."""
- logger.debug("Updating canvas")
- w, h = self.thread.current_resolution
-
- if not self.isFullScreen() and (
- self.display_width != w or self.display_height != h
- ):
- self.display_width, self.display_height = w, h
- self.resize(self.display_width, self.display_height)
- self.label.setStyleSheet(
- f"background-color: {self.thread.current_background_color}"
- )
-
-
-@click.command()
-@click.option(
- "--folder",
- metavar="DIRECTORY",
- default=FOLDER_PATH,
- type=click.Path(exists=True, file_okay=False, path_type=Path),
- help="Set slides folder.",
- show_default=True,
-)
-@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}"
- )
- pass
-
- 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
-@click.option(
- "--folder",
- metavar="DIRECTORY",
- default=FOLDER_PATH,
- type=click.Path(exists=True, file_okay=False, path_type=Path),
- help="Set slides folder.",
- show_default=True,
-)
-@click.option("--start-paused", is_flag=True, help="Start paused.")
-@click.option("--fullscreen", is_flag=True, help="Fullscreen 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(
- "-r",
- "--resolution",
- metavar="",
- type=(int, int),
- default=None,
- help="Window resolution WIDTH HEIGHT used if fullscreen is not set. You may manually resize the window afterward.",
-)
-@click.option(
- "--to",
- "--record-to",
- "record_to",
- metavar="FILE",
- type=click.Path(dir_okay=False, path_type=Path),
- default=None,
- help="If set, the presentation will be recorded into a AVI video file with given name.",
-)
-@click.option(
- "--exit-after-last-slide",
- is_flag=True,
- help="At the end of last slide, the application will be exited.",
-)
-@click.option(
- "--hide-mouse",
- is_flag=True,
- help="Hide mouse cursor.",
-)
-@click.option(
- "--aspect-ratio",
- type=click.Choice(ASPECT_RATIO_MODES.keys(), case_sensitive=False),
- default="auto",
- help="Set the aspect ratio mode to be used when rescaling video. `'auto'` option is equivalent to `'ignore'`, but can be much faster due to not calling `scaled()` method on every frame.",
- show_default=True,
-)
-@click.option(
- "--resize-mode",
- type=click.Choice(RESIZE_MODES.keys(), case_sensitive=False),
- default="smooth",
- help="Set the resize (i.e., transformation) mode to be used when rescaling video.",
- show_default=True,
-)
-@click.option(
- "--background-color",
- "--bgcolor",
- "background_color",
- metavar="COLOR",
- type=str,
- default=None,
- help='Set the background color for borders when using "keep" resize mode. Can be any valid CSS color, e.g., "green", "#FF6500" or "rgba(255, 255, 0, .5)". If not set, it defaults to the background color configured in the Manim scene.',
- show_default=True,
-)
-@click.option(
- "--sa",
- "--start-at",
- "start_at",
- metavar="",
- 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=None,
- help="Start presenting at a given scene number (0 is first, -1 is last).",
-)
-@click.option(
- "--sasn",
- "--start-at-slide-number",
- "start_at_slide_number",
- metavar="INDEX",
- type=int,
- default=None,
- help="Start presenting at a given slide number (0 is first, -1 is last).",
-)
-@click.option(
- "--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,
- fullscreen: bool,
- skip_all: bool,
- resolution: Optional[Tuple[int, int]],
- record_to: Optional[Path],
- exit_after_last_slide: bool,
- hide_mouse: bool,
- aspect_ratio: str,
- resize_mode: str,
- background_color: Optional[str],
- start_at: Tuple[Optional[int], Optional[int], Optional[int]],
- start_at_scene_number: Optional[int],
- start_at_slide_number: Optional[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 resolution is not None:
- for presentation_config in presentation_configs:
- presentation_config.resolution = resolution
-
- if background_color is not None:
- for presentation_config in presentation_configs:
- presentation_config.background_color = background_color
-
- presentations = [
- Presentation(presentation_config)
- for presentation_config in presentation_configs
- ]
-
- # TODO: remove me in v5
- if config_path.suffix == ".json" or Path(".manim-slides.json").exists():
- logger.warn(
- "Manim Slides now uses a TOML file for configuration. "
- "Please create a new configuration file with `manim-slides init` "
- "and move all the keys from your old config, if needed. "
- "Then, delete your old JSON config file."
- )
-
- 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 record_to is not None:
- ext = record_to.suffix
- if ext.lower() != ".avi":
- raise click.UsageError(
- "Recording only support '.avi' extension. "
- "For other video formats, "
- "please convert the resulting '.avi' file afterwards."
- )
-
- if start_at[0]:
- start_at_scene_number = start_at[0]
-
- if start_at[1]:
- start_at_slide_number = start_at[1]
-
- if not QApplication.instance():
- app = QApplication(sys.argv)
- else:
- app = QApplication.instance()
-
- 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
-
- a = App(
- presentations,
- config=config,
- start_paused=start_paused,
- fullscreen=fullscreen,
- skip_all=skip_all,
- record_to=record_to,
- exit_after_last_slide=exit_after_last_slide,
- hide_mouse=hide_mouse,
- aspect_ratio=ASPECT_RATIO_MODES[aspect_ratio],
- resize_mode=RESIZE_MODES[resize_mode],
- start_at_scene_number=start_at_scene_number,
- start_at_slide_number=start_at_slide_number,
- screen=screen,
- )
-
- a.show()
-
- # inform about CTRL+C
- def sigkill_handler(signum, frame): # type: ignore
- logger.warn(
- "Thie application cannot be closed with usual CTRL+C, "
- "please use the appropriate key defined in your config "
- "(default: q)."
- )
-
- raise KeyboardInterrupt
-
- signal.signal(signal.SIGINT, sigkill_handler)
- sys.exit(app.exec_())
diff --git a/manim_slides/present/__init__.py b/manim_slides/present/__init__.py
new file mode 100644
index 0000000..ea722b6
--- /dev/null
+++ b/manim_slides/present/__init__.py
@@ -0,0 +1,303 @@
+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="",
+ 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_())
diff --git a/manim_slides/present/player.py b/manim_slides/present/player.py
new file mode 100644
index 0000000..6f351c1
--- /dev/null
+++ b/manim_slides/present/player.py
@@ -0,0 +1,346 @@
+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 None:
+ index = self.current_slide_index
+ count = self.current_slides_count
+ self.info.slide_label.setText(f"{index+1:4d}/{count:4 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()
diff --git a/manim_slides/slide.py b/manim_slides/slide.py
index 3b6f940..5faedaa 100644
--- a/manim_slides/slide.py
+++ b/manim_slides/slide.py
@@ -15,7 +15,7 @@ from warnings import warn
import numpy as np
from tqdm import tqdm
-from .config import PresentationConfig, PreSlideConfig, SlideConfig, SlideType
+from .config import PresentationConfig, PreSlideConfig, SlideConfig
from .defaults import FOLDER_PATH
from .manim import (
LEFT,
@@ -354,10 +354,8 @@ class Slide(Scene): # type:ignore
self.__slides.append(
PreSlideConfig(
- type=SlideType.slide,
start_animation=self.__pause_start_animation,
end_animation=self.__current_animation,
- number=self.__current_slide,
)
)
self.__current_slide += 1
@@ -384,15 +382,13 @@ 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(
- type=SlideType.last,
start_animation=self.__pause_start_animation,
end_animation=self.__current_animation,
- number=self.__current_slide,
+ loop=self.__loop_start_animation is not None,
)
)
@@ -443,10 +439,9 @@ class Slide(Scene): # type:ignore
), "You have to start a loop before ending it"
self.__slides.append(
PreSlideConfig(
- type=SlideType.loop,
start_animation=self.__loop_start_animation,
end_animation=self.__current_animation,
- number=self.__current_slide,
+ loop=True,
)
)
self.__current_slide += 1
diff --git a/manim_slides/utils.py b/manim_slides/utils.py
index 457e6cb..56d1a2e 100644
--- a/manim_slides/utils.py
+++ b/manim_slides/utils.py
@@ -61,7 +61,7 @@ def merge_basenames(files: List[Path]) -> Path:
# https://github.com/jeertmans/manim-slides/issues/123
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
- logger.info(f"Generated a new basename for basenames: {basenames} -> '{basename}'")
+ logger.debug(f"Generated a new basename for basenames: {basenames} -> '{basename}'")
return dirname.joinpath(basename + ext)
diff --git a/manim_slides/wizard.py b/manim_slides/wizard.py
index 4f725f7..1cebdae 100644
--- a/manim_slides/wizard.py
+++ b/manim_slides/wizard.py
@@ -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)
+ label.setText(key_info.title())
self.layout.addWidget(label, i, 0)
# Create button that will pop-up a dialog and ask to input a new key
diff --git a/static/wizard_dark.png b/static/wizard_dark.png
index 41cb2a9..d90e4bb 100644
Binary files a/static/wizard_dark.png and b/static/wizard_dark.png differ
diff --git a/static/wizard_light.png b/static/wizard_light.png
index 5aa7f0d..471305f 100644
Binary files a/static/wizard_light.png and b/static/wizard_light.png differ
diff --git a/tests/data/slides/BasicSlide.json b/tests/data/slides/BasicSlide.json
index cf8d7a2..460bdc9 100644
--- a/tests/data/slides/BasicSlide.json
+++ b/tests/data/slides/BasicSlide.json
@@ -1,24 +1,24 @@
{
"slides": [
{
- "type": "slide",
"file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4",
- "rev_file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4"
+ "rev_file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4",
+ "loop": false
},
{
- "type": "loop",
"file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4",
- "rev_file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4"
+ "rev_file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4",
+ "loop": true
},
{
- "type": "slide",
"file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4",
- "rev_file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4"
+ "rev_file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4",
+ "loop": false
},
{
- "type": "last",
"file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4",
- "rev_file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4"
+ "rev_file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4",
+ "loop": false
}
],
"resolution": [
diff --git a/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4 b/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4
index d543e0f..40321d1 100644
Binary files a/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4 and b/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4 differ
diff --git a/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4 b/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4
index cbf709f..e8fcfca 100644
Binary files a/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4 and b/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4 differ
diff --git a/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4 b/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4
index d468763..a7d2443 100644
Binary files a/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4 and b/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4 differ
diff --git a/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4 b/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4
index 073050e..b85c338 100644
Binary files a/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4 and b/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4 differ
diff --git a/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4 b/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4
index e0cfd22..878f86f 100644
Binary files a/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4 and b/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4 differ
diff --git a/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4 b/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4
index a0ffe05..f80d681 100644
Binary files a/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4 and b/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4 differ
diff --git a/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4 b/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4
index 212137d..2f97236 100644
Binary files a/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4 and b/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4 differ
diff --git a/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4 b/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4
index 783f06e..1f557f9 100644
Binary files a/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4 and b/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4 differ