feat(lib): enhance notes support (#324)

* feat(lib): enhance notes support

TODO: fix keyboard inputs and window order with `present`.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* chore(lint): allow too complex

* wip: presenter mode

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* feat(cli): add presenter view

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Jérome Eertmans
2023-11-28 15:15:56 +01:00
committed by GitHub
parent a9b8081167
commit 050ee0ae78
6 changed files with 208 additions and 27 deletions

View File

@ -3,6 +3,7 @@ import shutil
from functools import wraps from functools import wraps
from inspect import Parameter, signature from inspect import Parameter, signature
from pathlib import Path from pathlib import Path
from textwrap import dedent
from typing import Any, Callable, Dict, List, Optional, Set, Tuple from typing import Any, Callable, Dict, List, Optional, Set, Tuple
import rtoml import rtoml
@ -145,6 +146,7 @@ class BaseSlideConfig(BaseModel): # type: ignore
playback_rate: float = 1.0 playback_rate: float = 1.0
reversed_playback_rate: float = 1.0 reversed_playback_rate: float = 1.0
notes: str = "" notes: str = ""
dedent_notes: bool = True
@classmethod @classmethod
def wrapper(cls, arg_name: str) -> Callable[..., Any]: def wrapper(cls, arg_name: str) -> Callable[..., Any]:
@ -188,6 +190,16 @@ class BaseSlideConfig(BaseModel): # type: ignore
return _wrapper_ return _wrapper_
@model_validator(mode="after")
@classmethod
def apply_dedent_notes(
cls, base_slide_config: "BaseSlideConfig"
) -> "BaseSlideConfig":
if base_slide_config.dedent_notes:
base_slide_config.notes = dedent(base_slide_config.notes)
return base_slide_config
class PreSlideConfig(BaseSlideConfig): class PreSlideConfig(BaseSlideConfig):
"""Slide config to be used prior to rendering.""" """Slide config to be used prior to rendering."""

View File

@ -489,7 +489,7 @@ class PowerPoint(Converter):
def open(self, file: Path) -> None: def open(self, file: Path) -> None:
return open_with_default(file) return open_with_default(file)
def convert_to(self, dest: Path) -> None: def convert_to(self, dest: Path) -> None: # noqa: C901
"""Convert this configuration into a PowerPoint presentation, saved to DEST.""" """Convert this configuration into a PowerPoint presentation, saved to DEST."""
prs = pptx.Presentation() prs = pptx.Presentation()
prs.slide_width = self.width * 9525 prs.slide_width = self.width * 9525
@ -557,6 +557,9 @@ class PowerPoint(Converter):
poster_frame_image=poster_frame_image, poster_frame_image=poster_frame_image,
mime_type=mime_type, mime_type=mime_type,
) )
if slide_config.notes != "":
slide.notes_slide.notes_text_frame.text = slide_config.notes
if self.auto_play_media: if self.auto_play_media:
auto_play_media(movie, loop=slide_config.loop) auto_play_media(movie, loop=slide_config.loop)

View File

