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
This commit is contained in:
Jérome Eertmans
2023-03-16 15:41:31 +01:00
committed by GitHub
parent 04dcf530f5
commit 2a327c470b
5 changed files with 108 additions and 67 deletions

View File

@ -5,9 +5,9 @@ import subprocess
import tempfile import tempfile
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Set, Union from typing import Dict, List, Optional, Set, Tuple, Union
from pydantic import BaseModel, FilePath, root_validator, validator from pydantic import BaseModel, FilePath, PositiveInt, root_validator, validator
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from .defaults import FFMPEG_BIN from .defaults import FFMPEG_BIN
@ -20,7 +20,7 @@ def merge_basenames(files: List[FilePath]) -> Path:
""" """
logger.info(f"Generating a new filename for animations: {files}") logger.info(f"Generating a new filename for animations: {files}")
dirname = files[0].parent dirname: Path = files[0].parent
ext = files[0].suffix ext = files[0].suffix
basenames = (file.stem for file in files) basenames = (file.stem for file in files)
@ -31,7 +31,7 @@ def merge_basenames(files: List[FilePath]) -> Path:
# https://github.com/jeertmans/manim-slides/issues/123 # https://github.com/jeertmans/manim-slides/issues/123
basename = hashlib.sha256(basenames_str.encode()).hexdigest() basename = hashlib.sha256(basenames_str.encode()).hexdigest()
return dirname / (basename + ext) return dirname.joinpath(basename + ext)
class Key(BaseModel): # type: ignore class Key(BaseModel): # type: ignore
@ -149,13 +149,14 @@ class SlideConfig(BaseModel): # type: ignore
class PresentationConfig(BaseModel): # type: ignore class PresentationConfig(BaseModel): # type: ignore
slides: List[SlideConfig] slides: List[SlideConfig]
files: List[FilePath] files: List[FilePath]
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
@root_validator @root_validator
def animation_indices_match_files( def animation_indices_match_files(
cls, values: Dict[str, Union[List[SlideConfig], List[FilePath]]] cls, values: Dict[str, Union[List[SlideConfig], List[FilePath]]]
) -> Dict[str, Union[List[SlideConfig], List[FilePath]]]: ) -> Dict[str, Union[List[SlideConfig], List[FilePath]]]:
files = values.get("files") files: List[FilePath] = values.get("files") # type: ignore
slides = values.get("slides") slides: List[SlideConfig] = values.get("slides") # type: ignore
if files is None or slides is None: if files is None or slides is None:
return values return values
@ -163,7 +164,7 @@ class PresentationConfig(BaseModel): # type: ignore
n_files = len(files) n_files = len(files)
for slide in slides: for slide in slides:
if slide.end_animation > n_files: # type: ignore if slide.end_animation > n_files:
raise ValueError( raise ValueError(
f"The following slide's contains animations not listed in files {files}: {slide}" f"The following slide's contains animations not listed in files {files}: {slide}"
) )

View File

