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
from enum import Enum
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 .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}")
dirname = files[0].parent
dirname: Path = files[0].parent
ext = files[0].suffix
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
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
return dirname / (basename + ext)
return dirname.joinpath(basename + ext)
class Key(BaseModel): # type: ignore
@ -149,13 +149,14 @@ class SlideConfig(BaseModel): # type: ignore
class PresentationConfig(BaseModel): # type: ignore
slides: List[SlideConfig]
files: List[FilePath]
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
@root_validator
def animation_indices_match_files(
cls, values: Dict[str, Union[List[SlideConfig], List[FilePath]]]
) -> Dict[str, Union[List[SlideConfig], List[FilePath]]]:
files = values.get("files")
slides = values.get("slides")
files: List[FilePath] = values.get("files") # type: ignore
slides: List[SlideConfig] = values.get("slides") # type: ignore
if files is None or slides is None:
return values
@ -163,7 +164,7 @@ class PresentationConfig(BaseModel): # type: ignore
n_files = len(files)
for slide in slides:
if slide.end_animation > n_files: # type: ignore
if slide.end_animation > n_files:
raise ValueError(
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
def open_with_default(file: Path):
def open_with_default(file: Path) -> None:
system = platform.system()
if system == "Darwin":
subprocess.call(("open", str(file)))
@ -66,7 +66,7 @@ class Converter(BaseModel): # type: ignore
An empty string is returned if no template is used."""
return ""
def open(self, file: Path) -> bool:
def open(self, file: Path) -> Any:
"""Opens a file, generated with converter, using appropriate application."""
raise NotImplementedError
@ -376,7 +376,7 @@ class PowerPoint(Converter):
use_enum_values = True
extra = "forbid"
def open(self, file: Path) -> bool:
def open(self, file: Path) -> None:
return open_with_default(file)
def convert_to(self, dest: Path) -> None:
@ -389,7 +389,9 @@ class PowerPoint(Converter):
# From GitHub issue comment:
# - 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_cnt = xpath(
media.element.getparent().getparent().getparent(),
@ -463,7 +465,7 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
ctx.exit()
return click.option(
return click.option( # type: ignore
"--show-config",
is_flag=True,
help="Show supported options for given format and exit.",
@ -491,7 +493,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
ctx.exit()
return click.option(
return click.option( # type: ignore
"--show-template",
is_flag=True,
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 cv2
import numpy as np
from click import Context, Parameter
from pydantic import ValidationError
from PySide6.QtCore import Qt, QThread, Signal, Slot
from PySide6.QtGui import QCloseEvent, QIcon, QImage, QKeyEvent, QPixmap, QResizeEvent
@ -70,8 +71,7 @@ class Presentation:
"""Creates presentation from a configuration object."""
def __init__(self, config: PresentationConfig) -> None:
self.slides: List[SlideConfig] = config.slides
self.files: List[str] = config.files
self.config = config
self.__current_slide_index: int = 0
self.current_animation: int = self.current_slide.start_animation
@ -90,23 +90,41 @@ class Presentation:
def __len__(self) -> int:
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
def current_slide_index(self) -> int:
return self.__current_slide_index
@current_slide_index.setter
def current_slide_index(self, value: Optional[int]):
if value:
def current_slide_index(self, value: Optional[int]) -> None:
if value is not None:
if -len(self) <= value < len(self):
self.__current_slide_index = value
self.current_animation = self.current_slide.start_animation
logger.debug(f"Set current slide index to {value}")
else:
logger.error(
f"Could not load slide number {value}, playing first slide instead."
)
def set_current_animation_and_update_slide_number(self, value: Optional[int]):
if value:
def set_current_animation_and_update_slide_number(
self, value: Optional[int]
) -> None:
if value is not None:
n_files = len(self.files)
if -n_files <= value < n_files:
if value < 0:
@ -116,6 +134,8 @@ class Presentation:
if value < slide.end_animation:
self.current_slide_index = i
self.current_animation = value
logger.debug(f"Playing animation {value}, at slide index {i}")
return
assert (
@ -159,7 +179,7 @@ class Presentation:
self.release_cap()
file: str = self.files[animation]
file: str = str(self.files[animation])
if self.reverse:
file = "{}_reversed{}".format(*os.path.splitext(file))
@ -178,7 +198,7 @@ class Presentation:
def rewind_current_slide(self) -> None:
"""Rewinds current slide to first frame."""
logger.debug("Rewinding curring slide")
logger.debug("Rewinding current slide")
if self.reverse:
self.current_animation = self.current_slide.end_animation - 1
else:
@ -216,9 +236,10 @@ class Presentation:
def load_previous_slide(self) -> None:
"""Loads previous slide."""
logger.debug("Loading previous slide")
logger.debug(f"Loading previous slide, current is {self.current_slide_index}")
self.cancel_reverse()
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()
@property
@ -229,7 +250,8 @@ class Presentation:
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
# TODO: understand why we sometimes get 0 fps
return max(fps, 1) # type: ignore
def reset(self) -> None:
"""Rests current presentation."""
@ -320,6 +342,7 @@ class Display(QThread): # type: ignore
change_video_signal = Signal(np.ndarray)
change_info_signal = Signal(dict)
change_presentation_sigal = Signal()
finished = Signal()
def __init__(
@ -365,10 +388,12 @@ class Display(QThread): # type: ignore
return self.__current_presentation_index
@current_presentation_index.setter
def current_presentation_index(self, value: Optional[int]):
if value:
def current_presentation_index(self, value: Optional[int]) -> None:
if value is not None:
if -len(self) <= value < len(self):
self.__current_presentation_index = value
self.current_presentation.release_cap()
self.change_presentation_sigal.emit()
else:
logger.error(
f"Could not load scene number {value}, playing first scene instead."
@ -379,6 +404,11 @@ class Display(QThread): # type: ignore
"""Returns the current presentation."""
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:
"""Runs a series of presentations until end or exit."""
while self.run_flag:
@ -413,7 +443,7 @@ class Display(QThread): # type: ignore
if self.record_to is not None:
self.record_movie()
logger.debug("Closing video thread gracully and exiting")
logger.debug("Closing video thread gracefully and exiting")
self.finished.emit()
def record_movie(self) -> None:
@ -587,7 +617,6 @@ class App(QWidget): # type: ignore
*args: Any,
config: Config = DEFAULT_CONFIG,
fullscreen: bool = False,
resolution: Tuple[int, int] = (1980, 1080),
hide_mouse: bool = False,
aspect_ratio: AspectRatio = AspectRatio.auto,
resize_mode: Qt.TransformationMode = Qt.SmoothTransformation,
@ -599,7 +628,12 @@ class App(QWidget): # type: ignore
self.setWindowTitle(WINDOW_NAME)
self.icon = QIcon(":/icon.png")
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.resize_mode = resize_mode
self.hide_mouse = hide_mouse
@ -619,9 +653,6 @@ class App(QWidget): # type: ignore
self.label.setPixmap(self.pixmap)
self.label.setMinimumSize(1, 1)
# create the video capture thread
kwargs["config"] = config
self.thread = Display(*args, **kwargs)
# create the info dialog
self.info = Info()
self.info.show()
@ -635,6 +666,7 @@ class App(QWidget): # type: ignore
# connect signals
self.thread.change_video_signal.connect(self.update_image)
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.send_key_signal.connect(self.thread.set_key)
@ -688,6 +720,14 @@ class App(QWidget): # type: ignore
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.option(
@ -757,7 +797,7 @@ def prompt_for_scenes(folder: Path) -> List[str]:
while True:
try:
scenes = click.prompt("Choice(s)", value_proc=value_proc)
return scenes
return scenes # type: ignore
except ValueError as e:
raise click.UsageError(str(e))
@ -785,7 +825,9 @@ def get_scenes_presentation_config(
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)":
return (None, None, None)
@ -838,9 +880,8 @@ def start_at_callback(ctx, param, values: str) -> Tuple[Optional[int], ...]:
"--resolution",
metavar="<WIDTH HEIGHT>",
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.",
show_default=True,
)
@click.option(
"--to",
@ -931,7 +972,7 @@ def present(
start_paused: bool,
fullscreen: bool,
skip_all: bool,
resolution: Tuple[int, int],
resolution: Optional[Tuple[int, int]],
record_to: Optional[Path],
exit_after_last_slide: bool,
hide_mouse: bool,
@ -956,9 +997,15 @@ def present(
if skip_all:
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 = [
Presentation(presentation_config)
for presentation_config in get_scenes_presentation_config(scenes, folder)
for presentation_config in presentation_configs
]
if config_path.exists():
@ -994,7 +1041,6 @@ def present(
start_paused=start_paused,
fullscreen=fullscreen,
skip_all=skip_all,
resolution=resolution,
record_to=record_to,
exit_after_last_slide=exit_after_last_slide,
hide_mouse=hide_mouse,

View File

@ -2,7 +2,7 @@ import os
import platform
import shutil
import subprocess
from typing import Any, List, Optional
from typing import Any, List, Optional, Tuple
from warnings import warn
from tqdm import tqdm
@ -54,6 +54,14 @@ class Slide(Scene): # type:ignore
self.__loop_start_animation: Optional[int] = None
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
def __partial_movie_files(self) -> List[str]:
"""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,
"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
)
else:
return self.renderer.file_writer.partial_movie_files
return self.renderer.file_writer.partial_movie_files # type: ignore
@property
def __show_progress_bar(self) -> bool:
@ -76,7 +84,7 @@ class Slide(Scene): # type:ignore
if MANIMGL:
return getattr(self, "show_progress_bar", True)
else:
return config["progress_bar"] != "none"
return config["progress_bar"] != "none" # type: ignore
@property
def __leave_progress_bar(self) -> bool:
@ -84,21 +92,21 @@ class Slide(Scene): # type:ignore
if MANIMGL:
return getattr(self, "leave_progress_bars", False)
else:
return config["progress_bar"] == "leave"
return config["progress_bar"] == "leave" # type: ignore
@property
def __start_at_animation_number(self) -> Optional[int]:
if MANIMGL:
return getattr(self, "start_at_animation_number", None)
else:
return config["from_animation_number"]
return config["from_animation_number"] # type: ignore
def play(self, *args: Any, **kwargs: Any) -> None:
"""Overloads `self.play` and increment animation count."""
super().play(*args, **kwargs)
self.__current_animation += 1
def next_slide(self):
def next_slide(self) -> None:
"""
Creates a new slide with previous animations.
@ -312,7 +320,9 @@ class Slide(Scene): # type:ignore
with open(slide_path, "w") as f:
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(