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 name: Test Examples
env:
QT_QPA_PLATFORM: offscreen
jobs: jobs:
build-examples: build-examples:
strategy: strategy:

View File

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

View File

@ -3,8 +3,8 @@ from enum import Enum
from typing import List, Optional, Set from typing import List, Optional, Set
from pydantic import BaseModel, root_validator, validator 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 from .manim import logger
@ -14,13 +14,16 @@ class Key(BaseModel):
ids: Set[int] ids: Set[int]
name: Optional[str] = None name: Optional[str] = None
def set_ids(self, *ids: int) -> None:
self.ids = set(ids)
@validator("ids", each_item=True) @validator("ids", each_item=True)
def id_is_posint(cls, v: int): def id_is_posint(cls, v: int) -> int:
if v < 0: if v < 0:
raise ValueError("Key ids cannot be negative integers") raise ValueError("Key ids cannot be negative integers")
return v return v
def match(self, key_id: int): def match(self, key_id: int) -> bool:
m = key_id in self.ids m = key_id in self.ids
if m: if m:
@ -32,12 +35,12 @@ class Key(BaseModel):
class Config(BaseModel): class Config(BaseModel):
"""General Manim Slides config""" """General Manim Slides config"""
QUIT: Key = Key(ids=[ord("q")], name="QUIT") QUIT: Key = Key(ids=[Qt.Key_Q], name="QUIT")
CONTINUE: Key = Key(ids=[RIGHT_ARROW_KEY_CODE], name="CONTINUE / NEXT") CONTINUE: Key = Key(ids=[Qt.Key_Right], name="CONTINUE / NEXT")
BACK: Key = Key(ids=[LEFT_ARROW_KEY_CODE], name="BACK") BACK: Key = Key(ids=[Qt.Key_Left], name="BACK")
REVERSE: Key = Key(ids=[ord("v")], name="REVERSE") REVERSE: Key = Key(ids=[Qt.Key_V], name="REVERSE")
REWIND: Key = Key(ids=[ord("r")], name="REWIND") REWIND: Key = Key(ids=[Qt.Key_R], name="REWIND")
PLAY_PAUSE: Key = Key(ids=[32], name="PLAY / PAUSE") PLAY_PAUSE: Key = Key(ids=[Qt.Key_Space], name="PLAY / PAUSE")
@root_validator @root_validator
def ids_are_unique_across_keys(cls, values): def ids_are_unique_across_keys(cls, values):
@ -46,7 +49,7 @@ class Config(BaseModel):
for key in values.values(): for key in values.values():
if len(ids.intersection(key.ids)) != 0: if len(ids.intersection(key.ids)) != 0:
raise ValueError( 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) 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" FOLDER_PATH: str = "./slides"
CONFIG_PATH: str = ".manim-slides.json" 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.group(cls=DefaultGroup, default="present", default_if_no_args=True)
@click.version_option(__version__, "-v", "--version") @click.version_option(__version__, "-v", "--version")
@click.help_option("-h", "--help") @click.help_option("-h", "--help")
def cli(): def cli() -> None:
""" """
Manim Slides command-line utilities. Manim Slides command-line utilities.

View File

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

View File

