diff --git a/Dockerfile b/Dockerfile index 00158bd61..f34314887 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ +ARG PYVERSION=3.11 + # Create pipenv image to convert Pipfile to requirements.txt -FROM python:3.11-slim as pipenv +FROM python:${PYVERSION}-slim as pipenv # Copy Pipfile and Pipfile.lock COPY Pipfile Pipfile.lock ./ @@ -8,7 +10,7 @@ COPY Pipfile Pipfile.lock ./ RUN pip3 install --no-cache-dir --upgrade pipenv; \ pipenv requirements > requirements.txt -FROM python:3.11-slim as python-reqs +FROM python:${PYVERSION}-slim as python-reqs # Copy requirements.txt from pipenv stage COPY --from=pipenv /requirements.txt requirements.txt @@ -19,7 +21,7 @@ RUN apt-get update; \ pip3 install --no-cache-dir -r requirements.txt # Set base image for running TCM -FROM python:3.11-slim +FROM python:${PYVERSION}-slim LABEL maintainer="CollinHeist" \ description="Automated title card maker for Plex" @@ -28,6 +30,7 @@ WORKDIR /maker COPY . /maker # Copy python packages from python-reqs +# update with python version COPY --from=python-reqs /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages # Script environment variables @@ -48,6 +51,8 @@ RUN set -eux; \ rm -rf /var/lib/apt/lists/*; \ cp modules/ref/policy.xml /etc/ImageMagick-6/policy.xml +VOLUME [ "/config" ] + # Entrypoint CMD ["python3", "main.py", "--run", "--no-color"] ENTRYPOINT ["bash", "./start.sh"] diff --git a/README.md b/README.md index 43a32129f..b7def0341 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ For invocation and configuration details, read [here](https://github.com/CollinH Below are examples of almost all the types of title card that can be created automatically by TitleCardMaker: ### Built-in Card Types -Anime comic book Cutout Divider Fade Frame Landscape Logo Olivier Poster Roman Standard Star Wars tinted Frame Tinted Glass White Border +Anime comic book Cutout Divider Fade Frame Landscape Logo Marvel Olivier Overline Poster Roman Standard Star Wars tinted Frame Tinted Glass White Border -> The above cards are, in order, the [anime](https://github.com/CollinHeist/TitleCardMaker/wiki/AnimeTitleCard), [comic book](https://github.com/CollinHeist/TitleCardMaker/wiki/ComicBookTitleCard), [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), [tinted glass](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedGlassTitleCard), and the [white border](https://github.com/CollinHeist/TitleCardMaker/wiki/WhiteBorderTitleCard) 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), [comic book](https://github.com/CollinHeist/TitleCardMaker/wiki/ComicBookTitleCard), [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), [marvel](https://github.com/CollinHeist/TitleCardMaker/wiki/MarvelTitleCard), [olivier](https://github.com/CollinHeist/TitleCardMaker/wiki/OlivierTitleCard), [overline](https://github.com/CollinHeist/TitleCardMaker/wiki/OverlineTitleCard), [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), [tinted glass](https://github.com/CollinHeist/TitleCardMaker/wiki/TintedGlassTitleCard), and the [white border](https://github.com/CollinHeist/TitleCardMaker/wiki/WhiteBorderTitleCard) title cards - the [textless](https://github.com/CollinHeist/TitleCardMaker/wiki/TitleCard) card is not shown.

User-Created Card Types

