diff --git a/polklabs/ColoredBannerTitleCard.py b/polklabs/ColoredBannerTitleCard.py new file mode 100644 index 0000000..3c66fbc --- /dev/null +++ b/polklabs/ColoredBannerTitleCard.py @@ -0,0 +1,381 @@ +from pathlib import Path +import random +import math +import re +from collections import namedtuple +from typing import ClassVar + +from app.cards.loader import RemoteDirectory +from app.cards.base import ( + BaseCardType, + CardTypeDescription, + DefaultCardConfig, + Extra, + ImageMagickCommands, +) +from app.schemas.base import BaseCardTypeCustomFontAllText + +BoxCoordinates = namedtuple('BoxCoordinates', ('corners',)) + +class ColoredBannerTitleCard(BaseCardType): + """ + This class defines a type of CardType that produces title-centric + cards that do not feature any index text (i.e. season or episode + text). The title is prominently featured in the lower half of the image. And each episode features a different color. The image itself is tinted to match the banner color. And the banner is placed slightly askew. + """ + + """API Parameters""" + API_DETAILS = CardTypeDescription( + name='ColoredBanner', + identifier='ColoredBanner', + example="https://github.com/user-attachments/assets/915192f0-ca0f-480c-a6e0-fbea82492d28", + creators=['polklabs'], + source='remote', + supports_custom_fonts=True, + supports_custom_seasons=False, + supported_extras=[ + Extra( + name='Box Colors', + identifier='box_colors', + description='Colors of the box around the title text.', + tooltip='Default is "#A50000;#C25301;#2E61A3;#C39400"', + default='#A50000;#C25301;#2E61A3;#C39400' + ), + Extra( + name='Color Order', + identifier='color_order', + description='Determines how colors appear per episode.', + tooltip='Default is sequence which is based on number in episode text and number of box colors. Options: random, sequence', + default='sequence' + ), + Extra( + name='Background Opacity', + identifier='darken_opacity', + description='Opacity of the background', + tooltip='Default is 0.5.', + default=0.5, + ), + Extra( + name='Text Spacing', + identifier='text_spacing', + description='Additional spacing between box and title text', + tooltip='Default is 1.5.', + default=1.5, + ), + ], + description=[ + 'Produce TitleCards with askew colored banner box ' + 'and a tinted background color ' + ] + ) + + """Directory where all reference files used by this card are stored""" + REF_DIRECTORY = BaseCardType.BASE_REF_DIRECTORY + FONT_DIRECTORY: ClassVar[RemoteDirectory] = RemoteDirectory('polklabs', 'ref/fonts') + + """Default configuration for this card type""" + CardConfig = DefaultCardConfig( + font_file=(FONT_DIRECTORY / 'impact.ttf').resolve(), + font_color='#EBEBEB', + 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}', + ) + + class CardModel(BaseCardTypeCustomFontAllText): + title_text: str + episode_text: str + font_color: str + font_file: str | Path + font_interline_spacing: int + font_interword_spacing: int + font_kerning: float + font_size: float + font_vertical_shift: int + + box_colors: str = '#A50000;#C25301;#2E61A3;#C39400' + box_color: str = '' + color_order: str = 'sequence' + darken_opacity: float = 0.5 + text_spacing: float = 1.5 + angle: float = 6 + shift: int = 0 + + __slots__ = ( + 'box_colors', + 'box_color', + 'color_order', + 'darken_opacity', + 'text_spacing', + 'angle', + 'shift', + 'font_color', + 'font_file', + 'font_interline_spacing', + 'font_interword_spacing', + 'font_kerning', + 'font_size', + 'font_vertical_shift', + 'output_file', + 'source_file', + 'title_text', + 'episode_text', + ) + + def __init__(self, *, + source_file: Path, + card_file: Path, + title_text: str, + episode_text: str, + font_file: str = str(CardConfig.font_file), + font_color: str = CardConfig.font_color, + font_interline_spacing: int = 0, + font_interword_spacing: int = 0, + font_size: float = 1.0, + font_kerning: float = 1.0, + font_vertical_shift: float = 0, + blur: bool = False, + grayscale: bool = False, + box_colors: str = '#A50000;#C25301;#2E61A3;#C39400', + color_order: str = 'sequence', + darken_opacity: float = 0.5, + text_spacing: float = 1.5, + angle: float = 6, + shift: int = 0, + **unused + ) -> None: + """Construct a new instance of this Card.""" + + # Initialize the parent class - this sets up an ImageMagickInterface + super().__init__(blur, grayscale) + + # Store object attributes + self.source_file = source_file + self.output_file = card_file + + self.title_text = self.image_magick.escape_chars(title_text) + self.episode_text = self.image_magick.escape_chars(episode_text) + + # Font customizations + self.font_color = font_color + self.font_file = font_file + self.font_interline_spacing = font_interline_spacing + self.font_interword_spacing = font_interword_spacing + self.font_size = font_size + self.font_kerning = 1.0 * font_kerning + self.font_vertical_shift = font_vertical_shift + + # Store extras + self.darken_opacity = darken_opacity + self.color_order = color_order + self.text_spacing = text_spacing + self.shift = shift + self.angle = angle + self.box_colors = box_colors + colors = self.box_colors.split(';') + + if self.color_order == 'random': + self.box_color = random.choice(colors) + else: + # Use regex to get number from string self.episode_text + episode_number = re.search(r'\d+', self.episode_text) + if episode_number: + episode_number = int(episode_number.group()) + self.box_color = colors[episode_number % len(colors)] + else: + self.box_color = random.choice(colors) + + self.angle = random.choice([-1, 1]) * random.uniform(4, 9) + + @property + def darken_commands(self) -> ImageMagickCommands: + """ + Subcommand to darken the image if indicated. + + Args: + coordinates: Tuple of coordinates to that indicate where to + darken. + + Returns: + List of ImageMagick commands. + """ + + # Don't darken if blurring or not enabled + if self.darken_opacity <= 0: + return [] + + return [ + '(', + f'-size "{ColoredBannerTitleCard.TITLE_CARD_SIZE}"', + f'xc:"{self.box_color}"', + '-alpha set', + '-channel A', + f'-evaluate set {self.darken_opacity * 100}%', + ')', + '-gravity center', + '-composite', + ] + + def get_font_commands(self) -> ImageMagickCommands: + font_size = 150 * self.font_size + interline_spacing = -40 + self.font_interline_spacing + interword_spacing = 40 + self.font_interword_spacing + kerning = 40 * self.font_kerning + + return [ + f'-font "{self.font_file}"', + '-gravity center', + f'-pointsize {font_size:.1f}', + f'-interline-spacing {interline_spacing:.1f}', + f'-interword-spacing {interword_spacing:.1f}', + f'-kerning {kerning:.2f}', + f'-fill "{self.font_color}"' + ] + + + @property + def bounding_box_coordinates(self) -> BoxCoordinates: + """The coordinates of the bounding box around the title that stretches the full width of the image.""" + + # Text-relevant commands + text_commands = self.get_font_commands() + ['-annotate +0+0 "Test"'] + + # Get dimensions of text - since text is stacked, do max/sum operations + _, height = self.image_magick.get_text_dimensions( + ['-background none'] + text_commands + ) + height += 20 # Add 20px margin + + height *= self.text_spacing + + # Get start coordinates of the bounding box + x_start, x_end = 0, self.WIDTH + y_start, y_end = (self.HEIGHT - height) / 2, (self.HEIGHT + height) / 2 + + # Adjust coordinates by spacing and manual adjustments + x_start -= 200 + x_end += 200 + + # Rotate box by self.angle degrees + center_x = (x_start + x_end) / 2 + center_y = (y_start + y_end) / 2 + corners = [ + (x_start, y_start), + (x_end, y_start), + (x_end, y_end), + (x_start, y_end) + ] + def rotate_point(px, py, cx, cy, angle): + angle_rad = math.radians(angle) + cos_a = math.cos(angle_rad) + sin_a = math.sin(angle_rad) + x_new = cx + (px - cx) * cos_a - (py - cy) * sin_a + y_new = cy + (px - cx) * sin_a + (py - cy) * cos_a + return x_new, y_new + + rotated_corners = [rotate_point(x, y, center_x, center_y, self.angle) for x, y in corners] + + # Get max Y shift allowed before banner would go off the card + amount = (self.HEIGHT/2) - (max(y for _, y in rotated_corners) - min(y for _, y in rotated_corners))/2 + + # Get a random value for Y shift between center and bottom of card, will be used later when applying text + self.shift = random.uniform(0, amount) + + rotated_corners = [(x, y + self.shift) for x, y in rotated_corners] + + return BoxCoordinates(corners=rotated_corners) + + + def add_bounding_box_commands(self, + coordinates: BoxCoordinates, + ) -> ImageMagickCommands: + """ + Subcommand to add the bounding box around the title text. + + Args: + coordinates: Tuple of coordinates to that indicate where to + darken. + + Returns: + List of ImageMagick commands. + """ + + corners = coordinates.corners + rect = 'polygon ' + ' '.join(f'{x},{y}' for x, y in corners) + + return [ + f'-fill "{self.box_color}"', + f'-draw "{rect}"', + ] + + # Text is shifted up when text is multiple lines, so we need a way to check for that. + def is_text_multiple_lines(self) -> bool: + text_commands = self.get_font_commands() + + _, title_height = self.image_magick.get_text_dimensions( + ['-background none'] + text_commands + [f'-annotate +0+0 "{self.title_text}"'] + ) + + _, one_line_height = self.image_magick.get_text_dimensions( + ['-background none'] + text_commands + ['-annotate +0+0 "A"'] + ) + + return title_height <= one_line_height * 1.2 + + + @property + def title_text_commands(self) -> ImageMagickCommands: + """Subcommands to add the title text to the image.""" + + text_commands = self.get_font_commands() + + if self.is_text_multiple_lines(): + vertical_shift = self.font_vertical_shift + else: + vertical_shift = self.font_vertical_shift - (self.font_size * 100) + 30 + + vertical_shift += self.shift + + return text_commands + [ + '-gravity center', + f'-annotate {self.angle}x{self.angle}+0+{vertical_shift} "{self.title_text}"', + ] + + + def create(self) -> None: + """Create this object's defined Title Card.""" + + # If title is blank, just stylize + if not self.title_text: + self.image_magick.run([ + fr'convert "{self.source_file.resolve()}"', + *self.resize_and_style, + *self.darken_commands, + fr'"{self.output_file.resolve()}"', + ]) + return None + + # Get coordinates for bounding box + bounding_box = self.bounding_box_coordinates + + self.image_magick.run([ + 'convert', + f'"{self.source_file.resolve()}"', + # Resize and apply any style modifiers + *self.resize_and_style, + # Add image darkening + *self.darken_commands, + # # Add bounding box + *self.add_bounding_box_commands(bounding_box), + # Add title text + *self.title_text_commands, + # Attempt to overlay mask + *self.add_overlay_mask(self.source_file), + # Create card + *self.resize_output, + fr'"{self.output_file.resolve()}"', + ]) + return None \ No newline at end of file diff --git a/polklabs/README.md b/polklabs/README.md new file mode 100644 index 0000000..9a14563 --- /dev/null +++ b/polklabs/README.md @@ -0,0 +1,27 @@ +# `polklabs/ColoredBanner` +## Description +This is a title card, loosely based on Landscape card type. It is built to mimic the Brooklyn Nine-Nine intro sequence. + +The card places a the title text in a box that spans the full width of the card. The box and title is rotated a random amount between `+- 4 to 6 degrees`. It is then shifted down a random amount to place it somewhere in the lower half of the card (as to be less likely to overlap with faces in the card). The background is tinted the same color as the box. + +The color is tied to the modulo of the episode number, this is to prevent groupings of the same color but it can be customized. + +## Example + +![preview](.\preview.jpg) +![preview](.\previews.png) + +## Customization +The color sequence can be modified by specifying the `box_colors` extra and `color_order`, like so: +```yaml +extras: + box_colors: "#A50000;#C25301;#2E61A3;#C39400" + color_order: sequence +``` + +You can also specify the background tint amount between 0 and 1. And specify the banner height compared to the text height, 1=Same Height: +```yaml +extras: + darken_opacity: 0.5 + text_spacing: 1.5 +``` diff --git a/polklabs/preview.jpg b/polklabs/preview.jpg new file mode 100644 index 0000000..2de8c07 Binary files /dev/null and b/polklabs/preview.jpg differ diff --git a/polklabs/previews.png b/polklabs/previews.png new file mode 100644 index 0000000..38ab91a Binary files /dev/null and b/polklabs/previews.png differ diff --git a/polklabs/ref/fonts/impact.ttf b/polklabs/ref/fonts/impact.ttf new file mode 100644 index 0000000..2675688 Binary files /dev/null and b/polklabs/ref/fonts/impact.ttf differ