Skip to content

Commit 2a327c4

Browse files
authored
feat(cli): auto detect resolution (#158)
* feat(cli): auto detect resolution The `present` command will now read by default the resolution of each presentation, and only change it if specified by the user. This PR also fixes bugs introduced by #156 and previous PRs, where the transition between two presentation was not correct... * fix(lib): better to test if not None
1 parent 04dcf53 commit 2a327c4

File tree

5 files changed

+108
-67
lines changed

5 files changed

+108
-67
lines changed

manim_slides/config.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import tempfile
66
from enum import Enum
77
from pathlib import Path
8-
from typing import Dict, List, Optional, Set, Union
8+
from typing import Dict, List, Optional, Set, Tuple, Union
99

10-
from pydantic import BaseModel, FilePath, root_validator, validator
10+
from pydantic import BaseModel, FilePath, PositiveInt, root_validator, validator
1111
from PySide6.QtCore import Qt
1212

1313
from .defaults import FFMPEG_BIN
@@ -20,7 +20,7 @@ def merge_basenames(files: List[FilePath]) -> Path:
2020
"""
2121
logger.info(f"Generating a new filename for animations: {files}")
2222

23-
dirname = files[0].parent
23+
dirname: Path = files[0].parent
2424
ext = files[0].suffix
2525

2626
basenames = (file.stem for file in files)
@@ -31,7 +31,7 @@ def merge_basenames(files: List[FilePath]) -> Path:
3131
# https://github.com/jeertmans/manim-slides/issues/123
3232
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
3333

34-
return dirname / (basename + ext)
34+
return dirname.joinpath(basename + ext)
3535

3636

3737
class Key(BaseModel): # type: ignore
@@ -149,21 +149,22 @@ def slides_slice(self) -> slice:
149149
class PresentationConfig(BaseModel): # type: ignore
150150
slides: List[SlideConfig]
151151
files: List[FilePath]
152+
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
152153

153154
@root_validator
154155
def animation_indices_match_files(
155156
cls, values: Dict[str, Union[List[SlideConfig], List[FilePath]]]
156157
) -> Dict[str, Union[List[SlideConfig], List[FilePath]]]:
157-
files = values.get("files")
158-
slides = values.get("slides")
158+
files: List[FilePath] = values.get("files") # type: ignore
159+
slides: List[SlideConfig] = values.get("slides") # type: ignore
159160

160161
if files is None or slides is None:
161162
return values
162163

163164
n_files = len(files)
164165

165166
for slide in slides:
166-
if slide.end_animation > n_files: # type: ignore
167+
if slide.end_animation > n_files:
167168
raise ValueError(
168169
f"The following slide's contains animations not listed in files {files}: {slide}"
169170
)

manim_slides/convert.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from .present import get_scenes_presentation_config
2525

2626

27-
def open_with_default(file: Path):
27+
def open_with_default(file: Path) -> None:
2828
system = platform.system()
2929
if system == "Darwin":
3030
subprocess.call(("open", str(file)))
@@ -66,7 +66,7 @@ def load_template(self) -> str:
6666
An empty string is returned if no template is used."""
6767
return ""
6868

69-
def open(self, file: Path) -> bool:
69+
def open(self, file: Path) -> Any:
7070
"""Opens a file, generated with converter, using appropriate application."""
7171
raise NotImplementedError
7272

@@ -376,7 +376,7 @@ class Config:
376376
use_enum_values = True
377377
extra = "forbid"
378378

379-
def open(self, file: Path) -> bool:
379+
def open(self, file: Path) -> None:
380380
return open_with_default(file)
381381

382382
def convert_to(self, dest: Path) -> None:
@@ -389,7 +389,9 @@ def convert_to(self, dest: Path) -> None:
389389

390390
# From GitHub issue comment:
391391
# - https://github.com/scanny/python-pptx/issues/427#issuecomment-856724440
392-
def auto_play_media(media: pptx.shapes.picture.Movie, loop: bool = False):
392+
def auto_play_media(
393+
media: pptx.shapes.picture.Movie, loop: bool = False
394+
) -> None:
393395
el_id = xpath(media.element, ".//p:cNvPr")[0].attrib["id"]
394396
el_cnt = xpath(
395397
media.element.getparent().getparent().getparent(),
@@ -463,7 +465,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None:
463465

464466
ctx.exit()
465467

466-
return click.option(
468+
return click.option( # type: ignore
467469
"--show-config",
468470
is_flag=True,
469471
help="Show supported options for given format and exit.",
@@ -491,7 +493,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None:
491493

492494
ctx.exit()
493495

494-
return click.option(
496+
return click.option( # type: ignore
495497
"--show-template",
496498
is_flag=True,
497499
help="Show the template (currently) used for a given conversion format and exit.",

manim_slides/present.py

Lines changed: 71 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import click
1010
import cv2
1111
import numpy as np
12+
from click import Context, Parameter
1213
from pydantic import ValidationError
1314
from PySide6.QtCore import Qt, QThread, Signal, Slot
1415
from PySide6.QtGui import QCloseEvent, QIcon, QImage, QKeyEvent, QPixmap, QResizeEvent
@@ -70,8 +71,7 @@ class Presentation:
7071
"""Creates presentation from a configuration object."""
7172

7273
def __init__(self, config: PresentationConfig) -> None:
73-
self.slides: List[SlideConfig] = config.slides
74-
self.files: List[str] = config.files
74+
self.config = config
7575

7676
self.__current_slide_index: int = 0
7777
self.current_animation: int = self.current_slide.start_animation
@@ -90,23 +90,41 @@ def __init__(self, config: PresentationConfig) -> None:
9090
def __len__(self) -> int:
9191
return len(self.slides)
9292

93+
@property
94+
def slides(self) -> List[SlideConfig]:
95+
"""Returns the list of slides."""
96+
return self.config.slides
97+
98+
@property
99+
def files(self) -> List[Path]:
100+
"""Returns the list of animation files."""
101+
return self.config.files
102+
103+
@property
104+
def resolution(self) -> Tuple[int, int]:
105+
"""Returns the resolution."""
106+
return self.config.resolution
107+
93108
@property
94109
def current_slide_index(self) -> int:
95110
return self.__current_slide_index
96111

97112
@current_slide_index.setter
98-
def current_slide_index(self, value: Optional[int]):
99-
if value:
113+
def current_slide_index(self, value: Optional[int]) -> None:
114+
if value is not None:
100115
if -len(self) <= value < len(self):
101116
self.__current_slide_index = value
102117
self.current_animation = self.current_slide.start_animation
118+
logger.debug(f"Set current slide index to {value}")
103119
else:
104120
logger.error(
105121
f"Could not load slide number {value}, playing first slide instead."
106122
)
107123

108-
def set_current_animation_and_update_slide_number(self, value: Optional[int]):
109-
if value:
124+
def set_current_animation_and_update_slide_number(
125+
self, value: Optional[int]
126+
) -> None:
127+
if value is not None:
110128
n_files = len(self.files)
111129
if -n_files <= value < n_files:
112130
if value < 0:
@@ -116,6 +134,8 @@ def set_current_animation_and_update_slide_number(self, value: Optional[int]):
116134
if value < slide.end_animation:
117135
self.current_slide_index = i
118136
self.current_animation = value
137+
138+
logger.debug(f"Playing animation {value}, at slide index {i}")
119139
return
120140

121141
assert (
@@ -159,7 +179,7 @@ def load_animation_cap(self, animation: int) -> None:
159179

160180
self.release_cap()
161181

162-
file: str = self.files[animation]
182+
file: str = str(self.files[animation])
163183

164184
if self.reverse:
165185
file = "{}_reversed{}".format(*os.path.splitext(file))
@@ -178,7 +198,7 @@ def current_cap(self) -> cv2.VideoCapture:
178198

179199
def rewind_current_slide(self) -> None:
180200
"""Rewinds current slide to first frame."""
181-
logger.debug("Rewinding curring slide")
201+
logger.debug("Rewinding current slide")
182202
if self.reverse:
183203
self.current_animation = self.current_slide.end_animation - 1
184204
else:
@@ -216,9 +236,10 @@ def load_next_slide(self) -> None:
216236

217237
def load_previous_slide(self) -> None:
218238
"""Loads previous slide."""
219-
logger.debug("Loading previous slide")
239+
logger.debug(f"Loading previous slide, current is {self.current_slide_index}")
220240
self.cancel_reverse()
221241
self.current_slide_index = max(0, self.current_slide_index - 1)
242+
logger.debug(f"Loading slide index {self.current_slide_index}")
222243
self.rewind_current_slide()
223244

224245
@property
@@ -229,7 +250,8 @@ def fps(self) -> int:
229250
logger.warn(
230251
f"Something is wrong with video file {self.current_file}, as the fps returned by frame {self.current_frame_number} is 0"
231252
)
232-
return max(fps, 1) # TODO: understand why we sometimes get 0 fps
253+
# TODO: understand why we sometimes get 0 fps
254+
return max(fps, 1) # type: ignore
233255

234256
def reset(self) -> None:
235257
"""Rests current presentation."""
@@ -320,6 +342,7 @@ class Display(QThread): # type: ignore
320342

321343
change_video_signal = Signal(np.ndarray)
322344
change_info_signal = Signal(dict)
345+
change_presentation_sigal = Signal()
323346
finished = Signal()
324347

325348
def __init__(
@@ -365,10 +388,12 @@ def current_presentation_index(self) -> int:
365388
return self.__current_presentation_index
366389

367390
@current_presentation_index.setter
368-
def current_presentation_index(self, value: Optional[int]):
369-
if value:
391+
def current_presentation_index(self, value: Optional[int]) -> None:
392+
if value is not None:
370393
if -len(self) <= value < len(self):
371394
self.__current_presentation_index = value
395+
self.current_presentation.release_cap()
396+
self.change_presentation_sigal.emit()
372397
else:
373398
logger.error(
374399
f"Could not load scene number {value}, playing first scene instead."
@@ -379,6 +404,11 @@ def current_presentation(self) -> Presentation:
379404
"""Returns the current presentation."""
380405
return self.presentations[self.current_presentation_index]
381406

407+
@property
408+
def current_resolution(self) -> Tuple[int, int]:
409+
"""Returns the resolution of the current presentation."""
410+
return self.current_presentation.resolution
411+
382412
def run(self) -> None:
383413
"""Runs a series of presentations until end or exit."""
384414
while self.run_flag:
@@ -413,7 +443,7 @@ def run(self) -> None:
413443
if self.record_to is not None:
414444
self.record_movie()
415445

416-
logger.debug("Closing video thread gracully and exiting")
446+
logger.debug("Closing video thread gracefully and exiting")
417447
self.finished.emit()
418448

419449
def record_movie(self) -> None:
@@ -587,7 +617,6 @@ def __init__(
587617
*args: Any,
588618
config: Config = DEFAULT_CONFIG,
589619
fullscreen: bool = False,
590-
resolution: Tuple[int, int] = (1980, 1080),
591620
hide_mouse: bool = False,
592621
aspect_ratio: AspectRatio = AspectRatio.auto,
593622
resize_mode: Qt.TransformationMode = Qt.SmoothTransformation,
@@ -599,7 +628,12 @@ def __init__(
599628
self.setWindowTitle(WINDOW_NAME)
600629
self.icon = QIcon(":/icon.png")
601630
self.setWindowIcon(self.icon)
602-
self.display_width, self.display_height = resolution
631+
632+
# create the video capture thread
633+
kwargs["config"] = config
634+
self.thread = Display(*args, **kwargs)
635+
636+
self.display_width, self.display_height = self.thread.current_resolution
603637
self.aspect_ratio = aspect_ratio
604638
self.resize_mode = resize_mode
605639
self.hide_mouse = hide_mouse
@@ -619,9 +653,6 @@ def __init__(
619653
self.label.setPixmap(self.pixmap)
620654
self.label.setMinimumSize(1, 1)
621655

622-
# create the video capture thread
623-
kwargs["config"] = config
624-
self.thread = Display(*args, **kwargs)
625656
# create the info dialog
626657
self.info = Info()
627658
self.info.show()
@@ -635,6 +666,7 @@ def __init__(
635666
# connect signals
636667
self.thread.change_video_signal.connect(self.update_image)
637668
self.thread.change_info_signal.connect(self.info.update_info)
669+
self.thread.change_presentation_sigal.connect(self.update_canvas)
638670
self.thread.finished.connect(self.closeAll)
639671
self.send_key_signal.connect(self.thread.set_key)
640672

@@ -688,6 +720,14 @@ def update_image(self, cv_img: np.ndarray) -> None:
688720

689721
self.label.setPixmap(QPixmap.fromImage(qt_img))
690722

723+
@Slot()
724+
def update_canvas(self) -> None:
725+
"""Update the canvas when a presentation has changed."""
726+
logger.debug("Updating canvas")
727+
self.display_width, self.display_height = self.thread.current_resolution
728+
if not self.isFullScreen():
729+
self.resize(self.display_width, self.display_height)
730+
691731

692732
@click.command()
693733
@click.option(
@@ -757,7 +797,7 @@ def value_proc(value: Optional[str]) -> List[str]:
757797
while True:
758798
try:
759799
scenes = click.prompt("Choice(s)", value_proc=value_proc)
760-
return scenes
800+
return scenes # type: ignore
761801
except ValueError as e:
762802
raise click.UsageError(str(e))
763803

@@ -785,7 +825,9 @@ def get_scenes_presentation_config(
785825
return presentation_configs
786826

787827

788-
def start_at_callback(ctx, param, values: str) -> Tuple[Optional[int], ...]:
828+
def start_at_callback(
829+
ctx: Context, param: Parameter, values: str
830+
) -> Tuple[Optional[int], ...]:
789831
if values == "(None, None, None)":
790832
return (None, None, None)
791833

@@ -838,9 +880,8 @@ def str_to_int_or_none(value: str) -> Optional[int]:
838880
"--resolution",
839881
metavar="<WIDTH HEIGHT>",
840882
type=(int, int),
841-
default=(1920, 1080),
883+
default=None,
842884
help="Window resolution WIDTH HEIGHT used if fullscreen is not set. You may manually resize the window afterward.",
843-
show_default=True,
844885
)
845886
@click.option(
846887
"--to",
@@ -931,7 +972,7 @@ def present(
931972
start_paused: bool,
932973
fullscreen: bool,
933974
skip_all: bool,
934-
resolution: Tuple[int, int],
975+
resolution: Optional[Tuple[int, int]],
935976
record_to: Optional[Path],
936977
exit_after_last_slide: bool,
937978
hide_mouse: bool,
@@ -956,9 +997,15 @@ def present(
956997
if skip_all:
957998
exit_after_last_slide = True
958999

1000+
presentation_configs = get_scenes_presentation_config(scenes, folder)
1001+
1002+
if resolution is not None:
1003+
for presentation_config in presentation_configs:
1004+
presentation_config.resolution = resolution
1005+
9591006
presentations = [
9601007
Presentation(presentation_config)
961-
for presentation_config in get_scenes_presentation_config(scenes, folder)
1008+
for presentation_config in presentation_configs
9621009
]
9631010

9641011
if config_path.exists():
@@ -994,7 +1041,6 @@ def present(
9941041
start_paused=start_paused,
9951042
fullscreen=fullscreen,
9961043
skip_all=skip_all,
997-
resolution=resolution,
9981044
record_to=record_to,
9991045
exit_after_last_slide=exit_after_last_slide,
10001046
hide_mouse=hide_mouse,

0 commit comments

Comments
 (0)