Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
381 changes: 381 additions & 0 deletions polklabs/ColoredBannerTitleCard.py
Original file line number Diff line number Diff line change
@@ -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 "<c>#A50000</c>;<c>#C25301</c>;<c>#2E61A3</c>;<c>#C39400</c>"',
default='#A50000;#C25301;#2E61A3;#C39400'
),
Extra(
name='Color Order',
identifier='color_order',
description='Determines how colors appear per episode.',
tooltip='Default is <v>sequence</v> which is based on number in episode text and number of box colors. Options: <v>random</v>, <v>sequence</v>',
default='sequence'
),
Extra(
name='Background Opacity',
identifier='darken_opacity',
description='Opacity of the background',
tooltip='Default is <v>0.5</v>.',
default=0.5,
),
Extra(
name='Text Spacing',
identifier='text_spacing',
description='Additional spacing between box and title text',
tooltip='Default is <v>1.5</v>.',
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
Loading