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
-
+
-> 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