@ -24,7 +24,7 @@ from .logger import logger
from .present import get_scenes_presentation_config from .present import get_scenes_presentation_config
def open_with_default(file: Path): def open_with_default(file: Path) -> None:
system = platform.system() system = platform.system()
if system == "Darwin": if system == "Darwin":
subprocess.call(("open", str(file))) subprocess.call(("open", str(file)))
@ -66,7 +66,7 @@ class Converter(BaseModel): # type: ignore
An empty string is returned if no template is used.""" An empty string is returned if no template is used."""
return "" return ""
def open(self, file: Path) -> bool: def open(self, file: Path) -> Any:
"""Opens a file, generated with converter, using appropriate application.""" """Opens a file, generated with converter, using appropriate application."""
raise NotImplementedError raise NotImplementedError
@ -376,7 +376,7 @@ class PowerPoint(Converter):
use_enum_values = True use_enum_values = True
extra = "forbid" extra = "forbid"
def open(self, file: Path) -> bool: def open(self, file: Path) -> None:
return open_with_default(file) return open_with_default(file)
def convert_to(self, dest: Path) -> None: def convert_to(self, dest: Path) -> None:
@ -389,7 +389,9 @@ class PowerPoint(Converter):
# From GitHub issue comment: # From GitHub issue comment:
# - https://github.com/scanny/python-pptx/issues/427#issuecomment-856724440 # - https://github.com/scanny/python-pptx/issues/427#issuecomment-856724440
def auto_play_media(media: pptx.shapes.picture.Movie, loop: bool = False): def auto_play_media(
media: pptx.shapes.picture.Movie, loop: bool = False
) -> None:
el_id = xpath(media.element, ".//p:cNvPr")[0].attrib["id"] el_id = xpath(media.element, ".//p:cNvPr")[0].attrib["id"]
el_cnt = xpath( el_cnt = xpath(
media.element.getparent().getparent().getparent(), media.element.getparent().getparent().getparent(),
@ -463,7 +465,7 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
ctx.exit() ctx.exit()
return click.option( return click.option( # type: ignore
"--show-config", "--show-config",
is_flag=True, is_flag=True,
help="Show supported options for given format and exit.", help="Show supported options for given format and exit.",
@ -491,7 +493,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
ctx.exit() ctx.exit()
return click.option( return click.option( # type: ignore
"--show-template", "--show-template",
is_flag=True, is_flag=True,
help="Show the template (currently) used for a given conversion format and exit.", help="Show the template (currently) used for a given conversion format and exit.",

View File

@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
import click import click
import cv2 import cv2
import numpy as np import numpy as np
from click import Context, Parameter
from pydantic import ValidationError from pydantic import ValidationError
from PySide6.QtCore import Qt, QThread, Signal, Slot from PySide6.QtCore import Qt, QThread, Signal, Slot
from PySide6.QtGui import QCloseEvent, QIcon, QImage, QKeyEvent, QPixmap, QResizeEvent from PySide6.QtGui import QCloseEvent, QIcon, QImage, QKeyEvent, QPixmap, QResizeEvent
@ -70,8 +71,7 @@ class Presentation:
"""Creates presentation from a configuration object.""" """Creates presentation from a configuration object."""
def __init__(self, config: PresentationConfig) -> None: def __init__(self, config: PresentationConfig) -> None:
self.slides: List[SlideConfig] = config.slides self.config = config
self.files: List[str] = config.files
self.__current_slide_index: int = 0 self.__current_slide_index: int = 0
self.current_animation: int = self.current_slide.start_animation self.current_animation: int = self.current_slide.start_animation
@ -90,23 +90,41 @@ class Presentation:
def __len__(self) -> int: def __len__(self) -> int:
return len(self.slides) return len(self.slides)
@property
def slides(self) -> List[SlideConfig]:
"""Returns the list of slides."""
return self.config.slides
@property
def files(self) -> List[Path]:
"""Returns the list of animation files."""
return self.config.files
@property
def resolution(self) -> Tuple[int, int]:
"""Returns the resolution."""
return self.config.resolution
@property @property
def current_slide_index(self) -> int: def current_slide_index(self) -> int:
return self.__current_slide_index return self.__current_slide_index
@current_slide_index.setter @current_slide_index.setter
def current_slide_index(self, value: Optional[int]): def current_slide_index(self, value: Optional[int]) -> None:
if value: if value is not None:
if -len(self) <= value < len(self): if -len(self) <= value < len(self):
self.__current_slide_index = value self.__current_slide_index = value
self.current_animation = self.current_slide.start_animation self.current_animation = self.current_slide.start_animation
logger.debug(f"Set current slide index to {value}")
else: else:
logger.error( logger.error(
f"Could not load slide number {value}, playing first slide instead." f"Could not load slide number {value}, playing first slide instead."
) )
def set_current_animation_and_update_slide_number(self, value: Optional[int]): def set_current_animation_and_update_slide_number(
if value: self, value: Optional[int]
) -> None:
if value is not None:
n_files = len(self.files) n_files = len(self.files)
if -n_files <= value < n_files: if -n_files <= value < n_files:
if value < 0: if value < 0:
@ -116,6 +134,8 @@ class Presentation:
if value < slide.end_animation: if value < slide.end_animation:
self.current_slide_index = i self.current_slide_index = i
self.current_animation = value self.current_animation = value
logger.debug(f"Playing animation {value}, at slide index {i}")
return return
assert ( assert (
@ -159,7 +179,7 @@ class Presentation:
self.release_cap() self.release_cap()
file: str = self.files[animation] file: str = str(self.files[animation])
if self.reverse: if self.reverse:
file = "{}_reversed{}".format(*os.path.splitext(file)) file = "{}_reversed{}".format(*os.path.splitext(file))
@ -178,7 +198,7 @@ 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") logger.debug("Rewinding current 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:
@ -216,9 +236,10 @@ 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") logger.debug(f"Loading previous slide, current is {self.current_slide_index}")
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)
logger.debug(f"Loading slide index {self.current_slide_index}")
self.rewind_current_slide() self.rewind_current_slide()
@property @property
@ -229,7 +250,8 @@ class Presentation:
logger.warn( logger.warn(
f"Something is wrong with video file {self.current_file}, as the fps returned by frame {self.current_frame_number} is 0" 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 # TODO: understand why we sometimes get 0 fps
return max(fps, 1) # type: ignore
def reset(self) -> None: def reset(self) -> None:
"""Rests current presentation.""" """Rests current presentation."""
@ -320,6 +342,7 @@ class Display(QThread): # type: ignore
change_video_signal = Signal(np.ndarray) change_video_signal = Signal(np.ndarray)
change_info_signal = Signal(dict) change_info_signal = Signal(dict)
change_presentation_sigal = Signal()
finished = Signal() finished = Signal()
def __init__( def __init__(
@ -365,10 +388,12 @@ class Display(QThread): # type: ignore
return self.__current_presentation_index return self.__current_presentation_index
@current_presentation_index.setter @current_presentation_index.setter
def current_presentation_index(self, value: Optional[int]): def current_presentation_index(self, value: Optional[int]) -> None:
if value: if value is not None:
if -len(self) <= value < len(self): if -len(self) <= value < len(self):
self.__current_presentation_index = value self.__current_presentation_index = value
self.current_presentation.release_cap()
self.change_presentation_sigal.emit()
else: else:
logger.error( logger.error(
f"Could not load scene number {value}, playing first scene instead." f"Could not load scene number {value}, playing first scene instead."
@ -379,6 +404,11 @@ class Display(QThread): # type: ignore
"""Returns the current presentation.""" """Returns the current presentation."""
return self.presentations[self.current_presentation_index] return self.presentations[self.current_presentation_index]
@property
def current_resolution(self) -> Tuple[int, int]:
"""Returns the resolution of the current presentation."""
return self.current_presentation.resolution
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 self.run_flag: while self.run_flag:
@ -413,7 +443,7 @@ class Display(QThread): # type: ignore
if self.record_to is not None: if self.record_to is not None:
self.record_movie() self.record_movie()
logger.debug("Closing video thread gracully and exiting") logger.debug("Closing video thread gracefully and exiting")
self.finished.emit() self.finished.emit()
def record_movie(self) -> None: def record_movie(self) -> None:
@ -587,7 +617,6 @@ class App(QWidget): # type: ignore
*args: Any, *args: Any,
config: Config = DEFAULT_CONFIG, config: Config = DEFAULT_CONFIG,
fullscreen: bool = False, fullscreen: bool = False,
resolution: Tuple[int, int] = (1980, 1080),
hide_mouse: bool = False, hide_mouse: bool = False,
aspect_ratio: AspectRatio = AspectRatio.auto, aspect_ratio: AspectRatio = AspectRatio.auto,
resize_mode: Qt.TransformationMode = Qt.SmoothTransformation, resize_mode: Qt.TransformationMode = Qt.SmoothTransformation,
@ -599,7 +628,12 @@ class App(QWidget): # type: ignore
self.setWindowTitle(WINDOW_NAME) self.setWindowTitle(WINDOW_NAME)
self.icon = QIcon(":/icon.png") self.icon = QIcon(":/icon.png")
self.setWindowIcon(self.icon) self.setWindowIcon(self.icon)
self.display_width, self.display_height = resolution
# create the video capture thread
kwargs["config"] = config
self.thread = Display(*args, **kwargs)
self.display_width, self.display_height = self.thread.current_resolution
self.aspect_ratio = aspect_ratio self.aspect_ratio = aspect_ratio
self.resize_mode = resize_mode self.resize_mode = resize_mode
self.hide_mouse = hide_mouse self.hide_mouse = hide_mouse
@ -619,9 +653,6 @@ class App(QWidget): # type: ignore
self.label.setPixmap(self.pixmap) self.label.setPixmap(self.pixmap)
self.label.setMinimumSize(1, 1) self.label.setMinimumSize(1, 1)
# create the video capture thread
kwargs["config"] = config
self.thread = Display(*args, **kwargs)
# create the info dialog # create the info dialog
self.info = Info() self.info = Info()
self.info.show() self.info.show()
@ -635,6 +666,7 @@ class App(QWidget): # type: ignore
# connect signals # connect signals
self.thread.change_video_signal.connect(self.update_image) self.thread.change_video_signal.connect(self.update_image)
self.thread.change_info_signal.connect(self.info.update_info) self.thread.change_info_signal.connect(self.info.update_info)
self.thread.change_presentation_sigal.connect(self.update_canvas)
self.thread.finished.connect(self.closeAll) self.thread.finished.connect(self.closeAll)
self.send_key_signal.connect(self.thread.set_key) self.send_key_signal.connect(self.thread.set_key)
@ -688,6 +720,14 @@ class App(QWidget): # type: ignore
self.label.setPixmap(QPixmap.fromImage(qt_img)) self.label.setPixmap(QPixmap.fromImage(qt_img))
@Slot()
def update_canvas(self) -> None:
"""Update the canvas when a presentation has changed."""
logger.debug("Updating canvas")
self.display_width, self.display_height = self.thread.current_resolution
if not self.isFullScreen():
self.resize(self.display_width, self.display_height)
@click.command() @click.command()
@click.option( @click.option(
@ -757,7 +797,7 @@ def prompt_for_scenes(folder: Path) -> List[str]:
while True: while True:
try: try:
scenes = click.prompt("Choice(s)", value_proc=value_proc) scenes = click.prompt("Choice(s)", value_proc=value_proc)
return scenes return scenes # type: ignore
except ValueError as e: except ValueError as e:
raise click.UsageError(str(e)) raise click.UsageError(str(e))
@ -785,7 +825,9 @@ def get_scenes_presentation_config(
return presentation_configs return presentation_configs
def start_at_callback(ctx, param, values: str) -> Tuple[Optional[int], ...]: def start_at_callback(
ctx: Context, param: Parameter, values: str
) -> Tuple[Optional[int], ...]:
if values == "(None, None, None)": if values == "(None, None, None)":
return (None, None, None) return (None, None, None)
@ -838,9 +880,8 @@ def start_at_callback(ctx, param, values: str) -> Tuple[Optional[int], ...]:
"--resolution", "--resolution",
metavar="<WIDTH HEIGHT>", metavar="<WIDTH HEIGHT>",
type=(int, int), type=(int, int),
default=(1920, 1080), default=None,
help="Window resolution WIDTH HEIGHT used if fullscreen is not set. You may manually resize the window afterward.", help="Window resolution WIDTH HEIGHT used if fullscreen is not set. You may manually resize the window afterward.",
show_default=True,
) )
@click.option( @click.option(
"--to", "--to",
@ -931,7 +972,7 @@ def present(
start_paused: bool, start_paused: bool,
fullscreen: bool, fullscreen: bool,
skip_all: bool, skip_all: bool,
resolution: Tuple[int, int], resolution: Optional[Tuple[int, int]],
record_to: Optional[Path], record_to: Optional[Path],
exit_after_last_slide: bool, exit_after_last_slide: bool,
hide_mouse: bool, hide_mouse: bool,
@ -956,9 +997,15 @@ def present(
if skip_all: if skip_all:
exit_after_last_slide = True exit_after_last_slide = True
presentation_configs = get_scenes_presentation_config(scenes, folder)
if resolution is not None:
for presentation_config in presentation_configs:
presentation_config.resolution = resolution
presentations = [ presentations = [
Presentation(presentation_config) Presentation(presentation_config)
for presentation_config in get_scenes_presentation_config(scenes, folder) for presentation_config in presentation_configs
] ]
if config_path.exists(): if config_path.exists():
@ -994,7 +1041,6 @@ def present(
start_paused=start_paused, start_paused=start_paused,
fullscreen=fullscreen, fullscreen=fullscreen,
skip_all=skip_all, skip_all=skip_all,
resolution=resolution,
record_to=record_to, record_to=record_to,
exit_after_last_slide=exit_after_last_slide, exit_after_last_slide=exit_after_last_slide,
hide_mouse=hide_mouse, hide_mouse=hide_mouse,

View File

@ -2,7 +2,7 @@ import os
import platform import platform
import shutil import shutil
import subprocess import subprocess
from typing import Any, List, Optional from typing import Any, List, Optional, Tuple
from warnings import warn from warnings import warn
from tqdm import tqdm from tqdm import tqdm
@ -54,6 +54,14 @@ class Slide(Scene): # type:ignore
self.__loop_start_animation: Optional[int] = None self.__loop_start_animation: Optional[int] = None
self.__pause_start_animation = 0 self.__pause_start_animation = 0
@property
def __resolution(self) -> Tuple[int, int]:
"""Returns the scene's resolution used during rendering."""
if MANIMGL:
return self.camera_config["pixel_width"], self.camera_config["pixel_height"]
else:
return config["pixel_width"], config["pixel_height"]
@property @property
def __partial_movie_files(self) -> List[str]: def __partial_movie_files(self) -> List[str]:
"""Returns a list of partial movie files, a.k.a animations.""" """Returns a list of partial movie files, a.k.a animations."""
@ -64,11 +72,11 @@ class Slide(Scene): # type:ignore
"remove_non_integer_files": True, "remove_non_integer_files": True,
"extension": self.file_writer.movie_file_extension, "extension": self.file_writer.movie_file_extension,
} }
return get_sorted_integer_files( return get_sorted_integer_files( # type: ignore
self.file_writer.partial_movie_directory, **kwargs self.file_writer.partial_movie_directory, **kwargs
) )
else: else:
return self.renderer.file_writer.partial_movie_files return self.renderer.file_writer.partial_movie_files # type: ignore
@property @property
def __show_progress_bar(self) -> bool: def __show_progress_bar(self) -> bool:
@ -76,7 +84,7 @@ class Slide(Scene): # type:ignore
if MANIMGL: if MANIMGL:
return getattr(self, "show_progress_bar", True) return getattr(self, "show_progress_bar", True)
else: else:
return config["progress_bar"] != "none" return config["progress_bar"] != "none" # type: ignore
@property @property
def __leave_progress_bar(self) -> bool: def __leave_progress_bar(self) -> bool:
@ -84,21 +92,21 @@ class Slide(Scene): # type:ignore
if MANIMGL: if MANIMGL:
return getattr(self, "leave_progress_bars", False) return getattr(self, "leave_progress_bars", False)
else: else:
return config["progress_bar"] == "leave" return config["progress_bar"] == "leave" # type: ignore
@property @property
def __start_at_animation_number(self) -> Optional[int]: def __start_at_animation_number(self) -> Optional[int]:
if MANIMGL: if MANIMGL:
return getattr(self, "start_at_animation_number", None) return getattr(self, "start_at_animation_number", None)
else: else:
return config["from_animation_number"] return config["from_animation_number"] # type: ignore
def play(self, *args: Any, **kwargs: Any) -> 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
def next_slide(self): def next_slide(self) -> None:
""" """
Creates a new slide with previous animations. Creates a new slide with previous animations.
@ -312,7 +320,9 @@ class Slide(Scene): # type:ignore
with open(slide_path, "w") as f: with open(slide_path, "w") as f:
f.write( f.write(
PresentationConfig(slides=self.__slides, files=files).json(indent=2) PresentationConfig(
slides=self.__slides, files=files, resolution=self.__resolution
).json(indent=2)
) )
logger.info( logger.info(

View File

@ -10,28 +10,10 @@ profile = "black"
py_version = 38 py_version = 38
[tool.mypy] [tool.mypy]
check-untyped-defs = true disallow_untyped_decorators = false
# Disallow dynamic typing install_types = true
disallow-any-generics = true
disallow-incomplete-defs = true
disallow-subclassing-any = true
# Disallow untyped definitions and calls
disallow-untyped-defs = true
ignore-missing-imports = true
install-types = true
# None and optional handling
no-implicit-optional = true
no-warn-return-any = true
non-interactive = true
python_version = "3.8" python_version = "3.8"
# Strict equality strict = true
strict-equality = true
warn-no-return = true
warn-redundant-casts = true
# Config file
warn-unused-configs = true
# Configuring warnings
warn-unused-ignores = true
[tool.poetry] [tool.poetry]
authors = [ authors = [