From a3228db025982fa017f29562d0a4cb2a0855c642 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Mon, 10 Apr 2023 18:55:15 -0600 Subject: [PATCH 01/44] Set series ID's in sorted order Set series database ID's based on the series episode data source. This should reduce false matches on media servers with a lot of similar-named content --- modules/Show.py | 54 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/modules/Show.py b/modules/Show.py index c0a60898..3ec6cb7e 100755 --- a/modules/Show.py +++ b/modules/Show.py @@ -406,26 +406,42 @@ def assign_interfaces(self, emby_interface: 'EmbyInterface' = None, def set_series_ids(self) -> None: """Set the series ID's for this show.""" - if self.emby_interface: - self.emby_interface.set_series_ids( - self.library_name, self.series_info - ) - - if self.jellyfin_interface: - self.jellyfin_interface.set_series_ids( - self.library_name, self.series_info - ) - - if self.plex_interface: - self.plex_interface.set_series_ids( - self.library_name, self.series_info - ) + # Temporary function to set series ID's + def set_emby(infos): + if self.emby_interface: + self.emby_interface.set_series_ids( + self.library_name, self.series_info + ) + def set_jellyfin(infos): + if self.jellyfin_interface: + self.jellyfin_interface.set_series_ids( + self.library_name, self.series_info + ) + def set_plex(infos): + if self.plex_interface: + self.plex_interface.set_series_ids( + self.library_name, self.series_info + ) + def set_sonarr(infos): + if self.sonarr_interface: + self.sonarr_interface.set_series_ids(self.series_info) + def set_tmdb(infos): + if self.tmdb_interface: + self.tmdb_interface.set_series_ids(self.series_info) - if self.sonarr_interface: - self.sonarr_interface.set_series_ids(self.series_info) + # Identify interface order for ID gathering based on primary episode + # data source + interface_orders = { + 'emby': [set_emby, set_sonarr, set_tmdb, set_plex, set_jellyfin], + 'jellyfin': [set_jellyfin, set_sonarr, set_tmdb, set_plex, set_emby], + 'sonarr': [set_sonarr, set_plex, set_emby, set_jellyfin, set_tmdb], + 'plex': [set_plex, set_sonarr, set_tmdb, set_emby, set_jellyfin], + 'tmdb': [set_tmdb, set_sonarr, set_plex, set_emby, set_jellyfin], + } - if self.tmdb_interface: - self.tmdb_interface.set_series_ids(self.series_info) + # Go through each interface and load ID's from it + for interface_function in interface_orders[self.episode_data_source]: + interface_function(infos) def __get_destination(self, episode_info: 'EpisodeInfo') -> Path: @@ -587,7 +603,7 @@ def load_tmdb(infos): # data source interface_orders = { 'emby': [load_emby, load_sonarr, load_tmdb, load_plex, load_jellyfin], - 'jellyfin': [load_jellyfin, load_sonarr, load_tmdb, load_plex, load_jellyfin], + 'jellyfin': [load_jellyfin, load_sonarr, load_tmdb, load_plex, load_emby], 'sonarr': [load_sonarr, load_plex, load_emby, load_jellyfin, load_tmdb], 'plex': [load_plex, load_sonarr, load_tmdb, load_emby, load_jellyfin], 'tmdb': [load_tmdb, load_sonarr, load_plex, load_emby, load_jellyfin], From 6db02eabe151b74d3b4a6cb6966999f706539ea1 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Mon, 10 Apr 2023 19:02:34 -0600 Subject: [PATCH 02/44] Remove reference to infos object --- modules/Show.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/Show.py b/modules/Show.py index 3ec6cb7e..3f21bb21 100755 --- a/modules/Show.py +++ b/modules/Show.py @@ -407,25 +407,25 @@ def set_series_ids(self) -> None: """Set the series ID's for this show.""" # Temporary function to set series ID's - def set_emby(infos): + def set_emby(): if self.emby_interface: self.emby_interface.set_series_ids( self.library_name, self.series_info ) - def set_jellyfin(infos): + def set_jellyfin(): if self.jellyfin_interface: self.jellyfin_interface.set_series_ids( self.library_name, self.series_info ) - def set_plex(infos): + def set_plex(): if self.plex_interface: self.plex_interface.set_series_ids( self.library_name, self.series_info ) - def set_sonarr(infos): + def set_sonarr(): if self.sonarr_interface: self.sonarr_interface.set_series_ids(self.series_info) - def set_tmdb(infos): + def set_tmdb(): if self.tmdb_interface: self.tmdb_interface.set_series_ids(self.series_info) @@ -441,7 +441,7 @@ def set_tmdb(infos): # Go through each interface and load ID's from it for interface_function in interface_orders[self.episode_data_source]: - interface_function(infos) + interface_function() def __get_destination(self, episode_info: 'EpisodeInfo') -> Path: From 184f5067dc5ac090e4fd458c3468614553efcfe6 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Mon, 10 Apr 2023 19:46:03 -0600 Subject: [PATCH 03/44] Require year match for full name Plex search --- modules/PlexInterface.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/PlexInterface.py b/modules/PlexInterface.py index 2969e94d..d1131ec1 100755 --- a/modules/PlexInterface.py +++ b/modules/PlexInterface.py @@ -178,7 +178,10 @@ def __get_series(self, library: 'Library', # Try by full name try: - return library.get(series_info.full_name) + series = library.get(series_info.full_name) + if series.year == series_info.year: + return series + raise NotFound except NotFound: pass From ba6a3031fecfe4f2bad5b2b912a48605c4018d85 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Tue, 11 Apr 2023 20:17:13 -0600 Subject: [PATCH 04/44] Allow background images in LogoTitleCard Implements #325 --- modules/cards/LogoTitleCard.py | 106 ++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 27 deletions(-) diff --git a/modules/cards/LogoTitleCard.py b/modules/cards/LogoTitleCard.py index fee28194..90848ac1 100755 --- a/modules/cards/LogoTitleCard.py +++ b/modules/cards/LogoTitleCard.py @@ -85,25 +85,34 @@ class LogoTitleCard(BaseCardType): 'font', 'font_size', 'title_color', 'hide_season', 'separator', 'blur', 'vertical_shift', 'interline_spacing', 'kerning', 'stroke_width', 'logo', 'omit_gradient', 'background', 'stroke_color', + 'use_background_image', 'blur_only_image', ) - def __init__(self, output_file: Path, title: str, season_text: str, - episode_text: str, hide_season: bool, font: str, - title_color: str, - font_size: float=1.0, - kerning: float=1.0, - interline_spacing: int=0, - stroke_width: float=1.0, - vertical_shift: int=0, - season_number: int=1, - episode_number: int=1, - blur: bool=False, - grayscale: bool=False, - logo: SeriesExtra[str]=None, - separator: SeriesExtra[str]='•', - background: SeriesExtra[str]='black', - stroke_color: SeriesExtra[str]='black', - omit_gradient: SeriesExtra[bool]=True, + def __init__(self, + output_file: Path, + title: str, + season_text: str, + episode_text: str, + source: Optional[Path] = None, + hide_season: bool = False, + font: str = TITLE_FONT, + title_color: str = TITLE_COLOR, + font_size: float = 1.0, + kerning: float = 1.0, + interline_spacing: int = 0, + stroke_width: float = 1.0, + vertical_shift: int = 0, + season_number: int = 1, + episode_number: int = 1, + blur: bool = False, + grayscale: bool = False, + logo: SeriesExtra[str] = None, + background: SeriesExtra[str] = 'black', + separator: SeriesExtra[str] = '•', + stroke_color: SeriesExtra[str] = 'black', + omit_gradient: SeriesExtra[bool] = True, + use_background_image: SeriesExtra[bool] = False, + blur_only_image: SeriesExtra[bool] = False, **unused) -> None: """ Construct a new instance of this card. @@ -128,10 +137,14 @@ def __init__(self, output_file: Path, title: str, season_text: str, blur: Whether to blur the source image. grayscale: Whether to make the source image grayscale. logo: Filepath (or file format) to the logo file. - separator: Character to use to separate season/episode text. background: Backround color. - omit_gradient: Whether to omit the gradient overlay. + separator: Character to use to separate season/episode text. stroke_color: Color to use for the back-stroke color. + omit_gradient: Whether to omit the gradient overlay. + use_background_image: Whether to use a background image + instead of a solid background color. + blur_only_image: Whether the blur attribute applies to the + source image _only_, or the logo as well. unused: Unused arguments. """ @@ -150,18 +163,27 @@ def __init__(self, output_file: Path, title: str, season_text: str, self.valid = False log.exception(f'Invalid logo file "{logo}"', e) + # Get source file if indicated + self.use_background_image = use_background_image + self.blur_only_image = blur_only_image + self.source_file = source + if self.use_background_image and self.source_file is None: + log.error(f'Source file must be provided if using a background' + f'image') + self.valid = False + self.output_file = output_file # Ensure characters that need to be escaped are self.title = self.image_magick.escape_chars(title) self.season_text = self.image_magick.escape_chars(season_text.upper()) self.episode_text = self.image_magick.escape_chars(episode_text.upper()) + self.hide_season = hide_season # Font attributes self.font = font self.font_size = font_size self.title_color = title_color - self.hide_season = hide_season self.vertical_shift = vertical_shift self.interline_spacing = interline_spacing self.kerning = kerning @@ -319,6 +341,11 @@ def create(self) -> None: log.error(f'Logo file "{self.logo.resolve()}" does not exist') return None + # Skip if source is indicated and does not exist + if self.use_background_image and not self.source_file.exists(): + log.warning(f'Source "{self.source_file.resolve()}" does not exist') + return None + # Resize logo, get resized height to determine offset resized_logo = self.resize_logo() _, height = self.get_image_dimensions(resized_logo) @@ -331,6 +358,23 @@ def create(self) -> None: kerning = -1.25 * self.kerning stroke_width = 3.0 * self.stroke_width + # Sub-command to add source file or create colored background + if self.use_background_image: + blur_command = '' + if self.blur and self.blur_only_image: + blur_command = f'-blur {self.BLUR_PROFILE}' + background_command = [ + f'"{self.source_file.resolve()}"', + *self.resize, + blur_command, + ] + else: + background_command = [ + f'-set colorspace sRGB', + f'-size "{self.TITLE_CARD_SIZE}"', + f'xc:"{self.background}"', + ] + # Sub-command to optionally add gradient gradient_command = [] if not self.omit_gradient: @@ -339,21 +383,29 @@ def create(self) -> None: f'-composite', ] + # Sub-command to style the overall image if indicated + style_command = [] + if self.blur_only_image and self.grayscale: + style_command = [ + f'-colorspace gray', + f'-set colorspace sRGB', + ] + elif not self.blur_only_image: + style_command = self.style + command = ' '.join([ f'convert', - f'-set colorspace sRGB', - # Crate canvas of static background color - f'-size "{self.TITLE_CARD_SIZE}"', - f'xc:"{self.background}"', + # Add background image or color + *background_command, # Overlay resized logo f'"{resized_logo.resolve()}"', f'-gravity north', f'-geometry "+0+{offset}"', f'-composite', - # Optionally overlay logo + # Optionally overlay gradient *gradient_command, - # Resize and optionally blur source image - *self.resize_and_style, + # Apply style that is applicable to entire image + *style_command, # Global title text options f'-gravity south', f'-font "{self.font}"', From 26ced5aae8bb9ad6a02c1397183667ef8f045718 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Tue, 11 Apr 2023 20:27:23 -0600 Subject: [PATCH 05/44] Add style only command set to BaseCardType --- modules/BaseCardType.py | 42 ++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/modules/BaseCardType.py b/modules/BaseCardType.py index dcfa6ad5..02628186 100755 --- a/modules/BaseCardType.py +++ b/modules/BaseCardType.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import Any, Optional +from typing import Any, Optional, Union from titlecase import titlecase @@ -51,7 +51,7 @@ class BaseCardType(ImageMaker): @property @abstractmethod - def TITLE_CHARACTERISTICS(self) -> dict[str, 'int | bool']: + def TITLE_CHARACTERISTICS(self) -> dict[str, Union[int, bool]]: """ Characteristics of title splitting for this card type. Must have keys for max_line_width, max_line_count, and top_heavy. See @@ -180,13 +180,9 @@ def is_custom_season_titles() -> bool: @property - def resize_and_style(self) -> ImageMagickCommands: + def resize(self) -> ImageMagickCommands: """ - ImageMagick commands to resize and apply any style modifiers to - an image. - Returns: - List of ImageMagick commands. """ return [ @@ -200,19 +196,37 @@ def resize_and_style(self) -> ImageMagickCommands: # Fit to title card size f'-resize "{self.TITLE_CARD_SIZE}^"', f'-extent "{self.TITLE_CARD_SIZE}"', + ] + + + @property + def style(self) -> ImageMagickCommands: + """ + ImageMagick commands to apply any style modifiers to an image. + + Returns: + List of ImageMagick commands. + """ + + return [ + # Full sRGB colorspace on source image + f'-set colorspace sRGB', + # Ignore profile conversion warnings + f'+profile "*"', # Optionally blur f'-blur {self.BLUR_PROFILE}' if self.blur else '', # Optionally set gray colorspace f'-colorspace gray' if self.grayscale else '', # Reset to full colorspace - f'-set colorspace sRGB', + f'-set colorspace sRGB' if self.grayscale else '', ] @property - def style(self) -> ImageMagickCommands: + def resize_and_style(self) -> ImageMagickCommands: """ - ImageMagick commands to apply any style modifiers to an image. + ImageMagick commands to resize and apply any style modifiers to + an image. Returns: List of ImageMagick commands. @@ -223,12 +237,18 @@ def style(self) -> ImageMagickCommands: f'-set colorspace sRGB', # Ignore profile conversion warnings f'+profile "*"', + # Background resize shouldn't fill with any color + f'-background transparent', + f'-gravity center', + # Fit to title card size + f'-resize "{self.TITLE_CARD_SIZE}^"', + f'-extent "{self.TITLE_CARD_SIZE}"', # Optionally blur f'-blur {self.BLUR_PROFILE}' if self.blur else '', # Optionally set gray colorspace f'-colorspace gray' if self.grayscale else '', # Reset to full colorspace - f'-set colorspace sRGB' if self.grayscale else '', + f'-set colorspace sRGB', ] From ec929b533f9a7a3ebbd3d1dbc6be05fd4fb9b217 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Tue, 11 Apr 2023 20:28:53 -0600 Subject: [PATCH 06/44] Revert "Allow background images in LogoTitleCard" This reverts commit ba6a3031fecfe4f2bad5b2b912a48605c4018d85. --- modules/cards/LogoTitleCard.py | 106 +++++++++------------------------ 1 file changed, 27 insertions(+), 79 deletions(-) diff --git a/modules/cards/LogoTitleCard.py b/modules/cards/LogoTitleCard.py index 90848ac1..fee28194 100755 --- a/modules/cards/LogoTitleCard.py +++ b/modules/cards/LogoTitleCard.py @@ -85,34 +85,25 @@ class LogoTitleCard(BaseCardType): 'font', 'font_size', 'title_color', 'hide_season', 'separator', 'blur', 'vertical_shift', 'interline_spacing', 'kerning', 'stroke_width', 'logo', 'omit_gradient', 'background', 'stroke_color', - 'use_background_image', 'blur_only_image', ) - def __init__(self, - output_file: Path, - title: str, - season_text: str, - episode_text: str, - source: Optional[Path] = None, - hide_season: bool = False, - font: str = TITLE_FONT, - title_color: str = TITLE_COLOR, - font_size: float = 1.0, - kerning: float = 1.0, - interline_spacing: int = 0, - stroke_width: float = 1.0, - vertical_shift: int = 0, - season_number: int = 1, - episode_number: int = 1, - blur: bool = False, - grayscale: bool = False, - logo: SeriesExtra[str] = None, - background: SeriesExtra[str] = 'black', - separator: SeriesExtra[str] = '•', - stroke_color: SeriesExtra[str] = 'black', - omit_gradient: SeriesExtra[bool] = True, - use_background_image: SeriesExtra[bool] = False, - blur_only_image: SeriesExtra[bool] = False, + def __init__(self, output_file: Path, title: str, season_text: str, + episode_text: str, hide_season: bool, font: str, + title_color: str, + font_size: float=1.0, + kerning: float=1.0, + interline_spacing: int=0, + stroke_width: float=1.0, + vertical_shift: int=0, + season_number: int=1, + episode_number: int=1, + blur: bool=False, + grayscale: bool=False, + logo: SeriesExtra[str]=None, + separator: SeriesExtra[str]='•', + background: SeriesExtra[str]='black', + stroke_color: SeriesExtra[str]='black', + omit_gradient: SeriesExtra[bool]=True, **unused) -> None: """ Construct a new instance of this card. @@ -137,14 +128,10 @@ def __init__(self, blur: Whether to blur the source image. grayscale: Whether to make the source image grayscale. logo: Filepath (or file format) to the logo file. - background: Backround color. separator: Character to use to separate season/episode text. - stroke_color: Color to use for the back-stroke color. + background: Backround color. omit_gradient: Whether to omit the gradient overlay. - use_background_image: Whether to use a background image - instead of a solid background color. - blur_only_image: Whether the blur attribute applies to the - source image _only_, or the logo as well. + stroke_color: Color to use for the back-stroke color. unused: Unused arguments. """ @@ -163,27 +150,18 @@ def __init__(self, self.valid = False log.exception(f'Invalid logo file "{logo}"', e) - # Get source file if indicated - self.use_background_image = use_background_image - self.blur_only_image = blur_only_image - self.source_file = source - if self.use_background_image and self.source_file is None: - log.error(f'Source file must be provided if using a background' - f'image') - self.valid = False - self.output_file = output_file # Ensure characters that need to be escaped are self.title = self.image_magick.escape_chars(title) self.season_text = self.image_magick.escape_chars(season_text.upper()) self.episode_text = self.image_magick.escape_chars(episode_text.upper()) - self.hide_season = hide_season # Font attributes self.font = font self.font_size = font_size self.title_color = title_color + self.hide_season = hide_season self.vertical_shift = vertical_shift self.interline_spacing = interline_spacing self.kerning = kerning @@ -341,11 +319,6 @@ def create(self) -> None: log.error(f'Logo file "{self.logo.resolve()}" does not exist') return None - # Skip if source is indicated and does not exist - if self.use_background_image and not self.source_file.exists(): - log.warning(f'Source "{self.source_file.resolve()}" does not exist') - return None - # Resize logo, get resized height to determine offset resized_logo = self.resize_logo() _, height = self.get_image_dimensions(resized_logo) @@ -358,23 +331,6 @@ def create(self) -> None: kerning = -1.25 * self.kerning stroke_width = 3.0 * self.stroke_width - # Sub-command to add source file or create colored background - if self.use_background_image: - blur_command = '' - if self.blur and self.blur_only_image: - blur_command = f'-blur {self.BLUR_PROFILE}' - background_command = [ - f'"{self.source_file.resolve()}"', - *self.resize, - blur_command, - ] - else: - background_command = [ - f'-set colorspace sRGB', - f'-size "{self.TITLE_CARD_SIZE}"', - f'xc:"{self.background}"', - ] - # Sub-command to optionally add gradient gradient_command = [] if not self.omit_gradient: @@ -383,29 +339,21 @@ def create(self) -> None: f'-composite', ] - # Sub-command to style the overall image if indicated - style_command = [] - if self.blur_only_image and self.grayscale: - style_command = [ - f'-colorspace gray', - f'-set colorspace sRGB', - ] - elif not self.blur_only_image: - style_command = self.style - command = ' '.join([ f'convert', - # Add background image or color - *background_command, + f'-set colorspace sRGB', + # Crate canvas of static background color + f'-size "{self.TITLE_CARD_SIZE}"', + f'xc:"{self.background}"', # Overlay resized logo f'"{resized_logo.resolve()}"', f'-gravity north', f'-geometry "+0+{offset}"', f'-composite', - # Optionally overlay gradient + # Optionally overlay logo *gradient_command, - # Apply style that is applicable to entire image - *style_command, + # Resize and optionally blur source image + *self.resize_and_style, # Global title text options f'-gravity south', f'-font "{self.font}"', From 8e7de59ae986a570bf7e6f4633f1d94113723a92 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Tue, 11 Apr 2023 20:27:23 -0600 Subject: [PATCH 07/44] Add style only command set to BaseCardType --- modules/BaseCardType.py | 42 ++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/modules/BaseCardType.py b/modules/BaseCardType.py index dcfa6ad5..02628186 100755 --- a/modules/BaseCardType.py +++ b/modules/BaseCardType.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import Any, Optional +from typing import Any, Optional, Union from titlecase import titlecase @@ -51,7 +51,7 @@ class BaseCardType(ImageMaker): @property @abstractmethod - def TITLE_CHARACTERISTICS(self) -> dict[str, 'int | bool']: + def TITLE_CHARACTERISTICS(self) -> dict[str, Union[int, bool]]: """ Characteristics of title splitting for this card type. Must have keys for max_line_width, max_line_count, and top_heavy. See @@ -180,13 +180,9 @@ def is_custom_season_titles() -> bool: @property - def resize_and_style(self) -> ImageMagickCommands: + def resize(self) -> ImageMagickCommands: """ - ImageMagick commands to resize and apply any style modifiers to - an image. - Returns: - List of ImageMagick commands. """ return [ @@ -200,19 +196,37 @@ def resize_and_style(self) -> ImageMagickCommands: # Fit to title card size f'-resize "{self.TITLE_CARD_SIZE}^"', f'-extent "{self.TITLE_CARD_SIZE}"', + ] + + + @property + def style(self) -> ImageMagickCommands: + """ + ImageMagick commands to apply any style modifiers to an image. + + Returns: + List of ImageMagick commands. + """ + + return [ + # Full sRGB colorspace on source image + f'-set colorspace sRGB', + # Ignore profile conversion warnings + f'+profile "*"', # Optionally blur f'-blur {self.BLUR_PROFILE}' if self.blur else '', # Optionally set gray colorspace f'-colorspace gray' if self.grayscale else '', # Reset to full colorspace - f'-set colorspace sRGB', + f'-set colorspace sRGB' if self.grayscale else '', ] @property - def style(self) -> ImageMagickCommands: + def resize_and_style(self) -> ImageMagickCommands: """ - ImageMagick commands to apply any style modifiers to an image. + ImageMagick commands to resize and apply any style modifiers to + an image. Returns: List of ImageMagick commands. @@ -223,12 +237,18 @@ def style(self) -> ImageMagickCommands: f'-set colorspace sRGB', # Ignore profile conversion warnings f'+profile "*"', + # Background resize shouldn't fill with any color + f'-background transparent', + f'-gravity center', + # Fit to title card size + f'-resize "{self.TITLE_CARD_SIZE}^"', + f'-extent "{self.TITLE_CARD_SIZE}"', # Optionally blur f'-blur {self.BLUR_PROFILE}' if self.blur else '', # Optionally set gray colorspace f'-colorspace gray' if self.grayscale else '', # Reset to full colorspace - f'-set colorspace sRGB' if self.grayscale else '', + f'-set colorspace sRGB', ] From 7d595fcc78d32e4f8d27bd5172b67a013f973bb4 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Tue, 11 Apr 2023 20:45:36 -0600 Subject: [PATCH 08/44] Allow background images in LogoTitleCard Implements #325 --- modules/EmbyInterface.py | 7 ++- modules/Manager.py | 2 +- modules/SonarrInterface.py | 10 ++-- modules/cards/LogoTitleCard.py | 106 ++++++++++++++++++++++++--------- 4 files changed, 89 insertions(+), 36 deletions(-) diff --git a/modules/EmbyInterface.py b/modules/EmbyInterface.py index fcb10389..3b690651 100755 --- a/modules/EmbyInterface.py +++ b/modules/EmbyInterface.py @@ -234,7 +234,7 @@ def set_episode_ids(self, series_info: SeriesInfo, self.get_all_episodes(series_info) - def get_library_paths(self, filter_libraries: list[str]=[] + def get_library_paths(self, filter_libraries: list[str] = [] ) -> dict[str, list[str]]: """ Get all libraries and their associated base directories. @@ -267,8 +267,9 @@ def include_library(emby_library) -> bool: } - def get_all_series(self, filter_libraries: list[str]=[], - required_tags: list[str]=[]) -> list[tuple[SeriesInfo, str, str]]: + def get_all_series(self, + filter_libraries: list[str] = [], + required_tags: list[str] = []) -> list[tuple[SeriesInfo, str, str]]: """ Get all series within Emby, as filtered by the given libraries. diff --git a/modules/Manager.py b/modules/Manager.py index 4acb16c7..4f7b220a 100755 --- a/modules/Manager.py +++ b/modules/Manager.py @@ -368,7 +368,7 @@ def create_summaries(self) -> None: show_archive.create_summary() - def __run(self, *, serial: bool=False) -> None: + def __run(self, *, serial: bool = False) -> None: """ Run the Manager. If serial execution is not indicated, then sync is run and Show/ShowArchive objects are created. diff --git a/modules/SonarrInterface.py b/modules/SonarrInterface.py index 8e945cd6..1b482b23 100755 --- a/modules/SonarrInterface.py +++ b/modules/SonarrInterface.py @@ -180,11 +180,11 @@ def has_series(self, series_info: SeriesInfo) -> bool: def get_all_series(self, - required_tags: list[str]=[], - excluded_tags: list[str]=[], - monitored_only: bool=False, - downloaded_only: bool=False, - series_type: Optional[str]=None, + required_tags: list[str] = [], + excluded_tags: list[str] = [], + monitored_only: bool = False, + downloaded_only: bool = False, + series_type: Optional[str] = None, ) -> list[tuple[SeriesInfo, str]]: """ Get all the series within Sonarr, filtered by the given diff --git a/modules/cards/LogoTitleCard.py b/modules/cards/LogoTitleCard.py index fee28194..90848ac1 100755 --- a/modules/cards/LogoTitleCard.py +++ b/modules/cards/LogoTitleCard.py @@ -85,25 +85,34 @@ class LogoTitleCard(BaseCardType): 'font', 'font_size', 'title_color', 'hide_season', 'separator', 'blur', 'vertical_shift', 'interline_spacing', 'kerning', 'stroke_width', 'logo', 'omit_gradient', 'background', 'stroke_color', + 'use_background_image', 'blur_only_image', ) - def __init__(self, output_file: Path, title: str, season_text: str, - episode_text: str, hide_season: bool, font: str, - title_color: str, - font_size: float=1.0, - kerning: float=1.0, - interline_spacing: int=0, - stroke_width: float=1.0, - vertical_shift: int=0, - season_number: int=1, - episode_number: int=1, - blur: bool=False, - grayscale: bool=False, - logo: SeriesExtra[str]=None, - separator: SeriesExtra[str]='•', - background: SeriesExtra[str]='black', - stroke_color: SeriesExtra[str]='black', - omit_gradient: SeriesExtra[bool]=True, + def __init__(self, + output_file: Path, + title: str, + season_text: str, + episode_text: str, + source: Optional[Path] = None, + hide_season: bool = False, + font: str = TITLE_FONT, + title_color: str = TITLE_COLOR, + font_size: float = 1.0, + kerning: float = 1.0, + interline_spacing: int = 0, + stroke_width: float = 1.0, + vertical_shift: int = 0, + season_number: int = 1, + episode_number: int = 1, + blur: bool = False, + grayscale: bool = False, + logo: SeriesExtra[str] = None, + background: SeriesExtra[str] = 'black', + separator: SeriesExtra[str] = '•', + stroke_color: SeriesExtra[str] = 'black', + omit_gradient: SeriesExtra[bool] = True, + use_background_image: SeriesExtra[bool] = False, + blur_only_image: SeriesExtra[bool] = False, **unused) -> None: """ Construct a new instance of this card. @@ -128,10 +137,14 @@ def __init__(self, output_file: Path, title: str, season_text: str, blur: Whether to blur the source image. grayscale: Whether to make the source image grayscale. logo: Filepath (or file format) to the logo file. - separator: Character to use to separate season/episode text. background: Backround color. - omit_gradient: Whether to omit the gradient overlay. + separator: Character to use to separate season/episode text. stroke_color: Color to use for the back-stroke color. + omit_gradient: Whether to omit the gradient overlay. + use_background_image: Whether to use a background image + instead of a solid background color. + blur_only_image: Whether the blur attribute applies to the + source image _only_, or the logo as well. unused: Unused arguments. """ @@ -150,18 +163,27 @@ def __init__(self, output_file: Path, title: str, season_text: str, self.valid = False log.exception(f'Invalid logo file "{logo}"', e) + # Get source file if indicated + self.use_background_image = use_background_image + self.blur_only_image = blur_only_image + self.source_file = source + if self.use_background_image and self.source_file is None: + log.error(f'Source file must be provided if using a background' + f'image') + self.valid = False + self.output_file = output_file # Ensure characters that need to be escaped are self.title = self.image_magick.escape_chars(title) self.season_text = self.image_magick.escape_chars(season_text.upper()) self.episode_text = self.image_magick.escape_chars(episode_text.upper()) + self.hide_season = hide_season # Font attributes self.font = font self.font_size = font_size self.title_color = title_color - self.hide_season = hide_season self.vertical_shift = vertical_shift self.interline_spacing = interline_spacing self.kerning = kerning @@ -319,6 +341,11 @@ def create(self) -> None: log.error(f'Logo file "{self.logo.resolve()}" does not exist') return None + # Skip if source is indicated and does not exist + if self.use_background_image and not self.source_file.exists(): + log.warning(f'Source "{self.source_file.resolve()}" does not exist') + return None + # Resize logo, get resized height to determine offset resized_logo = self.resize_logo() _, height = self.get_image_dimensions(resized_logo) @@ -331,6 +358,23 @@ def create(self) -> None: kerning = -1.25 * self.kerning stroke_width = 3.0 * self.stroke_width + # Sub-command to add source file or create colored background + if self.use_background_image: + blur_command = '' + if self.blur and self.blur_only_image: + blur_command = f'-blur {self.BLUR_PROFILE}' + background_command = [ + f'"{self.source_file.resolve()}"', + *self.resize, + blur_command, + ] + else: + background_command = [ + f'-set colorspace sRGB', + f'-size "{self.TITLE_CARD_SIZE}"', + f'xc:"{self.background}"', + ] + # Sub-command to optionally add gradient gradient_command = [] if not self.omit_gradient: @@ -339,21 +383,29 @@ def create(self) -> None: f'-composite', ] + # Sub-command to style the overall image if indicated + style_command = [] + if self.blur_only_image and self.grayscale: + style_command = [ + f'-colorspace gray', + f'-set colorspace sRGB', + ] + elif not self.blur_only_image: + style_command = self.style + command = ' '.join([ f'convert', - f'-set colorspace sRGB', - # Crate canvas of static background color - f'-size "{self.TITLE_CARD_SIZE}"', - f'xc:"{self.background}"', + # Add background image or color + *background_command, # Overlay resized logo f'"{resized_logo.resolve()}"', f'-gravity north', f'-geometry "+0+{offset}"', f'-composite', - # Optionally overlay logo + # Optionally overlay gradient *gradient_command, - # Resize and optionally blur source image - *self.resize_and_style, + # Apply style that is applicable to entire image + *style_command, # Global title text options f'-gravity south', f'-font "{self.font}"', From fe71d30b425ba1346f4c9b52abe73b90079e1072 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Wed, 12 Apr 2023 12:52:06 -0600 Subject: [PATCH 09/44] Skip interface enable check in Manager No need to check for global interface enables in Manager before executing loop. --- modules/Manager.py | 37 ++++--------------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/modules/Manager.py b/modules/Manager.py index 4f7b220a..fc4151e4 100755 --- a/modules/Manager.py +++ b/modules/Manager.py @@ -182,8 +182,8 @@ def create_shows(self) -> None: def assign_interfaces(self) -> None: """Assign all interfaces to each Show known to this Manager""" - # Assign interfaces for each show - for show in tqdm(self.shows + self.archives,desc='Assigning interfaces', + for show in tqdm(self.shows + self.archives, + desc='Assigning interfaces', **TQDM_KWARGS): show.assign_interfaces( self.emby_interface, @@ -198,14 +198,8 @@ def assign_interfaces(self) -> None: def set_show_ids(self) -> None: """Set the series ID's of each Show known to this Manager""" - # If neither Sonarr nor TMDb are enabled, skip - if not self.preferences.use_sonarr and not self.preferences.use_tmdb: - return None - - # For each show in the Manager, set series IDs for show in tqdm(self.shows + self.archives, desc='Setting series IDs', **TQDM_KWARGS): - # Select interfaces based on what's enabled show.set_series_ids() @@ -217,7 +211,6 @@ def read_show_source(self) -> None: multipart episodes. """ - # Read source files for Show objects for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Reading source files for {show}') show.read_source() @@ -228,13 +221,6 @@ def read_show_source(self) -> None: def add_new_episodes(self) -> None: """Add any new episodes to this Manager's shows.""" - # If Sonarr, Plex, and TMDb are disabled, exit - if (not self.preferences.use_sonarr and not self.preferences.use_tmdb - and not self.preferences.use_plex): - return None - - # For each show in the Manager, look for new episodes using any of the - # possible interfaces for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Adding new episodes for {show}') show.add_new_episodes() @@ -244,12 +230,6 @@ def add_new_episodes(self) -> None: def set_episode_ids(self) -> None: """Set all episode ID's for all shows.""" - # If Sonarr, Plex, and TMDb are disabled, exit - if (not self.preferences.use_sonarr and not self.preferences.use_tmdb - and not self.preferences.use_plex): - return None - - # For each show in the Manager, set IDs for every episode for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Setting episode IDs for {show}') show.set_episode_ids() @@ -263,7 +243,6 @@ def add_translations(self) -> None: if not self.preferences.use_tmdb: return None - # For each show in the Manager, add translation for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Adding translations for {show}') show.add_translations() @@ -277,7 +256,6 @@ def download_logos(self) -> None: if not self.preferences.use_tmdb: return None - # For each show in the Manager, download a logo for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Downloading logo for {show}') show.download_logo() @@ -287,11 +265,6 @@ def download_logos(self) -> None: def select_source_images(self) -> None: """Select and download the source images for all shows.""" - # If Plex and TMDb aren't enabled, skip - if not self.preferences.use_plex and not self.preferences.use_tmdb: - return None - - # Go through each show and download source images for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Selecting sources for {show}') show.select_source_images() @@ -301,7 +274,6 @@ def select_source_images(self) -> None: def create_missing_title_cards(self) -> None: """Creates all missing title cards for all shows.""" - # Go through every show in the Manager, create cards for show in (pbar := tqdm(self.shows, **TQDM_KWARGS)): pbar.set_description(f'Creating cards for {show}') show.create_missing_title_cards() @@ -311,7 +283,6 @@ def create_missing_title_cards(self) -> None: def create_season_posters(self) -> None: """Create season posters for all shows.""" - # For each show in the Manager, create its posters for show in tqdm(self.shows + self.archives, desc='Creating season posters',**TQDM_KWARGS): show.create_season_posters() @@ -324,13 +295,13 @@ def update_media_server(self) -> None: if Emby/Jellyfin/Plex are globally enabled. """ - # If Plex and Emby aren't enabled, skip + # If no media servers aren't enabled, skip if (not self.preferences.use_emby and not self.preferences.use_jellyfin and not self.preferences.use_plex): return None - # Go through each show in the Manager, update Plex + # Go through each show in the Manager, update the server for show in (pbar := tqdm(self.shows, **TQDM_KWARGS)): pbar.set_description(f'Updating Server for {show}') show.update_media_server() From f7c0932a450f7c841a3c2ea1b5b5723514cdb50e Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Mon, 17 Apr 2023 10:00:16 -0600 Subject: [PATCH 10/44] Handle title mismatches on Plex for titles with commas Implements fix for #327 --- modules/Manager.py | 19 ++++++++++--- modules/PlexInterface.py | 50 +++++++++++++++------------------ modules/cards/AnimeTitleCard.py | 3 +- 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/modules/Manager.py b/modules/Manager.py index fc4151e4..1d46eb14 100755 --- a/modules/Manager.py +++ b/modules/Manager.py @@ -182,8 +182,8 @@ def create_shows(self) -> None: def assign_interfaces(self) -> None: """Assign all interfaces to each Show known to this Manager""" - for show in tqdm(self.shows + self.archives, - desc='Assigning interfaces', + # Assign interfaces for each show + for show in tqdm(self.shows + self.archives,desc='Assigning interfaces', **TQDM_KWARGS): show.assign_interfaces( self.emby_interface, @@ -198,8 +198,10 @@ def assign_interfaces(self) -> None: def set_show_ids(self) -> None: """Set the series ID's of each Show known to this Manager""" + # For each show in the Manager, set series IDs for show in tqdm(self.shows + self.archives, desc='Setting series IDs', **TQDM_KWARGS): + # Select interfaces based on what's enabled show.set_series_ids() @@ -211,6 +213,7 @@ def read_show_source(self) -> None: multipart episodes. """ + # Read source files for Show objects for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Reading source files for {show}') show.read_source() @@ -221,6 +224,8 @@ def read_show_source(self) -> None: def add_new_episodes(self) -> None: """Add any new episodes to this Manager's shows.""" + # For each show in the Manager, look for new episodes using any of the + # possible interfaces for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Adding new episodes for {show}') show.add_new_episodes() @@ -230,6 +235,7 @@ def add_new_episodes(self) -> None: def set_episode_ids(self) -> None: """Set all episode ID's for all shows.""" + # For each show in the Manager, set IDs for every episode for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Setting episode IDs for {show}') show.set_episode_ids() @@ -243,6 +249,7 @@ def add_translations(self) -> None: if not self.preferences.use_tmdb: return None + # For each show in the Manager, add translation for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Adding translations for {show}') show.add_translations() @@ -256,6 +263,7 @@ def download_logos(self) -> None: if not self.preferences.use_tmdb: return None + # For each show in the Manager, download a logo for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Downloading logo for {show}') show.download_logo() @@ -265,6 +273,7 @@ def download_logos(self) -> None: def select_source_images(self) -> None: """Select and download the source images for all shows.""" + # Go through each show and download source images for show in (pbar := tqdm(self.shows + self.archives, **TQDM_KWARGS)): pbar.set_description(f'Selecting sources for {show}') show.select_source_images() @@ -274,6 +283,7 @@ def select_source_images(self) -> None: def create_missing_title_cards(self) -> None: """Creates all missing title cards for all shows.""" + # Go through every show in the Manager, create cards for show in (pbar := tqdm(self.shows, **TQDM_KWARGS)): pbar.set_description(f'Creating cards for {show}') show.create_missing_title_cards() @@ -283,6 +293,7 @@ def create_missing_title_cards(self) -> None: def create_season_posters(self) -> None: """Create season posters for all shows.""" + # For each show in the Manager, create its posters for show in tqdm(self.shows + self.archives, desc='Creating season posters',**TQDM_KWARGS): show.create_season_posters() @@ -295,13 +306,13 @@ def update_media_server(self) -> None: if Emby/Jellyfin/Plex are globally enabled. """ - # If no media servers aren't enabled, skip + # If no media servers are enabled, skip if (not self.preferences.use_emby and not self.preferences.use_jellyfin and not self.preferences.use_plex): return None - # Go through each show in the Manager, update the server + # Go through each show in the Manager, update Plex for show in (pbar := tqdm(self.shows, **TQDM_KWARGS)): pbar.set_description(f'Updating Server for {show}') show.update_media_server() diff --git a/modules/PlexInterface.py b/modules/PlexInterface.py index d1131ec1..47b84530 100755 --- a/modules/PlexInterface.py +++ b/modules/PlexInterface.py @@ -143,9 +143,9 @@ def __get_library(self, library_name: str) -> 'Library': def __get_series(self, library: 'Library', series_info: SeriesInfo) -> 'Show': """ - Get the Series object from within the given Library associated with the - given SeriesInfo. This tries to match by TVDb ID, TMDb ID, name, and - finally full name. + Get the Series object from within the given Library associated + with the given SeriesInfo. This tries to match by TVDb ID, + TMDb ID, name, and finally name. Args: library: The Library object to search for within Plex. @@ -176,33 +176,27 @@ def __get_series(self, library: 'Library', except NotFound: pass - # Try by full name + # Try by name try: - series = library.get(series_info.full_name) - if series.year == series_info.year: - return series - raise NotFound + for series in library.search(title=series_info.name, + year=series_info.year, libtype='show'): + if series.title in (series_info.name, series_info.full_name): + return series except NotFound: pass - # Try by name and match the year - try: - if (ser := library.get(series_info.name)).year == series_info.year: - return ser - raise NotFound - except NotFound: - key = f'{library.title}-{series_info.full_name}' - if key not in self.__warned: - log.warning(f'Series "{series_info}" was not found under ' - f'library "{library.title}" in Plex') - self.__warned.add(key) - - return None + # Not found, return None + key = f'{library.title}-{series_info.full_name}' + if key not in self.__warned: + log.warning(f'Series "{series_info}" was not found under ' + f'library "{library.title}" in Plex') + self.__warned.add(key) + return None @catch_and_log('Error getting library paths', default={}) - def get_library_paths(self,filter_libraries: list[str]=[] - ) -> dict[str, list[str]]: + def get_library_paths(self, + filter_libraries: list[str] = []) -> dict[str, list[str]]: """ Get all libraries and their associated base directories. @@ -382,8 +376,8 @@ def has_series(self, library_name: str, series_info: 'SeriesInfo') -> bool: Determine whether the given series is present within Plex. Args: - library_name: The name of the library potentially containing the - series. + library_name: The name of the library potentially containing + the series. series_info: The series to being evaluated. Returns: @@ -496,8 +490,10 @@ def set_series_ids(self, library_name: str, @catch_and_log("Error setting episode ID's") - def set_episode_ids(self, library_name: str, series_info: SeriesInfo, - infos: list[EpisodeInfo]) -> None: + def set_episode_ids(self, + library_name: str, + series_info: SeriesInfo, + infos: list[EpisodeInfo]) -> None: """ Set all the episode ID's for the given list of EpisodeInfo objects. This sets the Sonarr and TVDb ID's for each episode. As a byproduct, this diff --git a/modules/cards/AnimeTitleCard.py b/modules/cards/AnimeTitleCard.py index b102eb49..7a1e3f7d 100755 --- a/modules/cards/AnimeTitleCard.py +++ b/modules/cards/AnimeTitleCard.py @@ -10,8 +10,7 @@ class AnimeTitleCard(BaseCardType): """ This class describes a type of CardType that produces title cards in the anime-styled cards designed by reddit user /u/Recker_Man. These - cards don't support custom fonts, but does support optional kanji - text. + cards support custom fonts, and optional kanji text. """ """API Parameters""" From d655cbb03af767e631fd4e184b77c30f235c6678 Mon Sep 17 00:00:00 2001 From: CollinHeist Date: Tue, 18 Apr 2023 13:52:03 -0600 Subject: [PATCH 11/44] Allow specification of non-English languages for logo selection from TMDb Implements #330 --- modules/PreferenceParser.py | 12 ++++++ modules/TMDbInterface.py | 77 ++++++++++++++++++++++++------------- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/modules/PreferenceParser.py b/modules/PreferenceParser.py index 05c5e577..056b6e8f 100755 --- a/modules/PreferenceParser.py +++ b/modules/PreferenceParser.py @@ -157,6 +157,7 @@ def __init__(self, file: Path, is_docker: bool=False) -> None: self.tmdb_retry_count = TMDbInterface.BLACKLIST_THRESHOLD self.tmdb_minimum_resolution = {'width': 0, 'height': 0} self.tmdb_skip_localized_images = False + self.tmdb_logo_language_priority = ['en'] self.use_tautulli = False self.tautulli_url = None @@ -722,6 +723,17 @@ def __parse_yaml_tmdb(self) -> None: type_=bool)) is not None: self.tmdb_skip_localized_images = value + if (value := self._get('tmdb', 'logo_language_priority', + type_=str)) is not None: + codes = list(map(lambda s: str(s).lower().strip(), value.split(','))) + if all(code in TMDbInterface.LANGUAGE_CODES for code in codes): + self.tmdb_logo_language_priority = codes + else: + opts = '"' + '", "'.join(TMDbInterface.LANGUAGE_CODES) + '"' + log.critical(f'Invalid TMDb logo language codes - must be comma' + f'-separated list of any of the following: {opts}') + self.valid = False + def __parse_yaml_tautulli(self) -> None: """ diff --git a/modules/TMDbInterface.py b/modules/TMDbInterface.py index afef5bc0..fd3dc771 100755 --- a/modules/TMDbInterface.py +++ b/modules/TMDbInterface.py @@ -51,6 +51,7 @@ class TMDbInterface(EpisodeDataSource, WebInterface): 'vi': r'Episode {number}', 'zh': r'第 {number} 集', } + LANGUAGE_CODES = tuple(GENERIC_TITLE_FORMATS.keys()) """Filename for where to store blacklisted entries""" __BLACKLIST_DB = 'tmdb_blacklist.json' @@ -116,8 +117,10 @@ def inner(*args, **kwargs): return decorator - def __get_condition(self, query_type: str, series_info: SeriesInfo, - episode_info: EpisodeInfo=None) -> 'QueryInstance': + def __get_condition(self, + query_type: str, + series_info: SeriesInfo, + episode_info: EpisodeInfo=None) -> 'QueryInstance': """ Get the tinydb query condition for the given query. @@ -193,8 +196,10 @@ def __update_blacklist(self, series_info: SeriesInfo, }, condition) - def __is_blacklisted(self, series_info: SeriesInfo, - episode_info: EpisodeInfo, query_type: str) -> bool: + def __is_blacklisted(self, + series_info: SeriesInfo, + episode_info: EpisodeInfo, + query_type: str) -> bool: """ Determines if the specified entry is in the blacklist (e.g. should not bother querying TMDb. @@ -225,9 +230,10 @@ def __is_blacklisted(self, series_info: SeriesInfo, return datetime.now().timestamp() < entry['next'] - def is_permanently_blacklisted(self, series_info: SeriesInfo, - episode_info: EpisodeInfo, - query_type: str='image') -> bool: + def is_permanently_blacklisted(self, + series_info: SeriesInfo, + episode_info: EpisodeInfo, + query_type: str='image') -> bool: """ Determines if permanently blacklisted. @@ -570,9 +576,10 @@ def set_episode_ids(self, series_info: SeriesInfo, return None - def __determine_best_image(self, images: list['tmdbapis.objs.image.Still'], - *, is_source_image: bool=True, - skip_localized: bool=False) -> dict: + def __determine_best_image(self, + images: list['tmdbapis.objs.image.Still'], *, + is_source_image: bool=True, + skip_localized: bool=False) -> dict: """ Determine the best image and return it's contents from within the database return JSON. @@ -672,8 +679,8 @@ def get_source_image(self, series_info: SeriesInfo, return None - def __is_generic_title(self, title: str, language_code: str, - episode_info: EpisodeInfo) -> bool: + def __is_generic_title(self, + title: str, language_code: str, episode_info: EpisodeInfo) -> bool: """ Determine whether the given title is a generic translation of "Episode (x)" for the indicated language. @@ -705,9 +712,10 @@ def __is_generic_title(self, title: str, language_code: str, @catch_and_log('Error getting episode title', default=None) - def get_episode_title(self, series_info: SeriesInfo, - episode_info: EpisodeInfo, - language_code: str='en-US') -> str: + def get_episode_title(self, + series_info: SeriesInfo, + episode_info: EpisodeInfo, + language_code: str='en-US') -> str: """ Get the episode title for the given entry for the given language. @@ -781,20 +789,31 @@ def get_series_logo(self, series_info: SeriesInfo) -> str: return None # Get the best logo - best = None + best, best_priority = None, 999 for logo in series.logos: - # Skip non-English logos - if logo.iso_639_1 != 'en': + # Skip logos with unindicated languages + if (logo.iso_639_1 + not in self.preferences.tmdb_logo_language_priority): continue - # SVG images are always the best - if logo.url.endswith('.svg'): - return logo.url + # Get relative priority of this logo's language + priority = self.preferences.tmdb_logo_language_priority.index( + logo.iso_639_1 + ) + + # Skip this logo if the lang priority is less than the current best + # highest priority is index 0, so use > for lower priority + if priority > best_priority: + continue - # Choose best based on pixel count - if (best is None + # SVG images take priority over + if logo.url.endswith('.svg'): + best = logo + best_priority = priority + elif (best is None or logo.width * logo.height > best.width * best.height): best = logo + best_priority = priority # No valid image found, blacklist and exit if best is None: @@ -805,8 +824,9 @@ def get_series_logo(self, series_info: SeriesInfo) -> str: @catch_and_log('Error setting series backdrop', default=None) - def get_series_backdrop(self, series_info: SeriesInfo, *, - skip_localized_images: bool=False) -> str: + def get_series_backdrop(self, + series_info: SeriesInfo, *, + skip_localized_images: bool=False) -> str: """ Get the best backdrop for the given series. @@ -850,8 +870,11 @@ def get_series_backdrop(self, series_info: SeriesInfo, *, return None - def manually_download_season(self, title: str, year: int, - season_number: int, episode_range: Iterable[int], + def manually_download_season(self, + title: str, + year: int, + season_number: int, + episode_range: Iterable[int], directory: Path) -> None: """ Download episodes 1-episode_count of the requested season for the given From 220d200d1f25f8afc4abb990349debfefd45a8ec Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Tue, 18 Apr 2023 19:19:23 -0600 Subject: [PATCH 12/44] Finalize #330 --- modules/BaseCardType.py | 4 ++ modules/TMDbInterface.py | 71 +++++++++++++++++++-------------- modules/cards/AnimeTitleCard.py | 27 +++++++------ 3 files changed, 61 insertions(+), 41 deletions(-) diff --git a/modules/BaseCardType.py b/modules/BaseCardType.py index 02628186..d16079ed 100755 --- a/modules/BaseCardType.py +++ b/modules/BaseCardType.py @@ -182,7 +182,11 @@ def is_custom_season_titles() -> bool: @property def resize(self) -> ImageMagickCommands: """ + ImageMagick commands to only resize an image to the output title + card size. + Returns: + List of ImageMagick commands. """ return [ diff --git a/modules/TMDbInterface.py b/modules/TMDbInterface.py index fd3dc771..c7c212ac 100755 --- a/modules/TMDbInterface.py +++ b/modules/TMDbInterface.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from pathlib import Path from tinydb import where -from typing import Iterable +from typing import Any, Iterable from tmdbapis import TMDbAPIs, NotFound, Unauthorized, TMDbException @@ -88,8 +88,10 @@ def __repr__(self) -> str: return f'' - def catch_and_log(message: str, log_func=log.error, *, - default=None) -> callable: + def catch_and_log( + message: str, + log_func: callable = log.error, *, + default: Any = None) -> callable: """ Return a decorator that logs (with the given log function) the given message if the decorated function raises an uncaught TMDbException. @@ -150,8 +152,10 @@ def __get_condition(self, ) - def __update_blacklist(self, series_info: SeriesInfo, - episode_info: EpisodeInfo, query_type: str) -> None: + def __update_blacklist(self, + series_info: SeriesInfo, + episode_info: EpisodeInfo, + query_type: str) -> None: """ Adds the given request to the blacklist; indicating that this exact request shouldn't be queried to TMDb for another day. Write the updated @@ -233,7 +237,7 @@ def __is_blacklisted(self, def is_permanently_blacklisted(self, series_info: SeriesInfo, episode_info: EpisodeInfo, - query_type: str='image') -> bool: + query_type: str = 'image') -> bool: """ Determines if permanently blacklisted. @@ -396,9 +400,10 @@ def get_all_episodes(self, series_info: SeriesInfo) -> list[EpisodeInfo]: return all_episodes - def __find_episode(self, series_info: SeriesInfo, - episode_info: EpisodeInfo, - title_match: bool=True) ->'tmdbapis.objs.reload.Episode': + def __find_episode(self, + series_info: SeriesInfo, + episode_info: EpisodeInfo, + title_match: bool = True) ->'tmdbapis.objs.reload.Episode': """ Finds the episode index for the given entry. Searching is done in the following priority: @@ -562,8 +567,8 @@ def _match_by_index(episode_info, season_number, episode_number): @catch_and_log('Error setting episode IDs') - def set_episode_ids(self, series_info: SeriesInfo, - infos: list[EpisodeInfo]) -> None: + def set_episode_ids(self, + series_info: SeriesInfo, infos: list[EpisodeInfo]) -> None: """ Set all the episode ID's for the given list of EpisodeInfo objects. For TMDb, this does nothing, as TMDb cannot provide any useful episode ID's. @@ -578,8 +583,8 @@ def set_episode_ids(self, series_info: SeriesInfo, def __determine_best_image(self, images: list['tmdbapis.objs.image.Still'], *, - is_source_image: bool=True, - skip_localized: bool=False) -> dict: + is_source_image: bool = True, + skip_localized: bool = False) -> dict: """ Determine the best image and return it's contents from within the database return JSON. @@ -626,9 +631,11 @@ def __determine_best_image(self, @catch_and_log('Error getting source image', default=None) - def get_source_image(self, series_info: SeriesInfo, - episode_info: EpisodeInfo, *, title_match: bool=True, - skip_localized_images: bool=False) -> str: + def get_source_image(self, + series_info: SeriesInfo, + episode_info: EpisodeInfo, *, + title_match: bool = True, + skip_localized_images: bool = False) -> str: """ Get the best source image for the requested entry. The URL of this image is returned. @@ -680,7 +687,9 @@ def get_source_image(self, series_info: SeriesInfo, def __is_generic_title(self, - title: str, language_code: str, episode_info: EpisodeInfo) -> bool: + title: str, + language_code: str, + episode_info: EpisodeInfo) -> bool: """ Determine whether the given title is a generic translation of "Episode (x)" for the indicated language. @@ -715,7 +724,7 @@ def __is_generic_title(self, def get_episode_title(self, series_info: SeriesInfo, episode_info: EpisodeInfo, - language_code: str='en-US') -> str: + language_code: str = 'en-US') -> str: """ Get the episode title for the given entry for the given language. @@ -795,25 +804,29 @@ def get_series_logo(self, series_info: SeriesInfo) -> str: if (logo.iso_639_1 not in self.preferences.tmdb_logo_language_priority): continue - # Get relative priority of this logo's language priority = self.preferences.tmdb_logo_language_priority.index( logo.iso_639_1 ) - # Skip this logo if the lang priority is less than the current best - # highest priority is index 0, so use > for lower priority + # Skip this logo if the language priority is less than the current + # best. Highest priority is index 0, so use > for lower priority if priority > best_priority: continue - - # SVG images take priority over - if logo.url.endswith('.svg'): - best = logo - best_priority = priority - elif (best is None - or logo.width * logo.height > best.width * best.height): + # New logo is higher priority, use always + elif priority < best_priority: best = logo best_priority = priority + # Same priority, compare sizes + elif priority == best_priority: + # SVG logos are infinite size + if logo.url.endswith('.svg') and not best.url.endswith('.svg'): + best = logo + best_priority = priority + elif (best is None + or logo.width * logo.height > best.width * best.height): + best = logo + best_priority = priority # No valid image found, blacklist and exit if best is None: @@ -826,7 +839,7 @@ def get_series_logo(self, series_info: SeriesInfo) -> str: @catch_and_log('Error setting series backdrop', default=None) def get_series_backdrop(self, series_info: SeriesInfo, *, - skip_localized_images: bool=False) -> str: + skip_localized_images: bool = False) -> str: """ Get the best backdrop for the given series. diff --git a/modules/cards/AnimeTitleCard.py b/modules/cards/AnimeTitleCard.py index 7a1e3f7d..209240ba 100755 --- a/modules/cards/AnimeTitleCard.py +++ b/modules/cards/AnimeTitleCard.py @@ -27,7 +27,7 @@ class AnimeTitleCard(BaseCardType): 'description': 'Japanese text to place above title text'}, {'name': 'Require Kanji Text', 'identifier': 'require_kanji', - 'description': 'Whether to require kanji text to be provided for the card to be created'}, + 'description': 'Whether to require kanji text for the card creation'}, {'name': 'Kanji Vertical Shift', 'identifier': 'kanji_vertical_shift', 'description': 'Additional vertical offset to apply only to the kanji text'}, @@ -42,7 +42,8 @@ class AnimeTitleCard(BaseCardType): 'description': 'Whether to omit the gradient overlay from the card'}, ], 'description': [ 'Title card with all text aligned in the lower left of the image', - 'Although it is referred to as the "anime" card style, there is nothing preventing you from using it for any series.', + 'Although it is referred to as the "anime" card style, there is ' + 'nothing preventing you from using it for any type of series.', ], } @@ -272,7 +273,7 @@ def __series_count_text_black_stroke(self) -> list: @property - def title_command(self) -> list[str]: + def title_text_command(self) -> list[str]: """ Subcommand for adding title text to the source image. @@ -315,7 +316,7 @@ def title_command(self) -> list[str]: @property - def index_command(self) -> list[str]: + def index_text_command(self) -> list[str]: """ Subcommand for adding the index text to the source image. @@ -372,8 +373,10 @@ def index_command(self) -> list[str]: @staticmethod - def modify_extras(extras: dict[str, Any], custom_font: bool, - custom_season_titles: bool) -> None: + def modify_extras( + extras: dict[str, Any], + custom_font: bool, + custom_season_titles: bool) -> None: """ Modify the given extras base on whether font or season titles are custom. @@ -413,8 +416,8 @@ def is_custom_font(font: 'Font') -> bool: @staticmethod - def is_custom_season_titles(custom_episode_map: bool, - episode_text_format: str) -> bool: + def is_custom_season_titles( + custom_episode_map: bool, episode_text_format: str) -> bool: """ Determines whether the given attributes constitute custom or generic season titles. @@ -429,8 +432,8 @@ def is_custom_season_titles(custom_episode_map: bool, standard_etf = AnimeTitleCard.EPISODE_TEXT_FORMAT.upper() - return (custom_episode_map or - episode_text_format.upper() != standard_etf) + return (custom_episode_map + or episode_text_format.upper() != standard_etf) def create(self) -> None: @@ -462,9 +465,9 @@ def create(self) -> None: # Overlay gradient *gradient_command, # Add title or title+kanji - *self.title_command, + *self.title_text_command, # Add season or season+episode text - *self.index_command, + *self.index_text_command, # Create card *self.resize_output, f'"{self.output_file.resolve()}"', From 37d4eae8bb2d3ac991292345f3c7ad5c68e25296 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Wed, 19 Apr 2023 22:56:51 -0600 Subject: [PATCH 13/44] Add borderless option to Movie Posters Implements #332 --- mini_maker.py | 3 ++- modules/MoviePosterMaker.py | 26 +++++++++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/mini_maker.py b/mini_maker.py index e1dcdc97..a3329401 100755 --- a/mini_maker.py +++ b/mini_maker.py @@ -460,9 +460,10 @@ top_subtitle=args.movie_top_subtitle, movie_index=args.movie_index, logo=args.movie_logo, - font=args.movie_font, + font_file=args.movie_font, font_color=args.movie_font_color, font_size=float(args.movie_font_size[:-1])/100.0, + borderless=args.borderless, omit_gradient=args.no_gradient, ).create() diff --git a/modules/MoviePosterMaker.py b/modules/MoviePosterMaker.py index eeec0e0c..d766e763 100755 --- a/modules/MoviePosterMaker.py +++ b/modules/MoviePosterMaker.py @@ -20,14 +20,18 @@ class MoviePosterMaker(ImageMaker): __GRADIENT = REF_DIRECTORY / 'gradient.png' - def __init__(self, source: Path, output: Path, title: str, + def __init__(self, + source: Path, + output: Path, + title: str, subtitle: str = '', top_subtitle: str = '', movie_index: str = '', logo: Optional[Path] = None, - font: Path = FONT, + font_file: Path = FONT, font_color: str = FONT_COLOR, font_size: float = 1.0, + borderless: bool = False, omit_gradient: bool = False) -> None: """ Construct a new instance of a CollectionPosterMaker. @@ -43,9 +47,10 @@ def __init__(self, source: Path, output: Path, title: str, movie title. logo: Optional path to a logo file to place on top of the poster. - font: Path to the font file of the poster's title. + font_file: Path to the font file of the poster's title. font_color: Font color of the poster text. font_size: Scalar for the font size of the poster's title. + borderless: Whether to omit the white frame border. omit_gradient: Whether to make the poster with no gradient overlay. """ @@ -58,13 +63,14 @@ def __init__(self, source: Path, output: Path, title: str, self.output = output self.movie_index = movie_index self.logo = logo - self.font = font + self.font_file = font_file self.font_color = font_color self.font_size = font_size + self.borderless = borderless self.omit_gradient = omit_gradient # Uppercase title(s) if using default font - if font == self.FONT: + if font_file == self.FONT: self.top_subtitle = top_subtitle.upper().strip() self.title = title.upper().strip() self.subtitle = subtitle.upper().strip() @@ -199,7 +205,7 @@ def title_command(self) -> list[str]: # At least one title being added, return entire command return [ ## Global font attributes - f'-font "{self.font.resolve()}"', + f'-font "{self.font_file.resolve()}"', f'-fill "{self.font_color}"', # Create an image for each title f'\( -background transparent', @@ -248,16 +254,18 @@ def create(self) -> None: # Optionally overlay gradient *self.gradient_command, # Add frame - f'-background None', - f'"{self.__FRAME.resolve()}"', + f'-background transparent', + f'"{self.__FRAME.resolve()}"' if not self.borderless else '', f'-extent 2000x3000', - f'-composite', + f'-composite' if not self.borderless else '', # Optionally overlay logo *self.logo_command, # Add index text *self.index_command, # Add title text *self.title_command, + # Crop to remove the empty frame space if borderless + f'-gravity center -crop 1892x2892+0+0' if self.borderless else '', f'"{self.output.resolve()}"', ]) From 2d8a09593cc32694c5f0e42d4aed341e4cd273e7 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 12:02:36 -0600 Subject: [PATCH 14/44] Update all Pipfile dependencies --- Pipfile | 18 ++-- Pipfile.lock | 285 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 175 insertions(+), 128 deletions(-) mode change 100755 => 100644 Pipfile.lock diff --git a/Pipfile b/Pipfile index fe4cd89a..f8dbe61d 100755 --- a/Pipfile +++ b/Pipfile @@ -4,19 +4,19 @@ verify_ssl = true name = "pypi" [packages] -regex = "==2022.10.31" +regex = "==2023.3.23" num2words = "==0.5.12" pyyaml = "==6.0" -requests = "==2.28.1" +requests = "==2.28.2" titlecase = "==2.4" -tqdm = "==4.64.1" -fonttools = "==4.38.0" -plexapi = "==4.13.2" -tenacity = "==8.1.0" -tinydb = "==4.7.0" -schedule = "==1.1.0" +tqdm = "==4.65.0" +fonttools = "==4.39.3" +plexapi = "==4.13.4" +tenacity = "==8.2.2" +tinydb = "==4.7.1" +schedule = "==1.2.0" tmdbapis = "==1.1.0" -"ruamel.yaml" = "*" +"ruamel.yaml" = "==0.17.21" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock old mode 100755 new mode 100644 index 4b74b8af..76021eeb --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7f967f0d6c3b1984dd00960b1f310fe3cba72bfa6416d29c9ed5300be6fef520" + "sha256": "fcd04f5aea8a0a7c145216597ffbc53aa28660593d79db6671d404d702bcc34d" }, "pipfile-spec": 6, "requires": { @@ -26,11 +26,84 @@ }, "charset-normalizer": { "hashes": [ - "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", - "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" + "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", + "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1", + "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e", + "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373", + "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62", + "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230", + "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be", + "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c", + "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0", + "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448", + "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f", + "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649", + "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d", + "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0", + "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706", + "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a", + "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59", + "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23", + "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5", + "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb", + "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e", + "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e", + "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c", + "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28", + "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d", + "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41", + "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974", + "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce", + "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f", + "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1", + "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d", + "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8", + "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017", + "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31", + "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7", + "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8", + "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e", + "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14", + "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd", + "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d", + "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795", + "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b", + "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b", + "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b", + "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203", + "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f", + "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19", + "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1", + "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a", + "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac", + "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9", + "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0", + "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137", + "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f", + "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6", + "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5", + "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909", + "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f", + "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0", + "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324", + "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755", + "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb", + "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854", + "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c", + "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60", + "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84", + "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0", + "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b", + "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1", + "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531", + "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1", + "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11", + "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326", + "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", + "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" ], - "markers": "python_full_version >= '3.6.0'", - "version": "==2.1.1" + "markers": "python_full_version >= '3.7.0'", + "version": "==3.1.0" }, "docopt": { "hashes": [ @@ -40,11 +113,11 @@ }, "fonttools": { "hashes": [ - "sha256:2bb244009f9bf3fa100fc3ead6aeb99febe5985fa20afbfbaa2f8946c2fbdaf1", - "sha256:820466f43c8be8c3009aef8b87e785014133508f0de64ec469e4efb643ae54fb" + "sha256:64c0c05c337f826183637570ac5ab49ee220eec66cf50248e8df527edfa95aeb", + "sha256:9234b9f57b74e31b192c3fc32ef1a40750a8fbc1cd9837a7b7bfc4ca4a5c51d7" ], "index": "pypi", - "version": "==4.38.0" + "version": "==4.39.3" }, "idna": { "hashes": [ @@ -64,11 +137,11 @@ }, "plexapi": { "hashes": [ - "sha256:2a67b5739ec966e10dec957fea8abe38e9a4ff9d6b58dd0ec6a55ae758cead8e", - "sha256:4a7cd6729061419abd600de9c436bdf9565976a873d0a74487606c4126b98439" + "sha256:6f90c3c8736ca4974a23e2912bfe74f74caa0fc21f071a2ce3b8559ad641e392", + "sha256:b5208a76d56bd202baf50b5fa81527a6a4bac49290d309cf4ce7517a57feb4a4" ], "index": "pypi", - "version": "==4.13.2" + "version": "==4.13.4" }, "pyyaml": { "hashes": [ @@ -118,105 +191,77 @@ }, "regex": { "hashes": [ - "sha256:052b670fafbe30966bbe5d025e90b2a491f85dfe5b2583a163b5e60a85a321ad", - "sha256:0653d012b3bf45f194e5e6a41df9258811ac8fc395579fa82958a8b76286bea4", - "sha256:0a069c8483466806ab94ea9068c34b200b8bfc66b6762f45a831c4baaa9e8cdd", - "sha256:0cf0da36a212978be2c2e2e2d04bdff46f850108fccc1851332bcae51c8907cc", - "sha256:131d4be09bea7ce2577f9623e415cab287a3c8e0624f778c1d955ec7c281bd4d", - "sha256:144486e029793a733e43b2e37df16a16df4ceb62102636ff3db6033994711066", - "sha256:1ddf14031a3882f684b8642cb74eea3af93a2be68893901b2b387c5fd92a03ec", - "sha256:1eba476b1b242620c266edf6325b443a2e22b633217a9835a52d8da2b5c051f9", - "sha256:20f61c9944f0be2dc2b75689ba409938c14876c19d02f7585af4460b6a21403e", - "sha256:22960019a842777a9fa5134c2364efaed5fbf9610ddc5c904bd3a400973b0eb8", - "sha256:22e7ebc231d28393dfdc19b185d97e14a0f178bedd78e85aad660e93b646604e", - "sha256:23cbb932cc53a86ebde0fb72e7e645f9a5eec1a5af7aa9ce333e46286caef783", - "sha256:29c04741b9ae13d1e94cf93fca257730b97ce6ea64cfe1eba11cf9ac4e85afb6", - "sha256:2bde29cc44fa81c0a0c8686992c3080b37c488df167a371500b2a43ce9f026d1", - "sha256:2cdc55ca07b4e70dda898d2ab7150ecf17c990076d3acd7a5f3b25cb23a69f1c", - "sha256:370f6e97d02bf2dd20d7468ce4f38e173a124e769762d00beadec3bc2f4b3bc4", - "sha256:395161bbdbd04a8333b9ff9763a05e9ceb4fe210e3c7690f5e68cedd3d65d8e1", - "sha256:44136355e2f5e06bf6b23d337a75386371ba742ffa771440b85bed367c1318d1", - "sha256:44a6c2f6374e0033873e9ed577a54a3602b4f609867794c1a3ebba65e4c93ee7", - "sha256:4919899577ba37f505aaebdf6e7dc812d55e8f097331312db7f1aab18767cce8", - "sha256:4b4b1fe58cd102d75ef0552cf17242705ce0759f9695334a56644ad2d83903fe", - "sha256:4bdd56ee719a8f751cf5a593476a441c4e56c9b64dc1f0f30902858c4ef8771d", - "sha256:4bf41b8b0a80708f7e0384519795e80dcb44d7199a35d52c15cc674d10b3081b", - "sha256:4cac3405d8dda8bc6ed499557625585544dd5cbf32072dcc72b5a176cb1271c8", - "sha256:4fe7fda2fe7c8890d454f2cbc91d6c01baf206fbc96d89a80241a02985118c0c", - "sha256:50921c140561d3db2ab9f5b11c5184846cde686bb5a9dc64cae442926e86f3af", - "sha256:5217c25229b6a85049416a5c1e6451e9060a1edcf988641e309dbe3ab26d3e49", - "sha256:5352bea8a8f84b89d45ccc503f390a6be77917932b1c98c4cdc3565137acc714", - "sha256:542e3e306d1669b25936b64917285cdffcd4f5c6f0247636fec037187bd93542", - "sha256:543883e3496c8b6d58bd036c99486c3c8387c2fc01f7a342b760c1ea3158a318", - "sha256:586b36ebda81e6c1a9c5a5d0bfdc236399ba6595e1397842fd4a45648c30f35e", - "sha256:597f899f4ed42a38df7b0e46714880fb4e19a25c2f66e5c908805466721760f5", - "sha256:5a260758454580f11dd8743fa98319bb046037dfab4f7828008909d0aa5292bc", - "sha256:5aefb84a301327ad115e9d346c8e2760009131d9d4b4c6b213648d02e2abe144", - "sha256:5e6a5567078b3eaed93558842346c9d678e116ab0135e22eb72db8325e90b453", - "sha256:5ff525698de226c0ca743bfa71fc6b378cda2ddcf0d22d7c37b1cc925c9650a5", - "sha256:61edbca89aa3f5ef7ecac8c23d975fe7261c12665f1d90a6b1af527bba86ce61", - "sha256:659175b2144d199560d99a8d13b2228b85e6019b6e09e556209dfb8c37b78a11", - "sha256:6a9a19bea8495bb419dc5d38c4519567781cd8d571c72efc6aa959473d10221a", - "sha256:6b30bddd61d2a3261f025ad0f9ee2586988c6a00c780a2fb0a92cea2aa702c54", - "sha256:6ffd55b5aedc6f25fd8d9f905c9376ca44fcf768673ffb9d160dd6f409bfda73", - "sha256:702d8fc6f25bbf412ee706bd73019da5e44a8400861dfff7ff31eb5b4a1276dc", - "sha256:74bcab50a13960f2a610cdcd066e25f1fd59e23b69637c92ad470784a51b1347", - "sha256:75f591b2055523fc02a4bbe598aa867df9e953255f0b7f7715d2a36a9c30065c", - "sha256:763b64853b0a8f4f9cfb41a76a4a85a9bcda7fdda5cb057016e7706fde928e66", - "sha256:76c598ca73ec73a2f568e2a72ba46c3b6c8690ad9a07092b18e48ceb936e9f0c", - "sha256:78d680ef3e4d405f36f0d6d1ea54e740366f061645930072d39bca16a10d8c93", - "sha256:7b280948d00bd3973c1998f92e22aa3ecb76682e3a4255f33e1020bd32adf443", - "sha256:7db345956ecce0c99b97b042b4ca7326feeec6b75facd8390af73b18e2650ffc", - "sha256:7dbdce0c534bbf52274b94768b3498abdf675a691fec5f751b6057b3030f34c1", - "sha256:7ef6b5942e6bfc5706301a18a62300c60db9af7f6368042227ccb7eeb22d0892", - "sha256:7f5a3ffc731494f1a57bd91c47dc483a1e10048131ffb52d901bfe2beb6102e8", - "sha256:8a45b6514861916c429e6059a55cf7db74670eaed2052a648e3e4d04f070e001", - "sha256:8ad241da7fac963d7573cc67a064c57c58766b62a9a20c452ca1f21050868dfa", - "sha256:8b0886885f7323beea6f552c28bff62cbe0983b9fbb94126531693ea6c5ebb90", - "sha256:8ca88da1bd78990b536c4a7765f719803eb4f8f9971cc22d6ca965c10a7f2c4c", - "sha256:8e0caeff18b96ea90fc0eb6e3bdb2b10ab5b01a95128dfeccb64a7238decf5f0", - "sha256:957403a978e10fb3ca42572a23e6f7badff39aa1ce2f4ade68ee452dc6807692", - "sha256:9af69f6746120998cd9c355e9c3c6aec7dff70d47247188feb4f829502be8ab4", - "sha256:9c94f7cc91ab16b36ba5ce476f1904c91d6c92441f01cd61a8e2729442d6fcf5", - "sha256:a37d51fa9a00d265cf73f3de3930fa9c41548177ba4f0faf76e61d512c774690", - "sha256:a3a98921da9a1bf8457aeee6a551948a83601689e5ecdd736894ea9bbec77e83", - "sha256:a3c1ebd4ed8e76e886507c9eddb1a891673686c813adf889b864a17fafcf6d66", - "sha256:a5f9505efd574d1e5b4a76ac9dd92a12acb2b309551e9aa874c13c11caefbe4f", - "sha256:a8ff454ef0bb061e37df03557afda9d785c905dab15584860f982e88be73015f", - "sha256:a9d0b68ac1743964755ae2d89772c7e6fb0118acd4d0b7464eaf3921c6b49dd4", - "sha256:aa62a07ac93b7cb6b7d0389d8ef57ffc321d78f60c037b19dfa78d6b17c928ee", - "sha256:ac741bf78b9bb432e2d314439275235f41656e189856b11fb4e774d9f7246d81", - "sha256:ae1e96785696b543394a4e3f15f3f225d44f3c55dafe3f206493031419fedf95", - "sha256:b683e5fd7f74fb66e89a1ed16076dbab3f8e9f34c18b1979ded614fe10cdc4d9", - "sha256:b7a8b43ee64ca8f4befa2bea4083f7c52c92864d8518244bfa6e88c751fa8fff", - "sha256:b8e38472739028e5f2c3a4aded0ab7eadc447f0d84f310c7a8bb697ec417229e", - "sha256:bfff48c7bd23c6e2aec6454aaf6edc44444b229e94743b34bdcdda2e35126cf5", - "sha256:c14b63c9d7bab795d17392c7c1f9aaabbffd4cf4387725a0ac69109fb3b550c6", - "sha256:c27cc1e4b197092e50ddbf0118c788d9977f3f8f35bfbbd3e76c1846a3443df7", - "sha256:c28d3309ebd6d6b2cf82969b5179bed5fefe6142c70f354ece94324fa11bf6a1", - "sha256:c670f4773f2f6f1957ff8a3962c7dd12e4be54d05839b216cb7fd70b5a1df394", - "sha256:ce6910b56b700bea7be82c54ddf2e0ed792a577dfaa4a76b9af07d550af435c6", - "sha256:d0213671691e341f6849bf33cd9fad21f7b1cb88b89e024f33370733fec58742", - "sha256:d03fe67b2325cb3f09be029fd5da8df9e6974f0cde2c2ac6a79d2634e791dd57", - "sha256:d0e5af9a9effb88535a472e19169e09ce750c3d442fb222254a276d77808620b", - "sha256:d243b36fbf3d73c25e48014961e83c19c9cc92530516ce3c43050ea6276a2ab7", - "sha256:d26166acf62f731f50bdd885b04b38828436d74e8e362bfcb8df221d868b5d9b", - "sha256:d403d781b0e06d2922435ce3b8d2376579f0c217ae491e273bab8d092727d244", - "sha256:d8716f82502997b3d0895d1c64c3b834181b1eaca28f3f6336a71777e437c2af", - "sha256:e4f781ffedd17b0b834c8731b75cce2639d5a8afe961c1e58ee7f1f20b3af185", - "sha256:e613a98ead2005c4ce037c7b061f2409a1a4e45099edb0ef3200ee26ed2a69a8", - "sha256:ef4163770525257876f10e8ece1cf25b71468316f61451ded1a6f44273eedeb5" + "sha256:086afe222d58b88b62847bdbd92079b4699350b4acab892f88a935db5707c790", + "sha256:0b8eb1e3bca6b48dc721818a60ae83b8264d4089a4a41d62be6d05316ec38e15", + "sha256:11d00c31aeab9a6e0503bc77e73ed9f4527b3984279d997eb145d7c7be6268fd", + "sha256:11d1f2b7a0696dc0310de0efb51b1f4d813ad4401fe368e83c0c62f344429f98", + "sha256:1b1fc2632c01f42e06173d8dd9bb2e74ab9b0afa1d698058c867288d2c7a31f3", + "sha256:20abe0bdf03630fe92ccafc45a599bca8b3501f48d1de4f7d121153350a2f77d", + "sha256:22720024b90a6ba673a725dcc62e10fb1111b889305d7c6b887ac7466b74bedb", + "sha256:2472428efc4127374f494e570e36b30bb5e6b37d9a754f7667f7073e43b0abdd", + "sha256:25f0532fd0c53e96bad84664171969de9673b4131f2297f1db850d3918d58858", + "sha256:2848bf76673c83314068241c8d5b7fa9ad9bed866c979875a0e84039349e8fa7", + "sha256:37ae17d3be44c0b3f782c28ae9edd8b47c1f1776d4cabe87edc0b98e1f12b021", + "sha256:3cd9f5dd7b821f141d3a6ca0d5d9359b9221e4f051ca3139320adea9f1679691", + "sha256:4479f9e2abc03362df4045b1332d4a2b7885b245a30d4f4b051c4083b97d95d8", + "sha256:4c49552dc938e3588f63f8a78c86f3c9c75301e813bca0bef13bdb4b87ccf364", + "sha256:539dd010dc35af935b32f248099e38447bbffc10b59c2b542bceead2bed5c325", + "sha256:54c3fa855a3f7438149de3211738dd9b5f0c733f48b54ae05aa7fce83d48d858", + "sha256:55ae114da21b7a790b90255ea52d2aa3a0d121a646deb2d3c6a3194e722fc762", + "sha256:5ccfafd98473e007cebf7da10c1411035b7844f0f204015efd050601906dbb53", + "sha256:5fc33b27b1d800fc5b78d7f7d0f287e35079ecabe68e83d46930cf45690e1c8c", + "sha256:6560776ec19c83f3645bbc5db64a7a5816c9d8fb7ed7201c5bcd269323d88072", + "sha256:6572ff287176c0fb96568adb292674b421fa762153ed074d94b1d939ed92c253", + "sha256:6b190a339090e6af25f4a5fd9e77591f6d911cc7b96ecbb2114890b061be0ac1", + "sha256:7304863f3a652dab5e68e6fb1725d05ebab36ec0390676d1736e0571ebb713ef", + "sha256:75f288c60232a5339e0ff2fa05779a5e9c74e9fc085c81e931d4a264501e745b", + "sha256:7868b8f218bf69a2a15402fde08b08712213a1f4b85a156d90473a6fb6b12b09", + "sha256:787954f541ab95d8195d97b0b8cf1dc304424adb1e07365967e656b92b38a699", + "sha256:78ac8dd8e18800bb1f97aad0d73f68916592dddf233b99d2b5cabc562088503a", + "sha256:79e29fd62fa2f597a6754b247356bda14b866131a22444d67f907d6d341e10f3", + "sha256:845a5e2d84389c4ddada1a9b95c055320070f18bb76512608374aca00d22eca8", + "sha256:86b036f401895e854de9fefe061518e78d506d8a919cc250dc3416bca03f6f9a", + "sha256:87d9951f5a538dd1d016bdc0dcae59241d15fa94860964833a54d18197fcd134", + "sha256:8a9c63cde0eaa345795c0fdeb19dc62d22e378c50b0bc67bf4667cd5b482d98b", + "sha256:93f3f1aa608380fe294aa4cb82e2afda07a7598e828d0341e124b8fd9327c715", + "sha256:9bf4a5626f2a0ea006bf81e8963f498a57a47d58907eaa58f4b3e13be68759d8", + "sha256:9d764514d19b4edcc75fd8cb1423448ef393e8b6cbd94f38cab983ab1b75855d", + "sha256:a610e0adfcb0fc84ea25f6ea685e39e74cbcd9245a72a9a7aab85ff755a5ed27", + "sha256:a81c9ec59ca2303acd1ccd7b9ac409f1e478e40e96f8f79b943be476c5fdb8bb", + "sha256:b7006105b10b59971d3b248ad75acc3651c7e4cf54d81694df5a5130a3c3f7ea", + "sha256:c07ce8e9eee878a48ebeb32ee661b49504b85e164b05bebf25420705709fdd31", + "sha256:c125a02d22c555e68f7433bac8449992fa1cead525399f14e47c2d98f2f0e467", + "sha256:c37df2a060cb476d94c047b18572ee2b37c31f831df126c0da3cd9227b39253d", + "sha256:c869260aa62cee21c5eb171a466c0572b5e809213612ef8d495268cd2e34f20d", + "sha256:c88e8c226473b5549fe9616980ea7ca09289246cfbdf469241edf4741a620004", + "sha256:cd1671e9d5ac05ce6aa86874dd8dfa048824d1dbe73060851b310c6c1a201a96", + "sha256:cde09c4fdd070772aa2596d97e942eb775a478b32459e042e1be71b739d08b77", + "sha256:cf86b4328c204c3f315074a61bc1c06f8a75a8e102359f18ce99fbcbbf1951f0", + "sha256:d5bbe0e1511b844794a3be43d6c145001626ba9a6c1db8f84bdc724e91131d9d", + "sha256:d895b4c863059a4934d3e874b90998df774644a41b349ebb330f85f11b4ef2c0", + "sha256:db034255e72d2995cf581b14bb3fc9c00bdbe6822b49fcd4eef79e1d5f232618", + "sha256:dbb3f87e15d3dd76996d604af8678316ad2d7d20faa394e92d9394dfd621fd0c", + "sha256:dc80df325b43ffea5cdea2e3eaa97a44f3dd298262b1c7fe9dbb2a9522b956a7", + "sha256:dd7200b4c27b68cf9c9646da01647141c6db09f48cc5b51bc588deaf8e98a797", + "sha256:df45fac182ebc3c494460c644e853515cc24f5ad9da05f8ffb91da891bfee879", + "sha256:e152461e9a0aedec7d37fc66ec0fa635eca984777d3d3c3e36f53bf3d3ceb16e", + "sha256:e2396e0678167f2d0c197da942b0b3fb48fee2f0b5915a0feb84d11b6686afe6", + "sha256:e76b6fc0d8e9efa39100369a9b3379ce35e20f6c75365653cf58d282ad290f6f", + "sha256:ea3c0cb56eadbf4ab2277e7a095676370b3e46dbfc74d5c383bd87b0d6317910", + "sha256:ef3f528fe1cc3d139508fe1b22523745aa77b9d6cb5b0bf277f48788ee0b993f", + "sha256:fdf7ad455f1916b8ea5cdbc482d379f6daf93f3867b4232d14699867a5a13af7", + "sha256:fffe57312a358be6ec6baeb43d253c36e5790e436b7bf5b7a38df360363e88e9" ], "index": "pypi", - "version": "==2022.10.31" + "version": "==2023.3.23" }, "requests": { "hashes": [ - "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", - "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" + "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa", + "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf" ], "index": "pypi", - "version": "==2.28.1" + "version": "==2.28.2" }, "ruamel.yaml": { "hashes": [ @@ -256,39 +301,41 @@ "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a", "sha256:d5859983f26d8cd7bb5c287ef452e8aacc86501487634573d260968f753e1d71", "sha256:d5e51e2901ec2366b79f16c2299a03e74ba4531ddcfacc1416639c557aef0ad8", + "sha256:da538167284de58a52109a9b89b8f6a53ff8437dd6dc26d33b57bf6699153122", "sha256:debc87a9516b237d0466a711b18b6ebeb17ba9f391eb7f91c649c5c4ec5006c7", "sha256:df5828871e6648db72d1c19b4bd24819b80a755c4541d3409f0f7acd0f335c80", "sha256:ecdf1a604009bd35c674b9225a8fa609e0282d9b896c03dd441a91e5f53b534e", "sha256:efa08d63ef03d079dcae1dfe334f6c8847ba8b645d08df286358b1f5293d24ab", "sha256:f01da5790e95815eb5a8a138508c01c758e5f5bc0ce4286c4f7028b8dd7ac3d0", - "sha256:f34019dced51047d6f70cb9383b2ae2853b7fc4dce65129a5acd49f4f9256646" + "sha256:f34019dced51047d6f70cb9383b2ae2853b7fc4dce65129a5acd49f4f9256646", + "sha256:f6d3d39611ac2e4f62c3128a9eed45f19a6608670c5a2f4f07f24e8de3441d38" ], "markers": "python_version < '3.11' and platform_python_implementation == 'CPython'", "version": "==0.2.7" }, "schedule": { "hashes": [ - "sha256:617adce8b4bf38c360b781297d59918fbebfb2878f1671d189f4f4af5d0567a4", - "sha256:e6ca13585e62c810e13a08682e0a6a8ad245372e376ba2b8679294f377dfc8e4" + "sha256:415908febaba0bc9a7c727a32efb407d646fe994367ef9157d123aabbe539ea8", + "sha256:b4ad697aafba7184c9eb6a1e2ebc41f781547242acde8ceae9a0a25b04c0922d" ], "index": "pypi", - "version": "==1.1.0" + "version": "==1.2.0" }, "tenacity": { "hashes": [ - "sha256:35525cd47f82830069f0d6b73f7eb83bc5b73ee2fff0437952cedf98b27653ac", - "sha256:e48c437fdf9340f5666b92cd7990e96bc5fc955e1298baf4a907e3972067a445" + "sha256:2f277afb21b851637e8f52e6a613ff08734c347dc19ade928e519d7d2d8569b0", + "sha256:43af037822bd0029025877f3b2d97cc4d7bb0c2991000a3d59d71517c5c969e0" ], "index": "pypi", - "version": "==8.1.0" + "version": "==8.2.2" }, "tinydb": { "hashes": [ - "sha256:357eb7383dee6915f17b00596ec6dd2a890f3117bf52be28a4c516aeee581100", - "sha256:e2cdf6e2dad49813e9b5fceb3c7943387309a8738125fbff0b58d248a033f7a9" + "sha256:1534e498ca23f55c43b0f1e7c0cf174049498ab45a887c82ba9831e0f9868df3", + "sha256:8955c239a79b8a6c8f637900152e2de38690848199d71d870c33c16405433ca5" ], "index": "pypi", - "version": "==4.7.0" + "version": "==4.7.1" }, "titlecase": { "hashes": [ @@ -307,19 +354,19 @@ }, "tqdm": { "hashes": [ - "sha256:5f4f682a004951c1b450bc753c710e9280c5746ce6ffedee253ddbcbf54cf1e4", - "sha256:6fee160d6ffcd1b1c68c65f14c829c22832bc401726335ce92c52d395944a6a1" + "sha256:1871fb68a86b8fb3b59ca4cdd3dcccbc7e6d613eeed31f4c332531977b89beb5", + "sha256:c4f53a17fe37e132815abceec022631be8ffe1b9381c2e6e30aa70edc99e9671" ], "index": "pypi", - "version": "==4.64.1" + "version": "==4.65.0" }, "urllib3": { "hashes": [ - "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc", - "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8" + "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305", + "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.26.13" + "version": "==1.26.15" } }, "develop": {} From 081e4784a21bfad27e14f0038ad8fa60ad505fb5 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 12:05:59 -0600 Subject: [PATCH 15/44] Add TextlessTitleCard under "import" card_type identifier --- modules/Font.py | 7 +++++-- modules/TitleCard.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/modules/Font.py b/modules/Font.py index 478a44e8..ada5795a 100755 --- a/modules/Font.py +++ b/modules/Font.py @@ -74,7 +74,10 @@ def custom_hash(self) -> str: f'|{self.interline_spacing}|{self.kerning}|{self.stroke_width}') - def __error(self, attribute: str, value: str, description: str=None) ->None: + def __error(self, + attribute: str, + value: str, + description: Optional[str] = None) -> None: """ Print an error message for the given attribute of the given value. Also sets the valid attribute of this object to False. @@ -192,7 +195,7 @@ def reset(self) -> None: self.stroke_width = 1.0 - def get_attributes(self) -> dict[str: 'str | float']: + def get_attributes(self) -> dict[str, Any]: """ Return a dictionary of attributes for this font to be unpacked. diff --git a/modules/TitleCard.py b/modules/TitleCard.py index 0ad378ca..bec915ca 100755 --- a/modules/TitleCard.py +++ b/modules/TitleCard.py @@ -53,6 +53,7 @@ class TitleCard: 'frame': FrameTitleCard, 'generic': StandardTitleCard, 'gundam': PosterTitleCard, + 'import': TextlessTitleCard, 'ishalioh': OlivierTitleCard, 'landscape': LandscapeTitleCard, 'logo': LogoTitleCard, From a770ceb43affd70599c25ae7f0939bf10b9ffbe4 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:01:55 -0600 Subject: [PATCH 16/44] Rename font attributes --- modules/Font.py | 14 ++++++++------ modules/TitleCard.py | 34 ++++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/modules/Font.py b/modules/Font.py index ada5795a..4b0589f0 100755 --- a/modules/Font.py +++ b/modules/Font.py @@ -1,5 +1,6 @@ from pathlib import Path from re import compile as re_compile +from typing import Any, Optional from modules.Debug import log import modules.global_objects as global_objects @@ -206,13 +207,14 @@ def get_attributes(self) -> dict[str, Any]: """ return { - 'title_color': self.color, - 'font_size': self.size, + 'font_color': self.color, 'font': self.file, - 'vertical_shift': self.vertical_shift, - 'interline_spacing': self.interline_spacing, - 'kerning': self.kerning, - 'stroke_width': self.stroke_width, + 'font_file': self.file, + 'font_interline_spacing': self.interline_spacing, + 'font_kerning': self.kerning, + 'font_size': self.size, + 'font_stroke_width': self.stroke_width, + 'font_vertical_shift': self.vertical_shift, } diff --git a/modules/TitleCard.py b/modules/TitleCard.py index bec915ca..4883ff21 100755 --- a/modules/TitleCard.py +++ b/modules/TitleCard.py @@ -42,6 +42,8 @@ class TitleCard: DEFAULT_FILENAME_FORMAT = '{full_name} - S{season:02}E{episode:02}' """Default card dimensions""" + DEFAULT_WIDTH = BaseCardType.WIDTH + DEFAULT_HEIGHT = BaseCardType.HEIGHT DEFAULT_CARD_DIMENSIONS = BaseCardType.TITLE_CARD_SIZE """Mapping of card type identifiers to CardType classes""" @@ -76,7 +78,9 @@ class TitleCard: __slots__ = ('episode', 'profile', 'converted_title', 'maker', 'file') - def __init__(self, episode: 'Episode', profile: 'Profile', + def __init__(self, + episode: 'Episode', + profile: 'Profile', title_characteristics: dict[str, Any], **extra_characteristics: dict[str, Any]) -> None: """ @@ -103,22 +107,22 @@ def __init__(self, episode: 'Episode', profile: 'Profile', ) # Initialize this episode's CardType instance - args = { - 'source': episode.source, - 'output_file': episode.destination, - 'title': self.converted_title, + kwargs = { + 'source_file': episode.source, + 'card_file': episode.destination, + 'title_text': self.converted_title, 'season_text': profile.get_season_text(self.episode.episode_info), 'episode_text': profile.get_episode_text(self.episode), - 'hide_season': profile.hide_season_title, + 'hide_season_text': profile.hide_season_title, 'blur': episode.blur, - 'watched': episode.watched, 'grayscale': episode.grayscale, + 'watched': episode.watched, } | profile.font.get_attributes() \ | self.episode.episode_info.indices \ | extra_characteristics try: - self.maker = self.episode.card_class(**args) + self.maker = self.episode.card_class(**kwargs) except Exception as e: log.exception(f'Cannot initialize Card for {self.episode} - {e}', e) self.maker = None @@ -128,8 +132,11 @@ def __init__(self, episode: 'Episode', profile: 'Profile', @staticmethod - def get_output_filename(format_string: str, series_info: 'SeriesInfo', - episode_info: 'EpisodeInfo', media_directory: Path) -> Path: + def get_output_filename( + format_string: str, + series_info: 'SeriesInfo', + episode_info: 'EpisodeInfo', + media_directory: Path) -> Path: """ Get the output filename for a title card described by the given values. @@ -171,8 +178,11 @@ def get_output_filename(format_string: str, series_info: 'SeriesInfo', @staticmethod - def get_multi_output_filename(format_string: str, series_info: 'SeriesInfo', - multi_episode: 'MultiEpisode', media_directory: Path) -> Path: + def get_multi_output_filename( + format_string: str, + series_info: 'SeriesInfo', + multi_episode: 'MultiEpisode', + media_directory: Path) -> Path: """ Get the output filename for a title card described by the given values, and that represents a range of Episodes (not just one). From f689139658a59af0e198e19a01c3ed6d9447a29f Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:02:37 -0600 Subject: [PATCH 17/44] Rework TintedGlassTitleCard card variables --- modules/cards/TintedGlassTitleCard.py | 131 ++++++++++++-------------- 1 file changed, 59 insertions(+), 72 deletions(-) diff --git a/modules/cards/TintedGlassTitleCard.py b/modules/cards/TintedGlassTitleCard.py index 046ec7a3..2cab2296 100755 --- a/modules/cards/TintedGlassTitleCard.py +++ b/modules/cards/TintedGlassTitleCard.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Any, Literal, Optional -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.Debug import log SeriesExtra = Optional @@ -78,72 +78,52 @@ class TintedGlassTitleCard(BaseCardType): TEXT_BLUR_PROFILE = '0x6' __slots__ = ( - 'source', 'output_file', 'title', '__line_count', 'season_text', - 'episode_text', 'hide_season', 'font', 'font_size', 'title_color', - 'interline_spacing', 'kerning', 'vertical_shift', + 'source', 'output_file', 'title_text', '__line_count', 'episode_text', + 'hide_episode_text', 'font_file', 'font_size', 'font_color', + 'font_interline_spacing', 'font_kerning', 'font_vertical_shift', 'episode_text_color', 'episode_text_position', 'box_adjustments', ) - def __init__(self, source: Path, output_file: Path, title: str, - season_text: str, episode_text: str, hide_season: bool, - font: str, title_color: str, - font_size: float=1.0, - interline_spacing: int=0, - kerning: float=1.0, - vertical_shift: int=0, - blur: bool=False, - grayscale: bool=False, - episode_text_color: SeriesExtra[str]=EPISODE_TEXT_COLOR, - episode_text_position: SeriesExtra[Position]='center', - box_adjustments: SeriesExtra[str]=None, + def __init__(self, + source_file: Path, + card_file: Path, + title_text: str, + episode_text: str, + hide_episode_text: bool = False, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_interline_spacing: int = 0, + font_kerning: float = 1.0, + font_size: float = 1.0, + font_vertical_shift: int = 0, + blur: bool = False, + grayscale: bool = False, + episode_text_color: SeriesExtra[str] = EPISODE_TEXT_COLOR, + episode_text_position: SeriesExtra[Position] = 'center', + box_adjustments: SeriesExtra[str] = None, + preferences: 'Preferences' = None, **unused) -> None: - """ - Initialize this TitleCard object. - - Args: - source: Source image to base the card on. - output_file: Output file where to create the card. - title: Title text to add to created card. - season_text: The season text for this card. - episode_text: Episode text to add to created card. - hide_season: Whether to hide the season text. - font: Font name or path (as string) to use for episode title. - title_color: Color to use for title text. - font_size: Scalar to apply to title font size. - interline_spacing: Pixel count to adjust title interline - spacing by. - kerning: Scalar to apply to kerning of the title text. - vertical_shift: Vertical shift to apply to the title text. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - box_adjustments: How to adjust the bounds of the bounding - box. Given as a string of pixels in clockwise order - relative to the center. For example, "10 10 10 10" will - expand the box by 10 pixels in each direction. - unused: Unused arguments. - """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Store object attributes - self.source = source - self.output_file = output_file + self.source = source_file + self.output_file = card_file - self.title = self.image_magick.escape_chars(title) - self.__line_count = len(title.split('\n')) - self.season_text = self.image_magick.escape_chars(season_text.upper()) + self.title_text = self.image_magick.escape_chars(title_text) + self.__line_count = len(title_text.split('\n')) self.episode_text = self.image_magick.escape_chars(episode_text.upper()) - self.hide_season = hide_season + self.hide_episode_text = hide_episode_text or len(episode_text) == 0 - self.font = font + self.font_file = font_file self.font_size = font_size - self.title_color = title_color - self.interline_spacing = interline_spacing - self.kerning = kerning - self.vertical_shift = vertical_shift + self.font_color = font_color + self.font_interline_spacing = font_interline_spacing + self.font_kerning = font_kerning + self.font_vertical_shift = font_vertical_shift - # Store extras + # Store and validate extras self.episode_text_color = episode_text_color # Validate episode text position @@ -174,7 +154,7 @@ def __init__(self, source: Path, output_file: Path, title: str, def blur_rectangle_command(self, coordinates: BoxCoordinates, - rounding_radius: int) -> list[str]: + rounding_radius: int) -> ImageMagickCommands: """ Get the commands necessary to blur and darken a rectangle encompassing the given coordinates. @@ -213,7 +193,7 @@ def blur_rectangle_command(self, @property - def add_title_text_command(self) -> list[str]: + def add_title_text_command(self) -> ImageMagickCommands: """ Get the ImageMagick commands necessary to add the title text described by this card. @@ -223,19 +203,19 @@ def add_title_text_command(self) -> list[str]: """ font_size = 200 * self.font_size - kerning = -5 * self.kerning - interline_spacing = -50 + self.interline_spacing - vertical_shift = 300 + self.vertical_shift + kerning = -5 * self.font_kerning + interline_spacing = -50 + self.font_interline_spacing + vertical_shift = 300 + self.font_vertical_shift return [ f'-gravity south', - f'-font "{self.font}"', + f'-font "{self.font_file}"', f'-pointsize {font_size}', f'-interline-spacing {interline_spacing}', f'-kerning {kerning}', f'-interword-spacing 40', - f'-fill "{self.title_color}"', - f'-annotate +0+{vertical_shift} "{self.title}"', + f'-fill "{self.font_color}"', + f'-annotate +0+{vertical_shift} "{self.title_text}"', ] @@ -248,8 +228,9 @@ def get_title_box_coordinates(self) -> BoxCoordinates: """ # Get dimensions of text - since text is stacked, do max/sum operations - width, height = self.get_text_dimensions(self.add_title_text_command, - width='max', height='sum') + width, height = self.get_text_dimensions( + self.add_title_text_command, width='max', height='sum' + ) # Get start coordinates of the bounding box x_start, x_end = self.WIDTH/2 - width/2, self.WIDTH/2 + width/2 @@ -261,8 +242,8 @@ def get_title_box_coordinates(self) -> BoxCoordinates: y_start += 12 # Shift y coordinates by vertical shift - y_start += self.vertical_shift - y_end += self.vertical_shift + y_start += self.font_vertical_shift + y_end += self.font_vertical_shift # Adjust upper bounds of box if title is multi-line y_start += (65 * (self.__line_count-1)) if self.__line_count > 1 else 0 @@ -277,7 +258,7 @@ def get_title_box_coordinates(self) -> BoxCoordinates: def add_episode_text_command(self, - title_coordinates: BoxCoordinates) -> list[str]: + title_coordinates: BoxCoordinates) -> ImageMagickCommands: """ Get the list of ImageMagick commands to add episode text. @@ -289,6 +270,10 @@ def add_episode_text_command(self, List of ImageMagik commands. """ + # If hidden, return blank command + if self.hide_episode_text: + return [] + # Determine text position if self.episode_text_position == 'center': gravity = 'south' @@ -310,8 +295,9 @@ def add_episode_text_command(self, f'-annotate {position} "{self.episode_text}"', ] - width, height = self.get_text_dimensions(command, - width='max', height='max') + width, height = self.get_text_dimensions( + command, width='max', height='max' + ) # Center positioning requires padding adjustment if self.episode_text_position == 'center': @@ -381,7 +367,8 @@ def is_custom_font(font: 'Font') -> bool: or (font.interline_spacing != 0) or (font.kerning != 1.0) or (font.size != 1.0) - or (font.vertical_shift != 0)) + or (font.vertical_shift != 0) + ) @staticmethod @@ -404,8 +391,8 @@ def is_custom_season_titles( 'S{season_number} E{episode_number}' ) - return (custom_episode_map or - episode_text_format not in standard_etfs) + return (custom_episode_map + or episode_text_format not in standard_etfs) def create(self): From 62342a6fc82dae3345f09759f19ca91a5078067b Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:03:06 -0600 Subject: [PATCH 18/44] Rework TextlessTitleCard card variables --- modules/cards/TextlessTitleCard.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/modules/cards/TextlessTitleCard.py b/modules/cards/TextlessTitleCard.py index d2118ad9..000f9e21 100755 --- a/modules/cards/TextlessTitleCard.py +++ b/modules/cards/TextlessTitleCard.py @@ -1,6 +1,6 @@ from pathlib import Path -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.Debug import log class TextlessTitleCard(BaseCardType): @@ -52,27 +52,23 @@ class TextlessTitleCard(BaseCardType): __slots__ = ('source_file', 'output_file') - def __init__(self, source: Path, output_file: Path, + def __init__(self, + source_file: Path, + card_file: Path, blur: bool = False, grayscale: bool = False, + preferences: 'Preferences' = None, **unused) -> None: """ Construct a new instance of this card. - - Args: - source: Source image. - output_file: Output file. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - unused: Unused arguments. """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Store input/output files - self.source_file = source - self.output_file = output_file + self.source_file = source_file + self.output_file = card_file @staticmethod @@ -92,8 +88,8 @@ def is_custom_font(font: 'Font') -> bool: @staticmethod - def is_custom_season_titles(custom_episode_map: bool, - episode_text_format: str) -> bool: + def is_custom_season_titles( + custom_episode_map: bool, episode_text_format: str) -> bool: """ Determines whether the given attributes constitute custom or generic season titles. From 35a3b94b25f77139f817f37b74821192953612c6 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:03:27 -0600 Subject: [PATCH 19/44] Rework StarWarsTitleCard card variables --- modules/cards/StarWarsTitleCard.py | 48 ++++++++++++++---------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/modules/cards/StarWarsTitleCard.py b/modules/cards/StarWarsTitleCard.py index dcccd4a1..35293c69 100755 --- a/modules/cards/StarWarsTitleCard.py +++ b/modules/cards/StarWarsTitleCard.py @@ -4,7 +4,7 @@ from num2words import num2words -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.Debug import log SeriesExtra = Optional @@ -12,7 +12,7 @@ class StarWarsTitleCard(BaseCardType): """ This class describes a type of ImageMaker that produces title cards - in the theme of Star Wars cards as designed by reddit user + in the theme of Star Wars cards as designed by Reddit user /u/Olivier_286. """ @@ -66,41 +66,37 @@ class StarWarsTitleCard(BaseCardType): __SOURCE_WITH_STARS = BaseCardType.TEMP_DIR / 'source_gradient.png' __slots__ = ( - 'source_file', 'output_file', 'title', 'hide_episode_text', - 'episode_prefix', 'episode_text', 'blur' + 'source_file', 'output_file', 'title_text', 'episode_text', + 'hide_episode_text', 'episode_prefix', ) - def __init__(self, source: Path, output_file: Path, title: str, + def __init__(self, + source_file: Path, + card_file: Path, + title_text: str, episode_text: str, - blur: bool=False, - grayscale: bool=False, + hide_episode_text: bool = False, + blur: bool = False, + grayscale: bool = False, + preferences: 'Preferences' = None, **unused) -> None: """ Initialize the CardType object. - - Args: - source: Source image for this card. - output_file: Output filepath for this card. - title: The title for this card. - episode_text: The episode text for this card. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - kwargs: Unused arguments. """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Store source and output file - self.source_file = source - self.output_file = output_file + self.source_file = source_file + self.output_file = card_file # Store episode title - self.title = self.image_magick.escape_chars(title.upper()) + self.title_text = self.image_magick.escape_chars(title_text.upper()) # Modify episode text to remove "Episode"-like text, replace numbers # with text, strip spaces, and convert to uppercase - self.hide_episode_text = len(episode_text) == 0 + self.hide_episode_text = hide_episode_text or len(episode_text) == 0 if self.hide_episode_text: self.episode_prefix = None self.episode_text = self.image_magick.escape_chars(episode_text) @@ -194,11 +190,11 @@ def __add_title_text(self) -> list: f'-kerning 0.5', f'-interline-spacing 20', f'-fill "{self.TITLE_COLOR}"', - f'-annotate +320+829 "{self.title}"', + f'-annotate +320+829 "{self.title_text}"', ] - def __add_episode_prefix(self) -> list: + def __add_episode_prefix(self) -> ImageMagickCommands: """ ImageMagick commands to add the episode prefix text to an image. This is either "EPISODE" or "CHAPTER". @@ -217,7 +213,7 @@ def __add_episode_prefix(self) -> list: ] - def __add_episode_number_text(self) -> list: + def __add_episode_number_text(self) -> ImageMagickCommands: """ ImageMagick commands to add the episode text to an image. @@ -306,8 +302,8 @@ def is_custom_font(font: 'Font') -> bool: @staticmethod - def is_custom_season_titles(custom_episode_map: bool, - episode_text_format: str) -> bool: + def is_custom_season_titles( + custom_episode_map: bool, episode_text_format: str) -> bool: """ Determines whether the given attributes constitute custom or generic season titles. From 744f2de8da0071923c82aec0d06bbc23c21cd1f0 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:03:57 -0600 Subject: [PATCH 20/44] Rework AnimeTitleCard card variables --- modules/cards/AnimeTitleCard.py | 150 ++++++++++++++------------------ 1 file changed, 66 insertions(+), 84 deletions(-) diff --git a/modules/cards/AnimeTitleCard.py b/modules/cards/AnimeTitleCard.py index 209240ba..c1646cca 100755 --- a/modules/cards/AnimeTitleCard.py +++ b/modules/cards/AnimeTitleCard.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Any, Optional -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.Debug import log SeriesExtra = Optional @@ -80,27 +80,29 @@ class AnimeTitleCard(BaseCardType): SERIES_COUNT_TEXT_COLOR = '#CFCFCF' __slots__ = ( - 'source_file', 'output_file', 'title', 'kanji', 'use_kanji', - 'require_kanji', 'kanji_vertical_shift', 'season_text', 'episode_text', - 'hide_season', 'separator', 'font', 'font_size', 'font_color', - 'vertical_shift', 'interline_spacing', 'kerning', 'stroke_width', - 'omit_gradient', 'stroke_color', + 'source_file', 'output_file', 'title_text', 'season_text', + 'episode_text', 'hide_season_text', 'hide_episode_text', 'font_color', + 'font_file', 'font_kerning', 'font_size', 'font_stroke_width', + 'font_interline_spacing', 'font_vertical_shift', 'omit_gradient', + 'stroke_color', 'separator', 'kanji', 'use_kanji', 'require_kanji', + 'kanji_vertical_shift', ) def __init__(self, *, - source: Path, - output_file: Path, - title: str, + source_file: Path, + card_file: Path, + title_text: str, season_text: str, episode_text: str, - hide_season: bool = False, - font: str = TITLE_FONT, - title_color: str = TITLE_COLOR, + hide_season_text: bool = False, + hide_episode_text: bool = False, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_interline_spacing: int = 0, + font_kerning: float = 1.0, font_size: float = 1.0, - interline_spacing: int = 0, - kerning: float = 1.0, - stroke_width: float = 1.0, - vertical_shift: int = 0, + font_stroke_width: float = 1.0, + font_vertical_shift: int = 0, blur: bool = False, grayscale: bool = False, kanji: SeriesExtra[str] = None, @@ -109,48 +111,22 @@ def __init__(self, *, require_kanji: SeriesExtra[bool] = False, kanji_vertical_shift: SeriesExtra[float] = 0, stroke_color: SeriesExtra[str] = 'black', + preferences: 'Preferences' = None, **unused) -> None: - """ - Construct a new instance of this card. - - Args: - source: Source image for this card. - output_file: Output filepath for this card. - title: The title for this card. - season_text: The season text for this card. - episode_text: The episode text for this card. - font: Font name or path (as string) to use for episode title. - font_size: Scalar to apply to the title font size. - title_color: Color to use for title text. - hide_season: Whether to hide the season text. - vertical_shift: Vertical shift to apply to the title and - kanji text. - interline_spacing: Offset to interline spacing of title text - kerning: Scalar to apply to kerning of the title text. - stroke_width: Scalar to apply to stroke. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - kanji: Kanji text to place above the episode title. - separator: Character to use to separate season/episode text. - omit_gradient: Whether to omit the gradient overlay. - require_kanji: Whether to require kanji for this card. - kanji_vertical_shift: Vertical shift to apply to kanji text. - stroke_color: Color to use for the back-stroke color. - unused: Unused arguments. - """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Store source and output file - self.source_file = source - self.output_file = output_file + self.source_file = source_file + self.output_file = card_file # Escape title, season, and episode text - self.hide_season = hide_season - self.title = self.image_magick.escape_chars(title) + self.title_text = self.image_magick.escape_chars(title_text) self.season_text = self.image_magick.escape_chars(season_text.upper()) self.episode_text = self.image_magick.escape_chars(episode_text.upper()) + self.hide_season_text = hide_season_text or len(season_text) == 0 + self.hide_episode_text = hide_episode_text or len(episode_text) == 0 # Store kanji, set bool for whether to use it or not self.kanji = self.image_magick.escape_chars(kanji) @@ -159,13 +135,13 @@ def __init__(self, *, self.kanji_vertical_shift = float(kanji_vertical_shift) # Font customizations - self.font = font + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing + self.font_kerning = font_kerning self.font_size = font_size - self.font_color = title_color - self.vertical_shift = vertical_shift - self.interline_spacing = interline_spacing - self.kerning = kerning - self.stroke_width = stroke_width + self.font_stroke_width = font_stroke_width + self.font_vertical_shift = font_vertical_shift # Optional extras self.separator = separator @@ -174,7 +150,7 @@ def __init__(self, *, @property - def __title_text_global_effects(self) -> list: + def __title_text_global_effects(self) -> ImageMagickCommands: """ ImageMagick commands to implement the title text's global effects. Specifically the the font, kerning, fontsize, and @@ -184,12 +160,12 @@ def __title_text_global_effects(self) -> list: List of ImageMagick commands. """ - kerning = 2.0 * self.kerning - interline_spacing = -30 + self.interline_spacing + kerning = 2.0 * self.font_kerning + interline_spacing = -30 + self.font_interline_spacing font_size = 150 * self.font_size return [ - f'-font "{self.font}"', + f'-font "{self.font_file}"', f'-kerning {kerning}', f'-interline-spacing {interline_spacing}', f'-pointsize {font_size}', @@ -198,7 +174,7 @@ def __title_text_global_effects(self) -> list: @property - def __title_text_black_stroke(self) -> list[str]: + def __title_text_black_stroke(self) -> ImageMagickCommands: """ ImageMagick commands to implement the title text's black stroke. @@ -207,10 +183,10 @@ def __title_text_black_stroke(self) -> list[str]: """ # No stroke, return empty command - if self.stroke_width == 0: + if self.font_stroke_width == 0: return [] - stroke_width = 5 * self.stroke_width + stroke_width = 5 * self.font_stroke_width return [ f'-fill "{self.stroke_color}"', @@ -220,7 +196,7 @@ def __title_text_black_stroke(self) -> list[str]: @property - def __title_text_effects(self) -> list[str]: + def __title_text_effects(self) -> ImageMagickCommands: """ ImageMagick commands to implement the title text's standard effects. @@ -237,7 +213,7 @@ def __title_text_effects(self) -> list[str]: @property - def __series_count_text_global_effects(self) -> list[str]: + def __series_count_text_global_effects(self) -> ImageMagickCommands: """ ImageMagick commands for global text effects applied to all series count text (season/episode count and dot). @@ -256,7 +232,7 @@ def __series_count_text_global_effects(self) -> list[str]: @property - def __series_count_text_black_stroke(self) -> list: + def __series_count_text_black_stroke(self) -> ImageMagickCommands: """ ImageMagick commands for adding the necessary black stroke effects to series count text. @@ -273,7 +249,7 @@ def __series_count_text_black_stroke(self) -> list: @property - def title_text_command(self) -> list[str]: + def title_text_command(self) -> ImageMagickCommands: """ Subcommand for adding title text to the source image. @@ -282,21 +258,21 @@ def title_text_command(self) -> list[str]: """ # Base offset for the title text - base_offset = 175 + self.vertical_shift + base_offset = 175 + self.font_vertical_shift # If adding kanji, add additional annotate commands for kanji if self.use_kanji: - linecount = len(self.title.split('\n')) - 1 - variable_offset = 200 + ((165 + self.interline_spacing) * linecount) + linecount = len(self.title_text.split('\n')) - 1 + variable_offset = 200 + ((165 + self.font_interline_spacing) * linecount) kanji_offset = base_offset + variable_offset * self.font_size - kanji_offset += self.vertical_shift + self.kanji_vertical_shift + kanji_offset += self.font_vertical_shift + self.kanji_vertical_shift return [ *self.__title_text_global_effects, *self.__title_text_black_stroke, - f'-annotate +75+{base_offset} "{self.title}"', + f'-annotate +75+{base_offset} "{self.title_text}"', *self.__title_text_effects, - f'-annotate +75+{base_offset} "{self.title}"', + f'-annotate +75+{base_offset} "{self.title_text}"', f'-font "{self.KANJI_FONT.resolve()}"', *self.__title_text_black_stroke, f'-pointsize {85 * self.font_size}', @@ -309,14 +285,14 @@ def title_text_command(self) -> list[str]: return [ *self.__title_text_global_effects, *self.__title_text_black_stroke, - f'-annotate +75+{base_offset} "{self.title}"', + f'-annotate +75+{base_offset} "{self.title_text}"', *self.__title_text_effects, - f'-annotate +75+{base_offset} "{self.title}"', + f'-annotate +75+{base_offset} "{self.title_text}"', ] @property - def index_text_command(self) -> list[str]: + def index_text_command(self) -> ImageMagickCommands: """ Subcommand for adding the index text to the source image. @@ -324,16 +300,21 @@ def index_text_command(self) -> list[str]: List of ImageMagick commands. """ - # Add only episode text using annotate - if self.hide_season: + # Hiding all index text, return blank commands + if self.hide_season_text and self.hide_episode_text: + return [] + + # Add only season or episode text + if self.hide_season_text or self.hide_episode_text: + text = self.episode_text if self.hide_season_text else self.season_text return [ *self.__series_count_text_global_effects, *self.__series_count_text_black_stroke, - f'-annotate +75+90 "{self.episode_text}"', + f'-annotate +75+90 "{text}"', f'-fill "{self.SERIES_COUNT_TEXT_COLOR}"', f'-stroke "{self.SERIES_COUNT_TEXT_COLOR}"', f'-strokewidth 0', - f'-annotate +75+90 "{self.episode_text}"', + f'-annotate +75+90 "{text}"', ] # Add season and episode text @@ -406,13 +387,14 @@ def is_custom_font(font: 'Font') -> bool: True if a custom font is indicated, False otherwise. """ - return ((font.file != AnimeTitleCard.TITLE_FONT) - or (font.size != 1.0) - or (font.color != AnimeTitleCard.TITLE_COLOR) - or (font.vertical_shift != 0) + return ((font.color != AnimeTitleCard.TITLE_COLOR) + or (font.file != AnimeTitleCard.TITLE_FONT) or (font.interline_spacing != 0) or (font.kerning != 1.0) - or (font.stroke_width != 1.0)) + or (font.size != 1.0) + or (font.stroke_width != 1.0) + or (font.vertical_shift != 0) + ) @staticmethod From 50fc1ffabf2c50de183f5e786f26df4be7fe927a Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:04:29 -0600 Subject: [PATCH 21/44] Rework CutoutTitleCard card variables --- modules/cards/CutoutTitleCard.py | 89 ++++++++++++-------------------- 1 file changed, 33 insertions(+), 56 deletions(-) diff --git a/modules/cards/CutoutTitleCard.py b/modules/cards/CutoutTitleCard.py index 3236f387..66d58373 100755 --- a/modules/cards/CutoutTitleCard.py +++ b/modules/cards/CutoutTitleCard.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Optional -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.Debug import log SeriesExtra = Optional @@ -67,70 +67,48 @@ class CutoutTitleCard(BaseCardType): NUMBER_BLUR_PROFILE = '0x10' __slots__ = ( - 'source_file', 'output_file', 'title', 'episode_text', 'font', - 'font_size', 'title_color', 'vertical_shift', 'interline_spacing', - 'kerning', 'overlay_color', 'blur_edges', + 'source_file', 'output_file', 'title_text', 'episode_text', + 'font_color', 'font_file', 'font_size', 'font_vertical_shift', + 'overlay_color', 'blur_edges', ) - def __init__(self, source: Path, output_file: Path, title: str, - episode_text: str, font: str, font_size: float, - title_color: str, - vertical_shift: int=0, - interline_spacing: int=0, - kerning: float=1.0, - blur: bool=False, - grayscale: bool=False, - overlay_color: SeriesExtra[str]='black', - blur_edges: SeriesExtra[bool]=False, + def __init__(self, + source_file: Path, + card_file: Path, + title_text: str, + episode_text: str, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_size: float = 1.0, + font_vertical_shift: int = 0, + blur: bool = False, + grayscale: bool = False, + overlay_color: SeriesExtra[str] = 'black', + blur_edges: SeriesExtra[bool] = False, + preferences: 'Preferences' = None, **unused) -> None: """ - Construct a new instance of this card. - - Args: - source: Source image to base the card on. - output_file: Output file where to create the card. - title: Title text to add to created card. - season_text: Season text to add to created card. - episode_text: Episode text to add to created card. - font: Font name or path (as string) to use for episode - title. - font_size: Scalar to apply to title font size. - title_color: Color to use for title text. - hide_season: Whether to ignore season_text. - vertical_shift: Pixel count to adjust the title vertical - offset by. - interline_spacing: Pixel count to adjust title interline - spacing by. - kerning: Scalar to apply to kerning of the title text. - stroke_width: Scalar to apply to black stroke of the title - text. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - overlay_color: Color to use for the solid overlay. - blur_edges: Whether to blur edges of the number overlay. - unused: Unused arguments. + Construct a new instance of this Card. """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) - self.source_file = source - self.output_file = output_file + self.source_file = source_file + self.output_file = card_file # Ensure characters that need to be escaped are # Format episode text to split into 1/2 lines depending on word count - self.title = self.image_magick.escape_chars(title) + self.title_text = self.image_magick.escape_chars(title_text) self.episode_text = self.image_magick.escape_chars( self._format_episode_text(episode_text).upper() ) # Font/card customizations - self.font = font + self.font_color = font_color + self.font_file = font_file self.font_size = font_size - self.title_color = title_color - self.vertical_shift = vertical_shift - self.interline_spacing = interline_spacing - self.kerning = kerning + self.font_vertical_shift = font_vertical_shift # Optional extras self.overlay_color = overlay_color @@ -177,12 +155,11 @@ def is_custom_font(font: 'Font') -> bool: True if a custom font is indicated, False otherwise. """ - return ((font.file != CutoutTitleCard.TITLE_FONT) + return ((font.color != CutoutTitleCard.TITLE_COLOR) + or (font.file != CutoutTitleCard.TITLE_FONT) or (font.size != 1.0) - or (font.color != CutoutTitleCard.TITLE_COLOR) or (font.vertical_shift != 0) - or (font.interline_spacing != 0) - or (font.kerning != 1.0)) + ) @staticmethod @@ -240,11 +217,11 @@ def create(self) -> None: f'-composite', # Add title text f'-gravity south', - f'-pointsize 50', + f'-pointsize {50 * self.font_size}', f'+interline-spacing', - f'-fill "{self.title_color}"', - f'-font "{self.font}"', - f'-annotate +0+{100+self.vertical_shift} "{self.title}"', + f'-fill "{self.font_color}"', + f'-font "{self.font_file}"', + f'-annotate +0+{100 + self.font_vertical_shift} "{self.title_text}"', # Create card *self.resize_output, f'"{self.output_file.resolve()}"', From 16c5545d88c679ff0712b0ace8d1e688778a2bd9 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:04:52 -0600 Subject: [PATCH 22/44] Rework FadeTitleCard card variables --- modules/cards/FadeTitleCard.py | 118 ++++++++++++++------------------- 1 file changed, 50 insertions(+), 68 deletions(-) diff --git a/modules/cards/FadeTitleCard.py b/modules/cards/FadeTitleCard.py index 6a06492f..b26a2409 100755 --- a/modules/cards/FadeTitleCard.py +++ b/modules/cards/FadeTitleCard.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Optional -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.CleanPath import CleanPath from modules.Debug import log @@ -65,60 +65,43 @@ class FadeTitleCard(BaseCardType): __OVERLAY = REF_DIRECTORY / 'gradient_fade.png' __slots__ = ( - 'source_file', 'output_file', 'title', 'index_text', 'font', - 'font_size', 'title_color', 'interline_spacing', 'kerning', - 'vertical_shift', 'logo', 'episode_text_color', + 'source_file', 'output_file', 'title_text', 'index_text', 'font_file', + 'font_size', 'font_color', 'font_interline_spacing', 'font_kerning', + 'font_vertical_shift', 'logo', 'episode_text_color', ) - def __init__(self, source: Path, output_file: Path, title: str, - season_text: str, episode_text: str, hide_season: bool, - font: str, title_color: str, - interline_spacing: int=0, - kerning: float=1.0, - font_size: float=1.0, - vertical_shift: int=0, - season_number: int=1, - episode_number: int=1, - blur: bool=False, - grayscale: bool=False, - logo: SeriesExtra[str]=None, - episode_text_color: SeriesExtra[str]=EPISODE_TEXT_COLOR, - separator: SeriesExtra[str]='•', + def __init__(self, + source_file: Path, + card_file: Path, + title_text: str, + season_text: str, + episode_text: str, + hide_season_text: bool = False, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_interline_spacing: int = 0, + font_kerning: float = 1.0, + font_size: float = 1.0, + font_vertical_shift: int = 0, + season_number: int = 1, + episode_number: int = 1, + blur: bool = False, + grayscale: bool = False, + logo: SeriesExtra[str] = None, + episode_text_color: SeriesExtra[str] = EPISODE_TEXT_COLOR, + separator: SeriesExtra[str] = '•', + preferences: 'Preferences' = None, **unused) -> None: """ - Construct a new instance of this card. - - Args: - source: Source image to base the card on. - output_file: Output file where to create the card. - title: Title text to add to created card. - season_text: The season text for this card. - episode_text: Episode text to add to created card. - hide_season: Whether to hide the season text. - font: Font name or path (as string) to use for episode title. - title_color: Color to use for title text. - interline_spacing: Pixel count to adjust title interline - spacing by. - kerning: Scalar to apply to kerning of the title text. - font_size: Scalar to apply to title font size. - vertical_shift: Pixel count to adjust the title vertical - offset by. - season_number: Season number for logo-file formatting. - episode_number: Episode number for logo-file formatting. - logo: Filepath (or file format) to the logo file. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - episode_text_color: Color to use for the episode text. - separator: Character to use to separate season and episode text. - unused: Unused arguments. + Construct a new instance of this Card. """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Store source and output file - self.source_file = source - self.output_file = output_file + self.source_file = source_file + self.output_file = card_file # Find logo file if indicated if logo is None: @@ -138,34 +121,34 @@ def __init__(self, source: Path, output_file: Path, title: str, if logo.exists(): self.logo = logo # Try to find logo alongside source image - elif (source.parent / logo.name).exists(): - self.logo = source.parent / logo.name + elif (self.source_file.parent / logo.name).exists(): + self.logo = self.source_file.parent / logo.name # Assume non-existent explicitly specified filename else: self.logo = logo # Store attributes of the text - self.title = self.image_magick.escape_chars(title) - if hide_season: + self.title_text = self.image_magick.escape_chars(title_text) + if hide_season_text: self.index_text=self.image_magick.escape_chars(episode_text.upper()) else: index_text = f'{season_text} {separator} {episode_text}'.upper() self.index_text = self.image_magick.escape_chars(index_text) # Font customizations - self.font = font + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing + self.font_kerning = font_kerning self.font_size = font_size - self.interline_spacing = interline_spacing - self.kerning = kerning - self.title_color = title_color - self.vertical_shift = vertical_shift + self.font_vertical_shift = font_vertical_shift # Extras self.episode_text_color = episode_text_color @property - def add_logo(self) -> list[str]: + def add_logo(self) -> ImageMagickCommands: """ Subcommand to add the logo file to the source image. @@ -181,15 +164,13 @@ def add_logo(self) -> list[str]: f'\( "{self.logo.resolve()}"', f'-resize 900x', f'-resize x500\> \)', - # f'-gravity south', - # f'-geometry -1000+1200', f'-gravity west -geometry +100-550', f'-composite', ] @property - def add_title_text(self) -> list[str]: + def add_title_text(self) -> ImageMagickCommands: """ Subcommand to add the title text to the source image. @@ -198,27 +179,27 @@ def add_title_text(self) -> list[str]: """ # No title, return blank command - if len(self.title) == 0: + if len(self.title_text) == 0: return [] size = 115 * self.font_size - interline_spacing = -20 + self.interline_spacing - kerning = 5 * self.kerning - vertical_shift = 800 + self.vertical_shift + interline_spacing = -20 + self.font_interline_spacing + kerning = 5 * self.font_kerning + vertical_shift = 800 + self.font_vertical_shift return [ f'-gravity northwest', - f'-font "{self.font}"', + f'-font "{self.font_file}"', f'-pointsize {size}', f'-kerning {kerning}', f'-interline-spacing {interline_spacing}', - f'-fill "{self.title_color}"', - f'-annotate +100+{vertical_shift} "{self.title}"', + f'-fill "{self.font_color}"', + f'-annotate +100+{vertical_shift} "{self.title_text}"', ] @property - def add_index_text(self) -> list[str]: + def add_index_text(self) -> ImageMagickCommands: """ Subcommand to add the index text to the source image. @@ -258,7 +239,8 @@ def is_custom_font(font: 'Font') -> bool: or (font.interline_spacing != 0) or (font.kerning != 1.0) or (font.size != 1.0) - or (font.vertical_shift != 0)) + or (font.vertical_shift != 0) + ) @staticmethod From b232ad3156381367383a7f3fe6d607693420bfc5 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:05:07 -0600 Subject: [PATCH 23/44] Rework FrameTitleCard card variables --- modules/cards/FrameTitleCard.py | 116 ++++++++++++++------------------ 1 file changed, 52 insertions(+), 64 deletions(-) diff --git a/modules/cards/FrameTitleCard.py b/modules/cards/FrameTitleCard.py index 8e4459b6..3d52d8c7 100755 --- a/modules/cards/FrameTitleCard.py +++ b/modules/cards/FrameTitleCard.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Literal, Optional -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.Debug import log SeriesExtra = Optional @@ -68,72 +68,58 @@ class FrameTitleCard(BaseCardType): __FRAME_IMAGE = REF_DIRECTORY / 'frame.png' __slots__ = ( - 'source_file', 'output_file', 'title', 'season_text', 'episode_text', - 'hide_season', 'hide_episode', 'font', 'font_size', 'font_color', - 'vertical_shift', 'interline_spacing', 'kerning', 'episode_text_color', - 'episode_text_position', + 'source_file', 'output_file', 'title_text', 'season_text', + 'episode_text', 'hide_season', 'hide_episode', 'font_color', + 'font_file', 'font_interline_spacing', 'font_kerning', 'font_size', + 'font_vertical_shift', 'episode_text_color', 'episode_text_position', ) - def __init__(self, source: Path, output_file: Path, title: str, - season_text: str, episode_text: str, hide_season: bool, - font: str, title_color: str, - font_size: float=1.0, - vertical_shift: int=0, - interline_spacing: int=0, - kerning: float=1.0, - blur: bool=False, - grayscale: bool=False, - episode_text_color: SeriesExtra[str]=EPISODE_TEXT_COLOR, - episode_text_position: Position='surround', + def __init__(self, *, + source_file: Path, + card_file: Path, + title_text: str, + season_text: str, + episode_text: str, + hide_season_text: bool = False, + hide_episode_text: bool = False, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_interline_spacing: int = 0, + font_kerning: float = 1.0, + font_size: float = 1.0, + font_vertical_shift: int = 0, + blur: bool = False, + grayscale: bool = False, + episode_text_color: SeriesExtra[str] = EPISODE_TEXT_COLOR, + episode_text_position: Position = 'surround', + preferences: 'Preferences' = None, **unused) -> None: """ - Construct a new instance. - - Args: - source_file: Source image for this card. - card_file: Output filepath for this card. - title: The title for this card. - season_text: The season text for this card. - episode_text: The episode text for this card. - font: Font name or path (as string) to use for episode title. - font_size: Scalar to apply to the title font size. - title_color: Color to use for title text. - hide_season: Whether to hide the season text on this card. - vertical_shift: Vertical shift to apply to the title text. - interline_spacing: Offset to interline spacing of the title - text. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - episode_text_color: Custom color to utilize for the episode - text. - episode_text_position: How to position the episode text - relative to the title text. - unused: Unused arguments. + Construct a new instance of this Card. """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Store source and output file - self.source_file = source - self.output_file = output_file + self.source_file = source_file + self.output_file = card_file # Escape title, season, and episode text prep = lambda s: s.upper().strip() - self.title = self.image_magick.escape_chars(title) + self.title_text = self.image_magick.escape_chars(title_text) self.season_text = self.image_magick.escape_chars(prep(season_text)) self.episode_text = self.image_magick.escape_chars(prep(episode_text)) - - self.hide_season = hide_season or len(self.season_text) == 0 - self.hide_episode = len(self.episode_text) == 0 + self.hide_season = hide_season_text or len(self.season_text) == 0 + self.hide_episode = hide_episode_text or len(self.episode_text) == 0 # Font customizations - self.font = font + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing + self.font_kerning = font_kerning self.font_size = font_size - self.font_color = title_color - self.vertical_shift = vertical_shift - self.interline_spacing = interline_spacing - self.kerning = kerning + self.font_vertical_shift = font_vertical_shift # Verify/store extras self.episode_text_color = episode_text_color @@ -155,11 +141,11 @@ def _title_font_attributes(self) -> list[str]: """ title_size = 125 * self.font_size - interline_spacing = -45 + self.interline_spacing - kerning = 5 * self.kerning + interline_spacing = -45 + self.font_interline_spacing + kerning = 5 * self.font_kerning return [ - f'-font "{self.font}"', + f'-font "{self.font_file}"', f'-fill "{self.font_color}"', f'-pointsize {title_size}', f'-interline-spacing {interline_spacing}', @@ -196,12 +182,12 @@ def text_command(self) -> list[str]: """ # Command to add only the title to the source image - vertical_shift = 675 + self.vertical_shift + vertical_shift = 675 + self.font_vertical_shift title_only_command = [ # Set font attributes for title text *self._title_font_attributes, # Add title text - f'-annotate +0+{vertical_shift} "{self.title}"', + f'-annotate +0+{vertical_shift} "{self.title_text}"', ] # If no index text is being added, only add title @@ -210,8 +196,9 @@ def text_command(self) -> list[str]: # If adding season and/or episode text and title.. # Get width of title text for positioning - width, _ = self.get_text_dimensions(title_only_command, - width='max', height='sum') + width, _ = self.get_text_dimensions( + title_only_command, width='max', height='sum' + ) offset = 3200/2 + width/2 + 25 # Add index text to left or right @@ -279,13 +266,14 @@ def is_custom_font(font: 'Font') -> bool: True if a custom font is indicated, False otherwise. """ - return ((font.file != FrameTitleCard.TITLE_FONT) + return ((font.color != FrameTitleCard.TITLE_COLOR) + or (font.file != FrameTitleCard.TITLE_FONT) + or (font.kerning != 1.0) + or (font.interline_spacing != 0) or (font.size != 1.0) - or (font.color != FrameTitleCard.TITLE_COLOR) + or (font.stroke_width != 1.0) or (font.vertical_shift != 0) - or (font.interline_spacing != 0) - or (font.kerning != 1.0) - or (font.stroke_width != 1.0)) + ) @staticmethod @@ -305,8 +293,8 @@ def is_custom_season_titles( standard_etf = FrameTitleCard.EPISODE_TEXT_FORMAT.upper() - return (custom_episode_map or - episode_text_format.upper() != standard_etf) + return (custom_episode_map + or episode_text_format.upper() != standard_etf) def create(self) -> None: From d46d285957da5391c41128f8c7166a72b8239897 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:05:23 -0600 Subject: [PATCH 24/44] Rework LandscapeTitleCard card variables --- modules/cards/LandscapeTitleCard.py | 132 +++++++++++++--------------- 1 file changed, 60 insertions(+), 72 deletions(-) diff --git a/modules/cards/LandscapeTitleCard.py b/modules/cards/LandscapeTitleCard.py index 0503dba5..e5146a62 100755 --- a/modules/cards/LandscapeTitleCard.py +++ b/modules/cards/LandscapeTitleCard.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Any, Literal, Optional, Union -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.Debug import log SeriesExtra = Optional @@ -79,62 +79,45 @@ class LandscapeTitleCard(BaseCardType): DARKEN_COLOR = '#00000030' __slots__ = ( - 'source', 'output_file', 'title', 'font', 'font_size', 'title_color', - 'interline_spacing', 'kerning', 'vertical_shift', 'darken', - 'add_bounding_box', 'box_adjustments' + 'source_file', 'output_file', 'title_text', 'font_color', 'font_file', + 'font_interline_spacing', 'font_kerning', 'font_size', + 'font_vertical_shift', 'darken', 'add_bounding_box', 'box_adjustments' ) - def __init__(self, source: Path, output_file: Path, title: str, font: str, - title_color: str, - font_size: float=1.0, - interline_spacing: int=0, - kerning: float=1.0, - blur: bool=False, - grayscale: bool=False, - vertical_shift: float=0, - darken: DarkenOption=False, - add_bounding_box: SeriesExtra[bool]=False, - box_adjustments: SeriesExtra[str]=None, + def __init__(self, + source_file: Path, + card_file: Path, + title_text: str, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_interline_spacing: int = 0, + font_size: float = 1.0, + font_kerning: float = 1.0, + font_vertical_shift: float = 0, + blur: bool = False, + grayscale: bool = False, + darken: DarkenOption = False, + add_bounding_box: SeriesExtra[bool] = False, + box_adjustments: SeriesExtra[str] = None, + preferences: 'Preferences' = None, **unused) ->None: """ - Initialize this TitleCard object. - - Args: - source: Source image to base the card on. - output_file: Output file where to create the card. - title: Title text to add to created card. - font: Font name or path (as string) to use for episode title. - title_color: Color to use for title text. - interline_spacing: Pixel count to adjust title interline - spacing by. - kerning: Scalar to apply to kerning of the title text. - font_size: Scalar to apply to title font size. - vertical_shift: Vertical shift to apply to the title text. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - darken: Whether to darken the image (if not blurred). - add_bounding_box: Whether to add a bounding box around the - title text. - box_adjustments: How to adjust the bounds of the bounding - box. Given as a string of pixels in clockwise order - relative to the center. For example, "10 10 10 10" will - expand the box by 10 pixels in each direction. - unused: Unused arguments. + Construct a new instance of this Card. """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Store object attributes - self.source = source - self.output_file = output_file - self.title = self.image_magick.escape_chars(title) - self.font = font + self.source_file = source_file + self.output_file = card_file + self.title_text = self.image_magick.escape_chars(title_text) + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing self.font_size = font_size - self.title_color = title_color - self.interline_spacing = interline_spacing - self.kerning = kerning - self.vertical_shift = vertical_shift + self.font_kerning = font_kerning + self.font_vertical_shift = font_vertical_shift # Store extras self.add_bounding_box = add_bounding_box @@ -169,7 +152,7 @@ def __init__(self, source: Path, output_file: Path, title: str, font: str, self.valid = False - def darken_command(self, coordinates: BoxCoordinates) -> list[str]: + def darken_command(self, coordinates: BoxCoordinates) ->ImageMagickCommands: """ Subcommand to darken the image if indicated. @@ -208,7 +191,7 @@ def __add_no_title(self) -> None: """Only resize and apply style to this source image.""" command = ' '.join([ - f'convert "{self.source.resolve()}"', + f'convert "{self.source_file.resolve()}"', *self.resize_and_style, *self.darken_command((0, 0, 0, 0)), f'"{self.output_file.resolve()}"', @@ -239,19 +222,20 @@ def get_bounding_box_coordinates(self, # Text-relevant commands text_command = [ - f'-font "{self.font}"', + f'-font "{self.font_file}"', f'-pointsize {font_size}', f'-gravity center', f'-interline-spacing {interline_spacing}', f'-kerning {kerning}', f'-interword-spacing 40', - f'-fill "{self.title_color}"', - f'label:"{self.title}"', + f'-fill "{self.font_color}"', + f'label:"{self.title_text}"', ] # Get dimensions of text - since text is stacked, do max/sum operations - width, height = self.get_text_dimensions(text_command, - width='max', height='sum') + width, height = self.get_text_dimensions( + text_command, width='max', height='sum' + ) # Get start coordinates of the bounding box x_start, x_end = 3200/2 - width/2, 3200/2 + width/2 @@ -259,8 +243,8 @@ def get_bounding_box_coordinates(self, y_end -= 35 # Additional offset necessary for things to work out # Shift y coordinates by vertical shift - y_start += self.vertical_shift - y_end += self.vertical_shift + y_start += self.font_vertical_shift + y_end += self.font_vertical_shift # Adjust corodinates by spacing and manual adjustments x_start -= self.BOUNDING_BOX_SPACING + self.box_adjustments[3] @@ -271,7 +255,8 @@ def get_bounding_box_coordinates(self, return BoxCoordinates(x_start, y_start, x_end, y_end) - def add_bounding_box_command(self, coordinates: BoxCoordinates) ->list[str]: + def add_bounding_box_command(self, + coordinates: BoxCoordinates) -> ImageMagickCommands: """ Subcommand to add the bounding box around the title text. @@ -296,7 +281,7 @@ def add_bounding_box_command(self, coordinates: BoxCoordinates) ->list[str]: # Create bounding box f'-fill transparent', f'-strokewidth 10', - f'-stroke "{self.title_color}"', + f'-stroke "{self.font_color}"', f'-draw "rectangle {x_start},{y_start},{x_end},{y_end}"', # Create shadow of the bounding box f'\( +clone', @@ -313,8 +298,10 @@ def add_bounding_box_command(self, coordinates: BoxCoordinates) ->list[str]: @staticmethod - def modify_extras(extras: dict[str, Any], custom_font: bool, - custom_season_titles: bool) -> None: + def modify_extras( + extras: dict[str, Any], + custom_font: bool, + custom_season_titles: bool) -> None: """ Modify the given extras base on whether font or season titles are custom. @@ -344,11 +331,12 @@ def is_custom_font(font: 'Font') -> bool: True if the given font is custom, False otherwise. """ - return ((font.file != LandscapeTitleCard.TITLE_FONT) - or (font.size != 1.0) - or (font.color != LandscapeTitleCard.TITLE_COLOR) + return ((font.color != LandscapeTitleCard.TITLE_COLOR) + or (font.file != LandscapeTitleCard.TITLE_FONT) or (font.interline_spacing != 0) - or (font.kerning != 1.0)) + or (font.kerning != 1.0) + or (font.size != 1.0) + ) @staticmethod @@ -376,14 +364,14 @@ def create(self): """ # If title is 0-length, just stylize - if len(self.title.strip()) == 0: + if len(self.title_text.strip()) == 0: self.__add_no_title() return None # Scale font size and interline spacing of roman text font_size = int(150 * self.font_size) - interline_spacing = int(60 * self.interline_spacing) - kerning = int(40 * self.kerning) + interline_spacing = int(60 * self.font_interline_spacing) + kerning = int(40 * self.font_kerning) # Get coordinates for bounding box bounding_box = self.get_bounding_box_coordinates( @@ -392,20 +380,20 @@ def create(self): # Generate command to create card command = ' '.join([ - f'convert "{self.source.resolve()}"', + f'convert "{self.source_file.resolve()}"', # Resize and apply any style modifiers *self.resize_and_style, *self.darken_command(bounding_box), # Add title text f'\( -background None', - f'-font "{self.font}"', + f'-font "{self.font_file}"', f'-pointsize {font_size}', f'-gravity center', f'-interline-spacing {interline_spacing}', f'-kerning {kerning}', f'-interword-spacing 40', - f'-fill "{self.title_color}"', - f'label:"{self.title}"', + f'-fill "{self.font_color}"', + f'label:"{self.title_text}"', # Create drop shadow of title text f'\( +clone', f'-background None', @@ -417,7 +405,7 @@ def create(self): f'+repage \)', # Add title image(s) to source # Shift images vertically by indicated shift - f'-geometry +0+{self.vertical_shift}', + f'-geometry +0+{self.font_vertical_shift}', f'-composite', # Optionally add bounding box *self.add_bounding_box_command(bounding_box), From 5ed3605a6c6c9ed87877d6f37851c5c44268cdb2 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:05:38 -0600 Subject: [PATCH 25/44] Rework LogoTitleCard card variables --- modules/cards/LogoTitleCard.py | 126 +++++++++++++-------------------- 1 file changed, 49 insertions(+), 77 deletions(-) diff --git a/modules/cards/LogoTitleCard.py b/modules/cards/LogoTitleCard.py index 90848ac1..f00df5e1 100755 --- a/modules/cards/LogoTitleCard.py +++ b/modules/cards/LogoTitleCard.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Optional -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.CleanPath import CleanPath from modules.Debug import log @@ -81,27 +81,28 @@ class LogoTitleCard(BaseCardType): __GRADIENT_IMAGE = REF_DIRECTORY / 'GRADIENT.png' __slots__ = ( - 'source_file', 'output_file', 'title', 'season_text', 'episode_text', - 'font', 'font_size', 'title_color', 'hide_season', 'separator', 'blur', - 'vertical_shift', 'interline_spacing', 'kerning', 'stroke_width', - 'logo', 'omit_gradient', 'background', 'stroke_color', - 'use_background_image', 'blur_only_image', + 'source_file', 'output_file', 'title_text', 'season_text', + 'episode_text', 'hide_season_text', 'font_color', 'font_file', + 'font_kerning', 'font_interline_spacing', 'font_size', + 'font_stroke_width', 'font_vertical_shift', 'separator', 'logo', + 'omit_gradient', 'background', 'stroke_color', 'use_background_image', + 'blur_only_image', ) def __init__(self, - output_file: Path, - title: str, + card_file: Path, + title_text: str, season_text: str, episode_text: str, - source: Optional[Path] = None, - hide_season: bool = False, - font: str = TITLE_FONT, - title_color: str = TITLE_COLOR, + source_file: Optional[Path] = None, + hide_season_text: bool = False, + font_file: str = TITLE_FONT, + font_color: str = TITLE_COLOR, font_size: float = 1.0, - kerning: float = 1.0, - interline_spacing: int = 0, - stroke_width: float = 1.0, - vertical_shift: int = 0, + font_kerning: float = 1.0, + font_interline_spacing: int = 0, + font_stroke_width: float = 1.0, + font_vertical_shift: int = 0, season_number: int = 1, episode_number: int = 1, blur: bool = False, @@ -113,43 +114,14 @@ def __init__(self, omit_gradient: SeriesExtra[bool] = True, use_background_image: SeriesExtra[bool] = False, blur_only_image: SeriesExtra[bool] = False, + preferences: 'Preferences' = None, **unused) -> None: """ Construct a new instance of this card. - - Args: - output_file: Output file. - title: Episode title. - season_text: Text to use as season count text. Ignored if - hide_season is True. - episode_text: Text to use as episode count text. - hide_season: Whether to omit the season text (and joining - character) from the title card completely. - font: Font to use for the episode title. - title_color: Color to use for the episode title. - interline_spacing: Pixels to adjust title interline spacing. - stroke_width: Scalar to apply to stroke of title text. - kerning: Scalar to apply to kerning of the title text. - font_size: Scalar to apply to the title font size. - vertical_shift: Pixels to adjust title vertical shift by. - season_number: Season number for logo-file formatting. - episode_number: Episode number for logo-file formatting. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - logo: Filepath (or file format) to the logo file. - background: Backround color. - separator: Character to use to separate season/episode text. - stroke_color: Color to use for the back-stroke color. - omit_gradient: Whether to omit the gradient overlay. - use_background_image: Whether to use a background image - instead of a solid background color. - blur_only_image: Whether the blur attribute applies to the - source image _only_, or the logo as well. - unused: Unused arguments. """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Look for logo if it's a format string if logo is None: @@ -166,28 +138,28 @@ def __init__(self, # Get source file if indicated self.use_background_image = use_background_image self.blur_only_image = blur_only_image - self.source_file = source + self.source_file = source_file if self.use_background_image and self.source_file is None: - log.error(f'Source file must be provided if using a background' - f'image') + log.error(f'Source file must be provided if using a background ' + f'image') self.valid = False - self.output_file = output_file + self.output_file = card_file # Ensure characters that need to be escaped are - self.title = self.image_magick.escape_chars(title) + self.title_text = self.image_magick.escape_chars(title_text) self.season_text = self.image_magick.escape_chars(season_text.upper()) self.episode_text = self.image_magick.escape_chars(episode_text.upper()) - self.hide_season = hide_season + self.hide_season_text = hide_season_text or len(season_text) == 0 # Font attributes - self.font = font + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing + self.font_kerning = font_kerning self.font_size = font_size - self.title_color = title_color - self.vertical_shift = vertical_shift - self.interline_spacing = interline_spacing - self.kerning = kerning - self.stroke_width = stroke_width + self.font_stroke_width = font_stroke_width + self.font_vertical_shift = font_vertical_shift # Optional extras self.omit_gradient = omit_gradient @@ -218,7 +190,7 @@ def resize_logo(self) -> Path: @property - def index_command(self) -> list[str]: + def index_command(self) -> ImageMagickCommands: """ Subcommand for adding the index text to the source image. @@ -227,7 +199,7 @@ def index_command(self) -> list[str]: """ # Sub-command for adding season/episode text - if self.hide_season: + if self.hide_season_text: return [ f'-kerning 5.42', f'-pointsize 67.75', @@ -296,14 +268,14 @@ def is_custom_font(font: 'Font') -> bool: True if a custom font is indicated, False otherwise. """ - return ((font.file != LogoTitleCard.TITLE_FONT) - or (font.size != 1.0) - or (font.color != LogoTitleCard.TITLE_COLOR) - or (font.replacements != LogoTitleCard.FONT_REPLACEMENTS) - or (font.vertical_shift != 0) + return ((font.color != LogoTitleCard.TITLE_COLOR) + or (font.file != LogoTitleCard.TITLE_FONT) or (font.interline_spacing != 0) or (font.kerning != 1.0) - or (font.stroke_width != 1.0)) + or (font.size != 1.0) + or (font.stroke_width != 1.0) + or (font.vertical_shift != 0) + ) @staticmethod @@ -323,8 +295,8 @@ def is_custom_season_titles( standard_etf = LogoTitleCard.EPISODE_TEXT_FORMAT.upper() - return (custom_episode_map or - episode_text_format.upper() != standard_etf) + return (custom_episode_map + or episode_text_format.upper() != standard_etf) def create(self) -> None: @@ -352,11 +324,11 @@ def create(self) -> None: offset = 60 + ((1030 - height) // 2) # Font customizations - vertical_shift = 245 + self.vertical_shift + vertical_shift = 245 + self.font_vertical_shift font_size = 157.41 * self.font_size - interline_spacing = -22 + self.interline_spacing - kerning = -1.25 * self.kerning - stroke_width = 3.0 * self.stroke_width + interline_spacing = -22 + self.font_interline_spacing + kerning = -1.25 * self.font_kerning + stroke_width = 3.0 * self.font_stroke_width # Sub-command to add source file or create colored background if self.use_background_image: @@ -408,7 +380,7 @@ def create(self) -> None: *style_command, # Global title text options f'-gravity south', - f'-font "{self.font}"', + f'-font "{self.font_file}"', f'-kerning {kerning}', f'-interword-spacing 50', f'-interline-spacing {interline_spacing}', @@ -417,10 +389,10 @@ def create(self) -> None: f'-fill "{self.stroke_color}"', f'-stroke "{self.stroke_color}"', f'-strokewidth {stroke_width}', - f'-annotate +0+{vertical_shift} "{self.title}"', + f'-annotate +0+{vertical_shift} "{self.title_text}"', # Title text - f'-fill "{self.title_color}"', - f'-annotate +0+{vertical_shift} "{self.title}"', + f'-fill "{self.font_color}"', + f'-annotate +0+{vertical_shift} "{self.title_text}"', # Add episode or season+episode "image" *self.index_command, # Create card From 4dcde6a9475cd39aec135503b890517f10a4941d Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:05:56 -0600 Subject: [PATCH 26/44] Rework OlivierTitleCard card variables --- modules/cards/OlivierTitleCard.py | 122 ++++++++++++++---------------- 1 file changed, 55 insertions(+), 67 deletions(-) diff --git a/modules/cards/OlivierTitleCard.py b/modules/cards/OlivierTitleCard.py index 800e3b9d..b563a6bc 100755 --- a/modules/cards/OlivierTitleCard.py +++ b/modules/cards/OlivierTitleCard.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Any, Optional -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.Debug import log SeriesExtra = Optional @@ -62,62 +62,49 @@ class OlivierTitleCard(BaseCardType): ARCHIVE_NAME = 'Olivier Style' __slots__ = ( - 'source_file', 'output_file', 'title', 'hide_episode_text', - 'episode_prefix', 'episode_text', 'font', 'title_color', - 'episode_text_color', 'font_size', 'stroke_width', 'kerning', - 'vertical_shift', 'interline_spacing', 'stroke_color', + 'source_file', 'output_file', 'title_text', 'hide_episode_text', + 'episode_prefix', 'episode_text', 'font_color', 'font_file', + 'font_interline_spacing', 'font_kerning', 'font_size', + 'font_stroke_width', 'font_vertical_shift', 'stroke_color', + 'episode_text_color', ) - def __init__(self, source: Path, output_file: Path, title: str, - episode_text: str, font: str, title_color: str, - font_size: float=1.0, - stroke_width: float=1.0, - vertical_shift: int=0, - interline_spacing: int=0, - kerning: float=1.0, - blur: bool=False, - grayscale: bool=False, - episode_text_color: SeriesExtra[str]=EPISODE_TEXT_COLOR, - stroke_color: SeriesExtra[str]='black', + def __init__(self, + source_file: Path, + card_file: Path, + title_text: str, + episode_text: str, + hide_episode_text: bool = False, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_interline_spacing: int = 0, + font_size: float = 1.0, + font_stroke_width: float = 1.0, + font_vertical_shift: int = 0, + font_kerning: float = 1.0, + blur: bool = False, + grayscale: bool = False, + episode_text_color: SeriesExtra[str] = EPISODE_TEXT_COLOR, + stroke_color: SeriesExtra[str] = 'black', + preferences: 'Preferences' = None, **unused) -> None: """ Construct a new instance of this card. - - Args: - source: Source image to base the card on. - output_file: Output file where to create the card. - title: Title text to add to created card. - episode_text: Episode text to add to created card. - font: Font name or path (as string) to use for episode title. - title_color: Color to use for title text. - interline_spacing: Pixel count to adjust title interline - spacing by. - kerning: Scalar to apply to kerning of the title text. - font_size: Scalar to apply to title font size. - stroke_width: Scalar to apply to black stroke of the title - text. - vertical_shift: Pixel count to adjust the title vertical - offset by. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - episode_text_color: Color to use for the episode text. - stroke_color: Color to use for the back-stroke color. - unused: Unused arguments. """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Store source and output file - self.source_file = source - self.output_file = output_file + self.source_file = source_file + self.output_file = card_file # Store attributes of the text - self.title = self.image_magick.escape_chars(title) - self.hide_episode_text = len(episode_text) == 0 + self.title_text = self.image_magick.escape_chars(title_text) # Determine episode prefix, modify text to remove prefix self.episode_prefix = None + self.hide_episode_text = hide_episode_text or len(episode_text) == 0 if not self.hide_episode_text and ' ' in episode_text: prefix, number = episode_text.split(' ', 1) self.episode_prefix = prefix.upper() @@ -127,13 +114,13 @@ def __init__(self, source: Path, output_file: Path, title: str, self.episode_text = self.image_magick.escape_chars(episode_text.upper()) # Font customizations - self.font = font - self.title_color = title_color + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing + self.font_kerning = font_kerning self.font_size = font_size - self.stroke_width = stroke_width - self.vertical_shift = vertical_shift - self.interline_spacing = interline_spacing - self.kerning = kerning + self.font_stroke_width = font_stroke_width + self.font_vertical_shift = font_vertical_shift # Optional extras self.episode_text_color = episode_text_color @@ -141,7 +128,7 @@ def __init__(self, source: Path, output_file: Path, title: str, @property - def title_text_command(self) -> list[str]: + def title_text_command(self) -> ImageMagickCommands: """ Get the ImageMagick commands to add the episode title text to an image. @@ -151,13 +138,13 @@ def title_text_command(self) -> list[str]: """ font_size = 124 * self.font_size - stroke_width = 8.0 * self.stroke_width - kerning = 0.5 * self.kerning - interline_spacing = -20 + self.interline_spacing - vertical_shift = 785 + self.vertical_shift + stroke_width = 8.0 * self.font_stroke_width + kerning = 0.5 * self.font_kerning + interline_spacing = -20 + self.font_interline_spacing + vertical_shift = 785 + self.font_vertical_shift return [ - f'\( -font "{self.font}"', + f'\( -font "{self.font_file}"', f'-gravity northwest', f'-pointsize {font_size}', f'-kerning {kerning}', @@ -165,16 +152,16 @@ def title_text_command(self) -> list[str]: f'-fill "{self.stroke_color}"', f'-stroke "{self.stroke_color}"', f'-strokewidth {stroke_width}', - f'-annotate +320+{vertical_shift} "{self.title}" \)', - f'\( -fill "{self.title_color}"', - f'-stroke "{self.title_color}"', + f'-annotate +320+{vertical_shift} "{self.title_text}" \)', + f'\( -fill "{self.font_color}"', + f'-stroke "{self.font_color}"', f'-strokewidth 0', - f'-annotate +320+{vertical_shift} "{self.title}" \)', + f'-annotate +320+{vertical_shift} "{self.title_text}" \)', ] @property - def episode_prefix_command(self) -> list[str]: + def episode_prefix_command(self) -> ImageMagickCommands: """ Get the ImageMagick commands to add the episode prefix text to an image. @@ -204,7 +191,7 @@ def episode_prefix_command(self) -> list[str]: @property - def episode_number_text_command(self) -> list[str]: + def episode_number_text_command(self) -> ImageMagickCommands: """ Get the ImageMagick commands to add the episode number text to an image. @@ -249,8 +236,8 @@ def modify_extras( custom_font: bool, custom_season_titles: bool) -> None: """ - Modify the given extras based on whether font or season titles are - custom. + Modify the given extras based on whether font or season titles + are custom. Args: extras: Dictionary to modify. @@ -280,13 +267,14 @@ def is_custom_font(font: 'Font') -> bool: True if a custom font is indicated, False otherwise. """ - return ((font.file != OlivierTitleCard.TITLE_FONT) - or (font.size != 1.0) - or (font.color != OlivierTitleCard.TITLE_COLOR) - or (font.vertical_shift != 0) + return ((font.color != OlivierTitleCard.TITLE_COLOR) + or (font.file != OlivierTitleCard.TITLE_FONT) or (font.interline_spacing != 0) or (font.kerning != 1.0) - or (font.stroke_width != 1.0)) + or (font.size != 1.0) + or (font.stroke_width != 1.0) + or (font.vertical_shift != 0) + ) @staticmethod From dbb17e8f1634e7dd1d3151932638ed7ca34bf654 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:06:25 -0600 Subject: [PATCH 27/44] Rework PosterTitleCard card variables --- modules/cards/PosterTitleCard.py | 46 +++++++++++++------------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/modules/cards/PosterTitleCard.py b/modules/cards/PosterTitleCard.py index a0ef704d..64b45d40 100755 --- a/modules/cards/PosterTitleCard.py +++ b/modules/cards/PosterTitleCard.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Optional -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.CleanPath import CleanPath from modules.Debug import log @@ -67,41 +67,33 @@ class PosterTitleCard(BaseCardType): """Path to the reference star image to overlay on all source images""" __GRADIENT_OVERLAY = REF_DIRECTORY / 'stars-overlay.png' - __slots__ = ('source_file', 'output_file', 'logo', 'title', 'episode_text') + __slots__ = ( + 'source_file', 'output_file', 'logo', 'title_text', 'episode_text' + ) - def __init__(self, source: Path, output_file: Path, title: str, + def __init__(self, + source_file: Path, + card_file: Path, + title_text: str, episode_text: str, - blur: bool=False, - grayscale: bool=False, - season_number: int=1, - episode_number: int=1, - logo: SeriesExtra[str]=None, + blur: bool = False, + grayscale: bool = False, + season_number: int = 1, + episode_number: int = 1, + logo: SeriesExtra[str] = None, + preferences: 'Preferences' = None, **unused) -> None: """ Construct a new instance of this card. - - Args: - source: Source image for this card. - output_file: Output filepath for this card. - title: The title for this card. - episode_text: The episode text for this card. - season_number: Season number of the episode associated with - this card. - episode_number: Episode number of the episode associated - with this card. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - logo: Filepath (or file format) to the logo file. - unused: Unused arguments. """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Store source and output file - self.source_file = source - self.output_file = output_file + self.source_file = source_file + self.output_file = card_file # No logo file specified if logo is None: @@ -128,7 +120,7 @@ def __init__(self, source: Path, output_file: Path, title: str, self.logo = logo # Store text - self.title = self.image_magick.escape_chars(title.upper()) + self.title_text = self.image_magick.escape_chars(title_text.upper()) self.episode_text = self.image_magick.escape_chars(episode_text) @@ -223,7 +215,7 @@ def create(self) -> None: f'-gravity center', f'-pointsize 165', f'-interline-spacing -40', - f'-annotate +649+{title_offset} "{self.title}"', + f'-annotate +649+{title_offset} "{self.title_text}"', # Create card *self.resize_output, f'"{self.output_file.resolve()}"', From 555ead200eaf380ad92f27f64498f1904cbaeb2e Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:06:45 -0600 Subject: [PATCH 28/44] Rework RomanNumeralTitleCard card variables --- modules/cards/RomanNumeralTitleCard.py | 61 +++++++++++++++----------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/modules/cards/RomanNumeralTitleCard.py b/modules/cards/RomanNumeralTitleCard.py index 782e6d55..d6a78fcf 100755 --- a/modules/cards/RomanNumeralTitleCard.py +++ b/modules/cards/RomanNumeralTitleCard.py @@ -4,7 +4,7 @@ from re import compile as re_compile from typing import Any, Optional -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.Debug import log SeriesExtra = Optional @@ -244,18 +244,24 @@ class RomanNumeralTitleCard(BaseCardType): SEASON_TEXT_PLACEMENT_ATTEMPS = 10 __slots__ = ( - 'output_file', 'title', 'season_text', 'hide_season', 'title_color', - 'background', 'blur', 'roman_numeral_color', 'roman_numeral', + 'output_file', 'title_text', 'season_text', 'hide_season_text', + 'font_color', 'background', 'roman_numeral_color', 'roman_numeral', '__roman_text_scalar', '__roman_numeral_lines', 'rotation', 'offset', ) - def __init__(self, output_file: Path, title: str, season_text: str, - episode_text: str, hide_season: bool, title_color: str, - episode_number: int=1, - blur: bool=False, - grayscale: bool=False, - background: SeriesExtra[str]=BACKGROUND_COLOR, - roman_numeral_color: SeriesExtra[str]=ROMAN_NUMERAL_TEXT_COLOR, + def __init__(self, + card_file: Path, + title_text: str, + season_text: str, + episode_text: str, + hide_season_text: bool = False, + font_color: str = TITLE_COLOR, + episode_number: int = 1, + blur: bool = False, + grayscale: bool = False, + background: SeriesExtra[str] = BACKGROUND_COLOR, + roman_numeral_color: SeriesExtra[str] = ROMAN_NUMERAL_TEXT_COLOR, + preferences: 'Preferences' = None, **unused) -> None: """ Construct a new instance of this card. @@ -266,7 +272,7 @@ def __init__(self, output_file: Path, title: str, season_text: str, episode_text: The episode text to parse the roman numeral from. episode_number: Episode number for the roman numerals. - title_color: Color to use for the episode title. + font_color: Color to use for the episode title. blur: Whether to blur the source image. grayscale: Whether to make the source image grayscale. background: Color for the background. @@ -275,12 +281,12 @@ def __init__(self, output_file: Path, title: str, season_text: str, """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) # Store object attributes - self.output_file = output_file - self.title = self.image_magick.escape_chars(title) - self.title_color = title_color + self.output_file = card_file + self.title_text = self.image_magick.escape_chars(title_text) + self.font_color = font_color self.background = background self.roman_numeral_color = roman_numeral_color @@ -291,7 +297,7 @@ def __init__(self, output_file: Path, title: str, season_text: str, # Select roman numeral for season text self.season_text = season_text.strip().upper() - self.hide_season = hide_season or (len(self.season_text) == 0) + self.hide_season_text = hide_season_text or len(self.season_text) == 0 # Rotation and offset attributes to be determined later self.rotation, self.offset = None, None @@ -370,7 +376,8 @@ def __assign_roman_scalar(self, roman_text: list[str]) -> None: self.__roman_text_scalar = 1.0 - def create_roman_numeral_command(self, roman_numeral: str) -> list[str]: + def create_roman_numeral_command(self, + roman_numeral: str) -> ImageMagickCommands: """ Subcommand to add roman numerals to the image. @@ -392,7 +399,8 @@ def create_roman_numeral_command(self, roman_numeral: str) -> list[str]: ] - def create_season_text_command(self, rotation: str, offset: str)->list[str]: + def create_season_text_command(self, + rotation: str, offset: str) -> ImageMagickCommands: """ Generate the ImageMagick commands necessary to create season text at the given rotation and offset. @@ -407,12 +415,12 @@ def create_season_text_command(self, rotation: str, offset: str)->list[str]: List of ImageMagick commands. """ - if self.hide_season or rotation is None or offset is None: + if self.hide_season_text or rotation is None or offset is None: return [] # Override font color only if a custom background color was specified if self.background != self.BACKGROUND_COLOR: - color = self.title_color + color = self.font_color else: color = self.SEASON_TEXT_COLOR @@ -428,7 +436,7 @@ def create_season_text_command(self, rotation: str, offset: str)->list[str]: @property - def title_text_command(self) -> list[str]: + def title_text_command(self) -> ImageMagickCommands: """ Subcommand to add title text to the image. @@ -441,8 +449,8 @@ def title_text_command(self) -> list[str]: f'-pointsize 150', f'-interword-spacing 40', f'-interline-spacing 0', - f'-fill "{self.title_color}"', - f'-annotate +0+0 "{self.title}"', + f'-fill "{self.font_color}"', + f'-annotate +0+0 "{self.title_text}"', ] @@ -537,12 +545,13 @@ def place_season_text(self) -> None: """ # If season titles are hidden, exit - if self.hide_season: + if self.hide_season_text: return None # Get boundaries of title text - width, height = self.get_text_dimensions(self.title_text_command, - width='width', height='sum') + width, height = self.get_text_dimensions( + self.title_text_command, width='width', height='sum' + ) box0 = { 'start_x': -width/2 + 3200/2, 'start_y': -height/2 + 1800/2, From 9c6987994177a25bf5c0efa5e32eb77c817933cf Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:07:04 -0600 Subject: [PATCH 29/44] Rework StandardTitleCard card variables --- modules/cards/StandardTitleCard.py | 135 +++++++++++++---------------- 1 file changed, 61 insertions(+), 74 deletions(-) diff --git a/modules/cards/StandardTitleCard.py b/modules/cards/StandardTitleCard.py index a1bf19c7..fc92185c 100755 --- a/modules/cards/StandardTitleCard.py +++ b/modules/cards/StandardTitleCard.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Any, Optional -from modules.BaseCardType import BaseCardType +from modules.BaseCardType import BaseCardType, ImageMagickCommands from modules.Debug import log SeriesExtra = Optional @@ -51,8 +51,9 @@ class StandardTitleCard(BaseCardType): """Characteristics of the default title font""" TITLE_FONT = str((REF_DIRECTORY / 'Sequel-Neue.otf').resolve()) TITLE_COLOR = '#EBEBEB' - FONT_REPLACEMENTS = {'[': '(', ']': ')', '(': '[', ')': ']', '―': '-', - '…': '...', '“': '"'} + FONT_REPLACEMENTS = { + '[': '(', ']': ')', '(': '[', ')': ']', '―': '-', '…': '...', '“': '"' + } """Whether this CardType uses season titles for archival purposes""" USES_SEASON_TITLE = True @@ -69,73 +70,58 @@ class StandardTitleCard(BaseCardType): __GRADIENT_IMAGE = REF_DIRECTORY / 'GRADIENT.png' __slots__ = ( - 'source_file', 'output_file', 'title', 'season_text', 'episode_text', - 'font', 'font_size', 'title_color', 'hide_season', 'separator', - 'vertical_shift', 'interline_spacing', 'kerning', 'stroke_width', - 'omit_gradient', 'stroke_color', + 'source_file', 'output_file', 'title_text', 'season_text', + 'episode_text', 'hide_season_text', 'font_color', 'font_file', + 'font_interline_spacing', 'font_kerning', 'font_size', + 'font_stroke_width', 'font_vertical_shift', 'omit_gradient', + 'stroke_color', 'separator', ) - def __init__(self, source: Path, output_file: Path, title: str, - season_text: str, episode_text: str, hide_season: bool, - font: str, title_color: str, - font_size: float=1.0, - interline_spacing: int=0, - kerning: float=1.0, - stroke_width: float=1.0, - vertical_shift: int=0, - blur: bool=False, - grayscale: bool=False, - separator: Optional[str]='•', - stroke_color: Optional[str]='black', - omit_gradient: Optional[bool]=False, + def __init__(self, + source_file: Path, + card_file: Path, + title_text: str, + season_text: str, + episode_text: str, + hide_season_text: bool = False, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_interline_spacing: int = 0, + font_kerning: float = 1.0, + font_size: float = 1.0, + font_stroke_width: float = 1.0, + font_vertical_shift: int = 0, + blur: bool = False, + grayscale: bool = False, + separator: SeriesExtra[str] = '•', + stroke_color: SeriesExtra[str] = 'black', + omit_gradient: SeriesExtra[bool] = False, + preferences: 'Preferences' = None, **unused) -> None: """ Construct a new instance of this card. - - Args: - source: Source image to base the card on. - output_file: Output file where to create the card. - title: Title text to add to created card. - season_text: Season text to add to created card. - episode_text: Episode text to add to created card. - font: Font name or path (as string) to use for episode title. - font_size: Scalar to apply to title font size. - title_color: Color to use for title text. - hide_season: Whether to ignore season_text. - vertical_shift: Pixel count to adjust the title vertical - offset by. - interline_spacing: Pixel count to adjust title interline - spacing by. - kerning: Scalar to apply to kerning of the title text. - stroke_width: Scalar to apply to black stroke. - blur: Whether to blur the source image. - grayscale: Whether to make the source image grayscale. - separator: Character to use to separate season/episode text. - omit_gradient: Whether to omit the gradient overlay. - stroke_color: Color to use for the back-stroke color. - unused: Unused arguments. """ # Initialize the parent class - this sets up an ImageMagickInterface - super().__init__(blur, grayscale) + super().__init__(blur, grayscale, preferences=preferences) - self.source_file = source - self.output_file = output_file + self.source_file = source_file + self.output_file = card_file # Ensure characters that need to be escaped are - self.title = self.image_magick.escape_chars(title) + self.title_text = self.image_magick.escape_chars(title_text) self.season_text = self.image_magick.escape_chars(season_text.upper()) self.episode_text = self.image_magick.escape_chars(episode_text.upper()) + self.hide_season_text = hide_season_text or len(season_text) == 0 # Font/card customizations - self.font = font + self.font_color = font_color + self.font_file = font_file + self.font_kerning = font_kerning + self.font_interline_spacing = font_interline_spacing self.font_size = font_size - self.title_color = title_color - self.hide_season = hide_season - self.vertical_shift = vertical_shift - self.interline_spacing = interline_spacing - self.kerning = kerning - self.stroke_width = stroke_width + self.font_stroke_width = font_stroke_width + self.font_vertical_shift = font_vertical_shift # Optional extras self.separator = separator @@ -153,7 +139,7 @@ def index_command(self) -> list[str]: """ # Sub-command for adding season/episode text - if self.hide_season: + if self.hide_season_text: return [ f'-kerning 5.42', f'-pointsize 67.75', @@ -219,17 +205,17 @@ def black_title_command(self) -> list[str]: """ # Stroke disabled, return empty command - if self.stroke_width == 0: + if self.font_stroke_width == 0: return [] - vertical_shift = 245 + self.vertical_shift - stroke_width = 3.0 * self.stroke_width + vertical_shift = 245 + self.font_vertical_shift + stroke_width = 3.0 * self.font_stroke_width return [ f'-fill "{self.stroke_color}"', f'-stroke "{self.stroke_color}"', f'-strokewidth {stroke_width}', - f'-annotate +0+{vertical_shift} "{self.title}"', + f'-annotate +0+{vertical_shift} "{self.title_text}"', ] @@ -265,18 +251,19 @@ def is_custom_font(font: 'Font') -> bool: True if a custom font is indicated, False otherwise. """ - return ((font.file != StandardTitleCard.TITLE_FONT) + return ((font.color != StandardTitleCard.TITLE_COLOR) + or (font.file != StandardTitleCard.TITLE_FONT) + or (font.kerning != 1.0) + or (font.interline_spacing != 0) or (font.size != 1.0) - or (font.color != StandardTitleCard.TITLE_COLOR) + or (font.stroke_width != 1.0) or (font.vertical_shift != 0) - or (font.interline_spacing != 0) - or (font.kerning != 1.0) - or (font.stroke_width != 1.0)) + ) @staticmethod - def is_custom_season_titles(custom_episode_map: bool, - episode_text_format: str) -> bool: + def is_custom_season_titles( + custom_episode_map: bool, episode_text_format: str) -> bool: """ Determine whether the given attributes constitute custom or generic season titles. @@ -291,8 +278,8 @@ def is_custom_season_titles(custom_episode_map: bool, standard_etf = StandardTitleCard.EPISODE_TEXT_FORMAT.upper() - return (custom_episode_map or - episode_text_format.upper() != standard_etf) + return (custom_episode_map + or episode_text_format.upper() != standard_etf) def create(self) -> None: @@ -302,10 +289,10 @@ def create(self) -> None: """ # Font customizations - vertical_shift = 245 + self.vertical_shift + vertical_shift = 245 + self.font_vertical_shift font_size = 157.41 * self.font_size - interline_spacing = -22 + self.interline_spacing - kerning = -1.25 * self.kerning + interline_spacing = -22 + self.font_interline_spacing + kerning = -1.25 * self.font_kerning # Sub-command to optionally add gradient gradient_command = [] @@ -323,7 +310,7 @@ def create(self) -> None: *gradient_command, # Global title text options f'-gravity south', - f'-font "{self.font}"', + f'-font "{self.font_file}"', f'-kerning {kerning}', f'-interword-spacing 50', f'-interline-spacing {interline_spacing}', @@ -331,8 +318,8 @@ def create(self) -> None: # Black stroke behind title text *self.black_title_command, # Title text - f'-fill "{self.title_color}"', - f'-annotate +0+{vertical_shift} "{self.title}"', + f'-fill "{self.font_color}"', + f'-annotate +0+{vertical_shift} "{self.title_text}"', # Add episode or season+episode "image" *self.index_command, # Create card From 9c178b844a1e20cefb430b354073123568661a1b Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 16:49:56 -0600 Subject: [PATCH 30/44] Create DividerTitleCard Implements #326 (including all indicated extras) --- mini_maker.py | 37 +-- modules/TitleCard.py | 2 + modules/cards/DividerTitleCard.py | 381 ++++++++++++++++++++++++++++++ 3 files changed, 402 insertions(+), 18 deletions(-) create mode 100755 modules/cards/DividerTitleCard.py diff --git a/mini_maker.py b/mini_maker.py index a3329401..a09bfd11 100755 --- a/mini_maker.py +++ b/mini_maker.py @@ -93,7 +93,7 @@ metavar=('TITLE_LINE'), help="The title text for this card") title_card_group.add_argument( - '--font', '--font-file', + '--font-file', '--font', type=Path, default='__default', metavar='FONT_FILE', @@ -111,25 +111,25 @@ metavar='#HEX', help='A custom font color for this card') title_card_group.add_argument( - '--vertical-shift', '--shift', + '--font-vertical-shift', '--vertical-shift', type=float, default=0.0, metavar='PIXELS', help='How many pixels to vertically shift the title text') title_card_group.add_argument( - '--interline-spacing', '--spacing', + '--font-interline-spacing', '--interline-spacing', type=float, default=0.0, metavar='PIXELS', help='How many pixels to increase the interline spacing of the title text') title_card_group.add_argument( - '--kerning', + '--font-kerning', '--kerning', type=str, default='100%', metavar='SCALE%', help='Specify the font kerning scale (as percentage)') title_card_group.add_argument( - '--stroke-width', '--stroke', + '--font-stroke-width', '--stroke-width',' type=str, default='100%', metavar='SCALE%', @@ -383,8 +383,8 @@ RemoteFile.reset_loaded_database() # Override unspecified defaults with their class specific defaults - if args.font == Path('__default'): - args.font = Path(str(CardClass.TITLE_FONT)) + if args.font_file == Path('__default'): + args.font_file = Path(str(CardClass.TITLE_FONT)) if args.font_color == '__default': args.font_color = CardClass.TITLE_COLOR @@ -392,19 +392,20 @@ output_file = CleanPath(args.title_card[1]).sanitize() output_file.unlink(missing_ok=True) card = CardClass( - episode_text=args.episode, - source=Path(args.title_card[0]), - output_file=output_file, + source_file=CleanPath(args.title_card[0]).sanitize(), + card_file=output_file, + title_text='\n'.join(args.title), season_text=('' if not args.season else args.season), - title='\n'.join(args.title), - font=args.font.resolve(), + episode_text=args.episode, + hide_season_text=(not bool(args.season)), + hide_episode_text=(not bool(args.episode)), + font_color=args.font_color, + font_file=args.font_file.resolve(), + font_interline_spacing=args.font_interline_spacing, + font_kerning=float(args.font_kerning[:-1])/100.0, font_size=float(args.font_size[:-1])/100.0, - title_color=args.font_color, - hide_season=(not bool(args.season)), - vertical_shift=args.vertical_shift, - interline_spacing=args.interline_spacing, - kerning=float(args.kerning[:-1])/100.0, - stroke_width=float(args.stroke_width[:-1])/100.0, + font_stroke_width=float(args.sfont_troke_width[:-1])/100.0, + font_vertical_shift=args.font_vertical_shift, blur=args.blur, grayscale=args.grayscale, omit_gradient=args.no_gradient, diff --git a/modules/TitleCard.py b/modules/TitleCard.py index 4883ff21..382d0c8c 100755 --- a/modules/TitleCard.py +++ b/modules/TitleCard.py @@ -10,6 +10,7 @@ # Built-in BaseCardType classes from modules.cards.AnimeTitleCard import AnimeTitleCard from modules.cards.CutoutTitleCard import CutoutTitleCard +from modules.cards.DividerTitleCard import DividerTitleCard from modules.cards.FadeTitleCard import FadeTitleCard from modules.cards.FrameTitleCard import FrameTitleCard from modules.cards.LandscapeTitleCard import LandscapeTitleCard @@ -51,6 +52,7 @@ class TitleCard: CARD_TYPES = { 'anime': AnimeTitleCard, 'cutout': CutoutTitleCard, + 'divider': DividerTitleCard, 'fade': FadeTitleCard, 'frame': FrameTitleCard, 'generic': StandardTitleCard, diff --git a/modules/cards/DividerTitleCard.py b/modules/cards/DividerTitleCard.py new file mode 100755 index 00000000..aea0229d --- /dev/null +++ b/modules/cards/DividerTitleCard.py @@ -0,0 +1,381 @@ +from pathlib import Path +from typing import Any, Literal, Optional + +from modules.BaseCardType import BaseCardType, ImageMagickCommands +from modules.Debug import log + +SeriesExtra = Optional +TitleTextPosition = Literal['left', 'right'] +TextPosition = Literal[ + 'upper left', 'upper right', 'right', 'lower right', 'lower left', 'left', +] + +class DividerTitleCard(BaseCardType): + """ + This class describes a type of CardType that produces title cards + similar to the AnimeTitleCard (same font), but featuring a vertical + divider between the season and episode text. This card allows the + positioning of text on the image to be adjusted. The general design + was inspired by the title card interstitials in Overlord (season 3). + """ + + """Directory where all reference files used by this card are stored""" + REF_DIRECTORY = BaseCardType.BASE_REF_DIRECTORY / 'anime' + + """Characteristics for title splitting by this class""" + TITLE_CHARACTERISTICS = { + 'max_line_width': 18, # Character count to begin splitting titles + 'max_line_count': 4, # Maximum number of lines a title can take up + 'top_heavy': False, # This class uses bottom heavy titling + } + + """Characteristics of the default title font""" + TITLE_FONT = str((REF_DIRECTORY / 'Flanker Griffo.otf').resolve()) + TITLE_COLOR = 'white' + DEFAULT_FONT_CASE = 'source' + FONT_REPLACEMENTS = {'♡': '', '☆': '', '✕': 'x'} + + """Characteristics of the episode text""" + EPISODE_TEXT_FORMAT = 'Episode {episode_number}' + + """Whether this CardType uses season titles for archival purposes""" + USES_SEASON_TITLE = True + + """Standard class has standard archive name""" + ARCHIVE_NAME = 'Divider Style' + + __slots__ = ( + 'source_file', 'output_file', 'title_text', 'season_text', + 'episode_text', 'hide_season_text', 'hide_episode_text', 'font_color', + 'font_file', 'font_interline_spacing', 'font_kerning', 'font_size', + 'font_stroke_width', 'stroke_color', 'title_text_position', + 'text_position', + ) + + def __init__(self, *, + source_file: Path, + card_file: Path, + title_text: str, + season_text: str, + episode_text: str, + hide_season_text: bool = False, + hide_episode_text: bool = False, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_interline_spacing: int = 0, + font_kerning: float = 1.0, + font_size: float = 1.0, + font_stroke_width: float = 1.0, + blur: bool = False, + grayscale: bool = False, + stroke_color: SeriesExtra[str] = 'black', + title_text_position: TitleTextPosition = 'left', + text_position: TextPosition = 'lower right', + preferences: 'Preferences' = None, + **unused) -> None: + """ + Construct a new instance of this Card. + """ + + # Initialize the parent class - this sets up an ImageMagickInterface + super().__init__(blur, grayscale, preferences=preferences) + + self.source_file = source_file + self.output_file = card_file + + # Ensure characters that need to be escaped are + self.title_text = self.image_magick.escape_chars(title_text) + self.season_text = self.image_magick.escape_chars(season_text) + self.episode_text = self.image_magick.escape_chars(episode_text) + self.hide_season_text = hide_season_text or len(season_text) == 0 + self.hide_episode_text = hide_episode_text or len(episode_text) == 0 + + # Font/card customizations + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing + self.font_kerning = font_kerning + self.font_size = font_size + self.font_stroke_width = font_stroke_width + + # Optional extras + self.stroke_color = stroke_color + if str(title_text_position).lower() not in ('left', 'right'): + log.error(f'Invalid "title_text_position" - must be left, or right') + self.valid = False + self.title_text_position = str(title_text_position).lower() + if (str(text_position).lower() + not in ('upper left', 'upper right', 'right', 'lower left', + 'lower right', 'left')): + log.error(f'Invalid "text_position" - must be upper left, upper ' + f'right, right, lower left, lower right, or left') + self.valid = False + self.text_position = str(text_position).lower() + + + @property + def index_text_command(self) -> ImageMagickCommands: + """ + Subcommand for adding the index text to the source image. + + Returns: + List of ImageMagick commands. + """ + + gravity = 'west' if self.title_text_position == 'left' else 'east' + + # Hiding all index text, return empty command + if self.hide_season_text and self.hide_episode_text: + return [] + # Hiding season or episode text, only add that and divider bar + elif self.hide_season_text or self.hide_episode_text: + text = self.episode_text if self.hide_season_text else self.season_text + return [ + f'-gravity {gravity}', + f'-pointsize {100 * self.font_size}', + f'label:"{text}"', + ] + # Showing all text, add all text and divider + else: + return [ + f'-gravity {gravity}', + f'-pointsize {100 * self.font_size}', + f'label:"{self.season_text}\n{self.episode_text}"', + ] + + + @property + def title_text_command(self) -> ImageMagickCommands: + """ + Subcommand for adding the title text to the source image. + + Returns: + List of ImageMagick commands. + """ + + # No title text, return blank commands + if len(self.title_text) == 0: + return [] + + gravity = 'east' if self.title_text_position == 'left' else 'west' + return [ + f'-gravity {gravity}', + f'-pointsize {100 * self.font_size}', + f'label:"{self.title_text}"', + ] + + + @property + def divider_height(self) -> int: + """ + Get the height of the divider between the index and title text. + + Returns: + Height of the divider to create. + """ + + # No need for divider, use blank command + if (len(self.title_text) == 0 + or (self.hide_season_text and self.hide_episode_text)): + return 0 + + return max( + # Height of the index text + self.get_text_dimensions([ + f'-font "{self.font_file}"', + f'-interline-spacing {self.font_interline_spacing}', + *self.index_text_command, + ], width='max', height='sum' + )[1], + # Height of the title text + self.get_text_dimensions([ + f'-font "{self.font_file}"', + f'-interline-spacing {self.font_interline_spacing}', + *self.title_text_command, + ], width='max', height='sum' + )[1] + ) + + + def divider_command(self, + divider_height: int, + font_color: str) -> ImageMagickCommands: + """ + Subcommand to add the dividing rectangle to the image. + + Args: + divider_height: Height of the divider to create. + font_color: Color of the text to create the divider in. + + Returns: + List of ImageMagick commands. + """ + + # No need for divider, use blank command + if (len(self.title_text) == 0 + or (self.hide_season_text and self.hide_episode_text)): + return [] + + return [ + f'\( -size 7x{divider_height-25}', + f'xc:"{font_color}" \)', + f'+size', + f'-gravity center', + f'+smush 25', + ] + + + def text_command(self, + divider_height: int, font_color: str) -> ImageMagickCommands: + """ + Subcommand to add all text - index, title, and the divider - to + the image. + + Args: + divider_height: Height of the divider to create. + font_color: Color of the text being created. + + Returns: + List of ImageMagick commands. + """ + + # Title on left, add text as: title divider index + if self.title_text_position == 'left': + return [ + *self.title_text_command, + *self.divider_command(divider_height, font_color), + *self.index_text_command, + ] + + # Title on right, add text as index divider title + return [ + *self.index_text_command, + *self.divider_command(divider_height, font_color), + *self.title_text_command, + ] + + + @staticmethod + def modify_extras( + extras: dict[str, Any], + custom_font: bool, + custom_season_titles: bool) -> None: + """ + Modify the given extras based on whether font or season titles + are custom. + + Args: + extras: Dictionary to modify. + custom_font: Whether the font are custom. + custom_season_titles: Whether the season titles are custom. + """ + + # Generic font, reset stroke color + if not custom_font: + if 'stroke_color' in extras: + extras['stroke_color'] = 'black' + + + @staticmethod + def is_custom_font(font: 'Font') -> bool: + """ + Determine whether the given font characteristics constitute a + default or custom font. + + Args: + font: The Font being evaluated. + + Returns: + True if a custom font is indicated, False otherwise. + """ + + return ((font.color != DividerTitleCard.TITLE_COLOR) + or (font.file != DividerTitleCard.TITLE_FONT) + or (font.interline_spacing != 0) + or (font.kerning != 1.0) + or (font.size != 1.0) + or (font.stroke_width != 1.0) + ) + + + @staticmethod + def is_custom_season_titles( + custom_episode_map: bool, episode_text_format: str) -> bool: + """ + Determine whether the given attributes constitute custom or + generic season titles. + + Args: + custom_episode_map: Whether the EpisodeMap was customized. + episode_text_format: The episode text format in use. + + Returns: + True if custom season titles are indicated, False otherwise. + """ + + standard_etf = DividerTitleCard.EPISODE_TEXT_FORMAT.upper() + + return (custom_episode_map + or episode_text_format.upper() != standard_etf) + + + def create(self) -> None: + """ + Make the necessary ImageMagick and system calls to create this + object's defined title card. + """ + + interline_spacing = -20 + self.font_interline_spacing + kerning = 0 * self.font_kerning + stroke_width = 8 * self.font_stroke_width + + # The gravity of the text composition is based on the text position + gravity = { + 'upper left': 'northwest', + 'upper right': 'northeast', + 'right': 'east', + 'lower right': 'southeast', + 'lower left': 'southwest', + 'left': 'west', + }[self.text_position] + + # Get the height for the divider character based on the max text height + divider_height = self.divider_height + + command = ' '.join([ + f'convert "{self.source_file.resolve()}"', + # Resize and apply styles to source image + *self.resize_and_style, + # Add blurred stroke behind the title text + f'-background transparent', + f'-bordercolor transparent', + f'-font "{self.font_file}"', + f'-kerning {kerning}', + f'-strokewidth {stroke_width}', + f'-interline-spacing {interline_spacing}', + f'\( -stroke "{self.stroke_color}"', + *self.text_command(divider_height, self.stroke_color), + # Combine text images + f'+smush 25', + # Add border so the blurred text doesn't get sharply cut off + f'-border 50x50', + f'-blur 0x5 \)', + # Overlay blurred text in correct position + f'-gravity {gravity}', + f'-composite', + # Add title text + f'\( -fill "{self.font_color}"', + # Use basically transparent color so text spacing matches + f'-stroke "rgba(1, 1, 1, 0.01)"', + *self.text_command(divider_height, self.font_color), + f'+smush 25', + f'-border 50x50 \)', + # Overlay title text in correct position + f'-gravity {gravity}', + f'-composite', + # Create card + *self.resize_output, + f'"{self.output_file.resolve()}"', + ]) + + self.image_magick.run(command) \ No newline at end of file From a6eaabaf64008cb263da9a0ac6bedbb0a74e0aba Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 18:07:04 -0600 Subject: [PATCH 31/44] Rework StarWarsTitleCard - Make card much faster by using composite single image sequence instead of intermediate image - Generalize prefix text detection to work with more than just Chapter/Episode/Part - Change default episode text format string to "EPISODE {episode_number_cardinal}" - Allow custom font color, file, size, and interline spacing --- modules/cards/StarWarsTitleCard.py | 251 +++++++++-------------------- 1 file changed, 76 insertions(+), 175 deletions(-) diff --git a/modules/cards/StarWarsTitleCard.py b/modules/cards/StarWarsTitleCard.py index 35293c69..1e8a041a 100755 --- a/modules/cards/StarWarsTitleCard.py +++ b/modules/cards/StarWarsTitleCard.py @@ -48,7 +48,7 @@ class StarWarsTitleCard(BaseCardType): FONT_REPLACEMENTS = {'Ō': 'O', 'ō': 'o'} """Characteristics of the episode text""" - EPISODE_TEXT_FORMAT = 'EPISODE {episode_number}' + EPISODE_TEXT_FORMAT = 'EPISODE {episode_number_cardinal}' EPISODE_TEXT_COLOR = '#AB8630' EPISODE_TEXT_FONT = REF_DIRECTORY / 'HelveticaNeue.ttc' EPISODE_NUMBER_FONT = REF_DIRECTORY / 'HelveticaNeue-Bold.ttf' @@ -62,12 +62,10 @@ class StarWarsTitleCard(BaseCardType): """Path to the reference star image to overlay on all source images""" __STAR_GRADIENT_IMAGE = REF_DIRECTORY / 'star_gradient.png' - """Paths to intermediate files that are deleted after the card is created""" - __SOURCE_WITH_STARS = BaseCardType.TEMP_DIR / 'source_gradient.png' - __slots__ = ( - 'source_file', 'output_file', 'title_text', 'episode_text', - 'hide_episode_text', 'episode_prefix', + 'source_file', 'output_file', 'title_text', 'episode_text', + 'hide_episode_text', 'font_color', 'font_file', + 'font_interline_spacing', 'font_size', 'episode_prefix', ) def __init__(self, @@ -76,6 +74,10 @@ def __init__(self, title_text: str, episode_text: str, hide_episode_text: bool = False, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_interline_spacing: int = 0, + font_size: float = 1.0, blur: bool = False, grayscale: bool = False, preferences: 'Preferences' = None, @@ -94,88 +96,31 @@ def __init__(self, # Store episode title self.title_text = self.image_magick.escape_chars(title_text.upper()) - # Modify episode text to remove "Episode"-like text, replace numbers - # with text, strip spaces, and convert to uppercase + # Font customizations + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing + self.font_size = font_size + + # Attempt to detect prefix text self.hide_episode_text = hide_episode_text or len(episode_text) == 0 if self.hide_episode_text: - self.episode_prefix = None - self.episode_text = self.image_magick.escape_chars(episode_text) + self.episode_prefix, self.episode_text = None, None else: - self.episode_prefix = 'EPISODE' - self.episode_text = self.image_magick.escape_chars( - self.__modify_episode_text(episode_text) - ) - - - def __modify_episode_text(self, text: str) -> str: - """ - Modify the given episode text (such as "EPISODE 1" or - "CHAPTER 1") to fit the theme of this card. This removes preface - text like episode, chapter, or part; and converts numeric - episode numbers to their text equivalent. For example: - - >>> self.__modify_episode_text('Episode 9') - 'NINE' - >>> self.__modify_episode_text('PART 14') - 'FOURTEEN' - - Args: - text: The episode text to modify. - - Returns: - The modified episode text with preface text removed, numbers - replaced with words, and converted to uppercase. If numbers - cannot be replaced, that step is skipped. - """ - - # Convert to uppercase, remove space padding - modified_text = text.upper().strip() - - # Remove preface text - if CHAPTER or EPISODE, set object episode prefix - if match(rf'CHAPTER\s*(\d+)', modified_text): - self.episode_prefix = 'CHAPTER' - modified_text = modified_text.replace('CHAPTER', '') - elif match(rf'EPISODE\s*(\d+)', modified_text): - self.episode_prefix = 'EPISODE' - modified_text = modified_text.replace('EPISODE', '') - elif match(rf'PART\s*(\d+)', modified_text): - self.episode_prefix = 'PART' - modified_text = modified_text.replace('PART', '') - - try: - # Only digit episode text remains, return as a number (i.e. "two") - return num2words(int(modified_text.strip())).upper() - except ValueError: - # Not just a digit, return as-is - return modified_text.strip() - - - def __add_star_gradient(self, source: Path) -> Path: - """ - Add the static star gradient to the given source image. - - Args: - source: The source image to modify. - - Returns: - Path to the created image. - """ - - command = ' '.join([ - f'convert "{source.resolve()}"', - *self.resize_and_style, - f'"{self.__STAR_GRADIENT_IMAGE.resolve()}"', - f'-background None', - f'-layers Flatten', - f'"{self.__SOURCE_WITH_STARS.resolve()}"', - ]) - - self.image_magick.run(command) - - return self.__SOURCE_WITH_STARS + if ' ' in episode_text: + prefix, text = episode_text.split(' ', 1) + self.episode_prefix, self.episode_text = map( + self.image_magick.escape_chars, + (prefix, text) + ) + else: + self.episode_prefix = self.image_magick.escape_chars( + episode_text + ) - def __add_title_text(self) -> list: + @property + def title_text_command(self) -> ImageMagickCommands: """ ImageMagick commands to add the episode title text to an image. @@ -183,37 +128,22 @@ def __add_title_text(self) -> list: List of ImageMagick commands. """ + size = 124 * self.font_size + interline_spacing = 20 + self.font_interline_spacing + return [ - f'-font "{self.TITLE_FONT}"', + f'-font "{self.font_file}"', f'-gravity northwest', - f'-pointsize 124', + f'-pointsize {size}', f'-kerning 0.5', - f'-interline-spacing 20', - f'-fill "{self.TITLE_COLOR}"', + f'-interline-spacing {interline_spacing}', + f'-fill "{self.font_color}"', f'-annotate +320+829 "{self.title_text}"', ] - def __add_episode_prefix(self) -> ImageMagickCommands: - """ - ImageMagick commands to add the episode prefix text to an image. - This is either "EPISODE" or "CHAPTER". - - Returns: - List of ImageMagick commands. - """ - - return [ - f'-gravity west', - f'-font "{self.EPISODE_TEXT_FONT.resolve()}"', - f'-fill "{self.EPISODE_TEXT_COLOR}"', - f'-pointsize 53', - f'-kerning 19', - f'-annotate +325-140 "{self.episode_prefix}"', - ] - - - def __add_episode_number_text(self) -> ImageMagickCommands: + @property + def episode_text_command(self) -> ImageMagickCommands: """ ImageMagick commands to add the episode text to an image. @@ -221,70 +151,31 @@ def __add_episode_number_text(self) -> ImageMagickCommands: List of ImageMagick commands. """ - # Get variable horizontal offset based of episode prefix - text_offset = {'EPISODE': 720, 'CHAPTER': 720, 'PART': 570} - offset = text_offset[self.episode_prefix] + # Hiding episode text, return blank command + if self.hide_episode_text: + return [] return [ + # Global font options f'-gravity west', - f'-font "{self.EPISODE_NUMBER_FONT.resolve()}"', f'-pointsize 53', f'-kerning 19', - f'-annotate +{offset}-140 "{self.episode_text}"', + f'-fill "{self.EPISODE_TEXT_COLOR}"', + f'-background transparent', + # Create prefix text + f'\( -font "{self.EPISODE_TEXT_FONT.resolve()}"', + f'label:"{self.episode_prefix}"', + # Create actual episode text + f'-font "{self.EPISODE_NUMBER_FONT.resolve()}"', + f'label:"{self.episode_text}"', + # Combine prefix and episode text + f'+smush 65 \)', + # Add combined text to image + f'-geometry +325-140', + f'-composite', ] - def __add_only_title(self, gradient_source: Path) -> Path: - """ - Add the title to the given image. - - Args: - gradient_source: Source image with starry gradient overlaid. - - Returns: - List of ImageMagick commands. - """ - - command = ' '.join([ - f'convert "{gradient_source.resolve()}"', - *self.__add_title_text(), - # Create card - *self.resize_output, - f'"{self.output_file.resolve()}"', - ]) - - self.image_magick.run(command) - - return self.output_file - - - def __add_all_text(self, gradient_source: Path) -> Path: - """ - Add the title, "EPISODE" prefix, and episode text to the given - image. - - Args: - gradient_source: Source image with starry gradient overlaid. - - Returns: - List of ImageMagick commands. - """ - - command = ' '.join([ - f'convert "{gradient_source.resolve()}"', - *self.__add_title_text(), - *self.__add_episode_prefix(), - *self.__add_episode_number_text(), - # Create card - *self.resize_output, - f'"{self.output_file.resolve()}"', - ]) - - self.image_magick.run(command) - - return self.output_file - - @staticmethod def is_custom_font(font: 'Font') -> bool: """ @@ -295,10 +186,14 @@ def is_custom_font(font: 'Font') -> bool: font: The Font being evaluated. Returns: - False, as fonts are not customizable with this card. + True if a custom font is indicated, False otherwise. """ - return False + return ((font.color != DividerTitleCard.TITLE_COLOR) + or (font.file != DividerTitleCard.TITLE_FONT) + or (font.interline_spacing != 0) + or (font.size != 1.0) + ) @staticmethod @@ -324,14 +219,20 @@ def is_custom_season_titles( def create(self) -> None: """Create the title card as defined by this object.""" - # Add the starry gradient to the source image - star_image = self.__add_star_gradient(self.source_file) - - # Add text to starry image, result is output - if self.hide_episode_text: - self.__add_only_title(star_image) - else: - self.__add_all_text(star_image) + command = ' '.join([ + f'convert "{self.source_file.resolve()}"', + # Resize and apply styles + *self.resize_and_style, + # Overlay star gradient + f'"{self.__STAR_GRADIENT_IMAGE.resolve()}"', + f'-composite', + # Add title text + *self.title_text_command, + # Add episode text + *self.episode_text_command, + # Create card + *self.resize_output, + f'"{self.output_file.resolve()}"', + ]) - # Delete all intermediate images - self.image_magick.delete_intermediate_images(star_image) \ No newline at end of file + self.image_magick.run(command) \ No newline at end of file From 9aa32f4b0b42ac20d7c728613049395aabbb484c Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 18:59:45 -0600 Subject: [PATCH 32/44] Create TintedFrameTitleCard Implements #331 --- modules/TitleCard.py | 3 + modules/cards/TintedFrameTitleCard.py | 481 ++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100755 modules/cards/TintedFrameTitleCard.py diff --git a/modules/TitleCard.py b/modules/TitleCard.py index 382d0c8c..c01bdefe 100755 --- a/modules/TitleCard.py +++ b/modules/TitleCard.py @@ -21,6 +21,7 @@ from modules.cards.StandardTitleCard import StandardTitleCard from modules.cards.StarWarsTitleCard import StarWarsTitleCard from modules.cards.TextlessTitleCard import TextlessTitleCard +from modules.cards.TintedFrameTitleCard import TintedFrameTitleCard from modules.cards.TintedGlassTitleCard import TintedGlassTitleCard class TitleCard: @@ -51,6 +52,7 @@ class TitleCard: DEFAULT_CARD_TYPE = 'standard' CARD_TYPES = { 'anime': AnimeTitleCard, + 'blurred border': TintedFrameTitleCard, 'cutout': CutoutTitleCard, 'divider': DividerTitleCard, 'fade': FadeTitleCard, @@ -73,6 +75,7 @@ class TitleCard: 'standard': StandardTitleCard, 'star wars': StarWarsTitleCard, 'textless': TextlessTitleCard, + 'tinted frame': TintedFrameTitleCard, 'tinted glass': TintedGlassTitleCard, '4x3': FadeTitleCard, } diff --git a/modules/cards/TintedFrameTitleCard.py b/modules/cards/TintedFrameTitleCard.py new file mode 100755 index 00000000..1aef012a --- /dev/null +++ b/modules/cards/TintedFrameTitleCard.py @@ -0,0 +1,481 @@ +from pathlib import Path +from typing import Any, Literal, Optional, Union + +from modules.BaseCardType import BaseCardType, ImageMagickCommands +from modules.Debug import log + +SeriesExtra = Optional + +class Coordinate: + __slots__ = ('x', 'y') + + def __init__(self, x: float, y: float) -> None: + self.x = x + self.y = y + + def __str__(self) -> str: + return f'{self.x:.0f},{self.y:.0f}' + +class Rectangle: + __slots__ = ('start', 'end') + + def __init__(self, start: Coordinate, end: Coordinate) -> None: + self.start = start + self.end = end + + def __str__(self) -> str: + return f'rectangle {str(self.start)},{str(self.end)}' + + def draw(self) -> str: + return f'-draw "{str(self)}"' + + +class TintedFrameTitleCard(BaseCardType): + """ + This class describes a CardType that produces title cards featuring + a rectangular frame with blurred content on the edges of the box, + and unblurred content within. The box itself is intersected by the + title text on top of the image; and on the bottom with a logo or + season/episode text. + """ + + """Directory where all reference files used by this card are stored""" + REF_DIRECTORY = BaseCardType.BASE_REF_DIRECTORY / 'tinted_frame' + + """Characteristics for title splitting by this class""" + TITLE_CHARACTERISTICS = { + 'max_line_width': 35, # Character count to begin splitting titles + 'max_line_count': 2, # Maximum number of lines a title can take up + 'top_heavy': True, # This class uses top heavy titling + } + + """Characteristics of the default title font""" + TITLE_FONT = str((REF_DIRECTORY / 'Galey Semi Bold.ttf').resolve()) + TITLE_COLOR = 'white' + DEFAULT_FONT_CASE = 'upper' + FONT_REPLACEMENTS = {} + + """Characteristics of the episode text""" + EPISODE_TEXT_COLOR = TITLE_COLOR + EPISODE_TEXT_FONT = REF_DIRECTORY / 'Galey Semi Bold.ttf' + + """Whether this CardType uses season titles for archival purposes""" + USES_SEASON_TITLE = True + + """Standard class has standard archive name""" + ARCHIVE_NAME = 'Blurred Box Style' + + """How many pixels from the image edge the box is placed; and box width""" + BOX_OFFSET = 185 + BOX_WIDTH = 3 + + __slots__ = ( + 'source_file', 'output_file', 'title_text', 'season_text', + 'episode_text', 'hide_season_text', 'hide_episode_text', 'font_file', + 'font_size', 'font_color', 'font_interline_spacing', 'font_kerning', + 'font_vertical_shift', 'episode_text_color', 'separator', 'box_color', + 'logo', 'bottom_element', + ) + + def __init__(self, *, + source_file: Path, + card_file: Path, + title_text: str, + season_text: str, + episode_text: str, + hide_season_text: bool = False, + hide_episode_text: bool = False, + font_color: str = TITLE_COLOR, + font_file: str = TITLE_FONT, + font_interline_spacing: int = 0, + font_kerning: float = 1.0, + font_size: float = 1.0, + font_vertical_shift: int = 0, + blur: bool = False, + grayscale: bool = False, + episode_text_color: SeriesExtra[str] = EPISODE_TEXT_COLOR, + separator: SeriesExtra[str] = '-', + box_color: SeriesExtra[str] = None, + bottom_element: SeriesExtra[Literal['logo', 'omit', 'text']] = 'text', + logo: SeriesExtra[str] = None, + preferences: 'Preferences' = None, + **unused) -> None: + """ + Construct a new instance of this Card. + """ + + # Initialize the parent class - this sets up an ImageMagickInterface + super().__init__(blur, grayscale, preferences=preferences) + + self.source_file = source_file + self.output_file = card_file + + # Ensure characters that need to be escaped are + self.title_text = self.image_magick.escape_chars(title_text) + self.season_text = self.image_magick.escape_chars(season_text.upper()) + self.episode_text = self.image_magick.escape_chars(episode_text.upper()) + self.hide_season_text = hide_season_text or len(season_text) == 0 + self.hide_episode_text = hide_episode_text or len(episode_text) == 0 + + # Font/card customizations + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing + self.font_kerning = font_kerning + self.font_size = font_size + self.font_vertical_shift = font_vertical_shift + + # Optional extras + self.episode_text_color = episode_text_color + self.separator = separator + self.box_color = font_color if box_color is None else box_color + + # If a logo was provided, convert to Path object + if logo is None: + self.logo = None + else: + try: + self.logo = Path(logo) + except: + log.exception(f'Logo path is invalid', e) + self.valid = False + + # Validate bottom element extra + if bottom_element not in (None, 'omit', 'text', 'logo'): + log.warning(f'Invalid "bottom_element" - must be "omit", "text", or' + f'"logo"') + self.valid = False + + # If logo was indicated, verify logo was provided + self.bottom_element = bottom_element + if bottom_element == 'logo': + if self.logo is None: + log.warning(f'Logo not provided') + self.valid = False + else: + self.bottom_element = 'logo' + + + @property + def title_text_command(self) -> ImageMagickCommands: + """ + Subcommand for adding title text to the source image. + + Returns: + List of ImageMagick commands. + """ + + if len(self.title_text) == 0: + return [] + + return [ + f'-background transparent', + f'\( -font "{self.font_file}"', + f'-pointsize {100 * self.font_size}', + f'-kerning {1 * self.font_kerning}', + f'-interline-spacing {0 + self.font_interline_spacing}', + f'-fill "{self.font_color}"', + f'label:"{self.title_text}"', + # Create drop shadow + f'\( +clone', + f'-shadow 80x3+10+10 \)', + # Position shadow below text + f'+swap', + f'-layers merge', + f'+repage \)', + # Overlay text and shadow onto source image + f'-geometry +0-{700 + self.font_vertical_shift}', + f'-composite', + ] + + + @property + def index_text_command(self) -> ImageMagickCommands: + """ + Subcommand for adding index text to the source image. + + Returns: + List of ImageMagick commands. + """ + + # If the bottom element is not text, or all text is hidden, return + if (self.bottom_element != 'text' + or (self.hide_season_text and self.hide_episode_text)): + return [] + + # Set index text based on which text is hidden/not + if self.hide_season_text: + index_text = self.episode_text + elif self.hide_episode_text: + index_text = self.season_text + else: + index_text = f'{self.season_text} {self.separator} {self.episode_text}' + + return [ + f'-background transparent', + f'\( -font "{self.EPISODE_TEXT_FONT}"', + f'-pointsize {60}', + f'-fill "{self.episode_text_color}"', + f'label:"{index_text}"', + # Create drop shadow + f'\( +clone', + f'-shadow 80x3+6+6 \)', + # Position shadow below text + f'+swap', + f'-layers merge', + f'+repage \)', + # Overlay text and shadow onto source image + f'-gravity center', + f'-geometry +0+722', + f'-composite', + ] + + + @property + def logo_command(self) -> ImageMagickCommands: + """ + Subcommand for adding the logo to the image if indicated by the + bottom element extra (and the logo file exists). + + Returns: + List of ImageMagick commands. + """ + + # Logo not indicated or not available, return empty commands + if (self.bottom_element != 'logo' + or self.logo is None or not self.logo.exists()): + return [] + + return [ + f'\( "{self.logo.resolve()}"', + f'-resize x150 \)', + f'-gravity center', + f'-geometry +0+700', + f'-composite', + ] + + + @property + def box_command(self) -> ImageMagickCommands: + """ + Subcommand to add the box that separates the outer (blurred) + image and the interior (unblurred) image. This box features a + drop shadow. The top part of the box is intersected by the title + text (if present), and the bottom part can be intersected by the + index text, logo, or not at all. + + Returns: + List of ImageMagick commands. + """ + + INSET = self.BOX_OFFSET + BOX_WIDTH = self.BOX_WIDTH + + # Coordinates used by multiple rectangles + TopLeft = Coordinate(INSET, INSET) + TopRight = Coordinate(self.WIDTH - INSET, INSET + BOX_WIDTH) + BottomLeft = Coordinate(INSET + BOX_WIDTH, self.HEIGHT - INSET) + BottomRight = Coordinate(self.WIDTH - INSET, self.HEIGHT - INSET) + + # Determine top box draw commands + if len(self.title_text) == 0: + top_rectangle = Rectangle(TopLeft, TopRight) + top = [top_rectangle.draw()] + else: + title_width, _ = self.get_text_dimensions( + self.title_text_command, width='max', height='max', + ) + left_box_x = (self.WIDTH / 2) - (title_width / 2) - 10 + right_box_x = (self.WIDTH / 2) + (title_width / 2) + 10 + + top_left_rectangle = Rectangle( + TopLeft, + Coordinate(left_box_x, INSET + BOX_WIDTH) + ) + top_right_rectangle = Rectangle( + Coordinate(right_box_x, INSET), + TopRight, + ) + + top = [top_left_rectangle.draw(), top_right_rectangle.draw()] + + # Left and right rectangles are never intersected by content + left_rectangle = Rectangle(TopLeft, BottomLeft) + left = [left_rectangle.draw()] + right_rectangle = Rectangle( + Coordinate(self.WIDTH - INSET - BOX_WIDTH, INSET), + BottomRight, + ) + right = [right_rectangle.draw()] + + # Determine bottom box draw commands + # No bottom element, use singular full-width rectangle + if self.bottom_element == 'omit': + bottom_rectangle = Rectangle( + Coordinate(INSET, self.HEIGHT - INSET - BOX_WIDTH), + BottomRight + ) + bottom = [bottom_rectangle.draw()] + # Bottom element is logo, use boxes based on resized logo width + elif (self.bottom_element == 'logo' + and self.logo is not None + and self.logo.exists()): + logo_width, logo_height = self.get_image_dimensions(self.logo) + logo_width /= logo_height / 150 + + left_box_x = (self.WIDTH / 2) - (logo_width / 2) - 25 + right_box_x = (self.WIDTH / 2) + (logo_width / 2) + 25 + + bottom_left_rectangle = Rectangle( + Coordinate(INSET, self.HEIGHT - INSET - BOX_WIDTH), + Coordinate(left_box_x, self.HEIGHT - INSET), + ) + bottom_right_rectangle = Rectangle( + Coordinate(right_box_x, self.HEIGHT - INSET - BOX_WIDTH), + BottomRight, + ) + + bottom = [ + bottom_left_rectangle.draw(), bottom_right_rectangle.draw() + ] + # Bottom element is index text, use boxes based on text width + elif self.bottom_element == 'text': + index_text_width, _ = self.get_text_dimensions( + self.index_text_command, width='max', height='max', + ) + left_box_x = (self.WIDTH / 2) - (index_text_width / 2) - 25 + right_box_x = (self.WIDTH / 2) + (index_text_width / 2) + 25 + + bottom_left_rectangle = Rectangle( + Coordinate(INSET, self.HEIGHT - INSET - BOX_WIDTH), + Coordinate(left_box_x, self.HEIGHT - INSET) + ) + bottom_right_rectangle = Rectangle( + Coordinate(right_box_x, self.HEIGHT - INSET - BOX_WIDTH), + BottomRight, + ) + + bottom = [ + bottom_left_rectangle.draw(), bottom_right_rectangle.draw() + ] + + return [ + # Create blank canvas + f'\( -size {self.TITLE_CARD_SIZE}', + f'xc:transparent', + # Draw all sets of rectangles + f'-fill "{self.box_color}"', + *top, *left, *right, *bottom, + f'\( +clone', + f'-shadow 80x3+4+4 \)', + # Position drop shadow below rectangles + f'+swap', + f'-layers merge', + f'+repage \)', + # Overlay box and shadow onto source image + f'-geometry +0+0', + f'-composite', + ] + + + @staticmethod + def modify_extras( + extras: dict[str, Any], + custom_font: bool, + custom_season_titles: bool) -> None: + """ + Modify the given extras based on whether font or season titles + are custom. + + Args: + extras: Dictionary to modify. + custom_font: Whether the font are custom. + custom_season_titles: Whether the season titles are custom. + """ + + # Generic font, reset episode text and box colors + if not custom_font: + if 'episode_text_color' in extras: + extras['episode_text_color'] =\ + TintedFrameTitleCard.EPISODE_TEXT_COLOR + if 'box_color' in extras: + extras['box_color'] = TintedFrameTitleCard.TITLE_COLOR + + + @staticmethod + def is_custom_font(font: 'Font') -> bool: + """ + Determine whether the given font characteristics constitute a + default or custom font. + + Args: + font: The Font being evaluated. + + Returns: + True if a custom font is indicated, False otherwise. + """ + + return ((font.color != DividerTitleCard.TITLE_COLOR) + or (font.file != DividerTitleCard.TITLE_FONT) + or (font.interline_spacing != 0) + or (font.kerning != 1.0) + or (font.size != 1.0) + or (font.vertical_shift != 0) + ) + + + @staticmethod + def is_custom_season_titles( + custom_episode_map: bool, episode_text_format: str) -> bool: + """ + Determine whether the given attributes constitute custom or + generic season titles. + + Args: + custom_episode_map: Whether the EpisodeMap was customized. + episode_text_format: The episode text format in use. + + Returns: + True if custom season titles are indicated, False otherwise. + """ + + standard_etf = TintedFrameTitleCard.EPISODE_TEXT_FORMAT.upper() + + return (custom_episode_map + or episode_text_format.upper() != standard_etf) + + + def create(self) -> None: + """ + Make the necessary ImageMagick and system calls to create this + object's defined title card. + """ + + crop_width = self.WIDTH - (2 * self.BOX_OFFSET) - 6 # 6px margin + crop_height = self.HEIGHT - (2 * self.BOX_OFFSET) - 4 # 4px margin + + command = ' '.join([ + f'convert "{self.source_file.resolve()}"', + # Resize and apply styles to source image + *self.resize_and_style, + # Blur entire image + f'-blur 0x20', + # Crop out center area of the source image + f'-gravity center', + f'\( "{self.source_file.resolve()}"', + *self.resize_and_style, + f'-crop {crop_width}x{crop_height}+0+0', + f'+repage \)', + # Overlay unblurred center area + f'-composite', + # Add remaining sub-components + *self.title_text_command, + *self.index_text_command, + *self.logo_command, + *self.box_command, + # Create card + *self.resize_output, + f'"{self.output_file.resolve()}"', + ]) + + self.image_magick.run(command) \ No newline at end of file From 0948ca6021fc3995846b3853076338036a3c64b3 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Fri, 21 Apr 2023 22:10:23 -0600 Subject: [PATCH 33/44] Add typing imports --- modules/Font.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/Font.py b/modules/Font.py index ada5795a..4f4dd444 100755 --- a/modules/Font.py +++ b/modules/Font.py @@ -1,5 +1,6 @@ from pathlib import Path from re import compile as re_compile +from typing import Any, Optional from modules.Debug import log import modules.global_objects as global_objects From 9de757f55e1c2c577d4fd9f2fa7db25480475ee4 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Sat, 22 Apr 2023 11:24:42 -0600 Subject: [PATCH 34/44] Add more prominent links to the Discord --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 820808fe..b8cdc63b 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,14 @@ An automated title card maker for the Plex, Jellyfin, and Emby media servers. Al ## Description `TitleCardMaker` is a program and [Docker container](https://hub.docker.com/r/collinheist/titlecardmaker) written in Python that automates the creation of customized title cards (which are image previews of an episode of TV) for use in personal media server services like [Plex](https://www.plex.tv/), [Jellyfin](https://jellyfin.org/), or [Emby](https://emby.media/). -TitleCardMaker can be automated such that everything can be pulled without manual intervention. All your series can be read from your media server or Sonarr; episode data can be pulled from Sonarr, your media server, or [TheMovieDatabase](https://www.themoviedb.org/); images from TheMovieDatabase, or your media server; and TitleCardMaker can even utilize an episode's watch status to create "spoiler free" versions of title cards automatically, as shown below: +TitleCardMaker can be automated such that everything can be done without manual intervention. All your series can be read from your media server or Sonarr; episode data can be pulled from Sonarr, your media server, or [TheMovieDatabase](https://www.themoviedb.org/); images from TheMovieDatabase, or your media server; and TitleCardMaker can even utilize an episode's watch status to create "spoiler free" versions of title cards automatically, as shown below: card unblurring process All configuration/automation of the TitleCardMaker is done via YAML files, and the actual image creation is done using the open-source and free image library called [ImageMagick](https://imagemagick.org/). ## Getting Started -> The [Wiki](https://github.com/CollinHeist/TitleCardMaker/wiki) has very extensive documentation on every feature and customization available in TitleCardMaker. I __highly__ recommend looking here as the first step when troubleshooting or customizing your setup. +> The [Wiki](https://github.com/CollinHeist/TitleCardMaker/wiki) has very extensive documentation on every feature and customization available in TitleCardMaker. I __highly__ recommend looking here as the first step when troubleshooting or customizing your setup. [The Discord](https://discord.gg/bJ3bHtw8wH) is also a great place to get detailed help. Read the [Getting Started](https://github.com/CollinHeist/TitleCardMaker/wiki) page on the Wiki for the traditional install, or the [Getting Started on Docker](https://github.com/CollinHeist/TitleCardMaker/wiki/Docker-Tutorial) page to install using Docker. @@ -40,7 +40,7 @@ pipenv run python main.py --run For invocation and configuration details, read [here](https://github.com/CollinHeist/TitleCardMaker/wiki/Running-the-TitleCardMaker). -> If you have trouble getting the Maker working, or have a problem, [create an issue on GitHub](https://github.com/CollinHeist/TitleCardMaker/issues/new)! +> If you have trouble getting the Maker working, or have a problem, [create an issue on GitHub](https://github.com/CollinHeist/TitleCardMaker/issues/new), or [join the Discord](https://discord.gg/bJ3bHtw8wH) for help. ## Examples Below are some examples of each style of title card that can be created automatically by the TitleCardMaker: From e173bdfa447907794e0e3fe4f67abd9f5a125b08 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Sat, 22 Apr 2023 13:15:42 -0600 Subject: [PATCH 35/44] Add Help issue template --- .github/ISSUE_TEMPLATE/help.yml | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/help.yml diff --git a/.github/ISSUE_TEMPLATE/help.yml b/.github/ISSUE_TEMPLATE/help.yml new file mode 100644 index 00000000..2ea5d014 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/help.yml @@ -0,0 +1,56 @@ +name: Request help +description: Ask for help regarding an issue with your setup +title: 'HELP - ' +labels: ['question'] +assignees: 'CollinHeist' + +body: + - type: dropdown + id: docker + attributes: + label: Installation + description: Are you using Docker or Github; and which branch/tag? + options: + - Docker - master tag + - Docker - develop tag + - GitHub - master branch + - GitHub - develop branch + validations: + required: true + - type: textarea + id: description + attributes: + label: Describe your Problem + description: A clear and concise description of your issue. + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Attach any applicable screenshots that illustrate your problem. + - type: textarea + id: preferences + attributes: + label: Preference File + description: > + Paste your Preferences file (likely preferences.yml), with your API keys and URLs omitted. + This will be automatically formatted as YAML, so no need for backticks. + render: yaml + validations: + required: true + - type: textarea + id: seriesyaml + attributes: + label: Series YAML + description: > + Paste the YAML of the relevent series. + This will be automatically formatted as YAML, so no need for backticks. + render: yaml + - type: textarea + id: log + attributes: + label: Debug Log + description: Attach the relevant log file(s) from the logs/ directory. + validations: + required: true From 1d56aaf79931d27dc27b9a73b7b42acc598e5730 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Sat, 22 Apr 2023 13:16:59 -0600 Subject: [PATCH 36/44] Do not allow blank issues, add link to Discord to new issue page --- .github/ISSUE_TEMPLATE/config.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..6252d404 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Discord + url: https://discord.gg/bJ3bHtw8wH + about: Please use Discord to ask for support. From 47574d47340eb88ae11f5a7f8fcb3d8ff2befc1d Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Sat, 22 Apr 2023 17:03:35 -0600 Subject: [PATCH 37/44] Add Tinted Frame title card to README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b8cdc63b..0339b42c 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ For invocation and configuration details, read [here](https://github.com/CollinH Below are some examples of each style of title card that can be created automatically by the TitleCardMaker: ### Built-in Card Types -Anime Cutout Fade Frame Landscape Logo Olivier Poster Roman Standard Star Wars Tinted Glass +Anime Cutout Fade Frame Landscape Logo Olivier Poster Roman Standard Star Wars tinted Frame Tinted Glass -> The above cards are, in order, the [anime](https://github.com/CollinHeist/TitleCardMaker/wiki/AnimeTitleCard), [cutout](https://github.com/CollinHeist/TitleCardMaker/wiki/CutoutTitleCard), [fade](https://github.com/CollinHeist/TitleCardMaker/wiki/FadeTitleCard), [frame](https://github.com/CollinHeist/TitleCardMaker/wiki/FrameTitleCard), [landscape](https://github.com/CollinHeist/TitleCardMaker/wiki/LandscapeTitleCard), [logo](https://github.com/CollinHeist/TitleCardMaker/wiki/LogoTitleCard), [olivier](https://github.com/CollinHeist/TitleCardMaker/wiki/OlivierTitleCard), [poster](https://github.com/CollinHeist/TitleCardMaker/wiki/PosterTitleCard), [roman](https://github.com/CollinHeist/TitleCardMaker/wiki/RomanNumeralTitleCard), [standard](https://github.com/CollinHeist/TitleCardMaker/wiki/StandardTitleCard), [star wars](https://github.com/CollinHeist/TitleCardMaker/wiki/StarWarsTitleCard), and the [tinted glass](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedGlassTitleCard) title cards - the [textless](https://github.com/CollinHeist/TitleCardMaker/wiki/TitleCard) card is not shown. +> The above cards are, in order, the [anime](https://github.com/CollinHeist/TitleCardMaker/wiki/AnimeTitleCard), [cutout](https://github.com/CollinHeist/TitleCardMaker/wiki/CutoutTitleCard), [fade](https://github.com/CollinHeist/TitleCardMaker/wiki/FadeTitleCard), [frame](https://github.com/CollinHeist/TitleCardMaker/wiki/FrameTitleCard), [landscape](https://github.com/CollinHeist/TitleCardMaker/wiki/LandscapeTitleCard), [logo](https://github.com/CollinHeist/TitleCardMaker/wiki/LogoTitleCard), [olivier](https://github.com/CollinHeist/TitleCardMaker/wiki/OlivierTitleCard), [poster](https://github.com/CollinHeist/TitleCardMaker/wiki/PosterTitleCard), [roman](https://github.com/CollinHeist/TitleCardMaker/wiki/RomanNumeralTitleCard), [standard](https://github.com/CollinHeist/TitleCardMaker/wiki/StandardTitleCard), [star wars](https://github.com/CollinHeist/TitleCardMaker/wiki/StarWarsTitleCard), [tinted frame](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedFrameTitleCard), and the [tinted glass](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedGlassTitleCard) title cards - the [textless](https://github.com/CollinHeist/TitleCardMaker/wiki/TitleCard) card is not shown.

User-Created Card Types

From 0644d6986dae38c3e7d0d2d599972fa354c2e73a Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Sat, 22 Apr 2023 17:07:58 -0600 Subject: [PATCH 38/44] Change TintedFrame box_color extra to frame_color --- modules/cards/TintedFrameTitleCard.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/cards/TintedFrameTitleCard.py b/modules/cards/TintedFrameTitleCard.py index 1aef012a..f1fbbdb9 100755 --- a/modules/cards/TintedFrameTitleCard.py +++ b/modules/cards/TintedFrameTitleCard.py @@ -63,7 +63,7 @@ class TintedFrameTitleCard(BaseCardType): USES_SEASON_TITLE = True """Standard class has standard archive name""" - ARCHIVE_NAME = 'Blurred Box Style' + ARCHIVE_NAME = 'Tinted Frame Style' """How many pixels from the image edge the box is placed; and box width""" BOX_OFFSET = 185 @@ -73,7 +73,7 @@ class TintedFrameTitleCard(BaseCardType): 'source_file', 'output_file', 'title_text', 'season_text', 'episode_text', 'hide_season_text', 'hide_episode_text', 'font_file', 'font_size', 'font_color', 'font_interline_spacing', 'font_kerning', - 'font_vertical_shift', 'episode_text_color', 'separator', 'box_color', + 'font_vertical_shift', 'episode_text_color', 'separator', 'frame_color', 'logo', 'bottom_element', ) @@ -95,7 +95,7 @@ def __init__(self, *, grayscale: bool = False, episode_text_color: SeriesExtra[str] = EPISODE_TEXT_COLOR, separator: SeriesExtra[str] = '-', - box_color: SeriesExtra[str] = None, + frame_color: SeriesExtra[str] = None, bottom_element: SeriesExtra[Literal['logo', 'omit', 'text']] = 'text', logo: SeriesExtra[str] = None, preferences: 'Preferences' = None, @@ -128,7 +128,7 @@ def __init__(self, *, # Optional extras self.episode_text_color = episode_text_color self.separator = separator - self.box_color = font_color if box_color is None else box_color + self.frame_color = font_color if frame_color is None else frame_color # If a logo was provided, convert to Path object if logo is None: @@ -256,7 +256,7 @@ def logo_command(self) -> ImageMagickCommands: @property - def box_command(self) -> ImageMagickCommands: + def frame_command(self) -> ImageMagickCommands: """ Subcommand to add the box that separates the outer (blurred) image and the interior (unblurred) image. This box features a @@ -364,7 +364,7 @@ def box_command(self) -> ImageMagickCommands: f'\( -size {self.TITLE_CARD_SIZE}', f'xc:transparent', # Draw all sets of rectangles - f'-fill "{self.box_color}"', + f'-fill "{self.frame_color}"', *top, *left, *right, *bottom, f'\( +clone', f'-shadow 80x3+4+4 \)', @@ -415,8 +415,8 @@ def is_custom_font(font: 'Font') -> bool: True if a custom font is indicated, False otherwise. """ - return ((font.color != DividerTitleCard.TITLE_COLOR) - or (font.file != DividerTitleCard.TITLE_FONT) + return ((font.color != TintedFrameTitleCard.TITLE_COLOR) + or (font.file != TintedFrameTitleCard.TITLE_FONT) or (font.interline_spacing != 0) or (font.kerning != 1.0) or (font.size != 1.0) @@ -472,7 +472,7 @@ def create(self) -> None: *self.title_text_command, *self.index_text_command, *self.logo_command, - *self.box_command, + *self.frame_command, # Create card *self.resize_output, f'"{self.output_file.resolve()}"', From 0593bd539b299673f0ae86d93a49c0681fcf197d Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Sat, 22 Apr 2023 17:14:17 -0600 Subject: [PATCH 39/44] Add Divider title card to README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0339b42c..00ec7f50 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ For invocation and configuration details, read [here](https://github.com/CollinH Below are some examples of each style of title card that can be created automatically by the TitleCardMaker: ### Built-in Card Types -Anime Cutout Fade Frame Landscape Logo Olivier Poster Roman Standard Star Wars tinted Frame Tinted Glass +Anime Cutout Divider Fade Frame Landscape Logo Olivier Poster Roman Standard Star Wars tinted Frame Tinted Glass -> The above cards are, in order, the [anime](https://github.com/CollinHeist/TitleCardMaker/wiki/AnimeTitleCard), [cutout](https://github.com/CollinHeist/TitleCardMaker/wiki/CutoutTitleCard), [fade](https://github.com/CollinHeist/TitleCardMaker/wiki/FadeTitleCard), [frame](https://github.com/CollinHeist/TitleCardMaker/wiki/FrameTitleCard), [landscape](https://github.com/CollinHeist/TitleCardMaker/wiki/LandscapeTitleCard), [logo](https://github.com/CollinHeist/TitleCardMaker/wiki/LogoTitleCard), [olivier](https://github.com/CollinHeist/TitleCardMaker/wiki/OlivierTitleCard), [poster](https://github.com/CollinHeist/TitleCardMaker/wiki/PosterTitleCard), [roman](https://github.com/CollinHeist/TitleCardMaker/wiki/RomanNumeralTitleCard), [standard](https://github.com/CollinHeist/TitleCardMaker/wiki/StandardTitleCard), [star wars](https://github.com/CollinHeist/TitleCardMaker/wiki/StarWarsTitleCard), [tinted frame](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedFrameTitleCard), and the [tinted glass](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedGlassTitleCard) title cards - the [textless](https://github.com/CollinHeist/TitleCardMaker/wiki/TitleCard) card is not shown. +> The above cards are, in order, the [anime](https://github.com/CollinHeist/TitleCardMaker/wiki/AnimeTitleCard), [cutout](https://github.com/CollinHeist/TitleCardMaker/wiki/CutoutTitleCard), [divider](https://github.com/CollinHeist/TitleCardMaker/wiki/DividerTitleCard) [fade](https://github.com/CollinHeist/TitleCardMaker/wiki/FadeTitleCard), [frame](https://github.com/CollinHeist/TitleCardMaker/wiki/FrameTitleCard), [landscape](https://github.com/CollinHeist/TitleCardMaker/wiki/LandscapeTitleCard), [logo](https://github.com/CollinHeist/TitleCardMaker/wiki/LogoTitleCard), [olivier](https://github.com/CollinHeist/TitleCardMaker/wiki/OlivierTitleCard), [poster](https://github.com/CollinHeist/TitleCardMaker/wiki/PosterTitleCard), [roman](https://github.com/CollinHeist/TitleCardMaker/wiki/RomanNumeralTitleCard), [standard](https://github.com/CollinHeist/TitleCardMaker/wiki/StandardTitleCard), [star wars](https://github.com/CollinHeist/TitleCardMaker/wiki/StarWarsTitleCard), [tinted frame](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedFrameTitleCard), and the [tinted glass](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedGlassTitleCard) title cards - the [textless](https://github.com/CollinHeist/TitleCardMaker/wiki/TitleCard) card is not shown.

User-Created Card Types

From fc21e79298b6a493e2a51bbba60ae842c424bd2d Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Sun, 23 Apr 2023 00:37:53 -0600 Subject: [PATCH 40/44] Extend line-length limit to 250ch for YAML sync Wrap lines after 250 characters even on YAML append --- modules/SeriesYamlWriter.py | 44 ++++++++++++++++++------------------- modules/SyncInterface.py | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/modules/SeriesYamlWriter.py b/modules/SeriesYamlWriter.py index 547eaa40..6ba37b32 100755 --- a/modules/SeriesYamlWriter.py +++ b/modules/SeriesYamlWriter.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Literal +from typing import Literal, Optional from ruamel.yaml import YAML, round_trip_dump, comments from ruamel.yaml.constructor import DuplicateKeyError @@ -18,13 +18,16 @@ class SeriesYamlWriter: """ """Keyword arguments for yaml.dump()""" - __WRITE_OPTIONS = {'allow_unicode': True, 'width': 200} + __WRITE_OPTIONS = {'allow_unicode': True, 'width': 250} def __init__(self, - file: CleanPath, sync_mode: str='append', compact_mode: bool=True, - volume_map: dict[str, str]={}, template: str=None, - card_directory: CleanPath=None) -> None: + file: CleanPath, + sync_mode: str = 'append', + compact_mode: bool = True, + volume_map: dict[str, str] = {}, + template: Optional[str] = None, + card_directory: Optional[CleanPath] = None) -> None: """ Initialize an instance of a SeriesYamlWrite object. @@ -133,7 +136,7 @@ def __convert_path(self, path: str, *, media: bool) -> str: def __apply_exclusion(self, - yaml: dict[str, dict[str, str]], + yaml: SeriesYaml, exclusions: list[dict[str, str]]) -> None: """ Apply the given exclusions to the given YAML. This modifies the @@ -197,7 +200,7 @@ def yaml_contains(yaml: dict, key: str) -> tuple[bool, str]: del yaml['series'][key] - def __write(self, yaml: dict[str, dict[str, str]]) -> None: + def __write(self, yaml: SeriesYaml) -> None: """ Write the given YAML to this Writer's file. This either utilizes compact or verbose style. @@ -219,8 +222,7 @@ def __write(self, yaml: dict[str, dict[str, str]]) -> None: dump(yaml, file_handle, **self.__WRITE_OPTIONS) - def __read_existing_file(self, - yaml: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]: + def __read_existing_file(self, yaml: SeriesYaml) -> SeriesYaml: """ Read the existing YAML from this writer's file. If the file has no existing YAML to read, then just write the given YAML. @@ -265,7 +267,7 @@ def __read_existing_file(self, return existing_yaml - def __append(self, yaml: dict[str, dict[str, str]]) -> None: + def __append(self, yaml: SeriesYaml) -> None: """ Append the given YAML to this Writer's file. This either utilizes compact or verbose style. Appending does not modify library or series @@ -304,10 +306,10 @@ def __append(self, yaml: dict[str, dict[str, str]]) -> None: # Write YAML to file with self.file.open('w', encoding='utf-8') as file_handle: - round_trip_dump(existing_yaml, file_handle) + round_trip_dump(existing_yaml, file_handle, **self.__WRITE_OPTIONS) - def __match(self, yaml: dict[str, dict[str, str]]) -> None: + def __match(self, yaml: SeriesYaml) -> None: """ Match this Writer's file to the given YAML - i.e. remove series that shouldn't be present, and add series that should. Does not @@ -548,12 +550,12 @@ def __get_yaml_from_interface(self, def update_from_sonarr(self, sonarr_interface: 'SonarrInterface', - plex_libraries: dict[str, str]={}, - required_tags: list[str]=[], - monitored_only: bool=False, - downloaded_only: bool=False, - series_type: str=None, - exclusions: list[dict[str, str]]=[]) -> None: + plex_libraries: dict[str, str] = {}, + required_tags: list[str] = [], + monitored_only: bool = False, + downloaded_only: bool = False, + series_type: Optional[str] = None, + exclusions: list[dict[str, str]] = []) -> None: """ Update this object's file from Sonarr. @@ -622,8 +624,7 @@ def update_from_emby(self, emby_interface: 'EmbyInterface', filter_libraries: list[str] = [], required_tags: list[str] = [], - exclusions: list[dict[str, str]] = [] - ) -> None: + exclusions: list[dict[str, str]] = []) -> None: """ Update this object's file from Emby. @@ -656,8 +657,7 @@ def update_from_jellyfin(self, jellyfin_interface: 'JellyfinInterface', filter_libraries: list[str] = [], required_tags: list[str] = [], - exclusions: list[dict[str, str]] = [] - ) -> None: + exclusions: list[dict[str, str]] = []) -> None: """ Update this object's file from Jellyfin. diff --git a/modules/SyncInterface.py b/modules/SyncInterface.py index 1309cf6b..b1d4cbe8 100755 --- a/modules/SyncInterface.py +++ b/modules/SyncInterface.py @@ -8,7 +8,7 @@ class SyncInterface(ABC): """ def get_library_paths(self, - filter_libraries: list[str]=[]) -> dict[str, list[str]]: + filter_libraries: list[str] = []) -> dict[str, list[str]]: """ Get all libraries and their associated base directories. From f8280ad1298af6d5f9ce89432eafc3ef521b2c3b Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Sun, 23 Apr 2023 17:39:11 -0600 Subject: [PATCH 41/44] Allow customization of the top element for TintedFrameTitleCard Allow top element to also be adjusted, similarly to bottom_element. --- modules/cards/TintedFrameTitleCard.py | 289 ++++++++++++------- modules/ref/tinted_frame/Galey Semi Bold.ttf | Bin 0 -> 120068 bytes 2 files changed, 185 insertions(+), 104 deletions(-) create mode 100755 modules/ref/tinted_frame/Galey Semi Bold.ttf diff --git a/modules/cards/TintedFrameTitleCard.py b/modules/cards/TintedFrameTitleCard.py index f1fbbdb9..a9ae1045 100755 --- a/modules/cards/TintedFrameTitleCard.py +++ b/modules/cards/TintedFrameTitleCard.py @@ -5,6 +5,8 @@ from modules.Debug import log SeriesExtra = Optional +Element = Literal['index', 'logo', 'omit', 'title'] + class Coordinate: __slots__ = ('x', 'y') @@ -33,10 +35,9 @@ def draw(self) -> str: class TintedFrameTitleCard(BaseCardType): """ This class describes a CardType that produces title cards featuring - a rectangular frame with blurred content on the edges of the box, - and unblurred content within. The box itself is intersected by the - title text on top of the image; and on the bottom with a logo or - season/episode text. + a rectangular frame with blurred content on the edges of the frame, + and unblurred content within. The frame itself can be intersected by + title text, index text, or a logo at the top and bottom. """ """Directory where all reference files used by this card are stored""" @@ -74,7 +75,7 @@ class TintedFrameTitleCard(BaseCardType): 'episode_text', 'hide_season_text', 'hide_episode_text', 'font_file', 'font_size', 'font_color', 'font_interline_spacing', 'font_kerning', 'font_vertical_shift', 'episode_text_color', 'separator', 'frame_color', - 'logo', 'bottom_element', + 'logo', 'top_element', 'bottom_element', ) def __init__(self, *, @@ -96,7 +97,8 @@ def __init__(self, *, episode_text_color: SeriesExtra[str] = EPISODE_TEXT_COLOR, separator: SeriesExtra[str] = '-', frame_color: SeriesExtra[str] = None, - bottom_element: SeriesExtra[Literal['logo', 'omit', 'text']] = 'text', + top_element: Element = 'title', + bottom_element: Element = 'index', logo: SeriesExtra[str] = None, preferences: 'Preferences' = None, **unused) -> None: @@ -140,20 +142,28 @@ def __init__(self, *, log.exception(f'Logo path is invalid', e) self.valid = False - # Validate bottom element extra - if bottom_element not in (None, 'omit', 'text', 'logo'): - log.warning(f'Invalid "bottom_element" - must be "omit", "text", or' - f'"logo"') + # Validate top and bottom element extras + top_element = str(top_element).strip().lower() + if top_element not in ('omit', 'title', 'index', 'logo'): + log.warning(f'Invalid "top_element" - must be "omit", "title", ' + f'"index", or "logo"') + self.valid = False + bottom_element = str(bottom_element).strip().lower() + if bottom_element not in ('omit', 'title', 'index', 'logo'): + log.warning(f'Invalid "bottom_element" - must be "omit", "title", ' + f'"index", or "logo"') + self.valid = False + if top_element != 'omit' and top_element == bottom_element: + log.warning(f'Top and bottom element cannot both be "{top_element}"') self.valid = False # If logo was indicated, verify logo was provided + self.top_element = top_element self.bottom_element = bottom_element - if bottom_element == 'logo': - if self.logo is None: - log.warning(f'Logo not provided') - self.valid = False - else: - self.bottom_element = 'logo' + if ((top_element == 'logo' or bottom_element == 'logo') + and self.logo is None): + log.warning(f'Logo file not provided') + self.valid = False @property @@ -165,9 +175,17 @@ def title_text_command(self) -> ImageMagickCommands: List of ImageMagick commands. """ - if len(self.title_text) == 0: + # No title text, or not being shown + if (len(self.title_text) == 0 + or (self.top_element != 'title' and self.bottom_element !='title')): return [] + # Determine vertical position based on which element the title is + if self.top_element == 'title': + vertical_shift = -700 + self.font_vertical_shift + else: + vertical_shift = 722 + self.font_vertical_shift + return [ f'-background transparent', f'\( -font "{self.font_file}"', @@ -184,7 +202,7 @@ def title_text_command(self) -> ImageMagickCommands: f'-layers merge', f'+repage \)', # Overlay text and shadow onto source image - f'-geometry +0-{700 + self.font_vertical_shift}', + f'-geometry +0{vertical_shift:+}', f'-composite', ] @@ -198,8 +216,8 @@ def index_text_command(self) -> ImageMagickCommands: List of ImageMagick commands. """ - # If the bottom element is not text, or all text is hidden, return - if (self.bottom_element != 'text' + # If not showing index text, or all text is hidden, return + if ((self.top_element != 'index' and self.bottom_element != 'index') or (self.hide_season_text and self.hide_episode_text)): return [] @@ -211,9 +229,16 @@ def index_text_command(self) -> ImageMagickCommands: else: index_text = f'{self.season_text} {self.separator} {self.episode_text}' + # Determine vertical position based on which element this text is + if self.top_element == 'index': + vertical_shift = -708 + else: + vertical_shift = 722 + return [ f'-background transparent', f'\( -font "{self.EPISODE_TEXT_FONT}"', + f'+kerning +interline-spacing', f'-pointsize {60}', f'-fill "{self.episode_text_color}"', f'label:"{index_text}"', @@ -226,7 +251,7 @@ def index_text_command(self) -> ImageMagickCommands: f'+repage \)', # Overlay text and shadow onto source image f'-gravity center', - f'-geometry +0+722', + f'-geometry +0{vertical_shift:+}', f'-composite', ] @@ -242,122 +267,178 @@ def logo_command(self) -> ImageMagickCommands: """ # Logo not indicated or not available, return empty commands - if (self.bottom_element != 'logo' + if ((self.top_element != 'logo' and self.bottom_element != 'logo') or self.logo is None or not self.logo.exists()): return [] + # Determine vertical position based on which element the logo is + vertical_shift = 700 * (-1 if self.top_element == 'logo' else +1) + return [ f'\( "{self.logo.resolve()}"', f'-resize x150 \)', f'-gravity center', - f'-geometry +0+700', + f'-geometry +0{vertical_shift:+}', f'-composite', ] @property - def frame_command(self) -> ImageMagickCommands: + def _frame_top_commands(self) -> ImageMagickCommands: """ - Subcommand to add the box that separates the outer (blurred) - image and the interior (unblurred) image. This box features a - drop shadow. The top part of the box is intersected by the title - text (if present), and the bottom part can be intersected by the - index text, logo, or not at all. + Subcommand to add the top of the frame, intersected by the + selected element. Returns: List of ImageMagick commands. """ + # Coordinates used by multiple rectangles INSET = self.BOX_OFFSET BOX_WIDTH = self.BOX_WIDTH - - # Coordinates used by multiple rectangles TopLeft = Coordinate(INSET, INSET) TopRight = Coordinate(self.WIDTH - INSET, INSET + BOX_WIDTH) - BottomLeft = Coordinate(INSET + BOX_WIDTH, self.HEIGHT - INSET) - BottomRight = Coordinate(self.WIDTH - INSET, self.HEIGHT - INSET) - # Determine top box draw commands - if len(self.title_text) == 0: - top_rectangle = Rectangle(TopLeft, TopRight) - top = [top_rectangle.draw()] - else: - title_width, _ = self.get_text_dimensions( - self.title_text_command, width='max', height='max', - ) - left_box_x = (self.WIDTH / 2) - (title_width / 2) - 10 - right_box_x = (self.WIDTH / 2) + (title_width / 2) + 10 + # This frame is uninterrupted, draw single rectangle + if (self.top_element == 'omit' + or (self.top_element == 'index' + and self.hide_season_text and self.hide_episode_text) + or (self.top_element == 'logo' + and (self.logo is None or not self.logo.exists())) + or (self.top_element == 'title' and len(self.title_text) == 0)): - top_left_rectangle = Rectangle( - TopLeft, - Coordinate(left_box_x, INSET + BOX_WIDTH) + return [ + Rectangle(TopLeft, TopRight).draw() + ] + + # Element is index text + if self.top_element == 'index': + element_width, _ = self.get_text_dimensions( + self.index_text_command, width='max', height='max', ) - top_right_rectangle = Rectangle( - Coordinate(right_box_x, INSET), - TopRight, + margin = 25 + # Element is logo + elif self.top_element == 'logo': + element_width, logo_height = self.get_image_dimensions(self.logo) + element_width /= (logo_height / 150) + margin = 25 + # Element is title text + elif self.top_element == 'title': + element_width, _ = self.get_text_dimensions( + self.title_text_command, width='max', height='max', ) + margin = 10 - top = [top_left_rectangle.draw(), top_right_rectangle.draw()] + # Determine bounds based on element width + left_box_x = (self.WIDTH / 2) - (element_width / 2) - margin + right_box_x = (self.WIDTH / 2) + (element_width / 2) + margin - # Left and right rectangles are never intersected by content - left_rectangle = Rectangle(TopLeft, BottomLeft) - left = [left_rectangle.draw()] - right_rectangle = Rectangle( - Coordinate(self.WIDTH - INSET - BOX_WIDTH, INSET), - BottomRight, + # Create Rectangles for these two frame sections + top_left_rectangle = Rectangle( + TopLeft, + Coordinate(left_box_x, INSET + BOX_WIDTH) + ) + top_right_rectangle = Rectangle( + Coordinate(right_box_x, INSET), + TopRight, ) - right = [right_rectangle.draw()] - - # Determine bottom box draw commands - # No bottom element, use singular full-width rectangle - if self.bottom_element == 'omit': - bottom_rectangle = Rectangle( - Coordinate(INSET, self.HEIGHT - INSET - BOX_WIDTH), - BottomRight - ) - bottom = [bottom_rectangle.draw()] - # Bottom element is logo, use boxes based on resized logo width - elif (self.bottom_element == 'logo' - and self.logo is not None - and self.logo.exists()): - logo_width, logo_height = self.get_image_dimensions(self.logo) - logo_width /= logo_height / 150 - - left_box_x = (self.WIDTH / 2) - (logo_width / 2) - 25 - right_box_x = (self.WIDTH / 2) + (logo_width / 2) + 25 - - bottom_left_rectangle = Rectangle( - Coordinate(INSET, self.HEIGHT - INSET - BOX_WIDTH), - Coordinate(left_box_x, self.HEIGHT - INSET), - ) - bottom_right_rectangle = Rectangle( - Coordinate(right_box_x, self.HEIGHT - INSET - BOX_WIDTH), - BottomRight, - ) - bottom = [ - bottom_left_rectangle.draw(), bottom_right_rectangle.draw() + return [ + top_left_rectangle.draw(), + top_right_rectangle.draw() + ] + + + @property + def _frame_bottom_commands(self) -> ImageMagickCommands: + """ + Subcommand to add the bottom of the frame, intersected by the + selected element. + + Returns: + List of ImageMagick commands. + """ + + # Coordinates used by multiple rectangles + INSET = self.BOX_OFFSET + BOX_WIDTH = self.BOX_WIDTH + BottomLeft = Coordinate(INSET + BOX_WIDTH, self.HEIGHT - INSET) + BottomRight = Coordinate(self.WIDTH - INSET, self.HEIGHT - INSET) + + # This frame is uninterrupted, draw single rectangle + if (self.bottom_element == 'omit' + or (self.bottom_element == 'index' + and self.hide_season_text and self.hide_episode_text) + or (self.bottom_element == 'logo' + and (self.logo is None or not self.logo.exists())) + or (self.bottom_element == 'title' and len(self.title_text) == 0)): + + return [ + Rectangle( + Coordinate(INSET, self.HEIGHT - INSET - BOX_WIDTH), + BottomRight + ).draw() ] - # Bottom element is index text, use boxes based on text width - elif self.bottom_element == 'text': - index_text_width, _ = self.get_text_dimensions( + + # Element is index text + if self.bottom_element == 'index': + element_width, _ = self.get_text_dimensions( self.index_text_command, width='max', height='max', ) - left_box_x = (self.WIDTH / 2) - (index_text_width / 2) - 25 - right_box_x = (self.WIDTH / 2) + (index_text_width / 2) + 25 - - bottom_left_rectangle = Rectangle( - Coordinate(INSET, self.HEIGHT - INSET - BOX_WIDTH), - Coordinate(left_box_x, self.HEIGHT - INSET) + margin = 25 + # Element is logo + elif self.bottom_element == 'logo': + element_width, logo_height = self.get_image_dimensions(self.logo) + element_width /= (logo_height / 150) + margin = 25 + # Element is title + elif self.bottom_element == 'title': + element_width, _ = self.get_text_dimensions( + self.title_text_command, width='max', height='max', ) - bottom_right_rectangle = Rectangle( - Coordinate(right_box_x, self.HEIGHT - INSET - BOX_WIDTH), + margin = 10 + + # Determine bounds based on element width + left_box_x = (self.WIDTH / 2) - (element_width / 2) - margin + right_box_x = (self.WIDTH / 2) + (element_width / 2) + margin + + # Create Rectangles for these two frame sections + bottom_left_rectangle = Rectangle( + Coordinate(INSET, self.HEIGHT - INSET - BOX_WIDTH), + Coordinate(left_box_x, self.HEIGHT - INSET) + ) + bottom_right_rectangle = Rectangle( + Coordinate(right_box_x, self.HEIGHT - INSET - BOX_WIDTH), + BottomRight, + ) + + return [ + bottom_left_rectangle.draw(), + bottom_right_rectangle.draw(), + ] + + + @property + def frame_command(self) -> ImageMagickCommands: + """ + Subcommand to add the box that separates the outer (blurred) + image and the interior (unblurred) image. This box features a + drop shadow. The top and bottom parts of the frame are + optionally intersected by a index text, title text, or a logo. + + Returns: + List of ImageMagick commands. + """ + + # Determine frame draw commands + top = self._frame_top_commands + left = [Rectangle(TopLeft, BottomLeft).draw()] + right = [Rectangle( + Coordinate(self.WIDTH - INSET - BOX_WIDTH, INSET), BottomRight, - ) - - bottom = [ - bottom_left_rectangle.draw(), bottom_right_rectangle.draw() - ] + ).draw() + ] + bottom = self._frame_bottom_commands return [ # Create blank canvas @@ -398,8 +479,8 @@ def modify_extras( if 'episode_text_color' in extras: extras['episode_text_color'] =\ TintedFrameTitleCard.EPISODE_TEXT_COLOR - if 'box_color' in extras: - extras['box_color'] = TintedFrameTitleCard.TITLE_COLOR + if 'frame_color' in extras: + extras['frame_color'] = TintedFrameTitleCard.TITLE_COLOR @staticmethod diff --git a/modules/ref/tinted_frame/Galey Semi Bold.ttf b/modules/ref/tinted_frame/Galey Semi Bold.ttf new file mode 100755 index 0000000000000000000000000000000000000000..f449a2fe3c646cb83ccdbf48c596218f9c01e93e GIT binary patch literal 120068 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 Date: Sun, 23 Apr 2023 18:08:25 -0600 Subject: [PATCH 42/44] Update TintedFrameTitleCard.py --- modules/cards/TintedFrameTitleCard.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules/cards/TintedFrameTitleCard.py b/modules/cards/TintedFrameTitleCard.py index a9ae1045..6baa256f 100755 --- a/modules/cards/TintedFrameTitleCard.py +++ b/modules/cards/TintedFrameTitleCard.py @@ -430,6 +430,14 @@ def frame_command(self) -> ImageMagickCommands: List of ImageMagick commands. """ + # Coordinates used by multiple rectangles + INSET = self.BOX_OFFSET + BOX_WIDTH = self.BOX_WIDTH + TopLeft = Coordinate(INSET, INSET) + TopRight = Coordinate(self.WIDTH - INSET, INSET + BOX_WIDTH) + BottomLeft = Coordinate(INSET + BOX_WIDTH, self.HEIGHT - INSET) + BottomRight = Coordinate(self.WIDTH - INSET, self.HEIGHT - INSET) + # Determine frame draw commands top = self._frame_top_commands left = [Rectangle(TopLeft, BottomLeft).draw()] From f0eb8a51c2765c5dc3077096e97d6d4cafe8d8e5 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Sun, 23 Apr 2023 21:42:45 -0600 Subject: [PATCH 43/44] Correct class reference in StarWarsTitleCard --- modules/cards/StarWarsTitleCard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/cards/StarWarsTitleCard.py b/modules/cards/StarWarsTitleCard.py index 1e8a041a..451ad705 100755 --- a/modules/cards/StarWarsTitleCard.py +++ b/modules/cards/StarWarsTitleCard.py @@ -189,8 +189,8 @@ def is_custom_font(font: 'Font') -> bool: True if a custom font is indicated, False otherwise. """ - return ((font.color != DividerTitleCard.TITLE_COLOR) - or (font.file != DividerTitleCard.TITLE_FONT) + return ((font.color != StarWarsTitleCard.TITLE_COLOR) + or (font.file != StarWarsTitleCard.TITLE_FONT) or (font.interline_spacing != 0) or (font.size != 1.0) ) From e38db9f4d2724f78e18de3e289aed4e65b187ff9 Mon Sep 17 00:00:00 2001 From: Collin Heist Date: Sun, 23 Apr 2023 22:53:47 -0600 Subject: [PATCH 44/44] Update version number to v.14.0 --- modules/ref/version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ref/version b/modules/ref/version index 7bf6258f..79f9beba 100755 --- a/modules/ref/version +++ b/modules/ref/version @@ -1 +1 @@ -v1.13.5 \ No newline at end of file +v1.14.0