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