refactor: use PyQT5 for window display (#49)

* wip: use PyQT5 for window display

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

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

* wip: first slide is shown

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

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

* wip: pushing non-working code

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

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

* wip: some logging

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

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

* feat: new configuration wizard working

* fix: prevent key error

* wip: making action work

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

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

* wip: soon done! info + video

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

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

* fix: bugs in sleep and exiting

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

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

* try: offscreen

* fix: pop default value if not present

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

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

* feat: add aspect ratio option

* chore: typing wip

* fix: now() function returns seconds, not milliseconds anymore

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Jérome Eertmans
2022-10-19 11:08:41 +02:00
committed by GitHub
parent bc3d55fce2
commit d717bc651d
10 changed files with 464 additions and 255 deletions

View File

@ -6,6 +6,9 @@ on:
name: Test Examples
env:
QT_QPA_PLATFORM: offscreen
jobs:
build-examples:
strategy:

View File

@ -38,17 +38,13 @@ repos:
- --ignore-missing-imports
# Disallow dynamic typing
- --disallow-any-unimported
- --disallow-any-expr
- --disallow-any-decorated
- --disallow-any-generics
- --disallow-any-explicit
- --disallow-subclassing-any
# Disallow untyped definitions and calls
- --disallow-untyped-defs
- --disallow-incomplete-defs
- --check-untyped-defs
- --disallow-untyped-decorators
# None and optional handling
- --no-implicit-optional

View File

@ -3,8 +3,8 @@ from enum import Enum
from typing import List, Optional, Set
from pydantic import BaseModel, root_validator, validator
from PyQt5.QtCore import Qt
from .defaults import LEFT_ARROW_KEY_CODE, RIGHT_ARROW_KEY_CODE
from .manim import logger
@ -14,13 +14,16 @@ class Key(BaseModel):
ids: Set[int]
name: Optional[str] = None
def set_ids(self, *ids: int) -> None:
self.ids = set(ids)
@validator("ids", each_item=True)
def id_is_posint(cls, v: int):
def id_is_posint(cls, v: int) -> int:
if v < 0:
raise ValueError("Key ids cannot be negative integers")
return v
def match(self, key_id: int):
def match(self, key_id: int) -> bool:
m = key_id in self.ids
if m:
@ -32,12 +35,12 @@ class Key(BaseModel):
class Config(BaseModel):
"""General Manim Slides config"""
QUIT: Key = Key(ids=[ord("q")], name="QUIT")
CONTINUE: Key = Key(ids=[RIGHT_ARROW_KEY_CODE], name="CONTINUE / NEXT")
BACK: Key = Key(ids=[LEFT_ARROW_KEY_CODE], name="BACK")
REVERSE: Key = Key(ids=[ord("v")], name="REVERSE")
REWIND: Key = Key(ids=[ord("r")], name="REWIND")
PLAY_PAUSE: Key = Key(ids=[32], name="PLAY / PAUSE")
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")
@root_validator
def ids_are_unique_across_keys(cls, values):
@ -46,7 +49,7 @@ class Config(BaseModel):
for key in values.values():
if len(ids.intersection(key.ids)) != 0:
raise ValueError(
"Two or more keys share a common key code: please make sure each key has distinc key codes"
"Two or more keys share a common key code: please make sure each key has distinct key codes"
)
ids.update(key.ids)

View File

@ -1,32 +1,2 @@
import platform
from typing import Tuple
import cv2
__all__ = [
"FONT_ARGS",
"FOLDER_PATH",
"CONFIG_PATH",
"RIGHT_ARROW_KEY_CODE",
"LEFT_ARROW_KEY_CODE",
]
FONT_ARGS: Tuple[int, int, int, int, int] = (
cv2.FONT_HERSHEY_SIMPLEX, # type: ignore
1,
255,
1,
cv2.LINE_AA, # type: ignore
)
FOLDER_PATH: str = "./slides"
CONFIG_PATH: str = ".manim-slides.json"
if platform.system() == "Windows":
RIGHT_ARROW_KEY_CODE: int = 2555904
LEFT_ARROW_KEY_CODE: int = 2424832
elif platform.system() == "Darwin":
RIGHT_ARROW_KEY_CODE = 63235
LEFT_ARROW_KEY_CODE = 63234
else:
RIGHT_ARROW_KEY_CODE = 65363
LEFT_ARROW_KEY_CODE = 65361

View File

@ -9,7 +9,7 @@ from .wizard import init, wizard
@click.group(cls=DefaultGroup, default="present", default_if_no_args=True)
@click.version_option(__version__, "-v", "--version")
@click.help_option("-h", "--help")
def cli():
def cli() -> None:
"""
Manim Slides command-line utilities.

View File

@ -2,6 +2,7 @@ import os
import sys
from contextlib import contextmanager
from importlib.util import find_spec
from typing import Iterator
__all__ = [
"MANIM",
@ -21,7 +22,7 @@ __all__ = [
@contextmanager
def suppress_stdout() -> None:
def suppress_stdout() -> Iterator[None]:
with open(os.devnull, "w") as devnull:
old_stdout = sys.stdout
sys.stdout = devnull
@ -65,8 +66,6 @@ else:
)
FFMPEG_BIN = None
if MANIMGL:
from manimlib import Scene, ThreeDScene, config
from manimlib.constants import FFMPEG_BIN

View File

@ -1,35 +1,38 @@
import math
import os
import platform
import sys
import time
from enum import IntEnum, auto, unique
from typing import List, Tuple
from typing import List, Optional, Tuple
import click
import cv2
import numpy as np
from pydantic import ValidationError
from PyQt5 import QtGui
from PyQt5.QtCore import Qt, QThread, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import QApplication, QGridLayout, QLabel, QWidget
from tqdm import tqdm
from .commons import config_path_option, verbosity_option
from .config import Config, PresentationConfig, SlideConfig, SlideType
from .defaults import FOLDER_PATH, FONT_ARGS
from .defaults import FOLDER_PATH
from .manim import logger
INTERPOLATION_FLAGS = {
"nearest": cv2.INTER_NEAREST,
"linear": cv2.INTER_LINEAR,
"cubic": cv2.INTER_CUBIC,
"area": cv2.INTER_AREA,
"lanczos4": cv2.INTER_LANCZOS4,
"linear-exact": cv2.INTER_LINEAR_EXACT,
"nearest-exact": cv2.INTER_NEAREST_EXACT,
}
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"
ASPECT_RATIO_MODES = {
"ignore": Qt.IgnoreAspectRatio,
"keep": Qt.KeepAspectRatio,
}
@unique
class State(IntEnum):
@ -44,14 +47,9 @@ class State(IntEnum):
return self.name.capitalize()
def now() -> int:
"""Returns time.time() in milliseconds."""
return round(time.time() * 1000)
def fix_time(t: float) -> float:
"""Clips time t such that it is always positive."""
return t if t > 0 else 1
def now() -> float:
"""Returns time.time() in seconds."""
return time.time()
class Presentation:
@ -61,17 +59,17 @@ class Presentation:
self.slides: List[SlideConfig] = config.slides
self.files: List[str] = config.files
self.current_slide_index = 0
self.current_animation = self.current_slide.start_animation
self.current_file = None
self.current_slide_index: int = 0
self.current_animation: int = self.current_slide.start_animation
self.current_file: Optional[str] = None
self.loaded_animation_cap = -1
self.loaded_animation_cap: int = -1
self.cap = None # cap = cv2.VideoCapture
self.reverse = False
self.reversed_animation = -1
self.reverse: bool = False
self.reversed_animation: int = -1
self.lastframe = None
self.lastframe: Optional[np.ndarray] = None
self.reset()
self.add_last_slide()
@ -105,9 +103,11 @@ class Presentation:
self.reverse and self.reversed_animation != animation
): # cap already loaded
logger.debug(f"Loading new cap for animation #{animation}")
self.release_cap()
file = self.files[animation]
file: str = self.files[animation]
if self.reverse:
file = "{}_reversed{}".format(*os.path.splitext(file))
@ -126,16 +126,19 @@ class Presentation:
def rewind_current_slide(self) -> None:
"""Rewinds current slide to first frame."""
logger.debug("Rewinding curring slide")
if self.reverse:
self.current_animation = self.current_slide.end_animation - 1
else:
self.current_animation = self.current_slide.start_animation
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
cap = self.current_cap
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_animation = -1
self.release_cap()
@ -147,6 +150,7 @@ class Presentation:
def load_next_slide(self) -> None:
"""Loads next slide."""
logger.debug("Loading next slide")
if self.reverse:
self.cancel_reverse()
self.rewind_current_slide()
@ -160,6 +164,7 @@ class Presentation:
def load_previous_slide(self) -> None:
"""Loads previous slide."""
logger.debug("Loading previous slide")
self.cancel_reverse()
self.current_slide_index = max(0, self.current_slide_index - 1)
self.rewind_current_slide()
@ -167,7 +172,12 @@ class Presentation:
@property
def fps(self) -> int:
"""Returns the number of frames per second of the current video."""
return self.current_cap.get(cv2.CAP_PROP_FPS)
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"
)
return max(fps, 1) # TODO: understand why we sometimes get 0 fps
def add_last_slide(self) -> None:
"""Add a 'last' slide to the end of slides."""
@ -264,56 +274,37 @@ class Presentation:
return self.lastframe, state
class Display:
class Display(QThread):
"""Displays one or more presentations one after each other."""
change_video_signal = pyqtSignal(np.ndarray)
change_info_signal = pyqtSignal(dict)
finished = pyqtSignal()
def __init__(
self,
presentations,
config,
start_paused=False,
fullscreen=False,
skip_all=False,
resolution=(1980, 1080),
interpolation_flag=cv2.INTER_LINEAR,
record_to=None,
exit_after_last_slide=False,
) -> None:
super().__init__()
self.presentations = presentations
self.start_paused = start_paused
self.config = config
self.skip_all = skip_all
self.fullscreen = fullscreen
self.resolution = resolution
self.interpolation_flag = interpolation_flag
self.record_to = record_to
self.recordings = []
self.window_flags = (
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_NORMAL
)
self.recordings: List[Tuple[str, int, int]] = []
self.state = State.PLAYING
self.lastframe = None
self.lastframe: Optional[np.ndarray] = None
self.current_presentation_index = 0
self.exit = False
self.run_flag = True
self.lag = 0
self.last_time = now()
cv2.namedWindow(
WINDOW_INFO_NAME,
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_AUTOSIZE,
)
if self.fullscreen:
cv2.namedWindow(
WINDOW_NAME, cv2.WINDOW_GUI_NORMAL | cv2.WND_PROP_FULLSCREEN
)
cv2.setWindowProperty(
WINDOW_NAME, cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN
)
else:
cv2.namedWindow(WINDOW_NAME, self.window_flags)
cv2.resizeWindow(WINDOW_NAME, *self.resolution)
self.key = -1
self.exit_after_last_slide = exit_after_last_slide
@property
def current_presentation(self) -> Presentation:
@ -322,7 +313,8 @@ class Display:
def run(self) -> None:
"""Runs a series of presentations until end or exit."""
while not self.exit:
while self.run_flag:
last_time = now()
self.lastframe, self.state = self.current_presentation.update_state(
self.state
)
@ -332,88 +324,98 @@ class Display:
self.start_paused = False
if self.state == State.END:
if self.current_presentation_index == len(self.presentations) - 1:
self.quit()
continue
if self.exit_after_last_slide:
self.run_flag = False
continue
else:
self.current_presentation_index += 1
self.state = State.PLAYING
self.handle_key()
if self.exit:
continue
self.show_video()
self.show_info()
lag = now() - last_time
sleep_time = 1 / self.current_presentation.fps
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 gracully 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(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(_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."""
self.lag = now() - self.last_time
self.last_time = now()
if self.record_to is not None:
pres = self.current_presentation
self.recordings.append(
(pres.current_file, pres.current_frame_number, pres.fps)
)
frame = self.lastframe
# If Window was manually closed (impossible in fullscreen),
# we reopen it
if cv2.getWindowProperty(WINDOW_NAME, cv2.WND_PROP_VISIBLE) < 1:
cv2.namedWindow(WINDOW_NAME, self.window_flags)
cv2.resizeWindow(WINDOW_NAME, *self.resolution)
if WINDOWS: # Only resize on Windows
_, _, w, h = cv2.getWindowImageRect(WINDOW_NAME)
if (h, w) != frame.shape[:2]: # Only if shape is different
frame = cv2.resize(frame, (w, h), self.interpolation_flag)
cv2.imshow(WINDOW_NAME, frame)
frame: np.ndarray = self.lastframe
self.change_video_signal.emit(frame)
def show_info(self) -> None:
"""Shows updated information about presentations."""
info = np.zeros((130, 420), np.uint8)
font_args = (FONT_ARGS[0], 0.7, *FONT_ARGS[2:])
grid_x = [30, 230]
grid_y = [30, 70, 110]
cv2.putText(
info,
f"Animation: {self.current_presentation.current_animation}",
(grid_x[0], grid_y[0]),
*font_args,
)
cv2.putText(info, f"State: {self.state}", (grid_x[1], grid_y[0]), *font_args)
cv2.putText(
info,
f"Slide {self.current_presentation.current_slide.number}/{len(self.current_presentation.slides)}",
(grid_x[0], grid_y[1]),
*font_args,
)
cv2.putText(
info,
f"Slide Type: {self.current_presentation.current_slide.type}",
(grid_x[1], grid_y[1]),
*font_args,
self.change_info_signal.emit(
{
"animation": self.current_presentation.current_animation,
"state": self.state,
"slide_index": self.current_presentation.current_slide.number,
"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),
}
)
cv2.putText(
info,
f"Scene {self.current_presentation_index + 1}/{len(self.presentations)}",
((grid_x[0] + grid_x[1]) // 2, grid_y[2]),
*font_args,
)
cv2.imshow(WINDOW_INFO_NAME, info)
@pyqtSlot(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."""
sleep_time = math.ceil(1000 / self.current_presentation.fps)
key = cv2.waitKeyEx(fix_time(sleep_time - self.lag))
key = self.key
if self.config.QUIT.match(key):
self.quit()
self.run_flag = False
elif self.state == State.PLAYING and self.config.PLAY_PAUSE.match(key):
self.state = State.PAUSED
elif self.state == State.PAUSED and self.config.PLAY_PAUSE.match(key):
@ -446,42 +448,165 @@ class Display:
self.current_presentation.rewind_current_slide()
self.state = State.PLAYING
def quit(self) -> None:
"""Destroys all windows created by presentations and exits gracefully."""
cv2.destroyAllWindows()
self.key = -1 # No more key to be handled
if self.record_to is not None and len(self.recordings) > 0:
logger.debug(
f"A total of {len(self.recordings)} frames will be saved to {self.record_to}"
def stop(self):
"""Stops current thread, without doing anything after."""
self.run_flag = False
self.wait()
class Info(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle(WINDOW_INFO_NAME)
self.layout = QGridLayout()
self.setLayout(self.layout)
self.animationLabel = QLabel()
self.stateLabel = QLabel()
self.slideLabel = QLabel()
self.typeLabel = QLabel()
self.sceneLabel = QLabel()
self.layout.addWidget(self.animationLabel, 0, 0, 1, 2)
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({})
@pyqtSlot(dict)
def update_info(self, info: dict):
self.animationLabel.setText("Animation: {}".format(info.get("animation", "na")))
self.stateLabel.setText("State: {}".format(info.get("state", "unknown")))
self.slideLabel.setText(
"Slide: {}/{}".format(
info.get("slide_index", "na"), info.get("n_slides", "na")
)
file, frame_number, fps = self.recordings[0]
)
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")
)
)
cap = cv2.VideoCapture(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))
class InfoThread(QThread):
def __init__(self):
super().__init__()
self.dialog = Info()
self.run_flag = True
out.write(frame)
def start(self):
super().start()
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(_file)
self.dialog.show()
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number - 1)
_, frame = cap.read()
out.write(frame)
def stop(self):
self.dialog.deleteLater()
cap.release()
out.release()
self.exit = True
class App(QWidget):
send_key_signal = pyqtSignal(int)
def __init__(
self,
*args,
fullscreen: bool = False,
resolution: Tuple[int, int] = (1980, 1080),
hide_mouse: bool = False,
aspect_ratio: Qt.AspectRatioMode = Qt.IgnoreAspectRatio,
**kwargs,
):
super().__init__()
self.setWindowTitle(WINDOW_NAME)
self.display_width, self.display_height = resolution
self.aspect_ratio = aspect_ratio
if hide_mouse:
self.setCursor(Qt.BlankCursor)
self.label = QLabel(self)
self.label.setAlignment(Qt.AlignCenter)
self.label.resize(self.display_width, self.display_height)
self.pixmap = QPixmap(self.width(), self.height())
self.label.setPixmap(self.pixmap)
self.label.setMinimumSize(1, 1)
# create the video capture thread
self.thread = Display(*args, **kwargs)
# 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()
# connect signals
self.thread.change_video_signal.connect(self.update_image)
self.thread.change_info_signal.connect(self.info.update_info)
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):
key = event.key()
# We send key to be handled by video display
self.send_key_signal.emit(key)
event.accept()
def closeAll(self):
logger.debug("Closing all QT windows")
self.thread.stop()
self.info.deleteLater()
self.deleteLater()
def resizeEvent(self, event):
self.pixmap = self.pixmap.scaled(self.width(), self.height(), self.aspect_ratio)
self.label.setPixmap(self.pixmap)
self.label.resize(self.width(), self.height())
def closeEvent(self, event):
self.closeAll()
event.accept()
@pyqtSlot(np.ndarray)
def update_image(self, cv_img: dict):
"""Updates the image_label with a new opencv image"""
self.pixmap = self.convert_cv_qt(cv_img)
self.label.setPixmap(self.pixmap)
@pyqtSlot(dict)
def update_info(self, info: dict):
"""Updates the image_label with a new opencv image"""
pass
def convert_cv_qt(self, cv_img):
"""Convert from an opencv image to QPixmap"""
rgb_image = cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB)
h, w, ch = rgb_image.shape
bytes_per_line = ch * w
convert_to_Qt_format = QtGui.QImage(
rgb_image.data, w, h, bytes_per_line, QtGui.QImage.Format_RGB888
)
p = convert_to_Qt_format.scaled(
self.width(),
self.height(),
self.aspect_ratio,
)
return QPixmap.fromImage(p)
@click.command()
@ -517,7 +642,7 @@ def _list_scenes(folder) -> List[str]:
)
pass
logger.info(f"Found {len(scenes)} valid scene configuration files in `{folder}`.")
logger.debug(f"Found {len(scenes)} valid scene configuration files in `{folder}`.")
return scenes
@ -535,9 +660,10 @@ def _list_scenes(folder) -> List[str]:
@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.",
help="Skip all slides, useful the test if slides are working. Automatically sets `--skip-after-last-slide` to True.",
)
@click.option(
"-r",
@ -548,19 +674,30 @@ def _list_scenes(folder) -> List[str]:
show_default=True,
)
@click.option(
"-i",
"--interpolation-flag",
type=click.Choice(INTERPOLATION_FLAGS.keys(), case_sensitive=False),
default="linear",
help="Set the interpolation flag to be used when resizing image. See OpenCV cv::InterpolationFlags.",
show_default=True,
)
@click.option(
"--to",
"--record-to",
"record_to",
type=click.Path(dir_okay=False),
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="ignore",
help="Set the aspect ratio mode to be used when rescaling video.",
show_default=True,
)
@click.help_option("-h", "--help")
@verbosity_option
def present(
@ -571,8 +708,10 @@ def present(
fullscreen,
skip_all,
resolution,
interpolation_flag,
record_to,
exit_after_last_slide,
hide_mouse,
aspect_ratio,
) -> None:
"""
Present SCENE(s), one at a time, in order.
@ -584,6 +723,9 @@ 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 len(scenes) == 0:
scene_choices = _list_scenes(folder)
@ -638,7 +780,7 @@ def present(
except ValidationError as e:
raise click.UsageError(str(e))
else:
logger.info("No configuration file found, using default configuration.")
logger.debug("No configuration file found, using default configuration.")
config = Config()
if record_to is not None:
@ -648,14 +790,19 @@ def present(
"Recording only support '.avi' extension. For other video formats, please convert the resulting '.avi' file afterwards."
)
display = Display(
app = QApplication(sys.argv)
a = App(
presentations,
config=config,
start_paused=start_paused,
fullscreen=fullscreen,
skip_all=skip_all,
resolution=resolution,
interpolation_flag=INTERPOLATION_FLAGS[interpolation_flag],
record_to=record_to,
exit_after_last_slide=exit_after_last_slide,
hide_mouse=hide_mouse,
aspect_ratio=ASPECT_RATIO_MODES[aspect_ratio],
)
display.run()
a.show()
sys.exit(app.exec_())
logger.debug("After")

View File

@ -2,7 +2,7 @@ import os
import platform
import shutil
import subprocess
from typing import List
from typing import Any, List, Optional
from tqdm import tqdm
@ -23,7 +23,9 @@ class Slide(Scene):
Inherits from `manim.Scene` or `manimlib.Scene` and provide necessary tools for slides rendering.
"""
def __init__(self, *args, output_folder=FOLDER_PATH, **kwargs) -> None:
def __init__(
self, *args: Any, output_folder: str = FOLDER_PATH, **kwargs: Any
) -> None:
if MANIMGL:
if not os.path.isdir("videos"):
os.mkdir("videos")
@ -38,10 +40,10 @@ class Slide(Scene):
super().__init__(*args, **kwargs)
self.output_folder = output_folder
self.slides = []
self.slides: List[SlideConfig] = []
self.current_slide = 1
self.current_animation = 0
self.loop_start_animation = None
self.loop_start_animation: Optional[int] = None
self.pause_start_animation = 0
@property
@ -69,14 +71,14 @@ class Slide(Scene):
return config["progress_bar"] != "none"
@property
def leave_progress_bar(self) -> None:
def leave_progress_bar(self) -> bool:
"""Returns True if progress bar should be left after completed."""
if MANIMGL:
return getattr(super(Scene, self), "leave_progress_bars", False)
else:
return config["progress_bar"] == "leave"
def play(self, *args, **kwargs) -> None:
def play(self, *args: Any, **kwargs: Any) -> None:
"""Overloads `self.play` and increment animation count."""
super().play(*args, **kwargs)
self.current_animation += 1
@ -116,7 +118,7 @@ class Slide(Scene):
self.loop_start_animation = None
self.pause_start_animation = self.current_animation
def save_slides(self, use_cache=True) -> None:
def save_slides(self, use_cache: bool = True) -> None:
"""
Saves slides, optionally using cached files.
@ -182,12 +184,12 @@ class Slide(Scene):
f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'"
)
def run(self, *args, **kwargs) -> None:
def run(self, *args: Any, **kwargs: Any) -> None:
"""MANIMGL renderer"""
super().run(*args, **kwargs)
self.save_slides(use_cache=False)
def render(self, *args, **kwargs) -> None:
def render(self, *args: Any, **kwargs: Any) -> None:
"""MANIM render"""
# We need to disable the caching limit since we rely on intermidiate files
max_files_cached = config["max_files_cached"]

View File

@ -1,39 +1,127 @@
import os
import sys
from functools import partial
from typing import Any
import click
import cv2
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QGridLayout,
QLabel,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from .commons import config_options, verbosity_option
from .config import Config
from .defaults import CONFIG_PATH, FONT_ARGS
from .config import Config, Key
from .defaults import CONFIG_PATH
from .manim import logger
WINDOW_NAME = "Manim Slides Configuration Wizard"
WINDOW_SIZE = (120, 620)
WINDOW_NAME: str = "Configuration Wizard"
keymap = {}
for key, value in vars(Qt).items():
if isinstance(value, Qt.Key):
keymap[value] = key.partition("_")[2]
def center_text_horizontally(text, window_size, font_args) -> int:
"""Returns centered position for text to be displayed in current window."""
_, width = window_size
font, scale, _, thickness, _ = font_args
(size_in_pixels, _), _ = cv2.getTextSize(text, font, scale, thickness)
return (width - size_in_pixels) // 2
class KeyInput(QDialog):
def __init__(self) -> None:
super().__init__()
self.key = None
self.layout = QVBoxLayout()
self.setWindowTitle("Keyboard Input")
self.label = QLabel("Press any key to register it")
self.layout.addWidget(self.label)
self.setLayout(self.layout)
def keyPressEvent(self, event: Any) -> None:
self.key = event.key()
self.deleteLater()
event.accept()
def prompt(question: str) -> int:
"""Diplays some question in current window and waits for key press."""
display = np.zeros(WINDOW_SIZE, np.uint8)
class Wizard(QWidget):
def __init__(self, config: Config):
text = "* Manim Slides Wizard *"
text_org = center_text_horizontally(text, WINDOW_SIZE, FONT_ARGS), 33
question_org = center_text_horizontally(question, WINDOW_SIZE, FONT_ARGS), 85
super().__init__()
cv2.putText(display, "* Manim Slides Wizard *", text_org, *FONT_ARGS)
cv2.putText(display, question, question_org, *FONT_ARGS)
self.setWindowTitle(WINDOW_NAME)
self.config = config
cv2.imshow(WINDOW_NAME, display)
return cv2.waitKeyEx(-1)
QBtn = QDialogButtonBox.Save | QDialogButtonBox.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.saveConfig)
self.buttonBox.rejected.connect(self.closeWithoutSaving)
self.buttons = []
self.layout = QGridLayout()
for i, (key, value) in enumerate(self.config.dict().items()):
# Create label for key name information
label = QLabel()
key_info = value["name"] or key
label.setText(key_info)
self.layout.addWidget(label, i, 0)
# Create button that will pop-up a dialog and ask to input a new key
value = value["ids"].pop()
button = QPushButton(keymap[value])
button.setToolTip(
f"Click to modify the key associated to action {key_info}"
)
self.buttons.append(button)
button.clicked.connect(
partial(self.openDialog, i, getattr(self.config, key))
)
self.layout.addWidget(button, i, 1)
self.layout.addWidget(self.buttonBox, len(self.buttons), 1)
self.setLayout(self.layout)
def closeWithoutSaving(self) -> None:
logger.debug("Closing configuration wizard without saving")
self.deleteLater()
sys.exit(0)
def closeEvent(self, event: Any) -> None:
self.closeWithoutSaving()
event.accept()
def saveConfig(self) -> None:
try:
Config.parse_obj(self.config.dict())
except ValueError:
msg = QMessageBox()
msg.setIcon(QMessageBox.Critical)
msg.setText("Error")
msg.setInformativeText(
"Two or more actions share a common key: make sure actions have distinct key codes."
)
msg.setWindowTitle("Error: duplicated keys")
msg.exec_()
return
self.deleteLater()
def openDialog(self, button_number: int, key: Key) -> None:
button = self.buttons[button_number]
dialog = KeyInput()
dialog.exec_()
if dialog.key is not None:
key_name = keymap[dialog.key]
key.set_ids(dialog.key)
button.setText(key_name)
@click.command()
@ -69,27 +157,27 @@ def _init(config_path, force, merge, skip_interactive=False):
force = choice == "o"
merge = choice == "m"
if force:
click.secho("Overwriting.")
elif merge:
click.secho("Merging.")
else:
click.secho("Exiting.")
if not force and not merge:
logger.debug("Exiting without doing anything")
sys.exit(0)
config = Config()
if force:
logger.debug(f"Overwriting `{config_path}` if exists")
elif merge:
logger.debug("Merging new config into `{config_path}`")
if not skip_interactive:
if os.path.exists(config_path):
config = Config.parse_file(config_path)
cv2.namedWindow(
WINDOW_NAME,
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_AUTOSIZE,
)
app = QApplication(sys.argv)
window = Wizard(config)
window.show()
app.exec()
prompt("Press any key to continue")
for _, key in config:
key.ids = [prompt(f"Press the {key.name} key")]
config = window.config
if merge:
config = Config.parse_file(config_path).merge_with(config)
@ -97,4 +185,4 @@ def _init(config_path, force, merge, skip_interactive=False):
with open(config_path, "w") as config_file:
config_file.write(config.json(indent=2))
click.echo(f"Configuration file successfully save to `{config_path}`")
click.secho(f"Configuration file successfully saved to `{config_path}`")

View File

@ -39,6 +39,7 @@ setuptools.setup(
"click-default-group>=1.2",
"numpy>=1.19.3",
"pydantic>=1.9.1",
"pyqt5>=5.15",
"opencv-python>=4.6",
"tqdm>=4.62.3",
],