diff --git a/Supremicus/DawnTitleCard.preview.jpg b/Supremicus/DawnTitleCard.preview.jpg index 270ea88..7ed27f0 100644 Binary files a/Supremicus/DawnTitleCard.preview.jpg and b/Supremicus/DawnTitleCard.preview.jpg differ diff --git a/Supremicus/DawnTitleCard.py b/Supremicus/DawnTitleCard.py index 723c7fc..85fd5b3 100644 --- a/Supremicus/DawnTitleCard.py +++ b/Supremicus/DawnTitleCard.py @@ -1,7 +1,7 @@ from pathlib import Path -from typing import Annotated, Literal, Union +from typing import Annotated, ClassVar, Literal, Self -from pydantic import Field, root_validator +from pydantic import Field, FilePath, field_validator, model_validator from app.cards.base import ( BaseCardType, @@ -10,9 +10,13 @@ Extra, ImageMagickCommands, ) -from app.cards.loader import RemoteFile +from app.cards.loader import RemoteDirectory from app.info.episode import EpisodeInfo -from app.schemas.base import BaseCardTypeCustomFontAllText +from app.schemas.base import ( + BaseCardModel, + BaseCardTypeCustomFontAllText, + FontSize, +) class DawnTitleCard(BaseCardType): @@ -28,8 +32,8 @@ class DawnTitleCard(BaseCardType): name='Dawn', identifier='Supremicus/DawnTitleCard', example=( - 'https://raw.githubusercontent.com/CollinHeist/' - 'TitleCardMaker-CardTypes/web-ui/Supremicus/' + 'https://raw.githubusercontent.com/TitleCardMaker/' + 'CardTypes/web-ui-develop/Supremicus/' 'DawnTitleCard.preview.jpg' ), creators=['Supremicus'], @@ -44,26 +48,13 @@ class DawnTitleCard(BaseCardType): tooltip='Default is black.', default='black', ), - Extra( - name='Title Text Horizontal Shift', - identifier='title_text_horizontal_shift', - description='Horizontal shift for the title text', - tooltip=( - 'Horizontal shift to apply to the title text. Default is ' - '0. Unit is pixels.' - ), - default=0, - ), Extra( name='Episode Text Vertical Shift', identifier='episode_text_vertical_shift', description='Vertical shift for episode text', tooltip=( 'Additional vertical shift to apply to the season and ' - 'episode text. If you encounter multi-line issues, problem ' - 'fonts maybe fixed by Fix vertical metrics at ' - 'https://transfonter.org/. Default is 0. ' - 'Unit is pixels.' + 'episode text. Unit is pixels.' ), default=0, ), @@ -73,7 +64,7 @@ class DawnTitleCard(BaseCardType): description='Font to use for the season and episode text', tooltip=( 'This can be just a file name if the font file is in the ' - "Series' source directory, {title_font} to match " + 'Series source directory, {font_file} to match ' 'the Font used for the title text, or a full path to the ' 'font file.' ), @@ -101,16 +92,69 @@ class DawnTitleCard(BaseCardType): name='Episode Text Kerning', identifier='episode_text_kerning', description='Spacing between characters for the episode text', - tooltip='Default is 18. Unit is pixels.', - default=18, + tooltip='Default is 0. Unit is pixels.', + default=0, + ), + Extra( + name='Episode Text Interword Spacing', + identifier='episode_text_interword_spacing', + description='Spacing between words for the episode text', + tooltip='Default is 0. Unit is pixels.', + default=0, ), Extra( - name='Separator Character', + name='Separator Character / Image', identifier='separator', - description='Character to separate season and episode text', - tooltip='Default is .', + description='Character or image to separate season and episode text', + tooltip=( + 'Default is . You may also provide an image file ' + 'name if the image file is in the Series source directory, ' + 'or a full path to an image file to use as the separator.' + ), default='•', ), + Extra( + name='Separator Image Padding', + identifier='separator_image_padding', + description='Padding to apply to the separator image', + tooltip=( + 'Additional padding to apply to the left and right of the ' + 'separator image if used. Default is 0. Unit is pixels.' + ), + default=0, + ), + Extra( + name='Separator Image Height', + identifier='separator_image_height', + description='Adjust the height of the separator image', + tooltip=( + 'Adjusts the height of the separator image if used ' + 'keeping aspect ratio. Default is 100. Unit is pixels.' + ), + default=100, + ), + Extra( + name='Separator Image Y Offset', + identifier='separator_image_y_offset', + description='Adjust the vertical position of the separator image', + tooltip=( + 'Adjusts the vertical position of the separator image if used. ' + 'Default is 0. Unit is pixels.' + ), + default=0, + ), + Extra( + name='Separator Image Stroke', + identifier='separator_image_use_stroke', + description='Whether to apply a stroke to the separator image', + tooltip=( + 'Automatically apply a stroke to the separator image to match ' + 'text stroke width of text. Either True or False. ' + 'Default is True.' + '
WARNING: Artifacting will occur on stray pixels.' + ), + default='True', + ), Extra( name='Horizontal Alignment', identifier='h_align', @@ -161,112 +205,69 @@ class DawnTitleCard(BaseCardType): ] ) - class CardModel(BaseCardTypeCustomFontAllText): - stroke_color: str = 'black' - title_text_horizontal_shift: int = 0 - episode_text_vertical_shift: int = 0 - episode_text_font: Union[ - Literal['{font_file}'], - str, - Path - ] = str(RemoteFile('Supremicus', 'ref/fonts/ExoSoft-Medium.ttf')) - episode_text_font_size: Annotated[float, Field(ge=0)] = 1.0 - episode_text_color: str | None = None - episode_text_stroke_color: str | None = None - episode_text_kerning: int = 18 - separator: str = '•' - h_align: Literal['left', 'center', 'right'] = 'left' - crt_overlay: Literal['nobezel', 'bezel'] | None = None - crt_state_overlay: bool = False - omit_gradient: bool = True - watched: bool = True - - @root_validator(skip_on_failure=True, allow_reuse=True) - def validate_episode_text_font_file(cls, values: dict) -> dict: - # Specified as "{title_font}" - use title font file - etf = values['episode_text_font'] - if etf in ('{title_font}', '{font_file}'): - values['episode_text_font'] = values['font_file'] - # Episode text font does not exist, search alongside source image - elif not (etf := Path(etf)).exists(): - if (new_etf := values['source_file'].parent / etf.name).exists(): - values['episode_text_font'] = new_etf - - # Verify new specified font file does exist - values['episode_text_font'] = Path(values['episode_text_font']) - if not Path(values['episode_text_font']).exists(): - raise ValueError( - f'Specified Episode Text Font (' - f'{values["episode_text_font"]}) does not exist' - ) - - return values - - @root_validator(skip_on_failure=True) - def validate_extras(cls, values: dict) -> dict: - # Convert None colors to the default font color - if values['episode_text_color'] is None: - values['episode_text_color'] = values['font_color'] - if values['episode_text_stroke_color'] is None: - values['episode_text_stroke_color'] = values['stroke_color'] - - return values + """Remote file directories""" + FONT_DIRECTORY: ClassVar[RemoteDirectory] = RemoteDirectory('Supremicus', 'ref/fonts') + OVERLAY_DIRECTORY: ClassVar[RemoteDirectory] = RemoteDirectory('Supremicus', 'ref/overlays') """Default configuration for this card type""" - TITLE_FONT = RemoteFile('Supremicus', 'ref/fonts/HelveticaNeue-Bold.ttf').resolve() CardConfig = DefaultCardConfig( - font_file=TITLE_FONT, + font_file=(FONT_DIRECTORY / 'HelveticaNeue-Bold.ttf').resolve(), font_color='white', - title_max_line_width=28, + font_case='upper', + font_replacements={}, + title_max_line_width=24, title_max_line_count=4, title_split_style='bottom', + episode_text_format='EPISODE {episode_number}', ) """Characteristics of episode text""" - EPISODE_TEXT_FORMAT = 'EPISODE {to_cardinal(episode_number)}' - EPISODE_TEXT_FONT = RemoteFile('Supremicus', 'ref/fonts/ExoSoft-Medium.ttf').resolve() + EPISODE_TEXT_FONT = FONT_DIRECTORY / 'ExoSoft-Medium.ttf' """Source path for CRT overlays to be overlayed if enabled""" - __OVERLAY_PLAIN = str(RemoteFile('Supremicus', 'ref/overlays/overlay_plain.png')) - __OVERLAY_PLAIN_BEZEL = str(RemoteFile('Supremicus', 'ref/overlays/overlay_plain_bezel.png')) - __OVERLAY_PLAY = str(RemoteFile('Supremicus', 'ref/overlays/overlay_play.png')) - __OVERLAY_PLAY_BEZEL = str(RemoteFile('Supremicus', 'ref/overlays/overlay_play_bezel.png')) - __OVERLAY_REWIND = str(RemoteFile('Supremicus', 'ref/overlays/overlay_rewind.png')) - __OVERLAY_REWIND_BEZEL = str(RemoteFile('Supremicus', 'ref/overlays/overlay_rewind_bezel.png')) + __OVERLAY_PLAIN = OVERLAY_DIRECTORY / 'overlay_plain.png' + __OVERLAY_PLAIN_BEZEL = OVERLAY_DIRECTORY / 'overlay_plain_bezel.png' + __OVERLAY_PLAY = OVERLAY_DIRECTORY / 'overlay_play.png' + __OVERLAY_PLAY_BEZEL = OVERLAY_DIRECTORY / 'overlay_play_bezel.png' + __OVERLAY_REWIND = OVERLAY_DIRECTORY / 'overlay_rewind.png' + __OVERLAY_REWIND_BEZEL = OVERLAY_DIRECTORY / 'overlay_rewind_bezel.png' """Source path for the gradient image""" - __GRADIENT_IMAGE = str(RemoteFile('Supremicus', 'ref/overlays/gradient.png')) + __GRADIENT_IMAGE = OVERLAY_DIRECTORY / 'gradient.png' __slots__ = ( - 'crt_overlay', - 'crt_state_overlay', + 'source_file', + 'output_file', + 'title_text', + 'season_text', 'episode_text', + 'hide_season_text', + 'hide_episode_text', + 'font_file', + 'font_color', + 'font_size', + 'font_stroke_width', + 'font_interline_spacing', + 'font_interword_spacing', + 'font_kerning', + 'font_vertical_shift', + 'stroke_color', 'episode_text_vertical_shift', 'episode_text_font', 'episode_text_font_size', 'episode_text_color', 'episode_text_stroke_color', 'episode_text_kerning', - 'font_color', - 'font_file', - 'font_interline_spacing', - 'font_interword_spacing', - 'font_kerning', - 'font_size', - 'font_stroke_width', - 'font_vertical_shift', + 'episode_text_interword_spacing', + 'separator', + 'separator_image_padding', + 'separator_image_height', + 'separator_image_y_offset', + 'separator_image_use_stroke', 'h_align', - 'hide_season_text', - 'hide_episode_text', - 'line_count', + 'crt_overlay', + 'crt_state_overlay', 'omit_gradient', - 'output_file', - 'stroke_color', - 'title_text_horizontal_shift', - 'season_text', - 'separator', - 'title_text', - 'source_file', 'watched', ) @@ -278,25 +279,29 @@ def __init__(self, *, episode_text: str, hide_season_text: bool = False, hide_episode_text: bool = False, - font_color: str = CardConfig.font_color, font_file: str = str(CardConfig.font_file), + font_color: str = CardConfig.font_color, + font_size: float = 1.0, + font_stroke_width: float = 1.0, 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, stroke_color: str = 'black', - title_text_horizontal_shift: int = 0, episode_text_vertical_shift: int = 0, - episode_text_font: Path = EPISODE_TEXT_FONT, + episode_text_font: Path = EPISODE_TEXT_FONT.resolve(), episode_text_font_size: float = 1.0, - episode_text_color: str = CardConfig.font_color, - episode_text_stroke_color: str = 'black', - episode_text_kerning: int = 18, + episode_text_color: str | None = None, + episode_text_stroke_color: str | None = None, + episode_text_kerning: int = 0, + episode_text_interword_spacing: int = 0, separator: str = '•', + separator_image_padding: int = 0, + separator_image_height: int = 100, + separator_image_y_offset: int = 0, + separator_image_use_stroke: bool = True, h_align: Literal['left', 'center', 'right'] = 'left', crt_overlay: Literal['nobezel', 'bezel'] | None = None, crt_state_overlay: bool = False, @@ -318,28 +323,31 @@ def __init__(self, *, self.episode_text = self.image_magick.escape_chars(episode_text) self.hide_season_text = hide_season_text self.hide_episode_text = hide_episode_text - self.line_count = len(title_text.split('\n')) - # Font/card customizations - self.font_color = font_color + # Font customizations self.font_file = font_file - self.font_kerning = font_kerning + self.font_color = font_color + self.font_size = 140 * font_size + self.font_stroke_width = font_stroke_width self.font_interline_spacing = font_interline_spacing self.font_interword_spacing = font_interword_spacing - self.font_size = font_size - self.font_stroke_width = font_stroke_width + self.font_kerning = 1.0 * font_kerning self.font_vertical_shift = font_vertical_shift # Optional extras self.stroke_color = stroke_color - self.title_text_horizontal_shift = title_text_horizontal_shift self.episode_text_vertical_shift = episode_text_vertical_shift self.episode_text_font = episode_text_font - self.episode_text_font_size = episode_text_font_size + self.episode_text_font_size = 60 * episode_text_font_size self.episode_text_color = episode_text_color self.episode_text_stroke_color = episode_text_stroke_color self.episode_text_kerning = episode_text_kerning + self.episode_text_interword_spacing = episode_text_interword_spacing self.separator = separator + self.separator_image_padding = separator_image_padding + self.separator_image_height = separator_image_height + self.separator_image_y_offset = separator_image_y_offset + self.separator_image_use_stroke = separator_image_use_stroke self.h_align = h_align self.crt_overlay = crt_overlay self.crt_state_overlay = crt_state_overlay @@ -347,9 +355,84 @@ def __init__(self, *, self.watched = watched + def stroke_separator_image(self, image_path: str | Path) -> ImageMagickCommands: + """ + Return ImageMagick commands to apply a stroke/outline to an image. + Resizes separator image to separator image height, applies a border + and padding to avoid clipping, and then applies stroke. + + Returns the image with a stroke or just the image if no stroke is defined. + """ + + # Return the image path as a command if no stroke is to be applied + if not self.separator_image_use_stroke \ + or not self.font_stroke_width or self.font_stroke_width == 0: + return [f'"{image_path}"'] + + stroke_color = self.episode_text_stroke_color + # Divide stoke width by 2 to match imagemagick stroke of half + # on inside and half on outside of text + stroke_width = round(4.0 * self.font_stroke_width) / 2 + # Apply border to avoid clipping of stroke + some extra padding + border_width = stroke_width + 2 + + return [ + fr'\(', + fr'\(', + f'"{image_path}"', + f'-resize x{self.separator_image_height}', + f'-bordercolor none', + f'-border {border_width}', + f'-write mpr:bordered', + f'+delete', + fr'\)', + fr'\(', + f'mpr:bordered', + f'-alpha extract', + f'-morphology dilate disk:{stroke_width}', + fr'\)', + fr'\(', + f'mpr:bordered', + f'-alpha extract', + fr'\)', + f'-compose minus_src', + f'-composite', + f'-threshold 0', + f'-write mpr:stroke', + f'+delete', + fr'\(', + f'mpr:bordered', + f'-alpha off', + f'-fill {stroke_color}', + f'-colorize 100', + f'mpr:stroke', + f'-compose copy_opacity', + f'-composite', + fr'\)', + f'mpr:bordered', + f'-compose over', + f'-composite', + f'-alpha on', + fr'\)', + ] + + @property def index_text_commands(self) -> ImageMagickCommands: - """Subcommand for adding the index text to the image.""" + """ + Subcommand for adding the index text and separator to the image. + Combining it all together with gravity center for alignment and + composing together, then smushing horizontally into one image + layer like so: + + Top layer + +--------+-----------+--------+ + | text | separator | text | + +--------+-----------+--------+ + | stroke | stroke | stroke | + +--------+-----------+--------+ + Bottom layer + """ # All text hidden, return empty commands if self.hide_season_text and self.hide_episode_text: @@ -363,84 +446,110 @@ def index_text_commands(self) -> ImageMagickCommands: else: index_text = f'{self.season_text} {self.separator} {self.episode_text}' - # Horizontal Alignment - if self.h_align == 'left': - gravity = 'southwest' - x = 200 - elif self.h_align == 'center': - gravity = 'south' - x = 0 - else: - gravity = 'southeast' - x = 200 - - # Font customizations - stroke_width = 4.0 * self.font_stroke_width - - # Base commands - base_commands = [ - f'-background transparent', - f'-kerning {self.episode_text_kerning}', - f'-pointsize {60 * self.episode_text_font_size}', - f'-interword-spacing 14.5', - f'-gravity {gravity}', - f'-font "{self.episode_text_font.resolve()}"', - ] - - # Text offsets - offset = (124 * self.font_size) * self.line_count - vertical_shift = 50 + self.font_vertical_shift - y = 80 + vertical_shift + offset + self.episode_text_vertical_shift - + # Use image separator if applicable and no text is hidden + if not (self.hide_season_text or self.hide_episode_text) and not \ + (isinstance(self.separator, str) and len(self.separator) == 1): + + return [ + # Season text + fr'\(', + f'-font "{self.episode_text_font}"', + f'-pointsize {self.episode_text_font_size}', + f'-kerning {self.episode_text_kerning}', + f'-interword-spacing {self.episode_text_interword_spacing}', + f'-gravity center', + fr'\(', + *self._index_text_stroke_commands(self.season_text), + fr'\)', + fr'\(', + f'-fill "{self.episode_text_color}"', + f'label:"{self.season_text}"', + fr'\)', + *([f'-compose over'] if self.font_stroke_width != 0 else []), + *([f'-composite'] if self.font_stroke_width != 0 else []), + fr'\)', + # Separator image (resize height only to keep aspect ratio) + fr'\(', + # Create empty canvas to allow y offset positioning + f'xc:none', + # Double canvas size to accommodate separator image + offset + f'-resize x{self.separator_image_height * 2}', + fr'\(', + *self.stroke_separator_image(self.separator), + f'-resize x{self.separator_image_height}', + f'-geometry +0{self.separator_image_y_offset:+}', + fr'\)', + f'-composite', + fr'\)', + # Episode text + fr'\(', + fr'\(', + *self._index_text_stroke_commands(self.episode_text), + fr'\)', + fr'\(', + f'-fill "{self.episode_text_color}"', + f'label:"{self.episode_text}"', + fr'\)', + *([f'-compose over'] if self.font_stroke_width != 0 else []), + *([f'-composite'] if self.font_stroke_width != 0 else []), + fr'\)', + f'+smush {20 + self.separator_image_padding}', + ] + + # Return normal text commands if no image separator return [ - *base_commands, - f'-fill {self.episode_text_stroke_color}', - f'-stroke {self.episode_text_stroke_color}', - f'-strokewidth {stroke_width}', - f'-annotate {x:+}{y:+} "{index_text}"', + f'-font "{self.episode_text_font}"', + f'-pointsize {self.episode_text_font_size}', + f'-gravity center', + f'-kerning {self.episode_text_kerning}', + f'-interword-spacing {self.episode_text_interword_spacing}', + *self._index_text_stroke_commands(index_text), f'-fill "{self.episode_text_color}"', - f'-stroke "{self.episode_text_color}"', - f'-strokewidth 0', - f'-annotate {x:+}{y:+} "{index_text}"', + f'label:"{index_text}"', + *([f'-composite'] if self.font_stroke_width != 0 else []), ] - @property - def title_text_commands(self) -> ImageMagickCommands: - """Subcommands required to add the title text.""" - # If no title text, return empty commands - if not self.title_text: - return [] + def _index_text_stroke_commands(self, index_text: str) -> ImageMagickCommands: + """Generate stroke commands for index text.""" - # Horizontal Alignment - if self.h_align == 'left': - x = 200 + self.title_text_horizontal_shift - elif self.h_align == 'center': - x = 0 - else: - x = 200 + self.title_text_horizontal_shift + # If no stroke, return empty commands + if self.font_stroke_width == 0: + return [] - # Text offsets - vertical_shift = 50 + self.font_vertical_shift - y = 80 + vertical_shift + stroke_width = 4.0 * self.font_stroke_width return [ - *self.title_text_global_effects, - *self.title_text_black_stroke, - f'-annotate {x:+}{y:+} "{self.title_text}"', - *self.title_text_effects, - f'-annotate {x:+}{y:+} "{self.title_text}"', + f'-fill "{self.episode_text_stroke_color}"', + f'-stroke "{self.episode_text_stroke_color}"', + f'-strokewidth {stroke_width}', + f'label:"{index_text}"', + f'-stroke none', ] @property - def title_text_global_effects(self) -> ImageMagickCommands: + def title_text_commands(self) -> ImageMagickCommands: """ - ImageMagick commands to implement the title text's global - effects. Specifically the the font, kerning, fontsize, and - southwest gravity. + Subcommand for adding the title text to the image. Combines + it all together with gravity and transparent stroke for + alignment and composing into one image layer like so: + + Top layer + +--------+ + | text | + +--------+ + | stroke | + +--------+ + Bottom layer """ + # If no title text, return empty commands + if not self.title_text: + return [] + + stroke_width = 4.0 * self.font_stroke_width + # Horizontal Alignment if self.h_align == 'left': gravity = 'southwest' @@ -449,29 +558,27 @@ def title_text_global_effects(self) -> ImageMagickCommands: else: gravity = 'southeast' - # Font customizations - font_size = 124 * self.font_size - interline_spacing = -20 + self.font_interline_spacing - interword_spacing = 50 + self.font_interword_spacing - kerning = -1.25 * self.font_kerning - return [ - f'-font "{self.font_file}"', - f'-kerning {kerning}', - f'-interline-spacing {interline_spacing}', - f'-interword-spacing {interword_spacing}', - f'-pointsize {font_size}', + f'-font "{self.font_file.resolve()}"', + f'-pointsize {self.font_size}', f'-gravity {gravity}', + f'-kerning {self.font_kerning}', + f'-interline-spacing {self.font_interline_spacing}', + f'-interword-spacing {self.font_interword_spacing}', + *self._title_text_stroke_commands(), + f'-fill "{self.font_color}"', + # transparent stroke to keep alignment with h_align gravity and label + f'-stroke transparent', + f'-strokewidth {stroke_width}', + f'label:"{self.title_text}"', + *([f'-composite'] if self.font_stroke_width != 0 else []), ] - @property - def title_text_black_stroke(self) -> ImageMagickCommands: - """ - ImageMagick commands to implement the title text's black stroke. - """ + def _title_text_stroke_commands(self) -> ImageMagickCommands: + """Generate stroke/outline commands for title text.""" - # No stroke, return empty command + # If no stroke, return empty commands if self.font_stroke_width == 0: return [] @@ -481,23 +588,83 @@ def title_text_black_stroke(self) -> ImageMagickCommands: f'-fill "{self.stroke_color}"', f'-stroke "{self.stroke_color}"', f'-strokewidth {stroke_width}', + f'label:"{self.title_text}"', + f'-stroke none', ] @property - def title_text_effects(self) -> ImageMagickCommands: - """Subcommands to implement the title text's standard effects.""" + def combine_text_commands(self) -> ImageMagickCommands: + """ + Subcommands to combine index and title text layers together and + align by h_align. Layers are trimmed by the sides only so we + don't need the old title_text_horizontal_shift variable and to + keep some padding between the two, user can adjust the gap. + Smush pulled the separator image into whitespace so had to + resort to title_height and geometry for positioning. + """ + + # Auto adjust y offset when using separator image + if not (self.hide_season_text or self.hide_episode_text) and not \ + (isinstance(self.separator, str) and len(self.separator) == 1) and \ + self.separator_image_height > self.episode_text_font_size: + auto_adjust_y = (self.separator_image_height - self.episode_text_font_size) / 2 + else: + auto_adjust_y = 0 + # Horizontal Alignment + if self.h_align == 'left': + gravity = 'southwest' + x = 200 + elif self.h_align == 'center': + gravity = 'south' + x = 0 + else: + gravity = 'southeast' + x = 200 + + # Get height of title text using annotate, since we can't use + # get_text_label_dimensions trim with our trimming of sides only. + # Also keeps our title text at baseline with overhang. + title_text_height_command = [ + f'-font "{self.font_file.resolve()}"', + f'-pointsize {self.font_size}', + f'-interline-spacing {self.font_interline_spacing}', + f'-strokewidth {4.0 * self.font_stroke_width}', + f'-annotate +0+0 "{self.title_text}"', + ] + _, title_height = self.image_magick.get_text_dimensions( + title_text_height_command + ) + + y = 130 + self.font_vertical_shift + index_y = y + title_height + self.episode_text_vertical_shift - auto_adjust_y + + # Only trim sides to keep padding between text layers + # Conditonally composite only non-empty text layers return [ - f'-fill "{self.font_color}"', - f'-stroke "{self.font_color}"', - f'-strokewidth 0', + fr'\(', + *self.index_text_commands, + f'-define trim:edges=west,east', + f'-trim', + fr'\)', + f'-gravity {gravity}', + f'-geometry {x:+}{index_y:+}', + *([f'-composite'] if self.index_text_commands else []), + fr'\(', + *self.title_text_commands, + f'-define trim:edges=west,east', + f'-trim', + fr'\)', + f'-gravity {gravity}', + f'-geometry {x:+}{y:+}', + *([f'-composite'] if self.title_text_commands else []), ] @property def add_crt_overlay_commands(self) -> ImageMagickCommands: - """Add the static gradient to this object's source image.""" + """Add the CRT TV overlay to this object's source image.""" if self.crt_overlay is None: return [] @@ -528,10 +695,7 @@ def add_crt_overlay_commands(self) -> ImageMagickCommands: @property def gradient_commands(self) -> ImageMagickCommands: - """ - Subcommand to overlay the gradient to this image. This rotates - and repositions the gradient overlay based on the text position. - """ + """Add the gradient overlay to this object's source image.""" if self.omit_gradient: return [] @@ -552,33 +716,28 @@ def SEASON_TEXT_FORMATTER(episode_info: EpisodeInfo) -> str: determined. Returns: - 'Specials' if the season number is 0; otherwise the cardinal - version of the season number. If that's not possible, then - just 'S{xx}'. + 'SPECIALS' if the season number is 0. + 'SEASON {x}' for the given season number. """ if episode_info.season_number == 0: - return 'Specials' + return 'SPECIALS' - return 'S{season_number:02}' + return f'SEASON {episode_info.season_number}' def create(self) -> None: - """ - Make the necessary ImageMagick and system calls to create this - object's defined title card. - """ + """Create this object's defined Title Card.""" self.image_magick.run([ - f'convert "{self.source_file.resolve()}"', + f'convert', + f'"{self.source_file.resolve()}"', # Resize and optionally blur source image *self.resize_and_style, # Overlay gradient *self.gradient_commands, - # Add season episode text - *self.index_text_commands, - # Title text - *self.title_text_commands, + # Add combined index and title text + *self.combine_text_commands, # Add CRT TV overlay *self.add_crt_overlay_commands, # Attempt to overlay mask @@ -587,3 +746,80 @@ def create(self) -> None: *self.resize_output, f'"{self.output_file.resolve()}"', ]) + + +def get_validator_model() -> type[BaseCardModel]: + """Get the Pydantic validator class for this card type.""" + + class CardModel(BaseCardTypeCustomFontAllText): + font_file: FilePath = DawnTitleCard.CardConfig.font_file + font_color: str = DawnTitleCard.CardConfig.font_color + font_size: FontSize = 1.0 + stroke_color: str = 'black' + title_text_horizontal_shift: int = 0 + episode_text_vertical_shift: int = 0 + episode_text_font: FilePath = DawnTitleCard.EPISODE_TEXT_FONT + episode_text_font_size: FontSize = 1.0 + episode_text_color: str | None = None + episode_text_stroke_color: str | None = None + episode_text_kerning: int = 0 + episode_text_interword_spacing: int = 0 + separator: str = '•' + separator_image_padding: int = 0 + separator_image_height: Annotated[int, Field(ge=10)] = 100 + separator_image_y_offset: int = 0 + separator_image_use_stroke: bool = True + h_align: Literal['left', 'center', 'right'] = 'left' + crt_overlay: Literal['nobezel', 'bezel'] | None = None + crt_state_overlay: bool = False + omit_gradient: bool = True + watched: bool | None = None + + @field_validator('episode_text_font', mode='before') + @classmethod + def validate_episode_text_font(cls, v, info): + if v in ('{title_font}', '{font_file}'): + v = info.data['font_file'] + etf = Path(v) + if not etf.exists(): + source_file = info.data.get('source_file') + if source_file is not None: + candidate = Path(source_file).parent / etf.name + if candidate.exists(): + v = candidate + etf = candidate + etf = Path(v) + if not etf.exists(): + raise ValueError(f'Specified Episode Text Font "{etf}" does not exist') + return str(etf) + + @field_validator('separator', mode='before') + @classmethod + def validate_separator(cls, v, info): + # Allow a single character as separator + if isinstance(v, str) and len(v) == 1: + return v + # If it's a string that looks like a path, or a Path, try to resolve it + sep_path = Path(v) + if not sep_path.exists(): + source_file = info.data.get('source_file') + if source_file is not None: + candidate = Path(source_file).parent / sep_path.name + if candidate.exists(): + sep_path = candidate + if not sep_path.exists(): + raise ValueError(f'Separator must be a single character or a valid image path "{v}" is invalid') + return str(sep_path) + + @model_validator(mode='after') + def assign_unassigned_colors(self) -> Self: + """Assign any unassigned colors to their fallback values.""" + if self.episode_text_color is None: + self.episode_text_color = self.font_color + if self.episode_text_stroke_color is None: + self.episode_text_stroke_color = self.stroke_color + return self + + return CardModel + +DawnTitleCard.CardModel = get_validator_model() \ No newline at end of file diff --git a/Supremicus/HorizonTitleCard.preview.jpg b/Supremicus/HorizonTitleCard.preview.jpg index 03ff831..2446d09 100644 Binary files a/Supremicus/HorizonTitleCard.preview.jpg and b/Supremicus/HorizonTitleCard.preview.jpg differ diff --git a/Supremicus/HorizonTitleCard.py b/Supremicus/HorizonTitleCard.py index 2b2b6a2..e832f1a 100644 --- a/Supremicus/HorizonTitleCard.py +++ b/Supremicus/HorizonTitleCard.py @@ -1,7 +1,7 @@ from pathlib import Path -from typing import Annotated, Literal, Union +from typing import Annotated, ClassVar, Literal, Self -from pydantic import Field, FilePath, root_validator +from pydantic import Field, FilePath, field_validator, model_validator from app.cards.base import ( BaseCardType, @@ -10,9 +10,13 @@ Extra, ImageMagickCommands, ) -from app.cards.loader import RemoteDirectory, RemoteFile +from app.cards.loader import RemoteDirectory from app.info.episode import EpisodeInfo -from app.schemas.base import BaseCardTypeCustomFontAllText +from app.schemas.base import ( + BaseCardModel, + BaseCardTypeCustomFontAllText, + FontSize, +) class HorizonTitleCard(BaseCardType): @@ -29,13 +33,11 @@ class HorizonTitleCard(BaseCardType): name='Horizon', identifier='Supremicus/HorizonTitleCard', example=( - 'https://raw.githubusercontent.com/CollinHeist/' - 'TitleCardMaker-CardTypes/web-ui/Supremicus/' + 'https://raw.githubusercontent.com/TitleCardMaker/' + 'CardTypes/web-ui-develop/Supremicus/' 'HorizonTitleCard.preview.jpg' ), - creators=[ - 'Supremicus' - ], + creators=['Supremicus'], source='remote', supports_custom_fonts=True, supports_custom_seasons=True, @@ -53,10 +55,7 @@ class HorizonTitleCard(BaseCardType): description='Vertical shift for episode text', tooltip=( 'Additional vertical shift to apply to the season and ' - 'episode text. If you encounter multi-line issues, problem ' - 'fonts maybe fixed by Fix vertical metrics at ' - 'https://transfonter.org/. Default is 0. ' - 'Unit is pixels.' + 'episode text. Unit is pixels.' ), default=0, ), @@ -66,7 +65,7 @@ class HorizonTitleCard(BaseCardType): description='Font to use for the season and episode text', tooltip=( 'This can be just a file name if the font file is in the ' - "Series' source directory, {title_font} to match " + 'Series source directory, {font_file} to match ' 'the Font used for the title text, or a full path to the ' 'font file.' ), @@ -94,16 +93,69 @@ class HorizonTitleCard(BaseCardType): name='Episode Text Kerning', identifier='episode_text_kerning', description='Spacing between characters for the episode text', - tooltip='Default is 18. Unit is pixels.', - default=18, + tooltip='Default is 0. Unit is pixels.', + default=0, + ), + Extra( + name='Episode Text Interword Spacing', + identifier='episode_text_interword_spacing', + description='Spacing between words for the episode text', + tooltip='Default is 0. Unit is pixels.', + default=0, ), Extra( - name='Separator Character', + name='Separator Character / Image', identifier='separator', - description='Character to separate season and episode text', - tooltip='Default is .', + description='Character or image to separate season and episode text', + tooltip=( + 'Default is . You may also provide an image file ' + 'name if the image file is in the Series source directory, ' + 'or a full path to an image file to use as the separator.' + ), default='•', ), + Extra( + name='Separator Image Padding', + identifier='separator_image_padding', + description='Padding to apply to the separator image', + tooltip=( + 'Additional padding to apply to the left and right of the ' + 'separator image if used. Default is 0. Unit is pixels.' + ), + default=0, + ), + Extra( + name='Separator Image Height', + identifier='separator_image_height', + description='Adjust the height of the separator image', + tooltip=( + 'Adjusts the height of the separator image if used ' + 'keeping aspect ratio. Default is 100. Unit is pixels.' + ), + default=100, + ), + Extra( + name='Separator Image Y Offset', + identifier='separator_image_y_offset', + description='Adjust the vertical position of the separator image', + tooltip=( + 'Adjusts the vertical position of the separator image if used. ' + 'Default is 0. Unit is pixels.' + ), + default=0, + ), + Extra( + name='Separator Image Stroke', + identifier='separator_image_use_stroke', + description='Whether to apply a stroke to the separator image', + tooltip=( + 'Automatically apply a stroke to the separator image to match ' + 'text stroke width of text. Either True or False. ' + 'Default is True.' + '
WARNING: Artifacting will occur on stray pixels.' + ), + default='True', + ), Extra( name='Horizontal Alignment', identifier='h_align', @@ -132,7 +184,7 @@ class HorizonTitleCard(BaseCardType): tooltip=( 'Number between 0 and 100. 0% being ' 'fully transparent, 100% being fully opaque. Unit ' - 'percent.' + 'is percent.' ), default=100, ), @@ -168,18 +220,6 @@ class HorizonTitleCard(BaseCardType): ), default='True', ), - Extra( - name='Alignment Overlay', - identifier='alignment_overlay', - description='Alignment Overlay Toggle', - tooltip=( - 'Enable an alignment overlay to help assist adjusting ' - 'offsets for misaligned custom fonts. The overlay has ' - 'guiding lines every 10 pixels. Either True or ' - 'False. Default is False.' - ), - default='False', - ), ], description=[ 'Produce TitleCards with left or right aligned centered text with ' @@ -188,72 +228,21 @@ class HorizonTitleCard(BaseCardType): ], ) - class CardModel(BaseCardTypeCustomFontAllText): - stroke_color: str = 'black' - episode_text_vertical_shift: int = 0 - episode_text_font: Union[ - Literal['{font_file}'], - str, - FilePath, - ] = str(RemoteFile('Supremicus', 'ref/fonts/ExoSoft-Medium.ttf')) - episode_text_font_size: Annotated[float, Field(ge=0)] = 1.0 - episode_text_color: str | None = None - episode_text_stroke_color: str | None = None - episode_text_kerning: int = 18 - separator: str = '•' - h_align: Literal['left', 'center', 'right'] = 'left' - symbol: Literal[ - 'acolyte', 'ashoka', 'andor', 'bobafett', 'mandalorian', 'obiwan', - 'witcher', 'logo', - ] | None = None - symbol_opacity: Annotated[int, Field(ge=0, le=100)] = 100 - logo_file: Path - alignment_overlay: bool = False - crt_overlay: Literal['nobezel', 'bezel'] | None = None - crt_state_overlay: bool = False - omit_gradient: bool = True - - @root_validator(skip_on_failure=True, allow_reuse=True) - def validate_episode_text_font_file(cls, values: dict) -> dict: - if (etf := values['episode_text_font']) in ('{title_font}', '{font_file}'): - values['episode_text_font'] = values['font_file'] - # Episode text font does not exist, search alongside source image - elif not (etf := Path(etf)).exists(): - if (new_etf := values['source_file'].parent / etf.name).exists(): - values['episode_text_font'] = new_etf - - # Verify new specified font file does exist - values['episode_text_font'] = Path(values['episode_text_font']) - if not Path(values['episode_text_font']).exists(): - raise ValueError( - f'Specified Episode Text Font ' - f'({values["episode_text_font"]}) does not exist' - ) - - return values - - @root_validator(skip_on_failure=True) - def validate_extras(cls, values: dict) -> dict: - # Convert None colors to the default font color - if values['episode_text_color'] is None: - values['episode_text_color'] = values['font_color'] - if values['episode_text_stroke_color'] is None: - values['episode_text_stroke_color'] = values['stroke_color'] - - return values - - FONT_DIRECTORY = RemoteDirectory('Supremicus', 'ref/fonts') - SYMBOL_DIRECTORY = RemoteDirectory('Supremicus', 'ref/symbols') - OVERLAY_DIRECTORY = RemoteDirectory('Supremicus', 'ref/overlays') + """Remote file directories""" + FONT_DIRECTORY: ClassVar[RemoteDirectory] = RemoteDirectory('Supremicus', 'ref/fonts') + SYMBOL_DIRECTORY: ClassVar[RemoteDirectory] = RemoteDirectory('Supremicus', 'ref/symbols') + OVERLAY_DIRECTORY: ClassVar[RemoteDirectory] = RemoteDirectory('Supremicus', 'ref/overlays') """Default configuration for this card type""" CardConfig = DefaultCardConfig( font_file=(FONT_DIRECTORY / 'HelveticaNeue-Bold.ttf').resolve(), font_color='white', + font_case='upper', + font_replacements={}, title_max_line_width=16, title_max_line_count=4, - title_split_style='bottom', - episode_text_format='EPISODE {to_cardinal(episode_number)}', + title_split_style='top', + episode_text_format='EPISODE {episode_number}', ) """Characteristics of episode text""" @@ -268,9 +257,6 @@ def validate_extras(cls, values: dict) -> dict: __SYMBOL_IMAGE_OBIWAN = SYMBOL_DIRECTORY / 'obiwan.png' __SYMBOL_IMAGE_WITCHER = SYMBOL_DIRECTORY / 'witcher.png' - """Alignment overlay image""" - __ALIGNMENT_OVERLAY_IMAGE = OVERLAY_DIRECTORY / 'overlay_alignment.png' - """Source path for CRT overlays to be overlayed if enabled""" __OVERLAY_PLAIN = OVERLAY_DIRECTORY / 'overlay_plain.png' __OVERLAY_PLAIN_BEZEL = OVERLAY_DIRECTORY / 'overlay_plain_bezel.png' @@ -278,23 +264,51 @@ def validate_extras(cls, values: dict) -> dict: __OVERLAY_PLAY_BEZEL = OVERLAY_DIRECTORY / 'overlay_play_bezel.png' __OVERLAY_REWIND = OVERLAY_DIRECTORY / 'overlay_rewind.png' __OVERLAY_REWIND_BEZEL = OVERLAY_DIRECTORY / 'overlay_rewind_bezel.png' - """Source path for the gradient image""" + + """Source path for gradient images""" __GRADIENT_IMAGE = OVERLAY_DIRECTORY / 'radial_gradient.png' __GRADIENT_IMAGE_CENTERED = OVERLAY_DIRECTORY / 'radial_gradient_centered.png' __slots__ = ( - 'source_file', 'output_file', 'title_text', 'season_text', - 'episode_prefix', 'episode_text', 'hide_season_text', 'hide_episode_text', - 'line_count', 'font_color', 'font_file', 'font_interline_spacing', - 'font_interword_spacing', 'font_kerning', 'font_size', 'font_stroke_width', - 'font_vertical_shift', 'stroke_color', 'episode_text_vertical_shift', - 'episode_text_font', 'episode_text_font_size', 'episode_text_color', - 'episode_text_stroke_color', 'episode_text_kerning', 'separator', 'h_align', - 'symbol', 'symbol_opacity', 'logo', 'alignment_overlay', 'crt_overlay', - 'crt_state_overlay', 'omit_gradient', 'watched', + 'source_file', + 'output_file', + 'title_text', + 'season_text', + 'episode_text', + 'hide_season_text', + 'hide_episode_text', + 'font_file', + 'font_color', + 'font_size', + 'font_stroke_width', + 'font_interline_spacing', + 'font_interword_spacing', + 'font_kerning', + 'font_vertical_shift', + 'stroke_color', + 'episode_text_vertical_shift', + 'episode_text_font', + 'episode_text_font_size', + 'episode_text_color', + 'episode_text_stroke_color', + 'episode_text_kerning', + 'episode_text_interword_spacing', + 'separator', + 'separator_image_padding', + 'separator_image_height', + 'separator_image_y_offset', + 'separator_image_use_stroke', + 'h_align', + 'symbol', + 'symbol_opacity', + 'logo', + 'crt_overlay', + 'crt_state_overlay', + 'omit_gradient', + 'watched', ) - def __init__(self, + def __init__(self, *, source_file: Path, card_file: Path, title_text: str, @@ -302,13 +316,13 @@ def __init__(self, episode_text: str, hide_season_text: bool = False, hide_episode_text: bool = False, - font_color: str = CardConfig.font_color, font_file: str = str(CardConfig.font_file), + font_color: str = CardConfig.font_color, + font_size: float = 1.0, + font_stroke_width: float = 1.0, 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, @@ -316,12 +330,16 @@ def __init__(self, episode_text_vertical_shift: int = 0, episode_text_font: Path = EPISODE_TEXT_FONT.resolve(), episode_text_font_size: float = 1.0, - episode_text_color: str = CardConfig.font_color, - episode_text_stroke_color: str = 'black', - episode_text_kerning: int = 18, + episode_text_color: str | None = None, + episode_text_stroke_color: str | None = None, + episode_text_kerning: int = 0, + episode_text_interword_spacing: int = 0, separator: str = '•', + separator_image_padding: int = 0, + separator_image_height: int = 100, + separator_image_y_offset: int = 0, + separator_image_use_stroke: bool = True, h_align: Literal['left', 'center', 'right'] = 'left', - logo_file: Path | None = None, symbol: None | Literal[ 'acolyte', 'ashoka', @@ -333,7 +351,7 @@ def __init__(self, 'logo', ] = None, symbol_opacity: int = 100, - alignment_overlay: bool = False, + logo_file: Path | None = None, crt_overlay: Literal['nobezel', 'bezel'] | None = None, crt_state_overlay: bool = False, omit_gradient: bool = True, @@ -354,41 +372,119 @@ def __init__(self, self.episode_text = self.image_magick.escape_chars(episode_text) self.hide_season_text = hide_season_text self.hide_episode_text = hide_episode_text - self.line_count = len(title_text.split('\n')) - # Font/card customizations - self.font_color = font_color + # Font customizations self.font_file = font_file - self.font_kerning = font_kerning + self.font_color = font_color + self.font_size = 140 * font_size + self.font_stroke_width = font_stroke_width self.font_interline_spacing = font_interline_spacing self.font_interword_spacing = font_interword_spacing - self.font_size = font_size - self.font_stroke_width = font_stroke_width + self.font_kerning = 1.0 * font_kerning self.font_vertical_shift = font_vertical_shift # Optional extras self.stroke_color = stroke_color self.episode_text_vertical_shift = episode_text_vertical_shift self.episode_text_font = episode_text_font - self.episode_text_font_size = episode_text_font_size + self.episode_text_font_size = 60 * episode_text_font_size self.episode_text_color = episode_text_color self.episode_text_stroke_color = episode_text_stroke_color self.episode_text_kerning = episode_text_kerning + self.episode_text_interword_spacing = episode_text_interword_spacing self.separator = separator + self.separator_image_padding = separator_image_padding + self.separator_image_height = separator_image_height + self.separator_image_y_offset = separator_image_y_offset + self.separator_image_use_stroke = separator_image_use_stroke self.h_align = h_align - self.logo = logo_file self.symbol = symbol self.symbol_opacity = symbol_opacity - self.alignment_overlay = alignment_overlay + self.logo = logo_file self.crt_overlay = crt_overlay self.crt_state_overlay = crt_state_overlay self.omit_gradient = omit_gradient self.watched = watched + def stroke_separator_image(self, image_path: str | Path) -> ImageMagickCommands: + """ + Return ImageMagick commands to apply a stroke/outline to an image. + Resizes separator image to separator image height, applies a border + and padding to avoid clipping, and then applies stroke. + + Returns the image with a stroke or just the image if no stroke is defined. + """ + + # Return the image path as a command if no stroke is to be applied + if not self.separator_image_use_stroke \ + or not self.font_stroke_width or self.font_stroke_width == 0: + return [f'"{image_path}"'] + + stroke_color = self.episode_text_stroke_color + # Divide stoke width by 2 to match imagemagick stroke of half + # on inside and half on outside of text + stroke_width = round(4.0 * self.font_stroke_width) / 2 + # Apply border to avoid clipping of stroke + some extra padding + border_width = stroke_width + 2 + + return [ + fr'\(', + fr'\(', + f'"{image_path}"', + f'-resize x{self.separator_image_height}', + f'-bordercolor none', + f'-border {border_width}', + f'-write mpr:bordered', + f'+delete', + fr'\)', + fr'\(', + f'mpr:bordered', + f'-alpha extract', + f'-morphology dilate disk:{stroke_width}', + fr'\)', + fr'\(', + f'mpr:bordered', + f'-alpha extract', + fr'\)', + f'-compose minus_src', + f'-composite', + f'-threshold 0', + f'-write mpr:stroke', + f'+delete', + fr'\(', + f'mpr:bordered', + f'-alpha off', + f'-fill {stroke_color}', + f'-colorize 100', + f'mpr:stroke', + f'-compose copy_opacity', + f'-composite', + fr'\)', + f'mpr:bordered', + f'-compose over', + f'-composite', + f'-alpha on', + fr'\)', + ] + + @property def index_text_commands(self) -> ImageMagickCommands: - """Subcommand for adding the index text to the image.""" + """ + Subcommand for adding the index text and separator to the image. + Combining it all together with gravity center for alignment and + composing together, then smushing horizontally into one image + layer like so: + + Top layer + +--------+-----------+--------+ + | text | separator | text | + +--------+-----------+--------+ + | stroke | stroke | stroke | + +--------+-----------+--------+ + Bottom layer + """ # All text hidden, return empty commands if self.hide_season_text and self.hide_episode_text: @@ -402,91 +498,126 @@ def index_text_commands(self) -> ImageMagickCommands: else: index_text = f'{self.season_text} {self.separator} {self.episode_text}' - # Font customizations - stroke_width = 4.0 * self.font_stroke_width - - # Base commands - base_commands = [ - f'-background transparent', - f'-kerning {self.episode_text_kerning}', - f'-pointsize {60 * self.episode_text_font_size}', - f'-interword-spacing 14.5', - f'-gravity north', - f'-font "{self.episode_text_font.resolve()}"', - ] - - # Text offsets - offset = (124 * self.font_size / 2) * self.line_count - y = 900 - offset + self.episode_text_vertical_shift - 30 - x = -700 if self.h_align == 'left' else (700 if self.h_align == 'right' else 0) - + # Use image separator if applicable and no text is hidden + if not (self.hide_season_text or self.hide_episode_text) and not \ + (isinstance(self.separator, str) and len(self.separator) == 1): + + return [ + # Season text + fr'\(', + f'-font "{self.episode_text_font}"', + f'-pointsize {self.episode_text_font_size}', + f'-kerning {self.episode_text_kerning}', + f'-interword-spacing {self.episode_text_interword_spacing}', + f'-gravity center', + fr'\(', + *self._index_text_stroke_commands(self.season_text), + fr'\)', + fr'\(', + f'-fill "{self.episode_text_color}"', + f'label:"{self.season_text}"', + fr'\)', + *([f'-compose over'] if self.font_stroke_width != 0 else []), + *([f'-composite'] if self.font_stroke_width != 0 else []), + fr'\)', + # Separator image (resize height only to keep aspect ratio) + fr'\(', + # Create empty canvas to allow y offset positioning + f'xc:none', + # Double canvas size to accommodate separator image + offset + f'-resize x{self.separator_image_height * 2}', + fr'\(', + *self.stroke_separator_image(self.separator), + f'-resize x{self.separator_image_height}', + f'-geometry +0{self.separator_image_y_offset:+}', + fr'\)', + f'-composite', + fr'\)', + # Episode text + fr'\(', + fr'\(', + *self._index_text_stroke_commands(self.episode_text), + fr'\)', + fr'\(', + f'-fill "{self.episode_text_color}"', + f'label:"{self.episode_text}"', + fr'\)', + *([f'-compose over'] if self.font_stroke_width != 0 else []), + *([f'-composite'] if self.font_stroke_width != 0 else []), + fr'\)', + f'+smush {20 + self.separator_image_padding}', + ] + + # Return normal text commands if no image separator return [ - *base_commands, - f'-fill {self.episode_text_stroke_color}', - f'-stroke {self.episode_text_stroke_color}', - f'-strokewidth {stroke_width}', - f'-annotate {x:+}{y:+} "{index_text}"', + f'-font "{self.episode_text_font}"', + f'-pointsize {self.episode_text_font_size}', + f'-gravity center', + f'-kerning {self.episode_text_kerning}', + f'-interword-spacing {self.episode_text_interword_spacing}', + *self._index_text_stroke_commands(index_text), f'-fill "{self.episode_text_color}"', - f'-stroke "{self.episode_text_color}"', - f'-strokewidth 0', - f'-annotate {x:+}{y:+} "{index_text}"', + f'label:"{index_text}"', + *([f'-composite'] if self.font_stroke_width != 0 else []), ] - @property - def title_text_commands(self) -> ImageMagickCommands: - """Subcommands required to add the title text.""" - # If no title text, return empty commands - if not self.title_text: + def _index_text_stroke_commands(self, index_text: str) -> ImageMagickCommands: + """Generate stroke commands for index text.""" + + # If no stroke, return empty commands + if self.font_stroke_width == 0: return [] - font_size = 124 * self.font_size - offset = (font_size / 2) * self.line_count - vertical_shift = 42 + self.font_vertical_shift - x = -700 if self.h_align == 'left' else (700 if self.h_align == 'right' else 0) - y = 900 - offset + vertical_shift - 12 + stroke_width = 4.0 * self.font_stroke_width return [ - *self.title_text_global_effects, - *self.title_text_black_stroke, - f'-annotate {x:+}{y:+} "{self.title_text}"', - f'-fill "{self.font_color}"', - f'-stroke "{self.font_color}"', - f'-strokewidth 0', - f'-annotate {x:+}{y:+} "{self.title_text}"', + f'-fill "{self.episode_text_stroke_color}"', + f'-stroke "{self.episode_text_stroke_color}"', + f'-strokewidth {stroke_width}', + f'label:"{index_text}"', + f'-stroke none', ] @property - def title_text_global_effects(self) -> ImageMagickCommands: + def title_text_commands(self) -> ImageMagickCommands: """ - ImageMagick commands to implement the title text's global - effects. Specifically the the font, kerning, fontsize, and - southwest gravity. + Subcommand for adding the title text to the image. Combines + it all together with gravity center for alignment and + composing into one image layer like so: + + Top layer + +--------+ + | text | + +--------+ + | stroke | + +--------+ + Bottom layer """ - font_size = 124 * self.font_size - interline_spacing = -26 + self.font_interline_spacing - interword_spacing = 50 + self.font_interword_spacing - kerning = -1.25 * self.font_kerning + # If no title text, return empty commands + if not self.title_text: + return [] return [ - f'-font "{self.font_file}"', - f'-kerning {kerning}', - f'-interline-spacing {interline_spacing}', - f'-interword-spacing {interword_spacing}', - f'-pointsize {font_size}', - f'-gravity north', + f'-font "{self.font_file.resolve()}"', + f'-pointsize {self.font_size}', + f'-gravity center', + f'-kerning {self.font_kerning}', + f'-interline-spacing {self.font_interline_spacing}', + f'-interword-spacing {self.font_interword_spacing}', + *self._title_text_stroke_commands(), + f'-fill "{self.font_color}"', + f'label:"{self.title_text}"', + *([f'-composite'] if self.font_stroke_width != 0 else []), ] - @property - def title_text_black_stroke(self) -> ImageMagickCommands: - """ - ImageMagick commands to implement the title text's black stroke. - """ + def _title_text_stroke_commands(self) -> ImageMagickCommands: + """Generate stroke/outline commands for title text.""" - # No stroke, return empty command + # If no stroke, return empty commands if self.font_stroke_width == 0: return [] @@ -496,16 +627,94 @@ def title_text_black_stroke(self) -> ImageMagickCommands: f'-fill "{self.stroke_color}"', f'-stroke "{self.stroke_color}"', f'-strokewidth {stroke_width}', + f'label:"{self.title_text}"', + f'-stroke none', ] @property - def add_symbol_image_commands(self) -> ImageMagickCommands: - """Add the static gradient to this object's source image.""" + def combine_text_commands(self) -> ImageMagickCommands: + """ + Subcommands to combine index and title text layers together. + Combines into one image layer, trims and aligns it using gravity + center. Smush pulled the separator image into whitespace so + had to resort to title_height and geometry for positioning. + """ + + # Auto adjust y offset to try and keep text horizontally + # centered when using separator image, some fonts with uneven + # metrics may be a couple of pixels off + if not (self.hide_season_text or self.hide_episode_text) and not \ + (isinstance(self.separator, str) and len(self.separator) == 1) and \ + self.separator_image_height > self.episode_text_font_size: + img_calc = (self.separator_image_height - self.episode_text_font_size) / 2 + auto_adjust_y = (img_calc - self.separator_image_y_offset) / 2 + else: + auto_adjust_y = 0 + + # Get height of title text + title_text_height_command = [ + f'-font "{self.font_file.resolve()}"', + f'-pointsize {self.font_size}', + f'-interline-spacing {self.font_interline_spacing}', + f'-strokewidth {4.0 * self.font_stroke_width}', + f'label:"{self.title_text}"', + ] + _, title_height = self.image_magick.get_text_label_dimensions( + title_text_height_command + ) - if not self.symbol: + x = -700 if self.h_align == 'left' else (700 if self.h_align == 'right' else 0) + y = 0 + self.font_vertical_shift - auto_adjust_y + index_y = title_height + 42 + self.episode_text_vertical_shift + + # Build text layers to combine based on what text isn't hidden + layers = [] + if self.index_text_commands: + layers += [ + fr'\(', + *self.index_text_commands, + f'-trim', + fr'\)', + f'-gravity south', + f'-geometry +0{index_y:+}', + f'-composite', + ] + + if self.title_text_commands: + layers += [ + fr'\(', + *self.title_text_commands, + f'-trim', + fr'\)', + f'-gravity south', + f'-geometry +0+0', + f'-composite', + ] + + if layers: + return [ + fr'\(', + f'xc:none', + f'-resize {self.WIDTH}x{self.HEIGHT}!', + *layers, + fr'\)', + f'-trim', + f'-gravity center', + f'-geometry {x:+}{y:+}', + f'-composite', + ] + + else: + # Return empty if both text layers are empty + # Should never reach here but just in case return [] + + @property + def add_symbol_image_commands(self) -> ImageMagickCommands: + """Add the symbol image behind the text, if applicable.""" + SYMBOLS = { 'acolyte': self.__SYMBOL_IMAGE_ACOLYTE, 'ahsoka': self.__SYMBOL_IMAGE_AHSOKA, @@ -527,7 +736,7 @@ def add_symbol_image_commands(self) -> ImageMagickCommands: fr'\(', f'"{symbol_image.resolve()}"', f'-resize x850', - fr'-resize 850x850\>', + f'-resize 850x850\>', f'-matte', f'-channel A', f'+level 0,{self.symbol_opacity}%', @@ -540,7 +749,7 @@ def add_symbol_image_commands(self) -> ImageMagickCommands: @property def add_crt_overlay_commands(self) -> ImageMagickCommands: - """Add the static gradient to this object's source image.""" + """Add the CRT TV overlay to this object's source image.""" if self.crt_overlay is None: return [] @@ -571,7 +780,7 @@ def add_crt_overlay_commands(self) -> ImageMagickCommands: @property def gradient_commands(self) -> ImageMagickCommands: """ - Subcommand to overlay the gradient to this image. This rotates + Add the gradient overlay to this object's source image. This rotates and repositions the gradient overlay based on the text position. """ @@ -594,19 +803,6 @@ def gradient_commands(self) -> ImageMagickCommands: ] - @property - def add_alignment_overlay(self) -> ImageMagickCommands: - """Add alignment overlay image.""" - - if not self.alignment_overlay: - return [] - - return [ - f'"{self.__ALIGNMENT_OVERLAY_IMAGE.resolve()}"', - f'-composite', - ] - - @staticmethod def SEASON_TEXT_FORMATTER(episode_info: EpisodeInfo) -> str: """ @@ -617,42 +813,125 @@ def SEASON_TEXT_FORMATTER(episode_info: EpisodeInfo) -> str: determined. Returns: - 'Specials' if the season number is 0; otherwise the cardinal - version of the season number. If that's not possible, then - just 'S{xx}'. + 'SPECIALS' if the season number is 0. + 'SEASON {x}' for the given season number. """ if episode_info.season_number == 0: - return 'Specials' + return 'SPECIALS' - return 'S{season_number:02}' + return f'SEASON {episode_info.season_number}' def create(self) -> None: - """ - Make the necessary ImageMagick and system calls to create this - object's defined title card. - """ + """Create this object's defined Title Card.""" self.image_magick.run([ - f'convert "{self.source_file.resolve()}"', + f'convert', + f'"{self.source_file.resolve()}"', # Resize and optionally blur source image *self.resize_and_style, # Overlay gradient *self.gradient_commands, # Apply symbol image behind text *self.add_symbol_image_commands, - # Add season episode text - *self.index_text_commands, - # Title text - *self.title_text_commands, + # Add combined index and title text + *self.combine_text_commands, # Add CRT TV overlay *self.add_crt_overlay_commands, # Attempt to overlay mask *self.add_overlay_mask(self.source_file), - # Add Alignment overlay - *self.add_alignment_overlay, # Create card *self.resize_output, f'"{self.output_file.resolve()}"', ]) + + +def get_validator_model() -> type[BaseCardModel]: + """Get the Pydantic validator class for this card type.""" + + class CardModel(BaseCardTypeCustomFontAllText): + font_file: FilePath = HorizonTitleCard.CardConfig.font_file + font_color: str = HorizonTitleCard.CardConfig.font_color + font_size: FontSize = 1.0 + stroke_color: str = 'black' + episode_text_vertical_shift: int = 0 + episode_text_font: FilePath = HorizonTitleCard.EPISODE_TEXT_FONT + episode_text_color: str | None = None + episode_text_font_size: FontSize = 1.0 + episode_text_stroke_color: str | None = None + episode_text_kerning: int = 0 + episode_text_interword_spacing: int = 0 + separator: str = '•' + separator_image_padding: int = 0 + separator_image_height: Annotated[int, Field(ge=10)] = 100 + separator_image_y_offset: int = 0 + separator_image_use_stroke: bool = True + h_align: Literal['left', 'center', 'right'] = 'left' + symbol: None | Literal[ + 'acolyte', + 'ashoka', + 'andor', + 'bobafett', + 'mandalorian', + 'obiwan', + 'witcher', + 'logo', + ] = None + symbol_opacity: int = 100 + logo_file: Path | None = None + crt_overlay: Literal['nobezel', 'bezel'] | None = None + crt_state_overlay: bool = False + omit_gradient: bool = True + watched: bool | None = None + + @field_validator('episode_text_font', mode='before') + @classmethod + def validate_episode_text_font(cls, v, info): + if v in ('{title_font}', '{font_file}'): + v = info.data['font_file'] + etf = Path(v) + # Episode text font does not exist, search alongside source image + if not etf.exists(): + source_file = info.data.get('source_file') + if source_file is not None: + candidate = Path(source_file).parent / etf.name + if candidate.exists(): + v = candidate + etf = candidate + # Verify new specified font file does exist + etf = Path(v) + if not etf.exists(): + raise ValueError(f'Specified Episode Text Font "{etf}" does not exist') + return str(etf) + + @field_validator('separator', mode='before') + @classmethod + def validate_separator(cls, v, info): + # Allow a single character as separator + if isinstance(v, str) and len(v) == 1: + return v + # If it's a string that looks like a path, or a Path, try to resolve it + sep_path = Path(v) + if not sep_path.exists(): + source_file = info.data.get('source_file') + if source_file is not None: + candidate = Path(source_file).parent / sep_path.name + if candidate.exists(): + sep_path = candidate + if not sep_path.exists(): + raise ValueError(f'Separator must be a single character or a valid image path "{v}" is invalid') + return str(sep_path) + + @model_validator(mode='after') + def assign_unassigned_colors(self) -> Self: + """Assign any unassigned colors to their fallback values.""" + if self.episode_text_color is None: + self.episode_text_color = self.font_color + if self.episode_text_stroke_color is None: + self.episode_text_stroke_color = self.stroke_color + return self + + return CardModel + +HorizonTitleCard.CardModel = get_validator_model() \ No newline at end of file diff --git a/Supremicus/README.md b/Supremicus/README.md index 1cfd3e8..2d63b72 100644 --- a/Supremicus/README.md +++ b/Supremicus/README.md @@ -9,28 +9,34 @@ Below is a table of all valid series [extras](https://github.com/CollinHeist/Tit | Label | Default Value | Description | | :---: | :---: | :--- | | `stroke_color` | `black` | Stroke color of episode and title text | -| `title_text_horizontal_shift` | `0` | How many pixels to horizontally shift the title text | +| ~~`title_text_horizontal_shift`~~ | ~~`0`~~ | No longer required, title_text is trimmed automatically to align with episode text | | `episode_text_vertical_shift` | `0` | How many pixels to vertically shift the episode text | | `episode_text_font` | `Default` | Font file to use for the episode text | | `episode_text_font_size` | `1.0` | Scalar of the size of the episode text | | `episode_text_color` | `None` | Color to use separately for the episode text | | `episode_text_stroke_color` | `None` | Color to use separately for the episode text stroke | -| `episode_text_kerning` | `18` | Pixel spacing between characters for the episode text | -| `separator` | `•` | Character to separate the season and episode text | +| `episode_text_kerning` | `0` | Pixel spacing between characters for the episode text | +| `episode_text_interword_spacing` | `0` | Spacing between words for the episode text | +| `separator` | `•` | Single character or image to separate the season and episode text.
Example: `image.png` to use the shows source folder or `/path/to/image.png` to use full path to image | +| `separator_image_padding` | `0` | Additional padding to apply to the left and right of the separator image if used | +| `separator_image_height` | `100` | Adjusts the height of the separator image if used keeping aspect ratio | +| `separator_image_y_offset` | `0` | Adjusts the vertical position of the separator image if used | +| `separator_image_use_stroke` | `True` | Automatically apply a stroke to the separator image to match text stroke width of text | | `h_align` | `left` | Horizontal alignment. `left`, `center` or `right` | | `crt_overlay` | `None` | Enable CRT TV Overlay `none`, `nobezel` or `bezel` | | `crt_state_overlay` | `False` | Enable CRT TV Overlay watched/unwatched state `true`/`false` | | `omit_gradient` | `True` | Omit gradient overlay `true`/`false` | ## Example Cards - + ## Features -- Left side, Centered or Right side alignment +- Left, Centered or Right side alignment - Adjustable Episode text vertical offset for custom fonts. -- Adjustable Title text horizontal offset for custom fonts. +- ~~Adjustable Title text horizontal offset for custom fonts.~~ Is now automatically trimmed. - CRT TV overlay with a bezel or no bezel, with optional watched style (off by default) - Optional Gradient +- **NEW**: Separator can now be a single character or an image
@@ -49,28 +55,29 @@ Below is a table of all valid series [extras](https://github.com/CollinHeist/Tit | `episode_text_font_size` | `1.0` | Scalar of the size of the episode text | | `episode_text_color` | `None` | Color to use separately for the episode text | | `episode_text_stroke_color` | `None` | Color to use separately for the episode text stroke | -| `episode_text_kerning` | `18` | Pixel spacing between characters for the episode text | -| `separator` | `•` | Character to separate the season and episode text | +| `episode_text_kerning` | `0` | Pixel spacing between characters for the episode text | +| `episode_text_interword_spacing` | `0` | Spacing between words for the episode text | +| `separator` | `•` | Single character or image to separate the season and episode text.
Example: `image.png` to use the shows source folder or `/path/to/image.png` to use full path to image | +| `separator_image_padding` | `0` | Additional padding to apply to the left and right of the separator image if used | +| `separator_image_height` | `100` | Adjusts the height of the separator image if used keeping aspect ratio | +| `separator_image_y_offset` | `0` | Adjusts the vertical position of the separator image if used | +| `separator_image_use_stroke` | `True` | Automatically apply a stroke to the separator image to match text stroke width of text | | `h_align` | `left` | Horizontal alignment. `left`, `center` or `right` | | `symbol` | `None` | Custom symbol to use
Built-in: `acolyte`, `ahsoka`, `andor`, `bobafett`, `mandalorian`, `obiwan`, `witcher`
Custom: `logo` uses *source/show/logo.png* | | `symbol_opacity` | `100` | Adjust opacity of the symbol. `100`% to `0`% | | `crt_overlay` | `None` | Enable CRT TV Overlay `none`, `nobezel` or `bezel` | | `crt_state_overlay` | `False` | Enable CRT TV Overlay watched/unwatched state `true`/`false` | | `omit_gradient` | `True` | Omit gradient overlay `true`/`false` | -| `alignment_overlay` | `False` | Enable alignment overlay to adjust custom font offsets `true`/`false` | +| ~~`alignment_overlay`~~ | ~~`False`~~ | No longer used. Now horizontally centers automatically | ## Example Cards - + ## Features -- Left side or Right side alignment +- Left, Centered or Right side alignment - Built-in symbols for some shows, with the option to use your own custom symbols (uses logo.png in show source). - Adjustable Episode text vertical offset for custom fonts. -- Alignment overlay to adjust custom fonts if not using the default font. +- ~~Alignment overlay to adjust custom fonts if not using the default font.~~ Now horizontally centers automatically - CRT TV overlay with a bezel or no bezel, with optional watched style (off by default) - Optional Radial Gradient - -## Font Alignment & Custom Symbol - - -[12 Monkeys (2015) Example Logo](https://raw.githubusercontent.com/Supremicus/tcm-images/main/source/12%20Monkeys%20(2015)/logo.png) \ No newline at end of file +- **NEW**: Separator can now be a single character or an image \ No newline at end of file diff --git a/Supremicus/ref/overlays/overlay_alignment.png b/Supremicus/ref/overlays/overlay_alignment.png deleted file mode 100644 index 105f257..0000000 Binary files a/Supremicus/ref/overlays/overlay_alignment.png and /dev/null differ