mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-19 19:46:49 +08:00
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:
3
.github/workflows/test_examples.yml
vendored
3
.github/workflows/test_examples.yml
vendored
@ -6,6 +6,9 @@ on:
|
||||
|
||||
name: Test Examples
|
||||
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
|
||||
jobs:
|
||||
build-examples:
|
||||
strategy:
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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"]
|
||||
|
@ -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}`")
|
||||
|
Reference in New Issue
Block a user