@ -1,11 +1,18 @@
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, List, Optional from typing import List, Optional
from PySide6.QtCore import Qt, QUrl, Signal, Slot from PySide6.QtCore import Qt, QTimer, QUrl, Signal, Slot
from PySide6.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen from PySide6.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen
from PySide6.QtMultimedia import QMediaPlayer from PySide6.QtMultimedia import QMediaPlayer
from PySide6.QtMultimediaWidgets import QVideoWidget from PySide6.QtMultimediaWidgets import QVideoWidget
from PySide6.QtWidgets import QDialog, QGridLayout, QLabel, QMainWindow, QVBoxLayout from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QMainWindow,
QVBoxLayout,
QWidget,
)
from ..config import Config, PresentationConfig, SlideConfig from ..config import Config, PresentationConfig, SlideConfig
from ..logger import logger from ..logger import logger
@ -14,33 +21,145 @@ from ..resources import * # noqa: F403
WINDOW_NAME = "Manim Slides" WINDOW_NAME = "Manim Slides"
class Info(QDialog): # type: ignore[misc] class Info(QWidget): # type: ignore[misc]
def __init__(self, *args: Any, **kwargs: Any) -> None: key_press_event: Signal = Signal(QKeyEvent)
super().__init__(*args, **kwargs) close_event: Signal = Signal(QCloseEvent)
def __init__(
self,
*,
full_screen: bool,
aspect_ratio_mode: Qt.AspectRatioMode,
screen: Optional[QScreen],
) -> None:
super().__init__()
if screen:
self.setScreen(screen)
self.move(screen.geometry().topLeft())
if full_screen:
self.setWindowState(Qt.WindowFullScreen)
layout = QHBoxLayout()
# Current slide view
left_layout = QVBoxLayout()
left_layout.addWidget(
QLabel("Current slide"),
alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter,
)
main_video_widget = QVideoWidget()
main_video_widget.setAspectRatioMode(aspect_ratio_mode)
main_video_widget.setFixedSize(720, 480)
self.video_sink = main_video_widget.videoSink()
left_layout.addWidget(main_video_widget)
# Current slide informations
main_layout = QVBoxLayout()
labels_layout = QGridLayout()
notes_layout = QVBoxLayout()
self.scene_label = QLabel() self.scene_label = QLabel()
self.slide_label = QLabel() self.slide_label = QLabel()
self.slide_notes = QLabel("") self.start_time = datetime.now()
self.time_label = QLabel()
self.elapsed_label = QLabel("00h00m00s")
self.timer = QTimer()
self.timer.start(1000) # every second
self.timer.timeout.connect(self.update_time)
bottom_left_layout = QHBoxLayout()
bottom_left_layout.addWidget(
QLabel("Scene:"),
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
)
bottom_left_layout.addWidget(
self.scene_label,
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
bottom_left_layout.addWidget(
QLabel("Slide:"),
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
)
bottom_left_layout.addWidget(
self.slide_label,
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
bottom_left_layout.addWidget(
QLabel("Time:"),
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
)
bottom_left_layout.addWidget(
self.time_label,
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
bottom_left_layout.addWidget(
QLabel("Elapsed:"),
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
)
bottom_left_layout.addWidget(
self.elapsed_label,
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
left_layout.addLayout(bottom_left_layout)
layout.addLayout(left_layout)
layout.addSpacing(20)
# Next slide preview
right_layout = QVBoxLayout()
right_layout.addWidget(
QLabel("Next slide"),
alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter,
)
next_video_widget = QVideoWidget()
next_video_widget.setAspectRatioMode(aspect_ratio_mode)
next_video_widget.setFixedSize(360, 240)
self.next_media_player = QMediaPlayer()
self.next_media_player.setVideoOutput(next_video_widget)
self.next_media_player.setLoops(-1)
right_layout.addWidget(next_video_widget)
# Notes
self.slide_notes = QLabel()
self.slide_notes.setWordWrap(True) self.slide_notes.setWordWrap(True)
self.slide_notes.setTextFormat(Qt.TextFormat.MarkdownText)
self.slide_notes.setFixedWidth(360)
right_layout.addWidget(
self.slide_notes,
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
layout.addLayout(right_layout)
labels_layout.addWidget(QLabel("Scene:"), 1, 1) widget = QWidget()
labels_layout.addWidget(QLabel("Slide:"), 2, 1)
labels_layout.addWidget(self.scene_label, 1, 2)
labels_layout.addWidget(self.slide_label, 2, 2)
notes_layout.addWidget(self.slide_notes) widget.setLayout(layout)
main_layout.addLayout(labels_layout) main_layout = QVBoxLayout()
main_layout.addLayout(notes_layout) main_layout.addWidget(widget, alignment=Qt.AlignmentFlag.AlignCenter)
self.setLayout(main_layout) self.setLayout(main_layout)
if parent := self.parent(): @Slot()
self.closeEvent = parent.closeEvent def update_time(self) -> None:
self.keyPressEvent = parent.keyPressEvent now = datetime.now()
seconds = (now - self.start_time).total_seconds()
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
self.time_label.setText(now.strftime("%Y/%m/%d %H:%M:%S"))
self.elapsed_label.setText(
f"{int(hours):02d}h{int(minutes):02d}m{int(seconds):02d}s"
)
@Slot()
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.close_event.emit(event)
@Slot()
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
self.key_press_event.emit(event)
class Player(QMainWindow): # type: ignore[misc] class Player(QMainWindow): # type: ignore[misc]
@ -107,6 +226,7 @@ class Player(QMainWindow): # type: ignore[misc]
self.setWindowIcon(self.icon) self.setWindowIcon(self.icon)
self.video_widget = QVideoWidget() self.video_widget = QVideoWidget()
self.video_sink = self.video_widget.videoSink()
self.video_widget.setAspectRatioMode(aspect_ratio_mode) self.video_widget.setAspectRatioMode(aspect_ratio_mode)
self.setCentralWidget(self.video_widget) self.setCentralWidget(self.video_widget)
@ -117,7 +237,14 @@ class Player(QMainWindow): # type: ignore[misc]
self.presentation_changed.connect(self.presentation_changed_callback) self.presentation_changed.connect(self.presentation_changed_callback)
self.slide_changed.connect(self.slide_changed_callback) self.slide_changed.connect(self.slide_changed_callback)
self.info = Info(parent=self) self.info = Info(
full_screen=full_screen, aspect_ratio_mode=aspect_ratio_mode, screen=screen
)
self.info.close_event.connect(self.closeEvent)
self.info.key_press_event.connect(self.keyPressEvent)
self.video_sink.videoFrameChanged.connect(
lambda frame: self.info.video_sink.setVideoFrame(frame)
)
self.hide_info_window = hide_info_window self.hide_info_window = hide_info_window
# Connecting key callbacks # Connecting key callbacks
@ -228,6 +355,28 @@ class Player(QMainWindow): # type: ignore[misc]
def current_file(self, file: Path) -> None: def current_file(self, file: Path) -> None:
self.__current_file = file self.__current_file = file
@property
def next_slide_config(self) -> Optional[SlideConfig]:
if self.playing_reversed_slide:
return self.current_slide_config
elif self.current_slide_index < self.current_slides_count - 1:
return self.presentation_configs[self.current_presentation_index].slides[
self.current_slide_index + 1
]
elif self.current_presentation_index < self.presentations_count - 1:
return self.presentation_configs[
self.current_presentation_index + 1
].slides[0]
else:
return None
@property
def next_file(self) -> Optional[Path]:
if slide_config := self.next_slide_config:
return slide_config.file # type: ignore[no-any-return]
return None
@property @property
def playing_reversed_slide(self) -> bool: def playing_reversed_slide(self) -> bool:
return self.__playing_reversed_slide return self.__playing_reversed_slide
@ -286,6 +435,7 @@ class Player(QMainWindow): # type: ignore[misc]
def load_next_slide(self) -> None: def load_next_slide(self) -> None:
if self.playing_reversed_slide: if self.playing_reversed_slide:
self.playing_reversed_slide = False self.playing_reversed_slide = False
self.preview_next_slide() # Slide number did not change, but next did
elif self.current_slide_index < self.current_slides_count - 1: elif self.current_slide_index < self.current_slides_count - 1:
self.current_slide_index += 1 self.current_slide_index += 1
elif self.current_presentation_index < self.presentations_count - 1: elif self.current_presentation_index < self.presentations_count - 1:
@ -321,6 +471,13 @@ class Player(QMainWindow): # type: ignore[misc]
count = self.current_slides_count count = self.current_slides_count
self.info.slide_label.setText(f"{index+1:4d}/{count:4<d}") self.info.slide_label.setText(f"{index+1:4d}/{count:4<d}")
self.info.slide_notes.setText(self.current_slide_config.notes) self.info.slide_notes.setText(self.current_slide_config.notes)
self.preview_next_slide()
def preview_next_slide(self) -> None:
if slide_config := self.next_slide_config:
url = QUrl.fromLocalFile(slide_config.file)
self.info.next_media_player.setSource(url)
self.info.next_media_player.play()
def show(self) -> None: def show(self) -> None:
super().show() super().show()
@ -331,6 +488,7 @@ class Player(QMainWindow): # type: ignore[misc]
@Slot() @Slot()
def close(self) -> None: def close(self) -> None:
logger.info("Closing gracefully...") logger.info("Closing gracefully...")
self.info.close()
super().close() super().close()
@Slot() @Slot()
@ -353,6 +511,7 @@ class Player(QMainWindow): # type: ignore[misc]
@Slot() @Slot()
def reverse(self) -> None: def reverse(self) -> None:
self.load_reversed_slide() self.load_reversed_slide()
self.preview_next_slide()
@Slot() @Slot()
def replay(self) -> None: def replay(self) -> None:
@ -381,9 +540,11 @@ class Player(QMainWindow): # type: ignore[misc]
else: else:
self.setCursor(Qt.BlankCursor) self.setCursor(Qt.BlankCursor)
@Slot()
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.close() self.close()
@Slot()
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
key = event.key() key = event.key()
self.dispatch(key) self.dispatch(key)

View File

@ -43,7 +43,7 @@ def render(ce: bool, gl: bool, args: Tuple[str, ...]) -> None:
""" """
Render SCENE(s) from the input FILE, using the specified renderer. Render SCENE(s) from the input FILE, using the specified renderer.
Use 'manim-slides render --help' to see help information for Use ``manim-slides render --help`` to see help information for
a the specified renderer. a the specified renderer.
""" """
if ce and gl: if ce and gl:

View File

@ -289,10 +289,14 @@ class BaseSlide:
Note that this is only supported by ``manim-slides present``. Note that this is only supported by ``manim-slides present``.
:param notes: :param notes:
Presenter notes, in HTML format. Presenter notes, in Markdown format.
Note that PowerPoint does not support Markdown.
Note that this is only supported by ``manim-slides present`` Note that this is only supported by ``manim-slides present``
and ``manim-slides convert --to=html``. and ``manim-slides convert --to=html/pptx``.
:param dedent_notes:
If set, apply :func:`textwrap.dedent` to notes.
:param kwargs: :param kwargs:
Keyword arguments to be passed to Keyword arguments to be passed to
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`, :meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,

View File

@ -41,7 +41,7 @@
data-autoslide="{{ get_duration_ms(slide_config.file) }}" data-autoslide="{{ get_duration_ms(slide_config.file) }}"
{%- endif -%}> {%- endif -%}>
{% if slide_config.notes != "" -%} {% if slide_config.notes != "" -%}
<aside class="notes">{{ slide_config.notes }}</aside> <aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
{%- endif %} {%- endif %}
</section> </section>
{%- endfor -%} {%- endfor -%}
@ -54,6 +54,7 @@
<!-- To include plugins, see: https://revealjs.com/plugins/ --> <!-- To include plugins, see: https://revealjs.com/plugins/ -->
{% if has_notes -%} {% if has_notes -%}
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/markdown/markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script>
{%- endif -%} {%- endif -%}
@ -61,7 +62,7 @@
<script> <script>
Reveal.initialize({ Reveal.initialize({
{% if has_notes -%} {% if has_notes -%}
plugins: [ RevealNotes ], plugins: [ RevealMarkdown, RevealNotes ],
{%- endif %} {%- endif %}
// The "normal" size of the presentation, aspect ratio will // The "normal" size of the presentation, aspect ratio will
// be preserved when the presentation is scaled to fit different // be preserved when the presentation is scaled to fit different