@ -1,35 +1,38 @@
import math
import os import os
import platform import platform
import sys
import time import time
from enum import IntEnum, auto, unique from enum import IntEnum, auto, unique
from typing import List, Tuple from typing import List, Optional, Tuple
import click import click
import cv2 import cv2
import numpy as np import numpy as np
from pydantic import ValidationError 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 tqdm import tqdm
from .commons import config_path_option, verbosity_option from .commons import config_path_option, verbosity_option
from .config import Config, PresentationConfig, SlideConfig, SlideType from .config import Config, PresentationConfig, SlideConfig, SlideType
from .defaults import FOLDER_PATH, FONT_ARGS from .defaults import FOLDER_PATH
from .manim import logger from .manim import logger
INTERPOLATION_FLAGS = { os.environ.pop(
"nearest": cv2.INTER_NEAREST, "QT_QPA_PLATFORM_PLUGIN_PATH", None
"linear": cv2.INTER_LINEAR, ) # See why here: https://stackoverflow.com/a/67863156
"cubic": cv2.INTER_CUBIC,
"area": cv2.INTER_AREA,
"lanczos4": cv2.INTER_LANCZOS4,
"linear-exact": cv2.INTER_LINEAR_EXACT,
"nearest-exact": cv2.INTER_NEAREST_EXACT,
}
WINDOW_NAME = "Manim Slides" WINDOW_NAME = "Manim Slides"
WINDOW_INFO_NAME = f"{WINDOW_NAME}: Info" WINDOW_INFO_NAME = f"{WINDOW_NAME}: Info"
WINDOWS = platform.system() == "Windows" WINDOWS = platform.system() == "Windows"
ASPECT_RATIO_MODES = {
"ignore": Qt.IgnoreAspectRatio,
"keep": Qt.KeepAspectRatio,
}
@unique @unique
class State(IntEnum): class State(IntEnum):
@ -44,14 +47,9 @@ class State(IntEnum):
return self.name.capitalize() return self.name.capitalize()
def now() -> int: def now() -> float:
"""Returns time.time() in milliseconds.""" """Returns time.time() in seconds."""
return round(time.time() * 1000) return time.time()
def fix_time(t: float) -> float:
"""Clips time t such that it is always positive."""
return t if t > 0 else 1
class Presentation: class Presentation:
@ -61,17 +59,17 @@ class Presentation:
self.slides: List[SlideConfig] = config.slides self.slides: List[SlideConfig] = config.slides
self.files: List[str] = config.files self.files: List[str] = config.files
self.current_slide_index = 0 self.current_slide_index: int = 0
self.current_animation = self.current_slide.start_animation self.current_animation: int = self.current_slide.start_animation
self.current_file = None self.current_file: Optional[str] = None
self.loaded_animation_cap = -1 self.loaded_animation_cap: int = -1
self.cap = None # cap = cv2.VideoCapture self.cap = None # cap = cv2.VideoCapture
self.reverse = False self.reverse: bool = False
self.reversed_animation = -1 self.reversed_animation: int = -1
self.lastframe = None self.lastframe: Optional[np.ndarray] = None
self.reset() self.reset()
self.add_last_slide() self.add_last_slide()
@ -105,9 +103,11 @@ class Presentation:
self.reverse and self.reversed_animation != animation self.reverse and self.reversed_animation != animation
): # cap already loaded ): # cap already loaded
logger.debug(f"Loading new cap for animation #{animation}")
self.release_cap() self.release_cap()
file = self.files[animation] file: str = self.files[animation]
if self.reverse: if self.reverse:
file = "{}_reversed{}".format(*os.path.splitext(file)) file = "{}_reversed{}".format(*os.path.splitext(file))
@ -126,16 +126,19 @@ class Presentation:
def rewind_current_slide(self) -> None: def rewind_current_slide(self) -> None:
"""Rewinds current slide to first frame.""" """Rewinds current slide to first frame."""
logger.debug("Rewinding curring slide")
if self.reverse: if self.reverse:
self.current_animation = self.current_slide.end_animation - 1 self.current_animation = self.current_slide.end_animation - 1
else: else:
self.current_animation = self.current_slide.start_animation 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: def cancel_reverse(self) -> None:
"""Cancels any effet produced by a reversed slide.""" """Cancels any effet produced by a reversed slide."""
if self.reverse: if self.reverse:
logger.debug("Cancelling effects from previous 'reverse' action'")
self.reverse = False self.reverse = False
self.reversed_animation = -1 self.reversed_animation = -1
self.release_cap() self.release_cap()
@ -147,6 +150,7 @@ class Presentation:
def load_next_slide(self) -> None: def load_next_slide(self) -> None:
"""Loads next slide.""" """Loads next slide."""
logger.debug("Loading next slide")
if self.reverse: if self.reverse:
self.cancel_reverse() self.cancel_reverse()
self.rewind_current_slide() self.rewind_current_slide()
@ -160,6 +164,7 @@ class Presentation:
def load_previous_slide(self) -> None: def load_previous_slide(self) -> None:
"""Loads previous slide.""" """Loads previous slide."""
logger.debug("Loading previous slide")
self.cancel_reverse() self.cancel_reverse()
self.current_slide_index = max(0, self.current_slide_index - 1) self.current_slide_index = max(0, self.current_slide_index - 1)
self.rewind_current_slide() self.rewind_current_slide()
@ -167,7 +172,12 @@ class Presentation:
@property @property
def fps(self) -> int: def fps(self) -> int:
"""Returns the number of frames per second of the current video.""" """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: def add_last_slide(self) -> None:
"""Add a 'last' slide to the end of slides.""" """Add a 'last' slide to the end of slides."""
@ -264,56 +274,37 @@ class Presentation:
return self.lastframe, state return self.lastframe, state
class Display: class Display(QThread):
"""Displays one or more presentations one after each other.""" """Displays one or more presentations one after each other."""
change_video_signal = pyqtSignal(np.ndarray)
change_info_signal = pyqtSignal(dict)
finished = pyqtSignal()
def __init__( def __init__(
self, self,
presentations, presentations,
config, config,
start_paused=False, start_paused=False,
fullscreen=False,
skip_all=False, skip_all=False,
resolution=(1980, 1080),
interpolation_flag=cv2.INTER_LINEAR,
record_to=None, record_to=None,
exit_after_last_slide=False,
) -> None: ) -> None:
super().__init__()
self.presentations = presentations self.presentations = presentations
self.start_paused = start_paused self.start_paused = start_paused
self.config = config self.config = config
self.skip_all = skip_all self.skip_all = skip_all
self.fullscreen = fullscreen
self.resolution = resolution
self.interpolation_flag = interpolation_flag
self.record_to = record_to self.record_to = record_to
self.recordings = [] self.recordings: List[Tuple[str, int, int]] = []
self.window_flags = (
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_NORMAL
)
self.state = State.PLAYING self.state = State.PLAYING
self.lastframe = None self.lastframe: Optional[np.ndarray] = None
self.current_presentation_index = 0 self.current_presentation_index = 0
self.exit = False self.run_flag = True
self.lag = 0 self.key = -1
self.last_time = now() self.exit_after_last_slide = exit_after_last_slide
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)
@property @property
def current_presentation(self) -> Presentation: def current_presentation(self) -> Presentation:
@ -322,7 +313,8 @@ class Display:
def run(self) -> None: def run(self) -> None:
"""Runs a series of presentations until end or exit.""" """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.lastframe, self.state = self.current_presentation.update_state(
self.state self.state
) )
@ -332,88 +324,98 @@ class Display:
self.start_paused = False self.start_paused = False
if self.state == State.END: if self.state == State.END:
if self.current_presentation_index == len(self.presentations) - 1: if self.current_presentation_index == len(self.presentations) - 1:
self.quit() if self.exit_after_last_slide:
continue self.run_flag = False
continue
else: else:
self.current_presentation_index += 1 self.current_presentation_index += 1
self.state = State.PLAYING self.state = State.PLAYING
self.handle_key() self.handle_key()
if self.exit:
continue
self.show_video() self.show_video()
self.show_info() 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: def show_video(self) -> None:
"""Shows updated video.""" """Shows updated video."""
self.lag = now() - self.last_time
self.last_time = now()
if self.record_to is not None: if self.record_to is not None:
pres = self.current_presentation pres = self.current_presentation
self.recordings.append( self.recordings.append(
(pres.current_file, pres.current_frame_number, pres.fps) (pres.current_file, pres.current_frame_number, pres.fps)
) )
frame = self.lastframe frame: np.ndarray = self.lastframe
self.change_video_signal.emit(frame)
# 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)
def show_info(self) -> None: def show_info(self) -> None:
"""Shows updated information about presentations.""" """Shows updated information about presentations."""
info = np.zeros((130, 420), np.uint8) self.change_info_signal.emit(
font_args = (FONT_ARGS[0], 0.7, *FONT_ARGS[2:]) {
grid_x = [30, 230] "animation": self.current_presentation.current_animation,
grid_y = [30, 70, 110] "state": self.state,
"slide_index": self.current_presentation.current_slide.number,
cv2.putText( "n_slides": len(self.current_presentation.slides),
info, "type": self.current_presentation.current_slide.type,
f"Animation: {self.current_presentation.current_animation}", "scene_index": self.current_presentation_index + 1,
(grid_x[0], grid_y[0]), "n_scenes": len(self.presentations),
*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,
) )
cv2.putText( @pyqtSlot(int)
info, def set_key(self, key: int) -> None:
f"Scene {self.current_presentation_index + 1}/{len(self.presentations)}", """Sets the next key to be handled."""
((grid_x[0] + grid_x[1]) // 2, grid_y[2]), self.key = key
*font_args,
)
cv2.imshow(WINDOW_INFO_NAME, info)
def handle_key(self) -> None: def handle_key(self) -> None:
"""Handles key strokes.""" """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): if self.config.QUIT.match(key):
self.quit() self.run_flag = False
elif self.state == State.PLAYING and self.config.PLAY_PAUSE.match(key): elif self.state == State.PLAYING and self.config.PLAY_PAUSE.match(key):
self.state = State.PAUSED self.state = State.PAUSED
elif self.state == State.PAUSED and self.config.PLAY_PAUSE.match(key): 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.current_presentation.rewind_current_slide()
self.state = State.PLAYING self.state = State.PLAYING
def quit(self) -> None: self.key = -1 # No more key to be handled
"""Destroys all windows created by presentations and exits gracefully."""
cv2.destroyAllWindows()
if self.record_to is not None and len(self.recordings) > 0: def stop(self):
logger.debug( """Stops current thread, without doing anything after."""
f"A total of {len(self.recordings)} frames will be saved to {self.record_to}" 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] class InfoThread(QThread):
fourcc = cv2.VideoWriter_fourcc(*"XVID") def __init__(self):
out = cv2.VideoWriter(self.record_to, fourcc, fps, (h, w)) super().__init__()
self.dialog = Info()
self.run_flag = True
out.write(frame) def start(self):
super().start()
for _file, frame_number, _ in tqdm( self.dialog.show()
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) def stop(self):
_, frame = cap.read() self.dialog.deleteLater()
out.write(frame)
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() @click.command()
@ -517,7 +642,7 @@ def _list_scenes(folder) -> List[str]:
) )
pass 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 return scenes
@ -535,9 +660,10 @@ def _list_scenes(folder) -> List[str]:
@click.option("--start-paused", is_flag=True, help="Start paused.") @click.option("--start-paused", is_flag=True, help="Start paused.")
@click.option("--fullscreen", is_flag=True, help="Fullscreen mode.") @click.option("--fullscreen", is_flag=True, help="Fullscreen mode.")
@click.option( @click.option(
"-s",
"--skip-all", "--skip-all",
is_flag=True, 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( @click.option(
"-r", "-r",
@ -548,19 +674,30 @@ def _list_scenes(folder) -> List[str]:
show_default=True, show_default=True,
) )
@click.option( @click.option(
"-i", "--to",
"--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(
"--record-to", "--record-to",
"record_to",
type=click.Path(dir_okay=False), type=click.Path(dir_okay=False),
default=None, default=None,
help="If set, the presentation will be recorded into a AVI video file with given name.", 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") @click.help_option("-h", "--help")
@verbosity_option @verbosity_option
def present( def present(
@ -571,8 +708,10 @@ def present(
fullscreen, fullscreen,
skip_all, skip_all,
resolution, resolution,
interpolation_flag,
record_to, record_to,
exit_after_last_slide,
hide_mouse,
aspect_ratio,
) -> None: ) -> None:
""" """
Present SCENE(s), one at a time, in order. 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. 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: if len(scenes) == 0:
scene_choices = _list_scenes(folder) scene_choices = _list_scenes(folder)
@ -638,7 +780,7 @@ def present(
except ValidationError as e: except ValidationError as e:
raise click.UsageError(str(e)) raise click.UsageError(str(e))
else: else:
logger.info("No configuration file found, using default configuration.") logger.debug("No configuration file found, using default configuration.")
config = Config() config = Config()
if record_to is not None: 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." "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, presentations,
config=config, config=config,
start_paused=start_paused, start_paused=start_paused,
fullscreen=fullscreen, fullscreen=fullscreen,
skip_all=skip_all, skip_all=skip_all,
resolution=resolution, resolution=resolution,
interpolation_flag=INTERPOLATION_FLAGS[interpolation_flag],
record_to=record_to, 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 platform
import shutil import shutil
import subprocess import subprocess
from typing import List from typing import Any, List, Optional
from tqdm import tqdm 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. 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 MANIMGL:
if not os.path.isdir("videos"): if not os.path.isdir("videos"):
os.mkdir("videos") os.mkdir("videos")
@ -38,10 +40,10 @@ class Slide(Scene):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.output_folder = output_folder self.output_folder = output_folder
self.slides = [] self.slides: List[SlideConfig] = []
self.current_slide = 1 self.current_slide = 1
self.current_animation = 0 self.current_animation = 0
self.loop_start_animation = None self.loop_start_animation: Optional[int] = None
self.pause_start_animation = 0 self.pause_start_animation = 0
@property @property
@ -69,14 +71,14 @@ class Slide(Scene):
return config["progress_bar"] != "none" return config["progress_bar"] != "none"
@property @property
def leave_progress_bar(self) -> None: def leave_progress_bar(self) -> bool:
"""Returns True if progress bar should be left after completed.""" """Returns True if progress bar should be left after completed."""
if MANIMGL: if MANIMGL:
return getattr(super(Scene, self), "leave_progress_bars", False) return getattr(super(Scene, self), "leave_progress_bars", False)
else: else:
return config["progress_bar"] == "leave" 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.""" """Overloads `self.play` and increment animation count."""
super().play(*args, **kwargs) super().play(*args, **kwargs)
self.current_animation += 1 self.current_animation += 1
@ -116,7 +118,7 @@ class Slide(Scene):
self.loop_start_animation = None self.loop_start_animation = None
self.pause_start_animation = self.current_animation 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. 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)}'" 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""" """MANIMGL renderer"""
super().run(*args, **kwargs) super().run(*args, **kwargs)
self.save_slides(use_cache=False) self.save_slides(use_cache=False)
def render(self, *args, **kwargs) -> None: def render(self, *args: Any, **kwargs: Any) -> None:
"""MANIM render""" """MANIM render"""
# We need to disable the caching limit since we rely on intermidiate files # We need to disable the caching limit since we rely on intermidiate files
max_files_cached = config["max_files_cached"] max_files_cached = config["max_files_cached"]