diff --git a/modules/BaseCardType.py b/modules/BaseCardType.py index ad2f613bd..70cc5e563 100755 --- a/modules/BaseCardType.py +++ b/modules/BaseCardType.py @@ -27,6 +27,9 @@ class BaseCardType(ImageMaker): """Default case string for all title text""" DEFAULT_FONT_CASE = 'upper' + """Default font replacements""" + FONT_REPLACEMENTS = {} + """Mapping of 'case' strings to format functions""" CASE_FUNCTIONS = { 'blank': lambda _: '', @@ -85,13 +88,6 @@ def TITLE_COLOR(self) -> str: raise NotImplementedError(f'All CardType objects must implement this') - @property - @abstractmethod - def FONT_REPLACEMENTS(self) -> dict: - """Standard font replacements for the episode title font""" - raise NotImplementedError(f'All CardType objects must implement this') - - @property @abstractmethod def USES_SEASON_TITLE(self) -> bool: @@ -136,10 +132,9 @@ def __init__(self, def __repr__(self) -> str: """Returns an unambiguous string representation of the object.""" - attributes = ', '.join( - f'{attr}={getattr(self, attr)!r}' for attr in self.__slots__ - if not attr.startswith('__') - ) + attributes = ', '.join(f'{attr}={getattr(self, attr)!r}' + for attr in self.__slots__ + if not attr.startswith('__')) return f'<{self.__class__.__name__} {attributes}>' diff --git a/modules/DatabaseInfoContainer.py b/modules/DatabaseInfoContainer.py index 166051db6..a824fdc38 100755 --- a/modules/DatabaseInfoContainer.py +++ b/modules/DatabaseInfoContainer.py @@ -38,7 +38,7 @@ def __eq__(self, other: 'DatabaseInfoContainer') -> bool: # Verify class comparison if not isinstance(other, self.__class__): - raise TypeError(f'Can only compare like DatabaseInfoContainer objects') + raise TypeError(f'Can only compare like DatabaseInfoContainers') # Compare each ID attribute in slots for attr in self.__slots__: diff --git a/modules/Font.py b/modules/Font.py index 15f0533ca..61f568638 100755 --- a/modules/Font.py +++ b/modules/Font.py @@ -18,9 +18,9 @@ class Font(YamlReader): """Valid YAML attributes to customize a font""" VALID_ATTRIBUTES = ( - 'validate', 'color', 'size', 'file', 'case', 'case_name', - 'replacements', 'vertical_shift', 'interline_spacing', - 'interword_spacing', 'kerning', 'stroke_width', + 'validate', 'color', 'size', 'file', 'case', 'replacements', + 'vertical_shift', 'interline_spacing', 'interword_spacing', 'kerning', + 'stroke_width', ) """Compiled regex to identify percentage values for scalars""" diff --git a/modules/JellyfinInterface.py b/modules/JellyfinInterface.py index 30b26d038..263be2d69 100755 --- a/modules/JellyfinInterface.py +++ b/modules/JellyfinInterface.py @@ -40,10 +40,13 @@ class JellyfinInterface(EpisodeDataSource, MediaServer, SyncInterface): AIRDATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%f000000Z' - def __init__(self, url: str, api_key: str, + def __init__(self, + url: str, + api_key: str, username: Optional[str] = None, verify_ssl: bool = True, - filesize_limit: Optional[int] = None) -> None: + filesize_limit: Optional[int] = None, + ) -> None: """ Construct a new instance of an interface to a Jellyfin server. @@ -367,6 +370,11 @@ def get_all_episodes(self, params={'Fields': 'ProviderIds'} | self.__params ) + if not isinstance(response, dict) or 'Items' not in response: + log.error(f'Jellyfin returned bad Episode data for {series_info}') + log.debug(f'{response=}') + return [] + # Parse each returned episode into EpisodeInfo object all_episodes = [] for episode in response['Items']: diff --git a/modules/Manager.py b/modules/Manager.py index fd6477032..c060b0770 100755 --- a/modules/Manager.py +++ b/modules/Manager.py @@ -8,6 +8,7 @@ from modules.Debug import log, TQDM_KWARGS from modules.JellyfinInterface import JellyfinInterface from modules.PlexInterface import PlexInterface +from modules.Show import Show from modules.ShowArchive import ShowArchive from modules.SonarrInterface import SonarrInterface from modules.TautulliInterface import TautulliInterface @@ -108,8 +109,8 @@ def __init__(self, check_tautulli: bool = True) -> None: ) # Setup blank show and archive lists - self.shows = [] - self.archives = [] + self.shows: list[Show] = [] + self.archives: list[ShowArchive] = [] def sync_series_files(self) -> None: diff --git a/modules/MediaInfoSet.py b/modules/MediaInfoSet.py index 038604898..5d5988bbc 100755 --- a/modules/MediaInfoSet.py +++ b/modules/MediaInfoSet.py @@ -140,7 +140,7 @@ def get_series_info(self, self.series_info_db.insert({ 'full_name': full_name, 'emby_id': emby_id, 'imdb_id': imdb_id, 'jellyfin_id': jellyfin_id, 'sonarr_id': sonarr_id, - 'tmdb_id': tmdb_id, 'tvdb_id': tvdb_id, 'tvrage_id': tvrage_id, + 'tmdb_id': tmdb_id, 'tvdb_id': tvdb_id, 'tvrage_id': tvrage_id, }) return series_info diff --git a/modules/PlexInterface.py b/modules/PlexInterface.py index a62688aa5..dd20016fb 100755 --- a/modules/PlexInterface.py +++ b/modules/PlexInterface.py @@ -199,9 +199,11 @@ def __get_series(self, # Try by name try: - 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): + results = library.search( + title=series_info.name, year=series_info.year, libtype='show' + ) + for series in results: + if series_info.matches(series.title): return series except NotFound: pass @@ -217,7 +219,8 @@ def __get_series(self, @catch_and_log('Error getting library paths', default={}) 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. @@ -653,7 +656,8 @@ def get_libraries(self) -> list[str]: reraise=True) def __retry_upload(self, plex_object: Union[PlexEpisode, PlexSeason], - filepath: Path) -> None: + filepath: Path, + ) -> None: """ Upload the given poster to the given Episode, retrying if it fails. @@ -828,7 +832,8 @@ def set_season_posters(self, @catch_and_log('Error getting episode details') def get_episode_details(self, - rating_key: int) -> list[tuple[SeriesInfo, EpisodeInfo, str]]: + rating_key: int, + ) -> list[tuple[SeriesInfo, EpisodeInfo, str]]: """ Get all details for all episodes indicated by the given Plex rating key. diff --git a/modules/Profile.py b/modules/Profile.py index fb6069e21..0ac31431d 100755 --- a/modules/Profile.py +++ b/modules/Profile.py @@ -4,10 +4,12 @@ from modules.Debug import log from modules.EpisodeInfo import EpisodeInfo +from modules.EpisodeMap import EpisodeMap from modules.Font import Font from modules.MultiEpisode import MultiEpisode from modules.SeriesInfo import SeriesInfo + class Profile: """ This class describes a profile. A profile defines whether to use @@ -27,8 +29,9 @@ def __init__(self, series_info: SeriesInfo, font: Font, hide_seasons: bool, - episode_map: 'EpisodeMap', - episode_text_format: str) -> None: + episode_map: EpisodeMap, + episode_text_format: str, + ) -> None: """ Construct a new instance of a Profile. All given arguments will be applied through this Profile (and whether it's generic or @@ -140,9 +143,9 @@ def convert_profile(self, seasons: str, font: str) -> None: """ # Update this object's data - self.__use_custom_seasons = (seasons in ('custom', 'hidden')) - self.hide_season_title = (seasons == 'hidden') - self.__use_custom_font = (font == 'custom') + self.__use_custom_seasons = seasons in ('custom', 'hidden') + self.hide_season_title = seasons == 'hidden' + self.__use_custom_font = font == 'custom' # If the new profile has a generic font, reset font attributes if not self.__use_custom_font: @@ -220,8 +223,9 @@ def get_episode_text(self, episode: 'Episode') -> str: log.warning(f'Episode text formatting uses absolute episode number,' f' but {episode} has no absolute number - using episode' f' number instead') - format_string = self.episode_text_format.replace('{abs_', - '{episode_') + format_string = self.episode_text_format.replace( + '{abs_', '{episode_' + ) # Format MultiEpisode episode text if isinstance(episode, MultiEpisode): diff --git a/modules/RemoteFile.py b/modules/RemoteFile.py index 903d71a0d..366513821 100755 --- a/modules/RemoteFile.py +++ b/modules/RemoteFile.py @@ -7,6 +7,7 @@ from modules.Debug import log from modules.PersistentDatabase import PersistentDatabase + class RemoteFile: """ This class describes a RemoteFile. A RemoteFile is a file that is @@ -92,8 +93,10 @@ def __str__(self) -> str: def __repr__(self) -> str: """Returns an unambiguous string representation of the object.""" - return (f'') + return ( + f'' + ) def resolve(self) -> Path: @@ -117,7 +120,7 @@ def __get_remote_content(self) -> Response: Response object from this object's remote source. """ - return get(self.remote_source, timeout=30) + return get(self.remote_source, timeout=10) def download(self) -> None: diff --git a/modules/SeriesYamlWriter.py b/modules/SeriesYamlWriter.py index 5e58e5fe9..8f38ac1fe 100755 --- a/modules/SeriesYamlWriter.py +++ b/modules/SeriesYamlWriter.py @@ -169,7 +169,7 @@ def yaml_contains(yaml: dict, key: str) -> tuple[bool, str]: return True, key # If present in lowercase for yaml_key in yaml.get('series', {}).keys(): - if key.lower() == yaml_key.lower(): + if str(key).lower() == yaml_key.lower(): return True, yaml_key # Not present at all return False, key diff --git a/modules/StandardSummary.py b/modules/StandardSummary.py index 9228ce4cd..3bfbb1ad3 100755 --- a/modules/StandardSummary.py +++ b/modules/StandardSummary.py @@ -95,7 +95,7 @@ def _create_montage(self) -> Path: f'-tile 3x3', f'-geometry +80+80', f'-shadow', - f'"'+'" "'.join(self.inputs)+'"', # Wrap each filename in "" + f'"'+'" "'.join(self.inputs)+'"', # Wrap each filename in "" f'"{self.__MONTAGE_PATH.resolve()}"', ]) @@ -201,7 +201,8 @@ def _add_logo(self, montage: Path, logo: Path) -> Path: def _add_created_by(self, montage_and_logo: Path, - created_by: Path) -> Path: + created_by: Path, + ) -> Path: """ Add the 'created by' image to the bottom of the montage. @@ -232,7 +233,8 @@ def _add_created_by(self, def __add_background_image(self, montage_and_logo: Path, - created_by: Path) -> Path: + created_by: Path, + ) -> Path: """ Add the two images on top of the background image. diff --git a/modules/TitleCard.py b/modules/TitleCard.py index 3514eac5f..be038710c 100755 --- a/modules/TitleCard.py +++ b/modules/TitleCard.py @@ -17,7 +17,9 @@ from modules.cards.FrameTitleCard import FrameTitleCard from modules.cards.LandscapeTitleCard import LandscapeTitleCard from modules.cards.LogoTitleCard import LogoTitleCard +from modules.cards.MarvelTitleCard import MarvelTitleCard from modules.cards.OlivierTitleCard import OlivierTitleCard +from modules.cards.OverlineTitleCard import OverlineTitleCard from modules.cards.PosterTitleCard import PosterTitleCard from modules.cards.RomanNumeralTitleCard import RomanNumeralTitleCard from modules.cards.StandardTitleCard import StandardTitleCard @@ -70,8 +72,10 @@ class TitleCard: 'ishalioh': OlivierTitleCard, 'landscape': LandscapeTitleCard, 'logo': LogoTitleCard, + 'marvel': MarvelTitleCard, 'musikmann': WhiteBorderTitleCard, 'olivier': OlivierTitleCard, + 'overline': OverlineTitleCard, 'phendrena': CutoutTitleCard, 'photo': FrameTitleCard, 'polymath': StandardTitleCard, diff --git a/modules/cards/MarvelTitleCard.py b/modules/cards/MarvelTitleCard.py new file mode 100755 index 000000000..8a95e3fab --- /dev/null +++ b/modules/cards/MarvelTitleCard.py @@ -0,0 +1,498 @@ +from pathlib import Path +from typing import Literal, Optional + +from modules.BaseCardType import BaseCardType, ImageMagickCommands +from modules.ImageMagickInterface import Dimensions + + +class Coordinate: + """Class that defines a single Coordinate on an x/y plane.""" + __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: + """Class that defines movable SVG 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: + """Draw this Rectangle""" + return f'-draw "{str(self)}"' + + +class MarvelTitleCard(BaseCardType): + """ + This class describes a CardType that produces title cards intended to match + RedHeadJedi's style of Marvel Cinematic Universe posters. These cards + feature a white border on the left, top, and right edges, and a black box on + the bottom. All text is displayed in the bottom box. + """ + + """Directory where all reference files used by this card are stored""" + REF_DIRECTORY = BaseCardType.BASE_REF_DIRECTORY / 'marvel' + + """Characteristics for title splitting by this class""" + TITLE_CHARACTERISTICS = { + 'max_line_width': 25, # Character count to begin splitting titles + 'max_line_count': 1, # Maximum number of lines a title can take up + 'top_heavy': False, # This class uses top heavy titling + } + + """Characteristics of the default title font""" + TITLE_FONT = str((REF_DIRECTORY / 'Qualion ExtraBold.ttf').resolve()) + TITLE_COLOR = 'white' + DEFAULT_FONT_CASE = 'upper' + FONT_REPLACEMENTS = {} + + """Characteristics of the episode text""" + EPISODE_TEXT_COLOR = '#C9C9C9' + EPISODE_TEXT_FONT = REF_DIRECTORY / 'Qualion ExtraBold.ttf' + + """Whether this CardType uses season titles for archival purposes""" + USES_SEASON_TITLE = True + + """Standard class has standard archive name""" + ARCHIVE_NAME = 'Marvel Style' + + """How thick the border is (in pixels)""" + DEFAULT_BORDER_SIZE = 55 + + """Color of the border""" + DEFAULT_BORDER_COLOR = 'white' + + """Color of the text box""" + DEFAULT_TEXT_BOX_COLOR = 'black' + + """Height of the text box (in pixels)""" + DEFAULT_TEXT_BOX_HEIGHT = 200 + + __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_interword_spacing', 'font_kerning', 'font_vertical_shift', + 'border_color', 'border_size', 'episode_text_color', 'fit_text', + 'episode_text_position', 'hide_border', 'text_box_color', + 'text_box_height', 'font_size_modifier', + ) + + 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_interword_spacing: int = 0, + font_kerning: float = 1.0, + font_size: float = 1.0, + font_vertical_shift: int = 0, + blur: bool = False, + grayscale: bool = False, + border_color: str = DEFAULT_BORDER_COLOR, + border_size: int = DEFAULT_BORDER_SIZE, + episode_text_color: str = EPISODE_TEXT_COLOR, + episode_text_location: Literal['compact', 'fixed'] = 'fixed', + fit_text: bool = True, + hide_border: bool = False, + text_box_color: str = DEFAULT_TEXT_BOX_COLOR, + text_box_height: int = DEFAULT_TEXT_BOX_HEIGHT, + preferences: Optional['Preferences'] = None, # type: ignore + **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_interword_spacing = font_interword_spacing + self.font_kerning = font_kerning + self.font_size = font_size + self.font_size_modifier = 1.0 + self.font_vertical_shift = font_vertical_shift + + # Optional extras + self.border_color = border_color + self.border_size = border_size + self.episode_text_color = episode_text_color + self.episode_text_position = episode_text_location + self.fit_text = fit_text + self.hide_border = hide_border + self.text_box_color = text_box_color + self.text_box_height = text_box_height + + + @property + def title_text_commands(self) -> ImageMagickCommands: + """ + Subcommand for adding title text to the source image. + + Returns: + List of ImageMagick commands. + """ + + # No title text, or not being shown + if len(self.title_text) == 0: + return [] + + # Font characteristics + size = 150 * self.font_size * self.font_size_modifier + kerning = 1.0 * self.font_kerning * self.font_size_modifier + # When the modifier is <1.0, the text can appear shifted down - adjust + vertical_shift = 820 \ + + self.font_vertical_shift \ + - ((self.font_size_modifier - 1.0) * -10) + # Map the modifier [0.0, 1.0] -> [-10, 0] pixels + + return [ + f'-font "{self.font_file}"', + f'-fill "{self.font_color}"', + f'-pointsize {size}', + f'-kerning {kerning}', + f'-interline-spacing {self.font_interline_spacing}', + f'-interword-spacing {self.font_interword_spacing}', + f'-gravity center', + f'-annotate +0+{vertical_shift} "{self.title_text}"', + ] + + + def season_text_commands(self, + title_text_dimensions: Dimensions, + ) -> ImageMagickCommands: + """ + Subcommands for adding episode text to the source image. + + Args: + title_text_dimensions: Dimensions of the title text. For + positioning the text in compact positioning mode. + + Returns: + List of ImageMagick commands. + """ + + # Return if not showing text + if self.hide_season_text: + return [] + + # Vertical positioning of text + y_position = 810 + self.font_vertical_shift + + text_command = [] + if self.episode_text_position == 'compact': + x_position = (self.WIDTH + title_text_dimensions.width) / 2 + 20 + text_command = [ + f'-gravity east', + f'-annotate {x_position:+}{y_position:+} "{self.season_text}"', + ] + else: + text_command = [ + f'-gravity west', + f'-annotate +{self.border_size}{y_position:+} "{self.season_text}"', + ] + + font_size = 70 * self.font_size_modifier + + return [ + f'-font "{self.EPISODE_TEXT_FONT.resolve()}"', + f'-fill "{self.episode_text_color}"', + f'-pointsize {font_size}', + f'-kerning 1', + f'-interword-spacing 15', + *text_command, + ] + + + def episode_text_commands(self, + title_text_dimensions: Dimensions, + ) -> ImageMagickCommands: + """ + Subcommands for adding episode text to the source image. + + Args: + title_text_dimensions: Dimensions of the title text. For + positioning the text in compact positioning mode. + + Returns: + List of ImageMagick commands. + """ + + # Return if not showing text + if self.hide_episode_text: + return [] + + # Vertical positioning of text + y_position = 810 + self.font_vertical_shift + + text_command = [] + if self.episode_text_position == 'compact': + x_position = (self.WIDTH + title_text_dimensions.width) / 2 + 20 + text_command = [ + f'-gravity west', + f'-annotate {x_position:+}{y_position:+} "{self.episode_text}"', + ] + else: + text_command = [ + f'-gravity east', + f'-annotate +{self.border_size}{y_position:+} "{self.episode_text}"', + ] + + font_size = 70 * self.font_size_modifier + + return [ + f'-font "{self.EPISODE_TEXT_FONT.resolve()}"', + f'-fill "{self.episode_text_color}"', + f'-pointsize {font_size}', + f'-kerning 1', + f'-interword-spacing 15', + *text_command, + ] + + + def scale_text(self, title_text_dimensions: Dimensions) -> Dimensions: + """ + Set the font size modifier to scale the title and index text and + ensure it fits in the image. + + Args: + title_text_dimensions: Dimensions of the title text for + determining the scaling factor. + + Returns: + New dimensions of the title text. If `fit_text` is False, + or if the text is not scaled, then the original dimensions + are returned. + """ + + # If not fitting text, return original dimensions + if not self.fit_text: + return title_text_dimensions + + # Get dimensions of season and episode text + season_text_dimensions = self.get_text_dimensions( + self.season_text_commands(title_text_dimensions), + width='sum', height='sum', + ) + episode_text_dimensions = self.get_text_dimensions( + self.episode_text_commands(title_text_dimensions), + width='sum', height='sum', + ) + + # Check left/right separately for overlap + half_title = title_text_dimensions.width / 2 + left_width = half_title + season_text_dimensions.width + right_width = half_title + episode_text_dimensions.width + + # Add margin + left_width += 0 if self.hide_season_text else 40 + right_width += 0 if self.hide_episode_text else 40 + + # If either side is too wide, scale by largest size + max_width = (self.WIDTH / 2) - self.border_size + if left_width > max_width or right_width > max_width: + self.font_size_modifier = min( + max_width / left_width, + max_width / right_width, + ) + + # If font scalar was modified, recalculate+return text dimensions + if self.font_size_modifier < 1.0: + return self.get_text_dimensions( + self.title_text_commands, width='max', height='sum', + ) + + # Scalar unmodified, return original dimensions + return title_text_dimensions + + + @property + def border_commands(self) -> ImageMagickCommands: + """ + Subcommands to add the border to the image. + + Returns: + List of ImageMagick commands. + """ + + # Border is not being shown, skip + if self.hide_border: + return [] + + # Get each rectangle + left_rectangle = Rectangle( + Coordinate(0, 0), + Coordinate(self.border_size, self.HEIGHT) + ) + top_rectangle = Rectangle( + Coordinate(0, 0), + Coordinate(self.WIDTH, self.border_size) + ) + right_rectangle = Rectangle( + Coordinate(self.WIDTH - self.border_size, 0), + Coordinate(self.WIDTH, self.HEIGHT) + ) + + return [ + f'-fill "{self.border_color}"', + left_rectangle.draw(), + top_rectangle.draw(), + right_rectangle.draw(), + ] + + + @property + def bottom_border_commands(self) -> ImageMagickCommands: + """ + Subcommands to add the bottom border to the image. + + Returns: + List of ImageMagick commands. + """ + + rectangle = Rectangle( + Coordinate(0, self.HEIGHT - self.text_box_height), + Coordinate(self.WIDTH, self.HEIGHT) + ) + + return [ + f'-fill "{self.text_box_color}"', + rectangle.draw(), + ] + + + @staticmethod + def modify_extras( + extras: dict, + 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'] =MarvelTitleCard.EPISODE_TEXT_COLOR + if 'border_color' in extras: + extras['border_color'] = 'white' + + + @staticmethod + def is_custom_font(font: 'Font') -> bool: # type: ignore + """ + 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 != MarvelTitleCard.TITLE_COLOR) + or (font.file != MarvelTitleCard.TITLE_FONT) + or (font.interline_spacing != 0) + or (font.interword_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 = MarvelTitleCard.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. + """ + + # Get the dimensions of the title and index text + title_text_dimensions = self.get_text_dimensions( + self.title_text_commands, width='max', height='sum', + ) + + # Apply any font scaling to fit text + title_text_dimensions = self.scale_text(title_text_dimensions) + + command = ' '.join([ + f'convert "{self.source_file.resolve()}"', + # Resize and apply styles to source image + *self.resize_and_style, + # Resize to only fit in the bounds of the border + f'-resize {self.WIDTH - (2 * self.border_size)}x', + f'-extent {self.TITLE_CARD_SIZE}', + # Add borders + *self.border_commands, + *self.bottom_border_commands, + # Add text + *self.title_text_commands, + *self.season_text_commands(title_text_dimensions), + *self.episode_text_commands(title_text_dimensions), + # Create card + *self.resize_output, + f'"{self.output_file.resolve()}"', + ]) + + self.image_magick.run(command) diff --git a/modules/cards/OlivierTitleCard.py b/modules/cards/OlivierTitleCard.py index 63940d9a6..9a00750f8 100755 --- a/modules/cards/OlivierTitleCard.py +++ b/modules/cards/OlivierTitleCard.py @@ -44,7 +44,7 @@ class OlivierTitleCard(BaseCardType): 'episode_prefix', 'episode_text', 'font_color', 'font_file', 'font_interline_spacing', 'font_interword_spacing', 'font_kerning', 'font_size', 'font_stroke_width', 'font_vertical_shift', 'stroke_color', - 'episode_text_color', + 'episode_text_color', 'episode_text_vertical_shift', ) def __init__(self, @@ -64,6 +64,7 @@ def __init__(self, blur: bool = False, grayscale: bool = False, episode_text_color: str = EPISODE_TEXT_COLOR, + episode_text_vertical_shift: int = 0, stroke_color: str = STROKE_COLOR, preferences: Optional['Preferences'] = None, # type: ignore **unused, @@ -105,6 +106,7 @@ def __init__(self, # Optional extras self.episode_text_color = episode_text_color + self.episode_text_vertical_shift = episode_text_vertical_shift self.stroke_color = stroke_color @@ -156,6 +158,8 @@ def episode_prefix_command(self) -> ImageMagickCommands: if self.episode_prefix is None or self.hide_episode_text: return [] + vertical_shift = -150 + self.episode_text_vertical_shift + return [ f'-gravity west', f'-font "{self.EPISODE_PREFIX_FONT.resolve()}"', @@ -164,11 +168,11 @@ def episode_prefix_command(self) -> ImageMagickCommands: f'-fill black', f'-stroke black', f'-strokewidth 5', - f'-annotate +325-150 "{self.episode_prefix}"', + f'-annotate +325{vertical_shift:+} "{self.episode_prefix}"', f'-fill "{self.episode_text_color}"', f'-stroke "{self.episode_text_color}"', f'-strokewidth 0', - f'-annotate +325-150 "{self.episode_prefix}"', + f'-annotate +325{vertical_shift:+} "{self.episode_prefix}"', ] @@ -186,6 +190,9 @@ def episode_number_text_command(self) -> ImageMagickCommands: if self.hide_episode_text: return [] + # Vertical shift + vertical_shift = -150 + self.episode_text_vertical_shift + # Get variable horizontal offset based of episode prefix text_offset = {'EPISODE': 425, 'CHAPTER': 425, 'PART': 275} if self.episode_prefix is None: @@ -204,11 +211,11 @@ def episode_number_text_command(self) -> ImageMagickCommands: f'-fill black', f'-stroke black', f'-strokewidth 7', - f'-annotate +{325+offset}-150 "{self.episode_text}"', + f'-annotate +{325+offset}{vertical_shift:+} "{self.episode_text}"', f'-fill "{self.episode_text_color}"', f'-stroke "{self.episode_text_color}"', f'-strokewidth 1', - f'-annotate +{325+offset}-150 "{self.episode_text}"', + f'-annotate +{325+offset}{vertical_shift:+} "{self.episode_text}"', ] @@ -233,6 +240,8 @@ def modify_extras( if 'episode_text_color' in extras: extras['episode_text_color'] =\ OlivierTitleCard.EPISODE_TEXT_COLOR + if 'episode_text_vertical_shift' in extras: + extras['episode_text_vertical_shift'] = 0 if 'stroke_color' in extras: extras['stroke_color'] = 'black' diff --git a/modules/cards/OverlineTitleCard.py b/modules/cards/OverlineTitleCard.py new file mode 100755 index 000000000..a46f2d563 --- /dev/null +++ b/modules/cards/OverlineTitleCard.py @@ -0,0 +1,433 @@ +from pathlib import Path +from typing import Literal, Optional + +from modules.BaseCardType import BaseCardType, ImageMagickCommands +from modules.ImageMagickInterface import Dimensions + + +class Coordinate: + """Class that defines a single Coordinate on an x/y plane.""" + __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: + """Class that defines movable SVG 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: + """Draw this Rectangle""" + return f'-draw "{str(self)}"' + + +class OverlineTitleCard(BaseCardType): + """ + This class describes a CardType that produces title cards featuring + a thin line over (or under) the title text. This line is + interesected by the episode text, and can be recolored. + """ + + """Directory where all reference files used by this card are stored""" + REF_DIRECTORY = BaseCardType.BASE_REF_DIRECTORY / 'overline' + + """Characteristics for title splitting by this class""" + TITLE_CHARACTERISTICS = { + 'max_line_width': 30, # Character count to begin splitting titles + 'max_line_count': 2, # Maximum number of lines a title can take up + 'top_heavy': False, # This class uses top heavy titling + } + + """Characteristics of the default title font""" + TITLE_FONT = str((REF_DIRECTORY / 'HelveticaNeueMedium.ttf').resolve()) + TITLE_COLOR = 'white' + DEFAULT_FONT_CASE = 'upper' + FONT_REPLACEMENTS = {} + + """Characteristics of the episode text""" + EPISODE_TEXT_COLOR = TITLE_COLOR + EPISODE_TEXT_FONT = ( + BaseCardType.BASE_REF_DIRECTORY / 'Proxima Nova Semibold.otf' + ) + + """Whether this CardType uses season titles for archival purposes""" + USES_SEASON_TITLE = True + + """Standard class has standard archive name""" + ARCHIVE_NAME = 'Overline Style' + + """How thick the line is (in pixels)""" + LINE_THICKNESS = 7 + + """Gradient to overlay""" + GRADIENT_IMAGE = REF_DIRECTORY / 'small_gradient.png' + + __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_interword_spacing', 'font_kerning', 'font_stroke_width', + 'font_vertical_shift', 'episode_text_color', 'line_color', 'hide_line', + 'line_position', 'line_width', 'omit_gradient', 'separator', + ) + + 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_interword_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, + episode_text_color: str = EPISODE_TEXT_COLOR, + hide_line: bool = False, + line_color: str = TITLE_COLOR, + line_position: Literal['top', 'bottom'] = 'top', + line_width: int = LINE_THICKNESS, + omit_gradient: bool = False, + separator: str = '-', + preferences: Optional['Preferences'] = None, # type: ignore + **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 + self.hide_episode_text = hide_episode_text + + # Font/card customizations + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing + self.font_interword_spacing = font_interword_spacing + self.font_kerning = font_kerning + self.font_size = font_size + self.font_stroke_width = font_stroke_width + self.font_vertical_shift = font_vertical_shift + + # Optional extras + self.episode_text_color = episode_text_color + self.hide_line = hide_line + self.line_color = line_color + self.line_position = line_position + self.line_width = line_width + self.omit_gradient = omit_gradient + self.separator = separator + + + @property + def gradient_commands(self) -> ImageMagickCommands: + """ + Subcommand to add the gradient overlay to the source image. + + Returns: + List of ImageMagick commands. + """ + + if self.omit_gradient: + return [] + + return [ + f'"{self.GRADIENT_IMAGE.resolve()}"', + f'-composite', + ] + + + @property + def title_text_commands(self) -> ImageMagickCommands: + """ + Subcommand for adding title text to the source image. + + Returns: + List of ImageMagick commands. + """ + + # No title text, or not being shown + if len(self.title_text) == 0: + return [] + + # Position of the text is based on where the line is + vertical_position = self.font_vertical_shift + if self.line_position == 'top': + vertical_position += 70 + else: + vertical_position += 110 + + # Font characteristics + size = 55 * self.font_size + interline_spacing = 25 + self.font_interline_spacing + interword_spacing = 50 + self.font_interword_spacing + kerning = -2 * self.font_kerning + stroke_width = 5 * self.font_stroke_width + + return [ + f'-density 200', + f'-gravity south', + f'-font "{self.font_file}"', + f'-fill "{self.font_color}"', + f'-pointsize {size}', + f'-strokewidth {stroke_width}', + f'-stroke black', + f'-kerning {kerning}', + f'-interline-spacing {interline_spacing}', + f'-interword-spacing {interword_spacing}', + f'-annotate +0+{vertical_position} "{self.title_text}"', + ] + + + @property + def index_text_commands(self) -> ImageMagickCommands: + """ + Subcommands for adding index text to the source image. + + Returns: + List of ImageMagick commands. + """ + + # If not showing index text, return + if 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}' + + # Determine vertical position based on which element this text is + vertical_shift = self.font_vertical_shift + if self.line_position == 'top': + vertical_shift += 232 + else: + vertical_shift += 65 + + return [ + f'-density 200', + # f'-font "{self.EPISODE_TEXT_FONT.resolve()}"', + f'-font "{self.REF_DIRECTORY.parent / "Proxima Nova Semibold.otf"}"', + f'-fill "{self.episode_text_color}"', + f'-strokewidth 2', + f'-pointsize 22', + f'-interword-spacing 18', + f'-annotate +0+{vertical_shift} "{index_text}"' + ] + + + def line_commands(self, + title_text_dimensions: Dimensions, + index_text_dimensions: Dimensions, + ) -> ImageMagickCommands: + """ + Subcommands to add the over/underline to the image. + + Args: + title_text_dimensions: Dimensions of the title text. + index_text_dimensions: Dimensions of the index text. + + Returns: + List of ImageMagick commands. + """ + + # Line is not being shown, skip + if self.hide_line: + return [] + + # Determine starting vertical offset of the lines + vertical_position = self.font_vertical_shift + if self.line_position == 'top': + vertical_position += 265 + else: + vertical_position += 98 + vertical_position = self.HEIGHT - vertical_position + + # If index text is gone, draw singular rectangle + if self.hide_season_text and self.hide_episode_text: + right_rectangle = Rectangle(Coordinate(0, 0), Coordinate(0, 0)) + left_rectangle = Rectangle( + Coordinate( + (self.WIDTH / 2) - (title_text_dimensions.width / 2) + 30, + vertical_position - (self.line_width / 2) + ), + Coordinate( + (self.WIDTH / 2) + (title_text_dimensions.width / 2) - 30, + vertical_position + (self.line_width / 2), + ) + ) + else: + # Create left rectangle + left_rectangle = Rectangle( + Coordinate( + (self.WIDTH / 2) - (title_text_dimensions.width / 2) + 30, + vertical_position - (self.line_width / 2), + ), + Coordinate( + (self.WIDTH / 2) - (index_text_dimensions.width / 2), + vertical_position + (self.line_width / 2), + ) + ) + + # Create right rectangle + right_rectangle = Rectangle( + Coordinate( + (self.WIDTH / 2) + (index_text_dimensions.width / 2), + vertical_position - (self.line_width / 2), + ), + Coordinate( + (self.WIDTH / 2) + (title_text_dimensions.width / 2) - 30, + vertical_position + (self.line_width / 2), + ) + ) + + # Draw nothing if either rectangle would invert or is too short + if (left_rectangle.start.x > left_rectangle.end.x + or right_rectangle.start.x > right_rectangle.end.x + or left_rectangle.end.x - left_rectangle.start.x < 20 + or right_rectangle.end.x - right_rectangle.start.x < 20): + return [] + + return [ + f'-fill "{self.line_color}"', + f'-stroke black', + f'-strokewidth 2', + left_rectangle.draw(), + right_rectangle.draw(), + ] + + + @staticmethod + def modify_extras( + extras: dict, + 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'] =\ + OverlineTitleCard.EPISODE_TEXT_COLOR + if 'line_color' in extras: + extras['line_color'] = OverlineTitleCard.TITLE_COLOR + + + @staticmethod + def is_custom_font(font: 'Font') -> bool: # type: ignore + """ + 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 != OverlineTitleCard.TITLE_COLOR) + or (font.file != OverlineTitleCard.TITLE_FONT) + or (font.interline_spacing != 0) + or (font.interword_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 = OverlineTitleCard.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. + """ + + # Get the dimensions of the title and index text + title_text_dimensions = self.get_text_dimensions( + self.title_text_commands, width='max', height='sum', + ) + index_text_dimensions = self.get_text_dimensions( + self.index_text_commands, width='max', height='sum', + ) + + command = ' '.join([ + f'convert "{self.source_file.resolve()}"', + # Resize and apply styles to source image + *self.resize_and_style, + # Add gradient overlay + *self.gradient_commands, + # Add text + *self.title_text_commands, + *self.index_text_commands, + # Add line + *self.line_commands(title_text_dimensions, index_text_dimensions), + # Create card + *self.resize_output, + f'"{self.output_file.resolve()}"', + ]) + + self.image_magick.run(command) diff --git a/modules/cards/TintedFrameTitleCard.py b/modules/cards/TintedFrameTitleCard.py index e0c958150..6ca69b5fe 100755 --- a/modules/cards/TintedFrameTitleCard.py +++ b/modules/cards/TintedFrameTitleCard.py @@ -221,8 +221,8 @@ def blur_commands(self) -> ImageMagickCommands: List of ImageMagick Commands. """ - # Blurring is disabled, return empty command - if not self.blur_edges: + # Blurring is disabled (or being applied globally), return empty command + if not self.blur_edges or self.blur: return [] crop_width = self.WIDTH - (2 * self.BOX_OFFSET) - 6 # 6px margin @@ -536,7 +536,7 @@ def _frame_bottom_commands(self) -> ImageMagickCommands: @property def frame_commands(self) -> ImageMagickCommands: """ - Subcommand to add the box that separates the outer (blurred) + Subcommands 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. @@ -583,6 +583,34 @@ def frame_commands(self) -> ImageMagickCommands: ] + @property + def mask_commands(self) -> ImageMagickCommands: + """ + Subcommands to add the top-level mask which overlays all other + elements of the image, even the frame. This mask can be used to + have parts of the image appear to "pop out" of the frame. + + Returns: + List of ImageMagick commands. + """ + + # Do not apply mask if stylized + if self.blur or self.grayscale: + return [] + + # Look for mask file corresponding to this source image + mask = self.source_file.parent / f'{self.source_file.stem}-mask.png' + + # Mask exists, return commands to compose atop image + if mask.exists(): + return [ + f'\( "{mask.resolve()}"', + *self.resize_and_style, + f'\) -composite', + ] + + return [] + @staticmethod def modify_extras( extras: dict, @@ -684,6 +712,8 @@ def create(self) -> None: *self.index_text_commands, *self.logo_commands, *self.frame_commands, + # Attempt to overlay mask + *self.mask_commands, # Create card *self.resize_output, f'"{self.output_file.resolve()}"', diff --git a/modules/cards/TintedGlassTitleCard.py b/modules/cards/TintedGlassTitleCard.py index dd5fcaaef..6107abb36 100755 --- a/modules/cards/TintedGlassTitleCard.py +++ b/modules/cards/TintedGlassTitleCard.py @@ -59,7 +59,7 @@ class TintedGlassTitleCard(BaseCardType): 'hide_episode_text', 'font_file', 'font_size', 'font_color', 'font_interline_spacing', 'font_interword_spacing', 'font_kerning', 'font_vertical_shift', 'episode_text_color', 'episode_text_position', - 'box_adjustments', + 'box_adjustments', 'glass_color', ) def __init__(self, @@ -77,10 +77,11 @@ def __init__(self, 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, + episode_text_color: str = EPISODE_TEXT_COLOR, + episode_text_position: Position = 'center', + box_adjustments: Optional[str] = None, + glass_color: str = DARKEN_COLOR, + preferences: Optional['Preferences'] = None, # type: ignore **unused, ) -> None: @@ -131,6 +132,7 @@ def __init__(self, log.error(f'Invalid box adjustments "{box_adjustments}" - {e}') self.box_adjustments = (0, 0, 0, 0) self.valid = False + self.glass_color = glass_color def blur_rectangle_command(self, @@ -169,7 +171,7 @@ def blur_rectangle_command(self, f'-blur {self.TEXT_BLUR_PROFILE}', f'+mask', # Darken area behind title text - f'-fill "{self.DARKEN_COLOR}"', + f'-fill "{self.glass_color}"', f'-draw "roundrectangle {draw_coords}"', ] @@ -195,7 +197,7 @@ def add_title_text_command(self) -> ImageMagickCommands: f'-font "{self.font_file}"', f'-pointsize {font_size}', f'-interline-spacing {interline_spacing}', - f'-interword-spacing 40', + f'-interword-spacing {interword_spacing}', f'-kerning {kerning}', f'-fill "{self.font_color}"', f'-annotate +0+{vertical_shift} "{self.title_text}"', @@ -313,7 +315,7 @@ def add_episode_text_command(self, def modify_extras( extras: dict, custom_font: bool, - custom_season_titles: bool + custom_season_titles: bool, ) -> None: """ Modify the given extras base on whether font or season titles diff --git a/modules/global_objects.py b/modules/global_objects.py index 8aaf0ba43..d15f75a4e 100755 --- a/modules/global_objects.py +++ b/modules/global_objects.py @@ -12,7 +12,9 @@ class TemporaryPreferenceParser: def __init__(self, database_directory): """Fake initialize this object""" + self.card_dimensions = '3200x1800' self.database_directory = Path(database_directory) + self.imagemagick_container = None # pylint: disable=global-statement pp = TemporaryPreferenceParser(Path(__file__).parent / '.objects') diff --git a/modules/ref/marvel/Qualion ExtraBold.ttf b/modules/ref/marvel/Qualion ExtraBold.ttf new file mode 100755 index 000000000..ee9ee0116 Binary files /dev/null and b/modules/ref/marvel/Qualion ExtraBold.ttf differ diff --git a/modules/ref/overline/HelveticaNeueMedium.ttf b/modules/ref/overline/HelveticaNeueMedium.ttf new file mode 100755 index 000000000..9f18fe3f1 Binary files /dev/null and b/modules/ref/overline/HelveticaNeueMedium.ttf differ diff --git a/modules/ref/overline/small_gradient.png b/modules/ref/overline/small_gradient.png new file mode 100755 index 000000000..985783299 Binary files /dev/null and b/modules/ref/overline/small_gradient.png differ diff --git a/modules/ref/version b/modules/ref/version index c51944e25..2232b7f02 100755 --- a/modules/ref/version +++ b/modules/ref/version @@ -1 +1 @@ -v1.14.3 +v1.14.4