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