View File

@ -1,39 +1,127 @@
import os import os
import sys import sys
from functools import partial
from typing import Any
import click import click
import cv2 from PyQt5.QtCore import Qt
import numpy as np from PyQt5.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QGridLayout,
QLabel,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from .commons import config_options, verbosity_option from .commons import config_options, verbosity_option
from .config import Config from .config import Config, Key
from .defaults import CONFIG_PATH, FONT_ARGS from .defaults import CONFIG_PATH
from .manim import logger
WINDOW_NAME = "Manim Slides Configuration Wizard" WINDOW_NAME: str = "Configuration Wizard"
WINDOW_SIZE = (120, 620)
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: class KeyInput(QDialog):
"""Returns centered position for text to be displayed in current window.""" def __init__(self) -> None:
_, width = window_size super().__init__()
font, scale, _, thickness, _ = font_args self.key = None
(size_in_pixels, _), _ = cv2.getTextSize(text, font, scale, thickness)
return (width - size_in_pixels) // 2 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: class Wizard(QWidget):
"""Diplays some question in current window and waits for key press.""" def __init__(self, config: Config):
display = np.zeros(WINDOW_SIZE, np.uint8)
text = "* Manim Slides Wizard *" super().__init__()
text_org = center_text_horizontally(text, WINDOW_SIZE, FONT_ARGS), 33
question_org = center_text_horizontally(question, WINDOW_SIZE, FONT_ARGS), 85
cv2.putText(display, "* Manim Slides Wizard *", text_org, *FONT_ARGS) self.setWindowTitle(WINDOW_NAME)
cv2.putText(display, question, question_org, *FONT_ARGS) self.config = config
cv2.imshow(WINDOW_NAME, display) QBtn = QDialogButtonBox.Save | QDialogButtonBox.Cancel
return cv2.waitKeyEx(-1)
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() @click.command()
@ -69,27 +157,27 @@ def _init(config_path, force, merge, skip_interactive=False):
force = choice == "o" force = choice == "o"
merge = choice == "m" merge = choice == "m"
if force: if not force and not merge:
click.secho("Overwriting.") logger.debug("Exiting without doing anything")
elif merge:
click.secho("Merging.")
else:
click.secho("Exiting.")
sys.exit(0) sys.exit(0)
config = Config() 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 not skip_interactive:
if os.path.exists(config_path):
config = Config.parse_file(config_path)
cv2.namedWindow( app = QApplication(sys.argv)
WINDOW_NAME, window = Wizard(config)
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_AUTOSIZE, window.show()
) app.exec()
prompt("Press any key to continue") config = window.config
for _, key in config:
key.ids = [prompt(f"Press the {key.name} key")]
if merge: if merge:
config = Config.parse_file(config_path).merge_with(config) 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: with open(config_path, "w") as config_file:
config_file.write(config.json(indent=2)) 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", "click-default-group>=1.2",
"numpy>=1.19.3", "numpy>=1.19.3",
"pydantic>=1.9.1", "pydantic>=1.9.1",
"pyqt5>=5.15",
"opencv-python>=4.6", "opencv-python>=4.6",
"tqdm>=4.62.3", "tqdm>=4.62.3",
], ],