diff --git a/.gitignore b/.gitignore index 2a6f891d..088fd7f8 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ paper/media/ coverage.xml rendering_times.csv +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 92401605..19ef11a8 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,11 @@ Using Manim Slides is a two-step process: The documentation is available [online](https://eertmans.be/manim-slides/). +### Note for wayland users + +It may happen that the presentation player won't work when in fullscreen mode and will simply block the transition requiring a restart. +An easy way of resolving this issue is to force Qt to use xwayland for rendering the window by setting the environment variable `QT_QPA_PLATFORM=xcb` + ### Basic Example Call `self.next_slide()` everytime you want to create a pause between diff --git a/example.py b/examplePresentation/example.py similarity index 95% rename from example.py rename to examplePresentation/example.py index b7594581..e85f21da 100644 --- a/example.py +++ b/examplePresentation/example.py @@ -11,6 +11,17 @@ class BasicExample(Slide): + """ + This is the note section for this group of slides. The fist section will appear as a note on all the subslides + ------ + Five (_or more_) dash `-` signs will indicate the beginning of a new section. these are the notes for slide .1 + ------- + Slide 2 + ------- + All this text **can** be written in markdown + - Easy of use + - Easy of write + """ def construct(self): circle = Circle(radius=3, color=BLUE) dot = Dot() diff --git a/examplePresentation/presentation.py b/examplePresentation/presentation.py new file mode 100644 index 00000000..196e985f --- /dev/null +++ b/examplePresentation/presentation.py @@ -0,0 +1,16 @@ +from examplePresentation.example import ConvertExample, ThreeDExample, BasicExample +from manim_slides.slide.presentation import Presentation + + +class MyPresentation(Presentation): + def __init__(self, *args, **kwargs): + super().__init__( + *args, **kwargs, + slides=[ + ConvertExample, + ThreeDExample, + BasicExample, + ], + output_path="./slides" + ) + diff --git a/manim_slides/config.py b/manim_slides/config.py index cf4917bc..ba116dc4 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -74,8 +74,8 @@ class Keys(BaseModel): # type: ignore[misc] 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") + # 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") @@ -188,18 +188,22 @@ def slides_slice(self) -> slice: class SlideConfig(BaseModel): # type: ignore[misc] file: FilePath rev_file: FilePath + thumbnail: FilePath loop: bool = False - auto_next: bool = False + auto_next: bool = False, + notes: str = "" @classmethod def from_pre_slide_config_and_files( - cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path + cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path, thumbnail: Path, notes: str ) -> "SlideConfig": return cls( file=file, rev_file=rev_file, loop=pre_slide_config.loop, auto_next=pre_slide_config.auto_next, + notes=notes, + thumbnail=thumbnail ) diff --git a/manim_slides/present/__init__.py b/manim_slides/present/__init__.py index 9781002e..0df682eb 100644 --- a/manim_slides/present/__init__.py +++ b/manim_slides/present/__init__.py @@ -1,3 +1,4 @@ +import json import signal import sys from pathlib import Path @@ -136,6 +137,20 @@ def str_to_int_or_none(value: str) -> Optional[int]: ) +def get_screen(app, number: Optional[int]): + if number is None: + return None + + try: + return app.screens()[number] + except IndexError: + logger.error( + f"Invalid screen number {number}, " + f"allowed values are from 0 to {len(app.screens()) - 1} (incl.)" + ) + return None + + @click.command() @click.argument("scenes", nargs=-1) @config_path_option @@ -149,13 +164,6 @@ def str_to_int_or_none(value: str) -> Optional[int]: 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, @@ -212,6 +220,22 @@ def str_to_int_or_none(value: str) -> Optional[int]: default=None, help="Present content on the given screen (a.k.a. display).", ) +@click.option( + "-s", + "--presenter-screen", + "presenter_screen_number", + metavar="NUMBER", + type=int, + default=None, + help="Screen where to display the presenter window", +) +@click.option( + "-p", + "--presenter-window", + "presenter_window", + is_flag=True, + help="Display presenter window", +) @click.option( "--playback-rate", metavar="RATE", @@ -220,10 +244,12 @@ def str_to_int_or_none(value: str) -> Optional[int]: help="Playback rate of the video slides, see PySide6 docs for details.", ) @click.option( - "--next-terminates-loop", - "next_terminates_loop", - is_flag=True, - help="If set, pressing next will turn any looping slide into a play slide.", + "--presentation-file", + "-P", + "presentation_file", + metavar="FILE", + default=None, + help="If set the the slide order will be read from the presentation file passed", ) @click.help_option("-h", "--help") @verbosity_option @@ -233,7 +259,6 @@ def present( folder: Path, start_paused: bool, full_screen: bool, - skip_all: bool, exit_after_last_slide: bool, hide_mouse: bool, aspect_ratio: str, @@ -242,7 +267,9 @@ def present( start_at_slide_number: int, screen_number: Optional[int], playback_rate: float, - next_terminates_loop: bool, + presentation_file: str, + presenter_screen_number: Optional[int], + presenter_window: bool ) -> None: """ Present SCENE(s), one at a time, in order. @@ -255,8 +282,12 @@ def present( Use ``manim-slide list-scenes`` to list all available scenes in a given folder. """ - if skip_all: - exit_after_last_slide = True + + if presentation_file: + with open(presentation_file) as p: + presentation = json.loads(p.read()) + folder = Path(presentation.get("root", "./slides")) + scenes = presentation.get("sequence", []) presentation_configs = get_scenes_presentation_config(scenes, folder) @@ -275,35 +306,30 @@ def present( if start_at[1]: start_at_slide_number = start_at[1] + slide_index = start_at_slide_number + sum([len(x.slides) for x in presentation_configs[:start_at_scene_number]]) + if slide_index < 0: + logger.error("First slide is number 0") + exit(2) + app = qapp() 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 + screen = get_screen(app, screen_number) + presenter_screen = get_screen(app, presenter_screen_number) 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, + slide_index=slide_index, screen=screen, + presenter_screen=presenter_screen, playback_rate=playback_rate, - next_terminates_loop=next_terminates_loop, + presenter_window=presenter_window ) player.show() diff --git a/manim_slides/present/player.py b/manim_slides/present/player.py index 268405f6..6ae269b0 100644 --- a/manim_slides/present/player.py +++ b/manim_slides/present/player.py @@ -1,368 +1,639 @@ -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.QtCore import Qt, QUrl, Signal, Slot, QMargins, QTimer +from PySide6.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen, QPixmap, QPalette, QAction, QFont from PySide6.QtMultimedia import QMediaPlayer from PySide6.QtMultimediaWidgets import QVideoWidget -from PySide6.QtWidgets import QDialog, QGridLayout, QLabel, QMainWindow +from PySide6.QtWidgets import QGridLayout, QLabel, QMainWindow, QScrollArea, QVBoxLayout, QHBoxLayout, QWidget, \ + QProgressBar, QLayout, QStatusBar, QTextEdit, QMenuBar, QMenu, QInputDialog, QLineEdit, QMdiSubWindow +from pydantic import FilePath -from ..config import Config, PresentationConfig, SlideConfig +from ..config import Config, PresentationConfig from ..logger import logger from ..resources import * # noqa: F403 +from ..wizard import init 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) +class PresentationSlide: + def __init__(self, file: FilePath, rev_file: FilePath, thumbnail: FilePath, loop: bool = False, + auto_next: bool = False, notes: str = ""): + self.thumbnail = thumbnail + self.file = file + self.rev_file = rev_file + self.loop = loop + self.auto_next = auto_next + self.notes = notes - 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, - playback_rate: float = 1.0, - next_terminates_loop: bool = False, - ): +class SlideSequenceElement(QWidget): + def __init__(self, scroll_parent, index, slide, selected, mp, last): 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_presentation_index = presentation_index - self.current_slide_index = slide_index - - self.__current_file: Path = self.current_slide_config.file - - self.__playing_reversed_slide = False - - # Widgets - - if screen: - self.setScreen(screen) - self.move(screen.geometry().topLeft()) - - if full_screen: - self.setWindowState(Qt.WindowFullScreen) + self.mp = mp + self.index = index + self.slide = slide + self.last = last + self.__scroll_parent = scroll_parent + self.__layout = QHBoxLayout() + self.__index_label = QLabel() + self.__img_label = QLabel() + self.__loop_label = QLabel() + self.__auto_play_label = QLabel() + self.__progress = QProgressBar() + + self.__selected_palette = QPalette() + self.__palette = QPalette() + self.__selected_palette.setColor(QPalette.ColorRole.Window, Qt.GlobalColor.yellow) + self.__selected_palette.setColor(self.__index_label.foregroundRole(), Qt.GlobalColor.black) + + self.init_gui(index, slide, selected) + + def set_index(self, i): + self.__index_label.setText(str(i)) + + def set_selected(self, selected=True): + self.setPalette(self.__selected_palette if selected else self.__palette) + self.__progress.setVisible(selected) + self.__scroll_parent.ensureWidgetVisible(self) + + def set_position(self, perchentage): + self.__progress.setValue(perchentage) + + def init_gui(self, i, slide, s): + self.setAutoFillBackground(True) + self.set_index(i) + + indicators = QWidget() + vl = QVBoxLayout() + vl.addWidget(self.__index_label) + + if slide.auto_next: + self.__auto_play_label.setText("A") + + if slide.loop: + self.__auto_play_label.setText("L") + + vl.addWidget(self.__loop_label) + vl.addWidget(self.__auto_play_label) + indicators.setLayout(vl) + + preview = QWidget() + l = QVBoxLayout() + + img = QPixmap(slide.thumbnail).scaledToWidth(200, Qt.TransformationMode.SmoothTransformation) + self.__img_label.setPixmap(img) + + self.__img_label.setFixedWidth(img.width()) + self.__img_label.setFixedHeight(img.height()) + self.__progress.setVisible(False) + self.__progress.setFixedHeight(8) + self.__progress.setFixedWidth(img.width()) + self.__progress.setTextVisible(False) + self.__progress.setValue(100 if not s else 0) + + l.addWidget(self.__img_label) + l.addWidget(self.__progress) + l.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) + l.setContentsMargins(QMargins(0, 0, 0, 0)) + preview.setLayout(l) + + self.__layout.addWidget(indicators, Qt.AlignmentFlag.AlignLeft) + self.__layout.addWidget(preview, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + self.setLayout(self.__layout) + self.set_selected(s) + self.__layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + play_index = max(0, self.index if not self.slide.loop else self.index - 1) + play_paused = not self.slide.loop + self.mp.load_slide(play_index, play_paused, False, True) + self.__progress.setValue(100) + + +class SlideList(QScrollArea): + def __init__(self, *args, slides, mp, slide_index, **kwargs): + super().__init__(*args, **kwargs) + self.slides = slides + self.active_slide = slide_index + self.slide_list_elements = [ + SlideSequenceElement(self, i, s, i == self.active_slide, mp, i == len(self.slides) - 1) for i, s in + enumerate(self.slides)] + self.slide_list = QWidget(self) + self.layout = QVBoxLayout(self.slide_list) + for s in self.slide_list_elements: + self.layout.addWidget(s) + self.setWidget(self.slide_list) + self.setFixedWidth(self.slide_list.width() + 24) + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + + def set_active_slide(self, index): + for i, s in enumerate(self.slide_list_elements): + if not ((index == self.active_slide) and (index == i)): + s.set_position(100) + self.slide_list_elements[self.active_slide].set_selected(False) + self.slide_list_elements[index].set_selected(True) + self.active_slide = index + + def slide_play_position_updated(self, index, perchentage): + index = max(min(index, len(self.slide_list_elements) - 1), 0) + self.slide_list_elements[index].set_position(perchentage) + + +class Timer(QLabel): + def __init__(self, start_paused): + super().__init__() + self.setText("00:00:00") + font = QFont() + font.setBold(True) + font.setPointSize(48) + self.setFont(font) + + self.__timer = QTimer(self) + self.__timer.timeout.connect(self.tick) + self.time = (0, 0, 0) + self.reset() + + if not start_paused: + self.start() 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.media_player.setPlaybackRate(playback_rate) - - self.presentation_changed.connect(self.presentation_changed_callback) - self.slide_changed.connect(self.slide_changed_callback) - - self.info = Info(parent=self) + self.pause() - # Connecting key callbacks - - self.config.keys.QUIT.connect(self.close) - 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 - self.next_terminates_loop = next_terminates_loop + def start(self): + self.__timer.start() - # Setting-up everything + def pause(self): + self.__timer.stop() - if skip_all: + def reset(self): + self.__timer.stop() + self.time = (0, 0, 0) + self.update() + self.__timer.setInterval(1000) + self.__timer.start() - 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) + def update(self): + self.setText("{:02d}:{:02d}:{:02d}".format(*self.time)) + def toggle(self): + if self.__timer.isActive(): + self.pause() else: + self.start() - def media_status_changed(status: QMediaPlayer.MediaStatus) -> None: - if ( - status == QMediaPlayer.EndOfMedia - and self.current_slide_config.auto_next - ): - self.load_next_slide() - - self.media_player.mediaStatusChanged.connect(media_status_changed) + @Slot() + def tick(self): + h, m, s = self.time + s += 1 + if s == 60: + s = 0 + m += 1 + if m == 60: + m = 0 + h += 1 + self.time = (h, m, s) + self.update() + + +class SlideInfo(QWidget): + def __init__(self, slide, next_slide, start_paused): + super().__init__() - if self.current_slide_config.loop: - self.media_player.setLoops(-1) + self.__cur_slide_label = QLabel() + self.__next_slide_label = QLabel() + self.__cur_img = QPixmap() + self.__next_img = QPixmap() + self.timer = Timer(start_paused) + self.__notes = QTextEdit() + self.__layout = QVBoxLayout() + + self.init_gui() + self.set_cur_slide(slide, next_slide) + + def init_gui(self): + self.__cur_slide_label.setPixmap(self.__cur_img) + self.__next_slide_label.setPixmap(self.__next_img) + + top_layout = QHBoxLayout() + next_slide_layout = QVBoxLayout() + next_slide = QWidget() + next_slide_layout.addWidget(self.__next_slide_label) + next_slide_layout.addWidget(self.timer) + self.timer.setAlignment(Qt.AlignmentFlag.AlignCenter) + next_slide.setLayout(next_slide_layout) + + top_layout.addWidget(self.__cur_slide_label) + top_layout.addWidget(next_slide) + + top = QWidget() + top.setLayout(top_layout) + self.__layout.addWidget(top) + + self.__notes.setReadOnly(True) + self.__layout.addWidget(self.__notes) + self.setLayout(self.__layout) + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.__notes.setFocusPolicy(Qt.FocusPolicy.NoFocus) + + def set_cur_slide(self, slide, next_slide): + self.__cur_img = QPixmap(slide.thumbnail).scaledToWidth(600, Qt.TransformationMode.SmoothTransformation) + self.__cur_slide_label.setPixmap(self.__cur_img) + self.__cur_slide_label.setFixedWidth(self.__cur_img.width()) + self.__cur_slide_label.setFixedHeight(self.__cur_img.height()) + self.__notes.setMarkdown(slide.notes) + + if next_slide is None: + self.__next_img = QPixmap() + self.__next_slide_label.setPixmap(self.__next_img) + return - self.load_current_media(start_paused=start_paused) + self.__next_img = QPixmap(next_slide.thumbnail).scaledToWidth(380, Qt.TransformationMode.SmoothTransformation) + self.__next_slide_label.setPixmap(self.__next_img) + self.__next_slide_label.setFixedWidth(self.__next_img.width()) + self.__next_slide_label.setFixedHeight(self.__next_img.height()) - self.presentation_changed.emit() - self.slide_changed.emit() - """ - Properties - """ +class Info(QMainWindow): # type: ignore[misc] + def __init__(self, *args: Any, config, presenter_screen, slides, slide_load_signal, start_slide, start_paused, + **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.slides = slides + self.config = config + self.slide_load_signal = slide_load_signal + self.slide_list_widget = SlideList(self, slides=self.slides, mp=self.parent().media_player, + slide_index=start_slide) + self.slide_info = SlideInfo(self.slides[start_slide], + self.slides[start_slide + 1] if start_slide < len(self.slides) - 1 else None, + start_paused) + + w = QWidget() + self.__layout = QGridLayout() + self.__layout.addWidget(self.slide_list_widget, 0, 0) + self.__layout.addWidget(self.slide_info, 0, 1) + w.setLayout(self.__layout) + self.setCentralWidget(w) + + self.slide_counter = QLabel() + self.slide_counter.setText(f"0 of {len(self.slides)}") + + self.status_bar = QStatusBar(self) + self.setStatusBar(self.status_bar) + self.status_bar.showMessage(f"Slides: {start_slide} of {len(self.slides)}") + + if presenter_screen is not None: + self.setScreen(presenter_screen) + self.move(presenter_screen.geometry().center()) + + self.slide_load_signal.connect(self.on_slide_changed) + self.parent().media_player.positionChanged.connect(self.position_changed) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.build_menu() + self.setWindowTitle(f"{WINDOW_NAME} - Presenter View") + self.show() + + def build_menu(self): + menu_bar = QMenuBar() + menu_bar.addMenu(self.build_presentation_menu()) + menu_bar.addMenu(self.build_playback_menu()) + menu_bar.addMenu(self.build_timer_menu()) + self.setMenuBar(menu_bar) + + def set_pb_speed_from_input(self): + new_speed, result = QInputDialog().getDouble( + self, + "Please insert the playback speed multiplier", + "Playback multiplier", + self.parent().media_player.playbackRate(), 0.01) + if result: + self.parent().media_player.setPlaybackRate(new_speed) + + def build_presentation_menu(self): + presentation_menu = QMenu("Presentation") + for text, tip, action in [ + ("Toggle Fullscreen", "Switch fullscreen mode", lambda: self.parent().toggle_full_screen()), + ("Toggle Mouse", "Hides or shows the mouse on the presentation window", + lambda: self.parent().toggle_mouse()), + ("Blank Screen", "Blanks the screen until a next or a play command is issued", + lambda: self.parent().media_player.setVisible(False)) + ]: + a = QAction(text, parent=presentation_menu) + a.setToolTip(tip) + a.triggered.connect(action) + presentation_menu.addAction(a) + + return presentation_menu + + def build_playback_menu(self): + playback_menu = QMenu("Playback") + for text, tip, action in [ + ("Playback speed...", "Opens the playback speed dialog", lambda: self.set_pb_speed_from_input()), + ("Play/Pause", "Start or stops the reproduction of the current transition if one is playing", + lambda: self.parent().toggle_play()), + ("Next slide", "Starts the transition to the next slide or, if it is already playing, ends it.", + lambda: self.parent().next()), + ("Previous slide", "Starts the transition in reverse or, if it is already playing, ends it.", + lambda: self.parent().prev()) + ]: + a = QAction(text, parent=playback_menu) + a.setToolTip(tip) + a.triggered.connect(action) + playback_menu.addAction(a) + return playback_menu + + def build_timer_menu(self): + timer_menu = QMenu("Time") + for text, action in [("Start/Stop timer", lambda: self.slide_info.timer.toggle()), + ("Reset timer", lambda: self.slide_info.timer.reset())]: + a = QAction(text, parent=timer_menu) + a.triggered.connect(action) + timer_menu.addAction(a) + return timer_menu - @property - def presentations_count(self) -> int: - return len(self.presentation_configs) + @Slot() + def position_changed(self, position): + mp = self.parent().media_player + index = mp.slide_index + (1 if not mp.playingForward else 0) + perch = int((position / mp.duration()) * 100) + self.slide_list_widget.slide_play_position_updated(index, 100 - perch if not mp.playingForward else perch) - @property - def current_presentation_index(self) -> int: - return self.__current_presentation_index + @Slot() + def on_slide_changed(self): + mp = self.parent().media_player + cur_index = mp.slide_index + if mp.position() == 0 and not mp.isPlaying(): + cur_index -= 1 + + cur_index = max(cur_index, 0) + self.status_bar.showMessage(f"Slides: {cur_index} of {len(self.slides)}") + self.slide_list_widget.set_active_slide(cur_index) + self.slide_info.set_cur_slide(self.slides[cur_index], + self.slides[cur_index + 1] if cur_index < len(self.slides) - 1 else None) + + def keyPressEvent(self, arg__1): + self.parent().keyPressEvent(arg__1) + + def closeEvent(self, arg__1): + self.parent().close() + + +class PresentationPlayer(QMediaPlayer): + def __init__(self, parent, aspect_ratio_mode, playback_rate, slides: List[PresentationSlide], slide_load_signal, + start_slide=0, start_paused=False, exit_after_last_slide=False): + super().__init__(parent) + self.video_player = QVideoWidget() + self.video_player.setAspectRatioMode(aspect_ratio_mode) + self.setVideoOutput(self.video_player) + self.setPlaybackRate(playback_rate) + self.slides = slides + self.slide_index = start_slide + self.slide_load_signal = slide_load_signal + self.playingForward = True + self.load_slide(self.slide_index, start_paused, False) + self.mediaStatusChanged.connect(self.media_finished) + self.exit_after_last_slide = exit_after_last_slide - @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}.") + @Slot() + def media_finished(self, status): + """Executed when a transition is finished.""" + if status == QMediaPlayer.MediaStatus.EndOfMedia: + logger.debug("Transition finished") + + # If we do not need to loop then ensure no looping on the player + if not self.slides[self.slide_index].loop or not self.playingForward: + self.setLoops(1) # do not loop anymore + + # If the slide is set on auto_loop, then execute next callback if we weren't playing it in reverse. + if self.slides[self.slide_index].auto_next and self.playingForward: + return self.next() + + if self.slide_index == len(self.slides) - 1 and self.exit_after_last_slide: + exit(0) + + if not self.playingForward and self.slide_index >= 0: + return self.load_slide(self.slide_index, True, False, True) + # self.load_slide(self.slide_index + 1, True, False) + + def load_slide(self, index, paused=False, reversed=False, end=False): + """Loads the i-th slide and updates the internal reference index""" + if index >= len(self.slides): + logger.warn("No more slides!") + return + if index < 0: + logger.warn("No previous slides!") return - self.presentation_changed.emit() + slide = self.slides[index] + self.slide_index = index + self.playingForward = not reversed + logger.debug( + f"Slide {index} loaded, PAUSED={paused}, REVERSE={reversed}, LOOP={slide.loop}, AUTO_NEXT={slide.auto_next}") - @property - def current_presentation_config(self) -> PresentationConfig: - return self.presentation_configs[self.current_presentation_index] + # Load the resource + url = QUrl.fromLocalFile(slide.file if not reversed else slide.rev_file) + self.setSource(url) - @property - def current_slides_count(self) -> int: - return len(self.current_presentation_config.slides) + # If the slide requires looping then set the looping method + self.setLoops(-1 if slide.loop and not reversed else 1) - @property - def current_slide_index(self) -> int: - return self.__current_slide_index + if end: + self.setPosition(self.duration()) - @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 + # If we need to prepare a slide in paused state then pause the player + if paused: + self.pause() else: - logger.warn(f"Could not set slide index to {index}.") - return - - self.slide_changed.emit() + self.play() - @property - def current_slide_config(self) -> SlideConfig: - return self.current_presentation_config.slides[self.current_slide_index] + self.slide_load_signal.emit() - @property - def current_file(self) -> Path: - return self.__current_file + def next(self): + if self.isPlayingForward() and not self.isLooping(): + # If we are going forward but not in a looping slide we jump to the end of the animation + self.pause() + self.setPosition(self.duration()) + return - @current_file.setter - def current_file(self, file: Path) -> None: - self.__current_file = file + if self.isLooping(): + # If we are looping we can go to the next transition without pause + return self.load_slide(self.slide_index + 1, False, False) + + if self.isPlayingBackward(): + # We are going from index+1 to index (index has already been decremented) so we can reset to index+1 + return self.load_slide(self.slide_index + 1, True, False) + + # We are not playing + if self.mediaStatus() == QMediaPlayer.MediaStatus.EndOfMedia: + # Media has finished, start directly the next transition because no one has prepared it paused + return self.load_slide(self.slide_index + 1, False, False) + + # We are not playing but the media is not finished. + if self.position() == 0: + # Someone has already prepared the slide for us + return self.load_slide(self.slide_index, False, False) + + if self.position() == self.duration(): + # No one loaded the slide for us, just do it. + return self.load_slide(self.slide_index + 1, False, False) + # Just keep playing + self.play() + + def previous(self): + if self.isPlaying(): + # If we are playing in a looping slide we will play the reverse of it from the current duration + if self.slides[self.slide_index].loop: + self.pause() + rev_position = self.duration() - self.position() + self.load_slide(self.slide_index, False, True) + self.slide_index -= 1 + self.setPosition(rev_position) + return + + # Every time we are playing something we are preparing this slide going forward paused. + # If we were going forward not looping, reset the transition to the beginning + # If we were already going backwards, skip transition and get ready for start + return self.load_slide(self.slide_index, True, False) + + # We are not playing + if self.position() == self.duration(): + # No one has preloaded the next transition so we are free to play the reverse and decrement + # (if possible) + self.load_slide(self.slide_index, False, True) + self.slide_index -= 1 + return - @property - def playing_reversed_slide(self) -> bool: - return self.__playing_reversed_slide + # Someone has already prepared the next slide and is not started. we are in reality to slide index - 1 + self.load_slide(self.slide_index - 1, False, True) + self.slide_index -= 1 - @playing_reversed_slide.setter - def playing_reversed_slide(self, playing_reversed_slide: bool) -> None: - self.__playing_reversed_slide = playing_reversed_slide + def get_video_player(self): + return self.video_player - """ - Loading slides - """ + def isLooping(self): + return self.isPlaying() and self.slides[self.slide_index].loop - def load_current_media(self, start_paused: bool = False) -> None: - url = QUrl.fromLocalFile(self.current_file) - self.media_player.setSource(url) + def isPlayingForward(self): + return self.isPlaying() and self.playingForward - if start_paused: - self.media_player.pause() - else: - self.media_player.play() + def isPlayingBackward(self): + return self.isPlaying() and not self.playingForward - def load_current_slide(self) -> None: - slide_config = self.current_slide_config - self.current_file = slide_config.file + def setVisible(self, visible): + self.video_player.setVisible(visible) - if slide_config.loop: - self.media_player.setLoops(-1) - else: - self.media_player.setLoops(1) - self.load_current_media() +def load_presentation(presentation_configs: List[PresentationConfig]): + slides = [] + resolution = (0, 0) + for p in presentation_configs: + resolution = (max(p.resolution[0], resolution[0]), max(p.resolution[1], resolution[1])) + for s in p.slides: + slides.append(PresentationSlide(s.file, s.rev_file, s.thumbnail, s.loop, s.auto_next, s.notes)) + return slides, resolution - 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 +class Player(QMainWindow): + slide_load_signal = Signal() - 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.close() - return - else: - logger.info("No more slide to play.") - return + def __init__( + self, + config: Config, + presentation_configs: List[PresentationConfig], + *, + start_paused: bool = False, + full_screen: bool = False, + exit_after_last_slide: bool = False, + hide_mouse: bool = False, + aspect_ratio_mode: Qt.AspectRatioMode = Qt.KeepAspectRatio, + slide_index: int = 0, + screen: Optional[QScreen] = None, + presenter_screen: Optional[QScreen] = None, + presenter_window: bool = False, + playback_rate: float = 1.0, + ): + super().__init__() - self.load_current_slide() + # Wizard's config + self.config = config - def load_reversed_slide(self) -> None: - self.playing_reversed_slide = True - self.current_file = self.current_slide_config.rev_file - self.load_current_media() + self.slides, self.resolution = load_presentation(presentation_configs) + self.setup_window(screen, full_screen, hide_mouse) + self.media_player = PresentationPlayer(self, aspect_ratio_mode, playback_rate, self.slides, + self.slide_load_signal, + slide_index, start_paused, exit_after_last_slide) + self.setCentralWidget(self.media_player.get_video_player()) - """ - Key callbacks and slots - """ + self.config.keys.NEXT.connect(self.next) + self.config.keys.PREVIOUS.connect(self.prev) + self.config.keys.FULL_SCREEN.connect(self.toggle_full_screen) + self.config.keys.HIDE_MOUSE.connect(self.toggle_mouse) + self.config.keys.PLAY_PAUSE.connect(self.toggle_play) + self.dispatch = self.config.keys.dispatch_key_function() - @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() + def next(self): + self.raise_() + self.setFocus(QtCore.Qt.FocusReason.MouseFocusReason) + self.activateWindow() + self.media_player.setVisible(True) + self.media_player.next() @Slot() - def close(self) -> None: - logger.info("Closing gracefully...") - super().close() + def prev(self): + self.raise_() + self.setFocus(QtCore.Qt.FocusReason.MouseFocusReason) + self.activateWindow() + self.media_player.previous() @Slot() - def next(self) -> None: - if self.media_player.playbackState() == QMediaPlayer.PausedState: - self.media_player.play() - elif self.next_terminates_loop and self.media_player.loops() != 1: - position = self.media_player.position() - self.media_player.setLoops(1) - self.media_player.stop() - self.media_player.setPosition(position) - self.media_player.play() + def toggle_full_screen(self): + if self.windowState() == Qt.WindowState.WindowFullScreen: + self.setWindowState(Qt.WindowState.WindowNoState) else: - self.load_next_slide() + self.setWindowState(self.windowState() ^ Qt.WindowState.WindowFullScreen) @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) + def toggle_mouse(self): + if self.cursor() == Qt.CursorShape.BlankCursor: + self.setCursor(Qt.CursorShape.ArrowCursor) else: - self.setWindowState(Qt.WindowFullScreen) + self.setCursor(Qt.CursorShape.BlankCursor) - @Slot() - def hide_mouse(self) -> None: - if self.cursor().shape() == Qt.BlankCursor: - self.setCursor(Qt.ArrowCursor) + def toggle_play(self): + if self.media_player.isPlaying(): + self.media_player.pause() else: - self.setCursor(Qt.BlankCursor) - - def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - self.close() + self.media_player.play() + self.media_player.setVisible(True) def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 key = event.key() self.dispatch(key) event.accept() + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 + self.close() + + def setup_window(self, screen, full_screen, hide_mouse): + if screen: + self.setScreen(screen) + self.move(screen.geometry().topLeft()) + + w, h = self.resolution + geometry = self.geometry() + geometry.setWidth(w) + geometry.setHeight(h) + self.setGeometry(geometry) + if full_screen: + self.toggle_full_screen() + + if hide_mouse: + self.toggle_mouse() + + self.setWindowTitle(WINDOW_NAME) + self.setWindowIcon(QIcon(":/icon.png")) diff --git a/manim_slides/slide/base.py b/manim_slides/slide/base.py index 2a5c9ecd..f5d4dbe0 100644 --- a/manim_slides/slide/base.py +++ b/manim_slides/slide/base.py @@ -1,6 +1,7 @@ __all__ = ["BaseSlide"] import platform +import re from abc import abstractmethod from pathlib import Path from typing import Any, List, MutableMapping, Optional, Sequence, Tuple, ValuesView @@ -11,8 +12,9 @@ from ..config import PresentationConfig, PreSlideConfig, SlideConfig from ..defaults import FFMPEG_BIN, FOLDER_PATH from ..logger import logger -from ..utils import concatenate_video_files, merge_basenames, reverse_video_file +from ..utils import concatenate_video_files, merge_basenames, reverse_video_file, generate_slide_thumbnail from . import MANIM +from manim import config if MANIM: from manim.mobject.mobject import Mobject @@ -24,7 +26,7 @@ class BaseSlide: def __init__( - self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any + self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any ) -> None: super().__init__(*args, **kwargs) self._output_folder: Path = output_folder @@ -253,7 +255,7 @@ def play(self, *args: Any, **kwargs: Any) -> None: self._current_animation += 1 def next_slide( - self, *, loop: bool = False, auto_next: bool = False, **kwargs: Any + self, *, loop: bool = False, auto_next: bool = False, **kwargs: Any ) -> None: """ Create a new slide with previous animations, and setup options @@ -379,8 +381,8 @@ def construct(self): def _add_last_slide(self) -> None: """Add a 'last' slide to the end of slides.""" if ( - len(self._slides) > 0 - and self._current_animation == self._slides[-1].end_animation + len(self._slides) > 0 + and self._current_animation == self._slides[-1].end_animation ): return @@ -398,6 +400,9 @@ def _save_slides(self, use_cache: bool = True) -> None: Note that cached files only work with Manim. """ + if not config.write_to_movie: + return + self._add_last_slide() files_folder = self._output_folder / "files" @@ -420,18 +425,22 @@ def _save_slides(self, use_cache: bool = True) -> None: slides: List[SlideConfig] = [] - for pre_slide_config in tqdm( - self._slides, - desc=f"Concatenating animation files to '{scene_files_folder}' and generating reversed animations", - leave=self._leave_progress_bar, - ascii=True if platform.system() == "Windows" else None, - disable=not self._show_progress_bar, - ): + notes = self.__doc__ if self.__doc__ is not None else "" + notes = [x.strip() for x in re.split("------*\n", notes)] + + for i, pre_slide_config in enumerate(tqdm( + self._slides, + desc=f"Concatenating animation files to '{scene_files_folder}' and generating reversed animations", + leave=self._leave_progress_bar, + ascii=True if platform.system() == "Windows" else None, + disable=not self._show_progress_bar, + )): slide_files = files[pre_slide_config.slides_slice] file = merge_basenames(slide_files) dst_file = scene_files_folder / file.name rev_file = scene_files_folder / f"{file.stem}_reversed{file.suffix}" + thumbnail_file = scene_files_folder / f"{file.stem}_thumb.png" # We only concat animations if it was not present if not use_cache or not dst_file.exists(): @@ -441,9 +450,19 @@ def _save_slides(self, use_cache: bool = True) -> None: if not use_cache or not rev_file.exists(): reverse_video_file(self._ffmpeg_bin, dst_file, rev_file) + # We generate the slide thumbnail from the last frame + if not use_cache or not thumbnail_file.exists(): + generate_slide_thumbnail(self._ffmpeg_bin, dst_file, thumbnail_file) + + # We generate the note text based on the full slide notes in the doc + note = notes[0] + if len(notes) > i + 1: + note += "\n\n" + notes[i + 1] + slides.append( SlideConfig.from_pre_slide_config_and_files( - pre_slide_config, dst_file, rev_file + pre_slide_config, dst_file, rev_file, thumbnail_file, + note.strip() ) ) @@ -464,10 +483,10 @@ def _save_slides(self, use_cache: bool = True) -> None: ) def wipe( - self, - *args: Any, - direction: np.ndarray = LEFT, - **kwargs: Any, + self, + *args: Any, + direction: np.ndarray = LEFT, + **kwargs: Any, ) -> None: """ Play a wipe animation that will shift all the current objects outside of the @@ -520,9 +539,9 @@ def construct(self): self.play(animation) def zoom( - self, - *args: Any, - **kwargs: Any, + self, + *args: Any, + **kwargs: Any, ) -> None: """ Play a zoom animation that will fade out all the current objects, and fade in diff --git a/manim_slides/slide/presentation.py b/manim_slides/slide/presentation.py new file mode 100644 index 00000000..456211f4 --- /dev/null +++ b/manim_slides/slide/presentation.py @@ -0,0 +1,28 @@ +__all__ = ["Presentation"] + +import json +from pathlib import Path +from typing import List, Any, Type +from manim import Scene + +from manim_slides.defaults import FOLDER_PATH +from manim_slides.slide import Slide + + +class Presentation(Scene): + def __init__(self, *args, slides: List[Type[Slide]], output_path=FOLDER_PATH, name="presentation", **kwargs: Any): + super().__init__(*args, **kwargs) + self.list = slides + self.output_path = output_path + self.name = name + self.args = args + self.kwargs = kwargs + + def render(self, preview: bool = False): + presentation_obj = {"root": self.output_path, "sequence": []} + for SlideClass in self.list: + presentation_obj["sequence"].append(SlideClass.__name__) + SlideClass(*self.args, **self.kwargs).render(preview=preview) + + with open(Path(self.output_path, f"{self.name}.json"), "w") as out: + out.write(json.dumps(presentation_obj)) diff --git a/manim_slides/utils.py b/manim_slides/utils.py index c575308f..0edffd30 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -64,7 +64,7 @@ def merge_basenames(files: List[Path]) -> Path: def reverse_video_file(ffmpeg_bin: Path, src: Path, dst: Path) -> None: - """Reverses a video file, writting the result to `dst`.""" + """Reverses a video file, writing the result to `dst`.""" command = [str(ffmpeg_bin), "-y", "-i", str(src), "-vf", "reverse", str(dst)] logger.debug(" ".join(command)) process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) @@ -75,3 +75,17 @@ def reverse_video_file(ffmpeg_bin: Path, src: Path, dst: Path) -> None: if error: logger.debug(error.decode()) + + +def generate_slide_thumbnail(ffmpeg_bin: Path, src: Path, dst: Path): + """Gets the last frame of a video file, writing the result to `dst`.""" + command = [str(ffmpeg_bin), "-sseof", "-1", "-i", str(src), "-update", "1", "-q:v", "1", str(dst)] + logger.debug(" ".join(command)) + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate() + + if output: + logger.debug(output.decode()) + + if error: + logger.debug(error.decode())